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
@@ -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 {}