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 pub(crate) 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 pub edit_slot_survives: Option<bool>,
152 pub report_registration_downgrade: bool,
155}
156
157pub(crate) fn ensure_hashline_registration(
158 app_ctx: &AppContext,
159 project_root: &std::path::Path,
160 session: &str,
161 edit_slot_survives: Option<bool>,
162 report_registration_downgrade: bool,
163) -> bool {
164 let binding_root = app_ctx
165 .canonical_cache_root_opt()
166 .unwrap_or_else(|| project_root.to_path_buf());
167 let edit_slot_survives = match edit_slot_survives {
168 Some(value) => value,
169 None if app_ctx.harness_opt().is_some_and(|harness| {
170 matches!(
171 harness,
172 crate::harness::Harness::Opencode | crate::harness::Harness::Pi
173 )
174 }) && app_ctx
175 .hashline_bindings()
176 .peek(&binding_root, session)
177 .is_none() =>
178 {
179 false
180 }
181 None => return false,
182 };
183 let registration = app_ctx.hashline_bindings().register(
184 &binding_root,
185 session.to_string(),
186 crate::hashline::integration::RegistrationRequest {
187 configured_enabled: app_ctx.config().hashline_enabled,
188 edit_slot_survives,
189 },
190 );
191 report_registration_downgrade
192 && registration.downgrade.is_some()
193 && !registration.stores_preserved
194}
195
196fn attach_hashline_downgrade(response: &mut Response) {
197 let warning = crate::commands::configure::hashline_downgrade_warning();
198 if let Some(data) = response.data.as_object_mut() {
199 match data.get_mut("warnings").and_then(Value::as_array_mut) {
200 Some(warnings) => warnings.push(warning),
201 None => {
202 data.insert("warnings".to_string(), json!([warning]));
203 }
204 }
205 }
206}
207
208fn append_hashline_downgrade_text(text: &mut String) {
209 text.push_str("\n\n");
210 text.push_str(crate::commands::configure::HASHLINE_DOWNGRADE_MESSAGE);
211}
212
213pub(crate) struct PreparedToolCall {
214 pub(crate) request: RawRequest,
215 pub(crate) surface_downgraded: bool,
216}
217
218pub(crate) fn prepare_tool_call(
219 bare_name: &str,
220 args: Value,
221 format_context: &crate::subc_format::FormatContext,
222 ctx: &ToolCallContext,
223 app_ctx: &AppContext,
224 mut phase_trace: Option<&mut PhaseTrace>,
225) -> Result<PreparedToolCall, ToolCallResult> {
226 let sanitized_args = strip_agent_preview_arg_owned(args);
227 let binding_root = app_ctx
228 .canonical_cache_root_opt()
229 .unwrap_or_else(|| ctx.project_root.clone());
230 let session = ctx
231 .session_id
232 .as_deref()
233 .unwrap_or(crate::protocol::DEFAULT_SESSION_ID);
234 let surface_downgraded = ensure_hashline_registration(
235 app_ctx,
236 &ctx.project_root,
237 session,
238 ctx.edit_slot_survives,
239 ctx.report_registration_downgrade,
240 );
241 let binding_guard = app_ctx.hashline_bindings().capture(binding_root, session);
242 let translate_context = crate::subc_translate::TranslateContext {
243 diagnostics_on_edit: ctx.diagnostics_on_edit,
244 preview: ctx.preview,
245 effective_hashline: crate::hashline::integration::effective_for_capture(
246 binding_guard.as_ref(),
247 ),
248 };
249 let (command, translated_args) = if crate::subc_translate::supports_tool(bare_name) {
250 match crate::subc_translate::subc_translate_owned_with_context(
251 bare_name,
252 sanitized_args,
253 ctx.project_root.as_path(),
254 translate_context,
255 ) {
256 Ok(translated) => (translated.command, translated.args),
257 Err(err) => {
258 if let Some(trace) = phase_trace.as_mut() {
259 trace.mark_translate_done();
260 trace.mark_execute_done();
261 }
262 let response = Response::error(ctx.request_id.clone(), err.code, err.message);
263 let result = tool_call_result_from_response(
264 bare_name,
265 format_context,
266 response,
267 surface_downgraded,
268 );
269 if let Some(trace) = phase_trace.as_mut() {
270 trace.mark_format_done();
271 trace.mark_finalize_done();
272 }
273 return Err(result);
274 }
275 }
276 } else {
277 let map = match sanitized_args {
278 Value::Object(map) => map,
279 _ => serde_json::Map::new(),
280 };
281 (bare_name.to_string(), map)
282 };
283
284 let request = match raw_request_from_translated(command, translated_args, ctx) {
285 Ok(req) => req,
286 Err(error) => {
287 if let Some(trace) = phase_trace.as_mut() {
288 trace.mark_translate_done();
289 trace.mark_execute_done();
290 }
291 let response = Response::error(
292 ctx.request_id.clone(),
293 "invalid_request",
294 format!("failed to build request from tool call: {error}"),
295 );
296 let result = tool_call_result_from_response(
297 bare_name,
298 format_context,
299 response,
300 surface_downgraded,
301 );
302 if let Some(trace) = phase_trace.as_mut() {
303 trace.mark_format_done();
304 trace.mark_finalize_done();
305 }
306 return Err(result);
307 }
308 };
309 if let Some(trace) = phase_trace.as_mut() {
310 trace.mark_translate_done();
311 }
312
313 Ok(PreparedToolCall {
314 request,
315 surface_downgraded,
316 })
317}
318
319pub(crate) fn finish_tool_call_response(
320 bare_name: &str,
321 format_context: &crate::subc_format::FormatContext,
322 mut response: Response,
323 surface_downgraded: bool,
324 finalizer: Option<&FinalizeFn<'_>>,
325 mut phase_trace: Option<&mut PhaseTrace>,
326) -> ToolCallResult {
327 if surface_downgraded {
328 attach_hashline_downgrade(&mut response);
329 }
330 let mut text =
331 crate::subc_format::format_response_with_context(bare_name, &response, format_context);
332 if surface_downgraded {
333 append_hashline_downgrade_text(&mut text);
334 }
335 if let Some(trace) = phase_trace.as_mut() {
336 trace.mark_format_done();
337 }
338 if let Some(finalizer) = finalizer {
339 finalizer(&mut response);
340 }
341 if let Some(trace) = phase_trace.as_mut() {
342 trace.mark_finalize_done();
343 }
344 ToolCallResult { text, response }
345}
346
347pub fn run_tool_call(
348 bare_name: &str,
349 args: Value,
350 format_context: &crate::subc_format::FormatContext,
351 ctx: &ToolCallContext,
352 app_ctx: &AppContext,
353 dispatch: &DispatchFn<'_>,
354 finalizer: Option<&FinalizeFn<'_>>,
355 mut phase_trace: Option<&mut PhaseTrace>,
356) -> ToolCallOutcome {
357 let prepared = match prepare_tool_call(
358 bare_name,
359 args,
360 format_context,
361 ctx,
362 app_ctx,
363 phase_trace.as_deref_mut(),
364 ) {
365 Ok(prepared) => prepared,
366 Err(result) => return ToolCallOutcome::Unary(result),
367 };
368
369 let response = if prepared.request.command == "inspect" {
370 crate::commands::inspect::handle_inspect_tool_call(&prepared.request, app_ctx)
371 } else {
372 dispatch(prepared.request, app_ctx)
373 };
374 if let Some(trace) = phase_trace.as_mut() {
375 trace.mark_execute_done();
376 }
377 let result = finish_tool_call_response(
378 bare_name,
379 format_context,
380 response,
381 prepared.surface_downgraded,
382 finalizer,
383 phase_trace,
384 );
385 ToolCallOutcome::Unary(result)
386}
387
388fn raw_request_from_translated(
389 command: String,
390 mut params: serde_json::Map<String, Value>,
391 ctx: &ToolCallContext,
392) -> Result<RawRequest, &'static str> {
393 if params.contains_key("method") {
394 return Err("duplicate field `command`");
395 }
396
397 if ctx.preview {
398 params.insert("preview".to_string(), json!(true));
399 }
400
401 params.remove("id");
402 if command != "bash" {
403 params.remove("command");
404 }
405 params.remove("session_id");
406 let lsp_hints = params.remove("lsp_hints").filter(|value| !value.is_null());
407
408 Ok(RawRequest {
409 id: ctx.request_id.clone(),
410 command,
411 lsp_hints,
412 session_id: ctx.session_id.clone(),
413 params: Value::Object(params),
414 })
415}
416
417pub(crate) fn strip_agent_preview_arg_owned(mut args: Value) -> Value {
418 if let Some(map) = args.as_object_mut() {
419 map.remove("preview");
420 }
421 args
422}
423
424fn tool_call_result_from_response(
425 bare_name: &str,
426 format_context: &crate::subc_format::FormatContext,
427 mut response: Response,
428 surface_downgraded: bool,
429) -> ToolCallResult {
430 if surface_downgraded {
431 attach_hashline_downgrade(&mut response);
432 }
433 let mut text =
434 crate::subc_format::format_response_with_context(bare_name, &response, format_context);
435 if surface_downgraded {
436 append_hashline_downgrade_text(&mut text);
437 }
438 ToolCallResult { text, response }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn phase_trace_reports_execution_and_writer_egress_subphases() {
447 let t0 = Instant::now();
448 let trace = PhaseTrace {
449 frame_decoded: t0,
450 executor_submitted: Some(t0 + Duration::from_millis(1)),
451 job_admitted: Some(t0 + Duration::from_millis(3)),
452 translate_done: Some(t0 + Duration::from_millis(6)),
453 execute_done: Some(t0 + Duration::from_millis(10)),
454 format_done: Some(t0 + Duration::from_millis(15)),
455 finalize_done: Some(t0 + Duration::from_millis(21)),
456 };
457
458 let phases = trace
459 .finish(ToolCallEgressTiming {
460 enqueued: t0 + Duration::from_millis(28),
461 dequeued: t0 + Duration::from_millis(35),
462 write_started: t0 + Duration::from_millis(37),
463 write_finished: t0 + Duration::from_millis(48),
464 frame_bytes: 262_144,
465 queue_depth: 17,
466 writer_active_at_enqueue: true,
467 writer_queue_was_full: true,
468 reserve_timeouts: 2,
469 })
470 .unwrap();
471
472 assert_eq!(phases.queue, Duration::from_millis(2));
473 assert_eq!(phases.translate, Duration::from_millis(3));
474 assert_eq!(phases.execute, Duration::from_millis(4));
475 assert_eq!(phases.format, Duration::from_millis(5));
476 assert_eq!(phases.finalize, Duration::from_millis(6));
477 assert_eq!(phases.egress_enqueue, Duration::from_millis(7));
478 assert_eq!(phases.egress_queue, Duration::from_millis(7));
479 assert_eq!(phases.egress_prepare, Duration::from_millis(2));
480 assert_eq!(phases.egress_write, Duration::from_millis(11));
481 assert_eq!(phases.egress, Duration::from_millis(27));
482 assert_eq!(phases.frame_bytes, 262_144);
483 assert_eq!(phases.writer_queue_depth, 17);
484 assert!(phases.writer_active_at_enqueue);
485 assert!(phases.writer_queue_was_full);
486 assert_eq!(phases.writer_reserve_timeouts, 2);
487 assert_eq!(phases.total, Duration::from_millis(48));
488 }
489
490 mod raw_request_construction {
491 use std::hint::black_box;
492
493 use super::*;
494 use crate::test_allocations::count as count_allocations;
495
496 fn context(preview: bool) -> ToolCallContext {
497 ToolCallContext {
498 project_root: PathBuf::from("/workspace"),
499 session_id: Some("session-realistic".to_string()),
500 request_id: "subc-7-42".to_string(),
501 diagnostics_on_edit: true,
502 preview,
503 edit_slot_survives: None,
504 report_registration_downgrade: false,
505 }
506 }
507
508 fn object(value: Value) -> serde_json::Map<String, Value> {
509 value.as_object().cloned().expect("test input is an object")
510 }
511
512 fn legacy_raw_request(
513 command: String,
514 mut params: serde_json::Map<String, Value>,
515 ctx: &ToolCallContext,
516 ) -> Result<RawRequest, String> {
517 if ctx.preview {
518 params.insert("preview".to_string(), json!(true));
519 }
520 params.insert("id".to_string(), json!(ctx.request_id.clone()));
521 params.insert("command".to_string(), json!(command));
522 params.insert("session_id".to_string(), json!(ctx.session_id.clone()));
523 serde_json::from_value(Value::Object(params)).map_err(|error| error.to_string())
524 }
525
526 fn dispatch_result_bytes(request: RawRequest) -> Vec<u8> {
527 let response = Response::success(
528 request.id.clone(),
529 json!({
530 "received_command": request.command,
531 "received_lsp_hints": request.lsp_hints,
532 "received_session_id": request.session_id,
533 "received_params": request.params,
534 }),
535 );
536 serde_json::to_vec(&response).expect("serialize recording dispatch response")
537 }
538
539 #[test]
540 fn direct_raw_request_construction_avoids_flatten_rematerialization() {
541 let direct_params = object(json!({
542 "file": "/workspace/src/main.rs",
543 "start_line": 150,
544 "end_line": 229,
545 }));
546 let legacy_params = direct_params.clone();
547 let ctx = context(false);
548 let direct_command = "read".to_string();
549 let legacy_command = direct_command.clone();
550
551 let (direct, direct_allocations) = count_allocations(|| {
552 raw_request_from_translated(direct_command, direct_params, &ctx)
553 .expect("direct request")
554 });
555 let (legacy, legacy_allocations) = count_allocations(|| {
556 legacy_raw_request(legacy_command, legacy_params, &ctx).expect("legacy request")
557 });
558 black_box((&direct, &legacy));
559
560 assert_eq!(direct_allocations, 2);
561 assert!(
562 legacy_allocations >= 20,
563 "legacy flatten path unexpectedly used only {legacy_allocations} allocations"
564 );
565 assert!(
566 legacy_allocations >= direct_allocations + 18,
567 "direct={direct_allocations}, legacy={legacy_allocations}"
568 );
569 }
570
571 #[test]
572 fn direct_raw_request_matches_legacy_dispatch_bytes() {
573 let edits = (0..100)
574 .map(|index| {
575 json!({
576 "match": format!("old declaration {index}"),
577 "replacement": format!("new declaration {index}"),
578 "replace_all": false,
579 })
580 })
581 .collect::<Vec<_>>();
582 let cases = [
583 (
584 "read",
585 "read",
586 object(json!({
587 "file": "/workspace/src/main.rs",
588 "start_line": 1,
589 "end_line": 80,
590 })),
591 false,
592 ),
593 (
594 "write",
595 "write",
596 object(json!({
597 "file": "/workspace/src/new.rs",
598 "content": "fn created() {}\n",
599 "create_dirs": true,
600 })),
601 false,
602 ),
603 (
604 "batch-edit-100",
605 "batch",
606 object(json!({
607 "file": "/workspace/src/large.rs",
608 "edits": edits,
609 })),
610 false,
611 ),
612 (
613 "preview",
614 "read",
615 object(json!({"file": "/workspace/src/main.rs"})),
616 true,
617 ),
618 (
619 "lsp-hints",
620 "move_symbol",
621 object(json!({
622 "file": "/workspace/src/main.rs",
623 "symbol": "run",
624 "destination": "/workspace/src/moved.rs",
625 "lsp_hints": {
626 "symbols": [{
627 "name": "run",
628 "file": "/workspace/src/main.rs",
629 "line": 12,
630 "kind": "function",
631 }],
632 },
633 })),
634 false,
635 ),
636 (
637 "null-lsp-hints",
638 "move_symbol",
639 object(json!({
640 "file": "/workspace/src/main.rs",
641 "symbol": "run",
642 "destination": "/workspace/src/moved.rs",
643 "lsp_hints": null,
644 })),
645 false,
646 ),
647 ];
648
649 for (label, command, params, preview) in cases {
650 let ctx = context(preview);
651 let direct = raw_request_from_translated(command.to_string(), params.clone(), &ctx)
652 .expect("direct request");
653 let legacy =
654 legacy_raw_request(command.to_string(), params, &ctx).expect("legacy request");
655
656 assert_eq!(
657 dispatch_result_bytes(direct),
658 dispatch_result_bytes(legacy),
659 "recording dispatch response differed for {label}"
660 );
661 }
662 }
663
664 #[test]
665 fn direct_raw_request_preserves_method_alias_rejection() {
666 let params = object(json!({"method": "agent-supplied-command"}));
667 let ctx = context(false);
668 let direct_error =
669 raw_request_from_translated("read".to_string(), params.clone(), &ctx)
670 .expect_err("method alias must conflict with server-owned command");
671 let legacy_error = legacy_raw_request("read".to_string(), params, &ctx)
672 .expect_err("legacy path rejects the duplicate alias");
673
674 assert_eq!(direct_error, legacy_error);
675 }
676
677 #[test]
678 fn direct_raw_request_preserves_bash_command_param() {
679 let params = object(json!({
680 "command": "echo standalone-tool-call-ok",
681 "background": false,
682 }));
683 let ctx = context(false);
684 let request = raw_request_from_translated("bash".to_string(), params, &ctx)
685 .expect("bash request");
686
687 assert_eq!(request.command, "bash");
688 assert_eq!(
689 request.params.get("command").and_then(Value::as_str),
690 Some("echo standalone-tool-call-ok")
691 );
692 }
693
694 #[test]
695 fn direct_raw_request_still_strips_command_param_for_non_bash_tools() {
696 let params = object(json!({"command": "agent-supplied-command"}));
697 let ctx = context(false);
698 let request = raw_request_from_translated("read".to_string(), params, &ctx)
699 .expect("read request");
700
701 assert_eq!(request.command, "read");
702 assert!(request.params.get("command").is_none());
703 }
704 }
705}