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.
- klaude_code/__init__.py +0 -0
- klaude_code/cli/__init__.py +1 -0
- klaude_code/cli/main.py +298 -0
- klaude_code/cli/runtime.py +331 -0
- klaude_code/cli/session_cmd.py +80 -0
- klaude_code/command/__init__.py +43 -0
- klaude_code/command/clear_cmd.py +20 -0
- klaude_code/command/command_abc.py +92 -0
- klaude_code/command/diff_cmd.py +138 -0
- klaude_code/command/export_cmd.py +86 -0
- klaude_code/command/help_cmd.py +51 -0
- klaude_code/command/model_cmd.py +43 -0
- klaude_code/command/prompt-dev-docs-update.md +56 -0
- klaude_code/command/prompt-dev-docs.md +46 -0
- klaude_code/command/prompt-init.md +45 -0
- klaude_code/command/prompt_command.py +69 -0
- klaude_code/command/refresh_cmd.py +43 -0
- klaude_code/command/registry.py +110 -0
- klaude_code/command/status_cmd.py +111 -0
- klaude_code/command/terminal_setup_cmd.py +252 -0
- klaude_code/config/__init__.py +11 -0
- klaude_code/config/config.py +177 -0
- klaude_code/config/list_model.py +162 -0
- klaude_code/config/select_model.py +67 -0
- klaude_code/const/__init__.py +133 -0
- klaude_code/core/__init__.py +0 -0
- klaude_code/core/agent.py +165 -0
- klaude_code/core/executor.py +485 -0
- klaude_code/core/manager/__init__.py +19 -0
- klaude_code/core/manager/agent_manager.py +127 -0
- klaude_code/core/manager/llm_clients.py +42 -0
- klaude_code/core/manager/llm_clients_builder.py +49 -0
- klaude_code/core/manager/sub_agent_manager.py +86 -0
- klaude_code/core/prompt.py +89 -0
- klaude_code/core/prompts/prompt-claude-code.md +98 -0
- klaude_code/core/prompts/prompt-codex.md +331 -0
- klaude_code/core/prompts/prompt-gemini.md +43 -0
- klaude_code/core/prompts/prompt-subagent-explore.md +27 -0
- klaude_code/core/prompts/prompt-subagent-oracle.md +23 -0
- klaude_code/core/prompts/prompt-subagent-webfetch.md +46 -0
- klaude_code/core/prompts/prompt-subagent.md +8 -0
- klaude_code/core/reminders.py +445 -0
- klaude_code/core/task.py +237 -0
- klaude_code/core/tool/__init__.py +75 -0
- klaude_code/core/tool/file/__init__.py +0 -0
- klaude_code/core/tool/file/apply_patch.py +492 -0
- klaude_code/core/tool/file/apply_patch_tool.md +1 -0
- klaude_code/core/tool/file/apply_patch_tool.py +204 -0
- klaude_code/core/tool/file/edit_tool.md +9 -0
- klaude_code/core/tool/file/edit_tool.py +274 -0
- klaude_code/core/tool/file/multi_edit_tool.md +42 -0
- klaude_code/core/tool/file/multi_edit_tool.py +199 -0
- klaude_code/core/tool/file/read_tool.md +14 -0
- klaude_code/core/tool/file/read_tool.py +326 -0
- klaude_code/core/tool/file/write_tool.md +8 -0
- klaude_code/core/tool/file/write_tool.py +146 -0
- klaude_code/core/tool/memory/__init__.py +0 -0
- klaude_code/core/tool/memory/memory_tool.md +16 -0
- klaude_code/core/tool/memory/memory_tool.py +462 -0
- klaude_code/core/tool/memory/skill_loader.py +245 -0
- klaude_code/core/tool/memory/skill_tool.md +24 -0
- klaude_code/core/tool/memory/skill_tool.py +97 -0
- klaude_code/core/tool/shell/__init__.py +0 -0
- klaude_code/core/tool/shell/bash_tool.md +43 -0
- klaude_code/core/tool/shell/bash_tool.py +123 -0
- klaude_code/core/tool/shell/command_safety.py +363 -0
- klaude_code/core/tool/sub_agent_tool.py +83 -0
- klaude_code/core/tool/todo/__init__.py +0 -0
- klaude_code/core/tool/todo/todo_write_tool.md +182 -0
- klaude_code/core/tool/todo/todo_write_tool.py +121 -0
- klaude_code/core/tool/todo/update_plan_tool.md +3 -0
- klaude_code/core/tool/todo/update_plan_tool.py +104 -0
- klaude_code/core/tool/tool_abc.py +25 -0
- klaude_code/core/tool/tool_context.py +106 -0
- klaude_code/core/tool/tool_registry.py +78 -0
- klaude_code/core/tool/tool_runner.py +252 -0
- klaude_code/core/tool/truncation.py +170 -0
- klaude_code/core/tool/web/__init__.py +0 -0
- klaude_code/core/tool/web/mermaid_tool.md +21 -0
- klaude_code/core/tool/web/mermaid_tool.py +76 -0
- klaude_code/core/tool/web/web_fetch_tool.md +8 -0
- klaude_code/core/tool/web/web_fetch_tool.py +159 -0
- klaude_code/core/turn.py +220 -0
- klaude_code/llm/__init__.py +21 -0
- klaude_code/llm/anthropic/__init__.py +3 -0
- klaude_code/llm/anthropic/client.py +221 -0
- klaude_code/llm/anthropic/input.py +200 -0
- klaude_code/llm/client.py +49 -0
- klaude_code/llm/input_common.py +239 -0
- klaude_code/llm/openai_compatible/__init__.py +3 -0
- klaude_code/llm/openai_compatible/client.py +211 -0
- klaude_code/llm/openai_compatible/input.py +109 -0
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +80 -0
- klaude_code/llm/openrouter/__init__.py +3 -0
- klaude_code/llm/openrouter/client.py +200 -0
- klaude_code/llm/openrouter/input.py +160 -0
- klaude_code/llm/openrouter/reasoning_handler.py +209 -0
- klaude_code/llm/registry.py +22 -0
- klaude_code/llm/responses/__init__.py +3 -0
- klaude_code/llm/responses/client.py +216 -0
- klaude_code/llm/responses/input.py +167 -0
- klaude_code/llm/usage.py +109 -0
- klaude_code/protocol/__init__.py +4 -0
- klaude_code/protocol/commands.py +21 -0
- klaude_code/protocol/events.py +163 -0
- klaude_code/protocol/llm_param.py +147 -0
- klaude_code/protocol/model.py +287 -0
- klaude_code/protocol/op.py +89 -0
- klaude_code/protocol/op_handler.py +28 -0
- klaude_code/protocol/sub_agent.py +348 -0
- klaude_code/protocol/tools.py +15 -0
- klaude_code/session/__init__.py +4 -0
- klaude_code/session/export.py +624 -0
- klaude_code/session/selector.py +76 -0
- klaude_code/session/session.py +474 -0
- klaude_code/session/templates/export_session.html +1434 -0
- klaude_code/trace/__init__.py +3 -0
- klaude_code/trace/log.py +168 -0
- klaude_code/ui/__init__.py +91 -0
- klaude_code/ui/core/__init__.py +1 -0
- klaude_code/ui/core/display.py +103 -0
- klaude_code/ui/core/input.py +71 -0
- klaude_code/ui/core/stage_manager.py +55 -0
- klaude_code/ui/modes/__init__.py +1 -0
- klaude_code/ui/modes/debug/__init__.py +1 -0
- klaude_code/ui/modes/debug/display.py +36 -0
- klaude_code/ui/modes/exec/__init__.py +1 -0
- klaude_code/ui/modes/exec/display.py +63 -0
- klaude_code/ui/modes/repl/__init__.py +51 -0
- klaude_code/ui/modes/repl/clipboard.py +152 -0
- klaude_code/ui/modes/repl/completers.py +429 -0
- klaude_code/ui/modes/repl/display.py +60 -0
- klaude_code/ui/modes/repl/event_handler.py +375 -0
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
- klaude_code/ui/modes/repl/key_bindings.py +170 -0
- klaude_code/ui/modes/repl/renderer.py +281 -0
- klaude_code/ui/renderers/__init__.py +0 -0
- klaude_code/ui/renderers/assistant.py +21 -0
- klaude_code/ui/renderers/common.py +8 -0
- klaude_code/ui/renderers/developer.py +158 -0
- klaude_code/ui/renderers/diffs.py +215 -0
- klaude_code/ui/renderers/errors.py +16 -0
- klaude_code/ui/renderers/metadata.py +190 -0
- klaude_code/ui/renderers/sub_agent.py +71 -0
- klaude_code/ui/renderers/thinking.py +39 -0
- klaude_code/ui/renderers/tools.py +551 -0
- klaude_code/ui/renderers/user_input.py +65 -0
- klaude_code/ui/rich/__init__.py +1 -0
- klaude_code/ui/rich/live.py +65 -0
- klaude_code/ui/rich/markdown.py +308 -0
- klaude_code/ui/rich/quote.py +34 -0
- klaude_code/ui/rich/searchable_text.py +71 -0
- klaude_code/ui/rich/status.py +240 -0
- klaude_code/ui/rich/theme.py +274 -0
- klaude_code/ui/terminal/__init__.py +1 -0
- klaude_code/ui/terminal/color.py +244 -0
- klaude_code/ui/terminal/control.py +147 -0
- klaude_code/ui/terminal/notifier.py +107 -0
- klaude_code/ui/terminal/progress_bar.py +87 -0
- klaude_code/ui/utils/__init__.py +1 -0
- klaude_code/ui/utils/common.py +108 -0
- klaude_code/ui/utils/debouncer.py +42 -0
- klaude_code/version.py +163 -0
- klaude_code-1.2.6.dist-info/METADATA +178 -0
- klaude_code-1.2.6.dist-info/RECORD +167 -0
- klaude_code-1.2.6.dist-info/WHEEL +4 -0
- klaude_code-1.2.6.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Awaitable, Callable
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from klaude_code import const
|
|
8
|
+
from klaude_code.core.tool import BashTool, ReadTool, reset_tool_context, set_tool_context_from_session
|
|
9
|
+
from klaude_code.protocol import model, tools
|
|
10
|
+
from klaude_code.session import Session
|
|
11
|
+
|
|
12
|
+
type Reminder = Callable[[Session], Awaitable[model.DeveloperMessageItem | None]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_last_new_user_input(session: Session) -> str | None:
|
|
16
|
+
"""Get last user input & developer message (CLAUDE.md) from conversation history. if there's a tool result after user input, return None"""
|
|
17
|
+
result: list[str] = []
|
|
18
|
+
for item in reversed(session.conversation_history):
|
|
19
|
+
if isinstance(item, model.ToolResultItem):
|
|
20
|
+
return None
|
|
21
|
+
if isinstance(item, model.UserMessageItem):
|
|
22
|
+
result.append(item.content or "")
|
|
23
|
+
break
|
|
24
|
+
if isinstance(item, model.DeveloperMessageItem):
|
|
25
|
+
result.append(item.content or "")
|
|
26
|
+
return "\n\n".join(result)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def at_file_reader_reminder(
|
|
30
|
+
session: Session,
|
|
31
|
+
) -> model.DeveloperMessageItem | None:
|
|
32
|
+
"""Parse @foo/bar to read"""
|
|
33
|
+
last_user_input = get_last_new_user_input(session)
|
|
34
|
+
if not last_user_input or "@" not in last_user_input.strip():
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
at_patterns: list[str] = []
|
|
38
|
+
|
|
39
|
+
for item in last_user_input.strip().split():
|
|
40
|
+
if item.startswith("@") and len(item) > 1:
|
|
41
|
+
at_patterns.append(item.lower().strip("@"))
|
|
42
|
+
|
|
43
|
+
if len(at_patterns) == 0:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
at_files: dict[str, model.AtPatternParseResult] = {} # path -> content
|
|
47
|
+
collected_images: list[model.ImageURLPart] = []
|
|
48
|
+
|
|
49
|
+
for pattern in at_patterns:
|
|
50
|
+
path = Path(pattern).resolve()
|
|
51
|
+
context_token = set_tool_context_from_session(session)
|
|
52
|
+
try:
|
|
53
|
+
if path.exists() and path.is_file():
|
|
54
|
+
args = ReadTool.ReadArguments(file_path=str(path))
|
|
55
|
+
tool_result = await ReadTool.call_with_args(args)
|
|
56
|
+
at_result = model.AtPatternParseResult(
|
|
57
|
+
path=str(path),
|
|
58
|
+
tool_name=tools.READ,
|
|
59
|
+
result=tool_result.output or "",
|
|
60
|
+
tool_args=args.model_dump_json(exclude_none=True),
|
|
61
|
+
operation="Read",
|
|
62
|
+
images=tool_result.images,
|
|
63
|
+
)
|
|
64
|
+
at_files[str(path)] = at_result
|
|
65
|
+
if tool_result.images:
|
|
66
|
+
collected_images.extend(tool_result.images)
|
|
67
|
+
elif path.exists() and path.is_dir():
|
|
68
|
+
args = BashTool.BashArguments(command=f"ls {path}")
|
|
69
|
+
tool_result = await BashTool.call_with_args(args)
|
|
70
|
+
at_files[str(path)] = model.AtPatternParseResult(
|
|
71
|
+
path=str(path) + "/",
|
|
72
|
+
tool_name=tools.BASH,
|
|
73
|
+
result=tool_result.output or "",
|
|
74
|
+
tool_args=args.model_dump_json(exclude_none=True),
|
|
75
|
+
operation="List",
|
|
76
|
+
)
|
|
77
|
+
finally:
|
|
78
|
+
reset_tool_context(context_token)
|
|
79
|
+
|
|
80
|
+
if len(at_files) == 0:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
at_files_str = "\n\n".join(
|
|
84
|
+
[
|
|
85
|
+
f"""Called the {result.tool_name} tool with the following input: {result.tool_args}
|
|
86
|
+
Result of calling the {result.tool_name} tool:
|
|
87
|
+
{result.result}
|
|
88
|
+
"""
|
|
89
|
+
for result in at_files.values()
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
return model.DeveloperMessageItem(
|
|
93
|
+
content=f"""<system-reminder>{at_files_str}\n</system-reminder>""",
|
|
94
|
+
at_files=list(at_files.values()),
|
|
95
|
+
images=collected_images or None,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def empty_todo_reminder(session: Session) -> model.DeveloperMessageItem | None:
|
|
100
|
+
"""Remind agent to use TodoWrite tool when todos are empty/all completed.
|
|
101
|
+
|
|
102
|
+
Behavior:
|
|
103
|
+
- First time in empty state (counter == 0): trigger reminder and set cooldown (e.g., 3).
|
|
104
|
+
- While remaining in empty state with counter > 0: decrement each turn, no reminder.
|
|
105
|
+
- Do not decrement/reset while todos are non-empty (cooldown only counts during empty state).
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
empty_or_all_done = (not session.todos) or all(todo.status == "completed" for todo in session.todos)
|
|
109
|
+
|
|
110
|
+
# Only count down and possibly trigger when empty/all-done
|
|
111
|
+
if not empty_or_all_done:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
if session.need_todo_empty_cooldown_counter == 0:
|
|
115
|
+
session.need_todo_empty_cooldown_counter = 3
|
|
116
|
+
return model.DeveloperMessageItem(
|
|
117
|
+
content="""<system-reminder>This is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware. If you are working on tasks that would benefit from a todo list please use the TodoWrite tool to create one. If not, please feel free to ignore. Again do not mention this message to the user.</system-reminder>"""
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if session.need_todo_empty_cooldown_counter > 0:
|
|
121
|
+
session.need_todo_empty_cooldown_counter -= 1
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def todo_not_used_recently_reminder(
|
|
126
|
+
session: Session,
|
|
127
|
+
) -> model.DeveloperMessageItem | None:
|
|
128
|
+
"""Remind agent to use TodoWrite tool if it hasn't been used recently (>=10 other tool calls), with cooldown.
|
|
129
|
+
|
|
130
|
+
Cooldown behavior:
|
|
131
|
+
- When condition becomes active (>=10 non-todo tool calls since last TodoWrite) and counter == 0: trigger reminder, set counter = 3.
|
|
132
|
+
- While condition remains active and counter > 0: decrement each turn, do not remind.
|
|
133
|
+
- When condition not active: do nothing to the counter (no decrement), and do not remind.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
if not session.todos:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
# If all todos completed, skip reminder entirely
|
|
140
|
+
if all(todo.status == "completed" for todo in session.todos):
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
# Count non-todo tool calls since the last TodoWrite
|
|
144
|
+
other_tool_call_count_befor_last_todo = 0
|
|
145
|
+
for item in reversed(session.conversation_history):
|
|
146
|
+
if isinstance(item, model.ToolCallItem):
|
|
147
|
+
if item.name in (tools.TODO_WRITE, tools.UPDATE_PLAN):
|
|
148
|
+
break
|
|
149
|
+
other_tool_call_count_befor_last_todo += 1
|
|
150
|
+
if other_tool_call_count_befor_last_todo >= const.TODO_REMINDER_TOOL_CALL_THRESHOLD:
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
not_used_recently = other_tool_call_count_befor_last_todo >= const.TODO_REMINDER_TOOL_CALL_THRESHOLD
|
|
154
|
+
|
|
155
|
+
if not not_used_recently:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
if session.need_todo_not_used_cooldown_counter == 0:
|
|
159
|
+
session.need_todo_not_used_cooldown_counter = 3
|
|
160
|
+
return model.DeveloperMessageItem(
|
|
161
|
+
content=f"""<system-reminder>
|
|
162
|
+
The TodoWrite tool hasn't been used recently. If you're working on tasks that would benefit from tracking progress, consider using the TodoWrite tool to track progress. Also consider cleaning up the todo list if has become stale and no longer matches what you are working on. Only use it if it's relevant to the current work. This is just a gentle reminder - ignore if not applicable.
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
Here are the existing contents of your todo list:
|
|
166
|
+
|
|
167
|
+
{model.todo_list_str(session.todos)}</system-reminder>""",
|
|
168
|
+
todo_use=True,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if session.need_todo_not_used_cooldown_counter > 0:
|
|
172
|
+
session.need_todo_not_used_cooldown_counter -= 1
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def file_changed_externally_reminder(
|
|
177
|
+
session: Session,
|
|
178
|
+
) -> model.DeveloperMessageItem | None:
|
|
179
|
+
"""Remind agent about user/linter' changes to the files in FileTracker, provding the newest content of the file."""
|
|
180
|
+
changed_files: list[tuple[str, str, list[model.ImageURLPart] | None]] = []
|
|
181
|
+
collected_images: list[model.ImageURLPart] = []
|
|
182
|
+
if session.file_tracker and len(session.file_tracker) > 0:
|
|
183
|
+
for path, mtime in session.file_tracker.items():
|
|
184
|
+
try:
|
|
185
|
+
if Path(path).stat().st_mtime > mtime:
|
|
186
|
+
context_token = set_tool_context_from_session(session)
|
|
187
|
+
try:
|
|
188
|
+
tool_result = await ReadTool.call_with_args(
|
|
189
|
+
ReadTool.ReadArguments(file_path=path)
|
|
190
|
+
) # This tool will update file tracker
|
|
191
|
+
if tool_result.status == "success":
|
|
192
|
+
changed_files.append((path, tool_result.output or "", tool_result.images))
|
|
193
|
+
if tool_result.images:
|
|
194
|
+
collected_images.extend(tool_result.images)
|
|
195
|
+
finally:
|
|
196
|
+
reset_tool_context(context_token)
|
|
197
|
+
except (
|
|
198
|
+
FileNotFoundError,
|
|
199
|
+
IsADirectoryError,
|
|
200
|
+
OSError,
|
|
201
|
+
PermissionError,
|
|
202
|
+
UnicodeDecodeError,
|
|
203
|
+
):
|
|
204
|
+
continue
|
|
205
|
+
if len(changed_files) > 0:
|
|
206
|
+
changed_files_str = "\n\n".join(
|
|
207
|
+
[
|
|
208
|
+
f"Note: {file_path} was modified, either by the user or by a linter. Don't tell the user this, since they are already aware. This change was intentional, so make sure to take it into account as you proceed (ie. don't revert it unless the user asks you to). So that you don't need to re-read the file, here's the result of running `cat -n` on a snippet of the edited file:\n\n{file_content}"
|
|
209
|
+
""
|
|
210
|
+
for file_path, file_content, _ in changed_files
|
|
211
|
+
]
|
|
212
|
+
)
|
|
213
|
+
return model.DeveloperMessageItem(
|
|
214
|
+
content=f"""<system-reminder>{changed_files_str}""",
|
|
215
|
+
external_file_changes=[file_path for file_path, _, _ in changed_files],
|
|
216
|
+
images=collected_images or None,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_memory_paths() -> list[tuple[Path, str]]:
|
|
223
|
+
return [
|
|
224
|
+
(
|
|
225
|
+
Path.home() / ".claude" / "CLAUDE.md",
|
|
226
|
+
"user's private global instructions for all projects",
|
|
227
|
+
),
|
|
228
|
+
(
|
|
229
|
+
Path.home() / ".codex" / "AGENTS.md",
|
|
230
|
+
"user's private global instructions for all projects",
|
|
231
|
+
),
|
|
232
|
+
(Path.cwd() / "AGENTS.md", "project instructions, checked into the codebase"),
|
|
233
|
+
(Path.cwd() / "AGENT.md", "project instructions, checked into the codebase"),
|
|
234
|
+
(Path.cwd() / "CLAUDE.md", "project instructions, checked into the codebase"),
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class Memory(BaseModel):
|
|
239
|
+
path: str
|
|
240
|
+
instruction: str
|
|
241
|
+
content: str
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def get_last_user_message_image_count(session: Session) -> int:
|
|
245
|
+
"""Get image count from the last user message in conversation history."""
|
|
246
|
+
for item in reversed(session.conversation_history):
|
|
247
|
+
if isinstance(item, model.ToolResultItem):
|
|
248
|
+
return 0
|
|
249
|
+
if isinstance(item, model.UserMessageItem):
|
|
250
|
+
return len(item.images) if item.images else 0
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def image_reminder(session: Session) -> model.DeveloperMessageItem | None:
|
|
255
|
+
"""Remind agent about images attached by user in the last message."""
|
|
256
|
+
image_count = get_last_user_message_image_count(session)
|
|
257
|
+
if image_count == 0:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
return model.DeveloperMessageItem(
|
|
261
|
+
content=f"<system-reminder>User attached {image_count} image{'s' if image_count > 1 else ''} in their message. Make sure to analyze and reference these images as needed.</system-reminder>",
|
|
262
|
+
user_image_count=image_count,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def memory_reminder(session: Session) -> model.DeveloperMessageItem | None:
|
|
267
|
+
"""CLAUDE.md AGENTS.md"""
|
|
268
|
+
memory_paths = get_memory_paths()
|
|
269
|
+
memories: list[Memory] = []
|
|
270
|
+
for memory_path, instruction in memory_paths:
|
|
271
|
+
if memory_path.exists() and memory_path.is_file() and str(memory_path) not in session.loaded_memory:
|
|
272
|
+
try:
|
|
273
|
+
text = memory_path.read_text()
|
|
274
|
+
session.loaded_memory.append(str(memory_path))
|
|
275
|
+
memories.append(Memory(path=str(memory_path), instruction=instruction, content=text))
|
|
276
|
+
except (PermissionError, UnicodeDecodeError, OSError):
|
|
277
|
+
continue
|
|
278
|
+
if len(memories) > 0:
|
|
279
|
+
memories_str = "\n\n".join(
|
|
280
|
+
[f"Contents of {memory.path} ({memory.instruction}):\n\n{memory.content}" for memory in memories]
|
|
281
|
+
)
|
|
282
|
+
return model.DeveloperMessageItem(
|
|
283
|
+
content=f"""<system-reminder>As you answer the user's questions, you can use the following context:
|
|
284
|
+
|
|
285
|
+
# claudeMd
|
|
286
|
+
Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.
|
|
287
|
+
{memories_str}
|
|
288
|
+
|
|
289
|
+
#important-instruction-reminders
|
|
290
|
+
Do what has been asked; nothing more, nothing less.
|
|
291
|
+
NEVER create files unless they're absolutely necessary for achieving your goal.
|
|
292
|
+
ALWAYS prefer editing an existing file to creating a new one.
|
|
293
|
+
NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
|
294
|
+
|
|
295
|
+
IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.
|
|
296
|
+
</system-reminder>""",
|
|
297
|
+
memory_paths=[memory.path for memory in memories],
|
|
298
|
+
)
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def get_last_turn_tool_call(session: Session) -> list[model.ToolCallItem]:
|
|
303
|
+
tool_calls: list[model.ToolCallItem] = []
|
|
304
|
+
for item in reversed(session.conversation_history):
|
|
305
|
+
if isinstance(item, model.ToolCallItem):
|
|
306
|
+
tool_calls.append(item)
|
|
307
|
+
if isinstance(
|
|
308
|
+
item,
|
|
309
|
+
(
|
|
310
|
+
model.ReasoningEncryptedItem,
|
|
311
|
+
model.ReasoningTextItem,
|
|
312
|
+
model.AssistantMessageItem,
|
|
313
|
+
),
|
|
314
|
+
):
|
|
315
|
+
break
|
|
316
|
+
return tool_calls
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
MEMORY_FILE_NAMES = ["CLAUDE.md", "AGENTS.md", "AGENT.md"]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
async def last_path_memory_reminder(
|
|
323
|
+
session: Session,
|
|
324
|
+
) -> model.DeveloperMessageItem | None:
|
|
325
|
+
"""When last turn tool call entered a directory (or parent directory) with CLAUDE.md AGENTS.md"""
|
|
326
|
+
tool_calls = get_last_turn_tool_call(session)
|
|
327
|
+
if len(tool_calls) == 0:
|
|
328
|
+
return None
|
|
329
|
+
paths: list[str] = []
|
|
330
|
+
for tool_call in tool_calls:
|
|
331
|
+
if tool_call.name in (tools.READ, tools.EDIT, tools.MULTI_EDIT, tools.WRITE):
|
|
332
|
+
try:
|
|
333
|
+
json_dict = json.loads(tool_call.arguments)
|
|
334
|
+
if path := json_dict.get("file_path", ""):
|
|
335
|
+
paths.append(path)
|
|
336
|
+
except json.JSONDecodeError:
|
|
337
|
+
continue
|
|
338
|
+
elif tool_call.name == tools.BASH:
|
|
339
|
+
# TODO: haiku check file path
|
|
340
|
+
pass
|
|
341
|
+
paths = list(set(paths))
|
|
342
|
+
memories: list[Memory] = []
|
|
343
|
+
if len(paths) == 0:
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
cwd = Path.cwd().resolve()
|
|
347
|
+
loaded_set: set[str] = set(session.loaded_memory)
|
|
348
|
+
seen_memory_files: set[str] = set()
|
|
349
|
+
|
|
350
|
+
for p_str in paths:
|
|
351
|
+
p = Path(p_str)
|
|
352
|
+
full = (cwd / p).resolve() if not p.is_absolute() else p.resolve()
|
|
353
|
+
try:
|
|
354
|
+
_ = full.relative_to(cwd)
|
|
355
|
+
except ValueError:
|
|
356
|
+
# Not under cwd; skip
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
# Determine the deepest directory to scan (file parent or directory itself)
|
|
360
|
+
deepest_dir = full if full.is_dir() else full.parent
|
|
361
|
+
|
|
362
|
+
# Iterate each directory level from cwd to deepest_dir
|
|
363
|
+
try:
|
|
364
|
+
rel_parts = deepest_dir.relative_to(cwd).parts
|
|
365
|
+
except ValueError:
|
|
366
|
+
# Shouldn't happen due to check above, but guard anyway
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
current_dir = cwd
|
|
370
|
+
for part in rel_parts:
|
|
371
|
+
current_dir = current_dir / part
|
|
372
|
+
for fname in MEMORY_FILE_NAMES:
|
|
373
|
+
mem_path = current_dir / fname
|
|
374
|
+
mem_path_str = str(mem_path)
|
|
375
|
+
if mem_path_str in seen_memory_files or mem_path_str in loaded_set:
|
|
376
|
+
continue
|
|
377
|
+
if mem_path.exists() and mem_path.is_file():
|
|
378
|
+
try:
|
|
379
|
+
text = mem_path.read_text()
|
|
380
|
+
except (PermissionError, UnicodeDecodeError, OSError):
|
|
381
|
+
continue
|
|
382
|
+
session.loaded_memory.append(mem_path_str)
|
|
383
|
+
loaded_set.add(mem_path_str)
|
|
384
|
+
seen_memory_files.add(mem_path_str)
|
|
385
|
+
memories.append(
|
|
386
|
+
Memory(
|
|
387
|
+
path=mem_path_str,
|
|
388
|
+
instruction="project instructions, discovered near last accessed path",
|
|
389
|
+
content=text,
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
if len(memories) > 0:
|
|
394
|
+
memories_str = "\n\n".join(
|
|
395
|
+
[f"Contents of {memory.path} ({memory.instruction}):\n\n{memory.content}" for memory in memories]
|
|
396
|
+
)
|
|
397
|
+
return model.DeveloperMessageItem(
|
|
398
|
+
content=f"""<system-reminder>{memories_str}
|
|
399
|
+
</system-reminder>""",
|
|
400
|
+
memory_paths=[memory.path for memory in memories],
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
ALL_REMINDERS = [
|
|
405
|
+
empty_todo_reminder,
|
|
406
|
+
todo_not_used_recently_reminder,
|
|
407
|
+
file_changed_externally_reminder,
|
|
408
|
+
memory_reminder,
|
|
409
|
+
last_path_memory_reminder,
|
|
410
|
+
at_file_reader_reminder,
|
|
411
|
+
image_reminder,
|
|
412
|
+
]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def load_agent_reminders(
|
|
416
|
+
model_name: str, sub_agent_type: str | None = None, *, vanilla: bool = False
|
|
417
|
+
) -> list[Reminder]:
|
|
418
|
+
"""Get reminders for an agent based on model and agent type.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
model_name: The model name.
|
|
422
|
+
sub_agent_type: If None, returns main agent reminders. Otherwise returns sub-agent reminders.
|
|
423
|
+
vanilla: If True, returns minimal vanilla reminders (ignores sub_agent_type).
|
|
424
|
+
"""
|
|
425
|
+
if vanilla:
|
|
426
|
+
return [at_file_reader_reminder]
|
|
427
|
+
|
|
428
|
+
reminders: list[Reminder] = []
|
|
429
|
+
|
|
430
|
+
# Only main agent (not sub-agent) gets todo reminders, and not for GPT-5
|
|
431
|
+
if sub_agent_type is None and "gpt-5" not in model_name:
|
|
432
|
+
reminders.append(empty_todo_reminder)
|
|
433
|
+
reminders.append(todo_not_used_recently_reminder)
|
|
434
|
+
|
|
435
|
+
reminders.extend(
|
|
436
|
+
[
|
|
437
|
+
memory_reminder,
|
|
438
|
+
last_path_memory_reminder,
|
|
439
|
+
at_file_reader_reminder,
|
|
440
|
+
file_changed_externally_reminder,
|
|
441
|
+
image_reminder,
|
|
442
|
+
]
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
return reminders
|
klaude_code/core/task.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import AsyncGenerator, Callable, MutableMapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from klaude_code import const
|
|
10
|
+
from klaude_code.core.reminders import Reminder
|
|
11
|
+
from klaude_code.core.tool import TodoContext, ToolABC
|
|
12
|
+
from klaude_code.core.turn import TurnError, TurnExecutionContext, TurnExecutor
|
|
13
|
+
from klaude_code.protocol import events, model
|
|
14
|
+
from klaude_code.trace import DebugType, log_debug
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from klaude_code.core.agent import AgentProfile
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MetadataAccumulator:
|
|
21
|
+
"""Accumulates response metadata across multiple turns.
|
|
22
|
+
|
|
23
|
+
Tracks usage statistics including tokens, latency, and throughput,
|
|
24
|
+
merging them into a single aggregated result.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, model_name: str) -> None:
|
|
28
|
+
self._accumulated = model.ResponseMetadataItem(model_name=model_name)
|
|
29
|
+
self._throughput_weighted_sum: float = 0.0
|
|
30
|
+
self._throughput_tracked_tokens: int = 0
|
|
31
|
+
|
|
32
|
+
def add(self, turn_metadata: model.ResponseMetadataItem) -> None:
|
|
33
|
+
"""Merge a turn's metadata into the accumulated state."""
|
|
34
|
+
accumulated = self._accumulated
|
|
35
|
+
usage = turn_metadata.usage
|
|
36
|
+
|
|
37
|
+
if usage is not None:
|
|
38
|
+
if accumulated.usage is None:
|
|
39
|
+
accumulated.usage = model.Usage()
|
|
40
|
+
acc_usage = accumulated.usage
|
|
41
|
+
acc_usage.input_tokens += usage.input_tokens
|
|
42
|
+
acc_usage.cached_tokens += usage.cached_tokens
|
|
43
|
+
acc_usage.reasoning_tokens += usage.reasoning_tokens
|
|
44
|
+
acc_usage.output_tokens += usage.output_tokens
|
|
45
|
+
acc_usage.total_tokens += usage.total_tokens
|
|
46
|
+
|
|
47
|
+
if usage.context_usage_percent is not None:
|
|
48
|
+
acc_usage.context_usage_percent = usage.context_usage_percent
|
|
49
|
+
|
|
50
|
+
if usage.first_token_latency_ms is not None:
|
|
51
|
+
if acc_usage.first_token_latency_ms is None:
|
|
52
|
+
acc_usage.first_token_latency_ms = usage.first_token_latency_ms
|
|
53
|
+
else:
|
|
54
|
+
acc_usage.first_token_latency_ms = min(
|
|
55
|
+
acc_usage.first_token_latency_ms,
|
|
56
|
+
usage.first_token_latency_ms,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if usage.throughput_tps is not None:
|
|
60
|
+
current_output = usage.output_tokens
|
|
61
|
+
if current_output > 0:
|
|
62
|
+
self._throughput_weighted_sum += usage.throughput_tps * current_output
|
|
63
|
+
self._throughput_tracked_tokens += current_output
|
|
64
|
+
|
|
65
|
+
# Accumulate costs
|
|
66
|
+
if usage.input_cost is not None:
|
|
67
|
+
acc_usage.input_cost = (acc_usage.input_cost or 0.0) + usage.input_cost
|
|
68
|
+
if usage.output_cost is not None:
|
|
69
|
+
acc_usage.output_cost = (acc_usage.output_cost or 0.0) + usage.output_cost
|
|
70
|
+
if usage.cache_read_cost is not None:
|
|
71
|
+
acc_usage.cache_read_cost = (acc_usage.cache_read_cost or 0.0) + usage.cache_read_cost
|
|
72
|
+
if usage.total_cost is not None:
|
|
73
|
+
acc_usage.total_cost = (acc_usage.total_cost or 0.0) + usage.total_cost
|
|
74
|
+
|
|
75
|
+
if turn_metadata.provider is not None:
|
|
76
|
+
accumulated.provider = turn_metadata.provider
|
|
77
|
+
if turn_metadata.model_name:
|
|
78
|
+
accumulated.model_name = turn_metadata.model_name
|
|
79
|
+
if turn_metadata.response_id:
|
|
80
|
+
accumulated.response_id = turn_metadata.response_id
|
|
81
|
+
if turn_metadata.status is not None:
|
|
82
|
+
accumulated.status = turn_metadata.status
|
|
83
|
+
if turn_metadata.error_reason is not None:
|
|
84
|
+
accumulated.error_reason = turn_metadata.error_reason
|
|
85
|
+
|
|
86
|
+
def finalize(self, task_duration_s: float) -> model.ResponseMetadataItem:
|
|
87
|
+
"""Return the final accumulated metadata with computed throughput and duration."""
|
|
88
|
+
accumulated = self._accumulated
|
|
89
|
+
if accumulated.usage is not None:
|
|
90
|
+
if self._throughput_tracked_tokens > 0:
|
|
91
|
+
accumulated.usage.throughput_tps = self._throughput_weighted_sum / self._throughput_tracked_tokens
|
|
92
|
+
else:
|
|
93
|
+
accumulated.usage.throughput_tps = None
|
|
94
|
+
|
|
95
|
+
accumulated.task_duration_s = task_duration_s
|
|
96
|
+
return accumulated
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class TaskExecutionContext:
|
|
101
|
+
"""Execution context required to run a task."""
|
|
102
|
+
|
|
103
|
+
session_id: str
|
|
104
|
+
profile: AgentProfile
|
|
105
|
+
get_conversation_history: Callable[[], list[model.ConversationItem]]
|
|
106
|
+
append_history: Callable[[Sequence[model.ConversationItem]], None]
|
|
107
|
+
tool_registry: dict[str, type[ToolABC]]
|
|
108
|
+
file_tracker: MutableMapping[str, float]
|
|
109
|
+
todo_context: TodoContext
|
|
110
|
+
# For reminder processing - needs access to session
|
|
111
|
+
process_reminder: Callable[[Reminder], AsyncGenerator[events.DeveloperMessageEvent, None]]
|
|
112
|
+
sub_agent_state: model.SubAgentState | None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TaskExecutor:
|
|
116
|
+
"""Executes a complete task (multiple turns until no more tool calls).
|
|
117
|
+
|
|
118
|
+
Manages task-level state like metadata accumulation and retry logic.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, context: TaskExecutionContext) -> None:
|
|
122
|
+
self._context = context
|
|
123
|
+
self._current_turn: TurnExecutor | None = None
|
|
124
|
+
self._started_at: float = 0.0
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def current_turn(self) -> TurnExecutor | None:
|
|
128
|
+
return self._current_turn
|
|
129
|
+
|
|
130
|
+
def cancel(self) -> list[events.Event]:
|
|
131
|
+
"""Cancel the current turn and return any resulting events."""
|
|
132
|
+
ui_events: list[events.Event] = []
|
|
133
|
+
if self._current_turn is not None:
|
|
134
|
+
ui_events.extend(self._current_turn.cancel())
|
|
135
|
+
self._current_turn = None
|
|
136
|
+
return ui_events
|
|
137
|
+
|
|
138
|
+
async def run(self, user_input: model.UserInputPayload) -> AsyncGenerator[events.Event, None]:
|
|
139
|
+
"""Execute the task, yielding events as they occur."""
|
|
140
|
+
ctx = self._context
|
|
141
|
+
self._started_at = time.perf_counter()
|
|
142
|
+
|
|
143
|
+
yield events.TaskStartEvent(
|
|
144
|
+
session_id=ctx.session_id,
|
|
145
|
+
sub_agent_state=ctx.sub_agent_state,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
ctx.append_history([model.UserMessageItem(content=user_input.text, images=user_input.images)])
|
|
149
|
+
|
|
150
|
+
profile = ctx.profile
|
|
151
|
+
metadata_accumulator = MetadataAccumulator(model_name=profile.llm_client.model_name)
|
|
152
|
+
last_assistant_message: events.AssistantMessageEvent | None = None
|
|
153
|
+
|
|
154
|
+
while True:
|
|
155
|
+
# Process reminders at the start of each turn
|
|
156
|
+
for reminder in profile.reminders:
|
|
157
|
+
async for event in ctx.process_reminder(reminder):
|
|
158
|
+
yield event
|
|
159
|
+
|
|
160
|
+
turn_context = TurnExecutionContext(
|
|
161
|
+
session_id=ctx.session_id,
|
|
162
|
+
get_conversation_history=ctx.get_conversation_history,
|
|
163
|
+
append_history=ctx.append_history,
|
|
164
|
+
llm_client=profile.llm_client,
|
|
165
|
+
system_prompt=profile.system_prompt,
|
|
166
|
+
tools=profile.tools,
|
|
167
|
+
tool_registry=ctx.tool_registry,
|
|
168
|
+
file_tracker=ctx.file_tracker,
|
|
169
|
+
todo_context=ctx.todo_context,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
turn: TurnExecutor | None = None
|
|
173
|
+
turn_succeeded = False
|
|
174
|
+
last_error_message: str | None = None
|
|
175
|
+
|
|
176
|
+
for attempt in range(const.MAX_FAILED_TURN_RETRIES + 1):
|
|
177
|
+
turn = TurnExecutor(turn_context)
|
|
178
|
+
self._current_turn = turn
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
async for turn_event in turn.run():
|
|
182
|
+
match turn_event:
|
|
183
|
+
case events.AssistantMessageEvent() as am:
|
|
184
|
+
if am.content.strip() != "":
|
|
185
|
+
last_assistant_message = am
|
|
186
|
+
yield am
|
|
187
|
+
case events.ResponseMetadataEvent() as e:
|
|
188
|
+
metadata_accumulator.add(e.metadata)
|
|
189
|
+
case _:
|
|
190
|
+
yield turn_event
|
|
191
|
+
|
|
192
|
+
turn_succeeded = True
|
|
193
|
+
break
|
|
194
|
+
except TurnError as e:
|
|
195
|
+
last_error_message = str(e)
|
|
196
|
+
if attempt < const.MAX_FAILED_TURN_RETRIES:
|
|
197
|
+
delay = _retry_delay_seconds(attempt + 1)
|
|
198
|
+
error_msg = f"Retrying {attempt + 1}/{const.MAX_FAILED_TURN_RETRIES} in {delay:.1f}s"
|
|
199
|
+
if last_error_message:
|
|
200
|
+
error_msg = f"{error_msg} - {last_error_message}"
|
|
201
|
+
yield events.ErrorEvent(error_message=error_msg, can_retry=True)
|
|
202
|
+
await asyncio.sleep(delay)
|
|
203
|
+
finally:
|
|
204
|
+
self._current_turn = None
|
|
205
|
+
|
|
206
|
+
if not turn_succeeded:
|
|
207
|
+
log_debug(
|
|
208
|
+
"Maximum consecutive failed turns reached, aborting task",
|
|
209
|
+
style="red",
|
|
210
|
+
debug_type=DebugType.EXECUTION,
|
|
211
|
+
)
|
|
212
|
+
final_error = f"Turn failed after {const.MAX_FAILED_TURN_RETRIES} retries."
|
|
213
|
+
if last_error_message:
|
|
214
|
+
final_error = f"{last_error_message}\n{final_error}"
|
|
215
|
+
yield events.ErrorEvent(error_message=final_error, can_retry=False)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
if turn is None or not turn.has_tool_call:
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
# Finalize metadata
|
|
222
|
+
task_duration_s = time.perf_counter() - self._started_at
|
|
223
|
+
accumulated = metadata_accumulator.finalize(task_duration_s)
|
|
224
|
+
|
|
225
|
+
yield events.ResponseMetadataEvent(metadata=accumulated, session_id=ctx.session_id)
|
|
226
|
+
ctx.append_history([accumulated])
|
|
227
|
+
yield events.TaskFinishEvent(
|
|
228
|
+
session_id=ctx.session_id,
|
|
229
|
+
task_result=last_assistant_message.content if last_assistant_message else "",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _retry_delay_seconds(attempt: int) -> float:
|
|
234
|
+
"""Compute exponential backoff delay for the given attempt count."""
|
|
235
|
+
capped_attempt = max(1, attempt)
|
|
236
|
+
delay = const.INITIAL_RETRY_DELAY_S * (2 ** (capped_attempt - 1))
|
|
237
|
+
return min(delay, const.MAX_RETRY_DELAY_S)
|