Skip to main content

codetether_agent/cognition/
executor.rs

1//! Execution Engine — Capability Leases, Tool Execution, and Decision Receipts.
2
3use chrono::{DateTime, Duration, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::sync::Arc;
7use uuid::Uuid;
8
9use super::thinker::ThinkerClient;
10use super::{ThoughtEvent, ThoughtEventType, trim_for_storage};
11use crate::tool::ToolRegistry;
12
13/// A short-lived capability granting tool access to a persona.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CapabilityLease {
16    pub id: String,
17    pub persona_id: String,
18    pub tool_name: String,
19    pub granted_at: DateTime<Utc>,
20    pub expires_at: DateTime<Utc>,
21    pub granted_by: String,
22}
23
24impl CapabilityLease {
25    /// Create a new 60-second capability lease.
26    pub fn new(persona_id: &str, tool_name: &str, granted_by: &str) -> Self {
27        let now = Utc::now();
28        Self {
29            id: Uuid::new_v4().to_string(),
30            persona_id: persona_id.to_string(),
31            tool_name: tool_name.to_string(),
32            granted_at: now,
33            expires_at: now + Duration::seconds(60),
34            granted_by: granted_by.to_string(),
35        }
36    }
37
38    /// Check if the lease is still valid.
39    pub fn is_valid(&self) -> bool {
40        Utc::now() < self.expires_at
41    }
42}
43
44/// A single tool invocation record.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ToolInvocation {
47    pub tool_name: String,
48    pub arguments: Value,
49    pub result: Option<String>,
50    pub success: bool,
51    pub lease_id: String,
52    pub invoked_at: DateTime<Utc>,
53}
54
55/// Outcome of a proposal execution.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(tag = "type", rename_all = "snake_case")]
58pub enum ExecutionOutcome {
59    Success {
60        summary: String,
61    },
62    Failure {
63        error: String,
64        follow_up_attention: Option<String>,
65    },
66}
67
68/// Full audit trail for a proposal execution.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct DecisionReceipt {
71    pub id: String,
72    pub proposal_id: String,
73    pub inputs: Vec<String>,
74    pub governance_decision: String,
75    pub capability_leases: Vec<String>,
76    pub tool_invocations: Vec<ToolInvocation>,
77    pub outcome: ExecutionOutcome,
78    pub created_at: DateTime<Utc>,
79}
80
81/// Raw tool request extracted from LLM structured output.
82#[derive(Debug, Clone, Deserialize)]
83struct ExtractedToolRequest {
84    tool: String,
85    arguments: Value,
86}
87
88/// Wrapper for tool request extraction response.
89#[derive(Debug, Deserialize)]
90struct ToolRequestResponse {
91    tool_requests: Vec<ExtractedToolRequest>,
92}
93
94/// Validate tool arguments against the tool's JSON Schema.
95fn validate_tool_args(schema: &Value, args: &Value) -> Result<(), String> {
96    // Validate required fields
97    if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
98        for field in required {
99            if let Some(field_name) = field.as_str() {
100                if args.get(field_name).is_none() {
101                    return Err(format!("Missing required field: {}", field_name));
102                }
103            }
104        }
105    }
106
107    // Validate field types
108    if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
109        if let Some(args_obj) = args.as_object() {
110            for (key, value) in args_obj {
111                if let Some(prop_schema) = properties.get(key) {
112                    if let Some(expected_type) = prop_schema.get("type").and_then(|t| t.as_str()) {
113                        let type_ok = match expected_type {
114                            "string" => value.is_string(),
115                            "number" | "integer" => value.is_number(),
116                            "boolean" => value.is_boolean(),
117                            "array" => value.is_array(),
118                            "object" => value.is_object(),
119                            _ => true,
120                        };
121                        if !type_ok {
122                            return Err(format!(
123                                "Field '{}' has wrong type: expected {}, got {}",
124                                key,
125                                expected_type,
126                                json_type_name(value)
127                            ));
128                        }
129                    }
130                }
131            }
132        }
133    }
134
135    Ok(())
136}
137
138fn json_type_name(v: &Value) -> &'static str {
139    match v {
140        Value::Null => "null",
141        Value::Bool(_) => "boolean",
142        Value::Number(_) => "number",
143        Value::String(_) => "string",
144        Value::Array(_) => "array",
145        Value::Object(_) => "object",
146    }
147}
148
149/// Execute tool requests extracted from a Test-phase thought.
150///
151/// Returns ThoughtEvents for each tool execution result.
152pub async fn execute_tool_requests(
153    thinker: Option<&ThinkerClient>,
154    tool_registry: &Arc<ToolRegistry>,
155    persona_id: &str,
156    thought_text: &str,
157    allowed_tools: &[String],
158) -> Vec<ThoughtEvent> {
159    let Some(client) = thinker else {
160        return Vec::new();
161    };
162
163    // Structured extraction: ask LLM for tool requests
164    let system_prompt = "You are a tool request extractor. \
165Given a test/check thought, determine if any tools should be invoked. \
166Return ONLY valid JSON, no markdown fences. \
167If no tools are needed, return {\"tool_requests\":[]}."
168        .to_string();
169
170    let available: Vec<&str> = allowed_tools.iter().map(|s| s.as_str()).collect();
171    let user_prompt = format!(
172        "Available tools: {tools}\n\nThought:\n{thought}\n\n\
173Return JSON only: {{ \"tool_requests\": [{{ \"tool\": \"tool-name\", \"arguments\": {{...}} }}] }}",
174        tools = available.join(", "),
175        thought = thought_text
176    );
177
178    let output = match client.think(&system_prompt, &user_prompt).await {
179        Ok(output) => output,
180        Err(_) => return Vec::new(),
181    };
182
183    let text = output
184        .text
185        .trim()
186        .trim_start_matches("```json")
187        .trim_start_matches("```")
188        .trim_end_matches("```")
189        .trim();
190
191    let parsed: ToolRequestResponse = match serde_json::from_str(text) {
192        Ok(p) => p,
193        Err(_) => return Vec::new(),
194    };
195
196    let mut events = Vec::new();
197
198    for request in parsed.tool_requests {
199        // Validate tool exists and is allowed
200        if !allowed_tools.contains(&request.tool) {
201            events.push(ThoughtEvent {
202                id: Uuid::new_v4().to_string(),
203                event_type: ThoughtEventType::CheckResult,
204                persona_id: Some(persona_id.to_string()),
205                swarm_id: None,
206                timestamp: Utc::now(),
207                payload: serde_json::json!({
208                    "tool_rejected": true,
209                    "tool": request.tool,
210                    "reason": "tool not in allowed_tools",
211                }),
212            });
213            continue;
214        }
215
216        let tool = match tool_registry.get(&request.tool) {
217            Some(t) => t,
218            None => {
219                events.push(ThoughtEvent {
220                    id: Uuid::new_v4().to_string(),
221                    event_type: ThoughtEventType::CheckResult,
222                    persona_id: Some(persona_id.to_string()),
223                    swarm_id: None,
224                    timestamp: Utc::now(),
225                    payload: serde_json::json!({
226                        "tool_rejected": true,
227                        "tool": request.tool,
228                        "reason": "tool not found in registry",
229                    }),
230                });
231                continue;
232            }
233        };
234
235        // Schema validation
236        let schema = tool.parameters();
237        if let Err(validation_error) = validate_tool_args(&schema, &request.arguments) {
238            events.push(ThoughtEvent {
239                id: Uuid::new_v4().to_string(),
240                event_type: ThoughtEventType::CheckResult,
241                persona_id: Some(persona_id.to_string()),
242                swarm_id: None,
243                timestamp: Utc::now(),
244                payload: serde_json::json!({
245                    "tool_rejected": true,
246                    "tool": request.tool,
247                    "reason": "schema_validation_failed",
248                    "detail": validation_error,
249                }),
250            });
251            continue;
252        }
253
254        // Create capability lease
255        let lease = CapabilityLease::new(persona_id, &request.tool, "policy");
256
257        // Validate lease is still valid before executing
258        if !lease.is_valid() {
259            events.push(ThoughtEvent {
260                id: Uuid::new_v4().to_string(),
261                event_type: ThoughtEventType::CheckResult,
262                persona_id: Some(persona_id.to_string()),
263                swarm_id: None,
264                timestamp: Utc::now(),
265                payload: serde_json::json!({
266                    "tool_rejected": true,
267                    "tool": request.tool,
268                    "reason": "capability_lease_expired",
269                    "lease_id": lease.id,
270                }),
271            });
272            continue;
273        }
274
275        // Execute the tool
276        let result = tool.execute(request.arguments.clone()).await;
277
278        let (result_text, success) = match result {
279            Ok(tool_result) => (
280                trim_for_storage(&tool_result.output, 500),
281                tool_result.success,
282            ),
283            Err(e) => (format!("Error: {}", e), false),
284        };
285
286        events.push(ThoughtEvent {
287            id: Uuid::new_v4().to_string(),
288            event_type: ThoughtEventType::CheckResult,
289            persona_id: Some(persona_id.to_string()),
290            swarm_id: None,
291            timestamp: Utc::now(),
292            payload: serde_json::json!({
293                "tool_executed": true,
294                "tool": request.tool,
295                "lease_id": lease.id,
296                "success": success,
297                "result": result_text,
298            }),
299        });
300    }
301
302    events
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn capability_lease_creation_and_validity() {
311        let lease = CapabilityLease::new("p1", "Bash", "policy");
312        assert!(lease.is_valid());
313        assert_eq!(lease.persona_id, "p1");
314        assert_eq!(lease.tool_name, "Bash");
315    }
316
317    #[test]
318    fn validate_tool_args_checks_required_fields() {
319        let schema = serde_json::json!({
320            "type": "object",
321            "required": ["command"],
322            "properties": {
323                "command": { "type": "string" }
324            }
325        });
326
327        // Missing required field
328        let args = serde_json::json!({});
329        assert!(validate_tool_args(&schema, &args).is_err());
330
331        // Has required field
332        let args = serde_json::json!({ "command": "ls" });
333        assert!(validate_tool_args(&schema, &args).is_ok());
334    }
335
336    #[test]
337    fn validate_tool_args_checks_types() {
338        let schema = serde_json::json!({
339            "type": "object",
340            "properties": {
341                "command": { "type": "string" },
342                "timeout": { "type": "number" }
343            }
344        });
345
346        // Wrong type
347        let args = serde_json::json!({ "command": 42 });
348        assert!(validate_tool_args(&schema, &args).is_err());
349
350        // Correct types
351        let args = serde_json::json!({ "command": "ls", "timeout": 30 });
352        assert!(validate_tool_args(&schema, &args).is_ok());
353    }
354
355    #[test]
356    fn validate_rejects_unlisted_tools() {
357        let allowed = vec!["Bash".to_string()];
358        assert!(!allowed.contains(&"WebFetch".to_string()));
359        assert!(allowed.contains(&"Bash".to_string()));
360    }
361
362    #[tokio::test]
363    async fn execute_without_thinker_returns_empty() {
364        let registry = Arc::new(ToolRegistry::new());
365        let result =
366            execute_tool_requests(None, &registry, "p1", "some thought", &["Bash".to_string()])
367                .await;
368        assert!(result.is_empty());
369    }
370}