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_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    /// 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 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
1013/// Assemble the agent_loop options for one stage.
1014///
1015/// The policy *flattening* — collapsing the ~15 per-stage policy structs into
1016/// the options dict the loop consumes — lives in Harn
1017/// (`workflow_flatten_agent_loop_options` in `std/workflow/stage.harn`, design
1018/// D5). Rust keeps only the enforcement leaves: it re-derives the capability
1019/// ceiling (`tool spec ∩ stage capability_policy`) and, when the flattened
1020/// dict re-enters the host, rejects any result whose `policy` *widens* that
1021/// ceiling ([`enforce_flattened_ceiling`]). Raw model-policy / tool / compaction
1022/// values cross as `VmValue`s so their closures survive the round trip.
1023async 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    // Ceiling derivation stays in Rust (enforcement, not flattening): the
1032    // Harn flattener may narrow it but never widen it.
1033    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    // The host only forwards a tool spec when the stage actually exposes tools;
1058    // matching the former `add_stage_tools_option` gate keeps the dict identical.
1059    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
1103/// Enforce the ceiling invariant on a Harn-flattened stage options dict: its
1104/// `policy` (the capability policy the loop will run under) must never widen
1105/// `ceiling`, the workflow-level grant Rust derived. This is the trust
1106/// boundary — the Harn flattener is untrusted for *authority*, only for
1107/// *shape*, so the host re-checks the returned policy rather than assuming the
1108/// flattener narrowed correctly.
1109fn 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                // Do not use a login shell here. On macOS, `/bin/sh -l`
1231                // reads user dotfiles such as `~/.profile`, which makes
1232                // sandboxed verification depend on out-of-worktree state.
1233                ("/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    // Non-LLM stages (verify command, condition, fork, join, ...) don't produce
1392    // a "transcript" field; fall back to the input so cross-stage conversation
1393    // state survives transitions.
1394    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        // The parity path: flattener passes the ceiling through unchanged.
1532        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        // ... and surfaces as a ToolRejected VmError at the flatten seam.
1555        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        // Dropping the budget entirely is also a widening.
1609        let dropped = CapabilityPolicy::default();
1610        assert!(ceiling.assert_within_ceiling(&dropped).is_err());
1611        // Narrowing the budget is allowed.
1612        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        // Canonical `rank_str` ranks an unrecognized level as `none` (0), so a
1649        // typo/injected level can never outrank a real ceiling. A ceiling of
1650        // `none` still rejects a widening to a known-higher level.
1651        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        // An unknown requested level ranks 0 (== none), so it is within a
1661        // `none` ceiling rather than fail-open above it.
1662        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        // Narrowing (fewer roots) is allowed.
1692        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        // The common default: a stage that never set process_sandbox has EMPTY
1701        // read/write roots — ZERO extra subprocess FS access (additive grants,
1702        // no fallback), i.e. MOST restrictive. A flattener injecting any root
1703        // must be rejected, not waved through as "unbounded".
1704        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        // Empty requested against empty ceiling stays allowed (∅ ⊆ ∅).
1734        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        // A flattener that drops the scope constraint widens edit to anywhere.
1776        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        // Keeping it (and adding more) is allowed.
1783        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        // Dropping the annotation for a still-granted tool (loses path_params →
1812        // path constraint becomes unresolvable/permissive) is a widening.
1813        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        // Rewriting it (e.g. lowering the side-effect level) is also rejected.
1822        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        // But if the flattener narrows the tool set so `edit` is no longer
1833        // granted, dropping its annotation is fine.
1834        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    /// The pinned pre-move Rust flattening algorithm (the deleted
1842    /// `workflow_stage_agent_loop_options` body + helpers), preserved verbatim
1843    /// as the parity oracle. `flatten_matches_pre_move_rust` asserts the Harn
1844    /// flattener reproduces it dict-for-dict.
1845    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        // Nested command policy hoisted to the top level by the flattener.
1973        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        // A nil entry must be skipped by the merge.
1980        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        // Base agent_loop options (as std/workflow/options would normalize).
2023        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}