Skip to content

Commit f2fd77b

Browse files
authored
Merge pull request #61 from sanil-23/fix/recipe-pasted-input-gate
fix(adaptive): refuse a declared value pasted into an ask
2 parents 8f63a27 + aee97dd commit f2fd77b

4 files changed

Lines changed: 116 additions & 4 deletions

File tree

crates/adaptive/src/intake/recipe.rs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ Return JSON:
5656
- `declared`: the workflow's inputs — anything the goal supplies as data (a
5757
repository, a topic, an id), so the plan works for the NEXT goal of its
5858
kind with different values. `inputs` supplies this run's value for every
59-
required one. Declared values are attached to ask steps automatically.
59+
required one. Declared values are attached to ask steps automatically —
60+
NEVER also paste a value into an ask: a pasted value makes the plan
61+
single-use, so it cannot be kept for future goals, and it is refused.
6062
- The LAST step's output is the run's answer: make it the step that produces
6163
the deliverable.
6264
@@ -96,6 +98,17 @@ pub fn lower(answer: &Value) -> Result<(WorkflowGraph, Map<String, Value>, Strin
9698
let declared = parse_declared(answer);
9799
let inputs = answer["inputs"].as_object().cloned().unwrap_or_default();
98100

101+
// A declared value pasted into an ask defeats the declaration: the
102+
// lowering attaches the value anyway, so the paste is redundant now and
103+
// poisonous later — selected for a different value, the prompt would
104+
// carry BOTH, and the keep gate would rightly refuse to file the plan.
105+
// Refused here, where the feedback round can fix it, rather than
106+
// discovered as an unkeepable graph after a satisfied run.
107+
let pasted = pasted_values(&steps, &declared, &inputs);
108+
if !pasted.is_empty() {
109+
return Err(IntakeError::Invalid(pasted.join("; ")));
110+
}
111+
99112
let mut nodes = vec![Node {
100113
id: "start".into(),
101114
kind: NodeKind::Trigger,
@@ -166,6 +179,38 @@ pub fn lower(answer: &Value) -> Result<(WorkflowGraph, Map<String, Value>, Strin
166179
Ok((graph, inputs, why))
167180
}
168181

182+
/// Ask steps that restate a declared value instead of relying on the
183+
/// attachment. Only distinctive values count — refusing a plan because an
184+
/// ask contains the word "on" would block perfectly reusable recipes — and
185+
/// only DECLARED inputs: undeclared entries are trimmed by the author gate
186+
/// and never attached, so their values in an ask are just prose.
187+
fn pasted_values(
188+
steps: &[Step],
189+
declared: &[(String, String, bool)],
190+
inputs: &Map<String, Value>,
191+
) -> Vec<String> {
192+
let mut problems = Vec::new();
193+
for step in steps {
194+
let Action::Ask { prompt, .. } = &step.action else {
195+
continue;
196+
};
197+
for (name, _, _) in declared {
198+
let Some(value) = inputs.get(name).and_then(Value::as_str).map(str::trim) else {
199+
continue;
200+
};
201+
if crate::reuse::distinctive(value) && prompt.contains(value) {
202+
problems.push(format!(
203+
"step `{}` pastes the value of input `{name}` into its ask — remove \
204+
it; declared values are attached automatically, and a pasted value \
205+
makes the plan single-use",
206+
step.id
207+
));
208+
}
209+
}
210+
}
211+
problems
212+
}
213+
169214
/// The generated prompt expression for an ask step.
170215
///
171216
/// A jq program the model never sees: the instruction as a quoted literal,

crates/adaptive/src/intake/recipe_tests.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,67 @@ fn every_structural_problem_is_reported_at_once_with_the_fix() {
109109
}
110110
}
111111

112+
#[test]
113+
fn a_declared_value_pasted_into_an_ask_is_refused_with_the_remedy() {
114+
// Observed on a live host: the author declared `topic` AND wrote
115+
// "about the topic 'warm caches'" in the ask. The lowering attaches the
116+
// value anyway, so the paste is redundant now — and poisonous later:
117+
// selected for a different topic, the prompt carries both, and the keep
118+
// gate rightly refuses to file the plan. Caught here, the feedback
119+
// round fixes it before anything runs.
120+
let recipe = json!({
121+
"why": "poem",
122+
"declared": [{ "name": "topic", "description": "", "required": true }],
123+
"inputs": { "topic": "warm caches" },
124+
"steps": [
125+
{ "id": "write", "ask": "Write a two-line poem about the topic 'warm caches'." }
126+
]
127+
});
128+
let err = lower(&recipe).expect_err("refused").to_string();
129+
assert!(err.contains("pastes the value"), "{err}");
130+
assert!(err.contains("attached automatically"), "{err}");
131+
132+
// The same plan without the paste is exactly what should be written.
133+
let clean = json!({
134+
"why": "poem",
135+
"declared": [{ "name": "topic", "description": "", "required": true }],
136+
"inputs": { "topic": "warm caches" },
137+
"steps": [
138+
{ "id": "write", "ask": "Write a two-line poem about the given topic." }
139+
]
140+
});
141+
lower(&clean).expect("keepable");
142+
}
143+
144+
#[test]
145+
fn an_undeclared_input_value_in_an_ask_is_not_a_paste() {
146+
// Undeclared entries never attach to an ask — the author gate trims
147+
// them — so their values appearing in prose prove nothing about reuse.
148+
let recipe = json!({
149+
"why": "poem",
150+
"inputs": { "stray": "warm caches" },
151+
"steps": [
152+
{ "id": "write", "ask": "Write a two-line poem about warm caches." }
153+
]
154+
});
155+
lower(&recipe).expect("not a paste — nothing declared");
156+
}
157+
158+
#[test]
159+
fn an_indistinct_input_value_in_an_ask_is_not_a_paste() {
160+
// "on" appears in half of all prose; refusing on it would block
161+
// perfectly reusable plans. Only distinctive values count.
162+
let recipe = json!({
163+
"why": "toggle",
164+
"declared": [{ "name": "mode", "description": "", "required": true }],
165+
"inputs": { "mode": "on" },
166+
"steps": [
167+
{ "id": "flip", "ask": "Turn the feature on if the mode input says so." }
168+
]
169+
});
170+
lower(&recipe).expect("not a paste");
171+
}
172+
112173
#[test]
113174
fn a_reply_with_no_steps_says_what_to_return() {
114175
let err = lower(&json!({ "why": "empty" }))

crates/adaptive/src/reuse.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ const DISTINCTIVE_CHARS: [char; 6] = ['/', '.', ':', '@', '_', '-'];
4949
/// port name on every edge in the graph. Treating those as pasted would refuse
5050
/// to keep perfectly reusable procedures, and a gate that fires on noise is one
5151
/// nobody trusts.
52-
fn distinctive(value: &str) -> bool {
52+
pub(crate) fn distinctive(value: &str) -> bool {
5353
let length = value.chars().count();
5454
// A digit only counts alongside some length: `"1"` proves nothing and, via
5555
// the substring test, would match any config containing that character —

crates/adaptive/tests/driver.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,9 +406,15 @@ async fn a_graph_that_was_authored_and_worked_becomes_a_stored_procedure() {
406406
async fn a_graph_that_pasted_its_inputs_is_not_kept() {
407407
// Same run, same success — but the goal's specifics are welded into a
408408
// step, so it matches one task and never another. No model is asked.
409+
//
410+
// The paste sits in a `run` script, not an ask: the intake gate refuses
411+
// ask-pastes outright now, and this test is about the layer BEHIND it —
412+
// keep's own refusal, which still guards every path intake cannot see.
409413
let mut baked = parameterised();
410-
baked["steps"][0]["ask"] =
411-
json!("Review the open pull requests on acme/thing and summarise them.");
414+
baked["steps"] = json!([
415+
{ "id": "review", "run": "gh pr list -R acme/thing" },
416+
{ "id": "report", "ask": "Summarise the review output.", "reads": ["review"] }
417+
]);
412418

413419
let llm = succeeding(baked, true);
414420
let caps = Capabilities {

0 commit comments

Comments
 (0)