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
+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.");
}