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.
- acp/__init__.py +0 -4
- acp/acp_requests.py +20 -77
- acp/agent/connection.py +8 -0
- acp/agent/implementations/debug_server/debug_server.py +6 -2
- acp/agent/protocol.py +6 -0
- acp/client/connection.py +38 -29
- acp/client/implementations/default_client.py +3 -2
- acp/client/implementations/headless_client.py +2 -2
- acp/connection.py +2 -2
- acp/notifications.py +18 -49
- acp/schema/__init__.py +2 -0
- acp/schema/agent_responses.py +21 -0
- acp/schema/client_requests.py +3 -3
- acp/schema/session_state.py +63 -29
- acp/task/supervisor.py +2 -2
- acp/utils.py +2 -2
- agentpool/__init__.py +2 -0
- agentpool/agents/acp_agent/acp_agent.py +278 -263
- agentpool/agents/acp_agent/acp_converters.py +150 -17
- agentpool/agents/acp_agent/client_handler.py +35 -24
- agentpool/agents/acp_agent/session_state.py +14 -6
- agentpool/agents/agent.py +471 -643
- agentpool/agents/agui_agent/agui_agent.py +104 -107
- agentpool/agents/agui_agent/helpers.py +3 -4
- agentpool/agents/base_agent.py +485 -32
- agentpool/agents/claude_code_agent/FORKING.md +191 -0
- agentpool/agents/claude_code_agent/__init__.py +13 -1
- agentpool/agents/claude_code_agent/claude_code_agent.py +654 -334
- agentpool/agents/claude_code_agent/converters.py +4 -141
- agentpool/agents/claude_code_agent/models.py +77 -0
- agentpool/agents/claude_code_agent/static_info.py +100 -0
- agentpool/agents/claude_code_agent/usage.py +242 -0
- agentpool/agents/events/__init__.py +22 -0
- agentpool/agents/events/builtin_handlers.py +65 -0
- agentpool/agents/events/event_emitter.py +3 -0
- agentpool/agents/events/events.py +84 -3
- agentpool/agents/events/infer_info.py +145 -0
- agentpool/agents/events/processors.py +254 -0
- agentpool/agents/interactions.py +41 -6
- agentpool/agents/modes.py +13 -0
- agentpool/agents/slashed_agent.py +5 -4
- agentpool/agents/tool_wrapping.py +18 -6
- agentpool/common_types.py +35 -21
- agentpool/config_resources/acp_assistant.yml +2 -2
- agentpool/config_resources/agents.yml +3 -0
- agentpool/config_resources/agents_template.yml +1 -0
- agentpool/config_resources/claude_code_agent.yml +9 -8
- agentpool/config_resources/external_acp_agents.yml +2 -1
- agentpool/delegation/base_team.py +4 -30
- agentpool/delegation/pool.py +104 -265
- agentpool/delegation/team.py +57 -57
- agentpool/delegation/teamrun.py +50 -55
- agentpool/functional/run.py +10 -4
- agentpool/mcp_server/client.py +73 -38
- agentpool/mcp_server/conversions.py +54 -13
- agentpool/mcp_server/manager.py +9 -23
- agentpool/mcp_server/registries/official_registry_client.py +10 -1
- agentpool/mcp_server/tool_bridge.py +114 -79
- agentpool/messaging/connection_manager.py +11 -10
- agentpool/messaging/event_manager.py +5 -5
- agentpool/messaging/message_container.py +6 -30
- agentpool/messaging/message_history.py +87 -8
- agentpool/messaging/messagenode.py +52 -14
- agentpool/messaging/messages.py +2 -26
- agentpool/messaging/processing.py +10 -22
- agentpool/models/__init__.py +1 -1
- agentpool/models/acp_agents/base.py +6 -2
- agentpool/models/acp_agents/mcp_capable.py +124 -15
- agentpool/models/acp_agents/non_mcp.py +0 -23
- agentpool/models/agents.py +66 -66
- agentpool/models/agui_agents.py +1 -1
- agentpool/models/claude_code_agents.py +111 -17
- agentpool/models/file_parsing.py +0 -1
- agentpool/models/manifest.py +70 -50
- agentpool/prompts/conversion_manager.py +1 -1
- agentpool/prompts/prompts.py +5 -2
- agentpool/resource_providers/__init__.py +2 -0
- agentpool/resource_providers/aggregating.py +4 -2
- agentpool/resource_providers/base.py +13 -3
- agentpool/resource_providers/codemode/code_executor.py +72 -5
- agentpool/resource_providers/codemode/helpers.py +2 -2
- agentpool/resource_providers/codemode/provider.py +64 -12
- agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
- agentpool/resource_providers/codemode/remote_provider.py +9 -12
- agentpool/resource_providers/filtering.py +3 -1
- agentpool/resource_providers/mcp_provider.py +66 -12
- agentpool/resource_providers/plan_provider.py +111 -18
- agentpool/resource_providers/pool.py +5 -3
- agentpool/resource_providers/resource_info.py +111 -0
- agentpool/resource_providers/static.py +2 -2
- agentpool/sessions/__init__.py +2 -0
- agentpool/sessions/manager.py +2 -3
- agentpool/sessions/models.py +9 -6
- agentpool/sessions/protocol.py +28 -0
- agentpool/sessions/session.py +11 -55
- agentpool/storage/manager.py +361 -54
- agentpool/talk/registry.py +4 -4
- agentpool/talk/talk.py +9 -10
- agentpool/testing.py +1 -1
- agentpool/tool_impls/__init__.py +6 -0
- agentpool/tool_impls/agent_cli/__init__.py +42 -0
- agentpool/tool_impls/agent_cli/tool.py +95 -0
- agentpool/tool_impls/bash/__init__.py +64 -0
- agentpool/tool_impls/bash/helpers.py +35 -0
- agentpool/tool_impls/bash/tool.py +171 -0
- agentpool/tool_impls/delete_path/__init__.py +70 -0
- agentpool/tool_impls/delete_path/tool.py +142 -0
- agentpool/tool_impls/download_file/__init__.py +80 -0
- agentpool/tool_impls/download_file/tool.py +183 -0
- agentpool/tool_impls/execute_code/__init__.py +55 -0
- agentpool/tool_impls/execute_code/tool.py +163 -0
- agentpool/tool_impls/grep/__init__.py +80 -0
- agentpool/tool_impls/grep/tool.py +200 -0
- agentpool/tool_impls/list_directory/__init__.py +73 -0
- agentpool/tool_impls/list_directory/tool.py +197 -0
- agentpool/tool_impls/question/__init__.py +42 -0
- agentpool/tool_impls/question/tool.py +127 -0
- agentpool/tool_impls/read/__init__.py +104 -0
- agentpool/tool_impls/read/tool.py +305 -0
- agentpool/tools/__init__.py +2 -1
- agentpool/tools/base.py +114 -34
- agentpool/tools/manager.py +57 -1
- agentpool/ui/base.py +2 -2
- agentpool/ui/mock_provider.py +2 -2
- agentpool/ui/stdlib_provider.py +2 -2
- agentpool/utils/streams.py +21 -96
- agentpool/vfs_registry.py +7 -2
- {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/METADATA +16 -22
- {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/RECORD +242 -195
- {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
- agentpool_cli/__main__.py +20 -0
- agentpool_cli/create.py +1 -1
- agentpool_cli/serve_acp.py +59 -1
- agentpool_cli/serve_opencode.py +1 -1
- agentpool_cli/ui.py +557 -0
- agentpool_commands/__init__.py +12 -5
- agentpool_commands/agents.py +1 -1
- agentpool_commands/pool.py +260 -0
- agentpool_commands/session.py +1 -1
- agentpool_commands/text_sharing/__init__.py +119 -0
- agentpool_commands/text_sharing/base.py +123 -0
- agentpool_commands/text_sharing/github_gist.py +80 -0
- agentpool_commands/text_sharing/opencode.py +462 -0
- agentpool_commands/text_sharing/paste_rs.py +59 -0
- agentpool_commands/text_sharing/pastebin.py +116 -0
- agentpool_commands/text_sharing/shittycodingagent.py +112 -0
- agentpool_commands/utils.py +31 -32
- agentpool_config/__init__.py +30 -2
- agentpool_config/agentpool_tools.py +498 -0
- agentpool_config/converters.py +1 -1
- agentpool_config/event_handlers.py +42 -0
- agentpool_config/events.py +1 -1
- agentpool_config/forward_targets.py +1 -4
- agentpool_config/jinja.py +3 -3
- agentpool_config/mcp_server.py +1 -5
- agentpool_config/nodes.py +1 -1
- agentpool_config/observability.py +44 -0
- agentpool_config/session.py +0 -3
- agentpool_config/storage.py +38 -39
- agentpool_config/task.py +3 -3
- agentpool_config/tools.py +11 -28
- agentpool_config/toolsets.py +22 -90
- agentpool_server/a2a_server/agent_worker.py +307 -0
- agentpool_server/a2a_server/server.py +23 -18
- agentpool_server/acp_server/acp_agent.py +125 -56
- agentpool_server/acp_server/commands/acp_commands.py +46 -216
- agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +8 -7
- agentpool_server/acp_server/event_converter.py +651 -0
- agentpool_server/acp_server/input_provider.py +53 -10
- agentpool_server/acp_server/server.py +1 -11
- agentpool_server/acp_server/session.py +90 -410
- agentpool_server/acp_server/session_manager.py +8 -34
- agentpool_server/agui_server/server.py +3 -1
- agentpool_server/mcp_server/server.py +5 -2
- agentpool_server/opencode_server/ENDPOINTS.md +53 -14
- agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
- agentpool_server/opencode_server/__init__.py +0 -8
- agentpool_server/opencode_server/converters.py +132 -26
- agentpool_server/opencode_server/input_provider.py +160 -8
- agentpool_server/opencode_server/models/__init__.py +42 -20
- agentpool_server/opencode_server/models/app.py +12 -0
- agentpool_server/opencode_server/models/events.py +203 -29
- agentpool_server/opencode_server/models/mcp.py +19 -0
- agentpool_server/opencode_server/models/message.py +18 -1
- agentpool_server/opencode_server/models/parts.py +134 -1
- agentpool_server/opencode_server/models/question.py +56 -0
- agentpool_server/opencode_server/models/session.py +13 -1
- agentpool_server/opencode_server/routes/__init__.py +4 -0
- agentpool_server/opencode_server/routes/agent_routes.py +33 -2
- agentpool_server/opencode_server/routes/app_routes.py +66 -3
- agentpool_server/opencode_server/routes/config_routes.py +66 -5
- agentpool_server/opencode_server/routes/file_routes.py +184 -5
- agentpool_server/opencode_server/routes/global_routes.py +1 -1
- agentpool_server/opencode_server/routes/lsp_routes.py +1 -1
- agentpool_server/opencode_server/routes/message_routes.py +122 -66
- agentpool_server/opencode_server/routes/permission_routes.py +63 -0
- agentpool_server/opencode_server/routes/pty_routes.py +23 -22
- agentpool_server/opencode_server/routes/question_routes.py +128 -0
- agentpool_server/opencode_server/routes/session_routes.py +139 -68
- agentpool_server/opencode_server/routes/tui_routes.py +1 -1
- agentpool_server/opencode_server/server.py +47 -2
- agentpool_server/opencode_server/state.py +30 -0
- agentpool_storage/__init__.py +0 -4
- agentpool_storage/base.py +81 -2
- agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
- agentpool_storage/claude_provider/__init__.py +42 -0
- agentpool_storage/{claude_provider.py → claude_provider/provider.py} +190 -8
- agentpool_storage/file_provider.py +149 -15
- agentpool_storage/memory_provider.py +132 -12
- agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
- agentpool_storage/opencode_provider/__init__.py +16 -0
- agentpool_storage/opencode_provider/helpers.py +414 -0
- agentpool_storage/opencode_provider/provider.py +895 -0
- agentpool_storage/session_store.py +20 -6
- agentpool_storage/sql_provider/sql_provider.py +135 -2
- agentpool_storage/sql_provider/utils.py +2 -12
- agentpool_storage/zed_provider/__init__.py +16 -0
- agentpool_storage/zed_provider/helpers.py +281 -0
- agentpool_storage/zed_provider/models.py +130 -0
- agentpool_storage/zed_provider/provider.py +442 -0
- agentpool_storage/zed_provider.py +803 -0
- agentpool_toolsets/__init__.py +0 -2
- agentpool_toolsets/builtin/__init__.py +2 -4
- agentpool_toolsets/builtin/code.py +4 -4
- agentpool_toolsets/builtin/debug.py +115 -40
- agentpool_toolsets/builtin/execution_environment.py +54 -165
- agentpool_toolsets/builtin/skills.py +0 -77
- agentpool_toolsets/builtin/subagent_tools.py +64 -51
- agentpool_toolsets/builtin/workers.py +4 -2
- agentpool_toolsets/composio_toolset.py +2 -2
- agentpool_toolsets/entry_points.py +3 -1
- agentpool_toolsets/fsspec_toolset/grep.py +25 -5
- agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
- agentpool_toolsets/fsspec_toolset/toolset.py +350 -66
- agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
- agentpool_toolsets/mcp_discovery/toolset.py +74 -17
- agentpool_toolsets/mcp_run_toolset.py +8 -11
- agentpool_toolsets/notifications.py +33 -33
- agentpool_toolsets/openapi.py +3 -1
- agentpool_toolsets/search_toolset.py +3 -1
- agentpool_config/resources.py +0 -33
- agentpool_server/acp_server/acp_tools.py +0 -43
- agentpool_server/acp_server/commands/spawn.py +0 -210
- agentpool_storage/opencode_provider.py +0 -730
- agentpool_storage/text_log_provider.py +0 -276
- agentpool_toolsets/builtin/chain.py +0 -288
- agentpool_toolsets/builtin/user_interaction.py +0 -52
- agentpool_toolsets/semantic_memory_toolset.py +0 -536
- {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
- {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())
|