1use std::io::{BufRead, BufReader, Read, Write};
41use std::net::{TcpListener, TcpStream};
42
43pub fn inprocess(script: &str) -> Result<String, String> {
51 use std::collections::HashMap;
52 use std::sync::{Mutex, OnceLock};
53 static SERVERS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
54 let map = SERVERS.get_or_init(|| Mutex::new(HashMap::new()));
55 if let Some(addr) = map.lock().expect("mock servers").get(script) {
56 return Ok(addr.clone());
57 }
58 let addr_file = std::env::temp_dir().join(format!(
59 "agentd-mockintel-{}-{}.addr",
60 std::process::id(),
61 crate::sha::sha256_hex(script.as_bytes())
62 .chars()
63 .take(12)
64 .collect::<String>()
65 ));
66 let _ = std::fs::remove_file(&addr_file);
67 let (af, sc) = (addr_file.to_string_lossy().to_string(), script.to_string());
68 std::thread::Builder::new()
69 .name("mock-intel".into())
70 .spawn(move || run(&af, &sc))
71 .map_err(|e| format!("mock intelligence: spawn: {e}"))?;
72 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
73 loop {
74 if let Ok(addr) = std::fs::read_to_string(&addr_file) {
75 let addr = addr.trim().to_string();
76 if !addr.is_empty() {
77 map.lock()
78 .expect("mock servers")
79 .insert(script.to_string(), addr.clone());
80 return Ok(addr);
81 }
82 }
83 if std::time::Instant::now() > deadline {
84 return Err("mock intelligence: server never announced its address".into());
85 }
86 std::thread::sleep(std::time::Duration::from_millis(5));
87 }
88}
89
90pub fn run(addr_file: &str, script: &str) -> i32 {
93 let listener = match TcpListener::bind("127.0.0.1:0") {
94 Ok(l) => l,
95 Err(e) => {
96 eprintln!("mock-llm: bind 127.0.0.1:0: {e}");
97 return crate::exit::GENERIC;
98 }
99 };
100 if let Err(e) = crate::announce_addr(addr_file, &listener) {
101 eprintln!("mock-llm: write {addr_file}: {e}");
102 return crate::exit::GENERIC;
103 }
104 for stream in listener.incoming().flatten() {
111 let script = script.to_string();
112 std::thread::spawn(move || handle(stream, &script));
113 }
114 0
115}
116
117fn handle(mut stream: TcpStream, script: &str) {
118 let Some(body) = read_request_body(&mut stream) else {
119 return;
120 };
121 if let Some(path) = script.strip_prefix("file:") {
122 let payload = match std::fs::read_to_string(path)
123 .map_err(|e| e.to_string())
124 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).map_err(|e| e.to_string()))
125 {
126 Ok(playbook) => playbook_response(&playbook, &body),
127 Err(e) => final_answer(&format!("mock-llm: cannot load playbook {path}: {e}")),
128 };
129 let resp = format!(
130 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
131 payload.len(),
132 payload
133 );
134 let _ = stream.write_all(resp.as_bytes());
135 let _ = stream.flush();
136 return;
137 }
138 if script == "echo-system" {
143 let sys = serde_json::from_str::<serde_json::Value>(&body)
144 .ok()
145 .and_then(|v| {
146 v["messages"].as_array().and_then(|ms| {
147 ms.iter()
148 .find(|m| m["role"] == "system")
149 .and_then(|m| m["content"].as_str().map(str::to_string))
150 })
151 })
152 .unwrap_or_else(|| "(no system message)".to_string());
153 let payload = final_answer(&sys);
154 let resp = format!(
155 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
156 payload.len(),
157 payload
158 );
159 let _ = stream.write_all(resp.as_bytes());
160 let _ = stream.flush();
161 return;
162 }
163 let tool_results =
167 body.matches("\"role\":\"tool\"").count() + body.matches("\"role\": \"tool\"").count();
168 let saw_tool_result = tool_results > 0;
169 let script = match script {
174 "slow" => {
175 std::thread::sleep(std::time::Duration::from_secs(5));
176 "final"
177 }
178 "hang" => {
179 std::thread::sleep(std::time::Duration::from_secs(12));
183 "final"
184 }
185 other => other,
186 };
187 let payload = if script == "gate" {
190 match tool_results {
191 0 => tool_call(
192 "workflow.define",
193 r#"{"workflow":{"start":"gate","nodes":{
194 "gate":{"kind":"human","payload":{"question":"approve the deploy?"},
195 "timeout_ms":30000,"writes":"verdict",
196 "edges":{"replied":"done","timeout":"esc"}},
197 "done":{"kind":"halt","status":"completed","result_from":"verdict"},
198 "esc":{"kind":"halt","status":"refused"}}}}"#,
199 ),
200 1 => tool_call("workflow.run", r#"{"workflow_id":"w1"}"#),
201 _ => final_answer("gate flow complete"),
202 }
203 } else {
204 response_json(script, saw_tool_result)
205 };
206 let resp = format!(
207 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
208 payload.len(),
209 payload
210 );
211 let _ = stream.write_all(resp.as_bytes());
212 let _ = stream.flush();
213}
214
215fn read_request_body(stream: &mut TcpStream) -> Option<String> {
218 let mut reader = BufReader::new(stream);
219 let mut content_length = 0usize;
220 loop {
221 let mut line = String::new();
222 if reader.read_line(&mut line).ok()? == 0 {
223 return None; }
225 let t = line.trim_end();
226 if t.is_empty() {
227 break; }
229 if let Some(v) = t
230 .strip_prefix("Content-Length:")
231 .or_else(|| t.strip_prefix("content-length:"))
232 {
233 content_length = v.trim().parse().unwrap_or(0);
234 }
235 }
236 let mut body = vec![0u8; content_length];
237 reader.read_exact(&mut body).ok()?;
238 Some(String::from_utf8_lossy(&body).into_owned())
239}
240
241fn response_json(script: &str, saw_tool_result: bool) -> String {
243 match (script, saw_tool_result) {
244 ("read", false) => tool_call("resource.read", r#"{"uri":"file:///in.json"}"#),
245 ("read", true) => final_answer("read complete"),
246 ("mcp-call", false) => tool_call("bench_echo", r#"{"query":"ping"}"#),
251 ("mcp-call", true) => final_answer("bench tool called"),
252 ("shell-call", false) => tool_call("bash", r#"{"command":"echo pong > out.txt"}"#),
255 ("shell-call", true) => final_answer("ran the command"),
256 ("schedule", false) => tool_call(
257 "schedule",
258 r#"{"after_seconds":1,"instruction":"follow up"}"#,
259 ),
260 ("schedule", true) => final_answer("scheduled a follow-up"),
261 ("subscribe", false) => tool_call("subscribe", r#"{"uri":"file:///watch.json"}"#),
262 ("subscribe", true) => final_answer("now watching the resource"),
263 ("a2a-delegate", false) => tool_call(
267 "a2a.delegate",
268 r#"{"peer":"peer","objective":"summarize the mesh","output_contract":"one line"}"#,
269 ),
270 ("a2a-delegate", true) => final_answer("delegated over a2a"),
271 ("spawn-churn", _) => tool_call(
278 "subagent.spawn",
279 r#"{"instruction":"do a trivial subtask","detach":true}"#,
280 ),
281 ("wf-tool", false) => tool_call("billing.refund", r#"{"order_id":"A1"}"#),
290 ("wf-tool", true) => final_answer("refund started"),
291 ("wf-once", false) => tool_call("workflow.run", r#"{"name":"loop"}"#),
292 ("wf-once", true) => final_answer("started the workflow"),
293 ("json", _) => final_answer(r#"{"verdict":"approve","score":9}"#),
296 _ => final_answer("mock-llm done"),
297 }
298}
299
300fn playbook_response(playbook: &serde_json::Value, body: &str) -> String {
304 let tool_results =
305 body.matches("\"role\":\"tool\"").count() + body.matches("\"role\": \"tool\"").count();
306 let turn = playbook
307 .get("match")
308 .and_then(serde_json::Value::as_array)
309 .and_then(|rules| {
310 rules.iter().find(|r| {
311 r.get("when_contains")
312 .and_then(serde_json::Value::as_str)
313 .is_some_and(|needle| body.contains(needle))
314 })
315 })
316 .or_else(|| {
317 let turns = playbook.get("turns")?.as_array()?;
318 turns.get(tool_results.min(turns.len().saturating_sub(1)))
319 });
320 let Some(turn) = turn else {
321 return final_answer("mock-llm: empty playbook");
322 };
323 if let Some(ms) = turn.get("delay_ms").and_then(serde_json::Value::as_u64) {
324 std::thread::sleep(std::time::Duration::from_millis(ms));
325 }
326 let usage = turn.get("usage").cloned();
327 let mut resp: serde_json::Value = if let Some(calls) =
328 turn.get("tool_calls").and_then(serde_json::Value::as_array)
329 {
330 let tool_calls: Vec<serde_json::Value> = calls
331 .iter()
332 .enumerate()
333 .map(|(i, c)| {
334 let args = c.get("arguments").cloned().unwrap_or(serde_json::json!({}));
335 let args = match args {
336 serde_json::Value::String(s) => s,
337 other => other.to_string(),
338 };
339 serde_json::json!({"id": format!("call_{}", i + 1), "type": "function",
340 "function": {"name": c.get("name").and_then(serde_json::Value::as_str).unwrap_or(""), "arguments": args}})
341 })
342 .collect();
343 serde_json::json!({
344 "choices": [{"message": {"role": "assistant", "content": serde_json::Value::Null, "tool_calls": tool_calls},
345 "finish_reason": "tool_calls"}],
346 "usage": {"prompt_tokens": 11, "completion_tokens": 7}
347 })
348 } else {
349 let content = match turn.get("content") {
350 Some(serde_json::Value::String(s)) => s.clone(),
351 Some(other) => other.to_string(),
352 None => "mock-llm done".to_string(),
353 };
354 serde_json::json!({
355 "choices": [{"message": {"role": "assistant", "content": content}, "finish_reason": "stop"}],
356 "usage": {"prompt_tokens": 11, "completion_tokens": 5}
357 })
358 };
359 if let Some(u) = usage {
360 resp["usage"] = u;
361 }
362 resp.to_string()
363}
364
365fn final_answer(text: &str) -> String {
366 serde_json::json!({
367 "choices": [{"message": {"role": "assistant", "content": text}, "finish_reason": "stop"}],
368 "usage": {"prompt_tokens": 11, "completion_tokens": 5}
369 })
370 .to_string()
371}
372
373fn tool_call(name: &str, args: &str) -> String {
374 serde_json::json!({
375 "choices": [{
376 "message": {
377 "role": "assistant",
378 "content": serde_json::Value::Null,
379 "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": name, "arguments": args}}]
380 },
381 "finish_reason": "tool_calls"
382 }],
383 "usage": {"prompt_tokens": 11, "completion_tokens": 7}
384 })
385 .to_string()
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::intel::openai;
392
393 #[test]
394 fn final_script_parses_to_a_completed_answer() {
395 let resp = openai::parse_response(response_json("final", false).as_bytes()).unwrap();
396 assert_eq!(resp.text.as_deref(), Some("mock-llm done"));
397 assert!(resp.tool_calls.is_empty());
398 }
399
400 #[test]
401 fn read_script_calls_then_answers() {
402 let turn1 = openai::parse_response(response_json("read", false).as_bytes()).unwrap();
404 assert!(turn1.wants_tools());
405 assert_eq!(turn1.tool_calls[0].name, "resource.read");
406 assert_eq!(turn1.tool_calls[0].arguments["uri"], "file:///in.json");
407 let turn2 = openai::parse_response(response_json("read", true).as_bytes()).unwrap();
409 assert!(!turn2.wants_tools());
410 assert_eq!(turn2.text.as_deref(), Some("read complete"));
411 }
412
413 #[test]
414 fn schedule_script_calls_the_schedule_tool() {
415 let turn1 = openai::parse_response(response_json("schedule", false).as_bytes()).unwrap();
416 assert_eq!(turn1.tool_calls[0].name, "schedule");
417 assert_eq!(turn1.tool_calls[0].arguments["after_seconds"], 1);
418 }
419
420 #[test]
421 fn spawn_churn_never_converges() {
422 for saw_tool in [false, true] {
426 let turn =
427 openai::parse_response(response_json("spawn-churn", saw_tool).as_bytes()).unwrap();
428 assert!(turn.wants_tools(), "spawn-churn must keep calling tools");
429 assert_eq!(turn.tool_calls[0].name, "subagent.spawn");
430 assert_eq!(
431 turn.tool_calls[0].arguments["instruction"],
432 "do a trivial subtask"
433 );
434 }
435 }
436
437 #[test]
438 fn a_playbook_answers_by_match_rule_then_by_turn_index() {
439 let pb: serde_json::Value = serde_json::json!({
440 "turns": [
441 {"tool_calls": [{"name": "memory.set", "arguments": {"key": "k", "value": 1}}]},
442 {"content": "all done", "usage": {"prompt_tokens": 500, "completion_tokens": 50}}
443 ],
444 "match": [{"when_contains": "PREFLIGHT", "content": {"intent": "status"}}]
445 });
446 let t0 = openai::parse_response(
448 playbook_response(&pb, r#"{"messages":[{"role":"user","content":"hi"}]}"#).as_bytes(),
449 )
450 .unwrap();
451 assert!(t0.wants_tools());
452 assert_eq!(t0.tool_calls[0].name, "memory.set");
453 assert_eq!(t0.tool_calls[0].arguments["value"], 1);
454 let t1 = openai::parse_response(
456 playbook_response(&pb, r#"{"messages":[{"role":"tool","content":"ok"}]}"#).as_bytes(),
457 )
458 .unwrap();
459 assert!(!t1.wants_tools());
460 assert_eq!(t1.text.as_deref(), Some("all done"));
461 assert_eq!(t1.usage.input_tokens, 500);
462 assert_eq!(t1.usage.output_tokens, 50);
463 let t9 = openai::parse_response(
465 playbook_response(&pb, r#"[{"role":"tool"},{"role":"tool"},{"role":"tool"}]"#)
466 .as_bytes(),
467 )
468 .unwrap();
469 assert_eq!(t9.text.as_deref(), Some("all done"));
470 let m = openai::parse_response(
472 playbook_response(
473 &pb,
474 r#"{"messages":[{"role":"system","content":"PREFLIGHT"}]}"#,
475 )
476 .as_bytes(),
477 )
478 .unwrap();
479 assert_eq!(m.text.as_deref(), Some(r#"{"intent":"status"}"#));
480 }
481
482 #[test]
483 fn subscribe_script_calls_the_subscribe_tool() {
484 let turn1 = openai::parse_response(response_json("subscribe", false).as_bytes()).unwrap();
485 assert_eq!(turn1.tool_calls[0].name, "subscribe");
486 assert_eq!(turn1.tool_calls[0].arguments["uri"], "file:///watch.json");
487 let turn2 = openai::parse_response(response_json("subscribe", true).as_bytes()).unwrap();
488 assert!(!turn2.wants_tools());
489 }
490}