stakpak_server/context/
builder.rs1use crate::context::{
2 ContextBudget,
3 budget::{apply_budget, truncate_with_marker},
4 environment::EnvironmentContext,
5 project::{ContextFile, ProjectContext},
6};
7
8#[derive(Debug, Clone, Default)]
9pub struct SessionContext {
10 pub system_prompt: String,
11 pub user_context_block: Option<String>,
12}
13
14#[derive(Debug, Clone, Default)]
15pub struct SessionContextBuilder {
16 environment: Option<EnvironmentContext>,
17 project: Option<ProjectContext>,
18 base_system_prompt: Option<String>,
19 tool_summaries: Vec<String>,
20 budget: ContextBudget,
21}
22
23impl SessionContextBuilder {
24 pub fn new() -> Self {
25 Self::default()
26 }
27
28 pub fn environment(mut self, environment: EnvironmentContext) -> Self {
29 self.environment = Some(environment);
30 self
31 }
32
33 pub fn project(mut self, project: ProjectContext) -> Self {
34 self.project = Some(project);
35 self
36 }
37
38 pub fn base_system_prompt(mut self, prompt: impl Into<String>) -> Self {
39 self.base_system_prompt = Some(prompt.into());
40 self
41 }
42
43 pub fn tools(mut self, tools: &[stakai::Tool]) -> Self {
44 self.tool_summaries = tools
45 .iter()
46 .map(|tool| {
47 let description = tool.function.description.trim();
48 if description.is_empty() {
49 format!("- {}", tool.function.name)
50 } else {
51 format!("- {}: {}", tool.function.name, description)
52 }
53 })
54 .collect();
55 self
56 }
57
58 pub fn budget(mut self, budget: ContextBudget) -> Self {
59 self.budget = budget;
60 self
61 }
62
63 pub fn build(self) -> SessionContext {
64 let system_prompt = self.build_system_prompt();
65 let user_context_block = self.build_user_context_block();
66
67 SessionContext {
68 system_prompt,
69 user_context_block,
70 }
71 }
72
73 fn build_system_prompt(&self) -> String {
74 let mut sections = Vec::new();
75
76 if let Some(base) = self.base_system_prompt.as_ref().map(|prompt| prompt.trim())
77 && !base.is_empty()
78 {
79 sections.push(base.to_string());
80 }
81
82 if !self.tool_summaries.is_empty() {
83 sections.push(format!(
84 "## Available Tools\n{}",
85 self.tool_summaries.join("\n")
86 ));
87 }
88
89 let combined = sections.join("\n\n");
90 let (truncated, _) = truncate_with_marker(
91 &combined,
92 self.budget.system_prompt_max_chars,
93 "system prompt",
94 );
95 truncated
96 }
97
98 fn build_user_context_block(&self) -> Option<String> {
99 let mut sections = Vec::new();
100
101 if let Some(environment) = &self.environment {
102 sections.push(format!(
103 "<local_context>\n{}\n</local_context>",
104 environment.to_local_context_block()
105 ));
106 }
107
108 let mut files = self
109 .project
110 .as_ref()
111 .map(|project| project.files.clone())
112 .unwrap_or_default();
113
114 if !files.is_empty() {
115 apply_budget(&mut files, &self.budget);
116 for file in files {
117 sections.push(format_context_file(&file));
118 }
119 }
120
121 if sections.is_empty() {
122 return None;
123 }
124
125 Some(sections.join("\n\n"))
126 }
127}
128
129fn format_context_file(file: &ContextFile) -> String {
130 if file.name.eq_ignore_ascii_case("AGENTS.md") {
131 return format!(
132 "<agents_md>\n# AGENTS.md (from {})\n\n{}\n</agents_md>",
133 file.path, file.content
134 );
135 }
136
137 if file.name.eq_ignore_ascii_case("APPS.md") {
138 return format!(
139 "<apps_md>\n# APPS.md (from {})\n\n{}\n</apps_md>",
140 file.path, file.content
141 );
142 }
143
144 format!(
145 "<context_file name=\"{}\" path=\"{}\">\n{}\n</context_file>",
146 escape_xml_attribute(&file.name),
147 escape_xml_attribute(&file.path),
148 file.content
149 )
150}
151
152fn escape_xml_attribute(value: &str) -> String {
153 value
154 .replace('&', "&")
155 .replace('"', """)
156 .replace('\'', "'")
157 .replace('<', "<")
158 .replace('>', ">")
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::context::{ContextPriority, project::ContextFile};
165
166 fn test_environment() -> EnvironmentContext {
167 EnvironmentContext {
168 machine_name: "test-machine".to_string(),
169 operating_system: "Linux".to_string(),
170 shell_type: "bash".to_string(),
171 is_container: false,
172 working_directory: "/tmp".to_string(),
173 current_datetime_utc: chrono::Utc::now(),
174 directory_tree: "├── src".to_string(),
175 git: None,
176 }
177 }
178
179 #[test]
180 fn builds_context_with_local_context_and_agents_file() {
181 let project = ProjectContext {
182 files: vec![ContextFile::new(
183 "AGENTS.md",
184 "/tmp/AGENTS.md",
185 "Follow project conventions",
186 ContextPriority::Critical,
187 )],
188 };
189
190 let context = SessionContextBuilder::new()
191 .environment(test_environment())
192 .project(project)
193 .build();
194
195 assert!(context.user_context_block.is_some());
196 if let Some(block) = context.user_context_block {
197 assert!(block.contains("<local_context>"));
198 assert!(block.contains("<agents_md>"));
199 }
200 }
201
202 #[test]
203 fn system_prompt_includes_base_prompt() {
204 let context = SessionContextBuilder::new()
205 .base_system_prompt("You are an expert DevOps agent.")
206 .build();
207
208 assert!(context.system_prompt.contains("expert DevOps agent"));
209 }
210
211 #[test]
212 fn system_prompt_includes_tool_summaries() {
213 let tools = vec![stakai::Tool::function(
214 "run_command",
215 "Execute a shell command",
216 )];
217
218 let context = SessionContextBuilder::new().tools(&tools).build();
219
220 assert!(context.system_prompt.contains("run_command"));
221 assert!(context.system_prompt.contains("Execute a shell command"));
222 }
223
224 #[test]
225 fn empty_builder_produces_empty_system_prompt_and_no_user_context() {
226 let context = SessionContextBuilder::new().build();
227
228 assert!(context.system_prompt.is_empty());
229 assert!(context.user_context_block.is_none());
230 }
231
232 #[test]
233 fn apps_md_formatted_with_apps_md_tag() {
234 let project = ProjectContext {
235 files: vec![ContextFile::new(
236 "APPS.md",
237 "/workspace/APPS.md",
238 "App configuration guide",
239 ContextPriority::High,
240 )],
241 };
242
243 let context = SessionContextBuilder::new().project(project).build();
244
245 if let Some(block) = context.user_context_block {
246 assert!(block.contains("<apps_md>"));
247 assert!(block.contains("App configuration guide"));
248 } else {
249 panic!("expected user context block with APPS.md");
250 }
251 }
252
253 #[test]
254 fn generic_context_file_uses_context_file_tag() {
255 let project = ProjectContext {
256 files: vec![ContextFile::new(
257 "notes.txt",
258 "caller://notes.txt",
259 "custom caller notes",
260 ContextPriority::CallerSupplied,
261 )],
262 };
263
264 let context = SessionContextBuilder::new().project(project).build();
265
266 if let Some(block) = context.user_context_block {
267 assert!(block.contains("<context_file"));
268 assert!(block.contains("custom caller notes"));
269 } else {
270 panic!("expected user context block with context file");
271 }
272 }
273
274 #[test]
275 fn generic_context_file_escapes_xml_attributes() {
276 let project = ProjectContext {
277 files: vec![ContextFile::new(
278 "bad\"name<>",
279 "caller://path?x=1&y='2'",
280 "content",
281 ContextPriority::CallerSupplied,
282 )],
283 };
284
285 let context = SessionContextBuilder::new().project(project).build();
286
287 if let Some(block) = context.user_context_block {
288 assert!(block.contains("name=\"bad"name<>\""));
289 assert!(block.contains("path=\"caller://path?x=1&y='2'\""));
290 } else {
291 panic!("expected user context block with escaped attributes");
292 }
293 }
294
295 #[test]
296 fn system_prompt_truncated_by_budget() {
297 let budget = ContextBudget {
298 system_prompt_max_chars: 50,
299 ..Default::default()
300 };
301
302 let context = SessionContextBuilder::new()
303 .base_system_prompt("A".repeat(1_000))
304 .budget(budget)
305 .build();
306
307 assert!(
308 context.system_prompt.chars().count() <= 50,
309 "system prompt should be truncated to budget"
310 );
311 }
312}