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.
Files changed (52) hide show
  1. klaude_code/auth/codex/__init__.py +1 -1
  2. klaude_code/command/__init__.py +2 -0
  3. klaude_code/command/prompt-deslop.md +14 -0
  4. klaude_code/command/release_notes_cmd.py +86 -0
  5. klaude_code/command/status_cmd.py +92 -54
  6. klaude_code/core/agent.py +13 -19
  7. klaude_code/core/manager/sub_agent_manager.py +5 -1
  8. klaude_code/core/prompt.py +38 -28
  9. klaude_code/core/reminders.py +4 -4
  10. klaude_code/core/task.py +60 -45
  11. klaude_code/core/tool/__init__.py +2 -0
  12. klaude_code/core/tool/file/apply_patch_tool.py +1 -1
  13. klaude_code/core/tool/file/edit_tool.py +1 -1
  14. klaude_code/core/tool/file/multi_edit_tool.py +1 -1
  15. klaude_code/core/tool/file/write_tool.py +1 -1
  16. klaude_code/core/tool/memory/memory_tool.py +2 -2
  17. klaude_code/core/tool/sub_agent_tool.py +2 -1
  18. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  19. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  20. klaude_code/core/tool/tool_context.py +21 -4
  21. klaude_code/core/tool/tool_runner.py +5 -8
  22. klaude_code/core/tool/web/mermaid_tool.py +1 -4
  23. klaude_code/core/turn.py +90 -62
  24. klaude_code/llm/anthropic/client.py +15 -46
  25. klaude_code/llm/client.py +1 -1
  26. klaude_code/llm/codex/client.py +44 -30
  27. klaude_code/llm/input_common.py +0 -6
  28. klaude_code/llm/openai_compatible/client.py +29 -73
  29. klaude_code/llm/openai_compatible/input.py +6 -4
  30. klaude_code/llm/openai_compatible/stream_processor.py +82 -0
  31. klaude_code/llm/openrouter/client.py +29 -59
  32. klaude_code/llm/openrouter/input.py +4 -27
  33. klaude_code/llm/responses/client.py +49 -79
  34. klaude_code/llm/usage.py +51 -10
  35. klaude_code/protocol/commands.py +1 -0
  36. klaude_code/protocol/events.py +12 -2
  37. klaude_code/protocol/model.py +142 -26
  38. klaude_code/protocol/sub_agent.py +5 -1
  39. klaude_code/session/export.py +51 -27
  40. klaude_code/session/session.py +33 -16
  41. klaude_code/session/templates/export_session.html +4 -1
  42. klaude_code/ui/modes/repl/__init__.py +1 -5
  43. klaude_code/ui/modes/repl/event_handler.py +153 -54
  44. klaude_code/ui/modes/repl/renderer.py +6 -4
  45. klaude_code/ui/renderers/developer.py +35 -25
  46. klaude_code/ui/renderers/metadata.py +68 -30
  47. klaude_code/ui/renderers/tools.py +53 -87
  48. klaude_code/ui/rich/markdown.py +5 -5
  49. {klaude_code-1.2.7.dist-info → klaude_code-1.2.9.dist-info}/METADATA +1 -1
  50. {klaude_code-1.2.7.dist-info → klaude_code-1.2.9.dist-info}/RECORD +52 -49
  51. {klaude_code-1.2.7.dist-info → klaude_code-1.2.9.dist-info}/WHEEL +0 -0
  52. {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._accumulated = model.ResponseMetadataItem(model_name=model_name)
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
- accumulated = self._accumulated
35
+ main = self._main
35
36
  usage = turn_metadata.usage
36
37
 
37
38
  if usage is not None:
38
- if accumulated.usage is None:
39
- accumulated.usage = model.Usage()
40
- acc_usage = accumulated.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.context_usage_percent is not None:
49
- acc_usage.context_usage_percent = usage.context_usage_percent
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
- accumulated.provider = turn_metadata.provider
76
+ main.provider = turn_metadata.provider
78
77
  if turn_metadata.model_name:
79
- accumulated.model_name = turn_metadata.model_name
80
- if turn_metadata.response_id:
81
- accumulated.response_id = turn_metadata.response_id
82
- if turn_metadata.status is not None:
83
- accumulated.status = turn_metadata.status
84
- if turn_metadata.error_reason is not None:
85
- accumulated.error_reason = turn_metadata.error_reason
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
- accumulated = self._accumulated
90
- if accumulated.usage is not None:
86
+ main = self._main
87
+ if main.usage is not None:
91
88
  if self._throughput_tracked_tokens > 0:
92
- accumulated.usage.throughput_tps = self._throughput_weighted_sum / self._throughput_tracked_tokens
89
+ main.usage.throughput_tps = self._throughput_weighted_sum / self._throughput_tracked_tokens
93
90
  else:
94
- accumulated.usage.throughput_tps = None
91
+ main.usage.throughput_tps = None
95
92
 
96
- accumulated.task_duration_s = task_duration_s
97
- return accumulated
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 TaskExecutionContext:
102
- """Execution context required to run a task."""
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=ctx.session_id,
153
+ session_id=session_ctx.session_id,
146
154
  sub_agent_state=ctx.sub_agent_state,
147
155
  )
148
156
 
149
- ctx.append_history([model.UserMessageItem(content=user_input.text, images=user_input.images)])
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
- session_id=ctx.session_id,
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.ResponseMetadataEvent(metadata=accumulated, session_id=ctx.session_id)
227
- ctx.append_history([accumulated])
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=ctx.session_id,
230
- task_result=last_assistant_message.content if last_assistant_message else "",
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.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text),
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.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
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.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
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.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
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.ToolResultUIExtra:
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.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
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.ToolResultUIExtra(type=model.ToolResultUIExtraType.SESSION_ID, session_id=result.session_id),
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.ToolResultUIExtra(type=model.ToolResultUIExtraType.TODO_LIST, todo_list=ui_extra),
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.ToolResultUIExtra(type=model.ToolResultUIExtraType.TODO_LIST, todo_list=ui_extra),
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 = TodoContext(
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.ToolResultUIExtra(
38
- type=model.ToolResultUIExtraType.TRUNCATION,
39
- truncation=model.TruncationUIExtra(
40
- saved_file_path=truncation_result.saved_file_path,
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 is not None and tool_result.ui_extra.todo_list is not None:
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.ToolResultUIExtra(
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, Callable, MutableMapping, Sequence
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
- session_id: str
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
- # For tool context
37
- file_tracker: MutableMapping[str, float]
38
- todo_context: TodoContext
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._has_tool_call: bool = False
101
+ self._turn_result: TurnResult | None = None
91
102
 
92
103
  @property
93
104
  def has_tool_call(self) -> bool:
94
- return self._has_tool_call
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
- yield events.TurnStartEvent(session_id=ctx.session_id)
143
+ self._append_success_history(self._turn_result)
115
144
 
116
- turn_reasoning_items: list[model.ReasoningTextItem | model.ReasoningEncryptedItem] = []
117
- turn_assistant_message: model.AssistantMessageItem | None = None
118
- turn_tool_calls: list[model.ToolCallItem] = []
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=ctx.get_conversation_history(),
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=ctx.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
- pass
173
+ continue
140
174
  case model.ReasoningTextItem() as item:
141
- turn_reasoning_items.append(item)
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=ctx.session_id,
179
+ session_id=session_ctx.session_id,
146
180
  )
147
181
  case model.ReasoningEncryptedItem() as item:
148
- turn_reasoning_items.append(item)
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=ctx.session_id,
187
+ session_id=session_ctx.session_id,
154
188
  )
155
189
  case model.AssistantMessageItem() as item:
156
- turn_assistant_message = item
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=ctx.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=ctx.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
- response_failed = True
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=ctx.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
- turn_tool_calls.append(item)
218
+ turn_result.tool_calls.append(item)
190
219
  case _:
191
- pass
192
-
193
- if response_failed:
194
- yield events.TurnEndEvent(session_id=ctx.session_id)
195
- raise TurnError(error_message or "Turn failed")
196
-
197
- # Append to history only on success
198
- if turn_reasoning_items:
199
- ctx.append_history(turn_reasoning_items)
200
- if turn_assistant_message:
201
- ctx.append_history([turn_assistant_message])
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
- async for exec_event in executor.run_tools(turn_tool_calls):
216
- for ui_event in build_events_from_tool_executor_event(ctx.session_id, exec_event):
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)