mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)` |
|
||||
|
||||
@@ -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)` |
|
||||
|
||||
@@ -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)` |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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're watching, and visible only to you and
|
||||
them.
|
||||
Starts (or reopens) your own 1:1 with an agent — separate from the
|
||||
threads you'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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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={},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user