Skip to main content

everruns_core/
capability_dto.rs

1// Capability DTO types
2//
3// These types are API/DTO types for capabilities with ToSchema support.
4// Runtime types (CapabilityId, CapabilityStatus) are in capability_types.rs.
5
6use serde::{Deserialize, Serialize};
7
8#[cfg(feature = "openapi")]
9use utoipa::ToSchema;
10
11use crate::capabilities::RiskLevel;
12use crate::capability_types::{CapabilityId, CapabilityStatus};
13use crate::tool_types::ToolDefinition;
14
15/// Public capability information (without internal details)
16/// This is what gets returned from the API
17/// Named CapabilityInfo to distinguish from the Capability trait
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[cfg_attr(feature = "openapi", derive(ToSchema))]
20pub struct CapabilityInfo {
21    /// Unique capability identifier
22    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "session_file_system"))]
23    pub id: CapabilityId,
24    /// Display name
25    #[cfg_attr(feature = "openapi", schema(example = "Session File System"))]
26    pub name: String,
27    /// Description of what this capability provides
28    #[cfg_attr(
29        feature = "openapi",
30        schema(
31            example = "Read, write, edit, list, grep, delete, and stat files in the session workspace."
32        )
33    )]
34    pub description: String,
35    /// Current status
36    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "active"))]
37    pub status: CapabilityStatus,
38    /// Icon name (for UI rendering)
39    #[serde(skip_serializing_if = "Option::is_none")]
40    #[cfg_attr(feature = "openapi", schema(example = "Folder"))]
41    pub icon: Option<String>,
42    /// Category for grouping in UI
43    #[serde(skip_serializing_if = "Option::is_none")]
44    #[cfg_attr(feature = "openapi", schema(example = "filesystem"))]
45    pub category: Option<String>,
46    /// System prompt addition contributed by this capability
47    #[serde(skip_serializing_if = "Option::is_none")]
48    #[cfg_attr(
49        feature = "openapi",
50        schema(
51            example = "You can read and write files in /workspace via the session_file_system tools."
52        )
53    )]
54    pub system_prompt: Option<String>,
55    /// Tool definitions provided by this capability
56    #[serde(skip_serializing_if = "Vec::is_empty", default)]
57    #[cfg_attr(
58        feature = "openapi",
59        schema(
60            value_type = Vec<Object>,
61            example = json!([
62                {"name": "read_file", "description": "Read a file from the session workspace."},
63                {"name": "write_file", "description": "Write or overwrite a file in the session workspace."}
64            ])
65        )
66    )]
67    pub tool_definitions: Vec<ToolDefinition>,
68    /// Whether this is an MCP server capability (for UI badge)
69    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
70    #[cfg_attr(feature = "openapi", schema(example = false))]
71    pub is_mcp: bool,
72    /// Whether this is an Agent Skill capability (for UI badge)
73    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
74    #[cfg_attr(feature = "openapi", schema(example = false))]
75    pub is_skill: bool,
76    /// Whether this capability is a guardrail (constrains agent behavior
77    /// rather than granting abilities). Used for UI grouping and filtering.
78    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
79    #[cfg_attr(feature = "openapi", schema(example = false))]
80    pub is_guardrail: bool,
81    /// IDs of capabilities that this capability depends on.
82    /// When this capability is selected, its dependencies are automatically included.
83    #[serde(skip_serializing_if = "Vec::is_empty", default)]
84    #[cfg_attr(feature = "openapi", schema(example = json!(["approval"])))]
85    pub dependencies: Vec<String>,
86    /// UI feature strings this capability contributes to.
87    /// Multiple capabilities can contribute the same feature.
88    #[serde(skip_serializing_if = "Vec::is_empty", default)]
89    #[cfg_attr(feature = "openapi", schema(example = json!(["file_browser"])))]
90    pub features: Vec<String>,
91    /// JSON Schema for capability-specific per-agent config.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    #[cfg_attr(
94        feature = "openapi",
95        schema(
96            value_type = Object,
97            example = json!({
98                "type": "object",
99                "properties": {"max_file_bytes": {"type": "integer", "default": 1048576}}
100            })
101        )
102    )]
103    pub config_schema: Option<serde_json::Value>,
104    /// react-jsonschema-form uiSchema hints for rendering config_schema.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    #[cfg_attr(
107        feature = "openapi",
108        schema(
109            value_type = Object,
110            example = json!({"max_file_bytes": {"ui:widget": "updown"}})
111        )
112    )]
113    pub config_ui_schema: Option<serde_json::Value>,
114    /// TM-AGENT-005: Risk level. High-risk capabilities require admin approval.
115    #[serde(skip_serializing_if = "is_low_risk", default = "default_risk_level")]
116    #[cfg_attr(feature = "openapi", schema(example = "low"))]
117    pub risk_level: RiskLevel,
118    /// Number of active agents referencing this capability in the org.
119    #[serde(default, skip_serializing_if = "is_zero_u64")]
120    #[cfg_attr(feature = "openapi", schema(example = 42u64))]
121    pub agent_count: u64,
122    /// Number of active harnesses referencing this capability in the org.
123    #[serde(default, skip_serializing_if = "is_zero_u64")]
124    #[cfg_attr(feature = "openapi", schema(example = 7u64))]
125    pub harness_count: u64,
126    #[allow(rustdoc::bare_urls)]
127    /// Slug under https://dev.everruns.com/capabilities/ when public docs exist.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    #[cfg_attr(feature = "openapi", schema(example = "session_file_system"))]
130    pub docs_slug: Option<String>,
131    /// Localized display strings keyed by lowercase language tag (e.g. "uk").
132    /// The "en" entry carries only `config_description`, since the base
133    /// name/description/config_schema strings are already English.
134    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
135    #[cfg_attr(
136        feature = "openapi",
137        schema(
138            example = json!({
139                "uk": {"name": "Пам'ять", "description": "Монтує спільні файли пам'яті в сесії."}
140            })
141        )
142    )]
143    pub localizations: std::collections::BTreeMap<String, CapabilityLocalizationInfo>,
144}
145
146/// Localized display strings for one locale of a capability.
147#[derive(Debug, Clone, Default, Serialize, Deserialize)]
148#[cfg_attr(feature = "openapi", derive(ToSchema))]
149pub struct CapabilityLocalizationInfo {
150    /// Localized display name; absent means fall back to `name`.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub name: Option<String>,
153    /// Localized description; absent means fall back to `description`.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub description: Option<String>,
156    /// One-line summary of what this capability's config controls.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub config_description: Option<String>,
159    /// Overlay merged into `config_schema` before rendering: mirrors the
160    /// schema property tree with `title`/`description`/`enum_labels` leaves.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
163    pub config_overlay: Option<serde_json::Value>,
164}
165
166fn is_low_risk(r: &RiskLevel) -> bool {
167    *r == RiskLevel::Low
168}
169fn default_risk_level() -> RiskLevel {
170    RiskLevel::Low
171}
172fn is_zero_u64(v: &u64) -> bool {
173    *v == 0
174}
175
176#[allow(rustdoc::bare_urls)]
177/// Mapping from built-in capability ID to its docs slug under
178/// https://dev.everruns.com/capabilities/. Returns None for IDs that
179/// have no published documentation page.
180pub fn builtin_capability_docs_slug(id: &str) -> Option<&'static str> {
181    match id {
182        "agent_instructions" => Some("agent-instructions"),
183        "skills" => Some("agent-skills"),
184        "browserless" => Some("browserless"),
185        "budgeting" => Some("budgeting"),
186        "current_time" => Some("current-time"),
187        "daytona" => Some("daytona"),
188        "fake_aws" => Some("fake-aws"),
189        "fake_crm" => Some("fake-crm"),
190        "fake_warehouse" => Some("fake-warehouse"),
191        "github_scout" => Some("github-scout"),
192        "session_file_system" => Some("file-system"),
193        "infinity_context" => Some("infinity-context"),
194        "openai_image_generation" => Some("openai-image-generation"),
195        "openai_tool_search" => Some("openai-tool-search"),
196        "tool_search" => Some("tool-search"),
197        "auto_tool_search" => Some("auto-tool-search"),
198        "platform_management" => Some("platform-management"),
199        "prompt_canary_guardrail" => Some("prompt-canary-guardrail"),
200        "self_budget" => Some("self-budget"),
201        "session_schedule" => Some("session-schedules"),
202        "session_storage" => Some("session-storage"),
203        "session_sandbox" => Some("session"),
204        "session_sql_database" => Some("sql-database"),
205        "subagents" => Some("sub-agents"),
206        "stateless_todo_list" => Some("task-management"),
207        "bashkit_shell" => Some("bashkit-shell"),
208        "web_fetch" => Some("web-fetch"),
209        _ => None,
210    }
211}
212
213impl CapabilityInfo {
214    /// Case-insensitive search across name, description, category, and ID.
215    pub fn matches_search(&self, query: &str) -> bool {
216        let q = query.to_lowercase();
217        self.name.to_lowercase().contains(&q)
218            || self.description.to_lowercase().contains(&q)
219            || self.id.as_str().to_lowercase().contains(&q)
220            || self
221                .category
222                .as_deref()
223                .is_some_and(|cat| cat.to_lowercase().contains(&q))
224    }
225
226    /// Create a CapabilityInfo DTO from a core Capability trait object
227    pub fn from_core(cap: &dyn crate::capabilities::Capability) -> Self {
228        // Check if this is an MCP or skill capability by checking the ID prefix
229        let id_str = cap.id();
230        let is_mcp = id_str.starts_with("mcp:");
231        let is_skill =
232            id_str.starts_with("skill:") || id_str == "skills" || cap.category() == Some("Skills");
233
234        Self {
235            id: CapabilityId::new(id_str),
236            name: cap.name().to_string(),
237            description: cap.description().to_string(),
238            status: cap.status(),
239            icon: cap.icon().map(|s| s.to_string()),
240            category: cap.category().map(|s| s.to_string()),
241            system_prompt: cap.system_prompt_preview(),
242            tool_definitions: cap.tool_definitions(),
243            is_mcp,
244            is_skill,
245            is_guardrail: cap.is_guardrail(),
246            dependencies: cap.dependencies().iter().map(|s| s.to_string()).collect(),
247            features: cap.features().iter().map(|s| s.to_string()).collect(),
248            config_schema: cap.config_schema(),
249            config_ui_schema: cap.config_ui_schema(),
250            risk_level: cap.risk_level(),
251            agent_count: 0,
252            harness_count: 0,
253            docs_slug: builtin_capability_docs_slug(id_str).map(|s| s.to_string()),
254            localizations: cap
255                .localizations()
256                .into_iter()
257                .map(|entry| {
258                    (
259                        entry.locale.to_lowercase(),
260                        CapabilityLocalizationInfo {
261                            name: entry.name.map(str::to_string),
262                            description: entry.description.map(str::to_string),
263                            config_description: entry.config_description.map(str::to_string),
264                            config_overlay: entry.config_overlay,
265                        },
266                    )
267                })
268                .collect(),
269        }
270    }
271}
272
273/// Agent capability assignment with ordering
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[cfg_attr(feature = "openapi", derive(ToSchema))]
276pub struct AgentCapability {
277    /// The capability ID
278    #[cfg_attr(feature = "openapi", schema(value_type = String))]
279    pub capability_id: CapabilityId,
280    /// Position in the chain (lower = earlier)
281    pub position: i32,
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_capability_info_serialization() {
290        let cap = CapabilityInfo {
291            id: CapabilityId::new("research"),
292            name: "Research".to_string(),
293            description: "Deep research capability".to_string(),
294            status: CapabilityStatus::Available,
295            icon: Some("search".to_string()),
296            category: Some("AI".to_string()),
297            system_prompt: Some("You have research capabilities.".to_string()),
298            tool_definitions: vec![],
299            is_mcp: false,
300            is_skill: false,
301            is_guardrail: false,
302            dependencies: vec![],
303            features: vec![],
304            config_schema: None,
305            config_ui_schema: None,
306            risk_level: RiskLevel::Low,
307            agent_count: 0,
308            harness_count: 0,
309            docs_slug: None,
310            localizations: Default::default(),
311        };
312
313        let json = serde_json::to_string(&cap).unwrap();
314        assert!(json.contains("\"id\":\"research\""));
315        assert!(json.contains("\"status\":\"available\""));
316        assert!(json.contains("\"system_prompt\":\"You have research capabilities.\""));
317        // is_mcp: false should be skipped in serialization
318        assert!(!json.contains("\"is_mcp\""));
319        // is_skill: false should be skipped in serialization
320        assert!(!json.contains("\"is_skill\""));
321        // Empty dependencies should be skipped in serialization
322        assert!(!json.contains("\"dependencies\""));
323        // Empty features should be skipped in serialization
324        assert!(!json.contains("\"features\""));
325    }
326
327    #[test]
328    fn test_mcp_capability_info_serialization() {
329        let cap = CapabilityInfo {
330            id: CapabilityId::new("mcp:550e8400-e29b-41d4-a716-446655440000"),
331            name: "Microsoft Learn".to_string(),
332            description: "MCP Server for Microsoft documentation".to_string(),
333            status: CapabilityStatus::Available,
334            icon: Some("plug".to_string()),
335            category: Some("MCP Servers".to_string()),
336            system_prompt: None,
337            tool_definitions: vec![],
338            is_mcp: true,
339            is_skill: false,
340            is_guardrail: false,
341            dependencies: vec![],
342            features: vec![],
343            config_schema: None,
344            config_ui_schema: None,
345            risk_level: RiskLevel::Low,
346            agent_count: 0,
347            harness_count: 0,
348            docs_slug: None,
349            localizations: Default::default(),
350        };
351
352        let json = serde_json::to_string(&cap).unwrap();
353        assert!(json.contains("\"is_mcp\":true"));
354    }
355
356    #[test]
357    fn test_capability_with_dependencies_serialization() {
358        let cap = CapabilityInfo {
359            id: CapabilityId::new("sample_data"),
360            name: "Sample Data".to_string(),
361            description: "Sample data for testing".to_string(),
362            status: CapabilityStatus::Available,
363            icon: None,
364            category: None,
365            system_prompt: None,
366            tool_definitions: vec![],
367            is_mcp: false,
368            is_skill: false,
369            is_guardrail: false,
370            dependencies: vec!["session_file_system".to_string()],
371            features: vec![],
372            config_schema: None,
373            config_ui_schema: None,
374            risk_level: RiskLevel::Low,
375            agent_count: 0,
376            harness_count: 0,
377            docs_slug: None,
378            localizations: Default::default(),
379        };
380
381        let json = serde_json::to_string(&cap).unwrap();
382        assert!(json.contains("\"dependencies\":[\"session_file_system\"]"));
383    }
384
385    #[test]
386    fn test_agent_capability_serialization() {
387        let agent_cap = AgentCapability {
388            capability_id: CapabilityId::new("test_math"),
389            position: 1,
390        };
391
392        let json = serde_json::to_string(&agent_cap).unwrap();
393        assert!(json.contains("\"capability_id\":\"test_math\""));
394        assert!(json.contains("\"position\":1"));
395    }
396
397    #[test]
398    fn test_test_capabilities() {
399        // Verify test math and weather capabilities are available
400        assert_eq!(CapabilityId::new("test_math").to_string(), "test_math");
401        assert_eq!(
402            CapabilityId::new("test_weather").to_string(),
403            "test_weather"
404        );
405    }
406
407    #[test]
408    fn test_custom_capability_id() {
409        // Custom capability IDs should work
410        let custom = CapabilityId::new("my_custom_capability");
411        assert_eq!(custom.to_string(), "my_custom_capability");
412
413        let json = serde_json::to_string(&custom).unwrap();
414        assert_eq!(json, "\"my_custom_capability\"");
415    }
416
417    #[test]
418    fn test_capability_with_features_serialization() {
419        let cap = CapabilityInfo {
420            id: CapabilityId::new("session_storage"),
421            name: "Storage".to_string(),
422            description: "Storage capability".to_string(),
423            status: CapabilityStatus::Available,
424            icon: None,
425            category: None,
426            system_prompt: None,
427            tool_definitions: vec![],
428            is_mcp: false,
429            is_skill: false,
430            is_guardrail: false,
431            dependencies: vec![],
432            features: vec!["secrets".to_string(), "key_value".to_string()],
433            config_schema: None,
434            config_ui_schema: None,
435            risk_level: RiskLevel::Low,
436            agent_count: 0,
437            harness_count: 0,
438            docs_slug: None,
439            localizations: Default::default(),
440        };
441
442        let json = serde_json::to_string(&cap).unwrap();
443        assert!(json.contains("\"features\":[\"secrets\",\"key_value\"]"));
444    }
445
446    #[test]
447    fn test_from_core_populates_features() {
448        let registry = crate::capabilities::CapabilityRegistry::with_builtins();
449
450        let schedule_cap = registry.get("session_schedule").unwrap();
451        let info = CapabilityInfo::from_core(schedule_cap.as_ref());
452        assert_eq!(info.features, vec!["schedules"]);
453
454        let storage_cap = registry.get("session_storage").unwrap();
455        let info = CapabilityInfo::from_core(storage_cap.as_ref());
456        assert!(info.features.contains(&"secrets".to_string()));
457        assert!(info.features.contains(&"key_value".to_string()));
458
459        // Capability with no features
460        let noop_cap = registry.get("noop").unwrap();
461        let info = CapabilityInfo::from_core(noop_cap.as_ref());
462        assert!(info.features.is_empty());
463    }
464
465    #[test]
466    fn test_risk_level_serialization() {
467        // Low risk should be skipped in serialization
468        let cap = CapabilityInfo {
469            id: CapabilityId::new("safe"),
470            name: "Safe".to_string(),
471            description: "Low risk".to_string(),
472            status: CapabilityStatus::Available,
473            icon: None,
474            category: None,
475            system_prompt: None,
476            tool_definitions: vec![],
477            is_mcp: false,
478            is_skill: false,
479            is_guardrail: false,
480            dependencies: vec![],
481            features: vec![],
482            config_schema: None,
483            config_ui_schema: None,
484            risk_level: RiskLevel::Low,
485            agent_count: 0,
486            harness_count: 0,
487            docs_slug: None,
488            localizations: Default::default(),
489        };
490        let json = serde_json::to_string(&cap).unwrap();
491        assert!(
492            !json.contains("\"risk_level\""),
493            "Low risk should be omitted"
494        );
495
496        // High risk should be present
497        let cap_high = CapabilityInfo {
498            risk_level: RiskLevel::High,
499            ..cap
500        };
501        let json = serde_json::to_string(&cap_high).unwrap();
502        assert!(json.contains("\"risk_level\":\"high\""));
503    }
504
505    #[test]
506    fn test_from_core_populates_risk_level() {
507        let registry = crate::capabilities::CapabilityRegistry::with_builtins();
508
509        // bashkit_shell is High risk (code execution)
510        let bash_cap = registry.get("bashkit_shell").unwrap();
511        let info = CapabilityInfo::from_core(bash_cap.as_ref());
512        assert_eq!(info.risk_level, RiskLevel::High);
513
514        // web_fetch is High risk (network access)
515        let fetch_cap = registry.get("web_fetch").unwrap();
516        let info = CapabilityInfo::from_core(fetch_cap.as_ref());
517        assert_eq!(info.risk_level, RiskLevel::High);
518
519        // noop is Low risk (default)
520        let noop_cap = registry.get("noop").unwrap();
521        let info = CapabilityInfo::from_core(noop_cap.as_ref());
522        assert_eq!(info.risk_level, RiskLevel::Low);
523    }
524
525    #[test]
526    fn test_matches_search() {
527        let cap = CapabilityInfo {
528            id: CapabilityId::new("web_fetch"),
529            name: "Web Fetch".to_string(),
530            description: "Fetch content from URLs".to_string(),
531            status: CapabilityStatus::Available,
532            icon: None,
533            category: Some("Network".to_string()),
534            system_prompt: None,
535            tool_definitions: vec![],
536            is_mcp: false,
537            is_skill: false,
538            is_guardrail: false,
539            dependencies: vec![],
540            features: vec![],
541            config_schema: None,
542            config_ui_schema: None,
543            risk_level: RiskLevel::Low,
544            agent_count: 0,
545            harness_count: 0,
546            docs_slug: None,
547            localizations: Default::default(),
548        };
549
550        // Matches by name (case-insensitive)
551        assert!(cap.matches_search("web"));
552        assert!(cap.matches_search("WEB FETCH"));
553        // Matches by description
554        assert!(cap.matches_search("urls"));
555        // Matches by ID
556        assert!(cap.matches_search("web_fetch"));
557        // Matches by category
558        assert!(cap.matches_search("network"));
559        // No match
560        assert!(!cap.matches_search("zzz_nonexistent"));
561    }
562}