fast-agent-mcp 0.4.7__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 (261) hide show
  1. fast_agent/__init__.py +183 -0
  2. fast_agent/acp/__init__.py +19 -0
  3. fast_agent/acp/acp_aware_mixin.py +304 -0
  4. fast_agent/acp/acp_context.py +437 -0
  5. fast_agent/acp/content_conversion.py +136 -0
  6. fast_agent/acp/filesystem_runtime.py +427 -0
  7. fast_agent/acp/permission_store.py +269 -0
  8. fast_agent/acp/server/__init__.py +5 -0
  9. fast_agent/acp/server/agent_acp_server.py +1472 -0
  10. fast_agent/acp/slash_commands.py +1050 -0
  11. fast_agent/acp/terminal_runtime.py +408 -0
  12. fast_agent/acp/tool_permission_adapter.py +125 -0
  13. fast_agent/acp/tool_permissions.py +474 -0
  14. fast_agent/acp/tool_progress.py +814 -0
  15. fast_agent/agents/__init__.py +85 -0
  16. fast_agent/agents/agent_types.py +64 -0
  17. fast_agent/agents/llm_agent.py +350 -0
  18. fast_agent/agents/llm_decorator.py +1139 -0
  19. fast_agent/agents/mcp_agent.py +1337 -0
  20. fast_agent/agents/tool_agent.py +271 -0
  21. fast_agent/agents/workflow/agents_as_tools_agent.py +849 -0
  22. fast_agent/agents/workflow/chain_agent.py +212 -0
  23. fast_agent/agents/workflow/evaluator_optimizer.py +380 -0
  24. fast_agent/agents/workflow/iterative_planner.py +652 -0
  25. fast_agent/agents/workflow/maker_agent.py +379 -0
  26. fast_agent/agents/workflow/orchestrator_models.py +218 -0
  27. fast_agent/agents/workflow/orchestrator_prompts.py +248 -0
  28. fast_agent/agents/workflow/parallel_agent.py +250 -0
  29. fast_agent/agents/workflow/router_agent.py +353 -0
  30. fast_agent/cli/__init__.py +0 -0
  31. fast_agent/cli/__main__.py +73 -0
  32. fast_agent/cli/commands/acp.py +159 -0
  33. fast_agent/cli/commands/auth.py +404 -0
  34. fast_agent/cli/commands/check_config.py +783 -0
  35. fast_agent/cli/commands/go.py +514 -0
  36. fast_agent/cli/commands/quickstart.py +557 -0
  37. fast_agent/cli/commands/serve.py +143 -0
  38. fast_agent/cli/commands/server_helpers.py +114 -0
  39. fast_agent/cli/commands/setup.py +174 -0
  40. fast_agent/cli/commands/url_parser.py +190 -0
  41. fast_agent/cli/constants.py +40 -0
  42. fast_agent/cli/main.py +115 -0
  43. fast_agent/cli/terminal.py +24 -0
  44. fast_agent/config.py +798 -0
  45. fast_agent/constants.py +41 -0
  46. fast_agent/context.py +279 -0
  47. fast_agent/context_dependent.py +50 -0
  48. fast_agent/core/__init__.py +92 -0
  49. fast_agent/core/agent_app.py +448 -0
  50. fast_agent/core/core_app.py +137 -0
  51. fast_agent/core/direct_decorators.py +784 -0
  52. fast_agent/core/direct_factory.py +620 -0
  53. fast_agent/core/error_handling.py +27 -0
  54. fast_agent/core/exceptions.py +90 -0
  55. fast_agent/core/executor/__init__.py +0 -0
  56. fast_agent/core/executor/executor.py +280 -0
  57. fast_agent/core/executor/task_registry.py +32 -0
  58. fast_agent/core/executor/workflow_signal.py +324 -0
  59. fast_agent/core/fastagent.py +1186 -0
  60. fast_agent/core/logging/__init__.py +5 -0
  61. fast_agent/core/logging/events.py +138 -0
  62. fast_agent/core/logging/json_serializer.py +164 -0
  63. fast_agent/core/logging/listeners.py +309 -0
  64. fast_agent/core/logging/logger.py +278 -0
  65. fast_agent/core/logging/transport.py +481 -0
  66. fast_agent/core/prompt.py +9 -0
  67. fast_agent/core/prompt_templates.py +183 -0
  68. fast_agent/core/validation.py +326 -0
  69. fast_agent/event_progress.py +62 -0
  70. fast_agent/history/history_exporter.py +49 -0
  71. fast_agent/human_input/__init__.py +47 -0
  72. fast_agent/human_input/elicitation_handler.py +123 -0
  73. fast_agent/human_input/elicitation_state.py +33 -0
  74. fast_agent/human_input/form_elements.py +59 -0
  75. fast_agent/human_input/form_fields.py +256 -0
  76. fast_agent/human_input/simple_form.py +113 -0
  77. fast_agent/human_input/types.py +40 -0
  78. fast_agent/interfaces.py +310 -0
  79. fast_agent/llm/__init__.py +9 -0
  80. fast_agent/llm/cancellation.py +22 -0
  81. fast_agent/llm/fastagent_llm.py +931 -0
  82. fast_agent/llm/internal/passthrough.py +161 -0
  83. fast_agent/llm/internal/playback.py +129 -0
  84. fast_agent/llm/internal/silent.py +41 -0
  85. fast_agent/llm/internal/slow.py +38 -0
  86. fast_agent/llm/memory.py +275 -0
  87. fast_agent/llm/model_database.py +490 -0
  88. fast_agent/llm/model_factory.py +388 -0
  89. fast_agent/llm/model_info.py +102 -0
  90. fast_agent/llm/prompt_utils.py +155 -0
  91. fast_agent/llm/provider/anthropic/anthropic_utils.py +84 -0
  92. fast_agent/llm/provider/anthropic/cache_planner.py +56 -0
  93. fast_agent/llm/provider/anthropic/llm_anthropic.py +796 -0
  94. fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +462 -0
  95. fast_agent/llm/provider/bedrock/bedrock_utils.py +218 -0
  96. fast_agent/llm/provider/bedrock/llm_bedrock.py +2207 -0
  97. fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py +84 -0
  98. fast_agent/llm/provider/google/google_converter.py +466 -0
  99. fast_agent/llm/provider/google/llm_google_native.py +681 -0
  100. fast_agent/llm/provider/openai/llm_aliyun.py +31 -0
  101. fast_agent/llm/provider/openai/llm_azure.py +143 -0
  102. fast_agent/llm/provider/openai/llm_deepseek.py +76 -0
  103. fast_agent/llm/provider/openai/llm_generic.py +35 -0
  104. fast_agent/llm/provider/openai/llm_google_oai.py +32 -0
  105. fast_agent/llm/provider/openai/llm_groq.py +42 -0
  106. fast_agent/llm/provider/openai/llm_huggingface.py +85 -0
  107. fast_agent/llm/provider/openai/llm_openai.py +1195 -0
  108. fast_agent/llm/provider/openai/llm_openai_compatible.py +138 -0
  109. fast_agent/llm/provider/openai/llm_openrouter.py +45 -0
  110. fast_agent/llm/provider/openai/llm_tensorzero_openai.py +128 -0
  111. fast_agent/llm/provider/openai/llm_xai.py +38 -0
  112. fast_agent/llm/provider/openai/multipart_converter_openai.py +561 -0
  113. fast_agent/llm/provider/openai/openai_multipart.py +169 -0
  114. fast_agent/llm/provider/openai/openai_utils.py +67 -0
  115. fast_agent/llm/provider/openai/responses.py +133 -0
  116. fast_agent/llm/provider_key_manager.py +139 -0
  117. fast_agent/llm/provider_types.py +34 -0
  118. fast_agent/llm/request_params.py +61 -0
  119. fast_agent/llm/sampling_converter.py +98 -0
  120. fast_agent/llm/stream_types.py +9 -0
  121. fast_agent/llm/usage_tracking.py +445 -0
  122. fast_agent/mcp/__init__.py +56 -0
  123. fast_agent/mcp/common.py +26 -0
  124. fast_agent/mcp/elicitation_factory.py +84 -0
  125. fast_agent/mcp/elicitation_handlers.py +164 -0
  126. fast_agent/mcp/gen_client.py +83 -0
  127. fast_agent/mcp/helpers/__init__.py +36 -0
  128. fast_agent/mcp/helpers/content_helpers.py +352 -0
  129. fast_agent/mcp/helpers/server_config_helpers.py +25 -0
  130. fast_agent/mcp/hf_auth.py +147 -0
  131. fast_agent/mcp/interfaces.py +92 -0
  132. fast_agent/mcp/logger_textio.py +108 -0
  133. fast_agent/mcp/mcp_agent_client_session.py +411 -0
  134. fast_agent/mcp/mcp_aggregator.py +2175 -0
  135. fast_agent/mcp/mcp_connection_manager.py +723 -0
  136. fast_agent/mcp/mcp_content.py +262 -0
  137. fast_agent/mcp/mime_utils.py +108 -0
  138. fast_agent/mcp/oauth_client.py +509 -0
  139. fast_agent/mcp/prompt.py +159 -0
  140. fast_agent/mcp/prompt_message_extended.py +155 -0
  141. fast_agent/mcp/prompt_render.py +84 -0
  142. fast_agent/mcp/prompt_serialization.py +580 -0
  143. fast_agent/mcp/prompts/__init__.py +0 -0
  144. fast_agent/mcp/prompts/__main__.py +7 -0
  145. fast_agent/mcp/prompts/prompt_constants.py +18 -0
  146. fast_agent/mcp/prompts/prompt_helpers.py +238 -0
  147. fast_agent/mcp/prompts/prompt_load.py +186 -0
  148. fast_agent/mcp/prompts/prompt_server.py +552 -0
  149. fast_agent/mcp/prompts/prompt_template.py +438 -0
  150. fast_agent/mcp/resource_utils.py +215 -0
  151. fast_agent/mcp/sampling.py +200 -0
  152. fast_agent/mcp/server/__init__.py +4 -0
  153. fast_agent/mcp/server/agent_server.py +613 -0
  154. fast_agent/mcp/skybridge.py +44 -0
  155. fast_agent/mcp/sse_tracking.py +287 -0
  156. fast_agent/mcp/stdio_tracking_simple.py +59 -0
  157. fast_agent/mcp/streamable_http_tracking.py +309 -0
  158. fast_agent/mcp/tool_execution_handler.py +137 -0
  159. fast_agent/mcp/tool_permission_handler.py +88 -0
  160. fast_agent/mcp/transport_tracking.py +634 -0
  161. fast_agent/mcp/types.py +24 -0
  162. fast_agent/mcp/ui_agent.py +48 -0
  163. fast_agent/mcp/ui_mixin.py +209 -0
  164. fast_agent/mcp_server_registry.py +89 -0
  165. fast_agent/py.typed +0 -0
  166. fast_agent/resources/examples/data-analysis/analysis-campaign.py +189 -0
  167. fast_agent/resources/examples/data-analysis/analysis.py +68 -0
  168. fast_agent/resources/examples/data-analysis/fastagent.config.yaml +41 -0
  169. fast_agent/resources/examples/data-analysis/mount-point/WA_Fn-UseC_-HR-Employee-Attrition.csv +1471 -0
  170. fast_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
  171. fast_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +297 -0
  172. fast_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
  173. fast_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
  174. fast_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
  175. fast_agent/resources/examples/mcp/elicitations/forms_demo.py +107 -0
  176. fast_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
  177. fast_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
  178. fast_agent/resources/examples/mcp/elicitations/tool_call.py +21 -0
  179. fast_agent/resources/examples/mcp/state-transfer/agent_one.py +18 -0
  180. fast_agent/resources/examples/mcp/state-transfer/agent_two.py +18 -0
  181. fast_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +27 -0
  182. fast_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +15 -0
  183. fast_agent/resources/examples/researcher/fastagent.config.yaml +61 -0
  184. fast_agent/resources/examples/researcher/researcher-eval.py +53 -0
  185. fast_agent/resources/examples/researcher/researcher-imp.py +189 -0
  186. fast_agent/resources/examples/researcher/researcher.py +36 -0
  187. fast_agent/resources/examples/tensorzero/.env.sample +2 -0
  188. fast_agent/resources/examples/tensorzero/Makefile +31 -0
  189. fast_agent/resources/examples/tensorzero/README.md +56 -0
  190. fast_agent/resources/examples/tensorzero/agent.py +35 -0
  191. fast_agent/resources/examples/tensorzero/demo_images/clam.jpg +0 -0
  192. fast_agent/resources/examples/tensorzero/demo_images/crab.png +0 -0
  193. fast_agent/resources/examples/tensorzero/demo_images/shrimp.png +0 -0
  194. fast_agent/resources/examples/tensorzero/docker-compose.yml +105 -0
  195. fast_agent/resources/examples/tensorzero/fastagent.config.yaml +19 -0
  196. fast_agent/resources/examples/tensorzero/image_demo.py +67 -0
  197. fast_agent/resources/examples/tensorzero/mcp_server/Dockerfile +25 -0
  198. fast_agent/resources/examples/tensorzero/mcp_server/entrypoint.sh +35 -0
  199. fast_agent/resources/examples/tensorzero/mcp_server/mcp_server.py +31 -0
  200. fast_agent/resources/examples/tensorzero/mcp_server/pyproject.toml +11 -0
  201. fast_agent/resources/examples/tensorzero/simple_agent.py +25 -0
  202. fast_agent/resources/examples/tensorzero/tensorzero_config/system_schema.json +29 -0
  203. fast_agent/resources/examples/tensorzero/tensorzero_config/system_template.minijinja +11 -0
  204. fast_agent/resources/examples/tensorzero/tensorzero_config/tensorzero.toml +35 -0
  205. fast_agent/resources/examples/workflows/agents_as_tools_extended.py +73 -0
  206. fast_agent/resources/examples/workflows/agents_as_tools_simple.py +50 -0
  207. fast_agent/resources/examples/workflows/chaining.py +37 -0
  208. fast_agent/resources/examples/workflows/evaluator.py +77 -0
  209. fast_agent/resources/examples/workflows/fastagent.config.yaml +26 -0
  210. fast_agent/resources/examples/workflows/graded_report.md +89 -0
  211. fast_agent/resources/examples/workflows/human_input.py +28 -0
  212. fast_agent/resources/examples/workflows/maker.py +156 -0
  213. fast_agent/resources/examples/workflows/orchestrator.py +70 -0
  214. fast_agent/resources/examples/workflows/parallel.py +56 -0
  215. fast_agent/resources/examples/workflows/router.py +69 -0
  216. fast_agent/resources/examples/workflows/short_story.md +13 -0
  217. fast_agent/resources/examples/workflows/short_story.txt +19 -0
  218. fast_agent/resources/setup/.gitignore +30 -0
  219. fast_agent/resources/setup/agent.py +28 -0
  220. fast_agent/resources/setup/fastagent.config.yaml +65 -0
  221. fast_agent/resources/setup/fastagent.secrets.yaml.example +38 -0
  222. fast_agent/resources/setup/pyproject.toml.tmpl +23 -0
  223. fast_agent/skills/__init__.py +9 -0
  224. fast_agent/skills/registry.py +235 -0
  225. fast_agent/tools/elicitation.py +369 -0
  226. fast_agent/tools/shell_runtime.py +402 -0
  227. fast_agent/types/__init__.py +59 -0
  228. fast_agent/types/conversation_summary.py +294 -0
  229. fast_agent/types/llm_stop_reason.py +78 -0
  230. fast_agent/types/message_search.py +249 -0
  231. fast_agent/ui/__init__.py +38 -0
  232. fast_agent/ui/console.py +59 -0
  233. fast_agent/ui/console_display.py +1080 -0
  234. fast_agent/ui/elicitation_form.py +946 -0
  235. fast_agent/ui/elicitation_style.py +59 -0
  236. fast_agent/ui/enhanced_prompt.py +1400 -0
  237. fast_agent/ui/history_display.py +734 -0
  238. fast_agent/ui/interactive_prompt.py +1199 -0
  239. fast_agent/ui/markdown_helpers.py +104 -0
  240. fast_agent/ui/markdown_truncator.py +1004 -0
  241. fast_agent/ui/mcp_display.py +857 -0
  242. fast_agent/ui/mcp_ui_utils.py +235 -0
  243. fast_agent/ui/mermaid_utils.py +169 -0
  244. fast_agent/ui/message_primitives.py +50 -0
  245. fast_agent/ui/notification_tracker.py +205 -0
  246. fast_agent/ui/plain_text_truncator.py +68 -0
  247. fast_agent/ui/progress_display.py +10 -0
  248. fast_agent/ui/rich_progress.py +195 -0
  249. fast_agent/ui/streaming.py +774 -0
  250. fast_agent/ui/streaming_buffer.py +449 -0
  251. fast_agent/ui/tool_display.py +422 -0
  252. fast_agent/ui/usage_display.py +204 -0
  253. fast_agent/utils/__init__.py +5 -0
  254. fast_agent/utils/reasoning_stream_parser.py +77 -0
  255. fast_agent/utils/time.py +22 -0
  256. fast_agent/workflow_telemetry.py +261 -0
  257. fast_agent_mcp-0.4.7.dist-info/METADATA +788 -0
  258. fast_agent_mcp-0.4.7.dist-info/RECORD +261 -0
  259. fast_agent_mcp-0.4.7.dist-info/WHEEL +4 -0
  260. fast_agent_mcp-0.4.7.dist-info/entry_points.txt +7 -0
  261. fast_agent_mcp-0.4.7.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,814 @@
1
+ """
2
+ ACP Tool Progress Tracking
3
+
4
+ Provides integration between MCP tool execution and ACP tool call notifications.
5
+ When MCP tools execute and report progress, this module:
6
+ 1. Sends initial tool_call notifications to the ACP client
7
+ 2. Updates with progress via tool_call_update notifications
8
+ 3. Handles status transitions (pending -> in_progress -> completed/failed)
9
+ """
10
+
11
+ import asyncio
12
+ import uuid
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from acp.contrib import ToolCallTracker
16
+ from acp.helpers import (
17
+ audio_block,
18
+ embedded_blob_resource,
19
+ embedded_text_resource,
20
+ image_block,
21
+ resource_block,
22
+ resource_link_block,
23
+ text_block,
24
+ tool_content,
25
+ )
26
+ from acp.schema import ToolKind
27
+ from mcp.types import (
28
+ AudioContent,
29
+ BlobResourceContents,
30
+ EmbeddedResource,
31
+ ImageContent,
32
+ ResourceLink,
33
+ TextContent,
34
+ TextResourceContents,
35
+ )
36
+ from mcp.types import (
37
+ ContentBlock as MCPContentBlock,
38
+ )
39
+
40
+ from fast_agent.core.logging.logger import get_logger
41
+ from fast_agent.mcp.common import get_resource_name, get_server_name, is_namespaced_name
42
+
43
+ if TYPE_CHECKING:
44
+ from acp import AgentSideConnection
45
+
46
+ logger = get_logger(__name__)
47
+
48
+
49
+ class ACPToolProgressManager:
50
+ """
51
+ Manages tool call progress notifications for ACP clients.
52
+
53
+ Implements the ToolExecutionHandler protocol to provide lifecycle hooks
54
+ for tool execution. Sends sessionUpdate notifications to ACP clients as
55
+ tools execute and report progress.
56
+
57
+ Uses the SDK's ToolCallTracker for state management and notification generation.
58
+ """
59
+
60
+ def __init__(self, connection: "AgentSideConnection", session_id: str) -> None:
61
+ """
62
+ Initialize the progress manager.
63
+
64
+ Args:
65
+ connection: The ACP connection to send notifications on
66
+ session_id: The ACP session ID for this manager
67
+ """
68
+ self._connection = connection
69
+ self._session_id = session_id
70
+ # Use SDK's ToolCallTracker for state management
71
+ self._tracker = ToolCallTracker()
72
+ # Map ACP tool_call_id → external_id for reverse lookups
73
+ self._tool_call_id_to_external_id: dict[str, str] = {}
74
+ # Map tool_call_id → simple title (server/tool) for progress updates
75
+ self._simple_titles: dict[str, str] = {}
76
+ # Map tool_call_id → full title (with args) for completion
77
+ self._full_titles: dict[str, str] = {}
78
+ # Track tool_use_id from stream events to avoid duplicate notifications
79
+ self._stream_tool_use_ids: dict[str, str] = {} # tool_use_id → external_id
80
+ # Track pending stream notification tasks
81
+ self._stream_tasks: dict[str, asyncio.Task] = {} # tool_use_id → task
82
+ # Track stream chunk counts for title updates
83
+ self._stream_chunk_counts: dict[str, int] = {} # tool_use_id → chunk count
84
+ # Track base titles for streaming tools (before chunk count suffix)
85
+ self._stream_base_titles: dict[str, str] = {} # tool_use_id → base title
86
+ self._lock = asyncio.Lock()
87
+
88
+ async def get_tool_call_id_for_tool_use(self, tool_use_id: str) -> str | None:
89
+ """
90
+ Get the ACP toolCallId for a given LLM tool_use_id.
91
+
92
+ This is used by the permission handler to ensure the permission request
93
+ references the same toolCallId as any existing streaming notification.
94
+
95
+ Args:
96
+ tool_use_id: The LLM's tool use ID
97
+
98
+ Returns:
99
+ The ACP toolCallId if a streaming notification was already sent, None otherwise
100
+ """
101
+ # Check if there's a pending stream notification task for this tool_use_id
102
+ # If so, wait for it to complete so the toolCallId is available
103
+ task = self._stream_tasks.get(tool_use_id)
104
+ if task and not task.done():
105
+ try:
106
+ await task
107
+ except Exception:
108
+ pass # Ignore errors, just ensure task completed
109
+
110
+ # Now look up the toolCallId
111
+ external_id = self._stream_tool_use_ids.get(tool_use_id)
112
+ if external_id:
113
+ # Look up the toolCallId from the tracker
114
+ async with self._lock:
115
+ try:
116
+ model = self._tracker.tool_call_model(external_id)
117
+ if model and hasattr(model, "toolCallId"):
118
+ return model.toolCallId
119
+ except Exception:
120
+ # Swallow and fall back to local mapping
121
+ pass
122
+ # Fallback: check our own mapping
123
+ for tool_call_id, ext_id in self._tool_call_id_to_external_id.items():
124
+ if ext_id == external_id:
125
+ return tool_call_id
126
+ return None
127
+
128
+ def handle_tool_stream_event(self, event_type: str, info: dict[str, Any] | None = None) -> None:
129
+ """
130
+ Handle tool stream events from the LLM during streaming.
131
+
132
+ This gets called when the LLM streams tool use blocks, BEFORE tool execution.
133
+ Sends early ACP notifications so clients see tool calls immediately.
134
+
135
+ Args:
136
+ event_type: Type of stream event ("start", "delta", "text", "stop")
137
+ info: Event payload containing tool_name, tool_use_id, etc.
138
+ """
139
+ if event_type == "start" and info:
140
+ tool_name = info.get("tool_name")
141
+ tool_use_id = info.get("tool_use_id")
142
+
143
+ if tool_name and tool_use_id:
144
+ # Generate external_id SYNCHRONOUSLY to avoid race with delta events
145
+ external_id = str(uuid.uuid4())
146
+ self._stream_tool_use_ids[tool_use_id] = external_id
147
+
148
+ # Schedule async notification sending and store the task
149
+ task = asyncio.create_task(
150
+ self._send_stream_start_notification(tool_name, tool_use_id, external_id)
151
+ )
152
+ # Store task reference so we can await it in on_tool_start if needed
153
+ self._stream_tasks[tool_use_id] = task
154
+
155
+ elif event_type == "delta" and info:
156
+ tool_use_id = info.get("tool_use_id")
157
+ chunk = info.get("chunk")
158
+
159
+ if tool_use_id and chunk:
160
+ # Schedule async notification with accumulated arguments
161
+ asyncio.create_task(self._send_stream_delta_notification(tool_use_id, chunk))
162
+
163
+ async def _send_stream_start_notification(
164
+ self, tool_name: str, tool_use_id: str, external_id: str
165
+ ) -> None:
166
+ """
167
+ Send early ACP notification when tool stream starts.
168
+
169
+ Args:
170
+ tool_name: Name of the tool being called (may be namespaced like "server__tool")
171
+ tool_use_id: LLM's tool use ID
172
+ external_id: Pre-generated external ID for SDK tracker
173
+ """
174
+ try:
175
+ # Parse the tool name if it's namespaced (e.g., "acp_filesystem__write_text_file")
176
+ if is_namespaced_name(tool_name):
177
+ server_name = get_server_name(tool_name)
178
+ base_tool_name = get_resource_name(tool_name)
179
+ else:
180
+ server_name = None
181
+ base_tool_name = tool_name
182
+
183
+ # Infer tool kind (without arguments yet)
184
+ kind = self._infer_tool_kind(base_tool_name, None)
185
+
186
+ # Create title with server name if available
187
+ if server_name:
188
+ title = f"{server_name}/{base_tool_name}"
189
+ else:
190
+ title = base_tool_name
191
+
192
+ # Use SDK tracker to create the tool call start notification
193
+ async with self._lock:
194
+ tool_call_start = self._tracker.start(
195
+ external_id=external_id,
196
+ title=title,
197
+ kind=kind,
198
+ status="pending",
199
+ raw_input=None, # Don't have args yet
200
+ )
201
+ # Store mapping from ACP tool_call_id to external_id
202
+ self._tool_call_id_to_external_id[tool_call_start.toolCallId] = external_id
203
+ # Initialize streaming state for this tool
204
+ self._stream_base_titles[tool_use_id] = title
205
+ self._stream_chunk_counts[tool_use_id] = 0
206
+
207
+ # Send initial notification
208
+ await self._connection.session_update(
209
+ session_id=self._session_id, update=tool_call_start
210
+ )
211
+
212
+ logger.debug(
213
+ f"Sent early stream tool call notification: {tool_call_start.toolCallId}",
214
+ name="acp_tool_stream_start",
215
+ tool_call_id=tool_call_start.toolCallId,
216
+ external_id=external_id,
217
+ base_tool_name=base_tool_name,
218
+ server_name=server_name,
219
+ tool_use_id=tool_use_id,
220
+ )
221
+ except Exception as e:
222
+ logger.error(
223
+ f"Error sending stream tool_call notification: {e}",
224
+ name="acp_tool_stream_error",
225
+ exc_info=True,
226
+ )
227
+ finally:
228
+ # Clean up task reference
229
+ if tool_use_id in self._stream_tasks:
230
+ del self._stream_tasks[tool_use_id]
231
+
232
+ async def _send_stream_delta_notification(self, tool_use_id: str, chunk: str) -> None:
233
+ """
234
+ Send ACP notification with tool argument chunk as it streams.
235
+
236
+ Accumulates chunks into content and updates title with chunk count.
237
+
238
+ Args:
239
+ tool_use_id: LLM's tool use ID
240
+ chunk: JSON fragment chunk
241
+ """
242
+ try:
243
+ async with self._lock:
244
+ external_id = self._stream_tool_use_ids.get(tool_use_id)
245
+ if not external_id:
246
+ # No start notification sent yet, skip this chunk
247
+ return
248
+
249
+ # Increment chunk count and build title with count
250
+ self._stream_chunk_counts[tool_use_id] = (
251
+ self._stream_chunk_counts.get(tool_use_id, 0) + 1
252
+ )
253
+ chunk_count = self._stream_chunk_counts[tool_use_id]
254
+ base_title = self._stream_base_titles.get(tool_use_id, "Tool")
255
+ title_with_count = f"{base_title} (streaming: {chunk_count} chunks)"
256
+
257
+ # Use SDK's append_stream_text to accumulate chunks into content
258
+ update = self._tracker.append_stream_text(
259
+ external_id=external_id,
260
+ text=chunk,
261
+ title=title_with_count,
262
+ )
263
+
264
+ # Only send notifications after 25 chunks to avoid UI noise for small calls
265
+ if chunk_count < 25:
266
+ return
267
+
268
+ # Send notification outside the lock
269
+ await self._connection.session_update(session_id=self._session_id, update=update)
270
+
271
+ except Exception as e:
272
+ logger.debug(
273
+ f"Error sending stream delta notification: {e}",
274
+ name="acp_tool_stream_delta_error",
275
+ tool_use_id=tool_use_id,
276
+ )
277
+
278
+ # Tool kind patterns: mapping from ToolKind to keyword patterns
279
+ _TOOL_KIND_PATTERNS: dict[ToolKind, tuple[str, ...]] = {
280
+ "read": ("read", "get", "fetch", "list", "show"),
281
+ "edit": ("write", "edit", "update", "modify", "patch"),
282
+ "delete": ("delete", "remove", "clear", "clean", "rm"),
283
+ "move": ("move", "rename", "mv"),
284
+ "search": ("search", "find", "query", "grep"),
285
+ "execute": ("execute", "run", "exec", "command", "bash", "shell"),
286
+ "think": ("think", "plan", "reason"),
287
+ "fetch": ("fetch", "download", "http", "request"),
288
+ }
289
+
290
+ def _infer_tool_kind(self, tool_name: str, arguments: dict[str, Any] | None) -> ToolKind:
291
+ """
292
+ Infer the tool kind from the tool name and arguments.
293
+
294
+ Args:
295
+ tool_name: Name of the tool being called
296
+ arguments: Tool arguments (reserved for future use)
297
+
298
+ Returns:
299
+ The inferred ToolKind
300
+ """
301
+ name_lower = tool_name.lower()
302
+
303
+ for kind, patterns in self._TOOL_KIND_PATTERNS.items():
304
+ if any(pattern in name_lower for pattern in patterns):
305
+ return kind
306
+
307
+ return "other"
308
+
309
+ def _convert_mcp_content_to_acp(self, content: list[MCPContentBlock] | None) -> list | None:
310
+ """
311
+ Convert MCP content blocks to ACP tool call content using SDK helpers.
312
+
313
+ Args:
314
+ content: List of MCP content blocks (TextContent, ImageContent, etc.)
315
+
316
+ Returns:
317
+ List of ContentToolCallContent blocks, or None if no content
318
+ """
319
+ if not content:
320
+ return None
321
+
322
+ acp_content = []
323
+
324
+ for block in content:
325
+ try:
326
+ match block:
327
+ case TextContent():
328
+ acp_content.append(tool_content(text_block(block.text)))
329
+
330
+ case ImageContent():
331
+ acp_content.append(tool_content(image_block(block.data, block.mimeType)))
332
+
333
+ case AudioContent():
334
+ acp_content.append(tool_content(audio_block(block.data, block.mimeType)))
335
+
336
+ case ResourceLink():
337
+ # Use URI as the name for resource links
338
+ acp_content.append(
339
+ tool_content(
340
+ resource_link_block(
341
+ name=str(block.uri),
342
+ uri=str(block.uri),
343
+ mime_type=getattr(block, "mimeType", None),
344
+ )
345
+ )
346
+ )
347
+
348
+ case EmbeddedResource():
349
+ # Use SDK's resource_block helper with embedded resource contents
350
+ match block.resource:
351
+ case TextResourceContents():
352
+ embedded_res = embedded_text_resource(
353
+ uri=str(block.resource.uri),
354
+ text=block.resource.text,
355
+ mime_type=block.resource.mimeType,
356
+ )
357
+ case BlobResourceContents():
358
+ embedded_res = embedded_blob_resource(
359
+ uri=str(block.resource.uri),
360
+ blob=block.resource.blob,
361
+ mime_type=block.resource.mimeType,
362
+ )
363
+ case _:
364
+ continue # Skip unsupported resource types
365
+ acp_content.append(tool_content(resource_block(embedded_res)))
366
+
367
+ case _:
368
+ logger.warning(
369
+ f"Unknown content type: {type(block).__name__}",
370
+ name="acp_unknown_content_type",
371
+ )
372
+ except Exception as e:
373
+ logger.error(
374
+ f"Error converting content block {type(block).__name__}: {e}",
375
+ name="acp_content_conversion_error",
376
+ exc_info=True,
377
+ )
378
+
379
+ return acp_content if acp_content else None
380
+
381
+ async def on_tool_start(
382
+ self,
383
+ tool_name: str,
384
+ server_name: str,
385
+ arguments: dict[str, Any] | None = None,
386
+ tool_use_id: str | None = None,
387
+ ) -> str:
388
+ """
389
+ Called when a tool execution starts.
390
+
391
+ Implements ToolExecutionHandler.on_tool_start protocol method.
392
+
393
+ Args:
394
+ tool_name: Name of the tool being called
395
+ server_name: Name of the MCP server providing the tool
396
+ arguments: Tool arguments
397
+ tool_use_id: LLM's tool use ID (for matching with stream events)
398
+
399
+ Returns:
400
+ The tool call ID for tracking
401
+ """
402
+ # Check if we already sent a stream notification for this tool_use_id
403
+ existing_external_id = None
404
+ if tool_use_id:
405
+ # If there's a pending stream task, await it first
406
+ pending_task = self._stream_tasks.get(tool_use_id)
407
+ if pending_task and not pending_task.done():
408
+ logger.debug(
409
+ f"Waiting for pending stream notification task to complete: {tool_use_id}",
410
+ name="acp_tool_await_stream_task",
411
+ tool_use_id=tool_use_id,
412
+ )
413
+ try:
414
+ await pending_task
415
+ except Exception as e:
416
+ logger.warning(
417
+ f"Stream notification task failed: {e}",
418
+ name="acp_stream_task_failed",
419
+ tool_use_id=tool_use_id,
420
+ exc_info=True,
421
+ )
422
+
423
+ async with self._lock:
424
+ existing_external_id = self._stream_tool_use_ids.get(tool_use_id)
425
+ if existing_external_id:
426
+ logger.debug(
427
+ f"Found existing stream notification for tool_use_id: {tool_use_id}",
428
+ name="acp_tool_execution_match",
429
+ tool_use_id=tool_use_id,
430
+ external_id=existing_external_id,
431
+ )
432
+ else:
433
+ logger.debug(
434
+ f"No stream notification found for tool_use_id: {tool_use_id}",
435
+ name="acp_tool_execution_no_match",
436
+ tool_use_id=tool_use_id,
437
+ available_ids=list(self._stream_tool_use_ids.keys()),
438
+ )
439
+
440
+ # Infer tool kind
441
+ kind = self._infer_tool_kind(tool_name, arguments)
442
+
443
+ # Create title
444
+ title = f"{server_name}/{tool_name}"
445
+ if arguments:
446
+ # Include key argument info in title
447
+ arg_str = ", ".join(f"{k}={v}" for k, v in list(arguments.items())[:2])
448
+ if len(arg_str) > 50:
449
+ arg_str = arg_str[:47] + "..."
450
+ title = f"{title}({arg_str})"
451
+
452
+ # Use SDK tracker to create or update the tool call notification
453
+ async with self._lock:
454
+ if existing_external_id:
455
+ # Get final chunk count before clearing
456
+ final_chunk_count = self._stream_chunk_counts.get(tool_use_id or "", 0)
457
+
458
+ # Update title with streamed count only if we showed streaming progress
459
+ if final_chunk_count >= 25:
460
+ title = f"{title} (streamed {final_chunk_count} chunks)"
461
+
462
+ # Update the existing stream notification with full details
463
+ # Clear streaming content by setting content=[] since we now have full rawInput
464
+ tool_call_update = self._tracker.progress(
465
+ external_id=existing_external_id,
466
+ title=title, # Update with server_name and args
467
+ kind=kind, # Re-infer with arguments
468
+ status="in_progress", # Move from pending to in_progress
469
+ raw_input=arguments, # Add complete arguments
470
+ content=[], # Clear streaming content
471
+ )
472
+ tool_call_id = tool_call_update.toolCallId
473
+
474
+ # Ensure mapping exists - progress() may return different ID than start()
475
+ # or the stream notification task may not have stored it yet
476
+ self._tool_call_id_to_external_id[tool_call_id] = existing_external_id
477
+ # Store simple title (server/tool) for progress updates - no args
478
+ self._simple_titles[tool_call_id] = f"{server_name}/{tool_name}"
479
+ # Store full title (with args) for completion
480
+ self._full_titles[tool_call_id] = title
481
+
482
+ # Clean up streaming state since we're now in execution
483
+ if tool_use_id:
484
+ self._stream_chunk_counts.pop(tool_use_id, None)
485
+ self._stream_base_titles.pop(tool_use_id, None)
486
+ self._stream_tool_use_ids.pop(tool_use_id, None)
487
+
488
+ logger.debug(
489
+ f"Updated stream tool call with execution details: {tool_call_id}",
490
+ name="acp_tool_execution_update",
491
+ tool_call_id=tool_call_id,
492
+ external_id=existing_external_id,
493
+ tool_name=tool_name,
494
+ server_name=server_name,
495
+ tool_use_id=tool_use_id,
496
+ )
497
+ else:
498
+ # No stream notification - create new one (normal path)
499
+ external_id = str(uuid.uuid4())
500
+ tool_call_start = self._tracker.start(
501
+ external_id=external_id,
502
+ title=title,
503
+ kind=kind,
504
+ status="pending",
505
+ raw_input=arguments,
506
+ )
507
+ # Store mapping from ACP tool_call_id to external_id for later lookups
508
+ self._tool_call_id_to_external_id[tool_call_start.toolCallId] = external_id
509
+ tool_call_id = tool_call_start.toolCallId
510
+ tool_call_update = tool_call_start
511
+ # Store simple title (server/tool) for progress updates - no args
512
+ self._simple_titles[tool_call_id] = f"{server_name}/{tool_name}"
513
+ # Store full title (with args) for completion
514
+ self._full_titles[tool_call_id] = title
515
+
516
+ logger.debug(
517
+ f"Started tool call tracking: {tool_call_id}",
518
+ name="acp_tool_call_start",
519
+ tool_call_id=tool_call_id,
520
+ external_id=external_id,
521
+ tool_name=tool_name,
522
+ server_name=server_name,
523
+ )
524
+
525
+ # Send notification (either new start or update)
526
+ try:
527
+ await self._connection.session_update(
528
+ session_id=self._session_id, update=tool_call_update
529
+ )
530
+ except Exception as e:
531
+ logger.error(
532
+ f"Error sending tool_call notification: {e}",
533
+ name="acp_tool_call_error",
534
+ exc_info=True,
535
+ )
536
+
537
+ # Return the ACP tool_call_id for caller to track
538
+ return tool_call_id
539
+
540
+ async def on_tool_permission_denied(
541
+ self,
542
+ tool_name: str,
543
+ server_name: str,
544
+ tool_use_id: str | None,
545
+ error: str | None = None,
546
+ ) -> None:
547
+ """
548
+ Called when tool execution is denied before it starts.
549
+
550
+ Uses any pending stream-start notification to mark the call as failed
551
+ so ACP clients see the cancellation/denial.
552
+ """
553
+ if not tool_use_id:
554
+ return
555
+
556
+ # Wait for any pending stream notification to finish
557
+ pending_task = self._stream_tasks.get(tool_use_id)
558
+ if pending_task and not pending_task.done():
559
+ try:
560
+ await pending_task
561
+ except Exception as e: # noqa: BLE001
562
+ logger.warning(
563
+ f"Stream notification task failed for denied tool: {e}",
564
+ name="acp_permission_denied_stream_task_failed",
565
+ tool_use_id=tool_use_id,
566
+ exc_info=True,
567
+ )
568
+
569
+ async with self._lock:
570
+ external_id = self._stream_tool_use_ids.get(tool_use_id)
571
+
572
+ if not external_id:
573
+ # No stream notification; nothing to update
574
+ return
575
+
576
+ try:
577
+ update_data = self._tracker.progress(
578
+ external_id=external_id,
579
+ status="failed",
580
+ content=[tool_content(text_block(error))] if error else None,
581
+ )
582
+ except Exception as e: # noqa: BLE001
583
+ logger.error(
584
+ f"Error creating permission-denied update: {e}",
585
+ name="acp_permission_denied_update_error",
586
+ exc_info=True,
587
+ )
588
+ return
589
+
590
+ # Send the failure notification
591
+ try:
592
+ await self._connection.session_update(session_id=self._session_id, update=update_data)
593
+ except Exception as e: # noqa: BLE001
594
+ logger.error(
595
+ f"Error sending permission-denied notification: {e}",
596
+ name="acp_permission_denied_notification_error",
597
+ exc_info=True,
598
+ )
599
+ finally:
600
+ # Clean up tracker and mappings
601
+ async with self._lock:
602
+ self._tracker.forget(external_id)
603
+ self._stream_tool_use_ids.pop(tool_use_id, None)
604
+ self._stream_chunk_counts.pop(tool_use_id, None)
605
+ self._stream_base_titles.pop(tool_use_id, None)
606
+
607
+ async def on_tool_progress(
608
+ self,
609
+ tool_call_id: str,
610
+ progress: float,
611
+ total: float | None = None,
612
+ message: str | None = None,
613
+ ) -> None:
614
+ """
615
+ Called when tool execution reports progress.
616
+
617
+ Implements ToolExecutionHandler.on_tool_progress protocol method.
618
+ Updates the title with progress percentage and/or message.
619
+
620
+ Args:
621
+ tool_call_id: The tool call ID
622
+ progress: Current progress value
623
+ total: Total value for progress calculation (optional)
624
+ message: Optional progress message
625
+ """
626
+ # Look up external_id from tool_call_id
627
+ async with self._lock:
628
+ external_id = self._tool_call_id_to_external_id.get(tool_call_id)
629
+ if not external_id:
630
+ logger.warning(
631
+ f"Tool call {tool_call_id} not found for progress update",
632
+ name="acp_tool_progress_not_found",
633
+ )
634
+ return
635
+
636
+ # Build updated title with progress info (using simple title without args)
637
+ simple_title = self._simple_titles.get(tool_call_id, "Tool")
638
+ title_parts = [simple_title]
639
+
640
+ # Add progress indicator
641
+ if total is not None and total > 0:
642
+ # Show progress/total format (e.g., [50/100])
643
+ title_parts.append(f"[{progress:.0f}/{total:.0f}]")
644
+ else:
645
+ # Show just progress value (e.g., [50])
646
+ title_parts.append(f"[{progress:.0f}]")
647
+
648
+ # Add message if present
649
+ if message:
650
+ title_parts.append(f"- {message}")
651
+
652
+ updated_title = " ".join(title_parts)
653
+
654
+ # Use SDK tracker to create progress update with updated title
655
+ # Note: We don't include content since the title now shows the progress message
656
+ try:
657
+ update_data = self._tracker.progress(
658
+ external_id=external_id,
659
+ status="in_progress",
660
+ title=updated_title,
661
+ )
662
+ except Exception as e:
663
+ logger.error(
664
+ f"Error creating progress update: {e}",
665
+ name="acp_progress_creation_error",
666
+ exc_info=True,
667
+ )
668
+ return
669
+
670
+ # Send progress update
671
+ try:
672
+ await self._connection.session_update(session_id=self._session_id, update=update_data)
673
+
674
+ logger.debug(
675
+ f"Updated tool call progress: {tool_call_id}",
676
+ name="acp_tool_progress_update",
677
+ progress=progress,
678
+ total=total,
679
+ progress_message=message,
680
+ title=updated_title,
681
+ )
682
+ except Exception as e:
683
+ logger.error(
684
+ f"Error sending tool_call_update notification: {e}",
685
+ name="acp_tool_progress_error",
686
+ exc_info=True,
687
+ )
688
+
689
+ async def on_tool_complete(
690
+ self,
691
+ tool_call_id: str,
692
+ success: bool,
693
+ content: list[MCPContentBlock] | None = None,
694
+ error: str | None = None,
695
+ ) -> None:
696
+ """
697
+ Called when tool execution completes.
698
+
699
+ Implements ToolExecutionHandler.on_tool_complete protocol method.
700
+
701
+ Args:
702
+ tool_call_id: The tool call ID
703
+ success: Whether the tool execution succeeded
704
+ content: Optional content blocks (text, images, etc.) if successful
705
+ error: Optional error message if failed
706
+ """
707
+ # Look up external_id from tool_call_id
708
+ async with self._lock:
709
+ external_id = self._tool_call_id_to_external_id.get(tool_call_id)
710
+ if not external_id:
711
+ logger.warning(
712
+ f"Tool call {tool_call_id} not found for completion",
713
+ name="acp_tool_complete_not_found",
714
+ )
715
+ return
716
+
717
+ # Build content blocks
718
+ logger.debug(
719
+ f"on_tool_complete called: {tool_call_id}",
720
+ name="acp_tool_complete_entry",
721
+ success=success,
722
+ has_content=content is not None,
723
+ content_types=[type(c).__name__ for c in (content or [])],
724
+ has_error=error is not None,
725
+ )
726
+
727
+ if error:
728
+ # Error case: convert error string to text content using SDK helper
729
+ content_blocks = [tool_content(text_block(error))]
730
+ raw_output = error
731
+ elif content:
732
+ # Success case with structured content: convert MCP content to ACP using SDK helpers
733
+ content_blocks = self._convert_mcp_content_to_acp(content)
734
+ # For rawOutput, extract just text content for backward compatibility
735
+ text_parts = [c.text for c in content if isinstance(c, TextContent)]
736
+ raw_output = "\n".join(text_parts) if text_parts else None
737
+ else:
738
+ # No content or error
739
+ content_blocks = None
740
+ raw_output = None
741
+
742
+ # Determine status
743
+ status = "completed" if success else "failed"
744
+
745
+ # Use SDK tracker to create completion update
746
+ try:
747
+ async with self._lock:
748
+ # Restore full title with parameters for completion
749
+ full_title = self._full_titles.get(tool_call_id)
750
+ update_data = self._tracker.progress(
751
+ external_id=external_id,
752
+ status=status,
753
+ title=full_title, # Restore original title with args
754
+ content=content_blocks,
755
+ raw_output=raw_output,
756
+ )
757
+ except Exception as e:
758
+ logger.error(
759
+ f"Error creating completion update: {e}",
760
+ name="acp_completion_creation_error",
761
+ exc_info=True,
762
+ )
763
+ return
764
+
765
+ # Send completion notification
766
+ try:
767
+ await self._connection.session_update(session_id=self._session_id, update=update_data)
768
+
769
+ logger.info(
770
+ f"Completed tool call: {tool_call_id}",
771
+ name="acp_tool_call_complete",
772
+ status=status,
773
+ content_blocks=len(content_blocks) if content_blocks else 0,
774
+ )
775
+ except Exception as e:
776
+ logger.error(
777
+ f"Error sending tool_call completion notification: {e}",
778
+ name="acp_tool_complete_error",
779
+ exc_info=True,
780
+ )
781
+ finally:
782
+ # Clean up tracker using SDK's forget method
783
+ async with self._lock:
784
+ self._tracker.forget(external_id)
785
+ self._tool_call_id_to_external_id.pop(tool_call_id, None)
786
+ self._simple_titles.pop(tool_call_id, None)
787
+ self._full_titles.pop(tool_call_id, None)
788
+
789
+ async def cleanup_session_tools(self, session_id: str) -> None:
790
+ """
791
+ Clean up all tool trackers for a session.
792
+
793
+ Args:
794
+ session_id: The session ID to clean up
795
+ """
796
+ # The SDK tracker doesn't maintain session associations,
797
+ # so we just clear our mapping
798
+ async with self._lock:
799
+ count = len(self._tool_call_id_to_external_id)
800
+ # Forget all tracked tools
801
+ tracker_calls = getattr(self._tracker, "_calls", {})
802
+ for external_id in list(tracker_calls.keys()):
803
+ self._tracker.forget(external_id)
804
+ self._tool_call_id_to_external_id.clear()
805
+ self._simple_titles.clear()
806
+ self._full_titles.clear()
807
+ self._stream_tool_use_ids.clear()
808
+ self._stream_chunk_counts.clear()
809
+ self._stream_base_titles.clear()
810
+
811
+ logger.debug(
812
+ f"Cleaned up {count} tool trackers for session {session_id}",
813
+ name="acp_tool_cleanup",
814
+ )