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
14 changes: 14 additions & 0 deletions middleware/packages/harness-orchestrator/src/buildOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,16 @@ export interface BuiltOrchestrator {
readonly orchestrator: Orchestrator;
/** The `chatAgent` bundle — verifier-wrapped agent + raw orchestrator. */
readonly bundle: ChatAgentBundle;
/**
* The resolved orchestrator model after the per-Agent overlay was applied —
* i.e. the actual id the turn loop will send to the provider for this Agent.
* Mirrors `config.model`; carried out so callers (registry log, /admin UI)
* can read it without re-computing the overlay. Issue #296 acceptance #4.
*/
readonly effectiveModel: string;
/** Same as `config.modelRouting` — surfaced so callers can describe the
* per-turn routing the Agent is on without inspecting the orchestrator. */
readonly effectiveModelRouting?: ModelRoutingConfig;
}

/**
Expand Down Expand Up @@ -331,6 +341,8 @@ export function buildOrchestratorForAgent(
sessionLogger,
chatSessionStore,
},
effectiveModel: config.model,
...(config.modelRouting ? { effectiveModelRouting: config.modelRouting } : {}),
};
}

Expand All @@ -356,5 +368,7 @@ export function buildOrchestratorForAgent(
return {
orchestrator,
bundle: { agent, raw: orchestrator, sessionLogger, chatSessionStore },
effectiveModel: config.model,
...(config.modelRouting ? { effectiveModelRouting: config.modelRouting } : {}),
};
}
7 changes: 6 additions & 1 deletion middleware/packages/harness-orchestrator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export type { ScopedMemoryStoreOptions } from './registry/scopedMemoryStore.js';
export {
ConfigStore,
ConfigValidationError,
validateModelRef,
validateModelRoutingShape,
} from './registry/configStore.js';
export type {
AgentInput,
Expand Down Expand Up @@ -123,7 +125,10 @@ export type {
SubAgentGraph,
SubAgentToolDeps,
} from './registry/subAgentTools.js';
export { resolveAgentModelRouting } from './registry/agentRuntime.js';
export {
DEFAULT_ORCHESTRATOR_MODEL,
resolveAgentModelRouting,
} from './registry/agentRuntime.js';
export type { ResolvedAgentRuntime } from './registry/agentRuntime.js';

// Per-Agent Orchestrator factory (US3) — re-exported so US4-style external
Expand Down
11 changes: 6 additions & 5 deletions middleware/packages/harness-orchestrator/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import type { TurnHookRunner } from './turnHooks.js';
import type { ChatSessionStore } from './chatSessionStore.js';
import type { NativeToolRegistry } from './nativeToolRegistry.js';
import type { Orchestrator } from './orchestrator.js';
import { DEFAULT_ORCHESTRATOR_MODEL } from './registry/agentRuntime.js';
import { ConfigStore } from './registry/configStore.js';
import {
OrchestratorRegistry,
Expand Down Expand Up @@ -139,11 +140,11 @@ const GRAPH_POOL_SERVICE = 'graphPool';
const PLUGIN_CAPABILITIES_SERVICE = 'pluginCapabilities';

// Fallback when the operator has not set `orchestrator_model` in the install
// config. Must be a currently-served Anthropic model id — a stale/typo'd id
// makes every turn fail with `404 not_found_error` (the orchestrator main
// loop has no other model source). Kept in sync with the kernel default
// `ORCHESTRATOR_MODEL` in middleware/src/config.ts.
const DEFAULT_MODEL = 'claude-opus-4-8';
// config. Shared with the registry's per-instance resolution (tier 3) so the
// install-config default and the per-Agent overlay fall back to the same id.
// Kept in sync with the kernel default `ORCHESTRATOR_MODEL` in
// middleware/src/config.ts.
const DEFAULT_MODEL = DEFAULT_ORCHESTRATOR_MODEL;
// 8192, not 4096: a verbose preamble + a large structured tool call (e.g. a
// multi-sheet create_xlsx with formulas) truncates at 4096 → `max_tokens`
// mid-tool-call, so the file is never built. Also enforced as a floor below so
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Pool } from 'pg';

import { ConfigValidationError } from './configStore.js';
import { ConfigValidationError, validateModelRef } from './configStore.js';

/**
* Agent Builder graph store (P0).
Expand Down Expand Up @@ -315,7 +315,17 @@ export class AgentGraphStore {
return rows.map(mapSubAgent);
}

async createSubAgent(input: SubAgentInput): Promise<SubAgentRow> {
async createSubAgent(
input: SubAgentInput,
activeProvider?: string,
): Promise<SubAgentRow> {
// Issue #296 follow-up — guard sub-agent model writes against unknown
// ids the same way `setModelRouting` guards the orchestrator model.
// Empty / null skips validation (= inherit parent agent / platform).
// `activeProvider` additionally rejects a cross-provider pick.
if (input.model != null && input.model.trim() !== '') {
validateModelRef('subAgent.model', input.model.trim(), activeProvider);
}
try {
const { rows } = await this.pool.query<SubAgentDbRow>(
`INSERT INTO agent_subagents
Expand All @@ -327,7 +337,10 @@ export class AgentGraphStore {
input.parentAgentId,
input.name,
input.skillId ?? null,
input.model ?? null,
// Normalise the empty-string "(default)" dropdown choice to NULL so
// the column stays clean (`resolveSubAgentModel('')` would inherit
// anyway, but `''` is dirty data).
input.model?.trim() || null,
input.maxTokens ?? null,
input.maxIterations ?? null,
input.systemPromptOverride ?? null,
Expand All @@ -346,12 +359,29 @@ export class AgentGraphStore {
}
}

async updateSubAgent(id: string, patch: SubAgentPatch): Promise<SubAgentRow> {
async updateSubAgent(
id: string,
patch: SubAgentPatch,
activeProvider?: string,
): Promise<SubAgentRow> {
// Issue #296 follow-up — see `createSubAgent` rationale.
// Non-empty string → validate + pin the model.
// `null` / empty → CLEAR back to inherit-parent (skip validation).
// Absent (undefined) → keep the existing value untouched.
if (patch.model != null && patch.model.trim() !== '') {
Comment thread
ConnysCode marked this conversation as resolved.
validateModelRef('subAgent.model', patch.model.trim(), activeProvider);
}
// `model` cannot use COALESCE: COALESCE(NULL, model) keeps the old value,
// so a `null` clear would be a silent no-op. Guard the write with an
// explicit "was model in the patch?" flag ($10) so `undefined` keeps and
// `null`/'' clears to NULL.
const modelProvided = patch.model !== undefined;
const modelValue = patch.model?.trim() || null;
const { rows } = await this.pool.query<SubAgentDbRow>(
`UPDATE agent_subagents SET
name = COALESCE($2, name),
skill_id = COALESCE($3, skill_id),
model = COALESCE($4, model),
model = CASE WHEN $10 THEN $4 ELSE model END,
max_tokens = COALESCE($5, max_tokens),
max_iterations = COALESCE($6, max_iterations),
system_prompt_override = COALESCE($7, system_prompt_override),
Expand All @@ -364,12 +394,13 @@ export class AgentGraphStore {
id,
patch.name ?? null,
patch.skillId ?? null,
patch.model ?? null,
modelValue,
patch.maxTokens ?? null,
patch.maxIterations ?? null,
patch.systemPromptOverride ?? null,
patch.status ?? null,
patch.position ? JSON.stringify(patch.position) : null,
modelProvided,
],
);
const row = rows[0];
Expand Down
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';

/**
Expand All @@ -15,7 +17,65 @@ import type { ModelRoutingConfig as RuntimeModelRouting } from '../modelRouter.j
* unit-testable without a DB.
*/

const DEFAULT_CLASSIFIER_MODEL = 'claude-haiku-4-5';
// Must be an id the registry actually serves: `validateModelRef` rejects an
// unregistered ref, so the code's own default must agree with its write-
// validation. The registered Haiku is the dated id (alias `haiku`); the
// undated `claude-haiku-4-5` is NOT in the registry (issue #296 nit).
const DEFAULT_CLASSIFIER_MODEL = 'claude-haiku-4-5-20251001';

/**
* 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. */
Expand Down
63 changes: 59 additions & 4 deletions middleware/packages/harness-orchestrator/src/registry/applyDiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -160,18 +164,69 @@ 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 is resolved like any other — its models ARE registered
// (`claude-cli:opus-cli` etc.), so `resolveModelIdForProvider('claude-cli:opus-cli',
// 'claude-cli')` → `opus-cli` → (`-cli` stripped downstream) → `opus`, the
// alias the CLI expects. The old code special-cased CLI to pass the ref RAW,
// which left the picker's `claude-cli:opus-cli` unresolved → `claude-cli:opus`
// after the strip → an invalid `--model` on every turn (issue #296 BLOCKER).
//
// A ref the resolver returns `undefined` for (a registry-known CROSS-provider
// id, or a legacy bare CLI alias like `opus`) falls through to the raw trimmed
// ref. Cross-provider picks are rejected at WRITE time (the model-routing /
// sub-agent validators are scoped to the active provider, issue #296 MAJOR),
// so a cross-provider ref never reaches here for a fresh write; the raw
// fallthrough is what lets a CLI deployment's bare alias (`opus`) run. A
// registry-UNKNOWN same-context ref is already returned raw by the resolver.
// The platform default (`runtime.model`, operator-set env) is not passed
// through here — it works raw today and resolving it would change established
// behaviour.
const activeProvider = deps.provider?.id;
const resolveOverlay = (ref: string | undefined): string | undefined =>
(resolveModelIdForProvider(ref, activeProvider) ?? ref?.trim()) || undefined;

// 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 }
Expand Down
Loading
Loading