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