From 995864fec43a83a99467637090216e185b0ed9cb Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:41:21 +0000 Subject: [PATCH 01/14] feat: add generic wait tools Co-authored-by: Medulla --- crates/tinyagents-harness-tools/src/lib.rs | 2 + crates/tinyagents-harness-tools/src/wait.rs | 302 ++++++++++++++++++ .../src/wait_tests.rs | 133 ++++++++ 3 files changed, 437 insertions(+) create mode 100644 crates/tinyagents-harness-tools/src/wait.rs create mode 100644 crates/tinyagents-harness-tools/src/wait_tests.rs diff --git a/crates/tinyagents-harness-tools/src/lib.rs b/crates/tinyagents-harness-tools/src/lib.rs index 12579b82a..2aedbfec5 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; @@ -25,6 +26,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"] diff --git a/crates/tinyagents-harness-tools/src/wait.rs b/crates/tinyagents-harness-tools/src/wait.rs new file mode 100644 index 000000000..9529f000e --- /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::None + } + + 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::None + } + + 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; 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..2352b2c18 --- /dev/null +++ b/crates/tinyagents-harness-tools/src/wait_tests.rs @@ -0,0 +1,133 @@ +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")); +} + +#[test] +fn delayed_wait_tools_are_available_to_read_only_turns() { + assert_eq!( + WaitTool::new().permission_level(), + tinytools::PermissionLevel::None + ); + assert_eq!( + WaitLoopTool::new().permission_level(), + tinytools::PermissionLevel::None + ); +} + +/// 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 0049410a9262cbb83721d283eb14e9c8a21ea422 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:48:39 +0000 Subject: [PATCH 02/14] test: avoid cache TTL timing flake Co-authored-by: Medulla --- crates/tinyagents-graph/src/testkit/conformance.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-graph/src/testkit/conformance.rs b/crates/tinyagents-graph/src/testkit/conformance.rs index 835a3458c..4e45b54eb 100644 --- a/crates/tinyagents-graph/src/testkit/conformance.rs +++ b/crates/tinyagents-graph/src/testkit/conformance.rs @@ -792,11 +792,11 @@ where let short = key("g", "n", "short"); cache - .put(&short, json!(1), Some(Duration::from_millis(30))) + .put(&short, json!(1), Some(Duration::from_secs(2))) .await .unwrap(); assert_eq!(cache.get(&short).await.unwrap(), Some(json!(1))); - tokio::time::sleep(Duration::from_millis(80)).await; + tokio::time::sleep(Duration::from_millis(2_100)).await; assert_eq!(cache.get(&short).await.unwrap(), None, "expired after TTL"); assert_eq!(cache.get(&short).await.unwrap(), None, "stays expired"); cache.put(&short, json!(2), None).await.unwrap(); From 3566917c2b53eeaa5d901dca872cdddcf424a09e Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:49:45 +0000 Subject: [PATCH 03/14] docs: list wait tools in harness tools readme 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 4e0d5dec7..a9318224f 100644 --- a/crates/tinyagents-harness-tools/README.md +++ b/crates/tinyagents-harness-tools/README.md @@ -26,7 +26,8 @@ available without optional features. `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. Subagent registry policy stays in `tinyagents-orchestration`; this crate owns the reusable tool declarations. The harness owns tool registration, From 1510e88ed2082f2555dcf05e0a546b945678add0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 18:50:15 +0000 Subject: [PATCH 04/14] chore(vendor): bump tinyinference and tinytools submodules Update the pinned tinyinference and tinytools submodule commits to their latest revisions. 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 01d3836af..9040b6624 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 01d3836af685e3cbb4ba03422d246332d27f52e1 +Subproject commit 9040b66246bbd90fcebc3634996eefd55580438b diff --git a/vendor/tinytools b/vendor/tinytools index 3a01641f8..616408be3 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 3a01641f8ebd8a55656cd212f9a90638d958a447 +Subproject commit 616408be32fe5fad8ad1e262149f40ec1056142b From c899bbbab130688470f6b94f4f33cdbdf510edf8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 18:51:19 +0000 Subject: [PATCH 05/14] fix(vendor): retain merged release dependency pins 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 9040b6624..01d3836af 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 9040b66246bbd90fcebc3634996eefd55580438b +Subproject commit 01d3836af685e3cbb4ba03422d246332d27f52e1 diff --git a/vendor/tinytools b/vendor/tinytools index 616408be3..3a01641f8 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 616408be32fe5fad8ad1e262149f40ec1056142b +Subproject commit 3a01641f8ebd8a55656cd212f9a90638d958a447 From 6e9bed71a5801ed935fdc66024eb169383a64450 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 19:06:21 +0000 Subject: [PATCH 06/14] test(agent_loop): cover constant-cap retries and cached blank bypass Adds tests for the truncated-empty constant-cap path, verifying that retries reach the provider rather than replaying a cached blank and that only the usable answer is cached. Also covers recovery bypassing a blank cached before retries were enabled, while disabled recovery still replays exactly. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../agent_loop/run_loop_dead_call_tests.rs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs b/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs index 95fa44ac4..5277c5d4d 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs @@ -1,5 +1,6 @@ //! The call after a dead one (`RunPolicy::truncated_empty_reasoning_off`). +use super::*; use std::sync::Arc; use crate::context::{RunConfig, RunContext}; @@ -124,3 +125,68 @@ async fn reasoning_off_sticks_for_the_rest_of_the_run_after_repeated_dead_calls( vec![high, none, high, none, none] ); } + +#[tokio::test] +async fn constant_cap_retries_reach_the_provider_instead_of_replaying_cached_blanks() { + let model = Arc::new(ScriptedModel::new(vec![ + dead(2048), + crate::testkit::text_response("recovered"), + ])); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model("mock", model.clone()); + harness.with_response_cache(Arc::new(crate::cache::InMemoryResponseCache::new())); + harness.with_policy(RunPolicy { + truncated_empty_constant_cap: true, + truncated_empty_retries: 1, + truncated_empty_nudges: 0, + ..RunPolicy::default() + }); + let input = vec![Message::user("same question")]; + for run_id in ["recover", "reuse-answer"] { + let ctx = RunContext::new( + RunConfig::new(run_id).with_max_turn_output_tokens(2048), + (), + ); + let run = harness.invoke_in_context(&(), ctx, input.clone()).await.unwrap(); + assert_eq!(run.text(), Some("recovered".into())); + } + assert_eq!(model.requests().len(), 2, "only the usable answer is cached"); +} + +#[tokio::test] +async fn truncated_empty_recovery_bypasses_a_blank_cached_before_retries_were_enabled() { + let cache = Arc::new(crate::cache::InMemoryResponseCache::new()); + let input = vec![Message::user("same question")]; + let seed_model = Arc::new(ScriptedModel::new(vec![dead(2048)])); + let mut seed: AgentHarness<()> = AgentHarness::new(); + seed.register_model("mock", seed_model.clone()); + seed.with_response_cache(cache.clone()); + seed.with_policy(RunPolicy { + truncated_empty_retries: 0, + truncated_empty_nudges: 0, + ..RunPolicy::default() + }); + for run_id in ["seed-blank", "replay-with-recovery-disabled"] { + let ctx = RunContext::new( + RunConfig::new(run_id).with_max_turn_output_tokens(2048), + (), + ); + seed.invoke_in_context(&(), ctx, input.clone()).await.unwrap(); + } + assert_eq!(seed_model.requests().len(), 1, "disabled recovery retains exact replay"); + + let model = Arc::new(ScriptedModel::new(vec![crate::testkit::text_response("fresh answer")])); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model("mock", model.clone()); + harness.with_response_cache(cache); + harness.with_policy(RunPolicy { + truncated_empty_constant_cap: true, + truncated_empty_retries: 1, + truncated_empty_nudges: 0, + ..RunPolicy::default() + }); + let ctx = RunContext::new(RunConfig::new("recover-cached-blank").with_max_turn_output_tokens(2048), ()); + let run = harness.invoke_in_context(&(), ctx, input).await.unwrap(); + assert_eq!(run.text(), Some("fresh answer".into())); + assert_eq!(model.requests().len(), 1, "the prior blank cache hit is bypassed"); +} From ecc47c7792929bc979b5a6377040359c3d19af6c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 19:06:35 +0000 Subject: [PATCH 07/14] test(agent-loop): remove unused imports from dead call tests Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/agent_loop/run_loop_dead_call_tests.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs b/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs index 5277c5d4d..d9d7ced73 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs @@ -3,8 +3,6 @@ use super::*; use std::sync::Arc; -use crate::context::{RunConfig, RunContext}; -use crate::runtime::{AgentHarness, RunPolicy}; use crate::testkit::ScriptedModel; use tinyinference_llm::message::Message; use tinyinference_llm::model::{ModelResponse, ReasoningConfig, ReasoningEffort}; From c4cee6f138c552a54745f00ec3fc32fa9d6bc2a5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 19:06:50 +0000 Subject: [PATCH 08/14] test(agent_loop): import RunPolicy in dead call tests Add the missing RunPolicy import to the dead call test module so the tests can reference the runtime policy type. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/agent_loop/run_loop_dead_call_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs b/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs index d9d7ced73..3d117a4ee 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs @@ -3,6 +3,7 @@ use super::*; use std::sync::Arc; +use crate::runtime::RunPolicy; use crate::testkit::ScriptedModel; use tinyinference_llm::message::Message; use tinyinference_llm::model::{ModelResponse, ReasoningConfig, ReasoningEffort}; From 14e7a36b6e24fa973314c129fc156812478fb5a8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 19:07:09 +0000 Subject: [PATCH 09/14] docs(agent_loop): document truncated-empty retry and reasoning controls Update the README to match the retry behaviour: the default retry count is now two, the first retry reuses the original output cap before later retries double it, and a new constant-cap option pins every retry to that cap. Also document the reasoning-effort overrides that turn reasoning off for the call after a truncated-empty reply, either once or stickily for the rest of the run. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/agent_loop/README.md | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/README.md b/crates/tinyagents-harness/src/agent_loop/README.md index 6cf96e698..6195014e7 100644 --- a/crates/tinyagents-harness/src/agent_loop/README.md +++ b/crates/tinyagents-harness/src/agent_loop/README.md @@ -147,17 +147,31 @@ as Anthropic and some gateways spell it — an empty `max_tokens` reply takes th path too) with no visible text, no tool calls, and no structured output — a result useless to every caller. Before finalizing such a turn (and before structured extraction, which would otherwise fail on the empty completion) the loop retries the model call up to -`runtime::RunPolicy::truncated_empty_retries` times (default `1`, so two -attempts total). Each retry drops the useless assistant row, doubles the -request's `max_tokens` when one was set — clamped at 4x the original cap, and a -deliberate override of the per-turn output cap that caused the truncation — or -re-issues unchanged when no budget was set (the failure is stochastic, so a -plain retry still helps). Each attempt counts as a model call and emits +`runtime::RunPolicy::truncated_empty_retries` times (default `2`, so three +attempts total). Each retry drops the useless assistant row. The first retries +at the same output cap; subsequent retries double `max_tokens` when one was +set, clamped at 4x the original cap. This deliberately overrides the per-turn +output cap that caused the truncation. With no cap, the request is reissued +unchanged: the failure is stochastic, so a plain retry still helps. +`RunPolicy::truncated_empty_constant_cap` (default `false`) instead keeps every +retry at the original cap, including later retries that would normally grow. +The wall-clock and model-call limits still bound these retries. Each attempt counts as a model call and emits `AgentEvent::RetryScheduled`. The retry runs *before* `RunPolicy::error_on_empty_response`; only once the retries are exhausted does that guard (if enabled) turn the still-blank final into `TinyAgentsError::EmptyResponse`. +`RunPolicy::truncated_empty_reasoning_off` (default `false`) sends the call +after a truncated-empty reply, whether a retry or a nudge, with +`ReasoningEffort::None`. This overrides the configured reasoning effort for +that one call; later calls return to the policy's reasoning configuration. +`RunPolicy::truncated_empty_reasoning_off_sticky_after` (default `0`, disabled) +instead makes reasoning-off persist for the rest of the run once that many +truncated-empty replies have occurred, across tool-call steps. The sticky +threshold applies independently of the one-call setting. These controls can +combine with constant-cap retries to avoid spending a larger budget on the +same reasoning-only failure. + A hosted reasoning model on a high effort setting can deliberate past the boosted budget too. Once the retries are spent the loop does not finish on the blank reply: up to `RunPolicy::truncated_empty_nudges` times (default `1`) it From 02931c14038c776348a4eb17346409105853a98d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 19:07:49 +0000 Subject: [PATCH 10/14] fix(agent_loop): bypass response cache for truncated-empty retries Length-stopped blank responses are now excluded from the response cache whenever truncated-empty retries or nudges are enabled, so a retry at an unchanged cap reaches the provider instead of replaying the unusable cached answer. The check also consults the run policy rather than only the call shape, and the README documents the new caching behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/agent_loop/README.md | 6 ++- .../src/agent_loop/model_call.rs | 23 +++++++--- .../agent_loop/run_loop_dead_call_tests.rs | 46 +++++++++++++------ 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/README.md b/crates/tinyagents-harness/src/agent_loop/README.md index 6195014e7..4479dd76f 100644 --- a/crates/tinyagents-harness/src/agent_loop/README.md +++ b/crates/tinyagents-harness/src/agent_loop/README.md @@ -155,7 +155,11 @@ output cap that caused the truncation. With no cap, the request is reissued unchanged: the failure is stochastic, so a plain retry still helps. `RunPolicy::truncated_empty_constant_cap` (default `false`) instead keeps every retry at the original cap, including later retries that would normally grow. -The wall-clock and model-call limits still bound these retries. Each attempt counts as a model call and emits +The wall-clock and model-call limits still bound these retries. +When truncated-empty recovery is enabled, blank length-stopped responses are +neither stored in the response cache nor accepted from existing cache entries, +so retries at an unchanged cap still reach the provider. Usable answers remain +cacheable; disabling both truncated-empty retries and nudges retains replay. Each attempt counts as a model call and emits `AgentEvent::RetryScheduled`. The retry runs *before* `RunPolicy::error_on_empty_response`; only once the retries are exhausted does that guard (if enabled) turn the still-blank final into diff --git a/crates/tinyagents-harness/src/agent_loop/model_call.rs b/crates/tinyagents-harness/src/agent_loop/model_call.rs index 04fe0c337..25d1c1ef2 100644 --- a/crates/tinyagents-harness/src/agent_loop/model_call.rs +++ b/crates/tinyagents-harness/src/agent_loop/model_call.rs @@ -250,7 +250,7 @@ impl AgentHarness { } }; if let Some(mut cached) = looked_up { - if Self::should_skip_empty_response_cache(shape, &cached) { + if self.should_skip_empty_response_cache(shape, &cached) { tracing::debug!( call_id = %call_id.as_str(), "[cache] ignoring blank cached response; retry policy requires a provider call" @@ -333,7 +333,7 @@ impl AgentHarness { // reach the provider on the next identical request. Caching the // first successful-but-unusable completion would replay that blank // answer, mark it `served_from_cache`, and defeat the retry. - let blank_for_retry = Self::should_skip_empty_response_cache(shape, &response); + let blank_for_retry = self.should_skip_empty_response_cache(shape, &response); // Only the *primary* model's answer may be stored under this key. // `invoke_model_resolving` walks the fallback chain on failure and // can return a different model's response; writing that under the @@ -377,19 +377,28 @@ impl AgentHarness { /// A prior cache entry or a newly completed response must not short-circuit /// an opted-in retry when it contains no usable assistant answer. fn should_skip_empty_response_cache( + &self, shape: &super::dialect::CallShape, response: &ModelResponse, ) -> bool { // `ModelResponse` has no independent structured payload: the // StructuredExtractor reads visible text or tool-call arguments. The - // call shape still excludes structured plans so their own output retry - // policy remains the sole owner of failed extraction and caching. - shape.retry_empty_final + // call shape excludes structured plans from non-truncated empty retry; + // length-stopped blanks use the shared truncated-empty recovery before + // extraction, so they must also bypass cached dead responses. + let retryable = if crate::finish_reason::is_length_stop(response.finish_reason.as_deref()) { + // Truncated-empty recovery can resend an identical request, even + // without constant-cap mode: its first retry keeps the same cap. + // Exclude both prior entries and new writes so that retry reaches + // the provider rather than replaying the unusable cached answer. + self.policy.truncated_empty_retries > 0 || self.policy.truncated_empty_nudges > 0 + } else { + shape.retry_empty_final && response.finish_reason.as_deref() != Some("tool_calls") + }; + retryable && response.continue_turn.is_none() && response.message.tool_calls.is_empty() && response.text().trim().is_empty() - && !crate::finish_reason::is_length_stop(response.finish_reason.as_deref()) - && response.finish_reason.as_deref() != Some("tool_calls") } /// Resolves the effective [`CachePolicy`] for `request`: the per-request diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs b/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs index 3d117a4ee..22dfd50a3 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop_dead_call_tests.rs @@ -142,14 +142,18 @@ async fn constant_cap_retries_reach_the_provider_instead_of_replaying_cached_bla }); let input = vec![Message::user("same question")]; for run_id in ["recover", "reuse-answer"] { - let ctx = RunContext::new( - RunConfig::new(run_id).with_max_turn_output_tokens(2048), - (), - ); - let run = harness.invoke_in_context(&(), ctx, input.clone()).await.unwrap(); + let ctx = RunContext::new(RunConfig::new(run_id).with_max_turn_output_tokens(2048), ()); + let run = harness + .invoke_in_context(&(), ctx, input.clone()) + .await + .unwrap(); assert_eq!(run.text(), Some("recovered".into())); } - assert_eq!(model.requests().len(), 2, "only the usable answer is cached"); + assert_eq!( + model.requests().len(), + 2, + "only the usable answer is cached" + ); } #[tokio::test] @@ -166,15 +170,20 @@ async fn truncated_empty_recovery_bypasses_a_blank_cached_before_retries_were_en ..RunPolicy::default() }); for run_id in ["seed-blank", "replay-with-recovery-disabled"] { - let ctx = RunContext::new( - RunConfig::new(run_id).with_max_turn_output_tokens(2048), - (), - ); - seed.invoke_in_context(&(), ctx, input.clone()).await.unwrap(); + let ctx = RunContext::new(RunConfig::new(run_id).with_max_turn_output_tokens(2048), ()); + seed.invoke_in_context(&(), ctx, input.clone()) + .await + .unwrap(); } - assert_eq!(seed_model.requests().len(), 1, "disabled recovery retains exact replay"); + assert_eq!( + seed_model.requests().len(), + 1, + "disabled recovery retains exact replay" + ); - let model = Arc::new(ScriptedModel::new(vec![crate::testkit::text_response("fresh answer")])); + let model = Arc::new(ScriptedModel::new(vec![crate::testkit::text_response( + "fresh answer", + )])); let mut harness: AgentHarness<()> = AgentHarness::new(); harness.register_model("mock", model.clone()); harness.with_response_cache(cache); @@ -184,8 +193,15 @@ async fn truncated_empty_recovery_bypasses_a_blank_cached_before_retries_were_en truncated_empty_nudges: 0, ..RunPolicy::default() }); - let ctx = RunContext::new(RunConfig::new("recover-cached-blank").with_max_turn_output_tokens(2048), ()); + let ctx = RunContext::new( + RunConfig::new("recover-cached-blank").with_max_turn_output_tokens(2048), + (), + ); let run = harness.invoke_in_context(&(), ctx, input).await.unwrap(); assert_eq!(run.text(), Some("fresh answer".into())); - assert_eq!(model.requests().len(), 1, "the prior blank cache hit is bypassed"); + assert_eq!( + model.requests().len(), + 1, + "the prior blank cache hit is bypassed" + ); } From 53b022c6cb657d9af99d9a4f88ebb13b194297ca Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 19:08:42 +0000 Subject: [PATCH 11/14] test(agent_loop): assert the full retry cap ladder The truncated-empty retry test now scripts an extra truncated response and checks the max_tokens of every request, pinning that the first retry keeps its cap and only the second grows it. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinyagents-harness/src/agent_loop/mod_tests.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/mod_tests.rs b/crates/tinyagents-harness/src/agent_loop/mod_tests.rs index 9fdd1434c..8a6efcab6 100644 --- a/crates/tinyagents-harness/src/agent_loop/mod_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/mod_tests.rs @@ -1102,13 +1102,14 @@ async fn truncated_empty_retry_does_not_replay_a_cached_blank() { use crate::cache::InMemoryResponseCache; let model = Arc::new(crate::testkit::ScriptedModel::new(vec![ + truncated_empty_response(2048), truncated_empty_response(2048), text_response("recovered", 4, 3), ])); let mut harness: AgentHarness<()> = AgentHarness::new(); harness.register_model("mock", Arc::clone(&model) as _); harness.with_policy(RunPolicy { - // This test pins the cap ladder; keep reasoning on so the ladder runs. + // The first retry keeps its cap; only the second may grow it. ..RunPolicy::default() }); harness.with_response_cache(Arc::new(InMemoryResponseCache::new())); @@ -1122,8 +1123,12 @@ async fn truncated_empty_retry_does_not_replay_a_cached_blank() { .await .expect("length-truncated empty completion should reach the provider again"); assert_eq!(run.text(), Some("recovered".to_string())); - assert_eq!(model.requests().len(), 2); - assert_eq!(model.requests()[1].max_tokens, Some(4096)); + let caps: Vec<_> = model + .requests() + .iter() + .map(|request| request.max_tokens) + .collect(); + assert_eq!(caps, [Some(2048), Some(2048), Some(4096)]); } #[tokio::test] From ff18508b6303c31a035f9dc259b222a3a9068547 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 19:18:21 +0000 Subject: [PATCH 12/14] docs(pricing): clarify lookup fallback and remaining limitations The pricing README now documents that lookup matches an exact canonical id first, then an id with a leading vendor segment removed, and that the fallback only accepts dated or preview snapshot decorations of listed ids. Unlisted variants such as gpt-4.1-nano and o3-pro return None instead of inheriting a base model's rates, and the remaining cached-rate and long-context limitations are restated as needing separate behavioral corrections. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .github/workflows/session-mongodb.yml | 2 +- .../tinyagents-registry/src/pricing/README.md | 17 +++++++++++------ .../run-ledger-mongodb/Cargo.lock | 12 ++++++------ 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.github/workflows/session-mongodb.yml b/.github/workflows/session-mongodb.yml index b91b33e28..872bb1a49 100644 --- a/.github/workflows/session-mongodb.yml +++ b/.github/workflows/session-mongodb.yml @@ -55,4 +55,4 @@ jobs: run: .github/scripts/start-session-mongo-replset.sh 27017 - name: Run MongoDB driver conformance - run: cargo test --manifest-path integration-proofs/run-ledger-mongodb/Cargo.toml --features mongodb-storage-tests --test session_driver_conformance -- --ignored + run: cargo test --locked --manifest-path integration-proofs/run-ledger-mongodb/Cargo.toml --features mongodb-storage-tests --test session_driver_conformance -- --ignored diff --git a/crates/tinyagents-registry/src/pricing/README.md b/crates/tinyagents-registry/src/pricing/README.md index 5c90d8f22..7cebd737e 100644 --- a/crates/tinyagents-registry/src/pricing/README.md +++ b/crates/tinyagents-registry/src/pricing/README.md @@ -15,9 +15,14 @@ This module performs no I/O and does not refresh prices. The seed catalog and its validation remain independent; this additive API preserves the migrated consumer snapshot without silently changing existing seed lookups. -Inherited compatibility limitations remain: unlisted hyphenated variants can -match a base id; some cached rates are historical assumptions; long-context -pricing other than the MiniMax M3 estimate branch is unmodeled. The flat -metadata projection deliberately preserves row rates with no tiers, so it is -not equivalent to the tier-aware M3 call estimator. These need separate -behavioral corrections rather than silent changes in this migration. +Lookup first matches an exact canonical id, then an id with a leading vendor +segment removed. Its fallback accepts only dated or preview snapshot decorations +of a listed id, such as `gpt-5.4-mini-2026-05-01` or +`gemini-2.5-pro-preview-06-05`. Unlisted variants such as `gpt-4.1-nano` and +`o3-pro` return `None`; they do not inherit the rates or context window of a +listed base model. + +Some cached rates remain historical assumptions, and long-context pricing other +than the MiniMax M3 estimate branch is unmodeled. The flat metadata projection +preserves row rates with no tiers, so it is not equivalent to the tier-aware M3 +call estimator. Those limitations need separate behavioral corrections. diff --git a/integration-proofs/run-ledger-mongodb/Cargo.lock b/integration-proofs/run-ledger-mongodb/Cargo.lock index 372db3dd9..175fe3a6c 100644 --- a/integration-proofs/run-ledger-mongodb/Cargo.lock +++ b/integration-proofs/run-ledger-mongodb/Cargo.lock @@ -2479,7 +2479,7 @@ dependencies = [ [[package]] name = "tinyagents-definition" -version = "2.2.0" +version = "2.10.0" dependencies = [ "anyhow", "async-trait", @@ -2492,7 +2492,7 @@ dependencies = [ [[package]] name = "tinyagents-harness" -version = "2.2.0" +version = "2.10.0" dependencies = [ "anyhow", "async-trait", @@ -2539,7 +2539,7 @@ dependencies = [ [[package]] name = "tinyagents-session" -version = "2.2.0" +version = "2.10.0" dependencies = [ "anyhow", "async-trait", @@ -2563,7 +2563,7 @@ dependencies = [ [[package]] name = "tinyagents-tasks" -version = "2.2.0" +version = "2.10.0" dependencies = [ "anyhow", "async-trait", @@ -2719,7 +2719,7 @@ dependencies = [ [[package]] name = "tinytools" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "async-trait", @@ -2729,7 +2729,7 @@ dependencies = [ [[package]] name = "tinytools-agent" -version = "0.5.0" +version = "0.6.0" dependencies = [ "regex", "serde", From 18f414e48c32ed41f807bd7d015041018c1bf527 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 19:18:32 +0000 Subject: [PATCH 13/14] docs(pricing): clarify fallback id matching rules The README now describes the fallback as choosing the longest listed id extended only by date/version fragments or release-channel words such as latest, preview, and exp, replacing the earlier wording about dated or preview snapshot decorations. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinyagents-registry/src/pricing/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-registry/src/pricing/README.md b/crates/tinyagents-registry/src/pricing/README.md index 7cebd737e..2b7c0f46b 100644 --- a/crates/tinyagents-registry/src/pricing/README.md +++ b/crates/tinyagents-registry/src/pricing/README.md @@ -16,8 +16,9 @@ its validation remain independent; this additive API preserves the migrated consumer snapshot without silently changing existing seed lookups. Lookup first matches an exact canonical id, then an id with a leading vendor -segment removed. Its fallback accepts only dated or preview snapshot decorations -of a listed id, such as `gpt-5.4-mini-2026-05-01` or +segment removed. Its fallback chooses the longest listed id extended only by +date/version fragments or release-channel words (`latest`, `preview`, `exp`), +such as `gpt-5.4-mini-2026-05-01` or `gemini-2.5-pro-preview-06-05`. Unlisted variants such as `gpt-4.1-nano` and `o3-pro` return `None`; they do not inherit the rates or context window of a listed base model. From ed2880bbc5bc4bed33123e9180f792485c260973 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Fri, 23 Oct 2026 11:51:53 +0530 Subject: [PATCH 14/14] Apply #531 (state_block hide_goal) on OpenHuman's tinyagents pin Exactly tinyhumansai/tinyagents#531 (merged as d829f923b) on e19311758, the tinyagents commit OpenHuman main pins (an epic branch commit that does not carry #531 yet): `StateBlockMiddleware::hide_goal` leaves the thread goal out of the run-state block. Re-pin to a tinyagents commit that carries both once the epic reaches tinyagents main. Co-Authored-By: Claude Opus 5.5 --- .../src/state_block/README.md | 7 ++++ .../src/state_block/middleware.rs | 13 +++++- .../src/state_block/middleware_tests.rs | 42 +++++++++++++++++++ .../src/state_block/types.rs | 3 ++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-harness-tools/src/state_block/README.md b/crates/tinyagents-harness-tools/src/state_block/README.md index 0e8266ea7..f94630c8f 100644 --- a/crates/tinyagents-harness-tools/src/state_block/README.md +++ b/crates/tinyagents-harness-tools/src/state_block/README.md @@ -67,3 +67,10 @@ session key-value store). Install it on the turns that have the `todo` tool; it is an observer, so another middleware's control outcome never hides a call from its ledger. Per-run state is keyed by the run instance and dropped in `after_agent` / `on_error`. + +`hide_goal(true)` leaves the thread goal out of the block for the turns the +middleware is installed on; the plan, the model's notes and the run's facts +still show, and the goal itself is not touched. A host that installs the block +per turn sets it for a turn that must not be steered by the thread's goal (in +OpenHuman, a turn with `TurnOverrides::suppress_active_goal`, such as a +conversational reply on a thread whose goal belongs to an earlier task). diff --git a/crates/tinyagents-harness-tools/src/state_block/middleware.rs b/crates/tinyagents-harness-tools/src/state_block/middleware.rs index 65853820c..48b4e7134 100644 --- a/crates/tinyagents-harness-tools/src/state_block/middleware.rs +++ b/crates/tinyagents-harness-tools/src/state_block/middleware.rs @@ -33,6 +33,7 @@ impl StateBlockMiddleware { Self { store, fallback_thread: None, + hide_goal: false, runs: Mutex::default(), } } @@ -45,6 +46,16 @@ impl StateBlockMiddleware { self } + /// Leaves the thread goal out of the block when `hide` is true; the plan, + /// the model's notes and the run's own facts still show. The goal itself + /// is not touched. A host that installs the block for each turn sets this + /// for a turn that must not be steered by the thread's goal, such as a + /// conversational reply on a thread whose goal belongs to an earlier task. + pub fn hide_goal(mut self, hide: bool) -> Self { + self.hide_goal = hide; + self + } + fn with_run(&self, instance: u64, f: impl FnOnce(&mut RunLedger) -> T) -> T { let mut runs = self .runs @@ -138,7 +149,7 @@ impl Middleware for StateBlockMiddleware { saved.notes.as_ref().and_then(|pad| pad.blockers.last()), ); ledger.staged = render( - saved.goal.as_ref(), + saved.goal.as_ref().filter(|_| !self.hide_goal), saved.notes.as_ref(), &saved.todos, ledger, diff --git a/crates/tinyagents-harness-tools/src/state_block/middleware_tests.rs b/crates/tinyagents-harness-tools/src/state_block/middleware_tests.rs index 0a9e19177..46e13098e 100644 --- a/crates/tinyagents-harness-tools/src/state_block/middleware_tests.rs +++ b/crates/tinyagents-harness-tools/src/state_block/middleware_tests.rs @@ -370,6 +370,48 @@ async fn a_run_without_a_thread_reads_the_fallback_key() { assert!(shown.contains("[>] 1. Configure postfix"), "{shown}"); } +#[tokio::test] +async fn a_hidden_goal_leaves_the_plan_in_the_block() { + let store: Arc = Arc::new(InMemoryStore::default()); + goal_store::set(&store, THREAD, "Migrate the billing tables", None) + .await + .unwrap(); + + // Only a goal: hidden, nothing is left to show. + let hidden = StateBlockMiddleware::new(store.clone()).hide_goal(true); + assert_eq!( + block(&hidden, &mut ctx()).await, + None, + "nothing but the goal" + ); + + todo_store::replace(&store, THREAD, vec![TodoItem::new("Dump the schema")]) + .await + .unwrap(); + let shown = block(&StateBlockMiddleware::new(store.clone()), &mut ctx()) + .await + .expect("goal and plan"); + assert!(shown.contains("Migrate the billing tables"), "{shown}"); + assert!(shown.contains("[>] 1. Dump the schema"), "{shown}"); + + let without_goal = block(&hidden, &mut ctx()) + .await + .expect("the plan still shows"); + assert!( + !without_goal.contains("Migrate the billing tables"), + "{without_goal}" + ); + assert!(!without_goal.contains("Goal ("), "{without_goal}"); + assert!( + without_goal.contains("[>] 1. Dump the schema"), + "{without_goal}" + ); + assert!( + goal_store::get(&store, THREAD).await.unwrap().is_some(), + "the goal itself is untouched" + ); +} + /// A note another middleware pushes in `before_model`, after the block went in. fn late_note(request: &mut ModelRequest) { request.messages.push(Message::user("web budget exhausted")); diff --git a/crates/tinyagents-harness-tools/src/state_block/types.rs b/crates/tinyagents-harness-tools/src/state_block/types.rs index 92133821d..3deb3dee6 100644 --- a/crates/tinyagents-harness-tools/src/state_block/types.rs +++ b/crates/tinyagents-harness-tools/src/state_block/types.rs @@ -36,6 +36,9 @@ pub struct StateBlockMiddleware { /// The plan and goal key for a run with no thread id: the scope the host's /// `todo` tool falls back to (for example a session id). pub(super) fallback_thread: Option, + /// Leave the thread goal out of the block (see + /// [`StateBlockMiddleware::hide_goal`]). + pub(super) hide_goal: bool, /// Per-run ledgers, keyed by [`RunContext::instance_id`](tinyagents_harness::context::RunContext::instance_id). pub(super) runs: Mutex>, }