Skip to content

Commit 7ea8eba

Browse files
authored
Merge pull request #5840 from Hmbown/fix/serve-tool-history-5823
runtime: persist tool-call identity so restarts replay valid history (#5823)
2 parents 66360de + 208ce71 commit 7ea8eba

2 files changed

Lines changed: 352 additions & 35 deletions

File tree

crates/tui/src/runtime_threads.rs

Lines changed: 74 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8319,51 +8319,57 @@ impl RuntimeThreadManager {
83198319
}
83208320
TurnItemKind::ToolCall => {
83218321
let meta = item.metadata.as_ref();
8322-
let is_tool_result = meta.and_then(|m| m.get("tool_result_for")).is_some();
8323-
if is_tool_result {
8322+
let meta_str = |key: &str| {
8323+
meta.and_then(|m| m.get(key))
8324+
.and_then(Value::as_str)
8325+
.unwrap_or_default()
8326+
.to_string()
8327+
};
8328+
let tool_use_id = meta_str("tool_use_id");
8329+
let tool_name = meta_str("tool_name");
8330+
let tool_result_for = meta_str("tool_result_for");
8331+
// Completed live turns persist the call and its result
8332+
// on one item; seeded history persists them as two.
8333+
// Both shapes must rebuild the paired tool_call /
8334+
// tool_result. Snapshots persisted before tool identity
8335+
// was durable carry neither side: skip them rather than
8336+
// replay an empty tool_call shell that strict
8337+
// OpenAI-compatible endpoints reject (#5823).
8338+
if !tool_use_id.is_empty() && !tool_name.is_empty() {
8339+
flush_user(&mut user_blocks, &mut messages);
8340+
let input_str = meta
8341+
.and_then(|m| m.get("tool_input"))
8342+
.and_then(Value::as_str)
8343+
.map(str::to_string)
8344+
.or_else(|| item.detail.clone())
8345+
.unwrap_or_default();
8346+
let input: serde_json::Value =
8347+
serde_json::from_str(&input_str).unwrap_or(serde_json::Value::Null);
8348+
assistant_blocks.push(ContentBlock::ToolUse {
8349+
id: tool_use_id,
8350+
name: tool_name,
8351+
input,
8352+
caller: None,
8353+
thought_signature: None,
8354+
});
8355+
}
8356+
if !tool_result_for.is_empty() {
83248357
flush_assistant(&mut assistant_blocks, &mut messages);
8325-
let tool_use_id = meta
8326-
.and_then(|m| m.get("tool_result_for"))
8327-
.and_then(|v| v.as_str())
8328-
.unwrap_or("")
8329-
.to_string();
83308358
let content = item.detail.unwrap_or_default();
83318359
let is_error = meta
83328360
.and_then(|m| m.get("is_error"))
8333-
.and_then(|v| v.as_bool())
8361+
.and_then(Value::as_bool)
83348362
.unwrap_or(false);
83358363
let content_blocks = meta
83368364
.and_then(|m| m.get("content_blocks"))
8337-
.and_then(|v| v.as_array())
8365+
.and_then(Value::as_array)
83388366
.cloned();
83398367
user_blocks.push(ContentBlock::ToolResult {
8340-
tool_use_id,
8368+
tool_use_id: tool_result_for,
83418369
content,
83428370
is_error: if is_error { Some(true) } else { None },
83438371
content_blocks,
83448372
});
8345-
} else {
8346-
flush_user(&mut user_blocks, &mut messages);
8347-
let tool_use_id = meta
8348-
.and_then(|m| m.get("tool_use_id"))
8349-
.and_then(|v| v.as_str())
8350-
.unwrap_or("")
8351-
.to_string();
8352-
let tool_name = meta
8353-
.and_then(|m| m.get("tool_name"))
8354-
.and_then(|v| v.as_str())
8355-
.unwrap_or("")
8356-
.to_string();
8357-
let input_str = item.detail.unwrap_or_default();
8358-
let input: serde_json::Value =
8359-
serde_json::from_str(&input_str).unwrap_or(serde_json::Value::Null);
8360-
assistant_blocks.push(ContentBlock::ToolUse {
8361-
id: tool_use_id,
8362-
name: tool_name,
8363-
input,
8364-
caller: None,
8365-
thought_signature: None,
8366-
});
83678373
}
83688374
}
83698375
_ => {}
@@ -8702,15 +8708,25 @@ impl RuntimeThreadManager {
87028708
tool_items.insert(id.clone(), item_id.clone());
87038709
let kind = tool_kind_for_name(&name);
87048710
let summary = summarize_text(&format!("{name} started"), SUMMARY_LIMIT);
8711+
let input_str = serde_json::to_string(&input).unwrap_or_default();
87058712
let item = TurnItemRecord {
87068713
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
87078714
id: item_id.clone(),
87088715
turn_id: turn_id.clone(),
87098716
kind,
87108717
status: TurnItemLifecycleStatus::InProgress,
87118718
summary,
8712-
detail: Some(serde_json::to_string(&input).unwrap_or_default()),
8713-
metadata: None,
8719+
detail: Some(input_str.clone()),
8720+
// The tool identity must live in the durable item
8721+
// snapshot: restart history rebuild reads it back to
8722+
// re-emit provider tool_calls. Without it a restart
8723+
// replays empty id/name/arguments shells that strict
8724+
// OpenAI-compatible endpoints reject (#5823).
8725+
metadata: Some(json!({
8726+
"tool_use_id": id.clone(),
8727+
"tool_name": name.clone(),
8728+
"tool_input": input_str,
8729+
})),
87148730
artifact_refs: Vec::new(),
87158731
started_at: Some(Utc::now()),
87168732
ended_at: None,
@@ -8771,7 +8787,30 @@ impl RuntimeThreadManager {
87718787
SUMMARY_LIMIT,
87728788
);
87738789
item.detail = Some(output.content.clone());
8774-
item.metadata = output.metadata.clone();
8790+
// `detail` is now the tool output, so the
8791+
// call identity persisted at start must be
8792+
// carried through metadata. Mark the
8793+
// terminal result too so restart history
8794+
// rebuild can re-emit the paired
8795+
// tool_call/tool_result (#5823).
8796+
let mut meta = match output.metadata {
8797+
Some(Value::Object(map)) => Value::Object(map),
8798+
_ => json!({}),
8799+
};
8800+
if let Some(obj) = meta.as_object_mut() {
8801+
if let Some(started) =
8802+
item.metadata.as_ref().and_then(Value::as_object)
8803+
{
8804+
for key in ["tool_use_id", "tool_name", "tool_input"] {
8805+
if let Some(value) = started.get(key) {
8806+
obj.insert(key.to_string(), value.clone());
8807+
}
8808+
}
8809+
}
8810+
obj.insert("tool_result_for".to_string(), json!(id));
8811+
obj.insert("is_error".to_string(), json!(!output.success));
8812+
}
8813+
item.metadata = Some(meta);
87758814
}
87768815
}
87778816
Err(err) => {

0 commit comments

Comments
 (0)