import type { LLMClient, ToolDef, Message } from "../providers/types.js"; import type { ToolHandler } from "./tools/index.js"; import type { Effort } from "../config/schema.js"; import type { Budget } from "./workflow/budget.js"; import type { LoopEvent, LoopHooks } from "./loop-events.js"; import type { SessionStore } from "./session-store.js"; import { type SubagentDef } from "./subagents.js"; export type { SubagentDef } from "./subagents.js"; export interface AgenticLoopParams { /** Provider-agnostic LLM client. */ client: LLMClient; /** Model ID (resolved via model-resolver). */ model: string; /** System prompt (loaded from agent .md file). */ systemPrompt: string; /** Initial user message (task description, handoff, etc.). */ userMessage: string; /** Tool schemas to pass to the API. */ tools: ToolDef[]; /** Handler functions for each tool, keyed by name. */ toolHandlers: Map; /** Max tool-use round trips before stopping. */ maxTurns: number; /** Per-message max_tokens. Defaults to 16384. */ maxTokens?: number; /** Reasoning/output effort forwarded to ChatParams.effort (Anthropic only). */ effort?: Effort; /** * Optional per-run spend ceiling. Charged per turn (tokens + costUsd); a hit * ceiling ends the run gracefully (stopReason 'budget_exceeded') rather than * throwing — see ADR-4. `assertWithinBudget()` is never called from the loop. */ budget?: Budget; /** * When true, contiguous runs of read-only-annotated tool calls (per-tool * `ToolDef.readOnly === true`, derived once from `tools`) execute * concurrently within a turn; everything else stays strictly serial * (ADR-2). Absent/false (the default) is byte-identical to the pre-change * serial for-await loop — order, error shapes, and `onToolUse` behavior * are unchanged. */ parallelReadOnlyTools?: boolean; /** Called when the model invokes a tool (for logging/progress). */ onToolUse?: (name: string, input: unknown) => void; /** Called after each completed turn (for progress tracking). */ onTurnComplete?: (turn: number, toolsCalled: string[]) => void; /** * Optional completion predicate. When the model ends a turn WITHOUT calling a * tool, the loop normally treats that as "done". Some OpenAI-compatible models * (e.g. DeepSeek) instead narrate intentions ("let me write the files...") and * stop without calling any tool — which would end the loop with no work done. * * When this predicate is provided and returns `false` for a tool-less turn, * the loop injects a nudge message (see `nudgeMessage`) and continues, up to * `maxNudges` times, instead of returning prematurely. When omitted, behavior * is unchanged (any tool-less turn ends the loop). */ completionCheck?: (text: string) => boolean; /** Max nudges before giving up on an apparently-incomplete tool-less turn. Default 2. */ maxNudges?: number; /** The nudge text appended when `completionCheck` fails. A sensible default is used if omitted. */ nudgeMessage?: string; /** * Optional streaming text callback (sprint 8). Threaded into every chat call * as ChatParams.onTextDelta; the Anthropic adapter invokes it per text delta. * When onEvent is ALSO present, each delta additionally emits a * { type:"text-delta", turn, delta } LoopEvent. Absent (and no onEvent) => no * onTextDelta reaches chat, the adapter uses non-streaming create, byte-identical. */ onTextDelta?: (delta: string) => void; /** * Optional structured event stream (agent-loop-capability-port sprint 5). * Emits a typed `LoopEvent` at each natural loop point (init, turn-start, * tool-start/tool-end, turn-end, result) — a pure host-side observation * channel that adds zero tokens to the conversation and never changes loop * behavior. A throwing `onEvent` is caught and logged, never crashes the * loop. Absent (the default) is byte-identical to omitting it entirely. */ onEvent?: (event: LoopEvent) => void; /** * Optional host-side hooks (agent-loop-capability-port sprint 5): * `preToolUse` can veto a tool call (model gets an isError rejection, loop * continues), `postToolUse` observes each tool result, `onStop` observes * the final result exactly once. All observe-hooks are caught-and-logged * on throw; a throwing `preToolUse` is treated as a fail-closed deny. * Absent (the default) is byte-identical to omitting it entirely. */ hooks?: LoopHooks; /** * Opt-in loop-transcript persistence (agent-loop-capability-port sprint 6). * When present, the loop saves the full `Message[]` transcript + metadata * to `.bober/sessions/.json` after every turn (crash-resumable). * A save failure is caught and logged, never crashes the run. Absent (the * default) is byte-identical — no files or directories are created. */ session?: { store: SessionStore; sessionId: string; }; /** * A prior transcript to seed AHEAD of `userMessage` (loop resume). Use * `resumeSession()` to load this from a persisted session. Absent (the * default) is byte-identical to omitting it entirely. */ initialMessages?: Message[]; /** * Opt-in in-context auto-compaction (agent-loop-capability-port sprint 7). * When set and a turn's `response.usage.inputTokens` (the PER-REQUEST * prompt size, not a running total — a shrunken prompt naturally resets * this, avoiding thrash) exceeds `maxContextTokens`, the loop summarizes * older messages via ONE extra `client.chat` call, replacing the head with * a single summary message and keeping the last `keepRecentTurns * 2` * messages (default `2 * 2 = 4`) verbatim. The system prompt and the * turn's own pending tool exchange are never touched — compaction only * ever mutates `messages`. A failed summarization call fails open: logged, * skipped for that turn, the run continues uncompacted. Absent (the * default) => never compacts, byte-identical (sc-7-5). */ compaction?: { maxContextTokens: number; keepRecentTurns?: number; instructions?: string; }; /** * Optional abort signal (agent-loop-capability-port sprint 9). A * web-standard `AbortSignal`. Checked at the top of every turn AND right * after each chat response (before tool execution) — an in-flight * Anthropic request is additionally cancelled mid-flight (threaded into * `ChatParams.abortSignal`). When it fires, the loop ends gracefully at * the next boundary/cancellation point with `stopReason: "aborted"` plus * accumulated partial usage/costUsd/turnsUsed — NEVER a throw or rejected * promise. Adapters without native cancellation (openai/google/claude-code) * simply ignore the field; their in-flight request completes, but the * loop discards that response at the post-response check rather than * using it for a further turn. Absent (the default) is byte-identical. */ abortSignal?: AbortSignal; /** * Opt-in in-process scoped subagents (agent-loop-capability-port sprint 10). * When non-empty, a `spawn_subagent` ToolDef is registered whose handler * runs a NESTED `runAgenticLoop` with fresh context, the def's scoped tool * subset, per-agent model/effort/maxTurns, and the SAME `Budget` instance * (combined spend visible; a child cannot out-spend a parent ceiling). * One-level hard cap: children always get `subagents: undefined` — no * recursive nesting. Absent/empty => the tool list is byte-identical * (sc-10-4). */ subagents?: SubagentDef[]; } export interface AgenticLoopResult { /** The final text response from the model. */ finalText: string; /** Total tool-use round trips completed. */ turnsUsed: number; /** Names of all tools called across all turns. */ toolsCalled: string[]; /** Cumulative token usage. */ usage: { inputTokens: number; outputTokens: number; }; /** The stop reason of the final API response. */ stopReason: string; /** * True only when the provider refused. Absent (not `false`) when no refusal * occurred, so non-refusal runs stay byte-identical. Write-capable roles * (generator/curator) MUST treat this as success:false (ADR-5). */ refused?: boolean; /** * Cumulative USD cost summed across turns that reported a `costUsd`. Absent * (not `undefined`-valued — the key itself is omitted) when no turn reported * a cost, so cost-free runs stay byte-identical. */ costUsd?: number; } /** * Thrown by `chatWithRetry` (agent-loop-capability-port sprint 9) when a chat * call fails because the run's `abortSignal` fired. Never retried and never * escapes `runAgenticLoop` — the loop's chat catch maps it to a graceful * `stopReason: "aborted"` return instead of `"error"`. */ export declare class AbortedError extends Error { constructor(); } export interface CoerceJsonParams { client: LLMClient; model: string; systemPrompt: string; /** The original task/user message that started the loop. */ userMessage: string; /** The (non-JSON / wrong-shape) text the agentic loop produced, fed back for context. */ priorText: string; /** * Final instruction telling the model EXACTLY what JSON to emit. Because we * use the provider's loose json_object mode (not strict json_schema — DeepSeek * rejects the latter), this instruction must spell out every required field; * json_object mode only guarantees the output is *a* valid JSON object. */ instruction: string; maxTokens?: number; } /** * Force a structured-JSON response after an agentic loop failed to produce the * required object. Some OpenAI-compatible models (notably DeepSeek) either * narrate prose instead of JSON, or emit valid JSON of the WRONG shape (e.g. * following a short "summary" prompt instead of the full schema). * * Strategy: re-ask with `json_object` response_format (broadly supported, * including DeepSeek) plus an explicit field-by-field instruction. If the * provider rejects response_format at all (some servers 400 on it), fall back * to a plain prompt-only call — the instruction itself demands JSON-only output. * * Provider-agnostic and meant as a *fallback* after a normal parse attempt * fails, so it's a no-op for models that already comply (Claude). * * @returns The raw text of the coerced response (a JSON document). The caller * still validates/repairs it against its domain schema. */ export declare function coerceJsonOutput(params: CoerceJsonParams): Promise; /** * Run a multi-turn agentic conversation loop. * * The loop sends the initial user message, then iterates: if the model * responds with tool_use, we execute the tools and feed results back. * This continues until the model stops requesting tools or maxTurns * is exceeded. * * Uses provider-agnostic types throughout. The LLMClient implementation * handles all conversion to/from provider-specific formats. * * @returns The final text response and metadata about the conversation. */ export declare function runAgenticLoop(params: AgenticLoopParams): Promise; /** * Load a persisted transcript so a NEW `runAgenticLoop` call can continue it * with full prior context: pass the returned `initialMessages` (seeded * AHEAD of the new `userMessage`) alongside `session: { store, sessionId }` * so new turns append to the same session file. * * Never throws. A missing or corrupt session file returns a typed * `{ error }` result instead — conceptually aligned with the loop's own * `stopReason: "error"` path, but this runs BEFORE the loop starts, so it is * a separate discriminated-union return, not a shared code path. The loop is * never started on the error branch, so no empty session ever silently * replaces the requested one (sc-6-5). */ export declare function resumeSession(store: SessionStore, sessionId: string): Promise<{ initialMessages: Message[]; sessionId: string; } | { error: string; }>; /** * Copy the transcript at `sessionId` into a new session file so a new * `runAgenticLoop` invocation can branch from it without mutating the * original (sc-6-3). `newId` may be supplied explicitly (e.g. by tests); * when omitted, a deterministic id is derived from `sessionId` + the * store's injected clock (`sessionForkId` — no argless randomness). * * @returns The new session id (== `newId` when supplied). */ export declare function forkSession(store: SessionStore, sessionId: string, newId?: string): Promise; //# sourceMappingURL=agentic-loop.d.ts.map