use std::{collections::HashMap, sync::Arc};
use schemars::{JsonSchema, Schema, schema_for};
use rig_core::{
memory::ConversationMemory,
message::ToolChoice,
vector_store::{VectorSearchRequest, VectorStoreIndexDyn},
};
use crate::{
agent::hook::{AgentHook, CompletionCall, CompletionCallAction, HookContext, RequestPatch},
completion::{CompletionModel, Document},
tool::{
DynamicTool, PortableDynamicTool, Tool, ToolSet,
server::{ToolServer, ToolServerHandle},
},
};
use super::{Agent, ModelHandle, OutputMode, completion::AgentConfig};
struct DynamicContext<I> {
samples: usize,
index: I,
}
impl<I> AgentHook for DynamicContext<I>
where
I: VectorStoreIndexDyn,
{
async fn on_completion_call(
&self,
_ctx: &HookContext,
event: CompletionCall<'_>,
) -> CompletionCallAction {
let query = event.prompt.rag_text().or_else(|| {
event
.history
.iter()
.rev()
.find_map(|message| message.rag_text())
});
let Some(query) = query else {
return CompletionCallAction::continue_run();
};
let request = VectorSearchRequest::builder()
.query(query)
.samples(self.samples as u64)
.build();
match self.index.top_n(request).await {
Ok(results) => CompletionCallAction::patch(RequestPatch::new().extra_context(
results.into_iter().map(|(_, id, value)| Document {
id,
text:
serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()),
additional_props: Default::default(),
}),
)),
Err(error) => {
CompletionCallAction::stop(format!("failed to retrieve dynamic context: {error}"))
}
}
}
}
#[derive(Default)]
pub struct NoToolConfig;
pub struct WithToolServerHandle {
handle: ToolServerHandle,
}
pub struct WithBuilderTools(ToolServer);
pub struct AgentBuilder<ToolState = NoToolConfig> {
config: AgentConfig,
tool_state: ToolState,
}
impl<ToolState> AgentBuilder<ToolState> {
pub fn name(mut self, name: &str) -> Self {
self.config.name = Some(name.into());
self
}
pub fn description(mut self, description: &str) -> Self {
self.config.description = Some(description.into());
self
}
pub fn preamble(mut self, preamble: &str) -> Self {
self.config.preamble = Some(preamble.into());
self
}
pub fn without_preamble(mut self) -> Self {
self.config.preamble = None;
self
}
pub fn append_preamble(mut self, doc: &str) -> Self {
self.config.preamble = Some(format!(
"{}\n{}",
self.config.preamble.unwrap_or_default(),
doc
));
self
}
pub fn context(mut self, doc: &str) -> Self {
self.config.static_context.push(Document {
id: format!("static_doc_{}", self.config.static_context.len()),
text: doc.into(),
additional_props: HashMap::new(),
});
self
}
pub fn dynamic_context<I>(self, samples: usize, index: I) -> Self
where
I: VectorStoreIndexDyn + 'static,
{
self.add_hook(DynamicContext { samples, index })
}
pub fn tool_choice(mut self, tool_choice: ToolChoice) -> Self {
self.config.tool_choice = Some(tool_choice);
self
}
pub fn default_max_turns(mut self, default_max_turns: usize) -> Self {
self.config.max_turns = default_max_turns;
self
}
pub fn temperature(mut self, temperature: f64) -> Self {
self.config.temperature = Some(temperature);
self
}
pub fn max_tokens(mut self, max_tokens: u64) -> Self {
self.config.max_tokens = Some(max_tokens);
self
}
pub fn additional_params(mut self, params: serde_json::Value) -> Self {
self.config.additional_params = Some(params);
self
}
pub fn record_content_telemetry(mut self, enabled: bool) -> Self {
self.config.record_telemetry_content = enabled;
self
}
pub fn output_schema<T>(mut self) -> Self
where
T: JsonSchema,
{
self.config.output_schema = Some(schema_for!(T));
self
}
pub fn output_schema_raw(mut self, schema: Schema) -> Self {
self.config.output_schema = Some(schema);
self
}
pub fn output_mode(mut self, mode: OutputMode) -> Self {
self.config.output_mode = mode;
self
}
pub fn memory<B>(mut self, memory: B) -> Self
where
B: ConversationMemory + 'static,
{
self.config.memory = Some(Arc::new(memory));
self
}
pub fn conversation(mut self, id: impl Into<String>) -> Self {
self.config.conversation_id = Some(id.into());
self
}
pub fn add_hook<H>(mut self, hook: H) -> Self
where
H: AgentHook + 'static,
{
self.config.hooks.push(hook);
self
}
fn with_tool_state<S>(self, tool_state: S) -> AgentBuilder<S> {
AgentBuilder {
config: self.config,
tool_state,
}
}
fn build_agent(self, handle: impl FnOnce(ToolState) -> ToolServerHandle) -> Agent {
Agent {
tool_server_handle: handle(self.tool_state),
config: self.config,
}
}
}
impl AgentBuilder<NoToolConfig> {
pub fn new<M>(model: M) -> Self
where
M: CompletionModel + 'static,
{
Self::from_model_handle(ModelHandle::new(model))
}
pub fn from_model_handle(model: ModelHandle) -> Self {
Self {
config: AgentConfig::new(model),
tool_state: NoToolConfig,
}
}
}
impl AgentBuilder<NoToolConfig> {
pub fn tool_server_handle(
self,
handle: ToolServerHandle,
) -> AgentBuilder<WithToolServerHandle> {
self.with_tool_state(WithToolServerHandle { handle })
}
fn into_tool_builder(self) -> AgentBuilder<WithBuilderTools> {
self.with_tool_state(WithBuilderTools(ToolServer::new()))
}
pub fn tool<T>(self, tool: T) -> AgentBuilder<WithBuilderTools>
where
T: Tool + 'static,
{
self.into_tool_builder().tool(tool)
}
#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
#[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
pub fn rmcp_tool(
self,
tool: rmcp::model::Tool,
client: rmcp::service::ServerSink,
) -> AgentBuilder<WithBuilderTools> {
self.rmcp_tool_with_timeout(tool, client, crate::tool::rmcp::DEFAULT_MCP_TOOL_TIMEOUT)
}
#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
#[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
pub fn rmcp_tool_with_timeout(
self,
tool: rmcp::model::Tool,
client: rmcp::service::ServerSink,
timeout: impl Into<Option<std::time::Duration>>,
) -> AgentBuilder<WithBuilderTools> {
self.rmcp_tools_with_timeout(vec![tool], client, timeout)
}
pub fn build(self) -> Agent {
self.build_agent(|_| ToolServer::new().run())
}
}
macro_rules! forward_into_tool_builder {
($( $(#[$attr:meta])* $name:ident ( $($arg:ident : $ty:ty),* $(,)? ) );* $(;)?) => {
impl AgentBuilder<NoToolConfig> {
$(
$(#[$attr])*
pub fn $name(self, $($arg: $ty),*) -> AgentBuilder<WithBuilderTools> {
self.into_tool_builder().$name($($arg),*)
}
)*
}
};
}
forward_into_tool_builder! {
dynamic_tool(tool: DynamicTool);
portable_dynamic_tool(tool: PortableDynamicTool);
dynamic_tools(tools: Vec<DynamicTool>);
#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
#[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
rmcp_tools(tools: Vec<rmcp::model::Tool>, client: rmcp::service::ServerSink);
#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
#[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
rmcp_tools_with_timeout(
tools: Vec<rmcp::model::Tool>,
client: rmcp::service::ServerSink,
timeout: impl Into<Option<std::time::Duration>>
);
retrieved_tools(
sample: usize,
index: impl VectorStoreIndexDyn + Send + Sync + 'static,
toolset: ToolSet
);
}
impl AgentBuilder<WithToolServerHandle> {
pub fn build(self) -> Agent {
self.build_agent(|state| state.handle)
}
}
impl AgentBuilder<WithBuilderTools> {
fn map_server(self, register: impl FnOnce(ToolServer) -> ToolServer) -> Self {
let Self { config, tool_state } = self;
Self {
config,
tool_state: WithBuilderTools(register(tool_state.0)),
}
}
pub fn tool<T>(self, tool: T) -> Self
where
T: Tool + 'static,
{
self.map_server(|server| server.tool(tool))
}
pub fn dynamic_tool(self, tool: DynamicTool) -> Self {
self.map_server(|server| server.dynamic_tool(tool))
}
pub fn portable_dynamic_tool(self, tool: PortableDynamicTool) -> Self {
self.map_server(|server| server.portable_dynamic_tool(tool))
}
pub fn dynamic_tools(self, tools: Vec<DynamicTool>) -> Self {
self.map_server(|server| server.dynamic_tools(tools))
}
#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
#[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
pub fn rmcp_tools(
self,
tools: Vec<rmcp::model::Tool>,
client: rmcp::service::ServerSink,
) -> Self {
self.map_server(|server| server.rmcp_tools(tools, client))
}
#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
#[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
pub fn rmcp_tools_with_timeout(
self,
tools: Vec<rmcp::model::Tool>,
client: rmcp::service::ServerSink,
timeout: impl Into<Option<std::time::Duration>>,
) -> Self {
self.map_server(|server| server.rmcp_tools_with_timeout(tools, client, timeout))
}
pub fn retrieved_tools(
self,
sample: usize,
index: impl VectorStoreIndexDyn + Send + Sync + 'static,
toolset: ToolSet,
) -> Self {
self.map_server(|server| server.retrieved_tools(sample, index, toolset))
}
pub fn build(self) -> Agent {
self.build_agent(|state| state.0.run())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{MockAddTool, MockCompletionModel, MockSubtractTool, MockToolIndex};
use crate::tool::{ToolContext, ToolExecutionError};
#[derive(Clone)]
struct BuilderHook;
impl AgentHook for BuilderHook {}
#[test]
fn hook_can_be_set_after_tool_configuration() {
let _agent = AgentBuilder::new(MockCompletionModel::text("ok"))
.tool(MockAddTool)
.add_hook(BuilderHook)
.build();
}
struct NamedTool;
impl NamedTool {
fn new() -> Self {
Self
}
}
impl Tool for NamedTool {
const NAME: &'static str = "registered_named";
type Error = rig::tool::ToolExecutionError;
type Args = serde_json::Value;
type Output = String;
fn description(&self) -> String {
"uses its canonical name".to_string()
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({"type": "object", "properties": {}})
}
async fn call(
&self,
_context: &mut ToolContext,
_args: Self::Args,
) -> Result<Self::Output, ToolExecutionError> {
Ok("ok".to_string())
}
}
#[tokio::test]
async fn typed_tool_builder_paths_advertise_canonical_name() {
for agent in [
AgentBuilder::new(MockCompletionModel::text("ok"))
.tool(NamedTool::new())
.build(),
AgentBuilder::new(MockCompletionModel::text("ok"))
.tool(MockAddTool)
.tool(NamedTool::new())
.build(),
] {
let definitions = agent.tool_server_handle.get_tool_defs(None).await.unwrap();
assert!(
definitions
.iter()
.any(|definition| definition.name == NamedTool::NAME),
"the provider definitions dropped the canonical tool name"
);
let mut context = ToolContext::new();
let result = agent
.tool_server_handle
.execute(NamedTool::NAME, "{}", &mut context)
.await;
assert!(result.is_success());
assert_eq!(result.output().as_text(), Some("ok"));
}
}
#[tokio::test]
async fn retrieved_tools_are_exposed_only_for_prompted_retrieval() {
let retrieval_only = AgentBuilder::new(MockCompletionModel::text("ok"))
.retrieved_tools(
1,
MockToolIndex::new(["add"]),
ToolSet::from_tools(vec![MockAddTool]),
)
.build();
assert!(
retrieval_only
.tool_server_handle
.get_tool_defs(None)
.await
.unwrap()
.is_empty()
);
let agent = AgentBuilder::new(MockCompletionModel::text("ok"))
.tool(MockSubtractTool)
.retrieved_tools(
1,
MockToolIndex::new(["add"]),
ToolSet::from_tools(vec![MockAddTool]),
)
.build();
let always = agent.tool_server_handle.get_tool_defs(None).await.unwrap();
assert_eq!(
always
.iter()
.map(|definition| definition.name.as_str())
.collect::<Vec<_>>(),
vec!["subtract"]
);
let with_retrieval = agent
.tool_server_handle
.get_tool_defs(Some("add two numbers".to_string()))
.await
.unwrap();
assert_eq!(
with_retrieval
.iter()
.map(|definition| definition.name.as_str())
.collect::<Vec<_>>(),
vec!["add", "subtract"]
);
}
#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
#[tokio::test]
async fn builder_rmcp_tools_thread_timeout_into_registered_tools() {
use crate::tool::rmcp::{DEFAULT_MCP_TOOL_TIMEOUT, McpTool as RmcpTool};
use crate::tool::{ToolContext, ToolErrorKind};
use rmcp::model::{
CallToolRequestParams, CallToolResult, ClientInfo, ErrorData, Implementation,
ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
};
use rmcp::service::RequestContext;
use rmcp::{RoleServer, ServerHandler, ServiceExt};
use std::sync::Arc;
use std::time::Duration;
#[derive(Clone)]
struct HangingServer;
impl ServerHandler for HangingServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_protocol_version(ProtocolVersion::LATEST)
.with_server_info(Implementation::new("builder-timeout-test", "0.1.0"))
}
async fn call_tool(
&self,
_request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResult, ErrorData> {
std::future::pending::<Result<CallToolResult, ErrorData>>().await
}
}
fn tool(name: &str) -> Tool {
Tool::new(
name.to_string(),
String::new(),
Arc::new(serde_json::Map::new()),
)
}
let (c2s, sfc) = tokio::io::duplex(8192);
let (s2c, cfs) = tokio::io::duplex(8192);
let server_task = tokio::spawn(async move {
let running = HangingServer.serve((sfc, s2c)).await.expect("server start");
running.waiting().await.expect("server error");
});
let client = ClientInfo::default()
.serve((cfs, c2s))
.await
.expect("client connect");
let peer = client.peer().clone();
let built = RmcpTool::from_mcp_server(tool("a"), peer.clone());
assert_eq!(built.timeout(), Some(DEFAULT_MCP_TOOL_TIMEOUT));
assert_eq!(built.with_timeout(None).timeout(), None);
let agent = AgentBuilder::new(MockCompletionModel::text("ok"))
.rmcp_tools(vec![tool("a"), tool("b")], peer.clone())
.build();
let definitions = agent.tool_server_handle.get_tool_defs(None).await.unwrap();
assert_eq!(
definitions
.iter()
.map(|definition| definition.name.as_str())
.collect::<Vec<_>>(),
vec!["a", "b"]
);
let agent = AgentBuilder::new(MockCompletionModel::text("ok"))
.rmcp_tools_with_timeout(vec![tool("hang_forever")], peer, Duration::from_millis(200))
.build();
let timed = tokio::time::timeout(Duration::from_secs(5), async {
let mut context = ToolContext::new();
agent
.tool_server_handle
.execute("hang_forever", "{}", &mut context)
.await
})
.await;
let result = timed.expect("registered tool hung past the safety timeout");
assert!(result.is_error_kind(ToolErrorKind::Timeout));
assert!(result.output().render().contains("timed out"));
drop(client);
server_task.abort();
}
}