agentpool 2.1.9__py3-none-any.whl → 2.5.0__py3-none-any.whl

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