Files
roboco/roboco/agents_config.py
T
06682f33c6 Fix: agent workflow hardening (#70)
* fix(gateway): push the branch before QA handoff so reviewers see the latest commits

The commit content tool commits locally without pushing; only open_pr pushed
the branch. On the first submission that was fine, but a fix committed while
addressing needs_revision never reached origin (open_pr is skipped once the PR
exists), so QA — which reviews the remote PR branch — re-reviewed the stale
remote and re-failed the task on every cycle, a loop that never converged.

i_am_done now pushes the task branch (idempotent; a no-op when nothing is
unpushed) as part of the shared submit gate, covering both the normal and
resume-from-verifying paths. A push failure blocks the handoff with a clear
remediation rather than parking the task in awaiting_qa with commits that exist
only in the developer's local workspace.

* fix(orchestrator): don't reap a stale claim while the agent's container is alive

The stale-claim reaper released any claimed/in_progress task whose
last_heartbeat_at exceeded the TTL. The heartbeat only updates on certain
gateway calls, so a developer deep in a long edit/test cycle outran the TTL and
had its claim reaped mid-work — churning the task and risking a double spawn
against the still-running container.

The reaper now skips a task whose assignee still holds a live (ACTIVE) agent
instance, trusting container liveness — the ground truth — over the heartbeat
proxy. The check is defensive on missing fields so a heartbeat-only caller (and
the reaper's existing unit tests) behave exactly as before.

* fix(gateway): refuse to unblock a task while a dependency is unfinished

A PM unblock on a dependency-gated task moved it straight to in_progress,
overriding the dependency — letting a dependent proceed without its upstream's
work (e.g. a frontend task built before its UX design lands). A dependency
block is meant to clear on its own via _unblock_dependents the moment the
upstream reaches a terminal state.

unblock now refuses while any dependency is still non-terminal, returning a
clear remediation that the block resolves automatically. Manual unblock remains
available for genuine, non-dependency blockers.

* fix(gateway): release a dependency-blocked claim to pending instead of looping

A task that reached claimed/in_progress with an unfinished dependency was left
in that state when the claim guard rejected, so the orchestrator's respawn loop
kept reviving its assignee — which could make no progress — burning work for
nothing.

The claim guard now releases such a task back to pending. claimed -> blocked is
not a legal transition, so pending — held by the dispatch dependency filter — is
the lifecycle-correct resting state: the respawn loop ignores pending tasks, and
_unblock_dependents re-dispatches it once the upstream reaches a terminal state.
release_dependency_blocked_claim shares a _force_unclaim_to_pending core with
unclaim_for_reaper so both record a truthful work-session abandon reason.

* feat(security): warn at startup in header-trust mode + document the auth posture

When ROBOCO_AGENT_AUTH_REQUIRED is not enabled the API accepts the X-Agent-Id /
X-Agent-Role headers without a signed token, so any client that can reach it may
act as any role (including 'ceo'). The API now logs a clear warning at startup
in this mode, and the README gains a Security section documenting the auth
posture and how to harden it. Acceptable only on a trusted private network — do
not expose the API to untrusted networks.

* fix(workspace): scope the refresh fetch to current + default branch

ensure_workspace's healthy short-circuit ran an all-refs 'git fetch origin' to
keep every origin/<branch> ref current. On a monorepo with many accumulated
feature/* branches that exceeds the refresh timeout, the fetch silently fails,
and the workspace keeps a stale base — so an agent builds on an out-of-date
branch.

The refresh now fetches only the workspace's current branch and the repo's
default branch (resolved via origin/HEAD), with --no-tags --prune: it transfers
near-nothing and can't time out. Readers need their own branch and the default;
the integration branch is refreshed at branch-creation time.

* fix(git): refresh a dependency-blocked task's branch off the current integration tip

A cross-cell dependent (e.g. a frontend task waiting on the UX design) was
branched off a base captured before its upstream merged into the integration
branch, and the branch was never re-synced — so the agent built on a stale
snapshot with none of the upstream's work.

Two changes close the gap:
- release_dependency_blocked_claim now clears branch_name, so the re-claim
  (after the dependency clears) re-runs branch creation.
- create_branch, when the branch is already on disk with no commits of its own,
  resets it onto the freshly-pulled base — the dependent now builds on the
  current integration tip. A branch carrying real commits is left untouched, so
  no work is discarded; the cell->leaf cascade carries the upstream down to the
  dev branch automatically.

* refactor(gateway): drop the sibling-sequence claim guard

Sibling sequence no longer gates a claim. Cross-cell ordering is
enforced by task dependencies — a cell task that depends on another is
held until its upstream reaches a terminal state, a stronger,
status-aware gate than the sequence-number check. That check was
dormant in practice anyway: every fan-out child carries sequence 0, on
which the guard short-circuited. `sequence` stays a sibling-ordering /
dispatch-priority field (list_pending ordering and the panel).

Removes sibling_sequence_guard and its _earlier_blocking_sibling
helper, the now-unused skip_sequence parameter threaded through the
claim verbs, and the sibling fetch that fed it.

* feat(gateway): sort a cross-cell dependent after its upstream

When the frontend cell task is wired to depend on its UX/UI sibling, set
its sequence to the upstream's sequence + 1 so it sorts after the design
it waits on — list_pending ordering and the panel now show UX ahead of
the implementation it gates, in either delegation order.

Adds TaskService.set_sequence (the sibling-ordering field is a service
write; it carries no claim-gating semantics — dependencies gate claims).

* feat(gateway): make the backend cell depend on UX too

UX/UI design defines the screens and API contracts both implementation
cells build against, so the backend cell — not just the frontend — waits
on the UX/UI cell task in a product fan-out and sorts after it. Wires in
either delegation order: a backend task delegated after UX gets the
dependency directly; a UX task delegated after a still-pending backend
sibling retro-wires it.

Mirrors the existing frontend wiring (_depend_backend_on_ux and
_depend_pending_backends_on_ux). Backend is held by the same dependency
gate, so it costs no extra dispatch churn.

* fix(websocket): forward notification acks instead of logging them incomplete

The bridge handler serves both notification.sent and notification.acked,
but acked events carry `agent_id` (the acking agent) rather than
`recipient_id`, so every acknowledgement tripped the missing-field guard
and logged "Incomplete notification event" instead of reaching the panel.
Accept either field as the recipient.

* feat(api): hint the full UUID when a truncated task id fails validation

Agents copy the 8-character task prefix the system shows them (the commit
prefix, task summaries) and send it as task_id, which fails UUID
validation with an opaque "invalid length" 422 and wastes a call. The
request-validation handler now detects a task_id UUID error and attaches
a `remediate` hint telling the agent to retry with the full 36-character
UUID from its task envelope.

* fix(audit): record the blocked transition when a task is escalated

Escalation sets a task to blocked by writing task.status directly, which
bypassed the validated transition helper and so never emitted a
task.blocked audit row — the lifecycle moved but the Auditor saw nothing.
Extract the audit emit from the central transition helper into
_emit_status_transition_audit and call it from the escalate path,
capturing the prior status and outgoing owner before reassignment so the
row is attributed correctly.

* fix(docs): stop doubling the docs path so design specs index into RAG

The documenter sometimes hands a doc path already rooted at docs/, and
joining it onto DOCS_BASE_PATH (/app/docs) produced /app/docs/docs/...,
so the file was never found and the spec never indexed — the frontend
cell could not retrieve the UX design over RAG. Normalize the path
before joining: trust an absolute path, otherwise strip a single
redundant leading docs/ segment.

* feat(security): let the control panel authenticate in secure mode

With ROBOCO_AGENT_AUTH_REQUIRED=true every request must carry a valid
HMAC token, which locked the human control panel out — it sends role
headers but no token. nginx, the only trusted hop between the browser
and the API, now injects the CEO token on /api and /ws, so the browser
never holds the signing secret. The injected value is just the existing
per-agent token issued for the CEO identity (issue_panel_token), so the
token-verification path is unchanged. An empty value (dev/header-trust
mode) renders to no header.

`make panel-token` prints the value; set it as ROBOCO_PANEL_AGENT_TOKEN
in .env before enabling secure mode. .env.example and the README
Security section document the flow.

* chore(compose): consolidate the two compose files into one

docker-compose.yml and docker-compose.yaml had diverged: .yml — the file
Docker actually uses — carried ROBOCO_PUBLIC_BASE_URL but was missing the
/app/manifests bind-mount, while .yaml had the manifests mount but not
the base URL. Merge the union into docker-compose.yml and delete the
duplicate so there is one source of truth and no "multiple config files"
warning.

This activates the manifests mount in the deployed file: without it the
orchestrator writes per-agent tool manifests to its ephemeral container
fs, they never reach the host for the daemon to bind-mount, and agents
fall back to all-verbs registration. Drop the stale .yaml reference from
the config.py docstring, the labeler, and the CI path filters.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-05 16:35:22 +02:00

692 lines
23 KiB
Python

"""
Agent Configuration
Single source of truth for agent roles, teams, and cell memberships.
All enforcement modules and MCP servers should import from here.
PERMISSION ARCHITECTURE
-----------------------
The system uses TWO complementary permission layers:
1. MCP Layer (this module - agents_config.py):
- Controls which MCP tools agents can access
- Uses role-based helper functions: can_create_tasks(), can_cancel_tasks(), etc.
- Works with agent slugs/UUIDs directly
- Determines tool visibility at MCP server registration time
2. API Layer (roboco/services/permissions.py):
- Controls fine-grained API endpoint access
- Uses TaskAction enum and PermissionService class
- Works with AgentContext and Team enums
- Validates at request time with team-scoped checks
Both layers derive from the same source data (role definitions here) but serve
different purposes. MCP is coarse-grained (tool-level), API is fine-grained
(action + team context).
"""
import hashlib
import hmac
import os
from typing import Final
from roboco.foundation import identity as _foundation
from roboco.foundation.policy import communications as _comms
from roboco.models.base import NotificationPriority, NotificationType
from roboco.seeds.initial_data import AGENT_UUIDS, CEO_AGENT_ID
# Env var containing the HMAC secret used to sign agent auth tokens.
# Must be set in orchestrator + API container environments; if missing,
# token verification refuses every token (fail-closed). Generate with:
# python -c 'import secrets; print(secrets.token_hex(32))'
_AUTH_SECRET_ENV: Final[str] = "ROBOCO_AGENT_AUTH_SECRET"
def _auth_secret() -> bytes | None:
"""Return the HMAC secret bytes, or None when unset."""
v = os.environ.get(_AUTH_SECRET_ENV, "")
return v.encode("utf-8") if v else None
def _signing_payload(agent_id: str, role: str, team: str) -> bytes:
"""Canonical message for HMAC — all inputs lower-cased and stripped."""
parts = (
(agent_id or "").strip().lower(),
(role or "").strip().lower(),
(team or "").strip().lower(),
)
return ":".join(parts).encode("utf-8")
def issue_agent_token(agent_id: str, role: str, team: str = "") -> str:
"""Mint an auth token the orchestrator injects into an agent's env.
The token is a hex HMAC-SHA256 of `agent_id:role:team` signed with
ROBOCO_AGENT_AUTH_SECRET. It binds the agent's identity to the role
and team headers — if the agent later lies about its role, the
server-side HMAC won't match.
"""
secret = _auth_secret()
if not secret:
# Unset secret ⇒ tokens are meaningless; return a sentinel the
# verifier will reject. Caller should detect and log this.
return "UNSIGNED"
return hmac.new(
secret, _signing_payload(agent_id, role, team), hashlib.sha256
).hexdigest()
def verify_agent_token(token: str, agent_id: str, role: str, team: str = "") -> bool:
"""Return True iff `token` is a valid HMAC for (agent_id, role, team).
Fails closed when the secret is unset or the token is the UNSIGNED
sentinel.
"""
secret = _auth_secret()
if not secret or not token or token == "UNSIGNED":
return False
expected = hmac.new(
secret, _signing_payload(agent_id, role, team), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, token)
def issue_panel_token() -> str:
"""Mint the token the control panel presents to act as the CEO.
The panel calls the API as the CEO identity — ``X-Agent-Id`` = the CEO
UUID, ``X-Agent-Role`` = ``ceo``, and no team header — so the token is
signed for exactly those values (empty team). In secure mode nginx injects
it as ``X-Agent-Token`` so the browser never holds the signing secret;
this is just the existing per-agent token issued for the CEO identity, so
the verification path is unchanged. Returns ``UNSIGNED`` when the secret is
unset (same fail-closed contract as ``issue_agent_token``).
"""
return issue_agent_token(CEO_AGENT_ID, "ceo", "")
# Reverse mapping: UUID -> slug (computed from seeds)
_UUID_TO_SLUG: Final[dict[str, str]] = {
uuid: slug for slug, uuid in AGENT_UUIDS.items()
}
def _resolve_to_slug(agent_id: str) -> str:
"""Resolve agent ID (UUID or slug) to slug."""
return _UUID_TO_SLUG.get(agent_id, agent_id)
# =============================================================================
# AGENT ROLE MAPPINGS
# =============================================================================
# Agent catalog data is canonicalized in roboco/foundation/identity.py.
# These string-keyed maps are kept for backwards compatibility with code
# that types role/team as `str` rather than the foundation enums.
# Derived at module load — adding an agent edits foundation/identity.py only.
AGENT_ROLE_MAP: dict[str, str] = {
slug: row.role.value
for slug, row in _foundation.AGENTS.items()
if row.role != _foundation.Role.SYSTEM # exclude sentinel from string-keyed map
}
AGENT_TEAM_MAP: dict[str, str] = {
slug: row.team.value
for slug, row in _foundation.AGENTS.items()
if row.role != _foundation.Role.SYSTEM
}
CELL_MEMBERS: dict[str, list[str]] = {
team.value: sorted(_foundation.slugs_for_team(team))
for team in sorted(_foundation.CELL_TEAMS, key=lambda t: t.value)
}
# All agent IDs
ALL_AGENTS: Final[list[str]] = list(AGENT_ROLE_MAP.keys())
# Board members
BOARD_MEMBERS: Final[list[str]] = ["product-owner", "head-marketing", "auditor"]
# All documenters (cross-cell) — used for docs-write workspace permissions
ALL_DOCS: Final[list[str]] = ["be-doc", "fe-doc", "ux-doc"]
# `PM_ROLES` is canonical in foundation.identity (CELL_PM + MAIN_PM only).
# This file's historical 5-role set is renamed to TASK_CREATOR_ROLES — it
# represents "roles that can call task.create", not the PM hierarchy.
# StrEnum members hash like their .value strings, so `role_str in TASK_CREATOR_ROLES`
# still works for str inputs from get_agent_role().
TASK_CREATOR_ROLES: Final[frozenset[_foundation.Role]] = frozenset(
{
_foundation.Role.CELL_PM,
_foundation.Role.MAIN_PM,
_foundation.Role.PRODUCT_OWNER,
_foundation.Role.HEAD_MARKETING,
_foundation.Role.CEO,
}
)
# Escalation chain - who each agent escalates to
ESCALATION_CHAIN: Final[dict[str, str]] = {
# Developers → Cell PM
"be-dev-1": "be-pm",
"be-dev-2": "be-pm",
"fe-dev-1": "fe-pm",
"fe-dev-2": "fe-pm",
"ux-dev-1": "ux-pm",
"ux-dev-2": "ux-pm",
# QA → Cell PM
"be-qa": "be-pm",
"fe-qa": "fe-pm",
"ux-qa": "ux-pm",
# Documenters → Cell PM
"be-doc": "be-pm",
"fe-doc": "fe-pm",
"ux-doc": "ux-pm",
# Cell PM → Main PM
"be-pm": "main-pm",
"fe-pm": "main-pm",
"ux-pm": "main-pm",
# Main PM → Product Owner
"main-pm": "product-owner",
# Product Owner → CEO (final escalation)
"product-owner": "ceo",
"head-marketing": "ceo",
"auditor": "ceo",
}
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def get_agent_role(agent_id: str) -> str:
"""Get the role for an agent. Accepts both UUID and slug."""
slug = _resolve_to_slug(agent_id)
return AGENT_ROLE_MAP.get(slug, "unknown")
def get_agent_team(agent_id: str) -> str | None:
"""Get the team for an agent. Accepts both UUID and slug."""
slug = _resolve_to_slug(agent_id)
return AGENT_TEAM_MAP.get(slug)
def get_agent_cell(agent_id: str) -> str | None:
"""Get the cell an agent belongs to. Accepts both UUID and slug."""
return get_agent_team(agent_id)
def get_cell_members(cell: str) -> list[str]:
"""Get all members of a cell."""
return CELL_MEMBERS.get(cell, [])
def is_pm(agent_id: str) -> bool:
"""Check if agent is a PM (cell PM or main PM)."""
role = get_agent_role(agent_id)
return role in ("cell_pm", "main_pm")
def is_board_member(agent_id: str) -> bool:
"""Check if agent is a board member."""
return agent_id in BOARD_MEMBERS
def is_management(agent_id: str) -> bool:
"""Check if agent is in management (PM, Board, CEO)."""
role = get_agent_role(agent_id)
return role in (
"cell_pm",
"main_pm",
"product_owner",
"head_marketing",
"auditor",
"ceo",
)
def is_ceo(agent_id: str) -> bool:
"""Check if agent is CEO (has full bypass on all permissions)."""
return get_agent_role(agent_id) == "ceo"
def can_send_notifications(agent_id: str) -> bool:
"""Whether this agent's role may call notify(). Canonical in foundation."""
try:
return _foundation.Role(get_agent_role(agent_id)) in _comms.NOTIFY_SENDER_ROLES
except ValueError:
return False
def can_create_tasks(agent_id: str) -> bool:
"""Check if agent can create tasks (PMs, board, and CEO)."""
role = get_agent_role(agent_id)
return role in TASK_CREATOR_ROLES
def can_assign_tasks(agent_id: str) -> bool:
"""Check if agent can assign tasks (PMs, board, and CEO)."""
role = get_agent_role(agent_id)
return role in TASK_CREATOR_ROLES
# Cancel roles match task_lifecycle.py - CEO and Auditor cannot cancel (they observe)
_CANCEL_ROLES: Final[set[str]] = {
"cell_pm",
"main_pm",
"product_owner",
"head_marketing",
}
def can_cancel_tasks(agent_id: str) -> bool:
"""Check if agent can cancel tasks (PMs and board, not CEO/Auditor)."""
role = get_agent_role(agent_id)
return role in _CANCEL_ROLES
def get_escalation_target(agent_id: str) -> str | None:
"""Get the escalation target for an agent."""
return ESCALATION_CHAIN.get(agent_id)
def get_pm_for_team(team: str) -> str | None:
"""Get the cell PM for a team."""
team_to_pm = {
"backend": "be-pm",
"frontend": "fe-pm",
"ux_ui": "ux-pm",
}
return team_to_pm.get(team)
def get_pm_for_agent(agent_id: str) -> str | None:
"""
Get the PM responsible for an agent.
- For cell members: their cell PM
- For cell PMs: main-pm
- For main PM: product-owner
"""
role = get_agent_role(agent_id)
# Cell PM escalates to main-pm
if role == "cell_pm":
return "main-pm"
# Main PM escalates to product-owner
if role == "main_pm":
return "product-owner"
# Everyone else escalates to their cell PM
return get_escalation_target(agent_id)
# =============================================================================
# CHANNEL ACCESS RULES
# =============================================================================
#
# Channel ACL is canonicalized in foundation.policy.communications.CHANNELS.
# This slug-keyed dict-of-string-lists derives from the role-keyed foundation
# data. Adding a channel or changing its membership edits foundation.CHANNELS;
# this dict updates at module load.
#
# Derivation rules:
# - read: roles in (read_roles - silent_roles), filtered by team_scope
# - write: roles in write_roles, filtered by team_scope
# - silent: roles in silent_roles, filtered by team_scope
# Cross-cell roles (MAIN_PM, AUDITOR, CEO, board) are not subject to team_scope;
# only cell-member roles (DEVELOPER/QA/DOCUMENTER/CELL_PM) are filtered.
# Cell-member roles subject to team_scope filtering. Lifted to module scope so
# tests and downstream consumers can introspect the rule.
_TEAM_SCOPED_ROLES: Final[frozenset[_foundation.Role]] = frozenset(
{
_foundation.Role.DEVELOPER,
_foundation.Role.QA,
_foundation.Role.DOCUMENTER,
_foundation.Role.CELL_PM,
}
)
def _slugs_for_role_set(
role_set: frozenset[_foundation.Role],
team_scope: _foundation.Team | None,
) -> list[str]:
"""Expand a role-set to sorted agent slugs, honoring optional team_scope.
A slug qualifies when its role is in `role_set` AND, if its role is in
_TEAM_SCOPED_ROLES and team_scope is set, its team matches team_scope.
The system sentinel is always excluded.
"""
out: list[str] = []
for slug, row in _foundation.AGENTS.items():
if slug == "system":
continue
if row.role not in role_set:
continue
if (
team_scope is not None
and row.role in _TEAM_SCOPED_ROLES
and row.team != team_scope
):
continue
out.append(slug)
return sorted(out)
CHANNEL_ACCESS: Final[dict[str, dict[str, list[str]]]] = {
slug: {
"read": _slugs_for_role_set(
spec.read_roles - spec.silent_roles, spec.team_scope
),
"write": _slugs_for_role_set(spec.write_roles, spec.team_scope),
"silent": _slugs_for_role_set(spec.silent_roles, spec.team_scope),
}
for slug, spec in _comms.CHANNELS.items()
}
# =============================================================================
# NOTIFICATION PERMISSIONS
# =============================================================================
# =============================================================================
# PERMISSION LEVEL HIERARCHY
# =============================================================================
# Maps role strings to permission level names
# This is the SINGLE SOURCE OF TRUTH for role hierarchy
# Used by PermissionService to build AgentRole -> PermissionLevel mapping
ROLE_PERMISSION_LEVELS: Final[dict[str, str]] = {
"system": "CEO", # System/orchestrator has CEO-level access for internal operations
"ceo": "CEO",
"product_owner": "BOARD",
"head_marketing": "BOARD",
"auditor": "AUDITOR",
"main_pm": "MAIN_PM",
"cell_pm": "CELL_PM",
"developer": "CELL_MEMBER",
"qa": "CELL_MEMBER",
"documenter": "CELL_MEMBER",
}
# =============================================================================
# NOTIFICATION PERMISSIONS
# =============================================================================
# Sender allowlist now lives in foundation.policy.communications.NOTIFY_SENDER_ROLES.
# Scope rules (cell / all / list) live in services/permissions.py since they
# depend on AgentContext (role + team) — not pure foundation data.
VALID_NOTIFICATION_TYPES: Final[frozenset[str]] = frozenset(
t.value for t in NotificationType
)
VALID_NOTIFICATION_PRIORITIES: Final[frozenset[str]] = frozenset(
p.value for p in NotificationPriority
)
# =============================================================================
# A2A AGENT SKILLS (for Agent Cards)
# =============================================================================
# Skills define what each role can do - used for A2A discovery
ROLE_SKILLS: Final[dict[str, list[dict[str, str | list[str]]]]] = {
"developer": [
{
"id": "code_implementation",
"name": "Code Implementation",
"description": "Implement features, fix bugs, write production code",
"tags": ["coding", "implementation", "bugfix"],
},
{
"id": "code_review",
"name": "Code Review",
"description": "Review code changes and provide feedback",
"tags": ["review", "feedback"],
},
{
"id": "technical_research",
"name": "Technical Research",
"description": "Research technical solutions and approaches",
"tags": ["research", "analysis"],
},
],
"qa": [
{
"id": "code_review",
"name": "Code Review",
"description": "Review code for bugs, security issues, and quality",
"tags": ["review", "quality", "security"],
},
{
"id": "test_validation",
"name": "Test Validation",
"description": "Validate test coverage and test quality",
"tags": ["testing", "validation"],
},
{
"id": "security_audit",
"name": "Security Audit",
"description": "Audit code for security vulnerabilities",
"tags": ["security", "audit"],
},
],
"documenter": [
{
"id": "documentation",
"name": "Documentation",
"description": "Create and maintain documentation",
"tags": ["docs", "writing"],
},
{
"id": "handoff_review",
"name": "Handoff Review",
"description": "Review and document task handoffs",
"tags": ["handoff", "review"],
},
],
"cell_pm": [
{
"id": "task_management",
"name": "Task Management",
"description": "Create, assign, and manage tasks within the cell",
"tags": ["planning", "coordination"],
},
{
"id": "blocker_resolution",
"name": "Blocker Resolution",
"description": "Help resolve blockers and coordinate resources",
"tags": ["support", "coordination"],
},
{
"id": "qa_coordination",
"name": "QA Coordination",
"description": "Coordinate QA reviews and approvals",
"tags": ["qa", "approval"],
},
],
"main_pm": [
{
"id": "task_triage",
"name": "Task Triage",
"description": "Triage and distribute tasks to cell PMs",
"tags": ["triage", "distribution"],
},
{
"id": "cross_cell_coordination",
"name": "Cross-Cell Coordination",
"description": "Coordinate work across multiple cells",
"tags": ["coordination", "cross-team"],
},
{
"id": "escalation_handling",
"name": "Escalation Handling",
"description": "Handle escalated issues from cell PMs",
"tags": ["escalation", "support"],
},
],
"product_owner": [
{
"id": "requirements_clarification",
"name": "Requirements Clarification",
"description": "Clarify product requirements and priorities",
"tags": ["requirements", "product"],
},
{
"id": "feature_approval",
"name": "Feature Approval",
"description": "Approve feature implementations",
"tags": ["approval", "product"],
},
],
"head_marketing": [
{
"id": "market_analysis",
"name": "Market Analysis",
"description": "Provide market context and analysis",
"tags": ["marketing", "analysis"],
},
],
"auditor": [
{
"id": "quality_audit",
"name": "Quality Audit",
"description": "Audit quality and compliance",
"tags": ["audit", "quality"],
},
],
}
def get_agent_skills(agent_id: str) -> list[dict]:
"""Get A2A skills for an agent based on their role."""
role = get_agent_role(agent_id)
return list(ROLE_SKILLS.get(role, []))
# =============================================================================
# A2A PERMISSION ENFORCEMENT
# =============================================================================
# A2A follows the same hierarchy as escalations and notifications:
# - Within cell: Direct A2A allowed
# - Cross-cell: Must go through Cell PM → Main PM
# - To board: Must go through Main PM
# - To CEO: Must go through board
# Board roles (PO + Head Marketing + Auditor) — derived from foundation.
# Main PM is intentionally NOT in this set; main_pm is a layer above cells
# but below the board.
_BOARD_ROLES: Final[frozenset[_foundation.Role]] = _foundation.BOARD_ROLES
_MAIN_PM_TARGETS: Final[frozenset[str]] = frozenset(
{"cell_pm", "main_pm", "product_owner", "head_marketing", "auditor"}
)
def _check_cell_pm_a2a(
from_team: str | None, to_agent: str, to_role: str, to_team: str | None
) -> tuple[bool, str | None]:
"""Check A2A permissions for cell PM."""
# Own cell, other PMs, or main-pm
if to_team == from_team or to_role in ("cell_pm", "main_pm"):
return True, None
# Board/CEO - escalate
if to_role in _BOARD_ROLES:
return False, f"Cell PMs cannot A2A {to_role}. Escalate through main-pm."
# Other cell members
return False, f"Cannot A2A {to_agent} (different cell). Use main-pm."
def _check_cell_member_a2a(
from_agent: str, from_team: str, to_agent: str, to_role: str, to_team: str | None
) -> tuple[bool, str | None]:
"""Check A2A permissions for cell members (dev, qa, doc)."""
cell_pm = get_pm_for_team(from_team)
# Same cell - allowed
if to_team == from_team:
return True, None
# Cross-cell
if to_team:
target_pm = get_pm_for_team(to_team)
return (
False,
f"Cannot A2A {to_agent} (cell: {to_team}). "
f"Ask {cell_pm} to coordinate with {target_pm}.",
)
# Management - not direct
return False, f"Cannot A2A {to_role}. Route: {from_agent}{cell_pm} → main-pm."
def _check_main_pm_a2a(to_role: str, to_team: str | None) -> tuple[bool, str | None]:
"""Check A2A permissions for main PM."""
if to_role in _MAIN_PM_TARGETS:
return True, None
pm = get_pm_for_team(to_team) if to_team else "cell-pm"
return False, f"Main PM cannot A2A {to_role}s. Route through {pm or 'cell-pm'}."
def can_a2a_direct(from_agent: str, to_agent: str) -> tuple[bool, str | None]:
"""
Check if from_agent can send A2A directly to to_agent.
Returns (allowed, error_message). Error explains who to contact instead.
"""
from_role = get_agent_role(from_agent)
to_role = get_agent_role(to_agent)
from_team = get_agent_team(from_agent)
to_team = get_agent_team(to_agent)
# CEO is human - cannot A2A, use notifications
if to_role == "ceo":
return False, "CEO is human. Use notify() instead of A2A."
# Board → board/main-pm (not CEO, not cells directly)
if from_role in ("product_owner", "head_marketing", "auditor"):
return (
(True, None)
if to_role in _BOARD_ROLES or to_role == "main_pm"
else (False, f"Board cannot A2A {to_role}s. Route through main-pm.")
)
# Dispatch to role-specific handlers
handlers: dict[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),
}
if from_role in handlers:
return handlers[from_role]
# Cell members - use helper
if from_team:
return _check_cell_member_a2a(from_agent, from_team, to_agent, to_role, to_team)
return False, f"A2A from {from_agent} to {to_agent} not permitted."
def get_a2a_route_hint(from_agent: str, to_agent: str) -> str:
"""Get a hint for how to properly route an A2A message."""
to_role = get_agent_role(to_agent)
from_team = get_agent_team(from_agent)
to_team = get_agent_team(to_agent)
# CEO is human - no A2A route, use notifications
if to_role == "ceo":
return "CEO is human. Use notify() for CEO communication."
# Cross-cell routing
if from_team and to_team and from_team != to_team:
from_pm = get_pm_for_team(from_team)
to_pm = get_pm_for_team(to_team)
return f"Route: {from_agent}{from_pm}→main-pm→{to_pm}{to_agent}"
# Cell member to management
if from_team:
cell_pm = get_pm_for_team(from_team)
return f"Route: {from_agent}{cell_pm}→main-pm→board"
return "Use escalate_up() for proper escalation."