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