1use std::{collections::HashMap, sync::Arc};
2
3use schemars::{JsonSchema, Schema, schema_for};
4
5use rig_core::{
6 memory::ConversationMemory,
7 message::ToolChoice,
8 vector_store::{VectorSearchRequest, VectorStoreIndexDyn},
9};
10
11use crate::{
12 agent::hook::{AgentHook, CompletionCall, CompletionCallAction, HookContext, RequestPatch},
13 completion::{CompletionModel, Document},
14 tool::{
15 DynamicTool, PortableDynamicTool, Tool, ToolSet,
16 server::{ToolServer, ToolServerHandle},
17 },
18};
19
20use super::{Agent, ModelHandle, OutputMode, completion::AgentConfig};
21
22struct DynamicContext<I> {
23 samples: usize,
24 index: I,
25}
26
27impl<I> AgentHook for DynamicContext<I>
28where
29 I: VectorStoreIndexDyn,
30{
31 async fn on_completion_call(
32 &self,
33 _ctx: &HookContext,
34 event: CompletionCall<'_>,
35 ) -> CompletionCallAction {
36 let query = event.prompt.rag_text().or_else(|| {
37 event
38 .history
39 .iter()
40 .rev()
41 .find_map(|message| message.rag_text())
42 });
43 let Some(query) = query else {
44 return CompletionCallAction::continue_run();
45 };
46
47 let request = VectorSearchRequest::builder()
48 .query(query)
49 .samples(self.samples as u64)
50 .build();
51 match self.index.top_n(request).await {
52 Ok(results) => CompletionCallAction::patch(RequestPatch::new().extra_context(
53 results.into_iter().map(|(_, id, value)| Document {
54 id,
55 text:
56 serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()),
57 additional_props: Default::default(),
58 }),
59 )),
60 Err(error) => {
61 CompletionCallAction::stop(format!("failed to retrieve dynamic context: {error}"))
62 }
63 }
64 }
65}
66
67#[derive(Default)]
76pub struct NoToolConfig;
77
78pub struct WithToolServerHandle {
83 handle: ToolServerHandle,
84}
85
86pub struct WithBuilderTools(ToolServer);
93
94pub struct AgentBuilder<ToolState = NoToolConfig> {
121 config: AgentConfig,
123 tool_state: ToolState,
125}
126
127impl<ToolState> AgentBuilder<ToolState> {
128 pub fn name(mut self, name: &str) -> Self {
130 self.config.name = Some(name.into());
131 self
132 }
133
134 pub fn description(mut self, description: &str) -> Self {
136 self.config.description = Some(description.into());
137 self
138 }
139
140 pub fn preamble(mut self, preamble: &str) -> Self {
142 self.config.preamble = Some(preamble.into());
143 self
144 }
145
146 pub fn without_preamble(mut self) -> Self {
148 self.config.preamble = None;
149 self
150 }
151
152 pub fn append_preamble(mut self, doc: &str) -> Self {
154 self.config.preamble = Some(format!(
155 "{}\n{}",
156 self.config.preamble.unwrap_or_default(),
157 doc
158 ));
159 self
160 }
161
162 pub fn context(mut self, doc: &str) -> Self {
164 self.config.static_context.push(Document {
165 id: format!("static_doc_{}", self.config.static_context.len()),
166 text: doc.into(),
167 additional_props: HashMap::new(),
168 });
169 self
170 }
171
172 pub fn dynamic_context<I>(self, samples: usize, index: I) -> Self
182 where
183 I: VectorStoreIndexDyn + 'static,
184 {
185 self.add_hook(DynamicContext { samples, index })
186 }
187
188 pub fn tool_choice(mut self, tool_choice: ToolChoice) -> Self {
190 self.config.tool_choice = Some(tool_choice);
191 self
192 }
193
194 pub fn default_max_turns(mut self, default_max_turns: usize) -> Self {
197 self.config.max_turns = default_max_turns;
198 self
199 }
200
201 pub fn temperature(mut self, temperature: f64) -> Self {
203 self.config.temperature = Some(temperature);
204 self
205 }
206
207 pub fn max_tokens(mut self, max_tokens: u64) -> Self {
209 self.config.max_tokens = Some(max_tokens);
210 self
211 }
212
213 pub fn additional_params(mut self, params: serde_json::Value) -> Self {
215 self.config.additional_params = Some(params);
216 self
217 }
218
219 pub fn record_content_telemetry(mut self, enabled: bool) -> Self {
229 self.config.record_telemetry_content = enabled;
230 self
231 }
232
233 pub fn output_schema<T>(mut self) -> Self
236 where
237 T: JsonSchema,
238 {
239 self.config.output_schema = Some(schema_for!(T));
240 self
241 }
242
243 pub fn output_schema_raw(mut self, schema: Schema) -> Self {
245 self.config.output_schema = Some(schema);
246 self
247 }
248
249 pub fn output_mode(mut self, mode: OutputMode) -> Self {
254 self.config.output_mode = mode;
255 self
256 }
257
258 pub fn memory<B>(mut self, memory: B) -> Self
266 where
267 B: ConversationMemory + 'static,
268 {
269 self.config.memory = Some(Arc::new(memory));
270 self
271 }
272
273 pub fn conversation(mut self, id: impl Into<String>) -> Self {
278 self.config.conversation_id = Some(id.into());
279 self
280 }
281
282 pub fn add_hook<H>(mut self, hook: H) -> Self
291 where
292 H: AgentHook + 'static,
293 {
294 self.config.hooks.push(hook);
295 self
296 }
297
298 fn with_tool_state<S>(self, tool_state: S) -> AgentBuilder<S> {
300 AgentBuilder {
301 config: self.config,
302 tool_state,
303 }
304 }
305
306 fn build_agent(self, handle: impl FnOnce(ToolState) -> ToolServerHandle) -> Agent {
309 Agent {
310 tool_server_handle: handle(self.tool_state),
311 config: self.config,
312 }
313 }
314}
315
316impl AgentBuilder<NoToolConfig> {
317 pub fn new<M>(model: M) -> Self
322 where
323 M: CompletionModel + 'static,
324 {
325 Self::from_model_handle(ModelHandle::new(model))
326 }
327
328 pub fn from_model_handle(model: ModelHandle) -> Self {
330 Self {
331 config: AgentConfig::new(model),
332 tool_state: NoToolConfig,
333 }
334 }
335}
336
337impl AgentBuilder<NoToolConfig> {
338 pub fn tool_server_handle(
344 self,
345 handle: ToolServerHandle,
346 ) -> AgentBuilder<WithToolServerHandle> {
347 self.with_tool_state(WithToolServerHandle { handle })
348 }
349
350 fn into_tool_builder(self) -> AgentBuilder<WithBuilderTools> {
354 self.with_tool_state(WithBuilderTools(ToolServer::new()))
355 }
356
357 pub fn tool<T>(self, tool: T) -> AgentBuilder<WithBuilderTools>
362 where
363 T: Tool + 'static,
364 {
365 self.into_tool_builder().tool(tool)
366 }
367
368 #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
375 #[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
376 pub fn rmcp_tool(
377 self,
378 tool: rmcp::model::Tool,
379 client: rmcp::service::ServerSink,
380 ) -> AgentBuilder<WithBuilderTools> {
381 self.rmcp_tool_with_timeout(tool, client, crate::tool::rmcp::DEFAULT_MCP_TOOL_TIMEOUT)
382 }
383
384 #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
391 #[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
392 pub fn rmcp_tool_with_timeout(
393 self,
394 tool: rmcp::model::Tool,
395 client: rmcp::service::ServerSink,
396 timeout: impl Into<Option<std::time::Duration>>,
397 ) -> AgentBuilder<WithBuilderTools> {
398 self.rmcp_tools_with_timeout(vec![tool], client, timeout)
399 }
400
401 pub fn build(self) -> Agent {
405 self.build_agent(|_| ToolServer::new().run())
406 }
407}
408
409macro_rules! forward_into_tool_builder {
415 ($( $(#[$attr:meta])* $name:ident ( $($arg:ident : $ty:ty),* $(,)? ) );* $(;)?) => {
416 impl AgentBuilder<NoToolConfig> {
417 $(
418 $(#[$attr])*
419 pub fn $name(self, $($arg: $ty),*) -> AgentBuilder<WithBuilderTools> {
420 self.into_tool_builder().$name($($arg),*)
421 }
422 )*
423 }
424 };
425}
426
427forward_into_tool_builder! {
428 dynamic_tool(tool: DynamicTool);
430
431 portable_dynamic_tool(tool: PortableDynamicTool);
433
434 dynamic_tools(tools: Vec<DynamicTool>);
439
440 #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
447 #[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
448 rmcp_tools(tools: Vec<rmcp::model::Tool>, client: rmcp::service::ServerSink);
449
450 #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
458 #[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
459 rmcp_tools_with_timeout(
460 tools: Vec<rmcp::model::Tool>,
461 client: rmcp::service::ServerSink,
462 timeout: impl Into<Option<std::time::Duration>>
463 );
464
465 retrieved_tools(
469 sample: usize,
470 index: impl VectorStoreIndexDyn + Send + Sync + 'static,
471 toolset: ToolSet
472 );
473}
474
475impl AgentBuilder<WithToolServerHandle> {
476 pub fn build(self) -> Agent {
478 self.build_agent(|state| state.handle)
479 }
480}
481
482impl AgentBuilder<WithBuilderTools> {
483 fn map_server(self, register: impl FnOnce(ToolServer) -> ToolServer) -> Self {
487 let Self { config, tool_state } = self;
488 Self {
489 config,
490 tool_state: WithBuilderTools(register(tool_state.0)),
491 }
492 }
493
494 pub fn tool<T>(self, tool: T) -> Self
496 where
497 T: Tool + 'static,
498 {
499 self.map_server(|server| server.tool(tool))
500 }
501
502 pub fn dynamic_tool(self, tool: DynamicTool) -> Self {
504 self.map_server(|server| server.dynamic_tool(tool))
505 }
506
507 pub fn portable_dynamic_tool(self, tool: PortableDynamicTool) -> Self {
509 self.map_server(|server| server.portable_dynamic_tool(tool))
510 }
511
512 pub fn dynamic_tools(self, tools: Vec<DynamicTool>) -> Self {
514 self.map_server(|server| server.dynamic_tools(tools))
515 }
516
517 #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
522 #[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
523 pub fn rmcp_tools(
524 self,
525 tools: Vec<rmcp::model::Tool>,
526 client: rmcp::service::ServerSink,
527 ) -> Self {
528 self.map_server(|server| server.rmcp_tools(tools, client))
529 }
530
531 #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
538 #[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
539 pub fn rmcp_tools_with_timeout(
540 self,
541 tools: Vec<rmcp::model::Tool>,
542 client: rmcp::service::ServerSink,
543 timeout: impl Into<Option<std::time::Duration>>,
544 ) -> Self {
545 self.map_server(|server| server.rmcp_tools_with_timeout(tools, client, timeout))
546 }
547
548 pub fn retrieved_tools(
550 self,
551 sample: usize,
552 index: impl VectorStoreIndexDyn + Send + Sync + 'static,
553 toolset: ToolSet,
554 ) -> Self {
555 self.map_server(|server| server.retrieved_tools(sample, index, toolset))
556 }
557
558 pub fn build(self) -> Agent {
564 self.build_agent(|state| state.0.run())
565 }
566}
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use crate::test_utils::{MockAddTool, MockCompletionModel, MockSubtractTool, MockToolIndex};
571 use crate::tool::{ToolContext, ToolExecutionError};
572
573 #[derive(Clone)]
574 struct BuilderHook;
575
576 impl AgentHook for BuilderHook {}
577
578 #[test]
579 fn hook_can_be_set_after_tool_configuration() {
580 let _agent = AgentBuilder::new(MockCompletionModel::text("ok"))
581 .tool(MockAddTool)
582 .add_hook(BuilderHook)
583 .build();
584 }
585
586 struct NamedTool;
587
588 impl NamedTool {
589 fn new() -> Self {
590 Self
591 }
592 }
593
594 impl Tool for NamedTool {
595 const NAME: &'static str = "registered_named";
596 type Error = rig::tool::ToolExecutionError;
597 type Args = serde_json::Value;
598 type Output = String;
599
600 fn description(&self) -> String {
601 "uses its canonical name".to_string()
602 }
603
604 fn parameters(&self) -> serde_json::Value {
605 serde_json::json!({"type": "object", "properties": {}})
606 }
607
608 async fn call(
609 &self,
610 _context: &mut ToolContext,
611 _args: Self::Args,
612 ) -> Result<Self::Output, ToolExecutionError> {
613 Ok("ok".to_string())
614 }
615 }
616
617 #[tokio::test]
618 async fn typed_tool_builder_paths_advertise_canonical_name() {
619 for agent in [
620 AgentBuilder::new(MockCompletionModel::text("ok"))
621 .tool(NamedTool::new())
622 .build(),
623 AgentBuilder::new(MockCompletionModel::text("ok"))
624 .tool(MockAddTool)
625 .tool(NamedTool::new())
626 .build(),
627 ] {
628 let definitions = agent.tool_server_handle.get_tool_defs(None).await.unwrap();
629 assert!(
630 definitions
631 .iter()
632 .any(|definition| definition.name == NamedTool::NAME),
633 "the provider definitions dropped the canonical tool name"
634 );
635
636 let mut context = ToolContext::new();
637 let result = agent
638 .tool_server_handle
639 .execute(NamedTool::NAME, "{}", &mut context)
640 .await;
641 assert!(result.is_success());
642 assert_eq!(result.output().as_text(), Some("ok"));
643 }
644 }
645
646 #[tokio::test]
647 async fn retrieved_tools_are_exposed_only_for_prompted_retrieval() {
648 let retrieval_only = AgentBuilder::new(MockCompletionModel::text("ok"))
649 .retrieved_tools(
650 1,
651 MockToolIndex::new(["add"]),
652 ToolSet::from_tools(vec![MockAddTool]),
653 )
654 .build();
655 assert!(
656 retrieval_only
657 .tool_server_handle
658 .get_tool_defs(None)
659 .await
660 .unwrap()
661 .is_empty()
662 );
663
664 let agent = AgentBuilder::new(MockCompletionModel::text("ok"))
665 .tool(MockSubtractTool)
666 .retrieved_tools(
667 1,
668 MockToolIndex::new(["add"]),
669 ToolSet::from_tools(vec![MockAddTool]),
670 )
671 .build();
672
673 let always = agent.tool_server_handle.get_tool_defs(None).await.unwrap();
674 assert_eq!(
675 always
676 .iter()
677 .map(|definition| definition.name.as_str())
678 .collect::<Vec<_>>(),
679 vec!["subtract"]
680 );
681
682 let with_retrieval = agent
683 .tool_server_handle
684 .get_tool_defs(Some("add two numbers".to_string()))
685 .await
686 .unwrap();
687 assert_eq!(
688 with_retrieval
689 .iter()
690 .map(|definition| definition.name.as_str())
691 .collect::<Vec<_>>(),
692 vec!["add", "subtract"]
693 );
694 }
695
696 #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
701 #[tokio::test]
702 async fn builder_rmcp_tools_thread_timeout_into_registered_tools() {
703 use crate::tool::rmcp::{DEFAULT_MCP_TOOL_TIMEOUT, McpTool as RmcpTool};
704 use crate::tool::{ToolContext, ToolErrorKind};
705 use rmcp::model::{
706 CallToolRequestParams, CallToolResult, ClientInfo, ErrorData, Implementation,
707 ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
708 };
709 use rmcp::service::RequestContext;
710 use rmcp::{RoleServer, ServerHandler, ServiceExt};
711 use std::sync::Arc;
712 use std::time::Duration;
713
714 #[derive(Clone)]
715 struct HangingServer;
716 impl ServerHandler for HangingServer {
717 fn get_info(&self) -> ServerInfo {
718 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
719 .with_protocol_version(ProtocolVersion::LATEST)
720 .with_server_info(Implementation::new("builder-timeout-test", "0.1.0"))
721 }
722 async fn call_tool(
723 &self,
724 _request: CallToolRequestParams,
725 _context: RequestContext<RoleServer>,
726 ) -> Result<CallToolResult, ErrorData> {
727 std::future::pending::<Result<CallToolResult, ErrorData>>().await
728 }
729 }
730
731 fn tool(name: &str) -> Tool {
732 Tool::new(
733 name.to_string(),
734 String::new(),
735 Arc::new(serde_json::Map::new()),
736 )
737 }
738
739 let (c2s, sfc) = tokio::io::duplex(8192);
740 let (s2c, cfs) = tokio::io::duplex(8192);
741 let server_task = tokio::spawn(async move {
742 let running = HangingServer.serve((sfc, s2c)).await.expect("server start");
743 running.waiting().await.expect("server error");
744 });
745 let client = ClientInfo::default()
746 .serve((cfs, c2s))
747 .await
748 .expect("client connect");
749 let peer = client.peer().clone();
750
751 let built = RmcpTool::from_mcp_server(tool("a"), peer.clone());
754 assert_eq!(built.timeout(), Some(DEFAULT_MCP_TOOL_TIMEOUT));
755 assert_eq!(built.with_timeout(None).timeout(), None);
756
757 let agent = AgentBuilder::new(MockCompletionModel::text("ok"))
759 .rmcp_tools(vec![tool("a"), tool("b")], peer.clone())
760 .build();
761 let definitions = agent.tool_server_handle.get_tool_defs(None).await.unwrap();
762 assert_eq!(
763 definitions
764 .iter()
765 .map(|definition| definition.name.as_str())
766 .collect::<Vec<_>>(),
767 vec!["a", "b"]
768 );
769
770 let agent = AgentBuilder::new(MockCompletionModel::text("ok"))
772 .rmcp_tools_with_timeout(vec![tool("hang_forever")], peer, Duration::from_millis(200))
773 .build();
774 let timed = tokio::time::timeout(Duration::from_secs(5), async {
775 let mut context = ToolContext::new();
776 agent
777 .tool_server_handle
778 .execute("hang_forever", "{}", &mut context)
779 .await
780 })
781 .await;
782 let result = timed.expect("registered tool hung past the safety timeout");
783 assert!(result.is_error_kind(ToolErrorKind::Timeout));
784 assert!(result.output().render().contains("timed out"));
785
786 drop(client);
787 server_task.abort();
788 }
789}