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