From f8c6d45755acb5abf9ac68931268b7945da0fae0 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 15 Jun 2026 22:11:53 -0600 Subject: display fixes: markdown rendering, tool-specific components --- libpanto/src/agent.zig | 61 ++++++++++++++++++++++++++++++++- libpanto/src/pricing.zig | 87 ++++++++++++++++++++++++++++++++++++++++++++++++ libpanto/src/public.zig | 11 ++++++ libpanto/src/stream.zig | 5 +++ 4 files changed, 163 insertions(+), 1 deletion(-) (limited to 'libpanto') diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index 0ff231a..8a8a2e2 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -1150,6 +1150,10 @@ pub const Stream = struct { 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. @@ -1328,12 +1332,25 @@ pub const Stream = struct { return .turn_complete; } - // Dispatch the tool calls, bracketed by boundary events. + // 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; @@ -2394,6 +2411,48 @@ test "runStep dispatches a tool call and loops to a final text turn" { 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 agent.run(.{ .text = "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; diff --git a/libpanto/src/pricing.zig b/libpanto/src/pricing.zig index 7d4e1f8..97dfe38 100644 --- a/libpanto/src/pricing.zig +++ b/libpanto/src/pricing.zig @@ -83,6 +83,13 @@ pub const Pricing = struct { if (r >= @as(f64, @floatFromInt(std.math.maxInt(u64)))) return std.math.maxInt(u64); return @intFromFloat(r); } + + /// Inverse of `fromDollarsPerMtok`: convert the integer representation + /// back to USD per million tokens (the human-friendly unit). Returns + /// 0.0 for the null case. + pub fn toDollarsPerMtok(micro_cents_per_token: u64) f64 { + return @as(f64, @floatFromInt(micro_cents_per_token)) / 100.0; + } }; // ============================================================================= @@ -120,6 +127,86 @@ fn component(tokens: u64, price: ?u64) ?u64 { return tokens *% p; } +/// Add a turn's cost to a running session total, in micro-cents. +/// Saturating `+%` on the total (a session-bucket overflow is +/// catastrophic for a `u64` value, so we pin to max rather than wrap). +/// The poison rule matches `costMicroCents`: any individual turn +/// whose cost is unknown poisons the session total to `null`. +/// +/// This is the single accumulation point for session-level cost +/// display, so the model-switch tolerance lives here: a switch in the +/// middle of a session just means successive `costMicroCents` calls +/// run against different `Pricing` structs (one per `(provider, +/// model)`). The TUI footer's cost pass holds the registry and looks +/// up the right pricing for each turn at the time the turn lands. +pub fn addCost(total: ?u64, turn_cost: ?u64) ?u64 { + const t = total orelse return null; + const c = turn_cost orelse return null; + return t +% c; +} + +test "addCost: accumulates known costs across many turns" { + var s: ?u64 = 0; + s = addCost(s, 1_000_000); // $0.01 + s = addCost(s, 5_000_000); // $0.05 + s = addCost(s, 2_000_000); // $0.02 + try testing.expectEqual(@as(?u64, 8_000_000), s); +} + +test "addCost: a known + unknown + known sequence poisons the total" { + // The poison rule is one-way: once any priced component of any + // turn is unknown, the whole session cost is unknown forever. + var s: ?u64 = 0; + s = addCost(s, 1_000_000); // $0.01 (known) + try testing.expectEqual(@as(?u64, 1_000_000), s); + s = addCost(s, null); // poison! + try testing.expect(s == null); + s = addCost(s, 5_000_000); // still null + try testing.expect(s == null); +} + +test "addCost: tolerates a model switch mid-session (each turn's cost is per-model)" { + // A model switch in the middle of a session: each turn's + // cost is computed against the active model's pricing + // upstream of `addCost`, so the function itself just sees a + // sequence of independent turn costs. The TUI's session-cost + // display sums them all; this is the tolerance: a switch is + // invisible to the accumulator as long as both models have + // pricing entries. + var s: ?u64 = 0; + s = addCost(s, costMicroCents( + .{ .input = 100, .output = 50 }, + .{ .input = 300, .output = 1500 }, + ).?); + // Switch to a different-priced model. + s = addCost(s, costMicroCents( + .{ .input = 200, .output = 100 }, + .{ .input = 100, .output = 500 }, + ).?); + // 100*300 + 50*1500 = 105_000 + // 200*100 + 100*500 = 70_000 + // total = 175_000 micro-cents = $0.00175 -> $0.00 (rounded) + try testing.expectEqual(@as(?u64, 175_000), s); +} + +test "addCost: a switch from a priced model to an unpriced model poisons" { + // The new model has no pricing entry: `costMicroCents` returns + // null (every priced component is null), and `addCost` then + // poisons the session total to null. The cost UP TO the + // switch is "known"; after it the total is "unknown". + var s: ?u64 = 0; + s = addCost(s, costMicroCents( + .{ .input = 100, .output = 50 }, + .{ .input = 300, .output = 1500 }, + ).?); + // Switch to an unpriced model. + s = addCost(s, costMicroCents( + .{ .input = 200, .output = 100 }, + .{}, // no pricing at all + )); + try testing.expect(s == null); +} + // ============================================================================= // Registry // ============================================================================= diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig index 8a55e47..d547af3 100644 --- a/libpanto/src/public.zig +++ b/libpanto/src/public.zig @@ -218,6 +218,17 @@ pub const Agent = agent_mod.Agent; pub const Pricing = pricing_mod.Pricing; pub const PricingRegistry = pricing_mod.Registry; +/// Compute the cost of a single turn's `Usage` under the given `Pricing`, +/// in micro-cents (1/1,000,000 of a cent per token). Returns null when any +/// priced component of any nonzero category is null (the poison rule — +/// don't pretend a turn with unknown cache pricing is free). Aliased +/// through `libpanto` because the embedder accumulates session totals +/// across potentially-switched models. +pub const costMicroCents = pricing_mod.costMicroCents; +/// Add a turn's micro-cents cost to a running session total. Either +/// side null => result null. The single accumulation point for the +/// per-turn session cost (see `pricing.zig` for why). +pub const addCost = pricing_mod.addCost; // =========================================================================== // Sessions diff --git a/libpanto/src/stream.zig b/libpanto/src/stream.zig index e626f31..64748b2 100644 --- a/libpanto/src/stream.zig +++ b/libpanto/src/stream.zig @@ -70,6 +70,11 @@ pub const Event = union(enum) { /// concurrent tool execution. tool_dispatch_start: ToolDispatchStart, + /// One tool result is available. The payload is a user-role carrier + /// containing exactly one `ToolResult` block, keyed by `tool_use_id`. + /// This may arrive before the aggregate `tool_dispatch_complete` event. + tool_dispatch_result: ToolDispatchComplete, + /// The agent finished dispatching tools and appended a user(ToolResult) /// message to the conversation. `message` is borrowed. tool_dispatch_complete: ToolDispatchComplete, -- cgit v1.3