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
@@ -3,37 +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
12
16
  from agentpool.messaging import ChatMessage
13
17
  from agentpool.storage.serialization import serialize_messages
14
18
  from agentpool.utils.tasks import TaskManager
19
+ from agentpool_config.session import SessionQuery
15
20
  from agentpool_config.storage import (
21
+ ClaudeStorageConfig,
16
22
  FileStorageConfig,
17
23
  MemoryStorageConfig,
24
+ OpenCodeStorageConfig,
18
25
  SQLStorageConfig,
19
- TextLogConfig,
26
+ ZedStorageConfig,
20
27
  )
21
28
 
22
29
 
23
30
  if TYPE_CHECKING:
24
- from collections.abc import Sequence
31
+ from collections.abc import Callable, Sequence
25
32
  from datetime import datetime
26
33
  from types import TracebackType
27
34
 
28
35
  from agentpool.common_types import JsonValue
29
36
  from agentpool.sessions.models import ProjectData
30
- from agentpool_config.session import SessionQuery
31
37
  from agentpool_config.storage import BaseStorageProviderConfig, StorageConfig
32
38
  from agentpool_storage.base import StorageProvider
33
39
 
34
40
  logger = get_logger(__name__)
35
41
 
36
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
+
37
73
  class StorageManager:
38
74
  """Manages multiple storage providers.
39
75
 
@@ -42,8 +78,19 @@ class StorageManager:
42
78
  - Message distribution to providers
43
79
  - History loading from capable providers
44
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
45
89
  """
46
90
 
91
+ # Signal emitted when a conversation title is generated
92
+ title_generated: Signal[TitleGeneratedEvent] = Signal()
93
+
47
94
  def __init__(self, config: StorageConfig) -> None:
48
95
  """Initialize storage manager.
49
96
 
@@ -53,6 +100,7 @@ class StorageManager:
53
100
  self.config = config
54
101
  self.task_manager = TaskManager()
55
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
56
104
 
57
105
  async def __aenter__(self) -> Self:
58
106
  """Initialize all providers."""
@@ -124,15 +172,22 @@ class StorageManager:
124
172
  from agentpool_storage.file_provider import FileProvider
125
173
 
126
174
  return FileProvider(provider_config)
127
- case TextLogConfig():
128
- from agentpool_storage.text_log_provider import TextLogProvider
129
-
130
- return TextLogProvider(provider_config)
131
-
132
175
  case MemoryStorageConfig():
133
176
  from agentpool_storage.memory_provider import MemoryStorageProvider
134
177
 
135
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)
136
191
  case _:
137
192
  msg = f"Unknown provider type: {provider_config}"
138
193
  raise ValueError(msg)
@@ -154,11 +209,7 @@ class StorageManager:
154
209
  # Function to find capable provider by name
155
210
  def find_provider(name: str) -> StorageProvider | None:
156
211
  for p in self.providers:
157
- if (
158
- not getattr(p, "write_only", False)
159
- and p.can_load_history
160
- and p.__class__.__name__.lower() == name.lower()
161
- ):
212
+ if p.can_load_history and p.__class__.__name__.lower() == name.lower():
162
213
  return p
163
214
  return None
164
215
 
@@ -175,7 +226,7 @@ class StorageManager:
175
226
 
176
227
  # Find first capable provider
177
228
  for provider in self.providers:
178
- if not getattr(provider, "write_only", False) and provider.can_load_history:
229
+ if provider.can_load_history:
179
230
  return provider
180
231
 
181
232
  msg = "No capable provider found for loading history"
@@ -214,7 +265,6 @@ class StorageManager:
214
265
  cost_info=message.cost_info,
215
266
  model=message.model_name,
216
267
  response_time=message.response_time,
217
- forwarded_from=message.forwarded_from,
218
268
  provider_name=message.provider_name,
219
269
  provider_response_id=message.provider_response_id,
220
270
  messages=serialize_messages(message.messages),
@@ -228,16 +278,68 @@ class StorageManager:
228
278
  conversation_id: str,
229
279
  node_name: str,
230
280
  start_time: datetime | None = None,
281
+ initial_prompt: str | None = None,
282
+ on_title_generated: Callable[[str], None] | None = None,
231
283
  ) -> None:
232
- """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
+ """
233
296
  if not self.config.log_conversations:
234
297
  return
235
298
 
236
- for provider in self.providers:
237
- 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",
238
336
  conversation_id=conversation_id,
239
- node_name=node_name,
240
- 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]}",
241
343
  )
242
344
 
243
345
  @method_spawner
@@ -365,6 +467,140 @@ class StorageManager:
365
467
  provider = self.get_history_provider()
366
468
  return await provider.get_conversation_title(conversation_id)
367
469
 
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(
521
+ self,
522
+ conversation_id: str,
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,
578
+ ) -> str | None:
579
+ """Fork a conversation at a specific point.
580
+
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
+
368
604
  @method_spawner
369
605
  async def delete_conversation_messages(
370
606
  self,
@@ -434,7 +670,6 @@ class StorageManager:
434
670
  model_name=message.model_name,
435
671
  cost_info=message.cost_info,
436
672
  response_time=message.response_time,
437
- forwarded_from=message.forwarded_from,
438
673
  timestamp=message.timestamp,
439
674
  provider_name=message.provider_name,
440
675
  provider_response_id=message.provider_response_id,
@@ -446,6 +681,105 @@ class StorageManager:
446
681
 
447
682
  return deleted, added
448
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.
690
+
691
+ Args:
692
+ conversation_id: ID of the conversation to title
693
+ prompt_text: Formatted prompt text to send to the LLM
694
+
695
+ Returns:
696
+ ConversationMetadata with title, emoji, and icon, or None if generation fails.
697
+ """
698
+ logger.info("_generate_title_core called", conversation_id=conversation_id)
699
+ if not self.config.title_generation_model:
700
+ logger.info("No title_generation_model configured, skipping")
701
+ return None
702
+
703
+ try:
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,
709
+ instructions=self.config.title_generation_prompt,
710
+ output_type=ConversationMetadata,
711
+ )
712
+ logger.debug("Title generation prompt", prompt_text=prompt_text)
713
+ result = await agent.run(prompt_text)
714
+ metadata = result.output
715
+
716
+ # Store the title
717
+ await self.update_conversation_title(conversation_id, metadata.title)
718
+ logger.debug(
719
+ "Generated conversation metadata",
720
+ conversation_id=conversation_id,
721
+ title=metadata.title,
722
+ emoji=metadata.emoji,
723
+ icon=metadata.icon,
724
+ )
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",
735
+ conversation_id=conversation_id,
736
+ title=metadata.title,
737
+ )
738
+ await self.title_generated.emit(event)
739
+ except Exception:
740
+ logger.exception("Failed to generate session title", conversation_id=conversation_id)
741
+ return None
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)
780
+ return title
781
+ return None
782
+
449
783
  async def generate_conversation_title(
450
784
  self,
451
785
  conversation_id: str,
@@ -463,43 +797,18 @@ class StorageManager:
463
797
  Returns:
464
798
  The generated title, or None if title generation is disabled.
465
799
  """
466
- if not self.config.title_generation_model:
467
- return None
468
-
469
800
  # Check if title already exists
470
801
  existing = await self.get_conversation_title(conversation_id)
471
802
  if existing:
472
803
  return existing
473
804
 
474
805
  # Format messages for the prompt
475
- formatted = "\n".join(
476
- f"{msg.role}: {msg.content[:500]}"
477
- for msg in messages[:4] # Limit context
478
- )
806
+ formatted = "\n".join(f"{i.role}: {i.content[:500]}" for i in messages[:4])
479
807
 
480
- try:
481
- agent: Agent[None, str] = Agent(
482
- model=self.config.title_generation_model,
483
- instructions=self.config.title_generation_prompt,
484
- )
485
- result = await agent.run(formatted)
486
- title = result.output.strip().strip("\"'") # Remove quotes if present
808
+ # Generate using core logic
809
+ metadata = await self._generate_title_core(conversation_id, formatted)
487
810
 
488
- # Store the title
489
- await self.update_conversation_title(conversation_id, title)
490
- logger.debug(
491
- "Generated conversation title",
492
- conversation_id=conversation_id,
493
- title=title,
494
- )
495
- except Exception:
496
- logger.exception(
497
- "Failed to generate conversation title",
498
- conversation_id=conversation_id,
499
- )
500
- return None
501
- else:
502
- return title
811
+ return metadata.title if metadata else None
503
812
 
504
813
  # Project methods
505
814
 
@@ -512,10 +821,8 @@ class StorageManager:
512
821
  Raises:
513
822
  RuntimeError: If no capable provider found.
514
823
  """
515
- for provider in self.providers:
516
- # TextLogProvider doesn't support projects, others do
517
- if hasattr(provider, "save_project") and not getattr(provider, "write_only", False):
518
- return provider
824
+ if self.providers:
825
+ return self.providers[0]
519
826
  msg = "No provider found that supports project storage"
520
827
  raise RuntimeError(msg)
521
828
 
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from dataclasses import dataclass
6
6
  from typing import TYPE_CHECKING, Any, Literal
7
7
 
8
- from psygnal import Signal
8
+ from anyenv.signals import Signal
9
9
 
10
10
  from agentpool.log import get_logger
11
11
  from agentpool.talk.talk import Talk
@@ -64,7 +64,7 @@ class ConnectionRegistry(BaseRegistry[str, Talk]):
64
64
  connections get registered.
65
65
  """
66
66
 
67
- message_flow = Signal(Talk.ConnectionProcessed)
67
+ message_flow = Signal[Talk.ConnectionProcessed]()
68
68
 
69
69
  def __init__(self, *args: Any, **kwargs: Any) -> None:
70
70
  """Initialize registry and connect event handlers."""
@@ -90,9 +90,9 @@ class ConnectionRegistry(BaseRegistry[str, Talk]):
90
90
  new_talk.connection_processed.connect(self._handle_message_flow)
91
91
  logger.debug("Reconnected signal for talk", name=name)
92
92
 
93
- def _handle_message_flow(self, event: Talk.ConnectionProcessed) -> None:
93
+ async def _handle_message_flow(self, event: Talk.ConnectionProcessed) -> None:
94
94
  """Forward message flow to global stream."""
95
- self.message_flow.emit(event)
95
+ await self.message_flow.emit(event)
96
96
 
97
97
  @property
98
98
  def _error_class(self) -> type[ConnectionRegistryError]:
agentpool/talk/talk.py CHANGED
@@ -8,7 +8,7 @@ from contextlib import asynccontextmanager
8
8
  from dataclasses import dataclass, field, replace
9
9
  from typing import TYPE_CHECKING, Any, Self, overload
10
10
 
11
- from psygnal import Signal
11
+ from anyenv.signals import Signal
12
12
 
13
13
  from agentpool.log import get_logger
14
14
  from agentpool.messaging import ChatMessage
@@ -53,11 +53,11 @@ class Talk[TTransmittedData = Any]:
53
53
  timestamp: datetime = field(default_factory=get_now)
54
54
 
55
55
  # Original message "coming in"
56
- message_received = Signal(ChatMessage)
56
+ message_received = Signal[ChatMessage[Any]]()
57
57
  # After any transformation (one for each message, not per target)
58
- message_forwarded = Signal(ChatMessage)
58
+ message_forwarded = Signal[ChatMessage[Any]]()
59
59
  # Comprehensive signal capturing all information about one "message handling process"
60
- connection_processed = Signal(ConnectionProcessed)
60
+ connection_processed = Signal[ConnectionProcessed]()
61
61
 
62
62
  def __init__(
63
63
  self,
@@ -270,7 +270,7 @@ class Talk[TTransmittedData = Any]:
270
270
  )
271
271
  ]
272
272
  # 7. emit connection processed event
273
- self.connection_processed.emit(
273
+ await self.connection_processed.emit(
274
274
  self.ConnectionProcessed(
275
275
  message=processed_message,
276
276
  source=self.source,
@@ -283,7 +283,7 @@ class Talk[TTransmittedData = Any]:
283
283
  if target_list:
284
284
  messages = [*self._stats.messages, processed_message]
285
285
  self._stats = replace(self._stats, messages=messages)
286
- self.message_forwarded.emit(processed_message)
286
+ await self.message_forwarded.emit(processed_message)
287
287
 
288
288
  # 9. Second pass: Actually process for each target
289
289
  responses: list[ChatMessage[Any]] = []
@@ -308,10 +308,9 @@ class Talk[TTransmittedData = Any]:
308
308
 
309
309
  match self.connection_type:
310
310
  case "run":
311
- prompts: list[PromptCompatible] = [message]
312
- if prompt:
313
- prompts.append(prompt)
314
- return await target.run(*prompts)
311
+ # Use run_message to handle ChatMessage routing
312
+ # It extracts content, preserves conversation_id, and applies forwarding
313
+ return await target.run_message(message)
315
314
 
316
315
  case "context":
317
316
  meta = {
agentpool/testing.py CHANGED
@@ -35,7 +35,7 @@ from typing import TYPE_CHECKING, Any, Literal
35
35
  if TYPE_CHECKING:
36
36
  from collections.abc import AsyncIterator, Sequence
37
37
 
38
- from evented.configs import EventConfig
38
+ from evented_config import EventConfig
39
39
 
40
40
  from agentpool.agents.acp_agent import ACPAgent
41
41
  from agentpool.common_types import BuiltinEventHandlerType, IndividualEventHandler
@@ -0,0 +1,6 @@
1
+ """Standalone tool implementations.
2
+
3
+ Each tool is a subclass of Tool that can be used independently or grouped.
4
+ """
5
+
6
+ from __future__ import annotations
@@ -0,0 +1,42 @@
1
+ """Agent CLI tool for executing internal commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from agentpool.tool_impls.agent_cli.tool import AgentCliTool
8
+ from agentpool_config.tools import ToolHints
9
+
10
+
11
+ __all__ = ["AgentCliTool", "create_agent_cli_tool"]
12
+
13
+ # Tool metadata defaults
14
+ NAME = "agent_cli"
15
+ DESCRIPTION = "Execute an internal agent management command."
16
+ CATEGORY: Literal["other"] = "other"
17
+ HINTS = ToolHints()
18
+
19
+
20
+ def create_agent_cli_tool(
21
+ *,
22
+ name: str = NAME,
23
+ description: str = DESCRIPTION,
24
+ requires_confirmation: bool = False,
25
+ ) -> AgentCliTool:
26
+ """Create a configured AgentCliTool instance.
27
+
28
+ Args:
29
+ name: Tool name override.
30
+ description: Tool description override.
31
+ requires_confirmation: Whether tool execution needs confirmation.
32
+
33
+ Returns:
34
+ Configured AgentCliTool instance.
35
+ """
36
+ return AgentCliTool(
37
+ name=name,
38
+ description=description,
39
+ category=CATEGORY,
40
+ hints=HINTS,
41
+ requires_confirmation=requires_confirmation,
42
+ )