UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

239 lines (238 loc) • 15.4 kB
/** * 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>;