diff --git a/crates/adaptive/src/intake/recipe.rs b/crates/adaptive/src/intake/recipe.rs index 451e1d8..967bb97 100644 --- a/crates/adaptive/src/intake/recipe.rs +++ b/crates/adaptive/src/intake/recipe.rs @@ -56,7 +56,9 @@ Return JSON: - `declared`: the workflow's inputs — anything the goal supplies as data (a repository, a topic, an id), so the plan works for the NEXT goal of its kind with different values. `inputs` supplies this run's value for every - required one. Declared values are attached to ask steps automatically. + required one. Declared values are attached to ask steps automatically — + NEVER also paste a value into an ask: a pasted value makes the plan + single-use, so it cannot be kept for future goals, and it is refused. - The LAST step's output is the run's answer: make it the step that produces the deliverable. @@ -96,6 +98,17 @@ pub fn lower(answer: &Value) -> Result<(WorkflowGraph, Map, Strin let declared = parse_declared(answer); let inputs = answer["inputs"].as_object().cloned().unwrap_or_default(); + // A declared value pasted into an ask defeats the declaration: the + // lowering attaches the value anyway, so the paste is redundant now and + // poisonous later — selected for a different value, the prompt would + // carry BOTH, and the keep gate would rightly refuse to file the plan. + // Refused here, where the feedback round can fix it, rather than + // discovered as an unkeepable graph after a satisfied run. + let pasted = pasted_values(&steps, &declared, &inputs); + if !pasted.is_empty() { + return Err(IntakeError::Invalid(pasted.join("; "))); + } + let mut nodes = vec![Node { id: "start".into(), kind: NodeKind::Trigger, @@ -166,6 +179,38 @@ pub fn lower(answer: &Value) -> Result<(WorkflowGraph, Map, Strin Ok((graph, inputs, why)) } +/// Ask steps that restate a declared value instead of relying on the +/// attachment. Only distinctive values count — refusing a plan because an +/// ask contains the word "on" would block perfectly reusable recipes — and +/// only DECLARED inputs: undeclared entries are trimmed by the author gate +/// and never attached, so their values in an ask are just prose. +fn pasted_values( + steps: &[Step], + declared: &[(String, String, bool)], + inputs: &Map, +) -> Vec { + let mut problems = Vec::new(); + for step in steps { + let Action::Ask { prompt, .. } = &step.action else { + continue; + }; + for (name, _, _) in declared { + let Some(value) = inputs.get(name).and_then(Value::as_str).map(str::trim) else { + continue; + }; + if crate::reuse::distinctive(value) && prompt.contains(value) { + problems.push(format!( + "step `{}` pastes the value of input `{name}` into its ask — remove \ + it; declared values are attached automatically, and a pasted value \ + makes the plan single-use", + step.id + )); + } + } + } + problems +} + /// The generated prompt expression for an ask step. /// /// A jq program the model never sees: the instruction as a quoted literal, diff --git a/crates/adaptive/src/intake/recipe_tests.rs b/crates/adaptive/src/intake/recipe_tests.rs index 84aec23..fe66d45 100644 --- a/crates/adaptive/src/intake/recipe_tests.rs +++ b/crates/adaptive/src/intake/recipe_tests.rs @@ -109,6 +109,67 @@ fn every_structural_problem_is_reported_at_once_with_the_fix() { } } +#[test] +fn a_declared_value_pasted_into_an_ask_is_refused_with_the_remedy() { + // Observed on a live host: the author declared `topic` AND wrote + // "about the topic 'warm caches'" in the ask. The lowering attaches the + // value anyway, so the paste is redundant now — and poisonous later: + // selected for a different topic, the prompt carries both, and the keep + // gate rightly refuses to file the plan. Caught here, the feedback + // round fixes it before anything runs. + let recipe = json!({ + "why": "poem", + "declared": [{ "name": "topic", "description": "", "required": true }], + "inputs": { "topic": "warm caches" }, + "steps": [ + { "id": "write", "ask": "Write a two-line poem about the topic 'warm caches'." } + ] + }); + let err = lower(&recipe).expect_err("refused").to_string(); + assert!(err.contains("pastes the value"), "{err}"); + assert!(err.contains("attached automatically"), "{err}"); + + // The same plan without the paste is exactly what should be written. + let clean = json!({ + "why": "poem", + "declared": [{ "name": "topic", "description": "", "required": true }], + "inputs": { "topic": "warm caches" }, + "steps": [ + { "id": "write", "ask": "Write a two-line poem about the given topic." } + ] + }); + lower(&clean).expect("keepable"); +} + +#[test] +fn an_undeclared_input_value_in_an_ask_is_not_a_paste() { + // Undeclared entries never attach to an ask — the author gate trims + // them — so their values appearing in prose prove nothing about reuse. + let recipe = json!({ + "why": "poem", + "inputs": { "stray": "warm caches" }, + "steps": [ + { "id": "write", "ask": "Write a two-line poem about warm caches." } + ] + }); + lower(&recipe).expect("not a paste — nothing declared"); +} + +#[test] +fn an_indistinct_input_value_in_an_ask_is_not_a_paste() { + // "on" appears in half of all prose; refusing on it would block + // perfectly reusable plans. Only distinctive values count. + let recipe = json!({ + "why": "toggle", + "declared": [{ "name": "mode", "description": "", "required": true }], + "inputs": { "mode": "on" }, + "steps": [ + { "id": "flip", "ask": "Turn the feature on if the mode input says so." } + ] + }); + lower(&recipe).expect("not a paste"); +} + #[test] fn a_reply_with_no_steps_says_what_to_return() { let err = lower(&json!({ "why": "empty" })) diff --git a/crates/adaptive/src/reuse.rs b/crates/adaptive/src/reuse.rs index c7920ff..9dec967 100644 --- a/crates/adaptive/src/reuse.rs +++ b/crates/adaptive/src/reuse.rs @@ -49,7 +49,7 @@ const DISTINCTIVE_CHARS: [char; 6] = ['/', '.', ':', '@', '_', '-']; /// port name on every edge in the graph. Treating those as pasted would refuse /// to keep perfectly reusable procedures, and a gate that fires on noise is one /// nobody trusts. -fn distinctive(value: &str) -> bool { +pub(crate) fn distinctive(value: &str) -> bool { let length = value.chars().count(); // A digit only counts alongside some length: `"1"` proves nothing and, via // the substring test, would match any config containing that character — diff --git a/crates/adaptive/tests/driver.rs b/crates/adaptive/tests/driver.rs index 219de35..a544f2e 100644 --- a/crates/adaptive/tests/driver.rs +++ b/crates/adaptive/tests/driver.rs @@ -406,9 +406,15 @@ async fn a_graph_that_was_authored_and_worked_becomes_a_stored_procedure() { async fn a_graph_that_pasted_its_inputs_is_not_kept() { // Same run, same success — but the goal's specifics are welded into a // step, so it matches one task and never another. No model is asked. + // + // The paste sits in a `run` script, not an ask: the intake gate refuses + // ask-pastes outright now, and this test is about the layer BEHIND it — + // keep's own refusal, which still guards every path intake cannot see. let mut baked = parameterised(); - baked["steps"][0]["ask"] = - json!("Review the open pull requests on acme/thing and summarise them."); + baked["steps"] = json!([ + { "id": "review", "run": "gh pr list -R acme/thing" }, + { "id": "report", "ask": "Summarise the review output.", "reads": ["review"] } + ]); let llm = succeeding(baked, true); let caps = Capabilities {