1use async_trait::async_trait;
21use serde_json::{Value, json};
22use tokio::sync::oneshot;
23
24use super::{AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode};
25
26pub struct DebugTool;
33
34impl DebugTool {
35 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 pub fn is_supported_action(action: &str) -> bool {
61 Self::ACTIONS.contains(&action)
62 }
63
64 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 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 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 validate_action_params(action, ¶ms)?;
258
259 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 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
338fn 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 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 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 }
392 _ => {}
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 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 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 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}