Skip to main content

agentyk_engine/
in_process.rs

1//! The in-process host for the canonical turn engine.
2
3use std::sync::Arc;
4
5use agentyk_core::atoms;
6use agentyk_core::cancellation::CancellationToken;
7use agentyk_core::concurrency::join_all;
8use agentyk_core::driver::DeltaSink;
9use agentyk_core::error::{Error, Result};
10use agentyk_core::event::{EventData, EventListener, EventRequest, notify_event_listeners};
11use agentyk_core::id::{EventId, MessageId, SessionId, TurnId};
12use agentyk_core::message::Message;
13use agentyk_core::tool::{ToolContext, ToolOutput, ToolProgress, ToolProgressSink};
14use agentyk_core::turn::{TurnOutcome, TurnState};
15use async_trait::async_trait;
16use chrono::Utc;
17
18use crate::engine::{TurnEngine, TurnOperation};
19use crate::host::{TurnHost, TurnResult};
20
21/// Turns a running tool's progress reports into ephemeral `tool.progress`
22/// events for the session's listeners.
23///
24/// It fans out to listeners directly rather than going through
25/// [`TurnHost::record`], because the host is exclusively borrowed by the turn
26/// while the tool runs. That is sound precisely because the event is
27/// ephemeral: nothing is appended to the log and nothing is folded into
28/// history, so there is no ordering or sequencing to coordinate — the same
29/// reasoning that lets streaming deltas bypass storage.
30struct ListenerProgressSink {
31    listeners: Vec<Arc<dyn EventListener>>,
32    session_id: SessionId,
33    turn_id: TurnId,
34    call_id: String,
35    name: String,
36}
37
38#[async_trait]
39impl ToolProgressSink for ListenerProgressSink {
40    async fn progress(&self, progress: ToolProgress) {
41        if self.listeners.is_empty() {
42            return;
43        }
44        let data = EventData::ToolProgress {
45            call_id: self.call_id.clone(),
46            name: self.name.clone(),
47            progress,
48        };
49        let event = EventRequest::with_turn(self.session_id, self.turn_id, data)
50            .into_ephemeral_event(EventId::new(), Utc::now());
51        notify_event_listeners(&self.listeners, &event).await;
52    }
53}
54
55struct RecordingDeltaSink<'h, 'a> {
56    host: &'h mut TurnHost<'a>,
57    turn_id: TurnId,
58    message_id: MessageId,
59    cancellation: CancellationToken,
60}
61
62#[async_trait]
63impl DeltaSink for RecordingDeltaSink<'_, '_> {
64    async fn delta(&mut self, delta: &str, accumulated: &str) -> Result<()> {
65        if self.cancellation.is_cancelled() {
66            return Err(Error::Cancelled);
67        }
68        self.host
69            .record(
70                self.turn_id,
71                vec![EventData::OutputMessageDelta {
72                    message_id: self.message_id,
73                    delta: delta.to_string(),
74                    accumulated: accumulated.to_string(),
75                }],
76            )
77            .await
78    }
79}
80
81/// Executes every operation from [`TurnEngine`] immediately in the caller's
82/// async task.
83///
84/// A prepared tool batch runs **concurrently** by default: when a model asks
85/// for three file reads at once it means all three, and running them one after
86/// another turns a model's parallelism into a host's latency. Results are
87/// still recorded in batch order, so the event sequence is identical either
88/// way — see [`InProcessExecutor::sequential`] for when to give that up.
89#[derive(Debug, Clone, Copy)]
90pub struct InProcessExecutor {
91    concurrent: bool,
92}
93
94impl Default for InProcessExecutor {
95    fn default() -> Self {
96        Self { concurrent: true }
97    }
98}
99
100impl InProcessExecutor {
101    /// The default: a prepared batch runs concurrently.
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Run a batch one call at a time.
107    ///
108    /// The one thing this buys is policy *inside* a batch: budget checks and
109    /// cancellation are evaluated between calls, so a batch of ten can stop
110    /// after two. Concurrent dispatch necessarily commits to the whole batch
111    /// (cancellation still drops all of it at once). Choose this when tools
112    /// contend for the same resource, or when a budget must bite mid-batch.
113    pub fn sequential() -> Self {
114        Self { concurrent: false }
115    }
116}
117
118impl InProcessExecutor {
119    /// Run one turn by immediately executing every prepared engine operation.
120    pub async fn run_turn(&self, host: &mut TurnHost<'_>, input: Message) -> Result<TurnResult> {
121        let engine = TurnEngine;
122        let started = engine.start(host, input).await;
123        let turn_id = started.state.turn_id;
124        host.record(turn_id, started.events).await?;
125        if let Some(rejection) = started.rejection {
126            let warnings = engine
127                .finish(
128                    host,
129                    turn_id,
130                    serde_json::json!({
131                        "success": false,
132                        "error": rejection.reason,
133                    }),
134                )
135                .await;
136            host.record(turn_id, warnings).await?;
137            return Err(Error::HookBlocked {
138                reason: rejection.reason,
139                user_message: rejection.user_message,
140            });
141        }
142        self.resume_turn(host, started.state).await
143    }
144
145    /// Continue a replayed, incomplete turn from its current durable head.
146    ///
147    /// External operations are at-least-once: if a process failed after a tool
148    /// performed its side effect but before `tool.completed` was recorded,
149    /// recovery invokes that tool again. Hosts that need stronger guarantees
150    /// must give tools idempotency keys or deduplicate at the activity boundary.
151    pub async fn resume_turn(
152        &self,
153        host: &mut TurnHost<'_>,
154        state: TurnState,
155    ) -> Result<TurnResult> {
156        let turn_id = state.turn_id;
157        let result = self.resume_turn_inner(host, state).await;
158        let data = match &result {
159            Ok(result) => serde_json::json!({
160                "success": true,
161                "response": result.response,
162                "tool_calls": result.tool_calls,
163                "iterations": result.iterations,
164            }),
165            Err(error) => serde_json::json!({
166                "success": false,
167                "error": error.to_string(),
168            }),
169        };
170        let warnings = TurnEngine.finish(host, turn_id, data).await;
171        host.record(turn_id, warnings).await?;
172        result
173    }
174
175    async fn resume_turn_inner(
176        &self,
177        host: &mut TurnHost<'_>,
178        mut state: TurnState,
179    ) -> Result<TurnResult> {
180        if state.session_id != host.session_id {
181            return Err(Error::Other(format!(
182                "turn belongs to {}, not session {}",
183                state.session_id, host.session_id
184            )));
185        }
186        let engine = TurnEngine;
187        let assembled = engine.assemble(host).await?;
188        let driver = host
189            .environment
190            .drivers
191            .get(&host.model.driver)
192            .ok_or_else(|| Error::UnknownDriver(host.model.driver.to_string()))?;
193        let turn_id = state.turn_id;
194
195        loop {
196            let step = engine.prepare(host, &assembled, &mut state).await?;
197            host.record(turn_id, step.events).await?;
198            match step.operation {
199                TurnOperation::InvokeModel { request } => {
200                    let cancellation = host.cancellation.clone();
201                    let message_id = state.current_message_id.unwrap_or_default();
202                    let mut sink = RecordingDeltaSink {
203                        host: &mut *host,
204                        turn_id,
205                        message_id,
206                        cancellation,
207                    };
208                    match driver.complete_streaming(request, &mut sink).await {
209                        Ok(response) => {
210                            let events = engine.complete_model(&mut state, &response);
211                            host.record(turn_id, events).await?;
212                        }
213                        Err(Error::Cancelled) => {
214                            let events = state.on_cancel();
215                            host.record(turn_id, events).await?;
216                            return Err(Error::Cancelled);
217                        }
218                        Err(error) => {
219                            let events = engine.fail_model(&mut state, &error);
220                            host.record(turn_id, events).await?;
221                            return Err(error);
222                        }
223                    }
224                }
225                TurnOperation::InvokeTools { calls } => {
226                    // Execute the batch, then record it. Denied calls resolve
227                    // instantly; the rest run concurrently when the host is
228                    // configured for it, which is what makes a model's
229                    // parallel reads actually parallel.
230                    let mut pending = Vec::new();
231                    let mut outputs: Vec<Option<ToolOutput>> = Vec::with_capacity(calls.len());
232                    for prepared in &calls {
233                        match &prepared.denied {
234                            Some(output) => outputs.push(Some(output.clone())),
235                            None => {
236                                outputs.push(None);
237                                pending.push(prepared.call.clone());
238                            }
239                        }
240                    }
241
242                    if !pending.is_empty() {
243                        let contexts: Vec<ToolContext> = pending
244                            .iter()
245                            .map(|call| {
246                                ToolContext::new(host.session_id, turn_id)
247                                    .with_extensions(host.environment.extensions.clone())
248                                    .with_cancellation(host.cancellation.clone())
249                                    .with_progress(Arc::new(ListenerProgressSink {
250                                        listeners: host.environment.listeners.clone(),
251                                        session_id: host.session_id,
252                                        turn_id,
253                                        call_id: call.id.clone(),
254                                        name: call.name.clone(),
255                                    })
256                                        as Arc<dyn ToolProgressSink>)
257                            })
258                            .collect();
259
260                        // Race the whole batch against cancellation rather
261                        // than waiting it out: losing drops every in-flight
262                        // tool future where it stands, which is what kills a
263                        // `kill_on_drop` child process.
264                        let cancellation = host.cancellation.clone();
265                        let executed = match self.concurrent {
266                            true => {
267                                let futures = pending
268                                    .iter()
269                                    .zip(&contexts)
270                                    .map(|(call, context)| atoms::act(&assembled, call, context));
271                                cancellation.run_until_cancelled(join_all(futures)).await
272                            }
273                            // Sequential dispatch still checks policy between
274                            // calls, which a concurrent batch cannot do — see
275                            // `InProcessExecutor::sequential`.
276                            false => {
277                                cancellation
278                                    .run_until_cancelled(async {
279                                        let mut results = Vec::with_capacity(pending.len());
280                                        for (call, context) in pending.iter().zip(&contexts) {
281                                            results
282                                                .push(atoms::act(&assembled, call, context).await);
283                                        }
284                                        results
285                                    })
286                                    .await
287                            }
288                        };
289                        let Some(results) = executed else {
290                            let events = state.on_cancel();
291                            host.record(turn_id, events).await?;
292                            return Err(Error::Cancelled);
293                        };
294                        let mut results = results.into_iter();
295                        for slot in outputs.iter_mut() {
296                            if slot.is_none() {
297                                *slot = results.next();
298                            }
299                        }
300                    }
301
302                    // Recording is sequential and in batch order, however the
303                    // calls ran: two replays of one session must see the same
304                    // event sequence.
305                    for (prepared, output) in calls.into_iter().zip(outputs) {
306                        let output = output.expect("every prepared call produced an output");
307                        let events = engine
308                            .complete_tool(
309                                host,
310                                &assembled,
311                                &mut state,
312                                &prepared.call,
313                                output,
314                                prepared.denied.is_some(),
315                            )
316                            .await;
317                        host.record(turn_id, events).await?;
318                        if let Some(step) = engine.guard(host, &mut state).await {
319                            host.record(turn_id, step.events).await?;
320                            if let TurnOperation::Finished(outcome) = step.operation {
321                                return turn_result(host, state, outcome);
322                            }
323                            unreachable!("an engine guard only returns terminal operations");
324                        }
325                    }
326                }
327                TurnOperation::Finished(outcome) => {
328                    return turn_result(host, state, outcome);
329                }
330            }
331        }
332    }
333}
334
335fn turn_result(host: &TurnHost<'_>, state: TurnState, outcome: TurnOutcome) -> Result<TurnResult> {
336    match outcome {
337        TurnOutcome::Success { response } => Ok(TurnResult::new(
338            state.turn_id,
339            response,
340            state.iterations,
341            state.tool_calls_executed,
342            state.usage,
343        )),
344        TurnOutcome::MaxIterations => Err(Error::MaxIterations(host.definition.max_iterations)),
345        TurnOutcome::Failed { error } => Err(Error::Other(error)),
346        TurnOutcome::Cancelled => Err(Error::Cancelled),
347        TurnOutcome::Sealed(reason) => Err(Error::Sealed(reason)),
348    }
349}