feat(13-10): node-side chat history, scoped by caller, compacted not truncated

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 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-05 18:03:55 -04:00
co-authored by Claude Fable 5
parent 821d8700ce
commit 3da9928cc9
5 changed files with 900 additions and 18 deletions
@@ -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<Self>,
session_token: &Option<String>,
) -> Result<serde_json::Value> {
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<Self>,
session_token: &Option<String>,
) -> Result<serde_json::Value> {
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
+779
View File
@@ -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<Role> 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<serde_json::Value>,
}
#[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<String>,
#[serde(default)]
pub tool_calls: Vec<PersistedToolCall>,
#[serde(default)]
pub tool_results: Vec<PersistedToolResult>,
}
/// 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<PersistedMessage>,
}
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<String, PermissionCategory>,
) -> 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<PersistedMessage> = 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<PermissionCategory>) -> 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<String, PermissionCategory>,
) -> 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<String, PermissionCategory>) -> 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"
);
}
}
}
+38 -8
View File
@@ -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<ChatMessage>,
ctx: &ToolExecCtx,
) -> Result<String> {
) -> Result<(String, Vec<ChatMessage>)> {
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.");
+46 -9
View File
@@ -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
/// `<behavior>` bullets require it this plan.
pub async fn chat(
handler: Arc<RpcHandler>,
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<String, PermissionCategory> = 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.");
}
+1 -1
View File
@@ -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!(