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
10 changes: 10 additions & 0 deletions packages/sdk/src/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,12 +181,22 @@ function unknownModelDiagnostics(
options: PreflightOptions,
): PreflightRefusal[] {
const diagnostics: PreflightRefusal[] = [];
// model_unknown is a governance check: it exists to enforce a project's
// registry-declared allowlist. When no flows.json is found, `check.ts` sends
// `models: []` with `modelRegistryPath: undefined` — an empty list not
// because the project forbids everything, but because no policy exists.
// Refusing an inline `agents: { drafter: { cli, model } }` declaration in
// that state forces every self-contained example flow to ship a second file.
// A real allowlist (even an empty one from a found flows.json) still
// enforces; that state is signalled by modelRegistryPath.
const enforceRegistry = options.modelRegistryPath !== undefined;

// Named declarations remain in the normalized authoring object until this
// boundary so even unused or step-shadowed models are checked. toKernelSpec
// erases the map and selector only after this pass has had a chance to fail.
for (const [agent, declaration] of Object.entries(flow.agents ?? {})) {
if (isKnownModel(declaration.model, options.models)) continue;
if (!enforceRegistry) continue;
diagnostics.push({
severity: 'refusal',
kind: 'model_unknown',
Expand Down
66 changes: 66 additions & 0 deletions packages/sdk/tests/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ describe('preflight: CLI resolution and refusal predicates', () => {
}],
}), {
models: ['known-model'],
modelRegistryPath: '/project/flows.json',
probes: probes({
cli: () => {
probeCalls += 1;
Expand Down Expand Up @@ -482,6 +483,7 @@ describe('preflight: CLI resolution and refusal predicates', () => {

const result = preflight(compiled, {
models: ['known-model'],
modelRegistryPath: '/project/flows.json',
probes: probes({ cli: () => {
probeCalls += 1;
return { exists: true, authenticated: true, modelAvailable: true };
Expand All @@ -497,4 +499,68 @@ describe('preflight: CLI resolution and refusal predicates', () => {
expect(toKernelSpec(compiled)).not.toHaveProperty('agents');
},
);

it('accepts an inline named-agent model when no flows.json registry is found', () => {
// #263: a self-contained flow that declares agents inline should validate
// without a mandatory external flows.json allowlist. The CLI+model probe
// still runs (below); model_unknown is a *governance* refusal about a
// registry-declared allowlist, and no registry means no policy to enforce.
let probeCalls = 0;
const result = preflight({
version: '0.1.0',
agents: { drafter: { cli: 'claude', model: 'claude-sonnet-5' } },
steps: [{
id: 'draft',
type: 'agent',
agent: 'drafter',
instruction: 'Draft.',
}],
}, {
// Exactly what check.ts sends when readProjectConfig finds no flows.json:
// models is empty and modelRegistryPath is absent.
models: [],
probes: probes({
cli: () => {
probeCalls += 1;
return { exists: true, authenticated: true, modelAvailable: true };
},
}),
});

expect(result.ok).toBe(true);
expect(result.diagnostics).toEqual([]);
expect(result.resolutions).toEqual([{
stepId: 'draft',
cli: 'claude',
source: 'named',
model: 'claude-sonnet-5',
}]);
// The probe still ran: model authority does not skip auth verification.
expect(probeCalls).toBe(1);
});

it('still refuses an inline named-agent model when a registry IS present and disallows it', () => {
// Governance semantics preserved: once flows.json declares an allowlist,
// an inline model outside it is still model_unknown. The relaxation in the
// previous test is *only* for the no-registry state.
const result = preflight({
version: '0.1.0',
agents: { drafter: { cli: 'claude', model: 'claude-sonnet-5' } },
steps: [{
id: 'draft',
type: 'agent',
agent: 'drafter',
instruction: 'Draft.',
}],
}, {
models: ['claude-sonnet-4'],
modelRegistryPath: '/project/flows.json',
probes: probes(),
});

expect(result.ok).toBe(false);
expect(result.diagnostics).toEqual([
expect.objectContaining({ kind: 'model_unknown', agent: 'drafter', model: 'claude-sonnet-5' }),
]);
});
});
Loading