1use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use agentplane::model::ModelProvider;
12use speccoder_core::{
13 CommandData, CommandResult, CommandStatus, SkillEntry, SkillRunReport, SkillsReport,
14};
15
16use crate::format::Planning;
17use crate::{RunOptions, progress, redact, skills, substrate, tools, verify, worktree};
18
19fn root_of(path: &Path) -> Result<PathBuf, Box<CommandResult>> {
20 match crate::format::open(path, None) {
21 Ok(opened) => Ok(PathBuf::from(&opened.snapshot.root)),
22 Err(r) if r.unmarked => Err(Box::new(CommandResult::new(
23 "skill",
24 CommandStatus::NotAProject,
25 crate::format::reference().messages().no_project,
26 ))),
27 Err(r) => Err(Box::new(CommandResult::new("skill", r.status, &r.message))),
28 }
29}
30
31#[must_use]
33pub fn list(path: &Path) -> CommandResult {
34 let root = match root_of(path) {
35 Ok(r) => r,
36 Err(r) => return *r,
37 };
38 let format = crate::format::at(&root);
39 let found = skills::discover_with(&root, format.planning());
40 let message = if found.is_empty() {
41 format.messages().no_skills.to_owned()
42 } else {
43 format!(
44 "{} skill(s); run one with `speccoder spec <name> [arguments]`",
45 found.len()
46 )
47 };
48 CommandResult::new("skill", CommandStatus::Ok, &message).with_data(CommandData::Skills(
49 Box::new(SkillsReport {
50 skills: found
51 .into_iter()
52 .map(|s| SkillEntry {
53 name: s.name,
54 description: s.description,
55 path: s.path,
56 refused: s.refused,
57 })
58 .collect(),
59 }),
60 ))
61}
62
63pub fn run_with(
65 path: &Path,
66 name: &str,
67 arguments: &str,
68 state: &Path,
69 provider: Arc<dyn ModelProvider>,
70 secret: Option<&str>,
71 options: &RunOptions,
72) -> CommandResult {
73 run_in(
74 path, None, name, arguments, state, provider, secret, options,
75 )
76}
77
78#[allow(clippy::too_many_arguments)]
86pub fn run_in(
87 path: &Path,
88 feature: Option<&str>,
89 name: &str,
90 arguments: &str,
91 state: &Path,
92 provider: Arc<dyn ModelProvider>,
93 secret: Option<&str>,
94 options: &RunOptions,
95) -> CommandResult {
96 let reviews = crate::review::snapshot(path);
97 let result = run_unguarded(
98 path, feature, name, arguments, state, provider, secret, options, None,
99 );
100 let restored = crate::review::restore(&reviews);
101 if restored.is_empty() {
102 return result;
103 }
104 let message = format!(
105 "{}; undone: the skill changed the person's review ({}) — only `speccoder review` writes it",
106 result.message(),
107 restored.join(", ")
108 );
109 let status = result.status();
110 result.reworded(status, &message)
111}
112
113#[allow(clippy::too_many_arguments)]
116pub(crate) fn run_scoped(
117 path: &Path,
118 name: &str,
119 arguments: &str,
120 state: &Path,
121 provider: Arc<dyn ModelProvider>,
122 secret: Option<&str>,
123 options: &RunOptions,
124 rules: &'static crate::format::Rules,
125) -> CommandResult {
126 let reviews = crate::review::snapshot(path);
127 let result = run_unguarded(
128 path,
129 None,
130 name,
131 arguments,
132 state,
133 provider,
134 secret,
135 options,
136 Some(rules),
137 );
138 let _ = crate::review::restore(&reviews);
139 result
140}
141
142#[allow(clippy::too_many_arguments)]
143fn run_unguarded(
144 path: &Path,
145 feature: Option<&str>,
146 name: &str,
147 arguments: &str,
148 state: &Path,
149 provider: Arc<dyn ModelProvider>,
150 secret: Option<&str>,
151 options: &RunOptions,
152 scoped: Option<&'static crate::format::Rules>,
153) -> CommandResult {
154 let refuse = |status, m: String| CommandResult::new("skill", status, &redact(&m, secret));
155 let s = match crate::setup(path, feature, state, "skill") {
156 Ok(s) => s,
157 Err(r) => return *r,
158 };
159 let root = PathBuf::from(&s.snapshot.root);
160 let planning = s.format.planning();
161 let installed = skills::discover_with(&root, planning);
162 let Some(skill) = installed
163 .iter()
164 .find(|k| k.name == name || k.name == format!("{}{name}", planning.prefix))
165 else {
166 return refuse(
167 CommandStatus::NotFound,
168 format!("no skill `{name}` (see `speccoder spec`)"),
169 );
170 };
171 if let Some(why) = &skill.refused {
172 return refuse(
173 CommandStatus::UsageError,
174 format!("`{}` is not hosted: {why}", skill.name),
175 );
176 }
177 if !skill.invocable {
178 return refuse(
179 CommandStatus::UsageError,
180 format!("`{}` is marked user-invocable: false", skill.name),
181 );
182 }
183 let event = core_event(planning, &skill.name).map(str::to_owned);
185 let mut hook_lines = Vec::new();
186 if let Some(e) = &event {
187 match run_hooks_in(
188 &format!("before_{e}"),
189 arguments,
190 path,
191 feature,
192 state,
193 &provider,
194 secret,
195 options,
196 ) {
197 Ok(lines) => hook_lines.extend(lines),
198 Err(stop) => return *stop,
199 }
200 }
201 let instruction = match skills::instruction(&root, skill, arguments) {
202 Ok(i) => i,
203 Err(e) => return refuse(CommandStatus::Error, e),
204 };
205 let config_text = verify::find_config(&root)
206 .and_then(|d| std::fs::read_to_string(d.join(verify::config::FILE)).ok())
207 .unwrap_or_default();
208 let budget = match substrate::run_config::parse(&config_text) {
209 Ok(c) => c,
210 Err(e) => {
211 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
212 .with_code("CONFIG-INVALID");
213 }
214 };
215
216 let tmp = s.plane_dir.join("tmp");
217 let before = match worktree::checkpoint(&root, &tmp) {
218 Ok(c) => c,
219 Err(e) => {
220 return refuse(
221 CommandStatus::GitPrecondition,
222 format!(
223 "a hosted skill needs a git repository with a commit: {}",
224 e.0
225 ),
226 );
227 }
228 };
229 let turns = Arc::new(Turns::default());
232 let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
233 &s.plane_dir,
234 s.model.prices,
235 Some(Arc::new(Counted {
236 turns: Arc::clone(&turns),
237 next: options.progress.clone(),
238 }) as Arc<dyn progress::Progress>),
239 ));
240 live.event(&progress::Event::TaskStarted {
241 feature: "skill".to_owned(),
242 task: skill.name.clone(),
243 title: skill.description.clone(),
244 });
245 let rt = match crate::tokio_runtime() {
246 Ok(rt) => rt,
247 Err(e) => return refuse(CommandStatus::RunFailed, e),
248 };
249 let runner = match crate::sandbox::runner_at(&root) {
251 Ok((r, _)) => r,
252 Err(e) => return refuse(CommandStatus::ConfigError, e),
253 };
254 let canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
255 let short = skill
260 .name
261 .strip_prefix(planning.prefix)
262 .unwrap_or(&skill.name);
263 let creates = planning.creates.contains(&short);
264 let selected = s.snapshot.feature.as_ref().map(|f| f.feature.dir.clone());
265 let env: Vec<(String, String)> = match (planning.current_env, &selected) {
266 (Some(var), Some(dir)) if !creates => {
267 vec![(var.to_owned(), canonical.join(dir).display().to_string())]
268 }
269 _ => Vec::new(),
270 };
271 let server = match tools::WorkspaceTools::new(
272 &root,
273 tools::policy::Policy::new(&[], &[])
274 .rules(scoped.unwrap_or_else(|| s.format.rules()))
275 .authoring(),
276 ) {
277 Ok(t) => Arc::new(t.with_runner(runner).with_env(env)),
278 Err(e) => return refuse(CommandStatus::Error, e.to_string()),
279 };
280 let mut input = substrate::host::input(
281 &skill.name,
282 &instruction,
283 &canonical.display().to_string(),
284 (budget.max_turns, budget.max_tool_calls),
285 options.approver.is_some(),
286 );
287 if scoped.is_some() {
288 input["settings"]["scope"] = serde_json::json!("bug");
289 }
290 let outcome = rt.block_on(async {
291 let plane = substrate::Plane::open_with(
292 &s.plane_dir,
293 &s.model,
294 Arc::clone(&provider),
295 Some(server),
296 substrate::Hooks {
297 progress: Some(Arc::clone(&live)),
298 format: Some(s.format),
299 ..substrate::Hooks::default()
300 },
301 )?;
302 let outcome = plane
304 .skill(&skill.name, agentplane::core::Tainted::trusted(input))
305 .await?;
306 let context = crate::approvals::PresentContext {
309 root: Some(canonical.clone()),
310 ..crate::approvals::PresentContext::default()
311 };
312 plane
313 .decide_inline(outcome, options.approver.as_deref(), &context)
314 .await
315 });
316 let outcome = outcome.map(|i| {
319 let _ = crate::actions::allow_always_inline(
320 &root,
321 &i.always.first().map_or_else(String::new, |a| {
322 a.task.split('/').next().unwrap_or_default().to_owned()
323 }),
324 &i.always,
325 state,
326 );
327 i.outcome
328 });
329 let outcome = match outcome {
330 Ok(o) => o,
331 Err(e) => return refuse(CommandStatus::RunFailed, e.0),
332 };
333 let output = outcome
334 .output
335 .as_ref()
336 .map(|o| o.peek().clone())
337 .unwrap_or(serde_json::Value::Null);
338 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
339 let status = if succeeded {
340 output["status"].as_str().unwrap_or("failed").to_owned()
341 } else {
342 "failed".to_owned()
343 };
344 let changes: Vec<(String, String)> = worktree::checkpoint(&root, &tmp)
345 .and_then(|after| worktree::diff(&root, &before, &after))
346 .unwrap_or_default()
347 .into_iter()
348 .map(|c| {
349 let kind = match c.kind {
350 worktree::ChangeKind::Added => "added",
351 worktree::ChangeKind::Modified => "modified",
352 worktree::ChangeKind::Deleted => "deleted",
353 };
354 (c.path, kind.to_owned())
355 })
356 .collect();
357 let report = SkillRunReport {
358 skill: skill.name.clone(),
359 run_id: outcome.run_id.to_string(),
360 summary: output["report"]["summary"].as_str().map(str::to_owned),
361 questions: output["report"]["questions"]
362 .as_array()
363 .map(|a| {
364 a.iter()
365 .filter_map(|q| q.as_str().map(str::to_owned))
366 .collect()
367 })
368 .unwrap_or_default(),
369 changes,
370 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
371 pending: output.get("pending").cloned(),
372 reason: output["reason"]
373 .as_str()
374 .map(str::to_owned)
375 .or_else(|| (!succeeded).then(|| format!("{:?}", outcome.status))),
376 usage: output.get("usage").cloned(),
377 status: status.clone(),
378 hooks: Vec::new(),
379 current: None,
380 };
381 let mut report = report;
384 if creates && succeeded {
385 let known: Vec<&str> = s.snapshot.features.iter().map(|f| f.dir.as_str()).collect();
386 let made = s
387 .format
388 .inspect(&root, None)
389 .map(|after| after.features)
390 .unwrap_or_default()
391 .into_iter()
392 .rfind(|f| !known.contains(&f.dir.as_str()));
393 if let Some(f) = made {
394 match s.format.set_current(&root, Some(&f.dir)) {
395 Ok(pointer) => {
396 report.current = Some(format!("{} (recorded in {pointer})", f.name));
397 }
398 Err(e) => report.current = Some(format!("{} (not recorded: {e})", f.name)),
399 }
400 }
401 } else if let Some(f) = s.snapshot.feature.as_ref() {
402 report.current = Some(format!(
403 "{} ({})",
404 f.feature.name,
405 crate::current::reason_text(s.snapshot.selection.reason, s.format)
406 ));
407 }
408 let tokens = |k: &str| {
409 report
410 .usage
411 .as_ref()
412 .and_then(|u| u[k].as_u64())
413 .unwrap_or(0)
414 };
415 let (code, message) = match status.as_str() {
416 "completed" => (
417 CommandStatus::Ok,
418 format!(
419 "{} done: {} file(s) changed in your checkout, uncommitted — review them (git diff, git status)",
420 skill.name,
421 report.changes.len()
422 ),
423 ),
424 "failed" => (
425 CommandStatus::RunFailed,
426 format!(
427 "{} failed: {}",
428 skill.name,
429 report.reason.clone().unwrap_or_default()
430 ),
431 ),
432 other => (
433 CommandStatus::NeedsHuman,
434 format!(
435 "{} stopped ({other}): {}",
436 skill.name,
437 report
439 .pending
440 .as_ref()
441 .filter(|p| p.is_object())
442 .map(|p| tools::policy::unasked(
443 p,
444 &format!(
445 "run it again with prompts (`speccoder spec {}` without `--no-prompt`) and approve it when asked",
446 skill
447 .name
448 .strip_prefix(s.format.planning().prefix)
449 .unwrap_or(&skill.name)
450 ),
451 ))
452 .or_else(|| report.reason.clone().filter(|r| !r.is_empty()))
453 .or_else(|| {
454 (!report.questions.is_empty())
455 .then(|| format!("open questions: {}", report.questions.join(" | ")))
456 })
457 .or_else(|| report
458 .pending
459 .as_ref()
460 .map(|p| p["reason"].as_str().unwrap_or_default().to_owned())
461 .filter(|r| !r.is_empty()))
462 .or_else(|| report
464 .summary
465 .clone()
466 .filter(|r| !r.trim().is_empty())
467 .map(|r| format!("the skill says: {r}")))
468 .unwrap_or_else(|| format!(
469 "the skill ended without saying why — run it again (speccoder spec {})",
470 skill
471 .name
472 .strip_prefix(s.format.planning().prefix)
473 .unwrap_or(&skill.name)
474 ))
475 ),
476 ),
477 };
478 crate::controls::record_skill_usage(
481 &s.plane_dir,
482 &skill.name,
483 [
484 tokens("input_tokens"),
485 tokens("output_tokens"),
486 tokens("cache_read_tokens"),
487 tokens("cache_write_tokens"),
488 ],
489 s.model.prices,
490 u64::from(turns.calls()),
491 );
492 live.event(&progress::Event::TaskFinished {
493 task: skill.name.clone(),
494 outcome: status,
495 message: redact(&message, secret),
496 run: None,
497 input_tokens: tokens("input_tokens"),
498 output_tokens: tokens("output_tokens"),
499 cache_read_tokens: tokens("cache_read_tokens"),
500 cache_write_tokens: tokens("cache_write_tokens"),
501 usd: None,
502 regression: None,
503 });
504 if code == CommandStatus::Ok
505 && let Some(e) = &event
506 {
507 match run_hooks_in(
508 &format!("after_{e}"),
509 arguments,
510 path,
511 feature,
512 state,
513 &provider,
514 secret,
515 options,
516 ) {
517 Ok(lines) => hook_lines.extend(lines),
518 Err(stop) => {
519 report.hooks = hook_lines;
520 return CommandResult::carried("skill", &stop)
521 .with_data(CommandData::Skill(Box::new(report)));
522 }
523 }
524 }
525 report.hooks = hook_lines;
526 CommandResult::new("skill", code, &redact(&message, secret))
527 .with_data(CommandData::Skill(Box::new(report)))
528}
529
530#[must_use]
532pub fn run(
533 path: &Path,
534 feature: Option<&str>,
535 name: &str,
536 arguments: &str,
537 options: &RunOptions,
538) -> CommandResult {
539 crate::with_driver("skill", path, None, |state, provider, key| {
540 run_in(
541 path,
542 feature,
543 name,
544 arguments,
545 state,
546 Arc::clone(provider),
547 Some(key),
548 options,
549 )
550 })
551}
552
553pub fn run_hooks(
557 event: &str,
558 arguments: &str,
559 path: &Path,
560 state: &Path,
561 provider: &Arc<dyn ModelProvider>,
562 secret: Option<&str>,
563 options: &RunOptions,
564) -> Result<Vec<String>, Box<CommandResult>> {
565 run_hooks_in(
566 event, arguments, path, None, state, provider, secret, options,
567 )
568}
569
570#[allow(clippy::too_many_arguments)]
572pub fn run_hooks_in(
573 event: &str,
574 arguments: &str,
575 path: &Path,
576 feature: Option<&str>,
577 state: &Path,
578 provider: &Arc<dyn ModelProvider>,
579 secret: Option<&str>,
580 options: &RunOptions,
581) -> Result<Vec<String>, Box<CommandResult>> {
582 let root = root_of(path)?;
583 let plan = crate::hooks::plan(&root, event)
584 .map_err(|e| Box::new(CommandResult::new("hooks", CommandStatus::ConfigError, &e)))?;
585 let mut lines: Vec<String> = Vec::new();
586 for hook in &plan.run {
587 let note = |text: String| {
588 if let Some(p) = &options.progress {
589 p.event(&progress::Event::Notice { text });
590 }
591 };
592 note(format!(
593 "{event} hook ({}): {}",
594 hook.extension, hook.command
595 ));
596 let res = run_in(
597 path,
598 feature,
599 &hook.skill(),
600 arguments,
601 state,
602 Arc::clone(provider),
603 secret,
604 options,
605 );
606 if res.status() != CommandStatus::Ok {
607 let status = if res.status() == CommandStatus::NotFound {
608 CommandStatus::ConfigError
609 } else {
610 res.status()
611 };
612 return Err(Box::new(CommandResult::new(
613 "hooks",
614 status,
615 &format!(
616 "mandatory {event} hook `{}` ({}) did not complete: {}",
617 hook.command,
618 hook.extension,
619 res.message()
620 ),
621 )));
622 }
623 lines.push(format!(
624 "ran {event} hook {} ({})",
625 hook.command, hook.extension
626 ));
627 }
628 for hook in &plan.offer {
629 let line = crate::hooks::offer_line(event, hook);
630 if let Some(p) = &options.progress {
631 p.event(&progress::Event::Notice { text: line.clone() });
632 }
633 lines.push(line);
634 }
635 for (hook, why) in &plan.skipped {
636 lines.push(format!(
637 "skipped {event} hook {} ({}): {why}",
638 hook.command, hook.extension
639 ));
640 }
641 Ok(lines)
642}
643
644fn core_event<'a>(planning: &Planning, skill: &'a str) -> Option<&'a str> {
646 let short = skill.strip_prefix(planning.prefix).unwrap_or(skill);
647 planning.core.contains(&short).then_some(short)
648}
649
650#[must_use]
654pub fn uncommitted(path: &Path, feature: Option<&str>) -> Vec<String> {
655 let format = crate::format::at(path);
656 let Some(snap) = format.open(path, feature) else {
657 return Vec::new();
658 };
659 let Some(f) = snap.feature.as_ref() else {
660 return Vec::new();
661 };
662 let (top, dir) = repo_dir(Path::new(&snap.root), &f.feature.dir);
663 crate::git::Git::at(&top)
664 .run(&[
665 "status",
666 "--porcelain",
667 "-z",
668 "--untracked-files=all",
669 "--",
670 &dir,
671 ])
672 .map(|out| {
673 String::from_utf8_lossy(&out)
674 .split('\0')
675 .filter(|e| e.len() > 3)
676 .map(|e| e[3..].to_owned())
677 .collect()
678 })
679 .unwrap_or_default()
680}
681
682#[must_use]
686pub fn commit_planning(path: &Path, feature: Option<&str>, paths: &[String]) -> CommandResult {
687 commit_planning_as(path, feature, paths, None)
688}
689
690#[must_use]
692pub fn commit_planning_as(
693 path: &Path,
694 feature: Option<&str>,
695 paths: &[String],
696 subject: Option<&str>,
697) -> CommandResult {
698 let refuse = |status, m: &str| CommandResult::new("commit", status, m);
699 let format = crate::format::at(path);
700 let Some(snap) = format.open(path, feature) else {
701 return refuse(
702 CommandStatus::NotAProject,
703 &(format.messages().not_a_project)(path),
704 );
705 };
706 let Some(f) = snap.feature.as_ref() else {
707 return refuse(
708 CommandStatus::SelectionNeeded,
709 "no current feature to commit for",
710 );
711 };
712 let (top, unit) = repo_dir(Path::new(&snap.root), &f.feature.dir);
713 let dir = format!("{unit}/");
714 if paths.is_empty() {
715 return refuse(CommandStatus::NotFound, "nothing to commit");
716 }
717 let inside = |p: &str| {
720 p.strip_prefix(&dir).is_some_and(|rest| {
721 rest.split('/')
722 .all(|c| !c.is_empty() && c != "." && c != "..")
723 })
724 };
725 if let Some(p) = paths.iter().find(|p| !inside(p)) {
726 return refuse(
727 CommandStatus::UsageError,
728 &format!(
729 "`{p}` is not in {}; only planning files are committed here",
730 f.feature.dir
731 ),
732 );
733 }
734 let git = crate::git::Git::at(&top);
735 let names: Vec<&str> = paths
736 .iter()
737 .map(|p| p.rsplit('/').next().unwrap_or(p))
738 .collect();
739 let message = subject.map_or_else(
740 || format!("spec({}): {}", f.feature.name, names.join(", ")),
741 |t| format!("spec({}): {t}", f.feature.name),
742 );
743 let mut add = vec!["add", "--"];
744 add.extend(paths.iter().map(String::as_str));
745 let mut commit = vec!["commit", "-q", "-m", &message, "--"];
746 commit.extend(paths.iter().map(String::as_str));
747 match git.run(&add).and_then(|_| git.run(&commit)) {
748 Ok(_) => {
749 let id = git
750 .line(&["rev-parse", "--short", "HEAD"])
751 .unwrap_or_default();
752 CommandResult::new(
753 "commit",
754 CommandStatus::Ok,
755 &format!("committed {} file(s) as {id} \"{message}\"", paths.len()),
756 )
757 }
758 Err(e) => refuse(
759 CommandStatus::GitPrecondition,
760 &format!("commit failed: {}", e.0),
761 ),
762 }
763}
764
765#[must_use]
769pub fn commit_exactly(root: &Path, paths: &[String], message: &str) -> CommandResult {
770 let refuse = |status, m: &str| CommandResult::new("commit", status, m);
771 if paths.is_empty() {
772 return refuse(CommandStatus::NotFound, "nothing to commit");
773 }
774 let (top, _) = repo_dir(root, "");
775 let git = crate::git::Git::at(&top);
776 let mut add = vec!["add", "--"];
777 add.extend(paths.iter().map(String::as_str));
778 let mut commit = vec!["commit", "-q", "-m", message, "--"];
779 commit.extend(paths.iter().map(String::as_str));
780 match git.run(&add).and_then(|_| git.run(&commit)) {
781 Ok(_) => {
782 let id = git.line(&["rev-parse", "HEAD"]).unwrap_or_default();
783 CommandResult::new("commit", CommandStatus::Ok, &id)
784 }
785 Err(e) => refuse(
786 CommandStatus::GitPrecondition,
787 &format!("commit failed: {}", e.0),
788 ),
789 }
790}
791
792fn repo_dir(root: &Path, unit_dir: &str) -> (std::path::PathBuf, String) {
795 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
796 let top = crate::git::Git::at(root)
797 .toplevel()
798 .ok()
799 .flatten()
800 .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
801 let prefix = canonical(root)
802 .strip_prefix(&top)
803 .map(|p| p.to_string_lossy().replace('\\', "/"))
804 .unwrap_or_default();
805 let dir = if prefix.is_empty() {
806 unit_dir.to_owned()
807 } else {
808 format!("{prefix}/{unit_dir}")
809 };
810 (top, dir)
811}
812
813#[derive(Debug, Default)]
815struct Turns(std::sync::atomic::AtomicU32);
816
817impl Turns {
818 fn calls(&self) -> u32 {
820 self.0.load(std::sync::atomic::Ordering::SeqCst) + 1
821 }
822}
823
824#[derive(Debug)]
826struct Counted {
827 turns: Arc<Turns>,
828 next: Option<Arc<dyn progress::Progress>>,
829}
830
831impl progress::Progress for Counted {
832 fn event(&self, event: &progress::Event) {
833 if let progress::Event::ToolCall { turn, .. } = event {
834 self.turns
835 .0
836 .fetch_max(*turn, std::sync::atomic::Ordering::SeqCst);
837 }
838 if let Some(n) = &self.next {
839 n.event(event);
840 }
841 }
842}