Skip to main content

claw10_context/
lib.rs

1use tracing::warn;
2
3use claw10_domain::{
4    Agent, Evidence, Lineage, Memory, Mission, PolicyBundle, Skill, Task, Worker,
5};
6use claw10_toon::{ToonContext, ToonEncoder};
7
8#[derive(Debug, thiserror::Error)]
9pub enum ContextError {
10    #[error("Token budget exhausted: needed {needed}, available {available}")]
11    TokenBudgetExhausted { needed: usize, available: usize },
12    #[error("No suitable context sources available")]
13    NoSources,
14}
15
16/// Defines what context sources are available for building.
17#[derive(Debug, Default)]
18pub struct ContextSources<'a> {
19    pub task: Option<&'a Task>,
20    pub mission: Option<&'a Mission>,
21    pub memories: &'a [Memory],
22    pub policies: &'a [PolicyBundle],
23    pub skills: &'a [Skill],
24    pub history: &'a [String],
25    pub tools: &'a [String],
26    pub agents: &'a [Agent],
27    pub lineage: Option<&'a Lineage>,
28    pub workers: &'a [Worker],
29    pub evidence: &'a [Evidence],
30}
31
32#[derive(Debug, Clone)]
33pub struct PipelineConfig {
34    pub max_token_budget: usize,
35    pub include_task: bool,
36    pub include_mission: bool,
37    pub include_memories: bool,
38    pub include_policy: bool,
39    pub include_skills: bool,
40    pub include_history: bool,
41    pub include_tools: bool,
42    pub include_agent: bool,
43    pub include_lineage: bool,
44    pub include_workers: bool,
45    pub include_evidence: bool,
46}
47
48impl Default for PipelineConfig {
49    fn default() -> Self {
50        Self {
51            max_token_budget: 4096,
52            include_task: true,
53            include_mission: true,
54            include_memories: true,
55            include_policy: true,
56            include_skills: true,
57            include_history: true,
58            include_tools: true,
59            include_agent: true,
60            include_lineage: true,
61            include_workers: false,
62            include_evidence: true,
63        }
64    }
65}
66
67pub struct ContextPipeline {
68    config: PipelineConfig,
69}
70
71impl ContextPipeline {
72    #[must_use]
73    pub fn new(config: PipelineConfig) -> Self {
74        Self { config }
75    }
76
77    /// Build a complete context string from available sources.
78    /// Orchestrates selection -> encoding -> assembly within token budget.
79    pub async fn build_context(&self, sources: ContextSources<'_>) -> Result<String, ContextError> {
80        let mut ctx = ToonContext::new();
81
82        if self.config.include_task {
83            if let Some(task) = sources.task {
84                ctx.add_section("task", ToonEncoder::encode_task(task));
85            }
86        }
87
88        if self.config.include_mission {
89            if let Some(mission) = sources.mission {
90                ctx.add_section("mission", ToonEncoder::encode_mission(mission));
91            }
92        }
93
94        if self.config.include_memories && !sources.memories.is_empty() {
95            ctx.add_section("memory", ToonEncoder::encode_memories(sources.memories));
96        }
97
98        if self.config.include_policy && !sources.policies.is_empty() {
99            ctx.add_section(
100                "policy",
101                ToonEncoder::encode_policy_summary(sources.policies),
102            );
103        }
104
105        if self.config.include_skills && !sources.skills.is_empty() {
106            ctx.add_section("skills", ToonEncoder::encode_skills(sources.skills));
107        }
108
109        if self.config.include_history && !sources.history.is_empty() {
110            ctx.add_section("history", ToonEncoder::encode_history(sources.history));
111        }
112
113        if self.config.include_tools && !sources.tools.is_empty() {
114            ctx.add_section("tools", ToonEncoder::encode_tools(sources.tools));
115        }
116
117        if self.config.include_agent && !sources.agents.is_empty() {
118            ctx.add_section("agents", ToonEncoder::encode_agent_roster(sources.agents));
119        }
120
121        if self.config.include_lineage {
122            if let Some(lineage) = sources.lineage {
123                ctx.add_section("lineage", ToonEncoder::encode_lineage(lineage));
124            }
125        }
126
127        if self.config.include_workers && !sources.workers.is_empty() {
128            ctx.add_section("workers", ToonEncoder::encode_workers(sources.workers));
129        }
130
131        if self.config.include_evidence && !sources.evidence.is_empty() {
132            ctx.add_section("evidence", ToonEncoder::encode_evidence(sources.evidence));
133        }
134
135        let context = ctx.build();
136        let estimated_tokens = estimate_tokens(&context);
137
138        if estimated_tokens > self.config.max_token_budget {
139            warn!(
140                estimated_tokens,
141                max_budget = self.config.max_token_budget,
142                "Context exceeds token budget, trimming"
143            );
144            Ok(self.trim_to_budget(&context, self.config.max_token_budget))
145        } else {
146            Ok(context)
147        }
148    }
149
150    /// Trim context string to fit within token budget by truncating earliest/least important sections.
151    fn trim_to_budget(&self, context: &str, max_tokens: usize) -> String {
152        let sections: Vec<&str> = context.split("\n---\n").collect();
153        let mut selected = Vec::new();
154        let mut running_tokens = 0usize;
155
156        for section in sections.iter().rev() {
157            let section_tokens = estimate_tokens(section);
158            if running_tokens + section_tokens <= max_tokens {
159                selected.push(*section);
160                running_tokens += section_tokens;
161            } else {
162                let available = max_tokens.saturating_sub(running_tokens);
163                if available > 10 {
164                    let chars_per_token = 4;
165                    let max_chars = available * chars_per_token;
166                    let truncated: String = section.chars().take(max_chars).collect();
167                    selected.push(&*Box::leak(truncated.into_boxed_str()));
168                }
169                break;
170            }
171        }
172
173        selected.reverse();
174        selected.join("\n---\n")
175    }
176}
177
178/// Rough token estimate: 1 token ≈ 4 characters.
179fn estimate_tokens(text: &str) -> usize {
180    (text.len() + 3) / 4
181}
182
183#[cfg(test)]
184#[path = "lib_test.rs"]
185mod tests;
186