//! The Agent owns the conversation-driving loop: provider streaming + //! tool dispatch. //! //! On each turn, after the provider streams an assistant message, the //! agent inspects it for ToolUse blocks. If any are present, the agent: //! //! 1. Groups them by their *owning registration* in the registry — a //! single `Tool` is its own group; every `ToolSource`-backed tool //! whose name maps to the same source forms one group. //! 2. Spawns one concurrent task per group via `std.Io.Group`. //! A single-`Tool` group runs the tool's `invoke` once; a //! `ToolSource` group calls the source's `invoke_batch` with all //! of its calls at once. We use `Group.concurrent` (not `async`) //! because tool invocations may block on I/O and we need real //! concurrency, not just expressed asynchrony. //! 3. Awaits the group. ToolResult blocks are assembled in the //! *original* call order (i.e. the order the LLM emitted them). //! 4. Appends a user message containing the ToolResult blocks back //! into the conversation and loops. //! //! The "thread-safe" promise for single `Tool` registrations is //! unchanged. For `ToolSource`-backed tools, the source's runtime //! receives all of its calls on one thread per turn, so it can keep a //! single-threaded interpreter (Lua, Python, ...) without further //! synchronization. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const provider_mod = @import("provider.zig"); const stream_mod = @import("stream.zig"); const config_mod = @import("config.zig"); const conversation = @import("conversation.zig"); const compaction_mod = @import("compaction.zig"); const tool_mod = @import("tool.zig"); const image_mod = @import("image.zig"); const tool_source_mod = @import("tool_source.zig"); const tool_registry_mod = @import("tool_registry.zig"); const session_store_mod = @import("session_store.zig"); const null_store_mod = @import("null_store.zig"); const turn_persist = @import("turn_persist.zig"); pub const Tool = tool_mod.Tool; pub const ToolSource = tool_source_mod.ToolSource; pub const ToolRegistry = tool_registry_mod.ToolRegistry; const Event = stream_mod.Event; const Entry = tool_registry_mod.Entry; pub const Config = config_mod.Config; /// Re-export for the `compact` usages parameter (provider-reported token /// usage per message, used for retention sizing). pub const conversation_Usage = @import("session.zig").Usage; /// Append a single-text user message. `Conversation.addUserMessage` now /// takes a block slice (symmetric with `addAssistantMessage`); this wraps /// the common plain-text case used by the agent's interactive turn and the /// compaction prompt. fn addUserText(conv: *conversation.Conversation, text: []const u8) !void { const tb = try conversation.textualBlockFromSlice(conv.allocator, text); var block: conversation.ContentBlock = .{ .Text = tb }; errdefer block.deinit(conv.allocator); try conv.addUserMessage(&.{block}); } /// Deep-copy a message (role + all content blocks) into fresh owned /// allocations. Used when rebuilding the conversation after compaction. fn cloneMessage(alloc: Allocator, msg: conversation.Message) !conversation.Message { var content: std.ArrayList(conversation.ContentBlock) = .empty; errdefer { for (content.items) |*b| b.deinit(alloc); content.deinit(alloc); } try content.ensureTotalCapacity(alloc, msg.content.items.len); for (msg.content.items) |block| { content.appendAssumeCapacity(try cloneBlock(alloc, block)); } return .{ .role = msg.role, .content = content, .usage = msg.usage }; } fn cloneBlock(alloc: Allocator, block: conversation.ContentBlock) !conversation.ContentBlock { return switch (block) { .Text => |b| .{ .Text = try conversation.textualBlockFromSlice(alloc, b.items) }, .Thinking => |b| blk: { const tb = try conversation.textualBlockFromSlice(alloc, b.text.items); errdefer { var mut = tb; mut.deinit(alloc); } const sig: ?[]const u8 = if (b.signature) |s| try alloc.dupe(u8, s) else null; break :blk .{ .Thinking = .{ .text = tb, .signature = sig } }; }, .ToolUse => |b| blk: { const id = try alloc.dupe(u8, b.id); errdefer alloc.free(id); const name = try alloc.dupe(u8, b.name); errdefer alloc.free(name); const input = try conversation.textualBlockFromSlice(alloc, b.input.items); break :blk .{ .ToolUse = .{ .id = id, .name = name, .input = input } }; }, .ToolResult => |b| blk: { const tuid = try alloc.dupe(u8, b.tool_use_id); errdefer alloc.free(tuid); var parts: std.ArrayList(conversation.ResultPartStored) = .empty; errdefer { for (parts.items) |*p| p.deinit(alloc); parts.deinit(alloc); } try parts.ensureTotalCapacity(alloc, b.parts.items.len); for (b.parts.items) |src| { switch (src) { .text => |tb| { const t = try conversation.textualBlockFromSlice(alloc, tb.items); parts.appendAssumeCapacity(.{ .text = t }); }, .media => |m| { const mt = try alloc.dupe(u8, m.media_type); errdefer alloc.free(mt); const data = try conversation.textualBlockFromSlice(alloc, m.data.items); parts.appendAssumeCapacity(.{ .media = .{ .media_type = mt, .data = data } }); }, } } break :blk .{ .ToolResult = .{ .tool_use_id = tuid, .parts = parts, .is_error = b.is_error } }; }, .System => |b| .{ .System = .{ .text = try conversation.textualBlockFromSlice(alloc, b.text.items), .mode = b.mode, } }, .CompactionSummary => |b| .{ .CompactionSummary = .{ .text = try conversation.textualBlockFromSlice(alloc, b.text.items), } }, }; } fn isValidToolInput(input: []const u8) bool { if (input.len == 0) return true; if (input[0] != '{') return true; // legacy tests/tools may use opaque bytes var parsed = std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, input, .{}) catch return false; defer parsed.deinit(); return parsed.value == .object; } fn invalidInputResult(allocator: Allocator, input: []const u8) !tool_mod.ResultParts { const msg = try std.fmt.allocPrint( allocator, "Tool call was not executed: tool input was incomplete or invalid JSON. Partial input: {s}", .{input}, ); return tool_mod.ResultParts.fromTextOwned(allocator, msg); } /// What to do with an error returned by tool dispatch. const ToolErrorAction = enum { /// Surface the failure to the model as an error `ToolResult`, then let /// the agent loop continue. tool_result, /// Abort the whole turn and propagate to the embedder. Reserved for /// failures that belong to the host, not the model/provider exchange. hard_fail, }; /// Decide how to handle a tool dispatch error. Only genuine host failures /// abort the turn; everything else becomes a model-visible tool result so /// the model can correct course (and so every `ToolUse` keeps its matching /// `ToolResult`, which providers require). fn classifyToolError(err: anyerror) ToolErrorAction { return switch (err) { error.Canceled, error.OutOfMemory => .hard_fail, else => .tool_result, }; } /// Build an error `ResultPart` describing a failed tool call, in the /// model-readable form the plan specifies. fn toolErrorResult( allocator: Allocator, tool_name: []const u8, err: anyerror, ) !tool_mod.ResultParts { const msg = try std.fmt.allocPrint( allocator, "Tool execution failed for `{s}`: {s}\n" ++ "You may fix the arguments, try a different tool, or explain the failure to the user.", .{ tool_name, @errorName(err) }, ); return tool_mod.ResultParts.fromTextOwned(allocator, msg); } /// The user's submission that opens a turn. A struct (not a bare slice) so /// it can grow to carry file/image attachments alongside the chat text /// without changing `Agent.run`'s signature. pub const UserMessage = struct { text: []const u8, }; /// Outcome of a compaction attempt. pub const CompactionResult = struct { /// Whether the conversation was actually compacted. False means the /// active conversation already fit within the keep-verbatim budget /// (nothing to summarize) — the conversation is unchanged. compacted: bool, /// Number of whole turns kept verbatim after the summary. kept_turns: usize = 0, /// Number of conversation messages folded into the summary. summarized_messages: usize = 0, }; pub const Agent = struct { _allocator: Allocator, _io: Io, /// The active configuration snapshot, consulted fresh at the top of /// every turn. Immutable while a turn is in flight; swap this pointer /// (`setConfig`) between turns to change provider/model/base_url /// atomically. The pointee is owned by the embedder, not the agent. The /// tool set is no longer part of the snapshot — it lives on `_registry`. _config: *const Config, /// The tool set this agent exposes. Owned by the agent: created empty at /// `init`, populated via `registerTool`/`registerToolSource`, torn down /// in `deinit`. Read fresh by the agent loop each turn, so a /// registration between turns is visible at the next turn boundary. _registry: ToolRegistry, /// The live conversation the agent drives. Owned by the agent (adopted /// at `init`); torn down in `deinit`. Turn-driving methods operate on /// this directly rather than taking a `*Conversation` parameter. /// /// This is the one intended-public field (every other field is /// underscore-prefixed internal state): a borrowed handle on the live /// conversation, for in-place context-management surgery. Valid for the /// agent's lifetime; do not retain past it. conversation: conversation.Conversation, /// The session this agent appends to. Minted from the store at `init` /// (fresh: `store.create()`) or supplied by the embedder on resume /// (`resolve`/`latest`). `Session.append` proxies to the store and /// updates the session's last-used wire identity. The embedder owns the /// underlying store, which must outlive the agent. _session: session_store_mod.Session, /// Injectable streaming seam. Defaults to the real provider dispatch /// (`provider_mod.openStream`); tests override it with a stub. _open_stream_fn: provider_mod.OpenStreamFn = provider_mod.openStream, /// Set by the embedder after `runStep` returns to learn whether an /// automatic compaction occurred this turn (so it can persist the /// rewritten conversation). Reset at the top of each `runStep`. _auto_compacted: bool = false, /// PRNG state for backoff jitter. Seeded lazily on first retry. Only /// touched from the single agent-loop thread (retries are serial), so /// no synchronization is needed. _retry_prng: ?std.Random.DefaultPrng = null, /// Construct an agent. /// /// `store` is the persistence backend (use `null_store.store()` to opt /// out). `maybe_conversation` is adopted (ownership transferred) when /// non-null — the resume path: open a store, ask it for the /// conversation, hand it here. When null, a fresh empty conversation is /// created. Either way the agent owns and tears down the conversation. /// The inner is heap-pinned at `init`, so the returned `*Agent` is a /// cheap, movable handle ("don't move the Agent" stops being a rule /// anyone can violate). The caller owns it and must `deinit` it. pub fn init( allocator: Allocator, io: Io, config: *const Config, session: session_store_mod.Session, maybe_conversation: ?conversation.Conversation, ) !*Agent { const self = try allocator.create(Agent); self.* = .{ ._allocator = allocator, ._io = io, ._config = config, ._registry = ToolRegistry.init(allocator), .conversation = maybe_conversation orelse conversation.Conversation.init(allocator), ._session = session, }; return self; } pub fn deinit(self: *Agent) void { // The agent owns the conversation, the tool registry, and the // session handle's `info` (minted by `store.create()` or resolved // by the embedder and handed in). It borrows the config snapshot // and the underlying store, which the embedder tears down. It is // self-heap-pinned (`init` allocated it), so it frees itself last. const allocator = self._allocator; self._registry.deinit(); self.conversation.deinit(); self._session.info.deinit(allocator); allocator.destroy(self); } /// The id of the session this agent appends to. pub fn sessionId(self: *const Agent) []const u8 { return self._session.info.id; } /// Add a single tool to this agent's tool set. Visible at the next turn. pub fn registerTool(self: *Agent, tool: Tool) !void { try self._registry.register(tool); } /// Add a tool source (a dynamic group of tools) to this agent's tool /// set. Visible at the next turn. pub fn registerToolSource(self: *Agent, src: ToolSource) !void { try self._registry.registerSource(src); } /// The wire-format provider identity stamped on persisted entries, /// derived from the active config snapshot. Ground truth: never a CLI /// alias, never any `api_key` material. fn wireIdentity(self: *const Agent) session_store_mod.WireIdentity { return self._config.provider.wireIdentity(); } /// Swap the active configuration snapshot. Takes effect at the start of /// the next turn. Safe to call between `runStep` invocations or from a /// tool handler that runs between provider steps; never mutates a /// snapshot a turn is currently reading. pub fn setConfig(self: *Agent, config: *const Config) void { self._config = config; } /// Add a system message (append or replace mode) to the conversation /// and persist it. The persisted entry records the mode so replay /// reconstructs the same effective system prompt. /// Append a system message (`.append` mode) and persist it. Adds to the /// effective system prompt. pub fn addSystemMessage(self: *Agent, text: []const u8) !void { return self._persistSystemMessage(text, .append); } /// Replace the effective system prompt (`.replace` mode) and persist it. /// Discards all prior system text; replay reconstructs the same prompt. pub fn setSystemPrompt(self: *Agent, text: []const u8) !void { return self._persistSystemMessage(text, .replace); } fn _persistSystemMessage( self: *Agent, text: []const u8, mode: conversation.SystemMode, ) !void { const start = self.conversation.messages.items.len; switch (mode) { .append => try self.conversation.addSystemMessage(text), .replace => try self.conversation.replaceSystemMessage(text), } try turn_persist.persistTurn( self._allocator, &self._session, &self.conversation, start, self.wireIdentity(), &.{}, ); } /// Submit a user message and begin a turn, returning a resumable pull /// `Stream`. /// /// The user message is appended to the conversation and durably persisted /// *immediately* (before any provider call), so a crash before the model /// replies leaves a recoverable dangling prompt in the store. No provider /// I/O happens here: the request opens lazily on the first /// `Stream.next()`. /// /// The returned `*Stream` is heap-allocated; the caller owns it and must /// `deinit` it. Persistence of whatever the turn committed runs when the /// stream reaches its terminal `turn_complete` or is `deinit`ed early /// (so a partial turn is still durably logged), mirroring the previous /// `runStep` exit-path guarantee. /// /// The agent re-reads its `config` snapshot at the top of each provider /// response inside the stream, so a mid-conversation `setConfig` takes /// effect at the next response boundary, never mid-stream. pub fn run(self: *Agent, message: UserMessage) !*Stream { self._auto_compacted = false; // Append + persist the user prompt up front (the dangling-prompt // recovery guarantee). const user_start = self.conversation.messages.items.len; try addUserText(&self.conversation, message.text); try turn_persist.persistTurn( self._allocator, &self._session, &self.conversation, user_start, self.wireIdentity(), &.{}, ); const s = try self._allocator.create(Stream); s.* = Stream.init(self); return s; } /// Persist the messages a turn produced. When the turn auto-compacted, /// message indices shifted (the conversation was rewritten to /// `[system..., summary, kept-suffix...]`), so persist the whole /// post-compaction window instead of `[start..]`. fn persistTurnTail(self: *Agent, start: usize) !void { const id = self.wireIdentity(); if (self._auto_compacted) { try turn_persist.persistCompaction( self._allocator, &self._session, &self.conversation, id, &.{}, ); } else { try turn_persist.persistTurn( self._allocator, &self._session, &self.conversation, start, id, &.{}, ); } } fn hasToolUseBlock(msg: conversation.Message) bool { for (msg.content.items) |block| { if (block == .ToolUse) return true; } return false; } /// Open one provider response with the configured retry policy, pushing /// any informational `provider_retry` events into `out`. Returns the /// resumable `ProviderStream` once a request has been successfully /// opened (headers received), or propagates a terminal error. /// /// Decision path for a failed open: /// - `ContextOverflow`: compact once, then retry the same request a /// single time against the compacted conversation (a one-shot path, /// independent of the transient-retry budget). /// - retryable provider error (rate limit, server, transport, /// malformed stream): sleep with exponential backoff + jitter /// (honoring `Retry-After` when present) and retry, up to /// `retry.max_attempts` total attempts. /// - anything else (auth, bad request, cancellation, local errors): /// propagate immediately. /// /// A failed open never mutates the conversation (providers commit the /// assistant message only on success), so each retry runs against the /// same snapshot. Mid-stream failures (surfaced from `produce`) re-enter /// here via `Stream`, replaying the response from a fresh open — exactly /// as the previous push loop replayed a failed `streamStep`. fn openWithRetries( self: *Agent, cfg: *const Config, out: *stream_mod.EventQueue, ) !provider_mod.ProviderStream { const policy = cfg.retry; var attempt: usize = 1; while (true) { var diag: provider_mod.ProviderDiagnostic = .{}; const ps = self._open_stream_fn(self._allocator, self._io, cfg, &self._registry, &self.conversation, &diag) catch |err| { if (err == error.ContextOverflow) { return self.handleContextOverflow(cfg, out, err); } if (!provider_mod.isRetryableProviderError(err)) return err; // Out of attempts: hard-fail with the last error. if (attempt >= policy.max_attempts) return err; const delay_ms = self.backoffDelayMs(policy, attempt, diag.retry_after_ms); try out.push(.{ .provider_retry = .{ .attempt = attempt, .max_attempts = policy.max_attempts, .delay_ms = delay_ms, .err = err, .status_code = diag.status_code, .retry_after_ms = diag.retry_after_ms, .message = diag.message, } }); if (delay_ms > 0) { const ms: i64 = @intCast(@min(delay_ms, std.math.maxInt(i64))); self._io.sleep(.fromMilliseconds(ms), .real) catch |e| return e; } attempt += 1; continue; }; return ps; } } /// One-shot context-overflow recovery: compact once, retry once. Pushes /// a `provider_retry` event with `compaction = true` and `delay_ms = 0`, /// then re-opens the request against the compacted context. A second /// overflow (or any other error) propagates. fn handleContextOverflow( self: *Agent, cfg: *const Config, out: *stream_mod.EventQueue, err: anyerror, ) !provider_mod.ProviderStream { if (self._auto_compacted) return err; // already retried once this turn const sys = self._config.compaction.compaction_prompt orelse return err; const res = try self._compactInPlace(sys, null); if (!res.compacted) return err; // nothing to shed; give up self._auto_compacted = true; try out.push(.{ .provider_retry = .{ .attempt = 1, .max_attempts = 2, .delay_ms = 0, .err = err, .compaction = true, } }); // Retry the same request against the compacted context. var diag: provider_mod.ProviderDiagnostic = .{}; return self._open_stream_fn(self._allocator, self._io, cfg, &self._registry, &self.conversation, &diag); } /// Compute the backoff delay (ms) for the just-failed `attempt` /// (1-based). Prefers a provider `Retry-After` (capped by policy); /// otherwise exponential `initial * multiplier^(attempt-1)`, capped, /// with optional full jitter in `[0, delay)`. fn backoffDelayMs( self: *Agent, policy: config_mod.RetryConfig, attempt: usize, retry_after_ms: ?u64, ) u64 { if (retry_after_ms) |ra| { return @min(ra, policy.max_delay_ms); } const exp: f64 = @floatFromInt(attempt - 1); const base: f64 = @as(f64, @floatFromInt(policy.initial_delay_ms)) * std.math.pow(f64, policy.multiplier, exp); const capped: f64 = @min(base, @as(f64, @floatFromInt(policy.max_delay_ms))); var delay: u64 = @intFromFloat(capped); if (policy.jitter and delay > 0) { if (self._retry_prng == null) { const ns = std.Io.Clock.now(.real, self._io).nanoseconds; const seed: u64 = @truncate(@as(u128, @bitCast(@as(i128, ns)))); self._retry_prng = std.Random.DefaultPrng.init(seed); } const r = self._retry_prng.?.random(); delay = r.intRangeLessThan(u64, 0, delay + 1); } return delay; } /// Compact the conversation: summarize an older prefix into a single /// `.CompactionSummary` block and keep a recent suffix of whole turns /// verbatim. Mutates `self.conversation` in place. /// /// This is the pure transform — it does **not** persist. The explicit /// `/compact` entry point is the public `compact` (which persists); the /// automatic (context-overflow) path persists via `runStep`'s turn tail /// (the rewritten window is logged as a fresh compaction window). /// /// The system prompt survives untouched: all `.system`-role messages /// are preserved in order, and no `replace` block is written. Only the /// conversation (user/assistant) prefix is summarized. /// /// Per-message provider usage is read directly off the conversation /// (`Message.usage`, set live by the provider and on replay from disk). /// `computeSplit` uses it to size the retention window; messages /// lacking usage fall back to word counting. /// /// `extra_instructions`, when non-null, is appended to the compaction /// system prompt for this run (the `/compact $ARGUMENTS` path). /// /// `system_prompt` is the compaction system prompt (resolved by the /// embedder from its `COMPACTION.md` layers, or a built-in default). fn _compactInPlace( self: *Agent, system_prompt: []const u8, extra_instructions: ?[]const u8, ) !CompactionResult { const conv = &self.conversation; const messages = conv.messages.items; // Project per-message usage off the conversation for sizing. const usages = try self._allocator.alloc(?conversation_Usage, messages.len); defer self._allocator.free(usages); for (messages, 0..) |m, i| usages[i] = m.usage; const split = compaction_mod.computeSplit(messages, usages, self._config.compaction.keep_verbatim); // Determine the active conversation start (after any prior summary). const active_start: usize = if (conversation.latestCompactionIndex(messages)) |a| a + 1 else 0; // Nothing to summarize: the active conversation already fits, or the // prefix boundary is at/under the first active turn. if (split.prefix_end <= active_start) { return .{ .compacted = false }; } // Count how many *conversation* (non-system) messages are in the // summarized prefix. If none, this is also a no-op. var summarized: usize = 0; for (messages[active_start..split.prefix_end]) |m| { if (m.role != .system) summarized += 1; } if (summarized == 0) return .{ .compacted = false }; // Serialize the prefix transcript and carry forward the latest // existing summary (chained-compaction invariant). const transcript = try compaction_mod.serializeTranscript( self._allocator, messages[active_start..split.prefix_end], ); defer self._allocator.free(transcript); const previous_summary = compaction_mod.latestSummaryText(messages); const body = try compaction_mod.buildRequestBody(self._allocator, transcript, previous_summary); defer self._allocator.free(body); const summary = try self.runCompactionRequest(system_prompt, body, extra_instructions); defer self._allocator.free(summary.text); try self.rewriteWithSummary(conv, split.prefix_end, summary.text, summary.size); return .{ .compacted = true, .kept_turns = split.kept_turns, .summarized_messages = summarized, }; } /// Compact and persist the result to the session store. This is the /// explicit `/compact` entry point: it summarizes (via the private /// `_compactInPlace` transform) and, if anything was compacted, appends /// the new compaction window (summary + restated kept suffix) to the /// store. Returns the `CompactionResult` for the embedder to report. /// /// `override_system_prompt`, when non-null, is the compaction system /// prompt for this run; when null it falls back to /// `config.compaction.compaction_prompt`. With neither set, this errors /// (`error.NoCompactionPrompt`). pub fn compact( self: *Agent, override_system_prompt: ?[]const u8, extra_instructions: ?[]const u8, ) !CompactionResult { const system_prompt = override_system_prompt orelse self._config.compaction.compaction_prompt orelse return error.NoCompactionPrompt; const res = try self._compactInPlace(system_prompt, extra_instructions); if (res.compacted) { try turn_persist.persistCompaction( self._allocator, &self._session, &self.conversation, self.wireIdentity(), &.{}, ); } return res; } /// Rewrite `conv.messages` to `[all system messages..., summary, /// kept-suffix...]`. The summarized conversation prefix (everything /// before `prefix_end` that isn't a system message) is dropped; system /// messages survive in order; a `.CompactionSummary` user message is /// inserted; the kept suffix (`messages[prefix_end..]`) is preserved. fn rewriteWithSummary( self: *Agent, conv: *conversation.Conversation, prefix_end: usize, summary: []const u8, summary_size: u64, ) !void { const alloc = self._allocator; const old = conv.messages.items; var rebuilt: std.ArrayList(conversation.Message) = .empty; errdefer { for (rebuilt.items) |*m| m.deinit(alloc); rebuilt.deinit(alloc); } // 1. All system messages from the summarized prefix survive, in // order. (System messages in the kept suffix come along with it // below, so only scan the prefix here.) for (old[0..prefix_end]) |*m| { if (m.role != .system) continue; try rebuilt.append(alloc, try cloneMessage(alloc, m.*)); } // 2. The compaction summary, alone in a user message. { const tb = try conversation.textualBlockFromSlice(alloc, summary); var content: std.ArrayList(conversation.ContentBlock) = .empty; errdefer { for (content.items) |*b| b.deinit(alloc); content.deinit(alloc); } try content.append(alloc, .{ .CompactionSummary = .{ .text = tb } }); try rebuilt.append(alloc, .{ .role = .user, .content = content }); } // 3. The kept verbatim suffix, with usage recomputed so the // restated window reads like a fresh conversation anchored at // the summary. // // The post-compaction window is `[summary(user), kept_user, // kept_assistant, ...]`. `usage.input` is *cumulative* (the whole // prior prompt). We walk forward maintaining `running_input` — // the synthetic cumulative prompt size as of "just before the // next assistant output" — seeded with the summary's size: // // - non-assistant message → it occupies context; add its size // (word-count heuristic, the same `messageTokenEstimate` the // splitter uses for usage-less messages). // - assistant message → its synthetic prompt is `running_input`. // The whole prompt total collapses into `input` (the rewrite // busts any provider prefix cache, so cache_read/cache_write // are zeroed). `output`/`reasoning` are copied verbatim (real // generation). Then `running_input += output` for the next // turn. // // Assistants that had no usage stay null (we can't invent an // output we never measured; the splitter already tolerates null). var running_input: u64 = summary_size; for (old[prefix_end..]) |*m| { var cloned = try cloneMessage(alloc, m.*); if (cloned.role == .assistant) { if (m.usage) |u| { cloned.usage = .{ .input = running_input, .output = u.output, .cache_read = 0, .cache_write = 0, .reasoning = u.reasoning, }; running_input += u.output; } } else { running_input += compaction_mod.messageTokenEstimate(m.*, null); } try rebuilt.append(alloc, cloned); } // Swap in the rebuilt list and free the old one. for (conv.messages.items) |*m| m.deinit(alloc); conv.messages.deinit(alloc); conv.messages = rebuilt; } /// Run a single compaction provider call against a throwaway /// conversation. Returns the assistant's summary text (caller owns). /// /// Model selection: try `config.compaction.model` if set; on failure, /// fall back to the active chat model. Compaction runs with an empty /// tool registry and a single user message (the request body); no tools /// are exposed and no session logging occurs. fn runCompactionRequest( self: *Agent, system_prompt: []const u8, body: []const u8, extra_instructions: ?[]const u8, ) !CompactionSummary { const alloc = self._allocator; // Assemble the effective compaction system prompt (+ extra // instructions for a `/compact $ARGUMENTS` run). var sys_text: []const u8 = system_prompt; var sys_owned: ?[]u8 = null; defer if (sys_owned) |s| alloc.free(s); if (extra_instructions) |extra| { if (extra.len > 0) { const combined = try std.fmt.allocPrint( alloc, "{s}\n\n## Additional instructions for this compaction run\n\n{s}", .{ system_prompt, extra }, ); sys_owned = combined; sys_text = combined; } } var empty_registry = ToolRegistry.init(alloc); defer empty_registry.deinit(); // Try the configured compaction model first, then fall back to the // active chat model on any failure. if (self._config.compaction.model) |comp_provider| { const cfg: config_mod.Config = .{ .provider = comp_provider, .compaction = self._config.compaction, }; if (self.runSingleCompactionTurn(&cfg, &empty_registry, sys_text, body)) |summary| { return summary; } else |err| { std.log.warn("compaction model failed ({t}); falling back to active model", .{err}); } } const cfg: config_mod.Config = .{ .provider = self._config.provider, .compaction = self._config.compaction, }; return self.runSingleCompactionTurn(&cfg, &empty_registry, sys_text, body); } /// A generated compaction summary plus the token size to attribute to /// it when it becomes the new conversation anchor. `size` is the /// provider-reported `output` token count for the summary turn (the /// real generated length), falling back to the word-count heuristic /// when the provider emitted no usage. `text` is caller-owned. const CompactionSummary = struct { text: []u8, size: u64, }; /// One provider call for compaction. Builds a throwaway conversation /// (system prompt + one user message), streams a single turn through a /// capturing receiver, and returns the assembled assistant text plus /// the summary's token size (for usage anchoring on the rewrite). fn runSingleCompactionTurn( self: *Agent, cfg: *const config_mod.Config, registry: *const ToolRegistry, system_prompt: []const u8, body: []const u8, ) !CompactionSummary { const alloc = self._allocator; var conv = conversation.Conversation.init(alloc); defer conv.deinit(); try conv.addSystemMessage(system_prompt); try addUserText(&conv, body); // Drive one provider response to completion, ignoring every event. // Compaction doesn't need incremental output — the assembled message // is read off the conversation below — so we just pump the pull // stream until it commits the assistant message. var queue = stream_mod.EventQueue.init(alloc); defer queue.deinit(); var ps = try self._open_stream_fn(alloc, self._io, cfg, registry, &conv, null); defer ps.deinit(); while (true) { const status = try ps.produce(&queue); // Drain (and discard) any events to bound the queue/arena. while (queue.pop()) |_| {} if (status == .response_complete) break; } // The provider appended an assistant message; gather its text. const last = conv.messages.items[conv.messages.items.len - 1]; if (last.role != .assistant) return error.CompactionNoResponse; var out: std.ArrayList(u8) = .empty; errdefer out.deinit(alloc); for (last.content.items) |block| { if (block == .Text) try out.appendSlice(alloc, block.Text.items); } if (out.items.len == 0) return error.CompactionEmptySummary; // Summary size: prefer the provider's reported output token count // for this turn; otherwise word-count the assembled summary text // (the same fallback the splitter uses for usage-less messages). const size: u64 = if (last.usage) |u| u.output else compaction_mod.messageTokenEstimate(last, null); return .{ .text = try out.toOwnedSlice(alloc), .size = size }; } /// Dispatch every ToolUse block in `assistant_msg`. Groups by owning /// registration; one OS thread per group; results assembled in the /// original call order. fn dispatchToolCalls( self: *Agent, assistant_msg: conversation.Message, ) !void { const conv = &self.conversation; // Build the flat call list (in original order) and group calls // by owning registration. var calls: std.array_list.Managed(FlatCall) = .init(self._allocator); defer calls.deinit(); for (assistant_msg.content.items) |block| { if (block != .ToolUse) continue; const tu = block.ToolUse; if (!isValidToolInput(tu.input.items)) { try calls.append(.{ .tool_use_id = tu.id, .tool_name = tu.name, .input = tu.input.items, .entry = null, .result = try invalidInputResult(self._allocator, tu.input.items), .err = null, .is_error = true, }); continue; } const entry = self._registry.lookup(tu.name) orelse { // Unknown tool: don't abort. Synthesize an error result so // the model can correct, and so this ToolUse still gets its // matching ToolResult (providers reject a follow-up request // otherwise). try calls.append(.{ .tool_use_id = tu.id, .tool_name = tu.name, .input = tu.input.items, .entry = null, .result = try toolErrorResult(self._allocator, tu.name, error.UnknownTool), .err = null, .is_error = true, }); continue; }; try calls.append(.{ .tool_use_id = tu.id, .tool_name = tu.name, .input = tu.input.items, .entry = entry.entry, .result = null, .err = null, }); } std.debug.assert(calls.items.len > 0); // Partition into groups. A group's `kind` determines how it // runs; the `member_indices` are positions into `calls` (the // original call order) so we can write back results without // re-ordering. var groups: std.array_list.Managed(Group) = .init(self._allocator); defer { for (groups.items) |*g| g.deinit(self._allocator); groups.deinit(); } try buildGroups(self._allocator, calls.items, &groups); // Spawn one concurrent task per group via `std.Io.Group`. // Single-tool groups run the tool's vtable; source groups run // the source's `invoke_batch`. We use `concurrent` rather than // `async` because tool work may block on I/O — under a // single-threaded `Io` `async` would deadlock; `concurrent` // forces real concurrency (or `error.ConcurrencyUnavailable`). var task_group: Io.Group = .init; // `cancel` is idempotent with `await`; if anything below this // point errors before we successfully `await`, this releases // the group's resources. defer task_group.cancel(self._io); errdefer { for (calls.items) |*c| { if (c.result) |r| r.deinit(self._allocator); } } // Try real concurrency first. If the `Io` implementation can't // provide it (`error.ConcurrencyUnavailable`), fall back to running // every group sequentially on this thread — tool batches are small // (rarely more than a handful of calls) so the serial path is a fine // safety net rather than a hard failure. var ran_concurrently = true; for (groups.items) |*g| { task_group.concurrent(self._io, runGroup, .{ self, g, calls.items }) catch |e| { if (e == error.ConcurrencyUnavailable) { ran_concurrently = false; break; } return e; }; } if (ran_concurrently) { // `error.Canceled` here means cancellation propagated into this // dispatch from above; surface it like any other error. try task_group.await(self._io); } else { // Cancel any tasks that were spawned before the failure, then // run all groups serially. Only entry-bearing calls are touched // by `runGroup`; the pre-seeded error results (unknown tool, // invalid input) have `entry == null` and must be left intact. task_group.cancel(self._io); for (calls.items) |*c| { if (c.entry == null) continue; if (c.result) |r| r.deinit(self._allocator); c.result = null; c.err = null; } for (groups.items) |*g| runGroup(self, g, calls.items); } // Pre-pass: resolve worker-reported errors. A hard host failure // (cancellation, OOM) aborts the whole turn. Every other failure is // converted into a model-visible error `ToolResult` so the model can // recover and so each `ToolUse` keeps its matching `ToolResult` // (providers reject the next request otherwise). for (calls.items) |*c| { const e = c.err orelse continue; if (classifyToolError(e) == .hard_fail) return e; // Replace any partial result with a synthesized error result. if (c.result) |r| { r.deinit(self._allocator); c.result = null; } c.result = try toolErrorResult(self._allocator, c.tool_name, e); c.err = null; c.is_error = true; } // Assemble ToolResult blocks in original call order. var content: std.ArrayList(conversation.ContentBlock) = .empty; errdefer { for (content.items) |*b| b.deinit(self._allocator); content.deinit(self._allocator); } try content.ensureTotalCapacity(self._allocator, calls.items.len); for (calls.items) |*c| { const result_parts = c.result orelse { // Internal invariant: every call should now have a result // (success, synthesized error, or pre-seeded error). return error.MissingToolResult; }; c.result = null; // ownership transferred below defer result_parts.deinit(self._allocator); const id_copy = try self._allocator.dupe(u8, c.tool_use_id); errdefer self._allocator.free(id_copy); var stored: std.ArrayList(conversation.ResultPartStored) = .empty; errdefer { for (stored.items) |*p| p.deinit(self._allocator); stored.deinit(self._allocator); } try stored.ensureTotalCapacity(self._allocator, result_parts.items.len); for (result_parts.items) |part| { switch (part) { .text => |t| { var buf: conversation.TextualBlock = .empty; errdefer buf.deinit(self._allocator); try buf.appendSlice(self._allocator, t); stored.appendAssumeCapacity(.{ .text = buf }); }, .media => |m| { // libpanto owns the heavy lifting: detect the type // (when the tool gave no hint), resize large // rasters, then base64-encode for storage. Tools // hand over raw bytes only. const processed = image_mod.process(self._allocator, m.data, m.media_type) catch |e| { // Media processing failure: keep the turn alive by // dropping the attachment and noting it as text, // rather than aborting. `UnknownMediaType` gets a // friendly note; other failures name the error. var note: conversation.TextualBlock = .empty; errdefer note.deinit(self._allocator); if (e == error.UnknownMediaType) { try note.appendSlice(self._allocator, "[unrecognized binary attachment dropped]"); } else { const txt = try std.fmt.allocPrint( self._allocator, "[media attachment dropped: {s}]", .{@errorName(e)}, ); defer self._allocator.free(txt); try note.appendSlice(self._allocator, txt); } stored.appendAssumeCapacity(.{ .text = note }); continue; }; defer self._allocator.free(processed.data); const mt = try self._allocator.dupe(u8, processed.media_type); errdefer self._allocator.free(mt); const enc = std.base64.standard.Encoder; var buf: conversation.TextualBlock = .empty; errdefer buf.deinit(self._allocator); try buf.resize(self._allocator, enc.calcSize(processed.data.len)); _ = enc.encode(buf.items, processed.data); stored.appendAssumeCapacity(.{ .media = .{ .media_type = mt, .data = buf } }); }, } } content.appendAssumeCapacity(.{ .ToolResult = .{ .tool_use_id = id_copy, .parts = stored, .is_error = c.is_error, } }); } try conv.messages.append(self._allocator, .{ .role = .user, .content = content, }); } }; /// A resumable pull handle over one agent turn. /// /// `next()` pulls one `Event` at a time, driving the agent loop /// incrementally: open a provider response, stream its events, dispatch any /// tool calls between responses, and repeat until the model stops calling /// tools. The whole loop's state lives here (not on a stack frame), so the /// turn can suspend and resume between events. /// /// Contract (see `stream.zig`): /// - an `Event` value is streaming progress, including `turn_complete`; /// - `null` means exhausted (already past `turn_complete`), never before; /// - an error is a genuine failure (network/parse/provider). /// /// Event payloads borrow from the stream's decode state or the conversation /// and are valid only until the next `next()` call. pub const Stream = struct { // Internal state (underscore-prefixed by convention: not part of the // public surface, even though Zig fields are always reachable). The only // intended-public field is `state`, the transparent turn state machine. _agent: *Agent, _queue: stream_mod.EventQueue, state: State, /// The active provider response, when in `.streaming`. _response: ?provider_mod.ProviderStream = null, /// First message index of this turn (for persistence). _start: usize, /// Set once the turn's tail has been persisted (on terminal or deinit). _persisted: bool = false, /// A terminal error to surface once any already-queued events (e.g. /// `provider_retry` notices pushed before the failing attempt) have been /// drained. `next()` yields the queue first, then this error. _pending_error: ?anyerror = null, pub const State = enum { /// Open the next provider response (with retries). turn_start, /// Pump the active provider response into events. streaming, /// A provider response completed; decide tools-vs-done. after_response, /// The turn reached its terminal `turn_complete`. done, /// A failure already propagated; `next()` is poisoned. failed, }; fn init(agent: *Agent) Stream { return .{ ._agent = agent, ._queue = stream_mod.EventQueue.init(agent._allocator), .state = .turn_start, ._start = agent.conversation.messages.items.len, }; } pub fn deinit(self: *Stream) void { // Persist whatever the turn committed, on every exit path — including // dropping the stream mid-turn after some messages were committed. self.persistTail(); if (self._response) |ps| ps.deinit(); self._queue.deinit(); self._agent._allocator.destroy(self); } fn persistTail(self: *Stream) void { if (self._persisted) return; self._persisted = true; self._agent.persistTurnTail(self._start) catch |e| { std.log.err("session: failed to persist turn: {t}", .{e}); }; } /// Pull the next event, or null past the terminal. See the contract /// above. pub fn next(self: *Stream) !?Event { // Always drain queued events first; they borrow decode/conversation // state valid until this call returns. if (self._queue.pop()) |ev| return ev; // Queue drained: a deferred terminal error surfaces now. if (self._pending_error) |err| { self._pending_error = null; self.state = .failed; return err; } while (true) { switch (self.state) { .done => return null, .failed => return error.StreamPoisoned, .turn_start => { // Re-read the config snapshot at each response boundary // so a mid-conversation swap takes effect here, never // mid-stream. const cfg = self._agent._config; const ps = self._agent.openWithRetries(cfg, &self._queue) catch |err| { // Surface the failure after any queued retry notices // (pushed before each backoff) are drained. if (self._queue.pop()) |ev| { self._pending_error = err; return ev; } self.state = .failed; return err; }; self._response = ps; self.state = .streaming; if (self._queue.pop()) |ev| return ev; // retry notices }, .streaming => { const ps = self._response.?; const status = ps.produce(&self._queue) catch |err| { // Mid-stream failure. The conversation was not // mutated (commit happens only at response // completion), so retry by re-opening from scratch, // exactly as the prior push loop replayed a failed // streamStep. Non-retryable errors propagate. ps.deinit(); self._response = null; if (!provider_mod.isRetryableProviderError(err)) { self.state = .failed; return err; } self.state = .turn_start; // Emit a retry notice so consumers see the stall. const cfg = self._agent._config; const delay_ms = self._agent.backoffDelayMs(cfg.retry, 1, null); self._queue.push(.{ .provider_retry = .{ .attempt = 1, .max_attempts = cfg.retry.max_attempts, .delay_ms = delay_ms, .err = err, } }) catch |e| { self.state = .failed; return e; }; if (delay_ms > 0) { const ms: i64 = @intCast(@min(delay_ms, std.math.maxInt(i64))); self._agent._io.sleep(.fromMilliseconds(ms), .real) catch |e| { self.state = .failed; return e; }; } if (self._queue.pop()) |ev| return ev; continue; }; if (self._queue.pop()) |ev| return ev; if (status == .response_complete) { ps.deinit(); self._response = null; self.state = .after_response; } // else `.more`: loop and pump again. }, .after_response => { const conv = &self._agent.conversation; const last = conv.messages.items[conv.messages.items.len - 1]; std.debug.assert(last.role == .assistant); // Defense-in-depth: a provider that silently committed an // empty assistant message means the turn made no // observable progress. Surface it instead of looping. if (last.content.items.len == 0) { self.state = .failed; return error.EmptyAssistantResponse; } if (!Agent.hasToolUseBlock(last)) { self.state = .done; self.persistTail(); return .turn_complete; } // Dispatch the tool calls, bracketed by boundary events. const count = toolUseCount(last); self._queue.push(.{ .tool_dispatch_start = .{ .count = count } }) catch |e| { self.state = .failed; return e; }; self._agent.dispatchToolCalls(last) catch |err| { self.state = .failed; return err; }; const result_msg = conv.messages.items[conv.messages.items.len - 1]; self._queue.push(.{ .tool_dispatch_complete = .{ .message = result_msg } }) catch |e| { self.state = .failed; return e; }; self.state = .turn_start; if (self._queue.pop()) |ev| return ev; }, } } } }; fn toolUseCount(msg: conversation.Message) usize { var n: usize = 0; for (msg.content.items) |block| { if (block == .ToolUse) n += 1; } return n; } /// One ToolUse, as flattened into the agent's dispatch list. `result` /// and `err` are filled in by the worker; exactly one is non-null on /// successful task completion. const FlatCall = struct { tool_use_id: []const u8, // borrowed from assistant_msg tool_name: []const u8, // borrowed from assistant_msg input: []const u8, // borrowed from assistant_msg entry: ?Entry, /// Owned result parts from `Tool.invoke` or `ToolSource.invoke_batch`. /// Allocated with the agent's allocator. Transferred into a /// ToolResultBlock on success. result: ?tool_mod.ResultParts, /// If non-null, the worker reported a failure for this call. After /// dispatch it is classified: host failures abort the turn, everything /// else is converted into an error `ToolResult`. err: ?anyerror, /// True when `result` already holds a synthesized error result (unknown /// tool, invalid input). Worker-reported `err`s are folded into this /// during assembly. is_error: bool = false, }; /// One dispatch group. Either a single Tool invocation, or a batch of /// calls headed to one ToolSource. const Group = union(enum) { single: SingleGroup, source: SourceGroup, pub const SingleGroup = struct { tool: Tool, /// Index into the flat calls array. call_index: usize, }; pub const SourceGroup = struct { source: *ToolSource, /// Indices into the flat calls array. Owned by the group. member_indices: []usize, }; fn deinit(self: *Group, allocator: Allocator) void { switch (self.*) { .single => {}, .source => |sg| allocator.free(sg.member_indices), } } }; /// Partition the flat call list into groups. Order of groups is /// arbitrary; order within a `source` group preserves the original /// call order so that batch results can be written back positionally. fn buildGroups( allocator: Allocator, calls: []const FlatCall, out: *std.array_list.Managed(Group), ) !void { // Map from source pointer to the index of its group in `out`. // Buffers per source, accumulated then frozen into slices. var pending: std.AutoHashMap(*ToolSource, std.array_list.Managed(usize)) = .init(allocator); defer { var it = pending.valueIterator(); while (it.next()) |l| l.deinit(); pending.deinit(); } for (calls, 0..) |c, i| { const ent = c.entry orelse continue; switch (ent) { .single => |t| try out.append(.{ .single = .{ .tool = t, .call_index = i } }), .source => |sr| { const gop = try pending.getOrPut(sr.source); if (!gop.found_existing) { gop.value_ptr.* = std.array_list.Managed(usize).init(allocator); } try gop.value_ptr.append(i); }, } } // Freeze each pending list into a source-group entry. We move // ownership of the indices into `Group.source.member_indices`. var pit = pending.iterator(); while (pit.next()) |entry| { const src = entry.key_ptr.*; const indices = try entry.value_ptr.toOwnedSlice(); try out.append(.{ .source = .{ .source = src, .member_indices = indices } }); } } /// Worker entry point. Runs one group to completion, populating /// `calls[i].result` or `calls[i].err` for each member call. /// /// Return type is `void`, which coerces to `Io.Cancelable!void` as /// required by `Group.concurrent`. Tool errors are reported via /// `FlatCall.err`, not by returning from this function. fn runGroup(agent: *Agent, group: *Group, calls: []FlatCall) void { switch (group.*) { .single => |sg| { const i = sg.call_index; const c = &calls[i]; const out = sg.tool.vtable.invoke(sg.tool.ctx, c.input, agent._allocator) catch |e| { c.err = e; return; }; c.result = out; }, .source => |sg| runSourceGroup(agent, sg, calls), } } fn runSourceGroup(agent: *Agent, sg: Group.SourceGroup, calls: []FlatCall) void { const n = sg.member_indices.len; const batch_calls = agent._allocator.alloc(tool_source_mod.Call, n) catch |e| { for (sg.member_indices) |i| calls[i].err = e; return; }; defer agent._allocator.free(batch_calls); const batch_results = agent._allocator.alloc(tool_source_mod.CallResult, n) catch |e| { for (sg.member_indices) |i| calls[i].err = e; return; }; defer agent._allocator.free(batch_results); for (sg.member_indices, 0..) |idx, j| { batch_calls[j] = .{ .tool_name = calls[idx].tool_name, .input = calls[idx].input, }; batch_results[j] = .{ .err = error.SourceDroppedCall }; } sg.source.vtable.invoke_batch( sg.source.ctx, batch_calls, batch_results, agent._allocator, ) catch |e| { // Whole-batch failure: free any partial successes the source // already wrote, then mark every member as failed. for (batch_results) |r| switch (r) { .ok => |b| b.deinit(agent._allocator), .err => {}, }; for (sg.member_indices) |i| calls[i].err = e; return; }; // Per-call success/error. for (sg.member_indices, 0..) |i, j| { switch (batch_results[j]) { .ok => |b| calls[i].result = b, .err => |e| calls[i].err = e, } } } // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- const testing = std.testing; /// Test helper: submit `text` and drive the whole turn to completion via the /// pull `Stream`, discarding every event (the agent tests assert on /// conversation/store state, not on the event stream). Mirrors the old /// `submitUserMessage` + `runStep`: it returns the same terminal error a /// turn would raise. fn drainTurn(agent: *Agent, text: []const u8) !void { var s = try agent.run(.{ .text = text }); defer s.deinit(); while (try s.next()) |_| {} } /// Test helper: the items of a ToolResultBlock's first text part. fn trText(tr: conversation.ToolResultBlock) []const u8 { for (tr.parts.items) |p| { if (p == .text) return p.text.items; } return ""; } /// Test harness for the injectable `_open_stream_fn` seam. /// /// `provider_mod.OpenStreamFn` carries no user context (it mirrors the real /// free function exactly), so the stub parks its state in a module-level /// pointer that `stubOpenStream` reads. The Zig test runner executes tests /// serially in one process, so a single global slot is safe; each test sets /// it via `install` before driving the agent. var stub_active: ?*StubProvider = null; const StubProvider = struct { allocator: Allocator, scripted: []const ScriptedTurn, next: usize = 0, /// Number of leading stream opens that should fail with /// `error.ContextOverflow` before any scripted turn is served. Used to /// drive the auto-compaction path. Decremented on each overflow. overflow_calls: usize = 0, /// A queue of provider errors to return, in order, before any scripted /// turn is served. Each entry is consumed on one open. Used to drive the /// transient-retry path. scripted_errors: []const ScriptedError = &.{}, error_idx: usize = 0, /// Count of opens observed (failed + succeeded). Lets tests assert the /// exact number of attempts. calls_made: usize = 0, const ScriptedError = struct { err: anyerror, status_code: ?u16 = null, retry_after_ms: ?u64 = null, }; const ScriptedTurn = struct { blocks: []const TestBlock, /// Optional provider usage to stamp on the committed assistant /// message (mirrors a real provider's terminal usage). usage: ?conversation.Usage = null, }; const TestBlock = union(enum) { Text: []const u8, ToolUse: struct { id: []const u8, name: []const u8, input: []const u8, }, }; /// Point the global seam at this stub and return the function to assign /// to `agent._open_stream_fn`. Call once per test, after constructing the /// stub on the stack. fn install(self: *StubProvider) provider_mod.OpenStreamFn { stub_active = self; return stubOpenStream; } }; /// A canned resumable response: on the first `produce` it commits the /// scripted assistant message to the conversation and pushes the terminal /// `message_complete`, then reports `.response_complete`. It does not emit /// per-block events (the agent tests assert on conversation state, not the /// event stream), which is sufficient for driving the agent loop. const StubResponse = struct { allocator: Allocator, conv: *conversation.Conversation, turn: StubProvider.ScriptedTurn, done: bool = false, fn create( allocator: Allocator, conv: *conversation.Conversation, turn: StubProvider.ScriptedTurn, ) !provider_mod.ProviderStream { const self = try allocator.create(StubResponse); self.* = .{ .allocator = allocator, .conv = conv, .turn = turn }; return .{ .ptr = self, .vtable = &vtable }; } const vtable: provider_mod.ProviderStream.VTable = .{ .produce = produceVT, .deinit = deinitVT, }; fn produceVT(ptr: *anyopaque, out: *stream_mod.EventQueue) anyerror!provider_mod.ProviderStream.ProduceStatus { const self: *StubResponse = @ptrCast(@alignCast(ptr)); if (self.done) return .response_complete; self.done = true; var blocks: std.ArrayList(conversation.ContentBlock) = .empty; errdefer { for (blocks.items) |*b| b.deinit(self.allocator); blocks.deinit(self.allocator); } for (self.turn.blocks) |tb| { switch (tb) { .Text => |s| try blocks.append(self.allocator, .{ .Text = try conversation.textualBlockFromSlice(self.allocator, s), }), .ToolUse => |tu| { const id = try self.allocator.dupe(u8, tu.id); errdefer self.allocator.free(id); const name = try self.allocator.dupe(u8, tu.name); errdefer self.allocator.free(name); var input_buf: conversation.TextualBlock = .empty; errdefer input_buf.deinit(self.allocator); try input_buf.appendSlice(self.allocator, tu.input); try blocks.append(self.allocator, .{ .ToolUse = .{ .id = id, .name = name, .input = input_buf, } }); }, } } const moved = try blocks.toOwnedSlice(self.allocator); defer self.allocator.free(moved); try self.conv.addAssistantMessage(moved, self.turn.usage); const msg = self.conv.messages.items[self.conv.messages.items.len - 1]; try out.push(.{ .message_complete = .{ .message = msg, .usage = self.turn.usage } }); return .response_complete; } fn deinitVT(ptr: *anyopaque) void { const self: *StubResponse = @ptrCast(@alignCast(ptr)); self.allocator.destroy(self); } }; fn stubOpenStream( allocator: Allocator, _: Io, _: *const config_mod.Config, _: *const ToolRegistry, conv: *conversation.Conversation, diag: ?*provider_mod.ProviderDiagnostic, ) anyerror!provider_mod.ProviderStream { const self = stub_active orelse return error.NoStubInstalled; self.calls_made += 1; if (self.error_idx < self.scripted_errors.len) { const e = self.scripted_errors[self.error_idx]; self.error_idx += 1; if (diag) |d| { d.status_code = e.status_code; d.retry_after_ms = e.retry_after_ms; } return e.err; } if (self.overflow_calls > 0) { self.overflow_calls -= 1; return error.ContextOverflow; } if (self.next >= self.scripted.len) return error.NoMoreScriptedTurns; const turn = self.scripted[self.next]; self.next += 1; return StubResponse.create(allocator, conv, turn); } /// Build a stack registry + active `Config` snapshot for tests that drive /// the agent. Post-R1 the registry no longer lives on `Config` — it lives /// on the `Agent`. The harness still owns a registry so tests can pre-stage /// tools and copy them onto the agent (`seed`) after `init`. The caller /// owns both and must keep them alive for the agent's lifetime. const TestHarness = struct { registry: ToolRegistry, config: config_mod.Config, fn init(allocator: Allocator) TestHarness { return .{ .registry = ToolRegistry.init(allocator), .config = undefined }; } /// Finalize the config snapshot. Must be called after `init` and before /// constructing the agent, once the harness has a stable address. fn activate(self: *TestHarness) void { self.config = .{ .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } }, }; } /// Move the tools pre-staged on the harness registry onto a freshly /// `init`ed agent's own registry. Post-R1 the agent owns its tool set, /// so tests stage tools on the harness then transplant them here. The /// agent's empty registry is freed and replaced; the harness registry /// is left empty (its `deinit` becomes a no-op free). fn seedInto(self: *TestHarness, agent: *Agent) void { agent._registry.deinit(); agent._registry = self.registry; self.registry = ToolRegistry.init(self.registry.allocator); } fn deinit(self: *TestHarness) void { self.registry.deinit(); } }; const EchoTool = struct { prefix_owned: []u8, name_owned: []u8, fn create(allocator: Allocator, name: []const u8, prefix: []const u8) !Tool { const self = try allocator.create(EchoTool); errdefer allocator.destroy(self); self.name_owned = try allocator.dupe(u8, name); errdefer allocator.free(self.name_owned); self.prefix_owned = try allocator.dupe(u8, prefix); return .{ .decl = .{ .name = self.name_owned, .description = "echo", .schema_json = "{}", }, .ctx = self, .vtable = &vt, }; } const vt: Tool.VTable = .{ .invoke = invoke, .deinit = deinit }; fn invoke(ctx: *anyopaque, input: []const u8, allocator: Allocator) anyerror!tool_mod.ResultParts { const self: *EchoTool = @ptrCast(@alignCast(ctx)); const msg = try std.fmt.allocPrint(allocator, "{s}{s}", .{ self.prefix_owned, input }); return tool_mod.ResultParts.fromTextOwned(allocator, msg); } fn deinit(ctx: *anyopaque, allocator: Allocator) void { const self: *EchoTool = @ptrCast(@alignCast(ctx)); allocator.free(self.name_owned); allocator.free(self.prefix_owned); allocator.destroy(self); } }; const BarrierTool = struct { name_owned: []u8, barrier: *Barrier, const Barrier = struct { target: u32, arrived: std.atomic.Value(u32) = .init(0), thread_ids: [4]std.atomic.Value(u64) = .{ .init(0), .init(0), .init(0), .init(0), }, }; fn create(allocator: Allocator, name: []const u8, barrier: *Barrier) !Tool { const self = try allocator.create(BarrierTool); errdefer allocator.destroy(self); self.name_owned = try allocator.dupe(u8, name); self.barrier = barrier; return .{ .decl = .{ .name = self.name_owned, .description = "barrier", .schema_json = "{}", }, .ctx = self, .vtable = &vt, }; } const vt: Tool.VTable = .{ .invoke = invoke, .deinit = deinit }; fn invoke(ctx: *anyopaque, _: []const u8, allocator: Allocator) anyerror!tool_mod.ResultParts { const self: *BarrierTool = @ptrCast(@alignCast(ctx)); const arrived = self.barrier.arrived.fetchAdd(1, .acq_rel); if (arrived < self.barrier.thread_ids.len) { self.barrier.thread_ids[arrived].store(std.Thread.getCurrentId(), .release); } var i: usize = 0; while (self.barrier.arrived.load(.acquire) < self.barrier.target) : (i += 1) { if (i > 50_000) return error.BarrierTimeout; std.Thread.yield() catch {}; } return tool_mod.ResultParts.fromText(allocator, "done"); } fn deinit(ctx: *anyopaque, allocator: Allocator) void { const self: *BarrierTool = @ptrCast(@alignCast(ctx)); allocator.free(self.name_owned); allocator.destroy(self); } }; const FailingTool = struct { name_owned: []u8, fn create(allocator: Allocator, name: []const u8) !Tool { const self = try allocator.create(FailingTool); errdefer allocator.destroy(self); self.name_owned = try allocator.dupe(u8, name); return .{ .decl = .{ .name = self.name_owned, .description = "fails", .schema_json = "{}", }, .ctx = self, .vtable = &vt, }; } const vt: Tool.VTable = .{ .invoke = invoke, .deinit = deinit }; fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror!tool_mod.ResultParts { return error.ToolExploded; } fn deinit(ctx: *anyopaque, allocator: Allocator) void { const self: *FailingTool = @ptrCast(@alignCast(ctx)); allocator.free(self.name_owned); allocator.destroy(self); } }; /// A tool that returns a hard host failure (`error.Canceled`), which must /// abort the whole turn rather than degrade into a tool result. const HardFailTool = struct { name_owned: []u8, fn create(allocator: Allocator, name: []const u8) !Tool { const self = try allocator.create(HardFailTool); errdefer allocator.destroy(self); self.name_owned = try allocator.dupe(u8, name); return .{ .decl = .{ .name = self.name_owned, .description = "hard fail", .schema_json = "{}" }, .ctx = self, .vtable = &vt, }; } const vt: Tool.VTable = .{ .invoke = invoke, .deinit = deinit }; fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror!tool_mod.ResultParts { return error.Canceled; } fn deinit(ctx: *anyopaque, allocator: Allocator) void { const self: *HardFailTool = @ptrCast(@alignCast(ctx)); allocator.free(self.name_owned); allocator.destroy(self); } }; /// An in-memory `SessionStore` test double: records every appended /// `StoredMessage` (role + provider/model stamp) so tests can assert the /// agent persisted the right turn without touching disk. Honors the store /// ownership contract by freeing each consumed message after recording its /// salient fields. const CapturingStore = struct { allocator: Allocator, roles: std.ArrayList(conversation.MessageRole) = .empty, base_urls: std.ArrayList([]const u8) = .empty, fn init(allocator: Allocator) CapturingStore { return .{ .allocator = allocator }; } fn deinit(self: *CapturingStore) void { for (self.base_urls.items) |s| self.allocator.free(s); self.base_urls.deinit(self.allocator); self.roles.deinit(self.allocator); } fn createVT(ctx: *anyopaque) session_store_mod.Session { const self: *CapturingStore = @ptrCast(@alignCast(ctx)); const a = self.allocator; const info: session_store_mod.SessionInfo = .{ .id = a.dupe(u8, "cap") catch "cap", .created = a.dupe(u8, "") catch "", .modified = a.dupe(u8, "") catch "", .message_count = 0, .last_user_message = a.dupe(u8, "") catch "", .api_style = .openai_chat, .base_url = a.dupe(u8, "") catch "", .model = a.dupe(u8, "") catch "", .reasoning = .default, }; return .{ .info = info, .store = self.store() }; } fn listVT(ctx: *anyopaque) anyerror![]session_store_mod.SessionInfo { const self: *CapturingStore = @ptrCast(@alignCast(ctx)); return self.allocator.alloc(session_store_mod.SessionInfo, 0); } fn freeSessionInfosVT(ctx: *anyopaque, infos: []session_store_mod.SessionInfo) void { const self: *CapturingStore = @ptrCast(@alignCast(ctx)); for (infos) |i| i.deinit(self.allocator); self.allocator.free(infos); } fn resolveVT(_: *anyopaque, _: []const u8) anyerror!?session_store_mod.Session { return null; } fn latestVT(_: *anyopaque) anyerror!?session_store_mod.Session { return null; } fn loadVT(_: *anyopaque, _: []const u8) anyerror!?conversation.Conversation { return null; } fn appendMessagesVT( ctx: *anyopaque, _: []const u8, messages: []session_store_mod.PersistentMessage, ) anyerror!void { const self: *CapturingStore = @ptrCast(@alignCast(ctx)); for (messages) |m| { try self.roles.append(self.allocator, m.message.role); try self.base_urls.append(self.allocator, try self.allocator.dupe(u8, m.identity.base_url)); } } const vtable: session_store_mod.SessionStore.VTable = .{ .create = createVT, .list = listVT, .freeSessionInfos = freeSessionInfosVT, .resolve = resolveVT, .latest = latestVT, .load = loadVT, .appendMessages = appendMessagesVT, }; fn store(self: *CapturingStore) session_store_mod.SessionStore { return .{ .ptr = self, .vtable = &vtable }; } }; test "agent persists user, assistant, and tool-result messages of a turn" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{ .{ .ToolUse = .{ .id = "tc_1", .name = "echo", .input = "hello" } }, } }, .{ .blocks = &.{ .{ .Text = "ok" }, } }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); try h.registry.register(try EchoTool.create(allocator, "echo", "ECHO:")); h.activate(); var cap = CapturingStore.init(allocator); defer cap.deinit(); const agent = try Agent.init(allocator, io, &h.config, cap.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); try drainTurn(agent, "call a tool"); // Persisted, in order: user prompt, assistant(ToolUse), user(ToolResult), // assistant(text). try testing.expectEqual(@as(usize, 4), cap.roles.items.len); try testing.expectEqual(conversation.MessageRole.user, cap.roles.items[0]); try testing.expectEqual(conversation.MessageRole.assistant, cap.roles.items[1]); try testing.expectEqual(conversation.MessageRole.user, cap.roles.items[2]); try testing.expectEqual(conversation.MessageRole.assistant, cap.roles.items[3]); // The wire identity (base_url from the active config) rode through on // every entry. for (cap.base_urls.items) |b| { try testing.expectEqualStrings("u", b); } } test "agent runs a turn against NullStore without persisting or erroring" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "hi" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); try drainTurn(agent, "hello"); // Nothing crashed; the conversation has the user + assistant messages. try testing.expectEqual(@as(usize, 2), agent.conversation.messages.items.len); } /// A configurable ToolSource for testing the grouped-dispatch path. /// Stores every batch it receives so tests can assert "calls X and Y /// arrived in the same batch on the same thread". const TestSource = struct { name_owned: []u8, decls: []tool_source_mod.ToolDecl, decl_strings: std.array_list.Managed([]u8), /// Sequence of (thread_id, [tool_name; n]) per batch received. /// Only mutated inside `invoke_batch`. Because libpanto guarantees /// at most one outstanding `invoke_batch` per source at any time /// (one batch per turn per source), no synchronization is needed. batches: std.array_list.Managed(Batch), allocator: Allocator, const Batch = struct { thread_id: u64, names: std.array_list.Managed([]u8), }; fn create( allocator: Allocator, source_name: []const u8, tool_names: []const []const u8, ) !ToolSource { const self = try allocator.create(TestSource); errdefer allocator.destroy(self); var strings = std.array_list.Managed([]u8).init(allocator); errdefer { for (strings.items) |s| allocator.free(s); strings.deinit(); } const name_owned = try allocator.dupe(u8, source_name); try strings.append(name_owned); const decls = try allocator.alloc(tool_source_mod.ToolDecl, tool_names.len); errdefer allocator.free(decls); for (tool_names, 0..) |tn, i| { const n = try allocator.dupe(u8, tn); try strings.append(n); const d = try allocator.dupe(u8, "test src tool"); try strings.append(d); const s = try allocator.dupe(u8, "{}"); try strings.append(s); decls[i] = .{ .name = n, .description = d, .schema_json = s }; } self.* = .{ .name_owned = name_owned, .decls = decls, .decl_strings = strings, .batches = std.array_list.Managed(Batch).init(allocator), .allocator = allocator, }; return ToolSource{ .name = self.name_owned, .tools = self.decls, .ctx = self, .vtable = &vt, }; } const vt: ToolSource.VTable = .{ .invoke_batch = invokeBatch, .deinit = deinitSrc, }; fn invokeBatch( ctx: *anyopaque, calls: []const tool_source_mod.Call, results: []tool_source_mod.CallResult, allocator: Allocator, ) anyerror!void { const self: *TestSource = @ptrCast(@alignCast(ctx)); var batch: Batch = .{ .thread_id = std.Thread.getCurrentId(), .names = std.array_list.Managed([]u8).init(self.allocator), }; for (calls) |c| { const copy = try self.allocator.dupe(u8, c.tool_name); try batch.names.append(copy); } try self.batches.append(batch); for (calls, 0..) |c, i| { const msg = std.fmt.allocPrint( allocator, "{s}->{s}", .{ c.tool_name, c.input }, ) catch |e| { results[i] = .{ .err = e }; continue; }; results[i] = .{ .ok = tool_mod.ResultParts.fromTextOwned(allocator, msg) catch |e| { results[i] = .{ .err = e }; continue; }, }; } } fn deinitSrc(ctx: *anyopaque, _: Allocator) void { const self: *TestSource = @ptrCast(@alignCast(ctx)); for (self.decl_strings.items) |s| self.allocator.free(s); self.decl_strings.deinit(); for (self.batches.items) |*b| { for (b.names.items) |n| self.allocator.free(n); b.names.deinit(); } self.batches.deinit(); self.allocator.free(self.decls); self.allocator.destroy(self); } }; /// A source that always fails the whole batch by returning an error /// from invoke_batch (rather than recording per-call errors). Used to /// verify libpanto's whole-batch-failure path. const FailingSource = struct { name_owned: []u8, decls: []tool_source_mod.ToolDecl, decl_strings: std.array_list.Managed([]u8), allocator: Allocator, fn create(allocator: Allocator, source_name: []const u8, tool_names: []const []const u8) !ToolSource { const self = try allocator.create(FailingSource); errdefer allocator.destroy(self); var strings = std.array_list.Managed([]u8).init(allocator); errdefer { for (strings.items) |s| allocator.free(s); strings.deinit(); } const name_owned = try allocator.dupe(u8, source_name); try strings.append(name_owned); const decls = try allocator.alloc(tool_source_mod.ToolDecl, tool_names.len); errdefer allocator.free(decls); for (tool_names, 0..) |tn, i| { const n = try allocator.dupe(u8, tn); try strings.append(n); const d = try allocator.dupe(u8, "fails"); try strings.append(d); const s = try allocator.dupe(u8, "{}"); try strings.append(s); decls[i] = .{ .name = n, .description = d, .schema_json = s }; } self.* = .{ .name_owned = name_owned, .decls = decls, .decl_strings = strings, .allocator = allocator, }; return ToolSource{ .name = self.name_owned, .tools = self.decls, .ctx = self, .vtable = &vt }; } const vt: ToolSource.VTable = .{ .invoke_batch = invokeBatch, .deinit = deinitSrc }; fn invokeBatch( _: *anyopaque, _: []const tool_source_mod.Call, _: []tool_source_mod.CallResult, _: Allocator, ) anyerror!void { return error.SourceExploded; } fn deinitSrc(ctx: *anyopaque, _: Allocator) void { const self: *FailingSource = @ptrCast(@alignCast(ctx)); for (self.decl_strings.items) |s| self.allocator.free(s); self.decl_strings.deinit(); self.allocator.free(self.decls); self.allocator.destroy(self); } }; /// A source that succeeds the first member call and fails the rest with a /// per-call error (returning void from `invoke_batch`). Exercises the /// per-call error path distinct from a whole-batch failure. const PartialSource = struct { name_owned: []u8, decls: []tool_source_mod.ToolDecl, decl_strings: std.array_list.Managed([]u8), allocator: Allocator, fn create(allocator: Allocator, source_name: []const u8, tool_names: []const []const u8) !ToolSource { const self = try allocator.create(PartialSource); errdefer allocator.destroy(self); var strings = std.array_list.Managed([]u8).init(allocator); errdefer { for (strings.items) |s| allocator.free(s); strings.deinit(); } const name_owned = try allocator.dupe(u8, source_name); try strings.append(name_owned); const decls = try allocator.alloc(tool_source_mod.ToolDecl, tool_names.len); errdefer allocator.free(decls); for (tool_names, 0..) |tn, i| { const n = try allocator.dupe(u8, tn); try strings.append(n); const d = try allocator.dupe(u8, "partial"); try strings.append(d); const s = try allocator.dupe(u8, "{}"); try strings.append(s); decls[i] = .{ .name = n, .description = d, .schema_json = s }; } self.* = .{ .name_owned = name_owned, .decls = decls, .decl_strings = strings, .allocator = allocator }; return ToolSource{ .name = self.name_owned, .tools = self.decls, .ctx = self, .vtable = &vt }; } const vt: ToolSource.VTable = .{ .invoke_batch = invokeBatch, .deinit = deinitSrc }; fn invokeBatch( _: *anyopaque, calls: []const tool_source_mod.Call, results: []tool_source_mod.CallResult, allocator: Allocator, ) anyerror!void { for (calls, 0..) |_, j| { if (j == 0) { results[j] = .{ .ok = try tool_mod.ResultParts.fromText(allocator, "ok") }; } else { results[j] = .{ .err = error.PerCallBoom }; } } } fn deinitSrc(ctx: *anyopaque, _: Allocator) void { const self: *PartialSource = @ptrCast(@alignCast(ctx)); for (self.decl_strings.items) |s| self.allocator.free(s); self.decl_strings.deinit(); self.allocator.free(self.decls); self.allocator.destroy(self); } }; test "registry register and lookup" { var h = TestHarness.init(testing.allocator); defer h.deinit(); try h.registry.register(try EchoTool.create(testing.allocator, "echo", "ECHO:")); try testing.expectEqual(@as(usize, 1), h.registry.count()); try testing.expect(h.registry.lookup("echo") != null); } test "duplicate register returns error" { var h = TestHarness.init(testing.allocator); defer h.deinit(); try h.registry.register(try EchoTool.create(testing.allocator, "echo", "A:")); var dup = try EchoTool.create(testing.allocator, "echo", "B:"); try testing.expectError(error.DuplicateTool, h.registry.register(dup)); dup.vtable.deinit(dup.ctx, testing.allocator); } test "runStep dispatches a tool call and loops to a final text turn" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{ .{ .ToolUse = .{ .id = "tc_1", .name = "echo", .input = "hello" } }, } }, .{ .blocks = &.{ .{ .Text = "ok" }, } }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); try h.registry.register(try EchoTool.create(allocator, "echo", "ECHO:")); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try drainTurn(agent, "call a tool"); try testing.expectEqual(@as(usize, 4), conv.messages.items.len); try testing.expectEqual(conversation.MessageRole.assistant, conv.messages.items[1].role); try testing.expectEqual(@as(usize, 1), conv.messages.items[1].content.items.len); try testing.expectEqualStrings("tc_1", conv.messages.items[1].content.items[0].ToolUse.id); try testing.expectEqual(conversation.MessageRole.user, conv.messages.items[2].role); try testing.expectEqual(@as(usize, 1), conv.messages.items[2].content.items.len); const tr = conv.messages.items[2].content.items[0].ToolResult; try testing.expectEqualStrings("tc_1", tr.tool_use_id); try testing.expectEqualStrings("ECHO:hello", trText(tr)); try testing.expectEqual(conversation.MessageRole.assistant, conv.messages.items[3].role); try testing.expectEqualStrings("ok", conv.messages.items[3].content.items[0].Text.items); } test "runStep dispatches multiple tool calls in parallel" { const allocator = testing.allocator; var barrier: BarrierTool.Barrier = .{ .target = 3 }; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{ .{ .ToolUse = .{ .id = "a", .name = "barrierA", .input = "" } }, .{ .ToolUse = .{ .id = "b", .name = "barrierB", .input = "" } }, .{ .ToolUse = .{ .id = "c", .name = "barrierC", .input = "" } }, } }, .{ .blocks = &.{ .{ .Text = "done" }, } }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); try h.registry.register(try BarrierTool.create(allocator, "barrierA", &barrier)); try h.registry.register(try BarrierTool.create(allocator, "barrierB", &barrier)); try h.registry.register(try BarrierTool.create(allocator, "barrierC", &barrier)); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try drainTurn(agent, "go"); const tr_msg = conv.messages.items[2]; try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len); try testing.expectEqualStrings("a", tr_msg.content.items[0].ToolResult.tool_use_id); try testing.expectEqualStrings("b", tr_msg.content.items[1].ToolResult.tool_use_id); try testing.expectEqualStrings("c", tr_msg.content.items[2].ToolResult.tool_use_id); const t0 = barrier.thread_ids[0].load(.acquire); const t1 = barrier.thread_ids[1].load(.acquire); const t2 = barrier.thread_ids[2].load(.acquire); try testing.expect(t0 != 0 and t1 != 0 and t2 != 0); try testing.expect(t0 != t1 and t1 != t2 and t0 != t2); } test "runStep: native tool handler error becomes an error result and the model gets another turn" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{ .{ .ToolUse = .{ .id = "x", .name = "boom", .input = "" } }, } }, .{ .blocks = &.{.{ .Text = "i will recover" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); try h.registry.register(try FailingTool.create(allocator, "boom")); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try drainTurn(agent, "break it"); // user, assistant(tool_use), user(tool_result), assistant(text) try testing.expectEqual(@as(usize, 4), conv.messages.items.len); const tr = conv.messages.items[2].content.items[0].ToolResult; try testing.expectEqualStrings("x", tr.tool_use_id); try testing.expect(tr.is_error); try testing.expect(std.mem.indexOf(u8, trText(tr), "ToolExploded") != null); } test "runStep: unknown tool becomes an error tool result and the loop continues" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{ .{ .ToolUse = .{ .id = "z", .name = "ghost", .input = "" } }, } }, .{ .blocks = &.{.{ .Text = "ok, that tool does not exist" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try drainTurn(agent, "call a ghost"); // messages: user, assistant(tool_use), user(tool_result), assistant(text) try testing.expectEqual(@as(usize, 4), conv.messages.items.len); const tr_msg = conv.messages.items[2]; try testing.expectEqual(conversation.MessageRole.user, tr_msg.role); const tr = tr_msg.content.items[0].ToolResult; try testing.expectEqualStrings("z", tr.tool_use_id); try testing.expect(tr.is_error); try testing.expect(std.mem.indexOf(u8, trText(tr), "UnknownTool") != null); } test "runStep with no tool calls returns after one provider step" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "hi" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try drainTurn(agent, "hello"); try testing.expectEqual(@as(usize, 2), conv.messages.items.len); try testing.expectEqualStrings("hi", conv.messages.items[1].content.items[0].Text.items); } test "runStep surfaces EmptyAssistantResponse when provider commits an empty message" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); try testing.expectError(error.EmptyAssistantResponse, drainTurn(agent, "hi")); } // ------------ ToolSource tests ------------ test "runStep delivers all source-backed calls in one batch on one thread" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{ .{ .ToolUse = .{ .id = "a", .name = "lua_x", .input = "1" } }, .{ .ToolUse = .{ .id = "b", .name = "lua_y", .input = "2" } }, .{ .ToolUse = .{ .id = "c", .name = "lua_x", .input = "3" } }, } }, .{ .blocks = &.{.{ .Text = "done" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); try h.registry.registerSource(try TestSource.create(allocator, "panto-lua", &.{ "lua_x", "lua_y" })); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try drainTurn(agent, "go"); // Locate the source and inspect its observed batches. const view = agent._registry.lookup("lua_x") orelse return error.NotFound; const src_ptr = view.entry.source.source; const test_src: *TestSource = @ptrCast(@alignCast(src_ptr.ctx)); try testing.expectEqual(@as(usize, 1), test_src.batches.items.len); const b = test_src.batches.items[0]; try testing.expectEqual(@as(usize, 3), b.names.items.len); try testing.expectEqualStrings("lua_x", b.names.items[0]); try testing.expectEqualStrings("lua_y", b.names.items[1]); try testing.expectEqualStrings("lua_x", b.names.items[2]); // ToolResults arrived in the original call order. const tr_msg = conv.messages.items[2]; try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len); try testing.expectEqualStrings("a", tr_msg.content.items[0].ToolResult.tool_use_id); try testing.expectEqualStrings("lua_x->1", trText(tr_msg.content.items[0].ToolResult)); try testing.expectEqualStrings("b", tr_msg.content.items[1].ToolResult.tool_use_id); try testing.expectEqualStrings("lua_y->2", trText(tr_msg.content.items[1].ToolResult)); try testing.expectEqualStrings("c", tr_msg.content.items[2].ToolResult.tool_use_id); try testing.expectEqualStrings("lua_x->3", trText(tr_msg.content.items[2].ToolResult)); } test "runStep: distinct sources run on distinct threads in parallel" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{ .{ .ToolUse = .{ .id = "a", .name = "src_a_t", .input = "" } }, .{ .ToolUse = .{ .id = "b", .name = "src_b_t", .input = "" } }, } }, .{ .blocks = &.{.{ .Text = "done" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); try h.registry.registerSource(try TestSource.create(allocator, "src_a", &.{"src_a_t"})); try h.registry.registerSource(try TestSource.create(allocator, "src_b", &.{"src_b_t"})); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); try drainTurn(agent, "go"); const view_a = agent._registry.lookup("src_a_t") orelse return error.NotFound; const view_b = agent._registry.lookup("src_b_t") orelse return error.NotFound; const sa: *TestSource = @ptrCast(@alignCast(view_a.entry.source.source.ctx)); const sb: *TestSource = @ptrCast(@alignCast(view_b.entry.source.source.ctx)); try testing.expectEqual(@as(usize, 1), sa.batches.items.len); try testing.expectEqual(@as(usize, 1), sb.batches.items.len); // The two sources ran on distinct OS threads. try testing.expect(sa.batches.items[0].thread_id != sb.batches.items[0].thread_id); } test "runStep: source whole-batch error becomes per-call error results and continues" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{ .{ .ToolUse = .{ .id = "a", .name = "fa", .input = "" } }, .{ .ToolUse = .{ .id = "b", .name = "fb", .input = "" } }, } }, .{ .blocks = &.{.{ .Text = "recovered" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); try h.registry.registerSource(try FailingSource.create(allocator, "fs", &.{ "fa", "fb" })); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try drainTurn(agent, "kaboom"); // user, assistant(tool_use x2), user(tool_result x2), assistant(text) try testing.expectEqual(@as(usize, 4), conv.messages.items.len); const tr_msg = conv.messages.items[2]; try testing.expectEqual(@as(usize, 2), tr_msg.content.items.len); // Every member of the failed batch produced an error result, in order. const tr_a = tr_msg.content.items[0].ToolResult; const tr_b = tr_msg.content.items[1].ToolResult; try testing.expectEqualStrings("a", tr_a.tool_use_id); try testing.expectEqualStrings("b", tr_b.tool_use_id); try testing.expect(tr_a.is_error); try testing.expect(tr_b.is_error); try testing.expect(std.mem.indexOf(u8, trText(tr_a), "SourceExploded") != null); } test "runStep: mixed single Tools and source-backed tools coexist in one turn" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{ .{ .ToolUse = .{ .id = "a", .name = "single", .input = "X" } }, .{ .ToolUse = .{ .id = "b", .name = "src_t1", .input = "Y" } }, .{ .ToolUse = .{ .id = "c", .name = "src_t2", .input = "Z" } }, } }, .{ .blocks = &.{.{ .Text = "done" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); try h.registry.register(try EchoTool.create(allocator, "single", "S:")); try h.registry.registerSource(try TestSource.create(allocator, "src", &.{ "src_t1", "src_t2" })); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try drainTurn(agent, "go"); const tr_msg = conv.messages.items[2]; try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len); try testing.expectEqualStrings("S:X", trText(tr_msg.content.items[0].ToolResult)); try testing.expectEqualStrings("src_t1->Y", trText(tr_msg.content.items[1].ToolResult)); try testing.expectEqualStrings("src_t2->Z", trText(tr_msg.content.items[2].ToolResult)); } test "setConfig swaps provider between turns; agent tool set persists" { // Post-R1 the tool set lives on the `Agent`, not on `Config`. Swapping // the config pointer (`setConfig`) changes provider/model at the next // turn boundary but leaves the agent's registered tools intact: a turn // after the swap still resolves a tool registered before it. const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .ToolUse = .{ .id = "2", .name = "late", .input = "B" } }} }, .{ .blocks = &.{.{ .Text = "done" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); const cfg_a: config_mod.Config = .{ .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "a", .model = "m" } }, }; const cfg_b: config_mod.Config = .{ .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "b", .model = "m" } }, }; var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &cfg_a, ns.store().create(), null); defer agent.deinit(); try agent.registerTool(try EchoTool.create(allocator, "late", "B:")); agent._open_stream_fn = stub.install(); // The tool is visible regardless of which config is active. try testing.expect(agent._registry.lookup("late") != null); agent.setConfig(&cfg_b); try testing.expect(agent._registry.lookup("late") != null); // A real turn after the swap still resolves `late`, then loops to the // final text turn. const conv = &agent.conversation; try drainTurn(agent, "go"); const tr = conv.messages.items[2].content.items[0].ToolResult; try testing.expectEqualStrings("2", tr.tool_use_id); try testing.expectEqualStrings("B:B", trText(tr)); } test "compact: summarizes prefix, keeps suffix, system survives" { const allocator = testing.allocator; // The stub returns a single text turn — used as the summary text. const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "SUMMARY OF EARLIER" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); // keep_verbatim sized so only the last (short) turn fits: q2+a2 are // 3 words each => ceil(3*1.3)=4 tokens each => 8 total <= 10, while // adding the longer first turn exceeds it. h.config.compaction = .{ .keep_verbatim = 10 }; var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try conv.addSystemMessage("you are helpful"); try addUserText(conv, "first question here with several words"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with several words") }, }, null); try addUserText(conv, "second recent question"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "second recent answer") }, }, null); const res = try agent._compactInPlace("Summarize the conversation.", null); try testing.expect(res.compacted); // Expected rebuilt: [system, compaction summary(user), user q2, asst a2] try testing.expectEqual(@as(usize, 4), conv.messages.items.len); try testing.expectEqual(conversation.MessageRole.system, conv.messages.items[0].role); try testing.expectEqualStrings( "you are helpful", conv.messages.items[0].content.items[0].System.text.items, ); try testing.expectEqual(conversation.MessageRole.user, conv.messages.items[1].role); try testing.expectEqualStrings( "SUMMARY OF EARLIER", conv.messages.items[1].content.items[0].CompactionSummary.text.items, ); try testing.expectEqualStrings( "second recent question", conv.messages.items[2].content.items[0].Text.items, ); try testing.expectEqualStrings( "second recent answer", conv.messages.items[3].content.items[0].Text.items, ); } test "compact: restated suffix usage reconstructs a fresh cumulative chain" { const allocator = testing.allocator; // The compaction turn reports a known output token count (the summary // size used as the new anchor). const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "SUMMARY" }}, .usage = .{ .input = 9999, .output = 100 }, // input is ignored; only output anchors }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); // Budget keeps the last two turns verbatim (deltas 8 + 6 = 14) but // summarizes the prefix. h.config.compaction = .{ .keep_verbatim = 20 }; var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; // Prefix turn (will be summarized). Cumulative footprint = 500+40+10+50 // = 600. Its real usage (with cache buckets) is irrelevant // post-compaction. try addUserText(conv, "first question here with several words"); try conv.addAssistantMessage( &.{.{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with words") }}, .{ .input = 500, .output = 50, .cache_read = 40, .cache_write = 10 }, ); // Kept turn 1: cumulative 608 (delta 8 over prefix). Reasoning is a // subset of output. try addUserText(conv, "kept question"); // 2 words => ceil(2*1.3)=3 tokens try conv.addAssistantMessage( &.{.{ .Text = try conversation.textualBlockFromSlice(allocator, "kept answer") }}, .{ .input = 600, .output = 8, .reasoning = 5 }, ); // Kept turn 2: cumulative 614 (delta 6). Cache buckets present to prove // they collapse into `input` on restatement. try addUserText(conv, "two more words here"); // 4 words => ceil(4*1.3)=6 try conv.addAssistantMessage( &.{.{ .Text = try conversation.textualBlockFromSlice(allocator, "final answer") }}, .{ .input = 600, .output = 4, .cache_read = 8, .cache_write = 2 }, ); const res = try agent._compactInPlace("Summarize.", null); try testing.expect(res.compacted); // Rebuilt: [summary(user), u1, a1, u2, a2]. try testing.expectEqual(@as(usize, 5), conv.messages.items.len); const asst1 = conv.messages.items[2]; const asst2 = conv.messages.items[4]; try testing.expect(conv.messages.items[1].usage == null); // kept user // First restated assistant: input = summary_size(100) + // user_size("kept question" => 3) = 103. Cache zeroed; output/reasoning // verbatim. const us1 = asst1.usage.?; try testing.expectEqual(@as(u64, 103), us1.input); try testing.expectEqual(@as(u64, 0), us1.cache_read); try testing.expectEqual(@as(u64, 0), us1.cache_write); try testing.expectEqual(@as(u64, 8), us1.output); try testing.expectEqual(@as(u64, 5), us1.reasoning); // Second restated assistant: input = prev.input(103) + prev.output(8) + // user_size("two more words here" => 6) = 117. Original cache buckets // (8+2) collapse away; only the synthetic cumulative total survives in // `input`. const us2 = asst2.usage.?; try testing.expectEqual(@as(u64, 117), us2.input); try testing.expectEqual(@as(u64, 0), us2.cache_read); try testing.expectEqual(@as(u64, 0), us2.cache_write); try testing.expectEqual(@as(u64, 4), us2.output); } test "compact: no-op when conversation already fits the budget" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "should not be used" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); h.config.compaction = .{ .keep_verbatim = 1_000_000 }; var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try conv.addSystemMessage("sys"); try addUserText(conv, "hi"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "hello") }, }, null); const res = try agent._compactInPlace("Summarize.", null); try testing.expect(!res.compacted); try testing.expectEqual(@as(usize, 3), conv.messages.items.len); // Stub was never consumed. try testing.expectEqual(@as(usize, 0), stub.next); } test "compact: extra instructions are appended to the system prompt" { const allocator = testing.allocator; // Capture the system prompt the stub sees by scripting a turn and // inspecting the throwaway conversation isn't directly possible via the // current stub; instead we just assert compaction succeeds with extra // instructions present (smoke test of the append path). const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "S" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); h.config.compaction = .{ .keep_verbatim = 1 }; var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try addUserText(conv, "question one two three"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer one two three") }, }, null); try addUserText(conv, "question two"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer two") }, }, null); const res = try agent._compactInPlace("Base prompt.", "keep bug #3 details"); try testing.expect(res.compacted); } test "runStep: auto-compacts on context overflow and retries once" { const allocator = testing.allocator; // First stream call overflows; then the compaction request returns a // summary; then the retried main request returns a final text turn. const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "COMPACTED SUMMARY" }} }, // compaction call .{ .blocks = &.{.{ .Text = "final answer" }} }, // retried main call }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted, .overflow_calls = 1, }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); h.config.compaction = .{ .keep_verbatim = 10, .compaction_prompt = "Summarize the conversation." }; var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try conv.addSystemMessage("you are helpful"); try addUserText(conv, "first question with several words here"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with several words") }, }, null); try drainTurn(agent, "second recent question"); try testing.expect(agent._auto_compacted); // After compaction + retry: [system, summary, user q2, assistant final]. const msgs = conv.messages.items; try testing.expectEqual(conversation.MessageRole.system, msgs[0].role); try testing.expectEqualStrings( "COMPACTED SUMMARY", msgs[1].content.items[0].CompactionSummary.text.items, ); try testing.expectEqualStrings("second recent question", msgs[2].content.items[0].Text.items); try testing.expectEqualStrings("final answer", msgs[msgs.len - 1].content.items[0].Text.items); } test "runStep: context overflow without compaction prompt propagates" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "unused" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted, .overflow_calls = 1, }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); // No compaction_system_prompt set -> overflow propagates. try testing.expectError(error.ContextOverflow, drainTurn(agent, "hi")); } // ----------------------------------------------------------------------------- // Phase 6: provider retry + tool-error holistic tests // ----------------------------------------------------------------------------- /// Records the `provider_retry` events a turn emits, so tests can assert /// retry scheduling without a live provider. const RetryRecorder = struct { infos: std.ArrayList(provider_mod.ProviderRetryInfo) = .empty, allocator: Allocator, fn deinit(self: *RetryRecorder) void { self.infos.deinit(self.allocator); } }; /// Drive a whole turn via the pull `Stream`, recording every /// `provider_retry` event into `rr` and discarding the rest. Returns the /// same terminal error the turn would raise. fn drainTurnRecording(agent: *Agent, text: []const u8, rr: *RetryRecorder) !void { var s = try agent.run(.{ .text = text }); defer s.deinit(); while (try s.next()) |ev| { if (ev == .provider_retry) try rr.infos.append(rr.allocator, ev.provider_retry); } } /// Build an agent + harness with near-zero backoff so retry tests don't /// actually sleep. Caller owns the harness and must keep it alive. fn fastRetryHarness(h: *TestHarness) void { h.activate(); // Make sleeps negligible and deterministic (no jitter). h.config.retry = .{ .max_attempts = 4, .initial_delay_ms = 0, .max_delay_ms = 0, .multiplier = 2.0, .jitter = false, }; } test "runStep: provider 429 retries then succeeds without duplicate messages" { const allocator = testing.allocator; const errs = [_]StubProvider.ScriptedError{ .{ .err = error.ProviderRateLimited, .status_code = 429 }, .{ .err = error.ProviderRateLimited, .status_code = 429 }, }; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "finally" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted, .scripted_errors = &errs, }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); fastRetryHarness(&h); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; var rr = RetryRecorder{ .allocator = allocator }; defer rr.deinit(); try drainTurnRecording(agent, "hi", &rr); // Two failures + one success. try testing.expectEqual(@as(usize, 3), stub.calls_made); // No duplicate assistant messages: user + single assistant. try testing.expectEqual(@as(usize, 2), conv.messages.items.len); try testing.expectEqual(conversation.MessageRole.assistant, conv.messages.items[1].role); // Two retry notifications, delivered before each delayed retry. try testing.expectEqual(@as(usize, 2), rr.infos.items.len); try testing.expectEqual(@as(?u16, 429), rr.infos.items[0].status_code); try testing.expectEqual(@as(usize, 1), rr.infos.items[0].attempt); try testing.expectEqual(@as(usize, 2), rr.infos.items[1].attempt); } test "runStep: provider 500 retries with backoff notification" { const allocator = testing.allocator; const errs = [_]StubProvider.ScriptedError{ .{ .err = error.ProviderServerError, .status_code = 500 }, }; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "ok" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted, .scripted_errors = &errs, }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); fastRetryHarness(&h); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); var rr = RetryRecorder{ .allocator = allocator }; defer rr.deinit(); try drainTurnRecording(agent, "hi", &rr); try testing.expectEqual(@as(usize, 2), stub.calls_made); try testing.expectEqual(@as(usize, 1), rr.infos.items.len); try testing.expectEqual(error.ProviderServerError, rr.infos.items[0].err); try testing.expectEqual(@as(usize, 4), rr.infos.items[0].max_attempts); try testing.expect(!rr.infos.items[0].compaction); } test "runStep: provider auth failure does not retry" { const allocator = testing.allocator; const errs = [_]StubProvider.ScriptedError{ .{ .err = error.ProviderAuthFailed, .status_code = 401 }, }; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "unreachable" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted, .scripted_errors = &errs, }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); fastRetryHarness(&h); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); var rr = RetryRecorder{ .allocator = allocator }; defer rr.deinit(); try testing.expectError(error.ProviderAuthFailed, drainTurnRecording(agent, "hi", &rr)); // Exactly one attempt, no retry notification. try testing.expectEqual(@as(usize, 1), stub.calls_made); try testing.expectEqual(@as(usize, 0), rr.infos.items.len); } test "runStep: retries exhaust and hard-fail after max_attempts" { const allocator = testing.allocator; const errs = [_]StubProvider.ScriptedError{ .{ .err = error.ProviderUnavailable, .status_code = 503 }, .{ .err = error.ProviderUnavailable, .status_code = 503 }, .{ .err = error.ProviderUnavailable, .status_code = 503 }, .{ .err = error.ProviderUnavailable, .status_code = 503 }, }; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "unreachable" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted, .scripted_errors = &errs, }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); fastRetryHarness(&h); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); var rr = RetryRecorder{ .allocator = allocator }; defer rr.deinit(); try testing.expectError(error.ProviderUnavailable, drainTurnRecording(agent, "hi", &rr)); // 4 attempts total (max_attempts), 3 retry notifications. try testing.expectEqual(@as(usize, 4), stub.calls_made); try testing.expectEqual(@as(usize, 3), rr.infos.items.len); } test "runStep: Retry-After is honored and reported" { const allocator = testing.allocator; const errs = [_]StubProvider.ScriptedError{ .{ .err = error.ProviderRateLimited, .status_code = 429, .retry_after_ms = 7000 }, }; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "ok" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted, .scripted_errors = &errs, }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); // Cap below the Retry-After to verify the policy cap applies. h.config.retry = .{ .initial_delay_ms = 0, .max_delay_ms = 1, .jitter = false }; var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); var rr = RetryRecorder{ .allocator = allocator }; defer rr.deinit(); try drainTurnRecording(agent, "hi", &rr); try testing.expectEqual(@as(usize, 1), rr.infos.items.len); // Reported Retry-After is the raw provider value... try testing.expectEqual(@as(?u64, 7000), rr.infos.items[0].retry_after_ms); // ...but the actual delay is capped by policy.max_delay_ms. try testing.expectEqual(@as(u64, 1), rr.infos.items[0].delay_ms); } test "runStep: cancellation from a tool still hard-fails" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{ .{ .ToolUse = .{ .id = "x", .name = "hard", .input = "" } }, } }, .{ .blocks = &.{.{ .Text = "unreachable" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); try h.registry.register(try HardFailTool.create(allocator, "hard")); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try testing.expectError(error.Canceled, drainTurn(agent, "go")); // Turn aborts: no tool result appended (user + assistant only). try testing.expectEqual(@as(usize, 2), conv.messages.items.len); } test "runStep: source per-call error produces a per-call error result and continues" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{ .{ .ToolUse = .{ .id = "a", .name = "pa", .input = "" } }, .{ .ToolUse = .{ .id = "b", .name = "pb", .input = "" } }, } }, .{ .blocks = &.{.{ .Text = "moving on" }} }, }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); try h.registry.registerSource(try PartialSource.create(allocator, "ps", &.{ "pa", "pb" })); h.activate(); var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try drainTurn(agent, "go"); try testing.expectEqual(@as(usize, 4), conv.messages.items.len); const tr_msg = conv.messages.items[2]; const tr_a = tr_msg.content.items[0].ToolResult; // first call succeeded const tr_b = tr_msg.content.items[1].ToolResult; // second failed try testing.expect(!tr_a.is_error); try testing.expectEqualStrings("ok", trText(tr_a)); try testing.expect(tr_b.is_error); try testing.expect(std.mem.indexOf(u8, trText(tr_b), "PerCallBoom") != null); } test "runStep: context-overflow compaction fires a compaction retry notification" { const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ .{ .blocks = &.{.{ .Text = "COMPACTED SUMMARY" }} }, // compaction call .{ .blocks = &.{.{ .Text = "final answer" }} }, // retried main call }; var stub = StubProvider{ .allocator = allocator, .scripted = &scripted, .overflow_calls = 1, }; var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); h.config.compaction = .{ .keep_verbatim = 10, .compaction_prompt = "Summarize the conversation." }; var ns = null_store_mod.NullStore.init(allocator); const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; try conv.addSystemMessage("you are helpful"); try addUserText(conv, "first question with several words here"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with several words") }, }, null); var rr = RetryRecorder{ .allocator = allocator }; defer rr.deinit(); try drainTurnRecording(agent, "second recent question", &rr); try testing.expect(agent._auto_compacted); // Exactly one notification, flagged as a compaction retry with no delay. try testing.expectEqual(@as(usize, 1), rr.infos.items.len); try testing.expect(rr.infos.items[0].compaction); try testing.expectEqual(@as(u64, 0), rr.infos.items[0].delay_ms); try testing.expectEqual(error.ContextOverflow, rr.infos.items[0].err); }