diff --git a/common/src/mcp/__tests__/mcp-content-mapping.test.ts b/common/src/mcp/__tests__/mcp-content-mapping.test.ts new file mode 100644 index 0000000000..1ded5258a4 --- /dev/null +++ b/common/src/mcp/__tests__/mcp-content-mapping.test.ts @@ -0,0 +1,102 @@ +import { describe, test, expect } from 'bun:test' + +import { mcpContentToToolResultOutputs } from '../client' + +/** + * Regression tests for MCP tool-result content mapping. + * + * Tool results live in message history and are replayed into every later + * prompt build, and the AI SDK base64-decodes file-part data at prompt + * build. Text content therefore never travels as media: prose stored as + * media died with "The string contains invalid characters" on every + * subsequent turn, permanently, because the poisoned message replays from + * history. + */ +describe('mcpContentToToolResultOutputs resources', () => { + /** + * Given: an MCP resource whose contents are plain text. + * When: it is mapped. + * Then: the output is a json value carrying that text - never media. + */ + test('maps text resource to json value not media', () => { + const outputs = mcpContentToToolResultOutputs([ + { + type: 'resource', + resource: { + uri: 'file:///notes.txt', + mimeType: 'text/plain', + text: 'Resource 1: This is a plain text resource.', + }, + }, + ] as never) + + expect(outputs).toEqual([ + { + type: 'json', + value: 'Resource 1: This is a plain text resource.', + }, + ]) + }) + + /** + * Given: an MCP resource carrying binary image data. + * When: it is mapped. + * Then: the output stays media with the server's mime type, because + * every provider path accepts image file parts. + */ + test('keeps image resource as media with server mime type', () => { + const outputs = mcpContentToToolResultOutputs([ + { + type: 'resource', + resource: { + uri: 'file:///logo.png', + mimeType: 'image/png', + blob: 'aGVsbG8=', + }, + }, + ] as never) + + expect(outputs).toHaveLength(1) + expect(outputs[0].type).toBe('media') + expect((outputs[0] as { mediaType?: string }).mediaType).toBe('image/png') + }) + + /** + * Given: an MCP resource carrying non-image binary data. + * When: it is mapped. + * Then: the output is descriptive text, not media - media here killed + * the OpenAI-compatible converter at prompt build (session death). + */ + test('maps non-image binary resource to descriptive text not media', () => { + const outputs = mcpContentToToolResultOutputs([ + { + type: 'resource', + resource: { + uri: 'file:///archive.gz', + mimeType: 'application/gzip', + blob: 'aGVsbG8=', + }, + }, + ] as never) + + expect(outputs[0].type).toBe('json') + + const value = (outputs[0] as { value: string }).value + expect(value).toContain('application/gzip') + expect(value).toContain('not displayable') + }) + + /** + * Given: an ordinary MCP text content block (no resource involved). + * When: it is mapped. + * Then: it stays a json value - the extraction must not alter the + * pre-existing text mapping. + */ + test('maps plain text content to json value', () => { + const outputs = mcpContentToToolResultOutputs([ + { type: 'text', text: 'Echo: hello' }, + ] as never) + + expect(outputs).toEqual([{ type: 'json', value: 'Echo: hello' }]) + }) +}) diff --git a/common/src/mcp/client.ts b/common/src/mcp/client.ts index 5a5608d57f..fe2b526cbc 100644 --- a/common/src/mcp/client.ts +++ b/common/src/mcp/client.ts @@ -181,18 +181,14 @@ function getResourceData( return '' } -export async function callMCPTool( - clientId: string, - ...args: Parameters -): Promise { - const client = runningClients[clientId] - if (!client) { - throw new Error(`callTool: client not found with id: ${clientId}`) - } - const callResult = await client.callTool(...args) - const result = callResult as CallToolResult - const content = result.content - +/** + * Convert MCP tool-result content blocks into codebuff tool-result outputs. + * Pure function (no client access) so conversion rules are testable in + * isolation. No behavior change from the previous inline map. + */ +export function mcpContentToToolResultOutputs( + content: CallToolResult['content'], +): ToolResultOutput[] { return content.map((c: (typeof content)[number]) => { if (c.type === 'text') { return { @@ -215,10 +211,34 @@ export async function callMCPTool( } satisfies ToolResultOutput } if (c.type === 'resource') { + // A resource with text contents is text, not media. Wrapping prose as + // media makes the AI SDK base64-decode it when rebuilding the prompt on + // every later turn, which dies with "The string contains invalid + // characters" forever, since the poisoned message replays from history. + if ('text' in c.resource) { + return { + type: 'json', + value: c.resource.text, + } satisfies ToolResultOutput + } + const mimeType = c.resource.mimeType ?? 'application/octet-stream' + // Only images stay media: every provider path (including the + // OpenAI-compatible chat converter used by GLM) accepts image file + // parts but throws on anything else — and a thrown converter poisons + // the whole session, since the message replays on every later turn. + if (mimeType.startsWith('image/')) { + return { + type: 'media', + data: getResourceData(c.resource), + mediaType: mimeType, + } satisfies ToolResultOutput + } + // Other binary resources (gzip, PDF, ...): surface metadata instead of + // undecodable bytes. + const blobData = getResourceData(c.resource) return { - type: 'media', - data: getResourceData(c.resource), - mediaType: c.resource.mimeType ?? 'text/plain', + type: 'json', + value: `[Binary resource ${c.resource.uri}: ${mimeType}, ~${Math.round((blobData.length * 3) / 4)} bytes, not displayable]`, } satisfies ToolResultOutput } const fallbackValue = @@ -231,3 +251,18 @@ export async function callMCPTool( } satisfies ToolResultOutput }) } + +export async function callMCPTool( + clientId: string, + ...args: Parameters +): Promise { + const client = runningClients[clientId] + if (!client) { + throw new Error(`callTool: client not found with id: ${clientId}`) + } + const callResult = await client.callTool(...args) + const result = callResult as CallToolResult + const content = result.content + + return mcpContentToToolResultOutputs(content) +} diff --git a/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts b/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts new file mode 100644 index 0000000000..c73fb1d5e9 --- /dev/null +++ b/packages/agent-runtime/src/__tests__/mcp-schema-store.test.ts @@ -0,0 +1,105 @@ +import { describe, test, expect } from 'bun:test' +import { z } from 'zod/v4' + +import { getMCPToolData } from '../mcp' +import { MCP_TOOL_SEPARATOR } from '../mcp-constants' + +/** + * Regression tests for MCP tool-schema storage. + * + * Tool definitions returned by getMCPToolData are persisted in run/session + * state, which is snapshotted and JSON-serialized on every turn. Schemas + * must be stored verbatim: storing converted live zod instances instead + * round-trips to def/shape internals and can carry cycles that detonate + * JSON.stringify over the whole run state ("cannot serialize cyclic + * structures", session death from turn 2 onward). + */ +describe('getMCPToolData schema storage', () => { + /** + * Given: one MCP server reporting one tool with a JSON Schema. + * When: getMCPToolData stores it. + * Then: the stored schema round-trips through JSON as the exact schema + * the server sent - the persisted-state contract. + */ + test('stores the server JSON Schema verbatim and JSON round-trips it', async () => { + const serverSchema = { + type: 'object', + properties: { + location: { type: 'string', enum: ['NYC', 'LA'] }, + units: { type: 'string', description: 'metric or imperial' }, + }, + required: ['location'], + } + const writeTo: Record = {} + + await getMCPToolData({ + toolNames: ['weather/get_forecast'], + mcpServers: { + weather: { command: 'echo', args: [] }, + } as never, + writeTo: writeTo as never, + requestMcpToolData: async () => [ + { + name: 'get_forecast', + description: 'Get the forecast', + inputSchema: serverSchema, + }, + ], + }) + + const stored = writeTo[`weather${MCP_TOOL_SEPARATOR}get_forecast`] + const roundTripped = JSON.parse(JSON.stringify(stored.inputSchema)) + expect(roundTripped).toEqual(serverSchema) + }) + + /** + * Given: two servers each reporting one tool with a distinct schema. + * When: getMCPToolData stores both. + * Then: each server's tool carries its own schema, namespaced with the + * internal separator, verbatim and JSON-serializable. + */ + test('stores distinct schemas per server without conversion', async () => { + const schemaA = { type: 'object', properties: { a: { type: 'number' } } } + const schemaB = { type: 'string' } + const writeTo: Record = {} + + await getMCPToolData({ + toolNames: [], + mcpServers: { + alpha: { command: 'echo', args: [] }, + beta: { command: 'echo', args: [] }, + } as never, + writeTo: writeTo as never, + requestMcpToolData: async ({ toolNames }: { toolNames: unknown }) => { + void toolNames + return [ + { name: 't1', description: 'A', inputSchema: schemaA }, + { name: 't2', description: 'B', inputSchema: schemaB }, + ] + }, + }) + + const alphaStored = writeTo[`alpha${MCP_TOOL_SEPARATOR}t1`] + const betaStored = writeTo[`beta${MCP_TOOL_SEPARATOR}t2`] + expect(JSON.parse(JSON.stringify(alphaStored.inputSchema))).toEqual(schemaA) + expect(JSON.parse(JSON.stringify(betaStored.inputSchema))).toEqual(schemaB) + expect(betaStored.description).toBe('B') + }) + + /** + * Given: the old implementation stored convertJsonSchemaToZod output. + * When: a live zod instance is round-tripped through JSON. + * Then: the result is zod internals (def/shape), not the server schema - + * the failure mode this contract guards against, kept here as a + * characterization so a regression to zod storage cannot pass silently. + */ + test('keeps the zod storage failure mode characterized as non passing', () => { + const serverSchema = { type: 'object', properties: { q: { type: 'string' } } } + const zodInstance = z.object({ q: z.string() }) + + const roundTripped = JSON.parse(JSON.stringify(zodInstance)) + + expect(roundTripped).not.toEqual(serverSchema) + expect(roundTripped.def).toBeDefined() + }) +}) diff --git a/packages/agent-runtime/src/mcp.ts b/packages/agent-runtime/src/mcp.ts index a7390f219c..c797d3fe60 100644 --- a/packages/agent-runtime/src/mcp.ts +++ b/packages/agent-runtime/src/mcp.ts @@ -1,5 +1,4 @@ import { getErrorObject } from '@codebuff/common/util/error' -import { convertJsonSchemaToZod } from 'zod-from-json-schema' import { MCP_TOOL_SEPARATOR } from './mcp-constants' @@ -55,12 +54,21 @@ export async function getMCPToolData( }) for (const { name, description, inputSchema } of mcpData) { + // Store the raw JSON Schema from the server, NOT the converted Zod + // schema. Tool definitions are persisted in run state / session + // state and must stay JSON-serializable; Zod instances are cyclic + // and make any JSON.stringify over that state detonate. Consumers + // convert at point of use (ensureZodSchema / toTokenCountInputSchema). writeTo[mcpName + MCP_TOOL_SEPARATOR + name] = { - inputSchema: convertJsonSchemaToZod(inputSchema as any) as any, + inputSchema: inputSchema as {}, endsAgentStep: true, description, } } + logger?.debug( + { mcpServer: mcpName, toolCount: mcpData.length }, + `Loaded ${mcpData.length} tool(s) from MCP server "${mcpName}".`, + ) } catch (error) { // A failed MCP server (e.g. a stdio server that can't be spawned) // should disable just its own tools, not abort the whole turn. The diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index 9a97508e26..3030366933 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -22,9 +22,12 @@ import { userMessage, } from '@codebuff/common/util/messages' import { type ToolSet } from 'ai' -import { cloneDeep, mapValues } from 'lodash' +import { mapValues } from 'lodash' import z from 'zod/v4' +import { cloneDeepKeepingZod } from './util/zod-safe-clone' +import { toTokenCountInputSchema } from './util/to-json-schema' + import { maybeCompactHistory } from './compact-history' import { CACHE_DEBUG_FULL_LOGGING } from './constants' import { getMCPToolData } from './mcp' @@ -98,47 +101,9 @@ import type { ProjectFileContext, } from '@codebuff/common/util/file' -// Convert a tool's stored inputSchema into JSON Schema suitable for Anthropic's -// count_tokens API. Built-in and MCP tools store a Zod schema here; serializing -// it raw ships Zod internals (`def`/`shape`) instead of JSON Schema, so token -// counts are computed against garbage and any schema whose top-level isn't an -// object (e.g. a union → `anyOf`) arrives without `type`, which the API rejects -// with `tools.N.custom.input_schema.type: Field required`. We convert to JSON -// Schema and guarantee a top-level `type: 'object'`. -export function toTokenCountInputSchema( - inputSchema: unknown, -): Record | undefined { - if (inputSchema == null) return undefined - - let jsonSchema: Record - if ( - typeof (inputSchema as { safeParse?: unknown }).safeParse === 'function' - ) { - try { - jsonSchema = z.toJSONSchema(inputSchema as z.ZodType, { - io: 'input', - }) as Record - } catch { - jsonSchema = { type: 'object', properties: {} } - } - } else if (typeof inputSchema === 'object' && !Array.isArray(inputSchema)) { - // Already a plain object (e.g. a pre-serialized JSON Schema) — copy it. - jsonSchema = { ...(inputSchema as Record) } - } else { - return undefined - } - - // `$schema` is meaningless to count_tokens; drop it to keep the payload lean. - delete jsonSchema['$schema'] - // Anthropic requires a top-level `type: 'object'`. Object schemas already - // carry it; union/intersection schemas (anyOf/allOf) don't — backfill it. - // Treat missing / null / empty-string as absent (valid JSON Schema `type` is - // always a non-empty string or array). - if (jsonSchema.type == null || jsonSchema.type === '') { - jsonSchema.type = 'object' - } - return jsonSchema -} +// Moved to util/to-json-schema.ts so spawn-agent-inline can use it without an +// import cycle through run-agent-step. Re-exported here for existing importers. +export { toTokenCountInputSchema } from './util/to-json-schema' async function additionalToolDefinitions( params: { @@ -151,7 +116,7 @@ async function additionalToolDefinitions( ): Promise { const { agentTemplate, fileContext } = params - const defs = cloneDeep( + const defs = cloneDeepKeepingZod( Object.fromEntries( Object.entries(fileContext.customToolDefinitions).filter(([toolName]) => agentTemplate!.toolNames.includes(toolName), @@ -912,6 +877,7 @@ export async function loopAgentSteps( windowedFileReads: agentTemplate.windowedFileReads === true, suppressCommitAttribution: agentTemplate.suppressCommitAttribution === true, + logger, additionalToolDefinitions: async () => { if (!cachedAdditionalToolDefinitions) { cachedAdditionalToolDefinitions = await additionalToolDefinitions({ @@ -967,11 +933,14 @@ export async function loopAgentSteps( }), ) - // Convert tools to a serializable format for context-pruner token counting + // Convert tool definitions to a JSON-serializable format. These live in + // agent state (persisted, snapshotted, shipped over the wire), so every + // inputSchema must be plain JSON Schema — Zod instances are cyclic and + // detonate any JSON.stringify over the state (turn 2+ would die). const toolDefinitions = mapValues(tools, (tool) => ({ description: typeof tool.description === 'string' ? tool.description : undefined, - inputSchema: tool.inputSchema as {}, + inputSchema: toTokenCountInputSchema(tool.inputSchema) ?? {}, })) const additionalToolDefinitionsWithCache = async () => { @@ -994,7 +963,8 @@ export async function loopAgentSteps( // Convert tool definitions to Anthropic format for accurate token counting. // Tool definitions are stored as { [name]: { description, inputSchema } }, - // where inputSchema is a Zod schema. Anthropic's count_tokens API expects + // with inputSchema as plain JSON Schema (see toolDefinitions above). + // Anthropic's count_tokens API expects // [{ name, description, input_schema }] with input_schema being real JSON // Schema (with a top-level `type: 'object'`) — see toTokenCountInputSchema. const toolsForTokenCount = Object.entries(toolDefinitions).map( diff --git a/packages/agent-runtime/src/templates/prompts.ts b/packages/agent-runtime/src/templates/prompts.ts index d4e96faa03..51c55608d7 100644 --- a/packages/agent-runtime/src/templates/prompts.ts +++ b/packages/agent-runtime/src/templates/prompts.ts @@ -10,11 +10,26 @@ import type { ParamsExcluding } from '@codebuff/common/types/function-params' import type { AgentTemplateType } from '@codebuff/common/types/session-state' import type { ToolSet } from 'ai' -function ensureJsonSchemaCompatible(schema: z.ZodType): z.ZodType { +function ensureJsonSchemaCompatible( + schema: z.ZodType, + opts?: { logger?: Logger; name?: string }, +): z.ZodType { try { z.toJSONSchema(schema, { io: 'input' }) return schema - } catch { + } catch (error) { + // Same silent-fallback hazard as the copy in tools/prompts.ts: this once + // consumed amputated zod schemas without a trace. Log it loudly. + opts?.logger?.warn( + { + toolName: opts.name, + error: String(error), + schemaConstructor: schema?.constructor?.name, + }, + `input schema failed JSON Schema conversion; serving empty schema${ + opts.name ? ` for '${opts.name}'` : '' + }`, + ) const fallback = z.object({}).passthrough() return schema.description ? fallback.describe(schema.description) : fallback } @@ -81,7 +96,7 @@ export async function buildAgentToolSet( 'agentId' | 'localAgentTemplates' >, ): Promise { - const { spawnableAgents, agentTemplates } = params + const { spawnableAgents, agentTemplates, logger } = params const toolSet: ToolSet = {} @@ -97,6 +112,7 @@ export async function buildAgentToolSet( const toolName = getAgentToolName(agentType) const inputSchema = ensureJsonSchemaCompatible( buildAgentToolInputSchema(agentTemplate), + { logger, name: toolName }, ) // Use the same structure as other tools in toolParams diff --git a/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts b/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts index 3b996cdb87..a80ccaec5a 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts @@ -1,5 +1,7 @@ import { mapValues } from 'lodash' +import { toTokenCountInputSchema } from '../../../util/to-json-schema' + import { validateAndGetAgentTemplate, validateAgentInput, @@ -114,7 +116,7 @@ export const handleSpawnAgentInline = (async ( toolDefinitions: mapValues(parentTools, (tool) => ({ description: typeof tool.description === 'string' ? tool.description : undefined, - inputSchema: tool.inputSchema as {}, + inputSchema: toTokenCountInputSchema(tool.inputSchema) ?? {}, })), } diff --git a/packages/agent-runtime/src/tools/prompts.ts b/packages/agent-runtime/src/tools/prompts.ts index d3d9110665..d10662be63 100644 --- a/packages/agent-runtime/src/tools/prompts.ts +++ b/packages/agent-runtime/src/tools/prompts.ts @@ -8,12 +8,13 @@ import { getToolCallString } from '@codebuff/common/tools/utils' import { buildArray } from '@codebuff/common/util/array' import { formatAvailableSkillsXml } from '@codebuff/common/util/skills' import { pluralize } from '@codebuff/common/util/string' -import { cloneDeep } from 'lodash' +import { cloneDeepKeepingZod } from '../util/zod-safe-clone' import z from 'zod/v4' import { convertJsonSchemaToZod } from 'zod-from-json-schema' import type { ToolName } from '@codebuff/common/tools/constants' import type { SkillsMap } from '@codebuff/common/types/skill' +import type { Logger } from '@codebuff/common/types/contracts/logger' import type { CustomToolDefinitions, customToolDefinitionsSchema, @@ -38,11 +39,28 @@ export function ensureZodSchema( return convertJsonSchemaToZod(schema as Record) } -function ensureJsonSchemaCompatible(schema: z.ZodType): z.ZodType { +function ensureJsonSchemaCompatible( + schema: z.ZodType, + opts?: { logger?: Logger; name?: string }, +): z.ZodType { try { z.toJSONSchema(schema, { io: 'input' }) return schema - } catch { + } catch (error) { + // This fallback once silently consumed amputated zod schemas (lodash + // cloneDeep drops zod v4's non-enumerable _zod), turning a broken schema + // into an empty tool schema at the model. Loud failure here would have + // surfaced that bug in minutes instead of sessions. + opts?.logger?.warn( + { + toolName: opts.name, + error: String(error), + schemaConstructor: schema?.constructor?.name, + }, + `input schema failed JSON Schema conversion; serving empty schema${ + opts.name ? ` for '${opts.name}'` : '' + }`, + ) const fallback = z.object({}).passthrough() return schema.description ? fallback.describe(schema.description) : fallback } @@ -369,6 +387,7 @@ export async function getToolSet(params: { additionalToolDefinitions: () => Promise agentTools: ToolSet skills: SkillsMap + logger?: Logger }): Promise { const { toolNames, @@ -377,6 +396,7 @@ export async function getToolSet(params: { additionalToolDefinitions, agentTools, skills, + logger, } = params // Generate available skills XML for the skill tool description @@ -430,11 +450,14 @@ export async function getToolSet(params: { const toolDefinitions = await additionalToolDefinitions() for (const [toolName, toolDefinition] of Object.entries(toolDefinitions)) { - const clonedDef = cloneDeep(toolDefinition) + const clonedDef = cloneDeepKeepingZod(toolDefinition) // Custom tool inputSchema may be JSON Schema (from SDK) or Zod (from MCP) // Ensure it's a Zod schema for the AI SDK const zodSchema = ensureZodSchema(clonedDef.inputSchema) - const safeSchema = ensureJsonSchemaCompatible(zodSchema) + const safeSchema = ensureJsonSchemaCompatible(zodSchema, { + logger, + name: toolName, + }) toolSet[toolName] = { ...clonedDef, inputSchema: safeSchema, diff --git a/packages/agent-runtime/src/tools/tool-executor.ts b/packages/agent-runtime/src/tools/tool-executor.ts index 36c4708752..d3abd7c6f0 100644 --- a/packages/agent-runtime/src/tools/tool-executor.ts +++ b/packages/agent-runtime/src/tools/tool-executor.ts @@ -1,7 +1,7 @@ import { endsAgentStepParam, toolNames } from '@codebuff/common/tools/constants' import { toolParams } from '@codebuff/common/tools/list' import { generateCompactId } from '@codebuff/common/util/string' -import { cloneDeep } from 'lodash' +import { cloneDeepKeepingZod } from '../util/zod-safe-clone' import { getMCPToolData } from '../mcp' import { MCP_TOOL_SEPARATOR } from '../mcp-constants' @@ -675,7 +675,7 @@ export async function executeCustomToolCall( ...params, toolNames: agentTemplate.toolNames, mcpServers: agentTemplate.mcpServers, - writeTo: cloneDeep(fileContext.customToolDefinitions), + writeTo: cloneDeepKeepingZod(fileContext.customToolDefinitions), }), rawToolCall: { toolName, diff --git a/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts b/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts new file mode 100644 index 0000000000..a5cc67e1dd --- /dev/null +++ b/packages/agent-runtime/src/util/__tests__/to-json-schema.test.ts @@ -0,0 +1,85 @@ +import { describe, test, expect } from 'bun:test' +import { z } from 'zod/v4' + +import { toTokenCountInputSchema } from '../to-json-schema' + +/** + * Regression tests for the persisted-state schema conversion. + * + * Tool inputSchemas are persisted into agent state, snapshotted and replayed + * on every turn, and shipped to Anthropic's count_tokens API. Every stored + * schema must therefore be plain JSON Schema with a top-level type: zod + * internals never leak into state, and foreign (already-JSON) schemas pass + * through unmangled. + */ +describe('toTokenCountInputSchema', () => { + /** + * Given: a zod object schema with an optional field. + * When: it is converted. + * Then: the result is JSON Schema with type object and the field mapped, + * not a serialized zod instance. + */ + test('converts zod object schema to JSON Schema with top level type object', () => { + const schema = z.object({ + q: z.string().describe('query'), + n: z.number().optional(), + }) + + const out = toTokenCountInputSchema(schema) as Record | undefined + + expect(out?.type).toBe('object') + expect(out?.properties.q.type).toBe('string') + }) + + /** + * Given: a union schema, which JSON Schema represents as anyOf with no + * top-level type. + * When: it is converted. + * Then: type object is backfilled, because Anthropic's count_tokens + * rejects input_schema values without a top-level type. + */ + test('backfills type object for union schemas represented as anyOf', () => { + const schema = z.union([z.object({ a: z.string() }), z.object({ b: z.number() })]) + + const out = toTokenCountInputSchema(schema) as Record | undefined + + expect(out?.type).toBe('object') + expect(out?.anyOf).toBeDefined() + }) + + /** + * Given: a schema that is already a plain JSON Schema object (the shape + * MCP servers and the SDK send). + * When: it is converted. + * Then: it is copied as-is - conversion must not mangle foreign schemas. + */ + test('copies an already plain JSON Schema object unchanged', () => { + const jsonSchema = { + type: 'object', + properties: { location: { type: 'string', enum: ['NYC', 'LA'] } }, + required: ['location'], + } + + const out = toTokenCountInputSchema(jsonSchema) + + expect(out).toEqual(jsonSchema) + }) + + /** + * Given: nullish input and a schema carrying a $schema key. + * When: they are converted. + * Then: nullish input yields undefined, and the meaningless $schema key + * is dropped to keep the token-count payload lean. + */ + test('returns undefined for nullish input and strips the schema meta key', () => { + const withMeta = { $schema: 'https://json-schema.org/x', type: 'object' } + + const nullishOut = toTokenCountInputSchema(undefined) + const metaOut = toTokenCountInputSchema(withMeta) + + expect(nullishOut).toBeUndefined() + expect(toTokenCountInputSchema(null)).toBeUndefined() + expect(metaOut?.$schema).toBeUndefined() + expect(metaOut?.type).toBe('object') + }) +}) diff --git a/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts new file mode 100644 index 0000000000..a05f71287b --- /dev/null +++ b/packages/agent-runtime/src/util/__tests__/zod-safe-clone.test.ts @@ -0,0 +1,88 @@ +import { describe, test, expect } from 'bun:test' +import { cloneDeep } from 'lodash' +import { z } from 'zod/v4' + +import { cloneDeepKeepingZod } from '../zod-safe-clone' + +/** + * Regression tests for tool-schema cloning. + * + * Tool definitions carry live zod v4 schemas, and state boundaries + * deep-clone the surrounding data. lodash cloneDeep strips zod's + * non-enumerable _zod engine: the amputated clone still looks like a schema + * (safeParse, def, shape all present) but throws the first time zod + * internals touch it - which is how MCP and custom tool schemas silently + * became empty {} at the model. cloneDeepKeepingZod is the fix pinned here. + */ +describe('lodash cloneDeep zod amputation (the bug)', () => { + /** + * Given: a zod v4 schema. + * When: it is cloned with lodash cloneDeep. + * Then: the clone retains safeParse but loses _zod, and z.toJSONSchema + * throws on it - the production failure behind the empty-schema bug. + */ + test('cloneDeep strips the zod engine so toJSONSchema throws on the clone', () => { + const schema = z.object({ q: z.string() }) + + const cloned = cloneDeep(schema) + + expect(typeof cloned.safeParse).toBe('function') + expect('_zod' in cloned).toBe(false) + expect(() => z.toJSONSchema(cloned as never)).toThrow() + }) +}) + +describe('cloneDeepKeepingZod', () => { + /** + * Given: a plain structure with a live zod schema nested inside. + * When: it is cloned with cloneDeepKeepingZod. + * Then: plain data is deep-cloned (new references), the schema is the + * same live instance, and its engine still converts to JSON Schema. + */ + test('cloneDeepKeepingZod passes schemas through by reference so the engine survives', () => { + const schema = z.object({ q: z.string().describe('query') }) + const input = { cfg: schema, note: 'plain', nested: { arr: [1, 2] } } + + const out = cloneDeepKeepingZod(input) + + expect(out.note).toBe('plain') + expect(out.nested).not.toBe(input.nested) + expect(out.nested.arr).toEqual([1, 2]) + expect(out.cfg).toBe(schema) + + const jsonSchema = z.toJSONSchema(out.cfg) + expect(jsonSchema.type).toBe('object') + expect((jsonSchema.properties as { q: { type: string } }).q.type).toBe('string') + }) + + /** + * Given: a plain (schema-free) nested structure. + * When: it is cloned with cloneDeepKeepingZod. + * Then: the result matches cloneDeep exactly, including fresh nested + * references - the clone helper must not change plain-data semantics. + */ + test('cloneDeepKeepingZod deep-clones plain structures exactly like cloneDeep', () => { + const input = { a: { b: [1, { c: 'd' }] }, e: null } + + const out = cloneDeepKeepingZod(input) + + expect(out).toEqual(input) + expect(out.a).not.toBe(input.a) + expect(out.a.b[1]).not.toBe(input.a.b[1]) + }) + + /** + * Given: a zod schema nested inside a collection, the shape custom tool * definitions actually arrive in. + * When: the containing structure is cloned. + * Then: the schema survives as a live instance usable by zod internals. + */ + test('cloneDeepKeepingZod preserves schemas nested inside collections', () => { + const schema = z.object({ id: z.number() }) + const input = { tools: [{ name: 'x', inputSchema: schema }] } + + const out = cloneDeepKeepingZod(input) + + expect(out.tools[0].inputSchema).toBe(schema) + expect(() => z.toJSONSchema(out.tools[0].inputSchema)).not.toThrow() + }) +}) diff --git a/packages/agent-runtime/src/util/to-json-schema.ts b/packages/agent-runtime/src/util/to-json-schema.ts new file mode 100644 index 0000000000..fcf8744634 --- /dev/null +++ b/packages/agent-runtime/src/util/to-json-schema.ts @@ -0,0 +1,43 @@ +import z from 'zod/v4' + +// Convert a tool's stored inputSchema into plain JSON Schema. Built-in and MCP +// tools convert from a Zod schema; plain objects (e.g. a pre-serialized JSON +// Schema) are copied. Serializing a Zod schema raw would ship Zod internals +// (`def`/`shape`, non-enumerable `_zod`) instead of JSON Schema — which breaks +// JSON.stringify (zod schemas are cyclic) and makes token counts computed +// against garbage. Any schema whose top-level isn't an object (e.g. a union → +// `anyOf`) is backfilled with `type: 'object'`, which Anthropic requires. +export function toTokenCountInputSchema( + inputSchema: unknown, +): Record | undefined { + if (inputSchema == null) return undefined + + let jsonSchema: Record + if ( + typeof (inputSchema as { safeParse?: unknown }).safeParse === 'function' + ) { + try { + jsonSchema = z.toJSONSchema(inputSchema as z.ZodType, { + io: 'input', + }) as Record + } catch { + jsonSchema = { type: 'object', properties: {} } + } + } else if (typeof inputSchema === 'object' && !Array.isArray(inputSchema)) { + // Already a plain object (e.g. a pre-serialized JSON Schema) — copy it. + jsonSchema = { ...(inputSchema as Record) } + } else { + return undefined + } + + // `$schema` is meaningless to count_tokens; drop it to keep the payload lean. + delete jsonSchema['$schema'] + // Anthropic requires a top-level `type: 'object'`. Object schemas already + // carry it; union/intersection schemas (anyOf/allOf) don't — backfill it. + // Treat missing / null / empty-string as absent (valid JSON Schema `type` is + // always a non-empty string or array). + if (jsonSchema.type == null || jsonSchema.type === '') { + jsonSchema.type = 'object' + } + return jsonSchema +} diff --git a/packages/agent-runtime/src/util/zod-safe-clone.ts b/packages/agent-runtime/src/util/zod-safe-clone.ts new file mode 100644 index 0000000000..6e680895c4 --- /dev/null +++ b/packages/agent-runtime/src/util/zod-safe-clone.ts @@ -0,0 +1,34 @@ +import { cloneDeepWith } from 'lodash' + +/** + * lodash cloneDeep destroys zod v4 schema instances. + * + * zod v4 stores its engine on a non-enumerable `_zod` property, and lodash + * only copies enumerable own properties. The clone therefore looks like a + * schema (has safeParse/def/type) but has no `_zod` internals, and any zod + * internal that touches `schema._zod.*` detonates with: + * "undefined is not an object (evaluating 'schema._zod.def')" + * + * This deep-clones plain data (descriptions, maps, arrays) exactly like + * cloneDeep, but passes zod schema instances through by reference so their + * internals survive. + */ +export function cloneDeepKeepingZod(value: T): T { + const cloned = cloneDeepWith(value, (node) => { + if (isZodSchemaInstance(node)) { + // Pass the live schema through untouched. + return node as T + } + // Fall through to lodash's default deep clone. + return undefined + }) + return cloned as T +} + +function isZodSchemaInstance(node: unknown): boolean { + if (typeof node !== 'object' || node === null) { + return false + } + const candidate = node as { _zod?: unknown } + return typeof candidate._zod === 'object' && candidate._zod !== null +} diff --git a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts index 195d63b819..9ac4bd904c 100644 --- a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts +++ b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts @@ -1083,3 +1083,67 @@ describe('consecutive assistant messages', () => { ]) }) }) + +/** + * Regression tests for non-image file parts. + * + * MCP resources can put non-image file parts (e.g. gzip) into message + * history, which is replayed into every later prompt build. The + * OpenAI-compatible converter must degrade such parts to a text + * placeholder: throwing here failed the entire prompt build and, because + * the message stays in history, killed the session on every subsequent + * turn. + */ +describe('non-image file parts', () => { + // The fixture's base64 string is 20 chars; the placeholder estimates raw + // bytes as round(20 * 3 / 4) = 15. + const GZIP_FIXTURE_BASE64 = Buffer.from('Hello freebuff!').toString('base64') + const EXPECTED_BYTE_ESTIMATE = 15 + + it('degrades non-image file part to text placeholder instead of throwing', () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'user', + content: [ + { + type: 'file', + data: GZIP_FIXTURE_BASE64, + mediaType: 'application/gzip', + }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'text', + text: `[application/gzip file part not displayable (~${EXPECTED_BYTE_ESTIMATE} bytes)]`, + }, + ], + }, + ]) + }) + + it('converts image file parts to image_url data URIs unchanged', () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'user', + content: [ + { + type: 'file', + data: Buffer.from([0, 1, 2, 3]).toString('base64'), + mediaType: 'image/png', + }, + ], + }, + ]) + + expect(result[0].content[0]).toEqual({ + type: 'image_url', + image_url: { url: 'data:image/png;base64,AAECAw==' }, + }) + }) +}) diff --git a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts index ead5daab11..4491f8dfaa 100644 --- a/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts +++ b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts @@ -14,6 +14,25 @@ function getOpenAIMetadata(message: { return message?.providerOptions?.openaiCompatible ?? {} } +/** Approximate payload size of a file part's data, for placeholder text. */ +function filePartByteLength(data: unknown): number { + let value = data + if (value && typeof value === 'object' && 'type' in value) { + if (value.type === 'data' && 'data' in value) { + value = value.data + } else if (value.type === 'url' && 'url' in value) { + value = value.url + } + } + if (typeof value === 'string') { + return Math.round((value.length * 3) / 4) + } + if (value instanceof Uint8Array) { + return value.byteLength + } + return 0 +} + function imageUrlFromData(data: unknown, mediaType: string): string { // AI SDK 7 adapts this v2 provider to v4, whose file data is tagged. The // compatibility proxy passes that v4 shape through to the v2 implementation. @@ -89,9 +108,17 @@ export function convertToOpenAICompatibleChatMessages( ...partMetadata, } } else { - throw new UnsupportedFunctionalityError({ - functionality: `file part media type ${part.mediaType}`, - }) + // Non-image file parts (e.g. application/gzip from an MCP + // resource) have no OpenAI-compatible representation. + // Degrade to a text placeholder instead of throwing: a + // throw here fails the entire prompt build and, because + // the message stays in history, kills the session on every + // subsequent turn. + return { + type: 'text', + text: `[${part.mediaType} file part not displayable (~${filePartByteLength(part.data)} bytes)]`, + ...partMetadata, + } } } }