diff --git a/crates/aish-i18n/locales/de-DE.yaml b/crates/aish-i18n/locales/de-DE.yaml index 2bda0b36..9fad04c6 100644 --- a/crates/aish-i18n/locales/de-DE.yaml +++ b/crates/aish-i18n/locales/de-DE.yaml @@ -445,6 +445,7 @@ shell: input_tokens: "Eingabe-Tokens" output_tokens: "Ausgabe-Tokens" total: "Gesamt" + cached: "Cache-Treffer" api_calls: "API-Aufrufe" setup: no_config_manager: "Neukonfiguration nicht möglich: Konfigurationsmanager nicht verfügbar" diff --git a/crates/aish-i18n/locales/en-US.yaml b/crates/aish-i18n/locales/en-US.yaml index e8c9ec46..ff1e4bc1 100644 --- a/crates/aish-i18n/locales/en-US.yaml +++ b/crates/aish-i18n/locales/en-US.yaml @@ -887,6 +887,7 @@ shell: input_tokens: "Input tokens" output_tokens: "Output tokens" total: "Total" + cached: "Cache hits" api_calls: "API calls" setup: diff --git a/crates/aish-i18n/locales/es-ES.yaml b/crates/aish-i18n/locales/es-ES.yaml index 0c1df33a..3c60a537 100644 --- a/crates/aish-i18n/locales/es-ES.yaml +++ b/crates/aish-i18n/locales/es-ES.yaml @@ -445,6 +445,7 @@ shell: input_tokens: "Tokens de entrada" output_tokens: "Tokens de salida" total: "Total" + cached: "Aciertos de caché" api_calls: "Llamadas API" setup: no_config_manager: "No se puede reconfigurar: el gestor de configuración no está disponible" diff --git a/crates/aish-i18n/locales/fr-FR.yaml b/crates/aish-i18n/locales/fr-FR.yaml index 1344c93a..e6244a40 100644 --- a/crates/aish-i18n/locales/fr-FR.yaml +++ b/crates/aish-i18n/locales/fr-FR.yaml @@ -445,6 +445,7 @@ shell: input_tokens: "Tokens d'entrée" output_tokens: "Tokens de sortie" total: "Total" + cached: "Accès au cache" api_calls: "Appels API" setup: no_config_manager: "Reconfiguration impossible : gestionnaire de configuration indisponible" diff --git a/crates/aish-i18n/locales/ja-JP.yaml b/crates/aish-i18n/locales/ja-JP.yaml index a302ac10..b409d5cb 100644 --- a/crates/aish-i18n/locales/ja-JP.yaml +++ b/crates/aish-i18n/locales/ja-JP.yaml @@ -445,6 +445,7 @@ shell: input_tokens: "入力 tokens" output_tokens: "出力 tokens" total: "合計" + cached: "キャッシュヒット" api_calls: "API 呼び出し回数" setup: no_config_manager: "再設定できません: 設定マネージャーを利用できません" diff --git a/crates/aish-i18n/locales/zh-CN.yaml b/crates/aish-i18n/locales/zh-CN.yaml index c2f2341d..be56105b 100644 --- a/crates/aish-i18n/locales/zh-CN.yaml +++ b/crates/aish-i18n/locales/zh-CN.yaml @@ -887,6 +887,7 @@ shell: input_tokens: "输入 tokens" output_tokens: "输出 tokens" total: "总计" + cached: "缓存命中" api_calls: "API 调用次数" setup: diff --git a/crates/aish-llm/src/session.rs b/crates/aish-llm/src/session.rs index 0164c197..bf2b1cf5 100644 --- a/crates/aish-llm/src/session.rs +++ b/crates/aish-llm/src/session.rs @@ -947,6 +947,7 @@ impl LlmSession { // Accumulate token usage from SSE chunks let mut stream_prompt_tokens: u64 = 0; let mut stream_completion_tokens: u64 = 0; + let mut stream_cached_tokens: u64 = 0; while !stream_done { if self.cancellation_token.is_cancelled() { @@ -968,6 +969,7 @@ impl LlmSession { if let Some(u) = chunk_usage { stream_prompt_tokens = u.prompt_tokens; stream_completion_tokens = u.completion_tokens; + stream_cached_tokens = u.cached_tokens; } // Content is emitted before ToolCallDelta in // the same parse batch, so look ahead: a @@ -1106,6 +1108,7 @@ impl LlmSession { self.record_usage(crate::usage::TokenUsage { prompt_tokens: stream_prompt_tokens, completion_tokens: stream_completion_tokens, + cached_tokens: stream_cached_tokens, }); } @@ -1503,17 +1506,18 @@ impl LlmSession { messages.push(ChatMessage::tool_result(&tc.id, output.clone())); // Sub-agent cancel is shown by the shell as `shell.interrupted` // (same as Ctrl+C); do not return the tool string as AI body. - let suppress_body = result.meta.as_ref().is_some_and(|meta| { - matches!( - meta.get("reason").and_then(|v| v.as_str()), - Some("sub_agent_cancelled") | Some("user_cancelled") - ) - }); - let text = if self.security_notice_callback.is_some() || suppress_body { - String::new() - } else { - output - }; + // Sub-agent fatal, however, carries a user-facing failure summary + // (error category + completed tool steps) that MUST be surfaced — + // suppressing it leaves the terminal silent (#579). Only cancel / + // user-cancel reasons suppress the body; `security_notice_callback` + // being set must not blanket-suppress fatal summaries. + let reason = result + .meta + .as_ref() + .and_then(|m| m.get("reason")) + .and_then(|v| v.as_str()); + let suppress_body = matches!(reason, Some("sub_agent_cancelled" | "user_cancelled")); + let text = if suppress_body { String::new() } else { output }; let new_messages = messages[initial_len..].to_vec(); return Some(crate::types::ProcessResult { text, new_messages }); } @@ -3994,6 +3998,113 @@ mod tests { ); } + /// Agent tool that short-circuits with `sub_agent_fatal`, carrying a + /// structured failure summary (error category + completed tool steps) in + /// its `output`. Mirrors the real `AgentTool::fatal_tool_result` shape so + /// the suppress-logic regression (#579) is exercised end-to-end through + /// `process_tool_call_result`. + struct FatalAgent; + + impl Tool for FatalAgent { + fn name(&self) -> &str { + "Agent" + } + fn description(&self) -> &str { + "fatal probe" + } + fn parameters(&self) -> serde_json::Value { + serde_json::json!({"type": "object", "properties": {}}) + } + fn execute(&self, _args: serde_json::Value) -> crate::types::ToolResult { + crate::types::ToolResult { + ok: false, + output: "Sub-agent failed (api_error): 401 Unauthorized\n\nCompleted before failure:\n 0. grep: found 3 matches".into(), + meta: Some(serde_json::json!({ + "dispatch_status": "short_circuit", + "reason": "sub_agent_fatal", + "error_category": "api_error", + "completed_tools": ["grep"], + })), + } + } + } + + /// Regression for #579: when a sub-agent enters Fatal, the parent loop + /// short-circuits but MUST surface the failure summary (error category + + /// completed tool steps) in `ProcessResult.text`. Previously + /// `security_notice_callback` being set blanket-suppressed every + /// short-circuit `text` to empty, leaving the terminal silent. + #[tokio::test] + async fn sub_agent_fatal_text_not_suppressed_with_security_notice_callback() { + use crate::agents::mock_tool_call_response; + + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); + session.set_context_budget_policy(ContextBudgetPolicy { + enabled: false, + ..Default::default() + }); + // Mirror the real shell: a registered security_notice_callback must + // NOT cause fatal summaries to be blanked out. + session.set_security_notice_callback(Arc::new(|_ctx| {})); + session + .set_test_chat_responses(vec![Ok(mock_tool_call_response(&[("c1", "Agent", "{}")]))]); + session.register_tool(Box::new(FatalAgent)); + + let result = session + .process_input(&ChatMessage::user("run sub-agent"), &[], Some("sys"), false) + .await + .expect("process_input should succeed"); + + // The failure category must reach the caller (terminal display). + assert!( + result.text.contains("Sub-agent failed (api_error)"), + "fatal text must contain the error category, got: {:?}", + result.text + ); + // The completed-tool summary must also survive. + assert!( + result.text.contains("Completed before failure"), + "fatal text must list completed work, got: {:?}", + result.text + ); + assert!( + result.text.contains("grep"), + "fatal text must name the completed tool, got: {:?}", + result.text + ); + } + + /// Cancellation (sub_agent_cancelled) MUST still suppress the body text — + /// the #579 fix only relaxes suppression for `sub_agent_fatal`, not cancel. + #[tokio::test] + async fn sub_agent_cancelled_text_still_suppressed() { + use crate::agents::mock_tool_call_response; + + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); + session.set_context_budget_policy(ContextBudgetPolicy { + enabled: false, + ..Default::default() + }); + session.set_security_notice_callback(Arc::new(|_ctx| {})); + session.set_test_chat_responses(vec![Ok(mock_tool_call_response(&[( + "c1", + "Agent", + r#"{"cancel":true}"#, + )]))]); + session.register_tool(Box::new(ShortCircuitAgent)); + + let result = session + .process_input(&ChatMessage::user("run sub-agent"), &[], Some("sys"), false) + .await + .expect("process_input should succeed"); + + assert!( + result.text.is_empty(), + "cancelled short-circuit text must be suppressed, got: {:?}", + result.text + ); + } + #[test] fn sanitize_tool_pairs_repairs_dangling_sequences() { let mut assistant = ChatMessage::assistant("working on it"); diff --git a/crates/aish-llm/src/usage.rs b/crates/aish-llm/src/usage.rs index 31916911..054c5282 100644 --- a/crates/aish-llm/src/usage.rs +++ b/crates/aish-llm/src/usage.rs @@ -1,41 +1,67 @@ /// Token usage from a single LLM API response. +/// +/// `cached_tokens` is the subset of `prompt_tokens` served from a provider +/// cache (OpenAI `prompt_tokens_details.cached_tokens`, Anthropic +/// `cache_read_input_tokens`). It is NOT billed as fresh consumption, so +/// [`TokenStats::record`] subtracts it from the running input total to avoid +/// inflated `/token` numbers (#579 sister fix). #[derive(Debug, Default, Clone)] pub struct TokenUsage { pub prompt_tokens: u64, pub completion_tokens: u64, + pub cached_tokens: u64, } impl TokenUsage { /// Extract token usage from an OpenAI-compatible API response JSON. /// - /// Looks for `usage.prompt_tokens` and `usage.completion_tokens`. + /// Looks for `usage.prompt_tokens` and `usage.completion_tokens`, plus the + /// optional `usage.prompt_tokens_details.cached_tokens` cache-hit count. /// Returns default (zeroed) if the fields are missing. pub fn from_response_json(json: &serde_json::Value) -> Self { let usage = json.get("usage"); + let prompt_tokens = usage + .and_then(|u| u.get("prompt_tokens")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let cached_tokens = usage + .and_then(|u| u.get("prompt_tokens_details")) + .and_then(|d| d.get("cached_tokens")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); Self { - prompt_tokens: usage - .and_then(|u| u.get("prompt_tokens")) - .and_then(|v| v.as_u64()) - .unwrap_or(0), + prompt_tokens, completion_tokens: usage .and_then(|u| u.get("completion_tokens")) .and_then(|v| v.as_u64()) .unwrap_or(0), + cached_tokens, } } /// Extract token usage from an Anthropic Messages API response JSON. + /// + /// `usage.cache_read_input_tokens` counts prompt tokens served from the + /// Anthropic prompt cache; `cache_creation_input_tokens` are the tokens + /// written into the cache this turn. Only cache reads avoid fresh billing, + /// so `cached_tokens` tracks reads only. pub fn from_anthropic_json(json: &serde_json::Value) -> Self { let usage = json.get("usage"); + let prompt_tokens = usage + .and_then(|u| u.get("input_tokens")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let cached_tokens = usage + .and_then(|u| u.get("cache_read_input_tokens")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); Self { - prompt_tokens: usage - .and_then(|u| u.get("input_tokens")) - .and_then(|v| v.as_u64()) - .unwrap_or(0), + prompt_tokens, completion_tokens: usage .and_then(|u| u.get("output_tokens")) .and_then(|v| v.as_u64()) .unwrap_or(0), + cached_tokens, } } } @@ -49,12 +75,24 @@ pub struct TokenStats { /// Prompt tokens from the most recent API call — the actual context /// window consumption at the current conversation depth. pub last_prompt_tokens: u64, + /// Cumulative prompt tokens served from a provider cache (OpenAI + /// `cached_tokens` / Anthropic `cache_read_input_tokens`). Tracked + /// separately so `/token` can show cache hits without double-counting + /// them in `total_input` (#579 sister fix). + pub cached_input: u64, } impl TokenStats { /// Record a single API call's token usage. + /// + /// `total_input` accumulates only the non-cached portion of + /// `prompt_tokens` so `/token` reflects real billed consumption, not + /// the full prompt size that includes cache hits. `last_prompt_tokens` + /// still records the full prompt size (the real context-window depth). pub fn record(&mut self, usage: TokenUsage) { - self.total_input += usage.prompt_tokens; + let billed_input = usage.prompt_tokens.saturating_sub(usage.cached_tokens); + self.total_input += billed_input; + self.cached_input += usage.cached_tokens; self.total_output += usage.completion_tokens; self.last_prompt_tokens = usage.prompt_tokens; self.request_count += 1; @@ -67,10 +105,11 @@ impl TokenStats { pub fn merge_totals(&mut self, other: &TokenStats) { self.total_input += other.total_input; self.total_output += other.total_output; + self.cached_input += other.cached_input; self.request_count += other.request_count; } - /// Total tokens consumed (input + output). + /// Total tokens consumed (billed input + output, excluding cache hits). pub fn total_tokens(&self) -> u64 { self.total_input + self.total_output } @@ -85,12 +124,15 @@ mod tests { let json = serde_json::json!({ "usage": { "input_tokens": 120, - "output_tokens": 40 + "output_tokens": 40, + "cache_read_input_tokens": 80, + "cache_creation_input_tokens": 10 } }); let usage = TokenUsage::from_anthropic_json(&json); assert_eq!(usage.prompt_tokens, 120); assert_eq!(usage.completion_tokens, 40); + assert_eq!(usage.cached_tokens, 80); } #[test] @@ -98,12 +140,16 @@ mod tests { let json = serde_json::json!({ "usage": { "prompt_tokens": 150, - "completion_tokens": 50 + "completion_tokens": 50, + "prompt_tokens_details": { + "cached_tokens": 90 + } } }); let usage = TokenUsage::from_response_json(&json); assert_eq!(usage.prompt_tokens, 150); assert_eq!(usage.completion_tokens, 50); + assert_eq!(usage.cached_tokens, 90); } #[test] @@ -112,6 +158,7 @@ mod tests { let usage = TokenUsage::from_response_json(&json); assert_eq!(usage.prompt_tokens, 0); assert_eq!(usage.completion_tokens, 0); + assert_eq!(usage.cached_tokens, 0); } #[test] @@ -120,14 +167,20 @@ mod tests { stats.record(TokenUsage { prompt_tokens: 100, completion_tokens: 50, + cached_tokens: 0, }); stats.record(TokenUsage { prompt_tokens: 200, completion_tokens: 80, + cached_tokens: 60, }); - assert_eq!(stats.total_input, 300); + // total_input excludes cache hits: (100 - 0) + (200 - 60) = 240. + assert_eq!(stats.total_input, 240); + assert_eq!(stats.cached_input, 60); assert_eq!(stats.total_output, 130); assert_eq!(stats.request_count, 2); - assert_eq!(stats.total_tokens(), 430); + assert_eq!(stats.total_tokens(), 370); + // last_prompt_tokens keeps the full prompt size (context depth). + assert_eq!(stats.last_prompt_tokens, 200); } } diff --git a/crates/aish-shell/src/app.rs b/crates/aish-shell/src/app.rs index e3c80e6f..4cf53ab8 100644 --- a/crates/aish-shell/src/app.rs +++ b/crates/aish-shell/src/app.rs @@ -8913,6 +8913,13 @@ impl AishShell { aish_i18n::t("shell.token.output_tokens"), format_number(stats.total_output) ); + if stats.cached_input > 0 { + println!( + " {} {}", + aish_i18n::t("shell.token.cached"), + format_number(stats.cached_input) + ); + } println!( " {} {}", aish_i18n::t("shell.token.total"),