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,14 +3,37 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ from collections.abc import Callable, Coroutine
6
7
  from contextlib import asynccontextmanager
7
- from typing import TYPE_CHECKING
8
+ from dataclasses import dataclass, field
9
+ from typing import TYPE_CHECKING, Any, Literal
10
+
11
+ # Re-export FileTracker from new location for backwards compatibility
12
+ from agentpool.agents.events.processors import (
13
+ FileTracker,
14
+ FileTrackingProcessor,
15
+ extract_file_path_from_tool_call,
16
+ )
8
17
 
9
18
 
10
19
  if TYPE_CHECKING:
11
20
  from collections.abc import AsyncIterator
12
21
 
13
22
 
23
+ __all__ = [
24
+ "FileChange",
25
+ "FileOpsTracker",
26
+ "FileTracker",
27
+ "FileTrackingProcessor",
28
+ "TodoEntry",
29
+ "TodoPriority",
30
+ "TodoStatus",
31
+ "TodoTracker",
32
+ "extract_file_path_from_tool_call",
33
+ "merge_queue_into_iterator",
34
+ ]
35
+
36
+
14
37
  @asynccontextmanager
15
38
  async def merge_queue_into_iterator[T, V]( # noqa: PLR0915
16
39
  primary_stream: AsyncIterator[T],
@@ -66,7 +89,7 @@ async def merge_queue_into_iterator[T, V]( # noqa: PLR0915
66
89
  try:
67
90
  while not primary_done.is_set():
68
91
  try:
69
- secondary_event = await asyncio.wait_for(secondary_queue.get(), timeout=0.1)
92
+ secondary_event = await asyncio.wait_for(secondary_queue.get(), timeout=0.01)
70
93
  await event_queue.put(secondary_event)
71
94
  except TimeoutError:
72
95
  continue
@@ -110,3 +133,594 @@ async def merge_queue_into_iterator[T, V]( # noqa: PLR0915
110
133
  primary_task_obj.cancel()
111
134
  secondary_task_obj.cancel()
112
135
  await asyncio.gather(primary_task_obj, secondary_task_obj, return_exceptions=True)
136
+
137
+
138
+ @dataclass
139
+ class FileChange:
140
+ """Represents a single file change operation."""
141
+
142
+ path: str
143
+ """File path that was modified."""
144
+
145
+ old_content: str | None
146
+ """Content before change (None for new files)."""
147
+
148
+ new_content: str | None
149
+ """Content after change (None for deletions)."""
150
+
151
+ operation: str
152
+ """Type of operation: 'create', 'write', 'edit', 'delete'."""
153
+
154
+ timestamp: float = field(default_factory=lambda: __import__("time").time())
155
+ """Unix timestamp when the change occurred."""
156
+
157
+ message_id: str | None = None
158
+ """ID of the message that triggered this change (for revert-to-message)."""
159
+
160
+ agent_name: str | None = None
161
+ """Name of the agent that made this change."""
162
+
163
+ def to_unified_diff(self) -> str:
164
+ """Generate unified diff for this change.
165
+
166
+ Returns:
167
+ Unified diff string
168
+ """
169
+ import difflib
170
+
171
+ old_lines = (self.old_content or "").splitlines(keepends=True)
172
+ new_lines = (self.new_content or "").splitlines(keepends=True)
173
+
174
+ diff = difflib.unified_diff(
175
+ old_lines,
176
+ new_lines,
177
+ fromfile=f"a/{self.path}",
178
+ tofile=f"b/{self.path}",
179
+ )
180
+ return "".join(diff)
181
+
182
+
183
+ @dataclass
184
+ class FileOpsTracker:
185
+ r"""Tracks file operations with full content for diff/revert support.
186
+
187
+ Stores file changes with before/after content so they can be:
188
+ - Displayed as diffs
189
+ - Reverted to previous state
190
+ - Filtered by message ID
191
+
192
+ Example:
193
+ ```python
194
+ tracker = FileOpsTracker()
195
+
196
+ # Record a file edit
197
+ tracker.record_change(
198
+ path="src/main.py",
199
+ old_content="def foo(): pass",
200
+ new_content="def foo():\\n return 42",
201
+ operation="edit",
202
+ )
203
+
204
+ # Get all diffs
205
+ for change in tracker.changes:
206
+ print(change.to_unified_diff())
207
+
208
+ # Revert all changes
209
+ for path, content in tracker.get_revert_operations():
210
+ write_file(path, content)
211
+ ```
212
+ """
213
+
214
+ changes: list[FileChange] = field(default_factory=list)
215
+ """List of all recorded file changes in order."""
216
+
217
+ reverted_changes: list[FileChange] = field(default_factory=list)
218
+ """Changes that were reverted and can be restored with unrevert."""
219
+
220
+ def record_change(
221
+ self,
222
+ path: str,
223
+ old_content: str | None,
224
+ new_content: str | None,
225
+ operation: str,
226
+ message_id: str | None = None,
227
+ agent_name: str | None = None,
228
+ ) -> None:
229
+ """Record a file change.
230
+
231
+ Args:
232
+ path: File path that was modified
233
+ old_content: Content before change (None for new files)
234
+ new_content: Content after change (None for deletions)
235
+ operation: Type of operation ('create', 'write', 'edit', 'delete')
236
+ message_id: Optional message ID that triggered this change
237
+ agent_name: Optional name of the agent that made this change
238
+ """
239
+ self.changes.append(
240
+ FileChange(
241
+ path=path,
242
+ old_content=old_content,
243
+ new_content=new_content,
244
+ operation=operation,
245
+ message_id=message_id,
246
+ agent_name=agent_name,
247
+ )
248
+ )
249
+
250
+ def get_changes_for_path(self, path: str) -> list[FileChange]:
251
+ """Get all changes for a specific file path.
252
+
253
+ Args:
254
+ path: File path to filter by
255
+
256
+ Returns:
257
+ List of changes for the given path
258
+ """
259
+ return [c for c in self.changes if c.path == path]
260
+
261
+ def get_changes_since_message(self, message_id: str) -> list[FileChange]:
262
+ """Get all changes since (and including) a specific message.
263
+
264
+ Args:
265
+ message_id: Message ID to start from
266
+
267
+ Returns:
268
+ List of changes from the given message onwards
269
+ """
270
+ result = []
271
+ found = False
272
+ for change in self.changes:
273
+ if change.message_id == message_id:
274
+ found = True
275
+ if found:
276
+ result.append(change)
277
+ return result
278
+
279
+ def get_modified_paths(self) -> set[str]:
280
+ """Get set of all modified file paths.
281
+
282
+ Returns:
283
+ Set of file paths that have been modified
284
+ """
285
+ return {c.path for c in self.changes}
286
+
287
+ def get_current_state(self) -> dict[str, str | None]:
288
+ """Get the current state of all modified files.
289
+
290
+ For each file, returns the content after all changes have been applied.
291
+ Returns None for deleted files.
292
+
293
+ Returns:
294
+ Dict mapping path to current content (or None if deleted)
295
+ """
296
+ state: dict[str, str | None] = {}
297
+ for change in self.changes:
298
+ state[change.path] = change.new_content
299
+ return state
300
+
301
+ def get_original_state(self) -> dict[str, str | None]:
302
+ """Get the original state of all modified files.
303
+
304
+ For each file, returns the content before any changes were made.
305
+ Returns None for files that were created (didn't exist).
306
+
307
+ Returns:
308
+ Dict mapping path to original content (or None if created)
309
+ """
310
+ state: dict[str, str | None] = {}
311
+ for change in self.changes:
312
+ if change.path not in state:
313
+ state[change.path] = change.old_content
314
+ return state
315
+
316
+ def get_revert_operations(
317
+ self, since_message_id: str | None = None
318
+ ) -> list[tuple[str, str | None]]:
319
+ """Get operations needed to revert changes.
320
+
321
+ Returns list of (path, content) tuples in reverse order (newest first).
322
+ If content is None, the file should be deleted.
323
+
324
+ Args:
325
+ since_message_id: If provided, only revert changes from this message onwards.
326
+ If None, revert all changes.
327
+
328
+ Returns:
329
+ List of (path, content_to_restore) tuples for revert
330
+ """
331
+ if since_message_id:
332
+ changes = self.get_changes_since_message(since_message_id)
333
+ else:
334
+ changes = self.changes
335
+
336
+ # Build map of path -> content to restore
337
+ # For each path, we need the old_content of the FIRST change in our subset
338
+ # (that's what the file looked like before any of these changes)
339
+ original_for_path: dict[str, str | None] = {}
340
+ for change in changes:
341
+ if change.path not in original_for_path:
342
+ original_for_path[change.path] = change.old_content
343
+
344
+ return list(original_for_path.items())
345
+
346
+ def get_combined_diff(self) -> str:
347
+ """Get combined unified diff of all changes.
348
+
349
+ Returns:
350
+ Combined diff string for all file changes
351
+ """
352
+ diffs = []
353
+ for change in self.changes:
354
+ diff = change.to_unified_diff()
355
+ if diff:
356
+ diffs.append(diff)
357
+ return "\n".join(diffs)
358
+
359
+ def clear(self) -> None:
360
+ """Clear all recorded changes."""
361
+ self.changes.clear()
362
+
363
+ def remove_changes_since_message(self, message_id: str) -> int:
364
+ """Remove changes from a specific message onwards and store for unrevert.
365
+
366
+ The removed changes are stored in `reverted_changes` so they can be
367
+ restored later via `restore_reverted_changes()`.
368
+
369
+ Args:
370
+ message_id: Message ID to start removal from
371
+
372
+ Returns:
373
+ Number of changes removed
374
+ """
375
+ # Find the index of the first change with this message_id
376
+ start_idx = None
377
+ for i, change in enumerate(self.changes):
378
+ if change.message_id == message_id:
379
+ start_idx = i
380
+ break
381
+
382
+ if start_idx is None:
383
+ return 0
384
+
385
+ # Store removed changes for potential unrevert
386
+ self.reverted_changes = self.changes[start_idx:]
387
+ self.changes = self.changes[:start_idx]
388
+ return len(self.reverted_changes)
389
+
390
+ def get_unrevert_operations(self) -> list[tuple[str, str | None]]:
391
+ """Get operations needed to restore reverted changes.
392
+
393
+ Returns list of (path, content) tuples. The content is the new_content
394
+ from each reverted change (what the file should contain after unrevert).
395
+
396
+ Returns:
397
+ List of (path, content_to_write) tuples for unrevert
398
+ """
399
+ if not self.reverted_changes:
400
+ return []
401
+
402
+ # For each path, we want the LAST new_content in the reverted changes
403
+ # (that's what the file looked like before the revert)
404
+ final_content: dict[str, str | None] = {}
405
+ for change in self.reverted_changes:
406
+ final_content[change.path] = change.new_content
407
+
408
+ return list(final_content.items())
409
+
410
+ def restore_reverted_changes(self) -> int:
411
+ """Move reverted changes back to the main changes list.
412
+
413
+ Returns:
414
+ Number of changes restored
415
+ """
416
+ if not self.reverted_changes:
417
+ return 0
418
+
419
+ restored_count = len(self.reverted_changes)
420
+ self.changes.extend(self.reverted_changes)
421
+ self.reverted_changes = []
422
+ return restored_count
423
+
424
+ def to_dict(self) -> dict[str, Any]:
425
+ """Convert to dictionary for JSON serialization.
426
+
427
+ Returns:
428
+ Dict representation of all changes
429
+ """
430
+ return {
431
+ "changes": [
432
+ {
433
+ "path": c.path,
434
+ "operation": c.operation,
435
+ "timestamp": c.timestamp,
436
+ "message_id": c.message_id,
437
+ "agent_name": c.agent_name,
438
+ "has_old_content": c.old_content is not None,
439
+ "has_new_content": c.new_content is not None,
440
+ }
441
+ for c in self.changes
442
+ ],
443
+ "modified_paths": sorted(self.get_modified_paths()),
444
+ }
445
+
446
+
447
+ # =============================================================================
448
+ # Todo/Plan Tracking
449
+ # =============================================================================
450
+
451
+ TodoPriority = Literal["high", "medium", "low"]
452
+ TodoStatus = Literal["pending", "in_progress", "completed"]
453
+
454
+
455
+ @dataclass
456
+ class TodoEntry:
457
+ """A single todo/plan entry.
458
+
459
+ Represents a task that the agent intends to accomplish.
460
+ """
461
+
462
+ id: str
463
+ """Unique identifier for this entry."""
464
+
465
+ content: str
466
+ """Human-readable description of what this task aims to accomplish."""
467
+
468
+ status: TodoStatus = "pending"
469
+ """Current execution status."""
470
+
471
+ priority: TodoPriority = "medium"
472
+ """Relative importance of this task."""
473
+
474
+ created_at: float = field(default_factory=lambda: __import__("time").time())
475
+ """Unix timestamp when the entry was created."""
476
+
477
+ def to_dict(self) -> dict[str, Any]:
478
+ """Convert to dictionary for JSON serialization."""
479
+ return {
480
+ "id": self.id,
481
+ "content": self.content,
482
+ "status": self.status,
483
+ "priority": self.priority,
484
+ "created_at": self.created_at,
485
+ }
486
+
487
+
488
+ # Type for todo change callback (async coroutine)
489
+ TodoChangeCallback = Callable[["TodoTracker"], Coroutine[Any, Any, None]]
490
+
491
+
492
+ @dataclass
493
+ class TodoTracker:
494
+ """Tracks todo/plan entries at the pool level.
495
+
496
+ Provides a central place to manage todos that persists across
497
+ agent runs and is accessible from any toolset or endpoint.
498
+
499
+ Example:
500
+ ```python
501
+ tracker = TodoTracker()
502
+
503
+ # Add entries
504
+ tracker.add("Implement feature X", priority="high")
505
+ tracker.add("Write tests", priority="medium")
506
+
507
+ # Update status
508
+ tracker.update_status("todo_1", "in_progress")
509
+
510
+ # Get current entries
511
+ for entry in tracker.entries:
512
+ print(f"{entry.status}: {entry.content}")
513
+
514
+ # Subscribe to changes
515
+ tracker.on_change = lambda t: print(f"Todos changed: {len(t.entries)} items")
516
+ ```
517
+ """
518
+
519
+ entries: list[TodoEntry] = field(default_factory=list)
520
+ """List of all todo entries."""
521
+
522
+ _id_counter: int = field(default=0, repr=False)
523
+ """Counter for generating unique IDs."""
524
+
525
+ on_change: TodoChangeCallback | None = field(default=None, repr=False)
526
+ """Optional async callback invoked when todos change."""
527
+
528
+ _pending_tasks: set[asyncio.Task[None]] = field(default_factory=set, repr=False)
529
+ """Track pending notification tasks to prevent garbage collection."""
530
+
531
+ def _notify_change(self) -> None:
532
+ """Notify listener of changes (schedules async callback)."""
533
+ if self.on_change is not None:
534
+ task: asyncio.Task[None] = asyncio.create_task(self.on_change(self))
535
+ self._pending_tasks.add(task)
536
+ task.add_done_callback(self._pending_tasks.discard)
537
+
538
+ def _next_id(self) -> str:
539
+ """Generate next unique ID."""
540
+ self._id_counter += 1
541
+ return f"todo_{self._id_counter}"
542
+
543
+ def add(
544
+ self,
545
+ content: str,
546
+ *,
547
+ priority: TodoPriority = "medium",
548
+ status: TodoStatus = "pending",
549
+ index: int | None = None,
550
+ ) -> TodoEntry:
551
+ """Add a new todo entry.
552
+
553
+ Args:
554
+ content: Description of the task
555
+ priority: Relative importance (high/medium/low)
556
+ status: Initial status (default: pending)
557
+ index: Optional position to insert at (default: append)
558
+
559
+ Returns:
560
+ The created TodoEntry
561
+ """
562
+ entry = TodoEntry(
563
+ id=self._next_id(),
564
+ content=content,
565
+ priority=priority,
566
+ status=status,
567
+ )
568
+ if index is None or index >= len(self.entries):
569
+ self.entries.append(entry)
570
+ else:
571
+ self.entries.insert(max(0, index), entry)
572
+ self._notify_change()
573
+ return entry
574
+
575
+ def get(self, entry_id: str) -> TodoEntry | None:
576
+ """Get entry by ID.
577
+
578
+ Args:
579
+ entry_id: The entry ID to find
580
+
581
+ Returns:
582
+ The entry if found, None otherwise
583
+ """
584
+ for entry in self.entries:
585
+ if entry.id == entry_id:
586
+ return entry
587
+ return None
588
+
589
+ def get_by_index(self, index: int) -> TodoEntry | None:
590
+ """Get entry by index.
591
+
592
+ Args:
593
+ index: The 0-based index
594
+
595
+ Returns:
596
+ The entry if found, None otherwise
597
+ """
598
+ if 0 <= index < len(self.entries):
599
+ return self.entries[index]
600
+ return None
601
+
602
+ def update(
603
+ self,
604
+ entry_id: str,
605
+ *,
606
+ content: str | None = None,
607
+ status: TodoStatus | None = None,
608
+ priority: TodoPriority | None = None,
609
+ ) -> bool:
610
+ """Update an existing entry.
611
+
612
+ Args:
613
+ entry_id: The entry ID to update
614
+ content: New content (if provided)
615
+ status: New status (if provided)
616
+ priority: New priority (if provided)
617
+
618
+ Returns:
619
+ True if entry was found and updated, False otherwise
620
+ """
621
+ entry = self.get(entry_id)
622
+ if entry is None:
623
+ return False
624
+
625
+ changed = False
626
+ if content is not None and entry.content != content:
627
+ entry.content = content
628
+ changed = True
629
+ if status is not None and entry.status != status:
630
+ entry.status = status
631
+ changed = True
632
+ if priority is not None and entry.priority != priority:
633
+ entry.priority = priority
634
+ changed = True
635
+ if changed:
636
+ self._notify_change()
637
+ return True
638
+
639
+ def update_by_index(
640
+ self,
641
+ index: int,
642
+ *,
643
+ content: str | None = None,
644
+ status: TodoStatus | None = None,
645
+ priority: TodoPriority | None = None,
646
+ ) -> bool:
647
+ """Update an entry by index.
648
+
649
+ Args:
650
+ index: The 0-based index
651
+ content: New content (if provided)
652
+ status: New status (if provided)
653
+ priority: New priority (if provided)
654
+
655
+ Returns:
656
+ True if entry was found and updated, False otherwise
657
+ """
658
+ entry = self.get_by_index(index)
659
+ if entry is None:
660
+ return False
661
+
662
+ changed = False
663
+ if content is not None and entry.content != content:
664
+ entry.content = content
665
+ changed = True
666
+ if status is not None and entry.status != status:
667
+ entry.status = status
668
+ changed = True
669
+ if priority is not None and entry.priority != priority:
670
+ entry.priority = priority
671
+ changed = True
672
+ if changed:
673
+ self._notify_change()
674
+ return True
675
+
676
+ def remove(self, entry_id: str) -> bool:
677
+ """Remove an entry by ID.
678
+
679
+ Args:
680
+ entry_id: The entry ID to remove
681
+
682
+ Returns:
683
+ True if entry was found and removed, False otherwise
684
+ """
685
+ for i, entry in enumerate(self.entries):
686
+ if entry.id == entry_id:
687
+ self.entries.pop(i)
688
+ self._notify_change()
689
+ return True
690
+ return False
691
+
692
+ def remove_by_index(self, index: int) -> TodoEntry | None:
693
+ """Remove an entry by index.
694
+
695
+ Args:
696
+ index: The 0-based index
697
+
698
+ Returns:
699
+ The removed entry if found, None otherwise
700
+ """
701
+ if 0 <= index < len(self.entries):
702
+ entry = self.entries.pop(index)
703
+ self._notify_change()
704
+ return entry
705
+ return None
706
+
707
+ def clear(self) -> None:
708
+ """Clear all entries."""
709
+ if self.entries:
710
+ self.entries.clear()
711
+ self._notify_change()
712
+
713
+ def get_by_status(self, status: TodoStatus) -> list[TodoEntry]:
714
+ """Get all entries with a specific status.
715
+
716
+ Args:
717
+ status: The status to filter by
718
+
719
+ Returns:
720
+ List of matching entries
721
+ """
722
+ return [e for e in self.entries if e.status == status]
723
+
724
+ def to_list(self) -> list[dict[str, Any]]:
725
+ """Convert to list of dicts for JSON serialization."""
726
+ return [e.to_dict() for e in self.entries]