diff --git a/.changeset/fix-tools-to-model-tools.md b/.changeset/fix-tools-to-model-tools.md new file mode 100644 index 0000000000..304c93e8a6 --- /dev/null +++ b/.changeset/fix-tools-to-model-tools.md @@ -0,0 +1,5 @@ +--- +"@workflow/ai": patch +--- + +Forward `strict`, `inputExamples`, and `providerOptions` tool properties to language model providers, and handle `type: 'dynamic'` tools diff --git a/.changeset/start-in-workflow.md b/.changeset/start-in-workflow.md new file mode 100644 index 0000000000..d4e0fc3614 --- /dev/null +++ b/.changeset/start-in-workflow.md @@ -0,0 +1,6 @@ +--- +"@workflow/core": minor +"workflow": minor +--- + +Allow `start()` to be called directly inside workflow functions diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index f26a345646..47a1c662af 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -137,7 +137,7 @@ jobs: Do NOT run git cherry-pick --continue or git commit. PROMPT - opencode run --model vercel/anthropic/claude-opus-4.6 "$(cat /tmp/backport-prompt.txt)" + opencode run --model vercel/anthropic/claude-opus-4.7 "$(cat /tmp/backport-prompt.txt)" # Verify all conflicts are resolved REMAINING=$(git diff --name-only --diff-filter=U || true) diff --git a/packages/ai/src/agent/tools-to-model-tools.test.ts b/packages/ai/src/agent/tools-to-model-tools.test.ts index 88e37a5860..9dfde769e5 100644 --- a/packages/ai/src/agent/tools-to-model-tools.test.ts +++ b/packages/ai/src/agent/tools-to-model-tools.test.ts @@ -8,7 +8,7 @@ describe('toolsToModelTools', () => { const tools = { weather: tool({ description: 'Get the weather', - parameters: z.object({ city: z.string() }), + inputSchema: z.object({ city: z.string() }), execute: async ({ city }) => `Weather in ${city}: sunny`, }), }; @@ -61,7 +61,7 @@ describe('toolsToModelTools', () => { const tools = { weather: tool({ description: 'Get the weather', - parameters: z.object({ city: z.string() }), + inputSchema: z.object({ city: z.string() }), execute: async ({ city }) => `Weather in ${city}: sunny`, }), webSearch: providerTool, @@ -103,4 +103,119 @@ describe('toolsToModelTools', () => { args: {}, }); }); + + it('forwards strict: true', async () => { + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + strict: true, + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result[0]).toMatchObject({ strict: true }); + }); + + it('forwards strict: false', async () => { + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + strict: false, + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result[0]).toMatchObject({ strict: false }); + }); + + it('omits strict key when not set', async () => { + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result[0]).not.toHaveProperty('strict'); + }); + + it('forwards inputExamples', async () => { + const examples = [{ input: { location: 'Tokyo' } }]; + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + inputExamples: examples, + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result[0]).toMatchObject({ inputExamples: examples }); + }); + + it('omits inputExamples key when not set', async () => { + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result[0]).not.toHaveProperty('inputExamples'); + }); + + it('forwards providerOptions', async () => { + const providerOptions = { openai: { parallel_tool_calls: false } }; + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + providerOptions, + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result[0]).toMatchObject({ providerOptions }); + }); + + it('handles tools with type: "dynamic" as function tools', async () => { + const tools = { + dynamic: { + type: 'dynamic' as const, + description: 'A dynamic tool', + inputSchema: z.object({ input: z.string() }), + execute: async () => 'result', + }, + }; + + const result = await toolsToModelTools(tools as any); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'function', + name: 'dynamic', + description: 'A dynamic tool', + }); + }); + + it('returns empty array for empty tools', async () => { + const result = await toolsToModelTools({}); + expect(result).toEqual([]); + }); }); diff --git a/packages/ai/src/agent/tools-to-model-tools.ts b/packages/ai/src/agent/tools-to-model-tools.ts index a061a8a435..f40ed5f9b3 100644 --- a/packages/ai/src/agent/tools-to-model-tools.ts +++ b/packages/ai/src/agent/tools-to-model-tools.ts @@ -4,28 +4,45 @@ import type { } from '@ai-sdk/provider'; import { asSchema, type ToolSet } from 'ai'; +// Mirrors the tool→LanguageModelV3FunctionTool/LanguageModelV3ProviderTool +// mapping in the core AI SDK's prepareToolsAndToolChoice +// (ai/src/prompt/prepare-tools-and-tool-choice.ts). export async function toolsToModelTools( tools: ToolSet ): Promise> { return Promise.all( Object.entries(tools).map(async ([name, tool]) => { - // Preserve provider tool identity (e.g. anthropic.tools.webSearch) - // instead of converting to a plain function tool - if ((tool as any).type === 'provider') { - return { - type: 'provider' as const, - id: (tool as any).id as `${string}.${string}`, - name, - args: (tool as any).args ?? {}, - }; - } + const toolType = tool.type; - return { - type: 'function' as const, - name, - description: tool.description, - inputSchema: await asSchema(tool.inputSchema).jsonSchema, - }; + switch (toolType) { + case undefined: + case 'dynamic': + case 'function': + return { + type: 'function' as const, + name, + description: tool.description, + inputSchema: await asSchema(tool.inputSchema).jsonSchema, + ...(tool.inputExamples != null + ? { inputExamples: tool.inputExamples } + : {}), + providerOptions: tool.providerOptions, + ...(tool.strict != null ? { strict: tool.strict } : {}), + }; + case 'provider': + // Preserve provider tool identity (e.g. anthropic.tools.webSearch) + // instead of converting to a plain function tool + return { + type: 'provider' as const, + name, + id: tool.id, + args: tool.args ?? {}, + }; + default: { + const exhaustiveCheck: never = toolType as never; + throw new Error(`Unsupported tool type: ${exhaustiveCheck}`); + } + } }) ); } diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index 7f589732e3..c31109bbbc 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -1553,6 +1553,40 @@ describe('e2e', () => { } ); + test( + 'startFromWorkflow - calling start() directly inside a workflow function with hook communication', + { timeout: 120_000 }, + async () => { + const inputValue = 42; + const run = await start(await e2e('startFromWorkflow'), [inputValue]); + trackRun(run); + const returnValue = await run.returnValue; + + expect(returnValue.parentInput).toBe(inputValue); + expect(typeof returnValue.childRunId).toBe('string'); + expect(returnValue.childRunId.startsWith('wrun_')).toBe(true); + expect(returnValue.signalFromChild.processed).toBe(inputValue * 3); + + // Verify child workflow also completed independently + const childRun = getRun(returnValue.childRunId); + trackRun(childRun); + const childResult = await childRun.returnValue; + expect(childResult.processed).toBe(inputValue * 3); + } + ); + + test( + 'fibonacciWorkflow - recursive workflow composition via start()', + { timeout: 180_000 }, + async () => { + // fib(6) = 8, spawns a tree of child workflow runs + const run = await start(await e2e('fibonacciWorkflow'), [6]); + trackRun(run); + const returnValue = await run.returnValue; + expect(returnValue).toBe(8); + } + ); + // This test requires direct HTTP access and works when running locally. // For production use on Vercel with Deployment Protection enabled, use the // queue-based `healthCheck(world, endpoint, options)` function instead, which diff --git a/packages/core/src/runtime/start.ts b/packages/core/src/runtime/start.ts index 462a80de5b..73dcdc5f79 100644 --- a/packages/core/src/runtime/start.ts +++ b/packages/core/src/runtime/start.ts @@ -119,6 +119,7 @@ export async function start( argsOrOptions?: TArgs | StartOptions, options?: StartOptions ) { + 'use step'; return await waitedUntil(() => { // @ts-expect-error this field is added by our client transform const workflowName = workflow?.workflowId; diff --git a/packages/workflow/src/api-workflow.ts b/packages/workflow/src/api-workflow.ts index feccd8c569..6f8b6560b3 100644 --- a/packages/workflow/src/api-workflow.ts +++ b/packages/workflow/src/api-workflow.ts @@ -8,6 +8,7 @@ export type { } from '@workflow/core/runtime'; export { Run } from '@workflow/core/runtime/run'; +export { start } from '@workflow/core/runtime/start'; const workflowStub = (item: string) => { throw new Error( @@ -20,4 +21,3 @@ export const getHookByToken = () => workflowStub('getHookByToken'); export const resumeHook = () => workflowStub('resumeHook'); export const resumeWebhook = () => workflowStub('resumeWebhook'); export const runStep = () => workflowStub('runStep'); -export const start = () => workflowStub('start'); diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index 15970e24e1..cfbfd65b3d 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -13,7 +13,7 @@ import { RetryableError, sleep, } from 'workflow'; -import { getRun, Run, start } from 'workflow/api'; +import { getRun, Run, resumeHook, start } from 'workflow/api'; import { importedStepOnly } from './_imported_step_only'; import { callThrower, stepThatThrowsFromHelper } from './helpers'; @@ -1655,3 +1655,64 @@ export async function getterStepWorkflow( reading2, // 100 * 2 = 200 }; } + +////////////////////////////////////////////////////////// +// start() inside workflow functions +////////////////////////////////////////////////////////// + +/** + * Child workflow used by startFromWorkflow. + * Receives a hook token from its parent, processes a value, + * and signals the parent via resumeHook before completing. + */ +export async function childWorkflowWithHookSignal( + hookToken: string, + value: number +) { + 'use workflow'; + const result = await processAndSignalParent(hookToken, value); + return result; +} + +async function processAndSignalParent(hookToken: string, value: number) { + 'use step'; + const processed = value * 3; + await resumeHook(hookToken, { processed }); + return { processed }; +} + +/** + * Parent workflow that calls start() directly to spawn a child workflow, + * then waits for a hook signal from the child. + */ +export async function startFromWorkflow(inputValue: number) { + 'use workflow'; + const hook = createHook<{ processed: number }>(); + const childRun = await start(childWorkflowWithHookSignal, [ + hook.token, + inputValue, + ]); + const signal = await hook; + return { + parentInput: inputValue, + childRunId: childRun.runId, + signalFromChild: signal, + }; +} + +/** + * Recursive Fibonacci workflow. start() is called directly to spawn + * child workflows for fib(n-1) and fib(n-2). + */ +export async function fibonacciWorkflow(n: number): Promise { + 'use workflow'; + if (n <= 1) return n; + + const [runA, runB] = await Promise.all([ + start(fibonacciWorkflow, [n - 1]), + start(fibonacciWorkflow, [n - 2]), + ]); + + const [a, b] = await Promise.all([runA.returnValue, runB.returnValue]); + return a + b; +} diff --git a/workbench/nextjs-turbopack/app/workflows/definitions.ts b/workbench/nextjs-turbopack/app/workflows/definitions.ts index 36fa37ab35..57476122b0 100644 --- a/workbench/nextjs-turbopack/app/workflows/definitions.ts +++ b/workbench/nextjs-turbopack/app/workflows/definitions.ts @@ -31,6 +31,8 @@ const DEFAULT_ARGS_MAP: Record = { ], hookCleanupTestWorkflow: [RANDOM_ARG_PLACEHOLDER, RANDOM_ARG_PLACEHOLDER], closureVariableWorkflow: [7], + startFromWorkflow: [42], + fibonacciWorkflow: [3], // 100_durable_agent_e2e.ts agentBasicE2e: ['hello world'], agentToolCallE2e: [3, 7],