Skip to main content

synwire_core/agents/
directive.rs

1//! Directive system for agent effects.
2
3use crate::State;
4use crate::agents::streaming::AgentEvent;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::time::Duration;
8
9/// Directive - typed effect description returned by agent nodes.
10///
11/// Directives describe side effects without executing them, enabling pure unit testing.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(tag = "type")]
14#[non_exhaustive]
15pub enum Directive {
16    /// Emit an event to the event stream.
17    #[serde(rename = "emit")]
18    Emit {
19        /// Event to emit.
20        event: AgentEvent,
21    },
22
23    /// Request spawning a child agent.
24    #[serde(rename = "spawn_agent")]
25    SpawnAgent {
26        /// Agent name.
27        name: String,
28        /// Agent configuration.
29        config: Value,
30    },
31
32    /// Request stopping a child agent.
33    #[serde(rename = "stop_child")]
34    StopChild {
35        /// Child agent name.
36        name: String,
37    },
38
39    /// Schedule a delayed action.
40    #[serde(rename = "schedule")]
41    Schedule {
42        /// Action to schedule.
43        action: String,
44        /// Delay duration.
45        #[serde(with = "humantime_serde")]
46        delay: Duration,
47    },
48
49    /// Request runtime to execute instruction and route result back.
50    #[serde(rename = "run_instruction")]
51    RunInstruction {
52        /// Instruction text.
53        instruction: String,
54        /// Input data.
55        input: Value,
56    },
57
58    /// Schedule recurring action.
59    #[serde(rename = "cron")]
60    Cron {
61        /// Cron expression.
62        expression: String,
63        /// Action to execute.
64        action: String,
65    },
66
67    /// Request agent stop.
68    #[serde(rename = "stop")]
69    Stop {
70        /// Optional reason.
71        reason: Option<String>,
72    },
73
74    /// Spawn a background task.
75    #[serde(rename = "spawn_task")]
76    SpawnTask {
77        /// Task description.
78        description: String,
79        /// Task input data.
80        input: Value,
81    },
82
83    /// Cancel a background task.
84    #[serde(rename = "stop_task")]
85    StopTask {
86        /// Task ID to stop.
87        task_id: String,
88    },
89
90    /// User-defined directive (requires typetag registration).
91    #[serde(rename = "custom")]
92    Custom {
93        /// Custom directive payload.
94        #[serde(flatten)]
95        payload: Box<dyn DirectivePayload>,
96    },
97}
98
99/// Trait for custom directive payloads.
100///
101/// Implement this trait and use `#[typetag::serde]` for serialization support.
102#[typetag::serde(tag = "custom_type")]
103pub trait DirectivePayload: std::fmt::Debug + Send + Sync + dyn_clone::DynClone {}
104
105dyn_clone::clone_trait_object!(DirectivePayload);
106
107/// Result combining state update and zero or more directives.
108///
109/// Agent nodes return this to indicate both immediate state changes
110/// and deferred effects to be executed by the runtime.
111#[derive(Debug, Clone)]
112pub struct DirectiveResult<S: State> {
113    /// Updated state (applied immediately).
114    pub state: S,
115    /// Deferred effect descriptions (executed by runtime).
116    pub directives: Vec<Directive>,
117}
118
119impl<S: State> DirectiveResult<S> {
120    /// Create a result with only state, no directives.
121    #[must_use]
122    pub const fn state_only(state: S) -> Self {
123        Self {
124            state,
125            directives: Vec::new(),
126        }
127    }
128
129    /// Create a result with state and a single directive.
130    #[must_use]
131    pub fn with_directive(state: S, directive: Directive) -> Self {
132        Self {
133            state,
134            directives: vec![directive],
135        }
136    }
137
138    /// Create a result with state and multiple directives.
139    #[must_use]
140    pub const fn with_directives(state: S, directives: Vec<Directive>) -> Self {
141        Self { state, directives }
142    }
143}
144
145impl<S: State> From<S> for DirectiveResult<S> {
146    fn from(state: S) -> Self {
147        Self::state_only(state)
148    }
149}
150
151#[cfg(test)]
152#[allow(
153    clippy::unwrap_used,
154    clippy::expect_used,
155    clippy::panic,
156    clippy::redundant_clone
157)]
158mod tests {
159    use super::*;
160    use crate::agents::streaming::{AgentEvent, TerminationReason};
161
162    #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
163    struct TestState {
164        count: u32,
165    }
166
167    impl State for TestState {
168        fn to_value(&self) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
169            Ok(serde_json::to_value(self)?)
170        }
171
172        fn from_value(value: Value) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
173            Ok(serde_json::from_value(value)?)
174        }
175    }
176
177    #[test]
178    fn test_directive_result_state_only() {
179        let state = TestState { count: 1 };
180        let result = DirectiveResult::state_only(state.clone());
181        assert_eq!(result.state, state);
182        assert!(result.directives.is_empty());
183    }
184
185    #[test]
186    fn test_directive_result_with_directive() {
187        let state = TestState { count: 1 };
188        let directive = Directive::Stop { reason: None };
189        let result = DirectiveResult::with_directive(state.clone(), directive.clone());
190        assert_eq!(result.state, state);
191        assert_eq!(result.directives.len(), 1);
192    }
193
194    #[test]
195    fn test_directive_result_from_state() {
196        let state = TestState { count: 1 };
197        let result: DirectiveResult<TestState> = state.clone().into();
198        assert_eq!(result.state, state);
199        assert!(result.directives.is_empty());
200    }
201
202    #[test]
203    fn test_directive_serde_emit() {
204        let directive = Directive::Emit {
205            event: AgentEvent::TurnComplete {
206                reason: TerminationReason::Complete,
207            },
208        };
209        let json = serde_json::to_string(&directive).expect("serialize");
210        let deserialized: Directive = serde_json::from_str(&json).expect("deserialize");
211        // Manual check since AgentEvent doesn't derive PartialEq
212        assert!(matches!(deserialized, Directive::Emit { .. }));
213    }
214
215    #[test]
216    fn test_directive_serde_spawn_agent() {
217        let directive = Directive::SpawnAgent {
218            name: "helper".to_string(),
219            config: serde_json::json!({"model": "gpt-4"}),
220        };
221        let json = serde_json::to_string(&directive).expect("serialize");
222        let deserialized: Directive = serde_json::from_str(&json).expect("deserialize");
223        assert!(matches!(deserialized, Directive::SpawnAgent { .. }));
224    }
225
226    #[test]
227    fn test_directive_serde_stop() {
228        let directive = Directive::Stop {
229            reason: Some("Task complete".to_string()),
230        };
231        let json = serde_json::to_string(&directive).expect("serialize");
232        let deserialized: Directive = serde_json::from_str(&json).expect("deserialize");
233        assert!(matches!(deserialized, Directive::Stop { .. }));
234    }
235}