diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index c323803f80..2d60449178 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -32,7 +32,10 @@ import { QuickJSRuntimeFactory } from "@/node/services/ptc/quickjsRuntime"; import { resolveWorkflowScript } from "@/node/services/workflows/workflowScriptResolver"; import { WorkflowRunStore } from "@/node/services/workflows/WorkflowRunStore"; import { WorkflowService } from "@/node/services/workflows/WorkflowService"; -import { WorkflowTaskServiceAdapter } from "@/node/services/workflows/WorkflowTaskServiceAdapter"; +import { + DEFAULT_WORKFLOW_AGENT_ID, + WorkflowTaskServiceAdapter, +} from "@/node/services/workflows/WorkflowTaskServiceAdapter"; import { hasAnyConfiguredProvider, buildProvidersFromEnv } from "@/node/utils/providerRequirements"; import { getParseOptions } from "./argv"; import { exitAfterStdoutFlush } from "./processExit"; @@ -371,7 +374,7 @@ function createWorkflowService(input: { taskService: input.ctx.services.taskService, parentWorkspaceId: input.ctx.workspaceId, workflowRunId: runId, - defaultAgentId: "explore", + defaultAgentId: DEFAULT_WORKFLOW_AGENT_ID, experiments, modelString: input.model, thinkingLevel: input.thinkingLevel, diff --git a/src/node/builtinSkills/deep-research.md b/src/node/builtinSkills/deep-research.md index 2ed6147837..8863453d9f 100644 --- a/src/node/builtinSkills/deep-research.md +++ b/src/node/builtinSkills/deep-research.md @@ -16,4 +16,4 @@ workflow_run({ }); ``` -The workflow scopes search angles, searches and fetches sources, extracts falsifiable claims, verifies claims adversarially, and synthesizes a cited report with caveats. +The workflow scopes search angles, searches and fetches sources, extracts falsifiable claims, verifies claims adversarially with exec agents using their configured defaults, and synthesizes a cited report with caveats. diff --git a/src/node/builtinSkills/deep-research/workflow.js b/src/node/builtinSkills/deep-research/workflow.js index e91b6bf40e..8c4194de80 100644 --- a/src/node/builtinSkills/deep-research/workflow.js +++ b/src/node/builtinSkills/deep-research/workflow.js @@ -27,6 +27,9 @@ const MAX_VERIFY_CLAIMS = 25; const MAX_PARALLEL_FETCH = 5; const MAX_PARALLEL_VERIFY = 12; +const EXPLORE_AGENT = "explore"; +const EXEC_AGENT = "exec"; + const SCOPE_SCHEMA = { type: "object", required: ["question", "summary", "angles"], @@ -150,6 +153,7 @@ export default function workflow({ args, phase, log, agent, parallel, pipeline } const scope = agent(buildScopePrompt(question), { id: "scope", title: "Scope research angles", + agentId: EXPLORE_AGENT, schema: SCOPE_SCHEMA, }); const angles = scope.angles.slice(0, 6); @@ -167,6 +171,7 @@ export default function workflow({ args, phase, log, agent, parallel, pipeline } agent(buildSearchPrompt(question, angle), { id: stableId("search", angleIndex, angle.label), title: "Search: " + angle.label, + agentId: EXPLORE_AGENT, schema: SEARCH_SCHEMA, }), (searchResult, angleIndex) => { @@ -199,6 +204,7 @@ export default function workflow({ args, phase, log, agent, parallel, pipeline } agent(buildFetchPrompt(question, source, angle.label), { id: stableId("fetch", angleIndex + "-" + sourceIndex, hostFromUrl(source.url)), title: "Fetch: " + hostFromUrl(source.url), + agentId: EXPLORE_AGENT, schema: EXTRACT_SCHEMA, }) ), @@ -260,6 +266,8 @@ export default function workflow({ args, phase, log, agent, parallel, pipeline } agent(buildVerifyPrompt(question, spec.claim, spec.voteIndex), { id: stableId("verify", spec.claimIndex + "-" + spec.voteIndex, spec.claim.claim), title: "Verify claim " + (spec.claimIndex + 1) + "." + (spec.voteIndex + 1), + agentId: EXEC_AGENT, + onRefusal: "fail", schema: VERDICT_SCHEMA, }) ), @@ -289,6 +297,7 @@ export default function workflow({ args, phase, log, agent, parallel, pipeline } const report = agent(buildSynthesisPrompt(question, confirmed, killed), { id: "synthesize", title: "Synthesize research report", + agentId: EXEC_AGENT, schema: REPORT_SCHEMA, }); diff --git a/src/node/builtinSkills/workflow-authoring.md b/src/node/builtinSkills/workflow-authoring.md index b9cb715525..774908d969 100644 --- a/src/node/builtinSkills/workflow-authoring.md +++ b/src/node/builtinSkills/workflow-authoring.md @@ -163,7 +163,7 @@ Required options: - `id`: stable step ID used for replay; never derive from unstable ordering unless the input ordering is stable. - `schema`: optional JSON object schema. When present, the child reports schema-shaped data through `agent_report` and `agent()` returns that structured object directly. When omitted, `agent()` returns the child report markdown string. -Optional fields include `title`, `agentType`, `isolation`, and `onRefusal`. `model` and `effort` are rejected for now instead of being silently ignored. +Workflow agents default to `exec`. Optional fields include `title`, `agentId` (preferred), legacy `agentType`, `model`, `thinking`, `isolation`, and `onRefusal`. Use `agentId: "explore"` for read-only research/discovery stages. `model` accepts the same aliases/full model strings as the UI, `thinking` accepts `off|low|medium|high|xhigh|max` or a numeric index, and `effort` is rejected to avoid ambiguous provider-specific behavior. ```js const scope = agent("Scope this topic", { diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 15cd8d8f72..3cf99bd4b3 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -119,7 +119,10 @@ import { WorkflowService, type WorkflowBackgroundRunTerminalEvent, } from "@/node/services/workflows/WorkflowService"; -import { WorkflowTaskServiceAdapter } from "@/node/services/workflows/WorkflowTaskServiceAdapter"; +import { + DEFAULT_WORKFLOW_AGENT_ID, + WorkflowTaskServiceAdapter, +} from "@/node/services/workflows/WorkflowTaskServiceAdapter"; import { WorkflowArgsValidationError } from "@/node/services/workflows/workflowArgs"; import { resolveWorkflowScript } from "@/node/services/workflows/workflowScriptResolver"; import { isProjectTrusted } from "@/node/utils/projectTrust"; @@ -396,7 +399,7 @@ export async function resolveWorkflowContext( parentWorkspaceId: workspaceId, workflowRunId: runId, workflowName, - defaultAgentId: "explore", + defaultAgentId: DEFAULT_WORKFLOW_AGENT_ID, patchToolConfig: { workspaceId, cwd: workspacePath, diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 2568984e59..1192d1ff14 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -23,7 +23,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "});", "```", "", - "The workflow scopes search angles, searches and fetches sources, extracts falsifiable claims, verifies claims adversarially, and synthesizes a cited report with caveats.", + "The workflow scopes search angles, searches and fetches sources, extracts falsifiable claims, verifies claims adversarially with exec agents using their configured defaults, and synthesizes a cited report with caveats.", "", ].join("\n"), "workflow.js": [ @@ -56,6 +56,9 @@ export const BUILTIN_SKILL_FILES: Record> = { "const MAX_PARALLEL_FETCH = 5;", "const MAX_PARALLEL_VERIFY = 12;", "", + 'const EXPLORE_AGENT = "explore";', + 'const EXEC_AGENT = "exec";', + "", "const SCOPE_SCHEMA = {", ' type: "object",', ' required: ["question", "summary", "angles"],', @@ -179,6 +182,7 @@ export const BUILTIN_SKILL_FILES: Record> = { " const scope = agent(buildScopePrompt(question), {", ' id: "scope",', ' title: "Scope research angles",', + " agentId: EXPLORE_AGENT,", " schema: SCOPE_SCHEMA,", " });", " const angles = scope.angles.slice(0, 6);", @@ -196,6 +200,7 @@ export const BUILTIN_SKILL_FILES: Record> = { " agent(buildSearchPrompt(question, angle), {", ' id: stableId("search", angleIndex, angle.label),', ' title: "Search: " + angle.label,', + " agentId: EXPLORE_AGENT,", " schema: SEARCH_SCHEMA,", " }),", " (searchResult, angleIndex) => {", @@ -228,6 +233,7 @@ export const BUILTIN_SKILL_FILES: Record> = { " agent(buildFetchPrompt(question, source, angle.label), {", ' id: stableId("fetch", angleIndex + "-" + sourceIndex, hostFromUrl(source.url)),', ' title: "Fetch: " + hostFromUrl(source.url),', + " agentId: EXPLORE_AGENT,", " schema: EXTRACT_SCHEMA,", " })", " ),", @@ -289,6 +295,8 @@ export const BUILTIN_SKILL_FILES: Record> = { " agent(buildVerifyPrompt(question, spec.claim, spec.voteIndex), {", ' id: stableId("verify", spec.claimIndex + "-" + spec.voteIndex, spec.claim.claim),', ' title: "Verify claim " + (spec.claimIndex + 1) + "." + (spec.voteIndex + 1),', + " agentId: EXEC_AGENT,", + ' onRefusal: "fail",', " schema: VERDICT_SCHEMA,", " })", " ),", @@ -318,6 +326,7 @@ export const BUILTIN_SKILL_FILES: Record> = { " const report = agent(buildSynthesisPrompt(question, confirmed, killed), {", ' id: "synthesize",', ' title: "Synthesize research report",', + " agentId: EXEC_AGENT,", " schema: REPORT_SCHEMA,", " });", "", @@ -7667,7 +7676,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "- `id`: stable step ID used for replay; never derive from unstable ordering unless the input ordering is stable.", "- `schema`: optional JSON object schema. When present, the child reports schema-shaped data through `agent_report` and `agent()` returns that structured object directly. When omitted, `agent()` returns the child report markdown string.", "", - "Optional fields include `title`, `agentType`, `isolation`, and `onRefusal`. `model` and `effort` are rejected for now instead of being silently ignored.", + 'Workflow agents default to `exec`. Optional fields include `title`, `agentId` (preferred), legacy `agentType`, `model`, `thinking`, `isolation`, and `onRefusal`. Use `agentId: "explore"` for read-only research/discovery stages. `model` accepts the same aliases/full model strings as the UI, `thinking` accepts `off|low|medium|high|xhigh|max` or a numeric index, and `effort` is rejected to avoid ambiguous provider-specific behavior.', "", "```js", 'const scope = agent("Scope this topic", {', diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 9b32f094af..6663a0d124 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -148,7 +148,10 @@ import { WorkflowService, type WorkflowRunStatusChangedEvent, } from "@/node/services/workflows/WorkflowService"; -import { WorkflowTaskServiceAdapter } from "@/node/services/workflows/WorkflowTaskServiceAdapter"; +import { + DEFAULT_WORKFLOW_AGENT_ID, + WorkflowTaskServiceAdapter, +} from "@/node/services/workflows/WorkflowTaskServiceAdapter"; import { resolveWorkflowScript } from "@/node/services/workflows/workflowScriptResolver"; import { isProjectTrusted } from "@/node/utils/projectTrust"; @@ -1782,7 +1785,7 @@ export class AIService extends EventEmitter { parentWorkspaceId: workspaceId, workflowRunId: runId, workflowName, - defaultAgentId: "explore", + defaultAgentId: DEFAULT_WORKFLOW_AGENT_ID, patchToolConfig: { workspaceId, cwd: workspacePath, diff --git a/src/node/services/workflows/WorkflowRunner.test.ts b/src/node/services/workflows/WorkflowRunner.test.ts index a000cb16cf..07f6a50dbe 100644 --- a/src/node/services/workflows/WorkflowRunner.test.ts +++ b/src/node/services/workflows/WorkflowRunner.test.ts @@ -138,6 +138,50 @@ describe("WorkflowRunner", () => { expect(seenSpecs[1]).toMatchObject({ id: "markdown" }); }); + test("new agent API maps agent, model, and thinking options", async () => { + using tmp = new DisposableTempDir("workflow-runner-agent-options"); + const store = new WorkflowRunStore({ + sessionDir: tmp.path, + staleLeaseMs: WORKFLOW_RUNNER_TEST_STALE_LEASE_MS, + }); + await store.createRun({ + id: "wfr_agent_options", + workspaceId: "workspace-1", + workflow: definition, + source: `export default function workflow({ agent }) { + const result = agent("Verify claim", { + id: "verify", + agentType: "exec", + model: "fable", + thinking: "high", + }); + return { reportMarkdown: result }; +} +`, + args: {}, + now: "2026-05-29T00:00:00.000Z", + }); + const seenSpecs: WorkflowAgentSpec[] = []; + const runner = createRunner(store, { + async runAgent(spec) { + seenSpecs.push(spec); + return { taskId: "task_verify", reportMarkdown: "verified", structuredOutput: {} }; + }, + }); + + await expect(runner.run("wfr_agent_options")).resolves.toEqual({ + reportMarkdown: "verified", + }); + expect(seenSpecs).toEqual([ + expect.objectContaining({ + id: "verify", + agentId: "exec", + modelString: "anthropic:claude-fable-5", + thinkingLevel: "high", + }), + ]); + }); + test("parallel runs agent thunks concurrently and returns ordered schema outputs", async () => { using tmp = new DisposableTempDir("workflow-runner-parallel-api"); const store = new WorkflowRunStore({ diff --git a/src/node/services/workflows/WorkflowRunner.ts b/src/node/services/workflows/WorkflowRunner.ts index d1ddd5b8a1..5dd4b75702 100644 --- a/src/node/services/workflows/WorkflowRunner.ts +++ b/src/node/services/workflows/WorkflowRunner.ts @@ -6,6 +6,8 @@ import type { WorkflowRunEvent, WorkflowStepRecord, } from "@/common/types/workflow"; +import { parseThinkingInput, type ParsedThinkingInput } from "@/common/types/thinking"; +import { normalizeModelInput } from "@/common/utils/ai/normalizeModelInput"; import assert from "@/common/utils/assert"; import { getErrorMessage } from "@/common/utils/errors"; import { @@ -38,6 +40,8 @@ export interface WorkflowAgentSpec { prompt: string; title?: string; agentId?: string; + modelString?: string; + thinkingLevel?: ParsedThinkingInput; isolation?: "fork" | "none"; outputSchema?: unknown; /** Internal marker for new `agent(prompt, { id })` prose-only steps. */ @@ -2302,6 +2306,39 @@ function parseStartedWorkflowAgentHandle(rawHandle: unknown): StartedWorkflowAge return { handleId }; } +function parseWorkflowAgentModelString(rawValue: unknown): string | undefined { + if (rawValue === undefined) { + return undefined; + } + assert( + typeof rawValue === "string" && rawValue.trim().length > 0, + "agent model must be a non-empty string" + ); + const normalized = normalizeModelInput(rawValue); + assert( + normalized.model != null, + `agent model "${rawValue}" must be a known alias or provider:model string` + ); + return normalized.model; +} + +function parseWorkflowAgentThinkingLevel(rawValue: unknown): ParsedThinkingInput | undefined { + if (rawValue === undefined) { + return undefined; + } + const value = typeof rawValue === "number" ? String(rawValue) : rawValue; + assert( + typeof value === "string" && value.trim().length > 0, + "agent thinking must be a non-empty string or numeric index" + ); + const parsed = parseThinkingInput(value); + assert( + parsed !== undefined, + "agent thinking must be one of off, low, medium, high, xhigh, max, or a numeric index" + ); + return parsed; +} + function parseWorkflowAgentSpec( rawSpec: unknown, options: { allowMissingOutputSchema: boolean } @@ -2323,6 +2360,14 @@ function parseWorkflowAgentSpec( if (typeof spec.agentId === "string" && spec.agentId.length > 0) { parsed.agentId = spec.agentId; } + const modelString = parseWorkflowAgentModelString(spec.modelString); + if (modelString !== undefined) { + parsed.modelString = modelString; + } + const thinkingLevel = parseWorkflowAgentThinkingLevel(spec.thinkingLevel); + if (thinkingLevel !== undefined) { + parsed.thinkingLevel = thinkingLevel; + } if (spec.isolation !== undefined) { assert( spec.isolation === "fork" || spec.isolation === "none", @@ -2540,13 +2585,18 @@ function __muxAgent(prompt, options) { if (typeof options.id !== "string" || options.id.length === 0) { throw new Error("agent replay boundary requires a stable id"); } - if (Object.prototype.hasOwnProperty.call(options, "model")) { - throw new Error("agent options.model is not supported yet"); - } if (Object.prototype.hasOwnProperty.call(options, "effort")) { - throw new Error("agent options.effort is not supported yet"); + throw new Error("agent options.effort is not supported; use options.thinking"); } const spec = { ...options, prompt }; + if (Object.prototype.hasOwnProperty.call(spec, "model")) { + spec.modelString = spec.model; + delete spec.model; + } + if (Object.prototype.hasOwnProperty.call(spec, "thinking")) { + spec.thinkingLevel = spec.thinking; + delete spec.thinking; + } if (Object.prototype.hasOwnProperty.call(spec, "schema")) { spec.outputSchema = spec.schema; delete spec.schema; diff --git a/src/node/services/workflows/WorkflowTaskServiceAdapter.test.ts b/src/node/services/workflows/WorkflowTaskServiceAdapter.test.ts index 433e5fcfde..dba830cef9 100644 --- a/src/node/services/workflows/WorkflowTaskServiceAdapter.test.ts +++ b/src/node/services/workflows/WorkflowTaskServiceAdapter.test.ts @@ -7,7 +7,10 @@ import { Ok } from "@/common/types/result"; import type { TaskApplyGitPatchConfiguration } from "@/node/services/tools/task_apply_git_patch"; import { DisposableTempDir } from "@/node/services/tempDir"; import type { TaskCreateResult } from "@/node/services/taskService"; -import { WorkflowTaskServiceAdapter } from "./WorkflowTaskServiceAdapter"; +import { + DEFAULT_WORKFLOW_AGENT_ID, + WorkflowTaskServiceAdapter, +} from "./WorkflowTaskServiceAdapter"; describe("WorkflowTaskServiceAdapter", () => { test("spawns a workflow child task with workflow metadata and returns its report", async () => { @@ -23,7 +26,7 @@ describe("WorkflowTaskServiceAdapter", () => { taskService: { create, waitForAgentReport }, parentWorkspaceId: "parent_1", workflowRunId: "wfr_123", - defaultAgentId: "explore", + defaultAgentId: DEFAULT_WORKFLOW_AGENT_ID, }); const result = await adapter.runAgent({ @@ -36,7 +39,7 @@ describe("WorkflowTaskServiceAdapter", () => { expect(create).toHaveBeenCalledWith({ parentWorkspaceId: "parent_1", kind: "agent", - agentId: "explore", + agentId: DEFAULT_WORKFLOW_AGENT_ID, prompt: "Extract claims", title: "Claim extractor", workflowTask: { @@ -175,6 +178,37 @@ describe("WorkflowTaskServiceAdapter", () => { }); }); + test("per-step model and thinking override workflow defaults", async () => { + let createArgs: unknown; + const create = mock(async (args: unknown) => { + createArgs = args; + return Ok({ taskId: "task_1", kind: "agent" as const, status: "running" as const }); + }); + const waitForAgentReport = mock(async () => ({ reportMarkdown: "child report" })); + const adapter = new WorkflowTaskServiceAdapter({ + taskService: { create, waitForAgentReport }, + parentWorkspaceId: "parent_1", + workflowRunId: "wfr_123", + defaultAgentId: DEFAULT_WORKFLOW_AGENT_ID, + modelString: "opus", + thinkingLevel: "medium", + }); + + await adapter.runAgent({ + id: "verify", + agentId: "exec", + prompt: "Verify claim", + modelString: "anthropic:claude-fable-5", + thinkingLevel: "high", + }); + + expect(createArgs).toMatchObject({ + agentId: "exec", + modelString: "anthropic:claude-fable-5", + thinkingLevel: "high", + }); + }); + test("passes workflow experiments to Explore workflow task creation", async () => { let createArgs: unknown; const create = mock(async (args: unknown) => { diff --git a/src/node/services/workflows/WorkflowTaskServiceAdapter.ts b/src/node/services/workflows/WorkflowTaskServiceAdapter.ts index fb4ca251e4..97797a4ba6 100644 --- a/src/node/services/workflows/WorkflowTaskServiceAdapter.ts +++ b/src/node/services/workflows/WorkflowTaskServiceAdapter.ts @@ -26,6 +26,8 @@ import { type TaskApplyGitPatchResult, } from "@/node/services/tools/task_apply_git_patch"; +export const DEFAULT_WORKFLOW_AGENT_ID = "exec"; + interface WorkflowTaskExperiments { programmaticToolCalling?: boolean; programmaticToolCallingExclusive?: boolean; @@ -53,6 +55,7 @@ interface WorkflowTaskServiceLike { modelString?: string; thinkingLevel?: ParsedThinkingInput; isolation?: "fork" | "none"; + onRefusal?: "fail" | "fallback"; }): Promise<{ success: true; data: TaskCreateResult } | { success: false; error: string }>; createMany?( args: Array<{ @@ -71,6 +74,7 @@ interface WorkflowTaskServiceLike { modelString?: string; thinkingLevel?: ParsedThinkingInput; isolation?: "fork" | "none"; + onRefusal?: "fail" | "fallback"; }>, options?: { onTaskReserved?: (index: number, result: TaskCreateResult) => Promise | void; @@ -398,6 +402,8 @@ export class WorkflowTaskServiceAdapter implements WorkflowTaskAdapter { const agentId = spec.agentId ?? this.defaultAgentId; const experiments = this.getExperimentsForAgent(agentId); + const modelString = spec.modelString ?? this.modelString; + const thinkingLevel = spec.thinkingLevel ?? this.thinkingLevel; return { parentWorkspaceId: this.parentWorkspaceId, kind: "agent", @@ -407,8 +413,8 @@ export class WorkflowTaskServiceAdapter implements WorkflowTaskAdapter { workflowTask, ...(spec.isolation !== undefined ? { isolation: spec.isolation } : {}), ...(experiments !== undefined ? { experiments } : {}), - ...(this.modelString !== undefined ? { modelString: this.modelString } : {}), - ...(this.thinkingLevel !== undefined ? { thinkingLevel: this.thinkingLevel } : {}), + ...(modelString !== undefined ? { modelString } : {}), + ...(thinkingLevel !== undefined ? { thinkingLevel } : {}), // Refusal policy must survive both the single-step and parallel // (createAgentTasks) paths: a verifier step marked onRefusal: "fail" // must fail honestly instead of silently continuing on a fallback model.