entelix_core/tools/progress.rs
1//! Tool-phase reporting primitives.
2//!
3//! Long-running tools surface in-flight status to operators between
4//! the `ToolStart` and `ToolComplete` events by calling
5//! [`crate::AgentContext::record_phase`] /
6//! [`crate::ExecutionContext::record_phase`] at meaningful work
7//! boundaries — schema lookup, vector search, validation, retry
8//! arm — and the runtime fans the transition into the
9//! [`ToolProgressSink`] attached on the request scope.
10//!
11//! Two markers ride [`crate::ExecutionContext`] extensions to make the
12//! emit path zero-allocation in the absent-sink case:
13//!
14//! - [`ToolProgressSinkHandle`] — operator wires this once at the
15//! request boundary; tools never construct it.
16//! - [`CurrentToolInvocation`] — the layer that brackets tool
17//! dispatch (`ToolEventLayer` in `entelix-agents`) attaches this on
18//! entry so phase emissions correlate to a stable
19//! `(tool_use_id, tool_name, started_at)` triple without the tool
20//! author plumbing identity by hand.
21//!
22//! When either marker is missing the emit is a silent no-op — the
23//! `tracing::info!` discipline (no subscriber → no work). This keeps
24//! library tools cost-free to call regardless of whether the embedder
25//! cares about phase telemetry.
26
27use std::sync::Arc;
28use std::time::Instant;
29
30use async_trait::async_trait;
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34use crate::error::Result;
35use crate::identity::validate_request_identifier;
36
37// `Result` is used only at the construction surface (`CurrentToolInvocation::new`);
38// the sink emit surface is fire-and-forget by contract (invariant 18).
39
40/// Status transition of one named phase inside a tool dispatch.
41///
42/// Phases form a state machine per `(tool_use_id, phase)` pair —
43/// `Started` opens the phase, optional `Running` updates flow while
44/// the phase is in progress, and exactly one terminal `Completed` or
45/// `Failed` closes it. Sinks may flatten or drop intermediate
46/// `Running` updates; the contract is per-tool-author.
47#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49#[non_exhaustive]
50pub enum ToolProgressStatus {
51 /// The phase has begun. The first transition emitted for a phase.
52 Started,
53 /// The phase is still running. Intermediate progress (percent
54 /// complete, item count, partial result count) flows here.
55 Running,
56 /// The phase finished successfully. Terminal transition.
57 Completed,
58 /// The phase finished with a failure. Terminal transition. The
59 /// tool may still return its own error through `Tool::execute`.
60 Failed,
61}
62
63/// One phase transition emitted by a tool.
64///
65/// Sinks reconstruct per-phase wall-clock duration from successive
66/// transitions on the same `(tool_use_id, phase)`. The SDK does not
67/// hold phase-state on behalf of the tool — the `dispatch_elapsed_ms`
68/// marker is a timeline reference, not a per-phase length.
69#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
70#[non_exhaustive]
71pub struct ToolProgress {
72 /// Per-run correlation id. Echoes
73 /// [`crate::ExecutionContext::run_id`] so sinks can join phase
74 /// telemetry against `AgentEvent` streams. Empty string when the
75 /// dispatch did not originate inside an agent run.
76 pub run_id: String,
77 /// Stable tool-use id matching the originating model `ToolUse`
78 /// block. Pairs with `AgentEvent::ToolStart` / `ToolComplete` /
79 /// `ToolError` for the same dispatch.
80 pub tool_use_id: String,
81 /// Tool name being dispatched.
82 pub tool_name: String,
83 /// Phase identifier — `schema_lookup`, `vector_search`,
84 /// `validation`, `correction_retry`, ... The tool author picks the
85 /// vocabulary; sinks treat it as an opaque key.
86 pub phase: String,
87 /// Status transition for the phase.
88 pub status: ToolProgressStatus,
89 /// Wall-clock elapsed since the tool dispatch began. Acts as a
90 /// timeline marker across every phase in the same dispatch —
91 /// per-phase duration is the difference between successive
92 /// transitions on the same `(tool_use_id, phase)` pair.
93 pub dispatch_elapsed_ms: u64,
94 /// Optional structured metadata for UIs and telemetry sinks.
95 /// `Value::Null` when the tool author chose not to attach
96 /// anything.
97 pub metadata: Value,
98}
99
100/// Marker attached to [`crate::ExecutionContext`] extensions while a
101/// tool dispatch is in flight. The layer that brackets dispatch
102/// (`ToolEventLayer` in `entelix-agents`) is the only producer; tools
103/// read it indirectly through the `record_phase` helpers and never
104/// construct it themselves.
105#[derive(Clone, Debug)]
106pub struct CurrentToolInvocation {
107 tool_use_id: String,
108 tool_name: String,
109 started_at: Instant,
110}
111
112impl CurrentToolInvocation {
113 /// Build a marker for one dispatch. Validates that `tool_use_id`
114 /// and `tool_name` are well-formed request identifiers (no
115 /// control characters, no whitespace-only strings) so a malformed
116 /// dispatch never poisons the phase channel.
117 pub fn new(tool_use_id: impl Into<String>, tool_name: impl Into<String>) -> Result<Self> {
118 let tool_use_id =
119 validated_identifier("CurrentToolInvocation::new", "tool_use_id", tool_use_id)?;
120 let tool_name = validated_identifier("CurrentToolInvocation::new", "tool_name", tool_name)?;
121 Ok(Self {
122 tool_use_id,
123 tool_name,
124 started_at: Instant::now(),
125 })
126 }
127
128 /// Stable tool-use id for this dispatch.
129 #[must_use]
130 pub fn tool_use_id(&self) -> &str {
131 &self.tool_use_id
132 }
133
134 /// Tool name for this dispatch.
135 #[must_use]
136 pub fn tool_name(&self) -> &str {
137 &self.tool_name
138 }
139
140 /// Wall-clock elapsed since dispatch start, saturated to
141 /// `u64::MAX` milliseconds. Sinks use this as a per-dispatch
142 /// timeline reference.
143 #[must_use]
144 pub fn dispatch_elapsed_ms(&self) -> u64 {
145 u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX)
146 }
147}
148
149fn validated_identifier(surface: &str, field: &str, value: impl Into<String>) -> Result<String> {
150 let value = value.into();
151 validate_request_identifier(&format!("{surface}: {field}"), &value)?;
152 Ok(value)
153}
154
155/// Consumer of tool-phase transitions.
156///
157/// **Fire-and-forget by contract** — emit failures stay inside the
158/// sink and never propagate back to the tool. Operators with fallible
159/// IO (network channel, OTLP exporter, file logger) swallow internally
160/// and trace, mirroring [`crate::AuditSink`]'s invariant-18 contract.
161/// Hand-channel callers (`record_phase` / `record_phase_with`) never
162/// see a sink result, so a misbehaving telemetry sink cannot fail the
163/// dispatch path it observes (invariant 4 + 18 alignment).
164///
165/// Distinct from [`crate::AuditSink`] (lifecycle audit trail) and
166/// [`crate::events::EventBus`] (token-stream deltas) — phases sit at
167/// the granularity of *named steps inside one tool's work*.
168///
169/// [`record_phase`]: crate::ExecutionContext::record_phase
170#[async_trait]
171pub trait ToolProgressSink: Send + Sync + 'static {
172 /// Record one phase transition. Implementations with fallible IO
173 /// swallow errors internally (typically via `tracing::warn!`) —
174 /// the hand-channel emit site receives no signal of failure.
175 async fn record_progress(&self, progress: ToolProgress);
176}
177
178/// Refcounted handle to a [`ToolProgressSink`]. Operators attach one
179/// per request via [`crate::ExecutionContext::with_tool_progress_sink`]
180/// and downstream layers / tools resolve it through the typed
181/// extension lookup.
182#[derive(Clone)]
183pub struct ToolProgressSinkHandle {
184 sink: Arc<dyn ToolProgressSink>,
185}
186
187impl ToolProgressSinkHandle {
188 /// Wrap a concrete sink.
189 #[must_use]
190 pub fn new<S>(sink: S) -> Self
191 where
192 S: ToolProgressSink,
193 {
194 Self {
195 sink: Arc::new(sink),
196 }
197 }
198
199 /// Wrap an existing trait-object sink.
200 #[must_use]
201 pub fn from_arc(sink: Arc<dyn ToolProgressSink>) -> Self {
202 Self { sink }
203 }
204
205 /// Borrow the underlying sink for direct dispatch — used by the
206 /// `record_phase` emit path on [`crate::ExecutionContext`].
207 #[must_use]
208 pub fn inner(&self) -> &Arc<dyn ToolProgressSink> {
209 &self.sink
210 }
211}
212
213impl std::fmt::Debug for ToolProgressSinkHandle {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 f.debug_struct("ToolProgressSinkHandle")
216 .finish_non_exhaustive()
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn current_tool_invocation_rejects_invalid_identity() {
226 let Err(err) = CurrentToolInvocation::new(" ", "echo") else {
227 panic!("expected invalid tool_use_id to fail");
228 };
229 assert!(format!("{err}").contains("tool_use_id must not be empty"));
230
231 let Err(err) = CurrentToolInvocation::new("tu-1", "echo\nnext") else {
232 panic!("expected invalid tool_name to fail");
233 };
234 assert!(format!("{err}").contains("tool_name must not contain control characters"));
235 }
236
237 #[test]
238 fn current_tool_invocation_accepts_valid_identity() -> Result<()> {
239 let current = CurrentToolInvocation::new("tu-1", "echo")?;
240 assert_eq!(current.tool_use_id(), "tu-1");
241 assert_eq!(current.tool_name(), "echo");
242 Ok(())
243 }
244}