Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/openhuman/agent/harness/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pub mod task_recency_context;
pub(crate) mod tool_filter;
pub(crate) mod tool_result_artifacts;
pub mod turn_attachments_context;
pub mod turn_dispatch_guard;
pub mod turn_subagent_usage;

pub use agent_graph::{AgentGraph, AgentTurnRequest, AgentTurnResult, AgentTurnUsage};
Expand Down
20 changes: 19 additions & 1 deletion src/openhuman/agent/harness/session/turn/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -973,7 +973,18 @@ impl Agent {
// `spawn_subagent` runs inline on this task and records into the collector)
// so the turn's usage meters + the `chat_done` per-child breakdown include
// it — the collector scope the legacy engine installed.
let (outcome, subagent_usage_entries) =
// Install the turn's sub-agent dispatch guard around the same future
// (#5804). It records two facts the turn already produces but never
// wrote down — that a graceful pause has been requested at the
// model-call cap, and how long this turn's sub-agents actually take —
// so `run_subagent` can refuse a dispatch that cannot finish inside the
// remaining wall-clock budget instead of taking the whole turn down
// with it. Boxed at the call site: `with_dispatch_guard` takes its
// future by value, and the collector future wraps the entire turn
// generator, so passing it unboxed would move hundreds of KiB through
// this frame — the same hazard `with_turn_collector`'s own comment
// documents, with the gdb measurements behind it.
let turn_future = Box::pin(
crate::openhuman::agent::harness::turn_subagent_usage::with_turn_collector(
super::graph::run_chat_turn_graph(super::graph::ChatTurnGraph {
turn_models,
Expand Down Expand Up @@ -1007,6 +1018,13 @@ impl Agent {
.map(|definition| definition.sandbox_mode)
.unwrap_or(crate::openhuman::agent::harness::definition::SandboxMode::None),
}),
),
);
let (outcome, subagent_usage_entries) =
crate::openhuman::agent::harness::turn_dispatch_guard::with_dispatch_guard(
crate::openhuman::agent::tinyagents::agent_turn_wall_clock_ms()
.map(std::time::Duration::from_millis),
turn_future,
)
.await;
let outcome = outcome?;
Expand Down
2 changes: 2 additions & 0 deletions src/openhuman/agent/harness/subagent_runner/ops/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ pub(super) use crate::openhuman::agent::harness::definition::{AgentDefinition, P
#[cfg(test)]
pub(super) use crate::openhuman::agent::harness::fork_context::ParentExecutionContext;
#[cfg(test)]
pub(super) use crate::openhuman::agent::harness::turn_dispatch_guard;
#[cfg(test)]
pub(super) use crate::openhuman::agent::harness::{
current_spawn_depth, with_spawn_depth, MAX_SPAWN_DEPTH,
};
Expand Down
105 changes: 98 additions & 7 deletions src/openhuman/agent/harness/subagent_runner/ops/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ use crate::openhuman::agent::harness::subagent_runner::types::{
SubagentMode, SubagentRunError, SubagentRunOptions, SubagentRunOutcome, SubagentRunStatus,
SubagentUsage,
};
use crate::openhuman::agent::harness::turn_dispatch_guard;
use crate::openhuman::agent::harness::{
current_spawn_depth, with_current_sandbox_mode, with_spawn_depth, MAX_SPAWN_DEPTH,
};
Expand Down Expand Up @@ -306,13 +307,18 @@ async fn try_deterministic_memory_retrieval(
/// Run a sub-agent based on its definition and a task prompt.
///
/// This is the primary entry point for agent delegation. It performs the following:
/// 1. Resolves the [`ParentExecutionContext`] task-local.
/// 2. Generates a unique `task_id` if one wasn't provided.
/// 3. Dispatches to `run_typed_mode`.
/// 1. Generates a unique `task_id` if one wasn't provided.
/// 2. Asks the turn's
/// [dispatch guard](crate::openhuman::agent::harness::turn_dispatch_guard)
/// whether a delegation can still succeed, and refuses before spending
/// anything if it cannot (#5804).
/// 3. Resolves the [`ParentExecutionContext`] task-local.
/// 4. Dispatches to `run_typed_mode`.
///
/// On success returns a [`SubagentRunOutcome`] whose `output` is the
/// final assistant text. On failure the error is suitable for stringifying
/// into a `tool_result` block.
/// into a `tool_result` block — including the two dispatch refusals, whose
/// messages tell the model to summarise rather than delegate again.
pub async fn run_subagent(
definition: &AgentDefinition,
task_prompt: &str,
Expand All @@ -333,11 +339,63 @@ pub async fn run_subagent(
// child's tinyagents drive future further chunk the child's state so
// a single sub-agent run can't blow the stack either.
Box::pin(async move {
let parent = current_parent().ok_or(SubagentRunError::NoParentContext)?;
let task_id = options
.task_id
.clone()
.unwrap_or_else(|| format!("sub-{}", uuid::Uuid::new_v4()));

// Turn-scoped dispatch gate (#5804) — deliberately the FIRST gate, for
// the same reason the depth gate is synchronous and pre-dispatch: a
// delegation we already know cannot land should cost nothing, not a
// config load, a hook, or a provider round-trip.
//
// Two refusals, both evidence-based and both derived from what this
// turn has actually observed rather than from any configured constant
// or task shape: a graceful pause has been requested at the model-call
// cap, or less wall-clock remains than this turn's slowest completed
// sub-agent took. Outside a turn scope the guard is absent and this is
// a no-op, so CLI and direct invocations are unaffected.
match turn_dispatch_guard::check() {
turn_dispatch_guard::DispatchDecision::Allow => {}
turn_dispatch_guard::DispatchDecision::RefusePaused {
completed_model_calls,
cap,
} => {
tracing::info!(
agent_id = %definition.id,
task_id = %task_id,
completed_model_calls,
cap,
"[subagent_runner] dispatch refused — turn already requested a graceful pause"
);
return Err(SubagentRunError::PauseRequested {
completed_model_calls,
cap,
});
}
turn_dispatch_guard::DispatchDecision::RefuseBudget {
remaining_ms,
observed_max_ms,
observed_samples,
} => {
tracing::info!(
agent_id = %definition.id,
task_id = %task_id,
remaining_ms,
observed_max_ms,
observed_samples,
"[subagent_runner] dispatch refused — remaining budget is shorter than this \
turn's slowest sub-agent"
);
return Err(SubagentRunError::DispatchBudgetExhausted {
remaining_ms,
observed_max_ms,
observed_samples,
});
}
}

let parent = current_parent().ok_or(SubagentRunError::NoParentContext)?;
let started = Instant::now();
let current_depth = current_spawn_depth();
let attempted_depth = current_depth.saturating_add(1);
Expand Down Expand Up @@ -431,6 +489,19 @@ pub async fn run_subagent(
)
.await
{
// The fast path completes a real delegation and returns here,
// short-circuiting the recorder below — so record it too, or a
// turn whose only delegations are deterministic memory
// retrievals never accumulates a sample and the budget gate
// stays disarmed for the whole turn (#5804 review).
//
// Including it cannot weaken the gate. `observed_max` is a
// running **maximum**, so a short sample can only leave it
// where it was — an earlier revision of this comment claimed
// the opposite and was wrong about its own statistic. What it
// does buy is a correct `observed_samples` count and a gate
// that arms on a turn shaped entirely from fast-path work.
turn_dispatch_guard::record_subagent_elapsed(started.elapsed());
return Ok(outcome);
}
}
Expand Down Expand Up @@ -471,7 +542,7 @@ pub async fn run_subagent(
"[subagent_runner] worktree-isolated worker: descriptor will route acting-tool CWD"
);
}
let mut outcome = with_spawn_depth(attempted_depth, async {
let run_result = with_spawn_depth(attempted_depth, async {
with_file_state_agent_id(task_id.clone(), async {
with_current_sandbox_mode(definition.sandbox_mode, async {
with_parent_context(parent_for_subagent.clone(), async {
Expand All @@ -491,7 +562,27 @@ pub async fn run_subagent(
})
.await
})
.await?;
.await;

// Feed this delegation's wall-clock into the turn's running maximum,
// which is the only thing the budget gate above judges a later
// dispatch against (#5804). The deterministic fast path above records
// separately and returns, so it cannot reach here twice.
//
// Recorded on BOTH the success and the failure path, and before the
// `?`: a delegation that ran for three minutes and then errored spent
// exactly as much of the turn's budget as one that succeeded, and is
// exactly as much evidence about what a dispatch costs. Dropping
// failures would bias the estimate downwards, and the gate fails open,
// so the bias would show up as the guard not firing when it should.
//
// Measured from the outer `started` rather than `outcome.elapsed`, so
// the config load and the tier/hook gates are inside the figure — the
// question the gate asks is how long a *dispatch* takes end to end,
// not how long the child's own loop ran.
turn_dispatch_guard::record_subagent_elapsed(started.elapsed());
Comment thread
M3gA-Mind marked this conversation as resolved.

let mut outcome = run_result?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// #3883: offload an oversized worker result to `action_dir/outputs/`
// BEFORE the cap below truncates it, so the parent receives a path plus
Expand Down
86 changes: 86 additions & 0 deletions src/openhuman/agent/harness/subagent_runner/ops_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1714,3 +1714,89 @@ fn tier_gate_allows_upward_reasoning_to_chat() {
child.agent_tier = AgentTier::Chat;
assert!(gate(Some(&parent), &child).is_ok());
}

// ---------------------------------------------------------------------------
// Turn-scoped dispatch gate (#5804)
//
// These exercise the gate at its real call site rather than only the policy it
// consults. The lever is that no `ParentExecutionContext` is installed here, so
// an ungated `run_subagent` returns `NoParentContext`: each refusal below is
// therefore evidence the gate ran *and* that it ran before anything was spent,
// and removing the gate turns every one of them into `NoParentContext`.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn dispatch_is_refused_after_the_turn_requests_a_graceful_pause() {
let definition = make_def_named_tools(&[]);
let outcome = turn_dispatch_guard::with_dispatch_guard(
Some(std::time::Duration::from_secs(600)),
async {
// Stands in for `CapPauser`, which writes through a clone of this
// same `Arc` when the model-call cap is reached.
turn_dispatch_guard::current()
.expect("guard installed")
.record_pause_requested(15, 15);
run_subagent(&definition, "task", SubagentRunOptions::default()).await
},
)
.await;

assert!(
matches!(
outcome,
Err(SubagentRunError::PauseRequested {
completed_model_calls: 15,
cap: 15
})
),
"a dispatch after the cap-pause request must be refused, not run: {outcome:?}"
);
}

#[tokio::test]
async fn dispatch_is_refused_when_the_remaining_budget_cannot_fit_an_observed_subagent() {
let definition = make_def_named_tools(&[]);
let outcome = turn_dispatch_guard::with_dispatch_guard(
Some(std::time::Duration::from_millis(1)),
async {
// One completed sub-agent took a minute; the turn's whole ceiling
// is a millisecond and it has already elapsed.
turn_dispatch_guard::record_subagent_elapsed(std::time::Duration::from_secs(60));
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
run_subagent(&definition, "task", SubagentRunOptions::default()).await
},
)
.await;

assert!(
matches!(
outcome,
Err(SubagentRunError::DispatchBudgetExhausted { .. })
),
"a dispatch that cannot fit the remaining budget must be refused: {outcome:?}"
);
}

#[tokio::test]
async fn dispatch_is_not_refused_while_the_guard_has_no_evidence() {
// The other half of the contract, and the one that keeps this from being a
// throughput regression: with no pause requested and no completed
// sub-agent to learn from, the gate must let the dispatch through. Reaching
// `NoParentContext` is exactly that — the gate declined to interfere and
// the normal path ran.
let definition = make_def_named_tools(&[]);
let outcome = turn_dispatch_guard::with_dispatch_guard(
Some(std::time::Duration::from_millis(1)),
async {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
run_subagent(&definition, "task", SubagentRunOptions::default()).await
},
)
.await;

assert!(
matches!(outcome, Err(SubagentRunError::NoParentContext)),
"an exhausted budget with no observed sub-agent is not evidence — the \
dispatch must proceed: {outcome:?}"
);
}
37 changes: 37 additions & 0 deletions src/openhuman/agent/harness/subagent_runner/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,4 +248,41 @@ pub enum SubagentRunError {
/// satisfy — so the two must not read the same in a transcript.
#[error("delegation blocked by a configured hook: {0}")]
HookDenied(String),

/// The turn asked to pause gracefully at its model-call cap before this
/// dispatch was attempted (#5804).
///
/// Distinct from the budget refusal below on purpose: this one is a fact
/// about the turn's *intent* — the loop is going to stop at its next
/// boundary no matter how much time is left — while the other is a
/// measured prediction. Reported to the model as a terminal instruction to
/// summarise, because further fan-out cannot reach the answer and can only
/// consume the budget the checkpoint summary needs.
#[error(
"delegation refused: this turn reached its model-call cap ({completed_model_calls}/{cap}) \
and has already requested a graceful pause. Do not delegate again — summarise the results \
you already have and finish the turn."
)]
PauseRequested {
completed_model_calls: u64,
cap: u64,
},

/// Less wall-clock remained than the slowest sub-agent this turn has
/// actually completed, so the dispatch could not have finished (#5804).
///
/// The comparison is against this turn's own measured maximum, never a
/// configured constant, so the refusal means the same thing for a turn
/// with three fast children as for one with three hundred slow ones.
#[error(
"delegation refused: {remaining_ms} ms of this turn's wall-clock budget remain, but the \
slowest of its {observed_samples} completed sub-agent(s) took {observed_max_ms} ms, so a \
new delegation cannot finish in time. Summarise the results you already have and finish \
the turn."
)]
DispatchBudgetExhausted {
remaining_ms: u64,
observed_max_ms: u64,
observed_samples: u64,
},
}
Loading
Loading