Skip to content
Robotadocs

Agent Core Specification

Scope

  • Owns the core agent runtime, tool integration, conversation execution, and plugin-facing agent behavior.
  • Defines the canonical agent orchestration surface used by provider and higher-level packages.
  • Provides abstract base classes that provider packages and extensions must implement.

Boundaries

  • Keeps all provider-specific transport behavior in provider packages. Core must not branch on concrete provider names or model names.
  • Keeps package-specific domain contracts owned once and reused through public surfaces.
  • Does not own workflow visualization or session persistence; session persistence belongs to the session layer.
  • The main entry is platform-neutral, so the agent loop runs wherever its consumer does. It has no host default: hooks run only through the executors a caller passes, and $ENV: references resolve only through the resolver a caller passes; what needs a Node host is on ./node.
  • Zero dependency on other agent-* packages. agent-core must never import any other @robota-sdk/agent-* package as a production dependency. This is the foundation of the layered assembly architecture: other agent-* packages register with agent-core through its abstract contracts; agent-core never depends on them. Product-facing plugins live in @robota-sdk/agent-plugin for the same reason.

Architecture

agent runtime is a Facade over a Manager layer (provider registration, tool registry, agent lifecycle, conversation storage, module loading), a Service layer (message handling/LLM calls, tool schema validation and batch execution, unified event emission bound to an owner path), a Permission layer (deterministic policy evaluation for tool calls), a Hook layer (pluggable lifecycle hook execution via a strategy pattern), and a Plugin layer (one built-in event-coordination plugin; all product-facing plugins live in @robota-sdk/agent-plugin to preserve the zero-dependency boundary above).

All managers, services, and tools accept dependencies through constructor injection — there are no global singletons, and each ConversationAgent instance is completely independent. Safe defaults follow the Null Object pattern (a silent logger, a no-op event service), so a caller who wires nothing still gets working, side-effect-free behavior.

Type and Model Ownership

This package is the SSOT for the core message, provider, permission, hook, context, and interaction contract types that provider and higher-level packages build against; those packages must not re-declare them.

context/models.ts is the registry through which provider packages contribute model context-window and output metadata, and context/model-pricing.ts is the SSOT for per-model token pricing (USD per 1,000,000 tokens) so cost-estimating consumers read one table rather than embedding their own.

The context-window and output metadata of every vendor's models is intentionally not owned here — see "Model Metadata Registry" below.

Path Containment

The SSOT for "is this path inside that root?" whenever the answer is a security decision. path.resolve/path.normalize are purely lexical and never consult the filesystem, so they cannot see a symlink: a link sitting inside a root but pointing outside it satisfies a startsWith(root + sep) check while the syscall that follows escapes the boundary. Every security-boundary containment check in the monorepo (the file-tool sandbox, the CLI monitor asset server) routes through this module's canonical-path comparison, because two containment checks that can disagree are their own defect.

This is exported from @robota-sdk/agent-core/node, not the main barrel: it reads the filesystem, and a barrel carrying it puts node:fs in every consumer's static import graph — the import path is where the Node dependency becomes legible.

Egress Policy

The one outbound boundary for every caller- or model-supplied URL (web fetch, MCP HTTP and OAuth requests, the transport access-token verifier, and any future HTTP client a built-in or node runtime adds), exported from @robota-sdk/agent-core/node because it needs node:dns/node:net. It refuses loopback/private/link-local/CGNAT/multicast/reserved destinations for IPv4, IPv6, IPv4-mapped and IPv4-compatible forms — checking BOTH spellings of an embedded IPv4 address (the hex form a URL renders and the dotted form DNS lookups render), because classifying only one spelling leaves the other as a bypass. It also refuses a fixed hostname blocklist (including cloud metadata endpoints) and resolves hostnames, refusing if any answer is private.

Policy outcomes are returned as a discriminated result ({ ok: false, rejection }), never thrown; transport errors are thrown as fetch throws them. On a cross-origin redirect every caller-supplied header except User-Agent is dropped. The default Node transport connects only to the validated IP address set, preserving the original URL and its normal Host/TLS identity without a second DNS resolution or an ambient dispatcher. The installed transport entry is explicit because a runtime replacement of the bare package import may ignore dispatch options; unsupported capabilities refuse execution rather than selecting an unpinned carrier. Each bounded exchange releases its connections on completion, rejection, abort and failure; a streaming caller owns the signal and explicit cleanup until its response ends or is withdrawn. A failed pinned transport never falls back to another carrier. Trusted fetch injection owns its own connection policy and lifetime rather than inheriting this guarantee. Socket pinning is an application HTTP boundary, not physical worker egress containment or an existing connection's policy revocation.

Owner-Only Store

The SSOT for "create this directory or file so only its owner can read it," for host stores under the configured user and project state roots (session records, logs, settings, device credentials). This module exists because three facts about the Node filesystem API each independently produced a real defect before it did:

  • mkdirSync(path, { recursive: true, mode }) does not set the mode of a directory that already exists — it silently adopts whatever is there (measured: a log directory pre-created at 0777 stayed 0777 while its records were 0600, so another account could read, replace, or enumerate records).
  • writeFileSync(path, data, { mode }) applies the mode only when the file is created — a record an older version left at 0644 keeps 0644 through every later save.
  • Creating a file wide and tightening the permissions afterwards leaves a window where the full record is readable on disk. The module therefore creates with the final mode from the start (atomic wx) and never chmods after, so a mutation that removes the mode is caught rather than masked.

Create, set the mode, then verify — the verification is load-bearing, not a belt-and-braces extra: it is what catches a filesystem that accepts chmod and silently ignores it. Windows cannot express owner-only through chmod; the module reports which guarantee (posix-mode or windows-acl) is actually in force rather than claiming POSIX semantics everywhere, and a project state directory inside a world-writable directory on Windows is not protected by this module. A write that replaces the only copy of a key is durable on request: the temp file is flushed before the rename and the directory after it, so a crash leaves the old content or the new, never an empty file; ordinary records skip the flush because the next save rewrites them. A caller replacing many files can stage each one, flushed beside its target, before renaming any, so no rename is published ahead of content not yet flushed — on macOS, where the flush stops at the drive's cache, that order is most of what protects them. A write can be create-only, committed by a link that fails when anything is already at the path, so two creators cannot both succeed. A failure after the new content is in place is reported as in place but unconfirmed, never as not written, because a caller would otherwise tell its user the old content is still there. Exported from @robota-sdk/agent-core/node for the same reason as path containment: it reads and writes the filesystem.

Process-Scoped Directories

Scratch a process keeps for its own lifetime — spilled tool results, an editor buffer, checkpoint pre-images — is created in a directory named for the owning process that records its owner before anything else goes in, removed when that process ends, and swept by a later start when the owner can no longer be alive. Exported from @robota-sdk/agent-core/node beside the owner-only store, whose directory guarantees it builds on.

A sweep decides by the owner's identity, never by age: an idle session's directory is old and live, and removing it breaks the session. A pid alone is no identity — it is recycled, a containerized CLI is always pid 1, and containers sharing a state root each number their own processes — so the record carries the machine (its host name and, where the platform offers one, its machine id), the pid with its start time, the boot and, on Linux, the pid namespace. A directory is removed only when its owner provably cannot be alive: it is from this machine and an earlier boot, or from this machine, boot and namespace with its pid gone or now started at another time. On a state root shared between machines — a home directory over a network filesystem, a host directory mounted into a container on another kernel — a sweep therefore keeps every other machine's directory, and every one whose record does not name its machine: this machine cannot tell whether that owner is alive, since its boot differs, its pid means nothing here and every Linux machine reports the same initial pid namespace. The machine matches only when every part of it either record carries is equal in both, because a part only one side could read may belong to another machine. A directory from another pid namespace (its owner may be live in another container) or without a readable record is kept too; where a platform offers no boot, start time or machine id (Windows) the pid alone decides on the host the record names. The sweep touches only a real directory owned by the current account whose name has the exact scoped form; a link, a file or any other name is left alone. Inspection considers the same directories and removes nothing, for a host that must not act while another owner runs: such a host needs "alive" and "unknown" apart, so an owner is alive only on this machine, in this pid namespace, with a recorded start time its live pid still has, and every other owner that is not provably gone is unknown. That one three-valued rule is also how a process marker's owner is judged, so the two never disagree about whether a process runs.

What goes when a process ends — these directories, and the process groups of children given their own session (below) — is one registry for the whole process, shared by every copy of the module, so many entries cost one exit listener; a second registry would take the first's signal listener for someone else's handler, and the signal would be ignored. Node runs no exit listener when a signal's default action kills the process, so the registry also listens for SIGHUP, SIGINT and SIGTERM — but acts only when nothing else handles the signal, and then re-raises it, so the process dies by that signal exactly as it would have. A process with its own handler ends as that handler decides, and its exit runs the registry; signal-exit's listeners follow the same rule and are counted as cooperating, or each would wait for the other and the signal would be ignored. A crash handler that rethrows ends the process without an exit event and must run the registry itself first; none is installed here, since one would keep a crashing process alive. Windows console events are left to Node, and a run they end is swept at the next start.

Children's Sessions

Whether the children a process starts share its controlling terminal is one decision for the whole process, made once by its entry: the terminal belongs to the process, and this package's own hook executor and identity readers spawn while it may depend on nothing, so an option threaded through constructors would leave every default sharing the terminal. While the decision is off and no handover is held, the spawn options a caller passes come back untouched and execFile runs as itself, so plain spawning is unchanged. A held handover refuses these child-session spawn paths until its descriptor is consumed, so a new child cannot inherit that channel. Once on — it never turns off — each child leads its own session and process group, so it cannot open /dev/tty or type into the terminal with TIOCSTI; a caller may not hand it a terminal descriptor either (an explicit inherit or one of this process's standard streams is refused), because an inherited descriptor reaches the terminal without it being the controlling one. Node's own defaults hand none, a sync spawn included, which pipes stderr and copies it to this process afterwards, so a stream left to the default is left as it is. Detached children no longer get the terminal's hangup, so a child the process should end is registered with the registry above: its whole process group is killed when the process ends, and a signal sent through the module reaches the group, not only the leader. Both hold only while the leader lives and the registration is released when it exits, because once a leader is reaped its pid, and so the group's id, may belong to an unrelated process: nothing is signalled or kept registered for it then, and a descendant that outlives its leader is left running (a documented residual, as it is with execFile). execFile forwards no detached, so while the decision is on the same call runs over spawn and settles exactly as Node's does; the one difference is that a signal reaches the group while the leader lives. The registration outlasts the call, so a program that ignores the signal is still ended with the process. Children started through the terminal handover keep the terminal by design, and are the only ones not routed through this decision. Windows stays off: a console is not a session a child can leave — any same-user process can attach to it — and detaching there opens a new console window.

Process Markers

A process marker is an exclusive, named claim one process holds across every process that shares the directory it is kept in, so a host can give one record one writer across processes and not only within one. The port is neutral and the file implementation is on the Node entry. A marker names its owner by the same record a process-scoped directory carries, and stands while that owner is alive or unknown; a second publisher is refused with the owner, and only a marker whose owner is provably gone is broken.

Any program running as the same user can plant or remove a marker, so it is a coordination device and a planted one is a denial of service, never a trust anchor. A marker is published by linking a flushed temp file into place, so a crash leaves it complete or absent, never an empty marker that reads unknown forever. Marker transitions and each guarded mutation share a host-supplied, crash-released operation lock; a lock is never stolen merely because time has elapsed. Under that lock a handle verifies its marker before it runs the mutation, so a cooperating forced release cannot cross a write and a writer that lost its marker fails closed. The guard coordinates cooperating versions only; replacing its path can split the lock namespace. A process recognises its own markers from what it remembers publishing, never from liveness, since where no start time can be read a live owner, itself included, is unknown. A lent marker carries the digest of a nonce: whoever presents the nonce may take the marker over, and the lender ends the lease whoever holds it by then.

Credential Store and Artifact Sealer Ports

The contracts for keeping a host's secrets and for keeping its stored artifacts readable only to the key holder. The credential port — a string by a service-and-account key, read, written and removed, and nothing more — lets a package that needs a secret kept take the port without depending on the host that implements it, so an OS keychain and an owner-only file are interchangeable behind it. A store that cannot be read throws rather than reporting the secret absent. Coordination between readers (one refresh or one creator at a time) is the caller's, because a keychain offers no atomic create and the same lock must serve every backend.

The sealer port does the same for a session record, a log line or a checkpoint file: it seals and opens a stored artifact bound to its kind and its owner's id, so a package that writes artifacts takes the port without depending on the keyring behind it, and a sealed file moved to another owner's path does not open. Opening proves that binding and that nothing changed since sealing, not who sealed: sealing needs only the public key, so a locked process still writes, and so can any program that reads the key. Who wrote an artifact is what an authorship tag says, which only unlocked keys make and check, so a sealer that holds no secret offers none, and it says when its keys unlock so a writer can tag what it wrote while they were locked. Every sealed artifact opens on its own; the kinds appended and read back in bulk may share one data key per writer, because a public-key operation per line is too slow to read back, while each whole artifact gets its own key so nothing on disk links two of them, and the port classifies every kind so a new one cannot be added unclassified. A sealer may hand its sealing to another process as a grant only when the grant carries no secret and the key it seals to outlives the sealing process; any other sealer offers none. The two ways opening fails are different errors on purpose — locked means the artifact is intact and this process cannot open it now (its keys are locked, or it holds none and only the owner's process reads it), corrupt means it does not open here (not sealed in a framing this build reads, damaged, or bound to another owner or kind) — and neither carries plaintext, a key or a path. The framing of a sealed artifact is exported on its own, because a reader holding no sealer must still tell a sealed file it cannot read from a damaged one; every framing version counts as sealed, so an older reader never mistakes a newer build's artifact for plaintext.

Permission Argument Registry

Which argument a tool's permission patterns are scoped to is declared by the tool's own package, not resolved from a hardcoded table of product tool names in this vendor-neutral foundation — a hardcoded table could never know a product's full tool inventory, so an argument-scoped deny for an unknown tool could never match, and a false read from a deny list reads as "not denied," meaning the deny would silently lose to any broader allow beside it and the invocation would be auto-approved.

The argument key is declared together with its kind (path | url | command | text) as one object, because a single glob syntax served every kind and, for example, a URL pattern that looked like a plain wildcard was actually a wildcard over the whole URL including scheme and host. A bare */** pattern matches any invocation for any kind and even for a tool with no declared argument (the preset "match everything" case) — in both directions: a keyless tool under a deny list is denied, and under an allow list it is auto-approved in every mode.

Command authorization treats compound invocations conservatively. A deny or ask rule that cannot be conclusively evaluated requires fresh approval because an arbitrary shell program can invoke another command indirectly; a literal non-match alone is not evidence that the restricted command will not run.

A pattern or argument that cannot be evaluated in its declared kind is a distinct outcome from "did not match," and evaluatePermission routes an unevaluable deny or ask pattern to an ask rather than falling through to the allow list; a boolean match predicate alone cannot make that authorization decision. Ask patterns are read in the deny direction for the same reason: both mean stop and check. For the same reason a rule naming a tool's primary field as a parameter asks rather than being ignored, since an ignored deny fails open. Whether name: in a pattern names a parameter is decided by the tool's declared parameters, not by the text's shape, so a URL or command pattern containing a colon keeps its meaning. Allow rules are held to a narrower grammar than deny and ask rules — no parameter rules and no tool-name glob without a literal server prefix — because in an allow list either form would grant tools or calls the operator never saw. A tool denied outright is withheld from the model rather than offered and refused on every call.

Model Metadata Registry

Who owns the answer to "how big is this model's context window." The package that owns a model contributes its metadata (each provider package registers its own table), and this registry is the ONLY aggregation point: core carries no vendor table, because that would break its no-branching-on-concrete-models rule. DEFAULT_CONTEXT_WINDOW (200,000) is Claude's number, so a model nobody registered must never be planned against it silently — a model nobody registered is a model nobody owns, and the lookup helpers say so explicitly (one warning per unknown model) before falling back to the default.

Diagnostic Sink

Where this package's diagnostics go. A logger with no sink of its own writes to the process-wide sink a host installs (setGlobalLoggerSink), so every diagnostic log call, including failure paths, has a reachable destination. The default is silent: a library that starts writing to the console just because it was imported is its own defect, so turning diagnostics on must be something a host does deliberately. The sink is resolved per call rather than frozen at construction, which is what lets a logger created during module initialization honor a sink installed afterwards.

Abort Classification

The SSOT for "was this failure an abort?" whenever the answer changes what a caller reports. Testing an error's message text for the substring "abort" would report a real provider failure whose text happened to contain those letters as a clean interruption, with nothing downstream able to tell the difference. The authoritative signals are the caller's own AbortSignal and the error's own name, neither of which is a guess about wording. This is exported (not kept private) because more than one layer needs the identical decision, and a private copy is exactly how a wording-based bug survives a fix applied elsewhere.

Structured Output Schema Conversion (Zod / JSON Schema)

IParameterSchema is the one shape every tool schema and every structured-output schema in the repo is expressed in — the same shape describes a root object and every nested node; there is no separate nested form, because splitting the two would let a nested object be emitted as a bare { type: 'object' } while only the root named its fields.

  • Exactly one of type / anyOf may be present on a node; emitting both is invalid JSON Schema (a provider applies both constraints and rejects whichever branch fails), and a node with neither is refused everywhere rather than passed silently.
  • additionalProperties declares closure relative to a declared properties set: a node that declares properties (even an empty one) is closed unless additionalProperties says otherwise, while a node that declares no properties at all permits any properties, per JSON Schema semantics. The presence of the member, not its emptiness, is what decides — this reflects that the convention was authored for a tool's parameters root, where properties is always present, so "omitted rejects extras" only ever meant "nothing beyond the declared set" there; carrying that phrasing unchanged onto a nested free-form-object node (which legitimately omits properties) would silently give the omitted case a second, unintended meaning.
  • Depth and size bounds are owned by exactly one traversal (validateAgainstJsonSchema); a tool's own input validation keeps only its caller-facing leaf error messages and delegates the rest to that one function, so the input and output validation paths cannot disagree about the same schema. A size bound answers to the value rather than to the node's type, as JSON Schema says, so it holds on a union node too, and a string's length counts characters, not UTF-16 units.

Zod-to-schema conversion supports the common constructs (strings, numbers, objects, arrays, enums, unions, records, optional/default/nullable wrappers, refinements/transforms unwrapped at every level). A few choices are deliberate rather than incidental: ZodNullable keeps the null half of the accepted type in the emitted schema, because dropping it is a silent rejection of a payload the author's own Zod schema accepts. ZodNativeEnum emits only the enum's values, not the reverse-mapped names a numeric TypeScript enum also produces via Object.values, to avoid advertising an accepted string that the underlying field does not actually accept. ZodTuple, ZodIntersection, and ZodLazy throw at conversion rather than being mapped lossily — the target schema subset has no positional-items, allOf, or $ref construct to carry them faithfully, and a lossy mapping (e.g. flattening a tuple to "array of any") would silently tell the model a laxer contract than the author wrote. The error names the construct and why, and suggests an alternative Zod expression, so the boundary is discoverable rather than a silent surprise. Zod's three unknown-key modes (.passthrough()/default strip/.strict()) map distinctly to additionalProperties; strip still emits true because the extra keys are accepted at the boundary and dropped afterward by the tool wrapper, not rejected — emitting false for it would tell consumers to reject payloads the author's schema actually accepts. A non-object conversion root throws rather than returning an empty schema, because an empty schema reaches the model as "an object, contents unspecified" — exactly the ambiguity this converter exists to prevent.

Tool Schema Projection

A third-party tool's input schema still has to cross one more boundary before it reaches a provider: each vendor's wire format accepts a different subset (Anthropic: standard JSON Schema with an object root; OpenAI: an array must declare items and an object properties, and strict mode also wants every object closed and every property required, optionality expressed as nullable unions, no string-length or property-count bounds, no default and no schema-valued additionalProperties; Gemini: an OpenAPI-3.0-shaped subset that cannot carry additionalProperties at all and refuses an array without typed items). One shared, pure, deterministic projector is used by every provider instead of each reshaping the schema itself; it never throws and never mutates its input, returning one of three outcomes: adopted unchanged (same reference), adapted (a projected copy, with a list of what changed), or rejected (unusable for this provider, with a reason).

Refusals include a non-object root, a dangerous property name (__proto__/constructor/prototype), a cycle, excessive depth or node count, and (depending on the provider's policy) unrecognized keywords or a node declaring both type and anyOf. Adaptations strip or replace unsupported keywords/members and, where a provider requires it, declare the items or properties a node left open, close objects and force-require all properties (representing an optional field as nullable-and-required instead, since strict mode has no separate optionality channel). The completion belongs here rather than in the subset because a bare { type: 'array' } is a valid schema any server may declare; only some vendors refuse it. When an adaptation removes a validation constraint (something that actually restricts accepted values, e.g. pattern or additionalProperties) rather than a mere annotation, the tool's description gains one trailing note naming what was not shown to that provider — so a caller can tell, from the schema alone, that a real constraint was dropped for wire compatibility, as opposed to a harmless annotation.

Crucially, a projection changes only what is put on the wire to the provider: the pre-projection, unmodified schema remains what actual tool-call argument validation checks against, so a wire-compatibility relaxation (like the strict-mode nullable compensation) can never widen what the tool call itself is allowed to contain.

Structured Output Contract

Requesting a schema-validated result returns a validated typed object instead of a string; a streaming run delivers the validated object as the stream's terminal/return value rather than as another delta.

Before the first model call, the resolved provider's capability table is asked which transport can carry the requested schema for this model, and the request is shaped accordingly: a native schema parameter is used when available; a plain "JSON mode" flag is used when the provider guarantees valid JSON but not a specific shape; and when neither exists, the option is omitted from the transport and the schema is instead stated as a system instruction on the very first attempt — so a provider without a schema parameter does not have to spend its first attempt discovering that the target shape was never communicated. A provider that declares no capability table at all is sent the request unchanged: silence about a capability is not the same as a stated absence of it, and treating it as a denial would be an unjustified negative claim.

Endpoint provenance (is this endpoint actually the vendor's own, or a custom base URL that may not honor the vendor's stated guarantees) is tracked as a fact independent of the capability table itself, because a provider can decline to publish a capability table (nobody has verified one) while still needing to report whether it's pointed at a vendor default — folding the two together would force such a provider to either fabricate a capability claim or misreport a gateway as a model that lost a capability.

The final response text is parsed and validated on every run; a validation failure triggers a bounded number of retry turns whose input carries the validation issues alongside the schema, and every attempt (including retry-feedback turns) is committed to conversation history through the standard append-only path — structured output never rewrites history. Exhausting the retry budget throws a dedicated error carrying the validation issues and attempt count. Tools may run within a structured turn; the schema is validated only against the final assistant text once tool rounds complete.

A forced-tool transport (calling a synthetic tool whose parameters are the schema) was considered and deliberately not implemented: it would require a provider that simultaneously lacks a native schema parameter AND has enforceable strict-tool-argument support, and across this workspace that intersection is empty — introducing a union branch nothing can produce is a case every consumer would have to handle and no test could exercise.

Putting every registered tool's full schema in front of the model on every request does not scale with the tool count, so a tool schema is either resident (always offered — the default) or deferred (withheld from the request until a search tool loads it), and the set actually sent on a given request is what's offered.

Deferral here is stronger than the vendor feature of the same name: a vendor's native "defer loading" keeps a definition out of the model's visible context while the request still carries every schema server-side, because the vendor's own search needs them there. This implementation withholds the schema from the outbound request entirely, so both wire bytes and context tokens actually fall, and the same behavior works uniformly across every provider — including ones that document no comparable native feature — because the model-visible search mechanism is just an ordinary function tool over a catalog this package owns.

Two invariants are enforced, both refused rather than silently repaired: at least one tool must remain resident (a configuration that defers everything offers the model nothing to call and nothing to search with, mirroring a vendor's own rejection of the same configuration), and if deferral is engaged and at least one schema is actually withheld, the request must also offer a tool search tool — a deferred tool with no loader is unreachable, and the policy refuses that outcome rather than leaving it silently unreachable.

The offered tool list is read fresh on every round rather than snapshotted once per run, which is what makes "a tool loaded by round N is usable in round N+1" possible at all. Deferral engages automatically only past a threshold (either a deferrable-tool count or an estimated-schema-token share of the model's context window), not unconditionally, so a small tool set is completely unaffected and a growing one switches on with no explicit flag day; an explicit configuration setting always overrides the automatic threshold. Deferral never widens authority: the permission gate decides by tool name only and never consults residency state, so a permission rule has the same effect whether its tool is currently loaded or not.

Disposal Contract

Every disposal surface in the stack follows one convention: disposal is best-effort and never rejects for a cleanup failure, so a fire-and-forget disposal call is always safe (a rejection there would otherwise be an unhandled rejection capable of killing the host process). Every cleanup step runs regardless of an earlier step's failure; each failure is recorded and returned to the caller rather than thrown, and state is always reset before guarded manager operations can resume. Operation-style closes that a caller directly acts on (as opposed to teardown) are the deliberate exception and keep throwing, because their errors are answers the caller needs, not cleanup noise to be collected.

At the agent level specifically: destruction awaits the run-queue tail so in-flight and already-queued runs settle first, then disposes plugins and modules, closes providers whose registrations it owns, and resets state; a borrowed provider remains under its external owner's lifetime control. Once destruction is initiated the instance is terminal — new runs are rejected and re-initialization is impossible, but repeated destroy calls are idempotent. A failed (not yet successful) initialization is NOT cached as a permanent failure, so a caller can retry construction-time setup before the instance is actually destroyed. After shutdown completes, the agent must hold no live timers or listeners; an undisposed resource that keeps a process alive is a contract violation, not a cosmetic leak.

Plugin resource cleanup follows the same single entry point (dispose(), not an ad hoc destroy()), and a plugin owning timers, sockets, or storage handles must override it to release them. A plugin's own handler failures must never take down the host process: by default a throwing handler is recorded and swallowed rather than rethrown, and any background flush the plugin schedules must attach its own rejection handler — a bare unhandled promise there is exactly the kind of process-killing failure this contract exists to prevent.

Permission System

One evaluation order serves every caller. The interactive session, background tasks and subagents differ only in the context they pass, never in the order, because two orders let the same call be decided differently depending on who made it; a background policy therefore contributes a ceiling, an ask-everything flag and the task's own lists. A message from another agent session is text from an untrusted third party that carries no authority, so the turn it starts contributes nothing but whether the reply to that peer exists and whether a tool that acts only for the operator is withheld: everything the model does in it is decided by the same rules, mode and remembered consent as the session's own work, and nothing about the peer — where it runs, what it asked for — widens or narrows that. The reply exists only in such a turn, and there it is decided like any call that sends something off this machine — rules, mode and remembered consent apply to it as to the operator's own calls — never by inferring from the conversation whether what it carries came from the machine, because no reading of the history tells what the text contains; when it asks, the operator sees the full text and the peer it goes to. A provider's own hosted tools run at the vendor and never reach this evaluator; the owner's turns carry them because the owner enabled them, but in a turn a peer's message started no step of the ordinary permissions could decide them, so its requests carry none.

Invariants the order keeps:

  • Nothing later softens a denial. A matching deny rule and a caller's ceiling outrank every ask, every allow and every mode, bypass included. A call outside the ceiling is refused rather than offered to a person.
  • Some calls always reach a person, every time. A deny or ask rule that cannot be evaluated, an explicit ask rule, a removal whose loss would be catastrophic, and a write into a location that widens what later turns, child sessions or the user's shell will trust all ask in every mode — and a consent remembered from an earlier call never answers them, because a remembered scope is wide enough to cover the next such call too. Bypass is not exempt, unlike Claude Code: here bypass also reaches unattended subagents and SDK runs, where a silent write to configuration would widen trust for later turns and child sessions. Only what a tool's declared argument shows can be judged this way; a shell command writing into those locations is the containment seam's concern, not the gate's.
  • Bypass and allow rules only answer what is left. They turn a remaining question into proceeding and never override one of the answers above.
  • An ask resolves to a person where an approver is attached and to a denial where none is, so a detached caller and an attended one share the order and differ only in who answers.
  • Plan mode does not ask about changes. An ask about anything but an inspect-class call is a denial there, and a tool with no declared risk class is refused; only an explicit allow rule lets a change proceed in plan mode.

The mode itself decides only by the tool's risk class (inspect/modify/execute) — the declared one, except that a command tool whose call runs only built-in read-only commands, naming nothing outside the workspace once symlinks are followed, is decided as inspect, so looking around never prompts while a user's ask or deny rule, checked first, still can — and the matrix intentionally names no concrete product tool: this vendor-neutral foundation cannot know a product's tool inventory, so classification is declared by the package that defines each tool — a hardcoded matrix would drift from the actual tool set. A tool with no declared class prompts rather than defaulting to permissive, so an unclassified tool is at least as cautious as a hand-classified risky one.

Hook System

A pluggable lifecycle hook mechanism supporting multiple execution strategies (shell command, HTTP, and, in higher layers, LLM-prompt and sub-agent delegation).

Outcome contract. A hook executor returns a decoded three-way outcome — allow, deny, or error — rather than a raw exit code or HTTP status. error is the absence of a verdict, not a third verdict, and carries a reason for why no verdict could be decoded. This distinction exists because the previous shape had no channel for "the hook itself failed," so every failure was coerced into a verdict by ordinary truthiness: a malformed non-boolean field could read as approval and silently disable the gate, while a missing field could read as denial and block a tool call no hook had actually objected to. A decoded body is trusted only when its verdict field is exactly true or exactly false; an explicit block directive stated elsewhere in the same body is honored even alongside an otherwise-undecodable verdict field, because that directive is a decision the hook stated outright and an undecodable field beside it does not retract it. Only a command hook's body can steer a tool call short of refusing it — a PreToolUse permissionDecision of allow, ask or defer, or an updatedInput — because a prompt or agent hook answers from a model that reads the very tool input it judges, and a body that could approve would let that input approve itself. Among several hooks, ask outranks defer: a hook that leaves the call to the normal flow must not cancel another hook's demand for a person.

Per-event enforcement posture. Whether an error outcome actually blocks the operation is a per-event policy, not a blanket rule — recorded event-by-event, together with whether that event's call site is even capable of honoring a block (many fire sites do not await the hook result or do not inspect it). Only the event whose fire site both awaits and inspects the result is allowed to enforce; asserting "enforcing" for an event whose fire site can't act on it would flip a row that changes nothing while reading as though a gate had been switched on. In this codebase, tool-use gating is the sole event that fails closed on an undecodable hook result; every other lifecycle event is advisory. A single published predicate answers "does this event enforce," so no consumer re-derives the rule from the table and risks disagreeing with it.

Command hook exit-code mapping. A command hook's exit code is one input to the outcome decision, not the decision itself: exit 0 is allow (with stdout carried through), exit 2 is deny (with stderr as the reason), and every other exit code, a signal kill, a timeout, or a process that never started is error with a specific named reason (never silently coerced to allow or deny).

Cancellation Contract

A run's cancellation signal is the single source of cancellation for that run, and gates identically across every entry point into a turn — there is no second, cancellation-blind execution path. It reaches every provider call including abnormal ones (e.g. a forced end-of-round summary call goes through the same signal-carrying path as a normal round call), every tool execution (a long-running built-in tool MUST observe the signal and terminate its own work rather than completing silently after abort — silent completion after abort is a contract violation), and a streaming consumer that abandons its generator early also aborts the underlying turn, since a turn that keeps writing to history after its only reader left is worse than one that was cleanly cancelled. Cancellation resolves as "interrupted" with the text committed before abort, unless an awaited execution journal fails: persistence failure must remain a failure even when cancellation or an observer exception races it. By default cancellation can complete before the provider call settles; a host may opt into joining provider settlement so completion also releases ownership of that call, accepting that a provider which ignores abort can keep the run pending. A journaled run joins settlement because a late response may contain chargeable usage or tool intents that must be recorded. Other late provider results or rejections never replace the cancellation or timeout that won.

Reasoning Effort

A provider-neutral effort vocabulary (none through max, plus an auto selection state that is NOT itself a native provider control) that adapter packages resolve against their own source-dated, per-model capability tables — this package supplies no vendor defaults or field names. Resolution yields one of a small set of dispositions (applied exactly, clamped downward, fell back to the model's own default, or not applied at all) plus an immutable audit record, and the terminal outcome separately records whether a native control was actually sent to the provider and whether a provider dispatch occurred at all — a cached or otherwise no-dispatch response can never claim to have applied a native effort control, which matters for anything auditing what was actually sent on the wire.

The model-configuration read/write API (getting or setting the active model) is answerable immediately after construction, without requiring the agent's async readiness to complete first: the state it reads (provider registry, current provider/model pair) is established synchronously in the constructor from already-validated config, because forcing a caller through the async initializer just to ask "which model is this?" makes no sense. A destroyed agent reports its own teardown as teardown (a specific disposal-check message), not as "not yet initialized" — those are different failures, and conflating them sends an investigation looking for a missing await that does not exist.

Provider-Native Replay Payloads

Provider packages own their native SDK request/response/stream objects and report them through a provider-neutral callback bridge for replay-grade session capture; this package must not import concrete provider SDK types, inspect provider identity, or choose provider-specific payload fields — a provider names itself and its own API surface as opaque strings, and this package only wraps the callback, assigns a stream-order fallback when a provider omits one, and stamps the current execution/conversation/round identity onto the emitted event.

Provider Continuation

A provider may set opaque text on its response under PROVIDER_CONTINUATION_METADATA_KEY for what it needs back on a later request to continue that reply, such as a vendor's signed reasoning. It is the one piece of a response's provider metadata the committed assistant message keeps, because the provider stays stateless and a resumed or replayed session must carry it; this package never reads it, and a provider that did not write it ignores it.

Trusted Trace Context

A run carries W3C trace context only when its host supplies it; this package never adopts ambient trace state. Every call actually handed to a provider — the forced summary included, cache hits and refused preflights excluded — carries a traceparent whose span is derived from that call's own ID by the same function the exporter uses, so the parent a vendor sees is the span the trace contains. It is resolved at invocation, after the request was announced, so no replay event contains it. A provider that does not affirmatively declare it can propagate receives nothing and, when the host listed origins, the host is told which provider, by ID only; a run that hands its trace only to subprocesses has no provider to report. Every tool body is given its own minted ID, because a vendor's tool call ID is neither unique nor hex; in a run with trace context the body receives the traceparent whose span is derived from that ID, so a tool that forwards it names the span exported for that body. A tool body needs no capability declaration, since a tool that makes no outbound request simply ignores it. An adapter or tool sends the header only when its effective origin exactly equals a host-listed origin, because the header discloses trace identifiers to whoever receives it. Child processes are a separate grant, derived apart from the origins so either can be given alone: a class the host enables receives TRACEPARENT in a fresh copy of the environment — never by modifying the process's own, which every other child would inherit — naming the body's span for the shell and the prompt root for command hooks, with the ambient TRACESTATE dropped because it belongs to another parent. A command hook is handed that value apart from its input, so it never appears in the stdin JSON, in an HTTP hook body, or to any other hook type, and a TRACEPARENT the hook's own configuration sets wins over it.

Provider Contract — Dual Surface (Intentional)

Every provider implementation deliberately exposes two request/response surfaces: a universal, public surface (chat/chatStream, operating on the package's normalized message type) that generic layers and SDK consumers use, and a raw, internal-protocol surface used only by this package's own conversation service to thread provider-native request/response payloads for replay capture. The two live on one interface because a provider instance is legitimately both at once; the raw methods are an internal-protocol detail, not a second public API, and only the conversation service is meant to drive them.

Usage-Triple SSOT

The prompt/completion/total token-usage shape is owned once in this package; no package may re-declare that shape — every occurrence elsewhere in the monorepo is either a direct reference, a named alias or an extension of it, never an independently declared lookalike. The prompt-cache read a provider reports is a part of the prompt count, not an addition to it, and it extends the triple rather than joining it, because the triple is also the persisted and wired usage shape whose readers declare exactly its three counts.

Provider Capabilities

Generic layers must query a provider's capabilities through one function rather than branching on provider identity. The loop never chooses a substitute model, since it cannot build providers; a provider that can answer on another model says where a run's request goes and when it moved, and every record of the call — the request announcement, the call observation, the committed reply and the response cache key — names the model that actually answered, so usage and cost are never charged to, nor a cached answer served as, a model that did not run. Provider-native "web tools" (server-side search/fetch) are a distinct concept from this package's own local function tools of the same name: a provider can support a native web capability without that capability being enabled for a given instance — whoever constructs the provider decides, and no generic layer switches it on afterwards — and requesting a native web tool for one call must be checked and rejected before transport execution when the provider doesn't support or hasn't enabled it — failing before any network activity starts, not partway through a stream.

For model-level (rather than provider-level) capabilities, a model's capability table is asked before falling back to the vendor's stated default, and a capability the catalog has said nothing about is reported as genuinely unknown rather than coerced into false — silence about a capability is not a denial of it, and callers that need to act on silence must state their own assumption explicitly rather than have one assumed for them.

Context Window Tracking

Effective context-token usage trusts the latest provider report at a completed response; subsequent unreported messages add their estimated size to that report, bounded below by the serialized-history estimate. An optional caller-supplied floor remains a lower bound, never an additional charge. Historical full-request usage is never summed across turns: this preserves request overhead the provider counted without allowing that old report to hide new input or charging the same conversation repeatedly. Provider-reported usage — including the prompt-cache read, when the provider reports one — is normalized into one canonical metadata shape by one rule for every path that commits an assistant message, the forced summary included, so no path drops counts another keeps; counts the adapter could not attest as a consistent total are recorded and marked partial rather than dropped. Each provider round is tagged with a fresh observation identity so streamed fragments of the same round can be deduplicated by identity rather than by comparing token counts.

Class Registry and Cross-Layer Ports

Provider packages implement this package's provider base class; other layers extend its abstract executor, plugin, module, and tool base classes; and agent-core in turn owns the neutral multi-agent orchestration contracts (primitives, step/spec shapes, and their lifecycle event names) as pure, runtime-free type contracts — a separate, higher agent-framework layer implements the actual orchestration mechanism over them. These contracts carry no application-domain identity by design; if a second independent implementer of them appears, both the contracts and their event-type unions are meant to move out into their own dedicated package rather than staying bundled here.

Event Architecture

Events are named ownerType.localName (e.g. an execution-service event, a tool-execution event, an agent-level event) and each event carries an owner-path trace of the execution hierarchy that produced it, so a consumer can reconstruct which agent/tool/execution nesting emitted a given event without a separate correlation mechanism. The tool-body completion name identifies only the awaited body of a permitted call. A consumer must not interpret pre-execution permission or hook failures as executed tool spans. One tool instance can serve several agents, and the event service set on it is whichever agent set one last. A caller that sends its own service with a call therefore receives that call's FunctionTool span and no other caller's; a call that carries none still reports to the service set on the instance.

Conversation History Principles

  • Append-only: messages are only added, never edited or deleted.
  • Read-only for consumers: readers must not mutate existing messages.
  • Always committed: the begin/commit assistant-message protocol guarantees an assistant message is always appended, even on abort with empty content — there is a single commit path, not a branch between normal completion and abort.
  • No fallback: if a message should be in history, it IS in history; there is no silent fallback to an alternative data source.
  • Unbounded by default: history is never trimmed by message count unless a host explicitly configures a cap.

Only assistant messages may ever carry an "interrupted" state (indicating the response was aborted before natural completion); user/system/tool messages are always complete. When history is read back for a provider API call, an interrupted assistant message's text is annotated so the model itself understands its own previous response was cut short, rather than presenting a truncated response as if it had ended normally.

System Prompt

The system prompt is the agent's live instruction state, not ordinary conversation content — the append-only/read-only history principles above govern user/assistant/tool messages but deliberately do not govern the system prompt, which is replaceable.

  • Single owner, single head message: the system prompt has exactly one source of truth in the agent config, and a conversation store holds exactly one system message, always at the head; setting it removes any existing system messages first.
  • Injected once per session, then reused as-is: the prompt is injected into a session's log only when that log has no system message yet (session start, or the first turn after resume); on every later turn within that session the log is reused unmodified — the prompt is never re-derived or re-attached per turn, because once a prompt has been sent it is part of that session's own record.
  • Live updates propagate immediately: updating the system prompt updates both the config and the live conversation-store head in place, so the very next provider request carries the change — this is the path that lets a session's persona or environment-staleness refresh reach the model as an infrequent, deliberate mutation, not a per-turn rewrite. Updating only the config field without the store head is insufficient, because providers read the system prompt out of the message array, never from a side-channel config field.
  • Session restore refreshes the prompt: starting a new user turn from a persisted session injects the current live prompt instead of restoring a stale system message, while preserving the substantive user/assistant/tool history. Continuing an interrupted execution retains its canonical checkpoint, including the prompt already used by that execution.

This zero-dependency foundation layer injects no persona or product vocabulary into the model on its own — every string it can place in front of a model (default system message, a context-capacity notice, a tool-result-skip notice, a forced-summary instruction) defaults to empty or strictly neutral mechanism text, with an explicit override seam for a product layer that wants its own wording. Any new model-facing string added to this package must default to neutral text and, where appropriate, expose such a seam — this is a standing design rule, not a one-time inventory.

Provider Tool-Call ID Ownership

Provider adapters own the tool-call identifier value; this package treats it as an opaque transcript token linking an assistant tool call to its corresponding tool result message, never as a value it mints or rewrites. Conversation history must not require these IDs to be unique across a whole conversation, because some providers legitimately reuse the same ID value across separate turns — any internal subsystem that needs a truly unique identifier must mint its own internal ID rather than repurposing the provider's transcript token for uniqueness it was never designed to guarantee.

Unavailable Tool-Call Handling

Provider adapters must pass through a provider-native tool call even when its name is not locally registered — the execution decision belongs to this package, not the adapter. An unrecognized tool name is never executed and never silently aliased to a different registered tool; it is recorded as a distinct, explicitly labeled skip (naming the requested tool and what is actually available) rather than a generic failure, is not counted as an executed tool, and is reported with the same detail through replay events so downstream consumers can explain the skip. If unavailable tool calls repeat across consecutive rounds, a loop guard stops normal tool rounds and forces one final call without tools, whose instruction explicitly names which tool calls were never executed. Product layers must not paper over a naming mismatch with an ad hoc alias table — the correction path is making the tool visible and let the model's own error feedback loop correct itself.

Run Concurrency Contract

A single agent instance owns one conversation history, so concurrent executions on the same instance would otherwise interleave history writes. Runs on one instance are therefore serialized through a single FIFO slot: a call made while another run is in flight waits for the earlier run to finish and then executes with a strictly sequential history, without requiring the caller to do any external locking. A streaming run holds the slot only once its consumer actually begins iterating (an abandoned, never-consumed stream never acquires it) and releases it whether the stream completes naturally or is closed early by its consumer. A call whose cancellation signal is already set by the time its turn arrives fails immediately without ever touching the provider or history, and the failure is an abort a caller recognises from the error alone (name === 'AbortError'): the signal's own reason when that already is one, otherwise an abort error with the caller's reason reachable as its cause. This is a different failure mode from cancelling an already-in-flight run (which instead resolves normally as interrupted, per the cancellation contract above). This serialization is strictly per-instance; separate instances remain fully concurrent with each other.

If a run is aborted mid-stream, partial content already produced is preserved in history through the same single commit path used for a normal completion (marked interrupted rather than complete), any tool calls already in flight when the abort landed still complete and record normal results, and any tools still queued behind them are skipped with an explicit "interrupted by user" result rather than silently vanishing — both outcomes are recorded, so a reader of history can see exactly what ran and what didn't.

Execution Loop and Error Handling

Each attempted provider round, including a forced-summary call, emits one content-free completion observation with its actual start/end time, round number, and success/failure/interruption outcome. It describes the shared provider-call boundary (which may be served from cache), not proof of an outbound network request; request and response bodies belong only to their separate execution events and, when configured, the execution journal. Tool queue observations describe submission until selection for pre-dispatch admission or refusal, excluding admission, permission and body work; they never certify an effect or replace a settlement, and observer failure cannot change execution.

The default round budget for one run is a fixed number of model/tool rounds, overridable per-run or per-config (run-scoped values win); a budget of zero disables the round cap entirely and leaves stopping to abort, the context-window guard, and provider timeouts.

A run-scoped toolChoice: none omits local tool schemas from every ordinary provider round. If a provider nevertheless returns a tool call, the run fails before any tool body starts; the directive is not a substitute for provider-side hosted-tool suppression or for restrictions on trusted hooks and plugins. A later run without the override retains its configured tool policy.

Identical-tool-input guard. A configured limit on repeated byte-identical invocations of one tool within a single run raises a named, recoverable failure rather than a successful interruption: the agent failed to make progress, and the caller did not ask it to stop. Without an execution journal, the rejected batch remains in history with explicit undispatched failure results for every committed call so a later turn never sends those calls unanswered; a journaled run retains its durable recovery authority instead of inventing transcript-only settlements.

When the round budget is exhausted without a final assistant text response, and the caller has not allowed a turn to end on its tool calls, one forced summary call is made with toolChoice: none and without local tools, including provider-hosted tools that honor that directive. Returned tool calls are not dispatched; if the call produces no text, a fixed fallback message is returned instead of an empty response, and if the call fails, the run fails with that call's own error exactly as a failed round does. The synthetic instruction used to request that summary is a per-call prompt artifact only — it is never written into the persisted conversation history. A caller that allows tool-only completion gets no summary call, so a turn that ends with neither text nor a tool result is reported as a named empty-completion error rather than as an empty success that would look like a tool-only answer.

Pre-send context guard. Before every provider call, estimated token usage is checked against the model's context window; this is a hard-capacity stop (distinct from — and does not replace — the session layer's own configured automatic-compaction policy) and only trips when usage exceeds a high fixed threshold of the window, at which point it emits a diagnostic message explaining why the prompt was blocked rather than sending a request likely to fail with a provider-side size error.

Provider call failures are surfaced, not swallowed. If a provider call throws, the error is recorded as a readable assistant-visible message rather than the caller seeing an opaque "no response received," and if the whole execution pipeline throws unexpectedly, it is caught and turned into an error result. Execution control outcomes propagate directly: persistence failures retain their original cause, uncertain effects require reconciliation, and a saved pre-effect wait suspends the execution. None may become normal assistant output or a tool failure that the model may retry.

Tool-result context budget. Observations preserve the recorded execution outcome and the host-declared source captured for the call, even when execution never begins or content is omitted; a tool's response cannot replace its registered attribution. Attribution identifies a contributor without granting authority or proving installed bytes. Context-limited observations distinguish a completed action, a known undispatched call and an error whose effects may be uncertain. Omitted content directs recovery toward retained receipts, fresh observation or effect reconciliation; omission grants no authority to repeat an operation. A tool result's view is display data for a surface: it never enters model context, admission's size or the persisted history, and a result replaced by its reference keeps it. The execution loop continues with these bounded notices, and a supplied execution journal retains the full settled receipt for host-owned recovery.

Execution Journal and Continuation

A host may make a run recoverable by supplying an execution journal it owns exclusively. The journal exists so that no model call or tool effect is repeated or hidden after a restart: every boundary whose loss would leave that ambiguous is awaited before the runtime crosses it, and events or observations never stand in for it. A tool's effect is recorded with the arguments it actually runs with, since wrappers may change them after dispatch. A rejected write fails the run instead of becoming a model-visible or retryable tool failure, because a retried effect could run twice; further dispatch stops while effects already running settle.

Continuation resumes a compatible execution from its latest settled model response without new input, reusing every durable settlement instead of repeating it and keeping the limits and tool residency the execution started with. Whatever the journal cannot prove — an effect that started without a recorded result, a model call without a recorded response, runtime state that differs from the saved one — is refused for reconciliation rather than guessed, and live history that conflicts with the saved checkpoint is never overwritten. Restored history stays with the agent for the host to checkpoint or discard. Lifecycle observations must not manufacture repeated work during recovery: turn-level hooks are not replayed, a resumed pre-effect wait remains the same logical call, and restored settlements do not re-enter call observers.

Before its effect starts, a tool may save a request for an outside response and suspend the execution. Requests and responses are durable under stable identities; a response can answer only its own request, is saved before it is used, and never admits an effect by itself — current admission still decides. Suspension is control flow, neither completion nor failure: it returns only once pending writes are durable and tools already running beside the waiting one have finished, while calls not yet started stay pending. Running siblings finish rather than being interrupted because one tool's wait says nothing about an allowed neighbour, and an interrupted result would be saved as its final outcome; cancellation still interrupts them. Persistence and reconciliation failures take precedence over a wait.

Non-goals: visibility into a provider adapter's internal retries, and recovery of arbitrary effects without a cooperating host and runtime.

Class Extension Points

Provider, tool, plugin, module, executor, and storage integrations each extend a corresponding abstract base class or implement a corresponding port interface; a concrete implementation must fulfill that base class's documented lifecycle contract (e.g. a provider must implement both its chat and streaming-chat methods) to be usable by the rest of the runtime.

Error Taxonomy

Most errors this package raises extend one base error class carrying a machine-readable code, a category (user / provider / system), and a recoverable flag, so a caller can branch on failure kind without parsing message text; a provider failure also keeps the vendor's HTTP status and error type, because whether another model could serve the same request is decided from those facts, and a failure it cannot place is treated as not worth switching models for, since switching on an unknown cause can repeat a request that was never going to succeed or mask a bug. Some classes extend Error directly (tool-result admission refusals, owner-only-store mode errors), and some internal failures are thrown as plain Error with no code or category.

Canonical Direct Runtime Tool Invocation

An agent's registered tool catalog (including currently-deferred tools) can be invoked directly through the same execution path a model-driven tool call uses — the same validation, permission-wrapped execution, and event emission — without fabricating a model turn or requiring the model to have discovered a deferred tool first. Direct invocation does not bypass configured tool allowlists or permission enforcement, and core injects no interactive "ask the user" handler for it: only a caller that answers its own prompts supplies one, together with an interactive permission interaction, because a direct caller is not assumed to have a person behind it.

Extraction Trigger

The neutral orchestration contracts (see "Class Registry and Cross-Layer Ports" above) are intentionally light — pure types with no runtime and no application identity — because they exist to let exactly one implementer (agent-framework) build the actual mechanism without this foundation depending on it. If a second, independent implementer family appears, that is the trigger to extract both the contracts and their event-type unions into their own dedicated package rather than continuing to host them here.