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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. acp/__init__.py +0 -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/client/connection.py +38 -29
  7. acp/client/implementations/default_client.py +3 -2
  8. acp/client/implementations/headless_client.py +2 -2
  9. acp/connection.py +2 -2
  10. acp/notifications.py +18 -49
  11. acp/schema/__init__.py +2 -0
  12. acp/schema/agent_responses.py +21 -0
  13. acp/schema/client_requests.py +3 -3
  14. acp/schema/session_state.py +63 -29
  15. acp/task/supervisor.py +2 -2
  16. acp/utils.py +2 -2
  17. agentpool/__init__.py +2 -0
  18. agentpool/agents/acp_agent/acp_agent.py +278 -263
  19. agentpool/agents/acp_agent/acp_converters.py +150 -17
  20. agentpool/agents/acp_agent/client_handler.py +35 -24
  21. agentpool/agents/acp_agent/session_state.py +14 -6
  22. agentpool/agents/agent.py +471 -643
  23. agentpool/agents/agui_agent/agui_agent.py +104 -107
  24. agentpool/agents/agui_agent/helpers.py +3 -4
  25. agentpool/agents/base_agent.py +485 -32
  26. agentpool/agents/claude_code_agent/FORKING.md +191 -0
  27. agentpool/agents/claude_code_agent/__init__.py +13 -1
  28. agentpool/agents/claude_code_agent/claude_code_agent.py +654 -334
  29. agentpool/agents/claude_code_agent/converters.py +4 -141
  30. agentpool/agents/claude_code_agent/models.py +77 -0
  31. agentpool/agents/claude_code_agent/static_info.py +100 -0
  32. agentpool/agents/claude_code_agent/usage.py +242 -0
  33. agentpool/agents/events/__init__.py +22 -0
  34. agentpool/agents/events/builtin_handlers.py +65 -0
  35. agentpool/agents/events/event_emitter.py +3 -0
  36. agentpool/agents/events/events.py +84 -3
  37. agentpool/agents/events/infer_info.py +145 -0
  38. agentpool/agents/events/processors.py +254 -0
  39. agentpool/agents/interactions.py +41 -6
  40. agentpool/agents/modes.py +13 -0
  41. agentpool/agents/slashed_agent.py +5 -4
  42. agentpool/agents/tool_wrapping.py +18 -6
  43. agentpool/common_types.py +35 -21
  44. agentpool/config_resources/acp_assistant.yml +2 -2
  45. agentpool/config_resources/agents.yml +3 -0
  46. agentpool/config_resources/agents_template.yml +1 -0
  47. agentpool/config_resources/claude_code_agent.yml +9 -8
  48. agentpool/config_resources/external_acp_agents.yml +2 -1
  49. agentpool/delegation/base_team.py +4 -30
  50. agentpool/delegation/pool.py +104 -265
  51. agentpool/delegation/team.py +57 -57
  52. agentpool/delegation/teamrun.py +50 -55
  53. agentpool/functional/run.py +10 -4
  54. agentpool/mcp_server/client.py +73 -38
  55. agentpool/mcp_server/conversions.py +54 -13
  56. agentpool/mcp_server/manager.py +9 -23
  57. agentpool/mcp_server/registries/official_registry_client.py +10 -1
  58. agentpool/mcp_server/tool_bridge.py +114 -79
  59. agentpool/messaging/connection_manager.py +11 -10
  60. agentpool/messaging/event_manager.py +5 -5
  61. agentpool/messaging/message_container.py +6 -30
  62. agentpool/messaging/message_history.py +87 -8
  63. agentpool/messaging/messagenode.py +52 -14
  64. agentpool/messaging/messages.py +2 -26
  65. agentpool/messaging/processing.py +10 -22
  66. agentpool/models/__init__.py +1 -1
  67. agentpool/models/acp_agents/base.py +6 -2
  68. agentpool/models/acp_agents/mcp_capable.py +124 -15
  69. agentpool/models/acp_agents/non_mcp.py +0 -23
  70. agentpool/models/agents.py +66 -66
  71. agentpool/models/agui_agents.py +1 -1
  72. agentpool/models/claude_code_agents.py +111 -17
  73. agentpool/models/file_parsing.py +0 -1
  74. agentpool/models/manifest.py +70 -50
  75. agentpool/prompts/conversion_manager.py +1 -1
  76. agentpool/prompts/prompts.py +5 -2
  77. agentpool/resource_providers/__init__.py +2 -0
  78. agentpool/resource_providers/aggregating.py +4 -2
  79. agentpool/resource_providers/base.py +13 -3
  80. agentpool/resource_providers/codemode/code_executor.py +72 -5
  81. agentpool/resource_providers/codemode/helpers.py +2 -2
  82. agentpool/resource_providers/codemode/provider.py +64 -12
  83. agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
  84. agentpool/resource_providers/codemode/remote_provider.py +9 -12
  85. agentpool/resource_providers/filtering.py +3 -1
  86. agentpool/resource_providers/mcp_provider.py +66 -12
  87. agentpool/resource_providers/plan_provider.py +111 -18
  88. agentpool/resource_providers/pool.py +5 -3
  89. agentpool/resource_providers/resource_info.py +111 -0
  90. agentpool/resource_providers/static.py +2 -2
  91. agentpool/sessions/__init__.py +2 -0
  92. agentpool/sessions/manager.py +2 -3
  93. agentpool/sessions/models.py +9 -6
  94. agentpool/sessions/protocol.py +28 -0
  95. agentpool/sessions/session.py +11 -55
  96. agentpool/storage/manager.py +361 -54
  97. agentpool/talk/registry.py +4 -4
  98. agentpool/talk/talk.py +9 -10
  99. agentpool/testing.py +1 -1
  100. agentpool/tool_impls/__init__.py +6 -0
  101. agentpool/tool_impls/agent_cli/__init__.py +42 -0
  102. agentpool/tool_impls/agent_cli/tool.py +95 -0
  103. agentpool/tool_impls/bash/__init__.py +64 -0
  104. agentpool/tool_impls/bash/helpers.py +35 -0
  105. agentpool/tool_impls/bash/tool.py +171 -0
  106. agentpool/tool_impls/delete_path/__init__.py +70 -0
  107. agentpool/tool_impls/delete_path/tool.py +142 -0
  108. agentpool/tool_impls/download_file/__init__.py +80 -0
  109. agentpool/tool_impls/download_file/tool.py +183 -0
  110. agentpool/tool_impls/execute_code/__init__.py +55 -0
  111. agentpool/tool_impls/execute_code/tool.py +163 -0
  112. agentpool/tool_impls/grep/__init__.py +80 -0
  113. agentpool/tool_impls/grep/tool.py +200 -0
  114. agentpool/tool_impls/list_directory/__init__.py +73 -0
  115. agentpool/tool_impls/list_directory/tool.py +197 -0
  116. agentpool/tool_impls/question/__init__.py +42 -0
  117. agentpool/tool_impls/question/tool.py +127 -0
  118. agentpool/tool_impls/read/__init__.py +104 -0
  119. agentpool/tool_impls/read/tool.py +305 -0
  120. agentpool/tools/__init__.py +2 -1
  121. agentpool/tools/base.py +114 -34
  122. agentpool/tools/manager.py +57 -1
  123. agentpool/ui/base.py +2 -2
  124. agentpool/ui/mock_provider.py +2 -2
  125. agentpool/ui/stdlib_provider.py +2 -2
  126. agentpool/utils/streams.py +21 -96
  127. agentpool/vfs_registry.py +7 -2
  128. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/METADATA +16 -22
  129. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/RECORD +242 -195
  130. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
  131. agentpool_cli/__main__.py +20 -0
  132. agentpool_cli/create.py +1 -1
  133. agentpool_cli/serve_acp.py +59 -1
  134. agentpool_cli/serve_opencode.py +1 -1
  135. agentpool_cli/ui.py +557 -0
  136. agentpool_commands/__init__.py +12 -5
  137. agentpool_commands/agents.py +1 -1
  138. agentpool_commands/pool.py +260 -0
  139. agentpool_commands/session.py +1 -1
  140. agentpool_commands/text_sharing/__init__.py +119 -0
  141. agentpool_commands/text_sharing/base.py +123 -0
  142. agentpool_commands/text_sharing/github_gist.py +80 -0
  143. agentpool_commands/text_sharing/opencode.py +462 -0
  144. agentpool_commands/text_sharing/paste_rs.py +59 -0
  145. agentpool_commands/text_sharing/pastebin.py +116 -0
  146. agentpool_commands/text_sharing/shittycodingagent.py +112 -0
  147. agentpool_commands/utils.py +31 -32
  148. agentpool_config/__init__.py +30 -2
  149. agentpool_config/agentpool_tools.py +498 -0
  150. agentpool_config/converters.py +1 -1
  151. agentpool_config/event_handlers.py +42 -0
  152. agentpool_config/events.py +1 -1
  153. agentpool_config/forward_targets.py +1 -4
  154. agentpool_config/jinja.py +3 -3
  155. agentpool_config/mcp_server.py +1 -5
  156. agentpool_config/nodes.py +1 -1
  157. agentpool_config/observability.py +44 -0
  158. agentpool_config/session.py +0 -3
  159. agentpool_config/storage.py +38 -39
  160. agentpool_config/task.py +3 -3
  161. agentpool_config/tools.py +11 -28
  162. agentpool_config/toolsets.py +22 -90
  163. agentpool_server/a2a_server/agent_worker.py +307 -0
  164. agentpool_server/a2a_server/server.py +23 -18
  165. agentpool_server/acp_server/acp_agent.py +125 -56
  166. agentpool_server/acp_server/commands/acp_commands.py +46 -216
  167. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +8 -7
  168. agentpool_server/acp_server/event_converter.py +651 -0
  169. agentpool_server/acp_server/input_provider.py +53 -10
  170. agentpool_server/acp_server/server.py +1 -11
  171. agentpool_server/acp_server/session.py +90 -410
  172. agentpool_server/acp_server/session_manager.py +8 -34
  173. agentpool_server/agui_server/server.py +3 -1
  174. agentpool_server/mcp_server/server.py +5 -2
  175. agentpool_server/opencode_server/ENDPOINTS.md +53 -14
  176. agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
  177. agentpool_server/opencode_server/__init__.py +0 -8
  178. agentpool_server/opencode_server/converters.py +132 -26
  179. agentpool_server/opencode_server/input_provider.py +160 -8
  180. agentpool_server/opencode_server/models/__init__.py +42 -20
  181. agentpool_server/opencode_server/models/app.py +12 -0
  182. agentpool_server/opencode_server/models/events.py +203 -29
  183. agentpool_server/opencode_server/models/mcp.py +19 -0
  184. agentpool_server/opencode_server/models/message.py +18 -1
  185. agentpool_server/opencode_server/models/parts.py +134 -1
  186. agentpool_server/opencode_server/models/question.py +56 -0
  187. agentpool_server/opencode_server/models/session.py +13 -1
  188. agentpool_server/opencode_server/routes/__init__.py +4 -0
  189. agentpool_server/opencode_server/routes/agent_routes.py +33 -2
  190. agentpool_server/opencode_server/routes/app_routes.py +66 -3
  191. agentpool_server/opencode_server/routes/config_routes.py +66 -5
  192. agentpool_server/opencode_server/routes/file_routes.py +184 -5
  193. agentpool_server/opencode_server/routes/global_routes.py +1 -1
  194. agentpool_server/opencode_server/routes/lsp_routes.py +1 -1
  195. agentpool_server/opencode_server/routes/message_routes.py +122 -66
  196. agentpool_server/opencode_server/routes/permission_routes.py +63 -0
  197. agentpool_server/opencode_server/routes/pty_routes.py +23 -22
  198. agentpool_server/opencode_server/routes/question_routes.py +128 -0
  199. agentpool_server/opencode_server/routes/session_routes.py +139 -68
  200. agentpool_server/opencode_server/routes/tui_routes.py +1 -1
  201. agentpool_server/opencode_server/server.py +47 -2
  202. agentpool_server/opencode_server/state.py +30 -0
  203. agentpool_storage/__init__.py +0 -4
  204. agentpool_storage/base.py +81 -2
  205. agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
  206. agentpool_storage/claude_provider/__init__.py +42 -0
  207. agentpool_storage/{claude_provider.py → claude_provider/provider.py} +190 -8
  208. agentpool_storage/file_provider.py +149 -15
  209. agentpool_storage/memory_provider.py +132 -12
  210. agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
  211. agentpool_storage/opencode_provider/__init__.py +16 -0
  212. agentpool_storage/opencode_provider/helpers.py +414 -0
  213. agentpool_storage/opencode_provider/provider.py +895 -0
  214. agentpool_storage/session_store.py +20 -6
  215. agentpool_storage/sql_provider/sql_provider.py +135 -2
  216. agentpool_storage/sql_provider/utils.py +2 -12
  217. agentpool_storage/zed_provider/__init__.py +16 -0
  218. agentpool_storage/zed_provider/helpers.py +281 -0
  219. agentpool_storage/zed_provider/models.py +130 -0
  220. agentpool_storage/zed_provider/provider.py +442 -0
  221. agentpool_storage/zed_provider.py +803 -0
  222. agentpool_toolsets/__init__.py +0 -2
  223. agentpool_toolsets/builtin/__init__.py +2 -4
  224. agentpool_toolsets/builtin/code.py +4 -4
  225. agentpool_toolsets/builtin/debug.py +115 -40
  226. agentpool_toolsets/builtin/execution_environment.py +54 -165
  227. agentpool_toolsets/builtin/skills.py +0 -77
  228. agentpool_toolsets/builtin/subagent_tools.py +64 -51
  229. agentpool_toolsets/builtin/workers.py +4 -2
  230. agentpool_toolsets/composio_toolset.py +2 -2
  231. agentpool_toolsets/entry_points.py +3 -1
  232. agentpool_toolsets/fsspec_toolset/grep.py +25 -5
  233. agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
  234. agentpool_toolsets/fsspec_toolset/toolset.py +350 -66
  235. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  236. agentpool_toolsets/mcp_discovery/toolset.py +74 -17
  237. agentpool_toolsets/mcp_run_toolset.py +8 -11
  238. agentpool_toolsets/notifications.py +33 -33
  239. agentpool_toolsets/openapi.py +3 -1
  240. agentpool_toolsets/search_toolset.py +3 -1
  241. agentpool_config/resources.py +0 -33
  242. agentpool_server/acp_server/acp_tools.py +0 -43
  243. agentpool_server/acp_server/commands/spawn.py +0 -210
  244. agentpool_storage/opencode_provider.py +0 -730
  245. agentpool_storage/text_log_provider.py +0 -276
  246. agentpool_toolsets/builtin/chain.py +0 -288
  247. agentpool_toolsets/builtin/user_interaction.py +0 -52
  248. agentpool_toolsets/semantic_memory_toolset.py +0 -536
  249. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
  250. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING, Any
6
6
 
7
- from fastapi import APIRouter, HTTPException, Query
7
+ from fastapi import APIRouter, HTTPException, Query, status
8
8
  from pydantic_ai import FunctionToolCallEvent
9
9
  from pydantic_ai.messages import (
10
10
  PartDeltaEvent,
@@ -14,18 +14,18 @@ from pydantic_ai.messages import (
14
14
  ToolCallPart as PydanticToolCallPart,
15
15
  )
16
16
 
17
- from agentpool.agents.claude_code_agent.converters import derive_rich_tool_info
18
17
  from agentpool.agents.events import (
19
18
  CompactionEvent,
20
19
  FileContentItem,
21
20
  LocationContentItem,
22
21
  StreamCompleteEvent,
22
+ SubAgentEvent,
23
23
  TextContentItem,
24
24
  ToolCallCompleteEvent,
25
25
  ToolCallProgressEvent,
26
26
  ToolCallStartEvent,
27
27
  )
28
- from agentpool.messaging.messages import ChatMessage
28
+ from agentpool.agents.events.infer_info import derive_rich_tool_info
29
29
  from agentpool.utils import identifiers as identifier
30
30
  from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict
31
31
  from agentpool_server.opencode_server.converters import (
@@ -33,22 +33,20 @@ from agentpool_server.opencode_server.converters import (
33
33
  extract_user_prompt_from_parts,
34
34
  opencode_to_chat_message,
35
35
  )
36
- from agentpool_server.opencode_server.dependencies import StateDep # noqa: TC001
37
- from agentpool_server.opencode_server.models import ( # noqa: TC001
36
+ from agentpool_server.opencode_server.dependencies import StateDep
37
+ from agentpool_server.opencode_server.models import (
38
38
  AssistantMessage,
39
39
  MessagePath,
40
40
  MessageRequest,
41
41
  MessageTime,
42
42
  MessageUpdatedEvent,
43
43
  MessageWithParts,
44
- Part,
45
44
  PartUpdatedEvent,
46
45
  SessionCompactedEvent,
47
46
  SessionErrorEvent,
48
47
  SessionIdleEvent,
49
48
  SessionStatus,
50
49
  SessionStatusEvent,
51
- SessionUpdatedEvent,
52
50
  StepFinishPart,
53
51
  StepStartPart,
54
52
  TextPart,
@@ -71,14 +69,14 @@ from agentpool_server.opencode_server.models.parts import (
71
69
  TimeStartEndOptional,
72
70
  TokenCache,
73
71
  )
74
- from agentpool_server.opencode_server.routes.session_routes import (
75
- get_or_load_session,
76
- opencode_to_session_data,
77
- )
72
+ from agentpool_server.opencode_server.routes.session_routes import get_or_load_session
78
73
  from agentpool_server.opencode_server.time_utils import now_ms
79
74
 
80
75
 
81
76
  if TYPE_CHECKING:
77
+ from agentpool_server.opencode_server.models import (
78
+ Part,
79
+ )
82
80
  from agentpool_server.opencode_server.state import ServerState
83
81
 
84
82
 
@@ -172,48 +170,6 @@ async def persist_message_to_storage(
172
170
  pass
173
171
 
174
172
 
175
- async def _generate_session_title(
176
- state: ServerState,
177
- session_id: str,
178
- user_prompt: str,
179
- assistant_response: str,
180
- ) -> None:
181
- """Generate a title for the session in the background."""
182
- try:
183
- if not state.pool.storage:
184
- return
185
-
186
- # Create ChatMessage objects for the title generator
187
- messages = [
188
- ChatMessage[str](role="user", content=user_prompt),
189
- ChatMessage[str](role="assistant", content=assistant_response),
190
- ]
191
-
192
- # Generate title using storage manager
193
- title = await state.pool.storage.generate_conversation_title(
194
- messages=messages,
195
- conversation_id=session_id,
196
- )
197
-
198
- if title and session_id in state.sessions:
199
- # Update session with new title
200
- session = state.sessions[session_id]
201
- updated_session = session.model_copy(update={"title": title})
202
- state.sessions[session_id] = updated_session
203
- session_data = opencode_to_session_data(
204
- updated_session,
205
- agent_name=state.agent.name,
206
- pool_id=state.pool.manifest.config_file_path,
207
- )
208
- await state.pool.sessions.store.save(session_data)
209
-
210
- # Broadcast session update
211
- await state.broadcast_event(SessionUpdatedEvent.create(updated_session))
212
- except Exception: # noqa: BLE001
213
- # Don't fail if title generation fails
214
- pass
215
-
216
-
217
173
  router = APIRouter(prefix="/session/{session_id}", tags=["message"])
218
174
 
219
175
 
@@ -234,13 +190,16 @@ async def list_messages(
234
190
  return messages
235
191
 
236
192
 
237
- @router.post("/message")
238
- async def send_message( # noqa: PLR0915
193
+ async def _process_message( # noqa: PLR0915
239
194
  session_id: str,
240
195
  request: MessageRequest,
241
196
  state: StateDep,
242
197
  ) -> MessageWithParts:
243
- """Send a message and get response from the agent."""
198
+ """Internal helper to process a message request.
199
+
200
+ This does the actual work of creating messages, running the agent,
201
+ and broadcasting events. Used by both sync and async endpoints.
202
+ """
244
203
  session = await get_or_load_session(state, session_id)
245
204
  if session is None:
246
205
  raise HTTPException(status_code=404, detail="Session not found")
@@ -280,7 +239,8 @@ async def send_message( # noqa: PLR0915
280
239
  for part in user_parts:
281
240
  await state.broadcast_event(PartUpdatedEvent.create(part))
282
241
  state.session_status[session_id] = SessionStatus(type="busy")
283
- await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="busy")))
242
+ status_event = SessionStatusEvent.create(session_id, SessionStatus(type="busy"))
243
+ await state.broadcast_event(status_event)
284
244
  # Extract user prompt text
285
245
  user_prompt = extract_user_prompt_from_parts([p.model_dump() for p in request.parts])
286
246
  # Create assistant message with sortable ID (must come after user message)
@@ -331,7 +291,7 @@ async def send_message( # noqa: PLR0915
331
291
  agent = state.agent.agent_pool.all_agents.get(request.agent, state.agent)
332
292
 
333
293
  # Stream events from the agent
334
- async for event in agent.run_stream(user_prompt):
294
+ async for event in agent.run_stream(user_prompt, conversation_id=session_id):
335
295
  match event:
336
296
  # Text streaming start
337
297
  case PartStartEvent(part=PydanticTextPart(content=delta)):
@@ -460,6 +420,8 @@ async def send_message( # noqa: PLR0915
460
420
  tool_input=event_tool_input,
461
421
  ) if tool_call_id:
462
422
  # Extract text content from items and accumulate
423
+ # TODO: Handle TerminalContentItem for bash tool streaming - need to
424
+ # properly stream terminal output to OpenCode UI metadata
463
425
  new_output = ""
464
426
  file_paths: list[str] = []
465
427
  for item in items:
@@ -536,6 +498,7 @@ async def send_message( # noqa: PLR0915
536
498
  case ToolCallCompleteEvent(
537
499
  tool_call_id=tool_call_id,
538
500
  tool_result=result,
501
+ metadata=event_metadata,
539
502
  ) if tool_call_id in tool_parts:
540
503
  existing = tool_parts[tool_call_id]
541
504
  result_str = str(result) if result else ""
@@ -555,6 +518,7 @@ async def send_message( # noqa: PLR0915
555
518
  title=f"Completed {existing.tool}",
556
519
  input=tool_input,
557
520
  output=result_str,
521
+ metadata=event_metadata or {},
558
522
  time=TimeStartEndCompacted(start=now, end=now_ms()),
559
523
  )
560
524
 
@@ -582,6 +546,70 @@ async def send_message( # noqa: PLR0915
582
546
  # Cost is in Decimal dollars, OpenCode expects float dollars
583
547
  total_cost = float(msg.cost_info.total_cost)
584
548
 
549
+ # Sub-agent/team event - show final results only
550
+ case SubAgentEvent(
551
+ source_name=source_name,
552
+ source_type=source_type,
553
+ event=wrapped_event,
554
+ depth=depth,
555
+ ):
556
+ indent = " " * (depth - 1)
557
+
558
+ match wrapped_event:
559
+ # Final message from sub-agent/team
560
+ case StreamCompleteEvent(message=msg):
561
+ # Show indicator
562
+ icon = "⚡" if source_type == "team_parallel" else "→"
563
+ type_label = (
564
+ " (parallel)"
565
+ if source_type == "team_parallel"
566
+ else " (sequential)"
567
+ if source_type == "team_sequential"
568
+ else ""
569
+ )
570
+ indicator = f"{indent}{icon} {source_name}{type_label}"
571
+
572
+ indicator_part = TextPart(
573
+ id=identifier.ascending("part"),
574
+ message_id=assistant_msg_id,
575
+ session_id=session_id,
576
+ text=indicator,
577
+ time=TimeStartEndOptional(start=now_ms()),
578
+ )
579
+ assistant_msg_with_parts.parts.append(indicator_part)
580
+ await state.broadcast_event(PartUpdatedEvent.create(indicator_part))
581
+
582
+ # Show complete message content
583
+ content = str(msg.content) if msg.content else "(no output)"
584
+ content_part = TextPart(
585
+ id=identifier.ascending("part"),
586
+ message_id=assistant_msg_id,
587
+ session_id=session_id,
588
+ text=content,
589
+ time=TimeStartEndOptional(start=now_ms()),
590
+ )
591
+ assistant_msg_with_parts.parts.append(content_part)
592
+ await state.broadcast_event(PartUpdatedEvent.create(content_part))
593
+
594
+ # Tool call completed - show one-line summary
595
+ case ToolCallCompleteEvent(tool_name=tool_name, tool_result=result):
596
+ # Preview result (first 60 chars)
597
+ result_str = str(result) if result else ""
598
+ preview = (
599
+ result_str[:60] + "..." if len(result_str) > 60 else result_str # noqa: PLR2004
600
+ )
601
+ summary = f"{indent} ├─ {tool_name}: {preview}"
602
+
603
+ summary_part = TextPart(
604
+ id=identifier.ascending("part"),
605
+ message_id=assistant_msg_id,
606
+ session_id=session_id,
607
+ text=summary,
608
+ time=TimeStartEndOptional(start=now_ms()),
609
+ )
610
+ assistant_msg_with_parts.parts.append(summary_part)
611
+ await state.broadcast_event(PartUpdatedEvent.create(summary_part))
612
+
585
613
  # Compaction event - emit session.compacted SSE event
586
614
  case CompactionEvent(session_id=compact_session_id, phase=phase):
587
615
  if phase == "completed":
@@ -676,17 +704,45 @@ async def send_message( # noqa: PLR0915
676
704
  state.sessions[session_id] = session.model_copy(
677
705
  update={"time": TimeCreatedUpdated(created=session.time.created, updated=response_time)}
678
706
  )
679
- # Trigger title generation if session has default title
680
- if session.title == "New Session" and state.pool.storage:
681
- # Convert user_prompt to string if it's a list
682
- prompt_str = user_prompt if isinstance(user_prompt, str) else str(user_prompt)
683
- state.create_background_task(
684
- _generate_session_title(state, session_id, prompt_str, response_text),
685
- name=f"generate_title_{session_id}",
686
- )
707
+ # Title generation now handled by StorageManager signal (on_title_generated in server.py)
708
+ # Agent calls log_conversation() _generate_title_from_prompt() → emits title_generated signal
687
709
  return assistant_msg_with_parts
688
710
 
689
711
 
712
+ @router.post("/message")
713
+ async def send_message(
714
+ session_id: str,
715
+ request: MessageRequest,
716
+ state: StateDep,
717
+ ) -> MessageWithParts:
718
+ """Send a message and wait for the agent's response.
719
+
720
+ This is the synchronous version - waits for completion before returning.
721
+ For async processing, use POST /session/{id}/prompt_async instead.
722
+ """
723
+ return await _process_message(session_id, request, state)
724
+
725
+
726
+ @router.post("/prompt_async", status_code=status.HTTP_204_NO_CONTENT)
727
+ async def send_message_async(
728
+ session_id: str,
729
+ request: MessageRequest,
730
+ state: StateDep,
731
+ ) -> None:
732
+ """Send a message asynchronously without waiting for response.
733
+
734
+ Starts the agent processing in the background and returns immediately.
735
+ Client should listen to SSE events to get updates.
736
+
737
+ Returns 204 No Content immediately.
738
+ """
739
+ # Create background task to process the message
740
+ state.create_background_task(
741
+ _process_message(session_id, request, state),
742
+ name=f"process_message_{session_id}",
743
+ )
744
+
745
+
690
746
  @router.get("/message/{message_id}")
691
747
  async def get_message(
692
748
  session_id: str,
@@ -0,0 +1,63 @@
1
+ """Permission routes for OpenCode TUI compatibility."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+
7
+ from agentpool_server.opencode_server.dependencies import StateDep
8
+ from agentpool_server.opencode_server.models.events import PermissionResolvedEvent
9
+ from agentpool_server.opencode_server.routes.session_routes import PermissionResponse
10
+
11
+
12
+ router = APIRouter(prefix="/permission", tags=["permission"])
13
+
14
+
15
+ @router.post("/{permission_id}/reply")
16
+ async def reply_to_permission(
17
+ permission_id: str,
18
+ body: PermissionResponse,
19
+ state: StateDep,
20
+ ) -> bool:
21
+ """Respond to a pending permission request (OpenCode TUI compatibility).
22
+
23
+ This endpoint handles the OpenCode TUI's expected format:
24
+ POST /permission/{permission_id}/reply
25
+
26
+ The response can be:
27
+ - "once": Allow this tool execution once
28
+ - "always": Always allow this tool (remembered for session)
29
+ - "reject": Reject this tool execution
30
+ """
31
+ print(f"DEBUG permission endpoint: received reply '{body.reply}' for perm_id={permission_id}")
32
+ print(f"DEBUG permission endpoint: searching in {len(state.input_providers)} sessions")
33
+ # Find which session has this permission request
34
+ for session_id, input_provider in state.input_providers.items():
35
+ pending_perms = list(input_provider._pending_permissions.keys())
36
+ print(
37
+ f"DEBUG permission endpoint: session {session_id} has "
38
+ f"{len(pending_perms)} pending: {pending_perms}"
39
+ )
40
+ # Check if this permission belongs to this session
41
+ if permission_id in input_provider._pending_permissions:
42
+ print(f"DEBUG permission endpoint: found permission in session {session_id}")
43
+ # Resolve the permission
44
+ resolved = input_provider.resolve_permission(permission_id, body.reply)
45
+ print(f"DEBUG permission endpoint: resolve_permission returned {resolved}")
46
+ if not resolved:
47
+ raise HTTPException(
48
+ status_code=404,
49
+ detail="Permission not found or already resolved",
50
+ )
51
+
52
+ await state.broadcast_event(
53
+ PermissionResolvedEvent.create(
54
+ session_id=session_id,
55
+ request_id=permission_id,
56
+ reply=body.reply,
57
+ )
58
+ )
59
+
60
+ return True
61
+
62
+ # Permission not found in any session
63
+ raise HTTPException(status_code=404, detail="Permission not found")
@@ -10,17 +10,15 @@ import contextlib
10
10
  from dataclasses import dataclass, field
11
11
  from typing import TYPE_CHECKING, Any
12
12
 
13
- from fastapi import APIRouter, HTTPException, WebSocketDisconnect
13
+ from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect # noqa: TC002
14
14
 
15
- from agentpool_server.opencode_server.models import PtyInfo
15
+ from agentpool_server.opencode_server.dependencies import StateDep
16
+ from agentpool_server.opencode_server.models import PtyCreateRequest, PtyInfo, PtyUpdateRequest
16
17
 
17
18
 
18
19
  if TYPE_CHECKING:
19
20
  from exxec.pty_manager import PtyManagerProtocol
20
- from fastapi import WebSocket
21
21
 
22
- from agentpool_server.opencode_server.dependencies import StateDep
23
- from agentpool_server.opencode_server.models import PtyCreateRequest, PtyUpdateRequest
24
22
  from agentpool_server.opencode_server.state import ServerState
25
23
 
26
24
 
@@ -96,10 +94,15 @@ async def create_pty(request: PtyCreateRequest, state: StateDep) -> PtyInfo:
96
94
  from agentpool_server.opencode_server.models.events import PtyCreatedEvent
97
95
 
98
96
  manager = _get_pty_manager(state)
97
+ # Limit number of PTY sessions to prevent resource exhaustion
98
+ sessions = await manager.list_sessions()
99
+ if len(sessions) >= 20: # Max 20 concurrent PTY sessions # noqa: PLR2004
100
+ detail = f"Too many PTY sessions ({len(sessions)}). Close some terminals first."
101
+ raise HTTPException(status_code=429, detail=detail)
99
102
 
100
103
  # Use working dir from state if not specified
101
104
  cwd = request.cwd or state.working_dir
102
-
105
+ print(f"Creating PTY: command={request.command}, args={request.args}, cwd={cwd}")
103
106
  try:
104
107
  info = await manager.create(
105
108
  command=request.command,
@@ -107,25 +110,22 @@ async def create_pty(request: PtyCreateRequest, state: StateDep) -> PtyInfo:
107
110
  cwd=cwd,
108
111
  env=request.env,
109
112
  )
113
+ print(f"PTY created successfully: {info.id}, status={info.status}")
110
114
  except Exception as e:
111
115
  raise HTTPException(status_code=400, detail=f"Failed to create PTY: {e}") from e
112
116
 
113
117
  pty_id = info.id
114
118
  title = request.title or f"Terminal {pty_id[-4:]}"
115
-
116
119
  # Create session tracker for WebSocket subscribers
117
120
  session = PtySession(pty_id=pty_id)
118
121
  _pty_sessions[pty_id] = session
119
-
122
+ print(f"PTY session registered: {pty_id}, total sessions: {len(_pty_sessions)}")
120
123
  # Start background task to read output and distribute to subscribers
121
124
  session.read_task = asyncio.create_task(_read_pty_output(manager, pty_id, state))
122
-
123
125
  pty_info = _convert_pty_info(info, title=title)
124
-
125
126
  # Broadcast PTY created event
126
127
  event = PtyCreatedEvent.create(info=pty_info.model_dump(by_alias=True))
127
128
  await state.broadcast_event(event)
128
-
129
129
  return pty_info
130
130
 
131
131
 
@@ -178,6 +178,7 @@ async def get_pty(pty_id: str, state: StateDep) -> PtyInfo:
178
178
  return _convert_pty_info(info)
179
179
 
180
180
 
181
+ @router.put("/{pty_id}")
181
182
  @router.patch("/{pty_id}")
182
183
  async def update_pty(pty_id: str, request: PtyUpdateRequest, state: StateDep) -> PtyInfo:
183
184
  """Update PTY session (title, resize)."""
@@ -200,13 +201,10 @@ async def update_pty(pty_id: str, request: PtyUpdateRequest, state: StateDep) ->
200
201
 
201
202
  # Title is handled at the API level, not in the PTY manager
202
203
  title = request.title if request.title else f"Terminal {pty_id[-4:]}"
203
-
204
204
  pty_info = _convert_pty_info(info, title=title)
205
-
206
205
  # Broadcast PTY updated event
207
206
  event = PtyUpdatedEvent.create(info=pty_info.model_dump(by_alias=True))
208
207
  await state.broadcast_event(event)
209
-
210
208
  return pty_info
211
209
 
212
210
 
@@ -216,12 +214,10 @@ async def remove_pty(pty_id: str, state: StateDep) -> dict[str, bool]:
216
214
  from agentpool_server.opencode_server.models.events import PtyDeletedEvent
217
215
 
218
216
  manager = _get_pty_manager(state)
219
-
220
217
  # Kill the PTY session
221
218
  success = await manager.kill(pty_id)
222
219
  if not success:
223
220
  raise HTTPException(status_code=404, detail="PTY session not found")
224
-
225
221
  # Cleanup session tracker
226
222
  session = _pty_sessions.pop(pty_id, None)
227
223
  if session:
@@ -249,26 +245,32 @@ async def connect_pty(websocket: WebSocket, pty_id: str) -> None:
249
245
  # Get state from websocket's app
250
246
 
251
247
  state: ServerState = websocket.app.state.server_state
252
-
253
248
  try:
254
249
  manager = _get_pty_manager(state)
255
250
  except HTTPException:
256
- await websocket.close(code=4501, reason="PTY not supported")
251
+ # Must accept before we can close
252
+ await websocket.accept()
253
+ await websocket.close(code=1003, reason="PTY not supported")
254
+ return
255
+ except Exception as e: # noqa: BLE001
256
+ await websocket.accept()
257
+ await websocket.close(code=1011, reason=f"Error: {e}")
257
258
  return
258
259
 
260
+ # Check if PTY exists - if not, immediately reject like OpenCode does
259
261
  info = await manager.get_info(pty_id)
260
262
  if not info:
261
- await websocket.close(code=4004, reason="PTY session not found")
263
+ await websocket.accept()
264
+ await websocket.close(code=1003, reason="PTY session not found")
262
265
  return
263
266
 
267
+ # PTY exists, accept the WebSocket connection
264
268
  await websocket.accept()
265
-
266
269
  # Get or create session tracker
267
270
  if pty_id not in _pty_sessions:
268
271
  _pty_sessions[pty_id] = PtySession(pty_id=pty_id)
269
272
  session = _pty_sessions[pty_id]
270
273
  session.subscribers.add(websocket)
271
-
272
274
  # Send buffered output
273
275
  if session.buffer:
274
276
  try:
@@ -281,7 +283,6 @@ async def connect_pty(websocket: WebSocket, pty_id: str) -> None:
281
283
  while True:
282
284
  # Receive input from client
283
285
  data = await websocket.receive_text()
284
-
285
286
  # Write to PTY stdin
286
287
  info = await manager.get_info(pty_id)
287
288
  if info and info.status == "running":
@@ -0,0 +1,128 @@
1
+ """Question routes for OpenCode compatibility."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+
7
+ from agentpool_server.opencode_server.dependencies import StateDep
8
+ from agentpool_server.opencode_server.input_provider import OpenCodeInputProvider
9
+ from agentpool_server.opencode_server.models.events import (
10
+ QuestionRejectedEvent,
11
+ QuestionRepliedEvent,
12
+ )
13
+ from agentpool_server.opencode_server.models.question import QuestionReply, QuestionRequest
14
+
15
+
16
+ router = APIRouter(prefix="/question", tags=["question"])
17
+
18
+
19
+ @router.get("/", response_model=list[QuestionRequest])
20
+ async def list_questions(state: StateDep) -> list[QuestionRequest]:
21
+ """List all pending question requests.
22
+
23
+ Returns a list of all pending questions awaiting user response.
24
+ """
25
+ questions = []
26
+ for question_id, pending in state.pending_questions.items():
27
+ questions.append(
28
+ QuestionRequest(
29
+ id=question_id,
30
+ session_id=pending.session_id,
31
+ questions=pending.questions,
32
+ tool=pending.tool,
33
+ )
34
+ )
35
+ return questions
36
+
37
+
38
+ @router.post("/{requestID}/reply")
39
+ async def reply_to_question(
40
+ requestID: str, # noqa: N803
41
+ reply: QuestionReply,
42
+ state: StateDep,
43
+ ) -> bool:
44
+ """Reply to a question request.
45
+
46
+ The user provides answers to the questions. Answers must be provided
47
+ as an array of arrays, where each inner array contains the selected
48
+ label(s) for that question.
49
+
50
+ Args:
51
+ requestID: The question request ID
52
+ reply: The user's answers
53
+ state: Server state
54
+
55
+ Returns:
56
+ True if the question was resolved successfully
57
+
58
+ Raises:
59
+ HTTPException: If question not found or invalid provider
60
+ """
61
+ pending = state.pending_questions.get(requestID)
62
+ if not pending:
63
+ raise HTTPException(status_code=404, detail="Question request not found")
64
+
65
+ session_id = pending.session_id
66
+ provider = state.input_providers.get(session_id)
67
+
68
+ if not isinstance(provider, OpenCodeInputProvider):
69
+ raise HTTPException(status_code=500, detail="Invalid provider for session")
70
+
71
+ # Resolve via provider
72
+ success = provider.resolve_question(requestID, reply.answers)
73
+
74
+ if not success:
75
+ raise HTTPException(status_code=404, detail="Question already resolved")
76
+
77
+ # Broadcast replied event
78
+ event = QuestionRepliedEvent.create(
79
+ session_id=session_id,
80
+ request_id=requestID,
81
+ answers=reply.answers,
82
+ )
83
+ await state.broadcast_event(event)
84
+
85
+ return True
86
+
87
+
88
+ @router.post("/{requestID}/reject")
89
+ async def reject_question(
90
+ requestID: str, # noqa: N803
91
+ state: StateDep,
92
+ ) -> bool:
93
+ """Reject a question request.
94
+
95
+ Called when the user dismisses the question without providing an answer.
96
+
97
+ Args:
98
+ requestID: The question request ID
99
+ state: Server state
100
+
101
+ Returns:
102
+ True if the question was rejected successfully
103
+
104
+ Raises:
105
+ HTTPException: If question not found
106
+ """
107
+ pending = state.pending_questions.get(requestID)
108
+ if not pending:
109
+ raise HTTPException(status_code=404, detail="Question request not found")
110
+
111
+ session_id = pending.session_id
112
+ future = pending.future
113
+
114
+ # Cancel the future
115
+ if not future.done():
116
+ future.cancel()
117
+
118
+ # Remove from pending
119
+ del state.pending_questions[requestID]
120
+
121
+ # Broadcast rejected event
122
+ event = QuestionRejectedEvent.create(
123
+ session_id=session_id,
124
+ request_id=requestID,
125
+ )
126
+ await state.broadcast_event(event)
127
+
128
+ return True