Skip to content

Commit 81d3ac3

Browse files
jpcarranza94claude
andauthored
fix: prevent Tool.define() wrapper accumulation on object-defined tools (#16952)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent eb6f1da commit 81d3ac3

2 files changed

Lines changed: 109 additions & 1 deletion

File tree

packages/opencode/src/tool/tool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export namespace Tool {
5555
return {
5656
id,
5757
init: async (initCtx) => {
58-
const toolInfo = init instanceof Function ? await init(initCtx) : init
58+
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }
5959
const execute = toolInfo.execute
6060
toolInfo.execute = async (args, ctx) => {
6161
try {
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, test, expect } from "bun:test"
2+
import z from "zod"
3+
import { Tool } from "../../src/tool/tool"
4+
5+
const params = z.object({ input: z.string() })
6+
const defaultArgs = { input: "test" }
7+
8+
function makeTool(id: string, executeFn?: () => void) {
9+
return {
10+
description: "test tool",
11+
parameters: params,
12+
async execute() {
13+
executeFn?.()
14+
return { title: "test", output: "ok", metadata: {} }
15+
},
16+
}
17+
}
18+
19+
describe("Tool.define", () => {
20+
test("object-defined tool does not mutate the original init object", async () => {
21+
const original = makeTool("test")
22+
const originalExecute = original.execute
23+
24+
const tool = Tool.define("test-tool", original)
25+
26+
await tool.init()
27+
await tool.init()
28+
await tool.init()
29+
30+
// The original object's execute should never be overwritten
31+
expect(original.execute).toBe(originalExecute)
32+
})
33+
34+
test("object-defined tool does not accumulate wrapper layers across init() calls", async () => {
35+
let executeCalls = 0
36+
37+
const tool = Tool.define("test-tool", makeTool("test", () => executeCalls++))
38+
39+
// Call init() many times to simulate many agentic steps
40+
for (let i = 0; i < 100; i++) {
41+
await tool.init()
42+
}
43+
44+
// Resolve the tool and call execute
45+
const resolved = await tool.init()
46+
executeCalls = 0
47+
48+
// Capture the stack trace inside execute to measure wrapper depth
49+
let stackInsideExecute = ""
50+
const origExec = resolved.execute
51+
resolved.execute = async (args: any, ctx: any) => {
52+
const result = await origExec.call(resolved, args, ctx)
53+
const err = new Error()
54+
stackInsideExecute = err.stack || ""
55+
return result
56+
}
57+
58+
await resolved.execute(defaultArgs, {} as any)
59+
expect(executeCalls).toBe(1)
60+
61+
// Count how many times tool.ts appears in the stack.
62+
// With the fix: 1 wrapper layer (from the most recent init()).
63+
// Without the fix: 101 wrapper layers from accumulated closures.
64+
const toolTsFrames = stackInsideExecute.split("\n").filter((l) => l.includes("tool.ts")).length
65+
expect(toolTsFrames).toBeLessThan(5)
66+
})
67+
68+
test("function-defined tool returns fresh objects and is unaffected", async () => {
69+
const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test")))
70+
71+
const first = await tool.init()
72+
const second = await tool.init()
73+
74+
// Function-defined tools return distinct objects each time
75+
expect(first).not.toBe(second)
76+
})
77+
78+
test("object-defined tool returns distinct objects per init() call", async () => {
79+
const tool = Tool.define("test-copy", makeTool("test"))
80+
81+
const first = await tool.init()
82+
const second = await tool.init()
83+
84+
// Each init() should return a separate object so wrappers don't accumulate
85+
expect(first).not.toBe(second)
86+
})
87+
88+
test("validation still works after many init() calls", async () => {
89+
const tool = Tool.define("test-validation", {
90+
description: "validation test",
91+
parameters: z.object({ count: z.number().int().positive() }),
92+
async execute(args) {
93+
return { title: "test", output: String(args.count), metadata: {} }
94+
},
95+
})
96+
97+
for (let i = 0; i < 100; i++) {
98+
await tool.init()
99+
}
100+
101+
const resolved = await tool.init()
102+
103+
const result = await resolved.execute({ count: 42 }, {} as any)
104+
expect(result.output).toBe("42")
105+
106+
await expect(resolved.execute({ count: -1 }, {} as any)).rejects.toThrow("invalid arguments")
107+
})
108+
})

0 commit comments

Comments
 (0)