UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

160 lines (159 loc) • 11.5 kB
import type { ThinkingLevel } from "../internal/harness-types.js"; import type { Model } from "../internal/llm.js"; import type { ModelRef, ModelRole, ModelRoles, RoleSpec } from "./types.js"; export type { ModelRole, RoleSpec, ModelRoles } from "./types.js"; export { MODEL_ROLES } from "./types.js"; /** Resolve a string ModelRef against the catalog (a Model object passes through). */ export declare function resolveModel(ref: ModelRef, models?: Record<string, Model>): Model; /** 档位降档链(强→弱)。解析某档无绑定时沿链向后找第一个有绑定的档(fail-open 可用性优先;绑定缺口 * 在部署 catalog 审计面可见)。`ultra` 预留:排在 `max` 前,今天不绑,未来加档零码改。 */ export declare const DEFAULT_TIER_ORDER: readonly string[]; /** CC 档位词 → sema 档位(内置固定,兼容层):fable/mythos→max · opus→pro · sonnet→flash · haiku→lite。 */ export declare const CC_MODEL_TIER_ALIASES: Readonly<Record<string, string>>; /** the DISPLAY face of a string model ref: a CC tier ALIAS (haiku/sonnet/…) resolves to its * sema tier name (lite/flash/…) for labels — the alias verbatim reads as a strong claim about a * specific vendor's model while the actual routing may land anywhere ("haiku" showing over a * DeepSeek run, an operator field report). A non-alias string is the deployment's own model key — its * honest name — and passes through unchanged. Display only; routing (`expandTiers`) is untouched. */ export declare function resolveModelDisplayLabel(ref: string): string; /** * Expand a deployment's tier bindings into the model catalog: every tier name (and every CC alias whose * tier resolves) becomes a catalog key pointing at the bound Model, so the ENTIRE resolve surface * (`resolveModel` / roles / `@model` mentions / Agent tool `model` / workflow `agent() opts.model`) * accepts tier vocabulary with ZERO per-callsite changes. Called ONCE at Runner construction. * * Rules: * - a tier with no binding falls back DOWN {@link DEFAULT_TIER_ORDER} to the first bound tier * (e.g. `max` unbound + `pro` bound ⇒ "max" resolves to the pro model — availability over strictness; * the gap stays visible in the deployment's catalog audit, not silently absent); * - an existing catalog key of the same name WINS (deployment SHADOW semantics, mirroring agents): * a deployment that already ships a model literally named "pro" keeps it untouched; * - a binding may itself be a catalog name or a Model object (resolved through `resolveModel`); * an unknown binding name throws at construction (config error surfaces at boot, not first use); * - `"best"` (dynamic head-of-chain alias, CC `$L` parity) resolves to the FIRST tier in * {@link DEFAULT_TIER_ORDER} that carries a DIRECT binding — "the strongest model this deployment * actually bound", not a fixed tier — under the same SHADOW rule (a deployment catalog key named * `best` wins); with no bound known-order tier (or no tiers at all) the key stays absent and * `resolveModel("best")` refuses like any unknown ref. */ export declare function expandTiers(models: Record<string, Model> | undefined, tiers: Record<string, ModelRef> | undefined): Record<string, Model> | undefined; /** * The words a catalog word stands in for under the tier layer, nearest first — what {@link expandTiers} * recorded for a key IT wrote: the tier words its resolution passed through and the binding's own catalog * word, each naming the model the key resolves to. A deployment's own key (a shadowing one included), and any * catalog that was not tier-expanded, stands in for nothing. */ export declare function tierAliasVia(word: string, models: Record<string, Model> | undefined): readonly string[]; export interface ModelMention { /** The matched model name — always one of `allowedNames` — or undefined if none was found. */ model?: string; /** The message with the matched `@model` token removed and whitespace tidied. */ cleanedText: string; } /** * Parse a leading/inline `@model` mention out of a user message, restoring the chat "@-model" UX. * Returns the matched model **name** (a catalog ref the caller resolves), never a Model object — and * **only ever a name from `allowedNames`**. This is the security boundary: untrusted end-user text can * pick *which configured model* to use, but can never inject an arbitrary `baseUrl`/`apiKey`/Model * (see design/11). Matches the first allow-listed mention; the rest of the text is returned cleaned. * * **Case-INSENSITIVE, deliberately** — and deliberately UNLIKE the catalog, whose lookups * ({@link resolveModel}) are exact. The two answer different questions and the asymmetry is the * design, not an oversight: a chat mention is a HUMAN-typed UX surface where `@Opus` and `@opus` are * the same intent, so intake is wide; a catalog key is a machine identifier a deployment declared, so * lookup is exact. The join is safe because the wide half never widens the narrow one: this returns * the allowlist's OWN canonical spelling (never the user's casing), so the value handed to * `resolveModel` is always a key the deployment wrote. The only visible consequence is that a catalog * carrying two keys differing ONLY in case is addressed by whichever the allowlist iterates last — * `@x` cannot name both, and there is no spelling that disambiguates them. * * Typical use: `const { model, cleanedText } = parseModelMention(userMsg, Object.keys(runner.agentCatalog.models ?? {}));` * then `runTask({ objective: cleanedText, model })` (omit `model` to fall back to the default role). * * **Feed it the EXPANDED table.** The allowlist this takes is the host's own, and on a deployment that * configured `RunnerDeps.tiers` the host's `deps.models` is NOT what the Runner resolves against: the * constructor runs {@link expandTiers} once and keeps the expansion PRIVATE, so tier words and CC * aliases (`pro`/`flash`/`opus`/`sonnet`/`best`/…) are catalog keys inside the Runner and absent from * the host's table. Passing `Object.keys(deps.models)` therefore makes a user's `@opus` silently not * match — the mention falls through as ordinary text and the task quietly runs on the default role. * `runner.agentCatalog.models` is the expanded table (a per-read copy); a caller that has no Runner in * hand can pre-expand its own with the exported {@link expandTiers}. */ export declare function parseModelMention(text: string, allowedNames: Iterable<string>): ModelMention; /** * Role → 档位默认表(裁定 2026-07-11:「role 体系用户不感知」)。配了 `RunnerDeps.tiers` 的部署, * 各 role 未显式配置时自动落到语义档位——用户/壳只感知档位,roles 降级为内部路由层: * - `default` → `pro`(主力档兜底;用户/部署 roles.default / TaskSpec.model 恒可覆盖); * - `summarize` → `flash`(压缩本该便宜;flash 未绑沿降档链到 lite); * - `advisor` → 第一档(`ultra` 起,未绑降档到 max/pro——teacher 用最强的); * - `verifier` → `pro`; * - `classifier` → `flash`(design/143 批2b:auto-mode 权限分类腿是便宜档;flash 未绑沿降档链到 lite); * - `subagent`/`team`/`synthesize` 无档位默认 = 走 FALLBACK 继承主模型(LLM 可经 model 参数自选档); * - `consolidate` 同样无档位默认,但它的 FALLBACK 刻意只有 `summarize` 一站(design/376,见 * {@link FALLBACK} 的注):未配即响亮拒,不静默骑主模型。 * 未配 tiers 的部署:catalog 无 TIERS_ACTIVE 标记 → 表整体 INERT,行为与从前逐字节一致 * (即使 catalog 恰有裸模型名 `pro`/`flash` 也不入档位路由——INERT 靠配置成立,不靠键名运气)。 */ export declare const ROLE_TIER_DEFAULTS: Readonly<Partial<Record<ModelRole, string>>>; /** * Whether a RoleSpec NAMES a model — a string ref, a Model object, or a config object carrying `model` or * `select` — as opposed to a preset-only object (`{ thinking }`, `{ systemPrompt }`) that shapes the role's * request but leaves its model to the defaults. The classifier seat's own default (the tier below the run's * main model, `classifier-ladder.ts`) applies exactly when this is false: a `{select}` keeps its selection, a * preset-only role does not disable the relative pick. One reader, the same parts {@link specParts} reads. */ export declare function roleSpecNamesModel(spec: RoleSpec | undefined): boolean; export interface ResolvedRole { model: Model; /** * WHICH REF CHOSE {@link model} — the word, when a word chose it (the explicit `spec.model` string, the * role's own `model` ref, or the role→tier default's catalog key), else the `Model` object itself (an * explicit object, or one a `select` criteria picked: neither has a catalog word). * * The distinction is load-bearing wherever a LATER tick must ask the catalog the same question again: a * word re-resolves against the catalog generation in force at that tick (a hot swap redirects it, a * retirement refuses it loudly), while an object is pinned and never re-resolved. The engine-spawned * child that inherits its parent's model inherits THIS, not the resolved object, so it lands on the * generation its own prepare sees rather than on the one its parent started under; the same word keys * the deployment's per-model context-window overlay. */ modelRef: ModelRef; /** The effective thinking level: explicit `spec.thinking` wins, else the role's default. */ thinking?: ThinkingLevel; /** The requested role's preset system prompt, if it set one (independent of model fallback). */ systemPrompt?: string; } /** * Resolve the model (and default thinking) for a task. Explicit `spec.model` wins; otherwise walk the * `modelRole`'s fallback chain over the merged roles (task overrides runner) and use the first role * that supplies a model. Throws a helpful error if nothing resolves. * * @contract roles.side_channels_deployment_only — an engine SIDE CHANNEL (the auto-mode classifier, * the compaction/summarize lane, memory consolidation, next-prompt suggestion) resolves here with a * `modelRole` and NO run model: it runs on the deployment's roles alone — the boot default at the end of * its chain — and never follows the model a run was switched to. Only a run's CHILDREN follow the parent: * the delegation seat (`childModelSeat`) passes the parent's `Model` as `spec.model` with the role kept * on `modelRole` — and keeps that role on an EXPLICIT-model seat too, so the explicit branch below reads * the child's presets off the subagent role, not the default one. A side channel that must follow a run * passes that run's model explicitly; nothing here infers it. */ export declare function resolveTaskModel(spec: { model?: ModelRef; modelRole?: ModelRole; roles?: ModelRoles; thinking?: ThinkingLevel; }, deps: { models?: Record<string, Model>; roles?: ModelRoles; }): ResolvedRole; /** * Resolve a role's model **only if that exact role key is configured** (no fallback chain). Used for * the `summarize` compaction model: override only when a summarize-specific model is set, otherwise * let the caller fall back to the main model (unchanged behavior). */ export declare function roleModelIfSet(role: ModelRole, spec: { roles?: ModelRoles; }, deps: { models?: Record<string, Model>; roles?: ModelRoles; }): Model | undefined;