feat(llm): route adapters by provider

This commit is contained in:
Yichen Jiang
2026-07-14 21:57:52 +08:00
parent a0359bc4a9
commit e547980d77
218 changed files with 2605 additions and 1844 deletions

View File

@@ -57,10 +57,10 @@ type ResolvedConfig = Required<Config>
*/
const DESCRIPTION = `Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.
The workflow's identity rides the \`meta\` parameter as JSON: required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, model?}\`). The \`script\` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO \`export const meta\` statement — meta is a parameter, not code), running with top-level await; end with \`return <value>\` — the value must be JSON-serializable and is this tool's result.
The workflow's identity rides the \`meta\` parameter as JSON: required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, provider?, model?}\`). The \`script\` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO \`export const meta\` statement — meta is a parameter, not code), running with top-level await; end with \`return <value>\` — the value must be JSON-serializable and is this tool's result.
Script-body hooks:
- \`agent(prompt, opts?): Promise<any>\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), \`model\` (override). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly.
- \`agent(prompt, opts?): Promise<any>\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), \`provider\` and \`model\` (paired LLM target overrides). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly.
- \`pipeline(items, ...stages): Promise<any[]>\` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives \`(prev, item, index)\`. An ordinary stage throw drops that ITEM to \`null\` and skips its remaining stages.
- \`parallel(thunks): Promise<any[]>\` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to \`null\`.
- \`phase(title)\` — start a progress phase; \`log(message)\` — narrate progress; \`args\` — the tool call's \`args\` input, verbatim.
@@ -71,7 +71,12 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim
type WorkflowCallArgs = {
script: string
meta: { name: string; description: string; whenToUse?: string; phases?: { title: string; detail?: string; model?: string }[] }
meta: {
name: string
description: string
whenToUse?: string
phases?: { title: string; detail?: string; provider?: string; model?: string }[]
}
args?: Record<string, unknown>
}
@@ -153,6 +158,7 @@ export function apply(ctx: Context, config: Config): void {
properties: {
title: { type: 'string', required: true, description: 'The phase title phase() calls match by exact string.' },
detail: { type: 'string', description: 'Optional one-line description of the phase.' },
provider: { type: 'string', description: 'Optional provider override this phase is expected to use.' },
model: { type: 'string', description: 'Optional model override this phase is expected to use.' },
},
},

View File

@@ -388,7 +388,14 @@ export class WorkerRun implements WorkflowRun {
parent: this.parent,
signal: this.controller.signal,
...request.schema !== undefined ? { outputSchema: request.schema } : {},
...request.model !== undefined ? { agentOptions: { model: request.model } } : {},
...request.provider !== undefined || request.model !== undefined
? {
agentOptions: {
...request.provider !== undefined ? { provider: request.provider } : {},
...request.model !== undefined ? { model: request.model } : {},
},
}
: {},
})
} catch (error: unknown) {
const failure = this.childAdmissionFailure()

View File

@@ -40,15 +40,17 @@ function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: st
}
const entry = phase as Record<string, unknown>
for (const key of Object.keys(entry)) {
if (!['title', 'detail', 'model'].includes(key)) violations.push(`meta.phases[${index}].${key} is not a recognized field`)
if (!['title', 'detail', 'provider', 'model'].includes(key)) violations.push(`meta.phases[${index}].${key} is not a recognized field`)
}
if (typeof entry.title !== 'string' || entry.title.length === 0) violations.push(`meta.phases[${index}].title must be a non-empty string`)
if (entry.detail !== undefined && typeof entry.detail !== 'string') violations.push(`meta.phases[${index}].detail must be a string`)
if (entry.provider !== undefined && typeof entry.provider !== 'string') violations.push(`meta.phases[${index}].provider must be a string`)
if (entry.model !== undefined && typeof entry.model !== 'string') violations.push(`meta.phases[${index}].model must be a string`)
if (violations.length === 0) {
phases.push({
title: entry.title as string,
...entry.detail !== undefined ? { detail: entry.detail as string } : {},
...entry.provider !== undefined ? { provider: entry.provider as string } : {},
...entry.model !== undefined ? { model: entry.model as string } : {},
})
}

View File

@@ -61,7 +61,7 @@ export interface ExecutionObserver {
}
/** The `agent()` options the script may pass; everything else rejects loud. */
const SUPPORTED_AGENT_OPTIONS = new Set(['label', 'phase', 'schema', 'model'])
const SUPPORTED_AGENT_OPTIONS = new Set(['label', 'phase', 'schema', 'provider', 'model'])
/** Deferred Claude Code options we name explicitly in the rejection message. */
const DEFERRED_AGENT_OPTIONS = new Set(['effort', 'isolation', 'agentType'])
@@ -302,6 +302,7 @@ export class WorkflowExecution {
run = await this.children.startAgent({
prompt: rawPrompt,
...opts.schema !== undefined ? { schema: opts.schema } : {},
...opts.provider !== undefined ? { provider: opts.provider } : {},
...opts.model !== undefined ? { model: opts.model } : {},
})
} catch (error: unknown) {
@@ -369,7 +370,13 @@ export class WorkflowExecution {
}
/** Materialize + validate the `agent()` options bag from the realm. */
private readAgentOptions(rawOpts: unknown): { label?: string; phase?: string; model?: string; schema?: StructuredOutputSchema } {
private readAgentOptions(rawOpts: unknown): {
label?: string
phase?: string
provider?: string
model?: string
schema?: StructuredOutputSchema
} {
if (rawOpts === undefined) return {}
let opts: unknown
try {
@@ -388,9 +395,9 @@ export class WorkflowExecution {
if (DEFERRED_AGENT_OPTIONS.has(key)) {
throw new WorkflowError(`agent() option "${key}" is deferred and not supported by this engine (supported: label, phase, schema, model)`, 'UNSUPPORTED_OPTION')
}
throw new WorkflowError(`agent() option "${key}" is not recognized (supported: label, phase, schema, model)`, 'UNSUPPORTED_OPTION')
throw new WorkflowError(`agent() option "${key}" is not recognized (supported: label, phase, schema, provider, model)`, 'UNSUPPORTED_OPTION')
}
for (const key of ['label', 'phase', 'model'] as const) {
for (const key of ['label', 'phase', 'provider', 'model'] as const) {
if (record[key] !== undefined && typeof record[key] !== 'string') {
throw new WorkflowError(`agent() option "${key}" must be a string`, 'INVALID_ARGUMENT')
}
@@ -409,6 +416,7 @@ export class WorkflowExecution {
return {
...record.label !== undefined ? { label: record.label as string } : {},
...record.phase !== undefined ? { phase: record.phase as string } : {},
...record.provider !== undefined ? { provider: record.provider as string } : {},
...record.model !== undefined ? { model: record.model as string } : {},
...schema !== undefined ? { schema } : {},
}

View File

@@ -46,6 +46,8 @@ export interface ChildStartRequest {
prompt: string
/** The structured-output schema, if the call passed one (already subset-checked). */
schema?: StructuredOutputSchema
/** The per-child provider override, if the call passed one. */
provider?: string
/** The per-child model override, if the call passed one. */
model?: string
}

View File

@@ -37,7 +37,7 @@ async function setup(script: Script) {
await ctx.plugin(spawn, { providerName: 'spawn' })
await ctx.plugin(WorkerWorkflowEngine, {})
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent, adapter }
}

View File

@@ -33,7 +33,7 @@ describe('validateMeta', () => {
description: 'migrate call sites',
whenToUse: 'large mechanical sweeps',
phases: [
{ title: 'Discover' },
{ title: 'Discover', provider: 'openai' },
{ title: 'Transform', detail: 'one agent per file', model: 'deepseek-v4-pro' },
],
})
@@ -42,7 +42,7 @@ describe('validateMeta', () => {
description: 'migrate call sites',
whenToUse: 'large mechanical sweeps',
phases: [
{ title: 'Discover' },
{ title: 'Discover', provider: 'openai' },
{ title: 'Transform', detail: 'one agent per file', model: 'deepseek-v4-pro' },
],
})
@@ -73,6 +73,7 @@ describe('validateMeta', () => {
expectInvalid({ name: 'x', description: 'd', phases: [{ title: '' }] }, 'meta.phases[0].title must be a non-empty string')
expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', order: 1 }] }, 'meta.phases[0].order is not a recognized field')
expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', detail: 9 }] }, 'meta.phases[0].detail must be a string')
expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', provider: 9 }] }, 'meta.phases[0].provider must be a string')
expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', model: 9 }] }, 'meta.phases[0].model must be a string')
})

View File

@@ -35,7 +35,7 @@ interface FakeHost {
interface FakeHostOptions {
/** Auto-respond to child-start: reply started + settled per child index. Omit a reply to leave the child pending. */
reply?: (request: { prompt: string; schema?: unknown; model?: string }, index: number) => ChildResult | undefined
reply?: (request: { prompt: string; schema?: unknown; provider?: string; model?: string }, index: number) => ChildResult | undefined
/** Reject the start instead (child-start-error) when returning a string. */
refuse?: (index: number) => string | undefined
/** Auto-send `go` on `ready` (default true). */
@@ -143,6 +143,17 @@ describe('runWorkerSession over an in-process MessageChannel', () => {
host.close()
})
it('agent({provider}) forwards a provider without inventing a model', async () => {
const host = fakeHost({ reply: () => text('ok') })
void runWorkerSession(host.port, init("return await agent('route me', { provider: 'openai' })"))
const result = await host.result()
expect(result.value).toBe('ok')
const start = host.ofType(WorkerToHostType.ChildStart)[0]!
expect(start.request.provider).toBe('openai')
expect(start.request.model).toBeUndefined()
host.close()
})
it('a schema child completing WITHOUT a structured value resolves null with a failed outcome', async () => {
const host = fakeHost({ reply: () => text('prose, no structure') })
void runWorkerSession(host.port, init("return await agent('p', { schema: { type: 'object' } })"))

View File

@@ -35,7 +35,7 @@ async function harness(): Promise<Context> {
await built.plugin(ToolRegistry)
await built.plugin(AgentRegistry)
await built.plugin(AgentLoop, { agents: [] })
await built.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
await built.plugin(LlmDeepSeek)
await built.plugin(SubagentService)
await built.plugin(Spawn, { providerName: 'spawn' })
await built.plugin(WorkerWorkflowEngine, { provider: 'spawn' })
@@ -64,7 +64,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key
const parentHandle = await ctx.agents.create({
agentId: AgentId('wf-worker-e2e-parent'),
sessionId: 'wf-worker-e2e-session' as never,
agentOptions: { model: 'deepseek-v4-flash' },
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
})
const events: string[] = []

View File

@@ -236,6 +236,14 @@ describe('dsh-workflow-workerthread', () => {
expect(provider.runs[0]!.request.parent).toBeDefined()
})
it('agent({provider}) forwards provider-only agentOptions across the thread', async () => {
const { ctx, parent, provider } = await setup()
const result = await run(ctx, parent, scripted("return await agent('route me', { provider: 'openai' })"))
expect(result.value).toBe('stub reply')
expect(provider.runs[0]!.request.agentOptions).toEqual({ provider: 'openai' })
})
it('a fatal hook error inside the worker kills the script and reports the error', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, scripted("return await parallel([() => agent('x', { isolation: 'worktree' })])"))

View File

@@ -30,6 +30,8 @@ export interface WorkflowPhase {
title: string
/** Optional one-line description of what the phase does. */
detail?: string
/** Optional provider override this phase is expected to use (informational). */
provider?: string
/** Optional model override this phase is expected to use (informational). */
model?: string
}