Skip to main content

nyx_agent_ai/adapter/
claude_code.rs

1//! Claude Code adapter (`agent_loop`).
2//!
3//! Spawns the `claude` CLI as a subprocess so the rest of the agent
4//! does not have to embed Anthropic's tool-use loop. The adapter
5//! detects the binary on `PATH` at construction time and refuses to
6//! run if it is missing; callers fall back to the Anthropic adapter
7//! for `one_shot` work.
8//!
9//! Wire shape:
10//! 1. Write `agent_task.md` into a per-task scratch directory.
11//! 2. Invoke `claude --print --output-format stream-json --verbose` with
12//!    the instruction file content piped on stdin (the public CLI does
13//!    not currently expose a `--instruction-file` flag; the scratch
14//!    file still lands on disk so traces stay auditable).
15//! 3. Parse the NDJSON event stream into structured events,
16//!    republishing tool-use blocks as `AiEvent::ToolCallStarted`/
17//!    `Finished` on the shared event bus.
18//! 4. Lift recognised tool calls into `ExtractedAgentResult` variants
19//!    so downstream tasks (PayloadSynthesis, SpecExtraction,
20//!    ChainRanking, Exploration) consume a typed agent-loop result
21//!    rather than re-parsing Claude Code's raw transcript.
22
23use std::path::PathBuf;
24use std::process::Stdio;
25use std::time::Duration;
26
27use async_trait::async_trait;
28use nyx_agent_types::agent::{
29    classify_tool_use, AgentResult, AgentTask, AiError, Budget, CacheStats, CostEstimate,
30    ExtractedAgentResult, HaltReason, Prompt, Response, TokenUsage,
31};
32use nyx_agent_types::event::{AgentEvent, AiEvent, EventSink};
33use semver::Version;
34use serde::Deserialize;
35use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
36use tokio::process::{ChildStderr, Command};
37use tokio::task::JoinHandle;
38
39use crate::runtime::{AiRuntime, SharedBudgetTracker};
40
41/// Preferred binary name. Falls back to `claude-code` when `claude` is
42/// absent so operators who installed the older alias still work.
43pub const DEFAULT_CLAUDE_BINARY: &str = "claude";
44const FALLBACK_CLAUDE_BINARY: &str = "claude-code";
45
46/// Built-in minimum supported Claude Code version. The adapter's
47/// `--print --output-format stream-json --verbose --max-turns N` call
48/// shape and the `assistant` / `result` event types parsed in
49/// [`parse_stream_json`] have been stable since this release. Raise
50/// the floor when a load-bearing CLI flag or stream-json field
51/// shape change forces a hard cutover.
52pub const MINIMUM_CLAUDE_VERSION: &str = "1.0.0";
53
54/// Upper bound on the number of stderr bytes the drain task retains.
55/// `claude --verbose` can write multi-megabyte transcripts; an
56/// unbounded buffer would let a runaway child OOM the daemon. The
57/// drain keeps the trailing window (most recent bytes) because the
58/// proximate failure reason is almost always at the end of the stream.
59const MAX_STDERR_CAPTURE_BYTES: usize = 64 * 1024;
60
61/// Path + version string captured at adapter-construction time. Surfaced
62/// by `nyx-agent doctor` so operators can confirm which binary the
63/// daemon will spawn.
64#[derive(Clone, Debug)]
65pub struct ClaudeBinary {
66    pub path: PathBuf,
67    pub version: String,
68}
69
70/// Resolve the Claude Code binary on `PATH` and capture its
71/// `--version` output. Returns `AiError::AdapterUnavailable` when no
72/// candidate is found or the version probe fails.
73pub async fn detect_claude_binary() -> Result<ClaudeBinary, AiError> {
74    let path = which::which(DEFAULT_CLAUDE_BINARY)
75        .or_else(|_| which::which(FALLBACK_CLAUDE_BINARY))
76        .map_err(|_| {
77            AiError::AdapterUnavailable(format!(
78                "`{DEFAULT_CLAUDE_BINARY}` (or `{FALLBACK_CLAUDE_BINARY}`) not on PATH"
79            ))
80        })?;
81
82    let output = Command::new(&path)
83        .arg("--version")
84        .stdout(Stdio::piped())
85        .stderr(Stdio::piped())
86        .output()
87        .await
88        .map_err(|e| {
89            AiError::AdapterUnavailable(format!(
90                "failed to invoke {} --version: {e}",
91                path.display()
92            ))
93        })?;
94
95    if !output.status.success() {
96        return Err(AiError::AdapterUnavailable(format!(
97            "{} --version exited {}: {}",
98            path.display(),
99            output.status,
100            String::from_utf8_lossy(&output.stderr).trim()
101        )));
102    }
103
104    let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
105    let parsed = parse_claude_version(&version).ok_or_else(|| {
106        AiError::AdapterUnavailable(format!(
107            "claude-code version output `{version}` could not be parsed as semver"
108        ))
109    })?;
110    let floor = Version::parse(MINIMUM_CLAUDE_VERSION).expect("built-in floor parses");
111    if parsed < floor {
112        return Err(AiError::AdapterUnavailable(format!(
113            "claude-code v{parsed} below required minimum v{floor}"
114        )));
115    }
116    Ok(ClaudeBinary { path, version })
117}
118
119/// Extract a `semver::Version` from the `claude --version` stdout. The
120/// CLI prints either `<X.Y.Z> (Claude Code)` or a bare `<X.Y.Z>` token;
121/// the parser accepts both shapes and returns `None` when no
122/// semver-shaped token is present.
123fn parse_claude_version(raw: &str) -> Option<Version> {
124    let candidate = raw.split_whitespace().next()?;
125    Version::parse(candidate).ok()
126}
127
128/// Claude Code agent-loop adapter.
129#[derive(Clone)]
130pub struct ClaudeCodeAdapter {
131    binary: ClaudeBinary,
132    tracker: SharedBudgetTracker,
133    default_model: String,
134    /// Wall-clock cap on a single agent-loop invocation. Defaults to
135    /// 15 minutes; operators can override via `with_timeout`.
136    timeout: Duration,
137}
138
139impl ClaudeCodeAdapter {
140    /// Build an adapter from a pre-detected binary. Callers typically
141    /// run [`detect_claude_binary`] first and surface the result through
142    /// the doctor, then construct one of these.
143    pub fn new(binary: ClaudeBinary, tracker: SharedBudgetTracker) -> Self {
144        Self {
145            binary,
146            tracker,
147            default_model: "claude-opus-4-7".to_string(),
148            timeout: Duration::from_secs(15 * 60),
149        }
150    }
151
152    /// Convenience: detect + construct in one shot.
153    pub async fn discover(tracker: SharedBudgetTracker) -> Result<Self, AiError> {
154        let binary = detect_claude_binary().await?;
155        Ok(Self::new(binary, tracker))
156    }
157
158    pub fn with_default_model(mut self, model: impl Into<String>) -> Self {
159        self.default_model = model.into();
160        self
161    }
162
163    pub fn with_timeout(mut self, timeout: Duration) -> Self {
164        self.timeout = timeout;
165        self
166    }
167
168    pub fn binary(&self) -> &ClaudeBinary {
169        &self.binary
170    }
171}
172
173#[async_trait]
174impl AiRuntime for ClaudeCodeAdapter {
175    fn name(&self) -> &'static str {
176        "claude-code"
177    }
178
179    fn default_model(&self) -> &str {
180        &self.default_model
181    }
182
183    fn supports_agent_loop(&self) -> bool {
184        true
185    }
186
187    fn supports_prompt_cache(&self) -> bool {
188        true
189    }
190
191    fn supports_deterministic_sampling(&self) -> bool {
192        false
193    }
194
195    async fn one_shot(
196        &self,
197        prompt: Prompt,
198        budget: Budget,
199        sink: EventSink,
200    ) -> Result<Response, AiError> {
201        let model = prompt.model.clone().unwrap_or_else(|| self.default_model.clone());
202
203        // Mirror the Anthropic adapter's cap semantics for structured
204        // one-shot work: the effective ceiling is the tighter of the
205        // tracker row and the per-call envelope.
206        let spent_before = self.tracker.current_spend(&budget.run_id, budget.kind).await?;
207        let tracker_cap = self.tracker.cap(&budget.run_id, budget.kind).await?;
208        let cap = effective_cap(tracker_cap, budget.cap_usd_micros);
209        if spent_before > cap {
210            let _ = sink.send(AgentEvent::Ai {
211                data: AiEvent::TaskHalted {
212                    task_id: prompt.task_id.clone(),
213                    reason: HaltReason::BudgetCapReached,
214                },
215            });
216            return Err(AiError::BudgetExceeded {
217                cap_usd_micros: cap,
218                spent_usd_micros: spent_before,
219            });
220        }
221
222        let prompt_body = render_one_shot_prompt(&prompt);
223        let mut child = Command::new(&self.binary.path)
224            .arg("--print")
225            .arg("--output-format")
226            .arg("stream-json")
227            .arg("--verbose")
228            .arg("--max-turns")
229            .arg("1")
230            .arg("--model")
231            .arg(&model)
232            // TODO(release-hardening): make this opt-in/configured before
233            // shipping beyond local testing.
234            .arg("--dangerously-skip-permissions")
235            .stdin(Stdio::piped())
236            .stdout(Stdio::piped())
237            .stderr(Stdio::piped())
238            .kill_on_drop(true)
239            .spawn()
240            .map_err(|e| AiError::Transport(format!("spawn claude: {e}")))?;
241
242        if let Some(mut stdin) = child.stdin.take() {
243            stdin
244                .write_all(prompt_body.as_bytes())
245                .await
246                .map_err(|e| AiError::Transport(format!("write stdin: {e}")))?;
247            stdin.shutdown().await.map_err(|e| AiError::Transport(format!("close stdin: {e}")))?;
248        }
249
250        let stdout = child
251            .stdout
252            .take()
253            .ok_or_else(|| AiError::Transport("claude stdout missing".to_string()))?;
254        let stderr = child
255            .stderr
256            .take()
257            .ok_or_else(|| AiError::Transport("claude stderr missing".to_string()))?;
258        let stderr_handle = spawn_stderr_drain(stderr);
259        let mut reader = BufReader::new(stdout).lines();
260
261        let mut content = String::new();
262        let mut usage = TokenUsage { input_tokens: 0, output_tokens: 0 };
263        let mut cache = CacheStats { cache_creation_tokens: 0, cache_read_tokens: 0 };
264        let mut cost_usd_micros: i64 = 0;
265        let mut reported_model: Option<String> = None;
266
267        let read_loop = async {
268            while let Some(line) = reader
269                .next_line()
270                .await
271                .map_err(|e| AiError::Transport(format!("read stdout: {e}")))?
272            {
273                if line.trim().is_empty() {
274                    continue;
275                }
276                let Some(event) = parse_stream_json(&line) else {
277                    continue;
278                };
279                match event {
280                    ClaudeEvent::Assistant(msg) => {
281                        for block in msg.content {
282                            if let ContentBlock::Text { text } = block {
283                                content.push_str(&text);
284                                let _ = sink.send(AgentEvent::Ai {
285                                    data: AiEvent::TokenReceived {
286                                        task_id: prompt.task_id.clone(),
287                                        token: text,
288                                    },
289                                });
290                            }
291                        }
292                    }
293                    ClaudeEvent::Result(r) => {
294                        if let Some(model) = r.model {
295                            reported_model = Some(model);
296                        }
297                        if let Some(u) = r.usage {
298                            usage.input_tokens =
299                                usage.input_tokens.saturating_add(u.input_tokens.unwrap_or(0));
300                            usage.output_tokens =
301                                usage.output_tokens.saturating_add(u.output_tokens.unwrap_or(0));
302                            cache.cache_creation_tokens = cache
303                                .cache_creation_tokens
304                                .saturating_add(u.cache_creation_input_tokens.unwrap_or(0));
305                            cache.cache_read_tokens = cache
306                                .cache_read_tokens
307                                .saturating_add(u.cache_read_input_tokens.unwrap_or(0));
308                        }
309                        if let Some(c) = r.total_cost_usd {
310                            cost_usd_micros = (c * 1_000_000.0).round() as i64;
311                        }
312                        if let Some(text) = r.result {
313                            if content.is_empty() {
314                                content = text;
315                            }
316                        }
317                    }
318                    ClaudeEvent::Other => {}
319                }
320            }
321            Ok::<(), AiError>(())
322        };
323
324        match tokio::time::timeout(self.timeout, read_loop).await {
325            Ok(Ok(())) => {}
326            Ok(Err(e)) => {
327                let _ = child.kill().await;
328                let stderr_text = await_stderr_drain(stderr_handle).await;
329                return Err(annotate_with_stderr(e, &stderr_text));
330            }
331            Err(_) => {
332                let _ = child.kill().await;
333                let _ = sink.send(AgentEvent::Ai {
334                    data: AiEvent::TaskHalted {
335                        task_id: prompt.task_id.clone(),
336                        reason: HaltReason::OperatorCancelled,
337                    },
338                });
339                let stderr_text = await_stderr_drain(stderr_handle).await;
340                let base = format!("claude one_shot timed out after {}s", self.timeout.as_secs());
341                return Err(AiError::Transport(append_stderr(&base, &stderr_text)));
342            }
343        }
344
345        let status =
346            child.wait().await.map_err(|e| AiError::Transport(format!("wait claude: {e}")))?;
347        if !status.success() {
348            let stderr_text = await_stderr_drain(stderr_handle).await;
349            let base = format!("claude exited {status}");
350            return Err(AiError::UpstreamRefused(append_stderr(&base, &stderr_text)));
351        }
352        drop(stderr_handle);
353
354        if cache.cache_creation_tokens > 0 {
355            let _ = sink.send(AgentEvent::Ai {
356                data: AiEvent::CacheMiss {
357                    task_id: prompt.task_id.clone(),
358                    tokens: cache.cache_creation_tokens,
359                },
360            });
361        }
362        if cache.cache_read_tokens > 0 {
363            let _ = sink.send(AgentEvent::Ai {
364                data: AiEvent::CacheHit {
365                    task_id: prompt.task_id.clone(),
366                    tokens: cache.cache_read_tokens,
367                },
368            });
369        }
370
371        let spent_after =
372            self.tracker.add_spend(&budget.run_id, budget.kind, cost_usd_micros).await?;
373        let _ = sink.send(AgentEvent::Ai {
374            data: AiEvent::BudgetTick {
375                task_id: prompt.task_id.clone(),
376                run_id: budget.run_id.clone(),
377                spent_usd_micros: spent_after,
378            },
379        });
380
381        let tracker_cap = self.tracker.cap(&budget.run_id, budget.kind).await?;
382        let cap = effective_cap(tracker_cap, budget.cap_usd_micros);
383        if spent_after > cap {
384            let _ = sink.send(AgentEvent::Ai {
385                data: AiEvent::TaskHalted {
386                    task_id: prompt.task_id.clone(),
387                    reason: HaltReason::BudgetCapReached,
388                },
389            });
390            return Err(AiError::BudgetExceeded {
391                cap_usd_micros: cap,
392                spent_usd_micros: spent_after,
393            });
394        }
395
396        Ok(Response {
397            prompt_version: prompt.prompt_version,
398            task_id: prompt.task_id,
399            model: reported_model.unwrap_or(model),
400            content,
401            usage,
402            cache: Some(cache),
403            cost_usd_micros,
404        })
405    }
406
407    async fn agent_loop(
408        &self,
409        task: AgentTask,
410        budget: Budget,
411        sink: EventSink,
412    ) -> Result<AgentResult, AiError> {
413        // Pre-call budget check uses `>` to match the post-call check
414        // below; cap is the spendable ceiling, not a hard refuse-when-equal.
415        let spent_before = self.tracker.current_spend(&budget.run_id, budget.kind).await?;
416        if let Some(cap) = self.tracker.cap(&budget.run_id, budget.kind).await? {
417            if spent_before > cap {
418                let _ = sink.send(AgentEvent::Ai {
419                    data: AiEvent::TaskHalted {
420                        task_id: task.task_id.clone(),
421                        reason: HaltReason::BudgetCapReached,
422                    },
423                });
424                return Err(AiError::BudgetExceeded {
425                    cap_usd_micros: cap,
426                    spent_usd_micros: spent_before,
427                });
428            }
429        }
430
431        let scratch = tempfile::Builder::new()
432            .prefix("nyx-claude-task-")
433            .tempdir()
434            .map_err(|e| AiError::Transport(format!("scratch dir: {e}")))?;
435        let task_path = scratch.path().join("agent_task.md");
436        let task_body = render_task_markdown(&task);
437        tokio::fs::write(&task_path, &task_body)
438            .await
439            .map_err(|e| AiError::Transport(format!("write agent_task.md: {e}")))?;
440
441        let mut cmd = Command::new(&self.binary.path);
442        cmd.arg("--print")
443            .arg("--output-format")
444            .arg("stream-json")
445            .arg("--verbose")
446            .arg("--max-turns")
447            .arg(task.max_turns.to_string())
448            // TODO(release-hardening): make this opt-in/configured before
449            // shipping beyond local testing.
450            .arg("--dangerously-skip-permissions")
451            .stdin(Stdio::piped())
452            .stdout(Stdio::piped())
453            // Pipe stderr so failure paths can surface the upstream reason.
454            // A sibling task drains the pipe into a bounded buffer
455            // (`MAX_STDERR_CAPTURE_BYTES`) in parallel with stdout, so
456            // `--verbose` output cannot block the child on a full pipe.
457            .stderr(Stdio::piped())
458            // Ensure SIGKILL fires and the child is reaped if a future error
459            // path drops `child` before `wait().await` runs. The timeout arm
460            // below calls `kill().await` (which reaps), but `kill_on_drop`
461            // covers panic/early-return paths too.
462            .kill_on_drop(true);
463        if let Some(dir) = task.working_directory.as_deref() {
464            cmd.current_dir(dir);
465        }
466        let mut child =
467            cmd.spawn().map_err(|e| AiError::Transport(format!("spawn claude: {e}")))?;
468
469        if let Some(mut stdin) = child.stdin.take() {
470            stdin
471                .write_all(task_body.as_bytes())
472                .await
473                .map_err(|e| AiError::Transport(format!("write stdin: {e}")))?;
474            stdin.shutdown().await.map_err(|e| AiError::Transport(format!("close stdin: {e}")))?;
475        }
476
477        let stdout = child
478            .stdout
479            .take()
480            .ok_or_else(|| AiError::Transport("claude stdout missing".to_string()))?;
481        let stderr = child
482            .stderr
483            .take()
484            .ok_or_else(|| AiError::Transport("claude stderr missing".to_string()))?;
485        let stderr_handle = spawn_stderr_drain(stderr);
486        let mut reader = BufReader::new(stdout).lines();
487
488        let mut turns: u32 = 0;
489        let mut final_message = String::new();
490        let mut extracted: Vec<ExtractedAgentResult> = Vec::new();
491        let mut usage = TokenUsage { input_tokens: 0, output_tokens: 0 };
492        let mut cost_usd_micros: i64 = 0;
493
494        let read_loop = async {
495            while let Some(line) = reader
496                .next_line()
497                .await
498                .map_err(|e| AiError::Transport(format!("read stdout: {e}")))?
499            {
500                if line.trim().is_empty() {
501                    continue;
502                }
503                let Some(event) = parse_stream_json(&line) else {
504                    continue;
505                };
506                match event {
507                    ClaudeEvent::Assistant(msg) => {
508                        turns = turns.saturating_add(1);
509                        for block in msg.content {
510                            match block {
511                                ContentBlock::Text { text } => {
512                                    final_message.push_str(&text);
513                                    let _ = sink.send(AgentEvent::Ai {
514                                        data: AiEvent::TokenReceived {
515                                            task_id: task.task_id.clone(),
516                                            token: text,
517                                        },
518                                    });
519                                }
520                                ContentBlock::ToolUse { name, input, .. } => {
521                                    let _ = sink.send(AgentEvent::Ai {
522                                        data: AiEvent::ToolCallStarted {
523                                            task_id: task.task_id.clone(),
524                                            name: name.clone(),
525                                        },
526                                    });
527                                    if let Some(result) = classify_tool_use(&name, &input) {
528                                        extracted.push(result);
529                                    }
530                                    let _ = sink.send(AgentEvent::Ai {
531                                        data: AiEvent::ToolCallFinished {
532                                            task_id: task.task_id.clone(),
533                                            name,
534                                            ok: true,
535                                        },
536                                    });
537                                }
538                                ContentBlock::Other => {}
539                            }
540                        }
541                    }
542                    ClaudeEvent::Result(r) => {
543                        if let Some(u) = r.usage {
544                            usage.input_tokens =
545                                usage.input_tokens.saturating_add(u.input_tokens.unwrap_or(0));
546                            usage.output_tokens =
547                                usage.output_tokens.saturating_add(u.output_tokens.unwrap_or(0));
548                        }
549                        if let Some(c) = r.total_cost_usd {
550                            cost_usd_micros = (c * 1_000_000.0).round() as i64;
551                        }
552                        if let Some(text) = r.result {
553                            if final_message.is_empty() {
554                                final_message = text;
555                            }
556                        }
557                    }
558                    ClaudeEvent::Other => {}
559                }
560            }
561            Ok::<(), AiError>(())
562        };
563
564        match tokio::time::timeout(self.timeout, read_loop).await {
565            Ok(Ok(())) => {}
566            Ok(Err(e)) => {
567                let _ = child.kill().await;
568                let stderr_text = await_stderr_drain(stderr_handle).await;
569                return Err(annotate_with_stderr(e, &stderr_text));
570            }
571            Err(_) => {
572                let _ = child.kill().await;
573                let _ = sink.send(AgentEvent::Ai {
574                    data: AiEvent::TaskHalted {
575                        task_id: task.task_id.clone(),
576                        reason: HaltReason::OperatorCancelled,
577                    },
578                });
579                let stderr_text = await_stderr_drain(stderr_handle).await;
580                let base = format!("claude agent_loop timed out after {}s", self.timeout.as_secs());
581                return Err(AiError::Transport(append_stderr(&base, &stderr_text)));
582            }
583        }
584
585        let status =
586            child.wait().await.map_err(|e| AiError::Transport(format!("wait claude: {e}")))?;
587        if !status.success() {
588            let stderr_text = await_stderr_drain(stderr_handle).await;
589            let base = format!("claude exited {status}");
590            return Err(AiError::UpstreamRefused(append_stderr(&base, &stderr_text)));
591        }
592        // Success: detach the drain. The child has exited so the pipe is
593        // closed; the task completes naturally and its buffer is dropped.
594        drop(stderr_handle);
595
596        let spent_after =
597            self.tracker.add_spend(&budget.run_id, budget.kind, cost_usd_micros).await?;
598        let _ = sink.send(AgentEvent::Ai {
599            data: AiEvent::BudgetTick {
600                task_id: task.task_id.clone(),
601                run_id: budget.run_id.clone(),
602                spent_usd_micros: spent_after,
603            },
604        });
605
606        if let Some(cap) = self.tracker.cap(&budget.run_id, budget.kind).await? {
607            if spent_after > cap {
608                let _ = sink.send(AgentEvent::Ai {
609                    data: AiEvent::TaskHalted {
610                        task_id: task.task_id.clone(),
611                        reason: HaltReason::BudgetCapReached,
612                    },
613                });
614                return Err(AiError::BudgetExceeded {
615                    cap_usd_micros: cap,
616                    spent_usd_micros: spent_after,
617                });
618            }
619        }
620
621        extracted.extend(extract_tool_markers_from_text(&final_message));
622
623        Ok(AgentResult {
624            prompt_version: task.prompt_version,
625            task_id: task.task_id,
626            model: self.default_model.clone(),
627            final_message,
628            turns,
629            usage,
630            cache: None,
631            cost_usd_micros,
632            extracted,
633        })
634    }
635
636    fn cost_estimate(&self, _prompt: &Prompt) -> Option<CostEstimate> {
637        None
638    }
639}
640
641fn spawn_stderr_drain(mut stderr: ChildStderr) -> JoinHandle<Vec<u8>> {
642    tokio::spawn(async move {
643        let mut buf: Vec<u8> = Vec::with_capacity(1024);
644        let mut chunk = [0u8; 4096];
645        loop {
646            match stderr.read(&mut chunk).await {
647                Ok(0) => break,
648                Ok(n) => {
649                    buf.extend_from_slice(&chunk[..n]);
650                    if buf.len() > MAX_STDERR_CAPTURE_BYTES {
651                        let drop_n = buf.len() - MAX_STDERR_CAPTURE_BYTES;
652                        buf.drain(..drop_n);
653                    }
654                }
655                Err(_) => break,
656            }
657        }
658        buf
659    })
660}
661
662async fn await_stderr_drain(handle: JoinHandle<Vec<u8>>) -> String {
663    let bytes = match tokio::time::timeout(Duration::from_secs(1), handle).await {
664        Ok(Ok(b)) => b,
665        _ => return String::new(),
666    };
667    String::from_utf8_lossy(&bytes).trim().to_string()
668}
669
670fn append_stderr(base: &str, stderr_text: &str) -> String {
671    if stderr_text.is_empty() {
672        base.to_string()
673    } else {
674        format!("{base}: stderr: {stderr_text}")
675    }
676}
677
678fn annotate_with_stderr(err: AiError, stderr_text: &str) -> AiError {
679    if stderr_text.is_empty() {
680        return err;
681    }
682    match err {
683        AiError::Transport(msg) => AiError::Transport(append_stderr(&msg, stderr_text)),
684        AiError::UpstreamRefused(msg) => AiError::UpstreamRefused(append_stderr(&msg, stderr_text)),
685        other => other,
686    }
687}
688
689fn render_task_markdown(task: &AgentTask) -> String {
690    let tools_block = if task.tools.is_empty() {
691        "(none; answer from context)".to_string()
692    } else {
693        task.tools.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
694    };
695    let working_directory =
696        task.working_directory.as_deref().unwrap_or("(not set; adapter process cwd will be used)");
697    format!(
698        "# Agent task\n\
699         \n\
700         **prompt_version**: `{pv}`  \n\
701         **task_id**: `{tid}`  \n\
702         **working_directory**: `{working_directory}`  \n\
703         **max_turns**: {max_turns}\n\
704         \n\
705         ## System\n{system}\n\
706         \n\
707         ## Objective\n{objective}\n\
708         \n\
709         ## Tools available\n{tools}\n\
710         \n\
711         When you need to record a structured Nyx Agent artifact, emit a JSON object on its own line \
712         using this shape: {{\"tool\":\"<listed tool name>\",\"input\":{{...}}}}. Use one of the \
713         listed `record_*` tool names as the `tool` value and place its arguments in `input`.\n",
714        pv = task.prompt_version,
715        tid = task.task_id,
716        working_directory = working_directory,
717        max_turns = task.max_turns,
718        system = task.system,
719        objective = task.objective,
720        tools = tools_block,
721    )
722}
723
724fn extract_tool_markers_from_text(text: &str) -> Vec<ExtractedAgentResult> {
725    let mut out = Vec::new();
726    for line in text.lines() {
727        let trimmed = line.trim();
728        if !trimmed.starts_with('{') {
729            continue;
730        }
731        let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
732            continue;
733        };
734        extract_tool_marker_value(&value, &mut out);
735    }
736    out
737}
738
739fn extract_tool_marker_value(value: &serde_json::Value, out: &mut Vec<ExtractedAgentResult>) {
740    if let Some(calls) = value.get("tool_calls").and_then(|v| v.as_array()) {
741        for call in calls {
742            extract_tool_marker_value(call, out);
743        }
744        return;
745    }
746    let Some(name) = value.get("tool").or_else(|| value.get("name")).and_then(|v| v.as_str())
747    else {
748        return;
749    };
750    let input = value.get("input").cloned().unwrap_or_else(|| serde_json::json!({}));
751    if let Some(result) = classify_tool_use(name, &input) {
752        out.push(result);
753    }
754}
755
756fn render_one_shot_prompt(prompt: &Prompt) -> String {
757    format!(
758        "# Nyx Agent one-shot task\n\
759         \n\
760         **prompt_version**: `{pv}`  \n\
761         **task_id**: `{tid}`  \n\
762         **max_output_tokens**: {max_tokens}  \n\
763         **temperature**: {temperature}\n\
764         \n\
765         Return the requested content only. Preserve any JSON contract from the system prompt; \
766         do not add explanation, Markdown fences, or surrounding prose unless the prompt explicitly \
767         asks for them.\n\
768         \n\
769         ## System\n{system}\n\
770         \n\
771         ## User\n{user}\n",
772        pv = prompt.prompt_version,
773        tid = prompt.task_id,
774        max_tokens = prompt.max_output_tokens,
775        temperature = prompt.temperature,
776        system = prompt.system,
777        user = prompt.user,
778    )
779}
780
781fn effective_cap(tracker_cap: Option<i64>, envelope_cap: i64) -> i64 {
782    match tracker_cap {
783        Some(t) => t.min(envelope_cap),
784        None => envelope_cap,
785    }
786}
787
788/// Parse one NDJSON line from `claude --output-format stream-json` into
789/// a typed event. Unknown shapes return `None` so callers can skip
790/// gracefully across Claude Code CLI revisions.
791pub fn parse_stream_json(line: &str) -> Option<ClaudeEvent> {
792    let raw: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
793    let kind = raw.get("type")?.as_str()?;
794    match kind {
795        "assistant" => {
796            let msg = raw.get("message")?;
797            let content_raw = msg.get("content").cloned().unwrap_or(serde_json::json!([]));
798            let content: Vec<ContentBlock> = serde_json::from_value(content_raw).ok()?;
799            Some(ClaudeEvent::Assistant(AssistantMessage { content }))
800        }
801        "result" => {
802            let parsed: ResultPayload = serde_json::from_value(raw).ok()?;
803            Some(ClaudeEvent::Result(parsed))
804        }
805        _ => Some(ClaudeEvent::Other),
806    }
807}
808
809#[derive(Debug, Clone, PartialEq)]
810pub enum ClaudeEvent {
811    Assistant(AssistantMessage),
812    Result(ResultPayload),
813    Other,
814}
815
816#[derive(Debug, Clone, PartialEq)]
817pub struct AssistantMessage {
818    pub content: Vec<ContentBlock>,
819}
820
821#[derive(Debug, Clone, PartialEq, Deserialize)]
822#[serde(tag = "type")]
823pub enum ContentBlock {
824    #[serde(rename = "text")]
825    Text { text: String },
826    #[serde(rename = "tool_use")]
827    ToolUse {
828        #[serde(default)]
829        id: String,
830        name: String,
831        #[serde(default)]
832        input: serde_json::Value,
833    },
834    #[serde(other)]
835    Other,
836}
837
838#[derive(Debug, Clone, PartialEq, Deserialize)]
839pub struct ResultPayload {
840    #[serde(default)]
841    pub result: Option<String>,
842    #[serde(default)]
843    pub model: Option<String>,
844    #[serde(default)]
845    pub total_cost_usd: Option<f64>,
846    #[serde(default)]
847    pub usage: Option<ResultUsage>,
848}
849
850#[derive(Debug, Clone, PartialEq, Deserialize)]
851pub struct ResultUsage {
852    #[serde(default)]
853    pub input_tokens: Option<u32>,
854    #[serde(default)]
855    pub output_tokens: Option<u32>,
856    #[serde(default)]
857    pub cache_creation_input_tokens: Option<u32>,
858    #[serde(default)]
859    pub cache_read_input_tokens: Option<u32>,
860}
861
862#[cfg(test)]
863mod tests {
864    use std::sync::Arc;
865
866    use super::*;
867    use crate::runtime::InMemoryBudgetTracker;
868    use nyx_agent_types::agent::BudgetKind;
869    #[cfg(unix)]
870    use std::os::unix::fs::PermissionsExt;
871    use tokio::sync::broadcast;
872
873    fn sample_task() -> AgentTask {
874        AgentTask {
875            prompt_version: "phase13.test.v1".to_string(),
876            task_id: "task-claude-1".to_string(),
877            system: "you are a static analysis exploration agent".to_string(),
878            objective: "enumerate sinks in fixture.py".to_string(),
879            tools: vec!["fs.read".to_string(), "record_payload".to_string()],
880            working_directory: None,
881            max_turns: 3,
882        }
883    }
884
885    fn budget(cap_usd_micros: i64) -> Budget {
886        Budget { run_id: "run-claude-1".to_string(), kind: BudgetKind::AgentLoop, cap_usd_micros }
887    }
888
889    fn one_shot_budget(cap_usd_micros: i64) -> Budget {
890        Budget { run_id: "run-claude-1".to_string(), kind: BudgetKind::OneShot, cap_usd_micros }
891    }
892
893    fn fake_binary() -> ClaudeBinary {
894        ClaudeBinary { path: PathBuf::from("/usr/bin/false"), version: "0.0.0-test".to_string() }
895    }
896
897    fn fake_cli_script(body: &str) -> (tempfile::TempDir, ClaudeBinary) {
898        let dir = tempfile::tempdir().expect("tempdir");
899        let path = dir.path().join("claude");
900        std::fs::write(&path, body).expect("write fake claude");
901        #[cfg(unix)]
902        {
903            let mut perms = std::fs::metadata(&path).expect("metadata").permissions();
904            perms.set_mode(0o755);
905            std::fs::set_permissions(&path, perms).expect("chmod");
906        }
907        let binary = ClaudeBinary { path, version: "2.1.146-test".to_string() };
908        (dir, binary)
909    }
910
911    #[test]
912    fn capability_flags_match_phase13_contract() {
913        let tracker = Arc::new(InMemoryBudgetTracker::new()) as SharedBudgetTracker;
914        let adapter = ClaudeCodeAdapter::new(fake_binary(), tracker);
915        assert_eq!(adapter.name(), "claude-code");
916        assert!(adapter.supports_agent_loop());
917        assert!(adapter.supports_prompt_cache());
918        assert!(!adapter.supports_deterministic_sampling());
919    }
920
921    #[tokio::test]
922    async fn one_shot_parses_stream_json_and_records_budget() {
923        let script = r#"#!/bin/sh
924cat >/dev/null
925printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"{\"ok\":true}"}]}}'
926printf '%s\n' '{"type":"result","result":"{\"ok\":true}","model":"claude-sonnet-4-6","total_cost_usd":0.001234,"usage":{"input_tokens":10,"output_tokens":3,"cache_creation_input_tokens":4,"cache_read_input_tokens":5}}'
927"#;
928        let (_dir, binary) = fake_cli_script(script);
929        let tracker = Arc::new(InMemoryBudgetTracker::new());
930        let adapter = ClaudeCodeAdapter::new(binary, tracker.clone() as SharedBudgetTracker);
931        let (tx, _rx) = broadcast::channel::<AgentEvent>(4);
932        let prompt = Prompt {
933            prompt_version: "v1".to_string(),
934            task_id: "t".to_string(),
935            model: None,
936            system: "s".to_string(),
937            user: "u".to_string(),
938            max_output_tokens: 8,
939            temperature: 0.0,
940            seed: None,
941        };
942        let resp = adapter.one_shot(prompt, one_shot_budget(10_000), tx).await.expect("one_shot");
943        assert_eq!(resp.content, "{\"ok\":true}");
944        assert_eq!(resp.model, "claude-sonnet-4-6");
945        assert_eq!(resp.usage.input_tokens, 10);
946        assert_eq!(resp.usage.output_tokens, 3);
947        assert_eq!(resp.cache.unwrap().cache_creation_tokens, 4);
948        assert_eq!(resp.cost_usd_micros, 1_234);
949        assert_eq!(tracker.spent("run-claude-1", BudgetKind::OneShot), 1_234);
950    }
951
952    #[tokio::test]
953    async fn one_shot_enforces_post_call_budget_cap() {
954        let script = r#"#!/bin/sh
955cat >/dev/null
956printf '%s\n' '{"type":"result","result":"done","total_cost_usd":0.0002,"usage":{"input_tokens":1,"output_tokens":1}}'
957"#;
958        let (_dir, binary) = fake_cli_script(script);
959        let tracker = Arc::new(InMemoryBudgetTracker::new());
960        let adapter = ClaudeCodeAdapter::new(binary, tracker.clone() as SharedBudgetTracker);
961        let (tx, _rx) = broadcast::channel::<AgentEvent>(4);
962        let prompt = Prompt {
963            prompt_version: "v1".to_string(),
964            task_id: "t".to_string(),
965            model: None,
966            system: "s".to_string(),
967            user: "u".to_string(),
968            max_output_tokens: 8,
969            temperature: 0.0,
970            seed: None,
971        };
972        let err = adapter
973            .one_shot(prompt, one_shot_budget(100), tx)
974            .await
975            .expect_err("budget should trip");
976        assert!(matches!(
977            err,
978            AiError::BudgetExceeded { cap_usd_micros: 100, spent_usd_micros: 200 }
979        ));
980        assert_eq!(tracker.spent("run-claude-1", BudgetKind::OneShot), 200);
981    }
982
983    #[test]
984    fn parse_claude_version_accepts_bare_semver() {
985        let v = parse_claude_version("1.2.3").expect("bare semver parses");
986        assert_eq!(v, Version::new(1, 2, 3));
987    }
988
989    #[test]
990    fn parse_claude_version_strips_claude_code_suffix() {
991        let v = parse_claude_version("2.0.5 (Claude Code)").expect("suffixed semver parses");
992        assert_eq!(v, Version::new(2, 0, 5));
993    }
994
995    #[test]
996    fn parse_claude_version_rejects_non_semver_output() {
997        assert!(parse_claude_version("").is_none());
998        assert!(parse_claude_version("not-a-version").is_none());
999        assert!(parse_claude_version("v1.2.3").is_none());
1000    }
1001
1002    #[test]
1003    fn minimum_claude_version_constant_parses() {
1004        Version::parse(MINIMUM_CLAUDE_VERSION).expect("MINIMUM_CLAUDE_VERSION literal parses");
1005    }
1006
1007    #[tokio::test]
1008    async fn detect_returns_adapter_unavailable_when_binary_missing() {
1009        // Clear PATH so `which` fails deterministically.
1010        let prior = std::env::var_os("PATH");
1011        // SAFETY: tests run single-threaded under tokio::test; the env
1012        // mutation is reset before the test returns.
1013        unsafe {
1014            std::env::set_var("PATH", "");
1015        }
1016        let result = detect_claude_binary().await;
1017        if let Some(p) = prior {
1018            unsafe {
1019                std::env::set_var("PATH", p);
1020            }
1021        } else {
1022            unsafe {
1023                std::env::remove_var("PATH");
1024            }
1025        }
1026        assert!(matches!(result, Err(AiError::AdapterUnavailable(_))));
1027    }
1028
1029    #[test]
1030    fn parse_stream_json_lifts_text_blocks() {
1031        let line = serde_json::json!({
1032            "type": "assistant",
1033            "message": {
1034                "content": [
1035                    { "type": "text", "text": "scanning fixture.py" }
1036                ]
1037            }
1038        })
1039        .to_string();
1040        let ev = parse_stream_json(&line).expect("parsed");
1041        match ev {
1042            ClaudeEvent::Assistant(a) => {
1043                assert_eq!(a.content.len(), 1);
1044                assert!(matches!(
1045                    a.content[0],
1046                    ContentBlock::Text { ref text } if text == "scanning fixture.py"
1047                ));
1048            }
1049            other => panic!("expected Assistant, got {other:?}"),
1050        }
1051    }
1052
1053    #[test]
1054    fn parse_stream_json_lifts_tool_use_block() {
1055        let line = serde_json::json!({
1056            "type": "assistant",
1057            "message": {
1058                "content": [
1059                    {
1060                        "type": "tool_use",
1061                        "id": "toolu_01",
1062                        "name": "record_payload",
1063                        "input": { "rule_id": "py.cmdi.os_system", "body": "ls -la; #" }
1064                    }
1065                ]
1066            }
1067        })
1068        .to_string();
1069        let ev = parse_stream_json(&line).expect("parsed");
1070        let ClaudeEvent::Assistant(a) = ev else { panic!("expected Assistant") };
1071        let block = a.content.into_iter().next().expect("one block");
1072        let ContentBlock::ToolUse { name, input, .. } = block else { panic!("expected ToolUse") };
1073        assert_eq!(name, "record_payload");
1074        let extracted = classify_tool_use(&name, &input).expect("extract");
1075        assert!(matches!(
1076            extracted,
1077            ExtractedAgentResult::PayloadFound { ref rule_id, ref body }
1078                if rule_id == "py.cmdi.os_system" && body == "ls -la; #"
1079        ));
1080    }
1081
1082    #[test]
1083    fn parse_stream_json_lifts_result_event() {
1084        let line = serde_json::json!({
1085            "type": "result",
1086            "subtype": "success",
1087            "result": "done",
1088            "total_cost_usd": 0.0125,
1089            "usage": { "input_tokens": 100, "output_tokens": 50 },
1090            "model": "claude-opus-4-7"
1091        })
1092        .to_string();
1093        let ev = parse_stream_json(&line).expect("parsed");
1094        let ClaudeEvent::Result(r) = ev else { panic!("expected Result") };
1095        assert_eq!(r.result.as_deref(), Some("done"));
1096        assert_eq!(r.model.as_deref(), Some("claude-opus-4-7"));
1097        assert!((r.total_cost_usd.unwrap() - 0.0125).abs() < f64::EPSILON);
1098        assert_eq!(r.usage.unwrap().input_tokens, Some(100));
1099    }
1100
1101    #[test]
1102    fn classify_tool_use_falls_back_to_exploration_event() {
1103        let input = serde_json::json!({ "path": "src/main.rs" });
1104        let ev = classify_tool_use("fs.read", &input).expect("extracted");
1105        match ev {
1106            ExtractedAgentResult::ExplorationEvent { message } => {
1107                assert!(message.starts_with("tool fs.read"));
1108            }
1109            other => panic!("expected ExplorationEvent, got {other:?}"),
1110        }
1111    }
1112
1113    #[test]
1114    fn classify_tool_use_records_spec_and_chains() {
1115        let spec_input = serde_json::json!({
1116            "capability": "fs.write",
1117            "spec": "writes to /tmp"
1118        });
1119        let ev = classify_tool_use("record_spec", &spec_input).expect("extracted");
1120        assert!(matches!(
1121            ev,
1122            ExtractedAgentResult::SpecFound { ref capability, ref spec }
1123                if capability == "fs.write" && spec == "writes to /tmp"
1124        ));
1125
1126        let chain_input = serde_json::json!({
1127            "chain_ids": ["c1", "c2"],
1128            "rationale": "shorter sink reachability"
1129        });
1130        let ev = classify_tool_use("record_chains", &chain_input).expect("extracted");
1131        assert!(matches!(
1132            ev,
1133            ExtractedAgentResult::ChainsRanked { ref chain_ids, .. }
1134                if chain_ids == &vec!["c1".to_string(), "c2".to_string()]
1135        ));
1136    }
1137
1138    #[test]
1139    fn classify_tool_use_records_exploration_finding() {
1140        let input = serde_json::json!({
1141            "path": "<api:/api/admin/orders>",
1142            "line": 42,
1143            "cap": "AUTH_BYPASS",
1144            "rationale": "GET admin endpoint accepts unauthenticated requests",
1145            "endpoint": "GET /api/admin/orders",
1146            "suggested_payload_hint": "curl -i http://127.0.0.1:3000/api/admin/orders",
1147        });
1148        let ev = classify_tool_use("record_exploration_finding", &input).expect("extracted");
1149        assert!(matches!(
1150            ev,
1151            ExtractedAgentResult::ExplorationFinding {
1152                ref path, line: Some(42), ref cap, ref endpoint, ..
1153            } if path == "<api:/api/admin/orders>"
1154                && cap == "AUTH_BYPASS"
1155                && endpoint.as_deref() == Some("GET /api/admin/orders")
1156        ));
1157        // Empty required fields fall back to None (gating against
1158        // malformed tool-use blocks).
1159        let bad = serde_json::json!({ "path": "", "cap": "X", "rationale": "y" });
1160        assert!(classify_tool_use("record_exploration_finding", &bad).is_none());
1161    }
1162
1163    #[test]
1164    fn render_task_markdown_includes_all_fields() {
1165        let md = render_task_markdown(&sample_task());
1166        assert!(md.contains("phase13.test.v1"));
1167        assert!(md.contains("task-claude-1"));
1168        assert!(md.contains("enumerate sinks"));
1169        assert!(md.contains("- fs.read"));
1170        assert!(md.contains("- record_payload"));
1171    }
1172
1173    #[test]
1174    fn append_stderr_skips_when_empty() {
1175        assert_eq!(append_stderr("base", ""), "base");
1176        assert_eq!(append_stderr("base", "boom"), "base: stderr: boom");
1177    }
1178
1179    #[cfg(unix)]
1180    #[tokio::test]
1181    async fn agent_loop_surfaces_stderr_on_nonzero_exit() {
1182        use std::os::unix::fs::PermissionsExt;
1183
1184        let dir = tempfile::tempdir().expect("tempdir");
1185        let script = dir.path().join("fake_claude");
1186        let body = "#!/bin/sh\n\
1187                    cat > /dev/null\n\
1188                    printf 'API authentication failed: invalid bearer token\\n' >&2\n\
1189                    exit 7\n";
1190        tokio::fs::write(&script, body).await.expect("write script");
1191        let mut perms = tokio::fs::metadata(&script).await.expect("meta").permissions();
1192        perms.set_mode(0o755);
1193        tokio::fs::set_permissions(&script, perms).await.expect("perms");
1194
1195        let tracker = Arc::new(InMemoryBudgetTracker::new()) as SharedBudgetTracker;
1196        let adapter = ClaudeCodeAdapter::new(
1197            ClaudeBinary { path: script, version: "test".to_string() },
1198            tracker,
1199        )
1200        .with_timeout(Duration::from_secs(10));
1201        let (tx, _rx) = broadcast::channel::<AgentEvent>(4);
1202        let err = adapter
1203            .agent_loop(sample_task(), budget(1_000_000), tx)
1204            .await
1205            .expect_err("expected upstream refused");
1206        match err {
1207            AiError::UpstreamRefused(msg) => {
1208                assert!(msg.starts_with("claude exited"), "missing exit prefix: {msg}");
1209                assert!(msg.contains("API authentication failed"), "stderr missing: {msg}");
1210                assert!(msg.contains("invalid bearer token"), "trailing stderr trimmed: {msg}");
1211            }
1212            other => panic!("expected UpstreamRefused, got {other:?}"),
1213        }
1214    }
1215
1216    #[cfg(unix)]
1217    #[tokio::test]
1218    async fn agent_loop_surfaces_stderr_on_timeout() {
1219        use std::os::unix::fs::PermissionsExt;
1220
1221        let dir = tempfile::tempdir().expect("tempdir");
1222        let script = dir.path().join("fake_claude_slow");
1223        // Write to stderr before the stall so the drain task captures
1224        // a line for sure before the adapter timeout fires. `exec sleep`
1225        // avoids leaving an extra shell waiting on the sleep child.
1226        let body = "#!/bin/sh\n\
1227                    printf 'pre-stall diagnostic line\\n' >&2\n\
1228                    exec sleep 30\n";
1229        tokio::fs::write(&script, body).await.expect("write script");
1230        let mut perms = tokio::fs::metadata(&script).await.expect("meta").permissions();
1231        perms.set_mode(0o755);
1232        tokio::fs::set_permissions(&script, perms).await.expect("perms");
1233
1234        let tracker = Arc::new(InMemoryBudgetTracker::new()) as SharedBudgetTracker;
1235        let adapter = ClaudeCodeAdapter::new(
1236            ClaudeBinary { path: script, version: "test".to_string() },
1237            tracker,
1238        )
1239        .with_timeout(Duration::from_millis(2_000));
1240        let (tx, _rx) = broadcast::channel::<AgentEvent>(4);
1241        let err = adapter
1242            .agent_loop(sample_task(), budget(1_000_000), tx)
1243            .await
1244            .expect_err("expected timeout");
1245        match err {
1246            AiError::Transport(msg) => {
1247                assert!(msg.contains("timed out"), "missing timeout prefix: {msg}");
1248                assert!(msg.contains("pre-stall diagnostic line"), "stderr missing: {msg}");
1249            }
1250            other => panic!("expected Transport timeout, got {other:?}"),
1251        }
1252    }
1253}