-
Notifications
You must be signed in to change notification settings - Fork 4
feat(orchestrator): per-Agent LLM model selection #387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| import { resolveModelRef } from '@omadia/llm-provider'; | ||
|
|
||
| import type { ModelRoutingConfig as RuntimeModelRouting } from '../modelRouter.js'; | ||
|
|
||
| /** | ||
|
|
@@ -17,6 +19,60 @@ import type { ModelRoutingConfig as RuntimeModelRouting } from '../modelRouter.j | |
|
|
||
| const DEFAULT_CLASSIFIER_MODEL = 'claude-haiku-4-5'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MINOR — the default classifier is an id your own validator would reject. |
||
|
|
||
| /** | ||
| * Hard fallback orchestrator model — the last tier of the per-instance model | ||
| * resolution (issue #296 AC#2): | ||
| * | ||
| * 1. the Agent's own `model_routing.main` (operator's per-instance choice) | ||
| * 2. the global seeded platform default (`orchestrator_model` install config, | ||
| * itself seeded from the `ORCHESTRATOR_MODEL` env in middleware/src/config.ts) | ||
| * 3. this constant — so an empty / misconfigured platform default never yields | ||
| * an empty model id (which would 404 on every turn). | ||
| * | ||
| * Must be a currently-served model id, kept in sync with `ORCHESTRATOR_MODEL` | ||
| * (middleware/src/config.ts) and the plugin's install-config fallback. | ||
| */ | ||
| export const DEFAULT_ORCHESTRATOR_MODEL = 'claude-opus-4-8'; | ||
|
|
||
| /** | ||
| * Resolve a model ref to the active provider's concrete bare `modelId` | ||
| * (issue #296). | ||
| * | ||
| * Both the orchestrator main loop AND in-process sub-agents send `model` RAW to | ||
| * a single concrete provider adapter — there is no ref→modelId resolution in | ||
| * the send path. The Admin picker stores a provider-qualified id | ||
| * (`anthropic:claude-opus-4-8`) or a legacy alias (`opus`); sending either raw | ||
| * 404s every turn. Returns: | ||
| * - registry-known, same provider → the bare vendor `modelId` | ||
| * - registry-known, DIFFERENT provider than `activeProvider` → `undefined` | ||
| * (cross-provider is out of scope and would 404 on the wrong adapter — the | ||
| * caller falls back to its own default) | ||
| * - registry-UNKNOWN (not in the curated set) → the raw trimmed ref. The | ||
| * registry is a curated subset, not the universe of valid API ids — an id | ||
| * the registry does not list may still be served (e.g. an undated default | ||
| * or an operator-typed id). Passing it through preserves pre-resolution | ||
| * behaviour, matching the `resolveModelRef(x)?.modelId ?? x` contract used | ||
| * elsewhere (e.g. `builderPreviewPrompt`). | ||
| * - empty / whitespace → `undefined` (no model specified → caller falls back) | ||
| * | ||
| * The CLI provider owns its own alias scheme (`sonnet`/`opus`) and must be | ||
| * handled by the caller BEFORE this — pass its refs through untouched. | ||
| */ | ||
| export function resolveModelIdForProvider( | ||
| ref: string | null | undefined, | ||
| activeProvider: string | undefined, | ||
| ): string | undefined { | ||
| const trimmed = ref?.trim(); | ||
| if (!trimmed) return undefined; | ||
| const info = resolveModelRef( | ||
| trimmed, | ||
| activeProvider ? { defaultProvider: activeProvider } : {}, | ||
| ); | ||
| if (info === undefined) return trimmed; | ||
| if (activeProvider && info.provider !== activeProvider) return undefined; | ||
| return info.modelId; | ||
| } | ||
|
|
||
| export interface ResolvedAgentRuntime { | ||
| /** Primary model override (the agent's `main`), if set. */ | ||
| readonly model?: string; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,11 @@ import type { | |
| SubAgentRow, | ||
| ToolGrantRow, | ||
| } from './agentGraphStore.js'; | ||
| import { resolveAgentModelRouting } from './agentRuntime.js'; | ||
| import { | ||
| DEFAULT_ORCHESTRATOR_MODEL, | ||
| resolveAgentModelRouting, | ||
| resolveModelIdForProvider, | ||
| } from './agentRuntime.js'; | ||
| import type { | ||
| AgentPluginRow, | ||
| AgentRow, | ||
|
|
@@ -160,18 +164,56 @@ export function buildForAgent( | |
| // platform default: `main` overrides the model, `triage` mode adds per-turn | ||
| // Haiku→Sonnet/Opus routing. Falls back to the platform runtime when unset. | ||
| const routing = resolveAgentModelRouting(agent.modelRouting); | ||
|
|
||
| // The orchestrator hands `model` to a SINGLE concrete provider adapter, which | ||
| // sends it RAW to the wire API (no ref→modelId resolution in the send path). | ||
| // The Admin picker stores a provider-qualified id / alias, so resolve the | ||
| // per-Agent overlay to the active provider's concrete `modelId` HERE — see | ||
| // `resolveModelIdForProvider` (issue #296). The CLI provider owns its own | ||
| // alias scheme so its refs pass through untouched; the platform default | ||
| // (`runtime.model`, operator-set env) is left as-is — it works raw today and | ||
| // resolving it would change established behaviour. | ||
| const activeProvider = deps.provider?.id; | ||
| const resolveOverlay = (ref: string | undefined): string | undefined => | ||
| activeProvider === 'claude-cli' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BLOCKER (claude-cli deployments) — provider-qualified picker ids reach the CLI unresolved, so every per-Agent turn fails. This Please route the CLI branch through the resolver as well, so it yields the bare const resolveOverlay = (ref: string | undefined): string | undefined =>
resolveModelIdForProvider(ref, activeProvider) ?? ref?.trim() || undefined;
|
||
| ? ref?.trim() || undefined | ||
| : resolveModelIdForProvider(ref, activeProvider); | ||
|
|
||
| // Per-instance model resolution (issue #296 AC#2), three tiers: | ||
| // 1. the Agent's `model_routing.main` (operator's per-Agent choice) | ||
| // 2. the global seeded platform default `runtime.model` | ||
| // (= `orchestrator_model` install config = `ORCHESTRATOR_MODEL` env) | ||
| // 3. `DEFAULT_ORCHESTRATOR_MODEL` — guards against an empty / whitespace | ||
| // platform default so the turn loop never gets an empty model id. | ||
| const model = | ||
| resolveOverlay(routing.model) || | ||
| runtime.model?.trim() || | ||
| DEFAULT_ORCHESTRATOR_MODEL; | ||
|
|
||
| // Resolve the per-turn routing sub-models the same way. Any sub-model that | ||
| // does not resolve to the active provider falls back to the resolved `model` | ||
| // so every id the turn loop sends is a valid same-provider `modelId`. | ||
| const overlayRouting = routing.modelRouting | ||
| ? { | ||
| classifierModel: | ||
| resolveOverlay(routing.modelRouting.classifierModel) ?? model, | ||
| simpleModel: resolveOverlay(routing.modelRouting.simpleModel) ?? model, | ||
| complexModel: resolveOverlay(routing.modelRouting.complexModel) ?? model, | ||
| } | ||
| : undefined; | ||
|
|
||
| return buildOrchestratorForAgent( | ||
| { | ||
| agentId: agent.slug, | ||
| model: routing.model ?? runtime.model, | ||
| model, | ||
| maxTokens: runtime.maxTokens, | ||
| maxToolIterations: runtime.maxToolIterations, | ||
| // Per-turn model routing: prefer the agent's own persisted routing | ||
| // (Agent Builder P5); otherwise fall back to the platform default | ||
| // `runtime.modelRouting` so registry-managed orchestrators still emit | ||
| // `turn_routing` and the UI renders the Haiku-triage badge (origin/main). | ||
| ...((routing.modelRouting ?? runtime.modelRouting) | ||
| ? { modelRouting: routing.modelRouting ?? runtime.modelRouting } | ||
| ...((overlayRouting ?? runtime.modelRouting) | ||
| ? { modelRouting: overlayRouting ?? runtime.modelRouting } | ||
| : {}), | ||
| ...(runtime.loopRepeatSoft !== undefined | ||
| ? { loopRepeatSoft: runtime.loopRepeatSoft } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MAJOR — a pinned sub-agent model can never be cleared back to "inherit".
This comment says
nullis the wire form for "clear", but theUPDATEbelow usesmodel = COALESCE($4, model)(line 366) with$4 = patch.model ?? null(line 379).COALESCE(NULL, model)keeps the existing value, so passingmodel: nullis a no-op — an operator can't revert a sub-agent from a pinned model back to inherit-parent. (The siblingsetSubAgentSkilluses a direct write precisely becauseCOALESCEcan't clear a column; there's no equivalent formodel.)Separately,
createSubAgentpersists''rather thanNULLfor the empty-string "(default)" choice —input.model ?? null(line 336) doesn't catch''. Benign at runtime (resolveSubAgentModel('')→ parent default) but it leaves dirty data.Please give
modela real clear path: branch onpatch.model === null→ writemodel = NULL(or add a dedicatedclearSubAgentModeldirect write, mirroringsetSubAgentSkill), and bindinput.model?.trim() || nullincreateSubAgentso the column stays clean.