From a12c0e8511c41cda1e8b1ac00ae5a3668bb340f1 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Fri, 17 Apr 2026 07:17:36 -0700 Subject: [PATCH 1/3] ci: bump opencode model to claude-opus-4.7 in backport workflow (#1795) --- .github/workflows/backport.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 3f6d98f3f0b1e18af47a7665fa210d87f99ff979 Mon Sep 17 00:00:00 2001 From: Nishant Singh Date: Fri, 17 Apr 2026 20:08:29 +0530 Subject: [PATCH 2/3] [ai] Align toolsToModelTools with core AI SDK's prepareToolsAndToolChoice (#1544) --- .changeset/fix-tools-to-model-tools.md | 5 + .../ai/src/agent/tools-to-model-tools.test.ts | 119 +++++++++++++++++- packages/ai/src/agent/tools-to-model-tools.ts | 49 +++++--- 3 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 .changeset/fix-tools-to-model-tools.md 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/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}`); + } + } }) ); } From e295bae417bd072f8e18e8d07c76d90d40ae7cec Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Fri, 17 Apr 2026 07:40:25 -0700 Subject: [PATCH 3/3] feat: allow `start()` to be called directly inside workflow functions (#1491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: allow start() to be called directly inside workflow functions Add 'use step' to start() so it can be called directly from workflow code. The SWC compiler strips the function body in workflow mode and replaces it with a step proxy. When called from a workflow: 1. The workflow function reference is serialized via WorkflowFunction reducer (serializes { workflowId }) 2. start() executes in the step context with full Node.js access 3. The returned Run is serialized via WORKFLOW_SERIALIZE and deserialized back in the workflow VM 4. Run getters (.status, .returnValue, etc.) are 'use step' getters that each execute as separate steps Also re-exports start from @workflow/core/runtime/start in api-workflow.ts instead of using a throwing stub, adds e2e tests for startFromWorkflow (with hook communication) and fibonacciWorkflow (recursive composition). * fix(next): don't copy package step files in deferred builder to avoid duplicate classes Files belonging to packages (detected by walking up to find a package.json with a name field) are imported via relative path instead of being copied to __workflow_step_files__/. Copying creates a second module instance which breaks JS native private field (#) brand checks when the runtime creates instances from one copy and the step handler accesses fields from the other. * fix(next): only skip copying package files that are serde classes, not all package step files Regular package step files (like fetch) must still be copied to ensure the SWC loader registers them. Only serde class files from packages are excluded from copying since those define classes with JS native private fields (#) that break when duplicated. * fix(next): generate thin wrappers for package serde step files instead of full copies For package files that define serde classes (like Run), generate a thin wrapper that imports the original class and registers steps/classes from the manifest. This avoids duplicating the class definition (which breaks JS native private field brand checks) while still registering all step functions and the class in the serialization registry. Regular package step files (like fetch) are still copied as before. * fix(next): use forceStepModeFiles to transform package serde files in step mode Instead of copying package serde+step files (which creates duplicate classes with #private brand check issues) or generating fragile wrappers, add the original file paths to a shared forceStepModeFiles set. The loader checks this set and transforms those files in step mode directly, so the SWC plugin generates proper step registrations on the original class — no duplication, no reimplemented registration logic. * fix(next): use step mode for all files with step/serde patterns, not just copies The loader now selects step mode for any file that has 'use step' directives or serde patterns, regardless of whether it's a deferred step copy. Step mode is a superset of client mode — the only addition is step registry IIFEs, which are harmless for non-step consumers. This means package serde+step files (like Run) no longer need to be copied to get step registrations. They're imported directly in the step route and the loader transforms the original file in step mode. One class instance, no duplication, no wrapper generation. --------- Co-authored-by: Nathan Rajlich --- .changeset/start-in-workflow.md | 6 ++ packages/core/e2e/e2e.test.ts | 34 ++++++++++ packages/core/src/runtime/start.ts | 1 + packages/workflow/src/api-workflow.ts | 2 +- workbench/example/workflows/99_e2e.ts | 63 ++++++++++++++++++- .../app/workflows/definitions.ts | 2 + 6 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 .changeset/start-in-workflow.md 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/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],