agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
605 lines (604 loc) • 30.5 kB
TypeScript
/**
* Injection Engine — types.
*
* THE primitive that unifies every form of context engineering in the
* library. Skills, Steering docs, Instructions, RAG, Memory, custom
* Context — all reduce to one shape: an `Injection` with a `trigger`
* (when), `inject` (what — one or more slot targets), and a `flavor`
* (observability tag).
*
* Pattern: Strategy (GoF) — each Injection's trigger is a strategy for
* "should I activate this iteration?". Each Injection's
* `inject` is the Memento (GoF) carrying content to slots.
* Role: Layer-3 context engineering primitive in the stack.
* Sits below the slot subflows.
* Emits: Engine emits `agentfootprint.context.evaluated` once per
* iteration. Slot subflows emit `agentfootprint.context.injected`
* for each InjectionRecord they place.
*/
import type { ContextRole, ContextSource } from '../events/types.js';
import type { SkillCachePolicy, SkillTool, SkillToolSchema } from './hostContract.js';
import type { ToolResultStatus } from '../semantics/toolOutcome.js';
/**
* Discriminated union — exactly one of four kinds. Adding a new
* trigger kind is one new variant; engine evaluator + Lens chip
* naturally extend.
*/
export type InjectionTrigger =
/** Always-on. Used for steering-doc-style injections. */
{
readonly kind: 'always';
}
/** Predicate runs once per iteration. Most flexible. */
| {
readonly kind: 'rule';
readonly activeWhen: (ctx: InjectionContext) => boolean;
}
/** Activates after a specific tool returns. The "Dynamic ReAct" flavor —
* tool results steer the next iteration's prompt. `toolName` matches
* literally (string) or by regex. */
| {
readonly kind: 'on-tool-return';
readonly toolName: string | RegExp;
}
/** Activates when the LLM calls a designated tool. The "Skill" flavor:
* `read_skill('billing')` activates the billing Skill for the next
* iteration. */
| {
readonly kind: 'llm-activated';
readonly viaToolName: string;
};
/**
* Multi-slot per Injection. A Skill for example targets BOTH
* system-prompt (the body) AND tools (the unlocked capabilities)
* in one Injection. Lens displays the same Injection chip across
* each slot it lands in.
*/
export interface InjectionContent {
/** Text appended to the system-prompt slot when active. */
readonly systemPrompt?: string;
/**
* Messages DELIVERED into the conversation when active (7.21.0).
*
* Delivery means what it says: the agent's `Deliver` stage appends these
* to `scope.history` itself at the injection-engine boundary — never a
* parallel array spliced in at send time — so the window strategies, the
* three slots, the commit log and the wire all see one past. Two rules
* govern it, and both can say no:
*
* • a role the attached provider does not carry inside `messages` is
* REFUSED at run start, naming the provider (the Anthropic family
* drops `role: 'system'` there; the OpenAI family carries it);
* • a message whose role would collide with the turn at the end of the
* window, or that would split a `tool_use`/`tool_result` pair, is
* DEFERRED to the next boundary and recorded on
* `messagesDelivery.deferred`.
*
* Between 7.19.1 and 7.21.0 this field was unreachable by declaration:
* content placed here was recorded as injected and never sent, so the
* declaration was refused rather than believed. See
* `messagesSlotRefusal.ts` for the whole arc.
*/
readonly messages?: ReadonlyArray<{
readonly role: ContextRole;
readonly content: string;
}>;
/**
* Tools this Injection contributes (Skills are the flavor that carries them).
*
* By default a Skill's tools go into the agent's tool registry at BUILD time
* and are visible to the model from iteration 1 — activation adds the body,
* not the tools. Only a Skill with `autoActivate: 'currentSkill'` has its
* tools held back and readmitted through the tools slot while it is active.
*
* Typed as the NARROW {@link SkillTool} — a schema and something to run —
* rather than agentfootprint's own `Tool` (9.34.0). Every other member of
* `Tool` is optional, so a `Tool` satisfies this and this is accepted
* everywhere a `Tool` is; what changes is that DECLARING a skill no longer
* needs the framework's tool type, which is what lets this engine be read
* by a host that has its own.
*/
readonly tools?: readonly SkillTool[];
}
/**
* Context passed to `rule` predicates. Read-only snapshot of the
* agent's iteration state. Internal mutable state is hidden.
*/
export interface InjectionContext {
/** Current ReAct iteration (1-based). */
readonly iteration: number;
/**
* The turn's ACTION BUDGET — the agent's `maxIterations` (9.57.0).
*
* Absent only when the engine was driven without one (a hand-built
* evaluation, a foreign host). Every agent-mounted evaluation carries it.
*
* It arrives PAIRED with {@link iterationsRemaining}: both are present or
* neither is. A count without a denominator is the exact fabrication this
* pairing exists to prevent — "you have used 23" says nothing, and "23 of
* undefined" says something false.
*/
readonly maxIterations?: number;
/**
* Actions left in this turn — `maxIterations - iteration`, never negative
* (9.57.0). Computed by `iterationsRemainingOf`, the same one function the
* cache decision and the request assembly use, so the three cannot drift by
* one. Zero at the out-of-budget wrap-up call, which legally runs at
* `maxIterations + 1`.
*
* Paired with {@link maxIterations} — see there.
*/
readonly iterationsRemaining?: number;
/** The current user message that started this turn. */
readonly userMessage: string;
/**
* Conversation history up to (but not including) the current
* iteration's LLM call. Includes prior iterations within the same turn.
*
* **Not everything with `role: 'user'` here was said by a person.** This
* library writes seven kinds of user-role message itself — a compaction
* frame, a drop notice (whose text NAMES TOOLS), a schema-check correction,
* an evidence-check correction (which QUOTES the model's own values), the
* out-of-budget wrap-up instruction, the stepped-skill nudge (which names a
* skill id and every unrun step's TOOL NAME), and a message an Injection
* delivered — and all seven sit in this list beside the real ones. Read the
* person's messages with {@link saidByPerson}; a predicate that scans raw
* `history` for a phrase will sooner or later match our own bookkeeping.
*/
readonly history: ReadonlyArray<{
readonly role: ContextRole;
readonly content: string;
readonly toolName?: string;
/**
* WHO let this message in, when it was not the conversation (9.84.0).
*
* Present exactly when an Injection's `messages` slot delivered it — the
* same marker `LLMMessage.injectedBy` carries, narrowed to the two fields
* a routing decision has any business reading. It was on the object all
* along; until 9.84.0 this type hid it, so a rule could hand-match the
* four authored frames by their opening text and still not see this, the
* fifth library-written class.
*
* Absent on every message that came from the conversation itself, which
* is what makes `injectedBy === undefined` half of the rule
* {@link saidByPerson} applies.
*/
readonly injectedBy?: {
/** The `Injection.id` that produced this message. */
readonly injectionId: string;
/** The injection's flavor — the `source` the slot records. */
readonly flavor: ContextSource;
};
}>;
/**
* The most recent tool result, if the previous iteration ended in a
* tool call. Used by `rule` predicates; `on-tool-return` triggers and
* skill-graph routes read the full batch (`toolResults`) and fall back
* to this when only the singular was provided. When the model called
* several tools in one message this is the LAST of them — read
* `toolResults` when the whole batch matters.
*
* **`result` is the string the MODEL read, which artifact PLACEMENT can
* replace.** With `artifacts: { store, placement: { maxInlineChars } }`
* configured, a tool result over the threshold is checked into the artifact
* store and both the model and this field get the claim ticket instead —
* `{"placed":true,"ref":"art_…","kind":"tool-result/<tool>",…}` — or the
* tool's declared `resultKind` in place of that default kind (see
* `placeResults` in stages/toolCalls.ts, where the other end of this note
* lives). So a predicate matching on payload text stops firing once an
* operator turns placement on or lowers the threshold, and one matching on
* the ticket's `kind` only fires after it. Deliberate: routing judges what
* the model was told, never a string that is not in the conversation. Match
* on the tool NAME (`onToolReturn`) or a declared `status` (`onToolStatus`)
* when you want a guard placement cannot move.
*/
readonly lastToolResult?: {
readonly toolName: string;
readonly result: string;
};
/**
* EVERY tool result of the previous iteration's batch, in call order
* (9.16.0). When the model calls several tools in one message, each
* result lands here as it happens — so `on-tool-return` triggers and
* skill-graph routes evaluate the whole batch instead of only the last
* call (`lastToolResult` === the last entry). A batch of one behaves
* byte-identically to the singular. Absent on iteration 1 and for
* contexts built by callers that only supply `lastToolResult` — use
* {@link toolResultsOf} to read the batch with that fallback applied.
*
* Each `result` here is the same string the model read, so artifact
* PLACEMENT can substitute a claim ticket for it — see the note on
* {@link InjectionContext.lastToolResult}, which owns the statement.
*/
readonly toolResults?: ReadonlyArray<{
readonly toolName: string;
readonly result: string;
/** The provider's tool_use id for this call — names the exact call in
* `skill.route_conflict`. Absent for singular-fallback entries. */
readonly toolCallId?: string;
/** The tool's OWN declared outcome (9.19.0) — present only when the
* tool returned a result envelope carrying `status`. `onToolStatus`
* route edges key on it; a result without one can never match a
* status edge (an undeclared outcome is not evidence). */
readonly status?: ToolResultStatus;
}>;
/**
* IDs of LLM-activated injections that the LLM has activated this
* turn (via their `viaToolName` tool call). Engine includes them
* in the active set on subsequent iterations until turn end.
*/
readonly activatedInjectionIds: readonly string[];
/**
* The skill-graph CURSOR — which skill node the graph is currently
* *in*, persisted across iterations. Undefined before the first entry
* (cold start). `skillGraph()` route edges are `from`-gated against it:
* an edge `A → B` only fires while `currentSkillId === 'A'`, which kills
* cross-skill edge bleed (an edge firing while in an unrelated skill).
*
* Set by the loop's cursor-update stage to `graph.nextSkill(ctx)` each
* iteration; absent for agents that don't use `skillGraph()`. Plain
* `rule`/`always`/`on-tool-return` predicates may ignore it.
*
* **Scope: ONE run.** "Persisted across iterations" means across the iterations of
* a single `agent.run()`. It lives in that run's state, so a second `run()` on the
* same agent begins cold — at the entry, not at the skill the previous turn ended
* on. That is deliberate: a skill graph declares how ONE turn is routed, and a
* cursor that survived would silently make turn 2 start somewhere the author never
* declared. To carry a position between turns, persist the id in your own store and
* start the next turn's graph from it.
*/
readonly currentSkillId?: string;
/**
* The skill the MODEL asked for with `read_skill` on the previous iteration —
* and that the skill-graph reachability gate ACCEPTED. One-shot: the tool-calls
* stage rewrites it every iteration, so it names a pick made just now, never a
* stale one (which is what keeps it from dragging the cursor backwards after a
* declared edge has moved on).
*
* `graph.nextSkill` honours it as the "validated volunteer" hop — but only after
* a declared edge has had its say, so an author's deterministic route is never
* overridden by a model guess. Present only for agents with a `skillGraph()`
* whose gate is wired; absent everywhere else, which is why plain `read_skill`
* agents are untouched by it.
*/
readonly pendingSkillPick?: string;
/**
* The `propose-transition` tool effect the gate ACCEPTED last iteration
* (9.19.0) — a TOOL's validated proposal to move the cursor, already
* reachability-checked where it was accepted. One-shot by data: it is
* stamped with the granting iteration and threaded into this context only
* on the following one. `graph.nextSkill` honours it BETWEEN a declared
* edge (the author's determinism still wins — `D1`) and the model's own
* pick (a deterministic tool outranks a model guess): the winning clause
* is recorded as `cursorMove.by: 'tool-proposal'`. Present only for
* skill-graph agents whose tools returned one; absent everywhere else.
*/
readonly pendingToolTransition?: {
readonly targetSkillId: string;
};
/**
* Injection ids a `require-instruction` tool effect is PUSHING into this
* very evaluation (9.19.0) — the lease-served set, computed per pass from
* the granted leases (`'next-call'` serves exactly the following call;
* `'until-skill-exit'` serves while the granting tenure holds). The
* evaluator admits these ids into the active set beside what their own
* triggers decided; `read_skill` stays the pull door. Absent unless a
* lease is live this pass.
*/
readonly leaseActiveIds?: readonly string[];
/**
* Injection ids the mount kernel is SUPPRESSING this pass (9.58.0) —
* members of a parked map. The evaluator skips them with the honest
* reason `'parked'` before any trigger or lease is consulted. Absent
* unless the maps kernel is mounted AND something is parked, so the
* common evaluation is byte-identical to 9.57.0.
*/
readonly parkedIds?: readonly string[];
/**
* Skill ids the caller's ROLE may not see this pass — the agent's
* `skill_read` answer, resolved once per iteration BEFORE anything
* decides (the Injection Engine's Gather stage). Read through
* {@link isHiddenSkill} and nothing else: the evaluator refuses to activate
* a hidden skill whatever activated it (a rule, a lease, a cursor, a pick),
* and the skill-graph cursor never moves onto one, so the active set and
* the cursor agree. Absent unless the agent's permission checker governs
* `skill_read`, so every other evaluation is byte-identical.
*/
readonly hiddenSkillIds?: readonly string[];
/**
* The relevance ranking of entry candidates from an entry scorer (`.entryBy()` /
* `.entryByRelevance()`) — written by the PickEntry stage at turn start.
* `defineRelevanceHint()` reads it to detect a near-tie. Absent unless the graph
* used an entry scorer. `score` is the raw strategy score (cosine / word-overlap);
* `relevance` is the softmax share (the surfaced %).
*/
readonly entryScores?: ReadonlyArray<{
readonly id: string;
readonly score: number;
readonly relevance: number;
}>;
/** Name of the entry scorer that produced `entryScores` (e.g. `'keyword'`,
* `'embedding'`). Absent unless an entry scorer ran. */
readonly entryScorer?: string;
/**
* The turn-start routing verdict (SG-C) — written once per turn by the
* RouteTurn stage on agents whose graph runs the cascade (`classify` or
* `continuity: 'conversation'` configured), carried by the mount mappers.
* The cursor resolver consumes `to` on iteration 1 (the same
* precomputed-input pattern as `pendingSkillPick`); the tier-3 envelope
* reads `offered` while the menu is outstanding. Absent everywhere else —
* a graph without the new options never sees this key.
*/
readonly turnRoute?: import('./routingPolicy.js').TurnRoute;
/**
* WHERE the active skill's declared procedure stands (9.18.0) — present
* only while a stepped skill holds the tenure. THE FRESH pointer: the
* Evaluate stage re-keys it (cursor moved → reset; tenant unchanged →
* pass-through) BEFORE triggers run, so a `rule` predicate — the
* auto-registered `defineStepsHint` first among them — judges the tenure
* that is beginning, not the one that just ended. `step === total + 1`
* means the procedure completed. Strictly subordinate to
* `currentSkillId`: it can never move the cursor.
*/
readonly stepPointer?: import('./skillSteps.js').StepPointer;
}
/**
* Is `id` a skill this caller's role may not see, on this pass?
*
* Hidden means hidden on EVERY activation path: model and user text are
* untrusted, so a keyword in the message, a declared route, a tool proposal,
* a lease or a `read_skill` pick must never surface a skill the caller's role
* may not read. Every place that decides whether a skill becomes active asks
* this function, with the set resolved once per pass on the context.
*/
export declare function isHiddenSkill(ctx: Pick<InjectionContext, 'hiddenSkillIds'>, id: string): boolean;
/**
* The iteration's tool-result batch, in call order — with the singular
* fallback applied (9.16.0). THE one reader for batch-aware evaluation:
* `ctx.toolResults` when the runtime supplied it, else `[ctx.lastToolResult]`
* for contexts built by callers that only know the singular, else `[]`.
* Both the evaluator's `on-tool-return` arm and the skill-graph cursor
* resolver go through here, so the two can never disagree about what the
* batch was.
*/
export declare function toolResultsOf(ctx: InjectionContext): ReadonlyArray<{
readonly toolName: string;
readonly result: string;
readonly toolCallId?: string;
readonly status?: ToolResultStatus;
}>;
/**
* The messages in `ctx.history` a PERSON actually wrote, in order (9.84.0).
* THE one reader for a predicate that judges what was said.
*
* Seven kinds of `role: 'user'` message in that list came from this library,
* not from anybody: a compaction frame, a drop notice, the two in-loop
* corrections, the out-of-budget wrap-up instruction, the stepped-skill nudge,
* and a message an Injection delivered. They are ours, they are in the
* person's voice, two of them name tools and two quote text the model or a
* validator produced — so a rule written as
*
* ```ts
* activeWhen: (ctx) => ctx.history.some((m) => m.content.includes('refund'))
* ```
*
* fires on a summary of a refund conversation and on a notice saying the
* refund tool's result was dropped. Written as
* `saidByPerson(ctx).some(...)`, it fires only when somebody said it.
*
* The test itself is `isSaidByPerson` in `lib/saidByPerson.ts` — the same
* function the window's refusal engine uses to decide which message it may
* never drop, imported rather than restated so the two answers cannot differ.
*
* @param ctx the iteration context a trigger or entry rule was handed
*/
export declare function saidByPerson(ctx: InjectionContext): InjectionContext['history'];
/**
* THE primitive. Five fields. Four trigger kinds. Three slot targets.
*
* Every named flavor (Skill, Steering, Instruction, Context, RAG,
* Memory, Guardrail, …) is a sugar factory that produces one of these.
*
* @example
* // Direct construction (power user)
* const myInjection: Injection = {
* id: 'demo',
* flavor: 'instructions',
* trigger: { kind: 'rule', activeWhen: (ctx) => ctx.iteration > 1 },
* inject: { systemPrompt: 'Refine the previous answer.' },
* };
*
* // Sugar (recommended)
* const myInjection2 = defineInstruction({
* id: 'demo',
* activeWhen: (ctx) => ctx.iteration > 1,
* prompt: 'Refine the previous answer.',
* });
*/
export interface Injection {
/** Unique id. Used for observability + de-duplication + LLM-activation lookup. */
readonly id: string;
/** Human-readable description (Lens / docs / debug). */
readonly description?: string;
/**
* A skill's plain name for a person (`defineSkill({ title })`) — carried
* onto `skill.graph_declared` `nodes[].title`, never served to the model.
* Top-level so it survives every `{ ...injection }` spread.
*/
readonly title?: string;
/** Observability tag. Drives Lens chip color + ContextRecorder source field. */
readonly flavor: ContextSource;
/** WHEN to activate. */
readonly trigger: InjectionTrigger;
/** WHAT to contribute (one or more slots). */
readonly inject: InjectionContent;
/**
* `true` when `inject.systemPrompt` is a TEMPLATE — it names run-time facts
* from the closed vocabulary in `promptTemplate.ts` and must be rendered
* before it reaches anything (9.57.0).
*
* Top-level rather than a `metadata` key on purpose. At least one live path
* rebuilds `metadata` wholesale (`toolsFromActiveSkill`), and a marker lost
* there means the literal `{{actionsRemaining}}` reaches the model — the
* exact class of failure this feature exists to prevent. Top-level fields
* survive every `{...injection}` spread in the repo.
*
* The template text stays in `inject.systemPrompt`, so the nine build-time
* readers that ask length/existence questions about it keep answering them
* unchanged.
*/
readonly templated?: true;
/**
* Optional flavor-specific metadata. Engine ignores keys it doesn't
* recognize; flavor factories use this for opt-in fields without
* widening the Injection contract.
*
* Known keys:
* - `surfaceMode` (Skill) — `'auto' | 'system-prompt' | 'tool-only' | 'both'`
* - `autoActivate` (Skill) — `'currentSkill'`, the tool gate
* - `cache` (any flavor) — the cache directive
* - `steps` / `onSkip` (Skill, 9.18.0) — the declared procedure; folded
* into a frozen StepPlan map at Agent build (skillSteps.ts owns the
* grammar)
*/
readonly metadata?: Readonly<Record<string, unknown>>;
}
/**
* Returned by `evaluateInjections()`. Slot subflows consume `active`;
* `skipped` is observability metadata (predicate errors).
*/
export interface InjectionEvaluation {
readonly active: readonly Injection[];
readonly skipped: ReadonlyArray<{
readonly id: string;
/**
* `'unknown-fact'` (9.57.0): a templated instruction named a run-time fact
* this evaluation cannot supply, so the WHOLE instruction was skipped.
* All-or-nothing by design — a rendered gap, or a fabricated zero, is
* worse than an instruction that did not speak.
*
* `'parked'` (9.58.0): the injection belongs to a map the mount kernel
* parked — its contribution was served without corroboration for the
* configured grace, so it stops riding until evidence re-engages it.
* The map's own cursor is untouched.
*
* `'hidden'`: a skill the caller's role may not see
* (`ctx.hiddenSkillIds`). Refused before any trigger or lease is
* consulted, so no path can activate it. The id is on this developer
* record only; nothing the model reads names it.
*/
readonly reason: 'predicate-threw' | 'unknown-trigger-kind' | 'unknown-fact' | 'parked' | 'hidden';
readonly error?: string;
}>;
}
/**
* THE BOUNDARY CONTRACT — an active Injection as plain, serializable data.
*
* POJO projection of an active Injection: it flows through footprintjs scope
* (which cannot serialize functions) so that slot subflows can read it across
* the subflow boundary. It drops the `trigger` (already evaluated) and
* projects `inject.tools` to SCHEMAS ONLY — the tool's `execute` lives on the
* host's closure-held registry, looked up by injection id at exec time.
*
* That description is also, exactly, what a host on ANOTHER framework needs
* (9.34.0). This is the shape the injection engine hands out: no predicates,
* no closures, no framework tool objects, nothing that has to survive a
* `structuredClone` and doesn't. A host reads `inject.systemPrompt` into its
* own system message, `inject.messages` into its own history, and
* `inject.tools[].schema` into its own tool list — resolving `execute` by
* `injectionId` in whatever registry it keeps. Which is why the two fields
* that used to name our own layers by inline import — the tool schema and the
* cache directive — are now the structural {@link SkillToolSchema} and
* {@link SkillCachePolicy} from `hostContract.ts`: the same shapes, spelled
* without reaching for an adapter or a cache implementation.
*
* It is a PROJECTION, not a second source of truth. `projectActiveInjection`
* below is the only thing that builds one, and a field that is not on its
* copy list does not cross — see `cache` for what that cost once.
*/
export interface ActiveInjection {
readonly id: string;
readonly flavor: ContextSource;
readonly description?: string;
/**
* The DECLARED surfaceMode (Skill flavor only), copied through as written.
* It drives runtime dispatch — slot subflows skip system-slot injection when
* this is `'tool-only'`; the read_skill tool delivers the body in its result
* for `'tool-only'` and `'both'`.
*
* `'auto'` and absent both deliver like `'system-prompt'` (body in system
* slot, tool result is confirmation only). Nothing here resolves `'auto'`
* against provider/model — `resolveSurfaceMode` is a recommendation callers
* ask for, not a step in this projection.
*/
readonly surfaceMode?: 'auto' | 'system-prompt' | 'tool-only' | 'both';
/**
* Per-skill tool gating (Skill flavor only). Set, it means this skill's
* tools were kept OUT of the static registry, so the tools slot must
* readmit them for as long as the skill is active — which is exactly what
* `buildToolsSlot` does with `inject.tools` on every active injection.
* Compose your own ToolProvider instead with `skillScopedTools` from
* `agentfootprint/providers`.
*/
readonly autoActivate?: 'currentSkill';
/**
* The injection's cache directive, carried across the projection.
*
* It has to be here explicitly. `Injection.metadata` is a free-form bag
* that this projection deliberately does NOT copy wholesale, and the cache
* decision (`computeCacheMarkers`) reads exactly one key out of it — so
* before 7.21.0 every injection arrived at that decision with no policy
* and resolved to `'never'`, which meant no `tools` or `messages` cache
* marker could ever fire in a real run no matter what the consumer
* declared. Projecting the one key the decision reads makes the declared
* policy the policy that is applied.
*
* Absent when the injection declared none (hand-built Injections); the
* decision's own `?? 'never'` default then applies, as it always did.
*/
readonly cache?: SkillCachePolicy;
/**
* Why THIS piece of retrieved content earned its place (8.8.0).
*
* Present only on injections a retrieval produced — one per admitted
* chunk, carrying that chunk's own similarity score, its rank in the
* candidate pool, and the floor it had to clear. The slot composer
* copies these onto the `InjectionRecord`, which is what finally fills
* `ContextInjectedPayload.retrievalScore` / `.rankPosition` /
* `.threshold` — three fields declared since 2.x that nothing has ever
* written, because until the recall was split per chunk there was no
* single score for a record to carry.
*/
readonly retrieval?: {
readonly score: number;
readonly rank: number;
readonly threshold?: number;
};
readonly inject: {
readonly systemPrompt?: string;
readonly messages?: ReadonlyArray<{
readonly role: ContextRole;
readonly content: string;
}>;
/** Tool schemas only — `execute` lives on Agent's closure registry. */
readonly tools?: ReadonlyArray<{
readonly schema: SkillToolSchema;
readonly injectionId: string;
}>;
};
}
/**
* Project a full Injection (with functions) into a scope-safe POJO.
*
* `ctx` (9.57.0) is THE rendering seam: it is the one moment the context and
* the content are both in scope, and every downstream reader — the slots, the
* cache decision, Deliver, the ledger, the recorders, a foreign host —
* continues to see the same static `ActiveInjection` it always saw. Optional,
* so a caller from before 9.57.0 still compiles.
*
* A TEMPLATED injection projected with no usable ctx comes back with NO
* `systemPrompt` at all. Absence is honest; a literal `{{actionsRemaining}}`
* in front of the model is not. Through the engine that path is unreachable —
* `evaluateInjections` has already skipped such an injection by name.
*/
export declare function projectActiveInjection(inj: Injection, ctx?: Pick<InjectionContext, 'iteration' | 'maxIterations'>): ActiveInjection;