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:
co-authored by
Claude Fable 5
parent
821d8700ce
commit
3da9928cc9
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user