Skip to content
Merged
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
53 changes: 53 additions & 0 deletions crates/adaptive/src/closing/consolidate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ pub async fn consolidate(
if rows.is_empty() {
return Vec::new();
}
if was_a_plain_errand(satisfied, &rows) {
return Vec::new();
}
// Everything already stored, not a retrieval view. Retrieval answers "what
// applies to this task" and cuts by help rate, so a lesson written moments
// ago — nothing has had the chance to apply it — sorts last and is dropped.
Expand Down Expand Up @@ -121,6 +124,24 @@ pub async fn consolidate(
kept
}

/// An episode that was one errand, and worked.
///
/// The only shape worth skipping, and the test is deliberately narrow. An
/// errand is a goal with no procedure in it, answered in a turn — there is
/// nothing there for a different task to act on, and asking costs a call to be
/// told so. That is the whole economic case for the errand path, and paying a
/// consolidation call on every trivial goal would give most of it straight back.
///
/// Every other shape still consolidates, including the two that look similar:
///
/// * **an errand that failed** — the most informative errand there is. Something
/// read as one turn of work and was not, and *that* generalises.
/// * **an errand followed by a plan** — more than one row, so the trail is a
/// real one and worth reading whole.
fn was_a_plain_errand(satisfied: bool, rows: &[LedgerRow]) -> bool {
satisfied && rows.len() == 1 && rows[0].approach_sig == "errand"
}

/// One line per attempt, numbered, because the model cites rows by number.
fn render(goal: &Goal, satisfied: bool, rows: &[LedgerRow], existing: &[Lesson]) -> String {
let attempts = rows
Expand Down Expand Up @@ -309,4 +330,36 @@ mod tests {
assert!(rendered.contains("- L7:"), "{rendered}");
assert!(rendered.contains("corroborate by id"), "{rendered}");
}

#[test]
fn a_satisfied_one_turn_errand_is_not_worth_a_consolidation_call() {
// The economics the errand path exists for. Three calls become four if
// every trivial goal still pays a consolidator to be told there was
// nothing in it.
assert!(was_a_plain_errand(true, &[row("r1", "errand")]));
}

#[test]
fn an_errand_that_failed_is_the_most_informative_kind_there_is() {
// Something read as one turn of work and was not. That generalises,
// and it is exactly what the triage needs told back to it.
assert!(!was_a_plain_errand(false, &[row("r1", "errand")]));
}

#[test]
fn an_errand_followed_by_a_real_plan_still_consolidates() {
// More than one row means a real trail, whatever the first row was.
assert!(!was_a_plain_errand(
true,
&[row("r1", "errand"), row("r2", "authored:abc")]
));
}

#[test]
fn an_ordinary_satisfied_episode_is_untouched_by_the_gate() {
// The gate must be narrow: one wrong `true` here silently stops the
// whole loop learning, and nothing downstream would report it.
assert!(!was_a_plain_errand(true, &[row("r1", "selected:weekly")]));
assert!(!was_a_plain_errand(true, &[row("r1", "authored:abc")]));
}
}
9 changes: 7 additions & 2 deletions crates/adaptive/src/closing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,10 @@ pub async fn close(
// scoring its parent instead would leave the two indistinguishable and
// the promotion gate with nothing to compare.
Approach::Selected { workflow_id, .. } => Some(workflow_id.clone()),
Approach::Authored { .. } => None,
// Neither has a stored procedure behind it, so there is no counter to
// move. An errand additionally has nothing to *become* one: the row it
// leaves is the whole record of it.
Approach::Authored { .. } | Approach::Errand { .. } => None,
};
let row_id = ledger
.append(&LedgerRow {
Expand Down Expand Up @@ -265,7 +268,9 @@ fn decide_next(verdict: &Verdict, attempt: u32, stalled: u32, budget: &Budget) -

fn why(approach: &Approach) -> String {
match approach {
Approach::Selected { why, .. } | Approach::Authored { why, .. } => why.clone(),
Approach::Selected { why, .. }
| Approach::Authored { why, .. }
| Approach::Errand { why } => why.clone(),
}
}

Expand Down
57 changes: 57 additions & 0 deletions crates/adaptive/src/contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,25 @@ pub enum Approach {
/// and makes an identical re-author visible as the repeat it is.
fingerprint: String,
},
/// One turn of work with no procedure in it.
///
/// The third answer to "does anything stored do this", and the one that
/// says the question was wrong: some goals are not a procedure at all.
/// "What is the disk usage of this directory" has nothing in it worth
/// writing down, and putting it through authoring pays a large planning
/// call to produce a one-step graph, then files that graph where it dilutes
/// every later selection.
///
/// It is deliberately the *narrowest* of the three. An errand is judged
/// like any other attempt and can fail; what it cannot do is leave anything
/// behind. Nothing is kept ([`crate::closing::keep`] takes only authored
/// graphs), nothing is repaired (there is no procedure to vary), and the
/// signature is a constant so a second errand inside one episode is visibly
/// a repeat — if one turn did not do it, the goal was not an errand.
Errand {
/// Why no stored workflow was needed and none is worth writing.
why: String,
},
}

impl Approach {
Expand All @@ -277,6 +296,13 @@ impl Approach {
match self {
Self::Selected { workflow_id, .. } => format!("selected:{workflow_id}"),
Self::Authored { fingerprint, .. } => format!("authored:{fingerprint}"),
// No discriminator, on purpose. Two authored attempts are told
// apart by their fingerprints because the second may be a genuinely
// different graph; two errands cannot be, because an errand carries
// no plan to differ in. Signing them the same is what makes the
// second one read as the repeat it is — and what lets `decide` stop
// offering the option once it has been spent.
Self::Errand { .. } => "errand".to_string(),
}
}
}
Expand All @@ -285,6 +311,37 @@ impl Approach {
mod tests {
use super::*;

#[test]
fn two_errands_in_one_episode_are_visibly_the_same_attempt() {
// Unlike two authored graphs, which may genuinely differ and are told
// apart by their fingerprints. An errand carries no plan to differ in,
// so the constant signature is what puts it in the exclusion list and
// stops an episode spending its budget on identical single turns.
let first = Approach::Errand {
why: "one turn of work".into(),
};
let second = Approach::Errand {
why: "still one turn, honestly".into(),
};
assert_eq!(first.signature(), "errand");
assert_eq!(first.signature(), second.signature());
}

#[test]
fn an_errand_cannot_collide_with_a_stored_workflow_called_errand() {
// The namespacing that makes the constant safe: `selected:` prefixes
// every workflow id, so no shelf entry can occupy the errand slot.
let selected = Approach::Selected {
workflow_id: "errand".into(),
why: String::new(),
};
assert_eq!(selected.signature(), "selected:errand");
assert_ne!(
selected.signature(),
Approach::Errand { why: String::new() }.signature()
);
}

#[test]
fn an_unrecognised_blocker_is_continuable_rather_than_terminal() {
// `goal_not_meet` — one letter — used to end a run at attempt 3 of 12.
Expand Down
7 changes: 6 additions & 1 deletion crates/adaptive/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,12 @@ impl Loop<'_> {
// and the next attempt writes another, seeing why this one fell
// short. A variant of a one-off is a stored procedure nobody asked
// for.
Approach::Authored { .. } => return None,
//
// An errand is the same argument at its limit — there is no
// procedure at all, only a turn that did not land. What it needs is
// a plan, and `decide` will write one: the errand is spent, so the
// next attempt cannot choose it again.
Approach::Authored { .. } | Approach::Errand { .. } => return None,
};
let evidence = ran.evidence();
if !graph_is_suspect(&closed.verdict, &evidence) {
Expand Down
59 changes: 57 additions & 2 deletions crates/adaptive/src/intake/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use serde_json::{Map, Value};
use tinyflows::caps::Capabilities;
use tinyflows::model::WorkflowGraph;
use tinyflows::store::{WorkflowStore, WorkflowSummary};
use tinyflows::validate::validate_all;

use crate::contracts::{Approach, Goal};
use crate::host::HostFacts;
Expand Down Expand Up @@ -156,7 +157,18 @@ pub async fn decide(

let shown: Vec<String> = lessons.iter().map(|l| l.id.clone()).collect();

if let Some(chosen) = select(goal, &candidates, &past, caps, conn).await? {
// One errand per episode. A goal answered in a turn is an errand; a goal
// that took a turn and is still not done was never one, and offering the
// option again would let an episode spend its whole budget on single turns
// that each fall the same way short.
let errand_allowed = !tried.iter().any(|sig| sig == "errand");

if let Some(chosen) = select(goal, &candidates, &past, errand_allowed, caps, conn).await? {
// An errand names no stored workflow, so there is nothing to bind and
// nothing to load: its graph is lowered from the goal itself.
if matches!(chosen.approach, Approach::Errand { .. }) {
return errand_attempt(goal, chosen, facts, shown);
}
// `select` answers with an id; the graph and the input check come from
// the store. Returning the choice unbound would hand the engine an
// empty graph, which compiles to nothing and reads as the work failing.
Expand All @@ -178,7 +190,11 @@ pub async fn decide(
Supply a value for every required input this time, or \
decline so a graph is written instead."
);
if let Some(retry) = select(goal, &candidates, &noted, caps, conn).await? {
// No errand on the retry: the question was asked and answered
// one call ago, and this round exists to fix a binding slip.
// Re-offering it would let a model that could not fill an input
// reach for the one answer that needs none.
if let Some(retry) = select(goal, &candidates, &noted, false, caps, conn).await? {
match bind(retry, store) {
Ok(attempt) => {
return Ok(Attempt {
Expand Down Expand Up @@ -215,6 +231,45 @@ pub async fn decide(
})
}

/// Finish an errand: lower the one-step graph and hold it to the same gates.
///
/// Both gates matter here even though no model wrote the graph. `validate_all`
/// is free and catches a lowering that stopped producing a runnable shape.
/// `HostFacts::check` is the one that earns its place: an errand is an agent
/// turn, and a host with no agent capability must refuse it as
/// [`Unsupported`](IntakeError::Unsupported) — the same answer authoring would
/// give — rather than emit a graph the runner will fail on and the judge will
/// then blame on the work.
fn errand_attempt(
goal: &Goal,
chosen: Attempt,
facts: &HostFacts,
shown: Vec<String>,
) -> Result<Attempt> {
let graph = recipe::errand(&goal.text)?;

let problems = validate_all(&graph);
if !problems.is_empty() {
return Err(IntakeError::Invalid(
problems
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; "),
));
}
let refused = facts.check(&graph);
if !refused.is_empty() {
return Err(IntakeError::Unsupported(refused.join("; ")));
}

Ok(Attempt {
graph,
lessons_shown: shown,
..chosen
})
}

/// The stored workflows worth offering, with what is known about each.
///
/// Three filters, each removing something a planner must not be shown:
Expand Down
27 changes: 27 additions & 0 deletions crates/adaptive/src/intake/recipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,33 @@ pub fn lower(
Ok((graph, inputs, why))
}

/// The one-step graph an [`Errand`](crate::contracts::Approach::Errand) runs.
///
/// Built by handing [`lower`] a recipe nobody wrote, rather than assembling
/// nodes directly. The graph is trivial enough that hand-building it would look
/// like the simpler option, and that is the trap: it would be a second,
/// unexercised definition of what an `ask` step compiles to, and the first
/// change to the envelope path or the trigger node would leave errands quietly
/// producing a shape the rest of the loop no longer reads. Going through the
/// same door as an authored plan costs one `json!` and cannot drift.
///
/// Deterministic in the goal alone — no model call. The whole economic argument
/// for the errand path is that recognising one is free once `select` has
/// answered, so lowering it must not spend anything either.
///
/// # Errors
/// Only if `lower` refuses this recipe, which would mean the shared lowering
/// path had stopped accepting a bare `ask` step.
pub(crate) fn errand(goal: &str) -> Result<WorkflowGraph, IntakeError> {
let recipe = json!({
"why": "one turn of work, no procedure in it",
"declared": [],
"inputs": {},
"steps": [{ "id": "errand", "ask": goal.trim() }],
});
lower(&recipe, &[]).map(|(graph, _, _)| graph)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// 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
Expand Down
83 changes: 83 additions & 0 deletions crates/adaptive/src/intake/recipe_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -691,3 +691,86 @@ fn a_scripts_stdout_is_read_from_inside_its_json_because_that_is_where_it_is() {
"declared inputs too: {rendered}"
);
}

#[test]
fn an_errand_lowers_to_one_agent_turn_that_validates() {
let graph = super::errand("how much disk is this directory using").expect("lowers");

assert!(
validate_all(&graph).is_empty(),
"the engine must accept it: {:?}",
validate_all(&graph)
);
// A trigger and exactly one agent node. Anything more means the errand
// grew a procedure, which is the one thing it is defined as not having.
assert_eq!(graph.nodes.len(), 2, "{:?}", graph.nodes);
assert_eq!(graph.nodes[0].kind, NodeKind::Trigger);
assert_eq!(graph.nodes[1].kind, NodeKind::Agent);
assert!(
graph.inputs.is_empty(),
"an errand declares nothing: it is answered from the goal alone"
);
}

#[test]
fn an_errands_prompt_actually_carries_the_goal() {
// Evaluated, not string-matched. The `item.json.text` defect shipped past a
// reviewer *and* a test because both read the expression instead of running
// it — a prompt that resolves to nothing looks fine as source.
let graph = super::errand(" how much disk is this directory using ").expect("lowers");
let prompt = graph.nodes[1].config["prompt"]
.as_str()
.expect("the agent node carries a prompt");
let scope = json!({
"run": { "inputs": {} }, "inputs": {},
"item": null, "items": [], "nodes": {}
});
let rendered = tinyflows::expr::resolve(&json!(prompt), &scope);
let rendered = rendered.as_str().expect("resolves to a string");
assert_eq!(
rendered, "how much disk is this directory using",
"the goal, trimmed, and nothing else bolted on"
);
}

#[test]
fn an_errand_is_the_same_lowering_an_authored_ask_gets() {
// Why `errand` goes through `lower` rather than building two nodes by hand:
// a second definition of what an `ask` compiles to would drift silently the
// first time the envelope path changed.
let errand = super::errand("say something").expect("lowers");
let (authored, _, _) = lower(
&json!({
"why": "one turn of work, no procedure in it",
"declared": [], "inputs": {},
"steps": [{ "id": "errand", "ask": "say something" }]
}),
&[],
)
.expect("lowers");
assert_eq!(errand.nodes[1].config, authored.nodes[1].config);
}

#[test]
fn every_control_character_in_a_goal_survives_as_a_valid_jq_literal() {
let scope = json!({ "run": { "inputs": {} }, "inputs": {}, "item": null,
"items": [], "nodes": {} });
let mut broke = Vec::new();
for code in 0u32..0x20 {
let ch = char::from_u32(code).expect("control char");
let goal = format!("disk{ch}usage");
let Ok(graph) = super::errand(&goal) else {
broke.push(format!("U+{code:04X}: lowering refused it"));
continue;
};
let prompt = graph.nodes[1].config["prompt"].as_str().expect("prompt");
let rendered = tinyflows::expr::resolve(&json!(prompt), &scope);
if rendered.as_str().is_none() {
broke.push(format!("U+{code:04X}: {rendered:?}"));
}
}
assert!(
broke.is_empty(),
"control characters that broke the prompt: {broke:?}"
);
}
Loading