Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,14 @@ No process runs between events: the handler wakes, executes to its next await, p
the existing per-step `cli` and `model` fields. The validated selector and
map remain authoring metadata through `flows check`, so unused and
step-shadowed declarations are linted too; both are removed at the kernel
boundary. Explicit step values win independently:
step `cli`/`model` → named declaration → the existing flow/project CLI
default. Model has no flow/project default. An inline step that selects no
named declaration keeps the existing optional-model behavior. The worker
explicitly removes ambient `RELAYFLOW_MODEL`; raw provider adapters use a
model flag, while at worker execution a custom wrapper receives the model
only inside its identified same-process session when the step declares one.
boundary. CLI and model resolve independently. CLI priority is step → named
declaration → flow → project config. Model priority is step → named
declaration → registered adapter default. Claude's adapter default is
`claude-opus-5`; Codex and custom wrappers have no default. A frozen dollar
budget refuses before execution when the selected model has no frozen price.
The worker explicitly removes ambient `RELAYFLOW_MODEL`; raw provider
adapters use a model flag, while a custom wrapper receives an explicitly
declared model only inside its identified same-process session.

**Anonymous resolution law:** `f.agent\`task\`` with no name is the *default agent*, resolved (never guessed) in order: step options → flow header → project config (`flows.json`) → platform default. *The platform-default rung is declared but not yet implemented: no platform default is provisioned as of gate 1, so a flow that reaches this rung refuses with `cli_unresolved` rather than guessing. `flows check` never invents an implicit default.* `flows check` prints each resolved step CLI and its declaration source, validates it before submission, and refuses a missing or unauthenticated resolution before the checked flow is submitted, never at minute 27. Gate 1 does not make this guarantee for callers that bypass `flows check`: the journal client's direct `run.start` path does not invoke surface preflight.

Expand Down
2 changes: 1 addition & 1 deletion packages/schema/flows.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1296,7 +1296,7 @@
},
"model": {
"title": "model",
"description": "Model the declared CLI must use. Raw Claude/Codex adapters receive their\nreal model flag; an identified Relayflows wrapper receives it in its\nsame-process execution request. Declared here so the choice is journaled with the step\ninstead of being ambient host state.",
"description": "Model the declared CLI must use. A step declaration wins over its selected\nnamed agent and any registered adapter default. Raw Claude/Codex adapters\nreceive the effective model as a real flag; an identified Relayflows\nwrapper receives an explicitly declared model in its same-process request.\nThe effective choice is journaled instead of being ambient host state.",
"type": "string",
"minLength": 1,
"pattern": "^\\S(?:[\\s\\S]*\\S)?$",
Expand Down
4 changes: 3 additions & 1 deletion packages/schema/tests/parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ mkdirSync(join(root, 'bin'));
// every check must agree, including environment readiness (no ignored refusals).
const wrapper = '#!/bin/sh\nif [ "$1" = "--relayflows-adapter-v1" ]; then echo relayflows-agent-cli-v1; fi\nexit 0\n';
for (const file of ['preflight/authenticated-cli', 'preflight/analyze-story-claude-cli', 'bin/claude']) writeFileSync(join(root, file), wrapper, { mode: 0o755 });
writeFileSync(join(root, 'flows.json'), readFileSync(new URL('../../../testdata/flows.json', import.meta.url)));
const parityConfig = JSON.parse(readFileSync(new URL('../../../testdata/flows.json', import.meta.url), 'utf8'));
parityConfig.models.push('claude-opus-5');
writeFileSync(join(root, 'flows.json'), JSON.stringify(parityConfig));
const oldPath = process.env.PATH;
process.env.PATH = `${join(root, 'bin')}:${oldPath ?? ''}`;
afterAll(() => { process.env.PATH = oldPath; rmSync(root, { recursive: true, force: true }); });
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk/src/adapters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ export interface HeadlessAdapter {
/** Identity of this adapter — matches CliAdapterKind for registry keys. */
readonly kind: string;

/**
* Stable model used only when neither the step nor its selected named agent
* declares one. Explicit authoring always wins. A default used with frozen
* dollar budgets must also have an entry in MODEL_PRICING.
*/
readonly defaultModel?: string;

/** Shape-check invocation before classifying an auth failure. */
buildIdentification(): CliAdapterIdentification;

Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothin
* pre-#141 inline shape in `cli-adapter.ts` — only the packaging changed. */
export const claudeAdapter: HeadlessAdapter = {
kind: 'claude',
defaultModel: 'claude-opus-5',

buildIdentification(): CliAdapterIdentification {
return { invocation: { args: ['auth', 'status', '--help'], timeoutMs: 10_000 } };
Expand Down
19 changes: 15 additions & 4 deletions packages/sdk/src/budget-preflight.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import type { CompiledFlowSpec } from './compile.js';
import type { PreflightRefusal } from './preflight.js';
import { MODEL_PRICING } from './model-pricing.js';
import type { ResolvedCliModel } from './cli-adapter.js';

/** Legacy explicit envelopes keep their worker-supplied pricing contract. */
export function budgetDiagnostics(flow: CompiledFlowSpec): PreflightRefusal[] {
/** Frozen dollar budgets require an exact model with a frozen table price. */
export function budgetDiagnostics(
flow: CompiledFlowSpec,
resolvedModels: ReadonlyMap<string, ResolvedCliModel> = new Map(),
): PreflightRefusal[] {
if (flow.budget?.pricing !== 'frozen') return [];
const diagnostics: PreflightRefusal[] = [];
const declared = [
Expand All @@ -13,12 +17,19 @@ export function budgetDiagnostics(flow: CompiledFlowSpec): PreflightRefusal[] {
];
for (const step of flow.steps) {
if (step.type === 'deterministic' || flow.budget.maxDollars === undefined) continue;
const model = step.model ?? (step.type === 'agent' && step.agent !== undefined
? flow.agents?.[step.agent]?.model : undefined);
const resolved = resolvedModels.get(step.id);
const model = resolved?.model
?? step.model ?? (step.type === 'agent' && step.agent !== undefined
? flow.agents?.[step.agent]?.model : undefined);
if (model === undefined) diagnostics.push({
severity: 'refusal', kind: 'budget_missing_price', stepId: step.id,
message: `Step "${step.id}" needs a declared, priced model for its dollar budget.`,
});
else if (resolved?.source === 'adapter'
&& !Object.hasOwn(MODEL_PRICING, model)) diagnostics.push({
severity: 'refusal', kind: 'budget_missing_price', stepId: step.id, model,
message: `Model "${model}" has no frozen price for budget accounting.`,
});
}
for (const declaration of declared) {
if (Object.hasOwn(MODEL_PRICING, declaration.model)) continue;
Expand Down
27 changes: 27 additions & 0 deletions packages/sdk/src/cli-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,33 @@ export function cliAdapterKind(executable: string): CliAdapterKind {
return resolveAdapterKind(executable);
}

export type CliModelSource = 'step' | 'named' | 'adapter';

export interface ResolvedCliModel {
readonly model?: string;
readonly source?: CliModelSource;
}

/**
* Resolve the model once, in authoring priority order: step, selected named
* agent, then the registered CLI adapter's default. Unregistered executables
* resolve through the wrapper adapter, whose absent default remains undefined.
*/
export function resolveCliModelSelection(
executable: string,
declarations: Readonly<{ step?: string; named?: string }> = {},
): ResolvedCliModel {
if (declarations.step !== undefined) return { model: declarations.step, source: 'step' };
if (declarations.named !== undefined) return { model: declarations.named, source: 'named' };
const model = registeredAdapters()[resolveAdapterKind(executable)].defaultModel;
return model === undefined ? {} : { model, source: 'adapter' };
}

/** Resolve a runtime model, where any materialized value is step-owned. */
export function resolveCliModel(executable: string, model?: string): string | undefined {
return resolveCliModelSelection(executable, { step: model }).model;
}

/** Prove the adapter command shape before classifying an auth failure. */
export function adapterIdentification(kind: CliAdapterKind): CliAdapterIdentification {
return registeredAdapters()[kind].buildIdentification();
Expand Down
4 changes: 3 additions & 1 deletion packages/sdk/src/cli/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,9 @@ function bindResolvedCliPaths(
const resolution = byStep.get(step.id);
if (resolution === undefined) return step;
const directory = resolution.source === 'project' ? configDirectory : flowDirectory;
return { ...step, cli: canonicalCli(resolution.cli, directory) };
return { ...step, cli: canonicalCli(resolution.cli, directory),
...(resolution.modelSource === 'adapter' && resolution.model !== undefined
? { model: resolution.model } : {}) };
}),
};
}
Expand Down
6 changes: 4 additions & 2 deletions packages/sdk/src/llm-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { JournalClient } from './journal-client.js';
import type { CompletionReason, StepDispatchEvent } from './protocol.js';
import type { KernelLlmStep } from './spec.js';
import { runAgentCli } from './worker-cli.js';
import { resolveCliModel } from './cli-adapter.js';
import { withWorkerLease } from './worker-lease.js';
import { workerInstruction } from './worker-input.js';
import { jsonSchemaOutputError } from './json-schema.js';
Expand Down Expand Up @@ -49,11 +50,12 @@ export class LlmWorker extends EventEmitter {
const schema = spec.verification?.json_schema;
const prompt = schema === undefined ? spec.prompt
: `${spec.prompt}\n\nReturn only a JSON value matching this JSON Schema (no Markdown fences):\n${JSON.stringify(schema)}`;
const effectiveModel = typeof spec.cli === 'string' ? resolveCliModel(spec.cli, spec.model) : spec.model;
const completed: WorkerCliResult = await withWorkerLease(this.client, dispatch, signal =>
typeof spec.cli === 'string' && typeof spec.prompt === 'string'
? runAgentCli(spec.cli, workerInstruction(prompt, dispatch), dispatch.wake_context, spec.model, undefined, signal, 'llm')
? runAgentCli(spec.cli, workerInstruction(prompt, dispatch), dispatch.wake_context, effectiveModel, undefined, signal, 'llm')
: Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'llm step has no declared CLI' }));
const { result, usage } = workerSpend(completed, spec.model);
const { result, usage } = workerSpend(completed, effectiveModel);
let reason: CompletionReason = result.exit_code === 0 ? 'success' : 'worker_error';
let output: unknown = result.stdout_tail;
let detail = result.stderr_tail;
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/model-pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
export const MODEL_PRICING: Readonly<Record<string, Readonly<{ input: number; output: number }>>> = Object.freeze({
'claude-sonnet-4-6': Object.freeze({ input: 3, output: 15 }),
'claude-opus-4-7': Object.freeze({ input: 15, output: 75 }),
'claude-opus-5': Object.freeze({ input: 5, output: 25 }),
'codex-medium': Object.freeze({ input: 2, output: 8 }),
'codex-large': Object.freeze({ input: 5, output: 20 }),
});
Expand Down
55 changes: 38 additions & 17 deletions packages/sdk/src/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { TriggerSource } from '@relayflows/surface';
import { acceptsAnyOutput, inspectStepGate, type StepGateInspection } from './gate-contract.js';
import { compileSpec, CompileError } from './compile.js';
import { helperCall } from './yaml-helpers.js';
import { resolveCliModelSelection, type CliModelSource } from './cli-adapter.js';
import { isNamedGate, NAMED_GATE_FAILURE_KINDS, type NamedGateFailureKind } from './named-gates.js';
import { compileScopes, type ScopeInput, type MountRegistry } from './scope-compiler.js';
import { readMountRegistry } from './mount-registry.js';
Expand All @@ -22,8 +23,10 @@ export interface CliResolution {
stepId: string;
cli: string;
source: CliResolutionSource;
/** Model the step or selected named agent declared, probed with the CLI. */
/** Effective model probed with the CLI. */
model?: string;
/** Exact source selected by step > named agent > adapter default priority. */
modelSource?: CliModelSource;
}

export interface CliProbeResult {
Expand Down Expand Up @@ -208,18 +211,17 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
};
}
const diagnostics: PreflightDiagnostic[] = [];
const cliResolutionDiagnostics: PreflightDiagnostic[] = [];
const resolutions: CliResolution[] = [];
const resolutionByStep = new Map<string, CliResolution>();
const cliProbeResults = new Map<string, CliProbeOutcome>();

diagnostics.push(...unknownModelDiagnostics(compiled, options));
diagnostics.push(...scopeDiagnostics(compiled, options));
for (const server of new Set(options.mcpServers ?? [])) {
if (options.mcp !== undefined && Object.hasOwn(options.mcp, server)) continue;
diagnostics.push({ severity: 'refusal', kind: 'mcp_undeclared_server', server,
message: `MCP server "${server}" is not declared in the nearest flows.json mcp map.` });
}
diagnostics.push(...budgetDiagnostics(compiled));
// Resolve the complete flow before touching any environment fact. A later
// statically unresolved CLI makes the whole submission impossible, so no
// earlier command, provider/model, or trigger probe may run first.
Expand All @@ -228,7 +230,7 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
if (step.type === 'agent' && helperCall(step) !== undefined) continue;
const resolution = resolveCli(step, compiled, options.projectCli);
if (resolution === undefined) {
diagnostics.push({
cliResolutionDiagnostics.push({
severity: 'refusal',
kind: 'cli_unresolved',
stepId: step.id,
Expand All @@ -239,6 +241,18 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
resolutionByStep.set(step.id, resolution);
}
}
diagnostics.push(...cliResolutionDiagnostics);
if (cliResolutionDiagnostics.length > 0) {
return { ok: false, gates: compiled.steps.map(inspectStepGate), resolutions, diagnostics };
}
diagnostics.push(...unknownModelDiagnostics(compiled, options, resolutionByStep));
diagnostics.push(...budgetDiagnostics(
compiled,
new Map(resolutions.map(resolution => [resolution.stepId, {
...(resolution.model === undefined ? {} : { model: resolution.model }),
...(resolution.modelSource === undefined ? {} : { source: resolution.modelSource }),
}])),
));
if (diagnostics.length > 0) {
return { ok: false, gates: compiled.steps.map(inspectStepGate), resolutions, diagnostics };
}
Expand Down Expand Up @@ -311,6 +325,7 @@ function scopeDiagnostics(flow: FlowSpec, options: PreflightOptions): PreflightR
function unknownModelDiagnostics(
flow: FlowSpec,
options: PreflightOptions,
resolutionByStep: ReadonlyMap<string, CliResolution> = new Map(),
): PreflightRefusal[] {
const diagnostics: PreflightRefusal[] = [];
// model_unknown is a governance check: it exists to enforce a project's
Expand Down Expand Up @@ -340,17 +355,21 @@ function unknownModelDiagnostics(
}

for (const step of flow.steps) {
if (step.type === 'deterministic' || step.model === undefined) continue;
if (isKnownModel(step.model, options.models)) continue;
if (step.type === 'deterministic') continue;
const resolution = resolutionByStep.get(step.id);
// Selected named declarations were checked once above, including unused
// declarations. Other sources are step declarations or adapter defaults.
if (resolution?.modelSource === 'named') continue;
const model = resolution?.model ?? step.model;
if (model === undefined || isKnownModel(model, options.models)) continue;
if (!enforceRegistry) continue;
const resolution = resolveCli(step, flow, options.projectCli);
diagnostics.push({
severity: 'refusal',
kind: 'model_unknown',
stepId: step.id,
...(resolution === undefined ? {} : { cli: resolution.cli }),
model: step.model,
message: unknownModelMessage(step.id, step.model, resolution?.cli, options.modelRegistryPath),
model,
message: unknownModelMessage(step.id, model, resolution?.cli, options.modelRegistryPath),
});
}

Expand Down Expand Up @@ -403,14 +422,16 @@ function resolveCli(
const named = step.type === 'agent' && step.agent !== undefined
? flow.agents?.[step.agent]
: undefined;
// Model comes only from the step or its explicitly selected declaration.
// There is deliberately no flow/project or host default.
const effectiveModel = step.model ?? named?.model;
const model = effectiveModel !== undefined ? { model: effectiveModel } : {};
if (step.cli !== undefined) return { stepId: step.id, cli: step.cli, source: 'step', ...model };
if (named !== undefined) return { stepId: step.id, cli: named.cli, source: 'named', ...model };
if (flow.cli !== undefined) return { stepId: step.id, cli: flow.cli, source: 'flow', ...model };
if (projectCli !== undefined) return { stepId: step.id, cli: projectCli, source: 'project', ...model };
const resolved = (cli: string, source: CliResolutionSource): CliResolution => {
const effectiveModel = resolveCliModelSelection(cli, { step: step.model, named: named?.model });
return { stepId: step.id, cli, source,
...(effectiveModel.model === undefined ? {} : { model: effectiveModel.model }),
...(effectiveModel.source === undefined ? {} : { modelSource: effectiveModel.source }) };
};
if (step.cli !== undefined) return resolved(step.cli, 'step');
if (named !== undefined) return resolved(named.cli, 'named');
if (flow.cli !== undefined) return resolved(flow.cli, 'flow');
if (projectCli !== undefined) return resolved(projectCli, 'project');
return undefined;
}

Expand Down
9 changes: 5 additions & 4 deletions packages/sdk/src/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,11 @@ export interface AgentStepSpec extends BaseStepSpec {
/** Inert preflight declaration; overrides the flow/project CLI default. */
cli?: string;
/**
* Model the declared CLI must use. Raw Claude/Codex adapters receive their
* real model flag; an identified Relayflows wrapper receives it in its
* same-process execution request. Declared here so the choice is journaled with the step
* instead of being ambient host state.
* Model the declared CLI must use. A step declaration wins over its selected
* named agent and any registered adapter default. Raw Claude/Codex adapters
* receive the effective model as a real flag; an identified Relayflows
* wrapper receives an explicitly declared model in its same-process request.
* The effective choice is journaled instead of being ambient host state.
*/
model?: string;
surfaces?: AgentSurfaces;
Expand Down
Loading
Loading