diff --git a/packages/voice-assistant/src/server/acp/activity-curator.ts b/packages/voice-assistant/src/server/acp/activity-curator.ts new file mode 100644 index 000000000..4cc0b17d0 --- /dev/null +++ b/packages/voice-assistant/src/server/acp/activity-curator.ts @@ -0,0 +1,140 @@ +import type { AgentUpdate } from "./types.js"; + +/** + * Curate agent activity updates into a human-readable, token-efficient format + * Consolidates message chunks, formats tool calls, and structures output cleanly + */ +export function curateAgentActivity(updates: AgentUpdate[]): string { + const sections: string[] = []; + + // Group updates by type and consolidate + let currentMessage = ""; + let currentThought = ""; + const toolCalls: Array<{ timestamp: Date; data: any }> = []; + const plans: Array<{ timestamp: Date; data: any }> = []; + + for (const update of updates) { + const updateType = update.notification.update.sessionUpdate; + const content = (update.notification.update as any).content; + + switch (updateType) { + case "agent_message_chunk": + if (content?.type === "text" && content?.text) { + currentMessage += content.text; + } + break; + + case "agent_thought_chunk": + if (content?.type === "text" && content?.text) { + currentThought += content.text; + } + break; + + case "tool_call": + case "tool_call_update": + toolCalls.push({ + timestamp: update.timestamp, + data: update.notification.update, + }); + break; + + case "plan": + plans.push({ + timestamp: update.timestamp, + data: (update.notification.update as any).entries, + }); + break; + } + } + + // Format consolidated message + if (currentMessage.trim()) { + sections.push("## Agent Response\n\n" + currentMessage.trim()); + } + + // Format consolidated thoughts + if (currentThought.trim()) { + sections.push( + "## Agent Thoughts\n\n```\n" + currentThought.trim() + "\n```" + ); + } + + // Format tool calls + if (toolCalls.length > 0) { + const toolSection = ["## Tool Calls\n"]; + + // Group tool calls by toolCallId to show lifecycle + const toolGroups = new Map(); + for (const call of toolCalls) { + const toolCallId = call.data.toolCallId || "unknown"; + if (!toolGroups.has(toolCallId)) { + toolGroups.set(toolCallId, []); + } + toolGroups.get(toolCallId)!.push(call); + } + + for (const [toolCallId, calls] of toolGroups) { + const latestCall = calls[calls.length - 1]; + const data = latestCall.data; + + toolSection.push(`\n### ${data.title || data.kind || "Tool"}`); + toolSection.push(`- **Status**: ${data.status || "unknown"}`); + toolSection.push(`- **Call ID**: ${toolCallId.slice(0, 8)}...`); + + if (data.rawInput && Object.keys(data.rawInput).length > 0) { + toolSection.push(`- **Input**: ${JSON.stringify(data.rawInput, null, 2)}`); + } + + if (data.rawOutput && Object.keys(data.rawOutput).length > 0) { + toolSection.push(`- **Output**: ${JSON.stringify(data.rawOutput, null, 2)}`); + } + + if (data.content && data.content.length > 0) { + toolSection.push(`- **Content**: ${formatToolContent(data.content)}`); + } + } + + sections.push(toolSection.join("\n")); + } + + // Format plans + if (plans.length > 0) { + const latestPlan = plans[plans.length - 1]; + const planSection = ["## Plan\n"]; + + for (const entry of latestPlan.data) { + const icon = + entry.status === "completed" ? "✅" : + entry.status === "in_progress" ? "🔄" : + "⏳"; + + const priority = + entry.priority === "high" ? "🔴" : + entry.priority === "medium" ? "🟡" : + "🟢"; + + planSection.push(`${icon} ${priority} ${entry.content}`); + } + + sections.push(planSection.join("\n")); + } + + return sections.join("\n\n---\n\n") || "No activity to display."; +} + +/** + * Format tool call content into readable text + */ +function formatToolContent(content: any[]): string { + return content + .map((item) => { + if (item.type === "text") { + return item.text; + } + if (item.type === "image") { + return `[Image: ${item.source || "embedded"}]`; + } + return JSON.stringify(item); + }) + .join("\n"); +} diff --git a/packages/voice-assistant/src/server/acp/mcp-server.ts b/packages/voice-assistant/src/server/acp/mcp-server.ts index 659002162..195a2e76c 100644 --- a/packages/voice-assistant/src/server/acp/mcp-server.ts +++ b/packages/voice-assistant/src/server/acp/mcp-server.ts @@ -4,6 +4,7 @@ import { homedir } from "os"; import { resolve } from "path"; import { AgentManager } from "./agent-manager.js"; import type { SessionNotification } from "@agentclientprotocol/sdk"; +import { curateAgentActivity } from "./activity-curator.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; @@ -308,50 +309,75 @@ export async function createAgentMcpServer( { title: "Get Agent Activity", description: - "Get the complete activity log for an agent, including all messages, tool calls, and results. This shows you everything the agent has done - what it said, what tools it called, and what output it produced. Essential for understanding what a completed or failed agent actually accomplished.", + "Get the agent's activity in a human-readable, token-efficient format. Consolidates message chunks, formats tool calls, and structures plans. By default returns curated text, but you can request raw updates with format='raw'.", inputSchema: { agentId: z.string().describe("Agent ID to query"), + format: z + .enum(["curated", "raw"]) + .optional() + .default("curated") + .describe( + "Output format: 'curated' (default) for clean human-readable text, 'raw' for detailed JSON updates" + ), limit: z .number() .optional() .describe( - "Maximum number of updates to return (most recent first). Omit to get all updates." + "Maximum number of updates to include (most recent first). Only applies to 'raw' format. Omit to get all updates." ), }, outputSchema: { agentId: z.string(), - updateCount: z.number().describe("Total number of updates"), + format: z.enum(["curated", "raw"]), + updateCount: z.number().describe("Total number of updates available"), + content: z.string().describe("Formatted activity content (if curated) or empty string (if raw)"), updates: z.array( z.object({ timestamp: z.string(), type: z.string(), data: z.any(), }) - ), + ).nullable().describe("Raw updates array (if raw format) or null (if curated)"), }, }, - async ({ agentId, limit }) => { + async ({ agentId, format = "curated", limit }) => { const updates = agentManager.getAgentUpdates(agentId); - // Get the most recent updates if limit is specified - const selectedUpdates = limit - ? updates.slice(-limit).reverse() - : updates; + if (format === "curated") { + // Return curated, human-readable format + const curatedText = curateAgentActivity(updates); - const result = { - agentId, - updateCount: updates.length, - updates: selectedUpdates.map((update) => ({ - timestamp: update.timestamp.toISOString(), - type: getUpdateType(update.notification), - data: update.notification, - })), - }; + return { + content: [], + structuredContent: { + agentId, + format: "curated" as const, + updateCount: updates.length, + content: curatedText, + updates: null, + }, + }; + } else { + // Return raw format (old behavior) + const selectedUpdates = limit + ? updates.slice(-limit).reverse() + : updates; - return { - content: [], - structuredContent: result, - }; + return { + content: [], + structuredContent: { + agentId, + format: "raw" as const, + updateCount: updates.length, + content: "", + updates: selectedUpdates.map((update) => ({ + timestamp: update.timestamp.toISOString(), + type: getUpdateType(update.notification), + data: update.notification, + })), + }, + }; + } } );