1use std::collections::VecDeque;
22use std::fmt;
23use std::time::Instant;
24
25use tracing::info;
26
27use crate::operations::agent::AgentResult;
28use crate::operations::http::HttpOutput;
29use crate::operations::shell::ShellOutput;
30use crate::pricing::CostBreakdown;
31
32const DEFAULT_MAX_STEPS: usize = 10_000;
35
36pub struct WorkflowTracker {
43 name: String,
44 start: Instant,
45 steps: VecDeque<StepRecord>,
46 max_steps: usize,
47}
48
49struct StepRecord {
50 name: String,
51 kind: StepKind,
52 duration_ms: u64,
53 cost_usd: Option<f64>,
54 input_tokens: Option<u64>,
55 output_tokens: Option<u64>,
56 cost_breakdown: Option<CostBreakdown>,
57}
58
59enum StepKind {
60 Shell,
61 Http,
62 Agent,
63}
64
65impl fmt::Display for StepKind {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 match self {
68 Self::Shell => f.write_str("shell"),
69 Self::Http => f.write_str("http"),
70 Self::Agent => f.write_str("agent"),
71 }
72 }
73}
74
75impl WorkflowTracker {
76 #[must_use = "a tracker does nothing if not used to record steps"]
80 pub fn new(name: &str) -> Self {
81 Self {
82 name: name.to_string(),
83 start: Instant::now(),
84 steps: VecDeque::new(),
85 max_steps: DEFAULT_MAX_STEPS,
86 }
87 }
88
89 pub fn max_steps(mut self, limit: usize) -> Self {
93 self.max_steps = limit;
94 self
95 }
96
97 fn push_step(&mut self, record: StepRecord) {
98 if self.steps.len() >= self.max_steps {
99 self.steps.pop_front();
100 }
101 self.steps.push_back(record);
102 }
103
104 pub fn record_shell(&mut self, name: &str, output: &ShellOutput) {
109 self.push_step(StepRecord {
110 name: name.to_string(),
111 kind: StepKind::Shell,
112 duration_ms: output.duration_ms(),
113 cost_usd: None,
114 input_tokens: None,
115 output_tokens: None,
116 cost_breakdown: None,
117 });
118 }
119
120 pub fn record_http(&mut self, name: &str, output: &HttpOutput) {
125 self.push_step(StepRecord {
126 name: name.to_string(),
127 kind: StepKind::Http,
128 duration_ms: output.duration_ms(),
129 cost_usd: None,
130 input_tokens: None,
131 output_tokens: None,
132 cost_breakdown: None,
133 });
134 }
135
136 pub fn record_agent(&mut self, name: &str, result: &AgentResult) {
140 self.push_step(StepRecord {
141 name: name.to_string(),
142 kind: StepKind::Agent,
143 duration_ms: result.duration_ms(),
144 cost_usd: result.cost_usd(),
145 input_tokens: result.input_tokens(),
146 output_tokens: result.output_tokens(),
147 cost_breakdown: None,
148 });
149 }
150
151 pub fn record_agent_with_breakdown(
178 &mut self,
179 name: &str,
180 result: &AgentResult,
181 breakdown: CostBreakdown,
182 ) {
183 self.push_step(StepRecord {
184 name: name.to_string(),
185 kind: StepKind::Agent,
186 duration_ms: result.duration_ms(),
187 cost_usd: result.cost_usd(),
188 input_tokens: result.input_tokens(),
189 output_tokens: result.output_tokens(),
190 cost_breakdown: Some(breakdown),
191 });
192 }
193
194 pub fn total_cost_usd(&self) -> f64 {
198 self.steps.iter().filter_map(|s| s.cost_usd).sum()
199 }
200
201 pub fn total_input_tokens(&self) -> u64 {
203 self.steps.iter().filter_map(|s| s.input_tokens).sum()
204 }
205
206 pub fn total_output_tokens(&self) -> u64 {
208 self.steps.iter().filter_map(|s| s.output_tokens).sum()
209 }
210
211 pub fn total_duration_ms(&self) -> u64 {
213 self.start.elapsed().as_millis() as u64
214 }
215
216 pub fn step_count(&self) -> usize {
218 self.steps.len()
219 }
220
221 pub fn summary(&self) {
226 let total_cost = self.total_cost_usd();
227 let total_input = self.total_input_tokens();
228 let total_output = self.total_output_tokens();
229 let total_duration = self.total_duration_ms();
230 let steps = self.step_count();
231
232 info!(
233 workflow = %self.name,
234 steps,
235 total_cost_usd = total_cost,
236 total_input_tokens = total_input,
237 total_output_tokens = total_output,
238 total_duration_ms = total_duration,
239 "workflow completed"
240 );
241
242 for step in &self.steps {
243 if let Some(ref bd) = step.cost_breakdown {
244 info!(
245 workflow = %self.name,
246 step = %step.name,
247 kind = %step.kind,
248 duration_ms = step.duration_ms,
249 cost_usd = step.cost_usd,
250 prompt_usd = bd.prompt_usd,
251 cache_read_usd = bd.cache_read_usd,
252 cache_write_usd = bd.cache_write_usd,
253 completion_usd = bd.completion_usd,
254 input_tokens = step.input_tokens,
255 output_tokens = step.output_tokens,
256 "step detail"
257 );
258 } else {
259 info!(
260 workflow = %self.name,
261 step = %step.name,
262 kind = %step.kind,
263 duration_ms = step.duration_ms,
264 cost_usd = step.cost_usd,
265 input_tokens = step.input_tokens,
266 output_tokens = step.output_tokens,
267 "step detail"
268 );
269 }
270 }
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use serde_json::json;
278
279 use crate::operations::agent::AgentResult;
280 use crate::operations::shell::Shell;
281 use crate::provider::AgentOutput;
282
283 fn make_agent_result(
284 cost: Option<f64>,
285 input_tokens: Option<u64>,
286 output_tokens: Option<u64>,
287 ) -> AgentResult {
288 let mut output = AgentOutput::new(json!("result"));
289 output.cost_usd = cost;
290 output.input_tokens = input_tokens;
291 output.output_tokens = output_tokens;
292 output.duration_ms = 100;
293 AgentResult::from_output(output)
294 }
295
296 async fn make_shell_output() -> ShellOutput {
297 Shell::new("echo test").run().await.unwrap()
298 }
299
300 #[test]
301 fn new_tracker_has_zero_steps_and_zero_cost() {
302 let tracker = WorkflowTracker::new("test");
303 assert_eq!(tracker.step_count(), 0);
304 assert_eq!(tracker.total_cost_usd(), 0.0);
305 }
306
307 #[tokio::test]
308 async fn record_shell_increments_step_count() {
309 let mut tracker = WorkflowTracker::new("test");
310 let output = make_shell_output().await;
311 tracker.record_shell("step1", &output);
312 assert_eq!(tracker.step_count(), 1);
313 }
314
315 #[test]
316 fn record_agent_with_cost_reflected_in_total() {
317 let mut tracker = WorkflowTracker::new("test");
318 let result = make_agent_result(Some(0.05), Some(100), Some(50));
319 tracker.record_agent("agent1", &result);
320 assert_eq!(tracker.total_cost_usd(), 0.05);
321 }
322
323 #[test]
324 fn record_agent_without_cost_does_not_change_total() {
325 let mut tracker = WorkflowTracker::new("test");
326 let result = make_agent_result(None, None, None);
327 tracker.record_agent("agent1", &result);
328 assert_eq!(tracker.total_cost_usd(), 0.0);
329 }
330
331 #[tokio::test]
332 async fn multiple_steps_counted_correctly() {
333 let mut tracker = WorkflowTracker::new("test");
334 let shell = make_shell_output().await;
335 let agent = make_agent_result(Some(0.1), Some(200), Some(100));
336 tracker.record_shell("s1", &shell);
337 tracker.record_agent("a1", &agent);
338 tracker.record_shell("s2", &shell);
339 assert_eq!(tracker.step_count(), 3);
340 }
341
342 #[test]
343 fn total_input_tokens_sums_across_agent_steps() {
344 let mut tracker = WorkflowTracker::new("test");
345 let r1 = make_agent_result(None, Some(100), None);
346 let r2 = make_agent_result(None, Some(250), None);
347 tracker.record_agent("a1", &r1);
348 tracker.record_agent("a2", &r2);
349 assert_eq!(tracker.total_input_tokens(), 350);
350 }
351
352 #[test]
353 fn total_output_tokens_sums_across_agent_steps() {
354 let mut tracker = WorkflowTracker::new("test");
355 let r1 = make_agent_result(None, None, Some(50));
356 let r2 = make_agent_result(None, None, Some(75));
357 tracker.record_agent("a1", &r1);
358 tracker.record_agent("a2", &r2);
359 assert_eq!(tracker.total_output_tokens(), 125);
360 }
361
362 #[test]
363 fn tokens_with_mixed_none_values() {
364 let mut tracker = WorkflowTracker::new("test");
365 let r1 = make_agent_result(None, Some(100), Some(50));
366 let r2 = make_agent_result(None, None, None);
367 let r3 = make_agent_result(None, Some(200), Some(30));
368 tracker.record_agent("a1", &r1);
369 tracker.record_agent("a2", &r2);
370 tracker.record_agent("a3", &r3);
371 assert_eq!(tracker.total_input_tokens(), 300);
372 assert_eq!(tracker.total_output_tokens(), 80);
373 }
374
375 #[test]
376 fn total_duration_ms_is_positive() {
377 let tracker = WorkflowTracker::new("test");
378 assert!(tracker.total_duration_ms() < 1000); }
382
383 #[test]
384 fn summary_does_not_panic_empty() {
385 let tracker = WorkflowTracker::new("empty");
386 tracker.summary();
387 }
388
389 #[tokio::test]
390 async fn summary_does_not_panic_non_empty() {
391 let mut tracker = WorkflowTracker::new("test");
392 let shell = make_shell_output().await;
393 let agent = make_agent_result(Some(0.01), Some(10), Some(5));
394 tracker.record_shell("s1", &shell);
395 tracker.record_agent("a1", &agent);
396 tracker.summary();
397 }
398
399 #[test]
400 fn eviction_when_max_steps_exceeded() {
401 let mut tracker = WorkflowTracker::new("test").max_steps(3);
402 for i in 0..5 {
403 let r = make_agent_result(Some(i as f64), None, None);
404 tracker.record_agent(&format!("step-{i}"), &r);
405 }
406 assert_eq!(tracker.step_count(), 3);
407 assert_eq!(tracker.total_cost_usd(), 2.0 + 3.0 + 4.0);
409 }
410
411 #[test]
412 fn max_steps_one_keeps_last_only() {
413 let mut tracker = WorkflowTracker::new("test").max_steps(1);
414 let r1 = make_agent_result(Some(1.0), Some(100), None);
415 let r2 = make_agent_result(Some(2.0), Some(200), None);
416 tracker.record_agent("a1", &r1);
417 tracker.record_agent("a2", &r2);
418 assert_eq!(tracker.step_count(), 1);
419 assert_eq!(tracker.total_cost_usd(), 2.0);
420 assert_eq!(tracker.total_input_tokens(), 200);
421 }
422
423 #[test]
424 fn max_steps_builder_sets_limit() {
425 let mut tracker = WorkflowTracker::new("test").max_steps(42);
426 for i in 0..50 {
428 let r = make_agent_result(Some(1.0), None, None);
429 tracker.record_agent(&format!("step-{i}"), &r);
430 }
431 assert_eq!(tracker.step_count(), 42);
432 }
433
434 #[test]
435 fn record_agent_with_breakdown_stores_cost_split() {
436 let mut tracker = WorkflowTracker::new("test");
437 let result = make_agent_result(Some(0.05), Some(1000), Some(500));
438 let bd = CostBreakdown {
439 prompt_usd: 0.003,
440 cache_read_usd: 0.0,
441 cache_write_usd: 0.0,
442 completion_usd: 0.047,
443 total_usd: 0.05,
444 };
445 tracker.record_agent_with_breakdown("a1", &result, bd);
446 assert_eq!(tracker.step_count(), 1);
447 assert_eq!(tracker.total_cost_usd(), 0.05);
448 }
449
450 #[test]
451 fn summary_does_not_panic_with_breakdown() {
452 let mut tracker = WorkflowTracker::new("test");
453 let result = make_agent_result(Some(0.01), Some(100), Some(50));
454 let bd = CostBreakdown {
455 prompt_usd: 0.003,
456 cache_read_usd: 0.0,
457 cache_write_usd: 0.0,
458 completion_usd: 0.007,
459 total_usd: 0.01,
460 };
461 tracker.record_agent_with_breakdown("a1", &result, bd);
462 tracker.summary();
463 }
464}