1impl TurnProtocol for UnitTurnProtocol {
2 type Event = ();
3 type Termination = ();
4 type DriverState = serde_json::Value;
5}
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
9pub struct EffectId(pub u64);
10
11#[derive(Clone, Debug, Serialize, serde::Deserialize)]
12pub struct PendingToolCall {
13 pub call_id: String,
14 pub tool_name: String,
15 pub args: Value,
16 pub replay: Option<ProviderReplayMeta>,
18}
19
20#[derive(Clone, Debug, Serialize, serde::Deserialize)]
21pub struct CompletedToolCall {
22 pub call_id: String,
23 pub tool_name: String,
24 pub args: Value,
25 pub output: ToolCallOutput,
26 pub model_return: ModelToolReturn,
27 pub duration_ms: u64,
28 pub replay: Option<ProviderReplayMeta>,
30}
31
32#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
33pub struct TurnCause {
34 pub id: String,
35 pub event_type: String,
36 pub origin: MessageOrigin,
37 pub text: String,
38}
39
40impl TurnCause {
41 pub fn to_event_message(&self) -> Message {
42 Message {
43 id: self.id.clone(),
44 role: MessageRole::Event,
45 parts: Arc::new(vec![Part {
46 id: format!("{}.p0", self.id),
47 kind: PartKind::Text,
48 content: self.text.clone(),
49 attachment: None,
50 tool_call_id: None,
51 tool_name: None,
52 tool_replay: None,
53 prune_state: PruneState::Intact,
54 reasoning_meta: None,
55 response_meta: None,
56 }]),
57 origin: Some(self.origin.clone()),
58 }
59 }
60}
61
62#[derive(Clone, Debug, Default, Serialize, serde::Deserialize)]
63pub struct CheckpointDelivery {
64 #[serde(default)]
70 pub committed_user_messages: Vec<Message>,
71 pub messages: Vec<PluginMessage>,
72 pub transient_messages: Vec<PluginMessage>,
73 pub turn_causes: Vec<TurnCause>,
74}
75
76pub fn render_turn_causes_prompt(causes: &[TurnCause]) -> Option<String> {
77 if causes.is_empty() {
78 return None;
79 }
80
81 let mut rendered = String::from("=== TURN EVENTS ===");
82 for (index, cause) in causes.iter().enumerate() {
83 rendered.push_str("\n\n");
84 rendered.push_str(&format!(
85 "--- event[{index}] · {} · {} ---\n",
86 cause.event_type, cause.id
87 ));
88 rendered.push_str("Origin: ");
89 rendered.push_str(&render_message_origin(&cause.origin));
90 rendered.push_str("\n\n");
91 rendered.push_str(cause.text.trim());
92 }
93 Some(rendered)
94}
95
96fn render_message_origin(origin: &MessageOrigin) -> String {
97 match origin {
98 MessageOrigin::Plugin {
99 plugin_id,
100 transient,
101 } => {
102 if *transient {
103 format!("plugin {plugin_id} (transient)")
104 } else {
105 format!("plugin {plugin_id}")
106 }
107 }
108 MessageOrigin::Process {
109 process_id,
110 event_type,
111 sequence,
112 wake_id,
113 ..
114 } => match wake_id {
115 Some(wake_id) => {
116 format!("process {process_id} {event_type} #{sequence} ({wake_id})")
117 }
118 None => format!("process {process_id} {event_type} #{sequence}"),
119 },
120 }
121}
122
123#[derive(Clone, Debug, Serialize, serde::Deserialize)]
124pub enum LogEvent {
125 LlmDebug {
126 session_id: String,
127 protocol_iteration: usize,
128 usage: TokenUsage,
129 provider_usage: Option<Value>,
130 request_body: Option<String>,
131 response_text: String,
132 response_parts: Option<Value>,
133 },
134 LlmError {
135 session_id: String,
136 protocol_iteration: usize,
137 request_body: Option<String>,
138 message: String,
139 retryable: bool,
140 raw: Option<String>,
141 code: Option<String>,
142 terminal_reason: LlmTerminalReason,
143 },
144}
145
146#[derive(Debug, Serialize, serde::Deserialize)]
153#[allow(clippy::large_enum_variant)]
155pub enum Effect<M: TurnProtocol = UnitTurnProtocol> {
156 SyncExecutionEnvironment {
164 id: EffectId,
165 update_machine_config: bool,
166 },
167 LlmCall {
169 id: EffectId,
170 request: Arc<LlmRequest>,
171 },
172 CancelLlm { id: EffectId },
174 ToolCalls {
176 id: EffectId,
177 calls: Vec<PendingToolCall>,
178 },
179 ExecCode {
181 id: EffectId,
182 language: String,
183 code: String,
184 },
185 Checkpoint {
187 id: EffectId,
188 checkpoint: CheckpointKind,
189 },
190 Log { event: LogEvent },
192 Emit(SessionStreamEvent),
194 Progress {
200 messages: MessageSequence,
201 event_delta: Vec<SessionHistoryRecord<M::Event>>,
202 protocol_iteration: usize,
203 },
204 Done {
206 messages: MessageSequence,
207 event_delta: Vec<SessionHistoryRecord<M::Event>>,
208 protocol_iteration: usize,
209 },
210}
211
212impl<M: TurnProtocol> Clone for Effect<M> {
213 fn clone(&self) -> Self {
214 match self {
215 Self::SyncExecutionEnvironment {
216 id,
217 update_machine_config,
218 } => Self::SyncExecutionEnvironment {
219 id: *id,
220 update_machine_config: *update_machine_config,
221 },
222 Self::LlmCall { id, request } => Self::LlmCall {
223 id: *id,
224 request: Arc::clone(request),
225 },
226 Self::CancelLlm { id } => Self::CancelLlm { id: *id },
227 Self::ToolCalls { id, calls } => Self::ToolCalls {
228 id: *id,
229 calls: calls.clone(),
230 },
231 Self::ExecCode { id, language, code } => Self::ExecCode {
232 id: *id,
233 language: language.clone(),
234 code: code.clone(),
235 },
236 Self::Checkpoint { id, checkpoint } => Self::Checkpoint {
237 id: *id,
238 checkpoint: *checkpoint,
239 },
240 Self::Log { event } => Self::Log {
241 event: event.clone(),
242 },
243 Self::Emit(event) => Self::Emit(event.clone()),
244 Self::Progress {
245 messages,
246 event_delta,
247 protocol_iteration,
248 } => Self::Progress {
249 messages: messages.clone(),
250 event_delta: event_delta.clone(),
251 protocol_iteration: *protocol_iteration,
252 },
253 Self::Done {
254 messages,
255 event_delta,
256 protocol_iteration,
257 } => Self::Done {
258 messages: messages.clone(),
259 event_delta: event_delta.clone(),
260 protocol_iteration: *protocol_iteration,
261 },
262 }
263 }
264}
265
266impl<M: TurnProtocol> Effect<M> {
267 fn id(&self) -> Option<EffectId> {
268 match self {
269 Self::SyncExecutionEnvironment { id, .. }
270 | Self::LlmCall { id, .. }
271 | Self::CancelLlm { id }
272 | Self::ToolCalls { id, .. }
273 | Self::ExecCode { id, .. }
274 | Self::Checkpoint { id, .. } => Some(*id),
275 Self::Log { .. } | Self::Emit(_) | Self::Progress { .. } | Self::Done { .. } => None,
276 }
277 }
278}
279
280#[derive(Clone, Debug, Serialize, serde::Deserialize)]
282pub struct LlmCallError {
283 pub message: String,
284 pub retryable: bool,
285 #[serde(default)]
290 pub kind: crate::llm::types::ProviderFailureKind,
291 pub raw: Option<String>,
292 pub code: Option<String>,
293 pub terminal_reason: LlmTerminalReason,
294 pub request_body: Option<String>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub partial_response: Option<Box<LlmResponse>>,
299}
300
301pub enum Response {
303 ExecutionEnvironmentSynced {
305 id: EffectId,
306 result: Result<Option<ExecutionEnvironmentSync>, String>,
307 },
308 LlmComplete {
310 id: EffectId,
311 result: Result<LlmResponse, LlmCallError>,
312 text_streamed: bool,
315 },
316 ToolResults {
318 id: EffectId,
319 results: Vec<CompletedToolCall>,
320 },
321 ExecResult {
323 id: EffectId,
324 result: Result<crate::ExecResponse, String>,
325 },
326 Checkpoint {
328 id: EffectId,
329 delivery: CheckpointDelivery,
330 },
331}
332
333#[derive(Clone, Debug, Serialize, serde::Deserialize)]
334pub struct ExecutionEnvironmentSync {
335 pub system_prompt: Arc<str>,
336 pub tool_specs: Arc<Vec<LlmToolSpec>>,
337}
338
339pub struct WaitingLlmState<M: TurnProtocol = UnitTurnProtocol> {
340 pub request: Arc<LlmRequest>,
341 driver_state: Option<M::DriverState>,
342}
343
344impl<M: TurnProtocol> WaitingLlmState<M> {
345 pub fn take_driver_state(&mut self) -> Option<M::DriverState> {
346 self.driver_state.take()
347 }
348}
349
350pub struct WaitingExecState<M: TurnProtocol = UnitTurnProtocol> {
351 driver_state: M::DriverState,
352}
353
354impl<M: TurnProtocol> WaitingExecState<M> {
355 pub fn into_driver_state(self) -> M::DriverState {
356 self.driver_state
357 }
358}
359
360#[derive(Clone, Debug, PartialEq, Serialize, serde::Deserialize)]
361pub enum CheckpointResumeAction {
362 PrepareIteration,
363 Finish(TurnOutcome),
364}
365
366#[allow(clippy::large_enum_variant)]
368pub enum DriverAction<M: TurnProtocol = UnitTurnProtocol> {
369 Emit(SessionStreamEvent),
370 AppendEvents(Vec<SessionHistoryRecord<M::Event>>),
371 StartLlm {
372 request: Arc<LlmRequest>,
373 driver_state: Option<M::DriverState>,
374 },
375 StartTools {
376 calls: Vec<PendingToolCall>,
377 },
378 StartExec {
379 language: String,
380 code: String,
381 driver_state: M::DriverState,
382 },
383 StartCheckpoint {
384 checkpoint: CheckpointKind,
385 on_empty: CheckpointResumeAction,
386 },
387 AdvanceProtocolIteration,
388 ScheduleTurnLimitFinal {
389 message: Message,
390 },
391 Finish(TurnOutcome),
392}
393
394pub struct DriverContextView<'a, M: TurnProtocol = UnitTurnProtocol> {
395 config: &'a TurnMachineConfig<M>,
396 messages: &'a MessageSequence,
397 events: &'a [SessionHistoryRecord<M::Event>],
398 turn_causes: &'a [TurnCause],
399 protocol_iteration: usize,
400 protocol_run_offset: usize,
401 termination: &'a TurnTerminationPolicyState,
402}
403
404impl<'a, M: TurnProtocol> DriverContextView<'a, M> {
405 pub fn project_llm_request(&self, use_tools: bool) -> Arc<LlmRequest> {
406 self.config.projector.project(ProjectorContext {
407 config: self.config,
408 messages: self.messages,
409 events: self.events,
410 turn_causes: self.turn_causes,
411 protocol_iteration: self.protocol_iteration,
412 use_tools,
413 })
414 }
415
416 pub fn protocol_iteration(&self) -> usize {
417 self.protocol_iteration
418 }
419
420 pub fn protocol_run_offset(&self) -> usize {
421 self.protocol_run_offset
422 }
423
424 pub fn max_turns(&self) -> Option<usize> {
425 self.config.max_turns
426 }
427
428 pub fn termination(&self) -> &M::Termination {
429 &self.config.termination
430 }
431
432 pub fn autonomous(&self) -> bool {
433 self.config.autonomous
434 }
435
436 pub fn should_force_exit_after_grace_turn(&self) -> bool {
437 self.termination.should_force_exit_after_grace_turn()
438 }
439
440 pub fn turn_limit_final_to_schedule(&self) -> Option<usize> {
441 self.termination.turn_limit_final_to_schedule(
442 self.protocol_iteration,
443 self.protocol_run_offset,
444 self.config.max_turns,
445 )
446 }
447
448 pub fn messages(&self) -> &MessageSequence {
449 self.messages
450 }
451
452 pub fn events(&self) -> &[SessionHistoryRecord<M::Event>] {
453 self.events
454 }
455
456 pub fn turn_causes(&self) -> &[TurnCause] {
457 self.turn_causes
458 }
459}
460
461pub struct ProjectorContext<'a, M: TurnProtocol = UnitTurnProtocol> {
462 pub config: &'a TurnMachineConfig<M>,
463 pub messages: &'a MessageSequence,
464 pub events: &'a [SessionHistoryRecord<M::Event>],
465 pub turn_causes: &'a [TurnCause],
466 pub protocol_iteration: usize,
467 pub use_tools: bool,
468}
469
470pub trait ContextProjector<M: TurnProtocol = UnitTurnProtocol>: Send + Sync {
471 fn project(&self, ctx: ProjectorContext<'_, M>) -> Arc<LlmRequest>;
472}
473
474#[derive(Clone, Debug, Default)]
475pub struct ChatContextProjector;
476
477impl<M: TurnProtocol> ContextProjector<M> for ChatContextProjector {
478 fn project(&self, ctx: ProjectorContext<'_, M>) -> Arc<LlmRequest> {
479 let rendered_prompt = render_messages_for_projector(ctx.messages, ctx.turn_causes);
480 let attachments: Vec<AttachmentSource> = rendered_prompt.attachments;
481 let mut messages = rendered_prompt.messages;
482 if let Some(turn_events) = render_turn_causes_prompt(ctx.turn_causes) {
483 messages.push(crate::llm::types::LlmMessage::text(
484 crate::llm::types::LlmRole::User,
485 Arc::from(turn_events),
486 ));
487 }
488 if !ctx.config.system_prompt.trim().is_empty() {
489 messages.insert(
490 0,
491 crate::llm::types::LlmMessage::text(
492 crate::llm::types::LlmRole::System,
493 Arc::clone(&ctx.config.system_prompt),
494 ),
495 );
496 }
497
498 Arc::new(LlmRequest {
499 model: ctx.config.model.clone(),
500 messages,
501 attachments,
502 resolved_stored: Default::default(),
503 tools: if ctx.use_tools {
504 Arc::clone(&ctx.config.tool_specs)
505 } else {
506 Arc::new(Vec::new())
507 },
508 tool_choice: if ctx.use_tools {
509 LlmToolChoice::Auto
510 } else {
511 LlmToolChoice::None
512 },
513 model_variant: ctx.config.model_variant.clone(),
514 model_capability: ctx.config.model_capability.clone(),
515 generation: ctx.config.generation.clone(),
516 scope: crate::llm::types::LlmRequestScope::new(
517 ctx.config.session_id.clone(),
518 format!("{}:frame:sansio", ctx.config.session_id),
519 format!(
520 "{}:sansio:llm:{}",
521 ctx.config.session_id, ctx.protocol_iteration
522 ),
523 ),
524 output_spec: None,
525 stream_events: None,
526 provider_trace: None,
527 })
528 }
529}
530
531fn render_messages_for_projector(
532 messages: &MessageSequence,
533 turn_causes: &[TurnCause],
534) -> crate::RenderedPrompt {
535 if turn_causes.is_empty() {
536 return messages.render_prompt();
537 }
538
539 let active_cause_ids = turn_causes
540 .iter()
541 .map(|cause| cause.id.as_str())
542 .collect::<HashSet<_>>();
543 let filtered = messages
544 .iter()
545 .filter(|message| {
546 !(matches!(message.role, MessageRole::Event)
547 && active_cause_ids.contains(message.id.as_str()))
548 })
549 .cloned()
550 .collect::<Vec<_>>();
551 render_prompt(filtered.as_slice())
552}
553
554pub trait ProtocolDriverHandle<M: TurnProtocol = UnitTurnProtocol>: Send + Sync {
555 fn prepare_protocol_iteration(&self, ctx: DriverContextView<'_, M>) -> Vec<DriverAction<M>>;
556 fn handle_llm_success(
557 &self,
558 ctx: DriverContextView<'_, M>,
559 waiting: WaitingLlmState<M>,
560 llm_response: LlmResponse,
561 text_streamed: bool,
562 ) -> Vec<DriverAction<M>>;
563 fn handle_tool_results(
564 &self,
565 ctx: DriverContextView<'_, M>,
566 completed: Vec<CompletedToolCall>,
567 ) -> Vec<DriverAction<M>>;
568 fn handle_exec_result(
569 &self,
570 ctx: DriverContextView<'_, M>,
571 waiting: WaitingExecState<M>,
572 result: Result<crate::ExecResponse, String>,
573 ) -> Vec<DriverAction<M>>;
574}
575
576pub struct TurnMachineConfig<M: TurnProtocol = UnitTurnProtocol> {
578 pub protocol_driver: Arc<dyn ProtocolDriverHandle<M>>,
579 pub projector: Arc<dyn ContextProjector<M>>,
580 pub sync_execution_environment: bool,
581 pub model: String,
582 pub max_context_tokens: Option<usize>,
587 pub max_turns: Option<usize>,
588 pub model_variant: crate::ReasoningSelection,
589 pub model_capability: crate::llm::capability::ModelCapability,
590 pub generation: crate::llm::types::GenerationOptions,
591 pub autonomous: bool,
592 pub tool_specs: Arc<Vec<LlmToolSpec>>,
593 pub system_prompt: Arc<str>,
594 pub session_id: String,
595 pub emit_llm_trace: bool,
596 pub termination: M::Termination,
597 pub turn_limit_final_message: crate::TurnLimitFinalMessage,
598}
599
600#[cfg(test)]
601mod llm_call_error_tests {
602 use super::LlmCallError;
603 use crate::llm::types::ProviderFailureKind;
604
605 #[test]
606 fn llm_call_error_decodes_journal_entries_that_predate_kind() {
607 let legacy = r#"{
611 "message":"rate limited",
612 "retryable":true,
613 "raw":null,
614 "code":"429",
615 "terminal_reason":"provider_error",
616 "request_body":null
617 }"#;
618 let decoded: LlmCallError = serde_json::from_str(legacy).expect("legacy call error");
619 assert!(decoded.retryable);
620 assert_eq!(decoded.kind, ProviderFailureKind::Unknown);
621 }
622}
623
624