//! `/v1/respond` — the **execution** API (distinct from `/v1/chat`, the //! completion API). //! //! This is the consolidation move called out in the 2026-04-23 session: //! lift the proven pipeline from `tests/multi-agent/orchestrator.ts` //! (executor → reviewer → escalate → validate → seal playbook → //! write-through to KB) into the gateway, so the production path has //! the intelligence the tests already proved. //! //! `/v1/chat` stays a naive completion proxy for callers that want one. //! `/v1/respond` is where the loop lives. Every orchestrator-style //! caller migrates here and the TS harnesses become thin clients. //! //! This file holds the HTTP surface + request/response shapes. The loop //! itself lives in `execution_loop::ExecutionLoop`. use axum::{extract::State, http::StatusCode, Json}; use serde::{Deserialize, Serialize}; use super::V1State; use crate::execution_loop::{ExecutionLoop, LogEntry, RespondOutcome}; /// A structured task — mirrors `TaskSpec` in `tests/multi-agent/agent.ts`. /// Kept deliberately open so non-staffing task classes (code-gen, /// DevOps-long-horizon) can land without a schema fight. #[derive(Deserialize, Debug, Clone)] pub struct RespondRequest { /// Task class — routes to the right truth rules + validator. For the /// staffing substrate: `staffing.fill`, `staffing.rescue`, /// `staffing.sms_draft`. Truth-layer lookup is a no-op until a rule /// set is registered for the class. pub task_class: String, /// Human-readable operation description — becomes the playbook /// `operation` field on seal, and the primary signal for /// playbook_memory embedding. pub operation: String, /// Free-form structured context. Passed to the executor prompt and /// to the playbook seeder. Staffing tasks expect /// `{target_role, target_count, target_city, target_state, approach_hint}` /// but nothing here validates that — the validator crate will (Phase 43). #[serde(default)] pub spec: serde_json::Value, /// Executor model. Defaults to the hot-path local model if omitted. /// See orchestrator.ts:28 (`EXECUTOR_MODEL = "qwen3.5:latest"`). #[serde(default)] pub executor_model: Option, /// Reviewer model. Defaults to the hot-path local reviewer. /// See orchestrator.ts:29 (`REVIEWER_MODEL = "qwen3:latest"`). #[serde(default)] pub reviewer_model: Option, /// Hard cap on executor turns. Default matches orchestrator.ts:30 /// (`MAX_TURNS = 12`). Cloud escalation counts as a turn. #[serde(default)] pub max_turns: Option, } #[derive(Serialize)] pub struct RespondResponse { /// `ok` = consensus reached, playbook sealed. `failed` = loop ran /// out of turns or hit the drift cap. `blocked` = truth-layer /// veto (Phase 42 rule citation in `error`). pub status: &'static str, /// The final artifact — for staffing fills, `{fills: [{candidate_id, name}]}`. /// Empty on failure / block. pub artifact: serde_json::Value, /// Structured cross-turn log. Same shape as orchestrator.ts LogEntry /// so existing tooling (kb extractors, fact_extractor.ts) reads it /// without change. pub log: Vec, /// Iteration count actually used. ≤ max_turns. Stamped on /// outcomes.jsonl per the indicator audit (2026-04-23). pub iterations: u32, /// Error message on non-ok status. Truth-rule citations land here /// when `status == "blocked"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, } pub async fn respond( State(state): State, Json(req): Json, ) -> Result, (StatusCode, String)> { if req.operation.is_empty() { return Err((StatusCode::BAD_REQUEST, "operation must be non-empty".into())); } if req.task_class.is_empty() { return Err((StatusCode::BAD_REQUEST, "task_class must be non-empty".into())); } let mut loop_runner = ExecutionLoop::new(state, req); let outcome = loop_runner.run().await.map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("execution loop: {e}")) })?; let (status, error) = match &outcome { RespondOutcome::Ok { .. } => ("ok", None), RespondOutcome::Failed { reason, .. } => ("failed", Some(reason.clone())), RespondOutcome::Blocked { reason, .. } => ("blocked", Some(reason.clone())), }; Ok(Json(RespondResponse { status, artifact: outcome.artifact(), log: outcome.into_log(), iterations: loop_runner.turns_used(), error, })) } #[cfg(test)] mod tests { use super::*; #[test] fn respond_request_parses_minimal() { let raw = r#"{ "task_class": "staffing.fill", "operation": "fill: Welder x2 in Toledo, OH" }"#; let r: RespondRequest = serde_json::from_str(raw).unwrap(); assert_eq!(r.task_class, "staffing.fill"); assert_eq!(r.executor_model, None); assert_eq!(r.max_turns, None); } #[test] fn respond_request_parses_full() { let raw = r#"{ "task_class": "staffing.fill", "operation": "fill: Welder x2 in Toledo, OH", "spec": {"target_role": "Welder", "target_count": 2, "target_city": "Toledo", "target_state": "OH"}, "executor_model": "qwen3.5:latest", "reviewer_model": "qwen3:latest", "max_turns": 12 }"#; let r: RespondRequest = serde_json::from_str(raw).unwrap(); assert_eq!(r.executor_model.as_deref(), Some("qwen3.5:latest")); assert_eq!(r.max_turns, Some(12)); assert_eq!(r.spec["target_count"], 2); } }