diff --git a/Cargo.lock b/Cargo.lock index 189b039bcfb..8b60e713d39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,7 @@ dependencies = [ "non_empty", "prompt", "regex", + "rig-agent", "rig-core", "schemars 1.2.1", "serde", @@ -799,7 +800,7 @@ dependencies = [ "Inflector", "async-graphql-parser", "darling 0.23.0", - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", "strum 0.27.2", @@ -2978,7 +2979,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim 0.11.1", + "strsim", ] [[package]] @@ -3884,7 +3885,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim 0.11.1", + "strsim", "syn 2.0.117", ] @@ -3898,7 +3899,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim 0.11.1", + "strsim", "syn 2.0.117", ] @@ -3911,7 +3912,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim 0.11.1", + "strsim", "syn 2.0.117", ] @@ -4112,47 +4113,6 @@ dependencies = [ "workspace-hack", ] -[[package]] -name = "deluxe" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed332aaf752b459088acf3dd4eca323e3ef4b83c70a84ca48fb0ec5305f1488" -dependencies = [ - "deluxe-core", - "deluxe-macros", - "once_cell", - "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "deluxe-core" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddada51c8576df9d6a8450c351ff63042b092c9458b8ac7d20f89cbd0ffd313" -dependencies = [ - "arrayvec 0.7.6", - "proc-macro2", - "quote", - "strsim 0.10.0", - "syn 2.0.117", -] - -[[package]] -name = "deluxe-macros" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87546d9c837f0b7557e47b8bd6eae52c3c223141b76aa233c345c9ab41d9117" -dependencies = [ - "deluxe-core", - "heck 0.4.1", - "if_chain", - "proc-macro-crate 1.3.1", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "der" version = "0.7.10" @@ -6050,6 +6010,10 @@ name = "futures-timer" version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" +dependencies = [ + "gloo-timers", + "send_wrapper", +] [[package]] name = "futures-util" @@ -6312,6 +6276,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gmail_client" version = "0.1.0" @@ -7418,12 +7394,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "if_chain" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" - [[package]] name = "im" version = "15.1.0" @@ -9866,15 +9836,6 @@ dependencies = [ "workspace-hack", ] -[[package]] -name = "nanoid" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" -dependencies = [ - "rand 0.8.6", -] - [[package]] name = "native_app_server" version = "0.1.0" @@ -10346,7 +10307,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.117", @@ -11488,16 +11449,6 @@ dependencies = [ "elliptic-curve", ] -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -12477,10 +12428,33 @@ version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +[[package]] +name = "rig-agent" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b0796bbf47d7b76670401aac975bc619cf7fba3482b22dfe14992edaa9c2e04" +dependencies = [ + "async-stream", + "fastrand 2.4.1", + "futures", + "http 1.4.0", + "indexmap 2.14.0", + "rig-core", + "rig-derive", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-futures", +] + [[package]] name = "rig-core" -version = "0.38.2" -source = "git+https://github.com/macro-inc/rig?branch=feat%2Fresponses-api-non-strict-tools#6deadfc6f9b432c849ea79733a549f46407530b7" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f5520515ae8f6851adcbc6fde9eea8e96f657418c062e16c82cd81cce44e8e" dependencies = [ "as-any", "async-stream", @@ -12492,9 +12466,9 @@ dependencies = [ "futures-timer", "glob", "http 1.4.0", + "indexmap 2.14.0", "mime", "mime_guess", - "nanoid", "ordered-float", "pin-project-lite", "reqwest 0.13.4", @@ -12512,16 +12486,14 @@ dependencies = [ [[package]] name = "rig-derive" -version = "0.38.2" -source = "git+https://github.com/macro-inc/rig?branch=feat%2Fresponses-api-non-strict-tools#6deadfc6f9b432c849ea79733a549f46407530b7" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb868fcebdf3ba425e3afad2e4926bb6d9e1188a856843b00bcee2e15c07424f" dependencies = [ "convert_case 0.11.0", - "deluxe", - "indoc", - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", - "serde_json", "syn 2.0.117", ] @@ -13403,6 +13375,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -14562,12 +14540,6 @@ dependencies = [ "vte", ] -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - [[package]] name = "strsim" version = "0.11.1" @@ -15227,17 +15199,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 0.6.11", - "winnow 0.5.40", -] - [[package]] name = "toml_edit" version = "0.22.27" @@ -16722,15 +16683,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "0.7.15" @@ -17058,6 +17010,7 @@ dependencies = [ "regex-automata", "regex-syntax", "reqwest 0.13.4", + "rig-agent", "rig-core", "rmcp", "rsa", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 8f47f7b8ad0..d4e2f529b08 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -14,9 +14,8 @@ async-stream = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } regex = { workspace = true } -# Macro fork of rig (upstream main + with_non_strict_tools() on Responses -# API models). Drop back to crates.io once the patch lands upstream. -rig-core = { git = "https://github.com/macro-inc/rig", branch = "feat/responses-api-non-strict-tools" } +rig-core = "0.41.0" +rig-agent = "0.41.0" schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -38,4 +37,5 @@ tokio-util.workspace = true model-entity = { path = "../model-entity" } non_empty = { path = "../non_empty" } # Enable rig's scripted MockCompletionModel for tests (fake AI streams). -rig-core = { git = "https://github.com/macro-inc/rig", branch = "feat/responses-api-non-strict-tools", features = ["test-utils"] } +rig-core = { version = "0.41.0", features = ["test-utils"] } +rig-agent = { version = "0.41.0", features = ["test-utils"] } diff --git a/crates/agent/src/agent_loop.rs b/crates/agent/src/agent_loop.rs index d9f59486486..d0fd7185571 100644 --- a/crates/agent/src/agent_loop.rs +++ b/crates/agent/src/agent_loop.rs @@ -7,8 +7,8 @@ use crate::stream::ChatCompletionStream; use crate::tool_adapter::DynToolSetAdapter; use ai_toolset::{RequestContext, SearchableTool, ToolLoader, ToolSet as AiToolSet}; use ai_usage::{UsageContext, UsageRecorder}; +use rig_agent::tool::server::{ToolServer, ToolServerHandle}; use rig_core::message::Message; -use rig_core::tool::server::{ToolServer, ToolServerHandle}; use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Mutex, RwLock}; @@ -157,10 +157,7 @@ impl AgentLoop { let handle = ToolServer::new().run(); for adapter in adapters { - handle - .add_tool(adapter) - .await - .expect("failed to register tool"); + handle.add_dynamic_tool(adapter).await; } // Registers `SearchTools`-discovered tools with the live tool server so @@ -200,9 +197,7 @@ impl AgentLoop { context.clone(), request_context_rw.clone(), ); - if let Err(e) = handle.add_tool(adapter).await { - tracing::warn!(error = ?e, "failed to load searched tool"); - } + handle.add_dynamic_tool(adapter).await; } }) as Pin + Send>> }) @@ -259,7 +254,6 @@ impl AgentLoop { where Context: Clone + Send + Sync + 'static, M: rig_core::completion::CompletionModel + 'static, - M::StreamingResponse: rig_core::completion::GetTokenUsage + Send + Sync, { self.session_with( toolset, diff --git a/crates/agent/src/completion.rs b/crates/agent/src/completion.rs index 7d493390f38..e78ea1e5d5f 100644 --- a/crates/agent/src/completion.rs +++ b/crates/agent/src/completion.rs @@ -4,8 +4,9 @@ //! actual prompting lives here. use crate::model::router::{ModelRouter, RoutedModel}; use ai_usage::{UsageContext, UsageRecorder}; -use rig_core::agent::{AgentBuilder, PromptResponse}; -use rig_core::completion::{CompletionModel, Prompt}; +use rig_agent::agent::{AgentBuilder, PromptResponse}; +use rig_agent::completion::Prompt; +use rig_core::completion::CompletionModel; use rig_core::message::Message; const ONE_SHOT_MAX_TOKENS: u64 = 16_000; @@ -117,6 +118,6 @@ async fn prompt_with_history( Ok(agent .prompt(prompt.clone()) .extended_details() - .with_history(history.to_vec()) + .history(history.to_vec()) .await?) } diff --git a/crates/agent/src/error.rs b/crates/agent/src/error.rs index 7215d1f826f..2f35dff8889 100644 --- a/crates/agent/src/error.rs +++ b/crates/agent/src/error.rs @@ -1,18 +1,19 @@ -use rig_core::agent::StreamingError; -use rig_core::completion::{CompletionError, PromptError}; +use rig_agent::agent::StreamingError; +use rig_agent::completion::PromptError; +use rig_core::completion::CompletionError; /// Errors produced by the agent crate. #[derive(Debug, thiserror::Error)] pub enum AgentError { /// An error from the RIG completion layer. #[error(transparent)] - Completion(#[from] rig_core::completion::CompletionError), + Completion(#[from] CompletionError), /// An error from the RIG prompt/agentic loop. #[error(transparent)] - Prompt(#[from] rig_core::completion::PromptError), + Prompt(#[from] PromptError), /// An error from the RIG streaming layer. #[error(transparent)] - Streaming(#[from] rig_core::agent::StreamingError), + Streaming(#[from] StreamingError), /// Serialization / deserialization failure. #[error(transparent)] Json(#[from] serde_json::Error), @@ -39,8 +40,6 @@ pub enum AgentError { impl AgentError { /// is the error caused by a cancellation pub fn was_cancelled(&self) -> bool { - use rig_core::agent::StreamingError; - use rig_core::completion::PromptError; match self { // A direct prompt error. Self::Prompt(PromptError::PromptCancelled { .. }) => true, diff --git a/crates/agent/src/hook.rs b/crates/agent/src/hook.rs index 1dcabbc9cb7..1c195f932f6 100644 --- a/crates/agent/src/hook.rs +++ b/crates/agent/src/hook.rs @@ -1,13 +1,13 @@ -/// A [`rig_core::agent::PromptHook`] that bridges RIG lifecycle events into +/// A [`rig_agent::agent::AgentHook`] that bridges rig lifecycle events into /// [`StreamPart`] items sent through a channel. use crate::AgentError; use crate::stream::{McpInfo, StreamPart, ToolCall, ToolResponse, Usage}; use ai_toolset::{SearchableTool, ToolInfo}; -use rig_core::agent::{ - HookAction, InvalidToolCallContext, InvalidToolCallHookAction, PromptHook, ToolCallHookAction, +use rig_agent::agent::hook::{ + AgentHook, HookContext, InvalidToolCallAction, InvalidToolCallContext, ObservationAction, + StreamResponseFinish, TextDelta, ToolCallAction, ToolResultAction, ToolResultEvent, }; -use rig_core::completion::{CompletionModel, GetTokenUsage}; -use rig_core::message::Message; +use rig_agent::tool::ToolOutput; use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Mutex}; @@ -102,19 +102,29 @@ impl StreamBridge { } } -impl PromptHook for StreamBridge -where - M: CompletionModel, - M::StreamingResponse: GetTokenUsage + Send + Sync, -{ - async fn on_text_delta(&self, text_delta: &str, _aggregated_text: &str) -> HookAction { - let _ = self.tx.send(Ok(StreamPart::Content(text_delta.to_owned()))); +/// The model-visible presentation of a tool result as a single JSON value, +/// mirroring what the pre-0.41 string-based hook saw: our adapters return +/// structured JSON on success, so a JSON content block yields its value and +/// plain text is tried as JSON for compatibility. +fn presentation_json(presentation: &ToolOutput) -> Option { + if let Some(json) = presentation.as_json() { + return Some(json.clone()); + } + presentation + .as_text() + .and_then(|text| serde_json::from_str(text).ok()) +} + +/// The hook bodies, as inherent methods so tests can exercise them directly: +/// rig's [`HookContext`] has no public constructor, so the [`AgentHook`] impl +/// below is a thin delegation layer over these. +impl StreamBridge { + pub(crate) fn handle_text_delta(&self, delta: &str) -> ObservationAction { + let _ = self.tx.send(Ok(StreamPart::Content(delta.to_owned()))); if self.cancel.is_cancelled() { - HookAction::Terminate { - reason: CANCELLED_REASON.into(), - } + ObservationAction::stop(CANCELLED_REASON) } else { - HookAction::Continue + ObservationAction::Continue } } @@ -129,53 +139,49 @@ where /// is rebuilt from the live tool server, so the retried call is valid. For /// names that exist nowhere, the retry feedback points the model at /// `SearchTools` instead of failing the stream on a hallucinated name. - async fn on_invalid_tool_call( + pub(crate) async fn handle_invalid_tool_call( &self, - context: &InvalidToolCallContext, - ) -> InvalidToolCallHookAction { + tool_name: &str, + ) -> Option { match self .searchable_catalog .iter() - .find(|tool| tool.name == context.tool_name) + .find(|tool| tool.name == tool_name) { Some(tool) => { (self.register_loaded)(vec![tool.clone()]).await; tracing::info!( - tool = %context.tool_name, + tool = %tool_name, "auto-loaded searchable tool the model called without loading" ); - InvalidToolCallHookAction::retry(format!( - "The tool `{}` exists but was not loaded when you called it. \ - It is loaded now — call it again with the same arguments.", - context.tool_name - )) + Some(InvalidToolCallAction::retry(format!( + "The tool `{tool_name}` exists but was not loaded when you called it. \ + It is loaded now — call it again with the same arguments." + ))) } - None => InvalidToolCallHookAction::retry(format!( - "Unknown tool `{}`: no tool with that name exists in this session \ + None => Some(InvalidToolCallAction::retry(format!( + "Unknown tool `{tool_name}`: no tool with that name exists in this session \ or its connected integrations. Use `SearchTools` to find the \ - right tool, or continue without it.", - context.tool_name - )), + right tool, or continue without it." + ))), } } - async fn on_tool_call( + pub(crate) fn handle_tool_call( &self, tool_name: &str, - tool_call_id: Option, + tool_call_id: Option<&str>, internal_call_id: &str, args: &str, - ) -> ToolCallHookAction { + ) -> ToolCallAction { if self.cancel.is_cancelled() { - return ToolCallHookAction::Terminate { - reason: CANCELLED_REASON.into(), - }; + return ToolCallAction::stop(CANCELLED_REASON); } let json = serde_json::from_str(args) .ok() .filter(serde_json::Value::is_object) .unwrap_or_else(|| serde_json::json!({})); - let id = tool_call_id.unwrap_or_else(|| internal_call_id.to_owned()); + let id = tool_call_id.unwrap_or(internal_call_id).to_owned(); let mcp = (self.routing)(tool_name).map(|i| match i { ToolInfo::ExternalTool { service_name, @@ -193,17 +199,17 @@ where json, mcp, }))); - ToolCallHookAction::Continue + ToolCallAction::Run } - async fn on_tool_result( + pub(crate) async fn handle_tool_result( &self, tool_name: &str, - tool_call_id: Option, + tool_call_id: Option<&str>, internal_call_id: &str, - _args: &str, - result: &str, - ) -> HookAction { + presentation: &ToolOutput, + is_success: bool, + ) -> ToolResultAction { // Register any tools `SearchTools` asked to load. This fires after the // tool executes and before the next turn's request is built, so loaded // tools are advertised + callable next turn. (The lock guard is dropped @@ -216,34 +222,79 @@ where (self.register_loaded)(pending).await; } - let id = tool_call_id.unwrap_or_else(|| internal_call_id.to_owned()); - let response = match serde_json::from_str::(result) { - Ok(json) => ToolResponse::Json { + let id = tool_call_id.unwrap_or(internal_call_id).to_owned(); + let response = if is_success && let Some(json) = presentation_json(presentation) { + ToolResponse::Json { id, json, name: tool_name.to_owned(), - }, - Err(_) => ToolResponse::Err { + } + } else { + ToolResponse::Err { id, name: tool_name.to_owned(), - description: result.to_owned(), - }, + description: presentation.render(), + } }; let _ = self.tx.send(Ok(StreamPart::ToolResponse(response))); - HookAction::Continue + ToolResultAction::Keep } - async fn on_stream_completion_response_finish( + pub(crate) fn handle_usage(&self, usage: rig_core::completion::Usage) -> ObservationAction { + let _ = self.tx.send(Ok(StreamPart::Usage(Usage { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + }))); + ObservationAction::Continue + } +} + +impl AgentHook for StreamBridge { + async fn on_text_delta(&self, _ctx: &HookContext, event: TextDelta<'_>) -> ObservationAction { + self.handle_text_delta(event.delta) + } + + async fn on_invalid_tool_call( &self, - _prompt: &Message, - response: &M::StreamingResponse, - ) -> HookAction { - if let Some(usage) = response.token_usage() { - let _ = self.tx.send(Ok(StreamPart::Usage(Usage { - input_tokens: usage.input_tokens, - output_tokens: usage.output_tokens, - }))); - } - HookAction::Continue + _ctx: &HookContext, + event: &InvalidToolCallContext, + ) -> Option { + self.handle_invalid_tool_call(&event.tool_name).await + } + + async fn on_tool_call( + &self, + _ctx: &HookContext, + event: rig_agent::agent::hook::ToolCall<'_>, + ) -> ToolCallAction { + self.handle_tool_call( + event.tool_name, + event.tool_call_id, + event.internal_call_id, + event.args, + ) + } + + async fn on_tool_result( + &self, + _ctx: &HookContext, + event: ToolResultEvent<'_>, + ) -> ToolResultAction { + self.handle_tool_result( + event.tool_name, + event.tool_call_id, + event.internal_call_id, + event.presentation, + event.raw_result.is_success(), + ) + .await + } + + async fn on_stream_response_finish( + &self, + _ctx: &HookContext, + event: StreamResponseFinish<'_>, + ) -> ObservationAction { + self.handle_usage(event.usage) } } diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index fd598f2a97c..c0732f6d90d 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -29,4 +29,3 @@ pub use tool_adapter::{DynToolSetAdapter, ToolsetToolAdapter, normalize_request_ pub use rig_core::completion::CompletionError; pub use rig_core::message::Message; -pub use rig_core::tool::{Tool, ToolDyn}; diff --git a/crates/agent/src/model/openai.rs b/crates/agent/src/model/openai.rs index 214b6f533ea..3ed0c22eef5 100644 --- a/crates/agent/src/model/openai.rs +++ b/crates/agent/src/model/openai.rs @@ -78,12 +78,10 @@ impl<'a> OpenAiResponsesModel<'a> { /// /// Rig maps the generic `max_tokens` agent setting to the Responses API's /// `max_output_tokens`, avoiding the Chat Completions `max_tokens` 400s on - /// GPT reasoning models. Tools are sent verbatim (`with_non_strict_tools`) - /// rather than coerced into OpenAI's strict subset. + /// GPT reasoning models. Tools are sent verbatim (non-strict is rig's + /// default since 0.41) rather than coerced into OpenAI's strict subset. pub fn completion(&self) -> openai::responses_api::ResponsesCompletionModel { - self.client - .completion_model(self.model.name().to_string()) - .with_non_strict_tools() + self.client.completion_model(self.model.name().to_string()) } /// Best-effort reasoning config for OpenAI Responses models, or `None` if diff --git a/crates/agent/src/model/router.rs b/crates/agent/src/model/router.rs index af71348e530..88413189bb5 100644 --- a/crates/agent/src/model/router.rs +++ b/crates/agent/src/model/router.rs @@ -22,12 +22,13 @@ use ai_toolset::{RequestContext, SearchableTool}; use ai_usage::{UsageContext, UsageRecorder}; use futures::StreamExt; use macro_env_var::env_var; -use rig_core::agent::{Agent, AgentBuilder, MultiTurnStreamItem}; +use rig_agent::agent::{Agent, AgentBuilder, MultiTurnStreamItem}; +use rig_agent::streaming::StreamingPrompt; +use rig_agent::tool::server::ToolServerHandle; use rig_core::completion::{CompletionModel, GetTokenUsage}; use rig_core::message::Message; use rig_core::providers::{anthropic, openai}; -use rig_core::streaming::{StreamedAssistantContent, StreamingPrompt}; -use rig_core::tool::server::ToolServerHandle; +use rig_core::streaming::StreamedAssistantContent; use super::PredefinedModel; use super::anthropic::AnthropicModel; @@ -428,10 +429,10 @@ where let mut rig_stream = agent .stream_prompt(prompt) - .with_history(history) - .multi_turn(max_turns) + .history(history) + .max_turns(max_turns) .max_invalid_tool_call_retries(crate::hook::MAX_INVALID_TOOL_CALL_RETRIES) - .with_hook(bridge) + .add_hook(bridge) .await; // Drive the rig stream on its own task. The hook emits a tool call the @@ -460,7 +461,7 @@ where } match other { Ok(MultiTurnStreamItem::FinalResponse(final_resp)) => { - let usage = final_resp.usage(); + let usage = final_resp.usage; // Best-effort cost logging; never fails the stream. recorder.record(usage_ctx.clone().into_event( model.clone(), diff --git a/crates/agent/src/test/mod.rs b/crates/agent/src/test/mod.rs index 78aa481e043..f0a1a64f6e3 100644 --- a/crates/agent/src/test/mod.rs +++ b/crates/agent/src/test/mod.rs @@ -5,5 +5,6 @@ mod test_error; mod test_hook; mod test_model_types; mod test_predefined_model; +mod test_rig_invariants; mod test_router; mod test_tool_adapter; diff --git a/crates/agent/src/test/test_error.rs b/crates/agent/src/test/test_error.rs index 67a2f81866b..f72cd2419b1 100644 --- a/crates/agent/src/test/test_error.rs +++ b/crates/agent/src/test/test_error.rs @@ -1,6 +1,7 @@ use crate::error::*; -use rig_core::agent::StreamingError; -use rig_core::completion::{CompletionError, PromptError}; +use rig_agent::agent::StreamingError; +use rig_agent::completion::PromptError; +use rig_core::completion::CompletionError; fn prompt_cancelled() -> PromptError { PromptError::PromptCancelled { diff --git a/crates/agent/src/test/test_hook.rs b/crates/agent/src/test/test_hook.rs index 46fd84a63d9..74a5c5e80e4 100644 --- a/crates/agent/src/test/test_hook.rs +++ b/crates/agent/src/test/test_hook.rs @@ -1,10 +1,8 @@ use crate::hook::*; use crate::stream::StreamPart; use ai_toolset::SearchableTool; -use rig_core::agent::{ - HookAction, InvalidToolCallContext, InvalidToolCallHookAction, PromptHook, ToolCallHookAction, -}; -use rig_core::providers::anthropic::completion::CompletionModel as AnthropicModel; +use rig_agent::agent::{InvalidToolCallAction, ToolCallAction}; +use rig_agent::tool::ToolOutput; use schemars::Schema; use std::pin::Pin; use std::sync::Arc; @@ -46,17 +44,16 @@ async fn on_tool_result_drains_buffer_and_registers_loaded_tools() { let (bridge, _rx) = StreamBridge::channel(routing, buffer.clone(), register, Arc::new(vec![]), token); - let action = >::on_tool_result( - &bridge, - "SearchTools", - None, - "call-1", - "{}", - "{\"loaded\":[]}", - ) - .await; + bridge + .handle_tool_result( + "SearchTools", + None, + "call-1", + &ToolOutput::json(serde_json::json!({"loaded": []})), + true, + ) + .await; - assert!(matches!(action, HookAction::Continue)); // Buffer drained and both pending tools handed to the registrar. assert!(buffer.lock().unwrap().is_empty()); let mut got = registered.lock().unwrap().clone(); @@ -78,21 +75,21 @@ async fn on_tool_result_registers_nothing_when_buffer_empty() { let token = CancellationToken::new(); let (bridge, _rx) = StreamBridge::channel(routing, buffer, register, Arc::new(vec![]), token); - let _ = >::on_tool_result( - &bridge, - "WebSearch", - None, - "call-2", - "{}", - "{}", - ) - .await; + bridge + .handle_tool_result( + "WebSearch", + None, + "call-2", + &ToolOutput::json(serde_json::json!({})), + true, + ) + .await; assert!(registered.lock().unwrap().is_empty()); } /// A bare bridge with no routing, no loaded-tool buffer, and an empty -/// searchable catalog, for exercising [`StreamBridge::on_tool_call`] in +/// searchable catalog, for exercising [`StreamBridge::handle_tool_call`] in /// isolation. fn bare_bridge() -> ( StreamBridge, @@ -112,16 +109,9 @@ fn bare_bridge() -> ( async fn on_tool_call_parses_object_args() { let (bridge, mut rx) = bare_bridge(); - let action = >::on_tool_call( - &bridge, - "Search", - None, - "call-1", - "{\"query\":\"cats\"}", - ) - .await; + let action = bridge.handle_tool_call("Search", None, "call-1", "{\"query\":\"cats\"}"); - assert!(matches!(action, ToolCallHookAction::Continue)); + assert!(matches!(action, ToolCallAction::Run)); let Ok(StreamPart::ToolCall(tool_call)) = rx.try_recv().unwrap() else { panic!("expected a tool call"); }; @@ -137,16 +127,9 @@ async fn on_tool_call_coerces_non_object_args_to_empty_object() { for args in ["", "null", "\"oops\"", "[1,2,3]", "not json at all"] { let (bridge, mut rx) = bare_bridge(); - let action = >::on_tool_call( - &bridge, - "ListSkills", - None, - "call-1", - args, - ) - .await; + let action = bridge.handle_tool_call("ListSkills", None, "call-1", args); - assert!(matches!(action, ToolCallHookAction::Continue)); + assert!(matches!(action, ToolCallAction::Run)); let Ok(StreamPart::ToolCall(tool_call)) = rx.try_recv().unwrap() else { panic!("expected a tool call for args {args:?}"); }; @@ -159,21 +142,6 @@ async fn on_tool_call_coerces_non_object_args_to_empty_object() { } } -/// An [`InvalidToolCallContext`] for a model-emitted call to `tool_name`. -fn invalid_call(tool_name: &str) -> InvalidToolCallContext { - InvalidToolCallContext { - tool_name: tool_name.to_string(), - tool_call_id: Some("call-1".to_string()), - internal_call_id: Some("internal-1".to_string()), - args: Some("{}".to_string()), - available_tools: vec!["SearchTools".to_string()], - allowed_tools: vec!["SearchTools".to_string()], - tool_choice: None, - chat_history: vec![], - is_streaming: true, - } -} - #[tokio::test] async fn invalid_call_to_searchable_tool_loads_it_and_retries() { let (register, registered) = recording_register(); @@ -187,15 +155,13 @@ async fn invalid_call_to_searchable_tool_loads_it_and_retries() { CancellationToken::new(), ); - let action = >::on_invalid_tool_call( - &bridge, - &invalid_call("mcp__linear__create_issue"), - ) - .await; + let action = bridge + .handle_invalid_tool_call("mcp__linear__create_issue") + .await; // The unloaded-but-searchable tool was registered and the turn retries. assert_eq!(&*registered.lock().unwrap(), &["mcp__linear__create_issue"]); - let InvalidToolCallHookAction::Retry { feedback } = action else { + let Some(InvalidToolCallAction::Retry { feedback }) = action else { panic!("expected retry, got {action:?}"); }; assert!(feedback.contains("mcp__linear__create_issue")); @@ -214,16 +180,14 @@ async fn invalid_call_to_unknown_tool_retries_with_feedback_without_loading() { CancellationToken::new(), ); - let action = >::on_invalid_tool_call( - &bridge, - &invalid_call("mcp__nope__hallucinated"), - ) - .await; + let action = bridge + .handle_invalid_tool_call("mcp__nope__hallucinated") + .await; // Nothing exists to load; the model gets corrective feedback instead of // the stream failing. assert!(registered.lock().unwrap().is_empty()); - let InvalidToolCallHookAction::Retry { feedback } = action else { + let Some(InvalidToolCallAction::Retry { feedback }) = action else { panic!("expected retry, got {action:?}"); }; assert!(feedback.contains("mcp__nope__hallucinated")); diff --git a/crates/agent/src/test/test_rig_invariants.rs b/crates/agent/src/test/test_rig_invariants.rs new file mode 100644 index 00000000000..645a2636183 --- /dev/null +++ b/crates/agent/src/test/test_rig_invariants.rs @@ -0,0 +1,75 @@ +//! Canary tests pinning rig invariants this crate depends on but does not +//! implement itself. If a rig version bump drops one of these, the failure +//! shows up here instead of as a production 400. + +use rig_core::OneOrMany; +use rig_core::message::{AssistantContent, Message, ToolCall, ToolFunction}; +use rig_core::providers::anthropic::completion::{Content, Message as AnthropicMessage}; + +/// Anthropic's Messages API requires `tool_use.input` to be a JSON object. +/// +/// rig's invalid-tool-call retry path replays the rejected call into history +/// with `arguments: null` when the model streamed no input (e.g. a zero-arg +/// tool like `get_me` called before being loaded), and older persisted +/// history can carry stringified arguments. Both must be coerced to an object +/// at the send boundary — rig ≥ 0.41 does this in its Anthropic serializer +/// (`coerce_tool_input`). This exact gap took down prod chat streams with +/// `messages.N.content.M.tool_use.input: Input should be an object` +/// (request `req_011CdzpQPd3VcpdDMBffecEE`), so pin it against future bumps. +#[test] +fn anthropic_wire_coerces_non_object_tool_use_input_to_object() { + for arguments in [ + serde_json::Value::Null, + serde_json::json!(""), + serde_json::json!("not json"), + serde_json::json!([1, 2, 3]), + serde_json::json!(42), + ] { + let message = Message::Assistant { + id: None, + content: OneOrMany::one(AssistantContent::ToolCall(ToolCall::new( + "toolu_test".to_string(), + ToolFunction { + name: "get_me".to_string(), + arguments: arguments.clone(), + }, + ))), + }; + + let wire: AnthropicMessage = message + .try_into() + .expect("assistant tool call must convert"); + let Content::ToolUse { input, .. } = wire.content.first() else { + panic!("expected a tool_use content block for arguments {arguments:?}"); + }; + assert!( + input.is_object(), + "tool_use.input must serialize as an object for arguments {arguments:?}, got {input:?}" + ); + } +} + +/// A JSON-encoded object string must survive as the decoded object, not be +/// flattened to `{}` — replayed history from other providers stores arguments +/// this way. +#[test] +fn anthropic_wire_parses_stringified_object_tool_use_input() { + let message = Message::Assistant { + id: None, + content: OneOrMany::one(AssistantContent::ToolCall(ToolCall::new( + "toolu_test".to_string(), + ToolFunction { + name: "echo".to_string(), + arguments: serde_json::json!("{\"value\":\"ok\"}"), + }, + ))), + }; + + let wire: AnthropicMessage = message + .try_into() + .expect("assistant tool call must convert"); + let Content::ToolUse { input, .. } = wire.content.first() else { + panic!("expected a tool_use content block"); + }; + assert_eq!(input, serde_json::json!({"value": "ok"})); +} diff --git a/crates/agent/src/tool_adapter.rs b/crates/agent/src/tool_adapter.rs index 266ba32d01c..e8936034fd3 100644 --- a/crates/agent/src/tool_adapter.rs +++ b/crates/agent/src/tool_adapter.rs @@ -1,9 +1,7 @@ -/// Adapts `ai_toolset` tool types into RIG [`ToolDyn`] objects. +/// Adapts `ai_toolset` tool types into RIG [`DynamicTool`] objects. use ai_toolset::tool_object::ToolSetCallable; use ai_toolset::{AsyncToolCollection, RequestContext, RequestSchema, ToolSet as AiToolSet}; -use rig_core::completion::ToolDefinition; -use rig_core::tool::{ToolDyn, ToolError}; -use rig_core::wasm_compat::WasmBoxedFuture; +use rig_agent::tool::{DynamicTool, ToolExecutionError, ToolOutput}; use std::sync::{Arc, RwLock}; /// Ensure every object schema carries an explicit `properties` map. @@ -47,7 +45,7 @@ pub fn normalize_request_schema(schema: &mut serde_json::Value) { } } -type Deserializer = Box< +type Deserializer = Arc< dyn Fn( &serde_json::Value, ) -> Result + Send + Sync>, serde_json::Error> @@ -55,35 +53,28 @@ type Deserializer = Box< + Sync, >; -/// Wraps a single tool from an [`AsyncToolCollection`] as a RIG [`ToolDyn`]. +/// Builds RIG [`DynamicTool`]s from the tools of an [`AsyncToolCollection`]. /// -/// The adapter captures the shared service context and a mutable request -/// context (user ID) so that `ai_toolset` tools can be called through -/// RIG's agentic loop without modification. -pub struct ToolsetToolAdapter { - name: String, - description: String, - input_schema: serde_json::Value, - deserializer: Deserializer, - context: Arc, - request_context: Arc>, -} - -impl ToolsetToolAdapter -where - Context: Clone + Send + Sync + 'static, -{ - /// Consume an [`AsyncToolCollection`] and produce one [`ToolsetToolAdapter`] - /// per registered tool. +/// The produced tools capture the shared service context and a mutable request +/// context (user ID) so that `ai_toolset` tools can be called through RIG's +/// agentic loop without modification. +pub struct ToolsetToolAdapter; + +impl ToolsetToolAdapter { + /// Consume an [`AsyncToolCollection`] and produce one [`DynamicTool`] per + /// registered tool. /// /// `context` is the shared service context (e.g. `ToolServiceContext`). /// `request_context` is shared across all adapters and should be set before /// each session. - pub fn from_collection( + pub fn from_collection( collection: AsyncToolCollection, context: Arc, request_context: Arc>, - ) -> Vec { + ) -> Vec + where + Context: Clone + Send + Sync + 'static, + { collection .tools .into_iter() @@ -91,183 +82,149 @@ where let description = tool_object.description.clone(); let mut input_schema = serde_json::Value::Object(tool_object.input_schema.clone()); normalize_request_schema(&mut input_schema); - let deserializer = Box::new( + let deserializer: Deserializer = Arc::new( move |json: &serde_json::Value| -> Result< Box + Send + Sync>, serde_json::Error, > { tool_object.try_deserialize(json) }, ); + let context = context.clone(); + let request_context = request_context.clone(); - ToolsetToolAdapter { + DynamicTool::new( name, description, input_schema, - deserializer, - context: context.clone(), - request_context: request_context.clone(), - } + move |_tool_ctx, args: serde_json::Value| { + let deserializer = deserializer.clone(); + let context = context.clone(); + let request_context = request_context.clone(); + Box::pin(async move { + let callable = + (deserializer)(&args).map_err(invalid_args)?; + let ctx = (*context).clone(); + let req_ctx = request_context + .read() + .expect("request_context lock poisoned") + .clone(); + match callable.call(ctx, req_ctx).await { + Ok(value) => Ok(ToolOutput::json(value)), + Err(e) => { + tracing::error!(error = ?e.internal_error, "toolset tool error"); + Err(ToolExecutionError::other(e.description)) + } + } + }) + }, + ) }) .collect() } } -// Safety: inner fields are all Send + Sync. -unsafe impl Send for ToolsetToolAdapter {} -unsafe impl Sync for ToolsetToolAdapter {} - -impl ToolDyn for ToolsetToolAdapter -where - Context: Clone + Send + Sync + 'static, -{ - fn name(&self) -> String { - self.name.clone() - } - - fn definition<'a>(&'a self, _prompt: String) -> WasmBoxedFuture<'a, ToolDefinition> { - let def = ToolDefinition { - name: self.name.clone(), - description: self.description.clone(), - parameters: self.input_schema.clone(), - }; - Box::pin(async move { def }) - } - - fn call<'a>(&'a self, args: String) -> WasmBoxedFuture<'a, Result> { - Box::pin(async move { - let json: serde_json::Value = - serde_json::from_str(&args).map_err(ToolError::JsonError)?; - let callable = (self.deserializer)(&json).map_err(ToolError::JsonError)?; - let ctx = (*self.context).clone(); - let req_ctx = self - .request_context - .read() - .expect("request_context lock poisoned") - .clone(); - match callable.call(ctx, req_ctx).await { - Ok(value) => serde_json::to_string(&value).map_err(ToolError::JsonError), - Err(e) => { - tracing::error!(error = ?e.internal_error, "toolset tool error"); - Err(ToolError::ToolCallError(e.description.into())) - } - } - }) - } -} - -/// Wraps a single tool from a [`dyn AiToolSet`] as a RIG [`ToolDyn`]. +/// Builds RIG [`DynamicTool`]s that delegate through a shared +/// [`dyn AiToolSet`](AiToolSet). /// /// Unlike [`ToolsetToolAdapter`] which takes ownership of tools from an -/// `AsyncToolCollection`, this adapter delegates every call through the -/// shared [`AiToolSet`] trait object. This supports `CombinedToolSet` -/// (static tools + MCP tools) without decomposing it. -pub struct DynToolSetAdapter { - name: String, - schema: serde_json::Value, - toolset: Arc + Send + Sync>, - context: Arc, - request_context: Arc>, -} +/// `AsyncToolCollection`, these tools dispatch every call through the shared +/// [`AiToolSet`] trait object. This supports `CombinedToolSet` (static tools + +/// MCP tools) without decomposing it. +pub struct DynToolSetAdapter; -impl DynToolSetAdapter -where - Context: Clone + Send + Sync + 'static, -{ - /// Create one [`DynToolSetAdapter`] per tool in `toolset`. +impl DynToolSetAdapter { + /// Create one [`DynamicTool`] per tool in `toolset`. /// /// Tool names and schemas are read from /// [`AiToolSet::request_schemas`]. Calls are dispatched through the /// shared `toolset`. - pub fn from_toolset( + pub fn from_toolset( toolset: Arc + Send + Sync>, context: Arc, request_context: Arc>, - ) -> Vec { + ) -> Vec + where + Context: Clone + Send + Sync + 'static, + { let schemas = toolset.request_schemas().unwrap_or_default(); schemas .into_iter() .map(|RequestSchema { name, schema }| { - let mut schema_json = serde_json::to_value(&schema) + let schema_json = serde_json::to_value(&schema) .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); - normalize_request_schema(&mut schema_json); - DynToolSetAdapter { + Self::build( name, - schema: schema_json, - toolset: toolset.clone(), - context: context.clone(), - request_context: request_context.clone(), - } + schema_json, + toolset.clone(), + context.clone(), + request_context.clone(), + ) }) .collect() } - /// Build a single adapter for a tool loaded on demand via tool search. + /// Build a single tool for one loaded on demand via tool search. /// /// Used to register a `SearchTools`-discovered tool with the live tool /// server mid-session so it becomes advertised and callable on the next /// turn. Calls dispatch through the shared `toolset` by name, exactly like /// [`Self::from_toolset`]. - pub fn loaded( + pub fn loaded( name: String, schema: schemars::Schema, toolset: Arc + Send + Sync>, context: Arc, request_context: Arc>, - ) -> Self { - let mut schema_json = serde_json::to_value(&schema) + ) -> DynamicTool + where + Context: Clone + Send + Sync + 'static, + { + let schema_json = serde_json::to_value(&schema) .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); - normalize_request_schema(&mut schema_json); - DynToolSetAdapter { - name, - schema: schema_json, - toolset, - context, - request_context, - } - } -} - -unsafe impl Send for DynToolSetAdapter {} -unsafe impl Sync for DynToolSetAdapter {} - -impl ToolDyn for DynToolSetAdapter -where - Context: Clone + Send + Sync + 'static, -{ - fn name(&self) -> String { - self.name.clone() + Self::build(name, schema_json, toolset, context, request_context) } - fn definition<'a>(&'a self, _prompt: String) -> WasmBoxedFuture<'a, ToolDefinition> { - let def = ToolDefinition { - name: self.name.clone(), - description: String::new(), - parameters: self.schema.clone(), - }; - Box::pin(async move { def }) + fn build( + name: String, + mut schema: serde_json::Value, + toolset: Arc + Send + Sync>, + context: Arc, + request_context: Arc>, + ) -> DynamicTool + where + Context: Clone + Send + Sync + 'static, + { + normalize_request_schema(&mut schema); + let tool_name = name.clone(); + DynamicTool::new( + name, + String::new(), + schema, + move |_tool_ctx, args: serde_json::Value| { + let toolset = toolset.clone(); + let context = context.clone(); + let request_context = request_context.clone(); + let tool_name = tool_name.clone(); + Box::pin(async move { + let ctx = (*context).clone(); + let req_ctx = request_context + .read() + .expect("request_context lock poisoned") + .clone(); + match toolset.try_tool_call(ctx, req_ctx, &tool_name, &args).await { + Ok(Ok(value)) => Ok(ToolOutput::json(value)), + Ok(Err(e)) => { + tracing::error!(error = ?e.internal_error, "tool error"); + Err(ToolExecutionError::other(e.description)) + } + Err(e) => Err(ToolExecutionError::other(e.to_string())), + } + }) + }, + ) } +} - fn call<'a>(&'a self, args: String) -> WasmBoxedFuture<'a, Result> { - Box::pin(async move { - let json: serde_json::Value = - serde_json::from_str(&args).map_err(ToolError::JsonError)?; - let ctx = (*self.context).clone(); - let req_ctx = self - .request_context - .read() - .expect("request_context lock poisoned") - .clone(); - match self - .toolset - .try_tool_call(ctx, req_ctx, &self.name, &json) - .await - { - Ok(Ok(value)) => serde_json::to_string(&value).map_err(ToolError::JsonError), - Ok(Err(e)) => { - tracing::error!(error = ?e.internal_error, "tool error"); - Err(ToolError::ToolCallError(e.description.into())) - } - Err(e) => Err(ToolError::ToolCallError(e.to_string().into())), - } - }) - } +/// Deserialization failures are invalid model-supplied arguments. +fn invalid_args(e: serde_json::Error) -> ToolExecutionError { + ToolExecutionError::invalid_args(e.to_string()) } diff --git a/crates/workspace-hack/Cargo.toml b/crates/workspace-hack/Cargo.toml index e0106708154..fb5b677721b 100644 --- a/crates/workspace-hack/Cargo.toml +++ b/crates/workspace-hack/Cargo.toml @@ -95,7 +95,8 @@ redis = { version = "1", features = ["cluster-async", "tokio-rustls-comp"] } regex-automata = { version = "0.4", default-features = false, features = ["dfa-build", "dfa-onepass", "hybrid", "meta", "nfa-backtrack", "perf-inline", "perf-literal", "std", "unicode"] } regex-syntax = { version = "0.8" } reqwest = { version = "0.13", features = ["form", "gzip", "hickory-dns", "json", "multipart", "query", "stream"] } -rig-core = { git = "https://github.com/macro-inc/rig", branch = "feat/responses-api-non-strict-tools", features = ["test-utils"] } +rig-agent = { version = "0.41", features = ["test-utils"] } +rig-core = { version = "0.41", features = ["test-utils"] } rmcp = { version = "1", features = ["auth", "client", "transport-streamable-http-client-reqwest", "transport-streamable-http-server"] } rsa = { version = "0.9", features = ["getrandom", "sha1"] } rustc-hash = { version = "2" } @@ -225,7 +226,8 @@ redis = { version = "1", features = ["cluster-async", "tokio-rustls-comp"] } regex-automata = { version = "0.4", default-features = false, features = ["dfa-build", "dfa-onepass", "hybrid", "meta", "nfa-backtrack", "perf-inline", "perf-literal", "std", "unicode"] } regex-syntax = { version = "0.8" } reqwest = { version = "0.13", features = ["form", "gzip", "hickory-dns", "json", "multipart", "query", "stream"] } -rig-core = { git = "https://github.com/macro-inc/rig", branch = "feat/responses-api-non-strict-tools", features = ["test-utils"] } +rig-agent = { version = "0.41", features = ["test-utils"] } +rig-core = { version = "0.41", features = ["test-utils"] } rmcp = { version = "1", features = ["auth", "client", "transport-streamable-http-client-reqwest", "transport-streamable-http-server"] } rsa = { version = "0.9", features = ["getrandom", "sha1"] } rustc-hash = { version = "2" }