diff --git a/Cargo.lock b/Cargo.lock index 8f8342c0fd4..8b22aa2e005 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4173,6 +4173,7 @@ name = "openhuman-embed" version = "0.64.10" dependencies = [ "anyhow", + "async-trait", "dirs 6.0.0", "env_logger", "log", diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/research_budget.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/research_budget.rs index ee18744127e..2f4c3728e98 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/research_budget.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/research_budget.rs @@ -18,7 +18,15 @@ use crate::agent::tinyagents::host::OpenHumanRunContext; /// a batched `web_contents_tool` read, each of which counts once. pub(super) const DIRECT_WEB_READ_LIMIT: usize = 8; -const RESEARCH_CLOSE_INSTRUCTION: &str = "The direct web research budget for this turn is exhausted. Answer the user's latest request now using the results already available. State any remaining uncertainty. Do not search again, repeat a page fetch, or merely describe what you plan to read."; +/// The instruction appended when the budget is spent and the tools are +/// withdrawn. It says outright that tools are gone and a call will not run: +/// told only to "answer", a native model with a transcript full of tool calls +/// and no tool channel writes its next call as plain-text markup (DeepSeek V4's +/// `<|DSML|invoke …>`), which then stood as the turn's answer. Replaying a +/// bench request that hit this budget, the old wording leaked a call 6 times in +/// 8 and this wording 0 times in 8. The harness also withholds and re-prompts +/// any call that still arrives (tinyagents `TextRecovery::withholding`). +pub(crate) const RESEARCH_CLOSE_INSTRUCTION: &str = "The direct web research budget for this turn is exhausted, and tools are no longer available for this reply: any tool call you write now will not run. Answer the user's latest request now in plain text, using only the results already available. State any remaining uncertainty. Do not search again, repeat a page fetch, or merely describe what you plan to read."; #[derive(Default)] pub(crate) struct ResearchBudgetMiddleware { diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware_research_budget_tests.rs b/crates/openhuman-core/src/agent/tinyagents/middleware_research_budget_tests.rs index 75fd328ebf5..9950407f7c1 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware_research_budget_tests.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware_research_budget_tests.rs @@ -50,6 +50,32 @@ async fn web_research_concludes_after_eight_reads() { assert!(request.messages.last().unwrap().text().contains("Answer")); } +/// With the tools withdrawn, the instruction must say that a tool call will +/// not run: without it DeepSeek V4 wrote its next call as plain-text markup +/// that stood as the answer. +#[tokio::test] +async fn the_concluding_instruction_says_tools_are_gone() { + let mw = ResearchBudgetMiddleware::new(); + let mut run = ctx(); + for i in 0..research_budget::DIRECT_WEB_READ_LIMIT { + mw.after_tool( + &mut run, + &(), + &invocation(format!("web-{i}"), "web_fetch"), + &mut tool_result("web_fetch", "page"), + ) + .await + .unwrap(); + } + let mut request = research_request(); + mw.before_model(&mut run, &(), &mut request).await.unwrap(); + let instruction = request.messages.last().unwrap().text(); + assert_eq!(instruction, research_budget::RESEARCH_CLOSE_INSTRUCTION); + assert!(instruction.contains("tools are no longer available")); + assert!(instruction.contains("will not run")); + assert!(instruction.contains("plain text")); +} + #[tokio::test] async fn unrelated_tools_do_not_spend_the_web_research_budget() { let mw = ResearchBudgetMiddleware::new(); diff --git a/vendor/tinyagents b/vendor/tinyagents index 3c411b4408d..53cc2aaf651 160000 --- a/vendor/tinyagents +++ b/vendor/tinyagents @@ -1 +1 @@ -Subproject commit 3c411b4408de2ceb5b5d3f6df93703ec3b61c1cb +Subproject commit 53cc2aaf651392d0dac35f48f8c29128026e66f1