use std::sync::Arc;
use serde_json::json;
use super::flow::agent;
use super::workflow_tool::WorkflowRecipe;
use crate::error::Error;
use crate::tool::builtins::Question;
const CRITERIA_PROMPT: &str = "\
You are a requirements analyst. From the user's feature request below, produce \
3 to 8 ACCEPTANCE CRITERIA as markdown bullets. Each criterion MUST be phrased \
as OBSERVABLE BEHAVIOR — something a human or a command can verify (\"a human or \
a command can verify X\"), concrete and testable. Do NOT mention implementation \
details (no file names, function names, libraries, or internal design). Output \
ONLY the bulleted criteria, one per line, nothing else.\n\nFeature request:\n";
const GAP_PROMPT: &str = "\
You are a completeness critic. The question is NOT \"is this done?\" but \"what \
does this request IMPLY yet leave UNSPECIFIED?\". Examine the feature request \
and its extracted acceptance criteria below, then surface every gap.\n\n\
Classify each gap:\n\
- ALREADY SPECIFIED in the request (a stated location, language, scope, \
constraint): NOT a gap — never raise it as a question or re-litigate it.\n\
- HIGH-GUESS-RATE (output format / naming / wording): safe to assume — state a \
reasonable default as an ASSUMPTION, do NOT ask.\n\
- LOW-GUESS-RATE (scope / risk / user intent / destructive-vs-additive): asking \
beats guessing — raise it as a QUESTION.\n\
Every option you offer MUST honor every stated constraint — never include a \
choice that contradicts the request.\n\n\
Output STRICT JSON ONLY (no prose, no markdown fences), exactly this shape:\n\
{\"assumptions\": [\"...\"], \"questions\": [{\"question\": \"...\", \"header\": \
\"...\", \"options\": [{\"label\": \"...\", \"description\": \"...\"}], \
\"multiple\": false}]}\n\
Rules: 0 to 4 questions, ONLY for low-guess gaps; each question has a short \
header (<=12 chars) and 2 to 4 options; `multiple` is a boolean. If nothing is \
unspecified, return empty arrays.\n\n";
#[derive(serde::Deserialize, Default)]
struct GapReply {
#[serde(default)]
assumptions: Vec<String>,
#[serde(default)]
questions: Vec<Question>,
}
fn render(criteria: &str, assumptions: &[String], questions: &[Question]) -> String {
let criteria = {
let t = criteria.trim();
if t.is_empty() {
"(no criteria extracted)"
} else {
t
}
};
let assumptions_block = if assumptions.is_empty() {
"- (none)".to_string()
} else {
assumptions
.iter()
.map(|a| format!("- {}", a.trim()))
.collect::<Vec<_>>()
.join("\n")
};
let questions_block = if questions.is_empty() {
"(none — proceed)".to_string()
} else {
serde_json::to_string_pretty(questions).unwrap_or_else(|_| "(none — proceed)".to_string())
};
format!(
"## Acceptance criteria\n{criteria}\n\n\
## Assumptions (proceeding without asking)\n{assumptions_block}\n\n\
## Questions for the user (ask via the question tool BEFORE building)\n\
{questions_block}"
)
}
pub(crate) fn recipe() -> WorkflowRecipe {
WorkflowRecipe {
name: "intake".into(),
description: "Turn a feature request into observable acceptance criteria \
+ load-bearing gaps before building — the completion-loop \
front half."
.into(),
args_schema: json!({
"type": "object",
"properties": {
"request": {
"type": "string",
"description": "the user's feature request, verbatim"
}
},
"required": ["request"]
}),
run: Arc::new(|ctx, args| {
Box::pin(async move {
let request = args
.get("request")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|r| !r.is_empty())
.ok_or_else(|| Error::Agent("intake: 'request' is required".into()))?
.to_string();
let criteria = agent(&ctx, format!("{CRITERIA_PROMPT}{request}"))
.label("intake:criteria")
.model("fast")
.run()
.await?
.unwrap_or_default();
let gap_text = agent(
&ctx,
format!(
"{GAP_PROMPT}Feature request:\n{request}\n\n\
Extracted acceptance criteria:\n{criteria}"
),
)
.label("intake:gaps")
.model("fast")
.run()
.await?
.unwrap_or_default();
let (assumptions, questions) = parse_gaps(&gap_text);
Ok(render(&criteria, &assumptions, &questions))
})
}),
}
}
fn parse_gaps(text: &str) -> (Vec<String>, Vec<Question>) {
let trimmed = text.trim();
match parse_gap_json(trimmed) {
Some(reply) => {
let questions = reply
.questions
.into_iter()
.filter(valid_question)
.take(4)
.collect();
(reply.assumptions, questions)
}
None => {
let assumption = if trimmed.is_empty() {
Vec::new()
} else {
vec![trimmed.to_string()]
};
(assumption, Vec::new())
}
}
}
fn parse_gap_json(text: &str) -> Option<GapReply> {
if let Ok(reply) = serde_json::from_str::<GapReply>(text) {
return Some(reply);
}
let start = text.find('{')?;
let end = text.rfind('}')?;
if end <= start {
return None;
}
serde_json::from_str::<GapReply>(&text[start..=end]).ok()
}
fn valid_question(q: &Question) -> bool {
!q.question.trim().is_empty() && q.options.len() >= 2
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BoxedProvider;
use crate::agent::flow::WorkflowCtx;
use crate::llm::LlmProvider;
use crate::llm::types::{
CompletionRequest, CompletionResponse, ContentBlock, StopReason, TokenUsage,
};
use std::sync::{Arc, Mutex};
struct RoutedProvider {
criteria_reply: String,
gap_reply: String,
captured: Arc<Mutex<Vec<CompletionRequest>>>,
}
impl RoutedProvider {
fn text(t: &str) -> CompletionResponse {
CompletionResponse {
content: vec![ContentBlock::Text { text: t.into() }],
stop_reason: StopReason::EndTurn,
reasoning: None,
usage: TokenUsage::default(),
model: None,
}
}
}
impl LlmProvider for RoutedProvider {
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, Error> {
let prompt: String = request
.messages
.iter()
.flat_map(|m| m.content.iter())
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect();
self.captured.lock().expect("capture lock").push(request);
let reply = if prompt.contains("completeness critic") {
self.gap_reply.as_str()
} else if prompt.contains("requirements analyst") {
self.criteria_reply.as_str()
} else {
"unexpected prompt"
};
Ok(Self::text(reply))
}
}
async fn run_recipe(
criteria_reply: &str,
gap_reply: &str,
args: serde_json::Value,
) -> Result<String, Error> {
let captured = Arc::new(Mutex::new(Vec::new()));
let provider = Arc::new(BoxedProvider::from_arc(Arc::new(RoutedProvider {
criteria_reply: criteria_reply.to_string(),
gap_reply: gap_reply.to_string(),
captured,
})));
let ctx = WorkflowCtx::builder(provider).build().expect("ctx");
let r = recipe();
(r.run)(ctx, args).await
}
#[tokio::test(flavor = "multi_thread")]
async fn extracts_observable_acceptance_criteria() {
let criteria = "- the /health endpoint returns 200\n\
- a command can curl /health and see OK\n\
- the response body contains a status field";
let out = run_recipe(
criteria,
r#"{"assumptions": [], "questions": []}"#,
serde_json::json!({"request": "add a /health endpoint"}),
)
.await
.unwrap();
assert!(
out.contains("## Acceptance criteria"),
"missing criteria header: {out}"
);
assert!(out.contains("the /health endpoint returns 200"), "{out}");
assert!(
out.contains("the response body contains a status field"),
"{out}"
);
assert!(out.contains("(none — proceed)"), "{out}");
}
#[tokio::test(flavor = "multi_thread")]
async fn elicits_missing_low_guess_requirements() {
let gap = r#"{
"assumptions": ["output is rendered as markdown bullets"],
"questions": [{
"question": "Should deleting a record be destructive or soft-delete?",
"header": "Delete",
"options": [
{"label": "Hard delete", "description": "remove the row permanently"},
{"label": "Soft delete", "description": "mark a deleted flag"}
],
"multiple": false
}]
}"#;
let out = run_recipe(
"- a record can be deleted",
gap,
serde_json::json!({"request": "let users delete records"}),
)
.await
.unwrap();
assert!(
out.contains("Should deleting a record be destructive or soft-delete?"),
"question text missing: {out}"
);
assert!(
out.contains("## Assumptions (proceeding without asking)"),
"{out}"
);
assert!(
out.contains("output is rendered as markdown bullets"),
"assumption missing: {out}"
);
assert!(out.contains("Soft delete"), "options missing: {out}");
}
#[tokio::test(flavor = "multi_thread")]
async fn malformed_critic_json_degrades_gracefully() {
let garbage = "I think you should probably ask about auth, maybe? not sure.";
let out = run_recipe(
"- the feature works",
garbage,
serde_json::json!({"request": "build a thing"}),
)
.await
.unwrap();
assert!(out.contains("(none — proceed)"), "{out}");
assert!(
out.contains("I think you should probably ask about auth"),
"raw critic text must surface as an assumption: {out}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn empty_request_is_rejected() {
let err = run_recipe("-", "{}", serde_json::json!({"request": " "}))
.await
.unwrap_err();
assert!(err.to_string().contains("request"), "{err}");
let err2 = run_recipe("-", "{}", serde_json::json!({}))
.await
.unwrap_err();
assert!(err2.to_string().contains("request"), "{err2}");
}
#[test]
fn intake_recipe_registered_in_default_registry() {
let reg = crate::agent::workflow_tool::default_registry();
assert!(reg.get("intake").is_some(), "intake must be registered");
let meta = reg.meta();
assert!(
meta.iter().any(|(n, _)| n == "intake"),
"intake must appear in registry meta"
);
}
#[test]
fn questions_with_too_few_options_are_dropped() {
let (assumptions, questions) = parse_gaps(
r#"{"assumptions": ["x"], "questions": [
{"question": "Q?", "header": "H", "options": [{"label": "a", "description": "d"}], "multiple": false}
]}"#,
);
assert_eq!(assumptions, vec!["x".to_string()]);
assert!(questions.is_empty(), "1-option question must be dropped");
}
#[test]
fn extra_questions_are_capped_at_four() {
let one = r#"{"question":"Q?","header":"H","options":[{"label":"a","description":"d"},{"label":"b","description":"e"}],"multiple":false}"#;
let json =
format!(r#"{{"assumptions": [], "questions": [{one},{one},{one},{one},{one},{one}]}}"#);
let (_a, questions) = parse_gaps(&json);
assert_eq!(questions.len(), 4, "questions cap at 4");
}
#[test]
fn json_wrapped_in_prose_is_recovered() {
let (assumptions, questions) = parse_gaps(
"Here is my analysis:\n{\"assumptions\": [\"a1\"], \"questions\": []}\nDone.",
);
assert_eq!(assumptions, vec!["a1".to_string()]);
assert!(questions.is_empty());
}
}