Skip to main content

agent_sdk_toolkit/workspace/
edit.rs

1//! Workspace edit planner and applier. Use this toolkit module when a host-approved
2//! tool call needs anchor-checked text replacement. Successful execution mutates
3//! files and should be journaled through core effect records by the caller.
4//!
5use std::{fs, sync::Arc};
6
7use agent_sdk_core::{
8    AgentError, AgentErrorKind, ExecutorRef, PolicyRef, RetryClassification, ToolExecutionOutput,
9    ToolExecutionRequest, ToolExecutor, ToolPackId, ToolPackKind, ToolPackSnapshot,
10    policy::{CapabilityPermission, EffectClass, RiskClass},
11};
12use serde::{Deserialize, Serialize};
13
14use super::{
15    anchor::HashLineAnchor,
16    bounds::BoundedWorkspace,
17    policy::WorkspacePolicy,
18    util::{content_ref_for, first_arg_ref, hash_bytes, hash_line, policy_denial, tool_failure},
19};
20use crate::{
21    packs::{ToolkitPackBundle, tool_snapshot},
22    testing::{InMemoryJsonArgumentStore, InMemoryToolkitContentStore},
23};
24
25#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
26/// Workspace workspace edit request request or result value.
27/// Creating the value does not touch the filesystem; workspace executors document read, write, edit, or search effects.
28pub struct WorkspaceEditRequest {
29    /// Workspace-relative or resource path selected by the request or result.
30    pub path: String,
31    /// Anchor used by this record or request.
32    pub anchor: HashLineAnchor,
33    /// Replacement used by this record or request.
34    pub replacement: String,
35    /// Whether preview only is enabled.
36    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
37    pub preview_only: bool,
38    /// Deterministic preview hash used for stale checks, package evidence, or
39    /// replay comparisons.
40    pub preview_hash: Option<String>,
41}
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44/// Workspace workspace edit 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 WorkspaceEditOutput {
47    /// Workspace-relative or resource path selected by the request or result.
48    pub path: String,
49    /// Whether preview only is enabled.
50    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
51    pub preview_only: bool,
52    /// Whether applied is enabled.
53    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
54    pub applied: bool,
55    /// Deterministic before hash used for stale checks, package evidence, or
56    /// replay comparisons.
57    pub before_hash: String,
58    /// Deterministic after hash used for stale checks, package evidence, or
59    /// replay comparisons.
60    pub after_hash: String,
61    /// Deterministic preview hash used for stale checks, package evidence, or
62    /// replay comparisons.
63    pub preview_hash: String,
64    /// Diff used by this record or request.
65    pub diff: String,
66    /// Optional inverse candidate value.
67    /// When absent, callers should use the documented default or skip that optional behavior.
68    pub inverse_candidate: Option<String>,
69}
70
71impl BoundedWorkspace {
72    /// Plans or applies an anchor-checked workspace edit. Preview mode is
73    /// read-only; apply mode mutates the target file only after bounds and
74    /// stale-anchor checks pass.
75    pub(super) fn edit(
76        &self,
77        request: &WorkspaceEditRequest,
78    ) -> Result<WorkspaceEditOutput, AgentError> {
79        let path = self.resolve_existing_file(&request.path)?;
80        if fs::metadata(&path).map_err(tool_failure)?.len() > self.policy.max_file_bytes {
81            return Err(policy_denial("workspace edit exceeds max_file_bytes"));
82        }
83        let before = fs::read_to_string(&path).map_err(tool_failure)?;
84        let mut lines = before.lines().map(str::to_string).collect::<Vec<_>>();
85        let index =
86            request.anchor.line.checked_sub(1).ok_or_else(|| {
87                AgentError::contract_violation("hashline anchor line is one-based")
88            })?;
89        let Some(current_line) = lines.get(index) else {
90            return Err(AgentError::new(
91                AgentErrorKind::ToolFailure,
92                RetryClassification::UserActionNeeded,
93                "hashline anchor is outside the current file",
94            ));
95        };
96        if hash_line(current_line) != request.anchor.before_hash {
97            return Err(AgentError::new(
98                AgentErrorKind::PolicyDenial,
99                RetryClassification::UserActionNeeded,
100                "stale hashline anchor prevented workspace edit",
101            ));
102        }
103        let before_hash = hash_bytes(before.as_bytes());
104        let inverse = current_line.clone();
105        lines[index] = request.replacement.clone();
106        let mut after = lines.join("\n");
107        if before.ends_with('\n') {
108            after.push('\n');
109        }
110        let after_hash = hash_bytes(after.as_bytes());
111        let diff = format!(
112            "--- {}\n+++ {}\n@@ line {} @@\n-{}\n+{}",
113            request.path, request.path, request.anchor.line, inverse, request.replacement
114        );
115        let preview_hash = hash_bytes(format!("{before_hash}\n{after_hash}\n{diff}").as_bytes());
116        if !request.preview_only {
117            if request.preview_hash.as_deref() != Some(preview_hash.as_str()) {
118                return Err(policy_denial(
119                    "workspace edit apply requires matching preview_hash",
120                ));
121            }
122            fs::write(&path, after.as_bytes()).map_err(tool_failure)?;
123        }
124        Ok(WorkspaceEditOutput {
125            path: request.path.clone(),
126            preview_only: request.preview_only,
127            applied: !request.preview_only,
128            before_hash,
129            after_hash,
130            preview_hash,
131            diff,
132            inverse_candidate: Some(inverse),
133        })
134    }
135}
136
137#[derive(Clone)]
138/// Workspace workspace edit executor request or result value.
139/// Creating the value does not touch the filesystem; workspace executors document read, write, edit, or search effects.
140pub struct WorkspaceEditExecutor {
141    executor_ref: ExecutorRef,
142    workspace: Arc<BoundedWorkspace>,
143    arguments: InMemoryJsonArgumentStore,
144    content: InMemoryToolkitContentStore,
145}
146
147impl WorkspaceEditExecutor {
148    /// Creates a new workspace::edit value with explicit
149    /// caller-provided inputs. This constructor is data-only and
150    /// performs no I/O or external side effects.
151    pub fn new(
152        workspace: Arc<BoundedWorkspace>,
153        arguments: InMemoryJsonArgumentStore,
154        content: InMemoryToolkitContentStore,
155    ) -> Self {
156        Self {
157            executor_ref: ExecutorRef::new("executor.toolkit.workspace_edit.v1"),
158            workspace,
159            arguments,
160            content,
161        }
162    }
163
164    /// Pack bundle.
165    /// This returns the toolkit pack bundle that registers the operation route; it does not
166    /// execute the operation.
167    pub fn pack_bundle(
168        source: agent_sdk_core::SourceRef,
169        policy_ref: PolicyRef,
170        workspace: &WorkspacePolicy,
171    ) -> Result<ToolkitPackBundle, AgentError> {
172        let snapshot = ToolPackSnapshot::new(
173            ToolPackId::new("toolpack.workspace_edit.v1"),
174            ToolPackKind::WorkspaceEdit,
175            "v1",
176            source.clone(),
177        )
178        .with_workspace_bounds(workspace.bounds_snapshot(policy_ref.clone()))
179        .with_tool(tool_snapshot(
180            "cap.toolkit.workspace_edit",
181            "workspace_edit",
182            "executor.toolkit.workspace_edit.v1",
183            "schema.toolkit.workspace_edit.v1",
184            vec![policy_ref],
185            vec![CapabilityPermission::FilesystemWrite],
186            EffectClass::Write,
187            RiskClass::High,
188            &source,
189        ));
190        ToolkitPackBundle::from_snapshot(snapshot)
191    }
192}
193
194impl ToolExecutor for WorkspaceEditExecutor {
195    fn executor_ref(&self) -> &ExecutorRef {
196        &self.executor_ref
197    }
198
199    fn execute(&self, request: &ToolExecutionRequest) -> Result<ToolExecutionOutput, AgentError> {
200        let args_ref = first_arg_ref(request)?;
201        let edit_request: WorkspaceEditRequest = self.arguments.get(args_ref)?;
202        let output = self.workspace.edit(&edit_request)?;
203        let content_ref = content_ref_for(request, "workspace_edit");
204        self.content.put(content_ref.clone(), &output)?;
205        let mut envelope = ToolExecutionOutput::completed(if output.applied {
206            "workspace edit applied with before and after hashes"
207        } else {
208            "workspace edit preview returned diff without writing"
209        });
210        envelope.content_refs.push(content_ref);
211        envelope.reconciliation_ref = Some(format!("reconcile.{}", output.after_hash));
212        Ok(envelope)
213    }
214}