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
@@ -34,8 +34,9 @@ from agentpool.agents.events import RunStartedEvent, StreamCompleteEvent
34
34
  from agentpool.log import get_logger
35
35
  from agentpool.messaging import ChatMessage
36
36
  from agentpool.messaging.processing import prepare_prompts
37
- from agentpool.talk.stats import MessageStats
38
37
  from agentpool.tools import ToolManager
38
+ from agentpool.utils.streams import FileTracker
39
+ from agentpool.utils.token_breakdown import calculate_usage_from_parts
39
40
 
40
41
 
41
42
  if TYPE_CHECKING:
@@ -45,10 +46,13 @@ if TYPE_CHECKING:
45
46
 
46
47
  from ag_ui.core import Message, ToolMessage
47
48
  from evented.configs import EventConfig
49
+ from slashed import BaseCommand
50
+ from tokonomics.model_discovery.model_info import ModelInfo
48
51
 
49
52
  from agentpool.agents.base_agent import ToolConfirmationMode
50
53
  from agentpool.agents.context import AgentContext
51
54
  from agentpool.agents.events import RichAgentStreamEvent
55
+ from agentpool.agents.modes import ModeCategory, ModeInfo
52
56
  from agentpool.common_types import (
53
57
  BuiltinEventHandlerType,
54
58
  IndividualEventHandler,
@@ -132,6 +136,7 @@ class AGUIAgent[TDeps = None](BaseAgent[TDeps, str]):
132
136
  event_configs: Sequence[EventConfig] | None = None,
133
137
  event_handlers: Sequence[IndividualEventHandler | BuiltinEventHandlerType] | None = None,
134
138
  tool_confirmation_mode: ToolConfirmationMode = "per_tool",
139
+ commands: Sequence[BaseCommand] | None = None,
135
140
  ) -> None:
136
141
  """Initialize AG-UI agent client.
137
142
 
@@ -153,6 +158,7 @@ class AGUIAgent[TDeps = None](BaseAgent[TDeps, str]):
153
158
  event_configs: Event trigger configurations
154
159
  event_handlers: Sequence of event handlers to register
155
160
  tool_confirmation_mode: Tool confirmation mode
161
+ commands: Slash commands
156
162
  """
157
163
  super().__init__(
158
164
  name=name,
@@ -164,6 +170,7 @@ class AGUIAgent[TDeps = None](BaseAgent[TDeps, str]):
164
170
  event_configs=event_configs,
165
171
  tool_confirmation_mode=tool_confirmation_mode,
166
172
  event_handlers=event_handlers,
173
+ commands=commands,
167
174
  )
168
175
 
169
176
  # AG-UI specific configuration
@@ -379,11 +386,11 @@ class AGUIAgent[TDeps = None](BaseAgent[TDeps, str]):
379
386
  )
380
387
 
381
388
  from agentpool.agents.agui_agent.agui_converters import (
382
- ToolCallAccumulator,
383
389
  agui_to_native_event,
384
390
  to_agui_input_content,
385
391
  to_agui_tool,
386
392
  )
393
+ from agentpool.agents.tool_call_accumulator import ToolCallAccumulator
387
394
 
388
395
  if not self._client or not self._thread_id:
389
396
  msg = "Agent not initialized - use async context manager"
@@ -394,7 +401,11 @@ class AGUIAgent[TDeps = None](BaseAgent[TDeps, str]):
394
401
  self._current_stream_task = asyncio.current_task()
395
402
 
396
403
  conversation = message_history if message_history is not None else self.conversation
397
- user_msg, processed_prompts, _original_message = await prepare_prompts(*prompts)
404
+ # Get parent_id from last message in history for tree structure
405
+ last_msg_id = conversation.get_last_message_id()
406
+ user_msg, processed_prompts, _original_message = await prepare_prompts(
407
+ *prompts, parent_id=last_msg_id
408
+ )
398
409
  self._run_id = str(uuid4()) # New run ID for each run
399
410
  self._chunk_transformer.reset() # Reset chunk transformer
400
411
  # Track messages in pydantic-ai format: ModelRequest -> ModelResponse -> ModelRequest...
@@ -411,10 +422,9 @@ class AGUIAgent[TDeps = None](BaseAgent[TDeps, str]):
411
422
  run_id=self._run_id or str(uuid4()),
412
423
  agent_name=self.name,
413
424
  )
414
- for handler in self.event_handler._wrapped_handlers:
415
- await handler(None, run_started)
416
- yield run_started
417
425
 
426
+ await self.event_handler(None, run_started)
427
+ yield run_started
418
428
  # Get pending parts from conversation and convert them
419
429
  pending_parts = conversation.get_pending_parts()
420
430
  pending_content = to_agui_input_content(pending_parts)
@@ -432,6 +442,8 @@ class AGUIAgent[TDeps = None](BaseAgent[TDeps, str]):
432
442
  tool_accumulator = ToolCallAccumulator()
433
443
  pending_tool_results: list[ToolMessage] = []
434
444
  self.log.debug("Sending prompt to AG-UI agent", tool_names=[t.name for t in agui_tools])
445
+ # Track files modified during this run
446
+ file_tracker = FileTracker()
435
447
  # Loop to handle tool calls - agent may request multiple rounds
436
448
  try:
437
449
  while True:
@@ -509,25 +521,24 @@ class AGUIAgent[TDeps = None](BaseAgent[TDeps, str]):
509
521
 
510
522
  # Convert to native event and distribute to handlers
511
523
  if native_event := agui_to_native_event(event):
524
+ # Track file modifications
525
+ file_tracker.process_event(native_event)
512
526
  # Check for queued custom events first
513
527
  while not self._event_queue.empty():
514
528
  try:
515
529
  custom_event = self._event_queue.get_nowait()
516
- for handler in self.event_handler._wrapped_handlers:
517
- await handler(None, custom_event)
530
+ await self.event_handler(None, custom_event)
518
531
  yield custom_event
519
532
  except asyncio.QueueEmpty:
520
533
  break
521
534
  # Distribute to handlers
522
- for handler in self.event_handler._wrapped_handlers:
523
- await handler(None, native_event)
535
+ await self.event_handler(None, native_event)
524
536
  yield native_event
525
537
 
526
538
  # Flush any pending chunk events at end of stream
527
539
  for event in self._chunk_transformer.flush():
528
540
  if native_event := agui_to_native_event(event):
529
- for handler in self.event_handler._wrapped_handlers:
530
- await handler(None, native_event)
541
+ await self.event_handler(None, native_event)
531
542
  yield native_event
532
543
 
533
544
  except httpx.HTTPError:
@@ -592,12 +603,13 @@ class AGUIAgent[TDeps = None](BaseAgent[TDeps, str]):
592
603
  name=self.name,
593
604
  message_id=message_id or str(uuid4()),
594
605
  conversation_id=self.conversation_id,
606
+ parent_id=user_msg.message_id,
595
607
  messages=model_messages,
596
608
  finish_reason="stop",
609
+ metadata=file_tracker.get_metadata(),
597
610
  )
598
611
  complete_event = StreamCompleteEvent(message=final_message)
599
- for handler in self.event_handler._wrapped_handlers:
600
- await handler(None, complete_event)
612
+ await self.event_handler(None, complete_event)
601
613
  yield complete_event
602
614
  self._current_stream_task = None
603
615
  return
@@ -610,24 +622,36 @@ class AGUIAgent[TDeps = None](BaseAgent[TDeps, str]):
610
622
  while not self._event_queue.empty():
611
623
  try:
612
624
  queued_event = self._event_queue.get_nowait()
613
- for handler in self.event_handler._wrapped_handlers:
614
- await handler(None, queued_event)
625
+ await self.event_handler(None, queued_event)
615
626
  yield queued_event
616
627
  except asyncio.QueueEmpty:
617
628
  break
618
629
 
619
630
  text_content = "".join(text_chunks)
631
+
632
+ # Calculate approximate token usage from what we can observe
633
+ input_parts = [*processed_prompts, *pending_parts]
634
+ usage, cost_info = await calculate_usage_from_parts(
635
+ input_parts=input_parts,
636
+ response_parts=current_response_parts,
637
+ text_content=text_content,
638
+ model_name=self.model_name,
639
+ )
640
+
620
641
  final_message = ChatMessage[str](
621
642
  content=text_content,
622
643
  role="assistant",
623
644
  name=self.name,
624
645
  message_id=message_id or str(uuid4()),
625
646
  conversation_id=self.conversation_id,
647
+ parent_id=user_msg.message_id,
626
648
  messages=model_messages,
649
+ metadata=file_tracker.get_metadata(),
650
+ usage=usage,
651
+ cost_info=cost_info,
627
652
  )
628
653
  complete_event = StreamCompleteEvent(message=final_message)
629
- for handler in self.event_handler._wrapped_handlers:
630
- await handler(None, complete_event)
654
+ await self.event_handler(None, complete_event)
631
655
  yield complete_event
632
656
  # Record to conversation history
633
657
  conversation.add_chat_messages([user_msg, final_message])
@@ -657,9 +681,54 @@ class AGUIAgent[TDeps = None](BaseAgent[TDeps, str]):
657
681
  """Get model name (AG-UI doesn't expose this)."""
658
682
  return None
659
683
 
660
- async def get_stats(self) -> MessageStats:
661
- """Get message statistics for this node."""
662
- return MessageStats()
684
+ async def set_model(self, model: str) -> None:
685
+ """Set model (no-op for AG-UI as model is controlled by remote server)."""
686
+ # AG-UI agents don't support model selection - the model is
687
+ # determined by the remote server configuration
688
+
689
+ async def get_available_models(self) -> list[ModelInfo] | None:
690
+ """Get available models for AG-UI agent.
691
+
692
+ AG-UI doesn't expose model information, so returns a placeholder model
693
+ indicating the model is determined by the remote server.
694
+
695
+ Returns:
696
+ List with a single placeholder ModelInfo
697
+ """
698
+ from tokonomics.model_discovery.model_info import ModelInfo
699
+
700
+ return [
701
+ ModelInfo(
702
+ id="server-determined",
703
+ name="Determined by server",
704
+ description="The model is determined by the remote AG-UI server",
705
+ )
706
+ ]
707
+
708
+ def get_modes(self) -> list[ModeCategory]:
709
+ """Get available modes for AG-UI agent.
710
+
711
+ AG-UI doesn't expose mode information, so returns an empty list.
712
+
713
+ Returns:
714
+ Empty list - no modes supported
715
+ """
716
+ return []
717
+
718
+ async def set_mode(self, mode: ModeInfo | str, category_id: str | None = None) -> None:
719
+ """Set a mode for AG-UI agent.
720
+
721
+ AG-UI doesn't support modes, so this always raises an error.
722
+
723
+ Args:
724
+ mode: The mode to set (not supported)
725
+ category_id: Optional category ID (not supported)
726
+
727
+ Raises:
728
+ ValueError: Always - AG-UI doesn't support modes
729
+ """
730
+ msg = "AG-UI agent does not support modes"
731
+ raise ValueError(msg)
663
732
 
664
733
 
665
734
  if __name__ == "__main__":
@@ -12,7 +12,6 @@ from __future__ import annotations
12
12
  import base64
13
13
  from typing import TYPE_CHECKING, Any
14
14
 
15
- import anyenv
16
15
  from pydantic_ai import (
17
16
  AudioUrl,
18
17
  BinaryContent,
@@ -291,133 +290,3 @@ def to_agui_tool(tool: Tool) -> AGUITool:
291
290
  description=func_schema.get("description", ""),
292
291
  parameters=func_schema.get("parameters", {"type": "object", "properties": {}}),
293
292
  )
294
-
295
-
296
- def _repair_partial_json(buffer: str) -> str:
297
- """Attempt to repair truncated JSON for preview purposes.
298
-
299
- Handles common truncation cases:
300
- - Unclosed strings
301
- - Missing closing braces/brackets
302
- - Trailing commas
303
-
304
- Args:
305
- buffer: Potentially incomplete JSON string
306
-
307
- Returns:
308
- Repaired JSON string (may still be invalid in edge cases)
309
- """
310
- if not buffer:
311
- return "{}"
312
-
313
- result = buffer.rstrip()
314
-
315
- # Check if we're in the middle of a string by counting unescaped quotes
316
- in_string = False
317
- i = 0
318
- while i < len(result):
319
- char = result[i]
320
- if char == "\\" and i + 1 < len(result):
321
- i += 2 # Skip escaped character
322
- continue
323
- if char == '"':
324
- in_string = not in_string
325
- i += 1
326
-
327
- # Close unclosed string
328
- if in_string:
329
- result += '"'
330
-
331
- # Remove trailing comma (invalid JSON)
332
- result = result.rstrip()
333
- if result.endswith(","):
334
- result = result[:-1]
335
-
336
- # Balance braces and brackets
337
- open_braces = result.count("{") - result.count("}")
338
- open_brackets = result.count("[") - result.count("]")
339
-
340
- result += "]" * max(0, open_brackets)
341
- result += "}" * max(0, open_braces)
342
-
343
- return result
344
-
345
-
346
- class ToolCallAccumulator:
347
- """Accumulates streamed tool call arguments.
348
-
349
- AG-UI streams tool call arguments as deltas, this class accumulates them
350
- and provides the complete arguments when the tool call ends.
351
- """
352
-
353
- def __init__(self) -> None:
354
- self._calls: dict[str, dict[str, Any]] = {}
355
-
356
- def start(self, tool_call_id: str, tool_name: str) -> None:
357
- """Start tracking a new tool call."""
358
- self._calls[tool_call_id] = {"name": tool_name, "args_buffer": ""}
359
-
360
- def add_args(self, tool_call_id: str, delta: str) -> None:
361
- """Add argument delta to a tool call."""
362
- if tool_call_id in self._calls:
363
- self._calls[tool_call_id]["args_buffer"] += delta
364
-
365
- def complete(self, tool_call_id: str) -> tuple[str, dict[str, Any]] | None:
366
- """Complete a tool call and return (tool_name, parsed_args).
367
-
368
- Returns:
369
- Tuple of (tool_name, args_dict) or None if call not found
370
- """
371
- if tool_call_id not in self._calls:
372
- return None
373
-
374
- call_data = self._calls.pop(tool_call_id)
375
- args_str = call_data["args_buffer"]
376
- try:
377
- args = anyenv.load_json(args_str) if args_str else {}
378
- except anyenv.JsonLoadError:
379
- args = {"raw": args_str}
380
- return call_data["name"], args
381
-
382
- def get_pending(self, tool_call_id: str) -> tuple[str, str] | None:
383
- """Get pending call data (tool_name, args_buffer) without completing."""
384
- if tool_call_id not in self._calls:
385
- return None
386
- data = self._calls[tool_call_id]
387
- return data["name"], data["args_buffer"]
388
-
389
- def get_partial_args(self, tool_call_id: str) -> dict[str, Any]:
390
- """Get best-effort parsed args from incomplete JSON stream.
391
-
392
- Uses heuristics to complete truncated JSON for preview purposes.
393
- Handles unclosed strings, missing braces/brackets, and trailing commas.
394
-
395
- Args:
396
- tool_call_id: Tool call ID
397
-
398
- Returns:
399
- Partially parsed arguments or empty dict
400
- """
401
- if tool_call_id not in self._calls:
402
- return {}
403
-
404
- buffer = self._calls[tool_call_id]["args_buffer"]
405
- if not buffer:
406
- return {}
407
-
408
- # Try direct parse first
409
- try:
410
- return anyenv.load_json(buffer)
411
- except anyenv.JsonLoadError:
412
- pass
413
-
414
- # Try to repair truncated JSON
415
- try:
416
- repaired = _repair_partial_json(buffer)
417
- return anyenv.load_json(repaired)
418
- except anyenv.JsonLoadError:
419
- return {}
420
-
421
- def clear(self) -> None:
422
- """Clear all pending tool calls."""
423
- self._calls.clear()
@@ -7,6 +7,7 @@ import asyncio
7
7
  from typing import TYPE_CHECKING, Any, Literal
8
8
 
9
9
  from anyenv import MultiEventHandler
10
+ from anyenv.signals import BoundSignal
10
11
  from exxec import LocalExecutionEnvironment
11
12
 
12
13
  from agentpool.agents.events import resolve_event_handlers
@@ -20,17 +21,30 @@ if TYPE_CHECKING:
20
21
 
21
22
  from evented.configs import EventConfig
22
23
  from exxec import ExecutionEnvironment
24
+ from slashed import BaseCommand, CommandStore
25
+ from tokonomics.model_discovery.model_info import ModelInfo
23
26
 
27
+ from acp.schema import AvailableCommandsUpdate, ConfigOptionUpdate
24
28
  from agentpool.agents.context import AgentContext
25
29
  from agentpool.agents.events import RichAgentStreamEvent
26
- from agentpool.common_types import BuiltinEventHandlerType, IndividualEventHandler
30
+ from agentpool.agents.modes import ModeCategory, ModeInfo
31
+ from agentpool.common_types import (
32
+ BuiltinEventHandlerType,
33
+ IndividualEventHandler,
34
+ MCPServerStatus,
35
+ )
27
36
  from agentpool.delegation import AgentPool
37
+ from agentpool.talk.stats import MessageStats
28
38
  from agentpool.ui.base import InputProvider
29
39
  from agentpool_config.mcp_server import MCPServerConfig
30
40
 
41
+ # Union type for state updates emitted via state_updated signal
42
+ type StateUpdate = ModeInfo | ModelInfo | AvailableCommandsUpdate | ConfigOptionUpdate
43
+
31
44
 
32
45
  logger = get_logger(__name__)
33
46
 
47
+
34
48
  ToolConfirmationMode = Literal["always", "never", "per_tool"]
35
49
 
36
50
 
@@ -64,6 +78,7 @@ class BaseAgent[TDeps = None, TResult = str](MessageNode[TDeps, TResult]):
64
78
  output_type: type[TResult] = str, # type: ignore[assignment]
65
79
  tool_confirmation_mode: ToolConfirmationMode = "per_tool",
66
80
  event_handlers: Sequence[IndividualEventHandler | BuiltinEventHandlerType] | None = None,
81
+ commands: Sequence[BaseCommand] | None = None,
67
82
  ) -> None:
68
83
  """Initialize base agent with shared infrastructure.
69
84
 
@@ -80,6 +95,7 @@ class BaseAgent[TDeps = None, TResult = str](MessageNode[TDeps, TResult]):
80
95
  output_type: Output type for this agent
81
96
  tool_confirmation_mode: How tool execution confirmation is handled
82
97
  event_handlers: Event handlers for this agent
98
+ commands: Slash commands to register with this agent
83
99
  """
84
100
  super().__init__(
85
101
  name=name,
@@ -108,6 +124,31 @@ class BaseAgent[TDeps = None, TResult = str](MessageNode[TDeps, TResult]):
108
124
  self._cancelled = False
109
125
  self._current_stream_task: asyncio.Task[Any] | None = None
110
126
 
127
+ # State change signal - emitted when mode/model/commands change
128
+ # Uses union type for different state update kinds
129
+ self.state_updated: BoundSignal[StateUpdate] = BoundSignal()
130
+
131
+ # Command store for slash commands
132
+ from slashed import CommandStore
133
+
134
+ from agentpool_commands import get_commands
135
+
136
+ self._command_store: CommandStore = CommandStore()
137
+
138
+ # Register default agent commands
139
+ for command in get_commands():
140
+ self._command_store.register_command(command)
141
+
142
+ # Register additional provided commands
143
+ if commands:
144
+ for command in commands:
145
+ self._command_store.register_command(command)
146
+
147
+ @property
148
+ def command_store(self) -> CommandStore:
149
+ """Get the command store for slash commands."""
150
+ return self._command_store
151
+
111
152
  @abstractmethod
112
153
  def get_context(self, data: Any = None) -> AgentContext[Any]:
113
154
  """Create a new context for this agent.
@@ -126,6 +167,15 @@ class BaseAgent[TDeps = None, TResult = str](MessageNode[TDeps, TResult]):
126
167
  """Get the model name used by this agent."""
127
168
  ...
128
169
 
170
+ @abstractmethod
171
+ async def set_model(self, model: str) -> None:
172
+ """Set the model for this agent.
173
+
174
+ Args:
175
+ model: New model identifier to use
176
+ """
177
+ ...
178
+
129
179
  @abstractmethod
130
180
  def run_stream(
131
181
  self,
@@ -175,3 +225,115 @@ class BaseAgent[TDeps = None, TResult = str](MessageNode[TDeps, TResult]):
175
225
  if self._current_stream_task and not self._current_stream_task.done():
176
226
  self._current_stream_task.cancel()
177
227
  logger.info("Interrupted agent stream", agent=self.name)
228
+
229
+ async def get_stats(self) -> MessageStats:
230
+ """Get message statistics."""
231
+ from agentpool.talk.stats import MessageStats
232
+
233
+ return MessageStats(messages=list(self.conversation.chat_messages))
234
+
235
+ def get_mcp_server_info(self) -> dict[str, MCPServerStatus]:
236
+ """Get information about configured MCP servers.
237
+
238
+ Returns a dict mapping server names to their status info. Used by
239
+ the OpenCode /mcp endpoint to display MCP servers in the UI.
240
+
241
+ The default implementation checks external_providers on the tool manager.
242
+ Subclasses may override to provide agent-specific MCP server info
243
+ (e.g., ClaudeCodeAgent has its own MCP server handling).
244
+
245
+ Returns:
246
+ Dict mapping server name to MCPServerStatus
247
+ """
248
+ from agentpool.common_types import MCPServerStatus
249
+ from agentpool.mcp_server.manager import MCPManager
250
+ from agentpool.resource_providers import AggregatingResourceProvider
251
+ from agentpool.resource_providers.mcp_provider import MCPResourceProvider
252
+
253
+ def add_status(provider: MCPResourceProvider, result: dict[str, MCPServerStatus]) -> None:
254
+ status_dict = provider.get_status()
255
+ status_type = status_dict.get("status", "disabled")
256
+ if status_type == "connected":
257
+ result[provider.name] = MCPServerStatus(
258
+ name=provider.name, status="connected", server_type="stdio"
259
+ )
260
+ elif status_type == "failed":
261
+ error = status_dict.get("error", "Unknown error")
262
+ result[provider.name] = MCPServerStatus(
263
+ name=provider.name, status="error", error=error
264
+ )
265
+ else:
266
+ result[provider.name] = MCPServerStatus(name=provider.name, status="disconnected")
267
+
268
+ result: dict[str, MCPServerStatus] = {}
269
+ try:
270
+ for provider in self.tools.external_providers:
271
+ if isinstance(provider, MCPResourceProvider):
272
+ add_status(provider, result)
273
+ elif isinstance(provider, AggregatingResourceProvider):
274
+ for nested in provider.providers:
275
+ if isinstance(nested, MCPResourceProvider):
276
+ add_status(nested, result)
277
+ elif isinstance(provider, MCPManager):
278
+ for mcp_provider in provider.get_mcp_providers():
279
+ add_status(mcp_provider, result)
280
+ except Exception: # noqa: BLE001
281
+ pass
282
+
283
+ return result
284
+
285
+ @abstractmethod
286
+ async def get_available_models(self) -> list[ModelInfo] | None:
287
+ """Get available models for this agent.
288
+
289
+ Returns a list of models that can be used with this agent, or None
290
+ if model discovery is not supported for this agent type.
291
+
292
+ Uses tokonomics.ModelInfo which includes pricing, capabilities,
293
+ and limits. Can be converted to protocol-specific formats (OpenCode, ACP).
294
+
295
+ Returns:
296
+ List of tokonomics ModelInfo, or None if not supported
297
+ """
298
+ ...
299
+
300
+ @abstractmethod
301
+ def get_modes(self) -> list[ModeCategory]:
302
+ """Get available mode categories for this agent.
303
+
304
+ Returns a list of mode categories that can be switched. Each category
305
+ represents a group of mutually exclusive modes (e.g., permissions,
306
+ behavior presets).
307
+
308
+ Different agent types expose different modes:
309
+ - Native Agent: Tool confirmation modes (default, acceptEdits)
310
+ - ClaudeCodeAgent: Claude Code SDK modes (plan, code, etc.)
311
+ - ACPAgent: Passthrough from remote server
312
+ - AGUIAgent: Empty list (no modes)
313
+
314
+ Returns:
315
+ List of ModeCategory, empty list if no modes supported
316
+ """
317
+ ...
318
+
319
+ @abstractmethod
320
+ async def set_mode(self, mode: ModeInfo | str, category_id: str | None = None) -> None:
321
+ """Set a mode within a category.
322
+
323
+ Each agent type handles mode switching according to its own semantics:
324
+ - Native Agent: Maps to tool confirmation mode
325
+ - ClaudeCodeAgent: Maps to SDK permission mode
326
+ - ACPAgent: Forwards to remote server
327
+ - AGUIAgent: No-op (no modes supported)
328
+
329
+ Args:
330
+ mode: The mode to activate - either a ModeInfo object or mode ID string.
331
+ If ModeInfo, category_id is extracted from it (unless overridden).
332
+ category_id: Optional category ID. If None and mode is a string,
333
+ uses the first category. If None and mode is ModeInfo,
334
+ uses the mode's category_id.
335
+
336
+ Raises:
337
+ ValueError: If mode_id or category_id is invalid
338
+ """
339
+ ...