Skip to main content

heartbit_core/agent/
builder.rs

1#![allow(missing_docs)]
2use std::collections::HashMap;
3use std::sync::Arc;
4use std::time::Duration;
5
6use crate::error::Error;
7use crate::knowledge::KnowledgeBase;
8use crate::llm::LlmProvider;
9use crate::llm::types::ToolDefinition;
10use crate::memory::Memory;
11use crate::tool::Tool;
12use crate::tool::builtins::OnQuestion;
13
14use super::audit;
15use super::cache;
16use super::context::ContextStrategy;
17use super::events::OnEvent;
18use super::guardrail::Guardrail;
19use super::instructions;
20use super::observability;
21use super::permission;
22use super::pruner;
23use super::runner::{AgentRunner, OnInput, RESOURCEFULNESS_GUIDELINES};
24use super::tool_filter;
25
26/// Builder for [`AgentRunner`].
27///
28/// Construct via [`AgentRunner::builder`], configure the agent with chainable
29/// setter methods, then call [`build`](AgentRunnerBuilder::build) to produce
30/// the runner. Build validates several invariants: `on_input` and
31/// `structured_schema` are mutually exclusive, `max_tool_calls_per_turn = 0` is
32/// rejected, and turn/token limits must be non-zero. For multi-agent scenarios
33/// use [`OrchestratorBuilder`](crate::agent::orchestrator::OrchestratorBuilder)
34/// instead, which internally wraps an `AgentRunner` with sub-agent delegation.
35pub struct AgentRunnerBuilder<P: LlmProvider> {
36    pub(super) provider: Arc<P>,
37    pub(super) name: String,
38    pub(super) system_prompt: String,
39    pub(super) tools: Vec<Arc<dyn Tool>>,
40    pub(super) max_turns: usize,
41    pub(super) max_tokens: u32,
42    pub(super) context_strategy: Option<ContextStrategy>,
43    pub(super) summarize_threshold: Option<u32>,
44    pub(super) memory: Option<Arc<dyn Memory>>,
45    pub(super) knowledge_base: Option<Arc<dyn KnowledgeBase>>,
46    pub(super) on_text: Option<Arc<crate::llm::OnText>>,
47    pub(super) on_approval: Option<Arc<crate::llm::OnApproval>>,
48    pub(super) tool_timeout: Option<Duration>,
49    pub(super) max_tool_output_bytes: Option<usize>,
50    pub(super) structured_schema: Option<serde_json::Value>,
51    pub(super) on_event: Option<Arc<OnEvent>>,
52    pub(super) guardrails: Vec<Arc<dyn Guardrail>>,
53    pub(super) on_question: Option<Arc<OnQuestion>>,
54    pub(super) on_input: Option<Arc<OnInput>>,
55    pub(super) run_timeout: Option<Duration>,
56    pub(super) reasoning_effort: Option<crate::llm::types::ReasoningEffort>,
57    pub(super) enable_reflection: bool,
58    pub(super) tool_output_compression_threshold: Option<usize>,
59    pub(super) max_tools_per_turn: Option<usize>,
60    pub(super) tool_profile: Option<tool_filter::ToolProfile>,
61    pub(super) max_identical_tool_calls: Option<u32>,
62    pub(super) max_fuzzy_identical_tool_calls: Option<u32>,
63    /// Hard cap on the number of tool invocations the LLM may emit per turn.
64    /// Distinct from `max_tools_per_turn` (which limits tool *definitions* offered
65    /// to the LLM). `None` = unlimited. Zero is rejected at build time.
66    pub(super) max_tool_calls_per_turn: Option<u32>,
67    pub(super) permission_rules: permission::PermissionRuleset,
68    /// Instruction file contents to prepend to the system prompt.
69    pub(super) instruction_text: Option<String>,
70    pub(super) learned_permissions: Option<Arc<std::sync::Mutex<permission::LearnedPermissions>>>,
71    pub(super) lsp_manager: Option<Arc<crate::lsp::LspManager>>,
72    pub(super) session_prune_config: Option<pruner::SessionPruneConfig>,
73    pub(super) enable_recursive_summarization: bool,
74    pub(super) reflection_threshold: Option<u32>,
75    pub(super) consolidate_on_exit: bool,
76    pub(super) observability_mode: Option<observability::ObservabilityMode>,
77    /// Optional workspace root for file tool path resolution and system prompt.
78    pub(super) workspace: Option<std::path::PathBuf>,
79    /// Hard limit on cumulative tokens (input + output) across all turns.
80    pub(super) max_total_tokens: Option<u64>,
81    /// Controls whether audit records include full content or metadata only.
82    pub(super) audit_mode: audit::AuditMode,
83    /// Optional audit trail for recording untruncated agent decisions.
84    pub(super) audit_trail: Option<Arc<dyn audit::AuditTrail>>,
85    /// Optional user context for multi-tenant audit enrichment.
86    pub(super) audit_user_id: Option<String>,
87    pub(super) audit_tenant_id: Option<String>,
88    /// Delegation chain for audit records (e.g., `["heartbit-agent"]` when acting OBO user).
89    pub(super) audit_delegation_chain: Vec<String>,
90    /// Optional LRU response cache size. When set, builds a `ResponseCache`.
91    pub(super) response_cache_size: Option<usize>,
92    /// Optional per-tenant in-flight token tracker. When set, the runner calls
93    /// `tracker.adjust(&scope, delta)` after each LLM response to reconcile
94    /// actual usage against the estimate. Has no effect when `audit_tenant_id`
95    /// is unset.
96    pub(super) tenant_tracker: Option<Arc<crate::agent::tenant_tracker::TenantTokenTracker>>,
97}
98
99impl<P: LlmProvider> AgentRunnerBuilder<P> {
100    pub fn name(mut self, name: impl Into<String>) -> Self {
101        self.name = name.into();
102        self
103    }
104
105    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
106        self.system_prompt = prompt.into();
107        self
108    }
109
110    pub fn tool(mut self, tool: Arc<dyn Tool>) -> Self {
111        self.tools.push(tool);
112        self
113    }
114
115    /// Register a batch of tools.
116    ///
117    /// SECURITY (F-MCP-2): when MCP-discovered tools and builtins coexist,
118    /// **register the trusted builtins first**. The runner deduplicates by
119    /// name with first-wins semantics, so a hostile MCP server that exports a
120    /// tool named `bash` will be shadowed by the local `bash` builtin only if
121    /// the builtin was added before. The collision is logged at `error!` and
122    /// emits a `tool_name_collision` audit signal.
123    pub fn tools(mut self, tools: Vec<Arc<dyn Tool>>) -> Self {
124        self.tools.extend(tools);
125        self
126    }
127
128    pub fn max_turns(mut self, max_turns: usize) -> Self {
129        self.max_turns = max_turns;
130        self
131    }
132
133    pub fn max_tokens(mut self, max_tokens: u32) -> Self {
134        self.max_tokens = max_tokens;
135        self
136    }
137
138    pub fn context_strategy(mut self, strategy: ContextStrategy) -> Self {
139        self.context_strategy = Some(strategy);
140        self
141    }
142
143    /// Set the token threshold at which to trigger automatic summarization.
144    pub fn summarize_threshold(mut self, threshold: u32) -> Self {
145        self.summarize_threshold = Some(threshold);
146        self
147    }
148
149    /// Attach a memory store to the agent. Memory tools (store, recall, update,
150    /// forget, consolidate) are created at `build()` time using the builder's `name`.
151    ///
152    /// Call `.name()` before or after `.memory()` — the agent name is resolved at build.
153    pub fn memory(mut self, memory: Arc<dyn Memory>) -> Self {
154        self.memory = Some(memory);
155        self
156    }
157
158    /// Attach a knowledge base to the agent. The `knowledge_search` tool is
159    /// added at `build()` time.
160    pub fn knowledge(mut self, kb: Arc<dyn KnowledgeBase>) -> Self {
161        self.knowledge_base = Some(kb);
162        self
163    }
164
165    /// Set a callback for streaming text output. When set, the agent uses
166    /// `stream_complete` instead of `complete`, calling the callback for each
167    /// text delta as it arrives from the LLM.
168    ///
169    /// The callback must not panic. A panic inside the callback will propagate
170    /// through the agent loop and abort the run.
171    pub fn on_text(mut self, callback: Arc<crate::llm::OnText>) -> Self {
172        self.on_text = Some(callback);
173        self
174    }
175
176    /// Set a callback for human-in-the-loop approval before tool execution.
177    ///
178    /// When set, the callback is invoked with the list of tool calls before
179    /// each execution round. If it returns `false`, tool execution is denied
180    /// and the agent receives error results, allowing the LLM to adjust.
181    pub fn on_approval(mut self, callback: Arc<crate::llm::OnApproval>) -> Self {
182        self.on_approval = Some(callback);
183        self
184    }
185
186    /// Set a timeout for individual tool executions. If a tool does not
187    /// complete within this duration, the execution is cancelled and an
188    /// error result is returned to the LLM.
189    ///
190    /// Default: `None` (no timeout).
191    pub fn tool_timeout(mut self, timeout: Duration) -> Self {
192        self.tool_timeout = Some(timeout);
193        self
194    }
195
196    /// Set a maximum byte size for individual tool output content.
197    ///
198    /// Tool results exceeding this limit are truncated with a
199    /// `[truncated: N bytes omitted]` suffix, preventing oversized results
200    /// from blowing out the context window.
201    ///
202    /// Default: `None` (no truncation).
203    pub fn max_tool_output_bytes(mut self, max: usize) -> Self {
204        self.max_tool_output_bytes = Some(max);
205        self
206    }
207
208    /// Set a JSON Schema for structured output. The agent will receive a
209    /// synthetic `__respond__` tool with this schema. When the LLM calls
210    /// `__respond__`, its input is extracted as `AgentOutput::structured`.
211    ///
212    /// The agent can still use regular tools before producing output.
213    pub fn structured_schema(mut self, schema: serde_json::Value) -> Self {
214        self.structured_schema = Some(schema);
215        self
216    }
217
218    /// Set a callback for structured agent events. Events are emitted at key
219    /// points in the agent loop: run start/end, turn transitions, LLM responses,
220    /// tool call start/completion, approval decisions, and context summarization.
221    pub fn on_event(mut self, callback: Arc<OnEvent>) -> Self {
222        self.on_event = Some(callback);
223        self
224    }
225
226    /// Add a single guardrail. Multiple guardrails are evaluated in order;
227    /// first `Deny` wins.
228    pub fn guardrail(mut self, guardrail: Arc<dyn Guardrail>) -> Self {
229        self.guardrails.push(guardrail);
230        self
231    }
232
233    /// Add multiple guardrails at once.
234    pub fn guardrails(mut self, guardrails: Vec<Arc<dyn Guardrail>>) -> Self {
235        self.guardrails.extend(guardrails);
236        self
237    }
238
239    /// Set a callback for structured questions to the user. When set, a
240    /// `question` tool is added at `build()` time allowing the agent to
241    /// ask the user structured questions with predefined options.
242    pub fn on_question(mut self, callback: Arc<OnQuestion>) -> Self {
243        self.on_question = Some(callback);
244        self
245    }
246
247    /// Set a callback for interactive mode. When set and the LLM returns
248    /// text without tool calls, the callback is invoked to get the next
249    /// user message. Return `Some(message)` to continue the conversation
250    /// or `None` to end the session.
251    pub fn on_input(mut self, callback: Arc<OnInput>) -> Self {
252        self.on_input = Some(callback);
253        self
254    }
255
256    /// Set a wall-clock deadline for the entire run. If the agent does not
257    /// complete within this duration, `Error::RunTimeout` is returned.
258    ///
259    /// Default: `None` (no deadline).
260    pub fn run_timeout(mut self, timeout: Duration) -> Self {
261        self.run_timeout = Some(timeout);
262        self
263    }
264
265    /// Set the reasoning/thinking effort level. Enables extended thinking
266    /// on models that support it (e.g., Qwen3 via OpenRouter, Claude).
267    ///
268    /// Default: `None` (no reasoning).
269    pub fn reasoning_effort(mut self, effort: crate::llm::types::ReasoningEffort) -> Self {
270        self.reasoning_effort = Some(effort);
271        self
272    }
273
274    pub fn enable_reflection(mut self, enabled: bool) -> Self {
275        self.enable_reflection = enabled;
276        self
277    }
278
279    pub fn tool_output_compression_threshold(mut self, threshold: usize) -> Self {
280        self.tool_output_compression_threshold = Some(threshold);
281        self
282    }
283
284    pub fn max_tools_per_turn(mut self, max: usize) -> Self {
285        self.max_tools_per_turn = Some(max);
286        self
287    }
288
289    /// Set a static tool profile to pre-filter tools before dynamic selection.
290    ///
291    /// When set, tool definitions are filtered to the profile's subset before
292    /// `max_tools_per_turn` scoring applies. Use `ToolProfile::Conversational`
293    /// for chat-only agents, `Standard` for code agents, `Full` for all tools.
294    pub fn tool_profile(mut self, profile: tool_filter::ToolProfile) -> Self {
295        self.tool_profile = Some(profile);
296        self
297    }
298
299    /// Set the maximum number of consecutive identical tool-call turns before
300    /// the agent receives an error result instead of executing the tools.
301    ///
302    /// This detects "doom loops" where the LLM keeps repeating the exact same
303    /// tool calls. After `max` consecutive identical turns, all tool calls in
304    /// the turn receive an error result asking the LLM to try a different approach.
305    ///
306    /// Default: `None` (no detection).
307    pub fn max_identical_tool_calls(mut self, max: u32) -> Self {
308        self.max_identical_tool_calls = Some(max);
309        self
310    }
311
312    /// Set the maximum number of consecutive fuzzy-identical tool-call turns
313    /// before the agent receives an error result. Fuzzy matching compares sorted
314    /// tool names (ignoring inputs), catching loops where the agent retries the
315    /// same tools with different arguments.
316    ///
317    /// Default: `None` (no fuzzy detection).
318    pub fn max_fuzzy_identical_tool_calls(mut self, max: u32) -> Self {
319        self.max_fuzzy_identical_tool_calls = Some(max);
320        self
321    }
322
323    /// Cap the number of tool *invocations* the LLM may emit per turn.
324    /// When the LLM returns more tool_use blocks than `cap`, the run
325    /// returns `Error::Agent` (wrapped in `Error::WithPartialUsage`) and
326    /// no tools are dispatched.
327    ///
328    /// **Distinct from `max_tools_per_turn`**: that one limits the *tool
329    /// definitions* offered to the LLM before it responds (pre-filter).
330    /// This one caps the *invocations* in the LLM's actual response
331    /// (post-response). Both can be set independently.
332    ///
333    /// Default: `None` (unlimited). Recommended for production: 8.
334    /// Zero is rejected at build time.
335    pub fn max_tool_calls_per_turn(mut self, cap: u32) -> Self {
336        self.max_tool_calls_per_turn = Some(cap);
337        self
338    }
339
340    /// Set declarative permission rules for tool calls.
341    ///
342    /// Rules are evaluated per tool call before the `on_approval` callback.
343    /// `Allow` executes without asking, `Deny` returns an error result,
344    /// `Ask` falls through to the `on_approval` callback.
345    pub fn permission_rules(mut self, rules: permission::PermissionRuleset) -> Self {
346        self.permission_rules = rules;
347        self
348    }
349
350    /// Set learned permissions for persisting AlwaysAllow/AlwaysDeny decisions.
351    ///
352    /// When set, approval decisions with `AlwaysAllow` or `AlwaysDeny` are
353    /// saved to disk and injected into the live permission ruleset.
354    pub fn learned_permissions(
355        mut self,
356        learned: Arc<std::sync::Mutex<permission::LearnedPermissions>>,
357    ) -> Self {
358        self.learned_permissions = Some(learned);
359        self
360    }
361
362    /// Set an LSP manager for collecting diagnostics after file-modifying tools.
363    ///
364    /// When set, after any tool named `write`, `edit`, or `patch` completes,
365    /// the manager reads the modified file and collects diagnostics from the
366    /// language server. Diagnostics are appended to the tool result so the
367    /// LLM sees compilation errors immediately.
368    pub fn lsp_manager(mut self, manager: Arc<crate::lsp::LspManager>) -> Self {
369        self.lsp_manager = Some(manager);
370        self
371    }
372
373    /// Enable session pruning to reduce token usage by truncating old tool results.
374    pub fn session_prune_config(mut self, config: pruner::SessionPruneConfig) -> Self {
375        self.session_prune_config = Some(config);
376        self
377    }
378
379    /// Enable recursive (cluster-then-summarize) summarization for long conversations.
380    pub fn enable_recursive_summarization(mut self, enable: bool) -> Self {
381        self.enable_recursive_summarization = enable;
382        self
383    }
384
385    /// Set cumulative importance threshold for memory reflection triggers.
386    /// When the sum of stored memory importance values exceeds this threshold,
387    /// the store tool appends a reflection hint to guide the agent.
388    pub fn reflection_threshold(mut self, threshold: u32) -> Self {
389        self.reflection_threshold = Some(threshold);
390        self
391    }
392
393    /// Enable automatic memory consolidation at session end.
394    ///
395    /// When enabled, clusters related episodic memories by keyword overlap
396    /// and merges them into semantic summaries. Requires memory to be configured.
397    /// Adds LLM calls at session end (one per cluster).
398    pub fn consolidate_on_exit(mut self, enable: bool) -> Self {
399        self.consolidate_on_exit = enable;
400        self
401    }
402
403    /// Set the observability verbosity mode for this agent.
404    ///
405    /// Controls how much detail is recorded in tracing spans:
406    /// - `Production`: span names + durations only (near-zero overhead)
407    /// - `Analysis`: + metrics (tokens, latencies, costs)
408    /// - `Debug`: + full payloads (truncated to 4KB)
409    ///
410    /// When not set, resolved via `HEARTBIT_OBSERVABILITY` env var or default (`Production`).
411    pub fn observability_mode(mut self, mode: observability::ObservabilityMode) -> Self {
412        self.observability_mode = Some(mode);
413        self
414    }
415
416    /// Provide pre-loaded instruction text to prepend to the system prompt.
417    ///
418    /// Use [`instructions::load_instructions`] to load from file paths, or
419    /// [`instructions::discover_instruction_files`] to auto-discover them.
420    pub fn instruction_text(mut self, text: impl Into<String>) -> Self {
421        let text = text.into();
422        if !text.is_empty() {
423            self.instruction_text = Some(text);
424        }
425        self
426    }
427
428    /// Set a hard limit on cumulative tokens (input + output) across all turns.
429    ///
430    /// When the total tokens consumed exceed this limit, the agent returns
431    /// `Error::BudgetExceeded` with partial usage data.
432    ///
433    /// Default: `None` (no budget).
434    pub fn max_total_tokens(mut self, max: u64) -> Self {
435        self.max_total_tokens = Some(max);
436        self
437    }
438
439    /// Set the audit mode controlling what data is stored in audit records.
440    ///
441    /// - `Full` (default): all content is recorded.
442    /// - `MetadataOnly`: user content fields are replaced with `[stripped]`.
443    pub fn audit_mode(mut self, mode: audit::AuditMode) -> Self {
444        self.audit_mode = mode;
445        self
446    }
447
448    /// Attach an audit trail for recording untruncated agent decisions.
449    ///
450    /// When set, every LLM response, tool call, tool result, run completion,
451    /// run failure, and guardrail denial is recorded with full payloads.
452    /// Recording is best-effort: failures are logged, never abort the agent.
453    pub fn audit_trail(mut self, trail: Arc<dyn audit::AuditTrail>) -> Self {
454        self.audit_trail = Some(trail);
455        self
456    }
457
458    /// Set user context for multi-tenant audit enrichment.
459    /// When set, all `AuditRecord` entries include the user and tenant IDs.
460    pub fn audit_user_context(
461        mut self,
462        user_id: impl Into<String>,
463        tenant_id: impl Into<String>,
464    ) -> Self {
465        self.audit_user_id = Some(user_id.into());
466        self.audit_tenant_id = Some(tenant_id.into());
467        self
468    }
469
470    /// Set the delegation chain for audit records.
471    ///
472    /// Populated when the daemon acts on behalf of a user via RFC 8693 token exchange.
473    /// The chain records which agent(s) are in the delegation path.
474    pub fn audit_delegation_chain(mut self, chain: Vec<String>) -> Self {
475        self.audit_delegation_chain = chain;
476        self
477    }
478
479    /// Enable an LRU response cache with the given maximum number of entries.
480    /// Identical requests (same system prompt, messages, and tool names) return
481    /// cached responses without calling the LLM. Only non-streaming calls are cached.
482    /// Size must be at least 1.
483    pub fn response_cache_size(mut self, size: usize) -> Self {
484        self.response_cache_size = Some(size);
485        self
486    }
487
488    /// Set the agent's workspace directory. When set, file tools resolve
489    /// relative paths against this directory, BashTool starts here, and a
490    /// workspace hint is appended to the system prompt.
491    pub fn workspace(mut self, path: impl Into<std::path::PathBuf>) -> Self {
492        self.workspace = Some(path.into());
493        self
494    }
495
496    /// Optional per-tenant in-flight token tracker. When set, the runner
497    /// calls `tracker.adjust(&scope, delta)` after each LLM response,
498    /// reconciling the per-tenant `in_flight` counter against the
499    /// estimated reservation made at submit time. Has no effect when
500    /// `audit_tenant_id` is unset.
501    pub fn tenant_tracker(
502        mut self,
503        tracker: Arc<crate::agent::tenant_tracker::TenantTokenTracker>,
504    ) -> Self {
505        self.tenant_tracker = Some(tracker);
506        self
507    }
508
509    pub fn build(self) -> Result<AgentRunner<P>, Error> {
510        if self.name.is_empty() {
511            return Err(Error::Config("agent name must not be empty".into()));
512        }
513        if self.max_turns == 0 {
514            return Err(Error::Config("max_turns must be at least 1".into()));
515        }
516        if self.max_tokens == 0 {
517            return Err(Error::Config("max_tokens must be at least 1".into()));
518        }
519        if matches!(
520            self.context_strategy,
521            Some(ContextStrategy::SlidingWindow { .. })
522        ) && self.summarize_threshold.is_some()
523        {
524            return Err(Error::Config(
525                "cannot use summarize_threshold with SlidingWindow context strategy".into(),
526            ));
527        }
528        if self.on_input.is_some() && self.structured_schema.is_some() {
529            return Err(Error::Config(
530                "on_input (interactive mode) and structured_schema are mutually exclusive".into(),
531            ));
532        }
533        if self.max_tools_per_turn == Some(0) {
534            return Err(Error::Config(
535                "max_tools_per_turn must be at least 1".into(),
536            ));
537        }
538        if self.tool_output_compression_threshold == Some(0) {
539            return Err(Error::Config(
540                "tool_output_compression_threshold must be at least 1".into(),
541            ));
542        }
543        if self.max_identical_tool_calls == Some(0) {
544            return Err(Error::Config(
545                "max_identical_tool_calls must be at least 1".into(),
546            ));
547        }
548        if self.max_fuzzy_identical_tool_calls == Some(0) {
549            return Err(Error::Config(
550                "max_fuzzy_identical_tool_calls must be at least 1".into(),
551            ));
552        }
553        if self.max_tool_calls_per_turn == Some(0) {
554            return Err(Error::Config(
555                "max_tool_calls_per_turn must be > 0 if set".into(),
556            ));
557        }
558        if self.max_total_tokens == Some(0) {
559            return Err(Error::Config("max_total_tokens must be at least 1".into()));
560        }
561        if self.response_cache_size == Some(0) {
562            return Err(Error::Config(
563                "response_cache_size must be at least 1".into(),
564            ));
565        }
566
567        // Collect all tools, including memory and knowledge tools
568        let mut all_tools = self.tools;
569        let memory_scope = crate::auth::TenantScope::from_audit_fields(
570            self.audit_tenant_id.as_deref(),
571            self.audit_user_id.as_deref(),
572        );
573        let memory_ref = self.memory.clone();
574        if let Some(memory) = self.memory {
575            all_tools.extend(crate::memory::tools::memory_tools_with_reflection(
576                memory,
577                &self.name,
578                memory_scope,
579                self.reflection_threshold,
580            ));
581        }
582        if let Some(kb) = self.knowledge_base {
583            // SECURITY (F-KB-1): scope the KB tool to this runner's tenant.
584            let kb_scope = crate::auth::TenantScope::from_audit_fields(
585                self.audit_tenant_id.as_deref(),
586                self.audit_user_id.as_deref(),
587            );
588            all_tools.extend(crate::knowledge::tools::knowledge_tools(kb, kb_scope));
589        }
590        if let Some(on_question) = self.on_question {
591            all_tools.push(Arc::new(crate::tool::builtins::QuestionTool::new(
592                on_question,
593            )));
594        }
595
596        let mut tools: HashMap<String, Arc<dyn Tool>> = HashMap::with_capacity(all_tools.len());
597        let mut tool_defs: Vec<ToolDefinition> = Vec::with_capacity(all_tools.len());
598
599        for t in all_tools {
600            let def = t.definition();
601            if tools.contains_key(&def.name) {
602                // SECURITY (F-MCP-2): elevate the log level — a duplicate tool
603                // name is a potential MCP-shadowing attempt. The existing
604                // first-wins behavior is preserved (so trusted builtins added
605                // first take precedence) but the event is now auditable.
606                tracing::error!(
607                    tool = %def.name,
608                    "duplicate tool name (potential MCP-shadowing); keeping first registration"
609                );
610                continue;
611            }
612            tool_defs.push(def.clone());
613            tools.insert(def.name, t);
614        }
615
616        // Inject the synthetic __respond__ tool for structured output.
617        // Only the ToolDefinition is added — there's no Tool impl because
618        // the execute loop intercepts __respond__ calls before tool dispatch.
619        if let Some(ref schema) = self.structured_schema {
620            tool_defs.push(ToolDefinition {
621                name: crate::llm::types::RESPOND_TOOL_NAME.into(),
622                description: crate::llm::types::RESPOND_TOOL_DESCRIPTION.into(),
623                input_schema: schema.clone(),
624            });
625        }
626
627        // Prepend instruction text to the system prompt if provided.
628        let mut system_prompt = match self.instruction_text {
629            Some(ref text) => instructions::prepend_instructions(&self.system_prompt, text),
630            None => self.system_prompt,
631        };
632
633        // Append workspace hint to the system prompt if configured.
634        if let Some(ref ws) = self.workspace {
635            system_prompt.push_str(&format!(
636                "\n\nYour workspace directory is {}. You can freely create, organize, and manage \
637                 files there. Use it for notes, intermediate results, generated artifacts, and \
638                 anything you want to persist. Paths can be relative (resolved against workspace) \
639                 or absolute.",
640                ws.display()
641            ));
642        }
643
644        // Append resourcefulness guidelines only when the agent has power tools
645        // (bash, write, patch, edit) that make the guidance relevant. Saves ~180
646        // tokens for conversational-only agents.
647        let has_power_tools = tool_defs
648            .iter()
649            .any(|t| matches!(t.name.as_str(), "bash" | "write" | "patch" | "edit"));
650        if has_power_tools {
651            system_prompt.push_str(RESOURCEFULNESS_GUIDELINES);
652        }
653
654        // Inject current date/time so the model knows "today".
655        use chrono::Utc;
656        system_prompt.push_str(&format!(
657            "\n\nCurrent date and time: {} UTC",
658            Utc::now().format("%A, %B %-d, %Y %H:%M")
659        ));
660
661        Ok(AgentRunner {
662            provider: self.provider,
663            name: self.name,
664            system_prompt,
665            tools,
666            tool_defs,
667            max_turns: self.max_turns,
668            max_tokens: self.max_tokens,
669            context_strategy: self.context_strategy.unwrap_or(ContextStrategy::Unlimited),
670            summarize_threshold: self.summarize_threshold,
671            on_text: self.on_text,
672            on_approval: self.on_approval,
673            tool_timeout: self.tool_timeout,
674            max_tool_output_bytes: self.max_tool_output_bytes,
675            structured_schema: self.structured_schema,
676            on_event: self.on_event,
677            guardrails: self.guardrails,
678            on_input: self.on_input,
679            run_timeout: self.run_timeout,
680            reasoning_effort: self.reasoning_effort,
681            enable_reflection: self.enable_reflection,
682            tool_output_compression_threshold: self.tool_output_compression_threshold,
683            max_tools_per_turn: self.max_tools_per_turn,
684            tool_profile: self.tool_profile,
685            max_identical_tool_calls: self.max_identical_tool_calls,
686            max_fuzzy_identical_tool_calls: self.max_fuzzy_identical_tool_calls,
687            max_tool_calls_per_turn: self.max_tool_calls_per_turn,
688            permission_rules: parking_lot::RwLock::new(self.permission_rules),
689            learned_permissions: self.learned_permissions,
690            lsp_manager: self.lsp_manager,
691            session_prune_config: self.session_prune_config,
692            memory: memory_ref,
693            enable_recursive_summarization: self.enable_recursive_summarization,
694            consolidate_on_exit: self.consolidate_on_exit,
695            observability_mode: observability::ObservabilityMode::resolve(
696                observability::OBSERVABILITY_ENV_KEY,
697                None,
698                self.observability_mode,
699            ),
700            max_total_tokens: self.max_total_tokens,
701            audit_mode: self.audit_mode,
702            audit_trail: self.audit_trail,
703            audit_user_id: self.audit_user_id,
704            audit_tenant_id: self.audit_tenant_id,
705            audit_delegation_chain: self.audit_delegation_chain,
706            response_cache: self.response_cache_size.map(cache::ResponseCache::new),
707            tenant_tracker: self.tenant_tracker,
708            cumulative_actual_tokens: std::sync::atomic::AtomicUsize::new(0),
709        })
710    }
711}