mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
fix(runner): name the tool receipts that block the next provider turn (#16961)
When a resumed run cannot start a turn because the semantic tool bridge still holds pending or unsettled completed receipts, the error now names each blocking receipt by operation and call ID (bounded to four per set). Tool input and result content stay inside the bridge. Adds an integration test covering the four-receipt limit and the trailing "...". Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2 files changed
+131
-3
No files matched your search
@@ -579,9 +579,15 @@ impl ProviderToolBridge {
|
||||
|
||||
pub fn prepare_turn(&mut self) -> Result<(), ProviderBridgeError> {
|
||||
if !self.pending.is_empty() || !self.completed.is_empty() {
|
||||
return Err(ProviderBridgeError::invalid(
|
||||
"cannot prepare the next provider turn while tool calls are active",
|
||||
));
|
||||
// Name the blocking receipts so a recovery that can never start a
|
||||
// turn identifies the stuck call. IDs and operation names only; no
|
||||
// tool input or result content leaves the bridge.
|
||||
return Err(ProviderBridgeError::invalid(format!(
|
||||
"cannot prepare the next provider turn while tool calls are active \
|
||||
(pending: {}; completed: {})",
|
||||
describe_active_receipts(self.pending.values()),
|
||||
describe_active_receipts(self.completed.values().map(|entry| &entry.call)),
|
||||
)));
|
||||
}
|
||||
// The authoritative result bodies are needed only while the provider
|
||||
// can replay the just-settled turn. Release that bulky data at the
|
||||
@@ -984,6 +990,27 @@ impl ProviderToolBridge {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_DESCRIBED_ACTIVE_RECEIPTS: usize = 4;
|
||||
|
||||
fn describe_active_receipts<'a>(
|
||||
calls: impl ExactSizeIterator<Item = &'a PendingToolCall>,
|
||||
) -> String {
|
||||
let total = calls.len();
|
||||
if total == 0 {
|
||||
return "0".to_owned();
|
||||
}
|
||||
let described = calls
|
||||
.take(MAX_DESCRIBED_ACTIVE_RECEIPTS)
|
||||
.map(|call| format!("{}:{}", call.operation_id, call.call_id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if total > MAX_DESCRIBED_ACTIVE_RECEIPTS {
|
||||
format!("{total} [{described}, ...]")
|
||||
} else {
|
||||
format!("{total} [{described}]")
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_retained_result(entry: &CompletedToolCall) -> Result<(), ProviderBridgeError> {
|
||||
validate_stable_id(&entry.call.call_id, "retained tool call id")?;
|
||||
validate_stable_id(&entry.result.call_id, "retained tool result call id")?;
|
||||
@@ -2153,6 +2180,47 @@ mod tests {
|
||||
.expect("a new durable run resets the full legacy ledger");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_receipts_that_block_the_next_turn_are_named() {
|
||||
let mut bridge = completion_bridge();
|
||||
bridge
|
||||
.begin_call(
|
||||
"stuck-call".to_owned(),
|
||||
"paperclip_finish".to_owned(),
|
||||
json!({"summary": "private summary text"}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let message = bridge
|
||||
.prepare_turn()
|
||||
.expect_err("a pending call blocks the next turn")
|
||||
.to_string();
|
||||
assert!(message.contains("tool calls are active"));
|
||||
assert!(message.contains("pending: 1 [paperclip_finish:stuck-call]"));
|
||||
assert!(message.contains("completed: 0"));
|
||||
assert!(!message.contains("private summary text"));
|
||||
|
||||
bridge
|
||||
.apply_result(ToolResult {
|
||||
call_id: "stuck-call".to_owned(),
|
||||
operation_id: "paperclip_finish".to_owned(),
|
||||
result: json!({"ok": true}),
|
||||
is_error: false,
|
||||
})
|
||||
.unwrap();
|
||||
let message = bridge
|
||||
.prepare_turn()
|
||||
.expect_err("an unsettled completed call blocks the next turn")
|
||||
.to_string();
|
||||
assert!(message.contains("pending: 0;"));
|
||||
assert!(message.contains("completed: 1 [paperclip_finish:stuck-call]"));
|
||||
|
||||
bridge.settle_turn("provider_turn_terminated").unwrap();
|
||||
bridge
|
||||
.prepare_turn()
|
||||
.expect("settled receipts no longer block the turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_restart_rotates_only_an_idle_replay_epoch() {
|
||||
let operation = AuthorizedTool {
|
||||
|
||||
@@ -416,6 +416,66 @@ fn completed_receipts_are_exact_until_the_controlled_turn_limit() {
|
||||
recovered.validate_recovered().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_turn_error_names_at_most_four_receipts_per_set() {
|
||||
let mut bridge = ProviderToolBridge::default();
|
||||
bridge.prepare(tools("computed")).unwrap();
|
||||
for index in 0..4 {
|
||||
bridge
|
||||
.begin_call(
|
||||
format!("call-{index}"),
|
||||
"get_task_context".into(),
|
||||
json!({}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let message = bridge
|
||||
.prepare_turn()
|
||||
.expect_err("pending calls block the next turn")
|
||||
.to_string();
|
||||
assert!(message.contains(
|
||||
"pending: 4 [get_task_context:call-0, get_task_context:call-1, \
|
||||
get_task_context:call-2, get_task_context:call-3]"
|
||||
));
|
||||
assert!(!message.contains("..."));
|
||||
|
||||
bridge
|
||||
.begin_call("call-4".into(), "get_task_context".into(), json!({}))
|
||||
.unwrap();
|
||||
let message = bridge
|
||||
.prepare_turn()
|
||||
.expect_err("pending calls block the next turn")
|
||||
.to_string();
|
||||
assert!(message.contains(
|
||||
"pending: 5 [get_task_context:call-0, get_task_context:call-1, \
|
||||
get_task_context:call-2, get_task_context:call-3, ...]"
|
||||
));
|
||||
assert!(!message.contains("call-4"));
|
||||
assert!(message.contains("completed: 0"));
|
||||
|
||||
for index in 0..5 {
|
||||
bridge
|
||||
.apply_result(ToolResult {
|
||||
call_id: format!("call-{index}"),
|
||||
operation_id: "get_task_context".into(),
|
||||
result: json!({"ok": true}),
|
||||
is_error: false,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let message = bridge
|
||||
.prepare_turn()
|
||||
.expect_err("unsettled completed calls block the next turn")
|
||||
.to_string();
|
||||
assert!(message.contains("pending: 0;"));
|
||||
assert!(message.contains(
|
||||
"completed: 5 [get_task_context:call-0, get_task_context:call-1, \
|
||||
get_task_context:call-2, get_task_context:call-3, ...]"
|
||||
));
|
||||
assert!(!message.contains("call-4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_settlement_cannot_be_blocked_by_completed_value_pressure() {
|
||||
let mut bridge = ProviderToolBridge::default();
|
||||
|
||||
Reference in new issue
Block a user