Skip to main content

orchestral_runtime/tool_runtime/
read_precondition.rs

1use super::*;
2use orchestral_core::model_protocol::{ModelContent, ModelMessage, ModelRole};
3
4/// Successful Tool values present in the exact request sent to a model.
5/// Construct this before executing that response's Tool batch: a read from the
6/// same batch has not yet been observed by the model and is ineligible.
7#[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/// Executor-declared evidence of a complete, untruncated UTF-8 file read.
31/// This is a version precondition, never filesystem authority.
32#[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/// Verified before any Tool in the current response is executed. Its private
47/// contents cannot gain a newly committed read during that batch.
48#[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            // Never refresh a prepared precondition from newer reads, even
87            // when this call is retried after approval or process recovery.
88            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            // Call IDs need not be globally unique across Runs. An old
134            // message cannot be rebound to a read in this response's batch.
135            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                    // Exact committed bytes or the producer's complete model
177                    // view are evidence; a summary or edited page is not.
178                    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}