Skip to main content

agentyk_engine/
engine.rs

1//! Canonical one-step turn semantics shared by every execution host.
2
3use agentyk_core::atoms::{self, AssembledTurn};
4use agentyk_core::budget::BudgetDecision;
5use agentyk_core::context::ContextRequest;
6use agentyk_core::driver::{ChatRequest, ChatResponse};
7use agentyk_core::error::{Error, Result};
8use agentyk_core::event::EventData;
9use agentyk_core::hook::{HookContext, HookEvent};
10use agentyk_core::message::{Message, ToolCall};
11use agentyk_core::middleware::{self, ToolChainOutcome};
12use agentyk_core::tool::{ToolContext, ToolOutput};
13use agentyk_core::turn::{SealReason, TurnAction, TurnOutcome, TurnState};
14
15use crate::hooks::{self, PromptDecision, ToolDecision};
16use crate::host::TurnHost;
17/// External work requested by one prepared engine step.
18///
19/// Operations are runtime values, not durable payloads. In particular, a
20/// model request may contain credentials. Persist the accompanying events and
21/// resolve protected execution resources at the host's activity boundary.
22#[derive(Debug, Clone)]
23pub enum TurnOperation {
24    /// Ask the configured model for the next assistant message.
25    InvokeModel {
26        /// Complete provider-neutral request.
27        request: ChatRequest,
28    },
29    /// Run a prepared tool batch. A host may dispatch runnable calls
30    /// sequentially, concurrently, or as durable activities.
31    InvokeTools {
32        /// Calls after middleware has rewritten or denied each one.
33        calls: Vec<PreparedToolCall>,
34    },
35    /// The turn has reached a terminal outcome.
36    Finished(TurnOutcome),
37}
38
39/// One call in a prepared tool batch.
40#[derive(Debug, Clone)]
41pub struct PreparedToolCall {
42    /// Call after every middleware rewrite.
43    pub call: ToolCall,
44    /// Present when middleware denied the call and no tool may run.
45    pub denied: Option<ToolOutput>,
46}
47
48/// One engine decision: persist `events`, then execute `operation`.
49#[derive(Debug, Clone)]
50pub struct PreparedStep {
51    /// Durable transition events that precede the operation.
52    pub events: Vec<EventData>,
53    /// Work the host must execute or schedule.
54    pub operation: TurnOperation,
55}
56
57/// Canonical effects of opening a turn, before any model work is scheduled.
58pub struct PreparedTurnStart {
59    /// New pure turn state.
60    pub state: TurnState,
61    /// `turn.started`, the final (possibly rewritten) input, and hook effects.
62    pub events: Vec<EventData>,
63    /// Why a prompt hook rejected the turn, if it did.
64    pub rejection: Option<HookRejection>,
65}
66
67/// A block decision returned by a prompt hook.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct HookRejection {
70    /// Diagnostic reason supplied by the hook.
71    pub reason: String,
72    /// Optional safer text for a user-facing surface.
73    pub user_message: Option<String>,
74}
75
76/// The single interpreter of Agentyk turn semantics.
77#[derive(Debug, Default, Clone, Copy)]
78pub struct TurnEngine;
79
80impl TurnEngine {
81    /// Open a turn and apply `user_prompt_submit` hooks before its input is
82    /// persisted.
83    ///
84    /// Durable and immediate hosts both call this method, so prompt mutation
85    /// and rejection cannot drift between execution strategies.
86    pub async fn start(&self, host: &TurnHost<'_>, input: Message) -> PreparedTurnStart {
87        let (mut state, mut events) =
88            TurnState::start(host.session_id, host.definition.max_iterations, &input);
89        let (decision, hook_events) = hooks::run_prompt_hooks(
90            &host.definition.hooks,
91            HookContext::turn(host.session_id, state.turn_id),
92            input,
93        )
94        .await;
95        events.extend(hook_events);
96        let rejection = match decision {
97            PromptDecision::Continue(message) => {
98                for event in &mut events {
99                    if let EventData::InputMessage { message: recorded } = event {
100                        *recorded = (*message).clone();
101                    }
102                }
103                None
104            }
105            PromptDecision::Block {
106                reason,
107                user_message,
108            } => {
109                events.extend(
110                    state.on_failure(user_message.clone().unwrap_or_else(|| reason.clone())),
111                );
112                Some(HookRejection {
113                    reason,
114                    user_message,
115                })
116            }
117        };
118        PreparedTurnStart {
119            state,
120            events,
121            rejection,
122        }
123    }
124
125    /// Apply advisory `turn_end` hooks to a host-provided terminal summary.
126    ///
127    /// `data` uses the public hook wire shape (`success` plus result/error
128    /// details). The engine owns dispatch and error policy; a durable host
129    /// owns when its terminal operation has been committed and therefore when
130    /// to call this method. Like tool execution, a hook may run more than once
131    /// if the durable host crashes before committing its effects.
132    pub async fn finish(
133        &self,
134        host: &TurnHost<'_>,
135        turn_id: agentyk_core::id::TurnId,
136        data: serde_json::Value,
137    ) -> Vec<EventData> {
138        hooks::run_advisory_hooks(
139            &host.definition.hooks,
140            HookEvent::TurnEnd,
141            HookContext::turn(host.session_id, turn_id),
142            data,
143        )
144        .await
145    }
146
147    /// Resolve the agent's capabilities into the environment used by steps.
148    pub async fn assemble(&self, host: &TurnHost<'_>) -> Result<AssembledTurn> {
149        atoms::assemble(
150            host.definition.instructions.as_str(),
151            &host.definition.capabilities,
152            host.session_id,
153        )
154        .await
155    }
156
157    /// Prepare exactly one operation from the current state.
158    ///
159    /// A durable host persists the returned events before scheduling the
160    /// operation. The in-process host records them and executes immediately.
161    pub async fn prepare(
162        &self,
163        host: &TurnHost<'_>,
164        assembled: &AssembledTurn,
165        state: &mut TurnState,
166    ) -> Result<PreparedStep> {
167        if let Some(step) = self.guard(host, state).await {
168            return Ok(step);
169        }
170
171        match state.next_action() {
172            TurnAction::Reason => {
173                // Steering lands here and nowhere else: a message wedged
174                // between a tool call and its result would make the exchange
175                // invalid for every provider, and a reasoning step is the
176                // first moment the model could act on it anyway. The drained
177                // messages are recorded as ordinary `input.message` events, so
178                // they enter history — and a replay — like any other input.
179                let steering = host.input.drain();
180                let mut events: Vec<EventData> = steering
181                    .iter()
182                    .cloned()
183                    .map(|message| EventData::InputMessage { message })
184                    .collect();
185                events.extend(state.on_reason_started(Some(&host.model.model)));
186
187                // The host records this step's events *after* prepare returns,
188                // so `host.history` does not know about the steering yet. It
189                // has to be in the request it is meant to steer, so it is
190                // appended here — the events above then bring history to the
191                // same place, and a replay produces exactly this list.
192                let mut history = host.history.messages().to_vec();
193                history.extend(steering);
194
195                let point = host.log.head(host.session_id).await?;
196                let context = host
197                    .definition
198                    .context_assembler
199                    .assemble(ContextRequest {
200                        point,
201                        turn_id: state.turn_id,
202                        iteration: state.iterations + 1,
203                        model: host.model,
204                        token_limit: host.definition.context_token_limit,
205                        messages: &history,
206                        events: host.log,
207                    })
208                    .await?;
209                let (messages, context_events) = context.into_messages_and_events();
210                events.extend(context_events);
211                Ok(PreparedStep {
212                    events,
213                    operation: TurnOperation::InvokeModel {
214                        request: atoms::chat_request(host.model, assembled, messages),
215                    },
216                })
217            }
218            TurnAction::ExecuteTool { call } => {
219                let pending = state.pending_tool_actions();
220                let calls = if pending.is_empty() {
221                    vec![call]
222                } else {
223                    pending
224                        .into_iter()
225                        .filter_map(|action| match action {
226                            TurnAction::ExecuteTool { call } => Some(call),
227                            _ => None,
228                        })
229                        .collect()
230                };
231                // Middleware sees the turn's cancellation signal, so an
232                // approval prompt (or any other await inside `before_tool`)
233                // can stop waiting when the turn is cancelled.
234                let context = ToolContext::new(host.session_id, state.turn_id)
235                    .with_extensions(host.environment.extensions.clone())
236                    .with_cancellation(host.cancellation.clone());
237                let mut events = Vec::new();
238                let mut prepared = Vec::with_capacity(calls.len());
239                for call in calls {
240                    let hook_context = HookContext::turn(host.session_id, state.turn_id);
241                    let (hook_decision, hook_events) = hooks::run_pre_tool_hooks(
242                        &host.definition.hooks,
243                        hook_context,
244                        call.clone(),
245                    )
246                    .await;
247                    events.extend(hook_events);
248                    let call_after_hooks = match hook_decision {
249                        ToolDecision::Block {
250                            call: blocked,
251                            reason,
252                            user_message,
253                        } => {
254                            if blocked != call {
255                                events.extend(state.on_tool_rewritten(
256                                    &call.id,
257                                    blocked.clone(),
258                                    Some("user_hook".into()),
259                                ));
260                            }
261                            events.extend(state.on_tool_started(&blocked.id));
262                            events.push(EventData::ToolDenied {
263                                call_id: blocked.id.clone(),
264                                name: blocked.name.clone(),
265                                reason: reason.clone(),
266                            });
267                            if let Some(user_message) = user_message {
268                                events.push(EventData::custom(
269                                    "hook.user_message",
270                                    serde_json::json!({
271                                        "call_id": blocked.id,
272                                        "message": user_message,
273                                    }),
274                                ));
275                            }
276                            prepared.push(PreparedToolCall {
277                                call: blocked,
278                                denied: Some(ToolOutput::error(reason)),
279                            });
280                            continue;
281                        }
282                        ToolDecision::Continue(updated) => updated,
283                    };
284                    if call_after_hooks != call {
285                        events.extend(state.on_tool_rewritten(
286                            &call.id,
287                            call_after_hooks.clone(),
288                            Some("user_hook".into()),
289                        ));
290                    }
291
292                    let definition = assembled
293                        .tool(&call_after_hooks.name)
294                        .map(|tool| tool.definition());
295                    let decision = middleware::before_tool_chain(
296                        &host.definition.middleware,
297                        &call_after_hooks,
298                        definition.as_ref(),
299                        &context,
300                    )
301                    .await;
302                    match decision {
303                        ToolChainOutcome::Deny { reason } => {
304                            events.extend(state.on_tool_started(&call_after_hooks.id));
305                            events.push(EventData::ToolDenied {
306                                call_id: call_after_hooks.id.clone(),
307                                name: call_after_hooks.name.clone(),
308                                reason: reason.clone(),
309                            });
310                            prepared.push(PreparedToolCall {
311                                call: call_after_hooks,
312                                denied: Some(ToolOutput::error(reason)),
313                            });
314                        }
315                        ToolChainOutcome::Proceed {
316                            call: executed,
317                            rewritten,
318                        } => {
319                            if rewritten {
320                                events.extend(state.on_tool_rewritten(
321                                    &call_after_hooks.id,
322                                    executed.clone(),
323                                    None,
324                                ));
325                            }
326                            events.extend(state.on_tool_started(&executed.id));
327                            prepared.push(PreparedToolCall {
328                                call: executed,
329                                denied: None,
330                            });
331                        }
332                    }
333                }
334                Ok(PreparedStep {
335                    events,
336                    operation: TurnOperation::InvokeTools { calls: prepared },
337                })
338            }
339            TurnAction::Complete(outcome) => Ok(PreparedStep {
340                events: Vec::new(),
341                operation: TurnOperation::Finished(outcome),
342            }),
343        }
344    }
345
346    /// Apply cancellation and budget policy before one external action.
347    ///
348    /// Hosts that execute a prepared tool batch one call at a time use this
349    /// between calls so policy remains responsive inside the batch.
350    pub async fn guard(&self, host: &TurnHost<'_>, state: &mut TurnState) -> Option<PreparedStep> {
351        if host.cancellation.is_cancelled() {
352            return Some(PreparedStep {
353                events: state.on_cancel(),
354                operation: TurnOperation::Finished(TurnOutcome::Cancelled),
355            });
356        }
357        if let Some(checker) = &host.definition.budget_checker
358            && checker.check(host.session_id).await == BudgetDecision::Seal
359        {
360            let reason = SealReason::BudgetExhausted;
361            return Some(PreparedStep {
362                events: state.on_seal(reason),
363                operation: TurnOperation::Finished(TurnOutcome::Sealed(reason)),
364            });
365        }
366        None
367    }
368
369    /// Apply a successful model operation and return its durable events.
370    pub fn complete_model(&self, state: &mut TurnState, response: &ChatResponse) -> Vec<EventData> {
371        state.on_reason_completed(response)
372    }
373
374    /// Apply a failed model operation and return its durable event.
375    pub fn fail_model(&self, state: &mut TurnState, error: &Error) -> Vec<EventData> {
376        state.on_failure(error.to_string())
377    }
378
379    /// Apply one tool result, including post-tool middleware when the tool
380    /// actually ran, and return its durable completion event.
381    pub async fn complete_tool(
382        &self,
383        host: &TurnHost<'_>,
384        assembled: &AssembledTurn,
385        state: &mut TurnState,
386        call: &ToolCall,
387        output: ToolOutput,
388        denied: bool,
389    ) -> Vec<EventData> {
390        let output = if denied {
391            output
392        } else {
393            let context = ToolContext::new(host.session_id, state.turn_id)
394                .with_extensions(host.environment.extensions.clone())
395                .with_cancellation(host.cancellation.clone());
396            let definition = assembled.tool(&call.name).map(|tool| tool.definition());
397            let output = middleware::after_tool_chain(
398                &host.definition.middleware,
399                call,
400                definition.as_ref(),
401                &context,
402                output,
403            )
404            .await;
405            let (output, warnings) = hooks::run_post_tool_hooks(
406                &host.definition.hooks,
407                HookContext::turn(host.session_id, state.turn_id),
408                call,
409                output,
410            )
411            .await;
412            let mut events = warnings;
413            events.extend(state.on_tool_completed(&call.id, &output));
414            return events;
415        };
416        state.on_tool_completed(&call.id, &output)
417    }
418}