Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
34fe014
Allow company agents to contact each other in phase one
Y1fe1Zh0u Jun 17, 2026
e3585d5
Define the roster migration before implementation
Y1fe1Zh0u Jun 23, 2026
7828ec8
Split agent access from roster visibility
Y1fe1Zh0u Jun 23, 2026
7d5b2ea
Add roster query tool for visible members
Y1fe1Zh0u Jun 23, 2026
35370f9
Send A2A messages by target agent ID
Y1fe1Zh0u Jun 23, 2026
67a846d
Remove A2A colleague lists from agent prompts
Y1fe1Zh0u Jun 23, 2026
0ed0fa0
Define phase two human roster messaging plan
Y1fe1Zh0u Jun 23, 2026
460cc64
Split phase two plan from phase three archive
Y1fe1Zh0u Jun 23, 2026
c0f00fa
Break phase two human messaging into reviewable steps
Y1fe1Zh0u Jun 23, 2026
d535a02
Document unified channel dispatch for human messaging
Y1fe1Zh0u Jun 23, 2026
44ceaf7
Add exact human lookup to roster queries
Y1fe1Zh0u Jun 23, 2026
700a791
Resolve human roster targets before sending
Y1fe1Zh0u Jun 23, 2026
98458b4
Route human message sends through roster targets
Y1fe1Zh0u Jun 23, 2026
878f54f
Guide human messaging through roster IDs
Y1fe1Zh0u Jun 23, 2026
d18b8a8
Clarify the phase-two prompt cleanup boundary
Y1fe1Zh0u Jun 23, 2026
9103076
Guide seeded human tools through roster IDs
Y1fe1Zh0u Jun 23, 2026
051630c
Keep phase-two touched files lint-clean
Y1fe1Zh0u Jun 23, 2026
c81ac29
Hide legacy human send entries from models
Y1fe1Zh0u Jun 23, 2026
315a368
Remove legacy human send cues from prompts
Y1fe1Zh0u Jun 24, 2026
213f1e8
Make the agent directory show the real contact roster
Y1fe1Zh0u Jun 29, 2026
ad9a6c2
Align the roster phase archive with the directory UI rollout
Y1fe1Zh0u Jun 29, 2026
ef496c1
Force remembered contact calls back through roster IDs
Y1fe1Zh0u Jun 29, 2026
117fa01
Polish roster permissions and directory cleanup
Y1fe1Zh0u Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 55 additions & 13 deletions backend/app/api/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.database import get_db, async_session
from app.core.permissions import evaluate_agent_relationship_status, evaluate_human_relationship_status
from app.core.permissions import (
evaluate_agent_relationship_status,
evaluate_human_relationship_status,
can_auto_contact_company_agent,
)
from app.models.agent import Agent
from app.models.gateway_message import GatewayMessage
from app.models.user import User
Expand Down Expand Up @@ -174,9 +178,11 @@ async def poll_messages(
.where(AgentAgentRelationship.agent_id == agent.id)
.options(selectinload(AgentAgentRelationship.target_agent))
)
related_agent_ids = set()
for r in a_result.scalars().all():
status_info = await evaluate_agent_relationship_status(db, r)
if r.target_agent and status_info["access_status"] == "active":
related_agent_ids.add(r.target_agent.id)
rel_items.append(GatewayRelationshipItem(
name=r.target_agent.name,
type="agent",
Expand All @@ -185,6 +191,28 @@ async def poll_messages(
channels=["agent"],
))

c_result = await db.execute(
select(Agent)
.where(
Agent.tenant_id == agent.tenant_id,
Agent.id != agent.id,
Agent.access_mode == "company",
Agent.status.in_(["running", "idle"]),
)
.order_by(Agent.name.asc(), Agent.created_at.asc())
)
for candidate in c_result.scalars().all():
if candidate.id in related_agent_ids:
continue
if can_auto_contact_company_agent(agent, candidate):
rel_items.append(GatewayRelationshipItem(
name=candidate.name,
type="agent",
role="company",
description=candidate.role_description or None,
channels=["agent"],
))

await db.commit()
return GatewayPollResponse(messages=out, relationships=rel_items)

Expand Down Expand Up @@ -488,26 +516,40 @@ async def send_message(
content = body.content.strip()
channel_hint = (body.channel or "").strip().lower()

# 1. Try to find target as another Agent, limited to active relationships.
# 1. Try to find target as another Agent.
from app.models.org import AgentAgentRelationship
from sqlalchemy.orm import selectinload

target_agent = None
if not channel_hint or channel_hint == "agent":
company_result = await db.execute(
select(Agent).where(
Agent.name == target_name,
Agent.tenant_id == agent.tenant_id,
Agent.id != agent.id,
Agent.access_mode == "company",
)
)
company_candidate = company_result.scalars().first()
if company_candidate and can_auto_contact_company_agent(agent, company_candidate):
target_agent = company_candidate

rel_result = await db.execute(
select(AgentAgentRelationship)
.where(AgentAgentRelationship.agent_id == agent.id)
.options(selectinload(AgentAgentRelationship.target_agent))
)
target_agent = None
for rel in rel_result.scalars().all():
candidate = rel.target_agent
if not candidate:
continue
status_info = await evaluate_agent_relationship_status(db, rel)
if status_info["access_status"] != "active":
continue
if candidate.name.lower() == target_name.lower() or target_name.lower() in candidate.name.lower():
target_agent = candidate
break
if not target_agent:
for rel in rel_result.scalars().all():
candidate = rel.target_agent
if not candidate:
continue
status_info = await evaluate_agent_relationship_status(db, rel)
if status_info["access_status"] != "active":
continue
if candidate.name.lower() == target_name.lower() or target_name.lower() in candidate.name.lower():
target_agent = candidate
break

logger.info(f"[Gateway] send_message: target='{target_name}', found_agent={target_agent.name if target_agent else None}, agent_type={getattr(target_agent, 'agent_type', None) if target_agent else None}, channel_hint='{channel_hint}'")

Expand Down
20 changes: 20 additions & 0 deletions backend/app/core/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,23 @@ def is_agent_expired(agent: Agent) -> bool:
if expires_at and datetime.now(timezone.utc) > expires_at:
return True
return False


def can_auto_contact_company_agent(source_agent: Agent, target_agent: Agent) -> bool:
"""Return whether source can contact target via the phase-1 company-agent rule."""
if not source_agent or not target_agent:
return False
if getattr(source_agent, "id", None) == getattr(target_agent, "id", None):
return False
source_tenant_id = getattr(source_agent, "tenant_id", None)
target_tenant_id = getattr(target_agent, "tenant_id", None)
if not source_tenant_id or source_tenant_id != target_tenant_id:
return False
if getattr(target_agent, "access_mode", None) != "company":
return False
Comment on lines +449 to +450

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require the source agent to be company-scoped

When the source agent is private or custom but shares a tenant with a company agent, this helper still returns true because it only checks the target's access_mode. All new call sites use this result to add company agents to the source's relationship context/poll response and to bypass AgentAgentRelationship enforcement, so private/custom agents can auto-discover and message company agents without an explicit relationship even though the phase-one rule is described as company-agent auto-contact.

Useful? React with 👍 / 👎.

target_status = getattr(target_agent, "status", None)
if target_status and target_status not in ("running", "idle"):
return False
if is_agent_expired(target_agent):
return False
return True
39 changes: 36 additions & 3 deletions backend/app/services/agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,13 @@ async def _load_skills_index(agent_id: uuid.UUID) -> str:
async def _load_relationships_from_db(db, agent_id: uuid.UUID) -> str:
"""Query relationships directly from the database and format as a markdown list."""
from app.models.org import AgentRelationship, AgentAgentRelationship, OrgMember
from app.models.agent import Agent
from app.models.identity import IdentityProvider
from app.core.permissions import evaluate_human_relationship_status, evaluate_agent_relationship_status
from app.core.permissions import (
evaluate_human_relationship_status,
evaluate_agent_relationship_status,
can_auto_contact_company_agent,
)
from sqlalchemy.orm import selectinload
from sqlalchemy import select

Expand All @@ -169,6 +174,10 @@ async def _load_relationships_from_db(db, agent_id: uuid.UUID) -> str:
"other": "其他",
}

source_agent = (
await db.execute(select(Agent).where(Agent.id == agent_id))
).scalar_one_or_none()

# Load human relationships
h_result = await db.execute(
select(
Expand Down Expand Up @@ -200,12 +209,33 @@ def _display_provider_name(pn, pt):
.options(selectinload(AgentAgentRelationship.target_agent))
)
agent_rels = []
related_agent_ids = set()
for rel in a_result.scalars().all():
status_info = await evaluate_agent_relationship_status(db, rel)
if status_info["access_status"] == "active":
agent_rels.append(rel)
if getattr(rel, "target_agent_id", None):
related_agent_ids.add(rel.target_agent_id)

company_agents = []
if source_agent and getattr(source_agent, "tenant_id", None):
c_result = await db.execute(
select(Agent)
.where(
Agent.tenant_id == source_agent.tenant_id,
Agent.id != agent_id,
Agent.access_mode == "company",
Agent.status.in_(["running", "idle"]),
)
.order_by(Agent.name.asc(), Agent.created_at.asc())
)
for candidate in c_result.scalars().all():
if getattr(candidate, "id", None) in related_agent_ids:
continue
if can_auto_contact_company_agent(source_agent, candidate):
company_agents.append(candidate)

if not human_rows and not agent_rels:
if not human_rows and not agent_rels and not company_agents:
return ""

lines = []
Expand All @@ -225,7 +255,7 @@ def _display_provider_name(pn, pt):
lines.append("")

# Agent relationships
if agent_rels:
if agent_rels or company_agents:
lines.append("## 🤖 数字员工同事\n")
for r in agent_rels:
a = r.target_agent
Expand All @@ -236,6 +266,9 @@ def _display_provider_name(pn, pt):
if r.description:
lines.append(f"- {r.description}")
lines.append("")
for a in company_agents:
lines.append(f"### {a.name} — {a.role_description or '数字员工'}")
lines.append("")

return "\n".join(lines).strip()

Expand Down
32 changes: 18 additions & 14 deletions backend/app/services/agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from app.models.chat_session import ChatSession
from app.models.channel_config import ChannelConfig
from app.models.user import User as UserModel
from app.core.permissions import can_auto_contact_company_agent
from app.services.auth_registry import auth_provider_registry
from app.services.channel_session import find_or_create_channel_session
from app.services.channel_user_service import get_platform_user_by_org_member
Expand Down Expand Up @@ -7104,10 +7105,12 @@ async def _build_a2a_context(

# Find target agent by name — exact match first, then fuzzy
target = None
exact_match = False
exact_result = await db.execute(
select(AgentModel).where(AgentModel.name == agent_name, *base_filter)
)
target = exact_result.scalars().first()
exact_match = target is not None
if not target:
safe_name = agent_name.replace("%", "").replace("_", r"\_")
fuzzy_result = await db.execute(
Expand All @@ -7129,20 +7132,21 @@ async def _build_a2a_context(
if target.is_expired or (target.expires_at and datetime.now(timezone.utc) >= target.expires_at):
return f"⚠️ {target.name} is currently unavailable — their service period has ended. Please contact the platform administrator."

# Enforce relationship
rel_check = await db.execute(
select(AgentAgentRelationship).where(
AgentAgentRelationship.agent_id == from_agent_id,
AgentAgentRelationship.target_agent_id == target.id,
).limit(1)
)
rel = rel_check.scalar_one_or_none()
if not rel:
return f"❌ You do not have a relationship with {target.name}. Only agents in your relationship list can be contacted. Ask your administrator to add a relationship if needed."
if hasattr(rel, "agent_id"):
status_info = await evaluate_agent_relationship_status(db, rel)
if status_info["access_status"] != "active":
return f"❌ Relationship to {target.name} is not active ({status_info['access_status_reason'] or 'restricted'}). Ask a manager of both agents to review Relationships."
# Enforce relationship unless phase-1 company-agent auto-contact applies.
if not (exact_match and can_auto_contact_company_agent(source_agent, target)):
rel_check = await db.execute(
select(AgentAgentRelationship).where(
AgentAgentRelationship.agent_id == from_agent_id,
AgentAgentRelationship.target_agent_id == target.id,
).limit(1)
)
rel = rel_check.scalar_one_or_none()
if not rel:
return f"❌ You do not have a relationship with {target.name}. Only agents in your relationship list can be contacted. Ask your administrator to add a relationship if needed."
if hasattr(rel, "agent_id"):
status_info = await evaluate_agent_relationship_status(db, rel)
if status_info["access_status"] != "active":
return f"❌ Relationship to {target.name} is not active ({status_info['access_status_reason'] or 'restricted'}). Ask a manager of both agents to review Relationships."

src_part_r = await db.execute(select(Participant).where(Participant.type == "agent", Participant.ref_id == from_agent_id))
src_participant = src_part_r.scalar_one_or_none()
Expand Down
Loading