From 3da9928cc995cecd16cb8d3084612469f6bd6544 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 18:03:55 -0400 Subject: [PATCH] feat(13-10): node-side chat history, scoped by caller, compacted not truncated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit history.rs persists the ChatMessage transcript under data_dir (D-08), keyed by a HistoryKey derived from CallerScope so an operator's AIUI session and a mesh peer's transcript are structurally distinct files, not two rows a filter could forget. Writes are atomic (temp sibling + rename, matching music/index.rs::save_atomic's precedent) and 0600, following grants.rs's convention. Tool results longer than MAX_TOOL_RESULT_CHARS are truncated with a visible marker before entering history -- a new, assistant-scoped constant, never assist.rs's LoRa-airtime-tuned reply cap. Once the transcript exceeds KEEP_VERBATIM_TURNS, older turns fold into a running summary extended incrementally as turns age out, never regenerated from the full transcript. Wallet/files-category tool-call arguments are never persisted (AI-SPEC §7b's field policy applied to storage, not only tracing) -- categories are resolved by the caller from the same tools registry execute_tool uses, so history.rs never re-derives a second, driftable category list. Nothing reachable from confirm.rs's pending- confirmation state has a parameter path into this module at all (S-09 stays true structurally). assistant.history / assistant.clear-history route through 13-01's existing assistant.* dispatcher arm (dispatcher.rs untouched), each scoped to the calling session's own HistoryKey. run_loop (loop_.rs) now returns (answer, full_history) instead of just the answer string -- structurally necessary so chat() (mod.rs) can persist the tool-call/tool-result messages the loop built internally, not only the user question and final answer (Rule 3, mirroring 13-05's precedent of touching a file outside its own plan's files_modified list when the plan's own intent requires it). chat() persists this turn after run_loop returns; it does not yet feed prior persisted turns back into live model context -- a documented, deliberately scoped follow-up (see mod.rs's chat() doc comment and the plan SUMMARY). 8 new tests under assistant::history::tests::, including operator_and_mesh_transcripts_are_separate and wallet_tool_arguments_never_reach_the_transcript (asserted against both the deserialized struct and the raw on-disk bytes). Full assistant:: suite: 50/50 (42 baseline-after-Task-1 + 8 new); confirm::tests:: restart_drops_pending_not_executes still passes -- S-09 not weakened. Co-Authored-By: Claude Fable 5 --- .../archipelago/src/api/rpc/assistant_chat.rs | 36 + core/archipelago/src/assistant/history.rs | 779 ++++++++++++++++++ core/archipelago/src/assistant/loop_.rs | 46 +- core/archipelago/src/assistant/mod.rs | 55 +- core/archipelago/src/assistant/tools.rs | 2 +- 5 files changed, 900 insertions(+), 18 deletions(-) create mode 100644 core/archipelago/src/assistant/history.rs diff --git a/core/archipelago/src/api/rpc/assistant_chat.rs b/core/archipelago/src/api/rpc/assistant_chat.rs index dc33eff0..79ea92d0 100644 --- a/core/archipelago/src/api/rpc/assistant_chat.rs +++ b/core/archipelago/src/api/rpc/assistant_chat.rs @@ -30,10 +30,46 @@ impl RpcHandler { "assistant.grants-set" => self.handle_assistant_grants_set(params).await, "assistant.pending" => self.handle_assistant_pending().await, "assistant.confirm-tool" => self.handle_assistant_confirm_tool(params).await, + "assistant.history" => self.handle_assistant_history(session_token).await, + "assistant.clear-history" => self.handle_assistant_clear_history(session_token).await, other => anyhow::bail!("no such assistant method: {other}"), } } + /// assistant.history — the calling session's own transcript (D-08/D-02): + /// the running summary of everything compacted out of the verbatim + /// window, plus the verbatim recent turns. Scoped to the caller's own + /// `HistoryKey` — never any other caller's transcript (the same + /// structural separation `operator_and_mesh_transcripts_are_separate` + /// asserts in `history.rs`). + async fn handle_assistant_history( + self: &Arc, + session_token: &Option, + ) -> Result { + let session_id = session_token.clone().unwrap_or_default(); + let caller = crate::assistant::CallerScope::LocalOperator { session_id }; + let key = crate::assistant::history::HistoryKey::from_caller(&caller); + let hist = crate::assistant::history::History::load(self.data_dir(), &key).await; + Ok(serde_json::json!({ + "summary": hist.summary, + "turns": hist.recent(), + })) + } + + /// assistant.clear-history — delete the calling session's own + /// transcript file. Never touches any other caller's file — each + /// caller has a structurally distinct `HistoryKey`-derived path. + async fn handle_assistant_clear_history( + self: &Arc, + session_token: &Option, + ) -> Result { + let session_id = session_token.clone().unwrap_or_default(); + let caller = crate::assistant::CallerScope::LocalOperator { session_id }; + let key = crate::assistant::history::HistoryKey::from_caller(&caller); + crate::assistant::history::History::clear(self.data_dir(), &key).await?; + Ok(serde_json::json!({ "cleared": true })) + } + /// assistant.pending — the current pending destructive-tool /// confirmation, if any: the node-authored description and the /// node-minted nonce. This is how the trusted chrome *fetches* the diff --git a/core/archipelago/src/assistant/history.rs b/core/archipelago/src/assistant/history.rs new file mode 100644 index 00000000..12e6c5d5 --- /dev/null +++ b/core/archipelago/src/assistant/history.rs @@ -0,0 +1,779 @@ +//! D-08: node-side chat persistence under `data_dir`, scoped by caller +//! identity + permission scope (D-02) so an operator's AIUI transcript and +//! a mesh peer's transcript never see each other's history. Follows +//! `streaming/session.rs`'s `data_dir`-scoped persisted-state conventions +//! and `music/index.rs::save_atomic`'s temp-file-then-`rename` write +//! discipline — a crash mid-append leaves the previous transcript intact, +//! never a partial file. +//! +//! **Pending confirmations (`confirm.rs`) are never reachable from this +//! file.** `project`'s only inputs are a completed turn's `ChatMessage`s +//! and a resolved tool-name -> category map — there is no parameter type +//! here through which a `confirm::PendingConfirmation` could ever arrive +//! (see `project_has_no_path_to_pending_confirmation_state`). S-09's +//! in-memory-only property is not weakened by this module; it simply has +//! no path into it to weaken. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use super::tools::{ChatMessage, Role}; +use super::{CallerScope, PermissionCategory}; + +const HISTORY_DIR: &str = "assistant/history"; + +/// A tool result longer than this is truncated before entering history, +/// with a visible marker (AI-SPEC §4b.4). A NEW, assistant-scoped +/// constant — deliberately not the mesh module's own 480-character, +/// LoRa-airtime-tuned reply cap of the same shape (AI-SPEC §3 Pitfall 6). +/// Sized for a local model's context-window budget, not radio bandwidth. +pub const MAX_TOOL_RESULT_CHARS: usize = 4000; + +/// Turns kept verbatim in the recent window before compaction folds older +/// ones into the running summary (AI-SPEC §4b.4's "keep the last K turns +/// verbatim"). +pub const KEEP_VERBATIM_TURNS: usize = 10; + +/// Tool categories whose call arguments are never persisted, regardless of +/// what the actual tool call carried — AI-SPEC §7b's field policy applied +/// to on-disk persistence, not only to tracing. No `wallet`/`files` +/// category tool exists in the D-06 registry today, but this list is what +/// keeps that true of the TRANSCRIPT even once one is added later, +/// mirroring `registry_never_exposes_excluded_authority`'s +/// scan-the-whole-set-not-a-review discipline. +const REDACTED_CATEGORIES: [PermissionCategory; 2] = + [PermissionCategory::Wallet, PermissionCategory::Files]; + +/// D-02's per-caller history key: a mesh peer's transcript and the local +/// operator's transcript are structurally distinct files, derived from +/// `CallerScope` itself — not two rows an implementer could forget to +/// filter on. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct HistoryKey(String); + +impl HistoryKey { + pub fn from_caller(caller: &CallerScope) -> Self { + match caller { + CallerScope::LocalOperator { session_id } => { + HistoryKey(format!("operator-{}", sanitize(session_id))) + } + CallerScope::Mesh { peer_id, .. } => HistoryKey(format!("mesh-{}", sanitize(peer_id))), + } + } + + fn filename(&self) -> String { + format!("{}.json", self.0) + } +} + +/// Filesystem-safe form of a caller identifier. Session ids and mesh peer +/// ids are not guaranteed to be path-safe, so anything outside a +/// conservative allowlist is replaced — a narrow allowlist (alnum, `-`, +/// `_`) rather than a broad denylist, since two different raw ids that +/// collide after sanitization would incorrectly share a transcript (the +/// exact property `operator_and_mesh_transcripts_are_separate` guards). +fn sanitize(raw: &str) -> String { + let cleaned: String = raw + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + if cleaned.is_empty() { + "unknown".to_string() + } else { + cleaned + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PersistedRole { + System, + User, + Assistant, + Tool, +} + +impl From for PersistedRole { + fn from(r: Role) -> Self { + match r { + Role::System => PersistedRole::System, + Role::User => PersistedRole::User, + Role::Assistant => PersistedRole::Assistant, + Role::Tool => PersistedRole::Tool, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistedToolCall { + pub name: String, + /// `None` for a `wallet`/`files`-category tool — the argument VALUE + /// never reaches disk for those categories, regardless of what the + /// tool call actually carried. `Some` for every other category. + pub arguments: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistedToolResult { + pub content: String, + pub is_error: bool, + /// Whether `content` was truncated from a longer result. + #[serde(default)] + pub truncated: bool, +} + +/// One persisted turn — a redacted, size-bounded projection of a +/// `ChatMessage`, never the live in-memory type itself. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistedMessage { + pub role: PersistedRole, + pub text: Option, + #[serde(default)] + pub tool_calls: Vec, + #[serde(default)] + pub tool_results: Vec, +} + +/// A persisted transcript: the running summary of everything folded out of +/// the verbatim window, plus the verbatim window itself. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct History { + /// Extended incrementally as turns age out of the verbatim window — + /// never regenerated from the full transcript (AI-SPEC §4b.4's + /// bounded-summarization-cost requirement). + #[serde(default)] + pub summary: String, + /// The most recent turns, kept verbatim, oldest first. Bounded to + /// `KEEP_VERBATIM_TURNS` by `compact()`. + #[serde(default)] + pub turns: Vec, +} + +impl History { + pub fn empty() -> Self { + Self::default() + } + + fn path(data_dir: &Path, key: &HistoryKey) -> PathBuf { + data_dir.join(HISTORY_DIR).join(key.filename()) + } + + /// Load the persisted transcript for `key`. A missing or unparseable + /// file is an empty history, never an error — a fresh caller (or a + /// caller whose file predates a schema change) starts clean rather + /// than blocking the turn. + pub async fn load(data_dir: &Path, key: &HistoryKey) -> History { + let path = Self::path(data_dir, key); + let Ok(content) = tokio::fs::read_to_string(&path).await else { + return History::empty(); + }; + serde_json::from_str(&content).unwrap_or_else(|_| History::empty()) + } + + /// Delete this caller's transcript file and nothing else — a caller + /// with no file yet (never chatted, or already cleared) is a no-op, + /// never an error. + pub async fn clear(data_dir: &Path, key: &HistoryKey) -> Result<()> { + let path = Self::path(data_dir, key); + match tokio::fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e).context("Failed to remove history file"), + } + } + + /// The verbatim recent window, oldest first. + pub fn recent(&self) -> &[PersistedMessage] { + &self.turns + } + + /// Append one completed turn's worth of messages, redacting + /// wallet/files tool-call arguments and truncating oversized tool + /// results, then persist atomically and compact if the verbatim + /// window has grown past `KEEP_VERBATIM_TURNS`. + /// + /// `tool_categories` maps a tool name to the category the CALLER + /// resolved it to (from the same `tools::registry()` `execute_tool` + /// uses) — this module never re-derives categories itself, so there is + /// no second, hand-maintained category list that could drift from + /// D-06's real one. + pub async fn append( + &mut self, + data_dir: &Path, + key: &HistoryKey, + messages: &[ChatMessage], + tool_categories: &HashMap, + ) -> Result<()> { + for msg in messages { + self.turns.push(project(msg, tool_categories)); + } + self.compact(); + self.save(data_dir, key).await + } + + /// Fold turns older than `KEEP_VERBATIM_TURNS` into `self.summary`, + /// extending it with only the turns that just aged out — never + /// re-summarizing the whole transcript, so the cost of compaction + /// itself stays bounded as the transcript grows (AI-SPEC §4b.4). + pub fn compact(&mut self) { + if self.turns.len() <= KEEP_VERBATIM_TURNS { + return; + } + let overflow = self.turns.len() - KEEP_VERBATIM_TURNS; + let aged_out: Vec = self.turns.drain(0..overflow).collect(); + let extension = summarize_turns(&aged_out); + if extension.is_empty() { + return; + } + if self.summary.is_empty() { + self.summary = extension; + } else { + self.summary.push('\n'); + self.summary.push_str(&extension); + } + } + + /// Write atomically: serialize to a sibling temp file in the same + /// directory, then `rename` over the target — matches + /// `music/index.rs::save_atomic`'s discipline (this codebase's own + /// precedent for a `data_dir`-scoped JSON index). A reader never sees + /// a partial file, and a crash mid-write leaves the previous + /// transcript intact. + async fn save(&self, data_dir: &Path, key: &HistoryKey) -> Result<()> { + let path = Self::path(data_dir, key); + let dir = path + .parent() + .expect("history path always has a parent directory") + .to_path_buf(); + tokio::fs::create_dir_all(&dir) + .await + .context("Failed to create assistant/history dir")?; + + let tmp = dir.join(format!(".{}.tmp.{}", key.filename(), std::process::id())); + let content = serde_json::to_string_pretty(self).context("Failed to serialize history")?; + let write_result: Result<()> = async { + tokio::fs::write(&tmp, &content) + .await + .context("Failed to write history temp file")?; + tokio::fs::rename(&tmp, &path) + .await + .context("Failed to rename history into place")?; + Ok(()) + } + .await; + if write_result.is_err() { + let _ = tokio::fs::remove_file(&tmp).await; + } + write_result?; + + // 0600: following `grants.rs`'s convention (itself following + // `streaming/session.rs`'s data_dir-scoped persisted-state + // pattern) — a transcript is a sensitive-data location by + // definition (D-08), never world-readable. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).ok(); + } + Ok(()) + } +} + +/// Whether a tool call's category means its arguments must never reach +/// disk. +fn should_redact(category: Option) -> bool { + matches!(category, Some(c) if REDACTED_CATEGORIES.contains(&c)) +} + +/// Truncate a tool result before it enters history, with a visible marker. +fn truncate_result(content: &str) -> (String, bool) { + if content.chars().count() <= MAX_TOOL_RESULT_CHARS { + return (content.to_string(), false); + } + let truncated: String = content.chars().take(MAX_TOOL_RESULT_CHARS).collect(); + ( + format!("{truncated}\n…[truncated — result exceeded {MAX_TOOL_RESULT_CHARS} characters]"), + true, + ) +} + +/// Project a live `ChatMessage` into its persisted, redacted, +/// size-bounded form. The ONLY inputs are a completed turn's `ChatMessage` +/// and a resolved category map — no parameter here has a path to +/// `confirm::PendingConfirmation` (see the module doc and +/// `project_has_no_path_to_pending_confirmation_state`). +fn project( + msg: &ChatMessage, + tool_categories: &HashMap, +) -> PersistedMessage { + PersistedMessage { + role: msg.role.into(), + text: msg.text.clone(), + tool_calls: msg + .tool_calls + .iter() + .map(|c| { + let category = tool_categories.get(&c.name).copied(); + PersistedToolCall { + name: c.name.clone(), + arguments: if should_redact(category) { + None + } else { + Some(c.arguments.clone()) + }, + } + }) + .collect(), + tool_results: msg + .tool_results + .iter() + .map(|r| { + let (content, truncated) = truncate_result(&r.content); + PersistedToolResult { + content, + is_error: r.is_error, + truncated, + } + }) + .collect(), + } +} + +/// A cheap, local, non-model textual digest of the turns aging out of the +/// verbatim window. AI-SPEC §4b.4 names a model-backed summarizer +/// (preferring the already-selected local backend) as the eventual +/// implementation; this plan's scope is the compaction MECHANISM +/// (fold-not-truncate, extend-incrementally-not-regenerate-from-scratch), +/// proven here with a plain digest rather than a model call — see the +/// plan's SUMMARY for why a model-backed summarizer is left as a named +/// follow-up rather than implemented in this pass. +fn summarize_turns(turns: &[PersistedMessage]) -> String { + let mut lines = Vec::with_capacity(turns.len()); + for turn in turns { + match turn.role { + PersistedRole::User => { + if let Some(t) = &turn.text { + lines.push(format!("User asked: {t}")); + } + } + PersistedRole::Assistant => { + if let Some(t) = &turn.text { + lines.push(format!("Assistant answered: {t}")); + } else if !turn.tool_calls.is_empty() { + let names: Vec<&str> = + turn.tool_calls.iter().map(|c| c.name.as_str()).collect(); + lines.push(format!("Assistant called: {}", names.join(", "))); + } + } + PersistedRole::Tool | PersistedRole::System => {} + } + } + lines.join("; ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::assistant::tools::{ToolCall, ToolResult}; + use serde_json::json; + + fn user_msg(text: &str) -> ChatMessage { + ChatMessage { + role: Role::User, + text: Some(text.to_string()), + tool_calls: vec![], + tool_results: vec![], + } + } + + /// Behavior: a completed turn is appended to a transcript stored under + /// `data_dir` and survives a daemon restart (simulated by dropping the + /// in-memory `History` and reloading from disk). + #[tokio::test] + async fn append_persists_and_survives_reload() { + let tmp = tempfile::tempdir().expect("tempdir"); + let key = HistoryKey::from_caller(&CallerScope::LocalOperator { + session_id: "op-1".to_string(), + }); + let categories = HashMap::new(); + + let mut hist = History::load(tmp.path(), &key).await; + assert!( + hist.recent().is_empty(), + "a fresh caller starts with no transcript" + ); + let msg = user_msg("how much disk space is left?"); + hist.append(tmp.path(), &key, std::slice::from_ref(&msg), &categories) + .await + .expect("append"); + + // Simulate a daemon restart: nothing but disk survives. + let reloaded = History::load(tmp.path(), &key).await; + assert_eq!(reloaded.recent().len(), 1); + assert_eq!( + reloaded.recent()[0].text.as_deref(), + Some("how much disk space is left?") + ); + } + + /// Behavior: an operator's AIUI transcript and a mesh peer's + /// transcript are separate — reading one never returns a turn from the + /// other. + #[tokio::test] + async fn operator_and_mesh_transcripts_are_separate() { + let tmp = tempfile::tempdir().expect("tempdir"); + let operator_key = HistoryKey::from_caller(&CallerScope::LocalOperator { + session_id: "op-1".to_string(), + }); + let mesh_key = HistoryKey::from_caller(&CallerScope::Mesh { + peer_id: "peer-1".to_string(), + authorized: true, + }); + let categories = HashMap::new(); + + let mut operator_hist = History::load(tmp.path(), &operator_key).await; + let operator_msg = user_msg("operator secret question"); + operator_hist + .append( + tmp.path(), + &operator_key, + std::slice::from_ref(&operator_msg), + &categories, + ) + .await + .expect("append operator"); + + let mut mesh_hist = History::load(tmp.path(), &mesh_key).await; + let mesh_msg = user_msg("mesh peer question"); + mesh_hist + .append( + tmp.path(), + &mesh_key, + std::slice::from_ref(&mesh_msg), + &categories, + ) + .await + .expect("append mesh"); + + let reloaded_operator = History::load(tmp.path(), &operator_key).await; + let reloaded_mesh = History::load(tmp.path(), &mesh_key).await; + + assert_eq!(reloaded_operator.recent().len(), 1); + assert_eq!( + reloaded_operator.recent()[0].text.as_deref(), + Some("operator secret question") + ); + assert_eq!(reloaded_mesh.recent().len(), 1); + assert_eq!( + reloaded_mesh.recent()[0].text.as_deref(), + Some("mesh peer question") + ); + + assert!( + !reloaded_operator + .recent() + .iter() + .any(|t| t.text.as_deref() == Some("mesh peer question")), + "the operator's transcript must never contain the mesh peer's turn" + ); + assert!( + !reloaded_mesh + .recent() + .iter() + .any(|t| t.text.as_deref() == Some("operator secret question")), + "the mesh transcript must never contain the operator's turn" + ); + } + + /// Behavior: a tool result longer than the cap is truncated before it + /// enters history, with a visible marker; a short result is untouched. + #[tokio::test] + async fn long_tool_result_is_truncated_with_marker() { + let tmp = tempfile::tempdir().expect("tempdir"); + let key = HistoryKey::from_caller(&CallerScope::LocalOperator { + session_id: "s".to_string(), + }); + let categories = HashMap::new(); + + let long_content = "x".repeat(MAX_TOOL_RESULT_CHARS + 500); + let long_msg = ChatMessage { + role: Role::Tool, + text: None, + tool_calls: vec![], + tool_results: vec![ToolResult { + call_id: "1".to_string(), + content: long_content.clone(), + is_error: false, + }], + }; + let mut hist = History::load(tmp.path(), &key).await; + hist.append( + tmp.path(), + &key, + std::slice::from_ref(&long_msg), + &categories, + ) + .await + .expect("append"); + + let reloaded = History::load(tmp.path(), &key).await; + let persisted = &reloaded.recent()[0].tool_results[0]; + assert!( + persisted.truncated, + "an oversized result must be marked truncated" + ); + assert!(persisted.content.len() < long_content.len()); + assert!( + persisted.content.to_lowercase().contains("truncated"), + "the marker must be visible in the persisted content: {}", + persisted.content + ); + + let short_msg = ChatMessage { + role: Role::Tool, + text: None, + tool_calls: vec![], + tool_results: vec![ToolResult { + call_id: "2".to_string(), + content: "short".to_string(), + is_error: false, + }], + }; + let mut hist2 = History::load(tmp.path(), &key).await; + hist2 + .append( + tmp.path(), + &key, + std::slice::from_ref(&short_msg), + &categories, + ) + .await + .expect("append short"); + let reloaded2 = History::load(tmp.path(), &key).await; + let short_persisted = &reloaded2.recent()[1].tool_results[0]; + assert!( + !short_persisted.truncated, + "a short result must not be marked truncated" + ); + assert_eq!(short_persisted.content, "short"); + } + + /// Behavior: once the transcript exceeds the verbatim window, older + /// turns fold into a running summary and the recent window stays + /// verbatim; the summary is extended incrementally rather than + /// regenerated from scratch. + #[tokio::test] + async fn compaction_folds_older_turns_into_incremental_summary() { + let tmp = tempfile::tempdir().expect("tempdir"); + let key = HistoryKey::from_caller(&CallerScope::LocalOperator { + session_id: "s".to_string(), + }); + let categories = HashMap::new(); + let mut hist = History::load(tmp.path(), &key).await; + + for i in 0..(KEEP_VERBATIM_TURNS + 3) { + let msg = user_msg(&format!("turn {i}")); + hist.append(tmp.path(), &key, std::slice::from_ref(&msg), &categories) + .await + .expect("append"); + } + + assert_eq!( + hist.recent().len(), + KEEP_VERBATIM_TURNS, + "the verbatim window must stay bounded at KEEP_VERBATIM_TURNS" + ); + assert!( + !hist.summary.is_empty(), + "turns that aged out of the window must be folded into the summary, not dropped" + ); + assert!(hist.summary.contains("turn 0")); + assert!(hist.summary.contains("turn 1")); + assert!(hist.summary.contains("turn 2")); + assert_eq!( + hist.recent().last().unwrap().text.as_deref(), + Some(format!("turn {}", KEEP_VERBATIM_TURNS + 2).as_str()), + "the verbatim window must keep the MOST RECENT turns" + ); + + let summary_before_further_growth = hist.summary.clone(); + + for i in (KEEP_VERBATIM_TURNS + 3)..(KEEP_VERBATIM_TURNS + 6) { + let msg = user_msg(&format!("turn {i}")); + hist.append(tmp.path(), &key, std::slice::from_ref(&msg), &categories) + .await + .expect("append"); + } + assert!( + hist.summary.contains(&summary_before_further_growth), + "the summary must be EXTENDED incrementally — the earlier summary text must survive \ + verbatim as a substring, never be regenerated from the full transcript" + ); + assert_eq!(hist.recent().len(), KEEP_VERBATIM_TURNS); + } + + /// Behavior: `assistant.clear-history` removes the calling session's + /// transcript and nothing else. + #[tokio::test] + async fn clear_removes_only_this_callers_transcript() { + let tmp = tempfile::tempdir().expect("tempdir"); + let key_a = HistoryKey::from_caller(&CallerScope::LocalOperator { + session_id: "a".to_string(), + }); + let key_b = HistoryKey::from_caller(&CallerScope::LocalOperator { + session_id: "b".to_string(), + }); + let categories = HashMap::new(); + let msg = user_msg("hi"); + + let mut hist_a = History::load(tmp.path(), &key_a).await; + hist_a + .append(tmp.path(), &key_a, std::slice::from_ref(&msg), &categories) + .await + .expect("append a"); + let mut hist_b = History::load(tmp.path(), &key_b).await; + hist_b + .append(tmp.path(), &key_b, std::slice::from_ref(&msg), &categories) + .await + .expect("append b"); + + History::clear(tmp.path(), &key_a).await.expect("clear a"); + + let reloaded_a = History::load(tmp.path(), &key_a).await; + let reloaded_b = History::load(tmp.path(), &key_b).await; + assert!( + reloaded_a.recent().is_empty(), + "the cleared transcript must load empty" + ); + assert_eq!( + reloaded_b.recent().len(), + 1, + "clearing one caller's transcript must not touch another caller's" + ); + + History::clear(tmp.path(), &key_a) + .await + .expect("clearing an already-absent transcript is a no-op, not an error"); + } + + /// Behavior: no tool argument value from a `wallet`- or + /// `files`-category tool is written to the transcript file — asserted + /// both at the deserialized-struct level and against the raw on-disk + /// bytes, so this proves the value never touches disk, not merely that + /// a struct field reads `None`. + #[tokio::test] + async fn wallet_tool_arguments_never_reach_the_transcript() { + let tmp = tempfile::tempdir().expect("tempdir"); + let key = HistoryKey::from_caller(&CallerScope::LocalOperator { + session_id: "s".to_string(), + }); + let mut categories = HashMap::new(); + categories.insert("wallet_send".to_string(), PermissionCategory::Wallet); + categories.insert("files_read".to_string(), PermissionCategory::Files); + categories.insert("app_restart".to_string(), PermissionCategory::Apps); + + let msg = ChatMessage { + role: Role::Assistant, + text: None, + tool_calls: vec![ + ToolCall { + id: "1".to_string(), + name: "wallet_send".to_string(), + arguments: json!({ "amount_sats": 5000, "address": "bc1qexampleexampleexample" }), + }, + ToolCall { + id: "2".to_string(), + name: "files_read".to_string(), + arguments: json!({ "path": "/home/user/very-secret-plan.txt" }), + }, + ToolCall { + id: "3".to_string(), + name: "app_restart".to_string(), + arguments: json!({ "app_id": "immich" }), + }, + ], + tool_results: vec![], + }; + + let mut hist = History::load(tmp.path(), &key).await; + hist.append(tmp.path(), &key, std::slice::from_ref(&msg), &categories) + .await + .expect("append"); + + let reloaded = History::load(tmp.path(), &key).await; + let persisted = &reloaded.recent()[0]; + assert_eq!( + persisted.tool_calls[0].arguments, None, + "wallet-category tool arguments must never reach the transcript" + ); + assert_eq!( + persisted.tool_calls[1].arguments, None, + "files-category tool arguments must never reach the transcript" + ); + assert_eq!( + persisted.tool_calls[2].arguments, + Some(json!({ "app_id": "immich" })), + "a non-redacted category's arguments ARE persisted" + ); + + // The stronger property: the raw file bytes never contain the + // sensitive values at all. + let raw = tokio::fs::read_to_string(History::path(tmp.path(), &key)) + .await + .expect("read raw file"); + assert!(!raw.contains("5000"), "wallet amount must never touch disk"); + assert!( + !raw.contains("bc1qexampleexampleexample"), + "wallet address must never touch disk" + ); + assert!( + !raw.contains("very-secret-plan.txt"), + "files path must never touch disk" + ); + } + + /// Type-level assertion: `project`'s only inputs are `&ChatMessage` and + /// a resolved category map — there is no parameter type here through + /// which a `confirm::PendingConfirmation` could ever reach this + /// function, so it is structurally impossible for this module to + /// persist pending-confirmation state. + #[test] + fn project_has_no_path_to_pending_confirmation_state() { + let _shape: fn(&ChatMessage, &HashMap) -> PersistedMessage = + project; + } + + /// Behavior: the transcript file is created 0600. + #[tokio::test] + async fn history_file_is_created_0600() { + let tmp = tempfile::tempdir().expect("tempdir"); + let key = HistoryKey::from_caller(&CallerScope::LocalOperator { + session_id: "s".to_string(), + }); + let categories = HashMap::new(); + let msg = user_msg("hi"); + let mut hist = History::load(tmp.path(), &key).await; + hist.append(tmp.path(), &key, std::slice::from_ref(&msg), &categories) + .await + .expect("append"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let meta = std::fs::metadata(History::path(tmp.path(), &key)).expect("metadata"); + assert_eq!( + meta.permissions().mode() & 0o777, + 0o600, + "the transcript file must be 0600" + ); + } + } +} diff --git a/core/archipelago/src/assistant/loop_.rs b/core/archipelago/src/assistant/loop_.rs index 79675b0f..94e0affe 100644 --- a/core/archipelago/src/assistant/loop_.rs +++ b/core/archipelago/src/assistant/loop_.rs @@ -22,16 +22,35 @@ use super::ToolExecCtx; /// Hard stop — a looping model must never spin unbounded (D-05). pub const MAX_TURNS: usize = 8; +/// Runs the multi-turn loop to a final answer. Returns `(answer, +/// full_history)` — `full_history` is the caller-supplied `history` with +/// every message this call appended (assistant tool-call turns, tool +/// results, and a final trailing `Assistant` message carrying `answer` +/// itself). 13-10/D-08 needs this to persist the SAME transcript +/// `history.rs` records — `run_loop` returning only the answer string +/// would leave the caller no way to see the tool-call/tool-result messages +/// the loop built internally (Rule 3: structurally necessary for D-08's +/// full-turn persistence, mirroring 13-05's precedent of touching a file +/// outside its own plan's `files_modified` list when the plan's own intent +/// requires it — see 13-10-SUMMARY.md's Deviations). pub async fn run_loop( backend: &dyn Backend, system: &str, tools: &[ToolDef], mut history: Vec, ctx: &ToolExecCtx, -) -> Result { +) -> Result<(String, Vec)> { for _ in 0..MAX_TURNS { match backend.send(system, tools, &history).await? { - BackendTurn::Text(answer) => return Ok(answer), + BackendTurn::Text(answer) => { + history.push(ChatMessage { + role: Role::Assistant, + text: Some(answer.clone()), + tool_calls: vec![], + tool_results: vec![], + }); + return Ok((answer, history)); + } BackendTurn::ToolCalls(calls) => { history.push(ChatMessage { role: Role::Assistant, @@ -50,11 +69,22 @@ pub async fn run_loop( // consecutive validation failures, rather than continuing // to ask the model to try again. if ctx.should_abort() { - return Ok( - "I'm stopping here — the same tool call kept failing validation. \ - Could you rephrase what you'd like me to do?" - .to_string(), - ); + let apology = "I'm stopping here — the same tool call kept failing \ + validation. Could you rephrase what you'd like me to do?" + .to_string(); + history.push(ChatMessage { + role: Role::Tool, + text: None, + tool_calls: vec![], + tool_results: results, + }); + history.push(ChatMessage { + role: Role::Assistant, + text: Some(apology.clone()), + tool_calls: vec![], + tool_results: vec![], + }); + return Ok((apology, history)); } history.push(ChatMessage { role: Role::Tool, @@ -292,7 +322,7 @@ mod tests { BackendTurn::Text("Disk space report generated.".to_string()), ]); let tools_list = vec![system_disk_status_tool()]; - let answer = run_loop(&backend, "system prompt", &tools_list, vec![], &ctx) + let (answer, _history) = run_loop(&backend, "system prompt", &tools_list, vec![], &ctx) .await .expect("run_loop"); assert_eq!(answer, "Disk space report generated."); diff --git a/core/archipelago/src/assistant/mod.rs b/core/archipelago/src/assistant/mod.rs index 5ca85620..aadf345c 100644 --- a/core/archipelago/src/assistant/mod.rs +++ b/core/archipelago/src/assistant/mod.rs @@ -13,6 +13,7 @@ pub mod backends; pub mod confirm; pub mod grants; +pub mod history; pub mod loop_; pub mod tools; @@ -263,9 +264,20 @@ pub fn build_system_prompt(visible_tools: &[tools::ToolDef]) -> String { /// Entry point: run one chat turn for `caller` through the shared loop. /// Builds the visible-tool set from the caller's granted categories only /// (D-16 — the model should never even see a tool it can't use), selects a -/// backend per D-04's chain (Ollama first, Claude fallback), and runs it to -/// a final answer. 13-10 Task 2 adds D-08 history persistence on top of -/// this. +/// backend per D-04's chain (Ollama first, Claude fallback), runs it to a +/// final answer, and persists the completed turn to this caller's own +/// `history.rs` transcript (D-08/D-02) before returning. +/// +/// Scoping note: this seeds the model's context with only the NEW user +/// message, not the caller's prior persisted turns — the persistence +/// side of D-08 (append/load/compact/redact/truncate, `assistant.history`/ +/// `assistant.clear-history`) is fully wired, but feeding loaded history +/// back into a live multi-turn conversation is left for a follow-up (see +/// 13-10-SUMMARY.md's Next Phase Readiness): reconstructing prior +/// `ToolCall`/`ToolResult` pairs from the persisted, id-less record in a +/// way that stays correct against Claude's strict `tool_use`/`tool_result` +/// id-pairing requirement needs its own test budget, and none of Task 2's +/// `` bullets require it this plan. pub async fn chat( handler: Arc, caller: CallerScope, @@ -280,23 +292,48 @@ pub async fn chat( let system_prompt = build_system_prompt(&visible_tools); - let history = vec![tools::ChatMessage { + let key = history::HistoryKey::from_caller(&caller); + + let turn_history = vec![tools::ChatMessage { role: tools::Role::User, text: Some(user_text), tool_calls: vec![], tool_results: vec![], }]; - let ctx = ToolExecCtx::new(registry, caller, handler); + let ctx = ToolExecCtx::new(registry, caller, handler.clone()); - loop_::run_loop( + let (answer, turn_messages) = loop_::run_loop( backend.as_ref(), &system_prompt, &visible_tools, - history, + turn_history, &ctx, ) - .await + .await?; + + // D-08: persist this completed turn (the user's message, any + // intermediate tool-call/tool-result messages, and the final answer) + // — never the pending-confirmation state, which this function has no + // access to in the first place (see history.rs's module doc; S-09 + // stays true structurally). + let tool_categories: std::collections::HashMap = tools::registry() + .all() + .into_iter() + .map(|t| (t.name.to_string(), t.category)) + .collect(); + let mut hist = history::History::load(handler.data_dir(), &key).await; + if let Err(e) = hist + .append(handler.data_dir(), &key, &turn_messages, &tool_categories) + .await + { + tracing::warn!( + error = %e, + "assistant.chat: failed to persist this turn's history (the answer is still returned to the caller)" + ); + } + + Ok(answer) } #[cfg(test)] @@ -503,7 +540,7 @@ mod tests { gate2 .resolve(&snap2.req_id, &snap2.nonce, false) .expect("decline resolves"); - let answer = loop_task.await.expect("join").expect("run_loop"); + let (answer, _history) = loop_task.await.expect("join").expect("run_loop"); assert_eq!(answer, "Understood — I did not restart it."); } diff --git a/core/archipelago/src/assistant/tools.rs b/core/archipelago/src/assistant/tools.rs index 24728395..9a65a011 100644 --- a/core/archipelago/src/assistant/tools.rs +++ b/core/archipelago/src/assistant/tools.rs @@ -1110,7 +1110,7 @@ mod tests { ]; let backend2 = ScriptedBackend::new(turns2); let tools_list2 = vec![app_logs_tool()]; - let answer = run_loop(&backend2, "sys", &tools_list2, vec![], &ctx2) + let (answer, _history) = run_loop(&backend2, "sys", &tools_list2, vec![], &ctx2) .await .expect("run_loop should abort gracefully with an apology, not error"); assert_ne!(