klaude-code 1.2.6__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 (167) hide show
  1. klaude_code/__init__.py +0 -0
  2. klaude_code/cli/__init__.py +1 -0
  3. klaude_code/cli/main.py +298 -0
  4. klaude_code/cli/runtime.py +331 -0
  5. klaude_code/cli/session_cmd.py +80 -0
  6. klaude_code/command/__init__.py +43 -0
  7. klaude_code/command/clear_cmd.py +20 -0
  8. klaude_code/command/command_abc.py +92 -0
  9. klaude_code/command/diff_cmd.py +138 -0
  10. klaude_code/command/export_cmd.py +86 -0
  11. klaude_code/command/help_cmd.py +51 -0
  12. klaude_code/command/model_cmd.py +43 -0
  13. klaude_code/command/prompt-dev-docs-update.md +56 -0
  14. klaude_code/command/prompt-dev-docs.md +46 -0
  15. klaude_code/command/prompt-init.md +45 -0
  16. klaude_code/command/prompt_command.py +69 -0
  17. klaude_code/command/refresh_cmd.py +43 -0
  18. klaude_code/command/registry.py +110 -0
  19. klaude_code/command/status_cmd.py +111 -0
  20. klaude_code/command/terminal_setup_cmd.py +252 -0
  21. klaude_code/config/__init__.py +11 -0
  22. klaude_code/config/config.py +177 -0
  23. klaude_code/config/list_model.py +162 -0
  24. klaude_code/config/select_model.py +67 -0
  25. klaude_code/const/__init__.py +133 -0
  26. klaude_code/core/__init__.py +0 -0
  27. klaude_code/core/agent.py +165 -0
  28. klaude_code/core/executor.py +485 -0
  29. klaude_code/core/manager/__init__.py +19 -0
  30. klaude_code/core/manager/agent_manager.py +127 -0
  31. klaude_code/core/manager/llm_clients.py +42 -0
  32. klaude_code/core/manager/llm_clients_builder.py +49 -0
  33. klaude_code/core/manager/sub_agent_manager.py +86 -0
  34. klaude_code/core/prompt.py +89 -0
  35. klaude_code/core/prompts/prompt-claude-code.md +98 -0
  36. klaude_code/core/prompts/prompt-codex.md +331 -0
  37. klaude_code/core/prompts/prompt-gemini.md +43 -0
  38. klaude_code/core/prompts/prompt-subagent-explore.md +27 -0
  39. klaude_code/core/prompts/prompt-subagent-oracle.md +23 -0
  40. klaude_code/core/prompts/prompt-subagent-webfetch.md +46 -0
  41. klaude_code/core/prompts/prompt-subagent.md +8 -0
  42. klaude_code/core/reminders.py +445 -0
  43. klaude_code/core/task.py +237 -0
  44. klaude_code/core/tool/__init__.py +75 -0
  45. klaude_code/core/tool/file/__init__.py +0 -0
  46. klaude_code/core/tool/file/apply_patch.py +492 -0
  47. klaude_code/core/tool/file/apply_patch_tool.md +1 -0
  48. klaude_code/core/tool/file/apply_patch_tool.py +204 -0
  49. klaude_code/core/tool/file/edit_tool.md +9 -0
  50. klaude_code/core/tool/file/edit_tool.py +274 -0
  51. klaude_code/core/tool/file/multi_edit_tool.md +42 -0
  52. klaude_code/core/tool/file/multi_edit_tool.py +199 -0
  53. klaude_code/core/tool/file/read_tool.md +14 -0
  54. klaude_code/core/tool/file/read_tool.py +326 -0
  55. klaude_code/core/tool/file/write_tool.md +8 -0
  56. klaude_code/core/tool/file/write_tool.py +146 -0
  57. klaude_code/core/tool/memory/__init__.py +0 -0
  58. klaude_code/core/tool/memory/memory_tool.md +16 -0
  59. klaude_code/core/tool/memory/memory_tool.py +462 -0
  60. klaude_code/core/tool/memory/skill_loader.py +245 -0
  61. klaude_code/core/tool/memory/skill_tool.md +24 -0
  62. klaude_code/core/tool/memory/skill_tool.py +97 -0
  63. klaude_code/core/tool/shell/__init__.py +0 -0
  64. klaude_code/core/tool/shell/bash_tool.md +43 -0
  65. klaude_code/core/tool/shell/bash_tool.py +123 -0
  66. klaude_code/core/tool/shell/command_safety.py +363 -0
  67. klaude_code/core/tool/sub_agent_tool.py +83 -0
  68. klaude_code/core/tool/todo/__init__.py +0 -0
  69. klaude_code/core/tool/todo/todo_write_tool.md +182 -0
  70. klaude_code/core/tool/todo/todo_write_tool.py +121 -0
  71. klaude_code/core/tool/todo/update_plan_tool.md +3 -0
  72. klaude_code/core/tool/todo/update_plan_tool.py +104 -0
  73. klaude_code/core/tool/tool_abc.py +25 -0
  74. klaude_code/core/tool/tool_context.py +106 -0
  75. klaude_code/core/tool/tool_registry.py +78 -0
  76. klaude_code/core/tool/tool_runner.py +252 -0
  77. klaude_code/core/tool/truncation.py +170 -0
  78. klaude_code/core/tool/web/__init__.py +0 -0
  79. klaude_code/core/tool/web/mermaid_tool.md +21 -0
  80. klaude_code/core/tool/web/mermaid_tool.py +76 -0
  81. klaude_code/core/tool/web/web_fetch_tool.md +8 -0
  82. klaude_code/core/tool/web/web_fetch_tool.py +159 -0
  83. klaude_code/core/turn.py +220 -0
  84. klaude_code/llm/__init__.py +21 -0
  85. klaude_code/llm/anthropic/__init__.py +3 -0
  86. klaude_code/llm/anthropic/client.py +221 -0
  87. klaude_code/llm/anthropic/input.py +200 -0
  88. klaude_code/llm/client.py +49 -0
  89. klaude_code/llm/input_common.py +239 -0
  90. klaude_code/llm/openai_compatible/__init__.py +3 -0
  91. klaude_code/llm/openai_compatible/client.py +211 -0
  92. klaude_code/llm/openai_compatible/input.py +109 -0
  93. klaude_code/llm/openai_compatible/tool_call_accumulator.py +80 -0
  94. klaude_code/llm/openrouter/__init__.py +3 -0
  95. klaude_code/llm/openrouter/client.py +200 -0
  96. klaude_code/llm/openrouter/input.py +160 -0
  97. klaude_code/llm/openrouter/reasoning_handler.py +209 -0
  98. klaude_code/llm/registry.py +22 -0
  99. klaude_code/llm/responses/__init__.py +3 -0
  100. klaude_code/llm/responses/client.py +216 -0
  101. klaude_code/llm/responses/input.py +167 -0
  102. klaude_code/llm/usage.py +109 -0
  103. klaude_code/protocol/__init__.py +4 -0
  104. klaude_code/protocol/commands.py +21 -0
  105. klaude_code/protocol/events.py +163 -0
  106. klaude_code/protocol/llm_param.py +147 -0
  107. klaude_code/protocol/model.py +287 -0
  108. klaude_code/protocol/op.py +89 -0
  109. klaude_code/protocol/op_handler.py +28 -0
  110. klaude_code/protocol/sub_agent.py +348 -0
  111. klaude_code/protocol/tools.py +15 -0
  112. klaude_code/session/__init__.py +4 -0
  113. klaude_code/session/export.py +624 -0
  114. klaude_code/session/selector.py +76 -0
  115. klaude_code/session/session.py +474 -0
  116. klaude_code/session/templates/export_session.html +1434 -0
  117. klaude_code/trace/__init__.py +3 -0
  118. klaude_code/trace/log.py +168 -0
  119. klaude_code/ui/__init__.py +91 -0
  120. klaude_code/ui/core/__init__.py +1 -0
  121. klaude_code/ui/core/display.py +103 -0
  122. klaude_code/ui/core/input.py +71 -0
  123. klaude_code/ui/core/stage_manager.py +55 -0
  124. klaude_code/ui/modes/__init__.py +1 -0
  125. klaude_code/ui/modes/debug/__init__.py +1 -0
  126. klaude_code/ui/modes/debug/display.py +36 -0
  127. klaude_code/ui/modes/exec/__init__.py +1 -0
  128. klaude_code/ui/modes/exec/display.py +63 -0
  129. klaude_code/ui/modes/repl/__init__.py +51 -0
  130. klaude_code/ui/modes/repl/clipboard.py +152 -0
  131. klaude_code/ui/modes/repl/completers.py +429 -0
  132. klaude_code/ui/modes/repl/display.py +60 -0
  133. klaude_code/ui/modes/repl/event_handler.py +375 -0
  134. klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
  135. klaude_code/ui/modes/repl/key_bindings.py +170 -0
  136. klaude_code/ui/modes/repl/renderer.py +281 -0
  137. klaude_code/ui/renderers/__init__.py +0 -0
  138. klaude_code/ui/renderers/assistant.py +21 -0
  139. klaude_code/ui/renderers/common.py +8 -0
  140. klaude_code/ui/renderers/developer.py +158 -0
  141. klaude_code/ui/renderers/diffs.py +215 -0
  142. klaude_code/ui/renderers/errors.py +16 -0
  143. klaude_code/ui/renderers/metadata.py +190 -0
  144. klaude_code/ui/renderers/sub_agent.py +71 -0
  145. klaude_code/ui/renderers/thinking.py +39 -0
  146. klaude_code/ui/renderers/tools.py +551 -0
  147. klaude_code/ui/renderers/user_input.py +65 -0
  148. klaude_code/ui/rich/__init__.py +1 -0
  149. klaude_code/ui/rich/live.py +65 -0
  150. klaude_code/ui/rich/markdown.py +308 -0
  151. klaude_code/ui/rich/quote.py +34 -0
  152. klaude_code/ui/rich/searchable_text.py +71 -0
  153. klaude_code/ui/rich/status.py +240 -0
  154. klaude_code/ui/rich/theme.py +274 -0
  155. klaude_code/ui/terminal/__init__.py +1 -0
  156. klaude_code/ui/terminal/color.py +244 -0
  157. klaude_code/ui/terminal/control.py +147 -0
  158. klaude_code/ui/terminal/notifier.py +107 -0
  159. klaude_code/ui/terminal/progress_bar.py +87 -0
  160. klaude_code/ui/utils/__init__.py +1 -0
  161. klaude_code/ui/utils/common.py +108 -0
  162. klaude_code/ui/utils/debouncer.py +42 -0
  163. klaude_code/version.py +163 -0
  164. klaude_code-1.2.6.dist-info/METADATA +178 -0
  165. klaude_code-1.2.6.dist-info/RECORD +167 -0
  166. klaude_code-1.2.6.dist-info/WHEEL +4 -0
  167. klaude_code-1.2.6.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,170 @@
1
+ import json
2
+ import re
3
+ import time
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from urllib.parse import urlparse
8
+
9
+ from klaude_code import const
10
+ from klaude_code.protocol import model, tools
11
+
12
+
13
+ @dataclass
14
+ class TruncationResult:
15
+ """Result of truncation operation."""
16
+
17
+ output: str
18
+ was_truncated: bool
19
+ saved_file_path: str | None = None
20
+ original_length: int = 0
21
+ truncated_length: int = 0
22
+
23
+
24
+ def _extract_url_filename(url: str) -> str:
25
+ """Extract a safe filename from a URL."""
26
+ parsed = urlparse(url)
27
+ # Combine host and path for a meaningful filename
28
+ host = parsed.netloc.replace(".", "_").replace(":", "_")
29
+ path = parsed.path.strip("/").replace("/", "_")
30
+ if path:
31
+ name = f"{host}_{path}"
32
+ else:
33
+ name = host
34
+ # Sanitize: keep only alphanumeric, underscore, hyphen
35
+ name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name)
36
+ # Limit length
37
+ return name[:80] if len(name) > 80 else name
38
+
39
+
40
+ class TruncationStrategy(ABC):
41
+ """Abstract base class for tool output truncation strategies."""
42
+
43
+ @abstractmethod
44
+ def truncate(self, output: str, tool_call: model.ToolCallItem | None = None) -> TruncationResult:
45
+ """Truncate the output according to the strategy."""
46
+ ...
47
+
48
+
49
+ class SimpleTruncationStrategy(TruncationStrategy):
50
+ """Simple character-based truncation strategy."""
51
+
52
+ def __init__(self, max_length: int = const.TOOL_OUTPUT_MAX_LENGTH):
53
+ self.max_length = max_length
54
+
55
+ def truncate(self, output: str, tool_call: model.ToolCallItem | None = None) -> TruncationResult:
56
+ if len(output) > self.max_length:
57
+ truncated_length = len(output) - self.max_length
58
+ truncated_output = output[: self.max_length] + f"... (truncated {truncated_length} characters)"
59
+ return TruncationResult(
60
+ output=truncated_output,
61
+ was_truncated=True,
62
+ original_length=len(output),
63
+ truncated_length=truncated_length,
64
+ )
65
+ return TruncationResult(output=output, was_truncated=False, original_length=len(output))
66
+
67
+
68
+ class SmartTruncationStrategy(TruncationStrategy):
69
+ """Smart truncation strategy that saves full output to file and shows head/tail."""
70
+
71
+ def __init__(
72
+ self,
73
+ max_length: int = const.TOOL_OUTPUT_MAX_LENGTH,
74
+ head_chars: int = const.TOOL_OUTPUT_DISPLAY_HEAD,
75
+ tail_chars: int = const.TOOL_OUTPUT_DISPLAY_TAIL,
76
+ truncation_dir: str = const.TOOL_OUTPUT_TRUNCATION_DIR,
77
+ ):
78
+ self.max_length = max_length
79
+ self.head_chars = head_chars
80
+ self.tail_chars = tail_chars
81
+ self.truncation_dir = Path(truncation_dir)
82
+
83
+ def _get_file_identifier(self, tool_call: model.ToolCallItem | None) -> str:
84
+ """Get a file identifier based on tool call. For WebFetch, use URL; otherwise use call_id."""
85
+ if tool_call and tool_call.name == tools.WEB_FETCH:
86
+ try:
87
+ args = json.loads(tool_call.arguments)
88
+ url = args.get("url", "")
89
+ if url:
90
+ return _extract_url_filename(url)
91
+ except (json.JSONDecodeError, TypeError):
92
+ pass
93
+ # Fallback to call_id
94
+ if tool_call and tool_call.call_id:
95
+ return tool_call.call_id.replace("/", "_")
96
+ return "unknown"
97
+
98
+ def _save_to_file(self, output: str, tool_call: model.ToolCallItem | None) -> str | None:
99
+ """Save full output to file. Returns file path or None on failure."""
100
+ try:
101
+ self.truncation_dir.mkdir(parents=True, exist_ok=True)
102
+ timestamp = int(time.time())
103
+ tool_name = (tool_call.name if tool_call else "unknown").replace("/", "_")
104
+ identifier = self._get_file_identifier(tool_call)
105
+ filename = f"{tool_name}-{identifier}-{timestamp}.txt"
106
+ file_path = self.truncation_dir / filename
107
+ file_path.write_text(output, encoding="utf-8")
108
+ return str(file_path)
109
+ except (OSError, IOError):
110
+ return None
111
+
112
+ def truncate(self, output: str, tool_call: model.ToolCallItem | None = None) -> TruncationResult:
113
+ original_length = len(output)
114
+
115
+ if original_length <= self.max_length:
116
+ return TruncationResult(output=output, was_truncated=False, original_length=original_length)
117
+
118
+ # Save full output to file
119
+ saved_file_path = self._save_to_file(output, tool_call)
120
+
121
+ truncated_length = original_length - self.head_chars - self.tail_chars
122
+ head_content = output[: self.head_chars]
123
+ tail_content = output[-self.tail_chars :]
124
+
125
+ # Build truncated output with file info
126
+ if saved_file_path:
127
+ header = (
128
+ f"<system-reminder>Output truncated: {truncated_length} chars hidden. "
129
+ f"Full tool output saved to {saved_file_path}. "
130
+ f"Use Read with limit+offset or rg/grep to inspect.\n"
131
+ f"Showing first {self.head_chars} and last {self.tail_chars} chars:</system-reminder>\n\n"
132
+ )
133
+ else:
134
+ header = (
135
+ f"<system-reminder>Output truncated: {truncated_length} chars hidden. "
136
+ f"Showing first {self.head_chars} and last {self.tail_chars} chars:</system-reminder>\n\n"
137
+ )
138
+
139
+ truncated_output = (
140
+ f"{header}{head_content}\n\n"
141
+ f"<system-reminder>... {truncated_length} characters omitted ...</system-reminder>\n\n"
142
+ f"{tail_content}"
143
+ )
144
+
145
+ return TruncationResult(
146
+ output=truncated_output,
147
+ was_truncated=True,
148
+ saved_file_path=saved_file_path,
149
+ original_length=original_length,
150
+ truncated_length=truncated_length,
151
+ )
152
+
153
+
154
+ _default_strategy: TruncationStrategy = SmartTruncationStrategy()
155
+
156
+
157
+ def get_truncation_strategy() -> TruncationStrategy:
158
+ """Get the current truncation strategy."""
159
+ return _default_strategy
160
+
161
+
162
+ def set_truncation_strategy(strategy: TruncationStrategy) -> None:
163
+ """Set the truncation strategy to use."""
164
+ global _default_strategy
165
+ _default_strategy = strategy
166
+
167
+
168
+ def truncate_tool_output(output: str, tool_call: model.ToolCallItem | None = None) -> TruncationResult:
169
+ """Truncate tool output using the current strategy."""
170
+ return get_truncation_strategy().truncate(output, tool_call)
File without changes
@@ -0,0 +1,21 @@
1
+ Renders a Mermaid diagram from the provided code.
2
+
3
+ PROACTIVELY USE DIAGRAMS when they would better convey information than prose alone. The diagrams produced by this tool are shown to the user..
4
+
5
+ You should create diagrams WITHOUT being explicitly asked in these scenarios:
6
+ - When explaining system architecture or component relationships
7
+ - When describing workflows, data flows, or user journeys
8
+ - When explaining algorithms or complex processes
9
+ - When illustrating class hierarchies or entity relationships
10
+ - When showing state transitions or event sequences
11
+
12
+ Diagrams are especially valuable for visualizing:
13
+ - Application architecture and dependencies
14
+ - API interactions and data flow
15
+ - Component hierarchies and relationships
16
+ - State machines and transitions
17
+ - Sequence and timing of operations
18
+ - Decision trees and conditional logic
19
+
20
+ # Styling
21
+ - When defining custom classDefs, always define fill color, stroke color, and text color ("fill", "stroke", "color") explicitly
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import zlib
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from klaude_code.core.tool.tool_abc import ToolABC, load_desc
11
+ from klaude_code.core.tool.tool_registry import register
12
+ from klaude_code.protocol import llm_param, model, tools
13
+
14
+ _MERMAID_LIVE_PREFIX = "https://mermaid.live/view#pako:"
15
+
16
+
17
+ @register(tools.MERMAID)
18
+ class MermaidTool(ToolABC):
19
+ """Create shareable Mermaid.live links for diagram rendering."""
20
+
21
+ class MermaidArguments(BaseModel):
22
+ code: str = Field(description="The Mermaid diagram code to render")
23
+
24
+ @classmethod
25
+ def schema(cls) -> llm_param.ToolSchema:
26
+ return llm_param.ToolSchema(
27
+ name=tools.MERMAID,
28
+ type="function",
29
+ description=load_desc(Path(__file__).parent / "mermaid_tool.md"),
30
+ parameters={
31
+ "type": "object",
32
+ "properties": {
33
+ "code": {
34
+ "description": "The Mermaid diagram code to render (DO NOT override with custom colors or other styles, DO NOT use HTML tags in node labels)",
35
+ "type": "string",
36
+ },
37
+ },
38
+ "required": ["code"],
39
+ "additionalProperties": False,
40
+ },
41
+ )
42
+
43
+ @classmethod
44
+ async def call(cls, arguments: str) -> model.ToolResultItem:
45
+ try:
46
+ args = cls.MermaidArguments.model_validate_json(arguments)
47
+ except Exception as exc: # pragma: no cover - defensive
48
+ return model.ToolResultItem(status="error", output=f"Invalid arguments: {exc}")
49
+
50
+ link = cls._build_link(args.code)
51
+ line_count = cls._count_lines(args.code)
52
+ ui_extra = model.ToolResultUIExtra(
53
+ type=model.ToolResultUIExtraType.MERMAID_LINK,
54
+ mermaid_link=model.MermaidLinkUIExtra(link=link, line_count=line_count),
55
+ )
56
+ output = f"Mermaid diagram rendered successfully ({line_count} lines)."
57
+ return model.ToolResultItem(status="success", output=output, ui_extra=ui_extra)
58
+
59
+ @staticmethod
60
+ def _build_link(code: str) -> str:
61
+ state = {
62
+ "code": code,
63
+ "mermaid": {"theme": "neutral"},
64
+ "autoSync": True,
65
+ "updateDiagram": True,
66
+ }
67
+ json_payload = json.dumps(state, ensure_ascii=False)
68
+ compressed = zlib.compress(json_payload.encode("utf-8"), level=9)
69
+ encoded = base64.urlsafe_b64encode(compressed).decode("ascii").rstrip("=")
70
+ return f"{_MERMAID_LIVE_PREFIX}{encoded}"
71
+
72
+ @staticmethod
73
+ def _count_lines(code: str) -> int:
74
+ if not code:
75
+ return 0
76
+ return len(code.splitlines()) or 0
@@ -0,0 +1,8 @@
1
+ Fetch content from a URL and return it in a readable format.
2
+
3
+ The tool automatically processes the response based on Content-Type:
4
+ - HTML pages are converted to Markdown for easier reading
5
+ - JSON responses are formatted with indentation
6
+ - Markdown and other text content is returned as-is
7
+
8
+ Use this tool to retrieve web page content for analysis.
@@ -0,0 +1,159 @@
1
+ import asyncio
2
+ import json
3
+ import urllib.error
4
+ import urllib.request
5
+ from http.client import HTTPResponse
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from klaude_code.core.tool.tool_abc import ToolABC, load_desc
11
+ from klaude_code.core.tool.tool_registry import register
12
+ from klaude_code.protocol import llm_param, model, tools
13
+
14
+ DEFAULT_TIMEOUT_SEC = 30
15
+ DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; KlaudeCode/1.0)"
16
+
17
+
18
+ def _extract_content_type(response: HTTPResponse) -> str:
19
+ """Extract the base content type without charset parameters."""
20
+ content_type = response.getheader("Content-Type", "")
21
+ return content_type.split(";")[0].strip().lower()
22
+
23
+
24
+ def _validate_utf8(data: bytes) -> str:
25
+ """Validate and decode bytes as UTF-8."""
26
+ return data.decode("utf-8")
27
+
28
+
29
+ def _convert_html_to_markdown(html: str) -> str:
30
+ """Convert HTML to Markdown using trafilatura."""
31
+ import trafilatura
32
+
33
+ result = trafilatura.extract(html, output_format="markdown", include_links=True, include_images=True)
34
+ return result or ""
35
+
36
+
37
+ def _format_json(text: str) -> str:
38
+ """Format JSON with indentation."""
39
+ try:
40
+ parsed = json.loads(text)
41
+ return json.dumps(parsed, indent=2, ensure_ascii=False)
42
+ except json.JSONDecodeError:
43
+ return text
44
+
45
+
46
+ def _process_content(content_type: str, text: str) -> str:
47
+ """Process content based on Content-Type header."""
48
+ if content_type == "text/html":
49
+ return _convert_html_to_markdown(text)
50
+ elif content_type == "text/markdown":
51
+ return text
52
+ elif content_type in ("application/json", "text/json"):
53
+ return _format_json(text)
54
+ else:
55
+ return text
56
+
57
+
58
+ def _fetch_url(url: str, timeout: int = DEFAULT_TIMEOUT_SEC) -> tuple[str, str]:
59
+ """
60
+ Fetch URL content synchronously.
61
+
62
+ Returns:
63
+ Tuple of (content_type, response_text)
64
+
65
+ Raises:
66
+ Various exceptions on failure
67
+ """
68
+ headers = {
69
+ "Accept": "text/markdown, */*",
70
+ "User-Agent": DEFAULT_USER_AGENT,
71
+ }
72
+ request = urllib.request.Request(url, headers=headers)
73
+
74
+ with urllib.request.urlopen(request, timeout=timeout) as response:
75
+ content_type = _extract_content_type(response)
76
+ data = response.read()
77
+ text = _validate_utf8(data)
78
+ return content_type, text
79
+
80
+
81
+ @register(tools.WEB_FETCH)
82
+ class WebFetchTool(ToolABC):
83
+ @classmethod
84
+ def schema(cls) -> llm_param.ToolSchema:
85
+ return llm_param.ToolSchema(
86
+ name=tools.WEB_FETCH,
87
+ type="function",
88
+ description=load_desc(Path(__file__).parent / "web_fetch_tool.md"),
89
+ parameters={
90
+ "type": "object",
91
+ "properties": {
92
+ "url": {
93
+ "type": "string",
94
+ "description": "The URL to fetch",
95
+ },
96
+ },
97
+ "required": ["url"],
98
+ },
99
+ )
100
+
101
+ class WebFetchArguments(BaseModel):
102
+ url: str
103
+
104
+ @classmethod
105
+ async def call(cls, arguments: str) -> model.ToolResultItem:
106
+ try:
107
+ args = WebFetchTool.WebFetchArguments.model_validate_json(arguments)
108
+ except ValueError as e:
109
+ return model.ToolResultItem(
110
+ status="error",
111
+ output=f"Invalid arguments: {e}",
112
+ )
113
+ return await cls.call_with_args(args)
114
+
115
+ @classmethod
116
+ async def call_with_args(cls, args: WebFetchArguments) -> model.ToolResultItem:
117
+ url = args.url
118
+
119
+ # Basic URL validation
120
+ if not url.startswith(("http://", "https://")):
121
+ return model.ToolResultItem(
122
+ status="error",
123
+ output="Invalid URL: must start with http:// or https://",
124
+ )
125
+
126
+ try:
127
+ content_type, text = await asyncio.to_thread(_fetch_url, url)
128
+ processed = _process_content(content_type, text)
129
+
130
+ return model.ToolResultItem(
131
+ status="success",
132
+ output=processed,
133
+ )
134
+
135
+ except urllib.error.HTTPError as e:
136
+ return model.ToolResultItem(
137
+ status="error",
138
+ output=f"HTTP error {e.code}: {e.reason}",
139
+ )
140
+ except urllib.error.URLError as e:
141
+ return model.ToolResultItem(
142
+ status="error",
143
+ output=f"URL error: {e.reason}",
144
+ )
145
+ except UnicodeDecodeError as e:
146
+ return model.ToolResultItem(
147
+ status="error",
148
+ output=f"Content is not valid UTF-8: {e}",
149
+ )
150
+ except TimeoutError:
151
+ return model.ToolResultItem(
152
+ status="error",
153
+ output=f"Request timed out after {DEFAULT_TIMEOUT_SEC} seconds",
154
+ )
155
+ except Exception as e:
156
+ return model.ToolResultItem(
157
+ status="error",
158
+ output=f"Failed to fetch URL: {e}",
159
+ )
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncGenerator, Callable, MutableMapping, Sequence
4
+ from dataclasses import dataclass
5
+
6
+ from klaude_code.core.tool import TodoContext, ToolABC, tool_context
7
+ from klaude_code.core.tool.tool_runner import (
8
+ ToolExecutionCallStarted,
9
+ ToolExecutionResult,
10
+ ToolExecutionTodoChange,
11
+ ToolExecutor,
12
+ ToolExecutorEvent,
13
+ )
14
+ from klaude_code.llm import LLMClientABC
15
+ from klaude_code.protocol import events, llm_param, model
16
+ from klaude_code.trace import DebugType, log_debug
17
+
18
+
19
+ class TurnError(Exception):
20
+ """Raised when a turn fails and should be retried."""
21
+
22
+ pass
23
+
24
+
25
+ @dataclass
26
+ class TurnExecutionContext:
27
+ """Execution context required to run a single turn."""
28
+
29
+ session_id: str
30
+ get_conversation_history: Callable[[], list[model.ConversationItem]]
31
+ append_history: Callable[[Sequence[model.ConversationItem]], None]
32
+ llm_client: LLMClientABC
33
+ system_prompt: str | None
34
+ tools: list[llm_param.ToolSchema]
35
+ tool_registry: dict[str, type[ToolABC]]
36
+ # For tool context
37
+ file_tracker: MutableMapping[str, float]
38
+ todo_context: TodoContext
39
+
40
+
41
+ def build_events_from_tool_executor_event(session_id: str, event: ToolExecutorEvent) -> list[events.Event]:
42
+ """Translate internal tool executor events into public protocol events."""
43
+
44
+ ui_events: list[events.Event] = []
45
+
46
+ match event:
47
+ case ToolExecutionCallStarted(tool_call=tool_call):
48
+ ui_events.append(
49
+ events.ToolCallEvent(
50
+ session_id=session_id,
51
+ response_id=tool_call.response_id,
52
+ tool_call_id=tool_call.call_id,
53
+ tool_name=tool_call.name,
54
+ arguments=tool_call.arguments,
55
+ )
56
+ )
57
+ case ToolExecutionResult(tool_call=tool_call, tool_result=tool_result):
58
+ ui_events.append(
59
+ events.ToolResultEvent(
60
+ session_id=session_id,
61
+ response_id=tool_call.response_id,
62
+ tool_call_id=tool_call.call_id,
63
+ tool_name=tool_call.name,
64
+ result=tool_result.output or "",
65
+ ui_extra=tool_result.ui_extra,
66
+ status=tool_result.status,
67
+ )
68
+ )
69
+ case ToolExecutionTodoChange(todos=todos):
70
+ ui_events.append(
71
+ events.TodoChangeEvent(
72
+ session_id=session_id,
73
+ todos=todos,
74
+ )
75
+ )
76
+
77
+ return ui_events
78
+
79
+
80
+ class TurnExecutor:
81
+ """Executes a single model turn including tool calls.
82
+
83
+ Manages the lifecycle of tool execution and tool context internally.
84
+ Raises TurnError on failure.
85
+ """
86
+
87
+ def __init__(self, context: TurnExecutionContext) -> None:
88
+ self._context = context
89
+ self._tool_executor: ToolExecutor | None = None
90
+ self._has_tool_call: bool = False
91
+
92
+ @property
93
+ def has_tool_call(self) -> bool:
94
+ return self._has_tool_call
95
+
96
+ def cancel(self) -> list[events.Event]:
97
+ """Cancel running tools and return any resulting events."""
98
+ ui_events: list[events.Event] = []
99
+ if self._tool_executor is not None:
100
+ for exec_event in self._tool_executor.cancel():
101
+ for ui_event in build_events_from_tool_executor_event(self._context.session_id, exec_event):
102
+ ui_events.append(ui_event)
103
+ self._tool_executor = None
104
+ return ui_events
105
+
106
+ async def run(self) -> AsyncGenerator[events.Event, None]:
107
+ """Execute the turn, yielding events as they occur.
108
+
109
+ Raises:
110
+ TurnError: If the turn fails (stream error or non-completed status).
111
+ """
112
+ ctx = self._context
113
+
114
+ yield events.TurnStartEvent(session_id=ctx.session_id)
115
+
116
+ turn_reasoning_items: list[model.ReasoningTextItem | model.ReasoningEncryptedItem] = []
117
+ turn_assistant_message: model.AssistantMessageItem | None = None
118
+ turn_tool_calls: list[model.ToolCallItem] = []
119
+ response_failed = False
120
+ error_message: str | None = None
121
+
122
+ async for response_item in ctx.llm_client.call(
123
+ llm_param.LLMCallParameter(
124
+ input=ctx.get_conversation_history(),
125
+ system=ctx.system_prompt,
126
+ tools=ctx.tools,
127
+ store=False,
128
+ session_id=ctx.session_id,
129
+ )
130
+ ):
131
+ log_debug(
132
+ f"[{response_item.__class__.__name__}]",
133
+ response_item.model_dump_json(exclude_none=True),
134
+ style="green",
135
+ debug_type=DebugType.RESPONSE,
136
+ )
137
+ match response_item:
138
+ case model.StartItem():
139
+ pass
140
+ case model.ReasoningTextItem() as item:
141
+ turn_reasoning_items.append(item)
142
+ yield events.ThinkingEvent(
143
+ content=item.content,
144
+ response_id=item.response_id,
145
+ session_id=ctx.session_id,
146
+ )
147
+ case model.ReasoningEncryptedItem() as item:
148
+ turn_reasoning_items.append(item)
149
+ case model.AssistantMessageDelta() as item:
150
+ yield events.AssistantMessageDeltaEvent(
151
+ content=item.content,
152
+ response_id=item.response_id,
153
+ session_id=ctx.session_id,
154
+ )
155
+ case model.AssistantMessageItem() as item:
156
+ turn_assistant_message = item
157
+ yield events.AssistantMessageEvent(
158
+ content=item.content or "",
159
+ response_id=item.response_id,
160
+ session_id=ctx.session_id,
161
+ )
162
+ case model.ResponseMetadataItem() as item:
163
+ yield events.ResponseMetadataEvent(
164
+ session_id=ctx.session_id,
165
+ metadata=item,
166
+ )
167
+ status = item.status
168
+ if status is not None and status != "completed":
169
+ response_failed = True
170
+ error_message = f"Response status: {status}"
171
+ case model.StreamErrorItem() as item:
172
+ response_failed = True
173
+ error_message = item.error
174
+ log_debug(
175
+ "[StreamError]",
176
+ item.error,
177
+ style="red",
178
+ debug_type=DebugType.RESPONSE,
179
+ )
180
+ case model.ToolCallStartItem() as item:
181
+ yield events.TurnToolCallStartEvent(
182
+ session_id=ctx.session_id,
183
+ response_id=item.response_id,
184
+ tool_call_id=item.call_id,
185
+ tool_name=item.name,
186
+ arguments="",
187
+ )
188
+ case model.ToolCallItem() as item:
189
+ turn_tool_calls.append(item)
190
+ case _:
191
+ pass
192
+
193
+ if response_failed:
194
+ yield events.TurnEndEvent(session_id=ctx.session_id)
195
+ raise TurnError(error_message or "Turn failed")
196
+
197
+ # Append to history only on success
198
+ if turn_reasoning_items:
199
+ ctx.append_history(turn_reasoning_items)
200
+ if turn_assistant_message:
201
+ ctx.append_history([turn_assistant_message])
202
+ if turn_tool_calls:
203
+ ctx.append_history(turn_tool_calls)
204
+ self._has_tool_call = True
205
+
206
+ # Execute tools
207
+ if turn_tool_calls:
208
+ with tool_context(ctx.file_tracker, ctx.todo_context):
209
+ executor = ToolExecutor(
210
+ registry=ctx.tool_registry,
211
+ append_history=ctx.append_history,
212
+ )
213
+ self._tool_executor = executor
214
+
215
+ async for exec_event in executor.run_tools(turn_tool_calls):
216
+ for ui_event in build_events_from_tool_executor_event(ctx.session_id, exec_event):
217
+ yield ui_event
218
+ self._tool_executor = None
219
+
220
+ yield events.TurnEndEvent(session_id=ctx.session_id)
@@ -0,0 +1,21 @@
1
+ """LLM package init.
2
+
3
+ Imports built-in LLM clients so their ``@register`` decorators run and they
4
+ become available via the registry.
5
+ """
6
+
7
+ from .anthropic import AnthropicClient
8
+ from .client import LLMClientABC
9
+ from .openai_compatible import OpenAICompatibleClient
10
+ from .openrouter import OpenRouterClient
11
+ from .registry import create_llm_client
12
+ from .responses import ResponsesClient
13
+
14
+ __all__ = [
15
+ "LLMClientABC",
16
+ "ResponsesClient",
17
+ "OpenAICompatibleClient",
18
+ "OpenRouterClient",
19
+ "AnthropicClient",
20
+ "create_llm_client",
21
+ ]
@@ -0,0 +1,3 @@
1
+ from .client import AnthropicClient
2
+
3
+ __all__ = ["AnthropicClient"]