Skip to main content

agent_sdk_toolkit/workspace/
write.rs

1//! Workspace write helper. Use this toolkit module for policy-bounded file creation
2//! or replacement. Successful execution mutates files and returns content
3//! refs/metadata for the effect result.
4//!
5use std::{fs, sync::Arc};
6
7use agent_sdk_core::{
8    AgentError, ExecutorRef, PolicyRef, ToolExecutionOutput, ToolExecutionRequest, ToolExecutor,
9    ToolPackId, ToolPackKind, ToolPackSnapshot,
10    policy::{CapabilityPermission, EffectClass, RiskClass},
11};
12use serde::{Deserialize, Serialize};
13
14use super::{
15    bounds::BoundedWorkspace,
16    policy::WorkspacePolicy,
17    util::{content_ref_for, first_arg_ref, hash_bytes, policy_denial, tool_failure},
18};
19use crate::{
20    packs::{ToolkitPackBundle, tool_snapshot},
21    testing::{InMemoryJsonArgumentStore, InMemoryToolkitContentStore},
22};
23
24#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
25/// Workspace workspace write request request or result value.
26/// Creating the value does not touch the filesystem; workspace executors document read, write, edit, or search effects.
27pub struct WorkspaceWriteRequest {
28    /// Workspace-relative or resource path selected by the request or result.
29    pub path: String,
30    /// UTF-8 contents that the workspace write executor will place at the target path.
31    /// Use the write mode to decide whether these bytes create, overwrite, or preview the file.
32    pub contents: String,
33    /// Mode that selects how this operation or contract should behave.
34    /// Callers use it to choose the explicit execution path instead of relying on hidden
35    /// defaults.
36    pub mode: WorkspaceWriteMode,
37}
38
39#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
40#[serde(rename_all = "snake_case")]
41/// Enumerates the finite workspace write mode cases.
42/// Serialized names are part of the SDK contract; update fixtures when variants change.
43pub enum WorkspaceWriteMode {
44    /// Use this variant when the contract needs to represent create new; selecting it has no side effect by itself.
45    CreateNew,
46    /// Use this variant when the contract needs to represent overwrite; selecting it has no side effect by itself.
47    Overwrite,
48}
49
50#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51/// Workspace workspace write output request or result value.
52/// Creating the value does not touch the filesystem; workspace executors document read, write, edit, or search effects.
53pub struct WorkspaceWriteOutput {
54    /// Workspace-relative or resource path selected by the request or result.
55    pub path: String,
56    /// Whether created is enabled.
57    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
58    pub created: bool,
59    /// Whether overwritten is enabled.
60    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
61    pub overwritten: bool,
62    /// Deterministic before hash used for stale checks, package evidence, or
63    /// replay comparisons.
64    pub before_hash: Option<String>,
65    /// Deterministic after hash used for stale checks, package evidence, or
66    /// replay comparisons.
67    pub after_hash: String,
68    /// Optional non reversible reason value.
69    /// When absent, callers should use the documented default or skip that optional behavior.
70    pub non_reversible_reason: Option<String>,
71}
72
73impl BoundedWorkspace {
74    /// Write.
75    /// This writes to the policy-resolved workspace path and may create or overwrite files only
76    /// according to the request mode.
77    pub(super) fn write(
78        &self,
79        request: &WorkspaceWriteRequest,
80    ) -> Result<WorkspaceWriteOutput, AgentError> {
81        if request.contents.len() as u64 > self.policy.max_file_bytes {
82            return Err(policy_denial("workspace write exceeds max_file_bytes"));
83        }
84        let path = self.resolve_for_write(&request.path)?;
85        let exists = path.exists();
86        match (&request.mode, exists) {
87            (WorkspaceWriteMode::CreateNew, true) => {
88                return Err(policy_denial(
89                    "workspace write create_new refused existing path",
90                ));
91            }
92            (WorkspaceWriteMode::CreateNew, false) if !self.policy.allow_create => {
93                return Err(policy_denial("workspace write create scope is not granted"));
94            }
95            (WorkspaceWriteMode::Overwrite, false) if !self.policy.allow_create => {
96                return Err(policy_denial("workspace write missing create scope"));
97            }
98            (WorkspaceWriteMode::Overwrite, true) if !self.policy.allow_overwrite => {
99                return Err(policy_denial(
100                    "workspace write overwrite scope is not granted",
101                ));
102            }
103            _ => {}
104        }
105        let before_hash = if exists {
106            if fs::metadata(&path).map_err(tool_failure)?.len() > self.policy.max_file_bytes {
107                return Err(policy_denial(
108                    "workspace write existing file exceeds max_file_bytes",
109                ));
110            }
111            Some(hash_bytes(&fs::read(&path).map_err(tool_failure)?))
112        } else {
113            None
114        };
115        fs::write(&path, request.contents.as_bytes()).map_err(tool_failure)?;
116        let after_hash = hash_bytes(request.contents.as_bytes());
117        Ok(WorkspaceWriteOutput {
118            path: request.path.clone(),
119            created: !exists,
120            overwritten: exists,
121            before_hash,
122            after_hash,
123            non_reversible_reason: if exists {
124                None
125            } else {
126                Some("created file has no prior content to restore automatically".to_string())
127            },
128        })
129    }
130}
131
132#[derive(Clone)]
133/// Workspace workspace write executor request or result value.
134/// Creating the value does not touch the filesystem; workspace executors document read, write, edit, or search effects.
135pub struct WorkspaceWriteExecutor {
136    executor_ref: ExecutorRef,
137    workspace: Arc<BoundedWorkspace>,
138    arguments: InMemoryJsonArgumentStore,
139    content: InMemoryToolkitContentStore,
140}
141
142impl WorkspaceWriteExecutor {
143    /// Creates a new workspace::write value with explicit
144    /// caller-provided inputs. This constructor is data-only and
145    /// performs no I/O or external side effects.
146    pub fn new(
147        workspace: Arc<BoundedWorkspace>,
148        arguments: InMemoryJsonArgumentStore,
149        content: InMemoryToolkitContentStore,
150    ) -> Self {
151        Self {
152            executor_ref: ExecutorRef::new("executor.toolkit.workspace_write.v1"),
153            workspace,
154            arguments,
155            content,
156        }
157    }
158
159    /// Pack bundle.
160    /// This returns the toolkit pack bundle that registers the operation route; it does not
161    /// execute the operation.
162    pub fn pack_bundle(
163        source: agent_sdk_core::SourceRef,
164        policy_ref: PolicyRef,
165        workspace: &WorkspacePolicy,
166    ) -> Result<ToolkitPackBundle, AgentError> {
167        let snapshot = ToolPackSnapshot::new(
168            ToolPackId::new("toolpack.workspace_write.v1"),
169            ToolPackKind::WorkspaceWrite,
170            "v1",
171            source.clone(),
172        )
173        .with_workspace_bounds(workspace.bounds_snapshot(policy_ref.clone()))
174        .with_tool(tool_snapshot(
175            "cap.toolkit.workspace_write",
176            "workspace_write",
177            "executor.toolkit.workspace_write.v1",
178            "schema.toolkit.workspace_write.v1",
179            vec![policy_ref],
180            vec![CapabilityPermission::FilesystemWrite],
181            EffectClass::Write,
182            RiskClass::High,
183            &source,
184        ));
185        ToolkitPackBundle::from_snapshot(snapshot)
186    }
187}
188
189impl ToolExecutor for WorkspaceWriteExecutor {
190    fn executor_ref(&self) -> &ExecutorRef {
191        &self.executor_ref
192    }
193
194    fn execute(&self, request: &ToolExecutionRequest) -> Result<ToolExecutionOutput, AgentError> {
195        let args_ref = first_arg_ref(request)?;
196        let write_request: WorkspaceWriteRequest = self.arguments.get(args_ref)?;
197        let output = self.workspace.write(&write_request)?;
198        let content_ref = content_ref_for(request, "workspace_write");
199        self.content.put(content_ref.clone(), &output)?;
200        let mut envelope =
201            ToolExecutionOutput::completed("workspace write recorded before and after metadata");
202        envelope.content_refs.push(content_ref);
203        envelope.reconciliation_ref = Some(format!("reconcile.{}", output.after_hash));
204        Ok(envelope)
205    }
206}