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