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.
- acp/__init__.py +13 -0
- acp/bridge/README.md +15 -2
- acp/bridge/__init__.py +3 -2
- acp/bridge/__main__.py +60 -19
- acp/bridge/ws_server.py +173 -0
- acp/bridge/ws_server_cli.py +89 -0
- acp/notifications.py +2 -1
- acp/stdio.py +39 -9
- acp/transports.py +362 -2
- acp/utils.py +15 -2
- agentpool/__init__.py +4 -1
- agentpool/agents/__init__.py +2 -0
- agentpool/agents/acp_agent/acp_agent.py +203 -88
- agentpool/agents/acp_agent/acp_converters.py +46 -21
- agentpool/agents/acp_agent/client_handler.py +157 -3
- agentpool/agents/acp_agent/session_state.py +4 -1
- agentpool/agents/agent.py +314 -107
- agentpool/agents/agui_agent/__init__.py +0 -2
- agentpool/agents/agui_agent/agui_agent.py +90 -21
- agentpool/agents/agui_agent/agui_converters.py +0 -131
- agentpool/agents/base_agent.py +163 -1
- agentpool/agents/claude_code_agent/claude_code_agent.py +626 -179
- agentpool/agents/claude_code_agent/converters.py +71 -3
- agentpool/agents/claude_code_agent/history.py +474 -0
- agentpool/agents/context.py +40 -0
- agentpool/agents/events/__init__.py +2 -0
- agentpool/agents/events/builtin_handlers.py +2 -1
- agentpool/agents/events/event_emitter.py +29 -2
- agentpool/agents/events/events.py +20 -0
- agentpool/agents/modes.py +54 -0
- agentpool/agents/tool_call_accumulator.py +213 -0
- agentpool/common_types.py +21 -0
- agentpool/config_resources/__init__.py +38 -1
- agentpool/config_resources/claude_code_agent.yml +3 -0
- agentpool/delegation/pool.py +37 -29
- agentpool/delegation/team.py +1 -0
- agentpool/delegation/teamrun.py +1 -0
- agentpool/diagnostics/__init__.py +53 -0
- agentpool/diagnostics/lsp_manager.py +1593 -0
- agentpool/diagnostics/lsp_proxy.py +41 -0
- agentpool/diagnostics/lsp_proxy_script.py +229 -0
- agentpool/diagnostics/models.py +398 -0
- agentpool/mcp_server/__init__.py +0 -2
- agentpool/mcp_server/client.py +12 -3
- agentpool/mcp_server/manager.py +25 -31
- agentpool/mcp_server/registries/official_registry_client.py +25 -0
- agentpool/mcp_server/tool_bridge.py +78 -66
- agentpool/messaging/__init__.py +0 -2
- agentpool/messaging/compaction.py +72 -197
- agentpool/messaging/message_history.py +12 -0
- agentpool/messaging/messages.py +52 -9
- agentpool/messaging/processing.py +3 -1
- agentpool/models/acp_agents/base.py +0 -22
- agentpool/models/acp_agents/mcp_capable.py +8 -148
- agentpool/models/acp_agents/non_mcp.py +129 -72
- agentpool/models/agents.py +35 -13
- agentpool/models/claude_code_agents.py +33 -2
- agentpool/models/manifest.py +43 -0
- agentpool/repomap.py +1 -1
- agentpool/resource_providers/__init__.py +9 -1
- agentpool/resource_providers/aggregating.py +52 -3
- agentpool/resource_providers/base.py +57 -1
- agentpool/resource_providers/mcp_provider.py +23 -0
- agentpool/resource_providers/plan_provider.py +130 -41
- agentpool/resource_providers/pool.py +2 -0
- agentpool/resource_providers/static.py +2 -0
- agentpool/sessions/__init__.py +2 -1
- agentpool/sessions/manager.py +31 -2
- agentpool/sessions/models.py +50 -0
- agentpool/skills/registry.py +13 -8
- agentpool/storage/manager.py +217 -1
- agentpool/testing.py +537 -19
- agentpool/utils/file_watcher.py +269 -0
- agentpool/utils/identifiers.py +121 -0
- agentpool/utils/pydantic_ai_helpers.py +46 -0
- agentpool/utils/streams.py +690 -1
- agentpool/utils/subprocess_utils.py +155 -0
- agentpool/utils/token_breakdown.py +461 -0
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/METADATA +27 -7
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/RECORD +170 -112
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/WHEEL +1 -1
- agentpool_cli/__main__.py +4 -0
- agentpool_cli/serve_acp.py +41 -20
- agentpool_cli/serve_agui.py +87 -0
- agentpool_cli/serve_opencode.py +119 -0
- agentpool_commands/__init__.py +30 -0
- agentpool_commands/agents.py +74 -1
- agentpool_commands/history.py +62 -0
- agentpool_commands/mcp.py +176 -0
- agentpool_commands/models.py +56 -3
- agentpool_commands/tools.py +57 -0
- agentpool_commands/utils.py +51 -0
- agentpool_config/builtin_tools.py +77 -22
- agentpool_config/commands.py +24 -1
- agentpool_config/compaction.py +258 -0
- agentpool_config/mcp_server.py +131 -1
- agentpool_config/storage.py +46 -1
- agentpool_config/tools.py +7 -1
- agentpool_config/toolsets.py +92 -148
- agentpool_server/acp_server/acp_agent.py +134 -150
- agentpool_server/acp_server/commands/acp_commands.py +216 -51
- agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +10 -10
- agentpool_server/acp_server/server.py +23 -79
- agentpool_server/acp_server/session.py +181 -19
- agentpool_server/opencode_server/.rules +95 -0
- agentpool_server/opencode_server/ENDPOINTS.md +362 -0
- agentpool_server/opencode_server/__init__.py +27 -0
- agentpool_server/opencode_server/command_validation.py +172 -0
- agentpool_server/opencode_server/converters.py +869 -0
- agentpool_server/opencode_server/dependencies.py +24 -0
- agentpool_server/opencode_server/input_provider.py +269 -0
- agentpool_server/opencode_server/models/__init__.py +228 -0
- agentpool_server/opencode_server/models/agent.py +53 -0
- agentpool_server/opencode_server/models/app.py +60 -0
- agentpool_server/opencode_server/models/base.py +26 -0
- agentpool_server/opencode_server/models/common.py +23 -0
- agentpool_server/opencode_server/models/config.py +37 -0
- agentpool_server/opencode_server/models/events.py +647 -0
- agentpool_server/opencode_server/models/file.py +88 -0
- agentpool_server/opencode_server/models/mcp.py +25 -0
- agentpool_server/opencode_server/models/message.py +162 -0
- agentpool_server/opencode_server/models/parts.py +190 -0
- agentpool_server/opencode_server/models/provider.py +81 -0
- agentpool_server/opencode_server/models/pty.py +43 -0
- agentpool_server/opencode_server/models/session.py +99 -0
- agentpool_server/opencode_server/routes/__init__.py +25 -0
- agentpool_server/opencode_server/routes/agent_routes.py +442 -0
- agentpool_server/opencode_server/routes/app_routes.py +139 -0
- agentpool_server/opencode_server/routes/config_routes.py +241 -0
- agentpool_server/opencode_server/routes/file_routes.py +392 -0
- agentpool_server/opencode_server/routes/global_routes.py +94 -0
- agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
- agentpool_server/opencode_server/routes/message_routes.py +705 -0
- agentpool_server/opencode_server/routes/pty_routes.py +299 -0
- agentpool_server/opencode_server/routes/session_routes.py +1205 -0
- agentpool_server/opencode_server/routes/tui_routes.py +139 -0
- agentpool_server/opencode_server/server.py +430 -0
- agentpool_server/opencode_server/state.py +121 -0
- agentpool_server/opencode_server/time_utils.py +8 -0
- agentpool_storage/__init__.py +16 -0
- agentpool_storage/base.py +103 -0
- agentpool_storage/claude_provider.py +907 -0
- agentpool_storage/file_provider.py +129 -0
- agentpool_storage/memory_provider.py +61 -0
- agentpool_storage/models.py +3 -0
- agentpool_storage/opencode_provider.py +730 -0
- agentpool_storage/project_store.py +325 -0
- agentpool_storage/session_store.py +6 -0
- agentpool_storage/sql_provider/__init__.py +4 -2
- agentpool_storage/sql_provider/models.py +48 -0
- agentpool_storage/sql_provider/sql_provider.py +134 -1
- agentpool_storage/sql_provider/utils.py +10 -1
- agentpool_storage/text_log_provider.py +1 -0
- agentpool_toolsets/builtin/__init__.py +0 -8
- agentpool_toolsets/builtin/code.py +95 -56
- agentpool_toolsets/builtin/debug.py +16 -21
- agentpool_toolsets/builtin/execution_environment.py +99 -103
- agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
- agentpool_toolsets/builtin/skills.py +86 -4
- agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
- agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
- agentpool_toolsets/fsspec_toolset/grep.py +74 -2
- agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
- agentpool_toolsets/fsspec_toolset/toolset.py +159 -38
- agentpool_toolsets/mcp_discovery/__init__.py +5 -0
- agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
- agentpool_toolsets/mcp_discovery/toolset.py +454 -0
- agentpool_toolsets/mcp_run_toolset.py +84 -6
- agentpool_toolsets/builtin/agent_management.py +0 -239
- agentpool_toolsets/builtin/history.py +0 -36
- agentpool_toolsets/builtin/integration.py +0 -85
- agentpool_toolsets/builtin/tool_management.py +0 -90
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/entry_points.txt +0 -0
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
"""OpenCode storage provider - reads/writes to ~/.local/share/opencode format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
|
10
|
+
|
|
11
|
+
import anyenv
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
|
13
|
+
from pydantic_ai import RunUsage
|
|
14
|
+
from pydantic_ai.messages import (
|
|
15
|
+
ModelRequest,
|
|
16
|
+
ModelResponse,
|
|
17
|
+
TextPart,
|
|
18
|
+
ThinkingPart,
|
|
19
|
+
ToolCallPart,
|
|
20
|
+
ToolReturnPart,
|
|
21
|
+
UserPromptPart,
|
|
22
|
+
)
|
|
23
|
+
from pydantic_ai.usage import RequestUsage
|
|
24
|
+
|
|
25
|
+
from agentpool.log import get_logger
|
|
26
|
+
from agentpool.messaging import ChatMessage, TokenCost
|
|
27
|
+
from agentpool.utils.now import get_now
|
|
28
|
+
from agentpool_storage.base import StorageProvider
|
|
29
|
+
from agentpool_storage.models import TokenUsage
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import Sequence
|
|
34
|
+
|
|
35
|
+
from agentpool_config.session import SessionQuery
|
|
36
|
+
from agentpool_config.storage import OpenCodeStorageConfig
|
|
37
|
+
from agentpool_storage.models import (
|
|
38
|
+
ConversationData,
|
|
39
|
+
MessageData,
|
|
40
|
+
QueryFilters,
|
|
41
|
+
StatsFilters,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
logger = get_logger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# OpenCode data models
|
|
48
|
+
|
|
49
|
+
PartType = Literal["text", "step-start", "step-finish", "reasoning", "tool", "patch", "compaction"]
|
|
50
|
+
ToolStatus = Literal["pending", "running", "completed", "error"]
|
|
51
|
+
FinishReason = Literal["stop", "tool-calls", "length", "error"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class OpenCodeTime(BaseModel):
|
|
55
|
+
"""Timestamp fields used in OpenCode."""
|
|
56
|
+
|
|
57
|
+
created: int # Unix timestamp in milliseconds
|
|
58
|
+
updated: int | None = None
|
|
59
|
+
completed: int | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OpenCodeSummary(BaseModel):
|
|
63
|
+
"""Summary information for sessions/messages."""
|
|
64
|
+
|
|
65
|
+
additions: int | None = None
|
|
66
|
+
deletions: int | None = None
|
|
67
|
+
files: int = 0
|
|
68
|
+
title: str | None = None
|
|
69
|
+
diffs: list[Any] = Field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class OpenCodeModel(BaseModel):
|
|
73
|
+
"""Model information in messages."""
|
|
74
|
+
|
|
75
|
+
provider_id: str = Field(alias="providerID")
|
|
76
|
+
model_id: str = Field(alias="modelID")
|
|
77
|
+
|
|
78
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OpenCodePath(BaseModel):
|
|
82
|
+
"""Path context in messages."""
|
|
83
|
+
|
|
84
|
+
cwd: str
|
|
85
|
+
root: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class OpenCodeTokens(BaseModel):
|
|
89
|
+
"""Token usage information."""
|
|
90
|
+
|
|
91
|
+
input: int = 0
|
|
92
|
+
output: int = 0
|
|
93
|
+
reasoning: int = 0
|
|
94
|
+
cache: dict[str, int] = Field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class OpenCodeSession(BaseModel):
|
|
98
|
+
"""OpenCode session metadata."""
|
|
99
|
+
|
|
100
|
+
id: str
|
|
101
|
+
version: str
|
|
102
|
+
project_id: str = Field(alias="projectID")
|
|
103
|
+
directory: str
|
|
104
|
+
title: str
|
|
105
|
+
time: OpenCodeTime
|
|
106
|
+
summary: OpenCodeSummary = Field(default_factory=OpenCodeSummary)
|
|
107
|
+
|
|
108
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class OpenCodeMessage(BaseModel):
|
|
112
|
+
"""OpenCode message metadata."""
|
|
113
|
+
|
|
114
|
+
id: str
|
|
115
|
+
session_id: str = Field(alias="sessionID")
|
|
116
|
+
role: Literal["user", "assistant"]
|
|
117
|
+
time: OpenCodeTime
|
|
118
|
+
summary: OpenCodeSummary | bool | None = None
|
|
119
|
+
agent: str | None = None
|
|
120
|
+
model: OpenCodeModel | None = None
|
|
121
|
+
parent_id: str | None = Field(default=None, alias="parentID")
|
|
122
|
+
model_id: str | None = Field(default=None, alias="modelID")
|
|
123
|
+
provider_id: str | None = Field(default=None, alias="providerID")
|
|
124
|
+
mode: str | None = None
|
|
125
|
+
path: OpenCodePath | None = None
|
|
126
|
+
cost: float | None = None
|
|
127
|
+
tokens: OpenCodeTokens | None = None
|
|
128
|
+
finish: FinishReason | None = None
|
|
129
|
+
|
|
130
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class OpenCodeToolState(BaseModel):
|
|
134
|
+
"""Tool execution state."""
|
|
135
|
+
|
|
136
|
+
status: ToolStatus
|
|
137
|
+
input: dict[str, Any] | None = None
|
|
138
|
+
output: str | None = None
|
|
139
|
+
title: str | None = None
|
|
140
|
+
metadata: dict[str, Any] | None = None
|
|
141
|
+
time: dict[str, int] | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class OpenCodePartBase(BaseModel):
|
|
145
|
+
"""Base for all part types."""
|
|
146
|
+
|
|
147
|
+
id: str
|
|
148
|
+
session_id: str = Field(alias="sessionID")
|
|
149
|
+
message_id: str = Field(alias="messageID")
|
|
150
|
+
type: PartType
|
|
151
|
+
|
|
152
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class OpenCodeTextPart(OpenCodePartBase):
|
|
156
|
+
"""Text content part."""
|
|
157
|
+
|
|
158
|
+
type: Literal["text"]
|
|
159
|
+
text: str
|
|
160
|
+
time: dict[str, int] | None = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class OpenCodeReasoningPart(OpenCodePartBase):
|
|
164
|
+
"""Reasoning/thinking content part."""
|
|
165
|
+
|
|
166
|
+
type: Literal["reasoning"]
|
|
167
|
+
text: str
|
|
168
|
+
time: dict[str, int] | None = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class OpenCodeToolPart(OpenCodePartBase):
|
|
172
|
+
"""Tool call/result part."""
|
|
173
|
+
|
|
174
|
+
type: Literal["tool"]
|
|
175
|
+
call_id: str = Field(alias="callID")
|
|
176
|
+
tool: str
|
|
177
|
+
state: OpenCodeToolState
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class OpenCodeStepStartPart(OpenCodePartBase):
|
|
181
|
+
"""Step start marker."""
|
|
182
|
+
|
|
183
|
+
type: Literal["step-start"]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class OpenCodeStepFinishPart(OpenCodePartBase):
|
|
187
|
+
"""Step finish marker with stats."""
|
|
188
|
+
|
|
189
|
+
type: Literal["step-finish"]
|
|
190
|
+
reason: FinishReason | None = None
|
|
191
|
+
cost: float | None = None
|
|
192
|
+
tokens: OpenCodeTokens | None = None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class OpenCodePatchPart(OpenCodePartBase):
|
|
196
|
+
"""File patch/diff part."""
|
|
197
|
+
|
|
198
|
+
type: Literal["patch"]
|
|
199
|
+
hash: str | None = None
|
|
200
|
+
files: list[str] = Field(default_factory=list)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class OpenCodeCompactionPart(OpenCodePartBase):
|
|
204
|
+
"""Compaction marker."""
|
|
205
|
+
|
|
206
|
+
type: Literal["compaction"]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# Discriminated union for all part types
|
|
210
|
+
OpenCodePart = Annotated[
|
|
211
|
+
OpenCodeTextPart
|
|
212
|
+
| OpenCodeReasoningPart
|
|
213
|
+
| OpenCodeToolPart
|
|
214
|
+
| OpenCodeStepStartPart
|
|
215
|
+
| OpenCodeStepFinishPart
|
|
216
|
+
| OpenCodePatchPart
|
|
217
|
+
| OpenCodeCompactionPart,
|
|
218
|
+
Field(discriminator="type"),
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class OpenCodeStorageProvider(StorageProvider):
|
|
223
|
+
"""Storage provider that reads/writes OpenCode's native format.
|
|
224
|
+
|
|
225
|
+
OpenCode stores data in:
|
|
226
|
+
- ~/.local/share/opencode/storage/session/{project_id}/ - Session JSON files
|
|
227
|
+
- ~/.local/share/opencode/storage/message/{session_id}/ - Message JSON files
|
|
228
|
+
- ~/.local/share/opencode/storage/part/{message_id}/ - Part JSON files
|
|
229
|
+
|
|
230
|
+
Each file is a single JSON object (not JSONL).
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
can_load_history = True
|
|
234
|
+
|
|
235
|
+
def __init__(self, config: OpenCodeStorageConfig) -> None:
|
|
236
|
+
"""Initialize OpenCode storage provider."""
|
|
237
|
+
super().__init__(config)
|
|
238
|
+
self.base_path = Path(config.path).expanduser()
|
|
239
|
+
self.sessions_path = self.base_path / "session"
|
|
240
|
+
self.messages_path = self.base_path / "message"
|
|
241
|
+
self.parts_path = self.base_path / "part"
|
|
242
|
+
|
|
243
|
+
def _ms_to_datetime(self, ms: int) -> datetime:
|
|
244
|
+
"""Convert milliseconds timestamp to datetime."""
|
|
245
|
+
return datetime.fromtimestamp(ms / 1000, tz=UTC)
|
|
246
|
+
|
|
247
|
+
def _list_sessions(self, project_id: str | None = None) -> list[tuple[str, Path]]:
|
|
248
|
+
"""List all sessions, optionally filtered by project."""
|
|
249
|
+
sessions: list[tuple[str, Path]] = []
|
|
250
|
+
if not self.sessions_path.exists():
|
|
251
|
+
return sessions
|
|
252
|
+
|
|
253
|
+
if project_id:
|
|
254
|
+
project_dir = self.sessions_path / project_id
|
|
255
|
+
if project_dir.exists():
|
|
256
|
+
sessions.extend((f.stem, f) for f in project_dir.glob("*.json"))
|
|
257
|
+
else:
|
|
258
|
+
for project_dir in self.sessions_path.iterdir():
|
|
259
|
+
if project_dir.is_dir():
|
|
260
|
+
sessions.extend((f.stem, f) for f in project_dir.glob("*.json"))
|
|
261
|
+
return sessions
|
|
262
|
+
|
|
263
|
+
def _read_session(self, session_path: Path) -> OpenCodeSession | None:
|
|
264
|
+
"""Read session metadata."""
|
|
265
|
+
if not session_path.exists():
|
|
266
|
+
return None
|
|
267
|
+
try:
|
|
268
|
+
content = session_path.read_text(encoding="utf-8")
|
|
269
|
+
return anyenv.load_json(content, return_type=OpenCodeSession)
|
|
270
|
+
except anyenv.JsonLoadError as e:
|
|
271
|
+
logger.warning("Failed to parse session", path=str(session_path), error=str(e))
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
def _read_messages(self, session_id: str) -> list[OpenCodeMessage]:
|
|
275
|
+
"""Read all messages for a session."""
|
|
276
|
+
messages: list[OpenCodeMessage] = []
|
|
277
|
+
msg_dir = self.messages_path / session_id
|
|
278
|
+
if not msg_dir.exists():
|
|
279
|
+
return messages
|
|
280
|
+
|
|
281
|
+
for msg_file in sorted(msg_dir.glob("*.json")):
|
|
282
|
+
try:
|
|
283
|
+
content = msg_file.read_text(encoding="utf-8")
|
|
284
|
+
data = anyenv.load_json(content, return_type=OpenCodeMessage)
|
|
285
|
+
messages.append(data)
|
|
286
|
+
except anyenv.JsonLoadError as e:
|
|
287
|
+
logger.warning("Failed to parse message", path=str(msg_file), error=str(e))
|
|
288
|
+
return messages
|
|
289
|
+
|
|
290
|
+
def _read_parts(self, message_id: str) -> list[OpenCodePart]:
|
|
291
|
+
"""Read all parts for a message."""
|
|
292
|
+
parts: list[OpenCodePart] = []
|
|
293
|
+
parts_dir = self.parts_path / message_id
|
|
294
|
+
if not parts_dir.exists():
|
|
295
|
+
return parts
|
|
296
|
+
|
|
297
|
+
adapter = TypeAdapter[Any](OpenCodePart)
|
|
298
|
+
for part_file in sorted(parts_dir.glob("*.json")):
|
|
299
|
+
try:
|
|
300
|
+
content = part_file.read_text(encoding="utf-8")
|
|
301
|
+
data = anyenv.load_json(content)
|
|
302
|
+
parts.append(adapter.validate_python(data))
|
|
303
|
+
except anyenv.JsonLoadError as e:
|
|
304
|
+
logger.warning("Failed to parse part", path=str(part_file), error=str(e))
|
|
305
|
+
return parts
|
|
306
|
+
|
|
307
|
+
def _build_tool_id_mapping(self, parts: list[OpenCodePart]) -> dict[str, str]:
|
|
308
|
+
"""Build mapping from tool callID to tool name."""
|
|
309
|
+
mapping: dict[str, str] = {}
|
|
310
|
+
for part in parts:
|
|
311
|
+
if isinstance(part, OpenCodeToolPart):
|
|
312
|
+
mapping[part.call_id] = part.tool
|
|
313
|
+
return mapping
|
|
314
|
+
|
|
315
|
+
def _message_to_chat_message(
|
|
316
|
+
self,
|
|
317
|
+
msg: OpenCodeMessage,
|
|
318
|
+
parts: list[OpenCodePart],
|
|
319
|
+
conversation_id: str,
|
|
320
|
+
tool_id_mapping: dict[str, str] | None = None,
|
|
321
|
+
) -> ChatMessage[str]:
|
|
322
|
+
"""Convert OpenCode message + parts to ChatMessage."""
|
|
323
|
+
timestamp = self._ms_to_datetime(msg.time.created)
|
|
324
|
+
|
|
325
|
+
# Extract text content for display
|
|
326
|
+
content = self._extract_text_content(parts)
|
|
327
|
+
|
|
328
|
+
# Build pydantic-ai messages
|
|
329
|
+
pydantic_messages = self._build_pydantic_messages(
|
|
330
|
+
msg, parts, timestamp, tool_id_mapping or {}
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Extract cost info
|
|
334
|
+
cost_info = None
|
|
335
|
+
if msg.tokens:
|
|
336
|
+
input_tokens = msg.tokens.input + msg.tokens.cache.get("read", 0)
|
|
337
|
+
output_tokens = msg.tokens.output
|
|
338
|
+
if input_tokens or output_tokens:
|
|
339
|
+
cost_info = TokenCost(
|
|
340
|
+
token_usage=RunUsage(
|
|
341
|
+
input_tokens=input_tokens,
|
|
342
|
+
output_tokens=output_tokens,
|
|
343
|
+
),
|
|
344
|
+
total_cost=Decimal(str(msg.cost)) if msg.cost else Decimal(0),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Get model name
|
|
348
|
+
model_name = msg.model_id
|
|
349
|
+
if not model_name and msg.model:
|
|
350
|
+
model_name = msg.model.model_id
|
|
351
|
+
|
|
352
|
+
return ChatMessage[str](
|
|
353
|
+
content=content,
|
|
354
|
+
conversation_id=conversation_id,
|
|
355
|
+
role=msg.role,
|
|
356
|
+
message_id=msg.id,
|
|
357
|
+
name=msg.agent,
|
|
358
|
+
model_name=model_name,
|
|
359
|
+
cost_info=cost_info,
|
|
360
|
+
timestamp=timestamp,
|
|
361
|
+
parent_id=msg.parent_id,
|
|
362
|
+
messages=pydantic_messages,
|
|
363
|
+
provider_details={"finish_reason": msg.finish} if msg.finish else {},
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def _extract_text_content(self, parts: list[OpenCodePart]) -> str:
|
|
367
|
+
"""Extract text content from parts for display."""
|
|
368
|
+
text_parts: list[str] = []
|
|
369
|
+
for part in parts:
|
|
370
|
+
if isinstance(part, OpenCodeTextPart) and part.text:
|
|
371
|
+
text_parts.append(part.text)
|
|
372
|
+
elif isinstance(part, OpenCodeReasoningPart) and part.text:
|
|
373
|
+
text_parts.append(f"<thinking>\n{part.text}\n</thinking>")
|
|
374
|
+
return "\n".join(text_parts)
|
|
375
|
+
|
|
376
|
+
def _build_pydantic_messages(
|
|
377
|
+
self,
|
|
378
|
+
msg: OpenCodeMessage,
|
|
379
|
+
parts: list[OpenCodePart],
|
|
380
|
+
timestamp: datetime,
|
|
381
|
+
tool_id_mapping: dict[str, str],
|
|
382
|
+
) -> list[ModelRequest | ModelResponse]:
|
|
383
|
+
"""Build pydantic-ai ModelRequest and/or ModelResponse.
|
|
384
|
+
|
|
385
|
+
In OpenCode's model, assistant messages contain both tool calls AND their
|
|
386
|
+
results in the same message. We split these into:
|
|
387
|
+
- ModelResponse with ToolCallPart (the call)
|
|
388
|
+
- ModelRequest with ToolReturnPart (the result)
|
|
389
|
+
"""
|
|
390
|
+
result: list[ModelRequest | ModelResponse] = []
|
|
391
|
+
|
|
392
|
+
if msg.role == "user":
|
|
393
|
+
request_parts: list[UserPromptPart | ToolReturnPart] = [
|
|
394
|
+
UserPromptPart(content=part.text, timestamp=timestamp)
|
|
395
|
+
for part in parts
|
|
396
|
+
if isinstance(part, OpenCodeTextPart) and part.text
|
|
397
|
+
]
|
|
398
|
+
if request_parts:
|
|
399
|
+
result.append(ModelRequest(parts=request_parts, timestamp=timestamp))
|
|
400
|
+
return result
|
|
401
|
+
|
|
402
|
+
# Assistant message - may contain both tool calls and results
|
|
403
|
+
response_parts: list[TextPart | ToolCallPart | ThinkingPart] = []
|
|
404
|
+
tool_return_parts: list[ToolReturnPart] = []
|
|
405
|
+
|
|
406
|
+
# Build usage
|
|
407
|
+
usage = RequestUsage()
|
|
408
|
+
if msg.tokens:
|
|
409
|
+
usage = RequestUsage(
|
|
410
|
+
input_tokens=msg.tokens.input,
|
|
411
|
+
output_tokens=msg.tokens.output,
|
|
412
|
+
cache_read_tokens=msg.tokens.cache.get("read", 0),
|
|
413
|
+
cache_write_tokens=msg.tokens.cache.get("write", 0),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
for part in parts:
|
|
417
|
+
if isinstance(part, OpenCodeTextPart) and part.text:
|
|
418
|
+
response_parts.append(TextPart(content=part.text))
|
|
419
|
+
elif isinstance(part, OpenCodeReasoningPart) and part.text:
|
|
420
|
+
response_parts.append(ThinkingPart(content=part.text))
|
|
421
|
+
elif isinstance(part, OpenCodeToolPart):
|
|
422
|
+
# Add tool call to response
|
|
423
|
+
args = part.state.input or {}
|
|
424
|
+
tc_part = ToolCallPart(tool_name=part.tool, args=args, tool_call_id=part.call_id)
|
|
425
|
+
response_parts.append(tc_part)
|
|
426
|
+
# If completed, also create a tool return
|
|
427
|
+
if part.state.status == "completed" and part.state.output:
|
|
428
|
+
return_part = ToolReturnPart(
|
|
429
|
+
tool_name=part.tool,
|
|
430
|
+
content=part.state.output,
|
|
431
|
+
tool_call_id=part.call_id,
|
|
432
|
+
timestamp=timestamp,
|
|
433
|
+
)
|
|
434
|
+
tool_return_parts.append(return_part)
|
|
435
|
+
|
|
436
|
+
# Add the response if we have parts
|
|
437
|
+
if response_parts:
|
|
438
|
+
model_name = msg.model_id or (msg.model.model_id if msg.model else None)
|
|
439
|
+
result.append(
|
|
440
|
+
ModelResponse(
|
|
441
|
+
parts=response_parts,
|
|
442
|
+
usage=usage,
|
|
443
|
+
model_name=model_name,
|
|
444
|
+
timestamp=timestamp,
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# Add tool returns as a separate request (simulating user sending results back)
|
|
449
|
+
if tool_return_parts:
|
|
450
|
+
result.append(ModelRequest(parts=tool_return_parts, timestamp=timestamp))
|
|
451
|
+
|
|
452
|
+
return result
|
|
453
|
+
|
|
454
|
+
async def filter_messages(self, query: SessionQuery) -> list[ChatMessage[str]]:
|
|
455
|
+
"""Filter messages based on query."""
|
|
456
|
+
messages: list[ChatMessage[str]] = []
|
|
457
|
+
sessions = self._list_sessions()
|
|
458
|
+
|
|
459
|
+
for session_id, session_path in sessions:
|
|
460
|
+
if query.name and session_id != query.name:
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
session = self._read_session(session_path)
|
|
464
|
+
if not session:
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
oc_messages = self._read_messages(session_id)
|
|
468
|
+
|
|
469
|
+
# Build tool mapping from all parts
|
|
470
|
+
all_parts: list[OpenCodePart] = []
|
|
471
|
+
msg_parts_map: dict[str, list[OpenCodePart]] = {}
|
|
472
|
+
for oc_msg in oc_messages:
|
|
473
|
+
parts = self._read_parts(oc_msg.id)
|
|
474
|
+
msg_parts_map[oc_msg.id] = parts
|
|
475
|
+
all_parts.extend(parts)
|
|
476
|
+
tool_mapping = self._build_tool_id_mapping(all_parts)
|
|
477
|
+
|
|
478
|
+
for oc_msg in oc_messages:
|
|
479
|
+
parts = msg_parts_map.get(oc_msg.id, [])
|
|
480
|
+
chat_msg = self._message_to_chat_message(oc_msg, parts, session_id, tool_mapping)
|
|
481
|
+
|
|
482
|
+
# Apply filters
|
|
483
|
+
if query.agents and chat_msg.name not in query.agents:
|
|
484
|
+
continue
|
|
485
|
+
cutoff = query.get_time_cutoff()
|
|
486
|
+
if query.since and cutoff and chat_msg.timestamp < cutoff:
|
|
487
|
+
continue
|
|
488
|
+
if query.until:
|
|
489
|
+
until_dt = datetime.fromisoformat(query.until)
|
|
490
|
+
if chat_msg.timestamp > until_dt:
|
|
491
|
+
continue
|
|
492
|
+
if query.contains and query.contains not in chat_msg.content:
|
|
493
|
+
continue
|
|
494
|
+
if query.roles and chat_msg.role not in query.roles:
|
|
495
|
+
continue
|
|
496
|
+
messages.append(chat_msg)
|
|
497
|
+
|
|
498
|
+
if query.limit and len(messages) >= query.limit:
|
|
499
|
+
return messages
|
|
500
|
+
|
|
501
|
+
return messages
|
|
502
|
+
|
|
503
|
+
async def log_message(
|
|
504
|
+
self,
|
|
505
|
+
*,
|
|
506
|
+
message_id: str,
|
|
507
|
+
conversation_id: str,
|
|
508
|
+
content: str,
|
|
509
|
+
role: str,
|
|
510
|
+
name: str | None = None,
|
|
511
|
+
parent_id: str | None = None,
|
|
512
|
+
cost_info: TokenCost | None = None,
|
|
513
|
+
model: str | None = None,
|
|
514
|
+
response_time: float | None = None,
|
|
515
|
+
forwarded_from: list[str] | None = None,
|
|
516
|
+
provider_name: str | None = None,
|
|
517
|
+
provider_response_id: str | None = None,
|
|
518
|
+
messages: str | None = None,
|
|
519
|
+
finish_reason: Any | None = None,
|
|
520
|
+
) -> None:
|
|
521
|
+
"""Log a message to OpenCode format.
|
|
522
|
+
|
|
523
|
+
Note: Writing to OpenCode format is not fully implemented.
|
|
524
|
+
"""
|
|
525
|
+
logger.warning("Writing to OpenCode format is not fully supported")
|
|
526
|
+
|
|
527
|
+
async def log_conversation(
|
|
528
|
+
self,
|
|
529
|
+
*,
|
|
530
|
+
conversation_id: str,
|
|
531
|
+
node_name: str,
|
|
532
|
+
start_time: datetime | None = None,
|
|
533
|
+
) -> None:
|
|
534
|
+
"""Log a conversation start."""
|
|
535
|
+
# No-op for read-only provider
|
|
536
|
+
|
|
537
|
+
async def get_conversations(
|
|
538
|
+
self,
|
|
539
|
+
filters: QueryFilters,
|
|
540
|
+
) -> list[tuple[ConversationData, Sequence[ChatMessage[str]]]]:
|
|
541
|
+
"""Get filtered conversations with their messages."""
|
|
542
|
+
from agentpool_storage.models import ConversationData as ConvData
|
|
543
|
+
|
|
544
|
+
result: list[tuple[ConvData, Sequence[ChatMessage[str]]]] = []
|
|
545
|
+
sessions = self._list_sessions()
|
|
546
|
+
for session_id, session_path in sessions:
|
|
547
|
+
session = self._read_session(session_path)
|
|
548
|
+
if not session:
|
|
549
|
+
continue
|
|
550
|
+
|
|
551
|
+
oc_messages = self._read_messages(session_id)
|
|
552
|
+
if not oc_messages:
|
|
553
|
+
continue
|
|
554
|
+
|
|
555
|
+
# Build tool mapping
|
|
556
|
+
all_parts: list[OpenCodePart] = []
|
|
557
|
+
msg_parts_map: dict[str, list[OpenCodePart]] = {}
|
|
558
|
+
for oc_msg in oc_messages:
|
|
559
|
+
parts = self._read_parts(oc_msg.id)
|
|
560
|
+
msg_parts_map[oc_msg.id] = parts
|
|
561
|
+
all_parts.extend(parts)
|
|
562
|
+
tool_mapping = self._build_tool_id_mapping(all_parts)
|
|
563
|
+
|
|
564
|
+
# Convert messages
|
|
565
|
+
chat_messages: list[ChatMessage[str]] = []
|
|
566
|
+
total_tokens = 0
|
|
567
|
+
total_cost = 0.0
|
|
568
|
+
|
|
569
|
+
for oc_msg in oc_messages:
|
|
570
|
+
parts = msg_parts_map.get(oc_msg.id, [])
|
|
571
|
+
chat_msg = self._message_to_chat_message(oc_msg, parts, session_id, tool_mapping)
|
|
572
|
+
chat_messages.append(chat_msg)
|
|
573
|
+
|
|
574
|
+
if oc_msg.tokens:
|
|
575
|
+
total_tokens += oc_msg.tokens.input + oc_msg.tokens.output
|
|
576
|
+
if oc_msg.cost:
|
|
577
|
+
total_cost += oc_msg.cost
|
|
578
|
+
|
|
579
|
+
if not chat_messages:
|
|
580
|
+
continue
|
|
581
|
+
|
|
582
|
+
first_timestamp = self._ms_to_datetime(session.time.created)
|
|
583
|
+
|
|
584
|
+
# Apply filters
|
|
585
|
+
if filters.agent_name and not any(m.name == filters.agent_name for m in chat_messages):
|
|
586
|
+
continue
|
|
587
|
+
|
|
588
|
+
if filters.since and first_timestamp < filters.since:
|
|
589
|
+
continue
|
|
590
|
+
|
|
591
|
+
if filters.query and not any(filters.query in m.content for m in chat_messages):
|
|
592
|
+
continue
|
|
593
|
+
|
|
594
|
+
# Build MessageData list
|
|
595
|
+
msg_data_list: list[MessageData] = []
|
|
596
|
+
for chat_msg in chat_messages:
|
|
597
|
+
msg_data: MessageData = {
|
|
598
|
+
"role": chat_msg.role,
|
|
599
|
+
"content": chat_msg.content,
|
|
600
|
+
"timestamp": (chat_msg.timestamp or get_now()).isoformat(),
|
|
601
|
+
"parent_id": chat_msg.parent_id,
|
|
602
|
+
"model": chat_msg.model_name,
|
|
603
|
+
"name": chat_msg.name,
|
|
604
|
+
"token_usage": TokenUsage(
|
|
605
|
+
total=chat_msg.cost_info.token_usage.total_tokens
|
|
606
|
+
if chat_msg.cost_info
|
|
607
|
+
else 0,
|
|
608
|
+
prompt=chat_msg.cost_info.token_usage.input_tokens
|
|
609
|
+
if chat_msg.cost_info
|
|
610
|
+
else 0,
|
|
611
|
+
completion=chat_msg.cost_info.token_usage.output_tokens
|
|
612
|
+
if chat_msg.cost_info
|
|
613
|
+
else 0,
|
|
614
|
+
)
|
|
615
|
+
if chat_msg.cost_info
|
|
616
|
+
else None,
|
|
617
|
+
"cost": float(chat_msg.cost_info.total_cost) if chat_msg.cost_info else None,
|
|
618
|
+
"response_time": chat_msg.response_time,
|
|
619
|
+
}
|
|
620
|
+
msg_data_list.append(msg_data)
|
|
621
|
+
|
|
622
|
+
token_usage_data: TokenUsage | None = (
|
|
623
|
+
{"total": total_tokens, "prompt": 0, "completion": 0} if total_tokens else None
|
|
624
|
+
)
|
|
625
|
+
conv_data = ConvData(
|
|
626
|
+
id=session_id,
|
|
627
|
+
agent=chat_messages[0].name or "opencode",
|
|
628
|
+
title=session.title,
|
|
629
|
+
start_time=first_timestamp.isoformat(),
|
|
630
|
+
messages=msg_data_list,
|
|
631
|
+
token_usage=token_usage_data,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
result.append((conv_data, chat_messages))
|
|
635
|
+
|
|
636
|
+
if filters.limit and len(result) >= filters.limit:
|
|
637
|
+
break
|
|
638
|
+
|
|
639
|
+
return result
|
|
640
|
+
|
|
641
|
+
async def get_conversation_stats(
|
|
642
|
+
self,
|
|
643
|
+
filters: StatsFilters,
|
|
644
|
+
) -> dict[str, dict[str, Any]]:
|
|
645
|
+
"""Get conversation statistics."""
|
|
646
|
+
stats: dict[str, dict[str, Any]] = defaultdict(
|
|
647
|
+
lambda: {"total_tokens": 0, "messages": 0, "models": set(), "total_cost": 0.0}
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
sessions = self._list_sessions()
|
|
651
|
+
|
|
652
|
+
for _session_id, session_path in sessions:
|
|
653
|
+
session = self._read_session(session_path)
|
|
654
|
+
if not session:
|
|
655
|
+
continue
|
|
656
|
+
|
|
657
|
+
timestamp = self._ms_to_datetime(session.time.created)
|
|
658
|
+
if timestamp < filters.cutoff:
|
|
659
|
+
continue
|
|
660
|
+
|
|
661
|
+
oc_messages = self._read_messages(session.id)
|
|
662
|
+
|
|
663
|
+
for oc_msg in oc_messages:
|
|
664
|
+
if oc_msg.role != "assistant":
|
|
665
|
+
continue
|
|
666
|
+
|
|
667
|
+
model = oc_msg.model_id or (oc_msg.model.model_id if oc_msg.model else "unknown")
|
|
668
|
+
tokens = 0
|
|
669
|
+
if oc_msg.tokens:
|
|
670
|
+
tokens = oc_msg.tokens.input + oc_msg.tokens.output
|
|
671
|
+
|
|
672
|
+
msg_timestamp = self._ms_to_datetime(oc_msg.time.created)
|
|
673
|
+
|
|
674
|
+
# Group by specified criterion
|
|
675
|
+
match filters.group_by:
|
|
676
|
+
case "model":
|
|
677
|
+
key = model
|
|
678
|
+
case "hour":
|
|
679
|
+
key = msg_timestamp.strftime("%Y-%m-%d %H:00")
|
|
680
|
+
case "day":
|
|
681
|
+
key = msg_timestamp.strftime("%Y-%m-%d")
|
|
682
|
+
case _:
|
|
683
|
+
key = oc_msg.agent or "opencode"
|
|
684
|
+
|
|
685
|
+
stats[key]["messages"] += 1
|
|
686
|
+
stats[key]["total_tokens"] += tokens
|
|
687
|
+
stats[key]["models"].add(model)
|
|
688
|
+
stats[key]["total_cost"] += oc_msg.cost or 0.0
|
|
689
|
+
|
|
690
|
+
# Convert sets to lists
|
|
691
|
+
for value in stats.values():
|
|
692
|
+
value["models"] = list(value["models"])
|
|
693
|
+
|
|
694
|
+
return dict(stats)
|
|
695
|
+
|
|
696
|
+
async def reset(
|
|
697
|
+
self,
|
|
698
|
+
*,
|
|
699
|
+
agent_name: str | None = None,
|
|
700
|
+
hard: bool = False,
|
|
701
|
+
) -> tuple[int, int]:
|
|
702
|
+
"""Reset storage.
|
|
703
|
+
|
|
704
|
+
Warning: This would delete OpenCode data!
|
|
705
|
+
"""
|
|
706
|
+
logger.warning("Reset not implemented for OpenCode storage (read-only)")
|
|
707
|
+
return 0, 0
|
|
708
|
+
|
|
709
|
+
async def get_conversation_counts(
|
|
710
|
+
self,
|
|
711
|
+
*,
|
|
712
|
+
agent_name: str | None = None,
|
|
713
|
+
) -> tuple[int, int]:
|
|
714
|
+
"""Get counts of conversations and messages."""
|
|
715
|
+
conv_count = 0
|
|
716
|
+
msg_count = 0
|
|
717
|
+
|
|
718
|
+
sessions = self._list_sessions()
|
|
719
|
+
|
|
720
|
+
for session_id, session_path in sessions:
|
|
721
|
+
session = self._read_session(session_path)
|
|
722
|
+
if not session:
|
|
723
|
+
continue
|
|
724
|
+
|
|
725
|
+
oc_messages = self._read_messages(session_id)
|
|
726
|
+
if oc_messages:
|
|
727
|
+
conv_count += 1
|
|
728
|
+
msg_count += len(oc_messages)
|
|
729
|
+
|
|
730
|
+
return conv_count, msg_count
|