Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion crates/adaptive/src/intake/recipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -96,6 +98,17 @@ pub fn lower(answer: &Value) -> Result<(WorkflowGraph, Map<String, Value>, 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,
Expand Down Expand Up @@ -166,6 +179,38 @@ pub fn lower(answer: &Value) -> Result<(WorkflowGraph, Map<String, Value>, 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<String, Value>,
) -> Vec<String> {
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,
Expand Down
61 changes: 61 additions & 0 deletions crates/adaptive/src/intake/recipe_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" }))
Expand Down
2 changes: 1 addition & 1 deletion crates/adaptive/src/reuse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
10 changes: 8 additions & 2 deletions crates/adaptive/tests/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down