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:
@@ -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 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user