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