agentpool 2.1.9__py3-none-any.whl → 2.2.3__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 (174) hide show
  1. acp/__init__.py +13 -0
  2. acp/bridge/README.md +15 -2
  3. acp/bridge/__init__.py +3 -2
  4. acp/bridge/__main__.py +60 -19
  5. acp/bridge/ws_server.py +173 -0
  6. acp/bridge/ws_server_cli.py +89 -0
  7. acp/notifications.py +2 -1
  8. acp/stdio.py +39 -9
  9. acp/transports.py +362 -2
  10. acp/utils.py +15 -2
  11. agentpool/__init__.py +4 -1
  12. agentpool/agents/__init__.py +2 -0
  13. agentpool/agents/acp_agent/acp_agent.py +203 -88
  14. agentpool/agents/acp_agent/acp_converters.py +46 -21
  15. agentpool/agents/acp_agent/client_handler.py +157 -3
  16. agentpool/agents/acp_agent/session_state.py +4 -1
  17. agentpool/agents/agent.py +314 -107
  18. agentpool/agents/agui_agent/__init__.py +0 -2
  19. agentpool/agents/agui_agent/agui_agent.py +90 -21
  20. agentpool/agents/agui_agent/agui_converters.py +0 -131
  21. agentpool/agents/base_agent.py +163 -1
  22. agentpool/agents/claude_code_agent/claude_code_agent.py +626 -179
  23. agentpool/agents/claude_code_agent/converters.py +71 -3
  24. agentpool/agents/claude_code_agent/history.py +474 -0
  25. agentpool/agents/context.py +40 -0
  26. agentpool/agents/events/__init__.py +2 -0
  27. agentpool/agents/events/builtin_handlers.py +2 -1
  28. agentpool/agents/events/event_emitter.py +29 -2
  29. agentpool/agents/events/events.py +20 -0
  30. agentpool/agents/modes.py +54 -0
  31. agentpool/agents/tool_call_accumulator.py +213 -0
  32. agentpool/common_types.py +21 -0
  33. agentpool/config_resources/__init__.py +38 -1
  34. agentpool/config_resources/claude_code_agent.yml +3 -0
  35. agentpool/delegation/pool.py +37 -29
  36. agentpool/delegation/team.py +1 -0
  37. agentpool/delegation/teamrun.py +1 -0
  38. agentpool/diagnostics/__init__.py +53 -0
  39. agentpool/diagnostics/lsp_manager.py +1593 -0
  40. agentpool/diagnostics/lsp_proxy.py +41 -0
  41. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  42. agentpool/diagnostics/models.py +398 -0
  43. agentpool/mcp_server/__init__.py +0 -2
  44. agentpool/mcp_server/client.py +12 -3
  45. agentpool/mcp_server/manager.py +25 -31
  46. agentpool/mcp_server/registries/official_registry_client.py +25 -0
  47. agentpool/mcp_server/tool_bridge.py +78 -66
  48. agentpool/messaging/__init__.py +0 -2
  49. agentpool/messaging/compaction.py +72 -197
  50. agentpool/messaging/message_history.py +12 -0
  51. agentpool/messaging/messages.py +52 -9
  52. agentpool/messaging/processing.py +3 -1
  53. agentpool/models/acp_agents/base.py +0 -22
  54. agentpool/models/acp_agents/mcp_capable.py +8 -148
  55. agentpool/models/acp_agents/non_mcp.py +129 -72
  56. agentpool/models/agents.py +35 -13
  57. agentpool/models/claude_code_agents.py +33 -2
  58. agentpool/models/manifest.py +43 -0
  59. agentpool/repomap.py +1 -1
  60. agentpool/resource_providers/__init__.py +9 -1
  61. agentpool/resource_providers/aggregating.py +52 -3
  62. agentpool/resource_providers/base.py +57 -1
  63. agentpool/resource_providers/mcp_provider.py +23 -0
  64. agentpool/resource_providers/plan_provider.py +130 -41
  65. agentpool/resource_providers/pool.py +2 -0
  66. agentpool/resource_providers/static.py +2 -0
  67. agentpool/sessions/__init__.py +2 -1
  68. agentpool/sessions/manager.py +31 -2
  69. agentpool/sessions/models.py +50 -0
  70. agentpool/skills/registry.py +13 -8
  71. agentpool/storage/manager.py +217 -1
  72. agentpool/testing.py +537 -19
  73. agentpool/utils/file_watcher.py +269 -0
  74. agentpool/utils/identifiers.py +121 -0
  75. agentpool/utils/pydantic_ai_helpers.py +46 -0
  76. agentpool/utils/streams.py +690 -1
  77. agentpool/utils/subprocess_utils.py +155 -0
  78. agentpool/utils/token_breakdown.py +461 -0
  79. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/METADATA +27 -7
  80. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/RECORD +170 -112
  81. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/WHEEL +1 -1
  82. agentpool_cli/__main__.py +4 -0
  83. agentpool_cli/serve_acp.py +41 -20
  84. agentpool_cli/serve_agui.py +87 -0
  85. agentpool_cli/serve_opencode.py +119 -0
  86. agentpool_commands/__init__.py +30 -0
  87. agentpool_commands/agents.py +74 -1
  88. agentpool_commands/history.py +62 -0
  89. agentpool_commands/mcp.py +176 -0
  90. agentpool_commands/models.py +56 -3
  91. agentpool_commands/tools.py +57 -0
  92. agentpool_commands/utils.py +51 -0
  93. agentpool_config/builtin_tools.py +77 -22
  94. agentpool_config/commands.py +24 -1
  95. agentpool_config/compaction.py +258 -0
  96. agentpool_config/mcp_server.py +131 -1
  97. agentpool_config/storage.py +46 -1
  98. agentpool_config/tools.py +7 -1
  99. agentpool_config/toolsets.py +92 -148
  100. agentpool_server/acp_server/acp_agent.py +134 -150
  101. agentpool_server/acp_server/commands/acp_commands.py +216 -51
  102. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +10 -10
  103. agentpool_server/acp_server/server.py +23 -79
  104. agentpool_server/acp_server/session.py +181 -19
  105. agentpool_server/opencode_server/.rules +95 -0
  106. agentpool_server/opencode_server/ENDPOINTS.md +362 -0
  107. agentpool_server/opencode_server/__init__.py +27 -0
  108. agentpool_server/opencode_server/command_validation.py +172 -0
  109. agentpool_server/opencode_server/converters.py +869 -0
  110. agentpool_server/opencode_server/dependencies.py +24 -0
  111. agentpool_server/opencode_server/input_provider.py +269 -0
  112. agentpool_server/opencode_server/models/__init__.py +228 -0
  113. agentpool_server/opencode_server/models/agent.py +53 -0
  114. agentpool_server/opencode_server/models/app.py +60 -0
  115. agentpool_server/opencode_server/models/base.py +26 -0
  116. agentpool_server/opencode_server/models/common.py +23 -0
  117. agentpool_server/opencode_server/models/config.py +37 -0
  118. agentpool_server/opencode_server/models/events.py +647 -0
  119. agentpool_server/opencode_server/models/file.py +88 -0
  120. agentpool_server/opencode_server/models/mcp.py +25 -0
  121. agentpool_server/opencode_server/models/message.py +162 -0
  122. agentpool_server/opencode_server/models/parts.py +190 -0
  123. agentpool_server/opencode_server/models/provider.py +81 -0
  124. agentpool_server/opencode_server/models/pty.py +43 -0
  125. agentpool_server/opencode_server/models/session.py +99 -0
  126. agentpool_server/opencode_server/routes/__init__.py +25 -0
  127. agentpool_server/opencode_server/routes/agent_routes.py +442 -0
  128. agentpool_server/opencode_server/routes/app_routes.py +139 -0
  129. agentpool_server/opencode_server/routes/config_routes.py +241 -0
  130. agentpool_server/opencode_server/routes/file_routes.py +392 -0
  131. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  132. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  133. agentpool_server/opencode_server/routes/message_routes.py +705 -0
  134. agentpool_server/opencode_server/routes/pty_routes.py +299 -0
  135. agentpool_server/opencode_server/routes/session_routes.py +1205 -0
  136. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  137. agentpool_server/opencode_server/server.py +430 -0
  138. agentpool_server/opencode_server/state.py +121 -0
  139. agentpool_server/opencode_server/time_utils.py +8 -0
  140. agentpool_storage/__init__.py +16 -0
  141. agentpool_storage/base.py +103 -0
  142. agentpool_storage/claude_provider.py +907 -0
  143. agentpool_storage/file_provider.py +129 -0
  144. agentpool_storage/memory_provider.py +61 -0
  145. agentpool_storage/models.py +3 -0
  146. agentpool_storage/opencode_provider.py +730 -0
  147. agentpool_storage/project_store.py +325 -0
  148. agentpool_storage/session_store.py +6 -0
  149. agentpool_storage/sql_provider/__init__.py +4 -2
  150. agentpool_storage/sql_provider/models.py +48 -0
  151. agentpool_storage/sql_provider/sql_provider.py +134 -1
  152. agentpool_storage/sql_provider/utils.py +10 -1
  153. agentpool_storage/text_log_provider.py +1 -0
  154. agentpool_toolsets/builtin/__init__.py +0 -8
  155. agentpool_toolsets/builtin/code.py +95 -56
  156. agentpool_toolsets/builtin/debug.py +16 -21
  157. agentpool_toolsets/builtin/execution_environment.py +99 -103
  158. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  159. agentpool_toolsets/builtin/skills.py +86 -4
  160. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  161. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  162. agentpool_toolsets/fsspec_toolset/grep.py +74 -2
  163. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  164. agentpool_toolsets/fsspec_toolset/toolset.py +159 -38
  165. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  166. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  167. agentpool_toolsets/mcp_discovery/toolset.py +454 -0
  168. agentpool_toolsets/mcp_run_toolset.py +84 -6
  169. agentpool_toolsets/builtin/agent_management.py +0 -239
  170. agentpool_toolsets/builtin/history.py +0 -36
  171. agentpool_toolsets/builtin/integration.py +0 -85
  172. agentpool_toolsets/builtin/tool_management.py +0 -90
  173. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/entry_points.txt +0 -0
  174. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -3,13 +3,17 @@
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
8
10
 
9
11
 
10
12
  if TYPE_CHECKING:
11
13
  from collections.abc import AsyncIterator
12
14
 
15
+ from agentpool.common_types import SimpleJsonType
16
+
13
17
 
14
18
  @asynccontextmanager
15
19
  async def merge_queue_into_iterator[T, V]( # noqa: PLR0915
@@ -110,3 +114,688 @@ async def merge_queue_into_iterator[T, V]( # noqa: PLR0915
110
114
  primary_task_obj.cancel()
111
115
  secondary_task_obj.cancel()
112
116
  await asyncio.gather(primary_task_obj, secondary_task_obj, return_exceptions=True)
117
+
118
+
119
+ def extract_file_path_from_tool_call(tool_name: str, raw_input: dict[str, Any]) -> str | None:
120
+ """Extract file path from a tool call if it's a file-writing tool.
121
+
122
+ Uses simple heuristics:
123
+ - Tool name contains 'write' or 'edit' (case-insensitive)
124
+ - Input contains 'path' or 'file_path' key
125
+
126
+ Args:
127
+ tool_name: Name of the tool being called
128
+ raw_input: Tool call arguments
129
+
130
+ Returns:
131
+ File path if this is a file-writing tool, None otherwise
132
+ """
133
+ name_lower = tool_name.lower()
134
+ if "write" not in name_lower and "edit" not in name_lower:
135
+ return None
136
+
137
+ # Try common path argument names
138
+ for key in ("file_path", "path", "filepath", "filename", "file"):
139
+ if key in raw_input and isinstance(val := raw_input[key], str):
140
+ return val
141
+
142
+ return None
143
+
144
+
145
+ @dataclass
146
+ class FileTracker:
147
+ """Tracks files modified during a stream of events.
148
+
149
+ Example:
150
+ ```python
151
+ file_tracker = FileTracker()
152
+ async for event in file_tracker.track(events):
153
+ yield event
154
+
155
+ print(f"Modified files: {file_tracker.touched_files}")
156
+ ```
157
+ """
158
+
159
+ touched_files: set[str] = field(default_factory=set)
160
+ """Set of file paths that were modified by tool calls."""
161
+
162
+ extractor: Callable[[str, dict[str, Any]], str | None] = extract_file_path_from_tool_call
163
+ """Function to extract file path from tool call. Can be customized."""
164
+
165
+ def process_event(self, event: Any) -> None:
166
+ """Process an event and track any file modifications.
167
+
168
+ Args:
169
+ event: The event to process (checks for ToolCallStartEvent)
170
+ """
171
+ # Import here to avoid circular imports
172
+ from agentpool.agents.events import ToolCallStartEvent
173
+
174
+ if isinstance(event, ToolCallStartEvent) and (
175
+ file_path := self.extractor(event.tool_name or "", event.raw_input or {})
176
+ ):
177
+ self.touched_files.add(file_path)
178
+
179
+ def track[T](self, stream: AsyncIterator[T]) -> AsyncIterator[T]:
180
+ """Wrap an async iterator to automatically track file modifications.
181
+
182
+ Args:
183
+ stream: The event stream to wrap
184
+
185
+ Returns:
186
+ Wrapped async iterator that tracks file modifications
187
+
188
+ Example:
189
+ ```python
190
+ async for event in file_tracker.track(events):
191
+ yield event
192
+ ```
193
+ """
194
+
195
+ async def wrapped() -> AsyncIterator[T]:
196
+ async for event in stream:
197
+ self.process_event(event)
198
+ yield event
199
+
200
+ return wrapped()
201
+
202
+ def get_metadata(self) -> SimpleJsonType:
203
+ """Get metadata dict with touched files (if any).
204
+
205
+ Returns:
206
+ Dict with 'touched_files' key if files were modified, else empty dict
207
+ """
208
+ if self.touched_files:
209
+ return {"touched_files": sorted(self.touched_files)}
210
+ return {}
211
+
212
+
213
+ @dataclass
214
+ class FileChange:
215
+ """Represents a single file change operation."""
216
+
217
+ path: str
218
+ """File path that was modified."""
219
+
220
+ old_content: str | None
221
+ """Content before change (None for new files)."""
222
+
223
+ new_content: str | None
224
+ """Content after change (None for deletions)."""
225
+
226
+ operation: str
227
+ """Type of operation: 'create', 'write', 'edit', 'delete'."""
228
+
229
+ timestamp: float = field(default_factory=lambda: __import__("time").time())
230
+ """Unix timestamp when the change occurred."""
231
+
232
+ message_id: str | None = None
233
+ """ID of the message that triggered this change (for revert-to-message)."""
234
+
235
+ agent_name: str | None = None
236
+ """Name of the agent that made this change."""
237
+
238
+ def to_unified_diff(self) -> str:
239
+ """Generate unified diff for this change.
240
+
241
+ Returns:
242
+ Unified diff string
243
+ """
244
+ import difflib
245
+
246
+ old_lines = (self.old_content or "").splitlines(keepends=True)
247
+ new_lines = (self.new_content or "").splitlines(keepends=True)
248
+
249
+ diff = difflib.unified_diff(
250
+ old_lines,
251
+ new_lines,
252
+ fromfile=f"a/{self.path}",
253
+ tofile=f"b/{self.path}",
254
+ )
255
+ return "".join(diff)
256
+
257
+
258
+ @dataclass
259
+ class FileOpsTracker:
260
+ r"""Tracks file operations with full content for diff/revert support.
261
+
262
+ Stores file changes with before/after content so they can be:
263
+ - Displayed as diffs
264
+ - Reverted to previous state
265
+ - Filtered by message ID
266
+
267
+ Example:
268
+ ```python
269
+ tracker = FileOpsTracker()
270
+
271
+ # Record a file edit
272
+ tracker.record_change(
273
+ path="src/main.py",
274
+ old_content="def foo(): pass",
275
+ new_content="def foo():\\n return 42",
276
+ operation="edit",
277
+ )
278
+
279
+ # Get all diffs
280
+ for change in tracker.changes:
281
+ print(change.to_unified_diff())
282
+
283
+ # Revert all changes
284
+ for path, content in tracker.get_revert_operations():
285
+ write_file(path, content)
286
+ ```
287
+ """
288
+
289
+ changes: list[FileChange] = field(default_factory=list)
290
+ """List of all recorded file changes in order."""
291
+
292
+ reverted_changes: list[FileChange] = field(default_factory=list)
293
+ """Changes that were reverted and can be restored with unrevert."""
294
+
295
+ def record_change(
296
+ self,
297
+ path: str,
298
+ old_content: str | None,
299
+ new_content: str | None,
300
+ operation: str,
301
+ message_id: str | None = None,
302
+ agent_name: str | None = None,
303
+ ) -> None:
304
+ """Record a file change.
305
+
306
+ Args:
307
+ path: File path that was modified
308
+ old_content: Content before change (None for new files)
309
+ new_content: Content after change (None for deletions)
310
+ operation: Type of operation ('create', 'write', 'edit', 'delete')
311
+ message_id: Optional message ID that triggered this change
312
+ agent_name: Optional name of the agent that made this change
313
+ """
314
+ self.changes.append(
315
+ FileChange(
316
+ path=path,
317
+ old_content=old_content,
318
+ new_content=new_content,
319
+ operation=operation,
320
+ message_id=message_id,
321
+ agent_name=agent_name,
322
+ )
323
+ )
324
+
325
+ def get_changes_for_path(self, path: str) -> list[FileChange]:
326
+ """Get all changes for a specific file path.
327
+
328
+ Args:
329
+ path: File path to filter by
330
+
331
+ Returns:
332
+ List of changes for the given path
333
+ """
334
+ return [c for c in self.changes if c.path == path]
335
+
336
+ def get_changes_since_message(self, message_id: str) -> list[FileChange]:
337
+ """Get all changes since (and including) a specific message.
338
+
339
+ Args:
340
+ message_id: Message ID to start from
341
+
342
+ Returns:
343
+ List of changes from the given message onwards
344
+ """
345
+ result = []
346
+ found = False
347
+ for change in self.changes:
348
+ if change.message_id == message_id:
349
+ found = True
350
+ if found:
351
+ result.append(change)
352
+ return result
353
+
354
+ def get_modified_paths(self) -> set[str]:
355
+ """Get set of all modified file paths.
356
+
357
+ Returns:
358
+ Set of file paths that have been modified
359
+ """
360
+ return {c.path for c in self.changes}
361
+
362
+ def get_current_state(self) -> dict[str, str | None]:
363
+ """Get the current state of all modified files.
364
+
365
+ For each file, returns the content after all changes have been applied.
366
+ Returns None for deleted files.
367
+
368
+ Returns:
369
+ Dict mapping path to current content (or None if deleted)
370
+ """
371
+ state: dict[str, str | None] = {}
372
+ for change in self.changes:
373
+ state[change.path] = change.new_content
374
+ return state
375
+
376
+ def get_original_state(self) -> dict[str, str | None]:
377
+ """Get the original state of all modified files.
378
+
379
+ For each file, returns the content before any changes were made.
380
+ Returns None for files that were created (didn't exist).
381
+
382
+ Returns:
383
+ Dict mapping path to original content (or None if created)
384
+ """
385
+ state: dict[str, str | None] = {}
386
+ for change in self.changes:
387
+ if change.path not in state:
388
+ state[change.path] = change.old_content
389
+ return state
390
+
391
+ def get_revert_operations(
392
+ self, since_message_id: str | None = None
393
+ ) -> list[tuple[str, str | None]]:
394
+ """Get operations needed to revert changes.
395
+
396
+ Returns list of (path, content) tuples in reverse order (newest first).
397
+ If content is None, the file should be deleted.
398
+
399
+ Args:
400
+ since_message_id: If provided, only revert changes from this message onwards.
401
+ If None, revert all changes.
402
+
403
+ Returns:
404
+ List of (path, content_to_restore) tuples for revert
405
+ """
406
+ if since_message_id:
407
+ changes = self.get_changes_since_message(since_message_id)
408
+ else:
409
+ changes = self.changes
410
+
411
+ # Build map of path -> content to restore
412
+ # For each path, we need the old_content of the FIRST change in our subset
413
+ # (that's what the file looked like before any of these changes)
414
+ original_for_path: dict[str, str | None] = {}
415
+ for change in changes:
416
+ if change.path not in original_for_path:
417
+ original_for_path[change.path] = change.old_content
418
+
419
+ return list(original_for_path.items())
420
+
421
+ def get_combined_diff(self) -> str:
422
+ """Get combined unified diff of all changes.
423
+
424
+ Returns:
425
+ Combined diff string for all file changes
426
+ """
427
+ diffs = []
428
+ for change in self.changes:
429
+ diff = change.to_unified_diff()
430
+ if diff:
431
+ diffs.append(diff)
432
+ return "\n".join(diffs)
433
+
434
+ def clear(self) -> None:
435
+ """Clear all recorded changes."""
436
+ self.changes.clear()
437
+
438
+ def remove_changes_since_message(self, message_id: str) -> int:
439
+ """Remove changes from a specific message onwards and store for unrevert.
440
+
441
+ The removed changes are stored in `reverted_changes` so they can be
442
+ restored later via `restore_reverted_changes()`.
443
+
444
+ Args:
445
+ message_id: Message ID to start removal from
446
+
447
+ Returns:
448
+ Number of changes removed
449
+ """
450
+ # Find the index of the first change with this message_id
451
+ start_idx = None
452
+ for i, change in enumerate(self.changes):
453
+ if change.message_id == message_id:
454
+ start_idx = i
455
+ break
456
+
457
+ if start_idx is None:
458
+ return 0
459
+
460
+ # Store removed changes for potential unrevert
461
+ self.reverted_changes = self.changes[start_idx:]
462
+ self.changes = self.changes[:start_idx]
463
+ return len(self.reverted_changes)
464
+
465
+ def get_unrevert_operations(self) -> list[tuple[str, str | None]]:
466
+ """Get operations needed to restore reverted changes.
467
+
468
+ Returns list of (path, content) tuples. The content is the new_content
469
+ from each reverted change (what the file should contain after unrevert).
470
+
471
+ Returns:
472
+ List of (path, content_to_write) tuples for unrevert
473
+ """
474
+ if not self.reverted_changes:
475
+ return []
476
+
477
+ # For each path, we want the LAST new_content in the reverted changes
478
+ # (that's what the file looked like before the revert)
479
+ final_content: dict[str, str | None] = {}
480
+ for change in self.reverted_changes:
481
+ final_content[change.path] = change.new_content
482
+
483
+ return list(final_content.items())
484
+
485
+ def restore_reverted_changes(self) -> int:
486
+ """Move reverted changes back to the main changes list.
487
+
488
+ Returns:
489
+ Number of changes restored
490
+ """
491
+ if not self.reverted_changes:
492
+ return 0
493
+
494
+ restored_count = len(self.reverted_changes)
495
+ self.changes.extend(self.reverted_changes)
496
+ self.reverted_changes = []
497
+ return restored_count
498
+
499
+ def to_dict(self) -> dict[str, Any]:
500
+ """Convert to dictionary for JSON serialization.
501
+
502
+ Returns:
503
+ Dict representation of all changes
504
+ """
505
+ return {
506
+ "changes": [
507
+ {
508
+ "path": c.path,
509
+ "operation": c.operation,
510
+ "timestamp": c.timestamp,
511
+ "message_id": c.message_id,
512
+ "agent_name": c.agent_name,
513
+ "has_old_content": c.old_content is not None,
514
+ "has_new_content": c.new_content is not None,
515
+ }
516
+ for c in self.changes
517
+ ],
518
+ "modified_paths": sorted(self.get_modified_paths()),
519
+ }
520
+
521
+
522
+ # =============================================================================
523
+ # Todo/Plan Tracking
524
+ # =============================================================================
525
+
526
+ TodoPriority = Literal["high", "medium", "low"]
527
+ TodoStatus = Literal["pending", "in_progress", "completed"]
528
+
529
+
530
+ @dataclass
531
+ class TodoEntry:
532
+ """A single todo/plan entry.
533
+
534
+ Represents a task that the agent intends to accomplish.
535
+ """
536
+
537
+ id: str
538
+ """Unique identifier for this entry."""
539
+
540
+ content: str
541
+ """Human-readable description of what this task aims to accomplish."""
542
+
543
+ status: TodoStatus = "pending"
544
+ """Current execution status."""
545
+
546
+ priority: TodoPriority = "medium"
547
+ """Relative importance of this task."""
548
+
549
+ created_at: float = field(default_factory=lambda: __import__("time").time())
550
+ """Unix timestamp when the entry was created."""
551
+
552
+ def to_dict(self) -> dict[str, Any]:
553
+ """Convert to dictionary for JSON serialization."""
554
+ return {
555
+ "id": self.id,
556
+ "content": self.content,
557
+ "status": self.status,
558
+ "priority": self.priority,
559
+ "created_at": self.created_at,
560
+ }
561
+
562
+
563
+ # Type for todo change callback (async coroutine)
564
+ TodoChangeCallback = Callable[["TodoTracker"], Coroutine[Any, Any, None]]
565
+
566
+
567
+ @dataclass
568
+ class TodoTracker:
569
+ """Tracks todo/plan entries at the pool level.
570
+
571
+ Provides a central place to manage todos that persists across
572
+ agent runs and is accessible from any toolset or endpoint.
573
+
574
+ Example:
575
+ ```python
576
+ tracker = TodoTracker()
577
+
578
+ # Add entries
579
+ tracker.add("Implement feature X", priority="high")
580
+ tracker.add("Write tests", priority="medium")
581
+
582
+ # Update status
583
+ tracker.update_status("todo_1", "in_progress")
584
+
585
+ # Get current entries
586
+ for entry in tracker.entries:
587
+ print(f"{entry.status}: {entry.content}")
588
+
589
+ # Subscribe to changes
590
+ tracker.on_change = lambda t: print(f"Todos changed: {len(t.entries)} items")
591
+ ```
592
+ """
593
+
594
+ entries: list[TodoEntry] = field(default_factory=list)
595
+ """List of all todo entries."""
596
+
597
+ _id_counter: int = field(default=0, repr=False)
598
+ """Counter for generating unique IDs."""
599
+
600
+ on_change: TodoChangeCallback | None = field(default=None, repr=False)
601
+ """Optional async callback invoked when todos change."""
602
+
603
+ _pending_tasks: set[asyncio.Task[None]] = field(default_factory=set, repr=False)
604
+ """Track pending notification tasks to prevent garbage collection."""
605
+
606
+ def _notify_change(self) -> None:
607
+ """Notify listener of changes (schedules async callback)."""
608
+ if self.on_change is not None:
609
+ task: asyncio.Task[None] = asyncio.create_task(self.on_change(self))
610
+ self._pending_tasks.add(task)
611
+ task.add_done_callback(self._pending_tasks.discard)
612
+
613
+ def _next_id(self) -> str:
614
+ """Generate next unique ID."""
615
+ self._id_counter += 1
616
+ return f"todo_{self._id_counter}"
617
+
618
+ def add(
619
+ self,
620
+ content: str,
621
+ *,
622
+ priority: TodoPriority = "medium",
623
+ status: TodoStatus = "pending",
624
+ index: int | None = None,
625
+ ) -> TodoEntry:
626
+ """Add a new todo entry.
627
+
628
+ Args:
629
+ content: Description of the task
630
+ priority: Relative importance (high/medium/low)
631
+ status: Initial status (default: pending)
632
+ index: Optional position to insert at (default: append)
633
+
634
+ Returns:
635
+ The created TodoEntry
636
+ """
637
+ entry = TodoEntry(
638
+ id=self._next_id(),
639
+ content=content,
640
+ priority=priority,
641
+ status=status,
642
+ )
643
+ if index is None or index >= len(self.entries):
644
+ self.entries.append(entry)
645
+ else:
646
+ self.entries.insert(max(0, index), entry)
647
+ self._notify_change()
648
+ return entry
649
+
650
+ def get(self, entry_id: str) -> TodoEntry | None:
651
+ """Get entry by ID.
652
+
653
+ Args:
654
+ entry_id: The entry ID to find
655
+
656
+ Returns:
657
+ The entry if found, None otherwise
658
+ """
659
+ for entry in self.entries:
660
+ if entry.id == entry_id:
661
+ return entry
662
+ return None
663
+
664
+ def get_by_index(self, index: int) -> TodoEntry | None:
665
+ """Get entry by index.
666
+
667
+ Args:
668
+ index: The 0-based index
669
+
670
+ Returns:
671
+ The entry if found, None otherwise
672
+ """
673
+ if 0 <= index < len(self.entries):
674
+ return self.entries[index]
675
+ return None
676
+
677
+ def update(
678
+ self,
679
+ entry_id: str,
680
+ *,
681
+ content: str | None = None,
682
+ status: TodoStatus | None = None,
683
+ priority: TodoPriority | None = None,
684
+ ) -> bool:
685
+ """Update an existing entry.
686
+
687
+ Args:
688
+ entry_id: The entry ID to update
689
+ content: New content (if provided)
690
+ status: New status (if provided)
691
+ priority: New priority (if provided)
692
+
693
+ Returns:
694
+ True if entry was found and updated, False otherwise
695
+ """
696
+ entry = self.get(entry_id)
697
+ if entry is None:
698
+ return False
699
+
700
+ changed = False
701
+ if content is not None and entry.content != content:
702
+ entry.content = content
703
+ changed = True
704
+ if status is not None and entry.status != status:
705
+ entry.status = status
706
+ changed = True
707
+ if priority is not None and entry.priority != priority:
708
+ entry.priority = priority
709
+ changed = True
710
+ if changed:
711
+ self._notify_change()
712
+ return True
713
+
714
+ def update_by_index(
715
+ self,
716
+ index: int,
717
+ *,
718
+ content: str | None = None,
719
+ status: TodoStatus | None = None,
720
+ priority: TodoPriority | None = None,
721
+ ) -> bool:
722
+ """Update an entry by index.
723
+
724
+ Args:
725
+ index: The 0-based index
726
+ content: New content (if provided)
727
+ status: New status (if provided)
728
+ priority: New priority (if provided)
729
+
730
+ Returns:
731
+ True if entry was found and updated, False otherwise
732
+ """
733
+ entry = self.get_by_index(index)
734
+ if entry is None:
735
+ return False
736
+
737
+ changed = False
738
+ if content is not None and entry.content != content:
739
+ entry.content = content
740
+ changed = True
741
+ if status is not None and entry.status != status:
742
+ entry.status = status
743
+ changed = True
744
+ if priority is not None and entry.priority != priority:
745
+ entry.priority = priority
746
+ changed = True
747
+ if changed:
748
+ self._notify_change()
749
+ return True
750
+
751
+ def remove(self, entry_id: str) -> bool:
752
+ """Remove an entry by ID.
753
+
754
+ Args:
755
+ entry_id: The entry ID to remove
756
+
757
+ Returns:
758
+ True if entry was found and removed, False otherwise
759
+ """
760
+ for i, entry in enumerate(self.entries):
761
+ if entry.id == entry_id:
762
+ self.entries.pop(i)
763
+ self._notify_change()
764
+ return True
765
+ return False
766
+
767
+ def remove_by_index(self, index: int) -> TodoEntry | None:
768
+ """Remove an entry by index.
769
+
770
+ Args:
771
+ index: The 0-based index
772
+
773
+ Returns:
774
+ The removed entry if found, None otherwise
775
+ """
776
+ if 0 <= index < len(self.entries):
777
+ entry = self.entries.pop(index)
778
+ self._notify_change()
779
+ return entry
780
+ return None
781
+
782
+ def clear(self) -> None:
783
+ """Clear all entries."""
784
+ if self.entries:
785
+ self.entries.clear()
786
+ self._notify_change()
787
+
788
+ def get_by_status(self, status: TodoStatus) -> list[TodoEntry]:
789
+ """Get all entries with a specific status.
790
+
791
+ Args:
792
+ status: The status to filter by
793
+
794
+ Returns:
795
+ List of matching entries
796
+ """
797
+ return [e for e in self.entries if e.status == status]
798
+
799
+ def to_list(self) -> list[dict[str, Any]]:
800
+ """Convert to list of dicts for JSON serialization."""
801
+ return [e.to_dict() for e in self.entries]