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