diff --git a/src/openhuman/agent_orchestration/agent_teams/runtime.rs b/src/openhuman/agent_orchestration/agent_teams/runtime.rs index f547080c6..b1d25a8b4 100644 --- a/src/openhuman/agent_orchestration/agent_teams/runtime.rs +++ b/src/openhuman/agent_orchestration/agent_teams/runtime.rs @@ -31,8 +31,7 @@ use anyhow::{anyhow, Result}; use serde_json::json; -use crate::openhuman::agent::harness::fork_context::with_parent_context; -use crate::openhuman::agent_orchestration::parent_context::build_root_parent; +use crate::openhuman::agent_orchestration::parent_context::with_root_parent; use crate::openhuman::agent_orchestration::{ AgentOrchestrationSession, AgentStatus, SpawnAgentRequest, WaitAgentOptions, }; @@ -188,24 +187,21 @@ async fn run_member_loop( run_id: &str, model_override: Option, ) { - let outcome = match build_root_parent(config, "agent_team_runtime", "team", "teamrun").await { - Ok(parent) => { - with_parent_context(parent, async { - drive_member( - config, - team_id, - member_id, - agent_id, - &task, - run_id, - model_override, - ) - .await - }) - .await - } - Err(err) => Err(err), - }; + let outcome = with_root_parent(config, "agent_team_runtime", "team", "teamrun", async { + drive_member( + config, + team_id, + member_id, + agent_id, + &task, + run_id, + model_override, + ) + .await + }) + .await + // Flatten: outer Err = root-parent build failure, inner = drive_member result. + .unwrap_or_else(Err); if let Err(err) = outcome { log::error!( diff --git a/src/openhuman/agent_orchestration/agent_teams/runtime_tests.rs b/src/openhuman/agent_orchestration/agent_teams/runtime_tests.rs index f1655bf13..e5b9e01a9 100644 --- a/src/openhuman/agent_orchestration/agent_teams/runtime_tests.rs +++ b/src/openhuman/agent_orchestration/agent_teams/runtime_tests.rs @@ -291,6 +291,67 @@ async fn drive_member_completes_task_with_worker_output_as_evidence() { assert_eq!(member.current_task_id, None); } +/// Covers `run_member_loop` — the `with_root_parent` wrapper around +/// `drive_member`. With a mock parent already installed, `with_root_parent` +/// reuses it (rather than building a real root), so the member drives to +/// completion under the canned provider. Same setup as the `drive_member` +/// happy-path test, but through the wrapper the live runtime spawns. +#[tokio::test] +async fn run_member_loop_drives_member_under_ambient_parent() { + AgentDefinitionRegistry::init_global_builtins().unwrap(); + let (_dir, config) = test_config(); + seed_team(&config, "team-1"); + seed_member(&config, "team-1", "m1", Some("code_executor")); + seed_task( + &config, + "team-1", + "task-a", + AgentTeamTaskStatus::Todo, + None, + vec![], + ); + run_ledger::claim_agent_team_task(&config, "team-1", "task-a", "m1", "teamrun-y").unwrap(); + run_ledger::mark_agent_team_member_running( + &config, + "team-1", + "m1", + "task-a", + "teamrun-y", + "teamrun-y", + ) + .unwrap(); + let task = run_ledger::get_agent_team_task(&config, "task-a") + .unwrap() + .unwrap(); + + let provider = Arc::new(CannedProvider { + output: "did the thing".into(), + fail: false, + }); + with_parent_context(mock_parent(provider), async { + run_member_loop( + &config, + "team-1", + "m1", + "code_executor", + task, + "teamrun-y", + Some("test-model".into()), + ) + .await + }) + .await; + + let done = run_ledger::get_agent_team_task(&config, "task-a") + .unwrap() + .unwrap(); + assert_eq!( + done.status, + AgentTeamTaskStatus::Done, + "run_member_loop drove the member to completion through with_root_parent" + ); +} + #[tokio::test] async fn drive_member_releases_task_when_worker_fails() { AgentDefinitionRegistry::init_global_builtins().unwrap(); diff --git a/src/openhuman/agent_orchestration/parent_context/builder.rs b/src/openhuman/agent_orchestration/parent_context/builder.rs new file mode 100644 index 000000000..a08d724e2 --- /dev/null +++ b/src/openhuman/agent_orchestration/parent_context/builder.rs @@ -0,0 +1,242 @@ +//! Shared root [`ParentExecutionContext`] builder for controller-spawned +//! orchestration tasks (#3374 PR4, extracted from the #3375 workflow engine). +//! +//! The workflow-run engine ([`workflow_runs::engine`]), the agent-team runtime +//! ([`agent_teams::runtime`]), and the subconscious tick +//! ([`crate::openhuman::subconscious`]) all need to spawn real sub-agents from a +//! background task that has **no** enclosing agent turn on the stack. Those +//! spawns read their parent execution context from a task-local +//! ([`current_parent`]) that is only set inside an agent turn — so a naive spawn +//! fails with `NoParentContext` (the TAURI-RUST-HMW regression, #4337). +//! +//! The fix (proven in `triage::escalation::dispatch_target_agent`) is to build a +//! *root* [`ParentExecutionContext`] from a config-built [`Agent`] and run the +//! whole loop inside [`with_parent_context`]. Every nested `spawn_agent` then +//! resolves `current_parent()` to this root, inheriting a real provider, tool +//! registry, memory, and model — the same construction path `agent_chat` uses. +//! +//! [`with_root_parent`] is the single blessed entry point that folds the build + +//! install into one call, so a surface can neither hand-roll the parent nor +//! forget to install it. Every background orchestration surface goes through it. +//! +//! This was originally inlined in the workflow engine; #3374 PR4 lifted it here +//! so each surface reuses the exact same construction (and the single +//! registry-initialisation defense) rather than carrying a second copy of the +//! ~20-field context literal that could drift. + +use std::collections::HashSet; +use std::sync::Arc; + +use anyhow::{Context, Result}; + +use crate::openhuman::agent::harness::fork_context::{ + current_parent, with_parent_context, ParentExecutionContext, +}; +use crate::openhuman::agent::Agent; +use crate::openhuman::config::Config; + +const LOG_TARGET: &str = "agent_orchestration::parent_context"; + +/// Build a root [`ParentExecutionContext`] from a config-built [`Agent`]. +/// +/// Mirrors `triage::escalation::dispatch_target_agent` — the proven path for +/// running sub-agents without an enclosing agent turn. The caller supplies the +/// identity fields that distinguish one orchestration surface from another: +/// +/// - `agent_definition_id` — labels the root parent (e.g. `"workflow_engine"`, +/// `"agent_team_runtime"`); surfaced in spawn metadata / logs. +/// - `channel` — the logical channel the spawned work belongs to (e.g. +/// `"workflow"`, `"team"`). +/// - `session_prefix` — the `session_id` is `"{session_prefix}-{uuid}"`, keeping +/// each surface's sessions namespaced and greppable. +/// +/// Every other field is inherited verbatim from the config-built agent, so the +/// spawned children behave exactly like a normal sub-agent dispatch. +pub(crate) async fn build_root_parent( + config: &Config, + agent_definition_id: &str, + channel: &str, + session_prefix: &str, +) -> Result { + // Sub-agent spawns resolve their definition through the global + // agent-definition registry, so it MUST be initialised before any spawn. + // The full runtime boot (`bootstrap_core_runtime`) does this, but these + // engines can also be reached from contexts that only built the HTTP router + // (e.g. the JSON-RPC e2e harness) — so init defensively here. `OnceLock` + // makes this idempotent: a no-op when the registry is already loaded. + if crate::openhuman::agent::harness::AgentDefinitionRegistry::global().is_none() { + if let Err(err) = crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global( + &config.workspace_dir, + ) { + // A concurrent init may have won the race and populated the registry, + // in which case the `AlreadyInitialized`-style error is benign. But if + // the registry is *still* `None`, init genuinely failed — fail fast + // here rather than letting every downstream `spawn_agent` fail later + // with `NoParentContext` after orchestration state has advanced. + if crate::openhuman::agent::harness::AgentDefinitionRegistry::global().is_none() { + return Err(err) + .context("initialize AgentDefinitionRegistry for orchestration root parent"); + } + log::debug!( + target: LOG_TARGET, + "[parent_context] registry_init_raced err={err}" + ); + } + } + + let mut agent = Agent::from_config(config) + .context("build Agent from config for orchestration root parent")?; + + let integrations = crate::openhuman::composio::fetch_connected_integrations(config).await; + agent.set_connected_integrations(integrations); + + Ok(ParentExecutionContext { + agent_definition_id: agent_definition_id.to_string(), + allowed_subagent_ids: HashSet::new(), + provider: agent.provider_arc(), + all_tools: agent.tools_arc(), + all_tool_specs: agent.tool_specs_arc(), + // No visibility filter for this spawned/background builder — empty means + // "unknown" and callers fall back to the full registry (see field doc). + visible_tool_names: HashSet::new(), + model_name: agent.model_name().to_string(), + temperature: agent.temperature(), + workspace_dir: agent.workspace_dir().to_path_buf(), + memory: agent.memory_arc(), + agent_config: agent.agent_config().clone(), + workflows: Arc::new(agent.workflows().to_vec()), + memory_context: Arc::new(None), + session_id: format!("{session_prefix}-{}", uuid::Uuid::new_v4()), + channel: channel.to_string(), + connected_integrations: agent.connected_integrations().to_vec(), + tool_call_format: crate::openhuman::context::prompt::ToolCallFormat::PFormat, + session_key: agent.session_key().to_string(), + session_parent_prefix: agent.session_parent_prefix().map(str::to_string), + on_progress: None, + run_queue: None, + }) +} + +/// Ensure a parent execution context is installed for `fut`, then run it — the +/// single blessed entry point for **controller-spawned background orchestration +/// surfaces** that have no enclosing agent turn (the workflow-run engine, the +/// agent-team runtime, the subconscious tick). +/// +/// Folds [`build_root_parent`] + [`with_parent_context`] into one call so a +/// surface cannot install a hand-rolled parent, and — the TAURI-RUST-HMW +/// failure mode — cannot *forget* to install one at all and have every nested +/// `spawn_subagent` die at runtime with +/// [`SubagentRunError::NoParentContext`](crate::openhuman::agent::harness::subagent_runner::SubagentRunError::NoParentContext). +/// Running a background surface and establishing its root context become the +/// same act. +/// +/// When an ambient [`current_parent`] is already installed, `fut` runs under it +/// unchanged rather than building a second root. In production these surfaces +/// run on freshly-spawned tasks where task-locals never cross the `tokio::spawn` +/// boundary, so the ambient parent is always absent and a root is built from +/// `config` — but reusing an installed parent keeps the helper correct if it is +/// ever nested inside a turn, and lets tests drive a surface under a mock +/// parent (and thus a mock provider) hermetically. +/// +/// Returns the future's output on success, or the [`build_root_parent`] error +/// when the root context can't be constructed — the caller decides how to +/// degrade (fail the run, or fall back to an un-grounded path). +/// +/// Only for surfaces that build their parent *from `Config`* because there is +/// no ambient parent. Spawn sites that re-install an **inherited** +/// `current_parent()` across a `tokio::spawn` boundary (e.g. +/// `spawn_async_subagent`, `spawn_worker_thread`, the orchestration `ops` task) +/// are a different pattern and call [`with_parent_context`] directly. +pub(crate) async fn with_root_parent( + config: &Config, + agent_definition_id: &str, + channel: &str, + session_prefix: &str, + fut: F, +) -> Result +where + F: std::future::Future, +{ + // Already inside a turn (nested call, or a test harness installed a mock + // parent): reuse it rather than building a second root. + if current_parent().is_some() { + return Ok(fut.await); + } + let parent = build_root_parent(config, agent_definition_id, channel, session_prefix).await?; + Ok(with_parent_context(parent, fut).await) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::agent::harness::fork_context::current_parent; + + fn test_config() -> (tempfile::TempDir, Config) { + let dir = tempfile::tempdir().expect("tempdir"); + let config = Config { + workspace_dir: dir.path().to_path_buf(), + ..Config::default() + }; + (dir, config) + } + + /// Baseline for the bug: with no enclosing agent turn there is no ambient + /// parent — exactly the state the subconscious tick spawned `context_scout` + /// in (TAURI-RUST-HMW / #4337), which made `run_subagent` return + /// `NoParentContext`. + #[tokio::test] + async fn no_ambient_parent_outside_with_root_parent() { + assert!( + current_parent().is_none(), + "no parent context should be installed by default" + ); + } + + /// Regression (TAURI-RUST-HMW / #4337): `with_root_parent` must install a + /// real parent for the wrapped future so a background orchestration surface + /// (subconscious tick, workflow engine, team runtime) can spawn sub-agents + /// without hitting `NoParentContext`. Proven by observing the installed + /// parent from inside the future. + #[tokio::test] + async fn with_root_parent_installs_parent_for_inner_future() { + let (_dir, config) = test_config(); + let observed = with_root_parent( + &config, + "subconscious", + "subconscious", + "subconscious", + async { current_parent().map(|p| p.agent_definition_id) }, + ) + .await + .expect("root parent builds from config"); + assert_eq!( + observed.as_deref(), + Some("subconscious"), + "inner future must observe the installed root parent" + ); + } + + /// When a parent is already installed, `with_root_parent` reuses it instead + /// of building a second root — so a surface nested in a turn (or a test + /// driving it under a mock parent) runs under the ambient context. + #[tokio::test] + async fn with_root_parent_reuses_ambient_parent() { + let (_dir, config) = test_config(); + let outer = build_root_parent(&config, "outer", "outer", "outer") + .await + .expect("build ambient parent"); + let observed = with_parent_context(outer, async { + with_root_parent(&config, "inner", "inner", "inner", async { + current_parent().map(|p| p.agent_definition_id) + }) + .await + .expect("reuses ambient, no build error") + }) + .await; + assert_eq!( + observed.as_deref(), + Some("outer"), + "with_root_parent must reuse the ambient parent, not build a new 'inner' root" + ); + } +} diff --git a/src/openhuman/agent_orchestration/parent_context/mod.rs b/src/openhuman/agent_orchestration/parent_context/mod.rs index 50351ee21..87d5279e8 100644 --- a/src/openhuman/agent_orchestration/parent_context/mod.rs +++ b/src/openhuman/agent_orchestration/parent_context/mod.rs @@ -1,111 +1,10 @@ //! Shared root [`ParentExecutionContext`] builder for controller-spawned -//! orchestration tasks (#3374 PR4, extracted from the #3375 workflow engine). +//! orchestration tasks (#3374 PR4). Export-only; the implementation lives in +//! [`builder`] — see it for the rationale and the `with_root_parent` / +//! `build_root_parent` construction. //! -//! Both the workflow-run engine ([`workflow_runs::engine`]) and the agent-team -//! runtime ([`agent_teams::runtime`]) need to spawn real sub-agents from a -//! background task that has **no** enclosing agent turn on the stack. Those -//! spawns read their parent execution context from a task-local -//! ([`current_parent`]) that is only set inside an agent turn — so a naive spawn -//! fails with `NoParentContext`. -//! -//! The fix (proven in `triage::escalation::dispatch_target_agent`) is to build a -//! *root* [`ParentExecutionContext`] from a config-built [`Agent`] and run the -//! whole loop inside [`with_parent_context`]. Every nested `spawn_agent` then -//! resolves `current_parent()` to this root, inheriting a real provider, tool -//! registry, memory, and model — the same construction path `agent_chat` uses. -//! -//! This was originally inlined in the workflow engine; PR4 lifts it here so the -//! team runtime reuses the exact same construction (and the single -//! registry-initialisation defense) rather than carrying a second copy of the -//! ~20-field context literal that could drift. +//! [`ParentExecutionContext`]: crate::openhuman::agent::harness::fork_context::ParentExecutionContext -use std::collections::HashSet; -use std::sync::Arc; +mod builder; -use anyhow::{Context, Result}; - -use crate::openhuman::agent::harness::fork_context::ParentExecutionContext; -use crate::openhuman::agent::Agent; -use crate::openhuman::config::Config; - -const LOG_TARGET: &str = "agent_orchestration::parent_context"; - -/// Build a root [`ParentExecutionContext`] from a config-built [`Agent`]. -/// -/// Mirrors `triage::escalation::dispatch_target_agent` — the proven path for -/// running sub-agents without an enclosing agent turn. The caller supplies the -/// identity fields that distinguish one orchestration surface from another: -/// -/// - `agent_definition_id` — labels the root parent (e.g. `"workflow_engine"`, -/// `"agent_team_runtime"`); surfaced in spawn metadata / logs. -/// - `channel` — the logical channel the spawned work belongs to (e.g. -/// `"workflow"`, `"team"`). -/// - `session_prefix` — the `session_id` is `"{session_prefix}-{uuid}"`, keeping -/// each surface's sessions namespaced and greppable. -/// -/// Every other field is inherited verbatim from the config-built agent, so the -/// spawned children behave exactly like a normal sub-agent dispatch. -pub(crate) async fn build_root_parent( - config: &Config, - agent_definition_id: &str, - channel: &str, - session_prefix: &str, -) -> Result { - // Sub-agent spawns resolve their definition through the global - // agent-definition registry, so it MUST be initialised before any spawn. - // The full runtime boot (`bootstrap_core_runtime`) does this, but these - // engines can also be reached from contexts that only built the HTTP router - // (e.g. the JSON-RPC e2e harness) — so init defensively here. `OnceLock` - // makes this idempotent: a no-op when the registry is already loaded. - if crate::openhuman::agent::harness::AgentDefinitionRegistry::global().is_none() { - if let Err(err) = crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global( - &config.workspace_dir, - ) { - // A concurrent init may have won the race and populated the registry, - // in which case the `AlreadyInitialized`-style error is benign. But if - // the registry is *still* `None`, init genuinely failed — fail fast - // here rather than letting every downstream `spawn_agent` fail later - // with `NoParentContext` after orchestration state has advanced. - if crate::openhuman::agent::harness::AgentDefinitionRegistry::global().is_none() { - return Err(err) - .context("initialize AgentDefinitionRegistry for orchestration root parent"); - } - log::debug!( - target: LOG_TARGET, - "[parent_context] registry_init_raced err={err}" - ); - } - } - - let mut agent = Agent::from_config(config) - .context("build Agent from config for orchestration root parent")?; - - let integrations = crate::openhuman::composio::fetch_connected_integrations(config).await; - agent.set_connected_integrations(integrations); - - Ok(ParentExecutionContext { - agent_definition_id: agent_definition_id.to_string(), - allowed_subagent_ids: HashSet::new(), - provider: agent.provider_arc(), - all_tools: agent.tools_arc(), - all_tool_specs: agent.tool_specs_arc(), - // No visibility filter for this spawned/background builder — empty means - // "unknown" and callers fall back to the full registry (see field doc). - visible_tool_names: HashSet::new(), - model_name: agent.model_name().to_string(), - temperature: agent.temperature(), - workspace_dir: agent.workspace_dir().to_path_buf(), - memory: agent.memory_arc(), - agent_config: agent.agent_config().clone(), - workflows: Arc::new(agent.workflows().to_vec()), - memory_context: Arc::new(None), - session_id: format!("{session_prefix}-{}", uuid::Uuid::new_v4()), - channel: channel.to_string(), - connected_integrations: agent.connected_integrations().to_vec(), - tool_call_format: crate::openhuman::context::prompt::ToolCallFormat::PFormat, - session_key: agent.session_key().to_string(), - session_parent_prefix: agent.session_parent_prefix().map(str::to_string), - on_progress: None, - run_queue: None, - }) -} +pub(crate) use builder::{build_root_parent, with_root_parent}; diff --git a/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs b/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs index 9a5131dba..d44f1cfb9 100644 --- a/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs +++ b/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs @@ -125,16 +125,25 @@ pub async fn run_context_scout(question: &str, focus: Option<&str>) -> anyhow::R } /// Same as [`run_context_scout`] but with an **explicitly-supplied** tool -/// catalogue, so it can run *outside* an agent turn — e.g. from the -/// subconscious engine's structured tick, where `current_parent()` is unset -/// and the parent's visible tool set can't be auto-derived. +/// catalogue — for callers *outside* an agent turn that can't auto-derive the +/// parent's visible tool set from `current_parent()` (e.g. the subconscious +/// engine's structured tick). /// /// The caller passes the catalogue of tools the eventual decision agent can /// actually call (one `- name: description` per line), so the bundle's -/// `recommended_tool_calls` stay grounded in callable tools. Progress / -/// subagent-lifecycle events stay best-effort: with no parent context the -/// `parent_session` falls back to `standalone` and the progress sink is absent, -/// so those sends simply no-op. +/// `recommended_tool_calls` stay grounded in callable tools. +/// +/// **A parent execution context is still required.** Like [`run_context_scout`] +/// this spawns `context_scout` via `run_subagent`, which resolves its provider / +/// tools / model from the `PARENT_CONTEXT` task-local and returns +/// `NoParentContext` when it is unset. A background surface with no enclosing +/// turn MUST establish a root parent first — call this *inside* +/// [`with_root_parent`](crate::openhuman::agent_orchestration::parent_context::with_root_parent). +/// Skipping that is exactly the TAURI-RUST-HMW failure (#4337): every spawn +/// died with `NoParentContext` and the tick ran un-grounded. Only the +/// progress / subagent-lifecycle telemetry degrades gracefully without a +/// parent — `parent_session` falls back to `standalone` and the absent progress +/// sink no-ops — but the spawn itself does not. pub async fn run_context_scout_with_catalog( question: &str, focus: Option<&str>, diff --git a/src/openhuman/agent_orchestration/workflow_runs/engine.rs b/src/openhuman/agent_orchestration/workflow_runs/engine.rs index 61f448e03..15e81f35b 100644 --- a/src/openhuman/agent_orchestration/workflow_runs/engine.rs +++ b/src/openhuman/agent_orchestration/workflow_runs/engine.rs @@ -34,8 +34,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use anyhow::{anyhow, Context, Result}; use serde_json::{json, Value}; -use crate::openhuman::agent::harness::fork_context::with_parent_context; -use crate::openhuman::agent_orchestration::parent_context::build_root_parent; +use crate::openhuman::agent_orchestration::parent_context::with_root_parent; use crate::openhuman::config::Config; use crate::openhuman::session_db::run_ledger::{ get_workflow_run, upsert_workflow_run, WorkflowRun, WorkflowRunStatus, WorkflowRunUpsert, @@ -300,15 +299,12 @@ pub async fn resume_workflow_run(config: &Config, id: &str) -> Result { - with_parent_context(parent, async { - super::graph::drive_phases(config, run_id, &definition, &cancel).await - }) - .await - } - Err(err) => Err(err), - }; + let outcome = with_root_parent(config, "workflow_engine", "workflow", "workflow", async { + super::graph::drive_phases(config, run_id, &definition, &cancel).await + }) + .await + // Flatten: outer Err = root-parent build failure, inner = drive_phases result. + .unwrap_or_else(Err); if let Err(err) = outcome { log::error!( diff --git a/src/openhuman/agent_orchestration/workflow_runs/engine_tests.rs b/src/openhuman/agent_orchestration/workflow_runs/engine_tests.rs index 6faa742a5..eaa9d6353 100644 --- a/src/openhuman/agent_orchestration/workflow_runs/engine_tests.rs +++ b/src/openhuman/agent_orchestration/workflow_runs/engine_tests.rs @@ -329,6 +329,35 @@ async fn unit_phases_execute_in_dependency_order() { ); } +/// Covers `run_engine_loop` — the `with_root_parent` wrapper around +/// `drive_phases`. With a mock parent installed, `with_root_parent` reuses it +/// (rather than building a real root), so the engine loop drives the run to +/// completion under the mock provider. Mirrors the `drive_phases` happy path, +/// but through the wrapper the live engine spawns on its background task. +#[tokio::test] +async fn run_engine_loop_completes_run_under_ambient_parent() { + AgentDefinitionRegistry::init_global_builtins().unwrap(); + let (_dir, config) = test_config(); + let provider = PeakProvider::default(); + let def = linear_def(2, 8, 2); + let id = seed_run( + &config, + &def, + json!({ "question": "what is X?", "modelOverride": "test-model" }), + ); + + with_parent_context(mock_parent(Arc::new(provider.clone())), async { + run_engine_loop(&config, &id, def).await + }) + .await; + + assert_eq!( + status_of(&config, &id), + WorkflowRunStatus::Completed, + "run_engine_loop drove the run to completion through with_root_parent" + ); +} + #[tokio::test] async fn unit_concurrency_cap_is_respected() { AgentDefinitionRegistry::init_global_builtins().unwrap(); diff --git a/src/openhuman/subconscious/engine.rs b/src/openhuman/subconscious/engine.rs index 38513fe31..0d08761aa 100644 --- a/src/openhuman/subconscious/engine.rs +++ b/src/openhuman/subconscious/engine.rs @@ -27,6 +27,7 @@ use super::store; use super::types::{SubconsciousStatus, TickResult}; +use crate::openhuman::agent_orchestration::parent_context::with_root_parent; use crate::openhuman::config::schema::SubconsciousMode; use crate::openhuman::config::Config; use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER}; @@ -309,7 +310,7 @@ impl SubconsciousEngine { let has_external_content = true; // ── Stage 2: prepare_context — ground the diff before deciding ─────── - let prepared_context = self.prepare_context(&world_diff).await; + let prepared_context = self.prepare_context(&config, &world_diff).await; // ── Stage 3: decide — slim agent acts on diff + prepared context ───── let mut agent_prompt = @@ -379,20 +380,35 @@ impl SubconsciousEngine { /// Stage 2: run the read-only `context_scout` over the world diff to gather /// grounding context. Best-effort — on any error the decision agent simply /// runs without a prepared-context section. - async fn prepare_context(&self, world_diff: &str) -> String { + /// + /// The tick is a controller-spawned background surface with **no enclosing + /// agent turn**, so the `context_scout` spawn has no ambient + /// `current_parent()`. We establish a root parent for the spawn via the + /// shared [`with_root_parent`] helper — the same construction the + /// workflow-run engine and agent-team runtime use. Without it every tick's + /// scout died with `NoParentContext` and the decision ran un-grounded + /// (Sentry TAURI-RUST-HMW; #4337). The build runs only on ticks that have + /// world changes (the sole caller is past the `has_changes` gate), so quiet + /// ticks pay nothing. + async fn prepare_context(&self, config: &Config, world_diff: &str) -> String { let question = format!( "Background awareness check. Here is what changed in the user's connected sources \ since the last check:\n\n{world_diff}\n\nSurface what the user should be aware of or \ act on, and the context that grounds a good decision.", ); - match crate::openhuman::agent_orchestration::tools::run_context_scout_with_catalog( - &question, - None, - SUBCONSCIOUS_TOOL_CATALOG, - ) + // Flatten: outer Err = root-parent build failure, inner = scout result. + let scout = with_root_parent(config, "subconscious", "subconscious", "subconscious", { + crate::openhuman::agent_orchestration::tools::run_context_scout_with_catalog( + &question, + None, + SUBCONSCIOUS_TOOL_CATALOG, + ) + }) .await - { + .and_then(|inner| inner); + + match scout { Ok(result) if !result.is_error => { debug!( "[subconscious] prepared context bundle ({} chars)",