Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07d104f831 |
@@ -615,6 +615,22 @@
|
||||
`ToolCallRouter`, `ToolCallingRuntime`, and `PlanExecutionContext` so
|
||||
the resolver receives project-level execution environment values stored
|
||||
in `ContextConfig.execution_environment`. (#1080)
|
||||
- Added three missing built-in resource tools from the specification:
|
||||
`builtin/file-move` (move/rename files within sandbox boundaries),
|
||||
`builtin/dir-create` (create directories with optional parent creation),
|
||||
and `builtin/file-info` (retrieve comprehensive file metadata including
|
||||
encoding detection). All tools follow the existing ToolSpec pattern with
|
||||
proper JSON Schema definitions, capability metadata, and sandbox root
|
||||
validation. Updated `ChangeSetCapture` to correctly track move operations
|
||||
with source/destination paths and detect the `"move"` operation type.
|
||||
Review fixes applied: `_file_hash` now skips directories (prevents
|
||||
`IsADirectoryError` when `dir-create` is changeset-wrapped); `file-move`
|
||||
rejects directory destinations and non-existent sources with clear error
|
||||
messages; `dir-create` rejects paths that are existing regular files;
|
||||
`file-info` uses `st_birthtime` with `st_ctime` fallback, returns
|
||||
permission-only bits, and validates UTF-8 before reporting encoding;
|
||||
changeset move detection uses tool name instead of input field names.
|
||||
(#883)
|
||||
- Added BuiltinAdapter class and MCP automatic resource slot creation.
|
||||
BuiltinAdapter wraps register_file_tools/register_git_tools/register_subplan_tool
|
||||
into a unified adapter interface. McpAdapter.infer_resource_slots() analyzes
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""ASV benchmarks for new built-in file tools (move, dir-create, file-info).
|
||||
|
||||
Measures the performance of:
|
||||
- File move operations
|
||||
- Directory creation operations
|
||||
- File info retrieval operations
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cleveragents.tool.builtins import register_file_tools
|
||||
from cleveragents.tool.registry import ToolRegistry
|
||||
from cleveragents.tool.runner import ToolRunner
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
from cleveragents.tool.builtins import register_file_tools
|
||||
from cleveragents.tool.registry import ToolRegistry
|
||||
from cleveragents.tool.runner import ToolRunner
|
||||
|
||||
|
||||
class FileMoveSuite:
|
||||
"""Benchmark built-in file move operations."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.sandbox = tempfile.mkdtemp()
|
||||
self.registry = ToolRegistry()
|
||||
register_file_tools(self.registry)
|
||||
self.runner = ToolRunner(self.registry)
|
||||
self._counter = 0
|
||||
# Pre-create a pool of source files so file creation I/O is not
|
||||
# included in benchmark timing.
|
||||
self._pool_size = 500
|
||||
for i in range(self._pool_size):
|
||||
(Path(self.sandbox) / f"move_src_{i}.txt").write_text("benchmark data")
|
||||
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(self.sandbox, ignore_errors=True)
|
||||
|
||||
def time_file_move(self) -> None:
|
||||
idx = self._counter % self._pool_size
|
||||
src = f"move_src_{idx}.txt"
|
||||
dst = f"move_dst_{self._counter}.txt"
|
||||
self._counter += 1
|
||||
# Recreate the source if it was consumed by a previous iteration.
|
||||
src_path = Path(self.sandbox) / src
|
||||
if not src_path.exists():
|
||||
src_path.write_text("benchmark data")
|
||||
self.runner.execute(
|
||||
"builtin/file-move",
|
||||
{
|
||||
"source_path": src,
|
||||
"destination_path": dst,
|
||||
"sandbox_root": self.sandbox,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class DirCreateSuite:
|
||||
"""Benchmark built-in directory creation operations."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.sandbox = tempfile.mkdtemp()
|
||||
self.registry = ToolRegistry()
|
||||
register_file_tools(self.registry)
|
||||
self.runner = ToolRunner(self.registry)
|
||||
self._counter = 0
|
||||
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(self.sandbox, ignore_errors=True)
|
||||
|
||||
def time_dir_create(self) -> None:
|
||||
path = f"bench_dir_{self._counter}/sub/deep"
|
||||
self._counter += 1
|
||||
self.runner.execute(
|
||||
"builtin/dir-create",
|
||||
{"path": path, "sandbox_root": self.sandbox},
|
||||
)
|
||||
|
||||
|
||||
class FileInfoSuite:
|
||||
"""Benchmark built-in file info operations."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.sandbox = tempfile.mkdtemp()
|
||||
self.registry = ToolRegistry()
|
||||
register_file_tools(self.registry)
|
||||
self.runner = ToolRunner(self.registry)
|
||||
(Path(self.sandbox) / "info_test.txt").write_text("benchmark metadata\n" * 50)
|
||||
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(self.sandbox, ignore_errors=True)
|
||||
|
||||
def time_file_info(self) -> None:
|
||||
self.runner.execute(
|
||||
"builtin/file-info",
|
||||
{"path": "info_test.txt", "sandbox_root": self.sandbox},
|
||||
)
|
||||
|
||||
def time_dir_info(self) -> None:
|
||||
self.runner.execute(
|
||||
"builtin/file-info",
|
||||
{"path": ".", "sandbox_root": self.sandbox},
|
||||
)
|
||||
@@ -14,13 +14,13 @@ Feature: BuiltinAdapter and MCP resource slot inference
|
||||
And the discovered tools should include all file tools
|
||||
And the discovered tools should include all git tools
|
||||
And the discovered tools should include all subplan tools
|
||||
And the total discovered tool count should be 11
|
||||
And the total discovered tool count should be 14
|
||||
|
||||
Scenario: BuiltinAdapter.register registers all tools in a ToolRegistry
|
||||
Given a builtin adapter
|
||||
And a tool registry
|
||||
When I call register on the builtin adapter
|
||||
Then the registry should contain 11 tools
|
||||
Then the registry should contain 14 tools
|
||||
And the registry should contain tool "builtin/file-read"
|
||||
And the registry should contain tool "builtin/git-status"
|
||||
And the registry should contain tool "builtin/plan-subplan"
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
Feature: New Built-in File Tools (move, dir-create, file-info)
|
||||
As a developer
|
||||
I want additional built-in file operation tools
|
||||
So that agents can move files, create directories, and inspect file metadata safely
|
||||
|
||||
# ---- File Move ----
|
||||
|
||||
Scenario: Move a file successfully
|
||||
Given a temporary sandbox directory
|
||||
And a file "source.txt" with content "move me"
|
||||
When I execute the "builtin/file-move" tool moving "source.txt" to "dest.txt"
|
||||
Then the tool result should be successful
|
||||
And the output "moved" should be boolean true
|
||||
And the file "dest.txt" should exist in the sandbox
|
||||
And the file "source.txt" should not exist in the sandbox
|
||||
|
||||
Scenario: Move a file with destination path traversal is rejected
|
||||
Given a temporary sandbox directory
|
||||
And a file "innocent.txt" with content "data"
|
||||
When I execute the "builtin/file-move" tool moving "innocent.txt" to "../../escaped.txt"
|
||||
Then the tool result should not be successful
|
||||
And the tool result error should mention "traversal"
|
||||
|
||||
Scenario: Move a file with source path traversal is rejected
|
||||
Given a temporary sandbox directory
|
||||
When I execute the "builtin/file-move" tool moving "../../etc/passwd" to "stolen.txt"
|
||||
Then the tool result should not be successful
|
||||
And the tool result error should mention "traversal"
|
||||
|
||||
Scenario: Move a file overwrites existing destination
|
||||
Given a temporary sandbox directory
|
||||
And a file "src.txt" with content "new content"
|
||||
And a file "dst.txt" with content "old content"
|
||||
When I execute the "builtin/file-move" tool moving "src.txt" to "dst.txt"
|
||||
Then the tool result should be successful
|
||||
And the file "dst.txt" should exist in the sandbox
|
||||
And the file "src.txt" should not exist in the sandbox
|
||||
And the file "dst.txt" in the sandbox should have exact content "new content"
|
||||
|
||||
Scenario: Move a directory is rejected
|
||||
Given a temporary sandbox directory
|
||||
And a subdirectory "mydir" in the sandbox
|
||||
When I execute the "builtin/file-move" tool moving "mydir" to "renamed"
|
||||
Then the tool result should not be successful
|
||||
And the tool result error should mention "not a file"
|
||||
|
||||
Scenario: Move a non-existent source file fails
|
||||
Given a temporary sandbox directory
|
||||
When I execute the "builtin/file-move" tool moving "ghost.txt" to "dest.txt"
|
||||
Then the tool result should not be successful
|
||||
And the tool result error should mention "does not exist"
|
||||
|
||||
Scenario: Move a file when destination is an existing directory is rejected
|
||||
Given a temporary sandbox directory
|
||||
And a file "src.txt" with content "data"
|
||||
And a subdirectory "target_dir" in the sandbox
|
||||
When I execute the "builtin/file-move" tool moving "src.txt" to "target_dir"
|
||||
Then the tool result should not be successful
|
||||
And the tool result error should mention "existing directory"
|
||||
|
||||
Scenario: Move a file creating destination parent directories
|
||||
Given a temporary sandbox directory
|
||||
And a file "deep_src.txt" with content "nested move"
|
||||
When I execute the "builtin/file-move" tool moving "deep_src.txt" to "a/b/deep_dst.txt"
|
||||
Then the tool result should be successful
|
||||
And the file "a/b/deep_dst.txt" should exist in the sandbox
|
||||
And the file "deep_src.txt" should not exist in the sandbox
|
||||
|
||||
# ---- File Move with ChangeSet ----
|
||||
|
||||
Scenario: File move through changeset-wrapped tools records correct entry
|
||||
Given a temporary sandbox directory
|
||||
And a file "cs_src.txt" with content "changeset move"
|
||||
And a changeset capture with plan_id "plan-move"
|
||||
When I move "cs_src.txt" to "cs_dst.txt" through changeset-wrapped tools
|
||||
Then the changeset should have 1 entry
|
||||
And the changeset entry operation should be "move"
|
||||
And the changeset entry metadata should include a "source_path" key
|
||||
|
||||
# ---- Directory Create with ChangeSet ----
|
||||
|
||||
Scenario: Dir create through changeset-wrapped tools records correct entry
|
||||
Given a temporary sandbox directory
|
||||
And a changeset capture with plan_id "plan-dircreate"
|
||||
When I create directory "cs_newdir" through changeset-wrapped tools
|
||||
Then the changeset should have 1 entry
|
||||
And the changeset entry operation should be "create"
|
||||
|
||||
# ---- Directory Create ----
|
||||
|
||||
Scenario: Create a directory with parents
|
||||
Given a temporary sandbox directory
|
||||
When I execute the "builtin/dir-create" tool with path "a/b/c"
|
||||
Then the tool result should be successful
|
||||
And the output "created" should be boolean true
|
||||
And the directory "a/b/c" should exist in the sandbox
|
||||
|
||||
Scenario: Create a directory without parents when parent missing fails
|
||||
Given a temporary sandbox directory
|
||||
When I execute the "builtin/dir-create" tool with path "x/y/z" without parents
|
||||
Then the tool result should not be successful
|
||||
And the tool result error should mention "FileNotFoundError"
|
||||
|
||||
Scenario: Create a directory when path is an existing regular file fails
|
||||
Given a temporary sandbox directory
|
||||
And a file "blockfile.txt" with content "blocker"
|
||||
When I execute the "builtin/dir-create" tool with path "blockfile.txt"
|
||||
Then the tool result should not be successful
|
||||
And the tool result error should mention "not a directory"
|
||||
|
||||
Scenario: Create a directory that already exists returns created false
|
||||
Given a temporary sandbox directory
|
||||
And a subdirectory "existing" in the sandbox
|
||||
When I execute the "builtin/dir-create" tool with path "existing"
|
||||
Then the tool result should be successful
|
||||
And the output "created" should be boolean false
|
||||
|
||||
Scenario: Create a directory with path traversal is rejected
|
||||
Given a temporary sandbox directory
|
||||
When I execute the "builtin/dir-create" tool with path "../../escaped_dir"
|
||||
Then the tool result should not be successful
|
||||
And the tool result error should mention "traversal"
|
||||
|
||||
# ---- File Info ----
|
||||
|
||||
Scenario: Get file info for a regular file
|
||||
Given a temporary sandbox directory
|
||||
And a file "info.txt" with content "metadata test"
|
||||
When I execute the "builtin/file-info" tool with path "info.txt"
|
||||
Then the tool result should be successful
|
||||
And the output "is_file" should be boolean true
|
||||
And the output "is_dir" should be boolean false
|
||||
And the output "size" should be greater than 0
|
||||
And the output should contain a valid "modified" ISO timestamp
|
||||
And the output should contain a valid "created" ISO timestamp
|
||||
And the output should contain a valid "accessed" ISO timestamp
|
||||
And the output "encoding" should equal "utf-8"
|
||||
|
||||
Scenario: Get file info for a directory
|
||||
Given a temporary sandbox directory
|
||||
And a subdirectory "mydir" in the sandbox
|
||||
When I execute the "builtin/file-info" tool with path "mydir"
|
||||
Then the tool result should be successful
|
||||
And the output "is_dir" should be boolean true
|
||||
And the output "is_file" should be boolean false
|
||||
And the output "encoding" should be null
|
||||
|
||||
Scenario: Get file info for non-existent path fails
|
||||
Given a temporary sandbox directory
|
||||
When I execute the "builtin/file-info" tool with path "ghost.txt"
|
||||
Then the tool result should not be successful
|
||||
|
||||
Scenario: Get file info with path traversal is rejected
|
||||
Given a temporary sandbox directory
|
||||
When I execute the "builtin/file-info" tool with path "../../etc/passwd"
|
||||
Then the tool result should not be successful
|
||||
And the tool result error should mention "traversal"
|
||||
|
||||
Scenario: Get file info for a binary file returns null encoding
|
||||
Given a temporary sandbox directory
|
||||
And a binary file "data.bin" with null bytes in the sandbox
|
||||
When I execute the "builtin/file-info" tool with path "data.bin"
|
||||
Then the tool result should be successful
|
||||
And the output "is_file" should be boolean true
|
||||
And the output "encoding" should be null
|
||||
|
||||
Scenario: Get file info permissions use standard octal format
|
||||
Given a temporary sandbox directory
|
||||
And a file "perms.txt" with content "check permissions"
|
||||
When I execute the "builtin/file-info" tool with path "perms.txt"
|
||||
Then the tool result should be successful
|
||||
And the output "permissions" should not contain "0o100"
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Step definitions for the new built-in file tools (move, dir-create, file-info)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.tool.builtins import register_file_tools_with_changeset
|
||||
from cleveragents.tool.builtins.changeset import ChangeSetCapture
|
||||
from cleveragents.tool.registry import ToolRegistry
|
||||
from cleveragents.tool.runner import ToolRunner
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_tool(context: Any, tool_name: str, inputs: dict[str, Any]) -> None:
|
||||
"""Execute a built-in file tool through the runner."""
|
||||
from cleveragents.tool.builtins import register_file_tools
|
||||
|
||||
registry = ToolRegistry()
|
||||
register_file_tools(registry)
|
||||
runner = ToolRunner(registry)
|
||||
inputs["sandbox_root"] = context.sandbox_dir
|
||||
context.tool_result = runner.execute(tool_name, inputs)
|
||||
|
||||
|
||||
def _run_changeset_tool(
|
||||
context: Any,
|
||||
tool_name: str,
|
||||
inputs: dict[str, Any],
|
||||
) -> None:
|
||||
"""Execute a built-in file tool through a changeset-wrapped runner."""
|
||||
capture: ChangeSetCapture = context.changeset_capture
|
||||
registry = ToolRegistry()
|
||||
register_file_tools_with_changeset(registry, capture)
|
||||
runner = ToolRunner(registry)
|
||||
inputs["sandbox_root"] = context.sandbox_dir
|
||||
context.tool_result = runner.execute(tool_name, inputs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Givens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a binary file "{name}" with null bytes in the sandbox')
|
||||
def step_given_binary_file_null_bytes(context: Any, name: str) -> None:
|
||||
path = Path(context.sandbox_dir) / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"\x00\x01\x02\xff binary content \x00")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I execute the "builtin/file-move" tool moving "{source}" to "{destination}"')
|
||||
def step_when_file_move(context: Any, source: str, destination: str) -> None:
|
||||
_run_tool(
|
||||
context,
|
||||
"builtin/file-move",
|
||||
{"source_path": source, "destination_path": destination},
|
||||
)
|
||||
|
||||
|
||||
@when('I execute the "builtin/dir-create" tool with path "{path}"')
|
||||
def step_when_dir_create(context: Any, path: str) -> None:
|
||||
_run_tool(context, "builtin/dir-create", {"path": path})
|
||||
|
||||
|
||||
@when('I execute the "builtin/dir-create" tool with path "{path}" without parents')
|
||||
def step_when_dir_create_no_parents(context: Any, path: str) -> None:
|
||||
_run_tool(context, "builtin/dir-create", {"path": path, "parents": False})
|
||||
|
||||
|
||||
@when('I execute the "builtin/file-info" tool with path "{path}"')
|
||||
def step_when_file_info(context: Any, path: str) -> None:
|
||||
_run_tool(context, "builtin/file-info", {"path": path})
|
||||
|
||||
|
||||
@when('I move "{source}" to "{dest}" through changeset-wrapped tools')
|
||||
def step_when_move_changeset(context: Any, source: str, dest: str) -> None:
|
||||
_run_changeset_tool(
|
||||
context,
|
||||
"builtin/file-move",
|
||||
{"source_path": source, "destination_path": dest},
|
||||
)
|
||||
|
||||
|
||||
@when('I create directory "{path}" through changeset-wrapped tools')
|
||||
def step_when_dir_create_changeset(context: Any, path: str) -> None:
|
||||
_run_changeset_tool(
|
||||
context,
|
||||
"builtin/dir-create",
|
||||
{"path": path},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the directory "{name}" should exist in the sandbox')
|
||||
def step_then_directory_exists(context: Any, name: str) -> None:
|
||||
path = Path(context.sandbox_dir) / name
|
||||
assert path.is_dir(), f"Directory {path} does not exist"
|
||||
|
||||
|
||||
@then('the output should contain a valid "{key}" ISO timestamp')
|
||||
def step_then_output_valid_iso_timestamp(context: Any, key: str) -> None:
|
||||
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
|
||||
value = context.tool_result.output[key]
|
||||
assert isinstance(value, str), f"Expected string for {key}, got {type(value)}"
|
||||
# Validate by parsing — raises ValueError if not a valid ISO timestamp
|
||||
parsed = datetime.fromisoformat(value)
|
||||
assert parsed.tzinfo is not None, (
|
||||
f"Expected timezone-aware ISO timestamp for {key}, got '{value}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the file "{name}" in the sandbox should contain "{expected}"')
|
||||
def step_then_file_contains(context: Any, name: str, expected: str) -> None:
|
||||
path = Path(context.sandbox_dir) / name
|
||||
assert path.exists(), f"File {path} does not exist"
|
||||
content = path.read_text()
|
||||
assert expected in content, f"Expected '{expected}' in {path}, got '{content}'"
|
||||
|
||||
|
||||
@then('the file "{name}" in the sandbox should have exact content "{expected}"')
|
||||
def step_then_file_exact_content(context: Any, name: str, expected: str) -> None:
|
||||
path = Path(context.sandbox_dir) / name
|
||||
assert path.exists(), f"File {path} does not exist"
|
||||
content = path.read_text()
|
||||
assert content == expected, (
|
||||
f"Expected exact content '{expected}' in {path}, got '{content}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the output "{key}" should equal "{expected}"')
|
||||
def step_then_output_equals(context: Any, key: str, expected: str) -> None:
|
||||
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
|
||||
value = context.tool_result.output[key]
|
||||
assert str(value) == expected, f"Expected {key}='{expected}', got '{value}'"
|
||||
|
||||
|
||||
@then('the output "{key}" should be null')
|
||||
def step_then_output_is_null(context: Any, key: str) -> None:
|
||||
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
|
||||
value = context.tool_result.output[key]
|
||||
assert value is None, f"Expected {key}=None, got '{value}'"
|
||||
|
||||
|
||||
@then('the changeset entry metadata should include a "{key}" key')
|
||||
def step_then_changeset_metadata_key(context: Any, key: str) -> None:
|
||||
capture: ChangeSetCapture = context.changeset_capture
|
||||
cs = capture.get_changeset()
|
||||
assert cs.entries, "Changeset has no entries"
|
||||
entry = cs.entries[0]
|
||||
assert key in entry.metadata, (
|
||||
f"Expected '{key}' in metadata, got keys: {list(entry.metadata.keys())}"
|
||||
)
|
||||
@@ -194,19 +194,22 @@ Feature: Built-in File Tools
|
||||
Scenario: Register all file tools
|
||||
Given a tool registry
|
||||
When I register all file tools
|
||||
Then the registry should contain 6 tools
|
||||
Then the registry should contain 9 tools
|
||||
And the registry should contain tool "builtin/file-read"
|
||||
And the registry should contain tool "builtin/file-write"
|
||||
And the registry should contain tool "builtin/file-edit"
|
||||
And the registry should contain tool "builtin/file-delete"
|
||||
And the registry should contain tool "builtin/file-list"
|
||||
And the registry should contain tool "builtin/file-search"
|
||||
And the registry should contain tool "builtin/file-move"
|
||||
And the registry should contain tool "builtin/dir-create"
|
||||
And the registry should contain tool "builtin/file-info"
|
||||
|
||||
Scenario: Register file tools with changeset
|
||||
Given a tool registry
|
||||
And a changeset capture with plan_id "plan-reg"
|
||||
When I register file tools with changeset
|
||||
Then the registry should contain 6 tools
|
||||
Then the registry should contain 9 tools
|
||||
|
||||
# ---- ChangeSet model fields ----
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for new built-in file tools (move, dir-create, file-info)
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER_SCRIPT} robot/helper_builtin_file_tools_new.py
|
||||
|
||||
*** Test Cases ***
|
||||
File Move Tool
|
||||
[Documentation] Move a file using builtin/file-move
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} move cwd=${WORKSPACE} timeout=60s on_timeout=kill
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} file-move-ok
|
||||
|
||||
Directory Create Tool
|
||||
[Documentation] Create a directory using builtin/dir-create
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} dir_create cwd=${WORKSPACE} timeout=60s on_timeout=kill
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} dir-create-ok
|
||||
|
||||
File Info Tool
|
||||
[Documentation] Get file info using builtin/file-info
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} info cwd=${WORKSPACE} timeout=60s on_timeout=kill
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} file-info-ok
|
||||
|
||||
File Move Path Traversal Prevention
|
||||
[Documentation] Path traversal in move should be blocked
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} move_traversal cwd=${WORKSPACE} timeout=60s on_timeout=kill
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} move-traversal-ok
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Helper script for Robot Framework new built-in file tools smoke tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Ensure src is importable when run from workspace root
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from cleveragents.tool.builtins import register_file_tools
|
||||
from cleveragents.tool.registry import ToolRegistry
|
||||
from cleveragents.tool.runner import ToolRunner
|
||||
|
||||
|
||||
def _make_runner() -> ToolRunner:
|
||||
"""Create a ToolRunner with all built-in file tools registered."""
|
||||
registry = ToolRegistry()
|
||||
register_file_tools(registry)
|
||||
return ToolRunner(registry)
|
||||
|
||||
|
||||
def test_file_move() -> None:
|
||||
sandbox = tempfile.mkdtemp()
|
||||
try:
|
||||
(Path(sandbox) / "moveme.txt").write_text("moving data")
|
||||
runner = _make_runner()
|
||||
result = runner.execute(
|
||||
"builtin/file-move",
|
||||
{
|
||||
"source_path": "moveme.txt",
|
||||
"destination_path": "moved.txt",
|
||||
"sandbox_root": sandbox,
|
||||
},
|
||||
)
|
||||
assert result.success
|
||||
assert result.output["moved"] is True
|
||||
assert (Path(sandbox) / "moved.txt").exists()
|
||||
assert not (Path(sandbox) / "moveme.txt").exists()
|
||||
print("file-move-ok")
|
||||
finally:
|
||||
shutil.rmtree(sandbox, ignore_errors=True)
|
||||
|
||||
|
||||
def test_dir_create() -> None:
|
||||
sandbox = tempfile.mkdtemp()
|
||||
try:
|
||||
runner = _make_runner()
|
||||
result = runner.execute(
|
||||
"builtin/dir-create",
|
||||
{"path": "a/b/c", "sandbox_root": sandbox},
|
||||
)
|
||||
assert result.success
|
||||
assert result.output["created"] is True
|
||||
assert (Path(sandbox) / "a" / "b" / "c").is_dir()
|
||||
print("dir-create-ok")
|
||||
finally:
|
||||
shutil.rmtree(sandbox, ignore_errors=True)
|
||||
|
||||
|
||||
def test_file_info() -> None:
|
||||
sandbox = tempfile.mkdtemp()
|
||||
try:
|
||||
(Path(sandbox) / "info.txt").write_text("metadata")
|
||||
runner = _make_runner()
|
||||
result = runner.execute(
|
||||
"builtin/file-info",
|
||||
{"path": "info.txt", "sandbox_root": sandbox},
|
||||
)
|
||||
assert result.success
|
||||
assert result.output["is_file"] is True
|
||||
assert result.output["is_dir"] is False
|
||||
assert result.output["size"] > 0
|
||||
assert "T" in result.output["modified"]
|
||||
print("file-info-ok")
|
||||
finally:
|
||||
shutil.rmtree(sandbox, ignore_errors=True)
|
||||
|
||||
|
||||
def test_move_traversal() -> None:
|
||||
sandbox = tempfile.mkdtemp()
|
||||
try:
|
||||
(Path(sandbox) / "safe.txt").write_text("data")
|
||||
runner = _make_runner()
|
||||
result = runner.execute(
|
||||
"builtin/file-move",
|
||||
{
|
||||
"source_path": "safe.txt",
|
||||
"destination_path": "../../escaped.txt",
|
||||
"sandbox_root": sandbox,
|
||||
},
|
||||
)
|
||||
assert not result.success
|
||||
assert "traversal" in (result.error or "").lower()
|
||||
print("move-traversal-ok")
|
||||
finally:
|
||||
shutil.rmtree(sandbox, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "move"
|
||||
dispatch: dict[str, Any] = {
|
||||
"move": test_file_move,
|
||||
"dir_create": test_dir_create,
|
||||
"info": test_file_info,
|
||||
"move_traversal": test_move_traversal,
|
||||
}
|
||||
fn = dispatch.get(cmd)
|
||||
if fn:
|
||||
fn()
|
||||
else:
|
||||
print(f"Unknown command: {cmd}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -19,9 +19,12 @@ from cleveragents.tool.builtins.changeset import (
|
||||
)
|
||||
from cleveragents.tool.builtins.file_tools import (
|
||||
ALL_FILE_TOOLS,
|
||||
DIR_CREATE_SPEC,
|
||||
FILE_DELETE_SPEC,
|
||||
FILE_EDIT_SPEC,
|
||||
FILE_INFO_SPEC,
|
||||
FILE_LIST_SPEC,
|
||||
FILE_MOVE_SPEC,
|
||||
FILE_READ_SPEC,
|
||||
FILE_SEARCH_SPEC,
|
||||
FILE_WRITE_SPEC,
|
||||
@@ -45,7 +48,7 @@ from cleveragents.tool.registry import ToolRegistry
|
||||
|
||||
|
||||
def register_file_tools(registry: ToolRegistry) -> None:
|
||||
"""Register all 6 built-in file tools into *registry*."""
|
||||
"""Register all 9 built-in file tools into *registry*."""
|
||||
for spec in ALL_FILE_TOOLS:
|
||||
registry.register(spec)
|
||||
|
||||
@@ -80,9 +83,12 @@ __all__ = [
|
||||
"ALL_FILE_TOOLS",
|
||||
"ALL_GIT_TOOLS",
|
||||
"ALL_SUBPLAN_TOOLS",
|
||||
"DIR_CREATE_SPEC",
|
||||
"FILE_DELETE_SPEC",
|
||||
"FILE_EDIT_SPEC",
|
||||
"FILE_INFO_SPEC",
|
||||
"FILE_LIST_SPEC",
|
||||
"FILE_MOVE_SPEC",
|
||||
"FILE_READ_SPEC",
|
||||
"FILE_SEARCH_SPEC",
|
||||
"FILE_WRITE_SPEC",
|
||||
|
||||
@@ -116,10 +116,10 @@ class ChangeSet(BaseModel):
|
||||
|
||||
|
||||
def _file_hash(path_str: str, sandbox_root: str | None = None) -> str | None:
|
||||
"""Compute SHA-256 hash of a file, or None if missing."""
|
||||
"""Compute SHA-256 hash of a file, or ``None`` if missing or a directory."""
|
||||
root = Path(sandbox_root) if sandbox_root else Path.cwd()
|
||||
p = (root / path_str).resolve()
|
||||
if not p.exists():
|
||||
if not p.exists() or p.is_dir():
|
||||
return None
|
||||
return hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
|
||||
@@ -208,18 +208,28 @@ class ChangeSetCapture:
|
||||
def _wrapped_handler(
|
||||
inputs: dict[str, Any],
|
||||
) -> Any:
|
||||
path_str = inputs.get("path", "")
|
||||
# Detect move tools by name (consistent with _detect_operation).
|
||||
is_move = "move" in tool_spec.name
|
||||
source_path = inputs.get("source_path", "") if is_move else ""
|
||||
dest_path = inputs.get("destination_path", "") if is_move else ""
|
||||
|
||||
path_str = source_path if is_move else inputs.get("path", "")
|
||||
sandbox = inputs.get("sandbox_root") or capture._sandbox_root
|
||||
before = _file_hash(path_str, sandbox) if path_str else None
|
||||
|
||||
result = original_handler(inputs)
|
||||
|
||||
after = _file_hash(path_str, sandbox) if path_str else None
|
||||
after_path = dest_path if is_move else path_str
|
||||
after = _file_hash(after_path, sandbox) if after_path else None
|
||||
output = result if isinstance(result, dict) else {}
|
||||
|
||||
operation = _detect_operation(tool_spec.name, before, after, output)
|
||||
resource_id = inputs.get("resource_id") or capture._resource_id
|
||||
normalized = _normalize_path(path_str, sandbox)
|
||||
normalized = _normalize_path(after_path if is_move else path_str, sandbox)
|
||||
|
||||
metadata: dict[str, Any] = {"tool": tool_spec.name}
|
||||
if is_move:
|
||||
metadata["source_path"] = _normalize_path(source_path, sandbox)
|
||||
|
||||
entry = ChangeSetEntry(
|
||||
operation=operation,
|
||||
@@ -228,7 +238,7 @@ class ChangeSetCapture:
|
||||
tool_name=tool_spec.name,
|
||||
before_hash=before,
|
||||
after_hash=after,
|
||||
metadata={"tool": tool_spec.name},
|
||||
metadata=metadata,
|
||||
)
|
||||
capture._entries.append(entry)
|
||||
|
||||
@@ -302,11 +312,17 @@ def _detect_operation(
|
||||
"""Detect the operation type from tool name and file state."""
|
||||
if "delete" in tool_name:
|
||||
return "delete"
|
||||
if "move" in tool_name:
|
||||
return "move"
|
||||
if "edit" in tool_name:
|
||||
return "modify"
|
||||
# file-write: created vs modified
|
||||
if output.get("created", False):
|
||||
# Explicit "created" flag from tool output (e.g. dir-create, file-write).
|
||||
if output.get("created") is True:
|
||||
return "create"
|
||||
# dir-create on an already-existing directory is a no-op; both hashes
|
||||
# are None (directories have no content hash) and created == False.
|
||||
if output.get("created") is False and before is None and after is None:
|
||||
return "modify"
|
||||
if before is None and after is not None:
|
||||
return "create"
|
||||
return "modify"
|
||||
|
||||
@@ -14,6 +14,9 @@ path traversal prevention.
|
||||
| ``builtin/file-delete`` | ``writes`` | Delete a file |
|
||||
| ``builtin/file-list`` | ``read_only`` | List directory contents |
|
||||
| ``builtin/file-search`` | ``read_only`` | Search files for content |
|
||||
| ``builtin/file-move`` | ``writes`` | Move/rename a file |
|
||||
| ``builtin/dir-create`` | ``writes`` | Create a directory |
|
||||
| ``builtin/file-info`` | ``read_only`` | Get file metadata |
|
||||
|
||||
## Resource Slot Requirements
|
||||
|
||||
@@ -62,6 +65,9 @@ from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
import shutil
|
||||
import stat as stat_module
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -238,6 +244,92 @@ def _handle_file_search(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"matches": matches}
|
||||
|
||||
|
||||
def _handle_file_move(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Move/rename a file within sandbox boundaries."""
|
||||
source = validate_path(inputs["source_path"], inputs.get("sandbox_root"))
|
||||
destination = validate_path(inputs["destination_path"], inputs.get("sandbox_root"))
|
||||
|
||||
if not source.exists():
|
||||
raise ValueError(f"Source file does not exist: {source}")
|
||||
if not source.is_file():
|
||||
raise ValueError(
|
||||
f"Source is not a file (is a directory or special file): {source}"
|
||||
)
|
||||
if destination.is_dir():
|
||||
raise ValueError(
|
||||
f"Destination is an existing directory: {destination}. "
|
||||
"Provide a full file path as the destination."
|
||||
)
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(source), str(destination))
|
||||
|
||||
return {
|
||||
"source": str(source),
|
||||
"destination": str(destination),
|
||||
"moved": True,
|
||||
}
|
||||
|
||||
|
||||
def _handle_dir_create(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create directory with optional parents."""
|
||||
path = validate_path(inputs["path"], inputs.get("sandbox_root"))
|
||||
parents: bool = inputs.get("parents", True)
|
||||
|
||||
if path.exists() and not path.is_dir():
|
||||
raise ValueError(f"Path already exists and is not a directory: {path}")
|
||||
|
||||
existed = path.is_dir()
|
||||
path.mkdir(parents=parents, exist_ok=True)
|
||||
|
||||
return {
|
||||
"path": str(path),
|
||||
"created": not existed,
|
||||
}
|
||||
|
||||
|
||||
def _handle_file_info(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get file metadata."""
|
||||
path = validate_path(inputs["path"], inputs.get("sandbox_root"))
|
||||
|
||||
st = path.stat()
|
||||
# Use st_birthtime (macOS/BSD, Python 3.12+ on some Linux) when
|
||||
# available; fall back to st_ctime (inode change time on Linux).
|
||||
created_ts: float = getattr(st, "st_birthtime", st.st_ctime)
|
||||
return {
|
||||
"path": str(path),
|
||||
"size": st.st_size,
|
||||
"is_dir": path.is_dir(),
|
||||
"is_file": path.is_file(),
|
||||
"permissions": oct(stat_module.S_IMODE(st.st_mode)),
|
||||
"created": datetime.fromtimestamp(created_ts, tz=UTC).isoformat(),
|
||||
"modified": datetime.fromtimestamp(st.st_mtime, tz=UTC).isoformat(),
|
||||
"accessed": datetime.fromtimestamp(st.st_atime, tz=UTC).isoformat(),
|
||||
"encoding": _detect_encoding(path),
|
||||
}
|
||||
|
||||
|
||||
def _detect_encoding(path: Path) -> str | None:
|
||||
"""Detect basic encoding for a file, or ``None`` for directories/binary.
|
||||
|
||||
Returns ``"utf-8"`` only when the first 8 KiB of the file
|
||||
successfully decodes as UTF-8 and contains no null bytes.
|
||||
Returns ``None`` for directories, binary files, or files that
|
||||
are not valid UTF-8.
|
||||
"""
|
||||
if path.is_dir():
|
||||
return None
|
||||
try:
|
||||
with path.open("rb") as fh:
|
||||
chunk = fh.read(8192)
|
||||
if b"\x00" in chunk:
|
||||
return None # binary content
|
||||
chunk.decode("utf-8")
|
||||
return "utf-8"
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool specs
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -453,6 +545,96 @@ FILE_SEARCH_SPEC = ToolSpec(
|
||||
)
|
||||
|
||||
|
||||
FILE_MOVE_SPEC = ToolSpec(
|
||||
name="builtin/file-move",
|
||||
description="Move/rename a file within sandbox boundaries",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_path": {
|
||||
"type": "string",
|
||||
"description": "Source file path to move",
|
||||
},
|
||||
"destination_path": {
|
||||
"type": "string",
|
||||
"description": "Destination file path",
|
||||
},
|
||||
},
|
||||
"required": ["source_path", "destination_path"],
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"destination": {"type": "string"},
|
||||
"moved": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
capabilities=ToolCapability(writes=True),
|
||||
handler=_handle_file_move,
|
||||
)
|
||||
|
||||
DIR_CREATE_SPEC = ToolSpec(
|
||||
name="builtin/dir-create",
|
||||
description="Create directory with optional parents",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory path to create",
|
||||
},
|
||||
"parents": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Create parent directories if needed",
|
||||
},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"created": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
capabilities=ToolCapability(writes=True),
|
||||
handler=_handle_dir_create,
|
||||
)
|
||||
|
||||
FILE_INFO_SPEC = ToolSpec(
|
||||
name="builtin/file-info",
|
||||
description="Get file metadata",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path to get info for",
|
||||
},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"size": {"type": "integer"},
|
||||
"is_dir": {"type": "boolean"},
|
||||
"is_file": {"type": "boolean"},
|
||||
"permissions": {"type": "string"},
|
||||
"created": {"type": "string"},
|
||||
"modified": {"type": "string"},
|
||||
"accessed": {"type": "string"},
|
||||
"encoding": {"type": ["string", "null"]},
|
||||
},
|
||||
},
|
||||
capabilities=ToolCapability(read_only=True),
|
||||
handler=_handle_file_info,
|
||||
)
|
||||
|
||||
|
||||
ALL_FILE_TOOLS: list[ToolSpec] = [
|
||||
FILE_READ_SPEC,
|
||||
FILE_WRITE_SPEC,
|
||||
@@ -460,4 +642,7 @@ ALL_FILE_TOOLS: list[ToolSpec] = [
|
||||
FILE_DELETE_SPEC,
|
||||
FILE_LIST_SPEC,
|
||||
FILE_SEARCH_SPEC,
|
||||
FILE_MOVE_SPEC,
|
||||
DIR_CREATE_SPEC,
|
||||
FILE_INFO_SPEC,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user