Skip to content

Commit 664df5f

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@3f892919d2b3ed8b094ac58b76014de866dcb53c
1 parent 9bdc38b commit 664df5f

6 files changed

Lines changed: 212 additions & 21 deletions

File tree

bun.lock

Lines changed: 0 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/agent-runtime/src/run-agent-step.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ import {
5151
buildUserMessageContent,
5252
expireMessages,
5353
} from './util/messages'
54-
import { countTokensJson } from './util/token-counter'
54+
import {
55+
countTokens,
56+
countTokensJson,
57+
countTokensMessages,
58+
} from './util/token-counter'
5559

5660
import type { AgentTemplate } from '@codebuff/common/types/agent-template'
5761
import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics'
@@ -99,7 +103,9 @@ export function toTokenCountInputSchema(
99103
if (inputSchema == null) return undefined
100104

101105
let jsonSchema: Record<string, unknown>
102-
if (typeof (inputSchema as { safeParse?: unknown }).safeParse === 'function') {
106+
if (
107+
typeof (inputSchema as { safeParse?: unknown }).safeParse === 'function'
108+
) {
103109
try {
104110
jsonSchema = z.toJSONSchema(inputSchema as z.ZodType, {
105111
io: 'input',
@@ -340,7 +346,9 @@ export const runAgentStep = async (
340346
}
341347

342348
const iterationNum = agentState.messageHistory.length
343-
const systemTokens = countTokensJson(system)
349+
// system is a plain string; count it directly rather than JSON-stringifying
350+
// it (which would add quotes and escape every newline).
351+
const systemTokens = countTokens(system)
344352

345353
let cacheDebugCorrelation:
346354
| ReturnType<typeof createCacheDebugSnapshot>
@@ -977,9 +985,12 @@ export async function loopAgentSteps(
977985
}),
978986
)
979987

988+
// Count structured message content (not JSON.stringify, which inflates the
989+
// count and counts image base64 as text); system is a plain string; tool
990+
// schemas stay JSON since that's roughly how the model sees them.
980991
const estimateContextTokensLocally = () =>
981-
countTokensJson(messagesWithStepPrompt) +
982-
countTokensJson(system) +
992+
countTokensMessages(messagesWithStepPrompt) +
993+
countTokens(system) +
983994
countTokensJson(toolsForTokenCount)
984995

985996
// Free (freebuff) runs never call the token-count web API: the awaited
@@ -1014,11 +1025,7 @@ export async function loopAgentSteps(
10141025
{ error: tokenCountResult.error },
10151026
'Failed to get token count from web API',
10161027
)
1017-
const estimatedTokens =
1018-
countTokensJson(currentAgentState.messageHistory) +
1019-
countTokensJson(system) +
1020-
countTokensJson(toolDefinitions)
1021-
currentAgentState.contextTokenCount = estimatedTokens
1028+
currentAgentState.contextTokenCount = estimateContextTokensLocally()
10221029
}
10231030
}
10241031

packages/agent-runtime/src/util/__tests__/messages.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,8 @@ describe('trimMessagesToFitTokenLimit', () => {
377377
const result = trimMessagesToFitTokenLimit({
378378
messages,
379379
systemTokens: 0,
380-
maxTotalTokens: 1000,
380+
// Below the messages' structured token total so truncation triggers.
381+
maxTotalTokens: 300,
381382
logger,
382383
})
383384

@@ -438,7 +439,8 @@ describe('trimMessagesToFitTokenLimit', () => {
438439
const result = trimMessagesToFitTokenLimit({
439440
messages,
440441
systemTokens: 0,
441-
maxTotalTokens: 1000,
442+
// Below the messages' structured token total so truncation triggers.
443+
maxTotalTokens: 500,
442444
logger,
443445
})
444446

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import {
4+
countTokens,
5+
countTokensJson,
6+
countTokensMessages,
7+
} from '../token-counter'
8+
9+
import type { Message } from '@codebuff/common/types/messages/codebuff-message'
10+
11+
describe('countTokensMessages', () => {
12+
test('counts text content plus per-message overhead', () => {
13+
const messages = [
14+
{ role: 'user', content: [{ type: 'text', text: 'hello world' }] },
15+
] as unknown as Message[]
16+
17+
const count = countTokensMessages(messages)
18+
// At least the text tokens; overhead makes it strictly greater.
19+
expect(count).toBeGreaterThan(countTokens('hello world'))
20+
})
21+
22+
test('counts tool-call inputs and tool-result payloads', () => {
23+
const messages = [
24+
{
25+
role: 'assistant',
26+
content: [
27+
{ type: 'text', text: 'reading' },
28+
{
29+
type: 'tool-call',
30+
toolCallId: '1',
31+
toolName: 'read_files',
32+
input: { paths: ['a.ts', 'b.ts'] },
33+
},
34+
],
35+
},
36+
{
37+
role: 'tool',
38+
toolCallId: '1',
39+
toolName: 'read_files',
40+
content: [{ type: 'json', value: { files: ['contents here'] } }],
41+
},
42+
] as unknown as Message[]
43+
44+
expect(countTokensMessages(messages)).toBeGreaterThan(0)
45+
})
46+
47+
test('is cheaper than JSON.stringify counting for structured messages', () => {
48+
const codeBlock = 'const x = "a \\"b\\" c";\n'.repeat(50)
49+
const messages = [
50+
{
51+
role: 'tool',
52+
toolCallId: '1',
53+
toolName: 'read_files',
54+
content: [
55+
{ type: 'json', value: { path: 'x.ts', content: codeBlock } },
56+
],
57+
},
58+
{ role: 'user', content: [{ type: 'text', text: codeBlock }] },
59+
] as unknown as Message[]
60+
61+
// Structured counting removes the JSON envelope for text parts, so it must
62+
// not exceed the whole-array JSON count.
63+
expect(countTokensMessages(messages)).toBeLessThanOrEqual(
64+
countTokensJson(messages),
65+
)
66+
})
67+
68+
test('does NOT count image/file base64 as text (uses a fixed estimate)', () => {
69+
const bigB64 = 'A'.repeat(500_000)
70+
const messages = [
71+
{
72+
role: 'user',
73+
content: [
74+
{ type: 'text', text: 'screenshot:' },
75+
{ type: 'image', image: bigB64, mediaType: 'image/png' },
76+
],
77+
},
78+
{
79+
role: 'tool',
80+
toolCallId: '1',
81+
toolName: 'browser',
82+
content: [{ type: 'media', data: bigB64, mediaType: 'image/png' }],
83+
},
84+
] as unknown as Message[]
85+
86+
const count = countTokensMessages(messages)
87+
// Two images at a ~1600-token ceiling each, plus a little text/overhead —
88+
// nowhere near the ~hundreds-of-thousands of tokens the base64 would add.
89+
expect(count).toBeLessThan(10_000)
90+
})
91+
92+
test('tolerates string content without iterating characters', () => {
93+
const text = 'a plain string message content'
94+
const messages = [{ role: 'user', content: text }] as unknown as Message[]
95+
96+
// Must count the string as one blob, not char-by-char via the default case.
97+
expect(countTokensMessages(messages)).toBeGreaterThan(countTokens(text))
98+
expect(countTokensMessages(messages)).toBeLessThan(countTokens(text) + 100)
99+
})
100+
101+
test('does not throw on missing/non-array content', () => {
102+
const messages = [
103+
{ role: 'assistant' },
104+
{ role: 'user', content: undefined },
105+
] as unknown as Message[]
106+
107+
expect(() => countTokensMessages(messages)).not.toThrow()
108+
})
109+
110+
test('falls back to JSON for unknown part shapes so it never under-counts', () => {
111+
const messages = [
112+
{
113+
role: 'user',
114+
content: [{ type: 'mystery', payload: 'some meaningful content here' }],
115+
},
116+
] as unknown as Message[]
117+
118+
expect(countTokensMessages(messages)).toBeGreaterThan(0)
119+
})
120+
})

packages/agent-runtime/src/util/messages.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { closeXml } from '@codebuff/common/util/xml'
77
import { cloneDeep, isEqual } from 'lodash'
88

99
import { simplifyTerminalCommandResults } from './simplify-tool-results'
10-
import { countTokensJson } from './token-counter'
10+
import { countTokensMessages } from './token-counter'
1111

1212
import type { System } from '../llm-api/claude'
1313
import type {
@@ -189,7 +189,7 @@ export function trimMessagesToFitTokenLimit(params: {
189189
const maxMessageTokens = maxTotalTokens - systemTokens
190190

191191
// Check if we're already under the limit
192-
const initialTokens = countTokensJson(messages)
192+
const initialTokens = countTokensMessages(messages)
193193

194194
if (initialTokens < maxMessageTokens) {
195195
return messages
@@ -231,7 +231,7 @@ export function trimMessagesToFitTokenLimit(params: {
231231
}
232232
shortenedMessages.reverse()
233233

234-
const requiredTokens = countTokensJson(
234+
const requiredTokens = countTokensMessages(
235235
shortenedMessages.filter((m) => m.keepDuringTruncation),
236236
)
237237
let removedTokens = 0
@@ -245,13 +245,13 @@ export function trimMessagesToFitTokenLimit(params: {
245245
filteredMessages.push(message)
246246
continue
247247
}
248-
removedTokens += countTokensJson(message)
248+
removedTokens += countTokensMessages([message])
249249
if (
250250
filteredMessages.length === 0 ||
251251
filteredMessages[filteredMessages.length - 1] !== placeholder
252252
) {
253253
filteredMessages.push(placeholder)
254-
removedTokens -= countTokensJson(replacementMessage)
254+
removedTokens -= countTokensMessages([replacementMessage])
255255
}
256256
}
257257

packages/agent-runtime/src/util/token-counter.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
import { LRUCache } from '@codebuff/common/util/lru-cache'
22
import { encode } from 'gpt-tokenizer/esm/model/gpt-4o'
33

4+
import type { Message } from '@codebuff/common/types/messages/codebuff-message'
5+
46
const ANTHROPIC_TOKEN_FUDGE_FACTOR = 1.35
57

8+
/** Flat per-image/file cost. Anthropic bills a large image at ~1600 tokens; we
9+
* use that ceiling instead of counting the base64 as text (which JSON.stringify
10+
* would do), where a single screenshot is hundreds of thousands of chars. */
11+
const IMAGE_TOKEN_ESTIMATE = 1600
12+
13+
/** Per-message structural overhead (role marker, delimiters) Anthropic adds on
14+
* top of the raw content. */
15+
const PER_MESSAGE_TOKEN_OVERHEAD = 8
16+
617
const TOKEN_COUNT_CACHE = new LRUCache<string, number>(1000)
718

819
export function countTokens(text: string): number {
@@ -27,8 +38,61 @@ export function countTokens(text: string): number {
2738
}
2839
}
2940

30-
export function countTokensJson(text: string | object): number {
31-
return countTokens(JSON.stringify(text))
41+
export function countTokensJson(value: unknown): number {
42+
// JSON.stringify(undefined) returns undefined; fall back to '' so countTokens
43+
// always gets a string.
44+
return countTokens(JSON.stringify(value) ?? '')
45+
}
46+
47+
/**
48+
* Estimate tokens for a list of messages by counting the content the model
49+
* actually tokenizes (text, tool inputs, tool results, a flat cost per image)
50+
* plus a small per-message overhead — not the JSON envelope. Avoids the
51+
* scaffolding inflation of `countTokensJson(messages)` and, crucially, counting
52+
* image/file base64 character-for-character. `ANTHROPIC_TOKEN_FUDGE_FACTOR` still
53+
* applies, so the estimate stays deliberately a touch above the true count.
54+
*/
55+
export function countTokensMessages(messages: Message[]): number {
56+
let total = 0
57+
for (const message of messages) {
58+
total += PER_MESSAGE_TOKEN_OVERHEAD
59+
60+
// content is typed as an array, but tolerate string / missing content the
61+
// same way the replaced JSON.stringify did (see getTextContent), so a stray
62+
// shape can't crash or mis-count the estimate.
63+
const content = (message as { content?: unknown }).content
64+
if (typeof content === 'string') {
65+
total += countTokens(content)
66+
continue
67+
}
68+
if (!Array.isArray(content)) {
69+
continue
70+
}
71+
72+
for (const part of content as Array<Record<string, unknown>>) {
73+
switch (part.type) {
74+
case 'text':
75+
case 'reasoning':
76+
total += countTokens(part.text as string)
77+
break
78+
case 'tool-call':
79+
total +=
80+
countTokens(part.toolName as string) + countTokensJson(part.input)
81+
break
82+
case 'json': // tool result payload
83+
total += countTokensJson(part.value)
84+
break
85+
case 'image':
86+
case 'file':
87+
case 'media':
88+
total += IMAGE_TOKEN_ESTIMATE
89+
break
90+
default: // unknown shape: JSON fallback so we never under-count
91+
total += countTokensJson(part)
92+
}
93+
}
94+
}
95+
return total
3296
}
3397

3498
export function countTokensForFiles(

0 commit comments

Comments
 (0)