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."""