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

- **TUI**: added a read-only settings view (`S` key or the `settings` command-palette
entry) listing configured LLM providers, MCP servers, and sub-agent definitions in
three tabs, sourced as a live snapshot from `MetricsSnapshot` (never re-parsed from
disk). Provider entries are built by explicit whitelist field-copy so secret fields
(API keys, Cocoon access hash, Candle HF token) can never leak into the view; the
agents tab shows configured definitions, distinct from the runtime subagent sidebar's
spawned instances. Read/list only in this release — edit-in-place is deferred (#6024).
- **TUI**: added `Ctrl+F` in-transcript search — search the currently visible
conversation by message content or tool name, with match highlighting and
scroll-to-match, `Ctrl+F`/`Up`/`Down` to cycle matches, `Esc` to cancel (restoring the
prior scroll position), and `Enter` to accept. Independent of the existing `Ctrl+R`
input-history search; a match inside a currently-collapsed tool-output block scrolls
to the message's visible anchor rather than a position that assumes the block is
expanded (#6023).
- **Memory**: added MemGuard-inspired type-aware retrieval composition (`[memory.type_aware_compose]`,
spec 064, #6086). Retrieval-only, fetch-time gate on `schedule_context_fetchers`: a new
`FunctionalType` enum (`episodic` / `user_fact` / `behavioral_rule` / `reasoning_strategy` /
Expand Down
91 changes: 91 additions & 0 deletions crates/zeph-core/src/agent/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,52 @@ impl<C: Channel> Agent<C> {
self
}

/// Populate the TUI settings view's `providers` and `agent_definitions` metrics
/// fields (issue #6024) from the current provider pool and sub-agent definitions.
///
/// Must be called after [`with_provider_pool`][Self::with_provider_pool],
/// [`with_orchestration`][Self::with_orchestration] (if sub-agent definitions are
/// used), and [`with_metrics`][Self::with_metrics] — it is a `send_modify` against
/// the already-wired metrics channel, mirroring [`with_static_metrics`][Self::with_static_metrics].
/// Re-run the same population at the two other sites documented on
/// [`crate::metrics::MetricsSnapshot::providers`]: `/provider` switch and config
/// hot-reload — this call only covers the unconditional startup population.
///
/// # Panics
///
/// Panics if called before [`with_metrics`][Self::with_metrics] (no sender is wired yet).
#[must_use]
pub fn with_settings_metrics(self) -> Self {
let active_provider_name = if self.runtime.config.active_provider_name.is_empty() {
self.provider.name().to_owned()
} else {
self.runtime.config.active_provider_name.clone()
};
let providers = crate::metrics::ProviderSummary::build_pool(
&self.runtime.providers.provider_pool,
&active_provider_name,
);
let agent_definitions = self
.services
.orchestration
.subagent_manager
.as_ref()
.map(|mgr| crate::metrics::AgentDefSummary::build_all(mgr.definitions()))
.unwrap_or_default();
let tx = self
.runtime
.metrics
.metrics_tx
.as_ref()
.expect("with_settings_metrics must be called after with_metrics");
let _span = tracing::info_span!("core.metrics.settings_snapshot").entered();
tx.send_modify(|m| {
m.providers = providers;
m.agent_definitions = agent_definitions;
});
self
}

/// Inject a shared provider override slot for runtime model switching (e.g. via ACP
/// `set_session_config_option`). The agent checks and swaps the provider before each turn.
#[must_use]
Expand Down Expand Up @@ -3779,6 +3825,51 @@ mod tests {
}
}

/// Issue #6024, startup call site: `with_settings_metrics()` must populate
/// `MetricsSnapshot.providers`/`agent_definitions` from whatever provider pool /
/// subagent manager were wired earlier in the chain, using the running provider's
/// own name as the active marker when `active_provider_name` is unset (mirrors the
/// same fallback `provider_cmd.rs`'s `provider_list_as_string` uses).
#[test]
fn with_settings_metrics_populates_providers_from_pool() {
let (tx, rx) = tokio::sync::watch::channel(MetricsSnapshot::default());
let snapshot = crate::agent::state::ProviderConfigSnapshot {
claude_api_key: None,
openai_api_key: None,
gemini_api_key: None,
compatible_api_keys: std::collections::HashMap::new(),
llm_request_timeout_secs: 30,
embedding_model: String::new(),
gonka_private_key: None,
gonka_address: None,
cocoon_access_hash: None,
};
let _ = make_agent()
.with_metrics(tx)
.with_provider_pool(
vec![ProviderEntry {
name: Some("mock".into()),
default: true,
..Default::default()
}],
snapshot,
)
.with_settings_metrics();

let s = rx.borrow();
assert_eq!(s.providers.len(), 1);
assert_eq!(s.providers[0].name, "mock");
assert!(
s.providers[0].active,
"active_provider_name is unset, so the running MockProvider's own name (\"mock\") \
must be used as the active marker fallback"
);
assert!(
s.agent_definitions.is_empty(),
"no subagent_manager was wired, so agent_definitions must be empty, not panic"
);
}

#[test]
fn default_speculation_engine_is_none() {
let agent = make_agent();
Expand Down
53 changes: 53 additions & 0 deletions crates/zeph-core/src/agent/config_reload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use super::{Agent, resolve_context_budget};
use crate::channel::Channel;
use crate::config::Config;
use crate::context::ContextBudget;
use zeph_llm::provider::LlmProvider as _;

impl<C: Channel> Agent<C> {
#[allow(clippy::too_many_lines)]
Expand Down Expand Up @@ -154,6 +155,32 @@ impl<C: Channel> Agent<C> {
.clone_from(&config.hooks.turn_complete);
// file_changed_hooks require watcher restart to take effect — skipped here.

// Re-derive the settings-view provider/agent-definition lists (issue #6024): a
// hot-reload can add, remove, or edit `[[llm.providers]]` entries or sub-agent
// definitions, and — per the same rationale as `apply_provider_switch_metrics`
// — `providers`/`agent_definitions` are never refreshed by the MCP-lifecycle-driven
// `update_mcp_metrics`, so they must be re-emitted explicitly here.
let active_provider_name = if self.runtime.config.active_provider_name.is_empty() {
self.provider.name().to_owned()
} else {
self.runtime.config.active_provider_name.clone()
};
let providers = crate::metrics::ProviderSummary::build_pool(
&self.runtime.providers.provider_pool,
&active_provider_name,
);
let agent_definitions = self
.services
.orchestration
.subagent_manager
.as_ref()
.map(|mgr| crate::metrics::AgentDefSummary::build_all(mgr.definitions()))
.unwrap_or_default();
self.update_metrics(|m| {
m.providers = providers;
m.agent_definitions = agent_definitions;
});

tracing::info!("config reloaded");
}
/// Load config from disk, apply plugin overlays, validate, and warn on shell divergence.
Expand Down Expand Up @@ -342,4 +369,30 @@ mod tests {
"prior runtime state must be preserved when the reloaded config fails validate()"
);
}

// ── Settings-view metrics refresh on hot-reload (issue #6024) ──────────────────

#[test]
fn reload_config_repopulates_settings_metrics() {
let config = Config::default();
let (_dir, path) = write_config(&config);

let mut agent = QuickTestAgent::minimal("ok").agent;
agent.runtime.lifecycle.plugins_dir = std::path::PathBuf::new();
agent.runtime.lifecycle.config_path = Some(path);
agent.runtime.providers.provider_pool = vec![zeph_config::ProviderEntry {
name: Some("fast".to_owned()),
default: true,
..zeph_config::ProviderEntry::default()
}];
let (tx, rx) = tokio::sync::watch::channel(crate::metrics::MetricsSnapshot::default());
agent.runtime.metrics.metrics_tx = Some(tx);

agent.reload_config();

let snapshot = rx.borrow();
assert_eq!(snapshot.providers.len(), 1);
assert_eq!(snapshot.providers[0].name, "fast");
assert!(snapshot.providers[0].default);
}
}
50 changes: 50 additions & 0 deletions crates/zeph-core/src/agent/provider_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,11 +367,21 @@ impl<C: Channel> Agent<C> {
.and_then(|c| c.generation.top_p.map(|v| v as f32));
let switched_model = self.runtime.config.model_name.clone();
let name = configured_name.to_owned();
// Re-derive the settings-view provider list (issue #6024) so its `active` marker
// reflects the new provider — `providers` is populated at dedicated call sites
// (startup, here, config reload), never from the MCP-lifecycle-driven
// `update_mcp_metrics`, so it stays correct even when the switch happens before
// any MCP event ever fires.
let providers = crate::metrics::ProviderSummary::build_pool(
&self.runtime.providers.provider_pool,
&name,
);
self.update_metrics(|m| {
m.provider_name.clone_from(&name);
m.model_name = switched_model;
m.provider_temperature = provider_temperature;
m.provider_top_p = provider_top_p;
m.providers = providers;
});
}

Expand Down Expand Up @@ -717,6 +727,46 @@ mod tests {
assert_eq!(agent.runtime.config.model_name, "llama3.2");
}

/// Issue #6024 (S2 regression scenario): after a `/provider` switch, the settings
/// view's `providers` list must re-derive with the `active` marker moved to the
/// newly-selected provider, not stay stale on the previously-active one. This is
/// the exact wiring `apply_provider_switch_metrics` exists to keep correct.
#[tokio::test]
async fn provider_switch_updates_settings_metrics_active_marker() {
let entry_a = make_entry("ollama", ProviderKind::Ollama, Some("qwen3:8b"));
let entry_b = make_entry("ollama2", ProviderKind::Ollama, Some("llama3.2"));
let snapshot = ollama_snapshot();
let provider_a =
crate::provider_factory::build_provider_for_switch(&entry_a, &snapshot, None).unwrap();

let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider_a, channel, registry, None, 5, executor);
agent.runtime.providers.provider_pool = vec![entry_a, entry_b];
agent.runtime.providers.provider_config_snapshot = Some(snapshot);

let (tx, rx) = tokio::sync::watch::channel(crate::metrics::MetricsSnapshot::default());
agent.runtime.metrics.metrics_tx = Some(tx);

let out = agent.handle_provider_command_as_string("ollama2").await;
assert!(out.contains("Switched to provider:"), "unexpected: {out}");

let snapshot = rx.borrow();
assert_eq!(snapshot.providers.len(), 2);
let active_names: Vec<&str> = snapshot
.providers
.iter()
.filter(|p| p.active)
.map(|p| p.name.as_str())
.collect();
assert_eq!(
active_names,
vec!["ollama2"],
"exactly the newly-switched-to provider must be marked active"
);
}

#[tokio::test]
async fn provider_status_no_metrics() {
let mut qa = QuickTestAgent::minimal("ok");
Expand Down
Loading
Loading