Skip to main content

cookie_agent_tools/
read.rs

1use std::path::PathBuf;
2
3use async_trait::async_trait;
4use cookie_agent_engine::{
5    AttachmentGate, PreparedExecutor, PreparedTool, SessionToolContext, ToolCall, ToolError,
6    ToolExecutionContext, ToolPreparationContext, ToolProvider, ToolSpec, approved_media_type,
7    attachment_gate_error, gate_attachment,
8};
9use cookie_agent_protocol::{
10    ArtifactReadPath, PermissionAction, PersistedToolResult as ToolResult, ToolEmittedContent,
11    ToolEmittedMessage, ToolEmittedMessageRole,
12};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use crate::{fs_cap, parse_args, prepared_operation, prepared_path_resources, schema};
17
18const DEFAULT_LIMIT: usize = 2_000;
19
20#[derive(Debug)]
21pub struct ReadTool {
22    workspace: PathBuf,
23}
24
25#[derive(Debug, Deserialize, JsonSchema, Serialize)]
26#[serde(deny_unknown_fields)]
27struct ReadArgs {
28    #[serde(
29        rename = "filePath",
30        deserialize_with = "crate::path_args::deserialize"
31    )]
32    /// Path to the file or directory. Relative paths resolve against the session working directory.
33    file_path: String,
34    /// Maximum number of entries or lines to return. Defaults to 2000.
35    limit: Option<usize>,
36    /// Zero-based entry or line offset. Defaults to 0.
37    offset: Option<usize>,
38}
39
40struct ReadExecutor {
41    target: fs_cap::PreparedExisting,
42    offset: usize,
43    limit: usize,
44}
45
46impl ReadTool {
47    #[must_use]
48    pub fn new(workspace: impl Into<PathBuf>) -> Self {
49        Self {
50            workspace: workspace.into(),
51        }
52    }
53}
54
55impl Default for ReadTool {
56    fn default() -> Self {
57        Self::new(std::env::current_dir().expect("current directory"))
58    }
59}
60
61#[async_trait]
62impl ToolProvider for ReadTool {
63    fn provider_id(&self) -> &'static str {
64        "builtin.read"
65    }
66
67    fn tools_for_session(&self, _: &SessionToolContext) -> Result<Vec<ToolSpec>, ToolError> {
68        Ok(vec![ToolSpec {
69output: Default::default(),
70            concurrency: cookie_agent_engine::ToolConcurrency::Parallel,
71            result_truncation: cookie_agent_engine::ToolResultTruncationPolicy::OptOut,
72            name: "read".into(),
73            permission_name: Self::get_permission_name("read")?.into(),
74            description:
75                "Read a file or directory snapshot, or artifact://<64 lowercase hex digest>[/<stream>], using a zero-based offset. Artifact reads return stored content without file wrappers, use possession-based access, and allow at most 2000 lines per page."
76                    .into(),
77            parameters: schema::<ReadArgs>(),
78        }])
79    }
80
81    fn get_permission_name(tool_name: &str) -> Result<&'static str, ToolError> {
82        match tool_name {
83            "read" => Ok("read"),
84            _ => Err(ToolError::execution("read provider received another tool")),
85        }
86    }
87
88    fn get_permission_resource(
89        &self,
90        name: &str,
91        arguments: &serde_json::Value,
92    ) -> Result<(&'static str, Option<String>), ToolError> {
93        let permission_name = Self::get_permission_name(name)?;
94        let args: ReadArgs = parse_args("read", arguments.clone())?;
95        if args.file_path.starts_with("artifact://") {
96            ArtifactReadPath::parse(&args.file_path).map_err(ToolError::execution)?;
97            return Ok((permission_name, Some(args.file_path)));
98        }
99        if args.file_path.is_empty() {
100            return Err(ToolError::execution("filePath must not be empty"));
101        }
102        Ok((
103            permission_name,
104            Some(crate::permission_path_label(
105                &args.file_path,
106                &self.workspace,
107            )),
108        ))
109    }
110
111    fn get_display_argument(
112        &self,
113        name: &str,
114        arguments: &serde_json::Value,
115    ) -> Result<String, ToolError> {
116        if name != "read" {
117            return Err(ToolError::execution("read provider received another tool"));
118        }
119        let args: ReadArgs = parse_args("read", arguments.clone())?;
120        if args.file_path.starts_with("artifact://") {
121            ArtifactReadPath::parse(&args.file_path).map_err(ToolError::execution)?;
122            return Ok(args.file_path);
123        }
124        if args.file_path.is_empty() {
125            return Err(ToolError::execution("filePath must not be empty"));
126        }
127        let path = crate::permission_path_label(&args.file_path, &self.workspace);
128        let mut display = crate::abbreviated_display_path(&path, &self.workspace);
129        let window = match (args.offset, args.limit) {
130            (Some(offset), Some(limit)) => Some(format!("offset={offset}, limit={limit}")),
131            (Some(offset), None) => Some(format!("offset={offset}")),
132            (None, Some(limit)) => Some(format!("limit={limit}")),
133            (None, None) => None,
134        };
135        if let Some(window) = window {
136            display.push_str(" [");
137            display.push_str(&window);
138            display.push(']');
139        }
140        Ok(display)
141    }
142
143    async fn prepare(
144        &self,
145        ctx: ToolPreparationContext,
146        call: ToolCall,
147    ) -> Result<PreparedTool, ToolError> {
148        if call.name != "read" {
149            return Err(ToolError::execution("read provider received another tool"));
150        }
151        let mut args: ReadArgs = parse_args("read", call.arguments)?;
152        let offset = args.offset.unwrap_or(0);
153        let limit = args.limit.unwrap_or(DEFAULT_LIMIT);
154        if limit == 0 {
155            return Err(ToolError::execution("limit must be positive"));
156        }
157        args.offset = Some(offset);
158        args.limit = Some(limit);
159        if args.file_path.starts_with("artifact://") {
160            ArtifactReadPath::parse(&args.file_path).map_err(ToolError::execution)?;
161            args.limit = Some(limit.min(DEFAULT_LIMIT));
162            let binding =
163                serde_json::to_vec(&args).map_err(|e| ToolError::execution(e.to_string()))?;
164            let resource = crate::prepared_resource(
165                PermissionAction::Read,
166                "artifact",
167                args.file_path.as_bytes(),
168                &binding,
169                cookie_agent_protocol::PreparedBindingLifetime::RestartStable,
170                cookie_agent_protocol::ApprovalResourceSource::PrimaryOperation,
171            )?;
172            let operation = prepared_operation(
173                "read",
174                &args,
175                vec![(PermissionAction::Read, "read")],
176                vec![resource],
177                &fs_cap::cwd_context_bytes(&ctx.cwd)?,
178            )?;
179            let normalized =
180                serde_json::to_value(&args).map_err(|e| ToolError::execution(e.to_string()))?;
181            let label = args.file_path.clone();
182            return PreparedTool::new(
183                operation,
184                normalized,
185                None,
186                Box::new(ArtifactReadExecutor {
187                    args,
188                    session: ctx.session,
189                }),
190            )?
191            .with_policy_labels(vec![label]);
192        }
193        let target = fs_cap::prepare_existing(&ctx.cwd, std::path::Path::new(&args.file_path))?;
194        let binding = target.manifest_bytes()?;
195        let (resources, policy_labels) = prepared_path_resources(
196            PermissionAction::Read,
197            if target.directory {
198                "directory"
199            } else {
200                "file"
201            },
202            &target.display_path,
203            &self.workspace,
204            &binding,
205        )?;
206        let context = fs_cap::cwd_context_bytes(&ctx.cwd)?;
207        let operation = prepared_operation(
208            "read",
209            &args,
210            vec![(PermissionAction::Read, "read")],
211            resources,
212            &context,
213        )?;
214        let normalized_arguments = serde_json::json!({
215            "filePath": target.display_path,
216            "offset": offset,
217            "limit": limit,
218        });
219        PreparedTool::new(
220            operation,
221            normalized_arguments,
222            None,
223            Box::new(ReadExecutor {
224                target,
225                offset,
226                limit,
227            }),
228        )?
229        .with_policy_labels(policy_labels)
230    }
231}
232
233struct ArtifactReadExecutor {
234    args: ReadArgs,
235    session: cookie_agent_protocol::SessionId,
236}
237
238#[async_trait]
239impl PreparedExecutor for ArtifactReadExecutor {
240    async fn revalidate(&self) -> Result<(), ToolError> {
241        Ok(())
242    }
243
244    async fn execute(
245        self: Box<Self>,
246        context: ToolExecutionContext,
247    ) -> Result<cookie_agent_engine::ToolCompletion, ToolError> {
248        if context.session != self.session {
249            return Err(ToolError::operation_changed("session changed"));
250        }
251        if context.cancellation.is_cancelled() {
252            return Err(ToolError::execution("artifact read cancelled"));
253        }
254        let offset = self.args.offset.expect("normalized offset") as u64;
255        let limit = self.args.limit.expect("normalized limit") as u64;
256        let page = context
257            .read_artifact(&self.args.file_path, offset, limit)
258            .await?;
259        Ok(cookie_agent_engine::ToolCompletion::single(ToolResult {
260            title: crate::safe_title("Artifact page"),
261            output: page.content,
262            display: None,
263            retained_output: None,
264            metadata: serde_json::json!({"filePath": self.args.file_path, "offset": offset, "limit": limit, "next_offset": page.next_offset_lines, "source": page.source}),
265            truncation: None,
266            attachments: Vec::new(),
267            additional_messages: Vec::new(),
268        }))
269    }
270}
271
272#[async_trait]
273impl PreparedExecutor for ReadExecutor {
274    async fn revalidate(&self) -> Result<(), ToolError> {
275        self.target.revalidate()
276    }
277
278    async fn execute(
279        self: Box<Self>,
280        context: ToolExecutionContext,
281    ) -> Result<cookie_agent_engine::ToolCompletion, ToolError> {
282        let result: Result<ToolResult, ToolError> = async move {
283        if context.cancellation.is_cancelled() {
284            return Err(ToolError::execution(
285                "prepared read cancelled before execution",
286            ));
287        }
288        self.target.revalidate()?;
289        if self.target.directory {
290            let entries = self.target.directory_entries()?;
291            let snapshot = serde_json::to_vec(&entries)
292                .map_err(|error| ToolError::execution(error.to_string()))?;
293            if cookie_agent_protocol::Sha256Digest::of_bytes(&snapshot)
294                != self.target.content_digest
295            {
296                return Err(ToolError::operation_changed(
297                    "prepared directory snapshot changed",
298                ));
299            }
300            let page = directory_page(&entries, self.offset, self.limit).collect::<Vec<_>>();
301            let mut output = format!(
302                "<path>{}</path>\n<type>directory</type>\n<entries>\n",
303                self.target.display_path.display()
304            );
305            for (name, directory) in &page {
306                output.push_str(name);
307                if *directory {
308                    output.push('/');
309                }
310                output.push('\n');
311            }
312            output.push_str("</entries>");
313            return Ok(ToolResult {
314display: None,
315retained_output: None,
316                title: crate::safe_title(format!(
317                    "Read directory {}",
318                    self.target.display_path.display()
319                )),
320                output,
321                metadata: serde_json::json!({"kind":"directory","shown":page.len(),"total_entries":entries.len()}),
322                truncation: None,
323                attachments: Vec::new(),
324                additional_messages: Vec::new(),
325            });
326        }
327        let bytes = self.target.verified_bytes()?;
328        if let Some(mime) = approved_media_type(&self.target.display_path, &bytes)? {
329            let gate = gate_attachment(
330                context.turn_context.adapter_family,
331                &context.turn_context.capabilities,
332                mime,
333                &bytes,
334            );
335            if let Some(error) = attachment_gate_error(
336                gate,
337                mime,
338                &context.turn_context.model,
339                context.turn_context.adapter,
340            ) {
341                return Err(ToolError::execution(error));
342            }
343            let attachment = context.retain_validated_attachment(
344                mime,
345                self.target
346                    .display_path
347                    .file_name()
348                    .map(|name| name.to_string_lossy().into_owned()),
349                &bytes,
350            )?;
351            let sha256 = attachment.sha256.clone();
352            let (output, attachments, additional_messages) = match gate {
353                AttachmentGate::AttachToolResult => (
354                    format!("Attached {mime} ({} bytes).", bytes.len()),
355                    vec![attachment],
356                    Vec::new(),
357                ),
358                AttachmentGate::DeliverViaUserTurn => (
359                    format!(
360                        "Attached {mime} ({} bytes), delivered in the following message.",
361                        bytes.len()
362                    ),
363                    Vec::new(),
364                    vec![
365                        ToolEmittedMessage::new(
366                            ToolEmittedMessageRole::User,
367                            vec![ToolEmittedContent::File(attachment)],
368                        )
369                        .map_err(|error| ToolError::execution(error.to_string()))?,
370                    ],
371                ),
372                AttachmentGate::RejectUnsupportedModel
373                | AttachmentGate::RejectUnsupportedFamily
374                | AttachmentGate::RejectTooLarge { .. } => {
375                    unreachable!("rejected attachment gates returned an error")
376                }
377            };
378            return Ok(ToolResult {
379display: None,
380retained_output: None,
381                title: crate::safe_title(format!(
382                    "Read attachment {}",
383                    self.target.display_path.display()
384                )),
385                output,
386                metadata: serde_json::json!({"kind":"attachment","mime_type":mime,"sha256":sha256}),
387                truncation: None,
388                attachments,
389                additional_messages,
390            });
391        }
392        let text = std::str::from_utf8(&bytes)
393            .map_err(|_| ToolError::execution("read supports UTF-8 text or approved media"))?;
394        Ok(text_result(
395            &self.target.display_path,
396            text,
397            self.offset,
398            self.limit,
399        ))
400}.await;
401        result.map(cookie_agent_engine::ToolCompletion::single)
402    }
403}
404
405fn text_result(path: &std::path::Path, text: &str, offset: usize, limit: usize) -> ToolResult {
406    let lines = text.lines().collect::<Vec<_>>();
407    let mut output = format!(
408        "<path>{}</path>\n<type>file</type>\n<content>\n",
409        path.display()
410    );
411    for (index, line) in text_page(&lines, offset, limit) {
412        output.push_str(&format!("{}: {line}\n", index + 1));
413    }
414    output.push_str("</content>");
415    ToolResult {
416        display: None,
417        retained_output: None,
418        title: crate::safe_title(format!("Read file {}", path.display())),
419        output,
420        metadata: serde_json::json!({"kind":"text","offset":offset,"limit":limit,"total_lines":lines.len()}),
421        truncation: None,
422        attachments: Vec::new(),
423        additional_messages: Vec::new(),
424    }
425}
426
427fn text_page<'a>(
428    lines: &'a [&'a str],
429    offset: usize,
430    limit: usize,
431) -> impl Iterator<Item = (usize, &'a str)> + 'a {
432    lines.iter().copied().enumerate().skip(offset).take(limit)
433}
434
435fn directory_page<T>(entries: &[T], offset: usize, limit: usize) -> impl Iterator<Item = &T> {
436    entries.iter().skip(offset).take(limit)
437}
438
439#[cfg(test)]
440mod tests;
441#[tokio::test]
442async fn artifact_reads_use_the_public_uri_without_filesystem_preparation_or_retention() {
443    use cookie_agent_protocol::{RunId, SessionId, ToolCallId};
444    let context = |root: &std::path::Path| ToolPreparationContext {
445        session: SessionId::new_v7(),
446        run: RunId::new_v7(),
447        cwd: root.into(),
448        workspace_root: root.into(),
449        turn_context: crate::test_turn_context(),
450    };
451    let root = tempfile::tempdir().unwrap();
452    let producer =
453        ToolExecutionContext::for_test(root.path().join("artifacts"), crate::test_turn_context())
454            .unwrap();
455    let artifact = producer
456        .retain_validated_attachment("text/plain", None, b"zero\none\ntwo\n")
457        .unwrap();
458    let path = format!("artifact://{}", artifact.sha256);
459    let reader =
460        ToolExecutionContext::for_test(root.path().join("artifacts"), crate::test_turn_context())
461            .unwrap();
462    assert_ne!(producer.session, reader.session);
463    let tool = ReadTool::new(root.path());
464    let args = serde_json::json!({"filePath": path, "offset": 1, "limit": 1});
465    assert_eq!(
466        tool.get_permission_resource("read", &args).unwrap(),
467        ("read", Some(path.clone()))
468    );
469    let prepared = tool
470        .prepare(
471            ToolPreparationContext {
472                session: reader.session,
473                run: reader.run,
474                cwd: root.path().into(),
475                workspace_root: root.path().into(),
476                turn_context: reader.turn_context.clone(),
477            },
478            ToolCall {
479                id: ToolCallId::new_v7(),
480                name: "read".into(),
481                arguments: args,
482            },
483        )
484        .await
485        .unwrap();
486    assert_eq!(prepared.policy_labels(), [Some(path.clone())]);
487    let result = prepared.execute_for_test(reader).await.unwrap();
488    assert_eq!(result.output, "one\n");
489    assert_eq!(result.metadata["next_offset"], 2);
490    assert_eq!(result.metadata["filePath"], path);
491    assert!(result.retained_output.is_none());
492    assert!(result.truncation.is_none());
493    assert!(!result.output.contains("<path>"));
494    for path in [
495        "artifact://bad".to_owned(),
496        format!("artifact://{}/../bad", artifact.sha256),
497        format!("artifact://sha256/{}", artifact.sha256),
498    ] {
499        assert!(
500            tool.prepare(
501                context(root.path()),
502                ToolCall {
503                    id: ToolCallId::new_v7(),
504                    name: "read".into(),
505                    arguments: serde_json::json!({"filePath": path})
506                }
507            )
508            .await
509            .is_err()
510        );
511    }
512}