fix: bind Plugin receipts to durable Host Tool-call history identity

This commit is contained in:
2026-10-30 09:06:29 +09:00
parent 5329a93e3e
commit 5a317e6d2a
11 changed files with 744 additions and 127 deletions
+29 -10
View File
@@ -18,18 +18,25 @@ use agen::{Item, ToolResultDisposition};
/// Build synthetic `Item::ToolResult` items for every unanswered
/// `Item::ToolCall` in `history`, preserving order.
pub(crate) fn orphan_tool_result_closures(history: &[Item], summary: &str) -> Vec<Item> {
let mut answered: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut pending: Vec<Option<&str>> = Vec::new();
for item in history {
if let Item::ToolResult { call_id, .. } = item {
answered.insert(call_id.as_str());
match item {
Item::ToolCall { call_id, .. } => pending.push(Some(call_id)),
Item::ToolResult { call_id, .. } => {
if let Some(index) = pending
.iter()
.position(|entry| *entry == Some(call_id.as_str()))
{
pending[index] = None;
}
}
_ => {}
}
}
let mut out = Vec::new();
for item in history {
if let Item::ToolCall { call_id, .. } = item {
if !answered.contains(call_id.as_str()) {
out.push(Item::tool_result_item_with_disposition_and_attachments(
call_id.clone(),
for call_id in pending.into_iter().flatten() {
out.push(Item::tool_result_item_with_disposition_and_attachments(
call_id,
summary,
Some(
"Execution ended before completion could be confirmed. Completion and side effects are unknown."
@@ -38,8 +45,6 @@ pub(crate) fn orphan_tool_result_closures(history: &[Item], summary: &str) -> Ve
ToolResultDisposition::OutcomeUnknown,
Vec::new(),
));
}
}
}
out
}
@@ -59,6 +64,20 @@ fn interrupt_tool_result_summary() -> String {
mod tests {
use super::*;
#[test]
fn answered_provider_id_does_not_hide_later_unanswered_logical_call() {
let history = vec![
Item::tool_call("reused", "plugin", "{}"),
Item::tool_result("reused", "first finished"),
Item::tool_call("reused", "plugin", "{}"),
];
let closed = orphan_tool_result_closures(&history, "Interrupted");
assert_eq!(closed.len(), 1);
assert!(
matches!(&closed[0], Item::ToolResult { call_id, disposition: ToolResultDisposition::OutcomeUnknown, .. } if call_id == "reused")
);
}
#[test]
fn no_orphans_returns_empty() {
let history = vec![Item::user_message("hi"), Item::assistant_message("hello")];
+4
View File
@@ -437,6 +437,10 @@ impl WorkerInterceptor {
#[async_trait]
impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
fn logical_tool_call_id(&self, annotation: &SessionHistoryMetadata) -> Option<String> {
Some(annotation.entry_id.0.clone())
}
fn supports_early_tool_dispatch(&self) -> bool {
self.registry.on_turn_end.is_empty()
}
+8 -6
View File
@@ -291,18 +291,20 @@ impl Tool for PluginTool {
input_json: &str,
context: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
if context.call_id.is_empty() || context.call_id == "direct" {
return Err(failure("Plugin Tool requires committed Tool-call identity"));
}
let logical_call_id = context
.logical_call_id
.as_deref()
.filter(|id| !id.is_empty())
.ok_or_else(|| failure("Plugin Tool requires committed logical Tool-call identity"))?;
let _capacity = self
.capacity
.clone()
.try_acquire_owned()
.map_err(|_| failure("Plugin invocation capacity exceeded"))?;
let lease = self.start(&context)?;
// The persisted Session and provider call ID survive Runtime restoration;
// the engine-local batch ID identifies only cancellation of a live attempt.
let id_bytes = serde_json::to_vec(&(&self.session_id, &context.call_id))
// Only the host-owned persisted call identity is durable. Provider IDs may
// repeat in a Session; batch/provider IDs identify live attempts only.
let id_bytes = serde_json::to_vec(&(&self.session_id, logical_call_id))
.expect("identity serialization");
let hex: String = Sha256::digest(&id_bytes)
.iter()
+66 -7
View File
@@ -320,6 +320,7 @@ fn install(feature: PluginToolsFeature) -> (Engine<NoLlm, Mutable, ()>, HookRegi
fn context(call: &str, batch: &str) -> ToolExecutionContext {
ToolExecutionContext::new(call, batch, 0)
.with_logical_call_id(format!("persisted-entry-{call}"))
}
fn stopping() -> WorkerStoppingContext {
@@ -502,15 +503,41 @@ async fn adapter_late_publish_denial_withholds_successful_guest_output() {
}
#[tokio::test]
async fn consumer_identity_survives_batch_changes_but_isolates_sessions_and_calls() {
async fn consumer_identity_uses_host_entry_not_provider_call_or_live_batch() {
let fixture = Fixture::new();
let owner = Owner::new(Behavior::Allow);
// Re-preparation simulates a restored adapter, not just reuse of a live Tool.
for (session, call, batch) in [
("session-a", "provider-call", "batch-before-restore"),
("session-a", "provider-call", "batch-after-restore"),
("session-b", "provider-call", "batch-after-restore"),
("session-a", "different-call", "batch-after-restore"),
for (session, call, logical, batch) in [
(
"session-a",
"provider-call",
"host-entry-a",
"batch-before-restore",
),
(
"session-a",
"provider-call",
"host-entry-a",
"batch-after-restore",
),
(
"session-b",
"provider-call",
"host-entry-a",
"batch-after-restore",
),
(
"session-a",
"provider-call",
"host-entry-b",
"batch-after-restore",
),
(
"session-a",
"different-provider-call",
"host-entry-a",
"different-batch",
),
] {
let feature = fixture
.prepare(&["installation_a"], owner.clone(), session)
@@ -518,7 +545,11 @@ async fn consumer_identity_survives_batch_changes_but_isolates_sessions_and_call
let (engine, _) = install(feature);
engine
.tool_server_handle()
.call_tool(TOOL_A, INPUT, context(call, batch))
.call_tool(
TOOL_A,
INPUT,
ToolExecutionContext::new(call, batch, 0).with_logical_call_id(logical),
)
.await
.unwrap();
}
@@ -526,6 +557,34 @@ async fn consumer_identity_survives_batch_changes_but_isolates_sessions_and_call
assert_eq!(consumers[0], consumers[1]);
assert_ne!(consumers[0], consumers[2]);
assert_ne!(consumers[0], consumers[3]);
assert_eq!(consumers[0], consumers[4]);
}
#[tokio::test]
async fn missing_or_empty_logical_identity_refuses_owner_admission() {
let fixture = Fixture::new();
let owner = Owner::new(Behavior::Allow);
let feature = fixture
.prepare(&["installation_a"], owner.clone(), "session-a")
.await;
let (engine, _) = install(feature);
let tools = engine.tool_server_handle();
for context in [
ToolExecutionContext::direct(),
ToolExecutionContext::new("provider-call", "batch", 0),
ToolExecutionContext::new("provider-call", "batch", 0).with_logical_call_id(""),
] {
let error = tools.call_tool(TOOL_A, INPUT, context).await.unwrap_err();
assert!(
error
.to_string()
.contains("committed logical Tool-call identity")
);
}
assert!(
owner.requests().is_empty(),
"identity rejection must precede Begin"
);
}
struct FailInstall;