diff --git a/src/agents/claude-agent-sdk/sdk-agent-runtime.ts b/src/agents/claude-agent-sdk/sdk-agent-runtime.ts index fe5294893..8360f8bba 100644 --- a/src/agents/claude-agent-sdk/sdk-agent-runtime.ts +++ b/src/agents/claude-agent-sdk/sdk-agent-runtime.ts @@ -214,15 +214,6 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age ? `${params.provider ?? "anthropic"}/${params.model}` : (ccsdkOpts.modelTiers?.sonnet ?? context?.ccsdkConfig?.models?.sonnet ?? "default"); - log.debug("Starting CCSDK Agent session", { - sessionId: params.sessionId, - runId: params.runId, - provider: providerConfig.name, - model: effectiveModel, - agentId, - thinkLevel: params.thinkLevel, - }); - // Load conversation history from session file if not provided let conversationHistory = context?.conversationHistory; if (!conversationHistory && params.sessionFile) { @@ -275,12 +266,6 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age // Combine built-in tools with client tools const tools: AnyAgentTool[] = [...builtInTools, ...clientToolsConverted]; - log.debug("Built tools for CCSDK run", { - builtInCount: builtInTools.length, - clientToolCount: clientToolsConverted.length, - totalCount: tools.length, - }); - // Resolve CCSDK config directory for this agent's session storage. // This ensures Moltbot sessions are stored separately from CLI sessions. const ccsdkConfigDir = resolveCcSdkConfigDirForAgent(agentId); diff --git a/src/agents/claude-agent-sdk/sdk-runner.ts b/src/agents/claude-agent-sdk/sdk-runner.ts index 8f1693b53..8121839e3 100644 --- a/src/agents/claude-agent-sdk/sdk-runner.ts +++ b/src/agents/claude-agent-sdk/sdk-runner.ts @@ -630,12 +630,12 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise 0 - ? ` (skipped: ${bridgeResult.skippedTools.join(", ")})` - : ""), - ); + // log.debug( + // `Exposed ${bridgeResult.toolCount} tools to MCP server "${mcpServerName}"` + + // (bridgeResult.skippedTools.length > 0 + // ? ` (skipped: ${bridgeResult.skippedTools.join(", ")})` + // : ""), + // ); } // ------------------------------------------------------------------------- diff --git a/src/agents/claude-agent-sdk/tool-bridge.test.ts b/src/agents/claude-agent-sdk/tool-bridge.test.ts index 2a84c5289..4072aebb1 100644 --- a/src/agents/claude-agent-sdk/tool-bridge.test.ts +++ b/src/agents/claude-agent-sdk/tool-bridge.test.ts @@ -119,7 +119,8 @@ describe("tool-bridge", () => { expect(typeof zodSchema.parse).toBe("function"); expect(typeof zodSchema.safeParse).toBe("function"); expect(typeof zodSchema.safeParseAsync).toBe("function"); - expect(zodSchema._def.typeName).toBe("ZodObject"); + // Zod v4 Mini uses _zod instead of _def + expect((zodSchema as any)._zod).toBeDefined(); }); it("parse returns data for valid input", () => { @@ -645,4 +646,182 @@ describe("tool-bridge", () => { expect(result.skippedTools).toContain("bad_tool"); }); }); + + describe("tool call error handling", () => { + const mockExtra = { signal: new AbortController().signal }; + + it("includes tool name in error message when tool throws", async () => { + const mockExecute = vi.fn().mockRejectedValue(new Error("Database connection failed")); + + const tool = { + name: "database-query", + execute: mockExecute, + } as unknown as AnyAgentTool; + + const handler = wrapToolHandler(tool); + const result = await handler({ query: "SELECT *" }, mockExtra); + + expect(result.isError).toBe(true); + expect(result.content[0].type).toBe("text"); + expect((result.content[0] as { text: string }).text).toContain("database_query"); + expect((result.content[0] as { text: string }).text).toContain("Database connection failed"); + }); + + it("handles non-Error thrown values", async () => { + const mockExecute = vi.fn().mockRejectedValue("string error message"); + + const tool = { + name: "string-error-tool", + execute: mockExecute, + } as unknown as AnyAgentTool; + + const handler = wrapToolHandler(tool); + const result = await handler({}, mockExtra); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("string error message"); + }); + + it("handles undefined/null thrown values", async () => { + const mockExecute = vi.fn().mockRejectedValue(undefined); + + const tool = { + name: "undefined-error-tool", + execute: mockExecute, + } as unknown as AnyAgentTool; + + const handler = wrapToolHandler(tool); + const result = await handler({}, mockExtra); + + expect(result.isError).toBe(true); + expect(result.content.length).toBeGreaterThan(0); + }); + + it("converts tool_error blocks from result to MCP error format", () => { + const result: AgentToolResult = { + content: [ + { type: "tool_error", error: "Validation failed: missing required field 'name'" }, + ], + }; + + const mcpResult = convertToolResult(result); + + expect(mcpResult.isError).toBe(true); + expect(mcpResult.content).toHaveLength(1); + expect((mcpResult.content[0] as { text: string }).text).toBe( + "Validation failed: missing required field 'name'", + ); + }); + + it("preserves error details from multiple error blocks", () => { + const result: AgentToolResult = { + content: [ + { type: "tool_error", error: "Error 1: Invalid input" }, + { type: "tool_error", error: "Error 2: Permission denied" }, + ], + }; + + const mcpResult = convertToolResult(result); + + expect(mcpResult.isError).toBe(true); + expect(mcpResult.content).toHaveLength(2); + }); + + it("marks result as error when any block has error field", () => { + const result: AgentToolResult = { + content: [ + { type: "text", text: "Partial success" }, + { type: "warning", error: "But something went wrong" }, + ], + }; + + const mcpResult = convertToolResult(result); + + expect(mcpResult.isError).toBe(true); + }); + }); + + describe("Zod schema conversion", () => { + it("converts TypeBox string property to Zod string", () => { + const typeboxSchema = Type.Object({ + name: Type.String(), + }); + + const zodSchema = createZodCompatibleSchema(typeboxSchema); + + // Should successfully parse valid input + const parseResult = zodSchema.safeParse({ name: "test" }); + expect(parseResult.success).toBe(true); + }); + + it("converts TypeBox number property to Zod number", () => { + const typeboxSchema = Type.Object({ + count: Type.Number(), + }); + + const zodSchema = createZodCompatibleSchema(typeboxSchema); + + const parseResult = zodSchema.safeParse({ count: 42 }); + expect(parseResult.success).toBe(true); + }); + + it("converts TypeBox boolean property to Zod boolean", () => { + const typeboxSchema = Type.Object({ + active: Type.Boolean(), + }); + + const zodSchema = createZodCompatibleSchema(typeboxSchema); + + const parseResult = zodSchema.safeParse({ active: true }); + expect(parseResult.success).toBe(true); + }); + + it("converts TypeBox array property to Zod array", () => { + const typeboxSchema = Type.Object({ + items: Type.Array(Type.String()), + }); + + const zodSchema = createZodCompatibleSchema(typeboxSchema); + + const parseResult = zodSchema.safeParse({ items: ["a", "b", "c"] }); + expect(parseResult.success).toBe(true); + }); + + it("converts TypeBox optional property to Zod optional", () => { + const typeboxSchema = Type.Object({ + required: Type.String(), + optional: Type.Optional(Type.String()), + }); + + const zodSchema = createZodCompatibleSchema(typeboxSchema); + + // Should work without optional field + const parseResult = zodSchema.safeParse({ required: "value" }); + expect(parseResult.success).toBe(true); + }); + + it("rejects invalid input with Zod validation errors", () => { + const typeboxSchema = Type.Object({ + name: Type.String(), + count: Type.Number(), + }); + + const zodSchema = createZodCompatibleSchema(typeboxSchema); + + // Pass wrong types + const parseResult = zodSchema.safeParse({ name: 123, count: "not a number" }); + expect(parseResult.success).toBe(false); + }); + + it("has _zod property for MCP SDK compatibility", () => { + const typeboxSchema = Type.Object({ + value: Type.String(), + }); + + const zodSchema = createZodCompatibleSchema(typeboxSchema); + + // Zod v4 Mini schemas have _zod property that MCP SDK checks + expect((zodSchema as any)._zod).toBeDefined(); + }); + }); }); diff --git a/src/agents/claude-agent-sdk/tool-bridge.ts b/src/agents/claude-agent-sdk/tool-bridge.ts index 611f1d60d..b57deba59 100644 --- a/src/agents/claude-agent-sdk/tool-bridge.ts +++ b/src/agents/claude-agent-sdk/tool-bridge.ts @@ -20,6 +20,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { TSchema } from "@sinclair/typebox"; import { Value } from "@sinclair/typebox/value"; +import * as z from "zod/v4-mini"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { normalizeToolName } from "../tool-policy.js"; @@ -36,85 +37,136 @@ import type { const log = createSubsystemLogger("agents/claude-agent-sdk/tool-bridge"); // --------------------------------------------------------------------------- -// Schema conversion: TypeBox → Zod-compatible schema +// Schema conversion: TypeBox → Zod v4 Mini schema // --------------------------------------------------------------------------- -/** - * Interface for Zod-compatible schema that the MCP SDK expects. - * The SDK checks for `parse` and `safeParse` methods to identify valid schemas. - */ -export interface ZodCompatibleSchema { - /** Synchronous parse - throws on validation failure */ - parse(data: unknown): unknown; - /** Safe synchronous parse - returns success/error result */ - safeParse(data: unknown): { success: true; data: unknown } | { success: false; error: unknown }; - /** Async version of safeParse (used by MCP SDK) */ - safeParseAsync( - data: unknown, - ): Promise<{ success: true; data: unknown } | { success: false; error: unknown }>; - /** Mark this as a Zod-like schema instance (SDK checks for _def or _zod) */ - _def: { typeName: string; description?: string }; -} +// Use z.ZodMiniType as the base type for all Zod Mini schemas +// biome-ignore lint/suspicious/noExplicitAny: Zod Mini type system is complex; any allows proper schema composition +type ZodMiniSchema = z.ZodMiniType; /** - * Wrap a TypeBox schema to make it Zod-compatible for the MCP SDK. - * - * The MCP SDK's McpServer.tool() checks `isZodTypeLike()` which requires: - * - `parse` method (function) - * - `safeParse` method (function) - * - * This wrapper uses TypeBox's Value module to perform validation. + * Convert a TypeBox property schema to a Zod v4 Mini schema. + * This recursive converter handles common TypeBox/JSON Schema types. */ -export function createZodCompatibleSchema(typeboxSchema: TSchema): ZodCompatibleSchema { - return { - parse(data: unknown): unknown { - // Decode handles coercion (string→number, etc.) and throws on failure - return Value.Decode(typeboxSchema, data); - }, +function convertTypeBoxPropertyToZod(propSchema: TSchema): ZodMiniSchema { + const type = (propSchema as { type?: string }).type; - safeParse( - data: unknown, - ): { success: true; data: unknown } | { success: false; error: unknown } { - try { - // First check if valid - if (!Value.Check(typeboxSchema, data)) { - // Get the first validation error for a useful message - const errors = [...Value.Errors(typeboxSchema, data)]; - const firstError = errors[0]; - return { - success: false, - error: { - message: firstError?.message ?? "Validation failed", - path: firstError?.path ?? "", - issues: errors.map((e) => ({ message: e.message, path: e.path })), - }, - }; - } - // Decode to apply any transformations - const decoded = Value.Decode(typeboxSchema, data); - return { success: true, data: decoded }; - } catch (err) { - return { - success: false, - error: err instanceof Error ? { message: err.message } : { message: String(err) }, - }; + // Handle based on JSON Schema type + switch (type) { + case "string": { + return z.string(); + } + case "number": + case "integer": { + return z.number(); + } + case "boolean": { + return z.boolean(); + } + case "array": { + const items = (propSchema as { items?: TSchema }).items; + const itemSchema = items ? convertTypeBoxPropertyToZod(items) : z.unknown(); + return z.array(itemSchema); + } + case "object": { + // Nested object - recursively convert + const nestedProps = (propSchema as { properties?: Record }).properties; + const nestedRequired = (propSchema as { required?: string[] }).required ?? []; + if (nestedProps) { + const shape = buildZodShapeFromTypeBox(nestedProps, nestedRequired); + return z.object(shape); + } + // Object without defined properties - use passthrough + return z.record(z.string(), z.unknown()); + } + case "null": { + return z.null(); + } + default: { + // Check for enum/const + const enumValues = (propSchema as { enum?: string[] }).enum; + if (enumValues && Array.isArray(enumValues)) { + // Create a union of literals for enum + if (enumValues.length === 1) { + return z.literal(enumValues[0]); + } + const literals = enumValues.map((v) => z.literal(v)); + // TypeScript requires explicit tuple type assertion for z.union + return z.union(literals as unknown as [ZodMiniSchema, ZodMiniSchema, ...ZodMiniSchema[]]); } - }, - async safeParseAsync( - data: unknown, - ): Promise<{ success: true; data: unknown } | { success: false; error: unknown }> { - return this.safeParse(data); - }, + const constValue = (propSchema as { const?: unknown }).const; + if (constValue !== undefined) { + return z.literal(constValue as string | number | boolean); + } - // Mimic Zod v3's internal structure so isZodSchemaInstance returns true - _def: { - typeName: "ZodObject", - description: (typeboxSchema as { description?: string }).description, - }, - }; + // Fallback to unknown for unrecognized types + return z.unknown(); + } + } } +/** + * Build a Zod shape object from TypeBox properties. + */ +function buildZodShapeFromTypeBox( + properties: Record, + requiredProps: string[], +): Record { + const requiredSet = new Set(requiredProps); + const shape: Record = {}; + + for (const [propName, propSchema] of Object.entries(properties)) { + let zodProp = convertTypeBoxPropertyToZod(propSchema); + + // Wrap in optional if not required + if (!requiredSet.has(propName)) { + zodProp = z.optional(zodProp); + } + + shape[propName] = zodProp; + } + + return shape; +} + +/** + * Convert a TypeBox object schema to a Zod v4 Mini object schema. + * + * The MCP SDK natively supports Zod v4 Mini schemas. By converting TypeBox + * to real Zod schemas, we get proper JSON Schema generation for Claude + * and validation that matches what the SDK expects. + */ +export function convertTypeBoxToZod(typeboxSchema: TSchema): ZodMiniSchema { + const properties = (typeboxSchema as { properties?: Record }).properties; + const requiredProps = (typeboxSchema as { required?: string[] }).required ?? []; + + if (!properties || typeof properties !== "object") { + // No properties defined - return a permissive object schema + return z.object({}); + } + + const shape = buildZodShapeFromTypeBox(properties, requiredProps); + return z.object(shape); +} + +/** + * Create a Zod v4 Mini schema from a tool's TypeBox parameters. + * Returns undefined if the tool has no parameters. + */ +export function extractZodSchema(tool: AnyAgentTool): ZodMiniSchema | undefined { + const schema = tool.parameters as TSchema | undefined; + if (!schema || typeof schema !== "object") { + return undefined; + } + return convertTypeBoxToZod(schema); +} + +// Legacy exports for backward compatibility +export type ZodCompatibleSchema = ZodMiniSchema; +export const createZodCompatibleSchema = convertTypeBoxToZod; +export const extractZodCompatibleSchema = extractZodSchema; + /** * Extract a clean JSON Schema object from a TypeBox schema. * @@ -136,20 +188,6 @@ export function extractJsonSchema(tool: AnyAgentTool): Record { } } -/** - * Create a Zod-compatible schema from a tool's TypeBox parameters. - * Falls back to a permissive schema if the tool has no parameters. - */ -export function extractZodCompatibleSchema(tool: AnyAgentTool): ZodCompatibleSchema | undefined { - const schema = tool.parameters as TSchema | undefined; - if (!schema || typeof schema !== "object") { - // Return undefined to indicate no input schema - // The MCP SDK will then call handler(extra) instead of handler(args, extra) - return undefined; - } - return createZodCompatibleSchema(schema); -} - // --------------------------------------------------------------------------- // Result conversion: AgentToolResult → McpCallToolResult // ---------------------------------------------------------------------------