1use serde::{Deserialize, Serialize};
11
12use crate::core::knowledge::ProjectKnowledge;
13use crate::core::tokens::count_tokens;
14
15#[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
36pub 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
41pub 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 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
81pub 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
86pub 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}