Skip to content

Commit 7adbf86

Browse files
fix: resolve cyclic structure serialization crash in token counter
When the token count API call fails or is not available, the CLI fallback logic serializes using . However, contains raw Zod schema objects (), which have circular references, causing a TypeError. Changes: - Added a utility to strip circular references from objects. - Sanitize the inside at creation time using so it is a plain, serializable object. This also prevents similar serialization failures when saving run state to disk.
1 parent 366311e commit 7adbf86

3 files changed

Lines changed: 44 additions & 2 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
countTokens,
5858
countTokensJson,
5959
countTokensMessages,
60+
safeJsonStringify,
6061
} from './util/token-counter'
6162

6263
import type { AgentTemplate } from '@codebuff/common/types/agent-template'
@@ -940,7 +941,7 @@ export async function loopAgentSteps(
940941
const toolDefinitions = mapValues(tools, (tool) => ({
941942
description:
942943
typeof tool.description === 'string' ? tool.description : undefined,
943-
inputSchema: tool.inputSchema as {},
944+
inputSchema: JSON.parse(safeJsonStringify(tool.inputSchema) ?? 'null'),
944945
}))
945946

946947
const additionalToolDefinitionsWithCache = async () => {

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,33 @@ import {
44
countTokens,
55
countTokensJson,
66
countTokensMessages,
7+
safeJsonStringify,
78
} from '../token-counter'
89

910
import type { Message } from '@codebuff/common/types/messages/codebuff-message'
1011

12+
describe('safeJsonStringify', () => {
13+
test('handles circular references and drops function properties', () => {
14+
const schema: { name: string; self?: unknown; transform?: () => void } = {
15+
name: 'tool input',
16+
transform: () => {},
17+
}
18+
schema.self = schema
19+
20+
expect(safeJsonStringify(schema)).toBe(
21+
'{"name":"tool input","self":"[Circular]"}',
22+
)
23+
expect(() => countTokensJson(schema)).not.toThrow()
24+
expect(countTokensJson(schema)).toBeGreaterThan(0)
25+
})
26+
27+
test('preserves JSON string serialization when counting strings', () => {
28+
expect(countTokensJson('tool input')).toBe(
29+
countTokens(JSON.stringify('tool input')),
30+
)
31+
})
32+
})
33+
1134
describe('countTokensMessages', () => {
1235
test('counts text content plus per-message overhead', () => {
1336
const messages = [

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,27 @@ export function countTokens(text: string): number {
3838
}
3939
}
4040

41+
/**
42+
* Serialize arbitrary values for token counting and persisted tool metadata.
43+
* Circular references are represented explicitly; functions follow
44+
* JSON.stringify semantics and are omitted from object properties.
45+
*/
46+
export function safeJsonStringify(value: unknown): string | undefined {
47+
const seen = new WeakSet<object>()
48+
return JSON.stringify(value, (_key, nestedValue) => {
49+
if (typeof nestedValue === 'function') return undefined
50+
if (typeof nestedValue === 'object' && nestedValue !== null) {
51+
if (seen.has(nestedValue)) return '[Circular]'
52+
seen.add(nestedValue)
53+
}
54+
return nestedValue
55+
})
56+
}
57+
4158
export function countTokensJson(value: unknown): number {
4259
// JSON.stringify(undefined) returns undefined; fall back to '' so countTokens
4360
// always gets a string.
44-
return countTokens(JSON.stringify(value) ?? '')
61+
return countTokens(safeJsonStringify(value) ?? '')
4562
}
4663

4764
/**
@@ -95,6 +112,7 @@ export function countTokensMessages(messages: Message[]): number {
95112
return total
96113
}
97114

115+
98116
export function countTokensForFiles(
99117
files: Record<string, string | null>,
100118
): Record<string, number> {

0 commit comments

Comments
 (0)