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.
- fast_agent/__init__.py +183 -0
- fast_agent/acp/__init__.py +19 -0
- fast_agent/acp/acp_aware_mixin.py +304 -0
- fast_agent/acp/acp_context.py +437 -0
- fast_agent/acp/content_conversion.py +136 -0
- fast_agent/acp/filesystem_runtime.py +427 -0
- fast_agent/acp/permission_store.py +269 -0
- fast_agent/acp/server/__init__.py +5 -0
- fast_agent/acp/server/agent_acp_server.py +1472 -0
- fast_agent/acp/slash_commands.py +1050 -0
- fast_agent/acp/terminal_runtime.py +408 -0
- fast_agent/acp/tool_permission_adapter.py +125 -0
- fast_agent/acp/tool_permissions.py +474 -0
- fast_agent/acp/tool_progress.py +814 -0
- fast_agent/agents/__init__.py +85 -0
- fast_agent/agents/agent_types.py +64 -0
- fast_agent/agents/llm_agent.py +350 -0
- fast_agent/agents/llm_decorator.py +1139 -0
- fast_agent/agents/mcp_agent.py +1337 -0
- fast_agent/agents/tool_agent.py +271 -0
- fast_agent/agents/workflow/agents_as_tools_agent.py +849 -0
- fast_agent/agents/workflow/chain_agent.py +212 -0
- fast_agent/agents/workflow/evaluator_optimizer.py +380 -0
- fast_agent/agents/workflow/iterative_planner.py +652 -0
- fast_agent/agents/workflow/maker_agent.py +379 -0
- fast_agent/agents/workflow/orchestrator_models.py +218 -0
- fast_agent/agents/workflow/orchestrator_prompts.py +248 -0
- fast_agent/agents/workflow/parallel_agent.py +250 -0
- fast_agent/agents/workflow/router_agent.py +353 -0
- fast_agent/cli/__init__.py +0 -0
- fast_agent/cli/__main__.py +73 -0
- fast_agent/cli/commands/acp.py +159 -0
- fast_agent/cli/commands/auth.py +404 -0
- fast_agent/cli/commands/check_config.py +783 -0
- fast_agent/cli/commands/go.py +514 -0
- fast_agent/cli/commands/quickstart.py +557 -0
- fast_agent/cli/commands/serve.py +143 -0
- fast_agent/cli/commands/server_helpers.py +114 -0
- fast_agent/cli/commands/setup.py +174 -0
- fast_agent/cli/commands/url_parser.py +190 -0
- fast_agent/cli/constants.py +40 -0
- fast_agent/cli/main.py +115 -0
- fast_agent/cli/terminal.py +24 -0
- fast_agent/config.py +798 -0
- fast_agent/constants.py +41 -0
- fast_agent/context.py +279 -0
- fast_agent/context_dependent.py +50 -0
- fast_agent/core/__init__.py +92 -0
- fast_agent/core/agent_app.py +448 -0
- fast_agent/core/core_app.py +137 -0
- fast_agent/core/direct_decorators.py +784 -0
- fast_agent/core/direct_factory.py +620 -0
- fast_agent/core/error_handling.py +27 -0
- fast_agent/core/exceptions.py +90 -0
- fast_agent/core/executor/__init__.py +0 -0
- fast_agent/core/executor/executor.py +280 -0
- fast_agent/core/executor/task_registry.py +32 -0
- fast_agent/core/executor/workflow_signal.py +324 -0
- fast_agent/core/fastagent.py +1186 -0
- fast_agent/core/logging/__init__.py +5 -0
- fast_agent/core/logging/events.py +138 -0
- fast_agent/core/logging/json_serializer.py +164 -0
- fast_agent/core/logging/listeners.py +309 -0
- fast_agent/core/logging/logger.py +278 -0
- fast_agent/core/logging/transport.py +481 -0
- fast_agent/core/prompt.py +9 -0
- fast_agent/core/prompt_templates.py +183 -0
- fast_agent/core/validation.py +326 -0
- fast_agent/event_progress.py +62 -0
- fast_agent/history/history_exporter.py +49 -0
- fast_agent/human_input/__init__.py +47 -0
- fast_agent/human_input/elicitation_handler.py +123 -0
- fast_agent/human_input/elicitation_state.py +33 -0
- fast_agent/human_input/form_elements.py +59 -0
- fast_agent/human_input/form_fields.py +256 -0
- fast_agent/human_input/simple_form.py +113 -0
- fast_agent/human_input/types.py +40 -0
- fast_agent/interfaces.py +310 -0
- fast_agent/llm/__init__.py +9 -0
- fast_agent/llm/cancellation.py +22 -0
- fast_agent/llm/fastagent_llm.py +931 -0
- fast_agent/llm/internal/passthrough.py +161 -0
- fast_agent/llm/internal/playback.py +129 -0
- fast_agent/llm/internal/silent.py +41 -0
- fast_agent/llm/internal/slow.py +38 -0
- fast_agent/llm/memory.py +275 -0
- fast_agent/llm/model_database.py +490 -0
- fast_agent/llm/model_factory.py +388 -0
- fast_agent/llm/model_info.py +102 -0
- fast_agent/llm/prompt_utils.py +155 -0
- fast_agent/llm/provider/anthropic/anthropic_utils.py +84 -0
- fast_agent/llm/provider/anthropic/cache_planner.py +56 -0
- fast_agent/llm/provider/anthropic/llm_anthropic.py +796 -0
- fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +462 -0
- fast_agent/llm/provider/bedrock/bedrock_utils.py +218 -0
- fast_agent/llm/provider/bedrock/llm_bedrock.py +2207 -0
- fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py +84 -0
- fast_agent/llm/provider/google/google_converter.py +466 -0
- fast_agent/llm/provider/google/llm_google_native.py +681 -0
- fast_agent/llm/provider/openai/llm_aliyun.py +31 -0
- fast_agent/llm/provider/openai/llm_azure.py +143 -0
- fast_agent/llm/provider/openai/llm_deepseek.py +76 -0
- fast_agent/llm/provider/openai/llm_generic.py +35 -0
- fast_agent/llm/provider/openai/llm_google_oai.py +32 -0
- fast_agent/llm/provider/openai/llm_groq.py +42 -0
- fast_agent/llm/provider/openai/llm_huggingface.py +85 -0
- fast_agent/llm/provider/openai/llm_openai.py +1195 -0
- fast_agent/llm/provider/openai/llm_openai_compatible.py +138 -0
- fast_agent/llm/provider/openai/llm_openrouter.py +45 -0
- fast_agent/llm/provider/openai/llm_tensorzero_openai.py +128 -0
- fast_agent/llm/provider/openai/llm_xai.py +38 -0
- fast_agent/llm/provider/openai/multipart_converter_openai.py +561 -0
- fast_agent/llm/provider/openai/openai_multipart.py +169 -0
- fast_agent/llm/provider/openai/openai_utils.py +67 -0
- fast_agent/llm/provider/openai/responses.py +133 -0
- fast_agent/llm/provider_key_manager.py +139 -0
- fast_agent/llm/provider_types.py +34 -0
- fast_agent/llm/request_params.py +61 -0
- fast_agent/llm/sampling_converter.py +98 -0
- fast_agent/llm/stream_types.py +9 -0
- fast_agent/llm/usage_tracking.py +445 -0
- fast_agent/mcp/__init__.py +56 -0
- fast_agent/mcp/common.py +26 -0
- fast_agent/mcp/elicitation_factory.py +84 -0
- fast_agent/mcp/elicitation_handlers.py +164 -0
- fast_agent/mcp/gen_client.py +83 -0
- fast_agent/mcp/helpers/__init__.py +36 -0
- fast_agent/mcp/helpers/content_helpers.py +352 -0
- fast_agent/mcp/helpers/server_config_helpers.py +25 -0
- fast_agent/mcp/hf_auth.py +147 -0
- fast_agent/mcp/interfaces.py +92 -0
- fast_agent/mcp/logger_textio.py +108 -0
- fast_agent/mcp/mcp_agent_client_session.py +411 -0
- fast_agent/mcp/mcp_aggregator.py +2175 -0
- fast_agent/mcp/mcp_connection_manager.py +723 -0
- fast_agent/mcp/mcp_content.py +262 -0
- fast_agent/mcp/mime_utils.py +108 -0
- fast_agent/mcp/oauth_client.py +509 -0
- fast_agent/mcp/prompt.py +159 -0
- fast_agent/mcp/prompt_message_extended.py +155 -0
- fast_agent/mcp/prompt_render.py +84 -0
- fast_agent/mcp/prompt_serialization.py +580 -0
- fast_agent/mcp/prompts/__init__.py +0 -0
- fast_agent/mcp/prompts/__main__.py +7 -0
- fast_agent/mcp/prompts/prompt_constants.py +18 -0
- fast_agent/mcp/prompts/prompt_helpers.py +238 -0
- fast_agent/mcp/prompts/prompt_load.py +186 -0
- fast_agent/mcp/prompts/prompt_server.py +552 -0
- fast_agent/mcp/prompts/prompt_template.py +438 -0
- fast_agent/mcp/resource_utils.py +215 -0
- fast_agent/mcp/sampling.py +200 -0
- fast_agent/mcp/server/__init__.py +4 -0
- fast_agent/mcp/server/agent_server.py +613 -0
- fast_agent/mcp/skybridge.py +44 -0
- fast_agent/mcp/sse_tracking.py +287 -0
- fast_agent/mcp/stdio_tracking_simple.py +59 -0
- fast_agent/mcp/streamable_http_tracking.py +309 -0
- fast_agent/mcp/tool_execution_handler.py +137 -0
- fast_agent/mcp/tool_permission_handler.py +88 -0
- fast_agent/mcp/transport_tracking.py +634 -0
- fast_agent/mcp/types.py +24 -0
- fast_agent/mcp/ui_agent.py +48 -0
- fast_agent/mcp/ui_mixin.py +209 -0
- fast_agent/mcp_server_registry.py +89 -0
- fast_agent/py.typed +0 -0
- fast_agent/resources/examples/data-analysis/analysis-campaign.py +189 -0
- fast_agent/resources/examples/data-analysis/analysis.py +68 -0
- fast_agent/resources/examples/data-analysis/fastagent.config.yaml +41 -0
- fast_agent/resources/examples/data-analysis/mount-point/WA_Fn-UseC_-HR-Employee-Attrition.csv +1471 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +297 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
- fast_agent/resources/examples/mcp/elicitations/forms_demo.py +107 -0
- fast_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
- fast_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
- fast_agent/resources/examples/mcp/elicitations/tool_call.py +21 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_one.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_two.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +27 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +15 -0
- fast_agent/resources/examples/researcher/fastagent.config.yaml +61 -0
- fast_agent/resources/examples/researcher/researcher-eval.py +53 -0
- fast_agent/resources/examples/researcher/researcher-imp.py +189 -0
- fast_agent/resources/examples/researcher/researcher.py +36 -0
- fast_agent/resources/examples/tensorzero/.env.sample +2 -0
- fast_agent/resources/examples/tensorzero/Makefile +31 -0
- fast_agent/resources/examples/tensorzero/README.md +56 -0
- fast_agent/resources/examples/tensorzero/agent.py +35 -0
- fast_agent/resources/examples/tensorzero/demo_images/clam.jpg +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/crab.png +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/shrimp.png +0 -0
- fast_agent/resources/examples/tensorzero/docker-compose.yml +105 -0
- fast_agent/resources/examples/tensorzero/fastagent.config.yaml +19 -0
- fast_agent/resources/examples/tensorzero/image_demo.py +67 -0
- fast_agent/resources/examples/tensorzero/mcp_server/Dockerfile +25 -0
- fast_agent/resources/examples/tensorzero/mcp_server/entrypoint.sh +35 -0
- fast_agent/resources/examples/tensorzero/mcp_server/mcp_server.py +31 -0
- fast_agent/resources/examples/tensorzero/mcp_server/pyproject.toml +11 -0
- fast_agent/resources/examples/tensorzero/simple_agent.py +25 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_schema.json +29 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_template.minijinja +11 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/tensorzero.toml +35 -0
- fast_agent/resources/examples/workflows/agents_as_tools_extended.py +73 -0
- fast_agent/resources/examples/workflows/agents_as_tools_simple.py +50 -0
- fast_agent/resources/examples/workflows/chaining.py +37 -0
- fast_agent/resources/examples/workflows/evaluator.py +77 -0
- fast_agent/resources/examples/workflows/fastagent.config.yaml +26 -0
- fast_agent/resources/examples/workflows/graded_report.md +89 -0
- fast_agent/resources/examples/workflows/human_input.py +28 -0
- fast_agent/resources/examples/workflows/maker.py +156 -0
- fast_agent/resources/examples/workflows/orchestrator.py +70 -0
- fast_agent/resources/examples/workflows/parallel.py +56 -0
- fast_agent/resources/examples/workflows/router.py +69 -0
- fast_agent/resources/examples/workflows/short_story.md +13 -0
- fast_agent/resources/examples/workflows/short_story.txt +19 -0
- fast_agent/resources/setup/.gitignore +30 -0
- fast_agent/resources/setup/agent.py +28 -0
- fast_agent/resources/setup/fastagent.config.yaml +65 -0
- fast_agent/resources/setup/fastagent.secrets.yaml.example +38 -0
- fast_agent/resources/setup/pyproject.toml.tmpl +23 -0
- fast_agent/skills/__init__.py +9 -0
- fast_agent/skills/registry.py +235 -0
- fast_agent/tools/elicitation.py +369 -0
- fast_agent/tools/shell_runtime.py +402 -0
- fast_agent/types/__init__.py +59 -0
- fast_agent/types/conversation_summary.py +294 -0
- fast_agent/types/llm_stop_reason.py +78 -0
- fast_agent/types/message_search.py +249 -0
- fast_agent/ui/__init__.py +38 -0
- fast_agent/ui/console.py +59 -0
- fast_agent/ui/console_display.py +1080 -0
- fast_agent/ui/elicitation_form.py +946 -0
- fast_agent/ui/elicitation_style.py +59 -0
- fast_agent/ui/enhanced_prompt.py +1400 -0
- fast_agent/ui/history_display.py +734 -0
- fast_agent/ui/interactive_prompt.py +1199 -0
- fast_agent/ui/markdown_helpers.py +104 -0
- fast_agent/ui/markdown_truncator.py +1004 -0
- fast_agent/ui/mcp_display.py +857 -0
- fast_agent/ui/mcp_ui_utils.py +235 -0
- fast_agent/ui/mermaid_utils.py +169 -0
- fast_agent/ui/message_primitives.py +50 -0
- fast_agent/ui/notification_tracker.py +205 -0
- fast_agent/ui/plain_text_truncator.py +68 -0
- fast_agent/ui/progress_display.py +10 -0
- fast_agent/ui/rich_progress.py +195 -0
- fast_agent/ui/streaming.py +774 -0
- fast_agent/ui/streaming_buffer.py +449 -0
- fast_agent/ui/tool_display.py +422 -0
- fast_agent/ui/usage_display.py +204 -0
- fast_agent/utils/__init__.py +5 -0
- fast_agent/utils/reasoning_stream_parser.py +77 -0
- fast_agent/utils/time.py +22 -0
- fast_agent/workflow_telemetry.py +261 -0
- fast_agent_mcp-0.4.7.dist-info/METADATA +788 -0
- fast_agent_mcp-0.4.7.dist-info/RECORD +261 -0
- fast_agent_mcp-0.4.7.dist-info/WHEEL +4 -0
- fast_agent_mcp-0.4.7.dist-info/entry_points.txt +7 -0
- 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")
|