Skip to main content

aft/
run_tool_call.rs

1use std::borrow::Cow;
2use std::path::PathBuf;
3use std::time::{Duration, Instant};
4
5use serde_json::{json, Value};
6
7use crate::context::AppContext;
8use crate::protocol::{RawRequest, Response};
9
10pub type DispatchFn<'a> = dyn Fn(RawRequest, &AppContext) -> Response + 'a;
11pub type FinalizeFn<'a> = dyn Fn(&mut Response) + 'a;
12
13/// Monotonic timestamps for one subc tool call. The recorder stays on the
14/// request path and only takes an `Instant::now()` at each phase boundary.
15#[derive(Debug)]
16pub struct PhaseTrace {
17    frame_decoded: Instant,
18    executor_submitted: Option<Instant>,
19    job_admitted: Option<Instant>,
20    translate_done: Option<Instant>,
21    execute_done: Option<Instant>,
22    format_done: Option<Instant>,
23    finalize_done: Option<Instant>,
24}
25
26#[derive(Debug, Clone, Copy)]
27pub struct ToolCallEgressTiming {
28    pub enqueued: Instant,
29    pub dequeued: Instant,
30    pub write_started: Instant,
31    pub write_finished: Instant,
32    pub frame_bytes: usize,
33    pub queue_depth: usize,
34    pub writer_active_at_enqueue: bool,
35    pub writer_queue_was_full: bool,
36    pub reserve_timeouts: u32,
37}
38
39#[derive(Debug, Clone, Copy)]
40pub struct ToolCallPhaseDurations {
41    pub queue: Duration,
42    pub translate: Duration,
43    pub execute: Duration,
44    pub format: Duration,
45    pub finalize: Duration,
46    pub egress_enqueue: Duration,
47    pub egress_queue: Duration,
48    pub egress_prepare: Duration,
49    pub egress_write: Duration,
50    pub egress: Duration,
51    pub frame_bytes: usize,
52    pub writer_queue_depth: usize,
53    pub writer_active_at_enqueue: bool,
54    pub writer_queue_was_full: bool,
55    pub writer_reserve_timeouts: u32,
56    pub total: Duration,
57}
58
59impl PhaseTrace {
60    pub fn new(frame_decoded: Instant) -> Self {
61        Self {
62            frame_decoded,
63            executor_submitted: None,
64            job_admitted: None,
65            translate_done: None,
66            execute_done: None,
67            format_done: None,
68            finalize_done: None,
69        }
70    }
71
72    pub fn mark_executor_submitted(&mut self) {
73        self.executor_submitted = Some(Instant::now());
74    }
75
76    pub fn mark_job_admitted(&mut self) {
77        self.job_admitted = Some(Instant::now());
78    }
79
80    fn mark_translate_done(&mut self) {
81        self.translate_done = Some(Instant::now());
82    }
83
84    fn mark_execute_done(&mut self) {
85        self.execute_done = Some(Instant::now());
86    }
87
88    fn mark_format_done(&mut self) {
89        self.format_done = Some(Instant::now());
90    }
91
92    fn mark_finalize_done(&mut self) {
93        self.finalize_done = Some(Instant::now());
94    }
95
96    pub fn finish(self, egress: ToolCallEgressTiming) -> Option<ToolCallPhaseDurations> {
97        let executor_submitted = self.executor_submitted?;
98        let job_admitted = self.job_admitted?;
99        let translate_done = self.translate_done?;
100        let execute_done = self.execute_done?;
101        let format_done = self.format_done?;
102        let finalize_done = self.finalize_done?;
103        Some(ToolCallPhaseDurations {
104            queue: job_admitted.duration_since(executor_submitted),
105            translate: translate_done.duration_since(job_admitted),
106            execute: execute_done.duration_since(translate_done),
107            format: format_done.duration_since(execute_done),
108            finalize: finalize_done.duration_since(format_done),
109            egress_enqueue: egress.enqueued.duration_since(finalize_done),
110            egress_queue: egress.dequeued.duration_since(egress.enqueued),
111            egress_prepare: egress.write_started.duration_since(egress.dequeued),
112            egress_write: egress.write_finished.duration_since(egress.write_started),
113            egress: egress.write_finished.duration_since(finalize_done),
114            frame_bytes: egress.frame_bytes,
115            writer_queue_depth: egress.queue_depth,
116            writer_active_at_enqueue: egress.writer_active_at_enqueue,
117            writer_queue_was_full: egress.writer_queue_was_full,
118            writer_reserve_timeouts: egress.reserve_timeouts,
119            total: egress.write_finished.duration_since(self.frame_decoded),
120        })
121    }
122}
123
124/// The full result of a tool call: the COMPLETE dispatch Response carried VERBATIM,
125/// plus the server-rendered agent-facing text (what the deleted TS formatters used to produce).
126/// Oracle #1: carry the WHOLE Response — promote nothing, drop nothing (preview_diff, attachments,
127/// status_bar, bg_completions, lsp_diagnostics, code, message, candidates, … all ride inside `response`).
128#[derive(Debug)]
129pub struct ToolCallResult {
130    pub text: String,
131    pub response: crate::protocol::Response,
132}
133
134/// Reserve a discriminated seam so bash/PTY/streaming (P3) doesn't force a signature rewrite.
135/// Only `Unary` is constructed today. Do NOT build `Stream`.
136#[derive(Debug)]
137pub enum ToolCallOutcome {
138    Unary(ToolCallResult),
139}
140
141/// Server-owned settings for a single `tool_call` request.
142/// These fields cannot be supplied through the agent's arguments object.
143#[derive(Debug, Clone)]
144pub struct ToolCallContext {
145    pub project_root: PathBuf,
146    pub session_id: Option<String>,
147    pub request_id: String,
148    pub diagnostics_on_edit: bool,
149    pub preview: bool,
150}
151
152pub fn run_tool_call(
153    bare_name: &str,
154    args: &Value,
155    format_context: &crate::subc_format::FormatContext,
156    ctx: &ToolCallContext,
157    app_ctx: &AppContext,
158    dispatch: &DispatchFn<'_>,
159    finalizer: Option<&FinalizeFn<'_>>,
160    mut phase_trace: Option<&mut PhaseTrace>,
161) -> ToolCallOutcome {
162    let sanitized_args = strip_agent_preview_arg(args);
163    let translate_context = crate::subc_translate::TranslateContext {
164        diagnostics_on_edit: ctx.diagnostics_on_edit,
165        preview: ctx.preview,
166    };
167    let (command, translated_args) = match crate::subc_translate::subc_translate_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        // Return validation errors from the translation step immediately. Only
175        // the special unsupported_tool error can fall through, allowing native
176        // NDJSON commands such as configure/undo to reach dispatch unchanged.
177        Err(err) if err.code != "unsupported_tool" => {
178            if let Some(trace) = phase_trace.as_mut() {
179                trace.mark_translate_done();
180                trace.mark_execute_done();
181            }
182            let response = Response::error(ctx.request_id.clone(), err.code, err.message);
183            let result = tool_call_result_from_response(bare_name, format_context, response);
184            if let Some(trace) = phase_trace.as_mut() {
185                trace.mark_format_done();
186                trace.mark_finalize_done();
187            }
188            return ToolCallOutcome::Unary(result);
189        }
190        Err(_) => {
191            let map = sanitized_args.as_object().cloned().unwrap_or_default();
192            (bare_name.to_string(), map)
193        }
194    };
195
196    let mut map = translated_args;
197    if ctx.preview {
198        map.insert("preview".to_string(), json!(true));
199    }
200    map.insert("id".to_string(), json!(ctx.request_id.clone()));
201    map.insert("command".to_string(), json!(command));
202    map.insert("session_id".to_string(), json!(ctx.session_id.clone()));
203
204    let raw_req = match serde_json::from_value::<RawRequest>(Value::Object(map)) {
205        Ok(req) => req,
206        Err(error) => {
207            if let Some(trace) = phase_trace.as_mut() {
208                trace.mark_translate_done();
209                trace.mark_execute_done();
210            }
211            let response = Response::error(
212                ctx.request_id.clone(),
213                "invalid_request",
214                format!("failed to build request from tool call: {error}"),
215            );
216            let result = tool_call_result_from_response(bare_name, format_context, response);
217            if let Some(trace) = phase_trace.as_mut() {
218                trace.mark_format_done();
219                trace.mark_finalize_done();
220            }
221            return ToolCallOutcome::Unary(result);
222        }
223    };
224    if let Some(trace) = phase_trace.as_mut() {
225        trace.mark_translate_done();
226    }
227
228    let mut response = dispatch(raw_req, app_ctx);
229    if let Some(trace) = phase_trace.as_mut() {
230        trace.mark_execute_done();
231    }
232    let text =
233        crate::subc_format::format_response_with_context(bare_name, &response, format_context);
234    if let Some(trace) = phase_trace.as_mut() {
235        trace.mark_format_done();
236    }
237    if let Some(finalizer) = finalizer {
238        finalizer(&mut response);
239    }
240    if let Some(trace) = phase_trace.as_mut() {
241        trace.mark_finalize_done();
242    }
243
244    ToolCallOutcome::Unary(ToolCallResult { text, response })
245}
246
247pub(crate) fn strip_agent_preview_arg(args: &Value) -> Cow<'_, Value> {
248    let Some(map) = args.as_object() else {
249        return Cow::Borrowed(args);
250    };
251    if !map.contains_key("preview") {
252        return Cow::Borrowed(args);
253    }
254
255    let mut sanitized = map.clone();
256    sanitized.remove("preview");
257    Cow::Owned(Value::Object(sanitized))
258}
259
260pub(crate) fn strip_agent_preview_arg_owned(mut args: Value) -> Value {
261    if let Some(map) = args.as_object_mut() {
262        map.remove("preview");
263    }
264    args
265}
266
267fn tool_call_result_from_response(
268    bare_name: &str,
269    format_context: &crate::subc_format::FormatContext,
270    response: Response,
271) -> ToolCallResult {
272    let text =
273        crate::subc_format::format_response_with_context(bare_name, &response, format_context);
274    ToolCallResult { text, response }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn phase_trace_reports_execution_and_writer_egress_subphases() {
283        let t0 = Instant::now();
284        let trace = PhaseTrace {
285            frame_decoded: t0,
286            executor_submitted: Some(t0 + Duration::from_millis(1)),
287            job_admitted: Some(t0 + Duration::from_millis(3)),
288            translate_done: Some(t0 + Duration::from_millis(6)),
289            execute_done: Some(t0 + Duration::from_millis(10)),
290            format_done: Some(t0 + Duration::from_millis(15)),
291            finalize_done: Some(t0 + Duration::from_millis(21)),
292        };
293
294        let phases = trace
295            .finish(ToolCallEgressTiming {
296                enqueued: t0 + Duration::from_millis(28),
297                dequeued: t0 + Duration::from_millis(35),
298                write_started: t0 + Duration::from_millis(37),
299                write_finished: t0 + Duration::from_millis(48),
300                frame_bytes: 262_144,
301                queue_depth: 17,
302                writer_active_at_enqueue: true,
303                writer_queue_was_full: true,
304                reserve_timeouts: 2,
305            })
306            .unwrap();
307
308        assert_eq!(phases.queue, Duration::from_millis(2));
309        assert_eq!(phases.translate, Duration::from_millis(3));
310        assert_eq!(phases.execute, Duration::from_millis(4));
311        assert_eq!(phases.format, Duration::from_millis(5));
312        assert_eq!(phases.finalize, Duration::from_millis(6));
313        assert_eq!(phases.egress_enqueue, Duration::from_millis(7));
314        assert_eq!(phases.egress_queue, Duration::from_millis(7));
315        assert_eq!(phases.egress_prepare, Duration::from_millis(2));
316        assert_eq!(phases.egress_write, Duration::from_millis(11));
317        assert_eq!(phases.egress, Duration::from_millis(27));
318        assert_eq!(phases.frame_bytes, 262_144);
319        assert_eq!(phases.writer_queue_depth, 17);
320        assert!(phases.writer_active_at_enqueue);
321        assert!(phases.writer_queue_was_full);
322        assert_eq!(phases.writer_reserve_timeouts, 2);
323        assert_eq!(phases.total, Duration::from_millis(48));
324    }
325}