Skip to main content

everruns_core/capabilities/
platform_management.rs

1// Platform Management capability
2// THREAT[TM-AGENT-017]: Agents with this capability can manage org-wide entities
3//
4// Decision: Read/write split — read tools (read_*) return single item by ID or filtered list;
5//           write tools (manage_*) perform mutations. Session I/O split into three single-purpose tools.
6// Decision: All results include UI links via PlatformStore::base_url()
7// Decision: get_messages defaults to last 10; session_read_response defaults to 120s timeout
8// Decision: Platform docs (docs/) are optionally embedded at compile time and mounted
9//           as a virtual readonly filesystem at /docs. This gives the platform chat agent
10//           access to Everruns documentation without DB writes per session while allowing
11//           the public core crate to build without repo-root files.
12
13use super::{
14    Capability, CapabilityLocalization, CapabilityStatus, MountPoint, is_declarative_capability,
15};
16use crate::app::{App, AppChannel, ChannelType};
17#[cfg(all(feature = "embedded-platform-docs", everruns_has_workspace_docs))]
18use crate::capability_types::{MountAccess, MountSource, VirtualFileTree};
19use crate::tool_types::ToolHints;
20use crate::tools::{Tool, ToolExecutionResult};
21use crate::traits::ToolContext;
22use async_trait::async_trait;
23#[cfg(all(feature = "embedded-platform-docs", everruns_has_workspace_docs))]
24use include_dir::{Dir, include_dir};
25use serde_json::{Value, json};
26#[cfg(all(feature = "embedded-platform-docs", everruns_has_workspace_docs))]
27use std::sync::Arc;
28
29const SESSION_READ_MESSAGES_DEFAULT_LIMIT: usize = 10;
30const SESSION_READ_MESSAGES_MAX_LIMIT: usize = 50;
31const SESSION_READ_MESSAGES_DEFAULT_CONTENT_LIMIT: usize = 12_000;
32const SESSION_READ_MESSAGES_MAX_CONTENT_LIMIT: usize = 50_000;
33
34// =============================================================================
35// Capability
36// =============================================================================
37
38// =============================================================================
39// Embedded platform docs (virtual mount)
40// =============================================================================
41
42/// Docs directory embedded at compile time from the repo root `docs/`.
43#[cfg(all(feature = "embedded-platform-docs", everruns_has_workspace_docs))]
44static DOCS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../docs");
45
46#[cfg(all(feature = "embedded-platform-docs", everruns_has_workspace_docs))]
47fn dir_to_tree(dir: &Dir, base: &str) -> VirtualFileTree {
48    let mut tree = VirtualFileTree::new();
49    tree.insert_directory(base);
50    populate_tree(&mut tree, dir, base);
51    tree
52}
53
54#[cfg(all(feature = "embedded-platform-docs", everruns_has_workspace_docs))]
55fn populate_tree(tree: &mut VirtualFileTree, dir: &Dir, prefix: &str) {
56    for file in dir.files() {
57        let name = file
58            .path()
59            .file_name()
60            .and_then(|n| n.to_str())
61            .unwrap_or("");
62        // Only include markdown content (skip images, JSON, etc.)
63        let ext = file
64            .path()
65            .extension()
66            .and_then(|e| e.to_str())
67            .unwrap_or("");
68        if !matches!(ext, "md" | "mdx") {
69            continue;
70        }
71        let path = format!("{prefix}/{name}");
72        let content = std::str::from_utf8(file.contents()).unwrap_or("");
73        tree.insert_text(&path, content);
74    }
75    for subdir in dir.dirs() {
76        let name = subdir
77            .path()
78            .file_name()
79            .and_then(|n| n.to_str())
80            .unwrap_or("");
81        let path = format!("{prefix}/{name}");
82        tree.insert_directory(&path);
83        populate_tree(tree, subdir, &path);
84    }
85}
86
87/// Lazily-initialized shared tree (built once on first access).
88#[cfg(all(feature = "embedded-platform-docs", everruns_has_workspace_docs))]
89fn docs_tree() -> Arc<VirtualFileTree> {
90    use std::sync::OnceLock;
91    static TREE: OnceLock<Arc<VirtualFileTree>> = OnceLock::new();
92    TREE.get_or_init(|| Arc::new(dir_to_tree(&DOCS_DIR, "/docs")))
93        .clone()
94}
95
96#[cfg(all(feature = "embedded-platform-docs", everruns_has_workspace_docs))]
97const SYSTEM_PROMPT: &str = r#"Capabilities extend agent/harness functionality. Three types: built-in, MCP servers, and skills. Use `read_capabilities` to discover IDs before creating agents/harnesses. All results include UI links.
98<platform-docs>
99Platform documentation is available at /workspace/docs in the session filesystem.
100Use `read_file`, `list_directory`, or `grep` to browse and search it.
101Bash commands like `cat /workspace/docs/...`, `ls /workspace/docs/`, and
102`grep -r "pattern" /workspace/docs/` also work.
103
104Key sections:
105- /workspace/docs/getting-started/ — Introduction, concepts, architecture, Docker setup
106- /workspace/docs/features/ — SDK, CLI, UI, events, harnesses, capabilities, apps, skills
107- /workspace/docs/capabilities/ — Per-capability reference (file-system, bashkit-shell, web-fetch, etc.)
108- /workspace/docs/integrations/ — External integrations (Slack, Daytona, Browserless, etc.)
109- /workspace/docs/advanced/ — Budgets, compaction, embedding, network access, request signing
110- /workspace/docs/sre/ — Environment variables, admin container, runbooks
111
112When the user asks about Everruns features, configuration, or how things work,
113consult these docs before answering.
114</platform-docs>"#;
115
116#[cfg(not(all(feature = "embedded-platform-docs", everruns_has_workspace_docs)))]
117const SYSTEM_PROMPT: &str = "Capabilities extend agent/harness functionality. Three types: built-in, MCP servers, and skills. Use `read_capabilities` to discover IDs before creating agents/harnesses. All results include UI links.";
118
119pub const PLATFORM_MANAGEMENT_CAPABILITY_ID: &str = "platform_management";
120
121pub struct PlatformManagementCapability;
122
123impl Capability for PlatformManagementCapability {
124    fn id(&self) -> &str {
125        PLATFORM_MANAGEMENT_CAPABILITY_ID
126    }
127
128    fn name(&self) -> &str {
129        "Platform Management"
130    }
131
132    fn description(&self) -> &str {
133        "Tools to manage harnesses, agents, apps, channels, and sessions. Create, list, update, delete entities and interact with sessions programmatically."
134    }
135
136    fn localizations(&self) -> Vec<CapabilityLocalization> {
137        vec![CapabilityLocalization::text(
138            "uk",
139            "Керування платформою",
140            "Інструменти для керування harness-ами, агентами, застосунками, каналами та сесіями. Створюйте, переглядайте, оновлюйте й видаляйте сутності та взаємодійте із сесіями програмно.",
141        )]
142    }
143
144    fn status(&self) -> CapabilityStatus {
145        CapabilityStatus::Available
146    }
147
148    fn icon(&self) -> Option<&str> {
149        Some("settings-2")
150    }
151
152    fn category(&self) -> Option<&str> {
153        Some("Platform")
154    }
155
156    fn system_prompt_addition(&self) -> Option<&str> {
157        Some(SYSTEM_PROMPT)
158    }
159
160    fn tools(&self) -> Vec<Box<dyn Tool>> {
161        vec![
162            Box::new(ReadCapabilitiesTool),
163            Box::new(ReadHarnessesTool),
164            Box::new(ManageHarnessesTool),
165            Box::new(ReadAgentsTool),
166            Box::new(ManageAgentsTool),
167            Box::new(ReadAppsTool),
168            Box::new(ManageAppsTool),
169            Box::new(ManageAppChannelsTool),
170            Box::new(ReadSessionsTool),
171            Box::new(SessionContextReportTool),
172            Box::new(ManageSessionsTool),
173            Box::new(SessionSendMessageTool),
174            Box::new(SessionReadMessagesTool),
175            Box::new(SessionReadResponseTool),
176        ]
177    }
178
179    fn mounts(&self) -> Vec<MountPoint> {
180        #[cfg(all(feature = "embedded-platform-docs", everruns_has_workspace_docs))]
181        {
182            vec![MountPoint::new(
183                "/docs",
184                MountAccess::ReadOnly,
185                MountSource::Virtual { tree: docs_tree() },
186                self.id(),
187            )]
188        }
189        #[cfg(not(all(feature = "embedded-platform-docs", everruns_has_workspace_docs)))]
190        {
191            Vec::new()
192        }
193    }
194
195    fn dependencies(&self) -> Vec<&'static str> {
196        #[cfg(all(feature = "embedded-platform-docs", everruns_has_workspace_docs))]
197        {
198            vec!["session_file_system"]
199        }
200        #[cfg(not(all(feature = "embedded-platform-docs", everruns_has_workspace_docs)))]
201        {
202            Vec::new()
203        }
204    }
205}
206
207// =============================================================================
208// Helper: argument extraction / typed-ID parsing / store lookup
209// =============================================================================
210//
211// Canonical scaffolding lives in `super::util`. Tool logic lives in `_impl`
212// functions returning `Result<ToolExecutionResult, ToolExecutionResult>` so the
213// helpers compose with `?`; each trait `execute_with_context` calls the `_impl`
214// via `.unwrap_or_else(|e| e)`.
215
216use super::util::{get_platform_store, get_str, parse_id, require_id, require_str};
217
218fn parse_bounded_usize_arg(
219    args: &Value,
220    key: &str,
221    default: usize,
222    max: usize,
223) -> Result<usize, ToolExecutionResult> {
224    match args.get(key).and_then(|v| v.as_u64()) {
225        Some(0) => Err(ToolExecutionResult::tool_error(format!(
226            "{key} must be greater than 0"
227        ))),
228        Some(value) => Ok((value as usize).min(max)),
229        None => Ok(default),
230    }
231}
232
233fn parse_channel_type(value: &str, field: &str) -> Result<ChannelType, ToolExecutionResult> {
234    serde_json::from_value(Value::String(value.to_string()))
235        .map_err(|_| ToolExecutionResult::tool_error(format!("Invalid {field}: {value}")))
236}
237
238fn truncate_content_chars(content: &str, limit: usize) -> (String, bool, usize, usize) {
239    let mut end_byte = content.len();
240    let mut returned_chars = 0;
241    let mut total_chars = 0;
242
243    for (idx, (byte_idx, _)) in content.char_indices().enumerate() {
244        total_chars = idx + 1;
245        if idx == limit {
246            end_byte = byte_idx;
247        }
248        if idx < limit {
249            returned_chars = idx + 1;
250        }
251    }
252
253    let truncated = total_chars > limit;
254    if !truncated {
255        return (content.to_string(), false, total_chars, total_chars);
256    }
257
258    (
259        content[..end_byte].to_string(),
260        true,
261        total_chars,
262        returned_chars,
263    )
264}
265
266fn channel_json(channel: &AppChannel, include_config: bool) -> Value {
267    let mut json = json!({
268        "id": channel.public_id.to_string(),
269        "channel_type": channel.channel_type.to_string(),
270        "enabled": channel.enabled,
271        "created_at": channel.created_at.to_rfc3339(),
272        "updated_at": channel.updated_at.to_rfc3339(),
273    });
274    if include_config {
275        json["channel_config"] = channel.channel_config.clone();
276    }
277    json
278}
279
280fn app_json(app: &App, base_url: &str, include_channel_config: bool) -> Value {
281    json!({
282        "id": app.public_id.to_string(),
283        "name": app.name,
284        "description": app.description,
285        "status": app.status.to_string(),
286        "harness_id": app.harness_id.to_string(),
287        "agent_id": app.agent_id.as_ref().map(|id| id.to_string()),
288        "agent_identity_id": app.agent_identity_id.as_ref().map(|id| id.to_string()),
289        "published_at": app.published_at.map(|value| value.to_rfc3339()),
290        "created_at": app.created_at.to_rfc3339(),
291        "updated_at": app.updated_at.to_rfc3339(),
292        "channel_count": app.channels.len(),
293        "channels": app
294            .channels
295            .iter()
296            .map(|channel| channel_json(channel, include_channel_config))
297            .collect::<Vec<_>>(),
298        "ui_link": format!("{}/apps/{}", base_url, app.public_id),
299    })
300}
301
302// =============================================================================
303// Tool: read_harnesses (read-only: get by ID or list all)
304// =============================================================================
305
306pub struct ReadHarnessesTool;
307
308#[async_trait]
309impl Tool for ReadHarnessesTool {
310    fn name(&self) -> &str {
311        "read_harnesses"
312    }
313
314    fn display_name(&self) -> Option<&str> {
315        Some("Read Harnesses")
316    }
317
318    fn description(&self) -> &str {
319        "Read harnesses by ID or list all. When id is provided returns full detail including system_prompt; otherwise returns summaries."
320    }
321
322    fn parameters_schema(&self) -> Value {
323        json!({
324            "type": "object",
325            "properties": {
326                "id": {
327                    "type": "string",
328                    "description": "Optional harness ID to get a single harness with full detail (incl. system_prompt)"
329                }
330            },
331            "additionalProperties": false
332        })
333    }
334
335    fn hints(&self) -> ToolHints {
336        ToolHints::default()
337            .with_readonly(true)
338            .with_idempotent(true)
339    }
340
341    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
342        ToolExecutionResult::tool_error(
343            "read_harnesses requires context. This tool must be executed with session context.",
344        )
345    }
346
347    async fn execute_with_context(
348        &self,
349        arguments: Value,
350        context: &ToolContext,
351    ) -> ToolExecutionResult {
352        read_harnesses_impl(arguments, context)
353            .await
354            .unwrap_or_else(|e| e)
355    }
356
357    fn requires_context(&self) -> bool {
358        true
359    }
360}
361
362async fn read_harnesses_impl(
363    arguments: Value,
364    context: &ToolContext,
365) -> Result<ToolExecutionResult, ToolExecutionResult> {
366    let store = get_platform_store(context)?;
367    let base_url = store.base_url();
368
369    if let Some(id_str) = get_str(&arguments, "id") {
370        let id: crate::typed_id::HarnessId = parse_id(id_str, "harness id")?;
371        return Ok(match store.get_harness(id).await {
372            Ok(Some(h)) => ToolExecutionResult::success(json!({
373                "id": h.id.to_string(),
374                "name": h.name,
375                "display_name": h.display_name,
376                "description": h.description,
377                "system_prompt": h.system_prompt,
378                "status": format!("{:?}", h.status),
379                "capabilities": h.capabilities.iter().map(|c| c.capability_id().to_string()).collect::<Vec<_>>(),
380                "tags": h.tags,
381                "ui_link": format!("{}/harnesses/{}", base_url, h.id),
382            })),
383            Ok(None) => ToolExecutionResult::tool_error(format!("Harness not found: {id_str}")),
384            Err(e) => ToolExecutionResult::tool_error(format!("Failed to get harness: {e}")),
385        });
386    }
387
388    Ok(match store.list_harnesses().await {
389        Ok(harnesses) => {
390            let items: Vec<Value> = harnesses
391                .iter()
392                .map(|h| {
393                    json!({
394                        "id": h.id.to_string(),
395                        "name": h.name,
396                        "display_name": h.display_name,
397                        "description": h.description,
398                        "status": format!("{:?}", h.status),
399                        "capabilities": h.capabilities.iter().map(|c| c.capability_id().to_string()).collect::<Vec<_>>(),
400                        "tags": h.tags,
401                        "ui_link": format!("{}/harnesses/{}", base_url, h.id),
402                    })
403                })
404                .collect();
405            ToolExecutionResult::success(json!({"harnesses": items, "count": items.len()}))
406        }
407        Err(e) => ToolExecutionResult::tool_error(format!("Failed to list harnesses: {e}")),
408    })
409}
410
411// =============================================================================
412// Tool: manage_harnesses (mutations: create, update, delete, destroy, copy)
413// =============================================================================
414
415pub struct ManageHarnessesTool;
416
417#[async_trait]
418impl Tool for ManageHarnessesTool {
419    fn name(&self) -> &str {
420        "manage_harnesses"
421    }
422
423    fn display_name(&self) -> Option<&str> {
424        Some("Manage Harnesses")
425    }
426
427    fn description(&self) -> &str {
428        "Harness mutations: create, update, delete, destroy, copy."
429    }
430
431    fn parameters_schema(&self) -> Value {
432        json!({
433            "type": "object",
434            "properties": {
435                "operation": {
436                    "type": "string",
437                    "enum": ["create", "update", "delete", "copy"],
438                    "description": "The mutation to perform"
439                },
440                "harness_id": {
441                    "type": "string",
442                    "description": "Harness ID (required for update, delete, copy)"
443                },
444                "name": {
445                    "type": "string",
446                    "description": "Harness name (required for create, optional for update/copy)"
447                },
448                "new_name": {
449                    "type": "string",
450                    "description": "New name when copying a harness"
451                },
452                "description": {
453                    "type": "string",
454                    "description": "Harness description"
455                },
456                "system_prompt": {
457                    "type": "string",
458                    "description": "Optional base system prompt for the harness. Omit to contribute no base prompt — the effective prompt then comes from the parent harness, agent, session, and capabilities."
459                },
460                "parent_harness_id": {
461                    "type": ["string", "null"],
462                    "description": "Optional parent harness ID. Set to null on update to clear inheritance."
463                },
464                "capabilities": {
465                    "type": "array",
466                    "items": {"type": "string"},
467                    "description": "List of capability IDs"
468                }
469            },
470            "required": ["operation"],
471            "additionalProperties": false
472        })
473    }
474
475    fn hints(&self) -> ToolHints {
476        ToolHints::default().with_narration_noun("harness")
477    }
478
479    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
480        ToolExecutionResult::tool_error(
481            "manage_harnesses requires context. This tool must be executed with session context.",
482        )
483    }
484
485    async fn execute_with_context(
486        &self,
487        arguments: Value,
488        context: &ToolContext,
489    ) -> ToolExecutionResult {
490        manage_harnesses_impl(arguments, context)
491            .await
492            .unwrap_or_else(|e| e)
493    }
494
495    fn requires_context(&self) -> bool {
496        true
497    }
498}
499
500async fn manage_harnesses_impl(
501    arguments: Value,
502    context: &ToolContext,
503) -> Result<ToolExecutionResult, ToolExecutionResult> {
504    let store = get_platform_store(context)?;
505    let operation = require_str(&arguments, "operation")?;
506    let base_url = store.base_url();
507
508    Ok(match operation {
509        "create" => {
510            let name = require_str(&arguments, "name")?;
511            let display_name = require_str(&arguments, "display_name")?;
512            // Optional: when omitted the harness contributes no base prompt.
513            let system_prompt = get_str(&arguments, "system_prompt");
514            let description = get_str(&arguments, "description");
515            let parent_harness_id = match arguments.get("parent_harness_id") {
516                Some(Value::String(id_str)) => Some(parse_id::<crate::typed_id::HarnessId>(
517                    id_str,
518                    "parent_harness_id",
519                )?),
520                Some(Value::Null) | None => None,
521                Some(_) => {
522                    return Ok(ToolExecutionResult::tool_error(
523                        "parent_harness_id must be a harness ID string or null",
524                    ));
525                }
526            };
527            let capabilities: Vec<String> = arguments
528                .get("capabilities")
529                .and_then(|v| v.as_array())
530                .map(|arr| {
531                    arr.iter()
532                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
533                        .collect()
534                })
535                .unwrap_or_default();
536            match store
537                .create_harness(
538                    name,
539                    Some(display_name),
540                    description,
541                    system_prompt,
542                    parent_harness_id,
543                    &capabilities,
544                )
545                .await
546            {
547                Ok(h) => ToolExecutionResult::success(json!({
548                    "id": h.id.to_string(),
549                    "name": h.name,
550                    "display_name": h.display_name,
551                    "description": h.description,
552                    "parent_harness_id": h.parent_harness_id.map(|id| id.to_string()),
553                    "status": format!("{:?}", h.status),
554                    "ui_link": format!("{}/harnesses/{}", base_url, h.id),
555                    "message": "Harness created successfully"
556                })),
557                Err(e) => ToolExecutionResult::tool_error(format!("Failed to create harness: {e}")),
558            }
559        }
560
561        "update" => {
562            let id_str = require_str(&arguments, "harness_id")?;
563            let id: crate::typed_id::HarnessId = parse_id(id_str, "harness_id")?;
564            let name = get_str(&arguments, "name");
565            let display_name = get_str(&arguments, "display_name");
566            let description = get_str(&arguments, "description");
567            let system_prompt = get_str(&arguments, "system_prompt");
568            let parent_harness_id = match arguments.get("parent_harness_id") {
569                Some(Value::String(id_str)) => Some(Some(parse_id::<crate::typed_id::HarnessId>(
570                    id_str,
571                    "parent_harness_id",
572                )?)),
573                Some(Value::Null) => Some(None),
574                None => None,
575                Some(_) => {
576                    return Ok(ToolExecutionResult::tool_error(
577                        "parent_harness_id must be a harness ID string or null",
578                    ));
579                }
580            };
581            match store
582                .update_harness(
583                    id,
584                    name,
585                    display_name,
586                    description,
587                    system_prompt,
588                    parent_harness_id,
589                )
590                .await
591            {
592                Ok(h) => ToolExecutionResult::success(json!({
593                    "id": h.id.to_string(),
594                    "name": h.name,
595                    "display_name": h.display_name,
596                    "description": h.description,
597                    "parent_harness_id": h.parent_harness_id.map(|id| id.to_string()),
598                    "status": format!("{:?}", h.status),
599                    "ui_link": format!("{}/harnesses/{}", base_url, h.id),
600                    "message": "Harness updated successfully"
601                })),
602                Err(e) => ToolExecutionResult::tool_error(format!("Failed to update harness: {e}")),
603            }
604        }
605
606        "delete" => {
607            let id_str = require_str(&arguments, "harness_id")?;
608            let id: crate::typed_id::HarnessId = parse_id(id_str, "harness_id")?;
609            match store.delete_harness(id).await {
610                Ok(()) => ToolExecutionResult::success(json!({
611                    "harness_id": id_str,
612                    "ui_link": format!("{}/harnesses/{}", base_url, id_str),
613                    "message": "Harness archived successfully"
614                })),
615                Err(e) => ToolExecutionResult::tool_error(format!("Failed to delete harness: {e}")),
616            }
617        }
618
619        "copy" => {
620            let id_str = require_str(&arguments, "harness_id")?;
621            let id: crate::typed_id::HarnessId = parse_id(id_str, "harness_id")?;
622            let new_name = get_str(&arguments, "new_name");
623            match store.copy_harness(id, new_name).await {
624                Ok(h) => ToolExecutionResult::success(json!({
625                    "id": h.id.to_string(),
626                    "name": h.name,
627                    "display_name": h.display_name,
628                    "description": h.description,
629                    "status": format!("{:?}", h.status),
630                    "ui_link": format!("{}/harnesses/{}", base_url, h.id),
631                    "source_harness_id": id_str,
632                    "message": "Harness copied successfully"
633                })),
634                Err(e) => ToolExecutionResult::tool_error(format!("Failed to copy harness: {e}")),
635            }
636        }
637
638        _ => ToolExecutionResult::tool_error(format!(
639            "Unknown operation: {operation}. Valid: create, update, delete, copy"
640        )),
641    })
642}
643
644// =============================================================================
645// Tool: read_agents (read-only: get by ID or list all)
646// =============================================================================
647
648pub struct ReadAgentsTool;
649
650#[async_trait]
651impl Tool for ReadAgentsTool {
652    fn name(&self) -> &str {
653        "read_agents"
654    }
655
656    fn display_name(&self) -> Option<&str> {
657        Some("Read Agents")
658    }
659
660    fn description(&self) -> &str {
661        "Read agents by ID or list all. When id is provided returns full detail including system_prompt; otherwise returns summaries."
662    }
663
664    fn parameters_schema(&self) -> Value {
665        json!({
666            "type": "object",
667            "properties": {
668                "id": {
669                    "type": "string",
670                    "description": "Optional agent ID to get a single agent with full detail (incl. system_prompt)"
671                }
672            },
673            "additionalProperties": false
674        })
675    }
676
677    fn hints(&self) -> ToolHints {
678        ToolHints::default()
679            .with_readonly(true)
680            .with_idempotent(true)
681    }
682
683    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
684        ToolExecutionResult::tool_error(
685            "read_agents requires context. This tool must be executed with session context.",
686        )
687    }
688
689    async fn execute_with_context(
690        &self,
691        arguments: Value,
692        context: &ToolContext,
693    ) -> ToolExecutionResult {
694        read_agents_impl(arguments, context)
695            .await
696            .unwrap_or_else(|e| e)
697    }
698
699    fn requires_context(&self) -> bool {
700        true
701    }
702}
703
704async fn read_agents_impl(
705    arguments: Value,
706    context: &ToolContext,
707) -> Result<ToolExecutionResult, ToolExecutionResult> {
708    let store = get_platform_store(context)?;
709    let base_url = store.base_url();
710
711    if let Some(id_str) = get_str(&arguments, "id") {
712        let id: crate::typed_id::AgentId = parse_id(id_str, "agent id")?;
713        return Ok(match store.get_agent_by_id(id).await {
714            Ok(Some(a)) => ToolExecutionResult::success(json!({
715                "id": a.public_id.to_string(),
716                "name": a.name,
717                "display_name": a.display_name,
718                "description": a.description,
719                "system_prompt": a.system_prompt,
720                "status": format!("{:?}", a.status),
721                "capabilities": a.capabilities.iter().map(|c| c.capability_id().to_string()).collect::<Vec<_>>(),
722                "tags": a.tags,
723                "ui_link": format!("{}/agents/{}", base_url, a.public_id),
724            })),
725            Ok(None) => ToolExecutionResult::tool_error(format!("Agent not found: {id_str}")),
726            Err(e) => ToolExecutionResult::tool_error(format!("Failed to get agent: {e}")),
727        });
728    }
729
730    Ok(match store.list_agents().await {
731        Ok(agents) => {
732            let items: Vec<Value> = agents
733                .iter()
734                .map(|a| {
735                    json!({
736                        "id": a.public_id.to_string(),
737                        "name": a.name,
738                        "display_name": a.display_name,
739                        "description": a.description,
740                        "status": format!("{:?}", a.status),
741                        "capabilities": a.capabilities.iter().map(|c| c.capability_id().to_string()).collect::<Vec<_>>(),
742                        "tags": a.tags,
743                        "ui_link": format!("{}/agents/{}", base_url, a.public_id),
744                    })
745                })
746                .collect();
747            ToolExecutionResult::success(json!({"agents": items, "count": items.len()}))
748        }
749        Err(e) => ToolExecutionResult::tool_error(format!("Failed to list agents: {e}")),
750    })
751}
752
753// =============================================================================
754// Tool: manage_agents (mutations: create, update, delete, destroy)
755// =============================================================================
756
757pub struct ManageAgentsTool;
758
759#[async_trait]
760impl Tool for ManageAgentsTool {
761    fn name(&self) -> &str {
762        "manage_agents"
763    }
764
765    fn display_name(&self) -> Option<&str> {
766        Some("Manage Agents")
767    }
768
769    fn description(&self) -> &str {
770        "Agent mutations: create, update, delete, destroy."
771    }
772
773    fn parameters_schema(&self) -> Value {
774        json!({
775            "type": "object",
776            "properties": {
777                "operation": {
778                    "type": "string",
779                    "enum": ["create", "update", "delete"],
780                    "description": "The mutation to perform"
781                },
782                "agent_id": {
783                    "type": "string",
784                    "description": "Agent ID (required for update, delete)"
785                },
786                "name": {
787                    "type": "string",
788                    "description": "Addressable agent name (required for create). Lowercase letters, numbers, and hyphens only (e.g. 'customer-support')."
789                },
790                "display_name": {
791                    "type": "string",
792                    "description": "Human-readable display name shown in UI (e.g. 'Customer Support Agent'). Falls back to name when absent."
793                },
794                "description": {
795                    "type": "string",
796                    "description": "Agent description"
797                },
798                "system_prompt": {
799                    "type": "string",
800                    "description": "System prompt for the agent. Defaults to 'You are a helpful assistant.' if omitted."
801                },
802                "capabilities": {
803                    "type": "array",
804                    "items": {"type": "string"},
805                    "description": "List of capability IDs"
806                }
807            },
808            "required": ["operation"],
809            "additionalProperties": false
810        })
811    }
812
813    fn hints(&self) -> ToolHints {
814        ToolHints::default().with_narration_noun("agent")
815    }
816
817    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
818        ToolExecutionResult::tool_error(
819            "manage_agents requires context. This tool must be executed with session context.",
820        )
821    }
822
823    async fn execute_with_context(
824        &self,
825        arguments: Value,
826        context: &ToolContext,
827    ) -> ToolExecutionResult {
828        manage_agents_impl(arguments, context)
829            .await
830            .unwrap_or_else(|e| e)
831    }
832
833    fn requires_context(&self) -> bool {
834        true
835    }
836}
837
838async fn manage_agents_impl(
839    arguments: Value,
840    context: &ToolContext,
841) -> Result<ToolExecutionResult, ToolExecutionResult> {
842    let store = get_platform_store(context)?;
843    let operation = require_str(&arguments, "operation")?;
844    let base_url = store.base_url();
845
846    Ok(match operation {
847        "create" => {
848            let name = require_str(&arguments, "name")?;
849            if let Err(msg) = crate::agent::validate_addressable_name(name) {
850                return Ok(ToolExecutionResult::tool_error(format!(
851                    "Invalid agent name: {msg}"
852                )));
853            }
854            let display_name = get_str(&arguments, "display_name");
855            let system_prompt =
856                get_str(&arguments, "system_prompt").unwrap_or("You are a helpful assistant.");
857            let description = get_str(&arguments, "description");
858            let capabilities: Vec<String> = arguments
859                .get("capabilities")
860                .and_then(|v| v.as_array())
861                .map(|arr| {
862                    arr.iter()
863                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
864                        .collect()
865                })
866                .unwrap_or_default();
867            match store
868                .create_agent(
869                    name,
870                    display_name,
871                    description,
872                    system_prompt,
873                    &capabilities,
874                )
875                .await
876            {
877                Ok(a) => ToolExecutionResult::success(json!({
878                    "id": a.public_id.to_string(),
879                    "name": a.name,
880                    "display_name": a.display_name,
881                    "description": a.description,
882                    "status": format!("{:?}", a.status),
883                    "ui_link": format!("{}/agents/{}", base_url, a.public_id),
884                    "message": "Agent created successfully"
885                })),
886                Err(e) => ToolExecutionResult::tool_error(format!("Failed to create agent: {e}")),
887            }
888        }
889
890        "update" => {
891            let id_str = require_str(&arguments, "agent_id")?;
892            let id: crate::typed_id::AgentId = parse_id(id_str, "agent_id")?;
893            let name = get_str(&arguments, "name");
894            if let Some(n) = name
895                && let Err(msg) = crate::agent::validate_addressable_name(n)
896            {
897                return Ok(ToolExecutionResult::tool_error(format!(
898                    "Invalid agent name: {msg}"
899                )));
900            }
901            let display_name = get_str(&arguments, "display_name");
902            let description = get_str(&arguments, "description");
903            let system_prompt = get_str(&arguments, "system_prompt");
904            match store
905                .update_agent(id, name, display_name, description, system_prompt)
906                .await
907            {
908                Ok(a) => ToolExecutionResult::success(json!({
909                    "id": a.public_id.to_string(),
910                    "name": a.name,
911                    "display_name": a.display_name,
912                    "description": a.description,
913                    "status": format!("{:?}", a.status),
914                    "ui_link": format!("{}/agents/{}", base_url, a.public_id),
915                    "message": "Agent updated successfully"
916                })),
917                Err(e) => ToolExecutionResult::tool_error(format!("Failed to update agent: {e}")),
918            }
919        }
920
921        "delete" => {
922            let id_str = require_str(&arguments, "agent_id")?;
923            let id: crate::typed_id::AgentId = parse_id(id_str, "agent_id")?;
924            match store.delete_agent(id).await {
925                Ok(()) => ToolExecutionResult::success(json!({
926                    "agent_id": id_str,
927                    "ui_link": format!("{}/agents/{}", base_url, id_str),
928                    "message": "Agent archived successfully"
929                })),
930                Err(e) => ToolExecutionResult::tool_error(format!("Failed to delete agent: {e}")),
931            }
932        }
933
934        _ => ToolExecutionResult::tool_error(format!(
935            "Unknown operation: {operation}. Valid: create, update, delete"
936        )),
937    })
938}
939
940// =============================================================================
941// Tool: read_apps (read-only: get by ID or list/filter)
942// =============================================================================
943
944pub struct ReadAppsTool;
945
946#[async_trait]
947impl Tool for ReadAppsTool {
948    fn name(&self) -> &str {
949        "read_apps"
950    }
951
952    fn display_name(&self) -> Option<&str> {
953        Some("Read Apps")
954    }
955
956    fn description(&self) -> &str {
957        "Read apps by ID or list/filter. When id is provided returns full app detail including channels."
958    }
959
960    fn parameters_schema(&self) -> Value {
961        json!({
962            "type": "object",
963            "properties": {
964                "id": {
965                    "type": "string",
966                    "description": "Optional app ID to get a single app with channel details"
967                },
968                "search": {
969                    "type": "string",
970                    "description": "Optional case-insensitive search by app name or description"
971                },
972                "include_archived": {
973                    "type": "boolean",
974                    "description": "Include archived apps in list results (default: false)"
975                }
976            },
977            "additionalProperties": false
978        })
979    }
980
981    fn hints(&self) -> ToolHints {
982        ToolHints::default()
983            .with_readonly(true)
984            .with_idempotent(true)
985    }
986
987    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
988        ToolExecutionResult::tool_error(
989            "read_apps requires context. This tool must be executed with session context.",
990        )
991    }
992
993    async fn execute_with_context(
994        &self,
995        arguments: Value,
996        context: &ToolContext,
997    ) -> ToolExecutionResult {
998        read_apps_impl(arguments, context)
999            .await
1000            .unwrap_or_else(|e| e)
1001    }
1002
1003    fn requires_context(&self) -> bool {
1004        true
1005    }
1006}
1007
1008async fn read_apps_impl(
1009    arguments: Value,
1010    context: &ToolContext,
1011) -> Result<ToolExecutionResult, ToolExecutionResult> {
1012    let store = get_platform_store(context)?;
1013    let base_url = store.base_url();
1014
1015    if let Some(id_str) = get_str(&arguments, "id") {
1016        let id: crate::typed_id::AppId = parse_id(id_str, "app id")?;
1017        return Ok(match store.get_app(id).await {
1018            Ok(Some(app)) => ToolExecutionResult::success(app_json(&app, base_url, true)),
1019            Ok(None) => ToolExecutionResult::tool_error(format!("App not found: {id_str}")),
1020            Err(e) => ToolExecutionResult::tool_error(format!("Failed to get app: {e}")),
1021        });
1022    }
1023
1024    let search = get_str(&arguments, "search");
1025    let include_archived = arguments
1026        .get("include_archived")
1027        .and_then(|value| value.as_bool())
1028        .unwrap_or(false);
1029    Ok(match store.list_apps(search, include_archived).await {
1030        Ok(apps) => {
1031            let items = apps
1032                .iter()
1033                .map(|app| app_json(app, base_url, false))
1034                .collect::<Vec<_>>();
1035            ToolExecutionResult::success(json!({"apps": items, "count": items.len()}))
1036        }
1037        Err(e) => ToolExecutionResult::tool_error(format!("Failed to list apps: {e}")),
1038    })
1039}
1040
1041// =============================================================================
1042// Tool: manage_apps (mutations: create, update, delete, destroy, publish, unpublish)
1043// =============================================================================
1044
1045pub struct ManageAppsTool;
1046
1047#[async_trait]
1048impl Tool for ManageAppsTool {
1049    fn name(&self) -> &str {
1050        "manage_apps"
1051    }
1052
1053    fn display_name(&self) -> Option<&str> {
1054        Some("Manage Apps")
1055    }
1056
1057    fn description(&self) -> &str {
1058        "App mutations: create, update, delete (archive), destroy, publish, unpublish."
1059    }
1060
1061    fn parameters_schema(&self) -> Value {
1062        json!({
1063            "type": "object",
1064            "properties": {
1065                "operation": {
1066                    "type": "string",
1067                    "enum": ["create", "update", "delete", "destroy", "publish", "unpublish"],
1068                    "description": "The mutation to perform"
1069                },
1070                "app_id": {
1071                    "type": "string",
1072                    "description": "App ID (required for update/delete/destroy/publish/unpublish)"
1073                },
1074                "name": {
1075                    "type": "string",
1076                    "description": "App name (required for create)"
1077                },
1078                "description": {
1079                    "type": "string",
1080                    "description": "App description (optional)"
1081                },
1082                "harness_id": {
1083                    "type": "string",
1084                    "description": "Harness ID (required for create)"
1085                },
1086                "agent_id": {
1087                    "type": "string",
1088                    "description": "Optional agent ID"
1089                },
1090                "agent_identity_id": {
1091                    "type": ["string", "null"],
1092                    "description": "Optional agent identity ID. Pass null on update to clear it."
1093                },
1094                "channel_type": {
1095                    "type": "string",
1096                    "enum": ["slack", "ag_ui", "schedule", "webhook", "a2a", "fcp"],
1097                    "description": "Optional initial channel type for create"
1098                },
1099                "channel_config": {
1100                    "type": "object",
1101                    "description": "Optional initial channel configuration for create"
1102                }
1103            },
1104            "required": ["operation"],
1105            "additionalProperties": false
1106        })
1107    }
1108
1109    fn hints(&self) -> ToolHints {
1110        ToolHints::default().with_narration_noun("app")
1111    }
1112
1113    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1114        ToolExecutionResult::tool_error(
1115            "manage_apps requires context. This tool must be executed with session context.",
1116        )
1117    }
1118
1119    async fn execute_with_context(
1120        &self,
1121        arguments: Value,
1122        context: &ToolContext,
1123    ) -> ToolExecutionResult {
1124        manage_apps_impl(arguments, context)
1125            .await
1126            .unwrap_or_else(|e| e)
1127    }
1128
1129    fn requires_context(&self) -> bool {
1130        true
1131    }
1132}
1133
1134async fn manage_apps_impl(
1135    arguments: Value,
1136    context: &ToolContext,
1137) -> Result<ToolExecutionResult, ToolExecutionResult> {
1138    let store = get_platform_store(context)?;
1139    let operation = require_str(&arguments, "operation")?;
1140    let base_url = store.base_url();
1141
1142    Ok(match operation {
1143        "create" => {
1144            let name = require_str(&arguments, "name")?;
1145            let harness_id_str = require_str(&arguments, "harness_id")?;
1146            let harness_id: crate::typed_id::HarnessId = parse_id(harness_id_str, "harness_id")?;
1147            let description = get_str(&arguments, "description");
1148            let agent_id = match get_str(&arguments, "agent_id") {
1149                Some(value) => Some(parse_id::<crate::typed_id::AgentId>(value, "agent_id")?),
1150                None => None,
1151            };
1152            let agent_identity_id = if let Some(value) = arguments.get("agent_identity_id") {
1153                if value.is_null() {
1154                    None
1155                } else if let Some(value) = value.as_str() {
1156                    Some(parse_id::<crate::typed_id::AgentIdentityId>(
1157                        value,
1158                        "agent_identity_id",
1159                    )?)
1160                } else {
1161                    return Ok(ToolExecutionResult::tool_error(
1162                        "agent_identity_id must be a string or null",
1163                    ));
1164                }
1165            } else {
1166                None
1167            };
1168            let channel_type = match get_str(&arguments, "channel_type") {
1169                Some(value) => Some(parse_channel_type(value, "channel_type")?),
1170                None => None,
1171            };
1172            let channel_config = arguments.get("channel_config");
1173
1174            match store
1175                .create_app(
1176                    name,
1177                    description,
1178                    harness_id,
1179                    agent_id,
1180                    agent_identity_id,
1181                    channel_type,
1182                    channel_config,
1183                )
1184                .await
1185            {
1186                Ok(app) => {
1187                    let mut response = app_json(&app, base_url, true);
1188                    response["message"] = Value::String("App created successfully".to_string());
1189                    ToolExecutionResult::success(response)
1190                }
1191                Err(e) => ToolExecutionResult::tool_error(format!("Failed to create app: {e}")),
1192            }
1193        }
1194
1195        "update" => {
1196            let app_id_str = require_str(&arguments, "app_id")?;
1197            let app_id: crate::typed_id::AppId = parse_id(app_id_str, "app_id")?;
1198            let harness_id = match get_str(&arguments, "harness_id") {
1199                Some(value) => Some(parse_id::<crate::typed_id::HarnessId>(value, "harness_id")?),
1200                None => None,
1201            };
1202            let agent_id = match get_str(&arguments, "agent_id") {
1203                Some(value) => Some(parse_id::<crate::typed_id::AgentId>(value, "agent_id")?),
1204                None => None,
1205            };
1206            let agent_identity_id = if let Some(value) = arguments.get("agent_identity_id") {
1207                if value.is_null() {
1208                    Some(None)
1209                } else if let Some(value) = value.as_str() {
1210                    Some(Some(parse_id::<crate::typed_id::AgentIdentityId>(
1211                        value,
1212                        "agent_identity_id",
1213                    )?))
1214                } else {
1215                    return Ok(ToolExecutionResult::tool_error(
1216                        "agent_identity_id must be a string or null",
1217                    ));
1218                }
1219            } else {
1220                None
1221            };
1222
1223            match store
1224                .update_app(
1225                    app_id,
1226                    get_str(&arguments, "name"),
1227                    get_str(&arguments, "description"),
1228                    harness_id,
1229                    agent_id,
1230                    agent_identity_id,
1231                )
1232                .await
1233            {
1234                Ok(app) => {
1235                    let mut response = app_json(&app, base_url, true);
1236                    response["message"] = Value::String("App updated successfully".to_string());
1237                    ToolExecutionResult::success(response)
1238                }
1239                Err(e) => ToolExecutionResult::tool_error(format!("Failed to update app: {e}")),
1240            }
1241        }
1242
1243        "delete" => {
1244            let app_id_str = require_str(&arguments, "app_id")?;
1245            let app_id: crate::typed_id::AppId = parse_id(app_id_str, "app_id")?;
1246            match store.delete_app(app_id).await {
1247                Ok(()) => ToolExecutionResult::success(json!({
1248                    "app_id": app_id_str,
1249                    "ui_link": format!("{}/apps/{}", base_url, app_id_str),
1250                    "message": "App archived successfully"
1251                })),
1252                Err(e) => ToolExecutionResult::tool_error(format!("Failed to archive app: {e}")),
1253            }
1254        }
1255
1256        "destroy" => {
1257            let app_id_str = require_str(&arguments, "app_id")?;
1258            let app_id: crate::typed_id::AppId = parse_id(app_id_str, "app_id")?;
1259            match store.destroy_app(app_id).await {
1260                Ok(()) => ToolExecutionResult::success(json!({
1261                    "app_id": app_id_str,
1262                    "ui_link": format!("{}/apps", base_url),
1263                    "message": "App destroyed successfully"
1264                })),
1265                Err(e) => ToolExecutionResult::tool_error(format!("Failed to destroy app: {e}")),
1266            }
1267        }
1268
1269        "publish" => {
1270            let app_id_str = require_str(&arguments, "app_id")?;
1271            let app_id: crate::typed_id::AppId = parse_id(app_id_str, "app_id")?;
1272            match store.publish_app(app_id).await {
1273                Ok(app) => {
1274                    let mut response = app_json(&app, base_url, true);
1275                    response["message"] = Value::String("App published successfully".to_string());
1276                    ToolExecutionResult::success(response)
1277                }
1278                Err(e) => ToolExecutionResult::tool_error(format!("Failed to publish app: {e}")),
1279            }
1280        }
1281
1282        "unpublish" => {
1283            let app_id_str = require_str(&arguments, "app_id")?;
1284            let app_id: crate::typed_id::AppId = parse_id(app_id_str, "app_id")?;
1285            match store.unpublish_app(app_id).await {
1286                Ok(app) => {
1287                    let mut response = app_json(&app, base_url, true);
1288                    response["message"] = Value::String("App unpublished successfully".to_string());
1289                    ToolExecutionResult::success(response)
1290                }
1291                Err(e) => ToolExecutionResult::tool_error(format!("Failed to unpublish app: {e}")),
1292            }
1293        }
1294
1295        _ => ToolExecutionResult::tool_error(format!(
1296            "Unknown operation: {operation}. Valid: create, update, delete, destroy, publish, unpublish"
1297        )),
1298    })
1299}
1300
1301// =============================================================================
1302// Tool: manage_app_channels (mutations: add, update, delete)
1303// =============================================================================
1304
1305pub struct ManageAppChannelsTool;
1306
1307#[async_trait]
1308impl Tool for ManageAppChannelsTool {
1309    fn name(&self) -> &str {
1310        "manage_app_channels"
1311    }
1312
1313    fn display_name(&self) -> Option<&str> {
1314        Some("Manage App Channels")
1315    }
1316
1317    fn description(&self) -> &str {
1318        "App channel mutations: add, update, delete."
1319    }
1320
1321    fn parameters_schema(&self) -> Value {
1322        json!({
1323            "type": "object",
1324            "properties": {
1325                "operation": {
1326                    "type": "string",
1327                    "enum": ["add", "update", "delete"],
1328                    "description": "The channel mutation to perform"
1329                },
1330                "app_id": {
1331                    "type": "string",
1332                    "description": "App ID"
1333                },
1334                "channel_id": {
1335                    "type": "string",
1336                    "description": "Channel ID (required for update/delete)"
1337                },
1338                "channel_type": {
1339                    "type": "string",
1340                    "enum": ["slack", "ag_ui", "schedule", "webhook", "a2a", "fcp"],
1341                    "description": "Channel type (required for add, optional for update)"
1342                },
1343                "channel_config": {
1344                    "type": "object",
1345                    "description": "Channel-specific configuration object"
1346                },
1347                "enabled": {
1348                    "type": "boolean",
1349                    "description": "Whether the channel is enabled"
1350                }
1351            },
1352            "required": ["operation", "app_id"],
1353            "additionalProperties": false
1354        })
1355    }
1356
1357    fn hints(&self) -> ToolHints {
1358        ToolHints::default().with_narration_noun("app channel")
1359    }
1360
1361    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1362        ToolExecutionResult::tool_error(
1363            "manage_app_channels requires context. This tool must be executed with session context.",
1364        )
1365    }
1366
1367    async fn execute_with_context(
1368        &self,
1369        arguments: Value,
1370        context: &ToolContext,
1371    ) -> ToolExecutionResult {
1372        manage_app_channels_impl(arguments, context)
1373            .await
1374            .unwrap_or_else(|e| e)
1375    }
1376
1377    fn requires_context(&self) -> bool {
1378        true
1379    }
1380}
1381
1382async fn manage_app_channels_impl(
1383    arguments: Value,
1384    context: &ToolContext,
1385) -> Result<ToolExecutionResult, ToolExecutionResult> {
1386    let store = get_platform_store(context)?;
1387    let operation = require_str(&arguments, "operation")?;
1388    let app_id_str = require_str(&arguments, "app_id")?;
1389    let app_id: crate::typed_id::AppId = parse_id(app_id_str, "app_id")?;
1390    let base_url = store.base_url();
1391
1392    Ok(match operation {
1393        "add" => {
1394            let channel_type_str = require_str(&arguments, "channel_type")?;
1395            let channel_type = parse_channel_type(channel_type_str, "channel_type")?;
1396            let channel_config = arguments.get("channel_config");
1397            let enabled = arguments.get("enabled").and_then(|value| value.as_bool());
1398            match store
1399                .add_app_channel(app_id, channel_type, channel_config, enabled)
1400                .await
1401            {
1402                Ok(channel) => ToolExecutionResult::success(json!({
1403                    "app_id": app_id_str,
1404                    "channel": channel_json(&channel, true),
1405                    "ui_link": format!("{}/apps/{}", base_url, app_id),
1406                    "message": "App channel added successfully"
1407                })),
1408                Err(e) => {
1409                    ToolExecutionResult::tool_error(format!("Failed to add app channel: {e}"))
1410                }
1411            }
1412        }
1413
1414        "update" => {
1415            let channel_id_str = require_str(&arguments, "channel_id")?;
1416            let channel_id: crate::typed_id::AppChannelId = parse_id(channel_id_str, "channel_id")?;
1417            let channel_type = match get_str(&arguments, "channel_type") {
1418                Some(value) => Some(parse_channel_type(value, "channel_type")?),
1419                None => None,
1420            };
1421            let channel_config = arguments.get("channel_config");
1422            let enabled = arguments.get("enabled").and_then(|value| value.as_bool());
1423            match store
1424                .update_app_channel(app_id, channel_id, channel_type, channel_config, enabled)
1425                .await
1426            {
1427                Ok(channel) => ToolExecutionResult::success(json!({
1428                    "app_id": app_id_str,
1429                    "channel": channel_json(&channel, true),
1430                    "ui_link": format!("{}/apps/{}", base_url, app_id),
1431                    "message": "App channel updated successfully"
1432                })),
1433                Err(e) => {
1434                    ToolExecutionResult::tool_error(format!("Failed to update app channel: {e}"))
1435                }
1436            }
1437        }
1438
1439        "delete" => {
1440            let channel_id_str = require_str(&arguments, "channel_id")?;
1441            let channel_id: crate::typed_id::AppChannelId = parse_id(channel_id_str, "channel_id")?;
1442            match store.delete_app_channel(app_id, channel_id).await {
1443                Ok(()) => ToolExecutionResult::success(json!({
1444                    "app_id": app_id_str,
1445                    "channel_id": channel_id_str,
1446                    "ui_link": format!("{}/apps/{}", base_url, app_id),
1447                    "message": "App channel deleted successfully"
1448                })),
1449                Err(e) => {
1450                    ToolExecutionResult::tool_error(format!("Failed to delete app channel: {e}"))
1451                }
1452            }
1453        }
1454
1455        _ => ToolExecutionResult::tool_error(format!(
1456            "Unknown operation: {operation}. Valid: add, update, delete"
1457        )),
1458    })
1459}
1460
1461// =============================================================================
1462// Tool: read_sessions (read-only: get by ID or list/filter)
1463// =============================================================================
1464
1465pub struct ReadSessionsTool;
1466
1467#[async_trait]
1468impl Tool for ReadSessionsTool {
1469    fn name(&self) -> &str {
1470        "read_sessions"
1471    }
1472
1473    fn display_name(&self) -> Option<&str> {
1474        Some("Read Sessions")
1475    }
1476
1477    fn description(&self) -> &str {
1478        "Read sessions by ID or list/filter. When id is provided returns a single session; otherwise returns a filtered list."
1479    }
1480
1481    fn parameters_schema(&self) -> Value {
1482        json!({
1483            "type": "object",
1484            "properties": {
1485                "id": {
1486                    "type": "string",
1487                    "description": "Optional session ID to get a single session"
1488                },
1489                "agent_id": {
1490                    "type": "string",
1491                    "description": "Optional filter by agent"
1492                },
1493                "limit": {
1494                    "type": "integer",
1495                    "description": "Optional max results for list (default: 20)"
1496                }
1497            },
1498            "additionalProperties": false
1499        })
1500    }
1501
1502    fn hints(&self) -> ToolHints {
1503        ToolHints::default()
1504            .with_readonly(true)
1505            .with_idempotent(true)
1506    }
1507
1508    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1509        ToolExecutionResult::tool_error(
1510            "read_sessions requires context. This tool must be executed with session context.",
1511        )
1512    }
1513
1514    async fn execute_with_context(
1515        &self,
1516        arguments: Value,
1517        context: &ToolContext,
1518    ) -> ToolExecutionResult {
1519        read_sessions_impl(arguments, context)
1520            .await
1521            .unwrap_or_else(|e| e)
1522    }
1523
1524    fn requires_context(&self) -> bool {
1525        true
1526    }
1527}
1528
1529async fn read_sessions_impl(
1530    arguments: Value,
1531    context: &ToolContext,
1532) -> Result<ToolExecutionResult, ToolExecutionResult> {
1533    let store = get_platform_store(context)?;
1534    let base_url = store.base_url();
1535
1536    if let Some(id_str) = get_str(&arguments, "id") {
1537        let id: crate::typed_id::SessionId = parse_id(id_str, "session id")?;
1538        return Ok(match store.get_session_by_id(id).await {
1539            Ok(Some(s)) => ToolExecutionResult::success(json!({
1540                "id": s.id.to_string(),
1541                "organization_id": s.organization_id,
1542                "title": s.title,
1543                "status": format!("{:?}", s.status),
1544                "agent_id": s.agent_id.as_ref().map(|a| a.to_string()),
1545                "harness_id": s.harness_id.to_string(),
1546                "created_at": s.created_at.to_rfc3339(),
1547                "preview": s.preview,
1548                "output_preview": s.output_preview,
1549                "ui_link": format!("{}/sessions/{}/chat", base_url, s.id),
1550            })),
1551            Ok(None) => ToolExecutionResult::tool_error(format!("Session not found: {id_str}")),
1552            Err(e) => ToolExecutionResult::tool_error(format!("Failed to get session: {e}")),
1553        });
1554    }
1555
1556    let limit = arguments
1557        .get("limit")
1558        .and_then(|v| v.as_u64())
1559        .map(|v| v as usize);
1560    // Invalid agent_id is silently ignored here (filter is best-effort).
1561    let agent_id =
1562        get_str(&arguments, "agent_id").and_then(|s| s.parse::<crate::typed_id::AgentId>().ok());
1563    Ok(match store.list_sessions(limit, agent_id).await {
1564        Ok(sessions) => {
1565            let items: Vec<Value> = sessions
1566                .iter()
1567                .map(|s| {
1568                    json!({
1569                        "id": s.id.to_string(),
1570                        "organization_id": s.organization_id,
1571                        "title": s.title,
1572                        "status": format!("{:?}", s.status),
1573                        "agent_id": s.agent_id.as_ref().map(|a| a.to_string()),
1574                        "harness_id": s.harness_id.to_string(),
1575                        "created_at": s.created_at.to_rfc3339(),
1576                        "preview": s.preview,
1577                        "ui_link": format!("{}/sessions/{}/chat", base_url, s.id),
1578                    })
1579                })
1580                .collect();
1581            ToolExecutionResult::success(json!({"sessions": items, "count": items.len()}))
1582        }
1583        Err(e) => ToolExecutionResult::tool_error(format!("Failed to list sessions: {e}")),
1584    })
1585}
1586
1587// =============================================================================
1588// Tool: session_context_report
1589// =============================================================================
1590
1591pub struct SessionContextReportTool;
1592
1593#[async_trait]
1594impl Tool for SessionContextReportTool {
1595    fn name(&self) -> &str {
1596        "session_context_report"
1597    }
1598
1599    fn display_name(&self) -> Option<&str> {
1600        Some("Session Context Report")
1601    }
1602
1603    fn description(&self) -> &str {
1604        "Read the latest estimated context token breakdown for a session."
1605    }
1606
1607    fn parameters_schema(&self) -> Value {
1608        json!({
1609            "type": "object",
1610            "properties": {
1611                "session_id": {
1612                    "type": "string",
1613                    "description": "Session ID to inspect"
1614                }
1615            },
1616            "required": ["session_id"],
1617            "additionalProperties": false
1618        })
1619    }
1620
1621    fn hints(&self) -> ToolHints {
1622        ToolHints::default()
1623            .with_readonly(true)
1624            .with_idempotent(true)
1625    }
1626
1627    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1628        ToolExecutionResult::tool_error(
1629            "session_context_report requires context. This tool must be executed with session context.",
1630        )
1631    }
1632
1633    async fn execute_with_context(
1634        &self,
1635        arguments: Value,
1636        context: &ToolContext,
1637    ) -> ToolExecutionResult {
1638        session_context_report_impl(arguments, context)
1639            .await
1640            .unwrap_or_else(|e| e)
1641    }
1642
1643    fn requires_context(&self) -> bool {
1644        true
1645    }
1646}
1647
1648async fn session_context_report_impl(
1649    arguments: Value,
1650    context: &ToolContext,
1651) -> Result<ToolExecutionResult, ToolExecutionResult> {
1652    let store = get_platform_store(context)?;
1653    let id: crate::typed_id::SessionId = require_id(&arguments, "session_id")?;
1654
1655    Ok(match store.get_session_context_report(id).await {
1656        Ok(report) => ToolExecutionResult::success(json!(report)),
1657        Err(e) => ToolExecutionResult::tool_error(format!("Failed to get context report: {e}")),
1658    })
1659}
1660
1661// =============================================================================
1662// Tool: manage_sessions (mutations: create, delete)
1663// =============================================================================
1664
1665pub struct ManageSessionsTool;
1666
1667#[async_trait]
1668impl Tool for ManageSessionsTool {
1669    fn name(&self) -> &str {
1670        "manage_sessions"
1671    }
1672
1673    fn display_name(&self) -> Option<&str> {
1674        Some("Manage Sessions")
1675    }
1676
1677    fn description(&self) -> &str {
1678        "Session mutations: create, delete."
1679    }
1680
1681    fn parameters_schema(&self) -> Value {
1682        json!({
1683            "type": "object",
1684            "properties": {
1685                "operation": {
1686                    "type": "string",
1687                    "enum": ["create", "delete"],
1688                    "description": "The mutation to perform"
1689                },
1690                "session_id": {
1691                    "type": "string",
1692                    "description": "Session ID (required for delete)"
1693                },
1694                "harness_id": {
1695                    "type": "string",
1696                    "description": "Harness ID for the session. If omitted, uses the org's default (Generic) harness."
1697                },
1698                "agent_id": {
1699                    "type": "string",
1700                    "description": "Agent ID (optional for create)"
1701                },
1702                "title": {
1703                    "type": "string",
1704                    "description": "Session title (optional for create)"
1705                },
1706                "locale": {
1707                    "type": "string",
1708                    "description": "Session locale (optional for create, e.g. uk-UA)"
1709                }
1710            },
1711            "required": ["operation"],
1712            "additionalProperties": false
1713        })
1714    }
1715
1716    fn hints(&self) -> ToolHints {
1717        ToolHints::default().with_narration_noun("session")
1718    }
1719
1720    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1721        ToolExecutionResult::tool_error(
1722            "manage_sessions requires context. This tool must be executed with session context.",
1723        )
1724    }
1725
1726    async fn execute_with_context(
1727        &self,
1728        arguments: Value,
1729        context: &ToolContext,
1730    ) -> ToolExecutionResult {
1731        manage_sessions_impl(arguments, context)
1732            .await
1733            .unwrap_or_else(|e| e)
1734    }
1735
1736    fn requires_context(&self) -> bool {
1737        true
1738    }
1739}
1740
1741async fn manage_sessions_impl(
1742    arguments: Value,
1743    context: &ToolContext,
1744) -> Result<ToolExecutionResult, ToolExecutionResult> {
1745    let store = get_platform_store(context)?;
1746    let operation = require_str(&arguments, "operation")?;
1747    let base_url = store.base_url();
1748
1749    Ok(match operation {
1750        "create" => {
1751            let harness_id = if let Some(harness_id_str) = get_str(&arguments, "harness_id") {
1752                parse_id::<crate::typed_id::HarnessId>(harness_id_str, "harness_id")?
1753            } else {
1754                // Fall back to the org's default (Generic) harness
1755                match store.list_harnesses().await {
1756                    Ok(harnesses) => {
1757                        match harnesses
1758                            .iter()
1759                            .find(|h| h.is_built_in && h.name == "Generic")
1760                        {
1761                            Some(h) => h.id,
1762                            None => {
1763                                return Ok(ToolExecutionResult::tool_error(
1764                                    "No harness_id provided and no default Generic harness found. Please specify a harness_id.",
1765                                ));
1766                            }
1767                        }
1768                    }
1769                    Err(e) => {
1770                        return Ok(ToolExecutionResult::tool_error(format!(
1771                            "No harness_id provided and failed to resolve default harness: {e}"
1772                        )));
1773                    }
1774                }
1775            };
1776            // Invalid agent_id is silently ignored here (best-effort).
1777            let agent_id = get_str(&arguments, "agent_id")
1778                .and_then(|s| s.parse::<crate::typed_id::AgentId>().ok());
1779            let title = get_str(&arguments, "title");
1780            let locale = get_str(&arguments, "locale");
1781            match store
1782                .create_session(harness_id, agent_id, title, locale, None, None, None)
1783                .await
1784            {
1785                Ok(s) => ToolExecutionResult::success(json!({
1786                    "id": s.id.to_string(),
1787                    "organization_id": s.organization_id,
1788                    "title": s.title,
1789                    "locale": s.locale,
1790                    "status": format!("{:?}", s.status),
1791                    "harness_id": s.harness_id.to_string(),
1792                    "agent_id": s.agent_id.as_ref().map(|a| a.to_string()),
1793                    "ui_link": format!("{}/sessions/{}/chat", base_url, s.id),
1794                    "message": "Session created successfully"
1795                })),
1796                Err(e) => ToolExecutionResult::tool_error(format!("Failed to create session: {e}")),
1797            }
1798        }
1799
1800        "delete" => {
1801            let id_str = require_str(&arguments, "session_id")?;
1802            let id: crate::typed_id::SessionId = parse_id(id_str, "session_id")?;
1803            match store.delete_session(id).await {
1804                Ok(()) => ToolExecutionResult::success(json!({
1805                    "session_id": id_str,
1806                    "ui_link": format!("{}/sessions/{}/chat", base_url, id_str),
1807                    "message": "Session archived successfully"
1808                })),
1809                Err(e) => ToolExecutionResult::tool_error(format!("Failed to delete session: {e}")),
1810            }
1811        }
1812
1813        _ => ToolExecutionResult::tool_error(format!(
1814            "Unknown operation: {operation}. Valid: create, delete"
1815        )),
1816    })
1817}
1818
1819// =============================================================================
1820// Tool: session_send_message
1821// =============================================================================
1822
1823pub struct SessionSendMessageTool;
1824
1825#[async_trait]
1826impl Tool for SessionSendMessageTool {
1827    fn name(&self) -> &str {
1828        "session_send_message"
1829    }
1830
1831    fn display_name(&self) -> Option<&str> {
1832        Some("Send Message")
1833    }
1834
1835    fn description(&self) -> &str {
1836        "Send a user message to a session, triggering a turn."
1837    }
1838
1839    fn parameters_schema(&self) -> Value {
1840        json!({
1841            "type": "object",
1842            "properties": {
1843                "session_id": {
1844                    "type": "string",
1845                    "description": "Target session ID"
1846                },
1847                "content": {
1848                    "type": "string",
1849                    "description": "Message content"
1850                }
1851            },
1852            "required": ["session_id", "content"],
1853            "additionalProperties": false
1854        })
1855    }
1856
1857    fn hints(&self) -> ToolHints {
1858        ToolHints::default().with_long_running(true)
1859    }
1860
1861    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1862        ToolExecutionResult::tool_error(
1863            "session_send_message requires context. This tool must be executed with session context.",
1864        )
1865    }
1866
1867    async fn execute_with_context(
1868        &self,
1869        arguments: Value,
1870        context: &ToolContext,
1871    ) -> ToolExecutionResult {
1872        session_send_message_impl(arguments, context)
1873            .await
1874            .unwrap_or_else(|e| e)
1875    }
1876
1877    fn requires_context(&self) -> bool {
1878        true
1879    }
1880}
1881
1882async fn session_send_message_impl(
1883    arguments: Value,
1884    context: &ToolContext,
1885) -> Result<ToolExecutionResult, ToolExecutionResult> {
1886    let store = get_platform_store(context)?;
1887    let session_id_str = require_str(&arguments, "session_id")?;
1888    let session_id: crate::typed_id::SessionId = parse_id(session_id_str, "session_id")?;
1889    let content = require_str(&arguments, "content")?;
1890    let base_url = store.base_url();
1891
1892    Ok(match store.send_message(session_id, content).await {
1893        Ok(()) => ToolExecutionResult::success(json!({
1894            "session_id": session_id_str,
1895            "message": "Message sent successfully. Use session_read_response to wait for the agent response.",
1896            "ui_link": format!("{}/sessions/{}/chat", base_url, session_id),
1897        })),
1898        Err(e) => ToolExecutionResult::tool_error(format!("Failed to send message: {e}")),
1899    })
1900}
1901
1902// =============================================================================
1903// Tool: session_read_messages
1904// =============================================================================
1905
1906pub struct SessionReadMessagesTool;
1907
1908#[async_trait]
1909impl Tool for SessionReadMessagesTool {
1910    fn name(&self) -> &str {
1911        "session_read_messages"
1912    }
1913
1914    fn display_name(&self) -> Option<&str> {
1915        Some("Read Messages")
1916    }
1917
1918    fn description(&self) -> &str {
1919        "Read messages from a session."
1920    }
1921
1922    fn parameters_schema(&self) -> Value {
1923        json!({
1924            "type": "object",
1925            "properties": {
1926                "session_id": {
1927                    "type": "string",
1928                    "description": "Target session ID"
1929                },
1930                "limit": {
1931                    "type": "integer",
1932                    "description": "Max messages to return. Default: 10, maximum: 50",
1933                    "default": SESSION_READ_MESSAGES_DEFAULT_LIMIT,
1934                    "minimum": 1,
1935                    "maximum": SESSION_READ_MESSAGES_MAX_LIMIT
1936                },
1937                "content_limit": {
1938                    "type": "integer",
1939                    "description": "Max characters to return per message. Default: 12000, maximum: 50000",
1940                    "default": SESSION_READ_MESSAGES_DEFAULT_CONTENT_LIMIT,
1941                    "minimum": 1,
1942                    "maximum": SESSION_READ_MESSAGES_MAX_CONTENT_LIMIT
1943                }
1944            },
1945            "required": ["session_id"],
1946            "additionalProperties": false
1947        })
1948    }
1949
1950    fn hints(&self) -> ToolHints {
1951        ToolHints::default()
1952            .with_readonly(true)
1953            .with_idempotent(true)
1954    }
1955
1956    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1957        ToolExecutionResult::tool_error(
1958            "session_read_messages requires context. This tool must be executed with session context.",
1959        )
1960    }
1961
1962    async fn execute_with_context(
1963        &self,
1964        arguments: Value,
1965        context: &ToolContext,
1966    ) -> ToolExecutionResult {
1967        session_read_messages_impl(arguments, context)
1968            .await
1969            .unwrap_or_else(|e| e)
1970    }
1971
1972    fn requires_context(&self) -> bool {
1973        true
1974    }
1975}
1976
1977async fn session_read_messages_impl(
1978    arguments: Value,
1979    context: &ToolContext,
1980) -> Result<ToolExecutionResult, ToolExecutionResult> {
1981    let store = get_platform_store(context)?;
1982    let session_id_str = require_str(&arguments, "session_id")?;
1983    let session_id: crate::typed_id::SessionId = parse_id(session_id_str, "session_id")?;
1984
1985    let limit = parse_bounded_usize_arg(
1986        &arguments,
1987        "limit",
1988        SESSION_READ_MESSAGES_DEFAULT_LIMIT,
1989        SESSION_READ_MESSAGES_MAX_LIMIT,
1990    )?;
1991    let content_limit = parse_bounded_usize_arg(
1992        &arguments,
1993        "content_limit",
1994        SESSION_READ_MESSAGES_DEFAULT_CONTENT_LIMIT,
1995        SESSION_READ_MESSAGES_MAX_CONTENT_LIMIT,
1996    )?;
1997
1998    let base_url = store.base_url();
1999
2000    Ok(match store.get_messages(session_id, Some(limit)).await {
2001        Ok(messages) => {
2002            let items: Vec<Value> = messages
2003                .iter()
2004                .map(|m| {
2005                    let (content, truncated, total_chars, returned_chars) =
2006                        truncate_content_chars(&m.content, content_limit);
2007                    json!({
2008                        "role": m.role,
2009                        "content": content,
2010                        "content_truncated": truncated,
2011                        "content_total_chars": total_chars,
2012                        "content_returned_chars": returned_chars,
2013                        "created_at": m.created_at.to_rfc3339(),
2014                    })
2015                })
2016                .collect();
2017            let truncated_message_count = items
2018                .iter()
2019                .filter(|item| item["content_truncated"].as_bool().unwrap_or(false))
2020                .count();
2021            ToolExecutionResult::success(json!({
2022                "messages": items,
2023                "count": items.len(),
2024                "limit": limit,
2025                "content_limit": content_limit,
2026                "truncated_message_count": truncated_message_count,
2027                "session_id": session_id_str,
2028                "ui_link": format!("{}/sessions/{}/chat", base_url, session_id),
2029            }))
2030        }
2031        Err(e) => ToolExecutionResult::tool_error(format!("Failed to get messages: {e}")),
2032    })
2033}
2034
2035// =============================================================================
2036// Tool: session_read_response
2037// =============================================================================
2038
2039pub struct SessionReadResponseTool;
2040
2041#[async_trait]
2042impl Tool for SessionReadResponseTool {
2043    fn name(&self) -> &str {
2044        "session_read_response"
2045    }
2046
2047    fn display_name(&self) -> Option<&str> {
2048        Some("Read Response")
2049    }
2050
2051    fn description(&self) -> &str {
2052        "Wait for session to finish processing and return the response. Set timeout_secs to 0 to check status without waiting."
2053    }
2054
2055    fn parameters_schema(&self) -> Value {
2056        json!({
2057            "type": "object",
2058            "properties": {
2059                "session_id": {
2060                    "type": "string",
2061                    "description": "Target session ID"
2062                },
2063                "timeout_secs": {
2064                    "type": "integer",
2065                    "description": "Optional timeout (default: 120). Set to 0 to check status without waiting."
2066                }
2067            },
2068            "required": ["session_id"],
2069            "additionalProperties": false
2070        })
2071    }
2072
2073    fn hints(&self) -> ToolHints {
2074        ToolHints::default()
2075            .with_readonly(true)
2076            .with_idempotent(true)
2077            .with_long_running(true)
2078    }
2079
2080    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
2081        ToolExecutionResult::tool_error(
2082            "session_read_response requires context. This tool must be executed with session context.",
2083        )
2084    }
2085
2086    async fn execute_with_context(
2087        &self,
2088        arguments: Value,
2089        context: &ToolContext,
2090    ) -> ToolExecutionResult {
2091        session_read_response_impl(arguments, context)
2092            .await
2093            .unwrap_or_else(|e| e)
2094    }
2095
2096    fn requires_context(&self) -> bool {
2097        true
2098    }
2099}
2100
2101async fn session_read_response_impl(
2102    arguments: Value,
2103    context: &ToolContext,
2104) -> Result<ToolExecutionResult, ToolExecutionResult> {
2105    let store = get_platform_store(context)?;
2106    let session_id_str = require_str(&arguments, "session_id")?;
2107    let session_id: crate::typed_id::SessionId = parse_id(session_id_str, "session_id")?;
2108
2109    let timeout_secs = arguments.get("timeout_secs").and_then(|v| v.as_u64());
2110    let base_url = store.base_url();
2111
2112    Ok(match store.wait_for_idle(session_id, timeout_secs).await {
2113        Ok(status) => ToolExecutionResult::success(json!({
2114            "session_id": session_id_str,
2115            "status": status,
2116            "ui_link": format!("{}/sessions/{}/chat", base_url, session_id),
2117        })),
2118        Err(e) => ToolExecutionResult::tool_error(format!("Failed waiting for response: {e}")),
2119    })
2120}
2121
2122// =============================================================================
2123// Tool: read_capabilities
2124// =============================================================================
2125
2126pub struct ReadCapabilitiesTool;
2127
2128#[async_trait]
2129impl Tool for ReadCapabilitiesTool {
2130    fn name(&self) -> &str {
2131        "read_capabilities"
2132    }
2133
2134    fn display_name(&self) -> Option<&str> {
2135        Some("Read Capabilities")
2136    }
2137
2138    fn description(&self) -> &str {
2139        "Discover available capabilities (built-in, MCP servers, and skills). Use this to find capability IDs before creating or updating agents and harnesses."
2140    }
2141
2142    fn parameters_schema(&self) -> Value {
2143        json!({
2144            "type": "object",
2145            "properties": {
2146                "id": {
2147                    "type": "string",
2148                    "description": "Optional capability ID to get a single capability"
2149                },
2150                "search": {
2151                    "type": "string",
2152                    "description": "Optional search query to filter capabilities by name, description, category, or ID (case-insensitive)"
2153                }
2154            },
2155            "additionalProperties": false
2156        })
2157    }
2158
2159    fn hints(&self) -> ToolHints {
2160        ToolHints::default()
2161            .with_readonly(true)
2162            .with_idempotent(true)
2163    }
2164
2165    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
2166        ToolExecutionResult::tool_error(
2167            "read_capabilities requires context. This tool must be executed with session context.",
2168        )
2169    }
2170
2171    async fn execute_with_context(
2172        &self,
2173        arguments: Value,
2174        context: &ToolContext,
2175    ) -> ToolExecutionResult {
2176        read_capabilities_impl(arguments, context)
2177            .await
2178            .unwrap_or_else(|e| e)
2179    }
2180
2181    fn requires_context(&self) -> bool {
2182        true
2183    }
2184}
2185
2186async fn read_capabilities_impl(
2187    arguments: Value,
2188    context: &ToolContext,
2189) -> Result<ToolExecutionResult, ToolExecutionResult> {
2190    let store = get_platform_store(context)?;
2191    let base_url = store.base_url();
2192
2193    let id_filter = get_str(&arguments, "id");
2194    let search = get_str(&arguments, "search");
2195
2196    // If id is given, use it as the search filter to find the specific capability
2197    let effective_search = id_filter.or(search);
2198
2199    Ok(match store.list_capabilities(effective_search).await {
2200        Ok(capabilities) => {
2201            let items: Vec<Value> = capabilities
2202                .iter()
2203                .map(|c| {
2204                    let mut item = json!({
2205                        "id": c.id.as_str(),
2206                        "name": c.name,
2207                        "description": c.description,
2208                        "status": c.status.to_string(),
2209                        "ui_link": format!("{}/capabilities/{}", base_url, c.id.as_str()),
2210                    });
2211                    if let Some(cat) = &c.category {
2212                        item["category"] = json!(cat);
2213                    }
2214                    if c.is_mcp {
2215                        item["type"] = json!("mcp_server");
2216                    } else if c.is_skill {
2217                        item["type"] = json!("skill");
2218                    } else if is_declarative_capability(c.id.as_str()) {
2219                        item["type"] = json!("declarative");
2220                    } else {
2221                        item["type"] = json!("builtin");
2222                    }
2223                    if !c.tool_definitions.is_empty() {
2224                        item["tool_count"] = json!(c.tool_definitions.len());
2225                        item["tools"] = json!(
2226                            c.tool_definitions
2227                                .iter()
2228                                .map(|t| t.name())
2229                                .collect::<Vec<_>>()
2230                        );
2231                    }
2232                    if !c.dependencies.is_empty() {
2233                        item["dependencies"] = json!(c.dependencies);
2234                    }
2235                    item
2236                })
2237                .collect();
2238
2239            // When id is provided, return exact match as single item
2240            if let Some(target_id) = id_filter {
2241                if let Some(exact) = items.iter().find(|i| i["id"].as_str() == Some(target_id)) {
2242                    return Ok(ToolExecutionResult::success(exact.clone()));
2243                }
2244                return Ok(ToolExecutionResult::tool_error(format!(
2245                    "Capability not found: {target_id}"
2246                )));
2247            }
2248
2249            let count = items.len();
2250            ToolExecutionResult::success(json!({
2251                "capabilities": items,
2252                "count": count,
2253                "hint": "Use capability IDs when creating or updating agents and harnesses via manage_agents or manage_harnesses (capabilities parameter)."
2254            }))
2255        }
2256        Err(e) => ToolExecutionResult::tool_error(format!("Failed to list capabilities: {e}")),
2257    })
2258}
2259
2260#[cfg(test)]
2261mod tests {
2262    use super::*;
2263    use crate::platform_store::PlatformStore;
2264    use crate::platform_store::tests::MockPlatformStore;
2265    use crate::typed_id::{AgentId, HarnessId, SessionId};
2266    use std::sync::Arc;
2267
2268    fn mock_context() -> ToolContext {
2269        let store: Arc<dyn PlatformStore> = Arc::new(MockPlatformStore::new());
2270        let mut ctx = ToolContext::new(SessionId::new());
2271        ctx.platform_store = Some(store);
2272        ctx
2273    }
2274
2275    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
2276
2277    #[test]
2278    fn truncate_content_chars_respects_unicode_boundaries() {
2279        let (content, truncated, total_chars, returned_chars) = truncate_content_chars("ab😀cd", 3);
2280
2281        assert_eq!(content, "ab😀");
2282        assert!(truncated);
2283        assert_eq!(total_chars, 5);
2284        assert_eq!(returned_chars, 3);
2285    }
2286
2287    // =========================================================================
2288    // ReadHarnessesTool tests
2289    // =========================================================================
2290
2291    #[tokio::test]
2292    async fn read_harnesses_list_returns_harnesses_with_ui_link() {
2293        let ctx = mock_context();
2294        let tool = ReadHarnessesTool;
2295        let result = tool.execute_with_context(json!({}), &ctx).await;
2296        match result {
2297            ToolExecutionResult::Success(v) => {
2298                assert_eq!(v["count"], 1);
2299                let h = v["harnesses"].as_array().unwrap();
2300                assert!(h[0]["ui_link"].as_str().unwrap().contains("/harnesses/"));
2301            }
2302            other => panic!("expected success, got: {other:?}"),
2303        }
2304    }
2305
2306    #[tokio::test]
2307    async fn read_harnesses_get_by_id_returns_full_detail() {
2308        let ctx = mock_context();
2309        let tool = ReadHarnessesTool;
2310        let result = tool
2311            .execute_with_context(json!({"id": HarnessId::new().to_string()}), &ctx)
2312            .await;
2313        match result {
2314            ToolExecutionResult::Success(v) => {
2315                assert_eq!(v["name"], "test-harness");
2316                assert_eq!(v["display_name"], "Test Harness");
2317                assert!(v["system_prompt"].as_str().is_some());
2318                assert!(v["ui_link"].as_str().unwrap().contains("/harnesses/"));
2319            }
2320            other => panic!("expected success, got: {other:?}"),
2321        }
2322    }
2323
2324    #[tokio::test]
2325    async fn read_harnesses_invalid_id_returns_error() {
2326        let ctx = mock_context();
2327        let tool = ReadHarnessesTool;
2328        let result = tool.execute_with_context(json!({"id": "bad"}), &ctx).await;
2329        match result {
2330            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Invalid harness id")),
2331            other => panic!("expected tool error, got: {other:?}"),
2332        }
2333    }
2334
2335    // =========================================================================
2336    // ManageHarnessesTool tests
2337    // =========================================================================
2338
2339    #[tokio::test]
2340    async fn harness_create_returns_new_harness() {
2341        let ctx = mock_context();
2342        let tool = ManageHarnessesTool;
2343        let result = tool
2344            .execute_with_context(
2345                json!({"operation": "create", "name": "my-harness", "display_name": "My Harness", "system_prompt": "Be fun!"}),
2346                &ctx,
2347            )
2348            .await;
2349        match result {
2350            ToolExecutionResult::Success(v) => {
2351                assert_eq!(v["name"], "my-harness");
2352                assert_eq!(v["display_name"], "My Harness");
2353                assert!(
2354                    v["ui_link"]
2355                        .as_str()
2356                        .unwrap()
2357                        .starts_with("http://localhost:9300/harnesses/")
2358                );
2359            }
2360            other => panic!("expected success, got: {other:?}"),
2361        }
2362    }
2363
2364    #[tokio::test]
2365    async fn harness_copy_returns_copied_harness() {
2366        let ctx = mock_context();
2367        let tool = ManageHarnessesTool;
2368        let result = tool
2369            .execute_with_context(
2370                json!({"operation": "copy", "harness_id": HarnessId::new().to_string(), "new_name": "Fun"}),
2371                &ctx,
2372            )
2373            .await;
2374        match result {
2375            ToolExecutionResult::Success(v) => {
2376                assert_eq!(v["name"], "Fun");
2377                assert!(v["message"].as_str().unwrap().contains("copied"));
2378            }
2379            other => panic!("expected success, got: {other:?}"),
2380        }
2381    }
2382
2383    #[tokio::test]
2384    async fn harness_delete_succeeds() {
2385        let ctx = mock_context();
2386        let tool = ManageHarnessesTool;
2387        let result = tool
2388            .execute_with_context(
2389                json!({"operation": "delete", "harness_id": HarnessId::new().to_string()}),
2390                &ctx,
2391            )
2392            .await;
2393        match result {
2394            ToolExecutionResult::Success(v) => {
2395                assert!(v["message"].as_str().unwrap().contains("archived"))
2396            }
2397            other => panic!("expected success, got: {other:?}"),
2398        }
2399    }
2400
2401    #[tokio::test]
2402    async fn harness_invalid_operation_returns_error() {
2403        let ctx = mock_context();
2404        let tool = ManageHarnessesTool;
2405        let result = tool
2406            .execute_with_context(json!({"operation": "explode"}), &ctx)
2407            .await;
2408        match result {
2409            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Unknown operation")),
2410            other => panic!("expected tool error, got: {other:?}"),
2411        }
2412    }
2413
2414    #[tokio::test]
2415    async fn harness_update_succeeds() {
2416        let ctx = mock_context();
2417        let tool = ManageHarnessesTool;
2418        let result = tool
2419            .execute_with_context(
2420                json!({"operation": "update", "harness_id": HarnessId::new().to_string(), "name": "Updated"}),
2421                &ctx,
2422            )
2423            .await;
2424        match result {
2425            ToolExecutionResult::Success(v) => {
2426                assert_eq!(v["name"], "Updated");
2427                assert!(v["message"].as_str().unwrap().contains("updated"));
2428            }
2429            other => panic!("expected success, got: {other:?}"),
2430        }
2431    }
2432
2433    #[tokio::test]
2434    async fn harness_missing_required_param_returns_error() {
2435        let ctx = mock_context();
2436        let tool = ManageHarnessesTool;
2437        let result = tool
2438            .execute_with_context(json!({"operation": "create"}), &ctx)
2439            .await;
2440        match result {
2441            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Missing required")),
2442            other => panic!("expected tool error, got: {other:?}"),
2443        }
2444    }
2445
2446    // =========================================================================
2447    // ReadAgentsTool tests
2448    // =========================================================================
2449
2450    #[tokio::test]
2451    async fn read_agents_list_returns_agents() {
2452        let ctx = mock_context();
2453        let tool = ReadAgentsTool;
2454        let result = tool.execute_with_context(json!({}), &ctx).await;
2455        match result {
2456            ToolExecutionResult::Success(v) => {
2457                assert_eq!(v["count"], 1);
2458                assert!(
2459                    v["agents"].as_array().unwrap()[0]["ui_link"]
2460                        .as_str()
2461                        .unwrap()
2462                        .contains("/agents/")
2463                );
2464            }
2465            other => panic!("expected success, got: {other:?}"),
2466        }
2467    }
2468
2469    #[tokio::test]
2470    async fn read_agents_get_by_id_succeeds() {
2471        let ctx = mock_context();
2472        let tool = ReadAgentsTool;
2473        let result = tool
2474            .execute_with_context(json!({"id": AgentId::new().to_string()}), &ctx)
2475            .await;
2476        match result {
2477            ToolExecutionResult::Success(v) => {
2478                assert_eq!(v["name"], "test-agent");
2479                assert_eq!(v["display_name"], "Test Agent");
2480                assert!(v["ui_link"].as_str().unwrap().contains("/agents/"));
2481            }
2482            other => panic!("expected success, got: {other:?}"),
2483        }
2484    }
2485
2486    #[tokio::test]
2487    async fn read_agents_invalid_id_returns_error() {
2488        let ctx = mock_context();
2489        let tool = ReadAgentsTool;
2490        let result = tool
2491            .execute_with_context(json!({"id": "not-valid"}), &ctx)
2492            .await;
2493        match result {
2494            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Invalid agent id")),
2495            other => panic!("expected tool error, got: {other:?}"),
2496        }
2497    }
2498
2499    // =========================================================================
2500    // ManageAgentsTool tests
2501    // =========================================================================
2502
2503    #[tokio::test]
2504    async fn agent_create_returns_new_agent() {
2505        let ctx = mock_context();
2506        let tool = ManageAgentsTool;
2507        let result = tool
2508            .execute_with_context(
2509                json!({"operation": "create", "name": "new-agent", "system_prompt": "Be helpful"}),
2510                &ctx,
2511            )
2512            .await;
2513        match result {
2514            ToolExecutionResult::Success(v) => assert_eq!(v["name"], "new-agent"),
2515            other => panic!("expected success, got: {other:?}"),
2516        }
2517    }
2518
2519    #[tokio::test]
2520    async fn agent_create_rejects_non_slug_name() {
2521        let ctx = mock_context();
2522        let tool = ManageAgentsTool;
2523        let result = tool
2524            .execute_with_context(
2525                json!({"operation": "create", "name": "Bad Agent Name", "system_prompt": "hi"}),
2526                &ctx,
2527            )
2528            .await;
2529        match result {
2530            ToolExecutionResult::ToolError(_) => {} // expected
2531            other => panic!("expected tool error for non-slug name, got: {other:?}"),
2532        }
2533    }
2534
2535    #[tokio::test]
2536    async fn agent_create_with_display_name() {
2537        let ctx = mock_context();
2538        let tool = ManageAgentsTool;
2539        let result = tool
2540            .execute_with_context(
2541                json!({"operation": "create", "name": "support-bot", "display_name": "Support Bot", "system_prompt": "hi"}),
2542                &ctx,
2543            )
2544            .await;
2545        match result {
2546            ToolExecutionResult::Success(v) => {
2547                assert_eq!(v["name"], "support-bot");
2548                assert_eq!(v["display_name"], "Support Bot");
2549            }
2550            other => panic!("expected success, got: {other:?}"),
2551        }
2552    }
2553
2554    #[tokio::test]
2555    async fn agent_update_succeeds() {
2556        let ctx = mock_context();
2557        let tool = ManageAgentsTool;
2558        let result = tool
2559            .execute_with_context(
2560                json!({"operation": "update", "agent_id": AgentId::new().to_string(), "name": "renamed-agent"}),
2561                &ctx,
2562            )
2563            .await;
2564        match result {
2565            ToolExecutionResult::Success(v) => {
2566                assert_eq!(v["name"], "renamed-agent");
2567                assert!(v["message"].as_str().unwrap().contains("updated"));
2568            }
2569            other => panic!("expected success, got: {other:?}"),
2570        }
2571    }
2572
2573    #[tokio::test]
2574    async fn agent_delete_succeeds() {
2575        let ctx = mock_context();
2576        let tool = ManageAgentsTool;
2577        let result = tool
2578            .execute_with_context(
2579                json!({"operation": "delete", "agent_id": AgentId::new().to_string()}),
2580                &ctx,
2581            )
2582            .await;
2583        match result {
2584            ToolExecutionResult::Success(v) => {
2585                assert!(v["message"].as_str().unwrap().contains("archived"));
2586            }
2587            other => panic!("expected success, got: {other:?}"),
2588        }
2589    }
2590
2591    #[tokio::test]
2592    async fn agent_invalid_operation_returns_error() {
2593        let ctx = mock_context();
2594        let tool = ManageAgentsTool;
2595        let result = tool
2596            .execute_with_context(json!({"operation": "clone"}), &ctx)
2597            .await;
2598        match result {
2599            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Unknown operation")),
2600            other => panic!("expected tool error, got: {other:?}"),
2601        }
2602    }
2603
2604    #[tokio::test]
2605    async fn agent_create_missing_name_returns_error() {
2606        let ctx = mock_context();
2607        let tool = ManageAgentsTool;
2608        let result = tool
2609            .execute_with_context(
2610                json!({"operation": "create", "system_prompt": "test"}),
2611                &ctx,
2612            )
2613            .await;
2614        match result {
2615            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Missing required")),
2616            other => panic!("expected tool error, got: {other:?}"),
2617        }
2618    }
2619
2620    // =========================================================================
2621    // ReadAppsTool tests
2622    // =========================================================================
2623
2624    #[tokio::test]
2625    async fn read_apps_list_returns_apps() {
2626        let ctx = mock_context();
2627        let tool = ReadAppsTool;
2628        let result = tool.execute_with_context(json!({}), &ctx).await;
2629        match result {
2630            ToolExecutionResult::Success(v) => {
2631                assert_eq!(v["count"], 1);
2632                assert!(
2633                    v["apps"].as_array().unwrap()[0]["ui_link"]
2634                        .as_str()
2635                        .unwrap()
2636                        .contains("/apps/")
2637                );
2638            }
2639            other => panic!("expected success, got: {other:?}"),
2640        }
2641    }
2642
2643    #[tokio::test]
2644    async fn read_apps_get_by_id_returns_channels() {
2645        let ctx = mock_context();
2646        let tool = ReadAppsTool;
2647        let result = tool
2648            .execute_with_context(json!({"id": crate::AppId::new().to_string()}), &ctx)
2649            .await;
2650        match result {
2651            ToolExecutionResult::Success(v) => {
2652                assert_eq!(v["name"], "test-app");
2653                assert_eq!(v["channels"].as_array().unwrap().len(), 1);
2654            }
2655            other => panic!("expected success, got: {other:?}"),
2656        }
2657    }
2658
2659    #[tokio::test]
2660    async fn read_apps_invalid_id_returns_error() {
2661        let ctx = mock_context();
2662        let tool = ReadAppsTool;
2663        let result = tool.execute_with_context(json!({"id": "bad"}), &ctx).await;
2664        match result {
2665            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Invalid app id")),
2666            other => panic!("expected tool error, got: {other:?}"),
2667        }
2668    }
2669
2670    // =========================================================================
2671    // ManageAppsTool tests
2672    // =========================================================================
2673
2674    #[tokio::test]
2675    async fn manage_apps_create_returns_new_app() {
2676        let ctx = mock_context();
2677        let tool = ManageAppsTool;
2678        let result = tool
2679            .execute_with_context(
2680                json!({
2681                    "operation": "create",
2682                    "name": "repo-checker",
2683                    "harness_id": HarnessId::new().to_string(),
2684                    "channel_type": "schedule",
2685                    "channel_config": {
2686                        "cron_expression": "0 * * * * * *",
2687                        "timezone": "UTC",
2688                        "message": "run checks"
2689                    }
2690                }),
2691                &ctx,
2692            )
2693            .await;
2694        match result {
2695            ToolExecutionResult::Success(v) => {
2696                assert_eq!(v["name"], "repo-checker");
2697                assert_eq!(v["channels"].as_array().unwrap().len(), 1);
2698            }
2699            other => panic!("expected success, got: {other:?}"),
2700        }
2701    }
2702
2703    #[tokio::test]
2704    async fn manage_apps_publish_returns_published_app() {
2705        let ctx = mock_context();
2706        let tool = ManageAppsTool;
2707        let result = tool
2708            .execute_with_context(
2709                json!({"operation": "publish", "app_id": crate::AppId::new().to_string()}),
2710                &ctx,
2711            )
2712            .await;
2713        match result {
2714            ToolExecutionResult::Success(v) => assert_eq!(v["status"], "published"),
2715            other => panic!("expected success, got: {other:?}"),
2716        }
2717    }
2718
2719    #[tokio::test]
2720    async fn manage_apps_update_accepts_null_agent_identity() {
2721        let ctx = mock_context();
2722        let tool = ManageAppsTool;
2723        let result = tool
2724            .execute_with_context(
2725                json!({
2726                    "operation": "update",
2727                    "app_id": crate::AppId::new().to_string(),
2728                    "agent_identity_id": null
2729                }),
2730                &ctx,
2731            )
2732            .await;
2733        match result {
2734            ToolExecutionResult::Success(v) => assert!(v["agent_identity_id"].is_null()),
2735            other => panic!("expected success, got: {other:?}"),
2736        }
2737    }
2738
2739    // =========================================================================
2740    // ManageAppChannelsTool tests
2741    // =========================================================================
2742
2743    #[tokio::test]
2744    async fn manage_app_channels_add_returns_channel() {
2745        let ctx = mock_context();
2746        let tool = ManageAppChannelsTool;
2747        let result = tool
2748            .execute_with_context(
2749                json!({
2750                    "operation": "add",
2751                    "app_id": crate::AppId::new().to_string(),
2752                    "channel_type": "webhook",
2753                    "channel_config": {
2754                        "token": "secret-1",
2755                        "message": "process payload"
2756                    }
2757                }),
2758                &ctx,
2759            )
2760            .await;
2761        match result {
2762            ToolExecutionResult::Success(v) => {
2763                assert_eq!(v["channel"]["channel_type"], "webhook");
2764            }
2765            other => panic!("expected success, got: {other:?}"),
2766        }
2767    }
2768
2769    #[tokio::test]
2770    async fn manage_app_channels_delete_succeeds() {
2771        let ctx = mock_context();
2772        let tool = ManageAppChannelsTool;
2773        let result = tool
2774            .execute_with_context(
2775                json!({
2776                    "operation": "delete",
2777                    "app_id": crate::AppId::new().to_string(),
2778                    "channel_id": crate::AppChannelId::new().to_string()
2779                }),
2780                &ctx,
2781            )
2782            .await;
2783        match result {
2784            ToolExecutionResult::Success(v) => {
2785                assert!(v["message"].as_str().unwrap().contains("deleted"));
2786            }
2787            other => panic!("expected success, got: {other:?}"),
2788        }
2789    }
2790
2791    #[tokio::test]
2792    async fn manage_app_channels_invalid_channel_type_returns_error() {
2793        let ctx = mock_context();
2794        let tool = ManageAppChannelsTool;
2795        let result = tool
2796            .execute_with_context(
2797                json!({
2798                    "operation": "add",
2799                    "app_id": crate::AppId::new().to_string(),
2800                    "channel_type": "pagerduty"
2801                }),
2802                &ctx,
2803            )
2804            .await;
2805        match result {
2806            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Invalid channel_type")),
2807            other => panic!("expected tool error, got: {other:?}"),
2808        }
2809    }
2810
2811    // =========================================================================
2812    // ReadSessionsTool tests
2813    // =========================================================================
2814
2815    #[tokio::test]
2816    async fn read_sessions_list_returns_sessions() {
2817        let ctx = mock_context();
2818        let tool = ReadSessionsTool;
2819        let result = tool.execute_with_context(json!({}), &ctx).await;
2820        match result {
2821            ToolExecutionResult::Success(v) => {
2822                assert_eq!(v["count"], 1);
2823                assert!(
2824                    v["sessions"].as_array().unwrap()[0]["ui_link"]
2825                        .as_str()
2826                        .unwrap()
2827                        .contains("/chat")
2828                );
2829            }
2830            other => panic!("expected success, got: {other:?}"),
2831        }
2832    }
2833
2834    #[tokio::test]
2835    async fn read_sessions_get_by_id_succeeds() {
2836        let ctx = mock_context();
2837        let tool = ReadSessionsTool;
2838        let result = tool
2839            .execute_with_context(json!({"id": SessionId::new().to_string()}), &ctx)
2840            .await;
2841        match result {
2842            ToolExecutionResult::Success(v) => {
2843                assert_eq!(v["title"], "Test Session");
2844                assert!(v["ui_link"].as_str().unwrap().contains("/chat"));
2845            }
2846            other => panic!("expected success, got: {other:?}"),
2847        }
2848    }
2849
2850    #[tokio::test]
2851    async fn read_sessions_invalid_id_returns_error() {
2852        let ctx = mock_context();
2853        let tool = ReadSessionsTool;
2854        let result = tool.execute_with_context(json!({"id": "nope"}), &ctx).await;
2855        match result {
2856            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Invalid session id")),
2857            other => panic!("expected tool error, got: {other:?}"),
2858        }
2859    }
2860
2861    #[tokio::test]
2862    async fn session_context_report_returns_report() {
2863        let ctx = mock_context();
2864        let tool = SessionContextReportTool;
2865        let session_id = SessionId::new().to_string();
2866        let result = tool
2867            .execute_with_context(json!({"session_id": session_id}), &ctx)
2868            .await;
2869        match result {
2870            ToolExecutionResult::Success(value) => {
2871                assert_eq!(value["estimated_input_tokens"], 42);
2872                assert_eq!(value["sections"][0]["key"], "conversation");
2873            }
2874            other => panic!("expected success, got: {other:?}"),
2875        }
2876    }
2877
2878    // =========================================================================
2879    // ManageSessionsTool tests
2880    // =========================================================================
2881
2882    #[tokio::test]
2883    async fn session_create_returns_new_session() {
2884        let ctx = mock_context();
2885        let tool = ManageSessionsTool;
2886        let result = tool
2887            .execute_with_context(
2888                json!({"operation": "create", "harness_id": HarnessId::new().to_string(), "title": "My Session"}),
2889                &ctx,
2890            )
2891            .await;
2892        match result {
2893            ToolExecutionResult::Success(v) => {
2894                assert!(v["ui_link"].as_str().unwrap().contains("/chat"))
2895            }
2896            other => panic!("expected success, got: {other:?}"),
2897        }
2898    }
2899
2900    #[tokio::test]
2901    async fn session_delete_succeeds() {
2902        let ctx = mock_context();
2903        let tool = ManageSessionsTool;
2904        let result = tool
2905            .execute_with_context(
2906                json!({"operation": "delete", "session_id": SessionId::new().to_string()}),
2907                &ctx,
2908            )
2909            .await;
2910        match result {
2911            ToolExecutionResult::Success(v) => {
2912                assert!(v["message"].as_str().unwrap().contains("archived"));
2913            }
2914            other => panic!("expected success, got: {other:?}"),
2915        }
2916    }
2917
2918    #[tokio::test]
2919    async fn session_invalid_operation_returns_error() {
2920        let ctx = mock_context();
2921        let tool = ManageSessionsTool;
2922        let result = tool
2923            .execute_with_context(json!({"operation": "update"}), &ctx)
2924            .await;
2925        match result {
2926            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Unknown operation")),
2927            other => panic!("expected tool error, got: {other:?}"),
2928        }
2929    }
2930
2931    #[tokio::test]
2932    async fn session_create_missing_harness_id_falls_back_to_generic() {
2933        let ctx = mock_context();
2934        let tool = ManageSessionsTool;
2935        // Mock store has no built-in Generic harness, so fallback should error
2936        let result = tool
2937            .execute_with_context(json!({"operation": "create"}), &ctx)
2938            .await;
2939        match result {
2940            ToolExecutionResult::ToolError(msg) => {
2941                assert!(msg.contains("no default Generic harness found"))
2942            }
2943            other => panic!("expected tool error for missing Generic harness, got: {other:?}"),
2944        }
2945    }
2946
2947    // =========================================================================
2948    // SessionSendMessageTool tests
2949    // =========================================================================
2950
2951    #[tokio::test]
2952    async fn send_message_succeeds() {
2953        let ctx = mock_context();
2954        let tool = SessionSendMessageTool;
2955        let result = tool
2956            .execute_with_context(
2957                json!({"session_id": SessionId::new().to_string(), "content": "Hi!"}),
2958                &ctx,
2959            )
2960            .await;
2961        match result {
2962            ToolExecutionResult::Success(v) => {
2963                assert!(v["message"].as_str().unwrap().contains("sent"))
2964            }
2965            other => panic!("expected success, got: {other:?}"),
2966        }
2967    }
2968
2969    #[tokio::test]
2970    async fn send_message_missing_content_returns_error() {
2971        let ctx = mock_context();
2972        let tool = SessionSendMessageTool;
2973        let result = tool
2974            .execute_with_context(json!({"session_id": SessionId::new().to_string()}), &ctx)
2975            .await;
2976        match result {
2977            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Missing required")),
2978            other => panic!("expected tool error, got: {other:?}"),
2979        }
2980    }
2981
2982    #[tokio::test]
2983    async fn send_message_invalid_session_id_returns_error() {
2984        let ctx = mock_context();
2985        let tool = SessionSendMessageTool;
2986        let result = tool
2987            .execute_with_context(json!({"session_id": "bad-id", "content": "Hi!"}), &ctx)
2988            .await;
2989        match result {
2990            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Invalid session_id")),
2991            other => panic!("expected tool error, got: {other:?}"),
2992        }
2993    }
2994
2995    // =========================================================================
2996    // SessionReadMessagesTool tests
2997    // =========================================================================
2998
2999    #[tokio::test]
3000    async fn read_messages_returns_messages() {
3001        let ctx = mock_context();
3002        let tool = SessionReadMessagesTool;
3003        let result = tool
3004            .execute_with_context(
3005                json!({"session_id": SessionId::new().to_string(), "limit": 5}),
3006                &ctx,
3007            )
3008            .await;
3009        match result {
3010            ToolExecutionResult::Success(v) => {
3011                assert_eq!(v["count"], 2);
3012                let msgs = v["messages"].as_array().unwrap();
3013                assert_eq!(msgs[0]["role"], "user");
3014                assert_eq!(msgs[1]["role"], "agent");
3015            }
3016            other => panic!("expected success, got: {other:?}"),
3017        }
3018    }
3019
3020    #[tokio::test]
3021    async fn read_messages_applies_content_limit() {
3022        let ctx = mock_context();
3023        let tool = SessionReadMessagesTool;
3024        let result = tool
3025            .execute_with_context(
3026                json!({"session_id": SessionId::new().to_string(), "content_limit": 2}),
3027                &ctx,
3028            )
3029            .await;
3030        match result {
3031            ToolExecutionResult::Success(v) => {
3032                assert_eq!(v["content_limit"], 2);
3033                assert_eq!(v["truncated_message_count"], 2);
3034                let msgs = v["messages"].as_array().unwrap();
3035                assert_eq!(msgs[0]["content"], "He");
3036                assert_eq!(msgs[0]["content_truncated"], true);
3037                assert_eq!(msgs[0]["content_total_chars"], 5);
3038                assert_eq!(msgs[0]["content_returned_chars"], 2);
3039            }
3040            other => panic!("expected success, got: {other:?}"),
3041        }
3042    }
3043
3044    #[tokio::test]
3045    async fn read_messages_rejects_zero_limits() {
3046        let ctx = mock_context();
3047        let tool = SessionReadMessagesTool;
3048        let result = tool
3049            .execute_with_context(
3050                json!({"session_id": SessionId::new().to_string(), "limit": 0}),
3051                &ctx,
3052            )
3053            .await;
3054        match result {
3055            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("greater than 0")),
3056            other => panic!("expected tool error, got: {other:?}"),
3057        }
3058    }
3059
3060    #[tokio::test]
3061    async fn read_messages_invalid_session_id_returns_error() {
3062        let ctx = mock_context();
3063        let tool = SessionReadMessagesTool;
3064        let result = tool
3065            .execute_with_context(json!({"session_id": "bad-id"}), &ctx)
3066            .await;
3067        match result {
3068            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Invalid session_id")),
3069            other => panic!("expected tool error, got: {other:?}"),
3070        }
3071    }
3072
3073    #[tokio::test]
3074    async fn read_messages_missing_session_id_returns_error() {
3075        let ctx = mock_context();
3076        let tool = SessionReadMessagesTool;
3077        let result = tool.execute_with_context(json!({}), &ctx).await;
3078        match result {
3079            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Missing required")),
3080            other => panic!("expected tool error, got: {other:?}"),
3081        }
3082    }
3083
3084    // =========================================================================
3085    // SessionReadResponseTool tests
3086    // =========================================================================
3087
3088    #[tokio::test]
3089    async fn read_response_succeeds() {
3090        let ctx = mock_context();
3091        let tool = SessionReadResponseTool;
3092        let result = tool
3093            .execute_with_context(json!({"session_id": SessionId::new().to_string()}), &ctx)
3094            .await;
3095        match result {
3096            ToolExecutionResult::Success(v) => assert_eq!(v["status"], "idle"),
3097            other => panic!("expected success, got: {other:?}"),
3098        }
3099    }
3100
3101    // =========================================================================
3102    // Context and error tests
3103    // =========================================================================
3104
3105    #[tokio::test]
3106    async fn tool_without_context_returns_error() {
3107        let tool = ManageHarnessesTool;
3108        let result = tool.execute(json!({"operation": "create"})).await;
3109        match result {
3110            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("requires context")),
3111            other => panic!("expected tool error, got: {other:?}"),
3112        }
3113    }
3114
3115    #[tokio::test]
3116    async fn tool_without_platform_store_returns_error() {
3117        let ctx = ToolContext::new(SessionId::new());
3118        let tool = ReadHarnessesTool;
3119        let result = tool.execute_with_context(json!({}), &ctx).await;
3120        match result {
3121            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("not available")),
3122            other => panic!("expected tool error, got: {other:?}"),
3123        }
3124    }
3125
3126    #[tokio::test]
3127    async fn missing_operation_returns_error() {
3128        let ctx = mock_context();
3129        let tool = ManageHarnessesTool;
3130        let result = tool.execute_with_context(json!({}), &ctx).await;
3131        match result {
3132            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("operation")),
3133            other => panic!("expected tool error, got: {other:?}"),
3134        }
3135    }
3136
3137    #[tokio::test]
3138    async fn all_tools_require_context() {
3139        assert!(ReadCapabilitiesTool.requires_context());
3140        assert!(ReadHarnessesTool.requires_context());
3141        assert!(ManageHarnessesTool.requires_context());
3142        assert!(ReadAgentsTool.requires_context());
3143        assert!(ManageAgentsTool.requires_context());
3144        assert!(ReadAppsTool.requires_context());
3145        assert!(ManageAppsTool.requires_context());
3146        assert!(ManageAppChannelsTool.requires_context());
3147        assert!(ReadSessionsTool.requires_context());
3148        assert!(SessionContextReportTool.requires_context());
3149        assert!(ManageSessionsTool.requires_context());
3150        assert!(SessionSendMessageTool.requires_context());
3151        assert!(SessionReadMessagesTool.requires_context());
3152        assert!(SessionReadResponseTool.requires_context());
3153    }
3154
3155    #[tokio::test]
3156    async fn all_tools_without_context_return_error() {
3157        // execute() (no context) should fail for all tools
3158        for tool_name in [
3159            "read_capabilities",
3160            "read_harnesses",
3161            "manage_harnesses",
3162            "read_agents",
3163            "manage_agents",
3164            "read_apps",
3165            "manage_apps",
3166            "manage_app_channels",
3167            "read_sessions",
3168            "session_context_report",
3169            "manage_sessions",
3170            "session_send_message",
3171            "session_read_messages",
3172            "session_read_response",
3173        ] {
3174            let result = match tool_name {
3175                "read_capabilities" => ReadCapabilitiesTool.execute(json!({})).await,
3176                "read_harnesses" => ReadHarnessesTool.execute(json!({})).await,
3177                "manage_harnesses" => {
3178                    ManageHarnessesTool
3179                        .execute(json!({"operation": "create"}))
3180                        .await
3181                }
3182                "read_agents" => ReadAgentsTool.execute(json!({})).await,
3183                "manage_agents" => {
3184                    ManageAgentsTool
3185                        .execute(json!({"operation": "create"}))
3186                        .await
3187                }
3188                "read_apps" => ReadAppsTool.execute(json!({})).await,
3189                "manage_apps" => ManageAppsTool.execute(json!({"operation": "create"})).await,
3190                "manage_app_channels" => {
3191                    ManageAppChannelsTool
3192                        .execute(json!({"operation": "add", "app_id": "app_1"}))
3193                        .await
3194                }
3195                "read_sessions" => ReadSessionsTool.execute(json!({})).await,
3196                "session_context_report" => {
3197                    SessionContextReportTool
3198                        .execute(json!({"session_id": "x"}))
3199                        .await
3200                }
3201                "manage_sessions" => {
3202                    ManageSessionsTool
3203                        .execute(json!({"operation": "create"}))
3204                        .await
3205                }
3206                "session_send_message" => {
3207                    SessionSendMessageTool
3208                        .execute(json!({"session_id": "x", "content": "hi"}))
3209                        .await
3210                }
3211                "session_read_messages" => {
3212                    SessionReadMessagesTool
3213                        .execute(json!({"session_id": "x"}))
3214                        .await
3215                }
3216                "session_read_response" => {
3217                    SessionReadResponseTool
3218                        .execute(json!({"session_id": "x"}))
3219                        .await
3220                }
3221                _ => unreachable!(),
3222            };
3223            match result {
3224                ToolExecutionResult::ToolError(msg) => {
3225                    assert!(msg.contains("requires context"), "tool {tool_name}: {msg}");
3226                }
3227                other => panic!("{tool_name}: expected tool error, got: {other:?}"),
3228            }
3229        }
3230    }
3231
3232    // =========================================================================
3233    // ReadCapabilitiesTool tests
3234    // =========================================================================
3235
3236    #[tokio::test]
3237    async fn read_capabilities_returns_all() {
3238        let ctx = mock_context();
3239        let tool = ReadCapabilitiesTool;
3240        let result = tool.execute_with_context(json!({}), &ctx).await;
3241        match result {
3242            ToolExecutionResult::Success(v) => {
3243                let count = v["count"].as_u64().unwrap();
3244                assert!(count > 0, "should return at least one capability");
3245                let caps = v["capabilities"].as_array().unwrap();
3246                for cap in caps {
3247                    assert!(cap["id"].is_string());
3248                    assert!(cap["name"].is_string());
3249                    assert!(cap["type"].is_string());
3250                    assert!(cap["ui_link"].as_str().unwrap().contains("/capabilities/"));
3251                }
3252                assert!(v["hint"].as_str().unwrap().contains("capability IDs"));
3253            }
3254            other => panic!("expected success, got: {other:?}"),
3255        }
3256    }
3257
3258    #[tokio::test]
3259    async fn read_capabilities_search_filters_results() {
3260        let ctx = mock_context();
3261        let tool = ReadCapabilitiesTool;
3262        let result = tool
3263            .execute_with_context(json!({"search": "current_time"}), &ctx)
3264            .await;
3265        match result {
3266            ToolExecutionResult::Success(v) => {
3267                let count = v["count"].as_u64().unwrap();
3268                assert!(count >= 1, "should find at least current_time");
3269                let caps = v["capabilities"].as_array().unwrap();
3270                assert!(
3271                    caps.iter()
3272                        .any(|c| c["id"].as_str().unwrap() == "current_time"),
3273                    "should contain current_time"
3274                );
3275            }
3276            other => panic!("expected success, got: {other:?}"),
3277        }
3278    }
3279
3280    #[tokio::test]
3281    async fn read_capabilities_search_no_match() {
3282        let ctx = mock_context();
3283        let tool = ReadCapabilitiesTool;
3284        let result = tool
3285            .execute_with_context(json!({"search": "zzz_nonexistent_zzz"}), &ctx)
3286            .await;
3287        match result {
3288            ToolExecutionResult::Success(v) => {
3289                assert_eq!(v["count"], 0);
3290            }
3291            other => panic!("expected success, got: {other:?}"),
3292        }
3293    }
3294
3295    #[tokio::test]
3296    async fn read_capabilities_empty_id_returns_all() {
3297        let ctx = mock_context();
3298        let tool = ReadCapabilitiesTool;
3299        // LLMs sometimes send empty strings for optional params — must not crash
3300        let result = tool
3301            .execute_with_context(json!({"id": "", "search": ""}), &ctx)
3302            .await;
3303        match result {
3304            ToolExecutionResult::Success(v) => {
3305                let count = v["count"].as_u64().unwrap();
3306                assert!(count > 0, "empty id/search should return all capabilities");
3307            }
3308            other => panic!("expected success with all capabilities, got: {other:?}"),
3309        }
3310    }
3311
3312    #[tokio::test]
3313    async fn read_capabilities_empty_id_only_returns_all() {
3314        let ctx = mock_context();
3315        let tool = ReadCapabilitiesTool;
3316        let result = tool.execute_with_context(json!({"id": ""}), &ctx).await;
3317        match result {
3318            ToolExecutionResult::Success(v) => {
3319                let count = v["count"].as_u64().unwrap();
3320                assert!(count > 0, "empty id should return all capabilities");
3321            }
3322            other => panic!("expected success, got: {other:?}"),
3323        }
3324    }
3325
3326    #[test]
3327    fn capability_has_system_prompt_addition() {
3328        let cap = PlatformManagementCapability;
3329        let prompt = cap.system_prompt_addition().expect("should have prompt");
3330        assert!(prompt.contains("read_capabilities"));
3331        assert!(prompt.contains("Capabilities"));
3332    }
3333}