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(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
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_for_task(task);
56 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
85pub 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
90pub 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 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 #[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}