1use std::path::PathBuf;
2use std::time::{Duration, Instant};
3
4use serde_json::{json, Value};
5
6use crate::context::AppContext;
7use crate::protocol::{RawRequest, Response};
8
9pub type DispatchFn<'a> = dyn Fn(RawRequest, &AppContext) -> Response + 'a;
10pub type FinalizeFn<'a> = dyn Fn(&mut Response) + 'a;
11
12#[derive(Debug)]
15pub struct PhaseTrace {
16 frame_decoded: Instant,
17 executor_submitted: Option<Instant>,
18 job_admitted: Option<Instant>,
19 translate_done: Option<Instant>,
20 execute_done: Option<Instant>,
21 format_done: Option<Instant>,
22 finalize_done: Option<Instant>,
23}
24
25#[derive(Debug, Clone, Copy)]
26pub struct ToolCallEgressTiming {
27 pub enqueued: Instant,
28 pub dequeued: Instant,
29 pub write_started: Instant,
30 pub write_finished: Instant,
31 pub frame_bytes: usize,
32 pub queue_depth: usize,
33 pub writer_active_at_enqueue: bool,
34 pub writer_queue_was_full: bool,
35 pub reserve_timeouts: u32,
36}
37
38#[derive(Debug, Clone, Copy)]
39pub struct ToolCallPhaseDurations {
40 pub queue: Duration,
41 pub translate: Duration,
42 pub execute: Duration,
43 pub format: Duration,
44 pub finalize: Duration,
45 pub egress_enqueue: Duration,
46 pub egress_queue: Duration,
47 pub egress_prepare: Duration,
48 pub egress_write: Duration,
49 pub egress: Duration,
50 pub frame_bytes: usize,
51 pub writer_queue_depth: usize,
52 pub writer_active_at_enqueue: bool,
53 pub writer_queue_was_full: bool,
54 pub writer_reserve_timeouts: u32,
55 pub total: Duration,
56}
57
58impl PhaseTrace {
59 pub fn new(frame_decoded: Instant) -> Self {
60 Self {
61 frame_decoded,
62 executor_submitted: None,
63 job_admitted: None,
64 translate_done: None,
65 execute_done: None,
66 format_done: None,
67 finalize_done: None,
68 }
69 }
70
71 pub fn mark_executor_submitted(&mut self) {
72 self.executor_submitted = Some(Instant::now());
73 }
74
75 pub fn mark_job_admitted(&mut self) {
76 self.job_admitted = Some(Instant::now());
77 }
78
79 fn mark_translate_done(&mut self) {
80 self.translate_done = Some(Instant::now());
81 }
82
83 fn mark_execute_done(&mut self) {
84 self.execute_done = Some(Instant::now());
85 }
86
87 fn mark_format_done(&mut self) {
88 self.format_done = Some(Instant::now());
89 }
90
91 fn mark_finalize_done(&mut self) {
92 self.finalize_done = Some(Instant::now());
93 }
94
95 pub fn finish(self, egress: ToolCallEgressTiming) -> Option<ToolCallPhaseDurations> {
96 let executor_submitted = self.executor_submitted?;
97 let job_admitted = self.job_admitted?;
98 let translate_done = self.translate_done?;
99 let execute_done = self.execute_done?;
100 let format_done = self.format_done?;
101 let finalize_done = self.finalize_done?;
102 Some(ToolCallPhaseDurations {
103 queue: job_admitted.duration_since(executor_submitted),
104 translate: translate_done.duration_since(job_admitted),
105 execute: execute_done.duration_since(translate_done),
106 format: format_done.duration_since(execute_done),
107 finalize: finalize_done.duration_since(format_done),
108 egress_enqueue: egress.enqueued.duration_since(finalize_done),
109 egress_queue: egress.dequeued.duration_since(egress.enqueued),
110 egress_prepare: egress.write_started.duration_since(egress.dequeued),
111 egress_write: egress.write_finished.duration_since(egress.write_started),
112 egress: egress.write_finished.duration_since(finalize_done),
113 frame_bytes: egress.frame_bytes,
114 writer_queue_depth: egress.queue_depth,
115 writer_active_at_enqueue: egress.writer_active_at_enqueue,
116 writer_queue_was_full: egress.writer_queue_was_full,
117 writer_reserve_timeouts: egress.reserve_timeouts,
118 total: egress.write_finished.duration_since(self.frame_decoded),
119 })
120 }
121}
122
123#[derive(Debug)]
128pub struct ToolCallResult {
129 pub text: String,
130 pub response: crate::protocol::Response,
131}
132
133#[derive(Debug)]
136pub enum ToolCallOutcome {
137 Unary(ToolCallResult),
138}
139
140#[derive(Debug, Clone)]
143pub struct ToolCallContext {
144 pub project_root: PathBuf,
145 pub session_id: Option<String>,
146 pub request_id: String,
147 pub diagnostics_on_edit: bool,
148 pub preview: bool,
149}
150
151pub fn run_tool_call(
152 bare_name: &str,
153 args: Value,
154 format_context: &crate::subc_format::FormatContext,
155 ctx: &ToolCallContext,
156 app_ctx: &AppContext,
157 dispatch: &DispatchFn<'_>,
158 finalizer: Option<&FinalizeFn<'_>>,
159 mut phase_trace: Option<&mut PhaseTrace>,
160) -> ToolCallOutcome {
161 let sanitized_args = strip_agent_preview_arg_owned(args);
162 let translate_context = crate::subc_translate::TranslateContext {
163 diagnostics_on_edit: ctx.diagnostics_on_edit,
164 preview: ctx.preview,
165 };
166 let (command, translated_args) = if crate::subc_translate::supports_tool(bare_name) {
167 match crate::subc_translate::subc_translate_owned_with_context(
168 bare_name,
169 sanitized_args,
170 ctx.project_root.as_path(),
171 translate_context,
172 ) {
173 Ok(translated) => (translated.command, translated.args),
174 Err(err) => {
175 if let Some(trace) = phase_trace.as_mut() {
176 trace.mark_translate_done();
177 trace.mark_execute_done();
178 }
179 let response = Response::error(ctx.request_id.clone(), err.code, err.message);
180 let result = tool_call_result_from_response(bare_name, format_context, response);
181 if let Some(trace) = phase_trace.as_mut() {
182 trace.mark_format_done();
183 trace.mark_finalize_done();
184 }
185 return ToolCallOutcome::Unary(result);
186 }
187 }
188 } else {
189 let map = match sanitized_args {
190 Value::Object(map) => map,
191 _ => serde_json::Map::new(),
192 };
193 (bare_name.to_string(), map)
194 };
195
196 let raw_req = match raw_request_from_translated(command, translated_args, ctx) {
197 Ok(req) => req,
198 Err(error) => {
199 if let Some(trace) = phase_trace.as_mut() {
200 trace.mark_translate_done();
201 trace.mark_execute_done();
202 }
203 let response = Response::error(
204 ctx.request_id.clone(),
205 "invalid_request",
206 format!("failed to build request from tool call: {error}"),
207 );
208 let result = tool_call_result_from_response(bare_name, format_context, response);
209 if let Some(trace) = phase_trace.as_mut() {
210 trace.mark_format_done();
211 trace.mark_finalize_done();
212 }
213 return ToolCallOutcome::Unary(result);
214 }
215 };
216 if let Some(trace) = phase_trace.as_mut() {
217 trace.mark_translate_done();
218 }
219
220 let mut response = dispatch(raw_req, app_ctx);
221 if let Some(trace) = phase_trace.as_mut() {
222 trace.mark_execute_done();
223 }
224 let text =
225 crate::subc_format::format_response_with_context(bare_name, &response, format_context);
226 if let Some(trace) = phase_trace.as_mut() {
227 trace.mark_format_done();
228 }
229 if let Some(finalizer) = finalizer {
230 finalizer(&mut response);
231 }
232 if let Some(trace) = phase_trace.as_mut() {
233 trace.mark_finalize_done();
234 }
235
236 ToolCallOutcome::Unary(ToolCallResult { text, response })
237}
238
239fn raw_request_from_translated(
240 command: String,
241 mut params: serde_json::Map<String, Value>,
242 ctx: &ToolCallContext,
243) -> Result<RawRequest, &'static str> {
244 if params.contains_key("method") {
245 return Err("duplicate field `command`");
246 }
247
248 if ctx.preview {
249 params.insert("preview".to_string(), json!(true));
250 }
251
252 params.remove("id");
253 params.remove("command");
254 params.remove("session_id");
255 let lsp_hints = params.remove("lsp_hints").filter(|value| !value.is_null());
256
257 Ok(RawRequest {
258 id: ctx.request_id.clone(),
259 command,
260 lsp_hints,
261 session_id: ctx.session_id.clone(),
262 params: Value::Object(params),
263 })
264}
265
266pub(crate) fn strip_agent_preview_arg_owned(mut args: Value) -> Value {
267 if let Some(map) = args.as_object_mut() {
268 map.remove("preview");
269 }
270 args
271}
272
273fn tool_call_result_from_response(
274 bare_name: &str,
275 format_context: &crate::subc_format::FormatContext,
276 response: Response,
277) -> ToolCallResult {
278 let text =
279 crate::subc_format::format_response_with_context(bare_name, &response, format_context);
280 ToolCallResult { text, response }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn phase_trace_reports_execution_and_writer_egress_subphases() {
289 let t0 = Instant::now();
290 let trace = PhaseTrace {
291 frame_decoded: t0,
292 executor_submitted: Some(t0 + Duration::from_millis(1)),
293 job_admitted: Some(t0 + Duration::from_millis(3)),
294 translate_done: Some(t0 + Duration::from_millis(6)),
295 execute_done: Some(t0 + Duration::from_millis(10)),
296 format_done: Some(t0 + Duration::from_millis(15)),
297 finalize_done: Some(t0 + Duration::from_millis(21)),
298 };
299
300 let phases = trace
301 .finish(ToolCallEgressTiming {
302 enqueued: t0 + Duration::from_millis(28),
303 dequeued: t0 + Duration::from_millis(35),
304 write_started: t0 + Duration::from_millis(37),
305 write_finished: t0 + Duration::from_millis(48),
306 frame_bytes: 262_144,
307 queue_depth: 17,
308 writer_active_at_enqueue: true,
309 writer_queue_was_full: true,
310 reserve_timeouts: 2,
311 })
312 .unwrap();
313
314 assert_eq!(phases.queue, Duration::from_millis(2));
315 assert_eq!(phases.translate, Duration::from_millis(3));
316 assert_eq!(phases.execute, Duration::from_millis(4));
317 assert_eq!(phases.format, Duration::from_millis(5));
318 assert_eq!(phases.finalize, Duration::from_millis(6));
319 assert_eq!(phases.egress_enqueue, Duration::from_millis(7));
320 assert_eq!(phases.egress_queue, Duration::from_millis(7));
321 assert_eq!(phases.egress_prepare, Duration::from_millis(2));
322 assert_eq!(phases.egress_write, Duration::from_millis(11));
323 assert_eq!(phases.egress, Duration::from_millis(27));
324 assert_eq!(phases.frame_bytes, 262_144);
325 assert_eq!(phases.writer_queue_depth, 17);
326 assert!(phases.writer_active_at_enqueue);
327 assert!(phases.writer_queue_was_full);
328 assert_eq!(phases.writer_reserve_timeouts, 2);
329 assert_eq!(phases.total, Duration::from_millis(48));
330 }
331
332 mod raw_request_construction {
333 use std::hint::black_box;
334
335 use super::*;
336 use crate::test_allocations::count as count_allocations;
337
338 fn context(preview: bool) -> ToolCallContext {
339 ToolCallContext {
340 project_root: PathBuf::from("/workspace"),
341 session_id: Some("session-realistic".to_string()),
342 request_id: "subc-7-42".to_string(),
343 diagnostics_on_edit: true,
344 preview,
345 }
346 }
347
348 fn object(value: Value) -> serde_json::Map<String, Value> {
349 value.as_object().cloned().expect("test input is an object")
350 }
351
352 fn legacy_raw_request(
353 command: String,
354 mut params: serde_json::Map<String, Value>,
355 ctx: &ToolCallContext,
356 ) -> Result<RawRequest, String> {
357 if ctx.preview {
358 params.insert("preview".to_string(), json!(true));
359 }
360 params.insert("id".to_string(), json!(ctx.request_id.clone()));
361 params.insert("command".to_string(), json!(command));
362 params.insert("session_id".to_string(), json!(ctx.session_id.clone()));
363 serde_json::from_value(Value::Object(params)).map_err(|error| error.to_string())
364 }
365
366 fn dispatch_result_bytes(request: RawRequest) -> Vec<u8> {
367 let response = Response::success(
368 request.id.clone(),
369 json!({
370 "received_command": request.command,
371 "received_lsp_hints": request.lsp_hints,
372 "received_session_id": request.session_id,
373 "received_params": request.params,
374 }),
375 );
376 serde_json::to_vec(&response).expect("serialize recording dispatch response")
377 }
378
379 #[test]
380 fn direct_raw_request_construction_avoids_flatten_rematerialization() {
381 let direct_params = object(json!({
382 "file": "/workspace/src/main.rs",
383 "start_line": 150,
384 "end_line": 229,
385 }));
386 let legacy_params = direct_params.clone();
387 let ctx = context(false);
388 let direct_command = "read".to_string();
389 let legacy_command = direct_command.clone();
390
391 let (direct, direct_allocations) = count_allocations(|| {
392 raw_request_from_translated(direct_command, direct_params, &ctx)
393 .expect("direct request")
394 });
395 let (legacy, legacy_allocations) = count_allocations(|| {
396 legacy_raw_request(legacy_command, legacy_params, &ctx).expect("legacy request")
397 });
398 black_box((&direct, &legacy));
399
400 assert_eq!(direct_allocations, 2);
401 assert!(
402 legacy_allocations >= 20,
403 "legacy flatten path unexpectedly used only {legacy_allocations} allocations"
404 );
405 assert!(
406 legacy_allocations >= direct_allocations + 18,
407 "direct={direct_allocations}, legacy={legacy_allocations}"
408 );
409 }
410
411 #[test]
412 fn direct_raw_request_matches_legacy_dispatch_bytes() {
413 let edits = (0..100)
414 .map(|index| {
415 json!({
416 "match": format!("old declaration {index}"),
417 "replacement": format!("new declaration {index}"),
418 "replace_all": false,
419 })
420 })
421 .collect::<Vec<_>>();
422 let cases = [
423 (
424 "read",
425 "read",
426 object(json!({
427 "file": "/workspace/src/main.rs",
428 "start_line": 1,
429 "end_line": 80,
430 })),
431 false,
432 ),
433 (
434 "write",
435 "write",
436 object(json!({
437 "file": "/workspace/src/new.rs",
438 "content": "fn created() {}\n",
439 "create_dirs": true,
440 })),
441 false,
442 ),
443 (
444 "batch-edit-100",
445 "batch",
446 object(json!({
447 "file": "/workspace/src/large.rs",
448 "edits": edits,
449 })),
450 false,
451 ),
452 (
453 "preview",
454 "read",
455 object(json!({"file": "/workspace/src/main.rs"})),
456 true,
457 ),
458 (
459 "lsp-hints",
460 "move_symbol",
461 object(json!({
462 "file": "/workspace/src/main.rs",
463 "symbol": "run",
464 "destination": "/workspace/src/moved.rs",
465 "lsp_hints": {
466 "symbols": [{
467 "name": "run",
468 "file": "/workspace/src/main.rs",
469 "line": 12,
470 "kind": "function",
471 }],
472 },
473 })),
474 false,
475 ),
476 (
477 "null-lsp-hints",
478 "move_symbol",
479 object(json!({
480 "file": "/workspace/src/main.rs",
481 "symbol": "run",
482 "destination": "/workspace/src/moved.rs",
483 "lsp_hints": null,
484 })),
485 false,
486 ),
487 ];
488
489 for (label, command, params, preview) in cases {
490 let ctx = context(preview);
491 let direct = raw_request_from_translated(command.to_string(), params.clone(), &ctx)
492 .expect("direct request");
493 let legacy =
494 legacy_raw_request(command.to_string(), params, &ctx).expect("legacy request");
495
496 assert_eq!(
497 dispatch_result_bytes(direct),
498 dispatch_result_bytes(legacy),
499 "recording dispatch response differed for {label}"
500 );
501 }
502 }
503
504 #[test]
505 fn direct_raw_request_preserves_method_alias_rejection() {
506 let params = object(json!({"method": "agent-supplied-command"}));
507 let ctx = context(false);
508 let direct_error =
509 raw_request_from_translated("read".to_string(), params.clone(), &ctx)
510 .expect_err("method alias must conflict with server-owned command");
511 let legacy_error = legacy_raw_request("read".to_string(), params, &ctx)
512 .expect_err("legacy path rejects the duplicate alias");
513
514 assert_eq!(direct_error, legacy_error);
515 }
516 }
517}