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