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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Tests for `SubAgentConfig` deserialization, skill injection with and without skills, secret approval and deny flows (#973, #967, #969)
- `zeph-acp`: LSP diagnostics content block (#962): `ContentBlock::Resource` with MIME `application/vnd.zed.diagnostics+json` formatted as `<diagnostics>file:line: [SEVERITY] message</diagnostics>` before the prompt; unknown MIME types logged and skipped
- `zeph-acp`: `AvailableCommandsUpdate` (#961): emitted after `new_session` and `load_session`; slash commands (`/help`, `/model`, `/mode`, `/clear`, `/compact`) dispatched without entering agent loop
- `zeph-acp`: `/compact` command (#979): triggers `AgentContext::compact_context()` via agent-loop sentinel; responds with compaction status or no-op message when history is below minimum threshold; emits `UsageUpdate` after compaction
- `zeph-acp`: `/model` fuzzy matching (#980): case-insensitive multi-token substring match against `available_models` after exact match fails; returns error with candidate list on ambiguous input
- `zeph-acp`: provider model auto-discovery (#983): `LlmProvider::list_models()` default method added; `discover_models_from_config()` auto-populates `available_models` at session start when the config list is empty; static config override takes precedence
- `zeph-core`: `AgentContext::clear_history()` clears in-memory conversation window and deletes session events from SQLite via `memory.delete_conversation()`
- `zeph-acp`: `UsageUpdate` via `unstable-session-usage` feature (#957): token usage emitted after each LLM turn via `LoopbackEvent::Usage`; `LlmProvider::last_usage()` added with `ClaudeProvider` implementation
- `zeph-acp`: `SetSessionModel` via `unstable-session-model` feature (#958): `set_session_model` implemented; validates model against allowed list and swaps provider override
- `zeph-acp`: `SessionInfoUpdate` via `unstable-session-info-update` feature (#959): title generated after first agent response; persisted to SQLite via migration `016_acp_session_title.sql`
Expand All @@ -30,6 +34,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `tests/integration.rs`: added missing `llm_request_timeout_secs` field in `TimeoutConfig` initializer (#956)
- `zeph-acp`: XML-escape `path`, `severity`, and `message` fields in diagnostics context block to prevent prompt injection (#962)
- `zeph-acp`: trim leading whitespace before slash-command prefix check to prevent bypass via `\n/command` input (#961)
- `zeph-acp`: `/clear` now sends a sentinel to the agent loop to also clear in-memory `AgentContext` state and reset the token counter (#981)

## [0.12.2] - 2026-02-26

Expand Down
99 changes: 68 additions & 31 deletions crates/zeph-acp/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,9 +527,10 @@ impl acp::Agent for ZephAcpAgent {
return Err(acp::Error::invalid_request().data("prompt too large"));
}

if text.trim_start().starts_with('/') {
let trimmed_text = text.trim_start();
if trimmed_text.starts_with('/') && trimmed_text != "/compact" {
return self
.handle_slash_command(&args.session_id, text.trim_start())
.handle_slash_command(&args.session_id, trimmed_text)
.await;
}

Expand Down Expand Up @@ -1182,34 +1183,7 @@ impl ZephAcpAgent {
/clear — clear session history\n\
/compact — summarize and compact context"
.to_owned(),
"/model" => {
if arg.is_empty() {
let models = self.available_models.join(", ");
format!("Available models: {models}")
} else {
let Some(ref factory) = self.provider_factory else {
return Err(
acp::Error::internal_error().data("model switching not configured")
);
};
if !self.available_models.iter().any(|m| m == arg) {
return Err(acp::Error::invalid_request().data("model not in allowed list"));
}
let Some(new_provider) = factory(arg) else {
return Err(acp::Error::invalid_request().data("unknown model"));
};
let sessions = self.sessions.borrow();
let entry = sessions
.get(session_id)
.ok_or_else(|| acp::Error::internal_error().data("session not found"))?;
*entry
.provider_override
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(new_provider);
arg.clone_into(&mut entry.current_model.borrow_mut());
format!("Switched to model: {arg}")
}
}
"/model" => self.handle_model_command(session_id, arg)?,
"/mode" => {
let valid_ids: &[&str] = &["code", "architect", "ask"];
if !valid_ids.contains(&arg) {
Expand Down Expand Up @@ -1244,9 +1218,16 @@ impl ZephAcpAgent {
}
});
}
// Send sentinel to clear in-memory agent context.
let sessions = self.sessions.borrow();
if let Some(entry) = sessions.get(session_id) {
let _ = entry.input_tx.try_send(ChannelMessage {
text: "/clear".to_owned(),
attachments: vec![],
});
}
"Session history cleared.".to_owned()
}
"/compact" => "Context compaction is not yet implemented.".to_owned(),
_ => {
return Err(acp::Error::invalid_request().data(format!("unknown command: {cmd}")));
}
Expand All @@ -1262,6 +1243,62 @@ impl ZephAcpAgent {
Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
}

fn resolve_model_fuzzy<'a>(&'a self, query: &str) -> acp::Result<String> {
if self.available_models.iter().any(|m| m == query) {
return Ok(query.to_owned());
}
let tokens: Vec<String> = query
.to_lowercase()
.split_whitespace()
.map(String::from)
.collect();
let candidates: Vec<&'a String> = self
.available_models
.iter()
.filter(|m| {
let lower = m.to_lowercase();
tokens.iter().all(|t| lower.contains(t.as_str()))
})
.collect();
match candidates.len() {
0 => {
let models = self.available_models.join(", ");
Err(acp::Error::invalid_request()
.data(format!("no matching model found. Available: {models}")))
}
1 => Ok(candidates[0].clone()),
_ => {
let names: Vec<&str> = candidates.iter().map(|s| s.as_str()).collect();
Err(acp::Error::invalid_request()
.data(format!("ambiguous model, candidates: {}", names.join(", "))))
}
}
}

fn handle_model_command(&self, session_id: &acp::SessionId, arg: &str) -> acp::Result<String> {
if arg.is_empty() {
let models = self.available_models.join(", ");
return Ok(format!("Available models: {models}"));
}
let Some(ref factory) = self.provider_factory else {
return Err(acp::Error::internal_error().data("model switching not configured"));
};
let resolved = self.resolve_model_fuzzy(arg)?;
let Some(new_provider) = factory(&resolved) else {
return Err(acp::Error::invalid_request().data("unknown model"));
};
let sessions = self.sessions.borrow();
let entry = sessions
.get(session_id)
.ok_or_else(|| acp::Error::internal_error().data("session not found"))?;
*entry
.provider_override
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(new_provider);
resolved.clone_into(&mut entry.current_model.borrow_mut());
Ok(format!("Switched to model: {resolved}"))
}

async fn ext_method_mcp(&self, args: &acp::ExtRequest) -> acp::Result<acp::ExtResponse> {
let method = args.method.as_ref();
match method {
Expand Down
9 changes: 9 additions & 0 deletions crates/zeph-core/src/agent/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,15 @@ impl<C: Channel> Agent<C> {
result
}

pub(super) fn clear_history(&mut self) {
let system_prompt = self.messages.first().cloned();
self.messages.clear();
if let Some(sp) = system_prompt {
self.messages.push(sp);
}
self.recompute_prompt_tokens();
}

pub(super) fn remove_recall_messages(&mut self) {
self.messages.retain(|m| {
if m.role != Role::System {
Expand Down
21 changes: 21 additions & 0 deletions crates/zeph-core/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,27 @@ impl<C: Channel> Agent<C> {
continue;
}

if trimmed == "/compact" {
if self.messages.len() > self.context_manager.compaction_preserve_tail + 1 {
match self.compact_context().await {
Ok(()) => {
let _ = self.channel.send("Context compacted successfully.").await;
}
Err(e) => {
let _ = self.channel.send(&format!("Compaction failed: {e}")).await;
}
}
} else {
let _ = self.channel.send("Nothing to compact.").await;
}
continue;
}

if trimmed == "/clear" {
self.clear_history();
continue;
}

self.process_user_message(text, image_parts).await?;
}

Expand Down
4 changes: 4 additions & 0 deletions crates/zeph-llm/src/compatible.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ impl LlmProvider for CompatibleProvider {
&self.provider_name
}

fn list_models(&self) -> Vec<String> {
self.inner.list_models()
}

fn supports_structured_output(&self) -> bool {
self.inner.supports_structured_output()
}
Expand Down
4 changes: 4 additions & 0 deletions crates/zeph-llm/src/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,10 @@ impl LlmProvider for OpenAiProvider {
"openai"
}

fn list_models(&self) -> Vec<String> {
vec![self.model.clone()]
}

fn last_cache_usage(&self) -> Option<(u64, u64)> {
self.last_cache.lock().ok().and_then(|g| *g)
}
Expand Down
6 changes: 6 additions & 0 deletions crates/zeph-llm/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,12 @@ pub trait LlmProvider: Send + Sync {
None
}

/// Return the list of model identifiers this provider can serve.
/// Default: empty (provider does not advertise models).
fn list_models(&self) -> Vec<String> {
vec![]
}

/// Whether this provider supports native structured output.
fn supports_structured_output(&self) -> bool {
false
Expand Down
7 changes: 7 additions & 0 deletions crates/zeph-llm/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ impl LlmProvider for RouterProvider {
self.providers.iter().any(LlmProvider::supports_tool_use)
}

fn list_models(&self) -> Vec<String> {
self.providers
.iter()
.flat_map(super::provider::LlmProvider::list_models)
.collect()
}

#[allow(async_fn_in_trait)]
async fn chat_with_tools(
&self,
Expand Down
35 changes: 34 additions & 1 deletion src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,11 @@ async fn build_acp_deps(
acp_max_sessions: config.acp.max_sessions,
acp_session_idle_timeout_secs: config.acp.session_idle_timeout_secs,
acp_permission_file: config.acp.permission_file.clone(),
acp_available_models: config.acp.available_models.clone(),
acp_available_models: if config.acp.available_models.is_empty() {
discover_models_from_config(config)
} else {
config.acp.available_models.clone()
},
acp_auth_bearer_token: config.acp.auth_token.clone(),
acp_discovery_enabled: config.acp.discovery_enabled,
acp_provider_factory: Some(build_acp_provider_factory(config)),
Expand Down Expand Up @@ -323,6 +327,35 @@ async fn spawn_acp_agent(
}
}

/// Collect model keys from config when `acp.available_models` is not set.
///
/// Each key uses `"{provider_name}:{model}"` format matching the provider factory.
#[cfg(feature = "acp")]
fn discover_models_from_config(config: &zeph_core::config::Config) -> Vec<String> {
let mut models: Vec<String> = Vec::new();
models.push(format!("ollama:{}", config.llm.model));
if config.secrets.claude_api_key.is_some() {
let model = config
.llm
.cloud
.as_ref()
.map_or("claude-sonnet-4-5", |c| c.model.as_str());
models.push(format!("claude:{model}"));
}
if let (Some(_), Some(openai_cfg)) = (&config.secrets.openai_api_key, &config.llm.openai) {
models.push(format!("openai:{}", openai_cfg.model));
}
if let Some(ref entries) = config.llm.compatible {
for entry in entries {
if config.secrets.compatible_api_keys.contains_key(&entry.name) {
models.push(format!("{}:{}", entry.name, entry.model));
}
}
}
models.dedup();
models
}

/// Build a `ProviderFactory` from the known named providers in config.
///
/// Each available model key is `"{provider_name}:{model}"`.
Expand Down
Loading