klaude-code 1.2.7__py3-none-any.whl → 1.2.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klaude_code/auth/codex/__init__.py +1 -1
- klaude_code/command/__init__.py +2 -0
- klaude_code/command/prompt-deslop.md +14 -0
- klaude_code/command/release_notes_cmd.py +86 -0
- klaude_code/command/status_cmd.py +92 -54
- klaude_code/core/agent.py +13 -19
- klaude_code/core/manager/sub_agent_manager.py +5 -1
- klaude_code/core/prompt.py +38 -28
- klaude_code/core/reminders.py +4 -4
- klaude_code/core/task.py +60 -45
- klaude_code/core/tool/__init__.py +2 -0
- klaude_code/core/tool/file/apply_patch_tool.py +1 -1
- klaude_code/core/tool/file/edit_tool.py +1 -1
- klaude_code/core/tool/file/multi_edit_tool.py +1 -1
- klaude_code/core/tool/file/write_tool.py +1 -1
- klaude_code/core/tool/memory/memory_tool.py +2 -2
- klaude_code/core/tool/sub_agent_tool.py +2 -1
- klaude_code/core/tool/todo/todo_write_tool.py +1 -1
- klaude_code/core/tool/todo/update_plan_tool.py +1 -1
- klaude_code/core/tool/tool_context.py +21 -4
- klaude_code/core/tool/tool_runner.py +5 -8
- klaude_code/core/tool/web/mermaid_tool.py +1 -4
- klaude_code/core/turn.py +90 -62
- klaude_code/llm/anthropic/client.py +15 -46
- klaude_code/llm/client.py +1 -1
- klaude_code/llm/codex/client.py +44 -30
- klaude_code/llm/input_common.py +0 -6
- klaude_code/llm/openai_compatible/client.py +29 -73
- klaude_code/llm/openai_compatible/input.py +6 -4
- klaude_code/llm/openai_compatible/stream_processor.py +82 -0
- klaude_code/llm/openrouter/client.py +29 -59
- klaude_code/llm/openrouter/input.py +4 -27
- klaude_code/llm/responses/client.py +49 -79
- klaude_code/llm/usage.py +51 -10
- klaude_code/protocol/commands.py +1 -0
- klaude_code/protocol/events.py +12 -2
- klaude_code/protocol/model.py +142 -26
- klaude_code/protocol/sub_agent.py +5 -1
- klaude_code/session/export.py +51 -27
- klaude_code/session/session.py +33 -16
- klaude_code/session/templates/export_session.html +4 -1
- klaude_code/ui/modes/repl/__init__.py +1 -5
- klaude_code/ui/modes/repl/event_handler.py +153 -54
- klaude_code/ui/modes/repl/renderer.py +6 -4
- klaude_code/ui/renderers/developer.py +35 -25
- klaude_code/ui/renderers/metadata.py +68 -30
- klaude_code/ui/renderers/tools.py +53 -87
- klaude_code/ui/rich/markdown.py +5 -5
- {klaude_code-1.2.7.dist-info → klaude_code-1.2.9.dist-info}/METADATA +1 -1
- {klaude_code-1.2.7.dist-info → klaude_code-1.2.9.dist-info}/RECORD +52 -49
- {klaude_code-1.2.7.dist-info → klaude_code-1.2.9.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.7.dist-info → klaude_code-1.2.9.dist-info}/entry_points.txt +0 -0
klaude_code/core/task.py
CHANGED
|
@@ -25,28 +25,30 @@ class MetadataAccumulator:
|
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
27
|
def __init__(self, model_name: str) -> None:
|
|
28
|
-
self.
|
|
28
|
+
self._main = model.TaskMetadata(model_name=model_name)
|
|
29
|
+
self._sub_agent_metadata: list[model.TaskMetadata] = []
|
|
29
30
|
self._throughput_weighted_sum: float = 0.0
|
|
30
31
|
self._throughput_tracked_tokens: int = 0
|
|
31
32
|
|
|
32
33
|
def add(self, turn_metadata: model.ResponseMetadataItem) -> None:
|
|
33
34
|
"""Merge a turn's metadata into the accumulated state."""
|
|
34
|
-
|
|
35
|
+
main = self._main
|
|
35
36
|
usage = turn_metadata.usage
|
|
36
37
|
|
|
37
38
|
if usage is not None:
|
|
38
|
-
if
|
|
39
|
-
|
|
40
|
-
acc_usage =
|
|
39
|
+
if main.usage is None:
|
|
40
|
+
main.usage = model.Usage()
|
|
41
|
+
acc_usage = main.usage
|
|
41
42
|
acc_usage.input_tokens += usage.input_tokens
|
|
42
43
|
acc_usage.cached_tokens += usage.cached_tokens
|
|
43
44
|
acc_usage.reasoning_tokens += usage.reasoning_tokens
|
|
44
45
|
acc_usage.output_tokens += usage.output_tokens
|
|
45
|
-
acc_usage.total_tokens += usage.total_tokens
|
|
46
46
|
acc_usage.currency = usage.currency
|
|
47
47
|
|
|
48
|
-
if usage.
|
|
49
|
-
acc_usage.
|
|
48
|
+
if usage.context_window_size is not None:
|
|
49
|
+
acc_usage.context_window_size = usage.context_window_size
|
|
50
|
+
if usage.context_limit is not None:
|
|
51
|
+
acc_usage.context_limit = usage.context_limit
|
|
50
52
|
|
|
51
53
|
if usage.first_token_latency_ms is not None:
|
|
52
54
|
if acc_usage.first_token_latency_ms is None:
|
|
@@ -63,51 +65,56 @@ class MetadataAccumulator:
|
|
|
63
65
|
self._throughput_weighted_sum += usage.throughput_tps * current_output
|
|
64
66
|
self._throughput_tracked_tokens += current_output
|
|
65
67
|
|
|
66
|
-
# Accumulate costs
|
|
67
68
|
if usage.input_cost is not None:
|
|
68
69
|
acc_usage.input_cost = (acc_usage.input_cost or 0.0) + usage.input_cost
|
|
69
70
|
if usage.output_cost is not None:
|
|
70
71
|
acc_usage.output_cost = (acc_usage.output_cost or 0.0) + usage.output_cost
|
|
71
72
|
if usage.cache_read_cost is not None:
|
|
72
73
|
acc_usage.cache_read_cost = (acc_usage.cache_read_cost or 0.0) + usage.cache_read_cost
|
|
73
|
-
if usage.total_cost is not None:
|
|
74
|
-
acc_usage.total_cost = (acc_usage.total_cost or 0.0) + usage.total_cost
|
|
75
74
|
|
|
76
75
|
if turn_metadata.provider is not None:
|
|
77
|
-
|
|
76
|
+
main.provider = turn_metadata.provider
|
|
78
77
|
if turn_metadata.model_name:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def finalize(self, task_duration_s: float) -> model.ResponseMetadataItem:
|
|
78
|
+
main.model_name = turn_metadata.model_name
|
|
79
|
+
|
|
80
|
+
def add_sub_agent_metadata(self, sub_agent_metadata: model.TaskMetadata) -> None:
|
|
81
|
+
"""Add sub-agent task metadata to the accumulated state."""
|
|
82
|
+
self._sub_agent_metadata.append(sub_agent_metadata)
|
|
83
|
+
|
|
84
|
+
def finalize(self, task_duration_s: float) -> model.TaskMetadataItem:
|
|
88
85
|
"""Return the final accumulated metadata with computed throughput and duration."""
|
|
89
|
-
|
|
90
|
-
if
|
|
86
|
+
main = self._main
|
|
87
|
+
if main.usage is not None:
|
|
91
88
|
if self._throughput_tracked_tokens > 0:
|
|
92
|
-
|
|
89
|
+
main.usage.throughput_tps = self._throughput_weighted_sum / self._throughput_tracked_tokens
|
|
93
90
|
else:
|
|
94
|
-
|
|
91
|
+
main.usage.throughput_tps = None
|
|
95
92
|
|
|
96
|
-
|
|
97
|
-
return
|
|
93
|
+
main.task_duration_s = task_duration_s
|
|
94
|
+
return model.TaskMetadataItem(main=main, sub_agent_task_metadata=self._sub_agent_metadata)
|
|
98
95
|
|
|
99
96
|
|
|
100
97
|
@dataclass
|
|
101
|
-
class
|
|
102
|
-
"""
|
|
98
|
+
class SessionContext:
|
|
99
|
+
"""Shared session-level context for task and turn execution.
|
|
100
|
+
|
|
101
|
+
Contains common fields that both TaskExecutionContext and TurnExecutionContext need.
|
|
102
|
+
"""
|
|
103
103
|
|
|
104
104
|
session_id: str
|
|
105
|
-
profile: AgentProfile
|
|
106
105
|
get_conversation_history: Callable[[], list[model.ConversationItem]]
|
|
107
106
|
append_history: Callable[[Sequence[model.ConversationItem]], None]
|
|
108
|
-
tool_registry: dict[str, type[ToolABC]]
|
|
109
107
|
file_tracker: MutableMapping[str, float]
|
|
110
108
|
todo_context: TodoContext
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class TaskExecutionContext:
|
|
113
|
+
"""Execution context required to run a task."""
|
|
114
|
+
|
|
115
|
+
session_ctx: SessionContext
|
|
116
|
+
profile: AgentProfile
|
|
117
|
+
tool_registry: dict[str, type[ToolABC]]
|
|
111
118
|
# For reminder processing - needs access to session
|
|
112
119
|
process_reminder: Callable[[Reminder], AsyncGenerator[events.DeveloperMessageEvent, None]]
|
|
113
120
|
sub_agent_state: model.SubAgentState | None
|
|
@@ -139,18 +146,18 @@ class TaskExecutor:
|
|
|
139
146
|
async def run(self, user_input: model.UserInputPayload) -> AsyncGenerator[events.Event, None]:
|
|
140
147
|
"""Execute the task, yielding events as they occur."""
|
|
141
148
|
ctx = self._context
|
|
149
|
+
session_ctx = ctx.session_ctx
|
|
142
150
|
self._started_at = time.perf_counter()
|
|
143
151
|
|
|
144
152
|
yield events.TaskStartEvent(
|
|
145
|
-
session_id=
|
|
153
|
+
session_id=session_ctx.session_id,
|
|
146
154
|
sub_agent_state=ctx.sub_agent_state,
|
|
147
155
|
)
|
|
148
156
|
|
|
149
|
-
|
|
157
|
+
session_ctx.append_history([model.UserMessageItem(content=user_input.text, images=user_input.images)])
|
|
150
158
|
|
|
151
159
|
profile = ctx.profile
|
|
152
160
|
metadata_accumulator = MetadataAccumulator(model_name=profile.llm_client.model_name)
|
|
153
|
-
last_assistant_message: events.AssistantMessageEvent | None = None
|
|
154
161
|
|
|
155
162
|
while True:
|
|
156
163
|
# Process reminders at the start of each turn
|
|
@@ -159,15 +166,11 @@ class TaskExecutor:
|
|
|
159
166
|
yield event
|
|
160
167
|
|
|
161
168
|
turn_context = TurnExecutionContext(
|
|
162
|
-
|
|
163
|
-
get_conversation_history=ctx.get_conversation_history,
|
|
164
|
-
append_history=ctx.append_history,
|
|
169
|
+
session_ctx=session_ctx,
|
|
165
170
|
llm_client=profile.llm_client,
|
|
166
171
|
system_prompt=profile.system_prompt,
|
|
167
172
|
tools=profile.tools,
|
|
168
173
|
tool_registry=ctx.tool_registry,
|
|
169
|
-
file_tracker=ctx.file_tracker,
|
|
170
|
-
todo_context=ctx.todo_context,
|
|
171
174
|
)
|
|
172
175
|
|
|
173
176
|
turn: TurnExecutor | None = None
|
|
@@ -182,11 +185,14 @@ class TaskExecutor:
|
|
|
182
185
|
async for turn_event in turn.run():
|
|
183
186
|
match turn_event:
|
|
184
187
|
case events.AssistantMessageEvent() as am:
|
|
185
|
-
if am.content.strip() != "":
|
|
186
|
-
last_assistant_message = am
|
|
187
188
|
yield am
|
|
188
189
|
case events.ResponseMetadataEvent() as e:
|
|
189
190
|
metadata_accumulator.add(e.metadata)
|
|
191
|
+
case events.ToolResultEvent() as e:
|
|
192
|
+
# Collect sub-agent task metadata from tool results
|
|
193
|
+
if e.task_metadata is not None:
|
|
194
|
+
metadata_accumulator.add_sub_agent_metadata(e.task_metadata)
|
|
195
|
+
yield turn_event
|
|
190
196
|
case _:
|
|
191
197
|
yield turn_event
|
|
192
198
|
|
|
@@ -223,14 +229,23 @@ class TaskExecutor:
|
|
|
223
229
|
task_duration_s = time.perf_counter() - self._started_at
|
|
224
230
|
accumulated = metadata_accumulator.finalize(task_duration_s)
|
|
225
231
|
|
|
226
|
-
yield events.
|
|
227
|
-
|
|
232
|
+
yield events.TaskMetadataEvent(metadata=accumulated, session_id=session_ctx.session_id)
|
|
233
|
+
session_ctx.append_history([accumulated])
|
|
228
234
|
yield events.TaskFinishEvent(
|
|
229
|
-
session_id=
|
|
230
|
-
task_result=
|
|
235
|
+
session_id=session_ctx.session_id,
|
|
236
|
+
task_result=_get_last_assistant_message(session_ctx.get_conversation_history()) or "",
|
|
231
237
|
)
|
|
232
238
|
|
|
233
239
|
|
|
240
|
+
def _get_last_assistant_message(history: list[model.ConversationItem]) -> str | None:
|
|
241
|
+
"""Return the content of the most recent assistant message in history."""
|
|
242
|
+
|
|
243
|
+
for item in reversed(history):
|
|
244
|
+
if isinstance(item, model.AssistantMessageItem):
|
|
245
|
+
return item.content or ""
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
234
249
|
def _retry_delay_seconds(attempt: int) -> float:
|
|
235
250
|
"""Compute exponential backoff delay for the given attempt count."""
|
|
236
251
|
capped_attempt = max(1, attempt)
|
|
@@ -16,6 +16,7 @@ from .tool_abc import ToolABC
|
|
|
16
16
|
from .tool_context import (
|
|
17
17
|
TodoContext,
|
|
18
18
|
ToolContextToken,
|
|
19
|
+
build_todo_context,
|
|
19
20
|
current_run_subtask_callback,
|
|
20
21
|
reset_tool_context,
|
|
21
22
|
set_tool_context_from_session,
|
|
@@ -46,6 +47,7 @@ __all__ = [
|
|
|
46
47
|
"ToolABC",
|
|
47
48
|
# Tool context
|
|
48
49
|
"TodoContext",
|
|
50
|
+
"build_todo_context",
|
|
49
51
|
"ToolContextToken",
|
|
50
52
|
"current_run_subtask_callback",
|
|
51
53
|
"reset_tool_context",
|
|
@@ -26,7 +26,7 @@ class ApplyPatchHandler:
|
|
|
26
26
|
return model.ToolResultItem(
|
|
27
27
|
status="success",
|
|
28
28
|
output=output,
|
|
29
|
-
ui_extra=model.
|
|
29
|
+
ui_extra=model.DiffTextUIExtra(diff_text=diff_text),
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
@staticmethod
|
|
@@ -212,7 +212,7 @@ class EditTool(ToolABC):
|
|
|
212
212
|
)
|
|
213
213
|
)
|
|
214
214
|
diff_text = "\n".join(diff_lines)
|
|
215
|
-
ui_extra = model.
|
|
215
|
+
ui_extra = model.DiffTextUIExtra(diff_text=diff_text)
|
|
216
216
|
|
|
217
217
|
# Update tracker with new mtime
|
|
218
218
|
if file_tracker is not None:
|
|
@@ -183,7 +183,7 @@ class MultiEditTool(ToolABC):
|
|
|
183
183
|
)
|
|
184
184
|
)
|
|
185
185
|
diff_text = "\n".join(diff_lines)
|
|
186
|
-
ui_extra = model.
|
|
186
|
+
ui_extra = model.DiffTextUIExtra(diff_text=diff_text)
|
|
187
187
|
|
|
188
188
|
# Update tracker
|
|
189
189
|
if file_tracker is not None:
|
|
@@ -140,7 +140,7 @@ class WriteTool(ToolABC):
|
|
|
140
140
|
)
|
|
141
141
|
)
|
|
142
142
|
diff_text = "\n".join(diff_lines)
|
|
143
|
-
ui_extra = model.
|
|
143
|
+
ui_extra = model.DiffTextUIExtra(diff_text=diff_text)
|
|
144
144
|
|
|
145
145
|
message = f"File {'overwritten' if exists else 'created'} successfully at: {file_path}"
|
|
146
146
|
return model.ToolResultItem(status="success", output=message, ui_extra=ui_extra)
|
|
@@ -100,7 +100,7 @@ def _format_numbered_line(line_no: int, content: str) -> str:
|
|
|
100
100
|
return f"{line_no:>6}|{content}"
|
|
101
101
|
|
|
102
102
|
|
|
103
|
-
def _make_diff_ui_extra(before: str, after: str, path: str) -> model.
|
|
103
|
+
def _make_diff_ui_extra(before: str, after: str, path: str) -> model.DiffTextUIExtra:
|
|
104
104
|
diff_lines = list(
|
|
105
105
|
difflib.unified_diff(
|
|
106
106
|
before.splitlines(),
|
|
@@ -111,7 +111,7 @@ def _make_diff_ui_extra(before: str, after: str, path: str) -> model.ToolResultU
|
|
|
111
111
|
)
|
|
112
112
|
)
|
|
113
113
|
diff_text = "\n".join(diff_lines)
|
|
114
|
-
return model.
|
|
114
|
+
return model.DiffTextUIExtra(diff_text=diff_text)
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
@register(tools.MEMORY)
|
|
@@ -79,5 +79,6 @@ class SubAgentTool(ToolABC):
|
|
|
79
79
|
return model.ToolResultItem(
|
|
80
80
|
status="success" if not result.error else "error",
|
|
81
81
|
output=result.task_result or "",
|
|
82
|
-
ui_extra=model.
|
|
82
|
+
ui_extra=model.SessionIdUIExtra(session_id=result.session_id),
|
|
83
|
+
task_metadata=result.task_metadata,
|
|
83
84
|
)
|
|
@@ -116,6 +116,6 @@ Your todo list has changed. DO NOT mention this explicitly to the user. Here are
|
|
|
116
116
|
return model.ToolResultItem(
|
|
117
117
|
status="success",
|
|
118
118
|
output=response,
|
|
119
|
-
ui_extra=model.
|
|
119
|
+
ui_extra=model.TodoListUIExtra(todo_list=ui_extra),
|
|
120
120
|
side_effects=[model.ToolSideEffect.TODO_CHANGE],
|
|
121
121
|
)
|
|
@@ -99,6 +99,6 @@ class UpdatePlanTool(ToolABC):
|
|
|
99
99
|
return model.ToolResultItem(
|
|
100
100
|
status="success",
|
|
101
101
|
output="Plan updated",
|
|
102
|
-
ui_extra=model.
|
|
102
|
+
ui_extra=model.TodoListUIExtra(todo_list=ui_extra),
|
|
103
103
|
side_effects=[model.ToolSideEffect.TODO_CHANGE],
|
|
104
104
|
)
|
|
@@ -22,6 +22,19 @@ class TodoContext:
|
|
|
22
22
|
set_todos: Callable[[list[model.TodoItem]], None]
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
@dataclass
|
|
26
|
+
class SessionTodoStore:
|
|
27
|
+
"""Adapter exposing session todos through an explicit interface."""
|
|
28
|
+
|
|
29
|
+
session: Session
|
|
30
|
+
|
|
31
|
+
def get(self) -> list[model.TodoItem]:
|
|
32
|
+
return self.session.todos
|
|
33
|
+
|
|
34
|
+
def set(self, todos: list[model.TodoItem]) -> None:
|
|
35
|
+
self.session.todos = todos
|
|
36
|
+
|
|
37
|
+
|
|
25
38
|
@dataclass
|
|
26
39
|
class ToolContextToken:
|
|
27
40
|
"""Tokens used to restore tool execution context.
|
|
@@ -55,10 +68,7 @@ def set_tool_context_from_session(session: Session) -> ToolContextToken:
|
|
|
55
68
|
"""
|
|
56
69
|
|
|
57
70
|
file_tracker_token = current_file_tracker_var.set(session.file_tracker)
|
|
58
|
-
todo_ctx =
|
|
59
|
-
get_todos=lambda: session.todos,
|
|
60
|
-
set_todos=lambda todos: setattr(session, "todos", todos),
|
|
61
|
-
)
|
|
71
|
+
todo_ctx = build_todo_context(session)
|
|
62
72
|
todo_token = current_todo_context_var.set(todo_ctx)
|
|
63
73
|
return ToolContextToken(file_tracker_token=file_tracker_token, todo_token=todo_token)
|
|
64
74
|
|
|
@@ -87,6 +97,13 @@ def tool_context(
|
|
|
87
97
|
reset_tool_context(token)
|
|
88
98
|
|
|
89
99
|
|
|
100
|
+
def build_todo_context(session: Session) -> TodoContext:
|
|
101
|
+
"""Create a TodoContext backed by the given session."""
|
|
102
|
+
|
|
103
|
+
store = SessionTodoStore(session)
|
|
104
|
+
return TodoContext(get_todos=store.get, set_todos=store.set)
|
|
105
|
+
|
|
106
|
+
|
|
90
107
|
def get_current_file_tracker() -> MutableMapping[str, float] | None:
|
|
91
108
|
"""Return the current file tracker mapping for this tool context."""
|
|
92
109
|
|
|
@@ -34,13 +34,10 @@ async def run_tool(tool_call: model.ToolCallItem, registry: dict[str, type[ToolA
|
|
|
34
34
|
truncation_result = truncate_tool_output(tool_result.output, tool_call)
|
|
35
35
|
tool_result.output = truncation_result.output
|
|
36
36
|
if truncation_result.was_truncated and truncation_result.saved_file_path:
|
|
37
|
-
tool_result.ui_extra = model.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
original_length=truncation_result.original_length,
|
|
42
|
-
truncated_length=truncation_result.truncated_length,
|
|
43
|
-
),
|
|
37
|
+
tool_result.ui_extra = model.TruncationUIExtra(
|
|
38
|
+
saved_file_path=truncation_result.saved_file_path,
|
|
39
|
+
original_length=truncation_result.original_length,
|
|
40
|
+
truncated_length=truncation_result.truncated_length,
|
|
44
41
|
)
|
|
45
42
|
return tool_result
|
|
46
43
|
except asyncio.CancelledError:
|
|
@@ -244,7 +241,7 @@ class ToolExecutor:
|
|
|
244
241
|
for side_effect in side_effects:
|
|
245
242
|
if side_effect == model.ToolSideEffect.TODO_CHANGE:
|
|
246
243
|
todos: list[model.TodoItem] | None = None
|
|
247
|
-
if tool_result.ui_extra
|
|
244
|
+
if isinstance(tool_result.ui_extra, model.TodoListUIExtra):
|
|
248
245
|
todos = tool_result.ui_extra.todo_list.todos
|
|
249
246
|
if todos is not None:
|
|
250
247
|
side_effect_events.append(ToolExecutionTodoChange(todos=todos))
|
|
@@ -49,10 +49,7 @@ class MermaidTool(ToolABC):
|
|
|
49
49
|
|
|
50
50
|
link = cls._build_link(args.code)
|
|
51
51
|
line_count = cls._count_lines(args.code)
|
|
52
|
-
ui_extra = model.
|
|
53
|
-
type=model.ToolResultUIExtraType.MERMAID_LINK,
|
|
54
|
-
mermaid_link=model.MermaidLinkUIExtra(link=link, line_count=line_count),
|
|
55
|
-
)
|
|
52
|
+
ui_extra = model.MermaidLinkUIExtra(link=link, line_count=line_count)
|
|
56
53
|
output = f"Mermaid diagram rendered successfully ({line_count} lines)."
|
|
57
54
|
return model.ToolResultItem(status="success", output=output, ui_extra=ui_extra)
|
|
58
55
|
|
klaude_code/core/turn.py
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from collections.abc import AsyncGenerator
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
4
|
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from klaude_code.core.tool import ToolABC, tool_context
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from klaude_code.core.task import SessionContext
|
|
5
11
|
|
|
6
|
-
from klaude_code.core.tool import TodoContext, ToolABC, tool_context
|
|
7
12
|
from klaude_code.core.tool.tool_runner import (
|
|
8
13
|
ToolExecutionCallStarted,
|
|
9
14
|
ToolExecutionResult,
|
|
@@ -26,16 +31,21 @@ class TurnError(Exception):
|
|
|
26
31
|
class TurnExecutionContext:
|
|
27
32
|
"""Execution context required to run a single turn."""
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
get_conversation_history: Callable[[], list[model.ConversationItem]]
|
|
31
|
-
append_history: Callable[[Sequence[model.ConversationItem]], None]
|
|
34
|
+
session_ctx: SessionContext
|
|
32
35
|
llm_client: LLMClientABC
|
|
33
36
|
system_prompt: str | None
|
|
34
37
|
tools: list[llm_param.ToolSchema]
|
|
35
38
|
tool_registry: dict[str, type[ToolABC]]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class TurnResult:
|
|
43
|
+
"""Aggregated state produced while executing a turn."""
|
|
44
|
+
|
|
45
|
+
reasoning_items: list[model.ReasoningTextItem | model.ReasoningEncryptedItem]
|
|
46
|
+
assistant_message: model.AssistantMessageItem | None
|
|
47
|
+
tool_calls: list[model.ToolCallItem]
|
|
48
|
+
stream_error: model.StreamErrorItem | None
|
|
39
49
|
|
|
40
50
|
|
|
41
51
|
def build_events_from_tool_executor_event(session_id: str, event: ToolExecutorEvent) -> list[events.Event]:
|
|
@@ -64,6 +74,7 @@ def build_events_from_tool_executor_event(session_id: str, event: ToolExecutorEv
|
|
|
64
74
|
result=tool_result.output or "",
|
|
65
75
|
ui_extra=tool_result.ui_extra,
|
|
66
76
|
status=tool_result.status,
|
|
77
|
+
task_metadata=tool_result.task_metadata,
|
|
67
78
|
)
|
|
68
79
|
)
|
|
69
80
|
case ToolExecutionTodoChange(todos=todos):
|
|
@@ -87,18 +98,18 @@ class TurnExecutor:
|
|
|
87
98
|
def __init__(self, context: TurnExecutionContext) -> None:
|
|
88
99
|
self._context = context
|
|
89
100
|
self._tool_executor: ToolExecutor | None = None
|
|
90
|
-
self.
|
|
101
|
+
self._turn_result: TurnResult | None = None
|
|
91
102
|
|
|
92
103
|
@property
|
|
93
104
|
def has_tool_call(self) -> bool:
|
|
94
|
-
return self.
|
|
105
|
+
return bool(self._turn_result and self._turn_result.tool_calls)
|
|
95
106
|
|
|
96
107
|
def cancel(self) -> list[events.Event]:
|
|
97
108
|
"""Cancel running tools and return any resulting events."""
|
|
98
109
|
ui_events: list[events.Event] = []
|
|
99
110
|
if self._tool_executor is not None:
|
|
100
111
|
for exec_event in self._tool_executor.cancel():
|
|
101
|
-
for ui_event in build_events_from_tool_executor_event(self._context.session_id, exec_event):
|
|
112
|
+
for ui_event in build_events_from_tool_executor_event(self._context.session_ctx.session_id, exec_event):
|
|
102
113
|
ui_events.append(ui_event)
|
|
103
114
|
self._tool_executor = None
|
|
104
115
|
return ui_events
|
|
@@ -110,22 +121,45 @@ class TurnExecutor:
|
|
|
110
121
|
TurnError: If the turn fails (stream error or non-completed status).
|
|
111
122
|
"""
|
|
112
123
|
ctx = self._context
|
|
124
|
+
session_ctx = ctx.session_ctx
|
|
125
|
+
|
|
126
|
+
yield events.TurnStartEvent(session_id=session_ctx.session_id)
|
|
127
|
+
|
|
128
|
+
self._turn_result = TurnResult(
|
|
129
|
+
reasoning_items=[],
|
|
130
|
+
assistant_message=None,
|
|
131
|
+
tool_calls=[],
|
|
132
|
+
stream_error=None,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async for event in self._consume_llm_stream(self._turn_result):
|
|
136
|
+
yield event
|
|
137
|
+
|
|
138
|
+
if self._turn_result.stream_error is not None:
|
|
139
|
+
session_ctx.append_history([self._turn_result.stream_error])
|
|
140
|
+
yield events.TurnEndEvent(session_id=session_ctx.session_id)
|
|
141
|
+
raise TurnError(self._turn_result.stream_error.error)
|
|
113
142
|
|
|
114
|
-
|
|
143
|
+
self._append_success_history(self._turn_result)
|
|
115
144
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
response_failed = False
|
|
120
|
-
error_message: str | None = None
|
|
145
|
+
if self._turn_result.tool_calls:
|
|
146
|
+
async for ui_event in self._run_tool_executor(self._turn_result.tool_calls):
|
|
147
|
+
yield ui_event
|
|
121
148
|
|
|
149
|
+
yield events.TurnEndEvent(session_id=session_ctx.session_id)
|
|
150
|
+
|
|
151
|
+
async def _consume_llm_stream(self, turn_result: TurnResult) -> AsyncGenerator[events.Event, None]:
|
|
152
|
+
"""Stream events from LLM and update turn_result in place."""
|
|
153
|
+
|
|
154
|
+
ctx = self._context
|
|
155
|
+
session_ctx = ctx.session_ctx
|
|
122
156
|
async for response_item in ctx.llm_client.call(
|
|
123
157
|
llm_param.LLMCallParameter(
|
|
124
|
-
input=
|
|
158
|
+
input=session_ctx.get_conversation_history(),
|
|
125
159
|
system=ctx.system_prompt,
|
|
126
160
|
tools=ctx.tools,
|
|
127
161
|
store=False,
|
|
128
|
-
session_id=
|
|
162
|
+
session_id=session_ctx.session_id,
|
|
129
163
|
)
|
|
130
164
|
):
|
|
131
165
|
log_debug(
|
|
@@ -136,41 +170,36 @@ class TurnExecutor:
|
|
|
136
170
|
)
|
|
137
171
|
match response_item:
|
|
138
172
|
case model.StartItem():
|
|
139
|
-
|
|
173
|
+
continue
|
|
140
174
|
case model.ReasoningTextItem() as item:
|
|
141
|
-
|
|
175
|
+
turn_result.reasoning_items.append(item)
|
|
142
176
|
yield events.ThinkingEvent(
|
|
143
177
|
content=item.content,
|
|
144
178
|
response_id=item.response_id,
|
|
145
|
-
session_id=
|
|
179
|
+
session_id=session_ctx.session_id,
|
|
146
180
|
)
|
|
147
181
|
case model.ReasoningEncryptedItem() as item:
|
|
148
|
-
|
|
182
|
+
turn_result.reasoning_items.append(item)
|
|
149
183
|
case model.AssistantMessageDelta() as item:
|
|
150
184
|
yield events.AssistantMessageDeltaEvent(
|
|
151
185
|
content=item.content,
|
|
152
186
|
response_id=item.response_id,
|
|
153
|
-
session_id=
|
|
187
|
+
session_id=session_ctx.session_id,
|
|
154
188
|
)
|
|
155
189
|
case model.AssistantMessageItem() as item:
|
|
156
|
-
|
|
190
|
+
turn_result.assistant_message = item
|
|
157
191
|
yield events.AssistantMessageEvent(
|
|
158
192
|
content=item.content or "",
|
|
159
193
|
response_id=item.response_id,
|
|
160
|
-
session_id=
|
|
194
|
+
session_id=session_ctx.session_id,
|
|
161
195
|
)
|
|
162
196
|
case model.ResponseMetadataItem() as item:
|
|
163
197
|
yield events.ResponseMetadataEvent(
|
|
164
|
-
session_id=
|
|
198
|
+
session_id=session_ctx.session_id,
|
|
165
199
|
metadata=item,
|
|
166
200
|
)
|
|
167
|
-
status = item.status
|
|
168
|
-
if status is not None and status != "completed":
|
|
169
|
-
response_failed = True
|
|
170
|
-
error_message = f"Response status: {status}"
|
|
171
201
|
case model.StreamErrorItem() as item:
|
|
172
|
-
|
|
173
|
-
error_message = item.error
|
|
202
|
+
turn_result.stream_error = item
|
|
174
203
|
log_debug(
|
|
175
204
|
"[StreamError]",
|
|
176
205
|
item.error,
|
|
@@ -179,42 +208,41 @@ class TurnExecutor:
|
|
|
179
208
|
)
|
|
180
209
|
case model.ToolCallStartItem() as item:
|
|
181
210
|
yield events.TurnToolCallStartEvent(
|
|
182
|
-
session_id=
|
|
211
|
+
session_id=session_ctx.session_id,
|
|
183
212
|
response_id=item.response_id,
|
|
184
213
|
tool_call_id=item.call_id,
|
|
185
214
|
tool_name=item.name,
|
|
186
215
|
arguments="",
|
|
187
216
|
)
|
|
188
217
|
case model.ToolCallItem() as item:
|
|
189
|
-
|
|
218
|
+
turn_result.tool_calls.append(item)
|
|
190
219
|
case _:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if
|
|
199
|
-
|
|
200
|
-
if
|
|
201
|
-
|
|
202
|
-
if turn_tool_calls:
|
|
203
|
-
ctx.append_history(turn_tool_calls)
|
|
204
|
-
self._has_tool_call = True
|
|
205
|
-
|
|
206
|
-
# Execute tools
|
|
207
|
-
if turn_tool_calls:
|
|
208
|
-
with tool_context(ctx.file_tracker, ctx.todo_context):
|
|
209
|
-
executor = ToolExecutor(
|
|
210
|
-
registry=ctx.tool_registry,
|
|
211
|
-
append_history=ctx.append_history,
|
|
212
|
-
)
|
|
213
|
-
self._tool_executor = executor
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
def _append_success_history(self, turn_result: TurnResult) -> None:
|
|
223
|
+
"""Persist successful turn artifacts to conversation history."""
|
|
224
|
+
session_ctx = self._context.session_ctx
|
|
225
|
+
if turn_result.reasoning_items:
|
|
226
|
+
session_ctx.append_history(turn_result.reasoning_items)
|
|
227
|
+
if turn_result.assistant_message:
|
|
228
|
+
session_ctx.append_history([turn_result.assistant_message])
|
|
229
|
+
if turn_result.tool_calls:
|
|
230
|
+
session_ctx.append_history(turn_result.tool_calls)
|
|
214
231
|
|
|
215
|
-
|
|
216
|
-
|
|
232
|
+
async def _run_tool_executor(self, tool_calls: list[model.ToolCallItem]) -> AsyncGenerator[events.Event, None]:
|
|
233
|
+
"""Run tools for the turn and translate executor events to UI events."""
|
|
234
|
+
|
|
235
|
+
ctx = self._context
|
|
236
|
+
session_ctx = ctx.session_ctx
|
|
237
|
+
with tool_context(session_ctx.file_tracker, session_ctx.todo_context):
|
|
238
|
+
executor = ToolExecutor(
|
|
239
|
+
registry=ctx.tool_registry,
|
|
240
|
+
append_history=session_ctx.append_history,
|
|
241
|
+
)
|
|
242
|
+
self._tool_executor = executor
|
|
243
|
+
try:
|
|
244
|
+
async for exec_event in executor.run_tools(tool_calls):
|
|
245
|
+
for ui_event in build_events_from_tool_executor_event(session_ctx.session_id, exec_event):
|
|
217
246
|
yield ui_event
|
|
247
|
+
finally:
|
|
218
248
|
self._tool_executor = None
|
|
219
|
-
|
|
220
|
-
yield events.TurnEndEvent(session_id=ctx.session_id)
|