From db75f7c4b86dc645acb4f31bb1a03b186796772e Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 24 Oct 2026 03:58:14 +0200 Subject: [PATCH] feat(tool): a streaming resolver reports its own status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming progress seam grows from a bare output callback to ToolProgress: output chunks as before, plus an optional ToolStatus — the phase a tool's inner work is in and, when the tool runs model calls of its own, their cumulative spend and the model they ran on. The engine stamps the call's id on it and the sink receives it as an ephemeral ToolStatus event, replacing any earlier one for the call; status is screen-facing, never history. The subagent tool is the first emitter: its inner turn's lifecycle becomes phase transitions (thinking, running, writing output) and a running token total. Every sink decides what a status is worth: the cloud wire forwards it, ACP and the claw have no slot and drop it. --- crates/rhema-claw/src/accounts_tools.rs | 8 ++++---- crates/rhema-claw/src/interactive.rs | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/rhema-claw/src/accounts_tools.rs b/crates/rhema-claw/src/accounts_tools.rs index d64b108..67f4146 100644 --- a/crates/rhema-claw/src/accounts_tools.rs +++ b/crates/rhema-claw/src/accounts_tools.rs @@ -374,7 +374,7 @@ impl IdentityExec { async fn exec( &self, call: ToolCall, - progress: &mut (dyn for<'a> FnMut(&'a str) + Send), + progress: &mut dyn rhema_core::ToolProgress, ) -> ToolResult { let id = match call.args.get("id").and_then(Value::as_str) { Some(id) => id.trim().to_owned(), @@ -477,7 +477,7 @@ impl IdentityExec { let sink = if is_stderr { &mut stderr } else { &mut stdout }; sink.extend_from_slice(line.as_bytes()); sink.push(b'\n'); - progress(&format!("{line}\n")); + progress.output(&format!("{line}\n")); } None => break, } @@ -565,14 +565,14 @@ impl ToolResolver for IdentityExec { } async fn resolve(&self, call: ToolCall, _ctx: &ToolContext) -> ToolResult { - self.exec(call, &mut |_| {}).await + self.exec(call, &mut rhema_core::OutputOnly(|_| {})).await } async fn resolve_streaming( &self, call: ToolCall, ctx: &ToolContext, - progress: &mut (dyn for<'a> FnMut(&'a str) + Send), + progress: &mut dyn rhema_core::ToolProgress, ) -> ToolResult { if ctx.cancelled.load(std::sync::atomic::Ordering::Relaxed) { return ToolResult::failure(call.id, "cancelled"); diff --git a/crates/rhema-claw/src/interactive.rs b/crates/rhema-claw/src/interactive.rs index 8be18b3..f4d12e4 100644 --- a/crates/rhema-claw/src/interactive.rs +++ b/crates/rhema-claw/src/interactive.rs @@ -566,9 +566,11 @@ impl EventSink for HubSink { } } // Step bookkeeping is the engine's business, not the wire's; - // a running tool's live output lands with its result. + // a running tool's live output lands with its result, and a + // streaming tool's status has no turn-event shape to ride. ModelEvent::StepStart { .. } | ModelEvent::ToolOutputDelta { .. } + | ModelEvent::ToolStatus { .. } | ModelEvent::StepEnd { .. } => return, }; self.hub.emit(&self.key, turn);