orchestral_runtime/tool_runtime/
read_precondition.rs1use super::*;
2use orchestral_core::model_protocol::{ModelContent, ModelMessage, ModelRole};
3
4#[derive(Debug, Clone, Default)]
8pub struct ModelToolObservations(Vec<(ToolCallId, serde_json::Value)>);
9
10impl ModelToolObservations {
11 pub fn from_messages(messages: &[ModelMessage]) -> Self {
12 Self(
13 messages
14 .iter()
15 .filter(|message| message.role == ModelRole::Tool)
16 .flat_map(|message| &message.content)
17 .filter_map(|content| match content {
18 ModelContent::ToolResult {
19 call_id,
20 result,
21 is_error: false,
22 } => Some((ToolCallId::new(call_id.as_str()), result.clone())),
23 _ => None,
24 })
25 .collect(),
26 )
27 }
28}
29
30#[derive(Debug, Clone)]
33pub struct CompleteFileRead {
34 pub workspace: String,
35 pub path: String,
36 pub content_digest: Digest,
37}
38
39#[derive(Debug, Clone)]
40pub struct ObservedFileRead {
41 pub version: CompleteFileRead,
42 pub source: ToolEffectKey,
43 pub source_event_digest: Digest,
44}
45
46#[derive(Debug, Clone, Default)]
49pub struct FrozenToolObservations(Vec<ObservedFileRead>);
50
51pub(super) fn execution_invocation(
52 original: &ToolInvocation,
53 resolution: Option<&ToolArgumentResolution>,
54) -> ToolInvocation {
55 let mut invocation = original.clone();
56 if let Some(resolution) = resolution {
57 invocation.arguments = resolution.arguments.clone();
58 }
59 invocation
60}
61
62impl<S: ApprovalCapabilityStore> GuardedToolRuntime<S> {
63 pub(super) async fn resolve_invocation_arguments(
64 &self,
65 invocation: &ToolInvocation,
66 registered: &RegisteredTool,
67 observations: &FrozenToolObservations,
68 ) -> Result<Option<ToolArgumentResolution>, ToolOutcome> {
69 if !registered.executor.requires_observed_arguments(invocation) {
70 return Ok(None);
71 }
72 let key = ToolEffectKey::new(invocation.run_id.clone(), invocation.call_id.clone());
73 let records = self
74 .effect_journal
75 .load_effect(&key)
76 .await
77 .map_err(read_error)?;
78 if let Some(prior) = replay_tool_effect(&key, &records).map_err(read_error)? {
79 if prior.prepared.invocation != *invocation {
80 return Err(ToolOutcome::Rejected {
81 code: "call_identity_conflict".to_owned(),
82 message: "original Tool arguments differ from the prepared invocation"
83 .to_owned(),
84 });
85 }
86 return Ok(prior
89 .prepared
90 .argument_resolution
91 .map(|resolution| *resolution));
92 }
93 let reads = observations
94 .0
95 .iter()
96 .filter(|read| {
97 read.source.run_id == invocation.run_id && read.source.call_id != invocation.call_id
98 })
99 .cloned()
100 .collect::<Vec<_>>();
101 let resolution = registered.executor.resolve_arguments(invocation, &reads)?;
102 if let Some(resolution) = &resolution {
103 if !reads.iter().any(|read| {
104 read.source == resolution.source
105 && read.source_event_digest == resolution.source_event_digest
106 }) {
107 return Err(ToolOutcome::Rejected {
108 code: "tool_argument_resolution_invalid".to_owned(),
109 message: "resolved arguments refer to an unobserved Tool result".to_owned(),
110 });
111 }
112 registered
113 .descriptor
114 .model_schema
115 .validate_arguments(&resolution.arguments)
116 .map_err(|error| ToolOutcome::Rejected {
117 code: "input_schema_violation".to_owned(),
118 message: error.message,
119 })?;
120 }
121 Ok(resolution)
122 }
123
124 pub async fn freeze_model_observations(
125 &self,
126 run_id: &RunId,
127 observations: &ModelToolObservations,
128 pending_calls: &[ToolCallId],
129 ) -> Result<FrozenToolObservations, ToolOutcome> {
130 let mut verified = Vec::new();
131 let mut pages = Vec::new();
132 for (call_id, visible) in &observations.0 {
133 if pending_calls.contains(call_id) {
136 continue;
137 }
138 let source = ToolEffectKey::new(run_id.clone(), call_id.clone());
139 let records = self
140 .effect_journal
141 .load_effect(&source)
142 .await
143 .map_err(read_error)?;
144 let Some(prior) = replay_tool_effect(&source, &records).map_err(read_error)? else {
145 continue;
146 };
147 let ToolEffectPhase::Committed {
148 outcome: ToolOutcome::Completed { ref output },
149 ..
150 } = prior.phase
151 else {
152 continue;
153 };
154 let Some(producer) = self
155 .registered_tool(&prior.prepared.invocation.tool_id)
156 .map_err(|error| ToolOutcome::Rejected {
157 code: "runtime_unavailable".to_owned(),
158 message: error.to_string(),
159 })?
160 else {
161 continue;
162 };
163 if producer
164 .descriptor
165 .digest()
166 .map_err(|error| ToolOutcome::Rejected {
167 code: "invalid_descriptor".to_owned(),
168 message: error.message,
169 })?
170 != prior.prepared.descriptor_digest
171 {
172 continue;
173 }
174 match output {
175 ToolOutput::Inline(output) => {
176 if output != visible
179 && producer
180 .executor
181 .project_model_output(&prior.prepared.invocation, output)
182 != *visible
183 {
184 continue;
185 }
186 if let Some(page) = producer
187 .executor
188 .artifact_read_observation(&prior.prepared.invocation, output)
189 {
190 pages.push(page);
191 }
192 }
193 ToolOutput::Artifact(artifact) => {
194 if artifact_model_output(artifact) != *visible {
195 continue;
196 }
197 }
198 _ => continue,
199 }
200 verified.push((source, prior, producer, records));
201 }
202 let mut reads = Vec::new();
203 for (source, prior, producer, records) in verified {
204 let ToolEffectPhase::Committed {
205 outcome: ToolOutcome::Completed { output },
206 ..
207 } = prior.phase
208 else {
209 continue;
210 };
211 let output = match output {
212 ToolOutput::Inline(output) => output,
213 ToolOutput::Artifact(artifact) => {
214 let Some(output) =
215 super::artifact_observation::observed_artifact_output(&artifact, &pages)
216 else {
217 continue;
218 };
219 if producer.descriptor.validate_output(&output).is_err() {
220 continue;
221 }
222 output
223 }
224 _ => continue,
225 };
226 let Some(version) = producer
227 .executor
228 .complete_file_read(&prior.prepared.invocation, &output)
229 else {
230 continue;
231 };
232 reads.push(ObservedFileRead {
233 version,
234 source,
235 source_event_digest: records
236 .last()
237 .expect("committed effect has records")
238 .event_digest
239 .clone(),
240 });
241 }
242 Ok(FrozenToolObservations(reads))
243 }
244}
245
246fn read_error(error: ToolEffectError) -> ToolOutcome {
247 ToolOutcome::Rejected {
248 code: "effect_journal_unavailable".to_owned(),
249 message: error.to_string(),
250 }
251}