diff --git a/CHANGELOG.md b/CHANGELOG.md index 9684f8371..cb962f1d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). set it directly in `config.toml` or a task graph (bypassing the `--init` wizard, which already documents the reservation) a runtime signal that the value is currently inert. +### Fixed + +- `zeph-core`: `TurnSummary::tool_calls` was hardcoded to `0` at its only production + construction site, so any consumer of the per-turn completion notification always saw + zero tool calls regardless of actual turn activity (#6273). Added a `turn_tool_calls` + counter to `LifecycleState`, mirroring the existing `turn_llm_requests` pattern: reset + in `begin_turn`, incremented in `check_and_update_quota` for every tool call in a + dispatch batch, and read into `TurnSummary::tool_calls` when the turn's notification + summary is built. + +### Changed + +- `zeph-core`: collapsed 9 of 10 identified near-identical `CommandError`-wrapping delegate + boilerplate blocks in `agent_access_impl.rs` (#6262) into a single `delegate_cmd!` + declarative macro that forwards an optional argument to an inner async method and maps + its error via `CommandError::new(e.to_string())`. Covers `lsp_status`, `handle_skill`, + `handle_skills`, `handle_feedback_command`, `handle_plan` (scheduler feature), + `handle_experiment`, `list_worktrees`, `clean_worktrees`, and `handle_cocoon` (cocoon + feature). The 10th identified site, `handle_mcp`'s `_` match arm, was intentionally left + inline — it is a match arm inside a larger `match sub.as_str()` block, not a standalone + handler fn, so it does not fit the macro's shape. No behavior change. + ## [0.22.1] - 2026-07-15 ### Fixed diff --git a/crates/zeph-core/src/agent/agent_access_impl.rs b/crates/zeph-core/src/agent/agent_access_impl.rs index 2f81cc248..6fddbd1cb 100644 --- a/crates/zeph-core/src/agent/agent_access_impl.rs +++ b/crates/zeph-core/src/agent/agent_access_impl.rs @@ -223,6 +223,25 @@ async fn semantic_scan_plugin_add( Ok(None) } +/// Delegates an `AgentAccess` command handler to an inner async method, wrapping the +/// inner method's error in [`CommandError`]. Covers the pass-through shape shared by +/// most command handlers below: an optional single argument forwarded verbatim, and +/// the returned `Result` mapped to `Result` via `e.to_string()`. +macro_rules! delegate_cmd { + ($name:ident, $inner:ident $(, $arg:ident : $arg_ty:ty)? => $out:ty) => { + fn $name<'a>( + &'a mut self, + $($arg: $arg_ty)? + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + self.$inner($($arg)?) + .await + .map_err(|e| CommandError::new(e.to_string())) + }) + } + }; +} + impl AgentAccess for Agent { // ----- /memory ----- @@ -1056,15 +1075,7 @@ impl AgentAccess for Agent { // ----- /lsp ----- - fn lsp_status<'a>( - &'a mut self, - ) -> Pin> + Send + 'a>> { - Box::pin(async move { - self.handle_lsp_status_as_string() - .await - .map_err(|e| CommandError::new(e.to_string())) - }) - } + delegate_cmd!(lsp_status, handle_lsp_status_as_string => String); // ----- /recap ----- @@ -1298,59 +1309,20 @@ impl AgentAccess for Agent { // ----- /skill ----- - fn handle_skill<'a>( - &'a mut self, - args: &'a str, - ) -> Pin> + Send + 'a>> { - let args_owned = args.to_owned(); - Box::pin(async move { - self.handle_skill_command_as_string(&args_owned) - .await - .map_err(|e| CommandError::new(e.to_string())) - }) - } + delegate_cmd!(handle_skill, handle_skill_command_as_string, args: &'a str => String); // ----- /skills ----- - fn handle_skills<'a>( - &'a mut self, - args: &'a str, - ) -> Pin> + Send + 'a>> { - let args_owned = args.to_owned(); - Box::pin(async move { - self.handle_skills_as_string(&args_owned) - .await - .map_err(|e| CommandError::new(e.to_string())) - }) - } + delegate_cmd!(handle_skills, handle_skills_as_string, args: &'a str => String); // ----- /feedback ----- - fn handle_feedback_command<'a>( - &'a mut self, - args: &'a str, - ) -> Pin> + Send + 'a>> { - let args_owned = args.to_owned(); - Box::pin(async move { - self.handle_feedback_as_string(&args_owned) - .await - .map_err(|e| CommandError::new(e.to_string())) - }) - } + delegate_cmd!(handle_feedback_command, handle_feedback_as_string, args: &'a str => String); // ----- /plan ----- #[cfg(feature = "scheduler")] - fn handle_plan<'a>( - &'a mut self, - input: &'a str, - ) -> Pin> + Send + 'a>> { - Box::pin(async move { - self.dispatch_plan_command_as_string(input) - .await - .map_err(|e| CommandError::new(e.to_string())) - }) - } + delegate_cmd!(handle_plan, dispatch_plan_command_as_string, input: &'a str => String); #[cfg(not(feature = "scheduler"))] fn handle_plan<'a>( @@ -1362,16 +1334,7 @@ impl AgentAccess for Agent { // ----- /experiment ----- - fn handle_experiment<'a>( - &'a mut self, - input: &'a str, - ) -> Pin> + Send + 'a>> { - Box::pin(async move { - self.handle_experiment_command_as_string(input) - .await - .map_err(|e| CommandError::new(e.to_string())) - }) - } + delegate_cmd!(handle_experiment, handle_experiment_command_as_string, input: &'a str => String); // ----- /agent, @mention ----- @@ -1514,16 +1477,7 @@ impl AgentAccess for Agent { // ----- /cocoon ----- #[cfg(feature = "cocoon")] - fn handle_cocoon<'a>( - &'a mut self, - args: &'a str, - ) -> Pin> + Send + 'a>> { - Box::pin(async move { - self.handle_cocoon_as_string(args) - .await - .map_err(|e| CommandError::new(e.to_string())) - }) - } + delegate_cmd!(handle_cocoon, handle_cocoon_as_string, args: &'a str => String); #[cfg(not(feature = "cocoon"))] fn handle_cocoon<'a>( @@ -1883,26 +1837,9 @@ impl AgentAccess for Agent { // ----- /worktree ----- - fn list_worktrees<'a>( - &'a mut self, - ) -> Pin, CommandError>> + Send + 'a>> { - Box::pin(async move { - self.handle_worktree_list_as_string() - .await - .map_err(|e| CommandError::new(e.to_string())) - }) - } + delegate_cmd!(list_worktrees, handle_worktree_list_as_string => Option); - fn clean_worktrees<'a>( - &'a mut self, - force: bool, - ) -> Pin, CommandError>> + Send + 'a>> { - Box::pin(async move { - self.handle_worktree_clean_as_string(force) - .await - .map_err(|e| CommandError::new(e.to_string())) - }) - } + delegate_cmd!(clean_worktrees, handle_worktree_clean_as_string, force: bool => Option); // ----- /cd ----- diff --git a/crates/zeph-core/src/agent/mod.rs b/crates/zeph-core/src/agent/mod.rs index adc9862df..252047b2e 100644 --- a/crates/zeph-core/src/agent/mod.rs +++ b/crates/zeph-core/src/agent/mod.rs @@ -938,6 +938,8 @@ impl Agent { self.services.security.user_provided_urls.write().clear(); // Reset per-turn LLM request counter for the notification gate. self.runtime.lifecycle.turn_llm_requests = 0; + // Reset per-turn tool-call dispatch counter (feeds TurnSummary::tool_calls). + self.runtime.lifecycle.turn_tool_calls = 0; // Spec 050 §2: drain pending risk signals from executor layers before advancing. // Also advance MAGE accumulator (spec 004-16 FR-009) and ingest mapped signals. @@ -1391,8 +1393,7 @@ impl Agent { let summary = crate::notifications::TurnSummary { duration_ms, preview: self.last_assistant_preview(160), - // TODO: wire turn_tool_calls counter once LifecycleState tracks it (Phase 2). - tool_calls: 0, + tool_calls: self.runtime.lifecycle.turn_tool_calls, llm_requests: self.runtime.lifecycle.turn_llm_requests, exit_status: if is_error { crate::notifications::TurnExitStatus::Error diff --git a/crates/zeph-core/src/agent/state/mod.rs b/crates/zeph-core/src/agent/state/mod.rs index f6a3fb268..995632708 100644 --- a/crates/zeph-core/src/agent/state/mod.rs +++ b/crates/zeph-core/src/agent/state/mod.rs @@ -551,6 +551,9 @@ pub(crate) struct LifecycleState { pub(crate) notifier: Option, /// Per-turn LLM request counter. Incremented by `process_response`; reset at turn start. pub(crate) turn_llm_requests: u32, + /// Per-turn tool-call dispatch counter. Incremented by `check_and_update_quota` for every + /// tool call in a dispatch batch; reset at turn start. Feeds `TurnSummary::tool_calls`. + pub(crate) turn_tool_calls: u32, /// Timestamp of the last turn that ended with `LlmError::NoProviders`. /// /// Used to gate `advance_context_lifecycle`: when all providers are down, context preparation @@ -1442,6 +1445,7 @@ impl LifecycleState { bg_metrics_tick: None, notifier: None, turn_llm_requests: 0, + turn_tool_calls: 0, last_no_providers_at: None, pending_background_completions: VecDeque::new(), background_completion_rx: None, diff --git a/crates/zeph-core/src/agent/tool_execution/tier_loop.rs b/crates/zeph-core/src/agent/tool_execution/tier_loop.rs index 8624c93d7..b82289802 100644 --- a/crates/zeph-core/src/agent/tool_execution/tier_loop.rs +++ b/crates/zeph-core/src/agent/tool_execution/tier_loop.rs @@ -1206,10 +1206,16 @@ impl Agent { ); return true; } + let batch_count = u32::try_from(batch_len).unwrap_or(u32::MAX); self.tool_orchestrator.session_tool_call_count = self .tool_orchestrator .session_tool_call_count - .saturating_add(u32::try_from(batch_len).unwrap_or(u32::MAX)); + .saturating_add(batch_count); + self.runtime.lifecycle.turn_tool_calls = self + .runtime + .lifecycle + .turn_tool_calls + .saturating_add(batch_count); false }