Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ repository.workspace = true

[features]
default = []
full = ["a2a", "discord", "gateway", "index", "slack", "tui"]
full = ["a2a", "discord", "gateway", "index", "mock", "slack", "tui"]
a2a = ["dep:zeph-a2a", "zeph-a2a?/server"]
candle = ["zeph-llm/candle", "zeph-core/candle"]
metal = ["zeph-llm/metal", "zeph-core/metal"]
Expand Down
23 changes: 23 additions & 0 deletions crates/zeph-core/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,24 @@ impl<C: Channel, T: ToolExecutor> Agent<C, T> {
manager.shutdown_all_shared().await;
}

if let Some(ref tx) = self.metrics_tx {
let m = tx.borrow();
if m.filter_applications > 0 {
#[allow(clippy::cast_precision_loss)]
let pct = if m.filter_raw_tokens > 0 {
m.filter_saved_tokens as f64 / m.filter_raw_tokens as f64 * 100.0
} else {
0.0
};
tracing::info!(
raw_tokens = m.filter_raw_tokens,
saved_tokens = m.filter_saved_tokens,
applications = m.filter_applications,
"tool output filtering saved ~{} tokens ({pct:.0}%)",
m.filter_saved_tokens,
);
}
}
tracing::info!("agent shutdown complete");
}

Expand Down Expand Up @@ -1139,6 +1157,7 @@ pub(super) mod agent_tests {
tool_name: "bash".to_string(),
summary: "tool executed successfully".to_string(),
blocks_executed: 1,
filter_stats: None,
}))]);

let agent_channel = MockChannel::new(vec!["execute tool".to_string()]);
Expand Down Expand Up @@ -1336,6 +1355,7 @@ pub(super) mod agent_tests {
tool_name: "bash".to_string(),
summary: "[error] command failed [exit code 1]".to_string(),
blocks_executed: 1,
filter_stats: None,
})),
Ok(None),
]);
Expand All @@ -1355,6 +1375,7 @@ pub(super) mod agent_tests {
tool_name: "bash".to_string(),
summary: " ".to_string(),
blocks_executed: 1,
filter_stats: None,
}))]);

let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
Expand Down Expand Up @@ -1446,6 +1467,7 @@ pub(super) mod agent_tests {
tool_name: "bash".to_string(),
summary: "step 1 complete".to_string(),
blocks_executed: 1,
filter_stats: None,
})),
Ok(None),
]);
Expand Down Expand Up @@ -1473,6 +1495,7 @@ pub(super) mod agent_tests {
tool_name: "bash".to_string(),
summary: "continuing".to_string(),
blocks_executed: 1,
filter_stats: None,
})));
}
let executor = MockToolExecutor::new(outputs);
Expand Down
10 changes: 10 additions & 0 deletions crates/zeph-core/src/agent/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,13 +251,23 @@ impl<C: Channel, T: ToolExecutor> Agent<C, T> {
}

/// Returns `true` if the tool loop should continue.
#[allow(clippy::too_many_lines)]
pub(crate) async fn handle_tool_result(
&mut self,
response: &str,
result: Result<Option<ToolOutput>, ToolError>,
) -> Result<bool, super::error::AgentError> {
match result {
Ok(Some(output)) => {
if let Some(ref fs) = output.filter_stats {
let saved = fs.estimated_tokens_saved() as u64;
let raw = (fs.raw_chars / 4) as u64;
self.update_metrics(|m| {
m.filter_raw_tokens += raw;
m.filter_saved_tokens += saved;
m.filter_applications += 1;
});
}
if output.summary.trim().is_empty() {
tracing::warn!("tool execution returned empty output");
self.record_skill_outcomes("success", None).await;
Expand Down
22 changes: 22 additions & 0 deletions crates/zeph-core/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ pub struct MetricsSnapshot {
pub cache_read_tokens: u64,
pub cache_creation_tokens: u64,
pub cost_spent_cents: f64,
pub filter_raw_tokens: u64,
pub filter_saved_tokens: u64,
pub filter_applications: u64,
}

pub struct MetricsCollector {
Expand Down Expand Up @@ -89,6 +92,25 @@ mod tests {
assert_eq!(cloned.provider_name, "ollama");
}

#[test]
fn filter_metrics_tracking() {
let (collector, rx) = MetricsCollector::new();
collector.update(|m| {
m.filter_raw_tokens += 250;
m.filter_saved_tokens += 200;
m.filter_applications += 1;
});
collector.update(|m| {
m.filter_raw_tokens += 100;
m.filter_saved_tokens += 80;
m.filter_applications += 1;
});
let s = rx.borrow();
assert_eq!(s.filter_raw_tokens, 350);
assert_eq!(s.filter_saved_tokens, 280);
assert_eq!(s.filter_applications, 2);
}

#[test]
fn summaries_count_tracks_summarizations() {
let (collector, rx) = MetricsCollector::new();
Expand Down
1 change: 1 addition & 0 deletions crates/zeph-mcp/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ impl ToolExecutor for McpToolExecutor {
tool_name: "mcp".to_owned(),
summary: outputs.join("\n\n"),
blocks_executed,
filter_stats: None,
}))
}
}
Expand Down
4 changes: 4 additions & 0 deletions crates/zeph-tools/src/composite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ mod tests {
tool_name: "test".to_owned(),
summary: "matched".to_owned(),
blocks_executed: 1,
filter_stats: None,
}))
}
}
Expand Down Expand Up @@ -89,6 +90,7 @@ mod tests {
tool_name: "test".to_owned(),
summary: "second".to_owned(),
blocks_executed: 1,
filter_stats: None,
}))
}
}
Expand Down Expand Up @@ -164,6 +166,7 @@ mod tests {
tool_name: call.tool_id.clone(),
summary: "file_handler".to_owned(),
blocks_executed: 1,
filter_stats: None,
}))
} else {
Ok(None)
Expand All @@ -186,6 +189,7 @@ mod tests {
tool_name: "bash".to_owned(),
summary: "shell_handler".to_owned(),
blocks_executed: 1,
filter_stats: None,
}))
} else {
Ok(None)
Expand Down
49 changes: 49 additions & 0 deletions crates/zeph-tools/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,36 @@ pub struct ToolCall {
pub params: HashMap<String, serde_json::Value>,
}

/// Cumulative filter statistics for a single tool execution.
#[derive(Debug, Clone, Default)]
pub struct FilterStats {
pub raw_chars: usize,
pub filtered_chars: usize,
}

impl FilterStats {
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn savings_pct(&self) -> f64 {
if self.raw_chars == 0 {
return 0.0;
}
(1.0 - self.filtered_chars as f64 / self.raw_chars as f64) * 100.0
}

#[must_use]
pub fn estimated_tokens_saved(&self) -> usize {
self.raw_chars.saturating_sub(self.filtered_chars) / 4
}
}

/// Structured result from tool execution.
#[derive(Debug, Clone)]
pub struct ToolOutput {
pub tool_name: String,
pub summary: String,
pub blocks_executed: u32,
pub filter_stats: Option<FilterStats>,
}

impl fmt::Display for ToolOutput {
Expand Down Expand Up @@ -150,6 +174,7 @@ mod tests {
tool_name: "bash".to_owned(),
summary: "$ echo hello\nhello".to_owned(),
blocks_executed: 1,
filter_stats: None,
};
assert_eq!(output.to_string(), "$ echo hello\nhello");
}
Expand Down Expand Up @@ -241,4 +266,28 @@ mod tests {
let result = exec.execute_tool_call(&call).await.unwrap();
assert!(result.is_none());
}

#[test]
fn filter_stats_savings_pct() {
let fs = FilterStats {
raw_chars: 1000,
filtered_chars: 200,
};
assert!((fs.savings_pct() - 80.0).abs() < 0.01);
}

#[test]
fn filter_stats_savings_pct_zero() {
let fs = FilterStats::default();
assert!((fs.savings_pct()).abs() < 0.01);
}

#[test]
fn filter_stats_estimated_tokens_saved() {
let fs = FilterStats {
raw_chars: 1000,
filtered_chars: 200,
};
assert_eq!(fs.estimated_tokens_saved(), 200); // (1000 - 200) / 4
}
}
5 changes: 5 additions & 0 deletions crates/zeph-tools/src/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ impl FileExecutor {
tool_name: "read".to_owned(),
summary: selected.join("\n"),
blocks_executed: 1,
filter_stats: None,
}))
}

Expand All @@ -163,6 +164,7 @@ impl FileExecutor {
tool_name: "write".to_owned(),
summary: format!("Wrote {} bytes to {path_str}", content.len()),
blocks_executed: 1,
filter_stats: None,
}))
}

Expand Down Expand Up @@ -190,6 +192,7 @@ impl FileExecutor {
tool_name: "edit".to_owned(),
summary: format!("Edited {path_str}"),
blocks_executed: 1,
filter_stats: None,
}))
}

Expand Down Expand Up @@ -221,6 +224,7 @@ impl FileExecutor {
matches.join("\n")
},
blocks_executed: 1,
filter_stats: None,
}))
}

Expand Down Expand Up @@ -262,6 +266,7 @@ impl FileExecutor {
results.join("\n")
},
blocks_executed: 1,
filter_stats: None,
}))
}
}
Expand Down
4 changes: 2 additions & 2 deletions crates/zeph-tools/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ pub use audit::{AuditEntry, AuditLogger, AuditResult};
pub use composite::CompositeExecutor;
pub use config::{AuditConfig, ScrapeConfig, ShellConfig, ToolsConfig};
pub use executor::{
MAX_TOOL_OUTPUT_CHARS, ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor, ToolOutput,
truncate_tool_output,
FilterStats, MAX_TOOL_OUTPUT_CHARS, ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor,
ToolOutput, truncate_tool_output,
};
pub use file::FileExecutor;
pub use filter::{FilterConfig, FilterResult, OutputFilter, OutputFilterRegistry, sanitize_output};
Expand Down
2 changes: 2 additions & 0 deletions crates/zeph-tools/src/scrape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ impl ToolExecutor for WebScrapeExecutor {
tool_name: "web-scrape".to_owned(),
summary: outputs.join("\n\n"),
blocks_executed,
filter_stats: None,
}))
}

Expand Down Expand Up @@ -132,6 +133,7 @@ impl ToolExecutor for WebScrapeExecutor {
tool_name: "web-scrape".to_owned(),
summary: result,
blocks_executed: 1,
filter_stats: None,
}))
}
}
Expand Down
10 changes: 9 additions & 1 deletion crates/zeph-tools/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use schemars::JsonSchema;

use crate::audit::{AuditEntry, AuditLogger, AuditResult};
use crate::config::ShellConfig;
use crate::executor::{ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor, ToolOutput};
use crate::executor::{
FilterStats, ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor, ToolOutput,
};
use crate::filter::{OutputFilterRegistry, sanitize_output};
use crate::permissions::{PermissionAction, PermissionPolicy};

Expand Down Expand Up @@ -130,6 +132,7 @@ impl ShellExecutor {
}

let mut outputs = Vec::with_capacity(blocks.len());
let mut cumulative_filter_stats: Option<FilterStats> = None;
#[allow(clippy::cast_possible_truncation)]
let blocks_executed = blocks.len() as u32;

Expand Down Expand Up @@ -224,6 +227,10 @@ impl ShellExecutor {
savings_pct = fr.savings_pct(),
"output filter applied"
);
let stats =
cumulative_filter_stats.get_or_insert_with(FilterStats::default);
stats.raw_chars += fr.raw_chars;
stats.filtered_chars += fr.filtered_chars;
fr.output
}
None => sanitized,
Expand All @@ -238,6 +245,7 @@ impl ShellExecutor {
tool_name: "bash".to_owned(),
summary: outputs.join("\n\n"),
blocks_executed,
filter_stats: cumulative_filter_stats,
}))
}

Expand Down
1 change: 1 addition & 0 deletions crates/zeph-tools/src/trust_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ mod tests {
tool_name: call.tool_id.clone(),
summary: "ok".into(),
blocks_executed: 1,
filter_stats: None,
}))
}
}
Expand Down
12 changes: 12 additions & 0 deletions crates/zeph-tui/src/widgets/resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect) {
metrics.cache_read_tokens
)));
}
if metrics.filter_applications > 0 {
#[allow(clippy::cast_precision_loss)]
let pct = if metrics.filter_raw_tokens > 0 {
metrics.filter_saved_tokens as f64 / metrics.filter_raw_tokens as f64 * 100.0
} else {
0.0
};
res_lines.push(Line::from(format!(
" Filter saved: {} tok ({pct:.0}%)",
metrics.filter_saved_tokens,
)));
}
let resources = Paragraph::new(res_lines).block(
Block::default()
.borders(Borders::ALL)
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,7 @@ mod tests {
use std::path::Path;
use zeph_core::channel::Channel;
use zeph_core::config::ProviderKind;
#[cfg(feature = "a2a")]
use zeph_llm::ollama::OllamaProvider;

#[tokio::test]
Expand Down
Loading
Loading