agentpool 2.1.9__py3-none-any.whl → 2.2.3__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 (174) hide show
  1. acp/__init__.py +13 -0
  2. acp/bridge/README.md +15 -2
  3. acp/bridge/__init__.py +3 -2
  4. acp/bridge/__main__.py +60 -19
  5. acp/bridge/ws_server.py +173 -0
  6. acp/bridge/ws_server_cli.py +89 -0
  7. acp/notifications.py +2 -1
  8. acp/stdio.py +39 -9
  9. acp/transports.py +362 -2
  10. acp/utils.py +15 -2
  11. agentpool/__init__.py +4 -1
  12. agentpool/agents/__init__.py +2 -0
  13. agentpool/agents/acp_agent/acp_agent.py +203 -88
  14. agentpool/agents/acp_agent/acp_converters.py +46 -21
  15. agentpool/agents/acp_agent/client_handler.py +157 -3
  16. agentpool/agents/acp_agent/session_state.py +4 -1
  17. agentpool/agents/agent.py +314 -107
  18. agentpool/agents/agui_agent/__init__.py +0 -2
  19. agentpool/agents/agui_agent/agui_agent.py +90 -21
  20. agentpool/agents/agui_agent/agui_converters.py +0 -131
  21. agentpool/agents/base_agent.py +163 -1
  22. agentpool/agents/claude_code_agent/claude_code_agent.py +626 -179
  23. agentpool/agents/claude_code_agent/converters.py +71 -3
  24. agentpool/agents/claude_code_agent/history.py +474 -0
  25. agentpool/agents/context.py +40 -0
  26. agentpool/agents/events/__init__.py +2 -0
  27. agentpool/agents/events/builtin_handlers.py +2 -1
  28. agentpool/agents/events/event_emitter.py +29 -2
  29. agentpool/agents/events/events.py +20 -0
  30. agentpool/agents/modes.py +54 -0
  31. agentpool/agents/tool_call_accumulator.py +213 -0
  32. agentpool/common_types.py +21 -0
  33. agentpool/config_resources/__init__.py +38 -1
  34. agentpool/config_resources/claude_code_agent.yml +3 -0
  35. agentpool/delegation/pool.py +37 -29
  36. agentpool/delegation/team.py +1 -0
  37. agentpool/delegation/teamrun.py +1 -0
  38. agentpool/diagnostics/__init__.py +53 -0
  39. agentpool/diagnostics/lsp_manager.py +1593 -0
  40. agentpool/diagnostics/lsp_proxy.py +41 -0
  41. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  42. agentpool/diagnostics/models.py +398 -0
  43. agentpool/mcp_server/__init__.py +0 -2
  44. agentpool/mcp_server/client.py +12 -3
  45. agentpool/mcp_server/manager.py +25 -31
  46. agentpool/mcp_server/registries/official_registry_client.py +25 -0
  47. agentpool/mcp_server/tool_bridge.py +78 -66
  48. agentpool/messaging/__init__.py +0 -2
  49. agentpool/messaging/compaction.py +72 -197
  50. agentpool/messaging/message_history.py +12 -0
  51. agentpool/messaging/messages.py +52 -9
  52. agentpool/messaging/processing.py +3 -1
  53. agentpool/models/acp_agents/base.py +0 -22
  54. agentpool/models/acp_agents/mcp_capable.py +8 -148
  55. agentpool/models/acp_agents/non_mcp.py +129 -72
  56. agentpool/models/agents.py +35 -13
  57. agentpool/models/claude_code_agents.py +33 -2
  58. agentpool/models/manifest.py +43 -0
  59. agentpool/repomap.py +1 -1
  60. agentpool/resource_providers/__init__.py +9 -1
  61. agentpool/resource_providers/aggregating.py +52 -3
  62. agentpool/resource_providers/base.py +57 -1
  63. agentpool/resource_providers/mcp_provider.py +23 -0
  64. agentpool/resource_providers/plan_provider.py +130 -41
  65. agentpool/resource_providers/pool.py +2 -0
  66. agentpool/resource_providers/static.py +2 -0
  67. agentpool/sessions/__init__.py +2 -1
  68. agentpool/sessions/manager.py +31 -2
  69. agentpool/sessions/models.py +50 -0
  70. agentpool/skills/registry.py +13 -8
  71. agentpool/storage/manager.py +217 -1
  72. agentpool/testing.py +537 -19
  73. agentpool/utils/file_watcher.py +269 -0
  74. agentpool/utils/identifiers.py +121 -0
  75. agentpool/utils/pydantic_ai_helpers.py +46 -0
  76. agentpool/utils/streams.py +690 -1
  77. agentpool/utils/subprocess_utils.py +155 -0
  78. agentpool/utils/token_breakdown.py +461 -0
  79. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/METADATA +27 -7
  80. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/RECORD +170 -112
  81. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/WHEEL +1 -1
  82. agentpool_cli/__main__.py +4 -0
  83. agentpool_cli/serve_acp.py +41 -20
  84. agentpool_cli/serve_agui.py +87 -0
  85. agentpool_cli/serve_opencode.py +119 -0
  86. agentpool_commands/__init__.py +30 -0
  87. agentpool_commands/agents.py +74 -1
  88. agentpool_commands/history.py +62 -0
  89. agentpool_commands/mcp.py +176 -0
  90. agentpool_commands/models.py +56 -3
  91. agentpool_commands/tools.py +57 -0
  92. agentpool_commands/utils.py +51 -0
  93. agentpool_config/builtin_tools.py +77 -22
  94. agentpool_config/commands.py +24 -1
  95. agentpool_config/compaction.py +258 -0
  96. agentpool_config/mcp_server.py +131 -1
  97. agentpool_config/storage.py +46 -1
  98. agentpool_config/tools.py +7 -1
  99. agentpool_config/toolsets.py +92 -148
  100. agentpool_server/acp_server/acp_agent.py +134 -150
  101. agentpool_server/acp_server/commands/acp_commands.py +216 -51
  102. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +10 -10
  103. agentpool_server/acp_server/server.py +23 -79
  104. agentpool_server/acp_server/session.py +181 -19
  105. agentpool_server/opencode_server/.rules +95 -0
  106. agentpool_server/opencode_server/ENDPOINTS.md +362 -0
  107. agentpool_server/opencode_server/__init__.py +27 -0
  108. agentpool_server/opencode_server/command_validation.py +172 -0
  109. agentpool_server/opencode_server/converters.py +869 -0
  110. agentpool_server/opencode_server/dependencies.py +24 -0
  111. agentpool_server/opencode_server/input_provider.py +269 -0
  112. agentpool_server/opencode_server/models/__init__.py +228 -0
  113. agentpool_server/opencode_server/models/agent.py +53 -0
  114. agentpool_server/opencode_server/models/app.py +60 -0
  115. agentpool_server/opencode_server/models/base.py +26 -0
  116. agentpool_server/opencode_server/models/common.py +23 -0
  117. agentpool_server/opencode_server/models/config.py +37 -0
  118. agentpool_server/opencode_server/models/events.py +647 -0
  119. agentpool_server/opencode_server/models/file.py +88 -0
  120. agentpool_server/opencode_server/models/mcp.py +25 -0
  121. agentpool_server/opencode_server/models/message.py +162 -0
  122. agentpool_server/opencode_server/models/parts.py +190 -0
  123. agentpool_server/opencode_server/models/provider.py +81 -0
  124. agentpool_server/opencode_server/models/pty.py +43 -0
  125. agentpool_server/opencode_server/models/session.py +99 -0
  126. agentpool_server/opencode_server/routes/__init__.py +25 -0
  127. agentpool_server/opencode_server/routes/agent_routes.py +442 -0
  128. agentpool_server/opencode_server/routes/app_routes.py +139 -0
  129. agentpool_server/opencode_server/routes/config_routes.py +241 -0
  130. agentpool_server/opencode_server/routes/file_routes.py +392 -0
  131. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  132. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  133. agentpool_server/opencode_server/routes/message_routes.py +705 -0
  134. agentpool_server/opencode_server/routes/pty_routes.py +299 -0
  135. agentpool_server/opencode_server/routes/session_routes.py +1205 -0
  136. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  137. agentpool_server/opencode_server/server.py +430 -0
  138. agentpool_server/opencode_server/state.py +121 -0
  139. agentpool_server/opencode_server/time_utils.py +8 -0
  140. agentpool_storage/__init__.py +16 -0
  141. agentpool_storage/base.py +103 -0
  142. agentpool_storage/claude_provider.py +907 -0
  143. agentpool_storage/file_provider.py +129 -0
  144. agentpool_storage/memory_provider.py +61 -0
  145. agentpool_storage/models.py +3 -0
  146. agentpool_storage/opencode_provider.py +730 -0
  147. agentpool_storage/project_store.py +325 -0
  148. agentpool_storage/session_store.py +6 -0
  149. agentpool_storage/sql_provider/__init__.py +4 -2
  150. agentpool_storage/sql_provider/models.py +48 -0
  151. agentpool_storage/sql_provider/sql_provider.py +134 -1
  152. agentpool_storage/sql_provider/utils.py +10 -1
  153. agentpool_storage/text_log_provider.py +1 -0
  154. agentpool_toolsets/builtin/__init__.py +0 -8
  155. agentpool_toolsets/builtin/code.py +95 -56
  156. agentpool_toolsets/builtin/debug.py +16 -21
  157. agentpool_toolsets/builtin/execution_environment.py +99 -103
  158. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  159. agentpool_toolsets/builtin/skills.py +86 -4
  160. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  161. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  162. agentpool_toolsets/fsspec_toolset/grep.py +74 -2
  163. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  164. agentpool_toolsets/fsspec_toolset/toolset.py +159 -38
  165. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  166. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  167. agentpool_toolsets/mcp_discovery/toolset.py +454 -0
  168. agentpool_toolsets/mcp_run_toolset.py +84 -6
  169. agentpool_toolsets/builtin/agent_management.py +0 -239
  170. agentpool_toolsets/builtin/history.py +0 -36
  171. agentpool_toolsets/builtin/integration.py +0 -85
  172. agentpool_toolsets/builtin/tool_management.py +0 -90
  173. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/entry_points.txt +0 -0
  174. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,869 @@
1
+ """Converters between pydantic-ai/AgentPool and OpenCode message formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+ import uuid
7
+
8
+ from pydantic_ai import (
9
+ TextPart as PydanticTextPart,
10
+ ToolCallPart as PydanticToolCallPart,
11
+ )
12
+ from pydantic_ai.messages import (
13
+ ModelRequest,
14
+ ModelResponse,
15
+ ToolReturnPart as PydanticToolReturnPart,
16
+ UserPromptPart,
17
+ )
18
+
19
+ from agentpool_server.opencode_server.models import (
20
+ AssistantMessage,
21
+ MessagePath,
22
+ MessageTime,
23
+ MessageWithParts,
24
+ TextPart,
25
+ TimeStart,
26
+ TimeStartEnd,
27
+ TimeStartEndCompacted,
28
+ Tokens,
29
+ TokensCache,
30
+ ToolPart,
31
+ ToolStateCompleted,
32
+ ToolStateError,
33
+ ToolStatePending,
34
+ ToolStateRunning,
35
+ UserMessage,
36
+ )
37
+ from agentpool_server.opencode_server.models.common import TimeCreated
38
+ from agentpool_server.opencode_server.models.message import UserMessageModel
39
+ from agentpool_server.opencode_server.models.parts import (
40
+ StepFinishPart,
41
+ StepFinishTokens,
42
+ StepStartPart,
43
+ TimeStartEndOptional,
44
+ TokenCache,
45
+ )
46
+ from agentpool_server.opencode_server.time_utils import now_ms
47
+
48
+
49
+ if TYPE_CHECKING:
50
+ from collections.abc import Sequence
51
+
52
+ from pydantic_ai import (
53
+ UserContent,
54
+ )
55
+
56
+ from agentpool.agents.events import (
57
+ ToolCallCompleteEvent,
58
+ ToolCallProgressEvent,
59
+ ToolCallStartEvent,
60
+ )
61
+ from agentpool.messaging.messages import ChatMessage
62
+ from agentpool_server.opencode_server.models import Part
63
+
64
+
65
+ # Parameter name mapping from snake_case to camelCase for OpenCode TUI compatibility
66
+ _PARAM_NAME_MAP: dict[str, str] = {
67
+ "path": "filePath",
68
+ "file_path": "filePath",
69
+ "old_string": "oldString",
70
+ "new_string": "newString",
71
+ "replace_all": "replaceAll",
72
+ "line_hint": "lineHint",
73
+ }
74
+
75
+
76
+ def _convert_params_for_ui(params: dict[str, Any]) -> dict[str, Any]:
77
+ """Convert parameter names from snake_case to camelCase for OpenCode TUI.
78
+
79
+ OpenCode TUI expects camelCase parameter names like 'filePath', 'oldString', etc.
80
+ This converts our snake_case parameters to match those expectations.
81
+ """
82
+ return {_PARAM_NAME_MAP.get(k, k): v for k, v in params.items()}
83
+
84
+
85
+ def generate_part_id() -> str:
86
+ """Generate a unique part ID."""
87
+ return str(uuid.uuid4())
88
+
89
+
90
+ # =============================================================================
91
+ # Pydantic-AI to OpenCode Converters
92
+ # =============================================================================
93
+
94
+
95
+ def convert_pydantic_text_part(
96
+ part: PydanticTextPart,
97
+ session_id: str,
98
+ message_id: str,
99
+ ) -> TextPart:
100
+ """Convert a pydantic-ai TextPart to OpenCode TextPart."""
101
+ return TextPart(
102
+ id=part.id or generate_part_id(),
103
+ session_id=session_id,
104
+ message_id=message_id,
105
+ text=part.content,
106
+ )
107
+
108
+
109
+ def convert_pydantic_tool_call_part(
110
+ part: PydanticToolCallPart,
111
+ session_id: str,
112
+ message_id: str,
113
+ ) -> ToolPart:
114
+ """Convert a pydantic-ai ToolCallPart to OpenCode ToolPart (pending state)."""
115
+ # Tool call started - create pending state
116
+ return ToolPart(
117
+ id=generate_part_id(),
118
+ session_id=session_id,
119
+ message_id=message_id,
120
+ tool=part.tool_name,
121
+ call_id=part.tool_call_id,
122
+ state=ToolStatePending(status="pending"),
123
+ )
124
+
125
+
126
+ def _get_input_from_state(
127
+ state: ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError,
128
+ *,
129
+ convert_params: bool = False,
130
+ ) -> dict[str, Any]:
131
+ """Extract input from any tool state type.
132
+
133
+ Args:
134
+ state: Tool state to extract input from
135
+ convert_params: If True, convert param names to camelCase for UI display
136
+ """
137
+ if hasattr(state, "input") and state.input is not None:
138
+ return _convert_params_for_ui(state.input) if convert_params else state.input
139
+ return {}
140
+
141
+
142
+ def convert_pydantic_tool_return_part(
143
+ part: PydanticToolReturnPart,
144
+ session_id: str,
145
+ message_id: str,
146
+ existing_tool_part: ToolPart | None = None,
147
+ ) -> ToolPart:
148
+ """Convert a pydantic-ai ToolReturnPart to OpenCode ToolPart (completed state)."""
149
+ # Determine if it's an error or success based on content
150
+ content = part.content
151
+ is_error = isinstance(content, dict) and content.get("error")
152
+
153
+ existing_input = _get_input_from_state(existing_tool_part.state) if existing_tool_part else {}
154
+
155
+ if is_error:
156
+ state: ToolStateCompleted | ToolStateError = ToolStateError(
157
+ status="error",
158
+ error=str(content.get("error", "Unknown error")),
159
+ input=existing_input,
160
+ time=TimeStartEnd(start=now_ms() - 1000, end=now_ms()),
161
+ )
162
+ else:
163
+ # Format output for display
164
+ if isinstance(content, str):
165
+ output = content
166
+ elif isinstance(content, dict):
167
+ import json
168
+
169
+ output = json.dumps(content, indent=2)
170
+ else:
171
+ output = str(content)
172
+
173
+ state = ToolStateCompleted(
174
+ status="completed",
175
+ title=f"Completed {part.tool_name}",
176
+ input=existing_input,
177
+ output=output,
178
+ time=TimeStartEndCompacted(start=now_ms() - 1000, end=now_ms()),
179
+ )
180
+
181
+ return ToolPart(
182
+ id=existing_tool_part.id if existing_tool_part else generate_part_id(),
183
+ session_id=session_id,
184
+ message_id=message_id,
185
+ tool=part.tool_name,
186
+ call_id=part.tool_call_id,
187
+ state=state,
188
+ )
189
+
190
+
191
+ def convert_model_response_to_parts(
192
+ response: ModelResponse,
193
+ session_id: str,
194
+ message_id: str,
195
+ ) -> list[Part]:
196
+ """Convert a pydantic-ai ModelResponse to OpenCode Parts."""
197
+ parts: list[Part] = []
198
+
199
+ for part in response.parts:
200
+ if isinstance(part, PydanticTextPart):
201
+ parts.append(convert_pydantic_text_part(part, session_id, message_id))
202
+ elif isinstance(part, PydanticToolCallPart):
203
+ parts.append(convert_pydantic_tool_call_part(part, session_id, message_id))
204
+ # Other part types (ThinkingPart, FilePart) can be added as needed
205
+
206
+ return parts
207
+
208
+
209
+ # =============================================================================
210
+ # AgentPool Event to OpenCode State Converters
211
+ # =============================================================================
212
+
213
+
214
+ def convert_tool_start_event(
215
+ event: ToolCallStartEvent,
216
+ session_id: str,
217
+ message_id: str,
218
+ ) -> ToolPart:
219
+ """Convert AgentPool ToolCallStartEvent to OpenCode ToolPart."""
220
+ return ToolPart(
221
+ id=generate_part_id(),
222
+ session_id=session_id,
223
+ message_id=message_id,
224
+ tool=event.tool_name,
225
+ call_id=event.tool_call_id,
226
+ state=ToolStatePending(status="pending"),
227
+ )
228
+
229
+
230
+ def _get_title_from_state(
231
+ state: ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError,
232
+ ) -> str:
233
+ """Extract title from any tool state type."""
234
+ return getattr(state, "title", "")
235
+
236
+
237
+ def convert_tool_progress_event(
238
+ event: ToolCallProgressEvent,
239
+ existing_part: ToolPart,
240
+ ) -> ToolPart:
241
+ """Update ToolPart with progress from AgentPool ToolCallProgressEvent."""
242
+ # ToolStateRunning doesn't have output field, progress is indicated by title
243
+ return ToolPart(
244
+ id=existing_part.id,
245
+ session_id=existing_part.session_id,
246
+ message_id=existing_part.message_id,
247
+ tool=existing_part.tool,
248
+ call_id=existing_part.call_id,
249
+ state=ToolStateRunning(
250
+ status="running",
251
+ time=TimeStart(start=now_ms()),
252
+ title=event.title or _get_title_from_state(existing_part.state),
253
+ input=_get_input_from_state(existing_part.state),
254
+ ),
255
+ )
256
+
257
+
258
+ def convert_tool_complete_event(
259
+ event: ToolCallCompleteEvent,
260
+ existing_part: ToolPart,
261
+ ) -> ToolPart:
262
+ """Update ToolPart with completion from AgentPool ToolCallCompleteEvent."""
263
+ # Format the result
264
+ result = event.tool_result
265
+ if isinstance(result, str):
266
+ output = result
267
+ elif isinstance(result, dict):
268
+ import json
269
+
270
+ output = json.dumps(result, indent=2)
271
+ else:
272
+ output = str(result) if result is not None else ""
273
+
274
+ existing_input = _get_input_from_state(existing_part.state)
275
+
276
+ # ToolCallCompleteEvent doesn't have error field - check result for error indication
277
+ if isinstance(result, dict) and result.get("error"):
278
+ state: ToolStateCompleted | ToolStateError = ToolStateError(
279
+ status="error",
280
+ error=str(result.get("error", "Unknown error")),
281
+ input=existing_input,
282
+ time=TimeStartEnd(start=now_ms() - 1000, end=now_ms()),
283
+ )
284
+ else:
285
+ state = ToolStateCompleted(
286
+ status="completed",
287
+ title=f"Completed {existing_part.tool}",
288
+ input=existing_input,
289
+ output=output,
290
+ time=TimeStartEndCompacted(start=now_ms() - 1000, end=now_ms()),
291
+ )
292
+
293
+ return ToolPart(
294
+ id=existing_part.id,
295
+ session_id=existing_part.session_id,
296
+ message_id=existing_part.message_id,
297
+ tool=existing_part.tool,
298
+ call_id=existing_part.call_id,
299
+ state=state,
300
+ )
301
+
302
+
303
+ # =============================================================================
304
+ # OpenCode to Pydantic-AI Converters (for input)
305
+ # =============================================================================
306
+
307
+
308
+ def _convert_file_part_to_user_content(part: dict[str, Any]) -> Any:
309
+ """Convert an OpenCode FilePartInput to pydantic-ai MultiModalContent.
310
+
311
+ Supports:
312
+ - Images (image/*) -> ImageUrl or BinaryContent
313
+ - Documents (application/pdf, text/*) -> DocumentUrl or BinaryContent
314
+ - Audio (audio/*) -> AudioUrl or BinaryContent
315
+ - Video (video/*) -> VideoUrl or BinaryContent
316
+
317
+ Args:
318
+ part: OpenCode file part with mime, url, and optional filename
319
+
320
+ Returns:
321
+ Appropriate pydantic-ai content type
322
+ """
323
+ from pydantic_ai.messages import (
324
+ AudioUrl,
325
+ BinaryContent,
326
+ DocumentUrl,
327
+ ImageUrl,
328
+ VideoUrl,
329
+ )
330
+
331
+ mime = part.get("mime", "")
332
+ url = part.get("url", "")
333
+
334
+ # Handle data: URIs - convert to BinaryContent
335
+ if url.startswith("data:"):
336
+ return BinaryContent.from_data_uri(url)
337
+
338
+ # Handle regular URLs or file paths based on mime type
339
+ if mime.startswith("image/"):
340
+ return ImageUrl(url=url)
341
+ if mime.startswith("audio/"):
342
+ return AudioUrl(url=url)
343
+ if mime.startswith("video/"):
344
+ return VideoUrl(url=url)
345
+ if mime.startswith(("application/pdf", "text/")):
346
+ return DocumentUrl(url=url)
347
+
348
+ # Fallback: treat as document
349
+ return DocumentUrl(url=url)
350
+
351
+
352
+ def extract_user_prompt_from_parts(
353
+ parts: list[dict[str, Any]],
354
+ ) -> str | Sequence[UserContent]:
355
+ """Extract user prompt from OpenCode message parts.
356
+
357
+ Converts OpenCode parts to pydantic-ai UserContent format:
358
+ - Text parts become strings
359
+ - File parts become ImageUrl, DocumentUrl, AudioUrl, VideoUrl, or BinaryContent
360
+
361
+ Args:
362
+ parts: List of OpenCode message parts
363
+
364
+ Returns:
365
+ Either a simple string (text-only) or a list of UserContent items
366
+ """
367
+ result: list[UserContent] = []
368
+
369
+ for part in parts:
370
+ part_type = part.get("type")
371
+
372
+ if part_type == "text":
373
+ text = part.get("text", "")
374
+ if text:
375
+ result.append(text)
376
+
377
+ elif part_type == "file":
378
+ content = _convert_file_part_to_user_content(part)
379
+ result.append(content)
380
+
381
+ # If only text parts, join them as a single string for simplicity
382
+ if all(isinstance(item, str) for item in result):
383
+ return "\n".join(result) # type: ignore[arg-type]
384
+
385
+ return result
386
+
387
+
388
+ # =============================================================================
389
+ # ChatMessage <-> OpenCode MessageWithParts Converters
390
+ # =============================================================================
391
+
392
+
393
+ def _datetime_to_ms(dt: Any) -> int:
394
+ """Convert datetime to milliseconds timestamp."""
395
+ from datetime import datetime
396
+
397
+ if isinstance(dt, datetime):
398
+ return int(dt.timestamp() * 1000)
399
+ return now_ms()
400
+
401
+
402
+ def _ms_to_datetime(ms: int) -> Any:
403
+ """Convert milliseconds timestamp to datetime."""
404
+ from datetime import UTC, datetime
405
+
406
+ return datetime.fromtimestamp(ms / 1000, tz=UTC)
407
+
408
+
409
+ def chat_message_to_opencode( # noqa: PLR0915
410
+ msg: ChatMessage[Any],
411
+ session_id: str,
412
+ working_dir: str = "",
413
+ agent_name: str = "default",
414
+ model_id: str = "unknown",
415
+ provider_id: str = "agentpool",
416
+ ) -> MessageWithParts:
417
+ """Convert a ChatMessage to OpenCode MessageWithParts.
418
+
419
+ Args:
420
+ msg: The ChatMessage to convert
421
+ session_id: OpenCode session ID
422
+ working_dir: Working directory for path context
423
+ agent_name: Name of the agent
424
+ model_id: Model identifier
425
+ provider_id: Provider identifier
426
+
427
+ Returns:
428
+ OpenCode MessageWithParts with appropriate info and parts
429
+ """
430
+ message_id = msg.message_id
431
+ created_ms = _datetime_to_ms(msg.timestamp)
432
+
433
+ parts: list[Part] = []
434
+
435
+ # Track tool calls by ID for pairing with returns
436
+ tool_calls: dict[str, ToolPart] = {}
437
+
438
+ if msg.role == "user":
439
+ # User message
440
+ info: UserMessage | AssistantMessage = UserMessage(
441
+ id=message_id,
442
+ session_id=session_id,
443
+ time=TimeCreated(created=created_ms),
444
+ agent=agent_name,
445
+ model=UserMessageModel(provider_id=provider_id, model_id=model_id),
446
+ )
447
+
448
+ # Extract text from user message
449
+ # First try msg.content directly (simple case)
450
+ if msg.content and isinstance(msg.content, str):
451
+ parts.append(
452
+ TextPart(
453
+ id=generate_part_id(),
454
+ message_id=message_id,
455
+ session_id=session_id,
456
+ text=msg.content,
457
+ time=TimeStartEndOptional(start=created_ms),
458
+ )
459
+ )
460
+ else:
461
+ # Fall back to extracting from messages (pydantic-ai format)
462
+ for model_msg in msg.messages:
463
+ if isinstance(model_msg, ModelRequest):
464
+ for part in model_msg.parts:
465
+ if isinstance(part, UserPromptPart):
466
+ content = part.content
467
+ if isinstance(content, str):
468
+ text = content
469
+ else:
470
+ # Multi-modal content - extract text parts
471
+ text = " ".join(str(c) for c in content if isinstance(c, str))
472
+ if text:
473
+ parts.append(
474
+ TextPart(
475
+ id=generate_part_id(),
476
+ message_id=message_id,
477
+ session_id=session_id,
478
+ text=text,
479
+ time=TimeStartEndOptional(start=created_ms),
480
+ )
481
+ )
482
+ elif isinstance(model_msg, dict) and model_msg.get("kind") == "request":
483
+ # Handle serialized dict format from storage
484
+ for part in model_msg.get("parts", []):
485
+ if part.get("part_kind") == "user-prompt":
486
+ text = part.get("content", "")
487
+ if text and isinstance(text, str):
488
+ parts.append(
489
+ TextPart(
490
+ id=generate_part_id(),
491
+ message_id=message_id,
492
+ session_id=session_id,
493
+ text=text,
494
+ time=TimeStartEndOptional(start=created_ms),
495
+ )
496
+ )
497
+ else:
498
+ # Assistant message
499
+ completed_ms = created_ms
500
+ if msg.response_time:
501
+ completed_ms = created_ms + int(msg.response_time * 1000)
502
+
503
+ # Extract token usage (handle both object and dict formats)
504
+ usage = msg.usage
505
+ if usage:
506
+ if isinstance(usage, dict):
507
+ input_tokens = usage.get("input_tokens", 0) or 0
508
+ output_tokens = usage.get("output_tokens", 0) or 0
509
+ cache_read = usage.get("cache_read_tokens", 0) or 0
510
+ cache_write = usage.get("cache_write_tokens", 0) or 0
511
+ else:
512
+ input_tokens = usage.input_tokens or 0
513
+ output_tokens = usage.output_tokens or 0
514
+ cache_read = usage.cache_read_tokens or 0
515
+ cache_write = usage.cache_write_tokens or 0
516
+ else:
517
+ input_tokens = output_tokens = cache_read = cache_write = 0
518
+
519
+ tokens = Tokens(
520
+ input=input_tokens,
521
+ output=output_tokens,
522
+ reasoning=0,
523
+ cache=TokensCache(read=cache_read, write=cache_write),
524
+ )
525
+
526
+ info = AssistantMessage(
527
+ id=message_id,
528
+ session_id=session_id,
529
+ parent_id="", # Would need to track parent user message
530
+ model_id=msg.model_name or model_id,
531
+ provider_id=msg.provider_name or provider_id,
532
+ mode="default",
533
+ agent=agent_name,
534
+ path=MessagePath(cwd=working_dir, root=working_dir),
535
+ time=MessageTime(created=created_ms, completed=completed_ms),
536
+ tokens=tokens,
537
+ cost=float(msg.cost_info.total_cost) if msg.cost_info else 0.0,
538
+ finish=msg.finish_reason,
539
+ )
540
+
541
+ # Add step start
542
+ parts.append(
543
+ StepStartPart(
544
+ id=generate_part_id(),
545
+ message_id=message_id,
546
+ session_id=session_id,
547
+ )
548
+ )
549
+
550
+ # Process all model messages to extract parts
551
+ # Deserialize dicts to proper pydantic-ai objects if needed
552
+ from pydantic import TypeAdapter
553
+ from pydantic_ai.messages import ModelMessage
554
+
555
+ model_message_adapter: TypeAdapter[ModelMessage] = TypeAdapter(ModelMessage)
556
+
557
+ for raw_msg in msg.messages:
558
+ # Deserialize dict to proper ModelRequest/ModelResponse if needed
559
+ if isinstance(raw_msg, dict):
560
+ model_msg = model_message_adapter.validate_python(raw_msg)
561
+ else:
562
+ model_msg = raw_msg
563
+
564
+ if isinstance(model_msg, ModelResponse):
565
+ for p in model_msg.parts:
566
+ if isinstance(p, PydanticTextPart):
567
+ parts.append(
568
+ TextPart(
569
+ id=p.id or generate_part_id(),
570
+ message_id=message_id,
571
+ session_id=session_id,
572
+ text=p.content,
573
+ time=TimeStartEndOptional(start=created_ms, end=completed_ms),
574
+ )
575
+ )
576
+ elif isinstance(p, PydanticToolCallPart):
577
+ # Create tool part in pending/running state
578
+ from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict
579
+
580
+ tool_input = _convert_params_for_ui(safe_args_as_dict(p))
581
+ tool_part = ToolPart(
582
+ id=generate_part_id(),
583
+ message_id=message_id,
584
+ session_id=session_id,
585
+ tool=p.tool_name,
586
+ call_id=p.tool_call_id or generate_part_id(),
587
+ state=ToolStateRunning(
588
+ status="running",
589
+ time=TimeStart(start=created_ms),
590
+ input=tool_input,
591
+ title=f"Running {p.tool_name}",
592
+ ),
593
+ )
594
+ tool_calls[p.tool_call_id or ""] = tool_part
595
+ parts.append(tool_part)
596
+
597
+ elif isinstance(model_msg, ModelRequest):
598
+ # Check for tool returns in requests (they come after responses)
599
+ for part in model_msg.parts:
600
+ if isinstance(part, PydanticToolReturnPart):
601
+ call_id = part.tool_call_id or ""
602
+ existing = tool_calls.get(call_id)
603
+
604
+ # Format output
605
+ content = part.content
606
+ if isinstance(content, str):
607
+ output = content
608
+ elif isinstance(content, dict):
609
+ import json
610
+
611
+ output = json.dumps(content, indent=2)
612
+ else:
613
+ output = str(content) if content is not None else ""
614
+
615
+ # Check for error
616
+ is_error = isinstance(content, dict) and "error" in content
617
+
618
+ if existing:
619
+ # Update existing tool part with completion
620
+ existing_input = _get_input_from_state(existing.state)
621
+ if is_error:
622
+ existing.state = ToolStateError(
623
+ status="error",
624
+ error=str(content.get("error", "Unknown error")),
625
+ input=existing_input,
626
+ time=TimeStartEnd(start=created_ms, end=completed_ms),
627
+ )
628
+ else:
629
+ existing.state = ToolStateCompleted(
630
+ status="completed",
631
+ title=f"Completed {part.tool_name}",
632
+ input=existing_input,
633
+ output=output,
634
+ time=TimeStartEndCompacted(start=created_ms, end=completed_ms),
635
+ )
636
+ else:
637
+ # Orphan return - create completed tool part
638
+ state: ToolStateCompleted | ToolStateError
639
+ if is_error:
640
+ state = ToolStateError(
641
+ status="error",
642
+ error=str(content.get("error", "Unknown error")),
643
+ input={},
644
+ time=TimeStartEnd(start=created_ms, end=completed_ms),
645
+ )
646
+ else:
647
+ state = ToolStateCompleted(
648
+ status="completed",
649
+ title=f"Completed {part.tool_name}",
650
+ input={},
651
+ output=output,
652
+ time=TimeStartEndCompacted(start=created_ms, end=completed_ms),
653
+ )
654
+ parts.append(
655
+ ToolPart(
656
+ id=generate_part_id(),
657
+ message_id=message_id,
658
+ session_id=session_id,
659
+ tool=part.tool_name,
660
+ call_id=call_id,
661
+ state=state,
662
+ )
663
+ )
664
+
665
+ # Add step finish
666
+ parts.append(
667
+ StepFinishPart(
668
+ id=generate_part_id(),
669
+ message_id=message_id,
670
+ session_id=session_id,
671
+ reason=msg.finish_reason or "stop",
672
+ cost=float(msg.cost_info.total_cost) if msg.cost_info else 0.0,
673
+ tokens=StepFinishTokens(
674
+ input=tokens.input,
675
+ output=tokens.output,
676
+ reasoning=tokens.reasoning,
677
+ cache=TokenCache(read=tokens.cache.read, write=tokens.cache.write),
678
+ ),
679
+ )
680
+ )
681
+
682
+ return MessageWithParts(info=info, parts=parts)
683
+
684
+
685
+ def opencode_to_chat_message(
686
+ msg: MessageWithParts,
687
+ conversation_id: str | None = None,
688
+ ) -> ChatMessage[str]:
689
+ """Convert OpenCode MessageWithParts to ChatMessage.
690
+
691
+ Args:
692
+ msg: OpenCode message with parts
693
+ conversation_id: Optional conversation ID override
694
+
695
+ Returns:
696
+ ChatMessage with pydantic-ai model messages
697
+ """
698
+ from pydantic_ai.messages import ModelRequest, ModelResponse
699
+ from pydantic_ai.usage import RequestUsage
700
+
701
+ from agentpool.messaging.messages import ChatMessage
702
+
703
+ info = msg.info
704
+ message_id = info.id
705
+ session_id = info.session_id
706
+
707
+ # Determine role and extract timing
708
+ if isinstance(info, UserMessage):
709
+ role = "user"
710
+ created_ms = info.time.created
711
+ model_name = info.model.model_id if info.model else None
712
+ provider_name = info.model.provider_id if info.model else None
713
+ usage = RequestUsage()
714
+ finish_reason = None
715
+ else:
716
+ role = "assistant"
717
+ created_ms = info.time.created
718
+ model_name = info.model_id
719
+ provider_name = info.provider_id
720
+ usage = RequestUsage(
721
+ input_tokens=info.tokens.input,
722
+ output_tokens=info.tokens.output,
723
+ cache_read_tokens=info.tokens.cache.read,
724
+ cache_write_tokens=info.tokens.cache.write,
725
+ )
726
+ finish_reason = info.finish
727
+
728
+ timestamp = _ms_to_datetime(created_ms)
729
+
730
+ # Build model messages from parts
731
+ model_messages: list[ModelRequest | ModelResponse] = []
732
+
733
+ if role == "user":
734
+ # Collect text parts into a user prompt
735
+ text_content = [part.text for part in msg.parts if isinstance(part, TextPart)]
736
+ content = "\n".join(text_content) if text_content else ""
737
+ model_messages.append(
738
+ ModelRequest(
739
+ parts=[UserPromptPart(content=content)],
740
+ instructions=None,
741
+ )
742
+ )
743
+ else:
744
+ # Assistant message - collect response parts and tool interactions
745
+ response_parts: list[Any] = []
746
+ tool_returns: list[PydanticToolReturnPart] = []
747
+
748
+ for part in msg.parts:
749
+ if isinstance(part, TextPart):
750
+ response_parts.append(PydanticTextPart(content=part.text, id=part.id))
751
+ elif isinstance(part, ToolPart):
752
+ # Create tool call part
753
+
754
+ tool_input = _get_input_from_state(part.state)
755
+ response_parts.append(
756
+ PydanticToolCallPart(
757
+ tool_name=part.tool,
758
+ tool_call_id=part.call_id,
759
+ args=tool_input,
760
+ )
761
+ )
762
+
763
+ # If completed/error, also create tool return
764
+ if isinstance(part.state, ToolStateCompleted):
765
+ tool_returns.append(
766
+ PydanticToolReturnPart(
767
+ tool_name=part.tool,
768
+ tool_call_id=part.call_id,
769
+ content=part.state.output,
770
+ )
771
+ )
772
+ elif isinstance(part.state, ToolStateError):
773
+ tool_returns.append(
774
+ PydanticToolReturnPart(
775
+ tool_name=part.tool,
776
+ tool_call_id=part.call_id,
777
+ content={"error": part.state.error},
778
+ )
779
+ )
780
+ # Skip StepStartPart, StepFinishPart, FilePart for now
781
+
782
+ if response_parts:
783
+ model_messages.append(
784
+ ModelResponse(
785
+ parts=response_parts,
786
+ usage=usage,
787
+ model_name=model_name,
788
+ timestamp=timestamp,
789
+ )
790
+ )
791
+
792
+ # Add tool returns as a follow-up request if any
793
+ if tool_returns:
794
+ model_messages.append(
795
+ ModelRequest(
796
+ parts=tool_returns,
797
+ instructions=None,
798
+ )
799
+ )
800
+
801
+ # Extract content for the ChatMessage
802
+ content = ""
803
+ for part in msg.parts:
804
+ if isinstance(part, TextPart):
805
+ content = part.text
806
+ break
807
+
808
+ return ChatMessage(
809
+ content=content,
810
+ role=role, # type: ignore[arg-type]
811
+ message_id=message_id,
812
+ conversation_id=conversation_id or session_id,
813
+ timestamp=timestamp,
814
+ messages=model_messages,
815
+ usage=usage,
816
+ model_name=model_name,
817
+ provider_name=provider_name,
818
+ finish_reason=finish_reason, # type: ignore[arg-type]
819
+ )
820
+
821
+
822
+ def chat_messages_to_opencode(
823
+ messages: list[ChatMessage[Any]],
824
+ session_id: str,
825
+ working_dir: str = "",
826
+ agent_name: str = "default",
827
+ model_id: str = "unknown",
828
+ provider_id: str = "agentpool",
829
+ ) -> list[MessageWithParts]:
830
+ """Convert a list of ChatMessages to OpenCode format.
831
+
832
+ Args:
833
+ messages: List of ChatMessages to convert
834
+ session_id: OpenCode session ID
835
+ working_dir: Working directory for path context
836
+ agent_name: Name of the agent
837
+ model_id: Model identifier
838
+ provider_id: Provider identifier
839
+
840
+ Returns:
841
+ List of OpenCode MessageWithParts
842
+ """
843
+ return [
844
+ chat_message_to_opencode(
845
+ msg,
846
+ session_id=session_id,
847
+ working_dir=working_dir,
848
+ agent_name=agent_name,
849
+ model_id=model_id,
850
+ provider_id=provider_id,
851
+ )
852
+ for msg in messages
853
+ ]
854
+
855
+
856
+ def opencode_to_chat_messages(
857
+ messages: list[MessageWithParts],
858
+ conversation_id: str | None = None,
859
+ ) -> list[ChatMessage[str]]:
860
+ """Convert a list of OpenCode messages to ChatMessages.
861
+
862
+ Args:
863
+ messages: List of OpenCode MessageWithParts
864
+ conversation_id: Optional conversation ID override
865
+
866
+ Returns:
867
+ List of ChatMessages
868
+ """
869
+ return [opencode_to_chat_message(msg, conversation_id=conversation_id) for msg in messages]