car-memgine 0.15.0

Memgine — graph-based memory engine for Common Agent Runtime
Documentation
//! Trajectory distillation — extract skills from execution traces.
//!
//! Follows SkillRL's experience-based skill distillation:
//! - Successful trajectories → generalizable patterns and strategies
//! - Failed trajectories → failure lessons with preventive principles
//!
//! Achieves 10-20x token compression compared to raw trajectory storage.

use crate::graph::{SkillScope, SkillTrigger};
use car_ir::json_extract::extract_json_array as extract_json_array_from_text;
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// A skill distilled from an execution trajectory.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DistilledSkill {
    pub name: String,
    pub description: String,
    pub when_to_apply: String,
    pub scope: SkillScope,
    /// Source: "success" or "failure"
    pub source: String,
    /// The domain this skill applies to (empty for global).
    pub domain: String,
    /// Trigger context for retrieval.
    pub trigger: SkillTrigger,
    /// Optional code/procedure body.
    #[serde(default)]
    pub code: String,
}

/// A trace event capturing what happened during a single action execution.
/// Core fields (kind, action_id, tool, data) are always present.
/// Extended fields (timing, state diffs, reward) are populated when available
/// and provide the (s, a, r, s') tuples needed for RL-style learning.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TraceEvent {
    #[serde(default)]
    pub kind: String,
    #[serde(default)]
    pub action_id: Option<String>,
    #[serde(default)]
    pub tool: Option<String>,
    #[serde(default)]
    pub data: Value,
    /// Wall-clock duration of this action in milliseconds.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub duration_ms: Option<f64>,
    /// State keys and values before this action executed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub state_before: Option<std::collections::HashMap<String, Value>>,
    /// State keys and values after this action executed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub state_after: Option<std::collections::HashMap<String, Value>>,
    /// Scalar reward signal for RL. 1.0 = success, 0.0 = failure,
    /// intermediate values for partial success.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reward: Option<f64>,
}

/// Separate a trace into successful and failed sub-trajectories.
pub fn partition_trace(events: &[TraceEvent]) -> (Vec<&TraceEvent>, Vec<&TraceEvent>) {
    let mut successes = Vec::new();
    let mut failures = Vec::new();

    for event in events {
        match event.kind.as_str() {
            "action_succeeded" => successes.push(event),
            "action_failed" | "action_rejected" | "policy_violation" => failures.push(event),
            _ => {} // context events, skip for partitioning
        }
    }

    (successes, failures)
}

/// Build the distillation prompt for successful trajectories.
pub fn success_distillation_prompt(events: &[&TraceEvent]) -> String {
    let trace_summary = events
        .iter()
        .map(|e| {
            let tool = e.tool.as_deref().unwrap_or("?");
            let action = e.action_id.as_deref().unwrap_or("?");
            format!("- [{}] tool={}, data={}", action, tool, e.data)
        })
        .collect::<Vec<_>>()
        .join("\n");

    format!(
        r#"Analyze this successful execution trace and extract reusable skills.

## Trace
{trace_summary}

## Instructions
Extract 1-3 skills from this successful execution. For each skill, identify:
1. The critical decision point or pattern that led to success
2. A generalizable principle (not specific to this exact task)
3. When this skill should be applied

Output a JSON array of skills:
```json
[
  {{
    "name": "short_descriptive_name",
    "description": "The principle or strategy (1-2 sentences)",
    "when_to_apply": "Condition when this skill is useful",
    "scope": "global" or {{"domain": "domain_name"}},
    "source": "success",
    "domain": "",
    "trigger": {{"persona": "", "url_pattern": "", "task_keywords": []}},
    "code": ""
  }}
]
```

Output ONLY the JSON array, no other text."#
    )
}

/// Build the distillation prompt for failed trajectories.
pub fn failure_distillation_prompt(events: &[&TraceEvent]) -> String {
    let trace_summary = events
        .iter()
        .map(|e| {
            let tool = e.tool.as_deref().unwrap_or("?");
            let action = e.action_id.as_deref().unwrap_or("?");
            let error = e.data.get("error").and_then(|v| v.as_str()).unwrap_or("");
            format!(
                "- [{}] tool={}, error={}, data={}",
                action, tool, error, e.data
            )
        })
        .collect::<Vec<_>>()
        .join("\n");

    format!(
        r#"Analyze this failed execution trace and extract preventive skills.

## Failed Actions
{trace_summary}

## Instructions
For each failure pattern, identify:
1. The failure point and what went wrong
2. The flawed reasoning that led to the failure
3. The correct approach that should have been taken
4. A preventive principle to avoid this in the future

Output a JSON array of failure-lesson skills:
```json
[
  {{
    "name": "prevent_descriptive_name",
    "description": "Preventive principle (1-2 sentences)",
    "when_to_apply": "Condition when this lesson applies",
    "scope": "global" or {{"domain": "domain_name"}},
    "source": "failure",
    "domain": "",
    "trigger": {{"persona": "", "url_pattern": "", "task_keywords": []}},
    "code": ""
  }}
]
```

Output ONLY the JSON array, no other text."#
    )
}

/// Parse the model's JSON response into DistilledSkills.
pub fn parse_skills(response: &str) -> Vec<DistilledSkill> {
    // Find JSON array in the response (may be wrapped in markdown code blocks)
    let json_str = extract_json_array_from_text(response).unwrap_or_else(|| response.to_string());
    match serde_json::from_str::<Vec<DistilledSkill>>(&json_str) {
        Ok(skills) => skills,
        Err(_) => Vec::new(), // graceful fallback on parse failure
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn partition_separates_success_and_failure() {
        let events = vec![
            TraceEvent {
                kind: "action_succeeded".into(),
                action_id: Some("a1".into()),
                tool: Some("search".into()),
                data: Value::Null,
                ..Default::default()
            },
            TraceEvent {
                kind: "action_failed".into(),
                action_id: Some("a2".into()),
                tool: Some("shell".into()),
                data: Value::Null,
                ..Default::default()
            },
            TraceEvent {
                kind: "action_succeeded".into(),
                action_id: Some("a3".into()),
                tool: Some("read".into()),
                data: Value::Null,
                ..Default::default()
            },
            TraceEvent {
                kind: "state_changed".into(),
                action_id: None,
                tool: None,
                data: Value::Null,
                ..Default::default()
            },
        ];

        let (succ, fail) = partition_trace(&events);
        assert_eq!(succ.len(), 2);
        assert_eq!(fail.len(), 1);
    }

    #[test]
    fn parse_skills_from_json() {
        let json = r#"[{"name":"verify_before_act","description":"Always verify state before modifying","when_to_apply":"Before any write operation","scope":"global","source":"failure","domain":"","trigger":{"persona":"","url_pattern":"","task_keywords":[]},"code":""}]"#;
        let skills = parse_skills(json);
        assert_eq!(skills.len(), 1);
        assert_eq!(skills[0].name, "verify_before_act");
        assert_eq!(skills[0].scope, SkillScope::Global);
    }

    #[test]
    fn parse_skills_from_markdown() {
        let response = "Here are the skills:\n```json\n[{\"name\":\"test\",\"description\":\"d\",\"when_to_apply\":\"w\",\"scope\":\"global\",\"source\":\"success\",\"domain\":\"\",\"trigger\":{\"persona\":\"\",\"url_pattern\":\"\",\"task_keywords\":[]},\"code\":\"\"}]\n```";
        let skills = parse_skills(response);
        assert_eq!(skills.len(), 1);
    }

    #[test]
    fn parse_empty_on_bad_json() {
        let skills = parse_skills("not json at all");
        assert!(skills.is_empty());
    }

    #[test]
    fn success_prompt_includes_trace() {
        let events = vec![TraceEvent {
            kind: "action_succeeded".into(),
            action_id: Some("a1".into()),
            tool: Some("search".into()),
            data: serde_json::json!({"query": "test"}),
            ..Default::default()
        }];
        let refs: Vec<&TraceEvent> = events.iter().collect();
        let prompt = success_distillation_prompt(&refs);
        assert!(prompt.contains("search"));
        assert!(prompt.contains("JSON array"));
    }
}