klaude-code 1.2.6__py3-none-any.whl → 1.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klaude_code/auth/__init__.py +24 -0
- klaude_code/auth/codex/__init__.py +20 -0
- klaude_code/auth/codex/exceptions.py +17 -0
- klaude_code/auth/codex/jwt_utils.py +45 -0
- klaude_code/auth/codex/oauth.py +229 -0
- klaude_code/auth/codex/token_manager.py +84 -0
- klaude_code/cli/auth_cmd.py +73 -0
- klaude_code/cli/config_cmd.py +91 -0
- klaude_code/cli/cost_cmd.py +338 -0
- klaude_code/cli/debug.py +78 -0
- klaude_code/cli/list_model.py +307 -0
- klaude_code/cli/main.py +233 -134
- klaude_code/cli/runtime.py +309 -117
- klaude_code/{version.py → cli/self_update.py} +114 -5
- klaude_code/cli/session_cmd.py +37 -21
- klaude_code/command/__init__.py +88 -27
- klaude_code/command/clear_cmd.py +8 -7
- klaude_code/command/command_abc.py +31 -31
- klaude_code/command/debug_cmd.py +79 -0
- klaude_code/command/export_cmd.py +19 -53
- klaude_code/command/export_online_cmd.py +154 -0
- klaude_code/command/fork_session_cmd.py +267 -0
- klaude_code/command/help_cmd.py +7 -8
- klaude_code/command/model_cmd.py +60 -10
- klaude_code/command/model_select.py +84 -0
- klaude_code/command/prompt-jj-describe.md +32 -0
- klaude_code/command/prompt_command.py +19 -11
- klaude_code/command/refresh_cmd.py +8 -10
- klaude_code/command/registry.py +139 -40
- klaude_code/command/release_notes_cmd.py +84 -0
- klaude_code/command/resume_cmd.py +111 -0
- klaude_code/command/status_cmd.py +104 -60
- klaude_code/command/terminal_setup_cmd.py +7 -9
- klaude_code/command/thinking_cmd.py +98 -0
- klaude_code/config/__init__.py +14 -6
- klaude_code/config/assets/__init__.py +1 -0
- klaude_code/config/assets/builtin_config.yaml +303 -0
- klaude_code/config/builtin_config.py +38 -0
- klaude_code/config/config.py +378 -109
- klaude_code/config/select_model.py +117 -53
- klaude_code/config/thinking.py +269 -0
- klaude_code/{const/__init__.py → const.py} +50 -19
- klaude_code/core/agent.py +20 -28
- klaude_code/core/executor.py +327 -112
- klaude_code/core/manager/__init__.py +2 -4
- klaude_code/core/manager/llm_clients.py +1 -15
- klaude_code/core/manager/llm_clients_builder.py +10 -11
- klaude_code/core/manager/sub_agent_manager.py +37 -6
- klaude_code/core/prompt.py +63 -44
- klaude_code/core/prompts/prompt-claude-code.md +2 -13
- klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
- klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
- klaude_code/core/prompts/prompt-codex.md +9 -42
- klaude_code/core/prompts/prompt-minimal.md +12 -0
- klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
- klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
- klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
- klaude_code/core/reminders.py +283 -95
- klaude_code/core/task.py +113 -75
- klaude_code/core/tool/__init__.py +24 -31
- klaude_code/core/tool/file/_utils.py +36 -0
- klaude_code/core/tool/file/apply_patch.py +17 -25
- klaude_code/core/tool/file/apply_patch_tool.py +57 -77
- klaude_code/core/tool/file/diff_builder.py +151 -0
- klaude_code/core/tool/file/edit_tool.py +50 -63
- klaude_code/core/tool/file/move_tool.md +41 -0
- klaude_code/core/tool/file/move_tool.py +435 -0
- klaude_code/core/tool/file/read_tool.md +1 -1
- klaude_code/core/tool/file/read_tool.py +86 -86
- klaude_code/core/tool/file/write_tool.py +59 -69
- klaude_code/core/tool/report_back_tool.py +84 -0
- klaude_code/core/tool/shell/bash_tool.py +265 -22
- klaude_code/core/tool/shell/command_safety.py +3 -6
- klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
- klaude_code/core/tool/sub_agent_tool.py +13 -2
- klaude_code/core/tool/todo/todo_write_tool.md +0 -157
- klaude_code/core/tool/todo/todo_write_tool.py +1 -1
- klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
- klaude_code/core/tool/todo/update_plan_tool.py +1 -1
- klaude_code/core/tool/tool_abc.py +18 -0
- klaude_code/core/tool/tool_context.py +27 -12
- klaude_code/core/tool/tool_registry.py +7 -7
- klaude_code/core/tool/tool_runner.py +44 -36
- klaude_code/core/tool/truncation.py +29 -14
- klaude_code/core/tool/web/mermaid_tool.md +43 -0
- klaude_code/core/tool/web/mermaid_tool.py +2 -5
- klaude_code/core/tool/web/web_fetch_tool.md +1 -1
- klaude_code/core/tool/web/web_fetch_tool.py +112 -22
- klaude_code/core/tool/web/web_search_tool.md +23 -0
- klaude_code/core/tool/web/web_search_tool.py +130 -0
- klaude_code/core/turn.py +168 -66
- klaude_code/llm/__init__.py +2 -10
- klaude_code/llm/anthropic/client.py +190 -178
- klaude_code/llm/anthropic/input.py +39 -15
- klaude_code/llm/bedrock/__init__.py +3 -0
- klaude_code/llm/bedrock/client.py +60 -0
- klaude_code/llm/client.py +7 -21
- klaude_code/llm/codex/__init__.py +5 -0
- klaude_code/llm/codex/client.py +149 -0
- klaude_code/llm/google/__init__.py +3 -0
- klaude_code/llm/google/client.py +309 -0
- klaude_code/llm/google/input.py +215 -0
- klaude_code/llm/input_common.py +3 -9
- klaude_code/llm/openai_compatible/client.py +72 -164
- klaude_code/llm/openai_compatible/input.py +6 -4
- klaude_code/llm/openai_compatible/stream.py +273 -0
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
- klaude_code/llm/openrouter/client.py +89 -160
- klaude_code/llm/openrouter/input.py +18 -30
- klaude_code/llm/openrouter/reasoning.py +118 -0
- klaude_code/llm/registry.py +39 -7
- klaude_code/llm/responses/client.py +184 -171
- klaude_code/llm/responses/input.py +20 -1
- klaude_code/llm/usage.py +17 -12
- klaude_code/protocol/commands.py +17 -1
- klaude_code/protocol/events.py +31 -4
- klaude_code/protocol/llm_param.py +13 -10
- klaude_code/protocol/model.py +232 -29
- klaude_code/protocol/op.py +90 -1
- klaude_code/protocol/op_handler.py +35 -1
- klaude_code/protocol/sub_agent/__init__.py +117 -0
- klaude_code/protocol/sub_agent/explore.py +63 -0
- klaude_code/protocol/sub_agent/oracle.py +91 -0
- klaude_code/protocol/sub_agent/task.py +61 -0
- klaude_code/protocol/sub_agent/web.py +79 -0
- klaude_code/protocol/tools.py +4 -2
- klaude_code/session/__init__.py +2 -2
- klaude_code/session/codec.py +71 -0
- klaude_code/session/export.py +293 -86
- klaude_code/session/selector.py +89 -67
- klaude_code/session/session.py +320 -309
- klaude_code/session/store.py +220 -0
- klaude_code/session/templates/export_session.html +595 -83
- klaude_code/session/templates/mermaid_viewer.html +926 -0
- klaude_code/skill/__init__.py +27 -0
- klaude_code/skill/assets/deslop/SKILL.md +17 -0
- klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
- klaude_code/skill/assets/handoff/SKILL.md +39 -0
- klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
- klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
- klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
- klaude_code/skill/manager.py +70 -0
- klaude_code/skill/system_skills.py +192 -0
- klaude_code/trace/__init__.py +20 -2
- klaude_code/trace/log.py +150 -5
- klaude_code/ui/__init__.py +4 -9
- klaude_code/ui/core/input.py +1 -1
- klaude_code/ui/core/stage_manager.py +7 -7
- klaude_code/ui/modes/debug/display.py +2 -1
- klaude_code/ui/modes/repl/__init__.py +3 -48
- klaude_code/ui/modes/repl/clipboard.py +5 -5
- klaude_code/ui/modes/repl/completers.py +487 -123
- klaude_code/ui/modes/repl/display.py +5 -4
- klaude_code/ui/modes/repl/event_handler.py +370 -117
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
- klaude_code/ui/modes/repl/key_bindings.py +146 -23
- klaude_code/ui/modes/repl/renderer.py +189 -99
- klaude_code/ui/renderers/assistant.py +9 -2
- klaude_code/ui/renderers/bash_syntax.py +178 -0
- klaude_code/ui/renderers/common.py +78 -0
- klaude_code/ui/renderers/developer.py +104 -48
- klaude_code/ui/renderers/diffs.py +87 -6
- klaude_code/ui/renderers/errors.py +11 -6
- klaude_code/ui/renderers/mermaid_viewer.py +57 -0
- klaude_code/ui/renderers/metadata.py +112 -76
- klaude_code/ui/renderers/sub_agent.py +92 -7
- klaude_code/ui/renderers/thinking.py +40 -18
- klaude_code/ui/renderers/tools.py +405 -227
- klaude_code/ui/renderers/user_input.py +73 -13
- klaude_code/ui/rich/__init__.py +10 -1
- klaude_code/ui/rich/cjk_wrap.py +228 -0
- klaude_code/ui/rich/code_panel.py +131 -0
- klaude_code/ui/rich/live.py +17 -0
- klaude_code/ui/rich/markdown.py +305 -170
- klaude_code/ui/rich/searchable_text.py +10 -13
- klaude_code/ui/rich/status.py +190 -49
- klaude_code/ui/rich/theme.py +135 -39
- klaude_code/ui/terminal/__init__.py +55 -0
- klaude_code/ui/terminal/color.py +1 -1
- klaude_code/ui/terminal/control.py +13 -22
- klaude_code/ui/terminal/notifier.py +44 -4
- klaude_code/ui/terminal/selector.py +658 -0
- klaude_code/ui/utils/common.py +0 -18
- klaude_code-1.8.0.dist-info/METADATA +377 -0
- klaude_code-1.8.0.dist-info/RECORD +219 -0
- {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
- klaude_code/command/diff_cmd.py +0 -138
- klaude_code/command/prompt-dev-docs-update.md +0 -56
- klaude_code/command/prompt-dev-docs.md +0 -46
- klaude_code/config/list_model.py +0 -162
- klaude_code/core/manager/agent_manager.py +0 -127
- klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
- klaude_code/core/tool/file/multi_edit_tool.md +0 -42
- klaude_code/core/tool/file/multi_edit_tool.py +0 -199
- klaude_code/core/tool/memory/memory_tool.md +0 -16
- klaude_code/core/tool/memory/memory_tool.py +0 -462
- klaude_code/llm/openrouter/reasoning_handler.py +0 -209
- klaude_code/protocol/sub_agent.py +0 -348
- klaude_code/ui/utils/debouncer.py +0 -42
- klaude_code-1.2.6.dist-info/METADATA +0 -178
- klaude_code-1.2.6.dist-info/RECORD +0 -167
- /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
- /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
- /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
- {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
|
@@ -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,29 +1,89 @@
|
|
|
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 quote, urlparse, urlunparse
|
|
7
10
|
|
|
8
11
|
from pydantic import BaseModel
|
|
9
12
|
|
|
10
|
-
from klaude_code
|
|
13
|
+
from klaude_code import const
|
|
14
|
+
from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata, 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
|
-
def
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
def _encode_url(url: str) -> str:
|
|
24
|
+
"""Encode non-ASCII characters in URL to make it safe for HTTP requests."""
|
|
25
|
+
parsed = urlparse(url)
|
|
26
|
+
encoded_path = quote(parsed.path, safe="/-_.~")
|
|
27
|
+
encoded_query = quote(parsed.query, safe="=&-_.~")
|
|
28
|
+
# Handle IDN (Internationalized Domain Names) by encoding to punycode
|
|
29
|
+
try:
|
|
30
|
+
netloc = parsed.netloc.encode("idna").decode("ascii")
|
|
31
|
+
except UnicodeError:
|
|
32
|
+
netloc = parsed.netloc
|
|
33
|
+
return urlunparse((parsed.scheme, netloc, encoded_path, parsed.params, encoded_query, parsed.fragment))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _extract_content_type_and_charset(response: HTTPResponse) -> tuple[str, str | None]:
|
|
37
|
+
"""Extract the base content type and charset from Content-Type header."""
|
|
38
|
+
content_type_header = response.getheader("Content-Type", "")
|
|
39
|
+
parts = content_type_header.split(";")
|
|
40
|
+
content_type = parts[0].strip().lower()
|
|
41
|
+
|
|
42
|
+
charset = None
|
|
43
|
+
for part in parts[1:]:
|
|
44
|
+
part = part.strip()
|
|
45
|
+
if part.lower().startswith("charset="):
|
|
46
|
+
charset = part[8:].strip().strip("\"'")
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
return content_type, charset
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _detect_encoding(data: bytes, declared_charset: str | None) -> str:
|
|
53
|
+
"""Detect the encoding of the data."""
|
|
54
|
+
# 1. Use declared charset from HTTP header if available
|
|
55
|
+
if declared_charset:
|
|
56
|
+
return declared_charset
|
|
22
57
|
|
|
58
|
+
# 2. Try to detect from HTML meta tags (check first 2KB)
|
|
59
|
+
head = data[:2048].lower()
|
|
60
|
+
# <meta charset="xxx">
|
|
61
|
+
if match := re.search(rb'<meta[^>]+charset=["\']?([^"\'\s>]+)', head):
|
|
62
|
+
return match.group(1).decode("ascii", errors="ignore")
|
|
63
|
+
# <meta http-equiv="Content-Type" content="text/html; charset=xxx">
|
|
64
|
+
if match := re.search(rb'content=["\'][^"\']*charset=([^"\'\s;]+)', head):
|
|
65
|
+
return match.group(1).decode("ascii", errors="ignore")
|
|
23
66
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
67
|
+
# 3. Use chardet for automatic detection
|
|
68
|
+
import chardet
|
|
69
|
+
|
|
70
|
+
result = chardet.detect(data)
|
|
71
|
+
if result["encoding"] and result["confidence"] and result["confidence"] > 0.7:
|
|
72
|
+
return result["encoding"]
|
|
73
|
+
|
|
74
|
+
# 4. Default to UTF-8
|
|
75
|
+
return "utf-8"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _decode_content(data: bytes, declared_charset: str | None) -> str:
|
|
79
|
+
"""Decode bytes to string with automatic encoding detection."""
|
|
80
|
+
encoding = _detect_encoding(data, declared_charset)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
return data.decode(encoding)
|
|
84
|
+
except (UnicodeDecodeError, LookupError):
|
|
85
|
+
# Fallback: try UTF-8 with replacement for invalid chars
|
|
86
|
+
return data.decode("utf-8", errors="replace")
|
|
27
87
|
|
|
28
88
|
|
|
29
89
|
def _convert_html_to_markdown(html: str) -> str:
|
|
@@ -43,6 +103,30 @@ def _format_json(text: str) -> str:
|
|
|
43
103
|
return text
|
|
44
104
|
|
|
45
105
|
|
|
106
|
+
def _extract_url_filename(url: str) -> str:
|
|
107
|
+
"""Extract a safe filename from a URL."""
|
|
108
|
+
parsed = urlparse(url)
|
|
109
|
+
host = parsed.netloc.replace(".", "_").replace(":", "_")
|
|
110
|
+
path = parsed.path.strip("/").replace("/", "_")
|
|
111
|
+
name = f"{host}_{path}" if path else host
|
|
112
|
+
name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name)
|
|
113
|
+
return name[:80] if len(name) > 80 else name
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _save_web_content(url: str, content: str) -> str | None:
|
|
117
|
+
"""Save web content to file. Returns file path or None on failure."""
|
|
118
|
+
try:
|
|
119
|
+
WEB_FETCH_SAVE_DIR.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
timestamp = int(time.time())
|
|
121
|
+
identifier = _extract_url_filename(url)
|
|
122
|
+
filename = f"{identifier}-{timestamp}.md"
|
|
123
|
+
file_path = WEB_FETCH_SAVE_DIR / filename
|
|
124
|
+
file_path.write_text(content, encoding="utf-8")
|
|
125
|
+
return str(file_path)
|
|
126
|
+
except OSError:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
46
130
|
def _process_content(content_type: str, text: str) -> str:
|
|
47
131
|
"""Process content based on Content-Type header."""
|
|
48
132
|
if content_type == "text/html":
|
|
@@ -69,17 +153,22 @@ def _fetch_url(url: str, timeout: int = DEFAULT_TIMEOUT_SEC) -> tuple[str, str]:
|
|
|
69
153
|
"Accept": "text/markdown, */*",
|
|
70
154
|
"User-Agent": DEFAULT_USER_AGENT,
|
|
71
155
|
}
|
|
72
|
-
|
|
156
|
+
encoded_url = _encode_url(url)
|
|
157
|
+
request = urllib.request.Request(encoded_url, headers=headers)
|
|
73
158
|
|
|
74
159
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
75
|
-
content_type =
|
|
160
|
+
content_type, charset = _extract_content_type_and_charset(response)
|
|
76
161
|
data = response.read()
|
|
77
|
-
text =
|
|
162
|
+
text = _decode_content(data, charset)
|
|
78
163
|
return content_type, text
|
|
79
164
|
|
|
80
165
|
|
|
81
166
|
@register(tools.WEB_FETCH)
|
|
82
167
|
class WebFetchTool(ToolABC):
|
|
168
|
+
@classmethod
|
|
169
|
+
def metadata(cls) -> ToolMetadata:
|
|
170
|
+
return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=True)
|
|
171
|
+
|
|
83
172
|
@classmethod
|
|
84
173
|
def schema(cls) -> llm_param.ToolSchema:
|
|
85
174
|
return llm_param.ToolSchema(
|
|
@@ -120,40 +209,41 @@ class WebFetchTool(ToolABC):
|
|
|
120
209
|
if not url.startswith(("http://", "https://")):
|
|
121
210
|
return model.ToolResultItem(
|
|
122
211
|
status="error",
|
|
123
|
-
output="Invalid URL: must start with http:// or https://",
|
|
212
|
+
output=f"Invalid URL: must start with http:// or https:// (url={url})",
|
|
124
213
|
)
|
|
125
214
|
|
|
126
215
|
try:
|
|
127
216
|
content_type, text = await asyncio.to_thread(_fetch_url, url)
|
|
128
217
|
processed = _process_content(content_type, text)
|
|
129
218
|
|
|
219
|
+
# Always save content to file
|
|
220
|
+
saved_path = _save_web_content(url, processed)
|
|
221
|
+
|
|
222
|
+
# Build output with file path info
|
|
223
|
+
output = f"<file_saved>{saved_path}</file_saved>\n\n{processed}" if saved_path else processed
|
|
224
|
+
|
|
130
225
|
return model.ToolResultItem(
|
|
131
226
|
status="success",
|
|
132
|
-
output=
|
|
227
|
+
output=output,
|
|
133
228
|
)
|
|
134
229
|
|
|
135
230
|
except urllib.error.HTTPError as e:
|
|
136
231
|
return model.ToolResultItem(
|
|
137
232
|
status="error",
|
|
138
|
-
output=f"HTTP error {e.code}: {e.reason}",
|
|
233
|
+
output=f"HTTP error {e.code}: {e.reason} (url={url})",
|
|
139
234
|
)
|
|
140
235
|
except urllib.error.URLError as e:
|
|
141
236
|
return model.ToolResultItem(
|
|
142
237
|
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}",
|
|
238
|
+
output=f"URL error: {e.reason} (url={url})",
|
|
149
239
|
)
|
|
150
240
|
except TimeoutError:
|
|
151
241
|
return model.ToolResultItem(
|
|
152
242
|
status="error",
|
|
153
|
-
output=f"Request timed out after {DEFAULT_TIMEOUT_SEC} seconds",
|
|
243
|
+
output=f"Request timed out after {DEFAULT_TIMEOUT_SEC} seconds (url={url})",
|
|
154
244
|
)
|
|
155
245
|
except Exception as e:
|
|
156
246
|
return model.ToolResultItem(
|
|
157
247
|
status="error",
|
|
158
|
-
output=f"Failed to fetch URL: {e}",
|
|
248
|
+
output=f"Failed to fetch URL: {e} (url={url})",
|
|
159
249
|
)
|
|
@@ -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,130 @@
|
|
|
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, ToolConcurrencyPolicy, ToolMetadata, 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 metadata(cls) -> ToolMetadata:
|
|
67
|
+
return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=False)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def schema(cls) -> llm_param.ToolSchema:
|
|
71
|
+
return llm_param.ToolSchema(
|
|
72
|
+
name=tools.WEB_SEARCH,
|
|
73
|
+
type="function",
|
|
74
|
+
description=load_desc(Path(__file__).parent / "web_search_tool.md"),
|
|
75
|
+
parameters={
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {
|
|
78
|
+
"query": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"description": "The search query to use",
|
|
81
|
+
},
|
|
82
|
+
"max_results": {
|
|
83
|
+
"type": "integer",
|
|
84
|
+
"description": f"Maximum number of results to return (default: {DEFAULT_MAX_RESULTS}, max: {MAX_RESULTS_LIMIT})",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
"required": ["query"],
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
class WebSearchArguments(BaseModel):
|
|
92
|
+
query: str
|
|
93
|
+
max_results: int = DEFAULT_MAX_RESULTS
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
async def call(cls, arguments: str) -> model.ToolResultItem:
|
|
97
|
+
try:
|
|
98
|
+
args = WebSearchTool.WebSearchArguments.model_validate_json(arguments)
|
|
99
|
+
except ValueError as e:
|
|
100
|
+
return model.ToolResultItem(
|
|
101
|
+
status="error",
|
|
102
|
+
output=f"Invalid arguments: {e}",
|
|
103
|
+
)
|
|
104
|
+
return await cls.call_with_args(args)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
async def call_with_args(cls, args: WebSearchArguments) -> model.ToolResultItem:
|
|
108
|
+
query = args.query.strip()
|
|
109
|
+
if not query:
|
|
110
|
+
return model.ToolResultItem(
|
|
111
|
+
status="error",
|
|
112
|
+
output="Query cannot be empty",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
max_results = min(max(args.max_results, 1), MAX_RESULTS_LIMIT)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
results = await asyncio.to_thread(_search_duckduckgo, query, max_results)
|
|
119
|
+
formatted = _format_results(results)
|
|
120
|
+
|
|
121
|
+
return model.ToolResultItem(
|
|
122
|
+
status="success",
|
|
123
|
+
output=formatted,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
except Exception as e:
|
|
127
|
+
return model.ToolResultItem(
|
|
128
|
+
status="error",
|
|
129
|
+
output=f"Search failed: {e}",
|
|
130
|
+
)
|