From 34fe01411ea67f76df72de7a3bb21cbb98722cbc Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Wed, 17 Jun 2026 23:23:44 +0800 Subject: [PATCH] Allow company agents to contact each other in phase one Phase one keeps the existing name-based A2A surface and only extends authorization/discovery for same-tenant company agents. The rule is centralized in permissions, then reused by prompt context, tool calls, and the OpenClaw gateway path so the first authorization pass exposes the same callable set that send-message accepts. Constraint: Phase one must be minimal and keep send_message_to_agent(agent_name=...) without target-id migration Rejected: Add directory tables or cache materialization | out of scope for phase one Rejected: Frontend communication-directory UI | user deferred UI work for now Confidence: high Scope-risk: moderate Directive: Do not broaden auto-contact beyond same-tenant access_mode=company agents without revisiting authorization semantics Tested: backend/.venv/bin/pytest backend/tests/test_a2a_msg_type.py backend/tests/test_agent_visibility.py Not-tested: Full browser UI flow because this commit intentionally has no frontend changes --- backend/app/api/gateway.py | 68 +++++++++--- backend/app/core/permissions.py | 20 ++++ backend/app/services/agent_context.py | 39 ++++++- backend/app/services/agent_tools.py | 32 +++--- backend/tests/test_a2a_msg_type.py | 150 +++++++++++++++++++++++++- 5 files changed, 278 insertions(+), 31 deletions(-) diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index 501d72fcb..d4ba15dea 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -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 @@ -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", @@ -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) @@ -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}'") diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index df2bc4dbc..a26645444 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -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 + 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 diff --git a/backend/app/services/agent_context.py b/backend/app/services/agent_context.py index f9d78d8ee..5ecb522a9 100644 --- a/backend/app/services/agent_context.py +++ b/backend/app/services/agent_context.py @@ -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 @@ -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( @@ -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 = [] @@ -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 @@ -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() diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index e05b5cd9e..7c67825fe 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -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 @@ -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( @@ -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() diff --git a/backend/tests/test_a2a_msg_type.py b/backend/tests/test_a2a_msg_type.py index 6cefcb4d6..3a7b12df4 100644 --- a/backend/tests/test_a2a_msg_type.py +++ b/backend/tests/test_a2a_msg_type.py @@ -67,13 +67,21 @@ async def flush(self): def _make_agent( - agent_id=None, name="TestAgent", tenant_id=None, agent_type="native", expired=False, primary_model_id=None + agent_id=None, + name="TestAgent", + tenant_id=None, + agent_type="native", + expired=False, + primary_model_id=None, + access_mode=None, + status="running", ): agent = MagicMock() agent.id = agent_id or uuid.uuid4() agent.name = name agent.tenant_id = tenant_id or uuid.uuid4() agent.agent_type = agent_type + agent.status = status agent.is_expired = expired agent.expires_at = None agent.creator_id = uuid.uuid4() @@ -81,6 +89,8 @@ def _make_agent( agent.fallback_model_id = None agent.role_description = "" agent.max_tool_rounds = 50 + if access_mode is not None: + agent.access_mode = access_mode return agent @@ -388,6 +398,144 @@ async def test_missing_agent_name_returns_error(): assert "❌" in result +def test_company_auto_contact_helper_rejects_non_company_boundaries(): + """Phase-1 company auto-contact only applies inside tenant, not self, and not expired.""" + from app.core.permissions import can_auto_contact_company_agent + + tenant_id = uuid.uuid4() + source = _make_agent(tenant_id=tenant_id, access_mode="company") + company_target = _make_agent(tenant_id=tenant_id, access_mode="company") + custom_target = _make_agent(tenant_id=tenant_id, access_mode="custom") + foreign_target = _make_agent(tenant_id=uuid.uuid4(), access_mode="company") + expired_target = _make_agent(tenant_id=tenant_id, access_mode="company", expired=True) + stopped_target = _make_agent(tenant_id=tenant_id, access_mode="company", status="stopped") + + assert can_auto_contact_company_agent(source, company_target) is True + assert can_auto_contact_company_agent(source, source) is False + assert can_auto_contact_company_agent(source, custom_target) is False + assert can_auto_contact_company_agent(source, foreign_target) is False + assert can_auto_contact_company_agent(source, expired_target) is False + assert can_auto_contact_company_agent(source, stopped_target) is False + + +@pytest.mark.asyncio +async def test_relationship_prompt_includes_company_agent_without_relationship(): + """Company agents should be visible in prompt relationships without explicit A2A rows.""" + from app.services.agent_context import _load_relationships_from_db + + tenant_id = uuid.uuid4() + source_agent = _make_agent(tenant_id=tenant_id, name="Alice", access_mode="company") + company_agent = _make_agent(tenant_id=tenant_id, name="Bob", access_mode="company") + company_agent.role_description = "Backend helper" + + db = RecordingDB( + responses=[ + DummyResult(scalar_value=source_agent), + DummyResult(values=[]), + DummyResult(scalars_list=[]), + DummyResult(scalars_list=[company_agent]), + ] + ) + + relationships = await _load_relationships_from_db(db, source_agent.id) + + assert "ζ•°ε­—ε‘˜ε·₯εŒδΊ‹" in relationships + assert "Bob" in relationships + assert "Backend helper" in relationships + + +@pytest.mark.asyncio +async def test_company_agent_without_relationship_can_notify(): + """Company agents can be contacted without AgentAgentRelationship in phase 1.""" + from app.services.agent_tools import _send_message_to_agent + + tenant_id = uuid.uuid4() + from_agent_id = uuid.uuid4() + target_id = uuid.uuid4() + session_id = uuid.uuid4() + src_participant = _make_participant(ref_id=from_agent_id) + tgt_participant = _make_participant(ref_id=target_id) + source_agent = _make_agent(from_agent_id, name="Alice", tenant_id=tenant_id) + target_agent = _make_agent(target_id, name="Bob", tenant_id=tenant_id, access_mode="company") + + session = MagicMock() + session.id = session_id + session.last_message_at = None + + db = RecordingDB( + responses=[ + DummyResult(scalar_value=source_agent), + DummyResult(scalars_list=[target_agent]), + DummyResult(scalar_value=src_participant), + DummyResult(scalar_value=tgt_participant), + DummyResult(scalar_value=session), + DummyResult(scalar_value=_make_tenant()), + ] + ) + + with ( + patch("app.services.agent_tools.async_session") as mock_session_ctx, + patch("app.services.agent_tools._wake_agent_async", new_callable=AsyncMock) as mock_wake, + ): + mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=db) + mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False) + + result = await _send_message_to_agent( + from_agent_id, + { + "agent_name": "Bob", + "message": "Hello", + "msg_type": "notify", + }, + ) + + assert "Notification sent to Bob" in result + mock_wake.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_gateway_company_agent_without_relationship_queues_message(): + """Gateway send-message can target company agents without explicit A2A rows.""" + from app.api.gateway import send_message + from app.schemas.schemas import GatewaySendMessageRequest + + tenant_id = uuid.uuid4() + source_id = uuid.uuid4() + target_id = uuid.uuid4() + source_agent = _make_agent(source_id, name="Alice", tenant_id=tenant_id, agent_type="openclaw") + target_agent = _make_agent( + target_id, + name="Bob", + tenant_id=tenant_id, + agent_type="openclaw", + access_mode="company", + ) + db = RecordingDB( + responses=[ + DummyResult(scalars_list=[target_agent]), + DummyResult(scalars_list=[]), + ] + ) + + with patch("app.api.gateway._get_agent_by_key", new_callable=AsyncMock, return_value=source_agent): + result = await send_message( + GatewaySendMessageRequest(target="Bob", content="Hello"), + x_api_key="test-key", + db=db, + ) + + assert result["status"] == "accepted" + assert result["target"] == "Bob" + assert result["type"] == "openclaw_agent" + assert db.committed is True + assert len(db.added) == 1 + queued = db.added[0] + assert queued.agent_id == target_id + assert queued.sender_agent_id == source_id + assert queued.content == "Hello" + assert queued.status == "pending" + + @pytest.mark.asyncio async def test_no_relationship_returns_error(): """No relationship between agents should return an error."""