use serde::{Deserialize, Serialize};
use crate::core::knowledge::ProjectKnowledge;
use crate::core::tokens::count_tokens;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct SubAgentContractV1 {
pub contract_version: u32,
pub task: String,
pub budget_tokens: usize,
pub used_tokens: usize,
pub project_hash: String,
pub facts: Vec<ContractFact>,
pub return_format: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct ContractFact {
pub category: String,
pub key: String,
pub value: String,
}
pub(crate) const RETURN_FORMAT_V1: &str = "Report results as lines of 'category/key: value'. \
Each line becomes a recallable fact in the parent's knowledge store. \
Keep values self-contained; no transcript dumps.";
pub(crate) fn build_briefing_pack(
knowledge: &ProjectKnowledge,
task: &str,
budget_tokens: usize,
) -> SubAgentContractV1 {
let mut used = count_tokens(task);
let ranked = knowledge.recall(task);
let mut facts: Vec<ContractFact> = Vec::new();
for f in ranked {
let line_tokens = count_tokens(&format!("{}/{}: {}", f.category, f.key, f.value));
if used + line_tokens > budget_tokens {
continue;
}
used += line_tokens;
facts.push(ContractFact {
category: f.category.clone(),
key: f.key.clone(),
value: f.value.clone(),
});
}
SubAgentContractV1 {
contract_version: 1,
task: task.to_string(),
budget_tokens,
used_tokens: used,
project_hash: knowledge.project_hash.clone(),
facts,
return_format: RETURN_FORMAT_V1.to_string(),
}
}
pub(crate) fn serialize_pack(pack: &SubAgentContractV1) -> Result<String, String> {
serde_json::to_string_pretty(pack).map_err(|e| format!("contract serialization failed: {e}"))
}
pub(crate) fn parse_return_lines(input: &str) -> (Vec<ContractFact>, Vec<String>) {
let mut facts = Vec::new();
let mut rejected = Vec::new();
for line in input.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parsed = line.split_once(": ").and_then(|(head, value)| {
let (category, key) = head.split_once('/')?;
let category = category.trim();
let key = key.trim();
let value = value.trim();
if category.is_empty() || key.is_empty() || value.is_empty() {
return None;
}
Some(ContractFact {
category: category.to_string(),
key: key.to_string(),
value: value.to_string(),
})
});
match parsed {
Some(f) => facts.push(f),
None => rejected.push(line.to_string()),
}
}
(facts, rejected)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::memory_policy::MemoryPolicy;
fn knowledge_with_facts() -> ProjectKnowledge {
let policy = MemoryPolicy::default();
let mut k = ProjectKnowledge::new("/tmp/contract-test");
k.remember(
"architecture",
"auth",
"JWT RS256 authentication",
"s1",
0.9,
&policy,
);
k.remember(
"architecture",
"db",
"PostgreSQL 16 with pgvector",
"s1",
0.85,
&policy,
);
k.remember("deploy", "host", "AWS eu-central-1", "s1", 0.8, &policy);
k
}
#[test]
fn briefing_pack_is_deterministic() {
let k = knowledge_with_facts();
let a = serialize_pack(&build_briefing_pack(&k, "fix authentication bug", 500)).unwrap();
let b = serialize_pack(&build_briefing_pack(&k, "fix authentication bug", 500)).unwrap();
assert_eq!(a, b, "same input must produce byte-identical packs");
}
#[test]
fn briefing_pack_respects_budget() {
let k = knowledge_with_facts();
let tight = build_briefing_pack(&k, "authentication database deployment", 30);
assert!(
tight.used_tokens <= 30,
"used {} > budget 30",
tight.used_tokens
);
let roomy = build_briefing_pack(&k, "authentication database deployment", 5000);
assert!(roomy.facts.len() >= tight.facts.len());
}
#[test]
fn briefing_pack_includes_relevant_fact() {
let k = knowledge_with_facts();
let pack = build_briefing_pack(&k, "fix authentication bug", 500);
assert!(
pack.facts.iter().any(|f| f.key == "auth"),
"auth fact must be selected for an authentication task: {:?}",
pack.facts
);
assert_eq!(pack.contract_version, 1);
assert_eq!(pack.return_format, RETURN_FORMAT_V1);
}
#[test]
fn parse_return_accepts_contract_lines() {
let (facts, rejected) = parse_return_lines(
"finding/root-cause: race in session save\n\
decision/fix: serialize via mutate_locked\n\
\n\
this line is not contract formatted",
);
assert_eq!(facts.len(), 2);
assert_eq!(facts[0].category, "finding");
assert_eq!(facts[0].key, "root-cause");
assert_eq!(rejected.len(), 1);
}
#[test]
fn parse_return_rejects_empty_parts() {
let (facts, rejected) = parse_return_lines("/key: value\ncat/: value\ncat/key: ");
assert!(facts.is_empty());
assert_eq!(rejected.len(), 3);
}
}