1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
use harn_vm::{DebugState, Vm, VmValue};
use crate::host_bridge::DapHostBridge;
/// Execution state for stepping.
#[derive(Debug, Clone, PartialEq)]
pub enum StepMode {
/// Run until a breakpoint or end.
Continue,
/// Stop at the next line.
StepOver,
/// Stop at the next statement (step into functions).
StepIn,
/// Run until returning from the current function.
StepOut,
}
/// Program state.
pub(crate) enum ProgramState {
/// Not yet started.
NotStarted,
/// Running (VM is initialized).
Running,
/// Stopped at a debug point.
Stopped,
/// Program has terminated.
Terminated,
}
/// A segment in an expression path for evaluation.
pub(crate) enum PathSegment {
Field(String),
Index(i64),
}
/// The debug adapter implementation.
pub struct Debugger {
pub(crate) seq: i64,
pub(crate) source_path: Option<String>,
pub(crate) source_content: Option<String>,
pub(crate) breakpoints: Vec<crate::protocol::Breakpoint>,
pub(crate) next_bp_id: i64,
pub(crate) vm: Option<Vm>,
/// Variables captured at the current stop point.
pub(crate) variables: BTreeMap<String, VmValue>,
/// Current execution state.
pub(crate) stopped: bool,
/// Current line in the source.
pub(crate) current_line: i64,
/// Step mode.
pub(crate) step_mode: StepMode,
/// Output captured during execution.
pub(crate) output: String,
/// Program state.
pub(crate) program_state: ProgramState,
/// Structured variable references: reference_id -> children
pub(crate) var_refs: BTreeMap<i64, Vec<(String, VmValue)>>,
/// Tokio runtime for async VM execution.
pub(crate) runtime: tokio::runtime::Runtime,
/// Next variable reference ID (start at 100 to avoid conflict with scope refs).
pub(crate) next_var_ref: i64,
/// Whether to break on thrown exceptions.
pub(crate) break_on_exceptions: bool,
/// Active per-kind exception-filter set (#111). Populated by
/// setExceptionBreakpoints; consulted when the agent loop emits
/// a typed exception (tool_error, llm_refusal, budget_exceeded,
/// parse_failure). Optional per-filter condition lives alongside
/// so a filter can gate on e.g. `err.kind == "disk_full"`.
pub(crate) exception_filters: BTreeMap<String, Option<String>>,
/// Latest VM debug snapshot captured through the VM debug hook.
pub(crate) latest_debug_state: Rc<RefCell<Option<DebugState>>>,
/// Optional bridge that round-trips unhandled `host_call` ops to the
/// DAP client as reverse requests. When `None`, scripts only see the
/// harn-vm fallbacks.
pub(crate) host_bridge: Option<DapHostBridge>,
/// True when the VM is in a "should keep stepping" state (after
/// continue/next/stepIn/stepOut/configurationDone). The main loop
/// drives one VM step per iteration while this is set, draining any
/// pending DAP messages between steps so pause/disconnect/etc. get
/// serviced mid-run instead of being starved.
pub(crate) running: bool,
/// Snapshotted breakpoint conditions for the active run. Refreshed
/// each time we transition idle->running so condition edits between
/// runs take effect.
pub(crate) bp_conditions: Vec<(i64, Option<String>)>,
/// Per-breakpoint-id hit counter, keyed by `Breakpoint.id`. Increments
/// on every raw VM hit *before* condition/logpoint evaluation so
/// `hitCondition` expressions see a monotonic count even when the
/// user's condition causes the breakpoint to skip.
pub(crate) bp_hit_counts: BTreeMap<i64, u64>,
/// Function-name breakpoints. DAP `setFunctionBreakpoints` stores
/// the full list here; it's mirrored onto the VM on each launch /
/// edit so `Vm::function_breakpoints` stays in lockstep.
pub(crate) function_breakpoints: Vec<crate::protocol::FunctionBreakpoint>,
/// Armed state for triggered breakpoints (#102). A BP with
/// `triggered_by: [A, B]` stays disarmed until A or B fires at
/// least once; then flips to armed for the rest of the run.
/// Reset on enter_running / setBreakpoints edit. Entry present
/// (even if false) means the BP is known to be triggered; absent
/// means the BP has no trigger and is always armed.
pub(crate) armed_breakpoints: BTreeMap<i64, bool>,
/// Set by handle_pause; the next VM step honors it by emitting a
/// stopped event with reason="pause" and clearing the flag.
pub(crate) pending_pause: bool,
/// progressId of the in-flight DAP progressStart, if any. The IDE
/// uses it to display a "still working" indicator and reset its own
/// per-request timeouts. Cleared on stop/terminate via progressEnd.
pub(crate) active_progress_id: Option<String>,
/// Number of VM steps taken since the most recent progressStart;
/// used to throttle progressUpdate emission to one per ~256 steps
/// so we don't flood the IDE with no-op events.
pub(crate) steps_since_progress_update: u32,
/// DAP thread registry: stable `thread_id -> display_name` map.
/// Seeded with `{1 -> "main"}` so single-session flows see the same
/// id they always have. ACP sessions get registered via
/// `register_thread` as they open; exits unregister. The registry is
/// authoritative for `handle_threads` and drives the `threadId`
/// field on stopped/continued/stepping events.
pub(crate) threads: BTreeMap<u64, String>,
/// Inverse map of `threads`, keyed by ACP session id so we can
/// look up a thread id without scanning. Kept in lockstep with
/// `threads` by `register_thread` / `unregister_thread`.
#[allow(dead_code)]
pub(crate) session_to_thread: BTreeMap<String, u64>,
/// Monotonic allocator for DAP thread ids. Starts at 2 because id 1
/// is permanently the synthetic "main" thread.
#[allow(dead_code)]
pub(crate) next_thread_id: u64,
/// The thread id responsible for the current stop / step request.
/// Today we only run one VM so this is always 1, but every DAP
/// response that carries a `threadId` reads from this field so the
/// wire format is already session-accurate — a future multi-VM
/// implementation only has to swap the value when routing requests.
pub(crate) current_thread_id: u64,
}
impl Debugger {
pub fn new() -> Self {
Self {
seq: 1,
source_path: None,
source_content: None,
breakpoints: Vec::new(),
next_bp_id: 1,
vm: None,
variables: BTreeMap::new(),
stopped: false,
current_line: 0,
step_mode: StepMode::Continue,
output: String::new(),
program_state: ProgramState::NotStarted,
var_refs: BTreeMap::new(),
next_var_ref: 100,
runtime: tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap(),
break_on_exceptions: false,
exception_filters: BTreeMap::new(),
latest_debug_state: Rc::new(RefCell::new(None)),
host_bridge: None,
running: false,
bp_conditions: Vec::new(),
bp_hit_counts: BTreeMap::new(),
function_breakpoints: Vec::new(),
armed_breakpoints: BTreeMap::new(),
pending_pause: false,
active_progress_id: None,
steps_since_progress_update: 0,
threads: {
let mut m = BTreeMap::new();
m.insert(1, "main".to_string());
m
},
session_to_thread: BTreeMap::new(),
next_thread_id: 2,
current_thread_id: 1,
}
}
/// Register a new ACP session as a DAP thread. Returns the allocated
/// thread id. Idempotent: calling twice with the same session id
/// returns the existing id without re-emitting.
///
/// Callers that want to surface the registration on the DAP wire
/// should pair this with [`Debugger::thread_started_event`] and emit
/// the response. We keep the two steps separate so the debugger can
/// sequence thread events alongside its normal seq allocation.
///
/// Marked allow(dead_code) because #86 ships the public API ahead
/// of the co-requisite ACP-session wiring that will invoke it; the
/// DAP wire format is already session-accurate.
#[allow(dead_code)]
pub fn register_thread(&mut self, session_id: &str) -> u64 {
if let Some(&id) = self.session_to_thread.get(session_id) {
return id;
}
let id = self.next_thread_id;
self.next_thread_id += 1;
self.threads.insert(id, session_id.to_string());
self.session_to_thread.insert(session_id.to_string(), id);
id
}
/// Drop a session from the thread registry. Returns the freed id so
/// the caller can emit a matching `thread` exited event. Refuses to
/// unregister the synthetic `main` thread (id 1).
#[allow(dead_code)]
pub fn unregister_thread(&mut self, session_id: &str) -> Option<u64> {
let id = self.session_to_thread.remove(session_id)?;
if id == 1 {
// Keep main alive; put it back.
self.session_to_thread.insert(session_id.to_string(), id);
return None;
}
self.threads.remove(&id);
Some(id)
}
/// Build a DAP `thread` event announcing a newly-started session.
/// Paired with [`Debugger::register_thread`] by host code that wants
/// the IDE to light up a new row in its Threads pane.
#[allow(dead_code)]
pub fn thread_started_event(&mut self, thread_id: u64) -> crate::protocol::DapResponse {
let seq = self.next_seq();
crate::protocol::DapResponse::event(
seq,
"thread",
Some(serde_json::json!({
"reason": "started",
"threadId": thread_id,
})),
)
}
/// Build a DAP `thread` event announcing an exited session. Pair
/// with [`Debugger::unregister_thread`].
#[allow(dead_code)]
pub fn thread_exited_event(&mut self, thread_id: u64) -> crate::protocol::DapResponse {
let seq = self.next_seq();
crate::protocol::DapResponse::event(
seq,
"thread",
Some(serde_json::json!({
"reason": "exited",
"threadId": thread_id,
})),
)
}
pub(crate) fn next_seq(&mut self) -> i64 {
let s = self.seq;
self.seq += 1;
s
}
/// True when the main loop should keep stepping this debugger's VM.
/// Drives the message-interleave loop in `main.rs` -- when false, the
/// loop blocks on `request_rx.recv()` instead of busy-stepping.
pub fn is_running(&self) -> bool {
self.running && self.vm.is_some()
}
/// Install a host bridge. Cloned into an `Rc` and registered with
/// harn-vm via `set_host_call_bridge` whenever a fresh VM is built.
pub fn attach_host_bridge(&mut self, bridge: std::sync::Arc<DapHostBridge>) {
self.host_bridge = Some((*bridge).clone());
}
pub(crate) fn current_debug_state(&self) -> DebugState {
self.latest_debug_state
.borrow()
.clone()
.or_else(|| self.vm.as_ref().map(|vm| vm.debug_state()))
.unwrap_or(DebugState {
line: self.current_line.max(0) as usize,
variables: self.variables.clone(),
frame_name: "pipeline".to_string(),
frame_depth: 0,
})
}
}