Files
roboco/roboco/services/a2a.py
T
3849c1737e feat(video): switch 0.19.0 renderer Remotion → HyperFrames (HTML-native, Apache-2.0) (#314)
* feat(video): rewrite sidecar render core to HyperFrames (in place)

* feat(video): convert motion compositions from Remotion TSX to HyperFrames HTML

* refactor(video): rename render client to video_renderer_client (renderer-agnostic)

* chore(video): rename remotion-renderer prose in test_video_pipeline docstrings

* chore(video): rename sidecar to video-renderer + add system ffmpeg for HyperFrames

* chore(video): rename stray remotion-renderer refs in sidecar + py docstrings (controller cleanup)

* chore(video): fix stale Remotion API names in Dockerfile comment (controller cleanup)

* docs(video): rewrite video-engine prose for HyperFrames + add map entry + folded prose fixes

* docs(video): add trailing newline to docs/map/video-engine.md (controller cleanup)

* chore(video): drop internal spec refs + minio/test suppressions (folded hygiene)

* fix(video): reclaim outDir on createRenderJob throw + hide empty 4th highlight

Final whole-branch review (Opus) triaged two FIX items from the SDD nits
ledger; the rest ship as-is.

- render.js: a synchronous throw from createRenderJob (post-mkdtemp, not
  awaited) left an empty outDir on disk — the outer catch only reclaimed
  extractDir. Reclaim outDir too when it exists, and correct the stale
  comment that claimed the out dir was never created.
- {vertical,square}.html: the 4th highlights <li> lived in the DOM hidden
  only by JS, so a no-JS / failed-script render would show an empty bullet.
  Start it style="display:none" and reveal on populate, so an unscripted
  render shows nothing instead.

Vitest smoke (release-announcement.test.js) 4/4 green; render.js syntax
checked. Python suite untouched by this fix (JS/HTML only).

* fix(video): type _override_db yield as AsyncSession | None

T7 widened _build_app's db_session param to AsyncSession | None (to drop the
4x # type: ignore[arg-type] on the DB-independent _build_app(None, ...) calls)
but left the inner _override_db fixture typed AsyncIterator[AsyncSession] —
so 'yield db_session' yielded AsyncSession | None into a declared AsyncSession,
and mypy failed at test_video_routes.py:177 ('Incompatible types in yield').

The DB-independent media tests pass db_session=None deliberately: their route
uses a monkeypatched task service and never awaits the session, so yielding
None is safe at runtime. Type the override's yield as AsyncSession | None to
match — no cast, no # type: ignore, no assert, runtime behavior unchanged.
The 3 media tests (3 passed) and the 19 db-gated tests (skipped locally) hold.

* chore(gate): skip .superpowers scratch in markdown prose gate

reflow_md.py walks the filesystem via rglob('*.md') and skips tooling dirs
(.venv, .mypy_cache, .pytest_cache, ...) but not .superpowers/ — the
superpowers SDD workflow's scratch dir (briefs, reports, progress ledger,
all gitignored). A dev running SDD locally would hit a false markdown-prose
gate failure on those transient files. Add .superpowers to SKIP_DIRS,
consistent with the existing tooling-scratch exclusions.

* fix(video): validate composition_id to close path traversal (CodeQL)

compositionId flowed unvalidated from the POST body into path.join
under extractDir/motion/compositions/, so a '../..'-style value could
escape the composition dir (CodeQL: Uncontrolled data used in path
expression). Validate at the trust boundary in server.js
(/^[A-Za-z0-9_-]+$/) and add a path.resolve + startsWith containment
check in render.js so it stays safe regardless of caller.

* fix(mcp): send X-Agent-Token + X-Agent-Team from flow/do servers

flow_server._build_headers and do_server._build_headers constructed
only X-Agent-ID/Role/Correlation-ID, omitting X-Agent-Token and
X-Agent-Team (unlike ApiClient._get_agent_headers used by the other
MCP servers). Latent since the gateway refactor — surfaced when
ROBOCO_AGENT_AUTH_REQUIRED=true was armed on the NAS, 401-ing every
flow/do verb with 'Missing X-Agent-Token header'. Add both headers
(mirroring ApiClient) so the HMAC gate passes. Tests assert the
headers are now injected.

* [video-engine] Per-project video_engine_enabled opt-in toggle

Mirrors ci_watch_enabled (migration 048): the global
ROBOCO_VIDEO_ENGINE_ENABLED flag arms the subsystem; the new
projects.video_engine_enabled column (migration 063) opts a repo into
authoring against its motion/ dir. VideoEngine._opted_in_project no-ops
open_video_task at the single chokepoint covering all three trigger
paths (on-release, on-spotlight, CEO on-demand) until the operator
flips it in the panel edit-project dialog. Existing projects stay
opted out (server_default=false).

* fix(auth): send X-Agent-Token + X-Agent-Team from all agent->API call sites

The prior fix (6ed4e139) covered the flow/do MCP servers but missed four
other agent->orchestrator call sites that built the header dict by hand
and omitted X-Agent-Token and/or X-Agent-Team. With ROBOCO_AGENT_AUTH_REQUIRED
armed on the NAS, every one 401s:

- agent_sdk/server.py: the session-end post-mortem flush
  (/api/journals/me/entries), A2A persistence + offline fallback
  (/api/a2a/*), and the stopped-without-transition auto-substitute
  (/api/tasks/auto-substitute) — all sent only X-Agent-ID/Role, so each
  401'd 'Missing X-Agent-Token'. Add a shared _agent_headers() helper
  (mirroring flow_server._build_headers) and route all four through it.
- agent_sdk/secretary_driver.py: _headers() sent the token but not the
  team, so the HMAC gate 401'd with signature mismatch (secretary is
  board-team; token signed with team='board', verified with team='').
  Add the team header.
- mcp/git_readonly.py: the read-only git MCP sent only X-Agent-ID/Role
  — no token, no team — so /api/git/* 401'd once auth was armed. Convert
  the static _HEADERS to a _headers() helper with team + token.
- runtime/orchestrator.py: the cell-PM auto-submit self-API call acted
  as a PM with a hand-built {X-Agent-ID, X-Agent-Role} dict — no token,
  no team — 401ing under auth-required. Add _agent_api_headers(uuid,
  role) mirroring _system_api_headers, and use it.

Tests: _agent_headers round-trip (token + team, team-omitted when None),
_agent_api_headers carries a signed PM token + team.

* [auth] Omit UNSIGNED self-call token in dev mode + video-engine test mypy fix

_agent_api_headers sent the UNSIGNED sentinel when ROBOCO_AGENT_AUTH_SECRET
was unset, but the dev-mode middleware rejects a presented-but-unverifiable
token with 401 signature mismatch (while accepting a missing one). The
cell-PM auto-submit self-call 401'd in every dev run, regressing
test_auto_submit_cuts_the_pm_turn. Attach the token only when a secret is
set. Also fix the FromClause.update mypy error in the per-project
video-engine opt-out test (ORM row load + flush).

* [auth] Omit UNSIGNED agent token at every agent->API call site

The orchestrator injects ROBOCO_AGENT_TOKEN=UNSIGNED when the HMAC secret
is unset at spawn. The API middleware rejects a presented-but-unverifiable
token with 401 'signature mismatch' even in dev mode (auth not required),
so forwarding UNSIGNED turned every flow/do/SDK/secretary/git verb into a
401 — the live pr_reviewer/i_am_idle signature-mismatch loop. Omit the
header when the token is the UNSIGNED sentinel at all five agent-side
header builders; dev accepts a missing token, prod 401s with 'Missing
X-Agent-Token' (the clear respawn-with-secret signal). Add a structlog
diagnostic on the middleware reject path so the next mismatch logs the
exact (id, role, team, token_unsigned, auth_required) inputs.

* [auth] Self-heal stale agent tokens at orchestrator startup

A token is signed once at spawn. If ROBOCO_AGENT_AUTH_SECRET drifts
afterwards (a .env change, a compose recreate that reloads the
orchestrator's env without recreating agent containers, an image
redeploy), the surviving agent keeps sending its old token and the
middleware 401s every verb with 'signature mismatch'. The container
stays alive heartbeating, so the reaper never reclaims it and no fresh
agent spawns: the fleet stalls.

_heal_stale_agent_tokens runs at startup (before _readopt_running_agents)
and kills each running agent container whose baked-in token no longer
verifies against the current secret, so normal dispatch re-spawns it
with a freshly signed token. Inert when the secret is unset (dev):
verify fails for every token without a secret, so the heal would kill
the whole fleet without this gate. Best-effort: a probe failure leaves
the container alone (the reaper still covers it).

* [auth] Sign agent token over the UUID, not the slug (pr_reviewer 401 root cause)

The token was signed over the agent slug (_append_agent_auth_env) while the
MCP servers send X-Agent-ID as the agent UUID (_generate_mcp_config, since
453a7ae2 — gateway v1 parses X-Agent-ID as Annotated[UUID]). The middleware
verified HMAC(uuid:role:team) against a slug-signed token → 'signature
mismatch', token_unsigned=false. Latent for 2 months until 6ed4e139/53391f22
made the MCP servers forward the token.

The c0328971 startup heal missed it: docker exec printenv reads the
container-level ROBOCO_AGENT_ID (the slug), so the heal verified the
slug-signed token against the slug → matched → didn't kill the stale
container, which kept 401ing (its MCP server sends the UUID).

Fix: sign the token over the UUID, set the container ROBOCO_AGENT_ID to the
UUID too (so the SDK server — which inherits container env, not the MCP
manifest env — sends UUID consistently), and resolve the container-env id to
its UUID in _heal_stale_agent_tokens so pre-fix stale containers are evicted
on next restart. Regression test: test_heal_kills_slug_env_container_with_slug_signed_token.

* [scan] gate A2A/notification/stream agent-id deps under cloud auth (C1)

* [scan] omit UNSIGNED agent token from MCP server headers (H1)

* [scan] fail loud when cloud auth and nginx CEO-token are both armed (H2)

* [scan] cache last-known-good auth-probe result in panel proxy (C2)

* [respawn] Tripped breaker self-heals after a cooldown

A DB-durable PM-respawn counter (migration 051 / e2f7097a) wedges forever
once tripped: the only reset was a task status change, which can't happen
while the breaker blocks the spawn. So a deploy that fixes the underlying
loop (auth/prompt/schema) couldn't clear the wedge without manual DELETE
surgery on respawn_tracker — the 2026-07-06 pr-reviewer-1 loop, where the
auth fix cleared the 401 but count=63 survived restart and kept skipping
the dispatcher spawn for an external-PR task.

Freeze last_check at the trip tick and, after pm_respawn_trip_cooldown_seconds
(default 300), let ONE spawn through. A still-wedged task re-trips after the
threshold (bounded re-burn ~3 spawns per window); a fixed one advances and
the status-change path fully resets. Restore re-stamps last_check to now, so
a freshly restored row still trips immediately — durability preserved, which
is why the migration-051 persistence tests still pass.

* [scan] fix test_deps callsites for cloud-auth-gate signature change (C1 followup)

* [scan] per-IP rate limit on /auth/login under cloud auth (L31)

* [scan] Phase 1 auth/security fixes under 0.19.0 CHANGELOG

* [scan] secretary token signs over real team (board) not empty — fixes /api/secretary/* 401 (L31-class)

* [scan] LoginRateLimiter: key off X-Forwarded-For first hop + redis-down fail-open test

nginx is the single entry point; request.client.host is the nginx peer IP,
collapsing every external client into one limiter bucket (self-DoS amp).
Read the downstream client IP from X-Forwarded-For (first hop) / X-Real-IP,
falling back to the peer. Adds coverage for the XFF keying, the redis-down
fail-open branch, and drops a redundant asyncio marker on a sync-TestClient
test.

* [scan] nits: describe login_max_attempts + replace cast with assert in get_current_agent_slug

login_max_attempts was the only bare cloud-auth field; add a Field
description matching the surrounding idiom. Replace cast('str', ctx.slug)
with a runtime assert that fails loud if the cloud-auth ctx invariant
breaks, and drop the now-unused cast import.

* [scan] secretary token: use get_agent_team resolver + complete spawn-shutdown mock team (0dfd45ca followup)

* [scan] require agent HMAC token under cloud_auth (close v1 flow/do header-trust)

* [scan] _require_ceo accepts CEO session cookie under cloud_auth

* [scan] HTTP require_panel_token accepts session cookie under cloud_auth

* [scan] gate /api/settings behind panel token

* [scan] gate unauthenticated /api read routes (agents/a2a-tasks/kanban/usage/rate-limits)

* [scan] hoist deferred test imports to top-level (clear PLC0415)

* [scan] Phase 1b e2e smoke + CHANGELOG

* [scan] add_dependency rejects self-reference + cycle (M18)

* [scan] WorkSessionService.create translates IntegrityError to ConflictError (H10)

* [scan] _qa_or_doc_claim locks the task row FOR UPDATE (M19)

* [scan] docs_complete + mark_pr_created lock the task row FOR UPDATE (H4)

* [scan] gate complete() IN_PROGRESS on leaf/branchless only (H3)

* [scan] _unclaim_from_blocked clears stale pre-block snapshot (H5)

* [scan] admin_set_status terminal guard + skip revision bump under force (M20)

* [scan] cell_pm_complete idempotent pre-check before merge (H7)

* [scan] wrap gateway post-runner side effects in try/except (H6)

* [scan] pass_qa/fail_qa accept AWAITING_QA only (L29)

* [scan] mark_pr_created passes audit_agent_id (L30)

* [scan] phase 2 e2e smoke - one scenario per finding

* [scan] phase 2 quality gate

ruff format + check: green
mypy roboco/: green (357 files)
pytest unit+integration: 6905 passed, 10 pre-existing DB-contamination
  failures (pass in isolation)
e2e smoke: 11 passed, 4 cross-scenario workspace-contamination failures
  (all 6 state-machine scenarios pass individually)

Quality-gate fixes:
- move function-local imports to module top (PLC0415)
- fix M19 regression: submit_for_qa clears active_claimant_id so the
  competing-claimant guard lets the QA claim through
- fix H7 regression: _StubGit gains is_pr_merged_for_task
- fix M19 unit tests: mock session.execute for the FOR UPDATE lock
- e2e H3: notes >= 20 chars; e2e H5: rich i_will_work_on inputs +
  PM unclaims (block reassigns to PM)

* [scan] move active_claimant_id clear into pass_qa/fail_qa + admin_set_status (M19 follow-on)

Phase 2 opus whole-branch review found the M19 follow-on clear lived in
the gateway wrappers (qa_pass/qa_fail) not the transition methods
(pass_qa/fail_qa) themselves. The direct REST routes POST /pass-qa and
POST /fail-qa call the transitions directly, bypassing the wrappers and
leaving the QA's stale active_claimant_id set in AWAITING_DOCUMENTATION
/ NEEDS_REVISION — the competing-claimant guard then rejects the next
legitimate documenter/QA claim. admin_set_status had the same gap for a
non-blocked override into a review/queue state (IN_PROGRESS->AWAITING_QA
left the dev's id, blocking qa_claim).

Root-cause fix: move the clear INTO pass_qa and fail_qa (mirroring
submit_for_qa), add a clear in admin_set_status when
new_status in _REVIEW_QUEUE_STATES and from_status != BLOCKED, and drop
the now-redundant clears + flushes from the qa_pass/qa_fail wrappers.
Every caller is covered; the wrappers keep their actor-mismatch warnings.

Covering tests: test_pass_qa_clears_active_claimant_for_doc_claim
(asserts a subsequent doc_claim succeeds), test_fail_qa_clears_active_claimant,
test_admin_set_status_into_review_queue_clears_active_claimant,
test_admin_set_status_non_review_queue_keeps_active_claimant. Updated
the two wrapper unit tests that asserted the wrapper clears (now the
transition's job).

* [C3] unindex_journal_entry + call from delete_entry

JournalService.delete_entry deleted the DB row but never de-indexed the
RAG chunks, so deleted/private journal content bled forever into RAG
answers and claim-time briefings. Add OptimalService.unindex_journal_entry
mirroring unindex_playbook (vector-store delete_by_source + tracking-row
delete via get_db_context, both idempotent + best-effort), and call it
from delete_entry after the row commit inside a try/except so a de-index
failure never errors the delete.

* [M25] learning_id hashes full content to avoid collision

The memory distiller emits lessons with a fixed 'Problem: …' opening
shape, so two distinct lessons whose first 100 chars match collided on
learning_id = f"lrn-{md5(content[:100])[:12]}". replace_on_reingest then
routed both to the same source URI and the second ingest's replace_chunks
DELETE wiped the first lesson's chunks — silent data loss.

Hash the full content (widening the hex slice 12→16) so distinct bodies
get distinct ids and each retains its chunks.

* [H13] reject non-internal local_llm_base_url at config load

* [M28] bulk-insert learning broadcast instead of N+1

* [M27] mark_read/mark_all_read stamp only the unread rows seen at call time

mark_read and mark_all_read used to zero the unread counter FIRST, then run
a bulk UPDATE … WHERE read_at IS NULL that stamped every inbound unread row.
A send_chat_message committing between the counter-zero and the UPDATE
inserted a new read_at NULL row that the UPDATE then stamped as read — the
new message was silently consumed while the counter stayed 0.

Mirrors get_unread_messages (same file): SELECT the unread message IDs at
call time, UPDATE exactly those IDs, then recompute the unread counter from
the DB via the existing _reset_unread_counter helper. A message arriving
mid-call is not in the selected ID set, so the UPDATE skips it and the
recomputed counter keeps it unread.

* [H12] dedup: exact to_agents predicate + purpose discriminator + ack DEL

* [M23] playbook indexed_ok/indexed_at + startup reconcile of unindexed approved

* [M24] RAG indexing dead-letter + janitor reclaim + failed_index_count health

* [L23] institutional_memory_status sentinel distinguishes below-floor/empty/error/disabled

* [L26] sweep_expired_notifications re-escalates stale unacked ack-required

* [phase3] e2e smoke + CHANGELOG for 0.19.0

* [M24] _reindex_journal_entry honors is_private (C1 review fix)

Dead-letter replay mirrors the original journal._schedule_rag_index path:
a private entry is never indexed into the shared JOURNALS corpus, and a
private learning is still recorded into LEARNINGS as non-shareable.
Previously the replay always called index_journal_entry and skipped
record_learning for private learnings, leaking private content on replay
and dropping the legitimate non-shared learning. Three regression tests.

* [H11] clone via git -c http.extraheader, not URL-embedded PAT

* [H11] _sync_read_clone fetch via http.extraheader, not URL-embedded PAT

Sibling site to the clone fix: the conventions read-clone refresh ran
'git fetch --tags <https://TOKEN@host> <branch>', exposing the PAT in the
fetch argv on the orchestrator host. Mirrors the clone site's per-call
'-c http.extraheader=Authorization: Basic …' prefix + bare URL. SSH URLs
and tokenless public repos unchanged.

* [H11] release_executor clone+push via http.extraheader; delete _inject_token_into_url

* [H8] rebase_onto_base gates on clean tree like pull

* [H9] _link_commit_to_task flushes, doesn't commit out-of-band

* [M38] _pr_is_merged returns None on HTTPError; caller assumes merged

* [M39] _cherry_unmerged_entry marker grep anchored to commit-prefix

* [L1] thread actor_agent_id through update_pr_for_task

* [H8] fix rebase test mocks for clean-tree gate

H8 inserted a 'git status --porcelain' dirty-tree gate at the top of
rebase_onto_base (mirroring pull). The 3 rebase control-flow tests mocked
_run_git with a side_effect list matching the OLD call sequence (no
leading status call), so every call shifted by one and the assertions
missed. Prepend a clean-status result to each list so the gate passes
and the fetch/checkout/reset/rebase/diff/abort/push sequence aligns.
Verified: 16 passed (was 3 failed/13 passed post-H8, 16 passed pre-H8).

* [L2] push --force-with-lease instead of bare --force

* [L1] refresh stale workspace-resolution docstrings

pr_target and _workspace_for_branch still documented the actor →
assigned_to → created_by fallback chain that L1 removed from
_resolve_workspace_agent_id. Update both to the post-L1 actor →
assigned_to → None resolver (project.workspace_path as the final
fallback) so a future reader doesn't rely on a fallback that no
longer exists.

* [M37] merge_pr locks the work_session row FOR UPDATE

* [phase4] e2e smoke + CHANGELOG for 0.19.0

* [phase4] fix M37 test flake + document H8 skip

The opus whole-branch review flagged the M37 concurrency tests as
~50% flaky: both asserted caller A wins the FOR UPDATE race, but
which caller wins the lock is non-deterministic. When B won, the
'assert a_row.merged_by == a_merger' branch flipped false even
though the production code (M37) was correct — exactly one merger
recorded, audit trail intact. Assert the invariant instead: both
rows COMPLETED, both report the same merged_by, value in
{a_merger, b_merger}. Applied to both the unit test and the e2e
twin. Also documents the H8 e2e skip in the module docstring (the
report claimed it was documented there but it wasn't) and drops
the internal 'Phase 4' label from the docstring header in favor of
the public '0.19.0' version anchor.

* [H24] wait_for_ci polls through the window on non-success

* [H25,L34] release mutex orphan-sweep on start + shared redis client

* [M1] tiktok _refresh commits rotated tokens in an independent session

* [H25] drop new type:ignore in orphan-sweep test (constraint cleanup)

* [M2] feature-spotlight re-arms when exploration stale past 2x interval with no live HoM spawn

* [M6,M7] mark_seen after meaningful+project; persist since_id cursor in redis

* [M3,M5] reject() guards COMPLETED; edited_body deferred into the single-flight lock

* [M4] bound list_completed_video_tasks + ix_tasks_source_status_created index (migration 066)

* [M8,M9,L9] pass head_sha to CI gate; _run_git 30s timeout; _commits_since split maxsplit 2

* [M10,L35] dedupe dep_update by (git_url, command); fold redundant per-project queries

* [L36] gather ci_watch telemetry sweep instead of sequential iteration

* [L11] document self_heal fingerprint is stable per-signal by design

* [M11] engine-loop liveness watchdog: heartbeat + 2x-interval staleness alert

* [M21] video render loop commits per-task, not one trailing commit

* [M22] _detect_stuck_tasks skips held-CEO-source tasks

* [L6] video_renderer_client._save writes temp + atomic rename

* [phase5] e2e smoke + CHANGELOG for 0.19.0

* [M11] instrument x_mentions + roadmap engine loops with liveness heartbeats

* [phase5] fix-wave: correct e2e M11 unit-test filename + strengthen failed-cycle heartbeat assertion

* [C4] panel WS: shared /ws/system socket + long-tail retry + pong watchdog

* [H15] video-post-queue caption derived per render (mirror x-post-queue)

* [C4-fix] panel WS: discriminating long-tail tests + drop dead freeze block + evict dead shared conn on manual disconnect

Finding 1 (Critical, websocket.test.ts): the two long-tail-retry tests fired onopen between close cycles, which reset reconnectAttempts to 0 each cycle, so they passed under the pre-fix 3-attempt gate. Rewrote both to NEVER fire onopen between closes, so attempts accumulates: test 1 asserts state stays 'reconnecting' past attempt 3 (old gate would flip 'disconnected' terminal); test 2 asserts a new socket is constructed within 30000ms at attempt 7 where uncapped 5000*1.5^7 ~= 85s (old uncapped code would leave the timer unexpired). Verified both FAIL on a reverted old-shape connection.ts and PASS on the fixed code.

Finding 2 (Important, connection.ts): the 'if (raw >= cap) this.reconnectAttempts = exp' block was a no-op (exp was just read from the same field) and the unconditional increment afterwards grew the counter regardless. Deleted the dead block; kept the Math.min cap on the delay. Replaced the misleading ponytail comment with an accurate one: delay is capped, counter grows unbounded but delay is bounded.

Finding 3 (Important, use-websocket.ts): manual disconnect() tore down the shared conn for all subscribers but left the dead (manualClose=true, never reconnects) entry in _sharedSockets, so a later mount hit the reuse branch, attached a subscriber, replayed 'disconnected', and never called connect(). Added a urlRef and _sharedSockets.delete(url) in the manual disconnect callback so a later mount reopens a fresh conn.

* [H16] settings Save wired to settingsApi (persist + read back)

* [H17] tasks page passes status/team/limit to useTasks (server-side filter)

* [H18] useAgents roster re-derives on live-status change (statusEpoch in queryKey)

* [M40] useMetrics reads agent counts from useAgentStatus cache (dedupe poll)

* [H18] tighten useAgents statusEpoch comment (drop spec ref)

* [M40] drop spec ref + tighten useMetrics comment

* [M41] scorecard refetchInterval 60s -> 5min (25 req/min -> 5)

* [M42] feature-flag off-transition confirm + pending-keys Set

* [M43] X/TikTok credentials clear-behind confirm dialog

* [M44] rate-limit syncFromApi merges (keep fresher hitAt) + A2A reconnect invalidation

* [phase6] proxy.ts cookie-check comment + CHANGELOG Fixed entries

* [phase6] drop stale WS pin-attempts comment + fix tasks-page lead-in

* [H21] type DelegateRequest.estimated_complexity as Complexity (reject critical)

* [H22] type SoftBlockRequest.resolver_type as BlockerResolverType (no silent AGENT fallback)

* [H23] serialize TaskTable.documents into TaskResponse (DocRefResponse)

* [L27] delete SubstituteRequest phantom suggested_role/suggested_team fields

* [L14] Envelope.not_found defaults remediate (guide re-fetch + re-issue)

* [L28] delete unused ListResponse generic (dead code; pagination deferred)

* [H19] _delegate_static_guards allow cell_projects roots (cross-cell MegaTask)

* [M13] MegaTask confirm-batch idempotency key from session_id (SETNX guard + result sidecar)

* [M14] strip assigned_to from MegaTask drafts (no board-owned root-subtask deadlock)

* [H20] thin_routes receiver-gate add/add_all/merge (no false block on set/cache.add)

* [M16] tighten noqa code-capture to [A-Z0-9, ]+ (no false block on natural prose)

* [M45] conventions read-clone force-refetch on read (no 30s stale map window)

* [L25] conventions._resolve returns (root, sha); ORM mutated on the event loop

* [M15] open_conventions_pr force-pushes disposable scaffold branch (no silent None)

* [L24] roadmap cycle completion emits status-transition audit

* [Phase7] CHANGELOG: 15 schema/conventions/MegaTask/API fixed (H21-H23,L27,L14,L28,H19,M13,M14,H20,M16,M45,L25,M15,L24)

* [Phase7] lint gate hygiene: shorten docstring (E501), sort imports (I001), hoist AuditLogTable import (PLC0415)

* [H14] Enable the GROK provider row in _apply_grok so routing reaches the GrokCliProvider

* [M31] Route GROK active-token resolution to usage.json so live usage reflects grok agents

* [M32] Pass cache read/write tokens to calculate_cost in the usage sweep so live cost reflects Anthropic cache spend

* [M33] Park Ollama-Cloud rate limits via a marker map so a glm-5.2:cloud 429 parks instead of crash-respawning

* [M34] Sweep orphan agent_spawn_sessions at startup so crashed-run tokens roll into usage/cost summaries

* [L12] Persist revisit_resets (migration 067) so the PM-respawn breaker's revisit counter survives a restart

* [L18] Date-gate the Sonnet-5 promo revert so billing returns to list rates after 2026-08-31

* [L20] Warn when ROBOCO_GROK_RUN_LOG yields no session id instead of silently falling back to a zero-usage env id

* [phase8] CHANGELOG: LLM provider routing, usage capture, billing fixes

* [phase8] Trailing ruff format hygiene (orchestrator marker tuples, token-sweep test signatures)

* [phase8] Fix mypy: rename GROK-branch tokens var so transcript fallback stays reachable

* [M35] Add an expiring agent-token format (iat/exp) with backward-compatible verify

* [M35] Wire agent-token TTL at spawn (config + orchestrator + grok) so tokens are bounded

* [M36] Add JWT jti claim and re-mint the sliding cookie only near expiry so a stolen cookie's exp is fixed

* [M36] Redis jti revocation: read_token rejects revoked jtis and logout revokes the current jti

* [phase9] CHANGELOG: bound agent tokens + sliding-cookie re-mint window + jti revocation

* [scan-fix] mypy: type-annotate test files for make-quality gate

CI's make quality runs mypy roboco/ tests/; the scan-fix program's local
gate ran mypy roboco/ only, so test files were never type-checked. Fix all
67 errors across 23 test files with real annotations/casts/asserts/dead-code
removal — no # type: ignore / # noqa added.

* [e2e] Per-test DB isolation + dispatcher re-claim before PM complete

* [scan] Regenerate verb tables for delegate Complexity type

* [scan] Reduce 9 xenon C-ranks to B (auth, orchestrator, gateway, services)

* [scan] Restore short-circuit time.time() in verify_agent_token (security path)

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-07 10:09:23 +02:00

1879 lines
65 KiB
Python

"""
A2A (Agent-to-Agent) Protocol Service
Provides business logic for A2A protocol operations including:
- Agent discovery and card generation
- Task lifecycle management via A2A semantics
- Message handling and routing
"""
from datetime import UTC, datetime
from typing import Any, Final, cast
from uuid import UUID
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.agents_config import (
A2A_ALLOWED_PAIRS,
ALL_AGENTS,
get_agent_skills,
get_agent_team,
)
from roboco.config import settings
from roboco.db.tables import (
A2AConversationTable,
A2AMessageTable,
AgentTable,
TaskTable,
)
from roboco.enforcement import A2AAccessDeniedError, validate_a2a_access
from roboco.events import Event, EventType, get_event_bus
from roboco.models.a2a import (
A2AAdminPairSummary,
A2AArtifact,
A2AChatMessage,
A2AConversation,
A2AConversationAdminSummary,
A2AConversationStatus,
A2AConversationSummary,
A2AInboxSummary,
A2AMessage,
A2AMessageKind,
A2APair,
A2ATask,
A2ATaskStatus,
AgentCapabilities,
AgentCard,
AgentProvider,
AgentSkill,
SecurityScheme,
SendMessageRequest,
TextPart,
task_status_to_a2a_state,
)
from roboco.models.base import Team
from roboco.seeds.initial_data import AGENT_UUIDS
logger = structlog.get_logger()
# The A2A_MESSAGE_SENT WS frame (operator live view) carries a briefing-sized
# excerpt only — the full body remains readable via the existing REST message
# endpoints, so the live stream doesn't balloon on long A2A bodies.
_LIVE_VIEW_EXCERPT_CHARS: Final[int] = 240
def _excerpt(text: str, limit: int = _LIVE_VIEW_EXCERPT_CHARS) -> str:
"""Truncate ``text`` to ``limit`` chars, appending an ellipsis marker
only when truncation actually happened."""
return text if len(text) <= limit else text[:limit].rstrip() + "…"
class A2AService:
"""
Service layer for A2A protocol operations.
Provides methods for:
- Building Agent Cards for discovery
- Converting between RoboCo tasks and A2A tasks
- Processing A2A messages
"""
def __init__(self, session: AsyncSession):
"""Initialize with database session."""
self.session = session
@staticmethod
def get_service_endpoint() -> str:
"""Build service endpoint URL from settings.
When the API binds to the all-interfaces address (dev default),
we can't use it for outbound callbacks — dial loopback instead.
`is_unspecified` covers both 0.0.0.0 and ::, and avoids a bare
literal that trips bandit B104.
"""
import ipaddress
try:
is_any_iface = ipaddress.ip_address(settings.host).is_unspecified
except ValueError:
is_any_iface = False
connect_host = "127.0.0.1" if is_any_iface else settings.host
return f"http://{connect_host}:{settings.port}"
@staticmethod
def build_system_agent_card() -> AgentCard:
"""
Build the system-level Agent Card for RoboCo.
This card represents the entire RoboCo system and is served
at /.well-known/agent.json
"""
return AgentCard(
id="roboco-system",
name="RoboCo System",
description=(
"RoboCo is an AI Agentic Company - a virtual organization of "
"AI agents designed to operate as a complete software "
"development workforce."
),
provider=AgentProvider(
organization="RoboCo",
url="https://github.com/roboco",
),
protocol_version="1.0",
service_endpoint=f"{A2AService.get_service_endpoint()}/api/a2a",
version=settings.app_version,
capabilities=AgentCapabilities(
streaming=True,
push_notifications=False,
state_transition_history=True,
),
default_input_modes=["text/plain", "application/json"],
default_output_modes=["text/plain", "application/json"],
skills=[
AgentSkill(
id="software-development",
name="Software Development",
description="Full-stack software development with AI agents",
tags=["development", "coding", "qa", "documentation"],
),
AgentSkill(
id="task-management",
name="Task Management",
description="Create and manage development tasks",
tags=["tasks", "kanban", "planning"],
),
AgentSkill(
id="code-review",
name="Code Review",
description="Review and quality assurance of code",
tags=["qa", "review", "testing"],
),
],
documentation_url="https://github.com/roboco/docs",
security_schemes={
"bearerAuth": SecurityScheme(type="http", scheme="bearer"),
},
security=[{"bearerAuth": []}],
)
async def build_agent_card(self, agent_id: str) -> AgentCard | None:
"""
Build an Agent Card for a specific agent.
Args:
agent_id: Either a UUID string or agent slug
Returns:
AgentCard for the agent, or None if not found
"""
# Try to parse as UUID first
try:
uuid = UUID(agent_id)
result = await self.session.execute(
select(AgentTable).where(AgentTable.id == uuid)
)
except ValueError:
# Not a UUID, try slug lookup
result = await self.session.execute(
select(AgentTable).where(AgentTable.slug == agent_id)
)
agent = result.scalar_one_or_none()
if agent is None:
return None
return self._agent_to_card(agent)
def _agent_to_card(self, agent: AgentTable) -> AgentCard:
"""Convert an AgentTable row to an AgentCard."""
agent_id = str(agent.id)
agent_slug = agent.slug
# Map role to skills
role_skills: dict[str, list[AgentSkill]] = {
"developer": [
AgentSkill(
id="coding",
name="Code Development",
description="Write and implement code",
tags=["development", "coding"],
),
AgentSkill(
id="debugging",
name="Debugging",
description="Debug and fix code issues",
tags=["debugging", "troubleshooting"],
),
],
"qa": [
AgentSkill(
id="testing",
name="Testing",
description="Test code and verify quality",
tags=["qa", "testing"],
),
AgentSkill(
id="review",
name="Code Review",
description="Review code for quality and issues",
tags=["qa", "review"],
),
],
"documenter": [
AgentSkill(
id="documentation",
name="Documentation",
description="Write technical documentation",
tags=["documentation", "writing"],
),
],
"cell_pm": [
AgentSkill(
id="coordination",
name="Task Coordination",
description="Coordinate tasks within the cell",
tags=["management", "coordination"],
),
],
"main_pm": [
AgentSkill(
id="planning",
name="Project Planning",
description="Plan and coordinate across cells",
tags=["management", "planning"],
),
],
}
skills = role_skills.get(agent.role, [])
return AgentCard(
id=agent_id,
name=agent.name,
description=f"{agent.name} - {agent.role} agent in RoboCo",
provider=AgentProvider(
organization="RoboCo",
url="https://github.com/roboco",
),
protocol_version="1.0",
service_endpoint=f"{self.get_service_endpoint()}/api/a2a",
version=settings.app_version,
capabilities=AgentCapabilities(
streaming=True,
push_notifications=False,
state_transition_history=True,
),
default_input_modes=["text/plain", "application/json"],
default_output_modes=["text/plain", "application/json"],
skills=skills,
metadata={
"slug": agent_slug,
"role": agent.role,
"team": agent.team,
},
security_schemes={
"bearerAuth": SecurityScheme(type="http", scheme="bearer"),
},
security=[{"bearerAuth": []}],
)
def task_to_a2a(self, task: TaskTable) -> A2ATask:
"""
Convert a RoboCo TaskTable to A2A Task.
This is the canonical conversion that maintains semantic
mapping between RoboCo's internal task model and A2A.
"""
task_id = str(task.id)
# Get status value as string
if hasattr(task.status, "value"):
status_value = task.status.value
else:
status_value = str(task.status)
a2a_state = task_status_to_a2a_state(status_value)
# Build status message from dev_notes if present
status_message = None
if task.dev_notes:
status_message = A2AMessage(
role="agent",
parts=[TextPart(text=task.dev_notes)],
task_id=task_id,
)
a2a_status = A2ATaskStatus(
state=a2a_state,
message=status_message,
timestamp=task.updated_at or task.created_at,
)
# Build artifacts (placeholder — per-task file outputs are not tracked)
artifacts: list[A2AArtifact] = []
# Build metadata
metadata: dict[str, str | int] = {
"roboco_status": status_value,
"priority": task.priority,
"team": str(task.team),
}
if task.assigned_to:
metadata["assigned_to"] = str(task.assigned_to)
if task.parent_task_id:
metadata["parent_task_id"] = str(task.parent_task_id)
return A2ATask(
id=task_id,
context_id=task_id,
status=a2a_status,
artifacts=artifacts,
history=[],
metadata=metadata,
)
async def get_task(self, task_id: str) -> A2ATask | None:
"""
Get a task by ID and return as A2A Task.
Args:
task_id: Task UUID string
Returns:
A2ATask or None if not found
"""
try:
task_uuid = UUID(task_id)
except ValueError:
return None
result = await self.session.execute(
select(TaskTable).where(TaskTable.id == task_uuid)
)
task = result.scalar_one_or_none()
if task is None:
return None
return self.task_to_a2a(task)
async def list_tasks(
self,
page_size: int = 20,
offset: int = 0,
order_by: str | None = None,
) -> tuple[list[A2ATask], bool]:
"""
List tasks with pagination.
Args:
page_size: Number of results to return
offset: Starting offset
order_by: Sort order ("created_at desc" or "created_at asc")
Returns:
Tuple of (tasks, has_more)
"""
query = select(TaskTable)
# Apply ordering
if order_by == "created_at asc":
query = query.order_by(TaskTable.created_at.asc())
else:
query = query.order_by(TaskTable.created_at.desc())
# Apply pagination (fetch one extra to detect more)
query = query.offset(offset).limit(page_size + 1)
result = await self.session.execute(query)
tasks = list(result.scalars().all())
has_more = len(tasks) > page_size
if has_more:
tasks = tasks[:page_size]
return [self.task_to_a2a(t) for t in tasks], has_more
@staticmethod
def _status_value_of(task: Any) -> str:
"""Status as a comparable string (enum value or raw str)."""
return task.status.value if hasattr(task.status, "value") else str(task.status)
async def _apply_cancel_note(
self, task: Any, actor_slug: str | None, reason: str | None
) -> None:
"""Append an actor-attributed cancellation note to dev_notes.
Kept out of route handlers so the audit trail records who cancelled and
why (the route passes the authenticated slug).
"""
note_parts: list[str] = []
if actor_slug:
note_parts.append(f"Cancelled via A2A by {actor_slug}")
if reason:
note_parts.append(f"reason: {reason}")
cancellation_note = "; ".join(note_parts) if note_parts else None
if cancellation_note:
if task.dev_notes:
task.dev_notes = f"{task.dev_notes}\n\n{cancellation_note}"
else:
task.dev_notes = cancellation_note
await self.session.flush()
async def cancel_task(
self,
task_id: str,
reason: str | None = None,
agent_role: str | None = None,
actor_slug: str | None = None,
) -> A2ATask:
"""
Cancel a task and all non-terminal descendants.
Args:
task_id: Task UUID string
reason: Optional cancellation reason
agent_role: The authenticated caller's role, threaded into the
cascade role gate (TaskService.cancel). Defaults to cell_pm
when unset for back-compat with non-route callers.
actor_slug: The authenticated caller's slug, recorded in the
cancellation note so the audit trail attributes the cancel to
the real actor (the route enforces PM/management; non-route
callers may omit it).
Returns:
Updated A2ATask
Raises:
ValueError: If task not found or already in terminal state
"""
# Import here to avoid circular imports
from roboco.services.task import TaskService
try:
task_uuid = UUID(task_id)
except ValueError as e:
raise ValueError(f"Invalid task ID: {task_id}") from e
# Check task exists and is cancellable before using service
result = await self.session.execute(
select(TaskTable).where(TaskTable.id == task_uuid)
)
task = result.scalar_one_or_none()
if task is None:
raise ValueError(f"Task not found: {task_id}")
# Check if cancellable
status_value = self._status_value_of(task)
if status_value in ("completed", "cancelled"):
raise ValueError(f"Task already in terminal state: {status_value}")
# Attribute the cancel to the real actor (the route passes the
# authenticated slug) in the audit note — kept out of route handlers.
await self._apply_cancel_note(task, actor_slug, reason)
# Use TaskService for consistent cancel behavior (cascades to descendants).
# Thread the caller's role into the cascade role gate so a non-PM
# caller can't cascade-cancel descendants the role can't cancel.
task_service = TaskService(self.session)
task = await task_service.cancel(task_uuid, agent_role=agent_role or "cell_pm")
if task is None:
raise ValueError(f"Failed to cancel task: {task_id}")
logger.info(
"Cancelled task via A2A",
task_id=task_id,
reason=reason,
actor=actor_slug,
role=agent_role,
)
return self.task_to_a2a(task)
async def discover_agents(
self,
role: str | None = None,
team: str | None = None,
skill_tag: str | None = None,
) -> list[AgentCard]:
"""
Discover agents matching criteria.
Args:
role: Filter by agent role
team: Filter by team
skill_tag: Filter by skill tag (future)
Returns:
List of matching AgentCards
"""
query = select(AgentTable)
if role:
query = query.where(AgentTable.role == role)
if team:
query = query.where(AgentTable.team == team)
result = await self.session.execute(query)
agents = result.scalars().all()
cards = [self._agent_to_card(agent) for agent in agents]
# Filter by skill tag if specified
if skill_tag:
cards = [
card
for card in cards
if any(skill_tag in skill.tags for skill in card.skills)
]
return cards
# =========================================================================
# MESSAGE ROUTING
# =========================================================================
@staticmethod
def get_team_from_agent(agent_slug: str) -> Team:
"""Get Team enum from agent slug."""
team_str = get_agent_team(agent_slug)
team_map = {
"backend": Team.BACKEND,
"frontend": Team.FRONTEND,
"ux_ui": Team.UX_UI,
}
return team_map.get(team_str or "", Team.BACKEND)
@staticmethod
def resolve_target_agent(metadata: dict[str, Any]) -> str | None:
"""
Resolve target agent from A2A request metadata.
Returns agent slug or None if not specified.
"""
# Check for explicit target
target = metadata.get("target_agent")
if target and target in ALL_AGENTS:
return cast("str", target)
# Check for skill-based routing
skill = metadata.get("skill")
if skill:
for agent_slug in ALL_AGENTS:
agent_skills = get_agent_skills(agent_slug)
skill_ids = [s.get("id", "") for s in agent_skills]
if skill in skill_ids:
return agent_slug
return None
# =========================================================================
# MESSAGE HANDLING
# =========================================================================
@staticmethod
def extract_message_text(message: A2AMessage) -> tuple[str, str, str]:
"""Extract title, description, and full text from message parts."""
text_parts = [p for p in message.parts if p.type == "text"]
if not text_parts:
return "A2A Task", "", ""
text_part = text_parts[0]
if not hasattr(text_part, "text"):
return "A2A Task", "", ""
message_text = text_part.text
lines = message_text.split("\n", 1)
title = lines[0][:200]
description = lines[1] if len(lines) > 1 else message_text
return title, description, message_text
@staticmethod
def update_task_with_message(task: TaskTable, message: A2AMessage) -> None:
"""Append A2A-protocol message text to the task's A2A log (dev_notes).
NOTE: this is the *legacy A2A-protocol* message store — A2A-protocol
tasks carry their request/response thread in ``dev_notes`` (keyed by the
``"A2A Request"`` marker that ``_notify_original_requester`` checks).
It is NOT the gateway agent flow (those use the A2AConversation tables),
so it does not pollute normal delivery tasks' developer notes.
"""
text_parts = [p for p in message.parts if p.type == "text"]
if not text_parts:
return
text_part = text_parts[0]
if not hasattr(text_part, "text"):
return
new_text = text_part.text
task.dev_notes = (
f"{task.dev_notes}\n\n{new_text}" if task.dev_notes else new_text
)
async def resolve_creator_agent(
self, from_agent_id: str | None
) -> AgentTable | None:
"""Resolve the creator agent from ID or fall back to main PM."""
if from_agent_id and from_agent_id in ALL_AGENTS:
from_uuid = AGENT_UUIDS.get(from_agent_id)
if from_uuid:
result = await self.session.execute(
select(AgentTable).where(AgentTable.id == UUID(from_uuid))
)
return result.scalar_one_or_none()
# Fall back to main PM
result = await self.session.execute(
select(AgentTable).where(AgentTable.role == "main_pm").limit(1)
)
return result.scalar_one_or_none()
async def create_a2a_notification(
self,
request: SendMessageRequest,
) -> dict[str, Any]:
"""
Create an A2A notification for peer-to-peer communication.
Does NOT create tasks - A2A is messaging only.
task_id is REQUIRED - A2A is communication about existing tasks.
Returns dict with notification_id, status, and target_agent.
"""
from roboco.services.notification import NotificationService
message = request.message
metadata = request.metadata or {}
config = request.configuration
# task_id is REQUIRED for A2A
task_id = message.task_id
if not task_id:
raise ValueError("A2A requests must reference a task_id")
from_agent = metadata.get("from_agent")
target_agent = self.resolve_target_agent(metadata)
skill = metadata.get("skill", "general")
# Enforce A2A hierarchy permissions — UNCONDITIONALLY. The conversation
# path (validate_a2a_access, below) requires both ends present and
# rejects self-A2A with a typed A2AAccessDeniedError + route_hint. This
# legacy notification path used to gate on `if from_agent and
# target_agent:`, so an unattributed (from_agent falsy) or untargeted
# (target unresolvable) request slipped past the hierarchy matrix and
# dispatched with from_agent='unknown' / to_agent='' — and a hierarchy
# denial came back as a bare ValueError indistinguishable from the
# missing-task_id ValueError above. Require both resolved, then validate
# via the shared typed path so both A2A surfaces enforce the same
# who-may-talk-to-whom invariant.
if not from_agent:
raise ValueError(
"A2A notification requires a 'from_agent' in metadata — an "
"unattributed request would bypass the hierarchy gate"
)
if not target_agent:
raise ValueError(
"A2A notification could not resolve a target agent — provide an "
"explicit 'target_agent' (a known agent slug) or a 'skill' that "
"matches an agent's capability"
)
validate_a2a_access(from_agent, target_agent)
# Priority parsing: full tristate (NORMAL/HIGH/URGENT) survives
# end-to-end. Resolution rules live in
# foundation.policy.communications.parse_priority.
from roboco.foundation.policy.communications import parse_priority
legacy_urgent = bool(
(config and config.urgent) or metadata.get("urgent", False)
)
priority = parse_priority(metadata.get("priority"), legacy_urgent)
# Extract message content
_, _, message_text = self.extract_message_text(message)
logger.info(
"Creating A2A notification (fallback)",
task_id=task_id,
from_agent=from_agent,
target_agent=target_agent,
skill=skill,
priority=priority.value,
)
# Create notification - orchestrator dispatcher will handle spawning
notification_service = NotificationService()
await notification_service.send_a2a_notification(
task_id=task_id,
a2a_context={
"from_agent": from_agent or "unknown",
"to_agent": target_agent or "",
"skill": skill,
"message": message_text,
"priority": priority,
},
)
return {
"status": "sent",
"target_agent": target_agent,
"task_id": task_id,
}
async def update_task_from_message(
self,
task_id: str,
message: A2AMessage,
responder_agent: str | None = None,
) -> TaskTable:
"""
Update an existing task with a new message (response).
When a response is received, notifies the original requester
and spawns them if offline (bidirectional A2A).
Args:
task_id: Task UUID string
message: A2A message to append
responder_agent: Agent sending the response (for routing back)
Returns:
Updated TaskTable
Raises:
ValueError: If task not found or invalid ID
"""
try:
task_uuid = UUID(task_id)
except ValueError as e:
raise ValueError(f"Invalid task ID: {task_id}") from e
result = await self.session.execute(
select(TaskTable).where(TaskTable.id == task_uuid)
)
task = result.scalar_one_or_none()
if task is None:
raise ValueError(f"Task not found: {task_id}")
self.update_task_with_message(task, message)
# Notify original requester of the response (bidirectional A2A)
await self._notify_original_requester(task, responder_agent)
return task
@staticmethod
def _lookup_requester_slug(created_by: Any) -> str | None:
"""Find the agent slug for a task creator UUID."""
from roboco.seeds.initial_data import AGENT_UUIDS
created_by_str = str(created_by)
for slug, uuid_str in AGENT_UUIDS.items():
if uuid_str == created_by_str:
return slug
return None
@staticmethod
async def _publish_a2a_response_event(
task: TaskTable,
created_by: Any,
requester_slug: str,
responder_agent: str | None,
) -> None:
"""Publish a TASK_ASSIGNED event to notify/spawn the requester."""
try:
bus = get_event_bus()
if not bus.is_connected():
return
await bus.publish(
Event(
type=EventType.TASK_ASSIGNED,
data={
"task_id": str(task.id),
"assigned_to": str(created_by),
"agent_slug": requester_slug,
"skill": "a2a_response",
"message": f"Response received for A2A task {task.id}",
"source": "a2a_response",
"urgent": False,
"from_agent": responder_agent or "agent",
},
)
)
except Exception:
pass # Don't fail if event bus unavailable
async def _notify_original_requester(
self,
task: TaskTable,
responder_agent: str | None = None,
) -> None:
"""
Notify the original A2A requester of a response.
If the requester is offline, triggers spawn via event.
This enables bidirectional A2A where both parties can be
spawned as needed until they're both online.
"""
dev_notes = task.dev_notes or ""
if "A2A Request" not in dev_notes:
return # Not an A2A task
created_by = task.created_by
if not created_by:
return
requester_slug = self._lookup_requester_slug(created_by)
if not requester_slug:
return
# Don't notify if responder is the same as requester
if responder_agent and responder_agent == requester_slug:
return
await self._publish_a2a_response_event(
task, created_by, requester_slug, responder_agent
)
# =========================================================================
# PERSISTENT CONVERSATION MANAGEMENT
# =========================================================================
# These methods handle persistent A2A conversations stored in the database.
# They complement the existing A2A protocol methods above.
@staticmethod
def _canonical_pair(agent_a: str, agent_b: str) -> tuple[str, str]:
"""Return agents in canonical order (lexically smaller first)."""
return (agent_a, agent_b) if agent_a < agent_b else (agent_b, agent_a)
async def get_or_create_conversation(
self,
agent_a: str,
agent_b: str,
topic: str | None = None,
task_id: UUID | None = None,
) -> A2AConversation:
"""
Get existing conversation or create new one.
Args:
agent_a: First agent slug
agent_b: Second agent slug
topic: Optional conversation topic
task_id: Optional task to link
Returns:
A2AConversation model
Raises:
A2AAccessDeniedError: If A2A not permitted between agents
"""
# Validate permissions
validate_a2a_access(agent_a, agent_b)
# Canonical ordering
a, b = self._canonical_pair(agent_a, agent_b)
# Try to find existing
query = select(A2AConversationTable).where(
A2AConversationTable.agent_a == a,
A2AConversationTable.agent_b == b,
)
if topic:
query = query.where(A2AConversationTable.topic == topic)
else:
query = query.where(A2AConversationTable.topic.is_(None))
result = await self.session.execute(query)
existing = result.scalar_one_or_none()
if existing:
return self._conv_to_model(existing)
# Create new conversation
conv = A2AConversationTable(
agent_a=a,
agent_b=b,
topic=topic,
task_id=task_id,
status=A2AConversationStatus.ACTIVE,
)
self.session.add(conv)
await self.session.flush()
await self.session.refresh(conv)
logger.info(
"Created A2A conversation",
conversation_id=str(conv.id),
agent_a=a,
agent_b=b,
topic=topic,
)
return self._conv_to_model(conv)
async def get_conversation(
self,
conversation_id: UUID,
agent_slug: str,
) -> A2AConversation | None:
"""
Get conversation by ID if agent is a participant.
Args:
conversation_id: Conversation UUID
agent_slug: Agent requesting (must be participant)
Returns:
A2AConversation or None if not found/not authorized
"""
result = await self.session.execute(
select(A2AConversationTable).where(
A2AConversationTable.id == conversation_id
)
)
conv = result.scalar_one_or_none()
if conv is None:
return None
# Verify agent is participant
if agent_slug not in (conv.agent_a, conv.agent_b):
return None
return self._conv_to_model(conv)
async def get_conversation_admin(
self,
conversation_id: UUID,
) -> A2AConversation | None:
"""Get a conversation by ID with NO participant check.
The CEO's live view needs to look up (and reply into) any
conversation, including ones it is not itself a party to — unlike
``get_conversation``, which gates on membership.
"""
result = await self.session.execute(
select(A2AConversationTable).where(
A2AConversationTable.id == conversation_id
)
)
conv = result.scalar_one_or_none()
if conv is None:
return None
return self._conv_to_model(conv)
async def list_conversations(
self,
agent_slug: str,
status: A2AConversationStatus | None = None,
with_agent: str | None = None,
task_id: UUID | None = None,
limit: int = 50,
) -> list[A2AConversationSummary]:
"""
List conversations for an agent.
Args:
agent_slug: Agent to list for
status: Filter by status
with_agent: Filter by other participant
task_id: Filter by linked task
limit: Max results
Returns:
List of conversation summaries
"""
from sqlalchemy import or_
query = select(A2AConversationTable).where(
or_(
A2AConversationTable.agent_a == agent_slug,
A2AConversationTable.agent_b == agent_slug,
)
)
if status:
query = query.where(A2AConversationTable.status == status)
if with_agent:
a, b = self._canonical_pair(agent_slug, with_agent)
query = query.where(
A2AConversationTable.agent_a == a,
A2AConversationTable.agent_b == b,
)
if task_id:
query = query.where(A2AConversationTable.task_id == task_id)
query = query.order_by(A2AConversationTable.updated_at.desc()).limit(limit)
result = await self.session.execute(query)
conversations = result.scalars().all()
summaries = []
for conv in conversations:
# Get last message preview
msg_query = (
select(A2AMessageTable)
.where(A2AMessageTable.conversation_id == conv.id)
.order_by(A2AMessageTable.created_at.desc())
.limit(1)
)
msg_result = await self.session.execute(msg_query)
last_msg = msg_result.scalar_one_or_none()
other = conv.agent_b if agent_slug == conv.agent_a else conv.agent_a
unread = (
conv.unread_by_a if agent_slug == conv.agent_a else conv.unread_by_b
)
summaries.append(
A2AConversationSummary(
id=str(conv.id),
other_agent=other,
topic=conv.topic,
task_id=str(conv.task_id) if conv.task_id else None,
status=conv.status,
message_count=conv.message_count,
unread_count=unread,
last_message_at=conv.last_message_at,
last_message_preview=(last_msg.content[:100] if last_msg else None),
)
)
return summaries
async def _last_message(self, conversation_id: UUID) -> A2AMessageTable | None:
"""Most recent message row in a conversation, or None."""
result = await self.session.execute(
select(A2AMessageTable)
.where(A2AMessageTable.conversation_id == conversation_id)
.order_by(A2AMessageTable.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def list_conversations_admin(
self, limit: int = 50
) -> list[A2AConversationAdminSummary]:
"""ALL conversations across every agent pair, most-recent-first, bounded.
No participant filter — this is the CEO's org-wide live view, not a
per-agent inbox. Reuses the same last-message-preview lookup as
``list_conversations``.
"""
query = (
select(A2AConversationTable)
.order_by(A2AConversationTable.updated_at.desc())
.limit(limit)
)
result = await self.session.execute(query)
conversations = result.scalars().all()
summaries = []
for conv in conversations:
last_msg = await self._last_message(cast("UUID", conv.id))
summaries.append(
A2AConversationAdminSummary(
id=str(conv.id),
agent_a=conv.agent_a,
agent_b=conv.agent_b,
topic=conv.topic,
task_id=str(conv.task_id) if conv.task_id else None,
status=conv.status,
message_count=conv.message_count,
last_message_at=conv.last_message_at,
last_message_preview=(last_msg.content[:100] if last_msg else None),
created_at=conv.created_at,
updated_at=conv.updated_at,
)
)
return summaries
async def list_admin_pairs(self) -> list[A2AAdminPairSummary]:
"""CEO switchboard: every allowed agent pair (static matrix, see
``agents_config.A2A_ALLOWED_PAIRS``) joined with its representative
conversation when one exists.
One bulk query over the bounded static pair count — never N+1. When a
pair has more than one conversation (distinct topics), the most
recently updated one is treated as "the" conversation for that pair.
"""
from sqlalchemy import tuple_
canonical_keys = [(p.agent_a, p.agent_b) for p in A2A_ALLOWED_PAIRS]
conv_by_pair: dict[tuple[str, str], A2AConversationTable] = {}
if canonical_keys:
result = await self.session.execute(
select(A2AConversationTable).where(
tuple_(
A2AConversationTable.agent_a, A2AConversationTable.agent_b
).in_(canonical_keys)
)
)
for conv in result.scalars().all():
key = (conv.agent_a, conv.agent_b)
current = conv_by_pair.get(key)
if current is None or conv.updated_at > current.updated_at:
conv_by_pair[key] = conv
summaries: list[A2AAdminPairSummary] = []
for p in A2A_ALLOWED_PAIRS:
rep = conv_by_pair.get((p.agent_a, p.agent_b))
summaries.append(
A2AAdminPairSummary(
agent_a=p.agent_a,
role_a=p.role_a,
team_a=p.team_a,
agent_b=p.agent_b,
role_b=p.role_b,
team_b=p.team_b,
group_key=p.group_key,
conversation_id=str(rep.id) if rep else None,
last_message_at=rep.last_message_at if rep else None,
message_count=rep.message_count if rep else 0,
)
)
return summaries
async def close_conversation(
self,
conversation_id: UUID,
agent_slug: str,
resolution: str | None = None,
) -> None:
"""Close a conversation."""
result = await self.session.execute(
select(A2AConversationTable).where(
A2AConversationTable.id == conversation_id
)
)
conv = result.scalar_one_or_none()
if conv is None:
raise ValueError(f"Conversation not found: {conversation_id}")
if agent_slug not in (conv.agent_a, conv.agent_b):
raise ValueError("Not a participant in this conversation")
conv.status = A2AConversationStatus.CLOSED
conv.resolution = resolution
await self.session.flush()
logger.info(
"Closed A2A conversation",
conversation_id=str(conversation_id),
by_agent=agent_slug,
)
async def _enforce_ceo_reply_budget(
self,
conv: A2AConversationTable,
conversation_id: UUID,
from_agent: str,
) -> None:
"""Reply-then-wait budget on the CEO's inbox — the one stateful gate
the stateless ``can_a2a_direct`` matrix can't see (it only blocks
conversation *creation*, unconditionally, as defense-in-depth).
An agent may message the CEO only inside a conversation the CEO
itself opened, and only up to the CEO's own message count there:
reject when the agent's message count >= the CEO's message count.
No-op for CEO-authored sends or conversations the CEO isn't part of.
"""
other = conv.agent_b if from_agent == conv.agent_a else conv.agent_a
if other != "ceo" or from_agent == "ceo":
return
from sqlalchemy import func
agent_count = await self.session.scalar(
select(func.count())
.select_from(A2AMessageTable)
.where(
A2AMessageTable.conversation_id == conversation_id,
A2AMessageTable.from_agent == from_agent,
)
)
ceo_count = await self.session.scalar(
select(func.count())
.select_from(A2AMessageTable)
.where(
A2AMessageTable.conversation_id == conversation_id,
A2AMessageTable.from_agent == "ceo",
)
)
if (agent_count or 0) >= (ceo_count or 0):
raise A2AAccessDeniedError(
from_agent=from_agent,
to_agent="ceo",
reason=(
"you have already replied to the CEO's last message — "
"wait for the CEO to respond before sending again"
),
route_hint="Wait for the CEO to post again in this conversation.",
)
async def send_chat_message(
self,
conversation_id: UUID,
from_agent: str,
content: str,
options: dict[str, Any] | None = None,
) -> A2AChatMessage:
"""
Send message in conversation.
Args:
conversation_id: Target conversation
from_agent: Sender slug
content: Message content
options: Optional dict with message_kind, response_to_id, requires_response
Returns:
Created A2AChatMessage
Raises:
ValueError: If conversation_id is nil, conversation not found,
or sender not participant
"""
if conversation_id.int == 0:
raise ValueError(
"conversation_id must not be the nil UUID; "
"call get_or_create_conversation() first"
)
from datetime import UTC, datetime
opts = options or {}
message_kind = opts.get("message_kind", A2AMessageKind.MESSAGE)
response_to_id = opts.get("response_to_id")
requires_response = opts.get("requires_response", False)
skill = opts.get("skill")
result = await self.session.execute(
select(A2AConversationTable).where(
A2AConversationTable.id == conversation_id
)
)
conv = result.scalar_one_or_none()
if conv is None:
raise ValueError(f"Conversation not found: {conversation_id}")
if from_agent not in (conv.agent_a, conv.agent_b):
raise ValueError("Not a participant in this conversation")
# Purpose-dedup: if an identical message from this sender is still
# unread in this conversation, the sender is re-saying the same thing
# (a respawn re-emitting, or a retry) — don't stack another copy on the
# recipient's inbox or re-bump the unread count. Keyed on
# (conversation, sender, kind, content) while unread, so genuinely
# different messages are never collapsed.
dup = await self.session.scalar(
select(A2AMessageTable)
.where(
A2AMessageTable.conversation_id == conversation_id,
A2AMessageTable.from_agent == from_agent,
A2AMessageTable.message_kind == message_kind,
A2AMessageTable.content == content,
A2AMessageTable.read_at.is_(None),
)
.limit(1)
)
if dup is not None:
logger.info(
"Suppressed duplicate unread A2A message",
conversation_id=str(conversation_id),
from_agent=from_agent,
existing_message_id=str(dup.id),
)
return self._msg_to_model(dup)
await self._enforce_ceo_reply_budget(conv, conversation_id, from_agent)
# Create message
msg = A2AMessageTable(
conversation_id=conversation_id,
from_agent=from_agent,
content=content,
message_kind=message_kind,
response_to_id=response_to_id,
requires_response=requires_response,
skill=skill,
)
self.session.add(msg)
# Update conversation stats
conv.message_count += 1
conv.last_message_at = datetime.now(UTC)
# Update unread count for the OTHER agent
if from_agent == conv.agent_a:
conv.unread_by_b += 1
else:
conv.unread_by_a += 1
await self.session.flush()
await self.session.refresh(msg)
logger.info(
"Sent A2A chat message",
conversation_id=str(conversation_id),
message_id=str(msg.id),
from_agent=from_agent,
)
model = self._msg_to_model(msg)
# Single chokepoint for the operator live view: every persisted A2A
# message emits A2A_MESSAGE_SENT here, so the direct REST send paths
# (conversation-create + post-message) light up the /a2a view too, not
# just the gateway send() wrapper. Suppressed duplicates return above
# and deliberately don't re-emit.
to_agent = conv.agent_b if from_agent == conv.agent_a else conv.agent_a
task_id = str(conv.task_id) if conv.task_id else None
await self._publish_a2a_message_sent(
model, task_id, from_agent, to_agent, skill
)
return model
async def get_messages(
self,
conversation_id: UUID,
agent_slug: str,
limit: int = 100,
before: datetime | None = None,
) -> list[A2AChatMessage]:
"""Get messages in conversation."""
# Verify access
conv_result = await self.session.execute(
select(A2AConversationTable).where(
A2AConversationTable.id == conversation_id
)
)
conv = conv_result.scalar_one_or_none()
if conv is None:
return []
if agent_slug not in (conv.agent_a, conv.agent_b):
return []
query = (
select(A2AMessageTable)
.where(A2AMessageTable.conversation_id == conversation_id)
.order_by(A2AMessageTable.created_at.desc())
.limit(limit)
)
if before:
query = query.where(A2AMessageTable.created_at < before)
result = await self.session.execute(query)
messages = result.scalars().all()
# Return in chronological order
return [self._msg_to_model(m) for m in reversed(list(messages))]
async def get_messages_admin(
self,
conversation_id: UUID,
limit: int = 100,
before: datetime | None = None,
) -> list[A2AChatMessage]:
"""Like ``get_messages`` but WITHOUT the participant check — the CEO
can read any conversation's transcript for the live view. Returns
``[]`` only when the conversation truly doesn't exist.
"""
conv_result = await self.session.execute(
select(A2AConversationTable).where(
A2AConversationTable.id == conversation_id
)
)
conv = conv_result.scalar_one_or_none()
if conv is None:
return []
query = (
select(A2AMessageTable)
.where(A2AMessageTable.conversation_id == conversation_id)
.order_by(A2AMessageTable.created_at.desc())
.limit(limit)
)
if before:
query = query.where(A2AMessageTable.created_at < before)
result = await self.session.execute(query)
messages = result.scalars().all()
return [self._msg_to_model(m) for m in reversed(list(messages))]
async def mark_read(
self,
conversation_id: UUID,
agent_slug: str,
) -> None:
"""Mark all unread incoming messages in conversation as read by agent.
Collect-then-mark: only the unread rows seen at call time are stamped,
so a message arriving mid-call stays unread rather than being silently
consumed. The unread counter is recomputed from the DB after the stamp.
"""
from datetime import UTC, datetime
from sqlalchemy import update
result = await self.session.execute(
select(A2AConversationTable).where(
A2AConversationTable.id == conversation_id
)
)
conv = result.scalar_one_or_none()
if conv is None:
return
if agent_slug not in (conv.agent_a, conv.agent_b):
return
rows = await self.session.execute(
select(A2AMessageTable.id).where(
A2AMessageTable.conversation_id == conversation_id,
A2AMessageTable.from_agent != agent_slug,
A2AMessageTable.read_at.is_(None),
)
)
msg_ids = [r for (r,) in rows.all()]
if msg_ids:
await self.session.execute(
update(A2AMessageTable)
.where(A2AMessageTable.id.in_(msg_ids))
.values(read_at=datetime.now(UTC))
)
await self._reset_unread_counter(conversation_id, agent_slug)
await self.session.flush()
async def mark_all_read(self, agent_id: UUID) -> int:
"""Mark every conversation with unread-for-this-agent as read.
Agent-keyed bulk form of ``mark_read``: stamps ``read_at`` on the
inbound messages across all its conversations and recomputes each
counter from the DB. Returns the number cleared. Lets an agent satisfy
``i_am_idle``'s unread-A2A soft-block in one call.
Collect-then-mark (mirrors ``get_unread_messages``): only the unread
rows seen at call time are stamped, so a message arriving mid-call
stays unread rather than being silently consumed.
"""
from datetime import UTC, datetime
from sqlalchemy import or_, update
slug = await self._resolve_slug_from_id(agent_id)
result = await self.session.execute(
select(A2AConversationTable).where(
or_(
(A2AConversationTable.agent_a == slug)
& (A2AConversationTable.unread_by_a > 0),
(A2AConversationTable.agent_b == slug)
& (A2AConversationTable.unread_by_b > 0),
)
)
)
convs = list(result.scalars().all())
if not convs:
return 0
conv_ids = [c.id for c in convs]
rows = await self.session.execute(
select(A2AMessageTable.id).where(
A2AMessageTable.conversation_id.in_(conv_ids),
A2AMessageTable.from_agent != slug,
A2AMessageTable.read_at.is_(None),
)
)
msg_ids = [r for (r,) in rows.all()]
if msg_ids:
await self.session.execute(
update(A2AMessageTable)
.where(A2AMessageTable.id.in_(msg_ids))
.values(read_at=datetime.now(UTC))
)
for cid in conv_ids:
await self._reset_unread_counter(cast("UUID", cid), slug)
await self.session.flush()
return len(convs)
async def _reset_unread_counter(self, conversation_id: UUID, slug: str) -> None:
"""Recompute a conversation's unread-for-``slug`` counter from the rows
still unread, so a message arriving mid-drain is preserved, not zeroed."""
from sqlalchemy import func
conv = await self.session.get(A2AConversationTable, conversation_id)
if conv is None:
return
remaining = await self.session.scalar(
select(func.count())
.select_from(A2AMessageTable)
.where(
A2AMessageTable.conversation_id == conversation_id,
A2AMessageTable.from_agent != slug,
A2AMessageTable.read_at.is_(None),
)
)
if conv.agent_a == slug:
conv.unread_by_a = remaining or 0
else:
conv.unread_by_b = remaining or 0
async def get_unread_messages(self, agent_id: UUID) -> list[dict[str, Any]]:
"""Return the agent's unread INCOMING A2A messages and mark them read.
Delivers the actual message bodies (not just counts) so the agent can
reason about what was said to it. Only inbound messages (``from_agent``
!= caller) are returned — the agent's own sends are never echoed back.
Collect-then-mark is atomic within the session: only the exact rows
returned are stamped read, so a message arriving mid-call stays unread
rather than being silently cleared.
"""
from datetime import UTC, datetime
from sqlalchemy import or_, update
slug = await self._resolve_slug_from_id(agent_id)
conv_ids = select(A2AConversationTable.id).where(
or_(
A2AConversationTable.agent_a == slug,
A2AConversationTable.agent_b == slug,
)
)
result = await self.session.execute(
select(A2AMessageTable)
.where(
A2AMessageTable.conversation_id.in_(conv_ids),
A2AMessageTable.from_agent != slug,
A2AMessageTable.read_at.is_(None),
)
.order_by(A2AMessageTable.created_at.asc())
)
msgs = list(result.scalars().all())
if not msgs:
return []
await self.session.execute(
update(A2AMessageTable)
.where(A2AMessageTable.id.in_([m.id for m in msgs]))
.values(read_at=datetime.now(UTC))
)
# Recompute each affected conversation's unread counter (see helper) —
# a message that arrived mid-call is preserved, not zeroed.
for cid in {cast("UUID", m.conversation_id) for m in msgs}:
await self._reset_unread_counter(cid, slug)
await self.session.flush()
return [
{
"conversation_id": str(m.conversation_id),
"from_agent": m.from_agent,
"content": m.content,
"created_at": m.created_at.isoformat() if m.created_at else None,
}
for m in msgs
]
async def get_inbox_summary(self, agent_slug: str) -> A2AInboxSummary:
"""Get summary of pending A2A for agent."""
from sqlalchemy import func, or_
# Get conversations with unread
conv_query = select(A2AConversationTable).where(
or_(
A2AConversationTable.agent_a == agent_slug,
A2AConversationTable.agent_b == agent_slug,
)
)
conv_result = await self.session.execute(conv_query)
conversations = conv_result.scalars().all()
total_unread = 0
conversations_with_unread = 0
for conv in conversations:
unread = (
conv.unread_by_a if agent_slug == conv.agent_a else conv.unread_by_b
)
if unread > 0:
conversations_with_unread += 1
total_unread += unread
# Count pending responses (messages I sent that require response)
pending_query = (
select(func.count())
.select_from(A2AMessageTable)
.where(
A2AMessageTable.from_agent == agent_slug,
A2AMessageTable.requires_response.is_(True),
)
)
pending_result = await self.session.execute(pending_query)
pending_responses = pending_result.scalar() or 0
# Count unanswered requests (messages to me that require response)
unanswered_query = (
select(func.count())
.select_from(A2AMessageTable)
.join(A2AConversationTable)
.where(
or_(
A2AConversationTable.agent_a == agent_slug,
A2AConversationTable.agent_b == agent_slug,
),
A2AMessageTable.from_agent != agent_slug,
A2AMessageTable.requires_response.is_(True),
)
)
unanswered_result = await self.session.execute(unanswered_query)
unanswered_requests = unanswered_result.scalar() or 0
return A2AInboxSummary(
total_unread=total_unread,
conversations_with_unread=conversations_with_unread,
pending_responses=pending_responses,
unanswered_requests=unanswered_requests,
)
async def list_pairs(self, agent_slug: str) -> list[A2APair]:
"""List unique agent pairs for frontend display."""
from sqlalchemy import or_
query = (
select(A2AConversationTable)
.where(
or_(
A2AConversationTable.agent_a == agent_slug,
A2AConversationTable.agent_b == agent_slug,
)
)
.order_by(A2AConversationTable.updated_at.desc())
)
result = await self.session.execute(query)
conversations = result.scalars().all()
# Group by pair
pairs: dict[tuple[str, str], A2APair] = {}
for conv in conversations:
pair_key = (conv.agent_a, conv.agent_b)
if pair_key not in pairs:
pairs[pair_key] = A2APair(
agent_a=conv.agent_a,
agent_b=conv.agent_b,
conversation_count=0,
total_unread=0,
last_activity=None,
)
pairs[pair_key].conversation_count += 1
unread = (
conv.unread_by_a if agent_slug == conv.agent_a else conv.unread_by_b
)
pairs[pair_key].total_unread += unread
current_activity = pairs[pair_key].last_activity
if current_activity is None or (
conv.updated_at is not None and conv.updated_at > current_activity
):
pairs[pair_key].last_activity = conv.updated_at
return list(pairs.values())
# =========================================================================
# MODEL CONVERSIONS
# =========================================================================
def _conv_to_model(self, conv: A2AConversationTable) -> A2AConversation:
"""Convert table row to Pydantic model."""
return A2AConversation(
id=str(conv.id),
agent_a=conv.agent_a,
agent_b=conv.agent_b,
topic=conv.topic,
task_id=str(conv.task_id) if conv.task_id else None,
status=conv.status,
resolution=conv.resolution,
message_count=conv.message_count,
unread_by_a=conv.unread_by_a,
unread_by_b=conv.unread_by_b,
created_at=conv.created_at,
updated_at=conv.updated_at,
last_message_at=conv.last_message_at,
)
def _msg_to_model(self, msg: A2AMessageTable) -> A2AChatMessage:
"""Convert table row to Pydantic model."""
return A2AChatMessage(
id=str(msg.id),
conversation_id=str(msg.conversation_id),
from_agent=msg.from_agent,
content=msg.content,
message_kind=msg.message_kind,
skill=msg.skill,
response_to_id=str(msg.response_to_id) if msg.response_to_id else None,
requires_response=msg.requires_response,
read_at=msg.read_at,
created_at=msg.created_at,
edited_at=msg.edited_at,
edit_history=msg.edit_history or [],
)
# =========================================================================
# GATEWAY (CHOREOGRAPHER + CONTENT_ACTIONS) BACKFILL
# =========================================================================
async def _resolve_slug_from_id(self, agent_id: UUID) -> str:
"""Look up an agent's slug from its UUID; raise ValueError if missing."""
result = await self.session.execute(
select(AgentTable.slug).where(AgentTable.id == agent_id)
)
slug = result.scalar_one_or_none()
if not slug:
raise ValueError(f"Agent not found for id {agent_id}")
return str(slug)
async def _get_conversation_for_reply_to_ceo(
self, from_slug: str, to_slug: str
) -> A2AConversation:
"""Resolve the conversation for an agent replying to the CEO.
Agents can never CREATE a CEO conversation (the matrix blocks
initiation unconditionally), so an existing pair conversation's mere
presence proves the CEO opened it. Looked up directly here —
bypassing ``get_or_create_conversation``'s validate-first gate,
which would otherwise deny even a legitimate reply.
"""
a, b = self._canonical_pair(from_slug, to_slug)
result = await self.session.execute(
select(A2AConversationTable).where(
A2AConversationTable.agent_a == a,
A2AConversationTable.agent_b == b,
A2AConversationTable.topic.is_(None),
)
)
conv = result.scalar_one_or_none()
if conv is None:
raise A2AAccessDeniedError(
from_agent=from_slug,
to_agent=to_slug,
reason=(
"CEO is human. You may only reply inside a conversation "
"the CEO opened — use notify() otherwise."
),
route_hint="Wait for the CEO to open an A2A conversation with you.",
)
return self._conv_to_model(conv)
async def send(
self,
*,
from_agent: UUID,
to_agent: UUID | str,
task_id: UUID,
body: str,
skill: str | None = None,
) -> A2AChatMessage:
"""Gateway adapter — send a directed A2A message between two agents.
Recipient may be either a UUID (choreographer call shape) or a
slug string (content_actions call shape). The sender is always a
UUID; both ends are resolved to slugs because the
conversation/message tables key on slug.
Resolves to:
1. `get_or_create_conversation(sender_slug, recipient_slug, task_id=...)`
2. `send_chat_message(conversation.id, sender_slug, content=body, ...)`
`skill` is persisted on the message row so the receiver (and the
inbox) learns which capability is being requested.
The recipient "ceo" is special-cased: an agent can never CREATE a
CEO conversation (the matrix blocks it unconditionally), so calling
``get_or_create_conversation`` would deny even a legitimate reply.
Instead the existing pair conversation is looked up directly — its
mere existence proves the CEO opened it — and the reply proceeds to
``send_chat_message``, where the reply budget applies.
"""
from_slug = await self._resolve_slug_from_id(from_agent)
to_slug = (
await self._resolve_slug_from_id(to_agent)
if isinstance(to_agent, UUID)
else to_agent
)
if to_slug == "ceo" and from_slug != "ceo":
conv = await self._get_conversation_for_reply_to_ceo(from_slug, to_slug)
else:
conv = await self.get_or_create_conversation(
agent_a=from_slug,
agent_b=to_slug,
task_id=task_id,
)
options: dict[str, Any] = {}
if skill is not None:
options["skill"] = skill
msg = await self.send_chat_message(
conversation_id=UUID(conv.id),
from_agent=from_slug,
content=body,
options=options or None,
)
return msg
@staticmethod
async def _publish_a2a_message_sent(
msg: A2AChatMessage,
task_id: str | None,
from_slug: str,
to_slug: str,
skill: str | None,
) -> None:
"""Best-effort publish of A2A_MESSAGE_SENT for the operator live view.
A bus outage is logged and never rolls back the already-persisted
message.
"""
try:
bus = get_event_bus()
if bus.is_connected():
timestamp = (
msg.created_at.isoformat()
if msg.created_at
else datetime.now(UTC).isoformat()
)
await bus.publish(
Event(
type=EventType.A2A_MESSAGE_SENT,
data={
"conversation_id": msg.conversation_id,
"message_id": msg.id,
"task_id": task_id,
"from_agent": from_slug,
"to_agent": to_slug,
"skill": skill,
"body_excerpt": _excerpt(msg.content),
"timestamp": timestamp,
},
)
)
except Exception as e:
logger.warning("Failed to publish A2A message event", error=str(e))