klaude-code 1.9.0__py3-none-any.whl → 2.0.1__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 (132) hide show
  1. klaude_code/auth/base.py +2 -6
  2. klaude_code/cli/auth_cmd.py +4 -4
  3. klaude_code/cli/cost_cmd.py +1 -1
  4. klaude_code/cli/list_model.py +1 -1
  5. klaude_code/cli/main.py +1 -1
  6. klaude_code/cli/runtime.py +7 -5
  7. klaude_code/cli/self_update.py +1 -1
  8. klaude_code/cli/session_cmd.py +1 -1
  9. klaude_code/command/clear_cmd.py +6 -2
  10. klaude_code/command/command_abc.py +2 -2
  11. klaude_code/command/debug_cmd.py +4 -4
  12. klaude_code/command/export_cmd.py +2 -2
  13. klaude_code/command/export_online_cmd.py +12 -12
  14. klaude_code/command/fork_session_cmd.py +29 -23
  15. klaude_code/command/help_cmd.py +4 -4
  16. klaude_code/command/model_cmd.py +4 -4
  17. klaude_code/command/model_select.py +1 -1
  18. klaude_code/command/prompt-commit.md +11 -2
  19. klaude_code/command/prompt_command.py +3 -3
  20. klaude_code/command/refresh_cmd.py +2 -2
  21. klaude_code/command/registry.py +7 -5
  22. klaude_code/command/release_notes_cmd.py +4 -4
  23. klaude_code/command/resume_cmd.py +15 -11
  24. klaude_code/command/status_cmd.py +4 -4
  25. klaude_code/command/terminal_setup_cmd.py +8 -8
  26. klaude_code/command/thinking_cmd.py +4 -4
  27. klaude_code/config/assets/builtin_config.yaml +20 -0
  28. klaude_code/config/builtin_config.py +16 -5
  29. klaude_code/config/config.py +7 -2
  30. klaude_code/const.py +147 -91
  31. klaude_code/core/agent.py +3 -12
  32. klaude_code/core/executor.py +18 -39
  33. klaude_code/core/manager/sub_agent_manager.py +71 -7
  34. klaude_code/core/prompts/prompt-sub-agent-image-gen.md +1 -0
  35. klaude_code/core/prompts/prompt-sub-agent-web.md +27 -1
  36. klaude_code/core/reminders.py +88 -69
  37. klaude_code/core/task.py +44 -45
  38. klaude_code/core/tool/file/apply_patch_tool.py +9 -9
  39. klaude_code/core/tool/file/diff_builder.py +3 -5
  40. klaude_code/core/tool/file/edit_tool.py +23 -23
  41. klaude_code/core/tool/file/move_tool.py +43 -43
  42. klaude_code/core/tool/file/read_tool.py +44 -39
  43. klaude_code/core/tool/file/write_tool.py +14 -14
  44. klaude_code/core/tool/report_back_tool.py +4 -4
  45. klaude_code/core/tool/shell/bash_tool.py +23 -23
  46. klaude_code/core/tool/skill/skill_tool.py +7 -7
  47. klaude_code/core/tool/sub_agent_tool.py +38 -9
  48. klaude_code/core/tool/todo/todo_write_tool.py +9 -10
  49. klaude_code/core/tool/todo/update_plan_tool.py +6 -6
  50. klaude_code/core/tool/tool_abc.py +2 -2
  51. klaude_code/core/tool/tool_context.py +27 -0
  52. klaude_code/core/tool/tool_runner.py +88 -42
  53. klaude_code/core/tool/truncation.py +38 -20
  54. klaude_code/core/tool/web/mermaid_tool.py +6 -7
  55. klaude_code/core/tool/web/web_fetch_tool.py +68 -30
  56. klaude_code/core/tool/web/web_search_tool.py +15 -17
  57. klaude_code/core/turn.py +120 -73
  58. klaude_code/llm/anthropic/client.py +79 -44
  59. klaude_code/llm/anthropic/input.py +116 -108
  60. klaude_code/llm/bedrock/client.py +8 -5
  61. klaude_code/llm/claude/client.py +18 -8
  62. klaude_code/llm/client.py +4 -3
  63. klaude_code/llm/codex/client.py +15 -9
  64. klaude_code/llm/google/client.py +122 -60
  65. klaude_code/llm/google/input.py +94 -108
  66. klaude_code/llm/image.py +123 -0
  67. klaude_code/llm/input_common.py +136 -189
  68. klaude_code/llm/openai_compatible/client.py +17 -7
  69. klaude_code/llm/openai_compatible/input.py +36 -66
  70. klaude_code/llm/openai_compatible/stream.py +119 -67
  71. klaude_code/llm/openai_compatible/tool_call_accumulator.py +23 -11
  72. klaude_code/llm/openrouter/client.py +34 -9
  73. klaude_code/llm/openrouter/input.py +63 -64
  74. klaude_code/llm/openrouter/reasoning.py +22 -24
  75. klaude_code/llm/registry.py +20 -17
  76. klaude_code/llm/responses/client.py +107 -45
  77. klaude_code/llm/responses/input.py +115 -98
  78. klaude_code/llm/usage.py +52 -25
  79. klaude_code/protocol/__init__.py +1 -0
  80. klaude_code/protocol/events.py +16 -12
  81. klaude_code/protocol/llm_param.py +20 -2
  82. klaude_code/protocol/message.py +250 -0
  83. klaude_code/protocol/model.py +95 -285
  84. klaude_code/protocol/op.py +2 -15
  85. klaude_code/protocol/op_handler.py +0 -5
  86. klaude_code/protocol/sub_agent/__init__.py +1 -0
  87. klaude_code/protocol/sub_agent/explore.py +10 -0
  88. klaude_code/protocol/sub_agent/image_gen.py +119 -0
  89. klaude_code/protocol/sub_agent/task.py +10 -0
  90. klaude_code/protocol/sub_agent/web.py +10 -0
  91. klaude_code/session/codec.py +6 -6
  92. klaude_code/session/export.py +261 -62
  93. klaude_code/session/selector.py +7 -24
  94. klaude_code/session/session.py +126 -54
  95. klaude_code/session/store.py +5 -32
  96. klaude_code/session/templates/export_session.html +1 -1
  97. klaude_code/session/templates/mermaid_viewer.html +1 -1
  98. klaude_code/trace/log.py +11 -6
  99. klaude_code/ui/core/input.py +1 -1
  100. klaude_code/ui/core/stage_manager.py +1 -8
  101. klaude_code/ui/modes/debug/display.py +2 -2
  102. klaude_code/ui/modes/repl/clipboard.py +2 -2
  103. klaude_code/ui/modes/repl/completers.py +18 -10
  104. klaude_code/ui/modes/repl/event_handler.py +138 -132
  105. klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
  106. klaude_code/ui/modes/repl/key_bindings.py +136 -2
  107. klaude_code/ui/modes/repl/renderer.py +107 -15
  108. klaude_code/ui/renderers/assistant.py +2 -2
  109. klaude_code/ui/renderers/bash_syntax.py +36 -4
  110. klaude_code/ui/renderers/common.py +70 -10
  111. klaude_code/ui/renderers/developer.py +7 -6
  112. klaude_code/ui/renderers/diffs.py +11 -11
  113. klaude_code/ui/renderers/mermaid_viewer.py +49 -2
  114. klaude_code/ui/renderers/metadata.py +33 -5
  115. klaude_code/ui/renderers/sub_agent.py +57 -16
  116. klaude_code/ui/renderers/thinking.py +37 -2
  117. klaude_code/ui/renderers/tools.py +188 -178
  118. klaude_code/ui/rich/live.py +3 -1
  119. klaude_code/ui/rich/markdown.py +39 -7
  120. klaude_code/ui/rich/quote.py +76 -1
  121. klaude_code/ui/rich/status.py +14 -8
  122. klaude_code/ui/rich/theme.py +20 -14
  123. klaude_code/ui/terminal/image.py +34 -0
  124. klaude_code/ui/terminal/notifier.py +2 -1
  125. klaude_code/ui/terminal/progress_bar.py +4 -4
  126. klaude_code/ui/terminal/selector.py +22 -4
  127. klaude_code/ui/utils/common.py +11 -2
  128. {klaude_code-1.9.0.dist-info → klaude_code-2.0.1.dist-info}/METADATA +4 -2
  129. klaude_code-2.0.1.dist-info/RECORD +229 -0
  130. klaude_code-1.9.0.dist-info/RECORD +0 -224
  131. {klaude_code-1.9.0.dist-info → klaude_code-2.0.1.dist-info}/WHEEL +0 -0
  132. {klaude_code-1.9.0.dist-info → klaude_code-2.0.1.dist-info}/entry_points.txt +0 -0
@@ -19,7 +19,7 @@ from klaude_code.core.agent import Agent, DefaultModelProfileProvider, ModelProf
19
19
  from klaude_code.core.manager import LLMClients, SubAgentManager
20
20
  from klaude_code.core.tool import current_run_subtask_callback
21
21
  from klaude_code.llm.registry import create_llm_client
22
- from klaude_code.protocol import commands, events, model, op
22
+ from klaude_code.protocol import commands, events, message, model, op
23
23
  from klaude_code.protocol.llm_param import Thinking
24
24
  from klaude_code.protocol.op_handler import OperationHandler
25
25
  from klaude_code.protocol.sub_agent import SubAgentResult
@@ -176,33 +176,6 @@ class ExecutorContext:
176
176
  """Initialize an agent for a session and replay history to UI."""
177
177
  await self._ensure_agent(operation.session_id)
178
178
 
179
- async def handle_user_input(self, operation: op.UserInputOperation) -> None:
180
- """Handle a user input operation.
181
-
182
- Core should not parse slash commands. The UI/CLI layer is responsible for
183
- turning raw user input into one or more operations.
184
- """
185
-
186
- if operation.session_id is None:
187
- raise ValueError("session_id cannot be None")
188
-
189
- session_id = operation.session_id
190
- agent = await self._ensure_agent(session_id)
191
- user_input = operation.input
192
-
193
- await self.emit_event(
194
- events.UserMessageEvent(content=user_input.text, session_id=session_id, images=user_input.images)
195
- )
196
- agent.session.append_history([model.UserMessageItem(content=user_input.text, images=user_input.images)])
197
-
198
- await self.handle_run_agent(
199
- op.RunAgentOperation(
200
- id=operation.id,
201
- session_id=session_id,
202
- input=user_input,
203
- )
204
- )
205
-
206
179
  async def handle_run_agent(self, operation: op.RunAgentOperation) -> None:
207
180
  agent = await self._ensure_agent(operation.session_id)
208
181
  existing_active = self.task_manager.get(operation.id)
@@ -230,8 +203,8 @@ class ExecutorContext:
230
203
 
231
204
  if operation.emit_switch_message:
232
205
  default_note = " (saved as default)" if operation.save_as_default else ""
233
- developer_item = model.DeveloperMessageItem(
234
- content=f"Switched to: {llm_config.model}{default_note}",
206
+ developer_item = message.DeveloperMessage(
207
+ parts=message.text_parts_from_str(f"Switched to: {llm_config.model}{default_note}"),
235
208
  command_output=model.CommandOutput(command_name=commands.CommandName.MODEL),
236
209
  )
237
210
  agent.session.append_history([developer_item])
@@ -275,8 +248,8 @@ class ExecutorContext:
275
248
  new_status = _format_thinking_for_display(config.thinking)
276
249
 
277
250
  if operation.emit_switch_message:
278
- developer_item = model.DeveloperMessageItem(
279
- content=f"Thinking changed: {current} -> {new_status}",
251
+ developer_item = message.DeveloperMessage(
252
+ parts=message.text_parts_from_str(f"Thinking changed: {current} -> {new_status}"),
280
253
  command_output=model.CommandOutput(command_name=commands.CommandName.THINKING),
281
254
  )
282
255
  agent.session.append_history([developer_item])
@@ -293,11 +266,17 @@ class ExecutorContext:
293
266
  new_session.model_thinking = agent.session.model_thinking
294
267
  agent.session = new_session
295
268
 
296
- developer_item = model.DeveloperMessageItem(
297
- content="started new conversation",
269
+ developer_item = message.DeveloperMessage(
270
+ parts=message.text_parts_from_str("started new conversation"),
298
271
  command_output=model.CommandOutput(command_name=commands.CommandName.CLEAR),
299
272
  )
300
273
  await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
274
+ await self.emit_event(
275
+ events.WelcomeEvent(
276
+ work_dir=str(agent.session.work_dir),
277
+ llm_config=self.llm_clients.main.get_llm_config(),
278
+ )
279
+ )
301
280
 
302
281
  async def handle_resume_session(self, operation: op.ResumeSessionOperation) -> None:
303
282
  target_session = Session.load(operation.target_session_id)
@@ -338,8 +317,8 @@ class ExecutorContext:
338
317
  await asyncio.to_thread(output_path.parent.mkdir, parents=True, exist_ok=True)
339
318
  await asyncio.to_thread(output_path.write_text, html_doc, "utf-8")
340
319
  await asyncio.to_thread(self._open_file, output_path)
341
- developer_item = model.DeveloperMessageItem(
342
- content=f"Session exported and opened: {output_path}",
320
+ developer_item = message.DeveloperMessage(
321
+ parts=message.text_parts_from_str(f"Session exported and opened: {output_path}"),
343
322
  command_output=model.CommandOutput(command_name=commands.CommandName.EXPORT),
344
323
  )
345
324
  agent.session.append_history([developer_item])
@@ -347,8 +326,8 @@ class ExecutorContext:
347
326
  except Exception as exc: # pragma: no cover
348
327
  import traceback
349
328
 
350
- developer_item = model.DeveloperMessageItem(
351
- content=f"Failed to export session: {exc}\n{traceback.format_exc()}",
329
+ developer_item = message.DeveloperMessage(
330
+ parts=message.text_parts_from_str(f"Failed to export session: {exc}\n{traceback.format_exc()}"),
352
331
  command_output=model.CommandOutput(command_name=commands.CommandName.EXPORT, is_error=True),
353
332
  )
354
333
  agent.session.append_history([developer_item])
@@ -357,7 +336,7 @@ class ExecutorContext:
357
336
  async def _run_agent_task(
358
337
  self,
359
338
  agent: Agent,
360
- user_input: model.UserInputPayload,
339
+ user_input: message.UserInputPayload,
361
340
  task_id: str,
362
341
  session_id: str,
363
342
  ) -> None:
@@ -8,11 +8,13 @@ their events back to the shared event queue.
8
8
  from __future__ import annotations
9
9
 
10
10
  import asyncio
11
+ import json
11
12
 
12
13
  from klaude_code.core.agent import Agent, AgentProfile, ModelProfileProvider
13
14
  from klaude_code.core.manager.llm_clients import LLMClients
14
15
  from klaude_code.core.tool import ReportBackTool
15
- from klaude_code.protocol import events, model
16
+ from klaude_code.core.tool.tool_context import record_sub_agent_session_id
17
+ from klaude_code.protocol import events, message, model
16
18
  from klaude_code.protocol.sub_agent import SubAgentResult
17
19
  from klaude_code.session.session import Session
18
20
  from klaude_code.trace import DebugType, log_debug
@@ -39,10 +41,60 @@ class SubAgentManager:
39
41
  async def run_sub_agent(self, parent_agent: Agent, state: model.SubAgentState) -> SubAgentResult:
40
42
  """Run a nested sub-agent task and return its result."""
41
43
 
42
- # Create a child session under the same workdir
43
44
  parent_session = parent_agent.session
44
- child_session = Session(work_dir=parent_session.work_dir)
45
- child_session.sub_agent_state = state
45
+ resume_session_id = state.resume
46
+
47
+ def _append_agent_id(task_result: str, session_id: str) -> str:
48
+ trimmed = (task_result or "").rstrip()
49
+ footer = f"agentId: {session_id} (for resuming to continue this agent's work if needed)"
50
+ if trimmed.strip():
51
+ return f"{trimmed}\n\n{footer}"
52
+ return footer
53
+
54
+ if resume_session_id:
55
+ try:
56
+ child_session = Session.load(resume_session_id)
57
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
58
+ return SubAgentResult(
59
+ task_result=f"Failed to resume sub-agent session '{resume_session_id}': {exc}",
60
+ session_id="",
61
+ error=True,
62
+ )
63
+
64
+ if child_session.sub_agent_state is None:
65
+ return SubAgentResult(
66
+ task_result=(f"Invalid resume id '{resume_session_id}': target session is not a sub-agent session"),
67
+ session_id="",
68
+ error=True,
69
+ )
70
+ if child_session.sub_agent_state.sub_agent_type != state.sub_agent_type:
71
+ return SubAgentResult(
72
+ task_result=(
73
+ "Invalid resume id: sub-agent type mismatch. "
74
+ f"Expected '{state.sub_agent_type}', got '{child_session.sub_agent_state.sub_agent_type}'."
75
+ ),
76
+ session_id="",
77
+ error=True,
78
+ )
79
+
80
+ # Expose the session id immediately so ToolExecutor.cancel() can attach
81
+ # it to the synthesized cancellation ToolResult.
82
+ record_sub_agent_session_id(child_session.id)
83
+
84
+ # Update persisted sub-agent state to reflect the current invocation.
85
+ child_session.sub_agent_state.sub_agent_desc = state.sub_agent_desc
86
+ child_session.sub_agent_state.sub_agent_prompt = state.sub_agent_prompt
87
+ child_session.sub_agent_state.resume = resume_session_id
88
+ child_session.sub_agent_state.output_schema = state.output_schema
89
+ child_session.sub_agent_state.generation = state.generation
90
+ else:
91
+ # Create a new child session under the same workdir
92
+ child_session = Session(work_dir=parent_session.work_dir)
93
+ child_session.sub_agent_state = state
94
+
95
+ # Expose the new session id immediately so ToolExecutor.cancel() can attach
96
+ # it to the synthesized cancellation ToolResult.
97
+ record_sub_agent_session_id(child_session.id)
46
98
 
47
99
  child_profile = self._model_profile_provider.build_profile(
48
100
  self._llm_clients.get_client(state.sub_agent_type),
@@ -79,18 +131,30 @@ Only the content passed to `report_back` will be returned to user.\
79
131
  # Not emit the subtask's user input since task tool call is already rendered
80
132
  result: str = ""
81
133
  task_metadata: model.TaskMetadata | None = None
82
- sub_agent_input = model.UserInputPayload(text=state.sub_agent_prompt, images=None)
134
+ sub_agent_input = message.UserInputPayload(text=state.sub_agent_prompt, images=None)
83
135
  child_session.append_history(
84
- [model.UserMessageItem(content=sub_agent_input.text, images=sub_agent_input.images)]
136
+ [
137
+ message.UserMessage(
138
+ parts=message.parts_from_text_and_images(sub_agent_input.text, sub_agent_input.images)
139
+ )
140
+ ]
85
141
  )
86
142
  async for event in child_agent.run_task(sub_agent_input):
87
143
  # Capture TaskFinishEvent content for return
88
144
  if isinstance(event, events.TaskFinishEvent):
89
- result = event.task_result
145
+ result = _append_agent_id(event.task_result, child_session.id)
146
+ event = events.TaskFinishEvent(
147
+ session_id=event.session_id,
148
+ task_result=result,
149
+ has_structured_output=event.has_structured_output,
150
+ )
90
151
  # Capture TaskMetadataEvent for metadata propagation
91
152
  elif isinstance(event, events.TaskMetadataEvent):
92
153
  task_metadata = event.metadata.main_agent
93
154
  await self.emit_event(event)
155
+
156
+ # Ensure the sub-agent session is persisted before returning its id for resume.
157
+ await child_session.wait_for_flush()
94
158
  return SubAgentResult(
95
159
  task_result=result,
96
160
  session_id=child_session.id,
@@ -0,0 +1 @@
1
+ You are an image generation agent. Generate images based on the user's prompt.
@@ -1,5 +1,11 @@
1
1
  You are a web research subagent that searches and fetches web content to provide up-to-date information as part of team.
2
2
 
3
+ ## Core Principles
4
+
5
+ - **Never invent facts**. If you cannot verify something, say so clearly and explain what you did find.
6
+ - If evidence is thin, keep searching rather than guessing.
7
+ - When sources conflict, actively resolve contradictions by finding additional authoritative sources.
8
+
3
9
  ## Available Tools
4
10
 
5
11
  **WebSearch**: Search the web via DuckDuckGo
@@ -32,6 +38,19 @@ Balance efficiency with thoroughness. For open-ended questions (e.g., "recommend
32
38
  - Follow relevant links on pages with WebFetch
33
39
  - If truncated results are saved to local files, use grep/read to explore
34
40
 
41
+ ### Research Strategy
42
+
43
+ - Start with multiple targeted searches. Use parallel searches when helpful. Never rely on a single query.
44
+ - Begin broad enough to capture the main answer, then add targeted follow-ups to fill gaps or confirm claims.
45
+ - If the topic is time-sensitive, explicitly check for recent updates.
46
+ - If the query implies comparisons or recommendations, gather enough coverage to make tradeoffs clear.
47
+ - Keep iterating until additional searching is unlikely to materially change the answer.
48
+
49
+ ### Handling Ambiguity
50
+
51
+ - Do not ask clarifying questions - you cannot interact with the user.
52
+ - If the query is ambiguous, state your interpretation plainly, then comprehensively cover all plausible intents.
53
+
35
54
  ## Response Guidelines
36
55
 
37
56
  - Only your last message is returned to the main agent
@@ -40,7 +59,14 @@ Balance efficiency with thoroughness. For open-ended questions (e.g., "recommend
40
59
  - Include the file path from `<file_saved>` so the main agent can access full content if needed
41
60
  - Lead with the most recent info for evolving topics
42
61
  - Favor original sources (company blogs, papers, gov sites) over aggregators
43
- - Note conflicting sources when they exist
62
+ - When sources conflict, explain the discrepancy and which source is more authoritative
63
+
64
+ ### Before Finalizing
65
+
66
+ Stop only when all are true:
67
+ 1. You answered the query and every subpart
68
+ 2. You found sufficient sources for core claims
69
+ 3. You resolved any contradictions between sources
44
70
 
45
71
  ## Sources (REQUIRED)
46
72
 
@@ -7,14 +7,14 @@ from pathlib import Path
7
7
 
8
8
  from pydantic import BaseModel
9
9
 
10
- from klaude_code import const
10
+ from klaude_code.const import MEMORY_FILE_NAMES, REMINDER_COOLDOWN_TURNS, TODO_REMINDER_TOOL_CALL_THRESHOLD
11
11
  from klaude_code.core.tool import BashTool, ReadTool, reset_tool_context, set_tool_context_from_session
12
12
  from klaude_code.core.tool.file._utils import hash_text_sha256
13
- from klaude_code.protocol import model, tools
13
+ from klaude_code.protocol import message, model, tools
14
14
  from klaude_code.session import Session
15
15
  from klaude_code.skill import get_skill
16
16
 
17
- type Reminder = Callable[[Session], Awaitable[model.DeveloperMessageItem | None]]
17
+ type Reminder = Callable[[Session], Awaitable[message.DeveloperMessage | None]]
18
18
 
19
19
 
20
20
  # Match @ preceded by whitespace, start of line, or → (ReadTool line number arrow)
@@ -28,13 +28,13 @@ def get_last_new_user_input(session: Session) -> str | None:
28
28
  """Get last user input & developer message (CLAUDE.md) from conversation history. if there's a tool result after user input, return None"""
29
29
  result: list[str] = []
30
30
  for item in reversed(session.conversation_history):
31
- if isinstance(item, model.ToolResultItem):
31
+ if isinstance(item, message.ToolResultMessage):
32
32
  return None
33
- if isinstance(item, model.UserMessageItem):
34
- result.append(item.content or "")
33
+ if isinstance(item, message.UserMessage):
34
+ result.append(message.join_text_parts(item.parts))
35
35
  break
36
- if isinstance(item, model.DeveloperMessageItem):
37
- result.append(item.content or "")
36
+ if isinstance(item, message.DeveloperMessage):
37
+ result.append(message.join_text_parts(item.parts))
38
38
  return "\n\n".join(result)
39
39
 
40
40
 
@@ -62,16 +62,16 @@ def get_at_patterns_with_source(session: Session) -> list[AtPatternSource]:
62
62
  patterns: list[AtPatternSource] = []
63
63
 
64
64
  for item in reversed(session.conversation_history):
65
- if isinstance(item, model.ToolResultItem):
65
+ if isinstance(item, message.ToolResultMessage):
66
66
  break
67
67
 
68
- if isinstance(item, model.UserMessageItem):
69
- content = item.content or ""
68
+ if isinstance(item, message.UserMessage):
69
+ content = message.join_text_parts(item.parts)
70
70
  for path_str in _extract_at_patterns(content):
71
71
  patterns.append(AtPatternSource(pattern=path_str, mentioned_in=None))
72
72
  break
73
73
 
74
- if isinstance(item, model.DeveloperMessageItem) and item.memory_mentioned:
74
+ if isinstance(item, message.DeveloperMessage) and item.memory_mentioned:
75
75
  for memory_path, mentioned_patterns in item.memory_mentioned.items():
76
76
  for pattern in mentioned_patterns:
77
77
  patterns.append(AtPatternSource(pattern=pattern, mentioned_in=memory_path))
@@ -81,10 +81,10 @@ def get_at_patterns_with_source(session: Session) -> list[AtPatternSource]:
81
81
  def get_skill_from_user_input(session: Session) -> str | None:
82
82
  """Get $skill reference from the first line of last user input."""
83
83
  for item in reversed(session.conversation_history):
84
- if isinstance(item, model.ToolResultItem):
84
+ if isinstance(item, message.ToolResultMessage):
85
85
  return None
86
- if isinstance(item, model.UserMessageItem):
87
- content = item.content or ""
86
+ if isinstance(item, message.UserMessage):
87
+ content = message.join_text_parts(item.parts)
88
88
  first_line = content.split("\n", 1)[0]
89
89
  m = SKILL_PATTERN.match(first_line)
90
90
  if m:
@@ -114,7 +114,7 @@ async def _load_at_file_recursive(
114
114
  session: Session,
115
115
  pattern: str,
116
116
  at_files: dict[str, model.AtPatternParseResult],
117
- collected_images: list[model.ImageURLPart],
117
+ collected_images: list[message.ImageURLPart],
118
118
  visited: set[str],
119
119
  base_dir: Path | None = None,
120
120
  mentioned_in: str | None = None,
@@ -134,20 +134,20 @@ async def _load_at_file_recursive(
134
134
  return
135
135
  args = ReadTool.ReadArguments(file_path=path_str)
136
136
  tool_result = await ReadTool.call_with_args(args)
137
+ images = [part for part in tool_result.parts if isinstance(part, message.ImageURLPart)]
137
138
  at_files[path_str] = model.AtPatternParseResult(
138
139
  path=path_str,
139
140
  tool_name=tools.READ,
140
- result=tool_result.output or "",
141
+ result=tool_result.output_text,
141
142
  tool_args=args.model_dump_json(exclude_none=True),
142
143
  operation="Read",
143
- images=tool_result.images,
144
144
  mentioned_in=mentioned_in,
145
145
  )
146
- if tool_result.images:
147
- collected_images.extend(tool_result.images)
146
+ if images:
147
+ collected_images.extend(images)
148
148
 
149
149
  # Recursively parse @ references from ReadTool output
150
- output = tool_result.output or ""
150
+ output = tool_result.output_text
151
151
  if "@" in output:
152
152
  for match in AT_FILE_PATTERN.finditer(output):
153
153
  nested = match.group("quoted") or match.group("plain")
@@ -168,7 +168,7 @@ async def _load_at_file_recursive(
168
168
  at_files[path_str] = model.AtPatternParseResult(
169
169
  path=path_str + "/",
170
170
  tool_name=tools.BASH,
171
- result=tool_result.output or "",
171
+ result=tool_result.output_text,
172
172
  tool_args=args.model_dump_json(exclude_none=True),
173
173
  operation="List",
174
174
  )
@@ -178,14 +178,14 @@ async def _load_at_file_recursive(
178
178
 
179
179
  async def at_file_reader_reminder(
180
180
  session: Session,
181
- ) -> model.DeveloperMessageItem | None:
181
+ ) -> message.DeveloperMessage | None:
182
182
  """Parse @foo/bar to read, with recursive loading of nested @ references"""
183
183
  at_pattern_sources = get_at_patterns_with_source(session)
184
184
  if not at_pattern_sources:
185
185
  return None
186
186
 
187
187
  at_files: dict[str, model.AtPatternParseResult] = {} # path -> content
188
- collected_images: list[model.ImageURLPart] = []
188
+ collected_images: list[message.ImageURLPart] = []
189
189
  visited: set[str] = set()
190
190
 
191
191
  for source in at_pattern_sources:
@@ -210,14 +210,16 @@ Result of calling the {result.tool_name} tool:
210
210
  for result in at_files.values()
211
211
  ]
212
212
  )
213
- return model.DeveloperMessageItem(
214
- content=f"""<system-reminder>{at_files_str}\n</system-reminder>""",
213
+ return message.DeveloperMessage(
214
+ parts=message.parts_from_text_and_images(
215
+ f"""<system-reminder>{at_files_str}\n</system-reminder>""",
216
+ collected_images or None,
217
+ ),
215
218
  at_files=list(at_files.values()),
216
- images=collected_images or None,
217
219
  )
218
220
 
219
221
 
220
- async def empty_todo_reminder(session: Session) -> model.DeveloperMessageItem | None:
222
+ async def empty_todo_reminder(session: Session) -> message.DeveloperMessage | None:
221
223
  """Remind agent to use TodoWrite tool when todos are empty/all completed.
222
224
 
223
225
  Behavior:
@@ -233,9 +235,11 @@ async def empty_todo_reminder(session: Session) -> model.DeveloperMessageItem |
233
235
  return None
234
236
 
235
237
  if session.need_todo_empty_cooldown_counter == 0:
236
- session.need_todo_empty_cooldown_counter = 3
237
- return model.DeveloperMessageItem(
238
- 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>"""
238
+ session.need_todo_empty_cooldown_counter = REMINDER_COOLDOWN_TURNS
239
+ return message.DeveloperMessage(
240
+ parts=message.text_parts_from_str(
241
+ "<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>"
242
+ )
239
243
  )
240
244
 
241
245
  if session.need_todo_empty_cooldown_counter > 0:
@@ -245,7 +249,7 @@ async def empty_todo_reminder(session: Session) -> model.DeveloperMessageItem |
245
249
 
246
250
  async def todo_not_used_recently_reminder(
247
251
  session: Session,
248
- ) -> model.DeveloperMessageItem | None:
252
+ ) -> message.DeveloperMessage | None:
249
253
  """Remind agent to use TodoWrite tool if it hasn't been used recently (>=10 other tool calls), with cooldown.
250
254
 
251
255
  Cooldown behavior:
@@ -264,28 +268,37 @@ async def todo_not_used_recently_reminder(
264
268
  # Count non-todo tool calls since the last TodoWrite
265
269
  other_tool_call_count_before_last_todo = 0
266
270
  for item in reversed(session.conversation_history):
267
- if isinstance(item, model.ToolCallItem):
268
- if item.name in (tools.TODO_WRITE, tools.UPDATE_PLAN):
271
+ if not isinstance(item, message.AssistantMessage):
272
+ continue
273
+ for part in reversed(item.parts):
274
+ if not isinstance(part, message.ToolCallPart):
275
+ continue
276
+ if part.tool_name in (tools.TODO_WRITE, tools.UPDATE_PLAN):
277
+ other_tool_call_count_before_last_todo = 0
269
278
  break
270
279
  other_tool_call_count_before_last_todo += 1
271
- if other_tool_call_count_before_last_todo >= const.TODO_REMINDER_TOOL_CALL_THRESHOLD:
280
+ if other_tool_call_count_before_last_todo >= TODO_REMINDER_TOOL_CALL_THRESHOLD:
272
281
  break
282
+ if other_tool_call_count_before_last_todo == 0:
283
+ break
273
284
 
274
- not_used_recently = other_tool_call_count_before_last_todo >= const.TODO_REMINDER_TOOL_CALL_THRESHOLD
285
+ not_used_recently = other_tool_call_count_before_last_todo >= TODO_REMINDER_TOOL_CALL_THRESHOLD
275
286
 
276
287
  if not not_used_recently:
277
288
  return None
278
289
 
279
290
  if session.need_todo_not_used_cooldown_counter == 0:
280
- session.need_todo_not_used_cooldown_counter = 3
281
- return model.DeveloperMessageItem(
282
- content=f"""<system-reminder>
291
+ session.need_todo_not_used_cooldown_counter = REMINDER_COOLDOWN_TURNS
292
+ return message.DeveloperMessage(
293
+ parts=message.text_parts_from_str(
294
+ f"""<system-reminder>
283
295
  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.
284
296
 
285
297
 
286
298
  Here are the existing contents of your todo list:
287
299
 
288
- {model.todo_list_str(session.todos)}</system-reminder>""",
300
+ {model.todo_list_str(session.todos)}</system-reminder>"""
301
+ ),
289
302
  todo_use=True,
290
303
  )
291
304
 
@@ -296,10 +309,10 @@ Here are the existing contents of your todo list:
296
309
 
297
310
  async def file_changed_externally_reminder(
298
311
  session: Session,
299
- ) -> model.DeveloperMessageItem | None:
312
+ ) -> message.DeveloperMessage | None:
300
313
  """Remind agent about user/linter' changes to the files in FileTracker, provding the newest content of the file."""
301
- changed_files: list[tuple[str, str, list[model.ImageURLPart] | None]] = []
302
- collected_images: list[model.ImageURLPart] = []
314
+ changed_files: list[tuple[str, str, list[message.ImageURLPart] | None]] = []
315
+ collected_images: list[message.ImageURLPart] = []
303
316
  if session.file_tracker and len(session.file_tracker) > 0:
304
317
  for path, status in session.file_tracker.items():
305
318
  try:
@@ -320,9 +333,10 @@ async def file_changed_externally_reminder(
320
333
  ReadTool.ReadArguments(file_path=path)
321
334
  ) # This tool will update file tracker
322
335
  if tool_result.status == "success":
323
- changed_files.append((path, tool_result.output or "", tool_result.images))
324
- if tool_result.images:
325
- collected_images.extend(tool_result.images)
336
+ images = [part for part in tool_result.parts if isinstance(part, message.ImageURLPart)]
337
+ changed_files.append((path, tool_result.output_text, images or None))
338
+ if images:
339
+ collected_images.extend(images)
326
340
  finally:
327
341
  reset_tool_context(context_token)
328
342
  except (
@@ -341,10 +355,12 @@ async def file_changed_externally_reminder(
341
355
  for file_path, file_content, _ in changed_files
342
356
  ]
343
357
  )
344
- return model.DeveloperMessageItem(
345
- content=f"""<system-reminder>{changed_files_str}""",
358
+ return message.DeveloperMessage(
359
+ parts=message.parts_from_text_and_images(
360
+ f"""<system-reminder>{changed_files_str}""",
361
+ collected_images or None,
362
+ ),
346
363
  external_file_changes=[file_path for file_path, _, _ in changed_files],
347
- images=collected_images or None,
348
364
  )
349
365
 
350
366
  return None
@@ -393,26 +409,28 @@ class Memory(BaseModel):
393
409
  def get_last_user_message_image_count(session: Session) -> int:
394
410
  """Get image count from the last user message in conversation history."""
395
411
  for item in reversed(session.conversation_history):
396
- if isinstance(item, model.ToolResultItem):
412
+ if isinstance(item, message.ToolResultMessage):
397
413
  return 0
398
- if isinstance(item, model.UserMessageItem):
399
- return len(item.images) if item.images else 0
414
+ if isinstance(item, message.UserMessage):
415
+ return len([part for part in item.parts if isinstance(part, message.ImageURLPart)])
400
416
  return 0
401
417
 
402
418
 
403
- async def image_reminder(session: Session) -> model.DeveloperMessageItem | None:
419
+ async def image_reminder(session: Session) -> message.DeveloperMessage | None:
404
420
  """Remind agent about images attached by user in the last message."""
405
421
  image_count = get_last_user_message_image_count(session)
406
422
  if image_count == 0:
407
423
  return None
408
424
 
409
- return model.DeveloperMessageItem(
410
- 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>",
425
+ return message.DeveloperMessage(
426
+ parts=message.text_parts_from_str(
427
+ 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>"
428
+ ),
411
429
  user_image_count=image_count,
412
430
  )
413
431
 
414
432
 
415
- async def skill_reminder(session: Session) -> model.DeveloperMessageItem | None:
433
+ async def skill_reminder(session: Session) -> message.DeveloperMessage | None:
416
434
  """Load skill content when user references a skill with $skill syntax."""
417
435
  skill_name = get_skill_from_user_input(session)
418
436
  if not skill_name:
@@ -436,8 +454,8 @@ async def skill_reminder(session: Session) -> model.DeveloperMessageItem | None:
436
454
  </skill>
437
455
  </system-reminder>"""
438
456
 
439
- return model.DeveloperMessageItem(
440
- content=content,
457
+ return message.DeveloperMessage(
458
+ parts=message.text_parts_from_str(content),
441
459
  skill_name=skill.name,
442
460
  )
443
461
 
@@ -461,7 +479,7 @@ def _mark_memory_loaded(session: Session, path: str) -> None:
461
479
  session.file_tracker[path] = model.FileStatus(mtime=mtime, content_sha256=content_sha256, is_memory=True)
462
480
 
463
481
 
464
- async def memory_reminder(session: Session) -> model.DeveloperMessageItem | None:
482
+ async def memory_reminder(session: Session) -> message.DeveloperMessage | None:
465
483
  """CLAUDE.md AGENTS.md"""
466
484
  memory_paths = get_memory_paths()
467
485
  memories: list[Memory] = []
@@ -485,8 +503,9 @@ async def memory_reminder(session: Session) -> model.DeveloperMessageItem | None
485
503
  if patterns:
486
504
  memory_mentioned[memory.path] = patterns
487
505
 
488
- return model.DeveloperMessageItem(
489
- content=f"""<system-reminder>As you answer the user's questions, you can use the following context:
506
+ return message.DeveloperMessage(
507
+ parts=message.text_parts_from_str(
508
+ f"""<system-reminder>As you answer the user's questions, you can use the following context:
490
509
 
491
510
  # claudeMd
492
511
  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.
@@ -499,19 +518,17 @@ ALWAYS prefer editing an existing file to creating a new one.
499
518
  NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
500
519
 
501
520
  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.
502
- </system-reminder>""",
521
+ </system-reminder>"""
522
+ ),
503
523
  memory_paths=[memory.path for memory in memories],
504
524
  memory_mentioned=memory_mentioned or None,
505
525
  )
506
526
  return None
507
527
 
508
528
 
509
- MEMORY_FILE_NAMES = ["CLAUDE.md", "AGENTS.md", "AGENT.md"]
510
-
511
-
512
529
  async def last_path_memory_reminder(
513
530
  session: Session,
514
- ) -> model.DeveloperMessageItem | None:
531
+ ) -> message.DeveloperMessage | None:
515
532
  """Load CLAUDE.md/AGENTS.md from directories containing files in file_tracker.
516
533
 
517
534
  Uses session.file_tracker to detect accessed paths (works for both tool calls
@@ -579,9 +596,11 @@ async def last_path_memory_reminder(
579
596
  if patterns:
580
597
  memory_mentioned[memory.path] = patterns
581
598
 
582
- return model.DeveloperMessageItem(
583
- content=f"""<system-reminder>{memories_str}
584
- </system-reminder>""",
599
+ return message.DeveloperMessage(
600
+ parts=message.text_parts_from_str(
601
+ f"""<system-reminder>{memories_str}
602
+ </system-reminder>"""
603
+ ),
585
604
  memory_paths=[memory.path for memory in memories],
586
605
  memory_mentioned=memory_mentioned or None,
587
606
  )