use crate::{AgentTool, ToolCallEventStream, ToolInput};
use agent_client_protocol::schema 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, including any operation-specific inputs.
pub operation: DebuggerOperation,
/// Optional session ID. If not provided, uses the active session.
#[serde(default)]
pub session_id: Option,
}
/// The debugger operation to perform.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DebuggerOperation {
/// Set or update a breakpoint at a specific file and line.
SetBreakpoint {
/// The path to the file.
path: String,
/// The 1-based line number.
line: u32,
/// Whether to enable or disable the breakpoint.
#[serde(default)]
enabled: Option,
/// Optional condition expression that must evaluate to true for the breakpoint to trigger.
#[serde(default)]
condition: Option,
/// Optional log message to output when the breakpoint is hit.
#[serde(default)]
log_message: Option,
/// Optional hit count condition.
#[serde(default)]
hit_condition: Option,
},
/// Remove a breakpoint at a specific file and line.
RemoveBreakpoint {
/// The path to the file.
path: String,
/// The 1-based line number.
line: u32,
},
/// List all breakpoints in the project.
ListBreakpoints,
/// List all active debug sessions.
ListSessions,
/// Continue execution of a paused thread.
Continue {
/// Optional thread ID. If not provided, uses a stopped thread.
#[serde(default)]
thread_id: Option,
},
/// Pause execution of a running thread.
Pause {
/// Optional thread ID. If not provided, uses a running thread.
#[serde(default)]
thread_id: Option,
},
/// Step over the current line (execute without entering functions).
StepOver {
/// Optional thread ID. If not provided, uses a stopped thread.
#[serde(default)]
thread_id: Option,
},
/// Step into the current line (enter function calls).
StepIn {
/// Optional thread ID. If not provided, uses a stopped thread.
#[serde(default)]
thread_id: Option,
},
/// Step out of the current function.
StepOut {
/// Optional thread ID. If not provided, uses a stopped thread.
#[serde(default)]
thread_id: Option,
},
/// Get the stack trace for a stopped thread.
GetStackTrace {
/// Optional thread ID. If not provided, uses an appropriate thread.
#[serde(default)]
thread_id: Option,
},
/// Get variables in the current scope.
GetVariables {
/// Optional thread ID. If not provided, uses a stopped thread.
#[serde(default)]
thread_id: Option,
/// Optional stack frame index (0 = top of stack).
#[serde(default)]
frame_index: Option,
},
}
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 {
let session_id = input.session_id;
match input.operation {
DebuggerOperation::SetBreakpoint {
path,
line,
enabled,
condition,
log_message,
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 { path, line } => 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 { thread_id } => cx.update(|cx| {
let session = Self::find_session(&project, session_id, cx)?;
let tid = Self::find_stopped_thread(&session, thread_id, cx)?;
session.update(cx, |session, cx| {
session.continue_thread(tid, cx);
});
Ok(format!("Continued execution of thread {}", tid.0))
}),
DebuggerOperation::Pause { thread_id } => cx.update(|cx| {
let session = Self::find_session(&project, session_id, cx)?;
let tid = Self::find_running_thread(&session, thread_id, cx)?;
session.update(cx, |session, cx| {
session.pause_thread(tid, cx);
});
Ok(format!("Paused thread {}", tid.0))
}),
DebuggerOperation::StepOver { thread_id } => cx.update(|cx| {
let session = Self::find_session(&project, session_id, cx)?;
let tid = Self::find_stopped_thread(&session, 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 { thread_id } => cx.update(|cx| {
let session = Self::find_session(&project, session_id, cx)?;
let tid = Self::find_stopped_thread(&session, 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 { thread_id } => cx.update(|cx| {
let session = Self::find_session(&project, session_id, cx)?;
let tid = Self::find_stopped_thread(&session, 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 { thread_id } => cx.update(|cx| {
let session = Self::find_session(&project, session_id, cx)?;
let tid = Self::find_any_thread(&session, 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 {
thread_id,
frame_index,
} => cx.update(|cx| {
let session = Self::find_session(&project, session_id, cx)?;
let tid = Self::find_stopped_thread(&session, thread_id, cx)?;
let frame_idx = 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 { path, line, .. } => {
format!("Set breakpoint at {}:{}", MarkdownInlineCode(&path), line).into()
}
DebuggerOperation::RemoveBreakpoint { path, line } => format!(
"Remove breakpoint at {}:{}",
MarkdownInlineCode(&path),
line
)
.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())
})
}
}