mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 17:10:44 -10:00
fix(runtime): record a failed tool call as its own error result
A tool call that failed (ToolCallComplete Err) was persisted with only
status=failed and detail, never tool_result_for/is_error. After a runtime
restart the rebuilt transcript held a function_call with no output and the
provider rejected every later turn (`No tool output found for tool call`,
#6803 on 0.10.0). 9ddf26bcc6 already repairs such records on rebuild
(unanswered_call_result); this records the failure correctly at the
source, matching how a success:false result is stored. Old stores and
interrupted/canceled calls still go through the existing repair, so there
is still exactly one repair mechanism.
Regression test: runtime_threads::tests::execution_identity::
a_failed_tool_call_records_its_result_and_replays_after_restart (persist
a failed read, reopen the manager, restore messages, assert one ToolUse
and one matching is_error ToolResult).
Validation: cargo test -p codewhale-tui --lib --
runtime_threads::tests::execution_identity
runtime_threads::tests::monitor_preserves_admitted_correlation_and_redacts_private_answers:
4 passed, 0 failed. With the source hunk reverted, the new test fails
(0 passed; 1 failed). rustfmt --check clean.
Refs #6803
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
1 parent
da9f3f6480
commit
8b20d48d9a
2 files changed
+142
-3
No files matched your search
@@ -1279,9 +1279,12 @@ fn projects_tool_history(item: &TurnItemRecord) -> bool {
|
||||
/// The result a rebuilt history must give a tool call whose outcome the turn
|
||||
/// store never recorded, or `None` when nothing is missing.
|
||||
///
|
||||
/// A call that failed, was interrupted, or was canceled can be persisted as a
|
||||
/// single `tool_call` item that carries the call alone: the failure text lives
|
||||
/// on the item's own `detail`, and no `tool_result_for` item follows. Rebuilding
|
||||
/// A call that was interrupted or canceled can be persisted as a single
|
||||
/// `tool_call` item that carries the call alone, and so can a call that failed
|
||||
/// under a runtime older than #6803, which kept the failure text on the item's
|
||||
/// own `detail` without marking it `tool_result_for`. (A failure recorded now
|
||||
/// carries that marker, so the record answers it like any completed call and
|
||||
/// nothing is missing here.) Rebuilding
|
||||
/// only the call leaves it unanswered, which a provider rejects outright —
|
||||
/// `No tool output found for tool call …`. The missing result is also what
|
||||
/// stopped the first of two responses from flushing, so the rebuild glued them
|
||||
@@ -16694,6 +16697,18 @@ impl RuntimeThreadManager {
|
||||
item.summary =
|
||||
summarize_text(&format!("{name} failed: {err}"), SUMMARY_LIMIT);
|
||||
item.detail = Some(err);
|
||||
// The engine answered this call with an error
|
||||
// result the model received, so the record
|
||||
// holds that answer the same way a completed
|
||||
// or `success: false` call holds its own: the
|
||||
// started identity stays, `detail` is the
|
||||
// result, and the item is marked as the call's
|
||||
// result. Without the marker a restart rebuilt
|
||||
// a call nothing answered (#6803).
|
||||
if let Some(Value::Object(meta)) = item.metadata.as_mut() {
|
||||
meta.insert("tool_result_for".to_string(), json!(id));
|
||||
meta.insert("is_error".to_string(), json!(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.store.save_item(&item)?;
|
||||
|
||||
@@ -22518,6 +22518,130 @@ mod execution_identity {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// #6803: a tool that fails — a `read` of a missing file — is answered
|
||||
/// with an error result the model receives. That answer must live in the
|
||||
/// record itself (`tool_result_for` and `is_error` on the failed item, as a
|
||||
/// completed call records its own), so a runtime restarted on the same
|
||||
/// home rebuilds the call together with its result rather than a
|
||||
/// `function_call` the provider rejects with `No tool output found`.
|
||||
#[tokio::test]
|
||||
async fn a_failed_tool_call_records_its_result_and_replays_after_restart() -> Result<()> {
|
||||
let runtime = test_runtime_dir();
|
||||
let manager = test_manager(runtime.clone())?;
|
||||
let thread = manager.create_thread(Default::default()).await?;
|
||||
let mut harness = install_mock_engine(&manager, &thread.id).await;
|
||||
let turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "read a file that is not there".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert!(matches!(
|
||||
harness.rx_op.recv().await,
|
||||
Some(Op::SendMessage(_))
|
||||
));
|
||||
harness
|
||||
.tx_event
|
||||
.send(EngineEvent::TurnStarted {
|
||||
turn_id: turn.id.clone(),
|
||||
created_at: Utc::now(),
|
||||
route: None,
|
||||
submission_id: None,
|
||||
})
|
||||
.await?;
|
||||
harness
|
||||
.tx_event
|
||||
.send(EngineEvent::ToolCallStarted {
|
||||
id: "host-read".into(),
|
||||
name: "read".into(),
|
||||
input: json!({"path": "missing.txt"}),
|
||||
model_call: Some(ModelToolCall {
|
||||
provider_id: "call_00_missing".into(),
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
}),
|
||||
})
|
||||
.await?;
|
||||
harness
|
||||
.tx_event
|
||||
.send(EngineEvent::ToolCallComplete {
|
||||
id: "host-read".into(),
|
||||
name: "read".into(),
|
||||
result: Err(crate::tools::spec::ToolError::execution_failed(
|
||||
"missing.txt: No such file or directory",
|
||||
)),
|
||||
model_call: None,
|
||||
})
|
||||
.await?;
|
||||
harness
|
||||
.tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
usage: Usage::default(),
|
||||
parent_route_usage: Usage::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_terminal_turn(&manager, &turn.id).await?;
|
||||
|
||||
let detail = manager.get_thread_detail(&thread.id).await?;
|
||||
let failed = detail
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| {
|
||||
item.metadata
|
||||
.as_ref()
|
||||
.is_some_and(|meta| meta["tool_use_id"] == "host-read")
|
||||
})
|
||||
.context("failed call")?;
|
||||
assert_eq!(failed.status, TurnItemLifecycleStatus::Failed);
|
||||
let meta = failed.metadata.as_ref().unwrap();
|
||||
assert_eq!(meta["tool_result_for"], "host-read");
|
||||
assert_eq!(meta["is_error"], true);
|
||||
assert_eq!(meta["provider_tool_use_id"], "call_00_missing");
|
||||
|
||||
// Restart on the same home: the next request is rebuilt from the
|
||||
// persisted records alone.
|
||||
drop(harness);
|
||||
drop(manager);
|
||||
let manager = test_manager(runtime)?;
|
||||
let reopened = manager.get_thread(&thread.id).await?;
|
||||
let messages = manager.restore_thread_messages(&reopened)?;
|
||||
let blocks: Vec<&ContentBlock> = messages.iter().flat_map(|m| &m.content).collect();
|
||||
let calls: Vec<&ContentBlock> = blocks
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|block| matches!(block, ContentBlock::ToolUse { .. }))
|
||||
.collect();
|
||||
let results: Vec<&ContentBlock> = blocks
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|block| matches!(block, ContentBlock::ToolResult { .. }))
|
||||
.collect();
|
||||
assert_eq!(calls.len(), 1, "the failed call is replayed");
|
||||
assert_eq!(results.len(), 1, "and answered exactly once");
|
||||
assert_eq!(calls[0].tool_call_key(), results[0].tool_call_key());
|
||||
let ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
..
|
||||
} = results[0]
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(tool_use_id, "call_00_missing");
|
||||
assert_eq!(*is_error, Some(true));
|
||||
assert!(content.contains("No such file or directory"), "{content}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in new issue
Block a user