Skip to content

Commit a2bb53d

Browse files
committed
feat(tools): add coding-agent tool primitives + ACP terminal
Five new tools closing the biggest gaps for coding agents running through Zed ACP or any other serve adapter. Framework-level (reusable from any agent): - `beeai_framework.tools.code.ShellTool` — subprocess runner with timeout, list-form argv (no shell injection), returns exit code + stdout/stderr. - `beeai_framework.tools.filesystem.GlobTool` — pathlib-based file discovery with hidden-file gating and truncation cap. - `beeai_framework.tools.filesystem.GrepTool` — recursive regex search, shells out to `rg` when available (JSON stream parsing, gitignore-aware), falls back to stdlib `re` + `rglob` otherwise. - `beeai_framework.tools.filesystem.FilePatchTool` — two modes: unified-diff via `patch-ng`, and single-file exact-text replace keyed on expected occurrence count (prevents silent over-matching, the most common agent edit footgun). New optional `[filesystem]` extra. Adapter-level (Zed ACP): - `ACPZedTerminalTool` — routes commands through ACP `terminal/*` so live output renders in Zed's terminal widget and the process lifetime is editor-controlled. Extends `FsBridge` with `create_terminal`, `terminal_output`, `wait_for_terminal_exit`, `release_terminal`, `kill_terminal` passthroughs and a `can_terminal` capability gate. Wiring: - `examples/serve/acp_zed/lite.py` now registers all five tools alongside the existing FS read/write. - 23 new unit tests covering happy paths, timeouts, capability gating, path validation, rg-present / rg-missing grep branches, and diff round-trips. Signed-off-by: Tomáš Dvořák <toomas2d@gmail.com>
1 parent 5cb4611 commit a2bb53d

17 files changed

Lines changed: 995 additions & 13 deletions

File tree

‎python/beeai_framework/adapters/acp_zed/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33

44
from beeai_framework.adapters.acp_zed.serve.server import ACPZedServer, ACPZedServerConfig
55
from beeai_framework.adapters.acp_zed.tools.read_file import ACPZedReadFileTool
6+
from beeai_framework.adapters.acp_zed.tools.terminal import ACPZedTerminalTool
67
from beeai_framework.adapters.acp_zed.tools.write_file import ACPZedWriteFileTool
78

89
__all__ = [
910
"ACPZedReadFileTool",
1011
"ACPZedServer",
1112
"ACPZedServerConfig",
13+
"ACPZedTerminalTool",
1214
"ACPZedWriteFileTool",
1315
]

‎python/beeai_framework/adapters/acp_zed/serve/agent.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ def can_write(self) -> bool:
7373
fs = getattr(self._capabilities, "fs", None)
7474
return bool(getattr(fs, "write_text_file", False))
7575

76+
@property
77+
def can_terminal(self) -> bool:
78+
return bool(getattr(self._capabilities, "terminal", False))
79+
7680
def _active_session_id(self) -> str:
7781
sid = _active_session.get()
7882
if sid is None:
@@ -93,6 +97,39 @@ async def read_text_file(self, path: str, *, line: int | None = None, limit: int
9397
async def write_text_file(self, path: str, content: str) -> None:
9498
await self._require_conn().write_text_file(content=content, path=path, session_id=self._active_session_id())
9599

100+
async def create_terminal(
101+
self,
102+
command: str,
103+
*,
104+
args: list[str] | None = None,
105+
cwd: str | None = None,
106+
env: list[Any] | None = None,
107+
output_byte_limit: int | None = None,
108+
) -> str:
109+
response = await self._require_conn().create_terminal(
110+
command=command,
111+
session_id=self._active_session_id(),
112+
args=args,
113+
cwd=cwd,
114+
env=env,
115+
output_byte_limit=output_byte_limit,
116+
)
117+
return response.terminal_id
118+
119+
async def terminal_output(self, terminal_id: str) -> Any:
120+
return await self._require_conn().terminal_output(session_id=self._active_session_id(), terminal_id=terminal_id)
121+
122+
async def wait_for_terminal_exit(self, terminal_id: str) -> Any:
123+
return await self._require_conn().wait_for_terminal_exit(
124+
session_id=self._active_session_id(), terminal_id=terminal_id
125+
)
126+
127+
async def release_terminal(self, terminal_id: str) -> None:
128+
await self._require_conn().release_terminal(session_id=self._active_session_id(), terminal_id=terminal_id)
129+
130+
async def kill_terminal(self, terminal_id: str) -> None:
131+
await self._require_conn().kill_terminal(session_id=self._active_session_id(), terminal_id=terminal_id)
132+
96133

97134
class ACPZedServerAgent(ACPBaseAgent):
98135
"""Wrapper that exposes a BeeAI agent over the Zed Agent Client Protocol (stdio).

‎python/beeai_framework/adapters/acp_zed/tools/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
from beeai_framework.adapters.acp_zed.tools.read_file import ACPZedReadFileTool
5+
from beeai_framework.adapters.acp_zed.tools.terminal import ACPZedTerminalTool
56
from beeai_framework.adapters.acp_zed.tools.write_file import ACPZedWriteFileTool
67

7-
__all__ = ["ACPZedReadFileTool", "ACPZedWriteFileTool"]
8+
__all__ = ["ACPZedReadFileTool", "ACPZedTerminalTool", "ACPZedWriteFileTool"]
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
from typing import TYPE_CHECKING, Any, Self
7+
8+
from pydantic import BaseModel, Field
9+
10+
from beeai_framework.context import RunContext
11+
from beeai_framework.emitter.emitter import Emitter
12+
from beeai_framework.tools import JSONToolOutput
13+
from beeai_framework.tools.errors import ToolError
14+
from beeai_framework.tools.tool import Tool
15+
from beeai_framework.tools.types import ToolRunOptions
16+
17+
try:
18+
from acp.schema import EnvVariable
19+
except ModuleNotFoundError as e:
20+
raise ModuleNotFoundError(
21+
"Optional module [acp_zed] not found.\nRun 'pip install \"beeai-framework[acp-zed]\"' to install."
22+
) from e
23+
24+
if TYPE_CHECKING:
25+
from beeai_framework.adapters.acp_zed.serve.server import ACPZedServer
26+
27+
28+
class ACPZedTerminalToolInput(BaseModel):
29+
command: str = Field(description="Program to run (e.g. 'pytest', 'npm').")
30+
args: list[str] = Field(default_factory=list, description="Arguments passed to the program.")
31+
cwd: str | None = Field(default=None, description="Working directory (absolute path).")
32+
env: dict[str, str] | None = Field(default=None, description="Extra environment variables.")
33+
output_byte_limit: int | None = Field(default=None, description="Cap captured output in bytes. Editor-enforced.")
34+
wait: bool = Field(
35+
default=True,
36+
description=(
37+
"Wait for the process to exit and return its output. If false, returns the terminal id immediately."
38+
),
39+
)
40+
41+
42+
class ACPZedTerminalTool(Tool[ACPZedTerminalToolInput, ToolRunOptions, JSONToolOutput[dict[str, Any]]]):
43+
"""Run a command in the editor's terminal via ACP `terminal/*`.
44+
45+
Live output shows in the Zed terminal widget while the process is running; the
46+
agent blocks on exit (by default) and gets back `{exit_code, stdout, stderr,
47+
terminal_id}`. Requires the client to advertise `terminal` capability.
48+
"""
49+
50+
name = "acp_zed_terminal"
51+
description = (
52+
"Run a command in the user's editor terminal. Returns exit code, stdout, and stderr after the "
53+
"process exits. Prefer this over generic shell when running under Zed."
54+
)
55+
input_schema = ACPZedTerminalToolInput
56+
57+
def __init__(self, server: ACPZedServer[Any], options: dict[str, Any] | None = None) -> None:
58+
super().__init__(options=options)
59+
self._server = server
60+
61+
async def clone(self) -> Self:
62+
return type(self)(server=self._server, options=self.options)
63+
64+
def _create_emitter(self) -> Emitter:
65+
return Emitter.root().child(namespace=["tool", "acp_zed", "terminal"], creator=self)
66+
67+
async def _run(
68+
self, input: ACPZedTerminalToolInput, options: ToolRunOptions | None, context: RunContext
69+
) -> JSONToolOutput[dict[str, Any]]:
70+
bridge = self._server.bridge
71+
if not bridge.can_terminal:
72+
raise ToolError("ACP client does not advertise terminal capability")
73+
74+
env_list = [EnvVariable(name=k, value=v) for k, v in (input.env or {}).items()] or None
75+
terminal_id = await bridge.create_terminal(
76+
input.command,
77+
args=input.args,
78+
cwd=input.cwd,
79+
env=env_list,
80+
output_byte_limit=input.output_byte_limit,
81+
)
82+
83+
if not input.wait:
84+
return JSONToolOutput({"terminal_id": terminal_id, "waited": False})
85+
86+
try:
87+
exit_info = await bridge.wait_for_terminal_exit(terminal_id)
88+
output = await bridge.terminal_output(terminal_id)
89+
finally:
90+
await bridge.release_terminal(terminal_id)
91+
92+
return JSONToolOutput(
93+
{
94+
"terminal_id": terminal_id,
95+
"exit_code": getattr(exit_info, "exit_code", None),
96+
"signal": getattr(exit_info, "signal", None),
97+
"stdout": getattr(output, "output", "") or "",
98+
"truncated": getattr(output, "truncated", False),
99+
"waited": True,
100+
}
101+
)

‎python/beeai_framework/tools/code/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from beeai_framework.tools.code.output import PythonToolOutput
55
from beeai_framework.tools.code.python import PythonTool
66
from beeai_framework.tools.code.sandbox import SandboxTool
7+
from beeai_framework.tools.code.shell import ShellTool, ShellToolInput
78
from beeai_framework.tools.code.storage import LocalPythonStorage, PythonStorage
89

910
__all__ = [
@@ -12,4 +13,6 @@
1213
"PythonTool",
1314
"PythonToolOutput",
1415
"SandboxTool",
16+
"ShellTool",
17+
"ShellToolInput",
1518
]
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
import asyncio
7+
import os
8+
from typing import Any, Self
9+
10+
from pydantic import BaseModel, Field
11+
12+
from beeai_framework.context import RunContext
13+
from beeai_framework.emitter.emitter import Emitter
14+
from beeai_framework.tools import JSONToolOutput
15+
from beeai_framework.tools.errors import ToolError, ToolInputValidationError
16+
from beeai_framework.tools.tool import Tool
17+
from beeai_framework.tools.types import ToolRunOptions
18+
19+
20+
class ShellToolInput(BaseModel):
21+
command: list[str] = Field(
22+
description=(
23+
"Command and arguments as a list (no shell interpretation). "
24+
"Example: ['git', 'status', '--porcelain']. Use shlex.split on a user string if needed."
25+
)
26+
)
27+
cwd: str | None = Field(default=None, description="Working directory. Defaults to the current process cwd.")
28+
env: dict[str, str] | None = Field(
29+
default=None, description="Additional environment variables merged over the current environment."
30+
)
31+
timeout_seconds: float | None = Field(
32+
default=60.0, description="Kill the process if it runs longer. Set to None to disable."
33+
)
34+
input_text: str | None = Field(default=None, description="Optional text piped to the process on stdin.")
35+
36+
37+
class ShellTool(Tool[ShellToolInput, ToolRunOptions, JSONToolOutput[dict[str, Any]]]):
38+
"""Run a local subprocess and collect its output.
39+
40+
Takes the command as a list of arguments (no shell interpretation), so callers
41+
don't have to worry about quoting or injection. Captures stdout, stderr, exit
42+
code, and a `timed_out` flag. Under Zed's ACP adapter, prefer
43+
`ACPZedTerminalTool` — it routes through the editor's terminal widget and shows
44+
live output; this tool is for harnesses without a client-side terminal.
45+
"""
46+
47+
name = "shell"
48+
description = (
49+
"Run a local command as a subprocess and return its exit code, stdout, and stderr. "
50+
"The command must be a list of arguments; it is NOT run through a shell."
51+
)
52+
input_schema = ShellToolInput
53+
54+
async def clone(self) -> Self:
55+
return type(self)(options=self.options)
56+
57+
def _create_emitter(self) -> Emitter:
58+
return Emitter.root().child(namespace=["tool", "code", "shell"], creator=self)
59+
60+
async def _run(
61+
self, input: ShellToolInput, options: ToolRunOptions | None, context: RunContext
62+
) -> JSONToolOutput[dict[str, Any]]:
63+
if not input.command:
64+
raise ToolInputValidationError("`command` must be a non-empty list")
65+
66+
merged_env = {**os.environ, **(input.env or {})} if input.env else None
67+
try:
68+
process = await asyncio.create_subprocess_exec(
69+
*input.command,
70+
cwd=input.cwd,
71+
env=merged_env,
72+
stdin=asyncio.subprocess.PIPE if input.input_text is not None else None,
73+
stdout=asyncio.subprocess.PIPE,
74+
stderr=asyncio.subprocess.PIPE,
75+
)
76+
except FileNotFoundError as e:
77+
raise ToolError(f"Command not found: {input.command[0]!r}") from e
78+
79+
stdin_bytes = input.input_text.encode() if input.input_text is not None else None
80+
timed_out = False
81+
try:
82+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
83+
process.communicate(input=stdin_bytes), timeout=input.timeout_seconds
84+
)
85+
except TimeoutError:
86+
process.kill()
87+
stdout_bytes, stderr_bytes = await process.communicate()
88+
timed_out = True
89+
90+
return JSONToolOutput(
91+
{
92+
"exit_code": process.returncode if process.returncode is not None else -1,
93+
"stdout": stdout_bytes.decode(errors="replace"),
94+
"stderr": stderr_bytes.decode(errors="replace"),
95+
"timed_out": timed_out,
96+
}
97+
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from beeai_framework.tools.filesystem.glob_tool import GlobTool, GlobToolInput
5+
from beeai_framework.tools.filesystem.grep_tool import GrepTool, GrepToolInput
6+
7+
# `FilePatchTool` lives in `.patch` and depends on the optional `patch-ng` package
8+
# (install via `pip install "beeai-framework[filesystem]"`). Import it directly
9+
# from `beeai_framework.tools.filesystem.patch` so package import doesn't fail.
10+
11+
__all__ = [
12+
"GlobTool",
13+
"GlobToolInput",
14+
"GrepTool",
15+
"GrepToolInput",
16+
]
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
from pathlib import Path
7+
from typing import Any, Self
8+
9+
from pydantic import BaseModel, Field
10+
11+
from beeai_framework.context import RunContext
12+
from beeai_framework.emitter.emitter import Emitter
13+
from beeai_framework.tools import JSONToolOutput
14+
from beeai_framework.tools.tool import Tool
15+
from beeai_framework.tools.types import ToolRunOptions
16+
17+
18+
class GlobToolInput(BaseModel):
19+
pattern: str = Field(description="Glob pattern, e.g. '**/*.py' or 'src/**/*.ts'.")
20+
root: str = Field(default=".", description="Directory to search from.")
21+
include_hidden: bool = Field(default=False, description="Include entries whose name starts with a dot.")
22+
limit: int = Field(default=1000, description="Maximum number of paths to return.")
23+
24+
25+
class GlobTool(Tool[GlobToolInput, ToolRunOptions, JSONToolOutput[dict[str, Any]]]):
26+
"""Find paths matching a glob pattern under a root directory.
27+
28+
Uses `pathlib.Path.glob` — patterns like `**/*.py` work out of the box. Hidden
29+
files/directories (names starting with `.`) are excluded by default.
30+
"""
31+
32+
name = "glob"
33+
description = "List files and directories matching a glob pattern (e.g. '**/*.py')."
34+
input_schema = GlobToolInput
35+
36+
async def clone(self) -> Self:
37+
return type(self)(options=self.options)
38+
39+
def _create_emitter(self) -> Emitter:
40+
return Emitter.root().child(namespace=["tool", "filesystem", "glob"], creator=self)
41+
42+
async def _run(
43+
self, input: GlobToolInput, options: ToolRunOptions | None, context: RunContext
44+
) -> JSONToolOutput[dict[str, Any]]:
45+
root = Path(input.root).expanduser().resolve()
46+
matches: list[str] = []
47+
truncated = False
48+
for path in root.glob(input.pattern):
49+
if not input.include_hidden and any(part.startswith(".") for part in path.relative_to(root).parts):
50+
continue
51+
matches.append(str(path))
52+
if len(matches) >= input.limit:
53+
truncated = True
54+
break
55+
return JSONToolOutput({"matches": matches, "truncated": truncated})

0 commit comments

Comments
 (0)