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
@@ -24,12 +24,12 @@ from __future__ import annotations
24
24
 
25
25
  import asyncio
26
26
  from decimal import Decimal
27
- from typing import TYPE_CHECKING, Any, Self, cast
27
+ from typing import TYPE_CHECKING, Any, Literal, Self
28
28
  import uuid
29
29
 
30
30
  import anyio
31
- from pydantic import TypeAdapter
32
31
  from pydantic_ai import (
32
+ FunctionToolResultEvent,
33
33
  ModelRequest,
34
34
  ModelResponse,
35
35
  PartDeltaEvent,
@@ -41,9 +41,11 @@ from pydantic_ai import (
41
41
  ThinkingPart,
42
42
  ThinkingPartDelta,
43
43
  ToolCallPart,
44
+ ToolCallPartDelta,
44
45
  ToolReturnPart,
45
46
  UserPromptPart,
46
47
  )
48
+ from pydantic_ai.usage import RequestUsage
47
49
 
48
50
  from agentpool.agents.base_agent import BaseAgent
49
51
  from agentpool.agents.claude_code_agent.converters import claude_message_to_events
@@ -54,12 +56,13 @@ from agentpool.agents.events import (
54
56
  ToolCallCompleteEvent,
55
57
  ToolCallStartEvent,
56
58
  )
59
+ from agentpool.agents.modes import ModeInfo
57
60
  from agentpool.log import get_logger
58
61
  from agentpool.messaging import ChatMessage
59
62
  from agentpool.messaging.messages import TokenCost
60
63
  from agentpool.messaging.processing import prepare_prompts
61
64
  from agentpool.models.claude_code_agents import ClaudeCodeAgentConfig
62
- from agentpool.utils.streams import merge_queue_into_iterator
65
+ from agentpool.utils.streams import FileTracker, merge_queue_into_iterator
63
66
 
64
67
 
65
68
  if TYPE_CHECKING:
@@ -75,21 +78,25 @@ if TYPE_CHECKING:
75
78
  ToolPermissionContext,
76
79
  ToolUseBlock,
77
80
  )
81
+ from claude_agent_sdk.types import HookContext, HookInput, SyncHookJSONOutput
78
82
  from evented.configs import EventConfig
79
83
  from exxec import ExecutionEnvironment
84
+ from slashed import BaseCommand, Command, CommandContext
85
+ from tokonomics.model_discovery.model_info import ModelInfo
80
86
  from toprompt import AnyPromptType
81
87
 
82
88
  from agentpool.agents.context import AgentContext
83
89
  from agentpool.agents.events import RichAgentStreamEvent
90
+ from agentpool.agents.modes import ModeCategory
84
91
  from agentpool.common_types import (
85
92
  BuiltinEventHandlerType,
86
93
  IndividualEventHandler,
94
+ MCPServerStatus,
87
95
  PromptCompatible,
88
96
  )
89
97
  from agentpool.delegation import AgentPool
90
98
  from agentpool.mcp_server.tool_bridge import ToolManagerBridge
91
99
  from agentpool.messaging import MessageHistory
92
- from agentpool.talk.stats import MessageStats
93
100
  from agentpool.ui.base import InputProvider
94
101
  from agentpool_config.mcp_server import MCPServerConfig
95
102
  from agentpool_config.nodes import ToolConfirmationMode
@@ -97,6 +104,16 @@ if TYPE_CHECKING:
97
104
 
98
105
  logger = get_logger(__name__)
99
106
 
107
+ # Prefix to strip from tool names for cleaner UI display
108
+ _MCP_TOOL_PREFIX = "mcp__agentpool-claude-tools__"
109
+
110
+
111
+ def _strip_mcp_prefix(tool_name: str) -> str:
112
+ """Strip MCP server prefix from tool names for cleaner UI display."""
113
+ if tool_name.startswith(_MCP_TOOL_PREFIX):
114
+ return tool_name[len(_MCP_TOOL_PREFIX) :]
115
+ return tool_name
116
+
100
117
 
101
118
  class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
102
119
  """Agent wrapping Claude Agent SDK's ClaudeSDKClient.
@@ -126,6 +143,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
126
143
  include_builtin_system_prompt: bool = True,
127
144
  model: str | None = None,
128
145
  max_turns: int | None = None,
146
+ max_budget_usd: float | None = None,
129
147
  max_thinking_tokens: int | None = None,
130
148
  permission_mode: PermissionMode | None = None,
131
149
  mcp_servers: Sequence[MCPServerConfig] | None = None,
@@ -142,6 +160,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
142
160
  event_handlers: Sequence[IndividualEventHandler | BuiltinEventHandlerType] | None = None,
143
161
  tool_confirmation_mode: ToolConfirmationMode = "always",
144
162
  output_type: type[TResult] | None = None,
163
+ commands: Sequence[BaseCommand] | None = None,
145
164
  ) -> None:
146
165
  """Initialize ClaudeCodeAgent.
147
166
 
@@ -157,6 +176,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
157
176
  include_builtin_system_prompt: If True, the builtin system prompt is included.
158
177
  model: Model to use (e.g., "claude-sonnet-4-5")
159
178
  max_turns: Maximum conversation turns
179
+ max_budget_usd: Maximum budget to consume in dollars
160
180
  max_thinking_tokens: Max tokens for extended thinking
161
181
  permission_mode: Permission mode ("default", "acceptEdits", "plan", "bypassPermissions")
162
182
  mcp_servers: External MCP servers to connect to (internal format, converted at runtime)
@@ -173,6 +193,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
173
193
  event_handlers: Event handlers for streaming events
174
194
  tool_confirmation_mode: Tool confirmation behavior
175
195
  output_type: Type for structured output (uses JSON schema)
196
+ commands: Slash commands
176
197
  """
177
198
  from agentpool.agents.sys_prompts import SystemPrompts
178
199
 
@@ -211,6 +232,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
211
232
  output_type=output_type or str, # type: ignore[arg-type]
212
233
  tool_confirmation_mode=tool_confirmation_mode,
213
234
  event_handlers=event_handlers,
235
+ commands=commands,
214
236
  )
215
237
 
216
238
  self._config = config
@@ -234,6 +256,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
234
256
  self.sys_prompts = SystemPrompts(all_prompts, prompt_manager=prompt_manager)
235
257
  self._model = model or config.model
236
258
  self._max_turns = max_turns or config.max_turns
259
+ self._max_budget_usd = max_budget_usd or config.max_budget_usd
237
260
  self._max_thinking_tokens = max_thinking_tokens or config.max_thinking_tokens
238
261
  self._permission_mode: PermissionMode | None = permission_mode or config.permission_mode
239
262
  self._external_mcp_servers = list(mcp_servers) if mcp_servers else config.get_mcp_servers()
@@ -255,6 +278,10 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
255
278
  self._owns_bridge = False # Track if we created the bridge (for cleanup)
256
279
  self._mcp_servers: dict[str, McpServerConfig] = {} # Claude SDK MCP server configs
257
280
 
281
+ # Track pending tool call for permission matching
282
+ # Maps tool_name to tool_call_id for matching permissions to tool call UI parts
283
+ self._pending_tool_call_ids: dict[str, str] = {}
284
+
258
285
  def get_context(self, data: Any = None) -> AgentContext:
259
286
  """Create a new context for this agent.
260
287
 
@@ -272,57 +299,6 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
272
299
  node=self, pool=self.agent_pool, config=self._config, definition=defn, data=data
273
300
  )
274
301
 
275
- def _convert_mcp_servers_to_sdk_format(self) -> dict[str, McpServerConfig]:
276
- """Convert internal MCPServerConfig to Claude SDK format.
277
-
278
- Returns:
279
- Dict mapping server names to SDK-compatible config dicts
280
- """
281
- from claude_agent_sdk import McpServerConfig
282
-
283
- from agentpool_config.mcp_server import (
284
- SSEMCPServerConfig,
285
- StdioMCPServerConfig,
286
- StreamableHTTPMCPServerConfig,
287
- )
288
-
289
- result: dict[str, McpServerConfig] = {}
290
-
291
- for idx, server in enumerate(self._external_mcp_servers):
292
- # Determine server name
293
- if server.name:
294
- name = server.name
295
- elif isinstance(server, StdioMCPServerConfig) and server.args:
296
- name = server.args[-1].split("/")[-1].split("@")[0]
297
- elif isinstance(server, StdioMCPServerConfig):
298
- name = server.command
299
- elif isinstance(server, SSEMCPServerConfig | StreamableHTTPMCPServerConfig):
300
- from urllib.parse import urlparse
301
-
302
- name = urlparse(str(server.url)).hostname or f"server_{idx}"
303
- else:
304
- name = f"server_{idx}"
305
-
306
- # Build SDK-compatible config
307
- config: dict[str, Any]
308
- match server:
309
- case StdioMCPServerConfig(command=command, args=args):
310
- config = {"type": "stdio", "command": command, "args": args}
311
- if server.env:
312
- config["env"] = server.get_env_vars()
313
- case SSEMCPServerConfig(url=url):
314
- config = {"type": "sse", "url": str(url)}
315
- if server.headers:
316
- config["headers"] = server.headers
317
- case StreamableHTTPMCPServerConfig(url=url):
318
- config = {"type": "http", "url": str(url)}
319
- if server.headers:
320
- config["headers"] = server.headers
321
-
322
- result[name] = cast(McpServerConfig, config)
323
-
324
- return result
325
-
326
302
  async def _setup_toolsets(self) -> None:
327
303
  """Initialize toolsets from config and create bridge if needed.
328
304
 
@@ -330,11 +306,12 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
330
306
  and starts an MCP bridge to expose them to Claude Code via the SDK's
331
307
  native MCP support. Also converts external MCP servers to SDK format.
332
308
  """
309
+ from agentpool.agents.claude_code_agent.converters import convert_mcp_servers_to_sdk_format
333
310
  from agentpool.mcp_server.tool_bridge import BridgeConfig, ToolManagerBridge
334
311
 
335
312
  # Convert external MCP servers to SDK format first
336
313
  if self._external_mcp_servers:
337
- external_configs = self._convert_mcp_servers_to_sdk_format()
314
+ external_configs = convert_mcp_servers_to_sdk_format(self._external_mcp_servers)
338
315
  self._mcp_servers.update(external_configs)
339
316
  self.log.info("External MCP servers configured", server_count=len(external_configs))
340
317
 
@@ -346,14 +323,11 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
346
323
  provider = toolset_config.get_provider()
347
324
  self.tools.add_provider(provider)
348
325
 
349
- # Auto-create bridge to expose tools via MCP
350
- config = BridgeConfig(
351
- transport="streamable-http", server_name=f"agentpool-{self.name}-tools"
352
- )
326
+ server_name = f"agentpool-{self.name}-tools"
327
+ config = BridgeConfig(transport="streamable-http", server_name=server_name)
353
328
  self._tool_bridge = ToolManagerBridge(node=self, config=config)
354
329
  await self._tool_bridge.start()
355
330
  self._owns_bridge = True
356
-
357
331
  # Get Claude SDK-compatible MCP config and merge into our servers dict
358
332
  mcp_config = self._tool_bridge.get_claude_mcp_server_config()
359
333
  self._mcp_servers.update(mcp_config)
@@ -372,7 +346,6 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
372
346
  """
373
347
  if self._tool_bridge is None: # Don't replace our own bridge
374
348
  self._tool_bridge = bridge
375
-
376
349
  # Get Claude SDK-compatible config and merge
377
350
  mcp_config = bridge.get_claude_mcp_server_config()
378
351
  self._mcp_servers.update(mcp_config)
@@ -391,6 +364,61 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
391
364
  """Get the model name."""
392
365
  return self._current_model
393
366
 
367
+ def get_mcp_server_info(self) -> dict[str, MCPServerStatus]:
368
+ """Get information about configured MCP servers.
369
+
370
+ Returns a dict mapping server names to their status info. This is used
371
+ by the OpenCode /mcp endpoint to display MCP servers in the sidebar.
372
+
373
+ Returns:
374
+ Dict mapping server name to MCPServerStatus dataclass
375
+ """
376
+ from agentpool.common_types import MCPServerStatus
377
+
378
+ result: dict[str, MCPServerStatus] = {}
379
+ for name, config in self._mcp_servers.items():
380
+ server_type = config.get("type", "unknown")
381
+ result[name] = MCPServerStatus(
382
+ name=name,
383
+ status="connected", # Claude SDK manages connections
384
+ server_type=server_type,
385
+ )
386
+ return result
387
+
388
+ def _build_hooks(self) -> dict[str, list[Any]]:
389
+ """Build SDK hooks configuration.
390
+
391
+ Returns:
392
+ Dictionary mapping hook event names to HookMatcher lists
393
+ """
394
+ from claude_agent_sdk.types import HookMatcher
395
+
396
+ async def on_pre_compact(
397
+ input_data: HookInput,
398
+ tool_use_id: str | None,
399
+ context: HookContext,
400
+ ) -> SyncHookJSONOutput:
401
+ """Handle PreCompact hook by emitting a CompactionEvent."""
402
+ from agentpool.agents.events import CompactionEvent
403
+
404
+ # input_data is PreCompactHookInput when hook_event_name == "PreCompact"
405
+ trigger_value = input_data.get("trigger", "auto")
406
+ trigger: Literal["auto", "manual"] = "manual" if trigger_value == "manual" else "auto"
407
+
408
+ # Emit semantic CompactionEvent - consumers handle display differently
409
+ compaction_event = CompactionEvent(
410
+ session_id=self.conversation_id,
411
+ trigger=trigger,
412
+ phase="starting",
413
+ )
414
+ await self._event_queue.put(compaction_event)
415
+
416
+ return {"continue_": True}
417
+
418
+ return {
419
+ "PreCompact": [HookMatcher(matcher=None, hooks=[on_pre_compact])],
420
+ }
421
+
394
422
  def _build_options(self, *, formatted_system_prompt: str | None = None) -> ClaudeAgentOptions:
395
423
  """Build ClaudeAgentOptions from runtime state.
396
424
 
@@ -400,6 +428,8 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
400
428
  from claude_agent_sdk import ClaudeAgentOptions
401
429
  from claude_agent_sdk.types import SystemPromptPreset
402
430
 
431
+ from agentpool.agents.claude_code_agent.converters import to_output_format
432
+
403
433
  # Build system prompt value
404
434
  system_prompt: str | SystemPromptPreset | None = None
405
435
  if formatted_system_prompt:
@@ -424,13 +454,6 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
424
454
  self._can_use_tool if self.tool_confirmation_mode != "never" and not bypass else None
425
455
  )
426
456
 
427
- # Build structured output format if needed
428
- output_format: dict[str, Any] | None = None
429
- if self._output_type is not str:
430
- adapter = TypeAdapter(self._output_type)
431
- schema = adapter.json_schema()
432
- output_format = {"type": "json_schema", "schema": schema}
433
-
434
457
  return ClaudeAgentOptions(
435
458
  cwd=self._cwd,
436
459
  allowed_tools=self._allowed_tools or [],
@@ -438,6 +461,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
438
461
  system_prompt=system_prompt,
439
462
  model=self._model,
440
463
  max_turns=self._max_turns,
464
+ max_budget_usd=self._max_budget_usd,
441
465
  max_thinking_tokens=self._max_thinking_tokens,
442
466
  permission_mode=permission_mode,
443
467
  env=self._environment or {},
@@ -445,9 +469,10 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
445
469
  tools=self._builtin_tools,
446
470
  fallback_model=self._fallback_model,
447
471
  can_use_tool=can_use_tool,
448
- output_format=output_format,
472
+ output_format=to_output_format(self._output_type),
449
473
  mcp_servers=self._mcp_servers or {},
450
474
  include_partial_messages=True,
475
+ hooks=self._build_hooks(), # type: ignore[arg-type]
451
476
  )
452
477
 
453
478
  async def _can_use_tool( # noqa: PLR0911
@@ -481,13 +506,26 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
481
506
  if tool_name.startswith(bridge_prefix):
482
507
  return PermissionResultAllow()
483
508
 
509
+ # Auto-grant tools from configured external MCP servers
510
+ # These are explicitly configured by the user, so they should be trusted
511
+ # Tool names are like: mcp__{server_name}__{tool_name}
512
+ if tool_name.startswith("mcp__") and self._mcp_servers:
513
+ for server_name in self._mcp_servers:
514
+ if tool_name.startswith(f"mcp__{server_name}__"):
515
+ return PermissionResultAllow()
516
+
484
517
  # Use input provider if available
485
518
  if self._input_provider:
486
519
  # Create a dummy Tool for the confirmation dialog
487
520
  desc = f"Claude Code tool: {tool_name}"
488
521
  tool = Tool(callable=lambda: None, name=tool_name, description=desc)
522
+ # Get the tool call ID from our tracking dict (set from streaming events)
523
+ tool_call_id = self._pending_tool_call_ids.get(tool_name)
524
+ ctx = self.get_context()
525
+ # Attach tool_call_id to context for permission event
526
+ ctx.tool_call_id = tool_call_id
489
527
  result = await self._input_provider.get_tool_confirmation(
490
- context=self.get_context(),
528
+ context=ctx,
491
529
  tool=tool,
492
530
  args=input_data,
493
531
  )
@@ -536,6 +574,122 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
536
574
  self._client = None
537
575
  await super().__aexit__(exc_type, exc_val, exc_tb)
538
576
 
577
+ async def populate_commands(self) -> None:
578
+ """Populate the command store with slash commands from Claude Code.
579
+
580
+ Fetches available commands from the connected Claude Code server
581
+ and registers them as slashed Commands. Should be called after
582
+ connection is established.
583
+
584
+ Commands that are not supported or not useful for external use
585
+ are filtered out (e.g., login, logout, context, cost).
586
+ """
587
+ if not self._client:
588
+ self.log.warning("Cannot populate commands: not connected")
589
+ return
590
+
591
+ server_info = await self._client.get_server_info()
592
+ if not server_info:
593
+ self.log.warning("No server info available for command population")
594
+ return
595
+
596
+ commands = server_info.get("commands", [])
597
+ if not commands:
598
+ self.log.debug("No commands available from Claude Code server")
599
+ return
600
+
601
+ # Commands to skip - not useful or problematic in this context
602
+ unsupported = {"context", "cost", "login", "logout", "release-notes", "todos"}
603
+
604
+ for cmd_info in commands:
605
+ name = cmd_info.get("name", "")
606
+ if not name or name in unsupported:
607
+ continue
608
+
609
+ command = self._create_claude_code_command(cmd_info)
610
+ self._command_store.register_command(command)
611
+
612
+ self.log.info(
613
+ "Populated command store", command_count=len(self._command_store.list_commands())
614
+ )
615
+
616
+ def _create_claude_code_command(self, cmd_info: dict[str, Any]) -> Command:
617
+ """Create a slashed Command from Claude Code command info.
618
+
619
+ Args:
620
+ cmd_info: Command info dict with 'name', 'description', 'argumentHint'
621
+
622
+ Returns:
623
+ A slashed Command that executes via Claude Code
624
+ """
625
+ from slashed import Command
626
+
627
+ name = cmd_info.get("name", "")
628
+ description = cmd_info.get("description", "")
629
+ argument_hint = cmd_info.get("argumentHint")
630
+
631
+ # Handle MCP commands - they have " (MCP)" suffix in Claude Code
632
+ category = "claude_code"
633
+ if name.endswith(" (MCP)"):
634
+ name = f"mcp:{name.replace(' (MCP)', '')}"
635
+ category = "mcp"
636
+
637
+ async def execute_command(
638
+ ctx: CommandContext[Any],
639
+ args: list[str],
640
+ kwargs: dict[str, str],
641
+ ) -> None:
642
+ """Execute the Claude Code slash command."""
643
+ import re
644
+
645
+ from claude_agent_sdk.types import (
646
+ AssistantMessage,
647
+ ResultMessage,
648
+ TextBlock,
649
+ UserMessage,
650
+ )
651
+
652
+ # Build command string
653
+ args_str = " ".join(args) if args else ""
654
+ if kwargs:
655
+ kwargs_str = " ".join(f"{k}={v}" for k, v in kwargs.items())
656
+ args_str = f"{args_str} {kwargs_str}".strip()
657
+
658
+ full_command = f"/{name} {args_str}".strip()
659
+
660
+ # Execute via agent run - slash commands go through as prompts
661
+ if self._client:
662
+ await self._client.query(full_command)
663
+ async for msg in self._client.receive_response():
664
+ if isinstance(msg, AssistantMessage):
665
+ for block in msg.content:
666
+ if isinstance(block, TextBlock):
667
+ await ctx.print(block.text)
668
+ elif isinstance(msg, UserMessage):
669
+ # Handle local command output wrapped in XML tags
670
+ content = msg.content if isinstance(msg.content, str) else ""
671
+ # Extract content from <local-command-stdout> or <local-command-stderr>
672
+ match = re.search(
673
+ r"<local-command-(?:stdout|stderr)>(.*?)</local-command-(?:stdout|stderr)>",
674
+ content,
675
+ re.DOTALL,
676
+ )
677
+ if match:
678
+ await ctx.print(match.group(1))
679
+ elif isinstance(msg, ResultMessage):
680
+ if msg.result:
681
+ await ctx.print(msg.result)
682
+ if msg.is_error:
683
+ await ctx.print(f"Error: {msg.subtype}")
684
+
685
+ return Command.from_raw(
686
+ execute_command,
687
+ name=name,
688
+ description=description or f"Claude Code command: {name}",
689
+ category=category,
690
+ usage=argument_hint,
691
+ )
692
+
539
693
  async def run(
540
694
  self,
541
695
  *prompts: PromptCompatible,
@@ -576,6 +730,8 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
576
730
  message_id: str | None = None,
577
731
  input_provider: InputProvider | None = None,
578
732
  message_history: MessageHistory | None = None,
733
+ deps: TDeps | None = None,
734
+ event_handlers: Sequence[IndividualEventHandler | BuiltinEventHandlerType] | None = None,
579
735
  ) -> AsyncIterator[RichAgentStreamEvent[TResult]]:
580
736
  """Stream events from Claude Code execution.
581
737
 
@@ -584,6 +740,8 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
584
740
  message_id: Optional message ID for the final message
585
741
  input_provider: Optional input provider for permission requests
586
742
  message_history: Optional MessageHistory to use instead of agent's own
743
+ deps: Optional dependencies accessible via ctx.data in tools
744
+ event_handlers: Optional event handlers for this run (overrides agent's handlers)
587
745
 
588
746
  Yields:
589
747
  RichAgentStreamEvent instances during execution
@@ -603,18 +761,30 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
603
761
  # Reset cancellation state
604
762
  self._cancelled = False
605
763
  self._current_stream_task = asyncio.current_task()
606
-
607
764
  # Update input provider if provided
608
765
  if input_provider is not None:
609
766
  self._input_provider = input_provider
610
-
611
767
  if not self._client:
612
- msg = "Agent not initialized - use async context manager"
613
- raise RuntimeError(msg)
768
+ raise RuntimeError("Agent not initialized - use async context manager")
614
769
 
615
770
  conversation = message_history if message_history is not None else self.conversation
771
+ # Use provided event handlers or fall back to agent's handlers
772
+ if event_handlers is not None:
773
+ from anyenv import MultiEventHandler
774
+
775
+ from agentpool.agents.events import resolve_event_handlers
776
+
777
+ handler: MultiEventHandler[IndividualEventHandler] = MultiEventHandler(
778
+ resolve_event_handlers(event_handlers)
779
+ )
780
+ else:
781
+ handler = self.event_handler
616
782
  # Prepare prompts
617
- user_msg, processed_prompts, _original_message = await prepare_prompts(*prompts)
783
+ # Get parent_id from last message in history for tree structure
784
+ last_msg_id = conversation.get_last_message_id()
785
+ user_msg, processed_prompts, _original_message = await prepare_prompts(
786
+ *prompts, parent_id=last_msg_id
787
+ )
618
788
  # Get pending parts from conversation (staged content)
619
789
  pending_parts = conversation.get_pending_parts()
620
790
  # Combine pending parts with new prompts, then join into single string for Claude SDK
@@ -627,15 +797,28 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
627
797
  run_id=run_id,
628
798
  agent_name=self.name,
629
799
  )
630
- for handler in self.event_handler._wrapped_handlers:
631
- await handler(None, run_started)
800
+ await handler(None, run_started)
632
801
  yield run_started
633
802
  request = ModelRequest(parts=[UserPromptPart(content=prompt_text)])
634
803
  model_messages: list[ModelResponse | ModelRequest] = [request]
635
804
  current_response_parts: list[TextPart | ThinkingPart | ToolCallPart] = []
636
805
  text_chunks: list[str] = []
637
806
  pending_tool_calls: dict[str, ToolUseBlock] = {}
807
+ # Track tool calls that already had ToolCallStartEvent emitted (via StreamEvent)
808
+ emitted_tool_starts: set[str] = set()
638
809
 
810
+ # Accumulator for streaming tool arguments
811
+ from agentpool.agents.tool_call_accumulator import ToolCallAccumulator
812
+
813
+ tool_accumulator = ToolCallAccumulator()
814
+
815
+ # Track files modified during this run
816
+ file_tracker = FileTracker()
817
+
818
+ # Set deps on tool bridge for access during tool invocations
819
+ # (ContextVar doesn't work because MCP server runs in a separate task)
820
+ if self._tool_bridge:
821
+ self._tool_bridge.current_deps = deps
639
822
  try:
640
823
  await self._client.query(prompt_text)
641
824
  # Merge SDK messages with event queue for real-time tool event streaming
@@ -646,8 +829,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
646
829
  # Check if it's a queued event (from tools via EventEmitter)
647
830
  if not isinstance(event_or_message, Message):
648
831
  # It's an event from the queue - yield it immediately
649
- for handler in self.event_handler._wrapped_handlers:
650
- await handler(None, event_or_message)
832
+ await handler(None, event_or_message)
651
833
  yield event_or_message
652
834
  continue
653
835
 
@@ -666,29 +848,60 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
666
848
  current_response_parts.append(ThinkingPart(content=thinking))
667
849
  case ToolUseBlockType(id=tc_id, name=name, input=input_data):
668
850
  pending_tool_calls[tc_id] = block
669
- current_response_parts.append(
670
- ToolCallPart(
671
- tool_name=name, args=input_data, tool_call_id=tc_id
672
- )
673
- )
674
- # Emit ToolCallStartEvent with rich display info
675
- from agentpool.agents.claude_code_agent.converters import (
676
- derive_rich_tool_info,
851
+ display_name = _strip_mcp_prefix(name)
852
+ tool_call_part = ToolCallPart(
853
+ tool_name=display_name, args=input_data, tool_call_id=tc_id
677
854
  )
855
+ current_response_parts.append(tool_call_part)
856
+
857
+ # Emit FunctionToolCallEvent (triggers UI notification)
858
+ # func_tool_event = FunctionToolCallEvent(part=tool_call_part)
859
+ # await handler(None, func_tool_event)
860
+ # yield func_tool_event
861
+
862
+ # Only emit ToolCallStartEvent if not already emitted
863
+ # via streaming (emits early with partial info)
864
+ if tc_id not in emitted_tool_starts:
865
+ from agentpool.agents.claude_code_agent.converters import (
866
+ derive_rich_tool_info,
867
+ )
678
868
 
679
- rich_info = derive_rich_tool_info(name, input_data)
680
- tool_start_event = ToolCallStartEvent(
681
- tool_call_id=tc_id,
682
- tool_name=name,
683
- title=rich_info.title,
684
- kind=rich_info.kind,
685
- locations=rich_info.locations,
686
- content=rich_info.content,
687
- raw_input=input_data,
688
- )
689
- for handler in self.event_handler._wrapped_handlers:
869
+ rich_info = derive_rich_tool_info(name, input_data)
870
+ tool_start_event = ToolCallStartEvent(
871
+ tool_call_id=tc_id,
872
+ tool_name=display_name,
873
+ title=rich_info.title,
874
+ kind=rich_info.kind,
875
+ locations=rich_info.locations,
876
+ content=rich_info.content,
877
+ raw_input=input_data,
878
+ )
879
+ # Track file modifications
880
+ file_tracker.process_event(tool_start_event)
690
881
  await handler(None, tool_start_event)
691
- yield tool_start_event
882
+ yield tool_start_event
883
+ else:
884
+ # Already emitted early - emit update with full args
885
+ from agentpool.agents.claude_code_agent.converters import (
886
+ derive_rich_tool_info,
887
+ )
888
+
889
+ rich_info = derive_rich_tool_info(name, input_data)
890
+ updated_event = ToolCallStartEvent(
891
+ tool_call_id=tc_id,
892
+ tool_name=display_name,
893
+ title=rich_info.title,
894
+ kind=rich_info.kind,
895
+ locations=rich_info.locations,
896
+ content=rich_info.content,
897
+ raw_input=input_data,
898
+ )
899
+ # Track file modifications using derived info
900
+ file_tracker.process_event(updated_event)
901
+ await handler(None, updated_event)
902
+ yield updated_event
903
+ # Clean up from accumulator
904
+ tool_accumulator.complete(tc_id)
692
905
  case ToolResultBlock(tool_use_id=tc_id, content=content):
693
906
  # Tool result received - flush response parts and add request
694
907
  if current_response_parts:
@@ -698,8 +911,24 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
698
911
 
699
912
  # Get tool name from pending calls
700
913
  tool_use = pending_tool_calls.pop(tc_id, None)
701
- tool_name = tool_use.name if tool_use else "unknown"
914
+ tool_name = _strip_mcp_prefix(
915
+ tool_use.name if tool_use else "unknown"
916
+ )
702
917
  tool_input = tool_use.input if tool_use else {}
918
+
919
+ # Create ToolReturnPart for the result
920
+ tool_return_part = ToolReturnPart(
921
+ tool_name=tool_name, content=content, tool_call_id=tc_id
922
+ )
923
+
924
+ # Emit FunctionToolResultEvent (for session.py to complete UI)
925
+ func_result_event = FunctionToolResultEvent(
926
+ result=tool_return_part
927
+ )
928
+ await handler(None, func_result_event)
929
+ yield func_result_event
930
+
931
+ # Also emit ToolCallCompleteEvent for consumers that expect it
703
932
  tool_done_event = ToolCallCompleteEvent(
704
933
  tool_name=tool_name,
705
934
  tool_call_id=tc_id,
@@ -708,15 +937,11 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
708
937
  agent_name=self.name,
709
938
  message_id="",
710
939
  )
711
- for handler in self.event_handler._wrapped_handlers:
712
- await handler(None, tool_done_event)
940
+ await handler(None, tool_done_event)
713
941
  yield tool_done_event
714
942
 
715
943
  # Add tool return as ModelRequest
716
- part = ToolReturnPart(
717
- tool_name=tool_name, content=content, tool_call_id=tc_id
718
- )
719
- model_messages.append(ModelRequest(parts=[part]))
944
+ model_messages.append(ModelRequest(parts=[tool_return_part]))
720
945
 
721
946
  # Process user messages - may contain tool results
722
947
  elif isinstance(message, UserMessage):
@@ -738,9 +963,24 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
738
963
 
739
964
  # Get tool name from pending calls
740
965
  tool_use = pending_tool_calls.pop(tc_id, None)
741
- tool_name = tool_use.name if tool_use else "unknown"
966
+ tool_name = _strip_mcp_prefix(
967
+ tool_use.name if tool_use else "unknown"
968
+ )
742
969
  tool_input = tool_use.input if tool_use else {}
743
- # Emit ToolCallCompleteEvent
970
+
971
+ # Create ToolReturnPart for the result
972
+ tool_return_part = ToolReturnPart(
973
+ tool_name=tool_name,
974
+ content=result_content,
975
+ tool_call_id=tc_id,
976
+ )
977
+
978
+ # Emit FunctionToolResultEvent (for session.py to complete UI)
979
+ func_result_event = FunctionToolResultEvent(result=tool_return_part)
980
+ await handler(None, func_result_event)
981
+ yield func_result_event
982
+
983
+ # Also emit ToolCallCompleteEvent for consumers that expect it
744
984
  tool_complete_event = ToolCallCompleteEvent(
745
985
  tool_name=tool_name,
746
986
  tool_call_id=tc_id,
@@ -749,16 +989,11 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
749
989
  agent_name=self.name,
750
990
  message_id="",
751
991
  )
752
- for handler in self.event_handler._wrapped_handlers:
753
- await handler(None, tool_complete_event)
992
+ await handler(None, tool_complete_event)
754
993
  yield tool_complete_event
994
+
755
995
  # Add tool return as ModelRequest
756
- part = ToolReturnPart(
757
- tool_name=tool_name,
758
- content=result_content,
759
- tool_call_id=tc_id,
760
- )
761
- model_messages.append(ModelRequest(parts=[part]))
996
+ model_messages.append(ModelRequest(parts=[tool_return_part]))
762
997
 
763
998
  # Handle StreamEvent for real-time streaming
764
999
  elif isinstance(message, StreamEvent):
@@ -773,20 +1008,43 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
773
1008
 
774
1009
  if block_type == "text":
775
1010
  start_event = PartStartEvent(index=index, part=TextPart(content=""))
776
- for handler in self.event_handler._wrapped_handlers:
777
- await handler(None, start_event)
1011
+ await handler(None, start_event)
778
1012
  yield start_event
779
1013
 
780
1014
  elif block_type == "thinking":
781
1015
  thinking_part = ThinkingPart(content="")
782
1016
  start_event = PartStartEvent(index=index, part=thinking_part)
783
- for handler in self.event_handler._wrapped_handlers:
784
- await handler(None, start_event)
1017
+ await handler(None, start_event)
785
1018
  yield start_event
786
1019
 
787
1020
  elif block_type == "tool_use":
788
- # Tool use start is handled via AssistantMessage ToolUseBlock
789
- pass
1021
+ # Emit ToolCallStartEvent early (args still streaming)
1022
+ tc_id = content_block.get("id", "")
1023
+ raw_tool_name = content_block.get("name", "")
1024
+ tool_name = _strip_mcp_prefix(raw_tool_name)
1025
+ tool_accumulator.start(tc_id, tool_name)
1026
+ # Track for permission matching - permission callback will use this
1027
+ # Use raw name since SDK uses raw names for permissions
1028
+ self._pending_tool_call_ids[raw_tool_name] = tc_id
1029
+
1030
+ # Derive rich info with empty args for now
1031
+ from agentpool.agents.claude_code_agent.converters import (
1032
+ derive_rich_tool_info,
1033
+ )
1034
+
1035
+ rich_info = derive_rich_tool_info(raw_tool_name, {})
1036
+ tool_start_event = ToolCallStartEvent(
1037
+ tool_call_id=tc_id,
1038
+ tool_name=tool_name,
1039
+ title=rich_info.title,
1040
+ kind=rich_info.kind,
1041
+ locations=[], # No locations yet, args not complete
1042
+ content=rich_info.content,
1043
+ raw_input={}, # Empty, will be filled when complete
1044
+ )
1045
+ emitted_tool_starts.add(tc_id)
1046
+ await handler(None, tool_start_event)
1047
+ yield tool_start_event
790
1048
 
791
1049
  # Handle content_block_delta events (text streaming)
792
1050
  elif event_type == "content_block_delta":
@@ -798,26 +1056,46 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
798
1056
  if text_delta:
799
1057
  text_part = TextPartDelta(content_delta=text_delta)
800
1058
  delta_event = PartDeltaEvent(index=index, delta=text_part)
801
- for handler in self.event_handler._wrapped_handlers:
802
- await handler(None, delta_event)
1059
+ await handler(None, delta_event)
803
1060
  yield delta_event
804
1061
 
805
1062
  elif delta_type == "thinking_delta":
806
1063
  thinking_delta = delta.get("thinking", "")
807
1064
  if thinking_delta:
808
- delta = ThinkingPartDelta(content_delta=thinking_delta)
809
- delta_event = PartDeltaEvent(index=index, delta=delta)
810
- for handler in self.event_handler._wrapped_handlers:
811
- await handler(None, delta_event)
1065
+ thinking_part_delta = ThinkingPartDelta(
1066
+ content_delta=thinking_delta
1067
+ )
1068
+ delta_event = PartDeltaEvent(
1069
+ index=index, delta=thinking_part_delta
1070
+ )
1071
+ await handler(None, delta_event)
812
1072
  yield delta_event
813
1073
 
1074
+ elif delta_type == "input_json_delta":
1075
+ # Accumulate tool argument JSON fragments
1076
+ partial_json = delta.get("partial_json", "")
1077
+ if partial_json:
1078
+ # Find which tool call this belongs to by index
1079
+ # The index corresponds to the content block index
1080
+ for tc_id in tool_accumulator._calls:
1081
+ tool_accumulator.add_args(tc_id, partial_json)
1082
+ # Emit PartDeltaEvent with ToolCallPartDelta
1083
+ tool_delta = ToolCallPartDelta(
1084
+ tool_name_delta=None,
1085
+ args_delta=partial_json,
1086
+ tool_call_id=tc_id,
1087
+ )
1088
+ delta_event = PartDeltaEvent(index=index, delta=tool_delta)
1089
+ await handler(None, delta_event)
1090
+ yield delta_event
1091
+ break # Only one tool call streams at a time
1092
+
814
1093
  # Handle content_block_stop events
815
1094
  elif event_type == "content_block_stop":
816
1095
  # We don't have the full part content here, emit with empty part
817
1096
  # The actual content was accumulated via deltas
818
1097
  end_event = PartEndEvent(index=index, part=TextPart(content=""))
819
- for handler in self.event_handler._wrapped_handlers:
820
- await handler(None, end_event)
1098
+ await handler(None, end_event)
821
1099
  yield end_event
822
1100
 
823
1101
  # Skip further processing for StreamEvent - don't duplicate
@@ -832,8 +1110,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
832
1110
  pending_tool_calls={}, # Already handled above
833
1111
  )
834
1112
  for event in events:
835
- for handler in self.event_handler._wrapped_handlers:
836
- await handler(None, event)
1113
+ await handler(None, event)
837
1114
  yield event
838
1115
 
839
1116
  # Check for result (end of response) and capture usage info
@@ -841,25 +1118,13 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
841
1118
  result_message = message
842
1119
  break
843
1120
 
844
- # Check for cancellation
845
- if self._cancelled:
846
- self.log.info("Stream cancelled by user")
847
- # Emit partial response
848
- response_msg = ChatMessage[TResult](
849
- content="".join(text_chunks), # type: ignore[arg-type]
850
- role="assistant",
851
- name=self.name,
852
- message_id=message_id or str(uuid.uuid4()),
853
- conversation_id=self.conversation_id,
854
- model_name=self.model_name,
855
- messages=model_messages,
856
- finish_reason="stop",
857
- )
858
- complete_event = StreamCompleteEvent(message=response_msg)
859
- for handler in self.event_handler._wrapped_handlers:
860
- await handler(None, complete_event)
861
- yield complete_event
862
- return
1121
+ # Note: We do NOT return early on cancellation here.
1122
+ # The SDK docs warn against using break/return to exit receive_response()
1123
+ # early as it can cause asyncio cleanup issues. Instead, we let the
1124
+ # interrupt() call cause the SDK to send a ResultMessage that will
1125
+ # naturally terminate the stream via the isinstance(message, ResultMessage)
1126
+ # check above. The _cancelled flag is checked in process_prompt() to
1127
+ # return the correct stop reason.
863
1128
  else:
864
1129
  result_message = None
865
1130
 
@@ -872,23 +1137,31 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
872
1137
  name=self.name,
873
1138
  message_id=message_id or str(uuid.uuid4()),
874
1139
  conversation_id=self.conversation_id,
1140
+ parent_id=user_msg.message_id,
875
1141
  model_name=self.model_name,
876
1142
  messages=model_messages,
877
1143
  finish_reason="stop",
1144
+ metadata=file_tracker.get_metadata(),
878
1145
  )
879
1146
  complete_event = StreamCompleteEvent(message=response_msg)
880
- for handler in self.event_handler._wrapped_handlers:
881
- await handler(None, complete_event)
1147
+ await handler(None, complete_event)
882
1148
  yield complete_event
1149
+ # Record to history even on cancellation so context is preserved
1150
+ self.message_sent.emit(response_msg)
1151
+ conversation.add_chat_messages([user_msg, response_msg])
883
1152
  return
884
1153
 
885
1154
  except Exception as e:
886
1155
  error_event = RunErrorEvent(message=str(e), run_id=run_id, agent_name=self.name)
887
- for handler in self.event_handler._wrapped_handlers:
888
- await handler(None, error_event)
1156
+ await handler(None, error_event)
889
1157
  yield error_event
890
1158
  raise
891
1159
 
1160
+ finally:
1161
+ # Clear deps from tool bridge
1162
+ if self._tool_bridge:
1163
+ self._tool_bridge.current_deps = None
1164
+
892
1165
  # Flush any remaining response parts
893
1166
  if current_response_parts:
894
1167
  model_messages.append(ModelResponse(parts=current_response_parts))
@@ -900,35 +1173,47 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
900
1173
  else "".join(text_chunks)
901
1174
  )
902
1175
 
903
- # Build cost_info from ResultMessage if available
1176
+ # Build cost_info and usage from ResultMessage if available
904
1177
  cost_info: TokenCost | None = None
1178
+ request_usage: RequestUsage | None = None
905
1179
  if result_message and result_message.usage:
906
- usage = result_message.usage
1180
+ usage_dict = result_message.usage
907
1181
  run_usage = RunUsage(
908
- input_tokens=usage.get("input_tokens", 0),
909
- output_tokens=usage.get("output_tokens", 0),
910
- cache_read_tokens=usage.get("cache_read_input_tokens", 0),
911
- cache_write_tokens=usage.get("cache_creation_input_tokens", 0),
1182
+ input_tokens=usage_dict.get("input_tokens", 0),
1183
+ output_tokens=usage_dict.get("output_tokens", 0),
1184
+ cache_read_tokens=usage_dict.get("cache_read_input_tokens", 0),
1185
+ cache_write_tokens=usage_dict.get("cache_creation_input_tokens", 0),
912
1186
  )
913
1187
  total_cost = Decimal(str(result_message.total_cost_usd or 0))
914
1188
  cost_info = TokenCost(token_usage=run_usage, total_cost=total_cost)
1189
+ # Also set usage for OpenCode compatibility
1190
+ request_usage = RequestUsage(
1191
+ input_tokens=usage_dict.get("input_tokens", 0),
1192
+ output_tokens=usage_dict.get("output_tokens", 0),
1193
+ cache_read_tokens=usage_dict.get("cache_read_input_tokens", 0),
1194
+ cache_write_tokens=usage_dict.get("cache_creation_input_tokens", 0),
1195
+ )
915
1196
 
1197
+ # Determine finish reason - check if we were cancelled
916
1198
  chat_message = ChatMessage[TResult](
917
1199
  content=final_content,
918
1200
  role="assistant",
919
1201
  name=self.name,
920
1202
  message_id=message_id or str(uuid.uuid4()),
921
1203
  conversation_id=self.conversation_id,
1204
+ parent_id=user_msg.message_id,
922
1205
  model_name=self.model_name,
923
1206
  messages=model_messages,
924
1207
  cost_info=cost_info,
1208
+ usage=request_usage or RequestUsage(),
925
1209
  response_time=result_message.duration_ms / 1000 if result_message else None,
1210
+ finish_reason="stop" if self._cancelled else None,
1211
+ metadata=file_tracker.get_metadata(),
926
1212
  )
927
1213
 
928
1214
  # Emit stream complete
929
1215
  complete_event = StreamCompleteEvent[TResult](message=chat_message)
930
- for handler in self.event_handler._wrapped_handlers:
931
- await handler(None, complete_event)
1216
+ await handler(None, complete_event)
932
1217
  yield complete_event
933
1218
  # Record to history
934
1219
  self.message_sent.emit(chat_message)
@@ -953,12 +1238,14 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
953
1238
  async def interrupt(self) -> None:
954
1239
  """Interrupt the currently running stream.
955
1240
 
956
- Calls the Claude SDK's native interrupt() method to stop the query,
957
- then cancels the local stream task.
1241
+ Sets the cancelled flag and calls the Claude SDK's native interrupt()
1242
+ method to stop the query. The stream loop checks the flag and returns
1243
+ gracefully - we don't cancel the task ourselves to avoid CancelledError
1244
+ propagation issues.
958
1245
  """
959
1246
  self._cancelled = True
960
1247
 
961
- # Use Claude SDK's native interrupt
1248
+ # Use Claude SDK's native interrupt - this causes the SDK to stop yielding
962
1249
  if self._client:
963
1250
  try:
964
1251
  await self._client.interrupt()
@@ -966,10 +1253,6 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
966
1253
  except Exception:
967
1254
  self.log.exception("Failed to interrupt Claude Code client")
968
1255
 
969
- # Also cancel the current stream task
970
- if self._current_stream_task and not self._current_stream_task.done():
971
- self._current_stream_task.cancel()
972
-
973
1256
  async def set_model(self, model: str) -> None:
974
1257
  """Set the model for future requests.
975
1258
 
@@ -999,11 +1282,163 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
999
1282
  elif self._client and mode == "always":
1000
1283
  await self._client.set_permission_mode("default")
1001
1284
 
1002
- async def get_stats(self) -> MessageStats:
1003
- """Get message statistics."""
1004
- from agentpool.talk.stats import MessageStats
1285
+ async def get_available_models(self) -> list[ModelInfo] | None:
1286
+ """Get available models for Claude Code agent.
1005
1287
 
1006
- return MessageStats(messages=list(self.conversation.chat_messages))
1288
+ Returns a static list of Claude models (opus, sonnet, haiku) since
1289
+ Claude Code SDK only supports these models with simple IDs.
1290
+
1291
+ Returns:
1292
+ List of tokonomics ModelInfo for Claude models
1293
+ """
1294
+ from tokonomics.model_discovery.model_info import ModelInfo, ModelPricing
1295
+
1296
+ # Static Claude Code models - these are the simple IDs the SDK accepts
1297
+ # Use id_override to ensure pydantic_ai_id returns simple names like "opus"
1298
+ return [
1299
+ ModelInfo(
1300
+ id="claude-opus-4-20250514",
1301
+ name="Claude Opus",
1302
+ provider="anthropic",
1303
+ description="Claude Opus - most capable model",
1304
+ context_window=200000,
1305
+ max_output_tokens=32000,
1306
+ input_modalities={"text", "image"},
1307
+ output_modalities={"text"},
1308
+ pricing=ModelPricing(
1309
+ prompt=0.000015, # $15 per 1M tokens
1310
+ completion=0.000075, # $75 per 1M tokens
1311
+ ),
1312
+ id_override="opus", # Claude Code SDK uses simple names
1313
+ ),
1314
+ ModelInfo(
1315
+ id="claude-sonnet-4-20250514",
1316
+ name="Claude Sonnet",
1317
+ provider="anthropic",
1318
+ description="Claude Sonnet - balanced performance and speed",
1319
+ context_window=200000,
1320
+ max_output_tokens=16000,
1321
+ input_modalities={"text", "image"},
1322
+ output_modalities={"text"},
1323
+ pricing=ModelPricing(
1324
+ prompt=0.000003, # $3 per 1M tokens
1325
+ completion=0.000015, # $15 per 1M tokens
1326
+ ),
1327
+ id_override="sonnet", # Claude Code SDK uses simple names
1328
+ ),
1329
+ ModelInfo(
1330
+ id="claude-haiku-3-5-20241022",
1331
+ name="Claude Haiku",
1332
+ provider="anthropic",
1333
+ description="Claude Haiku - fast and cost-effective",
1334
+ context_window=200000,
1335
+ max_output_tokens=8000,
1336
+ input_modalities={"text", "image"},
1337
+ output_modalities={"text"},
1338
+ pricing=ModelPricing(
1339
+ prompt=0.0000008, # $0.80 per 1M tokens
1340
+ completion=0.000004, # $4 per 1M tokens
1341
+ ),
1342
+ id_override="haiku", # Claude Code SDK uses simple names
1343
+ ),
1344
+ ]
1345
+
1346
+ def get_modes(self) -> list[ModeCategory]:
1347
+ """Get available mode categories for Claude Code agent.
1348
+
1349
+ Claude Code exposes permission modes from the SDK.
1350
+
1351
+ Returns:
1352
+ List with single ModeCategory for Claude Code permission modes
1353
+ """
1354
+ from agentpool.agents.modes import ModeCategory, ModeInfo
1355
+
1356
+ # Get current mode - map our confirmation mode to Claude's permission mode
1357
+ current_id = self._permission_mode or "default"
1358
+ if self.tool_confirmation_mode == "never":
1359
+ current_id = "bypassPermissions"
1360
+
1361
+ category_id = "permissions"
1362
+ return [
1363
+ ModeCategory(
1364
+ id=category_id,
1365
+ name="Mode",
1366
+ available_modes=[
1367
+ ModeInfo(
1368
+ id="default",
1369
+ name="Default",
1370
+ description="Require confirmation for tool usage",
1371
+ category_id=category_id,
1372
+ ),
1373
+ ModeInfo(
1374
+ id="acceptEdits",
1375
+ name="Accept Edits",
1376
+ description="Auto-approve file edits without confirmation",
1377
+ category_id=category_id,
1378
+ ),
1379
+ ModeInfo(
1380
+ id="plan",
1381
+ name="Plan",
1382
+ description="Planning mode - no tool execution",
1383
+ category_id=category_id,
1384
+ ),
1385
+ ModeInfo(
1386
+ id="bypassPermissions",
1387
+ name="Bypass Permissions",
1388
+ description="Skip all permission checks (use with caution)",
1389
+ category_id=category_id,
1390
+ ),
1391
+ ],
1392
+ current_mode_id=current_id,
1393
+ )
1394
+ ]
1395
+
1396
+ async def set_mode(self, mode: ModeInfo | str, category_id: str | None = None) -> None:
1397
+ """Set a mode within a category.
1398
+
1399
+ For Claude Code, this handles permission modes from the SDK.
1400
+
1401
+ Args:
1402
+ mode: The mode to set - ModeInfo object or mode ID string
1403
+ category_id: Optional category ID (defaults to "permissions")
1404
+
1405
+ Raises:
1406
+ ValueError: If the category or mode is unknown
1407
+ """
1408
+ # Extract mode_id and category from ModeInfo if provided
1409
+ if isinstance(mode, ModeInfo):
1410
+ mode_id = mode.id
1411
+ category_id = category_id or mode.category_id or None
1412
+ else:
1413
+ mode_id = mode
1414
+
1415
+ # Default to first (and only) category
1416
+ if category_id is None:
1417
+ category_id = "permissions"
1418
+
1419
+ if category_id != "permissions":
1420
+ msg = f"Unknown category: {category_id}. Only 'permissions' is supported."
1421
+ raise ValueError(msg)
1422
+
1423
+ # Map mode_id to PermissionMode
1424
+ valid_modes: set[PermissionMode] = {"default", "acceptEdits", "plan", "bypassPermissions"}
1425
+ if mode_id not in valid_modes:
1426
+ msg = f"Unknown mode: {mode_id}. Available: {list(valid_modes)}"
1427
+ raise ValueError(msg)
1428
+
1429
+ permission_mode: PermissionMode = mode_id # type: ignore[assignment]
1430
+ self._permission_mode = permission_mode
1431
+
1432
+ # Update tool confirmation mode based on permission mode
1433
+ if mode_id == "bypassPermissions":
1434
+ self.tool_confirmation_mode = "never"
1435
+ elif mode_id in ("default", "plan"):
1436
+ self.tool_confirmation_mode = "always"
1437
+
1438
+ # Update SDK client if connected
1439
+ if self._client:
1440
+ await self._client.set_permission_mode(permission_mode)
1441
+ self.log.info("Permission mode changed", mode=mode_id)
1007
1442
 
1008
1443
 
1009
1444
  if __name__ == "__main__":
@@ -1011,11 +1446,23 @@ if __name__ == "__main__":
1011
1446
 
1012
1447
  os.environ["ANTHROPIC_API_KEY"] = ""
1013
1448
 
1449
+ # async def main() -> None:
1450
+ # """Demo: Basic call to Claude Code."""
1451
+ # async with ClaudeCodeAgent(name="demo", event_handlers=["detailed"]) as agent:
1452
+ # print("Response (streaming): ", end="", flush=True)
1453
+ # async for _ in agent.run_stream("What files are in the current directory?"):
1454
+ # pass
1455
+
1014
1456
  async def main() -> None:
1015
1457
  """Demo: Basic call to Claude Code."""
1016
- async with ClaudeCodeAgent(name="demo", event_handlers=["detailed"]) as agent:
1017
- print("Response (streaming): ", end="", flush=True)
1018
- async for _ in agent.run_stream("What files are in the current directory?"):
1019
- pass
1458
+ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
1459
+
1460
+ options = ClaudeAgentOptions(include_partial_messages=True)
1461
+ client = ClaudeSDKClient(options=options)
1462
+ await client.connect()
1463
+ prompt = "Do one tool call. list the cwd"
1464
+ await client.query(prompt)
1465
+ async for message in client.receive_response():
1466
+ print(message)
1020
1467
 
1021
1468
  anyio.run(main)