Watch
1
0
Fork
You've already forked souveraine
0

subconscious: heal persisted tool-call history

This commit is contained in:
Fimeg 2026-08-11 16:05:38 -04:00
commit dfc72d7214
2 changed files with 72 additions and 40 deletions

View file

@ -257,7 +257,12 @@ impl Session {
MessageRole::System => "system",
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::Tool => "tool",
// Cross-turn history is deliberately text-only.
// A role=tool message without tool_call_id is
// rejected by OpenAI-shaped providers, so even a
// legacy text block stored under Tool must replay
// as assistant context.
MessageRole::Tool => "assistant",
};
messages.push(crate::bridge::bifrost::Message::text(role, text.clone()));
}
@ -337,4 +342,49 @@ mod tests {
assert_eq!(msgs[0].role, "user");
assert_eq!(msgs[0].content.as_text(), "hello");
}
#[test]
fn cross_turn_tool_history_has_no_wire_obligations() {
let mut session = Session::new("subconscious");
session.add_message(ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![
ContentBlock::ToolUse {
id: "call-a".into(),
name: "read".into(),
input: r#"{"path":"a"}"#.into(),
},
ContentBlock::ToolUse {
id: "call-b".into(),
name: "read".into(),
input: r#"{"path":"b"}"#.into(),
},
],
usage: None,
timestamp: None,
});
session.add_message(ConversationMessage::tool_result(
"call-a", "read", "A", false,
));
// Simulate stale persisted damage: call-b never received a result.
session.add_message(ConversationMessage {
role: MessageRole::Tool,
blocks: vec![ContentBlock::Text {
text: "legacy tool text".into(),
}],
usage: None,
timestamp: None,
});
let msgs = session.to_bifrost_messages();
assert!(msgs.iter().all(|m| m.role != "tool"));
assert!(msgs.iter().all(|m| m.tool_calls.is_none()));
assert!(msgs.iter().all(|m| m.tool_call_id.is_none()));
assert!(msgs
.iter()
.any(|m| m.content.as_text().contains("Tool use: read")));
assert!(msgs
.iter()
.any(|m| m.content.as_text().contains("Result (read): A")));
}
}

View file

@ -32,7 +32,7 @@ use crate::bridge::model_router::TokenCounter;
use crate::bridge::LlmProvider;
use crate::bridge::ProviderRegistry;
use crate::core::compact::CompactionEngine;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole, Session};
use crate::core::subconscious::{InboxItem, SubconsciousInbox, Urgency};
use crate::core::tools::defs::ToolContext;
use crate::server::{AgentInventory, SessionManager};
@ -791,44 +791,26 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
let mut messages: Vec<Message> = Vec::new();
messages.push(Message::text("system", system_prompt.to_string()));
// Replay prior conversation (skip old system messages — we replaced above).
for msg in &prior_messages {
if msg.role == MessageRole::System {
continue;
}
for block in &msg.blocks {
match block {
ContentBlock::Text { text } => {
let role = match msg.role {
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::Tool => "tool",
MessageRole::System => continue,
};
messages.push(Message::text(role, text.clone()));
}
ContentBlock::ToolUse { id, name, input } => {
messages.push(Message::assistant_tool_calls(
String::new(),
vec![crate::bridge::bifrost::MessageToolCall::function(
id.clone(),
name.clone(),
input.clone(),
)],
));
}
ContentBlock::ToolResult {
tool_use_id,
tool_name,
output,
..
} => {
messages.push(Message::tool_result(tool_use_id, tool_name, output.clone()));
}
_ => {}
}
}
}
// Replay prior passes as text-only cross-turn context. Exact tool-call
// linkage matters inside the live loop below, but it is brittle across
// persisted turns: an older crash can leave a dangling call, and the
// former block-by-block replay split one assistant message containing
// several calls into adjacent assistant messages before any results.
// OpenAI-shaped providers reject both histories with:
// "assistant tool_calls must be followed by tool messages".
//
// Session's cross-turn projection preserves the semantic record as
// assistant prose while deliberately emitting no role=tool messages or
// tool_call_id obligations. That also heals already-persisted malformed
// subconscious threads without deleting their memory.
let mut persisted = Session::with_id(sub_id, &conv_id);
persisted.messages = prior_messages;
messages.extend(
persisted
.to_bifrost_messages()
.into_iter()
.filter(|m| m.role != "system"),
);
// Append the new exchange for this pass.
messages.push(Message::text("user", user_content));