From 53c8eb1af75dcf77d3222f0fc74f62a9cfb2f05b Mon Sep 17 00:00:00 2001 From: TC Date: Sun, 18 Oct 2026 22:58:08 +0200 Subject: [PATCH 1/2] agent-wasm: report step token counts in needTools and final outcomes outcome_to_json dropped step.timing. Both outcomes now carry timing: {promptTokens, thinkTokens, answerTokens}, so a page can show per-step statistics. Times are left out (0 on wasm32); the page times the step itself. --- crates/agent-wasm/src/lib.rs | 79 ++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/crates/agent-wasm/src/lib.rs b/crates/agent-wasm/src/lib.rs index 4dcabf7..d7df06d 100644 --- a/crates/agent-wasm/src/lib.rs +++ b/crates/agent-wasm/src/lib.rs @@ -8,8 +8,12 @@ //! one JSON "outcome" object the page inspects to decide whether to call //! tools or show the final answer: //! -//! - `{"type": "needTools", "calls": [{"callId", "name", "arguments"}]}` -//! - `{"type": "final", "text": "..."}` +//! - `{"type": "needTools", "calls": [{"callId", "name", "arguments"}], "timing": T}` +//! - `{"type": "final", "text": "...", "timing": T}` +//! +//! where `T` is `{"promptTokens", "thinkTokens", "answerTokens"}`, the +//! token counts of the step (0 when the generator cannot count). +//! //! - `{"type": "contextOverflow", "needed": n, "available": n}` //! - `{"type": "aborted"}` //! - `{"type": "error", "message": "..."}` @@ -24,20 +28,33 @@ use std::cell::RefCell; use wasm_bindgen::prelude::*; -use agent::agent::{AbortFlag, Agent, PendingToolCall, StepOutcome, Thinking}; +use agent::agent::{AbortFlag, Agent, PendingToolCall, Step, StepOutcome, Thinking}; use agent::lean_generator::LeanGenerator; use anyhow::Result; use lean::{Backend, GgufSource, LoadOptions, TokenizerSource}; use serde_json::Value; +/// Token counts of a step, as the `timing` field of `needTools` and +/// `final` outcomes. Times are not reported: wasm32 has no monotonic clock +/// in the agent, so the page times the step itself. +fn timing_json(step: &Step) -> Value { + serde_json::json!({ + "promptTokens": step.timing.prompt_tokens, + "thinkTokens": step.timing.think_tokens, + "answerTokens": step.timing.answer_tokens, + }) +} + fn outcome_to_json(outcome: StepOutcome) -> String { match outcome { - StepOutcome::NeedTools { calls, .. } => { + StepOutcome::NeedTools { calls, step } => { let calls: Vec = calls.iter().map(|c: &PendingToolCall| serde_json::json!({"callId": c.call_id, "name": c.name, "arguments": c.arguments})).collect(); - serde_json::json!({"type": "needTools", "calls": calls}).to_string() + serde_json::json!({"type": "needTools", "calls": calls, "timing": timing_json(&step)}) + .to_string() } - StepOutcome::Final { text, .. } => { - serde_json::json!({"type": "final", "text": text}).to_string() + StepOutcome::Final { text, step } => { + serde_json::json!({"type": "final", "text": text, "timing": timing_json(&step)}) + .to_string() } StepOutcome::ContextOverflow { needed, available } => { serde_json::json!({"type": "contextOverflow", "needed": needed, "available": available}) @@ -205,3 +222,51 @@ impl AgentJs { *self.agent.borrow_mut() = Some(agent); } } + +#[cfg(test)] +mod tests { + use super::*; + use agent::agent::{ParsedOutput, StepTiming}; + + fn step(timing: StepTiming) -> Step { + Step { + prompt: String::new(), + generated_text: String::new(), + parsed: ParsedOutput::Text(String::new()), + retries: 0, + tool_errors: 0, + oversized_results: 0, + repeat_guard: false, + forced_text_answer: false, + timing, + } + } + + #[test] + fn need_tools_and_final_carry_token_counts() { + let timing = StepTiming { + prompt_tokens: 812, + think_tokens: 40, + answer_tokens: 17, + ..Default::default() + }; + let expected = + serde_json::json!({"promptTokens": 812, "thinkTokens": 40, "answerTokens": 17}); + + let v: Value = serde_json::from_str(&outcome_to_json(StepOutcome::NeedTools { + calls: vec![], + step: step(timing), + })) + .unwrap(); + assert_eq!(v["type"], "needTools"); + assert_eq!(v["timing"], expected); + + let v: Value = serde_json::from_str(&outcome_to_json(StepOutcome::Final { + text: "ok".into(), + step: step(timing), + })) + .unwrap(); + assert_eq!(v["type"], "final"); + assert_eq!(v["timing"], expected); + } +} From f96c068d961d83c4baa8ac33fa354538a9ab0600 Mon Sep 17 00:00:00 2001 From: TC Date: Sun, 18 Oct 2026 23:04:21 +0200 Subject: [PATCH 2/2] agent-wasm: keep the outcome list in one piece; define T after it --- crates/agent-wasm/src/lib.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/agent-wasm/src/lib.rs b/crates/agent-wasm/src/lib.rs index d7df06d..aca3846 100644 --- a/crates/agent-wasm/src/lib.rs +++ b/crates/agent-wasm/src/lib.rs @@ -10,14 +10,13 @@ //! //! - `{"type": "needTools", "calls": [{"callId", "name", "arguments"}], "timing": T}` //! - `{"type": "final", "text": "...", "timing": T}` -//! -//! where `T` is `{"promptTokens", "thinkTokens", "answerTokens"}`, the -//! token counts of the step (0 when the generator cannot count). -//! //! - `{"type": "contextOverflow", "needed": n, "available": n}` //! - `{"type": "aborted"}` //! - `{"type": "error", "message": "..."}` //! +//! `T` is `{"promptTokens", "thinkTokens", "answerTokens"}`, the token +//! counts of the step. +//! //! Run it in a Web Worker. While `start` or `provideToolResults` is //! running, the agent is busy: any other call that needs it (a second //! `start`, `provideToolResults`, `setOnText`, `reset`) throws an "agent is