1use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use starweaver_core::{
6 AgentExecutionNode, CheckpointId, ConversationId, Metadata, RunId, RunLifecycle as RunStatus,
7 TraceContext,
8};
9use starweaver_usage::Usage;
10use thiserror::Error;
11
12use crate::{AgentRunState, durable_projection::project_run_state};
13
14#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
16pub struct AgentResumeCursor {
17 pub model_request_attempt: usize,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub tool_call_batch_id: Option<String>,
22 pub output_validation_attempt: usize,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub stream_cursor: Option<usize>,
27 pub message_cursor: usize,
29}
30
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33pub struct AgentResumeEvidence {
34 pub node: AgentExecutionNode,
36 pub status: RunStatus,
38 pub run_step: usize,
40 pub cursor: AgentResumeCursor,
42 pub usage: Usage,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub context_revision: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub environment_ref: Option<String>,
50 pub pending_approval_count: usize,
52 pub deferred_tool_count: usize,
54 #[serde(default, skip_serializing_if = "TraceContext::is_empty")]
56 pub trace_context: TraceContext,
57 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
59 pub metadata: Metadata,
60}
61
62impl AgentResumeEvidence {
63 #[must_use]
65 pub fn new(node: AgentExecutionNode, state: &AgentRunState) -> Self {
66 Self {
67 node,
68 status: state.status,
69 run_step: state.run_step,
70 cursor: AgentResumeCursor {
71 model_request_attempt: state.run_step,
72 tool_call_batch_id: (!state.pending_tool_calls.is_empty())
73 .then(|| format!("tool_batch_{}", state.run_step)),
74 output_validation_attempt: 0,
75 stream_cursor: None,
76 message_cursor: state.message_history.len(),
77 },
78 usage: state.usage.clone(),
79 context_revision: state
80 .metadata
81 .get("context_revision")
82 .and_then(serde_json::Value::as_str)
83 .map(str::to_string),
84 environment_ref: state
85 .metadata
86 .get("environment_ref")
87 .and_then(serde_json::Value::as_str)
88 .map(str::to_string),
89 pending_approval_count: state.pending_approval_tool_returns.len(),
90 deferred_tool_count: state.deferred_tool_returns.len(),
91 trace_context: TraceContext::default(),
92 metadata: Metadata::default(),
93 }
94 }
95
96 #[must_use]
98 pub const fn with_stream_cursor(mut self, stream_cursor: usize) -> Self {
99 self.cursor.stream_cursor = Some(stream_cursor);
100 self
101 }
102
103 #[must_use]
105 pub fn with_trace_context(mut self, trace_context: TraceContext) -> Self {
106 self.trace_context = trace_context;
107 self
108 }
109
110 #[must_use]
112 pub fn with_metadata(mut self, metadata: Metadata) -> Self {
113 self.metadata = metadata;
114 self
115 }
116}
117
118#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
120pub struct AgentCheckpoint {
121 pub checkpoint_id: CheckpointId,
123 pub run_id: RunId,
125 pub conversation_id: ConversationId,
127 pub node: AgentExecutionNode,
129 pub run_step: usize,
131 pub resume: AgentResumeEvidence,
133 pub state: AgentRunState,
135 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
137 pub metadata: Metadata,
138}
139
140impl starweaver_core::VersionedRecord for AgentCheckpoint {
141 const SCHEMA: &'static str = "starweaver.runtime.checkpoint";
142 const ALLOW_BARE_V0: bool = true;
143}
144
145impl AgentCheckpoint {
146 #[must_use]
148 pub fn new(node: AgentExecutionNode, state: &AgentRunState) -> Self {
149 let durable_state = project_run_state(state);
150 Self {
151 checkpoint_id: CheckpointId::new(),
152 run_id: state.run_id.clone(),
153 conversation_id: state.conversation_id.clone(),
154 node,
155 run_step: state.run_step,
156 resume: AgentResumeEvidence::new(node, &durable_state),
157 state: durable_state,
158 metadata: Metadata::default(),
159 }
160 }
161
162 #[must_use]
164 pub fn with_metadata(mut self, metadata: Metadata) -> Self {
165 self.metadata = metadata;
166 self
167 }
168
169 #[must_use]
171 pub fn with_stream_cursor(mut self, stream_cursor: usize) -> Self {
172 self.resume = self.resume.with_stream_cursor(stream_cursor);
173 self
174 }
175}
176
177#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
179#[serde(tag = "kind", rename_all = "snake_case")]
180pub enum AgentExecutionDecision {
181 Continue,
183 Suspend {
185 reason: String,
187 },
188}
189
190#[derive(Debug, Error)]
192pub enum AgentExecutorError {
193 #[error("executor failed: {0}")]
195 Failed(String),
196}
197
198#[async_trait]
200pub trait AgentExecutor: Send + Sync {
201 #[must_use]
207 fn requires_durable_hitl_preparation(&self) -> bool {
208 false
209 }
210
211 async fn checkpoint(
213 &self,
214 checkpoint: AgentCheckpoint,
215 ) -> Result<AgentExecutionDecision, AgentExecutorError>;
216}