From 7bfaaf46cfa1365652da425bac5ada8fdc76450d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 14:53:23 +0300 Subject: [PATCH 01/68] feat(harness): add turn runner types Introduce shared types for the turn runner so its inputs and outputs can be referenced consistently across the harness. Auto-committed-on: dragonfly --- .../src/turn_runner/types.rs | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/turn_runner/types.rs b/crates/tinyagents-harness/src/turn_runner/types.rs index 7068ae1da..47ede5912 100644 --- a/crates/tinyagents-harness/src/turn_runner/types.rs +++ b/crates/tinyagents-harness/src/turn_runner/types.rs @@ -12,7 +12,7 @@ use tokio::sync::{Mutex, mpsc}; use tokio::time::Instant; use crate::progress::AgentProgress; -use crate::run_queue::{QueueLane, QueueMode, QueueStatus, RunQueue}; +use crate::run_queue::{QueueLane, QueueMode, QueueStatus, QueuedMessage, RunQueue}; /// Identity that isolates session locks and active turns between agents. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -37,6 +37,59 @@ pub struct TurnRequest

{ pub idle_timeout: Option, } +/// Host payloads that can stand in for the metadata of a steer the harness +/// recovered and pushed back onto the run queue. +/// +/// The steering forwarder re-creates a recovered item through +/// [`QueuedMessage::requeued`], which knows only a fresh id, the text, the +/// thread label and the clock. [`TurnRequest`] implements that trait for every +/// payload that can build such a placeholder, so a turn's run queue can be the +/// harness's own queue. The runner restores the scope and thread of a recovered +/// item when it dispatches it as a fresh turn and lets the payload adopt the +/// metadata of the turn that was running. +pub trait RequeuePayload { + /// A placeholder payload for a recovered item. + fn requeued(thread_label: &str, queued_at_ms: u64) -> Self; + + /// Fill what a recovered placeholder lacks from the payload of the turn + /// that was running when it was recovered. Payloads that are already + /// complete must be left untouched. + fn adopt(&mut self, _running: &Self) {} +} + +impl RequeuePayload for serde_json::Value { + fn requeued(_thread_label: &str, _queued_at_ms: u64) -> Self { + serde_json::Value::Null + } +} + +impl QueuedMessage for TurnRequest

{ + fn id(&self) -> &str { + &self.request_id + } + + fn text(&self) -> &str { + &self.text + } + + fn requeued(id: String, text: String, thread_label: &str, queued_at_ms: u64) -> Self { + Self { + scope: ScopeKey { + agent_id: String::new(), + profile_id: None, + }, + thread_id: thread_label.to_owned(), + request_id: id, + text, + payload: P::requeued(thread_label, queued_at_ms), + host_authored: false, + disposition: QueueDisposition::Steer, + deadline: None, + idle_timeout: None, + } + } +} + /// Host-owned identity and fingerprint for the checked-out durable session. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TurnSession { @@ -259,7 +312,7 @@ where E: EventSink

+ 'static, F: FailureClassifier + 'static, S: Summarizer

+ 'static, - P: Clone + Send + Sync + 'static, + P: Clone + Send + Sync + RequeuePayload + 'static, { pub fn new( host: Arc, From 9ae78135c15e2d08810bb5897bc15b6039b5ce8a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 14:53:37 +0300 Subject: [PATCH 02/68] feat(turn_runner): add cancel grace window and restore steer metadata TurnRunnerConfig gains a cancel_grace duration that keeps a cancelled host run polled with its token fired so the host can persist completed rounds before being dropped. Recovered steers now inherit the finished turn's scope and payload metadata instead of being rebuilt without it. Auto-committed-on: dragonfly --- .../tinyagents-harness/src/turn_runner/mod.rs | 2 +- .../src/turn_runner/types.rs | 33 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-harness/src/turn_runner/mod.rs b/crates/tinyagents-harness/src/turn_runner/mod.rs index 52a0fda09..582b702a7 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod.rs @@ -10,7 +10,7 @@ mod types; pub use types::{ EventSink, FailureClassifier, IngressDecision, IngressPolicy, IngressStage, QueueDisposition, - ReplyStore, ScopeKey, Summarizer, TerminalEvent, TurnHost, TurnRequest, TurnResult, TurnRunner, + ReplyStore, RequeuePayload, ScopeKey, Summarizer, TerminalEvent, TurnHost, TurnRequest, TurnResult, TurnRunner, TurnRunnerConfig, TurnRunnerError, TurnSession, TurnSignal, deterministic_reply_id, }; diff --git a/crates/tinyagents-harness/src/turn_runner/types.rs b/crates/tinyagents-harness/src/turn_runner/types.rs index 47ede5912..eee0aff43 100644 --- a/crates/tinyagents-harness/src/turn_runner/types.rs +++ b/crates/tinyagents-harness/src/turn_runner/types.rs @@ -240,6 +240,11 @@ pub struct TurnRunnerConfig { pub idle_timeout: Duration, pub deadline: Option, pub close_timeout: Duration, + /// How long a cancelled host run keeps being polled, with its own + /// cancellation token already fired, before it is dropped. A host uses the + /// window to hand back and persist the rounds it completed. Zero drops the + /// run immediately. + pub cancel_grace: Duration, } impl Default for TurnRunnerConfig { @@ -248,6 +253,7 @@ impl Default for TurnRunnerConfig { idle_timeout: Duration::from_secs(900), deadline: None, close_timeout: Duration::from_secs(60), + cancel_grace: Duration::ZERO, } } } @@ -683,7 +689,12 @@ where if !request.host_authored && request.disposition != QueueDisposition::Parallel { let (collected, next) = match active_generation { Some(generation) => { - self.finalize_generation(&request.scope, &request.thread_id, generation) + self.finalize_generation( + &request.scope, + &request.thread_id, + generation, + &request.payload, + ) .await } None => (Vec::new(), None), @@ -838,6 +849,7 @@ where scope: &ScopeKey, thread_id: &str, owner_generation: u64, + running: &P, ) -> (Vec>, Option<(TurnRequest

, ActiveTurn

)>) { let key = (scope.clone(), thread_id.to_owned()); let mut active = self.active.lock().await; @@ -867,7 +879,13 @@ where } else { next }; - if let Some(request) = next { + if let Some(mut request) = next { + // A steer the harness recovered was rebuilt without its scope and + // payload metadata; it belongs to this thread and inherits what the + // finished turn knew. + request.scope = scope.clone(); + request.thread_id = thread_id.to_owned(); + request.payload.adopt(running); let reserved = ActiveTurn { request_id: request.request_id.clone(), cancel: crate::CancellationToken::new(), @@ -1000,6 +1018,17 @@ where _ = cancellation.cancelled() => { task_cancellation.cancel(); self.host.cancel_children(request).await; + if !self.config.cancel_grace.is_zero() { + // Let the host unwind with its token fired; its result + // is discarded either way. + let unwound = tokio::time::timeout(self.config.cancel_grace, &mut run).await; + tracing::debug!( + thread_id = %request.thread_id, + request_id = %request.request_id, + unwound = unwound.is_ok(), + "turn runner: cancelled run left its grace window" + ); + } return Err("turn cancelled".into()); } _ = async { if let Some(deadline) = deadline.at { tokio::time::sleep_until(deadline).await } else { std::future::pending().await } }, if deadline.at.is_some() => { From b264ceba6edaa6a48f8e8466e9cdd56cd85cfa50 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 14:54:51 +0300 Subject: [PATCH 03/68] test(turn_runner): cover recovered steer dispatch and cancel grace Add tests verifying that a recovered steer is dispatched under the finished turn's scope and payload, and that a cancelled run unwinds within the cancel grace but is dropped at once when the grace is zero. Auto-committed-on: dragonfly --- .../src/turn_runner/mod_tests.rs | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) diff --git a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs index d140f3608..251a68c6a 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs @@ -1865,3 +1865,321 @@ impl Summarizer for BlockingSummary { std::future::pending().await } } + +/// Payload that, like a host payload, is incomplete when the steering +/// forwarder rebuilds a recovered steer and completes itself from the turn that +/// was running. +#[derive(Clone, Debug, Default, PartialEq)] +struct ClientPayload { + client: String, +} + +impl RequeuePayload for ClientPayload { + fn requeued(_thread_label: &str, _queued_at_ms: u64) -> Self { + Self::default() + } + + fn adopt(&mut self, running: &Self) { + if self.client.is_empty() { + self.client.clone_from(&running.client); + } + } +} + +#[derive(Default)] +struct ClientHost { + started: tokio::sync::Notify, + release: tokio::sync::Notify, + /// `(request_id, scope agent, thread, client)` per run. + runs: Mutex>, + queue: Mutex>>>>, +} + +#[async_trait::async_trait] +impl TurnHost for ClientHost { + async fn checkout(&self, request: &TurnRequest) -> Result { + Ok(TurnSession { + session_id: request.thread_id.clone(), + fingerprint: None, + }) + } + async fn checkout_fork( + &self, + request: &TurnRequest, + ) -> Result { + self.checkout(request).await + } + fn run_origin(&self, _request: &TurnRequest) -> serde_json::Value { + serde_json::Value::Null + } + async fn run( + &self, + request: &TurnRequest, + _session: &TurnSession, + _origin: &serde_json::Value, + _cancellation: crate::CancellationToken, + queue: Arc>>, + _progress: tokio::sync::mpsc::Sender, + ) -> Result { + self.runs.lock().unwrap().push(( + request.request_id.clone(), + request.scope.agent_id.clone(), + request.thread_id.clone(), + request.payload.client.clone(), + )); + if request.request_id == "request-a" { + *self.queue.lock().unwrap() = Some(queue); + self.started.notify_one(); + self.release.notified().await; + } + Ok(TurnResult { + reply: "ok".into(), + data: serde_json::Value::Null, + }) + } + async fn checkin(&self, _request: &TurnRequest) {} + async fn cancel_children(&self, _request: &TurnRequest) {} +} + +struct ClientIngress; + +#[async_trait::async_trait] +impl IngressPolicy for ClientIngress { + async fn apply( + &self, + _stage: IngressStage, + _request: &mut TurnRequest, + ) -> Result { + Ok(IngressDecision::Continue) + } +} + +struct ClientReplies; + +#[async_trait::async_trait] +impl ReplyStore for ClientReplies { + async fn persist( + &self, + _request: &TurnRequest, + _reply_id: &str, + _result: &TurnResult, + ) -> Result<(), String> { + Ok(()) + } +} + +struct ClientEvents; + +#[async_trait::async_trait] +impl EventSink for ClientEvents { + async fn progress( + &self, + _request: &TurnRequest, + _event: crate::progress::AgentProgress, + ) { + } + async fn terminal(&self, _request: &TurnRequest, _event: TerminalEvent) {} +} + +struct ClientSummary; + +#[async_trait::async_trait] +impl Summarizer for ClientSummary { + async fn grounded_close( + &self, + _request: &TurnRequest, + _reason: &str, + ) -> Option { + None + } +} + +#[test] +fn run_queue_item_exposes_request_identity_to_the_steering_forwarder() { + let item = TurnRequest { + scope: ScopeKey { + agent_id: "agent-a".into(), + profile_id: None, + }, + thread_id: "thread-a".into(), + request_id: "item-1".into(), + text: "go left".into(), + payload: serde_json::Value::Null, + host_authored: false, + disposition: QueueDisposition::Steer, + deadline: None, + idle_timeout: None, + }; + assert_eq!(QueuedMessage::id(&item), "item-1"); + assert_eq!(QueuedMessage::text(&item), "go left"); + + let recovered = ::requeued( + "fresh".into(), + "go left".into(), + "thread-a", + 42, + ); + assert_eq!(recovered.request_id, "fresh"); + assert_eq!(recovered.text, "go left"); + assert_eq!(recovered.thread_id, "thread-a"); + assert_eq!(recovered.disposition, QueueDisposition::Steer); + assert!(!recovered.host_authored); + assert_eq!(recovered.payload, serde_json::Value::Null); +} + +#[tokio::test] +async fn recovered_steer_is_dispatched_under_the_finished_turns_scope_and_payload() { + let host = Arc::new(ClientHost::default()); + let runner = Arc::new(TurnRunner::new( + host.clone(), + Arc::new(ClientIngress), + Arc::new(ClientReplies), + Arc::new(ClientEvents), + Arc::new(Failures), + Arc::new(ClientSummary), + TurnRunnerConfig::default(), + )); + let primary = { + let runner = runner.clone(); + tokio::spawn(async move { + runner + .run(TurnRequest { + scope: ScopeKey { + agent_id: "agent-a".into(), + profile_id: None, + }, + thread_id: "thread-a".into(), + request_id: "request-a".into(), + text: "hello".into(), + payload: ClientPayload { + client: "client-1".into(), + }, + host_authored: false, + disposition: QueueDisposition::Interrupt, + deadline: None, + idle_timeout: Some(std::time::Duration::from_secs(5)), + }) + .await + }) + }; + host.started.notified().await; + + // What the steering forwarder does with a steer the run never applied: + // rebuild it from the bare id, text and thread label. + let queue = host.queue.lock().unwrap().clone().expect("run queue"); + queue + .push( + QueueLane::Steer, + as QueuedMessage>::requeued( + "recovered".into(), + "go left".into(), + "thread-a", + 7, + ), + ) + .await; + + host.release.notify_one(); + primary.await.unwrap().unwrap(); + + assert_eq!( + host.runs.lock().unwrap().as_slice(), + [ + ( + "request-a".to_owned(), + "agent-a".to_owned(), + "thread-a".to_owned(), + "client-1".to_owned() + ), + ( + "recovered".to_owned(), + "agent-a".to_owned(), + "thread-a".to_owned(), + "client-1".to_owned() + ), + ] + ); +} + +#[derive(Default)] +struct UnwindingHost { + started: tokio::sync::Notify, + persisted: std::sync::atomic::AtomicBool, +} + +#[async_trait::async_trait] +impl TurnHost for UnwindingHost { + async fn checkout(&self, request: &TurnRequest) -> Result { + Ok(TurnSession { + session_id: request.thread_id.clone(), + fingerprint: None, + }) + } + async fn checkout_fork(&self, request: &TurnRequest) -> Result { + self.checkout(request).await + } + fn run_origin(&self, _request: &TurnRequest) -> serde_json::Value { + serde_json::Value::Null + } + async fn run( + &self, + _request: &TurnRequest, + _session: &TurnSession, + _origin: &serde_json::Value, + cancellation: crate::CancellationToken, + _queue: Arc>, + _progress: tokio::sync::mpsc::Sender, + ) -> Result { + self.started.notify_one(); + cancellation.cancelled().await; + // Hand back and persist the rounds completed so far. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + self.persisted + .store(true, std::sync::atomic::Ordering::SeqCst); + Err("stopped".into()) + } + async fn checkin(&self, _request: &TurnRequest) {} + async fn cancel_children(&self, _request: &TurnRequest) {} +} + +async fn cancel_unwinding_turn(grace: std::time::Duration) -> bool { + let host = Arc::new(UnwindingHost::default()); + let runner = Arc::new(TurnRunner::new( + host.clone(), + Arc::new(Ingress::default()), + Arc::new(Replies::default()), + Arc::new(Events::default()), + Arc::new(Failures), + Arc::new(Summary), + TurnRunnerConfig { + cancel_grace: grace, + ..TurnRunnerConfig::default() + }, + )); + let primary = { + let runner = runner.clone(); + tokio::spawn(async move { + runner + .run(request(QueueDisposition::Interrupt, false)) + .await + }) + }; + host.started.notified().await; + let req = request(QueueDisposition::Interrupt, false); + assert!(runner.cancel(&req.scope, &req.thread_id, None).await); + assert!(matches!( + primary.await.unwrap(), + Err(TurnRunnerError::Cancelled) + )); + host.persisted.load(std::sync::atomic::Ordering::SeqCst) +} + +#[tokio::test] +async fn cancelled_run_unwinds_within_the_cancel_grace() { + assert!(cancel_unwinding_turn(std::time::Duration::from_secs(2)).await); +} + +#[tokio::test] +async fn cancelled_run_is_dropped_at_once_without_a_cancel_grace() { + assert!(!cancel_unwinding_turn(std::time::Duration::ZERO).await); +} From 9671bbbf38d6a5c6cf107c194d1ae43b4a840912 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 14:55:45 +0300 Subject: [PATCH 04/68] test(turn_runner): use default config in test fixtures The test configs now spread TurnRunnerConfig::default() so new fields don't break existing tests, and QueuedMessage is imported for upcoming test use. Auto-committed-on: dragonfly --- crates/tinyagents-harness/src/turn_runner/mod_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs index 251a68c6a..bf2b5a901 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::run_queue::{QueueLane, QueueMode, RunQueue}; +use crate::run_queue::{QueueLane, QueueMode, QueuedMessage, RunQueue}; use std::sync::{Arc, Mutex}; #[derive(Default)] @@ -1636,6 +1636,7 @@ async fn idle_guard_restarts_when_quiet_work_finishes() { idle_timeout: std::time::Duration::from_secs(5), deadline: None, close_timeout: std::time::Duration::from_secs(1), + ..TurnRunnerConfig::default() }, )); let mut turn = request(QueueDisposition::Interrupt, false); @@ -1672,6 +1673,7 @@ async fn continuous_progress_cannot_starve_the_wall_deadline() { idle_timeout: std::time::Duration::from_secs(60), deadline: Some(std::time::Duration::from_secs(5)), close_timeout: std::time::Duration::from_secs(1), + ..TurnRunnerConfig::default() }, )); let run = { @@ -1708,6 +1710,7 @@ async fn wall_deadline_is_polled_while_progress_delivery_is_pending() { idle_timeout: std::time::Duration::from_secs(60), deadline: Some(std::time::Duration::from_secs(5)), close_timeout: std::time::Duration::from_secs(1), + ..TurnRunnerConfig::default() }, )); let run = { @@ -1801,6 +1804,7 @@ async fn cancellation_during_grounded_close_prevents_persisting_completion() { idle_timeout: std::time::Duration::from_secs(5), deadline: None, close_timeout: std::time::Duration::from_secs(60), + ..TurnRunnerConfig::default() }, )); let mut turn = request(QueueDisposition::Interrupt, false); From 0425745adaeecae252ceea0d9e06de4dfe373298 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 14:56:53 +0300 Subject: [PATCH 05/68] docs(turn_runner): document requeue and cancel grace behaviour Added README notes explaining that TurnRequest implements QueuedMessage for requeue-capable payloads, how recovered steers are rebuilt and dispatched as fresh turns, and that cancel_grace keeps a cancelled host run polled briefly so it can persist completed rounds. Also applied rustfmt formatting to the module. Auto-committed-on: dragonfly --- crates/tinyagents-harness/src/turn_runner/README.md | 10 ++++++++++ crates/tinyagents-harness/src/turn_runner/mod.rs | 5 +++-- crates/tinyagents-harness/src/turn_runner/mod_tests.rs | 8 ++------ crates/tinyagents-harness/src/turn_runner/types.rs | 2 +- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/tinyagents-harness/src/turn_runner/README.md b/crates/tinyagents-harness/src/turn_runner/README.md index 764d44141..dbd8d96ce 100644 --- a/crates/tinyagents-harness/src/turn_runner/README.md +++ b/crates/tinyagents-harness/src/turn_runner/README.md @@ -36,3 +36,13 @@ separate total ceiling. Either guard cancels child work and asks the `AgentProgress` is the canonical channel-independent progress vocabulary. Hosts translate it to their own event wire format; this module does not define channel event names or transport payloads. + +`TurnRequest

` is itself a [`QueuedMessage`](crate::run_queue::QueuedMessage) +for every payload that implements `RequeuePayload`, so a turn's run queue can be +handed straight to the steering forwarder. A steer the forwarder recovers and +pushes back is rebuilt from a bare id, text and thread label; the runner +restores the scope and thread when it dispatches that item as a fresh turn and +lets the payload adopt the metadata of the turn that was running. +`TurnRunnerConfig::cancel_grace` keeps a cancelled host run polled, with its own +cancellation token already fired, for a bounded time so the host can hand back +and persist the rounds it completed. It defaults to zero (drop at once). diff --git a/crates/tinyagents-harness/src/turn_runner/mod.rs b/crates/tinyagents-harness/src/turn_runner/mod.rs index 582b702a7..ddd008adf 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod.rs @@ -10,8 +10,9 @@ mod types; pub use types::{ EventSink, FailureClassifier, IngressDecision, IngressPolicy, IngressStage, QueueDisposition, - ReplyStore, RequeuePayload, ScopeKey, Summarizer, TerminalEvent, TurnHost, TurnRequest, TurnResult, TurnRunner, - TurnRunnerConfig, TurnRunnerError, TurnSession, TurnSignal, deterministic_reply_id, + ReplyStore, RequeuePayload, ScopeKey, Summarizer, TerminalEvent, TurnHost, TurnRequest, + TurnResult, TurnRunner, TurnRunnerConfig, TurnRunnerError, TurnSession, TurnSignal, + deterministic_reply_id, }; #[cfg(test)] diff --git a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs index bf2b5a901..5a98cdfac 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs @@ -2017,12 +2017,8 @@ fn run_queue_item_exposes_request_identity_to_the_steering_forwarder() { assert_eq!(QueuedMessage::id(&item), "item-1"); assert_eq!(QueuedMessage::text(&item), "go left"); - let recovered = ::requeued( - "fresh".into(), - "go left".into(), - "thread-a", - 42, - ); + let recovered = + ::requeued("fresh".into(), "go left".into(), "thread-a", 42); assert_eq!(recovered.request_id, "fresh"); assert_eq!(recovered.text, "go left"); assert_eq!(recovered.thread_id, "thread-a"); diff --git a/crates/tinyagents-harness/src/turn_runner/types.rs b/crates/tinyagents-harness/src/turn_runner/types.rs index eee0aff43..2867b026c 100644 --- a/crates/tinyagents-harness/src/turn_runner/types.rs +++ b/crates/tinyagents-harness/src/turn_runner/types.rs @@ -695,7 +695,7 @@ where generation, &request.payload, ) - .await + .await } None => (Vec::new(), None), }; From b3ccaca1dba37d4db0ad5c393519d667808621c7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 14:59:34 +0300 Subject: [PATCH 06/68] feat(turn_runner): add turn runner types and module Introduce a turn runner module with the types it needs to drive a single agent turn. This gives the harness a dedicated place to orchestrate turn execution instead of spreading that logic across callers. Auto-committed-on: dragonfly --- .../tinyagents-harness/src/turn_runner/mod.rs | 2 +- .../src/turn_runner/types.rs | 85 +++++++++++++++++-- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/crates/tinyagents-harness/src/turn_runner/mod.rs b/crates/tinyagents-harness/src/turn_runner/mod.rs index ddd008adf..2d8b5b3f6 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod.rs @@ -9,7 +9,7 @@ mod types; pub use types::{ - EventSink, FailureClassifier, IngressDecision, IngressPolicy, IngressStage, QueueDisposition, + EventSink, FailureClassifier, IngressDecision, IngressPolicy, IngressStage, LiveTurn, QueueDisposition, ReplyStore, RequeuePayload, ScopeKey, Summarizer, TerminalEvent, TurnHost, TurnRequest, TurnResult, TurnRunner, TurnRunnerConfig, TurnRunnerError, TurnSession, TurnSignal, deterministic_reply_id, diff --git a/crates/tinyagents-harness/src/turn_runner/types.rs b/crates/tinyagents-harness/src/turn_runner/types.rs index 2867b026c..76b314bb5 100644 --- a/crates/tinyagents-harness/src/turn_runner/types.rs +++ b/crates/tinyagents-harness/src/turn_runner/types.rs @@ -21,6 +21,16 @@ pub struct ScopeKey { pub profile_id: Option, } +/// A turn that currently holds a cancellation slot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LiveTurn { + pub scope: ScopeKey, + pub thread_id: String, + pub request_id: String, + /// Whether it is a parallel turn rather than the thread's primary turn. + pub parallel: bool, +} + /// A request after the host has supplied its channel-independent metadata. #[derive(Debug, Clone)] pub struct TurnRequest

{ @@ -143,6 +153,9 @@ pub enum TerminalEvent { request_id: String, message: String, class: String, + /// The raw host or runner error the failure was classified from, for + /// hosts that render richer copy than `class` and `message` carry. + error: String, }, Cancelled { request_id: String, @@ -353,18 +366,31 @@ where thread_id: &str, request_id: Option<&str>, ) -> bool { + !self + .cancel_requests(scope, thread_id, request_id) + .await + .is_empty() + } + + /// [`cancel`](Self::cancel), returning the id of every turn it stopped: the + /// primary turn first, then each parallel turn. + pub async fn cancel_requests( + &self, + scope: &ScopeKey, + thread_id: &str, + request_id: Option<&str>, + ) -> Vec { let key = (scope.clone(), thread_id.to_owned()); + let mut cancelled = Vec::new(); let active = self.active.lock().await; - let cancelled_primary = if let Some(turn) = active + if let Some(turn) = active .get(&key) .filter(|turn| request_id.is_none_or(|id| turn.request_id == id)) { turn.queue.clear().await; turn.cancel.cancel(); - true - } else { - false - }; + cancelled.push(turn.request_id.clone()); + } drop(active); let mut parallel = self.parallel_active.lock().await; let keys: Vec<_> = parallel @@ -379,8 +405,53 @@ where token.cancel(); } self.parallel_queues.lock().await.remove(key); + cancelled.push(key.2.clone()); + } + cancelled + } + + /// Every turn that currently holds a cancellation slot, primary and parallel. + pub async fn live_turns(&self) -> Vec { + let mut turns: Vec = self + .active + .lock() + .await + .iter() + .map(|((scope, thread_id), turn)| LiveTurn { + scope: scope.clone(), + thread_id: thread_id.clone(), + request_id: turn.request_id.clone(), + parallel: false, + }) + .collect(); + turns.extend(self.parallel_active.lock().await.keys().map( + |(scope, thread_id, request_id)| LiveTurn { + scope: scope.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + parallel: true, + }, + )); + turns + } + + /// Pending items of the thread's active turn queue, oldest first per lane. + pub async fn queue_snapshot( + &self, + scope: &ScopeKey, + thread_id: &str, + ) -> Vec<(QueueLane, TurnRequest

)> { + let key = (scope.clone(), thread_id.to_owned()); + let queue = self + .active + .lock() + .await + .get(&key) + .map(|turn| Arc::clone(&turn.queue)); + match queue { + Some(queue) => queue.snapshot().await, + None => Vec::new(), } - cancelled_primary || !keys.is_empty() } /// Whether the thread owns a primary user-authored turn cancellation slot. @@ -827,6 +898,7 @@ where request_id: queued_request.request_id.clone(), message: format!("queued turn was not run: {message}"), class: class.clone(), + error: error.clone(), }, ) .await; @@ -838,6 +910,7 @@ where request_id: request.request_id.clone(), message: message.clone(), class, + error, }, ) .await; From 2e14cc083811aba63b0ba839fdd7c1211f600ce8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:00:59 +0300 Subject: [PATCH 07/68] test(turn_runner): cover live turn snapshots and scoped cancellation Add tests asserting that live_turns and queue_snapshot report every in-flight and queued turn for a scope, and that scoped cancellation targets only the named request while an unscoped cancel stops the primary turn and clears its queue. Also verify that a failed run emits a terminal event carrying the raw host error. Auto-committed-on: dragonfly --- .../tinyagents-harness/src/turn_runner/mod.rs | 8 +- .../src/turn_runner/mod_tests.rs | 118 ++++++++++++++++++ 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/crates/tinyagents-harness/src/turn_runner/mod.rs b/crates/tinyagents-harness/src/turn_runner/mod.rs index 2d8b5b3f6..bf7be1567 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod.rs @@ -9,10 +9,10 @@ mod types; pub use types::{ - EventSink, FailureClassifier, IngressDecision, IngressPolicy, IngressStage, LiveTurn, QueueDisposition, - ReplyStore, RequeuePayload, ScopeKey, Summarizer, TerminalEvent, TurnHost, TurnRequest, - TurnResult, TurnRunner, TurnRunnerConfig, TurnRunnerError, TurnSession, TurnSignal, - deterministic_reply_id, + EventSink, FailureClassifier, IngressDecision, IngressPolicy, IngressStage, LiveTurn, + QueueDisposition, ReplyStore, RequeuePayload, ScopeKey, Summarizer, TerminalEvent, TurnHost, + TurnRequest, TurnResult, TurnRunner, TurnRunnerConfig, TurnRunnerError, TurnSession, + TurnSignal, deterministic_reply_id, }; #[cfg(test)] diff --git a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs index 5a98cdfac..ff2d66f7a 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs @@ -2183,3 +2183,121 @@ async fn cancelled_run_unwinds_within_the_cancel_grace() { async fn cancelled_run_is_dropped_at_once_without_a_cancel_grace() { assert!(!cancel_unwinding_turn(std::time::Duration::ZERO).await); } + +#[tokio::test] +async fn live_turns_snapshot_and_scoped_cancel_report_every_turn() { + let started = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let host = Arc::new(Host { + started: Some(started.clone()), + release: Some(release.clone()), + ..Host::default() + }); + let runner = Arc::new(runner( + host.clone(), + Arc::new(Ingress::default()), + Arc::new(Replies::default()), + Arc::new(Events::default()), + )); + let primary_request = request(QueueDisposition::Interrupt, false); + let scope = primary_request.scope.clone(); + let thread = primary_request.thread_id.clone(); + let primary = { + let runner = runner.clone(); + tokio::spawn(async move { runner.run(primary_request).await }) + }; + started.notified().await; + let mut parallel_request = request(QueueDisposition::Parallel, false); + parallel_request.request_id = "parallel-a".into(); + let parallel = { + let runner = runner.clone(); + tokio::spawn(async move { runner.run(parallel_request).await }) + }; + started.notified().await; + + let mut queued = request(QueueDisposition::Followup, false); + queued.request_id = "queued-a".into(); + assert!(matches!( + runner.run(queued).await, + Err(TurnRunnerError::Queued) + )); + + let mut live = runner.live_turns().await; + live.sort_by(|a, b| a.request_id.cmp(&b.request_id)); + assert_eq!( + live.iter() + .map(|turn| (turn.request_id.as_str(), turn.parallel)) + .collect::>(), + [("parallel-a", true), ("request-a", false)] + ); + assert!( + live.iter() + .all(|turn| turn.scope == scope && turn.thread_id == thread) + ); + + let snapshot = runner.queue_snapshot(&scope, &thread).await; + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].0, QueueLane::Followup); + assert_eq!(snapshot[0].1.request_id, "queued-a"); + assert!( + runner + .queue_snapshot(&scope, "other-thread") + .await + .is_empty() + ); + + // A scoped cancel names exactly the parallel turn. + assert_eq!( + runner + .cancel_requests(&scope, &thread, Some("parallel-a")) + .await, + ["parallel-a"] + ); + assert!(matches!( + parallel.await.unwrap(), + Err(TurnRunnerError::Cancelled) + )); + // An unscoped cancel then stops the primary turn and drops its queue. + assert_eq!( + runner.cancel_requests(&scope, &thread, None).await, + ["request-a"] + ); + assert!(matches!( + primary.await.unwrap(), + Err(TurnRunnerError::Cancelled) + )); + assert!(runner.live_turns().await.is_empty()); + assert!( + runner + .cancel_requests(&scope, &thread, None) + .await + .is_empty() + ); +} + +#[tokio::test] +async fn failed_terminal_event_carries_the_raw_error() { + let events = Arc::new(Events::default()); + let host = Arc::new(Host { + fail_run: true, + ..Host::default() + }); + let runner = runner( + host, + Arc::new(Ingress::default()), + Arc::new(Replies::default()), + events.clone(), + ); + assert!(matches!( + runner + .run(request(QueueDisposition::Interrupt, false)) + .await, + Err(TurnRunnerError::Failed(_)) + )); + let terminal = events.terminal.lock().unwrap(); + assert!(matches!( + terminal.as_slice(), + [TerminalEvent::Failed { error, message, class, .. }] + if error == "host run failed" && message == "host run failed" && class == "internal" + )); +} From a2a16d2f5d6f5f45449f350a16596f88c826a9f4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:53:23 +0300 Subject: [PATCH 08/68] feat(progress): add SubagentUsage::cost accessor Expose a subagent run's cost as a CostTally so it can be folded into a parent's tally without reaching into the struct's fields directly. Auto-committed-on: dragonfly --- crates/tinyagents-harness/src/progress.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/tinyagents-harness/src/progress.rs b/crates/tinyagents-harness/src/progress.rs index 36179671f..010d4c595 100644 --- a/crates/tinyagents-harness/src/progress.rs +++ b/crates/tinyagents-harness/src/progress.rs @@ -457,6 +457,17 @@ pub struct SubagentUsage { pub cost_source: crate::cost::CostSource, } +impl SubagentUsage { + /// The run's cost as a tally, for folding into a parent's. + #[must_use] + pub fn cost(&self) -> crate::cost::CostTally { + crate::cost::CostTally { + known_usd: self.charged_amount_usd, + source: self.cost_source, + } + } +} + fn unknown_cost_source() -> crate::cost::CostSource { crate::cost::CostSource::Unknown } From 6c0db9cda8ef4f9b211630d455ecb0ea0f92f209 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:53:57 +0300 Subject: [PATCH 09/68] test(harness): add progress test module Wire up a test module for the progress tracking code so the new progress_tests.rs file is compiled and run with the crate's test suite. Auto-committed-on: dragonfly --- crates/tinyagents-harness/src/progress.rs | 4 +++ .../tinyagents-harness/src/progress_tests.rs | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 crates/tinyagents-harness/src/progress_tests.rs diff --git a/crates/tinyagents-harness/src/progress.rs b/crates/tinyagents-harness/src/progress.rs index 010d4c595..001e09272 100644 --- a/crates/tinyagents-harness/src/progress.rs +++ b/crates/tinyagents-harness/src/progress.rs @@ -486,3 +486,7 @@ impl AgentProgress { } } } + +#[cfg(test)] +#[path = "progress_tests.rs"] +mod tests; diff --git a/crates/tinyagents-harness/src/progress_tests.rs b/crates/tinyagents-harness/src/progress_tests.rs new file mode 100644 index 000000000..947aec85d --- /dev/null +++ b/crates/tinyagents-harness/src/progress_tests.rs @@ -0,0 +1,32 @@ +use super::*; +use crate::cost::CostSource; + +#[test] +fn subagent_usage_cost_carries_the_known_spend_and_its_certainty() { + let usage = SubagentUsage { + input_tokens: 10, + output_tokens: 5, + cached_input_tokens: 2, + charged_amount_usd: 0.25, + cost_source: CostSource::Charged, + }; + let tally = usage.cost(); + assert_eq!(tally.known_usd, 0.25); + assert_eq!(tally.source, CostSource::Charged); + assert_eq!(tally.usd(), Some(0.25)); +} + +#[test] +fn subagent_usage_with_an_unknown_cost_has_no_usd() { + let tally = SubagentUsage::default().cost(); + assert_eq!(tally.source, CostSource::Unknown); + assert_eq!(tally.usd(), None); +} + +#[test] +fn subagent_usage_omits_an_unknown_cost_source_on_the_wire() { + let json = serde_json::to_value(SubagentUsage::default()).unwrap(); + assert!(json.get("cost_source").is_none()); + let round_trip: SubagentUsage = serde_json::from_value(json).unwrap(); + assert_eq!(round_trip, SubagentUsage::default()); +} From 047144deb0255746372228ecd8310bdd3089376b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:54:51 +0300 Subject: [PATCH 10/68] test(progress): pin SubagentUsage unknown-cost wire behaviour --- crates/tinyagents-harness/src/progress_tests.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-harness/src/progress_tests.rs b/crates/tinyagents-harness/src/progress_tests.rs index 947aec85d..82edd1fff 100644 --- a/crates/tinyagents-harness/src/progress_tests.rs +++ b/crates/tinyagents-harness/src/progress_tests.rs @@ -18,15 +18,24 @@ fn subagent_usage_cost_carries_the_known_spend_and_its_certainty() { #[test] fn subagent_usage_with_an_unknown_cost_has_no_usd() { - let tally = SubagentUsage::default().cost(); + let usage = SubagentUsage { + cost_source: CostSource::Unknown, + ..SubagentUsage::default() + }; + let tally = usage.cost(); assert_eq!(tally.source, CostSource::Unknown); assert_eq!(tally.usd(), None); } #[test] fn subagent_usage_omits_an_unknown_cost_source_on_the_wire() { - let json = serde_json::to_value(SubagentUsage::default()).unwrap(); + let usage = SubagentUsage { + cost_source: CostSource::Unknown, + ..SubagentUsage::default() + }; + let json = serde_json::to_value(usage).unwrap(); assert!(json.get("cost_source").is_none()); + // Absent on the wire reads back as an unknown cost, never a charged one. let round_trip: SubagentUsage = serde_json::from_value(json).unwrap(); - assert_eq!(round_trip, SubagentUsage::default()); + assert_eq!(round_trip, usage); } From 1896ee9065c0f836b55d3ddb462f720b4ba5c376 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 09:58:11 +0300 Subject: [PATCH 11/68] refactor(turn_runner): introduce work tracker for idle detection This change introduces a `WorkTracker` to allow hosts to signal progress during agent work. This allows the idle guard to correctly distinguish between a quiet but healthy request and an actual idle state. Auto-committed-on: dragonfly --- .../src/turn_runner/README.md | 11 +- .../tinyagents-harness/src/turn_runner/mod.rs | 2 + .../src/turn_runner/work_tracker.rs | 65 +++++++++ .../src/turn_runner/work_tracker_tests.rs | 125 ++++++++++++++++++ 4 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 crates/tinyagents-harness/src/turn_runner/work_tracker.rs create mode 100644 crates/tinyagents-harness/src/turn_runner/work_tracker_tests.rs diff --git a/crates/tinyagents-harness/src/turn_runner/README.md b/crates/tinyagents-harness/src/turn_runner/README.md index dbd8d96ce..77c855de8 100644 --- a/crates/tinyagents-harness/src/turn_runner/README.md +++ b/crates/tinyagents-harness/src/turn_runner/README.md @@ -27,11 +27,12 @@ including fork isolation for parallel runs, and must check a session back in after every successful checkout. The runner persists a deterministic reply id before emitting exactly one terminal event. -The idle guard defaults to 900 seconds and resets on progress. Hosts signal -`WorkStarted` and `WorkFinished` around model or tool work so a quiet but -healthy request is not treated as idle. The optional wall deadline remains a -separate total ceiling. Either guard cancels child work and asks the -`Summarizer` for a bounded grounded close before classifying the turn as failed. +The idle guard defaults to 900 seconds and resets on progress. Hosts can use +`WorkTracker` to fold `AgentProgress` into `WorkStarted` and `WorkFinished` +signals around model or tool work, so a quiet but healthy request is not +treated as idle. The optional wall deadline remains a separate total ceiling. +Either guard cancels child work and asks the `Summarizer` for a bounded +grounded close before classifying the turn as failed. `AgentProgress` is the canonical channel-independent progress vocabulary. Hosts translate it to their own event wire format; this module does not define diff --git a/crates/tinyagents-harness/src/turn_runner/mod.rs b/crates/tinyagents-harness/src/turn_runner/mod.rs index bf7be1567..d30e56cb7 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod.rs @@ -7,6 +7,7 @@ //! owned. mod types; +mod work_tracker; pub use types::{ EventSink, FailureClassifier, IngressDecision, IngressPolicy, IngressStage, LiveTurn, @@ -14,6 +15,7 @@ pub use types::{ TurnRequest, TurnResult, TurnRunner, TurnRunnerConfig, TurnRunnerError, TurnSession, TurnSignal, deterministic_reply_id, }; +pub use work_tracker::WorkTracker; #[cfg(test)] #[path = "mod_tests.rs"] diff --git a/crates/tinyagents-harness/src/turn_runner/work_tracker.rs b/crates/tinyagents-harness/src/turn_runner/work_tracker.rs new file mode 100644 index 000000000..6789191b5 --- /dev/null +++ b/crates/tinyagents-harness/src/turn_runner/work_tracker.rs @@ -0,0 +1,65 @@ +//! Folds generic agent progress into balanced work-liveness signals. + +use std::collections::HashSet; + +use crate::progress::AgentProgress; + +use super::TurnSignal; + +/// Tracks whether model or tool work is currently open for a turn. +/// +/// The tracker emits [`TurnSignal::WorkStarted`] when progress first opens +/// work and [`TurnSignal::WorkFinished`] when the last open operation closes. +/// This lets a turn runner distinguish a quiet but healthy provider/tool call +/// from an idle turn without depending on a channel-specific event format. +#[derive(Debug, Default)] +pub struct WorkTracker { + model_call_open: bool, + open_tool_calls: HashSet, +} + +impl WorkTracker { + fn open(&self) -> bool { + self.model_call_open || !self.open_tool_calls.is_empty() + } + + /// Folds one progress event into the open-work state. + /// + /// `IterationStarted` closes tool calls whose completion may have been + /// dropped, while a parent `ToolCallStarted` closes the model call that + /// requested it. Subagent model completions leave parent work unchanged. + pub fn observe(&mut self, event: &AgentProgress) -> Option { + let was_open = self.open(); + match event { + AgentProgress::IterationStarted { .. } => { + self.open_tool_calls.clear(); + self.model_call_open = true; + } + AgentProgress::ModelCallCompleted { + subagent_task_id: None, + .. + } => self.model_call_open = false, + AgentProgress::ToolCallStarted { call_id, .. } => { + self.model_call_open = false; + self.open_tool_calls.insert(call_id.clone()); + } + AgentProgress::ToolCallCompleted { call_id, .. } => { + self.open_tool_calls.remove(call_id); + } + AgentProgress::TurnCompleted { .. } => { + self.open_tool_calls.clear(); + self.model_call_open = false; + } + _ => {} + } + match (was_open, self.open()) { + (false, true) => Some(TurnSignal::WorkStarted), + (true, false) => Some(TurnSignal::WorkFinished), + _ => None, + } + } +} + +#[cfg(test)] +#[path = "work_tracker_tests.rs"] +mod tests; diff --git a/crates/tinyagents-harness/src/turn_runner/work_tracker_tests.rs b/crates/tinyagents-harness/src/turn_runner/work_tracker_tests.rs new file mode 100644 index 000000000..8093f0e41 --- /dev/null +++ b/crates/tinyagents-harness/src/turn_runner/work_tracker_tests.rs @@ -0,0 +1,125 @@ +use super::*; + +fn iteration_started() -> AgentProgress { + AgentProgress::IterationStarted { + iteration: 1, + max_iterations: 50, + } +} + +fn model_call_completed(subagent_task_id: Option<&str>) -> AgentProgress { + AgentProgress::ModelCallCompleted { + model: "m".into(), + provider_id: "managed".into(), + subagent_task_id: subagent_task_id.map(str::to_string), + input: None, + output: None, + iteration: 1, + input_tokens: 0, + output_tokens: 0, + cached_input_tokens: 0, + cache_creation_tokens: 0, + reasoning_tokens: 0, + cost_usd: None, + } +} + +fn tool_started(call_id: &str) -> AgentProgress { + AgentProgress::ToolCallStarted { + call_id: call_id.into(), + tool_name: "shell".into(), + arguments: serde_json::json!({}), + iteration: 1, + display_label: None, + display_detail: None, + } +} + +fn tool_completed(call_id: &str) -> AgentProgress { + AgentProgress::ToolCallCompleted { + call_id: call_id.into(), + tool_name: "shell".into(), + success: true, + output_chars: 0, + output: String::new(), + arguments: None, + elapsed_ms: 0, + iteration: 1, + failure: None, + display_label: None, + display_detail: None, + structured: None, + } +} + +fn signal(tracker: &mut WorkTracker, event: &AgentProgress) -> Option<&'static str> { + tracker.observe(event).map(|signal| match signal { + TurnSignal::WorkStarted => "started", + TurnSignal::WorkFinished => "finished", + TurnSignal::Progress(_) => "progress", + }) +} + +/// A provider retrying a failed call remains work until the parent call ends. +#[test] +fn an_open_model_call_is_work_until_it_completes() { + let mut tracker = WorkTracker::default(); + assert_eq!(signal(&mut tracker, &iteration_started()), Some("started")); + assert_eq!(signal(&mut tracker, &AgentProgress::TextDelta { + delta: "hi".into(), + iteration: 1, + }), None); + assert_eq!(signal(&mut tracker, &model_call_completed(None)), Some("finished")); +} + +#[test] +fn a_subagent_model_call_does_not_close_parent_work() { + let mut tracker = WorkTracker::default(); + signal(&mut tracker, &iteration_started()); + assert_eq!(signal(&mut tracker, &model_call_completed(Some("task-1"))), None); +} + +/// Parallel tool calls stay open until the last completion arrives. +#[test] +fn open_tool_calls_finish_only_after_the_last_call_completes() { + let mut tracker = WorkTracker::default(); + assert_eq!(signal(&mut tracker, &tool_started("a")), Some("started")); + assert_eq!(signal(&mut tracker, &tool_started("b")), None); + assert_eq!(signal(&mut tracker, &tool_completed("a")), None); + assert_eq!(signal(&mut tracker, &tool_completed("b")), Some("finished")); +} + +/// A tool start hands work over from the model call without an idle gap. +#[test] +fn tool_start_closes_its_model_call_without_finishing_work() { + let mut tracker = WorkTracker::default(); + signal(&mut tracker, &iteration_started()); + assert_eq!(signal(&mut tracker, &tool_started("a")), None); + assert_eq!(signal(&mut tracker, &tool_completed("a")), Some("finished")); +} + +/// The next iteration closes a tool whose completion was lost under backpressure. +#[test] +fn the_next_iteration_closes_a_tool_with_missing_completion() { + let mut tracker = WorkTracker::default(); + signal(&mut tracker, &tool_started("a")); + assert_eq!(signal(&mut tracker, &iteration_started()), None); + assert_eq!(signal(&mut tracker, &model_call_completed(None)), Some("finished")); +} + +#[test] +fn turn_completion_closes_all_open_work() { + let mut tracker = WorkTracker::default(); + signal(&mut tracker, &iteration_started()); + signal(&mut tracker, &tool_started("a")); + assert_eq!(signal(&mut tracker, &AgentProgress::turn_completed(1)), Some("finished")); +} + +#[test] +fn duplicate_starts_and_unknown_completions_do_not_emit_spurious_edges() { + let mut tracker = WorkTracker::default(); + assert_eq!(signal(&mut tracker, &tool_started("a")), Some("started")); + assert_eq!(signal(&mut tracker, &tool_started("a")), None); + assert_eq!(signal(&mut tracker, &tool_completed("missing")), None); + assert_eq!(signal(&mut tracker, &tool_completed("a")), Some("finished")); +} From 27009a1cd0fdba727be91b5e950ac13a8ad948ec Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 09:58:20 +0300 Subject: [PATCH 12/68] test(work_tracker): reformat assertions to satisfy line width Rustfmt-compliant wrapping applied to the signal assertions in the work tracker tests. No test behaviour changed. Auto-committed-on: dragonfly --- .../src/turn_runner/work_tracker_tests.rs | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/tinyagents-harness/src/turn_runner/work_tracker_tests.rs b/crates/tinyagents-harness/src/turn_runner/work_tracker_tests.rs index 8093f0e41..59395ec16 100644 --- a/crates/tinyagents-harness/src/turn_runner/work_tracker_tests.rs +++ b/crates/tinyagents-harness/src/turn_runner/work_tracker_tests.rs @@ -65,18 +65,30 @@ fn signal(tracker: &mut WorkTracker, event: &AgentProgress) -> Option<&'static s fn an_open_model_call_is_work_until_it_completes() { let mut tracker = WorkTracker::default(); assert_eq!(signal(&mut tracker, &iteration_started()), Some("started")); - assert_eq!(signal(&mut tracker, &AgentProgress::TextDelta { - delta: "hi".into(), - iteration: 1, - }), None); - assert_eq!(signal(&mut tracker, &model_call_completed(None)), Some("finished")); + assert_eq!( + signal( + &mut tracker, + &AgentProgress::TextDelta { + delta: "hi".into(), + iteration: 1, + } + ), + None + ); + assert_eq!( + signal(&mut tracker, &model_call_completed(None)), + Some("finished") + ); } #[test] fn a_subagent_model_call_does_not_close_parent_work() { let mut tracker = WorkTracker::default(); signal(&mut tracker, &iteration_started()); - assert_eq!(signal(&mut tracker, &model_call_completed(Some("task-1"))), None); + assert_eq!( + signal(&mut tracker, &model_call_completed(Some("task-1"))), + None + ); } /// Parallel tool calls stay open until the last completion arrives. @@ -104,7 +116,10 @@ fn the_next_iteration_closes_a_tool_with_missing_completion() { let mut tracker = WorkTracker::default(); signal(&mut tracker, &tool_started("a")); assert_eq!(signal(&mut tracker, &iteration_started()), None); - assert_eq!(signal(&mut tracker, &model_call_completed(None)), Some("finished")); + assert_eq!( + signal(&mut tracker, &model_call_completed(None)), + Some("finished") + ); } #[test] @@ -112,7 +127,10 @@ fn turn_completion_closes_all_open_work() { let mut tracker = WorkTracker::default(); signal(&mut tracker, &iteration_started()); signal(&mut tracker, &tool_started("a")); - assert_eq!(signal(&mut tracker, &AgentProgress::turn_completed(1)), Some("finished")); + assert_eq!( + signal(&mut tracker, &AgentProgress::turn_completed(1)), + Some("finished") + ); } #[test] From 4ac5a5a5413bfbddf1a36956e707a32bf15c95ae Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 10:00:00 +0300 Subject: [PATCH 13/68] test(work-tracker): cover parent work closing on model call completion Adds assertions that a model call completing without a task id closes the parent work, and that turn completion closes work when no tool is open. These cases were previously untested. Auto-committed-on: dragonfly --- .../src/turn_runner/work_tracker_tests.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/tinyagents-harness/src/turn_runner/work_tracker_tests.rs b/crates/tinyagents-harness/src/turn_runner/work_tracker_tests.rs index 59395ec16..4bf4602b5 100644 --- a/crates/tinyagents-harness/src/turn_runner/work_tracker_tests.rs +++ b/crates/tinyagents-harness/src/turn_runner/work_tracker_tests.rs @@ -89,6 +89,10 @@ fn a_subagent_model_call_does_not_close_parent_work() { signal(&mut tracker, &model_call_completed(Some("task-1"))), None ); + assert_eq!( + signal(&mut tracker, &model_call_completed(None)), + Some("finished") + ); } /// Parallel tool calls stay open until the last completion arrives. @@ -126,6 +130,11 @@ fn the_next_iteration_closes_a_tool_with_missing_completion() { fn turn_completion_closes_all_open_work() { let mut tracker = WorkTracker::default(); signal(&mut tracker, &iteration_started()); + assert_eq!( + signal(&mut tracker, &AgentProgress::turn_completed(1)), + Some("finished") + ); + signal(&mut tracker, &tool_started("a")); assert_eq!( signal(&mut tracker, &AgentProgress::turn_completed(1)), From 5622d75210aeec9079a01e04e64d08d61dee4fb2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 23:23:00 +0300 Subject: [PATCH 14/68] test(session): cover host metadata preservation for voice notes Adds a test asserting that a user message's extra metadata, including the voice-note duration, survives transcript projection so the host can render its voice-note row after reload. Auto-committed-on: dragonfly --- .../transcript/view/transcript_view_tests.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/tinyagents-session/src/transcript/view/transcript_view_tests.rs b/crates/tinyagents-session/src/transcript/view/transcript_view_tests.rs index 4154c668f..0d2949a85 100644 --- a/crates/tinyagents-session/src/transcript/view/transcript_view_tests.rs +++ b/crates/tinyagents-session/src/transcript/view/transcript_view_tests.rs @@ -147,6 +147,26 @@ fn projects_turn_with_tools_reasoning_and_sanitization() { } } +#[test] +fn user_message_display_item_preserves_host_metadata_for_voice_note_duration() { + let dir = TempDir::new().unwrap(); + let path = write_raw( + dir.path(), + "200_orchestrator", + "thr_voice_note", + &[r#"{"role":"user","content":"Remember the dentist","extra_metadata":{"voiceNote":{"noteId":"vn-1","durationMs":12000}}}"#], + ); + let display = read_transcript_display(&path).unwrap(); + let items = project_records(&display.records); + + let value = serde_json::to_value(&items[0]).unwrap(); + assert_eq!( + value["extraMetadata"]["voiceNote"]["durationMs"], + 12000, + "the host needs the recorded duration to render its voice-note row after reload" + ); +} + #[test] fn reuses_synthetic_tool_call_ids_in_a_later_turn() { let dir = TempDir::new().unwrap(); From 75cb5e8ac8fda98f558a8ac3015c9178b91a68fe Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 23:26:16 +0300 Subject: [PATCH 15/68] feat(transcript): expose extra_metadata on display items User and assistant display items now carry the opaque extra_metadata persisted on the host's message rows, so consumers of the projected transcript can read it without going back to the raw session. The field is optional and skipped during serialization when absent, keeping existing output unchanged. Auto-committed-on: dragonfly --- crates/tinyagents-session/src/transcript/view/project.rs | 3 +++ crates/tinyagents-session/src/transcript/view/types.rs | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/crates/tinyagents-session/src/transcript/view/project.rs b/crates/tinyagents-session/src/transcript/view/project.rs index efaa0707f..b6ae509a1 100644 --- a/crates/tinyagents-session/src/transcript/view/project.rs +++ b/crates/tinyagents-session/src/transcript/view/project.rs @@ -289,6 +289,7 @@ impl Projector { display_content: sanitized, request_id: msg.request_id.clone(), ts: msg.ts.clone(), + extra_metadata: msg.message.extra_metadata.clone(), }); } "assistant" => self.assistant(msg), @@ -304,6 +305,7 @@ impl Projector { model: msg.turn_usage.as_ref().map(|tu| tu.model.clone()), iteration: msg.iteration, ts: msg.ts.clone(), + extra_metadata: msg.message.extra_metadata.clone(), }); } } @@ -389,6 +391,7 @@ impl Projector { model: msg.turn_usage.as_ref().map(|tu| tu.model.clone()), iteration: Some(iteration), ts: msg.ts.clone(), + extra_metadata: msg.message.extra_metadata.clone(), }); } diff --git a/crates/tinyagents-session/src/transcript/view/types.rs b/crates/tinyagents-session/src/transcript/view/types.rs index 5a794f121..10d78dec8 100644 --- a/crates/tinyagents-session/src/transcript/view/types.rs +++ b/crates/tinyagents-session/src/transcript/view/types.rs @@ -94,6 +94,9 @@ pub enum DisplayItem { /// before timestamps were persisted — never backfilled. #[serde(skip_serializing_if = "Option::is_none")] ts: Option, + /// Opaque metadata persisted on the host's user-message row. + #[serde(skip_serializing_if = "Option::is_none")] + extra_metadata: Option, }, /// An assistant answer. `interim: true` marks a non-terminal tool-calling /// step within a multi-iteration turn (not the final answer bubble). @@ -109,6 +112,9 @@ pub enum DisplayItem { iteration: Option, #[serde(skip_serializing_if = "Option::is_none")] ts: Option, + /// Opaque metadata persisted on the host's assistant-message row. + #[serde(skip_serializing_if = "Option::is_none")] + extra_metadata: Option, }, /// The model's reasoning/thinking that preceded an assistant message. /// `iteration` is the model call it belongs to — the same value as the From 5f35bc1d20d0b7a3b8759d83696699922bb2d588 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 23:27:53 +0300 Subject: [PATCH 16/68] test(transcript): reformat voice note metadata test Reformatted the voice note duration test to satisfy rustfmt line wrapping, splitting the long JSON fixture into a multi-line slice and collapsing the assertion arguments onto one line. No test behaviour changed. Auto-committed-on: dragonfly --- .../src/transcript/view/transcript_view_tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-session/src/transcript/view/transcript_view_tests.rs b/crates/tinyagents-session/src/transcript/view/transcript_view_tests.rs index 0d2949a85..06428d97f 100644 --- a/crates/tinyagents-session/src/transcript/view/transcript_view_tests.rs +++ b/crates/tinyagents-session/src/transcript/view/transcript_view_tests.rs @@ -154,15 +154,16 @@ fn user_message_display_item_preserves_host_metadata_for_voice_note_duration() { dir.path(), "200_orchestrator", "thr_voice_note", - &[r#"{"role":"user","content":"Remember the dentist","extra_metadata":{"voiceNote":{"noteId":"vn-1","durationMs":12000}}}"#], + &[ + r#"{"role":"user","content":"Remember the dentist","extra_metadata":{"voiceNote":{"noteId":"vn-1","durationMs":12000}}}"#, + ], ); let display = read_transcript_display(&path).unwrap(); let items = project_records(&display.records); let value = serde_json::to_value(&items[0]).unwrap(); assert_eq!( - value["extraMetadata"]["voiceNote"]["durationMs"], - 12000, + value["extraMetadata"]["voiceNote"]["durationMs"], 12000, "the host needs the recorded duration to render its voice-note row after reload" ); } From 055b63629e6c17b1d78e77239fb67728837201b3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 23:31:58 +0300 Subject: [PATCH 17/68] chore(vendor): bump tinyinference and tinytools submodules Updates the pinned tinyinference and tinytools submodule commits to their latest revisions. Auto-committed-on: dragonfly --- vendor/tinyinference | 2 +- vendor/tinytools | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 5c413dcf0..e80449003 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 5c413dcf00d116f036d9099b226f6955e2aad14b +Subproject commit e804490039e9e83f4b10d8e8848c5a9e598230de diff --git a/vendor/tinytools b/vendor/tinytools index 201c56321..616408be3 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 201c563219549114da3dffcfc8ef75f3eb2e130c +Subproject commit 616408be32fe5fad8ad1e262149f40ec1056142b From 5044f29b2d37c7f7ccc677c7eaf8b6641bcfa428 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 23:34:58 +0300 Subject: [PATCH 18/68] chore(deps): bump tinystoragedrivers to v1.0.0 Update the tinystoragedrivers dependency to the v1.0.0 tag, which pulls in the new core crate version alongside the secrets driver. This also brings in transitive dependencies such as jobserver, ulid, and zstd required by the updated crates. Auto-committed-on: dragonfly --- Cargo.lock | 90 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 03914ac22..0eb148b3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,6 +164,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -1001,6 +1003,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -1950,7 +1962,7 @@ dependencies = [ "tinyagents-harness", "tinyagents-tasks", "tinyinference-llm", - "tinystoragedrivers-core", + "tinystoragedrivers-core 0.11.0", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -1985,7 +1997,7 @@ dependencies = [ "tinyinference-image", "tinyinference-llm", "tinyinference-video", - "tinystoragedrivers-core", + "tinystoragedrivers-core 0.11.0", "tinystoragedrivers-sqlite", "tinytools", "tinytools-agent", @@ -2017,7 +2029,7 @@ dependencies = [ "tinyagents-session", "tinyinference-embeddings", "tinyinference-llm", - "tinystoragedrivers-core", + "tinystoragedrivers-core 0.11.0", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2058,7 +2070,7 @@ dependencies = [ "tinyagents-session", "tinyagents-tasks", "tinyinference-llm", - "tinystoragedrivers-core", + "tinystoragedrivers-core 0.11.0", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2098,7 +2110,7 @@ dependencies = [ "tinyagents-registry", "tinyagents-session", "tinyinference-llm", - "tinystoragedrivers-core", + "tinystoragedrivers-core 0.11.0", "tinytools", "tokio", "tracing", @@ -2121,7 +2133,7 @@ dependencies = [ "tinyagents-harness", "tinyagents-session", "tinyagents-tasks", - "tinystoragedrivers-core", + "tinystoragedrivers-core 0.11.0", "tinystoragedrivers-sqlite", "tinytools-agent", "tokio", @@ -2140,7 +2152,7 @@ dependencies = [ "tempfile", "tinyagents-harness", "tinyinference-llm", - "tinystoragedrivers-core", + "tinystoragedrivers-core 0.11.0", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2187,7 +2199,7 @@ dependencies = [ "serde_json", "thiserror", "tinyinference-core", - "tinystoragedrivers-core", + "tinystoragedrivers-core 1.0.0", "tokio", "tracing", ] @@ -2270,10 +2282,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "tinystoragedrivers-core" +version = "1.0.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v1.0.0#141fd9ed0ad5d14a8fb5a1b3e837a719e81ac08d" +dependencies = [ + "async-trait", + "data-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "tokio", + "ulid", + "zstd", +] + [[package]] name = "tinystoragedrivers-secrets" -version = "0.11.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v0.11.0#9eeef54aea403ac07461a612189bdf4982782823" +version = "1.0.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v1.0.0#141fd9ed0ad5d14a8fb5a1b3e837a719e81ac08d" dependencies = [ "async-trait", "atomicwrites", @@ -2285,7 +2313,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "tinystoragedrivers-core", + "tinystoragedrivers-core 1.0.0", "tokio", "winapi-util", "windows-permissions", @@ -2300,7 +2328,7 @@ source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v0.11.0#9ee dependencies = [ "rusqlite", "serde_json", - "tinystoragedrivers-core", + "tinystoragedrivers-core 0.11.0", "tokio", ] @@ -2587,6 +2615,16 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "web-time", +] + [[package]] name = "unicase" version = "2.10.0" @@ -3221,3 +3259,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.2+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" +dependencies = [ + "cc", + "pkg-config", +] From 5e0ba12a458894a5aa3d00a84bad9cb2da4ad9db Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 23:35:17 +0300 Subject: [PATCH 19/68] chore(vendor): bump tinyinference and tinytools submodules Update the vendored tinyinference and tinytools submodules to their latest commits. Auto-committed-on: dragonfly --- vendor/tinyinference | 2 +- vendor/tinytools | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index e80449003..5c413dcf0 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit e804490039e9e83f4b10d8e8848c5a9e598230de +Subproject commit 5c413dcf00d116f036d9099b226f6955e2aad14b diff --git a/vendor/tinytools b/vendor/tinytools index 616408be3..201c56321 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 616408be32fe5fad8ad1e262149f40ec1056142b +Subproject commit 201c563219549114da3dffcfc8ef75f3eb2e130c From 66fdb0c6290c5e61584947bc20b79095cb268c99 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 23:35:36 +0300 Subject: [PATCH 20/68] chore(deps): downgrade tinystoragedrivers to v0.11.0 Pin tinystoragedrivers-core and tinystoragedrivers-secrets back to the v0.11.0 tag, collapsing the duplicate core versions into a single entry. This drops the transitive jobserver, ulid, and zstd dependencies that came with the v1.0.0 release. Auto-committed-on: dragonfly --- Cargo.lock | 90 ++++++++---------------------------------------------- 1 file changed, 12 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0eb148b3b..03914ac22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,8 +164,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -1003,16 +1001,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - [[package]] name = "js-sys" version = "0.3.103" @@ -1962,7 +1950,7 @@ dependencies = [ "tinyagents-harness", "tinyagents-tasks", "tinyinference-llm", - "tinystoragedrivers-core 0.11.0", + "tinystoragedrivers-core", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -1997,7 +1985,7 @@ dependencies = [ "tinyinference-image", "tinyinference-llm", "tinyinference-video", - "tinystoragedrivers-core 0.11.0", + "tinystoragedrivers-core", "tinystoragedrivers-sqlite", "tinytools", "tinytools-agent", @@ -2029,7 +2017,7 @@ dependencies = [ "tinyagents-session", "tinyinference-embeddings", "tinyinference-llm", - "tinystoragedrivers-core 0.11.0", + "tinystoragedrivers-core", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2070,7 +2058,7 @@ dependencies = [ "tinyagents-session", "tinyagents-tasks", "tinyinference-llm", - "tinystoragedrivers-core 0.11.0", + "tinystoragedrivers-core", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2110,7 +2098,7 @@ dependencies = [ "tinyagents-registry", "tinyagents-session", "tinyinference-llm", - "tinystoragedrivers-core 0.11.0", + "tinystoragedrivers-core", "tinytools", "tokio", "tracing", @@ -2133,7 +2121,7 @@ dependencies = [ "tinyagents-harness", "tinyagents-session", "tinyagents-tasks", - "tinystoragedrivers-core 0.11.0", + "tinystoragedrivers-core", "tinystoragedrivers-sqlite", "tinytools-agent", "tokio", @@ -2152,7 +2140,7 @@ dependencies = [ "tempfile", "tinyagents-harness", "tinyinference-llm", - "tinystoragedrivers-core 0.11.0", + "tinystoragedrivers-core", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2199,7 +2187,7 @@ dependencies = [ "serde_json", "thiserror", "tinyinference-core", - "tinystoragedrivers-core 1.0.0", + "tinystoragedrivers-core", "tokio", "tracing", ] @@ -2282,26 +2270,10 @@ dependencies = [ "tokio", ] -[[package]] -name = "tinystoragedrivers-core" -version = "1.0.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v1.0.0#141fd9ed0ad5d14a8fb5a1b3e837a719e81ac08d" -dependencies = [ - "async-trait", - "data-encoding", - "serde", - "serde_json", - "sha2 0.10.9", - "thiserror", - "tokio", - "ulid", - "zstd", -] - [[package]] name = "tinystoragedrivers-secrets" -version = "1.0.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v1.0.0#141fd9ed0ad5d14a8fb5a1b3e837a719e81ac08d" +version = "0.11.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v0.11.0#9eeef54aea403ac07461a612189bdf4982782823" dependencies = [ "async-trait", "atomicwrites", @@ -2313,7 +2285,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "tinystoragedrivers-core 1.0.0", + "tinystoragedrivers-core", "tokio", "winapi-util", "windows-permissions", @@ -2328,7 +2300,7 @@ source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v0.11.0#9ee dependencies = [ "rusqlite", "serde_json", - "tinystoragedrivers-core 0.11.0", + "tinystoragedrivers-core", "tokio", ] @@ -2615,16 +2587,6 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "ulid" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" -dependencies = [ - "rand 0.9.4", - "web-time", -] - [[package]] name = "unicase" version = "2.10.0" @@ -3259,31 +3221,3 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.1.2+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" -dependencies = [ - "cc", - "pkg-config", -] From 16e066ea955e6bc64643c6bf026781c38fb91dc1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 00:40:36 +0300 Subject: [PATCH 21/68] feat(session): support atomic imported history adoption --- .../src/port/drivers/transcripts.rs | 19 ++++ .../drivers/transcripts_adoption_tests.rs | 107 ++++++++++++++++++ .../src/testkit/in_memory_transcript.rs | 18 +++ .../src/transcript/history.rs | 29 +++++ 4 files changed, 173 insertions(+) diff --git a/crates/tinyagents-session/src/port/drivers/transcripts.rs b/crates/tinyagents-session/src/port/drivers/transcripts.rs index 131da838c..8daf4291f 100644 --- a/crates/tinyagents-session/src/port/drivers/transcripts.rs +++ b/crates/tinyagents-session/src/port/drivers/transcripts.rs @@ -733,6 +733,25 @@ impl TranscriptHistory for DriverTranscriptHistory { .map(|_| ()) } + fn adopt_if_empty(&self, messages: &[TranscriptMessage]) -> anyhow::Result { + let messages = normalized(messages.to_vec()); + if messages.is_empty() { + return Ok(false); + } + self.commit(move |replay, seed| { + if replay.written { + return Ok(None); + } + anyhow::ensure!(!replay.sealed, "transcript generation is sealed"); + Ok(Some(Entry { + written: true, + meta: Some(seed.clone()), + set: Some(messages.clone()), + ..Entry::default() + })) + }) + } + fn replace(&self, messages: &[TranscriptMessage]) -> anyhow::Result<()> { let messages = normalized(messages.to_vec()); self.commit(move |replay, seed| { diff --git a/crates/tinyagents-session/src/port/drivers/transcripts_adoption_tests.rs b/crates/tinyagents-session/src/port/drivers/transcripts_adoption_tests.rs index 21e9c0be6..0fce64b31 100644 --- a/crates/tinyagents-session/src/port/drivers/transcripts_adoption_tests.rs +++ b/crates/tinyagents-session/src/port/drivers/transcripts_adoption_tests.rs @@ -58,6 +58,113 @@ fn contents(locator: &dyn TranscriptLocator, session: &SessionRef) -> Vec = + Arc::clone(storage.for_scope(&Scope::local()).unwrap().documents()); + let locators = [ + Arc::new(DriverTranscriptLocator::new( + Arc::clone(&docs), + Blocking::new().unwrap(), + "memory://adoption-a", + )), + Arc::new(DriverTranscriptLocator::new( + Arc::clone(&docs), + Blocking::new().unwrap(), + "memory://adoption-b", + )), + ]; + let session = SessionRef::scoped("relay-race", "orchestrator"); + let barrier = Arc::new(std::sync::Barrier::new(2)); + let histories = [ + vec![TranscriptMessage::user("first import")], + vec![ + TranscriptMessage::user("second import"), + TranscriptMessage::assistant("second answer"), + ], + ]; + let results = std::thread::scope(|scope| { + let handles = histories + .into_iter() + .zip(locators) + .map(|(messages, locator)| { + let barrier = Arc::clone(&barrier); + let session = session.clone(); + scope.spawn(move || { + barrier.wait(); + locator + .adopt_messages_if_absent(&session, meta("relay-race"), &messages) + .unwrap() + }) + }) + .collect::>(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>() + }); + assert_eq!(results.iter().filter(|adopted| **adopted).count(), 1); + + let locator = + DriverTranscriptLocator::new(docs, Blocking::new().unwrap(), "memory://adoption-read"); + let persisted = contents(&locator, &session); + assert!( + persisted == vec!["first import"] || persisted == vec!["second import", "second answer"], + "a competing import must not leave a partial or mixed transcript: {persisted:?}" + ); +} + #[test] fn legacy_roots_are_folded_oldest_first_once() { let locator = locator(); diff --git a/crates/tinyagents-session/src/testkit/in_memory_transcript.rs b/crates/tinyagents-session/src/testkit/in_memory_transcript.rs index a6548a040..16318431b 100644 --- a/crates/tinyagents-session/src/testkit/in_memory_transcript.rs +++ b/crates/tinyagents-session/src/testkit/in_memory_transcript.rs @@ -271,6 +271,24 @@ impl TranscriptHistory for InMemoryTranscriptHistory { Ok(()) } + fn adopt_if_empty(&self, messages: &[TranscriptMessage]) -> anyhow::Result { + if messages.is_empty() { + return Ok(false); + } + let _gate = self + .generation_gate + .lock() + .unwrap_or_else(|e| e.into_inner()); + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + anyhow::ensure!(!state.sealed, "transcript generation is sealed"); + if state.written { + return Ok(false); + } + state.messages = normalized_rows(messages); + state.written = true; + Ok(true) + } + fn replace(&self, messages: &[TranscriptMessage]) -> anyhow::Result<()> { let _gate = self .generation_gate diff --git a/crates/tinyagents-session/src/transcript/history.rs b/crates/tinyagents-session/src/transcript/history.rs index d384a965c..2656577eb 100644 --- a/crates/tinyagents-session/src/transcript/history.rs +++ b/crates/tinyagents-session/src/transcript/history.rs @@ -134,6 +134,20 @@ pub trait TranscriptHistory: TranscriptRead { /// Appends one durable message while preserving all of its fields. fn append(&self, message: TranscriptMessage) -> anyhow::Result<()>; + /// Adopt imported model-context messages only while this transcript has + /// never been written. Returns `true` when this call installed the import + /// and `false` when a competing or earlier turn already owns the session. + /// + /// Store implementations must make the empty check and write atomic. + /// The default refuses the operation so a storage backend cannot silently + /// provide a racy import. + fn adopt_if_empty(&self, messages: &[TranscriptMessage]) -> anyhow::Result { + if messages.is_empty() { + return Ok(false); + } + anyhow::bail!("transcript store does not support atomic history adoption") + } + /// Replaces the logical model context by appending a compaction record. fn replace(&self, messages: &[TranscriptMessage]) -> anyhow::Result<()>; @@ -344,6 +358,21 @@ pub trait TranscriptLocator: Send + Sync { self.open_stem(&session_stem(session), seed) } + /// Atomically install imported history for a not-yet-written session. + /// A previously written session wins unchanged; concurrent importers + /// serialize through the bound transcript's append contract. + fn adopt_messages_if_absent( + &self, + session: &SessionRef, + seed: TranscriptMeta, + messages: &[TranscriptMessage], + ) -> anyhow::Result { + if messages.is_empty() { + return Ok(false); + } + self.open_session(session, seed)?.adopt_if_empty(messages) + } + /// Folds any pre-identity transcripts of `thread_id` into `session`, once. /// /// A conversation written before session identity existed is spread across From 971c3806156a1446e8ca0dab697f93787734fa22 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 01:39:55 +0300 Subject: [PATCH 22/68] fix(session): initialize imported transcript prefixes --- crates/tinyagents-runtime/src/session.rs | 2 + .../tinyagents-runtime/src/session_tests.rs | 202 +++++++++++++++++- crates/tinyagents-runtime/src/types.rs | 5 + 3 files changed, 206 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-runtime/src/session.rs b/crates/tinyagents-runtime/src/session.rs index ca63b9b9c..221dd6fdb 100644 --- a/crates/tinyagents-runtime/src/session.rs +++ b/crates/tinyagents-runtime/src/session.rs @@ -867,6 +867,8 @@ impl Session { history: &self.history, raw_history: &self.persisted, prefix: &self.prefix, + prefix_is_frozen: self.persisted_prefix_len.is_some() + || !self.prefix.messages().is_empty(), transcript_target: self.target.as_ref(), committed_turns: self.committed_turns, resumed, diff --git a/crates/tinyagents-runtime/src/session_tests.rs b/crates/tinyagents-runtime/src/session_tests.rs index e4afaebe4..197d7d36c 100644 --- a/crates/tinyagents-runtime/src/session_tests.rs +++ b/crates/tinyagents-runtime/src/session_tests.rs @@ -2,7 +2,7 @@ use super::*; use crate::test_store::{memory, read, seed}; use crate::{DriverFailure, DriverOutcome, SessionBuilder, TranscriptCodec}; use async_trait::async_trait; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use tinyagents_session::transcript::{ BackgroundAppend, BackgroundAppendOutcome, SessionRef, SessionTranscript, TranscriptMessage, TranscriptMeta, session_stem, @@ -43,6 +43,7 @@ impl TranscriptCodec for RoleCodec { .messages .iter() .map(|row| match row.role.as_str() { + "system" => Message::system(&row.content), "user" => Message::user(&row.content), _ => Message::assistant(&row.content), }) @@ -52,15 +53,31 @@ impl TranscriptCodec for RoleCodec { fn reconcile( &self, prior: &[TranscriptMessage], - _: &[Message], + previous: &[Message], next: &[Message], _: &crate::TranscriptTurnOptions, ) -> Result, RuntimeError> { let mut rows = prior.to_vec(); + let prior_system_count = prior.iter().take_while(|row| row.role == "system").count(); + let next_system_count = next + .iter() + .take_while(|message| matches!(message, Message::System(_))) + .count(); + if prior_system_count == 0 && next_system_count > 0 { + rows.splice( + 0..0, + next[..next_system_count] + .iter() + .map(|message| TranscriptMessage::new("system", message.text())), + ); + } rows.extend( - next[prior.len().min(next.len())..] + next[next_system_count + .saturating_add(previous.len()) + .min(next.len())..] .iter() .map(|message| match message { + Message::System(_) => TranscriptMessage::new("system", message.text()), Message::User(_) => TranscriptMessage::user(message.text()), _ => TranscriptMessage::assistant(message.text()), }), @@ -69,6 +86,185 @@ impl TranscriptCodec for RoleCodec { } } +struct CapturingDriver(Arc>>>); + +#[async_trait] +impl SessionDriver for CapturingDriver { + async fn execute(&self, request: DriverRequest) -> Result { + self.0.lock().unwrap().push(request.history.clone()); + let mut history = request.history; + history.push(Message::assistant("reply")); + Ok(DriverOutcome { + outcome: None, + history, + output: Some("reply".into()), + partial: None, + interrupted: false, + }) + } +} + +struct PrefixWhenUnfrozen(&'static str); + +#[async_trait] +impl SessionHooks for PrefixWhenUnfrozen { + async fn before_turn( + &self, + _: &mut SessionTurnRequest, + _: &mut TurnOptions, + state: SessionStateView<'_>, + ) -> Result { + Ok(TurnPreparation { + prefix: (!state.prefix_is_frozen) + .then(|| PrefixSnapshot::new(vec![Message::system(self.0)])), + ..TurnPreparation::default() + }) + } + + async fn on_terminal(&self, _: SessionTerminal) -> Result<(), RuntimeError> { + Ok(()) + } + + async fn before_commit( + &self, + _: &SessionTurnOutcome, + _: &TranscriptTurnOptions, + ) -> Result<(), RuntimeError> { + Ok(()) + } +} + +/// An atomic import may create a durable session before its first model turn. +/// The resumed history must still receive the host's initial prefix, while a +/// later process must reuse that committed prefix rather than its new prompt. +#[tokio::test] +async fn imported_history_gets_initial_prefix_before_the_first_turn() { + let locator = memory(); + let session_ref = SessionRef::scoped("relay-import", "agent"); + let prior = vec![ + TranscriptMessage::user("legacy question"), + TranscriptMessage::assistant("legacy answer"), + ]; + assert!( + locator + .adopt_messages_if_absent(&session_ref, meta(), &prior) + .unwrap() + ); + + let first_requests = Arc::new(Mutex::new(Vec::new())); + let mut first = SessionBuilder::new(Arc::new(CapturingDriver(first_requests.clone()))) + .codec(Arc::new(RoleCodec)) + .hooks(Arc::new(PrefixWhenUnfrozen("configured initial prompt"))) + .session(locator.clone(), session_ref.clone(), meta()) + .build() + .unwrap(); + first + .turn( + SessionTurnRequest::new(Message::user("current question")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(session_ref.clone()), + thread_id: Some("relay-import".into()), + ..TurnOptions::default() + }, + ) + .await + .unwrap(); + + let first_history = first_requests.lock().unwrap()[0].clone(); + assert!(matches!(&first_history[0], Message::System(_))); + assert_eq!(first_history[0].text(), "configured initial prompt"); + assert!(matches!(&first_history[1], Message::User(_))); + assert_eq!(first_history[1].text(), "legacy question"); + assert!(matches!(&first_history[2], Message::Assistant(_))); + assert_eq!(first_history[2].text(), "legacy answer"); + assert!(matches!(&first_history[3], Message::User(_))); + assert_eq!(first_history[3].text(), "current question"); + let head = locator.head_generation(&session_ref); + let persisted = locator + .read_session_transcript(&head) + .unwrap() + .read_session() + .unwrap() + .unwrap(); + assert_eq!(persisted.messages[0].role, "system"); + assert_eq!(persisted.messages[0].content, "configured initial prompt"); + + let second_requests = Arc::new(Mutex::new(Vec::new())); + let mut second = SessionBuilder::new(Arc::new(CapturingDriver(second_requests.clone()))) + .codec(Arc::new(RoleCodec)) + .hooks(Arc::new(PrefixWhenUnfrozen("replacement prompt"))) + .session(locator, session_ref.clone(), meta()) + .build() + .unwrap(); + second + .turn( + SessionTurnRequest::new(Message::user("after restart")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(session_ref), + thread_id: Some("relay-import".into()), + ..TurnOptions::default() + }, + ) + .await + .unwrap(); + let second_history = second_requests.lock().unwrap()[0].clone(); + assert!(matches!(&second_history[0], Message::System(_))); + assert_eq!(second_history[0].text(), "configured initial prompt"); + assert!(matches!(&second_history[1], Message::User(_))); + assert_eq!(second_history[1].text(), "legacy question"); + assert!(matches!(&second_history[2], Message::Assistant(_))); + assert_eq!(second_history[2].text(), "legacy answer"); + assert!( + matches!(&second_history[3], Message::User(_)), + "resumed request history: {:?}", + second_history.iter().map(Message::text).collect::>() + ); + assert_eq!(second_history[3].text(), "current question"); + assert!(matches!(second_history.last(), Some(Message::User(_)))); + assert_eq!(second_history.last().unwrap().text(), "after restart"); +} + +#[tokio::test] +async fn an_explicit_empty_frozen_prefix_stays_empty() { + let locator = memory(); + let session_ref = SessionRef::scoped("empty-prefix", "agent"); + let mut explicit_empty_prefix = meta(); + explicit_empty_prefix.prefix_message_count = Some(0); + seed( + locator.as_ref(), + &session_stem(&session_ref), + &[TranscriptMessage::user("existing message")], + &explicit_empty_prefix, + ); + + let requests = Arc::new(Mutex::new(Vec::new())); + let mut session = SessionBuilder::new(Arc::new(CapturingDriver(requests.clone()))) + .codec(Arc::new(RoleCodec)) + .hooks(Arc::new(PrefixWhenUnfrozen("must remain unused"))) + .session(locator, session_ref.clone(), explicit_empty_prefix) + .build() + .unwrap(); + session + .turn( + SessionTurnRequest::new(Message::user("next message")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(session_ref), + thread_id: Some("empty-prefix".into()), + ..TurnOptions::default() + }, + ) + .await + .unwrap(); + assert!( + requests.lock().unwrap()[0] + .iter() + .all(|message| !matches!(message, Message::System(_))) + ); +} + /// Answers each turn with `reply`, but only after the test releases it. struct GatedDriver { started: Arc, diff --git a/crates/tinyagents-runtime/src/types.rs b/crates/tinyagents-runtime/src/types.rs index 0b096ca2d..d48ee225c 100644 --- a/crates/tinyagents-runtime/src/types.rs +++ b/crates/tinyagents-runtime/src/types.rs @@ -235,6 +235,11 @@ pub struct SessionStateView<'a> { pub history: &'a [Message], pub raw_history: &'a [TranscriptMessage], pub prefix: &'a PrefixSnapshot, + /// Whether the loaded transcript already has an authoritative prefix. + /// A durable transcript can contain imported conversation rows before its + /// first model turn; such a session is resumed for history purposes but + /// still needs the host to prepare and commit its initial prompt. + pub prefix_is_frozen: bool, pub transcript_target: Option<&'a TranscriptTarget>, pub committed_turns: usize, /// `true` only when this call loaded and decoded a durable transcript From 27ad102f9f38a0fe1af22148d6157c79f8db86a9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 03:18:35 +0300 Subject: [PATCH 23/68] feat(session): add reset_history to start a fresh resumable context Adds Session::reset_history, which seals the current generation and begins an empty successor parented to it so a durable session can be reset without losing its lineage. Resetting an empty or absent session is a no-op, avoiding timestamped identities or unbounded chains of empty generations. Auto-committed-on: dragonfly --- crates/tinyagents-runtime/src/session.rs | 58 ++++++ .../tinyagents-runtime/src/session_tests.rs | 167 ++++++++++++++++++ 2 files changed, 225 insertions(+) diff --git a/crates/tinyagents-runtime/src/session.rs b/crates/tinyagents-runtime/src/session.rs index 221dd6fdb..38948da94 100644 --- a/crates/tinyagents-runtime/src/session.rs +++ b/crates/tinyagents-runtime/src/session.rs @@ -492,6 +492,64 @@ impl Session { }) } + /// Starts a fresh model context for the bound durable session. + /// + /// The current generation is sealed and retained as the parent of an + /// empty successor. Repeating this operation on an empty or absent + /// session is a no-op, so it never manufactures a timestamped identity or + /// an unbounded chain of empty generations. + pub async fn reset_history(&mut self) -> Result { + let Some(target) = self.target.as_ref() else { + return Ok(false); + }; + let Some(bound) = target.session.as_ref() else { + return Ok(false); + }; + + // Generation locks are keyed by the first generation, so resetting a + // current head serializes with turns bound to either the root or head. + let root = bound.first_generation(); + let _turn = lock_session_turn(target.locator.as_ref(), &root).await; + let head = target.locator.head_generation(bound); + let Some(read) = target.locator.read_session_transcript(&head) else { + return Ok(false); + }; + let transcript = read + .read_session() + .map_err(|error| RuntimeError::Persistence(error.to_string()))?; + let Some(transcript) = transcript else { + return Ok(false); + }; + if transcript.messages.is_empty() + && transcript.meta.prefix_message_count.is_none() + && transcript.tools.is_none() + && transcript.meta.turn_count == 0 + { + return Ok(false); + } + + let (successor, handle) = target + .locator + .begin_generation_from_baseline(&head, transcript.meta.clone(), &transcript.messages) + .map_err(|error| RuntimeError::Persistence(error.to_string()))?; + + let target = self.target.as_mut().expect("target was checked above"); + target.session = Some(successor.clone()); + target.meta.session_id = Some(successor.session_id()); + target.meta.parent_session_id = successor.parent_session_id(); + target.meta.turn_count = 0; + target.meta.prefix_message_count = None; + self.transcript = Some(handle); + self.prefix = PrefixSnapshot::default(); + self.history.clear(); + self.persisted.clear(); + self.persisted_prefix_len = None; + self.recorded_tools = None; + self.recorded_tools_json = None; + self.committed_turns = 0; + Ok(true) + } + /// Runs one user turn: wraps `input` in a [`SessionTurnRequest`] and drives /// [`Session::turn`], the single entry hosts call instead of owning a turn /// loop. Host policy rides in `options` and the session's hooks. diff --git a/crates/tinyagents-runtime/src/session_tests.rs b/crates/tinyagents-runtime/src/session_tests.rs index 197d7d36c..70b1044c5 100644 --- a/crates/tinyagents-runtime/src/session_tests.rs +++ b/crates/tinyagents-runtime/src/session_tests.rs @@ -134,6 +134,173 @@ impl SessionHooks for PrefixWhenUnfrozen { } } +#[tokio::test] +async fn reset_history_seals_old_turns_and_starts_a_fresh_resumable_context() { + let locator = memory(); + let session_ref = SessionRef::scoped("reset-thread", "agent-id"); + let first_requests = Arc::new(Mutex::new(Vec::new())); + let mut session = SessionBuilder::new(Arc::new(CapturingDriver(first_requests))) + .codec(Arc::new(RoleCodec)) + .hooks(Arc::new(PrefixWhenUnfrozen("original prompt"))) + .session(locator.clone(), session_ref.clone(), meta()) + .build() + .unwrap(); + session + .turn( + SessionTurnRequest::new(Message::user("before reset")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(session_ref.clone()), + ..TurnOptions::default() + }, + ) + .await + .unwrap(); + + let old_head = locator.head_generation(&session_ref); + let old_before = locator + .read_session_transcript(&old_head) + .unwrap() + .read_session() + .unwrap() + .unwrap(); + assert_eq!(old_before.messages[0].content, "original prompt"); + assert!(session.reset_history().await.unwrap()); + + let new_head = locator.head_generation(&session_ref); + assert_eq!(new_head.generation, old_head.generation + 1); + assert_eq!(new_head.parent_session_id(), Some(old_head.session_id())); + let old_after = locator + .read_session_transcript(&old_head) + .unwrap() + .read_session() + .unwrap() + .unwrap(); + assert_eq!(format!("{old_after:?}"), format!("{old_before:?}")); + assert!( + locator + .read_session_transcript(&new_head) + .unwrap() + .read_session() + .unwrap() + .unwrap() + .messages + .is_empty() + ); + + assert!(!session.reset_history().await.unwrap()); + assert_eq!(locator.head_generation(&session_ref), new_head); + + let reset_requests = Arc::new(Mutex::new(Vec::new())); + let mut after_reset = SessionBuilder::new(Arc::new(CapturingDriver(reset_requests.clone()))) + .codec(Arc::new(RoleCodec)) + .hooks(Arc::new(PrefixWhenUnfrozen("fresh prompt"))) + .session(locator.clone(), session_ref.clone(), meta()) + .build() + .unwrap(); + after_reset + .turn( + SessionTurnRequest::new(Message::user("after reset")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(session_ref.clone()), + ..TurnOptions::default() + }, + ) + .await + .unwrap(); + let reset_request = reset_requests.lock().unwrap()[0].clone(); + assert_eq!(reset_request[0].text(), "fresh prompt"); + assert_eq!(reset_request[1].text(), "after reset"); + assert!( + !reset_request + .iter() + .any(|message| message.text() == "before reset") + ); + + let resumed_requests = Arc::new(Mutex::new(Vec::new())); + let mut resumed = SessionBuilder::new(Arc::new(CapturingDriver(resumed_requests.clone()))) + .codec(Arc::new(RoleCodec)) + .hooks(Arc::new(PrefixWhenUnfrozen("replacement prompt"))) + .session(locator.clone(), session_ref.clone(), meta()) + .build() + .unwrap(); + resumed + .turn( + SessionTurnRequest::new(Message::user("cold resume")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(session_ref.clone()), + ..TurnOptions::default() + }, + ) + .await + .unwrap(); + let cold_request = resumed_requests.lock().unwrap()[0].clone(); + assert_eq!(cold_request[0].text(), "fresh prompt"); + assert!( + !cold_request + .iter() + .any(|message| message.text() == "replacement prompt") + ); + assert!( + !cold_request + .iter() + .any(|message| message.text() == "before reset") + ); +} + +#[tokio::test] +async fn reset_history_advances_an_explicit_frozen_empty_prefix() { + let locator = memory(); + let session_ref = SessionRef::scoped("empty-prefix-reset", "agent-id"); + let mut empty_prefix_meta = meta(); + empty_prefix_meta.prefix_message_count = Some(0); + locator + .begin_generation(&session_ref, empty_prefix_meta) + .unwrap(); + let mut session = + SessionBuilder::new(Arc::new(CapturingDriver(Arc::new(Mutex::new(Vec::new()))))) + .codec(Arc::new(RoleCodec)) + .session(locator.clone(), session_ref.clone(), meta()) + .build() + .unwrap(); + + assert!(session.reset_history().await.unwrap()); + let reset_head = locator.head_generation(&session_ref); + assert_eq!(reset_head.generation, 1); + assert_eq!( + locator + .read_session_transcript(&reset_head) + .unwrap() + .read_session() + .unwrap() + .unwrap() + .meta + .prefix_message_count, + None + ); + + let requests = Arc::new(Mutex::new(Vec::new())); + let mut cold = SessionBuilder::new(Arc::new(CapturingDriver(requests.clone()))) + .codec(Arc::new(RoleCodec)) + .hooks(Arc::new(PrefixWhenUnfrozen("after empty reset"))) + .session(locator, session_ref.clone(), meta()) + .build() + .unwrap(); + cold.turn( + SessionTurnRequest::new(Message::user("new turn")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(session_ref), + ..TurnOptions::default() + }, + ) + .await + .unwrap(); + assert_eq!(requests.lock().unwrap()[0][0].text(), "after empty reset"); +} + /// An atomic import may create a durable session before its first model turn. /// The resumed history must still receive the host's initial prefix, while a /// later process must reuse that committed prefix rather than its new prompt. From 10e3c1ba1cba2f931864ae79e189e88ca4ea6cdb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 03:22:22 +0300 Subject: [PATCH 24/68] fix(session): persist empty successor on history reset A history reset now writes an explicit empty context for the new generation so a restart before the next user turn still resolves the successor as the head. The fresh empty head also no longer inherits the sealed generation's prompt boundary, while an explicit Some(0) remains authoritative. Auto-committed-on: dragonfly --- crates/tinyagents-runtime/src/session.rs | 27 +++++++++++++++++-- .../tinyagents-runtime/src/session_tests.rs | 5 ++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/tinyagents-runtime/src/session.rs b/crates/tinyagents-runtime/src/session.rs index 38948da94..9522db5b2 100644 --- a/crates/tinyagents-runtime/src/session.rs +++ b/crates/tinyagents-runtime/src/session.rs @@ -243,8 +243,20 @@ impl Session { .as_ref() .is_some_and(|session| session.generation > 0) || transcript.meta.parent_session_id.is_some(); + // A durable empty successor with no committed turns is the fresh head + // created by an explicit history reset. Do not inherit the sealed + // generation's prompt boundary into it. An explicit Some(0) on this + // head remains authoritative because `recorded_boundary` is present. + let reset_empty_head = compacted_head + && transcript.messages.is_empty() + && transcript.meta.turn_count == 0 + && recorded_boundary.is_none(); let mut recovered_boundary = None; - if recorded_boundary.is_none() && cached_boundary.is_none() && compacted_head { + if recorded_boundary.is_none() + && cached_boundary.is_none() + && compacted_head + && !reset_empty_head + { // Without the sealed root there is no safe boundary in a head // containing a System summary. If a replacement prefix was // supplied, fail rather than replaying unverifiable old System @@ -528,9 +540,20 @@ impl Session { return Ok(false); } + let mut seed = transcript.meta.clone(); + seed.turn_count = 0; + seed.prefix_message_count = None; + seed.session_id = None; + seed.parent_session_id = None; let (successor, handle) = target .locator - .begin_generation_from_baseline(&head, transcript.meta.clone(), &transcript.messages) + .begin_generation_from_baseline(&head, seed, &transcript.messages) + .map_err(|error| RuntimeError::Persistence(error.to_string()))?; + // Generation allocation reserves the successor but leaves it + // unwritten. Persist an explicit empty context now so a process restart + // before the next user turn still resolves the successor as the head. + handle + .replace(&[]) .map_err(|error| RuntimeError::Persistence(error.to_string()))?; let target = self.target.as_mut().expect("target was checked above"); diff --git a/crates/tinyagents-runtime/src/session_tests.rs b/crates/tinyagents-runtime/src/session_tests.rs index 70b1044c5..6e6b6fceb 100644 --- a/crates/tinyagents-runtime/src/session_tests.rs +++ b/crates/tinyagents-runtime/src/session_tests.rs @@ -256,9 +256,10 @@ async fn reset_history_advances_an_explicit_frozen_empty_prefix() { let session_ref = SessionRef::scoped("empty-prefix-reset", "agent-id"); let mut empty_prefix_meta = meta(); empty_prefix_meta.prefix_message_count = Some(0); - locator - .begin_generation(&session_ref, empty_prefix_meta) + let empty_head = locator + .open_stem(&session_stem(&session_ref), empty_prefix_meta) .unwrap(); + empty_head.replace(&[]).unwrap(); let mut session = SessionBuilder::new(Arc::new(CapturingDriver(Arc::new(Mutex::new(Vec::new()))))) .codec(Arc::new(RoleCodec)) From a46da23023800bf717de18bf8619dac12e8dfc6b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 03:29:07 +0300 Subject: [PATCH 25/68] fix(session): persist reset prefix state --- crates/tinyagents-runtime/src/lib_tests.rs | 1 + crates/tinyagents-runtime/src/session.rs | 20 +- .../tinyagents-runtime/src/session_tests.rs | 183 +++++++++++++++++- crates/tinyagents-runtime/src/test_store.rs | 1 + .../src/legacy/files_tests.rs | 1 + .../src/legacy/rewrite_tests.rs | 1 + .../src/legacy/transcripts_once_tests.rs | 1 + .../src/port/drivers/transcripts.rs | 1 + .../src/port/memory/mod_tests.rs | 2 + .../src/port/workspace_tests.rs | 1 + .../src/testkit/conformance.rs | 1 + .../src/transcript/background_tests.rs | 1 + .../src/transcript/history.rs | 1 + .../src/transcript/jsonl.rs | 4 + .../src/transcript/legacy_md.rs | 1 + .../src/transcript/spend_tests.rs | 1 + .../src/transcript/transcript_tests.rs | 15 ++ .../src/transcript/typed_rows_tests.rs | 1 + .../src/transcript/types.rs | 5 + .../view/transcript_ordering_tests.rs | 1 + .../transcript/view/transcript_view_tests.rs | 1 + .../src/turn_state/mirror/mod_tests.rs | 1 + 22 files changed, 231 insertions(+), 14 deletions(-) diff --git a/crates/tinyagents-runtime/src/lib_tests.rs b/crates/tinyagents-runtime/src/lib_tests.rs index 766e88ef1..4aa3d7b66 100644 --- a/crates/tinyagents-runtime/src/lib_tests.rs +++ b/crates/tinyagents-runtime/src/lib_tests.rs @@ -130,6 +130,7 @@ fn meta() -> TranscriptMeta { updated: "then".into(), turn_count: 0, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, diff --git a/crates/tinyagents-runtime/src/session.rs b/crates/tinyagents-runtime/src/session.rs index 9522db5b2..31264ec61 100644 --- a/crates/tinyagents-runtime/src/session.rs +++ b/crates/tinyagents-runtime/src/session.rs @@ -243,19 +243,12 @@ impl Session { .as_ref() .is_some_and(|session| session.generation > 0) || transcript.meta.parent_session_id.is_some(); - // A durable empty successor with no committed turns is the fresh head - // created by an explicit history reset. Do not inherit the sealed - // generation's prompt boundary into it. An explicit Some(0) on this - // head remains authoritative because `recorded_boundary` is present. - let reset_empty_head = compacted_head - && transcript.messages.is_empty() - && transcript.meta.turn_count == 0 - && recorded_boundary.is_none(); + // An explicit history reset suppresses prompt-boundary recovery from + // the sealed parent. The marker distinguishes it from an ordinary + // empty successor whose legacy prompt still needs to be inferred. + let reset_head = transcript.meta.prefix_reset && recorded_boundary.is_none(); let mut recovered_boundary = None; - if recorded_boundary.is_none() - && cached_boundary.is_none() - && compacted_head - && !reset_empty_head + if recorded_boundary.is_none() && cached_boundary.is_none() && compacted_head && !reset_head { // Without the sealed root there is no safe boundary in a head // containing a System summary. If a replacement prefix was @@ -543,6 +536,7 @@ impl Session { let mut seed = transcript.meta.clone(); seed.turn_count = 0; seed.prefix_message_count = None; + seed.prefix_reset = true; seed.session_id = None; seed.parent_session_id = None; let (successor, handle) = target @@ -562,6 +556,7 @@ impl Session { target.meta.parent_session_id = successor.parent_session_id(); target.meta.turn_count = 0; target.meta.prefix_message_count = None; + target.meta.prefix_reset = true; self.transcript = Some(handle); self.prefix = PrefixSnapshot::default(); self.history.clear(); @@ -1085,6 +1080,7 @@ impl Session { }; meta.turn_count += 1; meta.prefix_message_count = Some(prefix_len); + meta.prefix_reset = false; meta.updated = chrono::Utc::now().to_rfc3339(); // Record every ordinary turn's declarations. Comparing against this // session's cached snapshot is unsafe when another live Session has diff --git a/crates/tinyagents-runtime/src/session_tests.rs b/crates/tinyagents-runtime/src/session_tests.rs index 6e6b6fceb..094439ea6 100644 --- a/crates/tinyagents-runtime/src/session_tests.rs +++ b/crates/tinyagents-runtime/src/session_tests.rs @@ -25,6 +25,7 @@ fn meta() -> TranscriptMeta { updated: "then".into(), turn_count: 0, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, @@ -176,7 +177,10 @@ async fn reset_history_seals_old_turns_and_starts_a_fresh_resumable_context() { .read_session() .unwrap() .unwrap(); - assert_eq!(format!("{old_after:?}"), format!("{old_before:?}")); + assert_eq!( + serde_json::to_value((&old_after.meta, &old_after.messages, &old_after.tools,)).unwrap(), + serde_json::to_value((&old_before.meta, &old_before.messages, &old_before.tools,)).unwrap(), + ); assert!( locator .read_session_transcript(&new_head) @@ -279,7 +283,17 @@ async fn reset_history_advances_an_explicit_frozen_empty_prefix() { .unwrap() .meta .prefix_message_count, - None + None, + ); + assert!( + locator + .read_session_transcript(&reset_head) + .unwrap() + .read_session() + .unwrap() + .unwrap() + .meta + .prefix_reset ); let requests = Arc::new(Mutex::new(Vec::new())); @@ -302,6 +316,171 @@ async fn reset_history_advances_an_explicit_frozen_empty_prefix() { assert_eq!(requests.lock().unwrap()[0][0].text(), "after empty reset"); } +#[tokio::test] +async fn an_empty_compaction_without_reset_marker_recovers_explicit_parent_boundary() { + let locator = memory(); + let session_ref = SessionRef::scoped("empty-compaction", "agent-id"); + let mut root_meta = meta(); + root_meta.prefix_message_count = Some(0); + let root = locator + .open_stem(&session_stem(&session_ref), root_meta) + .unwrap(); + root.replace(&[]).unwrap(); + let (successor, empty_head) = locator + .begin_generation_from_baseline(&session_ref, meta(), &[]) + .unwrap(); + empty_head.replace(&[]).unwrap(); + + let requests = Arc::new(Mutex::new(Vec::new())); + let mut resumed = SessionBuilder::new(Arc::new(CapturingDriver(requests.clone()))) + .codec(Arc::new(RoleCodec)) + .hooks(Arc::new(PrefixWhenUnfrozen( + "must not replace frozen empty", + ))) + .session(locator, session_ref.clone(), meta()) + .build() + .unwrap(); + resumed + .turn( + SessionTurnRequest::new(Message::user("after compaction")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(session_ref), + ..TurnOptions::default() + }, + ) + .await + .unwrap(); + let request = requests.lock().unwrap()[0].clone(); + assert_eq!(request.len(), 1); + assert_eq!(request[0].text(), "after compaction"); + assert_eq!(successor.generation, 1); +} + +#[tokio::test] +async fn reset_waits_for_an_in_flight_append_before_sealing_the_head() { + let locator = memory(); + let session_ref = SessionRef::scoped("reset-race", "agent-id"); + seed( + locator.as_ref(), + &session_stem(&session_ref), + &[ + TranscriptMessage::user("before concurrent turn"), + TranscriptMessage::assistant("old answer"), + ], + &meta(), + ); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let mut turning = SessionBuilder::new(Arc::new(GatedDriver { + started: started.clone(), + release: release.clone(), + })) + .codec(Arc::new(RoleCodec)) + .session(locator.clone(), session_ref.clone(), meta()) + .build() + .unwrap(); + let turn_ref = session_ref.clone(); + let turn = tokio::spawn(async move { + turning + .turn( + SessionTurnRequest::new(Message::user("commits before reset")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(turn_ref), + ..TurnOptions::default() + }, + ) + .await + }); + started.notified().await; + + let reset_ref = SessionRef::scoped("reset-race", "agent-id"); + let mut resetting = + SessionBuilder::new(Arc::new(CapturingDriver(Arc::new(Mutex::new(Vec::new()))))) + .codec(Arc::new(RoleCodec)) + .session(locator.clone(), reset_ref, meta()) + .build() + .unwrap(); + let reset = tokio::spawn(async move { resetting.reset_history().await }); + tokio::task::yield_now().await; + assert!( + !reset.is_finished(), + "reset bypassed the active session turn lock" + ); + + release.notify_one(); + turn.await.unwrap().unwrap(); + assert!(reset.await.unwrap().unwrap()); + let old = locator + .read_session_transcript(&session_ref) + .unwrap() + .read_session() + .unwrap() + .unwrap(); + assert!( + old.messages + .iter() + .any(|message| message.content == "commits before reset") + ); + assert_eq!(locator.head_generation(&session_ref).generation, 1); +} + +#[tokio::test] +async fn reset_does_not_create_an_absent_session_or_cross_agent_boundary() { + let locator = memory(); + let absent_ref = SessionRef::scoped("absent-reset", "agent-a"); + let mut absent = + SessionBuilder::new(Arc::new(CapturingDriver(Arc::new(Mutex::new(Vec::new()))))) + .codec(Arc::new(RoleCodec)) + .session(locator.clone(), absent_ref.clone(), meta()) + .build() + .unwrap(); + assert!(!absent.reset_history().await.unwrap()); + assert!(locator.read_session_transcript(&absent_ref).is_none()); + + let first_ref = SessionRef::scoped("same-thread", "agent-a"); + let other_agent_ref = SessionRef::scoped("same-thread", "agent-b"); + seed( + locator.as_ref(), + &session_stem(&first_ref), + &[ + TranscriptMessage::user("first agent"), + TranscriptMessage::assistant("answer"), + ], + &meta(), + ); + seed( + locator.as_ref(), + &session_stem(&other_agent_ref), + &[ + TranscriptMessage::user("other agent"), + TranscriptMessage::assistant("answer"), + ], + &meta(), + ); + let mut first = + SessionBuilder::new(Arc::new(CapturingDriver(Arc::new(Mutex::new(Vec::new()))))) + .codec(Arc::new(RoleCodec)) + .session(locator.clone(), first_ref.clone(), meta()) + .build() + .unwrap(); + assert!(first.reset_history().await.unwrap()); + assert_eq!(locator.head_generation(&first_ref).generation, 1); + assert_eq!(locator.head_generation(&other_agent_ref).generation, 0); + assert!( + locator + .read_session_transcript(&other_agent_ref) + .unwrap() + .read_session() + .unwrap() + .unwrap() + .messages + .iter() + .any(|message| message.content == "other agent") + ); +} + /// An atomic import may create a durable session before its first model turn. /// The resumed history must still receive the host's initial prefix, while a /// later process must reuse that committed prefix rather than its new prompt. diff --git a/crates/tinyagents-runtime/src/test_store.rs b/crates/tinyagents-runtime/src/test_store.rs index e31e22c6d..60cf221f3 100644 --- a/crates/tinyagents-runtime/src/test_store.rs +++ b/crates/tinyagents-runtime/src/test_store.rs @@ -82,6 +82,7 @@ fn placeholder_meta() -> TranscriptMeta { updated: String::new(), turn_count: 0, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, diff --git a/crates/tinyagents-session/src/legacy/files_tests.rs b/crates/tinyagents-session/src/legacy/files_tests.rs index 33827317b..033425560 100644 --- a/crates/tinyagents-session/src/legacy/files_tests.rs +++ b/crates/tinyagents-session/src/legacy/files_tests.rs @@ -26,6 +26,7 @@ fn meta(thread: &str, turns: usize) -> TranscriptMeta { updated: "2026-01-01T00:00:00Z".into(), turn_count: turns, prefix_message_count: None, + prefix_reset: false, input_tokens: 10, output_tokens: 20, cached_input_tokens: 0, diff --git a/crates/tinyagents-session/src/legacy/rewrite_tests.rs b/crates/tinyagents-session/src/legacy/rewrite_tests.rs index 408cfaf1d..3b5115140 100644 --- a/crates/tinyagents-session/src/legacy/rewrite_tests.rs +++ b/crates/tinyagents-session/src/legacy/rewrite_tests.rs @@ -13,6 +13,7 @@ fn meta() -> TranscriptMeta { updated: "2026-01-01T00:00:00Z".into(), turn_count: 1, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, diff --git a/crates/tinyagents-session/src/legacy/transcripts_once_tests.rs b/crates/tinyagents-session/src/legacy/transcripts_once_tests.rs index e55bc6831..4c53c9fa6 100644 --- a/crates/tinyagents-session/src/legacy/transcripts_once_tests.rs +++ b/crates/tinyagents-session/src/legacy/transcripts_once_tests.rs @@ -21,6 +21,7 @@ fn meta() -> TranscriptMeta { updated: "2026-01-01T00:00:00Z".into(), turn_count: 1, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, diff --git a/crates/tinyagents-session/src/port/drivers/transcripts.rs b/crates/tinyagents-session/src/port/drivers/transcripts.rs index 8daf4291f..4f10e991b 100644 --- a/crates/tinyagents-session/src/port/drivers/transcripts.rs +++ b/crates/tinyagents-session/src/port/drivers/transcripts.rs @@ -985,6 +985,7 @@ fn discovered_seed(stem: &str) -> TranscriptMeta { updated: String::new(), turn_count: 0, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, diff --git a/crates/tinyagents-session/src/port/memory/mod_tests.rs b/crates/tinyagents-session/src/port/memory/mod_tests.rs index a786abbaa..d8dff2035 100644 --- a/crates/tinyagents-session/src/port/memory/mod_tests.rs +++ b/crates/tinyagents-session/src/port/memory/mod_tests.rs @@ -16,6 +16,7 @@ fn test_meta(thread_id: &str) -> TranscriptMeta { updated: "2026-01-01T00:00:00Z".to_string(), turn_count: 0, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, @@ -151,6 +152,7 @@ fn the_newest_root_wins_and_sub_agents_are_never_roots() { updated: "2026-01-01T00:00:00Z".to_string(), turn_count: 0, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, diff --git a/crates/tinyagents-session/src/port/workspace_tests.rs b/crates/tinyagents-session/src/port/workspace_tests.rs index 01561fe92..888e94308 100644 --- a/crates/tinyagents-session/src/port/workspace_tests.rs +++ b/crates/tinyagents-session/src/port/workspace_tests.rs @@ -18,6 +18,7 @@ fn meta() -> TranscriptMeta { updated: "2026-01-01T00:00:00Z".into(), turn_count: 1, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, diff --git a/crates/tinyagents-session/src/testkit/conformance.rs b/crates/tinyagents-session/src/testkit/conformance.rs index f99ca6b65..428641630 100644 --- a/crates/tinyagents-session/src/testkit/conformance.rs +++ b/crates/tinyagents-session/src/testkit/conformance.rs @@ -324,6 +324,7 @@ fn contract_meta() -> TranscriptMeta { updated: "2026-01-01T00:00:00Z".to_string(), turn_count: 0, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, diff --git a/crates/tinyagents-session/src/transcript/background_tests.rs b/crates/tinyagents-session/src/transcript/background_tests.rs index 45c4528cb..e433706c4 100644 --- a/crates/tinyagents-session/src/transcript/background_tests.rs +++ b/crates/tinyagents-session/src/transcript/background_tests.rs @@ -20,6 +20,7 @@ fn meta() -> TranscriptMeta { updated: "2026-10-06T00:00:00Z".into(), turn_count: 1, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, diff --git a/crates/tinyagents-session/src/transcript/history.rs b/crates/tinyagents-session/src/transcript/history.rs index 2656577eb..3611b4cd5 100644 --- a/crates/tinyagents-session/src/transcript/history.rs +++ b/crates/tinyagents-session/src/transcript/history.rs @@ -625,6 +625,7 @@ pub(super) fn seed_meta_for_discovered(agent_name: &str) -> TranscriptMeta { updated: String::new(), turn_count: 0, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, diff --git a/crates/tinyagents-session/src/transcript/jsonl.rs b/crates/tinyagents-session/src/transcript/jsonl.rs index 0a8e3108e..b2e7395fa 100644 --- a/crates/tinyagents-session/src/transcript/jsonl.rs +++ b/crates/tinyagents-session/src/transcript/jsonl.rs @@ -97,6 +97,8 @@ pub(super) struct MetaPayload { pub(super) turn_count: usize, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) prefix_message_count: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub(super) prefix_reset: bool, pub(super) input_tokens: u64, pub(super) output_tokens: u64, pub(super) cached_input_tokens: u64, @@ -284,6 +286,7 @@ fn meta_payload_from(meta: &TranscriptMeta) -> MetaPayload { updated: meta.updated.clone(), turn_count: meta.turn_count, prefix_message_count: meta.prefix_message_count, + prefix_reset: meta.prefix_reset, input_tokens: meta.input_tokens, output_tokens: meta.output_tokens, cached_input_tokens: meta.cached_input_tokens, @@ -737,6 +740,7 @@ pub(super) fn meta_from_payload(mp: MetaPayload) -> TranscriptMeta { updated: mp.updated, turn_count: mp.turn_count, prefix_message_count: mp.prefix_message_count, + prefix_reset: mp.prefix_reset, input_tokens: mp.input_tokens, output_tokens: mp.output_tokens, cached_input_tokens: mp.cached_input_tokens, diff --git a/crates/tinyagents-session/src/transcript/legacy_md.rs b/crates/tinyagents-session/src/transcript/legacy_md.rs index 6f6dd3660..fe7c24167 100644 --- a/crates/tinyagents-session/src/transcript/legacy_md.rs +++ b/crates/tinyagents-session/src/transcript/legacy_md.rs @@ -73,6 +73,7 @@ fn parse_legacy_meta(raw: &str) -> Result { updated: get("updated").unwrap_or_default(), turn_count: get("turn_count").and_then(|s| s.parse().ok()).unwrap_or(0), prefix_message_count: None, + prefix_reset: false, input_tokens: get("input_tokens") .and_then(|s| s.parse().ok()) .unwrap_or(0), diff --git a/crates/tinyagents-session/src/transcript/spend_tests.rs b/crates/tinyagents-session/src/transcript/spend_tests.rs index 308f114f7..90e1ba82c 100644 --- a/crates/tinyagents-session/src/transcript/spend_tests.rs +++ b/crates/tinyagents-session/src/transcript/spend_tests.rs @@ -26,6 +26,7 @@ pub(super) fn meta(agent: &str, agent_type: &str, thread_id: Option<&str>) -> Tr updated: "2026-09-22T01:00:00Z".into(), turn_count: 0, prefix_message_count: None, + prefix_reset: false, // Every fixture leaves the denormalised `_meta` rollups at zero: that is // exactly what is on disk for every root transcript written since // `33566d382`, and the aggregate must not depend on them. diff --git a/crates/tinyagents-session/src/transcript/transcript_tests.rs b/crates/tinyagents-session/src/transcript/transcript_tests.rs index 7636b41ef..2d0343972 100644 --- a/crates/tinyagents-session/src/transcript/transcript_tests.rs +++ b/crates/tinyagents-session/src/transcript/transcript_tests.rs @@ -22,6 +22,7 @@ fn meta() -> TranscriptMeta { updated: "2026-01-01T00:00:00Z".into(), turn_count: 1, prefix_message_count: None, + prefix_reset: false, input_tokens: 1, output_tokens: 2, cached_input_tokens: 0, @@ -51,6 +52,20 @@ fn frozen_prefix_count_round_trips_in_meta() { ); } +#[test] +fn reset_context_marker_round_trips_in_meta() { + let dir = tempdir().unwrap(); + let path = resolve_keyed_transcript_path(dir.path(), "reset-context").unwrap(); + let mut seed = meta(); + seed.prefix_message_count = None; + seed.prefix_reset = true; + write_transcript(&path, &[], &seed, None).unwrap(); + + let restored = read_transcript(&path).unwrap().meta; + assert!(restored.prefix_reset); + assert_eq!(restored.prefix_message_count, None); +} + #[test] fn jsonl_round_trip_keeps_raw_tool_arguments_and_provider_extension() { let dir = tempdir().unwrap(); diff --git a/crates/tinyagents-session/src/transcript/typed_rows_tests.rs b/crates/tinyagents-session/src/transcript/typed_rows_tests.rs index a0909670d..ab8db0b7a 100644 --- a/crates/tinyagents-session/src/transcript/typed_rows_tests.rs +++ b/crates/tinyagents-session/src/transcript/typed_rows_tests.rs @@ -24,6 +24,7 @@ fn meta() -> TranscriptMeta { updated: "2026-01-01T00:00:00Z".into(), turn_count: 1, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, diff --git a/crates/tinyagents-session/src/transcript/types.rs b/crates/tinyagents-session/src/transcript/types.rs index 620247d97..65327f2cc 100644 --- a/crates/tinyagents-session/src/transcript/types.rs +++ b/crates/tinyagents-session/src/transcript/types.rs @@ -688,6 +688,11 @@ pub struct TranscriptMeta { /// generation. `None` on older transcripts that predate this boundary. #[serde(default, skip_serializing_if = "Option::is_none")] pub prefix_message_count: Option, + /// Explicitly marks a successor generation as an intentionally reset + /// model context. Unlike `None` on legacy transcripts, this suppresses + /// prompt-boundary recovery from the sealed parent. + #[serde(default)] + pub prefix_reset: bool, /// Cumulative input tokens across all provider calls this session. pub input_tokens: u64, /// Cumulative output tokens across all provider calls this session. diff --git a/crates/tinyagents-session/src/transcript/view/transcript_ordering_tests.rs b/crates/tinyagents-session/src/transcript/view/transcript_ordering_tests.rs index f5b44a50c..8c4070610 100644 --- a/crates/tinyagents-session/src/transcript/view/transcript_ordering_tests.rs +++ b/crates/tinyagents-session/src/transcript/view/transcript_ordering_tests.rs @@ -26,6 +26,7 @@ fn meta(thread_id: &str, session_id: Option, parent: Option) -> updated: "2023-11-14T22:00:00+00:00".into(), turn_count: 1, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, diff --git a/crates/tinyagents-session/src/transcript/view/transcript_view_tests.rs b/crates/tinyagents-session/src/transcript/view/transcript_view_tests.rs index 06428d97f..516a7d692 100644 --- a/crates/tinyagents-session/src/transcript/view/transcript_view_tests.rs +++ b/crates/tinyagents-session/src/transcript/view/transcript_view_tests.rs @@ -495,6 +495,7 @@ fn append_transcript_turn_projects_full_display_shape() { updated: now, turn_count: 1, prefix_message_count: None, + prefix_reset: false, input_tokens: 30, output_tokens: 13, cached_input_tokens: 0, diff --git a/crates/tinyagents-session/src/turn_state/mirror/mod_tests.rs b/crates/tinyagents-session/src/turn_state/mirror/mod_tests.rs index 7a9b746ee..2cc9fe2c8 100644 --- a/crates/tinyagents-session/src/turn_state/mirror/mod_tests.rs +++ b/crates/tinyagents-session/src/turn_state/mirror/mod_tests.rs @@ -103,6 +103,7 @@ fn seed_root_transcript( updated: "2026-07-21T00:00:00Z".into(), turn_count: 1, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, From ca2fe7bd7259b45c375154306a08e8f1e9409237 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 03:40:56 +0300 Subject: [PATCH 26/68] fix(session): refresh live sessions after reset --- .../tests/feature_session_transcript.rs | 1 + .../tests/session_conformance.rs | 1 + crates/tinyagents-runtime/src/session.rs | 36 +++- .../tinyagents-runtime/src/session_tests.rs | 177 ++++++++++++++++++ 4 files changed, 213 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-integration-tests/tests/feature_session_transcript.rs b/crates/tinyagents-integration-tests/tests/feature_session_transcript.rs index 416d238d5..651fc1821 100644 --- a/crates/tinyagents-integration-tests/tests/feature_session_transcript.rs +++ b/crates/tinyagents-integration-tests/tests/feature_session_transcript.rs @@ -27,6 +27,7 @@ fn meta(turn_count: usize, input_tokens: u64, output_tokens: u64) -> TranscriptM updated: format!("2026-09-19T10:00:0{turn_count}Z"), turn_count, prefix_message_count: None, + prefix_reset: false, input_tokens, output_tokens, cached_input_tokens: 3, diff --git a/crates/tinyagents-integration-tests/tests/session_conformance.rs b/crates/tinyagents-integration-tests/tests/session_conformance.rs index bf3e81860..812c70a7a 100644 --- a/crates/tinyagents-integration-tests/tests/session_conformance.rs +++ b/crates/tinyagents-integration-tests/tests/session_conformance.rs @@ -45,6 +45,7 @@ fn contract_meta() -> TranscriptMeta { updated: String::new(), turn_count: 0, prefix_message_count: None, + prefix_reset: false, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, diff --git a/crates/tinyagents-runtime/src/session.rs b/crates/tinyagents-runtime/src/session.rs index 31264ec61..3d41c2245 100644 --- a/crates/tinyagents-runtime/src/session.rs +++ b/crates/tinyagents-runtime/src/session.rs @@ -24,6 +24,10 @@ pub struct Session { driver: Arc>, codec: Option>>, hooks: Arc>, + /// Prefix configured when this session instance was constructed. A reset + /// discovered through another live instance clears the old frozen prefix + /// but keeps this current host configuration available to its hooks. + configured_prefix: PrefixSnapshot, prefix: PrefixSnapshot, default_tools: ToolSnapshot, history: Vec, @@ -53,12 +57,14 @@ impl Session { default_tools: ToolSnapshot, target: Option, ) -> Self { + let configured_prefix = prefix.frozen(); Self { driver, codec, hooks, - history: prefix.messages().to_vec(), - prefix: prefix.frozen(), + history: configured_prefix.messages().to_vec(), + prefix: configured_prefix.clone(), + configured_prefix, default_tools, persisted: Vec::new(), target, @@ -247,6 +253,32 @@ impl Session { // the sealed parent. The marker distinguishes it from an ordinary // empty successor whose legacy prompt still needs to be inferred. let reset_head = transcript.meta.prefix_reset && recorded_boundary.is_none(); + let changed_head = self + .transcript + .as_ref() + .is_some_and(|bound| bound.path() != read.path()); + if changed_head { + // Another live Session instance may have advanced this identity to + // a new generation. Rebuild cached transcript state from that head + // instead of retaining the old handle's turn count or tool belt. + // An explicit reset head is unfrozen and starts with this instance's + // configured prefix; a committed successor below restores its + // authoritative persisted prefix (including an explicit Some(0)). + if reset_head || recorded_boundary.is_some() { + self.prefix = if reset_head { + self.configured_prefix.clone() + } else { + PrefixSnapshot::default() + }; + } + self.history = self.prefix.messages().to_vec(); + self.persisted.clear(); + self.persisted_prefix_len = None; + self.recorded_tools = None; + self.recorded_tools_json = None; + self.committed_turns = 0; + self.transcript = None; + } let mut recovered_boundary = None; if recorded_boundary.is_none() && cached_boundary.is_none() && compacted_head && !reset_head { diff --git a/crates/tinyagents-runtime/src/session_tests.rs b/crates/tinyagents-runtime/src/session_tests.rs index 094439ea6..fcf0c3b93 100644 --- a/crates/tinyagents-runtime/src/session_tests.rs +++ b/crates/tinyagents-runtime/src/session_tests.rs @@ -135,6 +135,37 @@ impl SessionHooks for PrefixWhenUnfrozen { } } +struct MutablePrefix(Arc>); + +#[async_trait] +impl SessionHooks for MutablePrefix { + async fn before_turn( + &self, + _: &mut SessionTurnRequest, + _: &mut TurnOptions, + state: SessionStateView<'_>, + ) -> Result { + let prompt = self.0.lock().unwrap().clone(); + Ok(TurnPreparation { + prefix: (!state.prefix_is_frozen) + .then(|| PrefixSnapshot::new(vec![Message::system(prompt)])), + ..TurnPreparation::default() + }) + } + + async fn on_terminal(&self, _: SessionTerminal) -> Result<(), RuntimeError> { + Ok(()) + } + + async fn before_commit( + &self, + _: &SessionTurnOutcome, + _: &TranscriptTurnOptions, + ) -> Result<(), RuntimeError> { + Ok(()) + } +} + #[tokio::test] async fn reset_history_seals_old_turns_and_starts_a_fresh_resumable_context() { let locator = memory(); @@ -254,6 +285,152 @@ async fn reset_history_seals_old_turns_and_starts_a_fresh_resumable_context() { ); } +#[tokio::test] +async fn reset_by_another_live_session_rebinds_cached_prompt_and_turn_state() { + let locator = memory(); + let session_ref = SessionRef::scoped("live-reset", "agent-id"); + let prompt = Arc::new(Mutex::new("original prompt".to_owned())); + let requests = Arc::new(Mutex::new(Vec::new())); + let mut live = SessionBuilder::new(Arc::new(CapturingDriver(requests.clone()))) + .codec(Arc::new(RoleCodec)) + .hooks(Arc::new(MutablePrefix(prompt.clone()))) + .session(locator.clone(), session_ref.clone(), meta()) + .build() + .unwrap(); + live.turn( + SessionTurnRequest::new(Message::user("before reset")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(session_ref.clone()), + ..TurnOptions::default() + }, + ) + .await + .unwrap(); + + let mut resetter = + SessionBuilder::new(Arc::new(CapturingDriver(Arc::new(Mutex::new(Vec::new()))))) + .codec(Arc::new(RoleCodec)) + .session(locator.clone(), session_ref.clone(), meta()) + .build() + .unwrap(); + assert!(resetter.reset_history().await.unwrap()); + *prompt.lock().unwrap() = "replacement prompt".to_owned(); + + live.turn( + SessionTurnRequest::new(Message::user("after reset")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(session_ref.clone()), + ..TurnOptions::default() + }, + ) + .await + .unwrap(); + let history = requests.lock().unwrap()[1].clone(); + assert_eq!(history[0].text(), "replacement prompt"); + assert_eq!(history[1].text(), "after reset"); + assert!( + !history + .iter() + .any(|message| message.text() == "original prompt") + ); + assert!( + !history + .iter() + .any(|message| message.text() == "before reset") + ); + let head = locator.head_generation(&session_ref); + let transcript = locator + .read_session_transcript(&head) + .unwrap() + .read_session() + .unwrap() + .unwrap(); + assert_eq!(transcript.meta.turn_count, 1); + assert_eq!(transcript.meta.prefix_message_count, Some(1)); +} + +#[tokio::test] +async fn a_live_session_rebinds_after_another_session_commits_the_reset_head() { + let locator = memory(); + let session_ref = SessionRef::scoped("live-reset-commit", "agent-id"); + let prompt = Arc::new(Mutex::new("original prompt".to_owned())); + let requests = Arc::new(Mutex::new(Vec::new())); + let mut live = SessionBuilder::new(Arc::new(CapturingDriver(requests.clone()))) + .codec(Arc::new(RoleCodec)) + .hooks(Arc::new(MutablePrefix(prompt.clone()))) + .session(locator.clone(), session_ref.clone(), meta()) + .build() + .unwrap(); + live.turn( + SessionTurnRequest::new(Message::user("before reset")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(session_ref.clone()), + ..TurnOptions::default() + }, + ) + .await + .unwrap(); + + let mut resetter = + SessionBuilder::new(Arc::new(CapturingDriver(Arc::new(Mutex::new(Vec::new()))))) + .codec(Arc::new(RoleCodec)) + .session(locator.clone(), session_ref.clone(), meta()) + .build() + .unwrap(); + assert!(resetter.reset_history().await.unwrap()); + + let mut first_after_reset = + SessionBuilder::new(Arc::new(CapturingDriver(Arc::new(Mutex::new(Vec::new()))))) + .codec(Arc::new(RoleCodec)) + .hooks(Arc::new(PrefixWhenUnfrozen("committed reset prompt"))) + .session(locator.clone(), session_ref.clone(), meta()) + .build() + .unwrap(); + first_after_reset + .turn( + SessionTurnRequest::new(Message::user("first after reset")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(session_ref.clone()), + ..TurnOptions::default() + }, + ) + .await + .unwrap(); + + *prompt.lock().unwrap() = "stale replacement prompt".to_owned(); + live.turn( + SessionTurnRequest::new(Message::user("after concurrent commit")), + TurnOptions { + resume: ResumeMode::Session, + session: Some(session_ref.clone()), + ..TurnOptions::default() + }, + ) + .await + .unwrap(); + let history = requests.lock().unwrap()[1].clone(); + assert_eq!(history[0].text(), "committed reset prompt"); + assert_eq!(history[1].text(), "first after reset"); + assert_eq!(history[2].text(), "reply"); + assert_eq!(history[3].text(), "after concurrent commit"); + assert!(!history.iter().any(|message| { + matches!(message, Message::System(_)) + && (message.text() == "original prompt" || message.text() == "stale replacement prompt") + })); + let head = locator.head_generation(&session_ref); + let transcript = locator + .read_session_transcript(&head) + .unwrap() + .read_session() + .unwrap() + .unwrap(); + assert_eq!(transcript.meta.turn_count, 2); +} + #[tokio::test] async fn reset_history_advances_an_explicit_frozen_empty_prefix() { let locator = memory(); From bd7d5df03783a6096c99196d97b08db9fbedaf3b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 04:30:03 +0300 Subject: [PATCH 27/68] feat(session): derive UI wire schemas --- Cargo.lock | 64 +++++++++++++++++++ crates/tinyagents-session/Cargo.toml | 1 + .../examples/turn_state_schema.rs | 25 ++++++++ .../src/run_ledger/types.rs | 18 ++++-- .../src/run_ledger/types_tests.rs | 36 +++++++++++ .../src/turn_state/shape_tests.rs | 24 +++++++ .../src/turn_state/types.rs | 20 +++--- 7 files changed, 171 insertions(+), 17 deletions(-) create mode 100644 crates/tinyagents-session/examples/turn_state_schema.rs create mode 100644 crates/tinyagents-session/src/run_ledger/types_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 03914ac22..64ea10872 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,6 +400,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "equivalent" version = "1.0.2" @@ -1445,6 +1451,26 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "regex" version = "1.13.1" @@ -1653,6 +1679,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1689,6 +1741,17 @@ dependencies = [ "syn 3.0.2", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -2113,6 +2176,7 @@ dependencies = [ "chrono", "hex", "parking_lot", + "schemars", "serde", "serde_json", "sha2 0.11.0", diff --git a/crates/tinyagents-session/Cargo.toml b/crates/tinyagents-session/Cargo.toml index 7d32a8638..495bb28fd 100644 --- a/crates/tinyagents-session/Cargo.toml +++ b/crates/tinyagents-session/Cargo.toml @@ -15,6 +15,7 @@ anyhow = { workspace = true } thiserror = "2" serde = { workspace = true } serde_json = { workspace = true } +schemars = { version = "1", features = ["derive", "chrono04"] } tinyagents-harness = { path = "../tinyagents-harness", version = "2.1.4", default-features = false, features = ["sqlite"] } # `OrchestrationTaskStatus` is the canonical lifecycle status the run-ledger and # transcript-view statuses convert to and from. diff --git a/crates/tinyagents-session/examples/turn_state_schema.rs b/crates/tinyagents-session/examples/turn_state_schema.rs new file mode 100644 index 000000000..05c5e3aa5 --- /dev/null +++ b/crates/tinyagents-session/examples/turn_state_schema.rs @@ -0,0 +1,25 @@ +//! Print the authoritative Rust JSON Schema for persisted session UI records. + +use schemars::JsonSchema; +use tinyagents_session::{ + run_ledger::types::{AgentRun, RunEvent, RunTelemetry}, + turn_state::types::TurnState, +}; + +#[derive(JsonSchema)] +#[schemars(rename_all = "camelCase")] +#[allow(dead_code)] +struct SessionUiWireTypes { + turn_state: TurnState, + agent_run: AgentRun, + run_event: RunEvent, + run_telemetry: RunTelemetry, +} + +fn main() -> Result<(), Box> { + println!( + "{}", + serde_json::to_string_pretty(&schemars::schema_for!(SessionUiWireTypes))? + ); + Ok(()) +} diff --git a/crates/tinyagents-session/src/run_ledger/types.rs b/crates/tinyagents-session/src/run_ledger/types.rs index e133c910a..cc0a92108 100644 --- a/crates/tinyagents-session/src/run_ledger/types.rs +++ b/crates/tinyagents-session/src/run_ledger/types.rs @@ -20,7 +20,7 @@ use serde_json::Value; /// /// Distinguishes the several call sites that create a run row so listings and /// UI can filter by origin without inferring it from other fields. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AgentRunKind { /// A synchronous sub-agent invocation nested inside a parent run. @@ -64,7 +64,7 @@ impl AgentRunKind { /// /// Convertible to and from the canonical `tinyagents_tasks::OrchestrationTaskStatus` /// (see `status_map.rs`); the serialized form is unchanged. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AgentRunStatus { /// Created but not yet started. @@ -182,7 +182,7 @@ impl WorkflowRunStatus { /// A persisted background agent run: a subagent, worker thread, background /// agent, team member, or workflow child, as classified by `kind`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] pub struct AgentRun { /// Caller-supplied stable identifier. @@ -274,7 +274,7 @@ pub enum WorkflowLeaseClaim { /// /// `sequence` is allocated by the insert itself (see `super::ops`) rather /// than computed by the caller, so concurrent appenders cannot collide. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] pub struct RunEvent { pub run_id: String, @@ -292,7 +292,7 @@ pub struct RunEvent { /// /// One row per `run_id`, updated in place as a run progresses (see /// `super::ops::upsert_run_telemetry` for how partial updates are coalesced). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] pub struct RunTelemetry { pub run_id: String, @@ -402,7 +402,7 @@ pub struct AgentRunListRequest { } /// Response shape for `super::ops::list_agent_runs`. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] pub struct AgentRunListResponse { pub runs: Vec, @@ -451,7 +451,7 @@ pub struct RunEventListRequest { } /// Response shape for `super::ops::list_recent_run_events`. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] pub struct RunEventListResponse { pub events: Vec, @@ -778,3 +778,7 @@ pub enum RunTransition { /// No run has this id. NotFound, } + +#[cfg(test)] +#[path = "types_tests.rs"] +mod tests; diff --git a/crates/tinyagents-session/src/run_ledger/types_tests.rs b/crates/tinyagents-session/src/run_ledger/types_tests.rs new file mode 100644 index 000000000..ddfc928ed --- /dev/null +++ b/crates/tinyagents-session/src/run_ledger/types_tests.rs @@ -0,0 +1,36 @@ +use super::*; + +#[test] +fn run_ledger_schemas_use_the_persisted_camel_case_fields_and_enum_names() { + let run_schema = serde_json::to_value(schemars::schema_for!(AgentRun)).unwrap(); + let run_properties = run_schema["properties"].as_object().unwrap(); + for field in [ + "parentThreadId", + "workerThreadId", + "startedAt", + "completedAt", + ] { + assert!( + run_properties.contains_key(field), + "AgentRun schema is missing {field}" + ); + } + + let event_schema = serde_json::to_value(schemars::schema_for!(RunEvent)).unwrap(); + let event_properties = event_schema["properties"].as_object().unwrap(); + for field in ["runId", "eventType", "timestamp"] { + assert!( + event_properties.contains_key(field), + "RunEvent schema is missing {field}" + ); + } + + assert_eq!( + serde_json::to_value(AgentRunKind::WorkerThread).unwrap(), + "worker_thread" + ); + assert_eq!( + serde_json::to_value(AgentRunStatus::AwaitingUser).unwrap(), + "awaiting_user" + ); +} diff --git a/crates/tinyagents-session/src/turn_state/shape_tests.rs b/crates/tinyagents-session/src/turn_state/shape_tests.rs index d72146d98..ea5375e72 100644 --- a/crates/tinyagents-session/src/turn_state/shape_tests.rs +++ b/crates/tinyagents-session/src/turn_state/shape_tests.rs @@ -36,3 +36,27 @@ fn snapshot_written_before_optional_fields_still_loads() { assert_eq!(state.lifecycle, TurnLifecycle::Interrupted); assert!(state.transcript.is_empty()); } + +#[test] +fn turn_state_json_schema_tracks_the_serialized_wire_names() { + let schema = serde_json::to_value(schemars::schema_for!(TurnState)).unwrap(); + let properties = schema["properties"].as_object().unwrap(); + for field in [ + "threadId", + "requestId", + "maxIterations", + "toolTimeline", + "transcript", + "startedAt", + "updatedAt", + ] { + assert!(properties.contains_key(field), "schema is missing {field}"); + } + assert!( + schema["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == "threadId") + ); +} diff --git a/crates/tinyagents-session/src/turn_state/types.rs b/crates/tinyagents-session/src/turn_state/types.rs index 841eae0f1..6d86dafff 100644 --- a/crates/tinyagents-session/src/turn_state/types.rs +++ b/crates/tinyagents-session/src/turn_state/types.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; /// progress signal arrives. `Interrupted` is stamped at startup on /// any snapshot that survived a process restart — there is no live /// driver to resume it, so the UI should surface a retry affordance. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum TurnLifecycle { Started, @@ -29,7 +29,7 @@ pub enum TurnLifecycle { } /// High-level phase the agent is in within an iteration. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum TurnPhase { Thinking, @@ -38,7 +38,7 @@ pub enum TurnPhase { } /// Per-tool entry shown in the live timeline UI. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ToolTimelineStatus { Running, @@ -54,7 +54,7 @@ pub enum ToolTimelineStatus { /// settled/reloaded turn keeps its "why + what to do next" copy across a thread /// switch or a cold boot. Absent on successful rows and on snapshots written /// before this field. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] pub struct PersistedToolFailure { /// Stable failure-class variant name, e.g. `"Timeout"`, `"Denied"`. @@ -74,7 +74,7 @@ pub struct PersistedToolFailure { /// Field names use camelCase on the wire so a snapshot can be applied /// directly to `chatRuntimeSlice.toolTimelineByThread` without a /// translation layer in the UI. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ToolTimelineEntry { pub id: String, @@ -112,7 +112,7 @@ pub struct ToolTimelineEntry { } /// Live sub-agent activity nested under a `subagent:*` timeline row. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] pub struct SubagentActivity { pub task_id: String, @@ -164,7 +164,7 @@ pub struct SubagentActivity { } /// One child tool call performed by a running sub-agent. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] pub struct SubagentToolCall { pub call_id: String, @@ -221,7 +221,7 @@ pub struct SubagentToolCall { /// union 1:1 (order = push order; no `seq` is needed because each sub-agent's /// transcript is built as a single ordered list). Persisting these lets the /// inline "Agentic task insights" thoughts survive a settled turn / reload. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] // `rename_all` renames the variant tags; `rename_all_fields` renames the // fields *inside* the struct variants (call_id → callId, …) — without the // latter the FE would read `undefined` for camelCase fields. @@ -272,7 +272,7 @@ pub enum SubagentTranscriptItem { /// (round alone can't order narration vs thinking within one round). Tool /// items hold only a `call_id` pointer into [`TurnState::tool_timeline`] so /// the row's status/label live in exactly one place. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] // `rename_all_fields` is required so the `ToolCall.call_id` field serializes as // `callId` (the FE reads camelCase) — `rename_all` alone only renames variants. #[serde( @@ -314,7 +314,7 @@ pub enum TranscriptItem { /// "View processing" panel can replay the finished turn's transcript); a /// non-terminal snapshot surviving startup is marked /// [`TurnLifecycle::Interrupted`]. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] pub struct TurnState { pub thread_id: String, From 74cd53c9eddedc505d4bc28d83db5ab1a9f5057e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 05:01:54 +0300 Subject: [PATCH 28/68] fix(session): export serialized turn state schema --- .../examples/turn_state_schema.rs | 9 +++---- .../src/turn_state/shape_tests.rs | 24 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/crates/tinyagents-session/examples/turn_state_schema.rs b/crates/tinyagents-session/examples/turn_state_schema.rs index 05c5e3aa5..87ca20fdc 100644 --- a/crates/tinyagents-session/examples/turn_state_schema.rs +++ b/crates/tinyagents-session/examples/turn_state_schema.rs @@ -17,9 +17,10 @@ struct SessionUiWireTypes { } fn main() -> Result<(), Box> { - println!( - "{}", - serde_json::to_string_pretty(&schemars::schema_for!(SessionUiWireTypes))? - ); + let schema = schemars::generate::SchemaSettings::draft2020_12() + .for_serialize() + .into_generator() + .into_root_schema_for::(); + println!("{}", serde_json::to_string_pretty(&schema)?); Ok(()) } diff --git a/crates/tinyagents-session/src/turn_state/shape_tests.rs b/crates/tinyagents-session/src/turn_state/shape_tests.rs index ea5375e72..409aa430b 100644 --- a/crates/tinyagents-session/src/turn_state/shape_tests.rs +++ b/crates/tinyagents-session/src/turn_state/shape_tests.rs @@ -39,7 +39,13 @@ fn snapshot_written_before_optional_fields_still_loads() { #[test] fn turn_state_json_schema_tracks_the_serialized_wire_names() { - let schema = serde_json::to_value(schemars::schema_for!(TurnState)).unwrap(); + let schema = serde_json::to_value( + schemars::generate::SchemaSettings::draft2020_12() + .for_serialize() + .into_generator() + .into_root_schema_for::(), + ) + .unwrap(); let properties = schema["properties"].as_object().unwrap(); for field in [ "threadId", @@ -52,11 +58,13 @@ fn turn_state_json_schema_tracks_the_serialized_wire_names() { ] { assert!(properties.contains_key(field), "schema is missing {field}"); } - assert!( - schema["required"] - .as_array() - .unwrap() - .iter() - .any(|field| field == "threadId") - ); + let required = schema["required"].as_array().unwrap(); + for field in ["threadId", "streamingText", "thinking", "toolTimeline"] { + assert!(required.iter().any(|required| required == field)); + } + assert!(!required.iter().any(|required| required == "transcript")); + let subagent = &schema["$defs"]["SubagentActivity"]; + let subagent_required = subagent["required"].as_array().unwrap(); + assert!(subagent_required.iter().any(|field| field == "toolCalls")); + assert!(!subagent_required.iter().any(|field| field == "transcript")); } From ccf1efb9615130944ca0c2cd4e64f9ff341fd432 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 05:19:00 +0300 Subject: [PATCH 29/68] feat(session): merge message metadata atomically --- .../src/threads/driver/rewrite.rs | 27 ++++++ .../src/threads/driver_tests.rs | 74 ++++++++++++++++ .../src/threads/persistence_tests.rs | 88 +++++++++++++++++++ .../tinyagents-session/src/threads/types.rs | 6 ++ .../src/threads/types_tests.rs | 9 ++ 5 files changed, 204 insertions(+) diff --git a/crates/tinyagents-session/src/threads/driver/rewrite.rs b/crates/tinyagents-session/src/threads/driver/rewrite.rs index 08333a574..7a8da3c48 100644 --- a/crates/tinyagents-session/src/threads/driver/rewrite.rs +++ b/crates/tinyagents-session/src/threads/driver/rewrite.rs @@ -124,6 +124,9 @@ impl DriverConversationStore { if let Some(extra) = patch.extra_metadata.clone() { message.extra_metadata = extra; } + if let Some(merge) = patch.merge_extra_metadata.clone() { + Self::merge_json_patch(&mut message.extra_metadata, merge); + } let updated = message.clone(); Ok(Some((messages, updated))) }) @@ -131,6 +134,30 @@ impl DriverConversationStore { updated.ok_or_else(|| StorageError::not_found("conversation message not found").into()) } + /// Apply an RFC 7396-style JSON merge patch to a message metadata value. + /// Called inside `rewrite`'s CAS retry so each retry uses the current row. + fn merge_json_patch(target: &mut serde_json::Value, patch: serde_json::Value) { + match patch { + serde_json::Value::Object(patch) => { + if !target.is_object() { + *target = serde_json::json!({}); + } + let target = target.as_object_mut().expect("object established"); + for (key, value) in patch { + if value.is_null() { + target.remove(&key); + } else { + Self::merge_json_patch( + target.entry(key).or_insert(serde_json::Value::Null), + value, + ); + } + } + } + replacement => *target = replacement, + } + } + pub(super) async fn delete_messages_from_native( &self, thread_id: &str, diff --git a/crates/tinyagents-session/src/threads/driver_tests.rs b/crates/tinyagents-session/src/threads/driver_tests.rs index 5ffc5d448..99458ae12 100644 --- a/crates/tinyagents-session/src/threads/driver_tests.rs +++ b/crates/tinyagents-session/src/threads/driver_tests.rs @@ -357,6 +357,80 @@ async fn conflicting_metadata_edits_reread_version_and_preserve_both_fields() { assert_eq!(row.labels, vec!["tasks"]); } +#[tokio::test(flavor = "current_thread")] +async fn concurrent_message_metadata_merges_retry_against_latest_row() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let backend = MemoryStorage::new(); + let base = scoped(&backend, "profile"); + seed(&base, "one", &["hello"]).await; + base.update_message( + "one", + "message:0", + ThreadMessagePatch { + extra_metadata: Some(json!({"voice": {"transcript": "spoken"}})), + ..Default::default() + }, + ) + .await + .unwrap(); + + let scope = Scope::new("profile").unwrap(); + let ports = backend.for_scope(&scope).unwrap(); + let docs = Arc::new(fixtures::ConflictingDocs { + inner: ports.documents().clone(), + attempts: AtomicUsize::new(0), + barrier: tokio::sync::Barrier::new(2), + refuse_get: false, + }); + let store = DriverConversationStore::new(ScopedStorage::new( + scope, + "test", + docs.clone(), + ports.streams().clone(), + ports.blobs().clone(), + )); + let (reaction, receipt) = tokio::join!( + store.update_message( + "one", + "message:0", + ThreadMessagePatch { + merge_extra_metadata: Some(json!({ + "channelActivity": {"reactions": {"reaction-key": {"value": "👍"}}} + })), + ..Default::default() + }, + ), + store.update_message( + "one", + "message:0", + ThreadMessagePatch { + merge_extra_metadata: Some(json!({ + "channelActivity": {"readReceipts": {"receipt-key": {"actorId": "reader"}}} + })), + ..Default::default() + }, + ), + ); + reaction.unwrap(); + receipt.unwrap(); + assert!( + docs.attempts.load(Ordering::SeqCst) >= 3, + "fixture must force one metadata rewrite to retry after a CAS conflict" + ); + + let messages = store.get_messages("one").await.unwrap(); + let message = &messages[0]; + assert_eq!(message.extra_metadata["voice"]["transcript"], "spoken"); + assert_eq!( + message.extra_metadata["channelActivity"]["reactions"]["reaction-key"]["value"], + "👍" + ); + assert_eq!( + message.extra_metadata["channelActivity"]["readReceipts"]["receipt-key"]["actorId"], + "reader" + ); +} + fn instrumented_streams( store: &DriverConversationStore, pause_first: bool, diff --git a/crates/tinyagents-session/src/threads/persistence_tests.rs b/crates/tinyagents-session/src/threads/persistence_tests.rs index b884a2284..d46c147f0 100644 --- a/crates/tinyagents-session/src/threads/persistence_tests.rs +++ b/crates/tinyagents-session/src/threads/persistence_tests.rs @@ -83,6 +83,7 @@ async fn memory_store_preserves_title_labels_and_metadata_patch() { "agent:x", ThreadMessagePatch { extra_metadata: Some(json!({"success": true})), + ..Default::default() }, ) .await @@ -118,6 +119,92 @@ async fn memory_store_preserves_title_labels_and_metadata_patch() { ); } +#[tokio::test] +async fn concurrent_metadata_merge_patches_preserve_each_other_and_existing_metadata() { + let store = DriverConversationStore::in_memory().await; + store.ensure_thread(thread("one")).await.unwrap(); + store + .append_message( + "one", + ThreadMessage { + extra_metadata: json!({"voice": {"transcript": "spoken"}}), + ..message("user:receipt", "hello") + }, + ) + .await + .unwrap(); + + let (reaction, receipt) = tokio::join!( + store.update_message( + "one", + "user:receipt", + ThreadMessagePatch { + merge_extra_metadata: Some(json!({ + "channelActivity": {"reactions": {"actor-reaction": {"value": "👍"}}} + })), + ..Default::default() + }, + ), + store.update_message( + "one", + "user:receipt", + ThreadMessagePatch { + merge_extra_metadata: Some(json!({ + "channelActivity": {"readReceipts": {"reader": {"timestamp": "2026-10-20T00:00:00Z"}}} + })), + ..Default::default() + }, + ), + ); + reaction.unwrap(); + receipt.unwrap(); + + let stored = store.get_messages("one").await.unwrap(); + let metadata = &stored[0].extra_metadata; + assert_eq!(metadata["voice"]["transcript"], "spoken"); + assert_eq!( + metadata["channelActivity"]["reactions"]["actor-reaction"]["value"], + "👍" + ); + assert_eq!( + metadata["channelActivity"]["readReceipts"]["reader"]["timestamp"], + "2026-10-20T00:00:00Z" + ); +} + +#[tokio::test] +async fn metadata_merge_patch_null_removes_only_the_named_member() { + let store = DriverConversationStore::in_memory().await; + store.ensure_thread(thread("one")).await.unwrap(); + store + .append_message( + "one", + ThreadMessage { + extra_metadata: json!({"channelActivity": {"reactions": {"gone": {}, "kept": {}}}}), + ..message("user:receipt", "hello") + }, + ) + .await + .unwrap(); + store + .update_message( + "one", + "user:receipt", + ThreadMessagePatch { + merge_extra_metadata: Some(json!({ + "channelActivity": {"reactions": {"gone": null}} + })), + ..Default::default() + }, + ) + .await + .unwrap(); + let messages = store.get_messages("one").await.unwrap(); + let reactions = &messages[0].extra_metadata["channelActivity"]["reactions"]; + assert!(reactions.get("gone").is_none()); + assert!(reactions.get("kept").is_some()); +} + #[tokio::test] async fn memory_store_tail_delete_recreate_and_purge() { let store = DriverConversationStore::in_memory().await; @@ -274,6 +361,7 @@ async fn update_message_rewrites_only_the_patched_message_and_keeps_appends_work "b", ThreadMessagePatch { extra_metadata: Some(json!({"k": 1})), + ..Default::default() }, ) .await diff --git a/crates/tinyagents-session/src/threads/types.rs b/crates/tinyagents-session/src/threads/types.rs index 1037a521b..4ab9fbc0d 100644 --- a/crates/tinyagents-session/src/threads/types.rs +++ b/crates/tinyagents-session/src/threads/types.rs @@ -102,6 +102,12 @@ pub struct ThreadMessagePatch { /// untouched. #[serde(default)] pub extra_metadata: Option, + /// Recursively merges a JSON Merge Patch into the latest `extraMetadata`. + /// Object members are merged, arrays/scalars are replaced, and `null` + /// removes an object member. Applied inside the driver's compare-and-swap + /// retry so concurrent patches are not based on a stale host-side read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub merge_extra_metadata: Option, } /// A single match returned by diff --git a/crates/tinyagents-session/src/threads/types_tests.rs b/crates/tinyagents-session/src/threads/types_tests.rs index 23e13b6e0..3f8e79454 100644 --- a/crates/tinyagents-session/src/threads/types_tests.rs +++ b/crates/tinyagents-session/src/threads/types_tests.rs @@ -32,6 +32,7 @@ fn conversation_thread_serde_uses_camel_case_and_defaults_labels() { fn conversation_message_patch_defaults_to_no_changes() { let patch: ThreadMessagePatch = serde_json::from_value(json!({})).unwrap(); assert!(patch.extra_metadata.is_none()); + assert!(patch.merge_extra_metadata.is_none()); let patch_with_metadata: ThreadMessagePatch = serde_json::from_value(json!({"extraMetadata": {"source": "mock"}})).unwrap(); @@ -39,6 +40,14 @@ fn conversation_message_patch_defaults_to_no_changes() { patch_with_metadata.extra_metadata, Some(json!({"source": "mock"})) ); + assert!(patch_with_metadata.merge_extra_metadata.is_none()); + + let merge_patch: ThreadMessagePatch = + serde_json::from_value(json!({"mergeExtraMetadata": {"channelActivity": {}}})).unwrap(); + assert_eq!( + merge_patch.merge_extra_metadata, + Some(json!({"channelActivity": {}})) + ); } #[test] From 56e9eabf34748c508d864b8dc8cb51096e65075d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 06:31:06 +0300 Subject: [PATCH 30/68] refactor(threads): introduce is_idempotent_message_id for append retries This change introduces `is_idempotent_message_id` to correctly identify message IDs that can be safely appended multiple times during retries. The logic in `DriverConversationStore` is updated to use this new function instead of the older `is_deterministic_message_id` for this check, clarifying the contract between deterministic correlation and append idempotency. Auto-committed-on: dragonfly --- .../src/threads/driver/append.rs | 2 +- crates/tinyagents-session/src/threads/mod.rs | 4 +-- .../src/threads/persistence_tests.rs | 20 ++++++++++++ .../tinyagents-session/src/threads/types.rs | 31 +++++++++++-------- .../src/threads/types_tests.rs | 11 +++++-- 5 files changed, 50 insertions(+), 18 deletions(-) diff --git a/crates/tinyagents-session/src/threads/driver/append.rs b/crates/tinyagents-session/src/threads/driver/append.rs index 4d1ede1bc..8e1301b06 100644 --- a/crates/tinyagents-session/src/threads/driver/append.rs +++ b/crates/tinyagents-session/src/threads/driver/append.rs @@ -87,7 +87,7 @@ impl DriverConversationStore { thread_id: &str, message: &ThreadMessage, ) -> ConversationResult> { - if !is_deterministic_message_id(&message.id) + if !is_idempotent_message_id(&message.id) && message .extra_metadata .get("scope") diff --git a/crates/tinyagents-session/src/threads/mod.rs b/crates/tinyagents-session/src/threads/mod.rs index 710bcab2e..05b022185 100644 --- a/crates/tinyagents-session/src/threads/mod.rs +++ b/crates/tinyagents-session/src/threads/mod.rs @@ -33,8 +33,8 @@ mod types; pub use types::{ ConversationThread, CreateConversationThread, CrossThreadHit, DETERMINISTIC_MESSAGE_ID_PREFIX, - ThreadMessage, ThreadMessagePatch, is_deterministic_message_id, reply_run_id, - run_reply_message_id, + ThreadMessage, ThreadMessagePatch, is_deterministic_message_id, is_idempotent_message_id, + reply_run_id, run_reply_message_id, }; #[cfg(test)] diff --git a/crates/tinyagents-session/src/threads/persistence_tests.rs b/crates/tinyagents-session/src/threads/persistence_tests.rs index d46c147f0..0ac782580 100644 --- a/crates/tinyagents-session/src/threads/persistence_tests.rs +++ b/crates/tinyagents-session/src/threads/persistence_tests.rs @@ -59,6 +59,26 @@ async fn memory_store_preserves_deterministic_append_and_missing_thread_behavior ); } +#[tokio::test] +async fn concurrent_user_request_id_appends_are_idempotent() { + let store = DriverConversationStore::in_memory().await; + store.ensure_thread(thread("one")).await.unwrap(); + let user = ThreadMessage { + sender: "user".into(), + ..message("user:request-1", "queued prompt") + }; + + let (first, retry) = tokio::join!( + store.append_message("one", user.clone()), + store.append_message("one", user), + ); + let (first, retry) = (first.unwrap(), retry.unwrap()); + assert_eq!(first, retry); + let messages = store.get_messages("one").await.unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].id, "user:request-1"); +} + #[tokio::test] async fn memory_store_preserves_title_labels_and_metadata_patch() { let store = DriverConversationStore::in_memory().await; diff --git a/crates/tinyagents-session/src/threads/types.rs b/crates/tinyagents-session/src/threads/types.rs index 4ab9fbc0d..0da7f8088 100644 --- a/crates/tinyagents-session/src/threads/types.rs +++ b/crates/tinyagents-session/src/threads/types.rs @@ -130,14 +130,23 @@ pub struct CrossThreadHit { pub score: f64, } -/// Prefix of the message ids the core mints **deterministically** rather than -/// from a fresh UUID. +/// Prefix of the assistant reply ids the core mints from a request id. /// -/// Only such an id can be presented to the store twice by two different -/// writers, so it is also the marker -/// [`is_deterministic_message_id`] keys the store's idempotency lookup on. +/// [`is_deterministic_message_id`] identifies this assistant-only format for +/// transcript correlation. The append retry contract is broader and is +/// defined by [`is_idempotent_message_id`]. pub const DETERMINISTIC_MESSAGE_ID_PREFIX: &str = "agent:"; +/// Whether a message id marks an append that may be retried safely. +/// +/// Agent replies use `agent:` and user rows submitted through a +/// queued/hosted path use `user:`. Both identifiers are stable +/// across retries; ordinary UI ids and provider-specific sender ids remain +/// outside this contract unless their message carries `scope: channel`. +pub fn is_idempotent_message_id(id: &str) -> bool { + id.starts_with(DETERMINISTIC_MESSAGE_ID_PREFIX) || id.starts_with("user:") +} + /// The id an autonomous run's closing reply is stored under. /// /// Two writers legitimately persist that one reply — the core's @@ -149,15 +158,11 @@ pub fn run_reply_message_id(run_id: &str) -> String { format!("{DETERMINISTIC_MESSAGE_ID_PREFIX}{run_id}") } -/// Whether `id` is one the core mints deterministically, i.e. one a second -/// writer can legitimately present again. +/// Whether `id` is an assistant reply id minted from a run/request id. /// -/// This is what buys back the constant-time append path: every other id in the -/// store is UUID-fresh by construction (`user:`, `:`), can -/// never be re-presented, and so must not pay for a duplicate lookup. The -/// `agent:` ids the subagent/worker-thread writers mint do match — they -/// pay for a lookup they can never hit, which is one cheap scan of a -/// two-message worker transcript. +/// This predicate is used for transcript correlation by +/// [`reply_run_id`]. Use [`is_idempotent_message_id`] to check the broader +/// append retry contract. pub fn is_deterministic_message_id(id: &str) -> bool { id.starts_with(DETERMINISTIC_MESSAGE_ID_PREFIX) } diff --git a/crates/tinyagents-session/src/threads/types_tests.rs b/crates/tinyagents-session/src/threads/types_tests.rs index 3f8e79454..387c5567f 100644 --- a/crates/tinyagents-session/src/threads/types_tests.rs +++ b/crates/tinyagents-session/src/threads/types_tests.rs @@ -85,8 +85,8 @@ fn run_reply_id_is_deterministic_and_recognised_as_such() { #[test] fn client_generated_ids_are_not_deterministic() { - // These are UUID-fresh per message, so they can never be re-presented and - // must keep the constant-time append path. + // These are client-owned UI ids, not stable request keys. They are not + // transcript-correlated assistant replies or append-idempotency keys. assert!(!is_deterministic_message_id( "user:5f1d0c3e-1f8b-4c1a-9c2e-2a7b6d4e8f90" )); @@ -94,3 +94,10 @@ fn client_generated_ids_are_not_deterministic() { "assistant:5f1d0c3e-1f8b-4c1a-9c2e-2a7b6d4e8f90" )); } + +#[test] +fn queued_user_request_ids_are_append_idempotency_keys() { + assert!(is_idempotent_message_id("agent:run-7")); + assert!(is_idempotent_message_id("user:request-7")); + assert!(!is_idempotent_message_id("ui:message-7")); +} From aa6b5002e53acc43f4fa13dd85bffe65021e506d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 06:34:22 +0300 Subject: [PATCH 31/68] test(threads): clarify user append idempotency --- crates/tinyagents-session/src/threads/types_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-session/src/threads/types_tests.rs b/crates/tinyagents-session/src/threads/types_tests.rs index 387c5567f..7d29b1c70 100644 --- a/crates/tinyagents-session/src/threads/types_tests.rs +++ b/crates/tinyagents-session/src/threads/types_tests.rs @@ -85,8 +85,8 @@ fn run_reply_id_is_deterministic_and_recognised_as_such() { #[test] fn client_generated_ids_are_not_deterministic() { - // These are client-owned UI ids, not stable request keys. They are not - // transcript-correlated assistant replies or append-idempotency keys. + // User ids are not transcript-correlated assistant replies. The user: + // prefix is a separate append-idempotency contract. assert!(!is_deterministic_message_id( "user:5f1d0c3e-1f8b-4c1a-9c2e-2a7b6d4e8f90" )); From 8a4826d89e3acccef1701f3d91a702b31ded70d9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 08:38:16 +0300 Subject: [PATCH 32/68] chore(vendor): update tinytools subproject commit This updates the subproject commit hash for tinytools to the latest version. This ensures the dependency is using the most recent changes. Auto-committed-on: dragonfly --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 201c56321..9e1156141 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 201c563219549114da3dffcfc8ef75f3eb2e130c +Subproject commit 9e11561413374bf0865a3ff5377a0b4de451f10e From 3695e4fbe2454eb989d5f2744312401091fab37e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 08:39:12 +0300 Subject: [PATCH 33/68] chore(vendor): update tinytools subproject commit This updates the subproject commit hash for tinytools to the latest version. This ensures the dependency is using the most recent stable release. Auto-committed-on: dragonfly --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 9e1156141..201c56321 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 9e11561413374bf0865a3ff5377a0b4de451f10e +Subproject commit 201c563219549114da3dffcfc8ef75f3eb2e130c From b52fbeb0699ca57cac228ec9c64e51b205d40d0e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 08:42:51 +0300 Subject: [PATCH 34/68] chore(deps): update tinystoragedrivers-core versions This updates the dependency versions for tinystoragedrivers-core across various parts of the project. This ensures consistency with the latest stable releases of the storage drivers. Auto-committed-on: dragonfly --- Cargo.lock | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a8c06e60..cd74bfc22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,7 +2024,7 @@ dependencies = [ "tinyagents-harness", "tinyagents-tasks", "tinyinference-llm", - "tinystoragedrivers-core", + "tinystoragedrivers-core 1.0.0", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2059,7 +2059,7 @@ dependencies = [ "tinyinference-image", "tinyinference-llm", "tinyinference-video", - "tinystoragedrivers-core", + "tinystoragedrivers-core 1.0.0", "tinystoragedrivers-sqlite", "tinytools", "tinytools-agent", @@ -2091,7 +2091,7 @@ dependencies = [ "tinyagents-session", "tinyinference-embeddings", "tinyinference-llm", - "tinystoragedrivers-core", + "tinystoragedrivers-core 1.0.0", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2132,7 +2132,7 @@ dependencies = [ "tinyagents-session", "tinyagents-tasks", "tinyinference-llm", - "tinystoragedrivers-core", + "tinystoragedrivers-core 1.0.0", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2172,7 +2172,7 @@ dependencies = [ "tinyagents-registry", "tinyagents-session", "tinyinference-llm", - "tinystoragedrivers-core", + "tinystoragedrivers-core 1.0.0", "tinytools", "tokio", "tracing", @@ -2196,7 +2196,7 @@ dependencies = [ "tinyagents-harness", "tinyagents-session", "tinyagents-tasks", - "tinystoragedrivers-core", + "tinystoragedrivers-core 1.0.0", "tinystoragedrivers-sqlite", "tinytools-agent", "tokio", @@ -2215,7 +2215,7 @@ dependencies = [ "tempfile", "tinyagents-harness", "tinyinference-llm", - "tinystoragedrivers-core", + "tinystoragedrivers-core 1.0.0", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2262,7 +2262,7 @@ dependencies = [ "serde_json", "thiserror", "tinyinference-core", - "tinystoragedrivers-core", + "tinystoragedrivers-core 0.11.0", "tokio", "tracing", ] @@ -2333,6 +2333,18 @@ dependencies = [ "url", ] +[[package]] +name = "tinystoragedrivers-core" +version = "0.11.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v0.11.0#9eeef54aea403ac07461a612189bdf4982782823" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror", + "tokio", +] + [[package]] name = "tinystoragedrivers-core" version = "1.0.0" @@ -2351,8 +2363,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-secrets" -version = "1.0.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v1.0.0#141fd9ed0ad5d14a8fb5a1b3e837a719e81ac08d" +version = "0.11.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v0.11.0#9eeef54aea403ac07461a612189bdf4982782823" dependencies = [ "async-trait", "atomicwrites", @@ -2364,7 +2376,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "tinystoragedrivers-core", + "tinystoragedrivers-core 0.11.0", "tokio", "winapi-util", "windows-permissions", @@ -2380,7 +2392,7 @@ dependencies = [ "rusqlite", "serde_json", "sha2 0.10.9", - "tinystoragedrivers-core", + "tinystoragedrivers-core 1.0.0", "tokio", "zstd", ] From 26309a929fd60db13982e3e82c433c30951bc884 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 08:44:38 +0300 Subject: [PATCH 35/68] test(schema): add merge_extra_metadata to conversation read test This adds `merge_extra_metadata: None` to the `ThreadMessagePatch` in the schema test. This ensures that the test correctly simulates the behavior when metadata is being patched during a conversation read. Auto-committed-on: dragonfly --- crates/tinyagents-session/src/threads/schema_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinyagents-session/src/threads/schema_tests.rs b/crates/tinyagents-session/src/threads/schema_tests.rs index 14cbd2ef4..a6b212870 100644 --- a/crates/tinyagents-session/src/threads/schema_tests.rs +++ b/crates/tinyagents-session/src/threads/schema_tests.rs @@ -328,6 +328,7 @@ async fn a_migrated_conversation_reads_recovers_its_intent_and_moves_to_canonica // A rewrite opens a fresh incarnation whose stream carries the thread id. let patch = ThreadMessagePatch { extra_metadata: Some(json!({"edited": true})), + merge_extra_metadata: None, }; store.update_message(id, "m1", patch).await.unwrap(); let streams = storage From 204972f3f133a60c793f2a2ffbbe3e677627052a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 09:00:47 +0300 Subject: [PATCH 36/68] test(transcript): update turn lock test locators Updates the string literals used for session locators in turn lock tests to use more descriptive names. This improves clarity when testing lock isolation between different session contexts. Auto-committed-on: dragonfly --- .../src/transcript/turn_lock_tests.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/tinyagents-session/src/transcript/turn_lock_tests.rs b/crates/tinyagents-session/src/transcript/turn_lock_tests.rs index cfbe61969..ab3d72746 100644 --- a/crates/tinyagents-session/src/transcript/turn_lock_tests.rs +++ b/crates/tinyagents-session/src/transcript/turn_lock_tests.rs @@ -34,7 +34,7 @@ impl TranscriptLocator for Keyed { #[tokio::test] async fn a_held_turn_lock_blocks_the_same_session_until_released() { - let locator = Keyed("store-a"); + let locator = Keyed("store-lock-release"); let session = SessionRef::scoped("thread-1", "agent"); let guard = lock_session_turn(&locator, &session).await.unwrap(); @@ -47,25 +47,25 @@ async fn a_held_turn_lock_blocks_the_same_session_until_released() { #[tokio::test] async fn every_generation_and_every_locator_over_one_destination_share_the_lock() { let session = SessionRef::scoped("thread-1", "agent"); - let _guard = lock_session_turn(&Keyed("store-a"), &session) + let _guard = lock_session_turn(&Keyed("store-generations"), &session) .await .unwrap(); // A compaction moves the head on; it is still the same conversation. A // host also rebuilds its locator per turn rather than sharing one. - let rebuilt = Keyed("store-a"); + let rebuilt = Keyed("store-generations"); assert!(is_held(&rebuilt, &session.next_generation().next_generation()).await); } #[tokio::test] async fn other_sessions_and_other_destinations_are_independent() { - let locator = Keyed("store-a"); + let locator = Keyed("store-independence"); let session = SessionRef::scoped("thread-1", "agent"); let _guard = lock_session_turn(&locator, &session).await.unwrap(); assert!(!is_held(&locator, &SessionRef::scoped("thread-2", "agent")).await); assert!(!is_held(&locator, &SessionRef::scoped("thread-1", "other-agent")).await); - assert!(!is_held(&Keyed("store-b"), &session).await); + assert!(!is_held(&Keyed("store-other-destination"), &session).await); } #[tokio::test] From f6fa08a6526b18a788cfbbb44826d7579233728d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 09:42:02 +0300 Subject: [PATCH 37/68] chore(deps): update dependencies and subproject commit This updates several dependencies, primarily bumping `windows-sys` to version 0.61.2 across multiple crates. It also updates the subproject commit for `vendor/tinyinference` to a newer version. Auto-committed-on: dragonfly --- Cargo.lock | 48 +++++++++++++++++--------------------------- vendor/tinyinference | 2 +- 2 files changed, 19 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd74bfc22..921f047bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,7 +382,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -421,7 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1632,7 +1632,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1878,7 +1878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1973,7 +1973,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2024,7 +2024,7 @@ dependencies = [ "tinyagents-harness", "tinyagents-tasks", "tinyinference-llm", - "tinystoragedrivers-core 1.0.0", + "tinystoragedrivers-core", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2059,7 +2059,7 @@ dependencies = [ "tinyinference-image", "tinyinference-llm", "tinyinference-video", - "tinystoragedrivers-core 1.0.0", + "tinystoragedrivers-core", "tinystoragedrivers-sqlite", "tinytools", "tinytools-agent", @@ -2091,7 +2091,7 @@ dependencies = [ "tinyagents-session", "tinyinference-embeddings", "tinyinference-llm", - "tinystoragedrivers-core 1.0.0", + "tinystoragedrivers-core", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2132,7 +2132,7 @@ dependencies = [ "tinyagents-session", "tinyagents-tasks", "tinyinference-llm", - "tinystoragedrivers-core 1.0.0", + "tinystoragedrivers-core", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2172,7 +2172,7 @@ dependencies = [ "tinyagents-registry", "tinyagents-session", "tinyinference-llm", - "tinystoragedrivers-core 1.0.0", + "tinystoragedrivers-core", "tinytools", "tokio", "tracing", @@ -2196,7 +2196,7 @@ dependencies = [ "tinyagents-harness", "tinyagents-session", "tinyagents-tasks", - "tinystoragedrivers-core 1.0.0", + "tinystoragedrivers-core", "tinystoragedrivers-sqlite", "tinytools-agent", "tokio", @@ -2215,7 +2215,7 @@ dependencies = [ "tempfile", "tinyagents-harness", "tinyinference-llm", - "tinystoragedrivers-core 1.0.0", + "tinystoragedrivers-core", "tinystoragedrivers-sqlite", "tinytools", "tokio", @@ -2262,7 +2262,7 @@ dependencies = [ "serde_json", "thiserror", "tinyinference-core", - "tinystoragedrivers-core 0.11.0", + "tinystoragedrivers-core", "tokio", "tracing", ] @@ -2333,18 +2333,6 @@ dependencies = [ "url", ] -[[package]] -name = "tinystoragedrivers-core" -version = "0.11.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v0.11.0#9eeef54aea403ac07461a612189bdf4982782823" -dependencies = [ - "async-trait", - "serde", - "serde_json", - "thiserror", - "tokio", -] - [[package]] name = "tinystoragedrivers-core" version = "1.0.0" @@ -2363,8 +2351,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-secrets" -version = "0.11.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v0.11.0#9eeef54aea403ac07461a612189bdf4982782823" +version = "1.0.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?tag=v1.0.0#141fd9ed0ad5d14a8fb5a1b3e837a719e81ac08d" dependencies = [ "async-trait", "atomicwrites", @@ -2376,7 +2364,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "tinystoragedrivers-core 0.11.0", + "tinystoragedrivers-core", "tokio", "winapi-util", "windows-permissions", @@ -2392,7 +2380,7 @@ dependencies = [ "rusqlite", "serde_json", "sha2 0.10.9", - "tinystoragedrivers-core 1.0.0", + "tinystoragedrivers-core", "tokio", "zstd", ] @@ -2930,7 +2918,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/vendor/tinyinference b/vendor/tinyinference index 5c413dcf0..c86f7ddbb 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 5c413dcf00d116f036d9099b226f6955e2aad14b +Subproject commit c86f7ddbbab359acbb8a0fdfec8681f2a254d558 From 318d0dde29f190d82c66b8b6d303688657d7def4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 12:00:54 +0300 Subject: [PATCH 38/68] fix(driver): add concurrent retry check for message append This change adds a check in `DriverConversationStore::append_message` to handle race conditions where a concurrent request might have already committed the same intent. This ensures that message appends remain idempotent even under high concurrency. Auto-committed-on: dragonfly --- .../src/threads/driver/append.rs | 5 ++ .../src/threads/persistence_tests.rs | 69 ++++++++++++++----- 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/crates/tinyagents-session/src/threads/driver/append.rs b/crates/tinyagents-session/src/threads/driver/append.rs index 0a71fdf41..4c92dd895 100644 --- a/crates/tinyagents-session/src/threads/driver/append.rs +++ b/crates/tinyagents-session/src/threads/driver/append.rs @@ -160,6 +160,11 @@ impl DriverConversationStore { } let stream = thread.stream()?; if thread.visible_end != offset { + // A concurrent retry with the same idempotency key may have + // committed this exact intent after the check above. + if self.committed(intent).await? { + return intent.message(); + } return Err(StorageError::serialization( "conversation append prefix is inconsistent", ) diff --git a/crates/tinyagents-session/src/threads/persistence_tests.rs b/crates/tinyagents-session/src/threads/persistence_tests.rs index 0ac782580..741f656d1 100644 --- a/crates/tinyagents-session/src/threads/persistence_tests.rs +++ b/crates/tinyagents-session/src/threads/persistence_tests.rs @@ -1,6 +1,6 @@ use super::*; use serde_json::json; -use tinystoragedrivers_core::ErrorKind; +use tinystoragedrivers_core::{ErrorKind, StorageBackend}; fn thread(id: &str) -> CreateConversationThread { CreateConversationThread { @@ -59,24 +59,57 @@ async fn memory_store_preserves_deterministic_append_and_missing_thread_behavior ); } -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 8)] async fn concurrent_user_request_id_appends_are_idempotent() { - let store = DriverConversationStore::in_memory().await; - store.ensure_thread(thread("one")).await.unwrap(); - let user = ThreadMessage { - sender: "user".into(), - ..message("user:request-1", "queued prompt") - }; - - let (first, retry) = tokio::join!( - store.append_message("one", user.clone()), - store.append_message("one", user), - ); - let (first, retry) = (first.unwrap(), retry.unwrap()); - assert_eq!(first, retry); - let messages = store.get_messages("one").await.unwrap(); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].id, "user:request-1"); + let dir = tempfile::tempdir().unwrap(); + let backend = + tinystoragedrivers_sqlite::SqliteStorage::open(dir.path().join("concurrent.db")).unwrap(); + backend.prepare_schema().await.unwrap(); + let scoped = backend + .for_scope(&tinystoragedrivers_core::Scope::new("alice").unwrap()) + .unwrap(); + DriverConversationStore::prepare_schema(scoped.documents().as_ref()) + .await + .unwrap(); + DriverConversationStore::prepare_append_schema(scoped.documents().as_ref()) + .await + .unwrap(); + let store = DriverConversationStore::new_validate_only(scoped); + for round in 0..32 { + let thread_id = format!("thread-{round}"); + store.ensure_thread(thread(&thread_id)).await.unwrap(); + let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(8)); + let mut writers = Vec::new(); + for _ in 0..8 { + let store = store.clone(); + let barrier = barrier.clone(); + let thread_id = thread_id.clone(); + writers.push(tokio::spawn(async move { + barrier.wait().await; + store + .append_message( + &thread_id, + ThreadMessage { + sender: "user".into(), + ..message("user:request-1", "queued prompt") + }, + ) + .await + })); + } + let mut result = None; + for writer in writers { + let row = writer.await.unwrap().unwrap(); + assert_eq!(row.id, "user:request-1"); + assert_eq!(row.content, "queued prompt"); + assert_eq!(result.get_or_insert(row.clone()), &row); + } + let messages = store.get_messages(&thread_id).await.unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].id, "user:request-1"); + assert_eq!(messages[0].content, "queued prompt"); + assert_eq!(messages[0].sender, "user"); + } } #[tokio::test] From e3f52b75c8aedaefbdc87989a581825ffaa461fb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 17:11:06 +0300 Subject: [PATCH 39/68] refactor(turn_runner): introduce structured failure classification This change introduces `FailureClassification` to allow hosts to pass structured failure metadata, such as copy keys and parameters, alongside a terminal event. This improves the ability to handle localized error messages while maintaining backward compatibility for existing event sinks. Auto-committed-on: dragonfly --- .../tinyagents-harness/src/turn_runner/mod.rs | 8 +- .../src/turn_runner/mod_tests.rs | 76 +++++++++++++++++++ .../src/turn_runner/types.rs | 35 +++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/crates/tinyagents-harness/src/turn_runner/mod.rs b/crates/tinyagents-harness/src/turn_runner/mod.rs index d30e56cb7..8f3f325c3 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod.rs @@ -10,10 +10,10 @@ mod types; mod work_tracker; pub use types::{ - EventSink, FailureClassifier, IngressDecision, IngressPolicy, IngressStage, LiveTurn, - QueueDisposition, ReplyStore, RequeuePayload, ScopeKey, Summarizer, TerminalEvent, TurnHost, - TurnRequest, TurnResult, TurnRunner, TurnRunnerConfig, TurnRunnerError, TurnSession, - TurnSignal, deterministic_reply_id, + EventSink, FailureClassification, FailureClassifier, IngressDecision, IngressPolicy, + IngressStage, LiveTurn, QueueDisposition, ReplyStore, RequeuePayload, ScopeKey, Summarizer, + TerminalEvent, TurnHost, TurnRequest, TurnResult, TurnRunner, TurnRunnerConfig, + TurnRunnerError, TurnSession, TurnSignal, deterministic_reply_id, }; pub use work_tracker::WorkTracker; diff --git a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs index ff2d66f7a..ec2426023 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs @@ -127,6 +127,7 @@ impl ReplyStore for Replies { struct Events { terminal: Mutex>, collected: Mutex>>, + classified_failures: Mutex>, } #[async_trait::async_trait] @@ -135,6 +136,18 @@ impl EventSink for Events { async fn terminal(&self, _request: &TurnRequest, event: TerminalEvent) { self.terminal.lock().unwrap().push(event); } + async fn classified_failure( + &self, + request: &TurnRequest, + event: TerminalEvent, + classification: FailureClassification, + ) { + self.classified_failures + .lock() + .unwrap() + .push(classification); + self.terminal(request, event).await; + } async fn collected(&self, _request: &TurnRequest, items: Vec) { self.collected .lock() @@ -2301,3 +2314,66 @@ async fn failed_terminal_event_carries_the_raw_error() { if error == "host run failed" && message == "host run failed" && class == "internal" )); } + +struct StructuredFailures; + +impl FailureClassifier for StructuredFailures { + fn classify(&self, _error: &str) -> (String, String) { + ("rate_limited".into(), "safe fallback".into()) + } + + fn classify_failure(&self, _error: &str) -> FailureClassification { + FailureClassification { + class: "rate_limited".into(), + message: "safe fallback".into(), + copy_key: Some("chat_error.rate_limited".into()), + copy_params: Some(serde_json::json!({"retrySeconds": 12})), + } + } +} + +#[tokio::test] +async fn runner_preserves_structured_failure_copy_for_the_host_sink() { + let events = Arc::new(Events::default()); + let runner = TurnRunner::new( + Arc::new(Host { + fail_run: true, + ..Host::default() + }), + Arc::new(Ingress::default()), + Arc::new(Replies::default()), + events.clone(), + Arc::new(StructuredFailures), + Arc::new(Summary), + TurnRunnerConfig::default(), + ); + + assert!(matches!( + runner + .run(request(QueueDisposition::Interrupt, false)) + .await, + Err(TurnRunnerError::Failed(_)) + )); + + assert_eq!( + events.classified_failures.lock().unwrap().as_slice(), + &[FailureClassification { + class: "rate_limited".into(), + message: "safe fallback".into(), + copy_key: Some("chat_error.rate_limited".into()), + copy_params: Some(serde_json::json!({"retrySeconds": 12})), + }] + ); +} + +#[test] +fn failure_copy_fields_default_when_reading_legacy_serialized_classification() { + let classification: FailureClassification = serde_json::from_value(serde_json::json!({ + "class": "internal", + "message": "safe fallback" + })) + .unwrap(); + + assert_eq!(classification.copy_key, None); + assert_eq!(classification.copy_params, None); +} diff --git a/crates/tinyagents-harness/src/turn_runner/types.rs b/crates/tinyagents-harness/src/turn_runner/types.rs index 76b314bb5..27651a51a 100644 --- a/crates/tinyagents-harness/src/turn_runner/types.rs +++ b/crates/tinyagents-harness/src/turn_runner/types.rs @@ -162,6 +162,20 @@ pub enum TerminalEvent { }, } +/// Neutral failure details passed from a host classifier to its event sink. +/// Hosts may attach product-owned localized copy without putting channel or UI +/// policy in the shared runner. The optional copy fields default when older +/// serialized records omit them. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FailureClassification { + pub class: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub copy_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub copy_params: Option, +} + /// Activity updates sent by the host so the idle guard distinguishes silence /// from a model or tool call that is still running. #[derive(Debug, Clone)] @@ -228,12 +242,33 @@ where { async fn progress(&self, request: &TurnRequest

, event: AgentProgress); async fn terminal(&self, request: &TurnRequest

, event: TerminalEvent); + /// Receive structured classifier output with a failure. Existing sinks + /// remain source-compatible and continue receiving the original event. + async fn classified_failure( + &self, + request: &TurnRequest

, + event: TerminalEvent, + _classification: FailureClassification, + ) { + self.terminal(request, event).await; + } async fn suggestions(&self, _request: &TurnRequest

, _suggestions: Vec) {} async fn collected(&self, _request: &TurnRequest

, _items: Vec>) {} } pub trait FailureClassifier: Send + Sync { fn classify(&self, error: &str) -> (String, String); + + /// Classify an error while optionally retaining neutral copy metadata. + /// The default keeps existing implementations compatible. + fn classify_failure(&self, error: &str) -> FailureClassification { + let (class, message) = self.classify(error); + FailureClassification { + class, + message, + ..FailureClassification::default() + } + } } #[async_trait] From 81bfe3f633fd106c242075b91fa28f12ee621315 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 17:15:39 +0300 Subject: [PATCH 40/68] refactor(turn_runner): update failure classification logic This refactors how turn failures are classified and reported within the turn runner. It introduces a dedicated `classify_failure` method and updates event emission to use the resulting classification object consistently. Auto-committed-on: dragonfly --- .../src/turn_runner/types.rs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/tinyagents-harness/src/turn_runner/types.rs b/crates/tinyagents-harness/src/turn_runner/types.rs index 27651a51a..b0b6c0f74 100644 --- a/crates/tinyagents-harness/src/turn_runner/types.rs +++ b/crates/tinyagents-harness/src/turn_runner/types.rs @@ -920,36 +920,41 @@ where error: String, generation: Option, ) -> Result { - let (class, message) = self.failures.classify(&error); + let classification = self.failures.classify_failure(&error); let queued = self.retire_generation(request, generation).await; if request.disposition == QueueDisposition::Parallel { self.clear_parallel_tracking(request).await; } for queued_request in queued { + let queued_message = format!("queued turn was not run: {}", classification.message); + let mut queued_classification = classification.clone(); + queued_classification.message = queued_message.clone(); self.events - .terminal( + .classified_failure( &queued_request, TerminalEvent::Failed { request_id: queued_request.request_id.clone(), - message: format!("queued turn was not run: {message}"), - class: class.clone(), + message: queued_message, + class: classification.class.clone(), error: error.clone(), }, + queued_classification, ) .await; } self.events - .terminal( + .classified_failure( request, TerminalEvent::Failed { request_id: request.request_id.clone(), - message: message.clone(), - class, - error, + message: classification.message.clone(), + class: classification.class.clone(), + error: error.clone(), }, + classification.clone(), ) .await; - Err(TurnRunnerError::Failed(message)) + Err(TurnRunnerError::Failed(classification.message)) } async fn finalize_generation( From 79edf09313fb24f7ca475b7273587700b11b3c6c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 17:21:42 +0300 Subject: [PATCH 41/68] chore: files changed crates/tinyagents-harness/src/turn_runner/mod_tests.rs Auto-committed-on: dragonfly --- .../src/turn_runner/mod_tests.rs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs index ec2426023..64bc751cd 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs @@ -2315,6 +2315,51 @@ async fn failed_terminal_event_carries_the_raw_error() { )); } +#[derive(Default)] +struct LegacyEvents(Mutex>); + +#[async_trait::async_trait] +impl EventSink for LegacyEvents { + async fn progress(&self, _request: &TurnRequest, _event: crate::progress::AgentProgress) {} + + async fn terminal(&self, _request: &TurnRequest, event: TerminalEvent) { + self.0.lock().unwrap().push(event); + } +} + +#[tokio::test] +async fn structured_failure_defaults_keep_legacy_event_sinks_compatible() { + let events = Arc::new(LegacyEvents::default()); + let runner = TurnRunner::new( + Arc::new(Host { + fail_run: true, + ..Host::default() + }), + Arc::new(Ingress::default()), + Arc::new(Replies::default()), + events.clone(), + Arc::new(Failures), + Arc::new(Summary), + TurnRunnerConfig::default(), + ); + + assert!(matches!( + runner + .run(request(QueueDisposition::Interrupt, false)) + .await, + Err(TurnRunnerError::Failed(_)) + )); + + assert!(matches!( + events.0.lock().unwrap().as_slice(), + [TerminalEvent::Failed { request_id, error, message, class }] + if request_id == "request-a" + && error == "host run failed" + && message == "host run failed" + && class == "internal" + )); +} + struct StructuredFailures; impl FailureClassifier for StructuredFailures { @@ -2366,6 +2411,68 @@ async fn runner_preserves_structured_failure_copy_for_the_host_sink() { ); } +#[tokio::test] +async fn queued_failures_keep_structured_copy_and_queued_message_prefix() { + let started = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let host = Arc::new(Host { + started: Some(started.clone()), + release: Some(release.clone()), + fail_run: true, + ..Host::default() + }); + let events = Arc::new(Events::default()); + let runner = Arc::new(TurnRunner::new( + host, + Arc::new(Ingress::default()), + Arc::new(Replies::default()), + events.clone(), + Arc::new(StructuredFailures), + Arc::new(Summary), + TurnRunnerConfig::default(), + )); + + let primary = { + let runner = runner.clone(); + tokio::spawn(async move { + runner + .run(request(QueueDisposition::Interrupt, false)) + .await + }) + }; + started.notified().await; + let mut queued = request(QueueDisposition::Followup, false); + queued.request_id = "queued-a".into(); + assert!(matches!( + runner.run(queued).await, + Err(TurnRunnerError::Queued) + )); + release.notify_one(); + assert!(matches!( + primary.await.unwrap(), + Err(TurnRunnerError::Failed(_)) + )); + + let failures = events.classified_failures.lock().unwrap(); + assert_eq!( + failures.as_slice(), + [ + FailureClassification { + class: "rate_limited".into(), + message: "queued turn was not run: safe fallback".into(), + copy_key: Some("chat_error.rate_limited".into()), + copy_params: Some(serde_json::json!({"retrySeconds": 12})), + }, + FailureClassification { + class: "rate_limited".into(), + message: "safe fallback".into(), + copy_key: Some("chat_error.rate_limited".into()), + copy_params: Some(serde_json::json!({"retrySeconds": 12})), + }, + ] + ); +} + #[test] fn failure_copy_fields_default_when_reading_legacy_serialized_classification() { let classification: FailureClassification = serde_json::from_value(serde_json::json!({ From bafab14ce0f694a6188c87c392fcfa223876ab96 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 17:39:06 +0300 Subject: [PATCH 42/68] feat(deps): add json-schema support This introduces optional support for generating JSON Schemas for various types across the graph and harness crates. This allows consumers to introspect the data structures without making schema generation a default dependency. Auto-committed-on: dragonfly --- crates/tinyagents-graph/Cargo.toml | 4 ++++ crates/tinyagents-graph/src/goals/types.rs | 2 ++ crates/tinyagents-graph/src/todos/types.rs | 2 ++ crates/tinyagents-harness/Cargo.toml | 3 +++ crates/tinyagents-harness/src/middleware/library/types.rs | 4 +++- 5 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-graph/Cargo.toml b/crates/tinyagents-graph/Cargo.toml index 2a8a3540a..f24783338 100644 --- a/crates/tinyagents-graph/Cargo.toml +++ b/crates/tinyagents-graph/Cargo.toml @@ -14,6 +14,7 @@ async-trait = { workspace = true } futures = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +schemars = { version = "1", features = ["derive"], optional = true } tinyagents-tasks = { path = "../tinyagents-tasks", version = "2.1.4", default-features = false } tinyagents-harness = { path = "../tinyagents-harness", version = "2.1.4", default-features = false, features = [ "langfuse", @@ -37,6 +38,9 @@ storage-drivers = [] # dependency above). This feature is retained as a no-op so downstream # feature forwards keep compiling. tracing = ["tinyagents-harness/tracing"] +# Exposes JSON Schema for the graph-owned thread goal and todo records without +# making schema generation a default dependency of graph consumers. +json-schema = ["dep:schemars", "tinyagents-harness/json-schema"] [dev-dependencies] tinystoragedrivers-sqlite = { workspace = true } diff --git a/crates/tinyagents-graph/src/goals/types.rs b/crates/tinyagents-graph/src/goals/types.rs index fb7dd1c9f..b84a2bf60 100644 --- a/crates/tinyagents-graph/src/goals/types.rs +++ b/crates/tinyagents-graph/src/goals/types.rs @@ -20,6 +20,7 @@ use serde::{Deserialize, Serialize}; /// (host control and accounting respectively), and clearing deletes the row /// entirely rather than being a status. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum ThreadGoalStatus { /// The graph may make progress and (when driven) auto-continue. @@ -61,6 +62,7 @@ impl ThreadGoalStatus { /// A single thread-scoped goal. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct ThreadGoal { /// The thread this goal belongs to (one goal per thread). diff --git a/crates/tinyagents-graph/src/todos/types.rs b/crates/tinyagents-graph/src/todos/types.rs index 5c20f668f..eef197e98 100644 --- a/crates/tinyagents-graph/src/todos/types.rs +++ b/crates/tinyagents-graph/src/todos/types.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; /// Lifecycle state of a todo item. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum TodoStatus { /// Not started. @@ -42,6 +43,7 @@ impl TodoStatus { /// One item on a thread's todo list. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct TodoItem { /// The step, as the model wrote it. diff --git a/crates/tinyagents-harness/Cargo.toml b/crates/tinyagents-harness/Cargo.toml index 41f2b39c1..cb996de28 100644 --- a/crates/tinyagents-harness/Cargo.toml +++ b/crates/tinyagents-harness/Cargo.toml @@ -26,6 +26,7 @@ regex = "1" reqwest = { workspace = true, features = ["stream", "http2"], optional = true } serde = { workspace = true } serde_json = { workspace = true } +schemars = { version = "1", features = ["derive"], optional = true } sha2 = "0.11" thiserror = "2" tracing = { workspace = true } @@ -71,6 +72,8 @@ media = ["dep:tinyinference-image", "dep:tinyinference-video"] claude-code = ["dep:wait-timeout", "dep:dirs"] # Gates the Langfuse observability exporter and its `reqwest` transport. langfuse = ["dep:reqwest"] +# Optional schema support for public harness wire types such as `RunMode`. +json-schema = ["dep:schemars"] # Tracing instrumentation is now always compiled in (via the `tracing` crate # dependency above). This feature is retained as a no-op so downstream # feature forwards keep compiling. diff --git a/crates/tinyagents-harness/src/middleware/library/types.rs b/crates/tinyagents-harness/src/middleware/library/types.rs index 7da300f24..2a0cef1cf 100644 --- a/crates/tinyagents-harness/src/middleware/library/types.rs +++ b/crates/tinyagents-harness/src/middleware/library/types.rs @@ -637,7 +637,9 @@ pub struct TraceCounts { /// the middleware's allowlist, which stay available in either mode so a host /// can keep read-only tools and a handful of plan-mode-specific tools (e.g. /// `plan_exit`, `request_plan_review`, `todo`) reachable while planning. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] pub enum RunMode { /// Every registered tool may be exposed and executed. The default. #[default] From 915fc1ffe25b444f150da056da7f9bb21bf87a8b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 17:39:38 +0300 Subject: [PATCH 43/68] test(all): add json schema validation tests Adds tests to verify that the generated JSON schemas for goals, todos, and run modes correctly reflect the serialized data structures and enum values. This ensures consistency between the Rust types and their external JSON representations when the json-schema feature is enabled. Auto-committed-on: dragonfly --- .../tinyagents-graph/src/goals/mod_tests.rs | 25 +++++++++++++++++++ .../tinyagents-graph/src/todos/mod_tests.rs | 18 +++++++++++++ .../src/middleware/library/mod_tests.rs | 16 ++++++++++++ 3 files changed, 59 insertions(+) diff --git a/crates/tinyagents-graph/src/goals/mod_tests.rs b/crates/tinyagents-graph/src/goals/mod_tests.rs index 9a884a42a..36fb1dae2 100644 --- a/crates/tinyagents-graph/src/goals/mod_tests.rs +++ b/crates/tinyagents-graph/src/goals/mod_tests.rs @@ -26,6 +26,31 @@ fn status_strings_match_serialized() { assert_eq!(ThreadGoalStatus::Complete.as_str(), "complete"); } +#[cfg(feature = "json-schema")] +#[test] +fn public_goal_schema_uses_the_serialized_camel_case_record() { + let schema = serde_json::to_value(schemars::schema_for!(ThreadGoal)).unwrap(); + let properties = &schema["properties"]; + for field in [ + "threadId", + "goalId", + "objective", + "status", + "tokenBudget", + "tokensUsed", + "timeUsedSeconds", + "createdAtMs", + "updatedAtMs", + "continuationSuppressed", + ] { + assert!(properties.get(field).is_some(), "missing {field}: {schema}"); + } + assert_eq!( + schema["$defs"]["ThreadGoalStatus"]["enum"], + serde_json::json!(["active", "paused", "budget_limited", "complete"]) + ); +} + #[test] fn active_and_terminal_predicates() { assert!(ThreadGoalStatus::Active.is_active()); diff --git a/crates/tinyagents-graph/src/todos/mod_tests.rs b/crates/tinyagents-graph/src/todos/mod_tests.rs index aadd8c87c..6a0dd9297 100644 --- a/crates/tinyagents-graph/src/todos/mod_tests.rs +++ b/crates/tinyagents-graph/src/todos/mod_tests.rs @@ -25,6 +25,24 @@ fn status_strings_match_serialized() { } } +#[cfg(feature = "json-schema")] +#[test] +fn public_todo_schema_matches_the_serialized_item_shape() { + let schema = serde_json::to_value(schemars::schema_for!(TodoItem)).unwrap(); + let properties = &schema["properties"]; + assert!(properties.get("content").is_some()); + assert!(properties.get("status").is_some()); + assert!(properties.get("reason").is_some()); + assert_eq!( + schema["$defs"]["TodoStatus"]["enum"], + serde_json::json!(["pending", "in_progress", "completed", "cancelled"]) + ); + assert_eq!( + serde_json::to_value(TodoItem::new("ship it")).unwrap(), + serde_json::json!({"content": "ship it", "status": "pending"}) + ); +} + #[test] fn parse_status_accepts_aliases() { assert_eq!(parse_status("pending").unwrap(), TodoStatus::Pending); diff --git a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs index 3f25e7950..d058ea298 100644 --- a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs +++ b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs @@ -1943,3 +1943,19 @@ async fn run_mode_handle_set_takes_effect_immediately_on_shared_clones() { .await .expect_err("plan mode now denies the same call"); } + +#[cfg(feature = "json-schema")] +#[test] +fn run_mode_schema_and_serde_use_the_wire_discriminants() { + let schema = serde_json::to_value(schemars::schema_for!(RunMode)).unwrap(); + assert_eq!(schema["enum"], json!(["build", "plan"])); + assert_eq!( + serde_json::to_value(RunMode::Build).unwrap(), + json!("build") + ); + assert_eq!(serde_json::to_value(RunMode::Plan).unwrap(), json!("plan")); + assert_eq!( + serde_json::from_value::(json!("plan")).unwrap(), + RunMode::Plan + ); +} From b0e280c6632cf7e26347f7bacbea05ca099effa3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 17:41:59 +0300 Subject: [PATCH 44/68] chore(deps): update dependencies This updates the Cargo.lock file to include the `schemars` crate in the project dependencies. This is necessary to support schema generation features. Auto-committed-on: dragonfly --- Cargo.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 921f047bc..1e1bb696a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2018,6 +2018,7 @@ dependencies = [ "anyhow", "async-trait", "futures", + "schemars", "serde", "serde_json", "tempfile", @@ -2047,6 +2048,7 @@ dependencies = [ "png", "regex", "reqwest", + "schemars", "serde", "serde_json", "sha2 0.11.0", From 4baa365485e367762717bd4d091dd552f6bd7811 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 17:45:38 +0300 Subject: [PATCH 45/68] test(schema): update schema generation in tests Updates the way JSON schemas are generated in unit tests across several modules. This change switches from a simpler schema generation call to using `schemars::generate` with `draft2020_12` settings for more precise schema definition. Auto-committed-on: dragonfly --- crates/tinyagents-graph/src/goals/mod_tests.rs | 8 +++++++- crates/tinyagents-graph/src/todos/mod_tests.rs | 8 +++++++- .../src/middleware/library/mod_tests.rs | 8 +++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-graph/src/goals/mod_tests.rs b/crates/tinyagents-graph/src/goals/mod_tests.rs index 36fb1dae2..4cc6df6f0 100644 --- a/crates/tinyagents-graph/src/goals/mod_tests.rs +++ b/crates/tinyagents-graph/src/goals/mod_tests.rs @@ -29,7 +29,13 @@ fn status_strings_match_serialized() { #[cfg(feature = "json-schema")] #[test] fn public_goal_schema_uses_the_serialized_camel_case_record() { - let schema = serde_json::to_value(schemars::schema_for!(ThreadGoal)).unwrap(); + let schema = serde_json::to_value( + schemars::generate::SchemaSettings::draft2020_12() + .for_serialize() + .into_generator() + .into_root_schema_for::(), + ) + .unwrap(); let properties = &schema["properties"]; for field in [ "threadId", diff --git a/crates/tinyagents-graph/src/todos/mod_tests.rs b/crates/tinyagents-graph/src/todos/mod_tests.rs index 6a0dd9297..e9e5f6c7f 100644 --- a/crates/tinyagents-graph/src/todos/mod_tests.rs +++ b/crates/tinyagents-graph/src/todos/mod_tests.rs @@ -28,7 +28,13 @@ fn status_strings_match_serialized() { #[cfg(feature = "json-schema")] #[test] fn public_todo_schema_matches_the_serialized_item_shape() { - let schema = serde_json::to_value(schemars::schema_for!(TodoItem)).unwrap(); + let schema = serde_json::to_value( + schemars::generate::SchemaSettings::draft2020_12() + .for_serialize() + .into_generator() + .into_root_schema_for::(), + ) + .unwrap(); let properties = &schema["properties"]; assert!(properties.get("content").is_some()); assert!(properties.get("status").is_some()); diff --git a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs index d058ea298..f7cbe4d0d 100644 --- a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs +++ b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs @@ -1947,7 +1947,13 @@ async fn run_mode_handle_set_takes_effect_immediately_on_shared_clones() { #[cfg(feature = "json-schema")] #[test] fn run_mode_schema_and_serde_use_the_wire_discriminants() { - let schema = serde_json::to_value(schemars::schema_for!(RunMode)).unwrap(); + let schema = serde_json::to_value( + schemars::generate::SchemaSettings::draft2020_12() + .for_serialize() + .into_generator() + .into_root_schema_for::(), + ) + .unwrap(); assert_eq!(schema["enum"], json!(["build", "plan"])); assert_eq!( serde_json::to_value(RunMode::Build).unwrap(), From 0b15f138ac6800499ae688b8cd63ffb5b2703cc0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 17:49:19 +0300 Subject: [PATCH 46/68] test(tests): update schema validation assertions Refactors the schema validation tests in goals and todos modules to use more robust assertions. Instead of checking for a specific enum array, the tests now verify that the status field is an object and that the enum values serialize correctly. Auto-committed-on: dragonfly --- crates/tinyagents-graph/src/goals/mod_tests.rs | 13 +++++++++---- crates/tinyagents-graph/src/todos/mod_tests.rs | 5 +---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/tinyagents-graph/src/goals/mod_tests.rs b/crates/tinyagents-graph/src/goals/mod_tests.rs index 4cc6df6f0..fbd87a81a 100644 --- a/crates/tinyagents-graph/src/goals/mod_tests.rs +++ b/crates/tinyagents-graph/src/goals/mod_tests.rs @@ -51,10 +51,15 @@ fn public_goal_schema_uses_the_serialized_camel_case_record() { ] { assert!(properties.get(field).is_some(), "missing {field}: {schema}"); } - assert_eq!( - schema["$defs"]["ThreadGoalStatus"]["enum"], - serde_json::json!(["active", "paused", "budget_limited", "complete"]) - ); + assert!(properties["status"].is_object()); + for (status, serialized) in [ + (ThreadGoalStatus::Active, "active"), + (ThreadGoalStatus::Paused, "paused"), + (ThreadGoalStatus::BudgetLimited, "budget_limited"), + (ThreadGoalStatus::Complete, "complete"), + ] { + assert_eq!(serde_json::to_value(status).unwrap(), serialized); + } } #[test] diff --git a/crates/tinyagents-graph/src/todos/mod_tests.rs b/crates/tinyagents-graph/src/todos/mod_tests.rs index e9e5f6c7f..3bed28034 100644 --- a/crates/tinyagents-graph/src/todos/mod_tests.rs +++ b/crates/tinyagents-graph/src/todos/mod_tests.rs @@ -39,10 +39,7 @@ fn public_todo_schema_matches_the_serialized_item_shape() { assert!(properties.get("content").is_some()); assert!(properties.get("status").is_some()); assert!(properties.get("reason").is_some()); - assert_eq!( - schema["$defs"]["TodoStatus"]["enum"], - serde_json::json!(["pending", "in_progress", "completed", "cancelled"]) - ); + assert!(properties["status"].is_object()); assert_eq!( serde_json::to_value(TodoItem::new("ship it")).unwrap(), serde_json::json!({"content": "ship it", "status": "pending"}) From 584c422db24b52ddb8e9e066afd2d9c8b94fd20e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 17:53:24 +0300 Subject: [PATCH 47/68] test(tests): update schema validation for enums This updates several unit tests to assert the specific values within JSON schema enums instead of just checking for object presence. This ensures that the serialized values match the expected constants for goals, todos, and run modes. Auto-committed-on: dragonfly --- crates/tinyagents-graph/src/goals/mod_tests.rs | 16 +++++++++++++++- crates/tinyagents-graph/src/todos/mod_tests.rs | 16 +++++++++++++++- .../src/middleware/library/mod_tests.rs | 8 +++++++- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-graph/src/goals/mod_tests.rs b/crates/tinyagents-graph/src/goals/mod_tests.rs index fbd87a81a..717ba1304 100644 --- a/crates/tinyagents-graph/src/goals/mod_tests.rs +++ b/crates/tinyagents-graph/src/goals/mod_tests.rs @@ -51,7 +51,21 @@ fn public_goal_schema_uses_the_serialized_camel_case_record() { ] { assert!(properties.get(field).is_some(), "missing {field}: {schema}"); } - assert!(properties["status"].is_object()); + let status_values = properties["status"]["oneOf"] + .as_array() + .unwrap() + .iter() + .map(|variant| variant["const"].clone()) + .collect::>(); + assert_eq!( + status_values, + vec![ + serde_json::json!("active"), + serde_json::json!("paused"), + serde_json::json!("budget_limited"), + serde_json::json!("complete"), + ] + ); for (status, serialized) in [ (ThreadGoalStatus::Active, "active"), (ThreadGoalStatus::Paused, "paused"), diff --git a/crates/tinyagents-graph/src/todos/mod_tests.rs b/crates/tinyagents-graph/src/todos/mod_tests.rs index 3bed28034..f770e004f 100644 --- a/crates/tinyagents-graph/src/todos/mod_tests.rs +++ b/crates/tinyagents-graph/src/todos/mod_tests.rs @@ -39,7 +39,21 @@ fn public_todo_schema_matches_the_serialized_item_shape() { assert!(properties.get("content").is_some()); assert!(properties.get("status").is_some()); assert!(properties.get("reason").is_some()); - assert!(properties["status"].is_object()); + let status_values = properties["status"]["oneOf"] + .as_array() + .unwrap() + .iter() + .map(|variant| variant["const"].clone()) + .collect::>(); + assert_eq!( + status_values, + vec![ + serde_json::json!("pending"), + serde_json::json!("in_progress"), + serde_json::json!("completed"), + serde_json::json!("cancelled"), + ] + ); assert_eq!( serde_json::to_value(TodoItem::new("ship it")).unwrap(), serde_json::json!({"content": "ship it", "status": "pending"}) diff --git a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs index f7cbe4d0d..e5035b61d 100644 --- a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs +++ b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs @@ -1954,7 +1954,13 @@ fn run_mode_schema_and_serde_use_the_wire_discriminants() { .into_root_schema_for::(), ) .unwrap(); - assert_eq!(schema["enum"], json!(["build", "plan"])); + let mode_values = schema["oneOf"] + .as_array() + .unwrap() + .iter() + .map(|variant| variant["const"].clone()) + .collect::>(); + assert_eq!(mode_values, vec![json!("build"), json!("plan")]); assert_eq!( serde_json::to_value(RunMode::Build).unwrap(), json!("build") From 714a7ce52ca78f638dab9348cbc15712a7422c21 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 17:57:18 +0300 Subject: [PATCH 48/68] test(tests): update schema validation assertions in goal and todo tests This updates the schema validation tests for goals and todos to correctly assert against the defined status references instead of directly checking the `oneOf` array. This makes the tests more robust against internal schema structure changes. Auto-committed-on: dragonfly --- crates/tinyagents-graph/src/goals/mod_tests.rs | 3 ++- crates/tinyagents-graph/src/todos/mod_tests.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-graph/src/goals/mod_tests.rs b/crates/tinyagents-graph/src/goals/mod_tests.rs index 717ba1304..2c6c0e7c3 100644 --- a/crates/tinyagents-graph/src/goals/mod_tests.rs +++ b/crates/tinyagents-graph/src/goals/mod_tests.rs @@ -51,7 +51,8 @@ fn public_goal_schema_uses_the_serialized_camel_case_record() { ] { assert!(properties.get(field).is_some(), "missing {field}: {schema}"); } - let status_values = properties["status"]["oneOf"] + assert_eq!(properties["status"]["$ref"], "#/$defs/ThreadGoalStatus"); + let status_values = schema["$defs"]["ThreadGoalStatus"]["oneOf"] .as_array() .unwrap() .iter() diff --git a/crates/tinyagents-graph/src/todos/mod_tests.rs b/crates/tinyagents-graph/src/todos/mod_tests.rs index f770e004f..39bd60000 100644 --- a/crates/tinyagents-graph/src/todos/mod_tests.rs +++ b/crates/tinyagents-graph/src/todos/mod_tests.rs @@ -39,7 +39,8 @@ fn public_todo_schema_matches_the_serialized_item_shape() { assert!(properties.get("content").is_some()); assert!(properties.get("status").is_some()); assert!(properties.get("reason").is_some()); - let status_values = properties["status"]["oneOf"] + assert_eq!(properties["status"]["$ref"], "#/$defs/TodoStatus"); + let status_values = schema["$defs"]["TodoStatus"]["oneOf"] .as_array() .unwrap() .iter() From 9ea19341e5e05075f9955a9a02de1bfd9e5f2a74 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 17:58:15 +0300 Subject: [PATCH 49/68] refactor(tests): introduce schema_string_values helper A helper function `schema_string_values` has been added to test modules to correctly extract and sort string enum values from JSON schemas. This standardizes how enum values are asserted in tests across different components. Auto-committed-on: dragonfly --- .../tinyagents-graph/src/goals/mod_tests.rs | 52 ++++++++++++++----- .../tinyagents-graph/src/todos/mod_tests.rs | 52 ++++++++++++++----- .../src/middleware/library/mod_tests.rs | 39 +++++++++++--- 3 files changed, 110 insertions(+), 33 deletions(-) diff --git a/crates/tinyagents-graph/src/goals/mod_tests.rs b/crates/tinyagents-graph/src/goals/mod_tests.rs index 2c6c0e7c3..155cc6806 100644 --- a/crates/tinyagents-graph/src/goals/mod_tests.rs +++ b/crates/tinyagents-graph/src/goals/mod_tests.rs @@ -3,6 +3,36 @@ use super::prompt::*; use super::types::*; +#[cfg(feature = "json-schema")] +fn schema_string_values(root: &serde_json::Value, mut schema: &serde_json::Value) -> Vec { + while let Some(reference) = schema.get("$ref").and_then(serde_json::Value::as_str) { + let pointer = reference.strip_prefix('#').expect("local schema reference"); + schema = root.pointer(pointer).expect("schema reference resolves"); + } + + let mut values = if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) { + values + .iter() + .map(|value| value.as_str().expect("string enum value").to_owned()) + .collect() + } else { + schema + .get("oneOf") + .and_then(serde_json::Value::as_array) + .expect("string enum or oneOf schema") + .iter() + .map(|variant| { + variant["const"] + .as_str() + .expect("string oneOf const") + .to_owned() + }) + .collect() + }; + values.sort(); + values +} + fn goal(status: ThreadGoalStatus, token_budget: Option, tokens_used: u64) -> ThreadGoal { ThreadGoal { thread_id: "t".into(), @@ -51,21 +81,17 @@ fn public_goal_schema_uses_the_serialized_camel_case_record() { ] { assert!(properties.get(field).is_some(), "missing {field}: {schema}"); } - assert_eq!(properties["status"]["$ref"], "#/$defs/ThreadGoalStatus"); - let status_values = schema["$defs"]["ThreadGoalStatus"]["oneOf"] - .as_array() - .unwrap() - .iter() - .map(|variant| variant["const"].clone()) - .collect::>(); + let status_values = schema_string_values(&schema, &properties["status"]); + let mut expected_status_values = vec![ + "active".to_owned(), + "paused".to_owned(), + "budget_limited".to_owned(), + "complete".to_owned(), + ]; + expected_status_values.sort(); assert_eq!( status_values, - vec![ - serde_json::json!("active"), - serde_json::json!("paused"), - serde_json::json!("budget_limited"), - serde_json::json!("complete"), - ] + expected_status_values ); for (status, serialized) in [ (ThreadGoalStatus::Active, "active"), diff --git a/crates/tinyagents-graph/src/todos/mod_tests.rs b/crates/tinyagents-graph/src/todos/mod_tests.rs index 39bd60000..6ed33fe0d 100644 --- a/crates/tinyagents-graph/src/todos/mod_tests.rs +++ b/crates/tinyagents-graph/src/todos/mod_tests.rs @@ -2,6 +2,36 @@ use super::types::*; +#[cfg(feature = "json-schema")] +fn schema_string_values(root: &serde_json::Value, mut schema: &serde_json::Value) -> Vec { + while let Some(reference) = schema.get("$ref").and_then(serde_json::Value::as_str) { + let pointer = reference.strip_prefix('#').expect("local schema reference"); + schema = root.pointer(pointer).expect("schema reference resolves"); + } + + let mut values = if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) { + values + .iter() + .map(|value| value.as_str().expect("string enum value").to_owned()) + .collect() + } else { + schema + .get("oneOf") + .and_then(serde_json::Value::as_array) + .expect("string enum or oneOf schema") + .iter() + .map(|variant| { + variant["const"] + .as_str() + .expect("string oneOf const") + .to_owned() + }) + .collect() + }; + values.sort(); + values +} + fn item(content: &str, status: TodoStatus) -> TodoItem { TodoItem::with_status(content, status) } @@ -39,21 +69,17 @@ fn public_todo_schema_matches_the_serialized_item_shape() { assert!(properties.get("content").is_some()); assert!(properties.get("status").is_some()); assert!(properties.get("reason").is_some()); - assert_eq!(properties["status"]["$ref"], "#/$defs/TodoStatus"); - let status_values = schema["$defs"]["TodoStatus"]["oneOf"] - .as_array() - .unwrap() - .iter() - .map(|variant| variant["const"].clone()) - .collect::>(); + let status_values = schema_string_values(&schema, &properties["status"]); + let mut expected_status_values = vec![ + "pending".to_owned(), + "in_progress".to_owned(), + "completed".to_owned(), + "cancelled".to_owned(), + ]; + expected_status_values.sort(); assert_eq!( status_values, - vec![ - serde_json::json!("pending"), - serde_json::json!("in_progress"), - serde_json::json!("completed"), - serde_json::json!("cancelled"), - ] + expected_status_values ); assert_eq!( serde_json::to_value(TodoItem::new("ship it")).unwrap(), diff --git a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs index e5035b61d..581d7a228 100644 --- a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs +++ b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs @@ -17,6 +17,36 @@ use tinyinference_llm::model::{ModelRequest, ModelResponse, ResponseFormat}; use tinyinference_llm::tool::{ToolCall, ToolFormat, ToolSchema}; use tinytools::{SandboxMode, ToolAccess, ToolPolicy, ToolResult, ToolRuntime, ToolSideEffects}; +#[cfg(feature = "json-schema")] +fn schema_string_values(root: &serde_json::Value, mut schema: &serde_json::Value) -> Vec { + while let Some(reference) = schema.get("$ref").and_then(serde_json::Value::as_str) { + let pointer = reference.strip_prefix('#').expect("local schema reference"); + schema = root.pointer(pointer).expect("schema reference resolves"); + } + + let mut values = if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) { + values + .iter() + .map(|value| value.as_str().expect("string enum value").to_owned()) + .collect() + } else { + schema + .get("oneOf") + .and_then(serde_json::Value::as_array) + .expect("string enum or oneOf schema") + .iter() + .map(|variant| { + variant["const"] + .as_str() + .expect("string oneOf const") + .to_owned() + }) + .collect() + }; + values.sort(); + values +} + // ── helpers ─────────────────────────────────────────────────────────────────── fn ctx_with_recorder() -> (RunContext, Arc) { @@ -1954,13 +1984,8 @@ fn run_mode_schema_and_serde_use_the_wire_discriminants() { .into_root_schema_for::(), ) .unwrap(); - let mode_values = schema["oneOf"] - .as_array() - .unwrap() - .iter() - .map(|variant| variant["const"].clone()) - .collect::>(); - assert_eq!(mode_values, vec![json!("build"), json!("plan")]); + let mode_values = schema_string_values(&schema, &schema); + assert_eq!(mode_values, vec!["build".to_owned(), "plan".to_owned()]); assert_eq!( serde_json::to_value(RunMode::Build).unwrap(), json!("build") From b20bd4af98a65d83a24aa93b71769e72b44f96c1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 17:58:24 +0300 Subject: [PATCH 50/68] test(tests): clean up test code formatting This change refactors several test files to simplify assertion syntax and improve code layout. It removes unnecessary line breaks around assertions in goal, todo, and harness tests. Auto-committed-on: dragonfly --- crates/tinyagents-graph/src/goals/mod_tests.rs | 8 +++----- crates/tinyagents-graph/src/todos/mod_tests.rs | 8 +++----- .../src/middleware/library/mod_tests.rs | 3 ++- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/crates/tinyagents-graph/src/goals/mod_tests.rs b/crates/tinyagents-graph/src/goals/mod_tests.rs index 155cc6806..f66c77e19 100644 --- a/crates/tinyagents-graph/src/goals/mod_tests.rs +++ b/crates/tinyagents-graph/src/goals/mod_tests.rs @@ -10,7 +10,8 @@ fn schema_string_values(root: &serde_json::Value, mut schema: &serde_json::Value schema = root.pointer(pointer).expect("schema reference resolves"); } - let mut values = if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) { + let mut values = if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) + { values .iter() .map(|value| value.as_str().expect("string enum value").to_owned()) @@ -89,10 +90,7 @@ fn public_goal_schema_uses_the_serialized_camel_case_record() { "complete".to_owned(), ]; expected_status_values.sort(); - assert_eq!( - status_values, - expected_status_values - ); + assert_eq!(status_values, expected_status_values); for (status, serialized) in [ (ThreadGoalStatus::Active, "active"), (ThreadGoalStatus::Paused, "paused"), diff --git a/crates/tinyagents-graph/src/todos/mod_tests.rs b/crates/tinyagents-graph/src/todos/mod_tests.rs index 6ed33fe0d..a24162785 100644 --- a/crates/tinyagents-graph/src/todos/mod_tests.rs +++ b/crates/tinyagents-graph/src/todos/mod_tests.rs @@ -9,7 +9,8 @@ fn schema_string_values(root: &serde_json::Value, mut schema: &serde_json::Value schema = root.pointer(pointer).expect("schema reference resolves"); } - let mut values = if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) { + let mut values = if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) + { values .iter() .map(|value| value.as_str().expect("string enum value").to_owned()) @@ -77,10 +78,7 @@ fn public_todo_schema_matches_the_serialized_item_shape() { "cancelled".to_owned(), ]; expected_status_values.sort(); - assert_eq!( - status_values, - expected_status_values - ); + assert_eq!(status_values, expected_status_values); assert_eq!( serde_json::to_value(TodoItem::new("ship it")).unwrap(), serde_json::json!({"content": "ship it", "status": "pending"}) diff --git a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs index 581d7a228..00892eb49 100644 --- a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs +++ b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs @@ -24,7 +24,8 @@ fn schema_string_values(root: &serde_json::Value, mut schema: &serde_json::Value schema = root.pointer(pointer).expect("schema reference resolves"); } - let mut values = if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) { + let mut values = if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) + { values .iter() .map(|value| value.as_str().expect("string enum value").to_owned()) From 3d13aede4f825497f075663608b703559f88e303 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 17:59:35 +0300 Subject: [PATCH 51/68] test(all): update schema_string_values function signatures in tests This change refactors the `schema_string_values` function across multiple test modules to explicitly type the `values` variable as `Vec`. This improves type safety and clarity within the test implementations. Auto-committed-on: dragonfly --- .../tinyagents-graph/src/goals/mod_tests.rs | 40 +++++++++---------- .../tinyagents-graph/src/todos/mod_tests.rs | 40 +++++++++---------- .../src/middleware/library/mod_tests.rs | 40 +++++++++---------- 3 files changed, 60 insertions(+), 60 deletions(-) diff --git a/crates/tinyagents-graph/src/goals/mod_tests.rs b/crates/tinyagents-graph/src/goals/mod_tests.rs index f66c77e19..539796fc3 100644 --- a/crates/tinyagents-graph/src/goals/mod_tests.rs +++ b/crates/tinyagents-graph/src/goals/mod_tests.rs @@ -10,26 +10,26 @@ fn schema_string_values(root: &serde_json::Value, mut schema: &serde_json::Value schema = root.pointer(pointer).expect("schema reference resolves"); } - let mut values = if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) - { - values - .iter() - .map(|value| value.as_str().expect("string enum value").to_owned()) - .collect() - } else { - schema - .get("oneOf") - .and_then(serde_json::Value::as_array) - .expect("string enum or oneOf schema") - .iter() - .map(|variant| { - variant["const"] - .as_str() - .expect("string oneOf const") - .to_owned() - }) - .collect() - }; + let mut values: Vec = + if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) { + values + .iter() + .map(|value| value.as_str().expect("string enum value").to_owned()) + .collect() + } else { + schema + .get("oneOf") + .and_then(serde_json::Value::as_array) + .expect("string enum or oneOf schema") + .iter() + .map(|variant| { + variant["const"] + .as_str() + .expect("string oneOf const") + .to_owned() + }) + .collect() + }; values.sort(); values } diff --git a/crates/tinyagents-graph/src/todos/mod_tests.rs b/crates/tinyagents-graph/src/todos/mod_tests.rs index a24162785..87753ddd5 100644 --- a/crates/tinyagents-graph/src/todos/mod_tests.rs +++ b/crates/tinyagents-graph/src/todos/mod_tests.rs @@ -9,26 +9,26 @@ fn schema_string_values(root: &serde_json::Value, mut schema: &serde_json::Value schema = root.pointer(pointer).expect("schema reference resolves"); } - let mut values = if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) - { - values - .iter() - .map(|value| value.as_str().expect("string enum value").to_owned()) - .collect() - } else { - schema - .get("oneOf") - .and_then(serde_json::Value::as_array) - .expect("string enum or oneOf schema") - .iter() - .map(|variant| { - variant["const"] - .as_str() - .expect("string oneOf const") - .to_owned() - }) - .collect() - }; + let mut values: Vec = + if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) { + values + .iter() + .map(|value| value.as_str().expect("string enum value").to_owned()) + .collect() + } else { + schema + .get("oneOf") + .and_then(serde_json::Value::as_array) + .expect("string enum or oneOf schema") + .iter() + .map(|variant| { + variant["const"] + .as_str() + .expect("string oneOf const") + .to_owned() + }) + .collect() + }; values.sort(); values } diff --git a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs index 00892eb49..73a098061 100644 --- a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs +++ b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs @@ -24,26 +24,26 @@ fn schema_string_values(root: &serde_json::Value, mut schema: &serde_json::Value schema = root.pointer(pointer).expect("schema reference resolves"); } - let mut values = if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) - { - values - .iter() - .map(|value| value.as_str().expect("string enum value").to_owned()) - .collect() - } else { - schema - .get("oneOf") - .and_then(serde_json::Value::as_array) - .expect("string enum or oneOf schema") - .iter() - .map(|variant| { - variant["const"] - .as_str() - .expect("string oneOf const") - .to_owned() - }) - .collect() - }; + let mut values: Vec = + if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) { + values + .iter() + .map(|value| value.as_str().expect("string enum value").to_owned()) + .collect() + } else { + schema + .get("oneOf") + .and_then(serde_json::Value::as_array) + .expect("string enum or oneOf schema") + .iter() + .map(|variant| { + variant["const"] + .as_str() + .expect("string oneOf const") + .to_owned() + }) + .collect() + }; values.sort(); values } From c98d8ef67415c5be45309dc02e06ac2bb17a7e6f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 18:00:18 +0300 Subject: [PATCH 52/68] refactor(tests): add lifetime annotations to schema_string_values Lifetime annotations were added to the `schema_string_values` function signature across multiple test modules to correctly handle borrowed references to JSON values. This improves type safety for functions operating on schema definitions. Auto-committed-on: dragonfly --- crates/tinyagents-graph/src/goals/mod_tests.rs | 5 ++++- crates/tinyagents-graph/src/todos/mod_tests.rs | 5 ++++- .../tinyagents-harness/src/middleware/library/mod_tests.rs | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-graph/src/goals/mod_tests.rs b/crates/tinyagents-graph/src/goals/mod_tests.rs index 539796fc3..ea0d817d8 100644 --- a/crates/tinyagents-graph/src/goals/mod_tests.rs +++ b/crates/tinyagents-graph/src/goals/mod_tests.rs @@ -4,7 +4,10 @@ use super::prompt::*; use super::types::*; #[cfg(feature = "json-schema")] -fn schema_string_values(root: &serde_json::Value, mut schema: &serde_json::Value) -> Vec { +fn schema_string_values<'a>( + root: &'a serde_json::Value, + mut schema: &'a serde_json::Value, +) -> Vec { while let Some(reference) = schema.get("$ref").and_then(serde_json::Value::as_str) { let pointer = reference.strip_prefix('#').expect("local schema reference"); schema = root.pointer(pointer).expect("schema reference resolves"); diff --git a/crates/tinyagents-graph/src/todos/mod_tests.rs b/crates/tinyagents-graph/src/todos/mod_tests.rs index 87753ddd5..d7c83d559 100644 --- a/crates/tinyagents-graph/src/todos/mod_tests.rs +++ b/crates/tinyagents-graph/src/todos/mod_tests.rs @@ -3,7 +3,10 @@ use super::types::*; #[cfg(feature = "json-schema")] -fn schema_string_values(root: &serde_json::Value, mut schema: &serde_json::Value) -> Vec { +fn schema_string_values<'a>( + root: &'a serde_json::Value, + mut schema: &'a serde_json::Value, +) -> Vec { while let Some(reference) = schema.get("$ref").and_then(serde_json::Value::as_str) { let pointer = reference.strip_prefix('#').expect("local schema reference"); schema = root.pointer(pointer).expect("schema reference resolves"); diff --git a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs index 73a098061..bb1a778a8 100644 --- a/crates/tinyagents-harness/src/middleware/library/mod_tests.rs +++ b/crates/tinyagents-harness/src/middleware/library/mod_tests.rs @@ -18,7 +18,10 @@ use tinyinference_llm::tool::{ToolCall, ToolFormat, ToolSchema}; use tinytools::{SandboxMode, ToolAccess, ToolPolicy, ToolResult, ToolRuntime, ToolSideEffects}; #[cfg(feature = "json-schema")] -fn schema_string_values(root: &serde_json::Value, mut schema: &serde_json::Value) -> Vec { +fn schema_string_values<'a>( + root: &'a serde_json::Value, + mut schema: &'a serde_json::Value, +) -> Vec { while let Some(reference) = schema.get("$ref").and_then(serde_json::Value::as_str) { let pointer = reference.strip_prefix('#').expect("local schema reference"); schema = root.pointer(pointer).expect("schema reference resolves"); From 6638e790afb212d8592fabeab78c89338f979fbc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 21:26:46 +0300 Subject: [PATCH 53/68] feat(orchestration): record run progress in ledger --- crates/tinyagents-orchestration/src/lib.rs | 1 + .../src/run_progress_ledger.rs | 429 ++++++++++++++++++ .../src/run_progress_ledger_tests.rs | 221 +++++++++ 3 files changed, 651 insertions(+) create mode 100644 crates/tinyagents-orchestration/src/run_progress_ledger.rs create mode 100644 crates/tinyagents-orchestration/src/run_progress_ledger_tests.rs diff --git a/crates/tinyagents-orchestration/src/lib.rs b/crates/tinyagents-orchestration/src/lib.rs index c8523f3f4..9dfa07529 100644 --- a/crates/tinyagents-orchestration/src/lib.rs +++ b/crates/tinyagents-orchestration/src/lib.rs @@ -14,6 +14,7 @@ //! depend on this composition layer. pub mod parallel; +pub mod run_progress_ledger; pub mod status; pub mod subagent; pub mod teams; diff --git a/crates/tinyagents-orchestration/src/run_progress_ledger.rs b/crates/tinyagents-orchestration/src/run_progress_ledger.rs new file mode 100644 index 000000000..731e6e80d --- /dev/null +++ b/crates/tinyagents-orchestration/src/run_progress_ledger.rs @@ -0,0 +1,429 @@ +//! Records generic agent progress in a host-selected, tenant-scoped run ledger. +//! +//! The host supplies the [`RunLedgerStore`] handle. This crate owns the +//! progress-to-ledger mapping; it never selects a backend or derives storage +//! scope from event payloads. + +use std::sync::Arc; + +use serde_json::json; +use tinyagents_session::run_ledger::{ + AgentRunKind, AgentRunStatus, AgentRunUpsert, RunEventAppend, RunLedgerStore, RunTelemetry, + RunTelemetryUpsert, +}; + +/// Identity attached to progress rows. Storage scope comes only from `store`. +#[derive(Debug, Clone)] +pub struct RunProgressIdentity { + pub client_id: String, + pub thread_id: String, + pub request_id: String, + pub source: String, + pub agent_id: String, +} + +/// What a finished sub-agent adds to its ledger row. +pub struct ChildDone<'a> { + pub agent_id: &'a str, + pub task_id: &'a str, + pub elapsed_ms: u64, + pub iterations: u32, + pub output_chars: usize, + pub worktree_path: &'a Option, + pub changed_files: &'a [String], + pub dirty_status: Option, + pub parent_call_id: &'a Option, + pub tool_count: Option, +} + +/// Progress recorder for one parent agent turn and its child runs. +pub struct RunProgressLedger { + store: Arc, + identity: RunProgressIdentity, +} + +impl RunProgressLedger { + /// Bind the recorder to the store already selected by the host's trusted + /// tenant/agent context. + pub fn new(store: Arc, identity: RunProgressIdentity) -> Self { + Self { store, identity } + } + + async fn run(&self, upsert: AgentRunUpsert) { + if let Err(err) = self.store.upsert_agent_run(upsert).await { + tracing::warn!(error = %err, "failed to upsert progress run"); + } + } + + async fn event(&self, run_id: &str, event_type: &str, payload: serde_json::Value) { + let append = RunEventAppend { + run_id: run_id.to_owned(), + event_type: event_type.to_owned(), + payload, + }; + if let Err(err) = self.store.append_run_event(append).await { + tracing::warn!(error = %err, "failed to append progress event"); + } + } + + async fn telemetry(&self, telemetry: RunTelemetryUpsert) { + if let Err(err) = self.store.upsert_run_telemetry(telemetry).await { + tracing::warn!(error = %err, "failed to upsert progress telemetry"); + } + } + + /// The turn's telemetry as written so far, for the trace export. + pub async fn read_telemetry(&self) -> Option { + match self.store.get_agent_run(&self.identity.request_id).await { + Ok(run) => run.and_then(|run| run.telemetry), + Err(err) => { + tracing::warn!(error = %err, "failed to read progress telemetry"); + None + } + } + } + + fn turn_row( + &self, + status: AgentRunStatus, + summary: Option, + error: Option, + metadata: serde_json::Value, + done: bool, + ) -> AgentRunUpsert { + AgentRunUpsert { + id: self.identity.request_id.clone(), + kind: AgentRunKind::BackgroundAgent, + parent_run_id: None, + parent_thread_id: Some(self.identity.thread_id.clone()), + agent_id: Some(self.identity.agent_id.clone()), + status, + prompt_ref: Some(format!( + "thread:{}:request:{}", + self.identity.thread_id, self.identity.request_id + )), + worker_thread_id: None, + checkpoint_path: None, + checkpoint: None, + summary, + error, + metadata, + started_at: None, + completed_at: done.then(chrono::Utc::now), + } + } + + pub async fn turn_started(&self) { + self.run(self.turn_row( + AgentRunStatus::Running, + None, + None, + json!({ "clientId": &self.identity.client_id, "source": &self.identity.source, "schemaVersion": 1 }), + false, + )).await; + self.event( + &self.identity.request_id, + "turn_started", + json!({ "threadId": &self.identity.thread_id, "clientId": &self.identity.client_id }), + ) + .await; + } + + pub async fn turn_completed(&self, iterations: u32) { + self.run(self.turn_row( + AgentRunStatus::Completed, + Some(format!("Completed in {iterations} iteration(s)")), + None, + json!({}), + true, + )) + .await; + self.event( + &self.identity.request_id, + "turn_completed", + json!({ "iterations": iterations }), + ) + .await; + } + + pub async fn turn_interrupted(&self, events_seen: u64) { + self.run(self.turn_row( + AgentRunStatus::Interrupted, + None, + Some("progress bridge exited before turn completion".to_owned()), + json!({}), + true, + )) + .await; + self.event( + &self.identity.request_id, + "turn_interrupted", + json!({ "eventsSeen": events_seen }), + ) + .await; + } + + pub async fn tool_started(&self, call_id: &str, tool_name: &str, iteration: u32, count: u64) { + self.event( + &self.identity.request_id, + "tool_call_started", + json!({ "callId": call_id, "toolName": tool_name, "iteration": iteration }), + ) + .await; + self.telemetry(RunTelemetryUpsert { + run_id: self.identity.request_id.clone(), + tool_count: Some(count), + ..Default::default() + }) + .await; + } + + pub async fn tool_completed(&self, payload: serde_json::Value) { + self.event(&self.identity.request_id, "tool_call_completed", payload) + .await; + } + + pub async fn cost( + &self, + model: &str, + input_tokens: u64, + output_tokens: u64, + cached_input_tokens: u64, + cost_usd: f64, + ) { + self.telemetry(RunTelemetryUpsert { + run_id: self.identity.request_id.clone(), + input_tokens: Some(input_tokens), + output_tokens: Some(output_tokens), + cached_input_tokens: Some(cached_input_tokens), + cost_usd: Some(cost_usd), + model: Some(model.to_owned()), + ..Default::default() + }) + .await; + } + + fn child_row( + &self, + task_id: &str, + agent_id: &str, + kind: AgentRunKind, + status: AgentRunStatus, + ) -> AgentRunUpsert { + AgentRunUpsert { + id: task_id.to_owned(), + kind, + parent_run_id: Some(self.identity.request_id.clone()), + parent_thread_id: Some(self.identity.thread_id.clone()), + agent_id: Some(agent_id.to_owned()), + status, + prompt_ref: None, + worker_thread_id: None, + checkpoint_path: None, + checkpoint: None, + summary: None, + error: None, + metadata: json!({}), + started_at: None, + completed_at: None, + } + } + + #[allow(clippy::too_many_arguments)] + pub async fn child_spawned( + &self, + agent_id: &str, + task_id: &str, + mode: &str, + dedicated_thread: bool, + prompt_chars: usize, + worker_thread_id: &Option, + display_name: &Option, + parent_call_id: &Option, + ) { + let kind = if worker_thread_id.is_some() { + AgentRunKind::WorkerThread + } else { + AgentRunKind::Subagent + }; + let mut row = self.child_row(task_id, agent_id, kind, AgentRunStatus::Running); + row.prompt_ref = worker_thread_id + .as_ref() + .map(|id| format!("thread:{id}:message:seed")); + row.worker_thread_id.clone_from(worker_thread_id); + row.metadata = json!({ + "mode": mode, + "dedicatedThread": dedicated_thread, + "promptChars": prompt_chars, + "displayName": display_name, + "parentCallId": parent_call_id, + "source": "agent_progress", + "schemaVersion": 1 + }); + self.run(row).await; + self.event( + task_id, + "subagent_spawned", + json!({ + "agentId": agent_id, + "parentRunId": &self.identity.request_id, + "threadId": &self.identity.thread_id, + "workerThreadId": worker_thread_id, + "mode": mode, + "dedicatedThread": dedicated_thread, + "promptChars": prompt_chars, + "displayName": display_name, + "parentCallId": parent_call_id + }), + ) + .await; + } + + pub async fn child_completed(&self, done: &ChildDone<'_>) { + let mut row = self.child_row( + done.task_id, + done.agent_id, + AgentRunKind::Subagent, + AgentRunStatus::Completed, + ); + row.summary = Some(format!( + "Completed in {} iteration(s), {} output chars", + done.iterations, done.output_chars + )); + row.completed_at = Some(chrono::Utc::now()); + self.run(row).await; + self.telemetry(RunTelemetryUpsert { + run_id: done.task_id.to_owned(), + elapsed_ms: Some(done.elapsed_ms), + tool_count: done.tool_count, + ..Default::default() + }) + .await; + self.event( + done.task_id, + "subagent_completed", + json!({ + "agentId": done.agent_id, + "elapsedMs": done.elapsed_ms, + "iterations": done.iterations, + "outputChars": done.output_chars, + "worktreePath": done.worktree_path, + "changedFiles": done.changed_files, + "dirtyStatus": done.dirty_status, + "parentCallId": done.parent_call_id + }), + ) + .await; + } + + pub async fn child_failed( + &self, + agent_id: &str, + task_id: &str, + error: &str, + parent_call_id: &Option, + tool_count: Option, + ) { + let mut row = self.child_row( + task_id, + agent_id, + AgentRunKind::Subagent, + AgentRunStatus::Failed, + ); + row.error = Some(error.to_owned()); + row.completed_at = Some(chrono::Utc::now()); + self.run(row).await; + self.telemetry(RunTelemetryUpsert { + run_id: task_id.to_owned(), + tool_count, + error: Some(error.to_owned()), + ..Default::default() + }) + .await; + self.event( + task_id, + "subagent_failed", + json!({ "agentId": agent_id, "error": error, "parentCallId": parent_call_id }), + ) + .await; + } + + pub async fn child_awaiting_user( + &self, + agent_id: &str, + task_id: &str, + question: &str, + worker_thread_id: &Option, + checkpoint_path: &Option, + parent_call_id: &Option, + ) { + let kind = if worker_thread_id.is_some() { + AgentRunKind::WorkerThread + } else { + AgentRunKind::Subagent + }; + let mut row = self.child_row(task_id, agent_id, kind, AgentRunStatus::AwaitingUser); + row.worker_thread_id.clone_from(worker_thread_id); + // What the runner actually wrote; a rebuild from `workspace_dir` would + // assert a checkpoint that may never have been written (#5928). + row.checkpoint_path.clone_from(checkpoint_path); + row.checkpoint = Some(json!({ + "resumeTool": "continue_subagent", + "taskId": task_id, + "agentId": agent_id, + "question": question, + "workerThreadId": worker_thread_id, + "checkpointPersisted": checkpoint_path.is_some() + })); + row.summary = Some(question.to_owned()); + self.run(row).await; + self.event( + task_id, + "subagent_awaiting_user", + json!({ + "agentId": agent_id, + "question": question, + "workerThreadId": worker_thread_id, + "parentCallId": parent_call_id + }), + ) + .await; + } + + pub async fn child_tool_started( + &self, + agent_id: &str, + task_id: &str, + call_id: &str, + tool_name: &str, + iteration: u32, + count: u64, + ) { + self.telemetry(RunTelemetryUpsert { + run_id: task_id.to_owned(), + tool_count: Some(count), + ..Default::default() + }) + .await; + self.event( + task_id, + "subagent_tool_call_started", + json!({ + "agentId": agent_id, + "callId": call_id, + "toolName": tool_name, + "iteration": iteration + }), + ) + .await; + } + + pub async fn child_tool_completed(&self, task_id: &str, payload: serde_json::Value) { + self.event(task_id, "subagent_tool_call_completed", payload) + .await; + } +} + +#[cfg(test)] +#[path = "run_progress_ledger_tests.rs"] +mod tests; diff --git a/crates/tinyagents-orchestration/src/run_progress_ledger_tests.rs b/crates/tinyagents-orchestration/src/run_progress_ledger_tests.rs new file mode 100644 index 000000000..1699d0829 --- /dev/null +++ b/crates/tinyagents-orchestration/src/run_progress_ledger_tests.rs @@ -0,0 +1,221 @@ +use super::*; +use crate::test_support::ledger_in; +use std::sync::Arc; +use tempfile::tempdir; +use tinyagents_session::run_ledger::{RunEventListRequest, RunLedgerStore}; + +fn recorder(store: Arc) -> RunProgressLedger { + RunProgressLedger::new( + store, + RunProgressIdentity { + client_id: "client-a".into(), + thread_id: "thread-a".into(), + request_id: "request-a".into(), + source: "test".into(), + agent_id: "orchestrator".into(), + }, + ) +} + +async fn events_for(store: &dyn RunLedgerStore, run_id: &str) -> Vec { + store + .list_recent_run_events(&RunEventListRequest { + run_id: run_id.into(), + ..Default::default() + }) + .await + .expect("read run events") + .events + .into_iter() + .map(|event| event.event_type) + .collect() +} + +#[tokio::test] +async fn records_parent_lifecycle_tool_events_and_latest_telemetry() { + let dir = tempdir().expect("temporary storage directory"); + let store: Arc = Arc::new(ledger_in(dir.path())); + let recorder = recorder(Arc::clone(&store)); + + recorder.turn_started().await; + recorder.tool_started("call-a", "search", 2, 1).await; + recorder.cost("model-a", 31, 7, 13, 0.0042).await; + recorder + .tool_completed(serde_json::json!({"callId": "call-a", "success": true})) + .await; + let telemetry = recorder.read_telemetry().await.expect("run telemetry"); + assert_eq!(telemetry.run_id, "request-a"); + assert_eq!(telemetry.input_tokens, 31); + assert_eq!(telemetry.output_tokens, 7); + assert_eq!(telemetry.cached_input_tokens, 13); + assert_eq!(telemetry.tool_count, 1); + assert_eq!(telemetry.model.as_deref(), Some("model-a")); + assert!((telemetry.cost_usd - 0.0042).abs() < f64::EPSILON); + + recorder.turn_completed(3).await; + let parent = store + .get_agent_run("request-a") + .await + .expect("read parent run") + .expect("parent run was written"); + assert_eq!(parent.parent_thread_id.as_deref(), Some("thread-a")); + assert_eq!(parent.status, AgentRunStatus::Completed); + assert_eq!( + events_for(store.as_ref(), "request-a").await, + [ + "turn_started", + "tool_call_started", + "tool_call_completed", + "turn_completed" + ] + ); +} + +#[tokio::test] +async fn records_child_completion_failure_and_checkpoint_availability() { + let dir = tempdir().expect("temporary storage directory"); + let store: Arc = Arc::new(ledger_in(dir.path())); + let recorder = recorder(Arc::clone(&store)); + + recorder + .child_spawned( + "agent-done", + "task-done", + "parallel", + false, + 12, + &None, + &None, + &Some("call-done".into()), + ) + .await; + recorder + .child_tool_started("agent-done", "task-done", "child-call", "read", 1, 1) + .await; + recorder + .child_tool_completed( + "task-done", + serde_json::json!({"agentId": "agent-done", "callId": "child-call", "success": true}), + ) + .await; + let worktree_path = Some("/work/agent-done".to_owned()); + let changed_files = vec!["src/lib.rs".to_owned()]; + let completed_parent_call = Some("call-done".to_owned()); + let done = ChildDone { + agent_id: "agent-done", + task_id: "task-done", + elapsed_ms: 120, + iterations: 2, + output_chars: 42, + worktree_path: &worktree_path, + changed_files: &changed_files, + dirty_status: Some(true), + parent_call_id: &completed_parent_call, + tool_count: Some(4), + }; + recorder.child_completed(&done).await; + + recorder + .child_spawned( + "agent-failed", + "task-failed", + "sequential", + true, + 8, + &None, + &None, + &None, + ) + .await; + recorder + .child_failed("agent-failed", "task-failed", "tool failed", &None, Some(2)) + .await; + + recorder + .child_awaiting_user( + "agent-paused", + "task-paused-with-checkpoint", + "Need confirmation", + &Some("worker-thread".into()), + &Some("checkpoint.json".into()), + &Some("parent-call".into()), + ) + .await; + recorder + .child_awaiting_user( + "agent-paused", + "task-paused-without-checkpoint", + "Need confirmation", + &None, + &None, + &None, + ) + .await; + + let completed = store + .get_agent_run("task-done") + .await + .expect("read completed child") + .expect("completed child row"); + assert_eq!(completed.parent_run_id.as_deref(), Some("request-a")); + assert_eq!(completed.parent_thread_id.as_deref(), Some("thread-a")); + assert_eq!(completed.status, AgentRunStatus::Completed); + assert_eq!( + completed.summary.as_deref(), + Some("Completed in 2 iteration(s), 42 output chars") + ); + let completed_telemetry = completed.telemetry.expect("child telemetry"); + assert_eq!(completed_telemetry.elapsed_ms, Some(120)); + assert_eq!(completed_telemetry.tool_count, 4); + + let failed = store + .get_agent_run("task-failed") + .await + .expect("read failed child") + .expect("failed child row"); + assert_eq!(failed.status, AgentRunStatus::Failed); + assert_eq!(failed.error.as_deref(), Some("tool failed")); + assert_eq!(failed.telemetry.expect("failed telemetry").tool_count, 2); + + let with_checkpoint = store + .get_agent_run("task-paused-with-checkpoint") + .await + .expect("read checkpointed child") + .expect("checkpointed child row"); + assert_eq!( + with_checkpoint.checkpoint_path.as_deref(), + Some("checkpoint.json") + ); + assert_eq!( + with_checkpoint.checkpoint.expect("checkpoint details")["checkpointPersisted"], + true + ); + let without_checkpoint = store + .get_agent_run("task-paused-without-checkpoint") + .await + .expect("read child without checkpoint") + .expect("child row without checkpoint"); + assert_eq!(without_checkpoint.checkpoint_path, None); + assert_eq!( + without_checkpoint.checkpoint.expect("checkpoint details")["checkpointPersisted"], + false + ); + + assert_eq!( + events_for(store.as_ref(), "task-done").await, + [ + "subagent_spawned", + "subagent_tool_call_started", + "subagent_tool_call_completed", + "subagent_completed" + ] + ); + assert_eq!( + events_for(store.as_ref(), "task-failed").await, + ["subagent_spawned", "subagent_failed"] + ); + assert_eq!( + events_for(store.as_ref(), "task-paused-with-checkpoint").await, + ["subagent_awaiting_user"] + ); +} From bba042e85d6b3489c36820f358ed3062a2dc466c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 21:43:33 +0300 Subject: [PATCH 54/68] refactor(orchestration): fold progress events into run ledger --- .../src/run_progress_ledger.rs | 292 ++++++++++++++++-- .../src/run_progress_ledger_tests.rs | 180 ++++++++++- 2 files changed, 452 insertions(+), 20 deletions(-) diff --git a/crates/tinyagents-orchestration/src/run_progress_ledger.rs b/crates/tinyagents-orchestration/src/run_progress_ledger.rs index 731e6e80d..f51ded738 100644 --- a/crates/tinyagents-orchestration/src/run_progress_ledger.rs +++ b/crates/tinyagents-orchestration/src/run_progress_ledger.rs @@ -4,9 +4,11 @@ //! progress-to-ledger mapping; it never selects a backend or derives storage //! scope from event payloads. +use std::collections::HashMap; use std::sync::Arc; use serde_json::json; +use tinyagents_harness::progress::AgentProgress; use tinyagents_session::run_ledger::{ AgentRunKind, AgentRunStatus, AgentRunUpsert, RunEventAppend, RunLedgerStore, RunTelemetry, RunTelemetryUpsert, @@ -38,43 +40,67 @@ pub struct ChildDone<'a> { /// Progress recorder for one parent agent turn and its child runs. pub struct RunProgressLedger { - store: Arc, + store: Option>, identity: RunProgressIdentity, + state: RunProgressState, +} + +#[derive(Default)] +struct RunProgressState { + events_seen: u64, + parent_completed: bool, + parent_tool_count: u64, + child_tool_counts: HashMap, + subagent_parent_call_ids: HashMap>, +} + +/// Correlation retained for the host's surface-specific subagent projection. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProgressLedgerContext { + pub parent_call_id: Option, } impl RunProgressLedger { /// Bind the recorder to the store already selected by the host's trusted /// tenant/agent context. - pub fn new(store: Arc, identity: RunProgressIdentity) -> Self { - Self { store, identity } + pub fn new(store: Option>, identity: RunProgressIdentity) -> Self { + Self { + store, + identity, + state: RunProgressState::default(), + } } async fn run(&self, upsert: AgentRunUpsert) { - if let Err(err) = self.store.upsert_agent_run(upsert).await { + let Some(store) = &self.store else { return }; + if let Err(err) = store.upsert_agent_run(upsert).await { tracing::warn!(error = %err, "failed to upsert progress run"); } } async fn event(&self, run_id: &str, event_type: &str, payload: serde_json::Value) { + let Some(store) = &self.store else { return }; let append = RunEventAppend { run_id: run_id.to_owned(), event_type: event_type.to_owned(), payload, }; - if let Err(err) = self.store.append_run_event(append).await { + if let Err(err) = store.append_run_event(append).await { tracing::warn!(error = %err, "failed to append progress event"); } } async fn telemetry(&self, telemetry: RunTelemetryUpsert) { - if let Err(err) = self.store.upsert_run_telemetry(telemetry).await { + let Some(store) = &self.store else { return }; + if let Err(err) = store.upsert_run_telemetry(telemetry).await { tracing::warn!(error = %err, "failed to upsert progress telemetry"); } } /// The turn's telemetry as written so far, for the trace export. pub async fn read_telemetry(&self) -> Option { - match self.store.get_agent_run(&self.identity.request_id).await { + let store = self.store.as_ref()?; + match store.get_agent_run(&self.identity.request_id).await { Ok(run) => run.and_then(|run| run.telemetry), Err(err) => { tracing::warn!(error = %err, "failed to read progress telemetry"); @@ -83,6 +109,234 @@ impl RunProgressLedger { } } + /// Fold a provider-neutral progress event into the run ledger. + /// + /// The host calls this once for every event in the turn's ordered stream. + /// It retains only generic run correlation/counters and does not select a + /// store scope or translate progress into a user-facing event. + pub async fn observe(&mut self, event: &AgentProgress) -> ProgressLedgerContext { + self.state.events_seen = self.state.events_seen.saturating_add(1); + match event { + AgentProgress::TurnStarted => self.turn_started().await, + AgentProgress::ToolCallStarted { + call_id, + tool_name, + iteration, + .. + } => { + self.state.parent_tool_count = self.state.parent_tool_count.saturating_add(1); + self.tool_started(call_id, tool_name, *iteration, self.state.parent_tool_count) + .await; + } + AgentProgress::ToolCallCompleted { + call_id, + tool_name, + success, + output_chars, + elapsed_ms, + iteration, + failure, + .. + } => { + self.tool_completed(json!({ + "callId": call_id, + "toolName": tool_name, + "success": success, + "outputChars": output_chars, + "elapsedMs": elapsed_ms, + "iteration": iteration, + "failure": failure.as_ref().and_then(|f| serde_json::to_value(f).ok()), + })) + .await; + } + AgentProgress::TurnCostUpdated { + model, + input_tokens, + output_tokens, + cached_input_tokens, + total_usd, + .. + } => { + self.cost( + model, + *input_tokens, + *output_tokens, + *cached_input_tokens, + *total_usd, + ) + .await; + } + AgentProgress::TurnCompleted { iterations, .. } => { + self.state.parent_completed = true; + self.turn_completed(*iterations).await; + } + AgentProgress::SubagentSpawned { + agent_id, + task_id, + mode, + dedicated_thread, + prompt_chars, + worker_thread_id, + display_name, + parent_call_id, + .. + } => { + self.state + .subagent_parent_call_ids + .insert(task_id.clone(), parent_call_id.clone()); + self.child_spawned( + agent_id, + task_id, + mode, + *dedicated_thread, + *prompt_chars, + worker_thread_id, + display_name, + parent_call_id, + ) + .await; + return ProgressLedgerContext { + parent_call_id: parent_call_id.clone(), + }; + } + AgentProgress::SubagentCompleted { + agent_id, + task_id, + elapsed_ms, + iterations, + output_chars, + worktree_path, + changed_files, + dirty_status, + .. + } => { + let parent_call_id = self + .state + .subagent_parent_call_ids + .remove(task_id) + .flatten(); + self.child_completed(&ChildDone { + agent_id, + task_id, + elapsed_ms: *elapsed_ms, + iterations: *iterations, + output_chars: *output_chars, + worktree_path, + changed_files, + dirty_status: *dirty_status, + parent_call_id: &parent_call_id, + tool_count: self.state.child_tool_counts.get(task_id).copied(), + }) + .await; + return ProgressLedgerContext { parent_call_id }; + } + AgentProgress::SubagentFailed { + agent_id, + task_id, + error, + } => { + let parent_call_id = self + .state + .subagent_parent_call_ids + .remove(task_id) + .flatten(); + self.child_failed( + agent_id, + task_id, + error, + &parent_call_id, + self.state.child_tool_counts.get(task_id).copied(), + ) + .await; + return ProgressLedgerContext { parent_call_id }; + } + AgentProgress::SubagentAwaitingUser { + agent_id, + task_id, + question, + worker_thread_id, + checkpoint_path, + } => { + let parent_call_id = self + .state + .subagent_parent_call_ids + .get(task_id) + .cloned() + .flatten(); + self.child_awaiting_user( + agent_id, + task_id, + question, + worker_thread_id, + checkpoint_path, + &parent_call_id, + ) + .await; + return ProgressLedgerContext { parent_call_id }; + } + AgentProgress::SubagentToolCallStarted { + agent_id, + task_id, + call_id, + tool_name, + iteration, + .. + } => { + let count = { + let count = self + .state + .child_tool_counts + .entry(task_id.clone()) + .or_insert(0); + *count = count.saturating_add(1); + *count + }; + self.child_tool_started(agent_id, task_id, call_id, tool_name, *iteration, count) + .await; + } + AgentProgress::SubagentToolCallCompleted { + agent_id, + task_id, + call_id, + tool_name, + success, + output_chars, + elapsed_ms, + iteration, + failure, + .. + } => { + self.child_tool_completed( + task_id, + json!({ + "agentId": agent_id, + "callId": call_id, + "toolName": tool_name, + "success": success, + "outputChars": output_chars, + "elapsedMs": elapsed_ms, + "iteration": iteration, + "failure": failure.as_ref().and_then(|f| serde_json::to_value(f).ok()), + }), + ) + .await; + } + _ => {} + } + ProgressLedgerContext::default() + } + + /// Record an interrupted run if the parent did not complete. + pub async fn finish(&self) { + if !self.state.parent_completed { + self.turn_interrupted(self.state.events_seen).await; + } + } + + pub fn events_seen(&self) -> u64 { + self.state.events_seen + } + fn turn_row( &self, status: AgentRunStatus, @@ -113,7 +367,7 @@ impl RunProgressLedger { } } - pub async fn turn_started(&self) { + async fn turn_started(&self) { self.run(self.turn_row( AgentRunStatus::Running, None, @@ -129,7 +383,7 @@ impl RunProgressLedger { .await; } - pub async fn turn_completed(&self, iterations: u32) { + async fn turn_completed(&self, iterations: u32) { self.run(self.turn_row( AgentRunStatus::Completed, Some(format!("Completed in {iterations} iteration(s)")), @@ -146,7 +400,7 @@ impl RunProgressLedger { .await; } - pub async fn turn_interrupted(&self, events_seen: u64) { + async fn turn_interrupted(&self, events_seen: u64) { self.run(self.turn_row( AgentRunStatus::Interrupted, None, @@ -163,7 +417,7 @@ impl RunProgressLedger { .await; } - pub async fn tool_started(&self, call_id: &str, tool_name: &str, iteration: u32, count: u64) { + async fn tool_started(&self, call_id: &str, tool_name: &str, iteration: u32, count: u64) { self.event( &self.identity.request_id, "tool_call_started", @@ -178,12 +432,12 @@ impl RunProgressLedger { .await; } - pub async fn tool_completed(&self, payload: serde_json::Value) { + async fn tool_completed(&self, payload: serde_json::Value) { self.event(&self.identity.request_id, "tool_call_completed", payload) .await; } - pub async fn cost( + async fn cost( &self, model: &str, input_tokens: u64, @@ -230,7 +484,7 @@ impl RunProgressLedger { } #[allow(clippy::too_many_arguments)] - pub async fn child_spawned( + async fn child_spawned( &self, agent_id: &str, task_id: &str, @@ -279,7 +533,7 @@ impl RunProgressLedger { .await; } - pub async fn child_completed(&self, done: &ChildDone<'_>) { + async fn child_completed(&self, done: &ChildDone<'_>) { let mut row = self.child_row( done.task_id, done.agent_id, @@ -316,7 +570,7 @@ impl RunProgressLedger { .await; } - pub async fn child_failed( + async fn child_failed( &self, agent_id: &str, task_id: &str, @@ -348,7 +602,7 @@ impl RunProgressLedger { .await; } - pub async fn child_awaiting_user( + async fn child_awaiting_user( &self, agent_id: &str, task_id: &str, @@ -390,7 +644,7 @@ impl RunProgressLedger { .await; } - pub async fn child_tool_started( + async fn child_tool_started( &self, agent_id: &str, task_id: &str, @@ -418,7 +672,7 @@ impl RunProgressLedger { .await; } - pub async fn child_tool_completed(&self, task_id: &str, payload: serde_json::Value) { + async fn child_tool_completed(&self, task_id: &str, payload: serde_json::Value) { self.event(task_id, "subagent_tool_call_completed", payload) .await; } diff --git a/crates/tinyagents-orchestration/src/run_progress_ledger_tests.rs b/crates/tinyagents-orchestration/src/run_progress_ledger_tests.rs index 1699d0829..d9b565bf0 100644 --- a/crates/tinyagents-orchestration/src/run_progress_ledger_tests.rs +++ b/crates/tinyagents-orchestration/src/run_progress_ledger_tests.rs @@ -2,11 +2,12 @@ use super::*; use crate::test_support::ledger_in; use std::sync::Arc; use tempfile::tempdir; +use tinyagents_harness::progress::AgentProgress; use tinyagents_session::run_ledger::{RunEventListRequest, RunLedgerStore}; fn recorder(store: Arc) -> RunProgressLedger { RunProgressLedger::new( - store, + Some(store), RunProgressIdentity { client_id: "client-a".into(), thread_id: "thread-a".into(), @@ -17,6 +18,183 @@ fn recorder(store: Arc) -> RunProgressLedger { ) } +#[tokio::test] +async fn observes_agent_progress_once_and_retains_child_correlation_for_the_host() { + let dir = tempdir().expect("temporary storage directory"); + let store: Arc = Arc::new(ledger_in(dir.path())); + let mut recorder = recorder(Arc::clone(&store)); + + recorder.observe(&AgentProgress::TurnStarted).await; + recorder + .observe(&AgentProgress::ToolCallStarted { + call_id: "parent-call".into(), + tool_name: "delegate".into(), + arguments: serde_json::json!({}), + iteration: 1, + display_label: None, + display_detail: None, + }) + .await; + recorder + .observe(&AgentProgress::TurnCostUpdated { + model: "model-a".into(), + iteration: 1, + input_tokens: 18, + output_tokens: 4, + cached_input_tokens: 7, + total_usd: 0.002, + }) + .await; + recorder + .observe(&AgentProgress::SubagentSpawned { + agent_id: "agent-child".into(), + task_id: "task-child".into(), + mode: "typed".into(), + dedicated_thread: false, + prompt_chars: 12, + worker_thread_id: None, + display_name: Some("Child".into()), + prompt: "do work".into(), + parent_call_id: Some("parent-call".into()), + }) + .await; + recorder + .observe(&AgentProgress::SubagentToolCallStarted { + agent_id: "agent-child".into(), + task_id: "task-child".into(), + call_id: "child-call".into(), + tool_name: "read".into(), + arguments: serde_json::json!({}), + iteration: 1, + display_label: None, + display_detail: None, + }) + .await; + let context = recorder + .observe(&AgentProgress::SubagentAwaitingUser { + agent_id: "agent-child".into(), + task_id: "task-child".into(), + question: "Approve?".into(), + worker_thread_id: None, + checkpoint_path: None, + }) + .await; + assert_eq!(context.parent_call_id.as_deref(), Some("parent-call")); + + recorder + .observe(&AgentProgress::SubagentFailed { + agent_id: "agent-child".into(), + task_id: "task-child".into(), + error: "denied".into(), + }) + .await; + recorder + .observe(&AgentProgress::TurnCompleted { + iterations: 1, + stop: None, + }) + .await; + + let parent = store + .get_agent_run("request-a") + .await + .expect("read parent") + .expect("parent exists"); + let telemetry = parent.telemetry.expect("parent telemetry"); + assert_eq!(telemetry.tool_count, 1); + assert_eq!(telemetry.input_tokens, 18); + assert_eq!(telemetry.model.as_deref(), Some("model-a")); + assert_eq!(parent.status, AgentRunStatus::Completed); + let child = store + .get_agent_run("task-child") + .await + .expect("read child") + .expect("child exists"); + assert_eq!(child.status, AgentRunStatus::Failed); + assert_eq!(child.error.as_deref(), Some("denied")); + assert_eq!(child.telemetry.expect("child telemetry").tool_count, 1); + assert_eq!( + events_for(store.as_ref(), "request-a").await, + ["turn_started", "tool_call_started", "turn_completed"] + ); + assert_eq!( + events_for(store.as_ref(), "task-child").await, + [ + "subagent_spawned", + "subagent_tool_call_started", + "subagent_awaiting_user", + "subagent_failed" + ] + ); + assert_eq!(recorder.events_seen(), 8); + recorder.finish().await; + assert!( + !events_for(store.as_ref(), "request-a") + .await + .contains(&"turn_interrupted".to_owned()) + ); +} + +#[tokio::test] +async fn observes_child_correlation_without_a_store() { + let mut recorder = RunProgressLedger::new( + None, + RunProgressIdentity { + client_id: "client-a".into(), + thread_id: "thread-a".into(), + request_id: "request-a".into(), + source: "test".into(), + agent_id: "orchestrator".into(), + }, + ); + recorder + .observe(&AgentProgress::SubagentSpawned { + agent_id: "agent-child".into(), + task_id: "task-child".into(), + mode: "typed".into(), + dedicated_thread: false, + prompt_chars: 12, + worker_thread_id: None, + display_name: None, + prompt: "do work".into(), + parent_call_id: Some("parent-call".into()), + }) + .await; + let context = recorder + .observe(&AgentProgress::SubagentAwaitingUser { + agent_id: "agent-child".into(), + task_id: "task-child".into(), + question: "Approve?".into(), + worker_thread_id: None, + checkpoint_path: None, + }) + .await; + assert_eq!(context.parent_call_id.as_deref(), Some("parent-call")); + assert_eq!(recorder.events_seen(), 2); + recorder.finish().await; + assert_eq!(recorder.events_seen(), 2); +} + +#[tokio::test] +async fn finish_records_interruption_when_parent_never_completes() { + let dir = tempdir().expect("temporary storage directory"); + let store: Arc = Arc::new(ledger_in(dir.path())); + let mut recorder = recorder(Arc::clone(&store)); + recorder.observe(&AgentProgress::TurnStarted).await; + recorder.finish().await; + + let parent = store + .get_agent_run("request-a") + .await + .expect("read parent") + .expect("interrupted run is written"); + assert_eq!(parent.status, AgentRunStatus::Interrupted); + assert_eq!( + events_for(store.as_ref(), "request-a").await, + ["turn_started", "turn_interrupted"] + ); +} + async fn events_for(store: &dyn RunLedgerStore, run_id: &str) -> Vec { store .list_recent_run_events(&RunEventListRequest { From 5919f8e333286d3f4eba9d11484dbf1aa5649100 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 02:08:37 +0000 Subject: [PATCH 55/68] chore(vendor): bump tinyinference and tinytools submodules Update the vendored tinyinference and tinytools submodule pointers to their latest commits. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinyinference | 2 +- vendor/tinytools | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index c86f7ddbb..c29d5118e 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit c86f7ddbbab359acbb8a0fdfec8681f2a254d558 +Subproject commit c29d5118eddfa12b83c266d7d5f07f6918dc3787 diff --git a/vendor/tinytools b/vendor/tinytools index 201c56321..82c0d975e 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 201c563219549114da3dffcfc8ef75f3eb2e130c +Subproject commit 82c0d975e2667e1c525afe525006134431641785 From d1cbeef0f43b24a5e0a20cd0d33a7e8d82fbdddb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 02:17:12 +0000 Subject: [PATCH 56/68] test(tinyagents-harness-tools): add wait tool tests Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/wait_tests.rs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 crates/tinyagents-harness-tools/src/wait_tests.rs diff --git a/crates/tinyagents-harness-tools/src/wait_tests.rs b/crates/tinyagents-harness-tools/src/wait_tests.rs new file mode 100644 index 000000000..434bb81f0 --- /dev/null +++ b/crates/tinyagents-harness-tools/src/wait_tests.rs @@ -0,0 +1,121 @@ +use super::*; + +#[test] +fn wait_schema_requires_message() { + let schema = WaitTool::new().parameters_schema(); + let required = schema + .get("required") + .and_then(|v| v.as_array()) + .expect("required list"); + assert!(required.iter().any(|v| v.as_str() == Some("message"))); + assert!(schema["properties"].get("duration_secs").is_some()); + assert!(schema["properties"].get("duration_ms").is_some()); +} + +#[test] +fn wait_loop_schema_includes_loop_controls() { + let schema = WaitLoopTool::new().parameters_schema(); + assert!(schema["properties"].get("loop_key").is_some()); + assert!(schema["properties"].get("iteration").is_some()); +} + +#[test] +fn parse_wait_request_clamps_duration_and_iteration() { + let request = parse_wait_request(&json!({ + "message": "check subagents", + "duration_secs": 9999, + "iteration": 0 + })) + .unwrap(); + assert_eq!(request.duration_ms, MAX_DURATION_SECS * MILLIS_PER_SEC); + assert_eq!(request.iteration, 1); +} + +#[test] +fn missing_message_is_rejected() { + let err = parse_wait_request(&json!({ "duration_secs": 1 })).unwrap_err(); + assert!(err.contains("message")); +} + +#[test] +fn wait_loop_tick_repeats_same_message() { + let request = parse_wait_request(&json!({ + "message": "poll async workers", + "duration_ms": 10, + "loop_key": "workers", + "iteration": 2 + })) + .unwrap(); + let output = format_wait_tick(&request, true); + + assert!(output.contains("Loop tick 2 elapsed")); + assert!(output.contains("\"tool\":\"wait_loop\"")); + assert!(output.contains("\"message\":\"poll async workers\"")); + assert!(output.contains("\"iteration\":3")); +} + +#[test] +fn wait_loop_tick_at_max_iteration_does_not_overflow() { + let request = parse_wait_request(&json!({ + "message": "poll", + "duration_ms": 10, + "iteration": u64::MAX + })) + .unwrap(); + let output = format_wait_tick(&request, true); + assert!(output.contains(&format!("\"iteration\":{}", u64::MAX))); +} + +#[test] +fn wait_loop_tick_omits_an_absent_loop_key() { + let request = parse_wait_request(&json!({ "message": "poll", "duration_ms": 10 })).unwrap(); + let output = format_wait_tick(&request, true); + let json = output + .split("[wait_tick]\n") + .nth(1) + .and_then(|rest| rest.split("\n[/wait_tick]").next()) + .expect("wait_tick payload"); + let payload: serde_json::Value = serde_json::from_str(json).unwrap(); + let arguments = &payload["instructions"]["repeat"]["arguments"]; + assert!(arguments.is_object()); + assert!( + arguments.get("loop_key").is_none(), + "the repeat call must not carry a null loop_key: {arguments}" + ); +} + +#[tokio::test] +async fn wait_execute_returns_callback_message() { + let res = WaitTool::new() + .execute(json!({ + "message": "time to check", + "duration_ms": 1 + })) + .await + .unwrap(); + assert!(!res.is_error); + assert!(res.output().contains("[wait_tick]")); + assert!(res.output().contains("time to check")); +} + +/// Wire shape is a contract with prompts and recorded transcripts: names and +/// schemas must stay byte-identical across moves. +#[test] +fn wire_schemas_are_stable() { + assert_eq!(WaitTool::new().name(), "wait"); + assert_eq!(WaitLoopTool::new().name(), "wait_loop"); + let base = json!({ + "type": "object", + "required": ["message"], + "properties": { + "message": {"type": "string", "description": "Callback message to return to the orchestrator after the wait elapses."}, + "duration_secs": {"type": "integer", "minimum": 1, "maximum": 600, "description": "Seconds to wait before returning the callback. Default 5. Ignored when duration_ms is supplied."}, + "duration_ms": {"type": "integer", "minimum": 1, "maximum": 600000, "description": "Milliseconds to wait before returning the callback. Use only for short test-sized waits."} + } + }); + assert_eq!(WaitTool::new().parameters_schema(), base); + let mut looped = base.clone(); + looped["properties"]["loop_key"] = json!({"type": "string", "description": "Optional caller-defined key for the polling loop."}); + looped["properties"]["iteration"] = json!({"type": "integer", "minimum": 1, "description": "Current loop iteration. Defaults to 1; the returned instruction increments it."}); + assert_eq!(WaitLoopTool::new().parameters_schema(), looped); +} From 5de5f88ca2624d42e182a86a3b8179297f524e99 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 02:17:34 +0000 Subject: [PATCH 57/68] test(wait): cover timeout policy for requested wait Add a test asserting that the wait tool's timeout policy allows the requested duration plus a grace period, and register the wait test module so it runs with the rest of the suite. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinyagents-harness-tools/src/lib.rs | 3 +++ crates/tinyagents-harness-tools/src/wait_tests.rs | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/crates/tinyagents-harness-tools/src/lib.rs b/crates/tinyagents-harness-tools/src/lib.rs index a8bbfcdee..8c2ab0080 100644 --- a/crates/tinyagents-harness-tools/src/lib.rs +++ b/crates/tinyagents-harness-tools/src/lib.rs @@ -36,3 +36,6 @@ mod time_parse_tests; #[cfg(test)] #[path = "time_tests.rs"] mod time_tests; +#[cfg(test)] +#[path = "wait_tests.rs"] +mod wait_tests; diff --git a/crates/tinyagents-harness-tools/src/wait_tests.rs b/crates/tinyagents-harness-tools/src/wait_tests.rs index 434bb81f0..fc0efc04f 100644 --- a/crates/tinyagents-harness-tools/src/wait_tests.rs +++ b/crates/tinyagents-harness-tools/src/wait_tests.rs @@ -84,6 +84,14 @@ fn wait_loop_tick_omits_an_absent_loop_key() { ); } +#[test] +fn timeout_policy_covers_the_requested_wait_with_grace() { + use tinytools::{Tool, ToolTimeout}; + + let args = json!({"message": "poll", "duration_secs": 4}); + assert_eq!(WaitTool::new().timeout_policy(&args), ToolTimeout::Millis(5_000)); +} + #[tokio::test] async fn wait_execute_returns_callback_message() { let res = WaitTool::new() From b8acaa2d7e3cba90988692482833b666e7312079 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 02:17:41 +0000 Subject: [PATCH 58/68] feat(tinyagents-harness-tools): add wait tool Introduce a wait tool that lets the harness pause execution for a specified duration. This gives agents a way to delay between steps without busy-looping. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinyagents-harness-tools/src/wait.rs | 302 ++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 crates/tinyagents-harness-tools/src/wait.rs diff --git a/crates/tinyagents-harness-tools/src/wait.rs b/crates/tinyagents-harness-tools/src/wait.rs new file mode 100644 index 000000000..fc107e010 --- /dev/null +++ b/crates/tinyagents-harness-tools/src/wait.rs @@ -0,0 +1,302 @@ +//! Tool: `wait` / `wait_loop` - delayed callback ticks for the orchestrator. +//! +//! These tools intentionally do not own scheduling state. They sleep for a +//! bounded duration, then return the caller-provided message as a tool result so +//! the orchestrator can decide whether to act, poll async sub-agents, or call the +//! loop variant again. + +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::json; +use tinytools::{PermissionLevel, Tool, ToolResult, ToolTimeout}; + +const DEFAULT_DURATION_SECS: u64 = 5; +const MAX_DURATION_SECS: u64 = 600; +const MILLIS_PER_SEC: u64 = 1_000; + +/// One-shot delayed callback tool for the orchestrator. +pub struct WaitTool; +/// Repeatable delayed callback tool for orchestrator-controlled polling loops. +pub struct WaitLoopTool; + +impl WaitTool { + pub fn new() -> Self { + Self + } +} + +impl Default for WaitTool { + fn default() -> Self { + Self::new() + } +} + +impl WaitLoopTool { + pub fn new() -> Self { + Self + } +} + +impl Default for WaitLoopTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for WaitTool { + fn name(&self) -> &str { + "wait" + } + + fn description(&self) -> &str { + "Wait for a bounded duration, then return the provided callback message \ + to the orchestrator. Use this to create a delayed tick before checking \ + async work or retrying a condition." + } + + fn parameters_schema(&self) -> serde_json::Value { + wait_schema(false) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + fn timeout_policy(&self, args: &serde_json::Value) -> ToolTimeout { + timeout_policy_for_wait(args) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + execute_wait(args, false).await + } +} + +#[async_trait] +impl Tool for WaitLoopTool { + fn name(&self) -> &str { + "wait_loop" + } + + fn description(&self) -> &str { + "Wait for a bounded duration, then return the same callback message plus \ + a ready-to-call wait_loop instruction. Use this for deliberate polling \ + loops where the orchestrator decides after each tick whether to repeat." + } + + fn parameters_schema(&self) -> serde_json::Value { + wait_schema(true) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + fn timeout_policy(&self, args: &serde_json::Value) -> ToolTimeout { + timeout_policy_for_wait(args) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + execute_wait(args, true).await + } +} + +/// Sleep for the requested duration, then return the callback tick payload. +async fn execute_wait(args: serde_json::Value, loop_mode: bool) -> anyhow::Result { + let request = match parse_wait_request(&args) { + Ok(request) => request, + Err(message) => return Ok(ToolResult::error(message)), + }; + let tool_name = if loop_mode { "wait_loop" } else { "wait" }; + tracing::info!( + "[wait] tool={} duration_ms={} iteration={} loop_key={} message_chars={}", + tool_name, + request.duration_ms, + request.iteration, + request.loop_key.as_deref().unwrap_or("none"), + request.message.chars().count() + ); + + tokio::time::sleep(Duration::from_millis(request.duration_ms)).await; + + tracing::debug!( + "[wait] elapsed tool={} duration_ms={} iteration={} loop_key={}", + tool_name, + request.duration_ms, + request.iteration, + request.loop_key.as_deref().unwrap_or("none") + ); + + Ok(ToolResult::success(format_wait_tick(&request, loop_mode))) +} + +/// Build the JSON schema shared by `wait` and `wait_loop`. +fn wait_schema(loop_mode: bool) -> serde_json::Value { + let mut properties = serde_json::Map::new(); + properties.insert( + "message".to_string(), + json!({ + "type": "string", + "description": "Callback message to return to the orchestrator after the wait elapses." + }), + ); + properties.insert( + "duration_secs".to_string(), + json!({ + "type": "integer", + "minimum": 1, + "maximum": MAX_DURATION_SECS, + "description": "Seconds to wait before returning the callback. Default 5. Ignored when duration_ms is supplied." + }), + ); + properties.insert( + "duration_ms".to_string(), + json!({ + "type": "integer", + "minimum": 1, + "maximum": MAX_DURATION_SECS * MILLIS_PER_SEC, + "description": "Milliseconds to wait before returning the callback. Use only for short test-sized waits." + }), + ); + + if loop_mode { + properties.insert( + "loop_key".to_string(), + json!({ + "type": "string", + "description": "Optional caller-defined key for the polling loop." + }), + ); + properties.insert( + "iteration".to_string(), + json!({ + "type": "integer", + "minimum": 1, + "description": "Current loop iteration. Defaults to 1; the returned instruction increments it." + }), + ); + } + + json!({ + "type": "object", + "required": ["message"], + "properties": serde_json::Value::Object(properties) + }) +} + +/// Give the harness a deadline that covers the requested wait plus grace. +fn timeout_policy_for_wait(args: &serde_json::Value) -> ToolTimeout { + let duration_ms = duration_ms_from_args(args).unwrap_or(DEFAULT_DURATION_SECS * MILLIS_PER_SEC); + let rounded_secs = duration_ms.saturating_add(MILLIS_PER_SEC - 1) / MILLIS_PER_SEC; + ToolTimeout::Millis( + rounded_secs + .saturating_add(1) + .saturating_mul(MILLIS_PER_SEC), + ) +} + +/// Extract and clamp the caller's requested wait duration in milliseconds. +fn duration_ms_from_args(args: &serde_json::Value) -> Option { + if let Some(duration_ms) = args.get("duration_ms").and_then(|v| v.as_u64()) { + return Some(duration_ms.clamp(1, MAX_DURATION_SECS * MILLIS_PER_SEC)); + } + args.get("duration_secs") + .and_then(|v| v.as_u64()) + .map(|secs| secs.clamp(1, MAX_DURATION_SECS) * MILLIS_PER_SEC) +} + +/// Validate tool arguments and normalize them into an internal request. +fn parse_wait_request(args: &serde_json::Value) -> Result { + let message = args + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if message.is_empty() { + return Err("wait: `message` is required".to_string()); + } + + let duration_ms = duration_ms_from_args(args).unwrap_or(DEFAULT_DURATION_SECS * MILLIS_PER_SEC); + let iteration = args + .get("iteration") + .and_then(|v| v.as_u64()) + .unwrap_or(1) + .max(1); + let loop_key = args + .get("loop_key") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + Ok(WaitRequest { + message, + duration_ms, + iteration, + loop_key, + }) +} + +/// Render the delayed callback as prose plus a machine-readable `[wait_tick]`. +fn format_wait_tick(request: &WaitRequest, loop_mode: bool) -> String { + let seconds = request.duration_ms as f64 / MILLIS_PER_SEC as f64; + let loop_instruction = loop_mode.then(|| { + let mut arguments = json!({ + "message": request.message, + "duration_ms": request.duration_ms, + "iteration": request.iteration.saturating_add(1) + }); + // The schema allows `loop_key` only as a string: omit it rather than + // hand back a ready-to-call instruction carrying `null`. + if let Some(loop_key) = &request.loop_key { + arguments["loop_key"] = json!(loop_key); + } + json!({ "tool": "wait_loop", "arguments": arguments }) + }); + let payload = json!({ + "status": "elapsed", + "message": request.message, + "duration_ms": request.duration_ms, + "durationMs": request.duration_ms, + "duration_secs": seconds, + "durationSecs": seconds, + "loop": loop_mode, + "loop_key": request.loop_key, + "loopKey": request.loop_key, + "iteration": request.iteration, + "instructions": { + "callback_message": request.message, + "repeat": loop_instruction + } + }); + + let prefix = if loop_mode { + format!( + "Loop tick {} elapsed after {}ms.", + request.iteration, request.duration_ms + ) + } else { + format!("Wait elapsed after {}ms.", request.duration_ms) + }; + + format!( + "{prefix}\n\n[wait_tick]\n{}\n[/wait_tick]\n\n{}", + serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()), + request.message + ) +} + +#[derive(Debug)] +/// Normalized wait arguments used by both wait tools. +struct WaitRequest { + message: String, + duration_ms: u64, + iteration: u64, + loop_key: Option, +} + +#[cfg(test)] +#[path = "wait_tests.rs"] +mod tests; From acae9d14d394e5fe8e8f60e3ae6a22356124963e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 02:18:01 +0000 Subject: [PATCH 59/68] feat(tinyagents-harness-tools): add wait tools and json-schema feature Add WaitTool and WaitLoopTool for bounded delayed callback ticks used by orchestrator-controlled polling, exporting them from the crate root and documenting them in the README. Also add an optional json-schema feature backed by schemars. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.lock | 1 + crates/tinyagents-harness-tools/Cargo.toml | 2 ++ crates/tinyagents-harness-tools/README.md | 7 ++++++- crates/tinyagents-harness-tools/src/lib.rs | 5 +++++ crates/tinyagents-harness-tools/src/wait_tests.rs | 5 ++++- 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 977bc81af..7e8bd6464 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2066,6 +2066,7 @@ dependencies = [ "async-trait", "chrono", "chrono-tz", + "schemars", "serde", "serde_json", "tempfile", diff --git a/crates/tinyagents-harness-tools/Cargo.toml b/crates/tinyagents-harness-tools/Cargo.toml index 13f733fe1..a98c7b272 100644 --- a/crates/tinyagents-harness-tools/Cargo.toml +++ b/crates/tinyagents-harness-tools/Cargo.toml @@ -14,6 +14,7 @@ chrono = { workspace = true } chrono-tz = "0.10" serde = { workspace = true } serde_json = { workspace = true } +schemars = { version = "1", features = ["derive"], optional = true } tinyagents-harness = { path = "../tinyagents-harness", version = "2.11.0", default-features = false } tinyagents-tasks = { path = "../tinyagents-tasks", version = "2.11.0", default-features = false, optional = true } tinyinference-llm = { path = "../../vendor/tinyinference/crates/tinyinference-llm", version = "0.5.0", optional = true } @@ -29,6 +30,7 @@ goals = [] skills = [] state-block = ["goals", "todos", "dep:tinyinference-llm"] subagents = ["dep:tinyagents-orchestration", "dep:tinyagents-tasks", "dep:tinyinference-llm"] +json-schema = ["dep:schemars"] [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/tinyagents-harness-tools/README.md b/crates/tinyagents-harness-tools/README.md index e2bb3d3f2..16c9589a2 100644 --- a/crates/tinyagents-harness-tools/README.md +++ b/crates/tinyagents-harness-tools/README.md @@ -29,7 +29,12 @@ run-state middleware and its required `goals` and `todos` modules. `tinyagents_orchestration::subagent::SubAgentJobRegistry`. - `thread_locks` provides the public `ThreadLockMap` concurrency primitive. - The crate root exports `CurrentTimeTool`, `ResolveTimeTool`, - `AskClarificationTool`, `PlanExitTool`, and the `per_call` binding adapter. + `AskClarificationTool`, `PlanExitTool`, `WaitTool`, `WaitLoopTool`, and the + `per_call` binding adapter. + +`WaitTool` and `WaitLoopTool` provide bounded delayed callback ticks for +orchestrator-controlled polling. They return the caller's callback message +without owning scheduling state. Subagent registry policy stays in `tinyagents-orchestration`; this crate owns the reusable tool declarations. The harness owns tool registration, diff --git a/crates/tinyagents-harness-tools/src/lib.rs b/crates/tinyagents-harness-tools/src/lib.rs index 8c2ab0080..fbd58e693 100644 --- a/crates/tinyagents-harness-tools/src/lib.rs +++ b/crates/tinyagents-harness-tools/src/lib.rs @@ -12,6 +12,7 @@ mod plan_exit; pub mod thread_locks; mod time; mod time_parse; +mod wait; #[cfg(feature = "goals")] pub mod goals; @@ -29,6 +30,7 @@ pub mod todos; pub use ask_clarification::AskClarificationTool; pub use plan_exit::{PLAN_EXIT_MARKER, PlanExitModeLookup, PlanExitTool}; pub use time::{CurrentTimeTool, ResolveTimeTool, register_time_tools, time_tools}; +pub use wait::{WaitLoopTool, WaitTool}; #[cfg(test)] #[path = "time_parse_tests.rs"] @@ -39,3 +41,6 @@ mod time_tests; #[cfg(test)] #[path = "wait_tests.rs"] mod wait_tests; +#[cfg(test)] +#[path = "wait_tests.rs"] +mod wait_tests; diff --git a/crates/tinyagents-harness-tools/src/wait_tests.rs b/crates/tinyagents-harness-tools/src/wait_tests.rs index fc0efc04f..1cb2f574b 100644 --- a/crates/tinyagents-harness-tools/src/wait_tests.rs +++ b/crates/tinyagents-harness-tools/src/wait_tests.rs @@ -89,7 +89,10 @@ fn timeout_policy_covers_the_requested_wait_with_grace() { use tinytools::{Tool, ToolTimeout}; let args = json!({"message": "poll", "duration_secs": 4}); - assert_eq!(WaitTool::new().timeout_policy(&args), ToolTimeout::Millis(5_000)); + assert_eq!( + WaitTool::new().timeout_policy(&args), + ToolTimeout::Millis(5_000) + ); } #[tokio::test] From a274a756648e51ad114393cd9383385faf563f69 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 02:18:19 +0000 Subject: [PATCH 60/68] chore: remove duplicate wait_tests module declaration The wait_tests module was declared twice in a row, which would fail to compile. Removed the redundant duplicate declaration. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinyagents-harness-tools/src/lib.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/tinyagents-harness-tools/src/lib.rs b/crates/tinyagents-harness-tools/src/lib.rs index fbd58e693..8961b7772 100644 --- a/crates/tinyagents-harness-tools/src/lib.rs +++ b/crates/tinyagents-harness-tools/src/lib.rs @@ -38,9 +38,3 @@ mod time_parse_tests; #[cfg(test)] #[path = "time_tests.rs"] mod time_tests; -#[cfg(test)] -#[path = "wait_tests.rs"] -mod wait_tests; -#[cfg(test)] -#[path = "wait_tests.rs"] -mod wait_tests; From 61580d7edec6b18d3cf112d95042bd333fc46ad6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 02:19:11 +0000 Subject: [PATCH 61/68] docs(tinyagents-harness-tools): document the json-schema feature The README now mentions that the json-schema feature enables schema derives on tool-owned data types, so the feature list matches what the crate actually exposes. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinyagents-harness-tools/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-harness-tools/README.md b/crates/tinyagents-harness-tools/README.md index 16c9589a2..9da463eb1 100644 --- a/crates/tinyagents-harness-tools/README.md +++ b/crates/tinyagents-harness-tools/README.md @@ -11,7 +11,8 @@ feature enables the generic job and message tools and brings in `tinyagents-orchestration` and its task registry dependencies. Time, clarification, plan-exit, per-call binding, and thread-lock support are available without optional features. The `state-block` feature enables the -run-state middleware and its required `goals` and `todos` modules. +run-state middleware and its required `goals` and `todos` modules. The +`json-schema` feature enables schema derives on tool-owned data types. ## Public modules and tools From 30a04393b57d3af54830a91ae04885c0c74576d3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:12:15 +0000 Subject: [PATCH 62/68] test(session): update tests for new storage and prefix fields Adjust session and runtime tests to match the reworked storage construction and the added prefix and reset flags on transcript metadata. The concurrent persistence test now reuses the shared in-memory store helper instead of standing up a SQLite backend, and scope construction goes through the fallible constructor. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinyagents-runtime/src/hooks_tests.rs | 2 ++ .../drivers/transcripts_adoption_tests.rs | 8 ++++++-- .../src/testkit/in_memory_transcript_tests.rs | 1 + .../src/threads/driver_tests.rs | 10 ++++++---- .../src/threads/persistence_tests.rs | 19 +++---------------- 5 files changed, 18 insertions(+), 22 deletions(-) diff --git a/crates/tinyagents-runtime/src/hooks_tests.rs b/crates/tinyagents-runtime/src/hooks_tests.rs index 59610acfa..8d3e83e1c 100644 --- a/crates/tinyagents-runtime/src/hooks_tests.rs +++ b/crates/tinyagents-runtime/src/hooks_tests.rs @@ -67,6 +67,7 @@ async fn callback_adapter_implements_runtime_session_hooks() { history: &[], raw_history: &raw_history, prefix: &prefix, + prefix_is_frozen: false, transcript_target: None, committed_turns: 4, resumed: true, @@ -135,6 +136,7 @@ async fn callback_adapter_propagates_hook_errors_through_runtime_trait() { history: &[], raw_history: &raw_history, prefix: &prefix, + prefix_is_frozen: false, transcript_target: None, committed_turns: 0, resumed: false, diff --git a/crates/tinyagents-session/src/port/drivers/transcripts_adoption_tests.rs b/crates/tinyagents-session/src/port/drivers/transcripts_adoption_tests.rs index 42b1f004b..abd589801 100644 --- a/crates/tinyagents-session/src/port/drivers/transcripts_adoption_tests.rs +++ b/crates/tinyagents-session/src/port/drivers/transcripts_adoption_tests.rs @@ -116,8 +116,12 @@ fn empty_import_does_not_reserve_a_session() { #[test] fn competing_imports_commit_one_complete_history() { let storage = MemoryStorage::new(); - let docs: Arc = - Arc::clone(storage.for_scope(&Scope::local()).unwrap().documents()); + let docs: Arc = Arc::clone( + storage + .for_scope(&Scope::new("local").unwrap()) + .unwrap() + .documents(), + ); let locators = [ Arc::new(DriverTranscriptLocator::new( Arc::clone(&docs), diff --git a/crates/tinyagents-session/src/testkit/in_memory_transcript_tests.rs b/crates/tinyagents-session/src/testkit/in_memory_transcript_tests.rs index 8ef7b37d5..0e6309481 100644 --- a/crates/tinyagents-session/src/testkit/in_memory_transcript_tests.rs +++ b/crates/tinyagents-session/src/testkit/in_memory_transcript_tests.rs @@ -22,6 +22,7 @@ fn in_memory_transcript_starts_empty() { task_id: None, session_id: None, parent_session_id: None, + prefix_reset: false, }; let transcript = locator.open_stem("fixture", meta).expect("bind transcript"); diff --git a/crates/tinyagents-session/src/threads/driver_tests.rs b/crates/tinyagents-session/src/threads/driver_tests.rs index 294448e54..c6b417ee7 100644 --- a/crates/tinyagents-session/src/threads/driver_tests.rs +++ b/crates/tinyagents-session/src/threads/driver_tests.rs @@ -324,13 +324,15 @@ async fn concurrent_message_metadata_merges_retry_against_latest_row() { barrier: tokio::sync::Barrier::new(2), refuse_get: false, }); - let store = DriverConversationStore::new(ScopedStorage::new( - scope, - "test", + let storage = tinystoragedrivers_api::Storage::new( docs.clone(), ports.streams().clone(), + ports.kv().clone(), ports.blobs().clone(), - )); + ports.queues().clone(), + ports.secrets().clone(), + ); + let store = DriverConversationStore::new(ScopedStorage::new(storage, scope)); let (reaction, receipt) = tokio::join!( store.update_message( "one", diff --git a/crates/tinyagents-session/src/threads/persistence_tests.rs b/crates/tinyagents-session/src/threads/persistence_tests.rs index 2a783f96d..380083f7f 100644 --- a/crates/tinyagents-session/src/threads/persistence_tests.rs +++ b/crates/tinyagents-session/src/threads/persistence_tests.rs @@ -72,20 +72,7 @@ async fn memory_store_preserves_deterministic_append_and_missing_thread_behavior #[tokio::test(flavor = "multi_thread", worker_threads = 8)] async fn concurrent_user_request_id_appends_are_idempotent() { - let dir = tempfile::tempdir().unwrap(); - let backend = - tinystoragedrivers_sqlite::SqliteStorage::open(dir.path().join("concurrent.db")).unwrap(); - backend.prepare_schema().await.unwrap(); - let scoped = backend - .for_scope(&tinystoragedrivers_core::Scope::new("alice").unwrap()) - .unwrap(); - DriverConversationStore::prepare_schema(scoped.documents().as_ref()) - .await - .unwrap(); - DriverConversationStore::prepare_append_schema(scoped.documents().as_ref()) - .await - .unwrap(); - let store = DriverConversationStore::new_validate_only(scoped); + let store = memory_store().await; for round in 0..32 { let thread_id = format!("thread-{round}"); store.ensure_thread(thread(&thread_id)).await.unwrap(); @@ -185,7 +172,7 @@ async fn memory_store_preserves_title_labels_and_metadata_patch() { #[tokio::test] async fn concurrent_metadata_merge_patches_preserve_each_other_and_existing_metadata() { - let store = DriverConversationStore::in_memory().await; + let store = memory_store().await; store.ensure_thread(thread("one")).await.unwrap(); store .append_message( @@ -238,7 +225,7 @@ async fn concurrent_metadata_merge_patches_preserve_each_other_and_existing_meta #[tokio::test] async fn metadata_merge_patch_null_removes_only_the_named_member() { - let store = DriverConversationStore::in_memory().await; + let store = memory_store().await; store.ensure_thread(thread("one")).await.unwrap(); store .append_message( From e5f0353b73969ec098c5fb3f4670af7b6cc22c18 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 24 Oct 2026 13:22:58 +0000 Subject: [PATCH 63/68] chore(orchestration): expose run_progress_ledger module Adds the run_progress_ledger module to the orchestration crate's public surface so the progress ledger can be used by dependents. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinyagents-orchestration/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinyagents-orchestration/src/lib.rs b/crates/tinyagents-orchestration/src/lib.rs index ae0cc682f..a4b446a1e 100644 --- a/crates/tinyagents-orchestration/src/lib.rs +++ b/crates/tinyagents-orchestration/src/lib.rs @@ -13,6 +13,7 @@ //! `orchestration -> {harness, session, tasks, storage ports}`. Lower-level //! crates never depend on this composition layer. +pub mod run_progress_ledger; pub mod steering; pub mod subagent; From 348fb7d93f26b0d4aa8090713c7a14c32391b227 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 24 Oct 2026 13:39:21 +0000 Subject: [PATCH 64/68] feat(turn_runner): add cancel_grace config for cancelled runs A new cancel_grace field on TurnRunnerConfig lets a cancelled host run keep being polled with its cancellation token already fired before it is dropped, giving the host a window to hand back and persist completed rounds. The default is zero, which drops the run immediately as before. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinyagents-harness/src/turn_runner/types.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/tinyagents-harness/src/turn_runner/types.rs b/crates/tinyagents-harness/src/turn_runner/types.rs index 68f82f21f..b737b930b 100644 --- a/crates/tinyagents-harness/src/turn_runner/types.rs +++ b/crates/tinyagents-harness/src/turn_runner/types.rs @@ -364,6 +364,11 @@ pub struct TurnRunnerConfig { pub idle_timeout: Duration, pub deadline: Option, pub close_timeout: Duration, + /// How long a cancelled host run keeps being polled, with its own + /// cancellation token already fired, before it is dropped. A host uses the + /// window to hand back and persist the rounds it completed. Zero drops the + /// run immediately. + pub cancel_grace: Duration, } impl Default for TurnRunnerConfig { @@ -372,6 +377,7 @@ impl Default for TurnRunnerConfig { idle_timeout: Duration::from_secs(900), deadline: None, close_timeout: Duration::from_secs(60), + cancel_grace: Duration::ZERO, } } } @@ -1301,6 +1307,17 @@ where _ = cancellation.cancelled() => { task_cancellation.cancel(); self.host.cancel_children(request).await; + if !self.config.cancel_grace.is_zero() { + // Let the host unwind with its token fired; its result + // is discarded either way. + let unwound = tokio::time::timeout(self.config.cancel_grace, &mut run).await; + tracing::debug!( + thread_id = %request.thread_id, + request_id = %request.request_id, + unwound = unwound.is_ok(), + "turn runner: cancelled run left its grace window" + ); + } return Err("turn cancelled".into()); } _ = async { if let Some(deadline) = deadline.at { tokio::time::sleep_until(deadline).await } else { std::future::pending().await } }, if deadline.at.is_some() => { From ec31e4c8257c77a3b23e39e09f383aa989bc77de Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 24 Oct 2026 13:39:48 +0000 Subject: [PATCH 65/68] test(turn_runner): cover cancellation grace during run unwinding Add tests that cancel an in-flight run whose host keeps working after cancellation, asserting that a configured cancel grace lets the run unwind and persist its progress while a zero grace drops it immediately. The existing config literals in this file now fall back to TurnRunnerConfig::default() so new fields do not break them. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/turn_runner/mod_tests.rs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs index 080fce43c..22d940c80 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs @@ -2242,6 +2242,7 @@ async fn idle_guard_restarts_when_quiet_work_finishes() { idle_timeout: std::time::Duration::from_secs(5), deadline: None, close_timeout: std::time::Duration::from_secs(1), + ..TurnRunnerConfig::default() }, )); let mut turn = request(QueueDisposition::Interrupt, false); @@ -2278,6 +2279,7 @@ async fn continuous_progress_cannot_starve_the_wall_deadline() { idle_timeout: std::time::Duration::from_secs(60), deadline: Some(std::time::Duration::from_secs(5)), close_timeout: std::time::Duration::from_secs(1), + ..TurnRunnerConfig::default() }, )); let run = { @@ -2314,6 +2316,7 @@ async fn wall_deadline_is_polled_while_progress_delivery_is_pending() { idle_timeout: std::time::Duration::from_secs(60), deadline: Some(std::time::Duration::from_secs(5)), close_timeout: std::time::Duration::from_secs(1), + ..TurnRunnerConfig::default() }, )); let run = { @@ -2625,3 +2628,86 @@ async fn queue_snapshot_preserves_lane_fifo_and_does_not_drain_work() { Err(TurnRunnerError::Cancelled) )); } + +#[derive(Default)] +struct UnwindingHost { + started: tokio::sync::Notify, + persisted: std::sync::atomic::AtomicBool, +} + +#[async_trait::async_trait] +impl TurnHost for UnwindingHost { + async fn checkout(&self, request: &TurnRequest) -> Result { + Ok(TurnSession { + session_id: request.thread_id.clone(), + fingerprint: None, + }) + } + async fn checkout_fork(&self, request: &TurnRequest) -> Result { + self.checkout(request).await + } + fn run_origin(&self, _request: &TurnRequest) -> serde_json::Value { + serde_json::Value::Null + } + async fn run( + &self, + _request: &TurnRequest, + _session: &TurnSession, + _origin: &serde_json::Value, + cancellation: crate::CancellationToken, + _queue: Arc>, + _progress: tokio::sync::mpsc::Sender, + ) -> Result { + self.started.notify_one(); + cancellation.cancelled().await; + // Hand back and persist the rounds completed so far. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + self.persisted + .store(true, std::sync::atomic::Ordering::SeqCst); + Err("stopped".into()) + } + async fn checkin(&self, _request: &TurnRequest) {} + async fn cancel_children(&self, _request: &TurnRequest) {} +} + +async fn cancel_unwinding_turn(grace: std::time::Duration) -> bool { + let host = Arc::new(UnwindingHost::default()); + let runner = Arc::new(TurnRunner::new( + host.clone(), + Arc::new(Ingress::default()), + Arc::new(Replies::default()), + Arc::new(Events::default()), + Arc::new(Failures), + Arc::new(Summary), + TurnRunnerConfig { + cancel_grace: grace, + ..TurnRunnerConfig::default() + }, + )); + let primary = { + let runner = runner.clone(); + tokio::spawn(async move { + runner + .run(request(QueueDisposition::Interrupt, false)) + .await + }) + }; + host.started.notified().await; + let req = request(QueueDisposition::Interrupt, false); + assert!(runner.cancel(&req.scope, &req.thread_id, None).await); + assert!(matches!( + primary.await.unwrap(), + Err(TurnRunnerError::Cancelled) + )); + host.persisted.load(std::sync::atomic::Ordering::SeqCst) +} + +#[tokio::test] +async fn cancelled_run_unwinds_within_the_cancel_grace() { + assert!(cancel_unwinding_turn(std::time::Duration::from_secs(2)).await); +} + +#[tokio::test] +async fn cancelled_run_is_dropped_at_once_without_a_cancel_grace() { + assert!(!cancel_unwinding_turn(std::time::Duration::ZERO).await); +} From 7ca372dacad64ad3de6287bb4fabff10416c187d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 24 Oct 2026 13:40:38 +0000 Subject: [PATCH 66/68] test(turn_runner): use default config fields in cancellation test The cancellation test now spreads TurnRunnerConfig::default() into its config literal so it keeps compiling as new fields are added to the struct. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinyagents-harness/src/turn_runner/mod_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs index 22d940c80..16cb3e6cf 100644 --- a/crates/tinyagents-harness/src/turn_runner/mod_tests.rs +++ b/crates/tinyagents-harness/src/turn_runner/mod_tests.rs @@ -2410,6 +2410,7 @@ async fn cancellation_during_grounded_close_prevents_persisting_completion() { idle_timeout: std::time::Duration::from_secs(5), deadline: None, close_timeout: std::time::Duration::from_secs(60), + ..TurnRunnerConfig::default() }, )); let mut turn = request(QueueDisposition::Interrupt, false); From 5765c5f2b9bb1078b74d62c084c43236eda2a6e1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 06:31:18 +0000 Subject: [PATCH 67/68] test(goals): update expected schema fields The public goal schema test now expects timeUsedSubsecNanos and no longer expects continuationSuppressed, matching the current serialized record. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinyagents-harness-tools/src/goals/mod_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinyagents-harness-tools/src/goals/mod_tests.rs b/crates/tinyagents-harness-tools/src/goals/mod_tests.rs index afa5e1f61..a2371f38d 100644 --- a/crates/tinyagents-harness-tools/src/goals/mod_tests.rs +++ b/crates/tinyagents-harness-tools/src/goals/mod_tests.rs @@ -79,9 +79,9 @@ fn public_goal_schema_uses_the_serialized_camel_case_record() { "tokenBudget", "tokensUsed", "timeUsedSeconds", + "timeUsedSubsecNanos", "createdAtMs", "updatedAtMs", - "continuationSuppressed", ] { assert!(properties.get(field).is_some(), "missing {field}: {schema}"); } From ce0cfd5dce80ef3681e88aaf30a4856f56d55a69 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 06:31:34 +0000 Subject: [PATCH 68/68] chore(run-ledger-mongodb): add schemars and its transitive deps to lockfile The lockfile now records schemars along with the crates it pulls in, such as dyn-clone, ref-cast, and serde_derive_internals, and lists schemars as a dependency of the run-ledger-mongodb proof. This keeps the lockfile in sync with the manifest after schemars was introduced. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../run-ledger-mongodb/Cargo.lock | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/integration-proofs/run-ledger-mongodb/Cargo.lock b/integration-proofs/run-ledger-mongodb/Cargo.lock index 7d7eaa5d9..f80ac737b 100644 --- a/integration-proofs/run-ledger-mongodb/Cargo.lock +++ b/integration-proofs/run-ledger-mongodb/Cargo.lock @@ -503,6 +503,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.19.0" @@ -1711,6 +1717,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "regex" version = "1.13.1" @@ -1931,6 +1957,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.9", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1983,6 +2035,17 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "serde_json" version = "1.0.154" @@ -2394,6 +2457,7 @@ dependencies = [ "chrono", "hex", "parking_lot", + "schemars", "serde", "serde_json", "sha2 0.11.1",