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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]
### Removed

- `zeph-core`: removed the `#[cfg(test)]`-only legacy sequential-dispatch harness in
`agent/tool_execution/tool_result.rs` (`handle_tool_result`, `record_tool_output_outcome`,
`process_successful_tool_output`, `handle_confirmation_required`) and the parallel legacy LLM
dispatch harness in `agent/tool_execution/llm_dispatch.rs` (`call_llm_with_timeout`,
`call_llm_non_streaming`, `call_llm_with_retry`) — both were superseded in production by
`process_one_tool_result`/`handle_confirmation_phase` (tier_loop.rs) and
`call_chat_with_tools_retry`/`call_chat_with_tools` respectively, but were kept around only to
keep their dependent tests green, which previously masked a real production gap (issue #6364).
All ~20 dependent tests across `parallel_and_handle_tests.rs`, `retry_and_skill_env_tests.rs`,
and `compaction_e2e.rs` were repointed to exercise the real production entry points
(`process_one_tool_result`, `stamp_and_send_tier_start`, `call_chat_with_tools_retry`,
`process_response`); response-cache tests were consolidated into the existing native-tool-use
cache coverage in `sanitize_and_native_tests.rs` (issue #6560).

### Fixed

- `zeph-core`/`zeph-skills`: the failure-driven self-learning path (`store_improved_version`)
Expand Down
2 changes: 1 addition & 1 deletion book/src/advanced/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ Text previews use safe UTF-8 truncation (`truncate_chars()`) that never splits a

## Reactive Retry on Context Length Errors

LLM calls in the agent loop (`call_llm_with_retry()` and `call_chat_with_tools_retry()`) intercept context length errors and automatically compact before retrying. The flow:
LLM calls in the agent loop (`call_chat_with_tools_retry()`) intercept context length errors and automatically compact before retrying. The flow:

1. Send messages to the LLM provider
2. If the provider returns a context length error, trigger `compact_context()`
Expand Down
8 changes: 4 additions & 4 deletions book/src/reference/security/untrusted-content-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,10 @@ The sanitizer is applied at every untrusted boundary:

| Source | Trust Level | Integration Point |
|--------|------------|-------------------|
| Shell / file tool results | `LocalUntrusted` | `handle_tool_result()` — both normal and confirmation-required paths |
| Web scrape output | `ExternalUntrusted` | `handle_tool_result()` |
| MCP tool responses | `ExternalUntrusted` | `handle_tool_result()` |
| A2A messages | `ExternalUntrusted` | `handle_tool_result()` |
| Shell / file tool results | `LocalUntrusted` | `process_one_tool_result()` — both normal and confirmation-resolved paths (`handle_confirmation_phase()`) |
| Web scrape output | `ExternalUntrusted` | `process_one_tool_result()` |
| MCP tool responses | `ExternalUntrusted` | `process_one_tool_result()` |
| A2A messages | `ExternalUntrusted` | `process_one_tool_result()` |
| Native tool-use results (Claude provider) | `LocalUntrusted` or `ExternalUntrusted` | `handle_native_tool_calls()` — routes through `sanitize_tool_output()` before placing output in `ToolResult` parts |
| Semantic memory recall | `ExternalUntrusted` | `prepare_context()` |
| Cross-session memory | `ExternalUntrusted` | `prepare_context()` |
Expand Down
2 changes: 1 addition & 1 deletion crates/zeph-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ The agent loop emits `StopHint` values that `ZephAcpAgent` maps to protocol-leve
1. **Before execution** — `SessionUpdate::ToolCall` with `status: InProgress` is sent as soon as tool invocation begins, enabling the IDE to display a running indicator.
2. **After execution** — `SessionUpdate::ToolCallUpdate` with `status: Completed` (or `Failed` on error) carries the output content and optional file locations.

Each tool call is identified by a UUID generated per invocation. The UUID is threaded through `LoopbackEvent::ToolStart` / `LoopbackEvent::ToolOutput` so the update correctly references the original announcement. Both the fenced-block execution path (`handle_tool_result`) and the structured parallel tool-call path emit this full two-step sequence unconditionally — output content always appears inside a tool call block in the IDE regardless of which path handled the tool.
Each tool call is identified by a UUID generated per invocation. The UUID is threaded through `LoopbackEvent::ToolStart` / `LoopbackEvent::ToolOutput` so the update correctly references the original announcement. The structured parallel tool-call path emits this full two-step sequence unconditionally — output content always appears inside a tool call block in the IDE.

**Terminal release ordering** — when a shell tool call embeds a terminal via `ToolCallContent::Terminal`, the ACP spec requires the terminal to remain alive until the IDE has processed the `tool_call_update` notification. `ZephAcpAgent` defers `terminal/release` until after all notifications for that event are dispatched. The deferred release is triggered from the `prompt()` event loop via `AcpShellExecutor::release_terminal()`, which is retained in `SessionEntry` for exactly this purpose.

Expand Down
2 changes: 1 addition & 1 deletion crates/zeph-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Core orchestration crate for the Zeph agent. Manages the main agent loop, bootst
| `agent::learning_engine` | `LearningEngine` — owns `LearningConfig`, tracks per-turn reflection state; delegates self-learning decisions to `is_enabled()` / `mark_reflection_used()` |
| `agent::feedback_detector` | `FeedbackDetector` (regex) and `JudgeDetector` (LLM-backed) — implicit correction detection from user messages with multi-language support (7 languages: English, Russian, Spanish, German, French, Portuguese, Chinese); `JudgeDetector` runs in background via `tokio::spawn` with sliding-window rate limiter (5 calls / 60 s) and XML-escaped adversarial-defense prompt; adaptive threshold gates judge invocation to the regex uncertainty zone |
| `agent::persistence` | Message persistence and background graph extraction integration; `maybe_spawn_graph_extraction` fires a `SemanticMemory::spawn_graph_extraction` task per user turn with injection-flag guard and last-4-user-messages context window |
| `agent::tool_execution` | Tool call handling, redaction, result processing; both the fenced-block path (`handle_tool_result`) and the structured tool-call path unconditionally emit `LoopbackEvent::ToolStart` (UUID generated per call) before execution and `LoopbackEvent::ToolOutput` (matching UUID, `is_error` flag) after; `call_llm_with_retry()` / `call_chat_with_tools_retry()` — auto-detect `ContextLengthExceeded`, compact context, and retry (max 2 attempts); `prune_stale_tool_outputs` invokes `count_tokens` once per `ToolResult` part |
| `agent::tool_execution` | Tool call handling, redaction, result processing; the structured tool-call path unconditionally emits `LoopbackEvent::ToolStart` (UUID generated per call, via `stamp_and_send_tier_start`) before execution and `LoopbackEvent::ToolOutput` (matching UUID, `is_error` flag, via `process_one_tool_result`) after; `call_chat_with_tools_retry()` — auto-detects `ContextLengthExceeded`, compacts context, and retries (max 2 attempts); `prune_stale_tool_outputs` invokes `count_tokens` once per `ToolResult` part |
| `agent::message_queue` | Message queue management |
| `agent::builder` | Agent builder API |
| `agent::commands` | Chat command dispatch (skills, feedback, skill management via `/skill install`, `/skill remove`, `/skill reject <name> <reason>`, sub-agent management via `/agent`, etc.) |
Expand Down
11 changes: 3 additions & 8 deletions crates/zeph-core/src/agent/tests/compaction_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,9 @@ async fn agent_recovers_from_context_length_exceeded_and_produces_response() {
metadata: MessageMetadata::default(),
});

// call_llm_with_retry is the direct entry point for the retry/compact flow
let result = agent.call_llm_with_retry(2).await.unwrap();

assert!(
result.is_some(),
"agent must produce a response after recovering from context length error"
);
assert_eq!(result.as_deref(), Some("final answer"));
// process_response is the real production entry point for the native tool loop, which
// internally retries via call_chat_with_tools_retry(_, 2) on a context-length error.
agent.process_response().await.unwrap();

// Verify the channel received the recovered response
let sent = agent.channel.sent_messages();
Expand Down
199 changes: 0 additions & 199 deletions crates/zeph-core/src/agent/tool_execution/llm_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
// SPDX-License-Identifier: MIT OR Apache-2.0

use tracing::Instrument;
#[cfg(test)]
use zeph_common::text::estimate_tokens;
use zeph_llm::provider::{
ChatResponse, LlmProvider, MessagePart, Role, ThinkingBlock, ToolDefinition,
};
Expand Down Expand Up @@ -352,201 +350,4 @@ impl<C: Channel> Agent<C> {
last.rebuild_content();
}
}

#[cfg(test)]
pub(super) async fn call_llm_with_timeout(
&mut self,
) -> Result<Option<String>, crate::agent::error::AgentError> {
if self.runtime.lifecycle.cancel_token.is_cancelled() {
return Ok(None);
}

if let Some(ref tracker) = self.runtime.metrics.cost_tracker
&& let Err(e) = tracker.check_budget()
{
self.update_metrics(|m| m.cost_budget_exhausted += 1);
self.channel
.send(&format!("Budget limit reached: {e}"))
.await?;
return Ok(None);
}

let query_embedding = match self.check_response_cache().await? {
super::CacheCheckResult::Hit(resp) => return Ok(Some(resp)),
super::CacheCheckResult::Miss { query_embedding } => query_embedding,
};

let llm_timeout = std::time::Duration::from_secs(self.runtime.config.timeouts.llm_seconds);
let start = std::time::Instant::now();
let prompt_estimate = self.runtime.providers.cached_prompt_tokens;

let memcot_state = match self.services.memory.extraction.memcot_accumulator.as_ref() {
Some(acc) => acc.current_state().await,
None => None,
};
let dump_id =
self.runtime
.debug
.debug_dumper
.as_ref()
.map(|d: &crate::debug_dump::DebugDumper| {
let provider_request = if d.is_trace_format() {
serde_json::Value::Null
} else {
self.provider.debug_request_json(
&self.msg.messages,
&[],
self.provider.supports_streaming(),
)
};
d.dump_request(&crate::debug_dump::RequestDebugDump {
model_name: &self.runtime.config.model_name,
messages: &self.msg.messages,
tools: &[],
provider_request,
memcot_state: memcot_state.as_deref(),
})
});

let trace_guard = self.runtime.debug.trace_collector.as_ref().and_then(|tc| {
self.runtime
.debug
.current_iteration_span_id
.map(|id| tc.begin_llm_request(id))
});

let llm_span = tracing::info_span!(
"llm.turn_call",
model = %self.runtime.config.model_name,
provider = self.provider.name(),
);
let result = self
.call_llm_non_streaming(
llm_timeout,
start,
prompt_estimate,
dump_id,
llm_span,
query_embedding,
)
.await;

if let Some(guard) = trace_guard
&& let Some(ref mut tc) = self.runtime.debug.trace_collector
{
let latency = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let (prompt_tokens, completion_tokens) =
self.provider.last_usage().unwrap_or((prompt_estimate, 0));
tc.end_llm_request(
guard,
&crate::debug_dump::trace::LlmAttributes {
model: self.runtime.config.model_name.clone(),
prompt_tokens,
completion_tokens,
latency_ms: latency,
streaming: false,
cache_hit: false,
},
);
}

result
}

#[cfg(test)]
async fn call_llm_non_streaming(
&mut self,
llm_timeout: std::time::Duration,
start: std::time::Instant,
prompt_estimate: u64,
dump_id: Option<u32>,
llm_span: tracing::Span,
query_embedding: Option<Vec<f32>>,
) -> Result<Option<String>, crate::agent::error::AgentError> {
let cancel = self.runtime.lifecycle.cancel_token.clone();
let chat_fut = self.provider.chat(&self.msg.messages).instrument(llm_span);
let result = tokio::select! {
r = tokio::time::timeout(llm_timeout, chat_fut) => r,
() = cancel.cancelled() => {
tracing::info!("LLM call cancelled by user");
self.update_metrics(|m| m.cancellations += 1);
self.channel.send("[Cancelled]").await?;
return Ok(None);
}
};
match result {
Ok(Ok(resp)) => {
let elapsed = start.elapsed();
let latency = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
let completion_heuristic = estimate_tokens(&resp) as u64;
let (final_prompt, final_completion) = self
.provider
.last_usage()
.unwrap_or((prompt_estimate, completion_heuristic));
let reasoning = self.provider.last_reasoning_tokens().unwrap_or(0);
self.update_metrics(|m| {
m.api_calls += 1;
m.last_llm_latency_ms = latency;
m.context_tokens = final_prompt;
m.prompt_tokens += final_prompt;
m.completion_tokens += final_completion;
m.total_tokens = m.prompt_tokens + m.completion_tokens;
m.reasoning_tokens += reasoning;
});
self.record_cost_and_cache(final_prompt, final_completion);
self.record_successful_task();
if let Some(ref recorder) = self.runtime.metrics.histogram_recorder {
recorder.observe_llm_latency(elapsed);
}
if self.run_response_verification(&resp) {
let _ = self
.channel
.send("[security] Response blocked by injection detection.")
.await;
return Ok(None);
}
let cleaned = self.scan_output_and_warn(&resp);
if let (Some(d), Some(id)) = (self.runtime.debug.debug_dumper.as_ref(), dump_id) {
d.dump_response(id, &cleaned);
}
let display = self.maybe_redact(&cleaned);
self.channel.send(&display).await?;
self.store_response_in_cache(&cleaned, query_embedding)
.await;
Ok(Some(cleaned))
}
Ok(Err(e)) => Err(e.into()),
Err(_) => {
self.channel
.send("LLM request timed out. Please try again.")
.await?;
Ok(None)
}
}
}

#[cfg(test)]
pub(in crate::agent) async fn call_llm_with_retry(
&mut self,
max_attempts: usize,
) -> Result<Option<String>, crate::agent::error::AgentError> {
for attempt in 0..max_attempts {
match self.call_llm_with_timeout().await {
Ok(result) => return Ok(result),
Err(e) if e.is_context_length_error() && attempt + 1 < max_attempts => {
tracing::warn!(
attempt,
"LLM context length exceeded, compacting and retrying"
);
self.channel
.send_status_best_effort("context too long, compacting...")
.await;
let _ = self.compact_context().await?;
self.channel.send_status_best_effort("").await;
}
Err(e) => return Err(e),
}
}
unreachable!("loop covers all attempts")
}
}
Loading
Loading