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.
Files changed (51) hide show
  1. agentkit/__init__.py +35 -0
  2. agentkit/agent/__init__.py +7 -0
  3. agentkit/agent/agent.py +368 -0
  4. agentkit/agent/budgets.py +48 -0
  5. agentkit/agent/report.py +166 -0
  6. agentkit/agent/tool_runtime.py +77 -0
  7. agentkit/cli/__init__.py +5 -0
  8. agentkit/cli/main.py +108 -0
  9. agentkit/config/__init__.py +23 -0
  10. agentkit/config/loader.py +108 -0
  11. agentkit/config/provider_defaults.py +96 -0
  12. agentkit/config/schema.py +148 -0
  13. agentkit/constants.py +21 -0
  14. agentkit/errors.py +58 -0
  15. agentkit/llm/__init__.py +53 -0
  16. agentkit/llm/base.py +36 -0
  17. agentkit/llm/factory.py +27 -0
  18. agentkit/llm/providers/__init__.py +15 -0
  19. agentkit/llm/providers/anthropic_provider.py +371 -0
  20. agentkit/llm/providers/gemini_provider.py +396 -0
  21. agentkit/llm/providers/openai_provider.py +881 -0
  22. agentkit/llm/providers/qwen_provider.py +34 -0
  23. agentkit/llm/providers/vllm_provider.py +47 -0
  24. agentkit/llm/types.py +215 -0
  25. agentkit/llm/usage.py +72 -0
  26. agentkit/py.typed +0 -0
  27. agentkit/runlog/__init__.py +15 -0
  28. agentkit/runlog/events.py +67 -0
  29. agentkit/runlog/jsonl.py +90 -0
  30. agentkit/runlog/recorder.py +94 -0
  31. agentkit/runlog/sinks.py +15 -0
  32. agentkit/tools/__init__.py +16 -0
  33. agentkit/tools/base.py +139 -0
  34. agentkit/tools/library/__init__.py +8 -0
  35. agentkit/tools/library/_fs_common.py +330 -0
  36. agentkit/tools/library/create_file.py +168 -0
  37. agentkit/tools/library/fs_tools.py +21 -0
  38. agentkit/tools/library/str_replace.py +241 -0
  39. agentkit/tools/library/view.py +372 -0
  40. agentkit/tools/library/word_count.py +138 -0
  41. agentkit/tools/loader.py +81 -0
  42. agentkit/tools/registry.py +284 -0
  43. agentkit/tools/types.py +98 -0
  44. agentkit/workspace/__init__.py +6 -0
  45. agentkit/workspace/fs.py +288 -0
  46. agentkit/workspace/layout.py +33 -0
  47. base_agentkit-0.1.0.dist-info/METADATA +142 -0
  48. base_agentkit-0.1.0.dist-info/RECORD +51 -0
  49. base_agentkit-0.1.0.dist-info/WHEEL +4 -0
  50. base_agentkit-0.1.0.dist-info/entry_points.txt +3 -0
  51. base_agentkit-0.1.0.dist-info/licenses/LICENSE +183 -0
@@ -0,0 +1,241 @@
1
+ """str_replace tool implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from agentkit.errors import WorkspaceError
8
+ from agentkit.tools.base import FunctionTool, Tool
9
+ from agentkit.tools.types import ToolInvocation
10
+ from agentkit.workspace.fs import WorkspaceFS
11
+
12
+ from ._fs_common import (
13
+ error_payload,
14
+ format_numbered_lines,
15
+ format_path_workspace_error,
16
+ path_details,
17
+ read_utf8_text_for_edit,
18
+ split_lines,
19
+ )
20
+
21
+ _SNIPPET_CONTEXT_LINES = 2
22
+
23
+
24
+ def build_str_replace_tool(fs: WorkspaceFS) -> Tool:
25
+ """Build the workspace-bound ``str_replace`` tool."""
26
+ return FunctionTool(
27
+ name="str_replace",
28
+ description=(
29
+ "Replace a single, unique occurrence of a text string in an existing workspace file.\n"
30
+ "\n"
31
+ "USE THIS TOOL WHEN you need to:\n"
32
+ "- Revise a specific passage: rephrase a paragraph, tighten wording, fix an error, "
33
+ "adjust tone, improve clarity, etc.\n"
34
+ "- Insert new content at a precise location by including surrounding text in `old_str` "
35
+ "and adding the new material in `new_str`.\n"
36
+ '- Delete a passage by replacing it with an empty string (omit `new_str` or set it to "").\n'
37
+ "\n"
38
+ "THIS IS THE PREFERRED TOOL for revising existing documents. "
39
+ "Use it instead of `create_file` whenever the change is localized.\n"
40
+ "\n"
41
+ "BEHAVIOR:\n"
42
+ "- Searches the file for `old_str` and replaces it with `new_str` (default: empty string).\n"
43
+ "- The match must be EXACT (including whitespace, punctuation, and line breaks) and UNIQUE "
44
+ "(appears exactly once in the file).\n"
45
+ "\n"
46
+ "IMPORTANT GUIDELINES:\n"
47
+ "- ALWAYS `view` the relevant section of the file first to get the exact current text. "
48
+ "Do not rely on memory — the content may have changed.\n"
49
+ "- If the text you want to replace appears more than once, "
50
+ "include more surrounding context in `old_str` to make it unique.\n"
51
+ "- Ensure `new_str` reads naturally in context: maintain consistent style, voice, "
52
+ "formatting, and terminology with the surrounding text.\n"
53
+ "\n"
54
+ "LIMITATIONS:\n"
55
+ "- Only works on UTF-8 text files.\n"
56
+ "- Exactly one occurrence of `old_str` must exist; zero or multiple matches cause an error."
57
+ ),
58
+ parameters={
59
+ "type": "object",
60
+ "properties": {
61
+ "description": {
62
+ "type": "string",
63
+ "description": (
64
+ "A short explanation of WHAT you are changing and WHY. Be specific. "
65
+ "Good: 'Rephrase the opening paragraph to make the hook more compelling'. "
66
+ "Bad: 'edit text'."
67
+ ),
68
+ },
69
+ "path": {
70
+ "type": "string",
71
+ "description": (
72
+ "Relative path (from workspace root) to the file to edit. "
73
+ "The file must already exist and be valid UTF-8. "
74
+ "Example: 'draft.md', 'chapters/05.md'."
75
+ ),
76
+ },
77
+ "old_str": {
78
+ "type": "string",
79
+ "description": (
80
+ "The exact text to find and replace. Must appear EXACTLY ONCE in the file. "
81
+ "Copy this verbatim from the file content (use `view` to get it). "
82
+ "Include enough surrounding context to ensure uniqueness. "
83
+ "Whitespace, punctuation, and line breaks must match exactly."
84
+ ),
85
+ },
86
+ "new_str": {
87
+ "type": "string",
88
+ "description": (
89
+ "The text to replace `old_str` with. "
90
+ "Omit or set to empty string to DELETE the matched passage. "
91
+ "Ensure the replacement fits naturally into the surrounding text."
92
+ ),
93
+ },
94
+ },
95
+ "required": ["description", "path", "old_str"],
96
+ "additionalProperties": False,
97
+ },
98
+ handler=lambda args: _str_replace(fs, args),
99
+ success_formatter=_format_str_replace_output,
100
+ error_formatter=_format_str_replace_error,
101
+ )
102
+
103
+
104
+ def _str_replace(fs: WorkspaceFS, args: dict[str, Any]) -> dict[str, Any]:
105
+ """Replace one unique string occurrence and return an updated snippet."""
106
+ path = args["path"]
107
+ old_str = args["old_str"]
108
+ new_str = args.get("new_str", "")
109
+ if old_str == "":
110
+ raise WorkspaceError("old_str must not be empty.")
111
+ content = read_utf8_text_for_edit(fs, path)
112
+ occurrences = content.count(old_str)
113
+ if occurrences == 0:
114
+ raise WorkspaceError(f"String not found in file: {old_str!r}")
115
+ if occurrences > 1:
116
+ raise WorkspaceError(f"String is not unique in file: {old_str!r}")
117
+ replacement_start = content.index(old_str)
118
+ updated_content = content.replace(old_str, new_str, 1)
119
+ fs.write_text(path, updated_content, overwrite=True)
120
+ snippet = _build_updated_snippet(updated_content, replacement_start, len(new_str))
121
+ return {
122
+ "path": path,
123
+ "replacements": 1,
124
+ "snippet_start_line": snippet["start_line"],
125
+ "snippet_end_line": snippet["end_line"],
126
+ "snippet": snippet["content"],
127
+ }
128
+
129
+
130
+ def _format_str_replace_output(output: Any, invocation: ToolInvocation) -> Any:
131
+ """Render the edit result with a contextual snippet when possible."""
132
+ del invocation
133
+ if not isinstance(output, dict):
134
+ return output
135
+
136
+ path = output.get("path")
137
+ snippet = output.get("snippet")
138
+ start_line = output.get("snippet_start_line")
139
+ end_line = output.get("snippet_end_line")
140
+ if not isinstance(path, str):
141
+ return {"output": output}
142
+ if not isinstance(snippet, str) or not snippet:
143
+ return f"The file {path} has been edited. The file is now empty."
144
+ return (
145
+ f"The file {path} has been edited. "
146
+ f"Here is a numbered snippet of the updated file around the edit "
147
+ f"({_format_line_range(start_line, end_line)}):\n"
148
+ f"{snippet}"
149
+ )
150
+
151
+
152
+ def _build_updated_snippet(
153
+ text: str, replacement_start: int, replacement_length: int
154
+ ) -> dict[str, Any]:
155
+ """Build a small numbered snippet around the replacement site."""
156
+ lines = split_lines(text)
157
+ if not lines:
158
+ return {"start_line": 0, "end_line": 0, "content": ""}
159
+
160
+ start_line = _line_number_for_offset(text, replacement_start)
161
+ end_line = _line_number_for_offset(text, replacement_start + replacement_length)
162
+ context_start_line = max(1, start_line - _SNIPPET_CONTEXT_LINES)
163
+ context_end_line = min(len(lines), end_line + _SNIPPET_CONTEXT_LINES)
164
+ snippet_lines = lines[context_start_line - 1 : context_end_line]
165
+ return {
166
+ "start_line": context_start_line,
167
+ "end_line": context_end_line,
168
+ "content": format_numbered_lines(
169
+ snippet_lines,
170
+ start_line=context_start_line,
171
+ total_lines=len(lines),
172
+ ),
173
+ }
174
+
175
+
176
+ def _line_number_for_offset(text: str, offset: int) -> int:
177
+ """Map a string offset back to its 1-based line number."""
178
+ if not text:
179
+ return 0
180
+ bounded = max(0, min(offset, len(text)))
181
+ if bounded == len(text) and bounded > 0:
182
+ bounded -= 1
183
+ return text.count("\n", 0, bounded) + 1
184
+
185
+
186
+ def _format_line_range(start_line: Any, end_line: Any) -> str:
187
+ """Render a human-readable line range for status messages."""
188
+ if not isinstance(start_line, int) or not isinstance(end_line, int):
189
+ return "updated file"
190
+ if start_line <= 0 or end_line <= 0:
191
+ return "updated file"
192
+ if start_line == end_line:
193
+ return f"line {start_line}"
194
+ return f"lines {start_line}-{end_line}"
195
+
196
+
197
+ def _format_str_replace_error(
198
+ error: Exception, invocation: ToolInvocation
199
+ ) -> dict[str, Any]:
200
+ """Translate ``str_replace`` failures into stable model-facing errors."""
201
+ common = format_path_workspace_error(
202
+ error,
203
+ invocation,
204
+ missing_message="The file to edit does not exist in the workspace.",
205
+ missing_hint="Call `view` first to confirm the file path, then retry `str_replace`.",
206
+ not_file_message="str_replace only works on existing files.",
207
+ )
208
+ if common is not None:
209
+ return common
210
+
211
+ message = str(error)
212
+ details = path_details(invocation)
213
+ if message == "old_str must not be empty.":
214
+ return error_payload(
215
+ "empty_old_str",
216
+ "old_str must not be empty.",
217
+ hint="Copy the exact text you want to replace from the file.",
218
+ details=details,
219
+ )
220
+ if message.startswith("File is not valid UTF-8 text: "):
221
+ return error_payload(
222
+ "non_utf8_file",
223
+ "The target file is not valid UTF-8 text.",
224
+ hint="Use a UTF-8 text file, or convert the file before retrying.",
225
+ details=details,
226
+ )
227
+ if message.startswith("String not found in file: "):
228
+ return error_payload(
229
+ "string_not_found",
230
+ "old_str was not found in the file.",
231
+ hint="Call `view` again and copy the exact current text, including whitespace and punctuation.",
232
+ details=details,
233
+ )
234
+ if message.startswith("String is not unique in file: "):
235
+ return error_payload(
236
+ "string_not_unique",
237
+ "old_str matches multiple locations in the file.",
238
+ hint="Include more surrounding context in old_str so it matches exactly one location.",
239
+ details=details,
240
+ )
241
+ return error_payload("str_replace_failed", message, details=details)
@@ -0,0 +1,372 @@
1
+ """View tool implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from agentkit.errors import WorkspaceError
8
+ from agentkit.tools.base import FunctionTool, Tool
9
+ from agentkit.tools.types import ToolInvocation
10
+ from agentkit.workspace.fs import WorkspaceFS
11
+
12
+ from ._fs_common import (
13
+ decode_text_for_view,
14
+ error_payload,
15
+ format_count,
16
+ format_human_size,
17
+ format_numbered_lines,
18
+ format_path_workspace_error,
19
+ is_probably_binary,
20
+ list_directory_entries,
21
+ normalize_view_range,
22
+ path_details,
23
+ split_lines,
24
+ )
25
+
26
+ _MAX_VIEW_MODEL_LINES = 250
27
+ _MAX_VIEW_DIRECTORY_ENTRIES = 50
28
+
29
+
30
+ def build_view_tool(fs: WorkspaceFS) -> Tool:
31
+ """Build the workspace-bound ``view`` tool."""
32
+ return FunctionTool(
33
+ name="view",
34
+ description=(
35
+ "Read the content of a file or list the structure of a directory within the workspace.\n"
36
+ "\n"
37
+ "USE THIS TOOL WHEN you need to:\n"
38
+ "- Read any text file: drafts, articles, outlines, notes, config files, etc.\n"
39
+ "- Explore the workspace folder structure to understand how materials are organized.\n"
40
+ "- Re-read a specific section of a long document before making edits or continuing work.\n"
41
+ "\n"
42
+ "BEHAVIOR:\n"
43
+ "- For FILES: returns the content with right-aligned 1-based line numbers prefixed "
44
+ "to each line (e.g. ' 1: ...', '12: ...'). UTF-8 is natively supported; non-UTF-8 "
45
+ "bytes are escaped.\n"
46
+ " Use `view_range` to read only a specific section — recommended for long documents.\n"
47
+ "- For DIRECTORIES: returns a sorted list of entries (files and subdirectories) up to "
48
+ "2 levels deep. Hidden files (dotfiles) are excluded.\n"
49
+ "\n"
50
+ "IMPORTANT GUIDELINES:\n"
51
+ "- ALWAYS read the relevant section of a file before editing it. "
52
+ "Do not rely on memory — the file may have changed since you last saw it.\n"
53
+ "- For long documents, prefer reading specific line ranges rather than the entire file.\n"
54
+ "\n"
55
+ "LIMITATIONS:\n"
56
+ "- The path must exist inside the workspace; otherwise an error is raised.\n"
57
+ "- Symlinks that escape the workspace boundary are silently skipped in directory listings."
58
+ ),
59
+ parameters={
60
+ "type": "object",
61
+ "properties": {
62
+ "description": {
63
+ "type": "string",
64
+ "description": (
65
+ "A short explanation of WHY you are viewing this path. Be specific. "
66
+ "Good: 'Re-read the conclusion section before revising it'. "
67
+ "Bad: 'view file'."
68
+ ),
69
+ },
70
+ "path": {
71
+ "type": "string",
72
+ "description": (
73
+ "Relative path (from workspace root) to the file or directory to view. "
74
+ "Examples: 'draft.md', 'chapters/03.md', 'notes/'. "
75
+ "Must point to an existing file or directory."
76
+ ),
77
+ },
78
+ "view_range": {
79
+ "type": "array",
80
+ "description": (
81
+ "Optional. A two-element array [start_line, end_line] to read only a portion of a file. "
82
+ "Both values are 1-based and inclusive. Use -1 for end_line to read through the end of file. "
83
+ "Only valid for files (not directories). Omit to read the entire file. "
84
+ "Example: [50, 100] reads lines 50–100; [200, -1] reads from line 200 to the end."
85
+ ),
86
+ },
87
+ },
88
+ "required": ["description", "path"],
89
+ "additionalProperties": False,
90
+ },
91
+ handler=lambda args: _view(fs, args),
92
+ success_formatter=_format_view_output,
93
+ error_formatter=_format_view_error,
94
+ )
95
+
96
+
97
+ def _view(fs: WorkspaceFS, args: dict[str, Any]) -> dict[str, Any]:
98
+ """Return normalized file content or directory entries for a workspace path."""
99
+ path = args["path"]
100
+ target = fs.resolve_path(path)
101
+ if not target.exists():
102
+ raise WorkspaceError(f"Path does not exist: {path}")
103
+ if target.is_dir():
104
+ entries = list_directory_entries(fs, target)
105
+ return {
106
+ "path": path,
107
+ "kind": "directory",
108
+ "entries": entries,
109
+ "directory_count": sum(1 for entry in entries if entry["kind"] == "directory"),
110
+ "file_count": sum(1 for entry in entries if entry["kind"] == "file"),
111
+ }
112
+ if not target.is_file():
113
+ raise WorkspaceError(f"Not a file or directory: {path}")
114
+ data = target.read_bytes()
115
+ if is_probably_binary(data):
116
+ raise WorkspaceError(
117
+ f"File appears to be binary and cannot be displayed as text: {path}"
118
+ )
119
+ text, encoding = decode_text_for_view(data)
120
+ lines = split_lines(text)
121
+ start_line, end_line = normalize_view_range(args.get("view_range"), len(lines))
122
+ selected_lines = lines[start_line - 1 : end_line] if lines else []
123
+ content = format_numbered_lines(
124
+ selected_lines,
125
+ start_line=start_line,
126
+ total_lines=len(lines),
127
+ )
128
+ return {
129
+ "path": path,
130
+ "kind": "file",
131
+ "start_line": start_line if lines else 0,
132
+ "end_line": end_line if lines else 0,
133
+ "total_lines": len(lines),
134
+ "size_bytes": len(data),
135
+ "encoding": encoding,
136
+ "lines": selected_lines,
137
+ "content": content,
138
+ }
139
+
140
+
141
+ def _format_view_output(output: Any, invocation: ToolInvocation) -> Any:
142
+ """Convert raw ``view`` results into model-facing display text."""
143
+ if not isinstance(output, dict):
144
+ return output
145
+ kind = output.get("kind")
146
+ if kind == "file":
147
+ return _format_view_file_output(output, invocation)
148
+ if kind == "directory":
149
+ return _format_view_directory_output(output)
150
+ return {"output": output}
151
+
152
+
153
+ def _format_view_file_output(
154
+ output: dict[str, Any], invocation: ToolInvocation
155
+ ) -> str | dict[str, Any]:
156
+ """Render file output with headers, line numbers, and truncation notes."""
157
+ path = output.get("path")
158
+ total_lines = output.get("total_lines")
159
+ size_bytes = output.get("size_bytes")
160
+ start_line = output.get("start_line")
161
+ end_line = output.get("end_line")
162
+ lines = output.get("lines")
163
+ if not (
164
+ isinstance(path, str)
165
+ and isinstance(total_lines, int)
166
+ and isinstance(size_bytes, int)
167
+ and isinstance(start_line, int)
168
+ and isinstance(end_line, int)
169
+ and isinstance(lines, list)
170
+ and all(isinstance(line, str) for line in lines)
171
+ ):
172
+ return {"output": output}
173
+
174
+ requested_range = invocation.arguments.get("view_range") if isinstance(
175
+ invocation.arguments, dict
176
+ ) else None
177
+ is_range_view = requested_range is not None
178
+
179
+ if total_lines == 0:
180
+ header = f"File: {path} ({format_count(0, 'line')}, {format_human_size(size_bytes)})"
181
+ return f"{header}\n---\n(empty file)\n---"
182
+
183
+ displayed_lines = list(lines)
184
+ displayed_start = start_line
185
+ displayed_end = end_line
186
+ note: str | None = None
187
+ if len(displayed_lines) > _MAX_VIEW_MODEL_LINES:
188
+ # Keep the payload bounded so a large file view does not crowd out the
189
+ # rest of the conversation context. The note tells the model how to ask
190
+ # for a narrower slice.
191
+ displayed_lines = displayed_lines[:_MAX_VIEW_MODEL_LINES]
192
+ displayed_end = displayed_start + len(displayed_lines) - 1
193
+ if is_range_view:
194
+ note = (
195
+ f"(Showing lines {displayed_start}-{displayed_end} of requested range "
196
+ f"{start_line}-{end_line}. Use a smaller view_range to narrow the result.)"
197
+ )
198
+ else:
199
+ note = (
200
+ f"(Showing lines {displayed_start}-{displayed_end} of {total_lines}. "
201
+ "Use view with view_range to see more.)"
202
+ )
203
+ elif not is_range_view and total_lines > _MAX_VIEW_MODEL_LINES:
204
+ displayed_lines = displayed_lines[:_MAX_VIEW_MODEL_LINES]
205
+ displayed_end = displayed_start + len(displayed_lines) - 1
206
+ note = (
207
+ f"(Showing lines {displayed_start}-{displayed_end} of {total_lines}. "
208
+ "Use view with view_range to see more.)"
209
+ )
210
+
211
+ if is_range_view:
212
+ header = f"File: {path} | Lines {start_line}-{end_line} (of {total_lines})"
213
+ else:
214
+ header = (
215
+ f"File: {path} ({format_count(total_lines, 'line')}, "
216
+ f"{format_human_size(size_bytes)})"
217
+ )
218
+
219
+ body = format_numbered_lines(
220
+ displayed_lines,
221
+ start_line=displayed_start,
222
+ total_lines=total_lines,
223
+ )
224
+ if note:
225
+ return f"{header}\n---\n{body}\n---\n{note}"
226
+ return f"{header}\n---\n{body}\n---"
227
+
228
+
229
+ def _format_view_directory_output(output: dict[str, Any]) -> str | dict[str, Any]:
230
+ """Render directory listings as a compact tree with summary counts."""
231
+ path = output.get("path")
232
+ entries = output.get("entries")
233
+ directory_count = output.get("directory_count")
234
+ file_count = output.get("file_count")
235
+ if not (
236
+ isinstance(path, str)
237
+ and isinstance(entries, list)
238
+ and isinstance(directory_count, int)
239
+ and isinstance(file_count, int)
240
+ ):
241
+ return {"output": output}
242
+
243
+ displayed_entries = [
244
+ entry for entry in entries[:_MAX_VIEW_DIRECTORY_ENTRIES] if isinstance(entry, dict)
245
+ ]
246
+ tree = _format_directory_tree(path, displayed_entries)
247
+ header = (
248
+ f"Directory: {_display_directory_path(path)} "
249
+ f"({format_count(directory_count, 'subdirectory', 'subdirectories')}, "
250
+ f"{format_count(file_count, 'file')})"
251
+ )
252
+ if len(entries) > _MAX_VIEW_DIRECTORY_ENTRIES:
253
+ note = (
254
+ f"(Showing first {_MAX_VIEW_DIRECTORY_ENTRIES} entries of {len(entries)}. "
255
+ "Directory too large to display fully.)"
256
+ )
257
+ return f"{header}\n---\n{tree}\n{note}\n---"
258
+ return f"{header}\n---\n{tree}\n---"
259
+
260
+
261
+ def _format_directory_tree(path: str, entries: list[dict[str, Any]]) -> str:
262
+ """Convert flattened directory entries into a printable tree."""
263
+ root: dict[str, Any] = {"children": {}}
264
+ for entry in entries:
265
+ entry_path = entry.get("path")
266
+ kind = entry.get("kind")
267
+ if not isinstance(entry_path, str) or not isinstance(kind, str):
268
+ continue
269
+ parts = [part for part in entry_path.split("/") if part]
270
+ node = root
271
+ for index, part in enumerate(parts):
272
+ children = node.setdefault("children", {})
273
+ child = children.setdefault(part, {"kind": "directory", "children": {}})
274
+ if index == len(parts) - 1:
275
+ child["kind"] = kind
276
+ node = child
277
+
278
+ lines = [_display_directory_path(path)]
279
+ lines.extend(_render_tree_children(root.get("children", {}), prefix=""))
280
+ return "\n".join(lines)
281
+
282
+
283
+ def _render_tree_children(children: dict[str, Any], *, prefix: str) -> list[str]:
284
+ """Render one nested tree level using box-drawing characters."""
285
+ lines: list[str] = []
286
+ items = list(children.items())
287
+ for index, (name, node) in enumerate(items):
288
+ is_last = index == len(items) - 1
289
+ connector = "└──" if is_last else "├──"
290
+ kind = node.get("kind")
291
+ suffix = "/" if kind == "directory" else ""
292
+ lines.append(f"{prefix}{connector} {name}{suffix}")
293
+ child_children = node.get("children", {})
294
+ if isinstance(child_children, dict) and child_children:
295
+ next_prefix = prefix + (" " if is_last else "│ ")
296
+ lines.extend(_render_tree_children(child_children, prefix=next_prefix))
297
+ return lines
298
+
299
+
300
+ def _display_directory_path(path: str) -> str:
301
+ """Normalize the root label shown for directory listings."""
302
+ if path in {".", "./"}:
303
+ return "./"
304
+ return path if path.endswith("/") else f"{path}/"
305
+
306
+
307
+ def _format_view_error(error: Exception, invocation: ToolInvocation) -> dict[str, Any]:
308
+ """Translate ``view`` failures into stable model-facing errors."""
309
+ common = format_path_workspace_error(
310
+ error,
311
+ invocation,
312
+ missing_message="The requested path does not exist in the workspace.",
313
+ missing_hint="Call `view` again with an existing file or directory path.",
314
+ not_dir_or_file_message="The requested path is neither a regular file nor a directory.",
315
+ )
316
+ if common is not None:
317
+ return common
318
+
319
+ message = str(error)
320
+ details = path_details(invocation)
321
+ if message.startswith("File appears to be binary and cannot be displayed as text: "):
322
+ return error_payload(
323
+ "binary_file",
324
+ "This file appears to be binary and cannot be viewed as text.",
325
+ hint="Use a text file path, or choose a different tool if you only need file metadata.",
326
+ details=details,
327
+ )
328
+ if message == "Cannot apply view_range to an empty file.":
329
+ return error_payload(
330
+ "empty_file",
331
+ "The file is empty, so a line range cannot be applied.",
332
+ hint="Call `view` again without `view_range`, or choose a non-empty file.",
333
+ details=details,
334
+ )
335
+ if message.startswith("start_line ") and "is beyond the end of file" in message:
336
+ return error_payload(
337
+ "view_range_out_of_bounds",
338
+ message,
339
+ hint="Choose a start_line within the file's total line count.",
340
+ details=details,
341
+ )
342
+ if message == "view_range must be a two-item array: [start_line, end_line].":
343
+ return error_payload(
344
+ "invalid_view_range",
345
+ "view_range must be a two-item array: [start_line, end_line].",
346
+ hint="Provide both start_line and end_line, for example [10, 20].",
347
+ details=details,
348
+ )
349
+ if message == "view_range values must both be integers.":
350
+ return error_payload(
351
+ "invalid_view_range_type",
352
+ "view_range values must both be integers.",
353
+ hint="Use integer line numbers such as [1, 50].",
354
+ details=details,
355
+ )
356
+ if message == "start_line must be >= 1.":
357
+ return error_payload(
358
+ "invalid_view_range",
359
+ "start_line must be at least 1.",
360
+ hint="Use 1-based line numbers.",
361
+ details=details,
362
+ )
363
+ if message == "end_line must be -1 or >= start_line." or (
364
+ message.startswith("end_line ") and "must be -1 or >= start_line" in message
365
+ ):
366
+ return error_payload(
367
+ "invalid_view_range",
368
+ "end_line must be -1 or greater than or equal to start_line.",
369
+ hint="Use -1 to read to the end of the file, or choose an end_line after start_line.",
370
+ details=details,
371
+ )
372
+ return error_payload("view_failed", message, details=details)