From 92300a438793413c150f76fea45f5972818d55d2 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Mon, 17 Aug 2026 20:41:11 +0530 Subject: [PATCH] fix(adaptive): gate the author's own inputs both ways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model that declares a required input and supplies no value has written a graph that dies at the engine's door — the failure cost a whole attempt (harness sessions included) every time it slipped through, as an 'input topic was not supplied' run refusal. bind() already checks a selection in both directions; the author's reply now gets the same treatment inside the feedback loop: a missing required value is refused back to the model, undeclared keys are trimmed. Observed in the field driving the loop from a medulla host. Co-Authored-By: Claude Fable 5 --- crates/adaptive/src/intake/author.rs | 104 ++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/crates/adaptive/src/intake/author.rs b/crates/adaptive/src/intake/author.rs index 5759f1e..0e78667 100644 --- a/crates/adaptive/src/intake/author.rs +++ b/crates/adaptive/src/intake/author.rs @@ -199,13 +199,37 @@ fn gated(answer: &Value, facts: &HostFacts, policy: &dyn HostPolicy) -> Result>, + } + + #[async_trait::async_trait] + impl LlmProvider for Forgetful { + async fn complete( + &self, + request: Value, + _conn: Option<&str>, + ) -> tinyflows::error::Result { + let shown = request["messages"][1]["content"] + .as_str() + .unwrap_or_default() + .to_string(); + let mut prompts = self.prompts.lock().expect("prompt log"); + prompts.push(shown.clone()); + let graph = serde_json::json!({ + "schema_version": 1, "name": "needs-topic", + "inputs": [{ "name": "topic", "type": "string", "required": true }], + "nodes": [{ + "id": "start", "kind": "trigger", "name": "manual", + "config": { "trigger_kind": "manual" } + }], + "edges": [] + }); + let inputs = if prompts.len() == 1 { + serde_json::json!({ "extraneous": "trimmed anyway" }) + } else { + assert!( + shown.contains("topic"), + "the retry names the unsupplied input: {shown}" + ); + serde_json::json!({ "topic": "flash models", "extraneous": "still here" }) + }; + Ok(serde_json::json!({ "graph": graph, "why": "test", "inputs": inputs })) + } + } + + #[derive(Debug, Default)] + struct Permissive; + impl HostPolicy for Permissive {} + + let provider = std::sync::Arc::new(Forgetful { + prompts: Mutex::new(Vec::new()), + }); + let caps = Capabilities { + llm: provider.clone(), + ..mock_capabilities() + }; + let attempt = author( + &Goal::new("do the thing"), + &HostFacts::unknown(), + &Permissive, + "", + &caps, + None, + ) + .await + .expect("the corrected inputs must land"); + assert_eq!(attempt.inputs["topic"], "flash models"); + assert!( + !attempt.inputs.contains_key("extraneous"), + "undeclared inputs are trimmed: {:?}", + attempt.inputs + ); + assert_eq!(provider.prompts.lock().expect("prompt log").len(), 2); + } + #[test] fn a_binding_path_inside_prose_is_refused_with_the_remedy() { use tinyflows::model::{Edge, Node, NodeKind};