fix: bind Plugin receipts to durable Host Tool-call history identity
This commit is contained in:
@@ -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")];
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user