Skip to main content

agentd/intel/
mock.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! A minimal built-in mock LLM for the observe-to-validate E2E suite.
3//! Hidden mode: `agentd --internal-mock-llm <addr-file> [script]`.
4//!
5//! Binds a **loopback TCP** listener on `127.0.0.1:0` and writes the bound
6//! `host:port` into `<addr-file>` atomically, via tmp-plus-rename, so the
7//! launching harness can discover the endpoint by waiting for the file to
8//! appear and is never able to read a half-written address. The harness then
9//! hands agentd `--intelligence http://<addr>`; loopback plaintext is the
10//! dev/test carve-out, since production intelligence is HTTPS-only.
11//!
12//! Speaks just enough OpenAI-compatible `/chat/completions` over that listener
13//! to drive a *real* agentic loop without a live model: it reads the request and
14//! returns a scripted assistant turn — a final answer or a tool call — switching
15//! to a final answer once a tool result appears in the transcript, so the ReAct
16//! cycle closes instead of spinning. Scripts: `final` (answer at once), `read`
17//! (call `resource.read` then answer), `schedule` (call the `schedule` self-tool
18//! then answer), `subscribe` (call the `subscribe` self-tool then answer),
19//! `spawn-churn` (call `subagent.spawn` on *every* turn, never converging, so a
20//! run issues a rapid burst of spawns and trips the spawn-rate limiter);
21//! `slow`/`hang` hold the response to exercise the stuck/deadline detectors.
22//! Small enough to ship, and it makes the loop and the self-* tools observable
23//! end to end.
24//!
25//! **Programmable scripts:** `file:<path>` loads a JSON playbook, so a test can
26//! script any conversation without adding a new built-in:
27//!
28//! ```json
29//! { "turns": [ {"tool_calls": [{"name": "memory.set", "arguments": {"key": "k", "value": 1}}]},
30//!              {"content": "done", "usage": {"prompt_tokens": 100, "completion_tokens": 20}} ],
31//!   "match": [ {"when_contains": "\"preflight\"", "content": "{\"intent\":\"task\"}"} ] }
32//! ```
33//!
34//! `match[]` rules are tried first (the first whose `when_contains` substring
35//! appears in the request body answers); otherwise `turns[i]` answers where `i`
36//! is the number of `role: tool` messages already in the transcript (clamped to
37//! the last turn). A turn carries `content` (final text) or `tool_calls`, an
38//! optional `usage`, and an optional `delay_ms`.
39
40use std::io::{BufRead, BufReader, Read, Write};
41use std::net::{TcpListener, TcpStream};
42
43/// Start the mock IN-PROCESS and return its loopback address. This backs the
44/// `intelligence.endpoints: mock:<script>` convenience, so a config runs fully
45/// offline in ONE process: no key, no network, no second terminal. At most one
46/// server is started per distinct script string and the address is memoised, so
47/// repeated resolves of the same script share a listener instead of leaking a
48/// thread per call. Scripts are the same as the hidden mode's
49/// (`final` | `read` | `schedule` | `file:<playbook.json>`).
50pub 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
90/// Serve the mock LLM until the process is killed, announcing the bound
91/// loopback address through `addr_file`. Returns the exit code.
92pub 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    // One request per connection, since the intel client sends
105    // `Connection: close`. Each is handled on its OWN thread so that a
106    // `slow`/`hang` script sleeps only its own request: a sequential accept loop
107    // would serialize every caller behind the slowest in-flight one, coupling
108    // concurrent tests' timing and queueing two runs that share one mock behind
109    // a 12s hang.
110    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    // `echo-system`: answer with the SYSTEM message verbatim. The system prompt
139    // is whatever an operator's `context.template` produced, so echoing it makes
140    // that observable end to end — a test asserts on what a model actually
141    // receives rather than on an internal function's return value.
142    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    // A `role:tool` message means the model already called a tool, so the next
164    // turn is a final answer. The `gate` script needs the COUNT (define → run →
165    // final is a three-phase conversation).
166    let tool_results =
167        body.matches("\"role\":\"tool\"").count() + body.matches("\"role\": \"tool\"").count();
168    let saw_tool_result = tool_results > 0;
169    // `slow`/`hang`: hold the response so the calling subagent stays alive in the
170    // model call — `slow` (5s) lets the chaos suite catch a live subagent before
171    // collapsing the tree; `hang` (long) keeps a run alive so a cancel/drain test
172    // proves it was the teardown (not natural completion) that ended it.
173    let script = match script {
174        "slow" => {
175            std::thread::sleep(std::time::Duration::from_secs(5));
176            "final"
177        }
178        "hang" => {
179            // Long enough to outlive a cancel/drain (kill ladder ~7s) so a test
180            // proves the teardown ended it — but bounded so a *leaked* (uncancelled)
181            // hang run can't pin the process-wide supervise lock for long.
182            std::thread::sleep(std::time::Duration::from_secs(12));
183            "final"
184        }
185        other => other,
186    };
187    // `gate`: the model AUTHORS a workflow containing a `human` gate, runs it —
188    // the run blocks while the gate awaits the A2A reply — and then answers.
189    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
215/// Read an HTTP/1.1 request: headers up to the blank line, then `Content-Length`
216/// body bytes. Returns the request body (the chat-completions JSON).
217fn 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; // EOF mid-headers
224        }
225        let t = line.trim_end();
226        if t.is_empty() {
227            break; // end of headers
228        }
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
241/// The scripted assistant turn as an OpenAI chat-completion body.
242fn 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        // Call an MCP *server* tool by its unprefixed catalogue name. The bench
247        // harness points agentd at a stub MCP server serving `bench_echo`, so
248        // the eval rig exercises a real tools/call round-trip end to end while
249        // staying offline. Turn 2 answers once the tool result is seen.
250        ("mcp-call", false) => tool_call("bench_echo", r#"{"query":"ping"}"#),
251        ("mcp-call", true) => final_answer("bench tool called"),
252        // Call a sandboxed `bash` tool served by the bench shell-bridge — the
253        // SWE-bench / Terminal-Bench environment shape — then answer.
254        ("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        // Delegate an objective to a declared remote A2A peer named "peer" and
264        // then answer once the distillate comes back as a tool result. Drives
265        // the agentd-as-A2A-client path end to end.
266        ("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        // Unlike read/schedule, which answer once a tool result is seen,
272        // spawn-churn ignores `saw_tool_result` and emits another
273        // `subagent.spawn` on every turn, so an in-loop run fires many rapid
274        // detached spawns and exercises the spawn-rate limiter end to end.
275        // `detach` keeps each accepted spawn fire-and-forget, which is what
276        // keeps the burst fast enough to reach the limiter.
277        ("spawn-churn", _) => tool_call(
278            "subagent.spawn",
279            r#"{"instruction":"do a trivial subtask","detach":true}"#,
280        ),
281        // Starts one workflow per turn, then answers. Paired with a workflow
282        // whose only step messages the agent back, this is the
283        // message → turn → run → message cycle in its shortest form — the one
284        // the hop cap has to stop. Exactly one run per turn keeps the chain
285        // LINEAR: a script that called on every turn would fan out instead,
286        // and would be testing the step limiter rather than the hop cap.
287        // Call a workflow REGISTERED AS A TOOL by its tool name, proving the
288        // registration is callable and not merely advertised.
289        ("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        // A structured JSON answer for the workflow `infer` node tests: the
294        // executor parses and schema-checks this object.
295        ("json", _) => final_answer(r#"{"verdict":"approve","score":9}"#),
296        _ => final_answer("mock-llm done"),
297    }
298}
299
300/// Answer from a `file:` playbook (see the module doc): a `match` rule by
301/// request-body substring first, else the turn indexed by the number of tool
302/// results already in the transcript.
303fn 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        // turn 1: a resource.read tool call
403        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        // turn 2 (a tool result was seen): the final answer
408        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        // Every turn — whether or not a tool result was seen — is another
423        // subagent.spawn with a valid (non-empty) instruction, so the run keeps
424        // hammering the chokepoint instead of answering.
425        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        // Turn 0: the scripted tool call (arguments serialized as a JSON string).
447        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        // Turn 1 (one tool result seen): the final answer with the scripted usage.
455        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        // Beyond the last turn: clamps to the last.
464        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        // A match rule wins over the turn index; object content is serialized.
471        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}