Skip to main content

starweaver_session/
approval.rs

1//! Approval and deferred tool records.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use starweaver_core::{Metadata, RunId, SessionId, TraceContext};
7use starweaver_model::TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY;
8
9use crate::records::ExecutionStatus;
10
11const fn initial_interaction_revision() -> u64 {
12    1
13}
14
15/// Tool-return evidence used to derive durable HITL records without depending on model crates.
16pub struct ToolReturnRecordInput<'a> {
17    /// Session id that owns the tool return.
18    pub session_id: &'a SessionId,
19    /// Run id that owns the tool return.
20    pub run_id: &'a RunId,
21    /// Tool call id.
22    pub tool_call_id: &'a str,
23    /// Tool name.
24    pub tool_name: &'a str,
25    /// Tool return metadata.
26    pub metadata: &'a Metadata,
27    /// Trace context propagated by the run, when available.
28    pub trace_context: Option<&'a TraceContext>,
29    /// Optional host policy metadata to copy into created records.
30    pub policy: Option<Value>,
31}
32
33impl<'a> ToolReturnRecordInput<'a> {
34    /// Create record input from durable ids and tool-return fields.
35    #[must_use]
36    pub const fn new(
37        session_id: &'a SessionId,
38        run_id: &'a RunId,
39        tool_call_id: &'a str,
40        tool_name: &'a str,
41        metadata: &'a Metadata,
42    ) -> Self {
43        Self {
44            session_id,
45            run_id,
46            tool_call_id,
47            tool_name,
48            metadata,
49            trace_context: None,
50            policy: None,
51        }
52    }
53
54    /// Attach trace context to generated records.
55    #[must_use]
56    pub const fn with_trace_context(mut self, trace_context: &'a TraceContext) -> Self {
57        self.trace_context = Some(trace_context);
58        self
59    }
60
61    /// Attach host policy metadata to generated records.
62    #[must_use]
63    pub fn with_policy(mut self, policy: Value) -> Self {
64        self.policy = Some(policy);
65        self
66    }
67
68    fn control_flow(&self) -> Option<&str> {
69        self.metadata.get("control_flow").and_then(Value::as_str)
70    }
71
72    fn approval_id(&self) -> String {
73        format!("approval_{}_{}", self.run_id.as_str(), self.tool_call_id)
74    }
75
76    fn deferred_id(&self) -> String {
77        format!("deferred_{}_{}", self.run_id.as_str(), self.tool_call_id)
78    }
79}
80
81/// Durable approval status.
82#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum ApprovalStatus {
85    /// Approval is waiting for a decision.
86    #[default]
87    Pending,
88    /// Approval was granted.
89    Approved,
90    /// Approval was denied.
91    Denied,
92    /// Approval expired.
93    Expired,
94    /// Approval was cancelled.
95    Cancelled,
96}
97
98/// Recorded approval decision.
99#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
100pub struct ApprovalDecision {
101    /// Decision status.
102    pub status: ApprovalStatus,
103    /// Actor that made the decision.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub decided_by: Option<String>,
106    /// Decision time.
107    pub decided_at: DateTime<Utc>,
108    /// Decision reason or note.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub reason: Option<String>,
111    /// Decision metadata.
112    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
113    pub metadata: Metadata,
114}
115
116/// Durable approval prompt record.
117#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
118pub struct ApprovalRecord {
119    /// Approval id.
120    pub approval_id: String,
121    /// Monotonic optimistic-concurrency revision.
122    #[serde(default = "initial_interaction_revision")]
123    pub revision: u64,
124    /// Session id.
125    pub session_id: SessionId,
126    /// Run id.
127    pub run_id: RunId,
128    /// Tool call id or action id.
129    pub action_id: String,
130    /// Tool or action name.
131    pub action_name: String,
132    /// Requested action payload.
133    #[serde(default, skip_serializing_if = "Value::is_null")]
134    pub request: Value,
135    /// Canonical tool arguments independently persisted as the reviewed effect boundary.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub reviewed_arguments: Option<Value>,
138    /// Current approval status.
139    #[serde(default)]
140    pub status: ApprovalStatus,
141    /// Recorded decision.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub decision: Option<ApprovalDecision>,
144    /// Creation time.
145    pub created_at: DateTime<Utc>,
146    /// Update time.
147    pub updated_at: DateTime<Utc>,
148    /// Trace context.
149    #[serde(default, skip_serializing_if = "TraceContext::is_empty")]
150    pub trace_context: TraceContext,
151    /// Approval metadata.
152    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
153    pub metadata: Metadata,
154}
155
156impl starweaver_core::VersionedRecord for ApprovalRecord {
157    const SCHEMA: &'static str = "starweaver.session.approval_record";
158    const ALLOW_BARE_V0: bool = true;
159}
160
161impl ApprovalRecord {
162    /// Build a pending approval record.
163    #[must_use]
164    pub fn new(
165        approval_id: impl Into<String>,
166        session_id: SessionId,
167        run_id: RunId,
168        action_id: impl Into<String>,
169        action_name: impl Into<String>,
170    ) -> Self {
171        let now = Utc::now();
172        Self {
173            approval_id: approval_id.into(),
174            revision: initial_interaction_revision(),
175            session_id,
176            run_id,
177            action_id: action_id.into(),
178            action_name: action_name.into(),
179            request: Value::Null,
180            reviewed_arguments: None,
181            status: ApprovalStatus::Pending,
182            decision: None,
183            created_at: now,
184            updated_at: now,
185            trace_context: TraceContext::default(),
186            metadata: Metadata::default(),
187        }
188    }
189
190    /// Build a durable approval record from an approval-required tool return.
191    #[must_use]
192    pub fn from_tool_return(input: &ToolReturnRecordInput<'_>) -> Option<Self> {
193        if input.control_flow() != Some("approval_required") {
194            return None;
195        }
196        let mut record = Self::new(
197            input.approval_id(),
198            input.session_id.clone(),
199            input.run_id.clone(),
200            input.tool_call_id,
201            input.tool_name,
202        );
203        record.request = input
204            .metadata
205            .get("approval")
206            .cloned()
207            .unwrap_or(Value::Null);
208        record.reviewed_arguments = input
209            .metadata
210            .get(TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY)
211            .cloned();
212        if let Some(trace_context) = input.trace_context {
213            record.trace_context = trace_context.clone();
214        }
215        if let Some(policy) = &input.policy {
216            record.metadata.insert("policy".to_string(), policy.clone());
217        }
218        Some(record)
219    }
220}
221
222/// Tool approval decision supplied by a host or user.
223#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
224#[serde(tag = "decision", rename_all = "snake_case")]
225pub enum ToolApprovalDecision {
226    /// Approve tool execution.
227    Approved {
228        /// Actor that approved the call.
229        #[serde(default, skip_serializing_if = "Option::is_none")]
230        decided_by: Option<String>,
231        /// Optional reason.
232        #[serde(default, skip_serializing_if = "Option::is_none")]
233        reason: Option<String>,
234        /// Optional replacement arguments for the approved call.
235        #[serde(default, skip_serializing_if = "Option::is_none")]
236        override_arguments: Option<Value>,
237        /// Decision metadata.
238        #[serde(default, skip_serializing_if = "Metadata::is_empty")]
239        metadata: Metadata,
240    },
241    /// Deny tool execution.
242    Denied {
243        /// Actor that denied the call.
244        #[serde(default, skip_serializing_if = "Option::is_none")]
245        decided_by: Option<String>,
246        /// Optional reason.
247        #[serde(default, skip_serializing_if = "Option::is_none")]
248        reason: Option<String>,
249        /// Decision metadata.
250        #[serde(default, skip_serializing_if = "Metadata::is_empty")]
251        metadata: Metadata,
252    },
253}
254
255impl ToolApprovalDecision {
256    /// Build an approved decision.
257    #[must_use]
258    pub fn approved() -> Self {
259        Self::Approved {
260            decided_by: None,
261            reason: None,
262            override_arguments: None,
263            metadata: Metadata::default(),
264        }
265    }
266
267    /// Build a denied decision with a reason.
268    #[must_use]
269    pub fn denied(reason: impl Into<String>) -> Self {
270        Self::Denied {
271            decided_by: None,
272            reason: Some(reason.into()),
273            metadata: Metadata::default(),
274        }
275    }
276
277    /// Attach replacement arguments to an approved decision.
278    #[must_use]
279    pub fn with_override_arguments(self, arguments: Value) -> Self {
280        match self {
281            Self::Approved {
282                decided_by,
283                reason,
284                metadata,
285                ..
286            } => Self::Approved {
287                decided_by,
288                reason,
289                override_arguments: Some(arguments),
290                metadata,
291            },
292            denied @ Self::Denied { .. } => denied,
293        }
294    }
295
296    /// Convert this SDK decision into a durable approval decision.
297    #[must_use]
298    pub fn into_approval_decision(self) -> ApprovalDecision {
299        let decided_at = Utc::now();
300        match self {
301            Self::Approved {
302                decided_by,
303                reason,
304                override_arguments,
305                mut metadata,
306            } => {
307                if let Some(arguments) = override_arguments {
308                    metadata.insert("override_arguments".to_string(), arguments);
309                }
310                ApprovalDecision {
311                    status: ApprovalStatus::Approved,
312                    decided_by,
313                    decided_at,
314                    reason,
315                    metadata,
316                }
317            }
318            Self::Denied {
319                decided_by,
320                reason,
321                metadata,
322            } => ApprovalDecision {
323                status: ApprovalStatus::Denied,
324                decided_by,
325                decided_at,
326                reason,
327                metadata,
328            },
329        }
330    }
331}
332
333/// Durable deferred tool record.
334#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
335pub struct DeferredToolRecord {
336    /// Deferred record id.
337    pub deferred_id: String,
338    /// Monotonic optimistic-concurrency revision.
339    #[serde(default = "initial_interaction_revision")]
340    pub revision: u64,
341    /// Session id.
342    pub session_id: SessionId,
343    /// Run id.
344    pub run_id: RunId,
345    /// Tool call id.
346    pub tool_call_id: String,
347    /// Tool name.
348    pub tool_name: String,
349    /// Arguments or request payload.
350    #[serde(default, skip_serializing_if = "Value::is_null")]
351    pub request: Value,
352    /// Deferred status.
353    pub status: ExecutionStatus,
354    /// Optional response payload.
355    #[serde(default, skip_serializing_if = "Value::is_null")]
356    pub response: Value,
357    /// Creation time.
358    pub created_at: DateTime<Utc>,
359    /// Update time.
360    pub updated_at: DateTime<Utc>,
361    /// Trace context.
362    #[serde(default, skip_serializing_if = "TraceContext::is_empty")]
363    pub trace_context: TraceContext,
364    /// Deferred metadata.
365    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
366    pub metadata: Metadata,
367}
368
369impl starweaver_core::VersionedRecord for DeferredToolRecord {
370    const SCHEMA: &'static str = "starweaver.session.deferred_tool_record";
371    const ALLOW_BARE_V0: bool = true;
372}
373
374impl DeferredToolRecord {
375    /// Build a deferred tool record.
376    #[must_use]
377    pub fn new(
378        deferred_id: impl Into<String>,
379        session_id: SessionId,
380        run_id: RunId,
381        tool_call_id: impl Into<String>,
382        tool_name: impl Into<String>,
383    ) -> Self {
384        let now = Utc::now();
385        Self {
386            deferred_id: deferred_id.into(),
387            revision: initial_interaction_revision(),
388            session_id,
389            run_id,
390            tool_call_id: tool_call_id.into(),
391            tool_name: tool_name.into(),
392            request: Value::Null,
393            status: ExecutionStatus::Pending,
394            response: Value::Null,
395            created_at: now,
396            updated_at: now,
397            trace_context: TraceContext::default(),
398            metadata: Metadata::default(),
399        }
400    }
401
402    /// Build a durable deferred-tool record from a deferred tool return.
403    #[must_use]
404    pub fn from_tool_return(input: &ToolReturnRecordInput<'_>) -> Option<Self> {
405        if input.control_flow() != Some("call_deferred") {
406            return None;
407        }
408        let mut record = Self::new(
409            input.deferred_id(),
410            input.session_id.clone(),
411            input.run_id.clone(),
412            input.tool_call_id,
413            input.tool_name,
414        );
415        record.request = input
416            .metadata
417            .get("deferred")
418            .cloned()
419            .unwrap_or(Value::Null);
420        record.status = ExecutionStatus::Waiting;
421        if let Some(trace_context) = input.trace_context {
422            record.trace_context = trace_context.clone();
423        }
424        if let Some(policy) = &input.policy {
425            record.metadata.insert("policy".to_string(), policy.clone());
426        }
427        Some(record)
428    }
429}
430
431/// SDK-facing deferred tool request.
432#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
433pub struct DeferredToolRequest {
434    /// Deferred request id.
435    pub deferred_id: String,
436    /// Session id.
437    pub session_id: SessionId,
438    /// Run id.
439    pub run_id: RunId,
440    /// Tool call id.
441    pub tool_call_id: String,
442    /// Tool name.
443    pub tool_name: String,
444    /// Arguments requested by the model or host.
445    #[serde(default, skip_serializing_if = "Value::is_null")]
446    pub arguments: Value,
447    /// Trace context.
448    #[serde(default, skip_serializing_if = "TraceContext::is_empty")]
449    pub trace_context: TraceContext,
450    /// Request metadata.
451    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
452    pub metadata: Metadata,
453}
454
455impl DeferredToolRequest {
456    /// Convert a durable record into an SDK request facade.
457    #[must_use]
458    pub fn from_record(record: &DeferredToolRecord) -> Self {
459        Self {
460            deferred_id: record.deferred_id.clone(),
461            session_id: record.session_id.clone(),
462            run_id: record.run_id.clone(),
463            tool_call_id: record.tool_call_id.clone(),
464            tool_name: record.tool_name.clone(),
465            arguments: record.request.clone(),
466            trace_context: record.trace_context.clone(),
467            metadata: record.metadata.clone(),
468        }
469    }
470
471    /// Convert this request into a durable record.
472    #[must_use]
473    pub fn into_record(self) -> DeferredToolRecord {
474        let mut record = DeferredToolRecord::new(
475            self.deferred_id,
476            self.session_id,
477            self.run_id,
478            self.tool_call_id,
479            self.tool_name,
480        );
481        record.request = self.arguments;
482        record.trace_context = self.trace_context;
483        record.metadata = self.metadata;
484        record
485    }
486}
487
488/// Collection of deferred tool requests.
489#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
490pub struct DeferredToolRequests {
491    /// Deferred requests.
492    #[serde(default, skip_serializing_if = "Vec::is_empty")]
493    pub requests: Vec<DeferredToolRequest>,
494}
495
496impl DeferredToolRequests {
497    /// Build a request collection from durable records.
498    #[must_use]
499    pub fn from_records(records: &[DeferredToolRecord]) -> Self {
500        Self {
501            requests: records
502                .iter()
503                .map(DeferredToolRequest::from_record)
504                .collect(),
505        }
506    }
507
508    /// Return whether the collection is empty.
509    #[must_use]
510    pub const fn is_empty(&self) -> bool {
511        self.requests.is_empty()
512    }
513}
514
515/// SDK-facing deferred tool result.
516#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
517pub struct DeferredToolResult {
518    /// Deferred request id.
519    pub deferred_id: String,
520    /// Result status.
521    pub status: ExecutionStatus,
522    /// Response payload.
523    #[serde(default, skip_serializing_if = "Value::is_null")]
524    pub response: Value,
525    /// Result metadata.
526    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
527    pub metadata: Metadata,
528}
529
530impl DeferredToolResult {
531    /// Build a completed deferred result.
532    #[must_use]
533    pub fn completed(deferred_id: impl Into<String>, response: Value) -> Self {
534        Self {
535            deferred_id: deferred_id.into(),
536            status: ExecutionStatus::Completed,
537            response,
538            metadata: Metadata::default(),
539        }
540    }
541
542    /// Build a failed deferred result.
543    #[must_use]
544    pub fn failed(deferred_id: impl Into<String>, response: Value) -> Self {
545        Self {
546            deferred_id: deferred_id.into(),
547            status: ExecutionStatus::Failed,
548            response,
549            metadata: Metadata::default(),
550        }
551    }
552
553    /// Build a cancelled deferred result.
554    #[must_use]
555    pub fn cancelled(deferred_id: impl Into<String>, response: Value) -> Self {
556        Self {
557            deferred_id: deferred_id.into(),
558            status: ExecutionStatus::Cancelled,
559            response,
560            metadata: Metadata::default(),
561        }
562    }
563
564    /// Attach result metadata.
565    #[must_use]
566    pub fn with_metadata(mut self, metadata: Metadata) -> Self {
567        self.metadata = metadata;
568        self
569    }
570
571    /// Apply this result to a durable deferred record.
572    pub fn apply_to_record(self, record: &mut DeferredToolRecord) {
573        record.status = self.status;
574        record.response = self.response;
575        record.updated_at = Utc::now();
576        record.metadata.extend(self.metadata);
577    }
578}
579
580/// Collection of deferred tool results.
581#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
582pub struct DeferredToolResults {
583    /// Deferred results.
584    #[serde(default, skip_serializing_if = "Vec::is_empty")]
585    pub results: Vec<DeferredToolResult>,
586}
587
588impl DeferredToolResults {
589    /// Build a result collection.
590    #[must_use]
591    pub fn new(results: impl IntoIterator<Item = DeferredToolResult>) -> Self {
592        Self {
593            results: results.into_iter().collect(),
594        }
595    }
596
597    /// Return whether the collection is empty.
598    #[must_use]
599    pub const fn is_empty(&self) -> bool {
600        self.results.is_empty()
601    }
602}