Skip to content

Commit deb0d39

Browse files
fix(core): harden canonical tool execution semantics
1 parent a03313a commit deb0d39

32 files changed

Lines changed: 2476 additions & 490 deletions

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Dynamic tools: `ToolDyn` is removed from the public API; use `DynamicTool` for runtime-defined tools. Rig's erased dispatch trait is private. Typed tools use `Tool::NAME` as their sole identity; runtime-named agents convert explicitly with `Agent::into_tool()`.
1616
- Registration vocabulary: `AgentBuilder::tools(Vec<DynamicTool>)` becomes `dynamic_tools`, while retrieval-backed `dynamic_tools(sample, index, toolset)` becomes `retrieved_tools`. On `ToolSetBuilder`, `runtime_tool(DynamicTool)` becomes `dynamic_tool`, and the former embedding-backed `dynamic_tool(ToolEmbedding)` becomes `retrieved_tool`.
1717
- Results and errors: replace `ToolError`, `ToolFailure`, `ToolFailureKind`, `ToolReturn`, `ToolReturnOutcome`, `ToolExecutionResult`, and `ToolOutcome` with `ToolExecutionError`, `ToolErrorKind`, and the read-only `ToolResult` observed by hooks.
18-
- Model presentation: serializable outputs convert once into `ToolOutput`; strings remain literal text, structured values remain JSON, and multimodal tools return explicit `ToolOutput::Content`. Result hooks now rewrite `ToolOutput`, provider adapters render JSON only at their terminal wire boundary, mixed user/tool-result blocks retain order, and Rig never reparses strings to infer rich content.
19-
- Error privacy: operator-facing error messages reach the model only when explicitly supplied with `with_model_feedback`; otherwise Rig emits stable kind-specific feedback. MCP responses preserve ordered supported text/image content, retain unsupported and future blocks as typed JSON, and attach raw `CallToolResult`, `structuredContent`, and response metadata to `ToolContext`. MCP list installation and refresh are atomic and ownership-aware, so stale handlers cannot replace or remove newer registrations.
18+
- Model presentation: serializable outputs convert once into canonical `ToolOutput` content blocks; strings remain literal text, explicit `serde_json::Value` values remain JSON, and multimodal tools use `ToolOutput::content` / `ToolOutput::one` or return typed `ToolResultContent` directly. Result hooks now rewrite `ToolOutput`, provider adapters render JSON only at their terminal wire boundary, mixed user/tool-result blocks retain order, and Rig never reparses strings to infer rich content.
19+
- Error presentation: detailed execution-error messages remain model-visible by default so argument, validation, and custom tool errors are actionable. Use `redact_model_feedback` when diagnostics contain secrets, `with_model_feedback` for replacement text, or `with_model_output` for JSON/multimodal feedback. MCP responses preserve ordered supported text/image content, retain unsupported and future blocks as typed JSON, and attach raw `CallToolResult`, `structuredContent`, and response metadata to `ToolContext`. MCP list installation and refresh are atomic and ownership-aware, so stale handlers cannot replace or remove newer registrations.
2020
- Dispatch: replace `ToolSet::{call, call_with_extensions, call_structured}` with `ToolSet::execute`; replace `ToolServerHandle::{call_tool, call_tool_with_extensions, call_tool_structured}` with `ToolServerHandle::execute`.
2121
- Registration and definitions: `ToolSet` is the single ordered registry and records whether each tool is always advertised or retrieval-only. `ToolSet::{get_tool_definitions, documents}` are now synchronous and infallible, `ToolServerHandle` registration/removal methods no longer return an artificial `Result`, and the obsolete `ToolSetError` is removed.
2222
- Hooks: replace `AgentHook::on_event`, `StepEvent`, and `Flow` with the event-specific `AgentHook` methods and their corresponding action types (`CompletionCallAction`, `ToolCallAction`, `ToolResultAction`, `InvalidToolCallAction`, and `ObservationAction`). Invalid-tool hooks return `None` to defer; every explicit action, including `Fail`, is terminal for that hook stack.

crates/rig-core/CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Dynamic tools: `ToolDyn` is removed from the public API; use `DynamicTool` for runtime-defined tools. Rig's erased dispatch trait is private. Typed tools use `Tool::NAME` as their sole identity; runtime-named agents convert explicitly with `Agent::into_tool()`.
1616
- Registration vocabulary: `AgentBuilder::tools(Vec<DynamicTool>)` becomes `dynamic_tools`, while retrieval-backed `dynamic_tools(sample, index, toolset)` becomes `retrieved_tools`. On `ToolSetBuilder`, `runtime_tool(DynamicTool)` becomes `dynamic_tool`, and the former embedding-backed `dynamic_tool(ToolEmbedding)` becomes `retrieved_tool`.
1717
- Results and errors: replace `ToolError`, `ToolFailure`, `ToolFailureKind`, `ToolReturn`, `ToolReturnOutcome`, `ToolExecutionResult`, and `ToolOutcome` with `ToolExecutionError`, `ToolErrorKind`, and the read-only `ToolResult` observed by hooks.
18-
- Model presentation: serializable outputs convert once into `ToolOutput`; strings remain literal text, structured values remain JSON, and multimodal tools return explicit `ToolOutput::Content`. Result hooks now rewrite `ToolOutput`, provider adapters render JSON only at their terminal wire boundary, mixed user/tool-result blocks retain order, and Rig never reparses strings to infer rich content.
19-
- Error privacy: operator-facing error messages reach the model only when explicitly supplied with `with_model_feedback`; otherwise Rig emits stable kind-specific feedback. MCP responses preserve ordered supported text/image content, retain unsupported and future blocks as typed JSON, and attach raw `CallToolResult`, `structuredContent`, and response metadata to `ToolContext`. MCP list installation and refresh are atomic and ownership-aware, so stale handlers cannot replace or remove newer registrations.
18+
- Model presentation: serializable outputs convert once into canonical `ToolOutput` content blocks; strings remain literal text, explicit `serde_json::Value` values remain JSON, and multimodal tools use `ToolOutput::content` / `ToolOutput::one` or return typed `ToolResultContent` directly. Result hooks now rewrite `ToolOutput`, provider adapters render JSON only at their terminal wire boundary, mixed user/tool-result blocks retain order, and Rig never reparses strings to infer rich content.
19+
- Error presentation: detailed execution-error messages remain model-visible by default so argument, validation, and custom tool errors are actionable. Use `redact_model_feedback` when diagnostics contain secrets, `with_model_feedback` for replacement text, or `with_model_output` for JSON/multimodal feedback. MCP responses preserve ordered supported text/image content, retain unsupported and future blocks as typed JSON, and attach raw `CallToolResult`, `structuredContent`, and response metadata to `ToolContext`. MCP list installation and refresh are atomic and ownership-aware, so stale handlers cannot replace or remove newer registrations.
2020
- Dispatch: replace `ToolSet::{call, call_with_extensions, call_structured}` with `ToolSet::execute`; replace `ToolServerHandle::{call_tool, call_tool_with_extensions, call_tool_structured}` with `ToolServerHandle::execute`.
2121
- Registration and definitions: `ToolSet` is the single ordered registry and records whether each tool is always advertised or retrieval-only. `ToolSet::{get_tool_definitions, documents}` are now synchronous and infallible, `ToolServerHandle` registration/removal methods no longer return an artificial `Result`, and the obsolete `ToolSetError` is removed.
2222
- Hooks: replace `AgentHook::on_event`, `StepEvent`, and `Flow` with the event-specific `AgentHook` methods and their corresponding action types (`CompletionCallAction`, `ToolCallAction`, `ToolResultAction`, `InvalidToolCallAction`, and `ObservationAction`). Invalid-tool hooks return `None` to defer; every explicit action, including `Fail`, is terminal for that hook stack.

crates/rig-core/src/agent/completion.rs

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::{
1111
json_utils,
1212
message::ToolChoice,
1313
streaming::{StreamingChat, StreamingCompletion, StreamingPrompt},
14-
tool::server::ToolServerHandle,
14+
tool::server::{ToolRegistrySnapshot, ToolServerHandle},
1515
vector_store::{VectorStoreError, request::VectorSearchRequest},
1616
wasm_compat::WasmCompatSend,
1717
};
@@ -33,6 +33,8 @@ pub type DynamicContextStore = Arc<
3333
/// to the provider for this turn.
3434
pub(crate) struct PreparedCompletionRequest<M: CompletionModel> {
3535
pub(crate) builder: CompletionRequestBuilder<M>,
36+
/// Exact implementations behind this turn's provider definitions.
37+
pub(crate) tool_snapshot: Arc<ToolRegistrySnapshot>,
3638
pub(crate) executable_tool_names: BTreeSet<String>,
3739
pub(crate) allowed_tool_names: BTreeSet<String>,
3840
/// When Tool output mode is active, the name of the synthetic output tool
@@ -325,7 +327,7 @@ pub(crate) async fn build_prepared_completion_request<M: CompletionModel>(
325327
// Fetch dynamic (RAG) documents and the real executable tool set first, so we
326328
// can resolve the output mode (which depends on whether tools exist) before
327329
// building the preamble and request.
328-
let (mut tooldefs, fetched_context): (Vec<crate::completion::ToolDefinition>, Vec<Document>) =
330+
let (mut tool_snapshot, fetched_context): (ToolRegistrySnapshot, Vec<Document>) =
329331
match &rag_text {
330332
Some(text) => {
331333
let search_futures = dynamic_context.iter().map(|(num_sample, index)| {
@@ -366,21 +368,25 @@ pub(crate) async fn build_prepared_completion_request<M: CompletionModel>(
366368
.flatten()
367369
.collect();
368370

369-
let tooldefs = tool_server_handle
370-
.get_tool_defs(Some(text.to_string()))
371+
let tool_snapshot = tool_server_handle
372+
.snapshot_tool_defs(Some(text.to_string()))
371373
.await
372374
.map_err(|_| {
373375
CompletionError::RequestError("Failed to get tool definitions".into())
374376
})?;
375377

376-
(tooldefs, fetched_context)
378+
(tool_snapshot, fetched_context)
377379
}
378380
None => {
379-
let tooldefs = tool_server_handle.get_tool_defs(None).await.map_err(|_| {
380-
CompletionError::RequestError("Failed to get tool definitions".into())
381-
})?;
382-
383-
(tooldefs, Vec::new())
381+
let tool_snapshot =
382+
tool_server_handle
383+
.snapshot_tool_defs(None)
384+
.await
385+
.map_err(|_| {
386+
CompletionError::RequestError("Failed to get tool definitions".into())
387+
})?;
388+
389+
(tool_snapshot, Vec::new())
384390
}
385391
};
386392

@@ -392,8 +398,13 @@ pub(crate) async fn build_prepared_completion_request<M: CompletionModel>(
392398
// narrowed set could commit a name that collides once the filter lifts.
393399
// Without a filter the full set equals `executable_tool_names` below, so we
394400
// skip the extra allocation and reuse that.
395-
let pre_filter_tool_names: Option<BTreeSet<String>> =
396-
active_tools.map(|_| tooldefs.iter().map(|tool| tool.name.clone()).collect());
401+
let pre_filter_tool_names: Option<BTreeSet<String>> = active_tools.map(|_| {
402+
tool_snapshot
403+
.definitions()
404+
.iter()
405+
.map(|tool| tool.name.clone())
406+
.collect()
407+
});
397408

398409
// Apply a per-turn `active_tools` allow-list (from a `CompletionCall` hook):
399410
// narrow the advertised tool set to the named tools BEFORE computing the
@@ -403,20 +414,25 @@ pub(crate) async fn build_prepared_completion_request<M: CompletionModel>(
403414
// allow-list. A name that isn't available this turn is a hook bug, surfaced
404415
// as a request error (mirroring `ToolChoice::Specific`'s contract).
405416
if let Some(allow) = active_tools {
406-
if let Some(missing) = allow
407-
.iter()
408-
.find(|name| !tooldefs.iter().any(|tool| &tool.name == *name))
409-
{
417+
if let Some(missing) = allow.iter().find(|name| {
418+
!tool_snapshot
419+
.definitions()
420+
.iter()
421+
.any(|tool| &tool.name == *name)
422+
}) {
410423
return Err(CompletionError::RequestError(
411424
format!(
412425
"active_tools requested tool `{missing}`, which is not available this turn"
413426
)
414427
.into(),
415428
));
416429
}
417-
tooldefs.retain(|tool| allow.iter().any(|name| name == &tool.name));
430+
let allowed: BTreeSet<String> = allow.iter().cloned().collect();
431+
tool_snapshot.retain_names(&allowed);
418432
}
419433

434+
let mut tooldefs = tool_snapshot.definitions().to_vec();
435+
420436
// Executable tools are the real tool-server tools, computed BEFORE any
421437
// synthetic output tool is appended.
422438
let executable_tool_names: BTreeSet<String> =
@@ -604,6 +620,7 @@ pub(crate) async fn build_prepared_completion_request<M: CompletionModel>(
604620

605621
Ok(PreparedCompletionRequest {
606622
builder: completion_request,
623+
tool_snapshot: Arc::new(tool_snapshot),
607624
executable_tool_names,
608625
allowed_tool_names,
609626
output_tool_name,

crates/rig-core/src/agent/hook.rs

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
11
//! Event-specific hooks for observing and steering an agent run.
22
//!
3-
//! Each lifecycle method returns an action type containing only actions that
4-
//! event can honor. Request patches merge, tool argument/result rewrites chain,
5-
//! and stop actions short-circuit in registration order.
3+
//! [`AgentHook`] replaces the old universal event/action pair with one lifecycle
4+
//! method and one action type per event. Unsupported combinations are therefore
5+
//! rejected by the compiler instead of being interpreted at runtime.
6+
//!
7+
//! Hooks run in registration order through [`HookStack`]. Completion-call
8+
//! [`RequestPatch`] values accumulate and merge; tool-call argument rewrites and
9+
//! tool-result presentation rewrites chain into later hooks. Nested stacks obey
10+
//! the same rules as flat stacks, including preserving an argument rewrite when
11+
//! an inner stack later skips or stops. Every stop action short-circuits the
12+
//! remaining hooks for that event.
13+
//!
14+
//! Register observe-only hooks before steering hooks when every observation is
15+
//! required: a steering stop intentionally prevents later observers from
16+
//! running. Tool-result rewrites change only the model-visible `presentation`;
17+
//! [`ToolResultEvent::raw_result`] and its [`ToolResultEvent::tool_context`]
18+
//! remain the execution truth used by policy and telemetry.
19+
//!
20+
//! Blocking and streaming agents share the same request, tool-call, and
21+
//! tool-result resolution path. Streaming adds delta-specific observations, but
22+
//! shared lifecycle actions have identical semantics on both surfaces.
623
724
use std::collections::HashMap;
825
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -1107,7 +1124,7 @@ impl<M: CompletionModel> HookStack<M> {
11071124
) -> (ToolCallAction, Option<serde_json::Value>) {
11081125
let mut effective = None;
11091126
for hook in &self.hooks {
1110-
let rewritten = effective.as_ref().map(json_utils::value_to_json_string);
1127+
let rewritten = effective.as_ref().map(json_utils::serialize_json_value);
11111128
let current = ToolCall {
11121129
args: rewritten.as_deref().unwrap_or(event.args),
11131130
..event
@@ -1420,7 +1437,7 @@ mod tests {
14201437
*seen.lock().unwrap(),
14211438
vec![
14221439
(
1423-
"tool execution timed out".into(),
1440+
"raw failure".into(),
14241441
ToolErrorKind::Timeout,
14251442
"request-metadata".into()
14261443
),
@@ -1431,7 +1448,7 @@ mod tests {
14311448
),
14321449
]
14331450
);
1434-
assert_eq!(raw.output().as_text(), Some("tool execution timed out"));
1451+
assert_eq!(raw.output().as_text(), Some("raw failure"));
14351452
assert_eq!(
14361453
context.result::<String>().map(String::as_str),
14371454
Some("request-metadata")
@@ -2013,6 +2030,43 @@ mod migrated_tests {
20132030
assert_eq!(calls.load(Ordering::Relaxed), 1);
20142031
}
20152032

2033+
#[tokio::test]
2034+
async fn string_rewrite_is_json_encoded_for_later_hook_in_same_stack() {
2035+
let spy = ArgsSpy::default();
2036+
let replacement = Value::String("sanitized".into());
2037+
let mut stack = HookStack::<M>::new();
2038+
stack.push(RewriteHook(replacement.clone()));
2039+
stack.push(spy.clone());
2040+
2041+
let (action, salvaged) = resolve(&stack).await;
2042+
2043+
assert_eq!(action, ToolCallAction::rewrite(replacement.clone()));
2044+
assert_eq!(salvaged, None);
2045+
assert_eq!(
2046+
spy.0.lock().unwrap().as_slice(),
2047+
[serde_json::to_string(&replacement).unwrap()]
2048+
);
2049+
}
2050+
2051+
#[tokio::test]
2052+
async fn string_rewrite_is_json_encoded_for_hook_in_nested_stack() {
2053+
let spy = ArgsSpy::default();
2054+
let replacement = Value::String("sanitized".into());
2055+
let inner = HookStack::<M>::with(spy.clone());
2056+
let mut outer = HookStack::<M>::new();
2057+
outer.push(RewriteHook(replacement.clone()));
2058+
outer.push(inner);
2059+
2060+
let (action, salvaged) = resolve(&outer).await;
2061+
2062+
assert_eq!(action, ToolCallAction::rewrite(replacement.clone()));
2063+
assert_eq!(salvaged, None);
2064+
assert_eq!(
2065+
spy.0.lock().unwrap().as_slice(),
2066+
[serde_json::to_string(&replacement).unwrap()]
2067+
);
2068+
}
2069+
20162070
#[tokio::test]
20172071
async fn nested_rewrite_then_skip_preserves_rewrite() {
20182072
let mut inner = HookStack::<M>::new();

0 commit comments

Comments
 (0)