debugger_tool.rs

  1use crate::{AgentTool, ToolCallEventStream, ToolInput};
  2use agent_client_protocol as acp;
  3use anyhow::{Result, anyhow};
  4use dap::SteppingGranularity;
  5use dap::client::SessionId;
  6use gpui::{App, AsyncApp, Entity, SharedString, Task};
  7use project::Project;
  8use project::debugger::breakpoint_store::{
  9    Breakpoint, BreakpointEditAction, BreakpointState, BreakpointWithPosition,
 10};
 11use project::debugger::session::{Session, ThreadId, ThreadStatus};
 12use schemars::JsonSchema;
 13use serde::{Deserialize, Serialize};
 14use std::fmt::Write;
 15use std::sync::Arc;
 16use text::Point;
 17use util::markdown::MarkdownInlineCode;
 18
 19/// Interact with the debugger to control debug sessions, set breakpoints, and inspect program state.
 20///
 21/// This tool allows you to:
 22/// - Set and remove breakpoints at specific file locations
 23/// - List all breakpoints in the project
 24/// - List active debug sessions
 25/// - Control execution (continue, pause, step over, step in, step out)
 26/// - Inspect stack traces and variables when stopped at a breakpoint
 27///
 28/// <guidelines>
 29/// - Before using debugger controls (continue, pause, step), ensure there is an active debug session
 30/// - When setting breakpoints, use the exact file path as it appears in the project
 31/// - Stack traces and variables are only available when the debugger is stopped at a breakpoint
 32/// - Use `list_sessions` to see available debug sessions before trying to control them
 33/// </guidelines>
 34#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 35pub struct DebuggerToolInput {
 36    /// The debugger operation to perform
 37    pub operation: DebuggerOperation,
 38    /// The path to the file (required for set_breakpoint and remove_breakpoint operations)
 39    #[serde(default)]
 40    pub path: Option<String>,
 41    /// The 1-based line number (required for set_breakpoint and remove_breakpoint operations)
 42    #[serde(default)]
 43    pub line: Option<u32>,
 44    /// Whether to enable or disable the breakpoint (for set_breakpoint only)
 45    #[serde(default)]
 46    pub enabled: Option<bool>,
 47    /// Optional condition expression that must evaluate to true for the breakpoint to trigger (for set_breakpoint only)
 48    #[serde(default)]
 49    pub condition: Option<String>,
 50    /// Optional log message to output when the breakpoint is hit (for set_breakpoint only)
 51    #[serde(default)]
 52    pub log_message: Option<String>,
 53    /// Optional hit count condition (for set_breakpoint only)
 54    #[serde(default)]
 55    pub hit_condition: Option<String>,
 56    /// Optional session ID. If not provided, uses the active session.
 57    #[serde(default)]
 58    pub session_id: Option<u32>,
 59    /// Optional thread ID. If not provided, uses an appropriate thread based on the operation.
 60    #[serde(default)]
 61    pub thread_id: Option<i64>,
 62    /// Optional stack frame index (0 = top of stack). Used for get_variables operation.
 63    #[serde(default)]
 64    pub frame_index: Option<usize>,
 65}
 66
 67/// The debugger operation to perform
 68#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
 69#[serde(rename_all = "snake_case")]
 70pub enum DebuggerOperation {
 71    /// Set or update a breakpoint at a specific file and line
 72    SetBreakpoint,
 73    /// Remove a breakpoint at a specific file and line
 74    RemoveBreakpoint,
 75    /// List all breakpoints in the project
 76    ListBreakpoints,
 77    /// List all active debug sessions
 78    ListSessions,
 79    /// Continue execution of a paused thread
 80    Continue,
 81    /// Pause execution of a running thread
 82    Pause,
 83    /// Step over the current line (execute without entering functions)
 84    StepOver,
 85    /// Step into the current line (enter function calls)
 86    StepIn,
 87    /// Step out of the current function
 88    StepOut,
 89    /// Get the stack trace for a stopped thread
 90    GetStackTrace,
 91    /// Get variables in the current scope
 92    GetVariables,
 93}
 94
 95pub struct DebuggerTool {
 96    project: Entity<Project>,
 97}
 98
 99impl DebuggerTool {
100    pub fn new(project: Entity<Project>) -> Self {
101        Self { project }
102    }
103
104    fn find_session(
105        project: &Entity<Project>,
106        session_id: Option<u32>,
107        cx: &App,
108    ) -> Result<Entity<Session>> {
109        let dap_store = project.read(cx).dap_store();
110        let dap_store = dap_store.read(cx);
111
112        if let Some(id) = session_id {
113            dap_store
114                .session_by_id(SessionId(id))
115                .ok_or_else(|| anyhow!("No debug session found with ID {}", id))
116        } else {
117            if let Some((session, _)) = project.read(cx).active_debug_session(cx) {
118                return Ok(session);
119            }
120            dap_store
121                .sessions()
122                .next()
123                .cloned()
124                .ok_or_else(|| anyhow!("No active debug session. Start a debug session first."))
125        }
126    }
127
128    fn find_stopped_thread(
129        session: &Entity<Session>,
130        thread_id: Option<i64>,
131        cx: &mut App,
132    ) -> Result<ThreadId> {
133        session.update(cx, |session, cx| {
134            let threads = session.threads(cx);
135
136            if let Some(tid) = thread_id {
137                let thread_id = ThreadId::from(tid);
138                if threads
139                    .iter()
140                    .any(|(t, _)| ThreadId::from(t.id) == thread_id)
141                {
142                    return Ok(thread_id);
143                }
144                return Err(anyhow!("Thread {} not found", tid));
145            }
146
147            threads
148                .iter()
149                .find(|(_, status)| *status == ThreadStatus::Stopped)
150                .map(|(t, _)| ThreadId::from(t.id))
151                .ok_or_else(|| {
152                    anyhow!("No stopped thread found. The debugger must be paused at a breakpoint.")
153                })
154        })
155    }
156
157    fn find_running_thread(
158        session: &Entity<Session>,
159        thread_id: Option<i64>,
160        cx: &mut App,
161    ) -> Result<ThreadId> {
162        session.update(cx, |session, cx| {
163            let threads = session.threads(cx);
164
165            if let Some(tid) = thread_id {
166                let thread_id = ThreadId::from(tid);
167                if threads
168                    .iter()
169                    .any(|(t, _)| ThreadId::from(t.id) == thread_id)
170                {
171                    return Ok(thread_id);
172                }
173                return Err(anyhow!("Thread {} not found", tid));
174            }
175
176            threads
177                .iter()
178                .find(|(_, status)| *status == ThreadStatus::Running)
179                .map(|(t, _)| ThreadId::from(t.id))
180                .ok_or_else(|| anyhow!("No running thread found."))
181        })
182    }
183
184    fn find_any_thread(
185        session: &Entity<Session>,
186        thread_id: Option<i64>,
187        cx: &mut App,
188    ) -> Result<ThreadId> {
189        session.update(cx, |session, cx| {
190            let threads = session.threads(cx);
191
192            if let Some(tid) = thread_id {
193                let thread_id = ThreadId::from(tid);
194                if threads
195                    .iter()
196                    .any(|(t, _)| ThreadId::from(t.id) == thread_id)
197                {
198                    return Ok(thread_id);
199                }
200                return Err(anyhow!("Thread {} not found", tid));
201            }
202
203            threads
204                .iter()
205                .find(|(_, status)| *status == ThreadStatus::Stopped)
206                .or_else(|| threads.first())
207                .map(|(t, _)| ThreadId::from(t.id))
208                .ok_or_else(|| anyhow!("No threads found in the debug session."))
209        })
210    }
211
212    async fn run_operation(
213        project: Entity<Project>,
214        input: DebuggerToolInput,
215        cx: &mut AsyncApp,
216    ) -> Result<String> {
217        match input.operation {
218            DebuggerOperation::SetBreakpoint => {
219                let path = input
220                    .path
221                    .ok_or_else(|| anyhow!("path is required for set_breakpoint operation"))?;
222                let line = input
223                    .line
224                    .ok_or_else(|| anyhow!("line is required for set_breakpoint operation"))?;
225                let enabled = input.enabled;
226                let condition = input.condition;
227                let log_message = input.log_message;
228                let hit_condition = input.hit_condition;
229
230                let (buffer_task, breakpoint_store, abs_path): (_, _, _) = cx.update(|cx| {
231                    let project_path = project.read(cx).find_project_path(&path, cx);
232                    let Some(project_path) = project_path else {
233                        return Err(anyhow!("Could not find path {} in project", path));
234                    };
235
236                    let breakpoint_store = project.read(cx).breakpoint_store();
237                    let buffer_task = project.update(cx, |project, cx| {
238                        project.open_buffer(project_path.clone(), cx)
239                    });
240
241                    let worktree = project
242                        .read(cx)
243                        .worktree_for_id(project_path.worktree_id, cx);
244                    let abs_path = worktree.map(|wt| wt.read(cx).absolutize(&project_path.path));
245
246                    Ok((buffer_task, breakpoint_store, abs_path))
247                })?;
248
249                let buffer = buffer_task.await?;
250                let abs_path =
251                    abs_path.ok_or_else(|| anyhow!("Could not determine absolute path"))?;
252
253                Ok(cx.update(|cx| {
254                    let snapshot = buffer.read(cx).snapshot();
255                    let row = line.saturating_sub(1);
256                    let point = Point::new(row, 0);
257                    let position = snapshot.anchor_before(point);
258
259                    let state = match enabled {
260                        Some(true) | None => BreakpointState::Enabled,
261                        Some(false) => BreakpointState::Disabled,
262                    };
263
264                    let breakpoint = Breakpoint {
265                        message: log_message.map(|s| s.into()),
266                        hit_condition: hit_condition.map(|s| s.into()),
267                        condition: condition.map(|s| s.into()),
268                        state,
269                    };
270
271                    let breakpoint_with_position = BreakpointWithPosition {
272                        position,
273                        bp: breakpoint,
274                    };
275
276                    breakpoint_store.update(cx, |store, cx| {
277                        store.toggle_breakpoint(
278                            buffer,
279                            breakpoint_with_position,
280                            BreakpointEditAction::Toggle,
281                            cx,
282                        );
283                    });
284
285                    format!("Breakpoint set at {}:{}", abs_path.display(), line)
286                }))
287            }
288
289            DebuggerOperation::RemoveBreakpoint => {
290                let path = input
291                    .path
292                    .ok_or_else(|| anyhow!("path is required for remove_breakpoint operation"))?;
293                let line = input
294                    .line
295                    .ok_or_else(|| anyhow!("line is required for remove_breakpoint operation"))?;
296
297                cx.update(|cx| {
298                    let project = project.read(cx);
299                    let Some(project_path) = project.find_project_path(&path, cx) else {
300                        return Err(anyhow!("Could not find path {} in project", path));
301                    };
302
303                    let worktree = project
304                        .worktree_for_id(project_path.worktree_id, cx)
305                        .ok_or_else(|| anyhow!("Worktree not found"))?;
306                    let abs_path = worktree.read(cx).absolutize(&project_path.path);
307
308                    let breakpoint_store = project.breakpoint_store();
309                    let row = line.saturating_sub(1);
310
311                    let result = breakpoint_store
312                        .read(cx)
313                        .breakpoint_at_row(&abs_path, row, cx);
314
315                    if let Some((buffer, breakpoint)) = result {
316                        breakpoint_store.update(cx, |store, cx| {
317                            store.toggle_breakpoint(
318                                buffer,
319                                breakpoint,
320                                BreakpointEditAction::Toggle,
321                                cx,
322                            );
323                        });
324                        Ok(format!("Breakpoint removed at {}:{}", path, line))
325                    } else {
326                        Ok(format!("No breakpoint found at {}:{}", path, line))
327                    }
328                })
329            }
330            DebuggerOperation::ListBreakpoints => Ok(cx.update(|cx| {
331                let breakpoint_store = project.read(cx).breakpoint_store();
332                let breakpoints = breakpoint_store.read(cx).all_source_breakpoints(cx);
333
334                let mut output = String::new();
335                if breakpoints.is_empty() {
336                    output.push_str("No breakpoints set.");
337                } else {
338                    writeln!(output, "Breakpoints:").ok();
339                    for (path, bps) in &breakpoints {
340                        for bp in bps {
341                            let state = if bp.state.is_enabled() {
342                                "enabled"
343                            } else {
344                                "disabled"
345                            };
346                            let mut details = vec![state.to_string()];
347
348                            if let Some(ref cond) = bp.condition {
349                                details.push(format!("condition: {}", cond));
350                            }
351                            if let Some(ref msg) = bp.message {
352                                details.push(format!("log: {}", msg));
353                            }
354                            if let Some(ref hit) = bp.hit_condition {
355                                details.push(format!("hit: {}", hit));
356                            }
357
358                            writeln!(
359                                output,
360                                "  - {}:{} [{}]",
361                                path.display(),
362                                bp.row + 1,
363                                details.join(", ")
364                            )
365                            .ok();
366                        }
367                    }
368                }
369                output
370            })),
371
372            DebuggerOperation::ListSessions => Ok(cx.update(|cx| {
373                let dap_store = project.read(cx).dap_store();
374                let sessions: Vec<_> = dap_store.read(cx).sessions().cloned().collect();
375
376                let mut output = String::new();
377                if sessions.is_empty() {
378                    output.push_str("No active debug sessions.");
379                } else {
380                    writeln!(output, "Debug sessions:").ok();
381                    for session in sessions {
382                        let session_ref = session.read(cx);
383                        let session_id = session_ref.session_id();
384                        let adapter = session_ref.adapter();
385                        let label = session_ref.label();
386                        let is_terminated = session_ref.is_terminated();
387
388                        let status = if is_terminated {
389                            "terminated"
390                        } else if session_ref.is_building() {
391                            "building"
392                        } else {
393                            "running"
394                        };
395
396                        let label_str = label.as_ref().map(|l| l.as_ref()).unwrap_or("unnamed");
397                        writeln!(
398                            output,
399                            "  - Session {} ({}): {} [{}]",
400                            session_id.0, adapter, label_str, status
401                        )
402                        .ok();
403                    }
404                }
405                output
406            })),
407
408            DebuggerOperation::Continue => cx.update(|cx| {
409                let session = Self::find_session(&project, input.session_id, cx)?;
410                let tid = Self::find_stopped_thread(&session, input.thread_id, cx)?;
411
412                session.update(cx, |session, cx| {
413                    session.continue_thread(tid, cx);
414                });
415
416                Ok(format!("Continued execution of thread {}", tid.0))
417            }),
418
419            DebuggerOperation::Pause => cx.update(|cx| {
420                let session = Self::find_session(&project, input.session_id, cx)?;
421                let tid = Self::find_running_thread(&session, input.thread_id, cx)?;
422
423                session.update(cx, |session, cx| {
424                    session.pause_thread(tid, cx);
425                });
426
427                Ok(format!("Paused thread {}", tid.0))
428            }),
429
430            DebuggerOperation::StepOver => cx.update(|cx| {
431                let session = Self::find_session(&project, input.session_id, cx)?;
432                let tid = Self::find_stopped_thread(&session, input.thread_id, cx)?;
433
434                session.update(cx, |session, cx| {
435                    session.step_over(tid, SteppingGranularity::Line, cx);
436                });
437
438                Ok(format!("Stepped over on thread {}", tid.0))
439            }),
440
441            DebuggerOperation::StepIn => cx.update(|cx| {
442                let session = Self::find_session(&project, input.session_id, cx)?;
443                let tid = Self::find_stopped_thread(&session, input.thread_id, cx)?;
444
445                session.update(cx, |session, cx| {
446                    session.step_in(tid, SteppingGranularity::Line, cx);
447                });
448
449                Ok(format!("Stepped into on thread {}", tid.0))
450            }),
451
452            DebuggerOperation::StepOut => cx.update(|cx| {
453                let session = Self::find_session(&project, input.session_id, cx)?;
454                let tid = Self::find_stopped_thread(&session, input.thread_id, cx)?;
455
456                session.update(cx, |session, cx| {
457                    session.step_out(tid, SteppingGranularity::Line, cx);
458                });
459
460                Ok(format!("Stepped out on thread {}", tid.0))
461            }),
462
463            DebuggerOperation::GetStackTrace => cx.update(|cx| {
464                let session = Self::find_session(&project, input.session_id, cx)?;
465                let tid = Self::find_any_thread(&session, input.thread_id, cx)?;
466
467                let frames = session.update(cx, |session, cx| session.stack_frames(tid, cx))?;
468
469                let mut output = String::new();
470                if frames.is_empty() {
471                    output.push_str("No stack frames available. The thread may be running.");
472                } else {
473                    writeln!(output, "Stack trace for thread {}:", tid.0).ok();
474                    for (i, frame) in frames.iter().enumerate() {
475                        let location = frame
476                            .dap
477                            .source
478                            .as_ref()
479                            .and_then(|s| s.path.as_ref())
480                            .map(|p| format!("{}:{}", p, frame.dap.line))
481                            .unwrap_or_else(|| "unknown".to_string());
482
483                        writeln!(output, "  #{} {} at {}", i, frame.dap.name, location).ok();
484                    }
485                }
486                Ok(output)
487            }),
488
489            DebuggerOperation::GetVariables => cx.update(|cx| {
490                let session = Self::find_session(&project, input.session_id, cx)?;
491                let tid = Self::find_stopped_thread(&session, input.thread_id, cx)?;
492                let frame_idx = input.frame_index.unwrap_or(0);
493
494                session.update(cx, |session, cx| {
495                    let frames = session.stack_frames(tid, cx)?;
496
497                    let frame = frames.get(frame_idx).ok_or_else(|| {
498                        anyhow!(
499                            "Stack frame index {} out of range (0-{})",
500                            frame_idx,
501                            frames.len().saturating_sub(1)
502                        )
503                    })?;
504
505                    let frame_id = frame.dap.id;
506                    let frame_name = frame.dap.name.clone();
507
508                    let scopes: Vec<_> = session.scopes(frame_id, cx).to_vec();
509
510                    let mut output = String::new();
511                    if scopes.is_empty() {
512                        output.push_str("No variables available in the current scope.");
513                    } else {
514                        writeln!(
515                            output,
516                            "Variables in frame #{} ({}):",
517                            frame_idx, frame_name
518                        )
519                        .ok();
520
521                        for scope in &scopes {
522                            writeln!(output, "\n  {}:", scope.name).ok();
523
524                            let variables = session.variables(scope.variables_reference.into(), cx);
525                            for var in variables {
526                                let type_info = var
527                                    .type_
528                                    .as_ref()
529                                    .map(|t| format!(" ({})", t))
530                                    .unwrap_or_default();
531
532                                writeln!(output, "    {} = {}{}", var.name, var.value, type_info)
533                                    .ok();
534                            }
535                        }
536                    }
537
538                    Ok(output)
539                })
540            }),
541        }
542    }
543}
544
545impl AgentTool for DebuggerTool {
546    type Input = DebuggerToolInput;
547    type Output = String;
548
549    const NAME: &'static str = "debugger";
550
551    fn kind() -> acp::ToolKind {
552        acp::ToolKind::Execute
553    }
554
555    fn initial_title(
556        &self,
557        input: Result<Self::Input, serde_json::Value>,
558        _cx: &mut App,
559    ) -> SharedString {
560        match input {
561            Ok(input) => match input.operation {
562                DebuggerOperation::SetBreakpoint => {
563                    if let (Some(path), Some(line)) = (&input.path, input.line) {
564                        format!("Set breakpoint at {}:{}", MarkdownInlineCode(path), line).into()
565                    } else {
566                        "Set breakpoint".into()
567                    }
568                }
569                DebuggerOperation::RemoveBreakpoint => {
570                    if let (Some(path), Some(line)) = (&input.path, input.line) {
571                        format!("Remove breakpoint at {}:{}", MarkdownInlineCode(path), line).into()
572                    } else {
573                        "Remove breakpoint".into()
574                    }
575                }
576                DebuggerOperation::ListBreakpoints => "List breakpoints".into(),
577                DebuggerOperation::ListSessions => "List debug sessions".into(),
578                DebuggerOperation::Continue => "Continue execution".into(),
579                DebuggerOperation::Pause => "Pause execution".into(),
580                DebuggerOperation::StepOver => "Step over".into(),
581                DebuggerOperation::StepIn => "Step into".into(),
582                DebuggerOperation::StepOut => "Step out".into(),
583                DebuggerOperation::GetStackTrace => "Get stack trace".into(),
584                DebuggerOperation::GetVariables => "Get variables".into(),
585            },
586            Err(_) => "Debugger operation".into(),
587        }
588    }
589
590    fn run(
591        self: Arc<Self>,
592        input: ToolInput<Self::Input>,
593        _event_stream: ToolCallEventStream,
594        cx: &mut App,
595    ) -> Task<Result<Self::Output, Self::Output>> {
596        let project = self.project.clone();
597        cx.spawn(async move |cx| {
598            let input = input
599                .recv()
600                .await
601                .map_err(|e| format!("Failed to receive tool input: {e}"))?;
602            Self::run_operation(project, input, cx)
603                .await
604                .map_err(|e| e.to_string())
605        })
606    }
607}