use crate::run::{Artifact, RunMetadata};
use crate::tool::ToolMemoryPolicy;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DisplayOutput {
pub format: DisplayFormat,
pub content: String,
pub metadata: RunMetadata,
}
impl DisplayOutput {
pub fn new(format: DisplayFormat, content: impl Into<String>) -> Self {
Self {
format,
content: content.into(),
metadata: RunMetadata::new(),
}
}
pub fn with_metadata(mut self, metadata: RunMetadata) -> Self {
self.metadata = metadata;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum DisplayFormat {
PlainText,
Markdown,
Json,
Custom(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum EffectKind {
ReadFile,
WriteFile,
ApplyPatch,
Search,
ExecuteCommand,
Git,
Network,
Browser,
Mcp,
Custom(String),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum RiskLevel {
#[default]
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EffectSource {
pub tool_call_id: Option<String>,
pub tool_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EffectRequest {
pub id: String,
pub kind: EffectKind,
pub description: String,
pub payload: serde_json::Value,
pub source: EffectSource,
pub risk: RiskLevel,
pub timeout: Option<Duration>,
pub metadata: RunMetadata,
}
impl EffectRequest {
pub fn new(
kind: EffectKind,
description: impl Into<String>,
payload: serde_json::Value,
) -> Self {
Self {
id: generated_effect_id(),
kind,
description: description.into(),
payload,
source: EffectSource::default(),
risk: RiskLevel::Low,
timeout: None,
metadata: RunMetadata::new(),
}
}
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = id.into();
self
}
pub fn with_source(
mut self,
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
) -> Self {
self.source.tool_call_id = Some(tool_call_id.into());
self.source.tool_name = Some(tool_name.into());
self
}
pub fn with_source_if_missing(
mut self,
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
) -> Self {
if self.source.tool_call_id.is_none() {
self.source.tool_call_id = Some(tool_call_id.into());
}
if self.source.tool_name.is_none() {
self.source.tool_name = Some(tool_name.into());
}
self
}
pub fn with_risk(mut self, risk: RiskLevel) -> Self {
self.risk = risk;
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn with_metadata(mut self, metadata: RunMetadata) -> Self {
self.metadata = metadata;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum EffectStatus {
Succeeded,
Denied,
Failed,
Cancelled,
TimedOut,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EffectOutput {
pub observation_for_model: String,
pub display: Option<DisplayOutput>,
pub artifacts: Vec<Artifact>,
pub memory_policy: ToolMemoryPolicy,
pub metadata: RunMetadata,
}
impl EffectOutput {
pub fn text(observation_for_model: impl Into<String>) -> Self {
Self {
observation_for_model: observation_for_model.into(),
display: None,
artifacts: Vec::new(),
memory_policy: ToolMemoryPolicy::Normal,
metadata: RunMetadata::new(),
}
}
pub fn with_display(mut self, display: DisplayOutput) -> Self {
self.display = Some(display);
self
}
pub fn with_artifacts(mut self, artifacts: Vec<Artifact>) -> Self {
self.artifacts = artifacts;
self
}
pub fn with_memory_policy(mut self, policy: ToolMemoryPolicy) -> Self {
self.memory_policy = policy;
self
}
pub fn with_metadata(mut self, metadata: RunMetadata) -> Self {
self.metadata = metadata;
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EffectObservation {
pub effect_id: String,
pub status: EffectStatus,
pub output: EffectOutput,
pub metadata: RunMetadata,
}
impl EffectObservation {
pub fn succeeded(effect_id: impl Into<String>, observation: impl Into<String>) -> Self {
Self {
effect_id: effect_id.into(),
status: EffectStatus::Succeeded,
output: EffectOutput::text(observation),
metadata: RunMetadata::new(),
}
}
pub fn with_metadata(mut self, metadata: RunMetadata) -> Self {
self.metadata = metadata;
self
}
}
fn generated_effect_id() -> String {
static START_NANOS: OnceLock<u128> = OnceLock::new();
static EFFECT_COUNTER: AtomicU64 = AtomicU64::new(0);
let start_nanos = *START_NANOS.get_or_init(|| {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
});
let n = EFFECT_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("effect-{start_nanos}-{n}")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn effect_request_builder_sets_fields() {
let request = EffectRequest::new(EffectKind::ReadFile, "read", json!({"path": "a.rs"}))
.with_id("effect-1")
.with_source("call-1", "read_file")
.with_risk(RiskLevel::Medium)
.with_timeout(Duration::from_secs(5));
assert_eq!(request.id, "effect-1");
assert_eq!(request.source.tool_call_id.as_deref(), Some("call-1"));
assert_eq!(request.source.tool_name.as_deref(), Some("read_file"));
assert_eq!(request.risk, RiskLevel::Medium);
assert_eq!(request.timeout, Some(Duration::from_secs(5)));
}
#[test]
fn effect_output_text_is_model_visible_only() {
let output = EffectOutput::text("observed")
.with_display(DisplayOutput::new(DisplayFormat::Markdown, "**observed**"));
assert_eq!(output.observation_for_model, "observed");
assert_eq!(output.display.unwrap().content, "**observed**");
}
}