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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]

### Added
- Configurable tool response offload — `OverflowConfig` with threshold (default 50k chars), retention (7 days), optional custom dir (#791)
- `[tools.overflow]` section in `config.toml` for offload configuration
- Security hardening: path canonicalization, symlink-safe cleanup, 0o600 file permissions on Unix
- Wire `AcpContext` (IDE-proxied FS, shell, permissions) through `AgentSpawner` into agent tool chain via `CompositeExecutor` — ACP executors take priority with automatic local fallback (#779)
- `DynExecutor` newtype in `zeph-tools` for object-safe `ToolExecutor` composition in `CompositeExecutor` (#779)
- `cancel_signal: Arc<Notify>` on `LoopbackHandle` for cooperative cancellation between ACP sessions and agent loop (#780)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
Most AI agent frameworks dump every tool description, skill, and raw output into the context window — and bill you for it. Zeph takes the opposite approach: **automated context engineering**. Only relevant data enters the context. The result — lower costs, faster responses, and an agent that runs on hardware you already have.

- **Semantic skill selection** — embeds skills as vectors, retrieves only top-K relevant per query instead of injecting all
- **Smart output filtering** — command-aware filters strip 70-99% of noise before context injection
- **Smart output filtering** — command-aware filters strip 70-99% of noise before context injection; oversized responses offloaded to filesystem
- **Two-tier context pruning** — selective eviction + adaptive chunked compaction with parallel summarization keeps the window clean
- **Proportional budget allocation** — context space distributed by purpose, not arrival order

Expand Down
9 changes: 9 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,15 @@ enabled = true
# [tools.permissions]
# shell = [{ pattern = "/tmp/*", action = "allow" }, { pattern = "/etc/*", action = "deny" }]

[tools.overflow]
# Offload large tool responses to filesystem instead of truncating in-memory.
# Characters threshold above which output is saved to a file (default: 50000)
threshold = 50000
# Days to retain offloaded files before cleanup on next startup (default: 7)
retention_days = 7
# Optional custom directory for overflow files (default: ~/.zeph/data/tool-output)
# dir = "/custom/path/to/overflow"

[tools.audit]
# Enable audit logging for tool executions
enabled = false
Expand Down
6 changes: 6 additions & 0 deletions crates/zeph-core/src/agent/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ impl<C: Channel> Agent<C> {
self
}

#[must_use]
pub fn with_overflow_config(mut self, config: zeph_tools::OverflowConfig) -> Self {
self.runtime.overflow_config = config;
self
}

#[must_use]
pub fn with_summary_provider(mut self, provider: AnyProvider) -> Self {
self.summary_provider = Some(provider);
Expand Down
53 changes: 49 additions & 4 deletions crates/zeph-core/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ pub(super) struct RuntimeConfig {
pub(super) permission_policy: zeph_tools::PermissionPolicy,
pub(super) redact_credentials: bool,
pub(super) token_safety_margin: f32,
pub(super) overflow_config: zeph_tools::OverflowConfig,
}

pub struct Agent<C: Channel> {
Expand Down Expand Up @@ -238,6 +239,7 @@ impl<C: Channel> Agent<C> {
permission_policy: zeph_tools::PermissionPolicy::default(),
redact_credentials: true,
token_safety_margin: 1.0,
overflow_config: zeph_tools::OverflowConfig::default(),
},
learning_config: None,
reflection_used: false,
Expand Down Expand Up @@ -1861,15 +1863,48 @@ pub(super) mod agent_tests {
}

#[tokio::test]
async fn test_maybe_summarize_long_output_disabled_truncates() {
async fn test_overflow_notice_contains_filename() {
let dir = tempfile::tempdir().unwrap();
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();

let agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_tool_summarization(false);
.with_tool_summarization(false)
.with_overflow_config(zeph_tools::OverflowConfig {
threshold: 100,
retention_days: 7,
dir: Some(dir.path().to_path_buf()),
});

let long = "x".repeat(zeph_tools::MAX_TOOL_OUTPUT_CHARS + 1000);
let result = agent.maybe_summarize_tool_output(&long).await;
assert!(result.contains("full output saved to"));
// Notice must contain only filename (UUID.txt), not a full path
let notice_start = result.find("full output saved to").unwrap();
let notice_part = &result[notice_start..];
assert!(notice_part.contains(".txt"));
assert!(!notice_part.contains('/'));
}

#[tokio::test]
async fn test_maybe_summarize_long_output_disabled_truncates() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();

let agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_tool_summarization(false)
.with_overflow_config(zeph_tools::OverflowConfig {
threshold: 1000,
retention_days: 7,
dir: None,
});

// Must exceed both overflow threshold (1000) and MAX_TOOL_OUTPUT_CHARS (30_000)
// so that truncate_tool_output produces the "truncated" marker.
let long = "x".repeat(zeph_tools::MAX_TOOL_OUTPUT_CHARS + 1000);
let result = agent.maybe_summarize_tool_output(&long).await;
assert!(result.contains("truncated"));
Expand All @@ -1883,7 +1918,12 @@ pub(super) mod agent_tests {
let executor = MockToolExecutor::no_tools();

let agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_tool_summarization(true);
.with_tool_summarization(true)
.with_overflow_config(zeph_tools::OverflowConfig {
threshold: 1000,
retention_days: 7,
dir: None,
});

let long = "x".repeat(zeph_tools::MAX_TOOL_OUTPUT_CHARS + 1000);
let result = agent.maybe_summarize_tool_output(&long).await;
Expand All @@ -1900,7 +1940,12 @@ pub(super) mod agent_tests {
let executor = MockToolExecutor::no_tools();

let agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_tool_summarization(true);
.with_tool_summarization(true)
.with_overflow_config(zeph_tools::OverflowConfig {
threshold: 1000,
retention_days: 7,
dir: None,
});

let long = "x".repeat(zeph_tools::MAX_TOOL_OUTPUT_CHARS + 1000);
let result = agent.maybe_summarize_tool_output(&long).await;
Expand Down
11 changes: 5 additions & 6 deletions crates/zeph-core/src/agent/tool_execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,14 +324,13 @@ impl<C: Channel> Agent<C> {
}

pub(super) async fn maybe_summarize_tool_output(&self, output: &str) -> String {
if output.len() <= zeph_tools::MAX_TOOL_OUTPUT_CHARS {
if output.len() <= self.runtime.overflow_config.threshold {
return output.to_string();
}
let overflow_notice = if let Some(path) = zeph_tools::save_overflow(output) {
format!(
"\n[full output saved to {}, use read tool to access]",
path.display()
)
let overflow_notice = if let Some(filename) =
zeph_tools::save_overflow(output, &self.runtime.overflow_config)
{
format!("\n[full output saved to {filename}, use read tool to access]")
} else {
String::new()
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
source: crates/zeph-core/src/config/types.rs
assertion_line: 1207
expression: toml_str
---
[agent]
Expand Down Expand Up @@ -99,6 +100,10 @@ enabled = true
enabled = true
extra_patterns = []

[tools.overflow]
threshold = 50000
retention_days = 7

[a2a]
enabled = false
host = "0.0.0.0"
Expand Down
4 changes: 2 additions & 2 deletions crates/zeph-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ Defines the `ToolExecutor` trait for sandboxed tool invocation and ships concret
| `registry` | Tool registry and discovery |
| `trust_gate` | Trust-based tool access control |
| `anomaly` | `AnomalyDetector` — unusual execution pattern detection |
| `overflow` | Output overflow handling |
| `config` | Per-tool TOML configuration |
| `overflow` | Large output offload to filesystem — configurable threshold (default 50K chars), retention-based cleanup with symlink-safe deletion, 0o600 file permissions on Unix, path canonicalization |
| `config` | Per-tool TOML configuration; `OverflowConfig` for `[tools.overflow]` section (threshold, retention_days, optional custom dir) |

**Re-exports:** `CompositeExecutor`, `AuditLogger`, `AnomalyDetector`

Expand Down
71 changes: 71 additions & 0 deletions crates/zeph-tools/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::permissions::{AutonomyLevel, PermissionPolicy, PermissionsConfig};
Expand Down Expand Up @@ -27,6 +29,35 @@ fn default_audit_destination() -> String {
"stdout".into()
}

fn default_overflow_threshold() -> usize {
50_000
}

fn default_retention_days() -> u64 {
7
}

/// Configuration for large tool response offload to filesystem.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OverflowConfig {
#[serde(default = "default_overflow_threshold")]
pub threshold: usize,
#[serde(default = "default_retention_days")]
pub retention_days: u64,
#[serde(default)]
pub dir: Option<PathBuf>,
}

impl Default for OverflowConfig {
fn default() -> Self {
Self {
threshold: default_overflow_threshold(),
retention_days: default_retention_days(),
dir: None,
}
}
}

/// Top-level configuration for tool execution.
#[derive(Debug, Deserialize, Serialize)]
pub struct ToolsConfig {
Expand All @@ -44,6 +75,8 @@ pub struct ToolsConfig {
pub permissions: Option<PermissionsConfig>,
#[serde(default)]
pub filters: crate::filter::FilterConfig,
#[serde(default)]
pub overflow: OverflowConfig,
}

impl ToolsConfig {
Expand Down Expand Up @@ -98,6 +131,7 @@ impl Default for ToolsConfig {
audit: AuditConfig::default(),
permissions: None,
filters: crate::filter::FilterConfig::default(),
overflow: OverflowConfig::default(),
}
}
}
Expand Down Expand Up @@ -362,4 +396,41 @@ mod tests {
assert!(!config.shell.confirm_patterns.is_empty());
assert!(policy.rules().contains_key("bash"));
}

#[test]
fn deserialize_overflow_config_full() {
let toml_str = r#"
[overflow]
threshold = 100000
retention_days = 14
dir = "/tmp/overflow"
"#;
let config: ToolsConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.overflow.threshold, 100_000);
assert_eq!(config.overflow.retention_days, 14);
assert_eq!(
config.overflow.dir.unwrap().to_str().unwrap(),
"/tmp/overflow"
);
}

#[test]
fn deserialize_overflow_config_partial_uses_defaults() {
let toml_str = r#"
[overflow]
threshold = 75000
"#;
let config: ToolsConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.overflow.threshold, 75_000);
assert_eq!(config.overflow.retention_days, 7);
assert!(config.overflow.dir.is_none());
}

#[test]
fn deserialize_overflow_config_omitted_uses_defaults() {
let config: ToolsConfig = toml::from_str("").unwrap();
assert_eq!(config.overflow.threshold, 50_000);
assert_eq!(config.overflow.retention_days, 7);
assert!(config.overflow.dir.is_none());
}
}
2 changes: 1 addition & 1 deletion crates/zeph-tools/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub mod trust_gate;
pub use anomaly::{AnomalyDetector, AnomalySeverity};
pub use audit::{AuditEntry, AuditLogger, AuditResult};
pub use composite::CompositeExecutor;
pub use config::{AuditConfig, ScrapeConfig, ShellConfig, ToolsConfig};
pub use config::{AuditConfig, OverflowConfig, ScrapeConfig, ShellConfig, ToolsConfig};
pub use executor::{
DiffData, DynExecutor, ErasedToolExecutor, FilterStats, MAX_TOOL_OUTPUT_CHARS, ToolCall,
ToolError, ToolEvent, ToolEventTx, ToolExecutor, ToolOutput, truncate_tool_output,
Expand Down
Loading
Loading