1use crate::error::ValidationError;
9use crate::layout::ContextLayout;
10use crate::lifecycle::CompactionConfig;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Blueprint {
21 pub name: String,
23
24 pub description: String,
26
27 pub stages: Vec<Stage>,
29
30 pub context_layout: ContextLayout,
32
33 pub transforms: Vec<ContextTransform>,
35
36 pub version: String,
38
39 pub compaction_config: Option<CompactionConfig>,
41
42 pub max_child_depth: Option<usize>,
44
45 pub entry_stage: Option<String>,
47
48 pub metadata: HashMap<String, serde_json::Value>,
50
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub security: Option<crate::taint::SecurityConfig>,
54
55 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub batch_tool_hint: Option<bool>,
60
61 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub shell_hint: Option<bool>,
66
67 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub nudge: Option<NudgeConfig>,
72
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub repetition_detection: Option<RepetitionDetectionConfig>,
76
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub file_tracking: Option<FileTrackingConfig>,
80
81 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
86
87 #[serde(default)]
92 pub dynamic_tools: bool,
93
94 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub read_paths: Option<ReadPathsConfig>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ReadPathsConfig {
110 #[serde(default)]
119 pub allow: Vec<String>,
120}
121
122impl Blueprint {
123 pub fn new(
125 name: String,
126 description: String,
127 stages: Vec<Stage>,
128 context_layout: ContextLayout,
129 ) -> Self {
130 Self {
131 name,
132 description,
133 stages,
134 context_layout,
135 transforms: Vec::new(),
136 version: "0.1.0".to_string(),
137 compaction_config: None,
138 max_child_depth: None,
139 entry_stage: None,
140 metadata: HashMap::new(),
141 security: None,
142 batch_tool_hint: None,
143 shell_hint: None,
144 nudge: None,
145 repetition_detection: None,
146 file_tracking: None,
147 sandbox: None,
148 dynamic_tools: false,
149 read_paths: None,
150 }
151 }
152
153 pub fn agent_tool_permissions(&self) -> HashMap<String, String> {
160 self.metadata
161 .iter()
162 .filter_map(|(k, v)| {
163 Some((
164 k.strip_prefix("tool_perm:")?.to_string(),
165 v.as_str()?.to_string(),
166 ))
167 })
168 .collect()
169 }
170
171 pub fn with_transforms(mut self, transforms: Vec<ContextTransform>) -> Self {
173 self.transforms = transforms;
174 self
175 }
176
177 pub fn with_version(mut self, version: String) -> Self {
179 self.version = version;
180 self
181 }
182
183 pub fn validate(&self) -> std::result::Result<(), ValidationError> {
185 self.context_layout.validate()?;
187
188 for stage in &self.stages {
190 stage.validate()?;
191 }
192
193 for transform in &self.transforms {
195 transform.validate(&self.context_layout)?;
196 }
197
198 self.validate_graph()?;
200
201 Ok(())
202 }
203
204 fn validate_graph(&self) -> std::result::Result<(), ValidationError> {
206 let stage_names: std::collections::HashSet<&str> =
207 self.stages.iter().map(|s| s.name.as_str()).collect();
208
209 if let Some(entry) = &self.entry_stage
211 && !stage_names.contains(entry.as_str())
212 {
213 return Err(ValidationError::Graph(format!(
214 "entry_stage '{}' does not match any defined stage",
215 entry
216 )));
217 }
218
219 for stage in &self.stages {
225 if let StageMode::FanOut { config } = &stage.mode {
226 let sources = [
227 config.worker_agent.is_some(),
228 config.worker_stage.is_some(),
229 config.worker_query.is_some(),
230 ]
231 .iter()
232 .filter(|&&set| set)
233 .count();
234 if sources != 1 {
235 return Err(ValidationError::Stage {
236 stage: stage.name.clone(),
237 message: "fan_out stage must set exactly one of worker_agent, \
238 worker_stage, or worker_query"
239 .to_string(),
240 });
241 }
242 if let Some(ws) = &config.worker_stage {
243 match self.stages.iter().find(|s| &s.name == ws) {
244 None => {
245 return Err(ValidationError::Stage {
246 stage: stage.name.clone(),
247 message: format!("fan_out worker_stage '{}' does not exist", ws),
248 });
249 }
250 Some(target) if !target.allow_as_worker => {
251 return Err(ValidationError::Stage {
252 stage: stage.name.clone(),
253 message: format!(
254 "fan_out worker_stage '{}' must set allow_as_worker = true",
255 ws
256 ),
257 });
258 }
259 Some(_) => {}
260 }
261 }
262 if let Some(ms) = &config.merge_stage
263 && !stage_names.contains(ms.as_str())
264 {
265 return Err(ValidationError::Stage {
266 stage: stage.name.clone(),
267 message: format!("fan_out merge_stage '{}' does not exist", ms),
268 });
269 }
270 }
271 }
272
273 let has_any_transitions = self.stages.iter().any(|s| s.transitions.is_some());
274 if !has_any_transitions {
275 return Ok(());
277 }
278
279 for stage in &self.stages {
281 if let Some(ref transitions) = stage.transitions {
282 for (target_name, edge) in transitions {
283 if !stage_names.contains(target_name.as_str()) {
284 return Err(ValidationError::Transition {
285 from: stage.name.clone(),
286 to: target_name.clone(),
287 message: "target stage does not exist".to_string(),
288 });
289 }
290 if edge.condition == TransitionCondition::Stuck
294 && !edge.stuck.is_some_and(|c| c.is_armed())
295 {
296 return Err(ValidationError::Transition {
297 from: stage.name.clone(),
298 to: target_name.clone(),
299 message: "condition = \"stuck\" requires at least one \
300 stuck_after_* threshold (the edge could never fire)"
301 .to_string(),
302 });
303 }
304 }
305
306 for (target_name, edge) in transitions {
310 let Some(gate) = &edge.gate else { continue };
311 if !gate.require_modifications {
312 continue;
313 }
314 let can_modify = stage.available_tools.iter().any(|t| {
315 MODIFYING_TOOLS.contains(&t.as_str())
316 || gate.tools.iter().any(|extra| extra == t)
317 });
318 if !can_modify {
319 return Err(ValidationError::Transition {
320 from: stage.name.clone(),
321 to: target_name.clone(),
322 message: "gate requires modifications, but the stage has no \
323 file-modifying tool in available_tools"
324 .to_string(),
325 });
326 }
327 }
328
329 if transitions.contains_key(&stage.name) && stage.max_revisits.is_none() {
331 return Err(ValidationError::Stage {
332 stage: stage.name.clone(),
333 message: "self-loop transition requires max_revisits".to_string(),
334 });
335 }
336 }
337 }
338
339 let entry = self.resolve_entry_stage_name();
342 let has_terminal = self.has_terminal_path(&entry, &mut std::collections::HashSet::new());
343 if !has_terminal {
344 return Err(ValidationError::Graph(
345 "no terminal path exists from entry stage - agent would never complete".to_string(),
346 ));
347 }
348
349 Ok(())
350 }
351
352 pub fn resolve_entry_stage_name(&self) -> String {
354 self.entry_stage.clone().unwrap_or_else(|| {
355 self.stages
356 .first()
357 .map(|s| s.name.clone())
358 .unwrap_or_default()
359 })
360 }
361
362 fn has_terminal_path(
364 &self,
365 stage_name: &str,
366 visited: &mut std::collections::HashSet<String>,
367 ) -> bool {
368 if visited.contains(stage_name) {
369 return false;
370 }
371 visited.insert(stage_name.to_string());
372
373 let stage = self.stages.iter().find(|s| s.name == stage_name);
374 let stage = match stage {
375 Some(s) => s,
376 None => return false,
382 };
383
384 if let StageMode::FanOut {
387 config:
388 FanOutConfig {
389 merge_stage: Some(ms),
390 ..
391 },
392 } = &stage.mode
393 {
394 return self.has_terminal_path(ms, visited);
395 }
396
397 match &stage.transitions {
398 None => {
399 let idx = self
401 .stages
402 .iter()
403 .position(|s| s.name == stage_name)
404 .unwrap_or(0);
405 if idx + 1 >= self.stages.len() {
406 return true; }
408 self.has_terminal_path(&self.stages[idx + 1].name, visited)
409 }
410 Some(transitions) => {
411 if transitions.is_empty() {
412 return true; }
414 for target in transitions.keys() {
416 if self.has_terminal_path(target, visited) {
417 return true;
418 }
419 }
420 transitions.keys().all(|target| {
423 self.stages
424 .iter()
425 .find(|s| s.name == *target)
426 .map(|s| s.max_revisits.is_some())
427 .unwrap_or(false)
428 })
429 }
430 }
431 }
432
433 pub fn find_stage(&self, name: &str) -> Option<&Stage> {
435 self.stages.iter().find(|s| s.name == name)
436 }
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
445pub struct FileTrackingConfig {
446 pub region: String,
448 #[serde(default = "default_true_val")]
450 pub track_reads: bool,
451 #[serde(default = "default_true_val")]
455 pub track_writes: bool,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub max_file_tokens: Option<usize>,
459}
460
461fn default_true_val() -> bool {
462 true
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct RepetitionDetectionConfig {
471 pub max_repeat_calls: Option<usize>,
473 pub max_readonly_streak: Option<usize>,
475 pub enabled: Option<bool>,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct ToolResultRouting {
482 pub default_region: String,
484 pub tool_overrides: HashMap<String, String>,
486 pub persist: bool,
488 pub max_result_tokens: Option<usize>,
490}
491
492impl Default for ToolResultRouting {
493 fn default() -> Self {
494 Self {
495 default_region: "tool_results".to_string(),
496 tool_overrides: HashMap::new(),
497 persist: true,
498 max_result_tokens: None,
499 }
500 }
501}
502
503#[derive(Debug, Clone, Default, Serialize, Deserialize)]
505pub enum StageMode {
506 #[default]
508 Autonomous,
509
510 Interactive,
512
513 InteractivePoints {
515 points: Vec<InteractionPoint>,
517 },
518
519 FanOut {
522 config: FanOutConfig,
524 },
525}
526
527impl PartialEq for StageMode {
528 #[inline(never)]
529 fn eq(&self, other: &Self) -> bool {
530 match (self, other) {
531 (Self::Autonomous, Self::Autonomous) | (Self::Interactive, Self::Interactive) => true,
532 (Self::InteractivePoints { points: a }, Self::InteractivePoints { points: b }) => {
533 a == b
534 }
535 (Self::FanOut { config: a }, Self::FanOut { config: b }) => a == b,
536 _ => false,
537 }
538 }
539}
540impl Eq for StageMode {}
541
542#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
544#[serde(rename_all = "snake_case")]
545pub enum WorkerFailurePolicy {
546 #[default]
549 Continue,
550 FailAll,
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
559pub struct FanOutConfig {
560 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub worker_agent: Option<String>,
563 #[serde(default, skip_serializing_if = "Option::is_none")]
566 pub worker_stage: Option<String>,
567 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub worker_query: Option<String>,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
572 pub merge_stage: Option<String>,
573 #[serde(default = "default_max_workers")]
575 pub max_workers: usize,
576 #[serde(default)]
578 pub on_worker_failure: WorkerFailurePolicy,
579 #[serde(default)]
581 pub split_prompt: String,
582}
583
584fn default_max_workers() -> usize {
586 4
587}
588
589#[derive(Debug, Clone, Default, Serialize, Deserialize)]
591#[serde(rename_all = "snake_case")]
592pub enum InteractionStyle {
593 #[default]
595 FreeText,
596 MultipleChoice,
598 Confirm,
600}
601
602impl PartialEq for InteractionStyle {
603 #[inline(never)]
604 fn eq(&self, other: &Self) -> bool {
605 std::mem::discriminant(self) == std::mem::discriminant(other)
606 }
607}
608impl Eq for InteractionStyle {}
609
610#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
612#[serde(rename_all = "snake_case")]
613pub enum UnattendedPolicy {
614 #[default]
618 AutoApprove,
619
620 Ask,
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
629pub struct InteractionPoint {
630 pub name: String,
632
633 pub prompt: String,
635
636 pub required: bool,
640
641 #[serde(default)]
645 pub unattended: UnattendedPolicy,
646
647 #[serde(default)]
649 pub style: InteractionStyle,
650
651 #[serde(default)]
653 pub options: Vec<String>,
654
655 #[serde(default, alias = "followups")]
666 pub directives: HashMap<String, String>,
667
668 #[serde(default)]
673 pub abort_options: Vec<String>,
674
675 #[serde(default)]
681 pub edit_options: Vec<String>,
682
683 #[serde(default)]
690 pub document_region: Option<String>,
691}
692
693#[derive(Debug, Clone, Serialize, Deserialize)]
705pub struct Stage {
706 pub name: String,
708
709 pub description: Option<String>,
711
712 pub model: ModelConfig,
714
715 pub available_tools: Vec<String>,
717
718 #[serde(default)]
733 pub required_tools: Vec<String>,
734
735 pub max_iterations: Option<usize>,
737
738 #[serde(default)]
740 pub mode: StageMode,
741
742 pub context_layout: Option<ContextLayout>,
745
746 pub config: HashMap<String, serde_json::Value>,
748
749 #[serde(default)]
753 pub tool_permissions: HashMap<String, String>,
754
755 #[serde(default)]
758 pub requires_children: bool,
759
760 pub transitions: Option<HashMap<String, TransitionEdge>>,
762
763 pub max_revisits: Option<usize>,
765
766 pub transition_prompt: Option<String>,
768
769 #[serde(default = "default_true")]
773 pub accepts_messages: bool,
774
775 #[serde(default)]
782 pub allow_complete: bool,
783
784 #[serde(default)]
789 pub allow_as_worker: bool,
790
791 #[serde(default)]
802 pub allow_blocking_tools: bool,
803
804 #[serde(default)]
809 pub security: Option<crate::taint::SecurityConfig>,
810
811 #[serde(default)]
816 pub batch_tool_hint: Option<bool>,
817
818 #[serde(default)]
823 pub shell_hint: Option<bool>,
824
825 #[serde(default)]
831 pub nudge: Option<NudgeConfig>,
832
833 #[serde(default)]
838 pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
839
840 #[serde(default)]
844 pub tool_result_routing: Option<ToolResultRouting>,
845}
846
847fn default_true() -> bool {
849 true
850}
851
852impl Stage {
853 pub fn new(name: String, model: ModelConfig) -> Self {
855 Self {
856 name,
857 description: None,
858 model,
859 available_tools: Vec::new(),
860 required_tools: Vec::new(),
861 max_iterations: None,
862 mode: StageMode::Autonomous,
863 context_layout: None,
864 config: HashMap::new(),
865 tool_permissions: HashMap::new(),
866 requires_children: false,
867 transitions: None,
868 max_revisits: None,
869 transition_prompt: None,
870 accepts_messages: true,
871 allow_complete: false,
872 allow_as_worker: false,
873 allow_blocking_tools: false,
874 security: None,
875 batch_tool_hint: None,
876 shell_hint: None,
877 nudge: None,
878 sandbox: None,
879 tool_result_routing: None,
880 }
881 }
882
883 pub fn with_tools(mut self, tools: Vec<String>) -> Self {
885 self.available_tools = tools;
886 self
887 }
888
889 pub fn with_mode(mut self, mode: StageMode) -> Self {
891 self.mode = mode;
892 self
893 }
894
895 pub fn with_context_layout(mut self, layout: ContextLayout) -> Self {
897 self.context_layout = Some(layout);
898 self
899 }
900
901 pub fn with_description(mut self, description: String) -> Self {
903 self.description = Some(description);
904 self
905 }
906
907 fn validate(&self) -> std::result::Result<(), ValidationError> {
909 if self.name.is_empty() {
910 return Err(ValidationError::Stage {
911 stage: "(empty)".to_string(),
912 message: "stage name cannot be empty".to_string(),
913 });
914 }
915
916 for tool in &self.required_tools {
921 if !self.available_tools.contains(tool) {
922 return Err(ValidationError::Stage {
923 stage: self.name.clone(),
924 message: format!(
925 "required_tools entry '{}' is not in available_tools - a tool the \
926 stage cannot call can't be kept through an unattended run",
927 tool
928 ),
929 });
930 }
931 }
932
933 if let Some(layout) = &self.context_layout {
935 layout.validate()?;
936 }
937
938 Ok(())
939 }
940}
941
942#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
944pub struct ModelEntry {
945 pub provider: String,
947
948 pub model: String,
950}
951
952impl ModelEntry {
953 pub fn new(provider: String, model: String) -> Self {
954 Self { provider, model }
955 }
956}
957
958#[derive(Debug, Clone, Serialize, Deserialize)]
966pub struct ModelConfig {
967 #[serde(default)]
969 pub models: Vec<ModelEntry>,
970
971 #[serde(default = "default_allow_user_default")]
974 pub allow_user_default: bool,
975
976 #[serde(default)]
978 pub parameters: HashMap<String, serde_json::Value>,
979
980 #[serde(default)]
988 pub request_timeout_secs: Option<u64>,
989}
990
991fn default_allow_user_default() -> bool {
992 true
993}
994
995impl ModelConfig {
996 pub fn new(provider: String, model: String) -> Self {
998 Self {
999 models: vec![ModelEntry::new(provider, model)],
1000 allow_user_default: true,
1001 parameters: HashMap::new(),
1002 request_timeout_secs: None,
1003 }
1004 }
1005
1006 pub fn provider(&self) -> &str {
1008 self.models
1009 .first()
1010 .map(|e| e.provider.as_str())
1011 .unwrap_or("anthropic")
1012 }
1013
1014 pub fn model(&self) -> &str {
1016 self.models
1017 .first()
1018 .map(|e| e.model.as_str())
1019 .unwrap_or("claude-sonnet-4-6")
1020 }
1021}
1022
1023#[derive(Debug, Clone, Serialize, Deserialize)]
1030pub struct ContextTransform {
1031 pub from_blueprint: String,
1033
1034 pub to_blueprint: String,
1036
1037 pub mappings: Vec<RegionMapping>,
1039}
1040
1041impl ContextTransform {
1042 fn validate(&self, layout: &ContextLayout) -> std::result::Result<(), ValidationError> {
1044 for mapping in &self.mappings {
1045 if layout.get_region(&mapping.to_region).is_none() {
1048 return Err(ValidationError::Region {
1049 region: mapping.to_region.clone(),
1050 message: "transform target region not found in layout".to_string(),
1051 });
1052 }
1053 }
1054 Ok(())
1055 }
1056}
1057
1058#[derive(Debug, Clone, Serialize, Deserialize)]
1060pub struct RegionMapping {
1061 pub from_region: String,
1063
1064 pub to_region: String,
1066
1067 pub transform: Option<ContentTransform>,
1069}
1070
1071#[derive(Debug, Clone, Serialize, Deserialize)]
1073pub struct TransitionEdge {
1074 pub target: String,
1076
1077 #[serde(default)]
1079 pub condition: TransitionCondition,
1080
1081 pub hint: Option<String>,
1083
1084 #[serde(default)]
1086 pub transform: EdgeTransform,
1087
1088 #[serde(default)]
1091 pub gate: Option<TransitionGate>,
1092
1093 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 pub stuck: Option<StuckConfig>,
1098}
1099
1100#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1108pub struct StuckConfig {
1109 #[serde(default, skip_serializing_if = "Option::is_none")]
1111 pub after_iterations: Option<usize>,
1112
1113 #[serde(default, skip_serializing_if = "Option::is_none")]
1115 pub after_minutes: Option<usize>,
1116
1117 #[serde(default, skip_serializing_if = "Option::is_none")]
1120 pub after_same_file_edits: Option<usize>,
1121
1122 #[serde(default, skip_serializing_if = "Option::is_none")]
1124 pub after_tool_calls: Option<usize>,
1125}
1126
1127impl StuckConfig {
1128 pub fn is_armed(&self) -> bool {
1130 self.after_iterations.is_some()
1131 || self.after_minutes.is_some()
1132 || self.after_same_file_edits.is_some()
1133 || self.after_tool_calls.is_some()
1134 }
1135}
1136
1137#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1145pub struct TransitionGate {
1146 #[serde(default)]
1149 pub require_modifications: bool,
1150
1151 #[serde(default)]
1154 pub message: Option<String>,
1155
1156 #[serde(default)]
1161 pub region: Option<String>,
1162
1163 #[serde(default)]
1166 pub tools: Vec<String>,
1167
1168 #[serde(default)]
1172 pub max_attempts: Option<usize>,
1173}
1174
1175pub const DEFAULT_GATE_ATTEMPTS: usize = 3;
1177
1178pub const MODIFYING_TOOLS: &[&str] = &["write_file", "edit_file"];
1182
1183#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1190pub struct NudgeConfig {
1191 #[serde(default)]
1196 pub enabled: Option<bool>,
1197
1198 #[serde(default)]
1201 pub max: Option<usize>,
1202
1203 #[serde(default)]
1207 pub text: Option<String>,
1208}
1209
1210pub const DEFAULT_NUDGE_TEXT: &str = "You have tools available. Please use them to complete the task. Start by reading the relevant files in the working directory.";
1213
1214pub const DEFAULT_MAX_NUDGES: usize = 3;
1217
1218#[derive(Debug, Clone, PartialEq, Eq)]
1221pub struct ResolvedNudge {
1222 pub enabled: bool,
1224 pub max: usize,
1226 pub text: String,
1228}
1229
1230pub fn resolve_nudge(
1239 global: Option<&NudgeConfig>,
1240 agent: Option<&NudgeConfig>,
1241 stage: Option<&NudgeConfig>,
1242 stage_is_reviewed: bool,
1243) -> ResolvedNudge {
1244 fn field<T: Clone>(
1245 global: Option<&NudgeConfig>,
1246 agent: Option<&NudgeConfig>,
1247 stage: Option<&NudgeConfig>,
1248 get: impl Fn(&NudgeConfig) -> Option<T>,
1249 ) -> Option<T> {
1250 stage
1251 .and_then(&get)
1252 .or_else(|| agent.and_then(&get))
1253 .or_else(|| global.and_then(&get))
1254 }
1255 ResolvedNudge {
1256 enabled: field(global, agent, stage, |c| c.enabled).unwrap_or(!stage_is_reviewed),
1257 max: field(global, agent, stage, |c| c.max).unwrap_or(DEFAULT_MAX_NUDGES),
1258 text: field(global, agent, stage, |c| c.text.clone())
1259 .unwrap_or_else(|| DEFAULT_NUDGE_TEXT.to_string()),
1260 }
1261}
1262
1263#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1265#[serde(rename_all = "snake_case")]
1266pub enum TransitionCondition {
1267 #[default]
1269 Always,
1270 Error,
1272 MaxIterations,
1274 LlmChoice,
1276 Stuck,
1282}
1283
1284#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1286#[serde(rename_all = "snake_case")]
1287pub enum EdgeTransform {
1288 #[default]
1290 Direct,
1291
1292 Clear,
1294
1295 Compact {
1297 #[serde(default)]
1298 prompt: Option<String>,
1299 },
1300
1301 Custom {
1303 carry: Vec<String>,
1304 compact: Vec<String>,
1305 clear: Vec<String>,
1306 compact_prompt: Option<String>,
1307 },
1308}
1309
1310impl PartialEq for EdgeTransform {
1311 #[inline(never)]
1312 fn eq(&self, other: &Self) -> bool {
1313 match (self, other) {
1314 (Self::Direct, Self::Direct) | (Self::Clear, Self::Clear) => true,
1315 (Self::Compact { prompt: a }, Self::Compact { prompt: b }) => a == b,
1316 (
1317 Self::Custom {
1318 carry: ca,
1319 compact: coa,
1320 clear: cla,
1321 compact_prompt: cpa,
1322 },
1323 Self::Custom {
1324 carry: cb,
1325 compact: cob,
1326 clear: clb,
1327 compact_prompt: cpb,
1328 },
1329 ) => ca == cb && coa == cob && cla == clb && cpa == cpb,
1330 _ => false,
1331 }
1332 }
1333}
1334impl Eq for EdgeTransform {}
1335
1336#[derive(Debug, Clone, Serialize, Deserialize)]
1338pub enum ContentTransform {
1339 Direct,
1341
1342 Summarize,
1344
1345 Extract { fields: Vec<String> },
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351 use super::*;
1352 use crate::layout::ContextLayout;
1353 use crate::layout::RegionDefinition;
1354 use crate::region::RegionKind;
1355
1356 #[test]
1357 fn resolve_nudge_defaults_when_nothing_is_configured() {
1358 let normal = resolve_nudge(None, None, None, false);
1361 assert!(normal.enabled);
1362 assert_eq!(normal.max, DEFAULT_MAX_NUDGES);
1363 assert_eq!(normal.text, DEFAULT_NUDGE_TEXT);
1364 let reviewed = resolve_nudge(None, None, None, true);
1365 assert!(!reviewed.enabled);
1366 assert_eq!(reviewed.max, DEFAULT_MAX_NUDGES);
1368 assert_eq!(reviewed.text, DEFAULT_NUDGE_TEXT);
1369 }
1370
1371 #[test]
1372 fn resolve_nudge_cascades_each_field_independently() {
1373 let global = NudgeConfig {
1374 enabled: Some(true),
1375 max: Some(10),
1376 text: Some("global".to_string()),
1377 };
1378 let agent = NudgeConfig {
1379 max: Some(2),
1380 ..Default::default()
1381 };
1382 let stage = NudgeConfig {
1383 text: Some("stage".to_string()),
1384 ..Default::default()
1385 };
1386 let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage), false);
1387 assert!(resolved.enabled);
1389 assert_eq!(resolved.max, 2);
1390 assert_eq!(resolved.text, "stage");
1391 let stage_all = NudgeConfig {
1393 enabled: Some(false),
1394 max: Some(0),
1395 text: Some("s".to_string()),
1396 };
1397 let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage_all), false);
1398 assert_eq!(
1399 resolved,
1400 ResolvedNudge {
1401 enabled: false,
1402 max: 0,
1403 text: "s".to_string()
1404 }
1405 );
1406 }
1407
1408 #[test]
1409 fn resolve_nudge_explicit_enabled_overrides_review_suppression() {
1410 let on = NudgeConfig {
1413 enabled: Some(true),
1414 ..Default::default()
1415 };
1416 assert!(resolve_nudge(None, None, Some(&on), true).enabled);
1417 assert!(resolve_nudge(None, Some(&on), None, true).enabled);
1418 assert!(resolve_nudge(Some(&on), None, None, true).enabled);
1419 let off = NudgeConfig {
1420 enabled: Some(false),
1421 ..Default::default()
1422 };
1423 assert!(!resolve_nudge(None, None, Some(&off), false).enabled);
1424 }
1425
1426 #[test]
1427 fn test_blueprint_creation() {
1428 let regions = vec![RegionDefinition::new(
1429 "test".to_string(),
1430 RegionKind::Pinned,
1431 5000,
1432 )];
1433 let layout = ContextLayout::new(regions, 10000);
1434
1435 let stages = vec![Stage::new(
1436 "analyze".to_string(),
1437 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1438 )];
1439
1440 let blueprint = Blueprint::new(
1441 "test-agent".to_string(),
1442 "A test agent".to_string(),
1443 stages,
1444 layout,
1445 );
1446
1447 assert_eq!(blueprint.name, "test-agent");
1448 assert_eq!(blueprint.stages.len(), 1);
1449 }
1450
1451 #[test]
1452 fn test_blueprint_with_transforms_version() {
1453 let stages = vec![Stage::new("plan".to_string(), make_model())];
1454 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout())
1455 .with_transforms(vec![ContextTransform {
1456 from_blueprint: "a".to_string(),
1457 to_blueprint: "b".to_string(),
1458 mappings: vec![],
1459 }])
1460 .with_version("2.0.0".to_string());
1461
1462 assert_eq!(bp.transforms.len(), 1);
1463 assert_eq!(bp.version, "2.0.0");
1464 }
1465
1466 #[test]
1467 fn agent_tool_permissions_projects_only_string_tool_perm_entries() {
1468 let stages = vec![Stage::new("plan".to_string(), make_model())];
1469 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1470 bp.metadata.insert(
1472 "tool_perm:bash".to_string(),
1473 serde_json::Value::String("deny".to_string()),
1474 );
1475 bp.metadata
1477 .insert("title".to_string(), serde_json::Value::String("x".into()));
1478 bp.metadata
1480 .insert("tool_perm:weird".to_string(), serde_json::Value::Bool(true));
1481
1482 let perms = bp.agent_tool_permissions();
1483 assert_eq!(perms.get("bash").map(String::as_str), Some("deny"));
1484 assert!(!perms.contains_key("title"));
1485 assert!(!perms.contains_key("weird"));
1486 assert_eq!(perms.len(), 1);
1487 }
1488
1489 #[test]
1490 fn test_blueprint_validate_runs_transform_validation() {
1491 let stages = vec![Stage::new("plan".to_string(), make_model())];
1494 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1495 bp.transforms.push(ContextTransform {
1496 from_blueprint: "a".to_string(),
1497 to_blueprint: "b".to_string(),
1498 mappings: vec![RegionMapping {
1499 from_region: "test".to_string(),
1500 to_region: "test".to_string(),
1501 transform: None,
1502 }],
1503 });
1504 assert!(bp.validate().is_ok());
1505 }
1506
1507 #[test]
1508 fn test_blueprint_validate_fails_on_transform_targeting_unknown_region() {
1509 let stages = vec![Stage::new("plan".to_string(), make_model())];
1510 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1511 bp.transforms.push(ContextTransform {
1512 from_blueprint: "a".to_string(),
1513 to_blueprint: "b".to_string(),
1514 mappings: vec![RegionMapping {
1515 from_region: "test".to_string(),
1516 to_region: "nonexistent".to_string(),
1517 transform: None,
1518 }],
1519 });
1520 let err = bp.validate().unwrap_err();
1521 assert_eq!(
1522 err,
1523 ValidationError::Region {
1524 region: "nonexistent".to_string(),
1525 message: "transform target region not found in layout".to_string(),
1526 }
1527 );
1528 }
1529
1530 #[test]
1531 fn test_mixed_linear_and_graph_mode_terminal_path() {
1532 let mut plan = Stage::new("plan".to_string(), make_model());
1536 let impl_stage = Stage::new("impl".to_string(), make_model());
1537 let review = Stage::new("review".to_string(), make_model());
1538
1539 let mut transitions = HashMap::new();
1540 transitions.insert(
1541 "impl".to_string(),
1542 TransitionEdge {
1543 target: "impl".to_string(),
1544 condition: TransitionCondition::Always,
1545 hint: None,
1546 transform: EdgeTransform::Direct,
1547 gate: None,
1548 stuck: None,
1549 },
1550 );
1551 plan.transitions = Some(transitions);
1552
1553 let bp = Blueprint::new(
1554 "t".into(),
1555 "".into(),
1556 vec![plan, impl_stage, review],
1557 make_layout(),
1558 );
1559 assert!(bp.validate().is_ok());
1560 }
1561
1562 #[test]
1563 fn test_stage_validation() {
1564 let stage = Stage::new(
1565 "test".to_string(),
1566 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1567 );
1568 assert!(stage.validate().is_ok());
1569
1570 let empty_stage = Stage::new(
1571 "".to_string(),
1572 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1573 );
1574 assert!(empty_stage.validate().is_err());
1575 }
1576
1577 #[test]
1578 fn test_stage_validate_with_valid_context_layout_is_ok() {
1579 let mut stage = Stage::new("test".to_string(), make_model());
1580 stage.context_layout = Some(make_layout());
1581 assert!(stage.validate().is_ok());
1582 }
1583
1584 #[test]
1585 fn test_stage_validate_with_invalid_context_layout_is_err() {
1586 let regions = vec![
1588 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1589 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1590 ];
1591 let mut stage = Stage::new("test".to_string(), make_model());
1592 stage.context_layout = Some(ContextLayout::new(regions, 200));
1593 assert!(stage.validate().is_err());
1594 }
1595
1596 #[test]
1597 fn test_stage_with_tools_context_layout_description() {
1598 let stage = Stage::new("test".to_string(), make_model())
1599 .with_tools(vec!["read_file".to_string(), "bash".to_string()])
1600 .with_context_layout(make_layout())
1601 .with_description("does things".to_string());
1602
1603 assert_eq!(stage.available_tools, vec!["read_file", "bash"]);
1604 assert!(stage.context_layout.is_some());
1605 assert_eq!(stage.description.as_deref(), Some("does things"));
1606 }
1607
1608 #[test]
1609 fn test_stage_with_mode() {
1610 let stage = Stage::new("test".to_string(), make_model())
1611 .with_mode(StageMode::InteractivePoints { points: vec![] });
1612 assert_eq!(stage.mode, StageMode::InteractivePoints { points: vec![] });
1613 }
1614
1615 #[test]
1616 fn test_stage_allow_complete_defaults_false() {
1617 let stage = Stage::new("review".to_string(), make_model());
1618 assert!(!stage.allow_complete);
1619 }
1620
1621 #[test]
1622 fn test_stage_allow_complete_serde_default_when_missing() {
1623 let json = r#"{
1626 "name": "review",
1627 "description": null,
1628 "model": {"provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {}},
1629 "available_tools": [],
1630 "max_iterations": null,
1631 "context_layout": null,
1632 "config": {},
1633 "transitions": null,
1634 "max_revisits": null,
1635 "transition_prompt": null
1636 }"#;
1637 let stage: Stage = serde_json::from_str(json).unwrap();
1638 assert!(!stage.allow_complete);
1639 assert!(stage.accepts_messages);
1640 }
1641
1642 #[test]
1643 fn test_stage_allow_complete_roundtrip() {
1644 let mut stage = Stage::new("review".to_string(), make_model());
1645 stage.allow_complete = true;
1646 let json = serde_json::to_string(&stage).unwrap();
1647 let back: Stage = serde_json::from_str(&json).unwrap();
1648 assert!(back.allow_complete);
1649 }
1650
1651 #[test]
1652 fn test_interaction_point_directives_default_empty() {
1653 let point = InteractionPoint {
1654 name: "plan_approval".to_string(),
1655 prompt: "Approve?".to_string(),
1656 required: true,
1657 unattended: UnattendedPolicy::AutoApprove,
1658 style: InteractionStyle::MultipleChoice,
1659 options: vec!["Approve".to_string(), "Revise".to_string()],
1660 directives: HashMap::new(),
1661 abort_options: Vec::new(),
1662 edit_options: Vec::new(),
1663 document_region: None,
1664 };
1665 assert!(point.directives.is_empty());
1666 assert!(point.abort_options.is_empty());
1667 assert!(point.edit_options.is_empty());
1668 }
1669
1670 #[test]
1671 fn test_interaction_point_directives_roundtrip() {
1672 let mut directives = HashMap::new();
1673 directives.insert(
1674 "Revise".to_string(),
1675 "Ask what to change, then re-plan.".to_string(),
1676 );
1677 let point = InteractionPoint {
1678 name: "plan_approval".to_string(),
1679 prompt: "Approve?".to_string(),
1680 required: true,
1681 unattended: UnattendedPolicy::Ask,
1682 style: InteractionStyle::MultipleChoice,
1683 options: vec!["Approve".to_string(), "Revise".to_string()],
1684 directives,
1685 abort_options: vec!["Abort".to_string()],
1686 edit_options: vec!["Add detail".to_string()],
1687 document_region: Some("plan".to_string()),
1688 };
1689 let json = serde_json::to_string(&point).unwrap();
1690 let back: InteractionPoint = serde_json::from_str(&json).unwrap();
1691 assert_eq!(
1692 back.directives.get("Revise").map(|s| s.as_str()),
1693 Some("Ask what to change, then re-plan.")
1694 );
1695 assert_eq!(back.abort_options, vec!["Abort".to_string()]);
1696 assert_eq!(back.edit_options, vec!["Add detail".to_string()]);
1697 assert_eq!(back.unattended, UnattendedPolicy::Ask);
1700 }
1701
1702 #[test]
1703 fn test_interaction_point_directives_serde_default_when_missing() {
1704 let json = r#"{
1705 "name": "plan_approval",
1706 "prompt": "Approve?",
1707 "required": true,
1708 "style": "multiple_choice",
1709 "options": ["Approve", "Revise"]
1710 }"#;
1711 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1712 assert!(point.directives.is_empty());
1713 assert!(point.abort_options.is_empty());
1714 }
1715
1716 #[test]
1717 fn test_interaction_point_followups_alias_still_deserializes() {
1718 let json = r#"{
1720 "name": "plan_approval",
1721 "prompt": "Approve?",
1722 "required": true,
1723 "style": "multiple_choice",
1724 "options": ["Approve", "Revise"],
1725 "followups": { "Revise": "What to change?" }
1726 }"#;
1727 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1728 assert_eq!(
1729 point.directives.get("Revise").map(|s| s.as_str()),
1730 Some("What to change?")
1731 );
1732 }
1733
1734 #[test]
1735 fn test_model_config_new_creates_single_entry() {
1736 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1737 assert_eq!(mc.models.len(), 1);
1738 assert_eq!(mc.models[0].provider, "anthropic");
1739 assert_eq!(mc.models[0].model, "claude-sonnet-4-6");
1740 assert!(mc.allow_user_default);
1741 }
1742
1743 #[test]
1744 fn test_model_config_with_multiple_models() {
1745 let mc = ModelConfig {
1746 models: vec![
1747 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1748 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1749 ModelEntry::new("ollama".to_string(), "llama3".to_string()),
1750 ],
1751 allow_user_default: true,
1752 parameters: HashMap::new(),
1753 request_timeout_secs: None,
1754 };
1755 assert_eq!(mc.models.len(), 3);
1756 assert_eq!(mc.models[0].provider, "anthropic");
1757 assert_eq!(mc.models[1].provider, "openai");
1758 assert_eq!(mc.models[2].provider, "ollama");
1759 }
1760
1761 #[test]
1762 fn test_model_config_serde_roundtrip() {
1763 let mc = ModelConfig {
1764 models: vec![
1765 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1766 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1767 ],
1768 allow_user_default: false,
1769 parameters: HashMap::new(),
1770 request_timeout_secs: None,
1771 };
1772 let json = serde_json::to_string(&mc).unwrap();
1773 let back: ModelConfig = serde_json::from_str(&json).unwrap();
1774 assert_eq!(back.models.len(), 2);
1775 assert_eq!(back.models[0].provider, "anthropic");
1776 assert_eq!(back.models[1].provider, "openai");
1777 assert!(!back.allow_user_default);
1778 }
1779
1780 #[test]
1781 fn test_model_config_serde_defaults_when_fields_missing() {
1782 let json = r#"{"parameters": {}}"#;
1784 let mc: ModelConfig = serde_json::from_str(json).unwrap();
1785 assert!(mc.models.is_empty());
1786 assert!(mc.allow_user_default);
1787 }
1788
1789 #[test]
1790 fn test_model_config_convenience_accessors() {
1791 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1792 assert_eq!(mc.provider(), "anthropic");
1793 assert_eq!(mc.model(), "claude-sonnet-4-6");
1794 }
1795
1796 #[test]
1797 fn test_model_config_convenience_accessors_empty_models() {
1798 let mc = ModelConfig {
1799 models: vec![],
1800 allow_user_default: true,
1801 parameters: HashMap::new(),
1802 request_timeout_secs: None,
1803 };
1804 assert_eq!(mc.provider(), "anthropic");
1805 assert_eq!(mc.model(), "claude-sonnet-4-6");
1806 }
1807
1808 fn make_model() -> ModelConfig {
1809 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
1810 }
1811
1812 fn make_layout() -> ContextLayout {
1813 let regions = vec![RegionDefinition::new(
1814 "test".to_string(),
1815 RegionKind::Pinned,
1816 5000,
1817 )];
1818 ContextLayout::new(regions, 10000)
1819 }
1820
1821 #[test]
1822 fn test_graph_validation_entry_stage_exists() {
1823 let stages = vec![Stage::new("plan".to_string(), make_model())];
1824 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1825 bp.entry_stage = Some("nonexistent".to_string());
1826 assert!(bp.validate().is_err());
1827 }
1828
1829 #[test]
1830 fn test_graph_validation_entry_stage_valid() {
1831 let stages = vec![Stage::new("plan".to_string(), make_model())];
1832 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1833 bp.entry_stage = Some("plan".to_string());
1834 assert!(bp.validate().is_ok());
1835 }
1836
1837 #[test]
1838 fn test_graph_validation_transition_target_missing() {
1839 let mut stage = Stage::new("plan".to_string(), make_model());
1840 let mut transitions = HashMap::new();
1841 transitions.insert(
1842 "nonexistent".to_string(),
1843 TransitionEdge {
1844 target: "nonexistent".to_string(),
1845 condition: TransitionCondition::Always,
1846 hint: None,
1847 transform: EdgeTransform::Direct,
1848 gate: None,
1849 stuck: None,
1850 },
1851 );
1852 stage.transitions = Some(transitions);
1853 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1854 assert!(bp.validate().is_err());
1855 }
1856
1857 #[test]
1861 fn test_graph_validation_modification_gate_needs_a_writing_stage() {
1862 let gated = |tools: &[&str], extra: &[&str]| {
1863 let mut stage = Stage::new("impl".to_string(), make_model());
1864 stage.available_tools = tools.iter().map(|t| t.to_string()).collect();
1865 let mut transitions = HashMap::new();
1866 transitions.insert(
1867 "review".to_string(),
1868 TransitionEdge {
1869 target: "review".to_string(),
1870 condition: TransitionCondition::Always,
1871 hint: None,
1872 transform: EdgeTransform::Direct,
1873 stuck: None,
1874 gate: Some(TransitionGate {
1875 require_modifications: true,
1876 tools: extra.iter().map(|t| t.to_string()).collect(),
1877 ..Default::default()
1878 }),
1879 },
1880 );
1881 stage.transitions = Some(transitions);
1882 Blueprint::new(
1883 "t".into(),
1884 "".into(),
1885 vec![stage, Stage::new("review".to_string(), make_model())],
1886 make_layout(),
1887 )
1888 };
1889 let err = gated(&["read_file"], &[]).validate().unwrap_err();
1890 assert!(err.to_string().contains("no file-modifying tool"));
1891 assert!(gated(&["read_file", "edit_file"], &[]).validate().is_ok());
1893 assert!(
1895 gated(&["read_file", "patch_file"], &["patch_file"])
1896 .validate()
1897 .is_ok()
1898 );
1899 let mut off = gated(&["read_file"], &[]);
1901 off.stages[0]
1902 .transitions
1903 .as_mut()
1904 .unwrap()
1905 .get_mut("review")
1906 .unwrap()
1907 .gate = Some(TransitionGate::default());
1908 assert!(off.validate().is_ok());
1909 off.stages[0]
1911 .transitions
1912 .as_mut()
1913 .unwrap()
1914 .get_mut("review")
1915 .unwrap()
1916 .gate = None;
1917 assert!(off.validate().is_ok());
1918 }
1919
1920 #[test]
1921 fn test_graph_validation_self_loop_requires_max_revisits() {
1922 let mut stage = Stage::new("impl".to_string(), make_model());
1923 let mut transitions = HashMap::new();
1924 transitions.insert(
1925 "impl".to_string(),
1926 TransitionEdge {
1927 target: "impl".to_string(),
1928 condition: TransitionCondition::Always,
1929 hint: None,
1930 transform: EdgeTransform::Direct,
1931 gate: None,
1932 stuck: None,
1933 },
1934 );
1935 stage.transitions = Some(transitions);
1936 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1937 assert!(bp.validate().is_err());
1938 }
1939
1940 #[test]
1941 fn test_graph_validation_self_loop_with_max_revisits_ok() {
1942 let mut stage = Stage::new("impl".to_string(), make_model());
1943 stage.max_revisits = Some(3);
1944 let mut transitions = HashMap::new();
1945 transitions.insert(
1946 "impl".to_string(),
1947 TransitionEdge {
1948 target: "impl".to_string(),
1949 condition: TransitionCondition::Always,
1950 hint: None,
1951 transform: EdgeTransform::Direct,
1952 gate: None,
1953 stuck: None,
1954 },
1955 );
1956 stage.transitions = Some(transitions);
1957 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1958 assert!(bp.validate().is_ok());
1961 }
1962
1963 #[test]
1964 fn test_graph_validation_terminal_path_exists() {
1965 let mut plan = Stage::new("plan".to_string(), make_model());
1966 let mut review = Stage::new("review".to_string(), make_model());
1967 review.transitions = Some(HashMap::new()); let mut transitions = HashMap::new();
1970 transitions.insert(
1971 "review".to_string(),
1972 TransitionEdge {
1973 target: "review".to_string(),
1974 condition: TransitionCondition::Always,
1975 hint: None,
1976 transform: EdgeTransform::Direct,
1977 gate: None,
1978 stuck: None,
1979 },
1980 );
1981 plan.transitions = Some(transitions);
1982
1983 let bp = Blueprint::new("t".into(), "".into(), vec![plan, review], make_layout());
1984 assert!(bp.validate().is_ok());
1985 }
1986
1987 #[test]
1988 fn test_graph_no_terminal_path() {
1989 let mut a = Stage::new("a".to_string(), make_model());
1991 let mut b = Stage::new("b".to_string(), make_model());
1992
1993 let mut a_transitions = HashMap::new();
1994 a_transitions.insert(
1995 "b".to_string(),
1996 TransitionEdge {
1997 target: "b".to_string(),
1998 condition: TransitionCondition::Always,
1999 hint: None,
2000 transform: EdgeTransform::Direct,
2001 gate: None,
2002 stuck: None,
2003 },
2004 );
2005 a.transitions = Some(a_transitions);
2006
2007 let mut b_transitions = HashMap::new();
2008 b_transitions.insert(
2009 "a".to_string(),
2010 TransitionEdge {
2011 target: "a".to_string(),
2012 condition: TransitionCondition::Always,
2013 hint: None,
2014 transform: EdgeTransform::Direct,
2015 gate: None,
2016 stuck: None,
2017 },
2018 );
2019 b.transitions = Some(b_transitions);
2020
2021 let bp = Blueprint::new("t".into(), "".into(), vec![a, b], make_layout());
2022 assert!(bp.validate().is_err());
2023 }
2024
2025 #[test]
2026 fn test_linear_stages_still_validate() {
2027 let stages = vec![
2029 Stage::new("plan".to_string(), make_model()),
2030 Stage::new("impl".to_string(), make_model()),
2031 Stage::new("review".to_string(), make_model()),
2032 ];
2033 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
2034 assert!(bp.validate().is_ok());
2035 }
2036
2037 #[test]
2038 fn test_resolve_entry_stage_name() {
2039 let stages = vec![
2040 Stage::new("plan".to_string(), make_model()),
2041 Stage::new("impl".to_string(), make_model()),
2042 ];
2043 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
2044 assert_eq!(bp.resolve_entry_stage_name(), "plan");
2045
2046 bp.entry_stage = Some("impl".to_string());
2047 assert_eq!(bp.resolve_entry_stage_name(), "impl");
2048 }
2049
2050 #[test]
2051 fn test_find_stage() {
2052 let stages = vec![
2053 Stage::new("plan".to_string(), make_model()),
2054 Stage::new("impl".to_string(), make_model()),
2055 ];
2056 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
2057 assert!(bp.find_stage("plan").is_some());
2058 assert!(bp.find_stage("impl").is_some());
2059 assert!(bp.find_stage("nonexistent").is_none());
2060 }
2061
2062 #[test]
2063 fn test_transition_condition_default() {
2064 let cond = TransitionCondition::default();
2065 assert_eq!(cond, TransitionCondition::Always);
2066 }
2067
2068 #[test]
2069 fn test_edge_transform_default() {
2070 let t = EdgeTransform::default();
2071 assert_eq!(t, EdgeTransform::Direct);
2072 }
2073
2074 #[test]
2075 fn test_stage_mode_equality() {
2076 assert_eq!(StageMode::Autonomous, StageMode::Autonomous);
2077 assert_eq!(StageMode::Interactive, StageMode::Interactive);
2078 assert_ne!(StageMode::Autonomous, StageMode::Interactive);
2079 }
2080
2081 #[test]
2082 fn test_interaction_style_equality() {
2083 assert_eq!(InteractionStyle::FreeText, InteractionStyle::FreeText);
2084 assert_ne!(InteractionStyle::FreeText, InteractionStyle::MultipleChoice);
2085 }
2086
2087 #[test]
2090 fn stuck_config_is_armed_only_when_a_threshold_is_set() {
2091 assert!(!StuckConfig::default().is_armed());
2092 for cfg in [
2093 StuckConfig {
2094 after_iterations: Some(1),
2095 ..Default::default()
2096 },
2097 StuckConfig {
2098 after_minutes: Some(1),
2099 ..Default::default()
2100 },
2101 StuckConfig {
2102 after_same_file_edits: Some(1),
2103 ..Default::default()
2104 },
2105 StuckConfig {
2106 after_tool_calls: Some(1),
2107 ..Default::default()
2108 },
2109 ] {
2110 assert!(cfg.is_armed(), "{cfg:?} should be armed");
2111 }
2112 }
2113
2114 #[test]
2115 fn transition_condition_stuck_round_trips_as_snake_case() {
2116 let json = serde_json::to_string(&TransitionCondition::Stuck).unwrap();
2117 assert_eq!(json, "\"stuck\"");
2118 let back: TransitionCondition = serde_json::from_str(&json).unwrap();
2119 assert_eq!(back, TransitionCondition::Stuck);
2120 assert_ne!(TransitionCondition::Stuck, TransitionCondition::Always);
2121 }
2122
2123 #[test]
2124 fn transition_edge_stuck_round_trips_and_is_omitted_when_absent() {
2125 let plain = TransitionEdge {
2126 target: "b".to_string(),
2127 condition: TransitionCondition::Always,
2128 hint: None,
2129 transform: EdgeTransform::Direct,
2130 gate: None,
2131 stuck: None,
2132 };
2133 let json = serde_json::to_string(&plain).unwrap();
2134 assert!(
2135 !json.contains("stuck"),
2136 "absent config must be skipped: {json}"
2137 );
2138
2139 let armed = TransitionEdge {
2140 condition: TransitionCondition::Stuck,
2141 stuck: Some(StuckConfig {
2142 after_iterations: Some(20),
2143 after_minutes: Some(10),
2144 after_same_file_edits: Some(3),
2145 after_tool_calls: Some(60),
2146 }),
2147 ..plain
2148 };
2149 let back: TransitionEdge = serde_json::from_str(&serde_json::to_string(&armed).unwrap())
2150 .expect("armed edge round-trips");
2151 assert_eq!(back.condition, TransitionCondition::Stuck);
2152 assert_eq!(back.stuck, armed.stuck);
2153 }
2154
2155 #[test]
2158 fn validate_rejects_a_stuck_edge_with_no_threshold() {
2159 let build = |stuck| {
2160 let mut a = Stage::new("a".to_string(), make_model());
2161 let b = Stage::new("b".to_string(), make_model());
2162 let mut transitions = std::collections::HashMap::new();
2163 transitions.insert(
2164 "b".to_string(),
2165 TransitionEdge {
2166 target: "b".to_string(),
2167 condition: TransitionCondition::Stuck,
2168 hint: None,
2169 transform: EdgeTransform::Direct,
2170 gate: None,
2171 stuck,
2172 },
2173 );
2174 a.transitions = Some(transitions);
2175 Blueprint::new("t".into(), "".into(), vec![a, b], make_layout())
2176 };
2177
2178 for dead in [None, Some(StuckConfig::default())] {
2179 let err = build(dead)
2180 .validate()
2181 .expect_err("dead stuck edge rejected");
2182 assert!(
2183 format!("{err:?}").contains("stuck_after_"),
2184 "unexpected error: {err:?}"
2185 );
2186 }
2187
2188 assert!(
2190 build(Some(StuckConfig {
2191 after_iterations: Some(5),
2192 ..Default::default()
2193 }))
2194 .validate()
2195 .is_ok()
2196 );
2197 }
2198
2199 #[test]
2203 fn validate_rejects_a_required_tool_the_stage_cannot_call() {
2204 let mut stage = Stage::new("plan".to_string(), make_model());
2205 stage.available_tools = vec!["read_file".to_string()];
2206 stage.required_tools = vec!["ask_user_text".to_string()];
2207 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2208
2209 let err = bp.validate().expect_err("a tool it cannot call");
2210 let text = format!("{err:?}");
2211 assert!(text.contains("ask_user_text"), "names the tool: {text}");
2212 assert!(text.contains("available_tools"), "says why: {text}");
2213 }
2214
2215 #[test]
2216 fn validate_accepts_a_required_tool_the_stage_offers() {
2217 let mut stage = Stage::new("plan".to_string(), make_model());
2218 stage.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
2219 stage.required_tools = vec!["ask_user_text".to_string()];
2220 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2221
2222 bp.validate().expect("the tool is on offer");
2223 }
2224
2225 #[test]
2226 fn test_transition_condition_equality() {
2227 assert_eq!(
2228 TransitionCondition::LlmChoice,
2229 TransitionCondition::LlmChoice
2230 );
2231 assert_ne!(TransitionCondition::Always, TransitionCondition::Error);
2232 }
2233
2234 #[test]
2235 fn test_edge_transform_compact_and_custom_equality() {
2236 let a = EdgeTransform::Compact {
2237 prompt: Some("p".to_string()),
2238 };
2239 let b = EdgeTransform::Compact {
2240 prompt: Some("p".to_string()),
2241 };
2242 assert_eq!(a, b);
2243
2244 let c1 = EdgeTransform::Custom {
2245 carry: vec!["a".to_string()],
2246 compact: vec!["b".to_string()],
2247 clear: vec!["c".to_string()],
2248 compact_prompt: Some("p".to_string()),
2249 };
2250 let c2 = c1.clone();
2251 assert_eq!(c1, c2);
2252
2253 assert_ne!(EdgeTransform::Direct, EdgeTransform::Clear);
2254 }
2255
2256 #[test]
2257 fn test_stage_accepts_messages_default_true() {
2258 let stage = Stage::new(
2259 "test".to_string(),
2260 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
2261 );
2262 assert!(stage.accepts_messages);
2263 }
2264
2265 #[test]
2266 fn test_stage_accepts_messages_serde_roundtrip() {
2267 let mut stage = Stage::new(
2269 "report".to_string(),
2270 ModelConfig::new("anthropic".to_string(), "claude-opus-4-6".to_string()),
2271 );
2272 stage.accepts_messages = false;
2273
2274 let json = serde_json::to_string(&stage).expect("should serialize");
2275 let deserialized: Stage = serde_json::from_str(&json).expect("should deserialize");
2276 assert!(!deserialized.accepts_messages);
2277 }
2278
2279 #[test]
2280 fn test_stage_accepts_messages_json_default() {
2281 let json = r#"{
2283 "name": "analyze",
2284 "model": { "provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {} },
2285 "available_tools": [],
2286 "mode": "Autonomous",
2287 "config": {},
2288 "tool_permissions": {},
2289 "requires_children": false
2290 }"#;
2291 let stage: Stage = serde_json::from_str(json).expect("should parse");
2292 assert!(stage.accepts_messages);
2293 }
2294
2295 #[test]
2296 fn test_has_terminal_path_unknown_stage_returns_false() {
2297 let stages = vec![Stage::new("start".to_string(), make_model())];
2301 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2302 let mut visited = std::collections::HashSet::new();
2303 assert!(!bp.has_terminal_path("nonexistent_stage", &mut visited));
2304 }
2305
2306 #[test]
2307 fn test_blueprint_validate_fails_when_layout_has_duplicate_region() {
2308 let regions = vec![
2309 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
2310 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
2311 ];
2312 let layout = ContextLayout::new(regions, 200);
2313 let stages = vec![Stage::new("start".to_string(), make_model())];
2314 let bp = Blueprint::new("t".into(), "d".into(), stages, layout);
2315 assert_eq!(
2316 bp.validate().unwrap_err(),
2317 ValidationError::Region {
2318 region: "dup".to_string(),
2319 message: "duplicate region name".to_string(),
2320 }
2321 );
2322 }
2323
2324 #[test]
2325 fn test_blueprint_validate_fails_when_stage_has_empty_name() {
2326 let stages = vec![Stage::new("".to_string(), make_model())];
2327 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2328 assert_eq!(
2329 bp.validate().unwrap_err(),
2330 ValidationError::Stage {
2331 stage: "(empty)".to_string(),
2332 message: "stage name cannot be empty".to_string(),
2333 }
2334 );
2335 }
2336
2337 #[test]
2338 fn test_file_tracking_config_defaults() {
2339 let json = r#"{"region": "files"}"#;
2340 let config: FileTrackingConfig = serde_json::from_str(json).unwrap();
2341 assert_eq!(config.region, "files");
2342 assert!(config.track_reads);
2343 assert!(config.track_writes);
2344 assert!(config.max_file_tokens.is_none());
2345 }
2346
2347 #[test]
2348 fn test_file_tracking_config_serde_roundtrip() {
2349 let config = FileTrackingConfig {
2350 region: "files".to_string(),
2351 track_reads: true,
2352 track_writes: false,
2353 max_file_tokens: Some(5000),
2354 };
2355 let json = serde_json::to_string(&config).unwrap();
2356 let back: FileTrackingConfig = serde_json::from_str(&json).unwrap();
2357 assert_eq!(back.region, "files");
2358 assert!(back.track_reads);
2359 assert!(!back.track_writes);
2360 assert_eq!(back.max_file_tokens, Some(5000));
2361 }
2362
2363 #[test]
2364 fn test_blueprint_file_tracking_default_none() {
2365 let stages = vec![Stage::new("plan".to_string(), make_model())];
2366 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2367 assert!(bp.file_tracking.is_none());
2368 }
2369
2370 #[test]
2371 fn test_blueprint_file_tracking_serde_roundtrip() {
2372 let stages = vec![Stage::new("plan".to_string(), make_model())];
2373 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2374 bp.file_tracking = Some(FileTrackingConfig {
2375 region: "files".to_string(),
2376 track_reads: true,
2377 track_writes: true,
2378 max_file_tokens: Some(3000),
2379 });
2380 let json = serde_json::to_string(&bp).unwrap();
2381 let back: Blueprint = serde_json::from_str(&json).unwrap();
2382 let ft = back.file_tracking.unwrap();
2383 assert_eq!(ft.region, "files");
2384 assert_eq!(ft.max_file_tokens, Some(3000));
2385 }
2386
2387 #[test]
2388 fn test_tool_result_routing_default() {
2389 let routing = ToolResultRouting::default();
2390 assert_eq!(routing.default_region, "tool_results");
2391 assert!(routing.persist);
2392 assert!(routing.tool_overrides.is_empty());
2393 assert!(routing.max_result_tokens.is_none());
2394 }
2395
2396 #[test]
2397 fn test_stage_new_has_no_tool_result_routing() {
2398 let stage = Stage::new("plan".to_string(), make_model());
2399 assert!(stage.tool_result_routing.is_none());
2400 }
2401
2402 #[test]
2403 fn test_tool_result_routing_serde_roundtrip() {
2404 let mut routing = ToolResultRouting {
2405 default_region: "custom_region".to_string(),
2406 persist: false,
2407 max_result_tokens: Some(4096),
2408 ..Default::default()
2409 };
2410 routing
2411 .tool_overrides
2412 .insert("read_file".to_string(), "file_reads".to_string());
2413
2414 let json = serde_json::to_string(&routing).unwrap();
2415 let back: ToolResultRouting = serde_json::from_str(&json).unwrap();
2416
2417 assert_eq!(back.default_region, "custom_region");
2418 assert!(!back.persist);
2419 assert_eq!(back.max_result_tokens, Some(4096));
2420 assert_eq!(
2421 back.tool_overrides.get("read_file").map(String::as_str),
2422 Some("file_reads")
2423 );
2424 }
2425
2426 #[test]
2427 fn test_stage_with_tool_result_routing_serde_roundtrip() {
2428 let stages = vec![{
2429 let mut s = Stage::new("plan".to_string(), make_model());
2430 s.tool_result_routing = Some(ToolResultRouting {
2431 default_region: "results".to_string(),
2432 tool_overrides: HashMap::new(),
2433 persist: true,
2434 max_result_tokens: Some(2048),
2435 });
2436 s
2437 }];
2438 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2439 let json = serde_json::to_string(&bp).unwrap();
2440 let back: Blueprint = serde_json::from_str(&json).unwrap();
2441
2442 let routing = back.stages[0]
2443 .tool_result_routing
2444 .as_ref()
2445 .expect("tool_result_routing should be Some");
2446 assert_eq!(routing.default_region, "results");
2447 assert!(routing.persist);
2448 assert_eq!(routing.max_result_tokens, Some(2048));
2449 assert!(routing.tool_overrides.is_empty());
2450 }
2451
2452 fn fanout_config() -> FanOutConfig {
2455 FanOutConfig {
2456 worker_agent: None,
2457 worker_stage: Some("fix_worker".to_string()),
2458 worker_query: None,
2459 merge_stage: Some("merge".to_string()),
2460 max_workers: 3,
2461 on_worker_failure: WorkerFailurePolicy::Continue,
2462 split_prompt: "split".to_string(),
2463 }
2464 }
2465
2466 fn fanout_blueprint(worker_allowed: bool, config: FanOutConfig) -> Blueprint {
2471 let mut fan = Stage::new("parallel".to_string(), make_model());
2472 fan.mode = StageMode::FanOut { config };
2473 let mut worker = Stage::new("fix_worker".to_string(), make_model());
2474 worker.allow_as_worker = worker_allowed;
2475 let mut merge = Stage::new("merge".to_string(), make_model());
2476 merge.transitions = Some(HashMap::new()); Blueprint::new(
2478 "t".into(),
2479 "d".into(),
2480 vec![fan, worker, merge],
2481 make_layout(),
2482 )
2483 }
2484
2485 #[test]
2486 fn fanout_stagemode_partial_eq_and_default_policy() {
2487 let a = StageMode::FanOut {
2488 config: fanout_config(),
2489 };
2490 let b = StageMode::FanOut {
2491 config: fanout_config(),
2492 };
2493 assert_eq!(a, b);
2494 let mut other = fanout_config();
2495 other.max_workers = 99;
2496 assert_ne!(a, StageMode::FanOut { config: other });
2497 assert_ne!(a, StageMode::Autonomous);
2498 assert_eq!(
2499 WorkerFailurePolicy::default(),
2500 WorkerFailurePolicy::Continue
2501 );
2502 }
2503
2504 #[test]
2505 fn fanout_config_serde_roundtrip_and_max_workers_default() {
2506 let toml = r#"
2507worker_agent = "fixer"
2508split_prompt = "go"
2509on_worker_failure = "fail_all"
2510"#;
2511 let cfg: FanOutConfig = toml::from_str(toml).unwrap();
2512 assert_eq!(cfg.worker_agent.as_deref(), Some("fixer"));
2513 assert_eq!(cfg.max_workers, 4); assert_eq!(cfg.on_worker_failure, WorkerFailurePolicy::FailAll);
2515 let json = serde_json::to_string(&fanout_config()).unwrap();
2517 let back: FanOutConfig = serde_json::from_str(&json).unwrap();
2518 assert_eq!(back, fanout_config());
2519 }
2520
2521 #[test]
2522 fn fanout_validate_ok_with_allowed_worker_stage() {
2523 assert!(fanout_blueprint(true, fanout_config()).validate().is_ok());
2524 }
2525
2526 #[test]
2527 fn fanout_validate_rejects_worker_stage_not_opted_in() {
2528 let err = fanout_blueprint(false, fanout_config())
2529 .validate()
2530 .unwrap_err();
2531 assert!(err.to_string().contains("allow_as_worker"));
2532 }
2533
2534 #[test]
2535 fn fanout_validate_rejects_missing_worker_stage() {
2536 let mut cfg = fanout_config();
2537 cfg.worker_stage = Some("nope".to_string());
2538 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2539 assert!(err.to_string().contains("does not exist"));
2540 }
2541
2542 #[test]
2543 fn fanout_validate_rejects_missing_merge_stage() {
2544 let mut cfg = fanout_config();
2545 cfg.merge_stage = Some("nomerge".to_string());
2546 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2547 assert!(err.to_string().contains("merge_stage"));
2548 }
2549
2550 #[test]
2551 fn fanout_validate_rejects_wrong_worker_source_count() {
2552 let mut cfg = fanout_config();
2554 cfg.worker_stage = None;
2555 assert!(fanout_blueprint(true, cfg).validate().is_err());
2556 let mut cfg2 = fanout_config();
2558 cfg2.worker_agent = Some("x".to_string()); assert!(fanout_blueprint(true, cfg2).validate().is_err());
2560 }
2561
2562 #[test]
2563 fn fanout_terminal_path_runs_through_merge_stage() {
2564 let mut cfg = fanout_config();
2566 cfg.worker_stage = None;
2567 cfg.worker_agent = Some("external".to_string());
2568 assert!(fanout_blueprint(false, cfg).validate().is_ok());
2569 }
2570
2571 #[test]
2572 fn fanout_validate_ok_without_merge_stage() {
2573 let mut cfg = fanout_config();
2576 cfg.merge_stage = None;
2577 assert!(fanout_blueprint(true, cfg).validate().is_ok());
2578 }
2579}