Add Phase 6: write tools, shell, and edit tools with reasoning-only fix

Implement 6 new agent tools — write_file, make_dir, delete_file,
str_replace, patch_apply, run_command — bringing the agent from
read-only observer to active code modifier. All write/shell operations
are gated through the existing permissions service.

Also fix a bug where qwen3.5 thinking mode produces reasoning tokens
but no content after tool results, causing the agent to silently exit.
The loop now detects reasoning-only responses, retries twice, then
injects a nudge message to break the model out of its thinking loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 09:45:48 -05:00
parent 0cf0d01657
commit f60c47a85f
12 changed files with 956 additions and 3 deletions

View File

@@ -1,4 +1,4 @@
"""Filesystem tools: read_file and list_dir."""
"""Filesystem tools: read_file, list_dir, write_file, make_dir, delete_file."""
from pathlib import Path
from typing import Any
@@ -13,6 +13,7 @@ from app.utils.file_helpers import (
PathSecurityError,
resolve_safe_path,
safe_read_file,
safe_write_file,
)
@@ -147,3 +148,175 @@ class ListDirTool(BaseTool):
status=ToolResultStatus.SUCCESS,
output="\n".join(lines),
)
class WriteFileParams(BaseModel):
"""Parameters for the write_file tool."""
file_path: str = Field(description="Path to the file to write (relative to workspace root)")
content: str = Field(description="Content to write to the file")
class WriteFileTool(BaseTool):
"""Write content to a file within the workspace."""
name = "write_file"
description = (
"Write text content to a file. Creates parent directories if needed. "
"Overwrites existing file content."
)
params_model = WriteFileParams
def execute(self, *, tool_call_id: str, file_path: str, content: str, **kwargs: Any) -> ToolResult:
fs_config = self.config.tools.filesystem
try:
safe_path = safe_write_file(
file_path,
content,
self.workspace_root,
max_size_bytes=fs_config.max_file_size_bytes,
)
except PathSecurityError as exc:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error=str(exc),
)
except FileSizeError as exc:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error=str(exc),
)
try:
rel_path = safe_path.relative_to(self.workspace_root)
except ValueError:
rel_path = safe_path
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.SUCCESS,
output=f"Wrote {len(content)} characters to {rel_path}",
)
class MakeDirParams(BaseModel):
"""Parameters for the make_dir tool."""
directory_path: str = Field(description="Path to the directory to create (relative to workspace root)")
class MakeDirTool(BaseTool):
"""Create a directory (and any missing parents) within the workspace."""
name = "make_dir"
description = "Create a directory and any necessary parent directories."
params_model = MakeDirParams
def execute(self, *, tool_call_id: str, directory_path: str, **kwargs: Any) -> ToolResult:
try:
safe_path = resolve_safe_path(directory_path, self.workspace_root)
except PathSecurityError as exc:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error=str(exc),
)
if safe_path.exists() and not safe_path.is_dir():
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error=f"Path exists and is not a directory: {safe_path}",
)
try:
safe_path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error=f"Failed to create directory: {exc}",
)
try:
rel_path = safe_path.relative_to(self.workspace_root)
except ValueError:
rel_path = safe_path
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.SUCCESS,
output=f"Created directory: {rel_path}",
)
class DeleteFileParams(BaseModel):
"""Parameters for the delete_file tool."""
file_path: str = Field(description="Path to the file to delete (relative to workspace root)")
class DeleteFileTool(BaseTool):
"""Delete a file within the workspace. Refuses to delete directories."""
name = "delete_file"
description = "Delete a single file. Does not delete directories."
params_model = DeleteFileParams
def execute(self, *, tool_call_id: str, file_path: str, **kwargs: Any) -> ToolResult:
try:
safe_path = resolve_safe_path(file_path, self.workspace_root)
except PathSecurityError as exc:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error=str(exc),
)
if safe_path.is_dir():
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error=f"Path is a directory, not a file: {safe_path}",
)
if not safe_path.exists():
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error=f"File not found: {safe_path}",
)
try:
safe_path.unlink()
except OSError as exc:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error=f"Failed to delete file: {exc}",
)
try:
rel_path = safe_path.relative_to(self.workspace_root)
except ValueError:
rel_path = safe_path
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.SUCCESS,
output=f"Deleted: {rel_path}",
)