feat: improve Daytona sandbox tools

Signed-off-by: Mislav Ivanda <mislavivanda454@gmail.com>
Co-authored-by: Greyson LaLonde <greyson.r.lalonde@gmail.com>
Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
This commit is contained in:
Mislav Ivanda
2026-05-08 23:29:30 +02:00
committed by GitHub
parent f495bda016
commit b9e71b322f
8 changed files with 583 additions and 107 deletions

View File

@@ -13,7 +13,7 @@ The Daytona sandbox tools give CrewAI agents access to isolated, ephemeral compu
- **`DaytonaExecTool`** — run any shell command inside a sandbox.
- **`DaytonaPythonTool`** — execute a block of Python source code inside a sandbox.
- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox.
- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox; also supports `move`, `find` (content grep), `search` (filename glob), `chmod` (permissions), `replace` (bulk find-and-replace), and `exists`.
All three tools share the same sandbox lifecycle controls, so you can mix and match them while keeping state in a single persistent sandbox.
@@ -55,7 +55,7 @@ from crewai_tools import DaytonaPythonTool
tool = DaytonaPythonTool()
result = tool.run(code="print(sum(range(10)))")
print(result)
# {"exit_code": 0, "result": "45\n", "artifacts": None}
# {"exit_code": 0, "result": "45\n", "artifacts": ExecutionArtifacts(stdout="45\n", charts=[])}
```
### Multi-step shell session (persistent)
@@ -63,17 +63,22 @@ print(result)
```python Code
from crewai_tools import DaytonaExecTool, DaytonaFileTool
# Create the persistent sandbox via the first tool, then attach the second
# tool to it so both share state (installed packages, files, env vars).
exec_tool = DaytonaExecTool(persistent=True)
file_tool = DaytonaFileTool(persistent=True)
# Install a package, then write and run a script — all in the same sandbox
exec_tool.run(command="pip install httpx -q")
file_tool.run(action="write", path="/workspace/fetch.py", content="import httpx; print(httpx.get('https://httpbin.org/get').status_code)")
exec_tool.run(command="python /workspace/fetch.py")
file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id)
file_tool.run(
action="write",
path="workspace/script.py",
content="import httpx; print(f'httpx loaded, version {httpx.__version__}')",
)
exec_tool.run(command="python workspace/script.py")
```
<Note>
Each tool instance maintains its own persistent sandbox. To share **one** sandbox across two tools, create the first tool, grab its sandbox id via `tool._persistent_sandbox.id`, and pass it to the second tool via `sandbox_id=...`.
By default, each tool with `persistent=True` lazily creates its **own** sandbox on first use. The pattern above shares a single sandbox across multiple tools by reading the first tool's `active_sandbox_id` after a `.run()` call and passing it to the others via `sandbox_id=...`. With `persistent=False` (the default), every `.run()` call gets a fresh sandbox that's deleted at the end of that call.
</Note>
### Attach to an existing sandbox
@@ -82,7 +87,7 @@ Each tool instance maintains its own persistent sandbox. To share **one** sandbo
from crewai_tools import DaytonaExecTool
tool = DaytonaExecTool(sandbox_id="my-long-lived-sandbox")
result = tool.run(command="ls /workspace")
result = tool.run(command="ls workspace")
```
### Custom sandbox parameters
@@ -102,6 +107,41 @@ tool = DaytonaExecTool(
)
```
### Searching, moving, and modifying files
```python Code
from crewai_tools import DaytonaFileTool
file_tool = DaytonaFileTool(persistent=True)
# Find every TODO in the source tree (grep file contents recursively)
file_tool.run(action="find", path="workspace/src", pattern="TODO:")
# Find all Python files (glob match on filenames)
file_tool.run(action="search", path="workspace", pattern="*.py")
# Make a script executable
file_tool.run(action="chmod", path="workspace/run.sh", mode="755")
# Rename or move a file
file_tool.run(
action="move",
path="workspace/draft.md",
destination="workspace/final.md",
)
# Bulk find-and-replace across multiple files
file_tool.run(
action="replace",
paths=["workspace/src/a.py", "workspace/src/b.py"],
pattern="old_function",
replacement="new_function",
)
# Quick existence check before a destructive op
file_tool.run(action="exists", path="workspace/cache.db")
```
### Agent integration
```python Code
@@ -121,7 +161,7 @@ coder = Agent(
)
task = Task(
description="Write a Python script that prints the first 10 Fibonacci numbers, save it to /workspace/fib.py, and run it.",
description="Write a Python script that prints the first 10 Fibonacci numbers, save it to workspace/fib.py, and run it.",
expected_output="The first 10 Fibonacci numbers printed to stdout.",
agent=coder,
)
@@ -168,12 +208,22 @@ All three tools accept these parameters at initialization:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`. |
| `path` | `str` | ✓ | Absolute path inside the sandbox. |
| `content` | `str \| None` | | Content to write or append. Required for `append`. |
| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`, `exists`, `move`, `find`, `search`, `chmod`, `replace`. |
| `path` | `str \| None` | ✓ for all actions except `replace` | Absolute path inside the sandbox. |
| `content` | `str \| None` | ✓ for `append` | Content to write or append. |
| `binary` | `bool` | | If `True`, `content` is base64 on write; returns base64 on read. |
| `recursive` | `bool` | | For `delete`: remove directories recursively. |
| `mode` | `str` | | For `mkdir`: octal permission string (default `"0755"`). |
| `mode` | `str \| None` | | For `mkdir`: octal permissions for the new directory (defaults to `"0755"`). For `chmod`: octal permissions to apply to the target. |
| `destination` | `str \| None` | ✓ for `move` | Destination path for `move`. |
| `pattern` | `str \| None` | ✓ for `find`, `search`, `replace` | For `find`: substring matched against file CONTENTS. For `search`: glob matched against file NAMES (e.g. `*.py`). For `replace`: text to replace inside files. |
| `replacement` | `str \| None` | ✓ for `replace` | Replacement text for `pattern`. |
| `paths` | `list[str] \| None` | ✓ for `replace` | List of file paths in which to replace text. |
| `owner` | `str \| None` | | For `chmod`: new file owner. |
| `group` | `str \| None` | | For `chmod`: new file group. |
<Note>
For `chmod`, pass at least one of `mode`, `owner`, or `group` — any field left as `None` is left unchanged on the target.
</Note>
<Tip>
For files larger than a few KB, create the file first with `action="write"` and empty content, then send the body via multiple `action="append"` calls of ~4 KB each to stay within tool-call payload limits.

View File

@@ -283,6 +283,7 @@
"en/tools/ai-ml/langchaintool",
"en/tools/ai-ml/ragtool",
"en/tools/ai-ml/codeinterpretertool",
"en/tools/ai-ml/daytona",
"en/tools/ai-ml/e2bsandboxtools"
]
},
@@ -764,6 +765,7 @@
"en/tools/ai-ml/langchaintool",
"en/tools/ai-ml/ragtool",
"en/tools/ai-ml/codeinterpretertool",
"en/tools/ai-ml/daytona",
"en/tools/ai-ml/e2bsandboxtools"
]
},
@@ -1724,8 +1726,8 @@
"en/tools/ai-ml/langchaintool",
"en/tools/ai-ml/ragtool",
"en/tools/ai-ml/codeinterpretertool",
"en/tools/ai-ml/e2bsandboxtools",
"en/tools/ai-ml/daytona"
"en/tools/ai-ml/daytona",
"en/tools/ai-ml/e2bsandboxtools"
]
},
{
@@ -2205,8 +2207,8 @@
"en/tools/ai-ml/langchaintool",
"en/tools/ai-ml/ragtool",
"en/tools/ai-ml/codeinterpretertool",
"en/tools/ai-ml/e2bsandboxtools",
"en/tools/ai-ml/daytona"
"en/tools/ai-ml/daytona",
"en/tools/ai-ml/e2bsandboxtools"
]
},
{
@@ -2686,8 +2688,8 @@
"en/tools/ai-ml/langchaintool",
"en/tools/ai-ml/ragtool",
"en/tools/ai-ml/codeinterpretertool",
"en/tools/ai-ml/e2bsandboxtools",
"en/tools/ai-ml/daytona"
"en/tools/ai-ml/daytona",
"en/tools/ai-ml/e2bsandboxtools"
]
},
{
@@ -3167,8 +3169,8 @@
"en/tools/ai-ml/langchaintool",
"en/tools/ai-ml/ragtool",
"en/tools/ai-ml/codeinterpretertool",
"en/tools/ai-ml/e2bsandboxtools",
"en/tools/ai-ml/daytona"
"en/tools/ai-ml/daytona",
"en/tools/ai-ml/e2bsandboxtools"
]
},
{
@@ -3647,8 +3649,8 @@
"en/tools/ai-ml/langchaintool",
"en/tools/ai-ml/ragtool",
"en/tools/ai-ml/codeinterpretertool",
"en/tools/ai-ml/e2bsandboxtools",
"en/tools/ai-ml/daytona"
"en/tools/ai-ml/daytona",
"en/tools/ai-ml/e2bsandboxtools"
]
},
{
@@ -4126,8 +4128,8 @@
"en/tools/ai-ml/langchaintool",
"en/tools/ai-ml/ragtool",
"en/tools/ai-ml/codeinterpretertool",
"en/tools/ai-ml/e2bsandboxtools",
"en/tools/ai-ml/daytona"
"en/tools/ai-ml/daytona",
"en/tools/ai-ml/e2bsandboxtools"
]
},
{
@@ -4605,8 +4607,8 @@
"en/tools/ai-ml/langchaintool",
"en/tools/ai-ml/ragtool",
"en/tools/ai-ml/codeinterpretertool",
"en/tools/ai-ml/e2bsandboxtools",
"en/tools/ai-ml/daytona"
"en/tools/ai-ml/daytona",
"en/tools/ai-ml/e2bsandboxtools"
]
},
{
@@ -5084,8 +5086,8 @@
"en/tools/ai-ml/langchaintool",
"en/tools/ai-ml/ragtool",
"en/tools/ai-ml/codeinterpretertool",
"en/tools/ai-ml/e2bsandboxtools",
"en/tools/ai-ml/daytona"
"en/tools/ai-ml/daytona",
"en/tools/ai-ml/e2bsandboxtools"
]
},
{
@@ -5565,8 +5567,8 @@
"en/tools/ai-ml/langchaintool",
"en/tools/ai-ml/ragtool",
"en/tools/ai-ml/codeinterpretertool",
"en/tools/ai-ml/e2bsandboxtools",
"en/tools/ai-ml/daytona"
"en/tools/ai-ml/daytona",
"en/tools/ai-ml/e2bsandboxtools"
]
},
{
@@ -6045,8 +6047,8 @@
"en/tools/ai-ml/langchaintool",
"en/tools/ai-ml/ragtool",
"en/tools/ai-ml/codeinterpretertool",
"en/tools/ai-ml/e2bsandboxtools",
"en/tools/ai-ml/daytona"
"en/tools/ai-ml/daytona",
"en/tools/ai-ml/e2bsandboxtools"
]
},
{

View File

@@ -13,7 +13,7 @@ The Daytona sandbox tools give CrewAI agents access to isolated, ephemeral compu
- **`DaytonaExecTool`** — run any shell command inside a sandbox.
- **`DaytonaPythonTool`** — execute a block of Python source code inside a sandbox.
- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox.
- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox; also supports `move`, `find` (content grep), `search` (filename glob), `chmod` (permissions), `replace` (bulk find-and-replace), and `exists`.
All three tools share the same sandbox lifecycle controls, so you can mix and match them while keeping state in a single persistent sandbox.
@@ -55,7 +55,7 @@ from crewai_tools import DaytonaPythonTool
tool = DaytonaPythonTool()
result = tool.run(code="print(sum(range(10)))")
print(result)
# {"exit_code": 0, "result": "45\n", "artifacts": None}
# {"exit_code": 0, "result": "45\n", "artifacts": ExecutionArtifacts(stdout="45\n", charts=[])}
```
### Multi-step shell session (persistent)
@@ -63,17 +63,22 @@ print(result)
```python Code
from crewai_tools import DaytonaExecTool, DaytonaFileTool
# Create the persistent sandbox via the first tool, then attach the second
# tool to it so both share state (installed packages, files, env vars).
exec_tool = DaytonaExecTool(persistent=True)
file_tool = DaytonaFileTool(persistent=True)
# Install a package, then write and run a script — all in the same sandbox
exec_tool.run(command="pip install httpx -q")
file_tool.run(action="write", path="/workspace/fetch.py", content="import httpx; print(httpx.get('https://httpbin.org/get').status_code)")
exec_tool.run(command="python /workspace/fetch.py")
file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id)
file_tool.run(
action="write",
path="workspace/script.py",
content="import httpx; print(f'httpx loaded, version {httpx.__version__}')",
)
exec_tool.run(command="python workspace/script.py")
```
<Note>
Each tool instance maintains its own persistent sandbox. To share **one** sandbox across two tools, create the first tool, grab its sandbox id via `tool._persistent_sandbox.id`, and pass it to the second tool via `sandbox_id=...`.
By default, each tool with `persistent=True` lazily creates its **own** sandbox on first use. The pattern above shares a single sandbox across multiple tools by reading the first tool's `active_sandbox_id` after a `.run()` call and passing it to the others via `sandbox_id=...`. With `persistent=False` (the default), every `.run()` call gets a fresh sandbox that's deleted at the end of that call.
</Note>
### Attach to an existing sandbox
@@ -82,7 +87,7 @@ Each tool instance maintains its own persistent sandbox. To share **one** sandbo
from crewai_tools import DaytonaExecTool
tool = DaytonaExecTool(sandbox_id="my-long-lived-sandbox")
result = tool.run(command="ls /workspace")
result = tool.run(command="ls workspace")
```
### Custom sandbox parameters
@@ -102,6 +107,41 @@ tool = DaytonaExecTool(
)
```
### Searching, moving, and modifying files
```python Code
from crewai_tools import DaytonaFileTool
file_tool = DaytonaFileTool(persistent=True)
# Find every TODO in the source tree (grep file contents recursively)
file_tool.run(action="find", path="workspace/src", pattern="TODO:")
# Find all Python files (glob match on filenames)
file_tool.run(action="search", path="workspace", pattern="*.py")
# Make a script executable
file_tool.run(action="chmod", path="workspace/run.sh", mode="755")
# Rename or move a file
file_tool.run(
action="move",
path="workspace/draft.md",
destination="workspace/final.md",
)
# Bulk find-and-replace across multiple files
file_tool.run(
action="replace",
paths=["workspace/src/a.py", "workspace/src/b.py"],
pattern="old_function",
replacement="new_function",
)
# Quick existence check before a destructive op
file_tool.run(action="exists", path="workspace/cache.db")
```
### Agent integration
```python Code
@@ -121,7 +161,7 @@ coder = Agent(
)
task = Task(
description="Write a Python script that prints the first 10 Fibonacci numbers, save it to /workspace/fib.py, and run it.",
description="Write a Python script that prints the first 10 Fibonacci numbers, save it to workspace/fib.py, and run it.",
expected_output="The first 10 Fibonacci numbers printed to stdout.",
agent=coder,
)
@@ -168,12 +208,22 @@ All three tools accept these parameters at initialization:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`. |
| `path` | `str` | ✓ | Absolute path inside the sandbox. |
| `content` | `str \| None` | | Content to write or append. Required for `append`. |
| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`, `exists`, `move`, `find`, `search`, `chmod`, `replace`. |
| `path` | `str \| None` | ✓ for all actions except `replace` | Absolute path inside the sandbox. |
| `content` | `str \| None` | ✓ for `append` | Content to write or append. |
| `binary` | `bool` | | If `True`, `content` is base64 on write; returns base64 on read. |
| `recursive` | `bool` | | For `delete`: remove directories recursively. |
| `mode` | `str` | | For `mkdir`: octal permission string (default `"0755"`). |
| `mode` | `str \| None` | | For `mkdir`: octal permissions for the new directory (defaults to `"0755"`). For `chmod`: octal permissions to apply to the target. |
| `destination` | `str \| None` | ✓ for `move` | Destination path for `move`. |
| `pattern` | `str \| None` | ✓ for `find`, `search`, `replace` | For `find`: substring matched against file CONTENTS. For `search`: glob matched against file NAMES (e.g. `*.py`). For `replace`: text to replace inside files. |
| `replacement` | `str \| None` | ✓ for `replace` | Replacement text for `pattern`. |
| `paths` | `list[str] \| None` | ✓ for `replace` | List of file paths in which to replace text. |
| `owner` | `str \| None` | | For `chmod`: new file owner. |
| `group` | `str \| None` | | For `chmod`: new file group. |
<Note>
For `chmod`, pass at least one of `mode`, `owner`, or `group` — any field left as `None` is left unchanged on the target.
</Note>
<Tip>
For files larger than a few KB, create the file first with `action="write"` and empty content, then send the body via multiple `action="append"` calls of ~4 KB each to stay within tool-call payload limits.

View File

@@ -13,7 +13,7 @@ The Daytona sandbox tools give CrewAI agents access to isolated, ephemeral compu
- **`DaytonaExecTool`** — run any shell command inside a sandbox.
- **`DaytonaPythonTool`** — execute a block of Python source code inside a sandbox.
- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox.
- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox; also supports `move`, `find` (content grep), `search` (filename glob), `chmod` (permissions), `replace` (bulk find-and-replace), and `exists`.
All three tools share the same sandbox lifecycle controls, so you can mix and match them while keeping state in a single persistent sandbox.
@@ -55,7 +55,7 @@ from crewai_tools import DaytonaPythonTool
tool = DaytonaPythonTool()
result = tool.run(code="print(sum(range(10)))")
print(result)
# {"exit_code": 0, "result": "45\n", "artifacts": None}
# {"exit_code": 0, "result": "45\n", "artifacts": ExecutionArtifacts(stdout="45\n", charts=[])}
```
### Multi-step shell session (persistent)
@@ -63,17 +63,22 @@ print(result)
```python Code
from crewai_tools import DaytonaExecTool, DaytonaFileTool
# Create the persistent sandbox via the first tool, then attach the second
# tool to it so both share state (installed packages, files, env vars).
exec_tool = DaytonaExecTool(persistent=True)
file_tool = DaytonaFileTool(persistent=True)
# Install a package, then write and run a script — all in the same sandbox
exec_tool.run(command="pip install httpx -q")
file_tool.run(action="write", path="/workspace/fetch.py", content="import httpx; print(httpx.get('https://httpbin.org/get').status_code)")
exec_tool.run(command="python /workspace/fetch.py")
file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id)
file_tool.run(
action="write",
path="workspace/script.py",
content="import httpx; print(f'httpx loaded, version {httpx.__version__}')",
)
exec_tool.run(command="python workspace/script.py")
```
<Note>
Each tool instance maintains its own persistent sandbox. To share **one** sandbox across two tools, create the first tool, grab its sandbox id via `tool._persistent_sandbox.id`, and pass it to the second tool via `sandbox_id=...`.
By default, each tool with `persistent=True` lazily creates its **own** sandbox on first use. The pattern above shares a single sandbox across multiple tools by reading the first tool's `active_sandbox_id` after a `.run()` call and passing it to the others via `sandbox_id=...`. With `persistent=False` (the default), every `.run()` call gets a fresh sandbox that's deleted at the end of that call.
</Note>
### Attach to an existing sandbox
@@ -82,7 +87,7 @@ Each tool instance maintains its own persistent sandbox. To share **one** sandbo
from crewai_tools import DaytonaExecTool
tool = DaytonaExecTool(sandbox_id="my-long-lived-sandbox")
result = tool.run(command="ls /workspace")
result = tool.run(command="ls workspace")
```
### Custom sandbox parameters
@@ -102,6 +107,41 @@ tool = DaytonaExecTool(
)
```
### Searching, moving, and modifying files
```python Code
from crewai_tools import DaytonaFileTool
file_tool = DaytonaFileTool(persistent=True)
# Find every TODO in the source tree (grep file contents recursively)
file_tool.run(action="find", path="workspace/src", pattern="TODO:")
# Find all Python files (glob match on filenames)
file_tool.run(action="search", path="workspace", pattern="*.py")
# Make a script executable
file_tool.run(action="chmod", path="workspace/run.sh", mode="755")
# Rename or move a file
file_tool.run(
action="move",
path="workspace/draft.md",
destination="workspace/final.md",
)
# Bulk find-and-replace across multiple files
file_tool.run(
action="replace",
paths=["workspace/src/a.py", "workspace/src/b.py"],
pattern="old_function",
replacement="new_function",
)
# Quick existence check before a destructive op
file_tool.run(action="exists", path="workspace/cache.db")
```
### Agent integration
```python Code
@@ -121,7 +161,7 @@ coder = Agent(
)
task = Task(
description="Write a Python script that prints the first 10 Fibonacci numbers, save it to /workspace/fib.py, and run it.",
description="Write a Python script that prints the first 10 Fibonacci numbers, save it to workspace/fib.py, and run it.",
expected_output="The first 10 Fibonacci numbers printed to stdout.",
agent=coder,
)
@@ -168,12 +208,22 @@ All three tools accept these parameters at initialization:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`. |
| `path` | `str` | ✓ | Absolute path inside the sandbox. |
| `content` | `str \| None` | | Content to write or append. Required for `append`. |
| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`, `exists`, `move`, `find`, `search`, `chmod`, `replace`. |
| `path` | `str \| None` | ✓ for all actions except `replace` | Absolute path inside the sandbox. |
| `content` | `str \| None` | ✓ for `append` | Content to write or append. |
| `binary` | `bool` | | If `True`, `content` is base64 on write; returns base64 on read. |
| `recursive` | `bool` | | For `delete`: remove directories recursively. |
| `mode` | `str` | | For `mkdir`: octal permission string (default `"0755"`). |
| `mode` | `str \| None` | | For `mkdir`: octal permissions for the new directory (defaults to `"0755"`). For `chmod`: octal permissions to apply to the target. |
| `destination` | `str \| None` | ✓ for `move` | Destination path for `move`. |
| `pattern` | `str \| None` | ✓ for `find`, `search`, `replace` | For `find`: substring matched against file CONTENTS. For `search`: glob matched against file NAMES (e.g. `*.py`). For `replace`: text to replace inside files. |
| `replacement` | `str \| None` | ✓ for `replace` | Replacement text for `pattern`. |
| `paths` | `list[str] \| None` | ✓ for `replace` | List of file paths in which to replace text. |
| `owner` | `str \| None` | | For `chmod`: new file owner. |
| `group` | `str \| None` | | For `chmod`: new file group. |
<Note>
For `chmod`, pass at least one of `mode`, `owner`, or `group` — any field left as `None` is left unchanged on the target.
</Note>
<Tip>
For files larger than a few KB, create the file first with `action="write"` and empty content, then send the body via multiple `action="append"` calls of ~4 KB each to stay within tool-call payload limits.

View File

@@ -13,7 +13,7 @@ The Daytona sandbox tools give CrewAI agents access to isolated, ephemeral compu
- **`DaytonaExecTool`** — run any shell command inside a sandbox.
- **`DaytonaPythonTool`** — execute a block of Python source code inside a sandbox.
- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox.
- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox; also supports `move`, `find` (content grep), `search` (filename glob), `chmod` (permissions), `replace` (bulk find-and-replace), and `exists`.
All three tools share the same sandbox lifecycle controls, so you can mix and match them while keeping state in a single persistent sandbox.
@@ -55,7 +55,7 @@ from crewai_tools import DaytonaPythonTool
tool = DaytonaPythonTool()
result = tool.run(code="print(sum(range(10)))")
print(result)
# {"exit_code": 0, "result": "45\n", "artifacts": None}
# {"exit_code": 0, "result": "45\n", "artifacts": ExecutionArtifacts(stdout="45\n", charts=[])}
```
### Multi-step shell session (persistent)
@@ -63,17 +63,22 @@ print(result)
```python Code
from crewai_tools import DaytonaExecTool, DaytonaFileTool
# Create the persistent sandbox via the first tool, then attach the second
# tool to it so both share state (installed packages, files, env vars).
exec_tool = DaytonaExecTool(persistent=True)
file_tool = DaytonaFileTool(persistent=True)
# Install a package, then write and run a script — all in the same sandbox
exec_tool.run(command="pip install httpx -q")
file_tool.run(action="write", path="/workspace/fetch.py", content="import httpx; print(httpx.get('https://httpbin.org/get').status_code)")
exec_tool.run(command="python /workspace/fetch.py")
file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id)
file_tool.run(
action="write",
path="workspace/script.py",
content="import httpx; print(f'httpx loaded, version {httpx.__version__}')",
)
exec_tool.run(command="python workspace/script.py")
```
<Note>
Each tool instance maintains its own persistent sandbox. To share **one** sandbox across two tools, create the first tool, grab its sandbox id via `tool._persistent_sandbox.id`, and pass it to the second tool via `sandbox_id=...`.
By default, each tool with `persistent=True` lazily creates its **own** sandbox on first use. The pattern above shares a single sandbox across multiple tools by reading the first tool's `active_sandbox_id` after a `.run()` call and passing it to the others via `sandbox_id=...`. With `persistent=False` (the default), every `.run()` call gets a fresh sandbox that's deleted at the end of that call.
</Note>
### Attach to an existing sandbox
@@ -82,7 +87,7 @@ Each tool instance maintains its own persistent sandbox. To share **one** sandbo
from crewai_tools import DaytonaExecTool
tool = DaytonaExecTool(sandbox_id="my-long-lived-sandbox")
result = tool.run(command="ls /workspace")
result = tool.run(command="ls workspace")
```
### Custom sandbox parameters
@@ -102,6 +107,41 @@ tool = DaytonaExecTool(
)
```
### Searching, moving, and modifying files
```python Code
from crewai_tools import DaytonaFileTool
file_tool = DaytonaFileTool(persistent=True)
# Find every TODO in the source tree (grep file contents recursively)
file_tool.run(action="find", path="workspace/src", pattern="TODO:")
# Find all Python files (glob match on filenames)
file_tool.run(action="search", path="workspace", pattern="*.py")
# Make a script executable
file_tool.run(action="chmod", path="workspace/run.sh", mode="755")
# Rename or move a file
file_tool.run(
action="move",
path="workspace/draft.md",
destination="workspace/final.md",
)
# Bulk find-and-replace across multiple files
file_tool.run(
action="replace",
paths=["workspace/src/a.py", "workspace/src/b.py"],
pattern="old_function",
replacement="new_function",
)
# Quick existence check before a destructive op
file_tool.run(action="exists", path="workspace/cache.db")
```
### Agent integration
```python Code
@@ -121,7 +161,7 @@ coder = Agent(
)
task = Task(
description="Write a Python script that prints the first 10 Fibonacci numbers, save it to /workspace/fib.py, and run it.",
description="Write a Python script that prints the first 10 Fibonacci numbers, save it to workspace/fib.py, and run it.",
expected_output="The first 10 Fibonacci numbers printed to stdout.",
agent=coder,
)
@@ -168,12 +208,22 @@ All three tools accept these parameters at initialization:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`. |
| `path` | `str` | ✓ | Absolute path inside the sandbox. |
| `content` | `str \| None` | | Content to write or append. Required for `append`. |
| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`, `exists`, `move`, `find`, `search`, `chmod`, `replace`. |
| `path` | `str \| None` | ✓ for all actions except `replace` | Absolute path inside the sandbox. |
| `content` | `str \| None` | ✓ for `append` | Content to write or append. |
| `binary` | `bool` | | If `True`, `content` is base64 on write; returns base64 on read. |
| `recursive` | `bool` | | For `delete`: remove directories recursively. |
| `mode` | `str` | | For `mkdir`: octal permission string (default `"0755"`). |
| `mode` | `str \| None` | | For `mkdir`: octal permissions for the new directory (defaults to `"0755"`). For `chmod`: octal permissions to apply to the target. |
| `destination` | `str \| None` | ✓ for `move` | Destination path for `move`. |
| `pattern` | `str \| None` | ✓ for `find`, `search`, `replace` | For `find`: substring matched against file CONTENTS. For `search`: glob matched against file NAMES (e.g. `*.py`). For `replace`: text to replace inside files. |
| `replacement` | `str \| None` | ✓ for `replace` | Replacement text for `pattern`. |
| `paths` | `list[str] \| None` | ✓ for `replace` | List of file paths in which to replace text. |
| `owner` | `str \| None` | | For `chmod`: new file owner. |
| `group` | `str \| None` | | For `chmod`: new file group. |
<Note>
For `chmod`, pass at least one of `mode`, `owner`, or `group` — any field left as `None` is left unchanged on the target.
</Note>
<Tip>
For files larger than a few KB, create the file first with `action="write"` and empty content, then send the body via multiple `action="append"` calls of ~4 KB each to stay within tool-call payload limits.

View File

@@ -55,10 +55,11 @@ from crewai_tools import DaytonaExecTool, DaytonaFileTool
exec_tool = DaytonaExecTool(persistent=True)
file_tool = DaytonaFileTool(persistent=True)
# Agent writes a script, then runs it — both share the same sandbox instance
# because they each keep their own persistent sandbox. If you need the *same*
# sandbox across two tools, create one tool, grab the sandbox id via
# `tool._persistent_sandbox.id`, and pass it to the other via `sandbox_id=...`.
# Agent writes a script, then runs it — but each tool keeps its OWN persistent
# sandbox. To share the *same* sandbox across two tools, create and use the
# first tool, then read its `active_sandbox_id` and pass it to the second:
# exec_tool.run(command="pip install httpx")
# file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id)
```
### Attach to an existing sandbox
@@ -99,9 +100,14 @@ tool = DaytonaExecTool(
- `timeout: int | None` — seconds.
### `DaytonaFileTool`
- `action: "read" | "write" | "list" | "delete" | "mkdir" | "info"`
- `path: str` — absolute path inside the sandbox.
- `content: str | None` — required for `write`.
- `action`: one of `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`, `exists`, `move`, `find`, `search`, `chmod`, `replace`.
- `path: str | None` — absolute path inside the sandbox. Required for all actions except `replace`.
- `content: str | None` — required for `append`; optional for `write`.
- `binary: bool` — if `True`, `content` is base64 on write / returned as base64 on read.
- `recursive: bool` — for `delete`, removes directories recursively.
- `mode: str` — for `mkdir`, octal permission string (default `"0755"`).
- `mode: str | None` — for `mkdir` (defaults to `"0755"`) or for `chmod` (e.g. `"755"`).
- `destination: str | None` — required for `move`.
- `pattern: str | None` — required for `find` (content grep), `search` (filename glob), and `replace`.
- `replacement: str | None` — required for `replace`.
- `paths: list[str] | None` — required for `replace`; list of files to operate on.
- `owner: str | None` / `group: str | None` — for `chmod`. Pass at least one of `mode`, `owner`, or `group`.

View File

@@ -196,3 +196,27 @@ class DaytonaBaseTool(BaseTool):
"the sandbox may need manual deletion.",
exc_info=True,
)
@property
def active_sandbox_id(self) -> str | None:
"""The id of the sandbox this tool is currently bound to, if any.
Returns:
- the explicitly attached `sandbox_id`, if set at construction;
- the id of the lazily-created persistent sandbox, once a call has
triggered creation;
- None for ephemeral mode (where no sandbox lives between calls).
Use this to share one sandbox across multiple tool instances:
exec_tool = DaytonaExecTool(persistent=True)
exec_tool.run(command="pip install httpx")
file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id)
"""
if self.sandbox_id:
return self.sandbox_id
with self._lock:
sandbox = self._persistent_sandbox
if sandbox is None:
return None
return getattr(sandbox, "id", None)

View File

@@ -4,6 +4,8 @@ import base64
from builtins import type as type_
import logging
import posixpath
import shlex
import uuid
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
@@ -14,22 +16,54 @@ from crewai_tools.tools.daytona_sandbox_tool.daytona_base_tool import DaytonaBas
logger = logging.getLogger(__name__)
FileAction = Literal["read", "write", "append", "list", "delete", "mkdir", "info"]
FileAction = Literal[
"read",
"write",
"append",
"list",
"delete",
"mkdir",
"info",
"exists",
"move",
"find",
"search",
"chmod",
"replace",
]
class DaytonaFileToolSchema(BaseModel):
action: FileAction = Field(
...,
description=(
"The filesystem action to perform: 'read' (returns file contents), "
"'write' (create or replace a file with content), 'append' (append "
"content to an existing file — use this for writing large files in "
"chunks to avoid hitting tool-call size limits), 'list' (lists a "
"directory), 'delete' (removes a file/dir), 'mkdir' (creates a "
"directory), 'info' (returns file metadata)."
"The filesystem action to perform: "
"'read' (returns file contents); "
"'write' (create or replace a file with content); "
"'append' (append content to an existing file — use this for "
"writing large files in chunks to avoid hitting tool-call size "
"limits); "
"'list' (lists a directory); "
"'delete' (removes a file/dir); "
"'mkdir' (creates a directory); "
"'info' (returns file metadata); "
"'exists' (returns whether a path exists); "
"'move' (rename or relocate a file/dir; requires 'destination'); "
"'find' (grep file CONTENTS recursively; requires 'pattern'); "
"'search' (find files by NAME pattern; requires 'pattern'); "
"'chmod' (change permissions/owner/group; pass at least one of "
"'mode', 'owner', 'group'); "
"'replace' (find-and-replace text across files; requires "
"'paths', 'pattern', and 'replacement')."
),
)
path: str | None = Field(
default=None,
description=(
"Absolute path inside the sandbox. Required for all actions "
"except 'replace' (which uses 'paths' instead)."
),
)
path: str = Field(..., description="Absolute path inside the sandbox.")
content: str | None = Field(
default=None,
description=(
@@ -50,18 +84,78 @@ class DaytonaFileToolSchema(BaseModel):
default=False,
description="For action='delete': remove directories recursively.",
)
mode: str = Field(
default="0755",
description="For action='mkdir': octal permission string (default 0755).",
mode: str | None = Field(
default=None,
description=(
"Octal permission string. For 'mkdir' it sets the new directory "
"permissions (defaults to '0755' if omitted). For 'chmod' it sets "
"the target's mode (e.g. '755' to make a script executable). "
"Ignored for other actions."
),
)
destination: str | None = Field(
default=None,
description="For action='move': absolute destination path.",
)
pattern: str | None = Field(
default=None,
description=(
"For 'find': substring matched against file CONTENTS. "
"For 'search': glob-style pattern matched against file NAMES "
"(e.g. '*.py'). "
"For 'replace': text to replace inside files."
),
)
replacement: str | None = Field(
default=None,
description="For action='replace': replacement text for 'pattern'.",
)
paths: list[str] | None = Field(
default=None,
description=(
"For action='replace': list of absolute file paths in which to "
"replace 'pattern' with 'replacement'."
),
)
owner: str | None = Field(
default=None,
description="For action='chmod': new file owner (user name).",
)
group: str | None = Field(
default=None,
description="For action='chmod': new file group.",
)
@model_validator(mode="after")
def _validate_action_args(self) -> DaytonaFileToolSchema:
if self.action != "replace" and not self.path:
raise ValueError(f"action={self.action!r} requires 'path'.")
if self.action == "append" and self.content is None:
raise ValueError(
"action='append' requires 'content'. Pass the chunk to append "
"in the 'content' field."
)
if self.action == "move" and not self.destination:
raise ValueError("action='move' requires 'destination'.")
if self.action == "find" and not self.pattern:
raise ValueError(
"action='find' requires 'pattern' (text to search for inside files)."
)
if self.action == "search" and not self.pattern:
raise ValueError("action='search' requires 'pattern' (glob, e.g. '*.py').")
if self.action == "chmod" and not (self.mode or self.owner or self.group):
raise ValueError(
"action='chmod' requires at least one of 'mode', 'owner', or 'group'."
)
if self.action == "replace":
if not self.paths:
raise ValueError(
"action='replace' requires 'paths' (list of file paths)."
)
if not self.pattern:
raise ValueError("action='replace' requires 'pattern'.")
if self.replacement is None:
raise ValueError("action='replace' requires 'replacement'.")
return self
@@ -75,9 +169,10 @@ class DaytonaFileTool(DaytonaBaseTool):
name: str = "Daytona Sandbox Files"
description: str = (
"Perform filesystem operations inside a Daytona sandbox: read a file, "
"write content to a path, append content to an existing file, list a "
"directory, delete a path, make a directory, or fetch file metadata. "
"Perform filesystem operations inside a Daytona sandbox: read, "
"write, append, list, delete, mkdir, info, exists, move, find "
"(content grep), search (filename glob), chmod (permissions/owner/"
"group), and replace (bulk find-and-replace across files). "
"For files larger than a few KB, create the file with action='write' "
"and empty content, then send the body via multiple 'append' calls of "
"~4KB each to stay within tool-call payload limits."
@@ -87,30 +182,79 @@ class DaytonaFileTool(DaytonaBaseTool):
def _run(
self,
action: FileAction,
path: str,
path: str | None = None,
content: str | None = None,
binary: bool = False,
recursive: bool = False,
mode: str = "0755",
mode: str | None = None,
destination: str | None = None,
pattern: str | None = None,
replacement: str | None = None,
paths: list[str] | None = None,
owner: str | None = None,
group: str | None = None,
) -> Any:
sandbox, should_delete = self._acquire_sandbox()
try:
if action == "read":
if path is None:
raise ValueError("action='read' requires 'path'")
return self._read(sandbox, path, binary=binary)
if action == "write":
if path is None:
raise ValueError("action='write' requires 'path'")
return self._write(sandbox, path, content or "", binary=binary)
if action == "append":
if path is None:
raise ValueError("action='append' requires 'path'")
return self._append(sandbox, path, content or "", binary=binary)
if action == "list":
if path is None:
raise ValueError("action='list' requires 'path'")
return self._list(sandbox, path)
if action == "delete":
if path is None:
raise ValueError("action='delete' requires 'path'")
sandbox.fs.delete_file(path, recursive=recursive)
return {"status": "deleted", "path": path}
if action == "mkdir":
sandbox.fs.create_folder(path, mode)
return {"status": "created", "path": path, "mode": mode}
if path is None:
raise ValueError("action='mkdir' requires 'path'")
mkdir_mode = mode or "0755"
sandbox.fs.create_folder(path, mkdir_mode)
return {"status": "created", "path": path, "mode": mkdir_mode}
if action == "info":
if path is None:
raise ValueError("action='info' requires 'path'")
return self._info(sandbox, path)
if action == "exists":
if path is None:
raise ValueError("action='exists' requires 'path'")
return self._exists(sandbox, path)
if action == "move":
if path is None or destination is None:
raise ValueError("action='move' requires 'path' and 'destination'")
sandbox.fs.move_files(path, destination)
return {"status": "moved", "from": path, "to": destination}
if action == "find":
if path is None or pattern is None:
raise ValueError("action='find' requires 'path' and 'pattern'")
return self._find(sandbox, path, pattern)
if action == "search":
if path is None or pattern is None:
raise ValueError("action='search' requires 'path' and 'pattern'")
return self._search(sandbox, path, pattern)
if action == "chmod":
if path is None:
raise ValueError("action='chmod' requires 'path'")
return self._chmod(sandbox, path, mode=mode, owner=owner, group=group)
if action == "replace":
if paths is None or pattern is None or replacement is None:
raise ValueError(
"action='replace' requires 'paths', 'pattern', and "
"'replacement'"
)
return self._replace(sandbox, paths, pattern, replacement)
raise ValueError(f"Unknown action: {action}")
finally:
self._release_sandbox(sandbox, should_delete)
@@ -146,17 +290,46 @@ class DaytonaFileTool(DaytonaBaseTool):
) -> dict[str, Any]:
chunk = base64.b64decode(content) if binary else content.encode("utf-8")
self._ensure_parent_dir(sandbox, path)
# Server-side `cat >>` keeps this O(chunk_size) per call. The naive
# download-concat-reupload alternative is O(N^2) in total transfer.
# /tmp/ is on the sandbox's ephemeral filesystem, not the host.
temp_path = f"/tmp/.crewai-append-{uuid.uuid4().hex}" # noqa: S108
sandbox.fs.upload_file(chunk, temp_path)
quoted_temp = shlex.quote(temp_path)
quoted_target = shlex.quote(path)
response = sandbox.process.exec(
f"cat {quoted_temp} >> {quoted_target}; "
f"rc=$?; rm -f {quoted_temp}; exit $rc"
)
exit_code = getattr(response, "exit_code", 0)
if exit_code != 0:
try:
sandbox.fs.delete_file(temp_path)
except Exception:
logger.debug(
"Best-effort temp-file cleanup failed after append "
"error; the file may need manual deletion.",
exc_info=True,
)
raise RuntimeError(
f"append failed: exit_code={exit_code}, "
f"output={getattr(response, 'result', '')!r}"
)
try:
existing: bytes = sandbox.fs.download_file(path)
info = sandbox.fs.get_file_info(path)
total_bytes = getattr(info, "size", None)
except Exception:
existing = b""
payload = existing + chunk
sandbox.fs.upload_file(payload, path)
total_bytes = None
return {
"status": "appended",
"path": path,
"appended_bytes": len(chunk),
"total_bytes": len(payload),
"total_bytes": total_bytes,
}
@staticmethod
@@ -190,6 +363,77 @@ class DaytonaFileTool(DaytonaBaseTool):
def _info(self, sandbox: Any, path: str) -> dict[str, Any]:
return self._file_info_to_dict(sandbox.fs.get_file_info(path))
def _exists(self, sandbox: Any, path: str) -> dict[str, Any]:
try:
info = sandbox.fs.get_file_info(path)
except Exception:
return {"path": path, "exists": False}
return {
"path": path,
"exists": True,
"is_dir": getattr(info, "is_dir", False),
}
def _find(self, sandbox: Any, path: str, pattern: str) -> dict[str, Any]:
matches = sandbox.fs.find_files(path, pattern)
return {
"path": path,
"pattern": pattern,
"matches": [
{
"file": getattr(m, "file", None),
"line": getattr(m, "line", None),
"content": getattr(m, "content", None),
}
for m in matches
],
}
def _search(self, sandbox: Any, path: str, pattern: str) -> dict[str, Any]:
response = sandbox.fs.search_files(path, pattern)
files = getattr(response, "files", None) or []
return {"path": path, "pattern": pattern, "files": list(files)}
def _chmod(
self,
sandbox: Any,
path: str,
*,
mode: str | None,
owner: str | None,
group: str | None,
) -> dict[str, Any]:
kwargs: dict[str, str] = {}
if mode is not None:
kwargs["mode"] = mode
if owner is not None:
kwargs["owner"] = owner
if group is not None:
kwargs["group"] = group
sandbox.fs.set_file_permissions(path, **kwargs)
return {"status": "permissions_set", "path": path, **kwargs}
def _replace(
self,
sandbox: Any,
paths: list[str],
pattern: str,
replacement: str,
) -> dict[str, Any]:
results = sandbox.fs.replace_in_files(paths, pattern, replacement)
return {
"pattern": pattern,
"replacement": replacement,
"results": [
{
"file": getattr(r, "file", None),
"success": getattr(r, "success", None),
"error": getattr(r, "error", None),
}
for r in (results or [])
],
}
@staticmethod
def _file_info_to_dict(info: Any) -> dict[str, Any]:
fields = (