Cargo.lock 🔗
@@ -165,6 +165,7 @@ dependencies = [
"collections",
"context_server",
"ctor",
+ "dap",
"db",
"derive_more",
"editor",
Anthony Eid created
Cargo.lock | 1
crates/agent/Cargo.toml | 1
crates/agent/src/thread.rs | 3
crates/agent/src/tools.rs | 3
crates/agent/src/tools/debugger_tool.rs | 607 +++++++++++++++++++++++++++
5 files changed, 614 insertions(+), 1 deletion(-)
@@ -165,6 +165,7 @@ dependencies = [
"collections",
"context_server",
"ctor",
+ "dap",
"db",
"derive_more",
"editor",
@@ -30,6 +30,7 @@ cloud_api_types.workspace = true
cloud_llm_client.workspace = true
collections.workspace = true
context_server.workspace = true
+dap.workspace = true
db.workspace = true
derive_more.workspace = true
feature_flags.workspace = true
@@ -1,6 +1,6 @@
use crate::{
ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread,
- DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool,
+ DebuggerTool, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool,
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool,
RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, StreamingEditFileTool,
SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, WebSearchTool,
@@ -1458,6 +1458,7 @@ impl Thread {
let language_registry = self.project.read(cx).languages().clone();
self.add_tool(CopyPathTool::new(self.project.clone()));
+ self.add_tool(DebuggerTool::new(self.project.clone()));
self.add_tool(CreateDirectoryTool::new(self.project.clone()));
self.add_tool(DeletePathTool::new(
self.project.clone(),
@@ -1,6 +1,7 @@
mod context_server_registry;
mod copy_path_tool;
mod create_directory_tool;
+mod debugger_tool;
mod delete_path_tool;
mod diagnostics_tool;
mod edit_file_tool;
@@ -27,6 +28,7 @@ use language_model::{LanguageModelRequestTool, LanguageModelToolSchemaFormat};
pub use context_server_registry::*;
pub use copy_path_tool::*;
pub use create_directory_tool::*;
+pub use debugger_tool::*;
pub use delete_path_tool::*;
pub use diagnostics_tool::*;
pub use edit_file_tool::*;
@@ -117,6 +119,7 @@ macro_rules! tools {
tools! {
CopyPathTool,
CreateDirectoryTool,
+ DebuggerTool,
DeletePathTool,
DiagnosticsTool,
EditFileTool,
@@ -0,0 +1,607 @@
+use crate::{AgentTool, ToolCallEventStream, ToolInput};
+use agent_client_protocol as acp;
+use anyhow::{Result, anyhow};
+use dap::SteppingGranularity;
+use dap::client::SessionId;
+use gpui::{App, AsyncApp, Entity, SharedString, Task};
+use project::Project;
+use project::debugger::breakpoint_store::{
+ Breakpoint, BreakpointEditAction, BreakpointState, BreakpointWithPosition,
+};
+use project::debugger::session::{Session, ThreadId, ThreadStatus};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::fmt::Write;
+use std::sync::Arc;
+use text::Point;
+use util::markdown::MarkdownInlineCode;
+
+/// Interact with the debugger to control debug sessions, set breakpoints, and inspect program state.
+///
+/// This tool allows you to:
+/// - Set and remove breakpoints at specific file locations
+/// - List all breakpoints in the project
+/// - List active debug sessions
+/// - Control execution (continue, pause, step over, step in, step out)
+/// - Inspect stack traces and variables when stopped at a breakpoint
+///
+/// <guidelines>
+/// - Before using debugger controls (continue, pause, step), ensure there is an active debug session
+/// - When setting breakpoints, use the exact file path as it appears in the project
+/// - Stack traces and variables are only available when the debugger is stopped at a breakpoint
+/// - Use `list_sessions` to see available debug sessions before trying to control them
+/// </guidelines>
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct DebuggerToolInput {
+ /// The debugger operation to perform
+ pub operation: DebuggerOperation,
+ /// The path to the file (required for set_breakpoint and remove_breakpoint operations)
+ #[serde(default)]
+ pub path: Option<String>,
+ /// The 1-based line number (required for set_breakpoint and remove_breakpoint operations)
+ #[serde(default)]
+ pub line: Option<u32>,
+ /// Whether to enable or disable the breakpoint (for set_breakpoint only)
+ #[serde(default)]
+ pub enabled: Option<bool>,
+ /// Optional condition expression that must evaluate to true for the breakpoint to trigger (for set_breakpoint only)
+ #[serde(default)]
+ pub condition: Option<String>,
+ /// Optional log message to output when the breakpoint is hit (for set_breakpoint only)
+ #[serde(default)]
+ pub log_message: Option<String>,
+ /// Optional hit count condition (for set_breakpoint only)
+ #[serde(default)]
+ pub hit_condition: Option<String>,
+ /// Optional session ID. If not provided, uses the active session.
+ #[serde(default)]
+ pub session_id: Option<u32>,
+ /// Optional thread ID. If not provided, uses an appropriate thread based on the operation.
+ #[serde(default)]
+ pub thread_id: Option<i64>,
+ /// Optional stack frame index (0 = top of stack). Used for get_variables operation.
+ #[serde(default)]
+ pub frame_index: Option<usize>,
+}
+
+/// The debugger operation to perform
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum DebuggerOperation {
+ /// Set or update a breakpoint at a specific file and line
+ SetBreakpoint,
+ /// Remove a breakpoint at a specific file and line
+ RemoveBreakpoint,
+ /// List all breakpoints in the project
+ ListBreakpoints,
+ /// List all active debug sessions
+ ListSessions,
+ /// Continue execution of a paused thread
+ Continue,
+ /// Pause execution of a running thread
+ Pause,
+ /// Step over the current line (execute without entering functions)
+ StepOver,
+ /// Step into the current line (enter function calls)
+ StepIn,
+ /// Step out of the current function
+ StepOut,
+ /// Get the stack trace for a stopped thread
+ GetStackTrace,
+ /// Get variables in the current scope
+ GetVariables,
+}
+
+pub struct DebuggerTool {
+ project: Entity<Project>,
+}
+
+impl DebuggerTool {
+ pub fn new(project: Entity<Project>) -> Self {
+ Self { project }
+ }
+
+ fn find_session(
+ project: &Entity<Project>,
+ session_id: Option<u32>,
+ cx: &App,
+ ) -> Result<Entity<Session>> {
+ let dap_store = project.read(cx).dap_store();
+ let dap_store = dap_store.read(cx);
+
+ if let Some(id) = session_id {
+ dap_store
+ .session_by_id(SessionId(id))
+ .ok_or_else(|| anyhow!("No debug session found with ID {}", id))
+ } else {
+ if let Some((session, _)) = project.read(cx).active_debug_session(cx) {
+ return Ok(session);
+ }
+ dap_store
+ .sessions()
+ .next()
+ .cloned()
+ .ok_or_else(|| anyhow!("No active debug session. Start a debug session first."))
+ }
+ }
+
+ fn find_stopped_thread(
+ session: &Entity<Session>,
+ thread_id: Option<i64>,
+ cx: &mut App,
+ ) -> Result<ThreadId> {
+ session.update(cx, |session, cx| {
+ let threads = session.threads(cx);
+
+ if let Some(tid) = thread_id {
+ let thread_id = ThreadId::from(tid);
+ if threads
+ .iter()
+ .any(|(t, _)| ThreadId::from(t.id) == thread_id)
+ {
+ return Ok(thread_id);
+ }
+ return Err(anyhow!("Thread {} not found", tid));
+ }
+
+ threads
+ .iter()
+ .find(|(_, status)| *status == ThreadStatus::Stopped)
+ .map(|(t, _)| ThreadId::from(t.id))
+ .ok_or_else(|| {
+ anyhow!("No stopped thread found. The debugger must be paused at a breakpoint.")
+ })
+ })
+ }
+
+ fn find_running_thread(
+ session: &Entity<Session>,
+ thread_id: Option<i64>,
+ cx: &mut App,
+ ) -> Result<ThreadId> {
+ session.update(cx, |session, cx| {
+ let threads = session.threads(cx);
+
+ if let Some(tid) = thread_id {
+ let thread_id = ThreadId::from(tid);
+ if threads
+ .iter()
+ .any(|(t, _)| ThreadId::from(t.id) == thread_id)
+ {
+ return Ok(thread_id);
+ }
+ return Err(anyhow!("Thread {} not found", tid));
+ }
+
+ threads
+ .iter()
+ .find(|(_, status)| *status == ThreadStatus::Running)
+ .map(|(t, _)| ThreadId::from(t.id))
+ .ok_or_else(|| anyhow!("No running thread found."))
+ })
+ }
+
+ fn find_any_thread(
+ session: &Entity<Session>,
+ thread_id: Option<i64>,
+ cx: &mut App,
+ ) -> Result<ThreadId> {
+ session.update(cx, |session, cx| {
+ let threads = session.threads(cx);
+
+ if let Some(tid) = thread_id {
+ let thread_id = ThreadId::from(tid);
+ if threads
+ .iter()
+ .any(|(t, _)| ThreadId::from(t.id) == thread_id)
+ {
+ return Ok(thread_id);
+ }
+ return Err(anyhow!("Thread {} not found", tid));
+ }
+
+ threads
+ .iter()
+ .find(|(_, status)| *status == ThreadStatus::Stopped)
+ .or_else(|| threads.first())
+ .map(|(t, _)| ThreadId::from(t.id))
+ .ok_or_else(|| anyhow!("No threads found in the debug session."))
+ })
+ }
+
+ async fn run_operation(
+ project: Entity<Project>,
+ input: DebuggerToolInput,
+ cx: &mut AsyncApp,
+ ) -> Result<String> {
+ match input.operation {
+ DebuggerOperation::SetBreakpoint => {
+ let path = input
+ .path
+ .ok_or_else(|| anyhow!("path is required for set_breakpoint operation"))?;
+ let line = input
+ .line
+ .ok_or_else(|| anyhow!("line is required for set_breakpoint operation"))?;
+ let enabled = input.enabled;
+ let condition = input.condition;
+ let log_message = input.log_message;
+ let hit_condition = input.hit_condition;
+
+ let (buffer_task, breakpoint_store, abs_path): (_, _, _) = cx.update(|cx| {
+ let project_path = project.read(cx).find_project_path(&path, cx);
+ let Some(project_path) = project_path else {
+ return Err(anyhow!("Could not find path {} in project", path));
+ };
+
+ let breakpoint_store = project.read(cx).breakpoint_store();
+ let buffer_task = project.update(cx, |project, cx| {
+ project.open_buffer(project_path.clone(), cx)
+ });
+
+ let worktree = project
+ .read(cx)
+ .worktree_for_id(project_path.worktree_id, cx);
+ let abs_path = worktree.map(|wt| wt.read(cx).absolutize(&project_path.path));
+
+ Ok((buffer_task, breakpoint_store, abs_path))
+ })?;
+
+ let buffer = buffer_task.await?;
+ let abs_path =
+ abs_path.ok_or_else(|| anyhow!("Could not determine absolute path"))?;
+
+ Ok(cx.update(|cx| {
+ let snapshot = buffer.read(cx).snapshot();
+ let row = line.saturating_sub(1);
+ let point = Point::new(row, 0);
+ let position = snapshot.anchor_before(point);
+
+ let state = match enabled {
+ Some(true) | None => BreakpointState::Enabled,
+ Some(false) => BreakpointState::Disabled,
+ };
+
+ let breakpoint = Breakpoint {
+ message: log_message.map(|s| s.into()),
+ hit_condition: hit_condition.map(|s| s.into()),
+ condition: condition.map(|s| s.into()),
+ state,
+ };
+
+ let breakpoint_with_position = BreakpointWithPosition {
+ position,
+ bp: breakpoint,
+ };
+
+ breakpoint_store.update(cx, |store, cx| {
+ store.toggle_breakpoint(
+ buffer,
+ breakpoint_with_position,
+ BreakpointEditAction::Toggle,
+ cx,
+ );
+ });
+
+ format!("Breakpoint set at {}:{}", abs_path.display(), line)
+ }))
+ }
+
+ DebuggerOperation::RemoveBreakpoint => {
+ let path = input
+ .path
+ .ok_or_else(|| anyhow!("path is required for remove_breakpoint operation"))?;
+ let line = input
+ .line
+ .ok_or_else(|| anyhow!("line is required for remove_breakpoint operation"))?;
+
+ cx.update(|cx| {
+ let project = project.read(cx);
+ let Some(project_path) = project.find_project_path(&path, cx) else {
+ return Err(anyhow!("Could not find path {} in project", path));
+ };
+
+ let worktree = project
+ .worktree_for_id(project_path.worktree_id, cx)
+ .ok_or_else(|| anyhow!("Worktree not found"))?;
+ let abs_path = worktree.read(cx).absolutize(&project_path.path);
+
+ let breakpoint_store = project.breakpoint_store();
+ let row = line.saturating_sub(1);
+
+ let result = breakpoint_store
+ .read(cx)
+ .breakpoint_at_row(&abs_path, row, cx);
+
+ if let Some((buffer, breakpoint)) = result {
+ breakpoint_store.update(cx, |store, cx| {
+ store.toggle_breakpoint(
+ buffer,
+ breakpoint,
+ BreakpointEditAction::Toggle,
+ cx,
+ );
+ });
+ Ok(format!("Breakpoint removed at {}:{}", path, line))
+ } else {
+ Ok(format!("No breakpoint found at {}:{}", path, line))
+ }
+ })
+ }
+ DebuggerOperation::ListBreakpoints => Ok(cx.update(|cx| {
+ let breakpoint_store = project.read(cx).breakpoint_store();
+ let breakpoints = breakpoint_store.read(cx).all_source_breakpoints(cx);
+
+ let mut output = String::new();
+ if breakpoints.is_empty() {
+ output.push_str("No breakpoints set.");
+ } else {
+ writeln!(output, "Breakpoints:").ok();
+ for (path, bps) in &breakpoints {
+ for bp in bps {
+ let state = if bp.state.is_enabled() {
+ "enabled"
+ } else {
+ "disabled"
+ };
+ let mut details = vec![state.to_string()];
+
+ if let Some(ref cond) = bp.condition {
+ details.push(format!("condition: {}", cond));
+ }
+ if let Some(ref msg) = bp.message {
+ details.push(format!("log: {}", msg));
+ }
+ if let Some(ref hit) = bp.hit_condition {
+ details.push(format!("hit: {}", hit));
+ }
+
+ writeln!(
+ output,
+ " - {}:{} [{}]",
+ path.display(),
+ bp.row + 1,
+ details.join(", ")
+ )
+ .ok();
+ }
+ }
+ }
+ output
+ })),
+
+ DebuggerOperation::ListSessions => Ok(cx.update(|cx| {
+ let dap_store = project.read(cx).dap_store();
+ let sessions: Vec<_> = dap_store.read(cx).sessions().cloned().collect();
+
+ let mut output = String::new();
+ if sessions.is_empty() {
+ output.push_str("No active debug sessions.");
+ } else {
+ writeln!(output, "Debug sessions:").ok();
+ for session in sessions {
+ let session_ref = session.read(cx);
+ let session_id = session_ref.session_id();
+ let adapter = session_ref.adapter();
+ let label = session_ref.label();
+ let is_terminated = session_ref.is_terminated();
+
+ let status = if is_terminated {
+ "terminated"
+ } else if session_ref.is_building() {
+ "building"
+ } else {
+ "running"
+ };
+
+ let label_str = label.as_ref().map(|l| l.as_ref()).unwrap_or("unnamed");
+ writeln!(
+ output,
+ " - Session {} ({}): {} [{}]",
+ session_id.0, adapter, label_str, status
+ )
+ .ok();
+ }
+ }
+ output
+ })),
+
+ DebuggerOperation::Continue => cx.update(|cx| {
+ let session = Self::find_session(&project, input.session_id, cx)?;
+ let tid = Self::find_stopped_thread(&session, input.thread_id, cx)?;
+
+ session.update(cx, |session, cx| {
+ session.continue_thread(tid, cx);
+ });
+
+ Ok(format!("Continued execution of thread {}", tid.0))
+ }),
+
+ DebuggerOperation::Pause => cx.update(|cx| {
+ let session = Self::find_session(&project, input.session_id, cx)?;
+ let tid = Self::find_running_thread(&session, input.thread_id, cx)?;
+
+ session.update(cx, |session, cx| {
+ session.pause_thread(tid, cx);
+ });
+
+ Ok(format!("Paused thread {}", tid.0))
+ }),
+
+ DebuggerOperation::StepOver => cx.update(|cx| {
+ let session = Self::find_session(&project, input.session_id, cx)?;
+ let tid = Self::find_stopped_thread(&session, input.thread_id, cx)?;
+
+ session.update(cx, |session, cx| {
+ session.step_over(tid, SteppingGranularity::Line, cx);
+ });
+
+ Ok(format!("Stepped over on thread {}", tid.0))
+ }),
+
+ DebuggerOperation::StepIn => cx.update(|cx| {
+ let session = Self::find_session(&project, input.session_id, cx)?;
+ let tid = Self::find_stopped_thread(&session, input.thread_id, cx)?;
+
+ session.update(cx, |session, cx| {
+ session.step_in(tid, SteppingGranularity::Line, cx);
+ });
+
+ Ok(format!("Stepped into on thread {}", tid.0))
+ }),
+
+ DebuggerOperation::StepOut => cx.update(|cx| {
+ let session = Self::find_session(&project, input.session_id, cx)?;
+ let tid = Self::find_stopped_thread(&session, input.thread_id, cx)?;
+
+ session.update(cx, |session, cx| {
+ session.step_out(tid, SteppingGranularity::Line, cx);
+ });
+
+ Ok(format!("Stepped out on thread {}", tid.0))
+ }),
+
+ DebuggerOperation::GetStackTrace => cx.update(|cx| {
+ let session = Self::find_session(&project, input.session_id, cx)?;
+ let tid = Self::find_any_thread(&session, input.thread_id, cx)?;
+
+ let frames = session.update(cx, |session, cx| session.stack_frames(tid, cx))?;
+
+ let mut output = String::new();
+ if frames.is_empty() {
+ output.push_str("No stack frames available. The thread may be running.");
+ } else {
+ writeln!(output, "Stack trace for thread {}:", tid.0).ok();
+ for (i, frame) in frames.iter().enumerate() {
+ let location = frame
+ .dap
+ .source
+ .as_ref()
+ .and_then(|s| s.path.as_ref())
+ .map(|p| format!("{}:{}", p, frame.dap.line))
+ .unwrap_or_else(|| "unknown".to_string());
+
+ writeln!(output, " #{} {} at {}", i, frame.dap.name, location).ok();
+ }
+ }
+ Ok(output)
+ }),
+
+ DebuggerOperation::GetVariables => cx.update(|cx| {
+ let session = Self::find_session(&project, input.session_id, cx)?;
+ let tid = Self::find_stopped_thread(&session, input.thread_id, cx)?;
+ let frame_idx = input.frame_index.unwrap_or(0);
+
+ session.update(cx, |session, cx| {
+ let frames = session.stack_frames(tid, cx)?;
+
+ let frame = frames.get(frame_idx).ok_or_else(|| {
+ anyhow!(
+ "Stack frame index {} out of range (0-{})",
+ frame_idx,
+ frames.len().saturating_sub(1)
+ )
+ })?;
+
+ let frame_id = frame.dap.id;
+ let frame_name = frame.dap.name.clone();
+
+ let scopes: Vec<_> = session.scopes(frame_id, cx).to_vec();
+
+ let mut output = String::new();
+ if scopes.is_empty() {
+ output.push_str("No variables available in the current scope.");
+ } else {
+ writeln!(
+ output,
+ "Variables in frame #{} ({}):",
+ frame_idx, frame_name
+ )
+ .ok();
+
+ for scope in &scopes {
+ writeln!(output, "\n {}:", scope.name).ok();
+
+ let variables = session.variables(scope.variables_reference.into(), cx);
+ for var in variables {
+ let type_info = var
+ .type_
+ .as_ref()
+ .map(|t| format!(" ({})", t))
+ .unwrap_or_default();
+
+ writeln!(output, " {} = {}{}", var.name, var.value, type_info)
+ .ok();
+ }
+ }
+ }
+
+ Ok(output)
+ })
+ }),
+ }
+ }
+}
+
+impl AgentTool for DebuggerTool {
+ type Input = DebuggerToolInput;
+ type Output = String;
+
+ const NAME: &'static str = "debugger";
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Execute
+ }
+
+ fn initial_title(
+ &self,
+ input: Result<Self::Input, serde_json::Value>,
+ _cx: &mut App,
+ ) -> SharedString {
+ match input {
+ Ok(input) => match input.operation {
+ DebuggerOperation::SetBreakpoint => {
+ if let (Some(path), Some(line)) = (&input.path, input.line) {
+ format!("Set breakpoint at {}:{}", MarkdownInlineCode(path), line).into()
+ } else {
+ "Set breakpoint".into()
+ }
+ }
+ DebuggerOperation::RemoveBreakpoint => {
+ if let (Some(path), Some(line)) = (&input.path, input.line) {
+ format!("Remove breakpoint at {}:{}", MarkdownInlineCode(path), line).into()
+ } else {
+ "Remove breakpoint".into()
+ }
+ }
+ DebuggerOperation::ListBreakpoints => "List breakpoints".into(),
+ DebuggerOperation::ListSessions => "List debug sessions".into(),
+ DebuggerOperation::Continue => "Continue execution".into(),
+ DebuggerOperation::Pause => "Pause execution".into(),
+ DebuggerOperation::StepOver => "Step over".into(),
+ DebuggerOperation::StepIn => "Step into".into(),
+ DebuggerOperation::StepOut => "Step out".into(),
+ DebuggerOperation::GetStackTrace => "Get stack trace".into(),
+ DebuggerOperation::GetVariables => "Get variables".into(),
+ },
+ Err(_) => "Debugger operation".into(),
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: ToolInput<Self::Input>,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output, Self::Output>> {
+ let project = self.project.clone();
+ cx.spawn(async move |cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+ Self::run_operation(project, input, cx)
+ .await
+ .map_err(|e| e.to_string())
+ })
+ }
+}