use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
use uuid::Uuid;
use super::thinker::ThinkerClient;
use super::{ThoughtEvent, ThoughtEventType, trim_for_storage};
use crate::tool::ToolRegistry;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityLease {
pub id: String,
pub persona_id: String,
pub tool_name: String,
pub granted_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub granted_by: String,
}
impl CapabilityLease {
pub fn new(persona_id: &str, tool_name: &str, granted_by: &str) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
persona_id: persona_id.to_string(),
tool_name: tool_name.to_string(),
granted_at: now,
expires_at: now + Duration::seconds(60),
granted_by: granted_by.to_string(),
}
}
pub fn is_valid(&self) -> bool {
Utc::now() < self.expires_at
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolInvocation {
pub tool_name: String,
pub arguments: Value,
pub result: Option<String>,
pub success: bool,
pub lease_id: String,
pub invoked_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ExecutionOutcome {
Success {
summary: String,
},
Failure {
error: String,
follow_up_attention: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecisionReceipt {
pub id: String,
pub proposal_id: String,
pub inputs: Vec<String>,
pub governance_decision: String,
pub capability_leases: Vec<String>,
pub tool_invocations: Vec<ToolInvocation>,
pub outcome: ExecutionOutcome,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize)]
struct ExtractedToolRequest {
tool: String,
arguments: Value,
}
#[derive(Debug, Deserialize)]
struct ToolRequestResponse {
tool_requests: Vec<ExtractedToolRequest>,
}
fn validate_tool_args(schema: &Value, args: &Value) -> Result<(), String> {
if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
for field in required {
if let Some(field_name) = field.as_str() {
if args.get(field_name).is_none() {
return Err(format!("Missing required field: {}", field_name));
}
}
}
}
if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
if let Some(args_obj) = args.as_object() {
for (key, value) in args_obj {
if let Some(prop_schema) = properties.get(key) {
if let Some(expected_type) = prop_schema.get("type").and_then(|t| t.as_str()) {
let type_ok = match expected_type {
"string" => value.is_string(),
"number" | "integer" => value.is_number(),
"boolean" => value.is_boolean(),
"array" => value.is_array(),
"object" => value.is_object(),
_ => true,
};
if !type_ok {
return Err(format!(
"Field '{}' has wrong type: expected {}, got {}",
key,
expected_type,
json_type_name(value)
));
}
}
}
}
}
}
Ok(())
}
fn json_type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
pub async fn execute_tool_requests(
thinker: Option<&ThinkerClient>,
tool_registry: &Arc<ToolRegistry>,
persona_id: &str,
thought_text: &str,
allowed_tools: &[String],
) -> Vec<ThoughtEvent> {
let Some(client) = thinker else {
return Vec::new();
};
let system_prompt = "You are a tool request extractor. \
Given a test/check thought, determine if any tools should be invoked. \
Return ONLY valid JSON, no markdown fences. \
If no tools are needed, return {\"tool_requests\":[]}."
.to_string();
let available: Vec<&str> = allowed_tools.iter().map(|s| s.as_str()).collect();
let user_prompt = format!(
"Available tools: {tools}\n\nThought:\n{thought}\n\n\
Return JSON only: {{ \"tool_requests\": [{{ \"tool\": \"tool-name\", \"arguments\": {{...}} }}] }}",
tools = available.join(", "),
thought = thought_text
);
let output = match client.think(&system_prompt, &user_prompt).await {
Ok(output) => output,
Err(_) => return Vec::new(),
};
let text = output
.text
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
let parsed: ToolRequestResponse = match serde_json::from_str(text) {
Ok(p) => p,
Err(_) => return Vec::new(),
};
let mut events = Vec::new();
for request in parsed.tool_requests {
if !allowed_tools.contains(&request.tool) {
events.push(ThoughtEvent {
id: Uuid::new_v4().to_string(),
event_type: ThoughtEventType::CheckResult,
persona_id: Some(persona_id.to_string()),
swarm_id: None,
timestamp: Utc::now(),
payload: serde_json::json!({
"tool_rejected": true,
"tool": request.tool,
"reason": "tool not in allowed_tools",
}),
});
continue;
}
let tool = match tool_registry.get(&request.tool) {
Some(t) => t,
None => {
events.push(ThoughtEvent {
id: Uuid::new_v4().to_string(),
event_type: ThoughtEventType::CheckResult,
persona_id: Some(persona_id.to_string()),
swarm_id: None,
timestamp: Utc::now(),
payload: serde_json::json!({
"tool_rejected": true,
"tool": request.tool,
"reason": "tool not found in registry",
}),
});
continue;
}
};
let schema = tool.parameters();
if let Err(validation_error) = validate_tool_args(&schema, &request.arguments) {
events.push(ThoughtEvent {
id: Uuid::new_v4().to_string(),
event_type: ThoughtEventType::CheckResult,
persona_id: Some(persona_id.to_string()),
swarm_id: None,
timestamp: Utc::now(),
payload: serde_json::json!({
"tool_rejected": true,
"tool": request.tool,
"reason": "schema_validation_failed",
"detail": validation_error,
}),
});
continue;
}
let lease = CapabilityLease::new(persona_id, &request.tool, "policy");
if !lease.is_valid() {
events.push(ThoughtEvent {
id: Uuid::new_v4().to_string(),
event_type: ThoughtEventType::CheckResult,
persona_id: Some(persona_id.to_string()),
swarm_id: None,
timestamp: Utc::now(),
payload: serde_json::json!({
"tool_rejected": true,
"tool": request.tool,
"reason": "capability_lease_expired",
"lease_id": lease.id,
}),
});
continue;
}
let result = tool.execute(request.arguments.clone()).await;
let (result_text, success) = match result {
Ok(tool_result) => (
trim_for_storage(&tool_result.output, 500),
tool_result.success,
),
Err(e) => (format!("Error: {}", e), false),
};
events.push(ThoughtEvent {
id: Uuid::new_v4().to_string(),
event_type: ThoughtEventType::CheckResult,
persona_id: Some(persona_id.to_string()),
swarm_id: None,
timestamp: Utc::now(),
payload: serde_json::json!({
"tool_executed": true,
"tool": request.tool,
"lease_id": lease.id,
"success": success,
"result": result_text,
}),
});
}
events
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capability_lease_creation_and_validity() {
let lease = CapabilityLease::new("p1", "Bash", "policy");
assert!(lease.is_valid());
assert_eq!(lease.persona_id, "p1");
assert_eq!(lease.tool_name, "Bash");
}
#[test]
fn validate_tool_args_checks_required_fields() {
let schema = serde_json::json!({
"type": "object",
"required": ["command"],
"properties": {
"command": { "type": "string" }
}
});
let args = serde_json::json!({});
assert!(validate_tool_args(&schema, &args).is_err());
let args = serde_json::json!({ "command": "ls" });
assert!(validate_tool_args(&schema, &args).is_ok());
}
#[test]
fn validate_tool_args_checks_types() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string" },
"timeout": { "type": "number" }
}
});
let args = serde_json::json!({ "command": 42 });
assert!(validate_tool_args(&schema, &args).is_err());
let args = serde_json::json!({ "command": "ls", "timeout": 30 });
assert!(validate_tool_args(&schema, &args).is_ok());
}
#[test]
fn validate_rejects_unlisted_tools() {
let allowed = vec!["Bash".to_string()];
assert!(!allowed.contains(&"WebFetch".to_string()));
assert!(allowed.contains(&"Bash".to_string()));
}
#[tokio::test]
async fn execute_without_thinker_returns_empty() {
let registry = Arc::new(ToolRegistry::new());
let result =
execute_tool_requests(None, ®istry, "p1", "some thought", &["Bash".to_string()])
.await;
assert!(result.is_empty());
}
}