fix: bind Plugin receipts to durable Host Tool-call history identity
This commit is contained in:
+76
-51
@@ -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::*;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user