klaude-code 2.4.2__py3-none-any.whl → 2.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. klaude_code/app/runtime.py +2 -6
  2. klaude_code/cli/main.py +0 -1
  3. klaude_code/config/assets/builtin_config.yaml +7 -0
  4. klaude_code/const.py +7 -4
  5. klaude_code/core/agent.py +10 -1
  6. klaude_code/core/agent_profile.py +47 -35
  7. klaude_code/core/executor.py +6 -21
  8. klaude_code/core/manager/sub_agent_manager.py +17 -1
  9. klaude_code/core/prompts/prompt-sub-agent-web.md +4 -4
  10. klaude_code/core/task.py +65 -4
  11. klaude_code/core/tool/__init__.py +0 -5
  12. klaude_code/core/tool/context.py +12 -1
  13. klaude_code/core/tool/offload.py +311 -0
  14. klaude_code/core/tool/shell/bash_tool.md +1 -43
  15. klaude_code/core/tool/sub_agent_tool.py +1 -0
  16. klaude_code/core/tool/todo/todo_write_tool.md +0 -23
  17. klaude_code/core/tool/tool_runner.py +14 -9
  18. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  19. klaude_code/core/tool/web/web_fetch_tool.py +14 -39
  20. klaude_code/core/turn.py +128 -139
  21. klaude_code/llm/anthropic/client.py +176 -82
  22. klaude_code/llm/bedrock/client.py +8 -12
  23. klaude_code/llm/claude/client.py +11 -15
  24. klaude_code/llm/client.py +31 -4
  25. klaude_code/llm/codex/client.py +7 -11
  26. klaude_code/llm/google/client.py +150 -69
  27. klaude_code/llm/openai_compatible/client.py +10 -15
  28. klaude_code/llm/openai_compatible/stream.py +68 -6
  29. klaude_code/llm/openrouter/client.py +9 -15
  30. klaude_code/llm/partial_message.py +35 -0
  31. klaude_code/llm/responses/client.py +134 -68
  32. klaude_code/llm/usage.py +30 -0
  33. klaude_code/protocol/commands.py +0 -4
  34. klaude_code/protocol/events/metadata.py +1 -0
  35. klaude_code/protocol/events/system.py +0 -4
  36. klaude_code/protocol/model.py +2 -15
  37. klaude_code/protocol/sub_agent/explore.py +0 -10
  38. klaude_code/protocol/sub_agent/image_gen.py +0 -7
  39. klaude_code/protocol/sub_agent/task.py +0 -10
  40. klaude_code/protocol/sub_agent/web.py +4 -12
  41. klaude_code/session/templates/export_session.html +4 -4
  42. klaude_code/skill/manager.py +2 -1
  43. klaude_code/tui/components/metadata.py +41 -49
  44. klaude_code/tui/components/rich/markdown.py +1 -3
  45. klaude_code/tui/components/rich/theme.py +2 -2
  46. klaude_code/tui/components/tools.py +0 -31
  47. klaude_code/tui/components/welcome.py +1 -32
  48. klaude_code/tui/input/prompt_toolkit.py +25 -9
  49. klaude_code/tui/machine.py +2 -1
  50. {klaude_code-2.4.2.dist-info → klaude_code-2.5.0.dist-info}/METADATA +1 -1
  51. {klaude_code-2.4.2.dist-info → klaude_code-2.5.0.dist-info}/RECORD +53 -53
  52. klaude_code/core/prompts/prompt-nano-banana.md +0 -1
  53. klaude_code/core/tool/truncation.py +0 -203
  54. {klaude_code-2.4.2.dist-info → klaude_code-2.5.0.dist-info}/WHEEL +0 -0
  55. {klaude_code-2.4.2.dist-info → klaude_code-2.5.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,311 @@
1
+ """Tool Output Offload & Truncation Strategies
2
+ ==============================================
3
+
4
+ This module manages how tool outputs are truncated and offloaded to files
5
+ to reduce LLM context usage while preserving access to full content.
6
+
7
+ Design Principles
8
+ -----------------
9
+ Different tools have different output characteristics, so we apply
10
+ tool-specific strategies:
11
+
12
+ ┌─────────────┬─────────────────────────┬─────────────────┬────────────────────────────┐
13
+ │ Tool │ Truncation Style │ Offload Policy │ Rationale │
14
+ ├─────────────┼─────────────────────────┼─────────────────┼────────────────────────────┤
15
+ │ Read │ Head-focused │ Never │ Source file already exists │
16
+ │ │ (line/char limits) │ │ on filesystem; use offset/ │
17
+ │ │ │ │ limit to paginate │
18
+ ├─────────────┼─────────────────────────┼─────────────────┼────────────────────────────┤
19
+ │ Others │ Head + Tail │ On threshold │ Generic fallback strategy │
20
+ │ │ (lines first, then │ │ (2000 lines or 40k chars) │
21
+ │ │ chars as fallback) │ │ │
22
+ └─────────────┴─────────────────────────┴─────────────────┴────────────────────────────┘
23
+
24
+ Implementation Notes
25
+ --------------------
26
+ - Read tool handles its own truncation internally (see read_tool.py)
27
+ - WebFetch handles its own file saving internally (see web_fetch_tool.py)
28
+ - All offload decisions are centralized in this module
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import secrets
34
+ from abc import ABC, abstractmethod
35
+ from dataclasses import dataclass
36
+ from enum import Enum, auto
37
+ from pathlib import Path
38
+ from typing import Protocol
39
+
40
+ from klaude_code.const import (
41
+ TOOL_OUTPUT_DISPLAY_HEAD,
42
+ TOOL_OUTPUT_DISPLAY_HEAD_LINES,
43
+ TOOL_OUTPUT_DISPLAY_TAIL,
44
+ TOOL_OUTPUT_DISPLAY_TAIL_LINES,
45
+ TOOL_OUTPUT_MAX_LENGTH,
46
+ TOOL_OUTPUT_MAX_LINES,
47
+ TOOL_OUTPUT_TRUNCATION_DIR,
48
+ )
49
+ from klaude_code.protocol import tools
50
+
51
+
52
+ class ToolCallLike(Protocol):
53
+ """Protocol for tool call objects."""
54
+
55
+ @property
56
+ def tool_name(self) -> str: ...
57
+
58
+
59
+ # =============================================================================
60
+ # Data Structures
61
+ # =============================================================================
62
+
63
+
64
+ class OffloadPolicy(Enum):
65
+ """When to offload full output to filesystem."""
66
+
67
+ NEVER = auto() # Never offload (e.g., Read - source file exists)
68
+ ON_THRESHOLD = auto() # Offload only when exceeding size threshold
69
+
70
+
71
+ class TruncationStyle(Enum):
72
+ """How to truncate content that exceeds limits."""
73
+
74
+ HEAD_ONLY = auto() # Keep head, discard tail (important content at top)
75
+ HEAD_TAIL = auto() # Keep head and tail, discard middle (errors at end)
76
+
77
+
78
+ @dataclass
79
+ class OffloadResult:
80
+ """Result of offload/truncation operation."""
81
+
82
+ output: str
83
+ was_truncated: bool
84
+ offloaded_path: str | None = None
85
+ original_length: int = 0
86
+ truncated_chars: int = 0
87
+
88
+
89
+ # =============================================================================
90
+ # Strategy Interface
91
+ # =============================================================================
92
+
93
+
94
+ class OffloadStrategy(ABC):
95
+ """Base class for tool-specific offload strategies."""
96
+
97
+ @property
98
+ @abstractmethod
99
+ def offload_policy(self) -> OffloadPolicy:
100
+ """When to offload content to file."""
101
+ ...
102
+
103
+ @property
104
+ @abstractmethod
105
+ def truncation_style(self) -> TruncationStyle:
106
+ """How to truncate content."""
107
+ ...
108
+
109
+ @abstractmethod
110
+ def process(self, output: str, tool_call: ToolCallLike | None = None) -> OffloadResult:
111
+ """Process tool output: truncate and optionally offload."""
112
+ ...
113
+
114
+
115
+ # =============================================================================
116
+ # Strategy Implementations
117
+ # =============================================================================
118
+
119
+
120
+ class ReadToolStrategy(OffloadStrategy):
121
+ """Strategy for Read tool output.
122
+
123
+ - Truncation: Head-focused (handled internally by read_tool.py)
124
+ - Offload: Never (source file already on filesystem)
125
+
126
+ This strategy is a pass-through since Read tool handles its own truncation.
127
+ """
128
+
129
+ @property
130
+ def offload_policy(self) -> OffloadPolicy:
131
+ return OffloadPolicy.NEVER
132
+
133
+ @property
134
+ def truncation_style(self) -> TruncationStyle:
135
+ return TruncationStyle.HEAD_ONLY
136
+
137
+ def process(self, output: str, tool_call: ToolCallLike | None = None) -> OffloadResult:
138
+ return OffloadResult(output=output, was_truncated=False, original_length=len(output))
139
+
140
+
141
+ class HeadTailOffloadStrategy(OffloadStrategy):
142
+ """Strategy for Bash and generic tools.
143
+
144
+ - Truncation: Head + Tail (preserve both ends, errors often at end)
145
+ - Offload: Configurable (default: on threshold)
146
+ """
147
+
148
+ def __init__(
149
+ self,
150
+ max_length: int = TOOL_OUTPUT_MAX_LENGTH,
151
+ head_chars: int = TOOL_OUTPUT_DISPLAY_HEAD,
152
+ tail_chars: int = TOOL_OUTPUT_DISPLAY_TAIL,
153
+ max_lines: int = TOOL_OUTPUT_MAX_LINES,
154
+ head_lines: int = TOOL_OUTPUT_DISPLAY_HEAD_LINES,
155
+ tail_lines: int = TOOL_OUTPUT_DISPLAY_TAIL_LINES,
156
+ offload_dir: str | None = None,
157
+ policy: OffloadPolicy = OffloadPolicy.ON_THRESHOLD,
158
+ ):
159
+ self.max_length = max_length
160
+ self.head_chars = head_chars
161
+ self.tail_chars = tail_chars
162
+ self.max_lines = max_lines
163
+ self.head_lines = head_lines
164
+ self.tail_lines = tail_lines
165
+ self.offload_dir = Path(offload_dir or TOOL_OUTPUT_TRUNCATION_DIR)
166
+ self._policy = policy
167
+
168
+ @property
169
+ def offload_policy(self) -> OffloadPolicy:
170
+ return self._policy
171
+
172
+ @property
173
+ def truncation_style(self) -> TruncationStyle:
174
+ return TruncationStyle.HEAD_TAIL
175
+
176
+ def _save_to_file(self, output: str, tool_call: ToolCallLike | None) -> str | None:
177
+ """Save full output to file. Returns path or None on failure."""
178
+ try:
179
+ self.offload_dir.mkdir(parents=True, exist_ok=True)
180
+ tool_name = (tool_call.tool_name if tool_call else "unknown").replace("/", "_").lower()
181
+ random_hex = secrets.token_hex(8)
182
+ filename = f"klaude-{tool_name}-{random_hex}.log"
183
+ file_path = self.offload_dir / filename
184
+ file_path.write_text(output, encoding="utf-8")
185
+ return str(file_path)
186
+ except OSError:
187
+ return None
188
+
189
+ def _should_offload(self, needs_truncation: bool) -> bool:
190
+ """Determine if content should be offloaded based on policy."""
191
+ if self._policy == OffloadPolicy.NEVER:
192
+ return False
193
+ # ON_THRESHOLD: offload only when truncating
194
+ return needs_truncation
195
+
196
+ def _truncate_by_lines(self, output: str, lines: list[str], offloaded_path: str | None) -> tuple[str, int]:
197
+ """Truncate by lines. Returns (truncated_output, hidden_lines)."""
198
+ total_lines = len(lines)
199
+ hidden_lines = total_lines - self.head_lines - self.tail_lines
200
+ head = "\n".join(lines[: self.head_lines])
201
+ tail = "\n".join(lines[-self.tail_lines :])
202
+
203
+ if offloaded_path:
204
+ header = (
205
+ f"<system-reminder>Output truncated due to length. "
206
+ f"Showing first {self.head_lines} and last {self.tail_lines} lines of {total_lines} lines. "
207
+ f"Full output saved to: {offloaded_path} </system-reminder>\n\n"
208
+ )
209
+ else:
210
+ header = (
211
+ f"<system-reminder>Output truncated due to length. "
212
+ f"Showing first {self.head_lines} and last {self.tail_lines} lines of {total_lines} lines."
213
+ f"</system-reminder>\n\n"
214
+ )
215
+
216
+ truncated_output = f"{header}{head}\n\n<...{hidden_lines} lines omitted...>\n\n{tail}"
217
+ return truncated_output, hidden_lines
218
+
219
+ def _truncate_by_chars(self, output: str, offloaded_path: str | None) -> tuple[str, int]:
220
+ """Truncate by characters. Returns (truncated_output, hidden_chars)."""
221
+ original_length = len(output)
222
+ hidden_chars = original_length - self.head_chars - self.tail_chars
223
+ head = output[: self.head_chars]
224
+ tail = output[-self.tail_chars :]
225
+
226
+ if offloaded_path:
227
+ header = (
228
+ f"<system-reminder>Output truncated due to length. "
229
+ f"Showing first {self.head_chars} and last {self.tail_chars} chars of {original_length} chars. "
230
+ f"Full output saved to: {offloaded_path} </system-reminder>\n\n"
231
+ )
232
+ else:
233
+ header = (
234
+ f"<system-reminder>Output truncated due to length. "
235
+ f"Showing first {self.head_chars} and last {self.tail_chars} chars of {original_length} chars."
236
+ f"</system-reminder>\n\n"
237
+ )
238
+
239
+ truncated_output = f"{header}{head}\n\n<...{hidden_chars} chars omitted...>\n\n{tail}"
240
+ return truncated_output, hidden_chars
241
+
242
+ def process(self, output: str, tool_call: ToolCallLike | None = None) -> OffloadResult:
243
+ original_length = len(output)
244
+ lines = output.splitlines()
245
+ total_lines = len(lines)
246
+
247
+ # Check if truncation is needed (by lines or by chars)
248
+ needs_line_truncation = total_lines > self.max_lines
249
+ needs_char_truncation = original_length > self.max_length
250
+ needs_truncation = needs_line_truncation or needs_char_truncation
251
+
252
+ # No truncation needed
253
+ if not needs_truncation:
254
+ return OffloadResult(
255
+ output=output,
256
+ was_truncated=False,
257
+ original_length=original_length,
258
+ )
259
+
260
+ # Truncation needed - offload if policy allows
261
+ offloaded_path = None
262
+ if self._should_offload(needs_truncation):
263
+ offloaded_path = self._save_to_file(output, tool_call)
264
+
265
+ # Prefer line-based truncation if line limit exceeded
266
+ if needs_line_truncation:
267
+ truncated_output, hidden = self._truncate_by_lines(output, lines, offloaded_path)
268
+ else:
269
+ truncated_output, hidden = self._truncate_by_chars(output, offloaded_path)
270
+
271
+ return OffloadResult(
272
+ output=truncated_output,
273
+ was_truncated=True,
274
+ offloaded_path=offloaded_path,
275
+ original_length=original_length,
276
+ truncated_chars=hidden,
277
+ )
278
+
279
+
280
+ # =============================================================================
281
+ # Strategy Registry
282
+ # =============================================================================
283
+
284
+ _STRATEGY_REGISTRY: dict[str, OffloadStrategy] = {
285
+ tools.READ: ReadToolStrategy(),
286
+ }
287
+
288
+ _DEFAULT_STRATEGY = HeadTailOffloadStrategy()
289
+
290
+
291
+ def get_strategy(tool_name: str | None) -> OffloadStrategy:
292
+ """Get the appropriate strategy for a tool."""
293
+ if tool_name and tool_name in _STRATEGY_REGISTRY:
294
+ return _STRATEGY_REGISTRY[tool_name]
295
+ return _DEFAULT_STRATEGY
296
+
297
+
298
+ # =============================================================================
299
+ # Public API
300
+ # =============================================================================
301
+
302
+
303
+ def offload_tool_output(output: str, tool_call: ToolCallLike | None = None) -> OffloadResult:
304
+ """Process tool output with appropriate offload/truncation strategy.
305
+
306
+ This is the main entry point. It selects the right strategy based on
307
+ the tool type and applies truncation/offload as needed.
308
+ """
309
+ tool_name = tool_call.tool_name if tool_call else None
310
+ strategy = get_strategy(tool_name)
311
+ return strategy.process(output, tool_call)
@@ -1,43 +1 @@
1
- Runs a shell command and returns its output.
2
-
3
- ### Usage Notes
4
- - When searching for text or files, prefer using `rg`, `rg --files` or `fd` respectively because `rg` and `fd` is much faster than alternatives like `grep` and `find`. (If these command is not found, then use alternatives.)
5
-
6
- ### Committing changes with git
7
- Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:
8
-
9
- Git Safety Protocol:
10
- - NEVER update the git config
11
- - NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them
12
- - NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
13
- - NEVER run force push to main/master, warn the user if they request it
14
- - Avoid git commit --amend. ONLY use --amend when either (1) user explicitly requested amend OR (2) adding edits from pre-commit hook (additional instructions below)
15
- - Before amending: ALWAYS check authorship (git log -1 --format='%an %ae')
16
- - NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
17
-
18
- 1. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, batch your tool calls together for optimal performance. run the following bash commands in parallel, each using the Bash tool:
19
- - Run a git status command to see all untracked files.
20
- - Run a git diff command to see both staged and unstaged changes that will be committed.
21
- - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
22
- 2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:
23
- - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.).
24
- - Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
25
- - Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
26
- - Ensure it accurately reflects the changes and their purpose
27
- 3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, batch your tool calls together for optimal performance. run the following commands in parallel:
28
- - Add relevant untracked files to the staging area.
29
- - Run git status to make sure the commit succeeded.
30
- 4. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
31
-
32
- Important notes:
33
- - NEVER run additional commands to read or explore code, besides git bash commands
34
- - NEVER use the TodoWrite or Task tools
35
- - DO NOT push to the remote repository unless the user explicitly asks you to do so
36
- - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
37
- - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
38
- - In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:<example>
39
- git commit -m "$(cat <<'EOF'
40
- Commit message here.
41
- EOF
42
- )"
43
- </example>
1
+ Runs a shell command and returns stdout and stderr.
@@ -111,6 +111,7 @@ class SubAgentTool(ToolABC):
111
111
  generation=generation_dict,
112
112
  ),
113
113
  context.record_sub_agent_session_id,
114
+ context.register_sub_agent_metadata_getter,
114
115
  )
115
116
  except asyncio.CancelledError:
116
117
  raise
@@ -1,25 +1,2 @@
1
1
  Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
2
2
  It also helps the user understand the progress of the task and overall progress of their requests.
3
-
4
- #### When to Use This Tool
5
- Use this tool proactively in these scenarios:
6
-
7
- 1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
8
- 2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
9
- 3. User explicitly requests todo list - When the user directly asks you to use the todo list
10
- 4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
11
- 5. After receiving new instructions - Immediately capture user requirements as todos
12
- 6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time
13
- 7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation
14
-
15
- #### When NOT to Use This Tool
16
-
17
- Skip using this tool when:
18
- 1. There is only a single, straightforward task
19
- 2. The task is trivial and tracking it provides no organizational benefit
20
- 3. The task can be completed in less than 3 trivial steps
21
- 4. The task is purely conversational or informational
22
-
23
- NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.
24
-
25
- When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully.
@@ -4,9 +4,9 @@ from dataclasses import dataclass
4
4
 
5
5
  from klaude_code.const import CANCEL_OUTPUT
6
6
  from klaude_code.core.tool.context import ToolContext
7
+ from klaude_code.core.tool.offload import offload_tool_output
7
8
  from klaude_code.core.tool.report_back_tool import ReportBackTool
8
9
  from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy
9
- from klaude_code.core.tool.truncation import truncate_tool_output
10
10
  from klaude_code.protocol import message, model, tools
11
11
 
12
12
 
@@ -52,14 +52,8 @@ async def run_tool(
52
52
  tool_result.call_id = tool_call.call_id
53
53
  tool_result.tool_name = tool_call.tool_name
54
54
  if tool_result.output_text:
55
- truncation_result = truncate_tool_output(tool_result.output_text, tool_call)
56
- tool_result.output_text = truncation_result.output
57
- if truncation_result.was_truncated and truncation_result.saved_file_path:
58
- tool_result.ui_extra = model.TruncationUIExtra(
59
- saved_file_path=truncation_result.saved_file_path,
60
- original_length=truncation_result.original_length,
61
- truncated_length=truncation_result.truncated_length,
62
- )
55
+ offload_result = offload_tool_output(tool_result.output_text, tool_call)
56
+ tool_result.output_text = offload_result.output
63
57
  return tool_result
64
58
  except asyncio.CancelledError:
65
59
  # Propagate cooperative cancellation so outer layers can handle interrupts correctly.
@@ -126,6 +120,7 @@ class ToolExecutor:
126
120
  self._call_event_emitted: set[str] = set()
127
121
  self._concurrent_tasks: set[asyncio.Task[list[ToolExecutorEvent]]] = set()
128
122
  self._sub_agent_session_ids: dict[str, str] = {}
123
+ self._sub_agent_metadata_getters: dict[str, Callable[[], model.TaskMetadata | None]] = {}
129
124
 
130
125
  async def run_tools(self, tool_calls: list[ToolCallRequest]) -> AsyncGenerator[ToolExecutorEvent]:
131
126
  """Run the given tool calls and yield execution events.
@@ -219,12 +214,16 @@ class ToolExecutor:
219
214
  unfinished = list(self._unfinished_calls.items())
220
215
  for idx, (call_id, tool_call) in enumerate(unfinished):
221
216
  session_id = self._sub_agent_session_ids.get(call_id)
217
+ # Get partial metadata from sub-agent if available
218
+ metadata_getter = self._sub_agent_metadata_getters.get(call_id)
219
+ task_metadata = metadata_getter() if metadata_getter is not None else None
222
220
  cancel_result = message.ToolResultMessage(
223
221
  call_id=tool_call.call_id,
224
222
  output_text=CANCEL_OUTPUT,
225
223
  status="aborted",
226
224
  tool_name=tool_call.tool_name,
227
225
  ui_extra=model.SessionIdUIExtra(session_id=session_id) if session_id else None,
226
+ task_metadata=task_metadata,
228
227
  )
229
228
 
230
229
  if call_id not in self._call_event_emitted:
@@ -242,6 +241,7 @@ class ToolExecutor:
242
241
  self._append_history([cancel_result])
243
242
  self._unfinished_calls.pop(call_id, None)
244
243
  self._sub_agent_session_ids.pop(call_id, None)
244
+ self._sub_agent_metadata_getters.pop(call_id, None)
245
245
 
246
246
  return events_to_yield
247
247
 
@@ -278,7 +278,11 @@ class ToolExecutor:
278
278
  if tool_call.call_id not in self._sub_agent_session_ids:
279
279
  self._sub_agent_session_ids[tool_call.call_id] = session_id
280
280
 
281
+ def _register_metadata_getter(getter: Callable[[], model.TaskMetadata | None]) -> None:
282
+ self._sub_agent_metadata_getters[tool_call.call_id] = getter
283
+
281
284
  call_context = self._context.with_record_sub_agent_session_id(_record_sub_agent_session_id)
285
+ call_context = call_context.with_register_sub_agent_metadata_getter(_register_metadata_getter)
282
286
  tool_result: message.ToolResultMessage = await run_tool(tool_call, self._registry, call_context)
283
287
 
284
288
  self._append_history([tool_result])
@@ -287,6 +291,7 @@ class ToolExecutor:
287
291
 
288
292
  self._unfinished_calls.pop(tool_call.call_id, None)
289
293
  self._sub_agent_session_ids.pop(tool_call.call_id, None)
294
+ self._sub_agent_metadata_getters.pop(tool_call.call_id, None)
290
295
 
291
296
  extra_events = self._build_tool_side_effect_events(tool_result)
292
297
  return [result_event, *extra_events]
@@ -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
- 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.
8
+ Content is always saved to a local file. The file path is shown at the start of the output in `[Web content saved to ...]` format. For large content that gets truncated, you can read the saved file directly.
@@ -1,7 +1,6 @@
1
1
  import asyncio
2
2
  import json
3
3
  import re
4
- import time
5
4
  import urllib.error
6
5
  import urllib.request
7
6
  from http.client import HTTPResponse
@@ -21,7 +20,7 @@ from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolM
21
20
  from klaude_code.core.tool.tool_registry import register
22
21
  from klaude_code.protocol import llm_param, message, tools
23
22
 
24
- WEB_FETCH_SAVE_DIR = Path(TOOL_OUTPUT_TRUNCATION_DIR) / "web"
23
+ WEB_FETCH_SAVE_DIR = Path(TOOL_OUTPUT_TRUNCATION_DIR)
25
24
 
26
25
 
27
26
  def _encode_url(url: str) -> str:
@@ -29,7 +28,6 @@ def _encode_url(url: str) -> str:
29
28
  parsed = urlparse(url)
30
29
  encoded_path = quote(parsed.path, safe="/-_.~")
31
30
  encoded_query = quote(parsed.query, safe="=&-_.~")
32
- # Handle IDN (Internationalized Domain Names) by encoding to punycode
33
31
  try:
34
32
  netloc = parsed.netloc.encode("idna").decode("ascii")
35
33
  except UnicodeError:
@@ -55,38 +53,30 @@ def _extract_content_type_and_charset(response: HTTPResponse) -> tuple[str, str
55
53
 
56
54
  def _detect_encoding(data: bytes, declared_charset: str | None) -> str:
57
55
  """Detect the encoding of the data."""
58
- # 1. Use declared charset from HTTP header if available
59
56
  if declared_charset:
60
57
  return declared_charset
61
58
 
62
- # 2. Try to detect from HTML meta tags (check first 2KB)
63
59
  head = data[:2048].lower()
64
- # <meta charset="xxx">
65
60
  if match := re.search(rb'<meta[^>]+charset=["\']?([^"\'\s>]+)', head):
66
61
  return match.group(1).decode("ascii", errors="ignore")
67
- # <meta http-equiv="Content-Type" content="text/html; charset=xxx">
68
62
  if match := re.search(rb'content=["\'][^"\']*charset=([^"\'\s;]+)', head):
69
63
  return match.group(1).decode("ascii", errors="ignore")
70
64
 
71
- # 3. Use chardet for automatic detection
72
65
  import chardet
73
66
 
74
67
  result = chardet.detect(data)
75
68
  if result["encoding"] and result["confidence"] and result["confidence"] > 0.7:
76
69
  return result["encoding"]
77
70
 
78
- # 4. Default to UTF-8
79
71
  return "utf-8"
80
72
 
81
73
 
82
74
  def _decode_content(data: bytes, declared_charset: str | None) -> str:
83
75
  """Decode bytes to string with automatic encoding detection."""
84
76
  encoding = _detect_encoding(data, declared_charset)
85
-
86
77
  try:
87
78
  return data.decode(encoding)
88
79
  except (UnicodeDecodeError, LookupError):
89
- # Fallback: try UTF-8 with replacement for invalid chars
90
80
  return data.decode("utf-8", errors="replace")
91
81
 
92
82
 
@@ -117,29 +107,27 @@ def _extract_url_filename(url: str) -> str:
117
107
  return name[:URL_FILENAME_MAX_LENGTH] if len(name) > URL_FILENAME_MAX_LENGTH else name
118
108
 
119
109
 
120
- def _save_web_content(url: str, content: str, extension: str = ".md") -> str | None:
121
- """Save web content to file. Returns file path or None on failure."""
110
+ def _save_binary_content(url: str, data: bytes, extension: str = ".bin") -> str | None:
111
+ """Save binary content to file. Returns file path or None on failure."""
122
112
  try:
123
113
  WEB_FETCH_SAVE_DIR.mkdir(parents=True, exist_ok=True)
124
- timestamp = int(time.time())
125
114
  identifier = _extract_url_filename(url)
126
- filename = f"{identifier}-{timestamp}{extension}"
115
+ filename = f"klaude-webfetch-{identifier}{extension}"
127
116
  file_path = WEB_FETCH_SAVE_DIR / filename
128
- file_path.write_text(content, encoding="utf-8")
117
+ file_path.write_bytes(data)
129
118
  return str(file_path)
130
119
  except OSError:
131
120
  return None
132
121
 
133
122
 
134
- def _save_binary_content(url: str, data: bytes, extension: str = ".bin") -> str | None:
135
- """Save binary content to file. Returns file path or None on failure."""
123
+ def _save_text_content(url: str, content: str) -> str | None:
124
+ """Save text content to file. Returns file path or None on failure."""
136
125
  try:
137
126
  WEB_FETCH_SAVE_DIR.mkdir(parents=True, exist_ok=True)
138
- timestamp = int(time.time())
139
127
  identifier = _extract_url_filename(url)
140
- filename = f"{identifier}-{timestamp}{extension}"
128
+ filename = f"klaude-webfetch-{identifier}.txt"
141
129
  file_path = WEB_FETCH_SAVE_DIR / filename
142
- file_path.write_bytes(data)
130
+ file_path.write_text(content, encoding="utf-8")
143
131
  return str(file_path)
144
132
  except OSError:
145
133
  return None
@@ -164,15 +152,7 @@ def _process_content(content_type: str, text: str) -> str:
164
152
 
165
153
 
166
154
  def _fetch_url(url: str, timeout: int = WEB_FETCH_DEFAULT_TIMEOUT_SEC) -> tuple[str, bytes, str | None]:
167
- """
168
- Fetch URL content synchronously.
169
-
170
- Returns:
171
- Tuple of (content_type, raw_data, charset)
172
-
173
- Raises:
174
- Various exceptions on failure
175
- """
155
+ """Fetch URL content synchronously."""
176
156
  headers = {
177
157
  "Accept": "text/markdown, */*",
178
158
  "User-Agent": WEB_FETCH_USER_AGENT,
@@ -229,7 +209,6 @@ class WebFetchTool(ToolABC):
229
209
  del context
230
210
  url = args.url
231
211
 
232
- # Basic URL validation
233
212
  if not url.startswith(("http://", "https://")):
234
213
  return message.ToolResultMessage(
235
214
  status="error",
@@ -239,7 +218,7 @@ class WebFetchTool(ToolABC):
239
218
  try:
240
219
  content_type, data, charset = await asyncio.to_thread(_fetch_url, url)
241
220
 
242
- # Handle PDF files
221
+ # Handle PDF files - must save binary content
243
222
  if content_type == "application/pdf" or _is_pdf_url(url):
244
223
  saved_path = _save_binary_content(url, data, ".pdf")
245
224
  if saved_path:
@@ -252,15 +231,11 @@ class WebFetchTool(ToolABC):
252
231
  output_text=f"Failed to save PDF file (url={url})",
253
232
  )
254
233
 
255
- # Handle text content
234
+ # Handle text content - save to file and return with path hint
256
235
  text = _decode_content(data, charset)
257
236
  processed = _process_content(content_type, text)
258
-
259
- # Always save content to file
260
- saved_path = _save_web_content(url, processed)
261
-
262
- # Build output with file path info
263
- output = f"<file_saved>{saved_path}</file_saved>\n\n{processed}" if saved_path else processed
237
+ saved_path = _save_text_content(url, processed)
238
+ output = f"[Web content saved to {saved_path}]\n\n{processed}" if saved_path else processed
264
239
 
265
240
  return message.ToolResultMessage(
266
241
  status="success",