Skip to content

Commit ee9ac10

Browse files
committed
count all tool inputs
Match message token estimates to the tool arguments OpenCode stores so large built-in, custom, and plugin tool inputs do not slip past sizing.
1 parent 2dc15e1 commit ee9ac10

File tree

2 files changed

+162
-14
lines changed

2 files changed

+162
-14
lines changed

lib/strategies/utils.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -81,23 +81,16 @@ export function estimateTokensBatch(texts: string[]): number {
8181
export function extractToolContent(part: any): string[] {
8282
const contents: string[] = []
8383

84-
if (part.tool === "question") {
85-
const questions = part.state?.input?.questions
86-
if (questions !== undefined) {
87-
const content = typeof questions === "string" ? questions : JSON.stringify(questions)
88-
contents.push(content)
89-
}
84+
if (part?.type !== "tool") {
9085
return contents
9186
}
9287

93-
if (part.tool === "edit" || part.tool === "write") {
94-
if (part.state?.input) {
95-
const inputContent =
96-
typeof part.state.input === "string"
97-
? part.state.input
98-
: JSON.stringify(part.state.input)
99-
contents.push(inputContent)
100-
}
88+
if (part.state?.input !== undefined) {
89+
const inputContent =
90+
typeof part.state.input === "string"
91+
? part.state.input
92+
: JSON.stringify(part.state.input)
93+
contents.push(inputContent)
10194
}
10295

10396
if (part.state?.status === "completed" && part.state?.output) {

tests/token-counting.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import type { WithParts } from "../lib/state"
4+
import {
5+
countAllMessageTokens,
6+
countToolTokens,
7+
estimateTokensBatch,
8+
extractToolContent,
9+
} from "../lib/strategies/utils"
10+
11+
function buildToolMessage(part: Record<string, any>): WithParts {
12+
return {
13+
info: {
14+
id: "msg-tool",
15+
role: "assistant",
16+
sessionID: "ses_token_counting",
17+
agent: "assistant",
18+
time: { created: 1 },
19+
} as WithParts["info"],
20+
parts: [part as any],
21+
}
22+
}
23+
24+
function buildToolPart(tool: string, state: Record<string, any>) {
25+
return {
26+
id: `tool-${tool}`,
27+
messageID: "msg-tool",
28+
sessionID: "ses_token_counting",
29+
type: "tool" as const,
30+
tool,
31+
callID: `call-${tool}`,
32+
state,
33+
}
34+
}
35+
36+
function assertCounted(part: Record<string, any>, expectedContents: string[]) {
37+
assert.deepEqual(extractToolContent(part), expectedContents)
38+
assert.equal(countToolTokens(part), estimateTokensBatch(expectedContents))
39+
assert.equal(
40+
countAllMessageTokens(buildToolMessage(part)),
41+
estimateTokensBatch(expectedContents),
42+
)
43+
}
44+
45+
test("counting includes input for large built-in tool calls", () => {
46+
const cases = [
47+
{
48+
tool: "compress",
49+
input: {
50+
topic: "Compression topic",
51+
content: [
52+
{ messageId: "m0001", topic: "Prior work", summary: "Compressed summary" },
53+
],
54+
},
55+
output: "compressed",
56+
},
57+
{
58+
tool: "apply_patch",
59+
input: {
60+
patchText: [
61+
"*** Begin Patch",
62+
"*** Update File: src/example.ts",
63+
"@@",
64+
"-oldLine()",
65+
"+newLine()",
66+
"*** End Patch",
67+
].join("\n"),
68+
},
69+
output: "Success. Updated the following files:\nM src/example.ts",
70+
},
71+
{
72+
tool: "task",
73+
input: {
74+
description: "Research bug",
75+
prompt: "Investigate the failing workflow and summarize root cause.",
76+
subagent_type: "general",
77+
command: "/investigate",
78+
},
79+
output: "Queued task ses_123",
80+
},
81+
{
82+
tool: "bash",
83+
input: {
84+
command: "python - <<'PY'\nprint(\"hello\")\nPY",
85+
description: "Runs inline Python script",
86+
workdir: "/tmp/project",
87+
},
88+
output: "hello",
89+
},
90+
{
91+
tool: "batch",
92+
input: {
93+
calls: [
94+
{ tool: "read", parameters: { filePath: "/tmp/a.txt" } },
95+
{ tool: "grep", parameters: { pattern: "TODO", path: "/tmp" } },
96+
],
97+
},
98+
output: [
99+
{ tool: "read", ok: true },
100+
{ tool: "grep", ok: true },
101+
],
102+
},
103+
{
104+
tool: "todowrite",
105+
input: {
106+
todos: [
107+
{ content: "Inspect bug", status: "in_progress", priority: "high" },
108+
{ content: "Write fix", status: "pending", priority: "high" },
109+
],
110+
},
111+
output: [{ content: "Inspect bug", status: "completed", priority: "high" }],
112+
},
113+
{
114+
tool: "question",
115+
input: {
116+
questions: [
117+
{
118+
question: "Use the safer option?",
119+
header: "Confirm",
120+
options: [{ label: "Yes", description: "Proceed safely" }],
121+
},
122+
],
123+
},
124+
output: ["Yes"],
125+
},
126+
]
127+
128+
for (const testCase of cases) {
129+
const part = buildToolPart(testCase.tool, {
130+
status: "completed",
131+
input: testCase.input,
132+
output: testCase.output,
133+
})
134+
const expectedContents = [
135+
JSON.stringify(testCase.input),
136+
typeof testCase.output === "string" ? testCase.output : JSON.stringify(testCase.output),
137+
]
138+
139+
assertCounted(part, expectedContents)
140+
}
141+
})
142+
143+
test("counting includes input for errored custom tools", () => {
144+
const customInput = {
145+
payload: "some large custom tool payload",
146+
options: { mode: "deep" },
147+
}
148+
const part = buildToolPart("custom_tool", {
149+
status: "error",
150+
input: customInput,
151+
error: "Tool execution failed",
152+
})
153+
154+
assertCounted(part, [JSON.stringify(customInput), "Tool execution failed"])
155+
})

0 commit comments

Comments
 (0)