Files
roboco/panel/src/lib/api/a2a.ts
T
da563487b8 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>
2026-07-03 00:07:55 +02:00

354 lines
8.8 KiB
TypeScript

/**
* A2A (Agent-to-Agent) Protocol API Client
*
* API functions for agent-to-agent communication protocol.
*/
import api from "./client";
import { isMockMode } from "@/lib/mock-data";
// =============================================================================
// Types
// =============================================================================
export interface A2AMessage {
id: string;
from_agent_id: string;
to_agent_id: string;
content: string;
message_type: string;
timestamp: string;
metadata?: Record<string, unknown>;
}
export interface A2AMessageSendRequest {
to_agent_id: string;
content: string;
message_type?: string;
task_id?: string;
metadata?: Record<string, unknown>;
}
export interface A2AMessageResponse {
message_id: string;
status: string;
delivered_at?: string;
}
export interface A2ATask {
id: string;
title: string;
status: string;
assigned_to: string | null;
created_at: string;
}
export interface A2AAgentCard {
agent_id: string;
name: string;
role: string;
capabilities: string[];
status: string;
current_task_id: string | null;
}
export interface A2AStreamChunk {
chunk_id: string;
content: string;
is_final: boolean;
}
/** Summary row for the CEO's admin view of an agent-to-agent conversation. */
export interface AdminConversationSummary {
id: string;
agent_a: string;
agent_b: string;
topic: string | null;
task_id: string | null;
status: string;
message_count: number;
last_message_at: string | null;
last_message_preview: string | null;
created_at: string;
updated_at: string;
}
/** One persisted A2A chat message (full body — WS frames only carry excerpts). */
export interface A2AChatMessage {
id: string;
conversation_id: string;
from_agent: string;
content: string;
message_kind: string;
response_to_id: string | null;
requires_response: boolean;
read_at: string | null;
created_at: string;
edited_at: string | null;
}
/**
* CEO reply payload. The backend sends a DIRECT CEO -> to_agent message (it
* lands in the CEO<->to_agent pairwise conversation), not an injection into
* the watched transcript.
*/
export interface AdminReplyRequest {
to_agent: string;
content: string;
skill?: string | null;
}
export interface AdminConversationListResponse {
items: AdminConversationSummary[];
total: number;
}
export interface AdminMessageListResponse {
items: A2AChatMessage[];
total: number;
has_more: boolean;
}
// =============================================================================
// API Client
// =============================================================================
export const a2aApi = {
// ===========================================================================
// MESSAGE ENDPOINTS
// ===========================================================================
/**
* Send a message to another agent
*/
sendMessage: async (
request: A2AMessageSendRequest,
): Promise<A2AMessageResponse> => {
if (isMockMode()) {
return {
message_id: `msg-${Date.now()}`,
status: "delivered",
delivered_at: new Date().toISOString(),
};
}
const { data } = await api.post<A2AMessageResponse>(
"/a2a/message/send",
request,
);
return data;
},
/**
* Stream a message to another agent (for long content)
*/
streamMessage: async (
request: A2AMessageSendRequest,
): Promise<A2AMessageResponse> => {
if (isMockMode()) {
return {
message_id: `msg-${Date.now()}`,
status: "streaming",
};
}
const { data } = await api.post<A2AMessageResponse>(
"/a2a/message/stream",
request,
);
return data;
},
// ===========================================================================
// TASK ENDPOINTS
// ===========================================================================
/**
* List tasks visible via A2A protocol
*/
listTasks: async (): Promise<A2ATask[]> => {
if (isMockMode()) {
return [];
}
const { data } = await api.get<A2ATask[]>("/a2a/tasks");
return data;
},
/**
* Get a specific task via A2A protocol
*/
getTask: async (taskId: string): Promise<A2ATask> => {
if (isMockMode()) {
return {
id: taskId,
title: "Mock Task",
status: "in_progress",
assigned_to: null,
created_at: new Date().toISOString(),
};
}
const { data } = await api.get<A2ATask>(`/a2a/tasks/${taskId}`);
return data;
},
/**
* Subscribe to task updates (returns SSE stream URL)
* Note: This endpoint returns Server-Sent Events, handle appropriately
*/
subscribeToTask: (taskId: string): string => {
// Returns the URL for SSE subscription
return `/a2a/tasks/${taskId}/subscribe`;
},
/**
* Cancel a task via A2A protocol
*/
cancelTask: async (
taskId: string,
): Promise<{ status: string; task_id: string }> => {
if (isMockMode()) {
return {
status: "cancelled",
task_id: taskId,
};
}
const { data } = await api.post<{ status: string; task_id: string }>(
`/a2a/tasks/${taskId}/cancel`,
);
return data;
},
// ===========================================================================
// AGENT ENDPOINTS
// ===========================================================================
/**
* List all agents available via A2A protocol
*/
listAgents: async (): Promise<A2AAgentCard[]> => {
if (isMockMode()) {
return [];
}
const { data } = await api.get<A2AAgentCard[]>("/a2a/agents");
return data;
},
/**
* Get agent card (profile/capabilities)
*/
getAgentCard: async (agentId: string): Promise<A2AAgentCard> => {
if (isMockMode()) {
return {
agent_id: agentId,
name: "Mock Agent",
role: "developer",
capabilities: ["coding", "testing"],
status: "idle",
current_task_id: null,
};
}
const { data } = await api.get<A2AAgentCard>(`/a2a/agents/${agentId}/card`);
return data;
},
// ===========================================================================
// ADMIN (CEO) ENDPOINTS — A2A live view
// ===========================================================================
/**
* List agent<->agent conversations, most-recent-first (CEO-only).
*/
listAdminConversations: async (
limit: number = 50,
): Promise<AdminConversationListResponse> => {
if (isMockMode()) {
const now = new Date().toISOString();
return {
items: [
{
id: "mock-conversation-1",
agent_a: "be-dev-1",
agent_b: "be-qa",
topic: "QA handoff",
task_id: null,
status: "active",
message_count: 3,
last_message_at: now,
last_message_preview: "Tests are green on the branch.",
created_at: now,
updated_at: now,
},
],
total: 1,
};
}
const { data } = await api.get<AdminConversationListResponse>(
"/a2a/chat/admin/conversations",
{ params: { limit } },
);
return data;
},
/**
* List a conversation's messages, chronological oldest-first (CEO-only).
*/
listAdminMessages: async (
conversationId: string,
limit: number = 100,
): Promise<AdminMessageListResponse> => {
if (isMockMode()) {
const now = new Date().toISOString();
return {
items: [
{
id: "mock-a2a-message-1",
conversation_id: conversationId,
from_agent: "be-dev-1",
content: "Branch is ready for QA.",
message_kind: "text",
response_to_id: null,
requires_response: false,
read_at: null,
created_at: now,
edited_at: null,
},
],
total: 1,
has_more: false,
};
}
const { data } = await api.get<AdminMessageListResponse>(
`/a2a/chat/admin/conversations/${conversationId}/messages`,
{ params: { limit } },
);
return data;
},
/**
* Send a CEO reply. Lands in the CEO<->to_agent pairwise conversation (the
* A2A model is strictly pairwise), NOT inside the watched transcript.
*/
replyAsCeo: async (
conversationId: string,
request: AdminReplyRequest,
): Promise<A2AChatMessage> => {
if (isMockMode()) {
return {
id: `mock-reply-${Date.now()}`,
conversation_id: conversationId,
from_agent: "ceo",
content: request.content,
message_kind: "text",
response_to_id: null,
requires_response: false,
read_at: null,
created_at: new Date().toISOString(),
edited_at: null,
};
}
const { data } = await api.post<A2AChatMessage>(
`/a2a/chat/admin/conversations/${conversationId}/reply`,
request,
);
return data;
},
};