Skip to main content

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(&params.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 &params.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}