klaude-code 1.2.6__py3-none-any.whl → 1.8.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.
- klaude_code/auth/__init__.py +24 -0
- klaude_code/auth/codex/__init__.py +20 -0
- klaude_code/auth/codex/exceptions.py +17 -0
- klaude_code/auth/codex/jwt_utils.py +45 -0
- klaude_code/auth/codex/oauth.py +229 -0
- klaude_code/auth/codex/token_manager.py +84 -0
- klaude_code/cli/auth_cmd.py +73 -0
- klaude_code/cli/config_cmd.py +91 -0
- klaude_code/cli/cost_cmd.py +338 -0
- klaude_code/cli/debug.py +78 -0
- klaude_code/cli/list_model.py +307 -0
- klaude_code/cli/main.py +233 -134
- klaude_code/cli/runtime.py +309 -117
- klaude_code/{version.py → cli/self_update.py} +114 -5
- klaude_code/cli/session_cmd.py +37 -21
- klaude_code/command/__init__.py +88 -27
- klaude_code/command/clear_cmd.py +8 -7
- klaude_code/command/command_abc.py +31 -31
- klaude_code/command/debug_cmd.py +79 -0
- klaude_code/command/export_cmd.py +19 -53
- klaude_code/command/export_online_cmd.py +154 -0
- klaude_code/command/fork_session_cmd.py +267 -0
- klaude_code/command/help_cmd.py +7 -8
- klaude_code/command/model_cmd.py +60 -10
- klaude_code/command/model_select.py +84 -0
- klaude_code/command/prompt-jj-describe.md +32 -0
- klaude_code/command/prompt_command.py +19 -11
- klaude_code/command/refresh_cmd.py +8 -10
- klaude_code/command/registry.py +139 -40
- klaude_code/command/release_notes_cmd.py +84 -0
- klaude_code/command/resume_cmd.py +111 -0
- klaude_code/command/status_cmd.py +104 -60
- klaude_code/command/terminal_setup_cmd.py +7 -9
- klaude_code/command/thinking_cmd.py +98 -0
- klaude_code/config/__init__.py +14 -6
- klaude_code/config/assets/__init__.py +1 -0
- klaude_code/config/assets/builtin_config.yaml +303 -0
- klaude_code/config/builtin_config.py +38 -0
- klaude_code/config/config.py +378 -109
- klaude_code/config/select_model.py +117 -53
- klaude_code/config/thinking.py +269 -0
- klaude_code/{const/__init__.py → const.py} +50 -19
- klaude_code/core/agent.py +20 -28
- klaude_code/core/executor.py +327 -112
- klaude_code/core/manager/__init__.py +2 -4
- klaude_code/core/manager/llm_clients.py +1 -15
- klaude_code/core/manager/llm_clients_builder.py +10 -11
- klaude_code/core/manager/sub_agent_manager.py +37 -6
- klaude_code/core/prompt.py +63 -44
- klaude_code/core/prompts/prompt-claude-code.md +2 -13
- klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
- klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
- klaude_code/core/prompts/prompt-codex.md +9 -42
- klaude_code/core/prompts/prompt-minimal.md +12 -0
- klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
- klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
- klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
- klaude_code/core/reminders.py +283 -95
- klaude_code/core/task.py +113 -75
- klaude_code/core/tool/__init__.py +24 -31
- klaude_code/core/tool/file/_utils.py +36 -0
- klaude_code/core/tool/file/apply_patch.py +17 -25
- klaude_code/core/tool/file/apply_patch_tool.py +57 -77
- klaude_code/core/tool/file/diff_builder.py +151 -0
- klaude_code/core/tool/file/edit_tool.py +50 -63
- klaude_code/core/tool/file/move_tool.md +41 -0
- klaude_code/core/tool/file/move_tool.py +435 -0
- klaude_code/core/tool/file/read_tool.md +1 -1
- klaude_code/core/tool/file/read_tool.py +86 -86
- klaude_code/core/tool/file/write_tool.py +59 -69
- klaude_code/core/tool/report_back_tool.py +84 -0
- klaude_code/core/tool/shell/bash_tool.py +265 -22
- klaude_code/core/tool/shell/command_safety.py +3 -6
- klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
- klaude_code/core/tool/sub_agent_tool.py +13 -2
- klaude_code/core/tool/todo/todo_write_tool.md +0 -157
- klaude_code/core/tool/todo/todo_write_tool.py +1 -1
- klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
- klaude_code/core/tool/todo/update_plan_tool.py +1 -1
- klaude_code/core/tool/tool_abc.py +18 -0
- klaude_code/core/tool/tool_context.py +27 -12
- klaude_code/core/tool/tool_registry.py +7 -7
- klaude_code/core/tool/tool_runner.py +44 -36
- klaude_code/core/tool/truncation.py +29 -14
- klaude_code/core/tool/web/mermaid_tool.md +43 -0
- klaude_code/core/tool/web/mermaid_tool.py +2 -5
- klaude_code/core/tool/web/web_fetch_tool.md +1 -1
- klaude_code/core/tool/web/web_fetch_tool.py +112 -22
- klaude_code/core/tool/web/web_search_tool.md +23 -0
- klaude_code/core/tool/web/web_search_tool.py +130 -0
- klaude_code/core/turn.py +168 -66
- klaude_code/llm/__init__.py +2 -10
- klaude_code/llm/anthropic/client.py +190 -178
- klaude_code/llm/anthropic/input.py +39 -15
- klaude_code/llm/bedrock/__init__.py +3 -0
- klaude_code/llm/bedrock/client.py +60 -0
- klaude_code/llm/client.py +7 -21
- klaude_code/llm/codex/__init__.py +5 -0
- klaude_code/llm/codex/client.py +149 -0
- klaude_code/llm/google/__init__.py +3 -0
- klaude_code/llm/google/client.py +309 -0
- klaude_code/llm/google/input.py +215 -0
- klaude_code/llm/input_common.py +3 -9
- klaude_code/llm/openai_compatible/client.py +72 -164
- klaude_code/llm/openai_compatible/input.py +6 -4
- klaude_code/llm/openai_compatible/stream.py +273 -0
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
- klaude_code/llm/openrouter/client.py +89 -160
- klaude_code/llm/openrouter/input.py +18 -30
- klaude_code/llm/openrouter/reasoning.py +118 -0
- klaude_code/llm/registry.py +39 -7
- klaude_code/llm/responses/client.py +184 -171
- klaude_code/llm/responses/input.py +20 -1
- klaude_code/llm/usage.py +17 -12
- klaude_code/protocol/commands.py +17 -1
- klaude_code/protocol/events.py +31 -4
- klaude_code/protocol/llm_param.py +13 -10
- klaude_code/protocol/model.py +232 -29
- klaude_code/protocol/op.py +90 -1
- klaude_code/protocol/op_handler.py +35 -1
- klaude_code/protocol/sub_agent/__init__.py +117 -0
- klaude_code/protocol/sub_agent/explore.py +63 -0
- klaude_code/protocol/sub_agent/oracle.py +91 -0
- klaude_code/protocol/sub_agent/task.py +61 -0
- klaude_code/protocol/sub_agent/web.py +79 -0
- klaude_code/protocol/tools.py +4 -2
- klaude_code/session/__init__.py +2 -2
- klaude_code/session/codec.py +71 -0
- klaude_code/session/export.py +293 -86
- klaude_code/session/selector.py +89 -67
- klaude_code/session/session.py +320 -309
- klaude_code/session/store.py +220 -0
- klaude_code/session/templates/export_session.html +595 -83
- klaude_code/session/templates/mermaid_viewer.html +926 -0
- klaude_code/skill/__init__.py +27 -0
- klaude_code/skill/assets/deslop/SKILL.md +17 -0
- klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
- klaude_code/skill/assets/handoff/SKILL.md +39 -0
- klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
- klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
- klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
- klaude_code/skill/manager.py +70 -0
- klaude_code/skill/system_skills.py +192 -0
- klaude_code/trace/__init__.py +20 -2
- klaude_code/trace/log.py +150 -5
- klaude_code/ui/__init__.py +4 -9
- klaude_code/ui/core/input.py +1 -1
- klaude_code/ui/core/stage_manager.py +7 -7
- klaude_code/ui/modes/debug/display.py +2 -1
- klaude_code/ui/modes/repl/__init__.py +3 -48
- klaude_code/ui/modes/repl/clipboard.py +5 -5
- klaude_code/ui/modes/repl/completers.py +487 -123
- klaude_code/ui/modes/repl/display.py +5 -4
- klaude_code/ui/modes/repl/event_handler.py +370 -117
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
- klaude_code/ui/modes/repl/key_bindings.py +146 -23
- klaude_code/ui/modes/repl/renderer.py +189 -99
- klaude_code/ui/renderers/assistant.py +9 -2
- klaude_code/ui/renderers/bash_syntax.py +178 -0
- klaude_code/ui/renderers/common.py +78 -0
- klaude_code/ui/renderers/developer.py +104 -48
- klaude_code/ui/renderers/diffs.py +87 -6
- klaude_code/ui/renderers/errors.py +11 -6
- klaude_code/ui/renderers/mermaid_viewer.py +57 -0
- klaude_code/ui/renderers/metadata.py +112 -76
- klaude_code/ui/renderers/sub_agent.py +92 -7
- klaude_code/ui/renderers/thinking.py +40 -18
- klaude_code/ui/renderers/tools.py +405 -227
- klaude_code/ui/renderers/user_input.py +73 -13
- klaude_code/ui/rich/__init__.py +10 -1
- klaude_code/ui/rich/cjk_wrap.py +228 -0
- klaude_code/ui/rich/code_panel.py +131 -0
- klaude_code/ui/rich/live.py +17 -0
- klaude_code/ui/rich/markdown.py +305 -170
- klaude_code/ui/rich/searchable_text.py +10 -13
- klaude_code/ui/rich/status.py +190 -49
- klaude_code/ui/rich/theme.py +135 -39
- klaude_code/ui/terminal/__init__.py +55 -0
- klaude_code/ui/terminal/color.py +1 -1
- klaude_code/ui/terminal/control.py +13 -22
- klaude_code/ui/terminal/notifier.py +44 -4
- klaude_code/ui/terminal/selector.py +658 -0
- klaude_code/ui/utils/common.py +0 -18
- klaude_code-1.8.0.dist-info/METADATA +377 -0
- klaude_code-1.8.0.dist-info/RECORD +219 -0
- {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
- klaude_code/command/diff_cmd.py +0 -138
- klaude_code/command/prompt-dev-docs-update.md +0 -56
- klaude_code/command/prompt-dev-docs.md +0 -46
- klaude_code/config/list_model.py +0 -162
- klaude_code/core/manager/agent_manager.py +0 -127
- klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
- klaude_code/core/tool/file/multi_edit_tool.md +0 -42
- klaude_code/core/tool/file/multi_edit_tool.py +0 -199
- klaude_code/core/tool/memory/memory_tool.md +0 -16
- klaude_code/core/tool/memory/memory_tool.py +0 -462
- klaude_code/llm/openrouter/reasoning_handler.py +0 -209
- klaude_code/protocol/sub_agent.py +0 -348
- klaude_code/ui/utils/debouncer.py +0 -42
- klaude_code-1.2.6.dist-info/METADATA +0 -178
- klaude_code-1.2.6.dist-info/RECORD +0 -167
- /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
- /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
- /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
- {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import hashlib
|
|
4
6
|
import os
|
|
5
7
|
from base64 import b64encode
|
|
6
8
|
from dataclasses import dataclass
|
|
@@ -9,17 +11,12 @@ from pathlib import Path
|
|
|
9
11
|
from pydantic import BaseModel, Field
|
|
10
12
|
|
|
11
13
|
from klaude_code import const
|
|
14
|
+
from klaude_code.core.tool.file._utils import file_exists, is_directory
|
|
12
15
|
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
13
16
|
from klaude_code.core.tool.tool_context import get_current_file_tracker
|
|
14
17
|
from klaude_code.core.tool.tool_registry import register
|
|
15
18
|
from klaude_code.protocol import llm_param, model, tools
|
|
16
19
|
|
|
17
|
-
SYSTEM_REMINDER_MALICIOUS = (
|
|
18
|
-
"<system-reminder>\n"
|
|
19
|
-
"Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.\n"
|
|
20
|
-
"</system-reminder>"
|
|
21
|
-
)
|
|
22
|
-
|
|
23
20
|
_IMAGE_MIME_TYPES: dict[str, str] = {
|
|
24
21
|
".png": "image/png",
|
|
25
22
|
".jpg": "image/jpeg",
|
|
@@ -28,24 +25,22 @@ _IMAGE_MIME_TYPES: dict[str, str] = {
|
|
|
28
25
|
".webp": "image/webp",
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
def _format_numbered_line(line_no: int, content: str) -> str:
|
|
33
|
-
# 6-width right-aligned line number followed by a right arrow
|
|
34
|
-
return f"{line_no:>6}→{content}"
|
|
28
|
+
_BINARY_CHECK_SIZE = 8192
|
|
35
29
|
|
|
36
30
|
|
|
37
|
-
def
|
|
31
|
+
def _is_binary_file(file_path: str) -> bool:
|
|
32
|
+
"""Check if a file is binary by looking for null bytes in the first chunk."""
|
|
38
33
|
try:
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
with open(file_path, "rb") as f:
|
|
35
|
+
chunk = f.read(_BINARY_CHECK_SIZE)
|
|
36
|
+
return b"\x00" in chunk
|
|
37
|
+
except OSError:
|
|
41
38
|
return False
|
|
42
39
|
|
|
43
40
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
except Exception:
|
|
48
|
-
return False
|
|
41
|
+
def _format_numbered_line(line_no: int, content: str) -> str:
|
|
42
|
+
# 6-width right-aligned line number followed by a right arrow
|
|
43
|
+
return f"{line_no:>6}→{content}"
|
|
49
44
|
|
|
50
45
|
|
|
51
46
|
@dataclass
|
|
@@ -55,6 +50,7 @@ class ReadOptions:
|
|
|
55
50
|
limit: int | None
|
|
56
51
|
char_limit_per_line: int | None = const.READ_CHAR_LIMIT_PER_LINE
|
|
57
52
|
global_line_cap: int | None = const.READ_GLOBAL_LINE_CAP
|
|
53
|
+
max_total_chars: int | None = const.READ_MAX_CHARS
|
|
58
54
|
|
|
59
55
|
|
|
60
56
|
@dataclass
|
|
@@ -63,20 +59,32 @@ class ReadSegmentResult:
|
|
|
63
59
|
selected_lines: list[tuple[int, str]]
|
|
64
60
|
selected_chars_count: int
|
|
65
61
|
remaining_selected_beyond_cap: int
|
|
62
|
+
remaining_due_to_char_limit: int
|
|
63
|
+
content_sha256: str
|
|
66
64
|
|
|
67
65
|
|
|
68
66
|
def _read_segment(options: ReadOptions) -> ReadSegmentResult:
|
|
69
67
|
total_lines = 0
|
|
70
68
|
selected_lines_count = 0
|
|
71
69
|
remaining_selected_beyond_cap = 0
|
|
70
|
+
remaining_due_to_char_limit = 0
|
|
72
71
|
selected_lines: list[tuple[int, str]] = []
|
|
73
72
|
selected_chars = 0
|
|
74
|
-
|
|
73
|
+
char_limit_reached = False
|
|
74
|
+
hasher = hashlib.sha256()
|
|
75
|
+
|
|
76
|
+
with open(options.file_path, encoding="utf-8", errors="replace") as f:
|
|
75
77
|
for line_no, raw_line in enumerate(f, start=1):
|
|
76
78
|
total_lines = line_no
|
|
79
|
+
hasher.update(raw_line.encode("utf-8"))
|
|
77
80
|
within = line_no >= options.offset and (options.limit is None or selected_lines_count < options.limit)
|
|
78
81
|
if not within:
|
|
79
82
|
continue
|
|
83
|
+
|
|
84
|
+
if char_limit_reached:
|
|
85
|
+
remaining_due_to_char_limit += 1
|
|
86
|
+
continue
|
|
87
|
+
|
|
80
88
|
selected_lines_count += 1
|
|
81
89
|
content = raw_line.rstrip("\n")
|
|
82
90
|
original_len = len(content)
|
|
@@ -86,27 +94,41 @@ def _read_segment(options: ReadOptions) -> ReadSegmentResult:
|
|
|
86
94
|
content[: options.char_limit_per_line]
|
|
87
95
|
+ f" ... (more {truncated_chars} characters in this line are truncated)"
|
|
88
96
|
)
|
|
89
|
-
|
|
97
|
+
line_chars = len(content) + 1
|
|
98
|
+
selected_chars += line_chars
|
|
99
|
+
|
|
100
|
+
if options.max_total_chars is not None and selected_chars > options.max_total_chars:
|
|
101
|
+
char_limit_reached = True
|
|
102
|
+
selected_lines.append((line_no, content))
|
|
103
|
+
continue
|
|
104
|
+
|
|
90
105
|
if options.global_line_cap is None or len(selected_lines) < options.global_line_cap:
|
|
91
106
|
selected_lines.append((line_no, content))
|
|
92
107
|
else:
|
|
93
108
|
remaining_selected_beyond_cap += 1
|
|
109
|
+
|
|
94
110
|
return ReadSegmentResult(
|
|
95
111
|
total_lines=total_lines,
|
|
96
112
|
selected_lines=selected_lines,
|
|
97
113
|
selected_chars_count=selected_chars,
|
|
98
114
|
remaining_selected_beyond_cap=remaining_selected_beyond_cap,
|
|
115
|
+
remaining_due_to_char_limit=remaining_due_to_char_limit,
|
|
116
|
+
content_sha256=hasher.hexdigest(),
|
|
99
117
|
)
|
|
100
118
|
|
|
101
119
|
|
|
102
|
-
def _track_file_access(file_path: str) -> None:
|
|
120
|
+
def _track_file_access(file_path: str, *, content_sha256: str | None = None, is_memory: bool = False) -> None:
|
|
103
121
|
file_tracker = get_current_file_tracker()
|
|
104
|
-
if file_tracker is None or not
|
|
122
|
+
if file_tracker is None or not file_exists(file_path) or is_directory(file_path):
|
|
105
123
|
return
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
124
|
+
with contextlib.suppress(Exception):
|
|
125
|
+
existing = file_tracker.get(file_path)
|
|
126
|
+
is_mem = is_memory or (existing.is_memory if existing else False)
|
|
127
|
+
file_tracker[file_path] = model.FileStatus(
|
|
128
|
+
mtime=Path(file_path).stat().st_mtime,
|
|
129
|
+
content_sha256=content_sha256,
|
|
130
|
+
is_memory=is_mem,
|
|
131
|
+
)
|
|
110
132
|
|
|
111
133
|
|
|
112
134
|
def _is_supported_image_file(file_path: str) -> bool:
|
|
@@ -121,12 +143,6 @@ def _image_mime_type(file_path: str) -> str:
|
|
|
121
143
|
return mime_type
|
|
122
144
|
|
|
123
145
|
|
|
124
|
-
def _encode_image_to_data_url(file_path: str, mime_type: str) -> str:
|
|
125
|
-
with open(file_path, "rb") as image_file:
|
|
126
|
-
encoded = b64encode(image_file.read()).decode("ascii")
|
|
127
|
-
return f"data:{mime_type};base64,{encoded}"
|
|
128
|
-
|
|
129
|
-
|
|
130
146
|
@register(tools.READ)
|
|
131
147
|
class ReadTool(ToolABC):
|
|
132
148
|
class ReadArguments(BaseModel):
|
|
@@ -170,30 +186,24 @@ class ReadTool(ToolABC):
|
|
|
170
186
|
return await cls.call_with_args(args)
|
|
171
187
|
|
|
172
188
|
@classmethod
|
|
173
|
-
def _effective_limits(cls) -> tuple[int | None, int | None, int | None
|
|
174
|
-
"""Return effective limits based on current policy: char_per_line, global_line_cap, max_chars, max_kb"""
|
|
189
|
+
def _effective_limits(cls) -> tuple[int | None, int | None, int | None]:
|
|
175
190
|
return (
|
|
176
191
|
const.READ_CHAR_LIMIT_PER_LINE,
|
|
177
192
|
const.READ_GLOBAL_LINE_CAP,
|
|
178
193
|
const.READ_MAX_CHARS,
|
|
179
|
-
const.READ_MAX_KB,
|
|
180
194
|
)
|
|
181
195
|
|
|
182
196
|
@classmethod
|
|
183
197
|
async def call_with_args(cls, args: ReadTool.ReadArguments) -> model.ToolResultItem:
|
|
184
|
-
# Accept relative path by resolving to absolute (schema encourages absolute)
|
|
185
198
|
file_path = os.path.abspath(args.file_path)
|
|
199
|
+
char_per_line, line_cap, max_chars = cls._effective_limits()
|
|
186
200
|
|
|
187
|
-
|
|
188
|
-
char_per_line, line_cap, max_chars, max_kb = cls._effective_limits()
|
|
189
|
-
|
|
190
|
-
# Common file errors
|
|
191
|
-
if _is_directory(file_path):
|
|
201
|
+
if is_directory(file_path):
|
|
192
202
|
return model.ToolResultItem(
|
|
193
203
|
status="error",
|
|
194
204
|
output="<tool_use_error>Illegal operation on a directory. read</tool_use_error>",
|
|
195
205
|
)
|
|
196
|
-
if not
|
|
206
|
+
if not file_exists(file_path):
|
|
197
207
|
return model.ToolResultItem(
|
|
198
208
|
status="error",
|
|
199
209
|
output="<tool_use_error>File does not exist.</tool_use_error>",
|
|
@@ -204,8 +214,9 @@ class ReadTool(ToolABC):
|
|
|
204
214
|
return model.ToolResultItem(
|
|
205
215
|
status="error",
|
|
206
216
|
output=(
|
|
207
|
-
"<tool_use_error>PDF files are not supported by this tool
|
|
208
|
-
"
|
|
217
|
+
"<tool_use_error>PDF files are not supported by this tool.\n"
|
|
218
|
+
"If there's an available skill for PDF, use it.\n"
|
|
219
|
+
"Or use a Python script with `pdfplumber` to extract text/tables:\n\n"
|
|
209
220
|
"```python\n"
|
|
210
221
|
"# /// script\n"
|
|
211
222
|
'# dependencies = ["pdfplumber"]\n'
|
|
@@ -219,13 +230,22 @@ class ReadTool(ToolABC):
|
|
|
219
230
|
),
|
|
220
231
|
)
|
|
221
232
|
|
|
222
|
-
|
|
233
|
+
is_image_file = _is_supported_image_file(file_path)
|
|
234
|
+
# Check for binary files (skip for images which are handled separately)
|
|
235
|
+
if not is_image_file and _is_binary_file(file_path):
|
|
236
|
+
return model.ToolResultItem(
|
|
237
|
+
status="error",
|
|
238
|
+
output=(
|
|
239
|
+
"<tool_use_error>This appears to be a binary file and cannot be read as text. "
|
|
240
|
+
"Use appropriate tools or libraries to handle binary files.</tool_use_error>"
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
|
|
223
244
|
try:
|
|
224
245
|
size_bytes = Path(file_path).stat().st_size
|
|
225
|
-
except
|
|
246
|
+
except OSError:
|
|
226
247
|
size_bytes = 0
|
|
227
248
|
|
|
228
|
-
is_image_file = _is_supported_image_file(file_path)
|
|
229
249
|
if is_image_file:
|
|
230
250
|
if size_bytes > const.READ_MAX_IMAGE_BYTES:
|
|
231
251
|
size_mb = size_bytes / (1024 * 1024)
|
|
@@ -237,42 +257,26 @@ class ReadTool(ToolABC):
|
|
|
237
257
|
)
|
|
238
258
|
try:
|
|
239
259
|
mime_type = _image_mime_type(file_path)
|
|
240
|
-
|
|
260
|
+
with open(file_path, "rb") as image_file:
|
|
261
|
+
image_bytes = image_file.read()
|
|
262
|
+
data_url = f"data:{mime_type};base64,{b64encode(image_bytes).decode('ascii')}"
|
|
241
263
|
except Exception as exc:
|
|
242
264
|
return model.ToolResultItem(
|
|
243
265
|
status="error",
|
|
244
266
|
output=f"<tool_use_error>Failed to read image file: {exc}</tool_use_error>",
|
|
245
267
|
)
|
|
246
268
|
|
|
247
|
-
_track_file_access(file_path)
|
|
269
|
+
_track_file_access(file_path, content_sha256=hashlib.sha256(image_bytes).hexdigest())
|
|
248
270
|
size_kb = size_bytes / 1024.0 if size_bytes else 0.0
|
|
249
271
|
output_text = f"[image] {Path(file_path).name} ({size_kb:.1f}KB)"
|
|
250
272
|
image_part = model.ImageURLPart(image_url=model.ImageURLPart.ImageURL(url=data_url, id=None))
|
|
251
273
|
return model.ToolResultItem(status="success", output=output_text, images=[image_part])
|
|
252
274
|
|
|
253
|
-
if (
|
|
254
|
-
not is_image_file
|
|
255
|
-
and max_kb is not None
|
|
256
|
-
and args.offset is None
|
|
257
|
-
and args.limit is None
|
|
258
|
-
and size_bytes > max_kb * 1024
|
|
259
|
-
):
|
|
260
|
-
size_kb = size_bytes / 1024.0
|
|
261
|
-
return model.ToolResultItem(
|
|
262
|
-
status="error",
|
|
263
|
-
output=(
|
|
264
|
-
f"File content ({size_kb:.1f}KB) exceeds maximum allowed size ({max_kb}KB). Please use offset and limit parameters to read specific portions of the file, or use the `rg` command to search for specific content."
|
|
265
|
-
),
|
|
266
|
-
)
|
|
267
|
-
|
|
268
275
|
offset = 1 if args.offset is None or args.offset < 1 else int(args.offset)
|
|
269
276
|
limit = None if args.limit is None else int(args.limit)
|
|
270
277
|
if limit is not None and limit < 0:
|
|
271
278
|
limit = 0
|
|
272
279
|
|
|
273
|
-
# Stream file line-by-line and build response
|
|
274
|
-
read_result: ReadSegmentResult | None = None
|
|
275
|
-
|
|
276
280
|
try:
|
|
277
281
|
read_result = await asyncio.to_thread(
|
|
278
282
|
_read_segment,
|
|
@@ -282,6 +286,7 @@ class ReadTool(ToolABC):
|
|
|
282
286
|
limit=limit,
|
|
283
287
|
char_limit_per_line=char_per_line,
|
|
284
288
|
global_line_cap=line_cap,
|
|
289
|
+
max_total_chars=max_chars,
|
|
285
290
|
),
|
|
286
291
|
)
|
|
287
292
|
|
|
@@ -296,31 +301,26 @@ class ReadTool(ToolABC):
|
|
|
296
301
|
output="<tool_use_error>Illegal operation on a directory. read</tool_use_error>",
|
|
297
302
|
)
|
|
298
303
|
|
|
299
|
-
# If offset beyond total lines, emit system reminder warning
|
|
300
304
|
if offset > max(read_result.total_lines, 0):
|
|
301
305
|
warn = f"<system-reminder>Warning: the file exists but is shorter than the provided offset ({offset}). The file has {read_result.total_lines} lines.</system-reminder>"
|
|
302
|
-
|
|
303
|
-
_track_file_access(file_path)
|
|
306
|
+
_track_file_access(file_path, content_sha256=read_result.content_sha256)
|
|
304
307
|
return model.ToolResultItem(status="success", output=warn)
|
|
305
308
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
)
|
|
309
|
+
lines_out: list[str] = [_format_numbered_line(no, content) for no, content in read_result.selected_lines]
|
|
310
|
+
|
|
311
|
+
# Show truncation info with reason
|
|
312
|
+
if read_result.remaining_due_to_char_limit > 0:
|
|
313
|
+
lines_out.append(
|
|
314
|
+
f"... ({read_result.remaining_due_to_char_limit} more lines truncated due to {max_chars} char limit, "
|
|
315
|
+
f"file has {read_result.total_lines} lines total, use offset/limit to read other parts)"
|
|
316
|
+
)
|
|
317
|
+
elif read_result.remaining_selected_beyond_cap > 0:
|
|
318
|
+
lines_out.append(
|
|
319
|
+
f"... ({read_result.remaining_selected_beyond_cap} more lines truncated due to {line_cap} line limit, "
|
|
320
|
+
f"file has {read_result.total_lines} lines total, use offset/limit to read other parts)"
|
|
313
321
|
)
|
|
314
322
|
|
|
315
|
-
# Build display with numbering and reminders
|
|
316
|
-
lines_out: list[str] = [_format_numbered_line(no, content) for no, content in read_result.selected_lines]
|
|
317
|
-
if read_result.remaining_selected_beyond_cap > 0:
|
|
318
|
-
lines_out.append(f"... (more {read_result.remaining_selected_beyond_cap} lines are truncated)")
|
|
319
323
|
read_result_str = "\n".join(lines_out)
|
|
320
|
-
|
|
321
|
-
# read_result_str += "\n\n" + SYSTEM_REMINDER_MALICIOUS
|
|
322
|
-
|
|
323
|
-
# Update FileTracker with last modified time
|
|
324
|
-
_track_file_access(file_path)
|
|
324
|
+
_track_file_access(file_path, content_sha256=read_result.content_sha256)
|
|
325
325
|
|
|
326
326
|
return model.ToolResultItem(status="success", output=read_result_str)
|
|
@@ -1,44 +1,20 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import
|
|
4
|
+
import contextlib
|
|
5
5
|
import os
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
|
|
10
|
+
from klaude_code.core.tool.file._utils import file_exists, hash_text_sha256, is_directory, read_text, write_text
|
|
11
|
+
from klaude_code.core.tool.file.diff_builder import build_structured_diff
|
|
10
12
|
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
11
13
|
from klaude_code.core.tool.tool_context import get_current_file_tracker
|
|
12
14
|
from klaude_code.core.tool.tool_registry import register
|
|
13
15
|
from klaude_code.protocol import llm_param, model, tools
|
|
14
16
|
|
|
15
17
|
|
|
16
|
-
def _is_directory(path: str) -> bool:
|
|
17
|
-
try:
|
|
18
|
-
return Path(path).is_dir()
|
|
19
|
-
except Exception:
|
|
20
|
-
return False
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _file_exists(path: str) -> bool:
|
|
24
|
-
try:
|
|
25
|
-
return Path(path).exists()
|
|
26
|
-
except Exception:
|
|
27
|
-
return False
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _write_text(path: str, content: str) -> None:
|
|
31
|
-
parent = Path(path).parent
|
|
32
|
-
parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
-
with open(path, "w", encoding="utf-8") as f:
|
|
34
|
-
f.write(content)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _read_text(path: str) -> str:
|
|
38
|
-
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
39
|
-
return f.read()
|
|
40
|
-
|
|
41
|
-
|
|
42
18
|
class WriteArguments(BaseModel):
|
|
43
19
|
file_path: str
|
|
44
20
|
content: str
|
|
@@ -73,74 +49,88 @@ class WriteTool(ToolABC):
|
|
|
73
49
|
async def call(cls, arguments: str) -> model.ToolResultItem:
|
|
74
50
|
try:
|
|
75
51
|
args = WriteArguments.model_validate_json(arguments)
|
|
76
|
-
except
|
|
52
|
+
except ValueError as e: # pragma: no cover - defensive
|
|
77
53
|
return model.ToolResultItem(status="error", output=f"Invalid arguments: {e}")
|
|
78
54
|
|
|
79
55
|
file_path = os.path.abspath(args.file_path)
|
|
80
56
|
|
|
81
|
-
if
|
|
57
|
+
if is_directory(file_path):
|
|
82
58
|
return model.ToolResultItem(
|
|
83
59
|
status="error",
|
|
84
60
|
output="<tool_use_error>Illegal operation on a directory. write</tool_use_error>",
|
|
85
61
|
)
|
|
86
62
|
|
|
87
63
|
file_tracker = get_current_file_tracker()
|
|
88
|
-
exists =
|
|
64
|
+
exists = file_exists(file_path)
|
|
65
|
+
tracked_status: model.FileStatus | None = None
|
|
89
66
|
|
|
90
67
|
if exists:
|
|
91
|
-
|
|
92
|
-
if
|
|
93
|
-
tracked_mtime = file_tracker.get(file_path)
|
|
94
|
-
if tracked_mtime is None:
|
|
68
|
+
tracked_status = file_tracker.get(file_path) if file_tracker is not None else None
|
|
69
|
+
if tracked_status is None:
|
|
95
70
|
return model.ToolResultItem(
|
|
96
71
|
status="error",
|
|
97
72
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
98
73
|
)
|
|
99
|
-
try:
|
|
100
|
-
current_mtime = Path(file_path).stat().st_mtime
|
|
101
|
-
except Exception:
|
|
102
|
-
current_mtime = tracked_mtime
|
|
103
|
-
if current_mtime != tracked_mtime:
|
|
104
|
-
return model.ToolResultItem(
|
|
105
|
-
status="error",
|
|
106
|
-
output=(
|
|
107
|
-
"File has been modified externally. Either by user or a linter. "
|
|
108
|
-
"Read it first before writing to it."
|
|
109
|
-
),
|
|
110
|
-
)
|
|
111
74
|
|
|
112
|
-
# Capture previous content (if any) for diff generation
|
|
75
|
+
# Capture previous content (if any) for diff generation and external-change detection.
|
|
113
76
|
before = ""
|
|
77
|
+
before_read_ok = False
|
|
114
78
|
if exists:
|
|
115
79
|
try:
|
|
116
|
-
before = await asyncio.to_thread(
|
|
117
|
-
|
|
80
|
+
before = await asyncio.to_thread(read_text, file_path)
|
|
81
|
+
before_read_ok = True
|
|
82
|
+
except OSError:
|
|
118
83
|
before = ""
|
|
84
|
+
before_read_ok = False
|
|
85
|
+
|
|
86
|
+
# Re-check external modifications using content hash when available.
|
|
87
|
+
if before_read_ok and tracked_status is not None and tracked_status.content_sha256 is not None:
|
|
88
|
+
current_sha256 = hash_text_sha256(before)
|
|
89
|
+
if current_sha256 != tracked_status.content_sha256:
|
|
90
|
+
return model.ToolResultItem(
|
|
91
|
+
status="error",
|
|
92
|
+
output=(
|
|
93
|
+
"File has been modified externally. Either by user or a linter. "
|
|
94
|
+
"Read it first before writing to it."
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
elif tracked_status is not None:
|
|
98
|
+
# Backward-compat: old sessions only stored mtime, or we couldn't hash.
|
|
99
|
+
try:
|
|
100
|
+
current_mtime = Path(file_path).stat().st_mtime
|
|
101
|
+
except OSError:
|
|
102
|
+
current_mtime = tracked_status.mtime
|
|
103
|
+
if current_mtime != tracked_status.mtime:
|
|
104
|
+
return model.ToolResultItem(
|
|
105
|
+
status="error",
|
|
106
|
+
output=(
|
|
107
|
+
"File has been modified externally. Either by user or a linter. "
|
|
108
|
+
"Read it first before writing to it."
|
|
109
|
+
),
|
|
110
|
+
)
|
|
119
111
|
|
|
120
112
|
try:
|
|
121
|
-
await asyncio.to_thread(
|
|
122
|
-
except
|
|
113
|
+
await asyncio.to_thread(write_text, file_path, args.content)
|
|
114
|
+
except (OSError, UnicodeError) as e: # pragma: no cover
|
|
123
115
|
return model.ToolResultItem(status="error", output=f"<tool_use_error>{e}</tool_use_error>")
|
|
124
116
|
|
|
125
117
|
if file_tracker is not None:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
diff_text = "\n".join(diff_lines)
|
|
143
|
-
ui_extra = model.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
|
|
118
|
+
with contextlib.suppress(Exception):
|
|
119
|
+
existing = file_tracker.get(file_path)
|
|
120
|
+
is_mem = existing.is_memory if existing else False
|
|
121
|
+
file_tracker[file_path] = model.FileStatus(
|
|
122
|
+
mtime=Path(file_path).stat().st_mtime,
|
|
123
|
+
content_sha256=hash_text_sha256(args.content),
|
|
124
|
+
is_memory=is_mem,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# For markdown files, use MarkdownDocUIExtra to render content as markdown
|
|
128
|
+
# Otherwise, build diff between previous and new content
|
|
129
|
+
ui_extra: model.ToolResultUIExtra | None
|
|
130
|
+
if file_path.endswith(".md"):
|
|
131
|
+
ui_extra = model.MarkdownDocUIExtra(file_path=file_path, content=args.content)
|
|
132
|
+
else:
|
|
133
|
+
ui_extra = build_structured_diff(before, args.content, file_path=file_path)
|
|
144
134
|
|
|
145
135
|
message = f"File {'overwritten' if exists else 'created'} successfully at: {file_path}"
|
|
146
136
|
return model.ToolResultItem(status="success", output=message, ui_extra=ui_extra)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""ReportBackTool for sub-agents to return structured output."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar, cast
|
|
4
|
+
|
|
5
|
+
from klaude_code.protocol import llm_param, model, tools
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _normalize_schema_types(schema: dict[str, Any]) -> dict[str, Any]:
|
|
9
|
+
"""Recursively normalize JSON schema type values to lowercase.
|
|
10
|
+
|
|
11
|
+
Some LLMs (e.g., Gemini 3) generate type values in uppercase like "OBJECT", "STRING".
|
|
12
|
+
Standard JSON Schema requires lowercase type values.
|
|
13
|
+
"""
|
|
14
|
+
result: dict[str, Any] = {}
|
|
15
|
+
for key, value in schema.items():
|
|
16
|
+
if key == "type" and isinstance(value, str):
|
|
17
|
+
result[key] = value.lower()
|
|
18
|
+
elif isinstance(value, dict):
|
|
19
|
+
result[key] = _normalize_schema_types(cast(dict[str, Any], value))
|
|
20
|
+
elif isinstance(value, list):
|
|
21
|
+
normalized_list: list[Any] = []
|
|
22
|
+
for item in cast(list[Any], value):
|
|
23
|
+
if isinstance(item, dict):
|
|
24
|
+
normalized_list.append(_normalize_schema_types(cast(dict[str, Any], item)))
|
|
25
|
+
else:
|
|
26
|
+
normalized_list.append(item)
|
|
27
|
+
result[key] = normalized_list
|
|
28
|
+
else:
|
|
29
|
+
result[key] = value
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ReportBackTool:
|
|
34
|
+
"""Special tool for sub-agents to return structured output and end the task.
|
|
35
|
+
|
|
36
|
+
This tool is dynamically injected when a parent agent calls a sub-agent with
|
|
37
|
+
an output_schema. The schema for this tool's parameters is defined by the
|
|
38
|
+
parent agent, allowing structured data to be returned.
|
|
39
|
+
|
|
40
|
+
Note: This class does not inherit from ToolABC because it's not registered
|
|
41
|
+
in the global tool registry. Instead, it's handled specially by the
|
|
42
|
+
TurnExecutor and SubAgentManager.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
_schema: ClassVar[dict[str, Any]] = {}
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def for_schema(cls, schema: dict[str, Any]) -> type["ReportBackTool"]:
|
|
49
|
+
"""Create a tool class with the specified output schema.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
schema: JSON Schema defining the expected structure of the report_back arguments.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
A new class with the schema set as a class variable.
|
|
56
|
+
"""
|
|
57
|
+
normalized = _normalize_schema_types(schema)
|
|
58
|
+
return type("ReportBackTool", (ReportBackTool,), {"_schema": normalized})
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def schema(cls) -> llm_param.ToolSchema:
|
|
62
|
+
"""Generate the tool schema for this report_back tool."""
|
|
63
|
+
return llm_param.ToolSchema(
|
|
64
|
+
name=tools.REPORT_BACK,
|
|
65
|
+
type="function",
|
|
66
|
+
description=(
|
|
67
|
+
"Report the final structured result back to the parent agent. "
|
|
68
|
+
"Call this when you have completed the task and want to return structured data. "
|
|
69
|
+
"The task will end after this tool is called."
|
|
70
|
+
),
|
|
71
|
+
parameters=cls._schema,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
async def call(cls, arguments: str) -> model.ToolResultItem:
|
|
76
|
+
"""Execute the report_back tool.
|
|
77
|
+
|
|
78
|
+
The actual handling of report_back results is done by TurnExecutor.
|
|
79
|
+
This method just returns a success status to maintain the tool call flow.
|
|
80
|
+
"""
|
|
81
|
+
return model.ToolResultItem(
|
|
82
|
+
status="success",
|
|
83
|
+
output="Result reported successfully. Task will end.",
|
|
84
|
+
)
|