Skip to main content

molo_core/
effect.rs

1//! Effect protocol: side-effect requests and observations.
2//!
3//! Effects are the boundary between an agent kernel and an outer harness.
4//! Tools may parse model arguments into an [`EffectRequest`], but the
5//! request is not executed by the tool itself. A harness or application
6//! runtime classifies, approves, sandboxes, executes, audits, and returns an
7//! [`EffectObservation`] for the agent to consume.
8
9use crate::run::{Artifact, RunMetadata};
10use crate::tool::ToolMemoryPolicy;
11use serde::{Deserialize, Serialize};
12use std::sync::OnceLock;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16/// Output intended for host/UI display.
17///
18/// Display output is not automatically inserted into model context. The
19/// model-visible text is carried separately by
20/// [`ToolOutput::content`](crate::tool::ToolOutput::content) or
21/// [`EffectOutput::observation_for_model`].
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct DisplayOutput {
24    /// Display format.
25    pub format: DisplayFormat,
26    /// Host/UI-facing content.
27    pub content: String,
28    /// Host-owned display metadata.
29    pub metadata: RunMetadata,
30}
31
32impl DisplayOutput {
33    /// Constructs display output.
34    pub fn new(format: DisplayFormat, content: impl Into<String>) -> Self {
35        Self {
36            format,
37            content: content.into(),
38            metadata: RunMetadata::new(),
39        }
40    }
41
42    /// Sets host-owned metadata.
43    pub fn with_metadata(mut self, metadata: RunMetadata) -> Self {
44        self.metadata = metadata;
45        self
46    }
47}
48
49/// Display output format.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[non_exhaustive]
52pub enum DisplayFormat {
53    /// Plain text.
54    PlainText,
55    /// Markdown.
56    Markdown,
57    /// JSON text.
58    Json,
59    /// Application-specific format, preferably namespaced.
60    Custom(String),
61}
62
63/// Kind of side effect requested by an agent.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[non_exhaustive]
66pub enum EffectKind {
67    /// Read a file or file-like workspace resource.
68    ReadFile,
69    /// Write a file or file-like workspace resource.
70    WriteFile,
71    /// Apply a patch.
72    ApplyPatch,
73    /// Search data, files, or indexes.
74    Search,
75    /// Execute a command.
76    ExecuteCommand,
77    /// Inspect or mutate git state.
78    Git,
79    /// Perform network I/O.
80    Network,
81    /// Drive a browser.
82    Browser,
83    /// Call an MCP server or MCP-like adapter.
84    Mcp,
85    /// Application-specific effect kind, preferably namespaced.
86    Custom(String),
87}
88
89/// Request-declared risk level.
90///
91/// A harness may reclassify or override this value. The requester-provided
92/// risk is a signal, not an authorization decision.
93#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
94#[non_exhaustive]
95pub enum RiskLevel {
96    /// Low-risk operation.
97    #[default]
98    Low,
99    /// Medium-risk operation.
100    Medium,
101    /// High-risk operation.
102    High,
103    /// Critical-risk operation.
104    Critical,
105}
106
107/// Source tool call that produced an effect request.
108#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
109pub struct EffectSource {
110    /// Source model tool-call id, when produced by a tool.
111    pub tool_call_id: Option<String>,
112    /// Source tool name, when produced by a tool.
113    pub tool_name: Option<String>,
114}
115
116/// Request for an outer harness to govern and execute a side effect.
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct EffectRequest {
119    /// Effect id, unique within the process or harness session.
120    pub id: String,
121    /// Effect kind.
122    pub kind: EffectKind,
123    /// User-visible approval/audit description.
124    pub description: String,
125    /// Kind-specific JSON payload.
126    pub payload: serde_json::Value,
127    /// Source tool-call metadata, when produced by a tool.
128    pub source: EffectSource,
129    /// Request-declared risk.
130    pub risk: RiskLevel,
131    /// Request-level timeout suggestion.
132    pub timeout: Option<Duration>,
133    /// Host/application metadata.
134    pub metadata: RunMetadata,
135}
136
137impl EffectRequest {
138    /// Constructs an effect request with a generated id.
139    pub fn new(
140        kind: EffectKind,
141        description: impl Into<String>,
142        payload: serde_json::Value,
143    ) -> Self {
144        Self {
145            id: generated_effect_id(),
146            kind,
147            description: description.into(),
148            payload,
149            source: EffectSource::default(),
150            risk: RiskLevel::Low,
151            timeout: None,
152            metadata: RunMetadata::new(),
153        }
154    }
155
156    /// Overrides the generated effect id.
157    pub fn with_id(mut self, id: impl Into<String>) -> Self {
158        self.id = id.into();
159        self
160    }
161
162    /// Sets the source tool-call metadata.
163    pub fn with_source(
164        mut self,
165        tool_call_id: impl Into<String>,
166        tool_name: impl Into<String>,
167    ) -> Self {
168        self.source.tool_call_id = Some(tool_call_id.into());
169        self.source.tool_name = Some(tool_name.into());
170        self
171    }
172
173    /// Fills missing source fields without overwriting fields already set by
174    /// the tool.
175    pub fn with_source_if_missing(
176        mut self,
177        tool_call_id: impl Into<String>,
178        tool_name: impl Into<String>,
179    ) -> Self {
180        if self.source.tool_call_id.is_none() {
181            self.source.tool_call_id = Some(tool_call_id.into());
182        }
183        if self.source.tool_name.is_none() {
184            self.source.tool_name = Some(tool_name.into());
185        }
186        self
187    }
188
189    /// Sets the request-declared risk.
190    pub fn with_risk(mut self, risk: RiskLevel) -> Self {
191        self.risk = risk;
192        self
193    }
194
195    /// Sets the request-level timeout suggestion.
196    pub fn with_timeout(mut self, timeout: Duration) -> Self {
197        self.timeout = Some(timeout);
198        self
199    }
200
201    /// Sets host/application metadata.
202    pub fn with_metadata(mut self, metadata: RunMetadata) -> Self {
203        self.metadata = metadata;
204        self
205    }
206}
207
208/// Terminal status of an executed effect.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210#[non_exhaustive]
211pub enum EffectStatus {
212    /// Effect succeeded.
213    Succeeded,
214    /// Effect was denied by policy or approval.
215    Denied,
216    /// Effect execution failed.
217    Failed,
218    /// Effect execution was cancelled.
219    Cancelled,
220    /// Effect execution timed out.
221    TimedOut,
222}
223
224/// Output produced by an executed effect.
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226pub struct EffectOutput {
227    /// Model-visible observation text.
228    pub observation_for_model: String,
229    /// Optional host/UI display output.
230    pub display: Option<DisplayOutput>,
231    /// Artifact handles produced by the effect.
232    pub artifacts: Vec<Artifact>,
233    /// Memory policy for the model-visible observation.
234    pub memory_policy: ToolMemoryPolicy,
235    /// Host/application metadata.
236    pub metadata: RunMetadata,
237}
238
239impl EffectOutput {
240    /// Constructs a text observation for the model.
241    pub fn text(observation_for_model: impl Into<String>) -> Self {
242        Self {
243            observation_for_model: observation_for_model.into(),
244            display: None,
245            artifacts: Vec::new(),
246            memory_policy: ToolMemoryPolicy::Normal,
247            metadata: RunMetadata::new(),
248        }
249    }
250
251    /// Sets host/UI display output.
252    pub fn with_display(mut self, display: DisplayOutput) -> Self {
253        self.display = Some(display);
254        self
255    }
256
257    /// Sets artifact handles.
258    pub fn with_artifacts(mut self, artifacts: Vec<Artifact>) -> Self {
259        self.artifacts = artifacts;
260        self
261    }
262
263    /// Sets the memory policy.
264    pub fn with_memory_policy(mut self, policy: ToolMemoryPolicy) -> Self {
265        self.memory_policy = policy;
266        self
267    }
268
269    /// Sets host/application metadata.
270    pub fn with_metadata(mut self, metadata: RunMetadata) -> Self {
271        self.metadata = metadata;
272        self
273    }
274}
275
276/// Observation returned to an agent after an effect request is governed and
277/// executed by an outer harness.
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct EffectObservation {
280    /// Effect id matching [`EffectRequest::id`].
281    pub effect_id: String,
282    /// Execution status.
283    pub status: EffectStatus,
284    /// Effect output.
285    pub output: EffectOutput,
286    /// Host/application metadata.
287    pub metadata: RunMetadata,
288}
289
290impl EffectObservation {
291    /// Constructs a successful text observation.
292    pub fn succeeded(effect_id: impl Into<String>, observation: impl Into<String>) -> Self {
293        Self {
294            effect_id: effect_id.into(),
295            status: EffectStatus::Succeeded,
296            output: EffectOutput::text(observation),
297            metadata: RunMetadata::new(),
298        }
299    }
300
301    /// Sets host/application metadata.
302    pub fn with_metadata(mut self, metadata: RunMetadata) -> Self {
303        self.metadata = metadata;
304        self
305    }
306}
307
308fn generated_effect_id() -> String {
309    static START_NANOS: OnceLock<u128> = OnceLock::new();
310    static EFFECT_COUNTER: AtomicU64 = AtomicU64::new(0);
311
312    let start_nanos = *START_NANOS.get_or_init(|| {
313        SystemTime::now()
314            .duration_since(UNIX_EPOCH)
315            .unwrap_or_default()
316            .as_nanos()
317    });
318    let n = EFFECT_COUNTER.fetch_add(1, Ordering::Relaxed);
319    format!("effect-{start_nanos}-{n}")
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use serde_json::json;
326
327    #[test]
328    fn effect_request_builder_sets_fields() {
329        let request = EffectRequest::new(EffectKind::ReadFile, "read", json!({"path": "a.rs"}))
330            .with_id("effect-1")
331            .with_source("call-1", "read_file")
332            .with_risk(RiskLevel::Medium)
333            .with_timeout(Duration::from_secs(5));
334
335        assert_eq!(request.id, "effect-1");
336        assert_eq!(request.source.tool_call_id.as_deref(), Some("call-1"));
337        assert_eq!(request.source.tool_name.as_deref(), Some("read_file"));
338        assert_eq!(request.risk, RiskLevel::Medium);
339        assert_eq!(request.timeout, Some(Duration::from_secs(5)));
340    }
341
342    #[test]
343    fn effect_output_text_is_model_visible_only() {
344        let output = EffectOutput::text("observed")
345            .with_display(DisplayOutput::new(DisplayFormat::Markdown, "**observed**"));
346        assert_eq!(output.observation_for_model, "observed");
347        assert_eq!(output.display.unwrap().content, "**observed**");
348    }
349}