klaude-code 1.2.18__py3-none-any.whl → 1.2.20__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/main.py +42 -22
- klaude_code/cli/runtime.py +46 -2
- klaude_code/{version.py → cli/self_update.py} +110 -2
- klaude_code/command/__init__.py +1 -3
- klaude_code/command/clear_cmd.py +5 -4
- klaude_code/command/command_abc.py +5 -40
- klaude_code/command/debug_cmd.py +2 -2
- klaude_code/command/diff_cmd.py +2 -1
- klaude_code/command/export_cmd.py +14 -49
- klaude_code/command/export_online_cmd.py +10 -4
- klaude_code/command/help_cmd.py +2 -1
- klaude_code/command/model_cmd.py +7 -5
- klaude_code/command/prompt-jj-workspace.md +18 -0
- klaude_code/command/prompt_command.py +16 -9
- klaude_code/command/refresh_cmd.py +3 -2
- klaude_code/command/registry.py +98 -28
- klaude_code/command/release_notes_cmd.py +2 -1
- klaude_code/command/status_cmd.py +2 -1
- klaude_code/command/terminal_setup_cmd.py +2 -1
- klaude_code/command/thinking_cmd.py +6 -4
- klaude_code/core/executor.py +187 -180
- klaude_code/core/manager/sub_agent_manager.py +3 -0
- klaude_code/core/prompt.py +4 -1
- klaude_code/core/prompts/prompt-sub-agent-explore.md +14 -2
- klaude_code/core/prompts/prompt-sub-agent-web.md +3 -3
- klaude_code/core/reminders.py +70 -26
- klaude_code/core/task.py +13 -12
- klaude_code/core/tool/__init__.py +2 -0
- klaude_code/core/tool/file/apply_patch_tool.py +3 -1
- klaude_code/core/tool/file/edit_tool.py +7 -5
- klaude_code/core/tool/file/multi_edit_tool.py +7 -5
- klaude_code/core/tool/file/read_tool.md +1 -1
- klaude_code/core/tool/file/read_tool.py +8 -4
- klaude_code/core/tool/file/write_tool.py +8 -6
- klaude_code/core/tool/memory/skill_loader.py +12 -10
- klaude_code/core/tool/shell/bash_tool.py +89 -17
- klaude_code/core/tool/sub_agent_tool.py +5 -1
- klaude_code/core/tool/tool_abc.py +18 -0
- klaude_code/core/tool/tool_context.py +6 -6
- klaude_code/core/tool/tool_registry.py +1 -1
- klaude_code/core/tool/tool_runner.py +7 -7
- klaude_code/core/tool/web/web_fetch_tool.py +77 -22
- klaude_code/core/tool/web/web_search_tool.py +5 -1
- klaude_code/llm/anthropic/client.py +25 -9
- klaude_code/llm/openai_compatible/client.py +5 -2
- klaude_code/llm/openrouter/client.py +7 -3
- klaude_code/llm/responses/client.py +6 -1
- klaude_code/protocol/model.py +8 -1
- klaude_code/protocol/op.py +47 -0
- klaude_code/protocol/op_handler.py +25 -1
- klaude_code/protocol/sub_agent/web.py +1 -1
- klaude_code/session/codec.py +71 -0
- klaude_code/session/export.py +21 -11
- klaude_code/session/session.py +186 -322
- klaude_code/session/store.py +215 -0
- klaude_code/session/templates/export_session.html +48 -47
- klaude_code/ui/modes/repl/completers.py +211 -71
- klaude_code/ui/modes/repl/event_handler.py +7 -23
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +5 -7
- klaude_code/ui/modes/repl/renderer.py +2 -2
- klaude_code/ui/renderers/common.py +54 -0
- klaude_code/ui/renderers/developer.py +2 -3
- klaude_code/ui/renderers/errors.py +1 -1
- klaude_code/ui/renderers/metadata.py +10 -1
- klaude_code/ui/renderers/tools.py +3 -4
- klaude_code/ui/rich/__init__.py +10 -1
- klaude_code/ui/rich/cjk_wrap.py +228 -0
- klaude_code/ui/rich/status.py +0 -1
- klaude_code/ui/utils/common.py +0 -18
- {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/METADATA +18 -2
- {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/RECORD +73 -70
- klaude_code/ui/utils/debouncer.py +0 -42
- {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/entry_points.txt +0 -0
|
@@ -9,6 +9,8 @@ from klaude_code.protocol import model
|
|
|
9
9
|
from klaude_code.protocol.sub_agent import SubAgentResult
|
|
10
10
|
from klaude_code.session.session import Session
|
|
11
11
|
|
|
12
|
+
type FileTracker = MutableMapping[str, model.FileStatus]
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
@dataclass
|
|
14
16
|
class TodoContext:
|
|
@@ -44,15 +46,13 @@ class ToolContextToken:
|
|
|
44
46
|
finishes running.
|
|
45
47
|
"""
|
|
46
48
|
|
|
47
|
-
file_tracker_token: Token[
|
|
49
|
+
file_tracker_token: Token[FileTracker | None] | None
|
|
48
50
|
todo_token: Token[TodoContext | None] | None
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
# Holds the current file tracker mapping for tool execution context.
|
|
52
54
|
# Set by Agent/Reminder right before invoking a tool.
|
|
53
|
-
current_file_tracker_var: ContextVar[
|
|
54
|
-
"current_file_tracker", default=None
|
|
55
|
-
)
|
|
55
|
+
current_file_tracker_var: ContextVar[FileTracker | None] = ContextVar("current_file_tracker", default=None)
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
# Holds the todo access context for tools.
|
|
@@ -83,7 +83,7 @@ def reset_tool_context(token: ToolContextToken) -> None:
|
|
|
83
83
|
|
|
84
84
|
|
|
85
85
|
@contextmanager
|
|
86
|
-
def tool_context(file_tracker:
|
|
86
|
+
def tool_context(file_tracker: FileTracker, todo_ctx: TodoContext) -> Generator[ToolContextToken]:
|
|
87
87
|
"""Context manager for setting and resetting tool execution context."""
|
|
88
88
|
|
|
89
89
|
file_tracker_token = current_file_tracker_var.set(file_tracker)
|
|
@@ -102,7 +102,7 @@ def build_todo_context(session: Session) -> TodoContext:
|
|
|
102
102
|
return TodoContext(get_todos=store.get, set_todos=store.set)
|
|
103
103
|
|
|
104
104
|
|
|
105
|
-
def get_current_file_tracker() ->
|
|
105
|
+
def get_current_file_tracker() -> FileTracker | None:
|
|
106
106
|
"""Return the current file tracker mapping for this tool context."""
|
|
107
107
|
|
|
108
108
|
return current_file_tracker_var.get()
|
|
@@ -66,7 +66,7 @@ def load_agent_tools(
|
|
|
66
66
|
|
|
67
67
|
# Main agent tools
|
|
68
68
|
if "gpt-5" in model_name:
|
|
69
|
-
tool_names = [tools.BASH, tools.APPLY_PATCH, tools.UPDATE_PLAN]
|
|
69
|
+
tool_names = [tools.BASH, tools.READ, tools.APPLY_PATCH, tools.UPDATE_PLAN]
|
|
70
70
|
elif "gemini-3" in model_name:
|
|
71
71
|
tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE]
|
|
72
72
|
else:
|
|
@@ -4,13 +4,9 @@ from dataclasses import dataclass
|
|
|
4
4
|
|
|
5
5
|
from klaude_code import const
|
|
6
6
|
from klaude_code.core.tool.report_back_tool import ReportBackTool
|
|
7
|
-
from klaude_code.core.tool.tool_abc import ToolABC
|
|
7
|
+
from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy
|
|
8
8
|
from klaude_code.core.tool.truncation import truncate_tool_output
|
|
9
9
|
from klaude_code.protocol import model, tools
|
|
10
|
-
from klaude_code.protocol.sub_agent import is_sub_agent_tool
|
|
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
10
|
|
|
15
11
|
|
|
16
12
|
async def run_tool(tool_call: model.ToolCallItem, registry: dict[str, type[ToolABC]]) -> model.ToolResultItem:
|
|
@@ -214,14 +210,18 @@ class ToolExecutor:
|
|
|
214
210
|
|
|
215
211
|
task.add_done_callback(_cleanup)
|
|
216
212
|
|
|
217
|
-
@staticmethod
|
|
218
213
|
def _partition_tool_calls(
|
|
214
|
+
self,
|
|
219
215
|
tool_calls: list[model.ToolCallItem],
|
|
220
216
|
) -> tuple[list[model.ToolCallItem], list[model.ToolCallItem]]:
|
|
221
217
|
sequential_tool_calls: list[model.ToolCallItem] = []
|
|
222
218
|
concurrent_tool_calls: list[model.ToolCallItem] = []
|
|
223
219
|
for tool_call in tool_calls:
|
|
224
|
-
|
|
220
|
+
tool_cls = self._registry.get(tool_call.name)
|
|
221
|
+
policy = (
|
|
222
|
+
tool_cls.metadata().concurrency_policy if tool_cls is not None else ToolConcurrencyPolicy.SEQUENTIAL
|
|
223
|
+
)
|
|
224
|
+
if policy == ToolConcurrencyPolicy.CONCURRENT:
|
|
225
225
|
concurrent_tool_calls.append(tool_call)
|
|
226
226
|
else:
|
|
227
227
|
sequential_tool_calls.append(tool_call)
|
|
@@ -6,12 +6,12 @@ import urllib.error
|
|
|
6
6
|
import urllib.request
|
|
7
7
|
from http.client import HTTPResponse
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from urllib.parse import urlparse
|
|
9
|
+
from urllib.parse import quote, urlparse, urlunparse
|
|
10
10
|
|
|
11
11
|
from pydantic import BaseModel
|
|
12
12
|
|
|
13
13
|
from klaude_code import const
|
|
14
|
-
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
14
|
+
from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata, load_desc
|
|
15
15
|
from klaude_code.core.tool.tool_registry import register
|
|
16
16
|
from klaude_code.protocol import llm_param, model, tools
|
|
17
17
|
|
|
@@ -20,15 +20,70 @@ DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; KlaudeCode/1.0)"
|
|
|
20
20
|
WEB_FETCH_SAVE_DIR = Path(const.TOOL_OUTPUT_TRUNCATION_DIR) / "web"
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
def
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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")
|
|
66
|
+
|
|
67
|
+
# 3. Use chardet for automatic detection
|
|
68
|
+
import chardet
|
|
27
69
|
|
|
70
|
+
result = chardet.detect(data)
|
|
71
|
+
if result["encoding"] and result["confidence"] and result["confidence"] > 0.7:
|
|
72
|
+
return result["encoding"]
|
|
28
73
|
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
|
|
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")
|
|
32
87
|
|
|
33
88
|
|
|
34
89
|
def _convert_html_to_markdown(html: str) -> str:
|
|
@@ -98,17 +153,22 @@ def _fetch_url(url: str, timeout: int = DEFAULT_TIMEOUT_SEC) -> tuple[str, str]:
|
|
|
98
153
|
"Accept": "text/markdown, */*",
|
|
99
154
|
"User-Agent": DEFAULT_USER_AGENT,
|
|
100
155
|
}
|
|
101
|
-
|
|
156
|
+
encoded_url = _encode_url(url)
|
|
157
|
+
request = urllib.request.Request(encoded_url, headers=headers)
|
|
102
158
|
|
|
103
159
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
104
|
-
content_type =
|
|
160
|
+
content_type, charset = _extract_content_type_and_charset(response)
|
|
105
161
|
data = response.read()
|
|
106
|
-
text =
|
|
162
|
+
text = _decode_content(data, charset)
|
|
107
163
|
return content_type, text
|
|
108
164
|
|
|
109
165
|
|
|
110
166
|
@register(tools.WEB_FETCH)
|
|
111
167
|
class WebFetchTool(ToolABC):
|
|
168
|
+
@classmethod
|
|
169
|
+
def metadata(cls) -> ToolMetadata:
|
|
170
|
+
return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=True)
|
|
171
|
+
|
|
112
172
|
@classmethod
|
|
113
173
|
def schema(cls) -> llm_param.ToolSchema:
|
|
114
174
|
return llm_param.ToolSchema(
|
|
@@ -149,7 +209,7 @@ class WebFetchTool(ToolABC):
|
|
|
149
209
|
if not url.startswith(("http://", "https://")):
|
|
150
210
|
return model.ToolResultItem(
|
|
151
211
|
status="error",
|
|
152
|
-
output="Invalid URL: must start with http:// or https://",
|
|
212
|
+
output=f"Invalid URL: must start with http:// or https:// (url={url})",
|
|
153
213
|
)
|
|
154
214
|
|
|
155
215
|
try:
|
|
@@ -170,25 +230,20 @@ class WebFetchTool(ToolABC):
|
|
|
170
230
|
except urllib.error.HTTPError as e:
|
|
171
231
|
return model.ToolResultItem(
|
|
172
232
|
status="error",
|
|
173
|
-
output=f"HTTP error {e.code}: {e.reason}",
|
|
233
|
+
output=f"HTTP error {e.code}: {e.reason} (url={url})",
|
|
174
234
|
)
|
|
175
235
|
except urllib.error.URLError as e:
|
|
176
236
|
return model.ToolResultItem(
|
|
177
237
|
status="error",
|
|
178
|
-
output=f"URL error: {e.reason}",
|
|
179
|
-
)
|
|
180
|
-
except UnicodeDecodeError as e:
|
|
181
|
-
return model.ToolResultItem(
|
|
182
|
-
status="error",
|
|
183
|
-
output=f"Content is not valid UTF-8: {e}",
|
|
238
|
+
output=f"URL error: {e.reason} (url={url})",
|
|
184
239
|
)
|
|
185
240
|
except TimeoutError:
|
|
186
241
|
return model.ToolResultItem(
|
|
187
242
|
status="error",
|
|
188
|
-
output=f"Request timed out after {DEFAULT_TIMEOUT_SEC} seconds",
|
|
243
|
+
output=f"Request timed out after {DEFAULT_TIMEOUT_SEC} seconds (url={url})",
|
|
189
244
|
)
|
|
190
245
|
except Exception as e:
|
|
191
246
|
return model.ToolResultItem(
|
|
192
247
|
status="error",
|
|
193
|
-
output=f"Failed to fetch URL: {e}",
|
|
248
|
+
output=f"Failed to fetch URL: {e} (url={url})",
|
|
194
249
|
)
|
|
@@ -4,7 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
|
|
5
5
|
from pydantic import BaseModel
|
|
6
6
|
|
|
7
|
-
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
7
|
+
from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata, load_desc
|
|
8
8
|
from klaude_code.core.tool.tool_registry import register
|
|
9
9
|
from klaude_code.protocol import llm_param, model, tools
|
|
10
10
|
|
|
@@ -62,6 +62,10 @@ def _format_results(results: list[SearchResult]) -> str:
|
|
|
62
62
|
|
|
63
63
|
@register(tools.WEB_SEARCH)
|
|
64
64
|
class WebSearchTool(ToolABC):
|
|
65
|
+
@classmethod
|
|
66
|
+
def metadata(cls) -> ToolMetadata:
|
|
67
|
+
return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=False)
|
|
68
|
+
|
|
65
69
|
@classmethod
|
|
66
70
|
def schema(cls) -> llm_param.ToolSchema:
|
|
67
71
|
return llm_param.ToolSchema(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import os
|
|
2
3
|
from collections.abc import AsyncGenerator
|
|
3
4
|
from typing import override
|
|
4
5
|
|
|
@@ -61,11 +62,20 @@ def build_payload(param: llm_param.LLMCallParameter) -> MessageCreateParamsStrea
|
|
|
61
62
|
class AnthropicClient(LLMClientABC):
|
|
62
63
|
def __init__(self, config: llm_param.LLMConfigParameter):
|
|
63
64
|
super().__init__(config)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
)
|
|
65
|
+
# Remove ANTHROPIC_AUTH_TOKEN env var to prevent anthropic SDK from adding
|
|
66
|
+
# Authorization: Bearer header that may conflict with third-party APIs
|
|
67
|
+
# (e.g., deepseek, moonshot) that use Authorization header for authentication.
|
|
68
|
+
# The API key will be sent via X-Api-Key header instead.
|
|
69
|
+
saved_auth_token = os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
|
|
70
|
+
try:
|
|
71
|
+
client = anthropic.AsyncAnthropic(
|
|
72
|
+
api_key=config.api_key,
|
|
73
|
+
base_url=config.base_url,
|
|
74
|
+
timeout=httpx.Timeout(300.0, connect=15.0, read=285.0),
|
|
75
|
+
)
|
|
76
|
+
finally:
|
|
77
|
+
if saved_auth_token is not None:
|
|
78
|
+
os.environ["ANTHROPIC_AUTH_TOKEN"] = saved_auth_token
|
|
69
79
|
self.client: anthropic.AsyncAnthropic = client
|
|
70
80
|
|
|
71
81
|
@classmethod
|
|
@@ -120,35 +130,38 @@ class AnthropicClient(LLMClientABC):
|
|
|
120
130
|
case BetaRawContentBlockDeltaEvent() as event:
|
|
121
131
|
match event.delta:
|
|
122
132
|
case BetaThinkingDelta() as delta:
|
|
123
|
-
|
|
133
|
+
if delta.thinking:
|
|
134
|
+
metadata_tracker.record_token()
|
|
124
135
|
accumulated_thinking.append(delta.thinking)
|
|
125
136
|
yield model.ReasoningTextDelta(
|
|
126
137
|
content=delta.thinking,
|
|
127
138
|
response_id=response_id,
|
|
128
139
|
)
|
|
129
140
|
case BetaSignatureDelta() as delta:
|
|
130
|
-
metadata_tracker.record_token()
|
|
131
141
|
yield model.ReasoningEncryptedItem(
|
|
132
142
|
encrypted_content=delta.signature,
|
|
133
143
|
response_id=response_id,
|
|
134
144
|
model=str(param.model),
|
|
135
145
|
)
|
|
136
146
|
case BetaTextDelta() as delta:
|
|
137
|
-
|
|
147
|
+
if delta.text:
|
|
148
|
+
metadata_tracker.record_token()
|
|
138
149
|
accumulated_content.append(delta.text)
|
|
139
150
|
yield model.AssistantMessageDelta(
|
|
140
151
|
content=delta.text,
|
|
141
152
|
response_id=response_id,
|
|
142
153
|
)
|
|
143
154
|
case BetaInputJSONDelta() as delta:
|
|
144
|
-
metadata_tracker.record_token()
|
|
145
155
|
if current_tool_inputs is not None:
|
|
156
|
+
if delta.partial_json:
|
|
157
|
+
metadata_tracker.record_token()
|
|
146
158
|
current_tool_inputs.append(delta.partial_json)
|
|
147
159
|
case _:
|
|
148
160
|
pass
|
|
149
161
|
case BetaRawContentBlockStartEvent() as event:
|
|
150
162
|
match event.content_block:
|
|
151
163
|
case BetaToolUseBlock() as block:
|
|
164
|
+
metadata_tracker.record_token()
|
|
152
165
|
yield model.ToolCallStartItem(
|
|
153
166
|
response_id=response_id,
|
|
154
167
|
call_id=block.id,
|
|
@@ -161,6 +174,7 @@ class AnthropicClient(LLMClientABC):
|
|
|
161
174
|
pass
|
|
162
175
|
case BetaRawContentBlockStopEvent() as event:
|
|
163
176
|
if len(accumulated_thinking) > 0:
|
|
177
|
+
metadata_tracker.record_token()
|
|
164
178
|
full_thinking = "".join(accumulated_thinking)
|
|
165
179
|
yield model.ReasoningTextItem(
|
|
166
180
|
content=full_thinking,
|
|
@@ -169,12 +183,14 @@ class AnthropicClient(LLMClientABC):
|
|
|
169
183
|
)
|
|
170
184
|
accumulated_thinking.clear()
|
|
171
185
|
if len(accumulated_content) > 0:
|
|
186
|
+
metadata_tracker.record_token()
|
|
172
187
|
yield model.AssistantMessageItem(
|
|
173
188
|
content="".join(accumulated_content),
|
|
174
189
|
response_id=response_id,
|
|
175
190
|
)
|
|
176
191
|
accumulated_content.clear()
|
|
177
192
|
if current_tool_name and current_tool_call_id:
|
|
193
|
+
metadata_tracker.record_token()
|
|
178
194
|
yield model.ToolCallItem(
|
|
179
195
|
name=current_tool_name,
|
|
180
196
|
call_id=current_tool_call_id,
|
|
@@ -23,7 +23,7 @@ def build_payload(param: llm_param.LLMCallParameter) -> tuple[CompletionCreatePa
|
|
|
23
23
|
|
|
24
24
|
extra_body: dict[str, object] = {}
|
|
25
25
|
|
|
26
|
-
if param.thinking:
|
|
26
|
+
if param.thinking and param.thinking.type == "enabled":
|
|
27
27
|
extra_body["thinking"] = {
|
|
28
28
|
"type": param.thinking.type,
|
|
29
29
|
"budget": param.thinking.budget_tokens,
|
|
@@ -182,7 +182,10 @@ class OpenAICompatibleClient(LLMClientABC):
|
|
|
182
182
|
yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
|
|
183
183
|
|
|
184
184
|
# Finalize
|
|
185
|
-
|
|
185
|
+
flushed_items = state.flush_all()
|
|
186
|
+
if flushed_items:
|
|
187
|
+
metadata_tracker.record_token()
|
|
188
|
+
for item in flushed_items:
|
|
186
189
|
yield item
|
|
187
190
|
|
|
188
191
|
metadata_tracker.set_response_id(state.response_id)
|
|
@@ -35,7 +35,7 @@ def build_payload(
|
|
|
35
35
|
extra_headers: dict[str, str] = {}
|
|
36
36
|
|
|
37
37
|
if param.thinking:
|
|
38
|
-
if param.thinking.budget_tokens is not None:
|
|
38
|
+
if param.thinking.type != "disabled" and param.thinking.budget_tokens is not None:
|
|
39
39
|
extra_body["reasoning"] = {
|
|
40
40
|
"max_tokens": param.thinking.budget_tokens,
|
|
41
41
|
"enable": True,
|
|
@@ -139,7 +139,8 @@ class OpenRouterClient(LLMClientABC):
|
|
|
139
139
|
for item in reasoning_details:
|
|
140
140
|
try:
|
|
141
141
|
reasoning_detail = ReasoningDetail.model_validate(item)
|
|
142
|
-
|
|
142
|
+
if reasoning_detail.text or reasoning_detail.summary:
|
|
143
|
+
metadata_tracker.record_token()
|
|
143
144
|
state.stage = "reasoning"
|
|
144
145
|
# Yield delta immediately for streaming
|
|
145
146
|
if reasoning_detail.text:
|
|
@@ -198,7 +199,10 @@ class OpenRouterClient(LLMClientABC):
|
|
|
198
199
|
yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
|
|
199
200
|
|
|
200
201
|
# Finalize
|
|
201
|
-
|
|
202
|
+
flushed_items = state.flush_all()
|
|
203
|
+
if flushed_items:
|
|
204
|
+
metadata_tracker.record_token()
|
|
205
|
+
for item in flushed_items:
|
|
202
206
|
yield item
|
|
203
207
|
|
|
204
208
|
metadata_tracker.set_response_id(state.response_id)
|
|
@@ -77,6 +77,7 @@ async def parse_responses_stream(
|
|
|
77
77
|
yield model.StartItem(response_id=response_id)
|
|
78
78
|
case responses.ResponseReasoningSummaryTextDeltaEvent() as event:
|
|
79
79
|
if event.delta:
|
|
80
|
+
metadata_tracker.record_token()
|
|
80
81
|
yield model.ReasoningTextDelta(
|
|
81
82
|
content=event.delta,
|
|
82
83
|
response_id=response_id,
|
|
@@ -89,10 +90,12 @@ async def parse_responses_stream(
|
|
|
89
90
|
model=str(param.model),
|
|
90
91
|
)
|
|
91
92
|
case responses.ResponseTextDeltaEvent() as event:
|
|
92
|
-
|
|
93
|
+
if event.delta:
|
|
94
|
+
metadata_tracker.record_token()
|
|
93
95
|
yield model.AssistantMessageDelta(content=event.delta, response_id=response_id)
|
|
94
96
|
case responses.ResponseOutputItemAddedEvent() as event:
|
|
95
97
|
if isinstance(event.item, responses.ResponseFunctionToolCall):
|
|
98
|
+
metadata_tracker.record_token()
|
|
96
99
|
yield model.ToolCallStartItem(
|
|
97
100
|
response_id=response_id,
|
|
98
101
|
call_id=event.item.call_id,
|
|
@@ -102,6 +105,7 @@ async def parse_responses_stream(
|
|
|
102
105
|
match event.item:
|
|
103
106
|
case responses.ResponseReasoningItem() as item:
|
|
104
107
|
if item.encrypted_content:
|
|
108
|
+
metadata_tracker.record_token()
|
|
105
109
|
yield model.ReasoningEncryptedItem(
|
|
106
110
|
id=item.id,
|
|
107
111
|
encrypted_content=item.encrypted_content,
|
|
@@ -109,6 +113,7 @@ async def parse_responses_stream(
|
|
|
109
113
|
model=str(param.model),
|
|
110
114
|
)
|
|
111
115
|
case responses.ResponseOutputMessage() as item:
|
|
116
|
+
metadata_tracker.record_token()
|
|
112
117
|
yield model.AssistantMessageItem(
|
|
113
118
|
content="\n".join(
|
|
114
119
|
[
|
klaude_code/protocol/model.py
CHANGED
|
@@ -69,6 +69,13 @@ class TodoItem(BaseModel):
|
|
|
69
69
|
active_form: str = Field(default="", alias="activeForm")
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
class FileStatus(BaseModel):
|
|
73
|
+
"""Tracks file state including modification time and memory file flag."""
|
|
74
|
+
|
|
75
|
+
mtime: float
|
|
76
|
+
is_memory: bool = False
|
|
77
|
+
|
|
78
|
+
|
|
72
79
|
class TodoUIExtra(BaseModel):
|
|
73
80
|
todos: list[TodoItem]
|
|
74
81
|
new_completed: list[str]
|
|
@@ -170,7 +177,7 @@ A conversation history input contains:
|
|
|
170
177
|
- [DeveloperMessageItem]
|
|
171
178
|
|
|
172
179
|
When adding a new item, please also modify the following:
|
|
173
|
-
- session.py
|
|
180
|
+
- session/codec.py (ConversationItem registry derived from ConversationItem union)
|
|
174
181
|
"""
|
|
175
182
|
|
|
176
183
|
|
klaude_code/protocol/op.py
CHANGED
|
@@ -23,6 +23,10 @@ class OperationType(Enum):
|
|
|
23
23
|
"""Enumeration of supported operation types."""
|
|
24
24
|
|
|
25
25
|
USER_INPUT = "user_input"
|
|
26
|
+
RUN_AGENT = "run_agent"
|
|
27
|
+
CHANGE_MODEL = "change_model"
|
|
28
|
+
CLEAR_SESSION = "clear_session"
|
|
29
|
+
EXPORT_SESSION = "export_session"
|
|
26
30
|
INTERRUPT = "interrupt"
|
|
27
31
|
INIT_AGENT = "init_agent"
|
|
28
32
|
END = "end"
|
|
@@ -51,6 +55,49 @@ class UserInputOperation(Operation):
|
|
|
51
55
|
await handler.handle_user_input(self)
|
|
52
56
|
|
|
53
57
|
|
|
58
|
+
class RunAgentOperation(Operation):
|
|
59
|
+
"""Operation for launching an agent task for a given session."""
|
|
60
|
+
|
|
61
|
+
type: OperationType = OperationType.RUN_AGENT
|
|
62
|
+
session_id: str
|
|
63
|
+
input: UserInputPayload
|
|
64
|
+
|
|
65
|
+
async def execute(self, handler: OperationHandler) -> None:
|
|
66
|
+
await handler.handle_run_agent(self)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ChangeModelOperation(Operation):
|
|
70
|
+
"""Operation for changing the model used by the active agent session."""
|
|
71
|
+
|
|
72
|
+
type: OperationType = OperationType.CHANGE_MODEL
|
|
73
|
+
session_id: str
|
|
74
|
+
model_name: str
|
|
75
|
+
|
|
76
|
+
async def execute(self, handler: OperationHandler) -> None:
|
|
77
|
+
await handler.handle_change_model(self)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ClearSessionOperation(Operation):
|
|
81
|
+
"""Operation for clearing the active session and starting a new one."""
|
|
82
|
+
|
|
83
|
+
type: OperationType = OperationType.CLEAR_SESSION
|
|
84
|
+
session_id: str
|
|
85
|
+
|
|
86
|
+
async def execute(self, handler: OperationHandler) -> None:
|
|
87
|
+
await handler.handle_clear_session(self)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ExportSessionOperation(Operation):
|
|
91
|
+
"""Operation for exporting a session transcript to HTML."""
|
|
92
|
+
|
|
93
|
+
type: OperationType = OperationType.EXPORT_SESSION
|
|
94
|
+
session_id: str
|
|
95
|
+
output_path: str | None = None
|
|
96
|
+
|
|
97
|
+
async def execute(self, handler: OperationHandler) -> None:
|
|
98
|
+
await handler.handle_export_session(self)
|
|
99
|
+
|
|
100
|
+
|
|
54
101
|
class InterruptOperation(Operation):
|
|
55
102
|
"""Operation for interrupting currently running tasks."""
|
|
56
103
|
|
|
@@ -9,7 +9,15 @@ from __future__ import annotations
|
|
|
9
9
|
from typing import TYPE_CHECKING, Protocol
|
|
10
10
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
|
-
from klaude_code.protocol.op import
|
|
12
|
+
from klaude_code.protocol.op import (
|
|
13
|
+
ChangeModelOperation,
|
|
14
|
+
ClearSessionOperation,
|
|
15
|
+
ExportSessionOperation,
|
|
16
|
+
InitAgentOperation,
|
|
17
|
+
InterruptOperation,
|
|
18
|
+
RunAgentOperation,
|
|
19
|
+
UserInputOperation,
|
|
20
|
+
)
|
|
13
21
|
|
|
14
22
|
|
|
15
23
|
class OperationHandler(Protocol):
|
|
@@ -19,6 +27,22 @@ class OperationHandler(Protocol):
|
|
|
19
27
|
"""Handle a user input operation."""
|
|
20
28
|
...
|
|
21
29
|
|
|
30
|
+
async def handle_run_agent(self, operation: RunAgentOperation) -> None:
|
|
31
|
+
"""Handle a run agent operation."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
async def handle_change_model(self, operation: ChangeModelOperation) -> None:
|
|
35
|
+
"""Handle a change model operation."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
async def handle_clear_session(self, operation: ClearSessionOperation) -> None:
|
|
39
|
+
"""Handle a clear session operation."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
async def handle_export_session(self, operation: ExportSessionOperation) -> None:
|
|
43
|
+
"""Handle an export session operation."""
|
|
44
|
+
...
|
|
45
|
+
|
|
22
46
|
async def handle_interrupt(self, operation: InterruptOperation) -> None:
|
|
23
47
|
"""Handle an interrupt operation."""
|
|
24
48
|
...
|
|
@@ -71,7 +71,7 @@ register_sub_agent(
|
|
|
71
71
|
description=WEB_AGENT_DESCRIPTION,
|
|
72
72
|
parameters=WEB_AGENT_PARAMETERS,
|
|
73
73
|
prompt_file="prompts/prompt-sub-agent-web.md",
|
|
74
|
-
tool_set=(tools.BASH, tools.READ, tools.WEB_FETCH, tools.WEB_SEARCH),
|
|
74
|
+
tool_set=(tools.BASH, tools.READ, tools.WEB_FETCH, tools.WEB_SEARCH, tools.WRITE),
|
|
75
75
|
prompt_builder=_web_agent_prompt_builder,
|
|
76
76
|
active_form="Surfing",
|
|
77
77
|
output_schema_arg="output_format",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, TypeGuard, cast, get_args
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from klaude_code.protocol import model
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_basemodel_subclass(tp: object) -> TypeGuard[type[BaseModel]]:
|
|
12
|
+
return isinstance(tp, type) and issubclass(tp, BaseModel)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _flatten_union(tp: object) -> list[object]:
|
|
16
|
+
args = list(get_args(tp))
|
|
17
|
+
if not args:
|
|
18
|
+
return [tp]
|
|
19
|
+
flattened: list[object] = []
|
|
20
|
+
for arg in args:
|
|
21
|
+
flattened.extend(_flatten_union(arg))
|
|
22
|
+
return flattened
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_type_registry() -> dict[str, type[BaseModel]]:
|
|
26
|
+
registry: dict[str, type[BaseModel]] = {}
|
|
27
|
+
for tp in _flatten_union(model.ConversationItem):
|
|
28
|
+
if not _is_basemodel_subclass(tp):
|
|
29
|
+
continue
|
|
30
|
+
registry[tp.__name__] = tp
|
|
31
|
+
return registry
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_CONVERSATION_ITEM_TYPES: dict[str, type[BaseModel]] = _build_type_registry()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def encode_conversation_item(item: model.ConversationItem) -> dict[str, Any]:
|
|
38
|
+
return {"type": item.__class__.__name__, "data": item.model_dump(mode="json")}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def decode_conversation_item(obj: dict[str, Any]) -> model.ConversationItem | None:
|
|
42
|
+
t = obj.get("type")
|
|
43
|
+
data = obj.get("data", {})
|
|
44
|
+
if not isinstance(t, str) or not isinstance(data, dict):
|
|
45
|
+
return None
|
|
46
|
+
cls = _CONVERSATION_ITEM_TYPES.get(t)
|
|
47
|
+
if cls is None:
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
item = cls(**data)
|
|
51
|
+
except TypeError:
|
|
52
|
+
return None
|
|
53
|
+
# pyright: ignore[reportReturnType]
|
|
54
|
+
return item # type: ignore[return-value]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def encode_jsonl_line(item: model.ConversationItem) -> str:
|
|
58
|
+
return json.dumps(encode_conversation_item(item), ensure_ascii=False) + "\n"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def decode_jsonl_line(line: str) -> model.ConversationItem | None:
|
|
62
|
+
line = line.strip()
|
|
63
|
+
if not line:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
obj = json.loads(line)
|
|
67
|
+
except json.JSONDecodeError:
|
|
68
|
+
return None
|
|
69
|
+
if not isinstance(obj, dict):
|
|
70
|
+
return None
|
|
71
|
+
return decode_conversation_item(cast(dict[str, Any], obj))
|