From e77c9dae17c765e61ca084714d70ef7914b297db Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 18 Aug 2026 14:12:33 +0530 Subject: [PATCH] fix(adaptive): lower run steps to the shell node's real config key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field observation, three attempts of one episode: every run step failed with 'shell node missing inline script or script_path' while the author rationally iterated on the only thing the feedback named — a config key it does not write. The lowering emitted `script`; the engine's shell node reads `source` (or `script_path`). Machine-generated config the model cannot reach must not be wrong, and this one was. Every previously satisfied recipe episode happened to be ask-only, which is how the defect hid. A new test asks the ENGINE's own shell contract which fields it requires and asserts the lowering fills one, so the two cannot drift apart silently again. Co-Authored-By: Claude Fable 5 --- crates/adaptive/src/intake/recipe.rs | 5 ++- crates/adaptive/src/intake/recipe_tests.rs | 47 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/adaptive/src/intake/recipe.rs b/crates/adaptive/src/intake/recipe.rs index 451e1d8..2a2f8af 100644 --- a/crates/adaptive/src/intake/recipe.rs +++ b/crates/adaptive/src/intake/recipe.rs @@ -115,7 +115,10 @@ pub fn lower(answer: &Value) -> Result<(WorkflowGraph, Map, Strin kind: NodeKind::Shell, type_version: 1, name: step.id.clone(), - config: json!({ "script": script }), + // `source`, not `script`: the engine's shell node reads + // config.source — the drift test below ties this key to the + // engine's own contract so it cannot silently rot again. + config: json!({ "source": script }), ports: Vec::new(), position: None, }, diff --git a/crates/adaptive/src/intake/recipe_tests.rs b/crates/adaptive/src/intake/recipe_tests.rs index 84aec23..ab0cc0a 100644 --- a/crates/adaptive/src/intake/recipe_tests.rs +++ b/crates/adaptive/src/intake/recipe_tests.rs @@ -31,6 +31,11 @@ fn a_recipe_lowers_to_a_graph_that_validates() { ); assert_eq!(graph.nodes.len(), 3, "trigger + two steps"); assert_eq!(graph.nodes[1].kind, NodeKind::Shell); + assert_eq!( + graph.nodes[1].config["source"], "gh issue list --json number,title", + "the engine's shell node reads config.source — a lowered run step \ + under any other key is born broken, and the model cannot fix it" + ); assert_eq!(graph.nodes[2].kind, NodeKind::Agent); assert_eq!(inputs["repo"], "acme/thing"); assert_eq!(why, "fetch then review"); @@ -117,6 +122,48 @@ fn a_reply_with_no_steps_says_what_to_return() { assert!(err.contains("at least one step"), "{err}"); } +#[test] +fn the_lowered_shell_config_satisfies_the_engines_own_contract() { + // Field observation, three attempts of one episode: every run step + // failed with "shell node missing inline script or script_path" while + // the author rationally iterated on the only thing the feedback named — + // a config key it does not write. The lowering emitted `script`; the + // engine reads `source`. This test asks the ENGINE which required + // fields its shell contract has and asserts the lowering fills one, so + // the two cannot drift apart silently again. + let recipe = json!({ + "why": "fetch", + "steps": [{ "id": "fetch", "run": "echo hi" }] + }); + let (graph, _, _) = lower(&recipe).expect("lowers"); + let shell = tinyflows::catalog::all_contracts() + .iter() + .find(|contract| contract.kind == "shell") + .expect("the engine has a shell contract") + .clone(); + let required: Vec<&str> = shell + .config_fields + .iter() + .filter(|field| field.required) + .map(|field| field.name.as_str()) + .collect(); + let config = &graph.nodes[1].config; + // The shell contract's requirement is one-of (source | script_path), so + // required may be empty — assert on the actual read keys instead when so. + if required.is_empty() { + assert!( + config.get("source").is_some() || config.get("script_path").is_some(), + "a lowered run step must fill config.source or config.script_path: {config}" + ); + } else { + assert!( + required.iter().any(|name| config.get(*name).is_some()), + "the lowering fills none of the engine's required shell fields \ + {required:?}: {config}" + ); + } +} + #[test] fn ids_are_sanitized_into_engine_and_jq_safe_names() { let recipe = json!({