summaryrefslogtreecommitdiff
path: root/libpanto/src
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-15 22:11:53 -0600
committert <t@tjp.lol>2026-06-16 11:17:14 -0600
commitf8c6d45755acb5abf9ac68931268b7945da0fae0 (patch)
treea88ed7edd4aa3e5acced9ff99ac61b7f36b267a6 /libpanto/src
parent8206642d3a1736aaf928a5b9a732e747f5294228 (diff)
display fixes: markdown rendering, tool-specific components
Diffstat (limited to 'libpanto/src')
-rw-r--r--libpanto/src/agent.zig61
-rw-r--r--libpanto/src/pricing.zig87
-rw-r--r--libpanto/src/public.zig11
-rw-r--r--libpanto/src/stream.zig5
4 files changed, 163 insertions, 1 deletions
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,