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 file_path: String,
34 limit: Option<usize>,
36 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}