1use crate::value::VmDictExt;
4use std::collections::{BTreeMap, BTreeSet};
5
6use serde::{Deserialize, Serialize};
7
8use super::{
9 new_id, now_rfc3339, 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 merge_raw_model_policy_options(options: &mut crate::value::DictMap, node: &WorkflowNode) {
967 if let Some(raw) = raw_model_policy_dict(node) {
968 for (key, value) in raw {
969 if !matches!(value, VmValue::Nil) {
970 options.insert(key.clone(), value.clone());
971 }
972 }
973 }
974}
975
976fn stage_tools_value(node: &WorkflowNode) -> Option<VmValue> {
977 node.raw_tools.clone().or_else(|| {
978 if matches!(node.tools, serde_json::Value::Null) {
979 None
980 } else {
981 Some(crate::stdlib::json_to_vm_value(&node.tools))
982 }
983 })
984}
985
986fn add_stage_tools_option(
987 options: &mut crate::value::DictMap,
988 tools_value: &Option<VmValue>,
989 tool_names: &[String],
990) {
991 if !tool_names.is_empty() {
992 if let Some(value) = tools_value.clone() {
993 options.insert(crate::value::intern_key("tools"), value);
994 }
995 }
996}
997
998fn workflow_stage_llm_options(
999 node: &WorkflowNode,
1000 stage_session_id: &str,
1001 tools_value: &Option<VmValue>,
1002 tool_names: &[String],
1003 stage_agent_options: &super::WorkflowStageAgentOptions,
1004) -> crate::value::DictMap {
1005 let mut options = stage_agent_options.llm_options_vm_dict();
1006 merge_raw_model_policy_options(&mut options, node);
1007 options.put_str("session_id", stage_session_id);
1008 options.put_str("tool_format", stage_agent_options.tool_format.clone());
1009 add_stage_tools_option(&mut options, tools_value, tool_names);
1010 options
1011}
1012
1013async fn workflow_stage_agent_loop_options(
1024 ctx: &crate::vm::AsyncBuiltinCtx,
1025 node: &WorkflowNode,
1026 stage_session_id: &str,
1027 tools_value: &Option<VmValue>,
1028 tool_names: &[String],
1029 stage_agent_options: &super::WorkflowStageAgentOptions,
1030) -> Result<crate::value::DictMap, VmError> {
1031 let tool_policy = tool_capability_policy_from_spec(&node.tools);
1034 let effective_policy = tool_policy
1035 .intersect(&node.capability_policy)
1036 .map_err(VmError::Runtime)?;
1037
1038 let stage_label = node
1039 .id
1040 .clone()
1041 .unwrap_or_else(|| stage_session_id.to_string());
1042
1043 let mut config = crate::value::DictMap::new();
1044 config.insert(
1045 crate::value::intern_key("base"),
1046 VmValue::dict(stage_agent_options.agent_loop_options_vm_dict()),
1047 );
1048 config.insert(
1049 crate::value::intern_key("raw_model_policy"),
1050 node.raw_model_policy.clone().unwrap_or(VmValue::Nil),
1051 );
1052 insert_json_vm_option(&mut config, "auto_compact", &node.auto_compact)?;
1053 config.insert(
1054 crate::value::intern_key("raw_auto_compact"),
1055 node.raw_auto_compact.clone().unwrap_or(VmValue::Nil),
1056 );
1057 config.insert(
1060 crate::value::intern_key("tools"),
1061 if tool_names.is_empty() {
1062 VmValue::Nil
1063 } else {
1064 tools_value.clone().unwrap_or(VmValue::Nil)
1065 },
1066 );
1067 if let Some(context) = crate::orchestration::current_workflow_skill_context() {
1068 if let Some(registry) = context.registry {
1069 config.insert(crate::value::intern_key("skills"), registry);
1070 }
1071 if let Some(match_config) = context.match_config {
1072 config.insert(crate::value::intern_key("skill_match"), match_config);
1073 }
1074 }
1075 insert_json_vm_option(&mut config, "policy", &effective_policy)?;
1076 insert_json_vm_option(&mut config, "approval_policy", &node.approval_policy)?;
1077 config.put_str("session_id", stage_session_id);
1078 config.put_str("tool_format", stage_agent_options.tool_format.clone());
1079 config.put_str(
1080 "nested_kind",
1081 crate::orchestration::NestedExecutionKind::WorkflowStage.as_str(),
1082 );
1083 config.put_str("nested_label", stage_label);
1084
1085 let flattened = crate::stdlib::harn_entry::call_harn_export_by_name(
1086 ctx,
1087 "std/workflow/stage",
1088 "workflow_flatten_agent_loop_options",
1089 "workflow_flatten_agent_loop_options",
1090 &[VmValue::dict(config)],
1091 )
1092 .await?;
1093 let VmValue::Dict(options) = flattened else {
1094 return Err(VmError::Runtime(
1095 "workflow_flatten_agent_loop_options must return a dict".to_string(),
1096 ));
1097 };
1098 let options = (*options).clone();
1099 enforce_flattened_ceiling(&options, &effective_policy)?;
1100 Ok(options)
1101}
1102
1103fn enforce_flattened_ceiling(
1110 options: &crate::value::DictMap,
1111 ceiling: &CapabilityPolicy,
1112) -> Result<(), VmError> {
1113 let Some(policy_value) = options.get("policy") else {
1114 return Err(VmError::Runtime(
1115 "flattened stage options are missing the capability policy".to_string(),
1116 ));
1117 };
1118 let requested: CapabilityPolicy = serde_json::from_value(vm_value_to_json(policy_value))
1119 .map_err(|error| {
1120 VmError::Runtime(format!(
1121 "flattened stage capability policy is malformed: {error}"
1122 ))
1123 })?;
1124 ceiling
1125 .assert_within_ceiling(&requested)
1126 .map_err(|message| VmError::CategorizedError {
1127 message,
1128 category: crate::value::ErrorCategory::ToolRejected,
1129 })
1130}
1131
1132#[derive(Clone, Debug)]
1133pub struct PreparedWorkflowStageNode {
1134 pub prompt: String,
1135 pub system: Option<String>,
1136 pub run_agent_loop: bool,
1137 pub llm_options: crate::value::DictMap,
1138 pub agent_loop_options: crate::value::DictMap,
1139 pub result: Option<serde_json::Value>,
1140 pub selected: Vec<ArtifactRecord>,
1141 pub rendered_context: String,
1142 pub rendered_verification: String,
1143 pub verification_contracts: Vec<VerificationContract>,
1144 pub tool_format: String,
1145 pub stage_session_id: String,
1146}
1147
1148pub async fn prepare_stage_node(
1149 ctx: &crate::vm::AsyncBuiltinCtx,
1150 node_id: &str,
1151 node: &WorkflowNode,
1152 task: &str,
1153 artifacts: &[ArtifactRecord],
1154) -> Result<PreparedWorkflowStageNode, VmError> {
1155 let selected_stage = super::select_workflow_stage_artifacts(
1156 ctx,
1157 artifacts,
1158 &node.context_policy,
1159 &node.input_contract,
1160 )
1161 .await?;
1162 let selected = selected_stage.artifacts;
1163 let context_policy = selected_stage.context_policy;
1164 let rendered_context_override = if let Some(assembler) = node.raw_context_assembler.as_ref() {
1165 let assembled =
1166 crate::stdlib::assemble::assemble_from_options(ctx, &selected, assembler).await?;
1167 Some(super::render_assembled_chunks(&assembled))
1168 } else {
1169 None
1170 };
1171 let verification_contracts = super::stage_verification_contracts(node_id, node)?;
1172 let stage_session_id = resolve_node_session_id(node);
1173 if node.input_contract.require_transcript && !crate::agent_sessions::exists(&stage_session_id) {
1174 return Err(VmError::Runtime(format!(
1175 "workflow stage {node_id} requires an existing session \
1176 (call agent_session_open and feed session_id through model_policy \
1177 before entering this stage)"
1178 )));
1179 }
1180 if let Some(min_inputs) = node.input_contract.min_inputs {
1181 if selected.len() < min_inputs {
1182 return Err(VmError::Runtime(format!(
1183 "workflow stage {node_id} requires at least {min_inputs} input artifacts"
1184 )));
1185 }
1186 }
1187 if let Some(max_inputs) = node.input_contract.max_inputs {
1188 if selected.len() > max_inputs {
1189 return Err(VmError::Runtime(format!(
1190 "workflow stage {node_id} accepts at most {max_inputs} input artifacts"
1191 )));
1192 }
1193 }
1194 let prepared_prompt = super::prepare_workflow_stage_prompt(
1195 ctx,
1196 task,
1197 node.task_label.as_deref(),
1198 &selected,
1199 &context_policy,
1200 rendered_context_override.as_deref(),
1201 &verification_contracts,
1202 )
1203 .await?;
1204 let prompt = prepared_prompt.prompt;
1205 let rendered_context = prepared_prompt.rendered_context;
1206 let rendered_verification = prepared_prompt.rendered_verification;
1207
1208 let tool_names = tool_names_from_spec(&node.tools);
1209 let stage_agent_options = super::prepare_workflow_stage_agent_options(
1210 ctx,
1211 node,
1212 &stage_session_id,
1213 !tool_names.is_empty(),
1214 )
1215 .await?;
1216 let tool_format = stage_agent_options.tool_format.clone();
1217 let result = if node.kind == "verify" {
1218 if let Some(command) = node
1219 .verify
1220 .as_ref()
1221 .and_then(|verify| verify.as_object())
1222 .and_then(|verify| verify.get("command"))
1223 .and_then(|value| value.as_str())
1224 .map(str::trim)
1225 .filter(|value| !value.is_empty())
1226 {
1227 let (program, args) = if cfg!(target_os = "windows") {
1228 ("cmd", vec!["/C".to_string(), command.to_string()])
1229 } else {
1230 ("/bin/sh", vec!["-c".to_string(), command.to_string()])
1234 };
1235 let mut process_config = crate::stdlib::sandbox::ProcessCommandConfig {
1236 stdin_null: true,
1237 ..Default::default()
1238 };
1239 if let Some(context) = crate::stdlib::process::current_execution_context() {
1240 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
1241 crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
1242 process_config.cwd = Some(std::path::PathBuf::from(cwd));
1243 }
1244 if !context.env.is_empty() {
1245 process_config.env.extend(context.env);
1246 }
1247 }
1248 let output = crate::stdlib::sandbox::command_output(program, &args, &process_config)?;
1249 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1250 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1251 let combined = if stderr.is_empty() {
1252 stdout.clone()
1253 } else if stdout.is_empty() {
1254 stderr.clone()
1255 } else {
1256 format!("{stdout}\n{stderr}")
1257 };
1258 serde_json::json!({
1259 "status": "completed",
1260 "text": combined,
1261 "visible_text": combined,
1262 "command": command,
1263 "stdout": stdout,
1264 "stderr": stderr,
1265 "exit_status": output.status.code().unwrap_or(-1),
1266 "success": output.status.success(),
1267 })
1268 } else {
1269 serde_json::json!({
1270 "status": "completed",
1271 "text": "",
1272 "visible_text": "",
1273 })
1274 }
1275 } else {
1276 let tools_value = stage_tools_value(node);
1277 let llm_options = workflow_stage_llm_options(
1278 node,
1279 &stage_session_id,
1280 &tools_value,
1281 &tool_names,
1282 &stage_agent_options,
1283 );
1284 let agent_loop_options = if stage_agent_options.run_agent_loop {
1285 workflow_stage_agent_loop_options(
1286 ctx,
1287 node,
1288 &stage_session_id,
1289 &tools_value,
1290 &tool_names,
1291 &stage_agent_options,
1292 )
1293 .await?
1294 } else {
1295 crate::value::DictMap::new()
1296 };
1297 return Ok(PreparedWorkflowStageNode {
1298 prompt,
1299 system: node.system.clone(),
1300 run_agent_loop: stage_agent_options.run_agent_loop,
1301 llm_options,
1302 agent_loop_options,
1303 result: None,
1304 selected,
1305 rendered_context,
1306 rendered_verification,
1307 verification_contracts,
1308 tool_format,
1309 stage_session_id,
1310 });
1311 };
1312
1313 Ok(PreparedWorkflowStageNode {
1314 prompt,
1315 system: node.system.clone(),
1316 run_agent_loop: false,
1317 llm_options: crate::value::DictMap::new(),
1318 agent_loop_options: crate::value::DictMap::new(),
1319 result: Some(result),
1320 selected,
1321 rendered_context,
1322 rendered_verification,
1323 verification_contracts,
1324 tool_format,
1325 stage_session_id,
1326 })
1327}
1328
1329pub fn complete_prepared_stage_node(
1330 node_id: &str,
1331 node: &WorkflowNode,
1332 prepared: &PreparedWorkflowStageNode,
1333 mut llm_result: serde_json::Value,
1334) -> Result<(serde_json::Value, Vec<ArtifactRecord>, Option<VmValue>), VmError> {
1335 if let Some(payload) = llm_result.as_object_mut() {
1336 payload.insert(
1337 "prompt".to_string(),
1338 serde_json::json!(prepared.prompt.clone()),
1339 );
1340 payload.insert(
1341 "system_prompt".to_string(),
1342 serde_json::json!(node.system.clone().unwrap_or_default()),
1343 );
1344 payload.insert(
1345 "rendered_context".to_string(),
1346 serde_json::json!(prepared.rendered_context.clone()),
1347 );
1348 if !prepared.verification_contracts.is_empty() {
1349 payload.insert(
1350 "verification_contracts".to_string(),
1351 serde_json::to_value(&prepared.verification_contracts).unwrap_or_default(),
1352 );
1353 payload.insert(
1354 "rendered_verification_context".to_string(),
1355 serde_json::json!(prepared.rendered_verification.clone()),
1356 );
1357 }
1358 payload.insert(
1359 "selected_artifact_ids".to_string(),
1360 serde_json::json!(prepared
1361 .selected
1362 .iter()
1363 .map(|artifact| artifact.id.clone())
1364 .collect::<Vec<_>>()),
1365 );
1366 payload.insert(
1367 "selected_artifact_titles".to_string(),
1368 serde_json::json!(prepared
1369 .selected
1370 .iter()
1371 .map(|artifact| artifact.title.clone())
1372 .collect::<Vec<_>>()),
1373 );
1374 match payload
1375 .entry("tools".to_string())
1376 .or_insert_with(|| serde_json::json!({}))
1377 {
1378 serde_json::Value::Object(tools) => {
1379 tools.insert(
1380 "mode".to_string(),
1381 serde_json::json!(prepared.tool_format.clone()),
1382 );
1383 }
1384 slot => {
1385 *slot = serde_json::json!({ "mode": prepared.tool_format.clone() });
1386 }
1387 }
1388 }
1389
1390 let visible_text = llm_result["text"].as_str().unwrap_or_default().to_string();
1391 let result_transcript = llm_result
1395 .get("transcript")
1396 .cloned()
1397 .map(|value| crate::stdlib::json_to_vm_value(&value));
1398 let session_transcript = crate::agent_sessions::snapshot(&prepared.stage_session_id);
1399 let transcript = result_transcript
1400 .or(session_transcript)
1401 .and_then(|value| redact_transcript_visibility(&value, node.output_visibility.as_deref()));
1402 let output_kind = node
1403 .output_contract
1404 .output_kinds
1405 .first()
1406 .cloned()
1407 .unwrap_or_else(|| {
1408 if node.kind == "verify" {
1409 "verification_result".to_string()
1410 } else {
1411 "artifact".to_string()
1412 }
1413 });
1414 let mut metadata = BTreeMap::new();
1415 metadata.insert(
1416 "input_artifact_ids".to_string(),
1417 serde_json::json!(prepared
1418 .selected
1419 .iter()
1420 .map(|artifact| artifact.id.clone())
1421 .collect::<Vec<_>>()),
1422 );
1423 metadata.insert("node_kind".to_string(), serde_json::json!(node.kind));
1424 if !node.approval_policy.write_path_allowlist.is_empty() {
1425 metadata.insert(
1426 "changed_paths".to_string(),
1427 serde_json::json!(node.approval_policy.write_path_allowlist),
1428 );
1429 }
1430 let artifact = ArtifactRecord {
1431 type_name: "artifact".to_string(),
1432 id: new_id("artifact"),
1433 kind: output_kind,
1434 title: Some(format!("stage {node_id} output")),
1435 text: Some(visible_text),
1436 data: Some(llm_result.clone()),
1437 source: Some(node_id.to_string()),
1438 created_at: now_rfc3339(),
1439 freshness: Some("fresh".to_string()),
1440 priority: None,
1441 lineage: prepared
1442 .selected
1443 .iter()
1444 .map(|artifact| artifact.id.clone())
1445 .collect(),
1446 relevance: Some(1.0),
1447 estimated_tokens: None,
1448 stage: Some(node_id.to_string()),
1449 metadata,
1450 }
1451 .normalize();
1452
1453 Ok((llm_result, vec![artifact], transcript))
1454}
1455
1456pub async fn execute_stage_node(
1457 ctx: &crate::vm::AsyncBuiltinCtx,
1458 node_id: &str,
1459 node: &WorkflowNode,
1460 task: &str,
1461 artifacts: &[ArtifactRecord],
1462) -> Result<(serde_json::Value, Vec<ArtifactRecord>, Option<VmValue>), VmError> {
1463 let prepared = prepare_stage_node(ctx, node_id, node, task, artifacts).await?;
1464 let llm_result = if let Some(result) = prepared.result.clone() {
1465 result
1466 } else if prepared.run_agent_loop {
1467 let result = crate::stdlib::harn_entry::call_agent_loop(
1468 ctx,
1469 prepared.prompt.clone(),
1470 prepared.system.clone(),
1471 prepared.agent_loop_options.clone(),
1472 )
1473 .await?;
1474 crate::llm::vm_value_to_json(&result)
1475 } else {
1476 let args = vec![
1477 VmValue::String(arcstr::ArcStr::from(prepared.prompt.clone())),
1478 prepared
1479 .system
1480 .clone()
1481 .map(|s| VmValue::String(arcstr::ArcStr::from(s)))
1482 .unwrap_or(VmValue::Nil),
1483 VmValue::dict(prepared.llm_options.clone()),
1484 ];
1485 let opts = extract_llm_options(&args)?;
1486 let result = vm_call_llm_full(&opts).await?;
1487 crate::llm::agent_loop_result_from_llm(&result, opts)
1488 };
1489 complete_prepared_stage_node(node_id, node, &prepared, llm_result)
1490}
1491
1492pub fn append_audit_entry(
1493 graph: &mut WorkflowGraph,
1494 op: &str,
1495 node_id: Option<String>,
1496 reason: Option<String>,
1497 metadata: BTreeMap<String, serde_json::Value>,
1498) {
1499 graph.audit_log.push(WorkflowAuditEntry {
1500 id: new_id("audit"),
1501 op: op.to_string(),
1502 node_id,
1503 timestamp: now_rfc3339(),
1504 reason,
1505 metadata,
1506 });
1507}
1508
1509#[cfg(test)]
1510mod flatten_tests {
1511 use super::*;
1512 use crate::orchestration::{CapabilityPolicy, WorkflowNode};
1513 use std::collections::BTreeMap;
1514
1515 fn ceiling_with_tools(tools: &[&str]) -> CapabilityPolicy {
1516 CapabilityPolicy {
1517 tools: tools.iter().map(|t| t.to_string()).collect(),
1518 ..Default::default()
1519 }
1520 }
1521
1522 fn options_with_policy(policy: &CapabilityPolicy) -> crate::value::DictMap {
1523 let mut options = crate::value::DictMap::new();
1524 insert_json_vm_option(&mut options, "policy", policy).unwrap();
1525 options
1526 }
1527
1528 #[test]
1529 fn ceiling_pass_through_is_within() {
1530 let ceiling = ceiling_with_tools(&["read", "edit"]);
1531 assert!(ceiling.assert_within_ceiling(&ceiling).is_ok());
1533 let options = options_with_policy(&ceiling);
1534 assert!(enforce_flattened_ceiling(&options, &ceiling).is_ok());
1535 }
1536
1537 #[test]
1538 fn narrowing_is_allowed() {
1539 let ceiling = ceiling_with_tools(&["read", "edit", "run_command"]);
1540 let narrowed = ceiling_with_tools(&["read"]);
1541 assert!(ceiling.assert_within_ceiling(&narrowed).is_ok());
1542 }
1543
1544 #[test]
1545 fn widening_tools_is_rejected() {
1546 let ceiling = ceiling_with_tools(&["read"]);
1547 let widened = ceiling_with_tools(&["read", "run_command"]);
1548 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1549 assert!(
1550 err.contains("run_command"),
1551 "error names the widened tool: {err}"
1552 );
1553
1554 let options = options_with_policy(&widened);
1556 match enforce_flattened_ceiling(&options, &ceiling) {
1557 Err(VmError::CategorizedError { message, category }) => {
1558 assert_eq!(category, crate::value::ErrorCategory::ToolRejected);
1559 assert!(message.contains("run_command"), "message: {message}");
1560 }
1561 other => panic!("expected a ToolRejected error, got {other:?}"),
1562 }
1563 }
1564
1565 #[test]
1566 fn widening_capability_op_is_rejected() {
1567 let mut ceiling = CapabilityPolicy::default();
1568 ceiling
1569 .capabilities
1570 .insert("fs".to_string(), vec!["read".to_string()]);
1571 let mut widened = CapabilityPolicy::default();
1572 widened.capabilities.insert(
1573 "fs".to_string(),
1574 vec!["read".to_string(), "write".to_string()],
1575 );
1576 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1577 assert!(err.contains("fs") && err.contains("write"), "error: {err}");
1578 }
1579
1580 #[test]
1581 fn adding_new_capability_is_rejected() {
1582 let mut ceiling = CapabilityPolicy::default();
1583 ceiling
1584 .capabilities
1585 .insert("fs".to_string(), vec!["read".to_string()]);
1586 let mut widened = ceiling.clone();
1587 widened
1588 .capabilities
1589 .insert("net".to_string(), vec!["connect".to_string()]);
1590 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1591 assert!(
1592 err.contains("net"),
1593 "error names the added capability: {err}"
1594 );
1595 }
1596
1597 #[test]
1598 fn widening_recursion_budget_is_rejected() {
1599 let ceiling = CapabilityPolicy {
1600 recursion_limit: Some(2),
1601 ..Default::default()
1602 };
1603 let widened = CapabilityPolicy {
1604 recursion_limit: Some(9),
1605 ..Default::default()
1606 };
1607 assert!(ceiling.assert_within_ceiling(&widened).is_err());
1608 let dropped = CapabilityPolicy::default();
1610 assert!(ceiling.assert_within_ceiling(&dropped).is_err());
1611 let narrowed = CapabilityPolicy {
1613 recursion_limit: Some(1),
1614 ..Default::default()
1615 };
1616 assert!(ceiling.assert_within_ceiling(&narrowed).is_ok());
1617 }
1618
1619 #[test]
1620 fn widening_roots_is_rejected() {
1621 let ceiling = CapabilityPolicy {
1622 workspace_roots: vec!["/repo".to_string()],
1623 ..Default::default()
1624 };
1625 let widened = CapabilityPolicy {
1626 workspace_roots: vec!["/repo".to_string(), "/etc".to_string()],
1627 ..Default::default()
1628 };
1629 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1630 assert!(err.contains("/etc"), "error: {err}");
1631 }
1632
1633 #[test]
1634 fn widening_side_effect_level_is_rejected() {
1635 let ceiling = CapabilityPolicy {
1636 side_effect_level: Some("read_only".to_string()),
1637 ..Default::default()
1638 };
1639 let widened = CapabilityPolicy {
1640 side_effect_level: Some("network".to_string()),
1641 ..Default::default()
1642 };
1643 assert!(ceiling.assert_within_ceiling(&widened).is_err());
1644 }
1645
1646 #[test]
1647 fn unknown_side_effect_level_ranks_fail_closed() {
1648 let ceiling = CapabilityPolicy {
1652 side_effect_level: Some("none".to_string()),
1653 ..Default::default()
1654 };
1655 let widened = CapabilityPolicy {
1656 side_effect_level: Some("desktop_control".to_string()),
1657 ..Default::default()
1658 };
1659 assert!(ceiling.assert_within_ceiling(&widened).is_err());
1660 let unknown = CapabilityPolicy {
1663 side_effect_level: Some("teleport".to_string()),
1664 ..Default::default()
1665 };
1666 assert!(ceiling.assert_within_ceiling(&unknown).is_ok());
1667 }
1668
1669 #[test]
1670 fn widening_process_sandbox_roots_is_rejected() {
1671 use crate::orchestration::ProcessSandboxPolicy;
1672 let ceiling = CapabilityPolicy {
1673 process_sandbox: ProcessSandboxPolicy {
1674 write_roots: vec!["/repo/.cache".to_string()],
1675 ..Default::default()
1676 },
1677 ..Default::default()
1678 };
1679 let widened = CapabilityPolicy {
1680 process_sandbox: ProcessSandboxPolicy {
1681 write_roots: vec!["/repo/.cache".to_string(), "/etc".to_string()],
1682 ..Default::default()
1683 },
1684 ..Default::default()
1685 };
1686 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1687 assert!(
1688 err.contains("process_sandbox.write_roots") && err.contains("/etc"),
1689 "error: {err}"
1690 );
1691 assert!(ceiling
1693 .assert_within_ceiling(&CapabilityPolicy::default())
1694 .is_ok());
1695 }
1696
1697 #[test]
1698 fn injecting_process_sandbox_roots_into_empty_ceiling_is_rejected() {
1699 use crate::orchestration::ProcessSandboxPolicy;
1700 let ceiling = CapabilityPolicy::default();
1705 for (field, requested) in [
1706 (
1707 "process_sandbox.read_roots",
1708 CapabilityPolicy {
1709 process_sandbox: ProcessSandboxPolicy {
1710 read_roots: vec!["/etc".to_string()],
1711 ..Default::default()
1712 },
1713 ..Default::default()
1714 },
1715 ),
1716 (
1717 "process_sandbox.write_roots",
1718 CapabilityPolicy {
1719 process_sandbox: ProcessSandboxPolicy {
1720 write_roots: vec!["/etc".to_string()],
1721 ..Default::default()
1722 },
1723 ..Default::default()
1724 },
1725 ),
1726 ] {
1727 let err = ceiling.assert_within_ceiling(&requested).unwrap_err();
1728 assert!(
1729 err.contains(field) && err.contains("/etc"),
1730 "empty ceiling must reject injected {field}: {err}"
1731 );
1732 }
1733 assert!(ceiling
1735 .assert_within_ceiling(&CapabilityPolicy::default())
1736 .is_ok());
1737 }
1738
1739 #[test]
1740 fn widening_process_sandbox_presets_is_rejected() {
1741 use crate::orchestration::{ProcessSandboxPolicy, ProcessSandboxPreset};
1742 let ceiling = CapabilityPolicy {
1743 process_sandbox: ProcessSandboxPolicy {
1744 presets: Some(vec![ProcessSandboxPreset::SystemRuntime]),
1745 ..Default::default()
1746 },
1747 ..Default::default()
1748 };
1749 let widened = CapabilityPolicy {
1750 process_sandbox: ProcessSandboxPolicy {
1751 presets: Some(vec![
1752 ProcessSandboxPreset::SystemRuntime,
1753 ProcessSandboxPreset::DeveloperToolchains,
1754 ]),
1755 ..Default::default()
1756 },
1757 ..Default::default()
1758 };
1759 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1760 assert!(err.contains("process_sandbox presets"), "error: {err}");
1761 }
1762
1763 #[test]
1764 fn dropping_tool_arg_constraint_is_rejected() {
1765 use crate::orchestration::ToolArgConstraint;
1766 let constraint = ToolArgConstraint {
1767 tool: "edit".to_string(),
1768 arg_patterns: vec!["src/**".to_string()],
1769 arg_key: Some("path".to_string()),
1770 };
1771 let ceiling = CapabilityPolicy {
1772 tool_arg_constraints: vec![constraint],
1773 ..Default::default()
1774 };
1775 let widened = CapabilityPolicy::default();
1777 let err = ceiling.assert_within_ceiling(&widened).unwrap_err();
1778 assert!(
1779 err.contains("tool_arg_constraints") && err.contains("edit"),
1780 "error: {err}"
1781 );
1782 let mut narrowed = ceiling.clone();
1784 narrowed.tool_arg_constraints.push(ToolArgConstraint {
1785 tool: "run_command".to_string(),
1786 arg_patterns: vec!["cargo *".to_string()],
1787 arg_key: None,
1788 });
1789 assert!(ceiling.assert_within_ceiling(&narrowed).is_ok());
1790 }
1791
1792 #[test]
1793 fn weakening_tool_annotation_is_rejected() {
1794 use crate::tool_annotations::{SideEffectLevel, ToolAnnotations, ToolArgSchema};
1795 let strong = ToolAnnotations {
1796 side_effect_level: SideEffectLevel::ReadOnly,
1797 arg_schema: ToolArgSchema {
1798 path_params: vec!["path".to_string()],
1799 ..Default::default()
1800 },
1801 ..Default::default()
1802 };
1803 let mut ceiling = CapabilityPolicy {
1804 tools: vec!["edit".to_string(), "read".to_string()],
1805 ..Default::default()
1806 };
1807 ceiling
1808 .tool_annotations
1809 .insert("edit".to_string(), strong.clone());
1810
1811 let mut dropped = ceiling.clone();
1814 dropped.tool_annotations.clear();
1815 let err = ceiling.assert_within_ceiling(&dropped).unwrap_err();
1816 assert!(
1817 err.contains("tool_annotations") && err.contains("edit"),
1818 "error: {err}"
1819 );
1820
1821 let mut rewritten = ceiling.clone();
1823 rewritten.tool_annotations.insert(
1824 "edit".to_string(),
1825 ToolAnnotations {
1826 side_effect_level: SideEffectLevel::None,
1827 ..strong
1828 },
1829 );
1830 assert!(ceiling.assert_within_ceiling(&rewritten).is_err());
1831
1832 let narrowed_tools = CapabilityPolicy {
1835 tools: vec!["read".to_string()],
1836 ..Default::default()
1837 };
1838 assert!(ceiling.assert_within_ceiling(&narrowed_tools).is_ok());
1839 }
1840
1841 fn legacy_flatten_reference(
1846 node: &WorkflowNode,
1847 session_id: &str,
1848 tool_format: &str,
1849 mut options: crate::value::DictMap,
1850 tools_value: &Option<VmValue>,
1851 tool_names: &[String],
1852 ) -> crate::value::DictMap {
1853 if let Some(raw) = node.raw_model_policy.as_ref().and_then(|v| v.as_dict()) {
1854 for (key, value) in raw {
1855 if !matches!(value, VmValue::Nil) {
1856 options.insert(key.clone(), value.clone());
1857 }
1858 }
1859 }
1860 if !options.contains_key("command_policy") {
1861 if let Some(command_policy) = node
1862 .raw_model_policy
1863 .as_ref()
1864 .and_then(|v| v.as_dict())
1865 .and_then(|d| d.get("policy"))
1866 .and_then(|v| v.as_dict())
1867 .and_then(|p| p.get("command_policy"))
1868 {
1869 options.insert(
1870 crate::value::intern_key("command_policy"),
1871 command_policy.clone(),
1872 );
1873 }
1874 }
1875 if !node.auto_compact.enabled {
1876 options.insert(
1877 crate::value::intern_key("auto_compact"),
1878 VmValue::Bool(false),
1879 );
1880 } else {
1881 options.insert(
1882 crate::value::intern_key("auto_compact"),
1883 VmValue::Bool(true),
1884 );
1885 if let Some(v) = node.auto_compact.token_threshold {
1886 options.insert(
1887 crate::value::intern_key("compact_threshold"),
1888 VmValue::Int(v as i64),
1889 );
1890 }
1891 if let Some(v) = node.auto_compact.tool_output_max_chars {
1892 options.insert(
1893 crate::value::intern_key("tool_output_max_chars"),
1894 VmValue::Int(v as i64),
1895 );
1896 }
1897 if let Some(v) = node.auto_compact.hard_limit_tokens {
1898 options.insert(
1899 crate::value::intern_key("hard_limit_tokens"),
1900 VmValue::Int(v as i64),
1901 );
1902 }
1903 if let Some(s) = node.auto_compact.compact_strategy.as_ref() {
1904 options.put_str("compact_strategy", s.clone());
1905 }
1906 if let Some(s) = node.auto_compact.hard_limit_strategy.as_ref() {
1907 options.put_str("hard_limit_strategy", s.clone());
1908 }
1909 let raw = node.raw_auto_compact.as_ref().and_then(|v| v.as_dict());
1910 let keep = raw
1911 .and_then(|d| d.get("compact_keep_last"))
1912 .and_then(|v| v.as_int())
1913 .filter(|v| *v >= 0)
1914 .or_else(|| {
1915 raw.and_then(|d| d.get("keep_last"))
1916 .and_then(|v| v.as_int())
1917 .filter(|v| *v >= 0)
1918 });
1919 if let Some(v) = keep {
1920 options.insert(
1921 crate::value::intern_key("compact_keep_last"),
1922 VmValue::Int(v),
1923 );
1924 }
1925 if let Some(p) = raw
1926 .and_then(|d| d.get("summarize_prompt"))
1927 .and_then(|v| match v {
1928 VmValue::String(t) if !t.trim().is_empty() => Some(t.to_string()),
1929 _ => None,
1930 })
1931 {
1932 options.put_str("summarize_prompt", p);
1933 }
1934 if let Some(d) = raw {
1935 for key in ["compress_callback", "mask_callback"] {
1936 if let Some(cb) = d.get(key) {
1937 options.insert(crate::value::intern_key(key), cb.clone());
1938 }
1939 }
1940 if let Some(cb) = d.get("custom_compactor") {
1941 options.insert(crate::value::intern_key("compact_callback"), cb.clone());
1942 }
1943 }
1944 }
1945 if !tool_names.is_empty() {
1946 if let Some(v) = tools_value.clone() {
1947 options.insert(crate::value::intern_key("tools"), v);
1948 }
1949 }
1950 let tool_policy = tool_capability_policy_from_spec(&node.tools);
1951 let effective = tool_policy.intersect(&node.capability_policy).unwrap();
1952 insert_json_vm_option(&mut options, "policy", &effective).unwrap();
1953 insert_json_vm_option(&mut options, "approval_policy", &node.approval_policy).unwrap();
1954 options.put_str("session_id", session_id);
1955 options.put_str("tool_format", tool_format);
1956 let label = node.id.clone().unwrap_or_else(|| session_id.to_string());
1957 crate::orchestration::annotate_nested_execution_options(
1958 &mut options,
1959 crate::orchestration::NestedExecutionKind::WorkflowStage,
1960 &label,
1961 );
1962 options
1963 }
1964
1965 fn representative_node() -> WorkflowNode {
1966 let mut raw_model_policy = BTreeMap::new();
1967 raw_model_policy.insert(
1968 "provider".to_string(),
1969 VmValue::String(arcstr::ArcStr::from("anthropic")),
1970 );
1971 raw_model_policy.insert("temperature".to_string(), VmValue::Float(0.2));
1972 let mut nested_policy = BTreeMap::new();
1974 nested_policy.insert(
1975 "command_policy".to_string(),
1976 VmValue::String(arcstr::ArcStr::from("worktree")),
1977 );
1978 raw_model_policy.insert("policy".to_string(), VmValue::dict(nested_policy));
1979 raw_model_policy.insert("nudge".to_string(), VmValue::Nil);
1981
1982 let mut raw_auto_compact = BTreeMap::new();
1983 raw_auto_compact.insert("keep_last".to_string(), VmValue::Int(4));
1984 raw_auto_compact.insert(
1985 "summarize_prompt".to_string(),
1986 VmValue::String(arcstr::ArcStr::from("summarize tersely")),
1987 );
1988
1989 WorkflowNode {
1990 id: Some("act".to_string()),
1991 kind: "stage".to_string(),
1992 mode: Some("agent".to_string()),
1993 tools: serde_json::json!(["read", "edit"]),
1994 auto_compact: crate::orchestration::AutoCompactPolicy {
1995 enabled: true,
1996 token_threshold: Some(8000),
1997 tool_output_max_chars: Some(2000),
1998 hard_limit_tokens: Some(20000),
1999 compact_strategy: Some("summary".to_string()),
2000 hard_limit_strategy: Some("truncate".to_string()),
2001 },
2002 capability_policy: CapabilityPolicy {
2003 tools: vec!["read".to_string(), "edit".to_string()],
2004 recursion_limit: Some(3),
2005 ..Default::default()
2006 },
2007 raw_model_policy: Some(VmValue::dict(raw_model_policy)),
2008 raw_auto_compact: Some(VmValue::dict(raw_auto_compact)),
2009 ..Default::default()
2010 }
2011 }
2012
2013 #[tokio::test(flavor = "current_thread", start_paused = true)]
2014 async fn flatten_matches_pre_move_rust() {
2015 crate::reset_thread_local_state();
2016 let node = representative_node();
2017 let session_id = "session-parity";
2018 let tool_format = "text";
2019 let tool_names = vec!["read".to_string(), "edit".to_string()];
2020 let tools_value = Some(crate::stdlib::json_to_vm_value(&node.tools));
2021
2022 let mut base = crate::value::DictMap::new();
2024 base.insert(
2025 crate::value::intern_key("loop_until_done"),
2026 VmValue::Bool(true),
2027 );
2028 base.insert(crate::value::intern_key("max_iterations"), VmValue::Int(16));
2029
2030 let stage_agent_options = super::super::WorkflowStageAgentOptions {
2031 run_agent_loop: true,
2032 tool_format: tool_format.to_string(),
2033 llm_options: BTreeMap::new(),
2034 agent_loop_options: base
2035 .iter()
2036 .map(|(k, v)| (k.to_string(), vm_value_to_json(v)))
2037 .collect(),
2038 };
2039
2040 let mut vm = crate::Vm::new();
2041 crate::register_vm_stdlib(&mut vm);
2042 let ctx = crate::vm::AsyncBuiltinCtx::for_test(vm);
2043
2044 let flattened = workflow_stage_agent_loop_options(
2045 &ctx,
2046 &node,
2047 session_id,
2048 &tools_value,
2049 &tool_names,
2050 &stage_agent_options,
2051 )
2052 .await
2053 .expect("harn flatten succeeds");
2054
2055 let expected = legacy_flatten_reference(
2056 &node,
2057 session_id,
2058 tool_format,
2059 base,
2060 &tools_value,
2061 &tool_names,
2062 );
2063
2064 let flattened_json = vm_value_to_json(&VmValue::dict(flattened));
2065 let expected_json = vm_value_to_json(&VmValue::dict(expected));
2066 assert_eq!(
2067 flattened_json, expected_json,
2068 "Harn flatten must be dict-equal to the pre-move Rust flatten"
2069 );
2070 }
2071}