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
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""File watcher utilities using watchfiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable, Set as AbstractSet
|
|
7
|
+
import contextlib
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Self
|
|
12
|
+
|
|
13
|
+
from watchfiles import Change, awatch
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Callback type for file change notifications
|
|
20
|
+
FileChangeCallback = Callable[[AbstractSet[tuple[Change, str]]], Awaitable[None]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FileWatcher:
|
|
25
|
+
"""Async file watcher using watchfiles.
|
|
26
|
+
|
|
27
|
+
Watches specified paths for changes and calls a callback when changes occur.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
```python
|
|
31
|
+
async def on_change(changes):
|
|
32
|
+
for change_type, path in changes:
|
|
33
|
+
print(f"{change_type}: {path}")
|
|
34
|
+
|
|
35
|
+
watcher = FileWatcher(
|
|
36
|
+
paths=["/path/to/.git/HEAD"],
|
|
37
|
+
callback=on_change,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async with watcher:
|
|
41
|
+
# Watcher is running
|
|
42
|
+
await asyncio.sleep(60)
|
|
43
|
+
# Watcher stopped
|
|
44
|
+
```
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
paths: list[str | Path]
|
|
48
|
+
"""Paths to watch (files or directories)."""
|
|
49
|
+
|
|
50
|
+
callback: FileChangeCallback
|
|
51
|
+
"""Async callback invoked when changes are detected."""
|
|
52
|
+
|
|
53
|
+
debounce: int = 100
|
|
54
|
+
"""Debounce time in milliseconds."""
|
|
55
|
+
|
|
56
|
+
_task: asyncio.Task[None] | None = field(default=None, repr=False)
|
|
57
|
+
"""Background watch task."""
|
|
58
|
+
|
|
59
|
+
_stop_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
|
|
60
|
+
"""Event to signal stop."""
|
|
61
|
+
|
|
62
|
+
async def start(self) -> None:
|
|
63
|
+
"""Start watching for file changes."""
|
|
64
|
+
if self._task is not None:
|
|
65
|
+
return # Already running
|
|
66
|
+
|
|
67
|
+
self._stop_event.clear()
|
|
68
|
+
self._task = asyncio.create_task(self._watch_loop())
|
|
69
|
+
|
|
70
|
+
async def stop(self) -> None:
|
|
71
|
+
"""Stop watching for file changes."""
|
|
72
|
+
if self._task is None:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
self._stop_event.set()
|
|
76
|
+
self._task.cancel()
|
|
77
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
78
|
+
await self._task
|
|
79
|
+
self._task = None
|
|
80
|
+
|
|
81
|
+
async def _watch_loop(self) -> None:
|
|
82
|
+
"""Internal watch loop."""
|
|
83
|
+
str_paths = [str(p) for p in self.paths]
|
|
84
|
+
|
|
85
|
+
# Filter to only existing paths
|
|
86
|
+
existing_paths = [p for p in str_paths if Path(p).exists()]
|
|
87
|
+
if not existing_paths:
|
|
88
|
+
logger.warning("FileWatcher: no existing paths to watch from %s", str_paths)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
logger.info("FileWatcher: starting watch loop for %s", existing_paths)
|
|
92
|
+
try:
|
|
93
|
+
async for changes in awatch(
|
|
94
|
+
*existing_paths,
|
|
95
|
+
debounce=self.debounce,
|
|
96
|
+
stop_event=self._stop_event,
|
|
97
|
+
):
|
|
98
|
+
logger.info("FileWatcher detected changes: %s", changes)
|
|
99
|
+
# Don't let callback errors kill the watcher
|
|
100
|
+
try:
|
|
101
|
+
await self.callback(changes)
|
|
102
|
+
except Exception:
|
|
103
|
+
logger.exception("Error in file watcher callback")
|
|
104
|
+
except Exception:
|
|
105
|
+
logger.exception("FileWatcher watch loop failed")
|
|
106
|
+
|
|
107
|
+
async def __aenter__(self) -> Self:
|
|
108
|
+
"""Start watcher on context enter."""
|
|
109
|
+
await self.start()
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
async def __aexit__(self, *args: object) -> None:
|
|
113
|
+
"""Stop watcher on context exit."""
|
|
114
|
+
await self.stop()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def get_git_branch(repo_path: str | Path) -> str | None:
|
|
118
|
+
"""Get the current git branch name.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
repo_path: Path to the git repository
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Branch name or None if not a git repo or on detached HEAD
|
|
125
|
+
|
|
126
|
+
TODO: For remote/ACP support, this should accept an optional ExecutionEnvironment
|
|
127
|
+
and use env.execute_command() instead of subprocess. This would allow git commands
|
|
128
|
+
to run on the client side where the repository lives.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
proc = await asyncio.create_subprocess_exec(
|
|
132
|
+
"git",
|
|
133
|
+
"rev-parse",
|
|
134
|
+
"--abbrev-ref",
|
|
135
|
+
"HEAD",
|
|
136
|
+
cwd=str(repo_path),
|
|
137
|
+
stdout=asyncio.subprocess.PIPE,
|
|
138
|
+
stderr=asyncio.subprocess.PIPE,
|
|
139
|
+
)
|
|
140
|
+
stdout, _ = await proc.communicate()
|
|
141
|
+
if proc.returncode != 0:
|
|
142
|
+
return None
|
|
143
|
+
except OSError:
|
|
144
|
+
return None
|
|
145
|
+
else:
|
|
146
|
+
branch = stdout.decode().strip()
|
|
147
|
+
return branch if branch != "HEAD" else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class GitBranchWatcher:
|
|
152
|
+
"""Watches for git branch changes using polling.
|
|
153
|
+
|
|
154
|
+
Polls the current git branch periodically and calls a callback when it changes.
|
|
155
|
+
Uses polling instead of file watching because git uses atomic renames which
|
|
156
|
+
are not reliably detected by inotify/watchfiles.
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
```python
|
|
160
|
+
async def on_branch_change(branch: str | None):
|
|
161
|
+
print(f"Branch changed to: {branch}")
|
|
162
|
+
|
|
163
|
+
watcher = GitBranchWatcher(
|
|
164
|
+
repo_path="/path/to/repo",
|
|
165
|
+
callback=on_branch_change,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async with watcher:
|
|
169
|
+
await asyncio.sleep(60)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
TODO: For remote/ACP support, this should accept an ExecutionEnvironment
|
|
173
|
+
and run git commands through env.execute_command(). The polling would still
|
|
174
|
+
happen server-side, but the git commands would execute on the client.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
repo_path: str | Path
|
|
178
|
+
"""Path to the git repository."""
|
|
179
|
+
|
|
180
|
+
callback: Callable[[str | None], Awaitable[None]]
|
|
181
|
+
"""Async callback invoked with new branch name when branch changes."""
|
|
182
|
+
|
|
183
|
+
poll_interval: float = 1.0
|
|
184
|
+
"""Polling interval in seconds."""
|
|
185
|
+
|
|
186
|
+
_current_branch: str | None = field(default=None, repr=False)
|
|
187
|
+
"""Cached current branch."""
|
|
188
|
+
|
|
189
|
+
_task: asyncio.Task[None] | None = field(default=None, repr=False)
|
|
190
|
+
"""Background polling task."""
|
|
191
|
+
|
|
192
|
+
_stop_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
|
|
193
|
+
"""Event to signal stop."""
|
|
194
|
+
|
|
195
|
+
async def start(self) -> None:
|
|
196
|
+
"""Start watching for branch changes."""
|
|
197
|
+
if self._task is not None:
|
|
198
|
+
return # Already running
|
|
199
|
+
|
|
200
|
+
repo = Path(self.repo_path)
|
|
201
|
+
git_dir = repo / ".git"
|
|
202
|
+
|
|
203
|
+
# Handle git worktrees - .git might be a file pointing to the real git dir
|
|
204
|
+
if git_dir.is_file():
|
|
205
|
+
content = git_dir.read_text().strip()
|
|
206
|
+
if content.startswith("gitdir:"):
|
|
207
|
+
git_dir = Path(content[7:].strip())
|
|
208
|
+
|
|
209
|
+
if not git_dir.exists():
|
|
210
|
+
logger.warning("Git directory not found: %s", git_dir)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
# Get initial branch
|
|
214
|
+
self._current_branch = await get_git_branch(self.repo_path)
|
|
215
|
+
logger.info(
|
|
216
|
+
"GitBranchWatcher started (polling), repo: %s, initial branch: %s",
|
|
217
|
+
self.repo_path,
|
|
218
|
+
self._current_branch,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
self._stop_event.clear()
|
|
222
|
+
self._task = asyncio.create_task(self._poll_loop())
|
|
223
|
+
|
|
224
|
+
async def _poll_loop(self) -> None:
|
|
225
|
+
"""Internal polling loop."""
|
|
226
|
+
while not self._stop_event.is_set():
|
|
227
|
+
try:
|
|
228
|
+
await asyncio.wait_for(
|
|
229
|
+
self._stop_event.wait(),
|
|
230
|
+
timeout=self.poll_interval,
|
|
231
|
+
)
|
|
232
|
+
break # Stop event was set
|
|
233
|
+
except TimeoutError:
|
|
234
|
+
# Poll interval elapsed, check for changes
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
new_branch = await get_git_branch(self.repo_path)
|
|
239
|
+
if new_branch != self._current_branch:
|
|
240
|
+
logger.info("Branch changed: %s -> %s", self._current_branch, new_branch)
|
|
241
|
+
self._current_branch = new_branch
|
|
242
|
+
await self.callback(new_branch)
|
|
243
|
+
except Exception:
|
|
244
|
+
logger.exception("Error polling git branch")
|
|
245
|
+
|
|
246
|
+
async def stop(self) -> None:
|
|
247
|
+
"""Stop watching for branch changes."""
|
|
248
|
+
if self._task is None:
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
self._stop_event.set()
|
|
252
|
+
self._task.cancel()
|
|
253
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
254
|
+
await self._task
|
|
255
|
+
self._task = None
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def current_branch(self) -> str | None:
|
|
259
|
+
"""Get the current cached branch name."""
|
|
260
|
+
return self._current_branch
|
|
261
|
+
|
|
262
|
+
async def __aenter__(self) -> Self:
|
|
263
|
+
"""Start watcher on context enter."""
|
|
264
|
+
await self.start()
|
|
265
|
+
return self
|
|
266
|
+
|
|
267
|
+
async def __aexit__(self, *args: object) -> None:
|
|
268
|
+
"""Stop watcher on context exit."""
|
|
269
|
+
await self.stop()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Identifier generation utilities.
|
|
2
|
+
|
|
3
|
+
Generates IDs that are lexicographically sortable by creation time.
|
|
4
|
+
Format: {prefix}_{hex_timestamp}{random_base62}
|
|
5
|
+
|
|
6
|
+
Compatible with OpenCode's identifier format.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import secrets
|
|
12
|
+
import time
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
PrefixType = Literal["session", "message", "permission", "user", "part", "pty"]
|
|
17
|
+
|
|
18
|
+
PREFIXES: dict[PrefixType, str] = {
|
|
19
|
+
"session": "ses",
|
|
20
|
+
"message": "msg",
|
|
21
|
+
"permission": "per",
|
|
22
|
+
"user": "usr",
|
|
23
|
+
"part": "prt",
|
|
24
|
+
"pty": "pty",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
28
|
+
ID_LENGTH = 26 # Characters after prefix (12 hex + 14 base62)
|
|
29
|
+
|
|
30
|
+
# State for monotonic ID generation
|
|
31
|
+
_last_timestamp = 0
|
|
32
|
+
_counter = 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _random_base62(length: int) -> str:
|
|
36
|
+
"""Generate random base62 string."""
|
|
37
|
+
return "".join(secrets.choice(BASE62_CHARS) for _ in range(length))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ascending(prefix: PrefixType, given: str | None = None) -> str:
|
|
41
|
+
"""Generate an ascending (chronologically sortable) ID.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
prefix: The type prefix for the ID
|
|
45
|
+
given: If provided, validate and return this ID instead of generating
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A sortable ID with the format {prefix}_{hex_timestamp}{random}
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If given ID doesn't start with expected prefix
|
|
52
|
+
"""
|
|
53
|
+
if given is not None:
|
|
54
|
+
expected_prefix = PREFIXES[prefix]
|
|
55
|
+
if not given.startswith(expected_prefix):
|
|
56
|
+
msg = f"ID {given} does not start with {expected_prefix}"
|
|
57
|
+
raise ValueError(msg)
|
|
58
|
+
return given
|
|
59
|
+
|
|
60
|
+
return _create(prefix, descending=False)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def descending(prefix: PrefixType) -> str:
|
|
64
|
+
"""Generate a descending (reverse chronologically sortable) ID.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
prefix: The type prefix for the ID
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
A reverse-sortable ID
|
|
71
|
+
"""
|
|
72
|
+
return _create(prefix, descending=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _create(prefix: PrefixType, *, descending: bool = False) -> str:
|
|
76
|
+
"""Create a new ID with timestamp encoding.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
prefix: The type prefix
|
|
80
|
+
descending: If True, invert the timestamp for reverse sorting
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A new ID string
|
|
84
|
+
"""
|
|
85
|
+
global _last_timestamp, _counter # noqa: PLW0603
|
|
86
|
+
|
|
87
|
+
current_timestamp = int(time.time() * 1000) # milliseconds
|
|
88
|
+
|
|
89
|
+
if current_timestamp != _last_timestamp:
|
|
90
|
+
_last_timestamp = current_timestamp
|
|
91
|
+
_counter = 0
|
|
92
|
+
_counter += 1
|
|
93
|
+
|
|
94
|
+
# Combine timestamp and counter
|
|
95
|
+
now = current_timestamp * 0x1000 + _counter
|
|
96
|
+
|
|
97
|
+
if descending:
|
|
98
|
+
now = ~now & 0xFFFFFFFFFFFF # Invert for descending order (48 bits)
|
|
99
|
+
|
|
100
|
+
# Encode as 6 bytes (48 bits), big-endian
|
|
101
|
+
time_bytes = bytearray(6)
|
|
102
|
+
for i in range(6):
|
|
103
|
+
time_bytes[i] = (now >> (40 - 8 * i)) & 0xFF
|
|
104
|
+
|
|
105
|
+
time_hex = time_bytes.hex()
|
|
106
|
+
|
|
107
|
+
# Add random suffix (14 chars for 26 total after prefix)
|
|
108
|
+
random_suffix = _random_base62(ID_LENGTH - 12)
|
|
109
|
+
|
|
110
|
+
return f"{PREFIXES[prefix]}_{time_hex}{random_suffix}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def generate_session_id() -> str:
|
|
114
|
+
"""Generate a unique, chronologically sortable session ID.
|
|
115
|
+
|
|
116
|
+
Convenience function for the common case.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A session ID like 'ses_b71310fdf001ZHcn6VSpkaBcHi'
|
|
120
|
+
"""
|
|
121
|
+
return ascending("session")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Helper utilities for working with pydantic-ai message types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from pydantic_ai.messages import BaseToolCallPart
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pydantic_ai.messages import ToolCallPartDelta
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def safe_args_as_dict(
|
|
15
|
+
part: BaseToolCallPart | ToolCallPartDelta,
|
|
16
|
+
*,
|
|
17
|
+
default: dict[str, Any] | None = None,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
"""Safely extract args as dict from a tool call part.
|
|
20
|
+
|
|
21
|
+
Models can return malformed JSON for tool arguments, especially during
|
|
22
|
+
streaming when args are still being assembled. This helper catches parse
|
|
23
|
+
errors and returns a fallback value.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
part: A tool call part (complete or delta) with args to extract
|
|
27
|
+
default: Value to return on parse failure. If None, returns {"_raw_args": ...}
|
|
28
|
+
with the original unparsed args.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The parsed arguments dict, or a fallback on parse failure.
|
|
32
|
+
"""
|
|
33
|
+
if not isinstance(part, BaseToolCallPart):
|
|
34
|
+
# ToolCallPartDelta doesn't have args_as_dict
|
|
35
|
+
if default is not None:
|
|
36
|
+
return default
|
|
37
|
+
raw = getattr(part, "args", None)
|
|
38
|
+
return {"_raw_args": raw} if raw else {}
|
|
39
|
+
try:
|
|
40
|
+
return part.args_as_dict()
|
|
41
|
+
except ValueError:
|
|
42
|
+
# Model returned malformed JSON for tool args
|
|
43
|
+
if default is not None:
|
|
44
|
+
return default
|
|
45
|
+
# Preserve raw args for debugging/inspection
|
|
46
|
+
return {"_raw_args": part.args} if part.args else {}
|