Files
AgentCompany/src/main/model-dispatch.ts
T
ZacharyZhang-NY cdf783c0b8 fix(dispatch): fail explicitly when no enabled model qualifies
`pickModelForAgent` used to fall back to the CEO's pair when the
auto-strategy scoring returned no winner, and then silently route
the run on that pair. Three distinct problems rendered invisible by
that fallback: a missing capability row for a deployed model, a
mis-inferred task profile, a misconfigured model pool. Cost
attribution became meaningless — the agent ran on the CEO's model,
not its own tier — and operators had no signal to fix the root
cause.

The picker now returns a `ModelPickResult` tagged union. Success
yields the dispatch-ready assignment; failure yields a
`NO_QUALIFYING_CANDIDATE` reason with a human-readable message
naming the agent, the required domain, and the required tier.

Heartbeat maps the failure to a new `model_pick_failed` skip
reason, logs an activity row so it shows up in the company audit
trail, and defers. Task-runner returns `fail("MODEL_PICK_FAILED",
...)` so the UI surfaces the message directly. `getCeoAssignment`
drops out of `PickModelDeps` entirely — no caller needs a silent
CEO path any more, so the dep surface shrinks and the tests do
too. The test suite gains a regression assertion for the explicit
failure case.
2026-04-19 01:06:50 -04:00

222 lines
7.9 KiB
TypeScript

import log from "electron-log/main.js";
import type {
AgentRecord,
ConnectorId,
ConnectorRecord,
ModelCapability,
TaskTier,
} from "@shared/types";
import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy";
import { getConnectorDefinition } from "./connectors";
import { getConnectorModelPolicy } from "./connector-model-policy";
import { inferTaskProfile } from "./agent-role-helpers";
/**
* Resolves which (provider, modelId) to use for an agent at run dispatch.
*
* Rules:
*
* 1. `explicit` strategy, or a CEO role (`reportsTo IS NULL` /
* `roleLevel === 'c_suite'`): use the agent's own connectorId +
* model verbatim.
*
* 2. The enabled (connector, model) universe has exactly one pair:
* use it. Single-model installs bypass scoring entirely.
*
* 3. `auto` strategy with multiple candidates: infer the agent's
* task profile (domain ∈ reasoning|coding|writing, required tier
* ∈ trivial|standard|hard), keep candidates that clear the domain
* score floor and the tier rank floor, and pick the cheapest.
*
* 4. `auto` strategy with no qualifying candidate: fail. Silently
* routing to the CEO's pair hid three distinct problems — a
* missing capability row, a mis-inferred profile, a misconfigured
* model pool — and made cost attribution meaningless. The caller
* converts the `ModelPickError` into a visible eligibility
* failure so operators see the real issue instead of "the agent
* ran on an unexpected model for a week".
*/
export interface EnabledModel {
provider: string;
connectorId: ConnectorId;
modelId: string;
}
export interface AgentAssignment {
connectorId: ConnectorId;
modelId: string | null;
}
/**
* Tagged union returned by `pickModelForAgent`. Callers must branch on
* `ok` — success yields a dispatch-ready assignment; failure yields a
* reason code that `getRunEligibilityFailure`'s surrounding handler
* turns into a user-visible eligibility error.
*/
export type ModelPickResult =
| { ok: true; assignment: AgentAssignment }
| { ok: false; reason: "NO_QUALIFYING_CANDIDATE"; message: string };
export interface PickModelDeps {
getConnector: (id: ConnectorId) => ConnectorRecord;
getDisabledModels: (companyId: string, connectorId: string) => Array<{ id: string; label: string }>;
listModelCapabilities: () => ModelCapability[];
readConnectors: () => ConnectorRecord[];
}
/**
* Minimum numeric score per tier — the model must score *at least* this on
* the task's domain axis. Higher tiers demand strictly higher scores; a
* tier-3 (hard) task refuses any model that scores below 4 on its domain
* regardless of the model's own tier flag. Tuned to the 5-grade rubric.
*/
const DOMAIN_SCORE_FLOOR: Record<TaskTier, number> = {
trivial: 2,
standard: 3,
hard: 4,
};
const TIER_RANK: Record<TaskTier, number> = {
trivial: 1,
standard: 2,
hard: 3,
};
/**
* Walk every connector the user has configured, keep the ones that are
* execution-ready (authenticated, healthy), and expand each to its enabled
* model list. Returns a flat list of (provider, connectorId, modelId) triples
* — the concrete universe the picker chooses from.
*/
export function listEnabledModels(companyId: string, deps: PickModelDeps): EnabledModel[] {
const result: EnabledModel[] = [];
for (const connector of deps.readConnectors()) {
if (getConnectorExecutionReadinessIssue(connector)) continue;
let definition;
try {
definition = getConnectorDefinition(connector.id);
} catch (err) {
log.warn(`[model-dispatch] Skipping unknown connector ${connector.id}:`, err instanceof Error ? err.message : String(err));
continue;
}
const policy = getConnectorModelPolicy(deps, companyId, connector.id as ConnectorId);
for (const model of policy.enabledModels) {
result.push({
provider: definition.provider,
connectorId: connector.id as ConnectorId,
modelId: model.id,
});
}
}
return result;
}
/**
* Pure scoring step. Given the agent's inferred profile, the capability
* matrix, and the concrete list of enabled (provider, modelId) pairs,
* pick the cheapest qualifying model — or return `null` when nothing
* clears the bar so the caller can apply its fallback.
*
* Split out from `pickModelForAgent` so unit tests can exercise the
* rubric without having to stand up the real connector catalog or
* policy lookups.
*/
export function pickFromCandidates(
profile: { domain: "reasoning" | "coding" | "writing"; requiredTier: TaskTier },
enabled: EnabledModel[],
capabilities: ModelCapability[],
): EnabledModel | null {
if (enabled.length === 0) return null;
const domainFloor = DOMAIN_SCORE_FLOOR[profile.requiredTier];
const tierFloor = TIER_RANK[profile.requiredTier];
const capabilityByKey = new Map<string, ModelCapability>();
for (const cap of capabilities) {
capabilityByKey.set(`${cap.provider}::${cap.modelId}`, cap);
}
const qualifying: Array<{ model: EnabledModel; cap: ModelCapability }> = [];
for (const model of enabled) {
const cap = capabilityByKey.get(`${model.provider}::${model.modelId}`);
if (!cap) continue;
if (cap[profile.domain] < domainFloor) continue;
if (TIER_RANK[cap.tier] < tierFloor) continue;
qualifying.push({ model, cap });
}
if (qualifying.length === 0) return null;
qualifying.sort((a, b) => a.cap.costPerMtokOut - b.cap.costPerMtokOut);
return qualifying[0]!.model;
}
/**
* The one-call entry point that run dispatch uses. Returns either a
* dispatch-ready assignment or a structured failure so callers can
* translate the miss into an eligibility error — no more silent CEO
* fallback.
*/
export function pickModelForAgent(
agent: AgentRecord,
deps: PickModelDeps,
): ModelPickResult {
const strategy = agent.modelStrategy ?? "explicit";
// CEO is pinned to the user's choice. The canonical CEO marker is
// `reportsTo === null` — the DB invariant (`getCeoForCompany` SELECTs on
// the same predicate), so this holds even when `roleLevel` is still the
// schema default 'ic' on an agent that was never tagged with the full
// seniority enum. We also accept the enum for completeness.
const isCeo = agent.reportsTo === null || agent.roleLevel === "c_suite";
if (strategy === "explicit" || isCeo) {
return {
ok: true,
assignment: { connectorId: agent.connectorId, modelId: agent.model ?? null },
};
}
const enabled = listEnabledModels(agent.companyId, deps);
// Single-model install: there's nothing to pick from. Use it.
if (enabled.length === 1) {
const only = enabled[0]!;
return { ok: true, assignment: { connectorId: only.connectorId, modelId: only.modelId } };
}
// Empty universe → fall back to whatever is on the agent row. This can
// only happen when every connector is unhealthy; the eligibility checks
// higher up in run dispatch will still block the run, but we return
// something sensible so the caller's contract stays total.
if (enabled.length === 0) {
return {
ok: true,
assignment: { connectorId: agent.connectorId, modelId: agent.model ?? null },
};
}
const profile = inferTaskProfile(agent.role, agent.title, agent.roleLevel);
const winner = pickFromCandidates(profile, enabled, deps.listModelCapabilities());
if (!winner) {
return {
ok: false,
reason: "NO_QUALIFYING_CANDIDATE",
message: `No enabled model clears the ${profile.requiredTier} ${profile.domain} bar for agent ${agent.name}. Enable a qualifying model, adjust the capability catalog, or switch this agent to modelStrategy=explicit.`,
};
}
return {
ok: true,
assignment: { connectorId: winner.connectorId, modelId: winner.modelId },
};
}
/**
* Re-export for test setups that want to exercise the rubric without
* standing up a full AgentRecord.
*/
export const __INTERNAL__ = {
DOMAIN_SCORE_FLOOR,
TIER_RANK,
};