diff --git a/src/agents/__tests__/frameworks/serializer.test.ts b/src/agents/__tests__/frameworks/serializer.test.ts index 025e77c0..43c8ab6f 100644 --- a/src/agents/__tests__/frameworks/serializer.test.ts +++ b/src/agents/__tests__/frameworks/serializer.test.ts @@ -431,4 +431,88 @@ describe("serializeFrameworkAgent", () => { expect(workers).toHaveLength(0); }); }); + + // ── Google ADK shapes ───────────────────────────────────── + + /** Structurally equivalent to a zod v4 schema (ADK bundles zod v4). */ + function zodV4Object( + shape: Record, + ): Record { + const v4 = (def: Record, description?: string) => ({ + _zod: { def }, + ...(description ? { description } : {}), + }); + const shapeOut: Record = {}; + for (const [k, v] of Object.entries(shape)) { + const inner = v4({ type: v.type }, v.description); + shapeOut[k] = v.optional ? v4({ type: "optional", innerType: inner }) : inner; + } + return v4({ type: "object", shape: shapeOut }); + } + + describe("Google ADK AgentTool extraction", () => { + it("detects an ADK-shaped AgentTool (plain agent property, no markers)", () => { + // Regression: undetected ADK AgentTools degraded into generic tool + // entries with no callable — the server scheduled a worker task that + // no local worker ever polled, hanging the run forever. + const childTool = { + name: "search_knowledge_base", + description: "Search the KB.", + parameters: zodV4Object({ query: { type: "string", description: "The search query" } }), + execute: async () => "ok", + }; + const childAgent = { + name: "researcher", + model: "openai/gpt-4o-mini", + instruction: "You research.", + tools: [childTool], + }; + const agentTool = { name: "researcher", description: "", agent: childAgent }; + const mockAgent = { + name: "manager", + model: "openai/gpt-4o-mini", + instruction: "You manage.", + tools: [agentTool], + }; + + const [config, workers] = serializeFrameworkAgent(mockAgent); + + const tools = config.tools as Record[]; + expect(tools[0]._type).toBe("AgentTool"); + expect(tools[0].name).toBe("researcher"); + expect((tools[0].agent as Record).name).toBe("researcher"); + // The child agent's tool is extracted as a registrable worker. + expect(workers.some((w) => w.name === "search_knowledge_base" && !!w.func)).toBe(true); + }); + }); + + describe("zod v4 schema conversion", () => { + it("converts a v4 schema to clean JSON Schema instead of mangled internals", () => { + // Regression: zod v4 dropped _def.typeName, so _isZodSchema missed v4 + // schemas and tools serialized with zod internals as their schema — + // the LLM saw a parameterless tool and called it with no arguments. + const tool = { + name: "skb", + description: "Search.", + parameters: zodV4Object({ + query: { type: "string", description: "The search query" }, + limit: { type: "number", optional: true }, + }), + execute: async () => "ok", + }; + const [, workers] = serializeFrameworkAgent({ + name: "a", + model: "m", + instruction: "x", + tools: [tool], + }); + + const schema = workers[0].inputSchema as Record; + expect(schema.type).toBe("object"); + const props = schema.properties as Record>; + expect(props.query).toEqual({ type: "string", description: "The search query" }); + expect(props.limit).toEqual({ type: "number" }); + expect(schema.required).toEqual(["query"]); + }); + }); }); diff --git a/src/agents/frameworks/serializer.ts b/src/agents/frameworks/serializer.ts index e1739b14..570d6a04 100644 --- a/src/agents/frameworks/serializer.ts +++ b/src/agents/frameworks/serializer.ts @@ -276,9 +276,25 @@ function _tryExtractAgentTool(obj: unknown, workers: WorkerInfo[]): Record; - if (!asAny._is_agent_tool && !asAny._agent_instance) return null; - - const childAgent = asAny._agent_instance; + // Two agent-as-tool shapes: + // - OpenAI Agents SDK: marker fields _is_agent_tool / _agent_instance + // - Google ADK AgentTool: a plain `agent` property holding the child + // agent (constructor names are minified, so detect by shape). Without + // this branch an ADK AgentTool degrades into a generic tool entry with + // no callable — the server schedules a worker task that no local + // worker ever polls, and the run hangs forever. + const maybeAgent = asAny.agent as Record | undefined; + const adkChild = + maybeAgent && + typeof maybeAgent === "object" && + typeof maybeAgent.name === "string" && + ("instruction" in maybeAgent || "model" in maybeAgent) + ? maybeAgent + : null; + + if (!asAny._is_agent_tool && !asAny._agent_instance && !adkChild) return null; + + const childAgent = asAny._agent_instance ?? adkChild; if (!childAgent) return null; const [childConfig, childWorkers] = serializeFrameworkAgent(childAgent); @@ -391,19 +407,106 @@ function _getSerializableKeys(obj: object): string[] { function _isZodSchema(obj: unknown): boolean { if (typeof obj !== "object" || obj === null) return false; const asAny = obj as Record; - // Zod schemas have _def property with typeName - return ( + // Zod v3 schemas have _def.typeName; v4 schemas (used by e.g. @google/adk) + // dropped typeName and carry a _zod internals object instead. Without the + // v4 branch, framework tool schemas fall through to generic property + // enumeration and serialize as mangled zod internals — the server then + // renders a parameterless tool and the LLM calls it with no arguments. + if ( typeof asAny._def === "object" && asAny._def !== null && typeof (asAny._def as Record).typeName === "string" + ) { + return true; + } + const zodInternals = asAny._zod as Record | undefined; + return ( + typeof zodInternals === "object" && + zodInternals !== null && + typeof zodInternals.def === "object" && + zodInternals.def !== null ); } +/** + * Convert a Zod v4 schema to JSON Schema by walking `_zod.def` structurally. + * + * The SDK's own zod dependency is v3 (no native z.toJSONSchema), and a v3 + * converter can't read a v4 schema instance from another package's zod, so + * the common shapes are handled here directly. + */ +function _zodV4ToJsonSchema(schema: unknown): Record | null { + const internals = (schema as Record | null)?._zod as + | Record + | undefined; + const def = internals?.def as Record | undefined; + if (!def || typeof def.type !== "string") return null; + + const description = (schema as Record).description; + const withDesc = (out: Record): Record => + typeof description === "string" && description.length > 0 + ? { ...out, description } + : out; + + switch (def.type) { + case "object": { + const shape = (def.shape ?? {}) as Record; + const properties: Record = {}; + const required: string[] = []; + for (const [key, child] of Object.entries(shape)) { + const childDef = (child as Record)?._zod as + | Record + | undefined; + const childType = (childDef?.def as Record | undefined)?.type; + const childSchema = _zodV4ToJsonSchema(child); + if (childSchema) properties[key] = childSchema; + if (childType !== "optional" && childType !== "default") required.push(key); + } + const out: Record = { type: "object", properties }; + if (required.length > 0) out.required = required; + return withDesc(out); + } + case "optional": + case "default": + case "nullable": { + const inner = _zodV4ToJsonSchema(def.innerType); + return inner ? withDesc(inner) : null; + } + case "array": { + const items = _zodV4ToJsonSchema(def.element) ?? {}; + return withDesc({ type: "array", items }); + } + case "enum": { + const entries = def.entries as Record | undefined; + return withDesc({ type: "string", enum: entries ? Object.values(entries) : [] }); + } + case "literal": { + const values = def.values as unknown[] | undefined; + return withDesc({ enum: values ?? [] }); + } + case "string": + case "number": + case "boolean": + return withDesc({ type: def.type }); + case "int": + return withDesc({ type: "integer" }); + default: + // Unknown v4 type — emit a permissive schema rather than zod internals. + return withDesc({}); + } +} + /** * Convert a Zod schema to JSON Schema. * Uses zod-to-json-schema if available, falls back to null. */ function _zodToJsonSchema(zodSchema: unknown): Record | null { + // Zod v4 instance (has _zod internals) — the v3 zod-to-json-schema library + // can't read these, so walk the def structure directly. + const asAny = zodSchema as Record | null; + if (asAny && typeof asAny._zod === "object" && asAny._zod !== null) { + return _zodV4ToJsonSchema(zodSchema); + } try { // Try dynamic import of zod-to-json-schema