Skip to content

Commit 44ef044

Browse files
fix(plugins): snapshot before_tool_call context payloads
1 parent b8c5a1e commit 44ef044

2 files changed

Lines changed: 110 additions & 12 deletions

File tree

src/agents/pi-tools.before-tool-call.context.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ describe("before_tool_call hook context payload", () => {
2626
content: [{ type: "text", text: "check this call" }],
2727
timestamp: Date.now(),
2828
},
29+
{
30+
role: "user",
31+
content: [{ type: "text", text: "followup" }],
32+
timestamp: Date.now(),
33+
},
2934
];
3035
const tools = [
3136
{
@@ -64,4 +69,79 @@ describe("before_tool_call hook context payload", () => {
6469
}),
6570
);
6671
});
72+
73+
it("clones and freezes params/messages/tools snapshots before dispatch", async () => {
74+
const runBeforeToolCall = vi.fn().mockResolvedValue(undefined);
75+
mockGetGlobalHookRunner.mockReturnValue({
76+
hasHooks: vi.fn((hookName: string) => hookName === "before_tool_call"),
77+
runBeforeToolCall,
78+
// oxlint-disable-next-line typescript/no-explicit-any
79+
} as any);
80+
81+
const params = { path: "/tmp/file" };
82+
const messages: AgentMessage[] = [
83+
{
84+
role: "user",
85+
content: [{ type: "text", text: "check this call" }],
86+
timestamp: Date.now(),
87+
},
88+
{
89+
role: "user",
90+
content: [{ type: "text", text: "followup" }],
91+
timestamp: Date.now(),
92+
},
93+
];
94+
const tools = [
95+
{
96+
name: "read",
97+
description: "Read a file",
98+
parameters: { type: "object", properties: { path: { type: "string" } } },
99+
},
100+
];
101+
102+
await runBeforeToolCallHook({
103+
toolName: "read",
104+
params,
105+
ctx: {
106+
getMessages: () => messages,
107+
getTools: () => tools,
108+
},
109+
});
110+
111+
const [event] = runBeforeToolCall.mock.calls[0] as [
112+
{
113+
params: Record<string, unknown>;
114+
messages?: AgentMessage[];
115+
tools?: Array<{ parameters?: { properties?: { path?: { type?: string } } } }>;
116+
},
117+
];
118+
119+
expect(event.params).not.toBe(params);
120+
expect(event.messages).not.toBe(messages);
121+
expect(event.tools).not.toBe(tools);
122+
expect(Object.isFrozen(event)).toBe(true);
123+
expect(Object.isFrozen(event.params)).toBe(true);
124+
expect(Object.isFrozen(event.messages ?? [])).toBe(true);
125+
expect(Object.isFrozen(event.messages?.[0] ?? {})).toBe(true);
126+
expect(Object.isFrozen(event.tools ?? [])).toBe(true);
127+
expect(Object.isFrozen(event.tools?.[0] ?? {})).toBe(true);
128+
129+
expect(() => {
130+
event.params.path = "/tmp/hacked";
131+
}).toThrow(TypeError);
132+
expect(() => {
133+
if (event.messages) {
134+
event.messages[0] = event.messages[1];
135+
}
136+
}).toThrow(TypeError);
137+
expect(() => {
138+
if (event.tools?.[0]?.parameters?.properties?.path) {
139+
event.tools[0].parameters.properties.path.type = "number";
140+
}
141+
}).toThrow(TypeError);
142+
143+
expect(params.path).toBe("/tmp/file");
144+
expect(messages[0]?.role).toBe("user");
145+
expect(tools[0]?.parameters?.properties?.path?.type).toBe("string");
146+
});
67147
});

src/agents/pi-tools.before-tool-call.ts

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,26 @@ const loadBeforeToolCallRuntime = createLazyRuntimeSurface(
4242
({ beforeToolCallRuntime }) => beforeToolCallRuntime,
4343
);
4444

45+
function freezeHookSnapshot<T>(value: T): T {
46+
if (Array.isArray(value)) {
47+
for (const item of value) {
48+
freezeHookSnapshot(item);
49+
}
50+
return Object.freeze(value);
51+
}
52+
if (isPlainObject(value)) {
53+
for (const nested of Object.values(value)) {
54+
freezeHookSnapshot(nested);
55+
}
56+
return Object.freeze(value);
57+
}
58+
return value;
59+
}
60+
61+
function cloneHookSnapshot<T>(value: T): T {
62+
return freezeHookSnapshot(structuredClone(value));
63+
}
64+
4565
function buildAdjustedParamsKey(params: { runId?: string; toolCallId: string }): string {
4666
if (params.runId && params.runId.trim()) {
4767
return `${params.runId}:${params.toolCallId}`;
@@ -199,18 +219,16 @@ export async function runBeforeToolCallHook(args: {
199219
...(args.ctx?.runId && { runId: args.ctx.runId }),
200220
...(args.toolCallId && { toolCallId: args.toolCallId }),
201221
};
202-
const hookResult = await hookRunner.runBeforeToolCall(
203-
{
204-
toolName,
205-
params: normalizedParams,
206-
...(args.ctx?.runId && { runId: args.ctx.runId }),
207-
...(args.toolCallId && { toolCallId: args.toolCallId }),
208-
...(args.ctx?.getSystemPrompt && { systemPrompt: args.ctx.getSystemPrompt() }),
209-
...(args.ctx?.getMessages && { messages: args.ctx.getMessages() }),
210-
...(args.ctx?.getTools && { tools: args.ctx.getTools() }),
211-
},
212-
toolContext,
213-
);
222+
const hookEvent = freezeHookSnapshot({
223+
toolName,
224+
params: cloneHookSnapshot(normalizedParams),
225+
...(args.ctx?.runId && { runId: args.ctx.runId }),
226+
...(args.toolCallId && { toolCallId: args.toolCallId }),
227+
...(args.ctx?.getSystemPrompt && { systemPrompt: args.ctx.getSystemPrompt() }),
228+
...(args.ctx?.getMessages && { messages: cloneHookSnapshot(args.ctx.getMessages()) }),
229+
...(args.ctx?.getTools && { tools: cloneHookSnapshot(args.ctx.getTools()) }),
230+
});
231+
const hookResult = await hookRunner.runBeforeToolCall(hookEvent, toolContext);
214232

215233
if (hookResult?.block) {
216234
return {

0 commit comments

Comments
 (0)