Skip to main content

agentd/intel/
mock.rs

1// SPDX-License-Identifier: Apache-2.0
2//! A minimal built-in mock LLM for the observe-to-validate E2E suite (M7).
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: tmp + rename) so the launching
7//! harness discovers the endpoint by waiting for the file — the same
8//! wait-for-path handshake the old unix-socket form had, except the file now
9//! *carries* the address instead of being the socket. The harness then hands
10//! agentd `--intelligence http://<addr>` (loopback plaintext is the dev/test
11//! carve-out; production intelligence is HTTPS-only).
12//!
13//! Speaks just enough OpenAI-compatible `/chat/completions` over that listener
14//! to drive a *real* agentic loop without a live model: it reads the request and
15//! returns a scripted assistant turn — a final answer or a tool call — switching
16//! to a final answer once a tool result appears in the transcript (so the ReAct
17//! cycle closes). Scripts: `final` (answer at once), `read` (call `resource.read`
18//! then answer), `schedule` (call the `schedule` self-tool then answer),
19//! `subscribe` (call the `subscribe` self-tool then answer), `spawn-churn`
20//! (call `subagent.spawn` on *every* turn — never converging — so a run issues
21//! a rapid burst of spawns that trips the spawn-rate limiter, RFC 0009 §3.6);
22//! `slow`/`hang` hold the response to exercise the stuck/deadline detectors.
23//! Small enough to ship; it makes the loop + self-* tools observable end to end.
24//!
25//! **Programmable scripts** (agentd 2.0 e2e): `file:<path>` loads a JSON
26//! playbook so a test scripts any conversation without 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/// Serve the mock LLM until the process is killed, announcing the bound
44/// loopback address through `addr_file`. Returns the exit code.
45pub 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    // One request per connection (the intel client uses Connection: close) —
58    // handled on its OWN thread, so a `slow`/`hang` script sleeps only its own
59    // request. A sequential accept loop serialized every caller behind the
60    // slowest in-flight one, which coupled concurrent tests' timing (the warm-
61    // session flake) and would make two runs sharing one mock queue behind a
62    // 12s hang.
63    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    // A `role:tool` message means the model already called a tool, so the next
92    // turn is a final answer. The `gate` script needs the COUNT (define → run →
93    // final is a three-phase conversation).
94    let tool_results =
95        body.matches("\"role\":\"tool\"").count() + body.matches("\"role\": \"tool\"").count();
96    let saw_tool_result = tool_results > 0;
97    // `slow`/`hang`: hold the response so the calling subagent stays alive in the
98    // model call — `slow` (5s) lets the chaos suite catch a live subagent before
99    // collapsing the tree; `hang` (long) keeps a run alive so a cancel/drain test
100    // proves it was the teardown (not natural completion) that ended it.
101    let script = match script {
102        "slow" => {
103            std::thread::sleep(std::time::Duration::from_secs(5));
104            "final"
105        }
106        "hang" => {
107            // Long enough to outlive a cancel/drain (kill ladder ~7s) so a test
108            // proves the teardown ended it — but bounded so a *leaked* (uncancelled)
109            // hang run can't pin the process-wide supervise lock for long.
110            std::thread::sleep(std::time::Duration::from_secs(12));
111            "final"
112        }
113        other => other,
114    };
115    // RFC 0021 §7 e2e: the model AUTHORS a workflow with a `human` gate, runs
116    // it (the run blocks while the gate awaits the A2A reply), then answers.
117    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
143/// Read an HTTP/1.1 request: headers up to the blank line, then `Content-Length`
144/// body bytes. Returns the request body (the chat-completions JSON).
145fn 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; // EOF mid-headers
152        }
153        let t = line.trim_end();
154        if t.is_empty() {
155            break; // end of headers
156        }
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
169/// The scripted assistant turn as an OpenAI chat-completion body.
170fn 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        // Call an MCP *server* tool by its (unprefixed) catalogue name — the
175        // bench harness (RFC 0024) points a stub MCP server that serves
176        // `bench_echo` so the eval rig exercises a real tools/call round-trip
177        // end to end (offline). Turn 2 answers once the tool result is seen.
178        ("mcp-call", false) => tool_call("bench_echo", r#"{"query":"ping"}"#),
179        ("mcp-call", true) => final_answer("bench tool called"),
180        // Call a sandboxed `bash` tool the bench shell-bridge serves (RFC 0024
181        // Phase 2, the SWE-bench / Terminal-Bench environment shape), then answer.
182        ("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        // Delegate an objective to a declared remote A2A peer named "peer"
192        // (RFC 0020 §3), then — once the distillate comes back as a tool result —
193        // answer. Drives the agentd-as-A2A-client path end to end.
194        ("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        // Unlike read/schedule (which answer once a tool result is seen),
200        // spawn-churn ignores `saw_tool_result` and keeps emitting a
201        // `subagent.spawn` call every turn — so an in-loop run fires many rapid,
202        // detached spawns and exercises the spawn-rate limiter end to end. detach
203        // keeps each accepted spawn fire-and-forget so the burst stays rapid.
204        ("spawn-churn", _) => tool_call(
205            "subagent.spawn",
206            r#"{"instruction":"do a trivial subtask","detach":true}"#,
207        ),
208        // A structured JSON answer for the workflow `infer` node tests: the exec
209        // parses + schema-checks this object.
210        ("json", _) => final_answer(r#"{"verdict":"approve","score":9}"#),
211        _ => final_answer("mock-llm done"),
212    }
213}
214
215/// Answer from a `file:` playbook (see the module doc): a `match` rule by
216/// request-body substring first, else the turn indexed by the number of tool
217/// results already in the transcript.
218fn 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        // turn 1: a resource.read tool call
318        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        // turn 2 (a tool result was seen): the final answer
323        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        // Every turn — whether or not a tool result was seen — is another
338        // subagent.spawn with a valid (non-empty) instruction, so the run keeps
339        // hammering the chokepoint instead of answering.
340        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        // Turn 0: the scripted tool call (arguments serialized as a JSON string).
362        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        // Turn 1 (one tool result seen): the final answer with the scripted usage.
370        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        // Beyond the last turn: clamps to the last.
379        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        // A match rule wins over the turn index; object content is serialized.
386        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}