base-agentkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentkit/__init__.py +35 -0
- agentkit/agent/__init__.py +7 -0
- agentkit/agent/agent.py +368 -0
- agentkit/agent/budgets.py +48 -0
- agentkit/agent/report.py +166 -0
- agentkit/agent/tool_runtime.py +77 -0
- agentkit/cli/__init__.py +5 -0
- agentkit/cli/main.py +108 -0
- agentkit/config/__init__.py +23 -0
- agentkit/config/loader.py +108 -0
- agentkit/config/provider_defaults.py +96 -0
- agentkit/config/schema.py +148 -0
- agentkit/constants.py +21 -0
- agentkit/errors.py +58 -0
- agentkit/llm/__init__.py +53 -0
- agentkit/llm/base.py +36 -0
- agentkit/llm/factory.py +27 -0
- agentkit/llm/providers/__init__.py +15 -0
- agentkit/llm/providers/anthropic_provider.py +371 -0
- agentkit/llm/providers/gemini_provider.py +396 -0
- agentkit/llm/providers/openai_provider.py +881 -0
- agentkit/llm/providers/qwen_provider.py +34 -0
- agentkit/llm/providers/vllm_provider.py +47 -0
- agentkit/llm/types.py +215 -0
- agentkit/llm/usage.py +72 -0
- agentkit/py.typed +0 -0
- agentkit/runlog/__init__.py +15 -0
- agentkit/runlog/events.py +67 -0
- agentkit/runlog/jsonl.py +90 -0
- agentkit/runlog/recorder.py +94 -0
- agentkit/runlog/sinks.py +15 -0
- agentkit/tools/__init__.py +16 -0
- agentkit/tools/base.py +139 -0
- agentkit/tools/library/__init__.py +8 -0
- agentkit/tools/library/_fs_common.py +330 -0
- agentkit/tools/library/create_file.py +168 -0
- agentkit/tools/library/fs_tools.py +21 -0
- agentkit/tools/library/str_replace.py +241 -0
- agentkit/tools/library/view.py +372 -0
- agentkit/tools/library/word_count.py +138 -0
- agentkit/tools/loader.py +81 -0
- agentkit/tools/registry.py +284 -0
- agentkit/tools/types.py +98 -0
- agentkit/workspace/__init__.py +6 -0
- agentkit/workspace/fs.py +288 -0
- agentkit/workspace/layout.py +33 -0
- base_agentkit-0.1.0.dist-info/METADATA +142 -0
- base_agentkit-0.1.0.dist-info/RECORD +51 -0
- base_agentkit-0.1.0.dist-info/WHEEL +4 -0
- base_agentkit-0.1.0.dist-info/entry_points.txt +3 -0
- base_agentkit-0.1.0.dist-info/licenses/LICENSE +183 -0
agentkit/tools/base.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Tool abstractions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from agentkit.errors import ToolError, WorkspaceError
|
|
9
|
+
from agentkit.tools.types import ToolInvocation, ToolModelError
|
|
10
|
+
|
|
11
|
+
ModelSuccessFormatter = Callable[[Any, ToolInvocation], Any]
|
|
12
|
+
ModelErrorFormatter = Callable[[Exception, ToolInvocation], Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Tool(ABC):
|
|
16
|
+
"""Abstract base class for agent tools."""
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
description: str
|
|
20
|
+
parameters: dict[str, Any]
|
|
21
|
+
|
|
22
|
+
def schema(self) -> dict[str, Any]:
|
|
23
|
+
"""Return this tool's model-facing function schema.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
dict[str, Any]: JSON-schema style tool descriptor.
|
|
27
|
+
"""
|
|
28
|
+
return {
|
|
29
|
+
"type": "function",
|
|
30
|
+
"name": self.name,
|
|
31
|
+
"description": self.description,
|
|
32
|
+
"parameters": self.parameters,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def run(self, arguments: dict[str, Any]) -> Any:
|
|
37
|
+
"""Execute the tool with validated arguments.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
arguments: Already validated tool arguments.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Any: Tool-specific output payload.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
Exception: Implementations may raise when execution fails.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def format_output_for_model(
|
|
50
|
+
self, output: Any, invocation: ToolInvocation
|
|
51
|
+
) -> Any:
|
|
52
|
+
"""Render a successful tool result into a model-facing payload."""
|
|
53
|
+
del invocation
|
|
54
|
+
return {"output": output}
|
|
55
|
+
|
|
56
|
+
def format_error_for_model(
|
|
57
|
+
self, error: Exception, invocation: ToolInvocation
|
|
58
|
+
) -> Any:
|
|
59
|
+
"""Render a failed tool result into a model-facing payload."""
|
|
60
|
+
del invocation
|
|
61
|
+
if isinstance(error, ToolModelError):
|
|
62
|
+
return error.to_model_payload()
|
|
63
|
+
if isinstance(error, (ToolError, WorkspaceError)):
|
|
64
|
+
return {
|
|
65
|
+
"error": {
|
|
66
|
+
"code": "tool_use_failed",
|
|
67
|
+
"message": str(error),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
"error": {
|
|
72
|
+
"code": "tool_execution_failed",
|
|
73
|
+
"message": f"The tool '{self.name}' failed unexpectedly.",
|
|
74
|
+
"hint": "Review the arguments and retry. If the issue persists, inspect the run log.",
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FunctionTool(Tool):
|
|
80
|
+
"""Simple tool wrapper backed by a Python callable."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
name: str,
|
|
86
|
+
description: str,
|
|
87
|
+
parameters: dict[str, Any],
|
|
88
|
+
handler: Callable[[dict[str, Any]], Any],
|
|
89
|
+
success_formatter: ModelSuccessFormatter | None = None,
|
|
90
|
+
error_formatter: ModelErrorFormatter | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Create a callable-backed tool implementation.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
name: Stable tool name shown to the model.
|
|
96
|
+
description: Human-readable tool description.
|
|
97
|
+
parameters: JSON-schema argument definition.
|
|
98
|
+
handler: Callable that executes tool logic.
|
|
99
|
+
success_formatter: Optional hook that converts successful tool output
|
|
100
|
+
into the model-facing payload stored in conversation history.
|
|
101
|
+
error_formatter: Optional hook that converts an exception into the
|
|
102
|
+
model-facing payload stored in conversation history.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
None
|
|
106
|
+
"""
|
|
107
|
+
self.name = name
|
|
108
|
+
self.description = description
|
|
109
|
+
self.parameters = parameters
|
|
110
|
+
self._handler = handler
|
|
111
|
+
self._success_formatter = success_formatter
|
|
112
|
+
self._error_formatter = error_formatter
|
|
113
|
+
|
|
114
|
+
def run(self, arguments: dict[str, Any]) -> Any:
|
|
115
|
+
"""Delegate execution to the wrapped handler.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
arguments: Validated tool arguments.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Any: Value returned by the wrapped handler.
|
|
122
|
+
"""
|
|
123
|
+
return self._handler(arguments)
|
|
124
|
+
|
|
125
|
+
def format_output_for_model(
|
|
126
|
+
self, output: Any, invocation: ToolInvocation
|
|
127
|
+
) -> Any:
|
|
128
|
+
"""Delegate successful model formatting to the custom formatter when set."""
|
|
129
|
+
if self._success_formatter is not None:
|
|
130
|
+
return self._success_formatter(output, invocation)
|
|
131
|
+
return super().format_output_for_model(output, invocation)
|
|
132
|
+
|
|
133
|
+
def format_error_for_model(
|
|
134
|
+
self, error: Exception, invocation: ToolInvocation
|
|
135
|
+
) -> Any:
|
|
136
|
+
"""Delegate error formatting to the custom formatter when set."""
|
|
137
|
+
if self._error_formatter is not None:
|
|
138
|
+
return self._error_formatter(error, invocation)
|
|
139
|
+
return super().format_error_for_model(error, invocation)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Concrete tool modules.
|
|
2
|
+
|
|
3
|
+
Developers can add new files under this directory and expose tools using one of:
|
|
4
|
+
|
|
5
|
+
- ``build_tools(fs)`` function returning ``Tool`` or ``Iterable[Tool]``
|
|
6
|
+
- ``TOOLS`` module variable containing ``Tool`` or ``Iterable[Tool]``
|
|
7
|
+
"""
|
|
8
|
+
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Shared helpers for workspace-scoped filesystem tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agentkit.errors import ToolError, WorkspaceError
|
|
9
|
+
from agentkit.tools.types import ToolInvocation, ToolModelError
|
|
10
|
+
from agentkit.workspace.fs import WorkspaceFS
|
|
11
|
+
|
|
12
|
+
_MAX_VIEW_DEPTH = 2
|
|
13
|
+
_BINARY_CHECK_SIZE = 8192
|
|
14
|
+
_ALLOWED_TEXT_CONTROL_BYTES = {9, 10, 12, 13}
|
|
15
|
+
_SKIP_NAMES: frozenset[str] = frozenset({"node_modules", "__pycache__"})
|
|
16
|
+
_COUNTED_CJK_IDEOGRAPH_RANGES: tuple[tuple[int, int], ...] = (
|
|
17
|
+
(0x3400, 0x4DBF),
|
|
18
|
+
(0x4E00, 0x9FFF),
|
|
19
|
+
(0xF900, 0xFAFF),
|
|
20
|
+
(0x20000, 0x2A6DF),
|
|
21
|
+
(0x2A700, 0x2B73F),
|
|
22
|
+
(0x2B740, 0x2B81F),
|
|
23
|
+
(0x2B820, 0x2CEAF),
|
|
24
|
+
(0x2CEB0, 0x2EBEF),
|
|
25
|
+
(0x2F800, 0x2FA1F),
|
|
26
|
+
)
|
|
27
|
+
_COUNTED_CJK_PUNCTUATION_RANGES: tuple[tuple[int, int], ...] = (
|
|
28
|
+
(0x3000, 0x303F),
|
|
29
|
+
(0xFE30, 0xFE4F),
|
|
30
|
+
(0xFF01, 0xFF0F),
|
|
31
|
+
(0xFF1A, 0xFF20),
|
|
32
|
+
(0xFF3B, 0xFF40),
|
|
33
|
+
(0xFF5B, 0xFF65),
|
|
34
|
+
)
|
|
35
|
+
_EXCLUDED_PLATFORM_COUNT_RANGES: tuple[tuple[int, int], ...] = (
|
|
36
|
+
(0x1100, 0x11FF),
|
|
37
|
+
(0x3040, 0x309F),
|
|
38
|
+
(0x30A0, 0x30FF),
|
|
39
|
+
(0x31F0, 0x31FF),
|
|
40
|
+
(0xA960, 0xA97F),
|
|
41
|
+
(0xAC00, 0xD7AF),
|
|
42
|
+
(0xD7B0, 0xD7FF),
|
|
43
|
+
(0xFF65, 0xFF9F),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def error_payload(
|
|
48
|
+
code: str,
|
|
49
|
+
message: str,
|
|
50
|
+
*,
|
|
51
|
+
hint: str | None = None,
|
|
52
|
+
details: dict[str, Any] | None = None,
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
"""Build the canonical structured error payload returned to the model."""
|
|
55
|
+
error: dict[str, Any] = {
|
|
56
|
+
"code": code,
|
|
57
|
+
"message": message,
|
|
58
|
+
}
|
|
59
|
+
if hint:
|
|
60
|
+
error["hint"] = hint
|
|
61
|
+
if details:
|
|
62
|
+
error["details"] = dict(details)
|
|
63
|
+
return {"error": error}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def path_details(invocation: ToolInvocation) -> dict[str, Any]:
|
|
67
|
+
"""Extract path context from invocation arguments when it is available."""
|
|
68
|
+
path = invocation.arguments.get("path") if isinstance(invocation.arguments, dict) else None
|
|
69
|
+
if isinstance(path, str) and path:
|
|
70
|
+
return {"path": path}
|
|
71
|
+
return {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def format_path_workspace_error(
|
|
75
|
+
error: Exception,
|
|
76
|
+
invocation: ToolInvocation,
|
|
77
|
+
*,
|
|
78
|
+
missing_message: str,
|
|
79
|
+
missing_hint: str,
|
|
80
|
+
not_file_message: str | None = None,
|
|
81
|
+
not_dir_or_file_message: str | None = None,
|
|
82
|
+
) -> dict[str, Any] | None:
|
|
83
|
+
"""Translate common workspace path failures into stable tool error payloads."""
|
|
84
|
+
if isinstance(error, ToolModelError):
|
|
85
|
+
return error.to_model_payload()
|
|
86
|
+
message = str(error)
|
|
87
|
+
details = path_details(invocation)
|
|
88
|
+
if message.startswith("Path escapes workspace: "):
|
|
89
|
+
return error_payload(
|
|
90
|
+
"path_outside_workspace",
|
|
91
|
+
"The path must stay inside the workspace.",
|
|
92
|
+
hint="Use a relative path inside the workspace root.",
|
|
93
|
+
details=details,
|
|
94
|
+
)
|
|
95
|
+
if message.startswith("Path does not exist: ") or message.startswith("File does not exist: "):
|
|
96
|
+
return error_payload(
|
|
97
|
+
"path_not_found",
|
|
98
|
+
missing_message,
|
|
99
|
+
hint=missing_hint,
|
|
100
|
+
details=details,
|
|
101
|
+
)
|
|
102
|
+
if not_dir_or_file_message and message.startswith("Not a file or directory: "):
|
|
103
|
+
return error_payload(
|
|
104
|
+
"invalid_path_kind",
|
|
105
|
+
not_dir_or_file_message,
|
|
106
|
+
hint="Choose an existing file or directory path that matches the tool's requirements.",
|
|
107
|
+
details=details,
|
|
108
|
+
)
|
|
109
|
+
if not_file_message and message.startswith("Not a file: "):
|
|
110
|
+
return error_payload(
|
|
111
|
+
"not_a_file",
|
|
112
|
+
not_file_message,
|
|
113
|
+
hint="Choose an existing file path, not a directory.",
|
|
114
|
+
details=details,
|
|
115
|
+
)
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def resolve_existing_file(fs: WorkspaceFS, path: str) -> Path:
|
|
120
|
+
"""Resolve ``path`` and ensure it exists as a regular file."""
|
|
121
|
+
target = fs.resolve_path(path)
|
|
122
|
+
if not target.exists():
|
|
123
|
+
raise WorkspaceError(f"File does not exist: {path}")
|
|
124
|
+
if not target.is_file():
|
|
125
|
+
raise WorkspaceError(f"Not a file: {path}")
|
|
126
|
+
return target
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_utf8_text_for_edit(fs: WorkspaceFS, path: str) -> str:
|
|
130
|
+
"""Read an editable UTF-8 file or raise a workspace-scoped error."""
|
|
131
|
+
target = resolve_existing_file(fs, path)
|
|
132
|
+
try:
|
|
133
|
+
return target.read_bytes().decode("utf-8")
|
|
134
|
+
except UnicodeDecodeError as exc:
|
|
135
|
+
raise WorkspaceError(f"File is not valid UTF-8 text: {path}") from exc
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def decode_text_for_view(data: bytes) -> tuple[str, str]:
|
|
139
|
+
"""Decode file bytes for viewing, escaping undecodable bytes when needed."""
|
|
140
|
+
try:
|
|
141
|
+
return data.decode("utf-8"), "utf-8"
|
|
142
|
+
except UnicodeDecodeError:
|
|
143
|
+
return data.decode("utf-8", errors="backslashreplace"), "utf-8/backslashreplace"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def normalize_view_range(view_range: Any, total_lines: int) -> tuple[int, int]:
|
|
147
|
+
"""Validate and normalize a 1-based inclusive file line range."""
|
|
148
|
+
if view_range is None:
|
|
149
|
+
if total_lines == 0:
|
|
150
|
+
return 1, 0
|
|
151
|
+
return 1, total_lines
|
|
152
|
+
if not isinstance(view_range, (list, tuple)) or len(view_range) != 2:
|
|
153
|
+
raise ToolError("view_range must be a two-item array: [start_line, end_line].")
|
|
154
|
+
start_line, end_line = view_range
|
|
155
|
+
if not isinstance(start_line, int) or not isinstance(end_line, int):
|
|
156
|
+
raise ToolError("view_range values must both be integers.")
|
|
157
|
+
if start_line < 1:
|
|
158
|
+
raise ToolError("start_line must be >= 1.")
|
|
159
|
+
if end_line < -1:
|
|
160
|
+
raise ToolError("end_line must be -1 or >= start_line.")
|
|
161
|
+
if total_lines == 0:
|
|
162
|
+
raise WorkspaceError("Cannot apply view_range to an empty file.")
|
|
163
|
+
if start_line > total_lines:
|
|
164
|
+
raise WorkspaceError(
|
|
165
|
+
f"start_line {start_line} is beyond the end of file ({total_lines} lines)."
|
|
166
|
+
)
|
|
167
|
+
if end_line == -1 or end_line > total_lines:
|
|
168
|
+
end_line = total_lines
|
|
169
|
+
if end_line < start_line:
|
|
170
|
+
raise WorkspaceError(
|
|
171
|
+
f"end_line {end_line} must be -1 or >= start_line {start_line}."
|
|
172
|
+
)
|
|
173
|
+
return start_line, end_line
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def split_lines(text: str) -> list[str]:
|
|
177
|
+
"""Split text into logical lines without inventing a trailing empty line."""
|
|
178
|
+
return text.splitlines() if text else []
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def format_numbered_lines(
|
|
182
|
+
lines: list[str], *, start_line: int, total_lines: int | None = None
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Prefix lines with right-aligned 1-based line numbers."""
|
|
185
|
+
if not lines:
|
|
186
|
+
return ""
|
|
187
|
+
|
|
188
|
+
last_line_number = start_line + len(lines) - 1
|
|
189
|
+
max_line_number = (
|
|
190
|
+
max(total_lines, last_line_number, 1)
|
|
191
|
+
if total_lines is not None
|
|
192
|
+
else max(last_line_number, 1)
|
|
193
|
+
)
|
|
194
|
+
number_width = len(str(max_line_number))
|
|
195
|
+
return "\n".join(
|
|
196
|
+
f"{line_number:>{number_width}}: {line}"
|
|
197
|
+
for line_number, line in enumerate(lines, start=start_line)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def format_human_size(size_bytes: int) -> str:
|
|
202
|
+
"""Format a byte count for compact human-facing output."""
|
|
203
|
+
if size_bytes < 1024:
|
|
204
|
+
return f"{size_bytes}B"
|
|
205
|
+
size = float(size_bytes)
|
|
206
|
+
for unit in ("KB", "MB", "GB", "TB"):
|
|
207
|
+
size /= 1024.0
|
|
208
|
+
if size < 1024 or unit == "TB":
|
|
209
|
+
return f"{size:.1f}{unit}"
|
|
210
|
+
return f"{size_bytes}B"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def format_count(value: int, singular: str, plural: str | None = None) -> str:
|
|
214
|
+
"""Format a singular/plural count phrase for status messages."""
|
|
215
|
+
if value == 1:
|
|
216
|
+
return f"1 {singular}"
|
|
217
|
+
return f"{value} {plural or singular + 's'}"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def list_directory_entries(fs: WorkspaceFS, root: Path) -> list[dict[str, Any]]:
|
|
221
|
+
"""Collect directory entries up to the shared view depth limit."""
|
|
222
|
+
entries: list[dict[str, Any]] = []
|
|
223
|
+
_walk_directory(fs, root, root, depth=1, entries=entries)
|
|
224
|
+
return sorted(entries, key=lambda entry: str(entry["path"]))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _walk_directory(
|
|
228
|
+
fs: WorkspaceFS,
|
|
229
|
+
root: Path,
|
|
230
|
+
current: Path,
|
|
231
|
+
*,
|
|
232
|
+
depth: int,
|
|
233
|
+
entries: list[dict[str, Any]],
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Recursively collect workspace-safe directory entries for tree rendering."""
|
|
236
|
+
if depth > _MAX_VIEW_DEPTH:
|
|
237
|
+
return
|
|
238
|
+
for child in sorted(current.iterdir(), key=lambda item: item.name):
|
|
239
|
+
if _should_skip_name(child.name):
|
|
240
|
+
continue
|
|
241
|
+
if not _is_within_workspace(child.resolve(strict=False), fs.root):
|
|
242
|
+
continue
|
|
243
|
+
rel_path = child.relative_to(root).as_posix()
|
|
244
|
+
kind = "directory" if child.is_dir() else "file"
|
|
245
|
+
entries.append({"path": rel_path, "kind": kind, "depth": depth})
|
|
246
|
+
if child.is_dir():
|
|
247
|
+
_walk_directory(fs, root, child, depth=depth + 1, entries=entries)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _should_skip_name(name: str) -> bool:
|
|
251
|
+
"""Return whether a file or directory should be hidden from tool output."""
|
|
252
|
+
return name.startswith(".") or name in _SKIP_NAMES
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _is_within_workspace(path: Path, root: Path) -> bool:
|
|
256
|
+
"""Return whether a resolved path stays under the workspace root."""
|
|
257
|
+
return path.is_relative_to(root)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def is_probably_binary(data: bytes) -> bool:
|
|
261
|
+
"""Heuristically reject binary files before attempting text rendering."""
|
|
262
|
+
if not data:
|
|
263
|
+
return False
|
|
264
|
+
sample = data[:_BINARY_CHECK_SIZE]
|
|
265
|
+
if b"\x00" in sample:
|
|
266
|
+
return True
|
|
267
|
+
disallowed_controls = sum(
|
|
268
|
+
1 for byte in sample if byte < 32 and byte not in _ALLOWED_TEXT_CONTROL_BYTES
|
|
269
|
+
)
|
|
270
|
+
return (disallowed_controls / len(sample)) > 0.05
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def classify_counted_character(char: str) -> str | None:
|
|
274
|
+
"""Classify a character according to the platform-aligned count heuristic."""
|
|
275
|
+
if char.isspace():
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
code_point = ord(char)
|
|
279
|
+
# Some scripts and punctuation are intentionally excluded because downstream
|
|
280
|
+
# product metrics do not count them toward the "word_count" surfaced to users.
|
|
281
|
+
if _is_in_code_point_ranges(code_point, _EXCLUDED_PLATFORM_COUNT_RANGES):
|
|
282
|
+
return None
|
|
283
|
+
if 0x21 <= code_point <= 0x7E:
|
|
284
|
+
return "ascii_visible"
|
|
285
|
+
if _is_in_code_point_ranges(code_point, _COUNTED_CJK_IDEOGRAPH_RANGES):
|
|
286
|
+
return "cjk_ideograph"
|
|
287
|
+
if _is_in_code_point_ranges(code_point, _COUNTED_CJK_PUNCTUATION_RANGES):
|
|
288
|
+
return "cjk_punctuation"
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def count_text_metrics(text: str) -> dict[str, int]:
|
|
293
|
+
"""Compute the text metrics returned by filesystem-oriented tools."""
|
|
294
|
+
ascii_visible_char_count = 0
|
|
295
|
+
cjk_ideograph_count = 0
|
|
296
|
+
cjk_punctuation_count = 0
|
|
297
|
+
for char in text:
|
|
298
|
+
category = classify_counted_character(char)
|
|
299
|
+
if category == "ascii_visible":
|
|
300
|
+
ascii_visible_char_count += 1
|
|
301
|
+
elif category == "cjk_ideograph":
|
|
302
|
+
cjk_ideograph_count += 1
|
|
303
|
+
elif category == "cjk_punctuation":
|
|
304
|
+
cjk_punctuation_count += 1
|
|
305
|
+
lines = split_lines(text)
|
|
306
|
+
# The exposed "word_count" matches product expectations rather than natural
|
|
307
|
+
# language tokenization: ASCII-visible characters and selected CJK code
|
|
308
|
+
# points count individually, while unsupported scripts are tracked separately.
|
|
309
|
+
word_count = (
|
|
310
|
+
ascii_visible_char_count + cjk_ideograph_count + cjk_punctuation_count
|
|
311
|
+
)
|
|
312
|
+
non_whitespace_char_count = sum(1 for char in text if not char.isspace())
|
|
313
|
+
return {
|
|
314
|
+
"word_count": word_count,
|
|
315
|
+
"char_count": len(text),
|
|
316
|
+
"non_whitespace_char_count": non_whitespace_char_count,
|
|
317
|
+
"line_count": len(lines),
|
|
318
|
+
"paragraph_count": sum(1 for line in lines if line.strip()),
|
|
319
|
+
"unsupported_non_whitespace_char_count": non_whitespace_char_count - word_count,
|
|
320
|
+
"ascii_visible_char_count": ascii_visible_char_count,
|
|
321
|
+
"cjk_ideograph_count": cjk_ideograph_count,
|
|
322
|
+
"cjk_punctuation_count": cjk_punctuation_count,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _is_in_code_point_ranges(
|
|
327
|
+
code_point: int, ranges: tuple[tuple[int, int], ...]
|
|
328
|
+
) -> bool:
|
|
329
|
+
"""Return whether ``code_point`` falls inside any inclusive range."""
|
|
330
|
+
return any(start <= code_point <= end for start, end in ranges)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""create_file tool implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agentkit.tools.base import FunctionTool, Tool
|
|
8
|
+
from agentkit.tools.types import ToolInvocation
|
|
9
|
+
from agentkit.workspace.fs import WorkspaceFS
|
|
10
|
+
|
|
11
|
+
from ._fs_common import (
|
|
12
|
+
count_text_metrics,
|
|
13
|
+
error_payload,
|
|
14
|
+
format_count,
|
|
15
|
+
format_human_size,
|
|
16
|
+
format_path_workspace_error,
|
|
17
|
+
path_details,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_create_file_tool(fs: WorkspaceFS) -> Tool:
|
|
22
|
+
"""Build the workspace-bound ``create_file`` tool."""
|
|
23
|
+
return FunctionTool(
|
|
24
|
+
name="create_file",
|
|
25
|
+
description=(
|
|
26
|
+
"Create a new file or completely overwrite an existing file with the provided text content.\n"
|
|
27
|
+
"\n"
|
|
28
|
+
"USE THIS TOOL WHEN you need to:\n"
|
|
29
|
+
"- Create a brand-new document: a draft, outline, translation, summary, report, etc.\n"
|
|
30
|
+
"- Completely rewrite an existing file when the changes are so extensive that making "
|
|
31
|
+
"individual edits with `str_replace` would be impractical.\n"
|
|
32
|
+
"\n"
|
|
33
|
+
"BEHAVIOR:\n"
|
|
34
|
+
"- Writes the exact content of `file_text` as UTF-8. Parent directories are created automatically.\n"
|
|
35
|
+
"- If the file already exists, it is FULLY OVERWRITTEN — all previous content is lost.\n"
|
|
36
|
+
"- Returns file metrics after writing, including line count and platform word count.\n"
|
|
37
|
+
"\n"
|
|
38
|
+
"IMPORTANT GUIDELINES:\n"
|
|
39
|
+
"- PREFER `str_replace` over this tool when only a localized revision is needed. "
|
|
40
|
+
"Overwriting an entire document to change a few sentences risks introducing unintended changes.\n"
|
|
41
|
+
"- The content in `file_text` must be COMPLETE and ready to use. "
|
|
42
|
+
"Do not leave placeholders like '[TODO]' or '[continue here]' unless the user explicitly asks for them.\n"
|
|
43
|
+
"\n"
|
|
44
|
+
"LIMITATIONS:\n"
|
|
45
|
+
"- Only UTF-8 text is supported.\n"
|
|
46
|
+
"- The path must resolve to a location inside the workspace."
|
|
47
|
+
),
|
|
48
|
+
parameters={
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {
|
|
51
|
+
"description": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": (
|
|
54
|
+
"A short explanation of WHY you are creating or overwriting this file. Be specific. "
|
|
55
|
+
"Good: 'Create the first draft of the project proposal based on the outline'. "
|
|
56
|
+
"Bad: 'create file'."
|
|
57
|
+
),
|
|
58
|
+
},
|
|
59
|
+
"path": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"description": (
|
|
62
|
+
"Relative path (from workspace root) for the file to create or overwrite. "
|
|
63
|
+
"Parent directories will be created if they don't exist. "
|
|
64
|
+
"Examples: 'chapters/03.md', 'report-v2.md'."
|
|
65
|
+
),
|
|
66
|
+
},
|
|
67
|
+
"file_text": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"description": (
|
|
70
|
+
"The COMPLETE text content to write into the file. "
|
|
71
|
+
"This must be the full desired file content — not a partial fragment or a diff. "
|
|
72
|
+
"Include proper formatting, paragraph breaks, and structure as appropriate."
|
|
73
|
+
),
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
"required": ["description", "path", "file_text"],
|
|
77
|
+
"additionalProperties": False,
|
|
78
|
+
},
|
|
79
|
+
handler=lambda args: _create_file(fs, args),
|
|
80
|
+
success_formatter=_format_create_file_output,
|
|
81
|
+
error_formatter=_format_create_file_error,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _create_file(fs: WorkspaceFS, args: dict[str, Any]) -> dict[str, Any]:
|
|
86
|
+
"""Create or overwrite a text file and return post-write metrics."""
|
|
87
|
+
path = args["path"]
|
|
88
|
+
file_text = args["file_text"]
|
|
89
|
+
target = fs.resolve_path(path)
|
|
90
|
+
previous_meta: dict[str, Any] | None = None
|
|
91
|
+
# Preserve the previous file size/line count so the model can see that an
|
|
92
|
+
# overwrite replaced existing content rather than creating a new file.
|
|
93
|
+
if target.exists() and target.is_file():
|
|
94
|
+
previous_bytes = target.read_bytes()
|
|
95
|
+
previous_meta = {
|
|
96
|
+
"line_count": len(previous_bytes.splitlines()) if previous_bytes else 0,
|
|
97
|
+
"size_bytes": len(previous_bytes),
|
|
98
|
+
}
|
|
99
|
+
fs.write_text(path, file_text, overwrite=True)
|
|
100
|
+
size_bytes = len(file_text.encode("utf-8"))
|
|
101
|
+
metrics = count_text_metrics(file_text)
|
|
102
|
+
return {
|
|
103
|
+
"path": path,
|
|
104
|
+
"chars_written": len(file_text),
|
|
105
|
+
"line_count": metrics["line_count"],
|
|
106
|
+
"word_count": metrics["word_count"],
|
|
107
|
+
"size_bytes": size_bytes,
|
|
108
|
+
"operation": "overwritten" if previous_meta is not None else "created",
|
|
109
|
+
"previous_file": previous_meta,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _format_create_file_output(output: Any, invocation: ToolInvocation) -> Any:
|
|
114
|
+
"""Render a concise summary of the create or overwrite operation."""
|
|
115
|
+
del invocation
|
|
116
|
+
if not isinstance(output, dict):
|
|
117
|
+
return output
|
|
118
|
+
|
|
119
|
+
path = output.get("path")
|
|
120
|
+
line_count = output.get("line_count")
|
|
121
|
+
size_bytes = output.get("size_bytes")
|
|
122
|
+
word_count = output.get("word_count")
|
|
123
|
+
operation = output.get("operation")
|
|
124
|
+
previous_file = output.get("previous_file")
|
|
125
|
+
if not (
|
|
126
|
+
isinstance(path, str)
|
|
127
|
+
and isinstance(line_count, int)
|
|
128
|
+
and isinstance(size_bytes, int)
|
|
129
|
+
and isinstance(word_count, int)
|
|
130
|
+
and operation in {"created", "overwritten"}
|
|
131
|
+
):
|
|
132
|
+
return {"output": output}
|
|
133
|
+
|
|
134
|
+
prefix = "File created" if operation == "created" else "File overwritten"
|
|
135
|
+
message = (
|
|
136
|
+
f"{prefix}: {path} "
|
|
137
|
+
f"({format_count(line_count, 'line')}, {format_human_size(size_bytes)}, "
|
|
138
|
+
f"{format_count(word_count, 'word')})"
|
|
139
|
+
)
|
|
140
|
+
if operation == "overwritten" and isinstance(previous_file, dict):
|
|
141
|
+
previous_line_count = previous_file.get("line_count")
|
|
142
|
+
previous_size_bytes = previous_file.get("size_bytes")
|
|
143
|
+
if isinstance(previous_line_count, int) and isinstance(
|
|
144
|
+
previous_size_bytes, int
|
|
145
|
+
):
|
|
146
|
+
message += (
|
|
147
|
+
"\n"
|
|
148
|
+
f"Note: Previous file ({format_count(previous_line_count, 'line')}, "
|
|
149
|
+
f"{format_human_size(previous_size_bytes)}) was replaced."
|
|
150
|
+
)
|
|
151
|
+
return message
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _format_create_file_error(
|
|
155
|
+
error: Exception, invocation: ToolInvocation
|
|
156
|
+
) -> dict[str, Any]:
|
|
157
|
+
"""Translate ``create_file`` failures into stable model-facing errors."""
|
|
158
|
+
common = format_path_workspace_error(
|
|
159
|
+
error,
|
|
160
|
+
invocation,
|
|
161
|
+
missing_message="The target path could not be prepared inside the workspace.",
|
|
162
|
+
missing_hint="Choose a writable path inside the workspace.",
|
|
163
|
+
)
|
|
164
|
+
if common is not None:
|
|
165
|
+
return common
|
|
166
|
+
return error_payload(
|
|
167
|
+
"create_file_failed", str(error), details=path_details(invocation)
|
|
168
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Workspace-scoped filesystem tool bundle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agentkit.tools.base import Tool
|
|
6
|
+
from agentkit.workspace.fs import WorkspaceFS
|
|
7
|
+
|
|
8
|
+
from .create_file import build_create_file_tool
|
|
9
|
+
from .str_replace import build_str_replace_tool
|
|
10
|
+
from .view import build_view_tool
|
|
11
|
+
from .word_count import build_word_count_tool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_tools(fs: WorkspaceFS) -> list[Tool]:
|
|
15
|
+
"""Create filesystem tools scoped to the workspace."""
|
|
16
|
+
return [
|
|
17
|
+
build_view_tool(fs),
|
|
18
|
+
build_create_file_tool(fs),
|
|
19
|
+
build_str_replace_tool(fs),
|
|
20
|
+
build_word_count_tool(fs),
|
|
21
|
+
]
|