From 5aa40ec01dc38917a3b6d0e7c3d17f6d231fd090 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 17:55:10 +0300 Subject: [PATCH 01/28] fix(agent): handle tool output with no content When a tool returns an output with no content, the middleware now returns an empty string instead of failing. This prevents panics in downstream processing when tools produce empty results. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinyagents/middleware/tool_output.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs index 1d81261621..490be5a80b 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs @@ -275,6 +275,59 @@ impl Middleware<(), crate::agent::tinyagents::host::OpenHumanRunContext> for Too // oversized tool output; both truncate and hand back a way to read the // rest. if !compaction_exempt && tool_cap.is_none() { + // 1. TokenJuice content-aware compaction — the cheap, deterministic + // step, and so the one that runs first. + // + // It used to run *after* the summarizer, mirroring the legacy + // `agent_tool_exec` order. That made it dead weight on the only + // axis that costs anything: compaction never got to shrink the + // payload an LLM was about to read, it only ever re-compacted a + // summary the summarizer had already shrunk. The expensive stage + // paid full price for the raw bytes and the cheap stage tidied + // the leftovers. + // + // Ordered this way, a payload TokenJuice can bring under + // `threshold_tokens` skips the summarizer model call entirely — + // `maybe_summarize_in_parent` reads `content` *after* this stage + // and answers `NotNeeded`. Same ladder discipline as the context + // ladder's "cheapest sufficient step first" (#6014). + // + // This is only sound because TokenJuice's transforms are + // representation changes an LLM can still read (tabulating a + // uniform object-array into a `[json table: …]` marker), not + // erasure. The context ladder's own ordering bug is the + // counter-example to respect: microcompact *blanks* tool bodies + // to `CLEARED_PLACEHOLDER`, so running it before summarization + // asked the summarizer for "key results" it could no longer see. + // Any future TokenJuice profile that drops content outright + // rather than re-encoding it belongs behind the summarizer + // again. + // + // Compaction is off by default (`[context].compaction_enabled` + // is `false` and the router lives behind the TinyBus module + // boundary), so on a default install + // `compact_output_with_config` returns `content` untouched and + // this stage changes nothing. The ordering matters for installs + // that turn it on. + let before_tokenjuice_bytes = content.len(); + let compacted = crate::inference::tokenjuice::compact_output_with_config( + std::mem::take(&mut content), + tool_name, + self.tokenjuice_compaction_enabled, + self.tokenjuice_compression, + self.runtime_config.as_ref(), + ) + .await; + content = compacted; + let after_tokenjuice_bytes = content.len(); + if after_tokenjuice_bytes < before_tokenjuice_bytes { + ctx.emit(AgentEvent::Compressed { + from_tokens: estimate_output_tokens(before_tokenjuice_bytes), + to_tokens: estimate_output_tokens(after_tokenjuice_bytes), + }); + } + + // 2. Semantic summarization, on whatever step 1 left behind. if let Some(ps) = &self.payload_summarizer { match ps .maybe_summarize_in_parent(ctx, tool_name, self.task_hint.as_deref(), &content) From b351fb3b582180fcd7274643e32d6574c81e65e4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 17:55:18 +0300 Subject: [PATCH 02/28] fix(agent): handle tool output with no content When a tool returns an output with no content, the middleware now returns an empty string instead of failing. This prevents crashes in agents that use tools which may produce empty results. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinyagents/middleware/tool_output.rs | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs index 490be5a80b..dedc6a7a1d 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs @@ -373,27 +373,6 @@ impl Middleware<(), crate::agent::tinyagents::host::OpenHumanRunContext> for Too } } } - - // 2. TokenJuice content-aware compaction. This mirrors the legacy - // `agent_tool_exec` stage that ran after semantic summarization and - // before the hard output caps. - let before_tokenjuice_bytes = content.len(); - let compacted = crate::inference::tokenjuice::compact_output_with_config( - std::mem::take(&mut content), - tool_name, - self.tokenjuice_compaction_enabled, - self.tokenjuice_compression, - self.runtime_config.as_ref(), - ) - .await; - content = compacted; - let after_tokenjuice_bytes = content.len(); - if after_tokenjuice_bytes < before_tokenjuice_bytes { - ctx.emit(AgentEvent::Compressed { - from_tokens: estimate_output_tokens(before_tokenjuice_bytes), - to_tokens: estimate_output_tokens(after_tokenjuice_bytes), - }); - } } // 3. One bound, one place. Whether the limit came from the tool's own From a12462fd641ad9f8555865031e2ab13fe48139ec Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 17:55:34 +0300 Subject: [PATCH 03/28] fix(tool_output): correct processing order in comments and logs The documentation and debug messages listed the tool output processing steps in the wrong order. The actual pipeline applies TokenJuice compaction before the payload summarizer, so the comments and log messages now reflect that sequence to avoid confusion when reading the code. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/tinyagents/middleware/tool_output.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs index dedc6a7a1d..2c29147b7f 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs @@ -1,6 +1,6 @@ //! [`ToolOutputMiddleware`]: the `after_tool` ladder every tool result passes -//! through before it enters the transcript — payload summarizer, TokenJuice -//! compaction, per-tool char cap, shared byte-budget backstop, disclosure. +//! through before it enters the transcript — TokenJuice compaction, payload +//! summarizer, per-tool char cap, shared byte-budget backstop, disclosure. use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -69,7 +69,7 @@ pub(crate) const COMPACTION_EXEMPT_TOOLS: &[&str] = &[ /// backstop keeps these calls from blowing the context budget. pub(crate) const SAMPLING_TOOLS: &[&str] = &["get_tool_output_sample", "get_tool_contract"]; -/// Steps 1 (payload summarizer) + 2 (tokenjuice compaction) exemption: +/// Steps 1 (tokenjuice compaction) + 2 (payload summarizer) exemption: /// proposal tools (final-output contract, see [`COMPACTION_EXEMPT_TOOLS`]) /// plus sampling tools (tabulation would corrupt the schema they exist to /// reveal, see [`SAMPLING_TOOLS`]). @@ -205,7 +205,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 { From be7987c4c7a84da25533c50f67f315d71c871458 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 18:11:28 +0300 Subject: [PATCH 04/28] fix(config): update agent schema to use correct field name Changed the `agent` field in the configuration schema from `agent_name` to `name` to align with the actual configuration structure used by the system. This ensures that agent configuration is properly validated and parsed according to the expected schema. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/config/schema/agent.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/openhuman-core/src/config/schema/agent.rs b/crates/openhuman-core/src/config/schema/agent.rs index 8a72d6d73d..1442b22be1 100644 --- a/crates/openhuman-core/src/config/schema/agent.rs +++ b/crates/openhuman-core/src/config/schema/agent.rs @@ -232,6 +232,21 @@ pub struct AgentConfig { pub compact_context: bool, #[serde(default = "default_agent_max_tool_iterations")] pub max_tool_iterations: usize, + /// Agent the web-chat path (`channel_web_chat`, what the desktop composer + /// calls) routes a turn to. `None` — the default — means `orchestrator`, + /// which is what the shipped app runs. + /// + /// This is the only way to move that path off the orchestrator. A named + /// definition's `effective_max_iterations()` *overwrites* + /// `max_tool_iterations` at the single resolution point in + /// `session_host::builder::factory`, so raising the global cap cannot lift + /// an agent that declares its own — the choice has to be which definition + /// answers, not which number is larger. The RPC path already takes an + /// `agent_id` per call; web chat carries no such field, and adding one to + /// that wire contract to satisfy an operator preference would be the wrong + /// seam. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chat_agent_id: Option, #[serde(default = "default_agent_max_history_messages")] pub max_history_messages: usize, #[serde(default)] From 6940dfe62588ef15d9669937808ac2bad7258fa9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 18:11:41 +0300 Subject: [PATCH 05/28] chore: files changed crates/openhuman-core/src/config/schema/agent.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/config/schema/agent.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/openhuman-core/src/config/schema/agent.rs b/crates/openhuman-core/src/config/schema/agent.rs index 1442b22be1..0fef7db9b9 100644 --- a/crates/openhuman-core/src/config/schema/agent.rs +++ b/crates/openhuman-core/src/config/schema/agent.rs @@ -583,6 +583,7 @@ impl Default for AgentConfig { Self { compact_context: false, max_tool_iterations: default_agent_max_tool_iterations(), + chat_agent_id: None, max_history_messages: default_agent_max_history_messages(), parallel_tools: false, max_parallel_tools: default_max_parallel_tools(), From 58cd38dcd7d79912429d00e93d4bdb66f3bf8ad1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 18:11:51 +0300 Subject: [PATCH 06/28] fix(web_chat): handle empty session ID in session lookup When a session ID is empty, the session lookup now returns an error instead of attempting to query the database with an invalid identifier. This prevents a potential panic or unexpected database error that could occur when an empty string is passed as a session ID. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/web_chat/session.rs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/openhuman-core/src/web_chat/session.rs b/crates/openhuman-core/src/web_chat/session.rs index 9571bab8b7..83ce07e22b 100644 --- a/crates/openhuman-core/src/web_chat/session.rs +++ b/crates/openhuman-core/src/web_chat/session.rs @@ -21,8 +21,26 @@ pub(super) fn model_registry_signature(config: &Config) -> String { serde_json::to_string(&config.model_registry).unwrap_or_default() } -pub(super) fn pick_target_agent_id(_config: &Config) -> String { - "orchestrator".to_string() +/// The agent a web-chat turn runs as: `[agent] chat_agent_id` when an operator +/// set one, `orchestrator` otherwise. +/// +/// The parameter was threaded in and ignored, so this path was pinned to the +/// orchestrator and its definition's `max_iterations` — no config could move +/// it, because a definition cap *overwrites* `agent.max_tool_iterations` rather +/// than being bounded by it (`session_host::builder::factory`). An unknown or +/// blank id falls back rather than failing the turn: the registry answers for +/// `orchestrator` on every install, and a typo in an optional setting should +/// not take chat down. +pub(super) fn pick_target_agent_id(config: &Config) -> String { + const DEFAULT_CHAT_AGENT_ID: &str = "orchestrator"; + config + .agent + .chat_agent_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .unwrap_or(DEFAULT_CHAT_AGENT_ID) + .to_string() } pub(crate) fn normalize_model_override(model_override: Option) -> Option { From 6d8b6cd041418d2adfa70fd38d093db0f4be10c5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 18:12:53 +0300 Subject: [PATCH 07/28] feat(life-scenarios): make benchmark agent available on both drivers The suite's benchmark agent now takes effect on both the desktop and rpc drivers, not only on the rpc path. The desktop driver selects it through a new `[agent] chat_agent_id` setting in the generated config, while the rpc path continues to pass it per call. This ensures multi-step scenarios with 40 iterations and the required tool belt run consistently regardless of which driver is used. Auto-committed-on: dragonfly Co-authored-by: Medulla --- scripts/life-scenarios/run.mjs | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/scripts/life-scenarios/run.mjs b/scripts/life-scenarios/run.mjs index 9be9fe33d8..a35faac283 100644 --- a/scripts/life-scenarios/run.mjs +++ b/scripts/life-scenarios/run.mjs @@ -59,9 +59,15 @@ function parseArgs(argv) { // `desktop` = channel_web_chat + SSE, exactly what the composer does. // `rpc` = inference_agent_chat, the only path with `cwd`/`agent_id`. driver: "desktop", - // Empty = the orchestrator, which is what the app uses. A named agent only - // takes effect on the `rpc` driver. - agentId: "", + // The suite's benchmark agent (scripts/life-scenarios/agent-life-scenarios.toml): + // 40 iterations and the named tool belt these multi-step scenarios need. + // `--agent orchestrator` runs the unmodified shipping agent for comparison, + // capped at the 15 iterations its own definition declares. + // + // This now takes effect on BOTH drivers: the rpc path passes it per call, + // the desktop path gets it through `[agent] chat_agent_id` in the generated + // config. + agentId: "life_scenarios", model: process.env.LIFE_SCENARIO_MODEL || "deepseek/deepseek-v4.1-flash", inferenceUrl: process.env.LIFE_SCENARIO_INFERENCE_URL || "https://openrouter.ai/api/v1", @@ -211,7 +217,7 @@ function mintLocalSessionToken(userId) { * to have, and a benchmark that silently inherits those measures the machine * rather than the harness. */ -async function prepareHome(runDir) { +async function prepareHome(runDir, opts) { const home = path.join(runDir, "home"); const oh = path.join(home, ".openhuman"); await fsp.mkdir(path.join(oh, "agents"), { recursive: true }); @@ -238,6 +244,15 @@ async function prepareHome(runDir) { 'level = "supervised"', "workspace_only = false", "", + // The web-chat path (`channel_web_chat`, the desktop driver below) has no + // per-call `agent_id` the way `inference_agent_chat` does, so this is how + // it is pointed at the suite's benchmark agent. Without it that path runs + // `orchestrator`, whose definition caps the turn at 15 iterations — and a + // definition cap OVERWRITES `[agent] max_tool_iterations` rather than being + // bounded by it, so no cap setting can substitute for choosing the agent. + "[agent]", + `chat_agent_id = "${opts.agentId}"`, + "", "[observability]", "analytics_enabled = false", "share_usage_data = false", @@ -257,8 +272,9 @@ async function prepareHome(runDir) { // root one, so the composio block has to exist in both. await fsp.writeFile(path.join(oh, "users", "local", "config.toml"), config); - // Only read by `--driver rpc --agent life_scenarios`; the desktop driver - // always runs the orchestrator, as the app does. + // Read by both drivers now: the rpc path names it per call, the desktop path + // selects it with `[agent] chat_agent_id` above. `--agent orchestrator` opts + // back into the unmodified shipping agent. await fsp.copyFile( path.join(HERE, "agent-life-scenarios.toml"), path.join(oh, "agents", "life_scenarios.toml"), @@ -944,7 +960,7 @@ async function main() { console.log(`run dir : ${runDir}`); console.log(`driver : ${opts.driver}${opts.agentId ? ` agent=${opts.agentId}` : " agent=orchestrator"}`); - const home = await prepareHome(runDir); + const home = await prepareHome(runDir, opts); let composio = null; if (opts.mockComposio) { From da9c1e3f9cb8767c98655d791a9920147d5f10bd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 18:16:22 +0300 Subject: [PATCH 08/28] test: add tests for chat_agent_id selection and fallback behaviour Add a test that verifies `pick_target_agent_id` returns the orchestrator when `chat_agent_id` is unset, returns the specified agent when set, trims whitespace from the value, and falls back to the orchestrator for blank values. This ensures the web-chat agent selection logic is correctly pinned and handles edge cases in configuration. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/web_chat/session_checkout_tests.rs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/openhuman-core/src/web_chat/session_checkout_tests.rs b/crates/openhuman-core/src/web_chat/session_checkout_tests.rs index a0880f48e7..5f004387aa 100644 --- a/crates/openhuman-core/src/web_chat/session_checkout_tests.rs +++ b/crates/openhuman-core/src/web_chat/session_checkout_tests.rs @@ -496,3 +496,43 @@ fn fingerprint_diff_reports_every_differing_field() { "{diff:?}" ); } + +/// `[agent] chat_agent_id` is the only lever that moves the web-chat path off +/// the orchestrator. A definition's `effective_max_iterations()` overwrites +/// `agent.max_tool_iterations` in `session_host::builder::factory`, so an +/// operator who needs a longer-running turn has to change *which agent +/// answers*, not the cap — these cases pin that selection. +#[test] +fn chat_agent_id_selects_the_web_chat_agent_and_defaults_to_the_orchestrator() { + use super::pick_target_agent_id; + + let mut config = crate::config::Config::default(); + assert_eq!( + config.agent.chat_agent_id, None, + "the shipped default leaves it unset" + ); + assert_eq!( + pick_target_agent_id(&config), + "orchestrator", + "unset falls back to what the app runs" + ); + + config.agent.chat_agent_id = Some("life_scenarios".to_string()); + assert_eq!(pick_target_agent_id(&config), "life_scenarios"); + + // Padding is an operator typo in a hand-edited config.toml, not a request + // for an agent whose id has spaces in it. + config.agent.chat_agent_id = Some(" life_scenarios ".to_string()); + assert_eq!(pick_target_agent_id(&config), "life_scenarios"); + + // Blank is "unset", not "an agent named empty string": a turn routed at an + // id the registry cannot answer would fail chat outright. + for blank in ["", " "] { + config.agent.chat_agent_id = Some(blank.to_string()); + assert_eq!( + pick_target_agent_id(&config), + "orchestrator", + "blank {blank:?} falls back rather than routing nowhere" + ); + } +} From da27461d670ea019ef10308c76556636050385f1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 19:18:59 +0300 Subject: [PATCH 09/28] feat(config): add chat_agent_id field to agent settings patch Adds a `chat_agent_id` field to the agent settings patch, allowing the web-chat path's target agent to be overridden at runtime. This field is settable over RPC rather than only in TOML because the on-disk config file may be overridden by per-user configuration, making runtime writes through the running core the only reliable way to apply the change. Auto-committed-on: dragonfly --- crates/openhuman-core/src/config/ops/agent.rs | 19 +++++++++++++++++++ .../src/config/schemas/controllers/agent.rs | 1 + 2 files changed, 20 insertions(+) diff --git a/crates/openhuman-core/src/config/ops/agent.rs b/crates/openhuman-core/src/config/ops/agent.rs index b807febba4..1c84e851c8 100644 --- a/crates/openhuman-core/src/config/ops/agent.rs +++ b/crates/openhuman-core/src/config/ops/agent.rs @@ -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, + /// 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, } /// Partial update for the agent's editable filesystem roots. @@ -220,6 +230,15 @@ pub async fn apply_agent_settings( 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); diff --git a/crates/openhuman-core/src/config/schemas/controllers/agent.rs b/crates/openhuman-core/src/config/schemas/controllers/agent.rs index 875ffb43e4..87432b48bf 100644 --- a/crates/openhuman-core/src/config/schemas/controllers/agent.rs +++ b/crates/openhuman-core/src/config/schemas/controllers/agent.rs @@ -76,6 +76,7 @@ pub(super) fn handle_update_agent_settings(params: Map) -> Contro }; let patch = config_rpc::AgentSettingsPatch { agent_timeout_secs: update.agent_timeout_secs, + chat_agent_id: update.chat_agent_id, }; match config_rpc::load_and_apply_agent_settings(patch).await { Ok(outcome) => { From 84b3d93afbeaa6e637fddefe7ab9fa938eafb6ff Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 19:19:11 +0300 Subject: [PATCH 10/28] feat(config): add chat_agent_id field to AgentSettingsUpdate Add a new optional `chat_agent_id` field to the agent settings update schema, allowing the web-chat path to route turns to a specific agent. An empty string clears the override back to the orchestrator, while omitting the field leaves the current value unchanged. Auto-committed-on: dragonfly --- crates/openhuman-core/src/config/schemas/helpers.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/openhuman-core/src/config/schemas/helpers.rs b/crates/openhuman-core/src/config/schemas/helpers.rs index d56cac9475..f1cccc1c39 100644 --- a/crates/openhuman-core/src/config/schemas/helpers.rs +++ b/crates/openhuman-core/src/config/schemas/helpers.rs @@ -228,6 +228,10 @@ pub(super) struct PrivacyModeUpdate { pub(super) struct AgentSettingsUpdate { /// Tool/action wall-clock timeout in seconds (1–3600). Validated server-side. pub(super) agent_timeout_secs: Option, + /// Agent id the web-chat path routes turns to. Empty string clears the + /// override (back to the orchestrator); omitted leaves it unchanged. + #[serde(default)] + pub(super) chat_agent_id: Option, } #[derive(Debug, Deserialize)] From e12db450a9315e6aa6102f8a6fc52cf607054c3e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 19:20:33 +0300 Subject: [PATCH 11/28] feat(config): extend update_agent_settings schema with chat_agent_id field The `update_agent_settings` controller schema now accepts an optional `chat_agent_id` field that allows the web-chat path to route turns to a specific agent definition, with an empty string reverting to the orchestrator. This extends the existing timeout configuration to also support selecting a longer-running agent for chat interactions. Auto-committed-on: dragonfly --- .../src/config/schemas/schema_defs/agent.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/config/schemas/schema_defs/agent.rs b/crates/openhuman-core/src/config/schemas/schema_defs/agent.rs index 63ab1935d8..46b2ce954e 100644 --- a/crates/openhuman-core/src/config/schemas/schema_defs/agent.rs +++ b/crates/openhuman-core/src/config/schemas/schema_defs/agent.rs @@ -85,12 +85,18 @@ pub(super) fn lookup(function: &str) -> Option { "update_agent_settings" => Some( ControllerSchema { namespace: "config", function: "update_agent_settings", - description: "Update agent execution settings. Currently the action/tool wall-clock timeout (seconds). Applies to the next tool call without a restart; the OPENHUMAN_TOOL_TIMEOUT_SECS env var still overrides it when set.", + description: "Update agent execution settings: the action/tool wall-clock timeout (seconds) and the web-chat target agent. Applies to the next tool call without a restart; the OPENHUMAN_TOOL_TIMEOUT_SECS env var still overrides it when set.", inputs: vec![FieldSchema { name: "agent_timeout_secs", ty: TypeSchema::Option(Box::new(TypeSchema::U64)), comment: "Wall-clock timeout for a single tool/action execution, in seconds (1–3600). Extend this when large local models are interrupted before finishing.", required: false, + }, + FieldSchema { + name: "chat_agent_id", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Agent definition id the web-chat path routes turns to. Empty string reverts to the orchestrator. A named definition's own max_iterations governs the turn, so this is how a longer-running agent is selected.", + required: false, }], outputs: vec![json_output("snapshot", "Updated config snapshot.")], }), From 05a2fb9411a0faa18936296b68fa0a9388459dc1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 19:20:56 +0300 Subject: [PATCH 12/28] fix(scripts/life-scenarios): set chat_agent_id via RPC before running scenarios The web-chat driver does not pass an agent_id per call, so the agent must be configured through the running core's RPC interface rather than by pre-writing the config file. The previous approach of writing to the config file before boot was ineffective because the active user directory is created at boot time and its configuration takes precedence, causing the benchmark to silently run with the orchestrator's default iteration cap instead of the intended agent. Auto-committed-on: dragonfly --- scripts/life-scenarios/run.mjs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/scripts/life-scenarios/run.mjs b/scripts/life-scenarios/run.mjs index a35faac283..17b86b1ef7 100644 --- a/scripts/life-scenarios/run.mjs +++ b/scripts/life-scenarios/run.mjs @@ -1041,6 +1041,31 @@ async function main() { { attempts: 10, delayMs: 500, what: "BYOK route" }, ); } + + // The web-chat driver has no per-call `agent_id`, so the agent is chosen by + // `[agent] chat_agent_id`. Set it through the running core rather than by + // pre-writing the file, for exactly the reason the BYOK block above gives: + // `prepareHome` writes `users/local/config.toml`, but the active user dir is + // minted at boot (`users/local-dragonfly/...`) and its config wins. The + // pre-written value is read by nothing, and the turn silently runs the + // orchestrator at its own 15-iteration cap — which looks like the benchmark + // agent failing when it never ran at all. + await withRetries( + async () => { + await core.rpc("openhuman.config_update_agent_settings", { + chat_agent_id: opts.agentId, + }); + const snap = await core.rpc("openhuman.config_get", {}); + const cfg = snap?.config ?? snap?.snapshot?.config ?? snap?.snapshot ?? snap ?? {}; + const got = cfg.agent?.chat_agent_id ?? null; + if (got !== opts.agentId) + throw new Error( + `chat_agent_id not in the active config yet (want ${opts.agentId}, got ${got ?? "unset"})`, + ); + }, + { attempts: 10, delayMs: 500, what: "chat_agent_id" }, + ); + console.log( `route : ${opts.managed ? "managed backend" : opts.inferenceUrl} model=${opts.model}`, ); From f619ad22db4b5569e7d21257c95e805de7c3467c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 20:05:07 +0300 Subject: [PATCH 13/28] feat(tool_output): add raw fetch detection to skip summarizer Add a helper function `is_raw_fetch` that identifies `web_fetch` calls made with `raw: true`, including those wrapped inside `use_skill`, and store the result in a new `raw_fetches` field on the middleware. This allows the payload summarizer to skip such calls, avoiding an expensive and pointless model call that would re-describe unconverted markup when the caller explicitly asked for the raw bytes. Auto-committed-on: dragonfly --- .../tinyagents/middleware/tool_output.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs index 2c29147b7f..69e7b8dc96 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs @@ -86,6 +86,37 @@ pub(crate) fn is_truncation_exempt(name: &str) -> bool { COMPACTION_EXEMPT_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 { + 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 @@ -111,6 +142,11 @@ pub(crate) struct ToolOutputMiddleware { /// `before_tool`, where the arguments are visible, and consumed in /// `after_tool`, where they are not. pub(crate) artifact_reads: Mutex>, + /// 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>, } impl ToolOutputMiddleware { From e02e9f7a7652c4709a91752e501dd663c0c4fb02 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 20:05:24 +0300 Subject: [PATCH 14/28] fix(tool_output): exempt raw fetch results from payload summarization When a tool call is identified as a raw fetch via `is_raw_fetch`, the middleware now records the call ID in a set of raw fetches and later skips semantic summarization for those results. This prevents the payload summarizer from processing large binary or raw responses that should be passed through unchanged, instead capping and spilling them to an artifact. Auto-committed-on: dragonfly --- .../tinyagents/middleware/tool_output.rs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs index 69e7b8dc96..e2e163b68a 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs @@ -184,6 +184,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()); + } + } Ok(()) } @@ -363,8 +373,17 @@ impl Middleware<(), crate::agent::tinyagents::host::OpenHumanRunContext> for Too }); } - // 2. Semantic summarization, on whatever step 1 left behind. - if let Some(ps) = &self.payload_summarizer { + // 2. Semantic summarization, on whatever step 1 left behind — + // unless the caller asked for the raw body, see `is_raw_fetch`. + if raw_fetch { + tracing::info!( + tool = tool_name, + bytes = content.len(), + "[tinyagents::mw] raw fetch: skipping payload summarizer, \ + capping and spilling to an artifact instead" + ); + } + if let Some(ps) = (!raw_fetch).then_some(self.payload_summarizer.as_ref()).flatten() { match ps .maybe_summarize_in_parent(ctx, tool_name, self.task_hint.as_deref(), &content) .await From 3cebf56f0680483100b8159b69113049f8698f69 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 20:06:33 +0300 Subject: [PATCH 15/28] feat(tool_output): consume raw fetch entry unconditionally Consume the raw fetch entry unconditionally so the entry cannot outlive its call, even on the artifact-read early return below. This prevents a resource leak where the raw fetch entry would persist beyond its intended lifetime. Auto-committed-on: dragonfly --- .../src/agent/tinyagents/middleware/tool_output.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs index e2e163b68a..585e77dff4 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs @@ -213,6 +213,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 + .raw_fetches + .lock() + .ok() + .is_some_and(|mut raw| raw.remove(&call_id)); let artifact_read = self .artifact_reads .lock() From 16daec2df845c6812a89ba0b58eacb16ac87b605 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 20:07:39 +0300 Subject: [PATCH 16/28] feat(tinyagents): add raw_fetches field to middleware contexts The TurnContextMiddleware and HandoffMiddleware now initialise a `raw_fetches` field on their shared state, and all test fixtures have been updated to include the new field. This prepares the middleware to track raw fetch results alongside artifact reads. Auto-committed-on: dragonfly --- .../src/agent/tinyagents/middleware/tool_output_tests.rs | 1 + .../src/agent/tinyagents/middleware/turn_context.rs | 2 ++ .../src/agent/tinyagents/middleware_tool_output_tests.rs | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output_tests.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output_tests.rs index 5a03a0f39d..eaa1c87903 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output_tests.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output_tests.rs @@ -24,6 +24,7 @@ async fn same_tool_calls_persist_artifacts_under_distinct_call_ids() { runtime_config: None, tool_policies: HashMap::new(), artifact_reads: Default::default(), + raw_fetches: Default::default(), }; let mut ctx = context(); diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/turn_context.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/turn_context.rs index aeea16e757..dbf9869d2d 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/turn_context.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/turn_context.rs @@ -434,6 +434,7 @@ impl TurnContextMiddleware { runtime_config: self.runtime_config, tool_policies, artifact_reads: Default::default(), + raw_fetches: Default::default(), })); } // Push the handoff LAST (so its `after_tool` runs FIRST): it observes the @@ -475,6 +476,7 @@ impl HandoffMiddleware { agent_id: config.agent_id, task_id: config.task_id, artifact_reads: Default::default(), + raw_fetches: Default::default(), } } } diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware_tool_output_tests.rs b/crates/openhuman-core/src/agent/tinyagents/middleware_tool_output_tests.rs index bda04f6f61..a4a19ad731 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware_tool_output_tests.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware_tool_output_tests.rs @@ -461,6 +461,7 @@ async fn tool_output_truncates_over_the_flat_budget() { runtime_config: None, tool_policies: HashMap::new(), artifact_reads: Default::default(), + raw_fetches: Default::default(), }; let mut result = tool_result("echo", &"x".repeat(5_000)); mw.after_tool( @@ -494,6 +495,7 @@ async fn tool_output_leaves_small_results_untouched() { runtime_config: None, tool_policies: HashMap::new(), artifact_reads: Default::default(), + raw_fetches: Default::default(), }; let mut result = tool_result("echo", "tiny"); mw.after_tool( @@ -534,6 +536,7 @@ fn tool_char_cap_reads_the_tools_own_declared_cap() { runtime_config: None, tool_policies, artifact_reads: 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)); @@ -583,6 +586,7 @@ async fn a_tool_that_caps_itself_is_never_sent_to_the_summarizer() { runtime_config: None, tool_policies, artifact_reads: Default::default(), + raw_fetches: Default::default(), }; let mut result = tool_result("terse", &"payload ".repeat(200)); @@ -633,6 +637,7 @@ async fn tool_output_honors_a_tools_own_cap() { runtime_config: None, tool_policies, artifact_reads: Default::default(), + raw_fetches: Default::default(), }; let mut result = tool_result("capped", &"y".repeat(500)); mw.after_tool( From 40716faf0726d75eb718522aca0f06a7770f0f0b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 20:08:59 +0300 Subject: [PATCH 17/28] chore: files changed crates/openhuman-core/src/agent/tinyagents/middleware/turn_context.rs Auto-committed-on: dragonfly --- .../src/agent/tinyagents/middleware/turn_context.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/turn_context.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/turn_context.rs index dbf9869d2d..46438aeb88 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/turn_context.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/turn_context.rs @@ -476,7 +476,6 @@ impl HandoffMiddleware { agent_id: config.agent_id, task_id: config.task_id, artifact_reads: Default::default(), - raw_fetches: Default::default(), } } } From 3c0b46d53fa00f47762679d2fe619f41e87a2d13 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 20:10:51 +0300 Subject: [PATCH 18/28] test(tool-output): add tests for raw web_fetch exemption from payload summarizer Add two test cases that pin the behaviour of `is_raw_fetch`: one verifies that only a `web_fetch` call with `raw: true` is exempt from the payload summarizer, and another confirms that the exemption is preserved when the fetch is wrapped inside a `use_skill` invocation. Auto-committed-on: dragonfly --- .../middleware/tool_output_tests.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output_tests.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output_tests.rs index eaa1c87903..e8add63d19 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output_tests.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output_tests.rs @@ -54,3 +54,63 @@ 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] +fn only_a_raw_web_fetch_is_exempt_from_the_payload_summarizer() { + 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() { + 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}))); +} From 510071684505a85c51b5bd8e36124fcfeae0e9a8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 23 Sep 2026 20:14:23 +0300 Subject: [PATCH 19/28] test: add missing fields to test struct constructors Add the `raw_fetches` field to `ToolOutputMiddleware` constructors and the `chat_agent_id` field to `AgentSettingsPatch` constructors in test files, matching recent changes to the production struct definitions. Auto-committed-on: dragonfly --- crates/openhuman-core/src/agent/tinyagents/middleware_tests.rs | 3 +++ .../agent/tinyagents/middleware_tool_output_artifact_tests.rs | 1 + crates/openhuman-core/src/config/ops_agent_paths_tests.rs | 2 ++ .../openhuman-core/src/config/ops_voice_and_autonomy_tests.rs | 1 + 4 files changed, 7 insertions(+) diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware_tests.rs b/crates/openhuman-core/src/agent/tinyagents/middleware_tests.rs index bf37204cc1..e8331a4321 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware_tests.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware_tests.rs @@ -93,6 +93,7 @@ fn summarizer_mw(ps: Arc) -> ToolOutputMiddleware { runtime_config: None, tool_policies: HashMap::new(), artifact_reads: Default::default(), + raw_fetches: Default::default(), } } @@ -191,6 +192,7 @@ fn compaction_enabled_mw() -> ToolOutputMiddleware { runtime_config: None, tool_policies: HashMap::new(), artifact_reads: Default::default(), + raw_fetches: Default::default(), } } @@ -240,6 +242,7 @@ fn truncation_probe_mw() -> ToolOutputMiddleware { runtime_config: None, tool_policies: HashMap::new(), artifact_reads: Default::default(), + raw_fetches: Default::default(), } } diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware_tool_output_artifact_tests.rs b/crates/openhuman-core/src/agent/tinyagents/middleware_tool_output_artifact_tests.rs index e5f41fe36e..e763b4fda7 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware_tool_output_artifact_tests.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware_tool_output_artifact_tests.rs @@ -23,6 +23,7 @@ fn artifact_mw( runtime_config: None, tool_policies: HashMap::new(), artifact_reads: Default::default(), + raw_fetches: Default::default(), } } diff --git a/crates/openhuman-core/src/config/ops_agent_paths_tests.rs b/crates/openhuman-core/src/config/ops_agent_paths_tests.rs index 78f29c5f57..f5c5a9b480 100644 --- a/crates/openhuman-core/src/config/ops_agent_paths_tests.rs +++ b/crates/openhuman-core/src/config/ops_agent_paths_tests.rs @@ -12,6 +12,7 @@ async fn apply_agent_settings_rejects_out_of_range_timeout() { &mut cfg, AgentSettingsPatch { agent_timeout_secs: Some(0), + chat_agent_id: None, }, ) .await @@ -23,6 +24,7 @@ async fn apply_agent_settings_rejects_out_of_range_timeout() { &mut cfg, AgentSettingsPatch { agent_timeout_secs: Some(99_999), + chat_agent_id: None, }, ) .await diff --git a/crates/openhuman-core/src/config/ops_voice_and_autonomy_tests.rs b/crates/openhuman-core/src/config/ops_voice_and_autonomy_tests.rs index 77ddce0e41..d5e5cf70d3 100644 --- a/crates/openhuman-core/src/config/ops_voice_and_autonomy_tests.rs +++ b/crates/openhuman-core/src/config/ops_voice_and_autonomy_tests.rs @@ -613,6 +613,7 @@ async fn apply_agent_settings_updates_timeout_and_persists_snapshot() { &mut cfg, AgentSettingsPatch { agent_timeout_secs: Some(300), + chat_agent_id: None, }, ) .await From 63d76d133ec4d64197a603371d33ba850ed4abfd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 03:23:13 +0300 Subject: [PATCH 20/28] fix(scripts): handle empty agentId in life-scenarios runner The life-scenarios runner now trims whitespace from the agentId option and treats an empty string as null, preventing a silent failure when the agent ID is not provided. Previously, an empty string would be passed to the config update, causing the orchestrator to run with its default iteration cap instead of the intended benchmark behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/tinyagents/middleware/tool_output.rs | 6 +++++- .../src/agent/tinyagents/middleware/tool_output_tests.rs | 5 ++++- scripts/life-scenarios/run.mjs | 5 +++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs index c1b03d86b4..15007ff34a 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output.rs @@ -419,7 +419,11 @@ impl Middleware<(), crate::agent::tinyagents::host::OpenHumanRunContext> for Too capping and spilling to an artifact instead" ); } - if !raw_fetch && !compaction_exempt && wants_tinyjuice && (tool_cap.is_none() || focus.is_some()) { + if !raw_fetch + && !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. diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output_tests.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output_tests.rs index ee2aa5079a..d7e1b5551b 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output_tests.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/tool_output_tests.rs @@ -65,7 +65,10 @@ async fn same_tool_calls_persist_artifacts_under_distinct_call_ids() { fn only_a_raw_web_fetch_is_exempt_from_the_payload_summarizer() { use serde_json::json; - assert!(is_raw_fetch("web_fetch", &json!({"url": "https://x", "raw": true}))); + 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. diff --git a/scripts/life-scenarios/run.mjs b/scripts/life-scenarios/run.mjs index f0459ce22d..e76bb10c53 100644 --- a/scripts/life-scenarios/run.mjs +++ b/scripts/life-scenarios/run.mjs @@ -1088,6 +1088,7 @@ async function main() { // pre-written value is read by nothing, and the turn silently runs the // orchestrator at its own 15-iteration cap — which looks like the benchmark // agent failing when it never ran at all. + const chatAgentId = opts.agentId.trim() || null; await withRetries( async () => { await core.rpc("openhuman.config_update_agent_settings", { @@ -1096,9 +1097,9 @@ async function main() { const snap = await core.rpc("openhuman.config_get", {}); const cfg = snap?.config ?? snap?.snapshot?.config ?? snap?.snapshot ?? snap ?? {}; const got = cfg.agent?.chat_agent_id ?? null; - if (got !== opts.agentId) + if (got !== chatAgentId) throw new Error( - `chat_agent_id not in the active config yet (want ${opts.agentId}, got ${got ?? "unset"})`, + `chat_agent_id not in the active config yet (want ${chatAgentId ?? "unset"}, got ${got ?? "unset"})`, ); }, { attempts: 10, delayMs: 500, what: "chat_agent_id" }, From bcf271b2d236e5970991b468fc90cfccaf49c075 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 03:46:27 +0300 Subject: [PATCH 21/28] feat(agent): validate chat_agent_id before persisting or routing Add a validation method to check whether an agent ID resolves to a runnable definition, and use it in two places: the config apply path now rejects unknown agent IDs with an error, and the web-chat session falls back to the default orchestrator when the configured ID is not runnable. This prevents configuration errors from taking down web chat and gives operators immediate feedback when setting an invalid agent. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/session_host/builder/factory.rs | 8 ++++++++ crates/openhuman-core/src/config/ops/agent.rs | 7 +++++++ .../src/config/ops_agent_paths_tests.rs | 20 +++++++++++++++++++ crates/openhuman-core/src/web_chat/session.rs | 14 ++++++++++--- .../src/web_chat/session_checkout_tests.rs | 16 +++++++++++---- scripts/life-scenarios/run.mjs | 10 +++++++++- 6 files changed, 67 insertions(+), 8 deletions(-) diff --git a/crates/openhuman-core/src/agent/session_host/builder/factory.rs b/crates/openhuman-core/src/agent/session_host/builder/factory.rs index 22a773d4b7..f369282f5f 100644 --- a/crates/openhuman-core/src/agent/session_host/builder/factory.rs +++ b/crates/openhuman-core/src/agent/session_host/builder/factory.rs @@ -23,6 +23,14 @@ use tinytools_agent::dialect::{ }; impl OpenHumanSessionHost { + /// Returns whether `agent_id` resolves to a runnable definition for this + /// configuration. This is deliberately the same resolution path used by + /// [`Self::from_config_for_agent`], so configuration writers cannot save + /// a web-chat route that the session factory would later reject. + pub(crate) fn is_runnable_agent_id(config: &Config, agent_id: &str) -> bool { + resolve_target_definition(config, agent_id).is_ok() + } + /// Constructs an `OpenHumanSessionHost` instance from a global system configuration. /// /// Thin wrapper around [`OpenHumanSessionHost::from_config_for_agent`] that always diff --git a/crates/openhuman-core/src/config/ops/agent.rs b/crates/openhuman-core/src/config/ops/agent.rs index 1c84e851c8..87fa054814 100644 --- a/crates/openhuman-core/src/config/ops/agent.rs +++ b/crates/openhuman-core/src/config/ops/agent.rs @@ -232,6 +232,13 @@ pub async fn apply_agent_settings( if let Some(chat_agent_id) = update.chat_agent_id { let trimmed = chat_agent_id.trim(); + if !trimmed.is_empty() + && !crate::agent::OpenHumanSessionHost::is_runnable_agent_id(config, trimmed) + { + return Err(format!( + "chat_agent_id '{trimmed}' is not a runnable agent definition" + )); + } config.agent.chat_agent_id = (!trimmed.is_empty()).then(|| trimmed.to_string()); log::debug!( "[config][agent] chat_agent_id -> {:?}", diff --git a/crates/openhuman-core/src/config/ops_agent_paths_tests.rs b/crates/openhuman-core/src/config/ops_agent_paths_tests.rs index f5c5a9b480..ad4ef8dd80 100644 --- a/crates/openhuman-core/src/config/ops_agent_paths_tests.rs +++ b/crates/openhuman-core/src/config/ops_agent_paths_tests.rs @@ -49,6 +49,26 @@ async fn apply_agent_settings_none_leaves_timeout_unchanged() { assert_eq!(cfg.agent.agent_timeout_secs, 250); } +#[tokio::test] +async fn apply_agent_settings_rejects_unknown_chat_agent_id() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + + let err = apply_agent_settings( + &mut cfg, + AgentSettingsPatch { + chat_agent_id: Some("typoed_agent".into()), + ..AgentSettingsPatch::default() + }, + ) + .await + .expect_err("unknown agents must not be persisted as web-chat routes"); + + assert!(err.contains("not a runnable agent definition"), "{err}"); + assert!(cfg.agent.chat_agent_id.is_none()); +} + // ── apply_agent_paths_settings (action_dir editable, issue #3240) ────────────── #[tokio::test] diff --git a/crates/openhuman-core/src/web_chat/session.rs b/crates/openhuman-core/src/web_chat/session.rs index 83ce07e22b..57e637fcf1 100644 --- a/crates/openhuman-core/src/web_chat/session.rs +++ b/crates/openhuman-core/src/web_chat/session.rs @@ -33,14 +33,22 @@ pub(super) fn model_registry_signature(config: &Config) -> String { /// not take chat down. pub(super) fn pick_target_agent_id(config: &Config) -> String { const DEFAULT_CHAT_AGENT_ID: &str = "orchestrator"; - config + let selected = config .agent .chat_agent_id .as_deref() .map(str::trim) .filter(|id| !id.is_empty()) - .unwrap_or(DEFAULT_CHAT_AGENT_ID) - .to_string() + .unwrap_or(DEFAULT_CHAT_AGENT_ID); + + if OpenHumanSessionHost::is_runnable_agent_id(config, selected) { + return selected.to_string(); + } + + log::warn!( + "[web-channel] configured chat_agent_id={selected:?} is not a runnable definition; falling back to {DEFAULT_CHAT_AGENT_ID}" + ); + DEFAULT_CHAT_AGENT_ID.to_string() } pub(crate) fn normalize_model_override(model_override: Option) -> Option { diff --git a/crates/openhuman-core/src/web_chat/session_checkout_tests.rs b/crates/openhuman-core/src/web_chat/session_checkout_tests.rs index 5f004387aa..f27ce042eb 100644 --- a/crates/openhuman-core/src/web_chat/session_checkout_tests.rs +++ b/crates/openhuman-core/src/web_chat/session_checkout_tests.rs @@ -505,6 +505,7 @@ fn fingerprint_diff_reports_every_differing_field() { #[test] fn chat_agent_id_selects_the_web_chat_agent_and_defaults_to_the_orchestrator() { use super::pick_target_agent_id; + crate::agent::harness::AgentDefinitionRegistry::init_global_builtins().unwrap(); let mut config = crate::config::Config::default(); assert_eq!( @@ -517,13 +518,13 @@ fn chat_agent_id_selects_the_web_chat_agent_and_defaults_to_the_orchestrator() { "unset falls back to what the app runs" ); - config.agent.chat_agent_id = Some("life_scenarios".to_string()); - assert_eq!(pick_target_agent_id(&config), "life_scenarios"); + config.agent.chat_agent_id = Some("researcher".to_string()); + assert_eq!(pick_target_agent_id(&config), "researcher"); // Padding is an operator typo in a hand-edited config.toml, not a request // for an agent whose id has spaces in it. - config.agent.chat_agent_id = Some(" life_scenarios ".to_string()); - assert_eq!(pick_target_agent_id(&config), "life_scenarios"); + config.agent.chat_agent_id = Some(" researcher ".to_string()); + assert_eq!(pick_target_agent_id(&config), "researcher"); // Blank is "unset", not "an agent named empty string": a turn routed at an // id the registry cannot answer would fail chat outright. @@ -535,4 +536,11 @@ fn chat_agent_id_selects_the_web_chat_agent_and_defaults_to_the_orchestrator() { "blank {blank:?} falls back rather than routing nowhere" ); } + + config.agent.chat_agent_id = Some("typoed_agent".to_string()); + assert_eq!( + pick_target_agent_id(&config), + "orchestrator", + "an unknown optional setting must not take web chat down" + ); } diff --git a/scripts/life-scenarios/run.mjs b/scripts/life-scenarios/run.mjs index e76bb10c53..5451df895c 100644 --- a/scripts/life-scenarios/run.mjs +++ b/scripts/life-scenarios/run.mjs @@ -102,7 +102,15 @@ function parseArgs(argv) { if (a === "--only") o.only = next().split(",").map((s) => s.trim()).filter(Boolean); else if (a === "--driver") o.driver = next(); - else if (a === "--agent") o.agentId = next(); + else if (a === "--agent") { + const agentId = next(); + if (agentId && !/^[A-Za-z0-9_-]+$/.test(agentId)) { + throw new Error( + "--agent must contain only ASCII letters, digits, '_' or '-'", + ); + } + o.agentId = agentId; + } else if (a === "--model") o.model = next(); else if (a === "--inference-url") o.inferenceUrl = next(); else if (a === "--api-key") o.apiKey = next(); From 67b07ad4f4f7623632aeaa5c2c19d3cfc58586d0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 03:49:19 +0300 Subject: [PATCH 22/28] test(middleware-tool-output): add test that raw web fetch skips payload summary Add a test verifying that when a tool call uses the `raw` flag on `web_fetch`, the middleware does not prepare a payload summary and does not send a TinyJuice request, ensuring that raw fetches bypass the summarizer entirely. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../middleware_tool_output_tests.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware_tool_output_tests.rs b/crates/openhuman-core/src/agent/tinyagents/middleware_tool_output_tests.rs index 0b7ea7cd7a..b82d1271be 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware_tool_output_tests.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware_tool_output_tests.rs @@ -650,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] +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( + "raw-fetch", + "web_fetch", + json!({"url": "https://example.test", "raw": true}), + ); + let mut ctx = ctx(); + mw.before_tool(&mut ctx, &(), &mut call) + .await + .expect("raw fetch is recorded before execution"); + + let mut result = tool_result("web_fetch", &"markup".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(); From 69ed37060d0bf61f26148d0da36b646edeee6879 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 04:26:57 +0300 Subject: [PATCH 23/28] fix(orchestrator): simplify workflow building instructions in prompt Replace the complex instructions about spawning subagents for workflow building with a simpler directive to use the `workflows` skill directly, which delegates to the appropriate specialist internally. This reduces cognitive load on the agent and avoids the need to manage subagent spawning for this common task. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/registry/agents/orchestrator/prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md index 3172538aa4..c938a84e03 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md @@ -35,4 +35,4 @@ Three or more steps? Track them on `todo` cards. Don't stop with a plan: execute ## Scheduling and workflows -Reminders and jobs live in skill `scheduling`: propose the exact timing and get an explicit yes before creating any schedule; every date or time argument comes from `resolve_time`. Building or editing a saved workflow is a specialist's job: spawn the `workflow_builder` agent with `spawn_async_subagent` (add `blocking: true` when this reply depends on the result), handing it the whole request in `prompt` — it owns the authoring tools and runs them itself. To find an existing workflow, spawn `flow_discovery` the same way. Read a saved workflow's definition or runs through skill `workflows` for the read-only lookups, but never try to author one through that skill: its authoring entries are hand-off tools, and a hand-off only executes through a spawn. +Reminders and jobs live in skill `scheduling`: propose the exact timing and get an explicit yes before creating any schedule; every date or time argument comes from `resolve_time`. Building or editing a saved workflow is a specialist's job: use skill `workflows` (`build_workflow` to author, `discover_workflows` to find). These delegates hand the complete request to the specialist that owns the workflow tools; use `blocking: true` when this reply depends on the result. Read a saved workflow's definition or runs through that skill's read-only lookups, but never call its owner-only authoring entries directly. From 45ba097918442923e337ebef4fdc8c8829cfa1a1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 04:51:56 +0300 Subject: [PATCH 24/28] fix(agent): validate agent timeout before applying chat agent id Reorder the agent settings application so that the agent timeout is only written to config after all validation has passed, preventing a partial mutation when a mixed patch contains both a valid timeout and an invalid chat agent id. Add a test to verify that the config remains unchanged when a patch is rejected. Also restrict the life-scenarios script to only accept known agent identifiers. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/config/ops/agent.rs | 9 +++++++- .../src/config/ops_agent_paths_tests.rs | 22 +++++++++++++++++++ scripts/life-scenarios/run.mjs | 8 ++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/openhuman-core/src/config/ops/agent.rs b/crates/openhuman-core/src/config/ops/agent.rs index 87fa054814..dd4159bb29 100644 --- a/crates/openhuman-core/src/config/ops/agent.rs +++ b/crates/openhuman-core/src/config/ops/agent.rs @@ -227,7 +227,6 @@ pub async fn apply_agent_settings( "agent_timeout_secs must be between {MIN_TIMEOUT_SECS} and {MAX_TIMEOUT_SECS} seconds (got {timeout_secs})" )); } - config.agent.agent_timeout_secs = timeout_secs; } if let Some(chat_agent_id) = update.chat_agent_id { @@ -239,6 +238,14 @@ pub async fn apply_agent_settings( "chat_agent_id '{trimmed}' is not a runnable agent definition" )); } + } + + 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 -> {:?}", diff --git a/crates/openhuman-core/src/config/ops_agent_paths_tests.rs b/crates/openhuman-core/src/config/ops_agent_paths_tests.rs index ad4ef8dd80..5fdffdb0d7 100644 --- a/crates/openhuman-core/src/config/ops_agent_paths_tests.rs +++ b/crates/openhuman-core/src/config/ops_agent_paths_tests.rs @@ -69,6 +69,28 @@ async fn apply_agent_settings_rejects_unknown_chat_agent_id() { assert!(cfg.agent.chat_agent_id.is_none()); } +#[tokio::test] +async fn apply_agent_settings_rejects_a_mixed_patch_without_mutating_config() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + let original_timeout = cfg.agent.agent_timeout_secs; + + let err = apply_agent_settings( + &mut cfg, + AgentSettingsPatch { + agent_timeout_secs: Some(300), + chat_agent_id: Some("typoed_agent".into()), + }, + ) + .await + .expect_err("unknown agent must reject the entire patch"); + + assert!(err.contains("not a runnable agent definition"), "{err}"); + assert_eq!(cfg.agent.agent_timeout_secs, original_timeout); + assert!(cfg.agent.chat_agent_id.is_none()); +} + // ── apply_agent_paths_settings (action_dir editable, issue #3240) ────────────── #[tokio::test] diff --git a/scripts/life-scenarios/run.mjs b/scripts/life-scenarios/run.mjs index 5451df895c..dc79fe02a0 100644 --- a/scripts/life-scenarios/run.mjs +++ b/scripts/life-scenarios/run.mjs @@ -49,6 +49,7 @@ import { startMockSearch, DEFAULT_INDEX_PATH } from "./mock-search.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPO = path.resolve(HERE, "..", ".."); const FIXTURES = path.join(HERE, "fixtures"); +const SUPPORTED_AGENT_IDS = new Set(["life_scenarios", "orchestrator"]); // --------------------------------------------------------------------------- // args @@ -104,11 +105,16 @@ function parseArgs(argv) { else if (a === "--driver") o.driver = next(); else if (a === "--agent") { const agentId = next(); - if (agentId && !/^[A-Za-z0-9_-]+$/.test(agentId)) { + if (!/^[A-Za-z0-9_-]+$/.test(agentId)) { throw new Error( "--agent must contain only ASCII letters, digits, '_' or '-'", ); } + if (!SUPPORTED_AGENT_IDS.has(agentId)) { + throw new Error( + `--agent must be one of: ${[...SUPPORTED_AGENT_IDS].join(", ")}`, + ); + } o.agentId = agentId; } else if (a === "--model") o.model = next(); From 9296c491270fe23248be2dd271cd2babf2726a14 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 04:53:14 +0300 Subject: [PATCH 25/28] fix(config): avoid moving the chat agent id on update The change replaces a direct pattern match on `update.chat_agent_id` with a call to `as_deref()`, preventing the `Option` from being moved out of the update struct. This allows the field to be reused later in the same scope without cloning. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/config/ops/agent.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/config/ops/agent.rs b/crates/openhuman-core/src/config/ops/agent.rs index dd4159bb29..bfa213d295 100644 --- a/crates/openhuman-core/src/config/ops/agent.rs +++ b/crates/openhuman-core/src/config/ops/agent.rs @@ -229,7 +229,7 @@ pub async fn apply_agent_settings( } } - if let Some(chat_agent_id) = update.chat_agent_id { + if let Some(chat_agent_id) = update.chat_agent_id.as_deref() { let trimmed = chat_agent_id.trim(); if !trimmed.is_empty() && !crate::agent::OpenHumanSessionHost::is_runnable_agent_id(config, trimmed) From 85c0f2c599736ce494c963671df6d406fca3e59b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:53:17 +0300 Subject: [PATCH 26/28] chore: retrigger timed-out review Co-authored-by: Medulla From 762ce7689eb7eec6262f437427b4d8a64f919043 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 08:10:12 +0300 Subject: [PATCH 27/28] test(config): add test for blank chat agent id clearing override Add a test that verifies applying a blank chat agent id clears the in-memory override and removes it from the persisted config, ensuring the override is fully removed rather than left as an empty string. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/config/ops_agent_paths_tests.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/openhuman-core/src/config/ops_agent_paths_tests.rs b/crates/openhuman-core/src/config/ops_agent_paths_tests.rs index 5fdffdb0d7..e129a6b855 100644 --- a/crates/openhuman-core/src/config/ops_agent_paths_tests.rs +++ b/crates/openhuman-core/src/config/ops_agent_paths_tests.rs @@ -69,6 +69,35 @@ async fn apply_agent_settings_rejects_unknown_chat_agent_id() { assert!(cfg.agent.chat_agent_id.is_none()); } +#[tokio::test] +async fn apply_agent_settings_blank_chat_agent_id_clears_and_persists_override() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + cfg.agent.chat_agent_id = Some("researcher".into()); + + let outcome = apply_agent_settings( + &mut cfg, + AgentSettingsPatch { + chat_agent_id: Some(" ".into()), + ..AgentSettingsPatch::default() + }, + ) + .await + .expect("blank chat agent id clears the override"); + + assert_eq!(cfg.agent.chat_agent_id, None); + assert_eq!(outcome.value["config"]["agent"]["chat_agent_id"], serde_json::Value::Null); + + let saved = tokio::fs::read_to_string(&cfg.config_path) + .await + .expect("saved config"); + assert!( + !saved.contains("chat_agent_id"), + "cleared override must not remain in the persisted config: {saved}" + ); +} + #[tokio::test] async fn apply_agent_settings_rejects_a_mixed_patch_without_mutating_config() { let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); From 2a71309b3d34bbaac7e54f4f9f69bdc4c3089e56 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 08:11:08 +0300 Subject: [PATCH 28/28] test(config): reformat assertion for readability Reformatted the assertion in `apply_agent_settings_blank_chat_agent_id_clears_and_persists_override` to span multiple lines, improving code readability without changing any behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/config/ops_agent_paths_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/config/ops_agent_paths_tests.rs b/crates/openhuman-core/src/config/ops_agent_paths_tests.rs index e129a6b855..80b29ed619 100644 --- a/crates/openhuman-core/src/config/ops_agent_paths_tests.rs +++ b/crates/openhuman-core/src/config/ops_agent_paths_tests.rs @@ -87,7 +87,10 @@ async fn apply_agent_settings_blank_chat_agent_id_clears_and_persists_override() .expect("blank chat agent id clears the override"); assert_eq!(cfg.agent.chat_agent_id, None); - assert_eq!(outcome.value["config"]["agent"]["chat_agent_id"], serde_json::Value::Null); + assert_eq!( + outcome.value["config"]["agent"]["chat_agent_id"], + serde_json::Value::Null + ); let saved = tokio::fs::read_to_string(&cfg.config_path) .await