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
+76 -51
View File
@@ -1480,15 +1480,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
fn get_unanswered_tool_calls(&self, history: &History<A>) -> (Vec<ToolCall>, Vec<String>) {
let mut pending_calls = Vec::new();
let mut started_call_ids = Vec::new();
let mut answered_call_ids = std::collections::HashSet::new();
for item in history.items() {
if let Item::ToolResult { call_id, .. } = item {
answered_call_ids.insert(call_id.clone());
}
}
for item in history.items() {
for entry in unanswered_tool_call_entries(history) {
if let Item::ToolCall {
call_id,
name,
@@ -1496,8 +1488,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
execution_id,
status,
..
} = item
&& !answered_call_ids.contains(call_id)
} = &entry.item
{
if execution_id.is_some() || *status == Some(ItemStatus::InProgress) {
started_call_ids.push(call_id.clone());
@@ -1639,6 +1630,30 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
} = info;
tool_call = call;
context.call_id = tool_call.id.clone();
if let PreToolAction::SyntheticResult(result) = &action
&& result.tool_use_id != expected_tool_use_id
{
return Err(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
InterceptorError::new(
InterceptorErrorCategory::ContractViolation,
"synthetic tool result changed immutable tool call identity",
),
)
.into());
}
let execution_id = if matches!(action, PreToolAction::Pause) {
None
} else {
Some(context.execution_id())
};
self.append_early_tool_call(history, annotate, &tool_call, call_index, execution_id)?;
// Policy runs before this call exists in history. Only the successful
// append's annotation may bind the identity, overriding hook changes.
context.logical_call_id = history
.entries()
.last()
.and_then(|entry| self.interceptor.logical_tool_call_id(&entry.annotation));
batch.call_info_map.insert(
tool_call.id.clone(),
(tool_call.clone(), meta, tool.clone(), context.clone()),
@@ -1647,13 +1662,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
match action {
PreToolAction::Continue => {
let attempt_id = context.execution_id();
self.append_early_tool_call(
history,
annotate,
&tool_call,
call_index,
Some(attempt_id.clone()),
)?;
batch
.attempt_fence
.register(tool_call.id.clone(), attempt_id.clone());
@@ -1695,34 +1703,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
batch.dispatched_any = true;
}
PreToolAction::SyntheticResult(result) => {
if result.tool_use_id != expected_tool_use_id {
return Err(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
InterceptorError::new(
InterceptorErrorCategory::ContractViolation,
"synthetic tool result changed immutable tool call identity",
),
)
.into());
}
self.append_early_tool_call(
history,
annotate,
&tool_call,
call_index,
Some(context.execution_id()),
)?;
batch.synthetic_results.push_back(result);
batch.dispatched_any = true;
}
PreToolAction::Skip => {
self.append_early_tool_call(
history,
annotate,
&tool_call,
call_index,
Some(context.execution_id()),
)?;
batch.synthetic_results.push_back(ToolResult::error(
&tool_call.id,
"Tool execution skipped by interceptor",
@@ -1730,13 +1714,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
batch.dispatched_any = true;
}
PreToolAction::Abort(reason) => {
self.append_early_tool_call(
history,
annotate,
&tool_call,
call_index,
Some(context.execution_id()),
)?;
batch.synthetic_results.push_back(ToolResult::error(
&tool_call.id,
format!("Tool execution aborted: {reason}"),
@@ -1745,7 +1722,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
return Err(EngineError::Aborted(reason));
}
PreToolAction::Pause => {
self.append_early_tool_call(history, annotate, &tool_call, call_index, None)?;
batch.dispatched_any = true;
return Ok(EarlyToolAdmission::Pause);
}
@@ -2010,10 +1986,24 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
let batch_id = format!("tool-batch-{}", self.tool_execution_batch_count);
self.tool_execution_batch_count += 1;
let mut logical_call_ids: HashMap<String, VecDeque<Option<String>>> = HashMap::new();
for entry in unanswered_tool_call_entries(history) {
if let Item::ToolCall { call_id, .. } = &entry.item {
logical_call_ids
.entry(call_id.clone())
.or_default()
.push_back(self.interceptor.logical_tool_call_id(&entry.annotation));
}
}
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
let mut approved_calls = Vec::new();
for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() {
let expected_tool_use_id = tool_call.id.clone();
let logical_call_id = logical_call_ids
.get_mut(&tool_call.id)
.and_then(VecDeque::pop_front)
.flatten();
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
if !tool_call.input.is_object() {
synthetic_results.push(ToolResult::error(
@@ -2079,6 +2069,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
let tool_call = info.call;
let mut context = info.context;
context.call_id = tool_call.id.clone();
context.logical_call_id = logical_call_id;
call_info_map.insert(
tool_call.id.clone(),
(tool_call, info.meta.clone(), info.tool.clone(), context),
@@ -2098,6 +2089,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
tool_call = info.call;
let mut context = info.context;
context.call_id = tool_call.id.clone();
context.logical_call_id = logical_call_id;
call_info_map.insert(
tool_call.id.clone(),
@@ -2111,7 +2103,8 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
approved_calls.push((tool_call, context, Some(info.tool)));
} else {
// Unknown tools go into approved list as-is (will error at execution)
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
let mut context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
context.logical_call_id = logical_call_id;
approved_calls.push((tool_call, context, None));
}
}
@@ -4333,6 +4326,38 @@ fn item_kind(item: &Item) -> &'static str {
}
}
/// Pair each result with one preceding unanswered occurrence, never with a later
/// call that happens to reuse the provider ID. Keep the exact annotated entries
/// so dispatch and restoration resolve the same host-owned identity.
fn unanswered_tool_call_entries<A>(history: &History<A>) -> Vec<&HistoryEntry<A>> {
let mut calls = Vec::new();
let mut by_provider_id: HashMap<&str, VecDeque<usize>> = HashMap::new();
for entry in history.entries() {
match &entry.item {
Item::ToolCall { call_id, .. } => {
by_provider_id
.entry(call_id.as_str())
.or_default()
.push_back(calls.len());
calls.push(Some(entry));
}
Item::ToolResult { call_id, .. } => {
if let Some(queue) = by_provider_id.get_mut(call_id.as_str())
&& let Some(index) = queue.pop_front()
{
calls[index] = None;
}
}
_ => {}
}
}
calls.into_iter().flatten().collect()
}
#[cfg(test)]
#[path = "tests/tool_call_identity.rs"]
mod tool_call_identity_tests;
#[cfg(test)]
mod tests {
use super::*;
+10 -2
View File
@@ -309,7 +309,7 @@ pub struct ToolCallInfo<'a, A = ()> {
pub meta: ToolMeta,
/// Tool instance (for state access).
pub tool: Arc<dyn Tool>,
/// Response-local execution context for this call.
/// Execution context. The Engine binds durable identity after pre-tool policy.
pub context: ToolExecutionContext,
}
@@ -324,7 +324,7 @@ pub struct ToolResultInfo<'a, A = ()> {
pub meta: ToolMeta,
/// Tool instance (for state access).
pub tool: Arc<dyn Tool>,
/// Response-local execution context for this call.
/// Execution context. The Engine binds durable identity after pre-tool policy.
pub context: ToolExecutionContext,
}
@@ -344,6 +344,14 @@ pub struct ToolResultInfo<'a, A = ()> {
/// flows, permission checks, and other trusted host adaptation.
#[async_trait]
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
/// Pure projection of a committed ToolCall's host annotation into an opaque,
/// durable call identity. Agen neither interprets storage metadata nor derives
/// this identity from provider IDs. Early pre-tool policy runs before commit;
/// the Engine binds this value after policy, before execution.
fn logical_tool_call_id(&self, _annotation: &A) -> Option<String> {
None
}
/// Whether side effects may start before [`Self::on_assistant_turn_end`].
///
/// The conservative default preserves the historical whole-response gate.
+250
View File
@@ -0,0 +1,250 @@
//! Engine component tests with a recording tool and scripted host annotations.
//! Persistence is represented by the append callback; Server/IPC storage is not
//! exercised here. Run with `cargo test -p agen --lib tool_call_identity_tests`.
use super::*;
use async_trait::async_trait;
use std::sync::Mutex;
#[derive(Clone)]
struct NoLlm;
#[async_trait]
impl LlmClient for NoLlm {
async fn stream(&self, _: Request) -> Result<ResponseStream, ClientError> {
panic!("identity tests must not request a model response")
}
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
}
struct HostIdentity {
early: bool,
}
#[async_trait]
impl Interceptor<String> for HostIdentity {
fn logical_tool_call_id(&self, annotation: &String) -> Option<String> {
Some(annotation.clone())
}
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, String>,
) -> Result<PreToolAction, InterceptorError> {
if self.early {
assert!(info.history.is_empty());
}
// Pre-tool hooks may replace the context, but cannot replace persisted identity.
info.context.logical_call_id = Some("hook-forged-identity".into());
Ok(PreToolAction::Continue)
}
}
struct RecordingTool(Arc<Mutex<Vec<ToolExecutionContext>>>);
#[async_trait]
impl crate::tool::Tool for RecordingTool {
async fn execute(
&self,
_: &str,
context: ToolExecutionContext,
) -> Result<crate::tool::ToolOutput, ToolError> {
self.0.lock().unwrap().push(context);
Ok("recorded".to_string().into())
}
}
fn engine(
early: bool,
) -> (
Engine<NoLlm, Mutable, String>,
Arc<Mutex<Vec<ToolExecutionContext>>>,
) {
let mut engine = Engine::new_annotated(NoLlm);
engine.set_interceptor(HostIdentity { early });
engine.max_turns = Some(0);
engine.start_logical_run();
let observed = Arc::new(Mutex::new(Vec::new()));
let tool = Arc::new(RecordingTool(observed.clone()));
engine.register_tool(Arc::new(move || {
(crate::tool::ToolMeta::new("record"), tool.clone())
}));
engine.tool_server_handle().flush_pending();
(engine, observed)
}
fn entry(item: Item, id: &str) -> HistoryEntry<String> {
HistoryEntry::new(item, id.to_string())
}
fn call(id: &str) -> Item {
Item::tool_call_json(id, "record", json!({}))
}
#[test]
fn results_answer_only_preceding_occurrences_of_reused_provider_ids() {
let history = History::from_entries(vec![
entry(
Item::tool_result("orphan", "no preceding call"),
"orphan-result",
),
entry(call("reused"), "host-first"),
entry(call("sibling"), "host-sibling"),
entry(
Item::tool_result("sibling", "out of order"),
"sibling-result",
),
entry(Item::tool_result("reused", "answered"), "first-result"),
entry(call("reused"), "host-pending"),
entry(call("orphan"), "host-after-orphan"),
]);
let pending = unanswered_tool_call_entries(&history);
assert_eq!(
pending
.iter()
.map(|entry| entry.annotation.as_str())
.collect::<Vec<_>>(),
["host-pending", "host-after-orphan"]
);
}
#[tokio::test]
async fn early_dispatch_binds_identity_only_after_successful_append() {
for reject_append in [false, true] {
let (mut engine, observed) = engine(true);
let mut history = History::new();
let mut batch = EarlyToolExecutionBatch::new("live-batch".into(), 0);
let result = engine
.admit_early_tool_calls(
&mut history,
&mut |_| {
if reject_append {
Err("storage unavailable".into())
} else {
Ok("host-committed-entry".into())
}
},
&mut batch,
vec![crate::timeline::CollectedToolCall {
call_index: 0,
call: ToolCall {
id: "provider-call".into(),
name: "record".into(),
input: json!({}),
},
}],
)
.await;
if reject_append {
assert!(matches!(result, Err(EngineError::HistoryAppend(_))));
assert!(history.is_empty());
assert!(batch.execution_handles.is_empty());
assert!(observed.lock().unwrap().is_empty());
} else {
assert!(matches!(result, Ok(EarlyToolAdmission::Continue)));
let (_, terminal) = batch.futures.next().await.unwrap();
assert_eq!(terminal.tool_use_id, "provider-call");
let observed = observed.lock().unwrap();
assert_eq!(observed.len(), 1);
assert_eq!(
observed[0].logical_call_id.as_deref(),
Some(history.entries()[0].annotation.as_str())
);
assert_eq!(
observed[0].logical_call_id.as_deref(),
Some("host-committed-entry")
);
assert_eq!(batch.call_info_map["provider-call"].3, observed[0]);
}
}
}
#[tokio::test]
async fn normal_dispatch_and_unstarted_restore_use_the_exact_unanswered_annotation() {
for restore in [false, true] {
let (mut engine, observed) = engine(false);
let mut history = History::from_entries(vec![
entry(call("reused"), "host-answered"),
entry(
Item::tool_result("reused", "already completed"),
"answered-result",
),
]);
engine
.append_history_items(&mut history, [call("reused")], &mut |_| {
Ok("host-pending".into())
})
.unwrap();
if restore {
let persisted = serde_json::to_string(&history).unwrap();
history = serde_json::from_str(&persisted).unwrap();
}
let (pending, started) = engine.get_unanswered_tool_calls(&history);
assert_eq!(pending.len(), 1);
assert!(started.is_empty());
if restore {
engine
.run_turn_loop(&mut history, &mut |_| Ok("terminal-result".into()))
.await
.unwrap();
} else {
let result = engine
.execute_tools(&mut history, &mut |_| Ok("terminal-result".into()), pending)
.await
.unwrap();
assert!(matches!(result, ToolExecutionResult::Completed));
}
assert_eq!(observed.lock().unwrap().len(), 1);
let context = observed.lock().unwrap()[0].clone();
assert_eq!(context.call_id, "reused");
assert_eq!(context.logical_call_id.as_deref(), Some("host-pending"));
// A subsequent resume sees the terminal result, not another live attempt.
engine
.run_turn_loop(&mut history, &mut |_| Ok("unexpected-append".into()))
.await
.unwrap();
assert_eq!(observed.lock().unwrap().len(), 1);
assert!(unanswered_tool_call_entries(&history).is_empty());
}
}
#[tokio::test]
async fn started_restore_with_reused_provider_id_closes_unknown_without_reexecution() {
let (mut engine, observed) = engine(false);
let mut history = History::from_entries(vec![
entry(call("reused"), "host-answered"),
entry(
Item::tool_result("reused", "already completed"),
"answered-result",
),
entry(
call("reused")
.with_tool_execution_metadata(0, Some("old-attempt".into()))
.with_status(ItemStatus::InProgress),
"host-started",
),
]);
let (pending, started) = engine.get_unanswered_tool_calls(&history);
assert!(pending.is_empty());
assert_eq!(started, ["reused"]);
engine
.run_turn_loop(&mut history, &mut |_| Ok("unknown-result".into()))
.await
.unwrap();
assert!(observed.lock().unwrap().is_empty());
assert!(matches!(
&history.entries().last().unwrap().item,
Item::ToolResult { call_id, disposition: ToolResultDisposition::OutcomeUnknown, .. }
if call_id == "reused"
));
let len = history.len();
engine
.run_turn_loop(&mut history, &mut |_| Ok("unexpected-append".into()))
.await
.unwrap();
assert_eq!(history.len(), len);
assert!(observed.lock().unwrap().is_empty());
}
+14 -6
View File
@@ -336,15 +336,16 @@ pub type ToolDefinition = Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Syn
/// Per-call context supplied by the engine when executing a tool call.
///
/// The context identifies a tool call within one assistant response's tool-call
/// batch without imposing any scheduling policy on the engine. Tool
/// implementations may use it for response-local ordering, diagnostics, or
/// correlation, but it is intentionally not a handle to engine state, history,
/// or session mutation.
/// Live batch identity and ordering are response-local. An optional opaque host
/// identity comes from the committed ToolCall annotation and survives restore.
/// Neither is a handle to engine state, history, or session mutation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolExecutionContext {
/// Provider/tool-call id for the call being executed.
pub call_id: String,
/// Opaque host identity from the committed ToolCall annotation, stable across
/// restoration. Provider call IDs and live batch IDs are not replay authority.
pub logical_call_id: Option<String>,
/// Engine-local identity shared by all tool calls from one execution batch.
pub batch_id: String,
/// Zero-based order of this call in the model-returned tool-call list.
@@ -355,11 +356,18 @@ impl ToolExecutionContext {
pub fn new(call_id: impl Into<String>, batch_id: impl Into<String>, call_index: usize) -> Self {
Self {
call_id: call_id.into(),
logical_call_id: None,
batch_id: batch_id.into(),
call_index,
}
}
/// Bind an identity supplied by the host's persisted-call annotation.
pub fn with_logical_call_id(mut self, logical_call_id: impl Into<String>) -> Self {
self.logical_call_id = Some(logical_call_id.into());
self
}
/// Identifies one live execution attempt without making the batch id a durable
/// replay or idempotency authority.
pub fn execution_id(&self) -> String {
@@ -572,7 +580,7 @@ pub trait Tool: Send + Sync {
///
/// # Arguments
/// * `input_json` - JSON-formatted arguments generated by LLM
/// * `ctx` - response-local call identity and ordering context
/// * `ctx` - per-call identity and live execution ordering context
///
/// # Returns
/// A [`ToolOutput`] with summary and optional detailed content.