From 8c0b26f740d71499cd95daff9d76fdea0f51d9c5 Mon Sep 17 00:00:00 2001 From: kittimzhe Date: Sat, 17 Oct 2026 16:10:17 +0800 Subject: [PATCH] fix(workflow): pass the agent model and effort to opencode run (#1193) A workflow step with agent: on OpenCode launched opencode run --format json --agent and nothing else, while pull deploys every OpenCode agent as mode: subagent. On OpenCode 1 a subagent is not a primary agent, so run falls back to the default agent - the definition's prompt and model never run. On OpenCode 2 the definition's prompt applies but its model and variant are ignored. The model half: the driver now passes the step agent's mapped model explicitly, the way the codex family in the same invocation already does. The report's request-body table shows both majors honor an explicit --model even in fallback. The mapped effort rides with it: --variant on OpenCode 1, inside the model id (prov#effort) on OpenCode 2, where run has no separate variant flag; the major is probed once per executable and cached, reading as 1 (the pinned devDependency) when the probe fails. The driver reads the effort off the deployed file's variant field (reverseFromOpencode already collects it into tool_extras.opencode). The mode half - whether pull should keep writing subagent and document tool_extras.opencode.mode as the escape, or write a primary-mode copy for workflow-destined agents - is a design call left on the issue. --- .../workflow-local-tool-adapter.test.ts | 46 ++++++++++++++++ src/workflow/local-driver.ts | 13 ++++- src/workflow/local-tool-adapter.ts | 54 +++++++++++++++++-- 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/src/__tests__/workflow-local-tool-adapter.test.ts b/src/__tests__/workflow-local-tool-adapter.test.ts index 439743bf9..1ed039974 100644 --- a/src/__tests__/workflow-local-tool-adapter.test.ts +++ b/src/__tests__/workflow-local-tool-adapter.test.ts @@ -160,6 +160,52 @@ describe('local tool adapter', () => { }); }); + it('passes the agent model and variant to opencode 1 as --model and --variant (#1193)', async () => { + const resultPath = path.join(os.tmpdir(), `teamai-workflow-opencode-model-1-${Date.now()}.json`); + files.push(resultPath); + await fs.writeFile(resultPath, JSON.stringify({ output: { ok: true } })); + const spawnTool = vi.fn((_command: string, args: string[]) => + child(args.includes('--version') ? '1.18.23\n' : JSON.stringify({ type: 'session.created', properties: { info: { id: 'ses_1' } } }) + '\n')); + const adapter = createLocalToolAdapter(spawnTool as unknown as ToolSpawn, () => '/usr/bin/opencode'); + + await adapter.run('opencode', { + prompt: 'Review the change.', + cwd: os.tmpdir(), + resultPath, + agent: { name: 'reviewer', instructions: 'Read only.', model: 'anthropic/claude-sonnet-4-5', effort: 'high' }, + }); + + const runArgs = spawnTool.mock.calls.find(([, args]) => (args as string[]).includes('run'))![1] as string[]; + expect(runArgs).toContain('anthropic/claude-sonnet-4-5'); + expect(runArgs).toContain('--model'); + expect(runArgs).toContain('high'); + expect(runArgs).toContain('--variant'); + expect(runArgs.join(' ')).not.toContain('#high'); + }); + + it('encodes the variant inside --model for opencode 2 (#1193)', async () => { + const resultPath = path.join(os.tmpdir(), `teamai-workflow-opencode-model-2-${Date.now()}.json`); + files.push(resultPath); + await fs.writeFile(resultPath, JSON.stringify({ output: { ok: true } })); + // A different executable path: the major is probed and cached per binary, + // so the OpenCode 2 fixture is its own probe, not the 1.x cache entry. + const spawnTool = vi.fn((_command: string, args: string[]) => + child(args.includes('--version') ? '2.0.30\n' : JSON.stringify({ type: 'session.created', properties: { info: { id: 'ses_1' } } }) + '\n')); + const adapter = createLocalToolAdapter(spawnTool as unknown as ToolSpawn, () => '/opt/opencode-2/opencode'); + + await adapter.run('opencode', { + prompt: 'Review the change.', + cwd: os.tmpdir(), + resultPath, + agent: { name: 'reviewer', instructions: 'Read only.', model: 'anthropic/claude-sonnet-4-5', effort: 'high' }, + }); + + const runArgs = spawnTool.mock.calls.find(([, args]) => (args as string[]).includes('run'))![1] as string[]; + expect(runArgs).toContain('--model'); + expect(runArgs).toContain('anthropic/claude-sonnet-4-5#high'); + expect(runArgs).not.toContain('--variant'); + }); + it('reports tool_unavailable without spawning when the executable cannot be resolved', async () => { const spawnTool = vi.fn() as unknown as ToolSpawn; const adapter = createLocalToolAdapter(spawnTool, () => null); diff --git a/src/workflow/local-driver.ts b/src/workflow/local-driver.ts index 5ebbd108e..c36606ede 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'; @@ -294,7 +295,17 @@ async function promptReport( prompt: lease.prompt!, cwd: driver.workspace, resultPath: attemptResultPath, - ...(agent ? { agent: { name: lease.agent!, instructions: agent.instructions, ...(agent.model ? { model: agent.model } : {}) } } : {}), + // The deployed OpenCode file carries the mapped effort as `variant` + // (agentEffortField); `run --agent` applies it only when the file is a + // primary agent, so it rides the command line instead (#1193). + ...(agent ? { agent: { + name: lease.agent!, + instructions: agent.instructions, + ...(agent.model ? { model: agent.model } : {}), + ...(localToolFamily(selected) === 'opencode' && typeof toolExtrasFor(agent, selected as ToolName)?.variant === 'string' + ? { effort: toolExtrasFor(agent, selected as ToolName)?.variant as string } + : {}), + } } : {}), }); } catch { result = { diff --git a/src/workflow/local-tool-adapter.ts b/src/workflow/local-tool-adapter.ts index 78216b786..04427cfa1 100644 --- a/src/workflow/local-tool-adapter.ts +++ b/src/workflow/local-tool-adapter.ts @@ -24,7 +24,7 @@ export interface LocalToolRun { prompt: string; cwd: string; resultPath: string; - agent?: { name: string; instructions: string; model?: string }; + agent?: { name: string; instructions: string; model?: string; effort?: string }; } export interface LocalToolResult { @@ -56,7 +56,32 @@ function resultInstruction(input: LocalToolRun, prompt: string): string { + 'Use this shape: {"output":{},"failure":{"code":"optional","message":"optional"}}.'; } -function invocation(tool: LocalWorkflowTool, input: LocalToolRun): { command: string; args: string[] } { +const opencodeMajorCache = new Map(); + +/** + * OpenCode's major version, probed once per executable and cached. The two + * majors take an effort differently (`--variant` on 1, inside `--model` on 2, + * #1193); a failed probe reads as 1, the pinned devDependency's major. + */ +async function opencodeMajor(spawn: ToolSpawn, executable: string): Promise { + const cached = opencodeMajorCache.get(executable); + if (cached !== undefined) return cached; + const major = await new Promise((resolve) => { + let stdout = ''; + try { + const probe = spawn(executable, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] }); + probe.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); + probe.on('close', () => resolve(Number.parseInt(stdout.trim(), 10) || 1)); + probe.on('error', () => resolve(1)); + } catch { + resolve(1); + } + }); + opencodeMajorCache.set(executable, major); + return major; +} + +function invocation(tool: LocalWorkflowTool, input: LocalToolRun, opencodeMajorVersion?: number): { command: string; args: string[] } { const family = localToolFamily(tool); const agentPrompt = input.agent?.instructions ? `${input.agent.instructions}\n\n${input.prompt}` @@ -74,9 +99,25 @@ function invocation(tool: LocalWorkflowTool, input: LocalToolRun): { command: st args: ['exec', '--json', ...(input.agent?.model ? ['--model', input.agent.model] : []), prompt], }; } + // An agent step names a model the deployed file already carries, but + // `opencode run --agent` applies the file's model only when the file is a + // primary agent; passing it explicitly reaches both majors (#1193). The + // effort rides with it: `--variant` on OpenCode 1, inside the model id + // (`prov#effort`) on OpenCode 2, where run has no separate variant flag. + const model = input.agent?.model; + const effort = input.agent?.effort; + const version = opencodeMajorVersion ?? 1; + const modelArg = model !== undefined + ? (effort !== undefined && version >= 2 ? `${model}#${effort}` : model) + : undefined; return { command: tool, - args: ['run', '--format', 'json', prompt, ...(input.agent ? ['--agent', input.agent.name] : [])], + args: [ + 'run', '--format', 'json', prompt, + ...(input.agent ? ['--agent', input.agent.name] : []), + ...(modelArg !== undefined ? ['--model', modelArg] : []), + ...(effort !== undefined && model !== undefined && version < 2 ? ['--variant', effort] : []), + ], }; } @@ -256,7 +297,12 @@ export function createLocalToolAdapter( return { async run(tool, input) { await fs.mkdir(path.dirname(input.resultPath), { recursive: true }); - const call = invocation(tool, input); + let major: number | undefined; + if (localToolFamily(tool) === 'opencode' && input.agent?.effort !== undefined) { + const executable = resolveTool(tool); + if (executable) major = await opencodeMajor(spawnTool, executable); + } + const call = invocation(tool, input, major); const executable = resolveTool(call.command); if (!executable) { return {