1use 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#[derive(Debug)]
21pub enum ConfigError {
22 Parse(String),
24 MissingEnvVar { var: String },
26 InvalidField {
28 field: String,
29 value: String,
30 expected: String,
31 },
32 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
56pub 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
73pub 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#[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 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 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 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 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
179fn 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 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 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 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 let profile = match profile_override {
273 Some(p) => p.clone(),
274 None => build_profile(&config.agent.profile)?,
275 };
276
277 let mut agent = BasicAgent::new(model_config);
279
280 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 agent = agent.with_profile(profile);
302
303 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 if let Some(temp) = config.agent.profile.temperature {
312 agent = agent.with_temperature(temp);
313 }
314
315 if let Some(max) = config.agent.profile.max_tokens {
317 agent = agent.with_max_tokens(max);
318 }
319
320 if let Some(ref id) = config.agent.profile.config_id {
322 agent = agent.with_config_id(id.clone());
323 }
324
325 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 if has_execution_config(&config.execution) {
335 let limits = build_execution_limits(&config.execution);
336 agent = agent.with_execution_limits(limits);
337 }
338
339 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 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 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 if let Some(tools) = tools_override {
359 agent = agent.with_tools(tools);
360 }
361
362 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 let cb_workspace = workspace.map(PathBuf::from);
376 wire_script_callbacks(&mut agent, &config.callbacks, cb_workspace);
377
378 Ok(agent)
379}
380
381fn 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
407fn resolve_profile_instance(
410 base: &super::schema::ProfileSection,
411 instance: &super::schema::ProfileInstanceSection,
412) -> Result<AgentProfile, ConfigError> {
413 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
450fn 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, };
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 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
720fn 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 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 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 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 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 let mut blocks = HashMap::new();
798 for (key, value) in &prompt_inst.blocks {
799 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
823fn 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}