Skip to main content

agent_sdk_toolkit/workspace/
read.rs

1//! Workspace read tool executor and request lowering. Use this toolkit module for
2//! bounded reads that produce content refs and metadata. Reads touch the local
3//! workspace through an explicit bounded policy and never grant ambient provider
4//! visibility.
5//!
6use std::{fs, io::Read, sync::Arc};
7
8use agent_sdk_core::{
9    AgentError, ExecutorRef, PolicyRef, ToolExecutionOutput, ToolExecutionRequest, ToolExecutor,
10    ToolPackId, ToolPackKind, ToolPackSnapshot,
11    policy::{CapabilityPermission, EffectClass, RiskClass},
12};
13use serde::{Deserialize, Serialize};
14
15use super::{
16    anchor::HashLineAnchor,
17    bounds::BoundedWorkspace,
18    policy::WorkspacePolicy,
19    read_pipeline::{
20        WorkspaceArchiveMetadata, WorkspaceDocumentMetadata, WorkspaceMediaMetadata,
21        WorkspaceReadDetection, WorkspaceReaderStep, WorkspaceResourceMetadata,
22        WorkspaceSqliteMetadata, detect_workspace_file,
23    },
24    readers::{render_bounded_prefix_read, render_workspace_read, render_workspace_uri},
25    util::{content_ref_for, first_arg_ref, hash_bytes, tool_failure},
26};
27use crate::{
28    packs::{ToolkitPackBundle, tool_snapshot},
29    testing::{InMemoryJsonArgumentStore, InMemoryToolkitContentStore},
30};
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33/// Workspace workspace read request request or result value.
34/// Creating the value does not touch the filesystem; workspace executors document read, write, edit, or search effects.
35pub struct WorkspaceReadRequest {
36    /// Workspace-relative or resource path selected by the request or result.
37    pub path: String,
38    /// Maximum byte budget the caller requested before truncation or summary
39    /// behavior is applied.
40    pub max_bytes: Option<u64>,
41}
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44/// Workspace workspace read output request or result value.
45/// Creating the value does not touch the filesystem; workspace executors document read, write, edit, or search effects.
46pub struct WorkspaceReadOutput {
47    /// Workspace-relative or resource path selected by the request or result.
48    pub path: String,
49    /// Detected or declared MIME type used for reader selection and
50    /// provider-safe summaries.
51    pub mime_type: String,
52    /// Detected used by this record or request.
53    pub detected: WorkspaceReadDetection,
54    /// Collection of reader pipeline values.
55    /// Ordering and membership should be treated as part of the serialized contract when
56    /// relevant.
57    pub reader_pipeline: Vec<WorkspaceReaderStep>,
58    /// Observed byte length for the source, sidecar, or extracted record.
59    pub byte_len: u64,
60    /// Stable hash for the bytes or canonical payload used for stale checks
61    /// and fingerprints.
62    pub content_hash: String,
63    /// Whether output was shortened by byte, item, page, archive, or parser
64    /// limits.
65    pub truncated: bool,
66    /// Whether the input is treated as binary so raw bytes are not exposed by
67    /// default.
68    pub binary: bool,
69    /// Hashline anchors and line metadata used for stale-read detection and
70    /// edit planning.
71    pub anchors: Vec<HashLineAnchor>,
72    /// Bounded textual content extracted for caller use; absent for binary
73    /// summaries or denied raw access.
74    pub content: String,
75    /// Redacted or bounded summary used when raw content is absent or
76    /// truncated.
77    pub content_summary: Option<String>,
78    /// Media metadata extracted without exposing raw media bytes.
79    pub media: Option<WorkspaceMediaMetadata>,
80    /// Document metadata such as parser, page/slide/sheet counts, and
81    /// extraction warnings.
82    pub document: Option<WorkspaceDocumentMetadata>,
83    /// Archive listing metadata with truncation and decompression warnings.
84    pub archive: Option<WorkspaceArchiveMetadata>,
85    /// SQLite schema/sample metadata gathered under bounded read limits.
86    pub sqlite: Option<WorkspaceSqliteMetadata>,
87    /// Resource/URI metadata resolved through an explicit resolver.
88    pub resource: Option<WorkspaceResourceMetadata>,
89    /// Non-fatal warnings from bounded readers, parsers, or policy
90    /// downgrades.
91    pub warnings: Vec<String>,
92}
93
94impl BoundedWorkspace {
95    /// Reads one bounded workspace path or supported URI. The method may inspect
96    /// file bytes under `BoundedWorkspace` policy and returns structured
97    /// metadata/content, but it never mutates files.
98    pub fn read(&self, request: &WorkspaceReadRequest) -> Result<WorkspaceReadOutput, AgentError> {
99        let max_output_bytes = request
100            .max_bytes
101            .unwrap_or(self.policy.max_output_bytes)
102            .min(self.policy.max_output_bytes);
103        if is_uri_read(&request.path) {
104            let uri_read =
105                render_workspace_uri(&request.path, self.policy.max_file_bytes, max_output_bytes)?;
106            return Ok(WorkspaceReadOutput {
107                path: request.path.clone(),
108                mime_type: uri_read.detection.mime_type.clone(),
109                detected: uri_read.detection,
110                reader_pipeline: uri_read.rendered.reader_pipeline,
111                byte_len: uri_read.byte_len,
112                content_hash: uri_read.content_hash,
113                truncated: uri_read.rendered.truncated,
114                binary: uri_read.rendered.binary,
115                anchors: uri_read.rendered.anchors,
116                content: uri_read.rendered.content,
117                content_summary: uri_read.rendered.content_summary,
118                media: uri_read.rendered.media,
119                document: uri_read.rendered.document,
120                archive: uri_read.rendered.archive,
121                sqlite: uri_read.rendered.sqlite,
122                resource: uri_read.rendered.resource,
123                warnings: uri_read.rendered.warnings,
124            });
125        }
126
127        let path = self.resolve_existing_file(&request.path)?;
128        let byte_len = fs::metadata(&path).map_err(tool_failure)?.len();
129        if byte_len > self.policy.max_file_bytes {
130            let prefix_limit = self
131                .policy
132                .max_file_bytes
133                .min(max_output_bytes.max(1))
134                .max(1);
135            let mut bytes = Vec::new();
136            fs::File::open(&path)
137                .map_err(tool_failure)?
138                .take(prefix_limit)
139                .read_to_end(&mut bytes)
140                .map_err(tool_failure)?;
141            let detected = detect_workspace_file(&path, &bytes);
142            let rendered =
143                render_bounded_prefix_read(&bytes, &detected, byte_len, max_output_bytes)?;
144            return Ok(WorkspaceReadOutput {
145                path: request.path.clone(),
146                mime_type: detected.mime_type.clone(),
147                detected,
148                reader_pipeline: rendered.reader_pipeline,
149                byte_len,
150                content_hash: hash_bytes(&bytes),
151                truncated: true,
152                binary: rendered.binary,
153                anchors: rendered.anchors,
154                content: rendered.content,
155                content_summary: rendered.content_summary,
156                media: rendered.media,
157                document: rendered.document,
158                archive: rendered.archive,
159                sqlite: rendered.sqlite,
160                resource: rendered.resource,
161                warnings: rendered.warnings,
162            });
163        }
164        let bytes = fs::read(&path).map_err(tool_failure)?;
165        let detected = detect_workspace_file(&path, &bytes);
166        let rendered = render_workspace_read(&path, &bytes, &detected, max_output_bytes)?;
167        Ok(WorkspaceReadOutput {
168            path: request.path.clone(),
169            mime_type: detected.mime_type.clone(),
170            detected,
171            reader_pipeline: rendered.reader_pipeline,
172            byte_len: bytes.len() as u64,
173            content_hash: hash_bytes(&bytes),
174            truncated: rendered.truncated,
175            binary: rendered.binary,
176            anchors: rendered.anchors,
177            content: rendered.content,
178            content_summary: rendered.content_summary,
179            media: rendered.media,
180            document: rendered.document,
181            archive: rendered.archive,
182            sqlite: rendered.sqlite,
183            resource: rendered.resource,
184            warnings: rendered.warnings,
185        })
186    }
187}
188
189fn is_uri_read(path: &str) -> bool {
190    path.starts_with("data:") || path.contains("://")
191}
192
193#[derive(Clone)]
194/// Workspace workspace read executor request or result value.
195/// Creating the value does not touch the filesystem; workspace executors document read, write, edit, or search effects.
196pub struct WorkspaceReadExecutor {
197    executor_ref: ExecutorRef,
198    workspace: Arc<BoundedWorkspace>,
199    arguments: InMemoryJsonArgumentStore,
200    content: InMemoryToolkitContentStore,
201}
202
203impl WorkspaceReadExecutor {
204    /// Creates a new workspace::read value with explicit
205    /// caller-provided inputs. This constructor is data-only and
206    /// performs no I/O or external side effects.
207    pub fn new(
208        workspace: Arc<BoundedWorkspace>,
209        arguments: InMemoryJsonArgumentStore,
210        content: InMemoryToolkitContentStore,
211    ) -> Self {
212        Self {
213            executor_ref: ExecutorRef::new("executor.toolkit.workspace_read.v1"),
214            workspace,
215            arguments,
216            content,
217        }
218    }
219
220    /// Pack bundle.
221    /// This returns the toolkit pack bundle that registers the operation route; it does not
222    /// execute the operation.
223    pub fn pack_bundle(
224        source: agent_sdk_core::SourceRef,
225        policy_ref: PolicyRef,
226        workspace: &WorkspacePolicy,
227    ) -> Result<ToolkitPackBundle, AgentError> {
228        let snapshot = ToolPackSnapshot::new(
229            ToolPackId::new("toolpack.workspace_readonly.v1"),
230            ToolPackKind::WorkspaceReadOnly,
231            "v1",
232            source.clone(),
233        )
234        .with_workspace_bounds(workspace.bounds_snapshot(policy_ref.clone()))
235        .with_tool(tool_snapshot(
236            "cap.toolkit.workspace_read",
237            "workspace_read",
238            "executor.toolkit.workspace_read.v1",
239            "schema.toolkit.workspace_read.v1",
240            vec![policy_ref],
241            vec![CapabilityPermission::FilesystemRead],
242            EffectClass::Read,
243            RiskClass::Low,
244            &source,
245        ));
246        ToolkitPackBundle::from_snapshot(snapshot)
247    }
248}
249
250impl ToolExecutor for WorkspaceReadExecutor {
251    fn executor_ref(&self) -> &ExecutorRef {
252        &self.executor_ref
253    }
254
255    fn execute(&self, request: &ToolExecutionRequest) -> Result<ToolExecutionOutput, AgentError> {
256        let args_ref = first_arg_ref(request)?;
257        let read_request: WorkspaceReadRequest = self.arguments.get(args_ref)?;
258        let output = self.workspace.read(&read_request)?;
259        let content_ref = content_ref_for(request, "workspace_read");
260        self.content.put(content_ref.clone(), &output)?;
261        let mut envelope = ToolExecutionOutput::completed("workspace read returned content ref");
262        envelope.content_refs.push(content_ref);
263        Ok(envelope)
264    }
265}