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