From 071e63a8e686de7fee2ca146d34df29b11c5a495 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 11 Sep 2026 09:52:56 +0200 Subject: [PATCH] feat(surface,sdk): expose cli/model on Ctx.agent options (#310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TS surface parity with YAML AgentStepSpec.cli?/model? — a f.agent call can now pin its CLI and model without going through flows.json's project default. Named-agent maps and use: composition remain out of scope (that's #300). - AgentOptions gets optional cli / model, mirroring LlmOptions and AgentStepSpec exactly. - authoredWorkerRunner's agent path passes them through into the compiled StepSpec the same way workspace flows through, with an early type check that surfaces a diagnostic before contacting the journal when either field is not a string. - authored-flow.test.ts covers the type-check refusal path across four bad values per field; the message-shape assertion distinguishes it from the shared agent_cli_unresolved code that preflight also throws. - live-kernel.test.ts's stale comment ("AgentOptions is {task, workspace} only") is refreshed to point at the new fields and the unit-test coverage. Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- packages/sdk/src/authored-worker-step.ts | 14 +++++++++++++ packages/sdk/tests/authored-flow.test.ts | 25 ++++++++++++++++++++++++ packages/sdk/tests/live-kernel.test.ts | 12 +++++++----- packages/surface/src/context.ts | 2 ++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/authored-worker-step.ts b/packages/sdk/src/authored-worker-step.ts index 800dc02e9..389f449f4 100644 --- a/packages/sdk/src/authored-worker-step.ts +++ b/packages/sdk/src/authored-worker-step.ts @@ -95,10 +95,24 @@ export function authoredWorkerRunner( + 'if you do not need enforcement, or use the declarative spec\'s `permissions` field, which is real.', ); } + if (options.cli !== undefined && typeof options.cli !== 'string') { + throw new AuthoredFlowExecutionError( + 'agent_cli_unresolved', + `f.agent options.cli must be a string when set (got ${typeof options.cli}).`, + ); + } + if (options.model !== undefined && typeof options.model !== 'string') { + throw new AuthoredFlowExecutionError( + 'agent_cli_unresolved', + `f.agent options.model must be a string when set (got ${typeof options.model}).`, + ); + } const output = await run({ id, type: 'agent', instruction: options.task, ...(localAgentStream === undefined ? {} : { surfaces: { streams: [{ stream: localAgentStream }] } }), ...(options.workspace === undefined ? {} : { surfaces: { workspace: [{ surface: options.workspace }] } }), + ...(options.cli === undefined ? {} : { cli: options.cli }), + ...(options.model === undefined ? {} : { model: options.model }), }); if (typeof output !== 'object' || output === null || Array.isArray(output)) { throw new AuthoredFlowExecutionError('journal_protocol_violation', `step "${id}" produced a non-object output`); diff --git a/packages/sdk/tests/authored-flow.test.ts b/packages/sdk/tests/authored-flow.test.ts index 7e1659197..40ce4111a 100644 --- a/packages/sdk/tests/authored-flow.test.ts +++ b/packages/sdk/tests/authored-flow.test.ts @@ -169,6 +169,31 @@ describe('authored flow journal executor', () => { }); }); + it('refuses non-string cli or model on f.agent options before contacting the journal', async () => { + const disconnectedJournal = new JournalClient('/journal-must-not-be-contacted'); + + // Both preflight and the type check throw `agent_cli_unresolved` + // (matching the shared LLM code pattern) — the diagnostic message + // distinguishes them so authors get an accurate error. + for (const bad of [42, true, {}, []]) { + await expect(executeAuthoredFlow(flow('agent-cli-not-string', async (f) => { + await f.agent('worker', { task: 'x', cli: bad as unknown as string }); + f.done('success'); + }), disconnectedJournal)).rejects.toMatchObject({ + code: 'agent_cli_unresolved', + message: expect.stringMatching(/f\.agent options\.cli must be a string when set/), + }); + + await expect(executeAuthoredFlow(flow('agent-model-not-string', async (f) => { + await f.agent('worker', { task: 'x', model: bad as unknown as string }); + f.done('success'); + }), disconnectedJournal)).rejects.toMatchObject({ + code: 'agent_cli_unresolved', + message: expect.stringMatching(/f\.agent options\.model must be a string when set/), + }); + } + }); + it('rejects invalid raw headers before the executor can contact the journal', async () => { const disconnectedJournal = new JournalClient('/journal-must-not-be-contacted'); diff --git a/packages/sdk/tests/live-kernel.test.ts b/packages/sdk/tests/live-kernel.test.ts index 0006d1210..bf485f2bb 100644 --- a/packages/sdk/tests/live-kernel.test.ts +++ b/packages/sdk/tests/live-kernel.test.ts @@ -345,11 +345,13 @@ process.stdin.on('end', () => { }); `); chmodSync(cli, 0o755); - // f.agent has no way to declare a CLI itself (AgentOptions is - // {task, workspace} only) — it resolves one exactly the way a bare - // `type: agent` YAML step with no explicit `cli` does: the nearest - // flows.json's project default, found searching upward from the flow's - // own path (checkAuthoredFlow, cli/check.ts). + // This test intentionally omits cli/model on f.agent (AgentOptions + // accepts { task, workspace, cli?, model? } as of #310) — it exercises + // the resolution fallback: with no step cli, an authored agent resolves + // exactly like a bare `type: agent` YAML step, walking up to the + // nearest flows.json's project default (checkAuthoredFlow, cli/check.ts). + // The explicit-cli/model path is covered by unit tests in + // authored-flow.test.ts. writeFileSync(join(directory, 'flows.json'), JSON.stringify({ cli })); await startDaemon(dataDir); diff --git a/packages/surface/src/context.ts b/packages/surface/src/context.ts index 164736b57..2594266f8 100644 --- a/packages/surface/src/context.ts +++ b/packages/surface/src/context.ts @@ -10,6 +10,8 @@ export interface AgentResult { export interface AgentOptions { task: string; workspace?: string; + cli?: string; + model?: string; } export interface LlmOptions {