-
Notifications
You must be signed in to change notification settings - Fork 4k
fix(web-chat): let an operator choose the chat agent, and stop a raw web_fetch buying an LLM summary of markup #6586
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2f13f00
8184729
5aa40ec
b351fb3
a12462f
be7987c
6940dfe
58cd38d
6d8b6cd
da9c1e3
da27461
84b3d93
e12db45
05a2fb9
7830814
f619ad2
e02e9f7
3cebf56
16daec2
40716fa
3c0b46d
5100716
25e2f42
8dfb666
63d76d1
bcf271b
67b07ad
69ed370
45ba097
9296c49
85c0f2c
762ce76
2a71309
7cea769
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -129,6 +129,37 @@ pub(crate) fn is_truncation_exempt(name: &str) -> bool { | |
| COMPACTION_EXEMPT_TOOLS.contains(&name) || DISCOVERY_TOOLS.contains(&name) | ||
| } | ||
|
|
||
| /// Whether this call is a `web_fetch` that asked for the body **as sent** | ||
| /// (`raw: true`), following `use_skill` into the tool it wraps exactly as | ||
| /// [`artifact_read_target`] does. | ||
| /// | ||
| /// Such a result is exempt from the payload summarizer (step 2). `web_fetch` | ||
| /// normally returns HTML as Markdown — `tinyjuice::compressors::html:: | ||
| /// html_to_markdown`, which drops scripts and styling — and `raw: true` turns | ||
| /// that off, so the payload is unconverted markup. Paying a full-price, | ||
| /// *uncached* model call to have an LLM paraphrase minified JS and CSS is the | ||
| /// worst trade in the ladder: one observed `raw: true` fetch of a 183 KB page | ||
| /// cost 44,561 prompt tokens, over half that turn's entire summarizer budget, | ||
| /// to re-describe a page the same turn had already read as clean Markdown. | ||
| /// | ||
| /// It is also the wrong answer to the question asked. A caller who wants the | ||
| /// body as sent wants the bytes, not a summary of them; steps 3–4 still bound | ||
| /// the result and spill the remainder to an artifact the model pages with | ||
| /// `file_read`, which returns the real markup, losslessly and without a model | ||
| /// call. | ||
| fn is_raw_fetch(tool_name: &str, args: &serde_json::Value) -> bool { | ||
|
senamakel marked this conversation as resolved.
|
||
| const FETCH_TOOL: &str = "web_fetch"; | ||
| let (name, args) = if tool_name == "use_skill" { | ||
| match (args.get("tool").and_then(|t| t.as_str()), args.get("args")) { | ||
| (Some(inner), Some(inner_args)) => (inner, inner_args), | ||
| _ => return false, | ||
| } | ||
| } else { | ||
| (tool_name, args) | ||
| }; | ||
| name == FETCH_TOOL && args.get("raw").and_then(|r| r.as_bool()).unwrap_or(false) | ||
| } | ||
|
|
||
| /// `after_tool`: apply the semantic payload summarizer (when configured) and | ||
| /// then the hard per-tool-result byte cap to each tool result's model-facing | ||
| /// content, before it enters the transcript. The graph analogue of the byte cap | ||
|
|
@@ -160,6 +191,11 @@ pub(crate) struct ToolOutputMiddleware { | |
| /// their calls lose the argument; any other tool with a parameter of the | ||
| /// same name (an MCP server's, say) keeps it. | ||
| pub(crate) summary_focus_tools: HashSet<String>, | ||
| /// Calls that asked `web_fetch` for the raw body, keyed by call id. Filled | ||
| /// in `before_tool`, where the arguments are visible, and consumed in | ||
| /// `after_tool`, where they are not — the same seam `artifact_reads` uses, | ||
| /// and for the same reason. See [`is_raw_fetch`]. | ||
| pub(crate) raw_fetches: Mutex<std::collections::HashSet<String>>, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Initialize raw_fetches at every construction site This adds a required field to Additional
|
||
| } | ||
|
|
||
| impl ToolOutputMiddleware { | ||
|
|
@@ -251,6 +287,16 @@ impl Middleware<(), crate::agent::tinyagents::host::OpenHumanRunContext> for Too | |
| reads.insert(call.id.clone(), read); | ||
| } | ||
| } | ||
| if is_raw_fetch(&call.name, &call.arguments) { | ||
| tracing::debug!( | ||
| tool = %call.name, | ||
| call_id = %call.id, | ||
| "[tinyagents::mw] raw fetch: exempting the result from the payload summarizer" | ||
| ); | ||
| if let Ok(mut raw) = self.raw_fetches.lock() { | ||
| raw.insert(call.id.clone()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clean up raw-fetch entries when after_tool is skipped
[RULE] unbounded-state · |
||
| } | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
|
|
@@ -270,6 +316,13 @@ impl Middleware<(), crate::agent::tinyagents::host::OpenHumanRunContext> for Too | |
| // compacts it, and the byte budget persists it as a *new* artifact with | ||
| // the same bounded preview — a loop that never reaches the data (#6284). | ||
| // Serve it verbatim, one bounded page at a time. | ||
| // Consumed unconditionally so the entry cannot outlive its call, even on | ||
| // the artifact-read early return below. | ||
| let raw_fetch = self | ||
|
senamakel marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test the after_tool raw-fetch skip branch The current test coverage verifies only the classifier, not the stateful [RULE] missing-behavior-test · |
||
| .raw_fetches | ||
| .lock() | ||
| .ok() | ||
| .is_some_and(|mut raw| raw.remove(&call_id)); | ||
| let artifact_read = self | ||
| .artifact_reads | ||
| .lock() | ||
|
|
@@ -311,7 +364,7 @@ impl Middleware<(), crate::agent::tinyagents::host::OpenHumanRunContext> for Too | |
| tracing::debug!( | ||
| tool = tool_name, | ||
| bytes = content.len(), | ||
| "[tinyagents::mw] compaction-exempt: skipping payload summarizer + tokenjuice" | ||
| "[tinyagents::mw] compaction-exempt: skipping tokenjuice + payload summarizer" | ||
| ); | ||
| } | ||
| if truncation_exempt { | ||
|
|
@@ -386,7 +439,29 @@ impl Middleware<(), crate::agent::tinyagents::host::OpenHumanRunContext> for Too | |
| .and_then(|mut focus| focus.remove(&call_id)); | ||
| let wants_tinyjuice = | ||
| self.tokenjuice_compaction_enabled || self.payload_summarizer.is_some(); | ||
| if !compaction_exempt && wants_tinyjuice && (tool_cap.is_none() || focus.is_some()) { | ||
| // A `raw: true` `web_fetch` is excluded outright, `summary_focus` | ||
| // or not: it asked for the body *as sent*, which switches off the | ||
| // HTML→Markdown conversion, so the payload is unconverted markup | ||
| // and a summary of it is an uncached model call spent paraphrasing | ||
| // minified JS. One observed such fetch cost 44,561 prompt tokens — | ||
| // over half that turn's summarizer budget — to re-describe a page | ||
| // the same turn had already read as clean Markdown. Step 3 still | ||
| // bounds it and spills the rest to an artifact, which hands back | ||
| // the real markup losslessly and for no model call. See | ||
| // [`is_raw_fetch`]. | ||
| if raw_fetch { | ||
|
senamakel marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add an end-to-end test for raw-fetch summarizer exemption The new behavior is exercised only through Additional
|
||
| tracing::info!( | ||
| tool = tool_name, | ||
| bytes = content.len(), | ||
| "[tinyagents::mw] raw fetch: skipping the tinyjuice summary, \ | ||
| capping and spilling to an artifact instead" | ||
| ); | ||
| } | ||
| if !raw_fetch | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drive raw-fetch summarizer exemption with an end-to-end test The exemption changes the model-facing behavior of a real [RULE] missing-end-to-end-test · |
||
| && !compaction_exempt | ||
| && wants_tinyjuice | ||
| && (tool_cap.is_none() || focus.is_some()) | ||
| { | ||
| // Bind a summary call to this turn only when the result is big | ||
| // enough for TinyJuice to want one; building the child context for | ||
| // every small result would be waste. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ async fn same_tool_calls_persist_artifacts_under_distinct_call_ids() { | |
| artifact_reads: Default::default(), | ||
| focus_by_call: Default::default(), | ||
| summary_focus_tools: Default::default(), | ||
| raw_fetches: Default::default(), | ||
| }; | ||
| let mut ctx = context(); | ||
|
|
||
|
|
@@ -54,3 +55,66 @@ async fn same_tool_calls_persist_artifacts_under_distinct_call_ids() { | |
| "second result is deliberately oversized" | ||
| ); | ||
| } | ||
|
|
||
| /// `raw: true` asks `web_fetch` for the body as sent, which switches off the | ||
| /// HTML→Markdown conversion — so the payload is unconverted markup, and handing | ||
| /// it to the summarizer buys an uncached model call to paraphrase minified JS. | ||
| /// One observed fetch cost 44,561 prompt tokens that way. These pin which calls | ||
| /// earn the exemption, not what the ladder then does with them. | ||
| #[test] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test the after_tool raw-fetch skip branch These tests only call [RULE] missing-regression-test · |
||
| fn only_a_raw_web_fetch_is_exempt_from_the_payload_summarizer() { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test the after_tool raw-fetch skip branch This only tests the pure [RULE] missing-regression-test · |
||
| use serde_json::json; | ||
|
|
||
| assert!(is_raw_fetch( | ||
| "web_fetch", | ||
| &json!({"url": "https://x", "raw": true}) | ||
| )); | ||
|
|
||
| // A converted fetch is the normal path and stays summarizer-eligible: its | ||
| // Markdown is prose the summarizer compresses well. | ||
| for args in [ | ||
| json!({"url": "https://x"}), | ||
| json!({"url": "https://x", "raw": false}), | ||
| json!({"url": "https://x", "raw": null}), | ||
| // `raw` is a bool on the wire; a string is not a request for raw bytes. | ||
| json!({"url": "https://x", "raw": "true"}), | ||
| ] { | ||
| assert!( | ||
| !is_raw_fetch("web_fetch", &args), | ||
| "{args} is a converted fetch" | ||
| ); | ||
| } | ||
|
|
||
| // The exemption is about `web_fetch`'s conversion, so a `raw` argument on | ||
| // any other tool means nothing here. | ||
| for tool in ["file_read", "shell", "http_request"] { | ||
| assert!( | ||
| !is_raw_fetch(tool, &json!({"raw": true})), | ||
| "{tool} has no HTML conversion to switch off" | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /// `use_skill` forwards the wrapped tool's result verbatim, so a raw fetch | ||
| /// reached through it is still a raw fetch — the same wrapper-following | ||
| /// `artifact_read_target` does. | ||
| #[test] | ||
| fn a_raw_fetch_wrapped_in_use_skill_is_still_a_raw_fetch() { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drive raw-fetch exemption through the agent harness The new behavior changes the model-facing result of a real [RULE] missing-integration-test · |
||
| use serde_json::json; | ||
|
|
||
| assert!(is_raw_fetch( | ||
| "use_skill", | ||
| &json!({"skill": "web", "tool": "web_fetch", "args": {"url": "https://x", "raw": true}}) | ||
| )); | ||
| assert!(!is_raw_fetch( | ||
| "use_skill", | ||
| &json!({"skill": "web", "tool": "web_fetch", "args": {"url": "https://x"}}) | ||
| )); | ||
| // A wrapper naming some other tool, and a malformed one, are not raw | ||
| // fetches — neither may silently inherit the exemption. | ||
| assert!(!is_raw_fetch( | ||
| "use_skill", | ||
| &json!({"skill": "files", "tool": "file_read", "args": {"raw": true}}) | ||
| )); | ||
| assert!(!is_raw_fetch("use_skill", &json!({"raw": true}))); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -468,6 +468,7 @@ async fn tool_output_truncates_over_the_flat_budget() { | |
| artifact_reads: Default::default(), | ||
| focus_by_call: Default::default(), | ||
| summary_focus_tools: Default::default(), | ||
| raw_fetches: Default::default(), | ||
| }; | ||
| let mut result = tool_result("echo", &"x".repeat(5_000)); | ||
| mw.after_tool( | ||
|
|
@@ -502,6 +503,7 @@ async fn tool_output_leaves_small_results_untouched() { | |
| artifact_reads: Default::default(), | ||
| focus_by_call: Default::default(), | ||
| summary_focus_tools: Default::default(), | ||
| raw_fetches: Default::default(), | ||
| }; | ||
| let mut result = tool_result("echo", "tiny"); | ||
| mw.after_tool( | ||
|
|
@@ -543,6 +545,7 @@ fn tool_char_cap_reads_the_tools_own_declared_cap() { | |
| artifact_reads: Default::default(), | ||
| focus_by_call: Default::default(), | ||
| summary_focus_tools: Default::default(), | ||
| raw_fetches: Default::default(), | ||
| }; | ||
| // Tool declares its own char cap → surfaced for the per-tool truncation. | ||
| assert_eq!(mw.tool_char_cap("big"), Some(10)); | ||
|
|
@@ -647,6 +650,36 @@ async fn a_tool_that_caps_itself_is_summarized_when_the_caller_gives_a_focus() { | |
| assert!(result_text(&result).contains("focused")); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
|
senamakel marked this conversation as resolved.
|
||
| async fn a_raw_web_fetch_never_prepares_a_payload_summary() { | ||
| let stub = StubSummarizer::replying(Ok("must remain unused".into())); | ||
| let mw = summarizer_mw(stub.clone()); | ||
| let mut call = TaToolCall::new( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Exercise the self-capped tool spill path end to end The new scenario covers only [RULE] insufficient-test-coverage · |
||
| "raw-fetch", | ||
| "web_fetch", | ||
| json!({"url": "https://example.test", "raw": true}), | ||
| ); | ||
| let mut ctx = ctx(); | ||
| mw.before_tool(&mut ctx, &(), &mut call) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drive raw-fetch routing through the production tool path This test invokes Additional
|
||
| .await | ||
| .expect("raw fetch is recorded before execution"); | ||
|
|
||
| let mut result = tool_result("web_fetch", &"<html>markup</html>".repeat(300)); | ||
| let (outcome, requests) = with_module(mw.after_tool( | ||
| &mut ctx, | ||
| &(), | ||
| &invocation("raw-fetch", "web_fetch"), | ||
| &mut result, | ||
| )) | ||
| .await; | ||
|
|
||
| outcome.expect("raw fetch result is processed"); | ||
| assert!( | ||
| !stub.was_prepared() && requests.is_empty(), | ||
| "raw fetches must bypass the payload summarizer and TinyJuice" | ||
| ); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn tool_output_honors_a_tools_own_cap() { | ||
| let mut tool_policies = HashMap::new(); | ||
|
|
@@ -675,6 +708,7 @@ async fn tool_output_honors_a_tools_own_cap() { | |
| artifact_reads: Default::default(), | ||
| focus_by_call: Default::default(), | ||
| summary_focus_tools: Default::default(), | ||
| raw_fetches: Default::default(), | ||
| }; | ||
| let mut result = tool_result("capped", &"y".repeat(500)); | ||
| mw.after_tool( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,6 +46,16 @@ pub struct AgentSettingsPatch { | |
| /// Tool/action wall-clock timeout in seconds. Validated to | ||
| /// `tool_timeout::MIN_TIMEOUT_SECS..=tool_timeout::MAX_TIMEOUT_SECS`. | ||
| pub agent_timeout_secs: Option<u64>, | ||
| /// Agent the web-chat path routes a turn to (`[agent] chat_agent_id`). | ||
| /// `Some("")`/whitespace clears the override and reverts to the | ||
| /// orchestrator; `Some(id)` sets it; `None` leaves it unchanged. | ||
| /// | ||
| /// Settable over RPC and not only in the TOML because the file on disk is | ||
| /// not reliably the file the core reads: once a user dir is active its | ||
| /// per-user `config.toml` takes precedence, so a value pre-written to the | ||
| /// root (or to a guessed user dir) is silently ignored. Going through the | ||
| /// running core writes wherever `Config::save` actually points. | ||
| pub chat_agent_id: Option<String>, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drive chat-agent routing through a real web-chat turn The new setting is covered only by configuration-level tests. None of the changed tests drives a web-chat turn through the actual session checkout/routing path with a valid agent ID, then verifies that the selected definition is used. A regression in the consumer, persistence reload, or route resolution would leave these tests green while the setting appears to save successfully. [RULE] missing-integration-test · |
||
| } | ||
|
|
||
| /// Partial update for the agent's editable filesystem roots. | ||
|
|
@@ -217,9 +227,32 @@ pub async fn apply_agent_settings( | |
| "agent_timeout_secs must be between {MIN_TIMEOUT_SECS} and {MAX_TIMEOUT_SECS} seconds (got {timeout_secs})" | ||
| )); | ||
| } | ||
| } | ||
|
|
||
| if let Some(chat_agent_id) = update.chat_agent_id.as_deref() { | ||
| let trimmed = chat_agent_id.trim(); | ||
| if !trimmed.is_empty() | ||
|
senamakel marked this conversation as resolved.
senamakel marked this conversation as resolved.
|
||
| && !crate::agent::OpenHumanSessionHost::is_runnable_agent_id(config, trimmed) | ||
| { | ||
| return Err(format!( | ||
| "chat_agent_id '{trimmed}' is not a runnable agent definition" | ||
| )); | ||
| } | ||
|
senamakel marked this conversation as resolved.
|
||
| } | ||
|
|
||
| if let Some(timeout_secs) = update.agent_timeout_secs { | ||
| config.agent.agent_timeout_secs = timeout_secs; | ||
| } | ||
|
|
||
| if let Some(chat_agent_id) = update.chat_agent_id { | ||
| let trimmed = chat_agent_id.trim(); | ||
| config.agent.chat_agent_id = (!trimmed.is_empty()).then(|| trimmed.to_string()); | ||
| log::debug!( | ||
| "[config][agent] chat_agent_id -> {:?}", | ||
| config.agent.chat_agent_id | ||
| ); | ||
| } | ||
|
|
||
| config.save().await.map_err(|e| e.to_string())?; | ||
|
|
||
| let effective = crate::tools::timeout::set_tool_timeout_secs(config.agent.agent_timeout_secs); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.