Skip to main content

a3s_effect/coding/
tool_effects.rs

1//! Versioned, dependency-neutral facts for one tool invocation.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use sha2::{Digest, Sha256};
6
7use crate::error::ActorError;
8
9pub const TOOL_PREPARED_SCHEMA: &str = "a3s.code.tool-prepared.v1";
10pub const TOOL_RESULT_SCHEMA: &str = "a3s.code.tool-result.v1";
11pub const MAX_TOOL_EFFECT_PATHS: usize = 4096;
12pub const MAX_TOOL_EFFECT_CANONICAL_BYTES: usize = 1024 * 1024;
13
14macro_rules! closed_set {
15    ($name:ident, $($variant:ident => $text:literal),+ $(,)?) => {
16        #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17        #[serde(rename_all = "snake_case")]
18        pub enum $name { $($variant),+ }
19        impl<'de> Deserialize<'de> for $name {
20            fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
21                let value = String::deserialize(deserializer)?;
22                Self::parse(&value).map_err(serde::de::Error::custom)
23            }
24        }
25        impl $name {
26            pub fn parse(value: &str) -> Result<Self, ActorError> {
27                match value {
28                    $($text => Ok(Self::$variant),)+
29                    _ => Err(ActorError::Schema(format!(
30                        "{} refuses unknown value {value:?}", stringify!($name)
31                    ))),
32                }
33            }
34            pub const fn as_str(self) -> &'static str {
35                match self { $(Self::$variant => $text,)+ }
36            }
37        }
38    };
39}
40
41closed_set!(ToolExecutionOutcome,
42    Succeeded => "succeeded", Failed => "failed", Cancelled => "cancelled",
43    Denied => "denied", Unknown => "unknown",
44);
45closed_set!(ToolRecoveryPolicy,
46    NeverReplay => "never_replay", QueryStatus => "query_status",
47    IdempotencyKey => "idempotency_key",
48);
49closed_set!(ToolObservationCompleteness, Complete => "complete", Incomplete => "incomplete");
50closed_set!(ToolFileKind,
51    File => "file", Directory => "directory", Symlink => "symlink",
52    Missing => "missing", Unknown => "unknown",
53);
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct ToolAdmissionIdentityV1 {
58    pub run_id: String,
59    pub thread_id: String,
60    pub capability_binding_digest: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct ToolInvocationIdentityV1 {
66    pub turn: u64,
67    pub cycle: u64,
68    pub tool_call_id: String,
69    pub batch_index: u32,
70    /// Absent only for the dependency-neutral/legacy runner adapter.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub admission: Option<ToolAdmissionIdentityV1>,
73}
74
75impl ToolInvocationIdentityV1 {
76    pub fn validate(&self) -> Result<(), ActorError> {
77        if self.turn == 0 || self.tool_call_id.is_empty() || self.tool_call_id.len() > 1024 {
78            return Err(identity_error("invalid scheduler identity"));
79        }
80        if let Some(admission) = &self.admission {
81            if admission.run_id.is_empty()
82                || admission.run_id.len() > 1024
83                || admission.thread_id.is_empty()
84                || admission.thread_id.len() > 128
85            {
86                return Err(identity_error("invalid admission identity"));
87            }
88            validate_digest(&admission.capability_binding_digest)?;
89        }
90        Ok(())
91    }
92
93    pub fn matches_scheduler(&self, expected: &Self) -> bool {
94        self.turn == expected.turn
95            && self.cycle == expected.cycle
96            && self.tool_call_id == expected.tool_call_id
97            && self.batch_index == expected.batch_index
98    }
99
100    pub fn key(&self) -> Result<String, ActorError> {
101        self.validate()?;
102        digest_json("a3s.code.tool-invocation.v1", self)
103    }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct ToolFileIdentityV1 {
109    pub kind: ToolFileKind,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub content_digest: Option<String>,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub mode: Option<u32>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct ToolPathChangeV1 {
119    pub path: String,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub before: Option<ToolFileIdentityV1>,
122    pub after: ToolFileIdentityV1,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct ToolEffectObservationV1 {
128    pub changes: Vec<ToolPathChangeV1>,
129    pub completeness: ToolObservationCompleteness,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub reason: Option<String>,
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub unresolved_external_operations: Vec<String>,
134}
135
136impl ToolEffectObservationV1 {
137    pub fn complete(changes: Vec<ToolPathChangeV1>) -> Self {
138        Self {
139            changes,
140            completeness: ToolObservationCompleteness::Complete,
141            reason: None,
142            unresolved_external_operations: Vec::new(),
143        }
144    }
145
146    pub fn incomplete(reason: impl Into<String>) -> Self {
147        Self {
148            changes: Vec::new(),
149            completeness: ToolObservationCompleteness::Incomplete,
150            reason: Some(reason.into()),
151            unresolved_external_operations: Vec::new(),
152        }
153    }
154
155    pub fn validate(&self) -> Result<(), ActorError> {
156        if self.changes.len() > MAX_TOOL_EFFECT_PATHS
157            || self.unresolved_external_operations.len() > MAX_TOOL_EFFECT_PATHS
158        {
159            return Err(observation_error(
160                "observation exceeds path/operation bound",
161            ));
162        }
163        if self.completeness == ToolObservationCompleteness::Incomplete
164            && self
165                .reason
166                .as_deref()
167                .is_none_or(|reason| reason.trim().is_empty())
168        {
169            return Err(observation_error("incomplete observation has no reason"));
170        }
171        let mut paths = std::collections::BTreeSet::new();
172        for change in &self.changes {
173            let escapes_workspace = std::path::Path::new(&change.path).components().any(|part| {
174                matches!(
175                    part,
176                    std::path::Component::RootDir
177                        | std::path::Component::ParentDir
178                        | std::path::Component::Prefix(_)
179                )
180            });
181            if change.path.is_empty()
182                || change.path.len() > 4096
183                || change.path.contains('\0')
184                || escapes_workspace
185                || !paths.insert(&change.path)
186            {
187                return Err(observation_error("invalid or duplicate mutation path"));
188            }
189            for identity in change.before.iter().chain(std::iter::once(&change.after)) {
190                if let Some(digest) = &identity.content_digest {
191                    validate_digest(digest)?;
192                }
193                if self.completeness == ToolObservationCompleteness::Complete
194                    && (identity.kind == ToolFileKind::Unknown
195                        || (matches!(identity.kind, ToolFileKind::File | ToolFileKind::Symlink)
196                            && identity.content_digest.is_none()))
197                {
198                    return Err(observation_error(
199                        "complete observation lacks byte identity",
200                    ));
201                }
202            }
203        }
204        bound_json(self)?;
205        Ok(())
206    }
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct ToolPreparedV1 {
212    pub schema: String,
213    pub identity: ToolInvocationIdentityV1,
214    pub tool_name: String,
215    pub original_args_digest: String,
216    pub effective_args_digest: String,
217    pub recovery: ToolRecoveryPolicy,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub baseline: Option<Value>,
220}
221
222impl ToolPreparedV1 {
223    pub fn validate(&self) -> Result<(), ActorError> {
224        if self.schema != TOOL_PREPARED_SCHEMA {
225            return Err(schema_error(&self.schema));
226        }
227        self.identity.validate()?;
228        if self.tool_name.is_empty() || self.tool_name.len() > 1024 {
229            return Err(identity_error("invalid tool name"));
230        }
231        validate_digest(&self.original_args_digest)?;
232        validate_digest(&self.effective_args_digest)?;
233        bound_json(self)?;
234        Ok(())
235    }
236
237    pub fn digest(&self) -> Result<String, ActorError> {
238        self.validate()?;
239        digest_json("a3s.code.tool-prepared-digest.v1", self)
240    }
241}
242
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244#[serde(deny_unknown_fields)]
245pub struct ToolExecutionResultV1 {
246    pub schema: String,
247    pub identity: ToolInvocationIdentityV1,
248    pub prepared_digest: String,
249    pub outcome: ToolExecutionOutcome,
250    pub exit_code: Option<i32>,
251    pub output: Value,
252    pub effects: ToolEffectObservationV1,
253}
254
255impl ToolExecutionResultV1 {
256    pub fn validate(&self) -> Result<(), ActorError> {
257        if self.schema != TOOL_RESULT_SCHEMA {
258            return Err(schema_error(&self.schema));
259        }
260        self.identity.validate()?;
261        validate_digest(&self.prepared_digest)?;
262        self.effects.validate()?;
263        Ok(())
264    }
265
266    pub fn ensure_prepared(&self, prepared: &ToolPreparedV1) -> Result<(), ActorError> {
267        self.validate()?;
268        if self.identity != prepared.identity || self.prepared_digest != prepared.digest()? {
269            return Err(identity_error("terminal does not bind its preparation"));
270        }
271        Ok(())
272    }
273
274    pub fn unknown(
275        prepared: &ToolPreparedV1,
276        reason: impl Into<String>,
277    ) -> Result<Self, ActorError> {
278        let reason = reason.into();
279        Ok(Self {
280            schema: TOOL_RESULT_SCHEMA.into(),
281            identity: prepared.identity.clone(),
282            prepared_digest: prepared.digest()?,
283            outcome: ToolExecutionOutcome::Unknown,
284            exit_code: None,
285            output: Value::String(format!("tool_effect_unresolved: {reason}")),
286            effects: ToolEffectObservationV1::incomplete(reason),
287        })
288    }
289}
290
291pub fn tool_args_digest(args: &Value) -> Result<String, ActorError> {
292    digest_json("a3s.code.tool-arguments.v1", args)
293}
294
295fn digest_json<T: Serialize>(domain: &str, value: &T) -> Result<String, ActorError> {
296    let bytes = serde_json::to_vec(value).map_err(|error| ActorError::Schema(error.to_string()))?;
297    let mut digest = Sha256::new();
298    digest.update(domain.as_bytes());
299    digest.update([0]);
300    digest.update(&bytes);
301    Ok(format!("sha256:{:x}", digest.finalize()))
302}
303
304fn bound_json<T: Serialize>(value: &T) -> Result<(), ActorError> {
305    let bytes = serde_json::to_vec(value).map_err(|error| ActorError::Schema(error.to_string()))?;
306    if bytes.len() > MAX_TOOL_EFFECT_CANONICAL_BYTES {
307        return Err(observation_error("tool effect evidence exceeds byte bound"));
308    }
309    Ok(())
310}
311
312fn validate_digest(value: &str) -> Result<(), ActorError> {
313    let Some(hex) = value.strip_prefix("sha256:") else {
314        return Err(identity_error("digest must use sha256 prefix"));
315    };
316    if hex.len() != 64
317        || !hex
318            .bytes()
319            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
320    {
321        return Err(identity_error("digest is not canonical SHA-256"));
322    }
323    Ok(())
324}
325
326fn identity_error(message: &str) -> ActorError {
327    ActorError::Schema(format!("tool_effect_identity_mismatch: {message}"))
328}
329fn schema_error(schema: &str) -> ActorError {
330    ActorError::Schema(format!("tool_effect_schema_unsupported: {schema}"))
331}
332fn observation_error(message: &str) -> ActorError {
333    ActorError::Schema(format!("tool_effect_observation_incomplete: {message}"))
334}
335
336#[cfg(test)]
337mod tests;