Skip to main content

agentic_core/executor/
engine.rs

1//! Stateful conversation executor.
2//!
3//! Exposes each step of the conversation pipeline as a public function so consumers
4//! can compose them directly (e.g. as Praxis filters). [`ExecuteRequest`] is the
5//! primary entry point; [`execute`] is a convenience shim for callers that don't
6//! need per-request configuration.
7
8use std::sync::Arc;
9
10use async_stream::stream;
11use either::Either;
12use tokio::sync::mpsc;
13use tracing::debug;
14
15use super::compaction::maybe_compact_context;
16use super::gateway::{
17    GatewayCallResult, LoopDecision, append_gateway_calls_to_new_input, append_output_items_to_input,
18    append_tool_outputs, classify_round, complete_gateway_event_plans, emit_gateway_completed_events,
19    emit_gateway_start_events, execute_and_emit_output_calls, execute_output_calls, gateway_event_plans,
20    has_client_owned_calls, is_client_custom_call, is_gateway_owned_call, public_output_items,
21};
22use super::gateway_accumulator::{GatewayStreamAccumulator, StreamEvent, error_sse_chunk};
23use crate::events::EventFrame;
24use crate::executor::error::ExecutorResult;
25use crate::executor::inference::DONE_MARKER;
26use crate::executor::persist::persist_if_needed;
27use crate::executor::rehydrate::rehydrate_conversation;
28use crate::executor::request::{ExecutionContext, RequestContext};
29use crate::executor::upstream::{emit_deferred_stream_events, fetch_blocking_payload, fetch_stream_payload};
30use crate::tool::{ToolRegistry, mcp};
31use crate::types::io::{OutputItem, ResponseUsage, ToolChoice};
32use crate::types::request_response::{IncompleteDetails, RequestPayload, ResponsePayload};
33
34pub use crate::executor::inference::BoxStream;
35
36const MAX_GATEWAY_TOOL_ROUNDS: usize = 10;
37
38fn add_usage(total: ResponseUsage, usage: ResponseUsage) -> ResponseUsage {
39    ResponseUsage {
40        input_tokens: total.input_tokens.saturating_add(usage.input_tokens),
41        output_tokens: total.output_tokens.saturating_add(usage.output_tokens),
42        total_tokens: total.total_tokens.saturating_add(usage.total_tokens),
43        input_tokens_details: crate::types::io::InputTokenDetails {
44            cached_tokens: total
45                .input_tokens_details
46                .cached_tokens
47                .saturating_add(usage.input_tokens_details.cached_tokens),
48        },
49        output_tokens_details: crate::types::io::OutputTokenDetails {
50            reasoning_tokens: total
51                .output_tokens_details
52                .reasoning_tokens
53                .saturating_add(usage.output_tokens_details.reasoning_tokens),
54        },
55    }
56}
57
58fn accumulate_usage(total: &mut Option<ResponseUsage>, usage: Option<ResponseUsage>) {
59    if let Some(usage) = usage {
60        *total = Some(total.map_or(usage, |current| add_usage(current, usage)));
61    }
62}
63
64struct AbortOnDrop<T> {
65    handle: tokio::task::JoinHandle<T>,
66}
67
68impl<T> AbortOnDrop<T> {
69    fn new(handle: tokio::task::JoinHandle<T>) -> Self {
70        Self { handle }
71    }
72}
73
74impl<T> std::ops::Deref for AbortOnDrop<T> {
75    type Target = tokio::task::JoinHandle<T>;
76
77    fn deref(&self) -> &Self::Target {
78        &self.handle
79    }
80}
81
82impl<T> std::ops::DerefMut for AbortOnDrop<T> {
83    fn deref_mut(&mut self) -> &mut Self::Target {
84        &mut self.handle
85    }
86}
87
88impl<T> Drop for AbortOnDrop<T> {
89    fn drop(&mut self) {
90        if !self.handle.is_finished() {
91            self.handle.abort();
92        }
93    }
94}
95
96async fn run_until_gateway_tools_complete(
97    mut ctx: RequestContext,
98    exec_ctx: &ExecutionContext,
99    auth: Option<&str>,
100    stream_upstream: bool,
101    mut stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender<StreamEvent>)>,
102) -> ExecutorResult<(ResponsePayload, RequestContext)> {
103    let mut executors = exec_ctx.gateway_executors.request_scoped();
104    let registry: ToolRegistry = match ctx.enriched_request.tools.as_mut() {
105        Some(tools) => ToolRegistry::build_with_handlers(tools, &mut executors).await?,
106        None => ToolRegistry::default(),
107    };
108    let mut combined_output: Vec<OutputItem> = registry
109        .mcp_list_tools_items()
110        .iter()
111        .map(mcp::handler::list_tools_output_item)
112        .collect();
113    let mut combined_usage = None;
114
115    for round in 0..MAX_GATEWAY_TOOL_ROUNDS {
116        let compaction_usage = maybe_compact_context(&mut ctx, exec_ctx, auth).await?;
117        accumulate_usage(&mut combined_usage, compaction_usage);
118        let output_offset = combined_output.len();
119        let (mut payload, deferred_stream_events): (ResponsePayload, Vec<_>) = if stream_upstream {
120            let stream_payload = fetch_stream_payload(
121                &ctx,
122                exec_ctx,
123                auth,
124                &registry,
125                stream
126                    .as_mut()
127                    .map(|(accumulator, sender)| (&mut **accumulator, *sender)),
128                output_offset,
129            )
130            .await?;
131            (stream_payload.payload, stream_payload.deferred_events)
132        } else {
133            (fetch_blocking_payload(&ctx, exec_ctx, auth).await?, Vec::new())
134        };
135        registry.restore_final_payload_output(&mut payload.output);
136        accumulate_usage(&mut combined_usage, payload.usage.take());
137        let current_output = std::mem::take(&mut payload.output);
138        for item in &current_output {
139            if let OutputItem::CustomToolCall(call) = item {
140                debug!(
141                    response_id = %ctx.response_id,
142                    call_id = %call.call_id,
143                    name = %call.name,
144                    input_bytes = call.input.len(),
145                    "custom tool call requires client execution"
146                );
147            }
148        }
149        let has_client_owned = has_client_owned_calls(&current_output, &registry);
150        let gateway_results = execute_and_emit_round_output_calls(
151            &current_output,
152            &registry,
153            output_offset,
154            deferred_stream_events,
155            &ctx,
156            stream
157                .as_mut()
158                .map(|(accumulator, sender)| (&mut **accumulator, *sender)),
159        )
160        .await?;
161        let public_output = public_output_items(&current_output, &registry, &gateway_results);
162        combined_output.extend(public_output);
163
164        match classify_round(has_client_owned, &gateway_results, round, MAX_GATEWAY_TOOL_ROUNDS) {
165            // Client-owned calls (plain function or Codex namespace tools) are
166            // handed back to the caller. Gateway calls in the same turn are
167            // still recorded so the returned conversation is complete.
168            LoopDecision::RequiresClientAction => {
169                append_gateway_calls_to_new_input(&mut ctx, &current_output, &registry);
170                append_tool_outputs(
171                    &mut ctx,
172                    gateway_results.into_iter().map(|result| result.input_item).collect(),
173                );
174                finalize_loop(&mut payload, combined_output, combined_usage, &ctx);
175                return Ok((payload, ctx));
176            }
177            // No gateway work remains — this turn is the final response.
178            LoopDecision::Done => {
179                finalize_loop(&mut payload, combined_output, combined_usage, &ctx);
180                return Ok((payload, ctx));
181            }
182            // Budget exhausted while the model was still requesting gateway
183            // tools: surface the accumulated work as a partial
184            // `status: "incomplete"` response instead of failing the request.
185            // The final round's gateway calls and outputs are recorded so a
186            // continuation is not fed a dangling tool call.
187            LoopDecision::Incomplete(reason) => {
188                append_gateway_calls_to_new_input(&mut ctx, &current_output, &registry);
189                append_tool_outputs(
190                    &mut ctx,
191                    gateway_results.into_iter().map(|result| result.input_item).collect(),
192                );
193                finalize_loop(&mut payload, combined_output, combined_usage, &ctx);
194                "incomplete".clone_into(&mut payload.status);
195                payload.incomplete_details = Some(IncompleteDetails { reason: Some(reason) });
196                return Ok((payload, ctx));
197            }
198            // Gateway tools ran and rounds remain; feed outputs back and loop.
199            LoopDecision::Continue => {
200                ctx.enriched_request.tool_choice = Some(ToolChoice::Auto);
201                append_output_items_to_input(&mut ctx.enriched_request.input, &current_output);
202                append_gateway_calls_to_new_input(&mut ctx, &current_output, &registry);
203                append_tool_outputs(
204                    &mut ctx,
205                    gateway_results.into_iter().map(|result| result.input_item).collect(),
206                );
207            }
208        }
209    }
210
211    unreachable!("the final round returns Done, RequiresClientAction, or Incomplete");
212}
213
214async fn execute_and_emit_round_output_calls(
215    output_items: &[OutputItem],
216    registry: &ToolRegistry,
217    output_offset: usize,
218    deferred_events: Vec<EventFrame>,
219    ctx: &RequestContext,
220    stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender<StreamEvent>)>,
221) -> ExecutorResult<Vec<GatewayCallResult>> {
222    match (deferred_events.is_empty(), stream) {
223        (true, stream) => execute_and_emit_output_calls(output_items, registry, output_offset, stream).await,
224        (false, Some((stream_accumulator, stream_sender))) => {
225            execute_and_emit_ordered_output_calls(
226                output_items,
227                registry,
228                output_offset,
229                deferred_events,
230                ctx,
231                stream_accumulator,
232                stream_sender,
233            )
234            .await
235        }
236        (false, None) => execute_and_emit_output_calls(output_items, registry, output_offset, None).await,
237    }
238}
239
240async fn execute_and_emit_ordered_output_calls(
241    output_items: &[OutputItem],
242    registry: &ToolRegistry,
243    output_offset: usize,
244    deferred_events: Vec<EventFrame>,
245    ctx: &RequestContext,
246    stream_accumulator: &mut GatewayStreamAccumulator,
247    stream_sender: &mpsc::UnboundedSender<StreamEvent>,
248) -> ExecutorResult<Vec<GatewayCallResult>> {
249    let mut events_by_output = Vec::with_capacity(output_items.len());
250    events_by_output.resize_with(output_items.len(), Vec::new);
251    let mut remaining_events = Vec::new();
252    for frame in deferred_events {
253        let Some(output_index) = frame
254            .wire
255            .output_index
256            .and_then(|index| usize::try_from(index).ok())
257            .filter(|index| *index < events_by_output.len())
258        else {
259            remaining_events.push(frame);
260            continue;
261        };
262        events_by_output[output_index].push(frame);
263    }
264
265    let mut event_plans = gateway_event_plans(output_items, registry, output_offset);
266    let first_gateway_index = output_items
267        .iter()
268        .position(|item| matches!(item, OutputItem::FunctionCall(call) if is_gateway_owned_call(call, registry)));
269    let first_gateway_run_end = first_gateway_index
270        .filter(|start| {
271            !output_items[..*start]
272                .iter()
273                .any(|item| matches!(item, OutputItem::FunctionCall(call) if is_client_custom_call(call, registry)))
274        })
275        .map_or(0, |start| {
276            output_items[start..]
277                .iter()
278                .take_while(
279                    |item| matches!(item, OutputItem::FunctionCall(call) if is_gateway_owned_call(call, registry)),
280                )
281                .count()
282                .saturating_add(start)
283        });
284    let first_gateway_run_len = first_gateway_run_end.saturating_sub(first_gateway_index.unwrap_or(0));
285    emit_gateway_start_events(&event_plans[..first_gateway_run_len], stream_accumulator, stream_sender)?;
286
287    let gateway_results = execute_output_calls(output_items, registry).await?;
288    complete_gateway_event_plans(&mut event_plans, &gateway_results);
289    let mut gateway_index = 0;
290    for (index, item) in output_items.iter().enumerate() {
291        if matches!(item, OutputItem::FunctionCall(call) if is_gateway_owned_call(call, registry)) {
292            let plan = &event_plans[gateway_index..=gateway_index];
293            let result = &gateway_results[gateway_index..=gateway_index];
294            if index >= first_gateway_run_end {
295                emit_gateway_start_events(plan, stream_accumulator, stream_sender)?;
296            }
297            emit_gateway_completed_events(result, plan, stream_accumulator, stream_sender)?;
298            emit_deferred_stream_events(
299                std::mem::take(&mut events_by_output[index]),
300                ctx,
301                registry,
302                stream_accumulator,
303                stream_sender,
304                output_offset,
305            )?;
306            gateway_index += 1;
307        } else {
308            emit_deferred_stream_events(
309                std::mem::take(&mut events_by_output[index]),
310                ctx,
311                registry,
312                stream_accumulator,
313                stream_sender,
314                output_offset,
315            )?;
316        }
317    }
318    emit_deferred_stream_events(
319        remaining_events,
320        ctx,
321        registry,
322        stream_accumulator,
323        stream_sender,
324        output_offset,
325    )?;
326    Ok(gateway_results)
327}
328
329/// Move accumulated output/usage onto the terminating round's payload and
330/// inject the response/conversation IDs. The payload's `model`/`created_at`/
331/// `status` from the latest inference turn are preserved.
332fn finalize_loop(
333    payload: &mut ResponsePayload,
334    combined_output: Vec<crate::types::io::OutputItem>,
335    combined_usage: Option<ResponseUsage>,
336    ctx: &RequestContext,
337) {
338    payload.output = combined_output;
339    payload.usage = combined_usage;
340    ctx.inject_ids(payload);
341}
342
343async fn run_blocking(
344    ctx: RequestContext,
345    exec_ctx: &ExecutionContext,
346    auth: Option<&str>,
347) -> ExecutorResult<ResponsePayload> {
348    let (payload, ctx) = run_until_gateway_tools_complete(ctx, exec_ctx, auth, false, None).await?;
349
350    let ch = exec_ctx.conv_handler.clone();
351    let rh = exec_ctx.resp_handler.clone();
352    persist_if_needed(payload.clone(), ctx, ch, rh).await?;
353
354    Ok(payload)
355}
356
357fn run_stream(ctx: RequestContext, exec_ctx: Arc<ExecutionContext>, auth: Option<String>) -> BoxStream {
358    Box::pin(stream! {
359        let (event_tx, mut event_rx) = mpsc::unbounded_channel();
360        let exec_ctx_for_run = Arc::clone(&exec_ctx);
361        let event_tx_for_run = event_tx.clone();
362        let stream_accumulator = GatewayStreamAccumulator::new();
363        let mut run_handle = AbortOnDrop::new(tokio::spawn(async move {
364            let mut stream_accumulator = stream_accumulator;
365            let result = run_until_gateway_tools_complete(
366                ctx,
367                exec_ctx_for_run.as_ref(),
368                auth.as_deref(),
369                true,
370                Some((&mut stream_accumulator, &event_tx_for_run)),
371            )
372            .await;
373            (result, stream_accumulator)
374        }));
375
376        let mut next_sequence_number = 0;
377        loop {
378            tokio::select! {
379                Some(event) = event_rx.recv() => {
380                    yield consume_stream_event(event, &mut next_sequence_number);
381                }
382                result = &mut run_handle.handle => {
383                    match result {
384                        Err(e) => {
385                            for chunk in panicked_stream_chunks(&e, &mut event_rx, &mut next_sequence_number) {
386                                yield chunk;
387                            }
388                        }
389                        Ok((Err(e), mut stream_accumulator)) => {
390                            while let Ok(event) = event_rx.try_recv() {
391                                yield consume_stream_event(event, &mut next_sequence_number);
392                            }
393                            yield stream_accumulator.executor_error_chunk(&e);
394                            yield DONE_MARKER.to_string();
395                        }
396                        Ok((Ok((payload, ctx)), mut stream_accumulator)) => {
397                            while let Ok(event) = event_rx.try_recv() {
398                                yield consume_stream_event(event, &mut next_sequence_number);
399                            }
400                            // Codex may close its WebSocket as soon as it receives
401                            // `response.completed`. Persist before exposing that
402                            // event so a custom call/output continuation cannot be
403                            // cancelled by the client disconnect.
404                            let ch = exec_ctx.conv_handler.clone();
405                            let rh = exec_ctx.resp_handler.clone();
406                            let mut terminal_accumulator = stream_accumulator.clone();
407                            let terminal_chunk = terminal_accumulator.terminal_response_chunk(&payload);
408                            match persist_if_needed(payload, ctx, ch, rh).await {
409                                Ok(()) => match terminal_chunk {
410                                    Ok(chunk) => yield chunk,
411                                    Err(e) => yield stream_accumulator.executor_error_chunk(&e),
412                                },
413                                Err(e) => yield stream_accumulator.executor_error_chunk(&e),
414                            }
415                            yield DONE_MARKER.to_string();
416                        }
417                    }
418                    break;
419                }
420            }
421        }
422    })
423}
424
425fn consume_stream_event(event: StreamEvent, next_sequence_number: &mut u64) -> String {
426    *next_sequence_number = event.sequence_number.saturating_add(1);
427    event.content
428}
429
430fn stream_task_failure_chunk(error: &tokio::task::JoinError, sequence_number: u64) -> String {
431    error_sse_chunk(&format!("stream task failed: {error}"), sequence_number)
432}
433
434fn panicked_stream_chunks(
435    error: &tokio::task::JoinError,
436    event_rx: &mut mpsc::UnboundedReceiver<StreamEvent>,
437    next_sequence_number: &mut u64,
438) -> Vec<String> {
439    let mut chunks = Vec::new();
440    while let Ok(event) = event_rx.try_recv() {
441        chunks.push(consume_stream_event(event, next_sequence_number));
442    }
443    chunks.push(stream_task_failure_chunk(error, *next_sequence_number));
444    chunks.push(DONE_MARKER.to_owned());
445    chunks
446}
447
448/// Create a new conversation and return its data.
449///
450/// Exposes the conversation-creation step as a standalone function so callers
451/// (e.g. `agentic-server`, Praxis filters, or tests) can pre-create a
452/// conversation before submitting response turns.
453///
454/// # Errors
455/// Returns [`ExecutorError`] if the conversation store is unavailable.
456pub async fn create_conversation(exec_ctx: &ExecutionContext) -> ExecutorResult<crate::ConversationData> {
457    exec_ctx.conv_handler.create().await
458}
459
460/// Builder for a stateful conversation turn.
461///
462/// ```ignore
463/// ExecuteRequest::new(payload, exec_ctx).with_auth(token).run().await
464/// ```
465pub struct ExecuteRequest {
466    payload: RequestPayload,
467    exec_ctx: Arc<ExecutionContext>,
468    client_auth: Option<String>,
469}
470
471impl ExecuteRequest {
472    #[must_use]
473    pub fn new(payload: RequestPayload, exec_ctx: Arc<ExecutionContext>) -> Self {
474        Self {
475            payload,
476            exec_ctx,
477            client_auth: None,
478        }
479    }
480
481    /// Override the bearer token for this request only; does not touch the shared [`ExecutionContext`].
482    #[must_use]
483    pub fn with_auth(mut self, token: Option<String>) -> Self {
484        self.client_auth = token;
485        self
486    }
487
488    /// Execute one stateful conversation turn.
489    ///
490    /// Returns `Either::Left(ResponsePayload)` for non-streaming requests, or
491    /// `Either::Right(BoxStream)` for streaming, where each yielded `String` is
492    /// a complete SSE frame ready to forward to the client.
493    ///
494    /// # Errors
495    /// Returns [`ExecutorError`] if rehydration or (non-streaming) LLM inference fails.
496    pub async fn run(self) -> ExecutorResult<Either<ResponsePayload, BoxStream>> {
497        debug!(
498            model = %self.payload.model,
499            store = self.payload.store,
500            stream = self.payload.stream,
501            has_previous_response_id = self.payload.previous_response_id.is_some(),
502            has_conversation_id = self.payload.conversation_id.is_some(),
503            tools = self.payload.tools.as_ref().map_or(0, Vec::len),
504            "executor received responses request"
505        );
506        let ctx = rehydrate_conversation(self.payload, &self.exec_ctx).await?;
507        if ctx.original_request.stream {
508            Ok(Either::Right(run_stream(ctx, self.exec_ctx, self.client_auth)))
509        } else {
510            Ok(Either::Left(
511                run_blocking(ctx, &self.exec_ctx, self.client_auth.as_deref()).await?,
512            ))
513        }
514    }
515}
516
517/// Execute one stateful conversation turn.
518///
519/// Thin shim over [`ExecuteRequest`] for callers that don't need per-request auth override.
520///
521/// # Errors
522/// Returns [`ExecutorError`] if rehydration or (non-streaming) LLM inference fails.
523pub async fn execute(
524    request: RequestPayload,
525    exec_ctx: Arc<ExecutionContext>,
526) -> ExecutorResult<Either<ResponsePayload, BoxStream>> {
527    ExecuteRequest::new(request, exec_ctx).run().await
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[tokio::test]
535    async fn stream_task_panic_after_event_uses_next_sequence_number_for_error() {
536        let accumulator = GatewayStreamAccumulator::new();
537        let (event_tx, mut event_rx) = mpsc::unbounded_channel();
538        let task = tokio::spawn(async move {
539            let mut accumulator = accumulator;
540            let event = accumulator
541                .process_sse_line(r#"data: {"type":"response.created"}"#, 0)
542                .expect("event should be emitted");
543            event_tx
544                .send(StreamEvent {
545                    content: "event".to_owned(),
546                    sequence_number: event.sequence_number().expect("event should be numbered"),
547                })
548                .expect("test receiver should remain open");
549            panic!("test task panic");
550        });
551
552        let error = task.await.expect_err("task should panic");
553        let mut next_sequence_number = 0;
554        let chunks = panicked_stream_chunks(&error, &mut event_rx, &mut next_sequence_number);
555        let mut error_lines = chunks[1].lines();
556        assert_eq!(error_lines.next(), Some("event: error"));
557        let error_data = error_lines
558            .next()
559            .and_then(|line| line.strip_prefix("data: "))
560            .expect("SSE data");
561        assert!(error_lines.all(str::is_empty), "unexpected SSE frame content");
562        let error_event: serde_json::Value =
563            serde_json::from_str(error_data).expect("error chunk should be valid JSON");
564
565        assert_eq!(chunks[0], "event");
566        assert_eq!(error_event["type"], "error");
567        assert_eq!(error_event["sequence_number"], 1);
568        assert_eq!(chunks[2], DONE_MARKER);
569    }
570}