fix(13-10/13-11): replay chat history to the model; unbreak general answers

Four defects found by on-device UAT, 2026-08-06.

1. D-08 persistence was WRITE-ONLY. chat() loaded the transcript only
   AFTER the loop, to append — the model was never shown any of it. The
   assistant answered "I don't have access to any previous conversation
   history" with its own transcript on disk, and "and is it healthy?"
   resolved to the node instead of the app just discussed. History now
   replays into every turn (text only: a stale tool result must not be
   re-presented as this turn's evidence), scoped by HistoryKey. The
   replayed prefix is excluded from the append, or each turn would
   re-persist the conversation and grow it geometrically.

2. The operator persona forbade the very answers the content surfaces
   render. 13-01's prompt refuses anything without a matching tool, so
   "recommend me 10 sci-fi films" was declined and the film/song/podcast
   grids from 13-11 could never populate — two plans in contradiction.
   The refusal rule now governs ACTIONS ON THE NODE; general questions
   and recommendations are answered from the model's own knowledge.
   (Whether the node should also SEARCH THE WEB depends on AIUI's
   web-search setting, which embedded mode never forwards — captured as
   a separate todo because it opens a new egress path.)

3. The content-surface loader labelled unrelated queries "Podcast
   recommendations": the classifier matched a bare "show", which is how
   operators phrase almost everything ("show me my files").

4. "Surfacing…" tracks at 0.2em and its final glyph collided with the
   close button; the header now spaces them properly.

assistant::history 9/9 green incl. replay_feeds_prior_turns_back_to_the_model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-06 13:24:41 -04:00
co-authored by Claude Fable 5
parent 857d9d4906
commit 82d1b60891
4 changed files with 132 additions and 7 deletions
+27 -4
View File
@@ -689,7 +689,14 @@ than redundant: it stalls the action behind a reply you cannot act on, and it tr
operator to rubber-stamp. If the user asks for something outside the tools listed below \
(including anything touching keys, seeds, wallet spends, federation trust, or a factory reset), \
refuse plainly and, if there is a real path in neode-ui's Settings screen for it, name that path \
instead of fabricating a tool call.";
instead of fabricating a tool call.\n\n\
That refusal rule is about ACTIONS ON THIS NODE, not about conversation. You are also the \
owner's general assistant: answer ordinary questions, explain things, and give recommendations \
(films, music, podcasts, books, reading) from your own knowledge, exactly as a capable \
assistant would. Those answers are what the content surfaces in this app render as cards, so \
declining to answer them leaves the owner staring at an empty panel. Only a request to CHANGE \
or READ something on the node needs a tool — and if no tool covers it, say so plainly rather \
than pretending. Never let a general question be refused merely because no tool matches it.";
pub fn build_system_prompt(visible_tools: &[tools::ToolDef]) -> String {
let mut prompt = String::from(SYSTEM_PROMPT_PREAMBLE);
@@ -741,12 +748,25 @@ pub async fn chat(
let key = history::HistoryKey::from_caller(&caller);
let turn_history = vec![tools::ChatMessage {
// D-08: replay this caller's own transcript so the model can resolve
// "it"/"that one" and answer "what were we just talking about?".
// Loading only AFTER the loop (to append) made persistence write-only:
// every turn was stored and none was ever shown, so the assistant
// denied having any history while its transcript sat on disk. Scoped
// by HistoryKey, so one caller never sees another's turns.
let prior = history::History::load(handler.data_dir(), &key).await;
let mut turn_history = history::replay(&prior);
// Everything before this index is ALREADY persisted — `run_loop`
// returns the whole conversation it was given plus this turn's new
// messages, so persisting it wholesale would re-append the replayed
// prefix and grow the transcript geometrically.
let already_persisted = turn_history.len();
turn_history.push(tools::ChatMessage {
role: tools::Role::User,
text: Some(user_text),
tool_calls: vec![],
tool_results: vec![],
}];
});
let ctx = ToolExecCtx::new(registry, caller, handler.clone());
@@ -770,8 +790,11 @@ pub async fn chat(
.map(|t| (t.name.to_string(), t.category))
.collect();
let mut hist = history::History::load(handler.data_dir(), &key).await;
let new_messages = turn_messages
.get(already_persisted..)
.unwrap_or(&turn_messages);
if let Err(e) = hist
.append(handler.data_dir(), &key, &turn_messages, &tool_categories)
.append(handler.data_dir(), &key, new_messages, &tool_categories)
.await
{
tracing::warn!(