feat(tool): a streaming resolver reports its own status

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.
This commit is contained in:
vhaudiquet committed 2026-10-24 03:58:14 +02:00
1 parent 7f6c13f608
commit db75f7c4b8
2 files changed
+7 -5

No files matched your search

+4 -4
View File
@@ -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");
+3 -1
View File
@@ -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);