diff --git a/Cargo.lock b/Cargo.lock index 6570398f5b22f2248a9cd59f84d2cf70080c3591..b8a82ff9ac42509fb9ed3c05f1fae62bf4498e5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,6 +165,7 @@ dependencies = [ "collections", "context_server", "ctor", + "dap", "db", "derive_more", "editor", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index fe2089d94dc2e3fc812f6cbe39c16c5cadc1a1f5..5c51a8208358660b0f343945cb892d8de929eb1c 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -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 diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 02ffac47f120ee3ec4694b3a3be085af053c5909..41f6205170e1c8b61cde3f24f441c1635569a795 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -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(), diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 446472e0c459aa15fa57bb8b49178b08e6781d11..cbadc9700881360e44524e947cc942cb284dad3e 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -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, diff --git a/crates/agent/src/tools/debugger_tool.rs b/crates/agent/src/tools/debugger_tool.rs new file mode 100644 index 0000000000000000000000000000000000000000..11c894ff79583a3fed9a2c25c3652d3f1aa6f806 --- /dev/null +++ b/crates/agent/src/tools/debugger_tool.rs @@ -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 +/// +/// +/// - 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 +/// +#[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, + /// The 1-based line number (required for set_breakpoint and remove_breakpoint operations) + #[serde(default)] + pub line: Option, + /// Whether to enable or disable the breakpoint (for set_breakpoint only) + #[serde(default)] + pub enabled: Option, + /// Optional condition expression that must evaluate to true for the breakpoint to trigger (for set_breakpoint only) + #[serde(default)] + pub condition: Option, + /// Optional log message to output when the breakpoint is hit (for set_breakpoint only) + #[serde(default)] + pub log_message: Option, + /// Optional hit count condition (for set_breakpoint only) + #[serde(default)] + pub hit_condition: Option, + /// Optional session ID. If not provided, uses the active session. + #[serde(default)] + pub session_id: Option, + /// Optional thread ID. If not provided, uses an appropriate thread based on the operation. + #[serde(default)] + pub thread_id: Option, + /// Optional stack frame index (0 = top of stack). Used for get_variables operation. + #[serde(default)] + pub frame_index: Option, +} + +/// 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, +} + +impl DebuggerTool { + pub fn new(project: Entity) -> Self { + Self { project } + } + + fn find_session( + project: &Entity, + session_id: Option, + cx: &App, + ) -> Result> { + 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, + thread_id: Option, + cx: &mut App, + ) -> Result { + 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, + thread_id: Option, + cx: &mut App, + ) -> Result { + 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, + thread_id: Option, + cx: &mut App, + ) -> Result { + 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, + input: DebuggerToolInput, + cx: &mut AsyncApp, + ) -> Result { + 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, + _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, + input: ToolInput, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + 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()) + }) + } +}