Skip to content

Commit 2835f03

Browse files
authored
Add metadata to tool calls, including structured tool output (#128)
Use the `_meta` field for tool calls to forward: - the original tool name (ie `Bash` etc) - the structured output of the tool call The output is received by using the `PostToolUse` hook from the SDK. The `tool_call_update` for the tool result is now only sent when both the hook callback and the original tool result (plain text) have been received. Claude Code describes [in its documentation ](https://docs.claude.com/en/api/agent-sdk/typescript#tool-output-types)the expected structure of the output for each tools. Unfortunately those are not part of the SDK, and [empirically I've found that there was some differences](https://github.com/getcmd-dev/cmd/blob/58c3100a36f7e8ec92e1fd04b3af3091867d6cfc/local-server/src/server/endpoints/sendMessage/acp/clients/claudeCode/types.ts#L265). Still I've found that having this structured output was useful for my client to provide a richer UX. Maybe Zed will too. I was not sure how to add tests for this change, given the current setup
1 parent 2710e14 commit 2835f03

File tree

4 files changed

+120
-1
lines changed

4 files changed

+120
-1
lines changed

src/acp-agent.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ import {
4747
planEntries,
4848
toolUpdateFromToolResult,
4949
ClaudePlanEntry,
50+
registerHookCallback,
51+
postToolUseHook,
5052
} from "./tools.js";
5153
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
5254
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
@@ -70,6 +72,16 @@ type BackgroundTerminal =
7072
pendingOutput: TerminalOutputResponse;
7173
};
7274

75+
/**
76+
* Extra metadata that the agent provides for each tool_call / tool_update update, under the `_meta.claudeCode` key.
77+
*/
78+
export type ToolUpdateMeta = {
79+
/* The name of the tool that was used in Claude Code. */
80+
toolName: string;
81+
/* The structured output provided by Claude Code. */
82+
toolResponse?: unknown;
83+
};
84+
7385
type ToolUseCache = {
7486
[key: string]: {
7587
type: "tool_use" | "server_tool_use" | "mcp_tool_use";
@@ -223,6 +235,13 @@ export class ClaudeAcpAgent implements Agent {
223235
...(process.env.CLAUDE_CODE_EXECUTABLE && {
224236
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
225237
}),
238+
hooks: {
239+
PostToolUse: [
240+
{
241+
hooks: [postToolUseHook],
242+
},
243+
],
244+
},
226245
};
227246

228247
const allowedTools = [];
@@ -371,6 +390,7 @@ export class ClaudeAcpAgent implements Agent {
371390
params.sessionId,
372391
this.toolUseCache,
373392
this.fileContentCache,
393+
this.client,
374394
)) {
375395
await this.client.sessionUpdate(notification);
376396
}
@@ -427,6 +447,7 @@ export class ClaudeAcpAgent implements Agent {
427447
params.sessionId,
428448
this.toolUseCache,
429449
this.fileContentCache,
450+
this.client,
430451
)) {
431452
await this.client.sessionUpdate(notification);
432453
}
@@ -772,6 +793,7 @@ export function toAcpNotifications(
772793
sessionId: string,
773794
toolUseCache: ToolUseCache,
774795
fileContentCache: { [key: string]: string },
796+
client: AgentSideConnection,
775797
): SessionNotification[] {
776798
if (typeof content === "string") {
777799
return [
@@ -837,13 +859,45 @@ export function toAcpNotifications(
837859
};
838860
}
839861
} else {
862+
// Register hook callback to receive the structured output from the hook
863+
registerHookCallback(chunk.id, {
864+
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
865+
const toolUse = toolUseCache[toolUseId];
866+
if (toolUse) {
867+
const update: SessionNotification["update"] = {
868+
_meta: {
869+
claudeCode: {
870+
toolResponse,
871+
toolName: toolUse.name,
872+
} satisfies ToolUpdateMeta,
873+
},
874+
toolCallId: toolUseId,
875+
sessionUpdate: "tool_call_update",
876+
};
877+
await client.sessionUpdate({
878+
sessionId,
879+
update,
880+
});
881+
} else {
882+
console.error(
883+
`[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`,
884+
);
885+
}
886+
},
887+
});
888+
840889
let rawInput;
841890
try {
842891
rawInput = JSON.parse(JSON.stringify(chunk.input));
843892
} catch {
844893
// ignore if we can't turn it to JSON
845894
}
846895
update = {
896+
_meta: {
897+
claudeCode: {
898+
toolName: chunk.name,
899+
} satisfies ToolUpdateMeta,
900+
},
847901
toolCallId: chunk.id,
848902
sessionUpdate: "tool_call",
849903
rawInput,
@@ -871,6 +925,11 @@ export function toAcpNotifications(
871925

872926
if (toolUse.name !== "TodoWrite") {
873927
update = {
928+
_meta: {
929+
claudeCode: {
930+
toolName: toolUse.name,
931+
} satisfies ToolUpdateMeta,
932+
},
874933
toolCallId: chunk.tool_use_id,
875934
sessionUpdate: "tool_call_update",
876935
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
@@ -905,6 +964,7 @@ export function streamEventToAcpNotifications(
905964
sessionId: string,
906965
toolUseCache: ToolUseCache,
907966
fileContentCache: { [key: string]: string },
967+
client: AgentSideConnection,
908968
): SessionNotification[] {
909969
const event = message.event;
910970
switch (event.type) {
@@ -915,6 +975,7 @@ export function streamEventToAcpNotifications(
915975
sessionId,
916976
toolUseCache,
917977
fileContentCache,
978+
client,
918979
);
919980
case "content_block_delta":
920981
return toAcpNotifications(
@@ -923,6 +984,7 @@ export function streamEventToAcpNotifications(
923984
sessionId,
924985
toolUseCache,
925986
fileContentCache,
987+
client,
926988
);
927989
// No content
928990
case "message_start":

src/lib.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export {
44
runAcp,
55
toAcpNotifications,
66
streamEventToAcpNotifications,
7+
type ToolUpdateMeta,
78
} from "./acp-agent.js";
89
export {
910
loadManagedSettings,

src/tests/acp-agent.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
22
import { spawn, spawnSync } from "child_process";
33
import {
44
Agent,
5+
AgentSideConnection,
56
AvailableCommand,
67
Client,
78
ClientSideConnection,
@@ -650,7 +651,14 @@ describe("tool conversions", () => {
650651
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
651652
};
652653
expect(
653-
toAcpNotifications(received.message.content, received.message.role, "test", {}, {}),
654+
toAcpNotifications(
655+
received.message.content,
656+
received.message.role,
657+
"test",
658+
{},
659+
{},
660+
{} as AgentSideConnection,
661+
),
654662
).toStrictEqual([
655663
{
656664
sessionId: "test",

src/tools.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
BetaWebFetchToolResultBlockParam,
1010
BetaWebSearchToolResultBlockParam,
1111
} from "@anthropic-ai/sdk/resources/beta.mjs";
12+
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
1213

1314
interface ToolInfo {
1415
title: string;
@@ -538,3 +539,50 @@ export function markdownEscape(text: string): string {
538539
}
539540
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
540541
}
542+
543+
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
544+
const toolUseCallbacks: {
545+
[toolUseId: string]: {
546+
onPostToolUseHook?: (
547+
toolUseID: string,
548+
toolInput: unknown,
549+
toolResponse: unknown,
550+
) => Promise<void>;
551+
};
552+
} = {};
553+
554+
/* Setup callbacks that will be called when receiving hooks from Claude Code */
555+
export const registerHookCallback = (
556+
toolUseID: string,
557+
{
558+
onPostToolUseHook,
559+
}: {
560+
onPostToolUseHook?: (
561+
toolUseID: string,
562+
toolInput: unknown,
563+
toolResponse: unknown,
564+
) => Promise<void>;
565+
},
566+
) => {
567+
toolUseCallbacks[toolUseID] = {
568+
onPostToolUseHook,
569+
};
570+
};
571+
572+
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
573+
export const postToolUseHook: HookCallback = async (
574+
input: any,
575+
toolUseID: string | undefined,
576+
): Promise<{ continue: boolean }> => {
577+
if (input.hook_event_name === "PostToolUse" && toolUseID) {
578+
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
579+
if (onPostToolUseHook) {
580+
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
581+
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
582+
} else {
583+
console.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
584+
delete toolUseCallbacks[toolUseID];
585+
}
586+
}
587+
return { continue: true };
588+
};

0 commit comments

Comments
 (0)