fix(tui): pre-release repair batch for 0.9.4 — stall honesty, idle wakes, truncation recovery, wait ergonomics
- turn_loop: a mid-stream chunk-timeout now counts toward the stream-error budget (stall with nothing streamed retries transparently; an exhausted budget fails the turn with the real reason instead of ending Completed over a frozen block). - idle engine: a finished background shell task wakes and starts an ordinary runtime turn even without an active goal; a dead provider route claims the completion once and reports where the output lives. - subagent: over-budget final reports spill to a session artifact and the truncation footer names the retrieve_tool_result ref; write failures degrade to the honest no-ref footer. Test-only wrappers marked cfg(test). - waits: agents/wait and agent action=wait default to 30 s and cap at 120 s (blocked waits deafen the session; settled children report back as sentinels). Bash action=wait honors timeout_secs/timeout aliases and block; result metadata reports the real wait_timeout_ms. - todo_write canonical naming: constructor is new(); work_update/TodoWrite/ todo stay hidden compat aliases; user-visible copy and docs updated. - behavioral tips: DurableStateWritten fires on successful remember calls; enum allow removed. voice.rs and work_surface model use let-chains. - test: Windows path-separator tolerant artifact footer assertion. - changelog: 0.9.4 additions (Agent Plugins v1.0.0, send_later, /advisor, quiet mode, automation forms, resume_from, transport resilience, durability, zh-Hant, update chip, RLM groundwork, stall/wake/truncation/ wait fixes). Dead-code budget re-baselined to 452.
This commit is contained in:
@@ -140,6 +140,39 @@ File edits, terminal width, and Windows installation.
|
||||
- Acceptance-level Gherkin coverage locking the existing user-command
|
||||
precedence, alias shadowing, fallback, and invalid-command error contract
|
||||
(PR #4992).
|
||||
- Agent Plugins v1.0.0: consume, publish, and slugify packaged sub-agent
|
||||
briefs, with an install/update/uninstall on-ramp in the TUI (PR #5182). A
|
||||
plugin bundles a prompt, posture, and routing as one shareable artifact;
|
||||
on-disk migration of the older `plugin.toml` scaffold is deliberately out
|
||||
of scope for this train.
|
||||
- `send_later`: a model-callable one-shot delayed continuation tool, so the
|
||||
model can schedule a single future nudge without an operator-approved
|
||||
durable automation (PR #5138).
|
||||
- `/advisor`: an opt-in background advisor watcher for live turns (PR #5139).
|
||||
- Notification quiet mode with per-category switches and action-first copy
|
||||
(PR #5066).
|
||||
- Automation scheduling forms — one-shot `ONCE`, five-field cron, and honest
|
||||
watcher modes — created through the approval-gated `automation` tool
|
||||
(PR #5183).
|
||||
- Sub-agent `resume_from` continuation chains (PR #5142), child-result
|
||||
diff-tainting when a claimed diff is not visible to git, per-turn usage
|
||||
receipts on the exec stream-json stream, and spawn receipts that report
|
||||
the model each sub-agent actually ran on.
|
||||
- Transport resilience: sub-agent exec transport retries with a 600 s
|
||||
default (PR #5210), SSE header stalls retryable instead of fatal, and
|
||||
headless turn resume after mid-stream network drops with an `EX_TEMPFAIL`
|
||||
exit.
|
||||
- Session durability and control: a deterministic compaction continuation
|
||||
contract (PR #5064), persisting interrupted output (PR #5206), stop-word
|
||||
cancellation (PR #5207), token-counter refresh (PR #5204), deny-by-default
|
||||
approval cards (PR #5090), and the Operate completion gate (PR #5067).
|
||||
- zh-Hant promoted to a full shipped locale with complete `en.json` parity
|
||||
(PR #5143).
|
||||
- A persistent update-available chip in the header, with the startup update
|
||||
check throttled and naming the right command.
|
||||
- RLM static intent extraction for code blocks (`rlm_block_intent.rs`)
|
||||
landed as groundwork for a future code-mode approval flow; it is not yet
|
||||
wired into the turn pipeline and ships dormant by design.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -186,6 +219,20 @@ File edits, terminal width, and Windows installation.
|
||||
futures-util to 0.3.33, libc to 0.2.189, actions/stale to 11.0.0, and
|
||||
docker/login-action to 4.5.2. The locked graph also includes the
|
||||
event-listener 5.4.2 fix for RUSTSEC-2026-0221.
|
||||
- The progress surface now speaks plainly everywhere: the last user-visible
|
||||
"Work update is pending" notices say "To-do list", the tool constructor and
|
||||
the docs name `todo_write` as the single canonical progress tool, and
|
||||
`work_update`, `TodoWrite`, and `todo` stay registered as hidden
|
||||
compatibility aliases so saved transcripts keep replaying.
|
||||
- Sub-agent and `agents/wait` waits stay short by default and by cap:
|
||||
blocking waits default to 30 s and refuse to block past 120 s, because a
|
||||
blocked wait deafens the session to typed input and settled children
|
||||
already report back as `<codewhale:subagent.done>` sentinels.
|
||||
- `Bash` `action=wait` honors `timeout_secs` (seconds) and bare `timeout`
|
||||
(milliseconds) alongside canonical `timeout_ms`, and `block` as an alias
|
||||
for `wait`, so a habit formed on other wait tools gets the duration it
|
||||
asked for instead of silently falling back to the 30 s default; the result
|
||||
metadata reports the real `wait_timeout_ms` applied.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -362,6 +409,21 @@ File edits, terminal width, and Windows installation.
|
||||
- Transcript wheel scrolling under iTerm2: xterm alternate-scroll (DECSET
|
||||
1007) now stays off while mouse capture is active, so wheel events arrive as
|
||||
mouse events instead of being converted into arrow keys (#5223, PR #5234).
|
||||
- A stalled model stream no longer ends the turn as `Completed` over a
|
||||
frozen reasoning block: a mid-stream chunk-timeout now counts toward the
|
||||
stream-error budget, so a stall with nothing streamed retries the request
|
||||
transparently, and a stall that exhausts the retry budget fails the turn
|
||||
with the real reason instead of reporting success.
|
||||
- A finished background shell task now wakes the engine even when no goal is
|
||||
active: the idle loop starts an ordinary runtime turn so the completion
|
||||
reaches the model immediately instead of sitting unclaimed until the user
|
||||
types (a dead provider route claims the completion once and reports where
|
||||
the output lives instead of re-arming the same error every tick).
|
||||
- Sub-agent final reports that exceed the summary budget are now spilled to
|
||||
a session artifact, and the truncation footer names the
|
||||
`retrieve_tool_result` ref for the elided middle instead of telling the
|
||||
model the bytes are unrecoverable; write failures degrade to the honest
|
||||
no-ref footer.
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -140,6 +140,39 @@ File edits, terminal width, and Windows installation.
|
||||
- Acceptance-level Gherkin coverage locking the existing user-command
|
||||
precedence, alias shadowing, fallback, and invalid-command error contract
|
||||
(PR #4992).
|
||||
- Agent Plugins v1.0.0: consume, publish, and slugify packaged sub-agent
|
||||
briefs, with an install/update/uninstall on-ramp in the TUI (PR #5182). A
|
||||
plugin bundles a prompt, posture, and routing as one shareable artifact;
|
||||
on-disk migration of the older `plugin.toml` scaffold is deliberately out
|
||||
of scope for this train.
|
||||
- `send_later`: a model-callable one-shot delayed continuation tool, so the
|
||||
model can schedule a single future nudge without an operator-approved
|
||||
durable automation (PR #5138).
|
||||
- `/advisor`: an opt-in background advisor watcher for live turns (PR #5139).
|
||||
- Notification quiet mode with per-category switches and action-first copy
|
||||
(PR #5066).
|
||||
- Automation scheduling forms — one-shot `ONCE`, five-field cron, and honest
|
||||
watcher modes — created through the approval-gated `automation` tool
|
||||
(PR #5183).
|
||||
- Sub-agent `resume_from` continuation chains (PR #5142), child-result
|
||||
diff-tainting when a claimed diff is not visible to git, per-turn usage
|
||||
receipts on the exec stream-json stream, and spawn receipts that report
|
||||
the model each sub-agent actually ran on.
|
||||
- Transport resilience: sub-agent exec transport retries with a 600 s
|
||||
default (PR #5210), SSE header stalls retryable instead of fatal, and
|
||||
headless turn resume after mid-stream network drops with an `EX_TEMPFAIL`
|
||||
exit.
|
||||
- Session durability and control: a deterministic compaction continuation
|
||||
contract (PR #5064), persisting interrupted output (PR #5206), stop-word
|
||||
cancellation (PR #5207), token-counter refresh (PR #5204), deny-by-default
|
||||
approval cards (PR #5090), and the Operate completion gate (PR #5067).
|
||||
- zh-Hant promoted to a full shipped locale with complete `en.json` parity
|
||||
(PR #5143).
|
||||
- A persistent update-available chip in the header, with the startup update
|
||||
check throttled and naming the right command.
|
||||
- RLM static intent extraction for code blocks (`rlm_block_intent.rs`)
|
||||
landed as groundwork for a future code-mode approval flow; it is not yet
|
||||
wired into the turn pipeline and ships dormant by design.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -186,6 +219,20 @@ File edits, terminal width, and Windows installation.
|
||||
futures-util to 0.3.33, libc to 0.2.189, actions/stale to 11.0.0, and
|
||||
docker/login-action to 4.5.2. The locked graph also includes the
|
||||
event-listener 5.4.2 fix for RUSTSEC-2026-0221.
|
||||
- The progress surface now speaks plainly everywhere: the last user-visible
|
||||
"Work update is pending" notices say "To-do list", the tool constructor and
|
||||
the docs name `todo_write` as the single canonical progress tool, and
|
||||
`work_update`, `TodoWrite`, and `todo` stay registered as hidden
|
||||
compatibility aliases so saved transcripts keep replaying.
|
||||
- Sub-agent and `agents/wait` waits stay short by default and by cap:
|
||||
blocking waits default to 30 s and refuse to block past 120 s, because a
|
||||
blocked wait deafens the session to typed input and settled children
|
||||
already report back as `<codewhale:subagent.done>` sentinels.
|
||||
- `Bash` `action=wait` honors `timeout_secs` (seconds) and bare `timeout`
|
||||
(milliseconds) alongside canonical `timeout_ms`, and `block` as an alias
|
||||
for `wait`, so a habit formed on other wait tools gets the duration it
|
||||
asked for instead of silently falling back to the 30 s default; the result
|
||||
metadata reports the real `wait_timeout_ms` applied.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -362,6 +409,21 @@ File edits, terminal width, and Windows installation.
|
||||
- Transcript wheel scrolling under iTerm2: xterm alternate-scroll (DECSET
|
||||
1007) now stays off while mouse capture is active, so wheel events arrive as
|
||||
mouse events instead of being converted into arrow keys (#5223, PR #5234).
|
||||
- A stalled model stream no longer ends the turn as `Completed` over a
|
||||
frozen reasoning block: a mid-stream chunk-timeout now counts toward the
|
||||
stream-error budget, so a stall with nothing streamed retries the request
|
||||
transparently, and a stall that exhausts the retry budget fails the turn
|
||||
with the real reason instead of reporting success.
|
||||
- A finished background shell task now wakes the engine even when no goal is
|
||||
active: the idle loop starts an ordinary runtime turn so the completion
|
||||
reaches the model immediately instead of sitting unclaimed until the user
|
||||
types (a dead provider route claims the completion once and reports where
|
||||
the output lives instead of re-arming the same error every tick).
|
||||
- Sub-agent final reports that exceed the summary budget are now spilled to
|
||||
a session artifact, and the truncation footer names the
|
||||
`retrieve_tool_result` ref for the elided middle instead of telling the
|
||||
model the bytes are unrecoverable; write failures degrade to the honest
|
||||
no-ref footer.
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -480,21 +480,21 @@ async fn transcribe_local_whisper(audio_samples: &[i16]) -> Result<String, Strin
|
||||
.arg("auto")
|
||||
.arg("--output-txt")
|
||||
.output();
|
||||
if let Ok(out) = output {
|
||||
if out.status.success() {
|
||||
let txt = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if let Ok(out) = output
|
||||
&& out.status.success()
|
||||
{
|
||||
let txt = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
if !txt.is_empty() {
|
||||
return Ok(txt);
|
||||
}
|
||||
// Some builds write to .txt sidecar
|
||||
let sidecar = tmp.with_extension("txt");
|
||||
if let Ok(s) = std::fs::read_to_string(&sidecar) {
|
||||
let _ = std::fs::remove_file(&sidecar);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
if !txt.is_empty() {
|
||||
return Ok(txt);
|
||||
}
|
||||
// Some builds write to .txt sidecar
|
||||
let sidecar = tmp.with_extension("txt");
|
||||
if let Ok(s) = std::fs::read_to_string(&sidecar) {
|
||||
let _ = std::fs::remove_file(&sidecar);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
if !s.trim().is_empty() {
|
||||
return Ok(s.trim().to_string());
|
||||
}
|
||||
if !s.trim().is_empty() {
|
||||
return Ok(s.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,7 +703,7 @@ mod tests {
|
||||
|
||||
let mut context = crate::tools::spec::ToolContext::new(app.workspace.clone());
|
||||
context.runtime.work = Some(work);
|
||||
crate::tools::todo::TodoWriteTool::work_update(app.todos.clone())
|
||||
crate::tools::todo::TodoWriteTool::new(app.todos.clone())
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"todos": [{"content": "relay the staged graph", "status": "in_progress"}]
|
||||
@@ -711,7 +711,7 @@ mod tests {
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect("graph-backed work_update");
|
||||
.expect("graph-backed todo_write");
|
||||
|
||||
assert!(
|
||||
app.todos.lock().await.snapshot().is_empty(),
|
||||
|
||||
@@ -1891,19 +1891,12 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the idle loop should poll for background shell completion:
|
||||
/// only while a goal is active and a background job is running or has
|
||||
/// finished without being claimed yet.
|
||||
/// Whether the idle loop should poll for background shell completion: a
|
||||
/// background job is running or has finished without being claimed yet.
|
||||
/// Plain interactive sessions arm exactly like goal sessions — a finished
|
||||
/// background task must reach the model without waiting for the user to
|
||||
/// type, the same wake an idle sub-agent completion already gets.
|
||||
fn idle_shell_wake_armed(&self) -> bool {
|
||||
let goal_active = self
|
||||
.config
|
||||
.goal_state
|
||||
.lock()
|
||||
.map(|state| state.snapshot().is_active())
|
||||
.unwrap_or(false);
|
||||
if !goal_active {
|
||||
return false;
|
||||
}
|
||||
self.shell_manager
|
||||
.lock()
|
||||
.map(|manager| manager.may_have_undelivered_completion())
|
||||
@@ -1918,18 +1911,82 @@ impl Engine {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// An idle-engine wake for finished background shell work: queue a goal
|
||||
/// continuation. The evidence itself is claimed by the boundary drain in
|
||||
/// `handle_send_message`, so the continuation turn reads the completion
|
||||
/// payload the same way a user-initiated turn would.
|
||||
/// An idle-engine wake for finished background shell work. With an active
|
||||
/// goal this queues a goal continuation; without one it starts an ordinary
|
||||
/// runtime turn so the completion reaches the model immediately instead of
|
||||
/// sitting unclaimed until the user types. Either way the evidence itself
|
||||
/// is claimed by the boundary drain in `handle_send_message`, so the
|
||||
/// follow-up turn reads the completion payload the same way a
|
||||
/// user-initiated turn would.
|
||||
async fn handle_idle_shell_completion_wake(&mut self) {
|
||||
let goal_active = self
|
||||
.config
|
||||
.goal_state
|
||||
.lock()
|
||||
.map(|state| state.snapshot().is_active())
|
||||
.unwrap_or(false);
|
||||
if goal_active {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(
|
||||
"Background shell work finished; continuing the active goal".to_string(),
|
||||
))
|
||||
.await;
|
||||
self.schedule_goal_continuation(Vec::new());
|
||||
return;
|
||||
}
|
||||
let route = match self.current_runtime_route() {
|
||||
Ok(route) => route,
|
||||
Err(err) => {
|
||||
// No route, no turn. Claim the once-only completion now so a
|
||||
// dead route cannot re-arm the wake into the same error every
|
||||
// poll tick; the user sees what finished and where the output
|
||||
// lives, and the next healthy turn proceeds normally.
|
||||
let finished = self
|
||||
.shell_manager
|
||||
.lock()
|
||||
.map(|mut manager| manager.drain_finished_jobs_with_evidence().len())
|
||||
.unwrap_or(0);
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::error(ErrorEnvelope::fatal_auth(format!(
|
||||
"{finished} background shell task(s) finished, but the turn cannot resume because the provider route is no longer valid: {err}. Their output stays available via /jobs."
|
||||
))))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(
|
||||
"Background shell work finished; continuing the active goal".to_string(),
|
||||
"Background shell work finished; resuming the turn".to_string(),
|
||||
))
|
||||
.await;
|
||||
self.schedule_goal_continuation(Vec::new());
|
||||
let _ = self
|
||||
.handle_send_message(
|
||||
"[runtime] A background shell task finished; its completion evidence follows."
|
||||
.to_string(),
|
||||
self.current_mode,
|
||||
route,
|
||||
self.config.compaction.clone(),
|
||||
self.config.goal_objective.clone(),
|
||||
self.config.goal_token_budget,
|
||||
self.config.goal_status,
|
||||
self.session.reasoning_effort.clone(),
|
||||
self.session.reasoning_effort_auto,
|
||||
self.session.auto_model,
|
||||
self.session.allow_shell,
|
||||
self.session.trust_mode,
|
||||
self.session.auto_approve,
|
||||
self.session.approval_mode,
|
||||
self.config.translation_enabled,
|
||||
self.config.allowed_tools.clone(),
|
||||
Vec::new(),
|
||||
self.config.hook_executor.clone(),
|
||||
self.config.verbosity.clone(),
|
||||
UserInputProvenance::Runtime,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Run the engine event loop
|
||||
@@ -3544,7 +3601,7 @@ impl Engine {
|
||||
Some(SubAgentForkContext {
|
||||
messages: self.messages_with_turn_metadata(),
|
||||
structured_state_block: state.to_system_block(),
|
||||
// Resolve at spawn time so a work update earlier in this turn
|
||||
// Resolve at spawn time so a todo_write earlier in this turn
|
||||
// reaches the child rather than freezing turn-start state.
|
||||
work_source: Some(self.work_state_source()),
|
||||
})
|
||||
|
||||
@@ -10900,10 +10900,10 @@ async fn run_graph_backed_work_update(
|
||||
use crate::tools::spec::ToolSpec as _;
|
||||
let mut context = crate::tools::spec::ToolContext::new(std::env::temp_dir());
|
||||
context.runtime.work = Some(work.clone());
|
||||
crate::tools::todo::TodoWriteTool::work_update(todos.clone())
|
||||
crate::tools::todo::TodoWriteTool::new(todos.clone())
|
||||
.execute(json!({ "todos": items }), &context)
|
||||
.await
|
||||
.expect("graph-backed work_update");
|
||||
.expect("graph-backed todo_write");
|
||||
}
|
||||
|
||||
/// #3983 runtime regression: a real graph-backed `work_update` stages the new
|
||||
@@ -15737,13 +15737,16 @@ async fn idle_engine_wakes_for_finished_background_shell_only_while_goal_active(
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
|
||||
// No active goal: the wake stays disarmed and the idle receive keeps
|
||||
// waiting — completions belong to the next user-initiated turn.
|
||||
let disarmed =
|
||||
tokio::time::timeout(Duration::from_millis(300), engine.next_run_input(false)).await;
|
||||
// No active goal: the wake still arms — a finished background task must
|
||||
// reach the model without waiting for the user to type, the same wake an
|
||||
// idle sub-agent completion already gets.
|
||||
let input = tokio::time::timeout(Duration::from_secs(10), engine.next_run_input(false))
|
||||
.await
|
||||
.expect("idle engine must wake for finished background shell work even without a goal")
|
||||
.expect("engine input");
|
||||
assert!(
|
||||
disarmed.is_err(),
|
||||
"without an active goal the engine must not start turns for shell completions"
|
||||
matches!(input, EngineRunInput::ShellCompletionWake),
|
||||
"wake input expected without an active goal"
|
||||
);
|
||||
|
||||
engine
|
||||
|
||||
@@ -353,7 +353,12 @@ impl Engine {
|
||||
manager.terminal_results_excluding(&self.delivered_subagent_completion_ids)
|
||||
};
|
||||
for result in synthesized {
|
||||
let completion = crate::tools::subagent::subagent_completion_from_result(&result);
|
||||
let report_ref =
|
||||
crate::tools::subagent::spill_subagent_final_report(&self.session.id, &result);
|
||||
let completion = crate::tools::subagent::subagent_completion_from_result_with_ref(
|
||||
&result,
|
||||
report_ref.as_deref(),
|
||||
);
|
||||
if let Some(completion) = super::claim_subagent_completion(
|
||||
&mut self.delivered_subagent_completion_ids,
|
||||
completion,
|
||||
@@ -991,6 +996,13 @@ impl Engine {
|
||||
}
|
||||
.into_envelope();
|
||||
crate::logging::warn(&envelope.message);
|
||||
// A stall is a stream error like any other:
|
||||
// count it so the nothing-streamed retry can
|
||||
// fire, and record it so an unrecovered stall
|
||||
// fails the turn with the real reason instead
|
||||
// of ending "Completed" over a frozen block.
|
||||
stream_errors = stream_errors.saturating_add(1);
|
||||
turn_error.get_or_insert(envelope.message.clone());
|
||||
let _ = self.tx_event.send(Event::error(envelope)).await;
|
||||
None
|
||||
}
|
||||
@@ -4102,7 +4114,7 @@ fn truncate_runtime_status_field(text: &str, max_chars: usize) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(test)]
|
||||
fn should_hold_turn_for_subagents(queued_completions: usize, running_children: usize) -> bool {
|
||||
// #3216: launching sub-agents must NOT barrier the parent turn. Only queued
|
||||
// completions (work already finished that must be surfaced into the
|
||||
|
||||
@@ -1117,7 +1117,7 @@ impl ToolRegistryBuilder {
|
||||
#[must_use]
|
||||
pub fn with_todo_tool(self, todo_list: super::todo::SharedTodoList) -> Self {
|
||||
use super::todo::TodoWriteTool;
|
||||
self.with_tool(Arc::new(TodoWriteTool::work_update(todo_list.clone())))
|
||||
self.with_tool(Arc::new(TodoWriteTool::new(todo_list.clone())))
|
||||
.with_tool(Arc::new(TodoWriteTool::alias(
|
||||
"work_update",
|
||||
todo_list.clone(),
|
||||
|
||||
@@ -2939,7 +2939,7 @@ impl ToolSpec for BashTool {
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in milliseconds. The default depends on the action: action=run 120000 (capped at 600000), action=wait 30000, action=interact 1000."
|
||||
"description": "Timeout in milliseconds. The default depends on the action: action=run 120000 (capped at 600000), action=wait 30000, action=interact 1000. For action=wait, `timeout_secs` (seconds) and `timeout` (milliseconds) are accepted aliases."
|
||||
},
|
||||
"background": {
|
||||
"type": "boolean",
|
||||
@@ -2983,7 +2983,7 @@ impl ToolSpec for BashTool {
|
||||
},
|
||||
"wait": {
|
||||
"type": "boolean",
|
||||
"description": "Block until task completes (action=wait, default: false)"
|
||||
"description": "Block until task completes or the timeout elapses (action=wait, default: false; `block` is an accepted alias)"
|
||||
},
|
||||
"close_stdin": {
|
||||
"type": "boolean",
|
||||
@@ -3614,8 +3614,8 @@ impl BashTool {
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let task_id = required_task_id(input)?;
|
||||
let wait = optional_bool(input, "wait", false)?;
|
||||
let timeout_ms = optional_u64(input, "timeout_ms", 30_000)?;
|
||||
let wait = optional_bool(input, "wait", false)? || optional_bool(input, "block", false)?;
|
||||
let timeout_ms = wait_timeout_ms(input)?;
|
||||
|
||||
let (delta, wait_canceled) = if wait {
|
||||
wait_for_shell_delta_cancellable(context, task_id, timeout_ms).await?
|
||||
@@ -3632,6 +3632,11 @@ impl BashTool {
|
||||
|
||||
let status = delta.result.status.clone();
|
||||
let mut result = build_shell_delta_tool_result(delta, context);
|
||||
if let Some(metadata) = result.metadata.as_mut()
|
||||
&& let Some(object) = metadata.as_object_mut()
|
||||
{
|
||||
object.insert("wait_timeout_ms".to_string(), json!(timeout_ms));
|
||||
}
|
||||
if wait_canceled {
|
||||
if matches!(status, ShellStatus::Running) {
|
||||
result.content = format!(
|
||||
@@ -3819,6 +3824,25 @@ fn first_present_field<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
/// Effective `action=wait` timeout in milliseconds. `timeout_ms` is
|
||||
/// canonical; `timeout_secs` (seconds) and bare `timeout` (milliseconds) are
|
||||
/// honored so a habit formed on other wait tools gets the duration it asked
|
||||
/// for instead of silently falling back to the 30 s default.
|
||||
fn wait_timeout_ms(input: &serde_json::Value) -> Result<u64, ToolError> {
|
||||
match first_present_field(input, &["timeout_ms", "timeout_secs", "timeout"]) {
|
||||
None => Ok(30_000),
|
||||
Some(("timeout_secs", value)) => {
|
||||
let secs = value
|
||||
.as_u64()
|
||||
.ok_or_else(|| type_mismatch("timeout_secs", value, "an integer"))?;
|
||||
Ok(secs.saturating_mul(1_000))
|
||||
}
|
||||
Some((name, value)) => value
|
||||
.as_u64()
|
||||
.ok_or_else(|| type_mismatch(name, value, "an integer")),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_shell_delta_tool_result(delta: ShellDeltaResult, context: &ToolContext) -> ToolResult {
|
||||
let result = delta.result;
|
||||
let network_restricted_hint =
|
||||
|
||||
@@ -22,9 +22,12 @@ use crate::tools::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
};
|
||||
|
||||
const COORD_WAIT_DEFAULT_TIMEOUT_SECS: u64 = 300;
|
||||
/// Bounds for `agents/wait`. Short on purpose: a blocked wait makes the
|
||||
/// session deaf to typed input, and settled children already report back as
|
||||
/// `<codewhale:subagent.done>` sentinels that start a fresh turn (#4097).
|
||||
const COORD_WAIT_DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
const COORD_WAIT_MIN_TIMEOUT_SECS: u64 = 1;
|
||||
const COORD_WAIT_MAX_TIMEOUT_SECS: u64 = 1800;
|
||||
const COORD_WAIT_MAX_TIMEOUT_SECS: u64 = 120;
|
||||
const COORD_WAIT_CHECK_INTERVAL: Duration = Duration::from_millis(250);
|
||||
const RECENT_PROGRESS_LIMIT: usize = 8;
|
||||
pub(super) const COORDINATION_RECORD_LIMIT: usize = 128;
|
||||
@@ -525,7 +528,7 @@ impl ToolSpec for AgentsWaitTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Block until watched children settle or the timeout elapses. One blocking wait is the right shape; polling agents/list in a loop is not. until=all is the fan-out join: it returns only when every child running at call time has left running, with each child's outcome. until=completion (default) returns as soon as any one child settles. until=activity also returns on progress."
|
||||
"Block briefly until watched children settle or the timeout elapses. Keep waits short: on timeout, end your turn — settled children wake you automatically as completion sentinels; polling agents/list in a loop is not the right shape either. until=all is the fan-out join: it returns only when every child running at call time has left running, with each child's outcome. until=completion (default) returns as soon as any one child settles. until=activity also returns on progress."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
@@ -539,8 +542,8 @@ impl ToolSpec for AgentsWaitTool {
|
||||
"timeout_secs": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1800,
|
||||
"description": "Maximum seconds to block. Default 300."
|
||||
"maximum": 120,
|
||||
"description": "Maximum seconds to block. Default 30. Keep it short — on timeout, end your turn; settled children report back as completion sentinels."
|
||||
},
|
||||
"until": {
|
||||
"type": "string",
|
||||
|
||||
@@ -1948,6 +1948,10 @@ struct SubAgentTerminalDeliveryContext {
|
||||
parent_completion_tx: Option<mpsc::UnboundedSender<SubAgentCompletion>>,
|
||||
mailbox: Option<Mailbox>,
|
||||
event_tx: Option<mpsc::Sender<Event>>,
|
||||
/// Shared session namespace (root session id), cloned down the spawn
|
||||
/// tree; over-budget final reports are spilled under it so the truncation
|
||||
/// footer can name a retrievable artifact.
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
impl SubAgentTerminalDeliveryContext {
|
||||
@@ -1957,6 +1961,7 @@ impl SubAgentTerminalDeliveryContext {
|
||||
parent_completion_tx: runtime.parent_completion_tx.clone(),
|
||||
mailbox: runtime.mailbox.clone(),
|
||||
event_tx: runtime.event_tx.clone(),
|
||||
session_id: runtime.context.state_namespace.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1964,7 +1969,8 @@ impl SubAgentTerminalDeliveryContext {
|
||||
/// manager owns the terminal claim. The public agent/worker states remain
|
||||
/// Running until all three sends have been attempted.
|
||||
fn deliver(&self, result: &SubAgentResult) {
|
||||
let completion = subagent_completion_from_result(result);
|
||||
let report_ref = spill_subagent_final_report(&self.session_id, result);
|
||||
let completion = subagent_completion_from_result_with_ref(result, report_ref.as_deref());
|
||||
|
||||
if self.spawn_depth > 0
|
||||
&& let Some(tx) = self.parent_completion_tx.as_ref()
|
||||
@@ -1973,7 +1979,7 @@ impl SubAgentTerminalDeliveryContext {
|
||||
}
|
||||
|
||||
if let Some(mailbox) = self.mailbox.as_ref() {
|
||||
let _ = mailbox.send(terminal_mailbox_message(result));
|
||||
let _ = mailbox.send(terminal_mailbox_message(result, report_ref.as_deref()));
|
||||
}
|
||||
|
||||
if let Some(event_tx) = self.event_tx.as_ref() {
|
||||
@@ -1985,10 +1991,11 @@ impl SubAgentTerminalDeliveryContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_mailbox_message(result: &SubAgentResult) -> MailboxMessage {
|
||||
fn terminal_mailbox_message(result: &SubAgentResult, report_ref: Option<&str>) -> MailboxMessage {
|
||||
match &result.status {
|
||||
SubAgentStatus::Completed => {
|
||||
let (summary, _) = stamp_subagent_summary(&summarize_subagent_result(result));
|
||||
let (summary, _) =
|
||||
stamp_subagent_summary_with_ref(&summarize_subagent_result(result), report_ref);
|
||||
MailboxMessage::Completed {
|
||||
agent_id: result.agent_id.clone(),
|
||||
summary,
|
||||
@@ -7055,7 +7062,7 @@ impl ToolSpec for AgentTool {
|
||||
"timeout_secs": {
|
||||
"type": "integer",
|
||||
"minimum": 5,
|
||||
"maximum": 1800,
|
||||
"maximum": 120,
|
||||
"description": "For action=wait: maximum seconds to block (default 30). Prefer ending the turn and staying reachable — results arrive automatically as <codewhale:subagent.done> sentinels — only wait when you must join before continuing."
|
||||
},
|
||||
"message": {
|
||||
@@ -7594,7 +7601,7 @@ const SUBAGENT_WAIT_DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
/// Runtime floor is 1s (schema advertises 5) so tests can exercise the
|
||||
/// timeout path without multi-second sleeps.
|
||||
const SUBAGENT_WAIT_MIN_TIMEOUT_SECS: u64 = 1;
|
||||
const SUBAGENT_WAIT_MAX_TIMEOUT_SECS: u64 = 1800;
|
||||
const SUBAGENT_WAIT_MAX_TIMEOUT_SECS: u64 = 120;
|
||||
/// Internal state-check cadence while blocked. Invisible to the model — the
|
||||
/// #4097 anti-pattern is model-visible polling that burns turns and tokens,
|
||||
/// not a cheap in-process timer.
|
||||
@@ -8921,7 +8928,17 @@ pub(crate) fn emit_parent_completion(
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn subagent_completion_from_result(result: &SubAgentResult) -> SubAgentCompletion {
|
||||
subagent_completion_from_result_with_ref(result, None)
|
||||
}
|
||||
|
||||
/// Completion builder that names the persisted full report in the truncation
|
||||
/// footer when `report_ref` is available; see `spill_subagent_final_report`.
|
||||
pub(crate) fn subagent_completion_from_result_with_ref(
|
||||
result: &SubAgentResult,
|
||||
report_ref: Option<&str>,
|
||||
) -> SubAgentCompletion {
|
||||
let raw = summarize_subagent_result(result);
|
||||
let mut evidence_truncated = false;
|
||||
let evidence_block = match &result.status {
|
||||
@@ -8944,7 +8961,7 @@ pub(crate) fn subagent_completion_from_result(result: &SubAgentResult) -> SubAge
|
||||
.as_ref()
|
||||
.map(|_| strip_evidence_block(&raw))
|
||||
.unwrap_or(raw);
|
||||
let (summary, truncated) = stamp_subagent_summary(&summary_source);
|
||||
let (summary, truncated) = stamp_subagent_summary_with_ref(&summary_source, report_ref);
|
||||
let summary_truncated = truncated || evidence_truncated;
|
||||
let sentinel = match &result.status {
|
||||
SubAgentStatus::Failed(error) => subagent_failed_sentinel(result, error),
|
||||
@@ -13472,12 +13489,12 @@ fn annotate_child_model_error(
|
||||
|
||||
/// Char budget above which a sub-agent summary is treated as a large dump and
|
||||
/// head+tail truncated. Mirrors `TOOL_RESULT_SENT_CHAR_BUDGET` in
|
||||
/// `crates/tui/src/client/chat.rs:702` so sub-agent summaries use the same
|
||||
/// `crates/tui/src/client/chat.rs:1377` so sub-agent summaries use the same
|
||||
/// threshold as regular tool outputs. Duplicated locally to avoid coupling the
|
||||
/// sub-agent module to the wire-compaction internals.
|
||||
const SUBAGENT_SUMMARY_CHAR_BUDGET: usize = 12_000;
|
||||
/// Head/tail slice sizes when truncating; mirror the wire constants
|
||||
/// (`TOOL_RESULT_HEAD_CHARS`/`TOOL_RESULT_TAIL_CHARS`, chat.rs:703-704).
|
||||
/// (`TOOL_RESULT_HEAD_CHARS`/`TOOL_RESULT_TAIL_CHARS`, chat.rs:1378-1379).
|
||||
const SUBAGENT_SUMMARY_HEAD_CHARS: usize = 4_000;
|
||||
const SUBAGENT_SUMMARY_TAIL_CHARS: usize = 4_000;
|
||||
|
||||
@@ -13494,12 +13511,20 @@ run the relevant tests) before relying on it.]";
|
||||
/// note and report `truncated: false`.
|
||||
/// - When it exceeds the budget, keep a head+tail slice and stamp it with the
|
||||
/// existing `[Output truncated ...]` vocabulary (reused from tool-output
|
||||
/// truncation), adapted to be honest that the elided middle is NOT in the
|
||||
/// spillover store — there is no `retrieve_tool_result` handle for
|
||||
/// sub-agent summaries. Report `truncated: true`.
|
||||
/// truncation). When `report_ref` names the persisted full report (see
|
||||
/// `spill_subagent_final_report`), the footer points at it so the elided
|
||||
/// middle stays retrievable via `retrieve_tool_result`; with no ref the
|
||||
/// footer stays honest that the middle cannot be retrieved. Report
|
||||
/// `truncated: true` either way.
|
||||
///
|
||||
/// Every summary therefore gets exactly one boundary marker, never both.
|
||||
#[cfg(test)]
|
||||
fn stamp_subagent_summary(raw: &str) -> (String, bool) {
|
||||
stamp_subagent_summary_with_ref(raw, None)
|
||||
}
|
||||
|
||||
/// The ref-aware stamper; see `stamp_subagent_summary`.
|
||||
fn stamp_subagent_summary_with_ref(raw: &str, report_ref: Option<&str>) -> (String, bool) {
|
||||
let total = raw.chars().count();
|
||||
if total <= SUBAGENT_SUMMARY_CHAR_BUDGET {
|
||||
return (format!("{raw}{SUBAGENT_SELF_REPORT_NOTE}"), false);
|
||||
@@ -13513,15 +13538,47 @@ fn stamp_subagent_summary(raw: &str) -> (String, bool) {
|
||||
let omitted = total
|
||||
.saturating_sub(SUBAGENT_SUMMARY_HEAD_CHARS)
|
||||
.saturating_sub(SUBAGENT_SUMMARY_TAIL_CHARS);
|
||||
let retrieval = match report_ref {
|
||||
Some(reference) => format!(
|
||||
"the full report is retained as artifact {reference} — read the elided middle ({omitted} \
|
||||
chars) with retrieve_tool_result using that ref (mode=lines/query/bytes). Re-verify material claims \
|
||||
before relying on them."
|
||||
),
|
||||
None => format!(
|
||||
"the elided middle ({omitted} chars) is not in the spillover store and cannot be \
|
||||
retrieved via retrieve_tool_result. Re-open the child or read changed files directly to verify \
|
||||
material claims."
|
||||
),
|
||||
};
|
||||
let stamped = format!(
|
||||
"{head}\n\n[Sub-agent summary truncated: {SUBAGENT_SUMMARY_HEAD_CHARS} + {SUBAGENT_SUMMARY_TAIL_CHARS} of {total} \
|
||||
chars shown. This is the child's self-report; the elided middle ({omitted} chars) is not in \
|
||||
the spillover store and cannot be retrieved via retrieve_tool_result. Re-open the child or \
|
||||
read changed files directly to verify material claims.]\n\n{tail}",
|
||||
chars shown. This is the child's self-report; {retrieval}]\n\n{tail}",
|
||||
);
|
||||
(stamped, true)
|
||||
}
|
||||
|
||||
/// Persist a final report that exceeds the summary budget so the truncated
|
||||
/// summary can name a retrievable artifact instead of dropping the elided
|
||||
/// middle. Returns the `retrieve_tool_result` ref on success; write failures
|
||||
/// degrade to no ref, mirroring `apply_spillover`'s passthrough posture. The
|
||||
/// artifact lands under the shared session root (`state_namespace`), which
|
||||
/// every agent in the spawn tree clones, so the parent and any sibling can
|
||||
/// resolve it. Same-id writes carry identical bytes, so a synthesized
|
||||
/// re-delivery of the same terminal result is idempotent.
|
||||
pub(crate) fn spill_subagent_final_report(
|
||||
session_id: &str,
|
||||
result: &SubAgentResult,
|
||||
) -> Option<String> {
|
||||
let raw = summarize_subagent_result(result);
|
||||
if raw.chars().count() <= SUBAGENT_SUMMARY_CHAR_BUDGET {
|
||||
return None;
|
||||
}
|
||||
let artifact_id = format!("art_sa_{}_report", result.agent_id);
|
||||
crate::artifacts::write_session_artifact(session_id, &artifact_id, &raw)
|
||||
.ok()
|
||||
.map(|_| artifact_id)
|
||||
}
|
||||
|
||||
fn summarize_subagent_result(result: &SubAgentResult) -> String {
|
||||
if let Some(needs_input) = result.needs_input.as_ref() {
|
||||
return format!("Needs input: {}", needs_input.question);
|
||||
|
||||
@@ -8725,7 +8725,7 @@ async fn write_todos_as(runtime: &SubAgentRuntime, contents: &[&str]) {
|
||||
.iter()
|
||||
.map(|content| json!({"content": content, "status": "pending"}))
|
||||
.collect();
|
||||
crate::tools::todo::TodoWriteTool::work_update(runtime.todos.clone())
|
||||
crate::tools::todo::TodoWriteTool::new(runtime.todos.clone())
|
||||
.execute(json!({"todos": items}), &runtime.context)
|
||||
.await
|
||||
.expect("work_update must succeed against the agent's own list");
|
||||
@@ -14582,7 +14582,7 @@ async fn child_work_state_publishes_only_real_changes_from_its_own_list() {
|
||||
let empty = source.snapshot().await;
|
||||
assert!(!work_state_worth_publishing(last.as_ref(), &empty));
|
||||
|
||||
crate::tools::todo::TodoWriteTool::work_update(child_todos.clone())
|
||||
crate::tools::todo::TodoWriteTool::new(child_todos.clone())
|
||||
.execute(
|
||||
serde_json::json!({"todos": [{"content": "CHILD: write the projection", "status": "in_progress"}]}),
|
||||
&context,
|
||||
@@ -14608,7 +14608,7 @@ async fn child_work_state_publishes_only_real_changes_from_its_own_list() {
|
||||
assert!(!work_state_worth_publishing(last.as_ref(), &again));
|
||||
|
||||
// A real transition, including back to empty, is published.
|
||||
crate::tools::todo::TodoWriteTool::work_update(child_todos.clone())
|
||||
crate::tools::todo::TodoWriteTool::new(child_todos.clone())
|
||||
.execute(serde_json::json!({"todos": []}), &context)
|
||||
.await
|
||||
.expect("child clears its list");
|
||||
|
||||
@@ -252,14 +252,15 @@ pub struct TodoWriteTool {
|
||||
|
||||
impl TodoWriteTool {
|
||||
/// Canonical model-facing progress surface (#4132).
|
||||
pub fn work_update(todo_list: SharedTodoList) -> Self {
|
||||
pub fn new(todo_list: SharedTodoList) -> Self {
|
||||
Self {
|
||||
name: CANONICAL_PROGRESS_TOOL,
|
||||
todo_list,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hidden compat alias for `work_update` — same handler, not model-visible.
|
||||
/// Hidden compat alias (`work_update`, `TodoWrite`, `todo`, …) — same
|
||||
/// handler, not model-visible.
|
||||
pub fn alias(name: &'static str, todo_list: SharedTodoList) -> Self {
|
||||
Self { name, todo_list }
|
||||
}
|
||||
@@ -424,7 +425,7 @@ mod tests {
|
||||
// 2026-07-23 user report: models wrote the list once and never
|
||||
// updated it while working. The canonical tool description must
|
||||
// carry the upkeep contract every provider sees.
|
||||
let tool = super::TodoWriteTool::work_update(super::new_shared_todo_list());
|
||||
let tool = super::TodoWriteTool::new(super::new_shared_todo_list());
|
||||
let description = crate::tools::spec::ToolSpec::description(&tool);
|
||||
for phrase in [
|
||||
"keep it live while you work",
|
||||
@@ -462,7 +463,7 @@ mod tests {
|
||||
serde_json::json!("cancelled")
|
||||
);
|
||||
|
||||
let schema = TodoWriteTool::work_update(new_shared_todo_list()).input_schema();
|
||||
let schema = TodoWriteTool::new(new_shared_todo_list()).input_schema();
|
||||
let statuses = &schema["properties"]["todos"]["items"]["properties"]["status"]["enum"];
|
||||
assert!(statuses.as_array().is_some_and(|values| {
|
||||
values
|
||||
@@ -551,7 +552,7 @@ mod tests {
|
||||
// #5123-class: statuses like "blocked" / "in-progress" used to be
|
||||
// recorded as pending with a success receipt on the canonical
|
||||
// progress surface.
|
||||
let tool = TodoWriteTool::work_update(new_shared_todo_list());
|
||||
let tool = TodoWriteTool::new(new_shared_todo_list());
|
||||
let context = ToolContext::new(std::env::temp_dir());
|
||||
let err = tool
|
||||
.execute(
|
||||
@@ -576,7 +577,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn work_update_returns_canonical_task_update_metadata() {
|
||||
let tool = TodoWriteTool::work_update(new_shared_todo_list());
|
||||
let tool = TodoWriteTool::new(new_shared_todo_list());
|
||||
let context = ToolContext::new(std::env::temp_dir());
|
||||
let result = tool
|
||||
.execute(
|
||||
@@ -622,7 +623,7 @@ mod tests {
|
||||
let mut context = ToolContext::new(std::env::temp_dir());
|
||||
context.runtime.work = Some(work.clone());
|
||||
|
||||
TodoWriteTool::work_update(todos.clone())
|
||||
TodoWriteTool::new(todos.clone())
|
||||
.execute(
|
||||
json!({"todos": [
|
||||
{"content": "Graph-owned", "status": "completed"},
|
||||
|
||||
@@ -16,7 +16,6 @@ const MAX_LIFETIME_IMPRESSIONS: u8 = 2;
|
||||
const MAX_TRACKED_MANUAL_COMMANDS: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[allow(dead_code)]
|
||||
pub enum BehavioralTip {
|
||||
PlanningMode,
|
||||
BackgroundJobReceipt,
|
||||
|
||||
@@ -838,7 +838,7 @@ pub(crate) fn build_dispatch_success_closure(
|
||||
PersistRequest::SaveCheckpoint { session },
|
||||
) {
|
||||
app.status_message = Some(format!(
|
||||
"Work update is pending: turn checkpoint could not be queued ({err})"
|
||||
"To-do list update pending: turn checkpoint could not be queued ({err})"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1200,6 +1200,15 @@ pub(crate) async fn run_event_loop(
|
||||
);
|
||||
}
|
||||
|
||||
// Every `remember` action mutates durable memory, so a
|
||||
// successful call is the moment the first-run tip
|
||||
// points at /memory (one-shot per session, lifetime-capped).
|
||||
if name == "remember" && matches!(&result, Ok(output) if output.success) {
|
||||
let _ = app.maybe_show_behavioral_tip(
|
||||
crate::tui::behavioral_tips::BehavioralTip::DurableStateWritten,
|
||||
);
|
||||
}
|
||||
|
||||
if result.is_ok()
|
||||
&& is_work_graph_mutation_tool(&name)
|
||||
&& let Err(err) = persist_pending_work_checkpoint(app).await
|
||||
@@ -1210,7 +1219,7 @@ pub(crate) async fn run_event_loop(
|
||||
"Work Graph checkpoint was not enqueued; projections remain unpublished"
|
||||
);
|
||||
app.status_message = Some(format!(
|
||||
"Work update is pending: checkpoint could not be queued ({err})"
|
||||
"To-do list update pending: checkpoint could not be queued ({err})"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1701,7 +1710,7 @@ pub(crate) async fn run_event_loop(
|
||||
.is_some_and(|work| work.has_pending_publish())
|
||||
{
|
||||
app.status_message = Some(
|
||||
"Work update is pending: session snapshot could not be queued"
|
||||
"To-do list update pending: session snapshot could not be queued"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@@ -1880,7 +1889,7 @@ pub(crate) async fn run_event_loop(
|
||||
PersistRequest::SaveCheckpoint { session },
|
||||
) {
|
||||
app.status_message = Some(format!(
|
||||
"Work update is pending: checkpoint could not be queued ({err})"
|
||||
"To-do list update pending: checkpoint could not be queued ({err})"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ pub(crate) fn persist_recovery_snapshot(app: &mut App) {
|
||||
persist_with_pending_work_boundary(app, PersistRequest::SaveCheckpoint { session })
|
||||
{
|
||||
app.status_message = Some(format!(
|
||||
"Work update is pending: recovery snapshot could not be queued ({err})"
|
||||
"To-do list update pending: recovery snapshot could not be queued ({err})"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -219,7 +219,7 @@ pub(crate) fn persist_full_reset_snapshot(app: &mut App) {
|
||||
persist_with_pending_work_boundary(app, PersistRequest::SessionSnapshot(session))
|
||||
{
|
||||
app.status_message = Some(format!(
|
||||
"Work update is pending: reset snapshot could not be queued ({err})"
|
||||
"To-do list update pending: reset snapshot could not be queued ({err})"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1596,10 +1596,9 @@ fn agent_elapsed_ms(app: &App, agent: &crate::tools::subagent::SubAgentResult) -
|
||||
if matches!(
|
||||
agent.status,
|
||||
crate::tools::subagent::SubAgentStatus::Running
|
||||
) {
|
||||
if let Some(started_at) = agent.started_at {
|
||||
return u64::try_from(started_at.elapsed().as_millis()).unwrap_or(agent.duration_ms);
|
||||
}
|
||||
) && let Some(started_at) = agent.started_at
|
||||
{
|
||||
return u64::try_from(started_at.elapsed().as_millis()).unwrap_or(agent.duration_ms);
|
||||
}
|
||||
app.work_surface
|
||||
.frozen_agent_elapsed_ms
|
||||
|
||||
@@ -585,13 +585,13 @@ mod tests {
|
||||
assert!(source.is_graph_backed());
|
||||
assert!(source.tail_message().await.is_none(), "no work yet");
|
||||
|
||||
crate::tools::todo::TodoWriteTool::work_update(todos.clone())
|
||||
crate::tools::todo::TodoWriteTool::new(todos.clone())
|
||||
.execute(
|
||||
serde_json::json!({"todos": [{"content": "staged item", "status": "in_progress"}]}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect("work_update");
|
||||
.expect("todo_write");
|
||||
|
||||
assert!(
|
||||
todos.lock().await.snapshot().is_empty(),
|
||||
|
||||
@@ -60,7 +60,10 @@ async fn headless_bash_success_and_failure_are_distinct_bounded_exact_evidence()
|
||||
"model-facing truncation must name the recovery path"
|
||||
);
|
||||
assert!(
|
||||
receipt.contains("/artifacts/"),
|
||||
// The footer prints the artifact directory with the platform's
|
||||
// path separator; compare on a normalized view so Windows
|
||||
// backslashes don't fail an otherwise-correct footer.
|
||||
receipt.replace('\\', "/").contains("/artifacts/"),
|
||||
"the footer names where the omitted bytes live on disk"
|
||||
);
|
||||
// The receipt must name a route the model can take *from this
|
||||
|
||||
@@ -42,7 +42,7 @@ Make the model-facing runtime smaller, calmer, and easier for models to use by:
|
||||
| `rlm` | durable RLM family (existing; deferred by default) |
|
||||
| `agent` | sub-agent dispatch |
|
||||
| `remember` | opt-in durable user-memory capture; eager whenever registered |
|
||||
| `work_update` | progress / plan-of-work updates |
|
||||
| `todo_write` | progress / plan-of-work updates |
|
||||
| `update_plan` | plan artifact updates |
|
||||
| `tool_search` | on-demand discovery of deferred tools |
|
||||
|
||||
@@ -113,7 +113,7 @@ including opt-in `remember`:
|
||||
| Full system-prompt bytes | 15,842 | 15,368 |
|
||||
|
||||
The final active names are `Bash`, `File`, `Git`, `Run`, `agent`, `remember`,
|
||||
`tasks`, `update_plan`, `work_update`, and `tool_search`. `remember` is present
|
||||
`tasks`, `update_plan`, `todo_write`, and `tool_search`. `remember` is present
|
||||
only when built-in memory is enabled; it is eager whenever registered. `File`
|
||||
advertises only read actions in Plan mode, and its `patch` action appears only
|
||||
when the existing apply-patch feature is enabled. Hidden aliases remain
|
||||
|
||||
+5
-5
@@ -94,16 +94,16 @@ task depends on decisions, files, todos, or plan state already in the parent
|
||||
transcript.
|
||||
|
||||
Forked state renders concrete Work progress from the To-do ledger — the sole
|
||||
canonical Work surface, written by `work_update`. The child's
|
||||
canonical Work surface, written by `todo_write`. The child's
|
||||
`<codewhale:fork_state>` block carries the same bounded body
|
||||
(`crates/tui/src/work_grounding.rs`) that the parent's own requests carry, so a
|
||||
fork continues from the parent's real progress position rather than a
|
||||
paraphrase. That Work section is resolved when the spawn happens, so a
|
||||
`work_update` earlier in the same parent turn is included.
|
||||
`todo_write` earlier in the same parent turn is included.
|
||||
|
||||
Each agent then grounds on **its own** ledger: every sub-agent request carries
|
||||
the same transient `<codewhale:work_state>` tail rendered from that agent's
|
||||
private To-do list (#4810), refreshed after the agent's own `work_update`. It is
|
||||
private To-do list (#4810), refreshed after the agent's own `todo_write`. It is
|
||||
request-scoped — never stored in the child transcript or its system prefix — so
|
||||
a worker can never read or write a parent's or sibling's **private transient
|
||||
tail**. A deliberately forked child still receives the bounded immutable parent
|
||||
@@ -129,7 +129,7 @@ longer reachable by a model: `model_visible()` returns `false`
|
||||
(`crates/tui/src/tools/plan.rs:408-413`), so it is filtered out of the API tool
|
||||
list and never appears to a child. It survives only to replay older transcripts.
|
||||
Strategy that used to go there now goes in the response body, and lifecycle
|
||||
state goes in `work_update`.
|
||||
state goes in `todo_write`.
|
||||
|
||||
## Worktree isolation
|
||||
|
||||
@@ -223,7 +223,7 @@ OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT.
|
||||
likely scope, and return `path:line-range` evidence instead of a narrative
|
||||
tour. The role name to use is `scout`.
|
||||
- **`planner`** — when the parent has an objective but no executable
|
||||
decomposition. Planners write artifacts (`work_update` items for the ledger,
|
||||
decomposition. Planners write artifacts (`todo_write` items for the ledger,
|
||||
strategy in the response body) but don't carry them out.
|
||||
- **`reviewer`** — when there's already a change and the parent wants
|
||||
it graded. Reviewers don't patch — they describe the fix in the
|
||||
|
||||
+24
-26
@@ -46,8 +46,9 @@ tools** that map to the *same* implementation under different names:
|
||||
(`registry.rs:527,530`).
|
||||
- `tts` and `speech` are both `SpeechTool`
|
||||
(`registry.rs:787-792`, both deferred).
|
||||
- `work_update`, `checklist_*`, and `todo_*` are the *same*
|
||||
`TodoWriteTool` surface, with only `work_update` visible to models.
|
||||
- `todo_write` is the single model-visible `TodoWriteTool` surface;
|
||||
`todo_write`, `TodoWrite`, `todo`, `checklist_write`, and
|
||||
`checklist_update` are hidden compat aliases of it.
|
||||
|
||||
For a strong model, redundant names are harmless noise. For **weaker / smaller
|
||||
models** (the Arcee Trinity lane, `deepseek-v4-flash` child executors, and any
|
||||
@@ -67,7 +68,7 @@ referenced a now-retired name.
|
||||
|
||||
### Canonical work-tracking surface for v0.9.1
|
||||
|
||||
The model-visible progress surface is a single tool: `work_update` (#4132).
|
||||
The model-visible progress surface is a single tool: `todo_write` (#4132).
|
||||
Agents and Fleet workers use it for concrete To-do / Work progress under the
|
||||
active runtime thread or durable task.
|
||||
|
||||
@@ -86,21 +87,21 @@ the To-do snapshot once, hard-bounded in both item count and characters, with
|
||||
the in-progress item preserved preferentially and any elision marked. That body
|
||||
is appended to each parent turn-loop and sub-agent step request as a transient
|
||||
`<codewhale:work_state>` block — rebuilt per request, so a mid-turn
|
||||
`work_update` is visible on the next step — and is never written to session
|
||||
`todo_write` is visible on the next step — and is never written to session
|
||||
history or the stable system prefix.
|
||||
Forked agents (`<codewhale:fork_state>`) and `/relay` reuse the same body.
|
||||
|
||||
Three properties of that seam are load-bearing:
|
||||
|
||||
- **Authority.** The snapshot is read from the `WorkRuntime` graph projection
|
||||
when a runtime owns that list, because `work_update` stages there and only
|
||||
when a runtime owns that list, because `todo_write` stages there and only
|
||||
publishes into the legacy `SharedTodoList` view later. Sessions with no
|
||||
attached runtime read the list directly.
|
||||
- **Per-agent isolation.** Every sub-agent gets the same tail rendered from
|
||||
*its own* list (`#4810`), so a worker sees its own progress and never a
|
||||
parent's or sibling's. The parent's ledger reaches a forked child only as the
|
||||
immutable `<codewhale:fork_state>` Work section, resolved at the spawn seam so
|
||||
a same-turn `work_update` is included.
|
||||
a same-turn `todo_write` is included.
|
||||
- **Context accounting.** The parent turn-loop preflight token estimate runs
|
||||
over the tail message that request actually carries, so it cannot approve a
|
||||
request that goes over-limit once the block is appended. Offline counts stay
|
||||
@@ -169,14 +170,11 @@ pub(super) const HIDDEN_COMPATIBILITY_TOOLS: &[&str] = &[
|
||||
"exec_wait", // == exec_shell_wait (ShellWaitTool)
|
||||
"exec_interact", // == exec_shell_interact (ShellInteractTool)
|
||||
"tts", // == speech (SpeechTool)
|
||||
"checklist_write", // == work_update (TodoWriteTool)
|
||||
"checklist_add", // == work_update single-item add
|
||||
"checklist_update", // == work_update single-item update
|
||||
"checklist_list", // == work_update list
|
||||
"todo_write", // == work_update
|
||||
"todo_add", // == work_update single-item add
|
||||
"todo_update", // == work_update single-item update
|
||||
"todo_list", // == work_update list
|
||||
"work_update", // == todo_write (TodoWriteTool)
|
||||
"TodoWrite", // == todo_write (TodoWriteTool)
|
||||
"todo", // == todo_write (TodoWriteTool)
|
||||
"checklist_write", // == todo_write (TodoWriteTool)
|
||||
"checklist_update", // == todo_write (TodoWriteTool)
|
||||
];
|
||||
|
||||
/// Deprecated aliases: invisible + dispatchable, with a replacement notice
|
||||
@@ -188,8 +186,8 @@ pub(super) struct DeprecatedAlias {
|
||||
}
|
||||
|
||||
pub(super) const DEPRECATED_ALIASES: &[DeprecatedAlias] = &[
|
||||
// Empty in the #4132 work-surface cutover: checklist_* and todo_* are
|
||||
// silent hidden-compatibility aliases of work_update for transcript replay.
|
||||
// Empty in the #4132 work-surface cutover: the legacy names above are
|
||||
// silent hidden-compatibility aliases of todo_write for transcript replay.
|
||||
];
|
||||
|
||||
#[inline]
|
||||
@@ -254,17 +252,17 @@ This was the proposed manifest. Columns are the #2681 AC columns. No entry was
|
||||
| `exec_wait` | `exec_shell_wait` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
|
||||
| `exec_interact` | `exec_shell_interact` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
|
||||
| `tts` | `speech` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
|
||||
| `checklist_write` | `work_update` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
|
||||
| `checklist_add` | `work_update` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
|
||||
| `checklist_update` | `work_update` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
|
||||
| `checklist_list` | `work_update` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
|
||||
| `todo_write` | `work_update` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
|
||||
| `todo_add` | `work_update` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
|
||||
| `todo_update` | `work_update` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
|
||||
| `todo_list` | `work_update` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
|
||||
| `checklist_write` | `todo_write` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
|
||||
| `checklist_add` | `todo_write` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
|
||||
| `checklist_update` | `todo_write` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
|
||||
| `checklist_list` | `todo_write` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
|
||||
| `todo_write` | `todo_write` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
|
||||
| `todo_add` | `todo_write` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
|
||||
| `todo_update` | `todo_write` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
|
||||
| `todo_list` | `todo_write` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
|
||||
|
||||
The `todo_*` aliases first entered hidden compatibility in v0.8.53. v0.9.0
|
||||
changes their canonical replacement to `work_update`; it does not reset their
|
||||
changes their canonical replacement to `todo_write`; it does not reset their
|
||||
first-deprecated version.
|
||||
|
||||
**Legacy subagent names — removed, no manifest entry needed.**
|
||||
@@ -333,7 +331,7 @@ else or an explicit budget bump in this doc.
|
||||
|---|---|---|---|
|
||||
| **Shell wait** | `exec_shell_wait` | `exec_wait` → hidden-compat | Same `ShellWaitTool` (`registry.rs:526,529`); router already unifies (`tool_routing.rs:1139`) |
|
||||
| **Shell interact** | `exec_shell_interact` | `exec_interact` → hidden-compat | Same `ShellInteractTool` (`registry.rs:527,530`) |
|
||||
| **Work progress / checklist / todo** | `work_update` | `checklist_write/add/update/list`, `todo_write/add/update/list` → hidden-compat | Same `TodoWriteTool`; compatibility names replay old transcripts only |
|
||||
| **Work progress / checklist / todo** | `todo_write` | `checklist_write/add/update/list`, `todo_write/add/update/list` → hidden-compat | Same `TodoWriteTool`; compatibility names replay old transcripts only |
|
||||
| **Speech / tts** | `speech` | `tts` → hidden-compat | Same `SpeechTool` (`registry.rs:787-792`) |
|
||||
| **Subagent lifecycle** | `agent` | old lifecycle names and tool-agent lane removed | Single async launcher. (The "child agents are leaf workers" note here did not ship — see §7.) |
|
||||
| **Edit family** | `apply_patch`, `edit_file`, `write_file`, `fim_edit` | none — **all distinct niches** | NOT touched (per #2681 non-goals); doc-only canonical guidance |
|
||||
|
||||
@@ -26,7 +26,7 @@ The default-active policy contains exactly these nine names:
|
||||
5. `agent`
|
||||
6. `remember`
|
||||
7. `tasks`
|
||||
8. `work_update`
|
||||
8. `todo_write`
|
||||
9. `tool_search`
|
||||
|
||||
The first eight are `DEFAULT_ACTIVE_NATIVE_TOOLS` in
|
||||
@@ -47,7 +47,7 @@ compatibility tool for loading older Plan artifacts", and
|
||||
`update_plan_is_hidden_replay_compatibility` (`plan.rs:598-605`) pins that.
|
||||
|
||||
Plan mode narrows the active set: `Bash` and `Run` drop out, leaving `File`,
|
||||
`Git`, `agent`, `tasks`, `work_update`, `tool_search`, and — when memory is
|
||||
`Git`, `agent`, `tasks`, `todo_write`, `tool_search`, and — when memory is
|
||||
enabled — `remember` (`should_register_remember_tool`,
|
||||
`crates/tui/src/core/engine/tool_setup.rs:113-118`).
|
||||
|
||||
@@ -82,11 +82,11 @@ by the former spellings remain in force.
|
||||
| `agent` | Dispatch one focused sub-agent run and return an id, compact receipt, and transcript handle. |
|
||||
| `remember` | Append one terse durable preference or convention when the user has enabled built-in memory. |
|
||||
| `tasks` | Create, list, read, cancel, gate, and inspect durable task work through one action family. |
|
||||
| `update_plan` | Registered but not model-visible; replays older Plan artifacts only. New work uses `work_update` plus a normal Plan-mode response. |
|
||||
| `work_update` | Replace the concrete To-do / Work progress projection for the active thread or durable task. |
|
||||
| `update_plan` | Registered but not model-visible; replays older Plan artifacts only. New work uses `todo_write` plus a normal Plan-mode response. |
|
||||
| `todo_write` | Replace the concrete To-do / Work progress projection for the active thread or durable task. |
|
||||
| `tool_search` | Discover and load a deferred tool only when the current turn needs it. |
|
||||
|
||||
`work_update` writes the **sole canonical Work ledger**. `update_plan` is
|
||||
`todo_write` writes the **sole canonical Work ledger**. `update_plan` is
|
||||
conversational reasoning — strategy, constraints, and route notes that help a
|
||||
reader understand the approach. It is not a second Work surface, and plan-only
|
||||
state never becomes model-facing Work grounding.
|
||||
@@ -197,7 +197,7 @@ compatibility for them. Tests pin the removals:
|
||||
| `github_issue_context`, `github_pr_context`, `github_comment`, `github_close_issue`, `github_close_pr` | `github` | same test |
|
||||
| `automation_create/list/read/update/pause/resume/delete/run` | `automation` | same test |
|
||||
| `rlm_session_objects`, `rlm_open`, `rlm_eval`, `rlm_configure`, `rlm_close` | `rlm` | `rlm_is_the_only_registered_session_surface`, registry.rs:1519-1538 |
|
||||
| `checklist_write/add/update/list`, `todo_write/add/update/list` | `work_update` | registry.rs:1476-1490 |
|
||||
| `todo_add/update/list`, `checklist_add/list` (removed); `work_update`, `TodoWrite`, `todo`, `checklist_write/update` (registered hidden aliases) | `todo_write` | registry alias assertions (`registry.rs`) |
|
||||
|
||||
This matches the "Removed spellings" section above rather than contradicting
|
||||
it. Replay compatibility does not make an alias a supported spelling for new
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
"agent",
|
||||
"remember",
|
||||
"tasks",
|
||||
"work_update",
|
||||
"todo_write",
|
||||
"tool_search"
|
||||
],
|
||||
"actions": {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"_comment": "Ceiling for `#[allow(dead_code)]` across crates/. This number may go down freely; raising it needs a reviewer to say why in the PR. Regenerate with: python3 scripts/check-dead-code-budget.py --update",
|
||||
"_issue": "https://github.com/Hmbown/CodeWhale/issues/4785",
|
||||
"total": 451,
|
||||
"total": 452,
|
||||
"per_crate": {
|
||||
"config": 1,
|
||||
"tools": 2,
|
||||
"tui": 448
|
||||
"tui": 449
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user