@sema-agent/core
Version:
Stateless, task-oriented AI agent core
160 lines (159 loc) • 10.3 kB
TypeScript
import type { RunnerSelfSeat } from "../core/runner/contracts.js";
import { type StrategyOrigin, type StrategyStore, type StrategyStoreIncident } from "../core/strategy-store.js";
import type { ModelRef, TaskResult, TaskSpec } from "../core/types.js";
/**
* Teacher mode (escalation cascade): a cheap "student" does the work; a strong "teacher" is consulted
* only when the student is *detectably* stuck or wrong, then withdraws.
*
* APPLICABILITY — teacher mode assumes the task has a **decidable verify signal**: escalation only fires
* when we can tell the student is failing (Tier 0 same-tool/same-error stuck, Tier 1 rubric verifier on
* the final output, or a `blocked`/`failed` terminal). It pays off when "wrong" is observable.
*
* It does NOT fit **open-ended completeness/quality judgement** — e.g. code-review "find ALL the bugs",
* creative quality — because there is no oracle for *completeness*: the verifier can't know how many bugs
* the code has or which one was missed, so a student that finds one obvious issue passes the rubric while
* silently missing others, and the escalation machinery idles (verified in practice: 0 escalations, the
* verifier becomes pure added cost). Such tasks are solved by **breadth + falsification-style debate** (a `team`
* council), not by **depth escalation** — the two are orthogonal. See design/12 §六 for the full reasoning.
*/
/** Default teacher (advisor) system prompt — returns ONLY structured JSON guidance. */
export declare const TEACHER_PROMPT = "You are an expert advisor to a less-capable \"student\" agent that got stuck.\nYou receive the task and the student's recent failed attempts. Your job is to help the student RECOVER,\nnot to do the work for it.\n\nReturn ONLY one valid JSON object (no markdown, no prose outside it):\n{\n \"strategy\": \"<general approach for this CLASS of problem, 1-2 sentences>\",\n \"correction\": \"<specifically what the student did wrong>\",\n \"nextStep\": \"<one concrete action the student should take now>\",\n \"takeover\": <true ONLY if the student is hopelessly stuck and you must do it yourself>,\n \"confidence\": <0=low .. 3=high, how sure you are this guidance is correct>\n}\n\nBe terse. Do NOT solve the whole task unless takeover=true. If you are unsure, set confidence=0 and takeover=false.";
export interface TeacherConfig {
/** Teacher model (overrides the `advisor` role). Default: resolve the `advisor` role. */
model?: ModelRef;
/** Cheap helper model for the stuck-monitor and verifier. Default: the student's own model. */
helperModel?: ModelRef;
/** Max escalations per run. Default 3. */
maxEscalations?: number;
/** Stop escalating once cumulative teacher tokens exceed this fraction of student tokens. Default 0.4. */
teacherSpendRatioCap?: number;
/** Consecutive same-tool failures before "suspected stuck". Default 3. */
stuckThreshold?: number;
/** Cumulative same-tool failures that force escalation regardless of the monitor. Default 5. */
stuckHardOverride?: number;
/** Second-guess a suspected stall with a cheap stuck-monitor call. Default true. */
useStuckMonitor?: boolean;
/** Run a rubric verifier on a completed student output. Default true. */
verifyOutput?: boolean;
/** After this many corrections fail, the teacher takes over instead of correcting again. Default 2. */
takeoverAfter?: number;
/** Prompt overrides (instruction text) for the teacher, the rubric verifier, and the stuck-monitor. */
prompts?: {
teacher?: string;
verifier?: string;
monitor?: string;
};
/** Per-escalation progress callback. */
onEscalation?: (e: EscalationRecord) => void;
/**
* Decoupled escalation decision (Intent-monitor pattern, design/15). Called after a trigger fires
* and the cost guards pass, but before actually consulting the teacher. Return `false` to **skip**
* this escalation (the current — unresolved — result is returned). Default: always escalate. A
* throw fails open (escalates), so a broken policy can't strand the student; but a policy that
* always vetoes makes *you* responsible for the task ever resolving. (No internal timeout — wrap
* your own if the policy may hang.) Use it to plug in a separate monitor (a cheap model or rules)
* that decides whether escalating is worth it.
*/
escalationPolicy?: (info: {
trigger: EscalationTrigger;
attempt: number;
result: TaskResult;
recent: string[];
verifyReason?: string;
}) => boolean | Promise<boolean>;
/**
* Self-consistency for the rubric verifier (design/15). When ≥2, the verifier runs this many times
* (cheap helper model) and **majority-votes** pass/fail — less noisy than a single call, so the
* verify-fail escalation decision is better-calibrated. Default 1.
*/
verifierSamples?: number;
/**
* Optional strategy repository (design/14). When set **with `scope`**, relevant past strategies are
* retrieved into the student's objective before the run, and the final escalation's strategy is
* stored on success — so similar problems can be solved without re-consulting the teacher.
*/
strategyStore?: StrategyStore;
/** Tenant/isolation scope for `strategyStore` (REQUIRED to enable it — strategies never cross scope). */
scope?: string;
/**
* How many strategies to retrieve. Default 3. Must be a non-negative integer, or `Infinity` for "no
* cap" — the store refuses anything else (ruled 2026-08-04) instead of folding it into 0, which used
* to make a fumbled value look identical to "this scope has no strategies".
*/
retrieveK?: number;
/** Minimum teacher confidence to store a strategy. Default 2. */
minConfidenceToStore?: number;
/** Inject retrieved strategies into the objective. Default true when a store+scope are set. */
injectStrategies?: boolean;
/**
* Disclosure sink for a strategy-store failure this loop CONTAINED (store-fault containment): a
* `find` that failed injects nothing, a `save` that failed stores nothing — neither ever fails the
* task (the repository is an optimization; an optimization's fault must not kill the work it was
* optimizing) — but each is disclosed here so a broken store is discoverable. The one exception
* stays loud: an error carrying a `config.`-prefixed code (an illegal `retrieveK` etc.) is a
* PROGRAMMER error and rethrows — containing it would silence the validation door entirely.
* Callback faults are themselves contained (safe-notify posture); absent ⇒ `console.warn`, once
* per op kind.
*/
onStrategyStoreIncident?: (i: StrategyStoreIncident) => void;
}
export type EscalationTrigger = "stuck" | "blocked" | "failed" | "verify-fail";
export interface TeacherAdvice {
strategy?: string;
correction?: string;
nextStep?: string;
takeover: boolean;
confidence: number;
/** Raw text when the JSON could not be parsed. */
raw?: string;
}
export interface EscalationRecord {
attempt: number;
trigger: EscalationTrigger;
teacher: TeacherAdvice;
tookOver: boolean;
}
export interface TeacherRunResult extends TaskResult {
/** Escalations that occurred during this run (empty if the student succeeded alone). */
escalations: EscalationRecord[];
/**
* Audit trail of the strategies retrieved into the student's objective for THIS run — id-level (the
* text is not copied; the id is the join key into the store), with each entry's stored timestamp and
* provenance stamp. Lets a host connect "what was injected" to "how the run ended" offline, which is
* the only way retrieval-induced degradation is ever measurable. PRESENT iff ≥1 strategy was
* injected; ABSENT means no store was configured, injection was disabled, retrieval matched nothing,
* or the find failed (a failure additionally raises `onStrategyStoreIncident`).
*/
strategiesInjected?: Array<{
id: string;
ts: string;
origin?: StrategyOrigin;
}>;
/** Usage spent on the teacher (advisor), separate from the student's `stats` (which includes the
* cheap helper monitor/verifier calls). `tasks` = teacher runs (escalation asks + any takeover).
* `humanReview` = the HITL-gate burden of the teacher's runs (notably a takeover that hit an approval
* gate) — kept here, not in the student `stats`, since it is teacher work (mirrors the cost split). */
teacherStats: {
tokens: number;
tasks: number;
costMicroUsd?: number;
/** REF-D16: the CANONICAL burden shape from {@link TaskResult}, not a re-typed narrowing. The entries
* pushed here are the teacher runs' own `stats.humanReview.gates` verbatim, so a locally declared
* 3-field element (the pre-fix shape) was a type-face NARROWER than the values actually present —
* a consumer reading `gates[i].toolName` (design/99 MF-24) got a type error on a field that is there. */
humanReview?: NonNullable<TaskResult["stats"]["humanReview"]>;
};
}
/** Best-effort parse of the teacher's JSON advice; on failure, keep the raw text as a low-confidence correction. */
export declare function parseTeacherAdvice(text: string): TeacherAdvice;
/**
* Run a task with a cheap "student" model that escalates to a strong "teacher" (advisor) when it gets
* stuck or produces a wrong answer — the escalation cascade / teacher mode (design/13).
*
* The teacher runs in an isolated session (its dialogue is discarded; only its structured JSON advice
* is injected back into the student's session, so the student's prefix cache survives). Triggers:
* Tier 0 — repeated same-tool failures (aborts the stuck run early); Tier 1 — a rubric verifier on a
* completed output (catches "passes but semantically wrong"). Correction-then-takeover, with hard
* cost guards (escalation cap, teacher-spend ratio, bounded teacher turns).
*/
export declare function runWithTeacher(runner: RunnerSelfSeat, studentSpec: TaskSpec, teacher?: TeacherConfig): Promise<TeacherRunResult>;