1#![deny(missing_docs)]
5#![deny(rustdoc::broken_intra_doc_links)]
6
7mod builder;
8mod compactor;
9mod error;
10mod extension;
11mod hooks;
12mod model;
13mod output;
14mod recovery;
15mod replay;
16mod tools;
17mod types;
18
19pub use af_agent::CancellationToken;
20pub use af_agent_session::{cancel_events, failure_events, recovery_events};
21use af_context::{InputId, InteractionId, RunId, ToolCallId};
22pub use compactor::ModelCompactor;
23pub use error::RuntimeError;
24use model::ModelTurn;
25use output::assistant_content;
26use replay::{content_text, tool_message, transcript_from_events};
27use tools::PlannedCall;
28pub use types::{
29 ApproximateTokenMeter, CompactionResult, Compactor, EventWriter, RuntimeLimits, RuntimeOutcome,
30 TokenMeter, TurnRequest,
31};
32
33use std::{collections::HashSet, sync::Arc};
34
35use af_agent::{
36 ChatModel, ContextContributor, Hook, HookDecision, MountedPlugins, PreStepDecision,
37 PromptRegistry, ToolRegistry, TurnStopDecision,
38};
39use af_agent_session::{
40 DeliveryMode, Event, RecordedToolCall, RunStatus, SessionEvent, SessionProjection,
41 ToolAuthorizationStatus,
42};
43use af_llm::{ChatMessage, FinishReason, Role};
44use serde_json::{json, Value};
45
46pub struct AgentRuntime {
48 model: Arc<dyn ChatModel>,
49 model_name: String,
50 prompts: PromptRegistry,
51 contexts: Vec<Arc<dyn ContextContributor>>,
52 tools: ToolRegistry,
53 hooks: Vec<Arc<dyn Hook>>,
54 limits: RuntimeLimits,
55 meter: Arc<dyn TokenMeter>,
56 compactor: Arc<dyn Compactor>,
57 plugins: Option<MountedPlugins>,
58 reasoning_effort: Option<af_llm::ReasoningEffort>,
59 output_policy: Option<Value>,
60}
61
62struct InputDrain<'a> {
63 claimed: &'a mut HashSet<InputId>,
64 transcript: &'a mut Vec<ChatMessage>,
65 context_query: &'a mut String,
66 seen_seq: &'a mut u64,
67}
68
69impl AgentRuntime {
70 pub async fn run(
73 &self,
74 request: TurnRequest,
75 writer: &dyn EventWriter,
76 cancellation: CancellationToken,
77 ) -> Result<RuntimeOutcome, RuntimeError> {
78 let projection = SessionProjection::replay(&request.history)
79 .map_err(|error| RuntimeError::Invariant(error.to_string()))?;
80 if projection
81 .active_run_id
82 .as_deref()
83 .is_some_and(|active| active != request.run_id)
84 {
85 return Err(RuntimeError::SessionBusy);
86 }
87 let user_text = content_text(&request.content);
88 if user_text.trim().is_empty() {
89 return Err(RuntimeError::InvalidInput(
90 "text content is required".into(),
91 ));
92 }
93 if projection.active_run_id.is_none() {
94 writer
95 .append(vec![
96 Event::InputClaimed {
97 input_id: request.input_id.clone(),
98 run_id: request.run_id.clone(),
99 },
100 Event::RunStarted {
101 run_id: request.run_id.clone(),
102 input_id: request.input_id.clone(),
103 },
104 ])
105 .await?;
106 }
107 let resuming = projection.open_turn.is_some();
108 if !resuming {
109 writer
110 .append(vec![
111 Event::TurnStarted {
112 run_id: request.run_id.clone(),
113 turn: 1,
114 },
115 Event::UserMessage {
116 run_id: request.run_id.clone(),
117 content: request.content.clone(),
118 },
119 ])
120 .await?;
121 }
122
123 let mut transcript = transcript_from_events(&request.history);
124 let mut context_query = user_text.clone();
125 if !resuming {
126 transcript.push(ChatMessage::user(user_text));
127 }
128 let (mut prompt_tokens, mut completion_tokens) = projection.usage_for(&request.run_id);
129 let mut tool_calls = request
130 .history
131 .iter()
132 .filter(|event| {
133 matches!(&event.event, Event::ToolCall { run_id, .. } if run_id == &request.run_id)
134 })
135 .count() as u32;
136 let mut seen_seq = request.history.last().map_or(0, |event| event.seq);
137 let mut claimed_inputs = projection
138 .claimed_inputs
139 .keys()
140 .cloned()
141 .collect::<HashSet<_>>();
142
143 let recovery = self
144 .recover_open_surface(
145 &request,
146 &projection,
147 writer,
148 cancellation.clone(),
149 &mut transcript,
150 (prompt_tokens, completion_tokens),
151 )
152 .await?;
153 if let Some(outcome) = recovery.terminal {
154 return Ok(outcome);
155 }
156 let first_step = recovery.first_step;
157
158 Self::drain_inputs(
159 &request,
160 &request.history,
161 writer,
162 InputDrain {
163 claimed: &mut claimed_inputs,
164 transcript: &mut transcript,
165 context_query: &mut context_query,
166 seen_seq: &mut seen_seq,
167 },
168 )
169 .await?;
170
171 for step in first_step..=self.limits.max_steps {
172 if cancellation.is_cancelled() {
173 return self
174 .finish_open(
175 writer,
176 &request.run_id,
177 None,
178 RunStatus::Cancelled,
179 None,
180 (prompt_tokens, completion_tokens),
181 )
182 .await;
183 }
184 let incoming = writer.load_after(seen_seq).await?;
185 if let Some(last) = incoming.last() {
186 seen_seq = last.seq;
187 }
188 Self::drain_inputs(
189 &request,
190 &incoming,
191 writer,
192 InputDrain {
193 claimed: &mut claimed_inputs,
194 transcript: &mut transcript,
195 context_query: &mut context_query,
196 seen_seq: &mut seen_seq,
197 },
198 )
199 .await?;
200 let step_context = self.step_context(&request, step);
201 if let PreStepDecision::Reject { reason } = self.pre_step(&step_context).await {
202 writer
203 .append(vec![Event::Extension {
204 run_id: request.run_id.clone(),
205 plugin_id: "af-agent-runtime".into(),
206 event_type: "step_rejected".into(),
207 payload: json!({ "step": step, "reason": reason }),
208 }])
209 .await?;
210 return self
211 .finish_open(
212 writer,
213 &request.run_id,
214 None,
215 RunStatus::Cancelled,
216 None,
217 (prompt_tokens, completion_tokens),
218 )
219 .await;
220 }
221 writer
222 .append(vec![Event::StepStarted {
223 run_id: request.run_id.clone(),
224 step,
225 }])
226 .await?;
227 let context_messages = match self
228 .context_messages(&request, step, &context_query, writer, cancellation.clone())
229 .await
230 {
231 Ok(messages) => messages,
232 Err(RuntimeError::Cancelled) => {
233 return self
234 .finish_open(
235 writer,
236 &request.run_id,
237 Some(step),
238 RunStatus::Cancelled,
239 None,
240 (prompt_tokens, completion_tokens),
241 )
242 .await
243 }
244 Err(error) => return Err(error),
245 };
246 let compacted = match self
247 .compact_if_needed(
248 writer,
249 &request.run_id,
250 step,
251 &mut transcript,
252 &context_messages,
253 cancellation.clone(),
254 )
255 .await
256 {
257 Ok(compacted) => compacted,
258 Err(RuntimeError::Cancelled) => {
259 return self
260 .finish_open(
261 writer,
262 &request.run_id,
263 Some(step),
264 RunStatus::Cancelled,
265 None,
266 (prompt_tokens, completion_tokens),
267 )
268 .await
269 }
270 Err(RuntimeError::CompactionConflict) => {
271 writer
272 .append(vec![Event::StepFinished {
273 run_id: request.run_id.clone(),
274 step,
275 }])
276 .await?;
277 continue;
278 }
279 Err(error) => return Err(error),
280 };
281 prompt_tokens += compacted.0;
282 completion_tokens += compacted.1;
283 let completion = match self
284 .complete_with_retry(
285 writer,
286 ModelTurn {
287 run_id: &request.run_id,
288 step,
289 step_context: &step_context,
290 transcript: &transcript,
291 context: &context_messages,
292 after_seq: seen_seq,
293 },
294 cancellation.clone(),
295 )
296 .await
297 {
298 Ok(completion) => completion,
299 Err(RuntimeError::Cancelled) => {
300 return self
301 .finish_open(
302 writer,
303 &request.run_id,
304 Some(step),
305 RunStatus::Cancelled,
306 None,
307 (prompt_tokens, completion_tokens),
308 )
309 .await
310 }
311 Err(RuntimeError::Steered) => {
312 writer
313 .append(vec![Event::StepFinished {
314 run_id: request.run_id.clone(),
315 step,
316 }])
317 .await?;
318 continue;
319 }
320 Err(error) => return Err(error),
321 };
322 prompt_tokens += completion.prompt_tokens;
323 completion_tokens += completion.completion_tokens;
324 let response = completion.response;
325 if prompt_tokens + completion_tokens > self.limits.max_tokens {
326 return self
327 .finish_open(
328 writer,
329 &request.run_id,
330 Some(step),
331 RunStatus::MaxStepsReached,
332 None,
333 (prompt_tokens, completion_tokens),
334 )
335 .await;
336 }
337 let choice = response
338 .choices
339 .into_iter()
340 .next()
341 .ok_or(RuntimeError::EmptyModelResponse)?;
342 let finish_reason = choice
343 .finish_reason
344 .ok_or_else(|| RuntimeError::Model("missing finish_reason".into()))?;
345 let message = choice.message;
346 let output_blocks = choice.output_blocks;
347 match finish_reason {
348 FinishReason::Length => {
349 return self
350 .finish_open(
351 writer,
352 &request.run_id,
353 Some(step),
354 RunStatus::MaxStepsReached,
355 None,
356 (prompt_tokens, completion_tokens),
357 )
358 .await
359 }
360 FinishReason::ContentFilter | FinishReason::Unknown(_) => {
361 return Err(RuntimeError::FinishReason(finish_reason))
362 }
363 FinishReason::Stop => {
364 let (content, answer) =
365 assistant_content(message.content.as_deref(), output_blocks)?;
366 if let Some(policy) = &self.output_policy {
367 af_agent::validate_json_schema_value(
368 policy,
369 &serde_json::to_value(&content)
370 .map_err(|error| RuntimeError::Invariant(error.to_string()))?,
371 "assistant output",
372 )
373 .map_err(RuntimeError::Model)?;
374 }
375 if let TurnStopDecision::Steer { content: steer } =
376 self.turn_stopping(&step_context).await
377 {
378 let steer_text = content_text(&steer);
379 writer
380 .append(vec![
381 Event::AssistantMessage {
382 run_id: request.run_id.clone(),
383 step,
384 attempt: completion.attempt,
385 content,
386 },
387 Event::StepFinished {
388 run_id: request.run_id.clone(),
389 step,
390 },
391 Event::UserMessage {
392 run_id: request.run_id.clone(),
393 content: steer,
394 },
395 ])
396 .await?;
397 transcript.push(ChatMessage::assistant(answer.clone().unwrap_or_default()));
398 transcript.push(ChatMessage::user(steer_text));
399 continue;
400 }
401 let terminal = writer
402 .append(vec![
403 Event::AssistantMessage {
404 run_id: request.run_id.clone(),
405 step,
406 attempt: completion.attempt,
407 content,
408 },
409 Event::StepFinished {
410 run_id: request.run_id.clone(),
411 step,
412 },
413 Event::TurnFinished {
414 run_id: request.run_id.clone(),
415 turn: 1,
416 },
417 Event::RunFinished {
418 run_id: request.run_id.clone(),
419 status: RunStatus::Completed,
420 error_code: None,
421 },
422 ])
423 .await;
424 if let Err(error) = terminal {
425 let pending = writer.load_after(seen_seq).await?;
426 if pending.iter().any(|event| matches!(
427 &event.event,
428 Event::InputQueued { run_id, mode: af_agent_session::DeliveryMode::Steer | af_agent_session::DeliveryMode::Inject, .. }
429 if run_id == &request.run_id
430 )) {
431 writer
432 .append(vec![Event::StepFinished {
433 run_id: request.run_id.clone(),
434 step,
435 }])
436 .await?;
437 continue;
438 }
439 return Err(error);
440 }
441 return Ok(RuntimeOutcome {
442 status: RunStatus::Completed.as_str().into(),
443 final_text: answer,
444 prompt_tokens,
445 completion_tokens,
446 waiting_interaction_id: None,
447 });
448 }
449 FinishReason::ToolCalls => {}
450 }
451 if !output_blocks.is_empty() {
452 return Err(RuntimeError::Model(
453 "structured output cannot accompany tool calls".into(),
454 ));
455 }
456 let calls = message.tool_calls.clone().unwrap_or_default();
457 if calls.is_empty() {
458 return Err(RuntimeError::Model(
459 "finish_reason tool_calls without tool_calls".into(),
460 ));
461 }
462 if tool_calls + calls.len() as u32 > self.limits.max_tool_calls {
463 return self
464 .finish_open(
465 writer,
466 &request.run_id,
467 Some(step),
468 RunStatus::MaxStepsReached,
469 None,
470 (prompt_tokens, completion_tokens),
471 )
472 .await;
473 }
474 let planned = calls
475 .into_iter()
476 .map(|call| {
477 let id: ToolCallId = call.id.clone();
478 let canonical_name = self
479 .tools
480 .canonical_name(&call.function.name)
481 .map(str::to_string);
482 let (arguments, preflight_error) =
483 match serde_json::from_str(&call.function.arguments) {
484 Ok(arguments) => {
485 let error = self
486 .tools
487 .validate_arguments(&call.function.name, &arguments)
488 .err();
489 (arguments, error)
490 }
491 Err(error) => (
492 json!({"_raw":call.function.arguments}),
493 Some(format!("invalid JSON arguments: {error}")),
494 ),
495 };
496 PlannedCall {
497 transcript_id: id.clone(),
498 id,
499 name: canonical_name.unwrap_or(call.function.name),
500 arguments,
501 step,
502 source_event_seq: 0,
503 preflight_error,
504 }
505 })
506 .collect::<Vec<_>>();
507 let mut durable_calls = vec![Event::AssistantToolCalls {
508 run_id: request.run_id.clone(),
509 step,
510 content: message.content.clone(),
511 calls: planned
512 .iter()
513 .map(|call| RecordedToolCall {
514 call_id: call.id.clone(),
515 tool: call.name.clone(),
516 arguments: call.arguments.clone(),
517 })
518 .collect(),
519 }];
520 durable_calls.extend(planned.iter().map(|call| Event::ToolCall {
521 run_id: request.run_id.clone(),
522 step,
523 call_id: call.id.clone(),
524 tool: call.name.clone(),
525 arguments: call.arguments.clone(),
526 }));
527 let appended_calls = writer.append(durable_calls).await?;
528 transcript.push(ChatMessage {
529 role: Role::Assistant,
530 content: message.content,
531 tool_calls: Some(
532 planned
533 .iter()
534 .map(|call| af_llm::ToolCall {
535 id: call.id.clone(),
536 kind: "function".into(),
537 function: af_llm::FunctionCall {
538 name: af_agent::model_tool_name(&call.name),
539 arguments: call.arguments.to_string(),
540 },
541 })
542 .collect(),
543 ),
544 tool_call_id: None,
545 name: None,
546 });
547
548 let mut planned = planned;
549 let mut decisions = Vec::with_capacity(planned.len());
550 for (call, envelope) in planned.iter_mut().zip(appended_calls.iter().skip(1)) {
551 call.source_event_seq = envelope.seq;
552 let decision = if let Some(reason) = &call.preflight_error {
553 HookDecision::Deny {
554 reason: reason.clone(),
555 }
556 } else {
557 self.authorize_call(&request, call, None, cancellation.clone())
558 .await
559 };
560 decisions.push(decision);
561 }
562 tool_calls += planned.len() as u32;
563 if let Some(waiting_index) = decisions
564 .iter()
565 .position(|decision| matches!(decision, HookDecision::WaitForInput { .. }))
566 {
567 let mut events = Vec::with_capacity(planned.len() * 2 + 2);
568 for (index, call) in planned.iter().enumerate() {
569 if index == waiting_index {
570 events.push(Event::ToolAuthorization {
571 run_id: request.run_id.clone(),
572 step,
573 call_id: call.id.clone(),
574 status: ToolAuthorizationStatus::Waiting,
575 reason: None,
576 });
577 } else {
578 let reason = match &decisions[index] {
579 HookDecision::Deny { reason } => reason.clone(),
580 _ => "blocked_by_pending_interaction".into(),
581 };
582 events.extend([
583 Event::ToolAuthorization {
584 run_id: request.run_id.clone(),
585 step,
586 call_id: call.id.clone(),
587 status: ToolAuthorizationStatus::Denied,
588 reason: Some(reason.clone()),
589 },
590 Event::ToolResult {
591 run_id: request.run_id.clone(),
592 step,
593 call_id: call.id.clone(),
594 result: json!({"error":reason}),
595 is_error: true,
596 },
597 ]);
598 }
599 }
600 let call = &planned[waiting_index];
601 let HookDecision::WaitForInput { kind, mut payload } =
602 decisions.swap_remove(waiting_index)
603 else {
604 unreachable!("waiting decision selected above")
605 };
606 if let Value::Object(object) = &mut payload {
607 object.insert("call_id".into(), Value::String(call.id.to_string()));
608 object.insert(
609 "source_event_seq".into(),
610 Value::from(call.source_event_seq),
611 );
612 }
613 let interaction_id = InteractionId::parse(uuid::Uuid::new_v4().to_string())
614 .expect("uuid interaction ids are never blank");
615 events.extend([
616 Event::InteractionRequested {
617 run_id: request.run_id.clone(),
618 interaction_id: interaction_id.clone(),
619 kind: if kind == "question" {
620 af_agent_session::InteractionKind::UserQuestion
621 } else {
622 af_agent_session::InteractionKind::Action
623 },
624 payload,
625 },
626 Event::RunWaiting {
627 run_id: request.run_id.clone(),
628 interaction_id: interaction_id.clone(),
629 },
630 ]);
631 writer.append(events).await?;
632 return Ok(RuntimeOutcome {
633 status: "waiting_for_input".into(),
634 final_text: None,
635 prompt_tokens,
636 completion_tokens,
637 waiting_interaction_id: Some(interaction_id),
638 });
639 }
640
641 let mut executable = Vec::new();
642 for (call, decision) in planned.iter().zip(decisions) {
643 match decision {
644 HookDecision::Continue => {
645 writer
646 .append(vec![Event::ToolAuthorization {
647 run_id: request.run_id.clone(),
648 step,
649 call_id: call.id.clone(),
650 status: ToolAuthorizationStatus::Allowed,
651 reason: None,
652 }])
653 .await?;
654 executable.push(call.clone());
655 }
656 HookDecision::Deny { reason } => {
657 let value = json!({"error":reason});
658 writer
659 .append(vec![
660 Event::ToolAuthorization {
661 run_id: request.run_id.clone(),
662 step,
663 call_id: call.id.clone(),
664 status: ToolAuthorizationStatus::Denied,
665 reason: Some(reason),
666 },
667 Event::ToolResult {
668 run_id: request.run_id.clone(),
669 step,
670 call_id: call.id.clone(),
671 result: value.clone(),
672 is_error: true,
673 },
674 ])
675 .await?;
676 transcript.push(tool_message(call, value));
677 }
678 HookDecision::WaitForInput { .. } => {
679 writer
680 .append(vec![Event::ToolAuthorization {
681 run_id: request.run_id.clone(),
682 step,
683 call_id: call.id.clone(),
684 status: ToolAuthorizationStatus::Waiting,
685 reason: None,
686 }])
687 .await?;
688 unreachable!("waiting decisions are handled as one atomic batch");
689 }
690 }
691 }
692 let executed = self
693 .execute_tools(&request, &executable, writer, cancellation.clone(), None)
694 .await?;
695 let mut tool_outcome_unknown = false;
696 for (call, result) in executable.into_iter().zip(executed) {
697 let outcome_unknown = matches!(
698 &result,
699 Err(error) if error.contains("tool_outcome_unknown")
700 );
701 tool_outcome_unknown |= outcome_unknown;
702 let value = match result {
703 Ok(value) => value,
704 Err(error) => json!({"error": error}),
705 };
706 if !outcome_unknown {
707 if let Err(error) = self
708 .run_after_hooks(&request, &call, &value, None, cancellation.clone())
709 .await
710 {
711 writer
712 .append(vec![Event::Extension {
713 run_id: request.run_id.clone(),
714 plugin_id: "agentfactory.runtime".into(),
715 event_type: "after_tool_failed".into(),
716 payload: json!({"call_id":call.id,"error":error}),
717 }])
718 .await?;
719 }
720 }
721 transcript.push(tool_message(&call, value));
722 }
723 writer
724 .append(vec![Event::StepFinished {
725 run_id: request.run_id.clone(),
726 step,
727 }])
728 .await?;
729 if tool_outcome_unknown {
730 return self
731 .finish_open(
732 writer,
733 &request.run_id,
734 None,
735 RunStatus::Failed,
736 None,
737 (prompt_tokens, completion_tokens),
738 )
739 .await;
740 }
741 }
742 self.finish_open(
743 writer,
744 &request.run_id,
745 None,
746 RunStatus::MaxStepsReached,
747 None,
748 (prompt_tokens, completion_tokens),
749 )
750 .await
751 }
752
753 async fn drain_inputs(
754 request: &TurnRequest,
755 events: &[SessionEvent],
756 writer: &dyn EventWriter,
757 state: InputDrain<'_>,
758 ) -> Result<(), RuntimeError> {
759 for envelope in events {
760 let Event::InputQueued {
761 input_id,
762 run_id,
763 mode: DeliveryMode::Steer | DeliveryMode::Inject,
764 content,
765 ..
766 } = &envelope.event
767 else {
768 continue;
769 };
770 if run_id != &request.run_id || !state.claimed.insert(input_id.clone()) {
771 continue;
772 }
773 let appended = writer
774 .append(vec![
775 Event::InputClaimed {
776 input_id: input_id.clone(),
777 run_id: request.run_id.clone(),
778 },
779 Event::UserMessage {
780 run_id: request.run_id.clone(),
781 content: content.clone(),
782 },
783 ])
784 .await?;
785 *state.seen_seq = appended.last().map_or(*state.seen_seq, |event| event.seq);
786 *state.context_query = content_text(content);
787 state
788 .transcript
789 .push(ChatMessage::user(state.context_query.clone()));
790 }
791 Ok(())
792 }
793
794 async fn finish_open(
795 &self,
796 writer: &dyn EventWriter,
797 run_id: &RunId,
798 step: Option<u32>,
799 status: RunStatus,
800 final_text: Option<String>,
801 usage: (u64, u64),
802 ) -> Result<RuntimeOutcome, RuntimeError> {
803 let mut events = Vec::with_capacity(3);
804 if let Some(step) = step {
805 events.push(Event::StepFinished {
806 run_id: run_id.clone(),
807 step,
808 });
809 }
810 events.push(Event::TurnFinished {
811 run_id: run_id.clone(),
812 turn: 1,
813 });
814 events.push(Event::RunFinished {
815 run_id: run_id.clone(),
816 status,
817 error_code: None,
818 });
819 writer.append(events).await?;
820 Ok(RuntimeOutcome {
821 status: status.as_str().into(),
822 final_text,
823 prompt_tokens: usage.0,
824 completion_tokens: usage.1,
825 waiting_interaction_id: None,
826 })
827 }
828}