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 _ => 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 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 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 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}