use crate::agent::{AgentContext, AgentEvent};
use crate::error::Error;
use crate::ids::ThreadId;
use crate::llm::{Message, Role, ToolCall};
use crate::memory::{Episode, ToolResult};
use crate::redact::{opaque_digest, AuditRedactor};
use tracing::debug;
use super::emit;
const REDACTED_ERROR_MARKER: &str = "[redacted error]";
pub(crate) async fn dispatch_tool_calls(
ctx: &AgentContext,
thread: &ThreadId,
tool_calls: &[ToolCall],
driver: &'static str,
) -> Result<(), Error> {
for call in tool_calls {
let tool_ctx =
crate::tool::ToolCtx::new(ctx.pubsub.clone(), ctx.kv.clone(), ctx.jobs.clone())
.with_cancel(ctx.cancel.clone());
emit(
ctx,
AgentEvent::ToolCallStarted {
name: call.name.clone(),
},
);
let outcome = ctx
.tools
.invoke(&call.name, call.args.clone(), tool_ctx)
.await;
emit(
ctx,
AgentEvent::ToolCallCompleted {
name: call.name.clone(),
ok: outcome.is_ok(),
},
);
let raw_result = match &outcome {
Ok(v) => ToolResult::Ok { value: v.clone() },
Err(e) => ToolResult::Err {
message: e.to_string(),
},
};
let tool_msg_content = match &outcome {
Ok(v) => v.to_string(),
Err(e) => format!("error: {e}"),
};
let (episode_args, episode_result) = if ctx.tools.tool_redacts_audit(&call.name) {
redacted_args_and_result(ctx.audit_redactor.as_deref(), &call.args, raw_result)
} else {
(call.args.clone(), raw_result)
};
ctx.episodic
.record(
ctx.run_id,
Episode::ToolCall {
name: call.name.clone(),
args: episode_args,
result: episode_result,
},
)
.await?;
ctx.short_term
.append(
thread.clone(),
Message {
role: Role::Tool,
content: tool_msg_content,
tool_calls: vec![],
tool_call_id: Some(call.id.clone()),
},
)
.await?;
if let Err(e) = outcome {
if !e.retryable() {
return Err(Error::Tool(e));
}
}
}
debug!(n = tool_calls.len(), driver, "dispatched tools");
Ok(())
}
fn redacted_args_and_result(
redactor: Option<&dyn AuditRedactor>,
args: &serde_json::Value,
result: ToolResult,
) -> (serde_json::Value, ToolResult) {
if let Some(redactor) = redactor {
let redacted_result = match result {
ToolResult::Ok { value } => ToolResult::Ok {
value: redactor.redact(&value),
},
ToolResult::Err { message } => ToolResult::Err {
message: redactor
.redact(&serde_json::Value::String(message))
.to_string(),
},
};
return (redactor.redact(args), redacted_result);
}
let opaque_result = match result {
ToolResult::Ok { value } => ToolResult::Ok {
value: opaque_digest(&value),
},
ToolResult::Err { .. } => ToolResult::Err {
message: REDACTED_ERROR_MARKER.to_string(),
},
};
(opaque_digest(args), opaque_result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::AgentContext;
use crate::error::ToolError;
use crate::ids::{RunId, ThreadId};
use crate::memory::EpisodicMemory;
use crate::test_utils::{fake_context, FakeToolInvoker, InMemoryEpisodic};
use std::sync::Arc;
fn make_ctx(tools: Arc<FakeToolInvoker>) -> (AgentContext, Arc<InMemoryEpisodic>) {
let episodic = Arc::new(InMemoryEpisodic::default());
let mut ctx = fake_context("dispatch-test");
ctx.episodic = episodic.clone();
ctx.tools = tools;
(ctx, episodic)
}
struct MaskingRedactor;
const MASK_TOKEN: &str = "[masked]";
impl crate::redact::AuditRedactor for MaskingRedactor {
fn redact(&self, value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => serde_json::Value::Object(
map.iter()
.map(|(k, v)| (k.clone(), self.redact(v)))
.collect(),
),
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(|v| self.redact(v)).collect())
}
serde_json::Value::String(_) => serde_json::json!(MASK_TOKEN),
other => other.clone(),
}
}
}
async fn recorded_episode_string(episodic: &InMemoryEpisodic, run: RunId) -> String {
let episodes = episodic.replay(run).await.unwrap();
serde_json::to_string(&episodes).unwrap()
}
fn first_tool_result(episodes: &[Episode]) -> &ToolResult {
episodes
.iter()
.find_map(|e| match e {
Episode::ToolCall { result, .. } => Some(result),
_ => None,
})
.expect("a ToolCall episode was recorded")
}
const PII_IBAN: &str = "DE89370400440532013000";
const PII_EMAIL: &str = "alice@example.com";
fn call_with_args(id: &str, name: &str, args: serde_json::Value) -> ToolCall {
ToolCall {
id: id.into(),
name: name.into(),
args,
}
}
fn call(id: &str, name: &str) -> ToolCall {
ToolCall {
id: id.into(),
name: name.into(),
args: serde_json::json!({}),
}
}
#[tokio::test]
async fn records_one_episode_and_one_short_term_message_per_successful_call() {
let tools = Arc::new(
FakeToolInvoker::new()
.with_tool("ok_a", "", |_| Ok(serde_json::json!("a")))
.with_tool("ok_b", "", |_| Ok(serde_json::json!("b")))
.with_tool("ok_c", "", |_| Ok(serde_json::json!("c"))),
);
let (ctx, episodic) = make_ctx(tools);
let thread = ThreadId::new("t-1");
let calls = vec![
call("c-1", "ok_a"),
call("c-2", "ok_b"),
call("c-3", "ok_c"),
];
dispatch_tool_calls(&ctx, &thread, &calls, "blocking")
.await
.expect("all-Ok dispatch must succeed");
let episodes = episodic.replay(ctx.run_id).await.unwrap();
let tool_call_count = episodes
.iter()
.filter(|e| matches!(e, Episode::ToolCall { .. }))
.count();
assert_eq!(tool_call_count, 3);
let history = ctx.short_term.load(thread, 1024).await.unwrap();
let tool_msgs: Vec<_> = history.iter().filter(|m| m.role == Role::Tool).collect();
assert_eq!(tool_msgs.len(), 3);
let ids: Vec<&str> = tool_msgs
.iter()
.filter_map(|m| m.tool_call_id.as_deref())
.collect();
assert_eq!(ids, vec!["c-1", "c-2", "c-3"]);
}
#[tokio::test]
async fn non_retryable_tool_error_aborts_after_persisting_that_call() {
let tools = Arc::new(
FakeToolInvoker::new()
.with_tool("ok", "", |_| Ok(serde_json::json!("ok-value")))
.with_tool("boom", "", |_| Err(ToolError::Permanent("nope".into())))
.with_tool("never_runs", "", |_| Ok(serde_json::json!("x"))),
);
let (ctx, episodic) = make_ctx(tools);
let thread = ThreadId::new("t-abort");
let calls = vec![
call("c-1", "ok"),
call("c-2", "boom"),
call("c-3", "never_runs"),
];
let err = dispatch_tool_calls(&ctx, &thread, &calls, "blocking")
.await
.expect_err("non-retryable must abort");
assert!(
matches!(&err, Error::Tool(ToolError::Permanent(m)) if m == "nope"),
"expected Tool(Permanent), got {err:?}"
);
let episodes = episodic.replay(ctx.run_id).await.unwrap();
let tool_episode_count = episodes
.iter()
.filter(|e| matches!(e, Episode::ToolCall { .. }))
.count();
assert_eq!(
tool_episode_count, 2,
"only ok + boom should have been recorded; never_runs aborts"
);
let history = ctx.short_term.load(thread, 1024).await.unwrap();
let tool_msgs: Vec<_> = history.iter().filter(|m| m.role == Role::Tool).collect();
assert_eq!(tool_msgs.len(), 2);
assert!(tool_msgs[1].content.contains("error: permanent: nope"));
}
#[tokio::test]
async fn retryable_tool_error_is_swallowed_and_loop_continues() {
let tools = Arc::new(
FakeToolInvoker::new()
.with_tool("flaky", "", |_| {
Err(ToolError::Retryable {
message: "transient".into(),
retry_after_secs: 1,
})
})
.with_tool("after", "", |_| Ok(serde_json::json!("after-value"))),
);
let (ctx, episodic) = make_ctx(tools);
let thread = ThreadId::new("t-retry");
let calls = vec![call("c-1", "flaky"), call("c-2", "after")];
dispatch_tool_calls(&ctx, &thread, &calls, "streaming")
.await
.expect("retryable error must not abort the loop");
let episodes = episodic.replay(ctx.run_id).await.unwrap();
let tool_episode_count = episodes
.iter()
.filter(|e| matches!(e, Episode::ToolCall { .. }))
.count();
assert_eq!(tool_episode_count, 2);
let history = ctx.short_term.load(thread, 1024).await.unwrap();
let tool_msgs: Vec<_> = history.iter().filter(|m| m.role == Role::Tool).collect();
assert_eq!(tool_msgs.len(), 2);
assert!(tool_msgs[0].content.contains("error: retryable"));
assert_eq!(tool_msgs[1].content, "\"after-value\"");
}
#[tokio::test]
async fn flagged_tool_with_redactor_records_masked_shape_never_raw_pii() {
let pii = serde_json::json!({"iban": PII_IBAN, "email": PII_EMAIL});
let tools = Arc::new(
FakeToolInvoker::new().with_redacting_tool("claim", "", move |_| Ok(pii.clone())),
);
let (mut ctx, episodic) = make_ctx(tools);
ctx.audit_redactor = Some(Arc::new(MaskingRedactor));
let calls = vec![call_with_args(
"c-1",
"claim",
serde_json::json!({"applicant": PII_EMAIL}),
)];
dispatch_tool_calls(&ctx, &ThreadId::new("t"), &calls, "blocking")
.await
.unwrap();
let recorded = recorded_episode_string(&episodic, ctx.run_id).await;
assert!(
!recorded.contains(PII_IBAN) && !recorded.contains(PII_EMAIL),
"no raw PII may survive in the episodic record; got {recorded}"
);
assert!(
recorded.contains(MASK_TOKEN),
"the masked shape must be present, proving structure is kept"
);
}
#[tokio::test]
async fn flagged_tool_without_redactor_records_opaque_digest_fail_closed() {
let pii = serde_json::json!({"iban": PII_IBAN});
let tools = Arc::new(
FakeToolInvoker::new().with_redacting_tool("claim", "", move |_| Ok(pii.clone())),
);
let (ctx, episodic) = make_ctx(tools);
assert!(ctx.audit_redactor.is_none(), "no redactor wired");
let calls = vec![call_with_args(
"c-1",
"claim",
serde_json::json!({"iban": PII_IBAN}),
)];
dispatch_tool_calls(&ctx, &ThreadId::new("t"), &calls, "blocking")
.await
.unwrap();
let recorded = recorded_episode_string(&episodic, ctx.run_id).await;
assert!(
!recorded.contains(PII_IBAN),
"fail-closed must not leak the secret; got {recorded}"
);
assert!(
!recorded.contains("[REDACTED:"),
"fail-closed must not emit a partial-redaction token"
);
assert!(
recorded.contains("sha256"),
"fail-closed must record the opaque-digest marker"
);
}
#[tokio::test]
async fn non_flagged_tool_records_args_and_result_raw() {
let marker = "RAW-MARKER-1234";
let value = serde_json::json!({"echo": marker});
let tools =
Arc::new(FakeToolInvoker::new().with_tool("plain", "", move |_| Ok(value.clone())));
let (ctx, episodic) = make_ctx(tools);
let calls = vec![call_with_args(
"c-1",
"plain",
serde_json::json!({"in": marker}),
)];
dispatch_tool_calls(&ctx, &ThreadId::new("t"), &calls, "blocking")
.await
.unwrap();
let recorded = recorded_episode_string(&episodic, ctx.run_id).await;
assert!(
recorded.contains(marker),
"a non-flagged tool must persist the value verbatim; got {recorded}"
);
}
#[tokio::test]
async fn flagged_tool_error_arm_redacts_message() {
let tools = Arc::new(
FakeToolInvoker::new().with_redacting_tool("claim", "", |_| {
Err(ToolError::Retryable {
message: format!("lookup failed for {PII_EMAIL}"),
retry_after_secs: 1,
})
}),
);
let (ctx, episodic) = make_ctx(tools);
let calls = vec![call_with_args("c-1", "claim", serde_json::json!({}))];
dispatch_tool_calls(&ctx, &ThreadId::new("t"), &calls, "blocking")
.await
.unwrap();
let episodes = episodic.replay(ctx.run_id).await.unwrap();
match first_tool_result(&episodes) {
ToolResult::Err { message } => assert!(
!message.contains(PII_EMAIL),
"the error message must not carry PII; got {message}"
),
ToolResult::Ok { .. } => panic!("expected an Err result"),
}
}
#[test]
fn redacted_helper_without_redactor_digests_args_and_marks_error() {
let (args, result) = redacted_args_and_result(
None,
&serde_json::json!({"iban": PII_IBAN}),
ToolResult::Err {
message: format!("failed {PII_EMAIL}"),
},
);
assert_eq!(args["redacted"], serde_json::json!("sha256"));
match result {
ToolResult::Err { message } => assert_eq!(message, REDACTED_ERROR_MARKER),
ToolResult::Ok { .. } => panic!("expected Err"),
}
}
#[test]
fn redacted_helper_with_redactor_masks_ok_value() {
let redactor = MaskingRedactor;
let (args, result) = redacted_args_and_result(
Some(&redactor),
&serde_json::json!({"k": PII_IBAN}),
ToolResult::Ok {
value: serde_json::json!({"out": PII_EMAIL}),
},
);
assert_eq!(args["k"], serde_json::json!(MASK_TOKEN));
match result {
ToolResult::Ok { value } => assert_eq!(value["out"], serde_json::json!(MASK_TOKEN)),
ToolResult::Err { .. } => panic!("expected Ok"),
}
}
}