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
+2 -2
View File
@@ -355,11 +355,11 @@ Each agent gets a **spawn manifest** at `/app/tool-manifest.json` listing the ve
| pr_reviewer | `give_me_work`, `claim_pr_review`, `post_pr_review` (inbound external/fork PRs), `claim_gate_review`, `pr_pass`, `pr_fail` (in-path assembled-PR gate), `unclaim` |
| product_owner | `triage`, `escalate_to_ceo` |
| head_marketing| `triage`, `escalate_to_ceo` |
| auditor | `triage`, `waive_finding` (read-only — no `dm`) |
| auditor | `triage`, `waive_finding` (read-only; carries `dm`/`read_a2a` as a content tool so it can reply to a CEO-opened DM, but never initiates) |
| prompter | (none beyond `i_am_idle` — not a delivery-lifecycle role; intake interviewer, human-only) |
| secretary | (none beyond `i_am_idle` — human-only chief-of-staff; reads company state + runs gated CEO directives) |
Content tools (do_server) — most roles: `commit`, `note`, `dm`, `read_a2a`, `evidence`. Delivery roles (developer / qa / documenter / cell_pm / main_pm) also get `draft_playbook` (draft a curated playbook for the KB). Product Owner additionally gets `propose_roadmap` (product_owner-only, authors the weekly board-roadmap cycle) and Head of Marketing additionally gets `propose_feature_spotlight` (head_marketing-only, drafts a feature-spotlight X post) — see "Board roadmap engine" / "RoboCo X account" below. Auditor is restricted to `note` (scope=reflect) + `evidence`, plus the playbook-curation verbs `approve_playbook` / `reject_playbook` / `archive_playbook` (a bounded, deliberate expansion — KB curation, not agent comms, so its no-`dm` restriction holds) and, when the Obsidian vault is armed, `curate_vault` (writes one narrative paragraph onto a just-completed root's vault note — see "Obsidian vault V1+V2" below). The `pr_reviewer` posts its change-request on the PR itself (no agent comms). The `prompter` (intake) and `secretary` are restricted to `note` + `evidence` — human-only, no `dm`/`notify`. The `note`/journal write returns as soon as the entry is persisted; RAG indexing (Ollama embedding) runs fire-and-forget, so the tool no longer times out under concurrent load.
Content tools (do_server) — most roles: `commit`, `note`, `dm`, `read_a2a`, `evidence`. Delivery roles (developer / qa / documenter / cell_pm / main_pm) also get `draft_playbook` (draft a curated playbook for the KB). Product Owner additionally gets `propose_roadmap` (product_owner-only, authors the weekly board-roadmap cycle) and Head of Marketing additionally gets `propose_feature_spotlight` (head_marketing-only, drafts a feature-spotlight X post) — see "Board roadmap engine" / "RoboCo X account" below. Auditor is restricted to `note` (scope=reflect) + `evidence` + `dm`/`read_a2a`, plus the playbook-curation verbs `approve_playbook` / `reject_playbook` / `archive_playbook` (a bounded, deliberate expansion — KB curation, not agent comms) and, when the Obsidian vault is armed, `curate_vault` (writes one narrative paragraph onto a just-completed root's vault note — see "Obsidian vault V1+V2" below). The auditor's `dm`/`read_a2a` exists so it can read and reply in-thread when the CEO opens a DM with it (mid-task, stuck) — it still never *initiates* peer A2A (`agents_config.can_a2a_direct` refuses it unconditionally as sender), preserving it as a silent observer to other agents. The `pr_reviewer` likewise now carries `dm`/`read_a2a` for the same CEO-reachability reason, on top of posting its change-request on the PR itself; its only INITIATION target stays its owning cell_pm/main_pm. The `prompter` (intake) and `secretary` are restricted to `note` + `evidence` — human-only, no `dm`/`notify`, they have their own dedicated chat pages instead. The `note`/journal write returns as soon as the entry is persisted; RAG indexing (Ollama embedding) runs fire-and-forget, so the tool no longer times out under concurrent load.
### MCP servers running per agent container
+2
View File
@@ -17,6 +17,8 @@
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `read_a2a` | `read_a2a(see do_server)` |
| `approve_playbook` | `approve_playbook(playbook_id: UUID)` |
| `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` |
| `archive_playbook` | `archive_playbook(playbook_id: UUID)` |
+2
View File
@@ -22,5 +22,7 @@
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `read_a2a` | `read_a2a(see do_server)` |
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` |
+4
View File
@@ -251,6 +251,8 @@ real tools live in their agent_sdk drivers, not role_config.
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `read_a2a` | `read_a2a(see do_server)` |
| `approve_playbook` | `approve_playbook(playbook_id: UUID)` |
| `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` |
| `archive_playbook` | `archive_playbook(playbook_id: UUID)` |
@@ -279,6 +281,8 @@ real tools live in their agent_sdk drivers, not role_config.
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `read_a2a` | `read_a2a(see do_server)` |
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` |
+1 -1
View File
@@ -22,7 +22,7 @@ The "misc" foundation-policy slice holds the pure, service-agnostic rule catalog
|---|---|---|---|
| parse_priority | function | roboco/foundation/policy/communications.py:23 | Resolve a NotificationPriority from raw_priority string or legacy urgent flag; unknown→NORMAL |
| NOTIFY_SENDER_ROLES | constant | roboco/foundation/policy/communications.py:51 | frozenset of roles permitted to call notify() (CELL_PM, MAIN_PM, PRODUCT_OWNER, HEAD_MARKETING, CEO) |
| NO_COMMS_ROLES | constant | roboco/foundation/policy/communications.py:66 | frozenset of roles with NO agent-comms surface at all (AUDITOR, PR_REVIEWER, PROMPTER, SECRETARY) — the canonical set both `content_actions.dm()`'s sender-side guard and `agents_config.can_a2a_direct`'s CEO-target-side check consume, so the two enforcement points can't drift apart |
| NO_COMMS_ROLES | constant | roboco/foundation/policy/communications.py:66 | frozenset of roles with NO agent-comms surface at all (PROMPTER, SECRETARY — human-only, own dedicated chat pages) — the canonical set both `content_actions.dm()`'s sender-side guard and `agents_config.can_a2a_direct`'s CEO-target-side check consume, so the two enforcement points can't drift apart. AUDITOR/PR_REVIEWER carry `dm`/`read_a2a` (CEO-reachable, reply-only) but are not in this set — the auditor's peer-silence is enforced separately in `can_a2a_direct`'s `from_role == "auditor"` branch |
| ACK_REQUIRED_BY_TYPE | constant | roboco/foundation/policy/communications.py:80 | NotificationType→requires_ack mapping (action-required vs informational) |
| Scope | enum | roboco/foundation/policy/journaling.py:21 | Journal entry scope StrEnum (note/decision/reflect/learning/struggle) |
| SCOPE_TO_TYPE | constant | roboco/foundation/policy/journaling.py:32 | Scope→JournalEntryType single-source mapping |
+3 -3
View File
@@ -116,10 +116,10 @@ This slice is the prompt-composition pipeline and the role/team/permission taxon
| _check_cell_pm_a2a | function | roboco/agents_config.py:590 | A2A permission for cell PM (own cell / other PMs / main-pm allowed; board escalated) |
| _check_cell_member_a2a | function | roboco/agents_config.py:604 | A2A permission for cell members (same-cell allowed; cross-cell via PMs) |
| _check_main_pm_a2a | function | roboco/agents_config.py:624 | A2A permission for main PM (_MAIN_PM_TARGETS allowed) |
| can_a2a_direct | function | roboco/agents_config.py:632 | (allowed, error) for direct A2A from one agent to another; routes CEO via notify, board/main_pm/cell-member via handlers; the CEO branch now consults `_check_ceo_a2a` (below) instead of an unconditional `True` |
| _check_ceo_a2a | function | roboco/agents_config.py:635 | CEO-initiated A2A target check: refuses `to_role in NO_COMMS_ROLES` (auditor/pr_reviewer/prompter/secretary — no `dm`/`read_a2a` on the manifest, so nothing on the other end could read or answer it), else allowed |
| can_a2a_direct | function | roboco/agents_config.py:632 | (allowed, error) for direct A2A from one agent to another; routes CEO via `_check_ceo_a2a`, `from_role == "auditor"` unconditionally refuses (silent peer observer — replies to a CEO-opened DM go through A2AService's stateful reply path instead, not this matrix), board/main_pm/cell-member via handlers |
| _check_ceo_a2a | function | roboco/agents_config.py:635 | CEO-initiated A2A target check: refuses `to_role in NO_COMMS_ROLES` (prompter/secretary only — no `dm`/`read_a2a` on the manifest, so nothing on the other end could read or answer it), else allowed. auditor/pr_reviewer carry `dm`/`read_a2a` now (CEO-reachable, reply-only) so they're no longer in NO_COMMS_ROLES |
| get_a2a_route_hint | function | roboco/agents_config.py:670 | Human-readable routing hint for an A2A message |
| A2A_ALLOWED_PAIRS | constant | roboco/agents_config.py | Statically-derived (via `_compute_a2a_allowed_pairs()`, calling `can_a2a_direct` for every pair) set of legal A2A pairs — sized 88 (`ceo` group 18) after `_check_ceo_a2a` excludes no-comms roles; the panel switchboard's section matrix reads off this same computation |
| A2A_ALLOWED_PAIRS | constant | roboco/agents_config.py | Statically-derived (via `_compute_a2a_allowed_pairs()`, calling `can_a2a_direct` for every pair) set of legal A2A pairs — sized 93 (`ceo` group 23) now that auditor + the 4 pr_reviewer slugs are CEO-reachable; the panel switchboard's section matrix reads off this same computation |
| _PATTERNS | list | roboco/agent_sdk/prompt_guard.py:28 | Five (regex, reason) injection patterns: ignore-previous, role-override, fake role prefix, control-token mimicry, fake executive-order |
| detect_injection | function | roboco/agent_sdk/prompt_guard.py:63 | Return deny reason if text matches an injection pattern (lowercased), else None |
| refusal_message | function | roboco/agent_sdk/prompt_guard.py:72 | Guidance string shown on denial (mirrors bash hook text) |
+1 -1
View File
@@ -67,7 +67,7 @@ Sending notifications means calling the `notify(target, text, priority)` content
| qa | No | - |
| documenter | No | - |
Non-senders (developer, qa, documenter, auditor) still communicate via `dm(recipient, text)` for direct agent-to-agent messages — those are not ack-required notifications. The Auditor is restricted further: it has `note(scope=reflect)` + `evidence` + read-only `notify_list`/`notify_get`, and NO `dm`/`notify`.
Non-senders (developer, qa, documenter) still communicate via `dm(recipient, text)` for direct agent-to-agent messages — those are not ack-required notifications. The Auditor is restricted further: `note(scope=reflect)` + `evidence` + read-only `notify_list`/`notify_get`, and NO `notify`. It does carry `dm`/`read_a2a`, but only to reply in-thread when the CEO opens a DM with it — `can_a2a_direct` refuses it as a sender unconditionally, so it never initiates to a peer.
## Task-Creator Roles
+2 -2
View File
@@ -89,7 +89,7 @@ Both share the same flow verbs and read-only git (none), but their content verbs
**Flow verbs:** `triage`, `i_am_idle` (read-only)
**Content verbs:** `note` (scope=reflect), `evidence` (no `dm` — Auditor observes silently)
**Content verbs:** `note` (scope=reflect), `evidence`, `dm`, `read_a2a` (dm/read_a2a exist only so it can reply in-thread to a CEO-opened DM — it never initiates to a peer, so it still observes silently)
**Read-only git:** none.
@@ -97,7 +97,7 @@ Both share the same flow verbs and read-only git (none), but their content verbs
**Flow verbs:** `give_me_work`, `claim_pr_review`, `post_pr_review` (inbound external/fork + internal PRs), `claim_gate_review`, `pr_pass`, `pr_fail` (in-path assembled-PR gate), `unclaim`, `i_am_idle` (read-only)
**Content verbs:** `note`, `evidence`, plus notification reads (`notify_list`, `notify_get`) — no `dm`: the change-request is posted server-side on the PR itself.
**Content verbs:** `note`, `evidence`, `dm`, `read_a2a`, plus notification reads (`notify_list`, `notify_get`) — the change-request itself is still posted server-side on the PR; `dm`/`read_a2a` exist so it can reply in-thread to a CEO-opened DM, and its only INITIATION target is its owning cell_pm/main_pm (the in-path gate verdict).
**Read-only git:** none.
+7 -6
View File
@@ -22,7 +22,7 @@ In addition to read-only observation, the Auditor is spawned reactively when a t
- The orchestrator's `_dispatch_audit_work` watches for unacknowledged `ALERT` notifications targeted at the auditor and spawns the auditor with a quality-alert prompt.
- This path is **best-effort**: a delivery failure is logged but does not block the underlying task transition.
You still cannot claim tasks, message agents, or write code — the reactive spawn only gives you a timely lens on quality events.
You still cannot claim tasks, initiate a message to a peer agent, or write code — the reactive spawn only gives you a timely lens on quality events.
## Scheduled Sweep Path
@@ -33,7 +33,7 @@ The Auditor is also spawned on a periodic sweep:
- If all conditions pass, the orchestrator spawns the auditor with a sweep prompt that instructs it to scan recent task state, quality drift, QA pass/fail patterns, convention violations, tracing gaps, and cross-cell hand-off friction.
- This path is **best-effort** and shares the same interval throttle with reactive alert spawns.
You still cannot claim tasks, message agents, or write code — the scheduled sweep is another read-only lens on delivery health.
You still cannot claim tasks, initiate a message to a peer agent, or write code — the scheduled sweep is another read-only lens on delivery health.
## What You CAN Do
@@ -43,7 +43,8 @@ You still cannot claim tasks, message agents, or write code — the scheduled sw
- Attach evidence via `evidence(task_id)`
- Search the knowledge base via `roboco_ask_mentor` / `roboco_kb_search`
- Waive one open **minor/nit** revision-findings-ledger finding via `waive_finding(finding_id, note)` — see below
- Curate the KB's playbook queue via `approve_playbook` / `reject_playbook` / `archive_playbook` — a deliberate, bounded expansion of your read-only surface (KB curation, not agent comms, so the no-`dm` restriction still holds)
- Curate the KB's playbook queue via `approve_playbook` / `reject_playbook` / `archive_playbook` — a deliberate, bounded expansion of your read-only surface (KB curation, not agent-initiated comms)
- Read `dm`s and reply in-thread when the CEO opens a DM with you (`read_a2a` / `dm`) — reachable mid-task if you're stuck, but you still never *initiate* to a peer agent
- Curate the Obsidian vault's narrative for a just-completed root task-tree via `curate_vault(task_id, narrative)` — see below (only when `ROBOCO_OBSIDIAN_VAULT_ENABLED`)
## What You CANNOT Do
@@ -51,7 +52,7 @@ You still cannot claim tasks, message agents, or write code — the scheduled sw
- Claim, create, assign, complete, or cancel tasks
- Pass or fail QA
- Escalate (`triage` is your only flow verb besides `i_am_idle`/`waive_finding`)
- DM agents (`dm`) or send `notify`
- Initiate a `dm` to a peer agent (you still reply in-thread if the CEO opens one), or send `notify`
- Acknowledge notifications (silent observer — `notify_ack` is not yours)
- Write to project docs, write code, or run git write operations
@@ -72,7 +73,7 @@ waive_finding(
The Auditor has **silent read access** across the org:
- Reads task state and the knowledge base
- Cannot send messages outward — there is no `dm` / `notify`
- Never initiates messages outward — no `notify`, and `dm` only replies inside a DM the CEO opened (never starts one to a peer)
- Observations are recorded privately via `note(scope="reflect")`
## Observation Areas
@@ -86,7 +87,7 @@ Monitor for:
## Recording Findings
The Auditor cannot create tasks or message agents. Findings are captured as private reflections, which the KB indexes for later review:
The Auditor cannot create tasks or initiate a message to a peer agent. Findings are captured as private reflections, which the KB indexes for later review:
```python
note(
+3 -2
View File
@@ -43,11 +43,12 @@ You cannot `pr_pass` / `pr_fail` an assembled PR you authored (self-review guard
- Read-only inspect git via `roboco_git_status / _log / _diff / _branch_list`.
- Search the knowledge base via `roboco_ask_mentor` / `roboco_kb_search`.
- Note evidence via `note(...)` and `evidence(...)`.
- Read `dm`s and reply in-thread when the CEO opens a DM with you (`read_a2a` / `dm`), and deliver an in-path gate verdict to your owning cell_pm/main_pm via `dm` — your only two comms surfaces.
## What You CANNOT Do
- Modify code, `commit`, push, open / merge PRs — not in your manifest.
- `dm` other agents — you have no comms surface; your output is the PR review.
- Initiate a `dm` to anyone other than your owning cell_pm/main_pm — your output is still the PR review, not agent chatter.
- Send `notify` (ack-required notifications) — PMs / Board only.
- Decide the PR's fate. You review; the **CEO** decides. Your completed review surfaces in the **CEO PR Review Queue** (Command Center), where the CEO chooses **Supersede** (the org cuts its own branch off the contributor's commits, hardens the work, opens its own PR, and — once that merges — closes and links the contributor PR) or **Dismiss**.
@@ -65,7 +66,7 @@ i_am_idle() → out of work
| MCP server | Verbs you can call |
|-----------------------|--------------------|
| `roboco-flow` | `give_me_work`, `claim_pr_review`, `post_pr_review`, `claim_gate_review`, `pr_pass`, `pr_fail`, `unclaim`, `i_am_idle` |
| `roboco-do` | `note`, `evidence`, `notify_list`, `notify_get` (no `dm` / `commit` / `notify`) |
| `roboco-do` | `note`, `evidence`, `dm`, `read_a2a`, `notify_list`, `notify_get` (`dm` only to your owning cell_pm/main_pm, or in reply to a CEO-opened DM — no `commit` / `notify`) |
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
+2 -2
View File
@@ -130,7 +130,7 @@ triage() # read-only list of actionable tasks
i_am_idle()
```
The Auditor is a silent observer: read-only `triage`, no `dm`/`notify`, no claim/complete/cancel.
The Auditor is a silent observer: read-only `triage`, no `notify`, no claim/complete/cancel. It carries `dm`/`read_a2a` but only to reply in-thread when the CEO opens a DM with it — it never initiates `dm` to a peer.
## PR Reviewer flow
@@ -142,7 +142,7 @@ unclaim(task_id) # release a claimed inbound or gate review back
i_am_idle()
```
The PR Reviewer reviews inbound external/fork (and, behind a flag, internal) PRs the org did not open. It is read-only: no `commit`/`open_pr`/`merge`, no `dm` — the change-request is posted server-side on the PR itself, and the CEO decides Supersede/Dismiss from the PR Review Queue.
The PR Reviewer reviews inbound external/fork (and, behind a flag, internal) PRs the org did not open. It is read-only: no `commit`/`open_pr`/`merge` — the change-request is posted server-side on the PR itself, and the CEO decides Supersede/Dismiss from the PR Review Queue. It carries `dm`/`read_a2a`, but only to its owning cell_pm/main_pm (the in-path gate verdict) or in reply to a CEO-opened DM — never broader agent chatter.
The same role also runs the **in-path PR-review gate** on the org's own assembled delivery PRs — the merge-level review before the PM merges:
+1 -1
View File
@@ -53,6 +53,6 @@ If a conversation surfaces work that needs a new task:
Most roles can `dm` (same-cell) and read incoming messages with `read_a2a`, plus check their notify inbox with `notify_list` / `notify_get`.
The **Auditor** is a silent observer: it can read (`notify_list`, `notify_get`) but has **no** `dm` or `notify` it never communicates outwardly.
The **Auditor** is a silent observer of peers: it never *initiates* `dm` to another agent and has no `notify`. It does carry `dm`/`read_a2a` so it can read and reply in-thread when the CEO opens a DM with it — that reply path is stateful (not gated by the peer-initiation rule).
Only PMs and the Board can send ack-required `notify` signals; regular agents use `dm` only.
@@ -23,15 +23,15 @@ import { getErrorMessage } from "@/lib/api/client";
import { useCreateCeoConversation } from "@/hooks/use-a2a-live";
import { useAgentDefinitions } from "@/hooks/use-agents";
// Self, plus every role that can't actually read/answer a DM: auditor and
// pr_reviewer carry no read_a2a on their manifests, prompter and secretary
// are human-only note/evidence roles — a DM to any of them is a black hole.
// Self, plus every role that can't actually read/answer a DM: prompter and
// secretary are human-only note/evidence roles with their own dedicated chat
// pages — a DM to either is a black hole. Auditor and pr_reviewer now carry
// dm/read_a2a (the CEO can reach a mid-flight one and it can reply in-thread)
// so they're no longer excluded here.
// Exported so other "start a fresh 1:1" surfaces (the /tg Mini App chat tab)
// share the exact same exclusion list instead of drifting out of sync.
export const EXCLUDE_NON_DM_ROLES = [
AgentRole.CEO,
AgentRole.AUDITOR,
AgentRole.PR_REVIEWER,
AgentRole.PROMPTER,
AgentRole.SECRETARY,
];
@@ -141,9 +141,8 @@ export function A2ANewDmDialog({
<DialogHeader>
<DialogTitle>New direct message</DialogTitle>
<DialogDescription>
Starts (or reopens) your own 1:1 with an agent separate from
the threads you&apos;re watching, and visible only to you and
them.
Starts (or reopens) your own 1:1 with an agent separate from the
threads you&apos;re watching, and visible only to you and them.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
@@ -186,7 +186,7 @@ describe("AgentCard", () => {
);
});
it("hides the DM quick-action for a role that can't read/answer a DM", () => {
it("shows the DM quick-action for the auditor now that it carries dm/read_a2a", () => {
const auditor = {
id: "auditor",
name: "Auditor",
@@ -194,9 +194,40 @@ describe("AgentCard", () => {
team: "board",
} as unknown as AgentDefinition;
render(<AgentCard agent={auditor} agentStatus={statusOf()} />);
expect(
screen.getByRole("button", { name: "DM this agent" }),
).toBeInTheDocument();
});
it("shows the DM quick-action for a PR reviewer now that it carries dm/read_a2a", () => {
const prReviewer = {
id: "pr-reviewer-1",
name: "PR Reviewer",
role: "pr_reviewer",
team: "board",
} as unknown as AgentDefinition;
render(<AgentCard agent={prReviewer} agentStatus={statusOf()} />);
expect(
screen.getByRole("button", { name: "DM this agent" }),
).toBeInTheDocument();
});
it("hides the DM quick-action for the human-only prompter/secretary roles", () => {
for (const role of ["prompter", "secretary"]) {
const agent = {
id: role,
name: role,
role,
team: null,
} as unknown as AgentDefinition;
const { unmount } = render(
<AgentCard agent={agent} agentStatus={statusOf()} />,
);
expect(
screen.queryByRole("button", { name: "DM this agent" }),
).not.toBeInTheDocument();
unmount();
}
});
it("hides the DM quick-action for the CEO card", () => {
+22 -6
View File
@@ -636,10 +636,10 @@ def _check_ceo_a2a(to_role: str) -> tuple[bool, str | None]:
"""Check A2A permissions for the CEO's asymmetric send-to-anyone reach.
A target with no agent-comms surface (no dm/read_a2a on its manifest
auditor, pr_reviewer, prompter, secretary) can never read or answer a
DM regardless of who sends it; the panel's New-DM dialog already
excludes these roles client-side (EXCLUDE_NON_DM_ROLES), this is the
server-side backstop so a direct API/A2A-service call can't bypass it.
prompter, secretary) can never read or answer a DM regardless of who
sends it; the panel's New-DM dialog already excludes these roles
client-side (EXCLUDE_NON_DM_ROLES), this is the server-side backstop so
a direct API/A2A-service call can't bypass it.
"""
if to_role in _comms.NO_COMMS_ROLES:
return (
@@ -650,6 +650,19 @@ def _check_ceo_a2a(to_role: str) -> tuple[bool, str | None]:
return True, None
def _check_auditor_a2a() -> tuple[bool, str | None]:
"""Silent observer: the auditor never INITIATES A2A to any target.
It can still read and reply inside a DM the CEO opens (a stateful reply
via A2AService._get_conversation_for_reply_to_ceo, not gated here), so a
mid-flight auditor is reachable by the CEO for help.
"""
return (
False,
"The auditor observes silently; it only replies inside a CEO-opened DM.",
)
def _check_pr_reviewer_a2a(to_role: str) -> tuple[bool, str | None]:
"""Check A2A permissions for a PR reviewer.
@@ -693,8 +706,10 @@ def can_a2a_direct(from_agent: str, to_agent: str) -> tuple[bool, str | None]:
"CEO opened — use notify() otherwise.",
)
# Board → board/main-pm (not CEO, not cells directly)
if from_role in ("product_owner", "head_marketing", "auditor"):
# Board → board/main-pm (not CEO, not cells directly). Auditor is split
# out of this tuple — it's dispatched via _check_auditor_a2a below since
# it never reaches a peer at all, board or otherwise.
if from_role in ("product_owner", "head_marketing"):
return (
(True, None)
if to_role in _BOARD_ROLES or to_role == "main_pm"
@@ -706,6 +721,7 @@ def can_a2a_direct(from_agent: str, to_agent: str) -> tuple[bool, str | None]:
"main_pm": _check_main_pm_a2a(to_role, to_team),
"cell_pm": _check_cell_pm_a2a(from_team, to_agent, to_role, to_team),
"pr_reviewer": _check_pr_reviewer_a2a(to_role),
"auditor": _check_auditor_a2a(),
}
if from_role in handlers:
return handlers[from_role]
+11 -9
View File
@@ -56,17 +56,19 @@ NOTIFY_SENDER_ROLES: frozenset[Role] = frozenset(
)
# Roles with no agent-comms surface at all: auditor (silent observer, no dm/
# read_a2a on its manifest), pr_reviewer (posts findings on the PR itself),
# and the human-only prompter/secretary (note + evidence only). A DM to any
# of these is a black hole — nothing on the other end can read or answer it.
# Canonical set consumed by both the dm() sender-side guard
# (services.gateway.content_actions) and the CEO's asymmetric target check
# (agents_config.can_a2a_direct) so the two never drift apart.
# Roles with no agent-comms surface at all: the human-only prompter/secretary
# (note + evidence only — they own dedicated chat pages, not agent A2A). A DM
# to either is a black hole — nothing on the other end can read or answer it.
# Auditor and pr_reviewer are NOT here: both now carry dm/read_a2a so the CEO
# can reach a mid-flight one and it can reply in-thread, but neither gains a
# peer-initiation surface — the auditor stays silent by the can_a2a_direct
# rule (agents_config.can_a2a_direct), the pr_reviewer stays scoped to its
# owning PM (_check_pr_reviewer_a2a). Canonical set consumed by both the dm()
# sender-side guard (services.gateway.content_actions) and the CEO's
# asymmetric target check (agents_config.can_a2a_direct) so the two never
# drift apart.
NO_COMMS_ROLES: frozenset[Role] = frozenset(
{
Role.AUDITOR,
Role.PR_REVIEWER,
Role.PROMPTER,
Role.SECRETARY,
}
+14 -18
View File
@@ -99,9 +99,12 @@ _NOTIFY_ALLOWED_ROLES: frozenset[str] = frozenset(
r.value for r in _comms.NOTIFY_SENDER_ROLES
)
# Roles with NO agent-comms surface (CLAUDE.md): auditor (silent observer),
# pr_reviewer (posts review findings on the PR itself — no dm), prompter
# and secretary (human-only, restricted to note + evidence — no dm/notify).
# Roles with NO agent-comms surface (CLAUDE.md): the human-only prompter and
# secretary — restricted to note + evidence, no dm/notify, they own their own
# dedicated chat pages instead. (Auditor and pr_reviewer carry dm/read_a2a
# now — the CEO can DM either and it can reply in-thread — so they're no
# longer in this set; the auditor's silence toward PEERS is enforced
# separately in agents_config.can_a2a_direct.)
# The spawn manifest already omits dm from these roles' tool surfaces, but
# that is convention-only — this frozenset is the handler-level defence-in-depth
# that refuses any call that bypassed the manifest (direct verb dispatch, test
@@ -113,16 +116,6 @@ _NOTIFY_ALLOWED_ROLES: frozenset[str] = frozenset(
_NO_COMMS_ROLES: frozenset[str] = frozenset(r.value for r in _comms.NO_COMMS_ROLES)
def _no_comms_remediate(role: str) -> str:
"""Role-appropriate remediation for a no-comms role blocked at dm."""
if role == "auditor":
return "record observations via note(scope='reflect') instead"
if role == "pr_reviewer":
return "post review findings on the PR itself via pr_pass/pr_fail instead"
# prompter / secretary are human-only (note + evidence).
return "use note() to record; this human-only role has no agent-comms surface"
_DECISION_SECTIONS: tuple[tuple[str, str], ...] = (
("context", "Context"),
("options", "Options Considered"),
@@ -1702,10 +1695,10 @@ class ContentActions:
"""A2A direct message. Requires task_id (active or explicit)."""
if rej := self._reject_soup(text, field="message", min_chars=2):
return rej
# Spec §5.5: silent / no-comms roles — defense-in-depth runtime guard.
# Defense-in-depth: dm() is the channel through which a no-comms role
# could "speak". Covers auditor,
# pr_reviewer, and the human-only prompter / secretary.
# Spec §5.5: no-comms roles — defense-in-depth runtime guard. dm() is
# the channel through which a no-comms role could "speak"; covers the
# human-only prompter/secretary (own dedicated chat pages, no agent
# A2A surface at all).
agent = await self.task.agent_for(agent_id)
caller_role = str(agent.role) if agent is not None else ""
if caller_role in _NO_COMMS_ROLES:
@@ -1714,7 +1707,10 @@ class ContentActions:
f"role '{caller_role}' is a silent / no-comms role;"
" dm is not permitted"
),
remediate=_no_comms_remediate(caller_role),
remediate=(
"use note() to record; this human-only role has no"
" agent-comms surface"
),
context_briefing={},
)
+12 -6
View File
@@ -140,13 +140,17 @@ _HEAD_MARKETING_DO = (
_AUDITOR_FLOW = spec.intents_for_role(spec.Role.AUDITOR)
# Auditor reads, does not chat or escalate. notify_list/get for inbox visibility;
# no ack (silent observer — wouldn't ack notifications).
# no ack (silent observer — wouldn't ack notifications). It now carries
# dm/read_a2a so the CEO can open a DM with a mid-flight auditor and it can
# reply in-thread, but it still never INITIATES peer A2A — that's enforced in
# agents_config.can_a2a_direct, not by omitting the tool.
# The Auditor is the playbook quality gate — a deliberate, bounded expansion of
# its surface (approve/reject/archive are KB curation actions, not agent comms,
# so the no-dm restriction is preserved).
# its surface (approve/reject/archive are KB curation actions, not agent comms).
_AUDITOR_DO = (
"note",
"evidence",
"dm",
"read_a2a",
"approve_playbook",
"reject_playbook",
"archive_playbook",
@@ -157,10 +161,12 @@ _AUDITOR_DO = (
# PR reviewer: a read-only reviewer of inbound external/fork PRs. Flow verbs come
# from the lifecycle spec (a dedicated review trio, not QA's). It reads diffs and
# records findings (note/evidence); the change-request is posted server-side, so
# it has no outward agent comms (no dm).
# records findings (note/evidence); the change-request is posted server-side. It
# now carries dm/read_a2a so the CEO can reach one mid-review and it can reply
# in-thread; its only INITIATION target stays its owning cell_pm/main_pm
# (agents_config._check_pr_reviewer_a2a).
_PR_REVIEWER_FLOW = spec.intents_for_role(spec.Role.PR_REVIEWER)
_PR_REVIEWER_DO = ("note", "evidence", "notify_list", "notify_get")
_PR_REVIEWER_DO = ("note", "evidence", "dm", "read_a2a", "notify_list", "notify_get")
_PROMPTER_FLOW = spec.intents_for_role(
spec.Role.PROMPTER
+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"
+15 -6
View File
@@ -99,23 +99,32 @@ def test_can_a2a_direct_to_ceo_message_explains_reply_only() -> None:
@pytest.mark.parametrize(
"target_slug",
["auditor", "pr-reviewer-1", "intake-1", "secretary-1"],
["intake-1", "secretary-1"],
)
def test_can_a2a_direct_ceo_to_no_comms_role_denied(target_slug: str) -> None:
"""The CEO's asymmetric reach still can't target a role with no dm/
read_a2a on its manifest (auditor, pr_reviewer, prompter, secretary)
nothing on the other end could ever read or answer the DM. The panel's
New-DM dialog already filters these client-side (EXCLUDE_NON_DM_ROLES);
this is the server-side backstop for a direct API/A2A-service call."""
read_a2a on its manifest (prompter, secretary human-only, own chat
pages) nothing on the other end could ever read or answer the DM."""
allowed, reason = can_a2a_direct("ceo", target_slug)
assert allowed is False
assert reason is not None
assert "comms" in reason.lower()
@pytest.mark.parametrize("target_slug", ["auditor", "pr-reviewer-1"])
def test_can_a2a_direct_ceo_to_auditor_or_pr_reviewer_allowed(target_slug: str) -> None:
"""The CEO can now DM a mid-flight auditor or PR reviewer — both carry
dm/read_a2a so they can read and reply in-thread, even though neither
gains a peer-initiation surface (auditor stays silent via
can_a2a_direct; the PR reviewer stays scoped to its owning PM)."""
allowed, reason = can_a2a_direct("ceo", target_slug)
assert allowed is True
assert reason is None
def test_can_a2a_direct_ceo_to_no_comms_role_reuses_canonical_set() -> None:
"""The refusal set must be exactly foundation.policy.communications'
NO_COMMS_ROLES the same set services.gateway.content_actions uses to
gate the dm() sender side so the two never drift apart."""
expected = {"auditor", "pr_reviewer", "prompter", "secretary"}
expected = {"prompter", "secretary"}
assert {role.value for role in NO_COMMS_ROLES} == expected
+46 -36
View File
@@ -1,10 +1,15 @@
"""Auditor is silent — runtime guard refuses dm().
"""dm() sender-side no-comms guard: prompter/secretary refused; auditor and
pr_reviewer now pass through (they carry dm/read_a2a so the CEO can DM a
mid-flight one and it can reply in-thread).
Spec §5.5: the auditor is a silent observer. The spawn manifest already
omits `dm` from the auditor's tool surface, but that is a convention-only
defense. These tests pin a defense-in-depth runtime guard inside
ContentActions.dm: if the caller's role is "auditor", the verb refuses with
Envelope.not_authorized regardless of how the call arrived.
Spec §5.5 originally made the auditor's silence absolute (no dm surface at
all). It's now scoped: the auditor still never INITIATES peer A2A — that's
enforced in agents_config.can_a2a_direct, not by ContentActions.dm's role
gate but the gate itself (``_NO_COMMS_ROLES``, derived from
foundation.policy.communications.NO_COMMS_ROLES) no longer blocks it or
pr_reviewer. These tests pin that the handler-level guard (defense-in-depth
for any call that bypassed the manifest) matches the current NO_COMMS_ROLES
set exactly.
"""
from __future__ import annotations
@@ -43,30 +48,9 @@ def _make_deps(agent_role: str, **overrides: AsyncMock) -> ContentActionsDeps:
)
@pytest.mark.asyncio
async def test_auditor_dm_returns_not_authorized() -> None:
"""Auditor role calling dm() is refused regardless of manifest."""
auditor_id = uuid4()
deps = _make_deps("auditor")
actions = ContentActions(deps)
env = await actions.dm(
agent_id=auditor_id,
recipient=str(uuid4()),
text="hi",
task_id=uuid4(),
)
body = env.as_dict()
assert body["error"] == "not_authorized"
haystack = (body.get("message") or "") + " " + (body.get("remediate") or "")
assert "silent" in haystack.lower() or "auditor" in haystack.lower()
deps.a2a.send.assert_not_called()
@pytest.mark.asyncio
async def test_developer_dm_passes_auditor_guard() -> None:
"""dm() for a non-auditor role is not blocked by the new guard."""
"""dm() for a non-auditor role is not blocked by the no-comms guard."""
dev_id = uuid4()
deps = _make_deps("developer")
actions = ContentActions(deps)
@@ -85,22 +69,23 @@ async def test_developer_dm_passes_auditor_guard() -> None:
# ---------------------------------------------------------------------------
# The same no-comms invariant covers pr_reviewer / prompter / secretary
# (CLAUDE.md): pr_reviewer "posts its change-request on the PR itself — no
# say/dm"; prompter + secretary are "restricted to note + evidence — no
# say/dm/notify". The auditor guard's own comment claims defence-in-depth for
# "any call that bypassed the manifest" — that rationale must hold for these
# three roles too, or the claimed defence-in-depth is only 1 of 4 silent roles.
# The no-comms invariant now covers only prompter/secretary (CLAUDE.md):
# they're restricted to note + evidence — human-only, own dedicated chat
# pages. Auditor and pr_reviewer carry dm/read_a2a on their manifests (a CEO
# can DM either and they can reply in-thread) so they must NOT hit this
# guard — the auditor's silence toward PEERS is enforced separately, in
# agents_config.can_a2a_direct, not here.
# ---------------------------------------------------------------------------
_NO_COMMS_ROLES = ("pr_reviewer", "prompter", "secretary")
_NO_COMMS_ROLES = ("prompter", "secretary")
_DM_CAPABLE_ROLES = ("auditor", "pr_reviewer")
@pytest.mark.asyncio
@pytest.mark.parametrize("role", _NO_COMMS_ROLES)
async def test_no_comms_role_dm_returns_not_authorized(role: str) -> None:
"""pr_reviewer / prompter / secretary may not dm() — handler-level guard.
"""prompter / secretary may not dm() — handler-level guard.
Asserts the no-comms signal ("silent") in the message so the test fails for
the right reason on RED: without the role guard, dm() with an unowned
@@ -122,3 +107,28 @@ async def test_no_comms_role_dm_returns_not_authorized(role: str) -> None:
haystack = (body.get("message") or "") + " " + (body.get("remediate") or "")
assert "silent" in haystack.lower()
deps.a2a.send.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("role", _DM_CAPABLE_ROLES)
async def test_auditor_and_pr_reviewer_dm_pass_no_comms_guard(role: str) -> None:
"""auditor / pr_reviewer are no longer refused by the no-comms guard.
Mirrors test_developer_dm_passes_auditor_guard: whatever else dm() does
downstream (ownership checks on the fake task_id), it must not be the
no-comms ("silent") rejection that guard no longer names these roles.
"""
deps = _make_deps(role)
actions = ContentActions(deps)
env = await actions.dm(
agent_id=uuid4(),
recipient=str(uuid4()),
text="hi",
task_id=uuid4(),
)
body = env.as_dict()
if body.get("error") == "not_authorized":
haystack = (body.get("message") or "") + " " + (body.get("remediate") or "")
assert "silent" not in haystack.lower()
+8 -4
View File
@@ -1,8 +1,9 @@
"""Playbook content verbs — role grants + ContentActions RBAC.
Delivery roles DRAFT playbooks; only the Auditor CURATES (approve/reject/archive).
The Auditor's no-say/no-dm restriction is preserved (these are KB curation
actions, not agent comms).
Curation is KB-curation, not agent comms, and stays separate from the
Auditor's dm/read_a2a surface (CEO-reachable, reply-only — see
agents_config.can_a2a_direct for the peer-initiation refusal).
"""
from __future__ import annotations
@@ -33,9 +34,12 @@ def test_auditor_curates_but_does_not_draft() -> None:
for verb in _CURATE_VERBS:
assert verb in do_tools
assert "draft_playbook" not in do_tools
# No-say/no-dm preserved.
# No "say" tool exists; dm/read_a2a ARE present (CEO-reachable, reply-only
# — the auditor still never initiates peer A2A, enforced in
# agents_config.can_a2a_direct, not by omitting the tool here).
assert "say" not in do_tools
assert "dm" not in do_tools
assert "dm" in do_tools
assert "read_a2a" in do_tools
def test_delivery_role_cannot_curate() -> None:
+30 -13
View File
@@ -471,6 +471,23 @@ def test_can_a2a_direct_board_to_developer_denied() -> None:
assert reason is not None
def test_can_a2a_direct_auditor_to_product_owner_denied() -> None:
"""The auditor is a silent observer of peers — it never INITIATES A2A,
even to a fellow board member. It still replies inside a CEO-opened DM
(a stateful path in A2AService, not gated by this matrix)."""
allowed, reason = can_a2a_direct("auditor", "product-owner")
assert allowed is False
assert reason is not None
assert "silent" in reason.lower()
def test_can_a2a_direct_auditor_to_main_pm_denied() -> None:
allowed, reason = can_a2a_direct("auditor", "main-pm")
assert allowed is False
assert reason is not None
assert "silent" in reason.lower()
def test_can_a2a_direct_pr_reviewer_to_main_pm_allowed() -> None:
"""The root→master gate reviewer delivers pr_fail change-requests to the
owning Main PM. Denying it silently strands the verdict (blind re-submit)."""
@@ -533,10 +550,10 @@ def test_get_a2a_route_hint_unknown_from_agent_falls_through() -> None:
# A2A_ALLOWED_PAIRS — the switchboard's static org-chart pair matrix
# ---------------------------------------------------------------------------
_EXPECTED_PAIR_COUNT = 88
_EXPECTED_PAIR_COUNT = 93
_EXPECTED_GROUP_COUNTS = {
"board": 3,
"ceo": 18,
"ceo": 23,
"cell-backend": 15,
"cell-frontend": 15,
"cell-ux_ui": 15,
@@ -574,22 +591,22 @@ def test_a2a_allowed_pairs_excludes_non_participants_keeps_ceo() -> None:
def test_a2a_allowed_pairs_ceo_paired_with_every_dm_capable_agent() -> None:
"""CEO → anyone with an agent-comms surface is allowed, so every non-CEO
switchboard slug EXCEPT the no-comms roles (auditor, pr_reviewer no
dm/read_a2a on their manifest, so a CEO DM to them is a black hole)
appears in exactly one ``ceo``-group pair."""
"""CEO → anyone with an agent-comms surface is allowed. Every switchboard
slug is now dm-capable (auditor and pr_reviewer carry dm/read_a2a so a
mid-flight one can read + reply prompter/secretary are the only
no-comms roles left, and they're already excluded from the switchboard
entirely, see test_a2a_allowed_pairs_excludes_non_participants_keeps_ceo),
so every non-CEO slug appears in exactly one ``ceo``-group pair."""
ceo_pairs = [p for p in A2A_ALLOWED_PAIRS if "ceo" in (p.agent_a, p.agent_b)]
non_ceo_slugs = (
{p.agent_a for p in A2A_ALLOWED_PAIRS} | {p.agent_b for p in A2A_ALLOWED_PAIRS}
) - {"ceo"}
dm_capable_slugs = {
s for s in non_ceo_slugs if get_agent_role(s) not in ("auditor", "pr_reviewer")
}
assert all(p.group_key == "ceo" for p in ceo_pairs)
assert len(ceo_pairs) == len(dm_capable_slugs)
# And the no-comms roles are confirmed absent from any ceo-group pair.
ceo_slugs = {p.agent_a for p in ceo_pairs} | {p.agent_b for p in ceo_pairs}
assert ceo_slugs.isdisjoint(non_ceo_slugs - dm_capable_slugs)
assert len(ceo_pairs) == len(non_ceo_slugs)
ceo_slugs = ({p.agent_a for p in ceo_pairs} | {p.agent_b for p in ceo_pairs}) - {
"ceo"
}
assert ceo_slugs == non_ceo_slugs
def test_a2a_allowed_pairs_group_key_counts() -> None: