agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
135 lines • 7.74 kB
TypeScript
/**
* hosting/standingAgent — an agent that stays up and remembers.
*
* await standingAgent({
* agent,
* sessions: memorySessions(),
* host: nodeHost({ port: 8080 }),
* durability: 'sync', // optional; 'exit' is the default
* });
*
* One request at a time it does four things: wake and hydrate the session,
* continue that session or start a fresh one, persist what the run leaves
* behind, then reply. Everything else is somebody else's job — the host carries
* bytes, the store keeps them, the agent thinks.
*
* ── A run has three ends, and this composer honours all three ────────────────
* It answered, it asked a person something, or it failed. An answer completes
* the reply and stores a conversation. A QUESTION stores the paused run as
* `'flowchart-v1'` and leaves through `reply.awaiting(...)` — its own terminal,
* never `fail`, because a pause is unfinished work and reporting it as a failure
* tells every dashboard downstream something untrue. A later request for that
* session carrying `decision` continues the run from exactly where it stopped.
*
* ── "Start a fresh one" is an answer it must EARN ───────────────────────────
* A session with nothing stored is answered fresh, and that is right. A session
* whose stored conversation cannot be READ is not: an unreadable stored
* conversation and an absent one are different facts, and only one of them is
* safe to answer with a fresh start. So an `UnreadableEnvelopeError` out of the
* store fails THIS REQUEST, naming the session, and the fresh-start path is
* never reached. The alternative is a reply that looks perfect to everyone
* involved while a user's conversation quietly stops existing.
*
* ── Resuming a CONVERSATION is a REPLAY, and that has a cost ─────────────────
* A stored conversation is restored through `agent.run({ message, continueFrom })`
* — the public conversation door since 9.2.0. This composer used to assemble the
* continuation by hand (append the turn, rewrite `originalInput`, hand the result
* to `resumeOnError`); that hand-assembly IS the door now, so a server and a
* script continue a conversation the same way and the identity travels with it.
* The caveat below is stated here in the words the Agent states it in, because a
* composition that hides the caveat of the thing it composes is worse than no
* composition at all:
*
* > **Tool re-execution / idempotency**: tool side effects from the FAILED
* > iteration are not in the checkpoint. The model re-decides from the
* > restored history and may re-issue those tool calls — they WILL execute
* > again (there is no built-in toolCallId dedup). Mutating tools (payments,
* > emails, DB writes) must be idempotent — key on stable call content, not
* > `ctx.toolCallId` (a re-issued call gets a new id).
*
* `durability` is the dial that bounds how much of that a crash can cost you.
* Resuming a PAUSED run is different in kind: it is not a replay at all — the
* engine continues from its own checkpoint, and no earlier tool call re-runs.
*
* ── Why one run at a time, and where that bound actually is ─────────────────
* An Agent instance holds per-run state on itself. Two runs overlapping on ONE
* instance do not crash — which is precisely the danger. They both finish, and
* the state the composer reads afterwards belongs to whichever started last, so
* one session's envelope can end up holding another session's conversation.
* Nothing in the recording would say so. **Runs on one instance are therefore
* serialized, and that is a correctness requirement rather than a tuning
* choice.**
*
* What CHANGED in 9.10.0 is not that rule but its scope. `{ agent }` hands this
* composer one instance to share, so the serialization is global — the shape
* every earlier release had, unchanged to the byte. `{ agentFactory }` hands it
* a way to MAKE instances, so each session gets its own and the same rule binds
* per session: sessions run in parallel, each session's turns queue behind that
* session's own instance.
*
* standingAgent({ agent }) → one instance, global queue
* standingAgent({ agentFactory }) → one per session, a queue each
*
* Everything else is identical between the two: the same stores, the same
* durability modes, the same pause/resume contract, the same refusals. The
* session store is keyed per session already, which is why the pool can evict a
* session's instance without the person on the other end noticing — the
* CONVERSATION was never in the instance.
*
* `onConcurrentInvoke` is the separate question of what to do when a second
* turn of the SAME conversation arrives while the first is running: refuse it
* (default) or queue it. It means the same thing in both shapes. A request for
* a DIFFERENT session is never refused — there is nothing wrong with it; in the
* shared shape it waits its turn, and in the pooled shape it does not have to.
*
* ── The pool, in one paragraph ──────────────────────────────────────────────
* One instance per ACTIVE session, built on first sight by the factory, bounded
* by `maxActiveSessions` (default 100) and evicted least-recently-used. An
* evicted session's agent has its tool sessions closed with reason `'evicted'`
* — the 9.7.0 vocabulary, not a new one — and is then shut down; its
* conversation stays in the store, so its next request re-hydrates onto a fresh
* instance and nothing was lost. A session that is RUNNING is never evicted:
* the bound is on retained idle instances, and a cache policy does not get to
* end somebody's turn. Requests with NO session share one fallback instance,
* because there is no conversation to isolate and an instance per anonymous
* request would be an instance per request.
*
* Pattern: Composition root. It owns wiring and ordering; it invents no
* mechanism of its own.
*/
import type { HostHandle, StandingAgentHandle, StandingAgentOptions } from './types.js';
/**
* Serve an agent, with per-session conversation memory, on any
* {@link AgentHost}.
*
* Resolves once the host is live. Closing the returned handle closes the host,
* detaches the listeners this composer added, and removes its durability
* wiring.
*
* Two shapes, one composer — see the file header for the full comparison:
*
* - `{ agent }` — one instance for every session, runs serialized globally.
* Correct, and unchanged from every earlier release.
* - `{ agentFactory }` — one instance per active session, sessions in
* PARALLEL, each session's turns serialized on its own instance.
*
* @example One agent, every session
* const handle = await standingAgent({
* agent,
* sessions: memorySessions(),
* host: nodeHost({ port: 0 }),
* onConcurrentInvoke: 'enqueue',
* durability: 'sync',
* });
* process.on('SIGTERM', () => void handle.close());
*
* @example One agent per session — they answer at the same time
* const handle = await standingAgent({
* agentFactory: () => Agent.create({ provider, model }).system('…').build(),
* sessions: sqliteSessions({ file: './sessions.db' }),
* host: nodeHost({ port: 8080 }),
* maxActiveSessions: 200,
* });
*/
export declare function standingAgent<TH extends HostHandle>(options: StandingAgentOptions<TH>): Promise<TH & StandingAgentHandle>;
//# sourceMappingURL=standingAgent.d.ts.map