diff options
Diffstat (limited to 'libpanto/src/agent.zig')
| -rw-r--r-- | libpanto/src/agent.zig | 3925 |
1 files changed, 0 insertions, 3925 deletions
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig deleted file mode 100644 index 09b0994..0000000 --- a/libpanto/src/agent.zig +++ /dev/null @@ -1,3925 +0,0 @@ -//! 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, - .metadata = if (msg.metadata) |m| try alloc.dupe(u8, m) else null, - // Preserve the producing identity so a kept-verbatim turn isn't - // re-stamped with the compaction model on persist. - .identity = if (msg.identity) |id| try conversation.dupeWireIdentity(alloc, id) else null, - }; -} - -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; - errdefer if (sig) |s| alloc.free(s); - const origin: ?conversation.SignatureOrigin = if (b.signature_origin) |o| try o.dupe(alloc) else null; - break :blk .{ .Thinking = .{ .text = tb, .signature = sig, .signature_origin = origin } }; - }, - .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: an ordered list of content -/// blocks (text, tool results, images, …), the same shape used to rebuild a -/// persisted user message. Ownership of `blocks` transfers to the agent's -/// conversation on `run`; the caller must not deinit them afterwards. A plain -/// chat turn is one `.Text` block; a turn that resumes after the embedder -/// handled tool calls is one or more `.ToolResult` blocks. -pub const UserMessage = struct { - blocks: []const conversation.ContentBlock, -}; - -/// 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, - /// High-water mark of messages durably handed to the store: the count of - /// conversation messages already persisted. `flushPersist` appends only - /// `[_persisted_through .. coherent_end)` and advances this, so the store - /// stays current at every `Stream.next()` boundary without re-appending. - /// Initialized to the loaded history length (already persisted) and - /// rewound by `rewriteWithSummary` so a compaction re-persists its summary - /// + restated suffix. - _persisted_through: usize = 0, - /// User-message blocks queued by an embedder seam (the CLI's - /// `panto.ext.agent:submit`) for the host front end to open the next - /// turn with, so the turn runs on the host's own driver (rendering, - /// interrupts) rather than inside the queuing callback. Blocks and the - /// list storage are owned by `conversation.allocator` — the same - /// ownership `run` adopts. - _pending_submission: std.ArrayList(conversation.ContentBlock) = .empty, - - /// 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, - }; - // Loaded history is already in the store; only messages produced from - // here on need persisting. - self._persisted_through = self.conversation.messages.items.len; - 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(); - for (self._pending_submission.items) |*b| b.deinit(self.conversation.allocator); - self._pending_submission.deinit(self.conversation.allocator); - 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 { - switch (mode) { - .append => try self.conversation.addSystemMessage(text), - .replace => try self.conversation.replaceSystemMessage(text), - } - self.flushPersist(); - } - - /// Replace the text of the ToolResult block matching `tool_use_id` in - /// the conversation's LAST message. Media parts are preserved (the new - /// text becomes the single leading text part); `is_error` is untouched. - /// Returns false when the last message holds no such block. - /// - /// This is the application half of the embedder's "tool_result output - /// override" (a `tool_result` event handler assigning `ev.output`): - /// called between tool dispatch and the next provider request, the - /// override becomes canonical — wire, persistence, and compaction all - /// see the replaced text. - pub fn overrideToolResultOutput(self: *Agent, tool_use_id: []const u8, text: []const u8) !bool { - const msgs = self.conversation.messages.items; - if (msgs.len == 0) return false; - const last = &msgs[msgs.len - 1]; - if (last.role != .user) return false; - for (last.content.items) |*block| { - if (block.* != .ToolResult) continue; - const tr = &block.ToolResult; - if (!std.mem.eql(u8, tr.tool_use_id, tool_use_id)) continue; - - var new_parts: std.ArrayList(conversation.ResultPartStored) = .empty; - errdefer new_parts.deinit(self._allocator); - try new_parts.ensureTotalCapacity(self._allocator, tr.parts.items.len + 1); - var new_text: conversation.TextualBlock = .empty; - try new_text.appendSlice(self._allocator, text); - errdefer new_text.deinit(self._allocator); - new_parts.appendAssumeCapacity(.{ .text = new_text }); - for (tr.parts.items) |*p| switch (p.*) { - .text => |*t| t.deinit(self._allocator), - .media => |m| new_parts.appendAssumeCapacity(.{ .media = m }), - }; - tr.parts.deinit(self._allocator); - tr.parts = new_parts; - return true; - } - return false; - } - - /// Replace the input JSON of the ToolUse block matching `tool_use_id` - /// in the conversation's LAST message (the committed assistant message, - /// between response completion and tool dispatch). Returns false when - /// no such block exists there. - /// - /// Application half of the "tool_call_complete input override": the - /// rewritten input is what the tool executes with AND what the model - /// sees of its own call in future requests. - pub fn overrideToolUseInput(self: *Agent, tool_use_id: []const u8, input_json: []const u8) !bool { - const msgs = self.conversation.messages.items; - if (msgs.len == 0) return false; - const last = &msgs[msgs.len - 1]; - if (last.role != .assistant) return false; - for (last.content.items) |*block| { - if (block.* != .ToolUse) continue; - const tu = &block.ToolUse; - if (!std.mem.eql(u8, tu.id, tool_use_id)) continue; - tu.input.clearRetainingCapacity(); - try tu.input.appendSlice(self._allocator, input_json); - return true; - } - return false; - } - - /// 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. - /// Append blocks to the pending submission (see `_pending_submission`). - /// Adopts the block *contents*; the `blocks` slice itself stays the - /// caller's. Multiple calls accumulate into one pending user message. - pub fn queueSubmission(self: *Agent, blocks: []const conversation.ContentBlock) !void { - try self._pending_submission.appendSlice(self.conversation.allocator, blocks); - } - - /// Take the pending submission, or null when none is queued. The caller - /// owns the returned slice: typically hand it to `run` (which adopts the - /// block contents) and free the slice itself with - /// `conversation.allocator`. - pub fn takeSubmission(self: *Agent) !?[]conversation.ContentBlock { - if (self._pending_submission.items.len == 0) return null; - return try self._pending_submission.toOwnedSlice(self.conversation.allocator); - } - - pub fn run(self: *Agent, message: UserMessage) !*Stream { - self._auto_compacted = false; - - // Append + persist the user prompt up front (the dangling-prompt - // recovery guarantee). `addMessage` adopts the blocks; persistence is - // brought current through the shared high-water flush. - try self.conversation.addMessage(.user, message.blocks, null); - self.flushPersist(); - - const s = try self._allocator.create(Stream); - s.* = Stream.init(self); - return s; - } - - /// Bring the session store current with the conversation: append every - /// message in `[_persisted_through .. coherent_end)` as a single batch and - /// advance the high-water mark. `coherent_end` excludes a trailing - /// assistant message whose ToolUse blocks have no following results — a - /// turn interrupted between the assistant's tool call and dispatch. Leaving - /// it unpersisted keeps the log replayable and the live conversation in a - /// state `compact()` (or a tool-result `run`) can resume from. Called at - /// every `Stream.next()` boundary, so after any `next()` the store reflects - /// all committed, coherent messages. - pub fn flushPersist(self: *Agent) void { - const msgs = self.conversation.messages.items; - // An operation that rewrote the prefix (replace-system, cancel) can - // leave the mark past the new length; clamp before comparing. - if (self._persisted_through > msgs.len) self._persisted_through = msgs.len; - var end = msgs.len; - if (end > self._persisted_through and - msgs[end - 1].role == .assistant and - turn_persist.hasToolUseWithoutFollowingResults(&self.conversation, end - 1)) - { - end -= 1; - } - if (end <= self._persisted_through) return; - turn_persist.persistRange( - self._allocator, - &self._session, - &self.conversation, - self._persisted_through, - end, - self.wireIdentity(), - &.{}, - ) catch |e| { - std.log.err("session: failed to persist turn: {t}", .{e}); - return; - }; - self._persisted_through = end; - } - - fn hasToolUseBlock(msg: conversation.Message) bool { - return toolUseCount(msg) > 0; - } - - /// 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); - // `rewriteWithSummary` rewound the high-water mark to the summary; - // flush appends the new compaction window (summary + restated suffix). - if (res.compacted) self.flushPersist(); - 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; - - // The rewrite invalidated the old message indices. Rewind the - // persistence high-water mark to the inserted summary so the next - // `flushPersist` re-appends the new compaction window (summary + - // restated kept suffix) as fresh entries. - self._persisted_through = - conversation.latestCompactionIndex(conv.messages.items) orelse 0; - } - - /// 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 (the boundary `cancel` rolls back to). - _start: usize, - /// 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, - /// Count of mid-stream retries attempted for the CURRENT response. Unlike - /// `openWithRetries` (which loops internally with a local counter), a - /// mid-stream failure unwinds back to `.turn_start` and re-opens, so the - /// attempt count must live on the stream to survive across re-opens. - /// Reset to 0 each time a response completes. A persistently-broken - /// stream would otherwise re-open forever without ever exhausting - /// `retry.max_attempts`. - _stream_retries: usize = 0, - /// Owned copy of the provider's diagnostic message for the in-flight - /// mid-stream retry notice. Borrowed by the queued `provider_retry` - /// event, so it must outlive that event: freed on the next mid-stream - /// failure and on `deinit`. - _retry_message: ?[]u8 = 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, - /// Tool dispatch has been announced to callers; run the blocking - /// dispatch on the next pull so `tool_dispatch_start` is observable - /// before long-running tools complete. - dispatching_tools, - /// 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. - // `next()` already flushes at every boundary, so this is usually a - // no-op; it catches a stream dropped without a final `next()`. - self._agent.flushPersist(); - if (self._response) |ps| ps.deinit(); - if (self._retry_message) |m| self._agent._allocator.free(m); - self._queue.deinit(); - self._agent._allocator.destroy(self); - } - - /// Abort this turn from the embedder side (for example, Escape in a TUI). - /// Provider/tool work that is currently inside `next()` cannot be unwound - /// until that call returns, but once control reaches the embedder this - /// drops every assistant/tool-result message produced by this stream before - /// `deinit()` has a chance to persist them. The already-persisted user - /// prompt (written by `Agent.run`) is intentionally kept, leaving a valid - /// conversation prefix with no dangling ToolUse blocks. - pub fn cancel(self: *Stream) void { - if (self._response) |ps| { - ps.deinit(); - self._response = null; - } - const conv = &self._agent.conversation; - while (conv.messages.items.len > self._start) { - var msg = conv.messages.pop().?; - msg.deinit(conv.allocator); - } - // Rolled the conversation back; clamp the persistence mark to match. - self._agent.flushPersist(); - self.state = .done; - } - - /// Reset a `.failed` stream back to `.turn_start` so the caller can resume - /// `next()` after changing the agent config (e.g. an embedder that catches - /// a terminal `ProviderBadRequest`, rewrites the provider config, and - /// wants to retry the SAME turn without re-appending the user message). - /// - /// Safe only at the OPEN boundary: a failed open never mutates the - /// conversation, so the already-committed user prompt at `_start` stays - /// intact and the re-open re-reads the (now-swapped) config snapshot. - /// Returns `error.StreamNotFailed` if the stream is not in `.failed`. - pub fn reopen(self: *Stream) !void { - if (self.state != .failed) return error.StreamNotFailed; - if (self._response) |ps| { - ps.deinit(); - self._response = null; - } - self._pending_error = null; - self.state = .turn_start; - } - - /// Pull the next event, or null past the terminal. See the contract - /// above. - pub fn next(self: *Stream) !?Event { - // Bring persistence current on every return path (events, terminal, - // errors). This is the interruptibility guarantee: after any `next()` - // the store reflects all committed, coherent messages, so the caller - // can `break` and immediately `compact()` or resume with a new turn. - defer self._agent.flushPersist(); - // 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. - // - // Capture the provider's diagnostic message (e.g. an - // Anthropic `overloaded_error`) BEFORE deinit frees - // it, dup'ing it so it outlives the response. Owned - // here and freed after the retry notice is queued. - if (self._retry_message) |old| { - self._agent._allocator.free(old); - self._retry_message = null; - } - if (ps.lastError()) |m| { - self._retry_message = self._agent._allocator.dupe(u8, m) catch null; - } - ps.deinit(); - self._response = null; - if (!provider_mod.isRetryableProviderError(err)) { - self.state = .failed; - return err; - } - // Out of attempts: hard-fail with the last error - // instead of re-opening forever. `_stream_retries` - // counts mid-stream retries already made for this - // response; the initial attempt is the open itself, - // so the Nth retry is attempt N+1. - const cfg = self._agent._config; - self._stream_retries += 1; - if (self._stream_retries >= cfg.retry.max_attempts) { - self.state = .failed; - return err; - } - self.state = .turn_start; - // Emit a retry notice so consumers see the stall. - const delay_ms = self._agent.backoffDelayMs(cfg.retry, self._stream_retries, null); - self._queue.push(.{ .provider_retry = .{ - .attempt = self._stream_retries, - .max_attempts = cfg.retry.max_attempts, - .delay_ms = delay_ms, - .err = err, - .message = self._retry_message, - } }) 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; - // A response completed: reset the mid-stream retry - // budget so a later turn starts fresh. - self._stream_retries = 0; - 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; - return .turn_complete; - } - - // Announce tool dispatch and return immediately. The - // dispatch itself can block for a long time (especially - // with parallel tools); yielding this boundary event first - // lets UIs/renderers show all tool calls as running instead - // of appearing frozen until the slowest tool completes. - const count = toolUseCount(last); - self._queue.push(.{ .tool_dispatch_start = .{ .count = count } }) catch |e| { - self.state = .failed; - return e; - }; - self.state = .dispatching_tools; - if (self._queue.pop()) |ev| return ev; - }, - .dispatching_tools => { - const conv = &self._agent.conversation; - const last = conv.messages.items[conv.messages.items.len - 1]; - std.debug.assert(last.role == .assistant); - std.debug.assert(Agent.hasToolUseBlock(last)); - - 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 runUserText(agent, text); - defer s.deinit(); - while (try s.next()) |_| {} -} - -/// Test helper: open a turn from a single user `.Text` block, mirroring the -/// old text-only `run(.{ .text = ... })`. The block is adopted by the agent's -/// conversation. -fn runUserText(agent: *Agent, text: []const u8) !*Stream { - var blocks = [_]conversation.ContentBlock{ - .{ .Text = try conversation.textualBlockFromSlice(agent._allocator, text) }, - }; - return agent.run(.{ .blocks = &blocks }); -} - -/// 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, - /// A queue of MID-STREAM errors. Unlike `scripted_errors` (which fail at - /// open time), each entry here lets the open succeed but makes the - /// returned response fail on its first `produce`, exercising the - /// `.streaming` retry path. Consumed in order, one per open. - stream_errors: []const StreamFailure = &.{}, - stream_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 StreamFailure = struct { - err: anyerror, - /// Optional provider diagnostic surfaced via `ProviderStream.lastError`. - message: ?[]const u8 = 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, - /// If set, the first `produce` returns this error instead of committing - /// (simulates a mid-stream provider failure after a successful open). - produce_error: ?anyerror = null, - /// Optional provider diagnostic surfaced via `lastError` after the - /// mid-stream failure (borrowed; static test strings). - error_message: ?[]const u8 = null, - - 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 }; - } - - fn createFailing( - allocator: Allocator, - conv: *conversation.Conversation, - err: anyerror, - message: ?[]const u8, - ) !provider_mod.ProviderStream { - const self = try allocator.create(StubResponse); - self.* = .{ - .allocator = allocator, - .conv = conv, - .turn = .{ .blocks = &.{} }, - .produce_error = err, - .error_message = message, - }; - return .{ .ptr = self, .vtable = &vtable }; - } - - const vtable: provider_mod.ProviderStream.VTable = .{ - .produce = produceVT, - .deinit = deinitVT, - .last_error = lastErrorVT, - }; - - fn lastErrorVT(ptr: *anyopaque) ?[]const u8 { - const self: *StubResponse = @ptrCast(@alignCast(ptr)); - return self.error_message; - } - - fn produceVT(ptr: *anyopaque, out: *stream_mod.EventQueue) anyerror!provider_mod.ProviderStream.ProduceStatus { - const self: *StubResponse = @ptrCast(@alignCast(ptr)); - if (self.produce_error) |err| return err; - 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.stream_error_idx < self.stream_errors.len) { - const f = self.stream_errors[self.stream_error_idx]; - self.stream_error_idx += 1; - return StubResponse.createFailing(allocator, conv, f.err, f.message); - } - 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 "interruption: persistence stays current and excludes a dangling tool call" { - 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(); - - var s = try runUserText(agent, "call a tool"); - - // `run` persisted the user prompt up front, and nothing else yet. - try testing.expectEqual(@as(usize, 1), cap.roles.items.len); - try testing.expectEqual(conversation.MessageRole.user, cap.roles.items[0]); - - // Pull until the assistant's tool-call message completes, then break — the - // embedder's interruption point (e.g. to handle the tool calls itself). - while (try s.next()) |ev| { - if (ev == .message_complete) break; - } - - // The tool-call message is live in the conversation... - const msgs = agent.conversation.messages.items; - try testing.expectEqual(conversation.MessageRole.assistant, msgs[msgs.len - 1].role); - try testing.expect(Agent.hasToolUseBlock(msgs[msgs.len - 1])); - // ...but it is a dangling tool call (no results), so the store still holds - // only the coherent prefix: the interruption guarantee. - try testing.expectEqual(@as(usize, 1), cap.roles.items.len); - - // Dropping the stream mid-turn must not persist the dangling call either. - s.deinit(); - try testing.expectEqual(@as(usize, 1), cap.roles.items.len); - - // The conversation is coherent enough to resume from: feeding the tool - // result as a fresh user turn resolves the dangling call, and now both the - // assistant tool-call and the user result become persistable. - var parts: std.ArrayList(conversation.ResultPartStored) = .empty; - try parts.append(allocator, .{ .text = try conversation.textualBlockFromSlice(allocator, "42") }); - var s2 = try agent.run(.{ .blocks = &.{ - .{ .ToolResult = .{ - .tool_use_id = try allocator.dupe(u8, "tc_1"), - .parts = parts, - .is_error = false, - } }, - } }); - defer s2.deinit(); - // user prompt, assistant(ToolUse), user(ToolResult) are all persisted once - // the dangling call is resolved by the new user turn. - try testing.expectEqual(@as(usize, 3), 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]); -} - -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); -} - -test "override mutators rewrite the conversation tail's tool blocks" { - const allocator = testing.allocator; - 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(); - - const conv = &agent.conversation; - // Committed assistant message carrying one ToolUse. - var a_content: std.ArrayList(conversation.ContentBlock) = .empty; - try a_content.append(allocator, .{ .ToolUse = .{ - .id = try allocator.dupe(u8, "tc_1"), - .name = try allocator.dupe(u8, "read"), - .input = try conversation.textualBlockFromSlice(allocator, "{\"path\":\"orig\"}"), - } }); - try conv.messages.append(allocator, .{ .role = .assistant, .content = a_content }); - - // Input override rewrites the tail's matching ToolUse; unknown ids miss. - try testing.expect(try agent.overrideToolUseInput("tc_1", "{\"path\":\"new\"}")); - try testing.expect(!try agent.overrideToolUseInput("tc_missing", "{}")); - const tu = conv.messages.items[0].content.items[0].ToolUse; - try testing.expectEqualStrings("{\"path\":\"new\"}", tu.input.items); - - // Tool-result user message: a text part followed by a media part. - var parts: std.ArrayList(conversation.ResultPartStored) = .empty; - try parts.append(allocator, .{ .text = try conversation.textualBlockFromSlice(allocator, "original output") }); - try parts.append(allocator, .{ .media = .{ - .media_type = try allocator.dupe(u8, "image/png"), - .data = try conversation.textualBlockFromSlice(allocator, "aW1n"), - } }); - var u_content: std.ArrayList(conversation.ContentBlock) = .empty; - try u_content.append(allocator, .{ .ToolResult = .{ - .tool_use_id = try allocator.dupe(u8, "tc_1"), - .parts = parts, - } }); - try conv.messages.append(allocator, .{ .role = .user, .content = u_content }); - - // The ToolUse override only targets the tail — now a user message. - try testing.expect(!try agent.overrideToolUseInput("tc_1", "{}")); - - // Output override replaces the text, keeps the media part. - try testing.expect(try agent.overrideToolResultOutput("tc_1", "replaced output")); - try testing.expect(!try agent.overrideToolResultOutput("tc_other", "x")); - const tr = conv.messages.items[1].content.items[0].ToolResult; - try testing.expectEqual(@as(usize, 2), tr.parts.items.len); - try testing.expectEqualStrings("replaced output", tr.parts.items[0].text.items); - try testing.expectEqualStrings("image/png", tr.parts.items[1].media.media_type); -} - -/// 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 "Stream emits tool_dispatch_start before running tools" { - 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(); - - var s = try runUserText(agent, "call a tool"); - defer s.deinit(); - - const first = (try s.next()).?; - try testing.expect(first == .message_complete); - try testing.expectEqual(@as(usize, 2), agent.conversation.messages.items.len); - - const start = (try s.next()).?; - try testing.expect(start == .tool_dispatch_start); - try testing.expectEqual(@as(usize, 1), start.tool_dispatch_start.count); - // The ToolResult user message must not exist yet; otherwise callers do - // not get a chance to render the tool as running before dispatch blocks. - try testing.expectEqual(@as(usize, 2), agent.conversation.messages.items.len); - - const complete = (try s.next()).?; - try testing.expect(complete == .tool_dispatch_complete); - try testing.expectEqual(@as(usize, 3), agent.conversation.messages.items.len); -} - -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 "interruption: resuming next() after a break dispatches the pending tools" { - 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(); - - var s = try runUserText(agent, "call a tool"); - defer s.deinit(); - - // Break right after the assistant's tool-call message completes — the - // stream is paused, NOT closed. The tool has not been dispatched. - while (try s.next()) |ev| { - if (ev == .message_complete) break; - } - try testing.expectEqual(@as(usize, 1), cap.roles.items.len); // only the prompt - - // Resuming `next()` on the same stream picks up where it left off and runs - // the agent's registered-tool machinery: no new user turn required. - var saw_dispatch = false; - while (try s.next()) |ev| { - if (ev == .tool_dispatch_complete) saw_dispatch = true; - } - try testing.expect(saw_dispatch); - - // The full turn is now persisted: 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[2]); -} - -test "compact: tolerates a trailing dangling tool call (interrupted turn)" { - const allocator = testing.allocator; - - 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(); - 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); - // A fresh turn whose assistant message ends in a tool call with no results - // yet — exactly the state a break right after `message_complete` leaves. - try addUserText(conv, "second recent question"); - try conv.addAssistantMessage(&.{ - .{ .ToolUse = .{ - .id = try allocator.dupe(u8, "tc_1"), - .name = try allocator.dupe(u8, "echo"), - .input = try conversation.textualBlockFromSlice(allocator, "hello"), - } }, - }, null); - - // Compaction must not choke on the dangling tail: it summarizes the older - // turn and keeps the interrupted turn (incl. the dangling call) verbatim. - const res = try agent._compactInPlace("Summarize the conversation.", null); - try testing.expect(res.compacted); - - // The dangling tool call survives as the tail, intact and resumable. - const last = conv.messages.items[conv.messages.items.len - 1]; - try testing.expectEqual(conversation.MessageRole.assistant, last.role); - try testing.expect(Agent.hasToolUseBlock(last)); - try testing.expectEqualStrings("tc_1", last.content.items[0].ToolUse.id); -} - -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, - - /// Append a copy of `info`, dup'ing its borrowed `message` so assertions - /// stay valid after the source `Stream` is deinit'd. - fn append(self: *RetryRecorder, info: provider_mod.ProviderRetryInfo) !void { - var owned = info; - if (info.message) |m| owned.message = try self.allocator.dupe(u8, m); - try self.infos.append(self.allocator, owned); - } - - fn deinit(self: *RetryRecorder) void { - for (self.infos.items) |info| { - if (info.message) |m| self.allocator.free(m); - } - 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 runUserText(agent, text); - defer s.deinit(); - while (try s.next()) |ev| { - if (ev == .provider_retry) try rr.append(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: mid-stream failure retries then succeeds" { - const allocator = testing.allocator; - - // The open succeeds every time; the FIRST two responses fail during - // `produce` (mid-stream), the third is a normal scripted turn. - const stream_errs = [_]StubProvider.StreamFailure{ - .{ .err = error.ProviderOverloaded, .message = "overloaded_error: Overloaded" }, - .{ .err = error.ProviderOverloaded, .message = "overloaded_error: Overloaded" }, - }; - const scripted = [_]StubProvider.ScriptedTurn{ - .{ .blocks = &.{.{ .Text = "finally" }} }, - }; - var stub = StubProvider{ - .allocator = allocator, - .scripted = &scripted, - .stream_errors = &stream_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); - - // Three opens: two that fail mid-stream + one that succeeds. - try testing.expectEqual(@as(usize, 3), stub.calls_made); - // No duplicate assistant messages: the mid-stream failures committed - // nothing, so we end with user + a 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, with a monotonically increasing attempt count - // (the bug was that this stayed pinned at 1 forever). - try testing.expectEqual(@as(usize, 2), rr.infos.items.len); - try testing.expectEqual(@as(usize, 1), rr.infos.items[0].attempt); - try testing.expectEqual(@as(usize, 2), rr.infos.items[1].attempt); - // The provider's diagnostic is surfaced on the retry notice (was the - // whole point: the user should see "overloaded", not a bare error name). - try testing.expectEqualStrings("overloaded_error: Overloaded", rr.infos.items[0].message.?); -} - -test "runStep: mid-stream failures exhaust and hard-fail after max_attempts" { - const allocator = testing.allocator; - - // Every open succeeds but every response fails mid-stream. Without the - // attempt counter this would re-open forever; it must hard-fail after - // `max_attempts` opens. - const stream_errs = [_]StubProvider.StreamFailure{ - .{ .err = error.ProviderStreamMalformed }, - .{ .err = error.ProviderStreamMalformed }, - .{ .err = error.ProviderStreamMalformed }, - .{ .err = error.ProviderStreamMalformed }, - }; - const scripted = [_]StubProvider.ScriptedTurn{ - .{ .blocks = &.{.{ .Text = "unreachable" }} }, - }; - var stub = StubProvider{ - .allocator = allocator, - .scripted = &scripted, - .stream_errors = &stream_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.ProviderStreamMalformed, drainTurnRecording(agent, "hi", &rr)); - - // 4 opens total (max_attempts), 3 retry notifications — then hard-fail - // instead of looping endlessly. - 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); -} |
