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
@@ -3,36 +3,73 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ from dataclasses import dataclass
7
+ import os
6
8
  from typing import TYPE_CHECKING, Any, Self
7
9
 
8
10
  from anyenv import method_spawner
11
+ from anyenv.signals import Signal
12
+ from pydantic import BaseModel
9
13
  from pydantic_ai import Agent
10
14
 
11
15
  from agentpool.log import get_logger
16
+ from agentpool.messaging import ChatMessage
12
17
  from agentpool.storage.serialization import serialize_messages
13
18
  from agentpool.utils.tasks import TaskManager
19
+ from agentpool_config.session import SessionQuery
14
20
  from agentpool_config.storage import (
21
+ ClaudeStorageConfig,
15
22
  FileStorageConfig,
16
23
  MemoryStorageConfig,
24
+ OpenCodeStorageConfig,
17
25
  SQLStorageConfig,
18
- TextLogConfig,
26
+ ZedStorageConfig,
19
27
  )
20
28
 
21
29
 
22
30
  if TYPE_CHECKING:
23
- from collections.abc import Sequence
31
+ from collections.abc import Callable, Sequence
24
32
  from datetime import datetime
25
33
  from types import TracebackType
26
34
 
27
35
  from agentpool.common_types import JsonValue
28
- from agentpool.messaging import ChatMessage
29
- from agentpool_config.session import SessionQuery
36
+ from agentpool.sessions.models import ProjectData
30
37
  from agentpool_config.storage import BaseStorageProviderConfig, StorageConfig
31
38
  from agentpool_storage.base import StorageProvider
32
39
 
33
40
  logger = get_logger(__name__)
34
41
 
35
42
 
43
+ class ConversationMetadata(BaseModel):
44
+ """Generated metadata for a conversation."""
45
+
46
+ title: str
47
+ """Short descriptive title (3-7 words)."""
48
+
49
+ emoji: str
50
+ """Single emoji representing the topic."""
51
+
52
+ icon: str
53
+ """Iconify icon name (e.g., 'mdi:code-braces')."""
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class TitleGeneratedEvent:
58
+ """Event emitted when a conversation title is generated.
59
+
60
+ Attributes:
61
+ conversation_id: ID of the conversation
62
+ title: Generated title text
63
+ emoji: Generated emoji representing the topic
64
+ icon: Generated iconify icon name
65
+ """
66
+
67
+ conversation_id: str
68
+ title: str
69
+ emoji: str
70
+ icon: str
71
+
72
+
36
73
  class StorageManager:
37
74
  """Manages multiple storage providers.
38
75
 
@@ -41,8 +78,19 @@ class StorageManager:
41
78
  - Message distribution to providers
42
79
  - History loading from capable providers
43
80
  - Global logging filters
81
+
82
+ Signals:
83
+ - title_generated: Emitted when a conversation title is generated.
84
+ Subscribers receive TitleGeneratedEvent with conversation_id, title, emoji, icon.
85
+
86
+ Example:
87
+ manager.title_generated.connect(my_handler)
88
+ # Handler will be called with TitleGeneratedEvent when titles are generated
44
89
  """
45
90
 
91
+ # Signal emitted when a conversation title is generated
92
+ title_generated: Signal[TitleGeneratedEvent] = Signal()
93
+
46
94
  def __init__(self, config: StorageConfig) -> None:
47
95
  """Initialize storage manager.
48
96
 
@@ -52,6 +100,7 @@ class StorageManager:
52
100
  self.config = config
53
101
  self.task_manager = TaskManager()
54
102
  self.providers = [self._create_provider(cfg) for cfg in self.config.effective_providers]
103
+ self._conversation_logged: set[str] = set() # Track logged conversations for idempotency
55
104
 
56
105
  async def __aenter__(self) -> Self:
57
106
  """Initialize all providers."""
@@ -123,15 +172,22 @@ class StorageManager:
123
172
  from agentpool_storage.file_provider import FileProvider
124
173
 
125
174
  return FileProvider(provider_config)
126
- case TextLogConfig():
127
- from agentpool_storage.text_log_provider import TextLogProvider
128
-
129
- return TextLogProvider(provider_config)
130
-
131
175
  case MemoryStorageConfig():
132
176
  from agentpool_storage.memory_provider import MemoryStorageProvider
133
177
 
134
178
  return MemoryStorageProvider(provider_config)
179
+ case ClaudeStorageConfig():
180
+ from agentpool_storage.claude_provider import ClaudeStorageProvider
181
+
182
+ return ClaudeStorageProvider(provider_config)
183
+ case OpenCodeStorageConfig():
184
+ from agentpool_storage.opencode_provider import OpenCodeStorageProvider
185
+
186
+ return OpenCodeStorageProvider(provider_config)
187
+ case ZedStorageConfig():
188
+ from agentpool_storage.zed_provider import ZedStorageProvider
189
+
190
+ return ZedStorageProvider(provider_config)
135
191
  case _:
136
192
  msg = f"Unknown provider type: {provider_config}"
137
193
  raise ValueError(msg)
@@ -153,11 +209,7 @@ class StorageManager:
153
209
  # Function to find capable provider by name
154
210
  def find_provider(name: str) -> StorageProvider | None:
155
211
  for p in self.providers:
156
- if (
157
- not getattr(p, "write_only", False)
158
- and p.can_load_history
159
- and p.__class__.__name__.lower() == name.lower()
160
- ):
212
+ if p.can_load_history and p.__class__.__name__.lower() == name.lower():
161
213
  return p
162
214
  return None
163
215
 
@@ -174,7 +226,7 @@ class StorageManager:
174
226
 
175
227
  # Find first capable provider
176
228
  for provider in self.providers:
177
- if not getattr(provider, "write_only", False) and provider.can_load_history:
229
+ if provider.can_load_history:
178
230
  return provider
179
231
 
180
232
  msg = "No capable provider found for loading history"
@@ -209,10 +261,10 @@ class StorageManager:
209
261
  content=str(message.content),
210
262
  role=message.role,
211
263
  name=message.name,
264
+ parent_id=message.parent_id,
212
265
  cost_info=message.cost_info,
213
266
  model=message.model_name,
214
267
  response_time=message.response_time,
215
- forwarded_from=message.forwarded_from,
216
268
  provider_name=message.provider_name,
217
269
  provider_response_id=message.provider_response_id,
218
270
  messages=serialize_messages(message.messages),
@@ -226,16 +278,68 @@ class StorageManager:
226
278
  conversation_id: str,
227
279
  node_name: str,
228
280
  start_time: datetime | None = None,
281
+ initial_prompt: str | None = None,
282
+ on_title_generated: Callable[[str], None] | None = None,
229
283
  ) -> None:
230
- """Log conversation to all providers."""
284
+ """Log conversation to all providers (idempotent).
285
+
286
+ If conversation was already logged, skips provider calls but still
287
+ triggers title generation if initial_prompt is provided.
288
+
289
+ Args:
290
+ conversation_id: Unique conversation identifier
291
+ node_name: Name of the node/agent
292
+ start_time: Optional start time
293
+ initial_prompt: Optional initial prompt to trigger title generation
294
+ on_title_generated: Optional callback invoked when title is generated
295
+ """
231
296
  if not self.config.log_conversations:
232
297
  return
233
298
 
234
- for provider in self.providers:
235
- await provider.log_conversation(
299
+ # Check if already logged (idempotent behavior)
300
+ if conversation_id not in self._conversation_logged:
301
+ # Mark as logged before calling providers
302
+ self._conversation_logged.add(conversation_id)
303
+
304
+ # Log to all providers
305
+ for provider in self.providers:
306
+ await provider.log_conversation(
307
+ conversation_id=conversation_id,
308
+ node_name=node_name,
309
+ start_time=start_time,
310
+ )
311
+
312
+ # Handle title generation based on prompt length
313
+ # Skip during tests to avoid external API calls
314
+ if not initial_prompt or os.environ.get("PYTEST_CURRENT_TEST"):
315
+ return
316
+ prompt_length = len(initial_prompt)
317
+ logger.info(
318
+ "log_conversation title decision",
319
+ conversation_id=conversation_id,
320
+ prompt_length=prompt_length,
321
+ has_model=bool(self.config.title_generation_model),
322
+ )
323
+
324
+ # For short prompts, use them directly as title (like Claude Code)
325
+ if prompt_length < 60: # noqa: PLR2004
326
+ logger.info(
327
+ "Using short prompt directly as title",
328
+ conversation_id=conversation_id,
329
+ title=initial_prompt,
330
+ )
331
+ await self.update_conversation_title(conversation_id, initial_prompt)
332
+ # For longer prompts, generate semantic title if model configured
333
+ elif self.config.title_generation_model:
334
+ logger.info(
335
+ "Creating title generation task for long prompt",
236
336
  conversation_id=conversation_id,
237
- node_name=node_name,
238
- start_time=start_time,
337
+ )
338
+ self.task_manager.create_task(
339
+ self._generate_title_from_prompt(
340
+ conversation_id, initial_prompt, on_title_generated
341
+ ),
342
+ name=f"title_gen_{conversation_id[:8]}",
239
343
  )
240
344
 
241
345
  @method_spawner
@@ -363,57 +467,476 @@ class StorageManager:
363
467
  provider = self.get_history_provider()
364
468
  return await provider.get_conversation_title(conversation_id)
365
469
 
366
- async def generate_conversation_title(
470
+ async def get_conversation_titles(
471
+ self,
472
+ conversation_ids: list[str],
473
+ ) -> dict[str, str | None]:
474
+ """Get titles for multiple conversations.
475
+
476
+ Args:
477
+ conversation_ids: List of conversation IDs
478
+
479
+ Returns:
480
+ Dict mapping conversation_id to title (or None if not set)
481
+ """
482
+ if not conversation_ids:
483
+ return {}
484
+
485
+ provider = self.get_history_provider()
486
+ titles: dict[str, str | None] = {}
487
+ for conv_id in conversation_ids:
488
+ try:
489
+ titles[conv_id] = await provider.get_conversation_title(conv_id)
490
+ except Exception: # noqa: BLE001
491
+ titles[conv_id] = None
492
+ return titles
493
+
494
+ async def get_message_counts(
495
+ self,
496
+ conversation_ids: list[str],
497
+ ) -> dict[str, int]:
498
+ """Get message counts for multiple conversations.
499
+
500
+ Args:
501
+ conversation_ids: List of conversation IDs
502
+
503
+ Returns:
504
+ Dict mapping conversation_id to message count
505
+ """
506
+ if not conversation_ids:
507
+ return {}
508
+
509
+ counts: dict[str, int] = {}
510
+ for conv_id in conversation_ids:
511
+ try:
512
+ query = SessionQuery(name=conv_id)
513
+ messages = await self.filter_messages(query)
514
+ counts[conv_id] = len(messages) if messages else 0
515
+ except Exception: # noqa: BLE001
516
+ counts[conv_id] = 0
517
+ return counts
518
+
519
+ @method_spawner
520
+ async def get_conversation_messages(
367
521
  self,
368
522
  conversation_id: str,
369
- messages: Sequence[ChatMessage[Any]],
523
+ *,
524
+ include_ancestors: bool = False,
525
+ ) -> list[ChatMessage[str]]:
526
+ """Get all messages for a conversation.
527
+
528
+ Args:
529
+ conversation_id: ID of the conversation
530
+ include_ancestors: If True, also include messages from ancestor
531
+ conversations by following the parent_id chain. Useful for
532
+ forked conversations.
533
+
534
+ Returns:
535
+ List of messages ordered by timestamp.
536
+ """
537
+ provider = self.get_history_provider()
538
+ return await provider.get_conversation_messages(
539
+ conversation_id, include_ancestors=include_ancestors
540
+ )
541
+
542
+ @method_spawner
543
+ async def get_message(self, message_id: str) -> ChatMessage[str] | None:
544
+ """Get a single message by ID.
545
+
546
+ Args:
547
+ message_id: ID of the message
548
+
549
+ Returns:
550
+ The message if found, None otherwise.
551
+ """
552
+ provider = self.get_history_provider()
553
+ return await provider.get_message(message_id)
554
+
555
+ @method_spawner
556
+ async def get_message_ancestry(self, message_id: str) -> list[ChatMessage[str]]:
557
+ """Get the ancestry chain of a message.
558
+
559
+ Traverses the parent_id chain to build full history leading to this message.
560
+
561
+ Args:
562
+ message_id: ID of the message
563
+
564
+ Returns:
565
+ List of messages from oldest ancestor to the specified message.
566
+ """
567
+ provider = self.get_history_provider()
568
+ return await provider.get_message_ancestry(message_id)
569
+
570
+ @method_spawner
571
+ async def fork_conversation(
572
+ self,
573
+ *,
574
+ source_conversation_id: str,
575
+ new_conversation_id: str,
576
+ fork_from_message_id: str | None = None,
577
+ new_agent_name: str | None = None,
370
578
  ) -> str | None:
371
- """Generate and store a title for a conversation.
579
+ """Fork a conversation at a specific point.
372
580
 
373
- Uses the configured title generation model to create a short,
374
- descriptive title based on the conversation content.
581
+ Creates a new conversation that branches from the source. New messages
582
+ in the forked conversation should use the returned fork_point_id as
583
+ their parent_id to maintain the history chain.
584
+
585
+ Args:
586
+ source_conversation_id: ID of the conversation to fork from
587
+ new_conversation_id: ID for the new forked conversation
588
+ fork_from_message_id: Message ID to fork from. If None, forks from
589
+ the last message.
590
+ new_agent_name: Agent name for the new conversation.
591
+
592
+ Returns:
593
+ The message_id of the fork point (use as parent_id for new messages),
594
+ or None if the source conversation is empty.
595
+ """
596
+ provider = self.get_history_provider()
597
+ return await provider.fork_conversation(
598
+ source_conversation_id=source_conversation_id,
599
+ new_conversation_id=new_conversation_id,
600
+ fork_from_message_id=fork_from_message_id,
601
+ new_agent_name=new_agent_name,
602
+ )
603
+
604
+ @method_spawner
605
+ async def delete_conversation_messages(
606
+ self,
607
+ conversation_id: str,
608
+ ) -> int:
609
+ """Delete all messages for a conversation in all providers.
610
+
611
+ Used for compaction - removes existing messages so they can be
612
+ replaced with compacted versions.
613
+
614
+ Args:
615
+ conversation_id: ID of the conversation to clear
616
+
617
+ Returns:
618
+ Total number of messages deleted across all providers
619
+ """
620
+ total_deleted = 0
621
+ for provider in self.providers:
622
+ try:
623
+ deleted = await provider.delete_conversation_messages(conversation_id)
624
+ total_deleted += deleted
625
+ except NotImplementedError:
626
+ # Provider doesn't support deletion (e.g., write-only log)
627
+ pass
628
+ except Exception:
629
+ logger.exception(
630
+ "Error deleting messages from provider",
631
+ provider=provider.__class__.__name__,
632
+ conversation_id=conversation_id,
633
+ )
634
+ return total_deleted
635
+
636
+ @method_spawner
637
+ async def replace_conversation_messages(
638
+ self,
639
+ conversation_id: str,
640
+ messages: Sequence[ChatMessage[Any]],
641
+ ) -> tuple[int, int]:
642
+ """Replace all messages for a conversation with new ones.
643
+
644
+ Deletes existing messages and logs new ones. Used for compaction
645
+ where the full history is replaced with a compacted version.
646
+
647
+ Args:
648
+ conversation_id: ID of the conversation
649
+ messages: New messages to store
650
+
651
+ Returns:
652
+ Tuple of (deleted_count, added_count)
653
+ """
654
+ # First delete existing messages
655
+ deleted = await self.delete_conversation_messages(conversation_id)
656
+
657
+ # Then log new messages
658
+ added = 0
659
+ for message in messages:
660
+ # Ensure conversation_id is set on the message
661
+ msg_to_log: ChatMessage[Any] = message
662
+ if not message.conversation_id:
663
+ msg_to_log = ChatMessage(
664
+ content=message.content,
665
+ role=message.role,
666
+ name=message.name,
667
+ conversation_id=conversation_id,
668
+ message_id=message.message_id,
669
+ parent_id=message.parent_id,
670
+ model_name=message.model_name,
671
+ cost_info=message.cost_info,
672
+ response_time=message.response_time,
673
+ timestamp=message.timestamp,
674
+ provider_name=message.provider_name,
675
+ provider_response_id=message.provider_response_id,
676
+ messages=message.messages,
677
+ finish_reason=message.finish_reason,
678
+ )
679
+ await self.log_message(msg_to_log)
680
+ added += 1
681
+
682
+ return deleted, added
683
+
684
+ async def _generate_title_core(
685
+ self,
686
+ conversation_id: str,
687
+ prompt_text: str,
688
+ ) -> ConversationMetadata | None:
689
+ """Core title generation logic using LLM with structured output.
375
690
 
376
691
  Args:
377
692
  conversation_id: ID of the conversation to title
378
- messages: Messages to use for title generation
693
+ prompt_text: Formatted prompt text to send to the LLM
379
694
 
380
695
  Returns:
381
- The generated title, or None if title generation is disabled.
696
+ ConversationMetadata with title, emoji, and icon, or None if generation fails.
382
697
  """
698
+ logger.info("_generate_title_core called", conversation_id=conversation_id)
383
699
  if not self.config.title_generation_model:
700
+ logger.info("No title_generation_model configured, skipping")
384
701
  return None
385
702
 
386
- # Check if title already exists
387
- existing = await self.get_conversation_title(conversation_id)
388
- if existing:
389
- return existing
390
-
391
- # Format messages for the prompt
392
- formatted = "\n".join(
393
- f"{msg.role}: {msg.content[:500]}"
394
- for msg in messages[:4] # Limit context
395
- )
396
-
397
703
  try:
398
- agent: Agent[None, str] = Agent(
399
- model=self.config.title_generation_model,
704
+ from llmling_models.models.helpers import infer_model
705
+
706
+ model = infer_model(self.config.title_generation_model)
707
+ agent: Agent[None, ConversationMetadata] = Agent(
708
+ model=model,
400
709
  instructions=self.config.title_generation_prompt,
710
+ output_type=ConversationMetadata,
401
711
  )
402
- result = await agent.run(formatted)
403
- title = result.output.strip().strip("\"'") # Remove quotes if present
712
+ logger.debug("Title generation prompt", prompt_text=prompt_text)
713
+ result = await agent.run(prompt_text)
714
+ metadata = result.output
404
715
 
405
716
  # Store the title
406
- await self.update_conversation_title(conversation_id, title)
717
+ await self.update_conversation_title(conversation_id, metadata.title)
407
718
  logger.debug(
408
- "Generated conversation title",
719
+ "Generated conversation metadata",
409
720
  conversation_id=conversation_id,
410
- title=title,
721
+ title=metadata.title,
722
+ emoji=metadata.emoji,
723
+ icon=metadata.icon,
411
724
  )
412
- except Exception:
413
- logger.exception(
414
- "Failed to generate conversation title",
725
+
726
+ # Emit signal for subscribers (e.g., OpenCode UI updates)
727
+ event = TitleGeneratedEvent(
728
+ conversation_id=conversation_id,
729
+ title=metadata.title,
730
+ emoji=metadata.emoji,
731
+ icon=metadata.icon,
732
+ )
733
+ logger.info(
734
+ "Emitting title_generated signal",
415
735
  conversation_id=conversation_id,
736
+ title=metadata.title,
416
737
  )
738
+ await self.title_generated.emit(event)
739
+ except Exception:
740
+ logger.exception("Failed to generate session title", conversation_id=conversation_id)
417
741
  return None
418
742
  else:
743
+ return metadata
744
+
745
+ async def _generate_title_from_prompt(
746
+ self,
747
+ conversation_id: str,
748
+ prompt: str,
749
+ on_title_generated: Callable[[str], None] | None = None,
750
+ ) -> str | None:
751
+ """Generate title from initial prompt (internal, fire-and-forget).
752
+
753
+ Called automatically by log_conversation when initial_prompt is provided.
754
+
755
+ Args:
756
+ conversation_id: ID of the conversation to title
757
+ prompt: The initial user prompt
758
+ on_title_generated: Optional callback invoked with the generated title
759
+
760
+ Returns:
761
+ The generated title, or None if generation fails/disabled.
762
+ """
763
+ # Check if title already exists
764
+ existing = await self.get_conversation_title(conversation_id)
765
+ if existing:
766
+ if on_title_generated:
767
+ on_title_generated(existing)
768
+ return existing
769
+
770
+ # Generate using core logic
771
+ metadata = await self._generate_title_core(
772
+ conversation_id,
773
+ f"user: {prompt[:500]}",
774
+ )
775
+
776
+ if metadata:
777
+ title = metadata.title
778
+ if on_title_generated:
779
+ on_title_generated(title)
419
780
  return title
781
+ return None
782
+
783
+ async def generate_conversation_title(
784
+ self,
785
+ conversation_id: str,
786
+ messages: Sequence[ChatMessage[Any]],
787
+ ) -> str | None:
788
+ """Generate and store a title for a conversation.
789
+
790
+ Uses the configured title generation model to create a short,
791
+ descriptive title based on the conversation content.
792
+
793
+ Args:
794
+ conversation_id: ID of the conversation to title
795
+ messages: Messages to use for title generation
796
+
797
+ Returns:
798
+ The generated title, or None if title generation is disabled.
799
+ """
800
+ # Check if title already exists
801
+ existing = await self.get_conversation_title(conversation_id)
802
+ if existing:
803
+ return existing
804
+
805
+ # Format messages for the prompt
806
+ formatted = "\n".join(f"{i.role}: {i.content[:500]}" for i in messages[:4])
807
+
808
+ # Generate using core logic
809
+ metadata = await self._generate_title_core(conversation_id, formatted)
810
+
811
+ return metadata.title if metadata else None
812
+
813
+ # Project methods
814
+
815
+ def get_project_provider(self) -> StorageProvider:
816
+ """Get provider capable of storing projects.
817
+
818
+ Returns:
819
+ First provider that supports project storage.
820
+
821
+ Raises:
822
+ RuntimeError: If no capable provider found.
823
+ """
824
+ if self.providers:
825
+ return self.providers[0]
826
+ msg = "No provider found that supports project storage"
827
+ raise RuntimeError(msg)
828
+
829
+ @method_spawner
830
+ async def save_project(self, project: ProjectData) -> None:
831
+ """Save or update a project in all capable providers.
832
+
833
+ Args:
834
+ project: Project data to persist
835
+ """
836
+ for provider in self.providers:
837
+ try:
838
+ await provider.save_project(project)
839
+ except NotImplementedError:
840
+ pass
841
+ except Exception:
842
+ logger.exception(
843
+ "Error saving project",
844
+ provider=provider.__class__.__name__,
845
+ project_id=project.project_id,
846
+ )
847
+
848
+ @method_spawner
849
+ async def get_project(self, project_id: str) -> ProjectData | None:
850
+ """Get a project by ID.
851
+
852
+ Args:
853
+ project_id: Project identifier
854
+
855
+ Returns:
856
+ Project data if found, None otherwise
857
+ """
858
+ provider = self.get_project_provider()
859
+ return await provider.get_project(project_id)
860
+
861
+ @method_spawner
862
+ async def get_project_by_worktree(self, worktree: str) -> ProjectData | None:
863
+ """Get a project by worktree path.
864
+
865
+ Args:
866
+ worktree: Absolute path to the project worktree
867
+
868
+ Returns:
869
+ Project data if found, None otherwise
870
+ """
871
+ provider = self.get_project_provider()
872
+ return await provider.get_project_by_worktree(worktree)
873
+
874
+ @method_spawner
875
+ async def get_project_by_name(self, name: str) -> ProjectData | None:
876
+ """Get a project by friendly name.
877
+
878
+ Args:
879
+ name: Project name
880
+
881
+ Returns:
882
+ Project data if found, None otherwise
883
+ """
884
+ provider = self.get_project_provider()
885
+ return await provider.get_project_by_name(name)
886
+
887
+ @method_spawner
888
+ async def list_projects(self, limit: int | None = None) -> list[ProjectData]:
889
+ """List all projects, ordered by last_active descending.
890
+
891
+ Args:
892
+ limit: Maximum number of projects to return
893
+
894
+ Returns:
895
+ List of project data objects
896
+ """
897
+ provider = self.get_project_provider()
898
+ return await provider.list_projects(limit=limit)
899
+
900
+ @method_spawner
901
+ async def delete_project(self, project_id: str) -> bool:
902
+ """Delete a project from all providers.
903
+
904
+ Args:
905
+ project_id: Project identifier
906
+
907
+ Returns:
908
+ True if project was deleted from at least one provider
909
+ """
910
+ deleted = False
911
+ for provider in self.providers:
912
+ try:
913
+ if await provider.delete_project(project_id):
914
+ deleted = True
915
+ except NotImplementedError:
916
+ pass
917
+ except Exception:
918
+ logger.exception(
919
+ "Error deleting project",
920
+ provider=provider.__class__.__name__,
921
+ project_id=project_id,
922
+ )
923
+ return deleted
924
+
925
+ @method_spawner
926
+ async def touch_project(self, project_id: str) -> None:
927
+ """Update project's last_active timestamp in all providers.
928
+
929
+ Args:
930
+ project_id: Project identifier
931
+ """
932
+ for provider in self.providers:
933
+ try:
934
+ await provider.touch_project(project_id)
935
+ except NotImplementedError:
936
+ pass
937
+ except Exception:
938
+ logger.exception(
939
+ "Error touching project",
940
+ provider=provider.__class__.__name__,
941
+ project_id=project_id,
942
+ )