Skip to main content

agentmux_srv/ambient/
cli.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The Claude CLI subprocess behind every ambient call: spawn it as bare text
5//! generation, race it against cancellation and a timeout, and parse its
6//! stream-json reply.
7
8use crate::backend::obj;
9use super::sanitize::sanitize_ambient_text;
10
11/// Where ambient side calls run: `<data dir>/ambient-calls`, created on
12/// demand; `None` (inherit srv's cwd, as before) only if it cannot be made.
13/// No agent works there, so no agent's history ever matches its transcripts
14/// (#3629).
15pub(crate) fn ambient_call_cwd() -> Option<std::path::PathBuf> {
16    let dir = crate::backend::base::get_mux_data_dir().join("ambient-calls");
17    std::fs::create_dir_all(&dir).ok()?;
18    Some(dir)
19}
20
21/// The model every ambient call runs on.
22pub const AMBIENT_MODEL: &str = "claude-haiku-4-5-20251001";
23
24/// Invoke the Claude CLI on [`AMBIENT_MODEL`] for one ambient call, within
25/// `timeout` (the purpose's own, `purpose::Purpose::timeout`).
26///
27/// `cancel` is this call's Ambient Model Call gateway cancellation token — if
28/// a newer request for the same `(entity, purpose)` key is admitted while
29/// this one is still running, `cancel` fires and the child process is killed
30/// immediately rather than left to run to completion (and keep burning
31/// tokens) only to have its result discarded on arrival.
32///
33/// Returns the response text plus token usage parsed from the CLI's `result`
34/// stream-json line (same `usage` shape the main turn pipeline parses —
35/// see `agents::translator::claude::parse_usage`), so ambient calls are
36/// never silently excluded from token accounting.
37pub async fn invoke_haiku(
38    cli_path: &str,
39    prompt: &str,
40    meta: &obj::MetaMapType,
41    cancel: tokio_util::sync::CancellationToken,
42    timeout: std::time::Duration,
43) -> Result<(String, Option<crate::agents::TokenCounts>), String> {
44    let auth_env = crate::backend::blockcontroller::cmd_env_of(meta);
45
46    let mut cmd = crate::server::cli_handlers::make_cli_cmd(cli_path);
47    // A bare text-generation call, not an agent session: no tools, one turn, no
48    // slash commands or MCP servers, nothing saved, and our own system prompt in
49    // place of Claude Code's. With the defaults the model answered as a
50    // conversational assistant ("I don't have access to...") whenever the
51    // digest was thin, and that text reached the UI.
52    cmd.args(["-p", "--output-format", "stream-json", "--verbose",
53              "--model", AMBIENT_MODEL,
54              "--tools", "", "--max-turns", "1",
55              "--no-session-persistence", "--disable-slash-commands",
56              "--strict-mcp-config",
57              "--system-prompt", crate::ambient::prompt::AMBIENT_SYSTEM_PROMPT])
58        .envs(&auth_env);
59    // Run in a scratch dir of our own, never srv's cwd (the user's home):
60    // Claude files each call's transcript under its cwd's project folder,
61    // and an agent working in that same dir would find every side call —
62    // whose prompts quote other agents' conversations — in its own history
63    // (#3629).
64    if let Some(dir) = ambient_call_cwd() {
65        cmd.current_dir(dir);
66    }
67    let mut child = cmd
68        .stdin(std::process::Stdio::piped())
69        .stdout(std::process::Stdio::piped())
70        .stderr(std::process::Stdio::null())
71        .kill_on_drop(true)
72        .spawn()
73        .map_err(|e| format!("failed to spawn the ambient CLI: {e}"))?;
74
75    if let Some(mut stdin) = child.stdin.take() {
76        use tokio::io::AsyncWriteExt;
77        stdin.write_all(prompt.as_bytes()).await
78            .map_err(|e| format!("ambient CLI stdin write: {e}"))?;
79        stdin.shutdown().await
80            .map_err(|e| format!("ambient CLI stdin shutdown: {e}"))?;
81    }
82
83    // `child.wait()` only borrows (unlike `wait_with_output()`, which
84    // consumes `child` by value and would make the cancel branch's
85    // `child.kill()` below impossible). Drain stdout concurrently via a
86    // separate task — same rationale `wait_with_output()` itself uses
87    // internally — so a chatty response can't fill the OS pipe buffer and
88    // deadlock the child waiting to write while nothing is reading.
89    let mut stdout_pipe = child.stdout.take()
90        .ok_or_else(|| "ambient CLI: no stdout pipe".to_string())?;
91    let stdout_task = tokio::spawn(async move {
92        use tokio::io::AsyncReadExt;
93        let mut buf = Vec::new();
94        let _ = stdout_pipe.read_to_end(&mut buf).await;
95        buf
96    });
97
98    let status = tokio::select! {
99        biased;
100        _ = cancel.cancelled() => {
101            let _ = child.kill().await;
102            return Err("cancelled: superseded by a newer request".to_string());
103        }
104        result = tokio::time::timeout(timeout, child.wait()) => {
105            result.map_err(|_| format!("ambient CLI timed out after {}s", timeout.as_secs()))?
106                .map_err(|e| format!("ambient CLI wait: {e}"))?
107        }
108    };
109
110    if !status.success() {
111        return Err(format!("ambient CLI exited with status {status}"));
112    }
113
114    let stdout_bytes = stdout_task.await
115        .map_err(|e| format!("ambient CLI stdout reader task: {e}"))?;
116    let stdout = String::from_utf8_lossy(&stdout_bytes);
117    // An empty reply is a valid answer ("nothing to say"), and the call still cost
118    // tokens, so it comes back as `Ok` with empty text rather than an error that
119    // would drop the usage. Callers already treat empty text as "no result".
120    Ok(parse_cli_stream(&stdout))
121}
122
123/// The last assistant text block (sanitized) and the usage from a `claude -p
124/// --output-format stream-json` transcript. Text is empty when the model said
125/// nothing, or only wrapped nothing in a fence.
126fn parse_cli_stream(stdout: &str) -> (String, Option<crate::agents::TokenCounts>) {
127    let mut last_text = String::new();
128    let mut tokens: Option<crate::agents::TokenCounts> = None;
129    for line in stdout.lines() {
130        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else { continue };
131        match val.get("type").and_then(|v| v.as_str()) {
132            Some("assistant") => {
133                if let Some(content) = val.get("message")
134                    .and_then(|m| m.get("content"))
135                    .and_then(|c| c.as_array())
136                {
137                    for block in content {
138                        if block.get("type").and_then(|v| v.as_str()) == Some("text") {
139                            if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
140                                last_text = text.trim().to_string();
141                            }
142                        }
143                    }
144                }
145            }
146            Some("result") => {
147                tokens = Some(crate::agents::translator::claude::parse_usage(val.get("usage")));
148            }
149            _ => {}
150        }
151    }
152    (sanitize_ambient_text(&last_text), tokens)
153}
154
155#[cfg(test)]
156mod ambient_call_cwd_tests {
157    /// #3629: side calls run in a dir of their own under the data dir —
158    /// never srv's cwd (the user's home), where an agent may work.
159    #[test]
160    fn side_calls_run_in_their_own_scratch_dir() {
161        let dir = super::ambient_call_cwd().expect("the scratch dir can be made");
162        assert!(dir.is_dir());
163        assert!(dir.ends_with("ambient-calls"));
164        assert!(dir.starts_with(crate::backend::base::get_mux_data_dir()));
165        assert_ne!(Some(dir.as_path()), dirs::home_dir().as_deref());
166    }
167}
168
169#[cfg(test)]
170mod ambient_cli_stream_tests {
171    use super::*;
172
173    #[test]
174    fn an_empty_fence_is_empty_text_but_the_usage_survives() {
175        let out = [
176            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"```\n```"}]}}"#,
177            r#"{"type":"result","usage":{"input_tokens":235,"output_tokens":4}}"#,
178        ].join("\n");
179        let (text, tokens) = parse_cli_stream(&out);
180        assert_eq!(text, "");
181        assert!(tokens.is_some());
182    }
183
184    #[test]
185    fn the_last_assistant_text_wins_and_thinking_blocks_are_ignored() {
186        let out = [
187            r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"hmm"}]}}"#,
188            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Run the tests"}]}}"#,
189            r#"{"type":"result","usage":{"input_tokens":1,"output_tokens":2}}"#,
190        ].join("\n");
191        assert_eq!(parse_cli_stream(&out).0, "Run the tests");
192    }
193
194    #[test]
195    fn a_transcript_with_no_result_line_has_no_usage() {
196        let out = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"x"}]}}"#;
197        assert!(parse_cli_stream(out).1.is_none());
198    }
199}
200
201/// Needs a logged-in `claude` on PATH and spends a few tokens, so it is not part
202/// of the default run: `cargo test --bin agentmux-srv live_cli -- --ignored`.
203/// It exercises the real argument list (`--tools ""`, `--system-prompt`, ...)
204/// that the stream-parsing tests above cannot.
205#[cfg(test)]
206mod live_cli_smoke {
207    use super::*;
208
209    #[tokio::test]
210    #[ignore = "spawns the real claude CLI"]
211    async fn live_cli_answers_a_thin_prompt_with_nothing_instead_of_a_refusal() {
212        let meta = obj::MetaMapType::new();
213        let prompt = crate::ambient::prompt::build_next_prompt_prompt("[user] hi");
214        let (text, tokens) = invoke_haiku(
215            "claude",
216            &prompt,
217            &meta,
218            tokio_util::sync::CancellationToken::new(),
219            crate::ambient::purpose::NEXT_PROMPT_SUGGESTION.timeout,
220        )
221        .await
222        .expect("the CLI ran");
223        assert!(tokens.is_some(), "usage is reported");
224        // A thin digest: the answer is SKIP, in the reply format, never prose.
225        assert_ne!(crate::ambient::reply::parse(&text), crate::ambient::reply::Parsed::Malformed, "unexpected reply: {text}");
226        assert!(!text.to_lowercase().contains("i don't have"), "{text}");
227    }
228
229    /// The reply format (`reply`) has to hold on the real model before it can be
230    /// relied on: every reply is `ANSWER: …` or `SKIP`, across activity that
231    /// clearly has a next step, finished work, and activity too thin to read.
232    /// `cargo test --bin agentmux-srv live_cli_reply_format -- --ignored --nocapture`
233    #[tokio::test]
234    #[ignore = "spawns the real claude CLI, about a dozen calls"]
235    async fn live_cli_reply_format_holds_across_digests() {
236        use crate::ambient::reply::{parse, Parsed};
237        let digests = [
238            // A clear next step.
239            "[user] fix the login redirect loop\n[assistant] Found it: the session cookie was set after the redirect. Fixed in auth.ts; the login flow works now.",
240            "[user] add a dark mode toggle\n[assistant] Added the toggle to the settings panel and wired it to the theme store. I haven't written tests yet.",
241            "[user] why is CI red\n[tool] Bash\n[error] npm test exited 1\n[assistant] Two snapshot tests fail after the button restyle; the snapshots need updating.",
242            "[user] profile the slow search\n[tool] Bash\n[assistant] Search spends 80% of its time re-sorting the whole index on every keystroke.",
243            "[user] bump the version\n[assistant] Bumped to 2.4.0 in package.json and the lockfile. The changelog still lists these changes under Unreleased.",
244            "[user] write the migration\n[assistant] The migration adds the column with a default and backfills in batches of 1000. It hasn't been run against staging.",
245            // Finished, nothing obvious left.
246            "[user] thanks, that's all\n[assistant] You're welcome. Everything is merged and deployed.",
247            "[user] merge it\n[assistant] Merged. The branch is deleted and main is green.",
248            // Thin or unreadable.
249            "[user] hi\n[assistant] Hello.",
250            "[user] ok\n[assistant] Done.",
251            "[tool] Read\n[tool] Read\n[tool] Grep\n[assistant] Looking.",
252            "[user] continue\n[assistant] Continuing with the next file.",
253        ];
254        let meta = obj::MetaMapType::new();
255        let mut bad = Vec::new();
256        let mut failed = 0;
257        for digest in digests {
258            let prompt = crate::ambient::prompt::build_next_prompt_prompt(digest);
259            // A CLI failure (most often the time limit) says nothing about the
260            // format; it is counted, not judged.
261            let text = match invoke_haiku(
262                "claude",
263                &prompt,
264                &meta,
265                tokio_util::sync::CancellationToken::new(),
266                crate::ambient::purpose::NEXT_PROMPT_SUGGESTION.timeout,
267            )
268            .await
269            {
270                Ok((text, _)) => text,
271                Err(e) => {
272                    println!("CLI failed: {e}");
273                    failed += 1;
274                    continue;
275                }
276            };
277            let parsed = parse(&text);
278            println!("{parsed:?} <- {text:?}");
279            if parsed == Parsed::Malformed {
280                bad.push(text);
281            }
282        }
283        println!("{failed} of {} calls failed in the CLI", digests.len());
284        assert!(failed < digests.len(), "no call completed");
285        assert!(bad.is_empty(), "replies not in the format: {bad:?}");
286    }
287
288    /// A message that waits for the user without a question mark passes the
289    /// turn-ending gate, so the prompt's SKIP case is what keeps a guess out of
290    /// the composer (#4476). Live, like the test above.
291    #[tokio::test]
292    #[ignore = "calls the real claude CLI; run with --ignored"]
293    async fn live_cli_skips_a_wait_the_gate_cannot_see() {
294        use crate::ambient::reply::{parse, Parsed};
295        let digests = [
296            "[user] pick a cache\n[assistant] Redis or an in-process LRU would both work. Tell me which one and I'll start.",
297            "[user] plan the refactor\n[assistant] Here's the plan: split the parser, then move the tests. Let me know if that works for you before I begin.",
298            "[user] deploy it\n[assistant] Ready to deploy to production. Waiting for your go-ahead.",
299        ];
300        let meta = obj::MetaMapType::new();
301        let mut answered = Vec::new();
302        for digest in digests {
303            let prompt = crate::ambient::prompt::build_next_prompt_prompt(digest);
304            let Ok((text, _)) = invoke_haiku(
305                "claude",
306                &prompt,
307                &meta,
308                tokio_util::sync::CancellationToken::new(),
309                crate::ambient::purpose::NEXT_PROMPT_SUGGESTION.timeout,
310            )
311            .await
312            else {
313                continue;
314            };
315            let parsed = parse(&text);
316            println!("{parsed:?} <- {text:?}");
317            if parsed != Parsed::Skip {
318                answered.push(text);
319            }
320        }
321        assert!(answered.is_empty(), "answered a message that waits for the user: {answered:?}");
322    }
323
324    /// The same check for every other one-line purpose: titles (create and
325    /// maintain), names (with an agent's task as the material, which Haiku used to
326    /// try to carry out), narration and previews.
327    /// `cargo test --bin agentmux-srv live_cli_reply_format_every -- --ignored --nocapture`
328    #[tokio::test]
329    #[ignore = "spawns the real claude CLI, about ten calls"]
330    async fn live_cli_reply_format_holds_for_every_purpose() {
331        use crate::ambient::prompt::*;
332        use crate::ambient::reply::{parse, Parsed};
333        let task = "Investigate the repository at C:\\work\\app. Read src/auth and report every place the session \
334                    cookie is set, with file:line references. Do not change any files.";
335        let prompts = [
336            ("title, create", build_session_title_prompt("", Some("fix the login redirect loop"), None, 7)),
337            ("title, create, nudge", build_session_title_prompt("", Some("continue"), None, 7)),
338            ("title, maintain, same goal", build_session_title_prompt("Fix login redirect loop", Some("also check the logout path"), None, 7)),
339            ("title, maintain, new goal", build_session_title_prompt("Fix login redirect loop", Some("now let's build the billing page"), None, 7)),
340            ("title, from activity", build_session_title_prompt("", None, Some("[user] add a dark mode toggle\n[assistant] Added it."), 7)),
341            ("subagent name", build_subagent_name_prompt(task)),
342            ("dispatch name", build_dispatch_name_prompt(task)),
343            ("narration", narration_prompt("background_task", "task dev").unwrap()),
344            ("preview", build_definition_summary_prompt("[user] add a dark mode toggle\n[assistant] Added it and its tests.")),
345            ("preview, thin", build_definition_summary_prompt("[user] hi\n[assistant] Hello.")),
346        ];
347        let meta = obj::MetaMapType::new();
348        let mut bad = Vec::new();
349        for (name, prompt) in prompts {
350            let text = match invoke_haiku(
351                "claude",
352                &prompt,
353                &meta,
354                tokio_util::sync::CancellationToken::new(),
355                crate::ambient::purpose::ACTIVITY_SUMMARY.timeout,
356            )
357            .await
358            {
359                Ok((text, _)) => text,
360                Err(e) => {
361                    println!("{name}: CLI failed: {e}");
362                    continue;
363                }
364            };
365            let parsed = parse(&text);
366            println!("{name}: {parsed:?} <- {text:?}");
367            if parsed == Parsed::Malformed {
368                bad.push(format!("{name}: {text}"));
369            }
370        }
371        assert!(bad.is_empty(), "replies not in the format: {bad:?}");
372    }
373}