Skip to main content

adk_managed/
agent_builder.rs

1//! Agent builder — constructs a runnable agent from a [`ManagedAgentDef`].
2//!
3//! The [`build_agent`] function is the bridge between the declarative agent
4//! definition and the live `LlmAgent` that the session loop drives. It wires:
5//!
6//! - Model (`Arc<dyn Llm>`) + system prompt → `LlmAgentBuilder`
7//! - Built-in tool declarations → in-process tool implementations
8//! - Custom tools → [`ManagedCustomTool`] wrappers (park via `ToolParkingLot`)
9//! - Permission policy → `ToolConfirmationPolicy`
10//! - Description → agent description
11//!
12//! MCP servers and skills are noted but their full integration is deferred to
13//! later tasks (MCP toolset lifecycle, skill injection).
14
15use std::collections::BTreeSet;
16use std::sync::Arc;
17
18use async_trait::async_trait;
19use serde_json::Value;
20
21use adk_agent::LlmAgentBuilder;
22use adk_core::{Agent, Llm, Tool, ToolConfirmationPolicy, ToolContext};
23#[cfg(feature = "sandbox")]
24use adk_sandbox::{ExecRequest, Language, SandboxBackend};
25
26use crate::types::{ManagedAgentDef, PermissionMode, PermissionPolicy, ToolConfig};
27
28/// Errors that can occur during agent construction.
29#[derive(Debug, thiserror::Error)]
30pub enum BuildError {
31    /// The agent definition is invalid.
32    #[error("invalid agent definition: {0}")]
33    InvalidDef(String),
34
35    /// Agent construction failed.
36    #[error("agent build failed: {0}")]
37    BuildFailed(String),
38}
39
40/// Build a runnable agent from a [`ManagedAgentDef`] and a resolved model.
41///
42/// This function constructs an `LlmAgent` by wiring the declarative definition
43/// fields into the builder. The resulting agent can be driven by the session loop.
44///
45/// # Arguments
46///
47/// * `def` — The declarative agent definition.
48/// * `model` — A resolved LLM instance (from [`ModelResolver`](crate::resolver::ModelResolver)).
49/// * `sandbox` — Optional sandbox backend for isolated tool execution (sandbox feature only).
50///
51/// # Returns
52///
53/// An `Arc<dyn Agent>` ready for execution by the `Runner`.
54///
55/// # Example
56///
57/// ```rust,ignore
58/// use adk_managed::agent_builder::build_agent;
59/// use adk_managed::types::ManagedAgentDef;
60///
61/// let agent = test_build_agent(&def, model);
62/// println!("Built agent: {}", agent.name());
63/// ```
64#[cfg(feature = "sandbox")]
65pub fn build_agent(
66    def: &ManagedAgentDef,
67    model: Arc<dyn Llm>,
68    sandbox: Option<Arc<dyn SandboxBackend>>,
69) -> Result<Arc<dyn Agent>, BuildError> {
70    let mut builder = LlmAgentBuilder::new(&def.name).model(model);
71
72    // Wire system prompt
73    if let Some(ref system) = def.system {
74        builder = builder.instruction(system.clone());
75    }
76
77    // Wire description
78    if let Some(ref description) = def.description {
79        builder = builder.description(description.clone());
80    }
81
82    // Wire tools
83    for tool_config in &def.tools {
84        let tool: Arc<dyn Tool> = match tool_config {
85            ToolConfig::Bash {} => Arc::new(ManagedBuiltinTool::new(
86                "bash",
87                "Execute bash shell commands in the agent's workspace.",
88                sandbox.clone(),
89            )),
90            ToolConfig::Filesystem {} => Arc::new(ManagedBuiltinTool::new(
91                "filesystem",
92                "Read, write, and manage files in the agent's workspace.",
93                sandbox.clone(),
94            )),
95            ToolConfig::WebSearch {} => Arc::new(ManagedBuiltinTool::new(
96                "web_search",
97                "Search the web for information.",
98                sandbox.clone(),
99            )),
100            ToolConfig::WebFetch {} => Arc::new(ManagedBuiltinTool::new(
101                "web_fetch",
102                "Fetch and extract content from a URL.",
103                sandbox.clone(),
104            )),
105            ToolConfig::CodeExecution {} => Arc::new(ManagedBuiltinTool::new(
106                "code_execution",
107                "Execute code in a sandboxed environment.",
108                sandbox.clone(),
109            )),
110            ToolConfig::Custom { name, description, input_schema } => {
111                Arc::new(ManagedCustomTool::new(
112                    name.clone(),
113                    description.clone().unwrap_or_default(),
114                    input_schema.clone(),
115                ))
116            }
117        };
118        builder = builder.tool(tool);
119    }
120
121    // Wire permission policy → ToolConfirmationPolicy
122    if let Some(ref policy) = def.permission_policy {
123        let confirmation_policy = map_permission_policy(policy);
124        builder = builder.tool_confirmation_policy(confirmation_policy);
125    }
126
127    // Note: MCP servers and skills are registered in later tasks.
128    if !def.mcp_servers.is_empty() {
129        tracing::debug!(
130            mcp_count = def.mcp_servers.len(),
131            "MCP server configs noted (wiring deferred to session loop)"
132        );
133    }
134    if !def.skills.is_empty() {
135        tracing::debug!(
136            skill_count = def.skills.len(),
137            "skill refs noted (wiring deferred to session loop)"
138        );
139    }
140
141    let agent = builder.build().map_err(|e| BuildError::BuildFailed(e.to_string()))?;
142
143    Ok(Arc::new(agent))
144}
145
146/// Build a runnable agent from a [`ManagedAgentDef`] and a resolved model.
147///
148/// See the `sandbox`-enabled variant for full documentation.
149#[cfg(not(feature = "sandbox"))]
150pub fn build_agent(
151    def: &ManagedAgentDef,
152    model: Arc<dyn Llm>,
153) -> Result<Arc<dyn Agent>, BuildError> {
154    let mut builder = LlmAgentBuilder::new(&def.name).model(model);
155
156    // Wire system prompt
157    if let Some(ref system) = def.system {
158        builder = builder.instruction(system.clone());
159    }
160
161    // Wire description
162    if let Some(ref description) = def.description {
163        builder = builder.description(description.clone());
164    }
165
166    // Wire tools
167    for tool_config in &def.tools {
168        let tool: Arc<dyn Tool> = match tool_config {
169            ToolConfig::Bash {} => Arc::new(ManagedBuiltinTool::new(
170                "bash",
171                "Execute bash shell commands in the agent's workspace.",
172            )),
173            ToolConfig::Filesystem {} => Arc::new(ManagedBuiltinTool::new(
174                "filesystem",
175                "Read, write, and manage files in the agent's workspace.",
176            )),
177            ToolConfig::WebSearch {} => {
178                Arc::new(ManagedBuiltinTool::new("web_search", "Search the web for information."))
179            }
180            ToolConfig::WebFetch {} => Arc::new(ManagedBuiltinTool::new(
181                "web_fetch",
182                "Fetch and extract content from a URL.",
183            )),
184            ToolConfig::CodeExecution {} => Arc::new(ManagedBuiltinTool::new(
185                "code_execution",
186                "Execute code in a sandboxed environment.",
187            )),
188            ToolConfig::Custom { name, description, input_schema } => {
189                Arc::new(ManagedCustomTool::new(
190                    name.clone(),
191                    description.clone().unwrap_or_default(),
192                    input_schema.clone(),
193                ))
194            }
195        };
196        builder = builder.tool(tool);
197    }
198
199    // Wire permission policy → ToolConfirmationPolicy
200    if let Some(ref policy) = def.permission_policy {
201        let confirmation_policy = map_permission_policy(policy);
202        builder = builder.tool_confirmation_policy(confirmation_policy);
203    }
204
205    // Note: MCP servers and skills are registered in later tasks.
206    if !def.mcp_servers.is_empty() {
207        tracing::debug!(
208            mcp_count = def.mcp_servers.len(),
209            "MCP server configs noted (wiring deferred to session loop)"
210        );
211    }
212    if !def.skills.is_empty() {
213        tracing::debug!(
214            skill_count = def.skills.len(),
215            "skill refs noted (wiring deferred to session loop)"
216        );
217    }
218
219    let agent = builder.build().map_err(|e| BuildError::BuildFailed(e.to_string()))?;
220
221    Ok(Arc::new(agent))
222}
223
224/// Map a [`PermissionPolicy`] to a [`ToolConfirmationPolicy`].
225///
226/// The mapping logic:
227/// - `default: AutoApprove` with no per-tool overrides → `Never`
228/// - `default: Prompt` with no per-tool overrides → `Always`
229/// - `default: Deny` → `Always` (deny requires confirmation; the runtime
230///   can then reject on deny)
231/// - Per-tool overrides with `Prompt` or `Deny` → `PerTool` containing those names
232fn map_permission_policy(policy: &PermissionPolicy) -> ToolConfirmationPolicy {
233    // Collect tools that require confirmation (Prompt or Deny modes)
234    let tools_requiring_confirmation: BTreeSet<String> = policy
235        .tools
236        .iter()
237        .filter(|(_, mode)| matches!(mode, PermissionMode::Prompt | PermissionMode::Deny))
238        .map(|(name, _)| name.clone())
239        .collect();
240
241    match policy.default {
242        PermissionMode::AutoApprove => {
243            if tools_requiring_confirmation.is_empty() {
244                ToolConfirmationPolicy::Never
245            } else {
246                ToolConfirmationPolicy::PerTool(tools_requiring_confirmation)
247            }
248        }
249        PermissionMode::Prompt | PermissionMode::Deny => {
250            // If the default requires confirmation, use Always unless there
251            // are explicit auto_approve overrides that narrow it down.
252            // In practice, "default: prompt" means all tools need confirmation.
253            ToolConfirmationPolicy::Always
254        }
255    }
256}
257
258// ─── ManagedBuiltinTool ──────────────────────────────────────────────────────
259
260/// Built-in tool for server-side execution (bash, filesystem, web_search, etc.).
261///
262/// When the Runner calls `execute()`, this tool performs the operation in-process.
263/// For `bash`, it spawns a child process via `tokio::process::Command`. For
264/// tools that require external services (web_search, web_fetch), it returns a
265/// structured error indicating the service is unavailable unless explicitly
266/// configured.
267///
268/// When a sandbox backend is configured, execution tools (`bash`, `code_execution`)
269/// delegate to the sandbox for isolated execution instead of running in-process.
270#[derive(Clone)]
271pub struct ManagedBuiltinTool {
272    name: String,
273    description: String,
274    /// Optional sandbox backend for isolated execution.
275    #[cfg(feature = "sandbox")]
276    sandbox: Option<Arc<dyn SandboxBackend>>,
277}
278
279impl std::fmt::Debug for ManagedBuiltinTool {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        let mut d = f.debug_struct("ManagedBuiltinTool");
282        d.field("name", &self.name).field("description", &self.description);
283        #[cfg(feature = "sandbox")]
284        d.field("sandbox", &self.sandbox.as_ref().map(|s| s.name()));
285        d.finish()
286    }
287}
288
289impl ManagedBuiltinTool {
290    /// Create a new built-in tool with sandbox support.
291    #[cfg(feature = "sandbox")]
292    pub fn new(
293        name: impl Into<String>,
294        description: impl Into<String>,
295        sandbox: Option<Arc<dyn SandboxBackend>>,
296    ) -> Self {
297        Self { name: name.into(), description: description.into(), sandbox }
298    }
299
300    /// Create a new built-in tool (no sandbox).
301    #[cfg(not(feature = "sandbox"))]
302    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
303        Self { name: name.into(), description: description.into() }
304    }
305
306    /// Execute a bash command in a child process.
307    async fn execute_bash(&self, args: &Value) -> adk_core::Result<Value> {
308        let command = args.get("command").and_then(|v| v.as_str()).unwrap_or_default();
309
310        if command.is_empty() {
311            return Ok(serde_json::json!({
312                "error": "no command provided",
313                "exit_code": 1
314            }));
315        }
316
317        let output = tokio::process::Command::new("sh")
318            .arg("-c")
319            .arg(command)
320            .output()
321            .await
322            .map_err(|e| adk_core::AdkError::tool(format!("failed to spawn bash: {e}")))?;
323
324        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
325        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
326        let exit_code = output.status.code().unwrap_or(-1);
327
328        Ok(serde_json::json!({
329            "stdout": stdout,
330            "stderr": stderr,
331            "exit_code": exit_code
332        }))
333    }
334
335    /// Execute a filesystem operation.
336    async fn execute_filesystem(&self, args: &Value) -> adk_core::Result<Value> {
337        let operation = args.get("operation").and_then(|v| v.as_str()).unwrap_or("read");
338
339        let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
340
341        match operation {
342            "read" => {
343                if path.is_empty() {
344                    return Ok(serde_json::json!({"error": "path is required"}));
345                }
346                match tokio::fs::read_to_string(path).await {
347                    Ok(content) => Ok(serde_json::json!({"content": content})),
348                    Err(e) => Ok(serde_json::json!({"error": format!("read failed: {e}")})),
349                }
350            }
351            "write" => {
352                let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
353                if path.is_empty() {
354                    return Ok(serde_json::json!({"error": "path is required"}));
355                }
356                match tokio::fs::write(path, content).await {
357                    Ok(()) => Ok(serde_json::json!({"status": "written", "path": path})),
358                    Err(e) => Ok(serde_json::json!({"error": format!("write failed: {e}")})),
359                }
360            }
361            "list" => {
362                let target = if path.is_empty() { "." } else { path };
363                match tokio::fs::read_dir(target).await {
364                    Ok(mut entries) => {
365                        let mut files = Vec::new();
366                        while let Ok(Some(entry)) = entries.next_entry().await {
367                            files.push(entry.file_name().to_string_lossy().to_string());
368                        }
369                        Ok(serde_json::json!({"files": files}))
370                    }
371                    Err(e) => Ok(serde_json::json!({"error": format!("list failed: {e}")})),
372                }
373            }
374            other => Ok(serde_json::json!({
375                "error": format!("unsupported filesystem operation: {other}")
376            })),
377        }
378    }
379
380    /// Execute via sandbox backend — delegates to `SandboxBackend::execute()`.
381    #[cfg(feature = "sandbox")]
382    async fn execute_via_sandbox(
383        &self,
384        sandbox: &Arc<dyn SandboxBackend>,
385        language: Language,
386        args: &Value,
387    ) -> adk_core::Result<Value> {
388        use std::collections::HashMap;
389        use std::time::Duration;
390
391        let code = match language {
392            Language::Command => {
393                // For bash/command, the code is in the "command" field
394                args.get("command").and_then(|v| v.as_str()).unwrap_or_default().to_string()
395            }
396            _ => {
397                // For language execution, the code is in the "code" field
398                args.get("code").and_then(|v| v.as_str()).unwrap_or_default().to_string()
399            }
400        };
401
402        if code.is_empty() {
403            return Ok(serde_json::json!({"error": "no code/command provided"}));
404        }
405
406        let timeout_secs = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(30);
407
408        let request = ExecRequest {
409            language,
410            code,
411            stdin: args.get("stdin").and_then(|v| v.as_str()).map(String::from),
412            timeout: Duration::from_secs(timeout_secs),
413            memory_limit_mb: args.get("memory_limit_mb").and_then(|v| v.as_u64()).map(|v| v as u32),
414            env: HashMap::new(),
415        };
416
417        match sandbox.execute(request).await {
418            Ok(result) => Ok(serde_json::json!({
419                "stdout": result.stdout,
420                "stderr": result.stderr,
421                "exit_code": result.exit_code,
422                "duration_ms": result.duration.as_millis() as u64
423            })),
424            Err(e) => Ok(serde_json::json!({
425                "error": format!("sandbox execution failed: {e}"),
426                "exit_code": -1
427            })),
428        }
429    }
430}
431
432#[async_trait]
433impl Tool for ManagedBuiltinTool {
434    fn name(&self) -> &str {
435        &self.name
436    }
437
438    fn description(&self) -> &str {
439        &self.description
440    }
441
442    async fn execute(&self, _ctx: Arc<dyn ToolContext>, args: Value) -> adk_core::Result<Value> {
443        match self.name.as_str() {
444            "bash" => {
445                #[cfg(feature = "sandbox")]
446                if let Some(ref sandbox) = self.sandbox {
447                    return self.execute_via_sandbox(sandbox, Language::Command, &args).await;
448                }
449                self.execute_bash(&args).await
450            }
451            "filesystem" => self.execute_filesystem(&args).await,
452            "code_execution" => {
453                // Code execution requires a sandbox backend. Without one configured,
454                // we fall back to running via bash with the appropriate interpreter.
455                let language = args.get("language").and_then(|v| v.as_str()).unwrap_or("python");
456                let code = args.get("code").and_then(|v| v.as_str()).unwrap_or_default();
457
458                if code.is_empty() {
459                    return Ok(serde_json::json!({"error": "no code provided"}));
460                }
461
462                #[cfg(feature = "sandbox")]
463                if let Some(ref sandbox) = self.sandbox {
464                    let lang = match language {
465                        "python" | "python3" => Language::Python,
466                        "javascript" | "js" | "node" => Language::JavaScript,
467                        "bash" | "sh" => Language::Command,
468                        "rust" => Language::Rust,
469                        "typescript" | "ts" => Language::TypeScript,
470                        other => {
471                            return Ok(serde_json::json!({
472                                "error": format!("unsupported language for sandbox: {other}")
473                            }));
474                        }
475                    };
476                    return self.execute_via_sandbox(sandbox, lang, &args).await;
477                }
478
479                let interpreter = match language {
480                    "python" | "python3" => "python3",
481                    "javascript" | "js" | "node" => "node",
482                    "bash" | "sh" => "sh",
483                    other => {
484                        return Ok(serde_json::json!({
485                            "error": format!("unsupported language: {other}. Configure a sandbox backend for full language support.")
486                        }));
487                    }
488                };
489
490                let output = tokio::process::Command::new(interpreter)
491                    .arg("-c")
492                    .arg(code)
493                    .output()
494                    .await
495                    .map_err(|e| {
496                        adk_core::AdkError::tool(format!("failed to spawn {interpreter}: {e}"))
497                    })?;
498
499                let stdout = String::from_utf8_lossy(&output.stdout).to_string();
500                let stderr = String::from_utf8_lossy(&output.stderr).to_string();
501                let exit_code = output.status.code().unwrap_or(-1);
502
503                Ok(serde_json::json!({
504                    "stdout": stdout,
505                    "stderr": stderr,
506                    "exit_code": exit_code
507                }))
508            }
509            "web_search" => {
510                // Web search requires an external API (Google, Bing, etc.).
511                // Return a structured response indicating the service needs configuration.
512                let query = args.get("query").and_then(|v| v.as_str()).unwrap_or_default();
513                Ok(serde_json::json!({
514                    "error": "web_search is not configured for in-process execution. Configure an API key or use a provider with built-in search grounding.",
515                    "query": query
516                }))
517            }
518            "web_fetch" => {
519                let url = args.get("url").and_then(|v| v.as_str()).unwrap_or_default();
520                Ok(serde_json::json!({
521                    "error": "web_fetch is not configured for in-process execution. Configure an HTTP client or sandbox with network access.",
522                    "url": url
523                }))
524            }
525            other => Err(adk_core::AdkError::tool(format!("unknown built-in tool: {other}"))),
526        }
527    }
528}
529
530// ─── ManagedCustomTool ───────────────────────────────────────────────────────
531
532/// Wrapper for custom (client-executed) tools declared in a [`ManagedAgentDef`].
533///
534/// When the Runner's agent calls `execute()`, this tool returns a pending status
535/// and signals `is_long_running() = true`. This causes the agent loop to break
536/// after the current turn, yielding control back to the session loop. The session
537/// loop then emits `agent.custom_tool_use` to notify the client and parks via
538/// the [`ToolParkingLot`](crate::parking::ToolParkingLot) until the client
539/// delivers a result through `user.custom_tool_result`.
540///
541/// # Multi-Turn Flow
542///
543/// 1. LLM returns a function call for this custom tool
544/// 2. Agent calls `execute()` → returns pending status
545/// 3. `is_long_running() = true` breaks the agent loop
546/// 4. Session loop sees the custom tool call in emitted events
547/// 5. Session loop emits `agent.custom_tool_use`, sets `RequiresAction` stop reason
548/// 6. Session loop parks until client delivers via `user.custom_tool_result`
549/// 7. On next turn, the delivered result is available for the agent
550#[derive(Debug, Clone)]
551pub struct ManagedCustomTool {
552    name: String,
553    description: String,
554    input_schema: Value,
555}
556
557impl ManagedCustomTool {
558    /// Create a new custom tool wrapper.
559    ///
560    /// # Arguments
561    ///
562    /// * `name` — Tool name as declared in the agent definition.
563    /// * `description` — Human-readable description for the LLM.
564    /// * `input_schema` — JSON Schema for the tool's input parameters.
565    pub fn new(name: String, description: String, input_schema: Value) -> Self {
566        Self { name, description, input_schema }
567    }
568}
569
570#[async_trait]
571impl Tool for ManagedCustomTool {
572    fn name(&self) -> &str {
573        &self.name
574    }
575
576    fn description(&self) -> &str {
577        &self.description
578    }
579
580    fn parameters_schema(&self) -> Option<Value> {
581        Some(self.input_schema.clone())
582    }
583
584    fn is_long_running(&self) -> bool {
585        // Custom tools are client-executed. Returning true causes the agent
586        // loop to break after this turn, giving the session loop control to
587        // park and wait for the client to deliver the result.
588        true
589    }
590
591    async fn execute(&self, _ctx: Arc<dyn ToolContext>, args: Value) -> adk_core::Result<Value> {
592        // Return a structured pending response. The agent loop will break
593        // because is_long_running() = true. The session loop handles the
594        // actual parking/delivery flow.
595        Ok(serde_json::json!({
596            "status": "pending_client_execution",
597            "tool": self.name,
598            "message": "This tool requires client-side execution. The result will be provided by the client.",
599            "args_received": args
600        }))
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use crate::types::{ManagedAgentDef, ModelRef, PermissionMode, PermissionPolicy, ToolConfig};
608    use adk_core::{Content, FinishReason, Llm, LlmRequest, LlmResponse, LlmResponseStream};
609    use async_stream::stream;
610    use std::collections::HashMap;
611
612    /// Mock LLM for testing agent construction.
613    struct MockLlm {
614        name: String,
615    }
616
617    impl MockLlm {
618        fn new(name: &str) -> Self {
619            Self { name: name.to_string() }
620        }
621    }
622
623    #[async_trait]
624    impl Llm for MockLlm {
625        fn name(&self) -> &str {
626            &self.name
627        }
628
629        async fn generate_content(
630            &self,
631            _request: LlmRequest,
632            _stream: bool,
633        ) -> adk_core::Result<LlmResponseStream> {
634            let s = stream! {
635                yield Ok(LlmResponse {
636                    content: Some(Content::new("model").with_text("Hello")),
637                    partial: false,
638                    turn_complete: true,
639                    finish_reason: Some(FinishReason::Stop),
640                    ..Default::default()
641                });
642            };
643            Ok(Box::pin(s))
644        }
645    }
646
647    /// Test helper: call build_agent with correct arity for current feature set.
648    #[cfg(feature = "sandbox")]
649    fn test_build_agent(def: &ManagedAgentDef, model: Arc<dyn Llm>) -> Arc<dyn Agent> {
650        build_agent(def, model, None).unwrap()
651    }
652
653    #[cfg(not(feature = "sandbox"))]
654    fn test_build_agent(def: &ManagedAgentDef, model: Arc<dyn Llm>) -> Arc<dyn Agent> {
655        build_agent(def, model).unwrap()
656    }
657
658    #[test]
659    fn test_build_agent_minimal_def() {
660        let def = ManagedAgentDef {
661            name: "test-agent".to_string(),
662            model: ModelRef::Shorthand("gemini-2.5-flash".to_string()),
663            system: None,
664            description: None,
665            tools: vec![],
666            mcp_servers: vec![],
667            skills: vec![],
668            permission_policy: None,
669            metadata: None,
670        };
671
672        let model: Arc<dyn Llm> = Arc::new(MockLlm::new("mock-model"));
673        let agent = test_build_agent(&def, model);
674
675        assert_eq!(agent.name(), "test-agent");
676    }
677
678    #[test]
679    fn test_build_agent_with_system_prompt() {
680        let def = ManagedAgentDef {
681            name: "prompted-agent".to_string(),
682            model: ModelRef::Shorthand("gemini-2.5-flash".to_string()),
683            system: Some("You are a helpful assistant.".to_string()),
684            description: Some("A helpful agent".to_string()),
685            tools: vec![],
686            mcp_servers: vec![],
687            skills: vec![],
688            permission_policy: None,
689            metadata: None,
690        };
691
692        let model: Arc<dyn Llm> = Arc::new(MockLlm::new("mock-model"));
693        let agent = test_build_agent(&def, model);
694
695        assert_eq!(agent.name(), "prompted-agent");
696        assert_eq!(agent.description(), "A helpful agent");
697    }
698
699    #[test]
700    fn test_build_agent_with_builtin_tools() {
701        let def = ManagedAgentDef {
702            name: "tool-agent".to_string(),
703            model: ModelRef::Shorthand("gemini-2.5-flash".to_string()),
704            system: None,
705            description: None,
706            tools: vec![
707                ToolConfig::Bash {},
708                ToolConfig::Filesystem {},
709                ToolConfig::WebSearch {},
710                ToolConfig::WebFetch {},
711                ToolConfig::CodeExecution {},
712            ],
713            mcp_servers: vec![],
714            skills: vec![],
715            permission_policy: None,
716            metadata: None,
717        };
718
719        let model: Arc<dyn Llm> = Arc::new(MockLlm::new("mock-model"));
720        let agent = test_build_agent(&def, model);
721        assert_eq!(agent.name(), "tool-agent");
722    }
723
724    #[test]
725    fn test_build_agent_with_custom_tool() {
726        let def = ManagedAgentDef {
727            name: "custom-tool-agent".to_string(),
728            model: ModelRef::Shorthand("gemini-2.5-flash".to_string()),
729            system: None,
730            description: None,
731            tools: vec![ToolConfig::Custom {
732                name: "get_weather".to_string(),
733                description: Some("Get current weather".to_string()),
734                input_schema: serde_json::json!({
735                    "type": "object",
736                    "properties": {
737                        "city": {"type": "string"}
738                    },
739                    "required": ["city"]
740                }),
741            }],
742            mcp_servers: vec![],
743            skills: vec![],
744            permission_policy: None,
745            metadata: None,
746        };
747
748        let model: Arc<dyn Llm> = Arc::new(MockLlm::new("mock-model"));
749        let agent = test_build_agent(&def, model);
750        assert_eq!(agent.name(), "custom-tool-agent");
751    }
752
753    #[test]
754    fn test_build_agent_with_permission_policy_auto_approve() {
755        let def = ManagedAgentDef {
756            name: "auto-agent".to_string(),
757            model: ModelRef::Shorthand("gemini-2.5-flash".to_string()),
758            system: None,
759            description: None,
760            tools: vec![ToolConfig::Bash {}],
761            mcp_servers: vec![],
762            skills: vec![],
763            permission_policy: Some(PermissionPolicy {
764                default: PermissionMode::AutoApprove,
765                tools: HashMap::new(),
766            }),
767            metadata: None,
768        };
769
770        let model: Arc<dyn Llm> = Arc::new(MockLlm::new("mock-model"));
771        let agent = test_build_agent(&def, model);
772        assert_eq!(agent.name(), "auto-agent");
773    }
774
775    #[test]
776    fn test_build_agent_with_permission_policy_prompt_default() {
777        let def = ManagedAgentDef {
778            name: "prompt-agent".to_string(),
779            model: ModelRef::Shorthand("gemini-2.5-flash".to_string()),
780            system: None,
781            description: None,
782            tools: vec![ToolConfig::Bash {}],
783            mcp_servers: vec![],
784            skills: vec![],
785            permission_policy: Some(PermissionPolicy {
786                default: PermissionMode::Prompt,
787                tools: HashMap::new(),
788            }),
789            metadata: None,
790        };
791
792        let model: Arc<dyn Llm> = Arc::new(MockLlm::new("mock-model"));
793        let agent = test_build_agent(&def, model);
794        assert_eq!(agent.name(), "prompt-agent");
795    }
796
797    #[test]
798    fn test_build_agent_with_per_tool_permission() {
799        let def = ManagedAgentDef {
800            name: "mixed-agent".to_string(),
801            model: ModelRef::Shorthand("gemini-2.5-flash".to_string()),
802            system: None,
803            description: None,
804            tools: vec![ToolConfig::Bash {}, ToolConfig::Filesystem {}],
805            mcp_servers: vec![],
806            skills: vec![],
807            permission_policy: Some(PermissionPolicy {
808                default: PermissionMode::AutoApprove,
809                tools: HashMap::from([
810                    ("bash".to_string(), PermissionMode::Prompt),
811                    ("delete_file".to_string(), PermissionMode::Deny),
812                ]),
813            }),
814            metadata: None,
815        };
816
817        let model: Arc<dyn Llm> = Arc::new(MockLlm::new("mock-model"));
818        let agent = test_build_agent(&def, model);
819        assert_eq!(agent.name(), "mixed-agent");
820    }
821
822    // ─── map_permission_policy tests ─────────────────────────────────────────
823
824    #[test]
825    fn test_map_auto_approve_no_overrides() {
826        let policy =
827            PermissionPolicy { default: PermissionMode::AutoApprove, tools: HashMap::new() };
828        assert_eq!(map_permission_policy(&policy), ToolConfirmationPolicy::Never);
829    }
830
831    #[test]
832    fn test_map_prompt_default() {
833        let policy = PermissionPolicy { default: PermissionMode::Prompt, tools: HashMap::new() };
834        assert_eq!(map_permission_policy(&policy), ToolConfirmationPolicy::Always);
835    }
836
837    #[test]
838    fn test_map_deny_default() {
839        let policy = PermissionPolicy { default: PermissionMode::Deny, tools: HashMap::new() };
840        assert_eq!(map_permission_policy(&policy), ToolConfirmationPolicy::Always);
841    }
842
843    #[test]
844    fn test_map_auto_approve_with_per_tool_prompt() {
845        let policy = PermissionPolicy {
846            default: PermissionMode::AutoApprove,
847            tools: HashMap::from([
848                ("bash".to_string(), PermissionMode::Prompt),
849                ("delete_file".to_string(), PermissionMode::Deny),
850            ]),
851        };
852        let result = map_permission_policy(&policy);
853        match result {
854            ToolConfirmationPolicy::PerTool(tools) => {
855                assert!(tools.contains("bash"));
856                assert!(tools.contains("delete_file"));
857                assert_eq!(tools.len(), 2);
858            }
859            other => panic!("expected PerTool, got: {other:?}"),
860        }
861    }
862
863    #[test]
864    fn test_map_auto_approve_with_auto_approve_overrides_only() {
865        let policy = PermissionPolicy {
866            default: PermissionMode::AutoApprove,
867            tools: HashMap::from([("read_file".to_string(), PermissionMode::AutoApprove)]),
868        };
869        // AutoApprove overrides don't add to confirmation set
870        assert_eq!(map_permission_policy(&policy), ToolConfirmationPolicy::Never);
871    }
872
873    // ─── ManagedBuiltinTool tests ────────────────────────────────────────────
874
875    /// Helper to create a builtin tool for testing (handles feature-gated constructor).
876    #[cfg(feature = "sandbox")]
877    fn make_builtin_tool(name: &str, desc: &str) -> ManagedBuiltinTool {
878        ManagedBuiltinTool::new(name, desc, None)
879    }
880
881    #[cfg(not(feature = "sandbox"))]
882    fn make_builtin_tool(name: &str, desc: &str) -> ManagedBuiltinTool {
883        ManagedBuiltinTool::new(name, desc)
884    }
885
886    #[test]
887    fn test_builtin_tool_metadata() {
888        let tool = make_builtin_tool("bash", "Execute bash commands.");
889        assert_eq!(tool.name(), "bash");
890        assert_eq!(tool.description(), "Execute bash commands.");
891    }
892
893    #[tokio::test]
894    async fn test_builtin_tool_bash_executes() {
895        let tool = make_builtin_tool("bash", "Execute bash commands.");
896        let ctx = Arc::new(adk_tool::SimpleToolContext::new("test-caller"));
897        let result = tool.execute(ctx, serde_json::json!({"command": "echo hello"})).await.unwrap();
898        assert_eq!(result["exit_code"], 0);
899        assert!(result["stdout"].as_str().unwrap().contains("hello"));
900    }
901
902    #[tokio::test]
903    async fn test_builtin_tool_web_search_returns_error() {
904        let tool = make_builtin_tool("web_search", "Search the web.");
905        let ctx = Arc::new(adk_tool::SimpleToolContext::new("test-caller"));
906        let result = tool.execute(ctx, serde_json::json!({"query": "rust lang"})).await.unwrap();
907        assert!(result["error"].as_str().unwrap().contains("not configured"));
908    }
909
910    // ─── ManagedCustomTool tests ─────────────────────────────────────────────
911
912    #[test]
913    fn test_custom_tool_metadata() {
914        let schema = serde_json::json!({
915            "type": "object",
916            "properties": {"city": {"type": "string"}}
917        });
918        let tool = ManagedCustomTool::new(
919            "get_weather".to_string(),
920            "Get current weather".to_string(),
921            schema.clone(),
922        );
923        assert_eq!(tool.name(), "get_weather");
924        assert_eq!(tool.description(), "Get current weather");
925        assert_eq!(tool.parameters_schema(), Some(schema));
926        assert!(tool.is_long_running());
927    }
928
929    #[tokio::test]
930    async fn test_custom_tool_execute_returns_pending_status() {
931        let tool = ManagedCustomTool::new(
932            "my_tool".to_string(),
933            "A custom tool".to_string(),
934            serde_json::json!({"type": "object"}),
935        );
936        let ctx = Arc::new(adk_tool::SimpleToolContext::new("test-caller"));
937
938        let result = tool.execute(ctx, serde_json::json!({"city": "Seattle"})).await.unwrap();
939
940        assert_eq!(result["status"], "pending_client_execution");
941        assert_eq!(result["tool"], "my_tool");
942        assert_eq!(result["args_received"]["city"], "Seattle");
943    }
944
945    #[test]
946    fn test_custom_tool_is_long_running() {
947        let tool = ManagedCustomTool::new(
948            "deploy".to_string(),
949            "Deploy to production".to_string(),
950            serde_json::json!({"type": "object"}),
951        );
952        // Must be true so the agent loop breaks after this tool call
953        assert!(tool.is_long_running());
954    }
955}