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 tokio::sync::oneshot;
23
24use super::{AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode};
25
26/// `debug` agent tool — DAP-backed debugger integration.
27///
28/// Surfaces DAP operations (`launch`, `attach`, breakpoint control,
29/// stepping, inspection, termination) to the model over the standard
30/// agent-loop contract. The long-term backing is the `xd://debug`
31/// virtual device; today the tool is a validated scaffold.
32pub struct DebugTool;
33
34impl DebugTool {
35    /// Canonical list of DAP actions supported by the tool.
36    ///
37    /// Order is chosen to mirror the natural debugging workflow:
38    /// session lifecycle first, then breakpoint control, then execution
39    /// control, then inspection, then teardown.
40    pub const ACTIONS: &'static [&'static str] = &[
41        "sessions",
42        "launch",
43        "attach",
44        "set_breakpoint",
45        "remove_breakpoint",
46        "continue",
47        "pause",
48        "step_in",
49        "step_over",
50        "step_out",
51        "threads",
52        "stack_trace",
53        "scopes",
54        "variables",
55        "evaluate",
56        "terminate",
57    ];
58
59    /// Returns `true` if `action` is a recognised DAP action.
60    pub fn is_supported_action(action: &str) -> bool {
61        Self::ACTIONS.contains(&action)
62    }
63
64    /// Per-action guidance describing how to issue the same operation
65    /// through the `xd://debug` virtual device right now.
66    fn guidance(action: &str) -> &'static str {
67        match action {
68            "sessions" => {
69                "List active debug sessions. Write `{\"action\":\"sessions\"}` to `xd://debug`."
70            }
71            "launch" => {
72                "Start a new DAP session. Send `{\"action\":\"launch\",\"program\":\"<bin>\",\"args\":[…],\"adapter\":\"<gdb|lldb-dap|debugpy|dlv>\"}` to `xd://debug`."
73            }
74            "attach" => {
75                "Attach to a running process. Send `{\"action\":\"attach\",\"adapter\":\"<gdb|lldb-dap|debugpy|dlv>\"}` plus the adapter's attach parameters to `xd://debug`."
76            }
77            "set_breakpoint" => {
78                "Set a source breakpoint. Send `{\"action\":\"set_breakpoint\",\"file\":\"<path>\",\"line\":<n>,\"condition\":\"<expr>\"?}` to `xd://debug`."
79            }
80            "remove_breakpoint" => {
81                "Remove a previously set breakpoint. Send `{\"action\":\"remove_breakpoint\",\"file\":\"<path>\",\"line\":<n>}` to `xd://debug`."
82            }
83            "continue" => {
84                "Resume execution on a thread. Send `{\"action\":\"continue\",\"thread_id\":<n>}` to `xd://debug`."
85            }
86            "pause" => {
87                "Suspend a running thread. Send `{\"action\":\"pause\",\"thread_id\":<n>}` to `xd://debug`."
88            }
89            "step_in" => {
90                "Step into the current call. Send `{\"action\":\"step_in\",\"thread_id\":<n>}` to `xd://debug`."
91            }
92            "step_over" => {
93                "Step over the current call. Send `{\"action\":\"step_over\",\"thread_id\":<n>}` to `xd://debug`."
94            }
95            "step_out" => {
96                "Step out of the current frame. Send `{\"action\":\"step_out\",\"thread_id\":<n>}` to `xd://debug`."
97            }
98            "threads" => {
99                "List threads in the current session. Send `{\"action\":\"threads\"}` to `xd://debug`."
100            }
101            "stack_trace" => {
102                "Fetch the stack frames for a thread. Send `{\"action\":\"stack_trace\",\"thread_id\":<n>}` to `xd://debug`."
103            }
104            "scopes" => {
105                "Fetch the lexical scopes for a frame. Send `{\"action\":\"scopes\",\"frame_id\":<n>}` to `xd://debug`."
106            }
107            "variables" => {
108                "Fetch variables for a scope or variable reference. Send `{\"action\":\"variables\",\"frame_id\":<n>,\"variable_ref\":<n>?}` to `xd://debug`."
109            }
110            "evaluate" => {
111                "Evaluate an expression in a frame. Send `{\"action\":\"evaluate\",\"expression\":\"<expr>\",\"frame_id\":<n>}` to `xd://debug`."
112            }
113            "terminate" => {
114                "End the debug session. Send `{\"action\":\"terminate\"}` to `xd://debug`."
115            }
116            _ => "Unknown action.",
117        }
118    }
119}
120
121#[async_trait]
122impl AgentTool for DebugTool {
123    fn name(&self) -> &str {
124        "debug"
125    }
126
127    fn label(&self) -> &str {
128        "Debug (DAP)"
129    }
130
131    fn description(&self) -> &str {
132        "Drive a debugger through the Debug Adapter Protocol (DAP). Supports launching and \
133         attaching to programs, setting and removing breakpoints, stepping (in/over/out), \
134         inspecting threads, stack frames, scopes, variables, and evaluating expressions, and \
135         terminating the session.\n\n\
136         Status: scaffold. Backed by the `xd://debug` virtual device in the host harness. Until \
137         the proxy is wired up, each call validates the action and returns the equivalent \
138         `xd://debug` request payload; the host harness will execute the corresponding action \
139         directly."
140    }
141
142    fn essential(&self) -> bool {
143        false
144    }
145
146    fn parameters_schema(&self) -> Value {
147        json!({
148            "type": "object",
149            "properties": {
150                "action": {
151                    "type": "string",
152                    "enum": [
153                        "attach",
154                        "continue",
155                        "launch",
156                        "pause",
157                        "stack_trace",
158                        "step_in",
159                        "step_over",
160                        "step_out",
161                        "terminate",
162                        "threads",
163                        "variables",
164                        "evaluate",
165                        "scopes",
166                        "set_breakpoint",
167                        "remove_breakpoint",
168                        "sessions"
169                    ],
170                    "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`."
171                },
172                "program": {
173                    "type": "string",
174                    "description": "Path to the debug target binary/script. Required for `launch`; for `attach` use the host/port fields exposed by the adapter instead."
175                },
176                "args": {
177                    "type": "array",
178                    "items": { "type": "string" },
179                    "description": "Arguments forwarded to the program under debug. Honoured by `launch`."
180                },
181                "adapter": {
182                    "type": "string",
183                    "enum": ["gdb", "lldb-dap", "debugpy", "dlv"],
184                    "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."
185                },
186                "expression": {
187                    "type": "string",
188                    "description": "Expression to evaluate. Used by `evaluate` (and as the body of conditional breakpoints when supplied with `condition: false`)."
189                },
190                "file": {
191                    "type": "string",
192                    "description": "Source file path. Required for `set_breakpoint` / `remove_breakpoint`; optional elsewhere for context."
193                },
194                "line": {
195                    "type": "number",
196                    "description": "Source line (1-based). Required for `set_breakpoint` / `remove_breakpoint`."
197                },
198                "condition": {
199                    "type": "string",
200                    "description": "Breakpoint condition expression. When set, the breakpoint only halts when the expression evaluates to truthy. Used with `set_breakpoint`."
201                },
202                "thread_id": {
203                    "type": "number",
204                    "description": "Thread id (from `threads`). Required for `continue`, `pause`, `step_in`, `step_over`, `step_out`, and `stack_trace`."
205                },
206                "frame_id": {
207                    "type": "number",
208                    "description": "Stack frame id (from `stack_trace`). Required for `scopes`, `variables`, and `evaluate`."
209                },
210                "variable_ref": {
211                    "type": "number",
212                    "description": "Variable reference handle (from `variables`). Used to fetch nested members when omitted on the top scope."
213                }
214            },
215            "required": ["action"]
216        })
217    }
218
219    fn intent(&self) -> Option<&str> {
220        Some("Drive a debugger via DAP")
221    }
222
223    fn execution_mode(&self) -> ToolExecutionMode {
224        // A debug session is a single mutable resource shared across
225        // the model: two parallel `step` / `set_breakpoint` calls
226        // would race on the same DAP client. Force sequential execution.
227        ToolExecutionMode::SequentialOnly
228    }
229
230    async fn execute(
231        &self,
232        _tool_call_id: &str,
233        params: Value,
234        _signal: Option<oneshot::Receiver<()>>,
235        _ctx: &ToolContext,
236    ) -> Result<AgentToolResult, ToolError> {
237        // ── Validate action ─────────────────────────────────────────
238        let action = params
239            .get("action")
240            .and_then(|v| v.as_str())
241            .ok_or_else(|| "Missing required parameter: action".to_string())?
242            .trim();
243
244        if action.is_empty() {
245            return Err("Parameter `action` must be a non-empty string".to_string());
246        }
247
248        if !Self::is_supported_action(action) {
249            return Err(format!(
250                "Unsupported debug action: `{}`. Supported actions: {}",
251                action,
252                Self::ACTIONS.join(", ")
253            ));
254        }
255
256        // ── Action-specific required params ────────────────────────
257        validate_action_params(action, &params)?;
258
259        // ── Sessions: probe for adapter availability ───────────────
260        if action == "sessions" {
261            let adapters = [
262                ("lldb-dap", "lldb-dap"),
263                ("gdb", "gdb"),
264                ("debugpy", "debugpy"),
265                ("dlv", "dlv"),
266            ];
267            let mut available = Vec::new();
268            let mut unavailable = Vec::new();
269            for (name, binary) in &adapters {
270                let found = tokio::process::Command::new("which")
271                    .arg(binary)
272                    .output()
273                    .await
274                    .map(|o| o.status.success())
275                    .unwrap_or(false);
276                if found {
277                    available.push(*name);
278                } else {
279                    unavailable.push(*name);
280                }
281            }
282
283            let msg = if available.is_empty() {
284                "No debug adapters found on PATH. Install one of: lldb-dap (via LLVM), \
285                 gdb, debugpy (pip install debugpy), dlv (go install github.com/go-delve/delve/cmd/dlv@latest)."
286                    .to_string()
287            } else {
288                format!(
289                    "Available debug adapters: {}.\nUnavailable: {}.\n\n\
290                     Use `action: \"launch\"` with `adapter` to start a session, or \
291                     `action: \"attach\"` to connect to a running process.",
292                    available.join(", "),
293                    if unavailable.is_empty() {
294                        "none".to_string()
295                    } else {
296                        unavailable.join(", ")
297                    }
298                )
299            };
300
301            return Ok(AgentToolResult::success(msg).with_metadata(json!({
302                "available_adapters": available,
303            })));
304        }
305
306        // ── Other actions: guidance ─────────────────────────────────
307        let adapter = params
308            .get("adapter")
309            .and_then(|v| v.as_str())
310            .unwrap_or("(inferred)");
311        let program = params
312            .get("program")
313            .and_then(|v| v.as_str())
314            .unwrap_or("(none)");
315
316        let guidance = Self::guidance(action);
317
318        let message = format!(
319            "Debug action `{action}` (adapter: {adapter}, program: {program}).\n\n\
320             {guidance}\n\n\
321             DAP routing via `xd://debug` is not proxied yet. Issue the request above through the\
322             host harness directly to drive the debugger.",
323            action = action,
324            adapter = adapter,
325            program = program,
326            guidance = guidance,
327        );
328
329        Ok(AgentToolResult::success(message).with_metadata(json!({
330            "action": action,
331            "adapter": params.get("adapter").cloned().unwrap_or(Value::Null),
332            "program": params.get("program").cloned().unwrap_or(Value::Null),
333            "guidance": guidance,
334        })))
335    }
336}
337
338/// Validate action-specific required parameters against the supplied JSON.
339///
340/// Returns `Err` with a human-readable message when a required field is
341/// missing or has the wrong JSON type.
342fn validate_action_params(action: &str, params: &Value) -> Result<(), ToolError> {
343    let require_str = |field: &str| -> Result<String, ToolError> {
344        params
345            .get(field)
346            .and_then(|v| v.as_str())
347            .map(|s| s.to_string())
348            .ok_or_else(|| format!("Action `{action}` requires string parameter `{field}`"))
349    };
350
351    let require_u64 = |field: &str| -> Result<u64, ToolError> {
352        params
353            .get(field)
354            .and_then(|v| v.as_u64())
355            .ok_or_else(|| format!("Action `{action}` requires integer parameter `{field}`"))
356    };
357
358    match action {
359        "launch" => {
360            require_str("program")?;
361            // `args` is optional but must be a string array if present.
362            if let Some(args) = params.get("args")
363                && !args.is_array()
364            {
365                return Err("Parameter `args` must be an array of strings".to_string());
366            }
367        }
368        "attach" => {
369            // Adapter is required for attach; program is not (attach is to a running process).
370            require_str("adapter")?;
371        }
372        "set_breakpoint" | "remove_breakpoint" => {
373            require_str("file")?;
374            require_u64("line")?;
375        }
376        "stack_trace" => {
377            require_u64("thread_id")?;
378        }
379        "scopes" | "variables" => {
380            require_u64("frame_id")?;
381        }
382        "evaluate" => {
383            require_str("expression")?;
384            require_u64("frame_id")?;
385        }
386        "continue" | "pause" | "step_in" | "step_over" | "step_out" => {
387            require_u64("thread_id")?;
388        }
389        "terminate" | "threads" | "sessions" => {
390            // No required params beyond `action`.
391        }
392        // Unreachable by construction: `action` is validated against the
393        // supported set before this match runs. No-op instead of panicking
394        // so a future supported action added to the validator but forgotten
395        // here degrades to a no-op rather than crashing the agent.
396        _ => {}
397    }
398
399    Ok(())
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use serde_json::json;
406
407    fn ctx() -> ToolContext {
408        ToolContext::default()
409    }
410
411    #[tokio::test]
412    async fn rejects_missing_action() {
413        let result = DebugTool.execute("c1", json!({}), None, &ctx()).await;
414        assert!(result.is_err());
415        let err = result.unwrap_err();
416        assert!(err.contains("action"), "unexpected error: {err}");
417    }
418
419    #[tokio::test]
420    async fn rejects_empty_action() {
421        let result = DebugTool
422            .execute("c2", json!({"action": "   "}), None, &ctx())
423            .await;
424        assert!(result.is_err());
425    }
426
427    #[tokio::test]
428    async fn rejects_unknown_action() {
429        let result = DebugTool
430            .execute("c3", json!({"action": "rerun"}), None, &ctx())
431            .await;
432        assert!(result.is_err());
433        let err = result.unwrap_err();
434        assert!(err.contains("rerun"), "unexpected error: {err}");
435        assert!(err.contains("Supported actions"), "unexpected error: {err}");
436    }
437
438    #[tokio::test]
439    async fn launch_requires_program() {
440        let result = DebugTool
441            .execute("c4", json!({"action": "launch"}), None, &ctx())
442            .await;
443        assert!(result.is_err());
444        assert!(result.unwrap_err().contains("program"));
445    }
446
447    #[tokio::test]
448    async fn launch_rejects_non_array_args() {
449        let result = DebugTool
450            .execute(
451                "c5",
452                json!({"action": "launch", "program": "./bin/app", "args": "not-an-array"}),
453                None,
454                &ctx(),
455            )
456            .await;
457        assert!(result.is_err());
458        assert!(result.unwrap_err().contains("args"));
459    }
460
461    #[tokio::test]
462    async fn attach_requires_adapter() {
463        let result = DebugTool
464            .execute("c6", json!({"action": "attach"}), None, &ctx())
465            .await;
466        assert!(result.is_err());
467        assert!(result.unwrap_err().contains("adapter"));
468    }
469
470    #[tokio::test]
471    async fn set_breakpoint_requires_file_and_line() {
472        let result = DebugTool
473            .execute(
474                "c7",
475                json!({"action": "set_breakpoint", "file": "src/main.rs"}),
476                None,
477                &ctx(),
478            )
479            .await;
480        assert!(result.is_err());
481        assert!(result.unwrap_err().contains("line"));
482
483        let result = DebugTool
484            .execute(
485                "c8",
486                json!({"action": "set_breakpoint", "line": 42}),
487                None,
488                &ctx(),
489            )
490            .await;
491        assert!(result.is_err());
492        assert!(result.unwrap_err().contains("file"));
493    }
494
495    #[tokio::test]
496    async fn stack_trace_requires_thread_id() {
497        let result = DebugTool
498            .execute("c9", json!({"action": "stack_trace"}), None, &ctx())
499            .await;
500        assert!(result.is_err());
501        assert!(result.unwrap_err().contains("thread_id"));
502    }
503
504    #[tokio::test]
505    async fn evaluate_requires_expression_and_frame() {
506        let result = DebugTool
507            .execute(
508                "c10",
509                json!({"action": "evaluate", "frame_id": 7}),
510                None,
511                &ctx(),
512            )
513            .await;
514        assert!(result.is_err());
515        assert!(result.unwrap_err().contains("expression"));
516
517        let result = DebugTool
518            .execute(
519                "c11",
520                json!({"action": "evaluate", "expression": "x + 1"}),
521                None,
522                &ctx(),
523            )
524            .await;
525        assert!(result.is_err());
526        assert!(result.unwrap_err().contains("frame_id"));
527    }
528
529    #[tokio::test]
530    async fn launch_with_program_succeeds() {
531        let result = DebugTool
532            .execute(
533                "c12",
534                json!({
535                    "action": "launch",
536                    "program": "./target/debug/app",
537                    "args": ["--flag", "value"],
538                    "adapter": "lldb-dap"
539                }),
540                None,
541                &ctx(),
542            )
543            .await
544            .expect("launch should succeed");
545        assert!(result.success);
546        assert!(result.output.contains("`launch`"));
547        assert!(result.output.contains("lldb-dap"));
548        assert!(result.output.contains("./target/debug/app"));
549        // Scaffold does NOT execute — it just acknowledges the request.
550        assert!(result.output.contains("xd://debug"));
551    }
552
553    #[tokio::test]
554    async fn set_breakpoint_with_condition_succeeds() {
555        let result = DebugTool
556            .execute(
557                "c13",
558                json!({
559                    "action": "set_breakpoint",
560                    "file": "src/main.rs",
561                    "line": 42,
562                    "condition": "i == 10"
563                }),
564                None,
565                &ctx(),
566            )
567            .await
568            .expect("set_breakpoint should succeed");
569        assert!(result.success);
570        assert!(result.output.contains("set_breakpoint"));
571    }
572
573    #[tokio::test]
574    async fn sessions_action_succeeds_without_extras() {
575        let result = DebugTool
576            .execute("c14", json!({"action": "sessions"}), None, &ctx())
577            .await
578            .expect("sessions should succeed");
579        assert!(result.success);
580        // Sessions probes available adapters — output mentions adapters.
581        assert!(
582            result.output.contains("adapter") || result.output.contains("debug"),
583            "unexpected output: {}",
584            result.output
585        );
586    }
587
588    #[tokio::test]
589    async fn guidance_text_matches_per_action() {
590        let result = DebugTool
591            .execute(
592                "c15",
593                json!({"action": "evaluate", "expression": "x + 1", "frame_id": 7}),
594                None,
595                &ctx(),
596            )
597            .await
598            .expect("evaluate should succeed");
599        assert!(result.output.contains("xd://debug"));
600        assert!(result.output.contains("evaluate"));
601    }
602
603    #[tokio::test]
604    async fn metadata_carries_scaffold_flag_and_action() {
605        let result = DebugTool
606            .execute(
607                "c16",
608                json!({
609                    "action": "set_breakpoint",
610                    "file": "main.rs",
611                    "line": 10
612                }),
613                None,
614                &ctx(),
615            )
616            .await
617            .expect("set_breakpoint should succeed");
618        let meta = result.metadata.expect("metadata should be set");
619        assert_eq!(meta["action"], json!("set_breakpoint"));
620        assert!(meta["guidance"].as_str().unwrap().contains("xd://debug"));
621    }
622
623    #[test]
624    fn schema_lists_required_action_and_enum() {
625        let schema = DebugTool.parameters_schema();
626        let required = schema.get("required").and_then(|v| v.as_array());
627        assert_eq!(
628            required.and_then(|r| r.first()).and_then(|v| v.as_str()),
629            Some("action"),
630            "schema must mark `action` as required"
631        );
632
633        let actions: Vec<&str> = schema
634            .pointer("/properties/action/enum")
635            .and_then(|v| v.as_array())
636            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
637            .unwrap_or_default();
638
639        for expected in [
640            "attach",
641            "continue",
642            "launch",
643            "pause",
644            "stack_trace",
645            "step_in",
646            "step_over",
647            "step_out",
648            "terminate",
649            "threads",
650            "variables",
651            "evaluate",
652            "scopes",
653            "set_breakpoint",
654            "remove_breakpoint",
655            "sessions",
656        ] {
657            assert!(
658                actions.contains(&expected),
659                "schema enum is missing `{expected}`"
660            );
661        }
662    }
663
664    #[test]
665    fn schema_adapters_match_supported_set() {
666        let schema = DebugTool.parameters_schema();
667        let adapters: Vec<&str> = schema
668            .pointer("/properties/adapter/enum")
669            .and_then(|v| v.as_array())
670            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
671            .unwrap_or_default();
672        for expected in ["gdb", "lldb-dap", "debugpy", "dlv"] {
673            assert!(
674                adapters.contains(&expected),
675                "adapter enum missing `{expected}`"
676            );
677        }
678    }
679
680    #[test]
681    fn schema_declares_dap_integer_properties() {
682        // The validator requires `thread_id` and `frame_id`, and the
683        // guidance text references `variable_ref` — all three must be
684        // declared in the schema so the model can send them.
685        let schema = DebugTool.parameters_schema();
686        for field in ["thread_id", "frame_id", "variable_ref"] {
687            let ty = schema
688                .pointer(&format!("/properties/{field}/type"))
689                .and_then(|v| v.as_str());
690            assert_eq!(
691                ty,
692                Some("number"),
693                "schema must declare `{field}` as a number"
694            );
695        }
696    }
697
698    #[test]
699    fn is_supported_action_matches_constant() {
700        for action in DebugTool::ACTIONS {
701            assert!(DebugTool::is_supported_action(action));
702        }
703        assert!(!DebugTool::is_supported_action("fly"));
704        assert!(!DebugTool::is_supported_action(""));
705    }
706
707    #[test]
708    fn intent_and_mode_are_pinned() {
709        assert_eq!(DebugTool.intent(), Some("Drive a debugger via DAP"));
710        assert!(matches!(
711            DebugTool.execution_mode(),
712            ToolExecutionMode::SequentialOnly
713        ));
714    }
715}