@sema-agent/core
Version:
Stateless, task-oriented AI agent core
239 lines (238 loc) • 15.4 kB
TypeScript
/**
* Teacher-mode strategy repository (design/14). Stores the *generalized* strategies a teacher gave
* that actually worked, so a later similar problem can be solved without re-consulting the teacher
* (Inter-Cascade cross-session reuse). Retrieval is deliberately **high-precision / low-recall**: a
* wrong strategy injected into a student costs wasted turns + a likely escalation (net negative),
* while a missed strategy just falls back to asking the teacher (baseline). `scope` is a **mandatory
* isolation boundary** (multi-tenant: one user's strategies must never leak to another).
*/
/** How a strategy first entered its scope. `"earned"` (or absent — older data predates the field) =
* stored by the escalation loop after a verified success; `"seeded"` = pre-installed via
* {@link seedStrategies}. An audit trail, NOT a security property: the store cannot authenticate
* its caller, so a direct `save` can claim either value — trust in seed material is the caller's
* decision, this field only records what the caller declared. */
export type StrategyOrigin = "earned" | "seeded";
export interface StoredStrategy {
/** Stable id. File-backed stores and {@link seedStrategies} require `[A-Za-z0-9_-]{1,64}` (the
* escalation loop mints UUIDs, which conform). NOTE: ids flow into host telemetry
* ({@link import("../agents/teacher.js").TeacherRunResult}`.strategiesInjected`) as join keys —
* do not encode sensitive text into an id. */
id: string;
/** The raw task objective that triggered the teacher's help — matched against future objectives. */
problem: string;
/** The teacher's generalized approach (1-2 sentences). */
strategy: string;
/** The teacher's confidence (0-3) when it gave this. */
confidence: number;
/** Tenant/isolation scope (e.g. "user:42"). Strategies are only ever retrieved within their scope. */
scope: string;
/** ISO timestamp stored. */
ts: string;
/** What the teacher leg was ADDRESSED as, for future staleness handling: the caller's word verbatim
* when the teacher was configured by string (a catalog key, tier word or CC alias), else the
* `Model.id` of the object it was configured with. NOT normalized to a served model id — two rows
* written by the same physical model under different spellings do not compare equal. */
teacherModel?: string;
/** Reserved for a future generalized signature (v3 semantic matching). */
signature?: string;
/** Provenance stamp (see {@link StrategyOrigin}). Absent = earned (backward compatible). */
origin?: StrategyOrigin;
}
/**
* One contained strategy-store incident, as disclosed by a persistent store's `onIncident` seat and by
* `TeacherConfig.onStrategyStoreIncident`. Phases are split so "the write never happened" (`save`) is
* distinguishable from "the write landed but housekeeping failed" (`evict`), and a quarantined corrupt
* entry (`parse`) from a retrieval that degraded (`find`).
*/
export interface StrategyStoreIncident {
op: "find" | "save" | "evict" | "parse";
/** What was observed and what the operation degraded to. */
error: string;
/** The strategy id involved, when one is known. */
id?: string;
/** Absolute path of the offending file, when the incident is file-level (e.g. a `parse` quarantine). */
path?: string;
}
export interface StrategyStore {
/** Persist a strategy (implementations may merge an exact near-duplicate and cap per-scope size). */
save(s: StoredStrategy): Promise<void> | void;
/**
* The most relevant strategies for `query`, within `scope`, at most `limit`.
*
* `limit` must be a non-negative integer, or `Infinity` for the explicit "no cap" (return every
* match). Any other value — NaN, `-Infinity`, negative, fractional — is refused rather than folded
* into some nearby number (ruled 2026-08-04): a retrieval cap that silently becomes 0 turns "give me
* everything" into "give me nothing", and the caller sees only an empty result it cannot distinguish
* from "no strategies stored".
*/
find(scope: string, query: string, limit: number): Promise<StoredStrategy[]> | StoredStrategy[];
/**
* Optional: trim a scope to `maxSize` (evicting the least useful). `maxSize` must be a non-negative
* integer; 0 is a legal "empty the scope". Unlike {@link find}'s `limit`, `Infinity` is REFUSED here
* (ruled 2026-08-04): this is a capacity cap, and a capacity cap can be widened but never turned off.
*/
prune?(scope: string, maxSize: number): Promise<void> | void;
/**
* Optional: how full a scope is (`used` entries out of `capacity`). {@link seedStrategies} uses it to
* refuse a batch that would not fit — silently evicting freshly seeded entries would be the most
* misleading possible "success". A store that cannot report usage simply omits this.
*/
scopeUsage?(scope: string): Promise<{
used: number;
capacity: number;
}> | {
used: number;
capacity: number;
};
/**
* Optional: is a content-identical entry (same normalized problem+strategy — the save-side merge
* key) already stored in `scope`? {@link seedStrategies} uses it to count only NET-NEW rows against
* the remaining capacity, so REPLAYING a batch whose entries already landed is never refused for
* "not fitting" space it does not need. A store that omits it gets the conservative whole-batch
* check (sound, but a replay into a full scope is falsely refused).
*/
hasStrategy?(scope: string, entry: Pick<StoredStrategy, "problem" | "strategy">): Promise<boolean> | boolean;
}
/**
* Validate a retrieval cap fail-loud (ruled 2026-08-04, same posture as `WebFetchConfig.maxBytes` and
* the workflow resilience knobs). The cap was previously applied as
* `slice(0, Math.max(0, Number.isFinite(limit) ? limit : 0))`, which mapped NaN, `Infinity` and every
* negative onto the SAME value — 0 — so the two opposite intentions ("no cap" and "a fumbled unit")
* both came out as "return nothing", the most misleading possible answer for a high-precision store.
* `Infinity` is HONORED here (a query cap can legitimately be absent), unlike a capacity cap.
*/
export declare function resolveFindLimit(limit: number): number;
/**
* Validate a per-scope CAPACITY cap fail-loud (ruled 2026-08-04). Two silent failures lived here: a
* negative `maxSize` was clamped to 0 and quietly wiped the whole scope, and a non-finite one made the
* `arr.length > cap` comparison permanently false — eviction disabled, unbounded growth, no signal.
* `Infinity` is refused by name (the "turn the cap off" misconception) rather than silently behaving
* like the unbounded store it would create. Shared by the in-memory and file-backed stores.
*/
export declare function resolveCapacityCap(name: string, cap: number): number;
/** Id grammar for persisted/seeded strategies: filename-safe, no traversal, bounded. */
export declare const STRATEGY_ID_RE: RegExp;
/** Byte cap on `problem` (the matched objective text). */
export declare const MAX_STRATEGY_PROBLEM_BYTES = 8192;
/** Byte cap on `strategy` (the injected guidance — the teacher contract is 1-2 sentences). */
export declare const MAX_STRATEGY_TEXT_BYTES = 4096;
/** Byte caps on the remaining serialized string fields, so a legitimate save cannot mint an entry
* whose on-disk record dwarfs the payload caps above (the file store also bounds what it will READ,
* and these write-side caps are what make that read bound compatible with every honest record). */
export declare const MAX_STRATEGY_SCOPE_BYTES = 4096;
export declare const MAX_STRATEGY_TEACHER_MODEL_BYTES = 512;
export declare const MAX_STRATEGY_SIGNATURE_BYTES = 4096;
/** A real ISO-8601 timestamp is ~24-35 bytes; 64 leaves headroom for exotic offsets while keeping a
* "string that happens to sit in the ts seat" from bloating a serialized record. */
export declare const MAX_STRATEGY_TS_BYTES = 64;
/** A stored `ts` may not sit further in the future than this — a far-future timestamp would pin the
* entry at maximum recency forever (a rank-to-the-top vector). Small clock skew stays legal. */
export declare const MAX_STRATEGY_TS_FUTURE_MS: number;
/** Total byte budget across the strategy texts one retrieval hands to a prompt. Enforced by the file
* store's `find` (close to the tamperable data) AND at the escalation loop's injection site (so the
* bound holds for ANY backend — the in-memory store deliberately accepts legacy-shaped entries). */
export declare const MAX_STRATEGY_INJECTION_TOTAL_BYTES = 16384;
/**
* Structural validity of a (possibly untrusted, e.g. read-from-disk) value as a {@link StoredStrategy}:
* strings where strings are promised, id grammar (filename safety), byte caps, confidence domain
* (finite, 0-3 per the declared contract), scope non-emptiness, origin closed set. Returns a reason
* string when invalid, `null` when acceptable. Deliberately does NOT judge the timestamp's VALUE —
* that is a write-side rule only ({@link validateStrategyForWrite}): applying it on read would let a
* host clock stepping backwards mass-invalidate entries that were legal when written. A MALFORMED
* (unparseable) `ts` is also allowed through — read-side ranking clamps it to "oldest", so it
* self-buries instead of pinning.
*/
export declare function storedStrategyShapeIssue(v: unknown): string | null;
/**
* The write-side door: refuse an entry a persistent store must not accept — the structural checks of
* {@link storedStrategyShapeIssue} PLUS the future-timestamp bound (a far-future `ts` would pin the
* entry at maximum recency; refusing it at write keeps the rank honest, while the read side merely
* clamps so a backwards clock step cannot invalidate history).
*/
export declare function validateStrategyForWrite(s: StoredStrategy): void;
/**
* Compile a query into the shared high-precision match predicate. Returns `null` when the query has
* no significant terms (nothing meaningful to match on → don't inject random strategies). Single
* source for the in-memory and file-backed stores, so retrieval semantics cannot fork between
* backends.
*
* TWO acceptance arms, OR-combined (retrieval semantics, pinned):
* - full arm — every significant term (alphanumeric AND CJK) present in the problem;
* - alphanumeric arm — every significant ALPHANUMERIC term present (and there is ≥1), so a mixed
* query ("请帮我 extract tables from report.pdf") still matches an English-only stored problem —
* requiring the CJK boilerplate too would un-match that pair.
*
* ACCEPTED COST (documented, pinned): via the alphanumeric arm, a mixed query whose alphanumeric
* terms all match can hit a problem whose CJK terms CONFLICT (提取 X vs 删除 X). The mitigation is
* the surrounding depth (negation preamble, verify-before-use instruction, injection audit);
* tightening precision is a measured-recalibration change, not a retrieval patch.
*/
export declare function compileStrategyQuery(query: string): ((problem: string) => boolean) | null;
export declare function normalizeStrategyText(s: string): string;
/** Duplicate key for save-side merge and read-side dedup: same normalized problem AND strategy. */
export declare function strategyDedupKey(s: Pick<StoredStrategy, "problem" | "strategy">): string;
/** Ranking score: confidence weighted by recency (no `uses` — avoids a reinforcement feedback loop). */
export declare function scoreStoredStrategy(s: StoredStrategy): number;
/**
* Default in-memory store. Retrieval: a stored problem must contain **every significant** query term
* (and there must be ≥1) — near-exact on the meaningful terms, ignoring stopword noise. High precision
* by design. Ranked by `confidence × recency`. Per-scope capacity cap with eviction on save.
*/
export declare class InMemoryStrategyStore implements StrategyStore {
private byScope;
private readonly maxPerScope;
constructor(maxPerScope?: number);
save(s: StoredStrategy): void;
find(scope: string, query: string, limit: number): StoredStrategy[];
prune(scope: string, maxSize: number): void;
scopeUsage(scope: string): {
used: number;
capacity: number;
};
hasStrategy(scope: string, entry: Pick<StoredStrategy, "problem" | "strategy">): boolean;
/** Total stored strategies (across scopes); for tests/observability. */
get size(): number;
}
/** One entry to seed: a {@link StoredStrategy} minus the fields the seeder stamps (`scope` comes from
* the call, `origin` is forced to `"seeded"`, `ts` defaults to now). */
export type SeedStrategyEntry = Omit<StoredStrategy, "scope" | "origin" | "ts"> & {
ts?: string;
};
/** Result of {@link seedStrategies}: how many entries landed, and where the write phase stopped if it
* did. `failedAt` present = a PARTIAL seed (entries before it are in the store); the batch is
* idempotent by content, so re-running the same call is the supported recovery. */
export interface SeedStrategiesReport {
written: number;
failedAt?: {
id: string;
error: string;
};
}
/**
* Seed a batch of strategies into `scope`, in two phases:
*
* 1. **Validate the whole batch first** — any invalid entry (shape/id grammar/byte caps/timestamp
* discipline, see {@link validateStrategyForWrite}), a duplicate id OR duplicate normalized
* content within the batch (two content-identical entries would merge into one, making `written`
* over-report and one seeded id unfindable), or a batch larger than the scope's remaining
* capacity (when the store reports usage) refuses the WHOLE batch loudly with ZERO writes.
* Silently evicting freshly seeded entries would be the most misleading possible "success", so a
* batch that cannot fully fit does not start.
* 2. **Write one by one** — a mid-batch failure returns a `{written, failedAt}` report instead of
* pretending to be a transaction; entries are idempotent (same id + content re-saves merge), so
* replaying the same batch completes the remainder.
*
* HONEST LIMITS (cache-store posture — no locks, no reservations):
* - the capacity preflight is a snapshot, not a reservation: with a CONCURRENT writer on the same
* scope, entries can still be evicted between the check and the writes (single-writer deployments
* — the aggregated backend under its boot lock — get the full guarantee);
* - an entry content-identical to one ALREADY STORED merges into the existing entry — the STORED id
* and the STORED `origin` stamp win (content earned before seeding stays `earned`; the requested
* id is not created). This counts as written: the content IS in the store, and replaying a batch
* relies on exactly that merge. Injection audit rows always carry the stored id, so joins hold.
*
* Every entry is stamped `origin: "seeded"`. That stamp is an AUDIT clue, not a security property —
* the store cannot authenticate the seeder, and a caller with store access can write anything; trust
* in the seed material is the caller's decision.
*/
export declare function seedStrategies(store: StrategyStore, scope: string, entries: readonly SeedStrategyEntry[]): Promise<SeedStrategiesReport>;