feat(a2a): CEO can DM the Auditor and PR reviewers (#623)

* feat(a2a): CEO can DM the Auditor and PR reviewers

A mid-flight PR reviewer or Auditor that's stuck was unreachable — the CEO
had no way to DM them. Both roles now carry dm/read_a2a, so the CEO can open
a 1:1 and they can reply in-thread through the existing CEO-reply path.

Scoped deliberately: the Auditor stays a silent observer to its peers — it
gains no peer-initiation surface (can_a2a_direct routes it through
_check_auditor_a2a, which refuses every initiation target; it can only reply
inside a CEO-opened DM). PR reviewers keep their owning-PM scope. Intake and
Secretary stay excluded — they have their own dedicated chat pages.

NO_COMMS_ROLES drops to {prompter, secretary}; the panel's EXCLUDE_NON_DM_ROLES
matches. KB/docs updated so the 'auditor/pr_reviewer have no dm' claim isn't
left stale.

* test(a2a): smoke guard checks _NO_COMMS_ROLES, not a hardcoded 'auditor'

The dm() runtime guard no longer names the auditor (it now carries dm to
reply to the CEO); it refuses the canonical _NO_COMMS_ROLES set. Assert on
that set so the smoke test tracks the guard, not a stale role name.

* chore(foundation): regenerate verb tables for auditor/pr_reviewer dm+read_a2a

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-21 05:54:29 +02:00
committed by GitHub
co-authored by Renn F
parent 775872cac0
commit 73c05cfa5e
24 changed files with 295 additions and 146 deletions
+58 -9
View File
@@ -2497,16 +2497,30 @@ async def test_agent_reply_to_ceo_creates_no_wake(a2a_setup: dict) -> None:
async def test_ceo_dm_to_non_a2a_role_denied_at_conversation_creation(
a2a_setup: dict,
) -> None:
"""A CEO DM to a role with no dm/read_a2a on its manifest (pr_reviewer,
auditor) must be refused outright at conversation creation — the root-
cause fix (can_a2a_direct's CEO branch now excludes NO_COMMS_ROLES)
supersedes the old symptom-level fix of letting the conversation exist
and only suppressing the wake notification (the recipient could never
ack it, so it would be immortal, permanently suppress future wakes via
the dedup pre-check, and drive futile respawns)."""
"""A CEO DM to a role with no dm/read_a2a on its manifest (the human-only
prompter/secretary — own dedicated chat pages) must be refused outright
at conversation creation — the root-cause fix (can_a2a_direct's CEO
branch excludes NO_COMMS_ROLES) supersedes the old symptom-level fix of
letting the conversation exist and only suppressing the wake
notification (the recipient could never ack it, so it would be
immortal, permanently suppress future wakes via the dedup pre-check,
and drive futile respawns)."""
svc: A2AService = a2a_setup["svc"]
with pytest.raises(A2AAccessDeniedError, match="no agent-comms surface"):
await svc.get_or_create_conversation(agent_a="ceo", agent_b="pr-reviewer-1")
await svc.get_or_create_conversation(agent_a="ceo", agent_b="secretary-1")
@pytest.mark.asyncio
@pytest.mark.parametrize("target_slug", ["auditor", "pr-reviewer-1"])
async def test_ceo_dm_to_auditor_or_pr_reviewer_conversation_allowed(
a2a_setup: dict, target_slug: str
) -> None:
"""The auditor and PR reviewer now carry dm/read_a2a, so a CEO can open
a DM with a mid-flight one — the conversation must be created, not
refused, even though neither gains a peer-initiation surface."""
svc: A2AService = a2a_setup["svc"]
conv = await svc.get_or_create_conversation(agent_a="ceo", agent_b=target_slug)
assert conv is not None
@pytest.mark.asyncio
@@ -2523,11 +2537,46 @@ async def test_maybe_wake_ceo_recipient_still_noops_for_no_comms_role(
with patch(
"roboco.services.notification.NotificationService", return_value=mock_ns
):
await svc._maybe_wake_ceo_recipient("ceo", "pr-reviewer-1", None)
await svc._maybe_wake_ceo_recipient("ceo", "secretary-1", None)
mock_ns.send_a2a_notification.assert_not_awaited()
@pytest.mark.asyncio
async def test_maybe_wake_ceo_recipient_wakes_auditor_now_that_it_has_read_a2a(
a2a_setup: dict,
) -> None:
"""auditor now carries read_a2a, so a CEO DM to an offline auditor wakes
it the same way it wakes any other reachable agent — the manifest check
that used to no-op for it must now let the wake through."""
svc: A2AService = a2a_setup["svc"]
auditor = AgentTable(
id=uuid4(),
name="Auditor",
slug="auditor",
role=AgentRole.AUDITOR,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="auditor",
capabilities=[],
permissions={},
metrics={},
)
db_session = svc.session
db_session.add(auditor)
await db_session.flush()
mock_ns = AsyncMock()
mock_ns.send_a2a_notification = AsyncMock(return_value=None)
with patch(
"roboco.services.notification.NotificationService", return_value=mock_ns
):
await svc._maybe_wake_ceo_recipient("ceo", "auditor", None)
mock_ns.send_a2a_notification.assert_awaited_once()
@pytest.mark.asyncio
async def test_interject_as_ceo_wakes_only_addressed_participant(
a2a_setup: dict,
@@ -79,14 +79,14 @@ def test_envelope_circuit_open_kind_distinct_from_tracing_gap() -> None:
assert env_co.as_dict()["error"] != env_tg.as_dict()["error"]
def test_auditor_silent_runtime_guard_in_dm() -> None:
"""Spec §5.5: auditor dm refused at runtime (defense in depth).
def test_no_comms_runtime_guard_in_dm() -> None:
"""Spec §5.5: no-comms roles' dm() refused at runtime (defense in depth).
say() was retired with the channels/messaging subsystem; dm() (A2A) is
the sole surviving agent-comms verb this guard still needs to cover.
The auditor and pr_reviewer now carry dm/read_a2a (the CEO can DM a
mid-flight one and it replies in-thread), so the runtime guard covers only
the human-only prompter/secretary — checked against the canonical
_NO_COMMS_ROLES set rather than a hardcoded role name. The behavioral test
lives in tests/unit/gateway/test_auditor_silent_guard.py.
"""
# The actual guard test lives in tests/unit/gateway/test_auditor_silent_guard.py.
# Smoke gate verifies the guard exists by checking the source for the
# specific role-check pattern.
dm_source = inspect.getsource(content_actions.ContentActions.dm)
assert "auditor" in dm_source.lower(), "dm() missing auditor runtime guard"
assert "_NO_COMMS_ROLES" in dm_source, "dm() missing no-comms runtime guard"