Skip to main content

tirea_contract/runtime/
control.rs

1//! Loop control-state schema stored under `Thread.state["loop_control"]`.
2//!
3//! These types define durable loop-control state for cross-step and cross-run
4//! flow control (pending interactions, inference error envelope).
5
6use crate::event::interaction::{FrontendToolInvocation, Interaction};
7use crate::thread::Thread;
8use serde::{Deserialize, Serialize};
9use tirea_state::State;
10
11/// Inference error emitted by the loop and consumed by telemetry plugins.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct InferenceError {
14    /// Stable error class used for metrics/telemetry dimensions.
15    #[serde(rename = "type")]
16    pub error_type: String,
17    /// Human-readable error message.
18    pub message: String,
19}
20
21/// Durable loop control state persisted at `state["loop_control"]`.
22///
23/// Used for cross-step and cross-run flow control that must survive restarts
24/// (not ephemeral in-memory variables).
25#[derive(Debug, Clone, Default, Serialize, Deserialize, State)]
26#[tirea(path = "loop_control")]
27pub struct LoopControlState {
28    /// Pending interaction that must be resolved by the client before the run can continue.
29    #[tirea(default = "None")]
30    pub pending_interaction: Option<Interaction>,
31    /// Structured frontend tool invocation (first-class model).
32    /// When present, takes precedence over `pending_interaction` for routing decisions.
33    #[tirea(default = "None")]
34    pub pending_frontend_invocation: Option<FrontendToolInvocation>,
35    /// Inference error envelope for AfterInference cleanup flow.
36    #[tirea(default = "None")]
37    pub inference_error: Option<InferenceError>,
38}
39
40/// Helpers for accessing loop control state from `Thread`.
41pub trait LoopControlExt {
42    /// Read pending interaction from durable control state.
43    fn pending_interaction(&self) -> Option<Interaction>;
44}
45
46impl LoopControlExt for Thread {
47    fn pending_interaction(&self) -> Option<Interaction> {
48        self.rebuild_state()
49            .ok()
50            .and_then(|state| {
51                state
52                    .get(LoopControlState::PATH)
53                    .and_then(|lc| lc.get("pending_interaction"))
54                    .cloned()
55            })
56            .and_then(|value| serde_json::from_value::<Interaction>(value).ok())
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use serde_json::Value;
64
65    #[test]
66    fn test_interaction_new() {
67        let interaction = Interaction::new("int_1", "confirm");
68        assert_eq!(interaction.id, "int_1");
69        assert_eq!(interaction.action, "confirm");
70        assert!(interaction.message.is_empty());
71        assert_eq!(interaction.parameters, Value::Null);
72        assert!(interaction.response_schema.is_none());
73    }
74
75    #[test]
76    fn test_loop_control_state_defaults() {
77        let state = LoopControlState::default();
78        assert!(state.pending_interaction.is_none());
79        assert!(state.inference_error.is_none());
80    }
81}