1use crate::value::VmDictExt;
4use std::collections::{BTreeMap, BTreeSet};
5
6use serde::{Deserialize, Serialize};
7
8use super::{
9 new_id, now_unix_seconds_text, redact_transcript_visibility, ArtifactRecord, AutoCompactPolicy,
10 BranchSemantics, CapabilityPolicy, ContextPolicy, EqIgnored, EscalationPolicy, JoinPolicy,
11 MapPolicy, ModelPolicy, ReducePolicy, RetryPolicy, StageContract,
12};
13use crate::llm::{extract_llm_options, vm_call_llm_full, vm_value_to_json};
14use crate::tool_surface::{tool_capability_policy_from_spec, tool_names_from_spec};
15use crate::value::{VmError, VmValue};
16
17pub const WORKFLOW_VERIFICATION_CONTRACTS_METADATA_KEY: &str = "workflow_verification_contracts";
18pub const WORKFLOW_VERIFICATION_SCOPE_METADATA_KEY: &str = "workflow_verification_scope";
19
20#[derive(Clone, Debug, Default, Serialize, Deserialize)]
21#[serde(default)]
22pub struct WorkflowNode {
23 pub id: Option<String>,
24 pub kind: String,
25 pub mode: Option<String>,
26 pub prompt: Option<String>,
27 pub system: Option<String>,
28 pub task_label: Option<String>,
29 pub done_sentinel: Option<String>,
30 pub tools: serde_json::Value,
31 pub model_policy: ModelPolicy,
32 pub auto_compact: AutoCompactPolicy,
37 #[serde(default)]
42 pub output_visibility: Option<String>,
43 pub context_policy: ContextPolicy,
44 pub retry_policy: RetryPolicy,
45 pub capability_policy: CapabilityPolicy,
46 pub approval_policy: super::ToolApprovalPolicy,
47 pub input_contract: StageContract,
48 pub output_contract: StageContract,
49 pub branch_semantics: BranchSemantics,
50 pub map_policy: MapPolicy,
51 pub join_policy: JoinPolicy,
52 pub reduce_policy: ReducePolicy,
53 pub escalation_policy: EscalationPolicy,
54 pub verify: Option<serde_json::Value>,
55 #[serde(default)]
60 pub exit_when_verified: bool,
61 pub metadata: BTreeMap<String, serde_json::Value>,
62 #[serde(skip)]
63 pub raw_tools: Option<VmValue>,
64 #[serde(skip)]
68 pub raw_auto_compact: Option<VmValue>,
69 #[serde(skip)]
72 pub raw_model_policy: Option<VmValue>,
73 #[serde(skip)]
78 pub raw_context_assembler: Option<VmValue>,
79 #[serde(skip)]
86 pub raw_verify: Option<VmValue>,
87 #[serde(skip)]
97 pub raw_executor: Option<VmValue>,
98}
99
100impl PartialEq for WorkflowNode {
101 fn eq(&self, other: &Self) -> bool {
102 serde_json::to_value(self).ok() == serde_json::to_value(other).ok()
103 }
104}
105
106#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
107#[serde(default)]
108pub struct VerificationRequirement {
109 pub kind: String,
110 pub value: String,
111 pub note: Option<String>,
112}
113
114#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
115#[serde(default)]
116pub struct VerificationContract {
117 pub source_node: Option<String>,
118 pub summary: Option<String>,
119 pub command: Option<String>,
120 pub expect_status: Option<i64>,
121 pub assert_text: Option<String>,
122 pub expect_text: Option<String>,
123 pub required_identifiers: Vec<String>,
124 pub required_paths: Vec<String>,
125 pub required_text: Vec<String>,
126 pub notes: Vec<String>,
127 pub checks: Vec<VerificationRequirement>,
128}
129
130impl VerificationContract {
131 fn is_empty(&self) -> bool {
132 self.summary.is_none()
133 && self.command.is_none()
134 && self.expect_status.is_none()
135 && self.assert_text.is_none()
136 && self.expect_text.is_none()
137 && self.required_identifiers.is_empty()
138 && self.required_paths.is_empty()
139 && self.required_text.is_empty()
140 && self.notes.is_empty()
141 && self.checks.is_empty()
142 }
143}
144
145fn push_unique_string(values: &mut Vec<String>, value: &str) {
146 let trimmed = value.trim();
147 if trimmed.is_empty() {
148 return;
149 }
150 if !values.iter().any(|existing| existing == trimmed) {
151 values.push(trimmed.to_string());
152 }
153}
154
155fn push_unique_requirement(
156 values: &mut Vec<VerificationRequirement>,
157 kind: &str,
158 value: &str,
159 note: Option<&str>,
160) {
161 let trimmed_kind = kind.trim();
162 let trimmed_value = value.trim();
163 let trimmed_note = note
164 .map(str::trim)
165 .filter(|candidate| !candidate.is_empty())
166 .map(|candidate| candidate.to_string());
167 if trimmed_kind.is_empty() || trimmed_value.is_empty() {
168 return;
169 }
170 let candidate = VerificationRequirement {
171 kind: trimmed_kind.to_string(),
172 value: trimmed_value.to_string(),
173 note: trimmed_note,
174 };
175 if !values.iter().any(|existing| existing == &candidate) {
176 values.push(candidate);
177 }
178}
179
180fn json_string_list(value: Option<&serde_json::Value>) -> Vec<String> {
181 match value {
182 Some(serde_json::Value::String(text)) => {
183 let mut values = Vec::new();
184 push_unique_string(&mut values, text);
185 values
186 }
187 Some(serde_json::Value::Array(items)) => {
188 let mut values = Vec::new();
189 for item in items {
190 if let Some(text) = item.as_str() {
191 push_unique_string(&mut values, text);
192 }
193 }
194 values
195 }
196 _ => Vec::new(),
197 }
198}
199
200fn merge_verification_requirement_list(
201 target: &mut Vec<VerificationRequirement>,
202 value: Option<&serde_json::Value>,
203) {
204 let Some(items) = value.and_then(|raw| raw.as_array()) else {
205 return;
206 };
207 for item in items {
208 let Some(object) = item.as_object() else {
209 continue;
210 };
211 let kind = object
212 .get("kind")
213 .and_then(|value| value.as_str())
214 .unwrap_or_default();
215 let value = object
216 .get("value")
217 .and_then(|value| value.as_str())
218 .unwrap_or_default();
219 let note = object
220 .get("note")
221 .or_else(|| object.get("description"))
222 .or_else(|| object.get("reason"))
223 .and_then(|value| value.as_str());
224 push_unique_requirement(target, kind, value, note);
225 }
226}
227
228fn merge_verification_contract_fields(
229 target: &mut VerificationContract,
230 object: &serde_json::Map<String, serde_json::Value>,
231) {
232 if target.summary.is_none() {
233 target.summary = object
234 .get("summary")
235 .and_then(|value| value.as_str())
236 .map(str::trim)
237 .filter(|value| !value.is_empty())
238 .map(|value| value.to_string());
239 }
240 if target.command.is_none() {
241 target.command = object
242 .get("command")
243 .and_then(|value| value.as_str())
244 .map(str::trim)
245 .filter(|value| !value.is_empty())
246 .map(|value| value.to_string());
247 }
248 if target.expect_status.is_none() {
249 target.expect_status = object.get("expect_status").and_then(|value| value.as_i64());
250 }
251 if target.assert_text.is_none() {
252 target.assert_text = object
253 .get("assert_text")
254 .and_then(|value| value.as_str())
255 .map(str::trim)
256 .filter(|value| !value.is_empty())
257 .map(|value| value.to_string());
258 }
259 if target.expect_text.is_none() {
260 target.expect_text = object
261 .get("expect_text")
262 .and_then(|value| value.as_str())
263 .map(str::trim)
264 .filter(|value| !value.is_empty())
265 .map(|value| value.to_string());
266 }
267
268 for value in json_string_list(
269 object
270 .get("required_identifiers")
271 .or_else(|| object.get("identifiers")),
272 ) {
273 push_unique_string(&mut target.required_identifiers, &value);
274 }
275 for value in json_string_list(object.get("required_paths").or_else(|| object.get("paths"))) {
276 push_unique_string(&mut target.required_paths, &value);
277 }
278 for value in json_string_list(
279 object
280 .get("required_text")
281 .or_else(|| object.get("exact_text"))
282 .or_else(|| object.get("required_strings")),
283 ) {
284 push_unique_string(&mut target.required_text, &value);
285 }
286 for value in json_string_list(object.get("notes")) {
287 push_unique_string(&mut target.notes, &value);
288 }
289 merge_verification_requirement_list(&mut target.checks, object.get("checks"));
290}
291
292fn load_verification_contract_file(path: &str) -> Result<serde_json::Value, VmError> {
293 let resolved = crate::stdlib::process::resolve_source_asset_path(path);
294 let contents = std::fs::read_to_string(&resolved).map_err(|error| {
295 VmError::Runtime(format!(
296 "workflow verification contract read failed for {}: {error}",
297 resolved.display()
298 ))
299 })?;
300 serde_json::from_str(&contents).map_err(|error| {
301 VmError::Runtime(format!(
302 "workflow verification contract parse failed for {}: {error}",
303 resolved.display()
304 ))
305 })
306}
307
308fn resolve_verification_contract_path(
309 verify: &serde_json::Map<String, serde_json::Value>,
310) -> Result<Option<serde_json::Value>, VmError> {
311 let Some(path) = verify
312 .get("contract_path")
313 .or_else(|| verify.get("verification_contract_path"))
314 .and_then(|value| value.as_str())
315 .map(str::trim)
316 .filter(|value| !value.is_empty())
317 else {
318 return Ok(None);
319 };
320 Ok(Some(load_verification_contract_file(path)?))
321}
322
323pub fn verification_contract_from_verify(
324 node_id: &str,
325 verify: Option<&serde_json::Value>,
326) -> Result<Option<VerificationContract>, VmError> {
327 let Some(verify_object) = verify.and_then(|value| value.as_object()) else {
328 return Ok(None);
329 };
330
331 let mut contract = VerificationContract {
332 source_node: Some(node_id.to_string()),
333 ..Default::default()
334 };
335
336 if let Some(file_contract) = resolve_verification_contract_path(verify_object)? {
337 let Some(object) = file_contract.as_object() else {
338 return Err(VmError::Runtime(
339 "workflow verification contract file must parse to a JSON object".to_string(),
340 ));
341 };
342 merge_verification_contract_fields(&mut contract, object);
343 }
344
345 if let Some(inline_contract) = verify_object.get("contract") {
346 let Some(object) = inline_contract.as_object() else {
347 return Err(VmError::Runtime(
348 "workflow verify.contract must be an object".to_string(),
349 ));
350 };
351 merge_verification_contract_fields(&mut contract, object);
352 }
353
354 merge_verification_contract_fields(&mut contract, verify_object);
355
356 if let Some(assert_text) = contract.assert_text.clone() {
357 push_unique_requirement(
358 &mut contract.checks,
359 "visible_text_contains",
360 &assert_text,
361 Some("verify stage requires visible output to contain this text"),
362 );
363 }
364 if let Some(expect_text) = contract.expect_text.clone() {
365 push_unique_requirement(
366 &mut contract.checks,
367 "combined_output_contains",
368 &expect_text,
369 Some("verify command requires combined stdout/stderr to contain this text"),
370 );
371 }
372 if let Some(expect_status) = contract.expect_status {
373 push_unique_requirement(
374 &mut contract.checks,
375 "expect_status",
376 &expect_status.to_string(),
377 Some("verify command exit status must match exactly"),
378 );
379 }
380 for identifier in contract.required_identifiers.clone() {
381 push_unique_requirement(
382 &mut contract.checks,
383 "identifier",
384 &identifier,
385 Some("use this exact identifier spelling"),
386 );
387 }
388 for path in contract.required_paths.clone() {
389 push_unique_requirement(
390 &mut contract.checks,
391 "path",
392 &path,
393 Some("preserve this exact path"),
394 );
395 }
396 for text in contract.required_text.clone() {
397 push_unique_requirement(
398 &mut contract.checks,
399 "text",
400 &text,
401 Some("required exact text or wiring snippet"),
402 );
403 }
404
405 if contract.is_empty() {
406 return Ok(None);
407 }
408 Ok(Some(contract))
409}
410
411fn push_unique_contract(values: &mut Vec<VerificationContract>, candidate: VerificationContract) {
412 if !values.iter().any(|existing| existing == &candidate) {
413 values.push(candidate);
414 }
415}
416
417pub fn workflow_verification_contracts(
418 graph: &WorkflowGraph,
419) -> Result<Vec<VerificationContract>, VmError> {
420 let mut contracts = Vec::new();
421 for (node_id, node) in &graph.nodes {
422 if let Some(contract) = verification_contract_from_verify(node_id, node.verify.as_ref())? {
423 push_unique_contract(&mut contracts, contract);
424 }
425 }
426 Ok(contracts)
427}
428
429pub fn inject_workflow_verification_contracts(
430 node: &mut WorkflowNode,
431 contracts: &[VerificationContract],
432) {
433 if contracts.is_empty() {
434 return;
435 }
436 node.metadata.insert(
437 WORKFLOW_VERIFICATION_CONTRACTS_METADATA_KEY.to_string(),
438 serde_json::to_value(contracts).unwrap_or_default(),
439 );
440}
441
442pub fn stage_verification_contracts(
443 node_id: &str,
444 node: &WorkflowNode,
445) -> Result<Vec<VerificationContract>, VmError> {
446 let local_contract = verification_contract_from_verify(node_id, node.verify.as_ref())?;
447 let local_only = matches!(
448 node.metadata
449 .get(WORKFLOW_VERIFICATION_SCOPE_METADATA_KEY)
450 .and_then(|value| value.as_str()),
451 Some("local_only")
452 );
453 if local_only {
454 return Ok(local_contract.into_iter().collect());
455 }
456
457 let mut contracts = node
458 .metadata
459 .get(WORKFLOW_VERIFICATION_CONTRACTS_METADATA_KEY)
460 .cloned()
461 .map(|value| {
462 serde_json::from_value::<Vec<VerificationContract>>(value).map_err(|error| {
463 VmError::Runtime(format!(
464 "workflow stage {node_id} verification contract metadata parse failed: {error}"
465 ))
466 })
467 })
468 .transpose()?
469 .unwrap_or_default();
470
471 if let Some(local_contract) = local_contract {
472 push_unique_contract(&mut contracts, local_contract);
473 }
474 Ok(contracts)
475}
476
477#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
478#[serde(default)]
479pub struct WorkflowEdge {
480 pub from: String,
481 pub to: String,
482 pub branch: Option<String>,
483 pub label: Option<String>,
484}
485
486#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
487#[serde(default)]
488pub struct WorkflowGraph {
489 #[serde(rename = "_type")]
490 pub type_name: String,
491 pub id: String,
492 pub name: Option<String>,
493 pub version: usize,
494 pub entry: String,
495 pub nodes: BTreeMap<String, WorkflowNode>,
496 pub edges: Vec<WorkflowEdge>,
497 pub capability_policy: CapabilityPolicy,
498 pub approval_policy: super::ToolApprovalPolicy,
499 pub metadata: BTreeMap<String, serde_json::Value>,
500 pub audit_log: Vec<WorkflowAuditEntry>,
501}
502
503#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
504#[serde(default)]
505pub struct WorkflowAuditEntry {
506 pub id: String,
507 pub op: String,
508 pub node_id: Option<String>,
509 pub timestamp: String,
510 pub reason: Option<String>,
511 pub metadata: BTreeMap<String, serde_json::Value>,
512}
513
514#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
515#[serde(default)]
516pub struct WorkflowValidationReport {
517 pub valid: bool,
518 pub errors: Vec<String>,
519 pub warnings: Vec<String>,
520 pub reachable_nodes: Vec<String>,
521}
522
523fn retry_repair_prompt_builder_from_dict(
529 dict: Option<&crate::value::DictMap>,
530) -> Option<EqIgnored<VmValue>> {
531 dict.and_then(|d| d.get("retry_policy"))
532 .and_then(|policy| policy.as_dict())
533 .and_then(|policy| policy.get("repair_prompt_builder"))
534 .filter(|value| !matches!(value, VmValue::Nil))
535 .cloned()
536 .map(EqIgnored)
537}
538
539pub fn parse_workflow_node_value(value: &VmValue, label: &str) -> Result<WorkflowNode, VmError> {
540 let mut node: WorkflowNode = super::parse_json_payload(vm_value_to_json(value), label)?;
541 let dict = value.as_dict();
542 node.raw_tools = dict.and_then(|d| d.get("tools")).cloned();
543 node.raw_auto_compact = dict.and_then(|d| d.get("auto_compact")).cloned();
544 node.raw_model_policy = dict.and_then(|d| d.get("model_policy")).cloned();
545 node.raw_context_assembler = dict.and_then(|d| d.get("context_assembler")).cloned();
546 node.raw_verify = dict
549 .and_then(|d| d.get("verify"))
550 .filter(|value| {
551 matches!(
552 value,
553 VmValue::Closure(_) | VmValue::BuiltinRef(_) | VmValue::BuiltinRefId(_)
554 )
555 })
556 .cloned();
557 node.raw_executor = dict
560 .and_then(|d| d.get("executor"))
561 .filter(|value| {
562 matches!(
563 value,
564 VmValue::Closure(_) | VmValue::BuiltinRef(_) | VmValue::BuiltinRefId(_)
565 )
566 })
567 .cloned();
568 node.retry_policy.repair_prompt_builder = retry_repair_prompt_builder_from_dict(dict);
569 Ok(node)
570}
571
572pub fn parse_workflow_node_json(
573 json: serde_json::Value,
574 label: &str,
575) -> Result<WorkflowNode, VmError> {
576 super::parse_json_payload(json, label)
577}
578
579pub fn parse_workflow_edge_json(
580 json: serde_json::Value,
581 label: &str,
582) -> Result<WorkflowEdge, VmError> {
583 super::parse_json_payload(json, label)
584}
585
586pub fn normalize_workflow_value(value: &VmValue) -> Result<WorkflowGraph, VmError> {
587 let mut graph: WorkflowGraph = super::parse_json_value(value)?;
588 let as_dict = value.as_dict().cloned().unwrap_or_default();
589
590 if graph.nodes.is_empty() {
591 for key in ["act", "verify", "repair"] {
592 if let Some(node_value) = as_dict.get(key) {
593 let mut node = parse_workflow_node_value(node_value, "orchestration")?;
594 let raw_node = node_value.as_dict().cloned().unwrap_or_default();
595 node.id = Some(key.to_string());
596 if node.kind.is_empty() {
597 node.kind = if key == "verify" {
598 "verify".to_string()
599 } else {
600 "stage".to_string()
601 };
602 }
603 if node.model_policy.provider.is_none() {
604 node.model_policy.provider = as_dict
605 .get("provider")
606 .map(|value| value.display())
607 .filter(|value| !value.is_empty());
608 }
609 if node.model_policy.model.is_none() {
610 node.model_policy.model = as_dict
611 .get("model")
612 .map(|value| value.display())
613 .filter(|value| !value.is_empty());
614 }
615 if node.model_policy.model_tier.is_none() {
616 node.model_policy.model_tier = as_dict
617 .get("model_tier")
618 .or_else(|| as_dict.get("tier"))
619 .map(|value| value.display())
620 .filter(|value| !value.is_empty());
621 }
622 if node.model_policy.temperature.is_none() {
623 node.model_policy.temperature = as_dict.get("temperature").and_then(|value| {
624 if let VmValue::Float(number) = value {
625 Some(*number)
626 } else {
627 value.as_int().map(|number| number as f64)
628 }
629 });
630 }
631 if node.model_policy.max_tokens.is_none() {
632 node.model_policy.max_tokens =
633 as_dict.get("max_tokens").and_then(|value| value.as_int());
634 }
635 if node.mode.is_none() {
636 node.mode = as_dict
637 .get("mode")
638 .map(|value| value.display())
639 .filter(|value| !value.is_empty());
640 }
641 if node.done_sentinel.is_none() {
642 node.done_sentinel = as_dict
643 .get("done_sentinel")
644 .map(|value| value.display())
645 .filter(|value| !value.is_empty());
646 }
647 if key == "verify"
648 && node.verify.is_none()
649 && (raw_node.contains_key("assert_text")
650 || raw_node.contains_key("command")
651 || raw_node.contains_key("expect_status")
652 || raw_node.contains_key("expect_text"))
653 {
654 node.verify = Some(serde_json::json!({
655 "assert_text": raw_node.get("assert_text").map(vm_value_to_json),
656 "command": raw_node.get("command").map(vm_value_to_json),
657 "expect_status": raw_node.get("expect_status").map(vm_value_to_json),
658 "expect_text": raw_node.get("expect_text").map(vm_value_to_json),
659 }));
660 }
661 graph.nodes.insert(key.to_string(), node);
662 }
663 }
664 if graph.entry.is_empty() && graph.nodes.contains_key("act") {
665 graph.entry = "act".to_string();
666 }
667 if graph.edges.is_empty() && graph.nodes.contains_key("act") {
668 if graph.nodes.contains_key("verify") {
669 graph.edges.push(WorkflowEdge {
670 from: "act".to_string(),
671 to: "verify".to_string(),
672 branch: None,
673 label: None,
674 });
675 }
676 if graph.nodes.contains_key("repair") {
677 graph.edges.push(WorkflowEdge {
678 from: "verify".to_string(),
679 to: "repair".to_string(),
680 branch: Some("failed".to_string()),
681 label: None,
682 });
683 graph.edges.push(WorkflowEdge {
684 from: "repair".to_string(),
685 to: "verify".to_string(),
686 branch: Some("retry".to_string()),
687 label: None,
688 });
689 }
690 }
691 }
692
693 if graph.type_name.is_empty() {
694 graph.type_name = "workflow_graph".to_string();
695 }
696 if graph.id.is_empty() {
697 graph.id = new_id("workflow");
698 }
699 if graph.version == 0 {
700 graph.version = 1;
701 }
702 if graph.entry.is_empty() {
703 graph.entry = graph
704 .nodes
705 .keys()
706 .next()
707 .cloned()
708 .unwrap_or_else(|| "act".to_string());
709 }
710 for (node_id, node) in &mut graph.nodes {
711 let raw_node = as_dict
712 .get("nodes")
713 .and_then(|nodes| nodes.as_dict())
714 .and_then(|nodes| nodes.get(node_id.as_str()))
715 .and_then(|node_value| node_value.as_dict());
716 if node.raw_tools.is_none() {
717 node.raw_tools = raw_node.and_then(|raw_node| raw_node.get("tools")).cloned();
718 }
719 if node.raw_verify.is_none() {
720 node.raw_verify = raw_node
723 .and_then(|raw_node| raw_node.get("verify"))
724 .filter(|value| {
725 matches!(
726 value,
727 VmValue::Closure(_) | VmValue::BuiltinRef(_) | VmValue::BuiltinRefId(_)
728 )
729 })
730 .cloned();
731 }
732 if node.raw_executor.is_none() {
733 node.raw_executor = raw_node
736 .and_then(|raw_node| raw_node.get("executor"))
737 .filter(|value| {
738 matches!(
739 value,
740 VmValue::Closure(_) | VmValue::BuiltinRef(_) | VmValue::BuiltinRefId(_)
741 )
742 })
743 .cloned();
744 }
745 if node.retry_policy.repair_prompt_builder.is_none() {
746 node.retry_policy.repair_prompt_builder =
747 retry_repair_prompt_builder_from_dict(raw_node);
748 }
749 if node.id.is_none() {
750 node.id = Some(node_id.clone());
751 }
752 if node.kind.is_empty() {
753 node.kind = "stage".to_string();
754 }
755 if node.join_policy.strategy.is_empty() {
756 node.join_policy.strategy = "all".to_string();
757 }
758 if node.reduce_policy.strategy.is_empty() {
759 node.reduce_policy.strategy = "concat".to_string();
760 }
761 if node.output_contract.output_kinds.is_empty() {
762 node.output_contract.output_kinds = vec![match node.kind.as_str() {
763 "verify" => "verification_result".to_string(),
764 "reduce" => node
765 .reduce_policy
766 .output_kind
767 .clone()
768 .unwrap_or_else(|| "summary".to_string()),
769 "map" => node
770 .map_policy
771 .output_kind
772 .clone()
773 .unwrap_or_else(|| "artifact".to_string()),
774 "escalation" => "plan".to_string(),
775 _ => "artifact".to_string(),
776 }];
777 }
778 if node.retry_policy.max_attempts == 0 {
779 node.retry_policy.max_attempts = 1;
780 }
781 }
782 Ok(graph)
783}
784
785pub fn validate_workflow(
786 graph: &WorkflowGraph,
787 ceiling: Option<&CapabilityPolicy>,
788) -> WorkflowValidationReport {
789 let mut errors = Vec::new();
790 let mut warnings = Vec::new();
791
792 if !graph.nodes.contains_key(&graph.entry) {
793 errors.push(format!("entry node does not exist: {}", graph.entry));
794 }
795
796 let node_ids: BTreeSet<String> = graph.nodes.keys().cloned().collect();
797 for edge in &graph.edges {
798 if !node_ids.contains(&edge.from) {
799 errors.push(format!("edge.from references unknown node: {}", edge.from));
800 }
801 if !node_ids.contains(&edge.to) {
802 errors.push(format!("edge.to references unknown node: {}", edge.to));
803 }
804 }
805
806 let reachable_nodes = reachable_nodes(graph);
807 for node_id in &node_ids {
808 if !reachable_nodes.contains(node_id) {
809 warnings.push(format!("node is unreachable: {node_id}"));
810 }
811 }
812
813 for (node_id, node) in &graph.nodes {
814 let incoming = graph
815 .edges
816 .iter()
817 .filter(|edge| edge.to == *node_id)
818 .count();
819 let outgoing: Vec<&WorkflowEdge> = graph
820 .edges
821 .iter()
822 .filter(|edge| edge.from == *node_id)
823 .collect();
824 if let Some(min_inputs) = node.input_contract.min_inputs {
825 if let Some(max_inputs) = node.input_contract.max_inputs {
826 if min_inputs > max_inputs {
827 errors.push(format!(
828 "node {node_id}: input contract min_inputs exceeds max_inputs"
829 ));
830 }
831 }
832 }
833 match node.kind.as_str() {
834 "condition" => {
835 let has_true = outgoing
836 .iter()
837 .any(|edge| edge.branch.as_deref() == Some("true"));
838 let has_false = outgoing
839 .iter()
840 .any(|edge| edge.branch.as_deref() == Some("false"));
841 if !has_true || !has_false {
842 errors.push(format!(
843 "node {node_id}: condition nodes require both 'true' and 'false' branch edges"
844 ));
845 }
846 }
847 "fork" if outgoing.len() < 2 => {
848 errors.push(format!(
849 "node {node_id}: fork nodes require at least two outgoing edges"
850 ));
851 }
852 "join" if incoming < 2 => {
853 warnings.push(format!(
854 "node {node_id}: join node has fewer than two incoming edges"
855 ));
856 }
857 "map"
858 if node.map_policy.items.is_empty()
859 && node.map_policy.item_artifact_kind.is_none()
860 && node.input_contract.input_kinds.is_empty() =>
861 {
862 errors.push(format!(
863 "node {node_id}: map nodes require items, item_artifact_kind, or input_contract.input_kinds"
864 ));
865 }
866 "reduce" if node.input_contract.input_kinds.is_empty() => {
867 warnings.push(format!(
868 "node {node_id}: reduce node has no input_contract.input_kinds; it will consume all available artifacts"
869 ));
870 }
871 _ => {}
872 }
873 }
874
875 if let Some(ceiling) = ceiling {
876 if let Err(error) = ceiling.intersect(&graph.capability_policy) {
877 errors.push(error);
878 }
879 for (node_id, node) in &graph.nodes {
880 if let Err(error) = ceiling.intersect(&node.capability_policy) {
881 errors.push(format!("node {node_id}: {error}"));
882 }
883 }
884 }
885
886 for diagnostic in crate::tool_surface::validate_workflow_graph(graph) {
887 let message = format!("{}: {}", diagnostic.code, diagnostic.message);
888 match diagnostic.severity {
889 crate::tool_surface::ToolSurfaceSeverity::Error => errors.push(message),
890 crate::tool_surface::ToolSurfaceSeverity::Warning => warnings.push(message),
891 }
892 }
893
894 WorkflowValidationReport {
895 valid: errors.is_empty(),
896 errors,
897 warnings,
898 reachable_nodes: reachable_nodes.into_iter().collect(),
899 }
900}
901
902fn reachable_nodes(graph: &WorkflowGraph) -> BTreeSet<String> {
903 let mut seen = BTreeSet::new();
904 let mut stack = vec![graph.entry.clone()];
905 while let Some(node_id) = stack.pop() {
906 if !seen.insert(node_id.clone()) {
907 continue;
908 }
909 for edge in graph.edges.iter().filter(|edge| edge.from == node_id) {
910 stack.push(edge.to.clone());
911 }
912 }
913 seen
914}
915
916fn resolve_node_session_id(node: &WorkflowNode) -> String {
922 if let Some(explicit) = node
923 .raw_model_policy
924 .as_ref()
925 .and_then(|v| v.as_dict())
926 .and_then(|d| d.get("session_id"))
927 .and_then(|v| match v {
928 VmValue::String(s) if !s.trim().is_empty() => Some(s.to_string()),
929 _ => None,
930 })
931 {
932 return explicit;
933 }
934 if let Some(persisted) = node
935 .metadata
936 .get("worker_session_id")
937 .and_then(|value| value.as_str())
938 .filter(|value| !value.trim().is_empty())
939 {
940 return persisted.to_string();
941 }
942 format!("workflow_stage_{}", uuid::Uuid::now_v7())
943}
944
945fn raw_model_policy_dict(node: &WorkflowNode) -> Option<&crate::value::DictMap> {
946 node.raw_model_policy
947 .as_ref()
948 .and_then(|value| value.as_dict())
949}
950
951fn insert_json_vm_option<T: Serialize>(
952 options: &mut crate::value::DictMap,
953 key: &str,
954 value: &T,
955) -> Result<(), VmError> {
956 let json = serde_json::to_value(value).map_err(|error| {
957 VmError::Runtime(format!("workflow stage option encode error: {error}"))
958 })?;
959 options.insert(
960 crate::value::intern_key(key),
961 crate::stdlib::json_to_vm_value(&json),
962 );
963 Ok(())
964}
965
966fn stage_tools_value(node: &WorkflowNode) -> Option<VmValue> {
967 node.raw_tools.clone().or_else(|| {
968 if matches!(node.tools, serde_json::Value::Null) {
969 None
970 } else {
971 Some(crate::stdlib::json_to_vm_value(&node.tools))
972 }
973 })
974}
975
976fn add_stage_tools_option(
977 options: &mut crate::value::DictMap,
978 tools_value: &Option<VmValue>,
979 tool_names: &[String],
980) {
981 if !tool_names.is_empty() {
982 if let Some(value) = tools_value.clone() {
983 options.insert(crate::value::intern_key("tools"), value);
984 }
985 }
986}
987
988fn workflow_stage_llm_options(
989 node: &WorkflowNode,
990 stage_session_id: &str,
991 tools_value: &Option<VmValue>,
992 tool_names: &[String],
993 stage_agent_options: &super::WorkflowStageAgentOptions,
994) -> Result<crate::value::DictMap, VmError> {
995 let mut options = stage_agent_options.llm_options_vm_dict();
996 if let Some(raw) = raw_model_policy_dict(node) {
997 for (key, value) in crate::llm::helpers::project_llm_options(raw)? {
998 if !matches!(value, VmValue::Nil) {
999 options.insert(key, value);
1000 }
1001 }
1002 }
1003 options.put_str("session_id", stage_session_id);
1004 options.put_str("tool_format", stage_agent_options.tool_format.clone());
1005 add_stage_tools_option(&mut options, tools_value, tool_names);
1006 Ok(options)
1007}
1008
1009async fn workflow_stage_agent_loop_options(
1020 ctx: &crate::vm::AsyncBuiltinCtx,
1021 node: &WorkflowNode,
1022 stage_session_id: &str,
1023 tools_value: &Option<VmValue>,
1024 tool_names: &[String],
1025 stage_agent_options: &super::WorkflowStageAgentOptions,
1026) -> Result<crate::value::DictMap, VmError> {
1027 let tool_policy = tool_capability_policy_from_spec(&node.tools);
1030 let effective_policy = tool_policy
1031 .intersect(&node.capability_policy)
1032 .map_err(VmError::Runtime)?;
1033
1034 let stage_label = node
1035 .id
1036 .clone()
1037 .unwrap_or_else(|| stage_session_id.to_string());
1038
1039 let mut config = crate::value::DictMap::new();
1040 config.insert(
1041 crate::value::intern_key("base"),
1042 VmValue::dict(stage_agent_options.agent_loop_options_vm_dict()),
1043 );
1044 config.insert(
1045 crate::value::intern_key("raw_model_policy"),
1046 node.raw_model_policy.clone().unwrap_or(VmValue::Nil),
1047 );
1048 insert_json_vm_option(&mut config, "auto_compact", &node.auto_compact)?;
1049 config.insert(
1050 crate::value::intern_key("raw_auto_compact"),
1051 node.raw_auto_compact.clone().unwrap_or(VmValue::Nil),
1052 );
1053 config.insert(
1056 crate::value::intern_key("tools"),
1057 if tool_names.is_empty() {
1058 VmValue::Nil
1059 } else {
1060 tools_value.clone().unwrap_or(VmValue::Nil)
1061 },
1062 );
1063 if let Some(context) = crate::orchestration::current_workflow_skill_context() {
1064 if let Some(registry) = context.registry {
1065 config.insert(crate::value::intern_key("skills"), registry);
1066 }
1067 if let Some(match_config) = context.match_config {
1068 config.insert(crate::value::intern_key("skill_match"), match_config);
1069 }
1070 }
1071 insert_json_vm_option(&mut config, "policy", &effective_policy)?;
1072 insert_json_vm_option(&mut config, "approval_policy", &node.approval_policy)?;
1073 config.put_str("session_id", stage_session_id);
1074 config.put_str("tool_format", stage_agent_options.tool_format.clone());
1075 config.put_str(
1076 "nested_kind",
1077 crate::orchestration::NestedExecutionKind::WorkflowStage.as_str(),
1078 );
1079 config.put_str("nested_label", stage_label);
1080
1081 let flattened = crate::stdlib::harn_entry::call_harn_export_by_name(
1082 ctx,
1083 "std/workflow/stage",
1084 "workflow_flatten_agent_loop_options",
1085 "workflow_flatten_agent_loop_options",
1086 &[VmValue::dict(config)],
1087 )
1088 .await?;
1089 let VmValue::Dict(options) = flattened else {
1090 return Err(VmError::Runtime(
1091 "workflow_flatten_agent_loop_options must return a dict".to_string(),
1092 ));
1093 };
1094 let options = (*options).clone();
1095 enforce_flattened_ceiling(&options, &effective_policy)?;
1096 Ok(options)
1097}
1098
1099fn enforce_flattened_ceiling(
1106 options: &crate::value::DictMap,
1107 ceiling: &CapabilityPolicy,
1108) -> Result<(), VmError> {
1109 let Some(policy_value) = options.get("policy") else {
1110 return Err(VmError::Runtime(
1111 "flattened stage options are missing the capability policy".to_string(),
1112 ));
1113 };
1114 let requested: CapabilityPolicy = serde_json::from_value(vm_value_to_json(policy_value))
1115 .map_err(|error| {
1116 VmError::Runtime(format!(
1117 "flattened stage capability policy is malformed: {error}"
1118 ))
1119 })?;
1120 ceiling
1121 .assert_within_ceiling(&requested)
1122 .map_err(|message| VmError::CategorizedError {
1123 message,
1124 category: crate::value::ErrorCategory::ToolRejected,
1125 })
1126}
1127
1128#[derive(Clone, Debug)]
1129pub struct PreparedWorkflowStageNode {
1130 pub prompt: String,
1131 pub system: Option<String>,
1132 pub run_agent_loop: bool,
1133 pub llm_options: crate::value::DictMap,
1134 pub agent_loop_options: crate::value::DictMap,
1135 pub result: Option<serde_json::Value>,
1136 pub selected: Vec<ArtifactRecord>,
1137 pub rendered_context: String,
1138 pub rendered_verification: String,
1139 pub verification_contracts: Vec<VerificationContract>,
1140 pub tool_format: String,
1141 pub stage_session_id: String,
1142}
1143
1144pub async fn prepare_stage_node(
1145 ctx: &crate::vm::AsyncBuiltinCtx,
1146 node_id: &str,
1147 node: &WorkflowNode,
1148 task: &str,
1149 artifacts: &[ArtifactRecord],
1150) -> Result<PreparedWorkflowStageNode, VmError> {
1151 let selected_stage = super::select_workflow_stage_artifacts(
1152 ctx,
1153 artifacts,
1154 &node.context_policy,
1155 &node.input_contract,
1156 )
1157 .await?;
1158 let selected = selected_stage.artifacts;
1159 let context_policy = selected_stage.context_policy;
1160 let rendered_context_override = if let Some(assembler) = node.raw_context_assembler.as_ref() {
1161 let assembled =
1162 crate::stdlib::assemble::assemble_from_options(ctx, &selected, assembler).await?;
1163 Some(super::render_assembled_chunks(&assembled))
1164 } else {
1165 None
1166 };
1167 let verification_contracts = super::stage_verification_contracts(node_id, node)?;
1168 let stage_session_id = resolve_node_session_id(node);
1169 if node.input_contract.require_transcript && !crate::agent_sessions::exists(&stage_session_id) {
1170 return Err(VmError::Runtime(format!(
1171 "workflow stage {node_id} requires an existing session \
1172 (call agent_session_open and feed session_id through model_policy \
1173 before entering this stage)"
1174 )));
1175 }
1176 if let Some(min_inputs) = node.input_contract.min_inputs {
1177 if selected.len() < min_inputs {
1178 return Err(VmError::Runtime(format!(
1179 "workflow stage {node_id} requires at least {min_inputs} input artifacts"
1180 )));
1181 }
1182 }
1183 if let Some(max_inputs) = node.input_contract.max_inputs {
1184 if selected.len() > max_inputs {
1185 return Err(VmError::Runtime(format!(
1186 "workflow stage {node_id} accepts at most {max_inputs} input artifacts"
1187 )));
1188 }
1189 }
1190 let prepared_prompt = super::prepare_workflow_stage_prompt(
1191 ctx,
1192 task,
1193 node.task_label.as_deref(),
1194 &selected,
1195 &context_policy,
1196 rendered_context_override.as_deref(),
1197 &verification_contracts,
1198 )
1199 .await?;
1200 let prompt = prepared_prompt.prompt;
1201 let rendered_context = prepared_prompt.rendered_context;
1202 let rendered_verification = prepared_prompt.rendered_verification;
1203
1204 let tool_names = tool_names_from_spec(&node.tools);
1205 let stage_agent_options = super::prepare_workflow_stage_agent_options(
1206 ctx,
1207 node,
1208 &stage_session_id,
1209 !tool_names.is_empty(),
1210 )
1211 .await?;
1212 let tool_format = stage_agent_options.tool_format.clone();
1213 let result = if node.kind == "verify" {
1214 if let Some(command) = node
1215 .verify
1216 .as_ref()
1217 .and_then(|verify| verify.as_object())
1218 .and_then(|verify| verify.get("command"))
1219 .and_then(|value| value.as_str())
1220 .map(str::trim)
1221 .filter(|value| !value.is_empty())
1222 {
1223 let (program, args) = if cfg!(target_os = "windows") {
1224 ("cmd", vec!["/C".to_string(), command.to_string()])
1225 } else {
1226 ("/bin/sh", vec!["-c".to_string(), command.to_string()])
1230 };
1231 let mut process_config = crate::stdlib::sandbox::ProcessCommandConfig {
1232 stdin_null: true,
1233 ..Default::default()
1234 };
1235 if let Some(context) = crate::stdlib::process::current_execution_context() {
1236 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
1237 crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
1238 process_config.cwd = Some(std::path::PathBuf::from(cwd));
1239 }
1240 if !context.env.is_empty() {
1241 process_config.env.extend(context.env);
1242 }
1243 }
1244 let output = crate::stdlib::sandbox::command_output(program, &args, &process_config)?;
1245 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1246 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1247 let combined = if stderr.is_empty() {
1248 stdout.clone()
1249 } else if stdout.is_empty() {
1250 stderr.clone()
1251 } else {
1252 format!("{stdout}\n{stderr}")
1253 };
1254 serde_json::json!({
1255 "status": "completed",
1256 "text": combined,
1257 "visible_text": combined,
1258 "command": command,
1259 "stdout": stdout,
1260 "stderr": stderr,
1261 "exit_status": output.status.code().unwrap_or(-1),
1262 "success": output.status.success(),
1263 })
1264 } else {
1265 serde_json::json!({
1266 "status": "completed",
1267 "text": "",
1268 "visible_text": "",
1269 })
1270 }
1271 } else {
1272 let tools_value = stage_tools_value(node);
1273 let llm_options = workflow_stage_llm_options(
1274 node,
1275 &stage_session_id,
1276 &tools_value,
1277 &tool_names,
1278 &stage_agent_options,
1279 )?;
1280 let agent_loop_options = if stage_agent_options.run_agent_loop {
1281 workflow_stage_agent_loop_options(
1282 ctx,
1283 node,
1284 &stage_session_id,
1285 &tools_value,
1286 &tool_names,
1287 &stage_agent_options,
1288 )
1289 .await?
1290 } else {
1291 crate::value::DictMap::new()
1292 };
1293 return Ok(PreparedWorkflowStageNode {
1294 prompt,
1295 system: node.system.clone(),
1296 run_agent_loop: stage_agent_options.run_agent_loop,
1297 llm_options,
1298 agent_loop_options,
1299 result: None,
1300 selected,
1301 rendered_context,
1302 rendered_verification,
1303 verification_contracts,
1304 tool_format,
1305 stage_session_id,
1306 });
1307 };
1308
1309 Ok(PreparedWorkflowStageNode {
1310 prompt,
1311 system: node.system.clone(),
1312 run_agent_loop: false,
1313 llm_options: crate::value::DictMap::new(),
1314 agent_loop_options: crate::value::DictMap::new(),
1315 result: Some(result),
1316 selected,
1317 rendered_context,
1318 rendered_verification,
1319 verification_contracts,
1320 tool_format,
1321 stage_session_id,
1322 })
1323}
1324
1325pub fn complete_prepared_stage_node(
1326 node_id: &str,
1327 node: &WorkflowNode,
1328 prepared: &PreparedWorkflowStageNode,
1329 mut llm_result: serde_json::Value,
1330) -> Result<(serde_json::Value, Vec<ArtifactRecord>, Option<VmValue>), VmError> {
1331 if let Some(payload) = llm_result.as_object_mut() {
1332 payload.insert(
1333 "prompt".to_string(),
1334 serde_json::json!(prepared.prompt.clone()),
1335 );
1336 payload.insert(
1337 "system_prompt".to_string(),
1338 serde_json::json!(node.system.clone().unwrap_or_default()),
1339 );
1340 payload.insert(
1341 "rendered_context".to_string(),
1342 serde_json::json!(prepared.rendered_context.clone()),
1343 );
1344 if !prepared.verification_contracts.is_empty() {
1345 payload.insert(
1346 "verification_contracts".to_string(),
1347 serde_json::to_value(&prepared.verification_contracts).unwrap_or_default(),
1348 );
1349 payload.insert(
1350 "rendered_verification_context".to_string(),
1351 serde_json::json!(prepared.rendered_verification.clone()),
1352 );
1353 }
1354 payload.insert(
1355 "selected_artifact_ids".to_string(),
1356 serde_json::json!(prepared
1357 .selected
1358 .iter()
1359 .map(|artifact| artifact.id.clone())
1360 .collect::<Vec<_>>()),
1361 );
1362 payload.insert(
1363 "selected_artifact_titles".to_string(),
1364 serde_json::json!(prepared
1365 .selected
1366 .iter()
1367 .map(|artifact| artifact.title.clone())
1368 .collect::<Vec<_>>()),
1369 );
1370 match payload
1371 .entry("tools".to_string())
1372 .or_insert_with(|| serde_json::json!({}))
1373 {
1374 serde_json::Value::Object(tools) => {
1375 tools.insert(
1376 "mode".to_string(),
1377 serde_json::json!(prepared.tool_format.clone()),
1378 );
1379 }
1380 slot => {
1381 *slot = serde_json::json!({ "mode": prepared.tool_format.clone() });
1382 }
1383 }
1384 }
1385
1386 let visible_text = llm_result["text"].as_str().unwrap_or_default().to_string();
1387 let result_transcript = llm_result
1391 .get("transcript")
1392 .cloned()
1393 .map(|value| crate::stdlib::json_to_vm_value(&value));
1394 let session_transcript = crate::agent_sessions::snapshot(&prepared.stage_session_id);
1395 let transcript = result_transcript
1396 .or(session_transcript)
1397 .and_then(|value| redact_transcript_visibility(&value, node.output_visibility.as_deref()));
1398 let output_kind = node
1399 .output_contract
1400 .output_kinds
1401 .first()
1402 .cloned()
1403 .unwrap_or_else(|| {
1404 if node.kind == "verify" {
1405 "verification_result".to_string()
1406 } else {
1407 "artifact".to_string()
1408 }
1409 });
1410 let mut metadata = BTreeMap::new();
1411 metadata.insert(
1412 "input_artifact_ids".to_string(),
1413 serde_json::json!(prepared
1414 .selected
1415 .iter()
1416 .map(|artifact| artifact.id.clone())
1417 .collect::<Vec<_>>()),
1418 );
1419 metadata.insert("node_kind".to_string(), serde_json::json!(node.kind));
1420 if !node.approval_policy.write_path_allowlist.is_empty() {
1421 metadata.insert(
1422 "changed_paths".to_string(),
1423 serde_json::json!(node.approval_policy.write_path_allowlist),
1424 );
1425 }
1426 let artifact = ArtifactRecord {
1427 type_name: "artifact".to_string(),
1428 id: new_id("artifact"),
1429 kind: output_kind,
1430 title: Some(format!("stage {node_id} output")),
1431 text: Some(visible_text),
1432 data: Some(llm_result.clone()),
1433 source: Some(node_id.to_string()),
1434 created_at: now_unix_seconds_text(),
1435 freshness: Some("fresh".to_string()),
1436 priority: None,
1437 lineage: prepared
1438 .selected
1439 .iter()
1440 .map(|artifact| artifact.id.clone())
1441 .collect(),
1442 relevance: Some(1.0),
1443 estimated_tokens: None,
1444 stage: Some(node_id.to_string()),
1445 metadata,
1446 }
1447 .normalize();
1448
1449 Ok((llm_result, vec![artifact], transcript))
1450}
1451
1452pub async fn execute_stage_node(
1453 ctx: &crate::vm::AsyncBuiltinCtx,
1454 node_id: &str,
1455 node: &WorkflowNode,
1456 task: &str,
1457 artifacts: &[ArtifactRecord],
1458) -> Result<(serde_json::Value, Vec<ArtifactRecord>, Option<VmValue>), VmError> {
1459 let prepared = prepare_stage_node(ctx, node_id, node, task, artifacts).await?;
1460 let llm_result = if let Some(result) = prepared.result.clone() {
1461 result
1462 } else if prepared.run_agent_loop {
1463 let result = crate::stdlib::harn_entry::call_agent_loop(
1464 ctx,
1465 prepared.prompt.clone(),
1466 prepared.system.clone(),
1467 prepared.agent_loop_options.clone(),
1468 )
1469 .await?;
1470 crate::llm::vm_value_to_json(&result)
1471 } else {
1472 let args = vec![
1473 VmValue::String(arcstr::ArcStr::from(prepared.prompt.clone())),
1474 prepared
1475 .system
1476 .clone()
1477 .map(|s| VmValue::String(arcstr::ArcStr::from(s)))
1478 .unwrap_or(VmValue::Nil),
1479 VmValue::dict(prepared.llm_options.clone()),
1480 ];
1481 let opts = extract_llm_options(&args)?;
1482 let result = vm_call_llm_full(&opts).await?;
1483 crate::llm::agent_loop_result_from_llm(&result, opts)
1484 };
1485 complete_prepared_stage_node(node_id, node, &prepared, llm_result)
1486}
1487
1488pub fn append_audit_entry(
1489 graph: &mut WorkflowGraph,
1490 op: &str,
1491 node_id: Option<String>,
1492 reason: Option<String>,
1493 metadata: BTreeMap<String, serde_json::Value>,
1494) {
1495 graph.audit_log.push(WorkflowAuditEntry {
1496 id: new_id("audit"),
1497 op: op.to_string(),
1498 node_id,
1499 timestamp: now_unix_seconds_text(),
1500 reason,
1501 metadata,
1502 });
1503}
1504
1505#[cfg(test)]
1506mod flatten_tests {
1507 use super::*;
1508 use crate::orchestration::{CapabilityPolicy, WorkflowNode};
1509 use std::collections::BTreeMap;
1510
1511 fn ceiling_with_tools(tools: &[&str]) -> CapabilityPolicy {
1512 CapabilityPolicy {
1513 tools: tools.iter().map(|t| t.to_string()).collect(),
1514 ..Default::default()
1515 }
1516 }
1517
1518 fn options_with_policy(policy: &CapabilityPolicy) -> crate::value::DictMap {
1519 let mut options = crate::value::DictMap::new();
1520 insert_json_vm_option(&mut options, "policy", policy).unwrap();
1521 options
1522 }
1523
1524 #[test]
1525 fn ceiling_pass_through_is_within() {
1526 let ceiling = ceiling_with_tools(&["read", "edit"]);
1527 assert!(ceiling.assert_within_ceiling(&ceiling).is_ok());
1529 let options = options_with_policy(&ceiling);
1530 assert!(enforce_flattened_ceiling(&options, &ceiling).is_ok());
1531 }
1532
1533 #[test]
1534 fn narrowing_is_allowed() {
1535 let ceiling = ceiling_with_tools(&["read", "edit", "run_command"]);
1536 let narrowed = ceiling_with_tools(&["read"]);
1537 assert!(ceiling.assert_within_ceiling(&narrowed).is_ok());
1538 }
1539
1540 #[test]
1541 fn widening_tools_is_rejected() {
1542 let ceiling = ceiling_with_tools(&["read"]);
1543 let widened = ceiling_with_tools(&["read", "run_command"]);
1544 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1545 assert!(
1546 err.contains("run_command"),
1547 "error names the widened tool: {err}"
1548 );
1549
1550 let options = options_with_policy(&widened);
1552 match enforce_flattened_ceiling(&options, &ceiling) {
1553 Err(VmError::CategorizedError { message, category }) => {
1554 assert_eq!(category, crate::value::ErrorCategory::ToolRejected);
1555 assert!(message.contains("run_command"), "message: {message}");
1556 }
1557 other => panic!("expected a ToolRejected error, got {other:?}"),
1558 }
1559 }
1560
1561 #[test]
1562 fn widening_capability_op_is_rejected() {
1563 let mut ceiling = CapabilityPolicy::default();
1564 ceiling
1565 .capabilities
1566 .insert("fs".to_string(), vec!["read".to_string()]);
1567 let mut widened = CapabilityPolicy::default();
1568 widened.capabilities.insert(
1569 "fs".to_string(),
1570 vec!["read".to_string(), "write".to_string()],
1571 );
1572 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1573 assert!(err.contains("fs") && err.contains("write"), "error: {err}");
1574 }
1575
1576 #[test]
1577 fn adding_new_capability_is_rejected() {
1578 let mut ceiling = CapabilityPolicy::default();
1579 ceiling
1580 .capabilities
1581 .insert("fs".to_string(), vec!["read".to_string()]);
1582 let mut widened = ceiling.clone();
1583 widened
1584 .capabilities
1585 .insert("net".to_string(), vec!["connect".to_string()]);
1586 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1587 assert!(
1588 err.contains("net"),
1589 "error names the added capability: {err}"
1590 );
1591 }
1592
1593 #[test]
1594 fn widening_recursion_budget_is_rejected() {
1595 let ceiling = CapabilityPolicy {
1596 recursion_limit: Some(2),
1597 ..Default::default()
1598 };
1599 let widened = CapabilityPolicy {
1600 recursion_limit: Some(9),
1601 ..Default::default()
1602 };
1603 assert!(ceiling.assert_within_ceiling(&widened).is_err());
1604 let dropped = CapabilityPolicy::default();
1606 assert!(ceiling.assert_within_ceiling(&dropped).is_err());
1607 let narrowed = CapabilityPolicy {
1609 recursion_limit: Some(1),
1610 ..Default::default()
1611 };
1612 assert!(ceiling.assert_within_ceiling(&narrowed).is_ok());
1613 }
1614
1615 #[test]
1616 fn widening_roots_is_rejected() {
1617 let ceiling = CapabilityPolicy {
1618 workspace_roots: vec!["/repo".to_string()],
1619 ..Default::default()
1620 };
1621 let widened = CapabilityPolicy {
1622 workspace_roots: vec!["/repo".to_string(), "/etc".to_string()],
1623 ..Default::default()
1624 };
1625 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1626 assert!(err.contains("/etc"), "error: {err}");
1627 }
1628
1629 #[test]
1630 fn widening_side_effect_level_is_rejected() {
1631 let ceiling = CapabilityPolicy {
1632 side_effect_level: Some("read_only".to_string()),
1633 ..Default::default()
1634 };
1635 let widened = CapabilityPolicy {
1636 side_effect_level: Some("network".to_string()),
1637 ..Default::default()
1638 };
1639 assert!(ceiling.assert_within_ceiling(&widened).is_err());
1640 }
1641
1642 #[test]
1643 fn unknown_side_effect_level_ranks_fail_closed() {
1644 let ceiling = CapabilityPolicy {
1648 side_effect_level: Some("none".to_string()),
1649 ..Default::default()
1650 };
1651 let widened = CapabilityPolicy {
1652 side_effect_level: Some("desktop_control".to_string()),
1653 ..Default::default()
1654 };
1655 assert!(ceiling.assert_within_ceiling(&widened).is_err());
1656 let unknown = CapabilityPolicy {
1659 side_effect_level: Some("teleport".to_string()),
1660 ..Default::default()
1661 };
1662 assert!(ceiling.assert_within_ceiling(&unknown).is_ok());
1663 }
1664
1665 #[test]
1666 fn widening_process_sandbox_roots_is_rejected() {
1667 use crate::orchestration::ProcessSandboxPolicy;
1668 let ceiling = CapabilityPolicy {
1669 process_sandbox: ProcessSandboxPolicy {
1670 write_roots: vec!["/repo/.cache".to_string()],
1671 ..Default::default()
1672 },
1673 ..Default::default()
1674 };
1675 let widened = CapabilityPolicy {
1676 process_sandbox: ProcessSandboxPolicy {
1677 write_roots: vec!["/repo/.cache".to_string(), "/etc".to_string()],
1678 ..Default::default()
1679 },
1680 ..Default::default()
1681 };
1682 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1683 assert!(
1684 err.contains("process_sandbox.write_roots") && err.contains("/etc"),
1685 "error: {err}"
1686 );
1687 assert!(ceiling
1689 .assert_within_ceiling(&CapabilityPolicy::default())
1690 .is_ok());
1691 }
1692
1693 #[test]
1694 fn injecting_process_sandbox_roots_into_empty_ceiling_is_rejected() {
1695 use crate::orchestration::ProcessSandboxPolicy;
1696 let ceiling = CapabilityPolicy::default();
1701 for (field, requested) in [
1702 (
1703 "process_sandbox.read_roots",
1704 CapabilityPolicy {
1705 process_sandbox: ProcessSandboxPolicy {
1706 read_roots: vec!["/etc".to_string()],
1707 ..Default::default()
1708 },
1709 ..Default::default()
1710 },
1711 ),
1712 (
1713 "process_sandbox.write_roots",
1714 CapabilityPolicy {
1715 process_sandbox: ProcessSandboxPolicy {
1716 write_roots: vec!["/etc".to_string()],
1717 ..Default::default()
1718 },
1719 ..Default::default()
1720 },
1721 ),
1722 ] {
1723 let err = ceiling.assert_within_ceiling(&requested).unwrap_err();
1724 assert!(
1725 err.contains(field) && err.contains("/etc"),
1726 "empty ceiling must reject injected {field}: {err}"
1727 );
1728 }
1729 assert!(ceiling
1731 .assert_within_ceiling(&CapabilityPolicy::default())
1732 .is_ok());
1733 }
1734
1735 #[test]
1736 fn widening_process_sandbox_presets_is_rejected() {
1737 use crate::orchestration::{ProcessSandboxPolicy, ProcessSandboxPreset};
1738 let ceiling = CapabilityPolicy {
1739 process_sandbox: ProcessSandboxPolicy {
1740 presets: Some(vec![ProcessSandboxPreset::SystemRuntime]),
1741 ..Default::default()
1742 },
1743 ..Default::default()
1744 };
1745 let widened = CapabilityPolicy {
1746 process_sandbox: ProcessSandboxPolicy {
1747 presets: Some(vec![
1748 ProcessSandboxPreset::SystemRuntime,
1749 ProcessSandboxPreset::DeveloperToolchains,
1750 ]),
1751 ..Default::default()
1752 },
1753 ..Default::default()
1754 };
1755 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1756 assert!(err.contains("process_sandbox presets"), "error: {err}");
1757 }
1758
1759 #[test]
1760 fn dropping_tool_arg_constraint_is_rejected() {
1761 use crate::orchestration::ToolArgConstraint;
1762 let constraint = ToolArgConstraint {
1763 tool: "edit".to_string(),
1764 arg_patterns: vec!["src/**".to_string()],
1765 arg_key: Some("path".to_string()),
1766 };
1767 let ceiling = CapabilityPolicy {
1768 tool_arg_constraints: vec![constraint],
1769 ..Default::default()
1770 };
1771 let widened = CapabilityPolicy::default();
1773 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1774 assert!(
1775 err.contains("tool_arg_constraints") && err.contains("edit"),
1776 "error: {err}"
1777 );
1778 let mut narrowed = ceiling.clone();
1780 narrowed.tool_arg_constraints.push(ToolArgConstraint {
1781 tool: "run_command".to_string(),
1782 arg_patterns: vec!["cargo *".to_string()],
1783 arg_key: None,
1784 });
1785 assert!(ceiling.assert_within_ceiling(&narrowed).is_ok());
1786 }
1787
1788 #[test]
1789 fn weakening_tool_annotation_is_rejected() {
1790 use crate::tool_annotations::{SideEffectLevel, ToolAnnotations, ToolArgSchema};
1791 let strong = ToolAnnotations {
1792 side_effect_level: SideEffectLevel::ReadOnly,
1793 arg_schema: ToolArgSchema {
1794 path_params: vec!["path".to_string()],
1795 ..Default::default()
1796 },
1797 ..Default::default()
1798 };
1799 let mut ceiling = CapabilityPolicy {
1800 tools: vec!["edit".to_string(), "read".to_string()],
1801 ..Default::default()
1802 };
1803 ceiling
1804 .tool_annotations
1805 .insert("edit".to_string(), strong.clone());
1806
1807 let mut dropped = ceiling.clone();
1810 dropped.tool_annotations.clear();
1811 let err = ceiling.assert_within_ceiling(&dropped).unwrap_err();
1812 assert!(
1813 err.contains("tool_annotations") && err.contains("edit"),
1814 "error: {err}"
1815 );
1816
1817 let mut rewritten = ceiling.clone();
1819 rewritten.tool_annotations.insert(
1820 "edit".to_string(),
1821 ToolAnnotations {
1822 side_effect_level: SideEffectLevel::None,
1823 ..strong
1824 },
1825 );
1826 assert!(ceiling.assert_within_ceiling(&rewritten).is_err());
1827
1828 let narrowed_tools = CapabilityPolicy {
1831 tools: vec!["read".to_string()],
1832 ..Default::default()
1833 };
1834 assert!(ceiling.assert_within_ceiling(&narrowed_tools).is_ok());
1835 }
1836
1837 fn legacy_flatten_reference(
1842 node: &WorkflowNode,
1843 session_id: &str,
1844 tool_format: &str,
1845 mut options: crate::value::DictMap,
1846 tools_value: &Option<VmValue>,
1847 tool_names: &[String],
1848 ) -> crate::value::DictMap {
1849 if let Some(raw) = node.raw_model_policy.as_ref().and_then(|v| v.as_dict()) {
1850 for (key, value) in raw {
1851 if !matches!(value, VmValue::Nil) {
1852 options.insert(key.clone(), value.clone());
1853 }
1854 }
1855 }
1856 if !options.contains_key("command_policy") {
1857 if let Some(command_policy) = node
1858 .raw_model_policy
1859 .as_ref()
1860 .and_then(|v| v.as_dict())
1861 .and_then(|d| d.get("policy"))
1862 .and_then(|v| v.as_dict())
1863 .and_then(|p| p.get("command_policy"))
1864 {
1865 options.insert(
1866 crate::value::intern_key("command_policy"),
1867 command_policy.clone(),
1868 );
1869 }
1870 }
1871 if !node.auto_compact.enabled {
1872 options.insert(
1873 crate::value::intern_key("auto_compact"),
1874 VmValue::Bool(false),
1875 );
1876 } else {
1877 options.insert(
1878 crate::value::intern_key("auto_compact"),
1879 VmValue::Bool(true),
1880 );
1881 if let Some(v) = node.auto_compact.token_threshold {
1882 options.insert(
1883 crate::value::intern_key("compact_threshold"),
1884 VmValue::Int(v as i64),
1885 );
1886 }
1887 if let Some(v) = node.auto_compact.tool_output_max_chars {
1888 options.insert(
1889 crate::value::intern_key("tool_output_max_chars"),
1890 VmValue::Int(v as i64),
1891 );
1892 }
1893 if let Some(v) = node.auto_compact.hard_limit_tokens {
1894 options.insert(
1895 crate::value::intern_key("hard_limit_tokens"),
1896 VmValue::Int(v as i64),
1897 );
1898 }
1899 if let Some(s) = node.auto_compact.compact_strategy.as_ref() {
1900 options.put_str("compact_strategy", s.clone());
1901 }
1902 if let Some(s) = node.auto_compact.hard_limit_strategy.as_ref() {
1903 options.put_str("hard_limit_strategy", s.clone());
1904 }
1905 let raw = node.raw_auto_compact.as_ref().and_then(|v| v.as_dict());
1906 let keep = raw
1907 .and_then(|d| d.get("compact_keep_last"))
1908 .and_then(|v| v.as_int())
1909 .filter(|v| *v >= 0)
1910 .or_else(|| {
1911 raw.and_then(|d| d.get("keep_last"))
1912 .and_then(|v| v.as_int())
1913 .filter(|v| *v >= 0)
1914 });
1915 if let Some(v) = keep {
1916 options.insert(
1917 crate::value::intern_key("compact_keep_last"),
1918 VmValue::Int(v),
1919 );
1920 }
1921 if let Some(p) = raw
1922 .and_then(|d| d.get("summarize_prompt"))
1923 .and_then(|v| match v {
1924 VmValue::String(t) if !t.trim().is_empty() => Some(t.to_string()),
1925 _ => None,
1926 })
1927 {
1928 options.put_str("summarize_prompt", p);
1929 }
1930 if let Some(d) = raw {
1931 for key in ["compress_callback", "mask_callback"] {
1932 if let Some(cb) = d.get(key) {
1933 options.insert(crate::value::intern_key(key), cb.clone());
1934 }
1935 }
1936 if let Some(cb) = d.get("custom_compactor") {
1937 options.insert(crate::value::intern_key("compact_callback"), cb.clone());
1938 }
1939 }
1940 }
1941 if !tool_names.is_empty() {
1942 if let Some(v) = tools_value.clone() {
1943 options.insert(crate::value::intern_key("tools"), v);
1944 }
1945 }
1946 let tool_policy = tool_capability_policy_from_spec(&node.tools);
1947 let effective = tool_policy.intersect(&node.capability_policy).unwrap();
1948 insert_json_vm_option(&mut options, "policy", &effective).unwrap();
1949 insert_json_vm_option(&mut options, "approval_policy", &node.approval_policy).unwrap();
1950 options.put_str("session_id", session_id);
1951 options.put_str("tool_format", tool_format);
1952 let label = node.id.clone().unwrap_or_else(|| session_id.to_string());
1953 crate::orchestration::annotate_nested_execution_options(
1954 &mut options,
1955 crate::orchestration::NestedExecutionKind::WorkflowStage,
1956 &label,
1957 );
1958 options
1959 }
1960
1961 fn representative_node() -> WorkflowNode {
1962 let mut raw_model_policy = BTreeMap::new();
1963 raw_model_policy.insert(
1964 "provider".to_string(),
1965 VmValue::String(arcstr::ArcStr::from("anthropic")),
1966 );
1967 raw_model_policy.insert("temperature".to_string(), VmValue::Float(0.2));
1968 let mut nested_policy = BTreeMap::new();
1970 nested_policy.insert(
1971 "command_policy".to_string(),
1972 VmValue::String(arcstr::ArcStr::from("worktree")),
1973 );
1974 raw_model_policy.insert("policy".to_string(), VmValue::dict(nested_policy));
1975 raw_model_policy.insert("nudge".to_string(), VmValue::Nil);
1977
1978 let mut raw_auto_compact = BTreeMap::new();
1979 raw_auto_compact.insert("keep_last".to_string(), VmValue::Int(4));
1980 raw_auto_compact.insert(
1981 "summarize_prompt".to_string(),
1982 VmValue::String(arcstr::ArcStr::from("summarize tersely")),
1983 );
1984
1985 WorkflowNode {
1986 id: Some("act".to_string()),
1987 kind: "stage".to_string(),
1988 mode: Some("agent".to_string()),
1989 tools: serde_json::json!(["read", "edit"]),
1990 auto_compact: crate::orchestration::AutoCompactPolicy {
1991 enabled: true,
1992 token_threshold: Some(8000),
1993 tool_output_max_chars: Some(2000),
1994 hard_limit_tokens: Some(20000),
1995 compact_strategy: Some("summary".to_string()),
1996 hard_limit_strategy: Some("truncate".to_string()),
1997 },
1998 capability_policy: CapabilityPolicy {
1999 tools: vec!["read".to_string(), "edit".to_string()],
2000 recursion_limit: Some(3),
2001 ..Default::default()
2002 },
2003 raw_model_policy: Some(VmValue::dict(raw_model_policy)),
2004 raw_auto_compact: Some(VmValue::dict(raw_auto_compact)),
2005 ..Default::default()
2006 }
2007 }
2008
2009 #[tokio::test(flavor = "current_thread", start_paused = true)]
2010 async fn flatten_matches_pre_move_rust() {
2011 crate::reset_thread_local_state();
2012 let node = representative_node();
2013 let session_id = "session-parity";
2014 let tool_format = "text";
2015 let tool_names = vec!["read".to_string(), "edit".to_string()];
2016 let tools_value = Some(crate::stdlib::json_to_vm_value(&node.tools));
2017
2018 let mut base = crate::value::DictMap::new();
2020 base.insert(
2021 crate::value::intern_key("loop_until_done"),
2022 VmValue::Bool(true),
2023 );
2024 base.insert(crate::value::intern_key("max_iterations"), VmValue::Int(16));
2025
2026 let stage_agent_options = super::super::WorkflowStageAgentOptions {
2027 run_agent_loop: true,
2028 tool_format: tool_format.to_string(),
2029 llm_options: BTreeMap::new(),
2030 agent_loop_options: base
2031 .iter()
2032 .map(|(k, v)| (k.to_string(), vm_value_to_json(v)))
2033 .collect(),
2034 };
2035
2036 let mut vm = crate::Vm::new();
2037 crate::register_vm_stdlib(&mut vm);
2038 let ctx = crate::vm::AsyncBuiltinCtx::for_test(vm);
2039
2040 let flattened = workflow_stage_agent_loop_options(
2041 &ctx,
2042 &node,
2043 session_id,
2044 &tools_value,
2045 &tool_names,
2046 &stage_agent_options,
2047 )
2048 .await
2049 .expect("harn flatten succeeds");
2050
2051 let expected = legacy_flatten_reference(
2052 &node,
2053 session_id,
2054 tool_format,
2055 base,
2056 &tools_value,
2057 &tool_names,
2058 );
2059
2060 let flattened_json = vm_value_to_json(&VmValue::dict(flattened));
2061 let expected_json = vm_value_to_json(&VmValue::dict(expected));
2062 assert_eq!(
2063 flattened_json, expected_json,
2064 "Harn flatten must be dict-equal to the pre-move Rust flatten"
2065 );
2066 }
2067}