1use 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#[derive(Debug, Clone)]
23pub enum TurnOperation {
24 InvokeModel {
26 request: ChatRequest,
28 },
29 InvokeTools {
32 calls: Vec<PreparedToolCall>,
34 },
35 Finished(TurnOutcome),
37}
38
39#[derive(Debug, Clone)]
41pub struct PreparedToolCall {
42 pub call: ToolCall,
44 pub denied: Option<ToolOutput>,
46}
47
48#[derive(Debug, Clone)]
50pub struct PreparedStep {
51 pub events: Vec<EventData>,
53 pub operation: TurnOperation,
55}
56
57pub struct PreparedTurnStart {
59 pub state: TurnState,
61 pub events: Vec<EventData>,
63 pub rejection: Option<HookRejection>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct HookRejection {
70 pub reason: String,
72 pub user_message: Option<String>,
74}
75
76#[derive(Debug, Default, Clone, Copy)]
78pub struct TurnEngine;
79
80impl TurnEngine {
81 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 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 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 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 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 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 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 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 pub fn complete_model(&self, state: &mut TurnState, response: &ChatResponse) -> Vec<EventData> {
371 state.on_reason_completed(response)
372 }
373
374 pub fn fail_model(&self, state: &mut TurnState, error: &Error) -> Vec<EventData> {
376 state.on_failure(error.to_string())
377 }
378
379 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}