diff --git a/docs/guide/workflow.md b/docs/guide/workflow.md index b7e4aa19b..51939dd35 100644 --- a/docs/guide/workflow.md +++ b/docs/guide/workflow.md @@ -24,6 +24,15 @@ steps: `schemaVersion` must be `v1alpha1`. `name` must match the file name. `description` is required. Each step has a unique `id` and exactly one of `command`, `prompt`, `prompt_file`, or `workflow`. `agent` is optional on prompt steps. `output` uses the existing shorthand: `string`, `number`, `boolean`, a literal union, an object, or an array with one item type. TeamAI validates successful command stdout or a prompt tool's `output` against it. +A prompt step can set `model`: an alias from `models/aliases.yaml` or a model name, without spaces or templates. `defaults: { model: fast }` at the top of a file sets one for its prompt steps. A prompt step runs on the first of these that is set: + +1. the step's `model`; +2. the model its agent's definition sets for the tool; +3. `defaults.model`; +4. the tool's default model. + +A step's `model` replaces the agent's model and effort, and keeps its other settings. A model name that is not an alias runs at the tool's default effort. A workflow file that another runs uses its own `defaults.model`, else the one in effect for the step that runs it. An inline workflow uses the enclosing one. When the alias has no model for the step's tool, or the member's `~/.teamai/models/aliases.yaml` turns it off, the step fails with `model_unavailable` before the tool starts. On OpenCode 1, so does a step whose `model` is its agent's own model with no effort while the agent file sets `variant` and a `mode` other than `subagent`, since OpenCode 1 would apply that variant. OpenCode 2 does not take the model or variant from the agent file, and OpenCode 1 does not start a subagent with `opencode run --agent`, so the driver passes the definition's own model and variant on the command line. For an agent file with `mode: subagent`, which pull writes by default, OpenCode 1 gets the agent's instructions in the prompt, and the driver warns about the file settings that then do not apply. A lease carries `model` and `defaultModel` as written, and the driver resolves them. + Every step after the first lists the steps it waits for in `needs`. `needs: []` starts another branch with no dependency. Each `next` leases the first step in the file whose needs are done. `--driver local` runs one step at a time unless `workflows.max_parallel` is above 1. A step is skipped when a step it needs failed, or when every step it needs was skipped. A skipped step never runs. A failure does not stop other branches. When nothing is left to run, the run ends `failed` if any step failed, otherwise `succeeded`. A missing `needs`, a `needs` id that is not a step, or a cycle makes the file invalid. A missing `needs` also leaves the workflow with nothing to run, even when an older version was valid. A step can carry an `if` expression. When it is false, the step is skipped, and that does not fail the run. An expression reads `steps..outcome` (`success`, `failed`, or `skipped`) and `steps..outputs.`, only for steps it needs, directly or through their needs. It has `==`, `!=`, `<`, `<=`, `>`, `>=`, `&&`, `||`, `!`, parentheses, and the literals `'text'`, numbers, `true`, `false`, `null`, and `[]`. A path can index an array, as in `steps.review.outputs.files[0]`. The functions `length`, `any`, `all`, and `filter` read arrays. A missing value reads as `null`. `false`, `null`, `0`, and `''` are false; everything else is true. `==` is strict and compares whole values: `'1' == 1` and `'Build' == 'build'` are false. @@ -71,7 +80,7 @@ A step can run another workflow from `workflows/` with `workflow: ` and pa task: "{{ steps.plan.outputs.task }}" ``` -Every string in `with`, at any depth, is a template. A string that is exactly one template keeps its JSON type, so `task` gets the object itself; any other string is text. When the step is ready, the values are checked against the child's `inputs`, and a mismatch fails the step with `invalid_inputs`. The child sees only its own inputs and steps. Its steps run in place of the step and are leased by their path, such as `check.classify`. The step takes the child's outcome. An `end` inside the child finishes only the child, and the parent reads it as `steps..end`. A child returns values with `outputs`, each a path to one of its steps, such as `outputs: { verdict: steps.classify.outputs.verdict }`, and the parent reads `steps..outputs.verdict`. A step that runs a workflow cannot have `output`, `agent`, or `env`. +Every string in `with`, at any depth, is a template. A string that is exactly one template keeps its JSON type, so `task` gets the object itself; any other string is text. When the step is ready, the values are checked against the child's `inputs`, and a mismatch fails the step with `invalid_inputs`. The child sees only its own inputs and steps. Its steps run in place of the step and are leased by their path, such as `check.classify`. The step takes the child's outcome. An `end` inside the child finishes only the child, and the parent reads it as `steps..end`. A child returns values with `outputs`, each a path to one of its steps, such as `outputs: { verdict: steps.classify.outputs.verdict }`, and the parent reads `steps..outputs.verdict`. A step that runs a workflow cannot have `output`, `agent`, `model`, or `env`. A `prompt` or `prompt_file` step can have `with` too, so several workflows can share one prompt file. Its values follow the same rules, and the prompt reads them as `{{ with. }}`, such as `Fix {{ with.task }}`. `{{ with. }}` is valid only in the prompt of a step whose `with` has that name. A command step cannot have `with`; it uses `env`. @@ -130,7 +139,7 @@ With [usage reporting](reporting.md) on, every run publishes a privacy-preservin A record carries only: the run id, workflow name, phase (`start` or `end`), driver kind, status, the end the run reached (when it reached one), the failure code of a run that failed without reaching an end, when it is a code teamai sets, such as `loop_exhausted` for a loop that ran out or `tool_unavailable` when the local driver cannot find the tool (never its message), start and end times, duration, per-step entries (step path such as `check.classify`, agent, tool, model, outcome, tokens — a mixed-agent run records each step's own fields; `tool` is kept only when it is one of the local driver's tools, and `model` is the name the driver reported, kept only when it is shaped like a model name), summary step counts (succeeded, failed, skipped, cancelled), the cumulative settled-attempt count, and the publishing username. Every entry has its `pass` number, and a step that a loop ran more than once has one entry per pass. An item of a `for_each` counts the times that item ran, so its `pass` can be lower than its step's when an earlier pass had fewer items. A cancelled step's entry has the outcome `cancelled`, and a skipped step's has none. The counts cover each step once, as the run ended. Steps of a sub-workflow that never started are not counted. Prompts, command text, input values, `env` values, outputs, diffs, secret values, local workspace paths, session ids, and hostnames have no field to land in — the record is built from an allowlist, not filtered from the run state. -An entry's `agent` is what that pass ran as, as observed. It holds the step's `agent:` definition and how the tool got it (`main` when the tool reads its own agent file, `prompt` when the instructions are prepended), the tool and its version, the model the launch asked for and the one the tool reported, the team commit the checkout last pulled and whether a delivered file was edited since, the project commit, the TeamAI version, and the driver kind. A value nobody observed is left out. `fingerprint` hashes the definition, tool, model, effort and team commit, so two passes with the same fingerprint ran with the same recorded parts. It is left out when the model or the team commit is unknown, or a delivered file was edited. A pass nothing observed, and a record from an older TeamAI, carry only the definition's name. While a step runs, the tool it starts, and any session that tool starts in the same checkout, skips the session-start pull when the checkout's last pull delivered completely to that tool, so the step keeps the team commit it records. +An entry's `agent` is what that pass ran as, as observed. It holds the step's `agent:` definition and how the tool got it (`main` when the tool reads its own agent file, `prompt` when the instructions are prepended), the tool and its version, the model and effort the launch asked for and the model the tool reported, the team commit the checkout last pulled and whether a delivered file was edited since, the project commit, the TeamAI version, and the driver kind. A value nobody observed is left out. `fingerprint` hashes the definition, tool, model, effort and team commit, so two passes with the same fingerprint ran with the same recorded parts. It is left out when the model or the team commit is unknown, or a delivered file was edited. A pass nothing observed, and a record from an older TeamAI, carry only the definition's name. While a step runs, the tool it starts, and any session that tool starts in the same checkout, skips the session-start pull when the checkout's last pull delivered completely to that tool, so the step keeps the team commit it records. The end record is published by the `next` that settles the run. A publication that fails — no remote, a busy reports lock, a rejected push — is queued in this scope's data directory and retried on the next `teamai pull`. The `start` and `next` envelopes then carry `record: pending` instead of `record: published`. The run itself is unaffected: a machine that dies after starting a run leaves it visible as started but not ended. diff --git a/docs/guide/zh-CN/workflow.md b/docs/guide/zh-CN/workflow.md index 4b75c116b..bcd9bcdf7 100644 --- a/docs/guide/zh-CN/workflow.md +++ b/docs/guide/zh-CN/workflow.md @@ -24,6 +24,15 @@ steps: `schemaVersion` 必须是 `v1alpha1`。`name` 必须和文件名一致。`description` 必填。每个步骤有唯一的 `id`,并且必须且只能使用 `command`、`prompt`、`prompt_file`、`workflow` 之一。提示词步骤可选填 `agent`。`output` 沿用已有简写:`string`、`number`、`boolean`、字面量联合、对象,或只含一种元素类型的数组。TeamAI 会用它校验成功命令的 stdout 或提示词工具返回的 `output`。 +提示词步骤可以设置 `model`:`models/aliases.yaml` 中的别名或模型名,不能含空格或模板。文件顶层的 `defaults: { model: fast }` 为其中的提示词步骤设置模型。提示词步骤使用以下第一个已设置的模型: + +1. 步骤的 `model`; +2. 其 agent 定义为该工具设置的模型; +3. `defaults.model`; +4. 工具的默认模型。 + +步骤的 `model` 替换 agent 的模型和推理强度(effort),保留其他设置。不是别名的模型名使用工具默认的推理强度。被另一个工作流运行的工作流文件使用自己的 `defaults.model`,没有时使用运行它的步骤所在处生效的那个。内联工作流使用外层的。别名没有该步骤工具的模型,或成员的 `~/.teamai/models/aliases.yaml` 关闭了它时,步骤会在工具启动前以 `model_unavailable` 失败。在 OpenCode 1 上,步骤的 `model` 是其 agent 自己的模型且没有推理强度、而 agent 文件设置了 `variant` 且 `mode` 不是 `subagent` 时,也会这样失败,因为 OpenCode 1 会套用那个 variant。OpenCode 2 不从 agent 文件读取模型和 variant,OpenCode 1 的 `opencode run --agent` 也不会启动 subagent,所以驱动在命令行上传入定义自己的模型和 variant。对 `mode: subagent` 的 agent 文件(pull 默认这样写),OpenCode 1 从提示词中获得 agent 的指令,驱动会对此时不生效的文件设置发出警告。租约原样携带 `model` 和 `defaultModel`,由驱动解析。 + 第一个步骤之后的每个步骤都要用 `needs` 列出它等待的步骤。`needs: []` 表示另起一个没有依赖的分支。每次 `next` 租出文件中第一个依赖都已完成的步骤。`--driver local` 一次运行一个步骤,除非 `workflows.max_parallel` 大于 1。某个依赖失败,或所有依赖都被跳过时,该步骤会被跳过。被跳过的步骤不会运行。一个步骤失败不会停止其他分支。没有可运行的步骤时,只要有步骤失败,运行结果就是 `failed`,否则是 `succeeded`。缺少 `needs`、`needs` 中的 id 不是本工作流的步骤,或依赖成环,都会让该文件无效。缺少 `needs` 时,即使之前有有效版本,该工作流也没有可运行版本。 步骤可以带 `if` 表达式。表达式为假时跳过该步骤,这不会让运行失败。表达式可以读取 `steps..outcome`(`success`、`failed` 或 `skipped`)和 `steps..outputs.`,且只能读取该步骤直接或间接依赖的步骤。可用 `==`、`!=`、`<`、`<=`、`>`、`>=`、`&&`、`||`、`!`、括号,以及字面量 `'text'`、数字、`true`、`false`、`null` 和 `[]`。路径可以用下标读取数组元素,例如 `steps.review.outputs.files[0]`。函数 `length`、`any`、`all` 和 `filter` 读取数组。不存在的值读作 `null`。`false`、`null`、`0` 和 `''` 为假,其他值都为真。`==` 是严格比较,并比较整个值:`'1' == 1` 和 `'Build' == 'build'` 都为假。 @@ -71,7 +80,7 @@ steps: task: "{{ steps.plan.outputs.task }}" ``` -`with` 中任意层级的字符串都是模板。恰好只有一个模板的字符串保留其 JSON 类型,所以 `task` 拿到的是对象本身;其他字符串都是文本。步骤就绪时,这些值会按子工作流的 `inputs` 校验,不匹配时该步骤以 `invalid_inputs` 失败。子工作流只能看到自己的输入和步骤。它的步骤代替该步骤运行,并按路径租用,例如 `check.classify`。该步骤的结果就是子工作流的结果。子工作流中的 `end` 只结束子工作流,父工作流通过 `steps..end` 读取它。子工作流用 `outputs` 返回值,每个值是指向它某个步骤的路径,例如 `outputs: { verdict: steps.classify.outputs.verdict }`,父工作流读取 `steps..outputs.verdict`。运行工作流的步骤不能有 `output`、`agent` 或 `env`。 +`with` 中任意层级的字符串都是模板。恰好只有一个模板的字符串保留其 JSON 类型,所以 `task` 拿到的是对象本身;其他字符串都是文本。步骤就绪时,这些值会按子工作流的 `inputs` 校验,不匹配时该步骤以 `invalid_inputs` 失败。子工作流只能看到自己的输入和步骤。它的步骤代替该步骤运行,并按路径租用,例如 `check.classify`。该步骤的结果就是子工作流的结果。子工作流中的 `end` 只结束子工作流,父工作流通过 `steps..end` 读取它。子工作流用 `outputs` 返回值,每个值是指向它某个步骤的路径,例如 `outputs: { verdict: steps.classify.outputs.verdict }`,父工作流读取 `steps..outputs.verdict`。运行工作流的步骤不能有 `output`、`agent`、`model` 或 `env`。 `prompt` 或 `prompt_file` 步骤也可以有 `with`,这样多个工作流可以共用一个提示词文件。这些值遵循同样的规则,提示词用 `{{ with. }}` 读取它们,例如 `Fix {{ with.task }}`。`{{ with. }}` 只在 `with` 中有该名称的步骤的提示词里有效。命令步骤不能有 `with`,应使用 `env`。 @@ -130,7 +139,7 @@ teamai workflow report --json '{"attempt":"","revision":1,"outcom 记录只包含:run id、工作流名、阶段(`start` 或 `end`)、驱动的 kind、状态、运行到达的 end(到达时才有)、未到达 end 就失败的运行的失败代码(仅限 teamai 设定的代码,如循环用完次数时的 `loop_exhausted`,或本地驱动找不到工具时的 `tool_unavailable`,不含失败消息)、开始和结束时间、时长、逐步条目(step 路径如 `check.classify`、agent、tool、model、outcome、tokens——混合 agent 的运行会分别记录每步自己的字段;`tool` 仅在是本地驱动的工具之一时保留,`model` 是驱动报告的名称,仅在形如模型名时保留)、步骤汇总计数(succeeded、failed、skipped、cancelled)、累计已结算 attempt 数,以及发布者用户名。每条条目都带 `pass` 编号,循环运行了多次的步骤每个 pass 各有一条条目。`for_each` 的项按该项自己运行的次数计 `pass`,所以前面某个 pass 的项较少时,项的 `pass` 可能比所属步骤的小。被取消步骤的条目 outcome 为 `cancelled`,被跳过的步骤没有 outcome。汇总计数按运行结束时的状态,每个步骤只计一次。从未开始的子工作流里的步骤不计入。提示词、命令文本、输入值、`env` 值、输出、diff、秘密值、本地工作区路径、会话 id 和主机名没有可落地的字段——记录由白名单构建,而不是从运行状态里过滤。 -条目的 `agent` 是该 pass 实际运行时观察到的组合:步骤的 `agent:` 定义及工具如何拿到它(工具自己读取 agent 文件时为 `main`,指令拼接到提示词前时为 `prompt`)、工具及其版本、启动时请求的模型和工具报告的模型、检出最近一次 pull 的团队提交以及之后是否改过已下发的文件、项目提交、TeamAI 版本和驱动的 kind。没有观察到的值不写。`fingerprint` 对定义、工具、模型、effort 和团队提交做哈希,fingerprint 相同的两个 pass 记录下来的这些部分相同。模型或团队提交未知,或改过已下发的文件时,不写 `fingerprint`。没有任何观察的 pass,以及旧版 TeamAI 写的记录,只带定义的名称。步骤运行期间,如果该检出最近一次 pull 已完整下发到步骤启动的工具,该工具以及它在同一检出中启动的会话都会跳过会话开始时的 pull,步骤因此保持它记录的团队提交。 +条目的 `agent` 是该 pass 实际运行时观察到的组合:步骤的 `agent:` 定义及工具如何拿到它(工具自己读取 agent 文件时为 `main`,指令拼接到提示词前时为 `prompt`)、工具及其版本、启动时请求的模型和 effort、工具报告的模型、检出最近一次 pull 的团队提交以及之后是否改过已下发的文件、项目提交、TeamAI 版本和驱动的 kind。没有观察到的值不写。`fingerprint` 对定义、工具、模型、effort 和团队提交做哈希,fingerprint 相同的两个 pass 记录下来的这些部分相同。模型或团队提交未知,或改过已下发的文件时,不写 `fingerprint`。没有任何观察的 pass,以及旧版 TeamAI 写的记录,只带定义的名称。步骤运行期间,如果该检出最近一次 pull 已完整下发到步骤启动的工具,该工具以及它在同一检出中启动的会话都会跳过会话开始时的 pull,步骤因此保持它记录的团队提交。 结束记录由结算该运行的那次 `next` 发布。发布失败时——没有远端、reports 锁被占用、push 被拒——记录会进入当前 scope 数据目录的本地队列,在下一次 `teamai pull` 时重试。此时 `start`、`next` 的返回会带 `record: pending` 而不是 `record: published`。运行本身不受影响:一台机器在运行开始后崩溃,该运行会以"已开始未结束"的状态保持可见。 diff --git a/src/__tests__/agent-observation.test.ts b/src/__tests__/agent-observation.test.ts index a680b77cd..662d11285 100644 --- a/src/__tests__/agent-observation.test.ts +++ b/src/__tests__/agent-observation.test.ts @@ -195,9 +195,8 @@ describe('observeLaunch (#1176)', () => { expect(await observeLaunch({ config, tool: 'claude', cwd: project, - definition: { stem: 'reviewer', file: agent, applied: 'main' }, + definition: { stem: 'reviewer', file: agent }, })).toEqual({ - definition: { applied: 'main' }, tool: { version: '2.1.301' }, model: { requested: 'claude-opus-4-1', alias: 'opus' }, team: { revision: rev, localChanges: false }, @@ -218,21 +217,21 @@ describe('observeLaunch (#1176)', () => { await recordDelivery(config, { rev: rev.slice(0, 7), targets: ['claude'], files: [agent], agentModels }); fs.writeFileSync(agent, 'model: another\n'); - const observed = await observeLaunch({ config, tool: 'claude', cwd: project, definition: { stem: 'reviewer', file: agent, applied: 'main' } }); + const observed = await observeLaunch({ config, tool: 'claude', cwd: project, definition: { stem: 'reviewer', file: agent } }); expect(observed).not.toHaveProperty('model'); expect(observed).not.toHaveProperty('effort'); }); - it('requests the model the launch passes on argv for a definition applied as prompt', async () => { + it('requests the model the launch passes on argv for a definition', async () => { const { config, project, rev, agent } = checkout(); const codexModels = { reviewer: { codex: { step: 'alias', model: 'gpt-5', alias: 'smart', effort: 'high' } } }; await recordDelivery(config, { rev: rev.slice(0, 7), targets: ['codex'], files: [agent], agentModels: codexModels }); const launch = { config, tool: 'codex', cwd: project }; - expect(await observeLaunch({ ...launch, definition: { stem: 'reviewer', file: agent, applied: 'prompt', model: 'gpt-5' } })) - .toMatchObject({ definition: { applied: 'prompt' }, model: { requested: 'gpt-5', alias: 'smart' } }); - const edited = await observeLaunch({ ...launch, definition: { stem: 'reviewer', file: agent, applied: 'prompt', model: 'gpt-4' } }); + expect(await observeLaunch({ ...launch, definition: { stem: 'reviewer', file: agent, model: 'gpt-5' } })) + .toMatchObject({ model: { requested: 'gpt-5', alias: 'smart' } }); + const edited = await observeLaunch({ ...launch, definition: { stem: 'reviewer', file: agent, model: 'gpt-4' } }); expect(edited.model).toEqual({ requested: 'gpt-4' }); expect(edited).not.toHaveProperty('effort'); }); diff --git a/src/__tests__/e2e/workflow-conformance.test.ts b/src/__tests__/e2e/workflow-conformance.test.ts index d4271c14b..77a850826 100644 --- a/src/__tests__/e2e/workflow-conformance.test.ts +++ b/src/__tests__/e2e/workflow-conformance.test.ts @@ -76,6 +76,7 @@ describe('workflow protocol conformance through the CLI', () => { const value = envelope(result.stdout); expect(value).toMatchObject(operation.expect); if (operation.cancelled) expect('data' in value && value.data.cancelled).toEqual(operation.cancelled.map(attemptOf)); + for (const field of operation.absent ?? []) expect('data' in value ? value.data.lease : undefined).not.toHaveProperty(field); if ('data' in value && value.data.lease) { leases.set(operation.saveAs, value.data.lease as { attempt: string; revision: number }); } diff --git a/src/__tests__/workflow-cache.test.ts b/src/__tests__/workflow-cache.test.ts index 35668e3db..9324f4b0a 100644 --- a/src/__tests__/workflow-cache.test.ts +++ b/src/__tests__/workflow-cache.test.ts @@ -2021,4 +2021,46 @@ steps: ['workflows/build.yaml:15: field "steps[1].if": step "use" if compares steps.grp.end with \'unknown\', which is not an end the inline workflow of step "grp" declares', 'undeclared_end'], ]); }); + + it('reports a model on a command or workflow step with the file and step, and does not cache that version', async () => { + const config = testConfig(); + writeWorkflow(config, 'build', `schemaVersion: v1alpha1 +name: build +description: Build it. +steps: + - id: compile + command: npm run build + - id: check + needs: [compile] + workflow: + steps: + - id: lint + command: npm run lint +`); + expect((await syncWorkflowDefinitions(config)).cached).toEqual(['build']); + writeWorkflow(config, 'build', `schemaVersion: v1alpha1 +name: build +description: Build it. +steps: + - id: compile + command: npm run build + model: strong + - id: check + needs: [compile] + workflow: + steps: + - id: lint + command: npm run lint + model: fast +`); + + const result = await syncWorkflowDefinitions(config); + + expect(result.cached).toEqual([]); + expect(result.invalid.map(formatDiagnostic)).toEqual([ + 'workflows/build.yaml:7: field "steps[0].model": model is only allowed on a prompt or prompt_file step', + 'workflows/build.yaml:14: field "steps[1].model": model is only allowed on a prompt or prompt_file step', + ]); + expect((await loadCachedWorkflow(config, 'build'))?.definition.steps.some((step) => step.model !== undefined)).toBe(false); + }); }); diff --git a/src/__tests__/workflow-conformance.test.ts b/src/__tests__/workflow-conformance.test.ts index 809e090bd..e7f02ae77 100644 --- a/src/__tests__/workflow-conformance.test.ts +++ b/src/__tests__/workflow-conformance.test.ts @@ -65,6 +65,7 @@ describe('workflow protocol conformance in process', () => { }); expect(envelope).toMatchObject(operation.expect); if (operation.cancelled) expect('data' in envelope && envelope.data.cancelled).toEqual(operation.cancelled.map(attemptOf)); + for (const field of operation.absent ?? []) expect('data' in envelope ? envelope.data.lease : undefined).not.toHaveProperty(field); if ('data' in envelope && envelope.data.lease) { leases.set(operation.saveAs, envelope.data.lease as { attempt: string; revision: number }); } diff --git a/src/__tests__/workflow-engine.test.ts b/src/__tests__/workflow-engine.test.ts index 3355fbd7e..2905ef419 100644 --- a/src/__tests__/workflow-engine.test.ts +++ b/src/__tests__/workflow-engine.test.ts @@ -3927,6 +3927,92 @@ ${pair(prompt, ' ')}`, header) }), 'each[0].s', true], }); }); +describe('the model a prompt step lease carries', () => { + type ModelFields = { model?: unknown; defaultModel?: unknown }; + + /** Leases every step in turn, each reported a success, and returns the model fields of each lease by step. */ + async function leasedModels(config: LocalConfig, runId: string): Promise> { + const models: Record = {}; + for (;;) { + const next = await leaseNext(config, runId, driverA); + if ('error' in next) throw new Error(next.error.message); + if (next.data.done) return models; + const lease = next.data.lease as { step: string; attempt: string; revision: number } & ModelFields; + models[lease.step] = { + ...('model' in lease ? { model: lease.model } : {}), + ...('defaultModel' in lease ? { defaultModel: lease.defaultModel } : {}), + }; + const reported = await reportAttempt(config, runId, { attempt: lease.attempt, revision: lease.revision, outcome: 'success', driver: driverA }); + if ('error' in reported) throw new Error(reported.error.message); + } + } + + it('carries the step model and defaults.model as written on prompt steps, and neither on a command step', async () => { + const { config, runId } = await startTree({ build: graph(` - id: plan + prompt: Plan it. + model: strong + - id: compile + needs: [plan] + command: node compile.js + - id: summary + needs: [compile] + prompt: Summarize it. + - id: check + needs: [summary] + for_each: inputs.targets + prompt: Check {{ item }}. +`, 'inputs: { targets: [string] }\ndefaults:\n model: fast\n') }, 'build', { targets: ['a'] }); + + expect(await leasedModels(config, runId)).toEqual({ + plan: { model: 'strong', defaultModel: 'fast' }, + compile: {}, + summary: { defaultModel: 'fast' }, + 'check[0]': { defaultModel: 'fast' }, + }); + }); + + it('gives the steps of an inline workflow the enclosing defaults.model, also on a lease handed back', async () => { + const { config, runId } = await startBuild(graph(` - id: check + workflow: + steps: + - id: classify + prompt: Classify it. +`, 'defaults:\n model: fast\n')); + + const first = await leaseNext(config, runId, driverA); + const again = await leaseNext(config, runId, driverA); + + expect(first).toMatchObject({ data: { lease: { step: 'check.classify', defaultModel: 'fast' } } }); + expect(again).toMatchObject({ data: { lease: { step: 'check.classify', defaultModel: 'fast' } } }); + }); + + it('gives the steps of a workflow file its own defaults.model, or else the one in effect for the step that runs it', async () => { + const { config, runId } = await startTree({ + own: workflow('own', ` - id: classify + prompt: Classify it. +`, 'defaults:\n model: strong\n'), + plain: workflow('plain', ` - id: comment + prompt: Comment on it. + - id: label + needs: [comment] + command: node label.js +`), + build: graph(` - id: first + workflow: own + - id: second + needs: [first] + workflow: plain +`, 'defaults:\n model: fast\n'), + }, 'build'); + + expect(await leasedModels(config, runId)).toEqual({ + 'first.classify': { defaultModel: 'strong' }, + 'second.comment': { defaultModel: 'fast' }, + 'second.label': {}, + }); + }); +}); + describe('the agent a pass ran as (#1176)', () => { const REVIEW = workflow('build', ` - id: review prompt: Review the change. diff --git a/src/__tests__/workflow-local-driver.test.ts b/src/__tests__/workflow-local-driver.test.ts index fea934bb4..7af7ab31e 100644 --- a/src/__tests__/workflow-local-driver.test.ts +++ b/src/__tests__/workflow-local-driver.test.ts @@ -472,7 +472,7 @@ ${agent ? ` agent: ${agent}\n` : ''}`; it('publishes the definition, tool version, team commit and requested model of a Claude pass', async () => { const { config, workspace, runId, rev } = await observed(reviewStep('reviewer')); - await runLocalDriver(config, runId, workspace, { adapter: reports({ model: 'claude-opus-4-1-20250805' }), toolPaths }); + await runLocalDriver(config, runId, workspace, { adapter: reports({ model: 'claude-opus-4-1-20250805', applied: 'main' }), toolPaths }); expect(await entry(config)).toMatchObject({ tool: 'claude', @@ -521,15 +521,44 @@ ${agent ? ` agent: ${agent}\n` : ''}`; }); }); - it('publishes an OpenCode pass without how the definition applied or the model its file sets', async () => { - const { config, workspace, runId } = await observed(reviewStep('reviewer'), { tool: 'opencode' }); + it('publishes an OpenCode pass with the handoff its launch used and the model and variant of its file it passed', async () => { + const opencodeToolPaths = { opencode: { agents: '.opencode/agents' } } as TeamaiConfig['toolPaths']; + const agentModels = { reviewer: { opencode: { step: 'alias', model: 'anthropic/claude-sonnet-4-5', alias: 'smart' } } }; + const viaPrompt = await observed(reviewStep('reviewer'), { tool: 'opencode', record: { agentModels } }); + const viaAgent = await observed(reviewStep('reviewer'), { tool: 'opencode', record: { agentModels } }); - await runLocalDriver(config, runId, workspace, { adapter: reports(), toolPaths: { opencode: { agents: '.opencode/agents' } } as TeamaiConfig['toolPaths'] }); + await runLocalDriver(viaPrompt.config, viaPrompt.runId, viaPrompt.workspace, { adapter: reports({ applied: 'prompt' }), toolPaths: opencodeToolPaths }); + await runLocalDriver(viaAgent.config, viaAgent.runId, viaAgent.workspace, { adapter: reports({ applied: 'main' }), toolPaths: opencodeToolPaths }); - const published = await entry(config); - expect(published?.agent).toMatchObject({ v: 1, definition: { name: 'reviewer' }, tool: { name: 'opencode' } }); - expect(published?.agent).not.toHaveProperty('definition.applied'); - expect(published?.agent).not.toHaveProperty('model'); + const requested = { model: { requested: 'anthropic/claude-sonnet-4-5', alias: 'smart' }, effort: { requested: 'high' } }; + expect((await entry(viaPrompt.config))?.agent).toMatchObject({ v: 1, definition: { name: 'reviewer', applied: 'prompt' }, tool: { name: 'opencode' }, ...requested }); + expect((await entry(viaAgent.config))?.agent).toMatchObject({ definition: { name: 'reviewer', applied: 'main' }, ...requested }); + }); + + it('names the alias of defaults.model only for a step it chose the model of', async () => { + const yaml = `schemaVersion: v1alpha1 +name: build +description: Review the project. +defaults: + model: strong +steps: + - id: review + prompt: Review this project. + agent: reviewer + - id: plan + needs: [review] + prompt: Plan it. +`; + const { config, workspace, runId } = await observed(yaml); + fs.mkdirSync(path.join(config.repo.localPath, 'models'), { recursive: true }); + fs.writeFileSync(path.join(config.repo.localPath, 'models', 'aliases.yaml'), 'aliases:\n strong:\n claude: claude-opus-4-1\n'); + + await runLocalDriver(config, runId, workspace, { adapter: reports(), toolPaths }); + + const [review, plan] = (await endRecord(config))?.steps ?? []; + expect(review?.agent).toMatchObject({ model: { requested: 'claude-opus-4-1' } }); + expect(review?.agent).not.toHaveProperty('model.alias'); + expect(plan?.agent).toMatchObject({ model: { requested: 'claude-opus-4-1', alias: 'strong' } }); }); it('has no definition for a step without agent:', async () => { @@ -1018,3 +1047,346 @@ steps: }); }); }); + +describe('the model a prompt step runs on', () => { + type FakeTool = 'claude' | 'codex' | 'opencode'; + + const AGENT_FILES: Record = { + claude: { dir: '.claude/agents', ext: '.md' }, + codex: { dir: '.codex/agents', ext: '.toml' }, + opencode: { dir: '.opencode/agents', ext: '.md' }, + }; + + /** What each tool prints in its JSON mode, so the adapter reads stdout as it would from the real tool. */ + const STDOUT: Record = { + claude: '{"type":"system","subtype":"init","model":"claude-opus-4-1-20250805"}\n{"type":"result","result":"done","session_id":"session-1"}', + codex: '{"type":"thread.started","thread_id":"thread-1"}', + opencode: '{"type":"step_finish","sessionID":"session-1"}', + }; + + const TEAM_ALIASES = `aliases: + strong: + claude: { model: opus, effort: high } + codex: { model: gpt-6-sol, effort: high } + opencode: { model: anthropic/claude-opus, effort: high } + fast: + claude: haiku + codex: gpt-6-mini +`; + + afterEach(() => { + fs.rmSync(path.join(os.homedir(), '.teamai', 'models'), { recursive: true, force: true }); + }); + + /** + * Runs `yaml` on `tool` through the real adapter, with a fake executable that appends its arguments as one JSON + * line to a file and writes a result, and answers `--version` with `version`. Like OpenCode, it first reads stdin + * to its end. Returns every argument list the tool was started with, in order. + */ + async function runOn( + tool: FakeTool, + yaml: string, + options: { agents?: Record; aliases?: string; localAliases?: string; version?: string } = {}, + ) { + const { config, workspace, runId } = await workflowFixture(yaml); + const root = path.dirname(workspace); + config.workflows = { tools: { default: tool } }; + for (const [name, content] of Object.entries(options.agents ?? {})) { + fs.mkdirSync(path.join(workspace, AGENT_FILES[tool].dir), { recursive: true }); + fs.writeFileSync(path.join(workspace, AGENT_FILES[tool].dir, `${name}${AGENT_FILES[tool].ext}`), content); + } + fs.mkdirSync(path.join(config.repo.localPath, 'models'), { recursive: true }); + fs.writeFileSync(path.join(config.repo.localPath, 'models', 'aliases.yaml'), options.aliases ?? TEAM_ALIASES); + if (options.localAliases !== undefined) { + fs.mkdirSync(path.join(os.homedir(), '.teamai', 'models'), { recursive: true }); + fs.writeFileSync(path.join(os.homedir(), '.teamai', 'models', 'aliases.yaml'), options.localAliases); + } + const argvFile = path.join(root, 'argv.jsonl'); + const executable = path.join(root, `fake-${tool}`); + fs.writeFileSync(executable, `#!/usr/bin/env node +const fs = require('node:fs'); +if (process.argv[2] === '--version') { + process.stdout.write(${JSON.stringify(options.version ?? '1.18.23')} + '\\n'); + process.exit(0); +} +fs.readFileSync(0); +fs.appendFileSync(${JSON.stringify(argvFile)}, JSON.stringify(process.argv.slice(2)) + '\\n'); +fs.writeFileSync(process.env.TEAMAI_WORKFLOW_RESULT, '{"output":{"ok":true}}'); +process.stdout.write(${JSON.stringify(STDOUT[tool])} + '\\n'); +`, { mode: 0o755 }); + const done = await runLocalDriver(config, runId, workspace, { + adapter: createLocalToolAdapter(undefined, () => executable), + toolPaths: Object.fromEntries(Object.entries(AGENT_FILES).map(([name, { dir }]) => [name, { agents: dir }])) as TeamaiConfig['toolPaths'], + }); + const argv = fs.existsSync(argvFile) + ? fs.readFileSync(argvFile, 'utf8').trim().split('\n').map((line) => JSON.parse(line) as string[]) + : undefined; + const run = await readRun(config, runId); + if (typeof run !== 'object') throw new Error('run was not stored'); + return { done, argv, run, workspace }; + } + + function oneStep(fields: string, header = ''): string { + return `schemaVersion: v1alpha1 +name: build +description: Review the project. +${header}steps: + - id: plan + prompt: Review this project. +${fields}`; + } + + /** The prompt every tool receives for `text`, with the result instruction the adapter adds. */ + const prompt = (text: string) => expect.stringMatching(new RegExp(`^${text.replaceAll('.', '\\.')}\\n\\nWrite the workflow result as JSON to .+result\\.json\\. Use this shape`, 's')); + + it('launches claude with --model and --effort of the alias for claude', async () => { + const { argv, run } = await runOn('claude', oneStep(' model: strong\n')); + + expect(argv).toEqual([['-p', prompt('Review this project.'), '--output-format', 'stream-json', '--verbose', '--model', 'opus', '--effort', 'high']]); + expect(run.steps[0]).toMatchObject({ + status: 'succeeded', + model: 'claude-opus-4-1-20250805', + agent: { model: { requested: 'opus', alias: 'strong', reported: 'claude-opus-4-1-20250805' }, effort: { requested: 'high' } }, + }); + }); + + it('launches codex with --model of the alias for codex, and -c model_reasoning_effort only when the alias sets an effort', async () => { + const strong = await runOn('codex', oneStep(' model: strong\n')); + const fast = await runOn('codex', oneStep(' model: fast\n')); + + expect(strong.argv).toEqual([['exec', '--json', '--model', 'gpt-6-sol', '-c', 'model_reasoning_effort=high', prompt('Review this project.')]]); + expect(fast.argv).toEqual([['exec', '--json', '--model', 'gpt-6-mini', prompt('Review this project.')]]); + }); + + it('runs an agent with a model, an effort and allowed tools on the step model and effort, and keeps its other settings', async () => { + const reviewer = `--- +name: reviewer +description: Reviews changes. +model: haiku +effort: low +tools: Read, Grep +--- +Follow the review checklist. +`; + const claude = await runOn('claude', oneStep(' agent: reviewer\n model: strong\n'), { agents: { reviewer } }); + + expect(claude.argv).toEqual([['-p', prompt('Review this project.'), '--output-format', 'stream-json', '--verbose', '--agent', 'reviewer', '--model', 'opus', '--effort', 'high']]); + expect(fs.readFileSync(path.join(claude.workspace, '.claude', 'agents', 'reviewer.md'), 'utf8')).toBe(reviewer); + expect(claude.run.steps[0]).toMatchObject({ + status: 'succeeded', + model: 'claude-opus-4-1-20250805', + agent: { definition: { applied: 'main' }, model: { requested: 'opus', alias: 'strong' }, effort: { requested: 'high' } }, + }); + + const codex = await runOn('codex', oneStep(' agent: reviewer\n model: strong\n'), { agents: { reviewer: `name = "reviewer" +description = "Reviews changes." +developer_instructions = "Follow the review checklist." +model = "gpt-5.4" +model_reasoning_effort = "low" +` } }); + + expect(codex.argv).toEqual([['exec', '--json', '--model', 'gpt-6-sol', '-c', 'model_reasoning_effort=high', prompt('Follow the review checklist.\n\nReview this project.')]]); + expect(codex.argv?.flat()).not.toContain('gpt-5.4'); + }); + + it('passes a model that is not an alias unchanged, at the tool default effort', async () => { + const claude = await runOn('claude', oneStep(' model: claude-opus-4.1\n')); + const opencode = await runOn('opencode', oneStep(' model: anthropic/claude-sonnet\n')); + + expect(claude.argv).toEqual([['-p', prompt('Review this project.'), '--output-format', 'stream-json', '--verbose', '--model', 'claude-opus-4.1']]); + expect(opencode.argv).toEqual([['run', '--format', 'json', prompt('Review this project.'), '--model', 'anthropic/claude-sonnet']]); + }); + + it('fails an alias without a model for the tool with model_unavailable, and does not start the tool', async () => { + const step = await runOn('codex', oneStep(' model: strong\n'), { aliases: 'aliases:\n strong:\n claude: opus\n' }); + const defaults = await runOn('codex', `schemaVersion: v1alpha1 +name: build +description: Summarize the review. +defaults: + model: fast +steps: + - id: summary + prompt: Summarize the review. +`, { aliases: 'aliases:\n fast:\n claude: haiku\n' }); + + expect(step.argv).toBeUndefined(); + expect(step.run.steps[0]).toMatchObject({ + status: 'failed', + tool: 'codex', + failure: { + code: 'model_unavailable', + message: 'Step "plan" asks for model alias "strong", which has no model for codex in models/aliases.yaml. Add one or remove model from the step.', + }, + }); + expect(defaults.argv).toBeUndefined(); + expect(defaults.run.steps[0]).toMatchObject({ + status: 'failed', + failure: { + code: 'model_unavailable', + message: 'Step "summary" gets model alias "fast" from defaults, which has no model for codex in models/aliases.yaml. Add one, or set model on the step.', + }, + }); + }); + + it('fails an alias the member turns off for the tool in their local aliases file', async () => { + const { argv, run } = await runOn('claude', oneStep(' model: strong\n'), { localAliases: 'aliases:\n strong:\n claude: ~\n' }); + + expect(argv).toBeUndefined(); + const local = path.join(os.homedir(), '.teamai', 'models', 'aliases.yaml'); + expect(run.steps[0]).toMatchObject({ + status: 'failed', + failure: { + code: 'model_unavailable', + message: `Step "plan" asks for model alias "strong", which ${local} turns off for claude. Remove that entry or remove model from the step.`, + }, + }); + }); + + it('fails a step model while the aliases cannot be read, naming a value it cannot tell to be an alias as a model', async () => { + const reserved = await runOn('claude', oneStep(' model: strong\n'), { aliases: 'aliases: [\n' }); + const other = await runOn('claude', oneStep(' model: claude-opus-4.1\n'), { aliases: 'aliases: [\n' }); + + expect(reserved.argv).toBeUndefined(); + expect(reserved.run.steps[0]).toMatchObject({ + failure: { code: 'model_unavailable', message: expect.stringMatching(/^Step "plan" asks for model alias "strong", which cannot be resolved for claude: Invalid model aliases YAML at models\/aliases\.yaml: .+\. Fix that, or remove model from the step\.$/) }, + }); + expect(other.argv).toBeUndefined(); + expect(other.run.steps[0]).toMatchObject({ + failure: { code: 'model_unavailable', message: expect.stringMatching(/^Step "plan" asks for model "claude-opus-4\.1", which cannot be resolved for claude: /) }, + }); + }); + + it('launches each tool as before when neither the step nor the workflow sets a model', async () => { + const claude = await runOn('claude', oneStep(' agent: reviewer\n'), { agents: { reviewer: `--- +name: reviewer +description: Reviews changes. +model: haiku +--- +Follow the review checklist. +` } }); + const codex = await runOn('codex', oneStep(' agent: reviewer\n'), { agents: { reviewer: `name = "reviewer" +description = "Reviews changes." +developer_instructions = "Follow the review checklist." +model = "gpt-5.4" +` } }); + const opencode = await runOn('opencode', oneStep('')); + + expect(claude.argv).toEqual([['-p', prompt('Review this project.'), '--output-format', 'stream-json', '--verbose', '--agent', 'reviewer']]); + expect(codex.argv).toEqual([['exec', '--json', '--model', 'gpt-5.4', prompt('Follow the review checklist.\n\nReview this project.')]]); + expect(opencode.argv).toEqual([['run', '--format', 'json', prompt('Review this project.')]]); + }); + + it('runs defaults.model where neither the step nor its definition sets a model, and leaves the others to theirs', async () => { + const { argv } = await runOn('claude', `schemaVersion: v1alpha1 +name: build +description: Plan, review, and summarize. +defaults: + model: fast +steps: + - id: plain + prompt: Plan it. + - id: unpinned + needs: [plain] + agent: helper + prompt: Help with it. + - id: pinned + needs: [unpinned] + agent: reviewer + prompt: Review it. + - id: own + needs: [pinned] + prompt: Summarize it. + model: strong +`, { agents: { + helper: '---\nname: helper\ndescription: Helps.\n---\nHelp out.\n', + reviewer: '---\nname: reviewer\ndescription: Reviews changes.\nmodel: sonnet\n---\nFollow the review checklist.\n', + } }); + + expect(argv).toEqual([ + ['-p', prompt('Plan it.'), '--output-format', 'stream-json', '--verbose', '--model', 'haiku'], + ['-p', prompt('Help with it.'), '--output-format', 'stream-json', '--verbose', '--agent', 'helper', '--model', 'haiku'], + ['-p', prompt('Review it.'), '--output-format', 'stream-json', '--verbose', '--agent', 'reviewer'], + ['-p', prompt('Summarize it.'), '--output-format', 'stream-json', '--verbose', '--model', 'opus', '--effort', 'high'], + ]); + }); + + it('passes --variant for an OpenCode agent, and fails an OpenCode 1 step on its primary agent\'s own model with no effort when the agent sets a variant', async () => { + const reviewer = '---\ndescription: Reviews changes.\nmodel: anthropic/claude-sonnet\nvariant: low\n---\nFollow the review checklist.\n'; + const strong = await runOn('opencode', oneStep(' agent: reviewer\n model: strong\n'), { agents: { reviewer } }); + const other = await runOn('opencode', oneStep(' agent: reviewer\n model: anthropic/claude-haiku\n'), { agents: { reviewer } }); + const none = await runOn('opencode', oneStep(' agent: reviewer\n'), { agents: { reviewer } }); + const defaults = await runOn('opencode', oneStep(' agent: helper\n', 'defaults:\n model: anthropic/claude-haiku\n'), { + agents: { helper: '---\ndescription: Helps.\nvariant: low\n---\nHelp out.\n' }, + }); + const own = await runOn('opencode', oneStep(' agent: reviewer\n model: anthropic/claude-sonnet\n'), { agents: { reviewer } }); + const ownOn2 = await runOn('opencode', oneStep(' agent: reviewer\n model: anthropic/claude-sonnet\n'), { + agents: { reviewer }, version: 'opencode v2.0.30', + }); + const subagent = await runOn('opencode', oneStep(' agent: reviewer\n model: anthropic/claude-sonnet\n'), { + agents: { reviewer: reviewer.replace('variant: low', 'mode: subagent\nvariant: low') }, + }); + + expect(strong.argv).toEqual([[ + 'run', '--format', 'json', prompt('Review this project.'), '--agent', 'reviewer', '--model', 'anthropic/claude-opus', '--variant', 'high', + ]]); + expect(other.argv).toEqual([['run', '--format', 'json', prompt('Review this project.'), '--agent', 'reviewer', '--model', 'anthropic/claude-haiku']]); + expect(none.argv).toEqual([[ + 'run', '--format', 'json', prompt('Review this project.'), '--agent', 'reviewer', '--model', 'anthropic/claude-sonnet', '--variant', 'low', + ]]); + expect(defaults.argv).toEqual([['run', '--format', 'json', prompt('Review this project.'), '--agent', 'helper', '--model', 'anthropic/claude-haiku']]); + expect(ownOn2.argv).toEqual([['run', '--format', 'json', prompt('Review this project.'), '--agent', 'reviewer', '--model', 'anthropic/claude-sonnet']]); + expect(subagent.argv).toEqual([['run', '--format', 'json', prompt('Follow the review checklist.\n\nReview this project.'), '--model', 'anthropic/claude-sonnet']]); + expect(own.argv).toBeUndefined(); + expect(own.run.steps[0]).toMatchObject({ + status: 'failed', + failure: { + code: 'model_unavailable', + message: 'Agent "reviewer" runs on its own model "anthropic/claude-sonnet" with no effort, and opencode 1 applies the agent\'s variant "low" ' + + 'to that model. Remove model from the step to run at that variant, or set it to an alias with an effort for opencode.', + }, + }); + }); + + it('runs an OpenCode definition on its model and variant, as --agent on OpenCode 2 and from the prompt for a subagent on OpenCode 1', async () => { + const reviewer = '---\ndescription: Reviews changes.\nmode: subagent\nmodel: anthropic/claude-sonnet\nvariant: low\npermission:\n edit: deny\n---\n' + + 'Follow the review checklist.\n'; + const warn = vi.spyOn(log, 'warn').mockImplementation(() => undefined); + const on2 = await runOn('opencode', oneStep(' agent: reviewer\n'), { agents: { reviewer }, version: 'opencode v2.0.30' }); + expect(warn).not.toHaveBeenCalled(); + const on1 = await runOn('opencode', oneStep(' agent: reviewer\n'), { agents: { reviewer } }); + + expect(on2.argv).toEqual([['run', '--format', 'json', prompt('Review this project.'), '--agent', 'reviewer', '--model', 'anthropic/claude-sonnet#low']]); + expect(on1.argv).toEqual([[ + 'run', '--format', 'json', prompt('Follow the review checklist.\n\nReview this project.'), '--model', 'anthropic/claude-sonnet', '--variant', 'low', + ]]); + const requested = { model: { requested: 'anthropic/claude-sonnet' }, effort: { requested: 'low' } }; + expect(on2.run.steps[0]).toMatchObject({ agent: { definition: { applied: 'main' }, ...requested } }); + expect(on1.run.steps[0]).toMatchObject({ agent: { definition: { applied: 'prompt' }, ...requested } }); + expect(warn).toHaveBeenCalledWith( + 'Agent "reviewer" runs on opencode 1 from the prompt, since opencode run does not start a subagent there, so it does not apply permission ' + + 'from the deployed agent file. The step is not blocked.', + ); + + const helper = '---\ndescription: Helps.\nmode: subagent\nvariant: low\n---\nHelp out.\n'; + const helperOn1 = await runOn('opencode', oneStep(' agent: helper\n'), { agents: { helper } }); + expect(helperOn1.argv).toEqual([['run', '--format', 'json', prompt('Help out.\n\nReview this project.')]]); + expect(warn).toHaveBeenLastCalledWith( + 'Agent "helper" runs on opencode 1 from the prompt, since opencode run does not start a subagent there, so it does not apply variant ' + + 'from the deployed agent file. The step is not blocked.', + ); + const helperOn2 = await runOn('opencode', oneStep(' agent: helper\n'), { agents: { helper }, version: 'opencode v2.0.30' }); + expect(helperOn2.argv).toEqual([['run', '--format', 'json', prompt('Review this project.'), '--agent', 'helper']]); + expect(warn).toHaveBeenLastCalledWith( + 'Agent "helper" sets a variant with no model, and opencode 2 takes a variant only with a model, so it does not apply variant ' + + 'from the deployed agent file. The step is not blocked.', + ); + }); + + it('passes the effort to OpenCode 2, which has no --variant, as model#variant', async () => { + const strong = await runOn('opencode', oneStep(' model: strong\n'), { version: 'opencode v2.0.30' }); + const literal = await runOn('opencode', oneStep(' model: anthropic/claude-sonnet\n'), { version: 'opencode v2.0.30' }); + + expect(strong.argv).toEqual([['run', '--format', 'json', prompt('Review this project.'), '--model', 'anthropic/claude-opus#high']]); + expect(literal.argv).toEqual([['run', '--format', 'json', prompt('Review this project.'), '--model', 'anthropic/claude-sonnet']]); + }); +}); diff --git a/src/__tests__/workflow-local-tool-adapter.test.ts b/src/__tests__/workflow-local-tool-adapter.test.ts index 4d3bdd9d0..5c3c7e681 100644 --- a/src/__tests__/workflow-local-tool-adapter.test.ts +++ b/src/__tests__/workflow-local-tool-adapter.test.ts @@ -2,8 +2,8 @@ import { EventEmitter } from 'node:events'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createLocalToolAdapter, definitionHandoff, type ToolSpawn } from '../workflow/local-tool-adapter.js'; +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'; +import { createLocalToolAdapter, type ToolSpawn } from '../workflow/local-tool-adapter.js'; const files: string[] = []; @@ -240,13 +240,56 @@ describe('local tool adapter', () => { env: expect.objectContaining({ TEAMAI_LAUNCHED_BY: 'workflow', TEAMAI_LAUNCHED_TOOL: 'claude', TEAMAI_LAUNCHED_CHECKOUT: 'checkout-1' }), })); }); -}); -describe('definitionHandoff (#1176)', () => { - it('hands Claude its own file and Codex the instructions with the model on argv, and knows no handoff for OpenCode', () => { - expect(definitionHandoff('tclaude', { model: 'opus' })).toEqual({ applied: 'main' }); - expect(definitionHandoff('opencode', { model: 'opus' })).toEqual({}); - expect(definitionHandoff('codex-internal', { model: 'gpt-5.4' })).toEqual({ applied: 'prompt', model: 'gpt-5.4' }); - expect(definitionHandoff('codex', {})).toEqual({ applied: 'prompt' }); + it('reports that Claude ran a definition from its own file and Codex from the prompt', async () => { + const resultPath = path.join(os.tmpdir(), `teamai-workflow-applied-${Date.now()}.json`); + files.push(resultPath); + const agent = { name: 'reviewer', instructions: 'Read only.' }; + const claude = vi.fn(() => child(claudeStream(undefined, { result: '{}' }))) as unknown as ToolSpawn; + const codex = vi.fn(() => child('{"type":"thread.started","thread_id":"thread-1"}')) as unknown as ToolSpawn; + + expect(await createLocalToolAdapter(claude, () => 'claude').run('tclaude', { prompt: 'Review', cwd: os.tmpdir(), resultPath, agent })) + .toMatchObject({ started: true, applied: 'main' }); + expect(await createLocalToolAdapter(codex, () => 'codex').run('codex-internal', { prompt: 'Review', cwd: os.tmpdir(), resultPath, agent })) + .toMatchObject({ started: true, applied: 'prompt' }); + expect(await createLocalToolAdapter(claude, () => 'claude').run('claude', { prompt: 'Review', cwd: os.tmpdir(), resultPath })) + .not.toHaveProperty('applied'); + }); + + it.each([['1.18.23', 'prompt'], ['opencode v2.0.30', 'main']])( + 'reports the OpenCode %s handoff of a run that started and wrote no result', + async (version, applied) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-workflow-handoff-')); + onTestFinished(() => fs.rm(root, { recursive: true, force: true })); + const executable = path.join(root, 'opencode'); + await fs.writeFile(executable, `#!/usr/bin/env node +if (process.argv[2] === '--version') { console.log(${JSON.stringify(version)}); process.exit(0); } +process.exit(1); +`, { mode: 0o755 }); + + const result = await createLocalToolAdapter(undefined, () => executable).run('opencode', { + prompt: 'Review', cwd: root, resultPath: path.join(root, 'result.json'), + agent: { name: 'reviewer', instructions: 'Read only.', settings: { mode: 'subagent' } }, + }); + + expect(result).toMatchObject({ started: true, exitCode: 1, applied, failure: { code: 'tool_failed' } }); + }, + ); + + it('reports the handoff of a run that started and then failed', async () => { + const failsAfterSpawn = (() => { + const process = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: Object.assign(new EventEmitter(), { resume: () => undefined }) }); + queueMicrotask(() => { + process.emit('spawn'); + process.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })); + }); + return process; + }) as unknown as ToolSpawn; + + const result = await createLocalToolAdapter(failsAfterSpawn, () => 'claude').run('claude', { + prompt: 'Review', cwd: os.tmpdir(), resultPath: path.join(os.tmpdir(), 'never-result.json'), agent: { name: 'reviewer', instructions: 'Read only.' }, + }); + + expect(result).toMatchObject({ started: true, applied: 'main', failure: { code: 'tool_failed' } }); }); }); diff --git a/src/__tests__/workflow-parse.test.ts b/src/__tests__/workflow-parse.test.ts index cf677ca10..ee3497a8b 100644 --- a/src/__tests__/workflow-parse.test.ts +++ b/src/__tests__/workflow-parse.test.ts @@ -842,3 +842,152 @@ describe('expressions in templates', () => { ]); }); }); + +describe('step model and defaults', () => { + it('reports model on a command or workflow step, with the file and step', () => { + const result = parse(`schemaVersion: v1alpha1 +name: build +description: Build it. +steps: + - id: compile + command: npm run build + model: strong + - id: check + needs: [compile] + workflow: + steps: + - id: lint + command: npm run lint + model: fast +`); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map(formatDiagnostic)).toEqual([ + 'workflows/build.yaml:7: field "steps[0].model": model is only allowed on a prompt or prompt_file step', + 'workflows/build.yaml:14: field "steps[1].model": model is only allowed on a prompt or prompt_file step', + ]); + expect(result.diagnostics.every((diagnostic) => diagnostic.code === undefined)).toBe(true); + }); + + it('reports a templated model on a step and in defaults as a validation error', () => { + for (const template of ['${{ inputs.m }}', '{{ inputs.m }}']) { + const result = parse(`schemaVersion: v1alpha1 +name: build +description: Build it. +inputs: + m: string +defaults: + model: "${template}" +steps: + - id: plan + prompt: Plan it. + model: "${template}" +`); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map(formatDiagnostic)).toEqual([ + 'workflows/build.yaml:7: field "defaults.model": model cannot contain a template; write an alias or a model name as is', + 'workflows/build.yaml:11: field "steps[0].model": model cannot contain a template; write an alias or a model name as is', + ]); + } + }); + + it('keeps a prompt step model and defaults.model as written', () => { + const result = parse(`schemaVersion: v1alpha1 +name: build +description: Build it. +defaults: + model: fast +steps: + - id: plan + prompt: Plan it. + model: strong + - id: review + needs: [plan] + prompt_file: prompts/review.md + agent: reviewer + model: claude-opus-4.1 + - id: summary + needs: [review] + prompt: Summarize it. +`); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.definition.defaults).toEqual({ model: 'fast' }); + expect(result.definition.steps.map((step) => step.model)).toEqual(['strong', 'claude-opus-4.1', undefined]); + }); + + it('rejects a model that is empty, not a string, or has spaces', () => { + const result = parse(`schemaVersion: v1alpha1 +name: build +description: Build it. +defaults: + model: 3 +steps: + - id: plan + prompt: Plan it. + model: '' + - id: review + needs: [plan] + prompt: Review it. + model: claude opus +`); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map(formatDiagnostic)).toEqual([ + 'workflows/build.yaml:5: field "defaults.model": model must be a non-empty string without spaces', + 'workflows/build.yaml:9: field "steps[0].model": model must be a non-empty string without spaces', + 'workflows/build.yaml:13: field "steps[1].model": model must be a non-empty string without spaces', + ]); + }); + + it('reports a defaults key this version does not know as unknown_key, and defaults that are not a mapping', () => { + const unknown = parse(`schemaVersion: v1alpha1 +name: build +description: Build it. +defaults: + model: fast + requires: [git] +steps: + - id: plan + prompt: Plan it. +`); + expect(unknown.ok).toBe(false); + if (unknown.ok) return; + expect(unknown.diagnostics).toEqual([expect.objectContaining({ field: 'defaults.requires', line: 6, code: 'unknown_key' })]); + + const scalar = parse(`schemaVersion: v1alpha1 +name: build +description: Build it. +defaults: fast +steps: + - id: plan + prompt: Plan it. +`); + expect(scalar.ok).toBe(false); + if (scalar.ok) return; + expect(scalar.diagnostics.map(formatDiagnostic)).toEqual(['workflows/build.yaml:4: field "defaults": defaults must be a mapping']); + }); + + it('reports defaults on an inline workflow, which carries none', () => { + const result = parse(`schemaVersion: v1alpha1 +name: build +description: Build it. +steps: + - id: check + workflow: + defaults: + model: fast + steps: + - id: plan + prompt: Plan it. +`); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map(formatDiagnostic)).toEqual([ + 'workflows/build.yaml:7: field "steps[0].workflow.defaults": step "check" runs an inline workflow, which carries only steps, outputs, ends, and loop', + ]); + expect(result.diagnostics[0]?.code).toBeUndefined(); + }); +}); + diff --git a/src/agent-observation.ts b/src/agent-observation.ts index aba2f7c32..b6164b441 100644 --- a/src/agent-observation.ts +++ b/src/agent-observation.ts @@ -88,11 +88,13 @@ export type ToolLaunch = { stem: string; /** The absolute path the launch read, the key of `delivered`. */ file: string; - /** Absent when the tool is not known to apply the file; then no model is requested from it. */ - applied?: z.infer; - /** The model the launch passes on argv (Codex `--model`); absent when the tool reads its own file. */ + /** The definition's model the launch passes on argv (Codex, OpenCode); absent when the tool reads its own file. */ model?: string; + /** The definition's effort the launch passes on argv with its model (an OpenCode variant). */ + effort?: string; }; + /** The model the step or its `defaults.model` chose, which the launch passes on argv in place of the definition's. */ + chosen?: { model: string; effort?: string; alias?: string }; }; /** Observe a launch before the tool starts. Never throws: each field it cannot observe is left out. */ @@ -108,18 +110,23 @@ export async function observeLaunch(launch: ToolLaunch): Promise const resolved = definition && await recordedUnchanged(record?.delivered, definition.file) ? record?.agentModels?.[definition.stem]?.[launch.tool] : undefined; - const model = definition?.applied === 'prompt' - ? names({ requested: definition.model, alias: definition.model && resolved?.model === definition.model ? resolved.alias : undefined }) - : definition?.applied === 'main' ? names({ requested: resolved?.model, alias: resolved?.alias }) : undefined; - // No launch requests an effort: none passes one on argv. Claude does not apply an agent file's `effort:` under - // `--agent`, and OpenCode 2 takes one only as the model's `#variant`, so the file's value is not what ran. + const { chosen } = launch; + const model = chosen + ? names({ requested: chosen.model, alias: chosen.alias }) + : definition?.model + ? names({ requested: definition.model, alias: resolved?.model === definition.model ? resolved.alias : undefined }) + // With no model on argv, the tool runs the one its deployed file sets, as the pull recorded it. + : definition ? names({ requested: resolved?.model, alias: resolved?.alias }) : undefined; + // The effort requested is the one the launch passes on argv. Claude does not apply an agent file's `effort:` under + // `--agent`, so the file's value is not what ran. + const effort = names({ requested: chosen ? chosen.effort : definition?.effort }, LABEL); const team = delivery?.complete ? { revision: delivery.commit, localChanges: !await allUnchanged(delivery.record.delivered ?? {}) } : undefined; return { - ...(definition?.applied ? { definition: { applied: definition.applied } } : {}), ...(version ? { tool: { version } } : {}), ...(model ? { model } : {}), + ...(effort ? { effort } : {}), ...(team ? { team } : {}), ...(head ? { project: { commit: head.head, dirty: head.dirty } } : {}), }; diff --git a/src/models/aliases.ts b/src/models/aliases.ts index 03e55259a..2aa07c7e2 100644 --- a/src/models/aliases.ts +++ b/src/models/aliases.ts @@ -507,16 +507,25 @@ export function resolveAgentModel(aliases: ModelAliases, spec: AgentSpec, tool: return { ok: true, step: 'extras', ...(typeof extrasModel === 'string' ? { model: extrasModel } : {}) }; } if (spec.model === undefined) return { ok: true, step: 'default' }; + return resolveModelValue(aliases, spec.model, tool, extras); +} + +/** + * What `tool` receives for a model value, an alias or a model name. `extras` + * are the tool extras of the agent that names it: an effort they set wins + * over the alias's. + */ +export function resolveModelValue(aliases: ModelAliases, model: string, tool: ToolName, extras?: Record): ModelResolution { // An unreadable team file may define any name, so no model can be told literal. if (!aliases.ok) return aliases; - if (!aliases.names.has(spec.model)) return { ok: true, step: 'literal', model: spec.model }; + if (!aliases.names.has(model)) return { ok: true, step: 'literal', model }; if (aliases.localFailure !== undefined) return { ok: false, reason: aliases.localFailure }; const switched = aliases.switches[tool]; if (switched?.ok === false) { return { ok: false, reason: `Cannot tell whether a tool is switched to a model profile: ${switched.reason}` }; } - const resolution = fromEntries(aliases, spec.model, tool, extras); + const resolution = fromEntries(aliases, model, tool, extras); return switched?.switched ? throughSwitch(resolution, tool, extras) : resolution; } diff --git a/src/opencode-hooks.ts b/src/opencode-hooks.ts index 0f1c15613..1a983bb7d 100644 --- a/src/opencode-hooks.ts +++ b/src/opencode-hooks.ts @@ -396,18 +396,23 @@ export async function injectOpencodeHooks(baseDir: string, scope: 'project' | 'u } } -let majorVersion: Promise | undefined; +const majorVersions = new Map>(); /** - * OpenCode's major version, from `opencode --version` (`opencode v2.0.24` on - * V2, `1.18.35` on V1), asked once per run. 1 when the binary is absent or - * prints no version. + * OpenCode's major version, from ` --version` (`opencode v2.0.24` on + * V2, `1.18.35` on V1), asked once per binary per run. 1 when the binary is + * absent or prints no version. */ -export function opencodeMajorVersion(): Promise { - return majorVersion ??= probeBinary('opencode').then((output) => { - const match = /(\d+)\.\d+/.exec(output); - return match ? Number(match[1]) : 1; - }); +export function opencodeMajorVersion(binary = 'opencode'): Promise { + let version = majorVersions.get(binary); + if (!version) { + version = probeBinary(binary).then((output) => { + const match = /(\d+)\.\d+/.exec(output); + return match ? Number(match[1]) : 1; + }); + majorVersions.set(binary, version); + } + return version; } /** diff --git a/src/workflow/conformance.ts b/src/workflow/conformance.ts index 451d59ab3..5e6957906 100644 --- a/src/workflow/conformance.ts +++ b/src/workflow/conformance.ts @@ -15,9 +15,12 @@ export interface ConformanceScenario { operations: ConformanceOperation[]; } -/** `running` and `cancelled` name leases that earlier operations saved; `cancelled` must match exactly. */ +/** + * `running` and `cancelled` name leases that earlier operations saved; `cancelled` must match exactly. + * `absent` lists fields the lease must not carry. + */ export type ConformanceOperation = - | { kind: 'next'; saveAs: string; running?: string[]; canTakeLease?: boolean; cancelled?: string[]; expect: Record } + | { kind: 'next'; saveAs: string; running?: string[]; canTakeLease?: boolean; cancelled?: string[]; absent?: string[]; expect: Record } | { kind: 'report'; lease?: string; @@ -106,6 +109,20 @@ steps: command: echo classify `; +const PROMPT_MODEL = `schemaVersion: v1alpha1 +name: build +description: A prompt step lease carries its model and the default model as written. +defaults: + model: fast +steps: + - id: compile + command: echo compile + - id: plan + needs: [compile] + prompt: Plan it. + model: strong +`; + const PROMPT_WITH = `schemaVersion: v1alpha1 name: prompt-with description: A prompt step reads its with values and keeps GitHub Actions text. @@ -326,6 +343,16 @@ export const workflowConformanceScenarios: ConformanceScenario[] = [ { kind: 'next', saveAs: 'fix', expect: { data: { lease: { step: 'fix', prompt: 'Fix the crash; keep ${{ inputs.version }}.' } } } }, ], }, + { + name: 'a prompt step lease carries model and defaultModel as written, and a command step lease neither', + workflowName: 'build', + yaml: PROMPT_MODEL, + operations: [ + { kind: 'next', saveAs: 'compile', absent: ['model', 'defaultModel'], expect: { data: { lease: { step: 'compile' } } } }, + { kind: 'report', lease: 'compile', body: { outcome: 'success' }, expect: { data: { step: { status: 'succeeded' } } } }, + { kind: 'next', saveAs: 'plan', expect: { data: { lease: { step: 'plan', model: 'strong', defaultModel: 'fast' } } } }, + ], + }, { name: 'a driver that lists its running attempts gets parallel leases', workflowName: 'build', diff --git a/src/workflow/engine.ts b/src/workflow/engine.ts index ed4a88df0..7f2373446 100644 --- a/src/workflow/engine.ts +++ b/src/workflow/engine.ts @@ -117,6 +117,8 @@ async function withRun( interface RenderedLease { prompt?: string; env?: Record; + /** The `defaults.model` in effect for a prompt step, as written. */ + defaultModel?: string; } function workflowLabel(scope: Scope): string { @@ -172,6 +174,7 @@ function renderLease(scope: Scope, states: StepState[], state: StepState, defini return { ...(rendered.has('prompt') ? { prompt: rendered.get('prompt') } : {}), ...(definition.env ? { env: Object.fromEntries(env) } : {}), + ...(definition.prompt !== undefined && scope.defaultModel !== undefined ? { defaultModel: scope.defaultModel } : {}), }; } @@ -186,6 +189,8 @@ function leaseData(run: RunState, state: LeasedStep, definition: WorkflowStep, r ...(rendered.env ? { env: rendered.env } : {}), ...(rendered.prompt !== undefined ? { prompt: rendered.prompt } : {}), ...(definition.agent !== undefined ? { agent: definition.agent } : {}), + ...(definition.model !== undefined ? { model: definition.model } : {}), + ...(rendered.defaultModel !== undefined ? { defaultModel: rendered.defaultModel } : {}), ...(definition.output ? { output: definition.output } : {}), } }; } diff --git a/src/workflow/local-driver.ts b/src/workflow/local-driver.ts index 4c20010cf..c45d0a690 100644 --- a/src/workflow/local-driver.ts +++ b/src/workflow/local-driver.ts @@ -8,6 +8,7 @@ import { reverseFromClaude, reverseFromCodex, reverseFromOpencode, + toolExtrasFor, type AgentSpec, type ToolName, } from '../resources/agent-format.js'; @@ -19,7 +20,16 @@ import { readFileSafe, writeJsonAtomic } from '../utils/fs.js'; import { log } from '../utils/logger.js'; import { leaseNext, reportAttempt } from './engine.js'; import { isRecord } from './expression.js'; -import { createLocalToolAdapter, definitionHandoff, isLocalWorkflowTool, localToolFamily, type LocalToolAdapter, type LocalToolResult, type LocalWorkflowTool } from './local-tool-adapter.js'; +import { + createLocalToolAdapter, + isLocalWorkflowTool, + localToolFamily, + type LocalToolAdapter, + type LocalToolResult, + type LocalToolRun, + type LocalWorkflowTool, +} from './local-tool-adapter.js'; +import { chooseStepModel } from './step-model.js'; import type { DriverIdentity, WorkflowEnvelope, WorkflowFailure } from './types.js'; import { isWorkflowError } from './types.js'; @@ -120,12 +130,16 @@ export interface LocalDriverOptions { } interface Lease { + /** The step path, such as `check.classify` inside a sub-workflow. */ + step: string; attempt: string; revision: number; command?: string; env?: Record; prompt?: string; agent?: string; + model?: string; + defaultModel?: string; } function stringMap(value: unknown): Record | undefined { @@ -147,6 +161,11 @@ function ignoredCodexSettings(agent: AgentSpec): string[] { return agent.tools?.length ? ['tools', ...extras] : extras; } +function definitionRun(tool: LocalWorkflowTool, name: string, agent: AgentSpec): NonNullable { + const settings = localToolFamily(tool) === 'opencode' ? toolExtrasFor(agent, 'opencode') : undefined; + return { name, instructions: agent.instructions, ...(agent.model ? { model: agent.model } : {}), ...(settings ? { settings } : {}) }; +} + function reverseAgent(tool: LocalWorkflowTool, file: string, content: string) { const family = localToolFamily(tool); if (family === 'claude') return reverseFromClaude(file, content); @@ -227,15 +246,18 @@ type ReportBody = Parameters[2]; function parseLease(raw: unknown): Lease | undefined { const rawLease = raw as Record | undefined; - if (!rawLease || typeof rawLease.attempt !== 'string' || typeof rawLease.revision !== 'number') return undefined; + if (!rawLease || typeof rawLease.step !== 'string' || typeof rawLease.attempt !== 'string' || typeof rawLease.revision !== 'number') return undefined; const env = stringMap(rawLease.env); const lease: Lease = { + step: rawLease.step, attempt: rawLease.attempt, revision: rawLease.revision, ...(typeof rawLease.command === 'string' ? { command: rawLease.command } : {}), ...(env ? { env } : {}), ...(typeof rawLease.prompt === 'string' ? { prompt: rawLease.prompt } : {}), ...(typeof rawLease.agent === 'string' ? { agent: rawLease.agent } : {}), + ...(typeof rawLease.model === 'string' ? { model: rawLease.model } : {}), + ...(typeof rawLease.defaultModel === 'string' ? { defaultModel: rawLease.defaultModel } : {}), }; return (lease.command === undefined) === (lease.prompt === undefined) ? undefined : lease; } @@ -285,18 +307,28 @@ async function promptReport( }; return { attempt: lease.attempt, revision: lease.revision, driver, tool: selected, ...failureReport(failure) }; } + const chosen = await chooseStepModel({ + step: lease.step, tool: selected, model: lease.model, defaultModel: lease.defaultModel, agent: agent ?? undefined, config, + }); + if (!chosen.ok) return { attempt: lease.attempt, revision: lease.revision, driver, tool: selected, ...failureReport(chosen.failure) }; if (agent && localToolFamily(selected) === 'codex') { const ignored = ignoredCodexSettings(agent); if (ignored.length > 0) { log.warn(`Agent "${agent.name}" on ${selected} does not apply ${ignored.join(', ')} from the deployed agent file. codex exec still has a shell. The step is not blocked.`); } } + const definitionModel = chosen.model && chosen.by === 'definition' + ? { model: chosen.model.name, ...(chosen.model.effort ? { effort: chosen.model.effort } : {}) } + : {}; const [observed, launch] = await Promise.all([ observeLaunch({ config, tool: selected, cwd: driver.workspace, - ...(lease.agent && loaded ? { definition: { stem: lease.agent, file: loaded.file, ...definitionHandoff(selected, loaded.spec) } } : {}), + ...(lease.agent && loaded ? { definition: { stem: lease.agent, file: loaded.file, ...definitionModel } } : {}), + ...(chosen.model && chosen.by !== 'definition' + ? { chosen: { model: chosen.model.name, ...(chosen.model.effort ? { effort: chosen.model.effort } : {}), ...(chosen.alias ? { alias: chosen.alias } : {}) } } + : {}), }), workflowLaunch(config, selected), ]); @@ -308,7 +340,8 @@ async function promptReport( cwd: driver.workspace, resultPath: attemptResultPath, ...(launch ? { launch } : {}), - ...(agent ? { agent: { name: lease.agent!, instructions: agent.instructions, ...(agent.model ? { model: agent.model } : {}) } } : {}), + ...(agent ? { agent: definitionRun(selected, lease.agent!, agent) } : {}), + ...(chosen.model ? { model: chosen.model } : {}), }); } catch { result = { @@ -333,7 +366,8 @@ async function promptReport( ...(ran?.session ? { session: ran.session } : {}), ...(ran?.tokens ? { tokens: ran.tokens } : {}), ...(ran?.output !== undefined ? { outputs: ran.output } : {}), - ...(ran ? { agent: observed } : {}), + // How the definition was handed over is known only once the launch resolved its tool: OpenCode's depends on its major. + ...(ran ? { agent: ran.applied ? { ...observed, definition: { applied: ran.applied } } : observed } : {}), exitCode: result.exitCode, ...(failure ? failureReport(failure) : { outcome: result.exitCode === 0 ? 'success' : 'failed' }), }; diff --git a/src/workflow/local-tool-adapter.ts b/src/workflow/local-tool-adapter.ts index 25f4ae4e4..f66200c6e 100644 --- a/src/workflow/local-tool-adapter.ts +++ b/src/workflow/local-tool-adapter.ts @@ -3,8 +3,10 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import crossSpawn from 'cross-spawn'; import { launchEnv, type Launch } from '../launch-context.js'; +import { opencodeMajorVersion } from '../opencode-hooks.js'; import { resolveCliPath } from '../utils/cli-path.js'; import { writeJsonAtomic } from '../utils/fs.js'; +import { log } from '../utils/logger.js'; import type { TokenUsage, WorkflowFailure } from './types.js'; const LOCAL_WORKFLOW_TOOLS = ['claude', 'claude-internal', 'tclaude', 'codex', 'codex-internal', 'tcodex', 'opencode'] as const; @@ -25,7 +27,10 @@ export interface LocalToolRun { prompt: string; cwd: string; resultPath: string; - agent?: { name: string; instructions: string; model?: string }; + /** The definition the step runs as. `settings` are the deployed OpenCode file's other fields, such as `mode` and `variant`. */ + agent?: { name: string; instructions: string; model?: string; settings?: Record }; + /** The model and effort the launch passes: the step's, which replaces the definition's, or on Codex and OpenCode the definition's own. */ + model?: { name: string; effort?: string }; /** The workflow launch the tool's session-start hook reads from its environment (#1176). */ launch?: Launch; } @@ -38,6 +43,8 @@ export type LocalToolResult = | { started: true; exitCode: number; + /** How the launch handed over the definition, when it ran one. */ + applied?: 'main' | 'prompt'; output?: unknown; model?: string; session?: string; @@ -66,45 +73,32 @@ function resultInstruction(input: LocalToolRun, prompt: string): string { + 'Use this shape: {"output":{},"failure":{"code":"optional","message":"optional"}}.'; } -/** - * How a launch hands `tool` its definition: Claude reads its own deployed file (`--agent`), while Codex gets the - * instructions prepended to the prompt and the file's model on argv. OpenCode gets `--agent` too, but OpenCode 1 runs - * its default agent in place of a `mode: subagent` file, and OpenCode 2 does not apply the file's model (#1193), so - * how it applied the definition is not known. - */ -export function definitionHandoff( - tool: LocalWorkflowTool, - spec: Pick, 'model'>, -): { applied?: 'main' | 'prompt'; model?: string } { +/** Codex, and OpenCode 1 for a subagent, which `opencode run --agent` does not start, get the definition's instructions in the prompt. */ +function promptHandoff(tool: LocalWorkflowTool, input: LocalToolRun, opencodeMajor: number): boolean { const family = localToolFamily(tool); - if (family === 'claude') return { applied: 'main' }; - if (family === 'opencode') return {}; - return { applied: 'prompt', ...(spec.model ? { model: spec.model } : {}) }; + return family === 'codex' || (family === 'opencode' && opencodeMajor < 2 && input.agent?.settings?.['mode'] === 'subagent'); } -function invocation(tool: LocalWorkflowTool, input: LocalToolRun): { command: string; args: string[] } { +function invocation(tool: LocalWorkflowTool, input: LocalToolRun, opencodeMajor: number): string[] { const family = localToolFamily(tool); + const viaPrompt = promptHandoff(tool, input, opencodeMajor); const agentPrompt = input.agent?.instructions ? `${input.agent.instructions}\n\n${input.prompt}` : input.prompt; - const prompt = resultInstruction(input, family === 'codex' ? agentPrompt : input.prompt); + const prompt = resultInstruction(input, viaPrompt ? agentPrompt : input.prompt); + const agent = input.agent && !viaPrompt ? ['--agent', input.agent.name] : []; + const model = input.model ? ['--model', input.model.name] : []; + const effort = input.model?.effort; if (family === 'claude') { - return { - command: tool, - args: ['-p', prompt, '--output-format', 'stream-json', '--verbose', ...(input.agent ? ['--agent', input.agent.name] : [])], - }; + return ['-p', prompt, '--output-format', 'stream-json', '--verbose', ...agent, ...model, ...(effort ? ['--effort', effort] : [])]; } if (family === 'codex') { - const model = input.agent ? definitionHandoff(tool, input.agent).model : undefined; - return { - command: tool, - args: ['exec', '--json', ...(model ? ['--model', model] : []), prompt], - }; + const codexModel = input.model?.name ?? input.agent?.model; + return ['exec', '--json', ...(codexModel ? ['--model', codexModel] : []), ...(effort ? ['-c', `model_reasoning_effort=${effort}`] : []), prompt]; } - return { - command: tool, - args: ['run', '--format', 'json', prompt, ...(input.agent ? ['--agent', input.agent.name] : [])], - }; + // OpenCode 2 has no `--variant` and reads the effort from `--model provider/model#variant`. + const opencodeModel = input.model && effort && opencodeMajor >= 2 ? ['--model', `${input.model.name}#${effort}`] : [...model, ...(effort ? ['--variant', effort] : [])]; + return ['run', '--format', 'json', prompt, ...agent, ...opencodeModel]; } function finiteNumber(value: unknown): number | undefined { @@ -283,8 +277,7 @@ export function createLocalToolAdapter( return { async run(tool, input) { await fs.mkdir(path.dirname(input.resultPath), { recursive: true }); - const call = invocation(tool, input); - const executable = resolveTool(call.command); + const executable = resolveTool(tool); if (!executable) { return { started: false, @@ -292,13 +285,44 @@ export function createLocalToolAdapter( failure: { code: 'tool_unavailable', message: `The ${tool} executable is not available on PATH` }, }; } + const opencode = localToolFamily(tool) === 'opencode'; + const opencodeMajor = opencode && (input.agent || input.model?.effort) ? await opencodeMajorVersion(executable) : 1; + if (opencode && input.agent) { + const { name, model, settings = {} } = input.agent; + const viaPrompt = promptHandoff(tool, input, opencodeMajor); + // OpenCode 1 applies an agent's variant to a launch on the agent's own model that passes none. + if (opencodeMajor < 2 && !viaPrompt && settings['variant'] !== undefined && input.model && input.model.name === model && input.model.effort === undefined) { + return { + started: false, + exitCode: 1, + failure: { + code: 'model_unavailable', + message: `Agent "${name}" runs on its own model "${model}" with no effort, and ${tool} 1 applies the agent's variant "${String(settings['variant'])}" ` + + `to that model. Remove model from the step to run at that variant, or set it to an alias with an effort for ${tool}.`, + }, + }; + } + // The variant goes on argv with a model, the definition's or the step's. + const dropped = Object.keys(settings).filter((key) => key !== 'mode' && (key !== 'variant' || !input.model)); + if (viaPrompt && dropped.length > 0) { + log.warn(`Agent "${name}" runs on ${tool} 1 from the prompt, since opencode run does not start a subagent there, so it does not apply ` + + `${dropped.join(', ')} from the deployed agent file. The step is not blocked.`); + } else if (opencodeMajor >= 2 && settings['variant'] !== undefined && !input.model) { + log.warn(`Agent "${name}" sets a variant with no model, and ${tool} 2 takes a variant only with a model, so it does not apply variant ` + + 'from the deployed agent file. The step is not blocked.'); + } + } + const args = invocation(tool, input, opencodeMajor); + const applied = input.agent ? { applied: promptHandoff(tool, input, opencodeMajor) ? 'prompt' as const : 'main' as const } : {}; let started = false; const completed = await new Promise<{ exitCode: number; stdout: string; error?: NodeJS.ErrnoException }>((resolve) => { let child: ChildProcess; try { - child = spawnTool(executable, call.args, { + child = spawnTool(executable, args, { cwd: input.cwd, env: { ...process.env, ...(input.launch ? launchEnv(input.launch) : {}), TEAMAI_WORKFLOW_RESULT: input.resultPath }, + // OpenCode waits for the end of a piped stdin before it starts. + stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); } catch (error) { @@ -325,6 +349,7 @@ export function createLocalToolAdapter( return { started: true, exitCode: completed.exitCode, + ...applied, failure: { code: 'tool_failed', message: `${tool} could not be started` }, }; } @@ -335,6 +360,7 @@ export function createLocalToolAdapter( return { started: true, exitCode: completed.exitCode, + ...applied, failure: { code: 'tool_failed', message: `${tool} did not write a valid workflow result` }, }; } @@ -343,7 +369,7 @@ export function createLocalToolAdapter( result.failure = { code: 'tool_failed', message: `${tool} exited with status ${completed.exitCode}` }; } if (!fileResult) await persistFallback(input.resultPath, result); - return { started: true, exitCode: completed.exitCode, ...result }; + return { started: true, exitCode: completed.exitCode, ...applied, ...result }; }, }; } diff --git a/src/workflow/parse.ts b/src/workflow/parse.ts index 2d381da16..4e2906a60 100644 --- a/src/workflow/parse.ts +++ b/src/workflow/parse.ts @@ -4,7 +4,7 @@ import { findTemplate, parseTemplate, stepExpressions, templatedTexts, templateExpressions, templateWithPaths, type StepExpression, type Template, } from './template.js'; import type { - EndOutcome, ForEach, InlineWorkflow, OutputSchema, StepLoop, WorkflowDefinition, WorkflowDiagnostic, WorkflowLoop, WorkflowStep, + EndOutcome, ForEach, InlineWorkflow, OutputSchema, StepLoop, WorkflowDefaults, WorkflowDefinition, WorkflowDiagnostic, WorkflowLoop, WorkflowStep, } from './types.js'; import { STEP_ACTIONS } from './types.js'; import { @@ -16,9 +16,9 @@ import { } from './reads.js'; import { loopProblems } from './loop.js'; -const HEADER_KEYS = new Set(['schemaVersion', 'name', 'description', 'inputs', 'ends', 'outputs', 'loop', 'steps']); +const HEADER_KEYS = new Set(['schemaVersion', 'name', 'description', 'inputs', 'defaults', 'ends', 'outputs', 'loop', 'steps']); const ACTION_KEYS = Object.keys(STEP_ACTIONS); -const STEP_KEYS = new Set(['id', 'needs', 'if', ...ACTION_KEYS, 'env', 'with', 'agent', 'output', 'end', 'loop', 'break', 'for_each']); +const STEP_KEYS = new Set(['id', 'needs', 'if', ...ACTION_KEYS, 'env', 'with', 'agent', 'model', 'output', 'end', 'loop', 'break', 'for_each']); const STEP_LOOP_KEYS = new Set(['back_to', 'while', 'max_iterations']); const FOR_EACH_KEYS = new Set(['items', 'max_parallel']); const WORKFLOW_LOOP_KEYS = new Set(['while', 'max_iterations']); @@ -192,6 +192,9 @@ export function parseWorkflow(file: string, content: string, files?: WorkflowFil if (schema?.type === 'object') inputs = schema.fields; } + const defaultsPair = root.items.find((pair) => scalarString(pair.key) === 'defaults'); + const defaults = defaultsPair ? parseDefaults(defaultsPair.value ?? defaultsPair.key, ctx) : undefined; + // A malformed inputs header is already reported; checking reads against it would only add noise. const { workflow: { ends, outputs, loop, steps } } = parseBody(root, '', 'workflow', ctx, inputsPair && !inputs ? undefined : inputs ?? {}, false); @@ -200,7 +203,7 @@ export function parseWorkflow(file: string, content: string, files?: WorkflowFil ok: true, definition: { schemaVersion: 'v1alpha1', name, description, ...(inputs ? { inputs } : {}), ...(ends ? { ends } : {}), ...(outputs ? { outputs } : {}), - ...(loop ? { loop } : {}), steps, + ...(loop ? { loop } : {}), ...(defaults ? { defaults } : {}), steps, }, promptFileLocations, }; @@ -364,6 +367,11 @@ function parseBody( } else if (agentPair && (commandPair || workflowPair)) { diagnostics.push(locate(file, lineCounter, agentPair.key ?? item, `${field}.agent`, 'agent is only allowed on a prompt or prompt_file step')); } + const modelPair = item.items.find((pair) => scalarString(pair.key) === 'model'); + const model = modelPair ? parseModel(modelPair.value ?? modelPair.key, `${field}.model`, ctx) : undefined; + if (modelPair && model !== undefined && (commandPair || workflowPair)) { + diagnostics.push(locate(file, lineCounter, modelPair.key ?? item, `${field}.model`, 'model is only allowed on a prompt or prompt_file step')); + } const envPair = item.items.find((pair) => scalarString(pair.key) === 'env'); const env = envPair ? parseEnv(envPair.value ?? envPair.key, `${field}.env`, label, ctx) : undefined; const withPair = item.items.find((pair) => scalarString(pair.key) === 'with'); @@ -479,6 +487,7 @@ function parseBody( ...(workflow ? { workflow } : {}), ...(withValues ? { with: withValues } : {}), ...(agent ? { agent } : {}), + ...(model !== undefined ? { model } : {}), ...(output ? { output } : {}), ...(end ? { end } : {}), ...(loop ? { loop } : {}), @@ -665,6 +674,37 @@ function parseForEach(node: Node | null | undefined, field: string, label: strin return { items, ...(maxNode ? { maxParallel: max as number } : {}) }; } +/** A `model`, on a step or in `defaults`: an alias or a model name, which the lease carries as written. */ +function parseModel(node: Node | null | undefined, field: string, ctx: ParseContext): string | undefined { + const { file, lineCounter, diagnostics } = ctx; + const model = scalarString(node); + if (model?.includes('{{')) { + diagnostics.push(locate(file, lineCounter, node, field, 'model cannot contain a template; write an alias or a model name as is')); + return undefined; + } + if (!model || /\s/.test(model)) { + diagnostics.push(locate(file, lineCounter, node, field, 'model must be a non-empty string without spaces')); + return undefined; + } + return model; +} + +function parseDefaults(node: Node | null | undefined, ctx: ParseContext): WorkflowDefaults | undefined { + const { file, lineCounter, diagnostics } = ctx; + if (!isMap(node)) { + diagnostics.push(locate(file, lineCounter, node, 'defaults', 'defaults must be a mapping')); + return undefined; + } + let model: string | undefined; + for (const pair of node.items) { + const keyNode = isYamlNode(pair.key) ? pair.key : undefined; + const key = scalarString(keyNode); + if (key === 'model') model = parseModel(valueNode(node, 'model'), 'defaults.model', ctx); + else diagnostics.push(unknownKey(file, lineCounter, keyNode ?? node, `defaults.${key ?? 'key'}`, key ?? 'key')); + } + return model === undefined ? undefined : { model }; +} + /** `env` maps variable names to text. A YAML number or boolean is read as its text. */ function parseEnv(node: Node | null | undefined, field: string, label: string, ctx: ParseContext): Record | undefined { const { file, lineCounter, diagnostics } = ctx; diff --git a/src/workflow/records.ts b/src/workflow/records.ts index d429928ad..6adf635cc 100644 --- a/src/workflow/records.ts +++ b/src/workflow/records.ts @@ -30,7 +30,7 @@ import type { DriverIdentity, RunState, RunStatus, TokenUsage, WorkflowFailure } */ const RECORDED_FAILURES = [ 'loop_exhausted', 'invalid_output', 'invalid_template', 'invalid_inputs', 'invalid_items', 'item_cycle', - 'tool_failed', 'tool_unavailable', 'agent_unavailable', 'read_only_dirty', 'tool_unconfigured', 'tool_unsupported', + 'tool_failed', 'tool_unavailable', 'agent_unavailable', 'model_unavailable', 'read_only_dirty', 'tool_unconfigured', 'tool_unsupported', ] as const; type RecordedFailure = (typeof RECORDED_FAILURES)[number]; diff --git a/src/workflow/scope.ts b/src/workflow/scope.ts index 2a63e706b..fa9a6d1b6 100644 --- a/src/workflow/scope.ts +++ b/src/workflow/scope.ts @@ -23,10 +23,14 @@ export interface Scope { runner?: StepState; /** In the inline workflow of a `for_each` item, at any depth: that item, which its steps read as `item`. */ item?: unknown; + /** The `defaults.model` in effect: the workflow file's own, else the enclosing scope's. */ + defaultModel?: string; } export function topScope(run: RunState): Scope { - return { prefix: '', definition: run.definition, inputs: run.inputs, loops: stepLoops(run.definition.steps) }; + return { + prefix: '', definition: run.definition, inputs: run.inputs, loops: stepLoops(run.definition.steps), defaultModel: run.definition.defaults?.model, + }; } /** @@ -37,11 +41,14 @@ export function childScope(run: RunState, scope: Scope, states: StepState[], sta if (typeof step.workflow === 'object') { return { prefix: `${state.id}.`, definition: step.workflow, inputs: scope.inputs, outer: expressionSteps(scope, states, state), - loops: stepLoops(step.workflow.steps), runner: state, ...itemOf(scope, state), + loops: stepLoops(step.workflow.steps), runner: state, ...itemOf(scope, state), defaultModel: scope.defaultModel, }; } const definition = step.workflow === undefined ? undefined : run.workflows?.[step.workflow]; - return definition && { prefix: `${state.id}.`, definition, inputs: state.inputs, loops: stepLoops(definition.steps), runner: state }; + return definition && { + prefix: `${state.id}.`, definition, inputs: state.inputs, loops: stepLoops(definition.steps), runner: state, + defaultModel: definition.defaults?.model ?? scope.defaultModel, + }; } /** diff --git a/src/workflow/step-model.ts b/src/workflow/step-model.ts new file mode 100644 index 000000000..8a26a1eee --- /dev/null +++ b/src/workflow/step-model.ts @@ -0,0 +1,111 @@ +/** + * Which model a prompt step runs on, first match wins: the step's `model`, the model its definition sets for the + * tool, the `defaults.model` in effect, the tool's default. The lease carries the first and third as written; they + * resolve here, with the member's aliases for the step's tool. Claude reads a definition's model from its file. Codex + * gets it on argv, and so does OpenCode with the variant: OpenCode 2 reads neither from the agent file, and OpenCode 1 + * does not start a subagent, which is how pull deploys OpenCode agents. + */ +import { isModelAlias, loadModelAliases, resolveModelValue, TEAM_ALIASES_FILE, type ResolutionStep } from '../models/aliases.js'; +import { toolExtrasFor, type AgentSpec } from '../resources/agent-format.js'; +import type { LocalConfig } from '../types.js'; +import { localToolFamily, type LocalToolRun, type LocalWorkflowTool } from './local-tool-adapter.js'; +import type { WorkflowFailure } from './types.js'; + +export type StepModel = NonNullable; + +/** + * The model and effort the launch passes, none when it passes none, or why the step cannot run. `by` says whether the + * step, its `defaults.model` or the definition chose it; `alias` is the alias the step or `defaults.model` named. + */ +export type StepModelChoice = + | { ok: true; model?: undefined } + | { ok: true; model: StepModel; by: 'definition' } + | { ok: true; model: StepModel; by: ModelOrigin; alias?: string } + | { ok: false; failure: WorkflowFailure }; + +export interface StepModelRequest { + /** The step path the lease names, such as `check.classify`. */ + step: string; + tool: LocalWorkflowTool; + /** The lease's `model`, as written. */ + model?: string; + /** The lease's `defaultModel`, as written. */ + defaultModel?: string; + /** The definition deployed for the tool, when the step names an agent. */ + agent?: AgentSpec; + /** The scope whose aliases resolve the step's `model` or `defaults.model`. */ + config: LocalConfig; +} + +type ModelOrigin = 'step' | 'defaults'; + +/** Why a value gives the tool no model: no entry for it, the member's `~` or `default`, a model switch, or an error. */ +type NoModel = 'unmapped' | 'local' | 'switched' | 'unresolved'; + +interface NoModelContext { + step: string; + /** `model alias "fast"`, or `model "fast"` when it cannot be told to be an alias. */ + value: string; + tool: string; + source: string; + reason: string; +} + +const NO_MODEL: Record string>> = { + unmapped: { + step: (at) => `Step "${at.step}" asks for ${at.value}, which has no model for ${at.tool} in ${at.source}. Add one or remove model from the step.`, + defaults: (at) => `Step "${at.step}" gets ${at.value} from defaults, which has no model for ${at.tool} in ${at.source}. Add one, or set model on the step.`, + }, + local: { + step: (at) => `Step "${at.step}" asks for ${at.value}, which ${at.source} turns off for ${at.tool}. Remove that entry or remove model from the step.`, + defaults: (at) => `Step "${at.step}" gets ${at.value} from defaults, which ${at.source} turns off for ${at.tool}. Remove that entry, or set model on the step.`, + }, + switched: { + step: (at) => `Step "${at.step}" asks for ${at.value}, which gives no model for ${at.tool} while teamai models switch points ${at.tool} at a model profile. ` + + 'Run teamai models restore, or remove model from the step.', + defaults: (at) => `Step "${at.step}" gets ${at.value} from defaults, which gives no model for ${at.tool} while teamai models switch points ${at.tool} at a model profile. ` + + 'Run teamai models restore, or set model on the step.', + }, + unresolved: { + step: (at) => `Step "${at.step}" asks for ${at.value}, which cannot be resolved for ${at.tool}: ${at.reason}. Fix that, or remove model from the step.`, + defaults: (at) => `Step "${at.step}" gets ${at.value} from defaults, which cannot be resolved for ${at.tool}: ${at.reason}. Fix that, or set model on the step.`, + }, +}; + +/** Why a resolution step gives no model. The only other one that can is `default`: the alias does not map the tool. */ +const NO_MODEL_STEPS: Partial> = { local: 'local', switched: 'switched' }; + +function unavailable(message: string): StepModelChoice { + return { ok: false, failure: { code: 'model_unavailable', message } }; +} + +export async function chooseStepModel(request: StepModelRequest): Promise { + const { step, tool, agent } = request; + const chosen: { origin: ModelOrigin; value: string } | undefined = request.model !== undefined + ? { origin: 'step', value: request.model } + : agent?.model === undefined && request.defaultModel !== undefined ? { origin: 'defaults', value: request.defaultModel } : undefined; + if (!chosen) { + const family = localToolFamily(tool); + if (!agent?.model || family === 'claude') return { ok: true }; + const variant = family === 'opencode' ? toolExtrasFor(agent, 'opencode')?.['variant'] : undefined; + return { ok: true, by: 'definition', model: { name: agent.model, ...(variant !== undefined ? { effort: String(variant) } : {}) } }; + } + const aliases = await loadModelAliases(request.config); + const resolution = resolveModelValue(aliases, chosen.value, tool); + if (!resolution.ok || resolution.model === undefined) { + const why = resolution.ok ? NO_MODEL_STEPS[resolution.step] ?? 'unmapped' : 'unresolved'; + return unavailable(NO_MODEL[why][chosen.origin]({ + step, + value: isModelAlias(aliases, chosen.value) ? `model alias "${chosen.value}"` : `model "${chosen.value}"`, + tool, + source: (resolution.ok && resolution.source) || TEAM_ALIASES_FILE, + reason: resolution.ok ? '' : resolution.reason, + })); + } + return { + ok: true, + by: chosen.origin, + model: { name: resolution.model, ...(resolution.effort !== undefined ? { effort: resolution.effort } : {}) }, + ...(isModelAlias(aliases, chosen.value) ? { alias: chosen.value } : {}), + }; +} diff --git a/src/workflow/types.ts b/src/workflow/types.ts index 56a976a55..a8667fee6 100644 --- a/src/workflow/types.ts +++ b/src/workflow/types.ts @@ -28,6 +28,8 @@ export interface WorkflowStep { /** Original team-repo-relative source path for a cached prompt. */ promptFile?: string; agent?: string; + /** The model a prompt step runs on, as written: an alias from `models/aliases.yaml` or a model name. The driver resolves it. */ + model?: string; output?: OutputSchema; /** Variables a command step receives, as written; `{{ inputs.… }}` and `{{ steps.… }}` render when the step is leased. */ env?: Record; @@ -99,6 +101,12 @@ export interface InlineWorkflow { steps: WorkflowStep[]; } +/** Values a workflow file sets for its steps. */ +export interface WorkflowDefaults { + /** The model of a prompt step whose step and definition choose none, as written. */ + model?: string; +} + export interface WorkflowDefinition { schemaVersion: 'v1alpha1'; name: string; @@ -111,6 +119,7 @@ export interface WorkflowDefinition { outputs?: Record; /** Runs the workflow's steps again, round after round. */ loop?: WorkflowLoop; + defaults?: WorkflowDefaults; steps: WorkflowStep[]; }