js/ai/runtime

js/ai/runtime.ts

fino:ai/runtime — shared agent integration helpers and result types.

This module exposes the small runtime surface that application adapters need without exposing the internal agent loop implementation. It carries the cross-cutting helpers — runContext, maxSteps(), streamText(), and the guardrail and suspension error types — together with the public state and result types shared by fino:ai/agent and fino:ai/session: run inputs and results, per-step state, streaming events, retry policy, and tool-approval requests. Import from here when writing integration code (UI adapters, observability hooks, session stores) that consumes agent runs but does not construct agents itself.

Boundary

Application code should create agents through fino:ai/agent. The runtime module is intentionally not a harness API and does not export the internal runtime class. This keeps the public integration layer stable while allowing the agent loop internals to evolve.

runContext is an async context populated while an agent step or session is driving work. Tools can read it for run metadata, but should use the explicit ToolRunContext when possible.

import { agent } from 'fino:ai/agent';
import { openai } from 'fino:ai/model';
import { maxSteps, streamText } from 'fino:ai/runtime';

const bot = agent({
  model: openai({ model: 'gpt-4o' }),
  stopWhen: maxSteps(4),
});

for await (const text of streamText(bot.stream('Draft a title.'))) {
  console.log(text);
}

Constants

const GuardrailError

Error thrown when an input or output guardrail blocks execution.

const runContext

Async context for the active agent or workflow run.

Functions

function maxSteps(n: number): StopCondition

Stop after n model steps.

function streamText(stream: AgentStream): AsyncGenerator<string>

Iterate over only text deltas from an agent stream.

Types

type AgentEvent = { type: 'model_event'; event: StreamEvent; } | { type: 'step_start'; stepIndex: number; model: string; provider: string; } | { type: 'step_end'; stepIndex: number; stopReason: StopReason; model: string; provider: string; } | { type: 'tool_start'; stepIndex: number; id: string; name: string; } | { type: 'tool_result'; stepIndex: number; id: string; name: string; isError?: boolean; } | { type: 'tool_error'; stepIndex: number; id: string; name: string; message: string; } | { type: 'retry'; attempt: number; model: string; provider: string; delayMs: number; } | { type: 'fallback'; model: string; provider: string; } | { type: 'guardrail'; stage: 'input' | 'output'; action: GuardrailResult['action']; reason?: string; } | { type: 'suspend'; stepIndex: number; reason?: string; payload?: unknown; } | { type: 'final'; result: AgentResult; }

Agent-level streaming event.

Provider StreamEvent values are wrapped in model_event. Lifecycle events add run-loop context around model streaming, retries, fallback, tool execution, guardrails, suspension, and final completion. Every run emits step_start/step_end pairs per model turn and exactly one final event (carrying the same AgentResult that AgentStream.result resolves with) when it completes successfully.

for await (const ev of stream.reader) {
  switch (ev.type) {
    case 'model_event':
      if (ev.event.type === 'text_delta') render(ev.event.text);
      break;
    case 'tool_start':
      console.log(`running ${ev.name}`);
      break;
    case 'final':
      console.log('cost:', ev.result.cost);
  }
}

type StopCondition = (state: AgentState, info: { stopReason: StopReason; }) => boolean

Predicate that decides whether an agent run should stop.

Conditions are checked after every completed step, including tool-execution steps; the run ends as soon as any configured condition returns true. When no stopWhen is configured, the runtime defaults to maxSteps(8).

import type { StopCondition } from 'fino:ai/runtime';

const budgetCap: StopCondition = (state) => (state.cost ?? 0) > 0.5;

Interfaces

interface AgentResult {

Final result of an agent run.

text is the assistant text from the last completed step, messages is the full conversation as rendered by the history strategy, and steps records the state after each step for inspection. object is set only on structured-output runs.

const result = await bot.generate('Name three sorting algorithms.');
console.log(result.text);
console.log(result.usage.inputTokens, result.usage.outputTokens);
console.log(result.stopReason); // 'end_turn'

Properties

text: string

Assistant text from the final step.

object?: unknown

Parsed structured output; present only when an output schema was set.

messages: ModelMessage[]

Full conversation, including tool calls and results.

steps: AgentState[]

Per-step state snapshots, in order.

usage: Usage

Token usage summed across all steps.

cost?: number

Estimated USD cost when pricing for the models used is known.

stopReason: StopReason

Stop reason from the final model turn.

interface AgentState {

Mutable state passed through one agent run.

Each step consumes a state and produces the next one: messages is the model-facing conversation rendered by the history strategy, usage and cost accumulate across steps, and stepIndex counts completed model turns. Harnesses that drive the loop manually with step() keep threading the returned state back in.

import type { AgentState } from 'fino:ai/runtime';

let state: AgentState = {
  messages: [{ role: 'user', content: 'Summarize the report.' }],
  stepIndex: 0,
  usage: { inputTokens: 0, outputTokens: 0 },
};

Properties

messages: ModelMessage[]

Model-facing conversation, as rendered by the history strategy.

stepIndex: number

Number of completed model steps in this run.

usage: Usage

Token usage accumulated across all steps so far.

cost?: number

Running USD cost derived from usage and the pricing table, when known.

history?: MessageHistory

History handle backing messages; present once a step has run.

signal?: AbortSignal

Abort signal observed by model requests and tool executions.

interface AgentStream {

Stream handle returned by Agent.stream().

Offers three views of one run: reader yields every AgentEvent in order, state is a retained signal holding a coarse AgentRunView snapshot, and result settles with the final AgentResult. The run starts immediately; if it throws, the reader fails with the error and result rejects.

const stream = bot.stream('Draft the changelog.');
for await (const ev of stream.reader) {
  if (ev.type === 'model_event' && ev.event.type === 'text_delta') {
    render(ev.event.text);
  }
}
const result = await stream.result;

Properties

reader: Reader<AgentEvent>

Ordered stream of every agent event; closes when the run finishes.

result: Promise<AgentResult>

Settles with the final result; rejects if the run throws.

state: ReadonlySignal<AgentRunView>

Retained coarse view of the run, updated as events arrive.

interface GuardrailResult {

Result returned by an input or output guardrail.

allow passes content through unchanged. block aborts the step with a GuardrailError. redact substitutes content: input guardrails supply replacement messages, output guardrails supply replacement text. If the relevant replacement field is omitted on a redact, the original content is kept.

import type { GuardrailResult } from 'fino:ai/runtime';

const redacted: GuardrailResult = {
  action: 'redact',
  text: '[removed]',
  reason: 'pii',
};

Properties

action: 'allow' | 'block' | 'redact'

Disposition: pass through, abort the step, or substitute content.

messages?: ModelMessage[]

Replacement conversation used when an input guardrail redacts.

text?: string

Replacement assistant text used when an output guardrail redacts.

reason?: string

Explanation surfaced on guardrail events and GuardrailError.reason.

interface Guardrails {

Optional input and output guardrails for an agent.

The input guardrail runs before every model request, after the history strategy has produced the model-facing view. The output guardrail runs on each step's assistant text and is skipped when the turn produced no text. Both may be async, and each invocation emits a guardrail agent event with the action taken.

import type { Guardrails } from 'fino:ai/runtime';

const guardrails: Guardrails = {
  input: (messages) =>
    messages.length > 200
      ? { action: 'block', reason: 'conversation too long' }
      : { action: 'allow' },
  output: (text) =>
    /ssn:\S+/.test(text)
      ? { action: 'redact', text: text.replace(/ssn:\S+/g, '[redacted]') }
      : { action: 'allow' },
};

Properties

input?: (messages: ModelMessage[]) => GuardrailResult | Promise<GuardrailResult>

Inspects the outgoing conversation before each model request.

output?: (text: string) => GuardrailResult | Promise<GuardrailResult>

Inspects each step's assistant text before it is recorded.

interface RetryOptions {

Retry policy for provider failures.

Retries apply per model before fallback advances: each model in [model, ...fallback] gets the full retry budget. Delays use exponential backoff with full jitter, capped at maxDelayMs, except when the provider supplied an explicit retry-after delay, which is honored verbatim. Errors thrown after streaming has begun are never retried — partial output cannot be safely replayed.

const bot = agent({
  model,
  retry: { maxRetries: 3, baseDelayMs: 250 },
  fallback: [backupModel],
});

Properties

maxRetries?: number

Additional attempts per model after the first failure. Default 0.

baseDelayMs?: number

Base backoff delay in milliseconds. Default 500.

maxDelayMs?: number

Upper bound on any backoff delay, in milliseconds. Default 30000.

retryOn?: (err: unknown) => boolean

Overrides which errors are retryable. By default only ModelErrors with status 429 or 5xx are retried.

interface RunInput {

Input accepted by Agent.generate() and Agent.stream().

Messages are appended to the run's history before the first step. The signal, when provided, aborts in-flight model requests and is passed to tool executions.

import type { RunInput } from 'fino:ai/runtime';

const controller = new AbortController();
const input: RunInput = {
  messages: [{ role: 'user', content: 'Plan the release.' }],
  signal: controller.signal,
};

Properties

messages: ModelMessage[]

Conversation to append before the first model request.

signal?: AbortSignal

Cancels the run: model streaming and tool calls observe this signal.

interface StepResult {

Result of one agent step.

done is true when the model finished without requesting tool calls, so the loop has nothing further to execute. When a tool suspended the step, suspend carries the SuspendSignal (with its payload, such as a ToolApprovalRequest) and the run should be checkpointed for later resumption.

import type { StepResult } from 'fino:ai/runtime';

let r: StepResult = await agent.step(state);
while (!r.done && !r.suspend) r = await agent.step(r.state);

Properties

state: AgentState

State after the step, including appended messages and updated usage.

done: boolean

True when the model stopped without requesting tool calls.

suspend?: SuspendSignal

Present when a tool paused the run; resume via a session or approveTool().

stopReason: StopReason

Why the model stopped this turn.

interface StrategyMemorySink {

Minimal sink a history strategy can use to emit durable memory.

Matches the ingest surface of fino:ai/memory stores so strategies can archive summarized or evicted conversation content without depending on a concrete store type. Hosts such as sessions hand a sink to their strategy, which calls it as part of compaction.

import type { StrategyMemorySink } from 'fino:ai/runtime';

async function archive(sink: StrategyMemorySink, summary: string) {
  await sink.ingest([{ text: summary, metadata: { kind: 'summary' } }]);
}

Methods

ingest(docs: { text: string; metadata?: Record<string, unknown>; }[]): Promise<void>

Stores the given documents durably.

interface ToolApprovalRequest {

Persisted approval request for a tool call that suspended before execution.

When a tool declares requiresApproval, the runtime does not execute it; instead the step suspends with a SuspendSignal whose payload is this shape. Sessions persist the payload and later pass it back to approveTool() along with the human decision. risk and sideEffects mirror the tool's own annotations so approval UIs can render them without loading the tool.

import type { ToolApprovalRequest } from 'fino:ai/runtime';

const request = r.suspend?.payload as ToolApprovalRequest;
if (request?.type === 'tool_approval') {
  console.log(`Approve ${request.toolName}?`, request.args);
}

Properties

type: 'tool_approval'

Discriminant identifying the suspend payload as a tool approval.

toolCallId: string

Id of the pending tool_use part awaiting a result.

toolName: string

Name of the tool the model asked to run.

args: unknown

Arguments the model supplied for the call.

risk?: string

Tool-declared risk annotation, for approval UIs.

sideEffects?: boolean

Whether the tool declares side effects.