/** * The TypeScript smooth-operator core: a native agentic loop. * * Phase-0 sibling of the C# `SmoothAgent` (`dotnet/core`), the Python core * (`python/core`), and the Rust reference engine. Drives an agentic tool-calling * loop over any OpenAI-compatible chat client (the `openai` SDK pointed at a * gateway): inject retrieved knowledge, call the model, run any requested tools, * feed results back, and loop until the model answers without a tool call or the * iteration budget is hit. * * Deliberately minimal (no compaction / budget / checkpointing yet) — those layer * on exactly as they did when the C# core grew past Phase 0. */ import type { Clearance } from './cast.js'; import type { CheckpointStore } from './checkpoint.js'; import type { SmoothAgentThread } from './thread.js'; import { type Memory } from './memory.js'; import type { Reranker } from './rerank.js'; import type { CostBudget, ModelPricing, Usage } from './cost.js'; import type { HumanGate } from './humanGate.js'; import type { Knowledge } from './knowledge.js'; import type { DenyPolicy } from './denyPolicy.js'; import { AutoMode } from './permission.js'; import type { PermissionGrants } from './permissionGrants.js'; import type { ImageContent } from './multimodal.js'; /** A callable tool the agent may invoke. Mirrors the reference engines' tool seam. */ export interface Tool { name: string; description: string; /** JSON Schema for the tool's arguments. */ parameters: Record; execute(args: Record): Promise; } /** A tool call requested by the model. Mirrors the Rust engine's `ToolCall`. */ export interface ToolCall { id: string; name: string; arguments: Record; } /** * Result of executing a tool. Mirrors the Rust engine's `ToolResult`. * * `content` is what the model/conversation sees — a {@link ToolHook.postCall} * hook may rewrite it in place (the redaction seam). */ export interface ToolResult { toolCallId: string; content: string; isError: boolean; /** Optional structured details for UI rendering (diffs, tables, etc.). */ details?: unknown; } /** * A hook that runs around every tool call, mirroring the Rust engine's * `ToolHook` trait (`pre_call` / `post_call`). Installed on the agent via * {@link SmoothAgent.addHook} or {@link AgentOptions.toolHooks} and run for both * {@link SmoothAgent.run} and {@link SmoothAgent.runStream}. * * The lifecycle is the enforcement + redaction seam Narc (and the server's * consumer-supplied surveillance hooks) plug into. */ export interface ToolHook { /** * Called before a tool executes. **Throw to block the call** (mirrors Rust's * `pre_call` returning `Err`) — the model is told the call was blocked and the * tool never runs. Optional; omit for a post-only (redaction) hook. */ preCall?(call: ToolCall): Promise; /** * Called after the tool executes with a **mutable** {@link ToolResult}. A hook * may rewrite `result.content` (e.g. redact a leaked secret) and the mutation * is what the model/conversation sees. A throw here is swallowed (logged, not * surfaced) so the redaction seam can never break the turn — mirroring Rust's * `post_call` whose `Err` is `tracing::warn`'d, not propagated. Optional. */ postCall?(call: ToolCall, result: ToolResult): Promise; } /** * The verdict of folding the SEP `tool_call` hook chain over one pending call — * shaped to match `FoldedHook` from `extension/host.ts`, declared structurally * here so the agent loop needs no import from the extension subsystem. */ export type ExtensionFold = { kind: 'proceed'; value: unknown; } | { kind: 'blocked'; reason: string; }; /** * The seam through which a SEP extension host participates in the agent loop — * the TypeScript sibling of Rust's `Agent::with_extension_host` and Go's * `core.ExtensionHooks`. Declared structurally (the concrete `ExtensionHost` * satisfies it as-is) so `agent.ts` and `extension/` stay import-cycle-free. */ export interface ExtensionHooks { /** Eager tool proxies, already namespaced `.`. */ tools(): Tool[]; /** Deferred tool proxies: hidden from the model until `tool_search` promotes them. */ deferredTools(): Tool[]; /** * Fold the `tool_call` hook chain over one pending call BEFORE it executes. * A `blocked` verdict vetoes the call; a `proceed` value may carry rewritten * `arguments` (rewrites are already scoped by the host's cross-tool guard). */ runToolCallHook(tool: string, args: unknown): Promise; /** Fire-and-forget event fan-out to subscribed extensions; never blocks the turn. */ dispatchEvent(event: string, payload: unknown): void; } export interface AgentOptions { instructions?: string; model?: string; maxIterations?: number; maxTokens?: number; /** * The active model's hard **output** ceiling (`max_output_tokens`), when known. * Each model call clamps `max_tokens` to `min(maxTokens, modelMaxOutput)` so a * budget/policy `maxTokens` (which may be tuned high) can never exceed what the * model can physically emit — otherwise a reasoning model burns its budget on * `reasoning_content` and returns empty `content`, or the upstream 400s (e.g. * `groq-compound` caps output at 8192). Source it from the gateway's * `/model/info` (`model_info.max_output_tokens`). Omitted / `undefined` / `0` ⇒ * no clamp (graceful passthrough, zero behaviour change). Mirrors the Rust * engine's `LlmClient::with_model_ceiling` / `AgentConfig.model_max_output` * (EPIC th-1cc9fa). */ modelMaxOutput?: number; /** * Opaque object forwarded verbatim as every model request's top-level * `metadata` field (LiteLLM records it on spend logs — e.g. an agent slug so * per-agent LLM spend is queryable at the gateway). Omitted / empty ⇒ the * field never appears on the wire, byte-identical to unset. Mirrors the Rust * engine's `AgentConfig.with_metadata`. */ metadata?: Record; temperature?: number; knowledge?: Knowledge; knowledgeTopK?: number; /** Reranker applied to retrieved hits before injection (default: passthrough). */ reranker?: Reranker; /** Candidate pool size to retrieve before reranking; when > knowledgeTopK, more docs are fetched, reranked, then trimmed. */ knowledgeCandidateK?: number; /** Optional long-term memory; relevant entries are recalled into context each turn. */ memory?: Memory; /** How many memory entries to recall per turn (default 4). */ memoryTopK?: number; tools?: Tool[]; /** * Tool-call surveillance hooks run around every tool dispatch (both `run` and * `runStream`). Each hook's `preCall` runs before the tool executes — a throw * blocks the call — and its `postCall` runs after with a mutable result it may * redact. Mirrors the Rust engine's `ToolRegistry` hook chain; the server's * `toolHooks` seam threads consumer-supplied hooks in here. Additional hooks can * be added post-construction via {@link SmoothAgent.addHook}. */ toolHooks?: ToolHook[]; /** * When `true` and an assistant turn returns ≥2 tool calls, dispatch them * concurrently (`Promise.all`) instead of sequentially. The tool-result * messages are still appended in the original `tool_calls` order, so the * transcript stays deterministic regardless of completion order. Default * `false` preserves the sequential behaviour. Per-tool semantics (clearance, * human-gate approval, tool_search promotion, JSON parsing, error handling) * are unchanged — only the dispatch loop runs in parallel. */ parallelToolCalls?: boolean; /** * Deferred tools — registered but with their schemas HIDDEN from the model. * When any are present, a built-in `tool_search` meta-tool is advertised in * their place; the model calls it to fuzzy-match and promote the ones it needs, * which then become visible + dispatchable on subsequent turns. Keeps the tool * schema payload small when there are many rarely-used tools. An unpromoted * deferred tool is NOT dispatchable. */ deferredTools?: Tool[]; /** * Image attachments for the CURRENT turn's user message (a multimodal turn). * Set by a host that received a chat turn carrying images; emitted as OpenAI * `image_url` content parts on that one turn. Unset (the default) leaves every * text-only turn byte-identical. Mirrors Rust's `AgentConfig::with_user_images`. */ nextUserImages?: ImageContent[]; /** * SEP extension host participating in the agent loop — the TypeScript sibling * of Rust's `Agent::with_extension_host` (and Go's `AgentOptions.Extensions`). * The host's eager tools are merged into {@link tools} as ORDINARY tools * (visible, dispatched, and permission-gated exactly like native tools), its * deferred tools into {@link deferredTools} (hidden until `tool_search` * promotes them), its `tool_call` hook folds over every pending call before * dispatch (veto or argument rewrite — already scoped by the host's cross-tool * guard), and turn lifecycle events fan out to subscribed extensions. * The concrete `ExtensionHost` satisfies this structurally — no import needed. * Omitted (the default) ⇒ the loop behaves exactly as before extensions existed. */ extensions?: ExtensionHooks; /** * Approximate token budget for the context window. Before each model call, * older non-system messages are dropped (sliding window) to stay under it. * `0` disables compaction. Defaults to 8000. */ maxContextTokens?: number; /** Optional ceiling for the turn (token and/or USD). The turn stops early once a model call pushes usage/cost over the budget. */ budget?: CostBudget; /** Per-model pricing override for cost accounting (defaults to DEFAULT_PRICING). */ pricing?: Record; /** Optional store for persisting/resuming the conversation. Used with `conversationId`. */ checkpointStore?: CheckpointStore; /** Conversation id for the checkpoint store (required to use checkpointing). */ conversationId?: string; /** * Optional tool-access policy. When set, a tool the clearance forbids is not * dispatched — a "tool not permitted" result is returned to the model instead. * Undefined allows every tool (the prior behaviour). */ clearance?: Clearance; /** * Optional human-in-the-loop gate. When set, the agent asks it for approval before * running any tool call for which {@link requiresApproval} returns true. A denied call * is not executed; the model is told it was denied and can adapt. */ humanGate?: HumanGate; /** * Which tool calls need human approval (e.g. writes / destructive actions), given the * tool name and parsed arguments. Default: none. Only consulted when `humanGate` is set. * Example: `requiresApproval: (name) => name === 'delete_record' || name === 'send_email'`. */ requiresApproval?: (name: string, args: Record) => boolean; /** * Enable the native permission gate ({@link PermissionHook}). When set (or when * {@link denyPolicy} / {@link permissionGrants} is set, defaulting to * {@link AutoMode.Ask}), every tool call is classified before dispatch: read-only * calls allow, dangerous calls (`rm -rf /`, credential paths, `curl | sh`, * dangerous domains, env dumps) hard-deny in EVERY mode, and mutating/unknown * calls `Ask`. An `Ask` is routed to {@link humanGate} when one is wired and * **fails closed** (blocked, surfaced to the model) otherwise. A blocked call is * never executed; the model is told why. Undefined ⇒ the gate is off (prior * behaviour). Mirrors the Rust engine's `SMOOTH_AUTO_MODE` / `PermissionHook`. */ permissionMode?: AutoMode; /** * A consumer {@link DenyPolicy} (declarative deny rules + predicates). Evaluated * FIRST as a circuit-breaker: a match hard-denies regardless of grants or mode * (including {@link AutoMode.Bypass}). Setting this alone enables the gate at * {@link AutoMode.Ask}. Purely additive — an empty/absent policy changes nothing. */ denyPolicy?: DenyPolicy; /** * In-memory allow-list consulted before prompting on an `Ask`. A matching grant * auto-approves silently; an `approveAlways` answer adds a grant. Setting this * alone enables the gate at {@link AutoMode.Ask}. The consumer owns persistence. */ permissionGrants?: PermissionGrants; /** * Number of ADDITIONAL attempts after the first if the model call throws a transient * error (rate-limit, 5xx, dropped connection). `0` (the default) preserves today's * behaviour: a single attempt, error propagates immediately. Only the model call is * retried — never tool execution. */ maxRetries?: number; /** * Base delay (milliseconds) for exponential backoff between retries. The wait before * retry attempt `n` (1-indexed) is `retryBackoffMs * 2 ** (n - 1)`. Defaults to 200. * Set to `0` to retry without sleeping (used by tests). */ retryBackoffMs?: number; } export interface AgentRunResponse { text: string; iterations: number; toolCalls: number; usage: Usage; costUsd: number; /** True if the turn stopped because the cost/token budget was hit. */ budgetExceeded: boolean; } /** * One streamed chunk from a streaming chat completion — the standard OpenAI * `chat.completions` streaming chunk shape. `content` deltas concatenate into the * assistant text; `tool_calls` fragments are assembled by their `index` (the `id` * + `function.name` appear when the call first opens, `function.arguments` arrives * in fragments). `usage` is sent by gateways on (typically) the final chunk. */ export interface ChatChunk { choices: Array<{ delta: { content?: string | null; tool_calls?: Array<{ index: number; id?: string; function?: { name?: string; arguments?: string; }; }> | null; }; }>; usage?: { prompt_tokens?: number | null; completion_tokens?: number | null; } | null; /** * The gateway's per-request cost, when the client surfaced one. It lives ONLY in * a response HEADER, and a streaming client that returns a bare stream has no * response object to read one off at all — so it captures the response, parses * the cost, and rides it on a chunk (the gateway client uses a leading chunk with * no `choices`, matching the Go engine). Absent ⇒ unmeasured, and the local * pricing estimate is used instead of a bogus $0. */ gatewayCostUsd?: number; /** Raw response headers, when the client hangs them off a chunk instead of pre-parsing. */ headers?: unknown; } /** * The minimal shape of the OpenAI-compatible client the agent needs. The real * `openai` SDK's `OpenAI` satisfies this; tests inject a fake. * * `chat.completions.create` is the non-streaming call the {@link SmoothAgent.run} * loop uses. `createStream` is the optional streaming call the * {@link SmoothAgent.runStream} loop uses — production wires it to the real SDK's * `create({ ...body, stream: true })` (which returns an async-iterable of * {@link ChatChunk}s). It is optional so non-streaming consumers and the existing * fakes keep satisfying the interface; `runStream` throws if it is absent. */ export interface ChatClientLike { chat: { completions: { create(body: Record): Promise<{ choices: Array<{ message: { content: string | null; tool_calls?: Array<{ id: string; function: { name: string; arguments: string; }; }> | null; }; }>; usage?: { prompt_tokens?: number | null; completion_tokens?: number | null; } | null; }>; /** * Streaming variant of {@link create}. Production wires this to the real * `openai` SDK's `create({ ...body, stream: true })`, which returns an * `AsyncIterable`. Optional so non-streaming clients still satisfy * the seam; {@link SmoothAgent.runStream} requires it. */ createStream?(body: Record): AsyncIterable; }; }; /** * The OpenAI-compatible base URL this client talks to, when it knows it. * Only used to gate Anthropic prompt-cache markers (see `cacheControl.ts`) — * a client that doesn't set it, such as {@link MockLlmProvider}, simply never * gets them, leaving its request bodies byte-identical. */ apiBaseUrl?: string; } /** * A streamed event from {@link SmoothAgent.runStream}. A tagged union discriminated * on `type`, mirroring the C# `RunStreamingAsync` update sequence and the Rust * reference engine's event stream: * * - `text` — an incremental assistant content delta as it streams in. * - `tool_call`— a tool call the model requested, emitted once (after the model * stream for the iteration completes) before it is dispatched. * - `tool_result` — a tool's result, emitted after it finishes. * - `done` — the single terminal event, carrying the same {@link AgentRunResponse} * that {@link SmoothAgent.run} would return for the same script. */ export type StreamEvent = { type: 'text'; text: string; } | { type: 'tool_call'; name: string; arguments: string; } | { type: 'tool_result'; name: string; result: string; /** * Structured, UI-facing payload a postCall hook attached to the * {@link ToolResult} (undefined when none) — forwarded verbatim and * un-truncated, never shown to the model. Mirrors the Rust engine's * `AgentEvent::ToolCallComplete.details`. */ details?: unknown; } | { type: 'done'; response: AgentRunResponse; }; /** * The `max_tokens` to actually send: the configured budget, clamped down to the * model's output ceiling when one is known. Never returns 0. `ceiling` of * `undefined` / `0` (or any non-positive value) ⇒ passthrough (no clamp), mirroring * the Rust engine's `LlmClient::effective_max_tokens` (EPIC th-1cc9fa). */ /** * Spreadable `{ metadata }` fragment for a model request body. Empty or absent * metadata yields `{}` so the wire stays byte-identical when unset (Rust * parity: `with_metadata` filters empty maps to `None`). */ export declare function metadataField(metadata?: Record): { metadata?: Record; }; export declare function effectiveMaxTokens(configured: number, ceiling?: number): number; export declare class SmoothAgent { private readonly client; private readonly toolsByName; /** Tool-call surveillance hooks, run in order around every dispatch. */ private readonly hooks; /** The native permission gate, built when permissionMode/denyPolicy/permissionGrants is set; else undefined (gate off). */ private readonly permissionHook?; private readonly options; constructor(client: ChatClientLike, options?: AgentOptions); /** Fire-and-forget SEP event fan-out; a no-op without an extension host. */ private sepDispatch; /** * Emit the end-of-turn SEP event pair in Rust's order: `message_end` carrying * the final assistant text, then `turn_end`. Called on EVERY turn exit — * budget-exceeded and max-iteration included — so a subscribed extension never * waits forever for an end event. */ private sepTurnComplete; /** * Fold the SEP `tool_call` hook over every pending call before any of them * execute — the TypeScript sibling of Rust's `sep_tool_call_plan` and Go's * `sepToolCallPlan`. Returns the calls to run (arguments possibly rewritten) * and, for any vetoed call, its id → reason. Without a host it returns the * input untouched. A call whose arguments do not parse as JSON skips the hook * so `dispatchTool` can surface its usual invalid-arguments error. */ private sepToolCallPlan; /** * Register a tool-call surveillance {@link ToolHook}, appended after any hooks * supplied via {@link AgentOptions.toolHooks}. Mirrors the Rust engine's * `ToolRegistry::add_hook`: every hook's `preCall` runs before a tool executes * (a throw blocks it) and its `postCall` runs after with a mutable result. */ addHook(hook: ToolHook): void; private buildSystem; private toolSpecs; /** * Run a single turn. * * `history` is prior OpenAI-format messages (multi-turn). `thread`, when given, * is a {@link SmoothAgentThread} carrying the conversation across runs: the turn * is seeded from the thread's messages, and this turn's new user + assistant * (+ tool) messages are appended back to it before returning. The thread takes * precedence over `history` as the prior context. */ run(message: string, history?: Array>, thread?: SmoothAgentThread): Promise; /** * Stream a single turn, yielding incremental {@link StreamEvent}s as the model * produces them. This drives the SAME agentic loop as {@link run} (system / * knowledge / memory build, seed messages, per-iteration compaction, cost * tracking, budget early-stop, deferred-tool specs, clearance + human-gate on * dispatch, checkpoint/thread persistence on exit) — but calls the model in * STREAMING mode and emits events as work happens: * * - a `text` event per non-empty content delta as it streams in; * - a `tool_call` event per requested tool call, after that iteration's model * stream ends, BEFORE the call is dispatched; * - a `tool_result` event per tool, after it finishes (in original call order * even when `parallelToolCalls` runs them concurrently); * - exactly one terminal `done` event carrying the same {@link AgentRunResponse} * {@link run} would return for the same script. * * NOTE: retry-with-backoff (`maxRetries`/`retryBackoffMs`) is intentionally NOT * applied here — re-running the call after a mid-stream failure would re-emit * already-yielded chunks. Retry stays scoped to non-streaming {@link run}; this * mirrors the C# `RunStreamingAsync` decision. */ runStream(message: string, history?: Array>, thread?: SmoothAgentThread): AsyncGenerator; /** * Invoke the model with bounded retry-with-exponential-backoff. * * On a transient error (anything the client throws — rate-limit, 5xx, dropped * connection) the call is retried up to `maxRetries` additional times, waiting * `retryBackoffMs * 2 ** (n - 1)` ms before the n-th (1-indexed) retry. If all * attempts fail the LAST error propagates, so the turn fails exactly as it did * before retries existed. Only this model call is retried — tool execution is not. */ private callModel; /** * Stamp Anthropic prompt-cache markers on an outbound body, when the upstream * understands them. A no-op for every other route, so the request stays * byte-identical on the OpenAI/Gemini/Groq paths and under the mock client. */ private markPromptCache; private dispatchTool; /** * dispatchTool returning the full {@link ToolResult}, so callers that surface * results to a UI (runStream) can forward the structured `details` a postCall * hook attached — the model itself only ever sees `content`. Mirrors the Rust * engine's `AgentEvent::ToolCallComplete.details`. */ private dispatchToolResult; } /** * Build a {@link Tool} that delegates a subtask to a child {@link SmoothAgent}. * * A sub-agent is just a tool backed by another agent: the model calls this tool * with a `task` argument, the child agent runs that task, and the child's final * reply becomes the tool result — composing with the existing tool loop, no special * wiring. The child can have its own instructions, tools, knowledge, etc. */ export declare function delegateTool(name: string, description: string, child: SmoothAgent, taskProperty?: string): Tool; //# sourceMappingURL=agent.d.ts.map