1use std::io::{BufRead, BufReader, Read, Write};
41use std::net::{TcpListener, TcpStream};
42
43pub fn run(addr_file: &str, script: &str) -> i32 {
46 let listener = match TcpListener::bind("127.0.0.1:0") {
47 Ok(l) => l,
48 Err(e) => {
49 eprintln!("mock-llm: bind 127.0.0.1:0: {e}");
50 return crate::exit::GENERIC;
51 }
52 };
53 if let Err(e) = crate::announce_addr(addr_file, &listener) {
54 eprintln!("mock-llm: write {addr_file}: {e}");
55 return crate::exit::GENERIC;
56 }
57 for stream in listener.incoming().flatten() {
64 let script = script.to_string();
65 std::thread::spawn(move || handle(stream, &script));
66 }
67 0
68}
69
70fn handle(mut stream: TcpStream, script: &str) {
71 let Some(body) = read_request_body(&mut stream) else {
72 return;
73 };
74 if let Some(path) = script.strip_prefix("file:") {
75 let payload = match std::fs::read_to_string(path)
76 .map_err(|e| e.to_string())
77 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).map_err(|e| e.to_string()))
78 {
79 Ok(playbook) => playbook_response(&playbook, &body),
80 Err(e) => final_answer(&format!("mock-llm: cannot load playbook {path}: {e}")),
81 };
82 let resp = format!(
83 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
84 payload.len(),
85 payload
86 );
87 let _ = stream.write_all(resp.as_bytes());
88 let _ = stream.flush();
89 return;
90 }
91 let tool_results =
95 body.matches("\"role\":\"tool\"").count() + body.matches("\"role\": \"tool\"").count();
96 let saw_tool_result = tool_results > 0;
97 let script = match script {
102 "slow" => {
103 std::thread::sleep(std::time::Duration::from_secs(5));
104 "final"
105 }
106 "hang" => {
107 std::thread::sleep(std::time::Duration::from_secs(12));
111 "final"
112 }
113 other => other,
114 };
115 let payload = if script == "gate" {
118 match tool_results {
119 0 => tool_call(
120 "workflow.define",
121 r#"{"workflow":{"start":"gate","nodes":{
122 "gate":{"kind":"human","payload":{"question":"approve the deploy?"},
123 "timeout_ms":30000,"writes":"verdict",
124 "edges":{"replied":"done","timeout":"esc"}},
125 "done":{"kind":"halt","status":"completed","result_from":"verdict"},
126 "esc":{"kind":"halt","status":"refused"}}}}"#,
127 ),
128 1 => tool_call("workflow.run", r#"{"workflow_id":"w1"}"#),
129 _ => final_answer("gate flow complete"),
130 }
131 } else {
132 response_json(script, saw_tool_result)
133 };
134 let resp = format!(
135 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
136 payload.len(),
137 payload
138 );
139 let _ = stream.write_all(resp.as_bytes());
140 let _ = stream.flush();
141}
142
143fn read_request_body(stream: &mut TcpStream) -> Option<String> {
146 let mut reader = BufReader::new(stream);
147 let mut content_length = 0usize;
148 loop {
149 let mut line = String::new();
150 if reader.read_line(&mut line).ok()? == 0 {
151 return None; }
153 let t = line.trim_end();
154 if t.is_empty() {
155 break; }
157 if let Some(v) = t
158 .strip_prefix("Content-Length:")
159 .or_else(|| t.strip_prefix("content-length:"))
160 {
161 content_length = v.trim().parse().unwrap_or(0);
162 }
163 }
164 let mut body = vec![0u8; content_length];
165 reader.read_exact(&mut body).ok()?;
166 Some(String::from_utf8_lossy(&body).into_owned())
167}
168
169fn response_json(script: &str, saw_tool_result: bool) -> String {
171 match (script, saw_tool_result) {
172 ("read", false) => tool_call("resource.read", r#"{"uri":"file:///in.json"}"#),
173 ("read", true) => final_answer("read complete"),
174 ("mcp-call", false) => tool_call("bench_echo", r#"{"query":"ping"}"#),
179 ("mcp-call", true) => final_answer("bench tool called"),
180 ("shell-call", false) => tool_call("bash", r#"{"command":"echo pong > out.txt"}"#),
183 ("shell-call", true) => final_answer("ran the command"),
184 ("schedule", false) => tool_call(
185 "schedule",
186 r#"{"after_seconds":1,"instruction":"follow up"}"#,
187 ),
188 ("schedule", true) => final_answer("scheduled a follow-up"),
189 ("subscribe", false) => tool_call("subscribe", r#"{"uri":"file:///watch.json"}"#),
190 ("subscribe", true) => final_answer("now watching the resource"),
191 ("a2a-delegate", false) => tool_call(
195 "a2a.delegate",
196 r#"{"peer":"peer","objective":"summarize the mesh","output_contract":"one line"}"#,
197 ),
198 ("a2a-delegate", true) => final_answer("delegated over a2a"),
199 ("spawn-churn", _) => tool_call(
205 "subagent.spawn",
206 r#"{"instruction":"do a trivial subtask","detach":true}"#,
207 ),
208 ("json", _) => final_answer(r#"{"verdict":"approve","score":9}"#),
211 _ => final_answer("mock-llm done"),
212 }
213}
214
215fn playbook_response(playbook: &serde_json::Value, body: &str) -> String {
219 let tool_results =
220 body.matches("\"role\":\"tool\"").count() + body.matches("\"role\": \"tool\"").count();
221 let turn = playbook
222 .get("match")
223 .and_then(serde_json::Value::as_array)
224 .and_then(|rules| {
225 rules.iter().find(|r| {
226 r.get("when_contains")
227 .and_then(serde_json::Value::as_str)
228 .is_some_and(|needle| body.contains(needle))
229 })
230 })
231 .or_else(|| {
232 let turns = playbook.get("turns")?.as_array()?;
233 turns.get(tool_results.min(turns.len().saturating_sub(1)))
234 });
235 let Some(turn) = turn else {
236 return final_answer("mock-llm: empty playbook");
237 };
238 if let Some(ms) = turn.get("delay_ms").and_then(serde_json::Value::as_u64) {
239 std::thread::sleep(std::time::Duration::from_millis(ms));
240 }
241 let usage = turn.get("usage").cloned();
242 let mut resp: serde_json::Value = if let Some(calls) =
243 turn.get("tool_calls").and_then(serde_json::Value::as_array)
244 {
245 let tool_calls: Vec<serde_json::Value> = calls
246 .iter()
247 .enumerate()
248 .map(|(i, c)| {
249 let args = c.get("arguments").cloned().unwrap_or(serde_json::json!({}));
250 let args = match args {
251 serde_json::Value::String(s) => s,
252 other => other.to_string(),
253 };
254 serde_json::json!({"id": format!("call_{}", i + 1), "type": "function",
255 "function": {"name": c.get("name").and_then(serde_json::Value::as_str).unwrap_or(""), "arguments": args}})
256 })
257 .collect();
258 serde_json::json!({
259 "choices": [{"message": {"role": "assistant", "content": serde_json::Value::Null, "tool_calls": tool_calls},
260 "finish_reason": "tool_calls"}],
261 "usage": {"prompt_tokens": 11, "completion_tokens": 7}
262 })
263 } else {
264 let content = match turn.get("content") {
265 Some(serde_json::Value::String(s)) => s.clone(),
266 Some(other) => other.to_string(),
267 None => "mock-llm done".to_string(),
268 };
269 serde_json::json!({
270 "choices": [{"message": {"role": "assistant", "content": content}, "finish_reason": "stop"}],
271 "usage": {"prompt_tokens": 11, "completion_tokens": 5}
272 })
273 };
274 if let Some(u) = usage {
275 resp["usage"] = u;
276 }
277 resp.to_string()
278}
279
280fn final_answer(text: &str) -> String {
281 serde_json::json!({
282 "choices": [{"message": {"role": "assistant", "content": text}, "finish_reason": "stop"}],
283 "usage": {"prompt_tokens": 11, "completion_tokens": 5}
284 })
285 .to_string()
286}
287
288fn tool_call(name: &str, args: &str) -> String {
289 serde_json::json!({
290 "choices": [{
291 "message": {
292 "role": "assistant",
293 "content": serde_json::Value::Null,
294 "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": name, "arguments": args}}]
295 },
296 "finish_reason": "tool_calls"
297 }],
298 "usage": {"prompt_tokens": 11, "completion_tokens": 7}
299 })
300 .to_string()
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use crate::intel::openai;
307
308 #[test]
309 fn final_script_parses_to_a_completed_answer() {
310 let resp = openai::parse_response(response_json("final", false).as_bytes()).unwrap();
311 assert_eq!(resp.text.as_deref(), Some("mock-llm done"));
312 assert!(resp.tool_calls.is_empty());
313 }
314
315 #[test]
316 fn read_script_calls_then_answers() {
317 let turn1 = openai::parse_response(response_json("read", false).as_bytes()).unwrap();
319 assert!(turn1.wants_tools());
320 assert_eq!(turn1.tool_calls[0].name, "resource.read");
321 assert_eq!(turn1.tool_calls[0].arguments["uri"], "file:///in.json");
322 let turn2 = openai::parse_response(response_json("read", true).as_bytes()).unwrap();
324 assert!(!turn2.wants_tools());
325 assert_eq!(turn2.text.as_deref(), Some("read complete"));
326 }
327
328 #[test]
329 fn schedule_script_calls_the_schedule_tool() {
330 let turn1 = openai::parse_response(response_json("schedule", false).as_bytes()).unwrap();
331 assert_eq!(turn1.tool_calls[0].name, "schedule");
332 assert_eq!(turn1.tool_calls[0].arguments["after_seconds"], 1);
333 }
334
335 #[test]
336 fn spawn_churn_never_converges() {
337 for saw_tool in [false, true] {
341 let turn =
342 openai::parse_response(response_json("spawn-churn", saw_tool).as_bytes()).unwrap();
343 assert!(turn.wants_tools(), "spawn-churn must keep calling tools");
344 assert_eq!(turn.tool_calls[0].name, "subagent.spawn");
345 assert_eq!(
346 turn.tool_calls[0].arguments["instruction"],
347 "do a trivial subtask"
348 );
349 }
350 }
351
352 #[test]
353 fn a_playbook_answers_by_match_rule_then_by_turn_index() {
354 let pb: serde_json::Value = serde_json::json!({
355 "turns": [
356 {"tool_calls": [{"name": "memory.set", "arguments": {"key": "k", "value": 1}}]},
357 {"content": "all done", "usage": {"prompt_tokens": 500, "completion_tokens": 50}}
358 ],
359 "match": [{"when_contains": "PREFLIGHT", "content": {"intent": "status"}}]
360 });
361 let t0 = openai::parse_response(
363 playbook_response(&pb, r#"{"messages":[{"role":"user","content":"hi"}]}"#).as_bytes(),
364 )
365 .unwrap();
366 assert!(t0.wants_tools());
367 assert_eq!(t0.tool_calls[0].name, "memory.set");
368 assert_eq!(t0.tool_calls[0].arguments["value"], 1);
369 let t1 = openai::parse_response(
371 playbook_response(&pb, r#"{"messages":[{"role":"tool","content":"ok"}]}"#).as_bytes(),
372 )
373 .unwrap();
374 assert!(!t1.wants_tools());
375 assert_eq!(t1.text.as_deref(), Some("all done"));
376 assert_eq!(t1.usage.input_tokens, 500);
377 assert_eq!(t1.usage.output_tokens, 50);
378 let t9 = openai::parse_response(
380 playbook_response(&pb, r#"[{"role":"tool"},{"role":"tool"},{"role":"tool"}]"#)
381 .as_bytes(),
382 )
383 .unwrap();
384 assert_eq!(t9.text.as_deref(), Some("all done"));
385 let m = openai::parse_response(
387 playbook_response(
388 &pb,
389 r#"{"messages":[{"role":"system","content":"PREFLIGHT"}]}"#,
390 )
391 .as_bytes(),
392 )
393 .unwrap();
394 assert_eq!(m.text.as_deref(), Some(r#"{"intent":"status"}"#));
395 }
396
397 #[test]
398 fn subscribe_script_calls_the_subscribe_tool() {
399 let turn1 = openai::parse_response(response_json("subscribe", false).as_bytes()).unwrap();
400 assert_eq!(turn1.tool_calls[0].name, "subscribe");
401 assert_eq!(turn1.tool_calls[0].arguments["uri"], "file:///watch.json");
402 let turn2 = openai::parse_response(response_json("subscribe", true).as_bytes()).unwrap();
403 assert!(!turn2.wants_tools());
404 }
405}