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,1205 @@
|
|
|
1
|
+
"""Session routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
8
|
+
|
|
9
|
+
from anyenv.text_sharing.opencode import Message, MessagePart, OpenCodeSharer
|
|
10
|
+
from fastapi import APIRouter, HTTPException
|
|
11
|
+
from pydantic_ai import FileUrl
|
|
12
|
+
|
|
13
|
+
from agentpool.repomap import RepoMap, find_src_files
|
|
14
|
+
from agentpool.sessions.models import SessionData
|
|
15
|
+
from agentpool.utils import identifiers as identifier
|
|
16
|
+
from agentpool_config.session import SessionQuery
|
|
17
|
+
from agentpool_server.opencode_server.command_validation import validate_command
|
|
18
|
+
from agentpool_server.opencode_server.converters import chat_message_to_opencode
|
|
19
|
+
from agentpool_server.opencode_server.dependencies import StateDep # noqa: TC001
|
|
20
|
+
from agentpool_server.opencode_server.input_provider import OpenCodeInputProvider
|
|
21
|
+
from agentpool_server.opencode_server.models import ( # noqa: TC001
|
|
22
|
+
AssistantMessage,
|
|
23
|
+
CommandRequest,
|
|
24
|
+
MessagePath,
|
|
25
|
+
MessageTime,
|
|
26
|
+
MessageUpdatedEvent,
|
|
27
|
+
MessageWithParts,
|
|
28
|
+
PartUpdatedEvent,
|
|
29
|
+
Session,
|
|
30
|
+
SessionCreatedEvent,
|
|
31
|
+
SessionCreateRequest,
|
|
32
|
+
SessionDeletedEvent,
|
|
33
|
+
SessionForkRequest,
|
|
34
|
+
SessionInitRequest,
|
|
35
|
+
SessionRevert,
|
|
36
|
+
SessionShare,
|
|
37
|
+
SessionStatus,
|
|
38
|
+
SessionStatusEvent,
|
|
39
|
+
SessionUpdatedEvent,
|
|
40
|
+
SessionUpdateRequest,
|
|
41
|
+
ShellRequest,
|
|
42
|
+
StepFinishPart,
|
|
43
|
+
StepStartPart,
|
|
44
|
+
SummarizeRequest,
|
|
45
|
+
TextPart,
|
|
46
|
+
TimeCreatedUpdated,
|
|
47
|
+
Todo,
|
|
48
|
+
Tokens,
|
|
49
|
+
TokensCache,
|
|
50
|
+
)
|
|
51
|
+
from agentpool_server.opencode_server.models.base import OpenCodeBaseModel
|
|
52
|
+
from agentpool_server.opencode_server.models.events import PermissionResolvedEvent
|
|
53
|
+
from agentpool_server.opencode_server.models.parts import StepFinishTokens, TokenCache
|
|
54
|
+
from agentpool_server.opencode_server.time_utils import now_ms
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from agentpool_server.opencode_server.state import ServerState
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# =============================================================================
|
|
62
|
+
# Conversion helpers between OpenCode Session and SessionData
|
|
63
|
+
# =============================================================================
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def session_data_to_opencode(data: SessionData) -> Session:
|
|
67
|
+
"""Convert SessionData to OpenCode Session model."""
|
|
68
|
+
# Convert datetime to milliseconds timestamp
|
|
69
|
+
created_ms = int(data.created_at.timestamp() * 1000)
|
|
70
|
+
updated_ms = int(data.last_active.timestamp() * 1000)
|
|
71
|
+
|
|
72
|
+
# Extract revert/share from metadata if present
|
|
73
|
+
revert = None
|
|
74
|
+
share = None
|
|
75
|
+
if "revert" in data.metadata:
|
|
76
|
+
revert = SessionRevert(**data.metadata["revert"])
|
|
77
|
+
if "share" in data.metadata:
|
|
78
|
+
share = SessionShare(**data.metadata["share"])
|
|
79
|
+
|
|
80
|
+
return Session(
|
|
81
|
+
id=data.session_id,
|
|
82
|
+
project_id=data.project_id or "default",
|
|
83
|
+
directory=data.cwd or "",
|
|
84
|
+
title=data.title or "New Session",
|
|
85
|
+
version=data.version,
|
|
86
|
+
time=TimeCreatedUpdated(created=created_ms, updated=updated_ms),
|
|
87
|
+
parent_id=data.parent_id,
|
|
88
|
+
revert=revert,
|
|
89
|
+
share=share,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def opencode_to_session_data(
|
|
94
|
+
session: Session,
|
|
95
|
+
*,
|
|
96
|
+
agent_name: str = "default",
|
|
97
|
+
pool_id: str | None = None,
|
|
98
|
+
) -> SessionData:
|
|
99
|
+
"""Convert OpenCode Session to SessionData for persistence."""
|
|
100
|
+
# Convert milliseconds timestamp to datetime
|
|
101
|
+
created_at = datetime.fromtimestamp(session.time.created / 1000, tz=UTC)
|
|
102
|
+
last_active = datetime.fromtimestamp(session.time.updated / 1000, tz=UTC)
|
|
103
|
+
# Store revert/share in metadata
|
|
104
|
+
metadata: dict[str, Any] = {}
|
|
105
|
+
if session.revert:
|
|
106
|
+
metadata["revert"] = session.revert.model_dump()
|
|
107
|
+
if session.share:
|
|
108
|
+
metadata["share"] = session.share.model_dump()
|
|
109
|
+
return SessionData(
|
|
110
|
+
session_id=session.id,
|
|
111
|
+
agent_name=agent_name,
|
|
112
|
+
conversation_id=session.id, # Use session_id as conversation_id
|
|
113
|
+
title=session.title,
|
|
114
|
+
pool_id=pool_id,
|
|
115
|
+
project_id=session.project_id,
|
|
116
|
+
parent_id=session.parent_id,
|
|
117
|
+
version=session.version,
|
|
118
|
+
cwd=session.directory,
|
|
119
|
+
created_at=created_at,
|
|
120
|
+
last_active=last_active,
|
|
121
|
+
metadata=metadata,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def load_messages_from_storage(state: ServerState, session_id: str) -> list[MessageWithParts]:
|
|
126
|
+
"""Load messages from storage and convert to OpenCode format.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
state: Server state with pool reference
|
|
130
|
+
session_id: Session/conversation ID
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
List of OpenCode MessageWithParts
|
|
134
|
+
"""
|
|
135
|
+
if state.pool.storage is None:
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
query = SessionQuery(name=session_id) # conversation_id = session_id
|
|
140
|
+
chat_messages = await state.pool.storage.filter_messages(query)
|
|
141
|
+
# Convert to OpenCode format
|
|
142
|
+
opencode_messages = []
|
|
143
|
+
working_dir = state.working_dir
|
|
144
|
+
agent_name = state.agent.name
|
|
145
|
+
for chat_msg in chat_messages:
|
|
146
|
+
opencode_msg = chat_message_to_opencode(
|
|
147
|
+
chat_msg,
|
|
148
|
+
session_id=session_id,
|
|
149
|
+
working_dir=working_dir,
|
|
150
|
+
agent_name=agent_name,
|
|
151
|
+
model_id=chat_msg.model_name or "unknown",
|
|
152
|
+
provider_id=chat_msg.provider_name or "agentpool",
|
|
153
|
+
)
|
|
154
|
+
opencode_messages.append(opencode_msg)
|
|
155
|
+
|
|
156
|
+
except Exception: # noqa: BLE001
|
|
157
|
+
# If storage fails, return empty list
|
|
158
|
+
return []
|
|
159
|
+
else:
|
|
160
|
+
return opencode_messages
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def get_or_load_session(state: ServerState, session_id: str) -> Session | None:
|
|
164
|
+
"""Get session from cache or load from storage.
|
|
165
|
+
|
|
166
|
+
Returns None if session not found in either location.
|
|
167
|
+
Also loads messages from storage if not already cached.
|
|
168
|
+
"""
|
|
169
|
+
# Check in-memory cache first
|
|
170
|
+
if session_id in state.sessions:
|
|
171
|
+
return state.sessions[session_id]
|
|
172
|
+
|
|
173
|
+
# Try to load from storage
|
|
174
|
+
data = await state.pool.sessions.store.load(session_id)
|
|
175
|
+
if data is not None:
|
|
176
|
+
session = session_data_to_opencode(data)
|
|
177
|
+
# Cache it
|
|
178
|
+
state.sessions[session_id] = session
|
|
179
|
+
# Initialize runtime state
|
|
180
|
+
if session_id not in state.session_status:
|
|
181
|
+
state.session_status[session_id] = SessionStatus(type="idle")
|
|
182
|
+
# Load messages from storage if not cached
|
|
183
|
+
if session_id not in state.messages:
|
|
184
|
+
state.messages[session_id] = await load_messages_from_storage(state, session_id)
|
|
185
|
+
return session
|
|
186
|
+
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
router = APIRouter(prefix="/session", tags=["session"])
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.get("")
|
|
194
|
+
async def list_sessions(state: StateDep) -> list[Session]:
|
|
195
|
+
"""List all sessions from storage.
|
|
196
|
+
|
|
197
|
+
Returns all persisted sessions, not just active ones.
|
|
198
|
+
"""
|
|
199
|
+
sessions: list[Session] = []
|
|
200
|
+
# Load all session IDs from storage
|
|
201
|
+
session_ids = await state.pool.sessions.store.list_sessions()
|
|
202
|
+
for session_id in session_ids:
|
|
203
|
+
# Use get_or_load to populate cache and get Session model
|
|
204
|
+
session = await get_or_load_session(state, session_id)
|
|
205
|
+
if session is not None:
|
|
206
|
+
sessions.append(session)
|
|
207
|
+
|
|
208
|
+
return sessions
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@router.post("")
|
|
212
|
+
async def create_session(state: StateDep, request: SessionCreateRequest | None = None) -> Session:
|
|
213
|
+
"""Create a new session and persist to storage."""
|
|
214
|
+
now = now_ms()
|
|
215
|
+
session_id = identifier.ascending("session")
|
|
216
|
+
session = Session(
|
|
217
|
+
id=session_id,
|
|
218
|
+
project_id="default", # TODO: Get from config/request
|
|
219
|
+
directory=state.working_dir,
|
|
220
|
+
title=request.title if request and request.title else "New Session",
|
|
221
|
+
version="1",
|
|
222
|
+
time=TimeCreatedUpdated(created=now, updated=now),
|
|
223
|
+
parent_id=request.parent_id if request else None,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Persist to storage
|
|
227
|
+
id_ = state.pool.manifest.config_file_path
|
|
228
|
+
session_data = opencode_to_session_data(session, agent_name=state.agent.name, pool_id=id_)
|
|
229
|
+
await state.pool.sessions.store.save(session_data)
|
|
230
|
+
# Cache in memory
|
|
231
|
+
state.sessions[session_id] = session
|
|
232
|
+
state.messages[session_id] = []
|
|
233
|
+
state.session_status[session_id] = SessionStatus(type="idle")
|
|
234
|
+
state.todos[session_id] = []
|
|
235
|
+
# Create input provider for this session
|
|
236
|
+
input_provider = OpenCodeInputProvider(state, session_id)
|
|
237
|
+
state.input_providers[session_id] = input_provider
|
|
238
|
+
# Set input provider on agent
|
|
239
|
+
state.agent._input_provider = input_provider
|
|
240
|
+
await state.broadcast_event(SessionCreatedEvent.create(session))
|
|
241
|
+
return session
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@router.get("/status")
|
|
245
|
+
async def get_session_status(state: StateDep) -> dict[str, SessionStatus]:
|
|
246
|
+
"""Get status for all sessions.
|
|
247
|
+
|
|
248
|
+
Returns only non-idle sessions. If all sessions are idle, returns empty dict.
|
|
249
|
+
"""
|
|
250
|
+
return {sid: status for sid, status in state.session_status.items() if status.type != "idle"}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@router.get("/{session_id}")
|
|
254
|
+
async def get_session(session_id: str, state: StateDep) -> Session:
|
|
255
|
+
"""Get session details.
|
|
256
|
+
|
|
257
|
+
Loads from storage if not in memory cache.
|
|
258
|
+
"""
|
|
259
|
+
session = await get_or_load_session(state, session_id)
|
|
260
|
+
if session is None:
|
|
261
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
262
|
+
return session
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@router.patch("/{session_id}")
|
|
266
|
+
async def update_session(
|
|
267
|
+
session_id: str,
|
|
268
|
+
request: SessionUpdateRequest,
|
|
269
|
+
state: StateDep,
|
|
270
|
+
) -> Session:
|
|
271
|
+
"""Update session properties and persist changes."""
|
|
272
|
+
session = await get_or_load_session(state, session_id)
|
|
273
|
+
if session is None:
|
|
274
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
275
|
+
|
|
276
|
+
if request.title is not None:
|
|
277
|
+
time_ = TimeCreatedUpdated(created=session.time.created, updated=now_ms())
|
|
278
|
+
session = session.model_copy(update={"title": request.title, "time": time_})
|
|
279
|
+
state.sessions[session_id] = session # Update cache
|
|
280
|
+
id_ = state.pool.manifest.config_file_path
|
|
281
|
+
session_data = opencode_to_session_data(session, agent_name=state.agent.name, pool_id=id_)
|
|
282
|
+
await state.pool.sessions.store.save(session_data)
|
|
283
|
+
await state.broadcast_event(SessionUpdatedEvent.create(session))
|
|
284
|
+
return session
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@router.delete("/{session_id}")
|
|
288
|
+
async def delete_session(session_id: str, state: StateDep) -> bool:
|
|
289
|
+
"""Delete a session from both cache and storage."""
|
|
290
|
+
# Check if session exists (in cache or storage)
|
|
291
|
+
session = await get_or_load_session(state, session_id)
|
|
292
|
+
if session is None:
|
|
293
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
294
|
+
|
|
295
|
+
# Cancel any pending permissions and clean up input provider
|
|
296
|
+
input_provider = state.input_providers.pop(session_id, None)
|
|
297
|
+
if input_provider is not None:
|
|
298
|
+
input_provider.cancel_all_pending()
|
|
299
|
+
|
|
300
|
+
# Remove from cache
|
|
301
|
+
state.sessions.pop(session_id, None)
|
|
302
|
+
state.messages.pop(session_id, None)
|
|
303
|
+
state.session_status.pop(session_id, None)
|
|
304
|
+
state.todos.pop(session_id, None)
|
|
305
|
+
# Delete from storage
|
|
306
|
+
await state.pool.sessions.store.delete(session_id)
|
|
307
|
+
await state.broadcast_event(SessionDeletedEvent.create(session_id))
|
|
308
|
+
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@router.post("/{session_id}/abort")
|
|
313
|
+
async def abort_session(session_id: str, state: StateDep) -> bool:
|
|
314
|
+
"""Abort a running session."""
|
|
315
|
+
session = await get_or_load_session(state, session_id)
|
|
316
|
+
if session is None:
|
|
317
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
318
|
+
# TODO: Actually abort running operations
|
|
319
|
+
state.session_status[session_id] = SessionStatus(type="idle")
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@router.post("/{session_id}/fork")
|
|
324
|
+
async def fork_session( # noqa: D417
|
|
325
|
+
session_id: str,
|
|
326
|
+
state: StateDep,
|
|
327
|
+
request: SessionForkRequest | None = None,
|
|
328
|
+
directory: str | None = None,
|
|
329
|
+
) -> Session:
|
|
330
|
+
"""Fork a session, optionally at a specific message.
|
|
331
|
+
|
|
332
|
+
Creates a new session with:
|
|
333
|
+
- parent_id pointing to the original session
|
|
334
|
+
- Copies all messages (or up to message_id if specified)
|
|
335
|
+
- Independent conversation history from that point forward
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
session_id: The session to fork from
|
|
339
|
+
request: Optional fork parameters (message_id to fork from)
|
|
340
|
+
directory: Optional directory for the forked session
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
The newly created forked session
|
|
344
|
+
"""
|
|
345
|
+
# Get the original session
|
|
346
|
+
original_session = await get_or_load_session(state, session_id)
|
|
347
|
+
if original_session is None:
|
|
348
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
349
|
+
|
|
350
|
+
# Get messages from the original session
|
|
351
|
+
original_messages = state.messages.get(session_id, [])
|
|
352
|
+
# Filter messages if message_id is specified
|
|
353
|
+
messages_to_copy: list[MessageWithParts] = []
|
|
354
|
+
if request and request.message_id:
|
|
355
|
+
# Copy messages up to and including the specified message_id
|
|
356
|
+
for msg in original_messages:
|
|
357
|
+
messages_to_copy.append(msg)
|
|
358
|
+
if msg.info.id == request.message_id:
|
|
359
|
+
break
|
|
360
|
+
else:
|
|
361
|
+
# message_id not found in messages
|
|
362
|
+
detail = f"Message {request.message_id} not found in session"
|
|
363
|
+
raise HTTPException(status_code=404, detail=detail)
|
|
364
|
+
else:
|
|
365
|
+
# Copy all messages
|
|
366
|
+
messages_to_copy = list(original_messages)
|
|
367
|
+
|
|
368
|
+
# Create the new forked session
|
|
369
|
+
now = now_ms()
|
|
370
|
+
new_session_id = identifier.ascending("session")
|
|
371
|
+
# Use provided directory or inherit from original session
|
|
372
|
+
fork_directory = directory if directory else original_session.directory
|
|
373
|
+
forked_session = Session(
|
|
374
|
+
id=new_session_id,
|
|
375
|
+
project_id=original_session.project_id,
|
|
376
|
+
directory=fork_directory,
|
|
377
|
+
title=f"{original_session.title} (fork)",
|
|
378
|
+
version="1",
|
|
379
|
+
time=TimeCreatedUpdated(created=now, updated=now),
|
|
380
|
+
parent_id=session_id, # Link to original session
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Persist the forked session to storage
|
|
384
|
+
session_data = opencode_to_session_data(
|
|
385
|
+
forked_session,
|
|
386
|
+
agent_name=state.agent.name,
|
|
387
|
+
pool_id=state.pool.manifest.config_file_path,
|
|
388
|
+
)
|
|
389
|
+
await state.pool.sessions.store.save(session_data)
|
|
390
|
+
# Cache in memory
|
|
391
|
+
state.sessions[new_session_id] = forked_session
|
|
392
|
+
state.session_status[new_session_id] = SessionStatus(type="idle")
|
|
393
|
+
state.todos[new_session_id] = []
|
|
394
|
+
# Copy messages to the new session (with updated session_id references)
|
|
395
|
+
copied_messages: list[MessageWithParts] = []
|
|
396
|
+
for msg_with_parts in messages_to_copy:
|
|
397
|
+
# Create new message info with updated session_id
|
|
398
|
+
new_info = msg_with_parts.info.model_copy(update={"session_id": new_session_id})
|
|
399
|
+
# Copy parts with updated session_id
|
|
400
|
+
new_parts = [
|
|
401
|
+
part.model_copy(update={"session_id": new_session_id}) for part in msg_with_parts.parts
|
|
402
|
+
]
|
|
403
|
+
copied_messages.append(MessageWithParts(info=new_info, parts=new_parts))
|
|
404
|
+
|
|
405
|
+
state.messages[new_session_id] = copied_messages
|
|
406
|
+
input_provider = OpenCodeInputProvider(state, new_session_id)
|
|
407
|
+
state.input_providers[new_session_id] = input_provider
|
|
408
|
+
# Broadcast session created event
|
|
409
|
+
await state.broadcast_event(SessionCreatedEvent.create(forked_session))
|
|
410
|
+
return forked_session
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@router.post("/{session_id}/init")
|
|
414
|
+
async def init_session( # noqa: D417
|
|
415
|
+
session_id: str,
|
|
416
|
+
state: StateDep,
|
|
417
|
+
request: SessionInitRequest | None = None,
|
|
418
|
+
) -> bool:
|
|
419
|
+
"""Initialize a session by analyzing the codebase and creating AGENTS.md.
|
|
420
|
+
|
|
421
|
+
Generates a repository map, reads README if present, and runs the agent
|
|
422
|
+
with a prompt to create an AGENTS.md file with project-specific context.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
session_id: The session to initialize
|
|
426
|
+
request: Optional model/provider override for the init task
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
True when the init task has been started (runs async)
|
|
430
|
+
"""
|
|
431
|
+
session = await get_or_load_session(state, session_id)
|
|
432
|
+
if session is None:
|
|
433
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
434
|
+
|
|
435
|
+
# Get the agent and filesystem
|
|
436
|
+
agent = state.agent
|
|
437
|
+
fs = agent.env.get_fs()
|
|
438
|
+
working_dir = state.working_dir
|
|
439
|
+
try:
|
|
440
|
+
all_files = await find_src_files(fs, working_dir)
|
|
441
|
+
repo_map = RepoMap(fs=fs, root_path=working_dir, max_tokens=4000)
|
|
442
|
+
repomap_content = await repo_map.get_map(all_files) or "No repository map generated."
|
|
443
|
+
except Exception as e: # noqa: BLE001
|
|
444
|
+
repomap_content = f"Error generating repository map: {e}"
|
|
445
|
+
|
|
446
|
+
# Try to read README.md
|
|
447
|
+
readme_content = ""
|
|
448
|
+
for readme_name in ["README.md", "readme.md", "README", "readme.txt"]:
|
|
449
|
+
try:
|
|
450
|
+
readme_path = f"{working_dir}/{readme_name}".replace("//", "/")
|
|
451
|
+
content = await fs._cat_file(readme_path)
|
|
452
|
+
readme_content = content.decode("utf-8") if isinstance(content, bytes) else content
|
|
453
|
+
break
|
|
454
|
+
except Exception: # noqa: BLE001
|
|
455
|
+
continue
|
|
456
|
+
|
|
457
|
+
# Build the init prompt
|
|
458
|
+
prompt_parts = [
|
|
459
|
+
"Please analyze this codebase and create an AGENTS.md file in the project root.",
|
|
460
|
+
"",
|
|
461
|
+
"<repository-structure>",
|
|
462
|
+
repomap_content,
|
|
463
|
+
"</repository-structure>",
|
|
464
|
+
]
|
|
465
|
+
if readme_content:
|
|
466
|
+
prompt_parts.extend(["", "<readme>", readme_content, "</readme>"])
|
|
467
|
+
prompt_parts.extend([
|
|
468
|
+
"",
|
|
469
|
+
"Include:",
|
|
470
|
+
"1. Build/lint/test commands - especially for running a single test",
|
|
471
|
+
"2. Code style guidelines (imports, formatting, types, naming conventions, error handling)",
|
|
472
|
+
"",
|
|
473
|
+
"The file will be given to AI coding agents working in this repository. "
|
|
474
|
+
"Keep it around 150 lines.",
|
|
475
|
+
"",
|
|
476
|
+
"If there are existing rules (.cursor/rules/, .cursorrules, "
|
|
477
|
+
".github/copilot-instructions.md), incorporate them.",
|
|
478
|
+
])
|
|
479
|
+
|
|
480
|
+
init_prompt = "\n".join(prompt_parts)
|
|
481
|
+
|
|
482
|
+
# Handle model selection if requested
|
|
483
|
+
original_model: str | None = None
|
|
484
|
+
if request and request.model_id and request.provider_id:
|
|
485
|
+
requested_model = f"{request.provider_id}:{request.model_id}"
|
|
486
|
+
try:
|
|
487
|
+
available_models = await agent.get_available_models()
|
|
488
|
+
if available_models:
|
|
489
|
+
valid_ids = [m.id_override if m.id_override else m.id for m in available_models]
|
|
490
|
+
if requested_model in valid_ids:
|
|
491
|
+
# Store original model to restore later
|
|
492
|
+
original_model = agent.model_name
|
|
493
|
+
await agent.set_model(requested_model)
|
|
494
|
+
except Exception: # noqa: BLE001
|
|
495
|
+
# Agent doesn't support model selection, ignore
|
|
496
|
+
pass
|
|
497
|
+
|
|
498
|
+
# Run the agent in the background
|
|
499
|
+
async def run_init() -> None:
|
|
500
|
+
try:
|
|
501
|
+
await agent.run(init_prompt)
|
|
502
|
+
finally:
|
|
503
|
+
# Restore original model if we changed it
|
|
504
|
+
if original_model is not None:
|
|
505
|
+
with contextlib.suppress(Exception):
|
|
506
|
+
await agent.set_model(original_model)
|
|
507
|
+
|
|
508
|
+
state.create_background_task(run_init(), name=f"init_{session_id}")
|
|
509
|
+
|
|
510
|
+
return True
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@router.get("/{session_id}/todo")
|
|
514
|
+
async def get_session_todos(session_id: str, state: StateDep) -> list[Todo]:
|
|
515
|
+
"""Get todos for a session.
|
|
516
|
+
|
|
517
|
+
Returns todos from the agent pool's TodoTracker.
|
|
518
|
+
"""
|
|
519
|
+
session = await get_or_load_session(state, session_id)
|
|
520
|
+
if session is None:
|
|
521
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
522
|
+
|
|
523
|
+
# Get todos from pool's TodoTracker
|
|
524
|
+
tracker = state.pool.todos
|
|
525
|
+
return [Todo(id=e.id, content=e.content, status=e.status) for e in tracker.entries]
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@router.get("/{session_id}/diff")
|
|
529
|
+
async def get_session_diff(
|
|
530
|
+
session_id: str,
|
|
531
|
+
state: StateDep,
|
|
532
|
+
message_id: str | None = None,
|
|
533
|
+
) -> list[dict[str, Any]]:
|
|
534
|
+
"""Get file diffs for a session.
|
|
535
|
+
|
|
536
|
+
Returns a list of file changes with unified diffs.
|
|
537
|
+
Optionally filter to changes since a specific message.
|
|
538
|
+
"""
|
|
539
|
+
session = await get_or_load_session(state, session_id)
|
|
540
|
+
if session is None:
|
|
541
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
542
|
+
|
|
543
|
+
file_ops = state.pool.file_ops
|
|
544
|
+
if not file_ops.changes:
|
|
545
|
+
return []
|
|
546
|
+
# Optionally filter by message_id
|
|
547
|
+
changes = file_ops.get_changes_since_message(message_id) if message_id else file_ops.changes
|
|
548
|
+
# Format as list of diffs
|
|
549
|
+
return [
|
|
550
|
+
{
|
|
551
|
+
"path": change.path,
|
|
552
|
+
"operation": change.operation,
|
|
553
|
+
"diff": change.to_unified_diff(),
|
|
554
|
+
"timestamp": change.timestamp,
|
|
555
|
+
"agent_name": change.agent_name,
|
|
556
|
+
"message_id": change.message_id,
|
|
557
|
+
}
|
|
558
|
+
for change in changes
|
|
559
|
+
]
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@router.post("/{session_id}/shell")
|
|
563
|
+
async def run_shell_command(
|
|
564
|
+
session_id: str,
|
|
565
|
+
request: ShellRequest,
|
|
566
|
+
state: StateDep,
|
|
567
|
+
) -> MessageWithParts:
|
|
568
|
+
"""Run a shell command directly."""
|
|
569
|
+
session = await get_or_load_session(state, session_id)
|
|
570
|
+
if session is None:
|
|
571
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
572
|
+
|
|
573
|
+
# Validate command for security issues
|
|
574
|
+
validate_command(request.command, state.working_dir)
|
|
575
|
+
now = now_ms()
|
|
576
|
+
# Create assistant message for the shell output
|
|
577
|
+
assistant_msg_id = identifier.ascending("message")
|
|
578
|
+
assistant_message = AssistantMessage(
|
|
579
|
+
id=assistant_msg_id,
|
|
580
|
+
session_id=session_id,
|
|
581
|
+
parent_id="", # Shell commands don't have a parent user message
|
|
582
|
+
model_id=request.model.model_id if request.model else "shell",
|
|
583
|
+
provider_id=request.model.provider_id if request.model else "local",
|
|
584
|
+
mode="shell",
|
|
585
|
+
agent=request.agent,
|
|
586
|
+
path=MessagePath(cwd=state.working_dir, root=state.working_dir),
|
|
587
|
+
time=MessageTime(created=now, completed=None),
|
|
588
|
+
tokens=Tokens(cache=TokensCache(read=0, write=0), input=0, output=0, reasoning=0),
|
|
589
|
+
cost=0.0,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
# Initialize message with empty parts
|
|
593
|
+
assistant_msg_with_parts = MessageWithParts(info=assistant_message, parts=[])
|
|
594
|
+
state.messages[session_id].append(assistant_msg_with_parts)
|
|
595
|
+
# Broadcast message created
|
|
596
|
+
await state.broadcast_event(MessageUpdatedEvent.create(assistant_message))
|
|
597
|
+
# Mark session as busy
|
|
598
|
+
state.session_status[session_id] = SessionStatus(type="busy")
|
|
599
|
+
await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="busy")))
|
|
600
|
+
# Add step-start part
|
|
601
|
+
step_start = StepStartPart(
|
|
602
|
+
id=identifier.ascending("part"),
|
|
603
|
+
message_id=assistant_msg_id,
|
|
604
|
+
session_id=session_id,
|
|
605
|
+
)
|
|
606
|
+
assistant_msg_with_parts.parts.append(step_start)
|
|
607
|
+
await state.broadcast_event(PartUpdatedEvent.create(step_start))
|
|
608
|
+
# Execute the command
|
|
609
|
+
output_text = ""
|
|
610
|
+
success = False
|
|
611
|
+
try:
|
|
612
|
+
result = await state.agent.env.execute_command(request.command)
|
|
613
|
+
success = result.success
|
|
614
|
+
if success:
|
|
615
|
+
output_text = str(result.result) if result.result else ""
|
|
616
|
+
else:
|
|
617
|
+
output_text = f"Error: {result.error}" if result.error else "Command failed"
|
|
618
|
+
except Exception as e: # noqa: BLE001
|
|
619
|
+
output_text = f"Error executing command: {e}"
|
|
620
|
+
|
|
621
|
+
response_time = now_ms()
|
|
622
|
+
# Create text part with output
|
|
623
|
+
text_part = TextPart(
|
|
624
|
+
id=identifier.ascending("part"),
|
|
625
|
+
message_id=assistant_msg_id,
|
|
626
|
+
session_id=session_id,
|
|
627
|
+
text=f"$ {request.command}\n{output_text}",
|
|
628
|
+
)
|
|
629
|
+
assistant_msg_with_parts.parts.append(text_part)
|
|
630
|
+
await state.broadcast_event(PartUpdatedEvent.create(text_part))
|
|
631
|
+
step_finish = StepFinishPart(
|
|
632
|
+
id=identifier.ascending("part"),
|
|
633
|
+
message_id=assistant_msg_id,
|
|
634
|
+
session_id=session_id,
|
|
635
|
+
tokens=StepFinishTokens(cache=TokenCache(read=0, write=0)),
|
|
636
|
+
)
|
|
637
|
+
assistant_msg_with_parts.parts.append(step_finish)
|
|
638
|
+
await state.broadcast_event(PartUpdatedEvent.create(step_finish))
|
|
639
|
+
# Update message with completion time
|
|
640
|
+
time_ = MessageTime(created=now, completed=response_time)
|
|
641
|
+
updated_assistant = assistant_message.model_copy(update={"time": time_})
|
|
642
|
+
assistant_msg_with_parts.info = updated_assistant
|
|
643
|
+
await state.broadcast_event(MessageUpdatedEvent.create(updated_assistant))
|
|
644
|
+
# Mark session as idle
|
|
645
|
+
state.session_status[session_id] = SessionStatus(type="idle")
|
|
646
|
+
await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="idle")))
|
|
647
|
+
return assistant_msg_with_parts
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
class PermissionResponse(OpenCodeBaseModel):
|
|
651
|
+
"""Request body for responding to a permission request."""
|
|
652
|
+
|
|
653
|
+
response: Literal["once", "always", "reject"]
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
@router.get("/{session_id}/permissions")
|
|
657
|
+
async def get_pending_permissions(session_id: str, state: StateDep) -> list[dict[str, Any]]:
|
|
658
|
+
"""Get all pending permission requests for a session.
|
|
659
|
+
|
|
660
|
+
Returns a list of pending permissions awaiting user response.
|
|
661
|
+
"""
|
|
662
|
+
session = await get_or_load_session(state, session_id)
|
|
663
|
+
if session is None:
|
|
664
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
665
|
+
|
|
666
|
+
# Get the input provider for this session
|
|
667
|
+
input_provider = state.input_providers.get(session_id)
|
|
668
|
+
if input_provider is None:
|
|
669
|
+
return []
|
|
670
|
+
|
|
671
|
+
return input_provider.get_pending_permissions()
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@router.post("/{session_id}/permissions/{permission_id}")
|
|
675
|
+
async def respond_to_permission(
|
|
676
|
+
session_id: str,
|
|
677
|
+
permission_id: str,
|
|
678
|
+
request: PermissionResponse,
|
|
679
|
+
state: StateDep,
|
|
680
|
+
) -> bool:
|
|
681
|
+
"""Respond to a pending permission request.
|
|
682
|
+
|
|
683
|
+
The response can be:
|
|
684
|
+
- "once": Allow this tool execution once
|
|
685
|
+
- "always": Always allow this tool (remembered for session)
|
|
686
|
+
- "reject": Reject this tool execution
|
|
687
|
+
"""
|
|
688
|
+
session = await get_or_load_session(state, session_id)
|
|
689
|
+
if session is None:
|
|
690
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
691
|
+
|
|
692
|
+
# Get the input provider for this session
|
|
693
|
+
input_provider = state.input_providers.get(session_id)
|
|
694
|
+
if input_provider is None:
|
|
695
|
+
raise HTTPException(status_code=404, detail="No input provider for session")
|
|
696
|
+
|
|
697
|
+
# Resolve the permission
|
|
698
|
+
resolved = input_provider.resolve_permission(permission_id, request.response)
|
|
699
|
+
if not resolved:
|
|
700
|
+
raise HTTPException(status_code=404, detail="Permission not found or already resolved")
|
|
701
|
+
|
|
702
|
+
await state.broadcast_event(
|
|
703
|
+
PermissionResolvedEvent.create(
|
|
704
|
+
session_id=session_id,
|
|
705
|
+
request_id=permission_id,
|
|
706
|
+
reply=request.response,
|
|
707
|
+
)
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
return True
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
# OpenCode-style continuation prompt for summarization
|
|
714
|
+
SUMMARIZE_PROMPT = """Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next considering new session will not have access to our conversation.""" # noqa: E501
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@router.post("/{session_id}/summarize")
|
|
718
|
+
async def summarize_session( # noqa: PLR0915
|
|
719
|
+
session_id: str,
|
|
720
|
+
state: StateDep,
|
|
721
|
+
request: SummarizeRequest | None = None,
|
|
722
|
+
) -> MessageWithParts:
|
|
723
|
+
"""Summarize the session conversation.
|
|
724
|
+
|
|
725
|
+
First runs the compaction pipeline to condense older messages,
|
|
726
|
+
then streams an LLM-generated summary/continuation prompt to the user.
|
|
727
|
+
The summary message is marked with summary=true for UI display.
|
|
728
|
+
"""
|
|
729
|
+
from pydantic_ai.messages import (
|
|
730
|
+
PartDeltaEvent,
|
|
731
|
+
PartStartEvent,
|
|
732
|
+
TextPart as PydanticTextPart,
|
|
733
|
+
TextPartDelta,
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
from agentpool.agents.events import StreamCompleteEvent
|
|
737
|
+
|
|
738
|
+
session = await get_or_load_session(state, session_id)
|
|
739
|
+
if session is None:
|
|
740
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
741
|
+
messages = state.messages.get(session_id, [])
|
|
742
|
+
if not messages:
|
|
743
|
+
raise HTTPException(status_code=400, detail="No messages to summarize")
|
|
744
|
+
|
|
745
|
+
# Determine model to use
|
|
746
|
+
model_id = request.model_id if request and request.model_id else "default"
|
|
747
|
+
provider_id = request.provider_id if request and request.provider_id else "agentpool"
|
|
748
|
+
|
|
749
|
+
now = now_ms()
|
|
750
|
+
# Create assistant message for the summary (marked with summary=true)
|
|
751
|
+
assistant_msg_id = identifier.ascending("message")
|
|
752
|
+
assistant_message = AssistantMessage(
|
|
753
|
+
id=assistant_msg_id,
|
|
754
|
+
session_id=session_id,
|
|
755
|
+
parent_id="",
|
|
756
|
+
model_id=model_id,
|
|
757
|
+
provider_id=provider_id,
|
|
758
|
+
mode="summarize",
|
|
759
|
+
agent="summarizer",
|
|
760
|
+
path=MessagePath(cwd=state.working_dir, root=state.working_dir),
|
|
761
|
+
time=MessageTime(created=now, completed=None),
|
|
762
|
+
tokens=Tokens(cache=TokensCache(read=0, write=0), input=0, output=0, reasoning=0),
|
|
763
|
+
cost=0.0,
|
|
764
|
+
summary=True, # Mark as summary message
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
assistant_msg_with_parts = MessageWithParts(info=assistant_message, parts=[])
|
|
768
|
+
state.messages[session_id].append(assistant_msg_with_parts)
|
|
769
|
+
# Broadcast message created
|
|
770
|
+
await state.broadcast_event(MessageUpdatedEvent.create(assistant_message))
|
|
771
|
+
# Mark session as busy
|
|
772
|
+
state.session_status[session_id] = SessionStatus(type="busy")
|
|
773
|
+
await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="busy")))
|
|
774
|
+
# Add step-start part
|
|
775
|
+
step_start = StepStartPart(
|
|
776
|
+
id=identifier.ascending("part"),
|
|
777
|
+
message_id=assistant_msg_id,
|
|
778
|
+
session_id=session_id,
|
|
779
|
+
)
|
|
780
|
+
assistant_msg_with_parts.parts.append(step_start)
|
|
781
|
+
await state.broadcast_event(PartUpdatedEvent.create(step_start))
|
|
782
|
+
|
|
783
|
+
# Step 1: Stream LLM summary generation FIRST (while we have full history)
|
|
784
|
+
# The LLM sees the complete conversation and generates a continuation prompt.
|
|
785
|
+
response_text = ""
|
|
786
|
+
input_tokens = 0
|
|
787
|
+
output_tokens = 0
|
|
788
|
+
text_part: TextPart | None = None
|
|
789
|
+
|
|
790
|
+
try:
|
|
791
|
+
agent = state.agent
|
|
792
|
+
# Stream events from the agent with the summarization prompt
|
|
793
|
+
# This runs with FULL history - the summary is based on complete context
|
|
794
|
+
async for event in agent.run_stream(SUMMARIZE_PROMPT):
|
|
795
|
+
match event:
|
|
796
|
+
# Text streaming start
|
|
797
|
+
case PartStartEvent(part=PydanticTextPart(content=delta)):
|
|
798
|
+
response_text = delta
|
|
799
|
+
text_part = TextPart(
|
|
800
|
+
id=identifier.ascending("part"),
|
|
801
|
+
message_id=assistant_msg_id,
|
|
802
|
+
session_id=session_id,
|
|
803
|
+
text=delta,
|
|
804
|
+
)
|
|
805
|
+
assistant_msg_with_parts.parts.append(text_part)
|
|
806
|
+
await state.broadcast_event(PartUpdatedEvent.create(text_part, delta=delta))
|
|
807
|
+
|
|
808
|
+
# Text streaming delta
|
|
809
|
+
case PartDeltaEvent(delta=TextPartDelta(content_delta=delta)) if delta:
|
|
810
|
+
response_text += delta
|
|
811
|
+
if text_part is not None:
|
|
812
|
+
text_part = TextPart(
|
|
813
|
+
id=text_part.id,
|
|
814
|
+
message_id=assistant_msg_id,
|
|
815
|
+
session_id=session_id,
|
|
816
|
+
text=response_text,
|
|
817
|
+
)
|
|
818
|
+
# Update in parts list
|
|
819
|
+
for i, p in enumerate(assistant_msg_with_parts.parts):
|
|
820
|
+
if isinstance(p, TextPart) and p.id == text_part.id:
|
|
821
|
+
assistant_msg_with_parts.parts[i] = text_part
|
|
822
|
+
break
|
|
823
|
+
await state.broadcast_event(PartUpdatedEvent.create(text_part, delta=delta))
|
|
824
|
+
|
|
825
|
+
# Stream complete - extract token usage
|
|
826
|
+
case StreamCompleteEvent(message=msg) if msg and msg.usage:
|
|
827
|
+
input_tokens = msg.usage.input_tokens or 0
|
|
828
|
+
output_tokens = msg.usage.output_tokens or 0
|
|
829
|
+
|
|
830
|
+
except Exception as e: # noqa: BLE001
|
|
831
|
+
response_text = f"Error generating summary: {e}"
|
|
832
|
+
|
|
833
|
+
response_time = now_ms()
|
|
834
|
+
|
|
835
|
+
# Create/update text part with final response
|
|
836
|
+
if text_part is None:
|
|
837
|
+
text_part = TextPart(
|
|
838
|
+
id=identifier.ascending("part"),
|
|
839
|
+
message_id=assistant_msg_id,
|
|
840
|
+
session_id=session_id,
|
|
841
|
+
text=response_text,
|
|
842
|
+
)
|
|
843
|
+
assistant_msg_with_parts.parts.append(text_part)
|
|
844
|
+
await state.broadcast_event(PartUpdatedEvent.create(text_part))
|
|
845
|
+
|
|
846
|
+
# Step 2: Run compaction pipeline AFTER summary is generated
|
|
847
|
+
# The summary was generated with full context. Now we compact the history.
|
|
848
|
+
# Final state will be: [compacted history] + [summary message]
|
|
849
|
+
# The compacted history becomes the cached prefix for future LLM calls.
|
|
850
|
+
try:
|
|
851
|
+
from agentpool.messaging.compaction import compact_conversation, summarizing_context
|
|
852
|
+
|
|
853
|
+
# Get the compaction pipeline from the agent pool configuration
|
|
854
|
+
pipeline = None
|
|
855
|
+
if state.agent.agent_pool is not None:
|
|
856
|
+
pipeline = state.agent.agent_pool.compaction_pipeline
|
|
857
|
+
|
|
858
|
+
if pipeline is None:
|
|
859
|
+
# Fall back to a default summarizing pipeline
|
|
860
|
+
pipeline = summarizing_context()
|
|
861
|
+
|
|
862
|
+
# Apply the compaction pipeline (modifies agent.conversation in place)
|
|
863
|
+
await compact_conversation(pipeline, state.agent.conversation)
|
|
864
|
+
|
|
865
|
+
# Persist compacted messages to storage, replacing the old ones
|
|
866
|
+
if state.pool.storage is not None:
|
|
867
|
+
compacted_history = state.agent.conversation.get_history()
|
|
868
|
+
await state.pool.storage.replace_conversation_messages(
|
|
869
|
+
session_id,
|
|
870
|
+
compacted_history,
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
# Update in-memory OpenCode messages list with compacted versions
|
|
874
|
+
# Keep only the summary message we just created
|
|
875
|
+
state.messages[session_id] = [assistant_msg_with_parts]
|
|
876
|
+
|
|
877
|
+
except Exception: # noqa: BLE001
|
|
878
|
+
# Compaction failure is not fatal - we still have the summary
|
|
879
|
+
pass
|
|
880
|
+
|
|
881
|
+
# Add step-finish part
|
|
882
|
+
step_finish = StepFinishPart(
|
|
883
|
+
id=identifier.ascending("part"),
|
|
884
|
+
message_id=assistant_msg_id,
|
|
885
|
+
session_id=session_id,
|
|
886
|
+
tokens=StepFinishTokens(
|
|
887
|
+
cache=TokenCache(read=0, write=0),
|
|
888
|
+
input=input_tokens,
|
|
889
|
+
output=output_tokens,
|
|
890
|
+
reasoning=0,
|
|
891
|
+
),
|
|
892
|
+
)
|
|
893
|
+
assistant_msg_with_parts.parts.append(step_finish)
|
|
894
|
+
await state.broadcast_event(PartUpdatedEvent.create(step_finish))
|
|
895
|
+
# Update message with completion time and tokens
|
|
896
|
+
updated_assistant = assistant_message.model_copy(
|
|
897
|
+
update={
|
|
898
|
+
"time": MessageTime(created=now, completed=response_time),
|
|
899
|
+
"tokens": Tokens(
|
|
900
|
+
cache=TokensCache(read=0, write=0),
|
|
901
|
+
input=input_tokens,
|
|
902
|
+
output=output_tokens,
|
|
903
|
+
reasoning=0,
|
|
904
|
+
),
|
|
905
|
+
}
|
|
906
|
+
)
|
|
907
|
+
assistant_msg_with_parts.info = updated_assistant
|
|
908
|
+
await state.broadcast_event(MessageUpdatedEvent.create(updated_assistant))
|
|
909
|
+
# Mark session as idle
|
|
910
|
+
state.session_status[session_id] = SessionStatus(type="idle")
|
|
911
|
+
await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="idle")))
|
|
912
|
+
return assistant_msg_with_parts
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
@router.post("/{session_id}/share")
|
|
916
|
+
async def share_session(
|
|
917
|
+
session_id: str,
|
|
918
|
+
state: StateDep,
|
|
919
|
+
num_messages: int | None = None,
|
|
920
|
+
) -> Session:
|
|
921
|
+
"""Share session conversation history via OpenCode's sharing service.
|
|
922
|
+
|
|
923
|
+
Uses the OpenCode share API to create a shareable link with the full
|
|
924
|
+
conversation including messages and parts.
|
|
925
|
+
|
|
926
|
+
Returns the updated session with the share URL.
|
|
927
|
+
"""
|
|
928
|
+
session = await get_or_load_session(state, session_id)
|
|
929
|
+
if session is None:
|
|
930
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
931
|
+
messages = state.messages.get(session_id, [])
|
|
932
|
+
|
|
933
|
+
if not messages:
|
|
934
|
+
raise HTTPException(status_code=400, detail="No messages to share")
|
|
935
|
+
|
|
936
|
+
# Apply message limit if specified
|
|
937
|
+
if num_messages is not None and num_messages > 0:
|
|
938
|
+
messages = messages[-num_messages:]
|
|
939
|
+
# Convert our messages to OpenCode Message format
|
|
940
|
+
opencode_messages: list[Message] = []
|
|
941
|
+
for msg_with_parts in messages:
|
|
942
|
+
info = msg_with_parts.info
|
|
943
|
+
# Map role to OpenCode sharing roles
|
|
944
|
+
role = info.role
|
|
945
|
+
if role == "model": # type: ignore[comparison-overlap]
|
|
946
|
+
mapped_role: Literal["user", "assistant", "system"] = "assistant"
|
|
947
|
+
elif role in ("user", "assistant", "system"):
|
|
948
|
+
mapped_role = role
|
|
949
|
+
else:
|
|
950
|
+
mapped_role = "user"
|
|
951
|
+
|
|
952
|
+
# Extract text parts
|
|
953
|
+
parts = [
|
|
954
|
+
MessagePart(type="text", text=part.text)
|
|
955
|
+
for part in msg_with_parts.parts
|
|
956
|
+
if isinstance(part, TextPart) and part.text
|
|
957
|
+
]
|
|
958
|
+
if parts:
|
|
959
|
+
opencode_messages.append(Message(role=mapped_role, parts=parts))
|
|
960
|
+
if not opencode_messages:
|
|
961
|
+
raise HTTPException(status_code=400, detail="No content to share")
|
|
962
|
+
|
|
963
|
+
# Share via OpenCode API
|
|
964
|
+
async with OpenCodeSharer() as sharer:
|
|
965
|
+
result = await sharer.share_conversation(opencode_messages, title=session.title)
|
|
966
|
+
share_url = result.url
|
|
967
|
+
# Store the share URL in the session
|
|
968
|
+
share_info = SessionShare(url=share_url)
|
|
969
|
+
updated_session = session.model_copy(update={"share": share_info})
|
|
970
|
+
state.sessions[session_id] = updated_session
|
|
971
|
+
# Broadcast session update
|
|
972
|
+
await state.broadcast_event(SessionUpdatedEvent.create(updated_session))
|
|
973
|
+
return updated_session
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
class RevertRequest(OpenCodeBaseModel):
|
|
977
|
+
"""Request body for reverting a message."""
|
|
978
|
+
|
|
979
|
+
message_id: str
|
|
980
|
+
part_id: str | None = None
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
@router.post("/{session_id}/revert")
|
|
984
|
+
async def revert_session(session_id: str, request: RevertRequest, state: StateDep) -> Session:
|
|
985
|
+
"""Revert file changes from a specific message.
|
|
986
|
+
|
|
987
|
+
Restores files to their state before the specified message's changes.
|
|
988
|
+
"""
|
|
989
|
+
session = await get_or_load_session(state, session_id)
|
|
990
|
+
if session is None:
|
|
991
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
992
|
+
file_ops = state.pool.file_ops
|
|
993
|
+
if not file_ops.changes:
|
|
994
|
+
raise HTTPException(status_code=400, detail="No file changes to revert")
|
|
995
|
+
# Get revert operations for changes since this message
|
|
996
|
+
revert_ops = file_ops.get_revert_operations(since_message_id=request.message_id)
|
|
997
|
+
|
|
998
|
+
if not revert_ops:
|
|
999
|
+
detail = f"No changes found for message {request.message_id}"
|
|
1000
|
+
raise HTTPException(status_code=404, detail=detail)
|
|
1001
|
+
# Get filesystem from the agent's environment
|
|
1002
|
+
fs = state.agent.env.get_fs()
|
|
1003
|
+
# Apply reverts using the filesystem
|
|
1004
|
+
# TODO: Currently write operations only track "existed vs created", not full old content.
|
|
1005
|
+
# Files that existed before a write will be restored as empty, not their original content.
|
|
1006
|
+
reverted_paths = []
|
|
1007
|
+
for path, content in revert_ops:
|
|
1008
|
+
try:
|
|
1009
|
+
if content is None:
|
|
1010
|
+
# File was created (old_text=None), delete it
|
|
1011
|
+
await fs._rm_file(path)
|
|
1012
|
+
else:
|
|
1013
|
+
# Restore original content
|
|
1014
|
+
content_bytes = content.encode("utf-8")
|
|
1015
|
+
await fs._pipe_file(path, content_bytes)
|
|
1016
|
+
reverted_paths.append(path)
|
|
1017
|
+
except Exception as e:
|
|
1018
|
+
raise HTTPException(status_code=500, detail=f"Failed to revert {path}: {e}") from e
|
|
1019
|
+
|
|
1020
|
+
# Remove the reverted changes from the tracker
|
|
1021
|
+
file_ops.remove_changes_since_message(request.message_id)
|
|
1022
|
+
# Update session with revert info
|
|
1023
|
+
session = state.sessions[session_id]
|
|
1024
|
+
# TODO: include the diff?
|
|
1025
|
+
revert_info = SessionRevert(message_id=request.message_id, part_id=request.part_id)
|
|
1026
|
+
updated_session = session.model_copy(update={"revert": revert_info})
|
|
1027
|
+
state.sessions[session_id] = updated_session
|
|
1028
|
+
# Broadcast session update
|
|
1029
|
+
await state.broadcast_event(SessionUpdatedEvent.create(updated_session))
|
|
1030
|
+
return updated_session
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
@router.post("/{session_id}/unrevert")
|
|
1034
|
+
async def unrevert_session(session_id: str, state: StateDep) -> Session:
|
|
1035
|
+
"""Restore all reverted file changes.
|
|
1036
|
+
|
|
1037
|
+
Re-applies the changes that were previously reverted.
|
|
1038
|
+
"""
|
|
1039
|
+
session = await get_or_load_session(state, session_id)
|
|
1040
|
+
if session is None:
|
|
1041
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1042
|
+
file_ops = state.pool.file_ops
|
|
1043
|
+
if not file_ops.reverted_changes:
|
|
1044
|
+
raise HTTPException(status_code=400, detail="No reverted changes to restore")
|
|
1045
|
+
# Get unrevert operations
|
|
1046
|
+
unrevert_ops = file_ops.get_unrevert_operations()
|
|
1047
|
+
# Get filesystem from the agent's environment
|
|
1048
|
+
fs = state.agent.env.get_fs()
|
|
1049
|
+
# Apply unrevert - write back the new_content
|
|
1050
|
+
for path, content in unrevert_ops:
|
|
1051
|
+
try:
|
|
1052
|
+
if content is None:
|
|
1053
|
+
# File was deleted in the original change, delete it again
|
|
1054
|
+
await fs._rm_file(path)
|
|
1055
|
+
else:
|
|
1056
|
+
# Restore the changed content
|
|
1057
|
+
content_bytes = content.encode("utf-8")
|
|
1058
|
+
await fs._pipe_file(path, content_bytes)
|
|
1059
|
+
except Exception as e:
|
|
1060
|
+
detail = f"Failed to unrevert {path}: {e}"
|
|
1061
|
+
raise HTTPException(status_code=500, detail=detail) from e
|
|
1062
|
+
|
|
1063
|
+
# Restore the changes to the tracker
|
|
1064
|
+
file_ops.restore_reverted_changes()
|
|
1065
|
+
# Clear revert info from session
|
|
1066
|
+
updated_session = session.model_copy(update={"revert": None})
|
|
1067
|
+
state.sessions[session_id] = updated_session
|
|
1068
|
+
# Broadcast session update
|
|
1069
|
+
await state.broadcast_event(SessionUpdatedEvent.create(updated_session))
|
|
1070
|
+
return updated_session
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
@router.delete("/{session_id}/share")
|
|
1074
|
+
async def unshare_session(session_id: str, state: StateDep) -> bool:
|
|
1075
|
+
"""Remove share link from a session.
|
|
1076
|
+
|
|
1077
|
+
Note: This only removes the link from the session metadata.
|
|
1078
|
+
The shared content may still exist on the provider's servers.
|
|
1079
|
+
"""
|
|
1080
|
+
session = await get_or_load_session(state, session_id)
|
|
1081
|
+
if session is None:
|
|
1082
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1083
|
+
if session.share is None:
|
|
1084
|
+
raise HTTPException(status_code=400, detail="Session is not shared")
|
|
1085
|
+
# Remove share info from session
|
|
1086
|
+
updated_session = session.model_copy(update={"share": None})
|
|
1087
|
+
state.sessions[session_id] = updated_session
|
|
1088
|
+
# Broadcast session update
|
|
1089
|
+
await state.broadcast_event(SessionUpdatedEvent.create(updated_session))
|
|
1090
|
+
return True
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
@router.post("/{session_id}/command")
|
|
1094
|
+
async def execute_command( # noqa: PLR0915
|
|
1095
|
+
session_id: str,
|
|
1096
|
+
request: CommandRequest,
|
|
1097
|
+
state: StateDep,
|
|
1098
|
+
) -> MessageWithParts:
|
|
1099
|
+
"""Execute a slash command (MCP prompt).
|
|
1100
|
+
|
|
1101
|
+
Commands are mapped to MCP prompts. The command name is used to find
|
|
1102
|
+
the matching prompt, and arguments are parsed and passed to it.
|
|
1103
|
+
"""
|
|
1104
|
+
session = await get_or_load_session(state, session_id)
|
|
1105
|
+
if session is None:
|
|
1106
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1107
|
+
prompts = await state.agent.tools.list_prompts()
|
|
1108
|
+
# Find matching prompt by name
|
|
1109
|
+
prompt = next((p for p in prompts if p.name == request.command), None)
|
|
1110
|
+
if prompt is None:
|
|
1111
|
+
detail = f"Command not found: {request.command}"
|
|
1112
|
+
raise HTTPException(status_code=404, detail=detail)
|
|
1113
|
+
|
|
1114
|
+
# Parse arguments - OpenCode uses $1, $2 style, MCP uses named arguments
|
|
1115
|
+
# For simplicity, we'll pass the raw arguments string to the first argument
|
|
1116
|
+
# or parse space-separated args into a dict
|
|
1117
|
+
arguments: dict[str, str] = {}
|
|
1118
|
+
if request.arguments and prompt.arguments:
|
|
1119
|
+
# Split arguments and map to prompt argument names
|
|
1120
|
+
arg_values = request.arguments.split()
|
|
1121
|
+
for i, arg_def in enumerate(prompt.arguments):
|
|
1122
|
+
if i < len(arg_values):
|
|
1123
|
+
arguments[arg_def["name"]] = arg_values[i]
|
|
1124
|
+
|
|
1125
|
+
now = now_ms()
|
|
1126
|
+
# Create assistant message
|
|
1127
|
+
assistant_msg_id = identifier.ascending("message")
|
|
1128
|
+
assistant_message = AssistantMessage(
|
|
1129
|
+
id=assistant_msg_id,
|
|
1130
|
+
session_id=session_id,
|
|
1131
|
+
parent_id="",
|
|
1132
|
+
model_id=request.model or "default",
|
|
1133
|
+
provider_id="mcp",
|
|
1134
|
+
mode="command",
|
|
1135
|
+
agent=request.agent or "default",
|
|
1136
|
+
path=MessagePath(cwd=state.working_dir, root=state.working_dir),
|
|
1137
|
+
time=MessageTime(created=now, completed=None),
|
|
1138
|
+
tokens=Tokens(cache=TokensCache(read=0, write=0), input=0, output=0, reasoning=0),
|
|
1139
|
+
cost=0.0,
|
|
1140
|
+
)
|
|
1141
|
+
assistant_msg_with_parts = MessageWithParts(info=assistant_message, parts=[])
|
|
1142
|
+
state.messages[session_id].append(assistant_msg_with_parts)
|
|
1143
|
+
await state.broadcast_event(MessageUpdatedEvent.create(assistant_message))
|
|
1144
|
+
# Mark session as busy
|
|
1145
|
+
state.session_status[session_id] = SessionStatus(type="busy")
|
|
1146
|
+
await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="busy")))
|
|
1147
|
+
# Add step-start part
|
|
1148
|
+
part_id = identifier.ascending("part")
|
|
1149
|
+
step_start = StepStartPart(id=part_id, message_id=assistant_msg_id, session_id=session_id)
|
|
1150
|
+
assistant_msg_with_parts.parts.append(step_start)
|
|
1151
|
+
await state.broadcast_event(PartUpdatedEvent.create(step_start))
|
|
1152
|
+
|
|
1153
|
+
# Get prompt content and execute through the agent
|
|
1154
|
+
try:
|
|
1155
|
+
prompt_parts = await prompt.get_components(arguments)
|
|
1156
|
+
# Extract text content from parts
|
|
1157
|
+
prompt_texts = []
|
|
1158
|
+
for part in prompt_parts:
|
|
1159
|
+
if hasattr(part, "content"):
|
|
1160
|
+
content = part.content
|
|
1161
|
+
if isinstance(content, str):
|
|
1162
|
+
prompt_texts.append(content)
|
|
1163
|
+
elif isinstance(content, list):
|
|
1164
|
+
# Handle Sequence[UserContent]
|
|
1165
|
+
for item in content:
|
|
1166
|
+
if isinstance(item, FileUrl):
|
|
1167
|
+
prompt_texts.append(item.url)
|
|
1168
|
+
elif isinstance(item, str):
|
|
1169
|
+
prompt_texts.append(item)
|
|
1170
|
+
prompt_text = "\n".join(prompt_texts)
|
|
1171
|
+
# Run the expanded prompt through the agent
|
|
1172
|
+
result = await state.agent.run(prompt_text)
|
|
1173
|
+
output_text = str(result.data)
|
|
1174
|
+
|
|
1175
|
+
except Exception as e: # noqa: BLE001
|
|
1176
|
+
output_text = f"Error executing command: {e}"
|
|
1177
|
+
|
|
1178
|
+
response_time = now_ms()
|
|
1179
|
+
# Create text part with output
|
|
1180
|
+
text_part = TextPart(
|
|
1181
|
+
id=identifier.ascending("part"),
|
|
1182
|
+
message_id=assistant_msg_id,
|
|
1183
|
+
session_id=session_id,
|
|
1184
|
+
text=output_text,
|
|
1185
|
+
)
|
|
1186
|
+
assistant_msg_with_parts.parts.append(text_part)
|
|
1187
|
+
await state.broadcast_event(PartUpdatedEvent.create(text_part))
|
|
1188
|
+
step_finish = StepFinishPart(
|
|
1189
|
+
id=identifier.ascending("part"),
|
|
1190
|
+
message_id=assistant_msg_id,
|
|
1191
|
+
session_id=session_id,
|
|
1192
|
+
tokens=StepFinishTokens(cache=TokenCache()),
|
|
1193
|
+
cost=0.0,
|
|
1194
|
+
)
|
|
1195
|
+
assistant_msg_with_parts.parts.append(step_finish)
|
|
1196
|
+
await state.broadcast_event(PartUpdatedEvent.create(step_finish))
|
|
1197
|
+
# Update message with completion time
|
|
1198
|
+
time_ = MessageTime(created=now, completed=response_time)
|
|
1199
|
+
updated_assistant = assistant_message.model_copy(update={"time": time_})
|
|
1200
|
+
assistant_msg_with_parts.info = updated_assistant
|
|
1201
|
+
await state.broadcast_event(MessageUpdatedEvent.create(updated_assistant))
|
|
1202
|
+
# Mark session as idle
|
|
1203
|
+
state.session_status[session_id] = SessionStatus(type="idle")
|
|
1204
|
+
await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="idle")))
|
|
1205
|
+
return assistant_msg_with_parts
|