Wave 2 features: A2A live view (CEO chime-in + reply budget) and prompter memory (#297)

* feat(a2a): live view — watch fleet conversations, CEO chime-in, reply budget

A2A_MESSAGE_SENT published from A2AService.send (excerpt-capped) and
fanned through the existing /ws/system bridge; CEO-only admin REST for
conversations/messages + a reply route on the publish-bearing send path;
panel /a2a page with live transcript and a composer gated on task-linked
conversations. The matrix gains its one asymmetric rule: CEO may message
anyone, nobody may target the CEO — and agent replies inside a
CEO-opened conversation are hard-budgeted to one per CEO message
(per conversation, per agent), rejected with wait-don't-retry guidance.
Built subagent-driven (Sonnet 5), reviewed; v1 seams documented in the
map delta.

* feat(prompter): intake remembers the task history

Intake spawns now carry a per-project chronological digest of recent
tasks (capped: 15 lines/project, 4000 chars total — ~300-1000 tokens)
merged into the ambient layer, and the interviewer gets a bounded
search_past_tasks tool (one shared implementation behind the grok MCP
tool and the Claude SDK in-process tool) to check precedent
mid-conversation. Informational memory only — the sequencing analyzer
keeps ownership of ordering. Built subagent-driven (Sonnet 5), reviewed;
pre-existing conventions-ambient MegaTask-scope gap flagged, untouched.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-03 00:07:55 +02:00
committed by GitHub
co-authored by Renn F
parent 48f2944086
commit da563487b8
39 changed files with 3808 additions and 27 deletions
+257 -9
View File
@@ -7,8 +7,8 @@ Provides business logic for A2A protocol operations including:
- Message handling and routing
"""
from datetime import datetime
from typing import Any, cast
from datetime import UTC, datetime
from typing import Any, Final, cast
from uuid import UUID
import structlog
@@ -23,12 +23,13 @@ from roboco.db.tables import (
AgentTable,
TaskTable,
)
from roboco.enforcement import validate_a2a_access
from roboco.enforcement import A2AAccessDeniedError, validate_a2a_access
from roboco.events import Event, EventType, get_event_bus
from roboco.models.a2a import (
A2AArtifact,
A2AChatMessage,
A2AConversation,
A2AConversationAdminSummary,
A2AConversationStatus,
A2AConversationSummary,
A2AInboxSummary,
@@ -51,6 +52,17 @@ 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:
"""
@@ -931,6 +943,26 @@ class A2AService:
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,
@@ -1012,6 +1044,54 @@ class A2AService:
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 close_conversation(
self,
conversation_id: UUID,
@@ -1042,6 +1122,54 @@ class A2AService:
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,
@@ -1118,6 +1246,8 @@ class A2AService:
)
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,
@@ -1190,6 +1320,41 @@ class A2AService:
# 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,
@@ -1432,6 +1597,38 @@ class A2AService:
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,
*,
@@ -1454,6 +1651,13 @@ class A2AService:
`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 = (
@@ -1462,17 +1666,61 @@ class A2AService:
else to_agent
)
conv = await self.get_or_create_conversation(
agent_a=from_slug,
agent_b=to_slug,
task_id=task_id,
)
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
return await self.send_chat_message(
msg = await self.send_chat_message(
conversation_id=UUID(conv.id),
from_agent=from_slug,
content=body,
options=options or None,
)
await self._publish_a2a_message_sent(msg, task_id, from_slug, to_slug, skill)
return msg
@staticmethod
async def _publish_a2a_message_sent(
msg: A2AChatMessage,
task_id: UUID,
from_slug: str,
to_slug: str,
skill: str | None,
) -> None:
"""Best-effort publish of A2A_MESSAGE_SENT for the operator live view.
Mirrors MessagingService.send_message's publish pattern: 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": str(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))