Skip to main content

lean_ctx/core/
subagent_contract.rs

1//! Sub-agent context contracts (GL#450).
2//!
3//! A briefing pack is the *contract* between a parent agent and a sub-agent:
4//! task, token budget, the facts the sub-agent needs, and the return format
5//! it must produce. Packs are deterministic — same input, byte-identical
6//! output — so they can be diffed, cached, and replayed. The return channel
7//! is the inverse: a sub-agent reports `category/key: value` lines, which the
8//! parent distills into recallable knowledge facts instead of raw transcript.
9
10use serde::{Deserialize, Serialize};
11
12use crate::core::knowledge::ProjectKnowledge;
13use crate::core::tokens::count_tokens;
14
15/// Versioned briefing pack. Field order is fixed by this struct; serialization
16/// uses `serde_json::to_string_pretty` which preserves struct order, keeping
17/// the bytes stable across runs.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SubAgentContractV1 {
20    pub contract_version: u32,
21    pub task: String,
22    pub budget_tokens: usize,
23    pub used_tokens: usize,
24    pub project_hash: String,
25    pub facts: Vec<ContractFact>,
26    pub return_format: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct ContractFact {
31    pub category: String,
32    pub key: String,
33    pub value: String,
34}
35
36/// Instruction embedded in every pack telling the sub-agent how to report.
37pub const RETURN_FORMAT_V1: &str = "Report results as lines of 'category/key: value'. \
38     Each line becomes a recallable fact in the parent's knowledge store. \
39     Keep values self-contained; no transcript dumps.";
40
41/// Build a deterministic briefing pack: task + the most relevant current
42/// facts, greedily filled (in stable relevance order) until `budget_tokens`
43/// is reached. The task itself is always included; the budget governs facts.
44pub fn build_briefing_pack(
45    knowledge: &ProjectKnowledge,
46    task: &str,
47    budget_tokens: usize,
48) -> SubAgentContractV1 {
49    let mut used = count_tokens(task);
50
51    // Deterministic relevance: term-coverage on the lexical index, quality
52    // tie-break, then stable (category, key) ordering. recall() already
53    // filters to current facts and sorts deterministically.
54    let ranked = knowledge.recall(task);
55
56    let mut facts: Vec<ContractFact> = Vec::new();
57    for f in ranked {
58        let line_tokens = count_tokens(&format!("{}/{}: {}", f.category, f.key, f.value));
59        if used + line_tokens > budget_tokens {
60            continue;
61        }
62        used += line_tokens;
63        facts.push(ContractFact {
64            category: f.category.clone(),
65            key: f.key.clone(),
66            value: f.value.clone(),
67        });
68    }
69
70    SubAgentContractV1 {
71        contract_version: 1,
72        task: task.to_string(),
73        budget_tokens,
74        used_tokens: used,
75        project_hash: knowledge.project_hash.clone(),
76        facts,
77        return_format: RETURN_FORMAT_V1.to_string(),
78    }
79}
80
81/// Serialize a pack with stable formatting (struct field order, pretty JSON).
82pub fn serialize_pack(pack: &SubAgentContractV1) -> Result<String, String> {
83    serde_json::to_string_pretty(pack).map_err(|e| format!("contract serialization failed: {e}"))
84}
85
86/// Parse sub-agent return lines (`category/key: value`) into structured
87/// facts. Lines that don't match the contract format are reported back as
88/// rejects instead of being silently dropped.
89pub fn parse_return_lines(input: &str) -> (Vec<ContractFact>, Vec<String>) {
90    let mut facts = Vec::new();
91    let mut rejected = Vec::new();
92
93    for line in input.lines() {
94        let line = line.trim();
95        if line.is_empty() {
96            continue;
97        }
98        let parsed = line.split_once(": ").and_then(|(head, value)| {
99            let (category, key) = head.split_once('/')?;
100            let category = category.trim();
101            let key = key.trim();
102            let value = value.trim();
103            if category.is_empty() || key.is_empty() || value.is_empty() {
104                return None;
105            }
106            Some(ContractFact {
107                category: category.to_string(),
108                key: key.to_string(),
109                value: value.to_string(),
110            })
111        });
112        match parsed {
113            Some(f) => facts.push(f),
114            None => rejected.push(line.to_string()),
115        }
116    }
117
118    (facts, rejected)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::core::memory_policy::MemoryPolicy;
125
126    fn knowledge_with_facts() -> ProjectKnowledge {
127        let policy = MemoryPolicy::default();
128        let mut k = ProjectKnowledge::new("/tmp/contract-test");
129        k.remember(
130            "architecture",
131            "auth",
132            "JWT RS256 authentication",
133            "s1",
134            0.9,
135            &policy,
136        );
137        k.remember(
138            "architecture",
139            "db",
140            "PostgreSQL 16 with pgvector",
141            "s1",
142            0.85,
143            &policy,
144        );
145        k.remember("deploy", "host", "AWS eu-central-1", "s1", 0.8, &policy);
146        k
147    }
148
149    #[test]
150    fn briefing_pack_is_deterministic() {
151        let k = knowledge_with_facts();
152        let a = serialize_pack(&build_briefing_pack(&k, "fix authentication bug", 500)).unwrap();
153        let b = serialize_pack(&build_briefing_pack(&k, "fix authentication bug", 500)).unwrap();
154        assert_eq!(a, b, "same input must produce byte-identical packs");
155    }
156
157    #[test]
158    fn briefing_pack_respects_budget() {
159        let k = knowledge_with_facts();
160        let tight = build_briefing_pack(&k, "authentication database deployment", 30);
161        assert!(
162            tight.used_tokens <= 30,
163            "used {} > budget 30",
164            tight.used_tokens
165        );
166        let roomy = build_briefing_pack(&k, "authentication database deployment", 5000);
167        assert!(roomy.facts.len() >= tight.facts.len());
168    }
169
170    #[test]
171    fn briefing_pack_includes_relevant_fact() {
172        let k = knowledge_with_facts();
173        let pack = build_briefing_pack(&k, "fix authentication bug", 500);
174        assert!(
175            pack.facts.iter().any(|f| f.key == "auth"),
176            "auth fact must be selected for an authentication task: {:?}",
177            pack.facts
178        );
179        assert_eq!(pack.contract_version, 1);
180        assert_eq!(pack.return_format, RETURN_FORMAT_V1);
181    }
182
183    #[test]
184    fn parse_return_accepts_contract_lines() {
185        let (facts, rejected) = parse_return_lines(
186            "finding/root-cause: race in session save\n\
187             decision/fix: serialize via mutate_locked\n\
188             \n\
189             this line is not contract formatted",
190        );
191        assert_eq!(facts.len(), 2);
192        assert_eq!(facts[0].category, "finding");
193        assert_eq!(facts[0].key, "root-cause");
194        assert_eq!(rejected.len(), 1);
195    }
196
197    #[test]
198    fn parse_return_rejects_empty_parts() {
199        let (facts, rejected) = parse_return_lines("/key: value\ncat/: value\ncat/key:    ");
200        assert!(facts.is_empty());
201        assert_eq!(rejected.len(), 3);
202    }
203}