Skip to content

Commit 8dea142

Browse files
authored
feat(core): add /new command to reset conversation while preserving session state (#2464)
Implements /new slash command that resets conversation history, tool call cache, and context budget without reinitializing memory (SQLite + Qdrant), MCP connections, or provider configuration. - Fail-fast: new conversation ID created in SQLite before any state mutation - Aborts in-flight JoinHandles (compression, sidequest, subgoal tasks) - Cancels running plan token and shuts down active subagents - Optional --no-digest flag skips carry-over session digest generation - Optional --keep-plan flag preserves pending orchestration graph - Background digest generation (30s bounded, fire-and-forget) - TUI spinner emitted via status_tx before and after reset - Channel-generic: works identically across CLI, TUI, and Telegram - 3 unit tests for reset_compaction, FocusState::reset, SidequestState::reset Closes #2451
1 parent e09bab9 commit 8dea142

File tree

8 files changed

+372
-0
lines changed

8 files changed

+372
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
### Added
1010

11+
- feat(core): `/new` slash command — resets conversation context (messages, compaction state, tool caches, focus/sidequest, pending plans) while preserving memory, MCP connections, providers, and skills; creates a new `ConversationId` in SQLite for audit trail; generates a session digest for the outgoing conversation fire-and-forget unless `--no-digest` is passed; active sub-agents and background compression tasks are cancelled; `--keep-plan` preserves a pending plan graph; available in all channels (CLI, TUI, Telegram) via the unified `handle_builtin_command` path (closes #2451)
12+
1113
- feat(acp): expose current model in `session/list` and emit `SessionInfoUpdate` on model change — each in-memory `SessionInfo` now carries `meta.currentModel`; after `session/set_config_option` with `configId=model` a `SessionInfoUpdate` notification with `meta.currentModel` is sent in addition to the existing `ConfigOptionUpdate`; same notification is sent after `session/set_session_model` (closes #2435)
1214
- feat(tools): adversarial policy agent — LLM-based pre-execution tool call validation against plain-language policies; configurable fail-closed/fail-open behavior (`fail_open = false` default); prompt injection hardening via code-fence param quoting; strict allow/deny response parsing; full `ToolExecutor` trait delegation; audit log `adversarial_policy_decision` field; executor chain order `PolicyGateExecutor → AdversarialPolicyGateExecutor → TrustGateExecutor`; gated on `policy-enforcer` feature; config `[tools.adversarial_policy]` (closes #2447)
1315
- feat(memory): Memex tool output archive — before compaction, `ToolOutput` bodies in the compaction range are saved to `tool_overflow` with `archive_type = 'archive'`; archived UUIDs are appended as a postfix after LLM summarization so references survive compaction; controlled by `[memory.compression] archive_tool_outputs = false`; archives are excluded from the short-lived cleanup job via `archive_type` column (migration 054, closes #2432)

crates/zeph-core/src/agent/context/assembly.rs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,177 @@ impl<C: Channel> Agent<C> {
366366
.retain(|m| m.role != Role::User || !m.content.starts_with(SESSION_DIGEST_PREFIX));
367367
}
368368

369+
/// Spawn a fire-and-forget background task to generate and persist a session digest for
370+
/// `conversation_id`. No-op when digest is disabled or the conversation has no messages.
371+
fn spawn_outgoing_digest(&self, conversation_id: Option<zeph_memory::ConversationId>) {
372+
if !self.memory_state.digest_config.enabled {
373+
return;
374+
}
375+
let non_system: Vec<_> = self
376+
.msg
377+
.messages
378+
.iter()
379+
.skip(1)
380+
.filter(|m| m.role != zeph_llm::provider::Role::System)
381+
.cloned()
382+
.collect();
383+
if non_system.is_empty() {
384+
return;
385+
}
386+
let digest_config = self.memory_state.digest_config.clone();
387+
let memory = self.memory_state.memory.clone();
388+
let provider = self.provider.clone();
389+
let tc = self.metrics.token_counter.clone();
390+
let status_tx = self.session.status_tx.clone();
391+
if let Some(ref tx) = self.session.status_tx {
392+
let _ = tx.send("Generating session digest...".to_string());
393+
}
394+
tokio::spawn(async move {
395+
if let (Some(mem), Some(cid)) = (memory, conversation_id) {
396+
super::super::session_digest::generate_and_store_digest(
397+
&provider,
398+
&mem,
399+
cid,
400+
&non_system,
401+
&digest_config,
402+
&tc,
403+
)
404+
.await;
405+
}
406+
if let Some(tx) = status_tx {
407+
let _ = tx.send(String::new());
408+
}
409+
});
410+
}
411+
412+
/// Reset the conversation window for `/new`.
413+
///
414+
/// Creates a new `ConversationId` in `SQLite` first (fail-fast: no state is mutated
415+
/// if the `DB` call fails). Then resets all session-scoped state while preserving
416+
/// cross-session state (memory, MCP, providers, skills).
417+
///
418+
/// `keep_plan` — when `true`, `orchestration.pending_graph` is preserved.
419+
/// `no_digest` — when `true`, skip generating a session digest for the outgoing
420+
/// conversation. Default behaviour: generate digest fire-and-forget.
421+
///
422+
/// Returns the old and new `ConversationId` for the confirmation message.
423+
///
424+
/// # Errors
425+
///
426+
/// Returns an error if [`create_conversation`](zeph_memory::store::SqliteStore::create_conversation)
427+
/// fails. In that case no agent state is modified.
428+
pub(in crate::agent) async fn reset_conversation(
429+
&mut self,
430+
keep_plan: bool,
431+
no_digest: bool,
432+
) -> Result<
433+
(
434+
Option<zeph_memory::ConversationId>,
435+
Option<zeph_memory::ConversationId>,
436+
),
437+
super::super::error::AgentError,
438+
> {
439+
// --- Step 1: create new ConversationId FIRST (fail-fast) ---
440+
let new_conversation_id = if let Some(ref memory) = self.memory_state.memory {
441+
match memory.sqlite().create_conversation().await {
442+
Ok(id) => Some(id),
443+
Err(e) => return Err(super::super::error::AgentError::Memory(e)),
444+
}
445+
} else {
446+
None
447+
};
448+
449+
let old_conversation_id = self.memory_state.conversation_id;
450+
451+
// --- Step 2: fire-and-forget digest for outgoing conversation ---
452+
if !no_digest {
453+
self.spawn_outgoing_digest(old_conversation_id);
454+
}
455+
456+
// --- Step 3: TUI status ---
457+
if let Some(ref tx) = self.session.status_tx {
458+
let _ = tx.send("Resetting conversation...".to_string());
459+
}
460+
461+
// --- Step 4: abort background compression tasks (context-compression) ---
462+
#[cfg(feature = "context-compression")]
463+
{
464+
if let Some(h) = self.compression.pending_task_goal.take() {
465+
h.abort();
466+
}
467+
if let Some(h) = self.compression.pending_sidequest_result.take() {
468+
h.abort();
469+
}
470+
if let Some(h) = self.compression.pending_subgoal.take() {
471+
h.abort();
472+
}
473+
self.compression.current_task_goal = None;
474+
self.compression.task_goal_user_msg_hash = None;
475+
self.compression.subgoal_registry =
476+
crate::agent::compaction_strategy::SubgoalRegistry::default();
477+
self.compression.subgoal_user_msg_hash = None;
478+
}
479+
480+
// --- Step 5: cancel running plan and clear orchestration ---
481+
if !keep_plan {
482+
if let Some(token) = self.orchestration.plan_cancel_token.take() {
483+
token.cancel();
484+
}
485+
self.orchestration.pending_graph = None;
486+
self.orchestration.pending_goal_embedding = None;
487+
}
488+
// Cancel running sub-agents regardless of keep_plan.
489+
if let Some(ref mut mgr) = self.orchestration.subagent_manager {
490+
mgr.shutdown_all();
491+
}
492+
493+
// --- Step 6: reset message history and caches ---
494+
self.clear_history();
495+
self.tool_orchestrator.clear_cache();
496+
497+
// Drain message queue, logging discarded entries.
498+
let discarded = self.clear_queue();
499+
if discarded > 0 {
500+
tracing::debug!(
501+
discarded,
502+
"/new: discarded queued messages that arrived during reset"
503+
);
504+
}
505+
self.msg.pending_image_parts.clear();
506+
507+
// --- Step 7: reset security URL sets ---
508+
if let Ok(mut urls) = self.security.user_provided_urls.write() {
509+
urls.clear();
510+
}
511+
self.security.flagged_urls.clear();
512+
513+
// --- Step 8: reset compaction and compression states ---
514+
self.context_manager.reset_compaction();
515+
self.focus.reset();
516+
self.sidequest.reset();
517+
518+
// --- Step 9: reset misc session-scoped fields ---
519+
self.debug_state.iteration_counter = 0;
520+
self.last_persisted_message_id = None;
521+
self.deferred_db_hide_ids.clear();
522+
self.deferred_db_summaries.clear();
523+
self.cached_filtered_tool_ids = None;
524+
self.providers.cached_prompt_tokens = 0;
525+
526+
// --- Step 10: update conversation ID and memory state ---
527+
self.memory_state.conversation_id = new_conversation_id;
528+
self.memory_state.unsummarized_count = 0;
529+
// Clear cached digest — the new conversation has no prior digest yet.
530+
self.memory_state.cached_session_digest = None;
531+
532+
// --- Step 11: clear TUI status ---
533+
if let Some(ref tx) = self.session.status_tx {
534+
let _ = tx.send(String::new());
535+
}
536+
537+
Ok((old_conversation_id, new_conversation_id))
538+
}
539+
369540
async fn fetch_document_rag(
370541
memory_state: &MemoryState,
371542
query: &str,

crates/zeph-core/src/agent/context_manager.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,15 @@ impl ContextManager {
151151
}
152152
}
153153

154+
/// Reset compaction state for a new conversation.
155+
///
156+
/// Clears cooldown, exhaustion, and turn counters so the new conversation starts
157+
/// with a clean compaction slate.
158+
pub(crate) fn reset_compaction(&mut self) {
159+
self.compaction = CompactionState::Ready;
160+
self.turns_since_last_hard_compaction = None;
161+
}
162+
154163
/// Determine which compaction tier applies for the given token count.
155164
///
156165
/// - `Hard` when `cached_tokens > budget * hard_compaction_threshold`
@@ -450,4 +459,14 @@ mod tests {
450459
let after_eviction = CompactionState::CompactedThisTurn { cooldown: 0 };
451460
assert!(after_eviction.is_compacted_this_turn());
452461
}
462+
463+
#[test]
464+
fn reset_compaction_clears_exhausted_state() {
465+
let mut cm = ContextManager::new();
466+
cm.compaction = CompactionState::Exhausted { warned: true };
467+
cm.turns_since_last_hard_compaction = Some(5);
468+
cm.reset_compaction();
469+
assert_eq!(cm.compaction, CompactionState::Ready);
470+
assert!(cm.turns_since_last_hard_compaction.is_none());
471+
}
453472
}

crates/zeph-core/src/agent/focus.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,23 @@ impl FocusState {
166166
}
167167
}
168168

169+
/// Reset focus state for a new conversation.
170+
///
171+
/// Clears the active session and accumulated knowledge so the new conversation
172+
/// starts without stale focus context.
173+
pub(crate) fn reset(&mut self) {
174+
self.knowledge_blocks.clear();
175+
#[cfg(feature = "context-compression")]
176+
{
177+
self.active_marker = None;
178+
self.compressing
179+
.store(false, std::sync::atomic::Ordering::Release);
180+
}
181+
self.active_scope = None;
182+
self.turns_since_focus = 0;
183+
self.turns_since_reminder = 0;
184+
}
185+
169186
/// Increment turn counters. Called at the start of each user-message turn.
170187
pub(crate) fn tick(&mut self) {
171188
self.turns_since_focus = self.turns_since_focus.saturating_add(1);
@@ -450,4 +467,18 @@ mod tests {
450467
assert!(msg.content.contains("Summary B"));
451468
assert!(msg.metadata.focus_pinned, "knowledge block must be pinned");
452469
}
470+
471+
#[test]
472+
fn reset_clears_all_session_state() {
473+
let mut state = FocusState::new(FocusConfig::default());
474+
state.knowledge_blocks.push("some knowledge".to_string());
475+
state.active_scope = Some("active scope".to_string());
476+
state.turns_since_focus = 7;
477+
state.turns_since_reminder = 3;
478+
state.reset();
479+
assert!(state.knowledge_blocks.is_empty());
480+
assert!(state.active_scope.is_none());
481+
assert_eq!(state.turns_since_focus, 0);
482+
assert_eq!(state.turns_since_reminder, 0);
483+
}
453484
}

crates/zeph-core/src/agent/mod.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2646,6 +2646,7 @@ impl<C: Channel> Agent<C> {
26462646
/// Returns `Some(true)` to break the loop (exit), `Some(false)` to continue to the next
26472647
/// iteration, or `None` if the command was not recognized (caller should call
26482648
/// `process_user_message`).
2649+
#[allow(clippy::too_many_lines)]
26492650
async fn handle_builtin_command(
26502651
&mut self,
26512652
trimmed: &str,
@@ -2689,6 +2690,31 @@ impl<C: Channel> Agent<C> {
26892690
return Ok(Some(false));
26902691
}
26912692

2693+
if trimmed == "/new" || trimmed.starts_with("/new ") {
2694+
let args = trimmed.strip_prefix("/new").unwrap_or("").trim();
2695+
let keep_plan = args.split_whitespace().any(|a| a == "--keep-plan");
2696+
let no_digest = args.split_whitespace().any(|a| a == "--no-digest");
2697+
match self.reset_conversation(keep_plan, no_digest).await {
2698+
Ok((old_id, new_id)) => {
2699+
let old = old_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
2700+
let new = new_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
2701+
let keep_note = if keep_plan { " (plan preserved)" } else { "" };
2702+
self.channel
2703+
.send(&format!(
2704+
"New conversation started. Previous: {old} → Current: {new}{keep_note}"
2705+
))
2706+
.await?;
2707+
}
2708+
Err(e) => {
2709+
self.channel
2710+
.send(&format!("Failed to start new conversation: {e}"))
2711+
.await?;
2712+
}
2713+
}
2714+
let _ = self.channel.flush_chunks().await;
2715+
return Ok(Some(false));
2716+
}
2717+
26922718
if trimmed == "/clear" {
26932719
self.clear_history();
26942720
self.tool_orchestrator.clear_cache();

crates/zeph-core/src/agent/session_digest.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,94 @@ use crate::channel::Channel;
6969

7070
use super::Agent;
7171

72+
/// Generate and persist a digest for a completed conversation from a background task.
73+
///
74+
/// Called fire-and-forget from `reset_conversation`. All errors are logged as warnings
75+
/// and swallowed.
76+
pub(super) async fn generate_and_store_digest(
77+
provider: &zeph_llm::any::AnyProvider,
78+
memory: &zeph_memory::semantic::SemanticMemory,
79+
conversation_id: zeph_memory::ConversationId,
80+
messages: &[zeph_llm::provider::Message],
81+
digest_config: &crate::config::DigestConfig,
82+
tc: &zeph_memory::TokenCounter,
83+
) {
84+
if messages.is_empty() {
85+
return;
86+
}
87+
88+
let max_input = digest_config.max_input_messages;
89+
let max_tokens = digest_config.max_tokens;
90+
91+
let slice = if messages.len() > max_input {
92+
&messages[messages.len() - max_input..]
93+
} else {
94+
messages
95+
};
96+
97+
let mut conv_text = String::new();
98+
for msg in slice {
99+
let role = match msg.role {
100+
zeph_llm::provider::Role::User => "User",
101+
zeph_llm::provider::Role::Assistant => "Assistant",
102+
zeph_llm::provider::Role::System => "System",
103+
};
104+
let _ =
105+
std::fmt::Write::write_fmt(&mut conv_text, format_args!("{role}: {}\n\n", msg.content));
106+
}
107+
108+
let prompt = format!(
109+
"You are a session summarizer. Read the following conversation excerpt and produce \
110+
a compact digest (under {max_tokens} tokens) of the key facts, decisions, outcomes, \
111+
and open questions from this session. Be specific and concise. \
112+
Output ONLY the digest text, no preamble.\n\n\
113+
Conversation:\n{conv_text}\n\
114+
Digest:"
115+
);
116+
117+
let chat_messages = vec![zeph_llm::provider::Message {
118+
role: zeph_llm::provider::Role::User,
119+
content: prompt,
120+
parts: vec![],
121+
metadata: zeph_llm::provider::MessageMetadata::default(),
122+
}];
123+
124+
let timeout = Duration::from_secs(30);
125+
let digest_text = tokio::select! {
126+
() = async { tokio::time::sleep(timeout).await } => {
127+
tracing::warn!("session digest (/new): LLM call timed out");
128+
return;
129+
}
130+
result = provider.chat_with_named_provider(&digest_config.provider, &chat_messages) => {
131+
match result {
132+
Ok(text) => text,
133+
Err(e) => {
134+
tracing::warn!("session digest (/new): LLM call failed: {e:#}");
135+
return;
136+
}
137+
}
138+
}
139+
};
140+
141+
let sanitized = sanitize_digest(&digest_text);
142+
let final_text = truncate_digest(&sanitized, max_tokens, tc);
143+
let token_count = i64::try_from(tc.count_tokens(&final_text)).unwrap_or(i64::MAX);
144+
145+
if let Err(e) = memory
146+
.sqlite()
147+
.save_session_digest(conversation_id, &final_text, token_count)
148+
.await
149+
{
150+
tracing::warn!("session digest (/new): storage failed: {e:#}");
151+
} else {
152+
tracing::info!(
153+
conversation_id = conversation_id.0,
154+
tokens = token_count,
155+
"session digest stored (via /new)"
156+
);
157+
}
158+
}
159+
72160
impl<C: Channel> Agent<C> {
73161
/// Generate and persist a session digest at shutdown when digest is enabled.
74162
///

0 commit comments

Comments
 (0)