1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Instant;
6
7use imp_llm::Model;
8
9use crate::agent::{Agent, AgentHandle};
10use crate::config::{Config, LuaCapabilityPolicy};
11use crate::error::Result;
12use crate::mana_prompt_context;
13use crate::policy::RunPolicy;
14use crate::resources;
15use crate::roles::Role;
16use crate::system_prompt::{self, Fact, TaskContext};
17use crate::tools::{LuaToolLoader, ToolRegistry};
18use crate::workflow::{
19 AutonomyMode, ImplicitWorkflowContractInput, VerificationGate, VerificationRequirement,
20 WorkflowContract,
21};
22
23fn load_scoped_memory_block(
24 cwd: &std::path::Path,
25 path: &std::path::Path,
26 label: &str,
27 char_limit: usize,
28) -> Option<String> {
29 let store = crate::memory::MemoryStore::load(path, char_limit).ok()?;
30 let filtered: Vec<String> = store
31 .entries()
32 .iter()
33 .filter(|entry| !entry.contains("/tower") || cwd.to_string_lossy().contains("/tower"))
34 .cloned()
35 .collect();
36
37 if filtered.is_empty() {
38 return None;
39 }
40
41 let used: usize = filtered.iter().map(|e| e.len()).sum::<usize>()
42 + if filtered.len() > 1 {
43 (filtered.len() - 1) * 3
44 } else {
45 0
46 };
47 let pct = if char_limit > 0 {
48 (used as f64 / char_limit as f64 * 100.0) as u32
49 } else {
50 0
51 };
52 let bar = "══════════════════════════════════════════════";
53 Some(format!(
54 "{bar}\n{label} [{pct}% — {used}/{char_limit} chars]\n{bar}\n{}",
55 filtered.join("\n§\n")
56 ))
57}
58
59pub struct AgentBuilder {
64 config: Config,
65 cwd: PathBuf,
66 model: Model,
67 api_key: String,
68 role: Option<Role>,
69 task: Option<TaskContext>,
70 facts: Vec<Fact>,
71 system_prompt_override: Option<String>,
73 #[allow(clippy::type_complexity)]
75 extra_tools: Option<Box<dyn FnOnce(&mut ToolRegistry) + Send>>,
76 preloaded_lua_tools: Option<ToolRegistry>,
78 #[allow(clippy::type_complexity)]
84 lua_tool_loader: Option<LuaToolLoader>,
85 run_policy: RunPolicy,
87 preloaded_prompt_context: Option<mana_prompt_context::SessionPromptContext>,
89 pub verification_gates: Vec<VerificationGate>,
91 workflow_contract: Option<WorkflowContract>,
92}
93
94impl AgentBuilder {
95 pub fn new(config: Config, cwd: PathBuf, model: Model, api_key: String) -> Self {
97 Self {
98 config,
99 cwd,
100 model,
101 api_key,
102 role: None,
103 task: None,
104 facts: Vec::new(),
105 system_prompt_override: None,
106 extra_tools: None,
107 preloaded_lua_tools: None,
108 preloaded_prompt_context: None,
109 lua_tool_loader: None,
110 run_policy: RunPolicy::default(),
111 verification_gates: Vec::new(),
112 workflow_contract: None,
113 }
114 }
115
116 pub fn role(mut self, role: Role) -> Self {
118 self.role = Some(role);
119 self
120 }
121
122 pub fn task(mut self, task: TaskContext) -> Self {
124 self.task = Some(task);
125 self
126 }
127
128 pub fn facts(mut self, facts: Vec<Fact>) -> Self {
130 self.facts = facts;
131 self
132 }
133
134 pub fn system_prompt(mut self, prompt: String) -> Self {
137 self.system_prompt_override = Some(prompt);
138 self
139 }
140
141 pub fn extra_tools<F>(mut self, f: F) -> Self
143 where
144 F: FnOnce(&mut ToolRegistry) + Send + 'static,
145 {
146 self.extra_tools = Some(Box::new(f));
147 self
148 }
149
150 pub fn preloaded_lua_tools(mut self, tools: ToolRegistry) -> Self {
160 self.preloaded_lua_tools = Some(tools);
161 self
162 }
163
164 pub fn lua_tool_loader<F>(mut self, f: F) -> Self
165 where
166 F: Fn(&LuaCapabilityPolicy, &mut ToolRegistry) + Send + Sync + 'static,
167 {
168 self.lua_tool_loader = Some(Arc::new(f));
169 self
170 }
171
172 pub fn run_policy(mut self, policy: RunPolicy) -> Self {
174 self.run_policy = policy;
175 self
176 }
177
178 pub fn verification_gate(mut self, gate: VerificationGate) -> Self {
180 self.verification_gates.push(gate);
181 self
182 }
183
184 pub fn verification_gates<I>(mut self, gates: I) -> Self
186 where
187 I: IntoIterator<Item = VerificationGate>,
188 {
189 self.verification_gates.extend(gates);
190 self
191 }
192
193 pub fn verify_command(mut self, command: impl Into<String>, required: bool) -> Self {
195 let requirement = VerificationRequirement {
196 name: None,
197 kind: crate::workflow::VerificationRequirementKind::Command {
198 command: command.into(),
199 },
200 required,
201 };
202 let gate = VerificationGate::from_requirement(self.verification_gates.len(), &requirement);
203 self.verification_gates.push(gate);
204 self
205 }
206
207 pub fn preloaded_prompt_context(
209 mut self,
210 context: mana_prompt_context::SessionPromptContext,
211 ) -> Self {
212 self.preloaded_prompt_context = Some(context);
213 self
214 }
215
216 pub fn workflow_contract(mut self, contract: WorkflowContract) -> Self {
218 self.workflow_contract = Some(contract);
219 self
220 }
221
222 pub fn autonomy_mode(mut self, mode: AutonomyMode) -> Self {
224 let mut contract = self.workflow_contract.unwrap_or_else(|| {
225 WorkflowContract::implicit_from(
226 ImplicitWorkflowContractInput::prompt("").cwd(&self.cwd),
227 )
228 });
229 contract.autonomy_mode = mode;
230 self.workflow_contract = Some(contract);
231 self
232 }
233
234 pub fn build(self) -> Result<(Agent, AgentHandle)> {
238 let build_started = Instant::now();
239 let trace_path = std::env::var_os("IMP_TUI_TRACE").map(PathBuf::from);
240 let trace_phase = |phase: &str, started: Instant| {
241 if let Some(path) = trace_path.as_ref() {
242 if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
243 let _ = writeln!(
244 file,
245 "{} agent_builder_phase phase={} duration_ms={}",
246 imp_llm::now(),
247 phase,
248 started.elapsed().as_millis()
249 );
250 }
251 }
252 };
253
254 let (mut agent, handle) = Agent::new(self.model, self.cwd.clone());
255 agent.api_key = self.api_key;
256 if let Some(thinking) = self.config.thinking {
257 agent.thinking_level = thinking;
258 }
259 if let Some(max_tokens) = self.config.max_tokens {
260 agent.max_tokens = Some(max_tokens);
261 }
262 agent.context_config = self.config.context.clone();
263 if let Some(ref role) = self.role {
264 if let Some(thinking) = role.thinking_level {
265 agent.thinking_level = thinking;
266 }
267 agent.role = Some(role.clone());
268 }
269 agent.hooks.load_from_config(self.config.hooks.clone());
270 agent.mode = self.config.mode;
271 agent.guardrail_config = self.config.guardrails.clone();
272 agent.guardrail_profile = if self.config.guardrails.is_enabled() {
273 Some(self.config.guardrails.resolve_effective_profile(&self.cwd))
274 } else {
275 None
276 };
277 agent.read_max_lines = self.config.ui.read_max_lines;
278 agent.continue_policy = self.config.ui.continue_policy;
279 agent.config = Arc::new(self.config.clone());
280 agent.run_policy = self.run_policy;
281 agent.verification_gates = self.verification_gates;
282 agent.lua_tool_loader = self.lua_tool_loader.clone();
283
284 let phase_started = Instant::now();
285 register_native_tools(&mut agent.tools);
286 if let Some(extra) = self.extra_tools {
287 extra(&mut agent.tools);
288 }
289 trace_phase("native_extra_tools", phase_started);
290
291 let phase_started = Instant::now();
292 if let Some(preloaded_lua_tools) = self.preloaded_lua_tools {
293 agent.tools.extend(preloaded_lua_tools);
294 } else if let Some(lua_loader) = self.lua_tool_loader {
295 let lua_policy = self.config.lua.resolve_policy(agent.mode);
296 lua_loader(&lua_policy, &mut agent.tools);
297 }
298 trace_phase("lua_tools", phase_started);
299
300 let phase_started = Instant::now();
301 if agent.mode != crate::config::AgentMode::Full {
302 let mode = agent.mode;
303 agent.tools.retain(|name| mode.allows_tool(name));
304 }
305 trace_phase("mode_filter", phase_started);
306
307 let phase_started = Instant::now();
308 agent.system_prompt = if let Some(prompt) = self.system_prompt_override {
309 prompt
310 } else {
311 let user_config_dir = Config::user_config_dir();
312 let resource_started = Instant::now();
313 let agents_md = resources::discover_agents_md(&self.cwd, &user_config_dir);
314 let soul = resources::discover_soul(&self.cwd, &user_config_dir);
315 let skills = resources::discover_skills(&self.cwd, &user_config_dir);
316 trace_phase("resources_discovery", resource_started);
317 agent.has_mana_skill = skills.iter().any(|skill| skill.name == "mana");
318 agent.has_mana_basics_skill = skills.iter().any(|skill| skill.name == "mana-basics");
319 agent.has_mana_delegation_skill =
320 skills.iter().any(|skill| skill.name == "mana-delegation");
321
322 let (memory_block, user_block) = if self.config.learning.enabled {
323 let memory_started = Instant::now();
324 let mem = load_scoped_memory_block(
325 &self.cwd,
326 &user_config_dir.join("memory.md"),
327 "MEMORY (your personal notes)",
328 self.config.learning.memory_char_limit,
329 );
330 let user = load_scoped_memory_block(
331 &self.cwd,
332 &user_config_dir.join("user.md"),
333 "USER PROFILE",
334 self.config.learning.user_char_limit,
335 );
336 trace_phase("memory_load", memory_started);
337 (mem, user)
338 } else {
339 (None, None)
340 };
341
342 let prompt_context_started = Instant::now();
343 let prompt_context = if self.facts.is_empty() {
344 self.preloaded_prompt_context
345 .clone()
346 .unwrap_or_else(|| mana_prompt_context::load_session_prompt_context(&self.cwd))
347 } else {
348 mana_prompt_context::SessionPromptContext {
349 facts: self.facts.clone(),
350 fact_provenance: self
351 .facts
352 .iter()
353 .map(|fact| {
354 crate::trust::TrustedContext::new(
355 fact.text.clone(),
356 crate::trust::Provenance::mana_record(
357 crate::trust::ManaRecordKind::Fact,
358 "builder-fact",
359 ),
360 )
361 })
362 .collect(),
363 project_memory_status: None,
364 project_memory_status_provenance: None,
365 }
366 };
367 trace_phase("mana_prompt_context", prompt_context_started);
368
369 let assemble_started = Instant::now();
370 let prompt = system_prompt::assemble(&system_prompt::AssembleParams {
371 tools: &agent.tools,
372 agents_md: &agents_md,
373 skills: &skills,
374 facts: &prompt_context.facts,
375 project_memory_status: prompt_context.project_memory_status.as_deref(),
376 personality: Some(&self.config.personality.profile),
377 soul: soul.as_ref(),
378 task: self.task.as_ref(),
379 role: self.role.as_ref(),
380 mode: &agent.mode,
381 memory: memory_block.as_deref(),
382 user_profile: user_block.as_deref(),
383 cwd: Some(&self.cwd),
384 learning_enabled: self.config.learning.enabled,
385 guardrail_profile: agent.guardrail_profile,
386 })
387 .text;
388 trace_phase("system_prompt_assemble", assemble_started);
389 prompt
390 };
391 trace_phase("system_prompt_total", phase_started);
392 if let Some(path) = trace_path.as_ref() {
393 if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
394 let _ = writeln!(
395 file,
396 "{} agent_builder_total duration_ms={}",
397 imp_llm::now(),
398 build_started.elapsed().as_millis()
399 );
400 }
401 }
402 Ok((agent, handle))
403 }
404}
405
406pub fn register_native_tools(tools: &mut ToolRegistry) {
410 use crate::tools::{
411 ask::AskTool, bash::BashTool, edit::EditTool, git::GitTool, mana::ManaTool, read::ReadTool,
412 scan::ScanTool, session_search::SessionSearchTool, web::WebTool, worktree::WorktreeTool,
413 write::WriteTool,
414 };
415
416 tools.register(Arc::new(AskTool));
417 tools.register(Arc::new(BashTool::canonical()));
418 tools.register(Arc::new(EditTool));
419 tools.register(Arc::new(GitTool));
420 tools.register(Arc::new(ManaTool::default()));
421 tools.register(Arc::new(ReadTool));
422 tools.register(Arc::new(WriteTool));
423 tools.register(Arc::new(ScanTool));
424 tools.register(Arc::new(SessionSearchTool));
425 tools.register(Arc::new(WebTool));
426 tools.register(Arc::new(WorktreeTool));
427 tools.register_alias("session_search", "recall");
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use std::pin::Pin;
434 use std::sync::Arc;
435
436 use async_trait::async_trait;
437 use futures_core::Stream;
438 use imp_llm::{
439 auth::{ApiKey, AuthStore},
440 model::{Capabilities, ModelMeta, ModelPricing},
441 provider::Provider,
442 Context, Model, RequestOptions, StreamEvent,
443 };
444
445 struct MockProvider;
446
447 #[async_trait]
448 impl Provider for MockProvider {
449 fn stream(
450 &self,
451 _model: &Model,
452 _context: Context,
453 _options: RequestOptions,
454 _api_key: &str,
455 ) -> Pin<Box<dyn Stream<Item = imp_llm::Result<StreamEvent>> + Send>> {
456 Box::pin(futures::stream::empty())
457 }
458
459 async fn resolve_auth(&self, _auth: &AuthStore) -> imp_llm::Result<ApiKey> {
460 Ok("test-key".to_string())
461 }
462
463 fn id(&self) -> &str {
464 "mock"
465 }
466
467 fn models(&self) -> &[ModelMeta] {
468 &[]
469 }
470 }
471
472 fn test_model() -> Model {
473 Model {
474 meta: ModelMeta {
475 id: "test-model".to_string(),
476 provider: "mock".to_string(),
477 name: "Test Model".to_string(),
478 context_window: 200_000,
479 max_output_tokens: 4096,
480 pricing: ModelPricing {
481 input_per_mtok: 3.0,
482 output_per_mtok: 15.0,
483 cache_read_per_mtok: 0.3,
484 cache_write_per_mtok: 3.75,
485 },
486 capabilities: Capabilities {
487 reasoning: false,
488 images: false,
489 tool_use: true,
490 },
491 },
492 provider: Arc::new(MockProvider),
493 }
494 }
495
496 #[test]
497 fn builder_applies_config_max_tokens() {
498 let config = Config {
499 max_tokens: Some(2048),
500 ..Default::default()
501 };
502
503 let (agent, _handle) =
504 AgentBuilder::new(config, PathBuf::from("/tmp"), test_model(), "key".into())
505 .build()
506 .unwrap();
507
508 assert_eq!(agent.max_tokens, Some(2048));
509 }
510
511 #[test]
512 fn builder_applies_context_config_thresholds() {
513 let mut config = Config::default();
514 config.context.observation_mask_threshold = 0.5;
515 config.context.mask_window = 7;
516
517 let (agent, _handle) =
518 AgentBuilder::new(config, PathBuf::from("/tmp"), test_model(), "key".into())
519 .build()
520 .unwrap();
521
522 assert!((agent.context_config.observation_mask_threshold - 0.5).abs() < f64::EPSILON);
523 assert_eq!(agent.context_config.mask_window, 7);
524 }
525
526 #[test]
527 fn builder_default_config_uses_standard_thresholds() {
528 let (agent, _handle) = AgentBuilder::new(
529 Config::default(),
530 PathBuf::from("/tmp"),
531 test_model(),
532 "key".into(),
533 )
534 .build()
535 .unwrap();
536
537 assert!((agent.context_config.observation_mask_threshold - 0.6).abs() < f64::EPSILON);
538 assert_eq!(agent.context_config.mask_window, 10);
539 }
540
541 #[test]
542 fn builder_system_prompt_override_skips_discovery() {
543 let (agent, _handle) = AgentBuilder::new(
544 Config::default(),
545 PathBuf::from("/tmp"),
546 test_model(),
547 "key".into(),
548 )
549 .system_prompt("custom system prompt".into())
550 .build()
551 .unwrap();
552
553 assert_eq!(agent.system_prompt, "custom system prompt");
554 }
555
556 #[test]
557 fn builder_api_key_wired() {
558 let (agent, _handle) = AgentBuilder::new(
559 Config::default(),
560 PathBuf::from("/tmp"),
561 test_model(),
562 "my-api-key".into(),
563 )
564 .build()
565 .unwrap();
566
567 assert_eq!(agent.api_key, "my-api-key");
568 }
569
570 #[test]
571 fn builder_extra_tools_registered() {
572 use crate::tools::{Tool, ToolContext, ToolOutput};
573
574 struct DummyTool;
575
576 #[async_trait]
577 impl Tool for DummyTool {
578 fn name(&self) -> &str {
579 "dummy"
580 }
581 fn label(&self) -> &str {
582 "Dummy"
583 }
584 fn description(&self) -> &str {
585 "A dummy tool for testing"
586 }
587 fn parameters(&self) -> serde_json::Value {
588 serde_json::json!({"type": "object"})
589 }
590 fn is_readonly(&self) -> bool {
591 true
592 }
593 async fn execute(
594 &self,
595 _call_id: &str,
596 _params: serde_json::Value,
597 _ctx: ToolContext,
598 ) -> crate::error::Result<ToolOutput> {
599 Ok(ToolOutput::text("ok"))
600 }
601 }
602
603 let (agent, _handle) = AgentBuilder::new(
604 Config::default(),
605 PathBuf::from("/tmp"),
606 test_model(),
607 "key".into(),
608 )
609 .extra_tools(|tools| tools.register(Arc::new(DummyTool)))
610 .build()
611 .unwrap();
612
613 assert!(agent.tools.get("dummy").is_some());
614 }
615
616 #[test]
617 fn builder_registers_canonical_tools_and_compat_aliases() {
618 let (agent, _handle) = AgentBuilder::new(
619 Config::default(),
620 PathBuf::from("/tmp"),
621 test_model(),
622 "key".into(),
623 )
624 .build()
625 .unwrap();
626
627 assert!(agent.tools.get("bash").is_some());
628 assert!(agent.tools.get("shell").is_none());
629 assert!(agent.tools.get("sh").is_none());
630 assert!(agent.tools.get("ask_agent").is_none());
631 assert!(agent.tools.get("imp").is_none());
632 assert!(agent.tools.get("spawn").is_none());
633 assert!(agent.tools.get("edit").is_some());
634 assert!(agent.tools.get("multi_edit").is_none());
635 assert!(agent.tools.get("memory").is_none());
636 assert!(agent.tools.get("recall").is_some());
637 assert!(agent.tools.get("session_search").is_some());
638 assert!(agent.tools.get("git").is_some());
639
640 let mut definition_names: Vec<_> = agent
641 .tools
642 .definitions()
643 .into_iter()
644 .map(|definition| definition.name)
645 .collect();
646 definition_names.sort();
647
648 assert!(definition_names.contains(&"bash".to_string()));
649 assert!(!definition_names.contains(&"ask_agent".to_string()));
650 assert!(!definition_names.contains(&"spawn".to_string()));
651 assert!(definition_names.contains(&"edit".to_string()));
652 assert!(!definition_names.contains(&"imp".to_string()));
653 assert!(!definition_names.contains(&"multi_edit".to_string()));
654 assert!(definition_names.contains(&"recall".to_string()));
655 assert!(!definition_names.contains(&"session_search".to_string()));
656 assert!(!definition_names.contains(&"memory".to_string()));
657 }
658
659 #[test]
660 fn builder_filters_tower_memory_outside_tower_projects() {
661 let temp = tempfile::TempDir::new().unwrap();
662 let prev = std::env::var_os("XDG_CONFIG_HOME");
663 std::env::set_var("XDG_CONFIG_HOME", temp.path());
664
665 let imp_dir = temp.path().join("imp");
666 std::fs::create_dir_all(&imp_dir).unwrap();
667 std::fs::write(
668 imp_dir.join("memory.md"),
669 "Project lives at /Users/asher/tower and uses root mana.",
670 )
671 .unwrap();
672 std::fs::write(
673 imp_dir.join("user.md"),
674 "User prefers root mana in /tower for Tower work.",
675 )
676 .unwrap();
677
678 let mut config = Config::default();
679 config.learning.enabled = true;
680
681 let (agent, _handle) = AgentBuilder::new(
682 config,
683 PathBuf::from("/tmp/not-tower/project"),
684 test_model(),
685 "key".into(),
686 )
687 .build()
688 .unwrap();
689
690 assert!(!agent.system_prompt.contains("/Users/asher/tower"));
691 assert!(!agent.system_prompt.contains("/tower for Tower work"));
692
693 if let Some(prev) = prev {
694 std::env::set_var("XDG_CONFIG_HOME", prev);
695 } else {
696 std::env::remove_var("XDG_CONFIG_HOME");
697 }
698 }
699
700 #[test]
701 fn builder_keeps_tower_memory_inside_tower_projects() {
702 let temp = tempfile::TempDir::new().unwrap();
703 let prev = std::env::var_os("XDG_CONFIG_HOME");
704 std::env::set_var("XDG_CONFIG_HOME", temp.path());
705
706 let imp_dir = temp.path().join("imp");
707 std::fs::create_dir_all(&imp_dir).unwrap();
708 std::fs::write(
709 imp_dir.join("memory.md"),
710 "Project lives at /Users/asher/tower and uses root mana.",
711 )
712 .unwrap();
713
714 let mut config = Config::default();
715 config.learning.enabled = true;
716
717 let (agent, _handle) = AgentBuilder::new(
718 config,
719 PathBuf::from("/Users/asher/tower/imp"),
720 test_model(),
721 "key".into(),
722 )
723 .build()
724 .unwrap();
725
726 assert!(agent.system_prompt.contains("/Users/asher/tower"));
727
728 if let Some(prev) = prev {
729 std::env::set_var("XDG_CONFIG_HOME", prev);
730 } else {
731 std::env::remove_var("XDG_CONFIG_HOME");
732 }
733 }
734
735 #[test]
736 fn builder_injects_mana_facts_into_system_prompt_when_available() {
737 let temp = tempfile::TempDir::new().unwrap();
738 let mana_dir = temp.path().join(".mana");
739 std::fs::create_dir(&mana_dir).unwrap();
740
741 let mana_config = mana_core::config::Config {
742 project: "test".to_string(),
743 ..Default::default()
744 };
745 mana_config.save(&mana_dir).unwrap();
746
747 let mut working = mana_core::unit::Unit::new("1", "Implement auth flow");
748 working.status = mana_core::unit::Status::InProgress;
749 working.paths = vec!["src/auth.rs".to_string()];
750 working.requires = vec!["AuthProvider".to_string()];
751 let working_slug = mana_core::util::title_to_slug(&working.title);
752 working
753 .to_file(mana_dir.join(format!("1-{}.md", working_slug)))
754 .unwrap();
755
756 let mut fact = mana_core::unit::Unit::new("2", "Auth uses RS256 signing");
757 fact.unit_type = "fact".to_string();
758 fact.paths = vec!["src/auth.rs".to_string()];
759 fact.produces = vec!["AuthProvider".to_string()];
760 fact.last_verified = Some(chrono::Utc::now() - chrono::Duration::hours(2));
761 let fact_slug = mana_core::util::title_to_slug(&fact.title);
762 fact.to_file(mana_dir.join(format!("2-{}.md", fact_slug)))
763 .unwrap();
764
765 let (agent, _handle) = AgentBuilder::new(
766 Config::default(),
767 temp.path().join("src"),
768 test_model(),
769 "key".into(),
770 )
771 .build()
772 .unwrap();
773
774 assert!(agent.system_prompt.contains("Project facts:"));
775 assert!(agent.system_prompt.contains("Auth uses RS256 signing"));
776 assert!(agent.system_prompt.contains("verified 2h ago"));
777 assert!(agent.system_prompt.contains("Project memory status:"));
778 assert!(agent.system_prompt.contains("Working on:"));
779 assert!(agent.system_prompt.contains("[1] Implement auth flow"));
780 }
781
782 #[test]
783 fn builder_injects_project_memory_status_into_system_prompt_when_available() {
784 let temp = tempfile::TempDir::new().unwrap();
785 let mana_dir = temp.path().join(".mana");
786 std::fs::create_dir(&mana_dir).unwrap();
787
788 let mana_config = mana_core::config::Config {
789 project: "test".to_string(),
790 ..Default::default()
791 };
792 mana_config.save(&mana_dir).unwrap();
793
794 let mut working = mana_core::unit::Unit::new("1", "Implement auth flow");
795 working.status = mana_core::unit::Status::InProgress;
796 working.claimed_by = Some("imp".to_string());
797 let working_slug = mana_core::util::title_to_slug(&working.title);
798 working
799 .to_file(mana_dir.join(format!("1-{}.md", working_slug)))
800 .unwrap();
801
802 let mut recent = mana_core::unit::Unit::new("3", "Recently closed cleanup");
803 recent.status = mana_core::unit::Status::Closed;
804 recent.closed_at = Some(chrono::Utc::now() - chrono::Duration::hours(2));
805 let recent_slug = mana_core::util::title_to_slug(&recent.title);
806 let archive_dir = mana_dir.join("archive").join("2026").join("05");
807 std::fs::create_dir_all(&archive_dir).unwrap();
808 recent
809 .to_file(archive_dir.join(format!("3-{}.md", recent_slug)))
810 .unwrap();
811
812 let (agent, _handle) = AgentBuilder::new(
813 Config::default(),
814 temp.path().join("src"),
815 test_model(),
816 "key".into(),
817 )
818 .build()
819 .unwrap();
820
821 assert!(agent.system_prompt.contains("Project memory status:"));
822 assert!(agent.system_prompt.contains("Working on:"));
823 assert!(agent.system_prompt.contains("[1] Implement auth flow"));
824 assert!(agent.system_prompt.contains("Recent work:"));
825 assert!(agent.system_prompt.contains("[3] Recently closed cleanup"));
826 }
827
828 #[test]
829 fn builder_task_fact_override_does_not_add_project_memory_status() {
830 let facts = vec![Fact {
831 text: "Auth uses RS256 signing".into(),
832 verified_ago: "2h ago".into(),
833 }];
834
835 let (agent, _handle) = AgentBuilder::new(
836 Config::default(),
837 PathBuf::from("/tmp"),
838 test_model(),
839 "key".into(),
840 )
841 .facts(facts)
842 .build()
843 .unwrap();
844
845 assert!(agent.system_prompt.contains("Project facts:"));
846 assert!(!agent.system_prompt.contains("Project memory status:"));
847 }
848
849 #[test]
850 fn builder_hooks_loaded_from_config() {
851 use crate::hooks::HookDef;
852
853 let mut config = Config::default();
854 config.hooks.push(HookDef {
855 event: "after_file_write".into(),
856 match_pattern: Some("*.rs".into()),
857 action: "shell".into(),
858 command: Some("echo hook fired".into()),
859 blocking: false,
860 threshold: None,
861 });
862
863 let (agent, _handle) =
864 AgentBuilder::new(config, PathBuf::from("/tmp"), test_model(), "key".into())
865 .build()
866 .unwrap();
867
868 assert_eq!(agent.hooks.len(), 1);
870 }
871}