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.
- acp/__init__.py +13 -0
- acp/bridge/README.md +15 -2
- acp/bridge/__init__.py +3 -2
- acp/bridge/__main__.py +60 -19
- acp/bridge/ws_server.py +173 -0
- acp/bridge/ws_server_cli.py +89 -0
- acp/notifications.py +2 -1
- acp/stdio.py +39 -9
- acp/transports.py +362 -2
- acp/utils.py +15 -2
- agentpool/__init__.py +4 -1
- agentpool/agents/__init__.py +2 -0
- agentpool/agents/acp_agent/acp_agent.py +203 -88
- agentpool/agents/acp_agent/acp_converters.py +46 -21
- agentpool/agents/acp_agent/client_handler.py +157 -3
- agentpool/agents/acp_agent/session_state.py +4 -1
- agentpool/agents/agent.py +314 -107
- agentpool/agents/agui_agent/__init__.py +0 -2
- agentpool/agents/agui_agent/agui_agent.py +90 -21
- agentpool/agents/agui_agent/agui_converters.py +0 -131
- agentpool/agents/base_agent.py +163 -1
- agentpool/agents/claude_code_agent/claude_code_agent.py +626 -179
- agentpool/agents/claude_code_agent/converters.py +71 -3
- agentpool/agents/claude_code_agent/history.py +474 -0
- agentpool/agents/context.py +40 -0
- agentpool/agents/events/__init__.py +2 -0
- agentpool/agents/events/builtin_handlers.py +2 -1
- agentpool/agents/events/event_emitter.py +29 -2
- agentpool/agents/events/events.py +20 -0
- agentpool/agents/modes.py +54 -0
- agentpool/agents/tool_call_accumulator.py +213 -0
- agentpool/common_types.py +21 -0
- agentpool/config_resources/__init__.py +38 -1
- agentpool/config_resources/claude_code_agent.yml +3 -0
- agentpool/delegation/pool.py +37 -29
- agentpool/delegation/team.py +1 -0
- agentpool/delegation/teamrun.py +1 -0
- agentpool/diagnostics/__init__.py +53 -0
- agentpool/diagnostics/lsp_manager.py +1593 -0
- agentpool/diagnostics/lsp_proxy.py +41 -0
- agentpool/diagnostics/lsp_proxy_script.py +229 -0
- agentpool/diagnostics/models.py +398 -0
- agentpool/mcp_server/__init__.py +0 -2
- agentpool/mcp_server/client.py +12 -3
- agentpool/mcp_server/manager.py +25 -31
- agentpool/mcp_server/registries/official_registry_client.py +25 -0
- agentpool/mcp_server/tool_bridge.py +78 -66
- agentpool/messaging/__init__.py +0 -2
- agentpool/messaging/compaction.py +72 -197
- agentpool/messaging/message_history.py +12 -0
- agentpool/messaging/messages.py +52 -9
- agentpool/messaging/processing.py +3 -1
- agentpool/models/acp_agents/base.py +0 -22
- agentpool/models/acp_agents/mcp_capable.py +8 -148
- agentpool/models/acp_agents/non_mcp.py +129 -72
- agentpool/models/agents.py +35 -13
- agentpool/models/claude_code_agents.py +33 -2
- agentpool/models/manifest.py +43 -0
- agentpool/repomap.py +1 -1
- agentpool/resource_providers/__init__.py +9 -1
- agentpool/resource_providers/aggregating.py +52 -3
- agentpool/resource_providers/base.py +57 -1
- agentpool/resource_providers/mcp_provider.py +23 -0
- agentpool/resource_providers/plan_provider.py +130 -41
- agentpool/resource_providers/pool.py +2 -0
- agentpool/resource_providers/static.py +2 -0
- agentpool/sessions/__init__.py +2 -1
- agentpool/sessions/manager.py +31 -2
- agentpool/sessions/models.py +50 -0
- agentpool/skills/registry.py +13 -8
- agentpool/storage/manager.py +217 -1
- agentpool/testing.py +537 -19
- agentpool/utils/file_watcher.py +269 -0
- agentpool/utils/identifiers.py +121 -0
- agentpool/utils/pydantic_ai_helpers.py +46 -0
- agentpool/utils/streams.py +690 -1
- agentpool/utils/subprocess_utils.py +155 -0
- agentpool/utils/token_breakdown.py +461 -0
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/METADATA +27 -7
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/RECORD +170 -112
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/WHEEL +1 -1
- agentpool_cli/__main__.py +4 -0
- agentpool_cli/serve_acp.py +41 -20
- agentpool_cli/serve_agui.py +87 -0
- agentpool_cli/serve_opencode.py +119 -0
- agentpool_commands/__init__.py +30 -0
- agentpool_commands/agents.py +74 -1
- agentpool_commands/history.py +62 -0
- agentpool_commands/mcp.py +176 -0
- agentpool_commands/models.py +56 -3
- agentpool_commands/tools.py +57 -0
- agentpool_commands/utils.py +51 -0
- agentpool_config/builtin_tools.py +77 -22
- agentpool_config/commands.py +24 -1
- agentpool_config/compaction.py +258 -0
- agentpool_config/mcp_server.py +131 -1
- agentpool_config/storage.py +46 -1
- agentpool_config/tools.py +7 -1
- agentpool_config/toolsets.py +92 -148
- agentpool_server/acp_server/acp_agent.py +134 -150
- agentpool_server/acp_server/commands/acp_commands.py +216 -51
- agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +10 -10
- agentpool_server/acp_server/server.py +23 -79
- agentpool_server/acp_server/session.py +181 -19
- agentpool_server/opencode_server/.rules +95 -0
- agentpool_server/opencode_server/ENDPOINTS.md +362 -0
- agentpool_server/opencode_server/__init__.py +27 -0
- agentpool_server/opencode_server/command_validation.py +172 -0
- agentpool_server/opencode_server/converters.py +869 -0
- agentpool_server/opencode_server/dependencies.py +24 -0
- agentpool_server/opencode_server/input_provider.py +269 -0
- agentpool_server/opencode_server/models/__init__.py +228 -0
- agentpool_server/opencode_server/models/agent.py +53 -0
- agentpool_server/opencode_server/models/app.py +60 -0
- agentpool_server/opencode_server/models/base.py +26 -0
- agentpool_server/opencode_server/models/common.py +23 -0
- agentpool_server/opencode_server/models/config.py +37 -0
- agentpool_server/opencode_server/models/events.py +647 -0
- agentpool_server/opencode_server/models/file.py +88 -0
- agentpool_server/opencode_server/models/mcp.py +25 -0
- agentpool_server/opencode_server/models/message.py +162 -0
- agentpool_server/opencode_server/models/parts.py +190 -0
- agentpool_server/opencode_server/models/provider.py +81 -0
- agentpool_server/opencode_server/models/pty.py +43 -0
- agentpool_server/opencode_server/models/session.py +99 -0
- agentpool_server/opencode_server/routes/__init__.py +25 -0
- agentpool_server/opencode_server/routes/agent_routes.py +442 -0
- agentpool_server/opencode_server/routes/app_routes.py +139 -0
- agentpool_server/opencode_server/routes/config_routes.py +241 -0
- agentpool_server/opencode_server/routes/file_routes.py +392 -0
- agentpool_server/opencode_server/routes/global_routes.py +94 -0
- agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
- agentpool_server/opencode_server/routes/message_routes.py +705 -0
- agentpool_server/opencode_server/routes/pty_routes.py +299 -0
- agentpool_server/opencode_server/routes/session_routes.py +1205 -0
- agentpool_server/opencode_server/routes/tui_routes.py +139 -0
- agentpool_server/opencode_server/server.py +430 -0
- agentpool_server/opencode_server/state.py +121 -0
- agentpool_server/opencode_server/time_utils.py +8 -0
- agentpool_storage/__init__.py +16 -0
- agentpool_storage/base.py +103 -0
- agentpool_storage/claude_provider.py +907 -0
- agentpool_storage/file_provider.py +129 -0
- agentpool_storage/memory_provider.py +61 -0
- agentpool_storage/models.py +3 -0
- agentpool_storage/opencode_provider.py +730 -0
- agentpool_storage/project_store.py +325 -0
- agentpool_storage/session_store.py +6 -0
- agentpool_storage/sql_provider/__init__.py +4 -2
- agentpool_storage/sql_provider/models.py +48 -0
- agentpool_storage/sql_provider/sql_provider.py +134 -1
- agentpool_storage/sql_provider/utils.py +10 -1
- agentpool_storage/text_log_provider.py +1 -0
- agentpool_toolsets/builtin/__init__.py +0 -8
- agentpool_toolsets/builtin/code.py +95 -56
- agentpool_toolsets/builtin/debug.py +16 -21
- agentpool_toolsets/builtin/execution_environment.py +99 -103
- agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
- agentpool_toolsets/builtin/skills.py +86 -4
- agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
- agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
- agentpool_toolsets/fsspec_toolset/grep.py +74 -2
- agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
- agentpool_toolsets/fsspec_toolset/toolset.py +159 -38
- agentpool_toolsets/mcp_discovery/__init__.py +5 -0
- agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
- agentpool_toolsets/mcp_discovery/toolset.py +454 -0
- agentpool_toolsets/mcp_run_toolset.py +84 -6
- agentpool_toolsets/builtin/agent_management.py +0 -239
- agentpool_toolsets/builtin/history.py +0 -36
- agentpool_toolsets/builtin/integration.py +0 -85
- agentpool_toolsets/builtin/tool_management.py +0 -90
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/entry_points.txt +0 -0
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/licenses/LICENSE +0 -0
agentpool/utils/streams.py
CHANGED
|
@@ -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
|
|
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]
|