Skip to content

Commit 9273aad

Browse files
committed
refactor(workspace): pluggable backend abstraction + 16 optimizations
Introduces a workspace capability abstraction so built-in tools (read, write, edit, patch, ls, bash, grep, glob, git, git_stash, git_worktree) route through trait-based providers instead of hard-coded local filesystem calls. The default LocalWorkspaceBackend preserves existing behavior; future remote/browser/DFS/container backends can plug in via WorkspaceServicesBuilder without changing tool schemas. Key changes: - Split workspace.rs into workspace/{mod.rs, local.rs}: trait definitions + LocalWorkspaceBackend in separate files (CLAUDE.md file-size rule). - New traits: WorkspaceFileSystem, WorkspaceCommandRunner, WorkspaceSearch, WorkspaceGit (slim core) + optional WorkspaceGitStashProvider and WorkspaceGitWorktreeProvider, CommandOutputObserver. - WorkspaceServices: assembled via WorkspaceServicesBuilder; auto-downgrades capabilities when matching providers are missing; carries an optional per-operation timeout enforced by run_with_timeout helper. - Capability gating: register_builtins(registry, &capabilities) only registers tools whose required capability is provided — bash, grep, glob, git can be hidden from the model when the backend lacks them. - Local backend now implements WorkspaceCommandRunner (no more bash-specific local fallback in the tool). - CommandRequest carries Arc<dyn CommandOutputObserver> instead of leaking ToolEventSender into the abstraction. - ChildRunContext propagates workspace_services to child runs. - New Session direct-tool APIs: write_file, ls, edit_file, patch_file. - WorkspaceFileSystemExt policy: future trait extensions go to a separate extension trait (additive only). - Removed dead WorkspaceCapabilities flags (watch, atomic_write), unreachable bash fallback branch, set_workspace_services mutation hazard, redundant ToolExecutor private constructors. - Deprecated ToolContext::resolve_path / resolve_path_for_write under non-local backends. Tests: - 17 new unit + integration tests in workspace + tools modules. - All 1553 lib tests + integration tests pass. - cargo clippy --all-targets --all-features -D warnings clean.
1 parent aea336a commit 9273aad

39 files changed

Lines changed: 3535 additions & 552 deletions

core/src/agent_api.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ pub struct SessionOptions {
175175
/// of `std::process::Command`. The host application constructs and owns
176176
/// the implementation (e.g., an A3S Box–backed handle).
177177
pub sandbox_handle: Option<Arc<dyn crate::sandbox::BashSandbox>>,
178+
/// Optional host-provided workspace backend.
179+
///
180+
/// When set, built-in tools such as `read`, `write`, `ls`, and `bash`
181+
/// execute against these workspace capabilities instead of assuming the
182+
/// server-local filesystem. This is the primary extension point for DFS,
183+
/// browser, container, and remote workspace deployments.
184+
pub workspace_services: Option<Arc<crate::workspace::WorkspaceServices>>,
178185
/// Enable auto-compaction when context usage exceeds threshold.
179186
pub auto_compact: bool,
180187
/// Context usage percentage threshold for auto-compaction (0.0 - 1.0).
@@ -691,6 +698,38 @@ impl AgentSession {
691698
DirectToolRuntime::from_session(self).read_file(path).await
692699
}
693700

701+
/// Write a file in the workspace.
702+
pub async fn write_file(&self, path: &str, content: &str) -> Result<ToolCallResult> {
703+
DirectToolRuntime::from_session(self)
704+
.write_file(path, content)
705+
.await
706+
}
707+
708+
/// List a directory in the workspace.
709+
pub async fn ls(&self, path: Option<&str>) -> Result<ToolCallResult> {
710+
DirectToolRuntime::from_session(self).ls(path).await
711+
}
712+
713+
/// Edit a file by replacing text in the workspace.
714+
pub async fn edit_file(
715+
&self,
716+
path: &str,
717+
old_string: &str,
718+
new_string: &str,
719+
replace_all: bool,
720+
) -> Result<ToolCallResult> {
721+
DirectToolRuntime::from_session(self)
722+
.edit_file(path, old_string, new_string, replace_all)
723+
.await
724+
}
725+
726+
/// Apply a unified diff patch to a workspace file.
727+
pub async fn patch_file(&self, path: &str, diff: &str) -> Result<ToolCallResult> {
728+
DirectToolRuntime::from_session(self)
729+
.patch_file(path, diff)
730+
.await
731+
}
732+
694733
/// Execute a bash command in the workspace.
695734
///
696735
/// When a sandbox handle is configured via

core/src/agent_api/capabilities.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,18 @@ pub(super) struct SessionCapabilities {
3939

4040
pub(super) fn build_session_capabilities(input: SessionCapabilityInput<'_>) -> SessionCapabilities {
4141
let artifact_limits = input.opts.artifact_store_limits.unwrap_or_default();
42-
let tool_executor = Arc::new(ToolExecutor::new_with_artifact_limits(
43-
input.workspace.display().to_string(),
44-
artifact_limits,
45-
));
42+
let workspace_services = input
43+
.opts
44+
.workspace_services
45+
.clone()
46+
.unwrap_or_else(|| crate::workspace::WorkspaceServices::local(input.workspace));
47+
let tool_executor = Arc::new(
48+
ToolExecutor::new_with_workspace_services_and_artifact_limits(
49+
input.workspace.display().to_string(),
50+
workspace_services,
51+
artifact_limits,
52+
),
53+
);
4654
let trace_sink = crate::trace::InMemoryTraceSink::default();
4755
tool_executor.set_trace_sink(Arc::new(trace_sink.clone()));
4856

@@ -151,6 +159,7 @@ fn register_task_capability(
151159
max_execution_time_ms: opts.max_execution_time_ms,
152160
circuit_breaker_threshold: opts.circuit_breaker_threshold,
153161
confirmation_manager: opts.confirmation_manager.clone(),
162+
workspace_services: opts.workspace_services.clone(),
154163
};
155164

156165
let registry = Arc::new(registry);

core/src/agent_api/direct_tools.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,40 @@ impl DirectToolRuntime {
4545
Ok(result.output)
4646
}
4747

48+
pub(super) async fn write_file(&self, path: &str, content: &str) -> Result<ToolCallResult> {
49+
let args = serde_json::json!({ "file_path": path, "content": content });
50+
self.call("write", args).await
51+
}
52+
53+
pub(super) async fn ls(&self, path: Option<&str>) -> Result<ToolCallResult> {
54+
let args = match path {
55+
Some(path) => serde_json::json!({ "path": path }),
56+
None => serde_json::json!({}),
57+
};
58+
self.call("ls", args).await
59+
}
60+
61+
pub(super) async fn edit_file(
62+
&self,
63+
path: &str,
64+
old_string: &str,
65+
new_string: &str,
66+
replace_all: bool,
67+
) -> Result<ToolCallResult> {
68+
let args = serde_json::json!({
69+
"file_path": path,
70+
"old_string": old_string,
71+
"new_string": new_string,
72+
"replace_all": replace_all,
73+
});
74+
self.call("edit", args).await
75+
}
76+
77+
pub(super) async fn patch_file(&self, path: &str, diff: &str) -> Result<ToolCallResult> {
78+
let args = serde_json::json!({ "file_path": path, "diff": diff });
79+
self.call("patch", args).await
80+
}
81+
4882
pub(super) async fn bash(&self, command: &str) -> Result<String> {
4983
let args = serde_json::json!({ "command": command });
5084
let result = self

core/src/agent_api/session_options.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ impl std::fmt::Debug for SessionOptions {
4343
.field("tool_timeout_ms", &self.tool_timeout_ms)
4444
.field("circuit_breaker_threshold", &self.circuit_breaker_threshold)
4545
.field("sandbox_handle", &self.sandbox_handle.is_some())
46+
.field("workspace_services", &self.workspace_services.is_some())
4647
.field("auto_compact", &self.auto_compact)
4748
.field("auto_compact_threshold", &self.auto_compact_threshold)
4849
.field("continuation_enabled", &self.continuation_enabled)
@@ -335,6 +336,19 @@ impl SessionOptions {
335336
self
336337
}
337338

339+
/// Provide a workspace backend for this session.
340+
///
341+
/// Built-in tools keep their stable names and schemas, while their backing
342+
/// implementation can target a DFS, browser workspace, remote runner, or
343+
/// any other host-provided backend.
344+
pub fn with_workspace_backend(
345+
mut self,
346+
services: Arc<crate::workspace::WorkspaceServices>,
347+
) -> Self {
348+
self.workspace_services = Some(services);
349+
self
350+
}
351+
338352
/// Enable auto-compaction when context usage exceeds threshold.
339353
///
340354
/// When enabled, the agent loop automatically prunes large tool outputs

core/src/agent_api/session_runtime.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,12 @@ fn build_command_queue(
105105

106106
fn build_tool_context(
107107
code_config: &CodeConfig,
108-
workspace: &Path,
108+
_workspace: &Path,
109109
opts: &SessionOptions,
110110
tool_executor: Arc<ToolExecutor>,
111111
agent_event_tx: broadcast::Sender<AgentEvent>,
112112
) -> ToolContext {
113-
let mut tool_context = ToolContext::new(workspace.to_path_buf());
113+
let mut tool_context = tool_executor.registry().context();
114114
if let Some(ref search_config) = code_config.search {
115115
tool_context = tool_context.with_search_config(search_config.clone());
116116
}

core/src/agent_api/tests.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,98 @@ struct CapturingContextProvider {
5353
session_ids: std::sync::Mutex<Vec<Option<String>>>,
5454
}
5555

56+
#[derive(Default)]
57+
struct TestWorkspaceFs {
58+
files: std::sync::RwLock<std::collections::HashMap<String, String>>,
59+
}
60+
61+
impl TestWorkspaceFs {
62+
fn insert(&self, path: &str, content: &str) {
63+
self.files
64+
.write()
65+
.unwrap()
66+
.insert(path.to_string(), content.to_string());
67+
}
68+
69+
fn read_raw(&self, path: &str) -> Option<String> {
70+
self.files.read().unwrap().get(path).cloned()
71+
}
72+
}
73+
74+
#[async_trait::async_trait]
75+
impl crate::workspace::WorkspaceFileSystem for TestWorkspaceFs {
76+
async fn read_text(&self, path: &crate::workspace::WorkspacePath) -> anyhow::Result<String> {
77+
self.files
78+
.read()
79+
.unwrap()
80+
.get(path.as_str())
81+
.cloned()
82+
.ok_or_else(|| anyhow::anyhow!("missing test workspace file: {}", path.as_str()))
83+
}
84+
85+
async fn write_text(
86+
&self,
87+
path: &crate::workspace::WorkspacePath,
88+
content: &str,
89+
) -> anyhow::Result<crate::workspace::WorkspaceWriteOutcome> {
90+
self.insert(path.as_str(), content);
91+
Ok(crate::workspace::WorkspaceWriteOutcome {
92+
bytes: content.len(),
93+
lines: content.lines().count(),
94+
})
95+
}
96+
97+
async fn list_dir(
98+
&self,
99+
path: &crate::workspace::WorkspacePath,
100+
) -> anyhow::Result<Vec<crate::workspace::WorkspaceDirEntry>> {
101+
let prefix = if path.is_root() {
102+
String::new()
103+
} else {
104+
format!("{}/", path.as_str())
105+
};
106+
let files = self.files.read().unwrap();
107+
let mut entries = Vec::new();
108+
109+
for (file_path, content) in files.iter() {
110+
if !file_path.starts_with(&prefix) {
111+
continue;
112+
}
113+
let remaining = &file_path[prefix.len()..];
114+
if remaining.is_empty() || remaining.contains('/') {
115+
continue;
116+
}
117+
entries.push(crate::workspace::WorkspaceDirEntry {
118+
name: remaining.to_string(),
119+
kind: crate::workspace::WorkspaceFileType::File,
120+
size: content.len() as u64,
121+
});
122+
}
123+
124+
Ok(entries)
125+
}
126+
}
127+
128+
#[derive(Default)]
129+
struct TestWorkspaceRunner {
130+
commands: std::sync::RwLock<Vec<String>>,
131+
}
132+
133+
#[async_trait::async_trait]
134+
impl crate::workspace::WorkspaceCommandRunner for TestWorkspaceRunner {
135+
async fn exec(
136+
&self,
137+
request: crate::workspace::CommandRequest,
138+
) -> anyhow::Result<crate::workspace::CommandOutput> {
139+
self.commands.write().unwrap().push(request.command.clone());
140+
Ok(crate::workspace::CommandOutput {
141+
output: format!("session runner: {}\n", request.command),
142+
exit_code: 0,
143+
timed_out: false,
144+
})
145+
}
146+
}
147+
56148
#[async_trait::async_trait]
57149
impl crate::context::ContextProvider for CapturingContextProvider {
58150
fn name(&self) -> &str {
@@ -250,6 +342,69 @@ async fn test_session_default() {
250342
assert!(debug.contains("AgentSession"));
251343
}
252344

345+
#[tokio::test]
346+
async fn test_session_uses_workspace_backend_for_direct_tools() {
347+
let fs = Arc::new(TestWorkspaceFs::default());
348+
fs.insert("app.txt", "hello from backend\n");
349+
let fs_backend: Arc<dyn crate::workspace::WorkspaceFileSystem> = fs.clone();
350+
let runner = Arc::new(TestWorkspaceRunner::default());
351+
let runner_backend: Arc<dyn crate::workspace::WorkspaceCommandRunner> = runner.clone();
352+
let services = crate::workspace::WorkspaceServices::builder(
353+
crate::workspace::WorkspaceRef::new("session-workspace", "session://workspace"),
354+
fs_backend,
355+
)
356+
.command_runner(runner_backend)
357+
.build();
358+
359+
let agent = Agent::from_config(test_config()).await.unwrap();
360+
let session = agent
361+
.session(
362+
"/server/local-placeholder",
363+
Some(SessionOptions::new().with_workspace_backend(services)),
364+
)
365+
.unwrap();
366+
367+
let tool_names = session.tool_names();
368+
assert!(tool_names.contains(&"read".to_string()));
369+
assert!(tool_names.contains(&"write".to_string()));
370+
assert!(tool_names.contains(&"ls".to_string()));
371+
assert!(tool_names.contains(&"bash".to_string()));
372+
assert!(!tool_names.contains(&"grep".to_string()));
373+
assert!(!tool_names.contains(&"glob".to_string()));
374+
assert!(!tool_names.contains(&"git".to_string()));
375+
376+
let read = session.read_file("app.txt").await.unwrap();
377+
assert!(read.contains("hello from backend"));
378+
379+
let write = session
380+
.write_file("created.txt", "one\ntwo\n")
381+
.await
382+
.unwrap();
383+
assert_eq!(write.exit_code, 0, "{}", write.output);
384+
assert_eq!(fs.read_raw("created.txt").as_deref(), Some("one\ntwo\n"));
385+
386+
let listing = session.ls(None).await.unwrap();
387+
assert_eq!(listing.exit_code, 0, "{}", listing.output);
388+
assert!(listing.output.contains("created.txt"));
389+
390+
let edit = session
391+
.edit_file("created.txt", "one", "uno", false)
392+
.await
393+
.unwrap();
394+
assert_eq!(edit.exit_code, 0, "{}", edit.output);
395+
assert_eq!(fs.read_raw("created.txt").as_deref(), Some("uno\ntwo\n"));
396+
397+
let patch = session
398+
.patch_file("created.txt", "@@ -1,2 +1,2 @@\n uno\n-two\n+dos")
399+
.await
400+
.unwrap();
401+
assert_eq!(patch.exit_code, 0, "{}", patch.output);
402+
assert_eq!(fs.read_raw("created.txt").as_deref(), Some("uno\ndos\n"));
403+
404+
let bash = session.bash("pwd").await.unwrap();
405+
assert_eq!(bash, "session runner: pwd\n");
406+
}
407+
253408
#[tokio::test]
254409
async fn test_session_routes_agents_md_through_context_provider() {
255410
let temp_dir = tempfile::tempdir().unwrap();

core/src/child_run.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
//! | max_execution_time_ms | Yes | Prevents runaway child runs |
1616
//! | circuit_breaker_threshold | Yes | LLM failure handling should be consistent |
1717
//! | confirmation_manager | Depends | Governed by ConfirmationInheritance |
18+
//! | workspace_services | Yes | Child tools must operate on the same workspace |
1819
//! | memory | No | Child has isolated context |
1920
//! | queue_config | No | Child runs are synchronous within parent |
2021
//! | planning_mode | No | Child tasks are pre-planned by parent |
@@ -39,6 +40,7 @@ pub struct ChildRunContext {
3940
pub max_execution_time_ms: Option<u64>,
4041
pub circuit_breaker_threshold: Option<u32>,
4142
pub confirmation_manager: Option<Arc<dyn ConfirmationProvider>>,
43+
pub workspace_services: Option<Arc<crate::workspace::WorkspaceServices>>,
4244
}
4345

4446
impl ChildRunContext {

core/src/lib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ pub(crate) mod tool_confirmation;
113113
pub mod tools;
114114
pub mod trace;
115115
pub mod verification;
116+
pub mod workspace;
116117

117118
// Re-export key types at crate root for ergonomic usage
118119
pub use agent::{AgentEvent, AgentResult};
@@ -133,3 +134,16 @@ pub use subagent::{
133134
AgentDefinition, AgentRegistry, CattleAgentKind, CattleAgentSpec, ConfirmationInheritance,
134135
WorkerAgentKind, WorkerAgentSpec,
135136
};
137+
pub use workspace::{
138+
CommandOutput, CommandOutputObserver, CommandRequest, LocalWorkspaceBackend,
139+
VirtualPathResolver, WorkspaceCapabilities, WorkspaceCommandRunner, WorkspaceDirEntry,
140+
WorkspaceFileSystem, WorkspaceFileType, WorkspaceGit, WorkspaceGitBranch,
141+
WorkspaceGitCheckoutOutput, WorkspaceGitCheckoutRequest, WorkspaceGitCommit,
142+
WorkspaceGitCreateBranchRequest, WorkspaceGitCreateWorktreeRequest, WorkspaceGitDiffRequest,
143+
WorkspaceGitRemote, WorkspaceGitRemoveWorktreeRequest, WorkspaceGitStash,
144+
WorkspaceGitStashProvider, WorkspaceGitStashRequest, WorkspaceGitStatus, WorkspaceGitWorktree,
145+
WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider, WorkspaceGlobRequest,
146+
WorkspaceGlobResult, WorkspaceGrepRequest, WorkspaceGrepResult, WorkspacePath,
147+
WorkspacePathResolver, WorkspaceRef, WorkspaceSearch, WorkspaceServices,
148+
WorkspaceServicesBuilder, WorkspaceWriteOutcome,
149+
};

core/src/llm/structured_tests.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,7 +1176,7 @@ async fn test_integration_generate_blocking_prompt_mode() {
11761176
let result = result.unwrap();
11771177
assert_eq!(result.object["sentiment"], "positive");
11781178
let confidence = result.object["confidence"].as_f64().unwrap();
1179-
assert!(confidence >= 0.0 && confidence <= 1.0);
1179+
assert!((0.0..=1.0).contains(&confidence));
11801180
eprintln!(
11811181
"Integration test passed: sentiment={}, confidence={}, repairs={}",
11821182
result.object["sentiment"], confidence, result.repair_rounds
@@ -1243,7 +1243,7 @@ async fn test_integration_generate_streaming_tool_mode() {
12431243
for lang in languages {
12441244
assert!(lang["name"].is_string());
12451245
let year = lang["year"].as_i64().unwrap();
1246-
assert!(year >= 1950 && year <= 2030);
1246+
assert!((1950..=2030).contains(&year));
12471247
}
12481248

12491249
let partial_count = partials.lock().unwrap().len();

0 commit comments

Comments
 (0)