Skip to main content

imp_core/
builder.rs

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
59/// Builder for creating a fully wired [`Agent`] from config and context.
60///
61/// Handles resource discovery, hook loading, system prompt assembly, and tool
62/// registration so callers don't need to repeat this boilerplate.
63pub 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    /// Override the assembled system prompt entirely.
72    system_prompt_override: Option<String>,
73    /// Additional tool registrar called after native tools are registered.
74    #[allow(clippy::type_complexity)]
75    extra_tools: Option<Box<dyn FnOnce(&mut ToolRegistry) + Send>>,
76    /// Preloaded Lua extension tool registrar.
77    preloaded_lua_tools: Option<ToolRegistry>,
78    /// Lua extension tool loader — called after native and extra tools.
79    ///
80    /// The imp-lua crate provides the actual implementation; the binary
81    /// crate wires it in to avoid a cyclic dependency between imp-core
82    /// and imp-lua.
83    #[allow(clippy::type_complexity)]
84    lua_tool_loader: Option<LuaToolLoader>,
85    /// Per-run tool/write policy layered on top of AgentMode.
86    run_policy: RunPolicy,
87    /// Preloaded mana prompt context; avoids duplicate mana reads for worker mode.
88    preloaded_prompt_context: Option<mana_prompt_context::SessionPromptContext>,
89    /// Optional workflow contract override. If absent, build creates an implicit contract.
90    pub verification_gates: Vec<VerificationGate>,
91    workflow_contract: Option<WorkflowContract>,
92}
93
94impl AgentBuilder {
95    /// Create a new builder.
96    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    /// Set the role for this agent.
117    pub fn role(mut self, role: Role) -> Self {
118        self.role = Some(role);
119        self
120    }
121
122    /// Set the task context (headless/task mode — Layer 5 of the system prompt).
123    pub fn task(mut self, task: TaskContext) -> Self {
124        self.task = Some(task);
125        self
126    }
127
128    /// Set task-specific facts to inject into the system prompt.
129    pub fn facts(mut self, facts: Vec<Fact>) -> Self {
130        self.facts = facts;
131        self
132    }
133
134    /// Override the assembled system prompt with a custom string.
135    /// When set, resource discovery and assembly are skipped.
136    pub fn system_prompt(mut self, prompt: String) -> Self {
137        self.system_prompt_override = Some(prompt);
138        self
139    }
140
141    /// Register additional tools after the native tools are registered.
142    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    /// Register a Lua extension tool loader.
151    ///
152    /// The provided closure should discover `.lua` extensions, create a
153    /// `LuaRuntime`, and register the resulting tools onto the registry.
154    /// This is called after native + extra tools are registered but before
155    /// mode filtering.
156    ///
157    /// The binary crate typically calls this with a closure that invokes
158    /// `imp_lua::load_lua_extensions()`.
159    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    /// Apply a per-run policy on top of the configured agent mode.
173    pub fn run_policy(mut self, policy: RunPolicy) -> Self {
174        self.run_policy = policy;
175        self
176    }
177
178    /// Add a verification gate to the agent run.
179    pub fn verification_gate(mut self, gate: VerificationGate) -> Self {
180        self.verification_gates.push(gate);
181        self
182    }
183
184    /// Add verification gates to the agent run.
185    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    /// Add a command verification gate to the agent run.
194    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    /// Use preloaded mana prompt context instead of loading it during build.
208    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    /// Override the implicit workflow contract for this agent run.
217    pub fn workflow_contract(mut self, contract: WorkflowContract) -> Self {
218        self.workflow_contract = Some(contract);
219        self
220    }
221
222    /// Set the autonomy mode on the implicit workflow contract.
223    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    /// Build the agent, wiring config → thresholds, hooks, resources, and tools.
235    ///
236    /// Returns `(Agent, AgentHandle)` ready for use.
237    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
406/// Register the standard set of native tools onto a tool registry.
407///
408/// This is the canonical list — update here when adding or removing tools.
409pub 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        // Hooks were loaded — the runner should have one registered hook
869        assert_eq!(agent.hooks.len(), 1);
870    }
871}