agent_sdk_toolkit/workspace/
edit.rs1use 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)]
26pub struct WorkspaceEditRequest {
29 pub path: String,
31 pub anchor: HashLineAnchor,
33 pub replacement: String,
35 pub preview_only: bool,
38 pub preview_hash: Option<String>,
41}
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44pub struct WorkspaceEditOutput {
47 pub path: String,
49 pub preview_only: bool,
52 pub applied: bool,
55 pub before_hash: String,
58 pub after_hash: String,
61 pub preview_hash: String,
64 pub diff: String,
66 pub inverse_candidate: Option<String>,
69}
70
71impl BoundedWorkspace {
72 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)]
138pub struct WorkspaceEditExecutor {
141 executor_ref: ExecutorRef,
142 workspace: Arc<BoundedWorkspace>,
143 arguments: InMemoryJsonArgumentStore,
144 content: InMemoryToolkitContentStore,
145}
146
147impl WorkspaceEditExecutor {
148 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 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}