Skip to main content

phi_core/config/
builder.rs

1//! Config → Agent construction.
2//!
3//! [`agent_from_config`] is the entry point: it takes a parsed [`AgentConfig`] and
4//! returns an `Arc<dyn Agent>` (a [`BasicAgent`] internally, wrapped in Arc).
5
6use super::reference::{parse_config_ref, ConfigRef};
7use super::schema::AgentConfig;
8use crate::agent_loop::script_callback::{is_script_path, ScriptCallback};
9use crate::agents::system_prompt::{CustomPromptStrategy, PromptBlockDef, SystemPrompt};
10use crate::agents::{Agent, AgentProfile, BasicAgent};
11use crate::context::{CompactionConfig, CompactionScope, ContextConfig, ExecutionLimits};
12use crate::provider::ModelConfig;
13use crate::tools::ToolRegistry;
14use crate::types::{AgentTool, CacheConfig, CacheStrategy, ThinkingLevel, ToolExecutionStrategy};
15use std::collections::HashMap;
16use std::path::PathBuf;
17use std::sync::Arc;
18
19/// Errors from config parsing and agent construction.
20#[derive(Debug)]
21pub enum ConfigError {
22    /// Config string could not be parsed.
23    Parse(String),
24    /// An environment variable referenced via `${VAR}` is not set.
25    MissingEnvVar { var: String },
26    /// A config field has an invalid value.
27    InvalidField {
28        field: String,
29        value: String,
30        expected: String,
31    },
32    /// I/O error reading a config file.
33    Io(std::io::Error),
34}
35
36impl std::fmt::Display for ConfigError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Self::Parse(msg) => write!(f, "Config parse error: {msg}"),
40            Self::MissingEnvVar { var } => write!(f, "Missing environment variable: ${{{var}}}"),
41            Self::InvalidField {
42                field,
43                value,
44                expected,
45            } => write!(
46                f,
47                "Invalid value for {field}: \"{value}\" (expected {expected})"
48            ),
49            Self::Io(e) => write!(f, "I/O error: {e}"),
50        }
51    }
52}
53
54impl std::error::Error for ConfigError {}
55
56/// Construct an agent from a parsed config.
57///
58/// Returns `Arc<dyn Agent>` — internally builds a [`BasicAgent`] (the reference
59/// implementation), wraps it in `Arc` for shared ownership across async tasks.
60///
61/// # Notes
62///
63/// - **Tools are not instantiated.** Config specifies tool names via `tools.enabled`;
64///   the caller must register tool instances via `.set_tools()` on the returned agent.
65///   Use [`agent_from_config_with_registry`] to resolve tools automatically (G10).
66/// - **Callbacks/hooks are Phase 2.** Config stores callback/hook strings but the
67///   builder ignores them in Phase 1. WASM plugin loading will activate them in Phase 2.
68pub fn agent_from_config(config: &AgentConfig) -> Result<Arc<dyn Agent>, ConfigError> {
69    let agent = build_basic_agent(config, None, None, None, None)?;
70    Ok(Arc::new(agent))
71}
72
73/// Construct an agent from config, resolving tool names via a [`ToolRegistry`] (G10).
74///
75/// Tools listed in `config.tools.enabled` are resolved through the registry.
76/// Unknown tool names are silently skipped. The rest of the construction pipeline
77/// is identical to [`agent_from_config`].
78pub fn agent_from_config_with_registry(
79    config: &AgentConfig,
80    registry: &ToolRegistry,
81) -> Result<Arc<dyn Agent>, ConfigError> {
82    let tools = registry.resolve(&config.tools.enabled);
83    let agent = build_basic_agent(config, None, None, Some(tools), None)?;
84    Ok(Arc::new(agent))
85}
86
87/// Construct multiple agents from a config with agent instances.
88///
89/// If `config.agent.instances` is empty, returns a single agent from
90/// [`agent_from_config`] with the name `"default"`.
91///
92/// Otherwise, builds one agent per instance, resolving `agent_profile` refs
93/// against `config.agent.profile.instances`. Each instance can override:
94/// - `agent_profile` — reference to a named profile instance
95/// - `system_prompt` — override the system prompt
96/// - `profile` — inline profile overrides
97#[allow(clippy::type_complexity)]
98pub fn agents_from_config(
99    config: &AgentConfig,
100) -> Result<Vec<(String, Arc<dyn Agent>)>, ConfigError> {
101    if config.agent.instances.is_empty() {
102        let agent = agent_from_config(config)?;
103        return Ok(vec![("default".to_string(), agent)]);
104    }
105
106    let mut agents = Vec::new();
107    for instance in &config.agent.instances {
108        let name = instance
109            .name
110            .clone()
111            .unwrap_or_else(|| "unnamed".to_string());
112
113        // Resolve profile: agent_profile ref -> find instance -> merge with base
114        let profile_override = if let Some(ref profile_ref) = instance.agent_profile {
115            let parsed = super::reference::parse_config_ref(profile_ref);
116            let ref_name = parsed.effective_name();
117            if let Some(inst) = find_profile_instance(config, ref_name) {
118                Some(resolve_profile_instance(&config.agent.profile, inst)?)
119            } else {
120                None
121            }
122        } else {
123            None
124        };
125
126        // Resolve provider instance from ref (if set)
127        let provider_inst = if let Some(ref provider_ref) = instance.provider {
128            let parsed = super::reference::parse_config_ref(provider_ref);
129            let ref_name = parsed.effective_name();
130            config.provider.instances.iter().find(|pi| {
131                let id_name = pi
132                    .id
133                    .as_deref()
134                    .map(|id| {
135                        super::reference::parse_config_ref(id)
136                            .effective_name()
137                            .to_string()
138                    })
139                    .unwrap_or_default();
140                let plain_name = pi.name.as_deref().unwrap_or("");
141                id_name == ref_name || plain_name == ref_name
142            })
143        } else {
144            None
145        };
146
147        // System prompt override from instance
148        let system_prompt_override = instance.system_prompt.clone();
149        let ws_override = instance.workspace.as_deref();
150
151        let agent = build_basic_agent(
152            config,
153            profile_override.as_ref(),
154            provider_inst,
155            None,
156            ws_override,
157        )?;
158
159        // Apply instance-level system prompt override after construction
160        let agent: Arc<dyn Agent> = if let Some(ref prompt) = system_prompt_override {
161            let mut a = build_basic_agent(
162                config,
163                profile_override.as_ref(),
164                provider_inst,
165                None,
166                ws_override,
167            )?;
168            a = a.with_system_prompt(prompt.clone());
169            Arc::new(a)
170        } else {
171            Arc::new(agent)
172        };
173
174        agents.push((name, agent));
175    }
176    Ok(agents)
177}
178
179/// Internal: build a `BasicAgent` from config with optional overrides.
180///
181/// - `profile_override`: when `Some`, replaces the profile built from `config.agent.profile`.
182/// - `provider_instance`: when `Some`, overrides model config fields from a provider instance.
183/// - `tools_override`: when `Some`, sets these tools on the agent.
184/// - `workspace_override`: when `Some`, overrides the workspace directory for this agent.
185fn build_basic_agent(
186    config: &AgentConfig,
187    profile_override: Option<&AgentProfile>,
188    provider_instance: Option<&super::schema::ProviderInstance>,
189    tools_override: Option<Vec<Arc<dyn AgentTool>>>,
190    workspace_override: Option<&str>,
191) -> Result<BasicAgent, ConfigError> {
192    // ── Build ModelConfig ────────────────────────────────────────────────
193    let model = config
194        .provider
195        .model
196        .as_deref()
197        .unwrap_or("unknown")
198        .to_string();
199    let api_key = config.provider.api_key.as_deref().unwrap_or("").to_string();
200    let provider_name = config
201        .provider
202        .provider
203        .as_deref()
204        .unwrap_or("anthropic")
205        .to_string();
206    let base_url = config
207        .provider
208        .base_url
209        .as_deref()
210        .unwrap_or("")
211        .to_string();
212    let display_name = config
213        .provider
214        .name
215        .as_deref()
216        .unwrap_or(&model)
217        .to_string();
218
219    let api_protocol = parse_api_protocol(
220        config
221            .provider
222            .api
223            .as_deref()
224            .unwrap_or("anthropic_messages"),
225    )?;
226
227    let mut model_config = ModelConfig {
228        id: model,
229        name: display_name,
230        api: api_protocol,
231        provider: provider_name,
232        base_url: if base_url.is_empty() {
233            default_base_url(api_protocol)
234        } else {
235            base_url
236        },
237        api_key,
238        reasoning: config.provider.reasoning.unwrap_or(false),
239        context_window: config.provider.context_window.unwrap_or(200_000),
240        max_tokens: config.provider.max_tokens.unwrap_or(8_192),
241        cost: build_cost_config(&config.provider.cost),
242        headers: config.provider.headers.clone(),
243        compat: build_compat_config(&config.provider.compat),
244        credentials: None,
245    };
246
247    // Apply provider instance overrides (from agent instance → provider ref)
248    if let Some(pi) = provider_instance {
249        if let Some(ref m) = pi.model {
250            model_config.id = m.clone();
251            model_config.name = m.clone();
252        }
253        if let Some(ref k) = pi.api_key {
254            model_config.api_key = k.clone();
255        }
256        if let Some(ref a) = pi.api {
257            model_config.api = parse_api_protocol(a)?;
258            // Re-derive base_url if the instance doesn't set one
259            if pi.base_url.is_none() {
260                model_config.base_url = default_base_url(model_config.api);
261            }
262        }
263        if let Some(ref u) = pi.base_url {
264            model_config.base_url = u.clone();
265        }
266        if let Some(ref p) = pi.provider {
267            model_config.provider = p.clone();
268        }
269    }
270
271    // ── Build AgentProfile ───────────────────────────────────────────────
272    let profile = match profile_override {
273        Some(p) => p.clone(),
274        None => build_profile(&config.agent.profile)?,
275    };
276
277    // ── Build the agent ──────────────────────────────────────────────────
278    let mut agent = BasicAgent::new(model_config);
279
280    // System prompt resolution chain (first non-empty wins):
281    //   1. agent-level override  2. profile instance override  3. base profile  4. empty
282    // Supports: inline text, file:path, or {{...}} reference (3-entity chain)
283    let raw_prompt = config
284        .agent
285        .system_prompt
286        .as_deref()
287        .or(profile_override.and_then(|p| p.system_prompt.as_deref()))
288        .or(config.agent.profile.system_prompt.as_deref())
289        .unwrap_or("");
290    let workspace_path = workspace_override
291        .or(config.agent.workspace.as_deref())
292        .or(config.default_workspace.as_deref())
293        .map(PathBuf::from)
294        .unwrap_or_else(|| PathBuf::from("."));
295    let system_prompt = resolve_system_prompt(raw_prompt, config, &workspace_path)?;
296    if !system_prompt.is_empty() {
297        agent = agent.with_system_prompt(system_prompt);
298    }
299
300    // Apply profile
301    agent = agent.with_profile(profile);
302
303    // Thinking level — use profile value (already set via with_profile), but
304    // agent-level config can further override
305    if let Some(ref level_str) = config.agent.profile.thinking_level {
306        let level = parse_thinking_level(level_str)?;
307        agent = agent.with_thinking(level);
308    }
309
310    // Temperature
311    if let Some(temp) = config.agent.profile.temperature {
312        agent = agent.with_temperature(temp);
313    }
314
315    // Max tokens
316    if let Some(max) = config.agent.profile.max_tokens {
317        agent = agent.with_max_tokens(max);
318    }
319
320    // Config ID
321    if let Some(ref id) = config.agent.profile.config_id {
322        agent = agent.with_config_id(id.clone());
323    }
324
325    // ── Context / Compaction (G5) ────────────────────────────────────────
326    // Resolve compaction instance from profile ref (if set)
327    let compaction_section = resolve_compaction_from_profile(config);
328    if compaction_section.max_context_tokens.is_some() {
329        let ctx_config = build_context_config(&compaction_section);
330        agent = agent.with_context_config(ctx_config);
331    }
332
333    // ── Execution limits ─────────────────────────────────────────────────
334    if has_execution_config(&config.execution) {
335        let limits = build_execution_limits(&config.execution);
336        agent = agent.with_execution_limits(limits);
337    }
338
339    // ── Retry config ─────────────────────────────────────────────────────
340    if has_retry_config(&config.execution.retry) {
341        let retry = build_retry_config(&config.execution.retry);
342        agent = agent.with_retry_config(retry);
343    }
344
345    // ── Cache config ─────────────────────────────────────────────────────
346    if config.execution.cache.enabled.is_some() || config.execution.cache.strategy.is_some() {
347        let cache = build_cache_config(&config.execution.cache);
348        agent = agent.with_cache_config(cache);
349    }
350
351    // ── Tool execution strategy ──────────────────────────────────────────
352    if let Some(ref strategy_str) = config.tools.tool_strategy {
353        let strategy = parse_tool_execution_strategy(strategy_str, config.tools.batch_size)?;
354        agent = agent.with_tool_execution(strategy);
355    }
356
357    // ── Tools (G10) ──────────────────────────────────────────────────────
358    if let Some(tools) = tools_override {
359        agent = agent.with_tools(tools);
360    }
361
362    // ── Workspace ────────────────────────────────────────────────────────
363    // Resolution: instance workspace > agent-level > default_workspace > None
364    let workspace = workspace_override
365        .or(config.agent.workspace.as_deref())
366        .or(config.default_workspace.as_deref());
367    if let Some(ws) = workspace {
368        agent = agent.with_workspace(ws);
369    }
370
371    // ── Script-based callbacks ───────────────────────────────────────────
372    // When config callback fields contain script paths (*.sh, *.py, or contain '/'),
373    // wrap them as ScriptCallback closures. Non-script strings (e.g., "module::func")
374    // are Phase 2 WASM references and are ignored.
375    let cb_workspace = workspace.map(PathBuf::from);
376    wire_script_callbacks(&mut agent, &config.callbacks, cb_workspace);
377
378    Ok(agent)
379}
380
381// ── Helper functions ────────────────────────────────────────────────────────
382
383fn build_profile(section: &super::schema::ProfileSection) -> Result<AgentProfile, ConfigError> {
384    let thinking_level = section
385        .thinking_level
386        .as_deref()
387        .map(parse_thinking_level)
388        .transpose()?;
389
390    Ok(AgentProfile {
391        profile_id: section
392            .profile_id
393            .clone()
394            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
395        name: section.name.clone(),
396        description: section.description.clone(),
397        system_prompt: section.system_prompt.clone(),
398        thinking_level,
399        temperature: section.temperature,
400        max_tokens: section.max_tokens,
401        config_id: section.config_id.clone(),
402        skills: section.skills.clone(),
403        workspace: None,
404    })
405}
406
407/// Build a profile by resolving a `{{...}}` reference against the config's
408/// profile instances, then merging: profile defaults ← instance overrides.
409fn resolve_profile_instance(
410    base: &super::schema::ProfileSection,
411    instance: &super::schema::ProfileInstanceSection,
412) -> Result<AgentProfile, ConfigError> {
413    // Instance fields override base profile defaults (Option::or pattern)
414    let thinking_str = instance
415        .thinking_level
416        .as_deref()
417        .or(base.thinking_level.as_deref());
418    let thinking_level = thinking_str.map(parse_thinking_level).transpose()?;
419
420    Ok(AgentProfile {
421        profile_id: base
422            .profile_id
423            .clone()
424            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
425        name: instance.name.clone().or_else(|| base.name.clone()),
426        description: instance
427            .description
428            .clone()
429            .or_else(|| base.description.clone()),
430        system_prompt: instance
431            .system_prompt
432            .clone()
433            .or_else(|| base.system_prompt.clone()),
434        thinking_level,
435        temperature: instance.temperature.or(base.temperature),
436        max_tokens: instance.max_tokens.or(base.max_tokens),
437        config_id: instance
438            .config_id
439            .clone()
440            .or_else(|| base.config_id.clone()),
441        skills: if instance.skills.is_empty() {
442            base.skills.clone()
443        } else {
444            instance.skills.clone()
445        },
446        workspace: None,
447    })
448}
449
450/// Look up a profile instance by reference name within the config.
451///
452/// Searches `[[agent.profile.instances]]` for an instance whose `id` field
453/// matches the given name (after stripping `{{...}}` syntax from both sides).
454fn find_profile_instance<'a>(
455    config: &'a AgentConfig,
456    ref_name: &str,
457) -> Option<&'a super::schema::ProfileInstanceSection> {
458    config.agent.profile.instances.iter().find(|inst| {
459        let inst_ref = super::reference::parse_config_ref(&inst.id);
460        inst_ref.effective_name() == ref_name
461    })
462}
463
464fn resolve_compaction_from_profile(config: &AgentConfig) -> super::schema::CompactionSection {
465    if let Some(ref comp_ref) = config.agent.profile.compaction {
466        let parsed = super::reference::parse_config_ref(comp_ref);
467        let ref_name = parsed.effective_name();
468        if let Some(inst) = config
469            .compaction
470            .instances
471            .iter()
472            .find(|i| super::reference::parse_config_ref(&i.id).effective_name() == ref_name)
473        {
474            return merge_compaction_instance(&config.compaction, inst);
475        }
476    }
477    config.compaction.clone()
478}
479
480fn merge_compaction_instance(
481    base: &super::schema::CompactionSection,
482    inst: &super::schema::CompactionInstanceSection,
483) -> super::schema::CompactionSection {
484    super::schema::CompactionSection {
485        max_context_tokens: inst.max_context_tokens.or(base.max_context_tokens),
486        system_prompt_tokens: inst.system_prompt_tokens.or(base.system_prompt_tokens),
487        compact_at_pct: inst.compact_at_pct.or(base.compact_at_pct),
488        compact_budget_threshold_pct: inst
489            .compact_budget_threshold_pct
490            .or(base.compact_budget_threshold_pct),
491        keep_first_turns: inst.keep_first_turns.or(base.keep_first_turns),
492        keep_recent_turns: inst.keep_recent_turns.or(base.keep_recent_turns),
493        max_summary_tokens: inst.max_summary_tokens.or(base.max_summary_tokens),
494        tool_output_max_lines: inst.tool_output_max_lines.or(base.tool_output_max_lines),
495        focus_message: inst
496            .focus_message
497            .clone()
498            .or_else(|| base.focus_message.clone()),
499        instances: base.instances.clone(),
500    }
501}
502
503fn parse_thinking_level(s: &str) -> Result<ThinkingLevel, ConfigError> {
504    match s.to_lowercase().as_str() {
505        "off" => Ok(ThinkingLevel::Off),
506        "minimal" => Ok(ThinkingLevel::Minimal),
507        "low" => Ok(ThinkingLevel::Low),
508        "medium" => Ok(ThinkingLevel::Medium),
509        "high" => Ok(ThinkingLevel::High),
510        _ => Err(ConfigError::InvalidField {
511            field: "thinking_level".to_string(),
512            value: s.to_string(),
513            expected: "off, minimal, low, medium, high".to_string(),
514        }),
515    }
516}
517
518fn parse_api_protocol(s: &str) -> Result<crate::provider::model::ApiProtocol, ConfigError> {
519    use crate::provider::model::ApiProtocol;
520    match s.to_lowercase().replace('-', "_").as_str() {
521        "anthropic_messages" | "anthropic" => Ok(ApiProtocol::AnthropicMessages),
522        "openai_completions" | "openai" => Ok(ApiProtocol::OpenAiCompletions),
523        "openai_responses" => Ok(ApiProtocol::OpenAiResponses),
524        "azure_openai_responses" | "azure" => Ok(ApiProtocol::AzureOpenAiResponses),
525        "google_generative_ai" | "google" | "gemini" => Ok(ApiProtocol::GoogleGenerativeAi),
526        "google_vertex" | "vertex" => Ok(ApiProtocol::GoogleVertex),
527        "bedrock_converse_stream" | "bedrock" => Ok(ApiProtocol::BedrockConverseStream),
528        _ => Err(ConfigError::InvalidField {
529            field: "provider.api".to_string(),
530            value: s.to_string(),
531            expected: "anthropic_messages, openai_completions, openai_responses, \
532                       azure_openai_responses, google_generative_ai, google_vertex, \
533                       bedrock_converse_stream"
534                .to_string(),
535        }),
536    }
537}
538
539fn default_base_url(api: crate::provider::model::ApiProtocol) -> String {
540    use crate::provider::model::ApiProtocol;
541    match api {
542        ApiProtocol::AnthropicMessages => "https://api.anthropic.com".to_string(),
543        ApiProtocol::OpenAiCompletions | ApiProtocol::OpenAiResponses => {
544            "https://api.openai.com".to_string()
545        }
546        ApiProtocol::GoogleGenerativeAi => "https://generativelanguage.googleapis.com".to_string(),
547        _ => String::new(),
548    }
549}
550
551fn build_cost_config(section: &super::schema::CostSection) -> crate::provider::model::CostConfig {
552    crate::provider::model::CostConfig {
553        input_per_million: section.input_per_million.unwrap_or(0.0),
554        output_per_million: section.output_per_million.unwrap_or(0.0),
555        cache_read_per_million: section.cache_read_per_million.unwrap_or(0.0),
556        cache_write_per_million: section.cache_write_per_million.unwrap_or(0.0),
557    }
558}
559
560fn build_context_config(section: &super::schema::CompactionSection) -> ContextConfig {
561    let defaults = ContextConfig::default();
562    let comp_defaults = CompactionConfig::default();
563
564    ContextConfig {
565        max_context_tokens: section
566            .max_context_tokens
567            .unwrap_or(defaults.max_context_tokens),
568        system_prompt_tokens: section
569            .system_prompt_tokens
570            .unwrap_or(defaults.system_prompt_tokens),
571        compaction: CompactionConfig {
572            compact_at_pct: section
573                .compact_at_pct
574                .unwrap_or(comp_defaults.compact_at_pct),
575            compact_budget_threshold_pct: section
576                .compact_budget_threshold_pct
577                .unwrap_or(comp_defaults.compact_budget_threshold_pct),
578            compaction_scope: CompactionScope::default(),
579            keep_first_turns: section
580                .keep_first_turns
581                .unwrap_or(comp_defaults.keep_first_turns),
582            keep_recent_turns: section
583                .keep_recent_turns
584                .unwrap_or(comp_defaults.keep_recent_turns),
585            max_summary_tokens: section
586                .max_summary_tokens
587                .unwrap_or(comp_defaults.max_summary_tokens),
588            tool_output_max_lines: section
589                .tool_output_max_lines
590                .unwrap_or(comp_defaults.tool_output_max_lines),
591            focus_message: section.focus_message.clone(),
592            in_memory_strategy: None,
593            block_strategy: None,
594        },
595        token_counter: None,
596        keep_recent: defaults.keep_recent,
597        keep_first: defaults.keep_first,
598        tool_output_max_lines: defaults.tool_output_max_lines,
599    }
600}
601
602fn has_execution_config(section: &super::schema::ExecutionSection) -> bool {
603    section.max_turns.is_some()
604        || section.max_total_tokens.is_some()
605        || section.max_duration_secs.is_some()
606        || section.max_cost.is_some()
607}
608
609fn build_execution_limits(section: &super::schema::ExecutionSection) -> ExecutionLimits {
610    let defaults = ExecutionLimits::default();
611    ExecutionLimits {
612        max_turns: section.max_turns.unwrap_or(defaults.max_turns),
613        max_total_tokens: section
614            .max_total_tokens
615            .unwrap_or(defaults.max_total_tokens),
616        max_duration: std::time::Duration::from_secs(
617            section
618                .max_duration_secs
619                .unwrap_or(defaults.max_duration.as_secs()),
620        ),
621        max_cost: section.max_cost.or(defaults.max_cost),
622    }
623}
624
625fn has_retry_config(section: &super::schema::RetrySection) -> bool {
626    section.max_retries.is_some()
627        || section.initial_delay_ms.is_some()
628        || section.backoff_multiplier.is_some()
629        || section.max_delay_ms.is_some()
630}
631
632fn build_retry_config(
633    section: &super::schema::RetrySection,
634) -> crate::provider::retry::RetryConfig {
635    let defaults = crate::provider::retry::RetryConfig::default();
636    crate::provider::retry::RetryConfig {
637        max_retries: section.max_retries.unwrap_or(defaults.max_retries),
638        initial_delay_ms: section
639            .initial_delay_ms
640            .unwrap_or(defaults.initial_delay_ms),
641        backoff_multiplier: section
642            .backoff_multiplier
643            .unwrap_or(defaults.backoff_multiplier),
644        max_delay_ms: section.max_delay_ms.unwrap_or(defaults.max_delay_ms),
645    }
646}
647
648fn build_cache_config(section: &super::schema::CacheSection) -> CacheConfig {
649    let enabled = section.enabled.unwrap_or(true);
650    let strategy = match section.strategy.as_deref() {
651        Some("disabled") => CacheStrategy::Disabled,
652        Some("auto") | None => CacheStrategy::Auto,
653        _ => CacheStrategy::Auto, // unknown strategies default to auto
654    };
655    CacheConfig { enabled, strategy }
656}
657
658fn build_compat_config(
659    section: &super::schema::CompatSection,
660) -> Option<crate::provider::model::OpenAiCompat> {
661    use crate::provider::model::{MaxTokensField, OpenAiCompat, ThinkingFormat};
662
663    // Return None if all fields are empty (non-OpenAI provider)
664    if section.auth_style.is_none()
665        && section.reasoning_format.is_none()
666        && section.max_tokens_field.is_none()
667        && section.supports_streaming.is_none()
668        && section.supports_system_message.is_none()
669    {
670        return None;
671    }
672
673    let mut compat = OpenAiCompat::default();
674
675    if let Some(ref fmt) = section.reasoning_format {
676        compat.thinking_format = match fmt.to_lowercase().as_str() {
677            "xai" => ThinkingFormat::Xai,
678            "qwen" => ThinkingFormat::Qwen,
679            "openrouter" => ThinkingFormat::OpenRouter,
680            _ => ThinkingFormat::OpenAi,
681        };
682    }
683
684    if let Some(ref field) = section.max_tokens_field {
685        compat.max_tokens_field = match field.to_lowercase().as_str() {
686            "max_completion_tokens" => MaxTokensField::MaxCompletionTokens,
687            _ => MaxTokensField::MaxTokens,
688        };
689    }
690
691    if let Some(streaming) = section.supports_streaming {
692        compat.supports_usage_in_streaming = streaming;
693    }
694
695    if let Some(developer) = section.supports_system_message {
696        compat.supports_developer_role = developer;
697    }
698
699    Some(compat)
700}
701
702fn parse_tool_execution_strategy(
703    s: &str,
704    batch_size: Option<usize>,
705) -> Result<ToolExecutionStrategy, ConfigError> {
706    match s.to_lowercase().as_str() {
707        "sequential" => Ok(ToolExecutionStrategy::Sequential),
708        "parallel" => Ok(ToolExecutionStrategy::Parallel),
709        "batched" => Ok(ToolExecutionStrategy::Batched {
710            size: batch_size.unwrap_or(3),
711        }),
712        _ => Err(ConfigError::InvalidField {
713            field: "tools.tool_strategy".to_string(),
714            value: s.to_string(),
715            expected: "sequential, parallel, batched".to_string(),
716        }),
717    }
718}
719
720// ── System prompt resolution ────────────────────────────────────────────
721
722/// Resolve system prompt: if raw text is a `{{...}}` reference, resolve through
723/// the 3-entity chain (SystemPromptStrategy → SystemPrompt → compose()).
724/// If it's a literal string, return as-is.
725fn resolve_system_prompt(
726    raw: &str,
727    config: &AgentConfig,
728    workspace: &std::path::Path,
729) -> Result<String, ConfigError> {
730    if raw.is_empty() {
731        return Ok(String::new());
732    }
733
734    // file: prefix — read prompt from disk. Relative paths resolve from workspace.
735    if let Some(path_str) = raw.strip_prefix("file:") {
736        let path = std::path::Path::new(path_str);
737        let full = if path.is_absolute() {
738            path.to_path_buf()
739        } else {
740            workspace.join(path)
741        };
742        return std::fs::read_to_string(&full).map_err(ConfigError::Io);
743    }
744
745    let config_ref = parse_config_ref(raw);
746    match config_ref {
747        ConfigRef::Literal(_) => Ok(raw.to_string()),
748        ref r if r.is_reference() => {
749            let prompt_name = r.effective_name();
750
751            // Find the SystemPrompt instance
752            let prompt_inst = config
753                .system_prompt
754                .instances
755                .iter()
756                .find(|p| parse_config_ref(&p.id).effective_name() == prompt_name)
757                .ok_or_else(|| ConfigError::InvalidField {
758                    field: "agent.system_prompt".into(),
759                    value: raw.into(),
760                    expected: format!(
761                        "a system_prompt instance named '{prompt_name}' in [[system_prompt.instances]]"
762                    ),
763                })?;
764
765            // Find the referenced strategy
766            let strategy_ref_raw = prompt_inst.strategy_type.as_deref().unwrap_or("");
767            let strategy_name = parse_config_ref(strategy_ref_raw)
768                .effective_name()
769                .to_string();
770
771            let strategy_inst = config
772                .system_prompt_strategy
773                .instances
774                .iter()
775                .find(|s| parse_config_ref(&s.id).effective_name() == strategy_name)
776                .ok_or_else(|| ConfigError::InvalidField {
777                    field: "system_prompt.type".into(),
778                    value: strategy_ref_raw.into(),
779                    expected: format!(
780                        "a strategy named '{strategy_name}' in [[system_prompt_strategy.instances]]"
781                    ),
782                })?;
783
784            // Build the strategy
785            let block_defs: Vec<PromptBlockDef> = strategy_inst
786                .blocks
787                .iter()
788                .map(|b| PromptBlockDef {
789                    name: b.name.clone(),
790                    order: b.order.unwrap_or(0),
791                    max_length: b.max_length.unwrap_or(usize::MAX),
792                })
793                .collect();
794            let strategy = CustomPromptStrategy { blocks: block_defs };
795
796            // Build the SystemPrompt with block content
797            let mut blocks = HashMap::new();
798            for (key, value) in &prompt_inst.blocks {
799                // Skip known metadata fields captured by serde(flatten)
800                if key == "id" || key == "description" || key == "type" {
801                    continue;
802                }
803                if let Some(text) = value.as_str() {
804                    blocks.insert(key.clone(), text.to_string());
805                }
806            }
807
808            let prompt = SystemPrompt {
809                id: prompt_inst.id.clone(),
810                description: prompt_inst.description.clone(),
811                strategy_ref: strategy_ref_raw.to_string(),
812                blocks,
813            };
814
815            prompt
816                .compose(&strategy, workspace)
817                .map_err(ConfigError::Io)
818        }
819        _ => Ok(raw.to_string()),
820    }
821}
822
823// ── Script callback wiring ──────────────────────────────────────────────
824
825/// Wire script-based callbacks from config into the agent via trait setters.
826/// Script paths (*.sh, *.py, or containing '/') are wrapped as ScriptCallback closures.
827/// Non-script strings are Phase 2 WASM references and are ignored.
828fn wire_script_callbacks(
829    agent: &mut dyn Agent,
830    callbacks: &super::schema::CallbacksSection,
831    workspace: Option<PathBuf>,
832) {
833    if let Some(ref path) = callbacks.before_loop {
834        if is_script_path(path) {
835            let script = ScriptCallback::new(path, workspace.clone());
836            agent.set_before_loop(Some(Arc::new(move |msgs, n| {
837                let input = serde_json::json!({
838                    "hook": "before_loop",
839                    "message_count": msgs.len(),
840                    "loop_index": n,
841                });
842                script
843                    .execute_sync(&input)
844                    .ok()
845                    .and_then(|v| v.get("allow").and_then(|a| a.as_bool()))
846                    .unwrap_or(true)
847            })));
848        }
849    }
850
851    if let Some(ref path) = callbacks.after_loop {
852        if is_script_path(path) {
853            let script = ScriptCallback::new(path, workspace.clone());
854            agent.set_after_loop(Some(Arc::new(move |_msgs, _usage| {
855                let input = serde_json::json!({"hook": "after_loop"});
856                let _ = script.execute_sync(&input);
857            })));
858        }
859    }
860
861    if let Some(ref path) = callbacks.before_turn {
862        if is_script_path(path) {
863            let script = ScriptCallback::new(path, workspace.clone());
864            agent.set_before_turn(Some(Arc::new(move |msgs, turn| {
865                let input = serde_json::json!({
866                    "hook": "before_turn",
867                    "message_count": msgs.len(),
868                    "turn_index": turn,
869                });
870                script
871                    .execute_sync(&input)
872                    .ok()
873                    .and_then(|v| v.get("allow").and_then(|a| a.as_bool()))
874                    .unwrap_or(true)
875            })));
876        }
877    }
878
879    if let Some(ref path) = callbacks.after_turn {
880        if is_script_path(path) {
881            let script = ScriptCallback::new(path, workspace.clone());
882            agent.set_after_turn(Some(Arc::new(move |_msgs, _usage| {
883                let input = serde_json::json!({"hook": "after_turn"});
884                let _ = script.execute_sync(&input);
885            })));
886        }
887    }
888
889    if let Some(ref path) = callbacks.before_tool_execution {
890        if is_script_path(path) {
891            let script = ScriptCallback::new(path, workspace.clone());
892            agent.set_before_tool_execution(Some(Arc::new(move |name, id, _args| {
893                let input = serde_json::json!({
894                    "hook": "before_tool_execution",
895                    "tool_name": name,
896                    "tool_call_id": id,
897                });
898                script
899                    .execute_sync(&input)
900                    .ok()
901                    .and_then(|v| v.get("allow").and_then(|a| a.as_bool()))
902                    .unwrap_or(true)
903            })));
904        }
905    }
906
907    if let Some(ref path) = callbacks.after_tool_execution {
908        if is_script_path(path) {
909            let script = ScriptCallback::new(path, workspace.clone());
910            agent.set_after_tool_execution(Some(Arc::new(move |name, id, is_error| {
911                let input = serde_json::json!({
912                    "hook": "after_tool_execution",
913                    "tool_name": name,
914                    "tool_call_id": id,
915                    "is_error": is_error,
916                });
917                let _ = script.execute_sync(&input);
918            })));
919        }
920    }
921
922    if let Some(ref path) = callbacks.before_compaction_start {
923        if is_script_path(path) {
924            let script = ScriptCallback::new(path, workspace.clone());
925            agent.set_before_compaction_start(Some(Arc::new(move |tokens, count| {
926                let input = serde_json::json!({
927                    "hook": "before_compaction_start",
928                    "estimated_tokens": tokens,
929                    "message_count": count,
930                });
931                script
932                    .execute_sync(&input)
933                    .ok()
934                    .and_then(|v| v.get("allow").and_then(|a| a.as_bool()))
935                    .unwrap_or(true)
936            })));
937        }
938    }
939
940    if let Some(ref path) = callbacks.after_compaction_end {
941        if is_script_path(path) {
942            let script = ScriptCallback::new(path, workspace);
943            agent.set_after_compaction_end(Some(Arc::new(
944                move |before, after, tok_before, tok_after| {
945                    let input = serde_json::json!({
946                        "hook": "after_compaction_end",
947                        "messages_before": before,
948                        "messages_after": after,
949                        "tokens_before": tok_before,
950                        "tokens_after": tok_after,
951                    });
952                    let _ = script.execute_sync(&input);
953                },
954            )));
955        }
956    }
957}