@sema-agent/core
Version:
Stateless, task-oriented AI agent core
182 lines (181 loc) • 13.2 kB
TypeScript
import { type TSchema } from "typebox";
import type { AgentTool } from "../../internal/harness-types.js";
import type { TerminalBlockedDetail } from "../terminal-cause.js";
import { type ResolvedSkillListingBudget } from "../skill-listing-budget.js";
import { type SkillGrantTable } from "../skill-grant.js";
/** Reserved name of the injected structured-output tool (CC 2.1.187 `StructuredOutput`; batch-A rename — old
* "submit_output" is canonicalized for pre-rename durable state). */
export declare const OUTPUT_TOOL_NAME = "StructuredOutput";
/** Reserved name of the injected blocked-report signal tool (PascalCase: `report_blocked`→`ReportBlocked`). */
export declare const REPORT_BLOCKED_TOOL_NAME = "ReportBlocked";
/** Name of the injected code-review findings echo tool (CC 2.1.207 parity — 锚 pretty.js:361412 `BCe`). */
export declare const REPORT_FINDINGS_TOOL_NAME = "ReportFindings";
/** Default `submit_output` retries (council OS3); total attempts = retries + 1. Overridable via `TaskSpec.outputRetries`. */
export declare const OUTPUT_DEFAULT_RETRIES = 2;
/**
* Resolve the per-task cap on *consecutive failed* `submit_output` attempts. The task fails with
* `output.invalid` when the failure streak EXCEEDS this value, so total attempts = cap + 1
* (default 2 → 3 attempts; `outputRetries: 0` → cap 0 → fail on the first invalid submit). Illegal
* values (NaN/Infinity/negative) fall back to the default; fractional values floor down.
*/
export declare function resolveOutputRetries(outputRetries?: number): number;
export interface BlockedRef {
reason?: string;
/** Written by the engine when a hook seat kept the prompt back (never by the `ReportBlocked` tool): the terminal's
* `detail`. Absent = the model's own report. */
detail?: TerminalBlockedDetail;
}
/** Captures the validated object a `submit_output` call submitted (structured output, 1.41). `set` is
* flipped so we can tell "model submitted {}" from "never called". Mirrors `BlockedRef`. */
export interface OutputRef {
value?: unknown;
set?: boolean;
}
/**
* @param strictSchema CC 2.1.205 parity (strict-output-schema.ts): the STRICT derivation of `schema` when
* it converted (`additionalProperties:false` stamped, subset-validated). It becomes the tool's
* `parameters` — both the model-visible wire schema (CC sends it as `strictInputJSONSchema`) and the
* validation schema (the strict form only TIGHTENS the caller's schema, so anything it accepts is valid
* under the original — CC's Ajv-against-original validation agrees on every accepted submission).
* Absent (fallback / legacy callers) → the caller's schema is served as-is, the pre-205 behavior.
*/
export declare function createOutputTool(ref: OutputRef, schema: TSchema, strictSchema?: TSchema): AgentTool;
export declare function createReportBlockedTool(ref: BlockedRef): AgentTool;
/** One reported finding (the CC 207 wire shape — see {@link createReportFindingsTool}). */
export interface ReportedFinding {
file: string;
line?: number;
summary: string;
failure_scenario: string;
category?: string;
verdict?: "CONFIRMED" | "PLAUSIBLE";
outcome?: "fixed" | "skipped" | "no_change_needed";
}
/** Build the injected `ReportFindings` echo tool (CC 2.1.207 parity — description verbatim from 锚
* pretty.js:361413; `effect: "read"` mirrors CC `isReadOnly()`/`isConcurrencySafe()` → parallel-safe). */
export declare function createReportFindingsTool(): AgentTool;
/** Reserved name of the injected skill-loading tool (present only when `spec.skills` is non-empty). */
export declare const SKILL_TOOL_NAME = "Skill";
/** Per-skill LOAD gate on `SkillSpec.content` (CC 2.1.207 parity: CC's only invoke-time size door is a 1MB file-load gate — an over-limit skill is REJECTED
* WHOLE, never truncated, and the invoked body enters context untruncated). Sema skills are spec-supplied, so the equivalent gate sits at spec assembly:
* an oversized skill is excluded from the disclosure block and the tool, with an operator-visible config error. The compaction-time retention caps are a
* different door, deliberately. */
export declare const SKILL_CONTENT_MAX_CHARS: number;
/** Total per-invoke cap for a skill's inlined attachments (`SkillSpec.files`) — an ATTACHMENT-face budget, distinct from the (gone) body truncation door:
* the body is delivered untruncated at invoke time (CC parity), while attachments ride along bounded and truncation-disclosed. */
export declare const SKILL_ATTACHMENTS_MAX_CHARS = 50000;
/** Per-attachment cap on the PATH rendered in its `###` header — one pathological path must not eat the budget. Overflow truncates with an ellipsis. */
export declare const SKILL_ATTACHMENT_PATH_MAX_CHARS = 512;
interface SkillLike {
name: string;
description: string;
content: string;
/** The skill's two lists (`SkillSpec.allowedTools` / `disallowedTools`) — carried so a successful activation writes its rows into the run's grant table. */
allowedTools?: string[];
disallowedTools?: string[];
/** Optional attachments (SkillSpec.files): paths in the block, delivered inline on invoke under {@link SKILL_ATTACHMENTS_MAX_CHARS}. Same caller-trusted
* contract as `content` — core never writes them to disk (deployment's half). */
files?: Array<{
path: string;
content: string;
}>;
/** Optional base directory (SkillSpec.baseDir) — see `@contract skill.base_dir`. Absent ⇒ byte-identical result. */ baseDir?: string;
}
/** [c209-C] The metadata slice of a skill the LISTING faces consume (disclosure block + the
* skills_listing turn attachment): name, description, attachment paths — never the body. The
* Prepared seam projects `SkillSpec`s down to this shape so the run loop's announce lane holds no
* skill content. `SkillLike` is structurally assignable (its `files` entries simply carry an extra
* `content` the listing renderers never read). */
export interface SkillListingEntry {
name: string;
description: string;
files?: Array<{
path: string;
}>;
/** The skill's rank in the DEPLOYMENT'S DECLARATION order (`TaskSpec.skills`, first-wins
* dedupe applied, 0 = declared first). Lower = higher priority. Used ONLY to decide which skills keep
* their descriptions when the block overflows its byte budget ({@link buildSkillsBlock} tier B); the
* DISPLAY order stays name-sorted, so adding this never moves a line.
*
* Why declaration order: CC 220's `sbs` (@317547) takes a priority function and is called with
* `uNt(name)` (@608552 → @402383) = usage count decayed by recency of last use, and additionally
* pins every `bundled` skill's description unconditionally. Core has NEITHER axis — no cross-session
* skill-usage store, and no bundled/user distinction (every skill arrives through the same
* caller-supplied `TaskSpec.skills`). Declaration order is the one priority statement the deployment
* can actually make here, and it is the same statement 220's bundled-pin encodes ("these matter most,
* keep their descriptions"). Absent ⇒ the entry's array index is used, i.e. the prior behavior. */
declaredRank?: number;
}
/** Dedupe by name (first occurrence wins) and sort by name — one deterministic list shared by the disclosure block
* and the tool, so what the prompt promises is exactly what the tool serves. Each field is READ ONCE, BY NAME, and
* both consumers see the copy: the gate screens this list while the invoke substitutes from it, so a `get baseDir()`
* answering those two reads differently passed a screen that measured a value the model never received — and a rest
* copy would silently drop an INHERITED or non-enumerable list. The two tables are read by {@link readSkillTable}.
*
* The sorted view of {@link readSkillsSeat} (skills-seat.ts), which holds the seat's one reading — total over what a caller can
* hand it, and once. */
export declare function normalizeSkills(skills: unknown): SkillLike[];
/** [c209-C] The skills listing's lead line — single-sourced so the run loop's durable-resume
* transcript probe (MED-3② skills arm, runtask) matches EXACTLY what the initial frame carries
* (probing the bare `<skills>` fence would be more collision-prone in model-quoted text). */
export declare const SKILLS_LISTING_PROBE_HEADER = "The following skills are available for this task.";
/** [c209-C] One skill's disclosure line — `- name: description[(files 尾缀)]` with the per-skill
* description cap. Single-sourced so the skills_listing DELTA frame (turn-attachments) renders adds
* with byte-identical line shape to the initial `<skills>` block. */
export declare function skillListingLine(s: SkillListingEntry, descMaxChars?: number): string;
/** What one {@link renderSkillsBlock} call produced: the block, and which skills the budget degraded. */
export interface SkillsBlockRender {
/** The rendered block — what {@link buildSkillsBlock} returns. */
text: string;
/** Skills listed WITHOUT their description (tier B's unspent candidates, or every listed name under
* tier C), in display order. Raw names: this is host-facing structured data, not prompt text. */
nameOnly: readonly string[];
/** Skills the block could not list at all (tier C's overflow tail). The `Skill` tool still serves them. */
dropped: readonly string[];
}
/** The text-only face of {@link renderSkillsBlock} — the `<skills>` BLOCK's face, not the listing
* family's: the drift frame renders its own arms through the shared tier engine
* ({@link renderSkillLinesWithin}), which is where "one ruler for every frame" lives. */
export declare function buildSkillsBlock(skills: ReadonlyArray<SkillListingEntry>, budget?: ResolvedSkillListingBudget): string | undefined;
/** Render the `<skills>` disclosure block (metadata only — bodies stay behind the tool).
* [c209-C] REVERSAL: no longer composed into the stable system prompt — this is now the RENDERER for
* the `skills_listing` initial frame (first-user-turn reminder / boundary re-announce), so the
* cached prefix carries zero skill bytes (design/C §2.3; CC 209 classic ships the same listing as a
* first-turn reminder). `budget` absent ⇒ the seat reader's no-knob answer ({@link SKILL_LISTING_DEFAULT_BUDGET}).
*
* The fence and its four header lines are this lane's frame; the budget arithmetic and the three
* degradation tiers belong to {@link renderSkillLinesWithin}, which the drift frame renders through
* as well. {@link buildSkillsBlock} is the text-only face; this one additionally reports what the
* budget cost, for the disclosure lane. */
export declare function renderSkillsBlock(skills: ReadonlyArray<SkillListingEntry>, budget?: ResolvedSkillListingBudget): SkillsBlockRender | undefined;
/**
* The skills VOCABULARY of the one folding engine (`listing-fold.ts` `foldListingWithin`): the full
* `- name: description` line, the name-only line, the block's cap marker and its overflow count line,
* measured byte-exact inside a caller-supplied FRAME — the engine both {@link renderSkillsBlock} and
* the drift frame's ADD arm render through, so the initial `<skills>` block and a later "new skills are
* now available" announcement degrade by the same rules against the same number — the number the seat
* reader resolved (`@contract skills.listing_budget_enforced_as_declared`), which is the frame's whole
* bound: the listing is an intact-class attachment, so no delivery lane measures it a second time.
*
* Tier B follows CC 220 `sbs` @317547-317586 (reserve the marker + every name-only line, then spend
* the slack on descriptions in {@link SkillListingEntry.declaredRank} order); tier C closes with an
* honest `(… +N more skills …)` count line — the tool still serves all of them.
*/
export declare function renderSkillLinesWithin(skills: ReadonlyArray<SkillListingEntry>, opts: {
/** The whole frame's byte budget — honoured as given; the seat reader is the one place that decides it. */
blockMaxBytes: number;
descMaxChars: number;
frame: (lines: readonly string[]) => string;
}): SkillsBlockRender | undefined;
/**
* The injected `skill` tool: returns a skill's full body as a tool result.
*
* A SUCCESSFUL activation writes the skill's two lists into the run's grant table (`grants`, origin `model`): its
* `allowed-tools` then run without asking and its `disallowed-tools` are refused for the rest of the run; an unknown skill
* throws first and writes nothing, as does one whose `disallowed-tools` holds an entry that reads as nothing (the table
* refuses it: the body is not served, nothing is granted). Activating a skill that declares a list was itself a question (the gate reads this
* tool's seat off the call's arguments — `Execute skill: <name>`), so a write here always follows a statement. The tool
* runs ALONE in its batch: a call after it in the same message is judged after the rows exist.
*/
export declare function createSkillTool(skills: SkillLike[], grants: SkillGrantTable): AgentTool;
export {};