klaude-code 1.2.16__py3-none-any.whl → 1.2.18__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 +3 -9
- klaude_code/cli/runtime.py +20 -13
- klaude_code/command/__init__.py +7 -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 +145 -0
- 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 +2 -4
- 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 +13 -8
- klaude_code/config/config.py +16 -17
- klaude_code/config/select_model.py +81 -5
- klaude_code/const/__init__.py +1 -1
- klaude_code/core/executor.py +236 -109
- klaude_code/core/manager/__init__.py +2 -4
- klaude_code/core/manager/sub_agent_manager.py +1 -1
- klaude_code/core/prompts/prompt-claude-code.md +1 -1
- klaude_code/core/prompts/prompt-sub-agent-oracle.md +0 -1
- klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
- klaude_code/core/reminders.py +9 -35
- klaude_code/core/task.py +8 -0
- klaude_code/core/tool/__init__.py +2 -0
- klaude_code/core/tool/file/read_tool.py +38 -10
- klaude_code/core/tool/report_back_tool.py +28 -2
- klaude_code/core/tool/shell/bash_tool.py +22 -2
- 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/tool/web/web_search_tool.md +23 -0
- klaude_code/core/tool/web/web_search_tool.py +126 -0
- klaude_code/core/turn.py +28 -0
- klaude_code/protocol/commands.py +2 -0
- klaude_code/protocol/events.py +8 -0
- klaude_code/protocol/sub_agent/__init__.py +1 -1
- klaude_code/protocol/sub_agent/explore.py +1 -1
- klaude_code/protocol/sub_agent/web.py +79 -0
- klaude_code/protocol/tools.py +1 -0
- klaude_code/session/session.py +2 -2
- klaude_code/session/templates/export_session.html +123 -37
- klaude_code/trace/__init__.py +20 -2
- klaude_code/ui/modes/repl/completers.py +19 -2
- klaude_code/ui/modes/repl/event_handler.py +44 -15
- klaude_code/ui/modes/repl/renderer.py +3 -3
- klaude_code/ui/renderers/metadata.py +2 -4
- klaude_code/ui/renderers/sub_agent.py +14 -10
- klaude_code/ui/renderers/thinking.py +24 -8
- klaude_code/ui/renderers/tools.py +83 -20
- klaude_code/ui/rich/code_panel.py +112 -0
- klaude_code/ui/rich/markdown.py +3 -4
- klaude_code/ui/rich/status.py +30 -6
- klaude_code/ui/rich/theme.py +10 -1
- {klaude_code-1.2.16.dist-info → klaude_code-1.2.18.dist-info}/METADATA +126 -25
- {klaude_code-1.2.16.dist-info → klaude_code-1.2.18.dist-info}/RECORD +67 -63
- klaude_code/core/manager/agent_manager.py +0 -132
- klaude_code/core/prompts/prompt-sub-agent-webfetch.md +0 -46
- klaude_code/protocol/sub_agent/web_fetch.py +0 -74
- /klaude_code/{config → cli}/list_model.py +0 -0
- {klaude_code-1.2.16.dist-info → klaude_code-1.2.18.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.16.dist-info → klaude_code-1.2.18.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
|
|
@@ -292,10 +311,21 @@ class ReadTool(ToolABC):
|
|
|
292
311
|
|
|
293
312
|
# After limit/offset, if total selected chars exceed limit, error (only check if limits are enabled)
|
|
294
313
|
if max_chars is not None and read_result.selected_chars_count > max_chars:
|
|
314
|
+
# Build segment statistics for better guidance
|
|
315
|
+
stats_lines: list[str] = []
|
|
316
|
+
for start, end, chars in read_result.segment_char_stats:
|
|
317
|
+
stats_lines.append(f" Lines {start}-{end}: {chars} chars")
|
|
318
|
+
segment_stats_str = "\n".join(stats_lines) if stats_lines else " (no segment data)"
|
|
319
|
+
|
|
295
320
|
return model.ToolResultItem(
|
|
296
321
|
status="error",
|
|
297
322
|
output=(
|
|
298
|
-
f"
|
|
323
|
+
f"Selected file content {read_result.selected_chars_count} chars exceeds maximum allowed chars ({max_chars}).\n"
|
|
324
|
+
f"File has {read_result.total_lines} total lines.\n\n"
|
|
325
|
+
f"Character distribution by segment:\n{segment_stats_str}\n\n"
|
|
326
|
+
f"Use offset and limit parameters to read specific portions. "
|
|
327
|
+
f"For example: offset=1, limit=100 to read the first 100 lines. "
|
|
328
|
+
f"Or use `rg` command to search for specific content."
|
|
299
329
|
),
|
|
300
330
|
)
|
|
301
331
|
|
|
@@ -304,8 +334,6 @@ class ReadTool(ToolABC):
|
|
|
304
334
|
if read_result.remaining_selected_beyond_cap > 0:
|
|
305
335
|
lines_out.append(f"... (more {read_result.remaining_selected_beyond_cap} lines are truncated)")
|
|
306
336
|
read_result_str = "\n".join(lines_out)
|
|
307
|
-
# if read_result_str:
|
|
308
|
-
# read_result_str += "\n\n" + SYSTEM_REMINDER_MALICIOUS
|
|
309
337
|
|
|
310
338
|
# Update FileTracker with last modified time
|
|
311
339
|
_track_file_access(file_path)
|
|
@@ -1,10 +1,35 @@
|
|
|
1
1
|
"""ReportBackTool for sub-agents to return structured output."""
|
|
2
2
|
|
|
3
|
-
from typing import Any, ClassVar
|
|
3
|
+
from typing import Any, ClassVar, cast
|
|
4
4
|
|
|
5
5
|
from klaude_code.protocol import llm_param, model, tools
|
|
6
6
|
|
|
7
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
|
+
|
|
8
33
|
class ReportBackTool:
|
|
9
34
|
"""Special tool for sub-agents to return structured output and end the task.
|
|
10
35
|
|
|
@@ -29,7 +54,8 @@ class ReportBackTool:
|
|
|
29
54
|
Returns:
|
|
30
55
|
A new class with the schema set as a class variable.
|
|
31
56
|
"""
|
|
32
|
-
|
|
57
|
+
normalized = _normalize_schema_types(schema)
|
|
58
|
+
return type("ReportBackTool", (ReportBackTool,), {"_schema": normalized})
|
|
33
59
|
|
|
34
60
|
@classmethod
|
|
35
61
|
def schema(cls) -> llm_param.ToolSchema:
|
|
@@ -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)
|
|
@@ -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:
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
- Search the web and use the results to inform responses
|
|
2
|
+
- Provides up-to-date information for current events and recent data
|
|
3
|
+
- Returns search result information formatted as search result blocks, including links as markdown hyperlinks
|
|
4
|
+
- Use this tool for accessing information beyond your knowledge cutoff
|
|
5
|
+
- Searches are performed automatically within a single API call
|
|
6
|
+
|
|
7
|
+
CRITICAL REQUIREMENT - You MUST follow this:
|
|
8
|
+
- After answering the user's question, you MUST include a "Sources:" section at the end of your response
|
|
9
|
+
- In the Sources section, list all relevant URLs from the search results as markdown hyperlinks: [Title](URL)
|
|
10
|
+
- This is MANDATORY - never skip including sources in your response
|
|
11
|
+
- Example format:
|
|
12
|
+
|
|
13
|
+
[Your answer here]
|
|
14
|
+
|
|
15
|
+
Sources:
|
|
16
|
+
- [Source Title 1](https://example.com/1)
|
|
17
|
+
- [Source Title 2](https://example.com/2)
|
|
18
|
+
|
|
19
|
+
Usage notes:
|
|
20
|
+
- Domain filtering is supported to include or block specific websites
|
|
21
|
+
- Web search is only available in the US
|
|
22
|
+
- Account for "Today's date" in <env>. For example, if <env> says "Today's date: 2025-07-01", and the user wants the latest docs, do not use 2024 in the search query. Use 2025.
|
|
23
|
+
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
8
|
+
from klaude_code.core.tool.tool_registry import register
|
|
9
|
+
from klaude_code.protocol import llm_param, model, tools
|
|
10
|
+
|
|
11
|
+
DEFAULT_MAX_RESULTS = 10
|
|
12
|
+
MAX_RESULTS_LIMIT = 20
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class SearchResult:
|
|
17
|
+
"""A single search result from DuckDuckGo."""
|
|
18
|
+
|
|
19
|
+
title: str
|
|
20
|
+
url: str
|
|
21
|
+
snippet: str
|
|
22
|
+
position: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _search_duckduckgo(query: str, max_results: int) -> list[SearchResult]:
|
|
26
|
+
"""Perform a web search using ddgs library."""
|
|
27
|
+
from ddgs import DDGS # type: ignore
|
|
28
|
+
|
|
29
|
+
results: list[SearchResult] = []
|
|
30
|
+
|
|
31
|
+
with DDGS() as ddgs:
|
|
32
|
+
for i, r in enumerate(ddgs.text(query, max_results=max_results)):
|
|
33
|
+
results.append(
|
|
34
|
+
SearchResult(
|
|
35
|
+
title=r.get("title", ""),
|
|
36
|
+
url=r.get("href", ""),
|
|
37
|
+
snippet=r.get("body", ""),
|
|
38
|
+
position=i + 1,
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return results
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _format_results(results: list[SearchResult]) -> str:
|
|
46
|
+
"""Format search results for LLM consumption."""
|
|
47
|
+
if not results:
|
|
48
|
+
return (
|
|
49
|
+
"No results were found for your search query. "
|
|
50
|
+
"Please try rephrasing your search or using different keywords."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
lines = [f"Found {len(results)} search results:\n"]
|
|
54
|
+
|
|
55
|
+
for result in results:
|
|
56
|
+
lines.append(f"{result.position}. {result.title}")
|
|
57
|
+
lines.append(f" URL: {result.url}")
|
|
58
|
+
lines.append(f" Summary: {result.snippet}\n")
|
|
59
|
+
|
|
60
|
+
return "\n".join(lines)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@register(tools.WEB_SEARCH)
|
|
64
|
+
class WebSearchTool(ToolABC):
|
|
65
|
+
@classmethod
|
|
66
|
+
def schema(cls) -> llm_param.ToolSchema:
|
|
67
|
+
return llm_param.ToolSchema(
|
|
68
|
+
name=tools.WEB_SEARCH,
|
|
69
|
+
type="function",
|
|
70
|
+
description=load_desc(Path(__file__).parent / "web_search_tool.md"),
|
|
71
|
+
parameters={
|
|
72
|
+
"type": "object",
|
|
73
|
+
"properties": {
|
|
74
|
+
"query": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"description": "The search query to use",
|
|
77
|
+
},
|
|
78
|
+
"max_results": {
|
|
79
|
+
"type": "integer",
|
|
80
|
+
"description": f"Maximum number of results to return (default: {DEFAULT_MAX_RESULTS}, max: {MAX_RESULTS_LIMIT})",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
"required": ["query"],
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
class WebSearchArguments(BaseModel):
|
|
88
|
+
query: str
|
|
89
|
+
max_results: int = DEFAULT_MAX_RESULTS
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
async def call(cls, arguments: str) -> model.ToolResultItem:
|
|
93
|
+
try:
|
|
94
|
+
args = WebSearchTool.WebSearchArguments.model_validate_json(arguments)
|
|
95
|
+
except ValueError as e:
|
|
96
|
+
return model.ToolResultItem(
|
|
97
|
+
status="error",
|
|
98
|
+
output=f"Invalid arguments: {e}",
|
|
99
|
+
)
|
|
100
|
+
return await cls.call_with_args(args)
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
async def call_with_args(cls, args: WebSearchArguments) -> model.ToolResultItem:
|
|
104
|
+
query = args.query.strip()
|
|
105
|
+
if not query:
|
|
106
|
+
return model.ToolResultItem(
|
|
107
|
+
status="error",
|
|
108
|
+
output="Query cannot be empty",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
max_results = min(max(args.max_results, 1), MAX_RESULTS_LIMIT)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
results = await asyncio.to_thread(_search_duckduckgo, query, max_results)
|
|
115
|
+
formatted = _format_results(results)
|
|
116
|
+
|
|
117
|
+
return model.ToolResultItem(
|
|
118
|
+
status="success",
|
|
119
|
+
output=formatted,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
except Exception as e:
|
|
123
|
+
return model.ToolResultItem(
|
|
124
|
+
status="error",
|
|
125
|
+
output=f"Search failed: {e}",
|
|
126
|
+
)
|
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()
|
klaude_code/protocol/commands.py
CHANGED
|
@@ -3,6 +3,7 @@ from enum import Enum
|
|
|
3
3
|
|
|
4
4
|
class CommandName(str, Enum):
|
|
5
5
|
INIT = "init"
|
|
6
|
+
DEBUG = "debug"
|
|
6
7
|
DIFF = "diff"
|
|
7
8
|
HELP = "help"
|
|
8
9
|
MODEL = "model"
|
|
@@ -11,6 +12,7 @@ class CommandName(str, Enum):
|
|
|
11
12
|
CLEAR = "clear"
|
|
12
13
|
TERMINAL_SETUP = "terminal-setup"
|
|
13
14
|
EXPORT = "export"
|
|
15
|
+
EXPORT_ONLINE = "export-online"
|
|
14
16
|
STATUS = "status"
|
|
15
17
|
RELEASE_NOTES = "release-notes"
|
|
16
18
|
THINKING = "thinking"
|