klaude-code 1.2.19__py3-none-any.whl → 1.2.21__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 +23 -0
- klaude_code/cli/runtime.py +17 -0
- 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 +2 -1
- 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 +31 -6
- 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 +12 -1
- klaude_code/core/executor.py +177 -190
- klaude_code/core/manager/sub_agent_manager.py +3 -0
- klaude_code/core/prompt.py +4 -1
- klaude_code/core/prompts/prompt-sub-agent-web.md +3 -3
- klaude_code/core/reminders.py +70 -26
- klaude_code/core/task.py +4 -5
- 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.py +5 -2
- klaude_code/core/tool/file/write_tool.py +8 -6
- klaude_code/core/tool/shell/bash_tool.py +90 -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_runner.py +7 -7
- klaude_code/core/tool/web/mermaid_tool.md +26 -0
- klaude_code/core/tool/web/web_fetch_tool.py +77 -22
- klaude_code/core/tool/web/web_search_tool.py +5 -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 +182 -331
- klaude_code/session/store.py +215 -0
- klaude_code/session/templates/export_session.html +13 -14
- klaude_code/ui/modes/repl/completers.py +1 -2
- klaude_code/ui/modes/repl/event_handler.py +7 -23
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +4 -6
- 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-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/METADATA +2 -1
- {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/RECORD +58 -55
- klaude_code/ui/utils/debouncer.py +0 -42
- {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/entry_points.txt +0 -0
klaude_code/core/task.py
CHANGED
|
@@ -2,13 +2,13 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import time
|
|
5
|
-
from collections.abc import AsyncGenerator, Callable,
|
|
5
|
+
from collections.abc import AsyncGenerator, Callable, Sequence
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
9
|
from klaude_code import const
|
|
10
10
|
from klaude_code.core.reminders import Reminder
|
|
11
|
-
from klaude_code.core.tool import TodoContext, ToolABC
|
|
11
|
+
from klaude_code.core.tool import FileTracker, TodoContext, ToolABC
|
|
12
12
|
from klaude_code.core.turn import TurnError, TurnExecutionContext, TurnExecutor
|
|
13
13
|
from klaude_code.protocol import events, model
|
|
14
14
|
from klaude_code.trace import DebugType, log_debug
|
|
@@ -100,7 +100,7 @@ class SessionContext:
|
|
|
100
100
|
session_id: str
|
|
101
101
|
get_conversation_history: Callable[[], list[model.ConversationItem]]
|
|
102
102
|
append_history: Callable[[Sequence[model.ConversationItem]], None]
|
|
103
|
-
file_tracker:
|
|
103
|
+
file_tracker: FileTracker
|
|
104
104
|
todo_context: TodoContext
|
|
105
105
|
|
|
106
106
|
|
|
@@ -149,8 +149,7 @@ class TaskExecutor:
|
|
|
149
149
|
session_id=session_ctx.session_id,
|
|
150
150
|
sub_agent_state=ctx.sub_agent_state,
|
|
151
151
|
)
|
|
152
|
-
|
|
153
|
-
session_ctx.append_history([model.UserMessageItem(content=user_input.text, images=user_input.images)])
|
|
152
|
+
del user_input # Persisted by the operation handler before launching the task.
|
|
154
153
|
|
|
155
154
|
profile = ctx.profile
|
|
156
155
|
metadata_accumulator = MetadataAccumulator(model_name=profile.llm_client.model_name)
|
|
@@ -15,6 +15,7 @@ from .todo.todo_write_tool import TodoWriteTool
|
|
|
15
15
|
from .todo.update_plan_tool import UpdatePlanTool
|
|
16
16
|
from .tool_abc import ToolABC
|
|
17
17
|
from .tool_context import (
|
|
18
|
+
FileTracker,
|
|
18
19
|
TodoContext,
|
|
19
20
|
ToolContextToken,
|
|
20
21
|
build_todo_context,
|
|
@@ -36,6 +37,7 @@ __all__ = [
|
|
|
36
37
|
"BashTool",
|
|
37
38
|
"DiffError",
|
|
38
39
|
"EditTool",
|
|
40
|
+
"FileTracker",
|
|
39
41
|
"MemoryTool",
|
|
40
42
|
"MermaidTool",
|
|
41
43
|
"MultiEditTool",
|
|
@@ -80,7 +80,9 @@ class ApplyPatchHandler:
|
|
|
80
80
|
|
|
81
81
|
if file_tracker is not None:
|
|
82
82
|
with contextlib.suppress(Exception): # pragma: no cover - file tracker best-effort
|
|
83
|
-
|
|
83
|
+
existing = file_tracker.get(resolved)
|
|
84
|
+
is_mem = existing.is_memory if existing else False
|
|
85
|
+
file_tracker[resolved] = model.FileStatus(mtime=Path(resolved).stat().st_mtime, is_memory=is_mem)
|
|
84
86
|
|
|
85
87
|
def remove_fn(path: str) -> None:
|
|
86
88
|
resolved = resolve_path(path)
|
|
@@ -119,8 +119,8 @@ class EditTool(ToolABC):
|
|
|
119
119
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
120
120
|
)
|
|
121
121
|
if file_tracker is not None:
|
|
122
|
-
|
|
123
|
-
if
|
|
122
|
+
tracked_status = file_tracker.get(file_path)
|
|
123
|
+
if tracked_status is None:
|
|
124
124
|
return model.ToolResultItem(
|
|
125
125
|
status="error",
|
|
126
126
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
@@ -128,8 +128,8 @@ class EditTool(ToolABC):
|
|
|
128
128
|
try:
|
|
129
129
|
current_mtime = Path(file_path).stat().st_mtime
|
|
130
130
|
except Exception:
|
|
131
|
-
current_mtime =
|
|
132
|
-
if current_mtime !=
|
|
131
|
+
current_mtime = tracked_status.mtime
|
|
132
|
+
if current_mtime != tracked_status.mtime:
|
|
133
133
|
return model.ToolResultItem(
|
|
134
134
|
status="error",
|
|
135
135
|
output=(
|
|
@@ -193,7 +193,9 @@ class EditTool(ToolABC):
|
|
|
193
193
|
# Update tracker with new mtime
|
|
194
194
|
if file_tracker is not None:
|
|
195
195
|
with contextlib.suppress(Exception):
|
|
196
|
-
|
|
196
|
+
existing = file_tracker.get(file_path)
|
|
197
|
+
is_mem = existing.is_memory if existing else False
|
|
198
|
+
file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
|
|
197
199
|
|
|
198
200
|
# Build output message
|
|
199
201
|
if args.replace_all:
|
|
@@ -92,8 +92,8 @@ class MultiEditTool(ToolABC):
|
|
|
92
92
|
# FileTracker check:
|
|
93
93
|
if file_exists(file_path):
|
|
94
94
|
if file_tracker is not None:
|
|
95
|
-
|
|
96
|
-
if
|
|
95
|
+
tracked_status = file_tracker.get(file_path)
|
|
96
|
+
if tracked_status is None:
|
|
97
97
|
return model.ToolResultItem(
|
|
98
98
|
status="error",
|
|
99
99
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
@@ -101,8 +101,8 @@ class MultiEditTool(ToolABC):
|
|
|
101
101
|
try:
|
|
102
102
|
current_mtime = Path(file_path).stat().st_mtime
|
|
103
103
|
except Exception:
|
|
104
|
-
current_mtime =
|
|
105
|
-
if current_mtime !=
|
|
104
|
+
current_mtime = tracked_status.mtime
|
|
105
|
+
if current_mtime != tracked_status.mtime:
|
|
106
106
|
return model.ToolResultItem(
|
|
107
107
|
status="error",
|
|
108
108
|
output=(
|
|
@@ -164,7 +164,9 @@ class MultiEditTool(ToolABC):
|
|
|
164
164
|
# Update tracker
|
|
165
165
|
if file_tracker is not None:
|
|
166
166
|
with contextlib.suppress(Exception):
|
|
167
|
-
|
|
167
|
+
existing = file_tracker.get(file_path)
|
|
168
|
+
is_mem = existing.is_memory if existing else False
|
|
169
|
+
file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
|
|
168
170
|
|
|
169
171
|
# Build output message
|
|
170
172
|
lines = [f"Applied {len(args.edits)} edits to {file_path}:"]
|
|
@@ -106,12 +106,15 @@ def _read_segment(options: ReadOptions) -> ReadSegmentResult:
|
|
|
106
106
|
)
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
def _track_file_access(file_path: str) -> None:
|
|
109
|
+
def _track_file_access(file_path: str, *, is_memory: bool = False) -> None:
|
|
110
110
|
file_tracker = get_current_file_tracker()
|
|
111
111
|
if file_tracker is None or not file_exists(file_path) or is_directory(file_path):
|
|
112
112
|
return
|
|
113
113
|
with contextlib.suppress(Exception):
|
|
114
|
-
|
|
114
|
+
existing = file_tracker.get(file_path)
|
|
115
|
+
# Preserve is_memory flag if already set
|
|
116
|
+
is_mem = is_memory or (existing.is_memory if existing else False)
|
|
117
|
+
file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
|
|
115
118
|
|
|
116
119
|
|
|
117
120
|
def _is_supported_image_file(file_path: str) -> bool:
|
|
@@ -64,10 +64,10 @@ class WriteTool(ToolABC):
|
|
|
64
64
|
exists = file_exists(file_path)
|
|
65
65
|
|
|
66
66
|
if exists:
|
|
67
|
-
|
|
67
|
+
tracked_status: model.FileStatus | None = None
|
|
68
68
|
if file_tracker is not None:
|
|
69
|
-
|
|
70
|
-
if
|
|
69
|
+
tracked_status = file_tracker.get(file_path)
|
|
70
|
+
if tracked_status is None:
|
|
71
71
|
return model.ToolResultItem(
|
|
72
72
|
status="error",
|
|
73
73
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
@@ -75,8 +75,8 @@ class WriteTool(ToolABC):
|
|
|
75
75
|
try:
|
|
76
76
|
current_mtime = Path(file_path).stat().st_mtime
|
|
77
77
|
except Exception:
|
|
78
|
-
current_mtime =
|
|
79
|
-
if current_mtime !=
|
|
78
|
+
current_mtime = tracked_status.mtime
|
|
79
|
+
if current_mtime != tracked_status.mtime:
|
|
80
80
|
return model.ToolResultItem(
|
|
81
81
|
status="error",
|
|
82
82
|
output=(
|
|
@@ -100,7 +100,9 @@ class WriteTool(ToolABC):
|
|
|
100
100
|
|
|
101
101
|
if file_tracker is not None:
|
|
102
102
|
with contextlib.suppress(Exception):
|
|
103
|
-
|
|
103
|
+
existing = file_tracker.get(file_path)
|
|
104
|
+
is_mem = existing.is_memory if existing else False
|
|
105
|
+
file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
|
|
104
106
|
|
|
105
107
|
# Build diff between previous and new content
|
|
106
108
|
after = args.content
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import os
|
|
2
4
|
import re
|
|
5
|
+
import signal
|
|
3
6
|
import subprocess
|
|
4
7
|
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
5
9
|
|
|
6
10
|
from pydantic import BaseModel
|
|
7
11
|
|
|
@@ -87,22 +91,94 @@ class BashTool(ToolABC):
|
|
|
87
91
|
|
|
88
92
|
# Run the command using bash -lc so shell semantics work (pipes, &&, etc.)
|
|
89
93
|
# Capture stdout/stderr, respect timeout, and return a ToolMessage.
|
|
94
|
+
#
|
|
95
|
+
# Important: this tool is intentionally non-interactive.
|
|
96
|
+
# - Always detach stdin (DEVNULL) so interactive programs can't steal REPL input.
|
|
97
|
+
# - Always disable pagers/editors to avoid launching TUI subprocesses that can
|
|
98
|
+
# leave the terminal in a bad state.
|
|
90
99
|
cmd = ["bash", "-lc", args.command]
|
|
91
100
|
timeout_sec = max(0.0, args.timeout_ms / 1000.0)
|
|
92
101
|
|
|
102
|
+
env = os.environ.copy()
|
|
103
|
+
env.update(
|
|
104
|
+
{
|
|
105
|
+
# Avoid blocking on git/jj prompts.
|
|
106
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
107
|
+
# Avoid pagers.
|
|
108
|
+
"PAGER": "cat",
|
|
109
|
+
"GIT_PAGER": "cat",
|
|
110
|
+
# Avoid opening editors.
|
|
111
|
+
"EDITOR": "true",
|
|
112
|
+
"VISUAL": "true",
|
|
113
|
+
"GIT_EDITOR": "true",
|
|
114
|
+
"JJ_EDITOR": "true",
|
|
115
|
+
# Encourage non-interactive output.
|
|
116
|
+
"TERM": "dumb",
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
|
|
121
|
+
# Best-effort termination. Ensure we don't hang on cancellation.
|
|
122
|
+
if proc.returncode is not None:
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
if os.name == "posix":
|
|
127
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
128
|
+
else:
|
|
129
|
+
proc.terminate()
|
|
130
|
+
except ProcessLookupError:
|
|
131
|
+
return
|
|
132
|
+
except Exception:
|
|
133
|
+
# Fall back to kill below.
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
with contextlib.suppress(Exception):
|
|
137
|
+
await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Escalate to hard kill if it didn't exit quickly.
|
|
141
|
+
with contextlib.suppress(Exception):
|
|
142
|
+
if os.name == "posix":
|
|
143
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
144
|
+
else:
|
|
145
|
+
proc.kill()
|
|
146
|
+
with contextlib.suppress(Exception):
|
|
147
|
+
await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
148
|
+
|
|
93
149
|
try:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
150
|
+
# Create a dedicated process group so we can terminate the whole tree.
|
|
151
|
+
# (macOS/Linux support start_new_session; Windows does not.)
|
|
152
|
+
kwargs: dict[str, Any] = {
|
|
153
|
+
"stdin": asyncio.subprocess.DEVNULL,
|
|
154
|
+
"stdout": asyncio.subprocess.PIPE,
|
|
155
|
+
"stderr": asyncio.subprocess.PIPE,
|
|
156
|
+
"env": env,
|
|
157
|
+
}
|
|
158
|
+
if os.name == "posix":
|
|
159
|
+
kwargs["start_new_session"] = True
|
|
160
|
+
elif os.name == "nt": # pragma: no cover
|
|
161
|
+
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
102
162
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
163
|
+
proc = await asyncio.create_subprocess_exec(*cmd, **kwargs)
|
|
164
|
+
try:
|
|
165
|
+
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
|
|
166
|
+
except TimeoutError:
|
|
167
|
+
with contextlib.suppress(Exception):
|
|
168
|
+
await _terminate_process(proc)
|
|
169
|
+
return model.ToolResultItem(
|
|
170
|
+
status="error",
|
|
171
|
+
output=f"Timeout after {args.timeout_ms} ms running: {args.command}",
|
|
172
|
+
)
|
|
173
|
+
except asyncio.CancelledError:
|
|
174
|
+
# Ensure subprocess is stopped and propagate cancellation.
|
|
175
|
+
with contextlib.suppress(Exception):
|
|
176
|
+
await asyncio.shield(_terminate_process(proc))
|
|
177
|
+
raise
|
|
178
|
+
|
|
179
|
+
stdout = _ANSI_ESCAPE_RE.sub("", (stdout_b or b"").decode(errors="replace"))
|
|
180
|
+
stderr = _ANSI_ESCAPE_RE.sub("", (stderr_b or b"").decode(errors="replace"))
|
|
181
|
+
rc = proc.returncode
|
|
106
182
|
|
|
107
183
|
if rc == 0:
|
|
108
184
|
output = stdout if stdout else ""
|
|
@@ -125,17 +201,14 @@ class BashTool(ToolABC):
|
|
|
125
201
|
status="error",
|
|
126
202
|
output=combined.strip(),
|
|
127
203
|
)
|
|
128
|
-
|
|
129
|
-
except subprocess.TimeoutExpired:
|
|
130
|
-
return model.ToolResultItem(
|
|
131
|
-
status="error",
|
|
132
|
-
output=f"Timeout after {args.timeout_ms} ms running: {args.command}",
|
|
133
|
-
)
|
|
134
204
|
except FileNotFoundError:
|
|
135
205
|
return model.ToolResultItem(
|
|
136
206
|
status="error",
|
|
137
207
|
output="bash not found on system path",
|
|
138
208
|
)
|
|
209
|
+
except asyncio.CancelledError:
|
|
210
|
+
# Propagate cooperative cancellation so outer layers can handle interrupts correctly.
|
|
211
|
+
raise
|
|
139
212
|
except Exception as e: # safeguard against unexpected failures
|
|
140
213
|
return model.ToolResultItem(
|
|
141
214
|
status="error",
|
|
@@ -10,7 +10,7 @@ import asyncio
|
|
|
10
10
|
import json
|
|
11
11
|
from typing import TYPE_CHECKING, ClassVar
|
|
12
12
|
|
|
13
|
-
from klaude_code.core.tool.tool_abc import ToolABC
|
|
13
|
+
from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata
|
|
14
14
|
from klaude_code.core.tool.tool_context import current_run_subtask_callback
|
|
15
15
|
from klaude_code.protocol import llm_param, model
|
|
16
16
|
|
|
@@ -36,6 +36,10 @@ class SubAgentTool(ToolABC):
|
|
|
36
36
|
{"_profile": profile},
|
|
37
37
|
)
|
|
38
38
|
|
|
39
|
+
@classmethod
|
|
40
|
+
def metadata(cls) -> ToolMetadata:
|
|
41
|
+
return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=True)
|
|
42
|
+
|
|
39
43
|
@classmethod
|
|
40
44
|
def schema(cls) -> llm_param.ToolSchema:
|
|
41
45
|
profile = cls._profile
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import string
|
|
2
2
|
from abc import ABC, abstractmethod
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
|
|
5
7
|
from klaude_code.protocol import llm_param, model
|
|
@@ -14,6 +16,10 @@ def load_desc(path: Path, substitutions: dict[str, str] | None = None) -> str:
|
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
class ToolABC(ABC):
|
|
19
|
+
@classmethod
|
|
20
|
+
def metadata(cls) -> "ToolMetadata":
|
|
21
|
+
return ToolMetadata()
|
|
22
|
+
|
|
17
23
|
@classmethod
|
|
18
24
|
@abstractmethod
|
|
19
25
|
def schema(cls) -> llm_param.ToolSchema:
|
|
@@ -23,3 +29,15 @@ class ToolABC(ABC):
|
|
|
23
29
|
@abstractmethod
|
|
24
30
|
async def call(cls, arguments: str) -> model.ToolResultItem:
|
|
25
31
|
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ToolConcurrencyPolicy(str, Enum):
|
|
35
|
+
SEQUENTIAL = "sequential"
|
|
36
|
+
CONCURRENT = "concurrent"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ToolMetadata:
|
|
41
|
+
concurrency_policy: ToolConcurrencyPolicy = ToolConcurrencyPolicy.SEQUENTIAL
|
|
42
|
+
has_side_effects: bool = False
|
|
43
|
+
requires_tool_context: bool = True
|
|
@@ -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()
|
|
@@ -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)
|
|
@@ -17,5 +17,31 @@ Diagrams are especially valuable for visualizing:
|
|
|
17
17
|
- Sequence and timing of operations
|
|
18
18
|
- Decision trees and conditional logic
|
|
19
19
|
|
|
20
|
+
# Syntax
|
|
21
|
+
- ALWAYS wrap node labels in double quotes, especially when they contain spaces, special characters, or non-ASCII text
|
|
22
|
+
- This applies to all node types: regular nodes, subgraph titles, and edge labels
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
```mermaid
|
|
26
|
+
graph LR
|
|
27
|
+
A["User Input"] --> B["Process Data"]
|
|
28
|
+
B --> C["Output Result"]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```mermaid
|
|
32
|
+
flowchart TD
|
|
33
|
+
subgraph auth["Authentication Module"]
|
|
34
|
+
login["Login Service"]
|
|
35
|
+
oauth["OAuth Provider"]
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```mermaid
|
|
40
|
+
sequenceDiagram
|
|
41
|
+
participant client as "Web Client"
|
|
42
|
+
participant server as "API Server"
|
|
43
|
+
client ->> server: "Send Request"
|
|
44
|
+
```
|
|
45
|
+
|
|
20
46
|
# Styling
|
|
21
47
|
- When defining custom classDefs, always define fill color, stroke color, and text color ("fill", "stroke", "color") explicitly
|
|
@@ -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(
|