klaude-code 1.2.17__py3-none-any.whl → 1.2.19__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/cli/config_cmd.py +1 -1
- klaude_code/cli/debug.py +1 -1
- klaude_code/cli/main.py +45 -31
- klaude_code/cli/runtime.py +49 -13
- klaude_code/{version.py → cli/self_update.py} +110 -2
- klaude_code/command/__init__.py +4 -1
- klaude_code/command/clear_cmd.py +2 -7
- klaude_code/command/command_abc.py +33 -5
- klaude_code/command/debug_cmd.py +79 -0
- klaude_code/command/diff_cmd.py +2 -6
- klaude_code/command/export_cmd.py +7 -7
- klaude_code/command/export_online_cmd.py +9 -8
- klaude_code/command/help_cmd.py +4 -9
- klaude_code/command/model_cmd.py +10 -6
- klaude_code/command/prompt_command.py +2 -6
- klaude_code/command/refresh_cmd.py +2 -7
- klaude_code/command/registry.py +69 -26
- klaude_code/command/release_notes_cmd.py +2 -6
- klaude_code/command/status_cmd.py +2 -7
- klaude_code/command/terminal_setup_cmd.py +2 -6
- klaude_code/command/thinking_cmd.py +16 -10
- klaude_code/config/select_model.py +81 -5
- klaude_code/const/__init__.py +1 -1
- klaude_code/core/executor.py +257 -110
- klaude_code/core/manager/__init__.py +2 -4
- klaude_code/core/prompts/prompt-claude-code.md +1 -1
- klaude_code/core/prompts/prompt-sub-agent-explore.md +14 -2
- klaude_code/core/prompts/prompt-sub-agent-web.md +8 -5
- klaude_code/core/reminders.py +9 -35
- klaude_code/core/task.py +9 -7
- klaude_code/core/tool/file/read_tool.md +1 -1
- klaude_code/core/tool/file/read_tool.py +41 -12
- klaude_code/core/tool/memory/skill_loader.py +12 -10
- klaude_code/core/tool/shell/bash_tool.py +22 -2
- klaude_code/core/tool/tool_registry.py +1 -1
- klaude_code/core/tool/tool_runner.py +26 -23
- klaude_code/core/tool/truncation.py +23 -9
- klaude_code/core/tool/web/web_fetch_tool.md +1 -1
- klaude_code/core/tool/web/web_fetch_tool.py +36 -1
- klaude_code/core/turn.py +28 -0
- klaude_code/llm/anthropic/client.py +25 -9
- klaude_code/llm/openai_compatible/client.py +5 -2
- klaude_code/llm/openrouter/client.py +7 -3
- klaude_code/llm/responses/client.py +6 -1
- klaude_code/protocol/commands.py +1 -0
- klaude_code/protocol/sub_agent/web.py +3 -2
- klaude_code/session/session.py +35 -15
- klaude_code/session/templates/export_session.html +45 -32
- klaude_code/trace/__init__.py +20 -2
- klaude_code/ui/modes/repl/completers.py +231 -73
- klaude_code/ui/modes/repl/event_handler.py +8 -6
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
- klaude_code/ui/modes/repl/renderer.py +2 -2
- klaude_code/ui/renderers/common.py +54 -0
- klaude_code/ui/renderers/developer.py +2 -3
- klaude_code/ui/renderers/errors.py +1 -1
- klaude_code/ui/renderers/metadata.py +12 -5
- klaude_code/ui/renderers/thinking.py +24 -8
- klaude_code/ui/renderers/tools.py +82 -14
- klaude_code/ui/rich/code_panel.py +112 -0
- klaude_code/ui/rich/markdown.py +3 -4
- klaude_code/ui/rich/status.py +0 -2
- klaude_code/ui/rich/theme.py +10 -1
- klaude_code/ui/utils/common.py +0 -18
- {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/METADATA +32 -7
- {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/RECORD +69 -68
- klaude_code/core/manager/agent_manager.py +0 -132
- /klaude_code/{config → cli}/list_model.py +0 -0
- {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/entry_points.txt +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
Reads a file from the local filesystem. You can access any file directly by using this tool.
|
|
2
2
|
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
|
3
|
+
When you need to read an image, use this tool.
|
|
3
4
|
|
|
4
5
|
Usage:
|
|
5
6
|
- The file_path parameter must be an absolute path, not a relative path
|
|
@@ -11,4 +12,3 @@ Usage:
|
|
|
11
12
|
- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.
|
|
12
13
|
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
|
|
13
14
|
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
|
14
|
-
- This tool does NOT support reading PDF files. Use a Python script with `pdfplumber` (for text/tables) or `pypdf` (for basic operations) to extract content from PDFs.
|
|
@@ -16,12 +16,6 @@ from klaude_code.core.tool.tool_context import get_current_file_tracker
|
|
|
16
16
|
from klaude_code.core.tool.tool_registry import register
|
|
17
17
|
from klaude_code.protocol import llm_param, model, tools
|
|
18
18
|
|
|
19
|
-
SYSTEM_REMINDER_MALICIOUS = (
|
|
20
|
-
"<system-reminder>\n"
|
|
21
|
-
"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"
|
|
22
|
-
"</system-reminder>"
|
|
23
|
-
)
|
|
24
|
-
|
|
25
19
|
_IMAGE_MIME_TYPES: dict[str, str] = {
|
|
26
20
|
".png": "image/png",
|
|
27
21
|
".jpg": "image/jpeg",
|
|
@@ -51,6 +45,8 @@ class ReadSegmentResult:
|
|
|
51
45
|
selected_lines: list[tuple[int, str]]
|
|
52
46
|
selected_chars_count: int
|
|
53
47
|
remaining_selected_beyond_cap: int
|
|
48
|
+
# For large file diagnostics: list of (start_line, end_line, char_count)
|
|
49
|
+
segment_char_stats: list[tuple[int, int, int]]
|
|
54
50
|
|
|
55
51
|
|
|
56
52
|
def _read_segment(options: ReadOptions) -> ReadSegmentResult:
|
|
@@ -59,6 +55,13 @@ def _read_segment(options: ReadOptions) -> ReadSegmentResult:
|
|
|
59
55
|
remaining_selected_beyond_cap = 0
|
|
60
56
|
selected_lines: list[tuple[int, str]] = []
|
|
61
57
|
selected_chars = 0
|
|
58
|
+
|
|
59
|
+
# Track char counts per 100-line segment for diagnostics
|
|
60
|
+
segment_size = 100
|
|
61
|
+
segment_char_stats: list[tuple[int, int, int]] = []
|
|
62
|
+
current_segment_start = options.offset
|
|
63
|
+
current_segment_chars = 0
|
|
64
|
+
|
|
62
65
|
with open(options.file_path, encoding="utf-8", errors="replace") as f:
|
|
63
66
|
for line_no, raw_line in enumerate(f, start=1):
|
|
64
67
|
total_lines = line_no
|
|
@@ -74,16 +77,32 @@ def _read_segment(options: ReadOptions) -> ReadSegmentResult:
|
|
|
74
77
|
content[: options.char_limit_per_line]
|
|
75
78
|
+ f" ... (more {truncated_chars} characters in this line are truncated)"
|
|
76
79
|
)
|
|
77
|
-
|
|
80
|
+
line_chars = len(content) + 1
|
|
81
|
+
selected_chars += line_chars
|
|
82
|
+
current_segment_chars += line_chars
|
|
83
|
+
|
|
84
|
+
# Check if we've completed a segment
|
|
85
|
+
if selected_lines_count % segment_size == 0:
|
|
86
|
+
segment_char_stats.append((current_segment_start, line_no, current_segment_chars))
|
|
87
|
+
current_segment_start = line_no + 1
|
|
88
|
+
current_segment_chars = 0
|
|
89
|
+
|
|
78
90
|
if options.global_line_cap is None or len(selected_lines) < options.global_line_cap:
|
|
79
91
|
selected_lines.append((line_no, content))
|
|
80
92
|
else:
|
|
81
93
|
remaining_selected_beyond_cap += 1
|
|
94
|
+
|
|
95
|
+
# Add the last partial segment if any
|
|
96
|
+
if current_segment_chars > 0 and selected_lines_count > 0:
|
|
97
|
+
last_line = options.offset + selected_lines_count - 1
|
|
98
|
+
segment_char_stats.append((current_segment_start, last_line, current_segment_chars))
|
|
99
|
+
|
|
82
100
|
return ReadSegmentResult(
|
|
83
101
|
total_lines=total_lines,
|
|
84
102
|
selected_lines=selected_lines,
|
|
85
103
|
selected_chars_count=selected_chars,
|
|
86
104
|
remaining_selected_beyond_cap=remaining_selected_beyond_cap,
|
|
105
|
+
segment_char_stats=segment_char_stats,
|
|
87
106
|
)
|
|
88
107
|
|
|
89
108
|
|
|
@@ -190,8 +209,9 @@ class ReadTool(ToolABC):
|
|
|
190
209
|
return model.ToolResultItem(
|
|
191
210
|
status="error",
|
|
192
211
|
output=(
|
|
193
|
-
"<tool_use_error>PDF files are not supported by this tool
|
|
194
|
-
"
|
|
212
|
+
"<tool_use_error>PDF files are not supported by this tool.\n"
|
|
213
|
+
"If there's an available skill for PDF, use it.\n"
|
|
214
|
+
"Or use a Python script with `pdfplumber` to extract text/tables:\n\n"
|
|
195
215
|
"```python\n"
|
|
196
216
|
"# /// script\n"
|
|
197
217
|
'# dependencies = ["pdfplumber"]\n'
|
|
@@ -292,10 +312,21 @@ class ReadTool(ToolABC):
|
|
|
292
312
|
|
|
293
313
|
# After limit/offset, if total selected chars exceed limit, error (only check if limits are enabled)
|
|
294
314
|
if max_chars is not None and read_result.selected_chars_count > max_chars:
|
|
315
|
+
# Build segment statistics for better guidance
|
|
316
|
+
stats_lines: list[str] = []
|
|
317
|
+
for start, end, chars in read_result.segment_char_stats:
|
|
318
|
+
stats_lines.append(f" Lines {start}-{end}: {chars} chars")
|
|
319
|
+
segment_stats_str = "\n".join(stats_lines) if stats_lines else " (no segment data)"
|
|
320
|
+
|
|
295
321
|
return model.ToolResultItem(
|
|
296
322
|
status="error",
|
|
297
323
|
output=(
|
|
298
|
-
f"
|
|
324
|
+
f"Selected file content {read_result.selected_chars_count} chars exceeds maximum allowed chars ({max_chars}).\n"
|
|
325
|
+
f"File has {read_result.total_lines} total lines.\n\n"
|
|
326
|
+
f"Character distribution by segment:\n{segment_stats_str}\n\n"
|
|
327
|
+
f"Use offset and limit parameters to read specific portions. "
|
|
328
|
+
f"For example: offset=1, limit=100 to read the first 100 lines. "
|
|
329
|
+
f"Or use `rg` command to search for specific content."
|
|
299
330
|
),
|
|
300
331
|
)
|
|
301
332
|
|
|
@@ -304,8 +335,6 @@ class ReadTool(ToolABC):
|
|
|
304
335
|
if read_result.remaining_selected_beyond_cap > 0:
|
|
305
336
|
lines_out.append(f"... (more {read_result.remaining_selected_beyond_cap} lines are truncated)")
|
|
306
337
|
read_result_str = "\n".join(lines_out)
|
|
307
|
-
# if read_result_str:
|
|
308
|
-
# read_result_str += "\n\n" + SYSTEM_REMINDER_MALICIOUS
|
|
309
338
|
|
|
310
339
|
# Update FileTracker with last modified time
|
|
311
340
|
_track_file_access(file_path)
|
|
@@ -132,20 +132,22 @@ class SkillLoader:
|
|
|
132
132
|
for user_dir in self.USER_SKILLS_DIRS:
|
|
133
133
|
expanded_dir = user_dir.expanduser()
|
|
134
134
|
if expanded_dir.exists():
|
|
135
|
-
for
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
for pattern in ("SKILL.md", "skill.md"):
|
|
136
|
+
for skill_file in expanded_dir.rglob(pattern):
|
|
137
|
+
skill = self.load_skill(skill_file, location="user")
|
|
138
|
+
if skill:
|
|
139
|
+
skills.append(skill)
|
|
140
|
+
self.loaded_skills[skill.name] = skill
|
|
140
141
|
|
|
141
142
|
# Load project-level skills (override user skills if same name)
|
|
142
143
|
project_dir = self.PROJECT_SKILLS_DIR.resolve()
|
|
143
144
|
if project_dir.exists():
|
|
144
|
-
for
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
for pattern in ("SKILL.md", "skill.md"):
|
|
146
|
+
for skill_file in project_dir.rglob(pattern):
|
|
147
|
+
skill = self.load_skill(skill_file, location="project")
|
|
148
|
+
if skill:
|
|
149
|
+
skills.append(skill)
|
|
150
|
+
self.loaded_skills[skill.name] = skill
|
|
149
151
|
|
|
150
152
|
# Log discovery summary
|
|
151
153
|
if skills:
|
|
@@ -11,8 +11,28 @@ from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
|
11
11
|
from klaude_code.core.tool.tool_registry import register
|
|
12
12
|
from klaude_code.protocol import llm_param, model, tools
|
|
13
13
|
|
|
14
|
-
# Regex to strip ANSI
|
|
15
|
-
|
|
14
|
+
# Regex to strip ANSI and terminal control sequences from command output
|
|
15
|
+
#
|
|
16
|
+
# This is intentionally broader than just SGR color codes (e.g. "\x1b[31m").
|
|
17
|
+
# Many interactive or TUI-style programs emit additional escape sequences
|
|
18
|
+
# that move the cursor, clear the screen, or switch screen buffers
|
|
19
|
+
# (CSI/OSC/DCS/APC/PM, etc). If these reach the Rich console, they can
|
|
20
|
+
# corrupt the REPL layout. We therefore remove all of them before
|
|
21
|
+
# rendering the output.
|
|
22
|
+
_ANSI_ESCAPE_RE = re.compile(
|
|
23
|
+
r"""
|
|
24
|
+
\x1B
|
|
25
|
+
(?:
|
|
26
|
+
\[[0-?]*[ -/]*[@-~] | # CSI sequences
|
|
27
|
+
\][0-?]*.*?(?:\x07|\x1B\\) | # OSC sequences
|
|
28
|
+
P.*?(?:\x07|\x1B\\) | # DCS sequences
|
|
29
|
+
_.*?(?:\x07|\x1B\\) | # APC sequences
|
|
30
|
+
\^.*?(?:\x07|\x1B\\) | # PM sequences
|
|
31
|
+
[@-Z\\-_] # 2-char sequences
|
|
32
|
+
)
|
|
33
|
+
""",
|
|
34
|
+
re.VERBOSE | re.DOTALL,
|
|
35
|
+
)
|
|
16
36
|
|
|
17
37
|
|
|
18
38
|
@register(tools.BASH)
|
|
@@ -66,7 +66,7 @@ def load_agent_tools(
|
|
|
66
66
|
|
|
67
67
|
# Main agent tools
|
|
68
68
|
if "gpt-5" in model_name:
|
|
69
|
-
tool_names = [tools.BASH, tools.APPLY_PATCH, tools.UPDATE_PLAN]
|
|
69
|
+
tool_names = [tools.BASH, tools.READ, tools.APPLY_PATCH, tools.UPDATE_PLAN]
|
|
70
70
|
elif "gemini-3" in model_name:
|
|
71
71
|
tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE]
|
|
72
72
|
else:
|
|
@@ -9,6 +9,9 @@ from klaude_code.core.tool.truncation import truncate_tool_output
|
|
|
9
9
|
from klaude_code.protocol import model, tools
|
|
10
10
|
from klaude_code.protocol.sub_agent import is_sub_agent_tool
|
|
11
11
|
|
|
12
|
+
# Tools that can run concurrently (IO-bound, no local state mutations)
|
|
13
|
+
_CONCURRENT_TOOLS: frozenset[str] = frozenset({tools.WEB_SEARCH, tools.WEB_FETCH})
|
|
14
|
+
|
|
12
15
|
|
|
13
16
|
async def run_tool(tool_call: model.ToolCallItem, registry: dict[str, type[ToolABC]]) -> model.ToolResultItem:
|
|
14
17
|
"""Execute a tool call and return the result.
|
|
@@ -89,8 +92,8 @@ class ToolExecutor:
|
|
|
89
92
|
"""Execute and coordinate a batch of tool calls for a single turn.
|
|
90
93
|
|
|
91
94
|
The executor is responsible for:
|
|
92
|
-
- Partitioning tool calls into
|
|
93
|
-
- Running
|
|
95
|
+
- Partitioning tool calls into sequential and concurrent tools
|
|
96
|
+
- Running sequential tools one by one and concurrent tools in parallel
|
|
94
97
|
- Emitting ToolCall/ToolResult events and tool side-effect events
|
|
95
98
|
- Tracking unfinished calls so `cancel()` can synthesize cancellation results
|
|
96
99
|
"""
|
|
@@ -106,7 +109,7 @@ class ToolExecutor:
|
|
|
106
109
|
|
|
107
110
|
self._unfinished_calls: dict[str, model.ToolCallItem] = {}
|
|
108
111
|
self._call_event_emitted: set[str] = set()
|
|
109
|
-
self.
|
|
112
|
+
self._concurrent_tasks: set[asyncio.Task[list[ToolExecutorEvent]]] = set()
|
|
110
113
|
|
|
111
114
|
async def run_tools(self, tool_calls: list[model.ToolCallItem]) -> AsyncGenerator[ToolExecutorEvent]:
|
|
112
115
|
"""Run the given tool calls and yield execution events.
|
|
@@ -119,10 +122,10 @@ class ToolExecutor:
|
|
|
119
122
|
for tool_call in tool_calls:
|
|
120
123
|
self._unfinished_calls[tool_call.call_id] = tool_call
|
|
121
124
|
|
|
122
|
-
|
|
125
|
+
sequential_tool_calls, concurrent_tool_calls = self._partition_tool_calls(tool_calls)
|
|
123
126
|
|
|
124
|
-
# Run
|
|
125
|
-
for tool_call in
|
|
127
|
+
# Run sequential tools one by one.
|
|
128
|
+
for tool_call in sequential_tool_calls:
|
|
126
129
|
tool_call_event = self._build_tool_call_started(tool_call)
|
|
127
130
|
self._call_event_emitted.add(tool_call.call_id)
|
|
128
131
|
yield tool_call_event
|
|
@@ -136,16 +139,16 @@ class ToolExecutor:
|
|
|
136
139
|
for exec_event in result_events:
|
|
137
140
|
yield exec_event
|
|
138
141
|
|
|
139
|
-
# Run sub-
|
|
140
|
-
if
|
|
142
|
+
# Run concurrent tools (sub-agents, web tools) in parallel.
|
|
143
|
+
if concurrent_tool_calls:
|
|
141
144
|
execution_tasks: list[asyncio.Task[list[ToolExecutorEvent]]] = []
|
|
142
|
-
for tool_call in
|
|
145
|
+
for tool_call in concurrent_tool_calls:
|
|
143
146
|
tool_call_event = self._build_tool_call_started(tool_call)
|
|
144
147
|
self._call_event_emitted.add(tool_call.call_id)
|
|
145
148
|
yield tool_call_event
|
|
146
149
|
|
|
147
150
|
task = asyncio.create_task(self._run_single_tool_call(tool_call))
|
|
148
|
-
self.
|
|
151
|
+
self._register_concurrent_task(task)
|
|
149
152
|
execution_tasks.append(task)
|
|
150
153
|
|
|
151
154
|
for task in asyncio.as_completed(execution_tasks):
|
|
@@ -165,7 +168,7 @@ class ToolExecutor:
|
|
|
165
168
|
def cancel(self) -> Iterable[ToolExecutorEvent]:
|
|
166
169
|
"""Cancel unfinished tool calls and synthesize error results.
|
|
167
170
|
|
|
168
|
-
- Cancels any running
|
|
171
|
+
- Cancels any running concurrent tool tasks so they stop emitting events.
|
|
169
172
|
- For each unfinished tool call, yields a ToolExecutionCallStarted (if not
|
|
170
173
|
already emitted for this turn) followed by a ToolExecutionResult with
|
|
171
174
|
error status and a standard cancellation output. The corresponding
|
|
@@ -174,11 +177,11 @@ class ToolExecutor:
|
|
|
174
177
|
|
|
175
178
|
events_to_yield: list[ToolExecutorEvent] = []
|
|
176
179
|
|
|
177
|
-
# Cancel running
|
|
178
|
-
for task in list(self.
|
|
180
|
+
# Cancel running concurrent tool tasks.
|
|
181
|
+
for task in list(self._concurrent_tasks):
|
|
179
182
|
if not task.done():
|
|
180
183
|
task.cancel()
|
|
181
|
-
self.
|
|
184
|
+
self._concurrent_tasks.clear()
|
|
182
185
|
|
|
183
186
|
if not self._unfinished_calls:
|
|
184
187
|
return events_to_yield
|
|
@@ -203,11 +206,11 @@ class ToolExecutor:
|
|
|
203
206
|
|
|
204
207
|
return events_to_yield
|
|
205
208
|
|
|
206
|
-
def
|
|
207
|
-
self.
|
|
209
|
+
def _register_concurrent_task(self, task: asyncio.Task[list[ToolExecutorEvent]]) -> None:
|
|
210
|
+
self._concurrent_tasks.add(task)
|
|
208
211
|
|
|
209
212
|
def _cleanup(completed: asyncio.Task[list[ToolExecutorEvent]]) -> None:
|
|
210
|
-
self.
|
|
213
|
+
self._concurrent_tasks.discard(completed)
|
|
211
214
|
|
|
212
215
|
task.add_done_callback(_cleanup)
|
|
213
216
|
|
|
@@ -215,14 +218,14 @@ class ToolExecutor:
|
|
|
215
218
|
def _partition_tool_calls(
|
|
216
219
|
tool_calls: list[model.ToolCallItem],
|
|
217
220
|
) -> tuple[list[model.ToolCallItem], list[model.ToolCallItem]]:
|
|
218
|
-
|
|
219
|
-
|
|
221
|
+
sequential_tool_calls: list[model.ToolCallItem] = []
|
|
222
|
+
concurrent_tool_calls: list[model.ToolCallItem] = []
|
|
220
223
|
for tool_call in tool_calls:
|
|
221
|
-
if is_sub_agent_tool(tool_call.name):
|
|
222
|
-
|
|
224
|
+
if is_sub_agent_tool(tool_call.name) or tool_call.name in _CONCURRENT_TOOLS:
|
|
225
|
+
concurrent_tool_calls.append(tool_call)
|
|
223
226
|
else:
|
|
224
|
-
|
|
225
|
-
return
|
|
227
|
+
sequential_tool_calls.append(tool_call)
|
|
228
|
+
return sequential_tool_calls, concurrent_tool_calls
|
|
226
229
|
|
|
227
230
|
def _build_tool_call_started(self, tool_call: model.ToolCallItem) -> ToolExecutionCallStarted:
|
|
228
231
|
return ToolExecutionCallStarted(tool_call=tool_call)
|
|
@@ -21,6 +21,15 @@ class TruncationResult:
|
|
|
21
21
|
truncated_length: int = 0
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
FILE_SAVED_PATTERN = re.compile(r"<file_saved>([^<]+)</file_saved>")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _extract_saved_file_path(output: str) -> str | None:
|
|
28
|
+
"""Extract file path from <file_saved> tag if present."""
|
|
29
|
+
match = FILE_SAVED_PATTERN.search(output)
|
|
30
|
+
return match.group(1) if match else None
|
|
31
|
+
|
|
32
|
+
|
|
24
33
|
def _extract_url_filename(url: str) -> str:
|
|
25
34
|
"""Extract a safe filename from a URL."""
|
|
26
35
|
parsed = urlparse(url)
|
|
@@ -116,24 +125,29 @@ class SmartTruncationStrategy(TruncationStrategy):
|
|
|
116
125
|
if original_length <= self.max_length:
|
|
117
126
|
return TruncationResult(output=output, was_truncated=False, original_length=original_length)
|
|
118
127
|
|
|
119
|
-
#
|
|
120
|
-
|
|
128
|
+
# Check if file was already saved (e.g., by WebFetch)
|
|
129
|
+
existing_file_path = _extract_saved_file_path(output)
|
|
130
|
+
saved_file_path = existing_file_path or self._save_to_file(output, tool_call)
|
|
131
|
+
|
|
132
|
+
# Strip existing <file_saved> tag to avoid duplication in head/tail
|
|
133
|
+
content_to_truncate = FILE_SAVED_PATTERN.sub("", output).lstrip("\n") if existing_file_path else output
|
|
134
|
+
content_length = len(content_to_truncate)
|
|
121
135
|
|
|
122
|
-
truncated_length =
|
|
123
|
-
head_content =
|
|
124
|
-
tail_content =
|
|
136
|
+
truncated_length = content_length - self.head_chars - self.tail_chars
|
|
137
|
+
head_content = content_to_truncate[: self.head_chars]
|
|
138
|
+
tail_content = content_to_truncate[-self.tail_chars :]
|
|
125
139
|
|
|
126
140
|
# Build truncated output with file info
|
|
127
141
|
if saved_file_path:
|
|
128
142
|
header = (
|
|
129
|
-
f"<system-reminder>Output truncated
|
|
130
|
-
f"Full
|
|
131
|
-
f"Use Read
|
|
143
|
+
f"<system-reminder>Output truncated ({truncated_length} chars hidden) to reduce context usage. "
|
|
144
|
+
f"Full content saved to <file_saved>{saved_file_path}</file_saved>. "
|
|
145
|
+
f"Use Read(offset, limit) or rg to inspect if needed. "
|
|
132
146
|
f"Showing first {self.head_chars} and last {self.tail_chars} chars:</system-reminder>\n\n"
|
|
133
147
|
)
|
|
134
148
|
else:
|
|
135
149
|
header = (
|
|
136
|
-
f"<system-reminder>Output truncated
|
|
150
|
+
f"<system-reminder>Output truncated ({truncated_length} chars hidden) to reduce context usage. "
|
|
137
151
|
f"Showing first {self.head_chars} and last {self.tail_chars} chars:</system-reminder>\n\n"
|
|
138
152
|
)
|
|
139
153
|
|
|
@@ -5,4 +5,4 @@ The tool automatically processes the response based on Content-Type:
|
|
|
5
5
|
- JSON responses are formatted with indentation
|
|
6
6
|
- Markdown and other text content is returned as-is
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Content is always saved to a local file. The file path is included at the start of the output in a `<file_saved>` tag. For large content that gets truncated, you can read the saved file directly.
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
3
5
|
import urllib.error
|
|
4
6
|
import urllib.request
|
|
5
7
|
from http.client import HTTPResponse
|
|
6
8
|
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlparse
|
|
7
10
|
|
|
8
11
|
from pydantic import BaseModel
|
|
9
12
|
|
|
13
|
+
from klaude_code import const
|
|
10
14
|
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
11
15
|
from klaude_code.core.tool.tool_registry import register
|
|
12
16
|
from klaude_code.protocol import llm_param, model, tools
|
|
13
17
|
|
|
14
18
|
DEFAULT_TIMEOUT_SEC = 30
|
|
15
19
|
DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; KlaudeCode/1.0)"
|
|
20
|
+
WEB_FETCH_SAVE_DIR = Path(const.TOOL_OUTPUT_TRUNCATION_DIR) / "web"
|
|
16
21
|
|
|
17
22
|
|
|
18
23
|
def _extract_content_type(response: HTTPResponse) -> str:
|
|
@@ -43,6 +48,30 @@ def _format_json(text: str) -> str:
|
|
|
43
48
|
return text
|
|
44
49
|
|
|
45
50
|
|
|
51
|
+
def _extract_url_filename(url: str) -> str:
|
|
52
|
+
"""Extract a safe filename from a URL."""
|
|
53
|
+
parsed = urlparse(url)
|
|
54
|
+
host = parsed.netloc.replace(".", "_").replace(":", "_")
|
|
55
|
+
path = parsed.path.strip("/").replace("/", "_")
|
|
56
|
+
name = f"{host}_{path}" if path else host
|
|
57
|
+
name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name)
|
|
58
|
+
return name[:80] if len(name) > 80 else name
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _save_web_content(url: str, content: str) -> str | None:
|
|
62
|
+
"""Save web content to file. Returns file path or None on failure."""
|
|
63
|
+
try:
|
|
64
|
+
WEB_FETCH_SAVE_DIR.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
timestamp = int(time.time())
|
|
66
|
+
identifier = _extract_url_filename(url)
|
|
67
|
+
filename = f"{identifier}-{timestamp}.md"
|
|
68
|
+
file_path = WEB_FETCH_SAVE_DIR / filename
|
|
69
|
+
file_path.write_text(content, encoding="utf-8")
|
|
70
|
+
return str(file_path)
|
|
71
|
+
except OSError:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
46
75
|
def _process_content(content_type: str, text: str) -> str:
|
|
47
76
|
"""Process content based on Content-Type header."""
|
|
48
77
|
if content_type == "text/html":
|
|
@@ -127,9 +156,15 @@ class WebFetchTool(ToolABC):
|
|
|
127
156
|
content_type, text = await asyncio.to_thread(_fetch_url, url)
|
|
128
157
|
processed = _process_content(content_type, text)
|
|
129
158
|
|
|
159
|
+
# Always save content to file
|
|
160
|
+
saved_path = _save_web_content(url, processed)
|
|
161
|
+
|
|
162
|
+
# Build output with file path info
|
|
163
|
+
output = f"<file_saved>{saved_path}</file_saved>\n\n{processed}" if saved_path else processed
|
|
164
|
+
|
|
130
165
|
return model.ToolResultItem(
|
|
131
166
|
status="success",
|
|
132
|
-
output=
|
|
167
|
+
output=output,
|
|
133
168
|
)
|
|
134
169
|
|
|
135
170
|
except urllib.error.HTTPError as e:
|
klaude_code/core/turn.py
CHANGED
|
@@ -100,6 +100,8 @@ class TurnExecutor:
|
|
|
100
100
|
self._context = context
|
|
101
101
|
self._tool_executor: ToolExecutor | None = None
|
|
102
102
|
self._turn_result: TurnResult | None = None
|
|
103
|
+
self._assistant_delta_buffer: list[str] = []
|
|
104
|
+
self._assistant_response_id: str | None = None
|
|
103
105
|
|
|
104
106
|
@property
|
|
105
107
|
def report_back_result(self) -> str | None:
|
|
@@ -138,6 +140,7 @@ class TurnExecutor:
|
|
|
138
140
|
def cancel(self) -> list[events.Event]:
|
|
139
141
|
"""Cancel running tools and return any resulting events."""
|
|
140
142
|
ui_events: list[events.Event] = []
|
|
143
|
+
self._persist_partial_assistant_on_cancel()
|
|
141
144
|
if self._tool_executor is not None:
|
|
142
145
|
for exec_event in self._tool_executor.cancel():
|
|
143
146
|
for ui_event in build_events_from_tool_executor_event(self._context.session_ctx.session_id, exec_event):
|
|
@@ -227,6 +230,9 @@ class TurnExecutor:
|
|
|
227
230
|
session_id=session_ctx.session_id,
|
|
228
231
|
)
|
|
229
232
|
case model.AssistantMessageDelta() as item:
|
|
233
|
+
if item.response_id:
|
|
234
|
+
self._assistant_response_id = item.response_id
|
|
235
|
+
self._assistant_delta_buffer.append(item.content)
|
|
230
236
|
yield events.AssistantMessageDeltaEvent(
|
|
231
237
|
content=item.content,
|
|
232
238
|
response_id=item.response_id,
|
|
@@ -274,6 +280,8 @@ class TurnExecutor:
|
|
|
274
280
|
session_ctx.append_history([turn_result.assistant_message])
|
|
275
281
|
if turn_result.tool_calls:
|
|
276
282
|
session_ctx.append_history(turn_result.tool_calls)
|
|
283
|
+
self._assistant_delta_buffer.clear()
|
|
284
|
+
self._assistant_response_id = None
|
|
277
285
|
|
|
278
286
|
async def _run_tool_executor(self, tool_calls: list[model.ToolCallItem]) -> AsyncGenerator[events.Event]:
|
|
279
287
|
"""Run tools for the turn and translate executor events to UI events."""
|
|
@@ -292,3 +300,23 @@ class TurnExecutor:
|
|
|
292
300
|
yield ui_event
|
|
293
301
|
finally:
|
|
294
302
|
self._tool_executor = None
|
|
303
|
+
|
|
304
|
+
def _persist_partial_assistant_on_cancel(self) -> None:
|
|
305
|
+
"""Persist streamed assistant text when a turn is interrupted.
|
|
306
|
+
|
|
307
|
+
Reasoning and tool calls are intentionally discarded on interrupt; only
|
|
308
|
+
the assistant message text collected so far is saved so it appears in
|
|
309
|
+
subsequent history/context.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
if not self._assistant_delta_buffer:
|
|
313
|
+
return
|
|
314
|
+
partial_text = "".join(self._assistant_delta_buffer) + "<system interrupted by user>"
|
|
315
|
+
if not partial_text:
|
|
316
|
+
return
|
|
317
|
+
message_item = model.AssistantMessageItem(
|
|
318
|
+
content=partial_text,
|
|
319
|
+
response_id=self._assistant_response_id,
|
|
320
|
+
)
|
|
321
|
+
self._context.session_ctx.append_history([message_item])
|
|
322
|
+
self._assistant_delta_buffer.clear()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import os
|
|
2
3
|
from collections.abc import AsyncGenerator
|
|
3
4
|
from typing import override
|
|
4
5
|
|
|
@@ -61,11 +62,20 @@ def build_payload(param: llm_param.LLMCallParameter) -> MessageCreateParamsStrea
|
|
|
61
62
|
class AnthropicClient(LLMClientABC):
|
|
62
63
|
def __init__(self, config: llm_param.LLMConfigParameter):
|
|
63
64
|
super().__init__(config)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
)
|
|
65
|
+
# Remove ANTHROPIC_AUTH_TOKEN env var to prevent anthropic SDK from adding
|
|
66
|
+
# Authorization: Bearer header that may conflict with third-party APIs
|
|
67
|
+
# (e.g., deepseek, moonshot) that use Authorization header for authentication.
|
|
68
|
+
# The API key will be sent via X-Api-Key header instead.
|
|
69
|
+
saved_auth_token = os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
|
|
70
|
+
try:
|
|
71
|
+
client = anthropic.AsyncAnthropic(
|
|
72
|
+
api_key=config.api_key,
|
|
73
|
+
base_url=config.base_url,
|
|
74
|
+
timeout=httpx.Timeout(300.0, connect=15.0, read=285.0),
|
|
75
|
+
)
|
|
76
|
+
finally:
|
|
77
|
+
if saved_auth_token is not None:
|
|
78
|
+
os.environ["ANTHROPIC_AUTH_TOKEN"] = saved_auth_token
|
|
69
79
|
self.client: anthropic.AsyncAnthropic = client
|
|
70
80
|
|
|
71
81
|
@classmethod
|
|
@@ -120,35 +130,38 @@ class AnthropicClient(LLMClientABC):
|
|
|
120
130
|
case BetaRawContentBlockDeltaEvent() as event:
|
|
121
131
|
match event.delta:
|
|
122
132
|
case BetaThinkingDelta() as delta:
|
|
123
|
-
|
|
133
|
+
if delta.thinking:
|
|
134
|
+
metadata_tracker.record_token()
|
|
124
135
|
accumulated_thinking.append(delta.thinking)
|
|
125
136
|
yield model.ReasoningTextDelta(
|
|
126
137
|
content=delta.thinking,
|
|
127
138
|
response_id=response_id,
|
|
128
139
|
)
|
|
129
140
|
case BetaSignatureDelta() as delta:
|
|
130
|
-
metadata_tracker.record_token()
|
|
131
141
|
yield model.ReasoningEncryptedItem(
|
|
132
142
|
encrypted_content=delta.signature,
|
|
133
143
|
response_id=response_id,
|
|
134
144
|
model=str(param.model),
|
|
135
145
|
)
|
|
136
146
|
case BetaTextDelta() as delta:
|
|
137
|
-
|
|
147
|
+
if delta.text:
|
|
148
|
+
metadata_tracker.record_token()
|
|
138
149
|
accumulated_content.append(delta.text)
|
|
139
150
|
yield model.AssistantMessageDelta(
|
|
140
151
|
content=delta.text,
|
|
141
152
|
response_id=response_id,
|
|
142
153
|
)
|
|
143
154
|
case BetaInputJSONDelta() as delta:
|
|
144
|
-
metadata_tracker.record_token()
|
|
145
155
|
if current_tool_inputs is not None:
|
|
156
|
+
if delta.partial_json:
|
|
157
|
+
metadata_tracker.record_token()
|
|
146
158
|
current_tool_inputs.append(delta.partial_json)
|
|
147
159
|
case _:
|
|
148
160
|
pass
|
|
149
161
|
case BetaRawContentBlockStartEvent() as event:
|
|
150
162
|
match event.content_block:
|
|
151
163
|
case BetaToolUseBlock() as block:
|
|
164
|
+
metadata_tracker.record_token()
|
|
152
165
|
yield model.ToolCallStartItem(
|
|
153
166
|
response_id=response_id,
|
|
154
167
|
call_id=block.id,
|
|
@@ -161,6 +174,7 @@ class AnthropicClient(LLMClientABC):
|
|
|
161
174
|
pass
|
|
162
175
|
case BetaRawContentBlockStopEvent() as event:
|
|
163
176
|
if len(accumulated_thinking) > 0:
|
|
177
|
+
metadata_tracker.record_token()
|
|
164
178
|
full_thinking = "".join(accumulated_thinking)
|
|
165
179
|
yield model.ReasoningTextItem(
|
|
166
180
|
content=full_thinking,
|
|
@@ -169,12 +183,14 @@ class AnthropicClient(LLMClientABC):
|
|
|
169
183
|
)
|
|
170
184
|
accumulated_thinking.clear()
|
|
171
185
|
if len(accumulated_content) > 0:
|
|
186
|
+
metadata_tracker.record_token()
|
|
172
187
|
yield model.AssistantMessageItem(
|
|
173
188
|
content="".join(accumulated_content),
|
|
174
189
|
response_id=response_id,
|
|
175
190
|
)
|
|
176
191
|
accumulated_content.clear()
|
|
177
192
|
if current_tool_name and current_tool_call_id:
|
|
193
|
+
metadata_tracker.record_token()
|
|
178
194
|
yield model.ToolCallItem(
|
|
179
195
|
name=current_tool_name,
|
|
180
196
|
call_id=current_tool_call_id,
|
|
@@ -23,7 +23,7 @@ def build_payload(param: llm_param.LLMCallParameter) -> tuple[CompletionCreatePa
|
|
|
23
23
|
|
|
24
24
|
extra_body: dict[str, object] = {}
|
|
25
25
|
|
|
26
|
-
if param.thinking:
|
|
26
|
+
if param.thinking and param.thinking.type == "enabled":
|
|
27
27
|
extra_body["thinking"] = {
|
|
28
28
|
"type": param.thinking.type,
|
|
29
29
|
"budget": param.thinking.budget_tokens,
|
|
@@ -182,7 +182,10 @@ class OpenAICompatibleClient(LLMClientABC):
|
|
|
182
182
|
yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
|
|
183
183
|
|
|
184
184
|
# Finalize
|
|
185
|
-
|
|
185
|
+
flushed_items = state.flush_all()
|
|
186
|
+
if flushed_items:
|
|
187
|
+
metadata_tracker.record_token()
|
|
188
|
+
for item in flushed_items:
|
|
186
189
|
yield item
|
|
187
190
|
|
|
188
191
|
metadata_tracker.set_response_id(state.response_id)
|