diff --git a/core/src/workflow/node_builders.ts b/core/src/workflow/node_builders.ts index 0e8da2bc..2680b5ba 100644 --- a/core/src/workflow/node_builders.ts +++ b/core/src/workflow/node_builders.ts @@ -4,11 +4,35 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {BaseTool, isBaseTool} from '../tools/base_tool.js'; +import {FunctionNode, FunctionNodeHandler} from './nodes/function_node.js'; +import {ToolNode} from './nodes/tool_node.js'; import type { NodeBuilder, ParallelWorkerFactory, } from './utils/workflow_graph_utils.js'; +/** Builds a {@link FunctionNode} from a plain function. */ +const FUNCTION_BUILDER: NodeBuilder = { + match: (value) => typeof value === 'function', + build: (value, options) => { + const handler = value as FunctionNodeHandler; + const name = options.name ?? (handler as {name?: string}).name; + if (!name) { + throw new Error( + 'node(): the wrapped function has no name; pass {name} explicitly.', + ); + } + return new FunctionNode(name, handler, options); + }, +}; + +/** Builds a {@link ToolNode} from a {@link BaseTool}. */ +const TOOL_BUILDER: NodeBuilder = { + match: (value) => isBaseTool(value), + build: (value, options) => new ToolNode(value as BaseTool, options), +}; + /** * The built-in node builders, consulted in order by `buildNode` / `isNodeLike` * to turn a bare function / tool / agent into the right `BaseNode`. @@ -16,10 +40,12 @@ import type { * This is a single, explicit, statically-imported list — node-type modules are * wired in here rather than self-registering at import time, so there is no * global mutable registry and no import-order side effects. Order is the match - * precedence (first match wins). Each node-type part adds its builder here; the - * list is empty in the engine-core part. + * precedence (first match wins). Each node-type part adds its builder here. */ -export const NODE_BUILDERS: readonly NodeBuilder[] = []; +export const NODE_BUILDERS: readonly NodeBuilder[] = [ + FUNCTION_BUILDER, + TOOL_BUILDER, +]; /** * Wraps an already-built node in a parallel worker. Wired in by the diff --git a/core/src/workflow/nodes/function_node.ts b/core/src/workflow/nodes/function_node.ts new file mode 100644 index 00000000..d4df8957 --- /dev/null +++ b/core/src/workflow/nodes/function_node.ts @@ -0,0 +1,249 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {AuthConfig} from '../../auth/auth_tool.js'; +import {createEvent, Event, isEvent} from '../../events/event.js'; +import {BaseNode, BaseNodeConfig, isContent, toContent} from '../base_node.js'; +import {NodeContext} from '../node_context.js'; +import { + createAuthRequestEvent, + hasAuthCredential, + processAuthResume, +} from '../utils/hitl_utils.js'; + +/** + * A value a {@link FunctionNodeHandler} may return or yield. + */ +export type FunctionNodeResult = + | TOutput + | Event + | null + | undefined + | void; + +/** + * The handler wrapped by a {@link FunctionNode}. + * + * Unlike Python's `FunctionNode` (which binds named parameters from `ctx.state` + * or `node_input` via runtime signature introspection), the TypeScript form + * uses the idiomatic explicit `(ctx, input)` signature. Read `ctx.state` + * directly for state-bound values. It may return a value/`Event`, a Promise, or + * a (sync/async) generator of those. + */ +export type FunctionNodeHandler = ( + ctx: NodeContext, + input: TInput, +) => + | FunctionNodeResult + | Promise> + | Generator, void, unknown> + | AsyncGenerator, void, unknown>; + +/** + * Options for a {@link FunctionNode}. + */ +export interface FunctionNodeConfig extends Partial< + Omit +> { + /** + * If set, the framework requests user authentication before running (Phase 5 + * enables the auth gate; stored here now for API parity). + */ + authConfig?: AuthConfig; +} + +/** + * A node that wraps a plain function, async function, or (sync/async) generator. + * + * Ported (TS-idiomatic subset) from `google/adk-python` `_function_node.py`. + * Return-value handling: + * - `Event` → emitted as-is (output validated against `outputSchema`) + * - genai `Content` → emitted as the event content + * - `null`/`undefined` → skipped (unless there are pending state deltas) + * - anything else → emitted as `Event(output=value)` + * State written via `ctx.state` during execution is attached to emitted events. + */ +export class FunctionNode extends BaseNode< + TInput, + TOutput +> { + readonly authConfig?: AuthConfig; + private readonly handler: FunctionNodeHandler; + /** Per-run shadow of the state entries already attached to an emitted event. */ + private readonly attachedStateByCtx = new WeakMap< + NodeContext, + Map + >(); + + constructor( + name: string, + handler: FunctionNodeHandler, + config: FunctionNodeConfig = {}, + ) { + if (typeof handler !== 'function') { + throw new TypeError('FunctionNode handler must be a function.'); + } + // Spread first so an explicit `undefined` name in `config` can't clobber + // the resolved name (which BaseNode requires to be non-empty). + super({...config, name}); + this.handler = handler; + this.authConfig = config.authConfig; + } + + protected async *runImpl( + ctx: NodeContext, + input: TInput, + ): AsyncGenerator { + // Auth gate: request credentials (and interrupt) if not yet available. + if (this.authConfig) { + const authRequest = await this.runAuthGate(ctx); + if (authRequest) { + yield authRequest; + return; + } + } + + const result = this.handler(ctx, input); + + if (isAsyncIterable(result)) { + for await (const item of result) { + yield item; + } + } else if (isSyncGenerator(result)) { + for (const item of result) { + yield item; + } + } else { + // Plain value or Promise of a value. + yield await (result as Promise>); + } + } + + /** + * Ensures a credential for `authConfig` is available. Returns an + * `adk_request_credential` interrupt event if the credential must be + * requested from the user, or `undefined` if the node may proceed. + * + * On resume, a credential provided via `ctx.resumeInputs[credentialKey]` is + * stored into state before re-checking. + */ + private async runAuthGate(ctx: NodeContext): Promise { + const authConfig = this.authConfig!; + if (hasAuthCredential(authConfig, ctx.state)) { + return undefined; + } + const resumeResponse = ctx.resumeInputs[authConfig.credentialKey]; + if (resumeResponse !== undefined) { + await processAuthResume({ + responseData: resumeResponse, + authConfig, + state: ctx.state, + }); + if (hasAuthCredential(authConfig, ctx.state)) { + return undefined; + } + } + // The credential key doubles as a deterministic interrupt id so the resume + // response matches across turns. + return createAuthRequestEvent(authConfig, authConfig.credentialKey); + } + + /** + * Returns the state-delta entries written since the last event was emitted + * for this run (new keys or changed values). A multi-event handler would + * otherwise re-emit the whole growing delta on every event. + * + * `ctx.actions.stateDelta` can't be drained — `NodeContext` builds its + * `State` over it — so we track what has already been attached in a shadow + * map keyed by the run's context (GC'd with the context). + */ + private pendingStateDelta( + ctx: NodeContext, + ): Record | undefined { + let shadow = this.attachedStateByCtx.get(ctx); + if (!shadow) { + shadow = new Map(); + this.attachedStateByCtx.set(ctx, shadow); + } + const delta: Record = {}; + for (const [key, value] of Object.entries(ctx.actions.stateDelta)) { + if (!shadow.has(key) || shadow.get(key) !== value) { + delta[key] = value; + shadow.set(key, value); + } + } + return Object.keys(delta).length > 0 ? delta : undefined; + } + + protected override toEvent(ctx: NodeContext, data: unknown): Event | null { + const stateDelta = this.pendingStateDelta(ctx); + + if (data === null || data === undefined) { + return stateDelta + ? createEvent({ + author: this.name, + invocationId: ctx.invocationId, + branch: ctx.branch, + actions: {stateDelta}, + }) + : null; + } + + if (isEvent(data)) { + const event = data as Event; + if (event.output !== undefined) { + event.output = this.validateOutput(event.output); + } + if (stateDelta) { + // The handler's own writes on the event it yielded win over the + // node's accumulated context state. + event.actions.stateDelta = {...stateDelta, ...event.actions.stateDelta}; + } + return event; + } + + if (isContent(data)) { + return createEvent({ + author: this.name, + invocationId: ctx.invocationId, + branch: ctx.branch, + content: data, + actions: stateDelta ? {stateDelta} : undefined, + }); + } + + const output = this.validateOutput(data); + return createEvent({ + author: this.name, + invocationId: ctx.invocationId, + branch: ctx.branch, + content: toContent(output), + output, + actions: stateDelta ? {stateDelta} : undefined, + }); + } +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return ( + value != null && + typeof (value as AsyncIterable)[Symbol.asyncIterator] === + 'function' + ); +} + +function isSyncGenerator(value: unknown): value is Generator { + // A string is iterable but has no `.next`, so the `.next` check already + // excludes it — no separate string guard needed. + return ( + value != null && + typeof (value as Iterable)[Symbol.iterator] === 'function' && + typeof (value as Generator).next === 'function' + ); +} + +// The builder that turns a plain function into a FunctionNode is wired into the +// static NODE_BUILDERS list in ../node_builders.ts. diff --git a/core/src/workflow/nodes/tool_node.ts b/core/src/workflow/nodes/tool_node.ts new file mode 100644 index 00000000..553186a1 --- /dev/null +++ b/core/src/workflow/nodes/tool_node.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {FunctionCall} from '@google/genai'; +import {handleFunctionCallList} from '../../agents/functions.js'; +import {Event, getFunctionResponses} from '../../events/event.js'; +import {BaseTool} from '../../tools/base_tool.js'; +import {BaseNode, BaseNodeConfig, isContent} from '../base_node.js'; +import {NodeContext} from '../node_context.js'; + +/** Options for a {@link ToolNode}. */ +export interface ToolNodeConfig extends Partial> { + /** Optional name override; defaults to the tool's name. */ + name?: string; +} + +/** + * A node that wraps an ADK {@link BaseTool} and invokes it with the node input + * as its arguments. + * + * Ported from `google/adk-python` `workflow/_tool_node.py`. The node input is + * coerced to a tool-args object: genai `Content` → its text; a JSON string → + * parsed object; `null`/empty → `{}`. + * + * The tool runs through the canonical execution path + * ({@link handleFunctionCallList}), so the plugin `before`/`after`/`onError` + * tool callbacks, the confirmation gate, telemetry, and everything the tool + * writes to its context (`stateDelta`, `artifactDelta`, requested credentials / + * confirmations, …) all apply — exactly as when the same tool is called from an + * LLM agent. The emitted event carries a canonical `functionResponse` part. + */ +export class ToolNode extends BaseNode { + readonly tool: BaseTool; + + constructor(tool: BaseTool, config: ToolNodeConfig = {}) { + // Spread first so an explicit `undefined` name in `config` can't clobber + // the fallback (which BaseNode requires to be non-empty). + super({...config, name: config.name ?? tool.name}); + if (tool.isLongRunning) { + // Long-running/HITL tools suspend the invocation; that machinery lands in + // a later part. Fail loud rather than silently completing the call. + throw new Error( + `ToolNode does not support long-running tools yet (tool '${tool.name}').`, + ); + } + this.tool = tool; + } + + protected async *runImpl( + ctx: NodeContext, + input: unknown, + ): AsyncGenerator { + // Coerce the node input into a tool-args object, then re-validate it against + // `inputSchema`. BaseNode.validateInput skips genai `Content` up front (a + // node coerces it itself), so this is the only point model-authored args are + // checked before reaching the tool. + const args = this.validateInput(coerceToolArgs(input)) as Record< + string, + unknown + >; + + // Deterministic id so credential/confirmation requests can be matched to + // their resume response across turns/retries (a fresh UUID never would). + const functionCall: FunctionCall = { + name: this.tool.name, + args, + id: `${ctx.nodePath}:${ctx.runId}`, + }; + + const responseEvent = await handleFunctionCallList({ + invocationContext: ctx.invocationContext, + functionCalls: [functionCall], + toolsDict: {[this.tool.name]: this.tool}, + // Plugin callbacks still run via invocationContext.pluginManager; there is + // no agent-level tool-callback list on a workflow node. + beforeToolCallbacks: [], + afterToolCallbacks: [], + }); + + if (!responseEvent) { + return; + } + responseEvent.author = this.name; + // Surface the tool's (post-callback) response as the node output so it can + // drive downstream nodes, while the event keeps its canonical + // functionResponse content for history. + const responses = getFunctionResponses(responseEvent); + if (responses.length > 0) { + responseEvent.output = responses[0].response; + } + yield responseEvent; + } +} + +/** Coerces arbitrary node input into a tool-arguments record. */ +function coerceToolArgs(input: unknown): Record { + let args: unknown = input; + + if (isContent(args)) { + args = extractText(args); + } + + if (typeof args === 'string') { + const trimmed = args.trim(); + if (!trimmed) { + args = null; + } else { + try { + args = JSON.parse(trimmed); + } catch { + // Leave as the raw string; rejected below. + } + } + } + + if (args === null || args === undefined) { + return {}; + } + if (typeof args !== 'object' || Array.isArray(args)) { + throw new TypeError( + 'The input to ToolNode must be an object of tool arguments or null, ' + + `but got ${typeof args}.`, + ); + } + return args as Record; +} + +function extractText(content: {parts?: Array<{text?: string}>}): string { + return (content.parts ?? []).map((p) => p.text ?? '').join(''); +} + +// The builder that turns a BaseTool into a ToolNode is wired into the static +// NODE_BUILDERS list in ../node_builders.ts. diff --git a/core/test/workflow/function_node_test.ts b/core/test/workflow/function_node_test.ts new file mode 100644 index 00000000..ea8e27fd --- /dev/null +++ b/core/test/workflow/function_node_test.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; +import {AuthCredentialTypes} from '../../src/auth/auth_credential.js'; +import {AuthConfig} from '../../src/auth/auth_tool.js'; +import {createEvent, Event} from '../../src/events/event.js'; +import {AsyncQueue} from '../../src/utils/async_queue.js'; +import {NodeContext} from '../../src/workflow/node_context.js'; +import {FunctionNode} from '../../src/workflow/nodes/function_node.js'; +import {REQUEST_CREDENTIAL_FUNCTION_CALL_NAME} from '../../src/workflow/utils/hitl_utils.js'; +import {createIc, driveNode} from './test_helpers.js'; + +describe('FunctionNode result handling', () => { + it('yields one event per item from a generator handler', async () => { + const node = new FunctionNode('gen', function* () { + yield 'a'; + yield 'b'; + }); + const {events, output} = await driveNode(node); + expect(events.map((e) => e.output)).toEqual(['a', 'b']); + expect(output).toBe('b'); + }); + + it('emits a genai Content result as the event content', async () => { + const node = new FunctionNode('c', () => ({ + role: 'model', + parts: [{text: 'hi'}], + })); + const {events} = await driveNode(node); + expect(events.at(-1)?.content?.parts?.[0]?.text).toBe('hi'); + }); + + it('skips a null result with no pending state', async () => { + const node = new FunctionNode('n', () => null); + const {events, output} = await driveNode(node); + expect(events).toHaveLength(0); + expect(output).toBeUndefined(); + }); + + it('passes an explicitly returned Event through', async () => { + const node = new FunctionNode('e', () => createEvent({output: 'x'})); + const {output} = await driveNode(node); + expect(output).toBe('x'); + }); +}); + +describe('FunctionNode state delta attachment', () => { + it('attaches each written key only once across a multi-event run', async () => { + const node = new FunctionNode('w', function* (ctx) { + ctx.state.set('k', 1); + yield 'a'; + yield 'b'; + }); + const {events} = await driveNode(node); + // First event carries the write; the second does not re-emit it. + expect(events[0].actions.stateDelta).toEqual({k: 1}); + expect(events[1].actions.stateDelta).toEqual({}); + }); + + it('lets a handler-set event delta win over the context delta', async () => { + const node = new FunctionNode('w', function* (ctx) { + ctx.state.set('k', 'ctx'); + yield createEvent({output: 'x', actions: {stateDelta: {k: 'handler'}}}); + }); + const {events} = await driveNode(node); + expect(events.at(-1)?.actions.stateDelta.k).toBe('handler'); + }); +}); + +describe('FunctionNode auth gate', () => { + const apiKeyConfig = (): AuthConfig => ({ + credentialKey: 'k', + authScheme: {type: 'apiKey', name: 'k', in: 'header'}, + rawAuthCredential: {authType: AuthCredentialTypes.API_KEY}, + }); + + it('interrupts with a credential request when none is available', async () => { + const node = new FunctionNode('needsAuth', () => 'ran', { + authConfig: apiKeyConfig(), + }); + const {events, output} = await driveNode(node, 'x'); + + // The handler never ran; a credential-request interrupt was emitted instead. + expect(output).toBeUndefined(); + const fc = events.at(-1)?.content?.parts?.[0]?.functionCall; + expect(fc?.name).toBe(REQUEST_CREDENTIAL_FUNCTION_CALL_NAME); + expect(events.at(-1)?.longRunningToolIds).toContain('k'); + }); + + it('proceeds when the credential is supplied via resumeInputs', async () => { + const node = new FunctionNode('needsAuth', () => 'ran', { + authConfig: apiKeyConfig(), + }); + const channel = new AsyncQueue(); + const root = new NodeContext({ + invocationContext: createIc(), + channel, + nodePath: '', + runId: 'root', + resumeInputs: {k: 'my-key'}, + }); + const child = await root.runNode(node, 'x', {useAsOutput: true}); + expect(child.output).toBe('ran'); + }); +}); diff --git a/core/test/workflow/node_builders_test.ts b/core/test/workflow/node_builders_test.ts new file mode 100644 index 00000000..9e8e0f03 --- /dev/null +++ b/core/test/workflow/node_builders_test.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; +import {BaseTool} from '../../src/tools/base_tool.js'; +import {FunctionNode} from '../../src/workflow/nodes/function_node.js'; +import {ToolNode} from '../../src/workflow/nodes/tool_node.js'; +import { + buildNode, + isNodeLike, +} from '../../src/workflow/utils/workflow_graph_utils.js'; + +// Importing the node modules above registers their builders as a side effect; +// these tests verify that self-registration wires buildNode/isNodeLike. + +class TestTool extends BaseTool { + constructor() { + super({name: 'test_tool', description: 'a tool'}); + } + async runAsync(): Promise { + return 'ok'; + } +} + +/** Returns a function with an empty `.name` (not bound to a variable). */ +function anonymousFn(): () => void { + return () => {}; +} + +describe('node builder registry', () => { + it('builds a FunctionNode from a named function', () => { + function greet() {} + const node = buildNode(greet); + expect(node).toBeInstanceOf(FunctionNode); + expect(node.name).toBe('greet'); + }); + + it('uses an explicit name for an anonymous function', () => { + const node = buildNode(anonymousFn(), {name: 'anon'}); + expect(node).toBeInstanceOf(FunctionNode); + expect(node.name).toBe('anon'); + }); + + it('throws for an unnamed function with no name option', () => { + expect(() => buildNode(anonymousFn())).toThrow(/no name/i); + }); + + it('builds a ToolNode from a BaseTool', () => { + const node = buildNode(new TestTool()); + expect(node).toBeInstanceOf(ToolNode); + expect(node.name).toBe('test_tool'); + }); + + it('returns an existing BaseNode as-is', () => { + const built = buildNode(() => {}, {name: 'x'}); + expect(buildNode(built)).toBe(built); + }); + + it('recognizes functions, tools, and START as node-like', () => { + expect(isNodeLike(() => {})).toBe(true); + expect(isNodeLike(new TestTool())).toBe(true); + expect(isNodeLike('START')).toBe(true); + expect(isNodeLike({not: 'a node'})).toBe(false); + expect(isNodeLike(42)).toBe(false); + }); +}); diff --git a/core/test/workflow/schema_validation_test.ts b/core/test/workflow/schema_validation_test.ts new file mode 100644 index 00000000..63dac997 --- /dev/null +++ b/core/test/workflow/schema_validation_test.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; +import {z} from 'zod'; +import {FunctionNode} from '../../src/workflow/nodes/function_node.js'; +import {driveNode} from './test_helpers.js'; + +describe('node schema validation', () => { + it('validates input against inputSchema', async () => { + const node = new FunctionNode('squared', (_c, n: number) => n * n, { + inputSchema: z.number(), + }); + expect((await driveNode(node, 5)).output).toBe(25); + await expect(driveNode(node, 'not-a-number')).rejects.toThrow(); + }); + + it('validates output against outputSchema', async () => { + const schema = z.object({total: z.number()}); + const good = new FunctionNode('g', () => ({total: 10}), { + outputSchema: schema, + }); + expect((await driveNode(good)).output).toEqual({total: 10}); + + const bad = new FunctionNode('b', () => ({total: 'oops'}), { + outputSchema: schema, + }); + await expect(driveNode(bad)).rejects.toThrow(); + }); + + it('coerces and validates a valid input, passing it to the handler', async () => { + let received: unknown; + const node = new FunctionNode( + 'capture', + (_c, value: {name: string}) => { + received = value; + return value.name; + }, + {inputSchema: z.object({name: z.string()})}, + ); + expect((await driveNode(node, {name: 'ada'})).output).toBe('ada'); + expect(received).toEqual({name: 'ada'}); + }); +}); diff --git a/core/test/workflow/tool_node_test.ts b/core/test/workflow/tool_node_test.ts new file mode 100644 index 00000000..522647c1 --- /dev/null +++ b/core/test/workflow/tool_node_test.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; +import {getFunctionResponses} from '../../src/events/event.js'; +import {BasePlugin} from '../../src/plugins/base_plugin.js'; +import {BaseTool, RunAsyncToolRequest} from '../../src/tools/base_tool.js'; +import {ToolNode} from '../../src/workflow/nodes/tool_node.js'; +import {createIc, driveNode} from './test_helpers.js'; + +/** A tool that records the args it was called with and echoes them back. */ +class EchoTool extends BaseTool { + lastArgs?: Record; + constructor() { + super({name: 'echo', description: 'echoes its args'}); + } + async runAsync({args}: RunAsyncToolRequest): Promise { + this.lastArgs = args; + return {echoed: args}; + } +} + +/** A tool that writes to its context state and returns a scalar. */ +class StateWritingTool extends BaseTool { + constructor() { + super({name: 'writer', description: 'writes state'}); + } + async runAsync({toolContext}: RunAsyncToolRequest): Promise { + toolContext.state.set('touched', true); + return 'done'; + } +} + +describe('ToolNode execution', () => { + it('invokes the tool with coerced args and surfaces the response', async () => { + const tool = new EchoTool(); + const {events, output} = await driveNode(new ToolNode(tool), {city: 'ams'}); + + expect(tool.lastArgs).toEqual({city: 'ams'}); + expect(output).toEqual({echoed: {city: 'ams'}}); + // The event carries a canonical functionResponse part (visible to history). + const responses = getFunctionResponses(events.at(-1)!); + expect(responses[0]?.name).toBe('echo'); + }); + + it('propagates tool context state writes onto the emitted event', async () => { + const {events} = await driveNode(new ToolNode(new StateWritingTool())); + expect(events.at(-1)?.actions.stateDelta).toEqual({touched: true}); + }); + + it('runs the plugin tool-callback chain (before_tool_callback override)', async () => { + const tool = new EchoTool(); + class OverridePlugin extends BasePlugin { + constructor() { + super('override'); + } + override async beforeToolCallback(): Promise> { + return {overridden: true}; + } + } + const ic = createIc(); + ic.pluginManager.registerPlugin(new OverridePlugin()); + + const {output} = await driveNode(new ToolNode(tool), {a: 1}, ic); + + // The plugin short-circuited the call: its response wins and the tool's own + // runAsync never ran — proof ToolNode goes through the shared execution path. + expect(output).toEqual({overridden: true}); + expect(tool.lastArgs).toBeUndefined(); + }); + + it('throws for a long-running tool at construction', () => { + class LongTool extends BaseTool { + constructor() { + super({name: 'long', description: 'long', isLongRunning: true}); + } + async runAsync(): Promise { + return null; + } + } + expect(() => new ToolNode(new LongTool())).toThrow(/long-running/i); + }); +}); + +describe('ToolNode argument coercion', () => { + const drive = async (input: unknown) => { + const tool = new EchoTool(); + await driveNode(new ToolNode(tool), input); + return tool.lastArgs; + }; + + it('passes an object through unchanged', async () => { + expect(await drive({a: 1})).toEqual({a: 1}); + }); + + it('parses a JSON-string input', async () => { + expect(await drive('{"a":2}')).toEqual({a: 2}); + }); + + it('extracts and parses text from genai Content', async () => { + expect(await drive({role: 'user', parts: [{text: '{"a":3}'}]})).toEqual({ + a: 3, + }); + }); + + it('treats null / empty string as no arguments', async () => { + expect(await drive(null)).toEqual({}); + expect(await drive('')).toEqual({}); + }); + + it('rejects array and scalar inputs', async () => { + await expect( + driveNode(new ToolNode(new EchoTool()), [1, 2]), + ).rejects.toThrow(TypeError); + await expect(driveNode(new ToolNode(new EchoTool()), 5)).rejects.toThrow( + TypeError, + ); + }); +});