feat: collect structured turns from ACP session updates

UwfAcpClient now tracks all session/update events:
- agent_message_chunk → assistant message content
- agent_thought_chunk → assistant reasoning
- tool_call → pending tool invocation (name + rawInput)
- tool_call_update (completed/failed) → assistant tool_call + tool result

Messages are accumulated across prompts (same session) and stored
via storeHermesSessionDetail, restoring the full structured detail
(turns with tool calls, reasoning) that was lost in the initial ACP
migration.

Ref #398
This commit is contained in:
2026-05-22 13:13:02 +00:00
parent 68af555313
commit f90614a622
3 changed files with 143 additions and 16 deletions
@@ -53,4 +53,25 @@ describe("HermesAcpClient", () => {
}, },
{ timeout: 2 * 60 * 1000 }, { timeout: 2 * 60 * 1000 },
); );
it(
"prompt() collects structured messages including tool calls",
async () => {
await client.connect(process.cwd());
const result = await client.prompt("Run this command: echo TOOL_DETAIL_TEST");
expect(result.messages.length).toBeGreaterThan(0);
// Should have at least one tool message (the echo command)
const toolMessages = result.messages.filter((m) => m.role === "tool");
expect(toolMessages.length).toBeGreaterThan(0);
// Tool message should contain the output
const toolContent = toolMessages[0]?.content ?? "";
expect(toolContent).toContain("TOOL_DETAIL_TEST");
// Should have assistant messages with tool_calls
const assistantWithTools = result.messages.filter(
(m) => m.role === "assistant" && m.tool_calls !== null,
);
expect(assistantWithTools.length).toBeGreaterThan(0);
},
{ timeout: 2 * 60 * 1000 },
);
}); });
+115 -11
View File
@@ -9,26 +9,109 @@ import type {
} from "@agentclientprotocol/sdk"; } from "@agentclientprotocol/sdk";
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
import type { HermesSessionMessage } from "./types.js";
const HERMES_COMMAND = "hermes"; const HERMES_COMMAND = "hermes";
/** Tracks in-flight tool calls so we can build complete messages when they finish. */
type PendingToolCall = {
name: string;
args: string;
};
/**
* Collects ACP session/update events into a list of {@link HermesSessionMessage}
* that mirrors what Hermes writes to its session JSONL files.
*/
class UwfAcpClient implements Client { class UwfAcpClient implements Client {
private messageChunks: string[] = []; private messageChunks: string[] = [];
private reasoningChunks: string[] = [];
private pendingTools = new Map<string, PendingToolCall>();
messages: HermesSessionMessage[] = [];
resetChunks(): void { resetPerPrompt(): void {
this.messageChunks = []; this.messageChunks = [];
} this.reasoningChunks = [];
collectChunks(): string {
return this.messageChunks.join("");
} }
async sessionUpdate(params: SessionNotification): Promise<void> { async sessionUpdate(params: SessionNotification): Promise<void> {
const { update } = params; const { update } = params;
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") { switch (update.sessionUpdate) {
this.messageChunks.push(update.content.text); case "agent_message_chunk":
if (update.content.type === "text") {
this.messageChunks.push(update.content.text);
}
break;
case "agent_thought_chunk":
if (update.content.type === "text") {
this.reasoningChunks.push(update.content.text);
}
break;
case "tool_call": {
// Agent is invoking a tool — record the call.
const title = update.title ?? "";
const rawInput =
update.rawInput !== undefined && update.rawInput !== null
? JSON.stringify(update.rawInput)
: "";
this.pendingTools.set(update.toolCallId, { name: title, args: rawInput });
// Flush accumulated assistant text + reasoning as an assistant message
// (the agent "spoke" before calling the tool).
this.flushAssistantMessage();
break;
}
case "tool_call_update": {
if (update.status === "completed" || update.status === "failed") {
const pending = this.pendingTools.get(update.toolCallId);
const toolName = pending?.name ?? update.toolCallId;
const rawOutput =
update.rawOutput !== undefined && update.rawOutput !== null
? typeof update.rawOutput === "string"
? update.rawOutput
: JSON.stringify(update.rawOutput)
: "";
this.messages.push({
role: "assistant",
content: null,
reasoning: null,
tool_calls: [{ function: { name: toolName, arguments: pending?.args ?? "" } }],
});
this.messages.push({
role: "tool",
content: rawOutput,
reasoning: null,
tool_calls: null,
});
this.pendingTools.delete(update.toolCallId);
}
break;
}
default:
break;
} }
} }
/** Flush any accumulated text/reasoning into an assistant message. */
flushAssistantMessage(): void {
const text = this.messageChunks.join("");
const reasoning = this.reasoningChunks.join("");
if (text !== "" || reasoning !== "") {
this.messages.push({
role: "assistant",
content: text || null,
reasoning: reasoning || null,
tool_calls: null,
});
}
this.messageChunks = [];
this.reasoningChunks = [];
}
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> { async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
const firstOption = params.options[0]; const firstOption = params.options[0];
return { return {
@@ -40,6 +123,12 @@ class UwfAcpClient implements Client {
} }
} }
export type AcpPromptResult = {
text: string;
sessionId: string;
messages: HermesSessionMessage[];
};
export class HermesAcpClient { export class HermesAcpClient {
private process: ChildProcess | null = null; private process: ChildProcess | null = null;
private connection: ClientSideConnection | null = null; private connection: ClientSideConnection | null = null;
@@ -92,22 +181,37 @@ export class HermesAcpClient {
return sessionId; return sessionId;
} }
/** Send prompt and collect full response text */ /** Send prompt and collect full response text + structured messages. */
async prompt(text: string): Promise<{ text: string; sessionId: string }> { async prompt(text: string): Promise<AcpPromptResult> {
if (this.connection === null || this.sessionId === null) { if (this.connection === null || this.sessionId === null) {
throw new Error("Not connected — call connect() first"); throw new Error("Not connected — call connect() first");
} }
this.client.resetChunks(); this.client.resetPerPrompt();
await this.connection.prompt({ await this.connection.prompt({
sessionId: this.sessionId, sessionId: this.sessionId,
prompt: [{ type: "text", text }], prompt: [{ type: "text", text }],
}); });
// Flush any trailing assistant text that wasn't followed by a tool call.
this.client.flushAssistantMessage();
// Extract the final assistant text from collected messages.
const messages = this.client.messages;
let finalText = "";
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg !== undefined && msg.role === "assistant" && msg.content !== null && msg.content.trim() !== "") {
finalText = msg.content;
break;
}
}
return { return {
text: this.client.collectChunks(), text: finalText,
sessionId: this.sessionId, sessionId: this.sessionId,
messages,
}; };
} }
+7 -5
View File
@@ -8,7 +8,7 @@ import {
} from "@uncaged/workflow-agent-kit"; } from "@uncaged/workflow-agent-kit";
import { HermesAcpClient } from "./acp-client.js"; import { HermesAcpClient } from "./acp-client.js";
import { storeHermesRawOutput } from "./session-detail.js"; import { storeHermesSessionDetail } from "./session-detail.js";
function buildHistorySummary(steps: AgentContext["steps"]): string { function buildHistorySummary(steps: AgentContext["steps"]): string {
if (steps.length === 0) { if (steps.length === 0) {
@@ -63,8 +63,9 @@ export function createHermesAgent(): () => Promise<void> {
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> { async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
const fullPrompt = buildHermesPrompt(ctx); const fullPrompt = buildHermesPrompt(ctx);
await client.connect(process.cwd()); await client.connect(process.cwd());
const { text, sessionId } = await client.prompt(fullPrompt); const { text, sessionId, messages } = await client.prompt(fullPrompt);
const detailHash = await storeHermesRawOutput(ctx.store, text); const session = { session_id: sessionId, model: "", session_start: new Date().toISOString(), messages };
const { detailHash } = await storeHermesSessionDetail(ctx.store, session);
return { output: text, detailHash, sessionId }; return { output: text, detailHash, sessionId };
} }
@@ -75,8 +76,9 @@ export function createHermesAgent(): () => Promise<void> {
): Promise<AgentRunResult> { ): Promise<AgentRunResult> {
// Client is already connected from runHermes — same ACP session, // Client is already connected from runHermes — same ACP session,
// so the agent sees the full conversation history (crucial for retries). // so the agent sees the full conversation history (crucial for retries).
const { text, sessionId } = await client.prompt(message); const { text, sessionId, messages } = await client.prompt(message);
const detailHash = await storeHermesRawOutput(store, text); const session = { session_id: sessionId, model: "", session_start: new Date().toISOString(), messages };
const { detailHash } = await storeHermesSessionDetail(store, session);
return { output: text, detailHash, sessionId }; return { output: text, detailHash, sessionId };
} }