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    /// IDs of capabilities that this capability depends on.
77    /// When this capability is selected, its dependencies are automatically included.
78    #[serde(skip_serializing_if = "Vec::is_empty", default)]
79    #[cfg_attr(feature = "openapi", schema(example = json!(["approval"])))]
80    pub dependencies: Vec<String>,
81    /// UI feature strings this capability contributes to.
82    /// Multiple capabilities can contribute the same feature.
83    #[serde(skip_serializing_if = "Vec::is_empty", default)]
84    #[cfg_attr(feature = "openapi", schema(example = json!(["file_browser"])))]
85    pub features: Vec<String>,
86    /// JSON Schema for capability-specific per-agent config.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    #[cfg_attr(
89        feature = "openapi",
90        schema(
91            value_type = Object,
92            example = json!({
93                "type": "object",
94                "properties": {"max_file_bytes": {"type": "integer", "default": 1048576}}
95            })
96        )
97    )]
98    pub config_schema: Option<serde_json::Value>,
99    /// react-jsonschema-form uiSchema hints for rendering config_schema.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    #[cfg_attr(
102        feature = "openapi",
103        schema(
104            value_type = Object,
105            example = json!({"max_file_bytes": {"ui:widget": "updown"}})
106        )
107    )]
108    pub config_ui_schema: Option<serde_json::Value>,
109    /// TM-AGENT-005: Risk level. High-risk capabilities require admin approval.
110    #[serde(skip_serializing_if = "is_low_risk", default = "default_risk_level")]
111    #[cfg_attr(feature = "openapi", schema(example = "low"))]
112    pub risk_level: RiskLevel,
113    /// Number of active agents referencing this capability in the org.
114    #[serde(default, skip_serializing_if = "is_zero_u64")]
115    #[cfg_attr(feature = "openapi", schema(example = 42u64))]
116    pub agent_count: u64,
117    /// Number of active harnesses referencing this capability in the org.
118    #[serde(default, skip_serializing_if = "is_zero_u64")]
119    #[cfg_attr(feature = "openapi", schema(example = 7u64))]
120    pub harness_count: u64,
121    #[allow(rustdoc::bare_urls)]
122    /// Slug under https://dev.everruns.com/capabilities/ when public docs exist.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    #[cfg_attr(feature = "openapi", schema(example = "session_file_system"))]
125    pub docs_slug: Option<String>,
126}
127
128fn is_low_risk(r: &RiskLevel) -> bool {
129    *r == RiskLevel::Low
130}
131fn default_risk_level() -> RiskLevel {
132    RiskLevel::Low
133}
134fn is_zero_u64(v: &u64) -> bool {
135    *v == 0
136}
137
138#[allow(rustdoc::bare_urls)]
139/// Mapping from built-in capability ID to its docs slug under
140/// https://dev.everruns.com/capabilities/. Returns None for IDs that
141/// have no published documentation page.
142pub fn builtin_capability_docs_slug(id: &str) -> Option<&'static str> {
143    match id {
144        "agent_instructions" => Some("agent-instructions"),
145        "skills" => Some("agent-skills"),
146        "browserless" => Some("browserless"),
147        "budgeting" => Some("budgeting"),
148        "current_time" => Some("current-time"),
149        "daytona" => Some("daytona"),
150        "fake_aws" => Some("fake-aws"),
151        "fake_crm" => Some("fake-crm"),
152        "fake_warehouse" => Some("fake-warehouse"),
153        "github_scout" => Some("github-scout"),
154        "session_file_system" => Some("file-system"),
155        "infinity_context" => Some("infinity-context"),
156        "openai_image_generation" => Some("openai-image-generation"),
157        "openai_tool_search" => Some("openai-tool-search"),
158        "tool_search" => Some("generic-tool-search"),
159        "auto_tool_search" => Some("auto-tool-search"),
160        "platform_management" => Some("platform-management"),
161        "prompt_canary_guardrail" => Some("prompt-canary-guardrail"),
162        "self_budget" => Some("self-budget"),
163        "session_schedule" => Some("session-schedules"),
164        "session_storage" => Some("session-storage"),
165        "session_sandbox" => Some("session"),
166        "session_sql_database" => Some("sql-database"),
167        "subagents" => Some("sub-agents"),
168        "stateless_todo_list" => Some("task-management"),
169        "virtual_bash" => Some("virtual-bash"),
170        "web_fetch" => Some("web-fetch"),
171        _ => None,
172    }
173}
174
175impl CapabilityInfo {
176    /// Case-insensitive search across name, description, category, and ID.
177    pub fn matches_search(&self, query: &str) -> bool {
178        let q = query.to_lowercase();
179        self.name.to_lowercase().contains(&q)
180            || self.description.to_lowercase().contains(&q)
181            || self.id.as_str().to_lowercase().contains(&q)
182            || self
183                .category
184                .as_deref()
185                .is_some_and(|cat| cat.to_lowercase().contains(&q))
186    }
187
188    /// Create a CapabilityInfo DTO from a core Capability trait object
189    pub fn from_core(cap: &dyn crate::capabilities::Capability) -> Self {
190        // Check if this is an MCP or skill capability by checking the ID prefix
191        let id_str = cap.id();
192        let is_mcp = id_str.starts_with("mcp:");
193        let is_skill =
194            id_str.starts_with("skill:") || id_str == "skills" || cap.category() == Some("Skills");
195
196        Self {
197            id: CapabilityId::new(id_str),
198            name: cap.name().to_string(),
199            description: cap.description().to_string(),
200            status: cap.status(),
201            icon: cap.icon().map(|s| s.to_string()),
202            category: cap.category().map(|s| s.to_string()),
203            system_prompt: cap.system_prompt_preview(),
204            tool_definitions: cap.tool_definitions(),
205            is_mcp,
206            is_skill,
207            dependencies: cap.dependencies().iter().map(|s| s.to_string()).collect(),
208            features: cap.features().iter().map(|s| s.to_string()).collect(),
209            config_schema: cap.config_schema(),
210            config_ui_schema: cap.config_ui_schema(),
211            risk_level: cap.risk_level(),
212            agent_count: 0,
213            harness_count: 0,
214            docs_slug: builtin_capability_docs_slug(id_str).map(|s| s.to_string()),
215        }
216    }
217}
218
219/// Agent capability assignment with ordering
220#[derive(Debug, Clone, Serialize, Deserialize)]
221#[cfg_attr(feature = "openapi", derive(ToSchema))]
222pub struct AgentCapability {
223    /// The capability ID
224    #[cfg_attr(feature = "openapi", schema(value_type = String))]
225    pub capability_id: CapabilityId,
226    /// Position in the chain (lower = earlier)
227    pub position: i32,
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_capability_info_serialization() {
236        let cap = CapabilityInfo {
237            id: CapabilityId::new("research"),
238            name: "Research".to_string(),
239            description: "Deep research capability".to_string(),
240            status: CapabilityStatus::Available,
241            icon: Some("search".to_string()),
242            category: Some("AI".to_string()),
243            system_prompt: Some("You have research capabilities.".to_string()),
244            tool_definitions: vec![],
245            is_mcp: false,
246            is_skill: false,
247            dependencies: vec![],
248            features: vec![],
249            config_schema: None,
250            config_ui_schema: None,
251            risk_level: RiskLevel::Low,
252            agent_count: 0,
253            harness_count: 0,
254            docs_slug: None,
255        };
256
257        let json = serde_json::to_string(&cap).unwrap();
258        assert!(json.contains("\"id\":\"research\""));
259        assert!(json.contains("\"status\":\"available\""));
260        assert!(json.contains("\"system_prompt\":\"You have research capabilities.\""));
261        // is_mcp: false should be skipped in serialization
262        assert!(!json.contains("\"is_mcp\""));
263        // is_skill: false should be skipped in serialization
264        assert!(!json.contains("\"is_skill\""));
265        // Empty dependencies should be skipped in serialization
266        assert!(!json.contains("\"dependencies\""));
267        // Empty features should be skipped in serialization
268        assert!(!json.contains("\"features\""));
269    }
270
271    #[test]
272    fn test_mcp_capability_info_serialization() {
273        let cap = CapabilityInfo {
274            id: CapabilityId::new("mcp:550e8400-e29b-41d4-a716-446655440000"),
275            name: "Microsoft Learn".to_string(),
276            description: "MCP Server for Microsoft documentation".to_string(),
277            status: CapabilityStatus::Available,
278            icon: Some("plug".to_string()),
279            category: Some("MCP Servers".to_string()),
280            system_prompt: None,
281            tool_definitions: vec![],
282            is_mcp: true,
283            is_skill: false,
284            dependencies: vec![],
285            features: vec![],
286            config_schema: None,
287            config_ui_schema: None,
288            risk_level: RiskLevel::Low,
289            agent_count: 0,
290            harness_count: 0,
291            docs_slug: None,
292        };
293
294        let json = serde_json::to_string(&cap).unwrap();
295        assert!(json.contains("\"is_mcp\":true"));
296    }
297
298    #[test]
299    fn test_capability_with_dependencies_serialization() {
300        let cap = CapabilityInfo {
301            id: CapabilityId::new("sample_data"),
302            name: "Sample Data".to_string(),
303            description: "Sample data for testing".to_string(),
304            status: CapabilityStatus::Available,
305            icon: None,
306            category: None,
307            system_prompt: None,
308            tool_definitions: vec![],
309            is_mcp: false,
310            is_skill: false,
311            dependencies: vec!["session_file_system".to_string()],
312            features: vec![],
313            config_schema: None,
314            config_ui_schema: None,
315            risk_level: RiskLevel::Low,
316            agent_count: 0,
317            harness_count: 0,
318            docs_slug: None,
319        };
320
321        let json = serde_json::to_string(&cap).unwrap();
322        assert!(json.contains("\"dependencies\":[\"session_file_system\"]"));
323    }
324
325    #[test]
326    fn test_agent_capability_serialization() {
327        let agent_cap = AgentCapability {
328            capability_id: CapabilityId::new("test_math"),
329            position: 1,
330        };
331
332        let json = serde_json::to_string(&agent_cap).unwrap();
333        assert!(json.contains("\"capability_id\":\"test_math\""));
334        assert!(json.contains("\"position\":1"));
335    }
336
337    #[test]
338    fn test_test_capabilities() {
339        // Verify test math and weather capabilities are available
340        assert_eq!(CapabilityId::new("test_math").to_string(), "test_math");
341        assert_eq!(
342            CapabilityId::new("test_weather").to_string(),
343            "test_weather"
344        );
345    }
346
347    #[test]
348    fn test_custom_capability_id() {
349        // Custom capability IDs should work
350        let custom = CapabilityId::new("my_custom_capability");
351        assert_eq!(custom.to_string(), "my_custom_capability");
352
353        let json = serde_json::to_string(&custom).unwrap();
354        assert_eq!(json, "\"my_custom_capability\"");
355    }
356
357    #[test]
358    fn test_capability_with_features_serialization() {
359        let cap = CapabilityInfo {
360            id: CapabilityId::new("session_storage"),
361            name: "Storage".to_string(),
362            description: "Storage capability".to_string(),
363            status: CapabilityStatus::Available,
364            icon: None,
365            category: None,
366            system_prompt: None,
367            tool_definitions: vec![],
368            is_mcp: false,
369            is_skill: false,
370            dependencies: vec![],
371            features: vec!["secrets".to_string(), "key_value".to_string()],
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        };
379
380        let json = serde_json::to_string(&cap).unwrap();
381        assert!(json.contains("\"features\":[\"secrets\",\"key_value\"]"));
382    }
383
384    #[test]
385    fn test_from_core_populates_features() {
386        let registry = crate::capabilities::CapabilityRegistry::with_builtins();
387
388        let schedule_cap = registry.get("session_schedule").unwrap();
389        let info = CapabilityInfo::from_core(schedule_cap.as_ref());
390        assert_eq!(info.features, vec!["schedules"]);
391
392        let storage_cap = registry.get("session_storage").unwrap();
393        let info = CapabilityInfo::from_core(storage_cap.as_ref());
394        assert!(info.features.contains(&"secrets".to_string()));
395        assert!(info.features.contains(&"key_value".to_string()));
396
397        // Capability with no features
398        let noop_cap = registry.get("noop").unwrap();
399        let info = CapabilityInfo::from_core(noop_cap.as_ref());
400        assert!(info.features.is_empty());
401    }
402
403    #[test]
404    fn test_risk_level_serialization() {
405        // Low risk should be skipped in serialization
406        let cap = CapabilityInfo {
407            id: CapabilityId::new("safe"),
408            name: "Safe".to_string(),
409            description: "Low risk".to_string(),
410            status: CapabilityStatus::Available,
411            icon: None,
412            category: None,
413            system_prompt: None,
414            tool_definitions: vec![],
415            is_mcp: false,
416            is_skill: false,
417            dependencies: vec![],
418            features: vec![],
419            config_schema: None,
420            config_ui_schema: None,
421            risk_level: RiskLevel::Low,
422            agent_count: 0,
423            harness_count: 0,
424            docs_slug: None,
425        };
426        let json = serde_json::to_string(&cap).unwrap();
427        assert!(
428            !json.contains("\"risk_level\""),
429            "Low risk should be omitted"
430        );
431
432        // High risk should be present
433        let cap_high = CapabilityInfo {
434            risk_level: RiskLevel::High,
435            ..cap
436        };
437        let json = serde_json::to_string(&cap_high).unwrap();
438        assert!(json.contains("\"risk_level\":\"high\""));
439    }
440
441    #[test]
442    fn test_from_core_populates_risk_level() {
443        let registry = crate::capabilities::CapabilityRegistry::with_builtins();
444
445        // virtual_bash is High risk (code execution)
446        let bash_cap = registry.get("virtual_bash").unwrap();
447        let info = CapabilityInfo::from_core(bash_cap.as_ref());
448        assert_eq!(info.risk_level, RiskLevel::High);
449
450        // web_fetch is High risk (network access)
451        let fetch_cap = registry.get("web_fetch").unwrap();
452        let info = CapabilityInfo::from_core(fetch_cap.as_ref());
453        assert_eq!(info.risk_level, RiskLevel::High);
454
455        // noop is Low risk (default)
456        let noop_cap = registry.get("noop").unwrap();
457        let info = CapabilityInfo::from_core(noop_cap.as_ref());
458        assert_eq!(info.risk_level, RiskLevel::Low);
459    }
460
461    #[test]
462    fn test_matches_search() {
463        let cap = CapabilityInfo {
464            id: CapabilityId::new("web_fetch"),
465            name: "Web Fetch".to_string(),
466            description: "Fetch content from URLs".to_string(),
467            status: CapabilityStatus::Available,
468            icon: None,
469            category: Some("Network".to_string()),
470            system_prompt: None,
471            tool_definitions: vec![],
472            is_mcp: false,
473            is_skill: false,
474            dependencies: vec![],
475            features: vec![],
476            config_schema: None,
477            config_ui_schema: None,
478            risk_level: RiskLevel::Low,
479            agent_count: 0,
480            harness_count: 0,
481            docs_slug: None,
482        };
483
484        // Matches by name (case-insensitive)
485        assert!(cap.matches_search("web"));
486        assert!(cap.matches_search("WEB FETCH"));
487        // Matches by description
488        assert!(cap.matches_search("urls"));
489        // Matches by ID
490        assert!(cap.matches_search("web_fetch"));
491        // Matches by category
492        assert!(cap.matches_search("network"));
493        // No match
494        assert!(!cap.matches_search("zzz_nonexistent"));
495    }
496}