subconscious: heal persisted tool-call history
This commit is contained in:
parent
2179b627d1
commit
dfc72d7214
2 changed files with 72 additions and 40 deletions
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue