- harness/es.py: append-only event store + проекции (образец — кооп-портал) - harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу - harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы - harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ create_harness) - cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом) - 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
165 lines
6.5 KiB
Python
165 lines
6.5 KiB
Python
"""Filesystem tools: ``read``, ``write``, ``edit``.
|
|
|
|
Thin tools that route file access through the same ``tool_context.agent.sandbox`` seam the SDK's
|
|
file editor uses, so they behave identically across host and container filesystems. They are
|
|
candidates to port into the core SDK later; keep them minimal and SDK-idiomatic.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from strands.tools.decorator import tool
|
|
from strands.types.tools import ToolContext, ToolResult
|
|
|
|
_READ_DEFAULT_LIMIT = 2000
|
|
|
|
# Media formats the SDK accepts in tool results, keyed by file extension. Detection is by
|
|
# extension because the sandbox seam exposes no MIME metadata. Text-representable document
|
|
# formats (csv, html, txt, md) stay on the numbered-lines path, which supports citing and
|
|
# paging. Video is omitted: the SDK's tool results do not accept video content.
|
|
_IMAGE_FORMATS = {"png": "png", "jpg": "jpeg", "jpeg": "jpeg", "gif": "gif", "webp": "webp"}
|
|
_DOCUMENT_FORMATS = ("pdf", "doc", "docx", "xls", "xlsx")
|
|
|
|
|
|
def _validate_path(path: str) -> None:
|
|
if not path.startswith("/"):
|
|
raise ValueError(f"The path {path} is not absolute; it should start with '/'.")
|
|
if ".." in re.split(r"[/\\]", path):
|
|
raise ValueError("Invalid path: path traversal is not allowed.")
|
|
|
|
|
|
def _number_lines(content: str, start: int) -> str:
|
|
return "\n".join(f"{i + start:>6}\t{line}" for i, line in enumerate(content.split("\n")))
|
|
|
|
|
|
def _extension(path: str) -> str:
|
|
name = re.split(r"[/\\]", path)[-1]
|
|
return name.rpartition(".")[2].lower() if "." in name else ""
|
|
|
|
|
|
def _document_name(path: str) -> str:
|
|
"""Sanitize a filename into a model-safe document name.
|
|
|
|
Bedrock accepts only alphanumerics, whitespace, hyphens, parentheses, and square brackets
|
|
in document names, with no consecutive whitespace.
|
|
"""
|
|
name = re.split(r"[/\\]", path)[-1]
|
|
name = re.sub(r"[^a-zA-Z0-9\s\-()\[\]]", " ", name)
|
|
name = re.sub(r"\s+", " ", name).strip()
|
|
return name or "document"
|
|
|
|
|
|
def _media_placeholder(path: str, kind: str, media_format: str, size: int) -> str:
|
|
return (
|
|
f"[{kind} file: {path} ({media_format}, {size} bytes). This model cannot view "
|
|
f"{kind.lower()} files, so its contents are not shown. Use shell tools if the file has a "
|
|
f"text-extractable form.]"
|
|
)
|
|
|
|
|
|
def make_read(media: bool = True):
|
|
"""Build the ``read`` tool.
|
|
|
|
Args:
|
|
media: Return images and binary documents as viewable media. Set ``False`` for a model that
|
|
rejects media blocks, and ``read`` describes those files in text instead. See
|
|
``_supports_media`` in ``models``.
|
|
"""
|
|
|
|
@tool(name="read", context="tool_context")
|
|
async def read(
|
|
path: str, tool_context: ToolContext, offset: int | None = None, limit: int | None = None
|
|
) -> str | ToolResult:
|
|
"""Read a file. Text returns ``cat -n`` style numbered lines so you can cite ``path:line``; images
|
|
(png/jpg/jpeg/gif/webp) and binary documents (pdf/doc/docx/xls/xlsx) return media you can view directly.
|
|
|
|
Args:
|
|
path: Absolute path to the file.
|
|
tool_context: Injected by the framework. Not user-facing.
|
|
offset: 1-indexed line to start from. Defaults to the first line. Text files only.
|
|
limit: Maximum number of lines to return. Defaults to 2000. Text files only.
|
|
"""
|
|
_validate_path(path)
|
|
extension = _extension(path)
|
|
|
|
if image_format := _IMAGE_FORMATS.get(extension):
|
|
data = await tool_context.agent.sandbox.read_file(path)
|
|
if not media:
|
|
return _media_placeholder(path, "Image", image_format, len(data))
|
|
return {
|
|
"toolUseId": tool_context.tool_use["toolUseId"],
|
|
"status": "success",
|
|
"content": [{"image": {"format": image_format, "source": {"bytes": data}}}],
|
|
}
|
|
|
|
if extension in _DOCUMENT_FORMATS:
|
|
data = await tool_context.agent.sandbox.read_file(path)
|
|
if not media:
|
|
return _media_placeholder(path, "Document", extension, len(data))
|
|
return {
|
|
"toolUseId": tool_context.tool_use["toolUseId"],
|
|
"status": "success",
|
|
"content": [
|
|
{"document": {"format": extension, "name": _document_name(path), "source": {"bytes": data}}}
|
|
],
|
|
}
|
|
|
|
content = await tool_context.agent.sandbox.read_text(path)
|
|
|
|
lines = content.split("\n")
|
|
start = max(0, (offset - 1) if offset else 0)
|
|
count = limit if limit is not None else _READ_DEFAULT_LIMIT
|
|
window = lines[start : start + count]
|
|
if not window:
|
|
return f"[File has {len(lines)} lines; offset {offset} is past the end.]"
|
|
|
|
numbered = _number_lines("\n".join(window), start + 1)
|
|
if start > 0 or start + count < len(lines):
|
|
shown_end = start + len(window)
|
|
numbered += f"\n[Showing lines {start + 1}-{shown_end} of {len(lines)}. Use offset/limit to read more.]"
|
|
return numbered
|
|
|
|
return read
|
|
|
|
|
|
read = make_read()
|
|
|
|
|
|
@tool(name="write", context="tool_context")
|
|
async def write(path: str, content: str, tool_context: ToolContext) -> str:
|
|
"""Write a file, creating it or overwriting it. Use ``edit`` for surgical changes to a large file.
|
|
|
|
Args:
|
|
path: Absolute path to the file.
|
|
content: The full file content to write.
|
|
tool_context: Injected by the framework. Not user-facing.
|
|
"""
|
|
_validate_path(path)
|
|
await tool_context.agent.sandbox.write_text(path, content)
|
|
line_count = 0 if content == "" else len(content.split("\n"))
|
|
return f"Wrote {line_count} lines to {path}."
|
|
|
|
|
|
@tool(name="edit", context="tool_context")
|
|
async def edit(path: str, old_str: str, new_str: str, tool_context: ToolContext) -> str:
|
|
"""Replace an exact string in a file. ``old_str`` must appear exactly once.
|
|
|
|
Args:
|
|
path: Absolute path to the file.
|
|
old_str: Exact text to find. Must be unique within the file.
|
|
new_str: Replacement text.
|
|
tool_context: Injected by the framework. Not user-facing.
|
|
"""
|
|
_validate_path(path)
|
|
sandbox = tool_context.agent.sandbox
|
|
content = await sandbox.read_text(path)
|
|
|
|
occurrences = content.count(old_str)
|
|
if occurrences == 0:
|
|
raise ValueError(f"old_str did not appear verbatim in {path}.")
|
|
if occurrences > 1:
|
|
raise ValueError(f"old_str appears {occurrences} times in {path}; make it unique.")
|
|
|
|
await sandbox.write_text(path, content.replace(old_str, new_str, 1))
|
|
return f"Edited {path}."
|