npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lostgradient/operative

v0.12.0

Published

A composable agent loop that wires Armorer and Conversationalist into the standard agentic cycle. Includes provider subpaths for Anthropic, OpenAI, and Gemini (dynamically imported — zero-SDK-if-unused).

Readme

Operative

@lostgradient/operative is the provider-agnostic agent runtime for Agent Bureau. It owns the loop that assembles context, calls a generate function, executes tools, records steps, handles stop conditions, emits events, manages sessions, and coordinates advanced runtime behavior.

Installation

bun add @lostgradient/operative zod armorer conversationalist @lostgradient/weft

Operative supports Bun and Node.js runtimes. Node.js consumers must use Node >=22: CI proves this floor, not the lower floor require(esm) alone would allow (the published CJS exports keep [email protected] external as a declared dependency, and that ESM-only dependency needs Node's unflagged require(esm) support, available since Node 20.19/22.12 — but nothing in this repository's CI exercises a Node runtime below 22, so the declared floor matches what is actually proved). Bun consumers must use Bun >=1.4.0. zod is required. Provider SDKs and OpenTelemetry are optional peers: install only the SDKs for the provider subpaths you use, and no provider SDK is loaded when its provider is unused.

The public package exports are @lostgradient/operative, conditions, durable, guardrails, instrumentation, retry, streaming, store, test, anthropic, openai, gemini, providers, providers/anthropic, providers/openai, providers/gemini, providers/fallover, providers/routing, providers/streaming, providers/embeddings, providers/embeddings/openai, providers/embeddings/gemini, providers/embeddings/voyage, providers/embeddings/ollama, providers/instrumentation, and providers/test.

What It Does

  • Defines agents with createAgent() and drives the loop directly with createActiveRun().
  • Executes tools through armorer and conversation history through conversationalist.
  • Accepts caller-provided generate functions instead of importing model SDKs.
  • Provides sessions, session stores, durable run support, scheduler primitives, and heartbeat utilities.
  • Adds hooks for generation, tool execution, context assembly, validation, run lifecycle, and error handling.
  • Provides guardrails, retry mutators, cache middleware, context compaction, streaming, backpressure, budgets, handoffs, subagents, and supervisors.

How It Works

The core loop starts with an agent definition, a conversation, tools, and a GenerateFunction. For each step, it prepares context, calls the generate function, validates the response, executes requested tools, appends tool results back to the conversation, emits typed events, and evaluates stop conditions.

Everything provider-specific stays behind a narrow seam: the @lostgradient/operative/anthropic, @lostgradient/operative/openai, and @lostgradient/operative/gemini subpaths (plus fallover, routing, and embedding factories under @lostgradient/operative/providers/*) supply ready-made generate functions, but callers can pass any function that satisfies the GenerateFunction type. Durable execution, scheduler tasks, and session persistence build on the same loop so product surfaces can recover or resume runs without changing agent code.

Project Role

operative is the center of the Agent Bureau runtime graph. gateway uses it to run requests and scheduler tasks, @lostgradient/operative/store observes run state and action history, memory and skills attach through hooks and tools, armorer supplies actions, and conversationalist supplies the conversation model.

Table of Contents

Quick Start

Create an agent with a stub generate function and run it to completion:

import { createAgent } from '@lostgradient/operative';
import type { GenerateFunction } from '@lostgradient/operative';

// Minimal inline generate function — swap for a real provider in production.
const generate: GenerateFunction = async ({ conversation }) => {
  const last = conversation.getMessages().at(-1);
  // `Message.content` is `string | readonly MultiModalContent[]` — guard rather than
  // interpolate it directly, which would print "[object Object],[object Object]" for
  // multi-modal input.
  const lastText = typeof last?.content === 'string' ? last.content : '(non-text content)';
  return {
    content: `Echo: ${lastText}`,
    toolCalls: [],
  };
};

const assistant = createAgent({
  generate,
  instructions: 'You are a helpful assistant.',
});

const run = assistant.run('Hello, agent!');
const result = await run.result();
console.log(result.content); // "Echo: Hello, agent!"
console.log(result.finishReason); // "stop-condition"

[!NOTE] createAgent defaults stopWhen to stopWhen.noToolCalls() when you don't supply one, so the loop above stops after the first step (the stub generate never returns tool calls) instead of running to maximumSteps (DEFAULT_MAXIMUM_STEPS, 25) and re-echoing its own output 25 times over. Pass an explicit stopWhen — for example stopWhen.toolCalled('submit-form') — to override it, most importantly for any agent that is expected to finish on a tool call rather than a plain reply (a handoff tool, for instance — see the Handoffs section under Multi-Agent Patterns).

Event-Driven Style with createActiveRun()

createAgent().run() returns a non-thenable AgentRun handle: iterate it directly for events, or call .result() for the terminal RunResult. For the full event-emitting surface (addEventListener, on, subscribe, …) reach for createActiveRun() — the lower-level factory createAgent builds on — which returns an ActiveRun instead. Attach listeners before awaiting result—the loop defers its first microtask so you never miss the opening events:

result() resolves for an ordinary completed, errored, or cancelled run and carries the classification in finishReason; abort() is therefore observed as finishReason: 'aborted'. A persistence or infrastructure failure rejects result() so a caller cannot mistake an uncommitted terminal run for success. The promise remains cached, including after rejection. abort() is synchronous request signaling; await result() when the caller needs the terminal outcome.

import { Conversation } from 'conversationalist';
import { createActiveRun, stopWhen } from '@lostgradient/operative';

const conversation = new Conversation();
conversation.appendUserMessage('Summarize the docs.');

const activeRun = createActiveRun({
  generate,
  toolbox, // a Toolbox from armorer's createToolbox() — empty or populated
  conversation,
  stopWhen: stopWhen.noToolCalls(),
});

activeRun.addEventListener('step.completed', (event) => {
  console.log(`Step ${event.step} done — ${event.content}`);
});

activeRun.addEventListener('run.completed', (event) => {
  console.log('Finish reason:', event.finishReason);
});

// Abort any time before completion.
// activeRun.abort('user cancelled');

const result = await activeRun.result;

Public API

operative — Core Entry Point

The main import surface for defining agents, running loops, managing sessions, backpressure, caching, context assembly, cost tracking, and multi-agent patterns.

createAgent(options)

The documented public factory for a standalone, bureau-less agent. generate is required — there's no bureau to inherit a provider from. Runs are in-memory and ephemeral by default (no durability, no session, no shared memory) unless you inject your own Toolbox and ConversationHistory, as below.

import { createAgent } from '@lostgradient/operative';

const agent = createAgent({
  generate: myProvider, // GenerateFunction — required
  instructions: 'You are a research assistant.',
  tools: { search: searchTool }, // name-keyed map; the map key is canonical
  stopWhen: (step) => step.toolCalls.length === 0,
});

const run = agent.run('Summarize the Q3 report.'); // fresh conversation
for await (const event of run) {
  /* iterate, OR */
}
const result = await run.result(); // await — same handle

createAgent's return value satisfies RunnableAgent<O, H> (AB-21) — it carries a readonly name (options.name, defaulting to '(agent)' when omitted), a readonly hasOutput: boolean (AB-234 — output !== undefined, the runtime witness for H), and run accepts an optional second AgentRunContext argument ({ signal, agentName, traceContext, withTraceContext, principal }), so it slots into createLazyAgent or a future AgentDefinitions map without a cast. context.signal drives per-run abort, context.agentName overrides the run's stamped agent name, context.traceContext becomes the same parentContext field RunOptions already uses for nested tracing, and context.principal (AB-241) forwards unchanged into RunOptions.principal — a bureau-owned bureau.run() catalog dispatch sets it from BureauRunOptions.principal.

CreateAgentOptions — key fields:

| Field | Type | Description | | ------------------- | --------------------------------------- | ------------------------------------------------------------------------------------ | | generate | GenerateFunction | Required. The caller-supplied LLM call. | | tools | Record<string, Tool> | Name-keyed tool map. Mutually exclusive with toolbox. | | toolbox | Toolbox | A pre-built Toolbox, used as-is across every run. Mutually exclusive with tools. | | instructions | string | System prompt appended on fresh string-input runs only. | | stopWhen | StopCondition \| StopCondition[] | Loop exit predicates. | | maximumSteps | number | Hard step cap. | | retry | RetryOptions | Transient generate failure retry policy. | | contextManagement | ContextManagementOptions | Automatic context compaction. | | permissions | HeadlessPermissionPolicyConfiguration | Deny-by-default headless mode (AB-94). Mutually exclusive with toolbox. |

agent.run(input) accepts either a plain string (starts a fresh conversation — instructions, if given, is appended as a system message, then input as a user message) or { conversation: ConversationHistory } (resumes an existing history — see the next section).

Stateless chat host: resume a conversation, share a toolbox, park on approval

A host with a browser- or client-owned conversation and an approval-gated toolbox — a stateless HTTP chat backend, for example — needs five things createAgent provides directly:

  1. A conversation input, not just a fresh string: agent.run({ conversation }) starts the loop from an existing ConversationHistory — the shape a stateless backend POSTs and stores between turns.
  2. Request-scoped authority, not authority stored on the toolbox: pass the authenticated request's requestContext through executeOptions. On resume, construct a fresh context for the approval request while preserving every identity and revision field bound into the approval. Do not reuse ephemeral controls such as the original request's absolute deadline.
  3. Versioned tools, not anonymous mutable definitions: every tool that can produce a resumable approval needs a stable version. Armorer binds that version into the approval and rejects unversioned or changed definitions.
  4. A pre-built Toolbox, not a freshly composed one: pass toolbox instead of tools. Reuse the same instance across requests, or configure every instance with the same secret and durable approval state store, so a later request can validate and consume the issued approval.
  5. Park-on-approval, not headless denial: stopWhen: [stopWhen.pendingApproval(), stopWhen.noToolCalls()] (from @lostgradient/operative/conditions) stops the run cleanly after a step whose tool results include a pending approval — no further generate call happens, and the pending approval stays reachable on the final RunResult's last step. noToolCalls() has to be combined in: pendingApproval() alone never fires on a normal, no-tool-call turn, so a plain text reply would otherwise run to maximumSteps instead of finishing.
import { unlink } from 'node:fs/promises';
import { createTool, createToolbox } from 'armorer';
import { createAgent, stopWhen } from '@lostgradient/operative';
import { z } from 'zod';

const deleteFileTool = createTool({
  name: 'delete-file',
  version: '1',
  description: 'Delete one file',
  input: z.object({ path: z.string() }),
  metadata: { mutates: true },
  async execute({ path }) {
    await unlink(path);
    return { deleted: true };
  },
});

// Built once per process — the stable approvalSecret is what makes
// resumeApproval() work across separate HTTP requests.
//
// `approvalPolicy` is load-bearing, not decoration: armorer only installs its
// approval hook when a policy (or a registry/tool policy, or a deny flag) is
// present. Without one, `allowMutation` and `allowDangerous` both default to
// true and `deleteFileTool` would simply EXECUTE — `approvalSecret` alone only
// signs a pending approval once something has produced one.
const toolbox = createToolbox([deleteFileTool], {
  approvalPolicy: { mode: 'on-mutation' },
  approvalSecret: Bun.env['APPROVAL_SECRET'],
});

// Build this from the authenticated request. Do not store tenant or principal
// authority in the reusable toolbox.
const requestContext = {
  authority: {
    principalId: currentUser.id,
    tenantId: currentTenant.id,
    ownerId: currentSession.id,
    capabilities: currentAuthorization.capabilities,
    authorizationRevision: currentAuthorization.revision,
  },
  audience: 'tenant',
  agentId: agentId,
  runId: runId,
} as const;

const agent = createAgent({
  generate: myProvider,
  toolbox,
  executeOptions: { requestContext },
  stopWhen: [stopWhen.pendingApproval(), stopWhen.noToolCalls()],
});

// Turn 1: run from the client-POSTed history.
const run = agent.run({ conversation: clientHistory });
const result = await run.result();
const pending = result.steps.at(-1)?.results.find((r) => r.pendingApproval)?.pendingApproval;
// ...send `pending` to a human, store `result.conversation.current` server-side...

// Later, on approval: authenticate the new HTTP request and construct a fresh
// context. Preserve the bound identity fields, but derive controls such as a
// deadline from this request instead of copying an expired deadline.
const approvalRequestContext = {
  authority: {
    principalId: approvalUser.id,
    tenantId: approvalTenant.id,
    ownerId: approvalSession.id,
    capabilities: approvalAuthorization.capabilities,
    authorizationRevision: approvalAuthorization.revision,
  },
  audience: requestContext.audience,
  agentId: requestContext.agentId,
  runId: requestContext.runId,
  deadline: Date.now() + 30_000,
};
const resumedResult = await toolbox.resumeApproval(signedApproval, {
  requestContext: approvalRequestContext,
});

[!IMPORTANT] Reconcile the pending result — do not append on top of it result.conversation already carries an action_required tool-result for this call: the loop appends it before stopWhen ever runs. Appending resumedResult alongside it leaves two tool-results for one call, which most providers reject or mishandle on the next turn.

Replace it in place with conversationalist's resolveToolResult(conversation, callId, resumedResult), which addresses the message by callId (not by position or recency) and therefore works identically on a Conversation rehydrated from a persisted ConversationHistory — the case a stateless host hits on every resumed request. Conversation.undo() is NOT a substitute: it walks the in-process undo/redo graph, which a rehydrated conversation does not have in the same shape.

Start the next turn from the resolved history. This README does not yet carry a full worked example of that round trip; see conversationalist's own documentation for resolveToolResult until one lands here.

Mutation ownership: agent.run({ conversation }) SNAPSHOTS the supplied ConversationHistory — it clones the value before wrapping it in a fresh internal Conversation, so the run's state and the object you passed in are independent from the moment run() is called: the run never mutates your object, and mutations you make to it afterward (a stateless host commonly keeps a mutable reference between turns) never leak into an in-flight run. This matches the durable path's existing snapshot semantics. instructions is not re-appended on this path; the supplied history is assumed to already carry whatever system context it needs, so resuming it repeatedly never duplicates system messages.

Process-local session timing

SessionHandle.sleep() and SessionHandle.monitor() are host-process conveniences. Both use local timers, so process exit loses the outstanding delay or monitor loop even when the session has a durable engine. Individual monitor ticks can still be durable runs; the monitor controller itself is not persisted or recovered.

For a process that only needs to reconnect to an existing durable run, construct the handle with the same session store, engine, and checkpoint store, then call await session.recover() without calling run() first. Recovery returns null when no run is available or when an engine resume fails, and emits session.recover failures for inspection, including a reconciliation persistence failure. run() requires configured runOptions and throws MissingRunOptionsError synchronously before reserving a run when they are absent.

const session = createSessionHandle(sessionId, {
  store,
  agentName: 'assistant',
  engine,
  checkpointStore,
  // No runOptions are needed for this recover-only path.
});
const recovered = await session.recover();
if (recovered) await recovered.result();

An elicitation callback returns a response carrying the same immutable request identity and schema-validated data, or null to decline/cancel:

const onElicitation: OnElicitation = async (request) => ({
  requestId: request.requestId,
  ...(request.toolCallId ? { toolCallId: request.toolCallId } : {}),
  data: request.schema.parse({ approved: true }),
});

Each persisted RunRef carries the exact userMessageId when available and a safe terminal outcome. Both fields are optional for older records, so an absent outcome does not mean the run completed successfully. Engine-level cancellation is recorded as { finishReason: 'aborted' }; engine failure and timeout are recorded as { finishReason: 'error' }. Failed schema validation retains finishReason: 'stop-condition' and records the safe output/INVALID_OUTPUT classifier, so consumers must also inspect outcome.error.

New RunRef records also retain baseConversationMetadata, captured when the run is reserved, so recovery can apply the checkpoint's metadata changes while preserving concurrent session edits. Older records without this snapshot use current-session precedence for conflicting metadata and apply only candidate-only additions; terminal records keep their current metadata during repeated reconciliation.

session.cancel() requests cancellation immediately. An attached run owns the terminal transaction: await its result() to observe the persisted outcome and final transcript. Without an attached run, cancellation reconciles the engine’s actual terminal state and available checkpoint history; without a checkpoint store, it retains the session’s existing history. A matching terminal record keeps its classification and known rows while accepting missing transcript rows from the same run. Cancellation cannot clear a newer run’s handle or overwrite another terminal classification. For a recovered run, closed() and session.closed() wait for the terminal session commit; a failed commit yields a failed cleanup acknowledgement.

Pass an AbortSignal to clear the active timer and stop the operation:

const controller = new AbortController();

const monitoring = session.monitor({
  every: 'PT30S',
  input: 'Check deployment health',
  until: (result) => result.content.includes('healthy'),
  signal: controller.signal,
});

controller.abort();
await monitoring; // rejects with AbortError

Use a Weft-backed run wakeup or signal when a current run must survive restart, and use a Bureau recurring schedule when future runs must survive restart. Session timing does not switch to those durable capabilities implicitly.

Session liveness (AB-88, AB-214, AB-215)

SessionHandle implements LivenessObservable (@lostgradient/operative/liveness): snapshot() returns a LivenessSnapshot narrowed to kind: 'session', and subscribeSnapshot(observer, options?) delivers it immediately, then again on every revision change. Unlike an AgentRun, a session never reaches a terminal liveness status — it can always accept another run() — so a subscribeSnapshot subscription stays open until unsubscribe() or options.signal aborts it. durability is always 'process-local' (AB-39: session monitoring is not durable-ized by this record).

const subscription = session.subscribeSnapshot((snapshot) => {
  console.log(snapshot.status, snapshot.reachability, snapshot.missedPulseCount);
});

await session.monitor({ every: 'PT30S', input: 'Check deployment health', until: () => false });
subscription.unsubscribe();

The session.monitor StallPolicy row (policies.ts) drives a watchdog for the lifetime of an active monitor() loop only — built via createStallWatchdog over the SAME setTimeoutFunction/clearTimeoutFunction pair sleep()/monitor() already use (no second timer seam), and disposed once the loop returns or throws, so a session not currently being polled accrues no missed pulses. Each session.monitor.tick records a 'host-reachability' pulse — a caller's own poll tick proves the process is scheduling callbacks, not that the underlying watched work is progressing — which sets lastActivityAt/lastHeartbeatAt/lastProgressAt on the snapshot.

A HumanWaitParkedEvent observed on a run this session started (typically from a requestHumanInput-style tool) moves the session to status: 'waiting' with a DeclaredWait: reason: 'review' when the event carries a prompt, reason: 'signal' otherwise, and deadline intentionally absent — both are legal unbounded waits per AB-88's unbounded-wait exception. The watchdog is paused (not merely ignored) for the wait's duration, so elapsed time never accrues toward stalled/unreachable, and resumes with a fresh instance once session.signal() delivers the matching signal.

createLazyAgent(loader, options?)

Type-preserving lazy loading for a whole RunnableAgent (AB-21) — instructions, tools, and output schema together, not just the generate function (createLazyGenerate covers that narrower case: createAgent({ generate: createLazyGenerate(loader) })). It defers import()ing an agent's module until the first run() call, while still returning an AgentRun handle synchronously:

import { createLazyAgent } from '@lostgradient/operative';

// A named export: select it inside the loader — there is no selector overload.
const researcher = createLazyAgent(() =>
  import('./agents/researcher').then((module) => module.researcher),
);

const run = researcher.run('Summarize the Q3 report.'); // synchronous — no await here
const result = await run.result(); // the module loads on first run(), then is cached

// A default export: `createLazyAgent` accepts the raw `import()` result
// directly and unwraps `.default` itself (AB-15's `AgentModule<O, H>`).
const plugin = createLazyAgent(() => import('./agents/plugin'));

createLazyAgent's options also accepts runtime?: RuntimeServices (AB-92/AB-252/AB-325) — the clock the synthetic liveness snapshot reads through for the window before the underlying agent resolves (startedAt/observedAt), forwarded into createDeferredAgentRun. Defaults to the real implementation.

createLazyAgent's return value is an ordinary RunnableAgent<O, H> — the same shape createAgent produces — so it slots into an AgentDefinitions map without unwrapping. The first successful load is cached and shared across concurrent run() calls; a load failure clears only that pending load, so a later run() retries.

Because this return value is synchronous — built before the loader has ever run — hasOutput is a live getter, not a value frozen at construction (AB-234). Before the loader resolves it falls back to options.hasOutput (typed as H itself, so it cannot disagree with the call's own type argument — pass { hasOutput: true } for a schema-backed agent; defaults to false, matching H's own default); once resolved, it switches to reading the loaded agent's own hasOutput directly, regardless of what (or whether) options.hasOutput said — so an omitted or inaccurate provisional value can never leave a permanently wrong witness once loading completes.

Each run() call owns its own waiting → started → terminal state, independent of every other call to the same lazy agent:

  • input and context are both snapshotted synchronously at run() call time (matching createAgent's own snapshot semantics), so a caller mutating the object it passed in — after run() already returned — never leaks into this run.
  • Events emitted before the underlying agent resolves are buffered and replayed to the returned handle's async iterator once a consumer actually starts iterating; a caller who only ever calls result() never subscribes to the underlying event stream at all, so a long or tool-heavy run doesn't grow an unbounded buffer of events nobody reads.
  • run.abort(reason) called before resolution completes means the underlying agent's run() is never called at all — a fast abort, not a delayed one; an abort that raced synchronously inside agent.run() itself disposes the handle it just returned instead of leaving it running unobserved.
  • run.abort(reason) called after resolution forwards to the real handle exactly once. context.signal is passed straight through to the underlying agent's own run(), so once the underlying handle exists, the wrapper stops separately forwarding that same signal — a compliant agent is already responsible for honoring it directly, and forwarding twice would risk duplicating non-idempotent cleanup.
  • result(), unwrap(), and output() delegate to the real handle once it exists.

A loader that throws or rejects surfaces AsyncDefinitionLoadError (the same error createLazyGenerate uses, kind 'load'). A loader that resolves to something that isn't a valid RunnableAgent — or whose run() returns something that isn't a valid AgentRun (missing result, abort, iteration, or [Symbol.dispose]) — surfaces AgentContractError (kind 'contract', code 'INVALID_AGENT_HANDLE') instead: the load itself succeeded, so this isn't retried on the next run() call. Either failure is delivered both ways — the returned AgentRun's result() resolves with finishReason: 'error' (or 'aborted') and the matching event, and its async iterator yields that one event before completing.

createActiveRun(options)

The full-control factory behind createAgent, createSessionHandle, and bureau-owned agents alike — documented, public API, not an internal implementation detail. It accepts the complete RunOptions bag directly: an existing Conversation instance (not just a ConversationHistory), a pre-built Toolbox, hooks, and durable routing (engine + checkpoint store + run id). bureau and evaluation both depend on it as first-party consumers.

Most callers should reach for createAgent({...}).run(...) instead — it wraps createActiveRun in the higher-level AgentRun handle and covers the common cases. Reach for createActiveRun directly when you need something createAgent doesn't expose: an already-live Conversation instance, durable routing, hooks (prepareStep, onStep, validateResponse, …), structured output via output, or a pre-built emitter to bind tool dispatches to.

import { createActiveRun, stopWhen } from '@lostgradient/operative';

// `stopWhen` is required for the in-memory loop to finish on an ordinary turn:
// without a stop condition, a text-only provider response keeps advancing until
// `maximumSteps` (25) instead of returning after the first reply.
const activeRun = createActiveRun({
  generate,
  toolbox,
  conversation,
  stopWhen: [stopWhen.noToolCalls()],
});
const result = await activeRun.result;
console.log(result.content, result.usage.total);

Like createAgent, a plain ConversationHistory passed here is SNAPSHOTTED on the way in, so a host that keeps mutating its stored history between turns cannot corrupt an in-flight run. Pass an already-live Conversation instance instead when you deliberately want the run to share it.

RunOptions — the complete options bag accepted by createActiveRun; key fields:

| Field | Type | Description | | --------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | generate | GenerateFunction | Required. The caller-supplied LLM call. | | toolbox | Toolbox | Required. Tool registry. | | conversation | Conversation \| ConversationHistory | Required. Seed conversation. | | stopWhen | StopCondition \| StopCondition[] | Loop exit predicates. | | maximumSteps | number | Hard step cap (default: 25). | | prepareStep | PrepareStepHook \| PrepareStepHook[] | Runs before each generate call. | | beforeToolExecution | BeforeToolExecutionHook \| BeforeToolExecutionHook[] | Modifies tool call list before execution. | | afterToolExecution | AfterToolExecutionHook \| AfterToolExecutionHook[] | Inspects/modifies tool results. | | onStep | OnStepHook \| OnStepHook[] | Called after each step completes. | | retry | RetryOptions | Transient generate failure retry policy. | | backpressure | BackpressureStrategy | Delay strategy applied before each step. | | validateResponse | ValidateResponseHook \| ValidateResponseHook[] | Post-generate response validation. | | validateToolResult | ValidateToolResultHook \| ValidateToolResultHook[] | Post-execute result validation. | | selectTools | SelectToolsHook \| SelectToolsHook[] | Per-step dynamic tool filtering. | | onElicitation | OnElicitation | Human-in-the-loop input handler. | | contextManagement | ContextManagementOptions | Automatic context compaction. | | output | ZodType | Structured output schema (AB-18) with retry. MUST NOT declare a field intended to carry binary or media content — a generated asset belongs in RunResult.parts as a managed-asset reference, never inlined as base64. | | schemaRetries | number | Retry attempts on schema validation failure. | | onMaximumSteps | (context) => Promise<string \| void> | Called when the loop exits on maximumSteps — a returned string replaces content. | | hooks | HookRegistry<OperativeHookMap> | Typed priority-ordered hook registry. | | signal | AbortSignal | External cancellation signal. | | runId | string | Stable run identity, used to stamp curated tool.* bubble events. Optional for a run with no steering (only supplied when the run has one, e.g. session-owned runs) — required whenever steering is set (AB-236): RunOptions is a discriminated pair on runId/steering, not two independently-optional fields, so a steering-enabled RunOptions literal with no runId is a compile error, not a silently-dropped steering.applied event. | | steering | SteeringGate | AB-67 runtime steering: { sessionId, getDesiredState(), awaitResume(), getAppliedFloor?() }, consulted at the entry of every step. paused: true blocks the step until awaitResume() resolves or signal fires. sessionId (AB-221) stamps steering.applied's sessionId field. getAppliedFloor?() (AB-199) — optional, the highest configVersion the gate has already observed applied by ANY run on the session — seeds a brand-new run's cross-run dedupe cursor (executeLoop/the durable driver call it once, at run start only) so a configVersion a prior run already applied is not re-fired as steering.applied; a gate that omits it seeds every fresh run at 0, unchanged from before AB-199. Optional — omit for unsteered runs; setting it requires runId (see above). | | selection | SelectionGate | AB-64/AB-250 selection-revalidation gate: { getPlan(), revalidate() }, both synchronous and pure, consulted at the same boundary as steering (after the pause-wait loop, before backpressure). A revalidated plan that no longer reaches outcome: 'selected' fails the step with SelectionRevalidationError. No runId coupling. Optional — omit for a run with no selection dependency. |

RunResult:

interface RunResult {
  conversation: Conversation;
  steps: readonly StepResult[];
  content: string;
  usage: TokenUsage; // { prompt, completion, total }
  finishReason: FinishReason; // 'stop-condition' | 'maximum-steps' | 'aborted' | 'error' | …
  error?: unknown;
  schemaValidation?: { success: boolean; error?: unknown }; // present when `output` is set
}

ActiveRun interface — returned by createActiveRun, the event-emitting entry point. Attach listeners before awaiting result:

| Member | Description | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | result: Promise<RunResult> | Resolves when the loop completes. | | durablyStarted?: Promise<void> | AB-361 — durable branch only; see below. | | abort(reason?) | Cancels the loop immediately; result resolves with finishReason: 'aborted' when cancellation is observed. | | closed(options?) | Cleanup acknowledgement — see below. | | complete() | Completes the event stream without aborting the loop. | | addEventListener(type, listener) | Standard EventTarget listener. | | removeEventListener(type, listener) | Removes a listener. | | on(type) | Returns an ObservableLike stream for the event type. | | once(type, listener) | One-time listener. | | subscribe(type, observer) | RxJS-style subscription. | | events(type, options?) | AsyncIterableIterator of typed events. | | toObservable() | All events as a single ObservableLike. | | snapshot() | Current LivenessSnapshot — see below. | | subscribeSnapshot(observer, opts?) | Non-consuming liveness observer — see below. | | [Symbol.dispose]() | Aborts and completes—use with using. |

durablyStarted (AB-361). On the durable branch (createActiveRun(options, { engine, checkpointStore, runId })), durablyStarted settles once this run's initial workflow record is durably committed — the write context.engine.start(...) performs — distinct from result (the run's own completion). A caller that needs the started-work control contract's durability guarantee (AB-34/AB-15: an acknowledged run is recoverable after any later crash) awaits it before treating a returned run identifier as durable; a caller that never reads it is unaffected, including when engine.start rejects. The in-memory branch leaves it undefined — there is no durable write to await.

Liveness (AB-88, AB-214). ActiveRun, AgentRun, and DiagnosticAgentRun all implement LivenessObservable: snapshot() returns the current LivenessSnapshot synchronously (never starts work, never blocks, never mutates); subscribeSnapshot(observer, options?) delivers that snapshot immediately, then a new one on every revision change, and delivers a terminal snapshot exactly once for already-terminal work. See @lostgradient/operative/liveness below for the full type contract and createStallWatchdog.

Event types emitted on ActiveRun (all prefixed by their lifecycle stage):

run.started, run.completed, run.error, run.aborted, step.started, step.generated, step.completed, step.aborted, generate.started, generate.completed, generate.error, generate.retry, tools.executing, tools.executed, response.validated, tool-result.validated, context.compacted, context.budget-warning, elicitation.requested, elicitation.resolved, backpressure.applied, backpressure.released, usage.accumulated, session.saved, session.loaded, steering.applied (AB-67/AB-221 — dispatched at the runStep boundary; see the SteeringGate/steering command table above and documentation/operative-type-safe-api.md's "Steering commands" section for steering.accepted/rejected/superseded/failed — exported here, documented as Bureau's submitSteeringCommand (AB-199) events to dispatch, but not yet actually dispatched by anything: ActiveRun's in-memory driver exposes no external dispatchEvent surface for an owner outside the run to use, a named gap documentation/operative-type-safe-api.md's "Steering commands" section tracks). budget.threshold and budget.exceeded are also exported members of OperativeEventMap, but neither has a production dispatch site: createCostBudgetMonitor (below) reports threshold and exhaustion through its own plain onThreshold/onExceeded callbacks, not by dispatching these events, so a subscriber never receives either one. budget.exceeded (BudgetExceededEvent) is additionally @deprecated — AB-231 settled budget-exceeded accounting through a run.completed event whose finishReason is 'budget-exceeded' instead, reached only via a thrown BudgetExceededError (not createCostBudgetMonitor's stopCondition, which resolves with finishReason: 'stop-condition') (AB-365).

Tool settlement is represented by the run's sealed tool-result.validated event and corresponding StepResult tool result. Filtering occurs before execution, so a filtered tool has no scripted settlement call; hook and validation failures preserve their final result payload and outcome in the emitted step result.

// Iterate events as an async stream
for await (const event of activeRun.events('step.completed')) {
  console.log(event.step, event.content);
}

GenerateFunction

The interface operative never provides for you. Wire in any provider:

import type { GenerateFunction } from '@lostgradient/operative';

// Stub for tests or demos
const generate: GenerateFunction = async ({ conversation, toolbox }) => ({
  content: 'I can help with that.',
  toolCalls: [],
  usage: { prompt: 10, completion: 8, total: 18 },
});

In production, operative's own provider subpaths (@lostgradient/operative/anthropic, @lostgradient/operative/openai, @lostgradient/operative/gemini) provide ready-made generate functions for Anthropic, OpenAI, and Gemini.

Sessions

The main entry point exposes session helpers for direct session management without a SessionStore:

  • createAgentSession(options): Creates a new AgentSession object.
  • loadAgentSession(persistence, sessionId): Loads an AgentSession from a conditional text-value store.
  • saveAgentSession(persistence, session): Persists an AgentSession through conflict-aware session storage.
import { createAgentSession, loadAgentSession, saveAgentSession } from '@lostgradient/operative';

// Direct persistence (use createSessionStore from session/index for the full API)
const session = createAgentSession({
  agentName: 'my-agent',
  conversationHistory: conversation.current,
  id: 'session-abc',
});

await saveAgentSession(store, session);
const loaded = await loadAgentSession(store, 'session-abc');

For richer session management, createSessionStore() and resumeSession() are available at the same path and documented below under operative — Core.

createSessionStore() and resumeSession()

import {
  createActiveRun,
  createSessionStore,
  resumeSession,
  stopWhen,
} from '@lostgradient/operative';
import { MemoryStorage, textValueStore } from '@lostgradient/weft/storage';

// createSessionStore requires a real Weft `ConditionalTextValueStore` — it
// checks for a `.conditionalBatch` method, so a hand-rolled `{ get, set,
// delete }` stub throws `TypeError: createSessionStore requires a
// ConditionalTextValueStore`. For tests and prototyping, wrap an in-memory
// `MemoryStorage` with `textValueStore()` (from `@lostgradient/weft/storage`)
// — this is the same pattern operative's own test suite uses. Swap in a
// real Weft-backed store for production.
const kvStore = textValueStore(new MemoryStorage());
const sessions = createSessionStore(kvStore);

await sessions.save(session);
const summaries = await sessions.list({ agentName: 'my-agent', sortBy: 'updatedAt' });
const loaded = await sessions.load('session-abc');
await sessions.delete('session-abc');
await sessions.cleanup({ olderThan: 7 * 24 * 60 * 60 * 1000 }); // 1 week

// resumeSession loads an existing session (or creates a new one) and returns
// the restored Conversation so you can pass it into a run.
const { session, conversation, isNew } = await resumeSession(sessions, 'session-abc', {
  agentName: 'my-agent',
});

// Then drive a run from the restored, already-live Conversation instance.
// createAgent().run() only accepts a plain ConversationHistory (it snapshots
// its own fresh Conversation internally); createActiveRun accepts a live
// Conversation directly, which is what resumeSession returns.
const activeRun = createActiveRun({
  generate,
  toolbox,
  conversation,
  stopWhen: stopWhen.noToolCalls(),
});
const result = await activeRun.result;

SessionStore interface:

| Method | Description | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | save(session) | Persist a session with conflict-aware merging. | | update(id, updater, options?) | Read-modify-write a session through optimistic concurrency. options.refreshActivity (default true) controls whether a successful write stamps a fresh updatedAt; pass false for a background write (e.g. pruning stale metadata) that must not read as session activity. | | load(id) | Load by id; returns undefined if not found.