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    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}
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}