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,1472 @@
1
+ """
2
+ AgentACPServer - Exposes FastAgent agents via the Agent Client Protocol (ACP).
3
+
4
+ This implementation allows fast-agent to act as an ACP agent, enabling editors
5
+ and other clients to interact with fast-agent agents over stdio using the ACP protocol.
6
+ """
7
+
8
+ import asyncio
9
+ import uuid
10
+ from dataclasses import dataclass, field
11
+ from importlib.metadata import version as get_version
12
+ from typing import Any, Awaitable, Callable
13
+
14
+ from acp import Agent as ACPAgent
15
+ from acp import (
16
+ Client,
17
+ InitializeResponse,
18
+ NewSessionResponse,
19
+ PromptResponse,
20
+ SetSessionModeResponse,
21
+ run_agent,
22
+ )
23
+ from acp import (
24
+ Client as ACPClient,
25
+ )
26
+ from acp.helpers import (
27
+ ContentBlock as ACPContentBlock,
28
+ )
29
+ from acp.helpers import (
30
+ update_agent_message_text,
31
+ update_agent_thought_text,
32
+ )
33
+ from acp.schema import (
34
+ AgentCapabilities,
35
+ AvailableCommandsUpdate,
36
+ ClientCapabilities,
37
+ HttpMcpServer,
38
+ Implementation,
39
+ McpServerStdio,
40
+ PromptCapabilities,
41
+ SessionMode,
42
+ SessionModeState,
43
+ SseMcpServer,
44
+ StopReason,
45
+ )
46
+
47
+ from fast_agent.acp.acp_context import ACPContext, ClientInfo
48
+ from fast_agent.acp.acp_context import ClientCapabilities as FAClientCapabilities
49
+ from fast_agent.acp.content_conversion import convert_acp_prompt_to_mcp_content_blocks
50
+ from fast_agent.acp.filesystem_runtime import ACPFilesystemRuntime
51
+ from fast_agent.acp.permission_store import PermissionStore
52
+ from fast_agent.acp.slash_commands import SlashCommandHandler
53
+ from fast_agent.acp.terminal_runtime import ACPTerminalRuntime
54
+ from fast_agent.acp.tool_permission_adapter import ACPToolPermissionAdapter
55
+ from fast_agent.acp.tool_progress import ACPToolProgressManager
56
+ from fast_agent.constants import (
57
+ DEFAULT_TERMINAL_OUTPUT_BYTE_LIMIT,
58
+ MAX_TERMINAL_OUTPUT_BYTE_LIMIT,
59
+ TERMINAL_AVG_BYTES_PER_TOKEN,
60
+ TERMINAL_OUTPUT_TOKEN_HEADROOM_RATIO,
61
+ TERMINAL_OUTPUT_TOKEN_RATIO,
62
+ )
63
+ from fast_agent.core.fastagent import AgentInstance
64
+ from fast_agent.core.logging.logger import get_logger
65
+ from fast_agent.core.prompt_templates import (
66
+ apply_template_variables,
67
+ enrich_with_environment_context,
68
+ )
69
+ from fast_agent.interfaces import ACPAwareProtocol, StreamingAgentProtocol
70
+ from fast_agent.llm.model_database import ModelDatabase
71
+ from fast_agent.llm.stream_types import StreamChunk
72
+ from fast_agent.mcp.helpers.content_helpers import is_text_content
73
+ from fast_agent.types import LlmStopReason, PromptMessageExtended, RequestParams
74
+ from fast_agent.workflow_telemetry import ACPPlanTelemetryProvider, ToolHandlerWorkflowTelemetry
75
+
76
+ logger = get_logger(__name__)
77
+
78
+ END_TURN: StopReason = "end_turn"
79
+ REFUSAL: StopReason = "refusal"
80
+ MAX_TOKENS: StopReason = "max_tokens"
81
+ CANCELLED: StopReason = "cancelled"
82
+
83
+
84
+ def map_llm_stop_reason_to_acp(llm_stop_reason: LlmStopReason | None) -> StopReason:
85
+ """
86
+ Map fast-agent LlmStopReason to ACP StopReason.
87
+
88
+ Args:
89
+ llm_stop_reason: The stop reason from the LLM response
90
+
91
+ Returns:
92
+ The corresponding ACP StopReason value
93
+ """
94
+ if llm_stop_reason is None:
95
+ return END_TURN
96
+
97
+ # Use string keys to avoid hashing Enum members with custom equality logic
98
+ key = (
99
+ llm_stop_reason.value
100
+ if isinstance(llm_stop_reason, LlmStopReason)
101
+ else str(llm_stop_reason)
102
+ )
103
+ mapping: dict[str, StopReason] = {
104
+ LlmStopReason.END_TURN.value: END_TURN,
105
+ LlmStopReason.STOP_SEQUENCE.value: END_TURN, # Normal completion
106
+ LlmStopReason.MAX_TOKENS.value: MAX_TOKENS,
107
+ LlmStopReason.TOOL_USE.value: END_TURN, # Tool use is normal completion in ACP
108
+ LlmStopReason.PAUSE.value: END_TURN, # Pause is treated as normal completion
109
+ LlmStopReason.ERROR.value: REFUSAL, # Errors are mapped to refusal
110
+ LlmStopReason.TIMEOUT.value: REFUSAL, # Timeouts are mapped to refusal
111
+ LlmStopReason.SAFETY.value: REFUSAL, # Safety triggers are mapped to refusal
112
+ LlmStopReason.CANCELLED.value: CANCELLED, # User cancellation
113
+ }
114
+
115
+ return mapping.get(key, END_TURN)
116
+
117
+
118
+ def format_agent_name_as_title(agent_name: str) -> str:
119
+ """
120
+ Format agent name as title case for display.
121
+
122
+ Examples:
123
+ code_expert -> Code Expert
124
+ general_assistant -> General Assistant
125
+
126
+ Args:
127
+ agent_name: The agent name (typically snake_case)
128
+
129
+ Returns:
130
+ Title-cased version of the name
131
+ """
132
+ return agent_name.replace("_", " ").title()
133
+
134
+
135
+ @dataclass
136
+ class ACPSessionState:
137
+ """Aggregated per-session ACP state for easier lifecycle management."""
138
+
139
+ session_id: str
140
+ instance: AgentInstance
141
+ current_agent_name: str | None = None
142
+ progress_manager: ACPToolProgressManager | None = None
143
+ permission_handler: ACPToolPermissionAdapter | None = None
144
+ terminal_runtime: ACPTerminalRuntime | None = None
145
+ filesystem_runtime: ACPFilesystemRuntime | None = None
146
+ slash_handler: SlashCommandHandler | None = None
147
+ acp_context: ACPContext | None = None
148
+ prompt_context: dict[str, str] = field(default_factory=dict)
149
+ resolved_instructions: dict[str, str] = field(default_factory=dict)
150
+
151
+
152
+ def truncate_description(text: str, max_length: int = 200) -> str:
153
+ """
154
+ Truncate text to a maximum length, taking the first line only.
155
+
156
+ Args:
157
+ text: The text to truncate
158
+ max_length: Maximum length (default 200 chars per spec)
159
+
160
+ Returns:
161
+ Truncated text
162
+ """
163
+ # Take first line only
164
+ first_line = text.split("\n")[0]
165
+ # Truncate to max length
166
+ if len(first_line) > max_length:
167
+ return first_line[:max_length]
168
+ return first_line
169
+
170
+
171
+ class AgentACPServer(ACPAgent):
172
+ """
173
+ Exposes FastAgent agents as an ACP agent through stdio.
174
+
175
+ This server:
176
+ - Handles ACP connection initialization and capability negotiation
177
+ - Manages sessions (maps sessionId to AgentInstance)
178
+ - Routes prompts to the appropriate fast-agent agent
179
+ - Returns responses in ACP format
180
+ """
181
+
182
+ def __init__(
183
+ self,
184
+ primary_instance: AgentInstance,
185
+ create_instance: Callable[[], Awaitable[AgentInstance]],
186
+ dispose_instance: Callable[[AgentInstance], Awaitable[None]],
187
+ instance_scope: str,
188
+ server_name: str = "fast-agent-acp",
189
+ server_version: str | None = None,
190
+ skills_directory_override: str | None = None,
191
+ permissions_enabled: bool = True,
192
+ ) -> None:
193
+ """
194
+ Initialize the ACP server.
195
+
196
+ Args:
197
+ primary_instance: The primary agent instance (used in shared mode)
198
+ create_instance: Factory function to create new agent instances
199
+ dispose_instance: Function to dispose of agent instances
200
+ instance_scope: How to scope instances ('shared', 'connection', or 'request')
201
+ server_name: Name of the server for capability advertisement
202
+ server_version: Version of the server (defaults to fast-agent version)
203
+ skills_directory_override: Optional skills directory override (relative to session cwd)
204
+ permissions_enabled: Whether to request tool permissions from client (default: True)
205
+ """
206
+ super().__init__()
207
+
208
+ self.primary_instance = primary_instance
209
+ self._create_instance_task = create_instance
210
+ self._dispose_instance_task = dispose_instance
211
+ self._instance_scope = instance_scope
212
+ self.server_name = server_name
213
+ self._skills_directory_override = skills_directory_override
214
+ self._permissions_enabled = permissions_enabled
215
+ # Use provided version or get fast-agent version
216
+ if server_version is None:
217
+ try:
218
+ server_version = get_version("fast-agent-mcp")
219
+ except Exception:
220
+ server_version = "unknown"
221
+ self.server_version = server_version
222
+
223
+ # Session management
224
+ self.sessions: dict[str, AgentInstance] = {}
225
+ self._session_lock = asyncio.Lock()
226
+
227
+ # Track sessions with active prompts to prevent overlapping requests (per ACP protocol)
228
+ self._active_prompts: set[str] = set()
229
+
230
+ # Track asyncio tasks per session for proper task-based cancellation
231
+ self._session_tasks: dict[str, asyncio.Task] = {}
232
+
233
+ # Aggregated per-session state
234
+ self._session_state: dict[str, ACPSessionState] = {}
235
+
236
+ # Connection reference (set during run_async)
237
+ self._connection: Client | None = None
238
+
239
+ # Client capabilities and info (set during initialize)
240
+ self._client_supports_terminal: bool = False
241
+ self._client_supports_fs_read: bool = False
242
+ self._client_supports_fs_write: bool = False
243
+ self._client_capabilities: dict | None = None
244
+ self._client_info: dict | None = None
245
+ self._protocol_version: int | None = None
246
+
247
+ # Parsed client capabilities and info for ACPContext
248
+ self._parsed_client_capabilities: FAClientCapabilities | None = None
249
+ self._parsed_client_info: ClientInfo | None = None
250
+
251
+ # Determine primary agent using FastAgent default flag when available
252
+ self.primary_agent_name = self._select_primary_agent(primary_instance)
253
+
254
+ logger.info(
255
+ "AgentACPServer initialized",
256
+ name="acp_server_initialized",
257
+ agent_count=len(primary_instance.agents),
258
+ instance_scope=instance_scope,
259
+ primary_agent=self.primary_agent_name,
260
+ )
261
+
262
+ def _calculate_terminal_output_limit(self, agent: Any) -> int:
263
+ """
264
+ Determine a default terminal output byte limit based on the agent's model.
265
+
266
+ Args:
267
+ agent: Agent instance that may expose an llm with model metadata.
268
+ """
269
+ # Some workflow agents (e.g., chain/parallel) don't attach an LLM directly.
270
+ llm = getattr(agent, "_llm", None)
271
+ model_name = getattr(llm, "model_name", None)
272
+ return self._calculate_terminal_output_limit_for_model(model_name)
273
+
274
+ @staticmethod
275
+ def _calculate_terminal_output_limit_for_model(model_name: str | None) -> int:
276
+ if not model_name:
277
+ return DEFAULT_TERMINAL_OUTPUT_BYTE_LIMIT
278
+
279
+ max_tokens = ModelDatabase.get_max_output_tokens(model_name)
280
+ if not max_tokens:
281
+ return DEFAULT_TERMINAL_OUTPUT_BYTE_LIMIT
282
+
283
+ terminal_token_budget = max(int(max_tokens * TERMINAL_OUTPUT_TOKEN_RATIO), 1)
284
+ terminal_token_budget = max(
285
+ int(terminal_token_budget * (1 - TERMINAL_OUTPUT_TOKEN_HEADROOM_RATIO)), 1
286
+ )
287
+ terminal_byte_budget = int(terminal_token_budget * TERMINAL_AVG_BYTES_PER_TOKEN)
288
+
289
+ terminal_byte_budget = min(terminal_byte_budget, MAX_TERMINAL_OUTPUT_BYTE_LIMIT)
290
+ return max(DEFAULT_TERMINAL_OUTPUT_BYTE_LIMIT, terminal_byte_budget)
291
+
292
+ async def initialize(
293
+ self,
294
+ protocol_version: int,
295
+ client_capabilities: ClientCapabilities | None = None,
296
+ client_info: Implementation | None = None,
297
+ **kwargs: Any,
298
+ ) -> InitializeResponse:
299
+ """
300
+ Handle ACP initialization request.
301
+
302
+ Negotiates protocol version and advertises capabilities.
303
+ """
304
+ try:
305
+ # Store protocol version
306
+ self._protocol_version = protocol_version
307
+
308
+ # Store client info
309
+ if client_info:
310
+ self._client_info = {
311
+ "name": getattr(client_info, "name", "unknown"),
312
+ "version": getattr(client_info, "version", "unknown"),
313
+ }
314
+ # Include title if available
315
+ if hasattr(client_info, "title"):
316
+ self._client_info["title"] = client_info.title
317
+
318
+ # Store client capabilities
319
+ if client_capabilities:
320
+ self._client_supports_terminal = bool(
321
+ getattr(client_capabilities, "terminal", False)
322
+ )
323
+
324
+ # Check for filesystem capabilities
325
+ if hasattr(client_capabilities, "fs"):
326
+ fs_caps = client_capabilities.fs
327
+ if fs_caps:
328
+ self._client_supports_fs_read = bool(
329
+ getattr(fs_caps, "readTextFile", False)
330
+ )
331
+ self._client_supports_fs_write = bool(
332
+ getattr(fs_caps, "writeTextFile", False)
333
+ )
334
+
335
+ # Convert capabilities to a dict for status reporting
336
+ self._client_capabilities = {}
337
+ if hasattr(client_capabilities, "fs"):
338
+ fs_caps = client_capabilities.fs
339
+ fs_capabilities = self._extract_fs_capabilities(fs_caps)
340
+ if fs_capabilities:
341
+ self._client_capabilities["fs"] = fs_capabilities
342
+
343
+ if hasattr(client_capabilities, "terminal") and client_capabilities.terminal:
344
+ self._client_capabilities["terminal"] = True
345
+
346
+ # Store _meta if present
347
+ if hasattr(client_capabilities, "_meta"):
348
+ meta = client_capabilities._meta
349
+ if meta:
350
+ self._client_capabilities["_meta"] = (
351
+ dict(meta) if isinstance(meta, dict) else {}
352
+ )
353
+
354
+ # Parse client capabilities and info for ACPContext
355
+ self._parsed_client_capabilities = FAClientCapabilities(
356
+ terminal=self._client_supports_terminal,
357
+ fs_read=self._client_supports_fs_read,
358
+ fs_write=self._client_supports_fs_write,
359
+ _meta=self._client_capabilities.get("_meta", {}) if self._client_capabilities else {},
360
+ )
361
+ self._parsed_client_info = ClientInfo.from_acp_info(client_info)
362
+
363
+ logger.info(
364
+ "ACP initialize request",
365
+ name="acp_initialize",
366
+ client_protocol=protocol_version,
367
+ client_info=client_info,
368
+ client_supports_terminal=self._client_supports_terminal,
369
+ client_supports_fs_read=self._client_supports_fs_read,
370
+ client_supports_fs_write=self._client_supports_fs_write,
371
+ )
372
+
373
+ # Build our capabilities
374
+ agent_capabilities = AgentCapabilities(
375
+ prompt_capabilities=PromptCapabilities(
376
+ image=True, # Support image content
377
+ embedded_context=True, # Support embedded resources
378
+ audio=False, # Don't support audio (yet)
379
+ ),
380
+ # We don't support loadSession yet
381
+ load_session=False,
382
+ )
383
+
384
+ # Build agent info using Implementation type
385
+ agent_info = Implementation(
386
+ name=self.server_name,
387
+ version=self.server_version,
388
+ )
389
+
390
+ response = InitializeResponse(
391
+ protocol_version=protocol_version, # Echo back the client's version
392
+ agent_capabilities=agent_capabilities,
393
+ agent_info=agent_info,
394
+ auth_methods=[], # No authentication for now
395
+ )
396
+
397
+ logger.info(
398
+ "ACP initialize response sent",
399
+ name="acp_initialize_response",
400
+ protocol_version=response.protocolVersion,
401
+ )
402
+
403
+ return response
404
+ except Exception as e:
405
+ logger.error(f"Error in initialize: {e}", name="acp_initialize_error", exc_info=True)
406
+ print(f"ERROR in initialize: {e}", file=__import__("sys").stderr)
407
+ raise
408
+
409
+ def _extract_fs_capabilities(self, fs_caps: Any) -> dict[str, bool]:
410
+ """Normalize filesystem capabilities for status reporting."""
411
+ normalized: dict[str, bool] = {}
412
+ if not fs_caps:
413
+ return normalized
414
+
415
+ if isinstance(fs_caps, dict):
416
+ for key, value in fs_caps.items():
417
+ if value is not None:
418
+ normalized[key] = bool(value)
419
+ return normalized
420
+
421
+ for attr in ("readTextFile", "writeTextFile", "readFile", "writeFile"):
422
+ if hasattr(fs_caps, attr):
423
+ value = getattr(fs_caps, attr)
424
+ if value is not None:
425
+ normalized[attr] = bool(value)
426
+
427
+ return normalized
428
+
429
+ def _build_session_modes(
430
+ self, instance: AgentInstance, session_state: ACPSessionState | None = None
431
+ ) -> SessionModeState:
432
+ """
433
+ Build SessionModeState from an AgentInstance's agents.
434
+
435
+ Each agent in the instance becomes an available mode.
436
+
437
+ Args:
438
+ instance: The AgentInstance containing agents
439
+
440
+ Returns:
441
+ SessionModeState with available modes and current mode ID
442
+ """
443
+ available_modes: list[SessionMode] = []
444
+
445
+ resolved_cache = session_state.resolved_instructions if session_state else {}
446
+
447
+ # Create a SessionMode for each agent
448
+ for agent_name, agent in instance.agents.items():
449
+ # Get instruction from agent's config
450
+ instruction = ""
451
+ resolved_instruction = resolved_cache.get(agent_name)
452
+ if resolved_instruction:
453
+ instruction = resolved_instruction
454
+ elif hasattr(agent, "_config") and hasattr(agent._config, "instruction"):
455
+ instruction = agent._config.instruction
456
+ elif hasattr(agent, "instruction"):
457
+ instruction = agent.instruction
458
+
459
+ # Format description (first line, truncated to 200 chars)
460
+ description = truncate_description(instruction) if instruction else None
461
+ display_name = format_agent_name_as_title(agent_name)
462
+
463
+ # Allow ACP-aware agents to supply custom name/description
464
+ if isinstance(agent, ACPAwareProtocol):
465
+ try:
466
+ mode_info = agent.acp_mode_info()
467
+ except Exception:
468
+ logger.warning(
469
+ "Error getting acp_mode_info from agent",
470
+ name="acp_mode_info_error",
471
+ agent_name=agent_name,
472
+ exc_info=True,
473
+ )
474
+ mode_info = None
475
+
476
+ if mode_info:
477
+ if mode_info.name:
478
+ display_name = mode_info.name
479
+ if mode_info.description:
480
+ description = mode_info.description
481
+
482
+ if description:
483
+ description = truncate_description(description)
484
+
485
+ # Create the SessionMode
486
+ mode = SessionMode(
487
+ id=agent_name,
488
+ name=display_name,
489
+ description=description,
490
+ )
491
+ available_modes.append(mode)
492
+
493
+ # Current mode is the primary agent name
494
+ current_mode_id = self.primary_agent_name or (
495
+ list(instance.agents.keys())[0] if instance.agents else "default"
496
+ )
497
+
498
+ return SessionModeState(
499
+ available_modes=available_modes,
500
+ current_mode_id=current_mode_id,
501
+ )
502
+
503
+ def _build_session_request_params(
504
+ self, agent: Any, session_state: ACPSessionState | None
505
+ ) -> RequestParams | None:
506
+ """
507
+ Apply late-binding template variables to an agent's instruction for this session.
508
+ """
509
+ # Only apply per-session system prompts when the target agent actually has an LLM.
510
+ # Workflow wrappers (chain/parallel) don't attach an LLM and will forward params
511
+ # to their children, which can override their instructions if we keep the prompt.
512
+ if not getattr(agent, "_llm", None):
513
+ return None
514
+
515
+ # Prefer cached resolved instructions to avoid reprocessing templates
516
+ resolved_cache = session_state.resolved_instructions if session_state else {}
517
+ resolved = resolved_cache.get(getattr(agent, "name", ""), None)
518
+ if not resolved:
519
+ context = session_state.prompt_context if session_state else None
520
+ if not context:
521
+ return None
522
+ template = getattr(agent, "instruction", None)
523
+ if not template:
524
+ return None
525
+ resolved = apply_template_variables(template, context)
526
+ if resolved == template:
527
+ return None
528
+ return RequestParams(systemPrompt=resolved)
529
+
530
+ async def new_session(
531
+ self,
532
+ cwd: str,
533
+ mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
534
+ **kwargs: Any,
535
+ ) -> NewSessionResponse:
536
+ """
537
+ Handle new session request.
538
+
539
+ Creates a new session and maps it to an AgentInstance based on instance_scope.
540
+ """
541
+ session_id = str(uuid.uuid4())
542
+
543
+ logger.info(
544
+ "ACP new session request",
545
+ name="acp_new_session",
546
+ session_id=session_id,
547
+ instance_scope=self._instance_scope,
548
+ cwd=cwd,
549
+ mcp_server_count=len(mcp_servers),
550
+ )
551
+
552
+ async with self._session_lock:
553
+ # Determine which instance to use based on scope
554
+ if self._instance_scope == "shared":
555
+ # All sessions share the primary instance
556
+ instance = self.primary_instance
557
+ elif self._instance_scope in ["connection", "request"]:
558
+ # Create a new instance for this session
559
+ instance = await self._create_instance_task()
560
+ else:
561
+ # Default to shared
562
+ instance = self.primary_instance
563
+
564
+ self.sessions[session_id] = instance
565
+ session_state = ACPSessionState(session_id=session_id, instance=instance)
566
+ self._session_state[session_id] = session_state
567
+
568
+ # Create tool progress manager for this session if connection is available
569
+ tool_handler = None
570
+ if self._connection:
571
+ # Create a progress manager for this session
572
+ tool_handler = ACPToolProgressManager(self._connection, session_id)
573
+ session_state.progress_manager = tool_handler
574
+ workflow_telemetry = ToolHandlerWorkflowTelemetry(
575
+ tool_handler, server_name=self.server_name
576
+ )
577
+
578
+ logger.info(
579
+ "ACP tool progress manager created for session",
580
+ name="acp_tool_progress_init",
581
+ session_id=session_id,
582
+ )
583
+
584
+ # Register tool handler with agents' aggregators
585
+ for agent_name, agent in instance.agents.items():
586
+ if hasattr(agent, "_aggregator"):
587
+ aggregator = agent._aggregator
588
+ aggregator._tool_handler = tool_handler
589
+
590
+ logger.info(
591
+ "ACP tool handler registered",
592
+ name="acp_tool_handler_registered",
593
+ session_id=session_id,
594
+ agent_name=agent_name,
595
+ )
596
+
597
+ if hasattr(agent, "workflow_telemetry"):
598
+ agent.workflow_telemetry = workflow_telemetry
599
+
600
+ # Set up plan telemetry for agents that support it (e.g., IterativePlanner)
601
+ if hasattr(agent, "plan_telemetry"):
602
+ plan_telemetry = ACPPlanTelemetryProvider(self._connection, session_id)
603
+ agent.plan_telemetry = plan_telemetry
604
+ logger.info(
605
+ "ACP plan telemetry registered",
606
+ name="acp_plan_telemetry_registered",
607
+ session_id=session_id,
608
+ agent_name=agent_name,
609
+ )
610
+
611
+ # Register tool handler as stream listener to get early tool start events
612
+ llm = getattr(agent, "_llm", None)
613
+ if llm and hasattr(llm, "add_tool_stream_listener"):
614
+ try:
615
+ llm.add_tool_stream_listener(tool_handler.handle_tool_stream_event)
616
+ logger.info(
617
+ "ACP tool handler registered as stream listener",
618
+ name="acp_tool_stream_listener_registered",
619
+ session_id=session_id,
620
+ agent_name=agent_name,
621
+ )
622
+ except Exception as e:
623
+ logger.warning(
624
+ f"Failed to register tool stream listener: {e}",
625
+ name="acp_tool_stream_listener_failed",
626
+ exc_info=True,
627
+ )
628
+
629
+ # If permissions are enabled, create and register permission handler
630
+ if self._permissions_enabled:
631
+ # Create shared permission store for this session
632
+ session_cwd = cwd or "."
633
+ permission_store = PermissionStore(cwd=session_cwd)
634
+
635
+ # Create permission adapter with tool_handler for toolCallId lookup
636
+ permission_handler = ACPToolPermissionAdapter(
637
+ connection=self._connection,
638
+ session_id=session_id,
639
+ store=permission_store,
640
+ cwd=session_cwd,
641
+ tool_handler=tool_handler,
642
+ )
643
+ session_state.permission_handler = permission_handler
644
+
645
+ # Register permission handler with all agents' aggregators
646
+ for agent_name, agent in instance.agents.items():
647
+ if hasattr(agent, "_aggregator"):
648
+ aggregator = agent._aggregator
649
+ aggregator._permission_handler = permission_handler
650
+
651
+ logger.info(
652
+ "ACP permission handler registered",
653
+ name="acp_permission_handler_registered",
654
+ session_id=session_id,
655
+ agent_name=agent_name,
656
+ )
657
+
658
+ logger.info(
659
+ "ACP tool permissions enabled for session",
660
+ name="acp_permissions_init",
661
+ session_id=session_id,
662
+ cwd=cwd,
663
+ )
664
+
665
+ # If client supports terminals and we have shell runtime enabled,
666
+ # inject ACP terminal runtime to replace local ShellRuntime
667
+ if self._client_supports_terminal:
668
+ # Check if any agent has shell runtime enabled
669
+ for agent_name, agent in instance.agents.items():
670
+ if (
671
+ hasattr(agent, "_shell_runtime_enabled")
672
+ and agent._shell_runtime_enabled
673
+ ):
674
+ # Create ACPTerminalRuntime for this session
675
+ default_limit = self._calculate_terminal_output_limit(agent)
676
+ # Get permission handler if enabled for this session
677
+ perm_handler = session_state.permission_handler
678
+ terminal_runtime = ACPTerminalRuntime(
679
+ connection=self._connection,
680
+ session_id=session_id,
681
+ activation_reason="via ACP terminal support",
682
+ timeout_seconds=getattr(
683
+ agent._shell_runtime, "timeout_seconds", 90
684
+ ),
685
+ tool_handler=tool_handler,
686
+ default_output_byte_limit=default_limit,
687
+ permission_handler=perm_handler,
688
+ )
689
+
690
+ # Inject into agent
691
+ if hasattr(agent, "set_external_runtime"):
692
+ agent.set_external_runtime(terminal_runtime)
693
+ session_state.terminal_runtime = terminal_runtime
694
+
695
+ logger.info(
696
+ "ACP terminal runtime injected",
697
+ name="acp_terminal_injected",
698
+ session_id=session_id,
699
+ agent_name=agent_name,
700
+ default_output_limit=default_limit,
701
+ )
702
+
703
+ # If client supports filesystem operations, inject ACP filesystem runtime
704
+ if self._client_supports_fs_read or self._client_supports_fs_write:
705
+ # Get permission handler if enabled for this session
706
+ perm_handler = session_state.permission_handler
707
+ # Create ACPFilesystemRuntime for this session with appropriate capabilities
708
+ filesystem_runtime = ACPFilesystemRuntime(
709
+ connection=self._connection,
710
+ session_id=session_id,
711
+ activation_reason="via ACP filesystem support",
712
+ enable_read=self._client_supports_fs_read,
713
+ enable_write=self._client_supports_fs_write,
714
+ tool_handler=tool_handler,
715
+ permission_handler=perm_handler,
716
+ )
717
+ session_state.filesystem_runtime = filesystem_runtime
718
+
719
+ # Inject filesystem runtime into each agent
720
+ for agent_name, agent in instance.agents.items():
721
+ if hasattr(agent, "set_filesystem_runtime"):
722
+ agent.set_filesystem_runtime(filesystem_runtime)
723
+ logger.info(
724
+ "ACP filesystem runtime injected",
725
+ name="acp_filesystem_injected",
726
+ session_id=session_id,
727
+ agent_name=agent_name,
728
+ read_enabled=self._client_supports_fs_read,
729
+ write_enabled=self._client_supports_fs_write,
730
+ )
731
+
732
+ # Track per-session template variables (used for late instruction binding)
733
+ session_context: dict[str, str] = {}
734
+ enrich_with_environment_context(
735
+ session_context, cwd, self._client_info, self._skills_directory_override
736
+ )
737
+ session_state.prompt_context = session_context
738
+
739
+ # Cache resolved instructions for this session (without mutating shared instances)
740
+ resolved_for_session: dict[str, str] = {}
741
+ for agent_name, agent in instance.agents.items():
742
+ template = getattr(agent, "instruction", None)
743
+ if not template:
744
+ continue
745
+ resolved = apply_template_variables(template, session_context)
746
+ if resolved:
747
+ resolved_for_session[agent_name] = resolved
748
+ if resolved_for_session:
749
+ session_state.resolved_instructions = resolved_for_session
750
+
751
+ # Create slash command handler for this session
752
+ resolved_prompts = session_state.resolved_instructions
753
+
754
+ slash_handler = SlashCommandHandler(
755
+ session_id,
756
+ instance,
757
+ self.primary_agent_name or "default",
758
+ client_info=self._client_info,
759
+ client_capabilities=self._client_capabilities,
760
+ protocol_version=self._protocol_version,
761
+ session_instructions=resolved_prompts,
762
+ )
763
+ session_state.slash_handler = slash_handler
764
+
765
+ # Create ACPContext for this session - centralizes all ACP state
766
+ if self._connection:
767
+ acp_context = ACPContext(
768
+ connection=self._connection,
769
+ session_id=session_id,
770
+ client_capabilities=self._parsed_client_capabilities,
771
+ client_info=self._parsed_client_info,
772
+ protocol_version=self._protocol_version,
773
+ )
774
+
775
+ # Store references to runtimes and handlers in ACPContext
776
+ if session_state.terminal_runtime:
777
+ acp_context.set_terminal_runtime(session_state.terminal_runtime)
778
+ if session_state.filesystem_runtime:
779
+ acp_context.set_filesystem_runtime(session_state.filesystem_runtime)
780
+ if session_state.permission_handler:
781
+ acp_context.set_permission_handler(session_state.permission_handler)
782
+ if session_state.progress_manager:
783
+ acp_context.set_progress_manager(session_state.progress_manager)
784
+
785
+ acp_context.set_slash_handler(slash_handler)
786
+
787
+ # Store ACPContext
788
+ session_state.acp_context = acp_context
789
+
790
+ # Set ACPContext on each agent's Context object (if they have one)
791
+ for agent_name, agent in instance.agents.items():
792
+ if hasattr(agent, "_context") and agent._context is not None:
793
+ agent._context.acp = acp_context
794
+ logger.debug(
795
+ "ACPContext set on agent",
796
+ name="acp_context_set",
797
+ session_id=session_id,
798
+ agent_name=agent_name,
799
+ )
800
+ elif hasattr(agent, "context"):
801
+ # Try via context property
802
+ try:
803
+ agent.context.acp = acp_context
804
+ logger.debug(
805
+ "ACPContext set on agent via context property",
806
+ name="acp_context_set",
807
+ session_id=session_id,
808
+ agent_name=agent_name,
809
+ )
810
+ except Exception:
811
+ # Agent may not have a context available
812
+ pass
813
+
814
+ logger.info(
815
+ "ACPContext created for session",
816
+ name="acp_context_created",
817
+ session_id=session_id,
818
+ has_terminal=acp_context.terminal_runtime is not None,
819
+ has_filesystem=acp_context.filesystem_runtime is not None,
820
+ has_permissions=acp_context.permission_handler is not None,
821
+ )
822
+ session_state.acp_context = acp_context
823
+
824
+ logger.info(
825
+ "ACP new session created",
826
+ name="acp_new_session_created",
827
+ session_id=session_id,
828
+ total_sessions=len(self.sessions),
829
+ terminal_enabled=session_state.terminal_runtime is not None,
830
+ filesystem_enabled=session_state.filesystem_runtime is not None,
831
+ )
832
+
833
+ # Schedule available_commands_update notification to be sent after response is returned
834
+ # This ensures the client receives session/new response before the session/update notification
835
+ if self._connection:
836
+ asyncio.create_task(self._send_available_commands_update(session_id))
837
+
838
+ # Build session modes from the instance's agents
839
+ session_modes = self._build_session_modes(instance, session_state)
840
+
841
+ # Initialize the current agent for this session
842
+ session_state.current_agent_name = session_modes.currentModeId
843
+
844
+ # Update ACPContext with mode information
845
+ if session_state.acp_context:
846
+ session_state.acp_context.set_available_modes(session_modes.availableModes)
847
+ session_state.acp_context.set_current_mode(session_modes.currentModeId)
848
+
849
+ logger.info(
850
+ "Session modes initialized",
851
+ name="acp_session_modes_init",
852
+ session_id=session_id,
853
+ current_mode=session_modes.currentModeId,
854
+ mode_count=len(session_modes.availableModes),
855
+ )
856
+
857
+ return NewSessionResponse(
858
+ session_id=session_id,
859
+ modes=session_modes,
860
+ )
861
+
862
+ async def set_session_mode(
863
+ self,
864
+ mode_id: str,
865
+ session_id: str,
866
+ **kwargs: Any,
867
+ ) -> SetSessionModeResponse | None:
868
+ """
869
+ Handle session mode change request.
870
+
871
+ Updates the current agent for the session to route future prompts
872
+ to the selected mode (agent).
873
+
874
+ Args:
875
+ mode_id: The ID of the mode (agent) to switch to
876
+ session_id: The session ID
877
+
878
+ Returns:
879
+ SetSessionModeResponse (empty response on success)
880
+
881
+ Raises:
882
+ ValueError: If session not found or mode ID is invalid
883
+ """
884
+ logger.info(
885
+ "ACP set session mode request",
886
+ name="acp_set_session_mode",
887
+ session_id=session_id,
888
+ mode_id=mode_id,
889
+ )
890
+
891
+ # Get the agent instance for this session
892
+ async with self._session_lock:
893
+ instance = self.sessions.get(session_id)
894
+ session_state = self._session_state.get(session_id)
895
+
896
+ if not instance:
897
+ logger.error(
898
+ "Session not found for set_session_mode",
899
+ name="acp_set_mode_error",
900
+ session_id=session_id,
901
+ )
902
+ raise ValueError(f"Session not found: {session_id}")
903
+
904
+ # Validate that the mode_id exists in the instance's agents
905
+ if mode_id not in instance.agents:
906
+ logger.error(
907
+ "Invalid mode ID for set_session_mode",
908
+ name="acp_set_mode_invalid",
909
+ session_id=session_id,
910
+ mode_id=mode_id,
911
+ available_modes=list(instance.agents.keys()),
912
+ )
913
+ raise ValueError(
914
+ f"Invalid mode ID '{mode_id}'. Available modes: {list(instance.agents.keys())}"
915
+ )
916
+
917
+ # Update the session's current agent
918
+ if session_state:
919
+ session_state.current_agent_name = mode_id
920
+
921
+ # Update slash handler's current agent so it queries the right agent's commands
922
+ if session_state and session_state.slash_handler:
923
+ session_state.slash_handler.set_current_agent(mode_id)
924
+
925
+ # Update ACPContext and send available_commands_update
926
+ # (commands may differ per agent)
927
+ if session_state and session_state.acp_context:
928
+ acp_context = session_state.acp_context
929
+ acp_context.set_current_mode(mode_id)
930
+ await acp_context.send_available_commands_update()
931
+
932
+ logger.info(
933
+ "Session mode updated",
934
+ name="acp_set_session_mode_success",
935
+ session_id=session_id,
936
+ new_mode=mode_id,
937
+ )
938
+
939
+ return SetSessionModeResponse()
940
+
941
+ def _select_primary_agent(self, instance: AgentInstance) -> str | None:
942
+ """
943
+ Pick the default agent to expose as the initial ACP mode.
944
+
945
+ Respects AgentConfig.default when set; otherwise falls back to the first agent.
946
+ """
947
+ if not instance.agents:
948
+ return None
949
+
950
+ for agent_name, agent in instance.agents.items():
951
+ config = getattr(agent, "config", None)
952
+ if config and getattr(config, "default", False):
953
+ return agent_name
954
+
955
+ return next(iter(instance.agents.keys()))
956
+
957
+ async def prompt(
958
+ self,
959
+ prompt: list[ACPContentBlock],
960
+ session_id: str,
961
+ **kwargs: Any,
962
+ ) -> PromptResponse:
963
+ """
964
+ Handle prompt request.
965
+
966
+ Extracts the prompt text, sends it to the fast-agent agent, and sends the response
967
+ back to the client via sessionUpdate notifications.
968
+
969
+ Per ACP protocol, only one prompt can be active per session at a time. If a prompt
970
+ is already in progress for this session, this will immediately return a refusal.
971
+ """
972
+ logger.info(
973
+ "ACP prompt request",
974
+ name="acp_prompt",
975
+ session_id=session_id,
976
+ )
977
+
978
+ # Check for overlapping prompt requests (per ACP protocol requirement)
979
+ async with self._session_lock:
980
+ if session_id in self._active_prompts:
981
+ logger.warning(
982
+ "Overlapping prompt request detected - refusing",
983
+ name="acp_prompt_overlap",
984
+ session_id=session_id,
985
+ )
986
+ # Return immediate refusal - ACP protocol requires sequential prompts per session
987
+ return PromptResponse(stop_reason=REFUSAL)
988
+
989
+ # Mark this session as having an active prompt
990
+ self._active_prompts.add(session_id)
991
+
992
+ # Track the current task for proper cancellation via asyncio.Task.cancel()
993
+ current_task = asyncio.current_task()
994
+ if current_task:
995
+ self._session_tasks[session_id] = current_task
996
+
997
+ # Use try/finally to ensure session is always removed from active prompts
998
+ try:
999
+ # Get the agent instance for this session
1000
+ async with self._session_lock:
1001
+ instance = self.sessions.get(session_id)
1002
+
1003
+ if not instance:
1004
+ logger.error(
1005
+ "ACP prompt error: session not found",
1006
+ name="acp_prompt_error",
1007
+ session_id=session_id,
1008
+ )
1009
+ # Return an error response
1010
+ return PromptResponse(stop_reason=REFUSAL)
1011
+
1012
+ # Convert ACP content blocks to MCP format
1013
+ mcp_content_blocks = convert_acp_prompt_to_mcp_content_blocks(prompt)
1014
+
1015
+ # Create a PromptMessageExtended with the converted content
1016
+ prompt_message = PromptMessageExtended(
1017
+ role="user",
1018
+ content=mcp_content_blocks,
1019
+ )
1020
+
1021
+ # Get current agent for this session (defaults to primary agent if not set).
1022
+ # Prefer ACPContext.current_mode so agent-initiated mode switches route correctly.
1023
+ session_state = self._session_state.get(session_id)
1024
+ acp_context = session_state.acp_context if session_state else None
1025
+ current_agent_name = None
1026
+ if acp_context is not None:
1027
+ current_agent_name = acp_context.current_mode
1028
+ if not current_agent_name and session_state:
1029
+ current_agent_name = session_state.current_agent_name
1030
+ if not current_agent_name:
1031
+ current_agent_name = self.primary_agent_name
1032
+
1033
+ # Check if this is a slash command
1034
+ # Only process slash commands if the prompt is a single text block
1035
+ # This ensures resources, images, and multi-part prompts are never treated as commands
1036
+ slash_handler = session_state.slash_handler if session_state else None
1037
+ is_single_text_block = len(mcp_content_blocks) == 1 and is_text_content(
1038
+ mcp_content_blocks[0]
1039
+ )
1040
+ prompt_text = prompt_message.all_text() or ""
1041
+ if (
1042
+ slash_handler
1043
+ and is_single_text_block
1044
+ and slash_handler.is_slash_command(prompt_text)
1045
+ ):
1046
+ logger.info(
1047
+ "Processing slash command",
1048
+ name="acp_slash_command",
1049
+ session_id=session_id,
1050
+ prompt_text=prompt_text[:100], # Log first 100 chars
1051
+ )
1052
+
1053
+ # Update slash handler with current agent before executing command
1054
+ slash_handler.set_current_agent(current_agent_name or "default")
1055
+
1056
+ # Parse and execute the command
1057
+ command_name, arguments = slash_handler.parse_command(prompt_text)
1058
+ response_text = await slash_handler.execute_command(command_name, arguments)
1059
+
1060
+ # Send the response via sessionUpdate
1061
+ if self._connection and response_text:
1062
+ try:
1063
+ message_chunk = update_agent_message_text(response_text)
1064
+ await self._connection.session_update(
1065
+ session_id=session_id, update=message_chunk
1066
+ )
1067
+ logger.info(
1068
+ "Sent slash command response",
1069
+ name="acp_slash_command_response",
1070
+ session_id=session_id,
1071
+ )
1072
+ except Exception as e:
1073
+ logger.error(
1074
+ f"Error sending slash command response: {e}",
1075
+ name="acp_slash_command_response_error",
1076
+ exc_info=True,
1077
+ )
1078
+
1079
+ # Return success
1080
+ return PromptResponse(stop_reason=END_TURN)
1081
+
1082
+ logger.info(
1083
+ "Sending prompt to fast-agent",
1084
+ name="acp_prompt_send",
1085
+ session_id=session_id,
1086
+ agent=current_agent_name,
1087
+ content_blocks=len(mcp_content_blocks),
1088
+ )
1089
+
1090
+ # Send to the fast-agent agent with streaming support
1091
+ # Track the stop reason to return in PromptResponse
1092
+ acp_stop_reason: StopReason = END_TURN
1093
+ try:
1094
+ if current_agent_name:
1095
+ agent = instance.agents[current_agent_name]
1096
+
1097
+ # Set up streaming if connection is available and agent supports it
1098
+ stream_listener = None
1099
+ remove_listener: Callable[[], None] | None = None
1100
+ streaming_tasks: list[asyncio.Task] = []
1101
+ if self._connection and isinstance(agent, StreamingAgentProtocol):
1102
+ connection = self._connection
1103
+ update_lock = asyncio.Lock()
1104
+
1105
+ async def send_stream_update(chunk: StreamChunk) -> None:
1106
+ """Send sessionUpdate with accumulated text so far."""
1107
+ if not chunk.text:
1108
+ return
1109
+ try:
1110
+ async with update_lock:
1111
+ if chunk.is_reasoning:
1112
+ message_chunk = update_agent_thought_text(chunk.text)
1113
+ else:
1114
+ message_chunk = update_agent_message_text(chunk.text)
1115
+ await connection.session_update(
1116
+ session_id=session_id, update=message_chunk
1117
+ )
1118
+ except Exception as e:
1119
+ logger.error(
1120
+ f"Error sending stream update: {e}",
1121
+ name="acp_stream_error",
1122
+ exc_info=True,
1123
+ )
1124
+
1125
+ def on_stream_chunk(chunk: StreamChunk):
1126
+ """
1127
+ Sync callback from fast-agent streaming.
1128
+ Sends each chunk as it arrives to the ACP client.
1129
+ """
1130
+ if not chunk or not chunk.text:
1131
+ return
1132
+ task = asyncio.create_task(send_stream_update(chunk))
1133
+ streaming_tasks.append(task)
1134
+
1135
+ # Register the stream listener and keep the cleanup function
1136
+ stream_listener = on_stream_chunk
1137
+ remove_listener = agent.add_stream_listener(stream_listener)
1138
+
1139
+ logger.info(
1140
+ "Streaming enabled for prompt",
1141
+ name="acp_streaming_enabled",
1142
+ session_id=session_id,
1143
+ )
1144
+
1145
+ try:
1146
+ # This will trigger streaming callbacks as chunks arrive
1147
+ session_request_params = self._build_session_request_params(
1148
+ agent, session_state
1149
+ )
1150
+ result = await agent.generate(
1151
+ prompt_message,
1152
+ request_params=session_request_params,
1153
+ )
1154
+ response_text = result.last_text() or "No content generated"
1155
+
1156
+ # Map the LLM stop reason to ACP stop reason
1157
+ try:
1158
+ acp_stop_reason = map_llm_stop_reason_to_acp(result.stop_reason)
1159
+ except Exception as e:
1160
+ logger.error(
1161
+ f"Error mapping stop reason: {e}",
1162
+ name="acp_stop_reason_error",
1163
+ exc_info=True,
1164
+ )
1165
+ # Default to END_TURN on error
1166
+ acp_stop_reason = END_TURN
1167
+
1168
+ logger.info(
1169
+ "Received complete response from fast-agent",
1170
+ name="acp_prompt_response",
1171
+ session_id=session_id,
1172
+ response_length=len(response_text),
1173
+ llm_stop_reason=str(result.stop_reason) if result.stop_reason else None,
1174
+ acp_stop_reason=acp_stop_reason,
1175
+ )
1176
+
1177
+ # Wait for all streaming tasks to complete before sending final message
1178
+ # and returning PromptResponse. This ensures all chunks arrive before END_TURN.
1179
+ if streaming_tasks:
1180
+ try:
1181
+ await asyncio.gather(*streaming_tasks)
1182
+ logger.debug(
1183
+ f"All {len(streaming_tasks)} streaming tasks completed",
1184
+ name="acp_streaming_complete",
1185
+ session_id=session_id,
1186
+ task_count=len(streaming_tasks),
1187
+ )
1188
+ except Exception as e:
1189
+ logger.error(
1190
+ f"Error waiting for streaming tasks: {e}",
1191
+ name="acp_streaming_wait_error",
1192
+ exc_info=True,
1193
+ )
1194
+
1195
+ # Only send final update if no streaming chunks were sent
1196
+ # When chunks were streamed, the final chunk already contains the complete response
1197
+ # This prevents duplicate messages from being sent to the client
1198
+ if not streaming_tasks and self._connection and response_text:
1199
+ try:
1200
+ message_chunk = update_agent_message_text(response_text)
1201
+ await self._connection.session_update(
1202
+ session_id=session_id, update=message_chunk
1203
+ )
1204
+ logger.info(
1205
+ "Sent final sessionUpdate with complete response (no streaming)",
1206
+ name="acp_final_update",
1207
+ session_id=session_id,
1208
+ )
1209
+ except Exception as e:
1210
+ logger.error(
1211
+ f"Error sending final update: {e}",
1212
+ name="acp_final_update_error",
1213
+ exc_info=True,
1214
+ )
1215
+
1216
+ except Exception as send_error:
1217
+ # Make sure listener is cleaned up even on error
1218
+ if stream_listener and remove_listener:
1219
+ try:
1220
+ remove_listener()
1221
+ logger.info(
1222
+ "Removed stream listener after error",
1223
+ name="acp_streaming_cleanup_error",
1224
+ session_id=session_id,
1225
+ )
1226
+ except Exception:
1227
+ logger.warning("Failed to remove ACP stream listener after error")
1228
+ # Re-raise the original error
1229
+ raise send_error
1230
+
1231
+ finally:
1232
+ # Clean up stream listener (if not already cleaned up in except)
1233
+ if stream_listener and remove_listener:
1234
+ try:
1235
+ remove_listener()
1236
+ except Exception:
1237
+ logger.warning("Failed to remove ACP stream listener")
1238
+ else:
1239
+ logger.info(
1240
+ "Removed stream listener",
1241
+ name="acp_streaming_cleanup",
1242
+ session_id=session_id,
1243
+ )
1244
+
1245
+ else:
1246
+ logger.error("No primary agent available")
1247
+ except Exception as e:
1248
+ logger.error(
1249
+ f"Error processing prompt: {e}",
1250
+ name="acp_prompt_error",
1251
+ exc_info=True,
1252
+ )
1253
+ import sys
1254
+ import traceback
1255
+
1256
+ print(f"ERROR processing prompt: {e}", file=sys.stderr)
1257
+ traceback.print_exc(file=sys.stderr)
1258
+ raise
1259
+
1260
+ # Return response with appropriate stop reason
1261
+ return PromptResponse(
1262
+ stop_reason=acp_stop_reason,
1263
+ )
1264
+ except asyncio.CancelledError:
1265
+ # Task was cancelled - return appropriate response
1266
+ logger.info(
1267
+ "Prompt cancelled by user",
1268
+ name="acp_prompt_cancelled",
1269
+ session_id=session_id,
1270
+ )
1271
+ return PromptResponse(stop_reason="cancelled")
1272
+ finally:
1273
+ # Always remove session from active prompts and cleanup task
1274
+ async with self._session_lock:
1275
+ self._active_prompts.discard(session_id)
1276
+ self._session_tasks.pop(session_id, None)
1277
+ logger.debug(
1278
+ "Removed session from active prompts",
1279
+ name="acp_prompt_complete",
1280
+ session_id=session_id,
1281
+ )
1282
+
1283
+ async def cancel(self, session_id: str, **kwargs: Any) -> None:
1284
+ """
1285
+ Handle session/cancel notification from the client.
1286
+
1287
+ This cancels any in-progress prompt for the specified session.
1288
+ Per ACP protocol, we should stop all LLM requests and tool invocations
1289
+ as soon as possible.
1290
+
1291
+ Uses asyncio.Task.cancel() for proper async cancellation, which raises
1292
+ asyncio.CancelledError in the running task.
1293
+ """
1294
+ logger.info(
1295
+ "ACP cancel request received",
1296
+ name="acp_cancel",
1297
+ session_id=session_id,
1298
+ )
1299
+
1300
+ # Get the task for this session and cancel it
1301
+ async with self._session_lock:
1302
+ task = self._session_tasks.get(session_id)
1303
+ if task and not task.done():
1304
+ task.cancel()
1305
+ logger.info(
1306
+ "Task cancelled for session",
1307
+ name="acp_cancel_task",
1308
+ session_id=session_id,
1309
+ )
1310
+ else:
1311
+ logger.warning(
1312
+ "No active prompt to cancel for session",
1313
+ name="acp_cancel_no_active",
1314
+ session_id=session_id,
1315
+ )
1316
+
1317
+ def on_connect(self, conn: ACPClient) -> None:
1318
+ """
1319
+ Called when connection is established.
1320
+
1321
+ Store connection reference for sending session_update notifications.
1322
+ """
1323
+ self._connection = conn
1324
+ logger.info("ACP connection established via on_connect")
1325
+
1326
+ async def run_async(self) -> None:
1327
+ """
1328
+ Run the ACP server over stdio.
1329
+
1330
+ Uses the new run_agent helper which handles stdio streams and message routing.
1331
+ """
1332
+ logger.info("Starting ACP server on stdio")
1333
+ # Startup messages are handled by fastagent.py to respect quiet mode and use correct stream
1334
+
1335
+ try:
1336
+ # Use the new run_agent helper which handles:
1337
+ # - stdio stream setup
1338
+ # - AgentSideConnection creation
1339
+ # - Message loop
1340
+ # The connection is passed to us via on_connect callback
1341
+ await run_agent(self)
1342
+ except (asyncio.CancelledError, KeyboardInterrupt):
1343
+ logger.info("ACP server shutting down")
1344
+ # Shutdown message is handled by fastagent.py to respect quiet mode
1345
+
1346
+ except Exception as e:
1347
+ logger.error(f"ACP server error: {e}", name="acp_server_error", exc_info=True)
1348
+ raise
1349
+
1350
+ finally:
1351
+ # Clean up sessions
1352
+ await self._cleanup_sessions()
1353
+
1354
+ async def _send_available_commands_update(self, session_id: str) -> None:
1355
+ """
1356
+ Send available_commands_update notification for a session.
1357
+
1358
+ This is called as a background task after NewSessionResponse is returned
1359
+ to ensure the client receives the session/new response before the session/update.
1360
+ """
1361
+ if not self._connection:
1362
+ return
1363
+
1364
+ try:
1365
+ session_state = self._session_state.get(session_id)
1366
+ if not session_state or not session_state.slash_handler:
1367
+ return
1368
+
1369
+ available_commands = session_state.slash_handler.get_available_commands()
1370
+ commands_update = AvailableCommandsUpdate(
1371
+ session_update="available_commands_update",
1372
+ available_commands=available_commands,
1373
+ )
1374
+ await self._connection.session_update(session_id=session_id, update=commands_update)
1375
+
1376
+ logger.info(
1377
+ "Sent available_commands_update",
1378
+ name="acp_available_commands_sent",
1379
+ session_id=session_id,
1380
+ command_count=len(available_commands),
1381
+ )
1382
+ except Exception as e:
1383
+ logger.error(
1384
+ f"Error sending available_commands_update: {e}",
1385
+ name="acp_available_commands_error",
1386
+ exc_info=True,
1387
+ )
1388
+
1389
+ async def _cleanup_sessions(self) -> None:
1390
+ """Clean up all sessions and dispose of agent instances."""
1391
+ logger.info(f"Cleaning up {len(self.sessions)} sessions")
1392
+
1393
+ async with self._session_lock:
1394
+ # Clean up per-session state
1395
+ for session_id, state in list(self._session_state.items()):
1396
+ if state.terminal_runtime:
1397
+ try:
1398
+ logger.debug(f"Terminal runtime for session {session_id} will be cleaned up")
1399
+ except Exception as e:
1400
+ logger.error(
1401
+ f"Error noting terminal cleanup for session {session_id}: {e}",
1402
+ name="acp_terminal_cleanup_error",
1403
+ )
1404
+
1405
+ if state.filesystem_runtime:
1406
+ try:
1407
+ logger.debug(f"Filesystem runtime for session {session_id} cleaned up")
1408
+ except Exception as e:
1409
+ logger.error(
1410
+ f"Error noting filesystem cleanup for session {session_id}: {e}",
1411
+ name="acp_filesystem_cleanup_error",
1412
+ )
1413
+
1414
+ if state.permission_handler:
1415
+ try:
1416
+ await state.permission_handler.clear_session_cache()
1417
+ logger.debug(f"Permission handler for session {session_id} cleaned up")
1418
+ except Exception as e:
1419
+ logger.error(
1420
+ f"Error cleaning up permission handler for session {session_id}: {e}",
1421
+ name="acp_permission_cleanup_error",
1422
+ )
1423
+
1424
+ if state.progress_manager:
1425
+ try:
1426
+ await state.progress_manager.cleanup_session_tools(session_id)
1427
+ logger.debug(f"Progress manager for session {session_id} cleaned up")
1428
+ except Exception as e:
1429
+ logger.error(
1430
+ f"Error cleaning up progress manager for session {session_id}: {e}",
1431
+ name="acp_progress_cleanup_error",
1432
+ )
1433
+
1434
+ if state.acp_context:
1435
+ try:
1436
+ await state.acp_context.cleanup()
1437
+ logger.debug(f"ACPContext for session {session_id} cleaned up")
1438
+ except Exception as e:
1439
+ logger.error(
1440
+ f"Error cleaning up ACPContext for session {session_id}: {e}",
1441
+ name="acp_context_cleanup_error",
1442
+ )
1443
+
1444
+ self._session_state.clear()
1445
+ self._session_tasks.clear()
1446
+ self._active_prompts.clear()
1447
+
1448
+ # Dispose of non-shared instances
1449
+ if self._instance_scope in ["connection", "request"]:
1450
+ for session_id, instance in self.sessions.items():
1451
+ if instance != self.primary_instance:
1452
+ try:
1453
+ await self._dispose_instance_task(instance)
1454
+ except Exception as e:
1455
+ logger.error(
1456
+ f"Error disposing instance for session {session_id}: {e}",
1457
+ name="acp_cleanup_error",
1458
+ )
1459
+
1460
+ # Dispose of primary instance
1461
+ if self.primary_instance:
1462
+ try:
1463
+ await self._dispose_instance_task(self.primary_instance)
1464
+ except Exception as e:
1465
+ logger.error(
1466
+ f"Error disposing primary instance: {e}",
1467
+ name="acp_cleanup_error",
1468
+ )
1469
+
1470
+ self.sessions.clear()
1471
+
1472
+ logger.info("ACP cleanup complete")