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}