nika_engine/runtime/rig_agent_loop/mod.rs
1//! Rig-based Agent Loop
2//!
3//! This module implements agentic execution using rig-core's AgentBuilder.
4//! It replaces the custom agent_loop.rs with rig's native multi-turn support.
5//!
6//! ## Key Benefits
7//! - Native tool calling via rig's ToolDyn trait
8//! - Simpler codebase (rig handles the loop)
9//! - Better provider abstraction (rig handles Claude/OpenAI/etc)
10//!
11//! ## Architecture
12//! ```text
13//! RigAgentLoop
14//! ├── Creates rig::Agent via AgentBuilder
15//! ├── Converts MCP tools to NikaMcpTool (implements ToolDyn)
16//! ├── Runs agent.chat() for multi-turn execution
17//! └── Emits events to EventLog for observability
18//! ```
19//!
20//! ## Module Organization
21//! - `types`: Status enums, result types, ToolChoice conversion
22//! - `chat`: Chat history management and multi-turn conversation
23//! - `streaming`: Streaming execution helpers for token tracking
24//! - `thinking`: Extended thinking, guardrails, confidence routing
25//! - `providers`: Provider-specific execution methods (run_*)
26
27mod chat;
28mod providers;
29mod streaming;
30#[cfg(test)]
31mod tests;
32mod thinking;
33pub mod types;
34
35// Re-export public types
36pub use types::{RigAgentLoopResult, RigAgentStatus};
37
38use std::path::PathBuf;
39use std::sync::Arc;
40
41use rig::message::Message;
42use rustc_hash::FxHashMap;
43
44use crate::ast::AgentParams;
45use crate::error::NikaError;
46use crate::event::EventLog;
47use crate::mcp::McpClient;
48use crate::provider::rig::{AgentMediaStaging, NikaMcpTool, NikaMcpToolDef};
49use crate::runtime::limit_tracker::LimitTracker;
50use crate::runtime::submit_tool::DynamicSubmitTool;
51use crate::runtime::SkillInjector;
52use crate::tools::{
53 EditTool, GlobTool, GrepTool, PermissionMode, ReadTool, ToolContext, WriteTool,
54};
55
56// ═══════════════════════════════════════════════════════════════════════════
57// RigAgentLoop
58// ═══════════════════════════════════════════════════════════════════════════
59
60/// Rig-based agentic execution loop
61///
62/// Uses rig-core's AgentBuilder for multi-turn execution with MCP tools.
63///
64/// ## Chat History
65///
66/// The agent loop now supports conversation history for multi-turn interactions:
67///
68/// ```rust,ignore
69/// let mut agent = RigAgentLoop::new(...)?;
70///
71/// // First turn
72/// let result = agent.run_claude().await?;
73///
74/// // Continue conversation with history
75/// agent.add_to_history("What's the capital of France?", &result.final_output.to_string());
76/// let result2 = agent.chat_continue("And what about Germany?").await?;
77/// ```
78pub struct RigAgentLoop {
79 /// Task identifier for event logging
80 task_id: String,
81 /// Agent parameters from workflow YAML
82 params: AgentParams,
83 /// Event log for observability
84 event_log: EventLog,
85 /// Connected MCP clients (used in run_claude for tool result callbacks)
86 #[allow(dead_code)] // Will be used when run_claude is fully implemented
87 mcp_clients: FxHashMap<String, Arc<McpClient>>,
88 /// Pre-built tools from MCP clients
89 tools: Vec<Arc<dyn rig::tool::ToolDyn>>,
90 /// Conversation history for multi-turn chat.
91 ///
92 /// NOTE: This Vec is cloned on each `chat()` call because rig-core's API
93 /// takes ownership. The clone is necessary to preserve history for future turns.
94 /// Pre-allocated with capacity based on `max_turns` to minimize reallocations.
95 history: Vec<Message>,
96 /// Monotonically incrementing turn counter.
97 /// Incremented in `add_to_history()` (one complete user+assistant exchange = one turn).
98 /// Replaces the ambiguous `(history.len() / 2 + 1)` formula which yields identical
99 /// values for even and odd history lengths due to integer division.
100 turn_count: u32,
101 /// Optional streaming channel for real-time token display
102 stream_tx: Option<tokio::sync::mpsc::Sender<crate::provider::rig::StreamChunk>>,
103 /// Skill injector for loading and caching skills
104 skill_injector: Option<Arc<SkillInjector>>,
105 /// Skills map from workflow definition (skill_name -> path)
106 skills_map: Option<std::collections::HashMap<String, String>>,
107 /// Base directory for resolving skill paths
108 base_dir: Option<PathBuf>,
109 /// Shared media staging for agent tool calls (H1 side-channel).
110 /// Binary content blocks from MCP tools are collected here since
111 /// rig's ToolDyn::call() returns String only.
112 pub media_staging: AgentMediaStaging,
113 /// Runtime limit tracker for cost/token/duration enforcement.
114 /// Instantiated from `AgentParams.limits` if configured, otherwise unlimited.
115 limit_tracker: LimitTracker,
116}
117
118impl std::fmt::Debug for RigAgentLoop {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 f.debug_struct("RigAgentLoop")
121 .field("task_id", &self.task_id)
122 .field("params", &self.params)
123 .field("tool_count", &self.tools.len())
124 .field("history_len", &self.history.len())
125 .field("media_staged", &self.media_staging.len())
126 .finish_non_exhaustive()
127 }
128}
129
130// ═══════════════════════════════════════════════════════════
131// ArcToolAdapter: wrap Arc<dyn ToolDyn> as Box<dyn ToolDyn>
132// ═══════════════════════════════════════════════════════════
133
134/// Adapter that wraps `Arc<dyn ToolDyn>` so it can be used as `Box<dyn ToolDyn>`.
135///
136/// rig-core's `AgentBuilder::tools()` takes `Vec<Box<dyn ToolDyn>>`.
137/// We store tools as `Vec<Arc<dyn ToolDyn>>` so they survive across multiple
138/// agent runs (chat_continue, retries). This adapter clones the Arc cheaply
139/// and delegates all trait methods.
140struct ArcToolAdapter(Arc<dyn rig::tool::ToolDyn>);
141
142impl rig::tool::ToolDyn for ArcToolAdapter {
143 fn name(&self) -> String {
144 self.0.name()
145 }
146
147 fn definition<'a>(
148 &'a self,
149 prompt: String,
150 ) -> std::pin::Pin<
151 Box<dyn std::future::Future<Output = rig::completion::ToolDefinition> + Send + 'a>,
152 > {
153 self.0.definition(prompt)
154 }
155
156 fn call<'a>(
157 &'a self,
158 args: String,
159 ) -> std::pin::Pin<
160 Box<dyn std::future::Future<Output = Result<String, rig::tool::ToolError>> + Send + 'a>,
161 > {
162 self.0.call(args)
163 }
164}
165
166impl RigAgentLoop {
167 /// Strip known provider prefix from model name.
168 ///
169 /// Users may write `model: openai/gpt-4o` or `model: claude/claude-sonnet-4-6`.
170 /// The API expects just `gpt-4o` or `claude-sonnet-4-6`.
171 fn strip_model_prefix(model: &str) -> &str {
172 const PREFIXES: &[&str] = &[
173 "anthropic/",
174 "claude/",
175 "openai/",
176 "mistral/",
177 "groq/",
178 "deepseek/",
179 "gemini/",
180 "google/",
181 "xai/",
182 ];
183 for prefix in PREFIXES {
184 if let Some(stripped) = model.strip_prefix(prefix) {
185 return stripped;
186 }
187 }
188 model
189 }
190
191 /// Build `additional_params` JSON for stop_sequences injection.
192 ///
193 /// rig-core has no native `.stop_sequences()` on AgentBuilder, but
194 /// `additional_params` is `#[serde(flatten)]`-ed into the request body,
195 /// so we inject the provider-specific key directly.
196 fn stop_sequences_params(provider: &str, sequences: &[String]) -> Option<serde_json::Value> {
197 if sequences.is_empty() {
198 return None;
199 }
200 // Gemini nests stopSequences inside generationConfig
201 if provider == "gemini" {
202 return Some(serde_json::json!({
203 "generationConfig": { "stopSequences": sequences }
204 }));
205 }
206 let key = match provider {
207 "anthropic" | "claude" => "stop_sequences",
208 // OpenAI, Mistral, Groq, DeepSeek, xAI all use "stop"
209 _ => "stop",
210 };
211 Some(serde_json::json!({ key: sequences }))
212 }
213
214 /// Create a new rig-based agent loop
215 ///
216 /// # Errors
217 /// - NIKA-113: Empty prompt
218 /// - NIKA-113: Invalid max_turns (0 or > 100)
219 pub fn new(
220 task_id: String,
221 params: AgentParams,
222 event_log: EventLog,
223 mcp_clients: FxHashMap<String, Arc<McpClient>>,
224 ) -> Result<Self, NikaError> {
225 // Validate params
226 if params.prompt.is_empty() {
227 return Err(NikaError::AgentValidationError {
228 reason: format!("Agent prompt cannot be empty (task: {})", task_id),
229 });
230 }
231
232 if let Some(max_turns) = params.max_turns {
233 if max_turns == 0 {
234 return Err(NikaError::AgentValidationError {
235 reason: format!("max_turns must be at least 1 (task: {})", task_id),
236 });
237 }
238 if max_turns > 100 {
239 return Err(NikaError::AgentValidationError {
240 reason: format!("max_turns cannot exceed 100 (task: {})", task_id),
241 });
242 }
243 }
244
245 // Create shared media staging for agent tool calls (H1 side-channel)
246 let media_staging: AgentMediaStaging = Arc::new(dashmap::DashMap::new());
247
248 // Build tools from MCP clients (with media staging for binary content)
249 let mut tools = Self::build_tools(¶ms.mcp, &mcp_clients, &media_staging)?;
250
251 // Add spawn_agent tool if depth_limit allows spawning (MVP 8 Phase 2)
252 // Default depth is 1 (root agent). Child agents get higher depths via spawn_agent.
253 let current_depth = 1_u32;
254 let max_depth = params.effective_depth_limit();
255 if current_depth < max_depth {
256 let spawn_tool = super::spawn::SpawnAgentTool::with_mcp(
257 current_depth,
258 max_depth,
259 Arc::from(task_id.as_str()),
260 event_log.clone(),
261 mcp_clients.clone(),
262 params.mcp.clone(),
263 tokio_util::sync::CancellationToken::new(),
264 )
265 .with_parent_config(
266 params.model.clone(),
267 params.provider.clone(),
268 params.temperature,
269 params.tools.clone(),
270 );
271 tools.push(Arc::new(spawn_tool));
272 }
273
274 // TODO(scope): AgentParams.scope (full/minimal/debug) is parsed but not yet implemented.
275 // When implemented, scope should define preset tool sets:
276 // - "full": all core + file + media tools (current default)
277 // - "minimal": only nika:complete + nika:log (for simple Q&A agents)
278 // - "debug": all tools + nika:assert + verbose logging
279 // For now, tool filtering is controlled via the explicit `tools:` list.
280
281 // Add builtin nika:* tools
282 // If params.tools is non-empty, only add tools that are explicitly requested.
283 // If params.tools is empty, add all core tools.
284 use super::builtin::{
285 AssertTool, CompleteTool, EmitTool, LogTool, NikaBuiltinToolAdapter, PromptTool,
286 RunTool, SleepTool,
287 };
288
289 // Create Arc wrappers for sharing with builtin tools
290 // EventLog is Clone with Arc internals, so this is cheap.
291 let event_log_arc = Arc::new(event_log.clone());
292 let task_id_arc: Arc<str> = task_id.as_str().into();
293
294 // Filter builtin tools based on params.tools
295 // "builtin" keyword means ALL builtin tools (core + file)
296 let all_builtins_requested = params.tools.iter().any(|t| t == "builtin");
297
298 // Extract the nika:* tools from params.tools for filtering
299 let requested_nika_tools: Vec<&str> = params
300 .tools
301 .iter()
302 .filter(|t| t.starts_with("nika:"))
303 .map(|t| t.as_str())
304 .collect();
305
306 // Helper: check if a tool should be added
307 // If no nika:* tools requested, add all core tools
308 // Otherwise, only add if explicitly requested
309 let should_add = |name: &str| -> bool {
310 if requested_nika_tools.is_empty() {
311 true // No filter specified, add all
312 } else {
313 let full_name = format!("nika:{}", name);
314 requested_nika_tools.contains(&full_name.as_str())
315 }
316 };
317
318 // Core builtin tools (only add if requested or no filter)
319 if should_add("sleep") {
320 tools.push(Arc::new(NikaBuiltinToolAdapter::new(Arc::new(SleepTool))));
321 }
322 if should_add("log") {
323 tools.push(Arc::new(
324 NikaBuiltinToolAdapter::new(Arc::new(LogTool))
325 .with_event_log(Arc::clone(&event_log_arc), Arc::clone(&task_id_arc)),
326 ));
327 }
328 if should_add("emit") {
329 tools.push(Arc::new(
330 NikaBuiltinToolAdapter::new(Arc::new(EmitTool))
331 .with_event_log(Arc::clone(&event_log_arc), Arc::clone(&task_id_arc)),
332 ));
333 }
334 if should_add("assert") {
335 tools.push(Arc::new(NikaBuiltinToolAdapter::new(Arc::new(AssertTool))));
336 }
337 if should_add("prompt") {
338 tools.push(Arc::new(NikaBuiltinToolAdapter::new(Arc::new(
339 PromptTool::default(),
340 ))));
341 }
342 if should_add("run") {
343 tools.push(Arc::new(NikaBuiltinToolAdapter::new(Arc::new(RunTool))));
344 }
345 if should_add("complete") {
346 tools.push(Arc::new(NikaBuiltinToolAdapter::new(Arc::new(
347 CompleteTool,
348 ))));
349 }
350
351 // Add file tools (nika:read, nika:write, nika:edit, nika:glob, nika:grep)
352 // File tools require a ToolContext for security boundaries
353 // Only add if explicitly requested in params.tools
354 let file_tools_requested: Vec<&str> = requested_nika_tools
355 .iter()
356 .filter(|t| {
357 matches!(
358 **t,
359 "nika:read" | "nika:write" | "nika:edit" | "nika:glob" | "nika:grep"
360 )
361 })
362 .copied()
363 .collect();
364
365 if all_builtins_requested || !file_tools_requested.is_empty() {
366 // Create ToolContext with current working directory and Plan mode
367 // (default safe mode — callers opt into YoloMode explicitly)
368 let working_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
369 let tool_ctx = Arc::new(ToolContext::new(working_dir, PermissionMode::Plan));
370
371 use super::builtin::FileToolAdapter;
372
373 if all_builtins_requested || file_tools_requested.contains(&"nika:read") {
374 tools.push(Arc::new(NikaBuiltinToolAdapter::new(Arc::new(
375 FileToolAdapter::new(ReadTool::new(Arc::clone(&tool_ctx))),
376 ))));
377 }
378 if all_builtins_requested || file_tools_requested.contains(&"nika:write") {
379 tools.push(Arc::new(NikaBuiltinToolAdapter::new(Arc::new(
380 FileToolAdapter::new(WriteTool::new(Arc::clone(&tool_ctx))),
381 ))));
382 }
383 if all_builtins_requested || file_tools_requested.contains(&"nika:edit") {
384 tools.push(Arc::new(NikaBuiltinToolAdapter::new(Arc::new(
385 FileToolAdapter::new(EditTool::new(Arc::clone(&tool_ctx))),
386 ))));
387 }
388 if all_builtins_requested || file_tools_requested.contains(&"nika:glob") {
389 tools.push(Arc::new(NikaBuiltinToolAdapter::new(Arc::new(
390 FileToolAdapter::new(GlobTool::new(Arc::clone(&tool_ctx))),
391 ))));
392 }
393 if all_builtins_requested || file_tools_requested.contains(&"nika:grep") {
394 tools.push(Arc::new(NikaBuiltinToolAdapter::new(Arc::new(
395 FileToolAdapter::new(GrepTool::new(tool_ctx)),
396 ))));
397 }
398 }
399
400 // PERF: Pre-allocate history capacity based on max_turns.
401 // Each turn adds 2 messages (user + assistant), so capacity = max_turns * 2.
402 // This reduces reallocations during conversation.
403 let history_capacity = params.max_turns.unwrap_or(10) as usize * 2;
404
405 // Initialize limit tracker from AgentParams.limits (or unlimited)
406 let limit_tracker = match ¶ms.limits {
407 Some(limits_config) => LimitTracker::new(limits_config.clone()),
408 None => LimitTracker::unlimited(),
409 };
410
411 Ok(Self {
412 task_id,
413 params,
414 event_log,
415 mcp_clients,
416 tools,
417 history: Vec::with_capacity(history_capacity),
418 turn_count: 0,
419 stream_tx: None,
420 skill_injector: None,
421 skills_map: None,
422 base_dir: None,
423 media_staging,
424 limit_tracker,
425 })
426 }
427
428 /// Set streaming channel for real-time token display
429 ///
430 /// When set, tokens will be sent to this channel as they arrive during streaming.
431 /// This enables Claude Code-like real-time text display in the TUI.
432 pub fn with_stream_tx(
433 mut self,
434 tx: tokio::sync::mpsc::Sender<crate::provider::rig::StreamChunk>,
435 ) -> Self {
436 self.stream_tx = Some(tx);
437 self
438 }
439
440 /// Configure skill injection for this agent
441 ///
442 /// When set, skills defined in the workflow are loaded and prepended to
443 /// the agent's system prompt before LLM calls.
444 ///
445 /// # Arguments
446 /// * `injector` - Shared SkillInjector instance (with DashMap cache)
447 /// * `skills_map` - Mapping of skill names to file paths from workflow YAML
448 /// * `base_dir` - Base directory for resolving relative skill paths
449 ///
450 /// # Example
451 /// ```ignore
452 /// let agent = RigAgentLoop::new(task_id, params, log, mcp)?
453 /// .with_skills(
454 /// Arc::new(SkillInjector::new()),
455 /// skills_map,
456 /// PathBuf::from("/path/to/workflow"),
457 /// );
458 /// ```
459 pub fn with_skills(
460 mut self,
461 injector: Arc<SkillInjector>,
462 skills_map: std::collections::HashMap<String, String>,
463 base_dir: PathBuf,
464 ) -> Self {
465 self.skill_injector = Some(injector);
466 self.skills_map = Some(skills_map);
467 self.base_dir = Some(base_dir);
468 self
469 }
470
471 /// Inject a `DynamicSubmitTool` for structured output enforcement.
472 ///
473 /// When the task has an output policy with a JSON schema, this adds
474 /// `submit_result` as an available tool. Unlike `infer:` (which forces
475 /// `tool_choice: Required`), the agent can call `submit_result` when
476 /// ready — it's available but not forced.
477 ///
478 /// # Arguments
479 /// * `schema` - JSON Schema as `serde_json::Value` for the expected output
480 pub fn with_structured_output(mut self, schema: serde_json::Value) -> Self {
481 // Validate schema is a proper JSON Schema object to prevent rig-core panics
482 let schema = if schema.get("type").is_none() {
483 tracing::warn!(
484 task_id = %self.task_id,
485 "output.schema missing 'type' field, wrapping in object schema"
486 );
487 // Wrap bare schema in a proper object schema
488 serde_json::json!({
489 "type": "object",
490 "properties": {
491 "result": schema
492 },
493 "required": ["result"]
494 })
495 } else {
496 schema
497 };
498
499 let submit_tool = DynamicSubmitTool::new(schema);
500 self.tools.push(Arc::new(submit_tool));
501 tracing::debug!(
502 task_id = %self.task_id,
503 "Added DynamicSubmitTool (submit_result) to agent tools"
504 );
505 self
506 }
507
508 // =========================================================================
509 // Skill Injection
510 // =========================================================================
511
512 /// Inject skills into the system prompt
513 ///
514 /// If skills are configured via `with_skills()` and the agent has skills
515 /// defined in `AgentParams.skills`, this method loads and prepends skill
516 /// content to the base system prompt.
517 ///
518 /// # Returns
519 /// - Enhanced prompt with skill content prepended, or
520 /// - Original system prompt if no skills configured
521 async fn inject_skills_into_prompt(&self) -> Result<String, NikaError> {
522 let mut preamble = self
523 .params
524 .system
525 .as_deref()
526 .unwrap_or_default()
527 .to_string();
528
529 // Add tool routing guide when builtin tools are available
530 if !self.tools.is_empty() {
531 let tool_names: Vec<String> = self
532 .tools
533 .iter()
534 .filter_map(|t| {
535 let name = t.name();
536 if name.starts_with("nika_") {
537 Some(name)
538 } else {
539 None
540 }
541 })
542 .collect();
543
544 if !tool_names.is_empty() {
545 preamble.push_str("\n\n## Available Tools\n");
546 for name in &tool_names {
547 match name.as_str() {
548 "nika_read" => {
549 preamble.push_str("- nika_read: Read file contents from disk\n")
550 }
551 "nika_write" => {
552 preamble.push_str("- nika_write: Create a NEW file (fails if exists)\n")
553 }
554 "nika_edit" => preamble
555 .push_str("- nika_edit: Edit an EXISTING file by replacing text\n"),
556 "nika_glob" => {
557 preamble.push_str("- nika_glob: Find files matching a pattern\n")
558 }
559 "nika_grep" => {
560 preamble.push_str("- nika_grep: Search file contents with regex\n")
561 }
562 "nika_complete" => preamble.push_str(
563 "- nika_complete: Signal task completion with structured result\n",
564 ),
565 "nika_log" => preamble.push_str(
566 "- nika_log: Emit a log message (for observability only, not output)\n",
567 ),
568 "nika_emit" => {
569 preamble.push_str("- nika_emit: Emit a named event with payload\n")
570 }
571 "nika_run" => preamble.push_str("- nika_run: Execute a sub-workflow\n"),
572 _ => {}
573 }
574 }
575 preamble.push_str(
576 "\nUse the MOST SPECIFIC tool for each action. Call nika_complete when done.\n",
577 );
578 }
579 }
580
581 // Inject completion instructions from CompletionConfig
582 // This tells the agent how to signal completion (explicit/pattern/natural)
583 if let Some(ref completion_config) = self.params.completion {
584 let instruction = completion_config.generate_system_instruction();
585 if !instruction.is_empty() {
586 preamble.push_str("\n\n## Completion Instructions\n");
587 preamble.push_str(&instruction);
588 }
589 }
590
591 // Check if skill injection is configured
592 let (Some(injector), Some(skills_map), Some(base_dir)) =
593 (&self.skill_injector, &self.skills_map, &self.base_dir)
594 else {
595 return Ok(preamble);
596 };
597
598 // Check if agent has skills defined
599 let Some(skill_names) = &self.params.skills else {
600 return Ok(preamble);
601 };
602
603 if skill_names.is_empty() {
604 return Ok(preamble);
605 }
606
607 // Convert Vec<String> to &[&str] for the inject() API
608 let skill_refs: Vec<&str> = skill_names.iter().map(|s| s.as_str()).collect();
609
610 // Inject skills into the preamble
611 let preamble_ref = if preamble.is_empty() {
612 None
613 } else {
614 Some(preamble.as_str())
615 };
616 injector
617 .inject(preamble_ref, &skill_refs, skills_map, base_dir)
618 .await
619 }
620
621 /// Create boxed tool copies from Arc-stored tools.
622 ///
623 /// Each call produces fresh `Box<dyn ToolDyn>` wrappers around the same
624 /// `Arc<dyn ToolDyn>` instances, so tools are never consumed.
625 fn tools_as_boxed(&self) -> Vec<Box<dyn rig::tool::ToolDyn>> {
626 self.tools
627 .iter()
628 .map(|t| Box::new(ArcToolAdapter(Arc::clone(t))) as Box<dyn rig::tool::ToolDyn>)
629 .collect()
630 }
631
632 /// Drain collected media content blocks from all agent tool calls.
633 ///
634 /// Returns all ContentBlocks that were staged during the agent loop.
635 /// The DashMap is drained (emptied) after this call.
636 pub fn drain_media(&self) -> Vec<crate::mcp::types::ContentBlock> {
637 let mut all_blocks = Vec::new();
638 // Drain all entries from the staging map
639 for entry in self.media_staging.iter() {
640 all_blocks.extend(entry.value().iter().cloned());
641 }
642 self.media_staging.clear();
643 all_blocks
644 }
645
646 /// Build NikaMcpTool instances from MCP clients with media staging
647 fn build_tools(
648 mcp_names: &[String],
649 mcp_clients: &FxHashMap<String, Arc<McpClient>>,
650 media_staging: &AgentMediaStaging,
651 ) -> Result<Vec<Arc<dyn rig::tool::ToolDyn>>, NikaError> {
652 let mut tools: Vec<Arc<dyn rig::tool::ToolDyn>> = Vec::new();
653
654 for mcp_name in mcp_names {
655 let client = mcp_clients
656 .get(mcp_name)
657 .ok_or_else(|| NikaError::McpNotConnected {
658 name: mcp_name.clone(),
659 })?;
660
661 // Get tool definitions from MCP client
662 let tool_defs = client.get_tool_definitions();
663
664 for def in tool_defs {
665 let tool = NikaMcpTool::with_media_staging(
666 NikaMcpToolDef {
667 name: def.name.clone(),
668 description: def.description.clone().unwrap_or_default(),
669 input_schema: def
670 .input_schema
671 .clone()
672 .unwrap_or_else(|| serde_json::json!({"type": "object"})),
673 },
674 client.clone(),
675 Arc::clone(media_staging),
676 );
677 tools.push(Arc::new(tool));
678 }
679 }
680
681 Ok(tools)
682 }
683
684 /// Get the number of tools available
685 pub fn tool_count(&self) -> usize {
686 self.tools.len()
687 }
688}