1use 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 #[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;