Skip to main content

oxi_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!("action was already validated against the supported set"),
393    }
394
395    Ok(())
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use serde_json::json;
402
403    fn ctx() -> ToolContext {
404        ToolContext::default()
405    }
406
407    #[tokio::test]
408    async fn rejects_missing_action() {
409        let result = DebugTool.execute("c1", json!({}), None, &ctx()).await;
410        assert!(result.is_err());
411        let err = result.unwrap_err();
412        assert!(err.contains("action"), "unexpected error: {err}");
413    }
414
415    #[tokio::test]
416    async fn rejects_empty_action() {
417        let result = DebugTool
418            .execute("c2", json!({"action": "   "}), None, &ctx())
419            .await;
420        assert!(result.is_err());
421    }
422
423    #[tokio::test]
424    async fn rejects_unknown_action() {
425        let result = DebugTool
426            .execute("c3", json!({"action": "rerun"}), None, &ctx())
427            .await;
428        assert!(result.is_err());
429        let err = result.unwrap_err();
430        assert!(err.contains("rerun"), "unexpected error: {err}");
431        assert!(err.contains("Supported actions"), "unexpected error: {err}");
432    }
433
434    #[tokio::test]
435    async fn launch_requires_program() {
436        let result = DebugTool
437            .execute("c4", json!({"action": "launch"}), None, &ctx())
438            .await;
439        assert!(result.is_err());
440        assert!(result.unwrap_err().contains("program"));
441    }
442
443    #[tokio::test]
444    async fn launch_rejects_non_array_args() {
445        let result = DebugTool
446            .execute(
447                "c5",
448                json!({"action": "launch", "program": "./bin/app", "args": "not-an-array"}),
449                None,
450                &ctx(),
451            )
452            .await;
453        assert!(result.is_err());
454        assert!(result.unwrap_err().contains("args"));
455    }
456
457    #[tokio::test]
458    async fn attach_requires_adapter() {
459        let result = DebugTool
460            .execute("c6", json!({"action": "attach"}), None, &ctx())
461            .await;
462        assert!(result.is_err());
463        assert!(result.unwrap_err().contains("adapter"));
464    }
465
466    #[tokio::test]
467    async fn set_breakpoint_requires_file_and_line() {
468        let result = DebugTool
469            .execute(
470                "c7",
471                json!({"action": "set_breakpoint", "file": "src/main.rs"}),
472                None,
473                &ctx(),
474            )
475            .await;
476        assert!(result.is_err());
477        assert!(result.unwrap_err().contains("line"));
478
479        let result = DebugTool
480            .execute(
481                "c8",
482                json!({"action": "set_breakpoint", "line": 42}),
483                None,
484                &ctx(),
485            )
486            .await;
487        assert!(result.is_err());
488        assert!(result.unwrap_err().contains("file"));
489    }
490
491    #[tokio::test]
492    async fn stack_trace_requires_thread_id() {
493        let result = DebugTool
494            .execute("c9", json!({"action": "stack_trace"}), None, &ctx())
495            .await;
496        assert!(result.is_err());
497        assert!(result.unwrap_err().contains("thread_id"));
498    }
499
500    #[tokio::test]
501    async fn evaluate_requires_expression_and_frame() {
502        let result = DebugTool
503            .execute(
504                "c10",
505                json!({"action": "evaluate", "frame_id": 7}),
506                None,
507                &ctx(),
508            )
509            .await;
510        assert!(result.is_err());
511        assert!(result.unwrap_err().contains("expression"));
512
513        let result = DebugTool
514            .execute(
515                "c11",
516                json!({"action": "evaluate", "expression": "x + 1"}),
517                None,
518                &ctx(),
519            )
520            .await;
521        assert!(result.is_err());
522        assert!(result.unwrap_err().contains("frame_id"));
523    }
524
525    #[tokio::test]
526    async fn launch_with_program_succeeds() {
527        let result = DebugTool
528            .execute(
529                "c12",
530                json!({
531                    "action": "launch",
532                    "program": "./target/debug/app",
533                    "args": ["--flag", "value"],
534                    "adapter": "lldb-dap"
535                }),
536                None,
537                &ctx(),
538            )
539            .await
540            .expect("launch should succeed");
541        assert!(result.success);
542        assert!(result.output.contains("`launch`"));
543        assert!(result.output.contains("lldb-dap"));
544        assert!(result.output.contains("./target/debug/app"));
545        // Scaffold does NOT execute — it just acknowledges the request.
546        assert!(result.output.contains("xd://debug"));
547    }
548
549    #[tokio::test]
550    async fn set_breakpoint_with_condition_succeeds() {
551        let result = DebugTool
552            .execute(
553                "c13",
554                json!({
555                    "action": "set_breakpoint",
556                    "file": "src/main.rs",
557                    "line": 42,
558                    "condition": "i == 10"
559                }),
560                None,
561                &ctx(),
562            )
563            .await
564            .expect("set_breakpoint should succeed");
565        assert!(result.success);
566        assert!(result.output.contains("set_breakpoint"));
567    }
568
569    #[tokio::test]
570    async fn sessions_action_succeeds_without_extras() {
571        let result = DebugTool
572            .execute("c14", json!({"action": "sessions"}), None, &ctx())
573            .await
574            .expect("sessions should succeed");
575        assert!(result.success);
576        // Sessions probes available adapters — output mentions adapters.
577        assert!(
578            result.output.contains("adapter") || result.output.contains("debug"),
579            "unexpected output: {}",
580            result.output
581        );
582    }
583
584    #[tokio::test]
585    async fn guidance_text_matches_per_action() {
586        let result = DebugTool
587            .execute(
588                "c15",
589                json!({"action": "evaluate", "expression": "x + 1", "frame_id": 7}),
590                None,
591                &ctx(),
592            )
593            .await
594            .expect("evaluate should succeed");
595        assert!(result.output.contains("xd://debug"));
596        assert!(result.output.contains("evaluate"));
597    }
598
599    #[tokio::test]
600    async fn metadata_carries_scaffold_flag_and_action() {
601        let result = DebugTool
602            .execute(
603                "c16",
604                json!({
605                    "action": "set_breakpoint",
606                    "file": "main.rs",
607                    "line": 10
608                }),
609                None,
610                &ctx(),
611            )
612            .await
613            .expect("set_breakpoint should succeed");
614        let meta = result.metadata.expect("metadata should be set");
615        assert_eq!(meta["action"], json!("set_breakpoint"));
616        assert!(meta["guidance"].as_str().unwrap().contains("xd://debug"));
617    }
618
619    #[test]
620    fn schema_lists_required_action_and_enum() {
621        let schema = DebugTool.parameters_schema();
622        let required = schema.get("required").and_then(|v| v.as_array());
623        assert_eq!(
624            required.and_then(|r| r.first()).and_then(|v| v.as_str()),
625            Some("action"),
626            "schema must mark `action` as required"
627        );
628
629        let actions: Vec<&str> = schema
630            .pointer("/properties/action/enum")
631            .and_then(|v| v.as_array())
632            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
633            .unwrap_or_default();
634
635        for expected in [
636            "attach",
637            "continue",
638            "launch",
639            "pause",
640            "stack_trace",
641            "step_in",
642            "step_over",
643            "step_out",
644            "terminate",
645            "threads",
646            "variables",
647            "evaluate",
648            "scopes",
649            "set_breakpoint",
650            "remove_breakpoint",
651            "sessions",
652        ] {
653            assert!(
654                actions.contains(&expected),
655                "schema enum is missing `{expected}`"
656            );
657        }
658    }
659
660    #[test]
661    fn schema_adapters_match_supported_set() {
662        let schema = DebugTool.parameters_schema();
663        let adapters: Vec<&str> = schema
664            .pointer("/properties/adapter/enum")
665            .and_then(|v| v.as_array())
666            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
667            .unwrap_or_default();
668        for expected in ["gdb", "lldb-dap", "debugpy", "dlv"] {
669            assert!(
670                adapters.contains(&expected),
671                "adapter enum missing `{expected}`"
672            );
673        }
674    }
675
676    #[test]
677    fn schema_declares_dap_integer_properties() {
678        // The validator requires `thread_id` and `frame_id`, and the
679        // guidance text references `variable_ref` — all three must be
680        // declared in the schema so the model can send them.
681        let schema = DebugTool.parameters_schema();
682        for field in ["thread_id", "frame_id", "variable_ref"] {
683            let ty = schema
684                .pointer(&format!("/properties/{field}/type"))
685                .and_then(|v| v.as_str());
686            assert_eq!(
687                ty,
688                Some("number"),
689                "schema must declare `{field}` as a number"
690            );
691        }
692    }
693
694    #[test]
695    fn is_supported_action_matches_constant() {
696        for action in DebugTool::ACTIONS {
697            assert!(DebugTool::is_supported_action(action));
698        }
699        assert!(!DebugTool::is_supported_action("fly"));
700        assert!(!DebugTool::is_supported_action(""));
701    }
702
703    #[test]
704    fn intent_and_mode_are_pinned() {
705        assert_eq!(DebugTool.intent(), Some("Drive a debugger via DAP"));
706        assert!(matches!(
707            DebugTool.execution_mode(),
708            ToolExecutionMode::SequentialOnly
709        ));
710    }
711}