Skip to main content

harn_vm/orchestration/
workflow.rs

1//! Workflow graph types, normalization, validation, and execution.
2
3use 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    /// Per-stage auto-compaction settings for the agent loop's context
33    /// window. Lifecycle operations (reset, fork, trim, compact) are NOT
34    /// expressible here — call the `agent_session_*` builtins before the
35    /// stage or in a prior stage.
36    pub auto_compact: AutoCompactPolicy,
37    /// Output visibility filter applied to the transcript after the
38    /// stage's agent loop exits. `"public"` / `"public_only"` drops
39    /// `tool_result` messages and non-public events. `None` or any
40    /// unknown string is a no-op.
41    #[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    /// When true, the stage's agent loop gates the done sentinel on the most
56    /// recent `run()` tool call exiting cleanly (`exit_code == 0`). Use for
57    /// persistent execute stages that fold verification into the loop via a
58    /// shell-exec tool the model invokes explicitly.
59    #[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    /// Raw auto_compact VmValue dict — preserved for extracting closure
65    /// fields (compress_callback, mask_callback, custom_compactor) that
66    /// can't go through serde.
67    #[serde(skip)]
68    pub raw_auto_compact: Option<VmValue>,
69    /// Raw model_policy VmValue dict — preserved for extracting closure
70    /// fields (post_turn_callback) that can't go through serde.
71    #[serde(skip)]
72    pub raw_model_policy: Option<VmValue>,
73    /// Raw context_assembler VmValue dict — when set, the stage's
74    /// artifact context is packed through `assemble_context` before
75    /// rendering the system prompt. Closure fields (`ranker_callback`)
76    /// are preserved here because they can't round-trip through serde.
77    #[serde(skip)]
78    pub raw_context_assembler: Option<VmValue>,
79    /// Raw `verify` VmValue — preserved so a *callable* verifier (fn-verify
80    /// mode) survives the builtin seam. The typed `verify` field above is a
81    /// `serde_json::Value`, which drops closures; when `verify` is a Harn
82    /// function the live closure is lifted here and re-attached by
83    /// `node_to_vm_with_raw` (stage.rs) so the embedded stage loop can invoke
84    /// it against each attempt's result (`workflow_evaluate_verification`).
85    #[serde(skip)]
86    pub raw_verify: Option<VmValue>,
87    /// Raw `executor` VmValue — a caller-supplied closure that runs as the
88    /// stage's leaf *instead of* spawning a delegated worker. When set, the
89    /// embedded stage loop (`workflow_execute_stage_attempts`) invokes it with
90    /// the retry context and shapes its return into the same settled payload
91    /// `__host_stage_execute_once` produces, so the SAME retry-with-feedback
92    /// threading, fn-verify gate, and (Rust sole-writer) attempt recording run
93    /// around it. `WorkflowNode` has no typed `executor` field — the closure
94    /// can't round-trip through serde — so it is lifted here and re-attached by
95    /// `node_to_vm_with_raw` (stage.rs), exactly like `raw_verify`.
96    #[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
523/// Extract the raw `retry_policy.repair_prompt_builder` closure from a node's
524/// source dict. The builder carries a Harn closure that cannot round-trip
525/// through serde, so — like `raw_model_policy`'s `post_turn_callback` — it is
526/// lifted onto the typed `RetryPolicy` here and travels to the embedded stage
527/// loop (`std/workflow/stage.harn`) as a raw value.
528fn 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    // Lift a callable verifier (fn-verify mode) so the closure survives; a
547    // plain dict/command verify is left to the typed `verify` field.
548    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    // Lift a caller-supplied executor closure (inline stage leaf) so it
558    // survives the serde crossing, mirroring the fn-verify treatment above.
559    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            // fn-verify: lift a callable verifier off the source dict so the
721            // closure survives the graph round-trip (serde drops it).
722            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            // Inline executor: lift a callable off the source dict so the
734            // closure survives the graph round-trip (serde drops it).
735            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
916/// Pick the session id a stage should run under. Prefers an explicit
917/// `session_id` on the node's `model_policy` dict (so pipelines with
918/// `agent_session_open` / `agent_session_fork` flowing through a graph
919/// line up); falls back to a stable, node-derived id so multi-stage
920/// graphs with no explicit session share a conversation across stages.
921fn 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
1009/// Assemble the agent_loop options for one stage.
1010///
1011/// The policy *flattening* — collapsing the ~15 per-stage policy structs into
1012/// the options dict the loop consumes — lives in Harn
1013/// (`workflow_flatten_agent_loop_options` in `std/workflow/stage.harn`, design
1014/// D5). Rust keeps only the enforcement leaves: it re-derives the capability
1015/// ceiling (`tool spec ∩ stage capability_policy`) and, when the flattened
1016/// dict re-enters the host, rejects any result whose `policy` *widens* that
1017/// ceiling ([`enforce_flattened_ceiling`]). Raw model-policy / tool / compaction
1018/// values cross as `VmValue`s so their closures survive the round trip.
1019async 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    // Ceiling derivation stays in Rust (enforcement, not flattening): the
1028    // Harn flattener may narrow it but never widen it.
1029    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    // The host only forwards a tool spec when the stage actually exposes tools;
1054    // matching the former `add_stage_tools_option` gate keeps the dict identical.
1055    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
1099/// Enforce the ceiling invariant on a Harn-flattened stage options dict: its
1100/// `policy` (the capability policy the loop will run under) must never widen
1101/// `ceiling`, the workflow-level grant Rust derived. This is the trust
1102/// boundary — the Harn flattener is untrusted for *authority*, only for
1103/// *shape*, so the host re-checks the returned policy rather than assuming the
1104/// flattener narrowed correctly.
1105fn 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                // Do not use a login shell here. On macOS, `/bin/sh -l`
1227                // reads user dotfiles such as `~/.profile`, which makes
1228                // sandboxed verification depend on out-of-worktree state.
1229                ("/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    // Non-LLM stages (verify command, condition, fork, join, ...) don't produce
1388    // a "transcript" field; fall back to the input so cross-stage conversation
1389    // state survives transitions.
1390    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        // The parity path: flattener passes the ceiling through unchanged.
1528        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        // ... and surfaces as a ToolRejected VmError at the flatten seam.
1551        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        // Dropping the budget entirely is also a widening.
1605        let dropped = CapabilityPolicy::default();
1606        assert!(ceiling.assert_within_ceiling(&dropped).is_err());
1607        // Narrowing the budget is allowed.
1608        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        // Canonical `rank_str` ranks an unrecognized level as `none` (0), so a
1645        // typo/injected level can never outrank a real ceiling. A ceiling of
1646        // `none` still rejects a widening to a known-higher level.
1647        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        // An unknown requested level ranks 0 (== none), so it is within a
1657        // `none` ceiling rather than fail-open above it.
1658        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        // Narrowing (fewer roots) is allowed.
1688        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        // The common default: a stage that never set process_sandbox has EMPTY
1697        // read/write roots — ZERO extra subprocess FS access (additive grants,
1698        // no fallback), i.e. MOST restrictive. A flattener injecting any root
1699        // must be rejected, not waved through as "unbounded".
1700        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        // Empty requested against empty ceiling stays allowed (∅ ⊆ ∅).
1730        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        // A flattener that drops the scope constraint widens edit to anywhere.
1772        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        // Keeping it (and adding more) is allowed.
1779        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        // Dropping the annotation for a still-granted tool (loses path_params →
1808        // path constraint becomes unresolvable/permissive) is a widening.
1809        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        // Rewriting it (e.g. lowering the side-effect level) is also rejected.
1818        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        // But if the flattener narrows the tool set so `edit` is no longer
1829        // granted, dropping its annotation is fine.
1830        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    /// The pinned pre-move Rust flattening algorithm (the deleted
1838    /// `workflow_stage_agent_loop_options` body + helpers), preserved verbatim
1839    /// as the parity oracle. `flatten_matches_pre_move_rust` asserts the Harn
1840    /// flattener reproduces it dict-for-dict.
1841    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        // Nested command policy hoisted to the top level by the flattener.
1969        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        // A nil entry must be skipped by the merge.
1976        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        // Base agent_loop options (as std/workflow/options would normalize).
2019        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}