Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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":

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Require company scope on the source agent

When the source agent is private or custom, this helper still returns True as long as the target is a same-tenant company agent, because it never checks source_agent.access_mode. The helper is used by the prompt/gateway/tool auto-contact paths, so those restricted agents can auto-discover or message company agents without an AgentAgentRelationship, bypassing the relationship-only model for non-company agents. Add a source-side company-mode check before allowing the phase-1 auto-contact path.

Useful? React with 👍 / 👎.

return False
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