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,705 @@
|
|
|
1
|
+
"""Message routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
8
|
+
from pydantic_ai import FunctionToolCallEvent
|
|
9
|
+
from pydantic_ai.messages import (
|
|
10
|
+
PartDeltaEvent,
|
|
11
|
+
PartStartEvent,
|
|
12
|
+
TextPart as PydanticTextPart,
|
|
13
|
+
TextPartDelta,
|
|
14
|
+
ToolCallPart as PydanticToolCallPart,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from agentpool.agents.claude_code_agent.converters import derive_rich_tool_info
|
|
18
|
+
from agentpool.agents.events import (
|
|
19
|
+
CompactionEvent,
|
|
20
|
+
FileContentItem,
|
|
21
|
+
LocationContentItem,
|
|
22
|
+
StreamCompleteEvent,
|
|
23
|
+
TextContentItem,
|
|
24
|
+
ToolCallCompleteEvent,
|
|
25
|
+
ToolCallProgressEvent,
|
|
26
|
+
ToolCallStartEvent,
|
|
27
|
+
)
|
|
28
|
+
from agentpool.messaging.messages import ChatMessage
|
|
29
|
+
from agentpool.utils import identifiers as identifier
|
|
30
|
+
from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict
|
|
31
|
+
from agentpool_server.opencode_server.converters import (
|
|
32
|
+
_convert_params_for_ui,
|
|
33
|
+
extract_user_prompt_from_parts,
|
|
34
|
+
opencode_to_chat_message,
|
|
35
|
+
)
|
|
36
|
+
from agentpool_server.opencode_server.dependencies import StateDep # noqa: TC001
|
|
37
|
+
from agentpool_server.opencode_server.models import ( # noqa: TC001
|
|
38
|
+
AssistantMessage,
|
|
39
|
+
MessagePath,
|
|
40
|
+
MessageRequest,
|
|
41
|
+
MessageTime,
|
|
42
|
+
MessageUpdatedEvent,
|
|
43
|
+
MessageWithParts,
|
|
44
|
+
Part,
|
|
45
|
+
PartUpdatedEvent,
|
|
46
|
+
SessionCompactedEvent,
|
|
47
|
+
SessionErrorEvent,
|
|
48
|
+
SessionIdleEvent,
|
|
49
|
+
SessionStatus,
|
|
50
|
+
SessionStatusEvent,
|
|
51
|
+
SessionUpdatedEvent,
|
|
52
|
+
StepFinishPart,
|
|
53
|
+
StepStartPart,
|
|
54
|
+
TextPart,
|
|
55
|
+
TimeCreated,
|
|
56
|
+
TimeCreatedUpdated,
|
|
57
|
+
TimeStartEnd,
|
|
58
|
+
Tokens,
|
|
59
|
+
TokensCache,
|
|
60
|
+
ToolPart,
|
|
61
|
+
ToolStateCompleted,
|
|
62
|
+
ToolStateError,
|
|
63
|
+
ToolStateRunning,
|
|
64
|
+
UserMessage,
|
|
65
|
+
)
|
|
66
|
+
from agentpool_server.opencode_server.models.message import UserMessageModel
|
|
67
|
+
from agentpool_server.opencode_server.models.parts import (
|
|
68
|
+
StepFinishTokens,
|
|
69
|
+
TimeStart,
|
|
70
|
+
TimeStartEndCompacted,
|
|
71
|
+
TimeStartEndOptional,
|
|
72
|
+
TokenCache,
|
|
73
|
+
)
|
|
74
|
+
from agentpool_server.opencode_server.routes.session_routes import (
|
|
75
|
+
get_or_load_session,
|
|
76
|
+
opencode_to_session_data,
|
|
77
|
+
)
|
|
78
|
+
from agentpool_server.opencode_server.time_utils import now_ms
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if TYPE_CHECKING:
|
|
82
|
+
from agentpool_server.opencode_server.state import ServerState
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _warmup_lsp_for_files(state: ServerState, file_paths: list[str]) -> None:
|
|
86
|
+
"""Warm up LSP servers for the given file paths.
|
|
87
|
+
|
|
88
|
+
This starts LSP servers asynchronously based on file extensions.
|
|
89
|
+
Like OpenCode's LSP.touchFile(), this triggers server startup without waiting.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
state: Server state with LSP manager
|
|
93
|
+
file_paths: List of file paths that were accessed
|
|
94
|
+
"""
|
|
95
|
+
import logging
|
|
96
|
+
|
|
97
|
+
logging.getLogger(__name__)
|
|
98
|
+
print(f"[LSP] _warmup_lsp_for_files called with: {file_paths}")
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
lsp_manager = state.get_or_create_lsp_manager()
|
|
102
|
+
print("[LSP] Got LSP manager successfully")
|
|
103
|
+
except RuntimeError as e:
|
|
104
|
+
# No execution environment available for LSP
|
|
105
|
+
print(f"[LSP] No LSP manager: {e}")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
async def warmup_files() -> None:
|
|
109
|
+
"""Start LSP servers for each file path."""
|
|
110
|
+
print("[LSP] warmup_files task started")
|
|
111
|
+
from agentpool_server.opencode_server.models.events import LspUpdatedEvent
|
|
112
|
+
|
|
113
|
+
servers_started = False
|
|
114
|
+
for path in file_paths:
|
|
115
|
+
# Find appropriate server for this file
|
|
116
|
+
server_info = lsp_manager.get_server_for_file(path)
|
|
117
|
+
print(f"[LSP] Server for {path}: {server_info.id if server_info else None}")
|
|
118
|
+
if server_info is None:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
server_id = server_info.id
|
|
122
|
+
if lsp_manager.is_running(server_id):
|
|
123
|
+
print(f"[LSP] Server {server_id} already running")
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# Start server for workspace root
|
|
127
|
+
root_uri = f"file://{state.working_dir}"
|
|
128
|
+
try:
|
|
129
|
+
print(f"[LSP] Starting server {server_id}...")
|
|
130
|
+
await lsp_manager.start_server(server_id, root_uri)
|
|
131
|
+
servers_started = True
|
|
132
|
+
print(f"[LSP] Server {server_id} started successfully")
|
|
133
|
+
except Exception as e: # noqa: BLE001
|
|
134
|
+
# Don't fail on LSP startup errors
|
|
135
|
+
print(f"[LSP] Failed to start server {server_id}: {e}")
|
|
136
|
+
|
|
137
|
+
# Emit lsp.updated event if any servers started
|
|
138
|
+
if servers_started:
|
|
139
|
+
print("[LSP] Broadcasting LspUpdatedEvent")
|
|
140
|
+
await state.broadcast_event(LspUpdatedEvent.create())
|
|
141
|
+
print("[LSP] warmup_files task completed")
|
|
142
|
+
|
|
143
|
+
# Run warmup in background (don't block the event handler)
|
|
144
|
+
print("[LSP] Creating background task for warmup")
|
|
145
|
+
state.create_background_task(warmup_files(), name="lsp-warmup")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def persist_message_to_storage(
|
|
149
|
+
state: ServerState,
|
|
150
|
+
msg: MessageWithParts,
|
|
151
|
+
session_id: str,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Persist an OpenCode message to storage.
|
|
154
|
+
|
|
155
|
+
Converts the OpenCode MessageWithParts to ChatMessage and saves it.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
state: Server state with pool reference
|
|
159
|
+
msg: OpenCode message to persist
|
|
160
|
+
session_id: Session/conversation ID
|
|
161
|
+
"""
|
|
162
|
+
if state.pool.storage is None:
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
# Convert to ChatMessage
|
|
167
|
+
chat_msg = opencode_to_chat_message(msg, conversation_id=session_id)
|
|
168
|
+
# Persist via storage manager
|
|
169
|
+
await state.pool.storage.log_message(chat_msg)
|
|
170
|
+
except Exception: # noqa: BLE001
|
|
171
|
+
# Don't fail the request if storage fails
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def _generate_session_title(
|
|
176
|
+
state: ServerState,
|
|
177
|
+
session_id: str,
|
|
178
|
+
user_prompt: str,
|
|
179
|
+
assistant_response: str,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Generate a title for the session in the background."""
|
|
182
|
+
try:
|
|
183
|
+
if not state.pool.storage:
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
# Create ChatMessage objects for the title generator
|
|
187
|
+
messages = [
|
|
188
|
+
ChatMessage[str](role="user", content=user_prompt),
|
|
189
|
+
ChatMessage[str](role="assistant", content=assistant_response),
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
# Generate title using storage manager
|
|
193
|
+
title = await state.pool.storage.generate_conversation_title(
|
|
194
|
+
messages=messages,
|
|
195
|
+
conversation_id=session_id,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if title and session_id in state.sessions:
|
|
199
|
+
# Update session with new title
|
|
200
|
+
session = state.sessions[session_id]
|
|
201
|
+
updated_session = session.model_copy(update={"title": title})
|
|
202
|
+
state.sessions[session_id] = updated_session
|
|
203
|
+
session_data = opencode_to_session_data(
|
|
204
|
+
updated_session,
|
|
205
|
+
agent_name=state.agent.name,
|
|
206
|
+
pool_id=state.pool.manifest.config_file_path,
|
|
207
|
+
)
|
|
208
|
+
await state.pool.sessions.store.save(session_data)
|
|
209
|
+
|
|
210
|
+
# Broadcast session update
|
|
211
|
+
await state.broadcast_event(SessionUpdatedEvent.create(updated_session))
|
|
212
|
+
except Exception: # noqa: BLE001
|
|
213
|
+
# Don't fail if title generation fails
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
router = APIRouter(prefix="/session/{session_id}", tags=["message"])
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@router.get("/message")
|
|
221
|
+
async def list_messages(
|
|
222
|
+
session_id: str,
|
|
223
|
+
state: StateDep,
|
|
224
|
+
limit: int | None = Query(default=None),
|
|
225
|
+
) -> list[MessageWithParts]:
|
|
226
|
+
"""List messages in a session."""
|
|
227
|
+
session = await get_or_load_session(state, session_id)
|
|
228
|
+
if session is None:
|
|
229
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
230
|
+
|
|
231
|
+
messages = state.messages.get(session_id, [])
|
|
232
|
+
if limit:
|
|
233
|
+
messages = messages[-limit:]
|
|
234
|
+
return messages
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@router.post("/message")
|
|
238
|
+
async def send_message( # noqa: PLR0915
|
|
239
|
+
session_id: str,
|
|
240
|
+
request: MessageRequest,
|
|
241
|
+
state: StateDep,
|
|
242
|
+
) -> MessageWithParts:
|
|
243
|
+
"""Send a message and get response from the agent."""
|
|
244
|
+
session = await get_or_load_session(state, session_id)
|
|
245
|
+
if session is None:
|
|
246
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
247
|
+
|
|
248
|
+
now = now_ms()
|
|
249
|
+
# Create user message with sortable ID
|
|
250
|
+
user_msg_id = identifier.ascending("message", request.message_id)
|
|
251
|
+
user_message = UserMessage(
|
|
252
|
+
id=user_msg_id,
|
|
253
|
+
session_id=session_id,
|
|
254
|
+
time=TimeCreated(created=now),
|
|
255
|
+
agent=request.agent or "default",
|
|
256
|
+
model=UserMessageModel(
|
|
257
|
+
provider_id=request.model.provider_id if request.model else "agentpool",
|
|
258
|
+
model_id=request.model.model_id if request.model else "default",
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Create parts from request
|
|
263
|
+
user_parts: list[Part] = [
|
|
264
|
+
TextPart(
|
|
265
|
+
id=identifier.ascending("part"),
|
|
266
|
+
message_id=user_msg_id,
|
|
267
|
+
session_id=session_id,
|
|
268
|
+
text=part.text,
|
|
269
|
+
)
|
|
270
|
+
for part in request.parts
|
|
271
|
+
if part.type == "text"
|
|
272
|
+
]
|
|
273
|
+
user_msg_with_parts = MessageWithParts(info=user_message, parts=user_parts)
|
|
274
|
+
state.messages[session_id].append(user_msg_with_parts)
|
|
275
|
+
# Persist user message to storage
|
|
276
|
+
await persist_message_to_storage(state, user_msg_with_parts, session_id)
|
|
277
|
+
# Broadcast user message created event
|
|
278
|
+
await state.broadcast_event(MessageUpdatedEvent.create(user_message))
|
|
279
|
+
# Broadcast user message parts so they appear in UI
|
|
280
|
+
for part in user_parts:
|
|
281
|
+
await state.broadcast_event(PartUpdatedEvent.create(part))
|
|
282
|
+
state.session_status[session_id] = SessionStatus(type="busy")
|
|
283
|
+
await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="busy")))
|
|
284
|
+
# Extract user prompt text
|
|
285
|
+
user_prompt = extract_user_prompt_from_parts([p.model_dump() for p in request.parts])
|
|
286
|
+
# Create assistant message with sortable ID (must come after user message)
|
|
287
|
+
assistant_msg_id = identifier.ascending("message")
|
|
288
|
+
tokens = Tokens(cache=TokensCache(read=0, write=0))
|
|
289
|
+
assistant_message = AssistantMessage(
|
|
290
|
+
id=assistant_msg_id,
|
|
291
|
+
session_id=session_id,
|
|
292
|
+
parent_id=user_msg_id, # Link to user message
|
|
293
|
+
model_id=request.model.model_id if request.model else "default",
|
|
294
|
+
provider_id=request.model.provider_id if request.model else "agentpool",
|
|
295
|
+
mode=request.agent or "default",
|
|
296
|
+
agent=request.agent or "default",
|
|
297
|
+
path=MessagePath(cwd=state.working_dir, root=state.working_dir),
|
|
298
|
+
time=MessageTime(created=now, completed=None),
|
|
299
|
+
tokens=tokens,
|
|
300
|
+
cost=0.0,
|
|
301
|
+
)
|
|
302
|
+
# Initialize assistant message with empty parts
|
|
303
|
+
assistant_msg_with_parts = MessageWithParts(info=assistant_message, parts=[])
|
|
304
|
+
state.messages[session_id].append(assistant_msg_with_parts)
|
|
305
|
+
# Broadcast assistant message created
|
|
306
|
+
await state.broadcast_event(MessageUpdatedEvent.create(assistant_message))
|
|
307
|
+
# Add step-start part
|
|
308
|
+
step_start = StepStartPart(
|
|
309
|
+
id=identifier.ascending("part"),
|
|
310
|
+
message_id=assistant_msg_id,
|
|
311
|
+
session_id=session_id,
|
|
312
|
+
)
|
|
313
|
+
assistant_msg_with_parts.parts.append(step_start)
|
|
314
|
+
await state.broadcast_event(PartUpdatedEvent.create(step_start))
|
|
315
|
+
# Call the agent
|
|
316
|
+
response_text = ""
|
|
317
|
+
input_tokens = 0
|
|
318
|
+
output_tokens = 0
|
|
319
|
+
total_cost = 0.0 # Cost in dollars
|
|
320
|
+
tool_parts: dict[str, ToolPart] = {} # Track tool parts by call_id
|
|
321
|
+
tool_outputs: dict[str, str] = {} # Track accumulated output per tool call
|
|
322
|
+
tool_inputs: dict[str, dict[str, Any]] = {} # Track inputs per tool call
|
|
323
|
+
# Track streaming text part for incremental updates
|
|
324
|
+
text_part: TextPart | None = None
|
|
325
|
+
text_part_id: str | None = None
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
# Get the specified agent from the pool, or fall back to default
|
|
329
|
+
agent = state.agent
|
|
330
|
+
if request.agent and state.agent.agent_pool is not None:
|
|
331
|
+
agent = state.agent.agent_pool.all_agents.get(request.agent, state.agent)
|
|
332
|
+
|
|
333
|
+
# Stream events from the agent
|
|
334
|
+
async for event in agent.run_stream(user_prompt):
|
|
335
|
+
match event:
|
|
336
|
+
# Text streaming start
|
|
337
|
+
case PartStartEvent(part=PydanticTextPart(content=delta)):
|
|
338
|
+
response_text = delta
|
|
339
|
+
text_part_id = identifier.ascending("part")
|
|
340
|
+
text_part = TextPart(
|
|
341
|
+
id=text_part_id,
|
|
342
|
+
message_id=assistant_msg_id,
|
|
343
|
+
session_id=session_id,
|
|
344
|
+
text=delta,
|
|
345
|
+
)
|
|
346
|
+
assistant_msg_with_parts.parts.append(text_part)
|
|
347
|
+
await state.broadcast_event(PartUpdatedEvent.create(text_part, delta=delta))
|
|
348
|
+
|
|
349
|
+
# Text streaming delta
|
|
350
|
+
case PartDeltaEvent(delta=TextPartDelta(content_delta=delta)) if delta:
|
|
351
|
+
response_text += delta
|
|
352
|
+
if text_part is not None:
|
|
353
|
+
text_part = TextPart(
|
|
354
|
+
id=text_part.id,
|
|
355
|
+
message_id=assistant_msg_id,
|
|
356
|
+
session_id=session_id,
|
|
357
|
+
text=response_text,
|
|
358
|
+
)
|
|
359
|
+
# Update in parts list
|
|
360
|
+
for i, p in enumerate(assistant_msg_with_parts.parts):
|
|
361
|
+
if isinstance(p, TextPart) and p.id == text_part.id:
|
|
362
|
+
assistant_msg_with_parts.parts[i] = text_part
|
|
363
|
+
break
|
|
364
|
+
await state.broadcast_event(PartUpdatedEvent.create(text_part, delta=delta))
|
|
365
|
+
|
|
366
|
+
# Tool call start - from Claude Code agent or toolsets
|
|
367
|
+
case ToolCallStartEvent(
|
|
368
|
+
tool_name=tool_name,
|
|
369
|
+
tool_call_id=tool_call_id,
|
|
370
|
+
raw_input=raw_input,
|
|
371
|
+
title=title,
|
|
372
|
+
):
|
|
373
|
+
# Convert param names for OpenCode TUI compatibility
|
|
374
|
+
ui_input = _convert_params_for_ui(raw_input) if raw_input else {}
|
|
375
|
+
if tool_call_id in tool_parts:
|
|
376
|
+
# Update existing part with the custom title
|
|
377
|
+
existing = tool_parts[tool_call_id]
|
|
378
|
+
tool_inputs[tool_call_id] = ui_input or tool_inputs.get(tool_call_id, {})
|
|
379
|
+
|
|
380
|
+
updated = ToolPart(
|
|
381
|
+
id=existing.id,
|
|
382
|
+
message_id=existing.message_id,
|
|
383
|
+
session_id=existing.session_id,
|
|
384
|
+
tool=existing.tool,
|
|
385
|
+
call_id=existing.call_id,
|
|
386
|
+
state=ToolStateRunning(
|
|
387
|
+
status="running",
|
|
388
|
+
time=TimeStart(start=now_ms()),
|
|
389
|
+
input=tool_inputs[tool_call_id],
|
|
390
|
+
title=title,
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
tool_parts[tool_call_id] = updated
|
|
394
|
+
for i, p in enumerate(assistant_msg_with_parts.parts):
|
|
395
|
+
if isinstance(p, ToolPart) and p.id == existing.id:
|
|
396
|
+
assistant_msg_with_parts.parts[i] = updated
|
|
397
|
+
break
|
|
398
|
+
await state.broadcast_event(PartUpdatedEvent.create(updated))
|
|
399
|
+
else:
|
|
400
|
+
# Create new tool part with the title
|
|
401
|
+
tool_inputs[tool_call_id] = ui_input
|
|
402
|
+
tool_outputs[tool_call_id] = ""
|
|
403
|
+
tool_state = ToolStateRunning(
|
|
404
|
+
status="running",
|
|
405
|
+
time=TimeStart(start=now_ms()),
|
|
406
|
+
input=ui_input,
|
|
407
|
+
title=title,
|
|
408
|
+
)
|
|
409
|
+
tool_part = ToolPart(
|
|
410
|
+
id=identifier.ascending("part"),
|
|
411
|
+
message_id=assistant_msg_id,
|
|
412
|
+
session_id=session_id,
|
|
413
|
+
tool=tool_name,
|
|
414
|
+
call_id=tool_call_id,
|
|
415
|
+
state=tool_state,
|
|
416
|
+
)
|
|
417
|
+
tool_parts[tool_call_id] = tool_part
|
|
418
|
+
assistant_msg_with_parts.parts.append(tool_part)
|
|
419
|
+
await state.broadcast_event(PartUpdatedEvent.create(tool_part))
|
|
420
|
+
|
|
421
|
+
# Pydantic-ai tool call events (fallback for pydantic-ai agents)
|
|
422
|
+
case (
|
|
423
|
+
FunctionToolCallEvent(part=tc_part)
|
|
424
|
+
| PartStartEvent(part=PydanticToolCallPart() as tc_part)
|
|
425
|
+
) if tc_part.tool_call_id not in tool_parts:
|
|
426
|
+
tool_call_id = tc_part.tool_call_id
|
|
427
|
+
tool_name = tc_part.tool_name
|
|
428
|
+
raw_input = safe_args_as_dict(tc_part)
|
|
429
|
+
# Convert param names for OpenCode TUI compatibility
|
|
430
|
+
ui_input = _convert_params_for_ui(raw_input)
|
|
431
|
+
# Store input and initialize output accumulator
|
|
432
|
+
tool_inputs[tool_call_id] = ui_input
|
|
433
|
+
tool_outputs[tool_call_id] = ""
|
|
434
|
+
# Derive initial title; toolset events may update it later
|
|
435
|
+
rich_info = derive_rich_tool_info(tool_name, raw_input)
|
|
436
|
+
tool_state = ToolStateRunning(
|
|
437
|
+
status="running",
|
|
438
|
+
time=TimeStart(start=now_ms()),
|
|
439
|
+
input=ui_input,
|
|
440
|
+
title=rich_info.title,
|
|
441
|
+
)
|
|
442
|
+
tool_part = ToolPart(
|
|
443
|
+
id=identifier.ascending("part"),
|
|
444
|
+
message_id=assistant_msg_id,
|
|
445
|
+
session_id=session_id,
|
|
446
|
+
tool=tool_name,
|
|
447
|
+
call_id=tool_call_id,
|
|
448
|
+
state=tool_state,
|
|
449
|
+
)
|
|
450
|
+
tool_parts[tool_call_id] = tool_part
|
|
451
|
+
assistant_msg_with_parts.parts.append(tool_part)
|
|
452
|
+
await state.broadcast_event(PartUpdatedEvent.create(tool_part))
|
|
453
|
+
|
|
454
|
+
# Tool call progress
|
|
455
|
+
case ToolCallProgressEvent(
|
|
456
|
+
tool_call_id=tool_call_id,
|
|
457
|
+
title=title,
|
|
458
|
+
items=items,
|
|
459
|
+
tool_name=tool_name,
|
|
460
|
+
tool_input=event_tool_input,
|
|
461
|
+
) if tool_call_id:
|
|
462
|
+
# Extract text content from items and accumulate
|
|
463
|
+
new_output = ""
|
|
464
|
+
file_paths: list[str] = []
|
|
465
|
+
for item in items:
|
|
466
|
+
if isinstance(item, TextContentItem):
|
|
467
|
+
new_output += item.text
|
|
468
|
+
elif isinstance(item, FileContentItem):
|
|
469
|
+
new_output += item.content
|
|
470
|
+
file_paths.append(item.path)
|
|
471
|
+
elif isinstance(item, LocationContentItem):
|
|
472
|
+
file_paths.append(item.path)
|
|
473
|
+
|
|
474
|
+
# Warm up LSP servers for accessed files (async, don't wait)
|
|
475
|
+
if file_paths:
|
|
476
|
+
_warmup_lsp_for_files(state, file_paths)
|
|
477
|
+
|
|
478
|
+
# Accumulate output (OpenCode streams via metadata.output)
|
|
479
|
+
if new_output:
|
|
480
|
+
tool_outputs[tool_call_id] = tool_outputs.get(tool_call_id, "") + new_output
|
|
481
|
+
|
|
482
|
+
if tool_call_id in tool_parts:
|
|
483
|
+
# Update existing part
|
|
484
|
+
existing = tool_parts[tool_call_id]
|
|
485
|
+
existing_title = getattr(existing.state, "title", "")
|
|
486
|
+
tool_input = tool_inputs.get(tool_call_id, {})
|
|
487
|
+
accumulated_output = tool_outputs.get(tool_call_id, "")
|
|
488
|
+
tool_state = ToolStateRunning(
|
|
489
|
+
status="running",
|
|
490
|
+
time=TimeStart(start=now_ms()),
|
|
491
|
+
title=title or existing_title,
|
|
492
|
+
input=tool_input,
|
|
493
|
+
metadata={"output": accumulated_output} if accumulated_output else None,
|
|
494
|
+
)
|
|
495
|
+
updated = ToolPart(
|
|
496
|
+
id=existing.id,
|
|
497
|
+
message_id=existing.message_id,
|
|
498
|
+
session_id=existing.session_id,
|
|
499
|
+
tool=existing.tool,
|
|
500
|
+
call_id=existing.call_id,
|
|
501
|
+
state=tool_state,
|
|
502
|
+
)
|
|
503
|
+
tool_parts[tool_call_id] = updated
|
|
504
|
+
for i, p in enumerate(assistant_msg_with_parts.parts):
|
|
505
|
+
if isinstance(p, ToolPart) and p.id == existing.id:
|
|
506
|
+
assistant_msg_with_parts.parts[i] = updated
|
|
507
|
+
break
|
|
508
|
+
await state.broadcast_event(PartUpdatedEvent.create(updated))
|
|
509
|
+
else:
|
|
510
|
+
# Create new tool part from progress event
|
|
511
|
+
ui_input = (
|
|
512
|
+
_convert_params_for_ui(event_tool_input) if event_tool_input else {}
|
|
513
|
+
)
|
|
514
|
+
tool_inputs[tool_call_id] = ui_input
|
|
515
|
+
accumulated_output = tool_outputs.get(tool_call_id, "")
|
|
516
|
+
tool_state = ToolStateRunning(
|
|
517
|
+
status="running",
|
|
518
|
+
time=TimeStart(start=now_ms()),
|
|
519
|
+
input=ui_input,
|
|
520
|
+
title=title or tool_name or "Running...",
|
|
521
|
+
metadata={"output": accumulated_output} if accumulated_output else None,
|
|
522
|
+
)
|
|
523
|
+
tool_part = ToolPart(
|
|
524
|
+
id=identifier.ascending("part"),
|
|
525
|
+
message_id=assistant_msg_id,
|
|
526
|
+
session_id=session_id,
|
|
527
|
+
tool=tool_name or "unknown",
|
|
528
|
+
call_id=tool_call_id,
|
|
529
|
+
state=tool_state,
|
|
530
|
+
)
|
|
531
|
+
tool_parts[tool_call_id] = tool_part
|
|
532
|
+
assistant_msg_with_parts.parts.append(tool_part)
|
|
533
|
+
await state.broadcast_event(PartUpdatedEvent.create(tool_part))
|
|
534
|
+
|
|
535
|
+
# Tool call complete
|
|
536
|
+
case ToolCallCompleteEvent(
|
|
537
|
+
tool_call_id=tool_call_id,
|
|
538
|
+
tool_result=result,
|
|
539
|
+
) if tool_call_id in tool_parts:
|
|
540
|
+
existing = tool_parts[tool_call_id]
|
|
541
|
+
result_str = str(result) if result else ""
|
|
542
|
+
tool_input = tool_inputs.get(tool_call_id, {})
|
|
543
|
+
is_error = isinstance(result, dict) and result.get("error")
|
|
544
|
+
|
|
545
|
+
if is_error:
|
|
546
|
+
new_state: ToolStateCompleted | ToolStateError = ToolStateError(
|
|
547
|
+
status="error",
|
|
548
|
+
error=str(result.get("error", "Unknown error")),
|
|
549
|
+
input=tool_input,
|
|
550
|
+
time=TimeStartEnd(start=now, end=now_ms()),
|
|
551
|
+
)
|
|
552
|
+
else:
|
|
553
|
+
new_state = ToolStateCompleted(
|
|
554
|
+
status="completed",
|
|
555
|
+
title=f"Completed {existing.tool}",
|
|
556
|
+
input=tool_input,
|
|
557
|
+
output=result_str,
|
|
558
|
+
time=TimeStartEndCompacted(start=now, end=now_ms()),
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
updated = ToolPart(
|
|
562
|
+
id=existing.id,
|
|
563
|
+
message_id=existing.message_id,
|
|
564
|
+
session_id=existing.session_id,
|
|
565
|
+
tool=existing.tool,
|
|
566
|
+
call_id=existing.call_id,
|
|
567
|
+
state=new_state,
|
|
568
|
+
)
|
|
569
|
+
tool_parts[tool_call_id] = updated
|
|
570
|
+
for i, p in enumerate(assistant_msg_with_parts.parts):
|
|
571
|
+
if isinstance(p, ToolPart) and p.id == existing.id:
|
|
572
|
+
assistant_msg_with_parts.parts[i] = updated
|
|
573
|
+
break
|
|
574
|
+
await state.broadcast_event(PartUpdatedEvent.create(updated))
|
|
575
|
+
|
|
576
|
+
# Stream complete - extract token usage and cost
|
|
577
|
+
case StreamCompleteEvent(message=msg) if msg:
|
|
578
|
+
if msg.usage:
|
|
579
|
+
input_tokens = msg.usage.input_tokens or 0
|
|
580
|
+
output_tokens = msg.usage.output_tokens or 0
|
|
581
|
+
if msg.cost_info and msg.cost_info.total_cost:
|
|
582
|
+
# Cost is in Decimal dollars, OpenCode expects float dollars
|
|
583
|
+
total_cost = float(msg.cost_info.total_cost)
|
|
584
|
+
|
|
585
|
+
# Compaction event - emit session.compacted SSE event
|
|
586
|
+
case CompactionEvent(session_id=compact_session_id, phase=phase):
|
|
587
|
+
if phase == "completed":
|
|
588
|
+
await state.broadcast_event(
|
|
589
|
+
SessionCompactedEvent.create(session_id=compact_session_id)
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
except Exception as e: # noqa: BLE001
|
|
593
|
+
response_text = f"Error calling agent: {e}"
|
|
594
|
+
# Emit session error event
|
|
595
|
+
await state.broadcast_event(
|
|
596
|
+
SessionErrorEvent.create(
|
|
597
|
+
session_id=session_id,
|
|
598
|
+
error_name=type(e).__name__,
|
|
599
|
+
error_message=str(e),
|
|
600
|
+
)
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
response_time = now_ms()
|
|
604
|
+
|
|
605
|
+
# Create text part with response (only if we didn't stream it already)
|
|
606
|
+
if response_text and text_part is None:
|
|
607
|
+
text_part = TextPart(
|
|
608
|
+
id=identifier.ascending("part"),
|
|
609
|
+
message_id=assistant_msg_id,
|
|
610
|
+
session_id=session_id,
|
|
611
|
+
text=response_text,
|
|
612
|
+
time=TimeStartEndOptional(start=now, end=response_time),
|
|
613
|
+
)
|
|
614
|
+
assistant_msg_with_parts.parts.append(text_part)
|
|
615
|
+
|
|
616
|
+
# Broadcast text part update
|
|
617
|
+
await state.broadcast_event(PartUpdatedEvent.create(text_part))
|
|
618
|
+
elif text_part is not None:
|
|
619
|
+
# Update the streamed text part with final timing
|
|
620
|
+
final_text_part = TextPart(
|
|
621
|
+
id=text_part.id,
|
|
622
|
+
message_id=assistant_msg_id,
|
|
623
|
+
session_id=session_id,
|
|
624
|
+
text=response_text,
|
|
625
|
+
time=TimeStartEndOptional(start=now, end=response_time),
|
|
626
|
+
)
|
|
627
|
+
# Update in parts list
|
|
628
|
+
for i, p in enumerate(assistant_msg_with_parts.parts):
|
|
629
|
+
if isinstance(p, TextPart) and p.id == text_part.id:
|
|
630
|
+
assistant_msg_with_parts.parts[i] = final_text_part
|
|
631
|
+
break
|
|
632
|
+
|
|
633
|
+
step_finish = StepFinishPart(
|
|
634
|
+
id=identifier.ascending("part"),
|
|
635
|
+
message_id=assistant_msg_id,
|
|
636
|
+
session_id=session_id,
|
|
637
|
+
tokens=StepFinishTokens(
|
|
638
|
+
cache=TokenCache(read=0, write=0),
|
|
639
|
+
input=input_tokens,
|
|
640
|
+
output=output_tokens,
|
|
641
|
+
reasoning=0,
|
|
642
|
+
),
|
|
643
|
+
cost=total_cost,
|
|
644
|
+
)
|
|
645
|
+
assistant_msg_with_parts.parts.append(step_finish)
|
|
646
|
+
await state.broadcast_event(PartUpdatedEvent.create(step_finish))
|
|
647
|
+
|
|
648
|
+
print(f"Response text: {response_text[:100] if response_text else 'EMPTY'}...")
|
|
649
|
+
|
|
650
|
+
# Update assistant message with final timing and tokens
|
|
651
|
+
updated_assistant = assistant_message.model_copy(
|
|
652
|
+
update={
|
|
653
|
+
"time": MessageTime(created=now, completed=response_time),
|
|
654
|
+
"tokens": Tokens(
|
|
655
|
+
cache=TokensCache(read=0, write=0),
|
|
656
|
+
input=input_tokens,
|
|
657
|
+
output=output_tokens,
|
|
658
|
+
reasoning=0,
|
|
659
|
+
),
|
|
660
|
+
"cost": total_cost,
|
|
661
|
+
}
|
|
662
|
+
)
|
|
663
|
+
assistant_msg_with_parts.info = updated_assistant
|
|
664
|
+
|
|
665
|
+
# Broadcast final message update
|
|
666
|
+
await state.broadcast_event(MessageUpdatedEvent.create(updated_assistant))
|
|
667
|
+
# Persist assistant message to storage
|
|
668
|
+
await persist_message_to_storage(state, assistant_msg_with_parts, session_id)
|
|
669
|
+
# Mark session as not running
|
|
670
|
+
state.session_status[session_id] = SessionStatus(type="idle")
|
|
671
|
+
await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="idle")))
|
|
672
|
+
await state.broadcast_event(SessionIdleEvent.create(session_id))
|
|
673
|
+
|
|
674
|
+
# Update session timestamp
|
|
675
|
+
session = state.sessions[session_id]
|
|
676
|
+
state.sessions[session_id] = session.model_copy(
|
|
677
|
+
update={"time": TimeCreatedUpdated(created=session.time.created, updated=response_time)}
|
|
678
|
+
)
|
|
679
|
+
# Trigger title generation if session has default title
|
|
680
|
+
if session.title == "New Session" and state.pool.storage:
|
|
681
|
+
# Convert user_prompt to string if it's a list
|
|
682
|
+
prompt_str = user_prompt if isinstance(user_prompt, str) else str(user_prompt)
|
|
683
|
+
state.create_background_task(
|
|
684
|
+
_generate_session_title(state, session_id, prompt_str, response_text),
|
|
685
|
+
name=f"generate_title_{session_id}",
|
|
686
|
+
)
|
|
687
|
+
return assistant_msg_with_parts
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
@router.get("/message/{message_id}")
|
|
691
|
+
async def get_message(
|
|
692
|
+
session_id: str,
|
|
693
|
+
message_id: str,
|
|
694
|
+
state: StateDep,
|
|
695
|
+
) -> MessageWithParts:
|
|
696
|
+
"""Get a specific message."""
|
|
697
|
+
session = await get_or_load_session(state, session_id)
|
|
698
|
+
if session is None:
|
|
699
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
700
|
+
|
|
701
|
+
for msg in state.messages.get(session_id, []):
|
|
702
|
+
if msg.info.id == message_id:
|
|
703
|
+
return msg
|
|
704
|
+
|
|
705
|
+
raise HTTPException(status_code=404, detail="Message not found")
|