agentpool 2.2.3__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 (250) hide show
  1. acp/__init__.py +0 -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/client/connection.py +38 -29
  7. acp/client/implementations/default_client.py +3 -2
  8. acp/client/implementations/headless_client.py +2 -2
  9. acp/connection.py +2 -2
  10. acp/notifications.py +18 -49
  11. acp/schema/__init__.py +2 -0
  12. acp/schema/agent_responses.py +21 -0
  13. acp/schema/client_requests.py +3 -3
  14. acp/schema/session_state.py +63 -29
  15. acp/task/supervisor.py +2 -2
  16. acp/utils.py +2 -2
  17. agentpool/__init__.py +2 -0
  18. agentpool/agents/acp_agent/acp_agent.py +278 -263
  19. agentpool/agents/acp_agent/acp_converters.py +150 -17
  20. agentpool/agents/acp_agent/client_handler.py +35 -24
  21. agentpool/agents/acp_agent/session_state.py +14 -6
  22. agentpool/agents/agent.py +471 -643
  23. agentpool/agents/agui_agent/agui_agent.py +104 -107
  24. agentpool/agents/agui_agent/helpers.py +3 -4
  25. agentpool/agents/base_agent.py +485 -32
  26. agentpool/agents/claude_code_agent/FORKING.md +191 -0
  27. agentpool/agents/claude_code_agent/__init__.py +13 -1
  28. agentpool/agents/claude_code_agent/claude_code_agent.py +654 -334
  29. agentpool/agents/claude_code_agent/converters.py +4 -141
  30. agentpool/agents/claude_code_agent/models.py +77 -0
  31. agentpool/agents/claude_code_agent/static_info.py +100 -0
  32. agentpool/agents/claude_code_agent/usage.py +242 -0
  33. agentpool/agents/events/__init__.py +22 -0
  34. agentpool/agents/events/builtin_handlers.py +65 -0
  35. agentpool/agents/events/event_emitter.py +3 -0
  36. agentpool/agents/events/events.py +84 -3
  37. agentpool/agents/events/infer_info.py +145 -0
  38. agentpool/agents/events/processors.py +254 -0
  39. agentpool/agents/interactions.py +41 -6
  40. agentpool/agents/modes.py +13 -0
  41. agentpool/agents/slashed_agent.py +5 -4
  42. agentpool/agents/tool_wrapping.py +18 -6
  43. agentpool/common_types.py +35 -21
  44. agentpool/config_resources/acp_assistant.yml +2 -2
  45. agentpool/config_resources/agents.yml +3 -0
  46. agentpool/config_resources/agents_template.yml +1 -0
  47. agentpool/config_resources/claude_code_agent.yml +9 -8
  48. agentpool/config_resources/external_acp_agents.yml +2 -1
  49. agentpool/delegation/base_team.py +4 -30
  50. agentpool/delegation/pool.py +104 -265
  51. agentpool/delegation/team.py +57 -57
  52. agentpool/delegation/teamrun.py +50 -55
  53. agentpool/functional/run.py +10 -4
  54. agentpool/mcp_server/client.py +73 -38
  55. agentpool/mcp_server/conversions.py +54 -13
  56. agentpool/mcp_server/manager.py +9 -23
  57. agentpool/mcp_server/registries/official_registry_client.py +10 -1
  58. agentpool/mcp_server/tool_bridge.py +114 -79
  59. agentpool/messaging/connection_manager.py +11 -10
  60. agentpool/messaging/event_manager.py +5 -5
  61. agentpool/messaging/message_container.py +6 -30
  62. agentpool/messaging/message_history.py +87 -8
  63. agentpool/messaging/messagenode.py +52 -14
  64. agentpool/messaging/messages.py +2 -26
  65. agentpool/messaging/processing.py +10 -22
  66. agentpool/models/__init__.py +1 -1
  67. agentpool/models/acp_agents/base.py +6 -2
  68. agentpool/models/acp_agents/mcp_capable.py +124 -15
  69. agentpool/models/acp_agents/non_mcp.py +0 -23
  70. agentpool/models/agents.py +66 -66
  71. agentpool/models/agui_agents.py +1 -1
  72. agentpool/models/claude_code_agents.py +111 -17
  73. agentpool/models/file_parsing.py +0 -1
  74. agentpool/models/manifest.py +70 -50
  75. agentpool/prompts/conversion_manager.py +1 -1
  76. agentpool/prompts/prompts.py +5 -2
  77. agentpool/resource_providers/__init__.py +2 -0
  78. agentpool/resource_providers/aggregating.py +4 -2
  79. agentpool/resource_providers/base.py +13 -3
  80. agentpool/resource_providers/codemode/code_executor.py +72 -5
  81. agentpool/resource_providers/codemode/helpers.py +2 -2
  82. agentpool/resource_providers/codemode/provider.py +64 -12
  83. agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
  84. agentpool/resource_providers/codemode/remote_provider.py +9 -12
  85. agentpool/resource_providers/filtering.py +3 -1
  86. agentpool/resource_providers/mcp_provider.py +66 -12
  87. agentpool/resource_providers/plan_provider.py +111 -18
  88. agentpool/resource_providers/pool.py +5 -3
  89. agentpool/resource_providers/resource_info.py +111 -0
  90. agentpool/resource_providers/static.py +2 -2
  91. agentpool/sessions/__init__.py +2 -0
  92. agentpool/sessions/manager.py +2 -3
  93. agentpool/sessions/models.py +9 -6
  94. agentpool/sessions/protocol.py +28 -0
  95. agentpool/sessions/session.py +11 -55
  96. agentpool/storage/manager.py +361 -54
  97. agentpool/talk/registry.py +4 -4
  98. agentpool/talk/talk.py +9 -10
  99. agentpool/testing.py +1 -1
  100. agentpool/tool_impls/__init__.py +6 -0
  101. agentpool/tool_impls/agent_cli/__init__.py +42 -0
  102. agentpool/tool_impls/agent_cli/tool.py +95 -0
  103. agentpool/tool_impls/bash/__init__.py +64 -0
  104. agentpool/tool_impls/bash/helpers.py +35 -0
  105. agentpool/tool_impls/bash/tool.py +171 -0
  106. agentpool/tool_impls/delete_path/__init__.py +70 -0
  107. agentpool/tool_impls/delete_path/tool.py +142 -0
  108. agentpool/tool_impls/download_file/__init__.py +80 -0
  109. agentpool/tool_impls/download_file/tool.py +183 -0
  110. agentpool/tool_impls/execute_code/__init__.py +55 -0
  111. agentpool/tool_impls/execute_code/tool.py +163 -0
  112. agentpool/tool_impls/grep/__init__.py +80 -0
  113. agentpool/tool_impls/grep/tool.py +200 -0
  114. agentpool/tool_impls/list_directory/__init__.py +73 -0
  115. agentpool/tool_impls/list_directory/tool.py +197 -0
  116. agentpool/tool_impls/question/__init__.py +42 -0
  117. agentpool/tool_impls/question/tool.py +127 -0
  118. agentpool/tool_impls/read/__init__.py +104 -0
  119. agentpool/tool_impls/read/tool.py +305 -0
  120. agentpool/tools/__init__.py +2 -1
  121. agentpool/tools/base.py +114 -34
  122. agentpool/tools/manager.py +57 -1
  123. agentpool/ui/base.py +2 -2
  124. agentpool/ui/mock_provider.py +2 -2
  125. agentpool/ui/stdlib_provider.py +2 -2
  126. agentpool/utils/streams.py +21 -96
  127. agentpool/vfs_registry.py +7 -2
  128. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/METADATA +16 -22
  129. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/RECORD +242 -195
  130. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
  131. agentpool_cli/__main__.py +20 -0
  132. agentpool_cli/create.py +1 -1
  133. agentpool_cli/serve_acp.py +59 -1
  134. agentpool_cli/serve_opencode.py +1 -1
  135. agentpool_cli/ui.py +557 -0
  136. agentpool_commands/__init__.py +12 -5
  137. agentpool_commands/agents.py +1 -1
  138. agentpool_commands/pool.py +260 -0
  139. agentpool_commands/session.py +1 -1
  140. agentpool_commands/text_sharing/__init__.py +119 -0
  141. agentpool_commands/text_sharing/base.py +123 -0
  142. agentpool_commands/text_sharing/github_gist.py +80 -0
  143. agentpool_commands/text_sharing/opencode.py +462 -0
  144. agentpool_commands/text_sharing/paste_rs.py +59 -0
  145. agentpool_commands/text_sharing/pastebin.py +116 -0
  146. agentpool_commands/text_sharing/shittycodingagent.py +112 -0
  147. agentpool_commands/utils.py +31 -32
  148. agentpool_config/__init__.py +30 -2
  149. agentpool_config/agentpool_tools.py +498 -0
  150. agentpool_config/converters.py +1 -1
  151. agentpool_config/event_handlers.py +42 -0
  152. agentpool_config/events.py +1 -1
  153. agentpool_config/forward_targets.py +1 -4
  154. agentpool_config/jinja.py +3 -3
  155. agentpool_config/mcp_server.py +1 -5
  156. agentpool_config/nodes.py +1 -1
  157. agentpool_config/observability.py +44 -0
  158. agentpool_config/session.py +0 -3
  159. agentpool_config/storage.py +38 -39
  160. agentpool_config/task.py +3 -3
  161. agentpool_config/tools.py +11 -28
  162. agentpool_config/toolsets.py +22 -90
  163. agentpool_server/a2a_server/agent_worker.py +307 -0
  164. agentpool_server/a2a_server/server.py +23 -18
  165. agentpool_server/acp_server/acp_agent.py +125 -56
  166. agentpool_server/acp_server/commands/acp_commands.py +46 -216
  167. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +8 -7
  168. agentpool_server/acp_server/event_converter.py +651 -0
  169. agentpool_server/acp_server/input_provider.py +53 -10
  170. agentpool_server/acp_server/server.py +1 -11
  171. agentpool_server/acp_server/session.py +90 -410
  172. agentpool_server/acp_server/session_manager.py +8 -34
  173. agentpool_server/agui_server/server.py +3 -1
  174. agentpool_server/mcp_server/server.py +5 -2
  175. agentpool_server/opencode_server/ENDPOINTS.md +53 -14
  176. agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
  177. agentpool_server/opencode_server/__init__.py +0 -8
  178. agentpool_server/opencode_server/converters.py +132 -26
  179. agentpool_server/opencode_server/input_provider.py +160 -8
  180. agentpool_server/opencode_server/models/__init__.py +42 -20
  181. agentpool_server/opencode_server/models/app.py +12 -0
  182. agentpool_server/opencode_server/models/events.py +203 -29
  183. agentpool_server/opencode_server/models/mcp.py +19 -0
  184. agentpool_server/opencode_server/models/message.py +18 -1
  185. agentpool_server/opencode_server/models/parts.py +134 -1
  186. agentpool_server/opencode_server/models/question.py +56 -0
  187. agentpool_server/opencode_server/models/session.py +13 -1
  188. agentpool_server/opencode_server/routes/__init__.py +4 -0
  189. agentpool_server/opencode_server/routes/agent_routes.py +33 -2
  190. agentpool_server/opencode_server/routes/app_routes.py +66 -3
  191. agentpool_server/opencode_server/routes/config_routes.py +66 -5
  192. agentpool_server/opencode_server/routes/file_routes.py +184 -5
  193. agentpool_server/opencode_server/routes/global_routes.py +1 -1
  194. agentpool_server/opencode_server/routes/lsp_routes.py +1 -1
  195. agentpool_server/opencode_server/routes/message_routes.py +122 -66
  196. agentpool_server/opencode_server/routes/permission_routes.py +63 -0
  197. agentpool_server/opencode_server/routes/pty_routes.py +23 -22
  198. agentpool_server/opencode_server/routes/question_routes.py +128 -0
  199. agentpool_server/opencode_server/routes/session_routes.py +139 -68
  200. agentpool_server/opencode_server/routes/tui_routes.py +1 -1
  201. agentpool_server/opencode_server/server.py +47 -2
  202. agentpool_server/opencode_server/state.py +30 -0
  203. agentpool_storage/__init__.py +0 -4
  204. agentpool_storage/base.py +81 -2
  205. agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
  206. agentpool_storage/claude_provider/__init__.py +42 -0
  207. agentpool_storage/{claude_provider.py → claude_provider/provider.py} +190 -8
  208. agentpool_storage/file_provider.py +149 -15
  209. agentpool_storage/memory_provider.py +132 -12
  210. agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
  211. agentpool_storage/opencode_provider/__init__.py +16 -0
  212. agentpool_storage/opencode_provider/helpers.py +414 -0
  213. agentpool_storage/opencode_provider/provider.py +895 -0
  214. agentpool_storage/session_store.py +20 -6
  215. agentpool_storage/sql_provider/sql_provider.py +135 -2
  216. agentpool_storage/sql_provider/utils.py +2 -12
  217. agentpool_storage/zed_provider/__init__.py +16 -0
  218. agentpool_storage/zed_provider/helpers.py +281 -0
  219. agentpool_storage/zed_provider/models.py +130 -0
  220. agentpool_storage/zed_provider/provider.py +442 -0
  221. agentpool_storage/zed_provider.py +803 -0
  222. agentpool_toolsets/__init__.py +0 -2
  223. agentpool_toolsets/builtin/__init__.py +2 -4
  224. agentpool_toolsets/builtin/code.py +4 -4
  225. agentpool_toolsets/builtin/debug.py +115 -40
  226. agentpool_toolsets/builtin/execution_environment.py +54 -165
  227. agentpool_toolsets/builtin/skills.py +0 -77
  228. agentpool_toolsets/builtin/subagent_tools.py +64 -51
  229. agentpool_toolsets/builtin/workers.py +4 -2
  230. agentpool_toolsets/composio_toolset.py +2 -2
  231. agentpool_toolsets/entry_points.py +3 -1
  232. agentpool_toolsets/fsspec_toolset/grep.py +25 -5
  233. agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
  234. agentpool_toolsets/fsspec_toolset/toolset.py +350 -66
  235. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  236. agentpool_toolsets/mcp_discovery/toolset.py +74 -17
  237. agentpool_toolsets/mcp_run_toolset.py +8 -11
  238. agentpool_toolsets/notifications.py +33 -33
  239. agentpool_toolsets/openapi.py +3 -1
  240. agentpool_toolsets/search_toolset.py +3 -1
  241. agentpool_config/resources.py +0 -33
  242. agentpool_server/acp_server/acp_tools.py +0 -43
  243. agentpool_server/acp_server/commands/spawn.py +0 -210
  244. agentpool_storage/opencode_provider.py +0 -730
  245. agentpool_storage/text_log_provider.py +0 -276
  246. agentpool_toolsets/builtin/chain.py +0 -288
  247. agentpool_toolsets/builtin/user_interaction.py +0 -52
  248. agentpool_toolsets/semantic_memory_toolset.py +0 -536
  249. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
  250. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,895 @@
1
+ """OpenCode storage provider.
2
+
3
+ This module implements storage compatible with OpenCode's normalized JSON format,
4
+ storing conversations as relational data across multiple directories.
5
+
6
+ Key differences from Claude Code:
7
+ - Normalized structure (sessions → messages → parts)
8
+ - SHA1-based project IDs
9
+ - Timestamp-based message ordering (no parent links)
10
+ - In-place file updates (not append-only)
11
+
12
+ See ARCHITECTURE.md for detailed documentation of the storage format.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections import defaultdict
18
+ from collections.abc import Sequence
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any, Literal
22
+
23
+ import anyenv
24
+ from pydantic import TypeAdapter
25
+ from pydantic_ai.messages import (
26
+ ModelRequest,
27
+ ModelResponse,
28
+ TextPart,
29
+ ThinkingPart,
30
+ ToolCallPart,
31
+ ToolReturnPart,
32
+ UserPromptPart,
33
+ )
34
+
35
+ from agentpool.log import get_logger
36
+ from agentpool.utils.now import get_now
37
+ from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict
38
+ from agentpool_storage.base import StorageProvider
39
+ from agentpool_storage.models import TokenUsage
40
+ from agentpool_storage.opencode_provider import helpers
41
+
42
+
43
+ if TYPE_CHECKING:
44
+ from collections.abc import Sequence
45
+
46
+ from agentpool.messaging import ChatMessage, TokenCost
47
+ from agentpool_config.session import SessionQuery
48
+ from agentpool_config.storage import OpenCodeStorageConfig
49
+ from agentpool_server.opencode_server.models import (
50
+ MessageInfo as OpenCodeMessage,
51
+ Part as OpenCodePart,
52
+ )
53
+ from agentpool_storage.models import (
54
+ ConversationData,
55
+ MessageData,
56
+ QueryFilters,
57
+ StatsFilters,
58
+ )
59
+
60
+ logger = get_logger(__name__)
61
+
62
+ # OpenCode version we're emulating
63
+ OPENCODE_VERSION = "1.1.7"
64
+
65
+ # Type aliases - use server models directly
66
+ PartType = Literal[
67
+ "text",
68
+ "step-start",
69
+ "step-finish",
70
+ "reasoning",
71
+ "tool",
72
+ "patch",
73
+ "compaction",
74
+ "snapshot",
75
+ "agent",
76
+ "subtask",
77
+ "retry",
78
+ ]
79
+ ToolStatus = Literal["pending", "running", "completed", "error"]
80
+ FinishReason = Literal["stop", "tool-calls", "length", "error"]
81
+
82
+
83
+ class OpenCodeStorageProvider(StorageProvider):
84
+ """Storage provider that reads/writes OpenCode's native format.
85
+
86
+ OpenCode stores data in:
87
+ - ~/.local/share/opencode/storage/session/{project_id}/ - Session JSON files
88
+ - ~/.local/share/opencode/storage/message/{session_id}/ - Message JSON files
89
+ - ~/.local/share/opencode/storage/part/{message_id}/ - Part JSON files
90
+
91
+ Each file is a single JSON object (not JSONL).
92
+ """
93
+
94
+ can_load_history = True
95
+
96
+ def __init__(self, config: OpenCodeStorageConfig) -> None:
97
+ """Initialize OpenCode storage provider."""
98
+ super().__init__(config)
99
+ self.base_path = Path(config.path).expanduser()
100
+ self.sessions_path = self.base_path / "session"
101
+ self.messages_path = self.base_path / "message"
102
+ self.parts_path = self.base_path / "part"
103
+
104
+ def _list_sessions(self, project_id: str | None = None) -> list[tuple[str, Path]]:
105
+ """List all sessions, optionally filtered by project."""
106
+ sessions: list[tuple[str, Path]] = []
107
+ if not self.sessions_path.exists():
108
+ return sessions
109
+
110
+ if project_id:
111
+ project_dir = self.sessions_path / project_id
112
+ if project_dir.exists():
113
+ sessions.extend((f.stem, f) for f in project_dir.glob("*.json"))
114
+ else:
115
+ for project_dir in self.sessions_path.iterdir():
116
+ if project_dir.is_dir():
117
+ sessions.extend((f.stem, f) for f in project_dir.glob("*.json"))
118
+ return sessions
119
+
120
+ def _read_messages(self, session_id: str) -> list[OpenCodeMessage]:
121
+ """Read all messages for a session."""
122
+ from agentpool_server.opencode_server.models import MessageInfo as OpenCodeMessage
123
+
124
+ messages: list[OpenCodeMessage] = []
125
+ msg_dir = self.messages_path / session_id
126
+ if not msg_dir.exists():
127
+ return messages
128
+
129
+ adapter = TypeAdapter[OpenCodeMessage](OpenCodeMessage)
130
+ for msg_file in sorted(msg_dir.glob("*.json")):
131
+ try:
132
+ content = msg_file.read_text(encoding="utf-8")
133
+ data_dict = anyenv.load_json(content)
134
+ data = adapter.validate_python(data_dict)
135
+ messages.append(data)
136
+ except anyenv.JsonLoadError as e:
137
+ logger.warning("Failed to parse message", path=str(msg_file), error=str(e))
138
+ return messages
139
+
140
+ def _read_parts(self, message_id: str) -> list[OpenCodePart]:
141
+ """Read all parts for a message."""
142
+ from agentpool_server.opencode_server.models import Part as OpenCodePart
143
+
144
+ parts: list[OpenCodePart] = []
145
+ parts_dir = self.parts_path / message_id
146
+ if not parts_dir.exists():
147
+ return parts
148
+
149
+ adapter = TypeAdapter[Any](OpenCodePart)
150
+ for part_file in sorted(parts_dir.glob("*.json")):
151
+ try:
152
+ content = part_file.read_text(encoding="utf-8")
153
+ data = anyenv.load_json(content)
154
+ parts.append(adapter.validate_python(data))
155
+ except anyenv.JsonLoadError as e:
156
+ logger.warning("Failed to parse part", path=str(part_file), error=str(e))
157
+ return parts
158
+
159
+ async def _write_message( # noqa: PLR0915
160
+ self,
161
+ *,
162
+ message_id: str,
163
+ conversation_id: str,
164
+ role: str,
165
+ model_messages: list[ModelRequest | ModelResponse],
166
+ parent_id: str | None = None,
167
+ model: str | None = None,
168
+ cost_info: TokenCost | None = None,
169
+ finish_reason: Any | None = None,
170
+ ) -> None:
171
+ """Write a message in OpenCode format."""
172
+ from agentpool_server.opencode_server.models import (
173
+ AssistantMessage,
174
+ MessagePath,
175
+ MessageTime,
176
+ ReasoningPart as OpenCodeReasoningPart,
177
+ TextPart as OpenCodeTextPart,
178
+ TimeCreated,
179
+ TimeStartEndCompacted,
180
+ TimeStartEndOptional,
181
+ Tokens,
182
+ TokensCache,
183
+ ToolPart as OpenCodeToolPart,
184
+ ToolStateCompleted,
185
+ ToolStateError,
186
+ ToolStatePending,
187
+ ToolStateRunning,
188
+ UserMessage,
189
+ UserMessageModel,
190
+ )
191
+
192
+ now_ms = int(get_now().timestamp() * 1000)
193
+
194
+ # Ensure message directory exists
195
+ msg_dir = self.messages_path / conversation_id
196
+ msg_dir.mkdir(parents=True, exist_ok=True)
197
+
198
+ # Create OpenCode message based on role
199
+ oc_message: OpenCodeMessage
200
+ if role == "assistant":
201
+ oc_message = AssistantMessage(
202
+ id=message_id,
203
+ session_id=conversation_id,
204
+ parent_id=parent_id or "",
205
+ model_id=model or "",
206
+ provider_id="", # TODO: get from somewhere
207
+ path=MessagePath(cwd="", root=""), # TODO: get real paths
208
+ time=MessageTime(created=now_ms),
209
+ tokens=Tokens(
210
+ input=cost_info.token_usage.input_tokens if cost_info else 0,
211
+ output=cost_info.token_usage.output_tokens if cost_info else 0,
212
+ cache=TokensCache(
213
+ read=cost_info.token_usage.cache_read_tokens if cost_info else 0,
214
+ write=cost_info.token_usage.cache_write_tokens if cost_info else 0,
215
+ ),
216
+ )
217
+ if cost_info
218
+ else Tokens(),
219
+ cost=float(cost_info.total_cost) if cost_info else 0.0,
220
+ finish=finish_reason,
221
+ )
222
+ else: # user message
223
+ oc_message = UserMessage(
224
+ id=message_id,
225
+ session_id=conversation_id,
226
+ time=TimeCreated(created=now_ms),
227
+ model=UserMessageModel(provider_id="", model_id=model or "") if model else None,
228
+ )
229
+
230
+ # Write message file
231
+ msg_file = msg_dir / f"{message_id}.json"
232
+ msg_file.write_text(
233
+ anyenv.dump_json(oc_message.model_dump(by_alias=True), indent=True),
234
+ encoding="utf-8",
235
+ )
236
+
237
+ # Convert model messages to OpenCode parts
238
+ parts_dir = self.parts_path / message_id
239
+ parts_dir.mkdir(parents=True, exist_ok=True)
240
+
241
+ part_counter = 0
242
+ for msg in model_messages:
243
+ if isinstance(msg, ModelRequest):
244
+ # User prompt parts
245
+ for part in msg.parts:
246
+ if isinstance(part, UserPromptPart):
247
+ # Convert UserContent to OpenCode parts using helper
248
+ text_parts = helpers.convert_user_content_to_parts(
249
+ content=part.content,
250
+ message_id=message_id,
251
+ session_id=conversation_id,
252
+ part_counter_start=part_counter,
253
+ )
254
+ part_counter += len(text_parts)
255
+
256
+ # Write each part to disk
257
+ for text_part in text_parts:
258
+ part_file = parts_dir / f"{text_part.id}.json"
259
+ part_file.write_text(
260
+ anyenv.dump_json(text_part.model_dump(by_alias=True), indent=True),
261
+ encoding="utf-8",
262
+ )
263
+ elif isinstance(part, ToolReturnPart):
264
+ # Tool return - update existing tool part with output
265
+ tool_part_file = None
266
+ # Find the tool part with matching call_id
267
+ for existing_file in parts_dir.glob("*.json"):
268
+ try:
269
+ content = anyenv.load_json(
270
+ existing_file.read_text(encoding="utf-8"),
271
+ return_type=dict,
272
+ )
273
+ if (
274
+ content.get("type") == "tool"
275
+ and content.get("callID") == part.tool_call_id
276
+ ):
277
+ tool_part_file = existing_file
278
+ break
279
+ except Exception: # noqa: BLE001
280
+ continue
281
+
282
+ if tool_part_file:
283
+ # Update the tool part with output - create new completed state
284
+ tool_part = anyenv.load_json(
285
+ tool_part_file.read_text(encoding="utf-8"),
286
+ return_type=OpenCodeToolPart,
287
+ )
288
+ # Create new ToolStateCompleted (states are immutable)
289
+ # All tool states have .input,
290
+ # but only Running/Completed/Error have .time
291
+ start_time = 0
292
+ if isinstance(
293
+ tool_part.state,
294
+ (ToolStateRunning, ToolStateCompleted, ToolStateError),
295
+ ):
296
+ start_time = tool_part.state.time.start
297
+
298
+ completed_state = ToolStateCompleted(
299
+ input=tool_part.state.input,
300
+ output=str(part.content),
301
+ title=tool_part.tool,
302
+ time=TimeStartEndCompacted(
303
+ start=start_time,
304
+ end=int(get_now().timestamp() * 1000),
305
+ ),
306
+ )
307
+ # Create new tool part with updated state
308
+ updated_tool_part = OpenCodeToolPart(
309
+ id=tool_part.id,
310
+ message_id=tool_part.message_id,
311
+ session_id=tool_part.session_id,
312
+ call_id=tool_part.call_id,
313
+ tool=tool_part.tool,
314
+ state=completed_state,
315
+ )
316
+ tool_part_file.write_text(
317
+ anyenv.dump_json(
318
+ updated_tool_part.model_dump(by_alias=True), indent=True
319
+ ),
320
+ encoding="utf-8",
321
+ )
322
+
323
+ elif isinstance(msg, ModelResponse):
324
+ # Model response parts
325
+ for part in msg.parts: # type: ignore[assignment]
326
+ part_id = f"{message_id}-{part_counter}"
327
+ part_counter += 1
328
+
329
+ if isinstance(part, TextPart):
330
+ text_part = OpenCodeTextPart(
331
+ id=part_id,
332
+ session_id=conversation_id,
333
+ message_id=message_id,
334
+ type="text",
335
+ text=part.content,
336
+ time=TimeStartEndOptional(start=now_ms),
337
+ )
338
+ part_file = parts_dir / f"{part_id}.json"
339
+ part_file.write_text(
340
+ anyenv.dump_json(text_part.model_dump(by_alias=True), indent=True),
341
+ encoding="utf-8",
342
+ )
343
+
344
+ elif isinstance(part, ThinkingPart):
345
+ reasoning_part = OpenCodeReasoningPart(
346
+ id=part_id,
347
+ session_id=conversation_id,
348
+ message_id=message_id,
349
+ type="reasoning",
350
+ text=part.content,
351
+ time=TimeStartEndOptional(start=now_ms),
352
+ )
353
+ part_file = parts_dir / f"{part_id}.json"
354
+ part_file.write_text(
355
+ anyenv.dump_json(reasoning_part.model_dump(by_alias=True), indent=True),
356
+ encoding="utf-8",
357
+ )
358
+
359
+ elif isinstance(part, ToolCallPart):
360
+ # Create tool part with pending status
361
+ tool_part = OpenCodeToolPart(
362
+ id=part_id,
363
+ session_id=conversation_id,
364
+ message_id=message_id,
365
+ type="tool",
366
+ call_id=part.tool_call_id,
367
+ tool=part.tool_name,
368
+ state=ToolStatePending(
369
+ status="pending",
370
+ input=safe_args_as_dict(part),
371
+ raw="",
372
+ ),
373
+ )
374
+ part_file = parts_dir / f"{part_id}.json"
375
+ part_file.write_text(
376
+ anyenv.dump_json(tool_part.model_dump(by_alias=True), indent=True),
377
+ encoding="utf-8",
378
+ )
379
+
380
+ async def filter_messages(self, query: SessionQuery) -> list[ChatMessage[str]]:
381
+ """Filter messages based on query."""
382
+ messages: list[ChatMessage[str]] = []
383
+ sessions = self._list_sessions()
384
+
385
+ for session_id, session_path in sessions:
386
+ if query.name and session_id != query.name:
387
+ continue
388
+
389
+ session = helpers.read_session(session_path)
390
+ if not session:
391
+ continue
392
+
393
+ oc_messages = self._read_messages(session_id)
394
+
395
+ # Read parts for all messages
396
+ msg_parts_map: dict[str, list[OpenCodePart]] = {}
397
+ for oc_msg in oc_messages:
398
+ parts = self._read_parts(oc_msg.id)
399
+ msg_parts_map[oc_msg.id] = parts
400
+ for oc_msg in oc_messages:
401
+ parts = msg_parts_map.get(oc_msg.id, [])
402
+ chat_msg = helpers.message_to_chat_message(oc_msg, parts, session_id)
403
+
404
+ # Apply filters
405
+ if query.agents and chat_msg.name not in query.agents:
406
+ continue
407
+ cutoff = query.get_time_cutoff()
408
+ if query.since and cutoff and chat_msg.timestamp < cutoff:
409
+ continue
410
+ if query.until:
411
+ until_dt = datetime.fromisoformat(query.until)
412
+ if chat_msg.timestamp > until_dt:
413
+ continue
414
+ if query.contains and query.contains not in chat_msg.content:
415
+ continue
416
+ if query.roles and chat_msg.role not in query.roles:
417
+ continue
418
+ messages.append(chat_msg)
419
+
420
+ if query.limit and len(messages) >= query.limit:
421
+ return messages
422
+
423
+ return messages
424
+
425
+ async def log_message(
426
+ self,
427
+ *,
428
+ message_id: str,
429
+ conversation_id: str,
430
+ content: str,
431
+ role: str,
432
+ name: str | None = None,
433
+ parent_id: str | None = None,
434
+ cost_info: TokenCost | None = None,
435
+ model: str | None = None,
436
+ response_time: float | None = None,
437
+ provider_name: str | None = None,
438
+ provider_response_id: str | None = None,
439
+ messages: str | None = None,
440
+ finish_reason: Any | None = None,
441
+ ) -> None:
442
+ """Log a message to OpenCode format."""
443
+ if not messages:
444
+ logger.debug("No structured messages to log, skipping")
445
+ return
446
+
447
+ try:
448
+ # Deserialize pydantic-ai messages
449
+ from agentpool.storage.serialization import messages_adapter
450
+
451
+ model_messages = messages_adapter.validate_json(messages)
452
+
453
+ # Convert to OpenCode format and write
454
+ await self._write_message(
455
+ message_id=message_id,
456
+ conversation_id=conversation_id,
457
+ role=role,
458
+ model_messages=model_messages,
459
+ parent_id=parent_id,
460
+ model=model,
461
+ cost_info=cost_info,
462
+ finish_reason=finish_reason,
463
+ )
464
+ except Exception as e:
465
+ logger.exception("Failed to write OpenCode message", error=str(e))
466
+
467
+ async def log_conversation(
468
+ self,
469
+ *,
470
+ conversation_id: str,
471
+ node_name: str,
472
+ start_time: datetime | None = None,
473
+ ) -> None:
474
+ """Log a conversation start."""
475
+ # No-op for read-only provider
476
+
477
+ async def get_conversations(
478
+ self,
479
+ filters: QueryFilters,
480
+ ) -> list[tuple[ConversationData, Sequence[ChatMessage[str]]]]:
481
+ """Get filtered conversations with their messages."""
482
+ from agentpool_server.opencode_server.models import AssistantMessage
483
+ from agentpool_storage.models import ConversationData as ConvData
484
+
485
+ result: list[tuple[ConvData, Sequence[ChatMessage[str]]]] = []
486
+ sessions = self._list_sessions()
487
+ for session_id, session_path in sessions:
488
+ session = helpers.read_session(session_path)
489
+ if not session:
490
+ continue
491
+
492
+ oc_messages = self._read_messages(session_id)
493
+ if not oc_messages:
494
+ continue
495
+
496
+ # Read parts for all messages
497
+ msg_parts_map: dict[str, list[OpenCodePart]] = {}
498
+ for oc_msg in oc_messages:
499
+ parts = self._read_parts(oc_msg.id)
500
+ msg_parts_map[oc_msg.id] = parts
501
+
502
+ # Convert messages
503
+ chat_messages: list[ChatMessage[str]] = []
504
+ total_tokens = 0
505
+ total_cost = 0.0
506
+
507
+ for oc_msg in oc_messages:
508
+ parts = msg_parts_map.get(oc_msg.id, [])
509
+ chat_msg = helpers.message_to_chat_message(oc_msg, parts, session_id)
510
+ chat_messages.append(chat_msg)
511
+
512
+ # Only assistant messages have tokens and cost
513
+ if isinstance(oc_msg, AssistantMessage):
514
+ if oc_msg.tokens:
515
+ total_tokens += oc_msg.tokens.input + oc_msg.tokens.output
516
+ if oc_msg.cost:
517
+ total_cost += oc_msg.cost
518
+
519
+ if not chat_messages:
520
+ continue
521
+
522
+ first_timestamp = helpers.ms_to_datetime(session.time.created)
523
+
524
+ # Apply filters
525
+ if filters.agent_name and not any(m.name == filters.agent_name for m in chat_messages):
526
+ continue
527
+
528
+ if filters.since and first_timestamp < filters.since:
529
+ continue
530
+
531
+ if filters.query and not any(filters.query in m.content for m in chat_messages):
532
+ continue
533
+
534
+ # Build MessageData list
535
+ msg_data_list: list[MessageData] = []
536
+ for chat_msg in chat_messages:
537
+ msg_data: MessageData = {
538
+ "role": chat_msg.role,
539
+ "content": chat_msg.content,
540
+ "timestamp": (chat_msg.timestamp or get_now()).isoformat(),
541
+ "parent_id": chat_msg.parent_id,
542
+ "model": chat_msg.model_name,
543
+ "name": chat_msg.name,
544
+ "token_usage": TokenUsage(
545
+ total=chat_msg.cost_info.token_usage.total_tokens
546
+ if chat_msg.cost_info
547
+ else 0,
548
+ prompt=chat_msg.cost_info.token_usage.input_tokens
549
+ if chat_msg.cost_info
550
+ else 0,
551
+ completion=chat_msg.cost_info.token_usage.output_tokens
552
+ if chat_msg.cost_info
553
+ else 0,
554
+ )
555
+ if chat_msg.cost_info
556
+ else None,
557
+ "cost": float(chat_msg.cost_info.total_cost) if chat_msg.cost_info else None,
558
+ "response_time": chat_msg.response_time,
559
+ }
560
+ msg_data_list.append(msg_data)
561
+
562
+ token_usage_data: TokenUsage | None = (
563
+ {"total": total_tokens, "prompt": 0, "completion": 0} if total_tokens else None
564
+ )
565
+ conv_data = ConvData(
566
+ id=session_id,
567
+ agent=chat_messages[0].name or "opencode",
568
+ title=session.title,
569
+ start_time=first_timestamp.isoformat(),
570
+ messages=msg_data_list,
571
+ token_usage=token_usage_data,
572
+ )
573
+
574
+ result.append((conv_data, chat_messages))
575
+
576
+ if filters.limit and len(result) >= filters.limit:
577
+ break
578
+
579
+ return result
580
+
581
+ async def get_conversation_stats(
582
+ self,
583
+ filters: StatsFilters,
584
+ ) -> dict[str, dict[str, Any]]:
585
+ """Get conversation statistics."""
586
+ from agentpool_server.opencode_server.models import AssistantMessage
587
+
588
+ stats: dict[str, dict[str, Any]] = defaultdict(
589
+ lambda: {"total_tokens": 0, "messages": 0, "models": set(), "total_cost": 0.0}
590
+ )
591
+
592
+ sessions = self._list_sessions()
593
+
594
+ for _session_id, session_path in sessions:
595
+ session = helpers.read_session(session_path)
596
+ if not session:
597
+ continue
598
+
599
+ timestamp = helpers.ms_to_datetime(session.time.created)
600
+ if timestamp < filters.cutoff:
601
+ continue
602
+
603
+ oc_messages = self._read_messages(session.id)
604
+
605
+ for oc_msg in oc_messages:
606
+ if not isinstance(oc_msg, AssistantMessage):
607
+ continue
608
+
609
+ # AssistantMessage only has model_id
610
+ model = oc_msg.model_id or "unknown"
611
+ tokens = 0
612
+ if oc_msg.tokens:
613
+ tokens = oc_msg.tokens.input + oc_msg.tokens.output
614
+
615
+ msg_timestamp = helpers.ms_to_datetime(oc_msg.time.created)
616
+
617
+ # Group by specified criterion
618
+ match filters.group_by:
619
+ case "model":
620
+ key = model
621
+ case "hour":
622
+ key = msg_timestamp.strftime("%Y-%m-%d %H:00")
623
+ case "day":
624
+ key = msg_timestamp.strftime("%Y-%m-%d")
625
+ case _:
626
+ key = oc_msg.agent or "opencode"
627
+
628
+ stats[key]["messages"] += 1
629
+ stats[key]["total_tokens"] += tokens
630
+ stats[key]["models"].add(model)
631
+ stats[key]["total_cost"] += oc_msg.cost or 0.0
632
+
633
+ # Convert sets to lists
634
+ for value in stats.values():
635
+ value["models"] = list(value["models"])
636
+
637
+ return dict(stats)
638
+
639
+ async def reset(
640
+ self,
641
+ *,
642
+ agent_name: str | None = None,
643
+ hard: bool = False,
644
+ ) -> tuple[int, int]:
645
+ """Reset storage.
646
+
647
+ Warning: This would delete OpenCode data!
648
+ """
649
+ logger.warning("Reset not implemented for OpenCode storage (read-only)")
650
+ return 0, 0
651
+
652
+ async def get_conversation_counts(
653
+ self,
654
+ *,
655
+ agent_name: str | None = None,
656
+ ) -> tuple[int, int]:
657
+ """Get counts of conversations and messages."""
658
+ conv_count = 0
659
+ msg_count = 0
660
+
661
+ sessions = self._list_sessions()
662
+
663
+ for session_id, session_path in sessions:
664
+ session = helpers.read_session(session_path)
665
+ if not session:
666
+ continue
667
+
668
+ oc_messages = self._read_messages(session_id)
669
+ if oc_messages:
670
+ conv_count += 1
671
+ msg_count += len(oc_messages)
672
+
673
+ return conv_count, msg_count
674
+
675
+ async def get_conversation_messages(
676
+ self,
677
+ conversation_id: str,
678
+ *,
679
+ include_ancestors: bool = False,
680
+ ) -> list[ChatMessage[str]]:
681
+ """Get all messages for a conversation.
682
+
683
+ Args:
684
+ conversation_id: Session ID (conversation ID in OpenCode format)
685
+ include_ancestors: If True, traverse parent_id chain to include
686
+ messages from ancestor conversations
687
+
688
+ Returns:
689
+ List of messages ordered by timestamp
690
+ """
691
+ # Read messages for this session
692
+ oc_messages = self._read_messages(conversation_id)
693
+
694
+ messages: list[ChatMessage[str]] = []
695
+ for oc_msg in oc_messages:
696
+ parts = self._read_parts(oc_msg.id)
697
+ chat_msg = helpers.message_to_chat_message(oc_msg, parts, conversation_id)
698
+ messages.append(chat_msg)
699
+
700
+ # Sort by timestamp
701
+ messages.sort(key=lambda m: m.timestamp or get_now())
702
+
703
+ if not include_ancestors or not messages:
704
+ return messages
705
+
706
+ # Get ancestor chain if first message has parent_id
707
+ first_msg = messages[0]
708
+ if first_msg.parent_id:
709
+ ancestors = await self.get_message_ancestry(first_msg.parent_id)
710
+ return ancestors + messages
711
+
712
+ return messages
713
+
714
+ async def get_message(self, message_id: str) -> ChatMessage[str] | None:
715
+ """Get a single message by ID.
716
+
717
+ Args:
718
+ message_id: ID of the message
719
+
720
+ Returns:
721
+ The message if found, None otherwise
722
+ """
723
+ # Search all sessions for the message
724
+ sessions = self._list_sessions()
725
+
726
+ for session_id, _session_path in sessions:
727
+ oc_messages = self._read_messages(session_id)
728
+ for oc_msg in oc_messages:
729
+ if oc_msg.id == message_id:
730
+ parts = self._read_parts(oc_msg.id)
731
+ return helpers.message_to_chat_message(oc_msg, parts, session_id)
732
+
733
+ return None
734
+
735
+ async def get_message_ancestry(self, message_id: str) -> list[ChatMessage[str]]:
736
+ """Get the ancestry chain of a message.
737
+
738
+ Traverses parent_id chain to build full history.
739
+
740
+ Args:
741
+ message_id: ID of the message
742
+
743
+ Returns:
744
+ List of messages from oldest ancestor to the specified message
745
+ """
746
+ ancestors: list[ChatMessage[str]] = []
747
+ current_id: str | None = message_id
748
+
749
+ while current_id:
750
+ msg = await self.get_message(current_id)
751
+ if not msg:
752
+ break
753
+ ancestors.append(msg)
754
+ current_id = msg.parent_id
755
+
756
+ # Reverse to get oldest first
757
+ ancestors.reverse()
758
+ return ancestors
759
+
760
+ async def fork_conversation(
761
+ self,
762
+ *,
763
+ source_conversation_id: str,
764
+ new_conversation_id: str,
765
+ fork_from_message_id: str | None = None,
766
+ new_agent_name: str | None = None,
767
+ ) -> str | None:
768
+ """Fork a conversation at a specific point.
769
+
770
+ Creates a new session directory. The fork point message_id is returned
771
+ so callers can set it as parent_id for new messages.
772
+
773
+ Args:
774
+ source_conversation_id: Source session ID
775
+ new_conversation_id: New session ID
776
+ fork_from_message_id: Message ID to fork from. If None, forks from last
777
+ new_agent_name: Not directly stored in OpenCode session format
778
+
779
+ Returns:
780
+ The ID of the fork point message
781
+ """
782
+ from agentpool_server.opencode_server.models import (
783
+ Session,
784
+ SessionSummary,
785
+ TimeCreatedUpdated,
786
+ )
787
+
788
+ # Find source session
789
+ sessions = self._list_sessions()
790
+ source_session = None
791
+ source_path = None
792
+ for session_id, session_path in sessions:
793
+ if session_id == source_conversation_id:
794
+ source_session = helpers.read_session(session_path)
795
+ source_path = session_path
796
+ break
797
+
798
+ if not source_session or not source_path:
799
+ msg = f"Source conversation not found: {source_conversation_id}"
800
+ raise ValueError(msg)
801
+
802
+ # Read source messages
803
+ oc_messages = self._read_messages(source_conversation_id)
804
+
805
+ # Find fork point
806
+ fork_point_id: str | None = None
807
+ if fork_from_message_id:
808
+ # Verify message exists
809
+ found = any(m.id == fork_from_message_id for m in oc_messages)
810
+ if not found:
811
+ err = f"Message {fork_from_message_id} not found in conversation"
812
+ raise ValueError(err)
813
+ fork_point_id = fork_from_message_id
814
+ # Fork from last message
815
+ elif oc_messages:
816
+ # Messages are already in time order from _read_messages
817
+ fork_point_id = oc_messages[-1].id
818
+
819
+ # Create new session directory structure
820
+ # Determine project from source path structure
821
+ project_id = source_path.parent.name
822
+ new_session_dir = self.sessions_path / project_id
823
+ new_session_dir.mkdir(parents=True, exist_ok=True)
824
+
825
+ # Create empty session file (will be populated when messages added)
826
+ new_session_path = new_session_dir / f"{new_conversation_id}.json"
827
+
828
+ # Create new session metadata
829
+ fork_title = f"{source_session.title} (fork)" if source_session.title else "Forked Session"
830
+ new_session = Session(
831
+ id=new_conversation_id,
832
+ project_id=project_id,
833
+ directory=source_session.directory, # Same project directory as source
834
+ title=fork_title,
835
+ version=OPENCODE_VERSION,
836
+ time=TimeCreatedUpdated(
837
+ created=int(get_now().timestamp() * 1000),
838
+ updated=int(get_now().timestamp() * 1000),
839
+ ),
840
+ summary=SessionSummary(
841
+ files=0,
842
+ additions=0,
843
+ deletions=0,
844
+ ),
845
+ )
846
+
847
+ # Write session file
848
+ new_session_path.write_text(
849
+ anyenv.dump_json(new_session.model_dump(by_alias=True), indent=True),
850
+ encoding="utf-8",
851
+ )
852
+
853
+ # Create message and part directories
854
+ (self.messages_path / new_conversation_id).mkdir(parents=True, exist_ok=True)
855
+ (self.parts_path / new_conversation_id).mkdir(parents=True, exist_ok=True)
856
+
857
+ return fork_point_id
858
+
859
+
860
+ if __name__ == "__main__":
861
+ import asyncio
862
+ import datetime as dt
863
+
864
+ from agentpool_config.storage import OpenCodeStorageConfig
865
+ from agentpool_storage.models import QueryFilters, StatsFilters
866
+
867
+ async def main() -> None:
868
+ config = OpenCodeStorageConfig()
869
+ provider = OpenCodeStorageProvider(config)
870
+
871
+ print(f"Base path: {provider.base_path}")
872
+ print(f"Exists: {provider.base_path.exists()}")
873
+
874
+ # List conversations
875
+ filters = QueryFilters(limit=10)
876
+ conversations = await provider.get_conversations(filters)
877
+ print(f"\nFound {len(conversations)} conversations")
878
+
879
+ for conv_data, messages in conversations[:5]:
880
+ print(f" - {conv_data['id'][:8]}... | {conv_data['title'] or 'Untitled'}")
881
+ print(f" Messages: {len(messages)}, Updated: {conv_data['start_time']}")
882
+
883
+ # Get counts
884
+ conv_count, msg_count = await provider.get_conversation_counts()
885
+ print(f"\nTotal: {conv_count} conversations, {msg_count} messages")
886
+
887
+ # Get stats
888
+ stats_filters = StatsFilters(
889
+ cutoff=dt.datetime.now(dt.UTC) - dt.timedelta(days=30),
890
+ group_by="day",
891
+ )
892
+ stats = await provider.get_conversation_stats(stats_filters)
893
+ print(f"\nStats: {stats}")
894
+
895
+ asyncio.run(main())