use crate::sdk::convert::{lua_value_from_json, value_to_json};
use luft_core::contract::backend::AgentTask;
use luft_core::contract::finding::Finding;
use luft_core::contract::ids::PhaseId;
use luft_core::journal::AgentCacheKey;
use mlua::{Lua, Table, Value};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
pub(crate) fn build_task(
opts: &Table,
phase_id: PhaseId,
agent_seq_counter: &Arc<AtomicU32>,
) -> mlua::Result<(AgentTask, AgentCacheKey, Option<String>)> {
let prompt: String = opts
.get("prompt")
.map_err(|_| mlua::Error::RuntimeError("agent: missing required 'prompt' field".into()))?;
let model: Option<String> = opts.get::<Option<String>>("model").ok().flatten();
let backend: Option<String> = opts.get::<Option<String>>("backend").ok().flatten();
let description: Option<String> = opts.get::<Option<String>>("description").ok().flatten();
let role: Option<String> = opts.get::<Option<String>>("role").ok().flatten();
let name: Option<String> = opts.get::<Option<String>>("name").ok().flatten();
let timeout = opts
.get::<i64>("timeout_ms")
.ok()
.filter(|v| *v > 0)
.map(|v| Duration::from_millis(v as u64));
let output_schema = match opts.get::<Value>("schema") {
Ok(Value::Table(t)) => Some(value_to_json(Value::Table(t))?),
Ok(Value::String(s)) => serde_json::from_str(&s.to_str()?).ok(),
Ok(Value::Boolean(b)) => Some(serde_json::Value::Bool(b)),
_ => None,
};
let prompt = match &output_schema {
Some(_) => format!(
"{prompt}\n\n\
---\n\
IMPORTANT: You MUST call the `structured_output` tool to submit your result.\n\
Do NOT return the result as a text message. Call the tool.",
prompt = prompt,
),
None => prompt,
};
let workdir_override = opts
.get::<Option<String>>("working_folder")
.ok()
.flatten()
.map(PathBuf::from);
let cache_key = AgentCacheKey::new(&prompt, model.as_deref(), phase_id);
let agent_seq = agent_seq_counter.fetch_add(1, Ordering::Relaxed);
let task = AgentTask {
agent_id: uuid::Uuid::now_v7(),
phase_id,
prompt,
model,
description,
role,
name,
agent_seq,
allowlist: None,
workdir: PathBuf::from("."),
mcp_endpoint: None,
timeout,
output_schema,
workdir_override,
thread_id: None,
};
Ok((task, cache_key, backend))
}
pub(crate) fn build_result_table(
lua: &Lua,
status: &str,
output: serde_json::Value,
tokens: u64,
findings: &[Finding],
) -> mlua::Result<Table> {
let t = lua.create_table()?;
t.set("status", status)?;
t.set("ok", status == "ok")?;
t.set("output", lua_value_from_json(lua, output)?)?;
t.set("tokens", tokens as i64)?;
let ft = lua.create_table()?;
for (i, f) in findings.iter().enumerate() {
let e = lua.create_table()?;
e.set("kind", f.kind.as_str())?;
e.set("severity", format!("{:?}", f.severity).to_lowercase())?;
e.set("title", f.title.as_str())?;
e.set("detail", f.detail.as_str())?;
ft.set(i + 1, e)?;
}
t.set("findings", ft)?;
Ok(t)
}
#[cfg(test)]
mod tests {
use super::*;
use luft_core::contract::finding::Severity;
use mlua::Lua;
fn opts(lua: &Lua) -> Table {
lua.create_table().unwrap()
}
fn seq_counter() -> Arc<AtomicU32> {
Arc::new(AtomicU32::new(0))
}
#[test]
fn build_task_requires_prompt() {
let lua = Lua::new();
assert!(build_task(&opts(&lua), 1, &seq_counter()).is_err());
}
#[test]
fn build_task_parses_recognised_fields() {
let lua = Lua::new();
let o = opts(&lua);
o.set("prompt", "do it").unwrap();
o.set("model", "claude-x").unwrap();
o.set("backend", "acp").unwrap();
o.set("timeout_ms", 5000).unwrap();
let (task, _key, backend) = build_task(&o, 7, &seq_counter()).unwrap();
assert_eq!(task.prompt, "do it");
assert_eq!(task.model.as_deref(), Some("claude-x"));
assert_eq!(task.phase_id, 7);
assert_eq!(task.timeout, Some(Duration::from_millis(5000)));
assert_eq!(backend.as_deref(), Some("acp"));
}
#[test]
fn build_task_drops_nonpositive_timeout() {
let lua = Lua::new();
let o = opts(&lua);
o.set("prompt", "x").unwrap();
o.set("timeout_ms", 0).unwrap();
let (task, _, _) = build_task(&o, 0, &seq_counter()).unwrap();
assert!(task.timeout.is_none());
}
#[test]
fn build_task_schema_injects_tool_call_instruction() {
let lua = Lua::new();
let o = opts(&lua);
o.set("prompt", "analyze").unwrap();
let schema = lua.create_table().unwrap();
schema.set("type", "object").unwrap();
let props = lua.create_table().unwrap();
props.set("x", lua.create_table().unwrap()).unwrap();
schema.set("properties", props).unwrap();
o.set("schema", schema).unwrap();
let (task, _, _) = build_task(&o, 0, &seq_counter()).unwrap();
assert!(task.prompt.contains("IMPORTANT"));
assert!(task.prompt.contains("structured_output"));
assert!(task.prompt.contains("tool"));
assert!(!task.prompt.contains("JSON Schema"));
assert!(task.output_schema.is_some());
}
#[test]
fn build_task_no_schema_keeps_prompt_clean() {
let lua = Lua::new();
let o = opts(&lua);
o.set("prompt", "just text").unwrap();
let (task, _, _) = build_task(&o, 0, &seq_counter()).unwrap();
assert_eq!(task.prompt, "just text");
assert!(task.output_schema.is_none());
}
#[test]
fn build_task_cache_key_is_deterministic() {
let lua = Lua::new();
let key = |phase| {
let o = opts(&lua);
o.set("prompt", "same prompt").unwrap();
o.set("model", "m").unwrap();
build_task(&o, phase, &seq_counter()).unwrap().1.hash
};
assert_eq!(key(3), key(3));
assert_ne!(key(3), key(4));
}
#[test]
fn result_table_ok_flag_and_findings_shape() {
let lua = Lua::new();
let findings = vec![Finding {
kind: "missing_auth".into(),
severity: Severity::High,
title: "t".into(),
detail: "d".into(),
location: None,
evidence: vec![],
data: serde_json::Value::Null,
}];
let t =
build_result_table(&lua, "ok", serde_json::json!({ "x": 1 }), 42, &findings).unwrap();
assert_eq!(t.get::<String>("status").unwrap(), "ok");
assert!(t.get::<bool>("ok").unwrap());
assert_eq!(t.get::<i64>("tokens").unwrap(), 42);
let ft: Table = t.get("findings").unwrap();
assert_eq!(ft.raw_len(), 1);
let f0: Table = ft.get(1).unwrap();
assert_eq!(f0.get::<String>("kind").unwrap(), "missing_auth");
assert_eq!(f0.get::<String>("severity").unwrap(), "high");
}
#[test]
fn result_table_ok_false_for_non_ok_status() {
let lua = Lua::new();
let t = build_result_table(&lua, "error", serde_json::Value::Null, 0, &[]).unwrap();
assert!(!t.get::<bool>("ok").unwrap());
}
}