Skip to main content

aft/
run_tool_call.rs

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/// Monotonic timestamps for one subc tool call. The recorder stays on the
13/// request path and only takes an `Instant::now()` at each phase boundary.
14#[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/// The full result of a tool call: the COMPLETE dispatch Response carried VERBATIM,
124/// plus the server-rendered agent-facing text (what the deleted TS formatters used to produce).
125/// Oracle #1: carry the WHOLE Response — promote nothing, drop nothing (preview_diff, attachments,
126/// status_bar, bg_completions, lsp_diagnostics, code, message, candidates, … all ride inside `response`).
127#[derive(Debug)]
128pub struct ToolCallResult {
129    pub text: String,
130    pub response: crate::protocol::Response,
131}
132
133/// Reserve a discriminated seam so bash/PTY/streaming (P3) doesn't force a signature rewrite.
134/// Only `Unary` is constructed today. Do NOT build `Stream`.
135#[derive(Debug)]
136pub enum ToolCallOutcome {
137    Unary(ToolCallResult),
138}
139
140/// Server-owned settings for a single `tool_call` request.
141/// These fields cannot be supplied through the agent's arguments object.
142#[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    /// Plugin-computed registration fact carried by transports whose bind
150    /// protocol cannot include configure-time process state.
151    pub edit_slot_survives: Option<bool>,
152    /// Whether configure's immediate registration-downgrade warning was discarded
153    /// by this transport and must be reported on the first tool call instead.
154    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}