Skip to content

Commit 5831474

Browse files
committed
Fix Hermes tool streaming and formatting
1 parent dcfb3af commit 5831474

8 files changed

Lines changed: 897 additions & 31 deletions

File tree

apps/server/src/provider/Layers/HermesAdapter.test.ts

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ it.layer(testLayer)("HermesAdapter", (it) => {
279279
}),
280280
);
281281

282-
it.effect("emits correlated native tool lifecycle events with full tool data", () =>
282+
it.effect("emits correlated native tool lifecycle events with canonical dynamic-tool data", () =>
283283
Effect.gen(function* () {
284284
const { adapter } = yield* HermesAdapterTestHarness;
285285
const threadId = ThreadId.make("hermes-tool-thread");
@@ -333,17 +333,112 @@ it.layer(testLayer)("HermesAdapter", (it) => {
333333
const completed = events[2];
334334
NodeAssert.equal(started?.itemId, completed?.itemId);
335335
NodeAssert.deepEqual(completed?.payload, {
336-
itemType: "mcp_tool_call",
336+
itemType: "dynamic_tool_call",
337337
status: "completed",
338338
title: "Read skill",
339339
detail: "Skill loaded",
340340
data: {
341341
toolCallId: "call-skill",
342342
item: {
343-
toolCallId: "call-skill",
344-
name: "skill_view",
345-
input: { name: "query" },
346-
result: { output: "Skill loaded" },
343+
type: "dynamicToolCall",
344+
id: "call-skill",
345+
tool: "skill_view",
346+
arguments: { name: "query" },
347+
status: "completed",
348+
success: true,
349+
result: "Skill loaded",
350+
},
351+
},
352+
});
353+
}),
354+
);
355+
356+
it.effect("formats command and patch callbacks as canonical T3 tool items", () =>
357+
Effect.gen(function* () {
358+
const { adapter } = yield* HermesAdapterTestHarness;
359+
const threadId = ThreadId.make("hermes-formatted-tool-thread");
360+
yield* adapter.startSession({
361+
provider: ProviderDriverKind.make("hermes"),
362+
threadId,
363+
runtimeMode: "full-access",
364+
});
365+
366+
const eventsFiber = yield* adapter.streamEvents.pipe(
367+
Stream.take(3),
368+
Stream.runCollect,
369+
Effect.forkChild,
370+
);
371+
yield* Effect.yieldNow;
372+
const turn = yield* adapter.sendTurn({ threadId, input: "run and patch" });
373+
const sourceMessageId = `hermes-user:${turn.turnId}`;
374+
yield* adapter.receiveCallback({
375+
protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION,
376+
requestId: "callback-command-request",
377+
deliveryId: "callback-command-delivery",
378+
type: "tool.completed",
379+
chatId: "t3agent",
380+
threadId,
381+
sourceMessageId,
382+
toolCallId: "call-command",
383+
name: "terminal",
384+
input: { command: "pnpm test", cwd: "/workspace" },
385+
result: "Tests passed",
386+
isError: false,
387+
});
388+
yield* adapter.receiveCallback({
389+
protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION,
390+
requestId: "callback-patch-request",
391+
deliveryId: "callback-patch-delivery",
392+
type: "tool.completed",
393+
chatId: "t3agent",
394+
threadId,
395+
sourceMessageId,
396+
toolCallId: "call-patch",
397+
name: "patch",
398+
input: {
399+
patch:
400+
"*** Begin Patch\n*** Update File: apps/web/src/App.tsx\n@@\n-old\n+new\n*** End Patch",
401+
},
402+
result: "Done!",
403+
isError: false,
404+
});
405+
406+
const events = Array.from(yield* Fiber.join(eventsFiber));
407+
NodeAssert.deepEqual(events[1]?.payload, {
408+
itemType: "command_execution",
409+
status: "completed",
410+
title: "Terminal",
411+
detail: "Tests passed",
412+
data: {
413+
toolCallId: "call-command",
414+
item: {
415+
type: "commandExecution",
416+
id: "call-command",
417+
command: "pnpm test",
418+
cwd: "/workspace",
419+
commandActions: [{ type: "unknown", command: "pnpm test" }],
420+
status: "completed",
421+
aggregatedOutput: "Tests passed",
422+
},
423+
},
424+
});
425+
NodeAssert.deepEqual(events[2]?.payload, {
426+
itemType: "file_change",
427+
status: "completed",
428+
title: "Edited files",
429+
data: {
430+
toolCallId: "call-patch",
431+
item: {
432+
type: "fileChange",
433+
id: "call-patch",
434+
status: "completed",
435+
changes: [
436+
{
437+
path: "apps/web/src/App.tsx",
438+
kind: { type: "update" },
439+
diff: "*** Begin Patch\n*** Update File: apps/web/src/App.tsx\n@@\n-old\n+new\n*** End Patch",
440+
},
441+
],
347442
},
348443
},
349444
});

apps/server/src/provider/Layers/HermesAdapter.ts

Lines changed: 211 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -253,27 +253,217 @@ function hermesToolTitle(name: string): string {
253253
return titles[name] ?? name.replaceAll("_", " ").replace(/^\w/, (value) => value.toUpperCase());
254254
}
255255

256-
function hermesToolItemType(
257-
name: string,
258-
):
256+
type HermesToolItemType =
259257
| "command_execution"
260258
| "file_change"
261259
| "mcp_tool_call"
260+
| "dynamic_tool_call"
262261
| "collab_agent_tool_call"
263262
| "web_search"
264-
| "image_view" {
263+
| "image_view";
264+
265+
type HermesToolStatus = "inProgress" | "completed" | "failed";
266+
267+
function asRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
268+
return typeof value === "object" && value !== null && !Array.isArray(value)
269+
? (value as Readonly<Record<string, unknown>>)
270+
: undefined;
271+
}
272+
273+
function stringField(
274+
record: Readonly<Record<string, unknown>> | undefined,
275+
...keys: ReadonlyArray<string>
276+
): string | undefined {
277+
for (const key of keys) {
278+
const value = record?.[key];
279+
if (typeof value === "string" && value.trim()) return value;
280+
}
281+
return undefined;
282+
}
283+
284+
function hermesMcpIdentity(
285+
name: string,
286+
input: unknown,
287+
): { readonly server: string; readonly tool: string } | undefined {
288+
const record = asRecord(input);
289+
const explicitServer = stringField(record, "server");
290+
const explicitTool = stringField(record, "tool", "operation");
291+
if (explicitServer && explicitTool) return { server: explicitServer, tool: explicitTool };
292+
293+
const parts = name.split("__").filter(Boolean);
294+
if (parts[0]?.toLowerCase() === "mcp" && parts.length >= 3) {
295+
return {
296+
server: parts[1] ?? "hermes",
297+
tool: parts.slice(2).join("__"),
298+
};
299+
}
300+
if (name.toLowerCase().startsWith("mcp")) {
301+
return { server: explicitServer ?? "hermes", tool: explicitTool ?? name };
302+
}
303+
return undefined;
304+
}
305+
306+
function hermesToolItemType(name: string, input: unknown): HermesToolItemType {
265307
if (name === "terminal" || name === "execute_code" || name === "process") {
266308
return "command_execution";
267309
}
268-
if (name === "write_file" || name === "patch") return "file_change";
310+
if (
311+
name === "write_file" ||
312+
name === "patch" ||
313+
name === "apply_patch" ||
314+
name === "edit_file" ||
315+
name === "delete_file" ||
316+
name === "move_file"
317+
) {
318+
return "file_change";
319+
}
320+
if (hermesMcpIdentity(name, input)) return "mcp_tool_call";
269321
if (name === "web_search" || name === "web_extract") return "web_search";
270322
if (name === "computer_use" || name === "image_view") return "image_view";
271323
if (name === "delegate_task") return "collab_agent_tool_call";
272-
return "mcp_tool_call";
324+
return "dynamic_tool_call";
273325
}
274326

275-
function toolResultData(result: unknown): unknown {
276-
return typeof result === "string" ? { output: result } : result;
327+
function resultText(result: unknown): string | undefined {
328+
if (typeof result === "string") return result;
329+
if (result === undefined) return undefined;
330+
try {
331+
return JSON.stringify(result);
332+
} catch {
333+
return String(result);
334+
}
335+
}
336+
337+
function patchChanges(input: unknown): ReadonlyArray<{
338+
readonly path: string;
339+
readonly kind:
340+
| { readonly type: "add" }
341+
| { readonly type: "delete" }
342+
| { readonly type: "update" };
343+
readonly diff: string;
344+
}> {
345+
const record = asRecord(input);
346+
const patch = stringField(record, "patch", "diff") ?? "";
347+
const changes: Array<{
348+
readonly path: string;
349+
readonly kind:
350+
| { readonly type: "add" }
351+
| { readonly type: "delete" }
352+
| { readonly type: "update" };
353+
readonly diff: string;
354+
}> = [];
355+
const seen = new Set<string>();
356+
const add = (path: string, kind: { readonly type: "add" | "delete" | "update" }): void => {
357+
const normalized = path.trim();
358+
if (!normalized || normalized === "/dev/null" || seen.has(normalized)) return;
359+
seen.add(normalized);
360+
changes.push({ path: normalized, kind, diff: patch });
361+
};
362+
363+
for (const line of patch.split(/\r?\n/u)) {
364+
const marker = /^\*\*\* (Add|Update|Delete) File: (.+)$/u.exec(line);
365+
if (marker) {
366+
const operation = marker[1];
367+
const path = marker[2];
368+
if (path) {
369+
add(path, {
370+
type: operation === "Add" ? "add" : operation === "Delete" ? "delete" : "update",
371+
});
372+
}
373+
continue;
374+
}
375+
const unified = /^\+\+\+ (?:b\/)?(.+)$/u.exec(line);
376+
if (unified?.[1]) add(unified[1], { type: "update" });
377+
}
378+
379+
if (changes.length === 0) {
380+
const path = stringField(record, "path", "file_path", "filePath", "filename");
381+
if (path) add(path, { type: "update" });
382+
}
383+
return changes;
384+
}
385+
386+
function hermesToolItem(
387+
itemType: HermesToolItemType,
388+
callback: Extract<HermesBridgeHermesToT3Request, { type: "tool.started" | "tool.completed" }>,
389+
status: HermesToolStatus,
390+
): unknown {
391+
const input = asRecord(callback.input) ?? {};
392+
const completed = callback.type === "tool.completed";
393+
const output = completed ? resultText(callback.result) : undefined;
394+
switch (itemType) {
395+
case "command_execution": {
396+
const command =
397+
stringField(input, "command", "code", "process") ?? hermesToolTitle(callback.name);
398+
return {
399+
type: "commandExecution",
400+
id: callback.toolCallId,
401+
command,
402+
cwd: stringField(input, "cwd", "workdir") ?? "",
403+
commandActions: [{ type: "unknown", command }],
404+
status,
405+
...(output !== undefined ? { aggregatedOutput: output } : {}),
406+
};
407+
}
408+
case "file_change":
409+
return {
410+
type: "fileChange",
411+
id: callback.toolCallId,
412+
status,
413+
changes: patchChanges(input),
414+
};
415+
case "mcp_tool_call": {
416+
const identity = hermesMcpIdentity(callback.name, input) ?? {
417+
server: "hermes",
418+
tool: callback.name,
419+
};
420+
return {
421+
type: "mcpToolCall",
422+
id: callback.toolCallId,
423+
server: identity.server,
424+
tool: identity.tool,
425+
arguments: input,
426+
status,
427+
...(completed ? { result: callback.result } : {}),
428+
};
429+
}
430+
case "web_search":
431+
return {
432+
type: "webSearch",
433+
id: callback.toolCallId,
434+
query: stringField(input, "query", "q", "url") ?? "",
435+
...(completed ? { results: callback.result } : {}),
436+
};
437+
case "image_view":
438+
return {
439+
type: "imageView",
440+
id: callback.toolCallId,
441+
path: stringField(input, "path", "image_path") ?? "",
442+
};
443+
case "collab_agent_tool_call":
444+
return {
445+
type: "collabAgentToolCall",
446+
id: callback.toolCallId,
447+
tool: callback.name,
448+
arguments: input,
449+
status,
450+
...(completed ? { result: callback.result } : {}),
451+
};
452+
case "dynamic_tool_call":
453+
return {
454+
type: "dynamicToolCall",
455+
id: callback.toolCallId,
456+
tool: callback.name,
457+
arguments: input,
458+
status,
459+
...(completed
460+
? {
461+
success: !callback.isError,
462+
result: callback.result,
463+
}
464+
: {}),
465+
};
466+
}
277467
}
278468

279469
function toolResultDetail(result: unknown): string | undefined {
@@ -528,28 +718,26 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (
528718
threadId,
529719
callback.type === "tool.started" ? "tool-started" : "tool-completed",
530720
);
531-
const itemType = hermesToolItemType(callback.name);
532-
const title = hermesToolTitle(callback.name);
533-
const item = {
534-
toolCallId: callback.toolCallId,
535-
name: callback.name,
536-
input: callback.input,
537-
...(callback.type === "tool.completed" ? { result: toolResultData(callback.result) } : {}),
538-
};
721+
const itemType = hermesToolItemType(callback.name, callback.input);
722+
const status: HermesToolStatus =
723+
callback.type === "tool.started" ? "inProgress" : callback.isError ? "failed" : "completed";
724+
const mcpIdentity =
725+
itemType === "mcp_tool_call" ? hermesMcpIdentity(callback.name, callback.input) : undefined;
726+
const title = mcpIdentity
727+
? `${mcpIdentity.server} · ${mcpIdentity.tool}`
728+
: hermesToolTitle(callback.name);
729+
const item = hermesToolItem(itemType, callback, status);
539730
const detail =
540-
callback.type === "tool.completed" ? toolResultDetail(callback.result) : undefined;
731+
callback.type === "tool.completed" && itemType !== "file_change"
732+
? toolResultDetail(callback.result)
733+
: undefined;
541734
yield* publish({
542735
...base,
543736
type: callback.type === "tool.started" ? "item.started" : "item.completed",
544737
itemId: RuntimeItemId.make(`hermes-tool:${callback.toolCallId}`),
545738
payload: {
546739
itemType,
547-
status:
548-
callback.type === "tool.started"
549-
? "inProgress"
550-
: callback.isError
551-
? "failed"
552-
: "completed",
740+
status,
553741
title,
554742
...(detail ? { detail } : {}),
555743
data: {

0 commit comments

Comments
 (0)