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(crate) 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 over the
52    // task's content words only (#1832), quality tie-break, then stable
53    // (category, key) ordering. recall_for_task() already filters to current
54    // facts and sorts deterministically.
55    let ranked = knowledge.recall_for_task(task);
56    // The pack carries fact values to a sub-agent: only facts that may be
57    // delivered now enter it (`ProjectKnowledge::delivery_filter`).
58    let deliverable = knowledge.delivery_filter();
59
60    let mut facts: Vec<ContractFact> = Vec::new();
61    for f in ranked.into_iter().filter(|&f| deliverable(f)) {
62        let line_tokens = count_tokens(&format!("{}/{}: {}", f.category, f.key, f.value));
63        if used + line_tokens > budget_tokens {
64            continue;
65        }
66        used += line_tokens;
67        facts.push(ContractFact {
68            category: f.category.clone(),
69            key: f.key.clone(),
70            value: f.value.clone(),
71        });
72    }
73
74    SubAgentContractV1 {
75        contract_version: 1,
76        task: task.to_string(),
77        budget_tokens,
78        used_tokens: used,
79        project_hash: knowledge.project_hash.clone(),
80        facts,
81        return_format: RETURN_FORMAT_V1.to_string(),
82    }
83}
84
85/// Serialize a pack with stable formatting (struct field order, pretty JSON).
86pub fn serialize_pack(pack: &SubAgentContractV1) -> Result<String, String> {
87    serde_json::to_string_pretty(pack).map_err(|e| format!("contract serialization failed: {e}"))
88}
89
90/// Parse sub-agent return lines (`category/key: value`) into structured
91/// facts. Lines that don't match the contract format are reported back as
92/// rejects instead of being silently dropped.
93pub fn parse_return_lines(input: &str) -> (Vec<ContractFact>, Vec<String>) {
94    let mut facts = Vec::new();
95    let mut rejected = Vec::new();
96
97    for line in input.lines() {
98        let line = line.trim();
99        if line.is_empty() {
100            continue;
101        }
102        let parsed = line.split_once(": ").and_then(|(head, value)| {
103            let (category, key) = head.split_once('/')?;
104            let category = category.trim();
105            let key = key.trim();
106            let value = value.trim();
107            if category.is_empty() || key.is_empty() || value.is_empty() {
108                return None;
109            }
110            Some(ContractFact {
111                category: category.to_string(),
112                key: key.to_string(),
113                value: value.to_string(),
114            })
115        });
116        match parsed {
117            Some(f) => facts.push(f),
118            None => rejected.push(line.to_string()),
119        }
120    }
121
122    (facts, rejected)
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::core::memory_policy::MemoryPolicy;
129
130    fn knowledge_with_facts() -> (tempfile::TempDir, ProjectKnowledge) {
131        let policy = MemoryPolicy::default();
132        let k_root = tempfile::tempdir().unwrap();
133        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
134        k.remember(
135            "architecture",
136            "auth",
137            "JWT RS256 authentication",
138            "s1",
139            0.9,
140            &policy,
141        );
142        k.remember(
143            "architecture",
144            "db",
145            "PostgreSQL 16 with pgvector",
146            "s1",
147            0.85,
148            &policy,
149        );
150        k.remember("deploy", "host", "AWS eu-central-1", "s1", 0.8, &policy);
151        (k_root, k)
152    }
153
154    #[test]
155    fn briefing_pack_is_deterministic() {
156        let (_root, k) = knowledge_with_facts();
157        let a = serialize_pack(&build_briefing_pack(&k, "fix authentication bug", 500)).unwrap();
158        let b = serialize_pack(&build_briefing_pack(&k, "fix authentication bug", 500)).unwrap();
159        assert_eq!(a, b, "same input must produce byte-identical packs");
160    }
161
162    #[test]
163    fn briefing_pack_respects_budget() {
164        let (_root, k) = knowledge_with_facts();
165        let tight = build_briefing_pack(&k, "authentication database deployment", 30);
166        assert!(
167            tight.used_tokens <= 30,
168            "used {} > budget 30",
169            tight.used_tokens
170        );
171        let roomy = build_briefing_pack(&k, "authentication database deployment", 5000);
172        assert!(roomy.facts.len() >= tight.facts.len());
173    }
174
175    #[test]
176    fn briefing_pack_includes_relevant_fact() {
177        let (_root, k) = knowledge_with_facts();
178        let pack = build_briefing_pack(&k, "fix authentication bug", 500);
179        assert!(
180            pack.facts.iter().any(|f| f.key == "auth"),
181            "auth fact must be selected for an authentication task: {:?}",
182            pack.facts
183        );
184        assert_eq!(pack.contract_version, 1);
185        assert_eq!(pack.return_format, RETURN_FORMAT_V1);
186    }
187
188    #[test]
189    fn briefing_pack_ignores_a_shared_generic_verb() {
190        // #1832: "fix" alone must not pull the deploy fact into the briefing.
191        let policy = MemoryPolicy::default();
192        let (_root, mut k) = knowledge_with_facts();
193        k.remember(
194            "deploy",
195            "rollout",
196            "Fix the rollout order",
197            "s1",
198            0.8,
199            &policy,
200        );
201        let pack = build_briefing_pack(&k, "fix authentication bug", 500);
202        assert!(
203            pack.facts.iter().all(|f| f.key != "rollout"),
204            "{:?}",
205            pack.facts
206        );
207    }
208
209    /// The pack carries fact values to a sub-agent: a fact bound to a file
210    /// that is gone is not ranked into it; a fact without a binding is.
211    #[test]
212    fn briefing_pack_leaves_out_a_fact_that_is_not_served() {
213        use crate::core::archive::bindings::{self, Item};
214        use crate::core::context_admission::stored::lineage_support::{governed, project, scoped};
215        let _data = governed();
216        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
217        let (_directory, root) = project();
218        std::fs::write(format!("{root}/auth.rs"), "pub fn verify_rs256() {}\n").expect("source");
219        let policy = MemoryPolicy::default();
220        let mut k = ProjectKnowledge::new(&root);
221        let bound = "JWT RS256 authentication";
222        let typed = "authentication rows live in PostgreSQL";
223        scoped(&root, || {
224            k.remember("architecture", "auth", bound, "s1", 0.9, &policy);
225            k.remember("architecture", "db", typed, "s1", 0.8, &policy);
226        });
227        let item = Item::KnowledgeFact {
228            project: &root,
229            category: "architecture",
230            key: "auth",
231        };
232        let sources =
233            bindings::sources_at(&root, &["auth.rs"], "ctx_knowledge").expect("readable source");
234        assert!(bindings::bind(&item, bound, &root, sources));
235        let keys = || {
236            scoped(&root, || {
237                build_briefing_pack(&k, "fix authentication bug", 500)
238            })
239            .facts
240            .into_iter()
241            .map(|fact| fact.key)
242            .collect::<Vec<_>>()
243        };
244
245        assert!(keys().contains(&"auth".to_owned()), "{:?}", keys());
246        assert!(keys().contains(&"db".to_owned()), "{:?}", keys());
247        std::fs::remove_file(format!("{root}/auth.rs")).expect("delete");
248        assert_eq!(keys(), ["db".to_owned()]);
249    }
250
251    #[test]
252    fn parse_return_accepts_contract_lines() {
253        let (facts, rejected) = parse_return_lines(
254            "finding/root-cause: race in session save\n\
255             decision/fix: serialize via mutate_locked\n\
256             \n\
257             this line is not contract formatted",
258        );
259        assert_eq!(facts.len(), 2);
260        assert_eq!(facts[0].category, "finding");
261        assert_eq!(facts[0].key, "root-cause");
262        assert_eq!(rejected.len(), 1);
263    }
264
265    #[test]
266    fn parse_return_rejects_empty_parts() {
267        let (facts, rejected) = parse_return_lines("/key: value\ncat/: value\ncat/key:    ");
268        assert!(facts.is_empty(), "{facts:?}");
269        assert_eq!(rejected.len(), 3);
270    }
271}