Skip to content

Commit 2a147e7

Browse files
committed
refactor(memory): extract tool_call_id collection; cover cache/metadata prune
Address the open items from self-review: - Extract the inline closure into a free fn dropped_tool_call_ids (clearer, no behavior change). - Add a test (truncate_prunes_dropped_tool_cache_and_stale_metadata) asserting the rewind prunes tool_result_cache rows for dropped tool_call_ids and resets stale session_metadata reflection pointers into the removed range. 6/6 memory tests green; clippy clean on memory.rs.
1 parent 444dc50 commit 2a147e7

1 file changed

Lines changed: 160 additions & 54 deletions

File tree

src/memory.rs

Lines changed: 160 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,61 +1061,9 @@ impl ActorLogic<MemoryMessage> for SqliteMemoryActor {
10611061
.map_err(|e| e.to_string())?
10621062
};
10631063

1064-
// Gather tool_call_ids of the rows we're about to drop so
1065-
// their cached tool results can be pruned together. Tool
1066-
// results live on role='tool' rows carrying tool_call_id.
1067-
let collect_tool_call_ids =
1068-
|tx: &rusqlite::Transaction,
1069-
after: Option<i64>|
1070-
-> Result<Vec<String>, String> {
1071-
let mut stmt = match after {
1072-
Some(_) => tx
1073-
.prepare(
1074-
"SELECT tool_call_id FROM messages
1075-
WHERE thread_id = ?1 AND id > ?2
1076-
AND tool_call_id IS NOT NULL",
1077-
)
1078-
.map_err(|e| e.to_string())?,
1079-
None => tx
1080-
.prepare(
1081-
"SELECT tool_call_id FROM messages
1082-
WHERE thread_id = ?1 AND tool_call_id IS NOT NULL",
1083-
)
1084-
.map_err(|e| e.to_string())?,
1085-
};
1086-
let mut out: Vec<String> = Vec::new();
1087-
match after {
1088-
Some(id) => {
1089-
let rows = stmt
1090-
.query_map(params![thread_id, id], |r| {
1091-
r.get::<_, Option<String>>(0)
1092-
})
1093-
.map_err(|e| e.to_string())?;
1094-
for row in rows {
1095-
if let Ok(Some(v)) = row {
1096-
out.push(v);
1097-
}
1098-
}
1099-
}
1100-
None => {
1101-
let rows = stmt
1102-
.query_map(params![thread_id], |r| {
1103-
r.get::<_, Option<String>>(0)
1104-
})
1105-
.map_err(|e| e.to_string())?;
1106-
for row in rows {
1107-
if let Ok(Some(v)) = row {
1108-
out.push(v);
1109-
}
1110-
}
1111-
}
1112-
}
1113-
Ok(out)
1114-
};
1115-
11161064
let (deleted, dropped_tool_call_ids) = match cutoff {
11171065
Some(cutoff_id) => {
1118-
let ids = collect_tool_call_ids(&tx, Some(cutoff_id))?;
1066+
let ids = dropped_tool_call_ids(&tx, &thread_id, Some(cutoff_id))?;
11191067
let n = tx
11201068
.execute(
11211069
"DELETE FROM messages WHERE thread_id = ?1 AND id > ?2",
@@ -1125,7 +1073,7 @@ impl ActorLogic<MemoryMessage> for SqliteMemoryActor {
11251073
(n, ids)
11261074
}
11271075
None if keep_user_messages == 0 => {
1128-
let ids = collect_tool_call_ids(&tx, None)?;
1076+
let ids = dropped_tool_call_ids(&tx, &thread_id, None)?;
11291077
let n = tx
11301078
.execute(
11311079
"DELETE FROM messages WHERE thread_id = ?1",
@@ -2293,6 +2241,54 @@ impl ActorLogic<MemoryMessage> for SqliteMemoryActor {
22932241
}
22942242
}
22952243

2244+
/// Collect the `tool_call_id`s of messages a rewind is about to remove, so
2245+
/// their cached tool results can be pruned in the same transaction. Tool
2246+
/// results live on `role='tool'` rows carrying `tool_call_id`. `after =
2247+
/// Some(id)` restricts to rows with `id > after`; `None` covers the whole
2248+
/// thread (used by the full-wipe path).
2249+
fn dropped_tool_call_ids(
2250+
tx: &rusqlite::Transaction,
2251+
thread_id: &str,
2252+
after: Option<i64>,
2253+
) -> Result<Vec<String>, String> {
2254+
let mut out: Vec<String> = Vec::new();
2255+
match after {
2256+
Some(id) => {
2257+
let mut stmt = tx
2258+
.prepare(
2259+
"SELECT tool_call_id FROM messages
2260+
WHERE thread_id = ?1 AND id > ?2 AND tool_call_id IS NOT NULL",
2261+
)
2262+
.map_err(|e| e.to_string())?;
2263+
let rows = stmt
2264+
.query_map(params![thread_id, id], |r| r.get::<_, Option<String>>(0))
2265+
.map_err(|e| e.to_string())?;
2266+
for row in rows {
2267+
if let Ok(Some(v)) = row {
2268+
out.push(v);
2269+
}
2270+
}
2271+
}
2272+
None => {
2273+
let mut stmt = tx
2274+
.prepare(
2275+
"SELECT tool_call_id FROM messages
2276+
WHERE thread_id = ?1 AND tool_call_id IS NOT NULL",
2277+
)
2278+
.map_err(|e| e.to_string())?;
2279+
let rows = stmt
2280+
.query_map(params![thread_id], |r| r.get::<_, Option<String>>(0))
2281+
.map_err(|e| e.to_string())?;
2282+
for row in rows {
2283+
if let Ok(Some(v)) = row {
2284+
out.push(v);
2285+
}
2286+
}
2287+
}
2288+
}
2289+
Ok(out)
2290+
}
2291+
22962292
#[cfg(test)]
22972293
mod root_thread_id_tests {
22982294
use super::{is_root_session_thread_id, MemoryMessage, SharedReply, SqliteMemoryActor};
@@ -2426,4 +2422,114 @@ mod root_thread_id_tests {
24262422
assert_eq!(deleted, 1);
24272423
assert!(session.get_context().await.expect("context").is_empty());
24282424
}
2425+
2426+
#[tokio::test]
2427+
async fn truncate_prunes_dropped_tool_cache_and_stale_metadata() {
2428+
let actor = SqliteMemoryActor::new(":memory:").expect("memory actor");
2429+
let node = NodeHandle::new(actor, 16, 1, Duration::from_millis(1));
2430+
let manager = SessionManager::new(node.clone());
2431+
let thread_id = "terminal:660e8400-e29b-41d4-a716-446655440099:";
2432+
let mut session = manager.get_session(thread_id).await.expect("session");
2433+
2434+
// Layout (ids 1..4): u1, a tool-result row carrying tool_call_id, u2, a2.
2435+
// Rewinding to u1 (keep=1) drops ids 2..4 — the tool row feeds the cache
2436+
// prune; a2 (id=4) backs the stale reflection pointer.
2437+
session
2438+
.add_message(crate::utils::ChatMessage::user("u1"))
2439+
.await
2440+
.expect("add u1");
2441+
session
2442+
.add_message(crate::utils::ChatMessage {
2443+
role: "tool".to_string(),
2444+
content: Some(crate::utils::MessageContent::Text(
2445+
"tool result".to_string(),
2446+
)),
2447+
name: None,
2448+
tool_calls: None,
2449+
tool_call_id: Some("call_1".to_string()),
2450+
reasoning_content: None,
2451+
is_error: None,
2452+
})
2453+
.await
2454+
.expect("add tool");
2455+
session
2456+
.add_message(crate::utils::ChatMessage::user("u2"))
2457+
.await
2458+
.expect("add u2");
2459+
session
2460+
.add_message(crate::utils::ChatMessage::assistant("a2"))
2461+
.await
2462+
.expect("add a2");
2463+
2464+
// Cache the tool result and point reflection at a2 (id=4, doomed).
2465+
let (tx, rx) = tokio::sync::oneshot::channel();
2466+
node.send_packet(MemoryMessage::CacheToolResult {
2467+
tool_call_id: "call_1".to_string(),
2468+
chat_id: thread_id.to_string(),
2469+
session_key: thread_id.to_string(),
2470+
tool_name: "demo".to_string(),
2471+
full_content: "full tool result".to_string(),
2472+
compact_summary: "summary".to_string(),
2473+
reply: SharedReply::new(tx),
2474+
})
2475+
.await
2476+
.expect("send cache");
2477+
rx.await.expect("reply").expect("cache ok");
2478+
2479+
let (tx, rx) = tokio::sync::oneshot::channel();
2480+
node.send_packet(MemoryMessage::UpdateThreadMetadata {
2481+
thread_id: thread_id.to_string(),
2482+
last_reflection_msg_id: Some(4),
2483+
reply: SharedReply::new(tx),
2484+
})
2485+
.await
2486+
.expect("send metadata");
2487+
rx.await.expect("reply").expect("metadata ok");
2488+
2489+
// Sanity: cache is present before the rewind.
2490+
let (tx, rx) = tokio::sync::oneshot::channel();
2491+
node.send_packet(MemoryMessage::FetchToolResult {
2492+
tool_call_id: "call_1".to_string(),
2493+
reply: SharedReply::new(tx),
2494+
})
2495+
.await
2496+
.expect("send fetch");
2497+
assert_eq!(
2498+
rx.await.expect("reply").expect("fetch ok"),
2499+
Some("full tool result".to_string())
2500+
);
2501+
2502+
// Rewind to u1 — drops ids 2..4.
2503+
let (tx, rx) = tokio::sync::oneshot::channel();
2504+
node.send_packet(MemoryMessage::TruncateAfterUserMessage {
2505+
thread_id: thread_id.to_string(),
2506+
keep_user_messages: 1,
2507+
reply: SharedReply::new(tx),
2508+
})
2509+
.await
2510+
.expect("send truncate");
2511+
let deleted = rx.await.expect("reply").expect("truncate ok");
2512+
assert_eq!(deleted, 3);
2513+
2514+
// Cached tool result for "call_1" was pruned with its message.
2515+
let (tx, rx) = tokio::sync::oneshot::channel();
2516+
node.send_packet(MemoryMessage::FetchToolResult {
2517+
tool_call_id: "call_1".to_string(),
2518+
reply: SharedReply::new(tx),
2519+
})
2520+
.await
2521+
.expect("send fetch");
2522+
assert_eq!(rx.await.expect("reply").expect("fetch ok"), None);
2523+
2524+
// Reflection pointer (id=4) was stale → metadata reset to default.
2525+
let (tx, rx) = tokio::sync::oneshot::channel();
2526+
node.send_packet(MemoryMessage::GetThreadMetadata {
2527+
thread_id: thread_id.to_string(),
2528+
reply: SharedReply::new(tx),
2529+
})
2530+
.await
2531+
.expect("send get-metadata");
2532+
let (last_id, _time) = rx.await.expect("reply").expect("metadata ok");
2533+
assert_eq!(last_id, None);
2534+
}
24292535
}

0 commit comments

Comments
 (0)