Skip to main content

oxicode_agent/tools/
debug_tool.rs

1//! Debug tool — Debugger integration via DAP (Debug Adapter Protocol).
2//!
3//! Provides model-driven access to a debugger through the `xd://debug`
4//! virtual device, which wraps DAP clients for common debug adapters
5//! (`gdb`, `lldb-dap`, `debugpy`, `dlv`).
6//!
7//! # Status
8//!
9//! This is a **scaffold**. The tool validates the requested `action`
10//! against the supported DAP set and returns a structured pointer
11//! describing how the same operation would be issued via the
12//! `xd://debug` device. A future change will route `execute` to wrap
13//! the device directly, streaming results back as the tool return
14//! value.
15//!
16//! For now, callers wanting real debugger control should use the host
17//! harness's `xd://debug` device directly. The agent still observes a
18//! regular `AgentToolResult` round-trip — the contract is preserved
19//! while the proxy wiring is being built.
20use async_trait::async_trait;
21use serde_json::{Value, json};
22use std::sync::Arc;
23use tokio::sync::oneshot;
24
25use super::{AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode};
26
27/// `debug` agent tool — DAP-backed debugger integration.
28///
29/// Surfaces DAP operations (`launch`, `attach`, breakpoint control,
30/// stepping, inspection, termination) to the model over the standard
31/// agent-loop contract. The long-term backing is the `xd://debug`
32/// virtual device; today the tool is a validated scaffold.
33pub struct DebugTool;
34
35impl DebugTool {
36    /// Canonical list of DAP actions supported by the tool.
37    ///
38    /// Order is chosen to mirror the natural debugging workflow:
39    /// session lifecycle first, then breakpoint control, then execution
40    /// control, then inspection, then teardown.
41    pub const ACTIONS: &'static [&'static str] = &[
42        "sessions",
43        "launch",
44        "attach",
45        "set_breakpoint",
46        "remove_breakpoint",
47        "continue",
48        "pause",
49        "step_in",
50        "step_over",
51        "step_out",
52        "threads",
53        "stack_trace",
54        "scopes",
55        "variables",
56        "evaluate",
57        "terminate",
58    ];
59
60    /// Returns `true` if `action` is a recognised DAP action.
61    pub fn is_supported_action(action: &str) -> bool {
62        Self::ACTIONS.contains(&action)
63    }
64
65    /// Per-action guidance describing how to issue the same operation
66    /// through the `xd://debug` virtual device right now.
67    fn guidance(action: &str) -> &'static str {
68        match action {
69            "sessions" => {
70                "List active debug sessions. Write `{\"action\":\"sessions\"}` to `xd://debug`."
71            }
72            "launch" => {
73                "Start a new DAP session. Send `{\"action\":\"launch\",\"program\":\"<bin>\",\"args\":[…],\"adapter\":\"<gdb|lldb-dap|debugpy|dlv>\"}` to `xd://debug`."
74            }
75            "attach" => {
76                "Attach to a running process. Send `{\"action\":\"attach\",\"adapter\":\"<gdb|lldb-dap|debugpy|dlv>\"}` plus the adapter's attach parameters to `xd://debug`."
77            }
78            "set_breakpoint" => {
79                "Set a source breakpoint. Send `{\"action\":\"set_breakpoint\",\"file\":\"<path>\",\"line\":<n>,\"condition\":\"<expr>\"?}` to `xd://debug`."
80            }
81            "remove_breakpoint" => {
82                "Remove a previously set breakpoint. Send `{\"action\":\"remove_breakpoint\",\"file\":\"<path>\",\"line\":<n>}` to `xd://debug`."
83            }
84            "continue" => {
85                "Resume execution on a thread. Send `{\"action\":\"continue\",\"thread_id\":<n>}` to `xd://debug`."
86            }
87            "pause" => {
88                "Suspend a running thread. Send `{\"action\":\"pause\",\"thread_id\":<n>}` to `xd://debug`."
89            }
90            "step_in" => {
91                "Step into the current call. Send `{\"action\":\"step_in\",\"thread_id\":<n>}` to `xd://debug`."
92            }
93            "step_over" => {
94                "Step over the current call. Send `{\"action\":\"step_over\",\"thread_id\":<n>}` to `xd://debug`."
95            }
96            "step_out" => {
97                "Step out of the current frame. Send `{\"action\":\"step_out\",\"thread_id\":<n>}` to `xd://debug`."
98            }
99            "threads" => {
100                "List threads in the current session. Send `{\"action\":\"threads\"}` to `xd://debug`."
101            }
102            "stack_trace" => {
103                "Fetch the stack frames for a thread. Send `{\"action\":\"stack_trace\",\"thread_id\":<n>}` to `xd://debug`."
104            }
105            "scopes" => {
106                "Fetch the lexical scopes for a frame. Send `{\"action\":\"scopes\",\"frame_id\":<n>}` to `xd://debug`."
107            }
108            "variables" => {
109                "Fetch variables for a scope or variable reference. Send `{\"action\":\"variables\",\"frame_id\":<n>,\"variable_ref\":<n>?}` to `xd://debug`."
110            }
111            "evaluate" => {
112                "Evaluate an expression in a frame. Send `{\"action\":\"evaluate\",\"expression\":\"<expr>\",\"frame_id\":<n>}` to `xd://debug`."
113            }
114            "terminate" => {
115                "End the debug session. Send `{\"action\":\"terminate\"}` to `xd://debug`."
116            }
117            _ => "Unknown action.",
118        }
119    }
120}
121
122#[async_trait]
123impl AgentTool for DebugTool {
124    fn name(&self) -> &str {
125        "debug"
126    }
127
128    fn label(&self) -> &str {
129        "Debug (DAP)"
130    }
131
132    fn description(&self) -> &str {
133        "Drive a debugger through the Debug Adapter Protocol (DAP). Supports launching and \
134         attaching to programs, setting and removing breakpoints, stepping (in/over/out), \
135         inspecting threads, stack frames, scopes, variables, and evaluating expressions, and \
136         terminating the session.\n\n\
137         Status: scaffold. Backed by the `xd://debug` virtual device in the host harness. Until \
138         the proxy is wired up, each call validates the action and returns the equivalent \
139         `xd://debug` request payload; the host harness will execute the corresponding action \
140         directly."
141    }
142
143    fn essential(&self) -> bool {
144        false
145    }
146
147    fn parameters_schema(&self) -> Value {
148        json!({
149            "type": "object",
150            "properties": {
151                "action": {
152                    "type": "string",
153                    "enum": [
154                        "attach",
155                        "continue",
156                        "launch",
157                        "pause",
158                        "stack_trace",
159                        "step_in",
160                        "step_over",
161                        "step_out",
162                        "terminate",
163                        "threads",
164                        "variables",
165                        "evaluate",
166                        "scopes",
167                        "set_breakpoint",
168                        "remove_breakpoint",
169                        "sessions"
170                    ],
171                    "description": "DAP action to perform. Session lifecycle: `sessions`, `launch`, `attach`, `terminate`. Breakpoints: `set_breakpoint`, `remove_breakpoint`. Execution control: `continue`, `pause`, `step_in`, `step_over`, `step_out`. Inspection: `threads`, `stack_trace`, `scopes`, `variables`, `evaluate`."
172                },
173                "program": {
174                    "type": "string",
175                    "description": "Path to the debug target binary/script. Required for `launch`; for `attach` use the host/port fields exposed by the adapter instead."
176                },
177                "args": {
178                    "type": "array",
179                    "items": { "type": "string" },
180                    "description": "Arguments forwarded to the program under debug. Honoured by `launch`."
181                },
182                "adapter": {
183                    "type": "string",
184                    "enum": ["gdb", "lldb-dap", "debugpy", "dlv"],
185                    "description": "DAP adapter to use. `gdb`/`lldb-dap` for native binaries, `debugpy` for Python, `dlv` for Go. Defaults to an adapter inferred from the program extension when unset."
186                },
187                "expression": {
188                    "type": "string",
189                    "description": "Expression to evaluate. Used by `evaluate` (and as the body of conditional breakpoints when supplied with `condition: false`)."
190                },
191                "file": {
192                    "type": "string",
193                    "description": "Source file path. Required for `set_breakpoint` / `remove_breakpoint`; optional elsewhere for context."
194                },
195                "line": {
196                    "type": "number",
197                    "description": "Source line (1-based). Required for `set_breakpoint` / `remove_breakpoint`."
198                },
199                "condition": {
200                    "type": "string",
201                    "description": "Breakpoint condition expression. When set, the breakpoint only halts when the expression evaluates to truthy. Used with `set_breakpoint`."
202                },
203                "thread_id": {
204                    "type": "number",
205                    "description": "Thread id (from `threads`). Required for `continue`, `pause`, `step_in`, `step_over`, `step_out`, and `stack_trace`."
206                },
207                "frame_id": {
208                    "type": "number",
209                    "description": "Stack frame id (from `stack_trace`). Required for `scopes`, `variables`, and `evaluate`."
210                },
211                "variable_ref": {
212                    "type": "number",
213                    "description": "Variable reference handle (from `variables`). Used to fetch nested members when omitted on the top scope."
214                }
215            },
216            "required": ["action"]
217        })
218    }
219
220    fn intent(&self) -> Option<&str> {
221        Some("Drive a debugger via DAP")
222    }
223
224    fn execution_mode(&self) -> ToolExecutionMode {
225        // A debug session is a single mutable resource shared across
226        // the model: two parallel `step` / `set_breakpoint` calls
227        // would race on the same DAP client. Force sequential execution.
228        ToolExecutionMode::SequentialOnly
229    }
230
231    async fn execute(
232        &self,
233        _tool_call_id: &str,
234        params: Value,
235        _signal: Option<oneshot::Receiver<()>>,
236        _ctx: &ToolContext,
237    ) -> Result<AgentToolResult, ToolError> {
238        // ── Validate action ─────────────────────────────────────────
239        let action = params
240            .get("action")
241            .and_then(|v| v.as_str())
242            .ok_or_else(|| "Missing required parameter: action".to_string())?
243            .trim();
244
245        if action.is_empty() {
246            return Err("Parameter `action` must be a non-empty string".to_string());
247        }
248
249        if !Self::is_supported_action(action) {
250            return Err(format!(
251                "Unsupported debug action: `{}`. Supported actions: {}",
252                action,
253                Self::ACTIONS.join(", ")
254            ));
255        }
256
257        // ── Action-specific required params ────────────────────────
258        validate_action_params(action, &params)?;
259
260        // ── Sessions: probe for adapter availability ───────────────
261        if action == "sessions" {
262            let adapters = [
263                ("lldb-dap", "lldb-dap"),
264                ("gdb", "gdb"),
265                ("debugpy", "debugpy"),
266                ("dlv", "dlv"),
267            ];
268            let mut available = Vec::new();
269            let mut unavailable = Vec::new();
270            for (name, binary) in &adapters {
271                let found = tokio::process::Command::new("which")
272                    .arg(binary)
273                    .output()
274                    .await
275                    .map(|o| o.status.success())
276                    .unwrap_or(false);
277                if found {
278                    available.push(*name);
279                } else {
280                    unavailable.push(*name);
281                }
282            }
283
284            let msg = if available.is_empty() {
285                "No debug adapters found on PATH. Install one of: lldb-dap (via LLVM), \
286                 gdb, debugpy (pip install debugpy), dlv (go install github.com/go-delve/delve/cmd/dlv@latest)."
287                    .to_string()
288            } else {
289                format!(
290                    "Available debug adapters: {}.\nUnavailable: {}.\n\n\
291                     Use `action: \"launch\"` with `adapter` to start a session, or \
292                     `action: \"attach\"` to connect to a running process.",
293                    available.join(", "),
294                    if unavailable.is_empty() {
295                        "none".to_string()
296                    } else {
297                        unavailable.join(", ")
298                    }
299                )
300            };
301
302            return Ok(AgentToolResult::success(msg).with_metadata(json!({
303                "available_adapters": available,
304            })));
305        }
306
307        // ── Other actions: guidance ─────────────────────────────────
308        let adapter = params
309            .get("adapter")
310            .and_then(|v| v.as_str())
311            .unwrap_or("(inferred)");
312        let program = params
313            .get("program")
314            .and_then(|v| v.as_str())
315            .unwrap_or("(none)");
316
317        let guidance = Self::guidance(action);
318
319        let message = format!(
320            "Debug action `{action}` (adapter: {adapter}, program: {program}).\n\n\
321             {guidance}\n\n\
322             DAP routing via `xd://debug` is not proxied yet. Issue the request above through the\
323             host harness directly to drive the debugger.",
324            action = action,
325            adapter = adapter,
326            program = program,
327            guidance = guidance,
328        );
329
330        Ok(AgentToolResult::success(message).with_metadata(json!({
331            "action": action,
332            "adapter": params.get("adapter").cloned().unwrap_or(Value::Null),
333            "program": params.get("program").cloned().unwrap_or(Value::Null),
334            "guidance": guidance,
335        })))
336    }
337}
338
339/// Validate action-specific required parameters against the supplied JSON.
340///
341/// Returns `Err` with a human-readable message when a required field is
342/// missing or has the wrong JSON type.
343fn validate_action_params(action: &str, params: &Value) -> Result<(), ToolError> {
344    let require_str = |field: &str| -> Result<String, ToolError> {
345        params
346            .get(field)
347            .and_then(|v| v.as_str())
348            .map(|s| s.to_string())
349            .ok_or_else(|| format!("Action `{action}` requires string parameter `{field}`"))
350    };
351
352    let require_u64 = |field: &str| -> Result<u64, ToolError> {
353        params
354            .get(field)
355            .and_then(|v| v.as_u64())
356            .ok_or_else(|| format!("Action `{action}` requires integer parameter `{field}`"))
357    };
358
359    match action {
360        "launch" => {
361            require_str("program")?;
362            // `args` is optional but must be a string array if present.
363            if let Some(args) = params.get("args")
364                && !args.is_array()
365            {
366                return Err("Parameter `args` must be an array of strings".to_string());
367            }
368        }
369        "attach" => {
370            // Adapter is required for attach; program is not (attach is to a running process).
371            require_str("adapter")?;
372        }
373        "set_breakpoint" | "remove_breakpoint" => {
374            require_str("file")?;
375            require_u64("line")?;
376        }
377        "stack_trace" => {
378            require_u64("thread_id")?;
379        }
380        "scopes" | "variables" => {
381            require_u64("frame_id")?;
382        }
383        "evaluate" => {
384            require_str("expression")?;
385            require_u64("frame_id")?;
386        }
387        "continue" | "pause" | "step_in" | "step_over" | "step_out" => {
388            require_u64("thread_id")?;
389        }
390        "terminate" | "threads" | "sessions" => {
391            // No required params beyond `action`.
392        }
393        // Unreachable by construction: `action` is validated against the
394        // supported set before this match runs. No-op instead of panicking
395        // so a future supported action added to the validator but forgotten
396        // here degrades to a no-op rather than crashing the agent.
397        _ => {}
398    }
399
400    Ok(())
401}
402
403/// `debug` agent tool routed through a real [`DebugService`](crate::runtime::DebugService).
404///
405/// Same action surface as the [`DebugTool`] scaffold, but every operation
406/// drives an actual DAP session: `launch`/`attach` start adapter processes,
407/// breakpoint/stepping/inspection actions issue DAP requests, and
408/// `terminate` tears the session down. The pack picks this variant when the
409/// host provides a `DebugService` and falls back to the scaffold otherwise.
410pub struct DapDebugTool {
411    service: Arc<dyn crate::runtime::DebugService>,
412    sessions: std::sync::Mutex<Vec<String>>,
413}
414
415/// Adapter enum value → DAP adapter command line.
416fn adapter_command(adapter: &str) -> Vec<String> {
417    match adapter {
418        "gdb" => vec!["gdb".into(), "--interpreter=dap".into(), "-q".into()],
419        "lldb-dap" => vec!["lldb-dap".into()],
420        "debugpy" => vec!["python3".into(), "-m".into(), "debugpy.adapter".into()],
421        "dlv" => vec!["dlv".into(), "dap".into()],
422        other => vec![other.to_string()],
423    }
424}
425
426/// Map a tool action to the DAP request command + arguments built from
427/// the tool parameters. Returns `None` for actions that are not plain
428/// DAP requests (lifecycle actions are handled by the service).
429fn dap_request(action: &str, params: &Value) -> Option<(String, Value)> {
430    let thread_id = || params.get("thread_id").cloned().unwrap_or(json!(0));
431    let frame_id = || params.get("frame_id").cloned().unwrap_or(json!(0));
432    match action {
433        "set_breakpoint" => {
434            let mut bp = json!({ "line": params.get("line").cloned().unwrap_or(json!(0)) });
435            if let Some(condition) = params.get("condition").filter(|v| !v.is_null()) {
436                bp["condition"] = condition.clone();
437            }
438            Some((
439                "setBreakpoints".to_string(),
440                json!({
441                    "source": { "path": params.get("file").cloned().unwrap_or(json!("")) },
442                    "breakpoints": [bp],
443                }),
444            ))
445        }
446        // DAP removes breakpoints by re-setting the file's breakpoint list.
447        "remove_breakpoint" => Some((
448            "setBreakpoints".to_string(),
449            json!({
450                "source": { "path": params.get("file").cloned().unwrap_or(json!("")) },
451                "breakpoints": [],
452            }),
453        )),
454        "continue" => Some(("continue".to_string(), json!({ "threadId": thread_id() }))),
455        "pause" => Some(("pause".to_string(), json!({ "threadId": thread_id() }))),
456        "step_in" => Some(("stepIn".to_string(), json!({ "threadId": thread_id() }))),
457        "step_over" => Some(("next".to_string(), json!({ "threadId": thread_id() }))),
458        "step_out" => Some(("stepOut".to_string(), json!({ "threadId": thread_id() }))),
459        "threads" => Some(("threads".to_string(), json!({}))),
460        "stack_trace" => Some(("stackTrace".to_string(), json!({ "threadId": thread_id() }))),
461        "scopes" => Some(("scopes".to_string(), json!({ "frameId": frame_id() }))),
462        "variables" => {
463            // DAP walks variables by reference handle; the frame id is the
464            // entry handle when the model has not obtained one from scopes.
465            let reference = params
466                .get("variable_ref")
467                .filter(|v| !v.is_null())
468                .cloned()
469                .unwrap_or_else(frame_id);
470            Some((
471                "variables".to_string(),
472                json!({ "variablesReference": reference }),
473            ))
474        }
475        "evaluate" => Some((
476            "evaluate".to_string(),
477            json!({
478                "expression": params.get("expression").cloned().unwrap_or(json!("")),
479                "frameId": frame_id(),
480            }),
481        )),
482        _ => None,
483    }
484}
485
486impl DapDebugTool {
487    /// Route operations through `service`.
488    pub fn new(service: Arc<dyn crate::runtime::DebugService>) -> Self {
489        Self {
490            service,
491            sessions: std::sync::Mutex::new(Vec::new()),
492        }
493    }
494
495    fn track_session(&self, id: String) {
496        // SAFETY: a poisoned lock means the previous holder panicked while
497        // holding it — a real bug that must surface, not be swallowed.
498        #[allow(clippy::expect_used)]
499        self.sessions
500            .lock()
501            .expect("session list poisoned")
502            .push(id);
503    }
504
505    fn forget_session(&self, id: &str) {
506        #[allow(clippy::expect_used)]
507        self.sessions
508            .lock()
509            .expect("session list poisoned")
510            .retain(|s| s != id);
511    }
512
513    fn listed_sessions(&self) -> Vec<String> {
514        #[allow(clippy::expect_used)]
515        self.sessions.lock().expect("session list poisoned").clone()
516    }
517
518    /// The session a non-lifecycle action targets: explicit `session`
519    /// param, else the most recently started session.
520    fn target_session(&self, explicit: Option<&str>) -> Result<String, ToolError> {
521        if let Some(id) = explicit {
522            return Ok(id.to_string());
523        }
524        #[allow(clippy::expect_used)]
525        self.sessions
526            .lock()
527            .expect("session list poisoned")
528            .last()
529            .cloned()
530            .ok_or_else(|| "No active debug session — call `launch` or `attach` first".to_string())
531    }
532}
533
534#[async_trait]
535impl AgentTool for DapDebugTool {
536    fn name(&self) -> &str {
537        "debug"
538    }
539
540    fn label(&self) -> &str {
541        "Debug (DAP, routed)"
542    }
543
544    fn essential(&self) -> bool {
545        false
546    }
547
548    fn description(&self) -> &str {
549        "Drive a real debugger through the Debug Adapter Protocol (DAP). \
550         `launch`/`attach` start adapter sessions; breakpoint, stepping, \
551         inspection, and evaluation actions issue live DAP requests and \
552         return the adapter's JSON responses; `terminate` ends the session. \
553         Pass the `session` id returned by `launch`/`attach` to target a \
554         specific session; the most recent one is used by default."
555    }
556
557    fn parameters_schema(&self) -> Value {
558        json!({
559            "type": "object",
560            "properties": {
561                "action": {
562                    "type": "string",
563                    "enum": [
564                        "attach",
565                        "continue",
566                        "launch",
567                        "pause",
568                        "stack_trace",
569                        "step_in",
570                        "step_over",
571                        "step_out",
572                        "terminate",
573                        "threads",
574                        "variables",
575                        "evaluate",
576                        "scopes",
577                        "set_breakpoint",
578                        "remove_breakpoint",
579                        "sessions"
580                    ],
581                    "description": "DAP action to perform. Session lifecycle: `sessions`, `launch`, `attach`, `terminate`. Breakpoints: `set_breakpoint`, `remove_breakpoint`. Execution control: `continue`, `pause`, `step_in`, `step_over`, `step_out`. Inspection: `threads`, `stack_trace`, `scopes`, `variables`, `evaluate`."
582                },
583                "session": {
584                    "type": "string",
585                    "description": "Session id returned by `launch`/`attach`. Defaults to the most recent session."
586                },
587                "program": {
588                    "type": "string",
589                    "description": "Path to the debug target binary/script. Required for `launch`."
590                },
591                "args": {
592                    "type": "array",
593                    "items": { "type": "string" },
594                    "description": "Arguments forwarded to the program under debug. Honoured by `launch`."
595                },
596                "cwd": {
597                    "type": "string",
598                    "description": "Working directory for the debug target. Honoured by `launch`/`attach` when the adapter supports it."
599                },
600                "adapter": {
601                    "type": "string",
602                    "enum": ["gdb", "lldb-dap", "debugpy", "dlv"],
603                    "description": "DAP adapter to use. `gdb`/`lldb-dap` for native binaries, `debugpy` for Python, `dlv` for Go. Defaults to an adapter inferred from the program extension when unset."
604                },
605                "adapter_command": {
606                    "type": "array",
607                    "items": { "type": "string" },
608                    "description": "Explicit adapter command line (argv) overriding the `adapter` preset — for custom or bundled adapters."
609                },
610                "expression": {
611                    "type": "string",
612                    "description": "Expression to evaluate. Used by `evaluate`."
613                },
614                "file": {
615                    "type": "string",
616                    "description": "Source file path. Required for `set_breakpoint` / `remove_breakpoint`."
617                },
618                "line": {
619                    "type": "number",
620                    "description": "Source line (1-based). Required for `set_breakpoint` / `remove_breakpoint`."
621                },
622                "condition": {
623                    "type": "string",
624                    "description": "Breakpoint condition expression. Used with `set_breakpoint`."
625                },
626                "thread_id": {
627                    "type": "number",
628                    "description": "Thread id (from `threads`). Required for `continue`, `pause`, `step_in`, `step_over`, `step_out`, and `stack_trace`."
629                },
630                "frame_id": {
631                    "type": "number",
632                    "description": "Stack frame id (from `stack_trace`). Required for `scopes`, `variables`, and `evaluate`."
633                },
634                "variable_ref": {
635                    "type": "number",
636                    "description": "Variable reference handle (from `variables`). Used to fetch nested members when omitted on the top scope."
637                }
638            },
639            "required": ["action"]
640        })
641    }
642
643    fn intent(&self) -> Option<&str> {
644        Some("Drive a real debugger via DAP")
645    }
646
647    fn execution_mode(&self) -> ToolExecutionMode {
648        // A debug session is a single mutable resource shared across the
649        // model: two parallel step / set_breakpoint calls would race on the
650        // same DAP client.
651        ToolExecutionMode::SequentialOnly
652    }
653
654    async fn execute(
655        &self,
656        _tool_call_id: &str,
657        params: Value,
658        _signal: Option<oneshot::Receiver<()>>,
659        _ctx: &ToolContext,
660    ) -> Result<AgentToolResult, ToolError> {
661        let action = params
662            .get("action")
663            .and_then(|v| v.as_str())
664            .ok_or_else(|| "Missing required parameter: action".to_string())?
665            .trim();
666        if action.is_empty() {
667            return Err("Parameter `action` must be a non-empty string".to_string());
668        }
669        if !DebugTool::is_supported_action(action) {
670            return Err(format!(
671                "Unsupported debug action: `{}`. Supported actions: {}",
672                action,
673                DebugTool::ACTIONS.join(", ")
674            ));
675        }
676        validate_action_params(action, &params)?;
677        let explicit_session = params.get("session").and_then(|v| v.as_str());
678
679        // ── Session lifecycle ──────────────────────────────────────
680        match action {
681            "sessions" => {
682                let sessions = self.listed_sessions();
683                let text = if sessions.is_empty() {
684                    "No active debug sessions. Use `launch` or `attach` to start one.".to_string()
685                } else {
686                    format!(
687                        "Active debug sessions (most recent last):\n{}",
688                        sessions
689                            .iter()
690                            .rev()
691                            .map(|s| format!("- {s}"))
692                            .collect::<Vec<_>>()
693                            .join("\n")
694                    )
695                };
696                return Ok(AgentToolResult::success(text).with_metadata(json!({
697                    "sessions": sessions,
698                })));
699            }
700            "launch" | "attach" => {
701                let adapter_argv: Vec<String> = params
702                    .get("adapter_command")
703                    .and_then(|v| v.as_array())
704                    .map(|items| {
705                        items
706                            .iter()
707                            .filter_map(|v| v.as_str().map(String::from))
708                            .collect()
709                    })
710                    .unwrap_or_else(|| {
711                        adapter_command(
712                            params
713                                .get("adapter")
714                                .and_then(|v| v.as_str())
715                                .unwrap_or("lldb-dap"),
716                        )
717                    });
718                let adapter_label = params
719                    .get("adapter")
720                    .and_then(|v| v.as_str())
721                    .unwrap_or("custom");
722                let mut config = json!({
723                    "type": adapter_label,
724                    "request": action,
725                    "adapter": adapter_argv,
726                });
727                if let Some(obj) = config.as_object_mut() {
728                    for (key, value) in params.as_object().into_iter().flatten() {
729                        if key != "action"
730                            && key != "adapter"
731                            && key != "adapter_command"
732                            && key != "session"
733                        {
734                            obj.insert(key.clone(), value.clone());
735                        }
736                    }
737                }
738                let session = self
739                    .service
740                    .start(&config)
741                    .await
742                    .map_err(|e| -> ToolError { format!("DAP start failed: {e}") })?;
743                self.track_session(session.clone());
744                let text = format!(
745                    "Debug session started ({action}, adapter: {adapter_label}).\nsession: {session}\n\
746                     The adapter reports `stopped` once the target is ready; use `threads`, \
747                     `set_breakpoint`, `continue`, `stack_trace`, `variables`, `evaluate`, \
748                     stepping actions, and `terminate` against this session."
749                );
750                return Ok(AgentToolResult::success(text).with_metadata(json!({
751                    "session": session,
752                    "request": action,
753                })));
754            }
755            "terminate" => {
756                let id = self.target_session(explicit_session)?;
757                self.service
758                    .terminate(&id)
759                    .await
760                    .map_err(|e| -> ToolError { format!("DAP terminate failed: {e}") })?;
761                self.forget_session(&id);
762                return Ok(AgentToolResult::success(format!(
763                    "Debug session terminated: {id}"
764                )));
765            }
766            _ => {}
767        }
768
769        // ── Plain DAP requests ─────────────────────────────────────
770        let Some((command, args)) = dap_request(action, &params) else {
771            return Err(format!("Action `{action}` is not mapped to a DAP request"));
772        };
773        let target = self.target_session(explicit_session)?;
774        let body = self
775            .service
776            .request(&target, &command, &args)
777            .await
778            .map_err(|e| -> ToolError { format!("DAP {command} failed: {e}") })?;
779        let pretty = serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string());
780        Ok(
781            AgentToolResult::success(format!("{action} → {command}\n{pretty}"))
782                .with_metadata(json!({ "session": target, "command": command, "body": body })),
783        )
784    }
785}