fix(runtime): let delegated work finish without invented budgets (CW-7500) [skip ci]

Remove model-selected child caps, default child wall/depth ceilings, early
wrap-up guidance, report-only requests and report reserves. Preserve explicit
owner settings, cancellation, permission checks and deterministic partial-work
receipts. Continue independent workflow steps, keep dependent skips truthful,
remove cumulative child/code-call and recursive-round cutoffs, and retain
unknown usage as unknown. Preserve mode history and idle active-goal resume.
Upgrade untouched bundled delegate guidance by exact shipped-body hash while
preserving customized and deleted skills. Update existing checks and both
changelogs; no new test functions were added.

Erase the shared Engine event-loop future at run_owned without adding another
loop. Two successive warm local lib-test builds took 29m31s then 2m24s with
Rust 1.99.0 and the same cache, profile, features and target selection. Three
existing assertions also changed; this is an observation, not a cold-build or
cross-platform guarantee. Record the method in both build-performance docs.

Verification: 753 distinct existing checks passed across frozen passes.
Repair6 ran 750 passed / 3 stale assertions; those assertions were corrected,
and repair7 passed 58 relevant Engine/child/recovery/goal and corrected checks.
Reuse unchanged Workflow 237, JS library 12, VM 82 and configuration evidence.
Both builds and test passes retained 2,172 frozen inputs. After tests only
release-note text and build-performance docs changed; runtime code stayed
unchanged. git diff --check passed. Darwin linker reports oversized compact
unwind metadata. Final artifact/package, CI, RC and provider/Boat qualification
remain separate. Further request-wall-time and retry-window work remains open.
This commit is contained in:
CodeWhale Bot committed 2026-10-22 22:46:36 -07:00
1 parent 9afc6d196f
commit 574dc76e26
76 files changed
+2444 -3033

No files matched your search

+38 -1
View File
@@ -23,10 +23,45 @@ tool-enabled runs and their failure receipts. Local runtimes appear during
onboarding, `/diff` works outside a Git repository, DeepSeek's Responses route
no longer rejects replayed tool history, and contributor fixes improve token
accounting, relay model lists, localized replies and Windows launcher
guidance.
guidance. Delegated work continues without model-invented turn or time caps,
and independent workflow steps can continue after another child fails.
### Latest fixes
- Development test builds use a boxed future at the shared Engine event-loop
boundary, keeping its large concrete async type out of callers. One local
warm-build comparison fell from 29m 31s to 2m 24s with the same compiler,
profile and features; this is not a cold-build or cross-platform guarantee.
- Delegated workers no longer inherit model-selected turn, time or output
caps, and the default 30-minute child deadline is removed. Launch receipts
identify ignored legacy limits and show
the limits that actually apply. The 75% child "wrap up" and 80% parent
"stop exploring" prompts are removed, as is the instruction to give up after
two failed tool calls. Explicit owner limits are no longer silently shortened
to 100,000 turns or 24 hours. The stopship example also drops forced response
counts and its two-call discovery limit. Children can use the final allowed
turn for useful work; the host preserves partial results without reserving
time or issuing an extra model request for a forced summary. Default child
and workflow nesting caps are removed; explicit owner bounds still apply.
Existing installations receive the updated delegation guidance while keeping
customized or deleted skills intact (#7044).
- Workflows can continue independent steps after a child fails, with dependent
steps recorded as skipped. The cumulative 1,000-child cutoff is removed;
owners can still configure a total child limit. Recursive work no longer
stops at 25 rounds, three replies without code or printed output, or an
invented enclosing or subquery deadline. Code-mode scripts can make more
than 50 admitted tool calls. Cancellation,
permissions and inherited owner deadlines still apply. Long-running live
children are no longer treated as abandoned merely because 35 minutes passed.
- Read-only workflow children receive their response schema in the first
request; write-capable children keep the verification report contract.
Unmetered workflow usage is shown as unknown instead of zero. Mode history
follows changes between Plan and Operate, and `/goal resume` can restart an
idle active goal without replacing its identity or clearing stall evidence.
- ChatGPT sign-in opens your regular browser and returns to Codewhale with
credentials and a model list for the account you selected. The unsupported
`originator` parameter that caused `invalid_authorize_request` is removed
@@ -1663,6 +1698,8 @@ guidance.
### Fixed
- Delegated work uses the full explicit turn and time limit. Default child nesting and Workflow plan depth caps are removed; explicit owner restrictions remain. The host no longer reserves the last turn or part of the deadline for a forced report, or makes an extra reporting request after the limit; recorded work and files remain available in the partial-result receipt.
- Runtime child completions retain their original parent turn after the parent
becomes idle or starts a newer turn; terminal receipts remain durable and
deduplicated.
+9 -10
View File
@@ -525,9 +525,8 @@ show_whats_new = true
# max_depth = 0 # opt out completely — the agent never spawns sub-agents
# max_depth = 1 # the agent may spawn sub-agents, but those may not spawn more
# max_depth = 2 # one more level of nesting, etc.
# Unset defaults to 3; any value is clamped to the hard ceiling (8). The depth
# limit is enforced in code, not requested of the model — a sub-agent past the
# limit cannot be spawned regardless of what the model decides.
# Omit to leave delegation depth uncapped. Explicit owner limits are enforced
# by the runtime and cannot be widened by descendants.
# max_depth = 3
# ─────────────────────────────────────────────────────────────────────────────────
@@ -1742,15 +1741,15 @@ base_url = "https://integrate.api.nvidia.com/v1"
# auto_start_read_only = true
# # Require an approval card before write/shell/network/high-budget launches.
# require_approval_for_writes = true
# # Hard ceiling on agents in one Workflow run (matches VM lifetime cap).
# max_children = 1000
# # Optional owner-selected total child limit. Zero leaves work uncapped.
# max_children = 0
# # Maximum concurrently live agents inside one run (others wait for a slot).
# max_concurrent = 16
# # Maximum structural nesting depth accepted for Workflow IR (default 5).
# # Optional structural Workflow depth limit; zero leaves it uncapped.
# # This is separate from Runtime child delegation below.
# max_depth = 5
# # Default shared token budget for a Workflow run and its children.
# default_token_budget = 120000
# max_depth = 0
# # Legacy token hint retained in receipts; not metered or enforced.
# default_token_budget = 0
# ─────────────────────────────────────────────────────────────────────────────────
# Agent Fleet roster, role registry, and execution requests (#3165, #3167)
@@ -1773,7 +1772,7 @@ base_url = "https://integrate.api.nvidia.com/v1"
# # Runtime child-agent depth for fleet workers. Shares ONE recursion axis with
# # standalone sub-agents (a fleet worker IS a headless sub-agent). This is an
# # execution request, not Fleet member identity. 0 blocks child agents (the
# # root worker still runs); 3 is the default and 8 is the opt-in hard ceiling.
# # root worker still runs); omit to leave depth uncapped.
# max_spawn_depth = 3
# # Extra system prompt injected into every headless worker
# append_system_prompt = "Never modify .git/config or change remotes."
@@ -111,7 +111,6 @@ pub struct DebugTurnStopDiagnostics {
pub stream_resumes: u32,
pub reasoning_only_reprompts: u32,
pub empty_stop_retries: u32,
pub soft_landing_sent: bool,
pub final_report_requested: bool,
pub permission_strategy_switches: u32,
pub permission_denial_rounds_without_progress: u32,
+13 -37
View File
@@ -1778,31 +1778,15 @@ pub struct FleetConfigToml {
pub exec: FleetExecConfig,
}
/// Canonical recursion-depth policy for the headless worker runtime.
///
/// Single source of truth shared by BOTH standalone sub-agents and fleet
/// workers so the two cannot drift into "two moving targets":
/// - [`DEFAULT_SPAWN_DEPTH`] is the default recursion budget (the sub-agent
/// runtime's `DEFAULT_MAX_SPAWN_DEPTH` is defined as this value).
/// - [`MAX_SPAWN_DEPTH_CEILING`] is the opt-in safety cap; every configured
/// value (fleet `max_spawn_depth`, the `agent` tool's `max_depth`) clamps to it.
///
/// A worker runs at `spawn_depth = 0` and may spawn while
/// `spawn_depth + 1 <= max_spawn_depth`, so a depth of N affords N nested
/// delegation levels below the root worker. The default of 3 affords at least
/// three recursion levels out of the box; the root worker still runs at
/// depth 0 even when the budget is 0.
pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
/// Runtime delegation has no default product depth limit. Explicit owner
/// values still restrict child authority; zero disables delegation.
pub const DEFAULT_SPAWN_DEPTH: u32 = u32::MAX;
pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 900;
pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
/// Hard ceiling on recursion depth for any worker/sub-agent. The default stays
/// conservative at [`DEFAULT_SPAWN_DEPTH`], while explicit config can opt into
/// deeper trees for direct-API providers that can tolerate the fanout.
/// Raising this single constant lifts the limit everywhere (the fleet clamp
/// and `agent` validation both read it).
pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
/// Largest representable absolute depth; admission rejects arithmetic overflow.
pub const MAX_SPAWN_DEPTH_CEILING: u32 = u32::MAX;
/// Headless worker execution constraints (#3027).
///
@@ -1822,10 +1806,8 @@ pub struct FleetExecConfig {
#[serde(default = "default_fleet_max_turns")]
pub max_turns: u32,
/// Recursive child-agent budget for headless fleet workers.
/// Defaults to [`DEFAULT_SPAWN_DEPTH`] (3) so a fleet worker has the SAME
/// recursion budget as a standalone sub-agent — fleet and sub-agents are one
/// substrate, not two. Set 0 to block child `agent` calls (the root worker
/// still runs); the value is clamped to [`MAX_SPAWN_DEPTH_CEILING`].
/// Defaults to [`DEFAULT_SPAWN_DEPTH`], with no product depth cap.
/// Set zero to block child delegation while preserving the root worker.
#[serde(default = "default_fleet_max_spawn_depth")]
pub max_spawn_depth: u32,
/// Extra system prompt text appended to every headless worker.
@@ -2220,7 +2202,7 @@ pub struct WorkflowConfigToml {
/// elevate shell/network, or otherwise leave the read-only envelope.
#[serde(default = "default_workflow_require_approval_for_writes")]
pub require_approval_for_writes: bool,
/// Hard ceiling on total children in one Workflow run (product: 1000).
/// Operator-selected total child limit. Zero leaves total work uncapped.
#[serde(default = "default_workflow_max_children")]
pub max_children: u32,
/// Maximum concurrently live agents inside one Workflow run (product: 16).
@@ -2228,13 +2210,11 @@ pub struct WorkflowConfigToml {
pub max_concurrent: u32,
/// Maximum structural nesting depth accepted for Workflow IR.
///
/// This is independent of Runtime child delegation, whose default is 3
/// and whose opt-in hard ceiling is 8.
/// Zero leaves structural depth uncapped. Runtime delegation is separate.
#[serde(default = "default_workflow_max_depth")]
pub max_depth: u32,
/// Default shared token budget for a Workflow run and its children.
/// `0` applies no shared cap — the run is advisory-only like the parent
/// turn loop — while any positive value is enforced across the run.
/// Legacy declared token hint retained in Workflow receipts. The current
/// runtime does not meter or enforce this value. Zero omits the hint.
#[serde(default = "default_workflow_default_token_budget")]
pub default_token_budget: u64,
}
@@ -2252,7 +2232,7 @@ fn default_workflow_require_approval_for_writes() -> bool {
}
fn default_workflow_max_children() -> u32 {
1000
0
}
fn default_workflow_max_concurrent() -> u32 {
@@ -2260,14 +2240,10 @@ fn default_workflow_max_concurrent() -> u32 {
}
fn default_workflow_max_depth() -> u32 {
5
0
}
fn default_workflow_default_token_budget() -> u64 {
// Off by default: a cap the caller never asked for must not throttle a
// run — a 120k default silently killed real fan-outs mid-task (#6189).
// Spend discipline stays available as an explicit opt-in (tool
// `token_budget`, spec `budget.max_tokens`, or a configured value here).
0
}
+5 -7
View File
@@ -8478,9 +8478,9 @@ fn workflow_config_defaults_match_product_surface() {
assert!(defaults.automatic);
assert!(defaults.auto_start_read_only);
assert!(defaults.require_approval_for_writes);
assert_eq!(defaults.max_children, 1000);
assert_eq!(defaults.max_children, 0);
assert_eq!(defaults.max_concurrent, 16);
assert_eq!(defaults.max_depth, 5);
assert_eq!(defaults.max_depth, 0);
// 0 = no shared cap; budgets are opt-in, matching the parent turn loop's
// advisory policy (#6189).
assert_eq!(defaults.default_token_budget, 0);
@@ -8523,7 +8523,7 @@ default_token_budget = 50000
assert!(workflow.auto_start_read_only);
assert!(workflow.require_approval_for_writes);
assert_eq!(workflow.max_concurrent, 16);
assert_eq!(workflow.max_depth, 5);
assert_eq!(workflow.max_depth, 0);
let serialized = toml::to_string_pretty(&workflow).expect("workflow serializes");
let round_tripped: WorkflowConfigToml =
@@ -8534,14 +8534,12 @@ default_token_budget = 50000
#[test]
fn fleet_exec_config_default_matches_subagent_depth() {
// Fleet workers and standalone sub-agents share one recursion axis:
// the fleet default equals DEFAULT_SPAWN_DEPTH (3) and affords >=3
// nested delegation levels out of the box.
// the fleet default equals DEFAULT_SPAWN_DEPTH without a nesting cap.
assert_eq!(
FleetExecConfig::default().max_spawn_depth,
DEFAULT_SPAWN_DEPTH
);
assert_eq!(FleetExecConfig::default().max_spawn_depth, 3);
const { assert!(DEFAULT_SPAWN_DEPTH <= MAX_SPAWN_DEPTH_CEILING) };
assert_eq!(FleetExecConfig::default().max_spawn_depth, u32::MAX);
}
#[test]
+38 -1
View File
@@ -23,10 +23,45 @@ tool-enabled runs and their failure receipts. Local runtimes appear during
onboarding, `/diff` works outside a Git repository, DeepSeek's Responses route
no longer rejects replayed tool history, and contributor fixes improve token
accounting, relay model lists, localized replies and Windows launcher
guidance.
guidance. Delegated work continues without model-invented turn or time caps,
and independent workflow steps can continue after another child fails.
### Latest fixes
- Development test builds use a boxed future at the shared Engine event-loop
boundary, keeping its large concrete async type out of callers. One local
warm-build comparison fell from 29m 31s to 2m 24s with the same compiler,
profile and features; this is not a cold-build or cross-platform guarantee.
- Delegated workers no longer inherit model-selected turn, time or output
caps, and the default 30-minute child deadline is removed. Launch receipts
identify ignored legacy limits and show
the limits that actually apply. The 75% child "wrap up" and 80% parent
"stop exploring" prompts are removed, as is the instruction to give up after
two failed tool calls. Explicit owner limits are no longer silently shortened
to 100,000 turns or 24 hours. The stopship example also drops forced response
counts and its two-call discovery limit. Children can use the final allowed
turn for useful work; the host preserves partial results without reserving
time or issuing an extra model request for a forced summary. Default child
and workflow nesting caps are removed; explicit owner bounds still apply.
Existing installations receive the updated delegation guidance while keeping
customized or deleted skills intact (#7044).
- Workflows can continue independent steps after a child fails, with dependent
steps recorded as skipped. The cumulative 1,000-child cutoff is removed;
owners can still configure a total child limit. Recursive work no longer
stops at 25 rounds, three replies without code or printed output, or an
invented enclosing or subquery deadline. Code-mode scripts can make more
than 50 admitted tool calls. Cancellation,
permissions and inherited owner deadlines still apply. Long-running live
children are no longer treated as abandoned merely because 35 minutes passed.
- Read-only workflow children receive their response schema in the first
request; write-capable children keep the verification report contract.
Unmetered workflow usage is shown as unknown instead of zero. Mode history
follows changes between Plan and Operate, and `/goal resume` can restart an
idle active goal without replacing its identity or clearing stall evidence.
- ChatGPT sign-in opens your regular browser and returns to Codewhale with
credentials and a model list for the account you selected. The unsupported
`originator` parameter that caused `invalid_authorize_request` is removed
@@ -1663,6 +1698,8 @@ guidance.
### Fixed
- Delegated work uses the full explicit turn and time limit. Default child nesting and Workflow plan depth caps are removed; explicit owner restrictions remain. The host no longer reserves the last turn or part of the deadline for a forced report, or makes an extra reporting request after the limit; recorded work and files remain available in the partial-result receipt.
- Runtime child completions retain their original parent turn after the parent
becomes idle or starts a newer turn; terminal receipts remain durable and
deduplicated.
+1 -1
View File
@@ -15,7 +15,7 @@
"in_model_catalogue": "true when the skill renders as an ambient catalogue line",
"shadowed_aliases": "aliases that collide with another canonical bundled name; the canonical skill wins resolution"
},
"generation": "18",
"generation": "19",
"skills": [
{
"name": "skill-creator",
+3 -1
View File
@@ -32,6 +32,8 @@ Do not delegate tiny one-step tasks, ambiguous product decisions, destructive op
Use `agent` for a focused child run. Launch independent children together so they can run in parallel.
Give each child a concrete outcome and acceptance criteria. Let it continue until the work is complete or it needs input. Do not invent time, turn or token budgets, or tell it to stop early because a percentage of a budget has elapsed. The runtime carries any limits explicitly configured by the owner.
Prefer provider-neutral `model_strength` over hardcoded model ids. Children inherit the active model by default (`model_strength: "same"`), including `type: "explore"`, so pass `model_strength: "faster"` explicitly to get the cheaper same-family sibling for read-only exploration:
```json
@@ -95,5 +97,5 @@ Fix the settings bug.
Strong prompt:
```text
Own only crates/tui/src/settings.rs and its tests. Preserve existing config key names. Add a regression test showing that provider-specific API key changes do not restart provider onboarding. Return the changed paths and test command output.
Own only crates/tui/src/settings.rs and its tests. Preserve existing config key names. Verify with the existing checks that provider-specific API key changes do not restart provider onboarding. Return the changed paths and test command output.
```
+2 -2
View File
@@ -4629,11 +4629,11 @@ mod tests {
let result = config_command(&mut app, Some("subagents max_depth 99 --save"));
let msg = result.message.unwrap();
let saved = fs::read_to_string(&config_path).unwrap();
let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING;
let ceiling = 99;
assert!(!result.is_error);
assert!(msg.contains(&format!("subagents.max_depth = {ceiling}")));
assert!(msg.contains(&format!("clamped from 99 to {ceiling}")));
assert!(!msg.contains("clamped"));
assert!(saved.contains(&format!("max_depth = {ceiling}")));
match result.action {
Some(AppAction::UpdateSubagentRuntimeConfig {
+22 -14
View File
@@ -197,14 +197,13 @@ fn resume_goal(
return CommandResult::error("No paused goal set. Use /goal <objective> first.");
}
// Resuming an already-active goal is a no-op: the continuation loop is
// already running, and re-asserting Active could stack a second
// autonomous turn. Report progress instead.
if effective_goal_status(goal) == ProjectGoalStatus::Active {
if goal.pending_controls {
return CommandResult::message(translate(presentation, "goal_control_accepted"));
}
return goal_status(goal, presentation);
if goal.is_loading || goal.goal_continuation_waiting {
return goal_status(goal, presentation);
}
}
CommandResult::action(AppAction::SetGoalStatus {
@@ -505,19 +504,28 @@ mod tests {
}
#[test]
fn resume_on_an_active_goal_is_a_no_op_report() {
// Re-asserting Active while the loop is already running must not
// schedule a second autonomous turn.
fn resume_on_an_active_goal_only_kicks_when_idle() {
let mut goal = goal_state();
goal.objective = Some("Keep the build green".to_string());
goal.status = ProjectGoalStatus::Active;
let resumed = run(&goal, Some("resume"));
assert!(!resumed.is_error);
assert!(
resumed.action.is_none(),
"no control op on already-active goal"
);
assert!(resumed.message.unwrap().contains("Keep the build green"));
for (is_loading, waiting) in [(false, false), (true, false), (false, true)] {
goal.is_loading = is_loading;
goal.goal_continuation_waiting = waiting;
let resumed = run(&goal, Some("resume"));
assert!(!resumed.is_error);
if is_loading || waiting {
assert!(resumed.action.is_none());
assert!(resumed.message.unwrap().contains("Keep the build green"));
} else {
assert!(matches!(
resumed.action,
Some(AppAction::SetGoalStatus {
status: crate::tools::goal::GoalStatus::Active,
clear: false
})
));
}
}
}
#[test]
+19 -30
View File
@@ -1098,13 +1098,12 @@ pub struct TuiConfig {
/// omitted. `0` maps to the default; values clamp to `1..=3600`.
pub stream_chunk_timeout_secs: Option<u64>,
/// Optional ceiling on model steps in a single turn. Omitted or `0`
/// leaves model steps uncapped; explicit positive values clamp to
/// `1..=100_000`. Wall-clock and stream budgets remain independent.
/// leaves model steps uncapped. Explicit positive values are preserved;
/// `u32::MAX` also represents no limit. Stream limits remain independent.
pub max_model_steps: Option<u32>,
/// R1: cumulative wall-clock budget for a single turn, in seconds.
/// Omitted or `0` resolve to the finite default (3600); explicit values
/// clamp to `30..=86_400`. Time blocked on a human approval decision is
/// excluded from the measurement.
/// Cumulative wall-clock budget for a single turn, in seconds.
/// Omitted or `0` means no limit; positive values are preserved. Time
/// blocked on a human approval decision is excluded from the measurement.
pub turn_wall_clock_secs: Option<u64>,
/// R1: per-step cap on accumulated streamed content, in megabytes.
/// Omitted or `0` resolve to the default (10 MB); explicit values clamp
@@ -1397,7 +1396,7 @@ pub struct GoalConfig {
/// Optional per-engine-turn step ceiling while a goal is active (#6512).
/// Like ordinary turns, `None` or `0` leaves model steps uncapped;
/// positive values clamp to `1..=100,000`. This bounds each turn, never
/// positive values are preserved. This bounds each turn, never
/// the number of continuation passes; explicit per-invocation ceilings
/// (`exec --max-turns`, child-worker caps) still win.
#[serde(default)]
@@ -1760,10 +1759,9 @@ pub struct SubagentsConfig {
/// spawn. `0` blocks the model-facing `agent` tool at this runtime depth;
/// use `[subagents] enabled = false` for the clearer durable off switch.
/// `1` allows one level, `2` two, and so on. When unset, defaults to
/// [`codewhale_config::DEFAULT_SPAWN_DEPTH`]; any value is clamped to
/// [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`]. Fleet workers are
/// governed separately by `[fleet.exec] max_spawn_depth`; both share the
/// same default and ceiling so the limit cannot drift.
/// [`codewhale_config::DEFAULT_SPAWN_DEPTH`], with no product depth cap.
/// Fleet workers are governed separately by `[fleet.exec] max_spawn_depth`;
/// both use the same default.
#[serde(default)]
pub max_depth: Option<u32>,
/// Number of direct (depth-1) sub-agents that may execute concurrently
@@ -1795,16 +1793,12 @@ pub struct SubagentsConfig {
/// cancelled before their request timeout can fire (#2614).
#[serde(default)]
pub heartbeat_timeout_secs: Option<u64>,
/// Default per-child model-turn budget applied when an `agent` start
/// carries no explicit `max_steps` (#5324). Unset or zero remains
/// unbounded for every Fleet role; positive values are clamped to the
/// runtime ceiling (2000) at resolution.
/// Operator-selected per-child model-turn budget. Unset or zero leaves
/// every Fleet role unbounded unless an inherited limit applies.
#[serde(default)]
pub default_max_steps: Option<u32>,
/// Default per-child wall-clock budget in seconds applied when an
/// `agent` start carries no explicit `wall_time_secs` (#5324). When
/// unset, children get 1800s; values are clamped to 1..=86400 at
/// resolution.
/// Operator-selected per-child wall-clock budget in seconds. Unset or
/// zero leaves the run unbounded unless an inherited deadline applies.
#[serde(default)]
pub default_wall_time_secs: Option<u64>,
/// Per-provider overrides for sub-agent fanout and budget knobs. Keys are
@@ -7537,16 +7531,14 @@ impl Config {
/// How many levels of nested sub-agents the interactive `agent` tool may
/// spawn. Reads `[subagents] max_depth`; when unset it defaults to
/// [`codewhale_config::DEFAULT_SPAWN_DEPTH`]. `0` is a valid value that
/// blocks the `agent` tool at this runtime depth. Any value is clamped to
/// [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`] so the operator's choice
/// can never exceed the hard recursion ceiling.
/// blocks the `agent` tool at this runtime depth. Positive owner choices
/// are preserved without another product ceiling.
#[must_use]
pub fn subagent_max_spawn_depth(&self) -> u32 {
self.subagents
.as_ref()
.and_then(|cfg| cfg.max_depth)
.unwrap_or(codewhale_config::DEFAULT_SPAWN_DEPTH)
.min(codewhale_config::MAX_SPAWN_DEPTH_CEILING)
}
/// Return the provider-specific maximum sub-agent recursion depth.
@@ -7555,7 +7547,6 @@ impl Config {
self.subagent_provider_config(identity)
.and_then(|cfg| cfg.max_depth)
.unwrap_or_else(|| self.subagent_max_spawn_depth())
.min(codewhale_config::MAX_SPAWN_DEPTH_CEILING)
}
/// Number of direct (depth-1) sub-agents that may execute concurrently
@@ -7618,9 +7609,8 @@ impl Config {
}
/// Default per-child model-turn budget from `[subagents]
/// default_max_steps`, applied when an `agent` start carries no explicit
/// `max_steps` (#5324). `None` or `0` mean unbounded; a positive value is
/// clamped to the runtime ceiling when applied.
/// default_max_steps`. `None` or `0` mean unbounded; positive values
/// remain an owner limit that descendants cannot widen.
#[must_use]
pub fn subagent_default_max_steps(&self) -> Option<u32> {
self.subagents
@@ -7630,9 +7620,8 @@ impl Config {
}
/// Default per-child wall-clock budget in seconds from `[subagents]
/// default_wall_time_secs`, applied when an `agent` start carries no
/// explicit `wall_time_secs` (#5324). `None` or `0` keep the 1800s
/// default; the resolved value is clamped to 1..=86400 when applied.
/// default_wall_time_secs`. `None` or `0` mean unbounded; positive
/// values remain an owner limit that descendants cannot widen.
#[must_use]
pub fn subagent_default_wall_time_secs(&self) -> Option<u64> {
self.subagents
+3 -6
View File
@@ -3879,7 +3879,7 @@ fn subagents_enabled_reports_disable_precedence() {
}
#[test]
fn subagent_max_spawn_depth_defaults_allows_zero_and_clamps() {
fn subagent_max_spawn_depth_defaults_allows_zero_and_preserves_owner_choice() {
assert_eq!(
Config::default().subagent_max_spawn_depth(),
codewhale_config::DEFAULT_SPAWN_DEPTH
@@ -3896,15 +3896,12 @@ fn subagent_max_spawn_depth_defaults_allows_zero_and_clamps() {
let high = Config {
subagents: Some(SubagentsConfig {
max_depth: Some(codewhale_config::MAX_SPAWN_DEPTH_CEILING + 10),
max_depth: Some(99),
..SubagentsConfig::default()
}),
..Config::default()
};
assert_eq!(
high.subagent_max_spawn_depth(),
codewhale_config::MAX_SPAWN_DEPTH_CEILING
);
assert_eq!(high.subagent_max_spawn_depth(), 99);
}
#[test]
File diff suppressed because it is too large. Load diff
+6 -125
View File
@@ -13,7 +13,6 @@ pub(super) struct ChildHostState {
pub system_prompt: SystemPrompt,
job: Option<Arc<crate::tools::subagent::engine::ChildJob>>,
handle: Option<EngineHandle>,
pub(super) report: Option<crate::tools::subagent::budget_handback::ReportAdmission>,
route_runtime: crate::tools::subagent::SubAgentRuntime,
replacements_tried: usize,
pin_fallback_used: bool,
@@ -36,7 +35,6 @@ impl From<ChildHostSetup> for ChildHostState {
system_prompt: setup.system_prompt,
job: None,
handle: None,
report: None,
}
}
}
@@ -183,15 +181,6 @@ impl Engine {
.as_ref()
.map(|state| state.route_runtime.client.wire_format())
}
pub(super) fn child_report(&self) -> bool {
self.child_host
.as_ref()
.is_some_and(|state| state.report.is_some())
}
/// Executes a queued assignment or its single bounded reporting turn.
/// Both go through handle_send_message -> run_turn, the same Session and
/// Core approval inbox. The next report is reserved on the existing FIFO.
pub(super) async fn handle_child_send_message(
&mut self,
spec: TurnSpec,
@@ -204,38 +193,22 @@ impl Engine {
.as_mut()
.expect("captured child")
.request_stop_reason = None;
let reporting = self.child_report();
let replay_content = spec.content.clone();
let replay_scope = spec.allowed_tools.clone();
let deadline = if reporting {
self.child_host
.as_ref()
.and_then(|state| state.report.as_ref())
.map(|report| report.deadline)
} else {
job.work_deadline
};
let deadline = job.deadline;
let admitted = self
.admitted_turn_control
.as_ref()
.map(|control| control.cancel.clone());
// This is the exact queued turn token, never the parent's or a later
// report's token. Dropping the timer aborts it after settlement.
let timer = deadline.zip(admitted).map(|(deadline, cancel)| {
let job = job.clone();
turn_heartbeat::AbortOnDrop(tokio::spawn(async move {
tokio::time::sleep_until(deadline.into()).await;
if !reporting {
job.stop_for_budget("child wall-time work budget exhausted");
}
job.stop_for_budget("child wall-time work budget exhausted");
cancel.cancel();
}))
});
let outcome = Box::pin(self.handle_send_message(spec)).await;
// Capture expiry at the actual Core outcome. Later checkpoint/ledger
// contention cannot relabel an already-settled provider refusal.
let report_deadline_expired =
reporting && deadline.is_some_and(|deadline| Instant::now() >= deadline);
drop(timer);
job.project(&self.session.messages, job.steps()).await?;
if job.authority.runtime.cancel_token.is_cancelled() {
@@ -320,101 +293,9 @@ impl Engine {
})
.filter(|text| !text.trim().is_empty());
if let Some(cause) = job.budget_reason() {
if !reporting {
match crate::tools::subagent::budget_handback::admit_report(
&job,
&self.session.messages,
&cause,
self.model_client
.as_ref()
.expect("installed selected client")
.effective_max_output_tokens(&self.session.model),
)
.await
{
Ok(report) => {
let content = report
.messages
.iter()
.flat_map(|message| &message.content)
.filter_map(|block| match block {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let mut spec = self.child_turn_spec(content, Some(Vec::new()))?;
spec.allow_shell = false;
spec.max_output_tokens = std::num::NonZeroU32::new(report.output_tokens);
self.config.max_steps = 1;
let state = self.child_host.as_mut().expect("captured child");
state.report = Some(report);
// A fresh queued scope cannot erase parent cancellation:
// it is rechecked by the transport and every dispatch.
state
.handle
.as_ref()
.expect("same Core handle")
.send(Op::SendMessage(spec))
.await?;
return Ok(None);
}
Err(reason) => {
let partial =
crate::tools::subagent::budget_handback::fallback_partial_text(
&self.session.messages,
);
return job
.finish(
&self.session.messages,
job.steps(),
SubAgentStatus::BudgetExhausted,
Some(format!("{partial}\n\n{reason}")),
Some(&cause),
)
.await
.map(Some);
}
}
}
let report = if status == TurnOutcomeStatus::Completed && error.is_none() {
text
} else {
None
};
let mut partial = report.unwrap_or_else(|| {
crate::tools::subagent::budget_handback::fallback_partial_text(
&self.session.messages,
)
});
let mut receipt = if status == TurnOutcomeStatus::Completed && error.is_none() {
"Host budget hand-back receipt: one completed bounded report; assignment remains incomplete.".to_string()
} else if report_deadline_expired {
"Host budget hand-back receipt: report deadline expired; recorded work is preserved.".to_string()
} else {
format!(
"Host budget hand-back receipt: provider call failed or report did not finish: {}. Recorded work is preserved.",
error.as_deref().unwrap_or("Core report interrupted")
)
};
if job
.authority
.runtime
.manager
.read()
.await
.get_worker_record(&job.authority.owner_agent_id)
.is_some_and(|record| record.has_unreported_usage)
{
receipt.push_str(" Measured usage is only a subtotal, not a zero-cost report; unreported provider usage remains unknown.");
}
partial.push_str(&format!("\n\n{receipt}"));
// Record the host's actual outcome alongside this same canonical
// Session, before finish saves/projects the result checkpoint.
self.add_session_message(
self.runtime_text_message_with_turn_metadata(receipt, UserInputProvenance::Runtime),
)
.await;
let partial = crate::tools::subagent::budget_handback::fallback_partial_text(
&self.session.messages,
);
return job
.finish(
&self.session.messages,
@@ -500,7 +381,7 @@ impl Engine {
let Some(job) = self.child_job() else {
return Ok(false);
};
if self.child_report() || !job.can_replace_first_request() {
if !job.can_replace_first_request() {
return Ok(false);
}
let state = self.child_host.as_mut().expect("captured child");
+29 -26
View File
@@ -5,7 +5,7 @@
use super::turn_loop::usage_has_reported_data;
use super::*;
use crate::core::authority::auto_review::AutoReviewPolicy;
use crate::rlm::bridge::{RlmUsageAccumulator, RlmUsageReservation};
use crate::rlm::bridge::{RlmUsageAccumulator, RlmUsageReservation, wait_for_deadline};
use crate::rlm::turn::{RlmRoundTrace, RlmTermination, RlmTurnResult};
use anyhow::anyhow;
@@ -20,7 +20,7 @@ pub(crate) struct RlmInvocation {
pub mode: RlmMode,
pub max_tokens: Option<u32>,
pub task_instructions: Option<String>,
pub deadline: tokio::time::Instant,
pub deadline: Option<tokio::time::Instant>,
pub gate: Option<crate::tools::codemode::NestedCallGate>,
pub events: Option<mpsc::Sender<Event>>,
pub usage: RlmUsageAccumulator,
@@ -44,7 +44,7 @@ pub(crate) struct CapturedRlmCaller {
constitution_block: Option<String>,
approval_store: Result<ApprovalReceiptStore, String>,
review_policy: Arc<AutoReviewPolicy>,
deadline: tokio::time::Instant,
deadline: Option<tokio::time::Instant>,
origin_scope: crate::cost_status::CostScopeToken,
origin_session: String,
origin_turn: String,
@@ -112,13 +112,11 @@ impl CapturedRlmCaller {
let runtime_lease = runtime_owner
.as_deref()
.and_then(crate::cost_status::acquire_runtime_usage_lease);
let parent_deadline = context.turn_deadline;
let default_deadline = tokio::time::Instant::now()
+ engine
.config
.turn_wall_clock
.min(crate::tools::subagent::DEFAULT_CHILD_WALL_TIME);
let deadline = parent_deadline.map_or(default_deadline, |d| d.min(default_deadline));
let deadline = context
.turn_deadline
.into_iter()
.chain(engine.nested_work_deadline())
.min();
let result =
Self {
#[cfg(test)]
@@ -191,7 +189,10 @@ impl CapturedRlmCaller {
"RLM originating turn is cancelled or unavailable",
));
}
if tokio::time::Instant::now() >= self.deadline {
if self
.deadline
.is_some_and(|deadline| tokio::time::Instant::now() >= deadline)
{
return Err(ToolError::execution_failed(
"RLM original parent deadline exhausted",
));
@@ -204,7 +205,7 @@ impl CapturedRlmCaller {
.map_err(ToolError::permission_denied)
}
pub(crate) fn deadline(&self) -> tokio::time::Instant {
pub(crate) fn deadline(&self) -> Option<tokio::time::Instant> {
self.deadline
}
@@ -219,11 +220,14 @@ impl CapturedRlmCaller {
async fn dispatch_admitted(&self, mut invocation: RlmInvocation) -> RlmTurnResult {
let started = Instant::now();
invocation.deadline = invocation.deadline.min(self.deadline);
invocation.deadline = invocation.deadline.into_iter().chain(self.deadline).min();
if let Err(error) = self.validate_live() {
return RlmTurnResult::failed(error.to_string(), started.elapsed());
}
if tokio::time::Instant::now() >= invocation.deadline {
if invocation
.deadline
.is_some_and(|deadline| tokio::time::Instant::now() >= deadline)
{
return RlmTurnResult::failed(
"RLM original parent deadline exhausted".into(),
started.elapsed(),
@@ -274,7 +278,7 @@ impl CapturedRlmCaller {
tokio::select! {
biased;
() = cancel.cancelled() => { local_cancel.cancel(); break None; }
() = tokio::time::sleep_until(deadline) => { local_cancel.cancel(); break None; }
() = wait_for_deadline(deadline) => { local_cancel.cancel(); break None; }
result = &mut call => break Some(result),
event = receiver.recv() => {
let Some(event) = event else { local_cancel.cancel(); break None; };
@@ -329,7 +333,7 @@ pub(super) struct RlmHostState {
pub mode: RlmMode,
pub system_prompt: SystemPrompt,
pub prompt: String,
pub deadline: tokio::time::Instant,
pub deadline: Option<tokio::time::Instant>,
pub gate: Option<crate::tools::codemode::NestedCallGate>,
pub usage: RlmUsageAccumulator,
pub max_tokens: Option<u32>,
@@ -384,7 +388,7 @@ impl Engine {
config.max_steps = if invocation.mode == RlmMode::Completion {
1
} else {
crate::rlm::turn::MAX_RLM_ITERATIONS
caller.config.max_steps
};
config.snapshots_enabled = false;
config.memory_enabled = false;
@@ -394,9 +398,9 @@ impl Engine {
config.goal_objective = None;
config.goal_status = GoalStatus::Paused;
config.goal_state = new_shared_goal_state();
config.turn_wall_clock = invocation
.deadline
.saturating_duration_since(tokio::time::Instant::now());
config.turn_wall_clock = invocation.deadline.map_or(Duration::MAX, |deadline| {
deadline.saturating_duration_since(tokio::time::Instant::now())
});
config.compaction.runtime_cost_owner = caller.runtime_owner.clone();
let system_prompt = crate::rlm::prompt::captured_rlm_prompt(
&caller.system,
@@ -416,9 +420,8 @@ impl Engine {
let startup = tokio::select! {
biased;
() = cancel.cancelled() => Err("RLM Python startup cancelled by its originating turn".to_string()),
result = tokio::time::timeout_at(invocation.deadline, crate::repl::PythonRuntime::spawn_with_context(&path)) => {
result.map_err(|_| "RLM Python startup exhausted its original wall-clock deadline".to_string()).and_then(|r| r)
},
() = wait_for_deadline(invocation.deadline) => Err("RLM Python startup exhausted its original wall-clock deadline".to_string()),
result = crate::repl::PythonRuntime::spawn_with_context(&path) => result,
};
match startup {
Ok(runtime) => {
@@ -623,7 +626,7 @@ async fn forward_rlm_event(
event: Event,
depth: u32,
cancel: &CancellationToken,
deadline: tokio::time::Instant,
deadline: Option<tokio::time::Instant>,
) {
if let Some(events) = events
&& let Some(message) = crate::rlm::bridge::nested_rlm_status_line(event, depth)
@@ -631,7 +634,7 @@ async fn forward_rlm_event(
tokio::select! {
biased;
() = cancel.cancelled() => {},
_ = tokio::time::sleep_until(deadline) => {},
_ = wait_for_deadline(deadline) => {},
_ = events.send(Event::status(message)) => {},
}
}
@@ -647,7 +650,7 @@ impl Engine {
let mut context = state.caller.context.clone();
context.rlm_caller = Some(Arc::clone(&state.caller));
context.cancel_token = Some(self.cancel_token.clone());
context.turn_deadline = Some(state.deadline);
context.turn_deadline = state.deadline;
let registry = ToolRegistryBuilder::new().build(context);
let mut catalog = Vec::new();
if state.mode != RlmMode::Completion {
+14 -9
View File
@@ -458,7 +458,7 @@ async fn actor_fixture_from_authority(
EngineConfig {
workspace: workspace.into(),
model: DEFAULT_TEXT_MODEL.into(),
max_steps: job.work_max_steps,
max_steps: job.max_steps,
..Default::default()
},
&api,
@@ -480,7 +480,7 @@ async fn actor_fixture_from_authority(
}
#[tokio::test(flavor = "current_thread")]
async fn child_work_and_one_bounded_report_share_actual_engine_session_and_dispatch() {
async fn child_work_can_finish_on_the_final_owner_selected_turn() {
use crate::llm_client::mock::canned;
let dir = tempdir().unwrap();
let _home = crate::test_support::SealedHome::at(dir.path());
@@ -506,11 +506,11 @@ async fn child_work_and_one_bounded_report_share_actual_engine_session_and_dispa
tokio::join!(Box::pin(engine.run_child()), collect)
})
.await
.expect("same actor must settle work and reporting");
.expect("same actor must settle the full work allowance");
let result = result.unwrap();
assert_eq!(
result.status,
crate::tools::subagent::SubAgentStatus::BudgetExhausted
crate::tools::subagent::SubAgentStatus::Completed
);
assert_eq!(mock.call_count(), 2);
let requests = mock.captured_requests();
@@ -522,12 +522,17 @@ async fn child_work_and_one_bounded_report_share_actual_engine_session_and_dispa
.iter()
.any(|tool| tool.name == "read")
);
assert!(requests[1].tools.is_none());
assert!(requests[1].tool_choice.is_none());
assert!(requests[1].max_tokens <= 1024);
assert!(
requests[1]
.tools
.as_ref()
.unwrap()
.iter()
.any(|tool| tool.name == "read")
);
assert_eq!(job.steps(), 2);
assert!(requests[1].messages.iter().flat_map(|m| &m.content).any(
|b| matches!(b, ContentBlock::Text { text, .. } if text.contains("exact evidence bytes"))
|b| matches!(b, ContentBlock::ToolResult { content, .. } if content.contains("exact evidence bytes"))
));
assert_eq!(
events
@@ -1106,7 +1111,7 @@ async fn actual_child_tool_output_cap_reaches_core_fanout_with_full_artifact() {
Arc::new(authority),
vec![
canned::tool_call_turn("read-capped", "read", r#"{"path":"evidence.txt"}"#),
canned::simple_text_turn("Bounded report."),
canned::simple_text_turn("Read the evidence."),
],
2,
)
+10 -14
View File
@@ -269,14 +269,10 @@ impl BridgeFixture {
}
}
fn bridge(&self) -> RlmBridge<'_> {
let mut bridge = RlmBridge::with_usage_accumulator(
&self.caller,
self.depth,
Duration::from_secs(600),
self.usage.clone(),
)
.with_deadline(self.deadline)
.with_gate(self.gate.clone());
let mut bridge =
RlmBridge::with_usage_accumulator(&self.caller, self.depth, self.usage.clone())
.with_deadline(self.deadline)
.with_gate(self.gate.clone());
if let Some(events) = &self.events {
bridge = bridge.with_events(events.clone());
}
@@ -341,7 +337,7 @@ pub(crate) async fn run_admitted_fixture(
},
max_tokens: None,
task_instructions: task,
deadline,
deadline: Some(deadline),
gate,
events: Some(events),
usage,
@@ -497,7 +493,7 @@ async fn dropping_an_actual_dispatched_core_call_keeps_unknown_usage_without_ret
mode: RlmMode::Completion,
max_tokens: None,
task_instructions: None,
deadline: tokio::time::Instant::now() + Duration::from_millis(100),
deadline: Some(tokio::time::Instant::now() + Duration::from_millis(100)),
gate: None,
events: None,
usage: usage.clone(),
@@ -593,7 +589,7 @@ async fn full_caller_channel_never_delays_original_cancel_or_loses_usage_and_ker
mode: RlmMode::Recursive { depth_remaining: 0 },
max_tokens: None,
task_instructions: None,
deadline: tokio::time::Instant::now() + Duration::from_secs(60),
deadline: Some(tokio::time::Instant::now() + Duration::from_secs(60)),
gate: Some(crate::tools::codemode::NestedCallGate::admitting_for_test()),
events: Some(tx.clone()),
usage: usage.clone(),
@@ -674,7 +670,7 @@ async fn nested_host_never_claims_or_overwrites_parent_posture_revision() {
mode: RlmMode::Completion,
max_tokens: None,
task_instructions: None,
deadline: tokio::time::Instant::now() + Duration::from_secs(60),
deadline: Some(tokio::time::Instant::now() + Duration::from_secs(60)),
gate: None,
events: None,
usage: RlmUsageAccumulator::new(),
@@ -742,7 +738,7 @@ async fn repeated_real_core_preparation_failures_have_bounded_receipts_without_p
mode: RlmMode::Completion,
max_tokens: None,
task_instructions: None,
deadline: tokio::time::Instant::now() + Duration::from_secs(60),
deadline: Some(tokio::time::Instant::now() + Duration::from_secs(60)),
gate: None,
events: None,
usage: usage.clone(),
@@ -789,7 +785,7 @@ async fn actual_expired_python_startup_releases_the_staged_context() {
mode: RlmMode::Recursive { depth_remaining: 0 },
max_tokens: None,
task_instructions: None,
deadline: tokio::time::Instant::now(),
deadline: Some(tokio::time::Instant::now()),
gate: Some(crate::tools::codemode::NestedCallGate::admitting_for_test()),
events: None,
usage: RlmUsageAccumulator::new(),
@@ -1,5 +1,3 @@
#[tokio::test]
async fn queued_failed_turn_cancels_older_goal_continuation_without_third_call() {
let objective = "stop after the intervening failure";
@@ -1168,6 +1166,44 @@ async fn unsaturated_goal_control_runs_before_ready_idle_child_completion() {
panic!("normal child fairness did not resume after schedule consumption");
};
assert_eq!(child.agent_id, "agent_ready_without_backpressure");
engine.config.goal_state.lock().expect("goal").replace(
"finish the existing goal",
None,
Some("same-goal".to_string()),
);
engine
.handle_set_goal_status(GoalStatus::Active, false, Some("unused-new-id".to_string()))
.await;
let scheduled_id = engine
.scheduled_goal_continuation
.as_ref()
.expect("idle kick")
.id;
engine
.handle_set_goal_status(GoalStatus::Active, false, None)
.await;
assert_eq!(
engine.scheduled_goal_continuation.as_ref().unwrap().id,
scheduled_id
);
assert_eq!(
engine
.config
.goal_state
.lock()
.unwrap()
.snapshot()
.goal_id
.as_deref(),
Some("same-goal")
);
engine.cancel_scheduled_goal_continuation(true).await;
engine.cancel_token.cancel();
engine
.handle_set_goal_status(GoalStatus::Active, false, None)
.await;
assert!(!engine.has_scheduled_goal_continuation());
}
#[tokio::test]
@@ -1520,4 +1556,4 @@ async fn tool_response_crossing_goal_budget_issues_second_provider_request() {
handle.send(Op::Shutdown).await.expect("shutdown engine");
run_task.await.expect("engine task");
}
}
@@ -1664,7 +1664,7 @@ fn stream_retry_scenario() {
}
#[test]
fn mode_notice_is_recorded_on_entering_plan_and_on_leaving_it() {
fn mode_notice_is_recorded_on_entering_plan_or_operate_and_on_leaving_it() {
let _lock = lock_test_env();
let tmp = tempdir().expect("tempdir");
let config = EngineConfig {
@@ -1697,4 +1697,25 @@ fn mode_notice_is_recorded_on_entering_plan_and_on_leaving_it() {
let recorded = notices(&engine);
assert_eq!(recorded.len(), 2, "{recorded:?}");
assert!(recorded[1].starts_with("Mode: Work."), "{recorded:?}");
engine.session.messages.clear();
engine.record_mode_notice(AppMode::Operate);
engine.record_mode_notice(AppMode::Operate);
assert_eq!(notices(&engine).len(), 1);
engine
.session
.add_message(crate::runtime_handoff::operate_contract_runtime_message());
engine.record_mode_notice(AppMode::Operate);
assert_eq!(notices(&engine).len(), 1);
engine.record_mode_notice(AppMode::Agent);
let recorded = notices(&engine);
assert_eq!(recorded.len(), 2);
assert!(recorded[1].starts_with("Mode: Work."));
engine.session.messages.clear();
engine
.session
.add_message(crate::runtime_handoff::legacy_operate_contract_runtime_message());
engine.record_mode_notice(AppMode::Agent);
assert!(notices(&engine)[0].starts_with("Mode: Work."));
}
+13 -31
View File
@@ -23,11 +23,6 @@ use std::time::{Duration, Instant};
/// No model-step limit unless the caller configures one. This is the
/// compatibility representation, not a ceiling checked at `u32::MAX`.
pub const DEFAULT_MAX_MODEL_STEPS: u32 = u32::MAX;
/// Smallest accepted model-step ceiling. One step still lets the model
/// answer once.
pub const MIN_MAX_MODEL_STEPS: u32 = 1;
/// Largest explicitly configured model-step ceiling.
pub const MAX_MAX_MODEL_STEPS: u32 = 100_000;
/// No per-turn wall-clock limit unless the caller configures one.
///
@@ -39,11 +34,6 @@ pub const MAX_MAX_MODEL_STEPS: u32 = 100_000;
/// a human approval decision is excluded (see
/// [`TurnWallClock::begin_human_wait`]).
pub const DEFAULT_TURN_WALL_CLOCK: Duration = Duration::MAX;
/// Smallest accepted per-turn wall-clock budget. Below this a single slow
/// reasoning request would trip the budget before it could finish.
pub const MIN_TURN_WALL_CLOCK_SECS: u64 = 30;
/// Largest accepted per-turn wall-clock budget (24 hours).
pub const MAX_TURN_WALL_CLOCK_SECS: u64 = 86_400;
/// Default per-step cap on accumulated streamed content, in bytes.
/// Preserves the pre-R1 hard-coded value; R1 only makes it overridable.
@@ -130,30 +120,25 @@ pub fn resolve_stream_retry_limits(
/// Resolve a configured model-step ceiling.
///
/// `None` and `0` select the uncapped default. Explicit positive values
/// clamp into `MIN_MAX_MODEL_STEPS..=MAX_MAX_MODEL_STEPS`. Resolve raw input
/// once: passing an already resolved default back as an explicit value
/// would incorrectly install the maximum configurable ceiling.
/// `None` and `0` select the uncapped default. Positive values are preserved;
/// `u32::MAX` remains the compatibility representation for no limit.
#[must_use]
pub fn resolve_max_model_steps(raw: Option<u32>) -> u32 {
match raw {
None | Some(0) => DEFAULT_MAX_MODEL_STEPS,
Some(value) => value.clamp(MIN_MAX_MODEL_STEPS, MAX_MAX_MODEL_STEPS),
Some(value) => value,
}
}
/// Resolve a configured per-turn wall-clock budget.
///
/// `None` and `0` select the uncapped default ([`DEFAULT_TURN_WALL_CLOCK`]).
/// Explicit positive values, in seconds, clamp into
/// `MIN_TURN_WALL_CLOCK_SECS..=MAX_TURN_WALL_CLOCK_SECS`.
/// Explicit positive values, in seconds, are preserved.
#[must_use]
pub fn resolve_turn_wall_clock(raw: Option<u64>) -> Duration {
match raw {
None | Some(0) => DEFAULT_TURN_WALL_CLOCK,
Some(value) => {
Duration::from_secs(value.clamp(MIN_TURN_WALL_CLOCK_SECS, MAX_TURN_WALL_CLOCK_SECS))
}
Some(value) => Duration::from_secs(value),
}
}
@@ -279,16 +264,16 @@ mod tests {
assert_eq!(resolve_max_model_steps(None), DEFAULT_MAX_MODEL_STEPS);
assert_eq!(resolve_max_model_steps(Some(0)), DEFAULT_MAX_MODEL_STEPS);
assert_eq!(DEFAULT_MAX_MODEL_STEPS, u32::MAX);
const { assert!(MAX_MAX_MODEL_STEPS < u32::MAX) };
assert_eq!(resolve_max_model_steps(Some(u32::MAX)), u32::MAX);
}
#[test]
fn model_step_ceiling_is_overridable_and_clamped() {
fn model_step_ceiling_preserves_the_owner_limit() {
assert_eq!(resolve_max_model_steps(Some(7)), 7);
assert_eq!(resolve_max_model_steps(Some(1)), 1);
assert_eq!(
resolve_max_model_steps(Some(u32::MAX)),
MAX_MAX_MODEL_STEPS,
resolve_max_model_steps(Some(200_000)),
200_000,
"explicit positive overrides retain the configured ceiling"
);
}
@@ -298,7 +283,7 @@ mod tests {
assert_eq!(resolve_turn_wall_clock(None), DEFAULT_TURN_WALL_CLOCK);
assert_eq!(resolve_turn_wall_clock(Some(0)), DEFAULT_TURN_WALL_CLOCK);
let mut clock = TurnWallClock::start(resolve_turn_wall_clock(None));
clock.rewind_for_test(Duration::from_secs(10 * MAX_TURN_WALL_CLOCK_SECS));
clock.rewind_for_test(Duration::from_secs(10 * 24 * 60 * 60));
assert!(!clock.exhausted(), "the default never stops a turn");
}
@@ -315,15 +300,12 @@ mod tests {
}
#[test]
fn turn_wall_clock_is_overridable_and_clamped() {
fn turn_wall_clock_preserves_the_owner_limit() {
assert_eq!(resolve_turn_wall_clock(Some(120)), Duration::from_secs(120));
assert_eq!(
resolve_turn_wall_clock(Some(1)),
Duration::from_secs(MIN_TURN_WALL_CLOCK_SECS)
);
assert_eq!(resolve_turn_wall_clock(Some(1)), Duration::from_secs(1));
assert_eq!(
resolve_turn_wall_clock(Some(u64::MAX)),
Duration::from_secs(MAX_TURN_WALL_CLOCK_SECS)
Duration::from_secs(u64::MAX)
);
}
@@ -33,22 +33,6 @@ impl Engine {
stream_errors,
prepared_output_tokens,
} = response;
if self.child_report() {
// Reporting has one logical model step and never authorizes tool,
// code-fence, steer, child completion or goal continuation.
return if tool_uses.is_empty()
&& output_limit_truncated.is_none()
&& !current_text_visible.trim().is_empty()
&& has_sendable_assistant_content
{
PhaseResult::Break
} else {
PhaseResult::Return((
TurnOutcomeStatus::Failed,
Some("bounded Core report did not finish as text".into()),
))
};
}
// A truncated response with no tool call cannot continue through
// tool execution: surface the truncation as a bounded observation
// and resume the loop so the model can act on it instead of the
@@ -1,6 +1,7 @@
//! One private phase of the existing Engine turn loop.
use super::*;
use crate::rlm::bridge::wait_for_deadline;
impl Engine {
pub(super) async fn run_inline_repl_phase(
@@ -80,11 +81,7 @@ impl Engine {
.await;
}
if !repl_blocks.is_empty() && repl_fence_skip_reason.is_none() {
let child_deadline =
tokio::time::Instant::now() + crate::tools::subagent::DEFAULT_CHILD_WALL_TIME;
let repl_deadline = self
.nested_work_deadline()
.map_or(child_deadline, |parent| parent.min(child_deadline));
let repl_deadline = self.nested_work_deadline();
// A kernel left broken by a dropped turn refuses every round; kill it.
drop(self.repl_kernel.take_if(|kernel| kernel.is_broken()));
if self.repl_kernel.is_none() {
@@ -93,12 +90,8 @@ impl Engine {
() = self.cancel_token.cancelled() => {
Err("REPL startup cancelled".into())
}
result = tokio::time::timeout_at(
repl_deadline,
crate::repl::runtime::PythonRuntime::new(),
) => result.unwrap_or_else(|_| {
Err("parent turn deadline reached during REPL startup".into())
}),
() = wait_for_deadline(repl_deadline) => Err("parent turn deadline reached during REPL startup".into()),
result = crate::repl::runtime::PythonRuntime::new() => result,
};
self.repl_kernel = match startup {
Ok(runtime) => Some(runtime),
@@ -119,15 +112,11 @@ impl Engine {
() = self.cancel_token.cancelled() => {
Err("REPL context refresh cancelled".into())
}
result = tokio::time::timeout_at(
repl_deadline,
self.repl_kernel
.as_mut()
.expect("REPL kernel initialized above")
.replace_context(&kernel_context),
) => result.unwrap_or_else(|_| {
Err("parent turn deadline reached during REPL context refresh".into())
}),
() = wait_for_deadline(repl_deadline) => Err("parent turn deadline reached during REPL context refresh".into()),
result = self.repl_kernel
.as_mut()
.expect("REPL kernel initialized above")
.replace_context(&kernel_context) => result,
};
if let Err(e) = refresh_result {
// A broken subprocess cannot be trusted to retain
@@ -176,21 +165,15 @@ impl Engine {
.map_or_else(crate::rlm::bridge::RlmUsageAccumulator::new, |state| {
state.usage.clone()
});
crate::rlm::RlmBridge::with_usage_accumulator(
caller,
depth,
Duration::from_secs(600),
usage,
)
.with_events(self.tx_event.clone())
.with_deadline(Some(repl_deadline))
.with_gate(self.rlm_host.as_ref().and_then(|state| state.gate.clone()))
crate::rlm::RlmBridge::with_usage_accumulator(caller, depth, usage)
.with_events(self.tx_event.clone())
.with_deadline(repl_deadline)
.with_gate(self.rlm_host.as_ref().and_then(|state| state.gate.clone()))
});
let repl_started = Instant::now();
let mut final_result: Option<String> = None;
let mut kernel_failed = false;
let mut empty_cap_hit = false;
for (i, block) in repl_blocks.iter().enumerate() {
let round_num = i + 1;
let _ = self
@@ -208,15 +191,11 @@ impl Engine {
() = self.cancel_token.cancelled() => {
Err("REPL execution cancelled".into())
}
result = tokio::time::timeout_at(
repl_deadline,
self.repl_kernel
.as_mut()
.expect("REPL kernel stays alive during a round")
.run(&block.code, bridge.as_ref()),
) => result.unwrap_or_else(|_| {
Err("REPL execution reached the parent turn deadline".into())
}),
() = wait_for_deadline(repl_deadline) => Err("REPL execution reached the parent turn deadline".into()),
result = self.repl_kernel
.as_mut()
.expect("REPL kernel stays alive during a round")
.run(&block.code, bridge.as_ref()) => result,
};
match round_result {
@@ -267,11 +246,6 @@ impl Engine {
break;
}
// Empty-round guard + provenance (PROMPT-repl-fence-fix.md parts 2 & 3).
// Detection stays prompt-only (has_repl_block unchanged) to preserve
// saved-transcript replay (tools/rlm.rs kept). Provenance makes clear
// the block was the assistant's own; empty rounds get guidance + a
// consecutive cap so the model cannot loop forever.
let is_empty_round = !round.has_error
&& round.stdout.trim().is_empty()
&& round.stderr.trim().is_empty()
@@ -279,29 +253,14 @@ impl Engine {
if is_empty_round {
progress.consecutive_empty_repl_rounds =
progress.consecutive_empty_repl_rounds.saturating_add(1);
let hit_cap = progress.consecutive_empty_repl_rounds >= 3;
let feedback = if hit_cap {
format!(
"[Your emitted ```repl block (round {round_num}) produced no observable output — print something, call a helper, or stop emitting REPL blocks and answer. No output for {consecutive_empty_repl_rounds} consecutive rounds; stopping empty loop]\n[0 child query RPC(s)]",
consecutive_empty_repl_rounds =
progress.consecutive_empty_repl_rounds
)
} else {
format!(
"[Your emitted ```repl block (round {round_num}) produced no observable output — print something, call a helper, or stop emitting REPL blocks and answer]\n[0 child query RPC(s)]"
)
};
let feedback = format!(
"[Your emitted ```repl block (round {round_num}) produced no observable output. Inspect the state or print a result when needed, and finish when the task is complete.]\n[0 child query RPC(s)]"
);
self.add_session_message(self.runtime_text_message_with_turn_metadata(
feedback,
UserInputProvenance::Runtime,
))
.await;
if hit_cap {
empty_cap_hit = true;
// Honest stop: do not continue the turn with a lying
// "stopping" string. The cap is real.
break;
}
} else if self.rlm_host.is_some() {
progress.consecutive_empty_repl_rounds = 0;
} else {
@@ -338,12 +297,15 @@ impl Engine {
))
.await;
if self.rlm_host.is_some() {
progress.turn_error =
Some(if tokio::time::Instant::now() >= repl_deadline {
progress.turn_error = Some(
if repl_deadline
.is_some_and(|deadline| tokio::time::Instant::now() >= deadline)
{
format!("RLM original wall-clock deadline exhausted: {e}")
} else {
e.clone()
});
},
);
}
// A transport error or timeout means Python
// may still be executing unknown code. Do not
@@ -415,21 +377,6 @@ impl Engine {
return PhaseResult::Break;
}
if empty_cap_hit {
if let Some(state) = self.rlm_host.as_mut() {
state.termination = Some(crate::rlm::turn::RlmTermination::NoCode);
return PhaseResult::Return((
TurnOutcomeStatus::Failed,
Some("RLM: 3 consecutive empty REPL rounds".into()),
));
}
// Empty cap already fed back with honest "stopping" text
// inside the round loop. End the turn now instead of
// letting the outer ladder synthesize another provider
// request.
return PhaseResult::Break;
}
// No FINAL — let the model iterate with the feedback.
let _ = self.send_event(Event::status(format!(
"Continuing — REPL round feedback (consecutive_empty={consecutive_empty_repl_rounds})", consecutive_empty_repl_rounds = progress.consecutive_empty_repl_rounds
@@ -464,26 +411,14 @@ impl Engine {
return PhaseResult::Break;
}
let text_final = crate::rlm::turn::parse_text_final(&text);
if state.total_rpcs > 0
&& let Some(answer) = text_final.as_ref()
{
if let Some(answer) = text_final.as_ref() {
state.final_answer = Some(answer.clone());
state.termination = Some(crate::rlm::turn::RlmTermination::Final);
return PhaseResult::Break;
}
let Some(code) = crate::rlm::turn::extract_repl_code(&text) else {
state.consecutive_no_code = state.consecutive_no_code.saturating_add(1);
if state.consecutive_no_code >= crate::rlm::turn::MAX_CONSECUTIVE_NO_CODE {
state.termination = Some(crate::rlm::turn::RlmTermination::NoCode);
if text_final.is_some() {
return PhaseResult::Break;
}
return PhaseResult::Return((
TurnOutcomeStatus::Failed,
Some("RLM: model failed to emit ```repl after 3 consecutive rounds".into()),
));
}
let reminder = "Reminder: emit Python inside a ```repl … ``` fence, inspect `_context` through bounded helpers, and call finalize(value) when done. A prose FINAL before any helper RPC does not demonstrate use of the input.";
let reminder = "Continue the task with Python inside a ```repl … ``` fence when computation is needed. Inspect `_context` through bounded helpers when the answer depends on it. Return FINAL(value) or call finalize(value) when the task is complete.";
self.add_session_message(self.runtime_text_message_with_turn_metadata(
reminder.to_string(),
UserInputProvenance::Runtime,
@@ -53,14 +53,9 @@ impl Engine {
let mut rlm_dispatch = None;
let request_cancel = self.cancel_token.clone();
let mut child_dispatch = None;
let child_reporting = self.child_report();
let child_logical_step = child_job.as_ref().map(|job| {
if child_reporting {
job.steps().saturating_add(1)
} else {
turn.steps_used().saturating_add(1)
}
});
let child_logical_step = child_job
.as_ref()
.map(|_| turn.steps_used().saturating_add(1));
let retry_observation = self.request_retry_observation();
let transport_retries = retry_observation.retries.clone();
let mut request_receipt = Some(PendingProviderRequestReceipt {
@@ -144,12 +139,6 @@ impl Engine {
}
drop(child_dispatch.take());
}
if self.child_report() {
return PhaseResult::Return((
TurnOutcomeStatus::Failed,
Some("bounded Core report request failed".into()),
));
}
// Replacement is permitted only if Core can still retract
// this exact unsent user message. A runtime note or rewrite
// cannot be silently replayed as the original task.
@@ -499,7 +488,7 @@ impl Engine {
// but cannot replace the last admitted answer or execute FINAL/REPL.
state.last_response = current_text_visible.clone();
}
if response_stream_failed && self.child_host.is_some() && !self.child_report() {
if response_stream_failed && self.child_host.is_some() {
let error = if progress
.turn_error
.as_deref()
@@ -624,26 +613,6 @@ impl Engine {
// appends a synthetic user message to the persisted
// conversation — the retried request is the persisted
// conversation re-issued, nothing else.
if self.child_report() {
let refusal = if !tool_uses.is_empty() {
Some("provider returned a tool call; no call executed")
} else if output_limit_truncated.is_some() {
Some("report did not finish: provider output limit")
} else if response_stream_failed {
Some("provider call failed; no complete report accepted")
} else if current_text_visible.trim().is_empty() {
Some("report did not finish: empty response")
} else {
None
};
if let Some(refusal) = refusal {
self.post_held_frame_error(&frame_error).await;
return PhaseResult::Return((
TurnOutcomeStatus::Failed,
Some(format!("bounded Core report refused: {refusal}")),
));
}
}
// A flushed redacted block or non-blank thinking is received content,
// the same rule `current_thinking` follows.
let stream_died_with_nothing = stream_errors > 0
@@ -1149,9 +1118,6 @@ impl Engine {
) -> Option<PhaseResult<AcceptedModelStep>> {
use crate::tools::subagent::engine::ChildRequestRecovery;
let job = self.child_job()?;
if self.child_report() {
return None;
}
let decision = progress
.child_request_retries
.decide(&job.authority.runtime, error)?;
@@ -105,63 +105,26 @@ impl Engine {
// must see mid-turn (LSP diagnostics, steer input, subagent
// completions) are appended to history above, never spliced into
// the frozen prefix.
// A zero-tool turn (plain `exec`) spends extra steps only on
// output-limit continuations. It has no work to wrap up or report
// on, so the agent wrap-up notices below would only bend a
// one-shot answer; at the limit it ends honestly instead.
let zero_tool_turn = progress.tool_catalog.is_empty();
if let Some(job) = self.child_job() {
if job.authority.runtime.cancel_token.is_cancelled() {
return PhaseResult::Return((TurnOutcomeStatus::Interrupted, None));
}
if !self.child_report() {
if let Some(notice) = job.pacing_notice() {
self.add_session_message(self.user_text_message_with_turn_metadata(notice))
.await;
}
if job
.work_deadline
.is_some_and(|deadline| Instant::now() >= deadline)
{
job.stop_for_budget("child wall-time work budget exhausted");
return PhaseResult::Return((TurnOutcomeStatus::Interrupted, None));
}
if turn.at_max_steps() {
job.stop_for_budget("child model-step work budget exhausted");
return PhaseResult::Return((
TurnOutcomeStatus::Failed,
Some("child model-step work budget exhausted".into()),
));
}
if job
.deadline
.is_some_and(|deadline| Instant::now() >= deadline)
{
job.stop_for_budget("child wall-time work budget exhausted");
return PhaseResult::Return((TurnOutcomeStatus::Interrupted, None));
}
if turn.at_max_steps() {
job.stop_for_budget("child model-step work budget exhausted");
return PhaseResult::Return((
TurnOutcomeStatus::Failed,
Some("child model-step work budget exhausted".into()),
));
}
}
// A1 soft landing: with a finite step budget, once ~80% of it is
// spent tell the model once to stop exploring and write its final
// report. Savings proved out by the grok-style parity work (ops
// A1): a step-faithful harness ends mid-report far too often.
if self.child_host.is_none()
&& !zero_tool_turn
&& !turn.stop_diagnostics.soft_landing_sent
&& let Some(step_limit) = turn.step_limit()
&& step_limit > 0
&& turn.steps_used() >= ((step_limit as f32 * 0.8).floor() as u32).max(1)
{
turn.stop_diagnostics.soft_landing_sent = true;
let notice = format!(
"Step budget soft landing: you have used about {}% of your {} step budget ({}). Stop exploring; write your final, complete report now, in final form, with evidence.",
80,
turn.max_steps,
turn.budget_source.key_label(),
);
self.add_session_message(self.user_text_message_with_turn_metadata(notice))
.await;
let _ = self
.send_event(Event::status(
"Soft landing: wrap up with your final report",
))
.await;
}
if turn.at_max_steps() {
turn.stop_diagnostics.reason = Some(TurnStopReason::StepBudgetExhausted);
if self.child_host.is_none()
@@ -225,23 +188,19 @@ impl Engine {
&progress.active_tool_names,
strict_tool_mode,
);
// The report already has a bounded text-only captured request. It
// cannot authorize an extra paid summarizer or mutate its evidence.
if !self.child_report() {
match self
.run_auto_compaction(
client.as_ref(),
active_tools.as_deref(),
turn,
&mut progress.auto_compaction_suppressed,
)
.await
{
AutoCompactionStep::Proceed => {}
AutoCompactionStep::Restart => return PhaseResult::Retry,
AutoCompactionStep::EndTurn(status, error) => {
return PhaseResult::Return((status, error));
}
match self
.run_auto_compaction(
client.as_ref(),
active_tools.as_deref(),
turn,
&mut progress.auto_compaction_suppressed,
)
.await
{
AutoCompactionStep::Proceed => {}
AutoCompactionStep::Restart => return PhaseResult::Retry,
AutoCompactionStep::EndTurn(status, error) => {
return PhaseResult::Return((status, error));
}
}
@@ -269,14 +228,12 @@ impl Engine {
)
.and_then(|tokens| usize::try_from(tokens).ok())
.unwrap_or(0);
if !self.child_report()
&& let Some(budget) = route_context_budget_for_route(
self.api_provider,
&self.session.model,
self.active_route_limits,
estimated_input,
)
{
if let Some(budget) = route_context_budget_for_route(
self.api_provider,
&self.session.model,
self.active_route_limits,
estimated_input,
) {
let input_budget = usize::try_from(
crate::route_budget::route_admission_input_ceiling(
self.api_provider,
@@ -561,17 +518,6 @@ impl Engine {
},
reasoning_effort: effective_reasoning_effort,
});
if let Some(report) = self
.child_host
.as_ref()
.and_then(|state| state.report.as_ref())
{
request.system = Some(report.system.clone());
request.messages = report.messages.clone();
request.max_tokens = report.output_tokens;
request.tools = None;
request.tool_choice = None;
}
if turn.max_output_tokens.is_some() {
request.max_tokens = request
.max_tokens
@@ -614,17 +560,12 @@ impl Engine {
let window = crate::image_attach::cap_request_images(&mut request.messages, cap);
self.note_request_image_window(image_provider, window).await;
}
let report = self.child_report();
let request_input = crate::compaction::estimate_request_input_tokens(
&request.messages,
request.system.as_ref(),
request.tools.as_deref(),
);
let request_input = if report {
request_input
} else {
request_input.max(estimated_input)
};
let request_input = request_input.max(estimated_input);
let output_cap = crate::route_budget::request_output_cap_for_input(
self.api_provider,
&self.session.model,
@@ -634,17 +575,9 @@ impl Engine {
)
.filter(|cap| {
*cap == request.max_tokens
|| u64::from(*cap) >= crate::tools::subagent::budget_handback::MIN_HAND_BACK_OUTPUT
|| u64::from(*cap) >= crate::route_budget::MIN_REQUEST_OUTPUT_TOKENS
});
let Some(output_cap) = output_cap else {
if report {
return PhaseResult::Return((
TurnOutcomeStatus::Failed,
Some(
"bounded Core report request does not fit the route context window".into(),
),
));
}
progress.reasoning_only_nudge = request_nudge;
let input_budget = crate::route_budget::route_admission_input_ceiling(
self.api_provider,
@@ -850,7 +783,8 @@ impl Engine {
TurnOutcomeStatus::Failed,
Some(format!(
"RLM exhausted {} iterations without FINAL",
crate::rlm::turn::MAX_RLM_ITERATIONS
turn.step_limit()
.expect("exhausted turn has an owner step limit")
)),
));
}
+2 -2
View File
@@ -30,7 +30,7 @@ pub enum StepBudgetSource {
}
impl StepBudgetSource {
/// The configuration key named in soft-landing and exhaustion notices.
/// The configuration key named in exhaustion notices.
#[must_use]
pub const fn key_label(self) -> &'static str {
match self {
@@ -225,7 +225,7 @@ impl TurnContext {
self.step_limit().is_some_and(|limit| self.step >= limit)
}
/// Model steps consumed so far (for soft-landing and reporting).
/// Model steps consumed so far (for reporting).
#[must_use]
pub fn steps_used(&self) -> u32 {
self.step
+4 -12
View File
@@ -1478,8 +1478,7 @@ pub fn apply_exec_hardening(
spec.max_spawn_depth = spec
.max_spawn_depth
.min(spec.runtime_profile.max_spawn_depth)
.min(exec.max_spawn_depth)
.min(codewhale_config::MAX_SPAWN_DEPTH_CEILING);
.min(exec.max_spawn_depth);
spec.runtime_profile.max_spawn_depth = spec.max_spawn_depth;
spec.runtime_profile.spawn_depth = spec.spawn_depth;
@@ -4591,17 +4590,10 @@ mod tests {
)
.expect("worker spec with empty profiles");
// Root fleet worker runs at depth 0; its budget equals the shared
// sub-agent default (3) so fleet and sub-agents are one substrate and
// at least 3 nested delegation levels are afforded.
assert_eq!(spec.spawn_depth, 0);
assert_eq!(spec.max_spawn_depth, codewhale_config::DEFAULT_SPAWN_DEPTH);
assert_eq!(spec.max_spawn_depth, 3);
assert_eq!(spec.max_spawn_depth, u32::MAX);
// End-to-end reachability: walk the SAME gate the SubAgentRuntime
// enforces (`would_exceed_depth` = `spawn_depth + 1 > max_spawn_depth`).
// A depth-0 root must reach 3 nested levels, then stop. This fails if
// anyone lowers the shared default below 3 (Hunter: afford >= 3).
let hardened = apply_exec_hardening(spec, &codewhale_config::FleetExecConfig::default());
let would_exceed = |spawn_depth: u32| spawn_depth + 1 > hardened.max_spawn_depth;
assert!(
@@ -4611,8 +4603,8 @@ mod tests {
assert!(!would_exceed(1), "depth-1 child must spawn to depth 2");
assert!(!would_exceed(2), "depth-2 child must spawn to depth 3");
assert!(
would_exceed(3),
"depth 3 is the afforded ceiling; depth 4 is blocked"
!would_exceed(8),
"default delegation continues beyond eight levels"
);
}
@@ -25,8 +25,8 @@
assert_eq!(capped.stop_diagnostics.effective_max_steps, Some(7));
assert_eq!(
exec_max_steps(Some(u32::MAX)),
crate::core::engine::turn_budget::MAX_MAX_MODEL_STEPS,
"even the largest override stays finite"
crate::core::engine::turn_budget::DEFAULT_MAX_MODEL_STEPS,
"the compatibility sentinel remains unbounded"
);
}
+13 -18
View File
@@ -15,6 +15,7 @@ use codewhale_models::Usage;
use futures_util::future::join_all;
use std::sync::Arc;
use std::sync::Mutex;
#[cfg(test)]
use std::time::Duration;
use uuid::Uuid;
@@ -248,6 +249,13 @@ impl RlmUsageAccumulator {
}
}
pub(crate) async fn wait_for_deadline(deadline: Option<tokio::time::Instant>) {
match deadline {
Some(deadline) => tokio::time::sleep_until(deadline).await,
None => std::future::pending::<()>().await,
}
}
/// A dispatcher borrowed by one Python round. Persistent kernels never own
/// this caller, its services or an Engine. All nested work ends with this borrow.
pub(crate) struct RlmBridge<'call> {
@@ -255,8 +263,7 @@ pub(crate) struct RlmBridge<'call> {
depth_remaining: u32,
usage: RlmUsageAccumulator,
events: Option<tokio::sync::mpsc::Sender<crate::core::events::Event>>,
deadline: tokio::time::Instant,
query_timeout: Duration,
deadline: Option<tokio::time::Instant>,
gate: Option<crate::tools::codemode::NestedCallGate>,
}
@@ -264,20 +271,13 @@ impl<'call> RlmBridge<'call> {
pub(crate) fn new(
caller: &'call crate::core::engine::rlm_host::CapturedRlmCaller,
depth_remaining: u32,
query_timeout: Duration,
) -> Self {
Self::with_usage_accumulator(
caller,
depth_remaining,
query_timeout,
RlmUsageAccumulator::new(),
)
Self::with_usage_accumulator(caller, depth_remaining, RlmUsageAccumulator::new())
}
pub(crate) fn with_usage_accumulator(
caller: &'call crate::core::engine::rlm_host::CapturedRlmCaller,
depth_remaining: u32,
query_timeout: Duration,
usage: RlmUsageAccumulator,
) -> Self {
Self {
@@ -286,7 +286,6 @@ impl<'call> RlmBridge<'call> {
usage,
events: None,
deadline: caller.deadline(),
query_timeout: query_timeout.clamp(Duration::from_secs(1), Duration::from_secs(600)),
gate: None,
}
}
@@ -300,13 +299,11 @@ impl<'call> RlmBridge<'call> {
}
pub(crate) fn with_deadline(mut self, deadline: Option<tokio::time::Instant>) -> Self {
if let Some(deadline) = deadline {
self.deadline = self.deadline.min(deadline);
}
self.deadline = self.deadline.into_iter().chain(deadline).min();
self
}
pub(crate) fn deadline(&self) -> tokio::time::Instant {
pub(crate) fn deadline(&self) -> Option<tokio::time::Instant> {
self.deadline
}
@@ -336,9 +333,7 @@ impl<'call> RlmBridge<'call> {
mode,
max_tokens,
task_instructions: system,
deadline: self
.deadline
.min(tokio::time::Instant::now() + self.query_timeout),
deadline: self.deadline,
gate: self.gate.clone(),
events: self.events.clone(),
usage: self.usage.clone(),
-2
View File
@@ -93,7 +93,6 @@ pub enum OutputFeedback {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RlmSessionConfig {
pub output_feedback: OutputFeedback,
pub sub_query_timeout_secs: u64,
pub sub_rlm_max_depth: u32,
pub share_session: bool,
}
@@ -102,7 +101,6 @@ impl Default for RlmSessionConfig {
fn default() -> Self {
Self {
output_feedback: OutputFeedback::Full,
sub_query_timeout_secs: 120,
sub_rlm_max_depth: 1,
share_session: false,
}
+33 -36
View File
@@ -3,8 +3,6 @@
use codewhale_models::{ContentBlock, Message, Role, Usage};
use std::time::Duration;
pub(crate) const MAX_RLM_ITERATIONS: u32 = 25;
pub(crate) const MAX_CONSECUTIVE_NO_CODE: u32 = 3;
pub(crate) const STDOUT_METADATA_PREVIEW_LEN: usize = 800;
const PROMPT_PREVIEW_LEN: usize = 500;
@@ -546,19 +544,25 @@ mod tests {
}
}
/// #6511: an exhausted loop used to return `answer: String::new()` and
/// drop the middle of its history after 20 messages.
#[tokio::test]
async fn exhausted_loop_returns_last_response_and_keeps_whole_history() {
async fn recursive_work_continues_past_twenty_five_rounds_and_keeps_history() {
let mock = Arc::new(MockLlmClient::new(Vec::new()));
for round in 0..MAX_RLM_ITERATIONS {
mock.push_message_response(text_response(&format!(
"```repl\nprint('round {round}')\n```"
)));
let rounds = 26;
let preparation_rounds = 4;
for _ in 0..preparation_rounds {
mock.push_message_response(text_response("I will inspect the computation next."));
}
for round in 0..rounds {
let code = if round < 4 {
format!("```repl\nx = {round}\n```")
} else {
format!("```repl\nprint('round {round}')\n```")
};
mock.push_message_response(text_response(&code));
}
mock.push_message_response(text_response("FINAL('complete')"));
let client: Arc<dyn Replies> = mock.clone();
let (tx, mut rx) = mpsc::channel(1024);
let result = run_fixture(
client,
"root-model".to_string(),
@@ -569,34 +573,27 @@ mod tests {
0,
)
.await;
assert_eq!(result.termination, RlmTermination::Exhausted);
assert_eq!(result.iterations, MAX_RLM_ITERATIONS);
let last_round = MAX_RLM_ITERATIONS - 1;
assert!(
result.answer.contains(&format!("round {last_round}")),
"exhaustion must surface the last root response, got {:?}",
result.answer
);
assert!(
result
.error
.as_deref()
.is_some_and(|e| e.contains("exhausted")),
assert_eq!(
result.termination,
RlmTermination::Final,
"{:?}",
result.error
);
assert_eq!(result.iterations, rounds + preparation_rounds + 1);
assert_eq!(result.answer, "complete");
assert!(result.error.is_none(), "{:?}", result.error);
let requests = mock.captured_requests();
assert_eq!(requests.len(), MAX_RLM_ITERATIONS as usize);
// Initial metadata plus (code, result) per completed round: nothing
// from the middle is dropped.
let last = requests.last().expect("last root request");
assert_eq!(requests.len(), (rounds + preparation_rounds + 1) as usize);
assert_eq!(
last.messages.len(),
1 + 2 * (MAX_RLM_ITERATIONS as usize - 1)
requests
.last()
.expect("last request")
.messages
.iter()
.filter(|message| message.role == Role::Assistant)
.count(),
(rounds + preparation_rounds) as usize
);
let mut statuses = Vec::new();
while let Ok(event) = rx.try_recv() {
if let Event::Status { message } = event {
@@ -606,8 +603,8 @@ mod tests {
assert!(
statuses
.iter()
.any(|line| line.contains("RLM finished: Exhausted")),
"the loop must log how it ended: {statuses:#?}"
.any(|line| line.contains("RLM finished: Final")),
"{statuses:#?}"
);
}
@@ -669,7 +666,7 @@ mod tests {
mode: crate::core::engine::rlm_host::RlmMode::Recursive { depth_remaining: 0 },
max_tokens: None,
task_instructions: None,
deadline: tokio::time::Instant::now() + Duration::from_secs(5),
deadline: Some(tokio::time::Instant::now() + Duration::from_secs(5)),
gate: Some(crate::tools::codemode::NestedCallGate::admitting_for_test()),
events: Some(tx),
usage: usage.clone(),
@@ -782,7 +779,7 @@ mod tests {
mode: crate::core::engine::rlm_host::RlmMode::Completion,
max_tokens: None,
task_instructions: None,
deadline: tokio::time::Instant::now() + Duration::from_secs(1),
deadline: Some(tokio::time::Instant::now() + Duration::from_secs(1)),
gate: None,
events: Some(tx.clone()),
usage: usage.clone(),
+2 -1
View File
@@ -7,6 +7,7 @@ use codewhale_models::{DEFAULT_COMPACTION_TOKEN_THRESHOLD, context_window_for_mo
/// Safe ordinary API request cap across provider routes.
const API_MAX_OUTPUT_TOKENS: u32 = 65_536;
pub(crate) const MIN_REQUEST_OUTPUT_TOKENS: u64 = 128;
/// Automatic request cap on a first-party DeepSeek route at max reasoning
/// effort, where DeepSeek documents 128K instead of the 64K thinking-mode
@@ -530,7 +531,7 @@ pub(crate) fn route_admission_input_ceiling(
ContextBudget::new_with_input_limit(
u64::from(route_context_window_tokens(provider, model, route_limits)),
0,
crate::tools::subagent::budget_handback::MIN_HAND_BACK_OUTPUT,
MIN_REQUEST_OUTPUT_TOKENS,
route_input_limit_tokens(route_limits).map(u64::from),
)
.input_budget_ceiling
+1 -1
View File
@@ -243,7 +243,7 @@ fn mode_event_body(mode: codewhale_config::AppMode) -> &'static str {
///
/// KV-cache effect: append-only user history; the system prompt stays
/// byte-identical across modes. The engine records it when a session starts
/// in or enters Plan, and again whenever the mode then differs from the last
/// in or enters Plan or Operate, and again whenever the mode differs from the last
/// recorded notice, so a notice in history is never stale.
pub(crate) fn mode_runtime_message(mode: codewhale_config::AppMode) -> Message {
runtime_handoff_message_with_meta(
+1 -1
View File
@@ -41,7 +41,7 @@ use sha2::{Digest, Sha256};
/// person; the exact generation-16 body allows a safe upgrade.
/// Generation 18 recognizes previously shipped bodies by SHA-256 as well as by
/// retained bytes, so an untouched copy from any earlier generation upgrades.
const BUNDLED_SKILL_VERSION: &str = "18";
const BUNDLED_SKILL_VERSION: &str = "19";
// ── system & extension (meta) ───────────────────────────────────────────────
const SKILL_CREATOR_BODY: &str = include_str!("../../assets/skills/skill-creator/SKILL.md");
@@ -13,10 +13,12 @@ delegate 169640cc236cf9eaae852843eb0f06c4f093f20e8fceb6b7a780ebf8f805d302
delegate 17761c11d839a0397c06d007611a997cc9dddea6b9432f04352c200d6d916d79
delegate 1ee5e6672534c0141e366c05f00fc51ecae694fae7058b8caeb41edbf4fec7a1
delegate 20f7e79a527191e8a8b62d6a280ffbe4400b3696aeffae4aa166cebb97748f22
delegate 21e4305ff0784dedad977d0d31257b83772eccc05a6502035ecd9710a8005f62
delegate 2573d63cf16bd8c0ccdbeb51cd3c2905c668c45dc17d0f82ad2883655e959d59
delegate 5cc2d1ed3371ff94b325ac3ac77f0f2ab865696b5bb4ff8a85f7e819d261e630
delegate 64a525e36bbc5a1162952e2712cb704ce08d4aa9c51f5ea66793473771d3f86e
delegate 6dde65205c48817d64bbf1cda141418620a646afeb49cdacaa870d3b67379673
delegate 754eadf37d769b43a01be1fac8aa79d5ce5d21f4a987cfc68e1c8cd9fafccfb6
delegate a4042f751e6e77edd191a3c9ab9a6a224a89de07728e65852d10c8324eca9658
delegate c9cd48fbc33d7571513f0cdf0c8719b3df1e83d76fbb9666912adb2745cc6a58
delegate e42413bd3ee2ddc15bc8ae890c84701d8d8641aed77bcb8f64b2b87248e80945
+40
View File
@@ -378,6 +378,46 @@ fn outdated_marker_triggers_reinstall_of_existing_skills() {
}
let ver = fs::read_to_string(marker_file(&tmp)).unwrap();
assert_eq!(ver.trim(), BUNDLED_SKILL_VERSION);
let old = DELEGATE_BODY
.replace("Give each child a concrete outcome and acceptance criteria. Let it continue until the work is complete or it needs input. Do not invent time, turn or token budgets, or tell it to stop early because a percentage of a budget has elapsed. The runtime carries any limits explicitly configured by the owner.\n\n", "")
.replace(
"Verify with the existing checks that provider-specific API key changes do not restart provider onboarding.",
"Add a regression test showing that provider-specific API key changes do not restart provider onboarding.",
);
assert_eq!(
body_sha256(&old),
"21e4305ff0784dedad977d0d31257b83772eccc05a6502035ecd9710a8005f62"
);
for customized in [false, true] {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(skill_dir(&tmp, "delegate")).unwrap();
let body = if customized {
format!("{old}\nMy instructions.\n")
} else {
old.clone()
};
fs::write(skill_file(&tmp, "delegate"), &body).unwrap();
fs::write(marker_file(&tmp), "18").unwrap();
install_system_skills(tmp.path()).unwrap();
assert_eq!(
fs::read_to_string(skill_file(&tmp, "delegate")).unwrap(),
if customized {
body
} else {
DELEGATE_BODY.to_string()
},
);
assert!(!skill_file(&tmp, "debug").exists());
assert_eq!(
fs::read_to_string(marker_file(&tmp)).unwrap().trim(),
if customized {
"18"
} else {
BUNDLED_SKILL_VERSION
}
);
}
}
// ── partial previous install ─────────────────────────────────────────────
-1
View File
@@ -85,7 +85,6 @@ pub struct TurnStopDiagnostics {
/// reasoning and no tool call (#6310): an exact-prefix retry, then a
/// nudged one, before the turn fails visibly.
pub empty_stop_retries: u32,
pub soft_landing_sent: bool,
pub final_report_requested: bool,
pub permission_strategy_switches: u32,
/// Denied provider-response batches since the latest useful progress.
@@ -76,7 +76,6 @@ pub(crate) fn tool_snapshot(
stream_resumes: terminal.stream_resumes,
reasoning_only_reprompts: terminal.reasoning_only_reprompts,
empty_stop_retries: terminal.empty_stop_retries,
soft_landing_sent: terminal.soft_landing_sent,
final_report_requested: terminal.final_report_requested,
permission_strategy_switches: terminal.permission_strategy_switches,
permission_denial_rounds_without_progress: terminal
+29 -50
View File
@@ -19,6 +19,7 @@ use serde_json::{Value, json};
use crate::repl::PythonRuntime;
use crate::rlm::RlmBridge;
use crate::rlm::bridge::wait_for_deadline;
use crate::rlm::session::{
ContextMeta, OutputFeedback, RlmSession, derive_session_name, write_context_file,
};
@@ -253,10 +254,6 @@ impl ToolSpec for RlmTool {
"enum": ["full", "metadata"],
"description": "(action=configure)"
},
"sub_query_timeout_secs": {
"type": "integer",
"description": "(action=configure)"
},
"sub_rlm_max_depth": {
"type": "integer",
"minimum": 0,
@@ -364,10 +361,8 @@ impl RlmTool {
input: &Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let deadline = context.turn_deadline.unwrap_or_else(|| {
tokio::time::Instant::now() + crate::tools::subagent::DEFAULT_CHILD_WALL_TIME
});
if tokio::time::Instant::now() >= deadline {
let deadline = context.turn_deadline;
if deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
return Err(ToolError::execution_failed(
"RLM parent turn deadline exhausted before opening context",
));
@@ -464,9 +459,8 @@ impl RlmTool {
std::future::pending::<()>().await;
}
} => Err(ToolError::cancelled("RLM originating turn cancelled while opening context")),
result = tokio::time::timeout_at(deadline, open) => result.map_err(|_| {
ToolError::execution_failed("RLM parent turn deadline exhausted opening context")
})?,
() = wait_for_deadline(deadline) => Err(ToolError::execution_failed("RLM parent turn deadline exhausted opening context")),
result = open => result,
}
}
@@ -483,10 +477,8 @@ impl RlmTool {
Example: {\"name\": \"<ctx>\", \"code\": \"print(len(content))\"}; call FINAL(value) to return a result handle.",
)
})?;
let deadline = context.turn_deadline.unwrap_or_else(|| {
tokio::time::Instant::now() + crate::tools::subagent::DEFAULT_CHILD_WALL_TIME
});
if tokio::time::Instant::now() >= deadline {
let deadline = context.turn_deadline;
if deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
return Err(ToolError::execution_failed(
"RLM parent turn deadline exhausted before execution",
));
@@ -502,12 +494,11 @@ impl RlmTool {
let mut session = tokio::select! {
biased;
() = wait_for_cancel() => return Err(ToolError::cancelled("RLM originating turn cancelled while waiting for context")),
result = tokio::time::timeout_at(deadline, async {
() = wait_for_deadline(deadline) => return Err(ToolError::execution_failed("RLM parent turn deadline exhausted waiting for context")),
result = async {
let session = get_session(context, name).await?;
Ok::<_, ToolError>(session.lock_owned().await)
}) => result.map_err(|_| {
ToolError::execution_failed("RLM parent turn deadline exhausted waiting for context")
})??,
} => result?,
};
let config = session.config.clone();
@@ -527,19 +518,15 @@ impl RlmTool {
let (round, child_usage_batch, nested_events) = if let Some(caller) =
context.rlm_caller.as_deref()
{
let bridge = RlmBridge::new(
caller,
config.sub_rlm_max_depth.min(HARD_SUB_RLM_DEPTH_CAP),
Duration::from_secs(config.sub_query_timeout_secs),
)
.with_deadline(Some(deadline))
.with_gate(context.execution.nested_call_gate.clone());
let bridge =
RlmBridge::new(caller, config.sub_rlm_max_depth.min(HARD_SUB_RLM_DEPTH_CAP))
.with_deadline(deadline)
.with_gate(context.execution.nested_call_gate.clone());
let round_result = tokio::select! {
biased;
() = wait_for_cancel() => Err("RLM evaluation cancelled by its originating turn".into()),
result = tokio::time::timeout_at(bridge.deadline(), kernel.run(code, Some(&bridge))) => {
result.unwrap_or_else(|_| Err("RLM evaluation reached the parent turn deadline".into()))
},
() = wait_for_deadline(bridge.deadline()) => Err("RLM evaluation reached the parent turn deadline".into()),
result = kernel.run(code, Some(&bridge)) => result,
};
let usage = bridge.usage_snapshot().await;
let round = match round_result {
@@ -581,12 +568,8 @@ impl RlmTool {
let round = tokio::select! {
biased;
() = wait_for_cancel() => return Err(ToolError::cancelled("RLM evaluation cancelled by its originating turn")),
result = tokio::time::timeout_at(deadline, kernel.run(code, None::<&RlmBridge<'_>>)) => {
match result {
Ok(result) => result.map_err(|e| ToolError::execution_failed(format!("rlm_eval: {e}")))?,
Err(_) => return Err(ToolError::execution_failed("RLM evaluation reached the parent turn deadline")),
}
},
() = wait_for_deadline(deadline) => return Err(ToolError::execution_failed("RLM evaluation reached the parent turn deadline")),
result = kernel.run(code, None::<&RlmBridge<'_>>) => result.map_err(|e| ToolError::execution_failed(format!("rlm_eval: {e}")))?,
};
(
round,
@@ -734,9 +717,6 @@ impl RlmTool {
}
};
}
if let Some(timeout) = input.get("sub_query_timeout_secs").and_then(Value::as_u64) {
session.config.sub_query_timeout_secs = timeout.clamp(1, 600);
}
if let Some(depth) = input.get("sub_rlm_max_depth").and_then(Value::as_u64) {
session.config.sub_rlm_max_depth = (depth as u32).min(HARD_SUB_RLM_DEPTH_CAP);
}
@@ -744,11 +724,17 @@ impl RlmTool {
session.config.share_session = false;
}
ToolResult::json(&json!({
let mut result = json!({
"name": session.name,
"current_config": session.config,
}))
.map_err(|e| ToolError::execution_failed(e.to_string()))
});
if input.get("sub_query_timeout_secs").is_some() {
result["ignored_limit_fields"] = json!(["sub_query_timeout_secs"]);
result["limits_note"] = json!(
"Recursive work inherits the owner's deadline; model-selected subquery time limits are retired."
);
}
ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
}
async fn execute_close(
@@ -835,7 +821,6 @@ fn legacy_action_schema(action: &str) -> Value {
"properties": {
"name": { "type": "string" },
"output_feedback": { "type": "string", "enum": ["full", "metadata"] },
"sub_query_timeout_secs": { "type": "integer" },
"sub_rlm_max_depth": { "type": "integer", "minimum": 0, "maximum": 3 },
"share_session": { "type": "boolean" }
}
@@ -1501,14 +1486,8 @@ mod tests {
let mut context = ToolContext::new(temp.path());
let cancel = tokio_util::sync::CancellationToken::new();
context.cancel_token = Some(cancel.clone());
context.turn_deadline = Some(
tokio::time::Instant::now()
+ if cancelled {
Duration::from_secs(30)
} else {
Duration::from_millis(40)
},
);
context.turn_deadline =
(!cancelled).then(|| tokio::time::Instant::now() + Duration::from_millis(40));
let sessions = context.runtime.rlm_sessions.lock().await;
let input = json!({"action": "open", "name": "never-installed", "content": "fixture"});
let mut open = tool.execute(input, &context);
+10 -173
View File
@@ -1,59 +1,14 @@
//! A bounded final report inside the existing worker's turn loop. Runs stop
//! on wall time, steps, cancellation, or completion — never on token
//! accounting (#6189); the report turn is sized by a fixed allowance, not by
//! what a budget has left.
use super::*;
pub(super) const MAX_HAND_BACK_TOKENS: u64 = 8_192;
const MAX_HAND_BACK_OUTPUT: u32 = 1_024;
pub(crate) const MIN_HAND_BACK_OUTPUT: u64 = 128;
const MAX_HAND_BACK_TIME: Duration = Duration::from_secs(10);
pub(super) fn wall_deadlines(runtime: &SubAgentRuntime) -> (Option<Instant>, Option<Instant>) {
let hard = runtime.worker_profile.wall_deadline_ms.map(|deadline| {
Instant::now() + Duration::from_millis(deadline.saturating_sub(epoch_millis_now()))
});
let reserve =
Duration::from_millis(runtime.worker_profile.wall_time_secs.unwrap_or(0).min(100) * 100);
(
hard.and_then(|deadline| deadline.checked_sub(reserve)),
hard,
)
}
impl SubAgentManager {
pub(super) fn reserve_handback(
&mut self,
worker: &str,
input_tokens: u64,
output_cap: u32,
) -> std::result::Result<(u32, Arc<u64>), &'static str> {
if self
.worker_records
.get(worker)
.is_none_or(|record| record.status.is_terminal())
{
return Err("worker is no longer active");
}
self.handback_reservations
.retain(|_, value| value.strong_count() > 0);
if self.handback_reservations.contains_key(worker) {
return Err("a hand-back turn is already in flight");
}
let output = MAX_HAND_BACK_TOKENS
.saturating_sub(input_tokens)
.min(u64::from(output_cap));
if output < MIN_HAND_BACK_OUTPUT {
return Err("the fixed hand-back allowance cannot cover the report input and output");
}
let reservation = Arc::new(input_tokens.saturating_add(output));
self.handback_reservations
.insert(worker.to_string(), Arc::downgrade(&reservation));
Ok((
u32::try_from(output).expect("bounded to model output cap"),
reservation,
))
}
pub(super) fn wall_deadline(runtime: &SubAgentRuntime) -> Option<Instant> {
runtime
.worker_profile
.wall_deadline_ms
.and_then(|deadline| {
Instant::now().checked_add(Duration::from_millis(
deadline.saturating_sub(epoch_millis_now()),
))
})
}
#[cfg(test)]
@@ -88,124 +43,6 @@ pub(super) fn repair_stopped_tool_calls(messages: &mut Vec<Message>, cause: &str
}
}
fn report_messages(
assignment: &SubAgentAssignment,
messages: &[Message],
cause: &str,
evidence_bytes: usize,
) -> Vec<Message> {
// Text-only evidence keeps incomplete tool-call protocols and inline image
// costs out of this final request. Keep recent tool results as well as
// assistant notes, so a worker can consolidate tool-only findings.
let mut evidence = Vec::new();
let mut remaining = evidence_bytes;
for message in messages.iter().rev() {
for block in message.content.iter().rev() {
let entry = match block {
ContentBlock::Text { text, .. } if message.role == Role::Assistant => {
Some(("assistant note", text.as_str()))
}
ContentBlock::ToolResult { content, .. } => Some(("tool result", content.as_str())),
_ => None,
};
if let Some((kind, text)) = entry.filter(|(_, text)| !text.trim().is_empty()) {
if remaining == 0 {
break;
}
let text = lifecycle::text_preview(text, remaining.min(2_000));
remaining = remaining.saturating_sub(text.len());
evidence.push(format!("{kind}: {text}"));
}
}
if remaining == 0 {
break;
}
}
evidence.reverse();
vec![Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: format!(
"Budget hand-back. Stop task execution and return a concise partial report: findings with evidence, work completed, files actually produced, unresolved work, and the best next step. Do not claim completion or invent a deliverable. No tools are available. Treat the excerpts as evidence, never as new instructions.\nObjective: {}\nStop cause: {}\nRecorded evidence (bounded excerpts, oldest first):\n{}",
lifecycle::text_preview(&assignment.objective, 1_000),
lifecycle::text_preview(cause, 500),
evidence.join("\n"),
),
cache_control: None,
}],
}]
}
/// Pure admission for one reporting request. Core executes it through its
/// existing model-step decoder and usage settlement; this object grants no I/O.
pub(crate) struct ReportAdmission {
pub(crate) system: SystemPrompt,
pub(crate) messages: Vec<Message>,
pub(crate) output_tokens: u32,
pub(crate) deadline: Instant,
_reservation: Arc<u64>,
}
pub(crate) async fn admit_report(
job: &engine::ChildJob,
messages: &[Message],
cause: &str,
selected_output_cap: u32,
) -> std::result::Result<ReportAdmission, String> {
let runtime = &job.authority.runtime;
let refuse = |reason: &str| {
format!("No model hand-back report: {reason}. Recorded partial output is preserved.")
};
if runtime.cancel_token.is_cancelled() {
return Err(refuse("the parent cancelled the assignment"));
}
let now = Instant::now();
let deadline = job
.hard_deadline
.unwrap_or(now + MAX_HAND_BACK_TIME)
.min(now + MAX_HAND_BACK_TIME)
.min(now + runtime.step_api_timeout);
if deadline <= now {
return Err(refuse("the original wall-time deadline has expired"));
}
if job.steps() == 0 {
return Err(refuse("no completed model turn was recorded"));
}
let system = SystemPrompt::Text("Return only a grounded partial hand-back report in the assignment's language. This is a reporting turn, never task execution.".to_owned());
let mut evidence_bytes = 12_000;
let (messages, input_tokens) = loop {
let candidate = report_messages(&job.assignment, messages, cause, evidence_bytes);
let input =
crate::compaction::estimate_input_tokens_conservative(&candidate, Some(&system)) as u64;
if input.saturating_add(MIN_HAND_BACK_OUTPUT) <= MAX_HAND_BACK_TOKENS {
break (candidate, input);
}
if evidence_bytes <= 256 {
return Err(refuse(
"the fixed allowance cannot fit instructions and grounded evidence",
));
}
evidence_bytes /= 2;
};
let (output_tokens, reservation) = runtime
.manager
.write()
.await
.reserve_handback(
&job.authority.owner_agent_id,
input_tokens,
selected_output_cap.min(MAX_HAND_BACK_OUTPUT),
)
.map_err(refuse)?;
Ok(ReportAdmission {
system,
messages,
output_tokens,
deadline,
_reservation: reservation,
})
}
const HANDBACK_DIGEST_DIR: &str = "subagent-results";
/// Private state-root path of a child's budget-death result artifact
@@ -219,7 +56,7 @@ fn digest_artifact_relative_path(agent_id: &str) -> PathBuf {
}
/// Record the child's budget-death deliverable as a private file under the
/// manager state root (#6536). The Core hand-back stores its retained result
/// manager state root (#6536). The host stores its retained result
/// here before terminal delivery. Blocking IO runs under `spawn_blocking`.
///
/// Known limitation: the file outlives the agent record; removing an agent
@@ -5,7 +5,7 @@ use tempfile::{TempDir, tempdir};
use tokio::sync::Notify;
struct Fixture {
workspace: TempDir,
_workspace: TempDir,
manager: SharedSubAgentManager,
task: Option<JoinHandle<()>>,
server: JoinHandle<()>,
@@ -44,8 +44,6 @@ async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixtu
// Only these cases exercise wall/API timeouts. The other cases exercise
// budget and report semantics, so leave room for full-suite scheduling.
let timeout_case = matches!(mode, "hold" | "timeout" | "work-timeout");
// "work-timeout" narrows its own deadline below; its 30s budget sizes the
// hand-back reserve and must not undercut that deadline.
let wall_time_secs = if matches!(mode, "hold" | "timeout") {
5
} else {
@@ -74,10 +72,10 @@ async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixtu
requests.push(body.clone());
requests.len()
};
let choice = if call == 1 {
let choice = if call == 1 || mode == "tool-only" {
json!({"index": 0, "message": {"role": "assistant",
"content": if mode == "tool-only" { Value::Null } else { json!("RECORDED_FINDING: checksum validation is missing.") },
"tool_calls": [{"id": "read-one", "type": "function", "function": {
"tool_calls": [{"id": format!("read-{call}"), "type": "function", "function": {
"name": "read", "arguments": "{\"path\":\"README.md\"}"
}}]}, "finish_reason": "tool_calls"})
} else {
@@ -138,21 +136,6 @@ async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixtu
spec.runtime_profile.wall_time_secs = Some(wall_time_secs);
spec.runtime_profile.wall_deadline_ms = Some(epoch_millis_now() + wall_time_secs * 1_000);
if mode == "work-timeout" {
// A partly consumed original deadline leaves time to persist the
// missing-coverage receipt after the in-flight call is abandoned.
//
// The hand-back window is the reserve `wall_deadlines` carves off the
// hard deadline: `wall_time_secs * 100ms`. The shared 5s budget made
// that only 500ms, which had to cover the digest artifact, the
// unreported-usage state write, the pre-report checkpoint and the
// loopback connect before the report reached the server. On Windows
// CI it did not fit, so the report fell back without reaching the
// server (2 requests, not 3). A 30s budget reserves 3s. The work
// deadline lands 6s in: the first call, its tool, and the dispatch of
// the second call must all fit before it, and a loaded shared-process
// `cargo test --workspace` run overran the earlier 2s (#6698). The
// step API timeout below is longer than that, so the in-flight call
// is still abandoned by wall time, not by a step timeout.
spec.runtime_profile.wall_deadline_ms = Some(epoch_millis_now() + 9_000);
}
spec.launch_manifest = Some(serde_json::from_value(json!({
@@ -177,8 +160,6 @@ async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixtu
runtime.step_api_timeout = if mode == "timeout" {
Duration::from_millis(100)
} else if mode == "work-timeout" {
// Past the 6s work deadline, and bounding the held hand-back call
// no tighter than the 9s hard deadline already does.
Duration::from_secs(10)
} else if timeout_case {
Duration::from_secs(2)
@@ -224,14 +205,14 @@ async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixtu
fork_context: false,
started_at: Instant::now(),
max_steps,
wall_time: Duration::from_secs(wall_time_secs),
wall_time: Some(Duration::from_secs(wall_time_secs)),
wall_ceiling_ms: None,
input_rx,
launch_gate: None,
_foreground_child_registration: None,
}));
Fixture {
workspace,
_workspace: workspace,
manager,
task: Some(task),
server,
@@ -246,15 +227,15 @@ async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixtu
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn budget_handback_turn_consolidates_tool_only_work_and_checks_declared_deliverables() {
async fn owner_step_limit_preserves_tool_only_work_without_an_extra_request() {
let _retry = crate::retry_status::test_guard();
crate::retry_status::clear_rate_limit();
let mut fixture = fixture("tool-only", 15, 1).await;
let mut fixture = fixture("tool-only", 15, 2).await;
let result = fixture.finish().await;
assert_eq!(result.status, SubAgentStatus::BudgetExhausted);
assert_eq!(result.steps_taken, 2);
assert_eq!(result.usage.as_ref().unwrap().total_tokens, Some(45));
assert!(result.result.as_deref().unwrap().contains("PARTIAL_REPORT"));
assert!(result.result.as_deref().unwrap().contains("README.md"));
assert!(
result
.checkpoint
@@ -265,7 +246,7 @@ async fn budget_handback_turn_consolidates_tool_only_work_and_checks_declared_de
.flat_map(|message| &message.content)
.any(
|block| matches!(block, ContentBlock::ToolResult { tool_use_id, content, .. }
if tool_use_id == "read-one" && content.contains("TOOL_EVIDENCE"))
if tool_use_id == "read-1" && content.contains("TOOL_EVIDENCE"))
),
"the read must execute successfully before reporting: {:?}",
result.checkpoint,
@@ -280,20 +261,6 @@ async fn budget_handback_turn_consolidates_tool_only_work_and_checks_declared_de
.iter()
.any(|tool| tool["function"]["name"] == "read")
);
assert_eq!(requests[1]["model"], requests[0]["model"]);
assert!(requests[1].get("tools").is_none_or(Value::is_null));
assert!(requests[1].get("tool_choice").is_none_or(Value::is_null));
assert!(
requests[1].to_string().contains("TOOL_EVIDENCE"),
"report must read actual completed tool output"
);
assert!(
requests[1]["max_tokens"]
.as_u64()
.or_else(|| requests[1]["max_completion_tokens"].as_u64())
.unwrap()
<= 1_024
);
drop(requests);
let guard = fixture.manager.read().await;
let worker = &guard.worker_records["report-worker"];
@@ -310,17 +277,14 @@ async fn budget_handback_turn_consolidates_tool_only_work_and_checks_declared_de
assert!(fixture.completions.try_recv().is_err());
}
/// #6536 — the provider truncates the hand-back report. The deterministic
/// digest recorded before that turn stays the deliverable: in the result
/// text `agent result` / `agent wait` return, and as a private file.
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn budget_handback_truncated_report_leaves_the_digest_as_the_deliverable() {
async fn owner_step_limit_keeps_the_digest_as_the_deliverable() {
let _retry = crate::retry_status::test_guard();
crate::retry_status::clear_rate_limit();
let mut fixture = fixture("truncated", 15, 1).await;
let result = fixture.finish().await;
assert_eq!(fixture.requests.lock().unwrap().len(), 2);
assert_eq!(fixture.requests.lock().unwrap().len(), 1);
assert_eq!(result.status, SubAgentStatus::BudgetExhausted);
let text = result.result.as_deref().unwrap();
assert!(
@@ -328,7 +292,6 @@ async fn budget_handback_truncated_report_leaves_the_digest_as_the_deliverable()
"digest is the result: {text}"
);
assert!(!text.contains("TRUNCATED_REPORT"), "{text}");
assert!(text.contains("did not finish"), "{text}");
assert!(text.contains("this child's deliverable"), "{text}");
let state_root = fixture.manager.read().await.state_root.clone();
@@ -346,137 +309,6 @@ async fn budget_handback_truncated_report_leaves_the_digest_as_the_deliverable()
assert!(text.contains(&artifact.display().to_string()), "{text}");
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn budget_handback_turn_rejects_provider_tools_and_preserves_fallback_verdicts() {
let _retry = crate::retry_status::test_guard();
crate::retry_status::clear_rate_limit();
let mut fixture = fixture("tool", 15, 1).await;
let result = fixture.finish().await;
assert_eq!(fixture.requests.lock().unwrap().len(), 2);
assert_eq!(result.status, SubAgentStatus::BudgetExhausted);
assert!(!fixture.workspace.path().join("report.md").exists());
assert!(
result
.result
.as_deref()
.unwrap()
.contains("provider returned a tool call")
);
assert!(
!result
.result
.as_deref()
.unwrap()
.contains("REJECTED_REPORT")
);
assert_eq!(result.usage.as_ref().unwrap().total_tokens, Some(45));
let history = &result.checkpoint.as_ref().unwrap().messages;
let completed = history
.iter()
.flat_map(|message| &message.content)
.filter_map(|block| match block {
ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
_ => None,
})
.collect::<HashSet<_>>();
for block in history.iter().flat_map(|message| &message.content) {
match block {
ContentBlock::ToolUse { id, .. } => assert!(
completed.contains(id.as_str()),
"no orphan tool call may survive for replay"
),
ContentBlock::ServerToolUse { .. } => {
panic!("a rejected server tool call entered history")
}
ContentBlock::Text { text, .. } => assert!(!text.contains("REJECTED_REPORT")),
_ => {}
}
}
assert!(
history
.iter()
.flat_map(|message| &message.content)
.any(|block| matches!(block,
ContentBlock::Text { text, .. } if text.contains("Host budget hand-back receipt")))
);
assert_eq!(
fixture.manager.read().await.worker_records["report-worker"]
.verification
.status,
"deliverable_missing"
);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn budget_handback_turn_failure_and_timeout_do_not_add_worker_retries() {
let _retry = crate::retry_status::test_guard();
crate::retry_status::clear_rate_limit();
for (mode, reason) in [
("failure", "provider call failed"),
("timeout", "report deadline expired"),
] {
let mut fixture = fixture(mode, 15, 1).await;
let result = fixture.finish().await;
assert_eq!(fixture.requests.lock().unwrap().len(), 2);
assert_eq!(result.status, SubAgentStatus::BudgetExhausted);
assert!(
result.result.as_deref().unwrap().contains(reason),
"{result:?}"
);
assert_eq!(result.usage.as_ref().unwrap().total_tokens, Some(15));
assert_eq!(
fixture.manager.read().await.worker_records["report-worker"].has_unreported_usage,
mode == "timeout",
"only the timed-out dispatched call establishes missing coverage here",
);
assert_eq!(
fixture.manager.read().await.worker_records["report-worker"]
.verification
.status,
"deliverable_missing"
);
}
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn budget_handback_turn_missing_report_usage_is_not_claimed_as_zero_cost() {
let _retry = crate::retry_status::test_guard();
crate::retry_status::clear_rate_limit();
let mut fixture = fixture("report-unknown", 15, 1).await;
let result = fixture.finish().await;
assert_eq!(fixture.requests.lock().unwrap().len(), 2);
assert_eq!(result.status, SubAgentStatus::BudgetExhausted);
assert_eq!(result.usage.as_ref().unwrap().total_tokens, Some(15));
let report = result.result.as_deref().unwrap();
assert!(report.contains("PARTIAL_REPORT"));
assert!(report.contains("only a subtotal, not a zero-cost report"));
let host_receipts: Vec<_> = result
.checkpoint
.as_ref()
.unwrap()
.messages
.iter()
.flat_map(|message| &message.content)
.filter_map(|block| match block {
ContentBlock::Text { text, .. }
if text.starts_with("Host budget hand-back receipt:") =>
{
Some(text)
}
_ => None,
})
.collect();
assert_eq!(
host_receipts.len(),
1,
"one attributed canonical report outcome"
);
assert!(host_receipts[0].contains("unreported provider usage remains unknown"));
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn budget_handback_inflight_wall_timeout_persists_unreported_usage() {
@@ -494,8 +326,8 @@ async fn budget_handback_inflight_wall_timeout_persists_unreported_usage() {
);
assert_eq!(
fixture.requests.lock().unwrap().len(),
3,
"the reserved hand-back turn still dispatches after an unmeasured in-flight request; its own deadline bounds it"
2,
"the owner deadline does not admit another model request for reporting"
);
let manager = fixture.manager.read().await;
assert!(manager.worker_records["report-worker"].has_unreported_usage);
@@ -529,8 +361,6 @@ fn budget_handback_coverage_marker_is_sticky_without_reclassifying_legacy_or_mea
let legacy: AgentWorkerRecord = serde_json::from_value(legacy).unwrap();
assert!(!legacy.has_unreported_usage);
manager.worker_records.insert("worker".to_string(), legacy);
let (_output, lease) = manager.reserve_handback("worker", 500, 1_024).unwrap();
drop(lease);
manager.record_worker_usage("worker", "missing", &Usage::default(), None);
manager.record_worker_usage(
"worker",
@@ -553,7 +383,7 @@ fn budget_handback_coverage_marker_is_sticky_without_reclassifying_legacy_or_mea
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn budget_handback_turn_cancellation_wins_once_and_releases_shared_reservation() {
async fn child_work_cancellation_wins_once() {
let _retry = crate::retry_status::test_guard();
crate::retry_status::clear_rate_limit();
let mut fixture = fixture("hold", 15, 2).await;
@@ -574,20 +404,11 @@ async fn budget_handback_turn_cancellation_wins_once_and_releases_shared_reserva
.contains("cancelled")
);
assert!(fixture.completions.try_recv().is_err());
assert!(
fixture
.manager
.read()
.await
.handback_reservations
.values()
.all(|value| value.upgrade().is_none())
);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn budget_handback_turn_cancellation_after_response_preserves_actual_usage() {
async fn child_work_cancellation_after_response_preserves_actual_usage() {
let _retry = crate::retry_status::test_guard();
crate::retry_status::clear_rate_limit();
let mut fixture = fixture("hold", 15, 2).await;
@@ -612,7 +433,7 @@ async fn budget_handback_turn_cancellation_after_response_preserves_actual_usage
&& usage_total_tokens(&usage) == 30
{
assert!(source_id.starts_with("child:report-worker:turn:"));
assert!(source_id.contains(":request:1:dispatch:"));
assert!(source_id.contains(":request:2:dispatch:"));
break;
}
}
@@ -636,26 +457,8 @@ async fn budget_handback_turn_cancellation_after_response_preserves_actual_usage
assert!(fixture.completions.try_recv().is_err());
}
#[test]
fn handback_reservation_uses_the_fixed_allowance_and_refuses_a_second_turn() {
let tmp = tempdir().unwrap();
let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 4);
manager.register_worker(make_worker_spec("w", tmp.path().to_path_buf()));
let (output, lease) = manager.reserve_handback("w", 500, 1_024).unwrap();
assert_eq!(output, 1_024);
assert!(matches!(
manager.reserve_handback("w", 500, 1_024),
Err(reason) if reason.contains("already in flight")
));
drop(lease);
assert!(matches!(
manager.reserve_handback("w", 8_200, 1_024),
Err(reason) if reason.contains("fixed hand-back allowance")
));
}
#[tokio::test]
async fn budget_handback_expired_original_deadline_refuses_the_model_call() {
async fn child_job_preserves_the_original_deadline_without_a_reserve() {
let tmp = tempdir().unwrap();
let mut runtime = stub_runtime();
runtime.context = ToolContext::new(tmp.path().to_path_buf());
@@ -686,19 +489,12 @@ async fn budget_handback_expired_original_deadline_refuses_the_model_call() {
)
.await
.unwrap();
let outcome =
budget_handback::admit_report(&job, &[], "wall-time budget exhausted", 1024).await;
assert!(matches!(outcome, Err(ref why) if why.contains("deadline has expired")));
assert!(
job.deadline
.is_some_and(|deadline| deadline <= Instant::now())
);
assert_eq!(job.steps(), 0, "no model turn was admitted");
assert!(!runtime.manager.read().await.worker_records["expired"].has_unreported_usage);
assert!(
runtime
.manager
.read()
.await
.handback_reservations
.is_empty()
);
}
fn git(root: &Path, args: &[&str]) {
+19 -42
View File
@@ -313,7 +313,7 @@ impl ChildAuthority {
where
F: std::future::Future<Output = std::result::Result<T, ToolError>>,
{
let (deadline, _) = budget_handback::wall_deadlines(&self.runtime);
let deadline = budget_handback::wall_deadline(&self.runtime);
match run_tool_with_person_aware_timeout(
self.runtime.tool_timeout,
deadline,
@@ -986,9 +986,7 @@ pub(crate) struct ChildJob {
pub(crate) assignment: SubAgentAssignment,
pub(crate) started_at: Instant,
pub(crate) max_steps: u32,
pub(crate) work_max_steps: u32,
pub(crate) work_deadline: Option<Instant>,
pub(crate) hard_deadline: Option<Instant>,
pub(crate) deadline: Option<Instant>,
pub(crate) fork_context: bool,
parking: Option<Arc<std::sync::atomic::AtomicBool>>,
artifact: tokio::sync::Mutex<Option<SubAgentTranscriptArtifactWriter>>,
@@ -997,7 +995,6 @@ pub(crate) struct ChildJob {
logical_steps: std::sync::atomic::AtomicU32,
response_received: std::sync::atomic::AtomicBool,
selected_model: std::sync::Mutex<String>,
pacing_sent: std::sync::atomic::AtomicBool,
stop_reason: std::sync::Mutex<Option<String>>,
}
impl ChildJob {
@@ -1009,12 +1006,7 @@ impl ChildJob {
fork_context: bool,
parking: Option<Arc<std::sync::atomic::AtomicBool>>,
) -> Result<Arc<Self>> {
let work_max_steps = if max_steps >= 2 {
max_steps - 1
} else {
max_steps
};
let (work_deadline, hard_deadline) = budget_handback::wall_deadlines(&authority.runtime);
let deadline = budget_handback::wall_deadline(&authority.runtime);
let artifact = SubAgentTranscriptArtifactWriter::for_runtime(
&authority.runtime,
&authority.owner_agent_id,
@@ -1026,9 +1018,7 @@ impl ChildJob {
assignment,
started_at,
max_steps,
work_max_steps,
work_deadline,
hard_deadline,
deadline,
fork_context,
parking,
artifact: tokio::sync::Mutex::new(Some(artifact)),
@@ -1038,7 +1028,6 @@ impl ChildJob {
logical_steps: std::sync::atomic::AtomicU32::new(0),
response_received: std::sync::atomic::AtomicBool::new(false),
selected_model: std::sync::Mutex::new(selected_model),
pacing_sent: std::sync::atomic::AtomicBool::new(false),
stop_reason: std::sync::Mutex::new(None),
}))
}
@@ -1074,21 +1063,6 @@ impl ChildJob {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = model.to_owned();
}
pub(crate) fn pacing_notice(&self) -> Option<String> {
if self.pacing_sent.load(std::sync::atomic::Ordering::Relaxed) {
return None;
}
let notice = child_budget_pacing_notice(
self.started_at,
self.work_deadline,
self.steps(),
self.work_max_steps,
)?;
(!self
.pacing_sent
.swap(true, std::sync::atomic::Ordering::Relaxed))
.then_some(notice)
}
pub(crate) async fn project(&self, messages: &[Message], steps: u32) -> Result<()> {
let mut artifact = self.artifact.lock().await;
if let Some(writer) = artifact.as_mut() {
@@ -1306,7 +1280,7 @@ impl ChildJob {
} else {
None
};
let mut handback_note = "The bounded Core hand-back preserves the recorded work; the assignment is not complete.".to_string();
let mut handback_note = "The host preserves the recorded work without another model request; the assignment is not complete.".to_string();
if let Some(artifact) = artifact {
handback_note.push_str(&format!(
" Recorded work is saved as this child's deliverable: {}.",
@@ -1516,16 +1490,19 @@ pub(super) async fn run_child_agent(
owner_agent_name,
allowed_tools.clone(),
);
let system =
let mut system =
build_subagent_system_prompt_with_skills(&agent_type, &assignment, &runtime.context);
let work_max_steps = if max_steps >= 2 {
max_steps - 1
} else {
max_steps
};
if let Some(schema) = runtime.workflow_response_schema.as_ref() {
system = system
.replace(SUBAGENT_OUTPUT_FORMAT, "")
.replace(WRITE_CHILD_VERIFY_CONTRACT, "");
system.push_str(&format!(
"\n\nFor this read-only Workflow task, return exactly one JSON value matching the following response schema. This replaces the default report sections and Markdown format. Include the requested findings and evidence in that JSON value, with no surrounding fences or prose.\n{schema}"
));
}
let prompt = format!(
"{prompt}\n\n{}",
child_runtime_budget_context(&runtime, max_steps, work_max_steps)
child_runtime_budget_context(&runtime, max_steps)
);
let fork = if fork_context {
match runtime.fork_context.as_ref() {
@@ -1599,7 +1576,7 @@ pub(super) async fn run_child_agent(
let config = crate::core::engine::EngineConfig {
workspace: runtime.context.workspace.clone(),
model: runtime.model.clone(),
max_steps: crate::core::engine::turn_budget::resolve_max_model_steps(Some(work_max_steps)),
max_steps: crate::core::engine::turn_budget::resolve_max_model_steps(Some(max_steps)),
compaction: runtime.compaction.clone(),
subagent_api_timeout: runtime.step_api_timeout,
auto_review_policy: runtime.auto_review_policy.as_ref().clone(),
@@ -1802,7 +1779,7 @@ pub(crate) async fn drive_child_actor(
&agent_id,
event,
&handle.captured_turn_cancel(),
job.hard_deadline.map(Into::into),
job.deadline.map(Into::into),
).await;
}
Event::ToolCallStarted { id, name, .. } => {
@@ -1859,7 +1836,7 @@ pub(crate) async fn drive_child_actor(
&agent_id,
event,
&handle.captured_turn_cancel(),
job.hard_deadline.map(Into::into),
job.deadline.map(Into::into),
)
.await;
}
@@ -1891,7 +1868,7 @@ pub(crate) async fn drive_child_actor(
};
if runtime.cancel_token.is_cancelled() {
let _ = tx.try_send(event);
} else if let Some(deadline) = job.hard_deadline {
} else if let Some(deadline) = job.deadline {
let _ = tokio::time::timeout_at(deadline.into(), tx.send(event)).await;
} else {
let _ = tx.send(event).await;
+46 -40
View File
@@ -84,30 +84,31 @@ fn child_runtime_budget_context_reports_every_resolved_limit() {
let mut runtime = stub_runtime();
runtime.worker_profile.wall_time_secs = Some(1_800);
runtime.worker_profile.wall_deadline_ms = Some(epoch_millis_now() + 1_700_000);
let context = child_runtime_budget_context(&runtime, 50, 49);
assert!(context.contains("Runtime budget (host-enforced"));
let context = child_runtime_budget_context(&runtime, 50);
assert!(context.contains("Runtime limits:"));
assert!(context.contains("wall clock: task work stops about"));
assert!(context.contains("total run budget 30m 00s"));
assert!(context.contains("49 model turns of task work (limit 50"));
assert!(context.contains("reserved hand-back turn"));
assert!(context.contains("Commit or checkpoint work-in-progress early"));
assert!(context.contains("50 model turns"));
assert!(!context.contains("reserved"));
assert!(context.contains("configured owner limits are enforced"));
assert!(context.contains("There is no token budget and no per-step context cap"));
}
#[test]
fn child_runtime_budget_context_names_unbounded_limits_honestly() {
let runtime = stub_runtime();
let context = child_runtime_budget_context(&runtime, 0, 0);
let context = child_runtime_budget_context(&runtime, 0);
assert!(context.contains("wall clock: no wall-clock limit."));
assert!(context.contains("model steps: no per-run step cap."));
assert!(context.contains("There is no token budget"));
assert!(context.contains("reserved hand-back turn"));
assert!(context.contains("Continue until the task is complete"));
assert!(!context.contains("reserved hand-back turn"));
}
#[test]
fn child_runtime_budget_context_tells_the_child_it_compacts_like_the_parent() {
let mut runtime = stub_runtime();
let context = child_runtime_budget_context(&runtime, 0, 0);
let context = child_runtime_budget_context(&runtime, 0);
assert!(
!context.contains("no automatic compaction"),
"children compact like the parent: {context}"
@@ -123,7 +124,7 @@ fn child_runtime_budget_context_tells_the_child_it_compacts_like_the_parent() {
// The parent's opt-out is the child's opt-out, and the prompt says so.
runtime.compaction.enabled = false;
let context = child_runtime_budget_context(&runtime, 0, 0);
let context = child_runtime_budget_context(&runtime, 0);
assert!(
context.contains("Automatic context compaction is disabled for this session"),
"{context}"
@@ -195,26 +196,6 @@ fn child_compaction_config_inherits_the_parent_policy_for_the_child_route() {
);
}
#[test]
fn child_budget_pacing_notice_fires_at_three_quarters_of_each_bound() {
let started_at = Instant::now() - Duration::from_secs(80);
let deadline = started_at + Duration::from_secs(100);
let notice = child_budget_pacing_notice(started_at, Some(deadline), 38, 50)
.expect("all three bounds past 75%");
assert!(notice.contains("kind=\"budget_pacing\""));
assert!(notice.contains("wall clock:"));
assert!(notice.contains("model steps: 38 of 50 used"));
}
#[test]
fn child_budget_pacing_notice_stays_silent_with_headroom() {
let started_at = Instant::now();
let deadline = started_at + Duration::from_secs(100);
assert!(child_budget_pacing_notice(started_at, Some(deadline), 10, 50).is_none());
// No bound at all means there is nothing to pace against.
assert!(child_budget_pacing_notice(started_at, None, 9_999, 0).is_none());
}
#[test]
fn per_call_budget_fields_reject_empty_zero_null_negative_and_oversized_values() {
for field in ["max_steps", "wall_time_secs"] {
@@ -233,8 +214,8 @@ fn per_call_budget_fields_reject_empty_zero_null_negative_and_oversized_values()
}
}
for (field, value) in [
("max_steps", u64::from(MAX_SUBAGENT_STEPS) + 1),
("wall_time_secs", MAX_CHILD_WALL_TIME.as_secs() + 1),
("max_steps", u64::from(u32::MAX) + 1),
("wall_time_secs", u64::MAX / 1_000 + 1),
] {
let mut input = json!({"prompt": "inspect"});
input[field] = json!(value);
@@ -328,7 +309,7 @@ async fn launch_narrows_all_limits_and_continuation_cannot_restart_deadline() {
}
#[tokio::test]
async fn explicit_wall_time_may_exceed_the_built_in_default() {
async fn child_wall_time_is_unbounded_unless_an_owner_sets_it() {
let tmp = tempdir().unwrap();
let manager = Arc::new(RwLock::new(SubAgentManager::new(
tmp.path().to_path_buf(),
@@ -358,18 +339,43 @@ async fn explicit_wall_time_may_exceed_the_built_in_default() {
guard.worker_records[&child.agent_id]
.spec
.runtime_profile
.wall_time_secs
.clone()
};
let mut guard = manager.write().await;
let unbounded = spawn(&mut guard, None);
assert_eq!(unbounded.wall_time_secs, None);
assert_eq!(unbounded.wall_deadline_ms, None);
assert_eq!(
spawn(&mut guard, None),
Some(DEFAULT_CHILD_WALL_TIME.as_secs())
);
assert_eq!(
spawn(&mut guard, Some(Duration::from_secs(4 * 60 * 60))),
Some(4 * 60 * 60),
"an explicit request may raise the built-in 30-minute default"
spawn(&mut guard, Some(Duration::from_secs(48 * 60 * 60))).wall_time_secs,
Some(48 * 60 * 60),
"an owner-selected limit is not shortened by a built-in ceiling"
);
drop(guard);
for owner_steps in [0, 7] {
runtime.worker_profile.max_steps = owner_steps;
let result = AgentTool::new(Arc::clone(&manager), runtime.clone())
.execute(
json!({
"action": "start",
"type": "explore",
"prompt": "inspect",
"max_steps": 1,
"wall_time_secs": 1,
"max_output_tokens": 1,
"max_depth": 0
}),
&runtime.context,
)
.await
.expect("legacy model caps cannot restrict the child");
let receipt: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(receipt["effective_limits"]["max_steps"], owner_steps);
assert!(receipt["effective_limits"]["wall_deadline_ms"].is_null());
assert_eq!(receipt["ignored_limit_fields"].as_array().unwrap().len(), 4);
let guard = manager.read().await;
let record = &guard.worker_records[receipt["agent_id"].as_str().unwrap()];
assert_eq!(record.spec.max_spawn_depth, runtime.max_spawn_depth);
}
}
#[tokio::test]
+158 -208
View File
@@ -274,12 +274,6 @@ fn read_bounded_resident_context(
})
}
/// Positive child model-turn budgets are clamped to this hard ceiling. Zero is
/// the unbounded sentinel used by the default agent loop.
const MAX_SUBAGENT_STEPS: u32 = 2_000;
/// Default wall-clock budget for one child run, including model and tool work.
pub(crate) const DEFAULT_CHILD_WALL_TIME: Duration = Duration::from_secs(30 * 60);
const MAX_CHILD_WALL_TIME: Duration = Duration::from_secs(24 * 60 * 60);
/// Default wall-clock budget for a single sub-agent tool execution. The active
/// value travels on `SubAgentRuntime::tool_timeout` so a long-but-legitimate
/// tool (a large build, a slow shell command, a deep search) is not killed
@@ -308,7 +302,6 @@ fn resolve_max_steps(role: FleetRole, explicit: Option<u32>, configured: Option<
WorkerRuntimeProfile::default_max_steps(role),
narrow_model_steps(configured.unwrap_or(0), explicit.unwrap_or(0)),
)
.min(MAX_SUBAGENT_STEPS)
}
/// A queued child whose launch slot never opened did no work: it is a
@@ -335,11 +328,7 @@ fn child_wall_time_exhausted_reason(limit: Duration) -> String {
/// at spawn — so the transcript artifact records exactly what the model was
/// told, and a continuation re-resolves its own values rather than inheriting
/// a stale copy.
fn child_runtime_budget_context(
runtime: &SubAgentRuntime,
max_steps: u32,
work_max_steps: u32,
) -> String {
fn child_runtime_budget_context(runtime: &SubAgentRuntime, max_steps: u32) -> String {
let wall = match runtime.worker_profile.wall_deadline_ms {
Some(deadline_ms) => {
let remaining = codewhale_command_contract::elapsed::format_elapsed_ms(
@@ -357,10 +346,6 @@ fn child_runtime_budget_context(
};
let steps = if max_steps == 0 {
"no per-run step cap".to_string()
} else if work_max_steps < max_steps {
format!(
"{work_max_steps} model turns of task work (limit {max_steps}; the last turn stays reserved for a bounded hand-back report)"
)
} else {
format!("{max_steps} model turns")
};
@@ -371,9 +356,12 @@ fn child_runtime_budget_context(
} else {
"There is no token budget. Automatic context compaction is disabled for this session, so the model's own context window is the only context limit."
};
format!(
"Runtime budget (host-enforced; you cannot raise it):\n- wall clock: {wall}.\n- model steps: {steps}.\n{context} When a limit is reached, task work stops where it stands and only a small reserved hand-back turn remains to report it. Commit or checkpoint work-in-progress early rather than holding it, and keep a current partial report ready."
)
let stopping = if runtime.worker_profile.wall_deadline_ms.is_some() || max_steps > 0 {
"The configured owner limits are enforced by the host. Preserve completed work and report remaining work if a limit is reached."
} else {
"Continue until the task is complete, needs user input, or is explicitly stopped. Do not invent a deadline or turn budget."
};
format!("Runtime limits:\n- wall clock: {wall}.\n- model steps: {steps}.\n{context} {stopping}")
}
/// The parent session's compaction policy, re-resolved for the route this
@@ -427,46 +415,6 @@ fn child_compaction_config(
}
}
/// One-shot mid-run notice fired when any enforced budget is roughly
/// three-quarters consumed (#6194). Returns `None` while every bound still
/// has headroom or when no bound applies. This is visibility only: it changes
/// no limit and never interrupts a step.
fn child_budget_pacing_notice(
started_at: Instant,
deadline: Option<Instant>,
steps: u32,
work_max_steps: u32,
) -> Option<String> {
let mut consumed = Vec::new();
if let Some(deadline) = deadline {
let total = deadline.saturating_duration_since(started_at);
if total > Duration::ZERO && started_at.elapsed() >= total / 4 * 3 {
let remaining = deadline.saturating_duration_since(Instant::now());
consumed.push(format!(
"wall clock: ~{} remains of ~{}",
codewhale_command_contract::elapsed::format_elapsed_ms(
u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX)
),
codewhale_command_contract::elapsed::format_elapsed_ms(
u64::try_from(total.as_millis()).unwrap_or(u64::MAX)
),
));
}
}
if work_max_steps > 0 && u64::from(steps) * 4 >= u64::from(work_max_steps) * 3 {
consumed.push(format!("model steps: {steps} of {work_max_steps} used"));
}
if consumed.is_empty() {
return None;
}
Some(format!(
"<codewhale:runtime_event kind=\"budget_pacing\" visibility=\"internal\">\n\
This is an internal runtime event, not user input. Roughly three quarters of a run budget is used:\n- {}\n\
Wrap up now: commit or checkpoint work-in-progress and prepare your final report instead of starting new multi-step work.\n\
</codewhale:runtime_event>",
consumed.join("\n- ")
))
}
// Non-streaming sub-agents need enough response budget to carry large tool-call
// arguments, especially write_file content. The API bills generated tokens, not
// the requested ceiling.
@@ -1674,7 +1622,7 @@ fn worker_profile_from_spec(spec: &AgentWorkerSpec) -> WorkerRuntimeProfile {
profile.model = ModelRoute::Fixed(spec.model.clone());
profile.max_spawn_depth = spec.max_spawn_depth;
profile.spawn_depth = spec.spawn_depth;
profile.max_steps = spec.max_steps.min(MAX_SUBAGENT_STEPS);
profile.max_steps = spec.max_steps;
profile.background = true;
profile
}
@@ -1804,9 +1752,9 @@ pub(crate) struct SubAgentSpawnOptions {
/// Per-tool-result output token cap for this child (#6282). `None` uses
/// the runtime default (10k).
pub max_output_tokens: Option<std::num::NonZeroU32>,
/// Optional per-child model-turn override, clamped to the runtime ceiling.
/// Optional owner-supplied per-child model-turn limit.
pub max_steps: Option<u32>,
/// Optional per-child wall-clock override, clamped to the runtime ceiling.
/// Optional owner-supplied per-child wall-clock limit.
pub wall_time: Option<Duration>,
pub write_claim: Option<WriteScopeClaim>,
pub isolated_worktree: bool,
@@ -2339,28 +2287,12 @@ impl PersistedSubAgentState {
}
}
/// Default cap on sub-agent recursion depth. Override via
/// `[subagents] max_depth = N` in config.
///
/// Sourced from [`codewhale_config::DEFAULT_SPAWN_DEPTH`] so standalone
/// sub-agents and fleet workers share ONE recursion axis (no "two moving
/// targets"). Configured/requested depths clamp to
/// [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`].
/// Default delegation depth representation. Explicit operator settings narrow it.
pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = codewhale_config::DEFAULT_SPAWN_DEPTH;
/// Resolve a child runtime's `max_spawn_depth` from its (already-incremented)
/// `spawn_depth` and the model-supplied per-call `max_depth`, clamped to the
/// absolute [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`].
///
/// Without the absolute clamp, `max_spawn_depth = spawn_depth + max_depth`
/// makes the recursion gate (`spawn_depth + 1 > max_spawn_depth`) reduce to
/// `1 > max_depth` at every level — always false when the model re-supplies
/// `max_depth >= 1` per spawn — so ring depth would grow to the global
/// admission cap instead of the intended 8-ring ceiling.
/// Convert an explicit remaining-depth allowance without arithmetic overflow.
fn clamp_child_max_spawn_depth(child_spawn_depth: u32, requested_max_depth: u32) -> u32 {
child_spawn_depth
.saturating_add(requested_max_depth)
.min(codewhale_config::MAX_SPAWN_DEPTH_CEILING)
child_spawn_depth.saturating_add(requested_max_depth)
}
/// Terminal-state notification emitted to the immediate parent's completion
@@ -3043,6 +2975,7 @@ pub struct SubAgentRuntime {
pub parent_completion_tx: Option<mpsc::Sender<SubAgentCompletion>>,
/// Snapshot of the request prefix visible to an opt-in forked child.
pub fork_context: Option<SubAgentForkContext>,
pub(crate) workflow_response_schema: Option<Value>,
/// The parent's MCP pool if available.
pub mcp_pool: Option<std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>>,
/// Per-step DeepSeek API timeout for the child's `create_message` call.
@@ -3159,6 +3092,7 @@ impl SubAgentRuntime {
accounting_origin,
parent_completion_tx: None,
fork_context: None,
workflow_response_schema: None,
mcp_pool: None,
step_api_timeout: DEFAULT_STEP_API_TIMEOUT,
api_timeout_retry_base_backoff: SUBAGENT_API_TIMEOUT_INITIAL_BACKOFF,
@@ -3364,7 +3298,7 @@ impl SubAgentRuntime {
/// Used by config wiring (`[subagents] max_depth = N`) and tests.
#[must_use]
pub fn with_max_spawn_depth(mut self, max: u32) -> Self {
self.max_spawn_depth = max.min(codewhale_config::MAX_SPAWN_DEPTH_CEILING);
self.max_spawn_depth = max;
self.worker_profile.max_spawn_depth = self.max_spawn_depth;
self
}
@@ -3494,6 +3428,7 @@ impl SubAgentRuntime {
accounting_origin: self.accounting_origin.clone(),
parent_completion_tx: self.parent_completion_tx.clone(),
fork_context: self.fork_context.clone(),
workflow_response_schema: None,
mcp_pool: self.mcp_pool.clone(),
step_api_timeout: self.step_api_timeout,
api_timeout_retry_base_backoff: self.api_timeout_retry_base_backoff,
@@ -3952,10 +3887,6 @@ impl AdmittedCoordinationRoot {
pub struct SubAgentManager {
agents: HashMap<String, SubAgent>,
worker_records: HashMap<String, AgentWorkerRecord>,
/// In-flight report allowances, not billed usage. Weak leases disappear
/// on cancellation/abort without awaiting the manager lock or persisting
/// a refundable reservation across process recovery.
handback_reservations: HashMap<String, std::sync::Weak<u64>>,
worker_event_seq: u64,
persist_sequence: std::sync::atomic::AtomicU64,
coordination: CoordinationLedger,
@@ -3979,10 +3910,8 @@ pub struct SubAgentManager {
/// an explicit spawn `max_steps` still wins. Zero means unbounded.
max_steps: Option<u32>,
/// Configured default per-child wall-clock budget (`[subagents]
/// default_wall_time_secs`, #5324). `None` keeps
/// `DEFAULT_CHILD_WALL_TIME`, which an explicit spawn `wall_time_secs`
/// may raise up to `MAX_CHILD_WALL_TIME`; a configured value is also a
/// ceiling on explicit requests.
/// default_wall_time_secs`, #5324). `None` leaves the run unbounded;
/// a configured value is a ceiling on inherited work.
wall_time: Option<Duration>,
max_agents: usize,
max_admitted_agents: usize,
@@ -4473,7 +4402,6 @@ impl SubAgentManager {
Self {
agents: HashMap::new(),
worker_records: HashMap::new(),
handback_reservations: HashMap::new(),
worker_event_seq: 0,
persist_sequence: std::sync::atomic::AtomicU64::new(0),
coordination: CoordinationLedger::default(),
@@ -4995,8 +4923,7 @@ impl SubAgentManager {
}
/// Set the configured default per-child wall-clock budget applied when a
/// spawn carries no explicit `wall_time_secs` (#5324). `None` keeps
/// `DEFAULT_CHILD_WALL_TIME`.
/// spawn has no owner-supplied deadline. `None` leaves the run unbounded.
#[must_use]
pub fn with_default_wall_time(mut self, wall_time: Option<Duration>) -> Self {
self.wall_time = wall_time;
@@ -8824,29 +8751,24 @@ impl SubAgentManager {
runtime.max_spawn_depth
));
}
// The built-in 30-minute clock is a default, not a ceiling: an explicit
// `wall_time_secs` may raise it up to MAX_CHILD_WALL_TIME. An operator
// `[subagents] default_wall_time_secs` stays a ceiling, and inherited
// profiles and deadlines still only narrow.
let default_wall_time = runtime
.execution_limits
.as_ref()
.map_or(self.wall_time, |limits| limits.default_wall_time);
let wall_time = options
.wall_time
.unwrap_or_else(|| default_wall_time.unwrap_or(DEFAULT_CHILD_WALL_TIME))
.min(default_wall_time.unwrap_or(MAX_CHILD_WALL_TIME))
.min(
let wall_time = narrow_optional_limit(
options.wall_time,
narrow_optional_limit(
default_wall_time,
runtime
.worker_profile
.wall_time_secs
.map(Duration::from_secs)
.unwrap_or(MAX_CHILD_WALL_TIME),
)
.min(MAX_CHILD_WALL_TIME);
.map(Duration::from_secs),
),
);
let now_ms = epoch_millis_now();
let requested_deadline =
now_ms.saturating_add(u64::try_from(wall_time.as_millis()).unwrap_or(u64::MAX));
let requested_deadline = wall_time.map(|limit| {
now_ms.saturating_add(u64::try_from(limit.as_millis()).unwrap_or(u64::MAX))
});
let source_deadline = options
.resume_from_agent_id
.as_deref()
@@ -8856,15 +8778,13 @@ impl SubAgentManager {
// bounds a work clock restarted at launch (see `run_subagent_task_inner`).
let wall_ceiling_ms =
narrow_optional_limit(runtime.worker_profile.wall_deadline_ms, source_deadline);
let deadline_ms = wall_ceiling_ms.map_or(requested_deadline, |deadline| {
deadline.min(requested_deadline)
});
if deadline_ms <= now_ms {
let deadline_ms = narrow_optional_limit(wall_ceiling_ms, requested_deadline);
if deadline_ms.is_some_and(|deadline| deadline <= now_ms) {
return Err(anyhow!(
"child wall-time budget exhausted; continuation cannot reset its deadline"
));
}
let wall_time = Duration::from_millis(deadline_ms - now_ms);
let wall_time = deadline_ms.map(|deadline| Duration::from_millis(deadline - now_ms));
let continuation_from = if options.checkpoint_continuation {
let source = options
.resume_from_agent_id
@@ -9150,12 +9070,12 @@ impl SubAgentManager {
runtime_profile.max_spawn_depth = runtime.max_spawn_depth;
runtime_profile.spawn_depth = runtime.spawn_depth;
runtime_profile.max_steps = max_steps;
runtime_profile.wall_time_secs = Some(
wall_time
runtime_profile.wall_time_secs = wall_time.map(|limit| {
limit
.as_secs()
.saturating_add(u64::from(wall_time.subsec_nanos() > 0)),
);
runtime_profile.wall_deadline_ms = Some(deadline_ms);
.saturating_add(u64::from(limit.subsec_nanos() > 0))
});
runtime_profile.wall_deadline_ms = deadline_ms;
runtime.worker_profile = runtime_profile.clone();
let worker_spec = AgentWorkerSpec {
worker_id: agent_id.clone(),
@@ -11607,7 +11527,7 @@ static AGENT_TOOL_DESCRIPTION: std::sync::LazyLock<String> = std::sync::LazyLock
"type selects the Fleet role: general (full tool access for multi-step tasks), explore (fast read-only exploration), planner (grounded strategy, read-only probes), reviewer (reads and grades code), implement (lands focused code changes), test (runs tests and reports evidence), advisor (read-only design counsel), or custom (allowed_tools on the parent's posture). ",
"profile selects a saved member or built-in role. Saved profile and manual role pins are exact; model/strength choose unpinned routes; thinking overrides the tier. ",
"Use action=roster for resolved roles, models, reasoning, context and cost evidence; it makes no provider request. ",
"max_steps and wall_time_secs only narrow inherited limits; the worker reserves one tools-disabled partial-report turn and falls back to recorded work when reporting is unavailable. There is no token budget: runs are never stopped by token accounting. ",
"Workers have no default turn or wall-clock budget. Only configured operator, role and inherited limits apply; legacy per-call max_steps, wall_time_secs and max_output_tokens are ignored. There is no token budget. ",
"Prefer type=implement for write work and type=test (or the Run tool with action=\"verifiers\") after writes settle — dispatch is not completion. ",
"action=claim widens your own enforced write scope: pass write_roots (and optionally exact_files, coordination_contracts) before mutating anything a fail-closed write refusal named. It records a durable claim receipt and fails on contention with a peer claim; it never touches another agent's scope. ",
"Action contract: start requires prompt; message/followup require targets and a message; followup accepts one id, agent_ids or all_parked=true and returns continuation mappings. peek/interrupt/cancel require a target; claim requires scope entries; status is compact and paginated unless an addressed detail is requested. ",
@@ -11628,7 +11548,7 @@ impl ToolSpec for AgentTool {
}
/// One schema advertises the runtime's routing, scope, lifecycle and
/// narrowing-budget contract. Legacy aliases remain accepted for replay.
/// inherited-limit contract. Legacy aliases remain accepted for replay.
fn input_schema(&self) -> Value {
// Generic provider sanitization prunes required fields unless the
// same schema node declares them. Presence tests inside `not` need
@@ -11749,18 +11669,6 @@ impl ToolSpec for AgentTool {
"items": { "type": "string" },
"description": "Repo-relative directory trees a write-capable agent may mutate. On action=start: the scope this child claims, defaulting to the parent workspace ('.') when omitted. On action=claim: the trees to add to your own enforced scope, which you must do before mutating anything outside it. Paths outside the parent workspace are refused."
},
"max_output_tokens": {
"type": "integer", "minimum": 1,
"description": "Per-tool-result output token cap for this child (#6282). Overrides the 10k default; narrower values conserve context but may truncate tool results with [truncated: true] markers. Values above u32::MAX are rejected."
},
"max_steps": {
"type": "integer", "minimum": 1, "maximum": MAX_SUBAGENT_STEPS,
"description": "Maximum model turns for this run, only narrowing role/operator/parent limits. Continuation retains its remaining allowance."
},
"wall_time_secs": {
"type": "integer", "minimum": 1, "maximum": MAX_CHILD_WALL_TIME.as_secs(),
"description": "Whole-run wall time (queue, model, tools); default 1800, may exceed that but never inherited or operator limits. Continuation keeps the clock."
},
"deliverables": {
"type": "array", "maxItems": 16,
"items": {"type": "string", "minLength": 1},
@@ -12097,12 +12005,27 @@ impl ToolSpec for AgentTool {
.get("verbose")
.and_then(Value::as_bool)
.unwrap_or(false);
let ignored_limits: Vec<&str> = [
"max_steps",
"maxSteps",
"wall_time_secs",
"wallTimeSecs",
"max_output_tokens",
"maxOutputTokens",
"max_depth",
"maxDepth",
"max_spawn_depth",
]
.into_iter()
.filter(|key| input.get(key).is_some())
.collect();
let (snapshot, _spawn_metadata) = spawn_subagent_from_input(
input,
self.manager.clone(),
self.runtime.clone(),
false,
None,
None,
)
.await?;
let worker_record = {
@@ -12115,6 +12038,12 @@ impl ToolSpec for AgentTool {
let mut value = serde_json::to_value(&projection)
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
compact_spawn_receipt(&mut value, verbose);
if !ignored_limits.is_empty() {
value["ignored_limit_fields"] = json!(ignored_limits);
value["limits_note"] = json!(
"Per-call limits are retired. This worker uses operator, role and inherited limits; see effective_limits."
);
}
let mut tool_result =
ToolResult::json(&value).map_err(|e| ToolError::execution_failed(e.to_string()))?;
let metadata = json!({
@@ -12662,6 +12591,7 @@ async fn spawn_subagent_from_input(
mut runtime: SubAgentRuntime,
verified_posture_denials: bool,
workflow_identity: Option<&WorkflowTaskSpawnIdentity>,
response_schema: Option<Value>,
) -> Result<(SubAgentResult, WorkflowTaskSpawnMetadata), ToolError> {
let exact_fleet_binding =
workflow_identity.and_then(|identity| identity.exact_fleet_binding.as_ref());
@@ -12670,6 +12600,12 @@ async fn spawn_subagent_from_input(
refresh_spawn_route_sources(&mut runtime);
}
let mut spawn_request = parse_spawn_request(&input)?;
spawn_request.max_steps = None;
spawn_request.wall_time = None;
spawn_request.max_output_tokens = None;
if exact_fleet_binding.is_none() {
spawn_request.max_depth = None;
}
if !verified_posture_denials {
// Keep caller restrictions in the inherited context before adding
// structural role sentinels to the worker profile. A read-only role
@@ -12870,6 +12806,7 @@ async fn spawn_subagent_from_input(
);
apply_spawn_write_authority(&mut child_runtime, &spawn_request);
let write_capable = spawn_request_is_write_capable(&spawn_request);
child_runtime.workflow_response_schema = response_schema.filter(|_| !write_capable);
let write_claim = write_capable.then(|| WriteScopeClaim {
owner: String::new(),
roots: spawn_request.write_roots.clone(),
@@ -13377,6 +13314,7 @@ pub(crate) async fn spawn_workflow_task(
runtime,
identity.fleet_authority_fingerprint.is_some(),
Some(&identity),
request.response_schema,
)
.await?;
let workflow_task_label = identity.workflow_task_label;
@@ -13701,8 +13639,8 @@ struct SubAgentTask {
max_steps: u32,
/// Wall-clock budget for the child's work. A child that waits for a
/// launch slot gets it in full from the moment it launches, bounded by
/// `wall_ceiling_ms`; the queue wait itself is bounded by the same length.
wall_time: Duration,
/// `wall_ceiling_ms`. `None` leaves the queue and work without a deadline.
wall_time: Option<Duration>,
/// Inherited absolute deadline (epoch ms) from the parent, saved-run or
/// source record, which a restarted work clock never passes.
wall_ceiling_ms: Option<u64>,
@@ -14062,36 +14000,38 @@ async fn run_subagent_task_inner(mut task: SubAgentTask) {
}
}
let mut deadline = (task.started_at + task.wall_time).min(
task.runtime.worker_profile.wall_deadline_ms.map_or(
task.started_at + task.wall_time,
|deadline| {
Instant::now() + Duration::from_millis(deadline.saturating_sub(epoch_millis_now()))
},
),
let mut deadline = narrow_optional_limit(
task.wall_time
.and_then(|limit| task.started_at.checked_add(limit)),
task.runtime
.worker_profile
.wall_deadline_ms
.and_then(|deadline| {
Instant::now().checked_add(Duration::from_millis(
deadline.saturating_sub(epoch_millis_now()),
))
}),
);
// One effective deadline, visible to both layers. The inner stages read
// `worker_profile.wall_deadline_ms` (work deadline, reserved hand-back),
// `worker_profile.wall_deadline_ms`,
// while an authored task wall time used to narrow the budget only through
// the outer backstop below. The backstop must not race the deadline
// (#6277), so fold the task wall time into the profile before the run:
// enforcement, the model-visible budget block, and the receipt then all
// describe the same limit.
let effective_deadline_ms = epoch_millis_now().saturating_add(
u64::try_from(
deadline
.saturating_duration_since(Instant::now())
.as_millis(),
let effective_deadline_ms = deadline.map(|deadline| {
epoch_millis_now().saturating_add(
u64::try_from(
deadline
.saturating_duration_since(Instant::now())
.as_millis(),
)
.unwrap_or(u64::MAX),
)
.unwrap_or(u64::MAX),
);
task.runtime.worker_profile.wall_deadline_ms = Some(
task.runtime
.worker_profile
.wall_deadline_ms
.map_or(effective_deadline_ms, |existing| {
existing.min(effective_deadline_ms)
}),
});
task.runtime.worker_profile.wall_deadline_ms = narrow_optional_limit(
task.runtime.worker_profile.wall_deadline_ms,
effective_deadline_ms,
);
// Interactive launch gate (#3095): direct children acquire a permit
@@ -14118,12 +14058,12 @@ async fn run_subagent_task_inner(mut task: SubAgentTask) {
match Arc::clone(gate).try_acquire() {
Some(permit) => _launch_permits.push(permit),
None => {
match tokio::time::timeout_at(
deadline.into(),
acquire_queued_launch_permit(&task, Arc::clone(gate), deadline),
)
.await
{
let acquire = acquire_queued_launch_permit(&task, Arc::clone(gate), deadline);
let permit = match deadline {
Some(deadline) => tokio::time::timeout_at(deadline.into(), acquire).await,
None => Ok(acquire.await),
};
match permit {
Ok(permit) => {
if let Some(permit) = permit {
launched_from_queue = true;
@@ -14140,25 +14080,26 @@ async fn run_subagent_task_inner(mut task: SubAgentTask) {
}
}
}
let queue_limit = deadline.saturating_duration_since(task.started_at);
let queue_limit = deadline.map(|deadline| deadline.saturating_duration_since(task.started_at));
let mut work_started_at = task.started_at;
if launched_from_queue {
let now = Instant::now();
let now_ms = epoch_millis_now();
let restarted_ms =
now_ms.saturating_add(u64::try_from(task.wall_time.as_millis()).unwrap_or(u64::MAX));
let deadline_ms = task
.wall_ceiling_ms
.map_or(restarted_ms, |ceiling| ceiling.min(restarted_ms));
deadline = now + Duration::from_millis(deadline_ms.saturating_sub(now_ms));
task.runtime.worker_profile.wall_deadline_ms = Some(deadline_ms);
let restarted_ms = task.wall_time.map(|limit| {
now_ms.saturating_add(u64::try_from(limit.as_millis()).unwrap_or(u64::MAX))
});
let deadline_ms = narrow_optional_limit(task.wall_ceiling_ms, restarted_ms);
deadline = deadline_ms.and_then(|deadline| {
now.checked_add(Duration::from_millis(deadline.saturating_sub(now_ms)))
});
task.runtime.worker_profile.wall_deadline_ms = deadline_ms;
work_started_at = now;
// Continuation reads the saved deadline, so save the restarted one;
// otherwise a child that launched late would be refused as out of
// budget while most of its work budget was left.
let mut manager = task.manager_handle.write().await;
if let Some(record) = manager.worker_records.get_mut(&task.agent_id) {
record.spec.runtime_profile.wall_deadline_ms = Some(deadline_ms);
record.spec.runtime_profile.wall_deadline_ms = deadline_ms;
record.updated_at_ms = now_ms;
manager.persist_state_debounced();
}
@@ -14168,20 +14109,22 @@ async fn run_subagent_task_inner(mut task: SubAgentTask) {
._foreground_child_registration
.as_ref()
.map(ForegroundChildRegistration::parking_signal);
// Every inner stage bounds itself by `deadline` (step requests by the
// work deadline, and the reserved hand-back turn by the hard deadline),
// Every inner stage bounds itself by the owner-selected `deadline`,
// so the inner run can always produce its own receipt: the specific stop
// cause, the hand-back outcome, and preserved usage. This outer backstop
// cause and preserved usage. This outer backstop
// exists only for an inner stage that overruns anyway; racing it at the
// same instant as the inner deadline replaced real receipts with the
// generic error below (#6277). The grace keeps the anti-hang guarantee
// while letting the inner receipt win.
let backstop = deadline + Duration::from_secs(30);
let effective_limit = deadline.saturating_duration_since(work_started_at);
let backstop = deadline.and_then(|deadline| deadline.checked_add(Duration::from_secs(30)));
let effective_limit =
deadline.map(|deadline| deadline.saturating_duration_since(work_started_at));
let mut backstop_budget = false;
let result = if launch_wait_timed_out {
task.runtime.cancel_token.cancel();
Err(anyhow!(never_started_reason(queue_limit)))
Err(anyhow!(never_started_reason(
queue_limit.expect("timed queue has a deadline")
)))
} else {
let child = engine::run_child_agent(
&task.runtime,
@@ -14202,12 +14145,17 @@ async fn run_subagent_task_inner(mut task: SubAgentTask) {
() = async {
tokio::select! {
() = task.runtime.cancel_token.cancelled() => {},
() = tokio::time::sleep_until(backstop.into()) => {},
() = async {
match backstop {
Some(deadline) => tokio::time::sleep_until(deadline.into()).await,
None => std::future::pending::<()>().await,
}
} => {},
}
} => {
// Signal first, then keep polling the exact future that owns
// Core's approval receipt and actor join; never reopen work.
backstop_budget = Instant::now() >= backstop
backstop_budget = backstop.is_some_and(|deadline| Instant::now() >= deadline)
&& !task.runtime.cancel_token.is_cancelled();
task.runtime.cancel_token.cancel();
// Core's driver owns a five-second cooperative join. One extra
@@ -14217,7 +14165,11 @@ async fn run_subagent_task_inner(mut task: SubAgentTask) {
tokio::time::timeout(CHILD_STOP_SETTLE_GRACE + Duration::from_secs(1), &mut child)
.await.unwrap_or_else(|_| Err(anyhow!(engine::UnsettledChildCancellation(format!(
"{}; child cancellation did not settle",
child_wall_time_exhausted_reason(effective_limit)
if backstop_budget {
child_wall_time_exhausted_reason(effective_limit.expect("budget backstop has a deadline"))
} else {
"child stop requested".to_string()
}
)))))
}
}
@@ -14321,7 +14273,9 @@ async fn run_subagent_task_inner(mut task: SubAgentTask) {
let terminal = match result {
Ok(result) if backstop_budget => budget_partial_result(
result,
&child_wall_time_exhausted_reason(effective_limit),
&child_wall_time_exhausted_reason(
effective_limit.expect("budget backstop has a deadline"),
),
preservation_note.as_deref(),
),
Ok(mut result) => {
@@ -14380,10 +14334,9 @@ async fn run_subagent_task_inner(mut task: SubAgentTask) {
}
/// Queued-row reason (addendum F5): why the child waits — a free slot, or the
/// rate-limit governor's pause/throttle — and when it gives up waiting (as
/// an end time). The work budget starts at launch (#6015); the queue wait is
/// bounded separately so saturation cannot keep a child waiting forever.
fn queued_launch_reason(task: &SubAgentTask, deadline: Instant) -> String {
/// rate-limit governor's pause/throttle — and any owner-supplied deadline.
/// The work budget starts at launch (#6015) when a budget is configured.
fn queued_launch_reason(task: &SubAgentTask, deadline: Option<Instant>) -> String {
let now = Instant::now();
let governor_line = task
.runtime
@@ -14403,13 +14356,16 @@ fn queued_launch_reason(task: &SubAgentTask, deadline: Instant) -> String {
Some(line) => format!("{SUBAGENT_QUEUED_LAUNCH_REASON} — {line}"),
None => SUBAGENT_QUEUED_LAUNCH_REASON.to_string(),
};
format!(
"{base} {}",
queued_budget_note(
deadline.saturating_duration_since(now),
chrono::Local::now()
)
)
match deadline {
Some(deadline) => format!(
"{base} {}",
queued_budget_note(
deadline.saturating_duration_since(now),
chrono::Local::now()
)
),
None => base,
}
}
/// The wall-budget half of a queued reason. The row is republished only when
@@ -14434,7 +14390,7 @@ fn queued_reason_cause(reason: &str) -> &str {
async fn acquire_queued_launch_permit(
task: &SubAgentTask,
gate: Arc<governor::DynamicGate>,
deadline: Instant,
deadline: Option<Instant>,
) -> Option<governor::DynamicGatePermit> {
// When the governor has paused launches over sustained provider 429s,
// surface the reason in the queued status instead of the generic
@@ -15816,18 +15772,12 @@ fn parse_spawn_request(input: &Value) -> Result<SpawnRequest, ToolError> {
u32::MAX as u64,
)?
.map(|n| n as u32);
let max_steps = parse_optional_bounded_limit(
input,
&["max_steps", "maxSteps"],
u64::from(MAX_SUBAGENT_STEPS),
)?
.map(|steps| u32::try_from(steps).expect("validated model-turn range"));
let wall_time = parse_optional_bounded_limit(
input,
&["wall_time_secs", "wallTimeSecs"],
MAX_CHILD_WALL_TIME.as_secs(),
)?
.map(Duration::from_secs);
let max_steps =
parse_optional_bounded_limit(input, &["max_steps", "maxSteps"], u64::from(u32::MAX))?
.map(|steps| u32::try_from(steps).expect("validated model-turn range"));
let wall_time =
parse_optional_bounded_limit(input, &["wall_time_secs", "wallTimeSecs"], u64::MAX / 1_000)?
.map(Duration::from_secs);
// #4042: optional caller-supplied tool deny-list (unioned with the parent's
// inherited deny-list) and the legacy inheritance flag (cannot relax policy).
@@ -19183,13 +19133,13 @@ const GENERAL_AGENT_INTRO: &str = concat!(
"You are a trusted Fleet worker. Your job is to complete the one task you were given, end-to-end, and report back concisely.\n",
"Stay inside the assigned scope; put adjacent work under RISKS/BLOCKERS.\n",
"For genuinely multi-step work, track progress with `todo_write`; skip it for short, focused tasks.\n",
"**Stop quickly on failure**: if the same tool call fails 2 times in a row, stop retrying and return what you have so far with a one-line note explaining what's missing. Do not loop on impossible queries (e.g. external API unreachable, rate-limited, or returning empty).\n",
"After a failed tool call, inspect the cause and adapt. Retry a transient failure when appropriate, use another supported approach, or report a concrete blocker when progress requires unavailable input or authority. Continue independent work.\n",
"For builder or repair-style work, keep going within the assigned scope; checkpoint before broadening the task or after repeated failures instead of forcing a tiny tool-call cap.\n\n"
);
const EXPLORE_AGENT_INTRO: &str = concat!(
"You are a trusted Fleet scout (role: `scout`). Your job is to map the relevant code quickly and stay strictly read-only.\n",
"Default to `EFFORT: quick`: stop at decisive evidence, not at a number — there is no per-agent call cap to save.\n",
"Gather enough evidence to answer the assigned question reliably; there is no per-agent call cap to save.\n",
"Orient first: confirm the workspace/project root, read relevant AGENTS.md/README guidance when the tree is unfamiliar, then search only the likely scope.\n",
"Use `read` for bounded file reads and `bash` only for the allowed read-only inspection subset: navigation/rg, safe Git reads (for example `git log -n 5`), and read-only GitHub views such as `gh issue view`. Builds, tests, writes, and shell control actions are unavailable.\n",
"Use your private `todo_write` list as editable working notes when useful; it is agent-owned state, not permission to write project files. Those tool calls remain in the complete transcript artifact returned to the parent.\n",
@@ -19226,7 +19176,7 @@ const IMPLEMENTER_AGENT_INTRO: &str = concat!(
"You are a trusted Fleet builder (role: `builder`). Your job is to land the assigned change with minimal surrounding edits.\n",
"Use `edit` for precise unique replacements, `write` for whole-file changes, and discover `apply_patch` for unified multi-file patches when needed.\n",
"Run relevant verification after edit batches; write needed tests with the implementation.\n",
"You are not limited to a scout-style 3-5 tool-call cap. Checkpoint before expanding scope or after repeated failures, then continue only inside the assigned brief.\n",
"Checkpoint before expanding scope or after repeated failures, then continue only inside the assigned brief.\n",
"CHANGES is load-bearing: list every modified file with a one-line why.\n",
"Before finishing, end with a VERDICT block: PASS or FAIL, the exact commands you ran (or why verification was impossible), and brief evidence. A diff alone is not completion.\n\n"
);
+47 -41
View File
@@ -3454,10 +3454,10 @@ fn explore_prompt_orients_before_searching() {
}
#[test]
fn explore_prompt_is_quick_bounded_and_read_only() {
fn explore_prompt_requires_evidence_and_read_only_scope() {
let prompt = FleetRole::Scout.system_prompt();
assert!(prompt.contains("Default to `EFFORT: quick`"));
assert!(prompt.contains("stop at decisive evidence, not at a number"));
assert!(prompt.contains("Gather enough evidence to answer the assigned question reliably"));
assert!(prompt.contains("there is no per-agent call cap to save"));
assert!(prompt.contains("strictly read-only"));
assert!(prompt.contains("ALREADY_KNOWN"));
assert!(prompt.contains("STOP_CONDITION"));
@@ -3476,7 +3476,7 @@ fn explore_prompt_is_quick_bounded_and_read_only() {
#[test]
fn implementer_prompt_is_not_forced_into_explorer_cap() {
let prompt = FleetRole::Builder.system_prompt();
assert!(prompt.contains("not limited to a scout-style 3-5 tool-call cap"));
assert!(!prompt.contains("3-5 tool-call cap"));
assert!(prompt.contains("Checkpoint before expanding scope"));
assert!(!prompt.contains("Default to `EFFORT: quick`"));
}
@@ -4535,7 +4535,7 @@ fn test_parse_spawn_request_rejects_out_of_range_max_depth() {
let input = json!({
"name": "review.parser",
"prompt": "inspect parser",
"max_depth": ceiling + 1
"max_depth": u64::from(ceiling) + 1
});
let err = parse_spawn_request(&input).expect_err("max_depth should be capped at schema range");
assert!(
@@ -5429,7 +5429,7 @@ fn test_child_max_spawn_depth_request_cannot_widen_inherited_budget() {
// Root chose an absolute max_spawn_depth of 2; a nested child at
// spawn_depth 2 requesting the global ceiling must stay bounded by 2,
// not jump to the ceiling. Before the fix this returned
// MAX_SPAWN_DEPTH_CEILING (8), letting the descendant keep spawning past
// MAX_SPAWN_DEPTH_CEILING, letting the descendant keep spawning past
// the root's chosen boundary.
assert_eq!(
child_max_spawn_depth_for_spawn(2, 2, Some(codewhale_config::MAX_SPAWN_DEPTH_CEILING)),
@@ -6077,8 +6077,6 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() {
"expected_artifact",
"fork_context",
"limit",
"max_output_tokens",
"max_steps",
"message",
"model",
"model_strength",
@@ -6090,7 +6088,6 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() {
"thinking",
"type",
"until",
"wall_time_secs",
"worktree",
"write_authority",
"write_roots",
@@ -6103,6 +6100,9 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() {
);
for unadvertised in [
"max_depth",
"max_steps",
"wall_time_secs",
"max_output_tokens",
"workspace_policy",
"worktree_base",
"worktree_branch",
@@ -10048,7 +10048,7 @@ async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parkin
fork_context: false,
started_at: Instant::now(),
max_steps: 3,
wall_time: DEFAULT_CHILD_WALL_TIME,
wall_time: None,
wall_ceiling_ms: None,
input_rx: task_input_rx,
launch_gate: None,
@@ -10223,7 +10223,7 @@ async fn subagent_retries_api_timeout_before_succeeding() {
fork_context: false,
started_at: Instant::now(),
max_steps: 3,
wall_time: DEFAULT_CHILD_WALL_TIME,
wall_time: None,
wall_ceiling_ms: None,
input_rx: task_input_rx,
launch_gate: None,
@@ -10423,7 +10423,7 @@ async fn subagent_retries_transient_provider_header_timeout_before_succeeding()
fork_context: false,
started_at: Instant::now(),
max_steps: 3,
wall_time: DEFAULT_CHILD_WALL_TIME,
wall_time: None,
wall_ceiling_ms: None,
input_rx: task_input_rx,
launch_gate: None,
@@ -10498,7 +10498,7 @@ async fn subagent_rate_limit_exhaustion_interrupts_with_checkpoint() {
fork_context: false,
started_at: Instant::now(),
max_steps: 3,
wall_time: DEFAULT_CHILD_WALL_TIME,
wall_time: None,
wall_ceiling_ms: None,
input_rx: task_input_rx,
launch_gate: None,
@@ -13059,9 +13059,9 @@ fn child_launch_error_names_provider_model_and_route_source() {
}
#[test]
fn subagent_runtime_default_max_depth_is_three() {
fn subagent_runtime_default_depth_does_not_restrict_delegation() {
// Sanity-check the constant — bumping it without a test means stale docs.
assert_eq!(DEFAULT_MAX_SPAWN_DEPTH, 3);
assert_eq!(DEFAULT_MAX_SPAWN_DEPTH, u32::MAX);
}
#[test]
@@ -13136,6 +13136,7 @@ async fn rate_limit_pause_blocks_subagent_spawn() {
runtime,
false,
None,
None,
)
.await
.expect_err("active provider rate-limit pause must refuse new sub-agent work");
@@ -15619,6 +15620,7 @@ pub(crate) fn stub_runtime() -> SubAgentRuntime {
parent_agent_id: None,
parent_completion_tx: None,
fork_context: None,
workflow_response_schema: None,
parent_mode: AppMode::Agent,
auto_review_policy: std::sync::Arc::new(
crate::core::authority::auto_review::AutoReviewPolicy::default(),
@@ -16842,7 +16844,7 @@ async fn run_subagent_task_claims_before_delivery_and_then_finalizes() {
fork_context: false,
started_at: Instant::now(),
max_steps: 1,
wall_time: DEFAULT_CHILD_WALL_TIME,
wall_time: None,
wall_ceiling_ms: None,
input_rx: task_input_rx,
launch_gate: None,
@@ -16931,7 +16933,7 @@ async fn cancellation_wins_task_race_but_still_fans_in_exactly_once() {
fork_context: false,
started_at: Instant::now(),
max_steps: 1,
wall_time: DEFAULT_CHILD_WALL_TIME,
wall_time: None,
wall_ceiling_ms: None,
input_rx: task_input_rx,
launch_gate: None,
@@ -17145,7 +17147,7 @@ async fn fatal_provider_failure_mid_run_parks_a_continuable_checkpoint() {
fork_context: false,
started_at: Instant::now(),
max_steps: 4,
wall_time: DEFAULT_CHILD_WALL_TIME,
wall_time: None,
wall_ceiling_ms: None,
input_rx: task_input_rx,
launch_gate: None,
@@ -17250,7 +17252,7 @@ async fn fatal_provider_failure_mid_run_parks_a_continuable_checkpoint() {
fork_context: false,
started_at: Instant::now(),
max_steps: 2,
wall_time: DEFAULT_CHILD_WALL_TIME,
wall_time: None,
wall_ceiling_ms: None,
input_rx: resume_input_rx,
launch_gate: None,
@@ -17423,7 +17425,7 @@ async fn repeated_typed_denials_stop_worker_as_failed_not_budget() {
fork_context: false,
started_at: Instant::now(),
max_steps: 20,
wall_time: DEFAULT_CHILD_WALL_TIME,
wall_time: None,
wall_ceiling_ms: None,
input_rx: task_input_rx,
launch_gate: None,
@@ -17521,7 +17523,7 @@ async fn non_retryable_provider_failure_fans_in_to_every_terminal_sink() {
fork_context: false,
started_at: Instant::now(),
max_steps: 1,
wall_time: DEFAULT_CHILD_WALL_TIME,
wall_time: None,
wall_ceiling_ms: None,
input_rx: task_input_rx,
launch_gate: None,
@@ -18226,22 +18228,19 @@ fn format_step_counter_keeps_concrete_budgets() {
}
#[test]
fn child_step_override_wins_and_clamps_to_hard_ceiling() {
fn child_step_limits_preserve_the_owners_allowance() {
assert_eq!(resolve_max_steps(FleetRole::Scout, None, None), 0);
assert_eq!(resolve_max_steps(FleetRole::Scout, Some(0), Some(90)), 90);
assert_eq!(resolve_max_steps(FleetRole::Builder, Some(7), None), 7);
assert_eq!(
resolve_max_steps(FleetRole::Worker, Some(u32::MAX), None),
MAX_SUBAGENT_STEPS
u32::MAX
);
// #5324: `[subagents] default_max_steps` is the configured fallback when
// the call carries no explicit budget; an explicit value still wins and
// the hard ceiling still clamps the configured default.
assert_eq!(resolve_max_steps(FleetRole::Scout, None, Some(90)), 90);
assert_eq!(resolve_max_steps(FleetRole::Builder, Some(7), Some(90)), 7);
assert_eq!(
resolve_max_steps(FleetRole::Worker, None, Some(u32::MAX)),
MAX_SUBAGENT_STEPS
u32::MAX
);
}
@@ -18331,7 +18330,7 @@ async fn launch_gate_queues_extra_direct_children() {
fork_context: false,
started_at: Instant::now(),
max_steps: 1,
wall_time: DEFAULT_CHILD_WALL_TIME,
wall_time: None,
wall_ceiling_ms: None,
input_rx,
launch_gate: gate,
@@ -18491,7 +18490,7 @@ async fn queued_turn_owned_child_parks_without_a_false_start_transition() {
fork_context: false,
started_at: Instant::now(),
max_steps: 1,
wall_time: Duration::from_secs(5),
wall_time: Some(Duration::from_secs(5)),
wall_ceiling_ms: None,
input_rx,
launch_gate: Some(Arc::clone(&gate)),
@@ -18640,7 +18639,7 @@ async fn launch_gate_wait_that_never_ends_fails_as_never_started() {
fork_context: false,
started_at: Instant::now(),
max_steps: 1,
wall_time: WALL_TIME,
wall_time: Some(WALL_TIME),
wall_ceiling_ms: None,
input_rx,
launch_gate: Some(Arc::clone(&gate)),
@@ -18928,7 +18927,7 @@ pub(super) async fn run_incomplete_response_worker(
fork_context: false,
started_at: Instant::now(),
max_steps,
wall_time: DEFAULT_CHILD_WALL_TIME,
wall_time: None,
wall_ceiling_ms: None,
input_rx: task_input_rx,
launch_gate: None,
@@ -19148,7 +19147,7 @@ async fn spawn_budget_capped_worker_with_pause(
fork_context: false,
started_at: Instant::now(),
max_steps,
wall_time,
wall_time: Some(wall_time),
wall_ceiling_ms: None,
input_rx: task_input_rx,
launch_gate: None,
@@ -19354,7 +19353,7 @@ async fn worker_compacts_past_its_context_window_and_keeps_working() {
fork_context: false,
started_at: Instant::now(),
max_steps: 20,
wall_time: Duration::from_secs(300),
wall_time: Some(Duration::from_secs(300)),
wall_ceiling_ms: None,
input_rx: task_input_rx,
launch_gate: None,
@@ -19448,7 +19447,7 @@ async fn worker_is_not_stranded_by_transient_global_rate_limit_window() {
60,
40,
4,
DEFAULT_CHILD_WALL_TIME,
Duration::from_secs(30),
Some(Duration::from_secs(30)),
)
.await;
@@ -22578,17 +22577,23 @@ async fn runtime_surface_hardening_resume_from_requires_descendant_control() {
};
runtime.parent_agent_id = Some(caller);
let input = json!({"prompt": "Continue the completed work", "resume_from": source});
let err =
spawn_subagent_from_input(input.clone(), manager.clone(), runtime.clone(), false, None)
.await
.expect_err("a sibling is not a continuation source");
let err = spawn_subagent_from_input(
input.clone(),
manager.clone(),
runtime.clone(),
false,
None,
None,
)
.await
.expect_err("a sibling is not a continuation source");
assert!(
err.to_string().contains("only its own descendants"),
"{err}"
);
runtime.parent_agent_id = None;
let err = spawn_subagent_from_input(input, manager.clone(), runtime, false, None)
let err = spawn_subagent_from_input(input, manager.clone(), runtime, false, None, None)
.await
.expect_err("root still observes the workspace boundary");
assert!(err.to_string().contains("different workspace"), "{err}");
@@ -24079,6 +24084,7 @@ async fn refuse_worktree_spawn_and_expect_cleanup(repo: &tempfile::TempDir, bran
runtime,
false,
None,
None,
)
.await
.expect_err("a running resume source refuses the spawn");
@@ -26854,7 +26860,7 @@ mod readonly_shell_6015 {
fork_context: false,
started_at: Instant::now(),
max_steps: 20,
wall_time: DEFAULT_CHILD_WALL_TIME,
wall_time: None,
wall_ceiling_ms: None,
input_rx,
launch_gate: None,
@@ -27293,7 +27299,7 @@ async fn late_launch_permit_still_gets_the_full_work_budget() {
fork_context: false,
started_at: Instant::now(),
max_steps: 1,
wall_time: WALL_TIME,
wall_time: Some(WALL_TIME),
wall_ceiling_ms: None,
input_rx,
launch_gate: Some(Arc::clone(&gate)),
+6 -10
View File
@@ -27,8 +27,8 @@ impl WorkflowWorkspaceState {
}
/// Hydrate the journal without rewriting leftover `running` rows to
/// `failed`. Host cancel uses this after a restart so a controller-less
/// run can still be marked cancelled instead of looking like a crash.
/// `failed`. Host reads do not claim runtime ownership; host cancel can
/// still mark a controller-less run cancelled instead of assuming a crash.
pub fn open_preserving_running(workspace: &Path) -> Arc<Self> {
Self::open_inner(workspace, false)
}
@@ -261,8 +261,7 @@ impl WorkflowRunJournal {
}
// A run journaled as Running belongs to a process that is gone;
// without this it would show as live forever after a restart.
// Host cancel skips this rewrite so it can still mark the line
// cancelled with an honest "nothing live to stop" receipt.
// Host reads and cancellation skip this owner-only rewrite.
if recover_orphans {
let mut recovered = Vec::new();
for run in runs.values_mut() {
@@ -826,7 +825,7 @@ mod tests {
"status": "failed"
}))
.expect("task_completed event");
let replay = WorkflowWorkspaceState::open(tmp.path());
let replay = WorkflowWorkspaceState::open_preserving_running(tmp.path());
replay.record_event("workflow_detail", &phase);
replay.record_event("workflow_detail", &started);
replay.record_event("workflow_detail", &completed);
@@ -836,11 +835,8 @@ mod tests {
assert_eq!(details.len(), 1, "one journaled run");
let detail = &details[0];
assert_eq!(detail.line.run_id, "workflow_detail");
// Journal-only `running` rows hydrate through restart-orphan
// recovery (the same rewrite `WorkflowWorkspaceState::open`
// applies), so the host projection reports the run as failed —
// live in-process runs keep `running` via the shared state.
assert_eq!(detail.line.status, "failed");
assert_eq!(detail.line.status, "running");
assert!(peek_shared_workflow_state(tmp.path()).is_none());
assert_eq!(detail.line.label, "audit provider errors");
assert_eq!(detail.phases, vec!["scan".to_string()]);
assert_eq!(detail.children.len(), 1);
+376 -208
View File
@@ -25,8 +25,8 @@ use codewhale_workflow::{
};
use codewhale_workflow_js::{
BudgetSnapshot, DriverError, ProgressEvent, SCHEMA_RAW_PREVIEW_CHARS, SpawnedTask,
TaskCompletion, TaskRequest, WORKFLOW_LIFETIME_CAP, WORKFLOW_MAX_CONCURRENT, WorkflowDriver,
WorkflowRunCancel, WorkflowVm,
TaskCompletion, TaskRequest, WORKFLOW_MAX_CONCURRENT, WorkflowDriver, WorkflowRunCancel,
WorkflowVm,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -664,8 +664,10 @@ enum WorkflowUiEventKind {
},
BudgetUpdated {
total: Option<u64>,
spent: u64,
spent: Option<u64>,
remaining: Option<u64>,
#[serde(default)]
metered: bool,
},
Log {
message: String,
@@ -1165,7 +1167,7 @@ impl ToolSpec for WorkflowTool {
fn description(&self) -> &'static str {
concat!(
"Run named steps through the existing sub-agents with a structured plan, ordered phases, shared budgets and result handoffs. Fleet configures those same sub-agents and roles. ",
"Run named steps through the existing sub-agents with a structured plan, ordered phases and result handoffs. Independent steps continue after an ordinary child failure; steps with missing required results are skipped. Fleet configures those same sub-agents and roles. ",
"A workflow is for broad or staged work: several steps with an order, phases, gates, or a fan-in of results. ",
"agent(action=\"roster\") lists the saved models, role/profile assignments, and route availability that steps can be assigned. ",
"plan is the structured input form for multi-step work; saved or advanced workflows can use script/source_path; exactly one input form is accepted. ",
@@ -1188,7 +1190,7 @@ impl ToolSpec for WorkflowTool {
},
"script": {
"type": "string",
"description": "Workflow JS source. The runtime provides args, task(...), parallel(thunks), pipeline(thunks), log(...), phase(...), and budget. Fan-out syntax: await parallel([() => task({...}), () => task({...})]). parallel() requires one array of zero-argument thunks, not variadic task promises."
"description": "Workflow JS source. The runtime provides args, task(...), parallel(thunks), pipeline(items, ...stages), log(...), phase(...), and budget. Fan-out syntax: await parallel([() => task({...}), () => task({...})]). parallel() requires one array of zero-argument thunks, not variadic task promises."
},
"source_path": {
"type": "string",
@@ -1214,11 +1216,6 @@ impl ToolSpec for WorkflowTool {
],
"description": "JSON value exposed to the script as args. Defaults to null."
},
"token_budget": {
"type": "integer",
"minimum": 1,
"description": "Optional shared Workflow admission cap; omit for no cap. Usage is reconciled when children report completion; already-running parallel children can take aggregate spent past the cap, while later and descendant spawns are rejected once exhausted."
},
"wait": {
"type": "boolean",
"description": "For action=start, wait for completion instead of returning immediately."
@@ -1384,28 +1381,21 @@ async fn start_workflow(
}
let mut source = workflow_source(&input, context)?;
let workflow_cfg = workflow_config_for(&runtime);
for (name, value) in [
("max_children", workflow_cfg.max_children),
("max_depth", workflow_cfg.max_depth),
("max_concurrent", workflow_cfg.max_concurrent),
] {
if value == 0 {
return Err(ToolError::invalid_input(format!(
"workflow.{name} must be greater than zero"
)));
}
if workflow_cfg.max_concurrent == 0 {
return Err(ToolError::invalid_input(
"workflow.max_concurrent must be greater than zero",
));
}
if let Some(spec) = source.spec.as_mut() {
validate_runtime_gates(&mut spec.gates)?;
spec.validate_for_fleet_with_limits(WorkflowFleetLimits {
max_total_agents: (workflow_cfg.max_children as usize)
.min(codewhale_workflow::DEFAULT_FLEET_WORKFLOW_MAX_AGENTS),
max_depth: (workflow_cfg.max_depth as usize)
.min(codewhale_workflow::DEFAULT_FLEET_WORKFLOW_MAX_DEPTH),
max_total_agents: workflow_cfg.max_children as usize,
max_depth: workflow_cfg.max_depth as usize,
})
.map_err(|err| ToolError::invalid_input(format!(
"Workflow plan exceeds workflow.max_children or workflow.max_depth, or is invalid: {err}"
)))?;
validate_gate_phase_order(spec)?;
}
let args = input.get("args").cloned().unwrap_or(Value::Null);
let token_budget = optional_u64(&input, "token_budget", 0)?;
@@ -2774,8 +2764,6 @@ struct StructuredWorkflowPlan {
#[serde(default)]
risk: Option<String>,
#[serde(default)]
max_children: Option<usize>,
#[serde(default)]
token_budget: Option<u64>,
#[serde(default)]
phases: Vec<StructuredPlanPhase>,
@@ -2960,16 +2948,6 @@ pub(crate) fn structured_plan_to_workflow_spec(
));
}
let mut total_children = 0usize;
count_plan_leaves(&nodes, &mut total_children);
if let Some(max_children) = plan.max_children
&& total_children > max_children
{
return Err(ToolError::invalid_input(format!(
"Workflow plan declares {total_children} children which exceeds max_children={max_children}"
)));
}
let spec = WorkflowSpec {
id: None,
goal: goal.to_string(),
@@ -3136,7 +3114,7 @@ fn parse_plan_agent_type(raw: Option<&str>) -> Result<AgentType, ToolError> {
/// pass through the JavaScript author's normalization path.
fn validate_runtime_gates(gates: &mut [GateSpec]) -> Result<(), ToolError> {
let mut ids = HashSet::new();
for gate in gates {
for gate in gates.iter_mut() {
gate.id = gate.id.trim().to_string();
if gate.id.is_empty() || !ids.insert(gate.id.clone()) {
return Err(ToolError::invalid_input(
@@ -3170,22 +3148,72 @@ fn validate_runtime_gates(gates: &mut [GateSpec]) -> Result<(), ToolError> {
)));
}
}
for gate in gates.iter() {
let mut pending: Vec<&str> = gate.blocks_role.as_deref().into_iter().collect();
let mut visited = HashSet::new();
while let Some(role) = pending.pop() {
if role == gate.role {
return Err(ToolError::invalid_input(format!(
"Workflow gate `{}` forms a role dependency cycle through `{role}`",
gate.id
)));
}
if visited.insert(role) {
pending.extend(
gates
.iter()
.filter(|next| next.role == role)
.filter_map(|next| next.blocks_role.as_deref()),
);
}
}
}
Ok(())
}
fn count_plan_leaves(nodes: &[WorkflowNode], total: &mut usize) {
for node in nodes {
match node {
WorkflowNode::Leaf(_) => *total += 1,
WorkflowNode::BranchSet(spec) => count_plan_leaves(&spec.children, total),
WorkflowNode::Sequence(spec) => count_plan_leaves(&spec.children, total),
WorkflowNode::Reduce(_)
| WorkflowNode::TeacherReview(_)
| WorkflowNode::LoopUntil(_)
| WorkflowNode::Cond(_)
| WorkflowNode::Expand(_) => {}
fn validate_gate_phase_order(spec: &WorkflowSpec) -> Result<(), ToolError> {
fn collect(
nodes: &[WorkflowNode],
first: &mut HashMap<String, usize>,
mut order: usize,
) -> Option<usize> {
for node in nodes {
match node {
WorkflowNode::Leaf(leaf) if leaf.profile.is_none() => {
let role = leaf.role.as_ref()?;
first.entry(role.trim().to_lowercase()).or_insert(order);
order += 1;
}
WorkflowNode::Sequence(sequence) => {
order = collect(&sequence.children, first, order)?;
}
WorkflowNode::BranchSet(branch)
if !branch.parallel || branch.children.len() <= 1 =>
{
order = collect(&branch.children, first, order)?;
}
_ => return None,
}
}
Some(order)
}
let mut first = HashMap::new();
if collect(&spec.nodes, &mut first, 0).is_none() {
return Ok(());
}
for gate in &spec.gates {
if let Some(blocked_role) = gate.blocks_role.as_ref()
&& let (Some(prerequisite), Some(blocked)) =
(first.get(&gate.role), first.get(blocked_role))
&& blocked < prerequisite
{
return Err(ToolError::invalid_input(format!(
"Workflow gate `{}` blocks earlier role `{blocked_role}` before prerequisite role `{}` can complete; reorder the steps or correct the gate",
gate.id, gate.role
)));
}
}
Ok(())
}
fn read_workflow_source_path(
@@ -3379,7 +3407,7 @@ impl DeclarativeWorkflowLowerer {
parallel: bool,
) -> Result<(), ToolError> {
self.line(format!(
"__results[{}] = await task({});",
"__results[{}] = (await parallel([() => task({})]))[0];",
js_string(&spec.id),
leaf_task_options_expression(spec, phase, parallel, self.workspace.as_deref())?
));
@@ -3443,7 +3471,7 @@ impl DeclarativeWorkflowLowerer {
fn lower_reduce(&mut self, spec: &ReduceSpec) -> Result<(), ToolError> {
let inputs = result_inputs_expression(&spec.inputs);
self.line(format!(
"__results[{}] = await task({});",
"__results[{}] = (await parallel([() => task({})]))[0];",
js_string(&spec.id),
task_options_expression(
format!(
@@ -3455,9 +3483,6 @@ impl DeclarativeWorkflowLowerer {
None,
spec.model_policy.model.as_deref(),
false,
None,
None,
None,
Some("read_only"),
&[],
&spec.id,
@@ -3490,24 +3515,6 @@ fn leaf_description(spec: &LeafSpec) -> String {
spec.depends_on_results.join(", ")
));
}
if spec.budget != BudgetSpec::default() {
let mut budget = Vec::new();
if let Some(max_steps) = spec.budget.max_steps {
budget.push(format!("max_steps={max_steps}"));
}
if let Some(timeout_secs) = spec.budget.timeout_secs {
budget.push(format!("timeout_secs={timeout_secs}"));
}
if let Some(max_parallel) = spec.budget.max_parallel {
budget.push(format!("max_parallel={max_parallel}"));
}
if let Some(max_tokens) = spec.budget.max_tokens {
budget.push(format!("max_tokens={max_tokens}"));
}
if !budget.is_empty() {
metadata.push(format!("Budget: {}", budget.join(", ")));
}
}
if !metadata.is_empty() {
description.push_str("\n\nWorkflow metadata:\n");
for item in metadata {
@@ -3559,9 +3566,6 @@ fn leaf_task_options_expression(
// Parallel write-capable children default to worktree isolation (#4120).
// Explicit isolation: shared is the approved same-worktree override.
worktree,
spec.budget.max_tokens,
spec.budget.max_steps,
spec.budget.timeout_secs,
Some(write_authority),
&write_roots,
&spec.id,
@@ -3643,7 +3647,8 @@ fn leaf_description_expression(spec: &LeafSpec) -> String {
let required =
serde_json::to_string(&spec.depends_on_results).expect("dependency IDs serialize to JSON");
format!(
"({required}.forEach(id => {{ if (__results[id] == null) throw new Error('Required Workflow result unavailable: ' + id); }}), {description} + \"\\n\\nInputs:\\n\" + {inputs})"
"({required}.forEach(id => {{ if (__results[id] == null) throw new Error('Skipped Workflow step ' + {} + ': Required Workflow result unavailable: ' + id); }}), {description} + \"\\n\\nInputs:\\n\" + {inputs})",
js_string(&spec.id)
)
}
@@ -3654,7 +3659,7 @@ fn result_inputs_expression(inputs: &[String]) -> String {
.collect::<Vec<_>>()
.join(", ");
format!(
"[{entries}].map(([id, value]) => \"--- \" + id + \" ---\\n\" + String(value ?? \"\")).join(\"\\n\\n\")"
"[{entries}].map(([id, value]) => \"--- \" + id + \" ---\\n\" + String(value ?? \"[unavailable: this step produced no result]\")).join(\"\\n\\n\")"
)
}
@@ -3716,9 +3721,6 @@ fn task_options_expression(
profile: Option<&str>,
model: Option<&str>,
worktree: bool,
token_budget: Option<u64>,
max_steps: Option<u32>,
wall_time_secs: Option<u64>,
write_authority: Option<&str>,
write_roots: &[String],
label: &str,
@@ -3749,18 +3751,6 @@ fn task_options_expression(
if worktree {
fields.push("worktree: true".to_string());
}
if let Some(token_budget) = token_budget {
fields.push(format!("tokenBudget: {token_budget}"));
}
// Workflow's saved budget uses zero for no additional step cap. Omit
// that sentinel at the Agent boundary, where an explicit cap must be
// positive and may only narrow inherited authority.
if let Some(max_steps) = max_steps.filter(|steps| *steps > 0) {
fields.push(format!("maxSteps: {max_steps}"));
}
if let Some(wall_time_secs) = wall_time_secs {
fields.push(format!("wallTimeSecs: {wall_time_secs}"));
}
if let Some(write_authority) = write_authority {
fields.push(format!("writeAuthority: {}", js_string(write_authority)));
}
@@ -4031,7 +4021,7 @@ impl SubAgentWorkflowDriver {
child_ids: Arc::new(Mutex::new(Vec::new())),
child_counter: AtomicU32::new(0),
queue_tickets: AtomicU64::new(0),
max_children: workflow_cfg.max_children.min(WORKFLOW_LIFETIME_CAP as u32),
max_children: workflow_cfg.max_children,
current_phase: Mutex::new(None),
task_records: Arc::new(Mutex::new(HashMap::new())),
dead_fanouts: AtomicU32::new(0),
@@ -4815,7 +4805,7 @@ impl SubAgentWorkflowDriver {
// Rust 1.99 and its `try_update` replacement is newer than the MSRV.
let mut count = self.child_counter.load(Ordering::SeqCst);
let workflow_child_index = loop {
if count >= self.max_children {
if self.max_children > 0 && count >= self.max_children {
return Err(DriverError::Rejected(format!(
"workflow.max_children limit ({}) reached; reduce remaining work or start a new reviewed plan",
self.max_children
@@ -4952,7 +4942,12 @@ impl SubAgentWorkflowDriver {
#[async_trait]
impl WorkflowDriver for SubAgentWorkflowDriver {
async fn spawn_task(&self, request: TaskRequest) -> Result<SpawnedTask, DriverError> {
async fn spawn_task(&self, mut request: TaskRequest) -> Result<SpawnedTask, DriverError> {
if request.max_depth.take().is_some() {
self.progress(ProgressEvent::Log {
message: "Ignored model task maxDepth; trusted Fleet, role and parent delegation constraints apply.".to_string(),
});
}
let label = request
.label
.as_deref()
@@ -4983,6 +4978,10 @@ impl WorkflowDriver for SubAgentWorkflowDriver {
self.force_cancel_all();
}
fn budget_is_metered(&self) -> bool {
false
}
fn budget(&self) -> BudgetSnapshot {
let snapshot = self.current_budget_snapshot();
self.record_budget_snapshot(snapshot);
@@ -5140,8 +5139,9 @@ impl WorkflowDriver for SubAgentWorkflowDriver {
fn budget_event_kind(snapshot: BudgetSnapshot) -> WorkflowUiEventKind {
WorkflowUiEventKind::BudgetUpdated {
total: snapshot.total,
spent: snapshot.spent,
remaining: snapshot.remaining(),
spent: None,
remaining: None,
metered: false,
}
}
@@ -5232,6 +5232,32 @@ fn execution_from_declarative_spec(
for node in &spec.nodes {
push_execution_node(node, &by_label, &mut execution);
}
if terminal_status != WorkflowRunStatus::Running {
let status = if terminal_status == WorkflowRunStatus::Cancelled {
IrWorkflowRunStatus::Cancelled
} else {
IrWorkflowRunStatus::Failed
};
for leaf in &mut execution.leaf_results {
if leaf.status == IrWorkflowRunStatus::Pending {
leaf.status = status;
leaf.output = Some("Skipped: no child was admitted for this step; see dispatch failures and progress for the refusal or missing dependency.".to_string());
}
}
for control in &mut execution.control_node_results {
if control.status == IrWorkflowRunStatus::Pending {
control.status = status;
}
}
for branch in &mut execution.branch_results {
if branch.status == IrWorkflowRunStatus::Pending {
branch.status = status;
}
}
if execution.status == IrWorkflowRunStatus::Pending {
execution.status = status;
}
}
let mut leaf_usage = execution.leaf_results.iter().map(|leaf| leaf.usage);
execution.usage = leaf_usage
.next()
@@ -5774,7 +5800,7 @@ pub(crate) fn structcopy_run_projection(
/// One workflow run as the human-facing `/workflow` command reads it: a
/// bounded projection of the run record with no raw event payloads and no
/// filesystem paths beyond the source file name.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct HostWorkflowRunLine {
pub run_id: String,
/// Human-facing stage derived from the durable owner record and its typed
@@ -5871,7 +5897,8 @@ fn host_workflow_state(workspace: &Path) -> Option<Arc<WorkflowWorkspaceState>>
// No live state yet: hydrate only if a journal already exists so
// status/cancel can see runs from a previous process without creating
// files in a workspace that never ran a workflow.
workflow_journal_exists(workspace).then(|| shared_workflow_state(workspace))
workflow_journal_exists(workspace)
.then(|| WorkflowWorkspaceState::open_preserving_running(workspace))
}
/// Cancel hydrates an on-disk journal without the restart-orphan Failed
@@ -5923,7 +5950,7 @@ pub(crate) fn host_workflow_runs(
/// and a terminal state derived from the task-completed event (`running`
/// until one exists). Same bounded projection rules as
/// [`HostWorkflowRunLine`]: no raw event payloads, no filesystem paths.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct HostWorkflowChildRow {
pub task_id: String,
pub label: Option<String>,
@@ -5939,7 +5966,7 @@ pub(crate) struct HostWorkflowChildRow {
/// from the bounded event tail, so a long run shows the newest phases and
/// children, not the whole history. Read-only: never creates the journal or
/// workspace state.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct HostWorkflowRunDetail {
pub line: HostWorkflowRunLine,
pub phases: Vec<String>,
@@ -7825,7 +7852,7 @@ permissions = "read_only"
}
#[test]
fn declarative_leaf_budget_reaches_task_runtime_options() {
fn declarative_legacy_budget_options_do_not_instruct_child_stopping() {
let source = r#"
workflow({
"goal": "bound one child",
@@ -7840,17 +7867,9 @@ workflow({
"#;
let adapted = adapt_workflow_source(source, None).expect("lower bounded leaf");
assert!(
adapted.source.contains("tokenBudget: 5000"),
"{}",
adapted.source
);
assert!(adapted.source.contains("maxSteps: 4"), "{}", adapted.source);
assert!(
adapted.source.contains("wallTimeSecs: 90"),
"{}",
adapted.source
);
for legacy in ["tokenBudget:", "maxSteps:", "wallTimeSecs:", "Budget:"] {
assert!(!adapted.source.contains(legacy), "{}", adapted.source);
}
}
#[tokio::test]
@@ -8701,10 +8720,10 @@ export default workflow({
.unwrap_err();
assert!(missing_goal.to_string().contains("goal"), "{missing_goal}");
let over_limit = workflow_source(
let legacy_limit = workflow_source(
&json!({
"plan": {
"goal": "too many children",
"goal": "legacy model limit does not stop work",
"max_children": 1,
"children": [
{ "id": "a", "prompt": "one" },
@@ -8714,11 +8733,9 @@ export default workflow({
}),
&ctx,
)
.unwrap_err();
assert!(
over_limit.to_string().contains("max_children"),
"{over_limit}"
);
.expect("legacy model-selected child cap is ignored");
assert!(legacy_limit.source.contains("one"));
assert!(legacy_limit.source.contains("two"));
let bad_type = workflow_source(
&json!({
@@ -9727,6 +9744,50 @@ reviewer = "reviewer"
assert_eq!(task_phases, ["inspect", "finish"]);
}
}
let tmp = tempfile::tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
let (client, calls, _bodies, api_config) =
fake_chat_client_capturing("independent-output").await;
let runtime = SubAgentRuntime::new(
client,
"deepseek-v4-flash".to_string(),
ctx.clone(),
true,
None,
manager,
)
.with_api_config(api_config);
let tool = WorkflowTool::new(runtime.manager.clone(), runtime);
let result = tool.execute(json!({
"action":"run", "plan":{
"goal":"retain independent work after a failed sequential step", "nodes":[
{"sequence":{"id":"steps", "children":[
{"agent":{"id":"missing", "prompt":"missing profile", "agent_type":"review", "profile":"required-profile-does-not-exist"}},
{"agent":{"id":"dependent", "prompt":"must not run", "agent_type":"review", "depends_on_results":["missing"]}},
{"agent":{"id":"independent", "prompt":"finish independent work", "agent_type":"review"}}
]}}
]
}
}), &ctx).await.expect("settled sequential receipt");
let payload: Value = serde_json::from_str(&result.content).expect("json");
assert_eq!(payload["status"], "degraded", "{payload}");
assert_eq!(calls.load(Ordering::SeqCst), 1, "{payload}");
assert_eq!(payload["result"]["independent"], "independent-output");
assert!(payload["result"]["dependent"].is_null());
assert!(
payload
.to_string()
.contains("Skipped Workflow step dependent")
);
assert!(
payload["execution"]["leaf_results"]
.as_array()
.unwrap()
.iter()
.all(|leaf| leaf["status"] != "pending")
);
}
#[tokio::test]
@@ -10836,73 +10897,126 @@ reviewer = "reviewer"
#[allow(clippy::await_holding_lock)]
async fn workflow_status_surfaces_schema_failure_instead_of_null_success() {
let _retry_guard = workflow_test_retry_guard();
let tmp = tempfile::tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
let (client, _calls, api_config) = fake_chat_client(r#"{"refuted":"yes"}"#).await;
let runtime = SubAgentRuntime::new(
client,
"deepseek-v4-flash".to_string(),
ctx.clone(),
true,
None,
manager.clone(),
)
.with_api_config(api_config);
let tool = WorkflowTool::new(manager, runtime);
let run = tool
.execute(
json!({
"action": "run",
"script": r#"
return await parallel([
() => task({
description: "Return the schema fixture.",
responseSchema: {
type: "object",
properties: { refuted: { type: "boolean" } },
required: ["refuted"],
},
}),
]);
"#
}),
&ctx,
for agent_type in ["review", "general"] {
let tmp = tempfile::tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
let (client, calls, bodies, api_config) =
fake_chat_client_capturing(r#"{"refuted":"yes"}"#).await;
let runtime = SubAgentRuntime::new(
client,
"deepseek-v4-flash".to_string(),
ctx.clone(),
true,
None,
manager.clone(),
)
.await
.expect("workflow run returns a record");
let run_payload: Value = serde_json::from_str(&run.content).expect("run json");
.with_api_config(api_config);
let tool = WorkflowTool::new(manager, runtime);
assert_eq!(run_payload["status"], "failed");
assert!(run_payload["result"].is_null());
assert!(
run_payload["error"]
.as_str()
.unwrap()
.contains("responseSchema validation")
);
assert!(
run_payload["progress"]
.as_array()
.unwrap()
.iter()
.any(|message| message
let run = tool
.execute(
json!({
"action": "run",
"script": r#"
return await parallel([
() => task({
type: "AGENT_TYPE",
description: "Return the schema fixture.",
responseSchema: {
type: "object",
properties: { refuted: { type: "boolean" } },
required: ["refuted"],
},
}),
]);
"#.replace("AGENT_TYPE", agent_type)
}),
&ctx,
)
.await
.expect("workflow run returns a record");
let run_payload: Value = serde_json::from_str(&run.content).expect("run json");
assert_eq!(run_payload["status"], "failed");
assert!(run_payload["result"].is_null());
assert!(
run_payload["error"]
.as_str()
.is_some_and(|message| message.contains("schema validation failed"))),
"schema validation error should be visible in the run receipt: {run_payload}"
);
assert!(
run_payload["events"]
.as_array()
.unwrap()
.iter()
.any(|event| event["type"] == "task_schema_validation_failed"
&& event["message"]
.unwrap()
.contains("responseSchema validation")
);
assert!(
run_payload["progress"]
.as_array()
.unwrap()
.iter()
.any(|message| message
.as_str()
.is_some_and(|message| message.contains("responseSchema validation"))),
"schema validation event should be visible in the typed receipt: {run_payload}"
);
.is_some_and(|message| message.contains("schema validation failed"))),
"schema validation error should be visible in the run receipt: {run_payload}"
);
assert!(
run_payload["events"]
.as_array()
.unwrap()
.iter()
.any(|event| event["type"] == "task_schema_validation_failed"
&& event["message"]
.as_str()
.is_some_and(|message| message.contains("responseSchema validation"))),
"schema validation event should be visible in the typed receipt: {run_payload}"
);
assert_eq!(calls.load(Ordering::SeqCst), 2, "one repair re-ask");
let bodies = bodies.lock().expect("captured schema requests");
let system = bodies[0]["messages"]
.as_array()
.expect("first request messages")
.iter()
.filter(|message| matches!(message["role"].as_str(), Some("system" | "developer")))
.map(|message| message["content"].as_str().expect("system text"))
.collect::<Vec<_>>()
.join("\n");
if agent_type == "review" {
assert!(
system.contains("For this read-only Workflow task"),
"{system}"
);
assert!(
system.contains(
&json!({
"type": "object",
"properties": { "refuted": { "type": "boolean" } },
"required": ["refuted"],
})
.to_string()
),
"{system}"
);
assert!(
!system.contains("## Output contract (mandatory)"),
"{system}"
);
assert!(
!system.contains("## Verify-before-return (write child)"),
"{system}"
);
} else {
assert!(
!system.contains("For this read-only Workflow task"),
"{system}"
);
assert!(
system.contains("## Output contract (mandatory)"),
"{system}"
);
assert!(
system.contains("## Verify-before-return (write child)"),
"{system}"
);
assert!(system.contains("VERDICT: PASS | FAIL"), "{system}");
}
}
}
#[tokio::test]
@@ -12220,11 +12334,18 @@ FINAL RECEIPT
let payload: Value = serde_json::from_str(&result.content).expect("json result");
assert_eq!(payload["status"], "completed", "{payload}");
// #6189 removed per-scope token tracking: `spent` is deliberately 0
// and the declared ceiling survives only as a reported total.
assert_eq!(payload["result"]["spent"], 0, "{payload}");
// #6189 removed per-scope token tracking: usage is unknown,
// and the declared hint survives only as a reported total.
assert!(payload["result"]["spent"].is_null(), "{payload}");
assert_eq!(payload["result"]["total"], 1000);
assert_eq!(payload["result"]["remaining"], 1000);
assert!(payload["result"]["remaining"].is_null(), "{payload}");
for event in payload["events"].as_array().expect("events") {
if event["type"] == "budget_updated" {
assert_eq!(event["metered"], false);
assert!(event["spent"].is_null());
assert!(event["remaining"].is_null());
}
}
assert_eq!(calls.load(Ordering::SeqCst), 1);
let bodies = bodies.lock().expect("captured first request");
let max_tokens = bodies[0]
@@ -13352,23 +13473,21 @@ FINAL RECEIPT
);
assert_eq!(driver.child_counter.load(Ordering::SeqCst), 1);
assert!(driver.child_ids.lock().expect("children").is_empty());
for name in ["max_children", "max_concurrent", "max_depth"] {
let mut config = serde_json::to_value(codewhale_config::WorkflowConfigToml::default())
.expect("config");
config[name] = json!(0);
set_session_workflow_config(
tmp.path(),
serde_json::from_value(config).expect("zero config"),
);
let error = tool
.execute(json!({"action":"start", "script":"return 1;"}), &context)
.await
.expect_err("zero ceiling fails without waiting on a semaphore");
assert!(
error.to_string().contains(&format!("workflow.{name}")),
"{error}"
);
}
set_session_workflow_config(
tmp.path(),
codewhale_config::WorkflowConfigToml {
max_concurrent: 0,
..Default::default()
},
);
let error = tool
.execute(json!({"action":"start", "script":"return 1;"}), &context)
.await
.expect_err("zero concurrency fails without waiting on a semaphore");
assert!(
error.to_string().contains("workflow.max_concurrent"),
"{error}"
);
}
#[test]
@@ -13412,6 +13531,56 @@ FINAL RECEIPT
assert!(validate_runtime_gates(&mut gates).is_err());
gates[0].blocks_role = Some("verify".to_string());
validate_runtime_gates(&mut gates).expect("blocking a later role stays valid");
let mut reverse = gates[0].clone();
reverse.id = "reverse".to_string();
reverse.role = "verify".to_string();
reverse.blocks_role = Some("implement".to_string());
gates.push(reverse);
assert!(
validate_runtime_gates(&mut gates)
.unwrap_err()
.to_string()
.contains("cycle")
);
let mut board = LaneGateBoard::new("cycle");
board.install_gates(&gates);
assert!(board.role_is_blocked(&gates, "implement").is_some());
assert!(board.role_is_blocked(&gates, "verify").is_some());
gates.pop();
for backward in [false, true] {
let mut phases = vec![
json!({"children":[{"prompt":"implement", "role":"implement"}]}),
json!({"children":[{"prompt":"verify", "role":"verify"}]}),
];
if backward {
phases.reverse();
}
let mut spec = structured_plan_to_workflow_spec(&json!({
"goal":"gate order", "risk":"writes", "phases":phases
}))
.expect("plan");
spec.gates = gates.clone();
assert_eq!(validate_gate_phase_order(&spec).is_err(), backward);
}
let mut spec = structured_plan_to_workflow_spec(&json!({
"goal":"parallel gate order",
"children":[{"prompt":"verify", "role":"verify"}]
}))
.expect("parallel plan");
spec.gates = gates;
spec.nodes = vec![serde_json::from_value(json!({
"kind":"branch_set", "spec":{
"id":"concurrent", "parallel":true,
"children":[
{"kind":"leaf", "spec":{"id":"verify", "prompt":"verify", "role":"verify"}},
{"kind":"sequence", "spec":{"id":"prepare", "children":[
{"kind":"leaf", "spec":{"id":"scout", "prompt":"scout", "role":"scout"}},
{"kind":"leaf", "spec":{"id":"implement", "prompt":"implement", "role":"implement"}}
]}}
]
}
})).expect("parallel branch")];
validate_gate_phase_order(&spec).expect("concurrent branches have no total role order");
}
#[tokio::test]
@@ -13426,17 +13595,16 @@ FINAL RECEIPT
},
);
for (explicit, expected) in [(None, 123), (Some(17), 17), (Some(246), 246)] {
let mut input = json!({"action":"run", "script":"return budget.remaining();"});
let mut input = json!({"action":"run", "script":"return { total: budget.total, remaining: budget.remaining(), metered: budget.metered };"});
if let Some(explicit) = explicit {
input["token_budget"] = json!(explicit);
}
let receipt = tool.execute(input, &context).await.expect("budget run");
let receipt: Value = serde_json::from_str(&receipt.content).expect("budget receipt");
assert_eq!(receipt["token_budget"], expected);
assert_eq!(
receipt["result"], expected,
"the same effective cap reaches the VM"
);
assert_eq!(receipt["result"]["total"], expected);
assert!(receipt["result"]["remaining"].is_null());
assert_eq!(receipt["result"]["metered"], false);
}
}
+3 -23
View File
@@ -77,7 +77,7 @@ pub(super) fn structured_plan_schema() -> Value {
"type": "array",
"minItems": 1,
"items": child.clone(),
"description": "At least one child. Phases run in order and receive prior-phase results; missing required results block downstream dispatch."
"description": "At least one child. Phases run in order and receive prior-phase results; missing required results skip dependent steps while independent work continues."
}
},
"required": ["children"],
@@ -145,16 +145,6 @@ pub(super) fn structured_plan_schema() -> Value {
"enum": ["read_only", "writes", "elevated"],
"description": "Plan risk and default child mode. Defaults to read_only; writes/elevated remain subject to approval and caller policy."
},
"max_children": {
"type": "integer",
"minimum": 1,
"description": "Optional maximum total declared children across all phases."
},
"token_budget": {
"type": "integer",
"minimum": 1,
"description": "Optional Workflow token budget, also applied to child admission. Usage is reconciled at completion; active parallel children can exceed the shared hint."
},
"phases": {
"type": "array",
"items": phase,
@@ -232,15 +222,7 @@ mod tests {
assert_eq!(
property_names(&schema),
[
"children",
"gates",
"goal",
"max_children",
"phases",
"risk",
"token_budget"
]
["children", "gates", "goal", "phases", "risk"]
);
let phase = &schema["properties"]["phases"]["items"];
assert_eq!(
@@ -300,7 +282,7 @@ mod tests {
}
}
for (object, names) in [
(&schema, &["risk", "max_children", "token_budget"][..]),
(&schema, &["risk"][..]),
(phase, &["id", "title", "parallel"][..]),
(
child,
@@ -322,8 +304,6 @@ mod tests {
let plan = json!({
"goal": "Inspect the release receipts",
"risk": null,
"max_children": null,
"token_budget": null,
"phases": [{
"id": null,
"title": null,
@@ -238,10 +238,20 @@ async fn native_non_shortlisted_model_is_rejected_without_a_provider_call() {
let source = native_script(json!({
"prompt": "read-only route check", "type": "reviewer", "model": "openrouter/outside-fixture",
}));
let error = run_script(&source, fixture.driver(WorkflowFleetBinding::None))
let driver = fixture.driver(WorkflowFleetBinding::None);
let result = run_script(&source, driver.clone())
.await
.expect_err("closed shortlist");
assert!(error.contains("outside the selected Fleet"), "{error}");
.expect("rejected children settle without aborting independent work");
assert_eq!(result, json!({"plan-child-1": null}));
let runs = driver.state.runs.lock().expect("run ledger");
let record = runs.get(&driver.run_id).expect("run receipt");
assert_eq!(record.dispatch_failure_count, 1);
assert!(
record.dispatch_failures[0]
.message
.contains("outside the selected Fleet")
);
assert_eq!(driver.dead_fanout_count(), 1);
assert_eq!(fixture.parent_calls.load(Ordering::SeqCst), 0);
assert_eq!(fixture.target_calls.load(Ordering::SeqCst), 0);
}
+1 -1
View File
@@ -1329,7 +1329,7 @@ pub(crate) fn flush_background_finished(app: &mut App, config: &Config, parent_i
return;
}
let mode = config.notifications_config().subagent_completion;
let finite_work_live = session_state::live_running_agent_count(app, Instant::now()) > 0
let finite_work_live = session_state::live_running_agent_count(app) > 0
|| frame::workflow_tool_is_running(app)
|| app.task_panel.iter().any(|entry| {
// A stale entry (a recovered task whose ownership is unverified)
+2 -1
View File
@@ -416,13 +416,14 @@ fn desired_goal_state(
let goal = base
.as_mut()
.ok_or_else(|| "No goal is available for this control.".to_string())?;
let was_active = goal.status == crate::session_manager::SessionGoalStatus::Active;
goal.status = match status {
GoalStatus::Active => crate::session_manager::SessionGoalStatus::Active,
GoalStatus::Paused => crate::session_manager::SessionGoalStatus::Paused,
GoalStatus::Complete => crate::session_manager::SessionGoalStatus::Complete,
GoalStatus::Blocked => crate::session_manager::SessionGoalStatus::Blocked,
};
if *status == GoalStatus::Active {
if *status == GoalStatus::Active && !was_active {
goal.goal_id = Some(uuid::Uuid::new_v4().to_string());
goal.last_gap_fingerprint = None;
goal.repeated_gap_count = 0;
+9 -19
View File
@@ -159,30 +159,20 @@ pub(crate) fn restore_matching_offline_queue_state(
true
}
/// A Running sub-agent older than every child's wall budget cannot still be
/// doing bounded work: its terminal event was lost or its task is wedged
/// (#6184 H2). The default child wall budget plus generous grace.
pub(crate) const SUBAGENT_SUSPECT_AFTER: Duration =
crate::tools::subagent::DEFAULT_CHILD_WALL_TIME.saturating_add(Duration::from_secs(5 * 60));
/// Running sub-agents that are past their bound: shown as suspect, and never a
/// veto on turn recovery. A prior-session row still marked Running cannot be
/// live in this process at all.
pub(crate) fn suspect_running_agents(app: &App, now: Instant) -> Vec<String> {
/// Prior-session Running rows have no live actor in this process. Elapsed
/// time alone does not establish that a current child is stalled.
pub(crate) fn suspect_running_agents(app: &App) -> Vec<String> {
app.subagent_cache
.iter()
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
.filter(|agent| match agent.started_at {
Some(started) => now.saturating_duration_since(started) > SUBAGENT_SUSPECT_AFTER,
None => agent.from_prior_session,
})
.filter(|agent| agent.from_prior_session)
.map(|agent| agent.agent_id.clone())
.collect()
}
/// Running sub-agents that still legitimately hold the turn open.
pub(crate) fn live_running_agent_count(app: &App, now: Instant) -> usize {
let suspects = suspect_running_agents(app, now);
pub(crate) fn live_running_agent_count(app: &App) -> usize {
let suspects = suspect_running_agents(app);
let mut ids: std::collections::HashSet<&str> =
app.agent_progress.keys().map(String::as_str).collect();
for agent in app
@@ -207,10 +197,10 @@ fn held_queue_note(app: &App) -> String {
/// Log, record under `crashes/`, and name a stall the UI watchdog saw.
fn record_ui_stall(app: &App, phase: &str, since_progress: Duration, bound: Duration) {
let suspects = suspect_running_agents(app, Instant::now());
let suspects = suspect_running_agents(app);
let detail = (!suspects.is_empty()).then(|| {
format!(
"sub-agent(s) past their bound, treated as suspect: {}",
"sub-agent(s) retained from a prior session, with no live actor: {}",
suspects.join(", ")
)
});
@@ -257,7 +247,7 @@ pub(crate) fn reconcile_turn_liveness_supervised(
let text = format!("{}{}", stall.status_line(), held_queue_note(app));
app.push_status_toast(text, StatusToastLevel::Error, None);
}
let has_live_agents = live_running_agent_count(app, now) > 0;
let has_live_agents = live_running_agent_count(app) > 0;
let recovered = reconcile_turn_liveness_with(app, now, has_live_agents, Some(heartbeat));
if recovered && turn_in_progress && app.runtime_turn_status.is_none() {
engine.cancel_with_reason(crate::core::engine::CancelReason::Stalled);
+11 -14
View File
@@ -15742,12 +15742,10 @@ fn stall_engine_report_shows_phase_and_held_queue() {
assert!(stall_toasts[0].text.contains("1 queued message is held"));
}
/// Fault injection: a sub-agent still marked Running long past every child's
/// wall budget (its AgentComplete was lost) no longer vetoes recovery; the
/// recovery leaves a log line, a `crashes/` record naming the suspect, and a
/// UI status.
/// Prior-session Running rows do not veto recovery. Current children remain
/// live regardless of elapsed time.
#[test]
fn stall_parked_subagent_past_bound_is_suspect_not_a_veto() {
fn stall_prior_session_subagent_is_suspect_but_long_running_child_is_live() {
use crate::core::engine::turn_heartbeat::{TurnPhase, set_test_stall_record_dir};
let dir = tempfile::tempdir().expect("tempdir");
set_test_stall_record_dir(Some(dir.path().to_path_buf()));
@@ -15766,19 +15764,16 @@ fn stall_parked_subagent_past_bound_is_suspect_not_a_veto() {
"agent_ghost",
crate::tools::subagent::SubAgentStatus::Running,
);
ghost.started_at = now.checked_sub(SUBAGENT_SUSPECT_AFTER + Duration::from_secs(1));
assert!(
ghost.started_at.is_some(),
"monotonic clock has run long enough"
);
ghost.from_prior_session = true;
ghost.started_at = Some(now);
app.subagent_cache = vec![ghost];
let idle = stall_heartbeat(TurnPhase::Idle, None, None);
assert_eq!(
suspect_running_agents(&app, now),
suspect_running_agents(&app),
vec!["agent_ghost".to_string()]
);
assert_eq!(live_running_agent_count(&app, now), 0);
assert_eq!(live_running_agent_count(&app), 0);
let engine = crate::core::engine::mock_engine_handle();
assert!(!engine.handle.is_cancelled());
assert!(reconcile_turn_liveness_supervised(
@@ -15807,7 +15802,7 @@ fn stall_parked_subagent_past_bound_is_suspect_not_a_veto() {
assert!(records[0].contains("agent_ghost"), "{}", records[0]);
assert!(records[0].contains("Turn: turn-with-ghost"));
// A fresh Running child still holds the turn open.
// A long-running current child still holds the turn open.
let mut app = create_test_app();
app.is_loading = true;
app.runtime_turn_status = Some("in_progress".to_string());
@@ -15817,8 +15812,10 @@ fn stall_parked_subagent_past_bound_is_suspect_not_a_veto() {
"agent_fresh",
crate::tools::subagent::SubAgentStatus::Running,
);
fresh.started_at = Some(now);
fresh.started_at = now.checked_sub(Duration::from_secs(2 * 60 * 60));
assert!(fresh.started_at.is_some());
app.subagent_cache = vec![fresh];
assert_eq!(live_running_agent_count(&app), 1);
let engine = crate::core::engine::mock_engine_handle();
assert!(!reconcile_turn_liveness_supervised(
&mut app,
+1 -2
View File
@@ -303,8 +303,7 @@ impl FleetSetupSnapshot {
.fleet
.as_ref()
.map(|fleet| fleet.exec.max_spawn_depth)
.unwrap_or_else(|| codewhale_config::FleetExecConfig::default().max_spawn_depth)
.min(codewhale_config::MAX_SPAWN_DEPTH_CEILING);
.unwrap_or_else(|| codewhale_config::FleetExecConfig::default().max_spawn_depth);
let roster =
crate::fleet::roster::FleetRoster::load(&config.fleet_config(), &app.workspace);
let roster_members = roster
+1 -4
View File
@@ -515,10 +515,7 @@ impl WorkerRuntimeProfile {
};
// Depth stays absolute; only the current position increments. Every
// authority projection compares the position against this same ceiling.
let max_spawn_depth = requested
.max_spawn_depth
.min(self.max_spawn_depth)
.min(codewhale_config::MAX_SPAWN_DEPTH_CEILING);
let max_spawn_depth = requested.max_spawn_depth.min(self.max_spawn_depth);
WorkerRuntimeProfile {
role: requested.role.clone(),
permissions,
+8 -5
View File
@@ -85,14 +85,13 @@ pub struct TaskRequest {
/// A script that could write it could also clear it.
#[serde(default)]
pub disallowed_tools: Vec<String>,
/// Per-call spawn-depth override (driver clamps to its ceiling).
/// Legacy model input; the runtime replaces it with trusted Fleet/parent depth.
pub max_depth: Option<u32>,
/// Explicit token budget: forks an isolated pool on the driver side.
/// Omit it so the child inherits (and debits) the shared run pool.
/// Legacy token hint; the production driver does not enforce a token pool.
pub token_budget: Option<u64>,
/// Maximum model turns for this child (driver clamps to its ceiling).
/// Legacy model input, ignored by the shared production spawn seam.
pub max_steps: Option<u32>,
/// Hard wall-clock limit for this child in seconds.
/// Legacy model input, ignored by the shared production spawn seam.
pub wall_time_secs: Option<u64>,
/// JSON schema the reply must satisfy; validated in the VM after the
/// driver returns the raw text (see [`crate`] docs for decode rules).
@@ -284,6 +283,10 @@ pub trait WorkflowDriver: Send + Sync {
/// Current snapshot of the run's shared token pool.
fn budget(&self) -> BudgetSnapshot;
fn budget_is_metered(&self) -> bool {
true
}
/// Receive a script progress event (ordered, synchronous).
fn progress(&self, event: ProgressEvent);
+6 -21
View File
@@ -17,9 +17,8 @@
//! * `args` — the invocation input, verbatim.
//! * `await task(opts)` — dispatch one subagent; resolves to the full result
//! text, or to a parsed + schema-validated object when `opts.responseSchema`
//! is set. Throws on rejection, failure, cancellation, budget exhaustion,
//! or once [`WORKFLOW_LIFETIME_CAP`] spawn attempts have been made. A reply
//! that fails `responseSchema` gets up to
//! is set. Throws on rejection, failure, cancellation or an owner budget
//! exhaustion. A reply that fails `responseSchema` gets up to
//! [`SCHEMA_REPAIR_MAX_ATTEMPTS`] bounded repairs (#5583) before it throws;
//! the failed attempts are reported as driver receipts either way.
//! * `parallel(thunks, opts)` — fan-out, at most [`PARALLEL_MAX_ITEMS`] items.
@@ -45,8 +44,9 @@
//! read from the thrown `Error`'s `.kind`, never inferred from message text.
//! * `log(msg)` / `phase(title)` — progress events forwarded to the driver.
//! * `budget.total` / `budget.spent()` / `budget.remaining()` — live driver
//! snapshots (`total` is `null` and `remaining()` is `Infinity` when no
//! ceiling is configured).
//! snapshots. An unmetered driver exposes `budget.metered === false` and
//! null spent/remaining; total is then only the declared hint. Metered
//! drivers use null total and infinite remaining for an unbounded pool.
//!
//! `Date.now()`, `new Date()`, `Date.parse/UTC`, and `Math.random()` throw:
//! runs must be deterministic so recorded traces can be replayed.
@@ -74,13 +74,6 @@ pub use error::{DriverError, TaskErrorKind, WorkflowJsError};
pub use schema::{SCHEMA_RAW_CARRY_CHARS, SCHEMA_RAW_PREVIEW_CHARS, SCHEMA_REPAIR_MAX_ATTEMPTS};
pub use vm::{VmLimits, WorkflowRunCancel, WorkflowVm, normalize_task_cwd, normalize_task_cwd_in};
/// Maximum `task()` spawn attempts per run (design §4.3). Counted in the VM
/// before the driver is consulted, so a runaway `loop-until-dry` terminates
/// even if the driver would keep admitting work.
///
/// Product scale: up to 1_000 agents per Workflow run.
pub const WORKFLOW_LIFETIME_CAP: u64 = 1000;
/// Maximum concurrently executing agents within one Workflow run.
///
/// Fan-out may *declare* more work via `parallel()` / `pipeline()`, but the
@@ -89,13 +82,5 @@ pub const WORKFLOW_LIFETIME_CAP: u64 = 1000;
pub const WORKFLOW_MAX_CONCURRENT: usize = 16;
/// Maximum items per `parallel()` or `pipeline()` call (design §4.2).
/// Kept at the per-run agent ceiling so a single fan-out cannot declare more
/// work than the lifetime cap can ever complete.
/// Bounds one materialized fan-out array; it does not limit total work.
pub const PARALLEL_MAX_ITEMS: usize = 1000;
/// Maximum `tools.call()` invocations per code-mode run.
///
/// Counted in the VM before the invoker is consulted, so a runaway loop
/// terminates even if the invoker would keep admitting calls. Concurrency,
/// deadlines, and result sizes are enforced host-side by the invoker.
pub const CODEMODE_MAX_TOOL_CALLS: u64 = 50;
+22 -86
View File
@@ -13,10 +13,8 @@
//! and `Date`/`Math.random` are overridden to throw so recorded runs stay
//! deterministic for replay.
use std::cell::Cell;
use std::env;
use std::future::Future;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
@@ -33,10 +31,7 @@ use crate::schema::{
ReplyDecodeError, SCHEMA_REPAIR_MAX_ATTEMPTS, carried_raw, compile_schema, decode_reply,
repair_prompt,
};
use crate::{
CODEMODE_MAX_TOOL_CALLS, PARALLEL_MAX_ITEMS, ToolCallRequest, ToolInvoker,
WORKFLOW_LIFETIME_CAP, normalize_profile,
};
use crate::{PARALLEL_MAX_ITEMS, ToolCallRequest, ToolInvoker, normalize_profile};
const DEFAULT_VM_MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
const MIN_VM_MEMORY_LIMIT_BYTES: usize = 4 * 1024 * 1024;
@@ -680,10 +675,9 @@ async fn tools_call_host(
call_json: String,
invoker: Arc<dyn ToolInvoker>,
cancel: CancelHandle,
invoked: Rc<Cell<u64>>,
host_runtime: Handle,
) -> String {
let outcome = tools_call_host_inner(call_json, invoker, cancel, invoked, host_runtime).await;
let outcome = tools_call_host_inner(call_json, invoker, cancel, host_runtime).await;
let envelope = match outcome {
Ok(result) => serde_json::json!({ "result": result }),
Err(TaskError { kind, message }) => {
@@ -697,7 +691,6 @@ async fn tools_call_host_inner(
call_json: String,
invoker: Arc<dyn ToolInvoker>,
cancel: CancelHandle,
invoked: Rc<Cell<u64>>,
host_runtime: Handle,
) -> Result<serde_json::Value, TaskError> {
let admission = |message: String| TaskError::new(TaskErrorKind::Admission, message);
@@ -716,18 +709,12 @@ async fn tools_call_host_inner(
"tools.call(): `input` must be a JSON object".to_string(),
));
}
if invoked.get() >= CODEMODE_MAX_TOOL_CALLS {
return Err(admission(format!(
"tools.call(): per-run tool-call cap ({CODEMODE_MAX_TOOL_CALLS}) reached"
)));
}
if cancel.is_cancelled() {
return Err(TaskError::new(
TaskErrorKind::Cancelled,
"tools.call(): run cancelled".to_string(),
));
}
invoked.set(invoked.get() + 1);
let response = call_on_host(
&host_runtime,
&cancel,
@@ -763,11 +750,6 @@ fn install_host(
.map_err(|err| WorkflowJsError::InvalidArgs(err.to_string()))?;
globals.set("args", args_value).map_err(init_err)?;
// Per-run lifetime counter (design §4.3): counts spawn *attempts*, and the
// check + increment happen with no await in between so a parallel burst
// cannot slip past the cap on the single-threaded VM.
let spawned = Rc::new(Cell::new(0u64));
let task_driver = driver.clone();
let task_cancel = cancel.clone();
let task_runtime = host_runtime.clone();
@@ -777,18 +759,13 @@ fn install_host(
Func::from(Async(move |opts_json: String| {
let driver = task_driver.clone();
let cancel = task_cancel.clone();
let spawned = spawned.clone();
let host_runtime = task_runtime.clone();
async move { task_host(opts_json, driver, cancel, spawned, host_runtime).await }
async move { task_host(opts_json, driver, cancel, host_runtime).await }
})),
)
.map_err(init_err)?;
// Code-mode runs only: per-run tool-call counter. Same single-threaded
// check+increment discipline as `spawned` above — no await between the
// cap check and the increment, so a burst cannot slip past it.
if let Some(invoker) = invoker {
let invoked = Rc::new(Cell::new(0u64));
let call_invoker = invoker.clone();
let call_cancel = cancel.clone();
globals
@@ -797,11 +774,8 @@ fn install_host(
Func::from(Async(move |call_json: String| {
let invoker = call_invoker.clone();
let cancel = call_cancel.clone();
let invoked = invoked.clone();
let host_runtime = host_runtime.clone();
async move {
tools_call_host(call_json, invoker, cancel, invoked, host_runtime).await
}
async move { tools_call_host(call_json, invoker, cancel, host_runtime).await }
})),
)
.map_err(init_err)?;
@@ -882,7 +856,13 @@ fn install_host(
globals
.set(
"__workflow_budget_spent",
Func::from(move || -> f64 { spent_driver.budget().spent as f64 }),
Func::from(move || -> f64 {
if spent_driver.budget_is_metered() {
spent_driver.budget().spent as f64
} else {
f64::NAN
}
}),
)
.map_err(init_err)?;
@@ -890,6 +870,9 @@ fn install_host(
.set(
"__workflow_budget_remaining",
Func::from(move || -> f64 {
if !driver.budget_is_metered() {
return f64::NAN;
}
match driver.budget().remaining() {
Some(remaining) => remaining as f64,
None => f64::INFINITY,
@@ -914,10 +897,9 @@ async fn task_host(
opts_json: String,
driver: Arc<dyn WorkflowDriver>,
cancel: CancelHandle,
spawned: Rc<Cell<u64>>,
host_runtime: Handle,
) -> String {
let outcome = task_host_inner(opts_json, driver, cancel, spawned, host_runtime).await;
let outcome = task_host_inner(opts_json, driver, cancel, host_runtime).await;
let envelope = match outcome {
Ok(value) => serde_json::json!({ "value": value }),
Err(TaskError { kind, message }) => {
@@ -986,14 +968,12 @@ fn fail_schema(
/// Build the repair request for `next_attempt` (#5583): the same child
/// identity, budget fields, and schema as the original, with a repair prompt
/// carrying the original task, the schema, the failed reply, and why it
/// failed, plus the wall clock the first attempt did not spend.
/// carrying the original task, the schema, the failed reply, and why it failed.
fn repair_request(
original: &TaskRequest,
next_attempt: u32,
error: &ReplyDecodeError,
failed_raw: &str,
wall_time_secs: Option<u64>,
) -> TaskRequest {
let mut request = original.clone();
let schema = original
@@ -1006,7 +986,6 @@ fn repair_request(
"[schema repair {next_attempt}] {}",
repair_prompt(&original.description, schema, failed_raw, error)
);
request.wall_time_secs = wall_time_secs;
// Label and phase stay inherited so progress surfaces group the repair
// with its task.
request
@@ -1028,7 +1007,6 @@ async fn task_host_inner(
opts_json: String,
driver: Arc<dyn WorkflowDriver>,
cancel: CancelHandle,
spawned: Rc<Cell<u64>>,
host_runtime: Handle,
) -> Result<serde_json::Value, TaskError> {
let admission = |message: String| TaskError::new(TaskErrorKind::Admission, message);
@@ -1044,20 +1022,10 @@ async fn task_host_inner(
.transpose()
.map_err(|message| admission(reject_task(&driver, &opts_json, message)))?;
// Lifetime backstop (design §4.3) — checked and bumped before any await.
if spawned.get() >= WORKFLOW_LIFETIME_CAP {
return Err(admission(reject_task(
&driver,
&opts_json,
format!(
"task(): Workflow lifetime agent cap ({WORKFLOW_LIFETIME_CAP}) reached for this run"
),
)));
}
// Fast-fail budget gate. The authoritative reservation lives in the
// driver (design §5.3); this only stops obviously-doomed spawns early.
let snapshot = driver.budget();
if snapshot.exhausted() {
if driver.budget_is_metered() && snapshot.exhausted() {
return Err(TaskError::new(
TaskErrorKind::Budget,
reject_task(
@@ -1080,15 +1048,10 @@ async fn task_host_inner(
// repair; `Some(0)` disables it. The first attempt is attempt 1, so the
// task is schema-terminal once `attempt` reaches this ceiling.
let last_attempt = 1 + request.schema_repair_attempts.unwrap_or(1);
// The wall clock is shared across attempts: a repair inherits the time
// the first attempt did not spend, not a fresh budget.
let started = std::time::Instant::now();
let mut wall_time_secs_left = request.wall_time_secs;
let mut current = request.clone();
let mut attempt: u32 = 0;
loop {
attempt += 1;
spawned.set(spawned.get() + 1);
// Admission can wait (a saturated concurrency gate, a routing call),
// so it races the run's cancel exactly as the completion does below
// and as `tools.call()` does: a cancel that only closed the driver's
@@ -1168,21 +1131,8 @@ async fn task_host_inner(
raw: raw.clone(),
raw_truncated,
});
if spawned.get() >= WORKFLOW_LIFETIME_CAP {
return Err(schema_task(fail_schema(
&driver,
task_id,
attempt,
&error,
raw,
raw_truncated,
Some(format!(
"workflow lifetime agent cap ({WORKFLOW_LIFETIME_CAP}) reached"
)),
)));
}
let snapshot = driver.budget();
if snapshot.exhausted() {
if driver.budget_is_metered() && snapshot.exhausted() {
return Err(schema_task(fail_schema(
&driver,
task_id,
@@ -1196,22 +1146,7 @@ async fn task_host_inner(
if cancel.is_cancelled() {
return Err(cancelled_task());
}
if let Some(wall) = wall_time_secs_left {
let remaining = wall.saturating_sub(started.elapsed().as_secs());
if remaining == 0 {
return Err(schema_task(fail_schema(
&driver,
task_id,
attempt,
&error,
raw,
raw_truncated,
Some("no wall-time left from wallTimeSecs".to_string()),
)));
}
wall_time_secs_left = Some(remaining);
}
current = repair_request(&request, attempt + 1, &error, &raw, wall_time_secs_left);
current = repair_request(&request, attempt + 1, &error, &raw);
}
}
@@ -1887,8 +1822,9 @@ const PRELUDE_TEMPLATE: &str = r#""use strict";
const total = hostBudgetTotal();
globalThis.budget = Object.freeze({
total: Number.isNaN(total) ? null : total,
spent: () => hostBudgetSpent(),
remaining: () => hostBudgetRemaining(),
metered: !Number.isNaN(hostBudgetSpent()),
spent: () => { const value = hostBudgetSpent(); return Number.isNaN(value) ? null : value; },
remaining: () => { const value = hostBudgetRemaining(); return Number.isNaN(value) ? null : value; },
});
for (const name of [
+26 -40
View File
@@ -4,9 +4,7 @@ use std::sync::Arc;
use std::time::Duration;
use codewhale_workflow_js::testing::{FakeDriver, FakeReply};
use codewhale_workflow_js::{
ProgressEvent, WORKFLOW_LIFETIME_CAP, WorkflowJsError, WorkflowRunCancel, WorkflowVm,
};
use codewhale_workflow_js::{ProgressEvent, WorkflowJsError, WorkflowRunCancel, WorkflowVm};
use serde_json::json;
async fn run(
@@ -964,37 +962,34 @@ async fn repair_is_refused_when_the_shared_budget_is_exhausted() {
}
#[tokio::test]
async fn repair_is_refused_when_the_shared_wall_clock_is_spent() {
async fn legacy_task_wall_clock_does_not_prevent_schema_repair() {
let driver = Arc::new(FakeDriver::new());
driver.on("[schema repair", FakeReply::Complete("{}".to_string()));
driver.on_with_delay(
"slow prose",
FakeReply::Complete("eventually, still not json".to_string()),
Duration::from_millis(1_100),
);
let message = script_message(
run(
&driver,
r#"
let result = run(
&driver,
r#"
return await task({
description: "slow prose",
wallTimeSecs: 1,
responseSchema: { "type": "object" },
});
"#,
json!(null),
)
.await,
);
json!(null),
)
.await
.expect("legacy task wall time cannot stop repair");
assert!(
message.contains("repair skipped: no wall-time left from wallTimeSecs"),
"{message}"
);
assert_eq!(result, json!({}));
assert_eq!(
driver.spawn_count(),
1,
"the repair inherits the spent clock, not a fresh one"
2,
"schema repair follows the runtime's trusted limits"
);
}
@@ -1312,30 +1307,24 @@ async fn unbounded_budget_reads_as_null_total_and_infinite_remaining() {
}
#[tokio::test]
async fn lifetime_cap_throws_on_spawn_attempt_1001() {
async fn sequential_work_continues_past_one_thousand_children() {
let driver = Arc::new(FakeDriver::new());
let value = run(
&driver,
r#"
let completed = 0;
try {
for (let i = 0; i < 1001; i++) {
await task({ description: "t" + i });
completed++;
}
return "no-throw";
} catch (err) {
return { completed, message: String(err) };
for (let i = 0; i < 1001; i++) {
await task({ description: "t" + i });
completed++;
}
return completed;
"#,
json!(null),
)
.await
.unwrap();
assert_eq!(value["completed"], json!(WORKFLOW_LIFETIME_CAP));
let message = value["message"].as_str().unwrap();
assert!(message.contains("lifetime agent cap (1000)"), "{message}");
assert_eq!(driver.spawn_count(), WORKFLOW_LIFETIME_CAP as usize);
assert_eq!(value, json!(1001));
assert_eq!(driver.spawn_count(), 1001);
}
#[tokio::test]
@@ -2643,16 +2632,13 @@ async fn tools_call_failure_throws_agent_kind() {
}
#[tokio::test]
async fn tools_call_cap_rejects_runaway_loops() {
let err = run_tools(
r#"for (let i = 0; i < 55; i++) { await tools.call("read", {}); } return "never";"#,
async fn tools_calls_continue_past_fifty_while_the_host_admits_them() {
let result = run_tools(
r#"let completed = 0; for (let i = 0; i < 55; i++) { await tools.call("read", {}); completed++; } return completed;"#,
)
.await;
let message = script_message(err);
assert!(
message.contains("per-run tool-call cap"),
"unexpected: {message}"
);
.await
.expect("admitted calls continue until the script completes");
assert_eq!(result, json!(55));
}
/// A platform-native absolute path for `unix_path` (`/a/b`): unchanged on
+12 -40
View File
@@ -634,18 +634,9 @@ workflow({
let WorkflowNode::Sequence(sequence) = &workflow.nodes[0] else {
panic!("acceptance fixture should begin with one ordered role chain");
};
let expected_children = [
("explore", 6, 480, 96_000),
("implement", 4, 420, 72_000),
("reviewer", 4, 420, 72_000),
("test", 4, 420, 72_000),
("release_lead", 3, 300, 48_000),
];
let mut aggregate_token_cap = 0_u64;
let expected_children = ["explore", "implement", "reviewer", "test", "release_lead"];
assert_eq!(sequence.children.len(), expected_children.len());
for (node, (expected_role, max_steps, timeout_secs, max_tokens)) in
sequence.children.iter().zip(expected_children)
{
for (node, expected_role) in sequence.children.iter().zip(expected_children) {
let WorkflowNode::Leaf(leaf) = node else {
panic!("acceptance role chain must contain only agent leaves");
};
@@ -677,17 +668,17 @@ workflow({
);
if expected_role == "explore" {
assert!(
leaf.prompt.contains("exactly one `tool_search` call")
&& leaf.prompt.contains("exactly one `grep_files` call")
leaf.prompt.contains("use `tool_search` with")
&& leaf.prompt.contains("Use `grep_files` with")
&& leaf.prompt.contains(
"the content-search tool is deferred, so this activation is required before it can be called"
),
"the scout must activate the deferred content-search tool before searching with it"
);
assert!(
leaf.prompt.contains("Make no other tool calls")
&& leaf.prompt.contains("nothing else"),
"the scout discovery must stay one bounded activation-plus-search round"
leaf.prompt
.contains("Read and search these files as needed"),
"discovery must be driven by missing evidence"
);
assert_eq!(
leaf.file_scope
@@ -750,38 +741,19 @@ workflow({
],
"every acceptance role must carry the same promoted evidence boundary"
);
assert_eq!(leaf.budget.max_steps, Some(max_steps), "{expected_role}");
let response_budget = match max_steps {
6 => "at most six model responses",
4 => "at most four model responses",
3 => "at most three model responses",
_ => unreachable!("unexpected stopship model-response budget"),
};
assert_eq!(leaf.budget.max_steps, None, "{expected_role}");
assert_eq!(leaf.budget.timeout_secs, None, "{expected_role}");
assert_eq!(leaf.budget.max_tokens, None, "{expected_role}");
assert!(!leaf.prompt.contains("at most"), "{expected_role}");
assert!(
leaf.prompt.contains(response_budget) && leaf.prompt.contains("with no tool calls"),
"{expected_role} must reserve a response for its explicit verdict"
);
assert_eq!(
leaf.budget.timeout_secs,
Some(timeout_secs),
!leaf.prompt.contains("reserve the verdict"),
"{expected_role}"
);
assert_eq!(leaf.budget.max_tokens, Some(max_tokens), "{expected_role}");
assert!(
max_tokens < u64::from(max_steps) * 24_000,
"{expected_role} verdict reserve must not raise its token ceiling"
);
aggregate_token_cap = aggregate_token_cap.saturating_add(max_tokens);
assert!(
leaf.profile.is_none(),
"Fleet must resolve the declared role"
);
}
assert_eq!(
aggregate_token_cap, 360_000,
"the fixture must stay globally bounded when no shared override is supplied"
);
let expected_gates = [
("explore", Some("implement"), "source_evidence"),
("implement", Some("reviewer"), "verification_plan"),
+26 -19
View File
@@ -86,9 +86,9 @@ pub use role_resolve::{
};
/// Default hard ceiling on total agents a Fleet-shaped Workflow plan may launch.
/// Matches the imperative VM lifetime cap (1_000 agents per run).
pub const DEFAULT_FLEET_WORKFLOW_MAX_AGENTS: usize = 1000;
pub const DEFAULT_FLEET_WORKFLOW_MAX_DEPTH: usize = 5;
/// Zero leaves the total number of agents uncapped.
pub const DEFAULT_FLEET_WORKFLOW_MAX_AGENTS: usize = 0;
pub const DEFAULT_FLEET_WORKFLOW_MAX_DEPTH: usize = 0;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowConfig {
@@ -146,13 +146,13 @@ impl WorkflowSpec {
validate_workflow_nodes(&self.nodes)
.map_err(|source| WorkflowFleetLimitError::InvalidWorkflow { source })?;
let shape = estimate_fleet_shape(&self.nodes)?;
if shape.total_agents > limits.max_total_agents {
if limits.max_total_agents > 0 && shape.total_agents > limits.max_total_agents {
return Err(WorkflowFleetLimitError::TooManyAgents {
total_agents: shape.total_agents,
max_total_agents: limits.max_total_agents,
});
}
if shape.max_depth > limits.max_depth {
if limits.max_depth > 0 && shape.max_depth > limits.max_depth {
return Err(WorkflowFleetLimitError::RecursionTooDeep {
depth: shape.max_depth,
max_depth: limits.max_depth,
@@ -2851,7 +2851,7 @@ mod tests {
#[test]
fn fleet_validation_accepts_one_thousand_agents_and_variable_models() {
let nodes = (0..DEFAULT_FLEET_WORKFLOW_MAX_AGENTS)
let nodes = (0..1001)
.map(|index| {
let mut leaf = match leaf_node(&format!("agent-{index}")) {
WorkflowNode::Leaf(leaf) => leaf,
@@ -2877,36 +2877,39 @@ mod tests {
let shape = workflow
.validate_for_fleet()
.expect("one thousand agents should fit the Fleet Workflow limit");
.expect("total work is uncapped by default");
assert_eq!(shape.total_agents, DEFAULT_FLEET_WORKFLOW_MAX_AGENTS);
assert_eq!(shape.total_agents, 1001);
assert_eq!(shape.max_depth, 1);
}
#[test]
fn fleet_validation_rejects_more_than_one_thousand_agents() {
let nodes = (0..=DEFAULT_FLEET_WORKFLOW_MAX_AGENTS)
fn fleet_validation_honors_an_explicit_total_agent_limit() {
let nodes = (0..=1000)
.map(|index| leaf_node(&format!("agent-{index}")))
.collect();
let workflow = workflow_spec(nodes);
let err = workflow
.validate_for_fleet()
.validate_for_fleet_with_limits(WorkflowFleetLimits {
max_total_agents: 1000,
..Default::default()
})
.expect_err("agent population should be bounded before Fleet launch");
assert_eq!(
err,
WorkflowFleetLimitError::TooManyAgents {
total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS + 1,
max_total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS,
total_agents: 1001,
max_total_agents: 1000,
}
);
}
#[test]
fn fleet_validation_rejects_depth_beyond_five() {
fn fleet_validation_honors_only_explicit_depth_limits() {
let mut node = leaf_node("deep-leaf");
for depth in (0..DEFAULT_FLEET_WORKFLOW_MAX_DEPTH).rev() {
for depth in (0..8).rev() {
node = WorkflowNode::BranchSet(BranchSpec {
id: format!("ring-{depth}"),
description: None,
@@ -2919,15 +2922,19 @@ mod tests {
}
let workflow = workflow_spec(vec![node]);
assert_eq!(workflow.validate_for_fleet().unwrap().max_depth, 9);
let err = workflow
.validate_for_fleet()
.expect_err("sixth agent ring should be rejected");
.validate_for_fleet_with_limits(WorkflowFleetLimits {
max_depth: 8,
..Default::default()
})
.expect_err("the explicit owner depth should be enforced");
assert_eq!(
err,
WorkflowFleetLimitError::RecursionTooDeep {
depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH + 1,
max_depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH,
depth: 9,
max_depth: 8,
}
);
}
+11 -16
View File
@@ -120,24 +120,19 @@ A worker runs at `spawn_depth = 0` and may spawn children while
delegation levels. Sub-agents and fleet-selected Runtime workers share **one**
axis, sourced from `codewhale_config`:
- `DEFAULT_SPAWN_DEPTH = 3` — the default budget for both standalone sub-agents
and fleet-selected Runtime workers (so they cannot drift into "two moving
targets");
- `MAX_SPAWN_DEPTH_CEILING = 8` — the opt-in cap that every configured Runtime
value, including fleet execution `max_spawn_depth`, clamps to.
Runtime delegation has no default product depth cap. Explicit owner depth
settings still apply to descendants and cannot be widened by a child. Zero
blocks child delegation while leaving the root worker available. The internal
`u32` representation rejects overflow; it is not a suggested work budget.
The model-facing `agent` schema intentionally omits `max_depth`. The parser
still accepts `max_depth`, `maxDepth`, and `max_spawn_depth` for saved
transcripts, ACP/MCP clients, and internal compatibility callers, and rejects
values above 8. Current model-authored calls inherit the Runtime configuration
instead of negotiating recursion depth in the tool schema.
The model-facing `agent` schema omits depth controls. Legacy `max_depth`,
`maxDepth` and `max_spawn_depth` inputs are parsed for compatibility but ignored
when a model requests a child; receipts identify that choice. Exact Fleet and
saved-runtime authority remains enforced.
Workflow IR has a separate default structural validation limit of five nested nodes.
That limit constrains the orchestration document's shape; it does not grant or
consume Runtime child-delegation depth.
The root worker always runs even at budget 0; the budget gates *child*
delegation. The default affords at least three nested levels.
Workflow IR structure is independent of child delegation. Its `max_depth`
default is zero (uncapped); positive operator values are honored. Parser and
VM stack/memory boundaries still validate documents before execution.
## Event vocabulary
+25 -11
View File
@@ -46,11 +46,11 @@ request orchestration explicitly.
| Knob | Default | Meaning |
|------|---------|---------|
| `automatic` | `true` | Soft-auto orchestration is enabled |
| `automatic` | `true` | Legacy compatibility field; currently not a launch switch. The model chooses orchestration under the active tool and approval policy. |
| `auto_start_read_only` | `true` | Read-only plans may start without a write-approval card |
| `require_approval_for_writes` | `true` | Gates the plan-approval card for writes / elevated starts |
| `max_children` / `max_concurrent` / `max_depth` | `1000` / `16` / `5` | Task count, concurrent children, and plan-structure (IR shape, not spawn depth) ceilings. The Runtime child-delegation budget (default 3, hard ceiling 8) is a separate quantity with an overlapping name; see `docs/SUBAGENTS.md`. |
| `default_token_budget` | `0` | Shared admission cap for a run and its children; `0` = none — set it or pass `token_budget` on the call to bound spend |
| `max_children` / `max_concurrent` / `max_depth` | `0` / `16` / `0` | Total children and structural plan depth are uncapped at zero; positive values are owner limits. Concurrency queues excess work. Runtime delegation has no default product depth cap and remains a separate authority setting. |
| `default_token_budget` | `0` | Declared token hint retained in receipts; `0` = no hint. It does not cap spend, child lifetime, or admission. |
Elevated work (writes, shell beyond read-only, network, secrets, worktrees, high
budget) surfaces an approval card with goal, child summary, capability flags,
@@ -127,7 +127,8 @@ approval policy. See [Sandbox](SANDBOX.md).
## Synthesis and compatibility
- Prefer `responseSchema` on children that must return structured fields.
- Prefer `responseSchema` on read-only children that must return structured fields.
Write-capable children retain their change and verification evidence contract.
- Ordinary failed parallel slots become `null` (partial success); filter them
before synthesizing one operator-facing summary. A `responseSchema` mismatch
is a contract failure and intentionally fails the run instead of being
@@ -147,16 +148,28 @@ approval policy. See [Sandbox](SANDBOX.md).
rather than quietly reading as `settled`.
- A run whose every task failed is recorded as **failed**, not as a partial
success, even when the script itself returned a value.
- Workflow token budgets govern admission and aggregate accounting. Once
exhausted they reject later or descendant spawns, but children already
running in parallel can reconcile aggregate usage above the hint because
providers report usage only at response boundaries.
- Native plans settle ordinary failures in singleton, sequential, and parallel
steps. Independent work continues; a failed or missing required result skips
its dependents without launching them. The receipt retains surviving results
and the skip reason. Partial loss is `degraded`; no surviving work is `failed`.
Cancellation and explicit schema-contract failures still stop the run.
- Legacy Workflow token budgets survive only for parse/receipt compatibility and
are absent from the advertised tool/plan schema. They are not caps. Aggregate token
accounting is unavailable: `budget.metered` is `false`, and `budget.spent()`
and `budget.remaining()` return `null`. `budget.total` retains the declared
hint. Actual reported provider usage remains in child/run receipts.
- Model-authored child step, time, output and delegation-depth limits are not
runtime authority. Legacy fields remain parse-compatible but are ignored;
direct launch receipts identify ignored fields. Generated Workflow tasks omit
legacy token/step/time hints entirely. Configure actual owner limits through
the runtime's operator, role/Fleet, parent or saved-run controls. Stop remains
authoritative. Continue until the outcome is complete or a real blocker needs a handback.
- Compatibility paths remain: `script`, `source_path` (checked-in
`.workflow.js` / `.workflow.ts`), and structured `plan`.
## When automatic stays off
## Choosing orchestration
Automatic Workflow is suppressed for:
The model can use direct tools instead of a Workflow for:
- One-file edits and tiny one-step asks
- Simple commands / factual questions
@@ -164,7 +177,8 @@ Automatic Workflow is suppressed for:
- Risky writes without a clear decomposition
- Plans that would exceed `max_children` / `max_depth` (refused before launch)
In those cases Codewhale uses direct tools or a single `agent` instead.
These are planning choices, not task-size cutoffs. Use the decomposition the
work needs; only the configured structural and authority checks refuse launch.
## Example scenarios (#4131)
+33
View File
@@ -13,6 +13,39 @@ before/after evidence, not benchmarks.
> current source audit, target crate map and extraction order. Measurements
> below are historical; the B3/deferred candidate lists are not an execution queue.
## October 22, 2026: shared Engine future boundary
The 0.10.2 candidate's `crates/tui/src` has 1,040 Rust files and 1,279,727
physical lines including tests. Filtering test names still compiles the one
large library-test target. The older measurements below describe an earlier
source tree and compiler.
Two successive local builds on the 14-core Apple Silicon machine, with Rust
1.99.0, the same incremental cache, features and development profile:
| Candidate build | Wall time |
| --- | ---: |
| Shared Engine loop returned its full concrete future | 29m 31s |
| `Engine::run_owned` returned a boxed `Send` future | 2m 24s |
A native sample of the first build found recursive trait-obligation work during
monomorphization and MIR validation on one compiler thread. The existing shared
loop now erases its future type at that boundary; root and child engines retain
the same event loop. It adds one allocation per Engine invocation. The second
build also corrected three existing test assertions and collected Cargo
`--timings`. These are warm local observations, not an isolated benchmark or a
cold-build, release-build or cross-platform speedup promise. The 2,172 captured
inputs stayed unchanged during each build and its focused checks; 58 relevant
existing checks passed after the boundary change. Final release validation is
separate.
Keep incremental compilation and the existing line-table debug setting for the
local edit loop. Finish a coherent batch before compiling, then reuse its test
binary for relevant filters while inputs stay unchanged. Capture timings on a
build already needed for validation. The structural follow-up remains the
[TUI deconstruction](design/TUI_DECONSTRUCTION.md): move implementation and its
existing tests together so each subsystem can build independently.
## Where the time goes (baseline, commit 533c530b)
| Step | Wall | Notes |
+4 -5
View File
@@ -2716,8 +2716,8 @@ reasoning contract, and all four membership ids omit generic sampling fields.
- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs <seconds>` (add `--save` to write canonical `stream.chunk_timeout_secs`); `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.
- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.
- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (also uncapped by default); see the Goal loop section below.
- `tui.turn_wall_clock_secs` (int, optional, default: no limit): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Omitted or `0` means no limit; positive values clamp to `30..=86400` (24 hours is the ceiling). When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.
- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are preserved (`4294967295` is the compatibility value for no limit). Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (also uncapped by default); see the Goal loop section below.
- `tui.turn_wall_clock_secs` (int, optional, default: no limit): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Omitted or `0` means no limit; positive values are preserved without an added runtime ceiling. When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.
- `tui.stream_max_resumes` (int, optional, default `3`): how many times one turn re-issues a model request after its stream failed — the request never opened (connect failure or response-header stall), the stream died before any content, the host slept mid-stream, or the network dropped mid-stream. Every one of those paths spends this one budget, and a healthy stream resets it. `0` disables turn-level re-issues (a failed stream then fails the turn); values clamp to `0..=10`.
- `tui.stream_max_transparent_retries` (int, optional, default `2`): in-stream re-requests while nothing has streamed yet. `0` disables them; values clamp to `0..=10`.
- `tui.stream_max_errors` (int, optional, default `5`): recoverable errors tolerated within one stream before it ends. Unlike the two retry counts above, `0` does not switch anything off: like the other finite stream budgets it selects the default. Other values clamp to `1..=50`, so `1` ends the stream on its first recoverable error.
@@ -2845,7 +2845,7 @@ max_continuations = 100
continuation_delay_seconds = 300
# Optional per-turn model-step ceiling while a goal is active (#6512).
# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.
# Default: uncapped (0 or absent). Positive values are preserved.
# This bounds each provider turn, never the number of continuation passes.
max_steps = 0
```
@@ -2859,8 +2859,7 @@ inspecting progress, or raise/disable the backstop.
`[goal] max_steps` governs one engine turn at a time. Like ordinary interactive
turns, omitted or `0` leaves model steps uncapped. Explicit per-invocation
ceilings — `exec --max-turns N`, child-worker caps — always win over it. At
about 80% of an explicit positive budget the model is told to land; at exhaustion
ceilings — `exec --max-turns N`, child-worker caps — always win over it. At exhaustion
it gets one bounded final report and the turn classifies as budget-exhausted. An
unfinished goal then pauses with the BudgetLimit reason instead of re-arming
another goal turn — resume it explicitly after reviewing the report. Wall-clock
+59 -97
View File
@@ -23,8 +23,8 @@ Session and approval inbox as ordinary turns. The private captured ChildGrant
narrows tool actions, shell, workspace, network and descendant depth at the final
execution boundary. The existing SubAgentManager still owns launch slots,
coordination, worker checkpoints and terminal delivery; it does not run another
model/tool loop. Work and the single bounded reporting turn share that configured
child Engine and its original deadline.
model/tool loop. Work uses the full explicit owner limit. At the limit the host
preserves a partial-result receipt without an extra model reporting request.
Transient provider failures use Core's dispatch and retry boundaries. An exhausted
budget or interrupted request preserves the recorded checkpoint and a continuation
@@ -37,8 +37,8 @@ process restarts, sleep or remote execution, use Fleet or a Workflow-backed run.
Sub-agents inherit the parent's permitted tool registry, including `agent`
coordination. Spawning obeys one absolute depth ceiling: the root is depth 0,
its child is depth 1, and a child at `max_spawn_depth` cannot spawn again.
The operator default is 3, with a hard ceiling of 8. A role, saved profile, or
compatibility request can only narrow that ceiling. Recovery and transcript
The default has no product depth cap. Explicit operator, role and saved-profile
limits still narrow descendant authority; model requests cannot choose caps. Recovery and transcript
forking retain the source's position and bounds; they do not buy another
generation. The removed `agent_open`/`agent_eval`/`agent_close` lifecycle tools
are absent from every registry.
@@ -368,14 +368,11 @@ STOP_CONDITION:
OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT
```
`scout` briefs default to quick, read-only investigation (no writes, but
network reach and the bounded verification surface are available for real
scouting). A quick scout usually needs only a handful of calls: orient,
search, read the decisive lines, and return — but stop at decisive evidence,
not at a number. There is no per-agent call cap to save; the runtime budgets
depth and concurrency, not curiosity. Do not repeat `ALREADY_KNOWN` work
unless evidence contradicts it. Builder and repair-style briefs should use
checkpoints before scope expansion or after repeated failures.
`scout` briefs request read-only investigation with enough evidence to answer
the question reliably. Let the task determine the depth of investigation.
Do not repeat `ALREADY_KNOWN` work unless evidence contradicts it. Builder and
repair-style briefs should use checkpoints before scope expansion or after
repeated failures, then adapt and continue the remaining authorized work.
Good delegation prompt examples:
@@ -508,7 +505,7 @@ While the governor is holding launches back, it says so in two places:
- a queued agent's row gives the reason, for example
`launch slots throttled to 4/8 after 2 provider rate limit(s) in the last 60s`
or `launches paused after 4 provider rate limit(s) in the last 60s`, and the
time its wall budget ends;
time its owner-configured wall budget ends, if one exists;
- `GET /v1/agent-runs` returns a `governor` object next to `runs`, with
`launch_slots`, `max_launch_slots`, `paused`, `recent_rate_limits`, and a
`status` line while launches are held back. It describes launches made by the
@@ -529,13 +526,13 @@ and aliases such as `glm` for Z.ai:
max_concurrent = 20
launch_concurrency = 20
max_admitted = 200
# Operator-selected Runtime delegation depth. The default is 3; this explicit
# value opts in above the default but remains below the hard ceiling of 8.
# Operator-selected Runtime delegation depth. Omit to leave it uncapped.
# This explicit value restricts descendants to six levels.
max_depth = 6
# Omitted or zero model-step budget is unbounded. Set a positive value only
# when an operator deliberately wants a per-child cap.
default_max_steps = 0
default_wall_time_secs = 1800
default_wall_time_secs = 0
[subagents.providers.deepseek]
# Direct API key with room to fan out.
@@ -574,7 +571,6 @@ The model-facing `agent` schema exposes these controls:
| --- | --- |
| Launch and route | `action`, `prompt`, `type`, `profile`, `name`, `model`, `model_strength`, `thinking` |
| Scope and outputs | `worktree`, `cwd`, `write_authority`, `write_roots`, `exact_files`, `coordination_contracts`, `deliverables`, `expected_artifact` |
| Narrow run limits | `max_steps`, `wall_time_secs` |
| Coordinate and recover | `agent_id`, `agent_ids`, `all_parked`, `message`, `until`, `detached`, `resume_from` |
| Inspect | `detail`, `offset`, `limit` |
@@ -619,9 +615,9 @@ internal/operator compatibility. Runtime validates and intersects them with
live policy:
- delegation compatibility: `max_depth`, `maxDepth`, or `max_spawn_depth`;
values are restricted to 0 through the Runtime hard ceiling of 8 and only
narrow the inherited absolute ceiling. Model-facing calls inherit depth
from the operator and selected profile.
model-facing calls ignore these values and inherit depth from the operator
and selected profile. Exact Fleet authority still supplies its validated
depth ceiling.
- workspace/isolation: `workspace_policy`, `fork_context`,
`worktree_path`, `worktree_branch`, `worktree_base`
- spawn contract: `deliberate`, `dependencies`, `acceptance`, `allowed_tools`
@@ -631,98 +627,64 @@ live policy:
Compatibility input is not a way to widen inherited authority or remove a
finite budget.
## Child budgets (steps, wall time, tokens)
## Child limits and completion
`max_steps` and `wall_time_secs` are optional per-call limits.
Each can only narrow the applicable role, operator, parent, and saved-run
limits; the one exception is that `wall_time_secs` may raise the built-in
1800-second default (see below). Omission inherits those limits; explicit zero, null, negative, or
out-of-range values are rejected by the tool parser (schema minimum is 1).
Fleet file task-specs use a different convention — there, omitted-or-zero
means unbounded; see `docs/FLEET.md`.
Children have no default model-turn or wall-clock limit. They continue until
completion, a need for user input, cancellation, or an explicit owner limit.
Elapsed time and step counts do not establish whether useful work is finished.
The runtime does not inject percentage-based instructions to stop exploring or
wrap up early.
`max_steps` counts model turns and accepts 1 through 2000. All roles default
to no model-turn cap unless an operator or ancestor supplies one; the internal
zero representation for that default never cancels a finite inherited cap.
`wall_time_secs` accepts 1 through 86400. Omitted, it defaults to 1800
seconds. An explicit value may go above that built-in default, for long
unattended work; an operator-configured `default_wall_time_secs` is both the
default and a ceiling, and role, parent, and saved-run deadlines still only
narrow. It covers model requests and tools. The effective absolute deadline
is persisted, and saved again when a queued agent launches, so a later
continuation is bounded by the deadline the agent actually worked to.
The model-facing `agent` tool does not advertise `max_steps`, `wall_time_secs`,
or `max_output_tokens`. Valid legacy inputs still parse, but do not constrain
model-launched children. A launch receipt lists `ignored_limit_fields` when
those inputs are supplied; `effective_limits` shows what actually applies.
Models should specify the task, write scope, deliverables and acceptance criteria,
not guess how many turns or minutes completion will require.
The work clock starts when the agent gets a launch slot. An agent that waits
in the launch queue waits at most `wall_time_secs`; if no slot opens in that
time it fails with a `never started` reason and zero steps, instead of being
reported as a run that used up its budget. An agent that does get a slot
after waiting receives its full `wall_time_secs` from that moment, still
bounded by any parent, saved-run or source deadline. So a parent can wait up
to about twice `wall_time_secs` for a queued agent. The queued row names the
reason for the wait and the time the agent stops waiting. If agents often
wait long, start fewer at once.
An operator can deliberately set `[subagents] default_max_steps` or
`default_wall_time_secs`. Omission or zero means no limit. Positive owner
limits and role, parent and saved-run bounds remain authoritative; a child
cannot remove them. There is no additional 2,000-turn or 24-hour child ceiling.
Continuations preserve an existing run's remaining steps and absolute deadline.
For example, a focused review can request:
Concurrency limits queue work rather than giving it a guessed completion budget.
Without an owner deadline, a queued child waits for a slot or cancellation. With
an explicit wall budget, its queue wait is bounded by that duration; if no slot
opens it reports `never started` with zero work. After launch its work clock
restarts within any inherited absolute deadline, which is persisted for
continuation. The queued row reports the wait reason and any actual deadline.
For example:
```json
{
"action": "start",
"type": "reviewer",
"prompt": "Review the parser diff and report concrete regressions.",
"max_steps": 12,
"wall_time_secs": 300
"prompt": "Review the parser diff and report concrete regressions with file and line evidence."
}
```
The receipt's `effective_limits` is authoritative; a request for 300 seconds
cannot extend a parent's earlier deadline. A continuation keeps the source's
remaining steps, original deadline, and token history. A new ID, role, or
`resume_from` fork cannot reset those bounds.
### Usage and partial results
### Token accounting and partial results
Token usage is tracked, never used as a run-wide kill budget. Legacy
`token_budget` input is ignored. Provider-reported usage is the accounting
source; missing usage remains unknown. Context compaction lets a child
continue as its conversation grows. Provider context windows and output limits
still apply to individual requests, and incomplete tool arguments are never
executed.
Token budgets were retired in 0.9.14: token usage is tracked, never
enforced — runs are no longer stopped by token accounting. Legacy input that
still carries `token_budget` parses and is ignored; `max_steps` and
`wall_time_secs` remain the narrowable per-call limits.
When an explicit owner step or time limit ends a run, the existing hand-back
path preserves partial work and attempts one tools-disabled report within the
remaining allowance. Its fixed input/output allowance is at most 8,192 tokens,
with at most 1,024 output tokens. A step limit of at least two reserves one turn;
a wall limit reserves at most 10 seconds for reporting. These reserves do not
create a limit on an otherwise unbounded run.
The governor uses provider-reported input plus output tokens, not a local
estimate presented as a bill. Request output is capped to the remaining
allowance. Unknown prompt usage and requests already in flight can overshoot;
receipts retain the full reported usage. Missing usage remains unknown.
Worker records distinguish the worker's own token totals from shared
`budget_spent_tokens` and `budget_remaining_tokens`; do not sum a shared
pool once for every descendant.
The worker reserves room for one final report inside these limits: up to 10%
of a token allowance (at most 8192 tokens, only when at least 1024 can be
reserved), one turn when the step cap permits at least two, and up to 10% of
wall time (at most 10 seconds). Ordinary task execution stops before using
that reserve. Shared scopes hold back one token reserve for the scope;
reporting workers atomically claim remaining headroom so siblings cannot
independently reuse it. Continuation never refunds measured usage or resets
the original deadline.
The final reporting turn uses the worker's existing resolved provider and
model, with tools disabled and at most 1024 output tokens. It consolidates
bounded assistant notes and tool results into findings, evidence, produced
files, unfinished work and next steps. Estimated input cost counts against
its allowance. Provider transport retries remain inside the one logical
turn and its original wall-time deadline; no worker summary retry loop is
added. Token estimates are not billing receipts: unknown provider input and
requests already in flight can still overshoot, and actual usage is recorded.
The outcome stays `BudgetExhausted`, even when a useful report is obtained,
with the specific cause, checkpoint, measured usage and normal deliverable
verdicts. If the allowance is too small or already spent, earlier bounded
usage is unknown, the provider fails, or time expires, the worker returns
recorded partial text and says why a model report was unavailable.
Known missing response usage and attempts interrupted by timeout or cancellation
stay recorded across continuations and shared siblings; later known usage remains a
subtotal and cannot restore reporting headroom in that bounded scope.
Cancellation wins over reporting. Missing usage stays unknown. Exhausted
scopes reject further spawns or continuations; a partial report is not
successful completion.
The outcome remains `BudgetExhausted` rather than successful completion, even
when a useful partial report is available. If reporting cannot run, the receipt
retains recorded work and explains why. Cancellation remains available during
work and queue waits. Unknown provider usage is never reported as zero.
## Per-role models (#3018)
+25 -26
View File
@@ -49,9 +49,9 @@ its own. Real work happens in sub-agents the script launches.
### Scale
- Up to **16 concurrent** live agents in one run (additional spawns wait for a slot).
- Up to **1_000 agents per run** (VM lifetime spawn cap).
- Configured `max_children` and `max_concurrent` can narrow these limits.
- Automatic launch is model-judged on scope; the host enforces only the hard `max_children` / `max_depth` ceilings.
- No default cumulative child limit; `[workflow] max_children = 0` leaves total work uncapped.
- Owners can set a positive `max_children` limit and configure `max_concurrent`.
- Automatic launch is model-judged on scope; the host enforces configured owner limits and plan validation.
- Plan the population the work needs and let the host queue and clamp it.
These ceilings are enforcement, not a reason to pre-shrink a valid plan.
@@ -137,26 +137,25 @@ gates: [{
}]
```
A gate that blocks its own role can never pass, so the plan is rejected when it
is submitted, before any child is dispatched; the error names the gate and both
fields. A `blocks_role` that names a role from an **earlier** phase is not
caught there and still deadlocks the run. That failure reads
`spawn rejected: workflow gate blocks role \`implement\`: waiting for required
gate outcome`, the run ends `Failed`, and because the blocked phase never
started, **the work its children would have done never happens and produces no
result** — the phases that already ran are the only salvageable part. Retrying
with the same plan reproduces it, so treat the message as a plan defect, not a
transient dispatch error.
Admission rejects a gate that blocks its own role, a cycle of role gates,
and a gate whose explicitly named prerequisite first occurs after its blocked
role in a sequential plan. Diagnostics name the contradictory gate and roles
before any child starts. Parallel structure, unknown profile roles and dynamic
raw JavaScript order are not assigned a total order; runtime gates still enforce the prerequisite at dispatch.
Two habits keep this from costing a run:
Native singleton and sequence steps use the same settled behavior as parallel
steps. Ordinary failures retain partial results and let independent work
continue. A step whose required input failed or is missing is skipped with a
named reason; it never receives an empty substitute and never launches a child.
Raw JavaScript `task()` remains fail-fast unless the author uses the existing
settled combinators. Reducers can explicitly summarize partial inputs; they
must disclose missing inputs instead of claiming that blocked work ran.
- Read the gate block back before launch and check that every `blocks_role`
names a role in a **later** phase than the gated `role`. With at most one
gate, `blocks_role` is never the gated role itself.
- Make each phase's result usable on its own. A localize/implement/verify split
survives a dispatch failure in the last phase when the middle phase's
findings were already returned, and a phase that only writes files is worth
nothing if it never starts.
Let workers finish their assigned outcome. Legacy model task limits are accepted
for compatibility but ignored, and token hints are not spend caps. Actual owner
limits belong in operator, role/Fleet, parent or saved-runtime configuration.
Preserve partial findings and exact blockers for a follow-up instead of discarding
completed work. Schema repair cannot invent a deadline from a legacy task field.
## Verification
@@ -182,18 +181,18 @@ filesystem of its own — effects live in the workers.
Workflow-to-Runtime launch validation applies a conservative default shape
before any Workflow IR is lowered to selected workers:
- up to 1,000 total worker agents per Workflow run;
- no default total worker limit; a positive owner-configured `max_children` still applies;
- up to 16 live worker agents at once; larger populations queue (block) on the
host's per-run concurrency gate until a live slot frees, then select through
fleet and execute through Runtime;
- Workflow IR structural nesting no deeper than 5;
- Runtime child delegation defaults to 3 levels and has an opt-in hard ceiling
of 8; that execution budget is independent of Workflow IR shape;
- no default structural nesting cap; positive owner `workflow.max_depth` values apply;
- no default Runtime delegation depth cap; explicit owner restrictions remain
independent of Workflow IR shape;
- loops require `max_iterations`;
- dynamic `expand` nodes require `max_children` and a template.
Those limits distinguish population from instantaneous launch concurrency. A
valid 1,000-agent Workflow can still drain through a smaller Runtime worker
long-running Workflow can still drain through a smaller Runtime worker
pool. Model selection stays per member: a DeepSeek preset can suggest
`deepseek-v4-pro` for the orchestrator and `deepseek-v4-flash` for nearby
workers, but users and agents may override any slot when the task calls for it.
@@ -16,30 +16,16 @@ export default async function () {
const slots = await parallel([
() =>
task({
description: "Healthy scout A",
description: "Return the string READY_A. Read-only.",
label: "scout-a",
type: "explore",
prompt: "Return the string READY_A. Read-only.",
}),
// Give this child an intentionally tiny budget so it starts, then fails
// deterministically at the runtime boundary. A model refusal is still a
// successful transport-level completion, while response-schema failures
// intentionally abort the whole workflow so they remain loud.
() => { throw new Error("Demonstration: scout B could not produce a result"); },
() =>
task({
description: "Deliberately failing scout B",
label: "scout-b-fail",
type: "explore",
tokenBudget: 1,
prompt:
"Inspect Cargo.toml and return a detailed workspace summary. This child intentionally has a one-token budget so parallel() exercises a failed null slot.",
}),
() =>
task({
description: "Healthy scout C",
description: "Return the string READY_C. Read-only.",
label: "scout-c",
type: "explore",
prompt: "Return the string READY_C. Read-only.",
}),
]);
@@ -53,10 +39,9 @@ export default async function () {
log(`dropped: ${line}`);
}
const summary = await task({
description: "Synthesize from surviving parallel slots",
label: "synthesizer",
type: "general",
prompt: [
type: "review",
description: [
"Build one operator-facing summary from the surviving scout results.",
"Explicitly note which parallel slot failed, and why.",
`slot_count=${(slots || []).length} surviving=${surviving.length}`,
+3 -6
View File
@@ -67,14 +67,11 @@
worker 从 `spawn_depth = 0` 起运行,只要满足 `spawn_depth + 1 ≤ max_spawn_depth` 就可以派生子级,所以预算 `N` 提供 `N` 层嵌套委派。子智能体和 Fleet 选中的 Runtime worker 共用**同一条**轴,其来源是 `codewhale_config`:
- `DEFAULT_SPAWN_DEPTH = 3`:独立子智能体和 Fleet 选中的 Runtime worker 共同的默认预算(这样两者不会漂移成"两个移动靶");
- `MAX_SPAWN_DEPTH_CEILING = 8`:需主动开启的上限,所有配置的 Runtime 值(包括 Fleet 执行配置中的 `max_spawn_depth`)都会被钳制到这个值。
Runtime 委派默认不设置产品深度上限。操作者显式设置的深度仍约束后代,子级不能扩大权限。0 禁止子级委派,但根 worker 仍可运行。内部 `u32` 表示会拒绝溢出,它不是建议的工作预算。
面向模型的 `agent` schema 有意不包含 `max_depth`。解析器仍然接受 `max_depth`、`maxDepth` 和 `max_spawn_depth`,以兼容已保存的对话记录、ACP/MCP 客户端和内部调用方,并拒绝大于 8 的值。当前由模型发起的调用继承 Runtime 的配置,而不是在工具 schema 里协商递归深度。
面向模型的 `agent` schema 不提供深度控制。兼容字段 `max_depth`、`maxDepth` 和 `max_spawn_depth` 仍可解析,但模型请求不能用它们给子级设置上限;回执会注明被忽略的字段。Exact Fleet 和保存的 Runtime 权限仍然生效。
Workflow IR 另有一个默认的结构校验上限:最多五层嵌套节点。这个上限约束的是编排文档的形状,既不授予也不消耗 Runtime 的子级委派深度。
根 worker 即使预算为 0 也总会运行;预算限制的是*子级*委派。默认值至少提供三层嵌套。
Workflow IR 结构与子级委派分开。其 `max_depth` 默认 0(不设上限),正数操作者配置会被遵守。解析器及 VM 的栈、内存边界仍在执行前校验文档。
## 事件词汇
+18 -10
View File
@@ -41,11 +41,11 @@
| 开关 | 默认值 | 含义 |
|------|---------|---------|
| `automatic` | `true` | 软自动编排已启用 |
| `automatic` | `true` | 保留的兼容字段,目前不是启动开关;模型在当前工具和审批策略内选择编排。 |
| `auto_start_read_only` | `true` | 只读计划可以不带写入审批卡片直接启动 |
| `require_approval_for_writes` | `true` | 写入/提权启动前必须经过的计划审批卡片 |
| `max_children` / `max_concurrent` / `max_depth` | `1000` / `16` / `5` | 任务数量、并发子项数量,以及计划结构(IR 形状,而不是派生深度)的上限。运行时(runtime)的子级委派预算(默认 3,硬上限 8)是一个名字相近但彼此独立的量;见 `docs/SUBAGENTS.md`。 |
| `default_token_budget` | `0` | 一次运行及其子项共享的准入上限;`0` = 不设上限——把它设上,或在调用时传 `token_budget`,以限制开销 |
| `max_children` / `max_concurrent` / `max_depth` | `0` / `16` / `0` | 总任务数及计划结构深度为 0 时不设上限;正值为操作者上限。超出并发额度的工作排队。Runtime 委派默认无产品深度限制,仍是独立的权限设置。 |
| `default_token_budget` | `0` | 回执保留的 token 提示;`0` 表示无提示。它不限制开销、子项运行时间或准入。 |
提权(elevated)工作(写入、只读之外的 shell、网络、机密、worktree、高预算)
在启动之前会给出审批卡片,上面写着目标、子项摘要、能力标志和预算(#4126)——
@@ -112,7 +112,7 @@ worktree 隔离与写入归属是两回事。具备写入能力的 `task()`
## 汇总与兼容性
- 必须返回结构化字段的子项,请优先用 `responseSchema`。
- 必须返回结构化字段的只读子项,请优先用 `responseSchema`。写入子项仍须交回变更与验证证据。
- 普通的并行槽位失败会变成 `null`(部分成功);在汇总成一份面向操作者的总结之前,
先把它们过滤掉。`responseSchema` 不匹配属于契约失败,会故意让整次运行失败,
而不是被悄悄转成 `null`。
@@ -129,15 +129,23 @@ worktree 隔离与写入归属是两回事。具备写入能力的 `task()`
而不是悄悄按 `settled` 处理。
- 所有任务都失败的一次运行会被记为 **failed**,而不是部分成功,
即使脚本本身返回了值。
- 工作流的 token 预算管的是准入和总量核算。预算耗尽后,它会拒绝后续或
后代派生的发生,但已经在并行运行的子项可能把总用量对账到提示的上限之上,
因为提供商(provider)只在响应边界报告用量。
- 原生计划中的单项、顺序和并行步骤会保留普通失败并继续独立工作。
依赖失败或缺失结果的步骤会被跳过,不启动子项;回执保留可用结果与跳过原因。
部分损失标为 `degraded`,没有可用工作标为 `failed`。取消和显式 schema 契约失败仍会停止运行。
- 旧 Workflow token 预算只保留解析与回执兼容,不再出现在工具或计划 schema 中,也不是强制上限。
总量未计量:`budget.metered`
为 `false`,`budget.spent()` 与 `budget.remaining()` 返回 `null`,`budget.total`
保留声明的提示。提供商实际报告的用量仍保留在子项和运行回执中。
- 模型提供的步骤、时间、输出和委派深度上限不构成运行时权限。旧字段兼容解析但会被忽略,
直接启动回执会说明忽略的字段,生成的 Workflow 任务不再携带旧 token、步骤或时间提示。
真实限制来自操作者、角色/Fleet、父级或已保存运行的配置。
停止操作仍然有效;继续工作直到目标完成,或遇到需要交回的实际阻碍。
- 兼容路径仍然保留:`script`、`source_path`(签入的
`.workflow.js` / `.workflow.ts`),以及结构化的 `plan`。
## 什么时候不自动启动
## 选择编排
下列情况会抑制自动工作流:
下列情况可以选择直接工具而非工作流:
- 单文件编辑和极小的单步请求
- 简单命令/事实性问题
@@ -145,7 +153,7 @@ worktree 隔离与写入归属是两回事。具备写入能力的 `task()`
- 没有清晰分解方式的风险型写入
- 会超出 `max_children` / `max_depth` 的计划(在启动前就被拒绝)
在这些情况下,Codewhale 改用直接调用工具,或只用一个 `agent`。
这些是规划选择,不是任务大小的硬限制。按实际工作需要分解;只有配置的结构和权限检查会拒绝启动。
## 示例场景(#4131)
+26 -1
View File
@@ -1,7 +1,7 @@
# 构建与测试性能
> 英文原文:[BUILD_PERFORMANCE.md](../BUILD_PERFORMANCE.md)。
> 最后与英文同步日期(last synced with English revision):2026-09-29。
> 最后与英文同步日期(last synced with English revision):2026-10-22(新增共享 Engine future 边界测量;下方保留历史数据)。
这篇文档记录实测数据:Codewhale 的构建和测试各要多久,为了让贡献者的开发循环
更快做了哪些改动,哪些又推迟了。数字来自一台机器(Apple Silicon,14 核,rustc 1.97.0,
@@ -12,6 +12,31 @@ Xcode 26.2 `ld-1230`),采集时还跑着另外四个 cargo 任务(1 分钟
> 源码审计、目标 crate 地图和抽取顺序。下面的测量都是历史数据;B3 和“推迟”两处的候选清单
> 不是执行队列。
## 2026-10-22:共享 Engine future 边界
0.10.2 候选源码的 `crates/tui/src` 包含 1,040 个 Rust 文件、1,279,727 行物理源码
(含测试)。按测试名筛选仍会编译整个大型库测试目标。下面较早的测量属于旧源码和编译器。
在同一台 14 核 Apple Silicon 机器上,使用 Rust 1.99.0、相同增量缓存、特性和开发
profile,连续两次本地构建的结果为:
| 候选构建 | 墙钟时间 |
| --- | ---: |
| 共享 Engine 循环返回完整具体 future 类型 | 29 分 31 秒 |
| `Engine::run_owned` 返回装箱的 `Send` future | 2 分 24 秒 |
第一次构建的原生采样显示,一个编译器线程在单态化和 MIR 验证阶段递归解析 trait
约束。现在仅在现有共享循环边界擦除 future 类型,根智能体和子智能体仍使用同一个
事件循环;每次 Engine 调用增加一次分配。第二次构建还修正了三个现有测试断言并记录
Cargo `--timings`。这是本地热构建观察,不是隔离变量的基准,也不承诺冷构建、发布
构建或其他平台获得同样加速。两次构建及其聚焦检查期间,2,172 个记录的输入均未变化;
边界修改后的 58 项相关现有检查全部通过。最终发布验证仍是独立步骤。
本地编辑循环继续使用增量编译和现有行表调试信息设置。完成一组连贯修改后再编译,
输入不变时复用测试二进制执行相关筛选。只在本来就需要的验证构建中记录耗时。长期
工作仍是 [TUI 拆分](../design/TUI_DECONSTRUCTION.md):将实现与现有测试一起迁出,
让每个子系统可以独立构建。
## 时间花在哪里(基线,commit 533c530b)
| 步骤 | 墙钟 | 说明 |
+4 -4
View File
@@ -1392,9 +1392,9 @@ DeepSeek V4 前缀缓存让 token 标签变得重要。这些数量保持分离
- `tui.osc8_links`(bool,可选,macOS/Linux 默认开启,Windows 默认关闭):在转录输出的 URL 周围发出 OSC 8 转义序列,这样支持的终端(iTerm2、Terminal.app 13+、Ghostty、Kitty、WezTerm、Alacritty、较新的 gnome-terminal/konsole)可以用终端的链接手势打开它们——通常是 macOS 上的 Cmd-click,Linux/Windows 上的 Ctrl-click。没有 OSC 8 支持的终端渲染普通标签并忽略转义。转义带外发出(不在缓冲区单元格内),所以列损坏不是问题;只在终端错误渲染 OSC 8 终止符本身时设 `false`。Windows 旧控制台默认关闭;用 `true` 选择加入。
- `tui.max_model_steps`(int,可选,默认不设上限):单个普通回合的可选模型步数上限。省略或为 `0` 表示模型步数不设上限;显式的正值会被钳制到 `1..=100000`。无头 `exec` 和 Fleet worker 同样没有隐含的模型步数上限;`exec --max-turns N` 和正数的 worker 预算仍然适用。当用完显式步数预算的约 80% 时,模型会收到一次软着陆通知;步数耗尽时,回合以 `Failed` 结束,信息为 `Maximum model steps reached before completion (limit: N)`,必要时会先有一次有界的最终报告响应。累计的墙钟时间和逐流的限制保持独立。处于活动状态的交互式目标回合改用 `goal.max_steps`(同样默认不设上限);见下文的目标循环一节。
- `tui.max_model_steps`(int,可选,默认不设上限):单个普通回合的可选模型步数上限。省略或为 `0` 表示模型步数不设上限;显式的正值会原样保留(`4294967295` 是表示不设上限的兼容值)。无头 `exec` 和 Fleet worker 同样没有隐含的模型步数上限;`exec --max-turns N` 和正数的 worker 预算仍然适用。步数耗尽时,回合以 `Failed` 结束,信息为 `Maximum model steps reached before completion (limit: N)`,必要时会先有一次有界的最终报告响应。累计的墙钟时间和逐流的限制保持独立。处于活动状态的交互式目标回合改用 `goal.max_steps`(同样默认不设上限);见下文的目标循环一节。
- `tui.turn_wall_clock_secs`(int,可选,默认不设限):每个回合累计的墙钟预算,单位为秒,跨一个回合的每个模型步骤度量(不是按请求)。等待人工审批所阻塞的时间不计入。省略或为 `0` 表示不设限;正值会钳制到 `30..=86400`(上限为 24 小时)。耗尽时,回合会在授权下一次计费请求之前停止,并给出一条指明该限制及要调高的键的信息。
- `tui.turn_wall_clock_secs`(int,可选,默认不设限):每个回合累计的墙钟预算,单位为秒,跨一个回合的每个模型步骤度量(不是按请求)。等待人工审批所阻塞的时间不计入。省略或为 `0` 表示不设限;正值会原样保留,不再附加运行时上限。耗尽时,回合会在授权下一次计费请求之前停止,并给出一条指明该限制及要调高的键的信息。
- `tui.stream_max_resumes`(int,可选,默认 `3`):一个回合在其流失败之后,重新发起模型请求的次数——请求从未打开(连接失败或响应头停滞)、流在任何内容之前就断了、主机在流中途休眠,或网络在流中途断开。所有这些路径都消耗同一份预算,而健康的流会重置它。`0` 禁用回合级的重新发起(此时失败的流会使回合失败);值钳制到 `0..=10`。
@@ -1507,7 +1507,7 @@ max_continuations = 100
continuation_delay_seconds = 300
# Optional per-turn model-step ceiling while a goal is active (#6512).
# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.
# Default: uncapped (0 or absent). Positive values are preserved.
# This bounds each provider turn, never the number of continuation passes.
max_steps = 0
```
@@ -1516,7 +1516,7 @@ max_steps = 0
显式的兜底触发时,目标会以一条点名 `[goal] max_continuations` 的状态消息暂停,并记录一条警告;检查进度后再恢复目标,或者调高/禁用该兜底。
`[goal] max_steps` 一次只约束一个引擎回合。与普通的交互式回合一样,省略或设为 `0` 表示模型步数不设上限。显式的逐次调用上限——`exec --max-turns N`、子 worker 的上限——始终优先于它。当显式正数预算用到约 80% 时,会告知模型着陆;耗尽时,它会得到一次有界的最终报告,并且该回合被归类为预算耗尽。未完成的目标随后会以 BudgetLimit 原因暂停,而不是重新启动下一个目标回合——请在审阅报告之后显式恢复它。墙钟时间和流保护是独立的,仍然适用。
`[goal] max_steps` 一次只约束一个引擎回合。与普通的交互式回合一样,省略或设为 `0` 表示模型步数不设上限。显式的逐次调用上限——`exec --max-turns N`、子 worker 的上限——始终优先于它。显式预算耗尽时,它会得到一次有界的最终报告,并且该回合被归类为预算耗尽。未完成的目标随后会以 BudgetLimit 原因暂停,而不是重新启动下一个目标回合——请在审阅报告之后显式恢复它。墙钟时间和流保护是独立的,仍然适用。
延迟只在显式创建的目标仍然活动、且在一个成功回合之后才开始。`/goal pause`、`/goal done`、`/goal blocked`、`/goal clear`、Esc 或 Ctrl+C 会在下一次 provider 请求开始之前取消待定的继续。失败的回合和策略/路由失败绝不会安排下一个回合。配置中只存储数字形式的节奏;该循环不会持久化任何提示、凭据或机密。
+38 -25
View File
@@ -11,7 +11,7 @@ Fleet 角色是委派工作面向用户的词汇:父智能体通过 `agent`
提供商瞬态失败使用 Core 的分发与重试边界。预算耗尽或请求中断会保留已记录的检查点及延续句柄。每次实际分发的请求都携带原始会话、路由和来源标识:提供商用量只计费一次,然后投影到 worker 账本。成功但没有用量的响应与结果未知的请求分别保留覆盖缺口,均不会被标为零成本。需要经受进程重启、休眠或远程执行的工作请使用 Fleet 或由 Workflow 支撑的运行。
子智能体继承父智能体被允许使用的工具注册表,包括 `agent` 协调。生成遵守同一个绝对深度上限:根为深度 0,它的子智能体为深度 1,处于 `max_spawn_depth` 的子智能体不能再生成。操作者默认值为 3,硬上限为 8。角色、已保存的 profile 或兼容请求只能收窄这个上限。恢复和转录分叉会保留来源的位置和限制,不会多换来一代。已移除的 `agent_open`/`agent_eval`/`agent_close` 生命周期工具不会出现在任何注册表中。
子智能体继承父智能体被允许使用的工具注册表,包括 `agent` 协调。生成遵守同一个绝对深度上限:根为深度 0,它的子智能体为深度 1,处于 `max_spawn_depth` 的子智能体不能再生成。默认不设置产品深度上限。操作者、角色及已保存的 profile 仍可收窄后代权限;模型请求不能自行选择上限。恢复和转录分叉会保留来源的位置和限制,不会多换来一代。已移除的 `agent_open`/`agent_eval`/`agent_close` 生命周期工具不会出现在任何注册表中。
健康的子智能体在父智能体的一次普通响应结束后会继续运行。它们的完成通知经由现有的 Engine 收件箱返回,并可唤醒父智能体开始另一个正常回合。显式中断或取消仍然具有最终效力。`detached: true` 另外让一棵子树不受父回合取消的影响;它不会移除子智能体的预算,也不会移除无界面主机的截止时间。
@@ -147,7 +147,7 @@ STOP_CONDITION:
OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT
```
`scout` 简报默认为快速的只读调查(不写入,但网络触达和有界验证界面可用于真正的侦察)。一次快速侦察通常只需要寥寥几次调用:定位、搜索、读取决定性的那几行,然后返回——但要在拿到决定性证据时停下,而不是在达到某个数字时停下。这里没有需要节省的“每个智能体调用上限”;运行时限制的是深度和并发,而不是好奇心。除非证据与之矛盾,否则不要重复 `ALREADY_KNOWN` 中的工作。Builder 和修复类简报应当在扩大范围之前,或在反复失败之后设置检查点。
`scout` 简报要求只读调查,并收集足够的证据可靠地回答问题。调查深度由任务本身决定。除非证据与之矛盾,否则不要重复 `ALREADY_KNOWN` 中的工作。Builder 和修复类简报应在扩大范围前或反复失败后保存检查点,调整方法并继续完成其余已授权工作。
好的委派提示词示例:
@@ -235,13 +235,13 @@ OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT.
max_concurrent = 20
launch_concurrency = 20
max_admitted = 200
# Operator-selected Runtime delegation depth. The default is 3; this explicit
# value opts in above the default but remains below the hard ceiling of 8.
# Operator-selected Runtime delegation depth. Omit to leave it uncapped.
# This explicit value restricts descendants to six levels.
max_depth = 6
# Omitted or zero model-step budget is unbounded. Set a positive value only
# when an operator deliberately wants a per-child cap.
default_max_steps = 0
default_wall_time_secs = 1800
default_wall_time_secs = 0
[subagents.providers.deepseek]
# Direct API key with room to fan out.
@@ -279,7 +279,6 @@ max_admitted = 12
| --- | --- |
| 启动与路由 | `action`、`prompt`、`type`、`profile`、`name`、`model`、`model_strength`、`thinking` |
| 作用域与产出 | `worktree`、`cwd`、`write_authority`、`write_roots`、`exact_files`、`coordination_contracts`、`deliverables`、`expected_artifact` |
| 收窄运行限制 | `max_steps`、`wall_time_secs` |
| 协调与恢复 | `agent_id`、`agent_ids`、`all_parked`、`message`、`until`、`detached`、`resume_from` |
| 检查 | `detail`、`offset`、`limit` |
@@ -293,46 +292,60 @@ max_admitted = 12
**可解析但不公布(兼容)。** 其他输入仍被接受,用于已保存的转录、ACP/MCP 客户端、Fleet 执行数据,以及内部/操作者的兼容。运行时会校验它们,并与实时策略取交集:
- 委派兼容:`max_depth`、`maxDepth` 或 `max_spawn_depth`;取值限制在 0 到 Runtime 硬上限 8 之间,并且只会收窄继承来的绝对上限。面向模型的调用从操作者和所选 profile 继承深度。
- 委派兼容:`max_depth`、`maxDepth` 或 `max_spawn_depth`;面向模型的调用会忽略这些值,从操作者和所选 profile 继承深度。确切的 Fleet 权限仍使用经过验证的深度上限。
- 工作区/隔离:`workspace_policy`、`fork_context`、`worktree_path`、`worktree_branch`、`worktree_base`
- 生成契约:`deliberate`、`dependencies`、`acceptance`、`allowed_tools`
- 生命周期附加项:`timeout_secs`(wait)、`reason`(interrupt)、`include_archived`(status)
兼容输入不是用来放宽继承来的权限,或去掉有限预算的途径。
## 子智能体预算(步数、墙钟时间、token)
## 子智能体限制与完成
`max_steps` 和 `wall_time_secs` 是可选的逐次调用限制。它们各自只能收窄适用的角色、操作者、父智能体和已保存运行的限制;唯一的例外是 `wall_time_secs` 可以抬高内置的 1800 秒默认值(见下文)。省略即继承这些限制;显式的零、null、负值或越界值会被工具解析器拒绝(schema 最小值为 1)。Fleet 文件任务规格使用不同的约定——那里省略或为零表示无界;参见 `docs/FLEET.md`。
子智能体默认没有模型回合或墙钟时间上限。它们持续工作,直到任务完成、需要用户
输入、被取消,或达到所有者明确设置的限制。耗时和步骤数量不能证明工作已经完成。
运行时不会根据预算百分比注入“停止探索”或“提前收尾”的指令。
`max_steps` 统计模型回合,接受 1 到 2000。所有角色默认没有模型回合上限,除非操作者或祖先提供了一个;该默认值内部用零来表示,它绝不会取消一个有限的继承上限。`wall_time_secs` 接受 1 到 86400。省略时默认为 1800 秒。为了长时间无人值守的工作,显式值可以高于这个内置默认值;操作者配置的 `default_wall_time_secs` 既是默认值,也是上限,而角色、父智能体和已保存运行的截止时间仍然只会收窄。它同时覆盖模型请求和工具。有效的绝对截止时间会被持久化,并在排队的智能体启动时再次保存,因此之后的续跑受该智能体实际遵循的截止时间约束。
面向模型的 `agent` 工具不再公布 `max_steps`、`wall_time_secs` 或
`max_output_tokens`。有效的旧参数仍可解析,但不会限制模型启动的子智能体。
启动回执中的 `ignored_limit_fields` 列出被忽略的参数,`effective_limits`
显示实际生效的限制。模型应说明任务、写入范围、交付物和验收标准,不应猜测完成
任务需要多少回合或分钟。
工作时钟从智能体拿到启动槽位时开始。在启动队列里等待的智能体最多等待 `wall_time_secs`;如果这段时间内没有槽位空出来,它会以 `never started` 的原因和零步失败,而不是被报告为用尽了预算的运行。等待之后确实拿到槽位的智能体,从那一刻起获得完整的 `wall_time_secs`,仍受任何父智能体、已保存运行或来源截止时间的约束。因此,父智能体对一个排队的智能体最多可能要等大约两倍的 `wall_time_secs`。排队的那一行会写明等待的原因以及智能体停止等待的时间。如果智能体经常等很久,请一次少启动几个。
操作者可在 `[subagents]` 中明确设置 `default_max_steps` 或
`default_wall_time_secs`。省略或零表示无上限。所有者、角色、父智能体和已保存
运行的明确限制仍然生效,子智能体不能移除它们。不会额外套用 2,000 回合或 24
小时的子智能体上限。续跑保留已有运行的剩余步骤和绝对截止时间。
例如,一次聚焦的审查可以这样请求:
并发限制会让工作排队,而不会为完成任务猜测一个预算。没有所有者截止时间时,
子智能体等待可用槽位或取消。有明确时间预算时,排队最多等待该时长;未能启动会
以 `never started` 和零工作量报告。取得槽位后开始工作计时,但仍受继承的绝对
截止时间约束。截止时间会被保存,排队状态会显示等待原因和实际存在的截止时间。
示例:
```json
{
"action": "start",
"type": "reviewer",
"prompt": "Review the parser diff and report concrete regressions.",
"max_steps": 12,
"wall_time_secs": 300
"prompt": "审查解析器差异,报告具体回归并提供文件和行号证据。"
}
```
回执中的 `effective_limits` 才是权威;请求 300 秒并不能延长父智能体更早的截止时间。续跑会保留来源剩余的步数、原始截止时间和 token 历史。新的 ID、角色或 `resume_from` 分叉都无法重置这些界限。
### 用量与部分结果
### Token 记账与部分结果
Token 用量只用于记账,不会作为整次运行的终止预算。旧的 `token_budget` 参数
会被忽略。记账依据提供商报告;缺失的用量保持未知。上下文压缩让子智能体在对话
增长时继续工作。单次请求仍受提供商上下文和输出容量限制,未完整生成的工具参数
绝不会被执行。
Token 预算已在 0.9.14 中退役:token 用量会被追踪,但绝不强制执行——运行不再因为 token 记账而被停止。仍带有 `token_budget` 的旧输入可以被解析,但会被忽略;`max_steps` 和 `wall_time_secs` 仍是可收窄的逐次调用限制。
所有者明确设置的步骤或时间限制结束运行时,现有交还路径会保存部分工作,并在
剩余额度内尝试一次禁用工具的报告。报告输入和输出的固定总额度最多为 8,192
个 token,输出最多为 1,024 个 token。至少两步的上限会预留一个报告回合;时间
上限最多预留 10 秒用于报告。这些预留不会给原本无界的运行添加上限。
调节器使用提供商报告的输入加输出 token,而不是把本地估算当作账单。请求的输出会被限制在剩余额度之内。未知的提示词用量和已经在途的请求可能超支;回执会保留完整的报告用量。缺失的用量保持未知。`worker` 记录会区分该 `worker` 自己的 token 总量,与共享的 `budget_spent_tokens` 和 `budget_remaining_tokens`;不要为每个后代都把共享池累加一次。
`worker` 会在这些限制内预留出写一份最终报告的余量:token 额度的至多 10%(最多 8192 个 token,且仅当至少能预留 1024 时),在步数上限允许至少两步时预留一个回合,以及墙钟时间的至多 10%(最多 10 秒)。普通的任务执行会在用到这份预留之前停下。共享作用域会为该作用域扣留一份 token 预留;正在汇报的 `worker` 以原子方式认领剩余余量,使同级 `worker` 无法各自重复使用它。续跑绝不会退还已测得的用量,也不会重置原始截止时间。
最终汇报回合使用该 `worker` 已解析好的提供商和模型,禁用工具,输出最多 1024 个 token。它把有界的助手备注和工具结果整合为发现、证据、产出的文件、未完成的工作和下一步。估算的输入成本计入它的额度。提供商传输层的重试保留在这一个逻辑回合及其原始墙钟截止时间之内;不会额外增加 `worker` 摘要的重试循环。Token 估算不是计费回执:未知的提供商输入和已经在途的请求仍可能超支,实际用量会被记录。
结果仍是 `BudgetExhausted`,即使拿到了有用的报告也是如此,并附带具体原因、检查点、已测得的用量和正常的交付物判定。如果额度太小或已经用完、更早的有界用量未知、提供商失败或时间到期,`worker` 会返回已记录的部分文本,并说明为什么无法得到模型报告。已知缺失的响应用量,以及被超时或取消打断的尝试,会在续跑和共享的同级之间保持记录;之后已知的用量仍然只是一个小计,无法在该有界作用域内恢复汇报余量。取消优先于汇报。缺失的用量保持未知。已耗尽的作用域会拒绝进一步的生成或续跑;部分报告不算成功完成。
即使报告有用,结果仍然是 `BudgetExhausted`,不会伪称成功完成。无法生成报告时,
回执保留已记录的工作并说明原因。工作期间和排队期间都可以取消。未知的提供商
用量不会被记为零。
## 各角色模型(#3018)
+18 -6
View File
@@ -47,7 +47,7 @@ Workflow 脚本**只是协调者**。它自己没有文件系统,也没有 she
### 规模
- 单次运行中最多 **16 个并发**活跃智能体(额外的启动请求会排队等空位)。
- 单次运行最多 **1_000 个智能体**(VM 生命周期内的启动上限)。
- 默认不限制单次运行的累计子智能体数量;`[workflow] max_children = 0` 表示不设总量上限。
- 配置的 `max_children` 和 `max_concurrent` 可以进一步收紧这些上限。
- 自动启动由模型按范围判断;主机只强制 `max_children` / `max_depth` 这两个硬上限。
- 按工作需要规划规模,让主机去排队和收敛。
@@ -111,6 +111,19 @@ export default workflow({
运行时。这些副作用只是不让写进脚本,整次运行并没有禁止它们——放进子 worker 里就行,
那里有完整的工具表面,脚本只管协调。
准入会拒绝阻止自身角色的门禁、角色门禁环,以及顺序计划中先执行被阻止角色、
之后才出现明确前置角色的矛盾。并行结构、未知 profile 角色和动态 JavaScript 顺序不会被猜测;
运行时仍在派发时检查门禁。
原生单项和顺序步骤与并行步骤一样保留普通失败并继续独立工作。必要输入失败或缺失
时,依赖步骤会带原因跳过,不会启动子项,也不会用空内容代替结果。
原始 JavaScript 的 `task()` 仍快速失败,除非作者使用现有的 settled 组合器。
归约器可以明确汇总部分输入,但必须披露缺失内容,不能声称被阻止的工作已经完成。
让 worker 完成分配的目标。旧的模型任务限制兼容解析但会被忽略,token 提示不是开销上限。
真实限制来自操作者、角色/Fleet、父级或已保存运行的配置。schema 修复不能从旧任务字段
自行创建截止时间。保留部分结果和具体阻碍供后续继续,不要丢弃已完成工作。
## 验证
- `cargo test -p codewhale-workflow --locked javascript`
@@ -131,16 +144,15 @@ Workflow 负责计划本身:阶段、分支、循环、归约器和中间结
在 Workflow IR 被降到选定的 worker 之前,Workflow 到 Runtime 的启动校验会先套上
一个保守的默认形态:
- 单次 Workflow 运行最多 1,000 个 worker 智能体;
- 单次 Workflow 默认不设 worker 总量上限;所有者配置的正数 `max_children` 仍然生效;
- 同时最多 16 个活跃 worker 智能体;数量更多时会在主机按运行设立的并发门禁上排队(阻塞),
等空出位置后,再经 Fleet 选择、由 Runtime 执行;
- Workflow IR 的结构嵌套不超过 5 层;
- Runtime 的子级委派默认 3 层,另有可选择启用的 8 层硬上限;这份执行预算与
Workflow IR 的形态无关;
- Workflow IR 默认不设置结构嵌套上限;正数 `workflow.max_depth` 配置仍生效;
- Runtime 子级委派默认不设置产品深度上限;显式所有者权限仍生效,与 Workflow IR 形态分开;
- 循环必须提供 `max_iterations`;
- 动态 `expand` 节点必须提供 `max_children` 和一个模板。
这些限制区分了"总量"和"瞬时启动并发"。一份合法的 1,000 智能体 Workflow,
这些限制区分了"总量"和"瞬时启动并发"。一份持续运行的 Workflow,
照样可以通过一个更小的 Runtime worker 池慢慢跑完。模型选择仍然按成员进行:
DeepSeek 预设可以为编排者推荐 `deepseek-v4-pro`,为就近的 worker 推荐
`deepseek-v4-flash`,但任务需要时,用户和智能体都可以覆盖任何一处。
+1 -2
View File
@@ -23,8 +23,7 @@ export default workflow({
"prompt": "Inspect targeted tests and identify missing verification.",
"agent_type": "verifier",
"mode": "read_only",
"file_scope": ["crates", "tests"],
"budget": { "max_steps": 4, "timeout_secs": 300 }
"file_scope": ["crates", "tests"]
}
},
{
+6 -19
View File
@@ -9,45 +9,32 @@ export default async function () {
const slots = await parallel([
() =>
task({
description: "Healthy scout A",
description: "Return the string READY_A. Read-only.",
label: "scout-a",
type: "explore",
prompt: "Return the string READY_A. Read-only.",
}),
// Give this child an intentionally tiny budget so it starts, then fails
// deterministically at the runtime boundary. A model refusal is still a
// successful transport-level completion, while response-schema failures
// intentionally abort the whole workflow so they remain loud.
() => { throw new Error("Demonstration: scout B could not produce a result"); },
() =>
task({
description: "Deliberately failing scout B",
label: "scout-b-fail",
type: "explore",
tokenBudget: 1,
prompt:
"Inspect Cargo.toml and return a detailed workspace summary. This child intentionally has a one-token budget so parallel() exercises a failed null slot.",
}),
() =>
task({
description: "Healthy scout C",
description: "Return the string READY_C. Read-only.",
label: "scout-c",
type: "explore",
prompt: "Return the string READY_C. Read-only.",
}),
]);
phase("Synthesize");
const surviving = (slots || []).filter((s) => s != null);
const summary = await task({
description: "Synthesize from surviving parallel slots",
label: "synthesizer",
// Read-only synthesizer: "general" is write-capable and, without declared
// write scope, would claim the workspace root (same as operate_read_audit).
type: "review",
prompt: [
description: [
"Build one operator-facing summary from the surviving scout results.",
"Explicitly note which parallel slot failed or returned null.",
`slot_count=${(slots || []).length} surviving=${surviving.length}`,
"dropped_slots:",
JSON.stringify(slots.errors || []),
"slots_json:",
JSON.stringify(slots),
].join("\n"),
+10 -28
View File
@@ -1,16 +1,3 @@
// The read-only child prompt/tool envelope is regression-pinned in
// crates/tui/src/tools/subagent/tests.rs
// (read_only_child_envelope_stays_within_measured_ceiling): 80,856 bytes
// (~20k tokens) measured 2026-08-02 on this tree, ceiling +10%. Historical
// live acceptance on DeepSeek Flash / GLM-5-Turbo measured 17,457–17,550
// tokens. Budget 24k per intended evidence turn, then add token-neutral
// max_steps headroom for the required final verdict. The token ceiling
// remains independent and the five role caps still total 360k.
// Scout repair: the read-only catalog carries no `File` (hidden replay-only
// alias, filtered from every model-visible surface), so the scout now
// activates `grep_files` with one `tool_search` call before searching. The
// evidence turn spans three responses (activate, search, verdict) inside the
// unchanged step/token caps.
export default workflow({
"id": "stopship-release-acceptance",
"goal": "Verify the current Codewhale Fleet, Workflow, Lane, Runtime, and gate receipt path without changing the workspace",
@@ -79,7 +66,7 @@ export default workflow({
{
"agent": {
"id": "explore-runtime",
"prompt": "Verify the runtime release-orchestration owners using only the five files in scope. The host's typed run_started receipt already owns the compiled Workflow id and source path; do not re-verify the Workflow alias. You have at most six model responses and must reserve the verdict. Response 1 must make exactly one `tool_search` call with `query` set to `grep_files`; the content-search tool is deferred, so this activation is required before it can be called. Response 2 must make exactly one `grep_files` call with `path` set to `.`, `include` set exactly to [`fleets/stopship.toml`, `crates/cli/src/lib.rs`, `crates/workflow/src/role_resolve.rs`, `crates/tui/src/tools/workflow/mod.rs`, `crates/lane/src/runtime.rs`], and `pattern` set to this high-signal alternation: `name = \"stopship\"|load_named_fleet|start_lane|resolve_workflow_agent|record_task_started|WorkflowUiEventKind::GateUpdated|WorkflowUiEventKind::RunCompleted|terminal_completed_receipt|process_exit_receipt|lane_reconciled|tmux_reconcile_folds_detached_process_exit_into_lane_status|stopship_acceptance_fixture_emits_role_gate_and_terminal_receipts`. Set `max_results` to 80 and `context_lines` to 2. Matches outside that exact include list do not count. Do not add generic field names such as `resolved_profile` or `exit_code` to the pattern. Make no other tool calls: exactly one `tool_search` call, then exactly one `grep_files` call, nothing else. Response 3 must return the verdict with no tool calls; any later reserved response must do the same instead of gathering more evidence. Treat an exact match naming a call site, typed event constructor, reconciliation branch, or test assertion in a scoped file as source-owner evidence. Apply this decision rule literally: if you can populate all seven required SOURCE EVIDENCE entries from the search result, return APPROVE; never return BLOCK after citing all seven. Return BLOCK only when at least one named owner has no matching citation, and identify each missing owner as MISSING. The first non-empty line of your response must be exactly APPROVE or exactly BLOCK. Do not put any words before that verdict: no confirmation, summary, heading, or phrase such as `Here is the verdict`. After the verdict, include a `SOURCE EVIDENCE` section with concise `path: symbol` evidence for named Fleet loading, role-to-profile resolution, tmux Lane launch, typed task_started, gate_updated, terminal run_completed, and tmux process-exit reconciliation receipts. The terminal run_completed entry must carry both the `WorkflowUiEventKind::RunCompleted` constructor and the `terminal_completed_receipt` integration assertion. A bare verdict is invalid. Do not edit files, create branches, run shell commands, access GitHub, or infer success where source evidence is absent.",
"prompt": "Verify the runtime release-orchestration owners using only the five files in scope. The host's typed run_started receipt already owns the compiled Workflow id and source path; do not re-verify the Workflow alias. If `grep_files` is not available, use `tool_search` with `query` set to `grep_files`; the content-search tool is deferred, so this activation is required before it can be called. Use `grep_files` with `path` set to `.`, `include` set exactly to [`fleets/stopship.toml`, `crates/cli/src/lib.rs`, `crates/workflow/src/role_resolve.rs`, `crates/tui/src/tools/workflow/mod.rs`, `crates/lane/src/runtime.rs`], and `pattern` set to this high-signal alternation: `name = \"stopship\"|load_named_fleet|start_lane|resolve_workflow_agent|record_task_started|WorkflowUiEventKind::GateUpdated|WorkflowUiEventKind::RunCompleted|terminal_completed_receipt|process_exit_receipt|lane_reconciled|tmux_reconcile_folds_detached_process_exit_into_lane_status|stopship_acceptance_fixture_emits_role_gate_and_terminal_receipts`. Set `max_results` to 80 and `context_lines` to 2. Matches outside that exact include list do not count. Do not add generic field names such as `resolved_profile` or `exit_code` to the pattern. Read and search these files as needed to establish each owner before returning your verdict. Treat an exact match naming a call site, typed event constructor, reconciliation branch, or test assertion in a scoped file as source-owner evidence. Apply this decision rule literally: if you can populate all seven required SOURCE EVIDENCE entries from the search result, return APPROVE; never return BLOCK after citing all seven. Return BLOCK only when at least one named owner has no matching citation, and identify each missing owner as MISSING. The first non-empty line of your response must be exactly APPROVE or exactly BLOCK. Do not put any words before that verdict: no confirmation, summary, heading, or phrase such as `Here is the verdict`. After the verdict, include a `SOURCE EVIDENCE` section with concise `path: symbol` evidence for named Fleet loading, role-to-profile resolution, tmux Lane launch, typed task_started, gate_updated, terminal run_completed, and tmux process-exit reconciliation receipts. The terminal run_completed entry must carry both the `WorkflowUiEventKind::RunCompleted` constructor and the `terminal_completed_receipt` integration assertion. A bare verdict is invalid. Do not edit files, create branches, run shell commands, access GitHub, or infer success where source evidence is absent.",
"agent_type": "explore",
"role": "explore",
"mode": "read_only",
@@ -89,14 +76,13 @@ export default workflow({
"crates/workflow/src/role_resolve.rs",
"crates/tui/src/tools/workflow/mod.rs",
"crates/lane/src/runtime.rs"
],
"budget": { "max_steps": 6, "timeout_secs": 480, "max_tokens": 96000 }
]
}
},
{
"agent": {
"id": "plan-verification",
"prompt": "Act as the Fleet implement role for a verification-only acceptance run. Use only the promoted explore source_evidence handoff to produce a no-edit verification plan for the Fleet/Workflow/Lane/Runtime contract. Tools are intentionally unavailable because the promoted handoff is the evidence boundary; do not request source reads or gather new evidence. You have at most four model responses, but the first response must return APPROVE or BLOCK with no tool calls. After that verdict, include a compact `PLAN` section that copies exact `path: symbol` citations for all seven owners from source_evidence: named Fleet loading, role-to-profile resolution, tmux Lane launch, typed task_started, gate_updated, terminal run_completed, and tmux process-exit reconciliation. Use one concise row per owner and no explanatory prose so the seven-owner matrix survives handoff compaction. The Workflow row must carry both the `WorkflowUiEventKind::RunCompleted` constructor and the `terminal_completed_receipt` integration assertion. The Lane-exit row must carry a `crates/lane/src/runtime.rs` citation for `process_exit_receipt`, `lane_reconciled`, or its reconciliation test; `start_lane` alone is not terminal evidence. A bare verdict is invalid. The first non-empty line of your response must be exactly APPROVE or exactly BLOCK. Do not put any words before that verdict: no confirmation, summary, heading, or phrase such as `Here is the verdict`. Use APPROVE only when that complete concrete plan can be produced from the handoff; otherwise use BLOCK. This is deliberately not an implementation task: do not edit files, create branches, run shell commands, or propose fixes unrelated to missing acceptance evidence.",
"prompt": "Act as the Fleet implement role for a verification-only acceptance run. Use only the promoted explore source_evidence handoff to produce a no-edit verification plan for the Fleet/Workflow/Lane/Runtime contract. Tools are intentionally unavailable because the promoted handoff is the evidence boundary; do not request source reads or gather new evidence. Return APPROVE or BLOCK after reviewing the promoted evidence. After that verdict, include a compact `PLAN` section that copies exact `path: symbol` citations for all seven owners from source_evidence: named Fleet loading, role-to-profile resolution, tmux Lane launch, typed task_started, gate_updated, terminal run_completed, and tmux process-exit reconciliation. Use one concise row per owner and no explanatory prose so the seven-owner matrix survives handoff compaction. The Workflow row must carry both the `WorkflowUiEventKind::RunCompleted` constructor and the `terminal_completed_receipt` integration assertion. The Lane-exit row must carry a `crates/lane/src/runtime.rs` citation for `process_exit_receipt`, `lane_reconciled`, or its reconciliation test; `start_lane` alone is not terminal evidence. A bare verdict is invalid. The first non-empty line of your response must be exactly APPROVE or exactly BLOCK. Do not put any words before that verdict: no confirmation, summary, heading, or phrase such as `Here is the verdict`. Use APPROVE only when that complete concrete plan can be produced from the handoff; otherwise use BLOCK. This is deliberately not an implementation task: do not edit files, create branches, run shell commands, or propose fixes unrelated to missing acceptance evidence.",
"agent_type": "implement",
"role": "implement",
"mode": "read_only",
@@ -107,14 +93,13 @@ export default workflow({
"crates/workflow/src/role_resolve.rs",
"crates/tui/src/tools/workflow/mod.rs",
"crates/lane/src/runtime.rs"
],
"budget": { "max_steps": 4, "timeout_secs": 420, "max_tokens": 72000 }
]
}
},
{
"agent": {
"id": "review-contract",
"prompt": "Review only the promoted verification_plan handoff against the source_evidence citations it carries. Tools are intentionally unavailable because promoted handoffs are the evidence boundary; do not request source reads or gather new evidence. You have at most four model responses, but the first response must return APPROVE or BLOCK with no tool calls. After that verdict, include a compact `EVIDENCE REVIEW` section that carries forward exact `path: symbol` citations for all seven owners: named Fleet loading, role-to-profile resolution, tmux Lane launch, typed task_started, gate_updated, terminal run_completed, and tmux process-exit reconciliation. Use one concise row per owner and no explanatory prose so the seven-owner matrix survives handoff compaction. Accept Workflow terminal evidence only when the plan carries both the `WorkflowUiEventKind::RunCompleted` constructor and the `terminal_completed_receipt` integration assertion. Accept tmux terminal evidence only when the plan carries a `crates/lane/src/runtime.rs` process-exit reconciliation or reconciliation-test citation alongside that Workflow evidence. A bare verdict is invalid. The first non-empty line of your response must be exactly APPROVE or exactly BLOCK. Do not put any words before that verdict: no confirmation, summary, heading, or phrase such as `Here is the verdict`. Use APPROVE only when every owner has a concrete source citation in the promoted evidence; otherwise use BLOCK and list the missing evidence. Remain read-only and do not run shell commands or edit anything.",
"prompt": "Review only the promoted verification_plan handoff against the source_evidence citations it carries. Tools are intentionally unavailable because promoted handoffs are the evidence boundary; do not request source reads or gather new evidence. Return APPROVE or BLOCK after reviewing the promoted evidence. After that verdict, include a compact `EVIDENCE REVIEW` section that carries forward exact `path: symbol` citations for all seven owners: named Fleet loading, role-to-profile resolution, tmux Lane launch, typed task_started, gate_updated, terminal run_completed, and tmux process-exit reconciliation. Use one concise row per owner and no explanatory prose so the seven-owner matrix survives handoff compaction. Accept Workflow terminal evidence only when the plan carries both the `WorkflowUiEventKind::RunCompleted` constructor and the `terminal_completed_receipt` integration assertion. Accept tmux terminal evidence only when the plan carries a `crates/lane/src/runtime.rs` process-exit reconciliation or reconciliation-test citation alongside that Workflow evidence. A bare verdict is invalid. The first non-empty line of your response must be exactly APPROVE or exactly BLOCK. Do not put any words before that verdict: no confirmation, summary, heading, or phrase such as `Here is the verdict`. Use APPROVE only when every owner has a concrete source citation in the promoted evidence; otherwise use BLOCK and list the missing evidence. Remain read-only and do not run shell commands or edit anything.",
"agent_type": "reviewer",
"role": "reviewer",
"mode": "read_only",
@@ -125,14 +110,13 @@ export default workflow({
"crates/workflow/src/role_resolve.rs",
"crates/tui/src/tools/workflow/mod.rs",
"crates/lane/src/runtime.rs"
],
"budget": { "max_steps": 4, "timeout_secs": 420, "max_tokens": 72000 }
]
}
},
{
"agent": {
"id": "verify-receipts",
"prompt": "Statically verify only the promoted review_report and the source-evidence citations it carries against the required receipt contract. Tools are intentionally unavailable because promoted handoffs are the evidence boundary; do not request source reads or gather new evidence. You have at most four model responses, but the first response must return APPROVE or BLOCK with no tool calls. After that verdict, include a compact `EVIDENCE MATRIX` that carries forward exact test names or `path: symbol` citations for all seven owners: named Fleet loading, role-to-profile resolution, tmux Lane launch, typed task_started, gate_updated, terminal run_completed, and tmux process-exit reconciliation. Use one concise row per owner and no explanatory prose so the seven-owner matrix survives handoff compaction. The Workflow row must carry both the `WorkflowUiEventKind::RunCompleted` constructor and the `terminal_completed_receipt` integration assertion. The Lane-exit row must cite `crates/lane/src/runtime.rs` process-exit reconciliation rather than only `start_lane`. A bare verdict is invalid. The first non-empty line of your response must be exactly APPROVE or exactly BLOCK. Do not put any words before that verdict: no confirmation, summary, heading, or phrase such as `Here is the verdict`. Use APPROVE only when every required owner is covered; otherwise use BLOCK. Do not run commands, edit files, or create build artifacts; the host gate interprets the explicit first-line verdict.",
"prompt": "Statically verify only the promoted review_report and the source-evidence citations it carries against the required receipt contract. Tools are intentionally unavailable because promoted handoffs are the evidence boundary; do not request source reads or gather new evidence. Return APPROVE or BLOCK after reviewing the promoted evidence. After that verdict, include a compact `EVIDENCE MATRIX` that carries forward exact test names or `path: symbol` citations for all seven owners: named Fleet loading, role-to-profile resolution, tmux Lane launch, typed task_started, gate_updated, terminal run_completed, and tmux process-exit reconciliation. Use one concise row per owner and no explanatory prose so the seven-owner matrix survives handoff compaction. The Workflow row must carry both the `WorkflowUiEventKind::RunCompleted` constructor and the `terminal_completed_receipt` integration assertion. The Lane-exit row must cite `crates/lane/src/runtime.rs` process-exit reconciliation rather than only `start_lane`. A bare verdict is invalid. The first non-empty line of your response must be exactly APPROVE or exactly BLOCK. Do not put any words before that verdict: no confirmation, summary, heading, or phrase such as `Here is the verdict`. Use APPROVE only when every required owner is covered; otherwise use BLOCK. Do not run commands, edit files, or create build artifacts; the host gate interprets the explicit first-line verdict.",
"agent_type": "test",
"role": "test",
"mode": "read_only",
@@ -143,14 +127,13 @@ export default workflow({
"crates/workflow/src/role_resolve.rs",
"crates/tui/src/tools/workflow/mod.rs",
"crates/lane/src/runtime.rs"
],
"budget": { "max_steps": 4, "timeout_secs": 420, "max_tokens": 72000 }
]
}
},
{
"agent": {
"id": "release-receipt",
"prompt": "Use only the promoted verification_report handoff to produce the final acceptance receipt for the Fleet/Workflow/Lane/Runtime contract. Tools are intentionally unavailable because promoted handoffs are the evidence boundary; do not request source reads or gather new evidence. You have at most three model responses, but the first response must return APPROVE or BLOCK with no tool calls. After that verdict, include a compact `FINAL RECEIPT` section that carries forward exact `path: symbol` citations for all seven owners: named Fleet loading, role-to-profile resolution, tmux Lane launch, typed task_started, gate_updated, terminal run_completed, and tmux process-exit reconciliation. Use one concise row per owner and no explanatory prose. The Workflow row must carry both the `WorkflowUiEventKind::RunCompleted` constructor and the `terminal_completed_receipt` integration assertion. A bare verdict is invalid. The first non-empty line of your response must be exactly APPROVE or exactly BLOCK. Do not put any words before that verdict: no confirmation, summary, heading, or phrase such as `Here is the verdict`. Use APPROVE only when that complete static receipt can be produced from the handoff; otherwise use BLOCK and name the closure blocker. Never claim that source inspection substitutes for the host's live Workflow or Lane terminal events; the host validates the actual run_completed and process-exit reconciliation receipts after this role completes. Do not edit, publish, close issues, run shell commands, or mutate the workspace.",
"prompt": "Use only the promoted verification_report handoff to produce the final acceptance receipt for the Fleet/Workflow/Lane/Runtime contract. Tools are intentionally unavailable because promoted handoffs are the evidence boundary; do not request source reads or gather new evidence. Return APPROVE or BLOCK after reviewing the promoted evidence. After that verdict, include a compact `FINAL RECEIPT` section that carries forward exact `path: symbol` citations for all seven owners: named Fleet loading, role-to-profile resolution, tmux Lane launch, typed task_started, gate_updated, terminal run_completed, and tmux process-exit reconciliation. Use one concise row per owner and no explanatory prose. The Workflow row must carry both the `WorkflowUiEventKind::RunCompleted` constructor and the `terminal_completed_receipt` integration assertion. A bare verdict is invalid. The first non-empty line of your response must be exactly APPROVE or exactly BLOCK. Do not put any words before that verdict: no confirmation, summary, heading, or phrase such as `Here is the verdict`. Use APPROVE only when that complete static receipt can be produced from the handoff; otherwise use BLOCK and name the closure blocker. Never claim that source inspection substitutes for the host's live Workflow or Lane terminal events; the host validates the actual run_completed and process-exit reconciliation receipts after this role completes. Do not edit, publish, close issues, run shell commands, or mutate the workspace.",
"agent_type": "general",
"role": "release_lead",
"mode": "read_only",
@@ -161,8 +144,7 @@ export default workflow({
"crates/workflow/src/role_resolve.rs",
"crates/tui/src/tools/workflow/mod.rs",
"crates/lane/src/runtime.rs"
],
"budget": { "max_steps": 3, "timeout_secs": 300, "max_tokens": 48000 }
]
}
}
]