iac-code 0.1.0__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.
- iac_code/__init__.py +2 -0
- iac_code/acp/__init__.py +97 -0
- iac_code/acp/convert.py +423 -0
- iac_code/acp/http_sse.py +448 -0
- iac_code/acp/mcp.py +54 -0
- iac_code/acp/metrics.py +71 -0
- iac_code/acp/server.py +662 -0
- iac_code/acp/session.py +446 -0
- iac_code/acp/slash_registry.py +125 -0
- iac_code/acp/state.py +99 -0
- iac_code/acp/tools.py +112 -0
- iac_code/acp/types.py +13 -0
- iac_code/acp/version.py +26 -0
- iac_code/agent/__init__.py +19 -0
- iac_code/agent/agent_loop.py +640 -0
- iac_code/agent/agent_tool.py +269 -0
- iac_code/agent/agent_types.py +87 -0
- iac_code/agent/message.py +153 -0
- iac_code/agent/system_prompt.py +313 -0
- iac_code/cli/__init__.py +3 -0
- iac_code/cli/headless.py +114 -0
- iac_code/cli/main.py +246 -0
- iac_code/cli/output_formats.py +125 -0
- iac_code/commands/__init__.py +93 -0
- iac_code/commands/auth.py +1055 -0
- iac_code/commands/clear.py +34 -0
- iac_code/commands/compact.py +43 -0
- iac_code/commands/debug.py +45 -0
- iac_code/commands/effort.py +116 -0
- iac_code/commands/exit.py +10 -0
- iac_code/commands/help.py +49 -0
- iac_code/commands/model.py +130 -0
- iac_code/commands/registry.py +245 -0
- iac_code/commands/resume.py +49 -0
- iac_code/commands/tasks.py +41 -0
- iac_code/config.py +304 -0
- iac_code/i18n/__init__.py +141 -0
- iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
- iac_code/memory/__init__.py +1 -0
- iac_code/memory/memory_manager.py +92 -0
- iac_code/memory/memory_tools.py +88 -0
- iac_code/providers/__init__.py +1 -0
- iac_code/providers/anthropic_provider.py +284 -0
- iac_code/providers/base.py +128 -0
- iac_code/providers/dashscope_provider.py +47 -0
- iac_code/providers/deepseek_provider.py +36 -0
- iac_code/providers/manager.py +399 -0
- iac_code/providers/openai_provider.py +344 -0
- iac_code/providers/retry.py +58 -0
- iac_code/providers/stream_watchdog.py +47 -0
- iac_code/providers/thinking.py +164 -0
- iac_code/services/__init__.py +1 -0
- iac_code/services/agent_factory.py +127 -0
- iac_code/services/cloud_credentials.py +22 -0
- iac_code/services/context_manager.py +221 -0
- iac_code/services/providers/__init__.py +1 -0
- iac_code/services/providers/aliyun.py +232 -0
- iac_code/services/session_index.py +281 -0
- iac_code/services/session_storage.py +245 -0
- iac_code/services/telemetry/__init__.py +66 -0
- iac_code/services/telemetry/attributes.py +84 -0
- iac_code/services/telemetry/client.py +330 -0
- iac_code/services/telemetry/config.py +76 -0
- iac_code/services/telemetry/constants.py +75 -0
- iac_code/services/telemetry/content_serializer.py +124 -0
- iac_code/services/telemetry/events.py +42 -0
- iac_code/services/telemetry/fallback.py +59 -0
- iac_code/services/telemetry/identity.py +73 -0
- iac_code/services/telemetry/metrics.py +62 -0
- iac_code/services/telemetry/names.py +199 -0
- iac_code/services/telemetry/sanitize.py +88 -0
- iac_code/services/telemetry/sink.py +67 -0
- iac_code/services/telemetry/tracing.py +38 -0
- iac_code/services/telemetry/types.py +13 -0
- iac_code/services/token_budget.py +54 -0
- iac_code/services/token_counter.py +76 -0
- iac_code/skills/__init__.py +1 -0
- iac_code/skills/bundled/__init__.py +94 -0
- iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
- iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
- iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
- iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
- iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
- iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
- iac_code/skills/bundled/simplify.py +28 -0
- iac_code/skills/discovery.py +136 -0
- iac_code/skills/frontmatter.py +119 -0
- iac_code/skills/listing.py +92 -0
- iac_code/skills/loader.py +42 -0
- iac_code/skills/processor.py +81 -0
- iac_code/skills/renderer.py +157 -0
- iac_code/skills/skill_definition.py +82 -0
- iac_code/skills/skill_tool.py +261 -0
- iac_code/state/__init__.py +5 -0
- iac_code/state/app_state.py +122 -0
- iac_code/tasks/__init__.py +1 -0
- iac_code/tasks/notification_queue.py +28 -0
- iac_code/tasks/task_state.py +66 -0
- iac_code/tasks/task_tools.py +114 -0
- iac_code/tools/__init__.py +8 -0
- iac_code/tools/base.py +226 -0
- iac_code/tools/bash.py +133 -0
- iac_code/tools/cloud/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
- iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
- iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
- iac_code/tools/cloud/aliyun/ros_client.py +56 -0
- iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
- iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
- iac_code/tools/cloud/base_api.py +162 -0
- iac_code/tools/cloud/base_stack.py +242 -0
- iac_code/tools/cloud/registry.py +20 -0
- iac_code/tools/cloud/types.py +105 -0
- iac_code/tools/edit_file.py +121 -0
- iac_code/tools/glob.py +103 -0
- iac_code/tools/grep.py +254 -0
- iac_code/tools/list_files.py +104 -0
- iac_code/tools/read_file.py +127 -0
- iac_code/tools/result_storage.py +39 -0
- iac_code/tools/tool_executor.py +165 -0
- iac_code/tools/web_fetch.py +177 -0
- iac_code/tools/write_file.py +88 -0
- iac_code/types/__init__.py +40 -0
- iac_code/types/permissions.py +26 -0
- iac_code/types/skill_source.py +11 -0
- iac_code/types/stream_events.py +227 -0
- iac_code/ui/__init__.py +5 -0
- iac_code/ui/banner.py +110 -0
- iac_code/ui/components/__init__.py +0 -0
- iac_code/ui/components/dialog.py +142 -0
- iac_code/ui/components/divider.py +20 -0
- iac_code/ui/components/fuzzy_picker.py +308 -0
- iac_code/ui/components/progress_bar.py +54 -0
- iac_code/ui/components/search_box.py +165 -0
- iac_code/ui/components/select.py +319 -0
- iac_code/ui/components/status_icon.py +42 -0
- iac_code/ui/components/tabs.py +128 -0
- iac_code/ui/core/__init__.py +0 -0
- iac_code/ui/core/in_place_render.py +129 -0
- iac_code/ui/core/input_history.py +118 -0
- iac_code/ui/core/key_event.py +41 -0
- iac_code/ui/core/prompt_input.py +507 -0
- iac_code/ui/core/raw_input.py +302 -0
- iac_code/ui/core/screen.py +80 -0
- iac_code/ui/dialogs/__init__.py +0 -0
- iac_code/ui/dialogs/global_search.py +178 -0
- iac_code/ui/dialogs/history_search.py +100 -0
- iac_code/ui/dialogs/model_picker.py +280 -0
- iac_code/ui/dialogs/quick_open.py +108 -0
- iac_code/ui/dialogs/resume_picker.py +749 -0
- iac_code/ui/keybindings/__init__.py +0 -0
- iac_code/ui/keybindings/manager.py +124 -0
- iac_code/ui/renderer.py +1535 -0
- iac_code/ui/repl.py +772 -0
- iac_code/ui/spinner.py +112 -0
- iac_code/ui/suggestions/__init__.py +0 -0
- iac_code/ui/suggestions/aggregator.py +171 -0
- iac_code/ui/suggestions/command_provider.py +43 -0
- iac_code/ui/suggestions/directory_provider.py +95 -0
- iac_code/ui/suggestions/file_provider.py +121 -0
- iac_code/ui/suggestions/shell_history_provider.py +108 -0
- iac_code/ui/suggestions/token_extractor.py +77 -0
- iac_code/ui/suggestions/types.py +45 -0
- iac_code/ui/transcript_view.py +199 -0
- iac_code/utils/__init__.py +0 -0
- iac_code/utils/background_housekeeping.py +53 -0
- iac_code/utils/cleanup.py +68 -0
- iac_code/utils/json_utils.py +60 -0
- iac_code/utils/log.py +150 -0
- iac_code/utils/project_paths.py +74 -0
- iac_code/utils/tool_input_parser.py +62 -0
- iac_code-0.1.0.dist-info/LICENSE +201 -0
- iac_code-0.1.0.dist-info/METADATA +64 -0
- iac_code-0.1.0.dist-info/RECORD +184 -0
- iac_code-0.1.0.dist-info/WHEEL +5 -0
- iac_code-0.1.0.dist-info/entry_points.txt +2 -0
- iac_code-0.1.0.dist-info/top_level.txt +1 -0
iac_code/acp/http_sse.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""HTTP+SSE transport for ACP server.
|
|
2
|
+
|
|
3
|
+
Bridges HTTP POST/GET/DELETE requests to ``acp.run_agent()`` via in-memory
|
|
4
|
+
asyncio pipes using a *pipe-bridge* pattern::
|
|
5
|
+
|
|
6
|
+
HTTP Client
|
|
7
|
+
|
|
|
8
|
+
Starlette ASGI App
|
|
9
|
+
|
|
|
10
|
+
+-- POST /acp -----> StreamReader (requests) -----> acp.run_agent(server)
|
|
11
|
+
| |
|
|
12
|
+
+-- GET /acp <----- StreamReader (responses) <----- (responses/notifications)
|
|
13
|
+
|
|
|
14
|
+
+-- DELETE /acp ---> close connection
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import contextlib
|
|
21
|
+
import hmac
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import uuid
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from starlette.applications import Starlette
|
|
30
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
31
|
+
from starlette.requests import Request
|
|
32
|
+
from starlette.responses import JSONResponse, Response
|
|
33
|
+
from starlette.routing import Route
|
|
34
|
+
|
|
35
|
+
from iac_code.acp.server import ACPServer
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Transport type enum
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TransportType(str, Enum):
|
|
46
|
+
"""Supported ACP transport types."""
|
|
47
|
+
|
|
48
|
+
STDIO = "stdio"
|
|
49
|
+
HTTP = "http"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# In-memory transport: StreamWriter that feeds into a StreamReader
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _MemoryTransport(asyncio.Transport):
|
|
58
|
+
"""A custom :class:`asyncio.Transport` that feeds written bytes into an
|
|
59
|
+
:class:`asyncio.StreamReader`, enabling creation of an in-memory
|
|
60
|
+
:class:`asyncio.StreamWriter` / :class:`asyncio.StreamReader` pair.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, reader: asyncio.StreamReader) -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
self._reader = reader
|
|
66
|
+
self._closing = False
|
|
67
|
+
|
|
68
|
+
def write(self, data: bytes | bytearray | memoryview) -> None:
|
|
69
|
+
if not self._closing:
|
|
70
|
+
# ``StreamReader.feed_data`` requires an immutable bytes-like
|
|
71
|
+
# buffer, so coerce bytearray / memoryview into ``bytes``.
|
|
72
|
+
self._reader.feed_data(bytes(data))
|
|
73
|
+
|
|
74
|
+
def is_closing(self) -> bool:
|
|
75
|
+
return self._closing
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
self._closing = True
|
|
79
|
+
self._reader.feed_eof()
|
|
80
|
+
|
|
81
|
+
def get_extra_info(self, name: str, default: Any = None) -> Any:
|
|
82
|
+
return default
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _create_memory_stream_pair() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
86
|
+
"""Create an in-memory (StreamReader, StreamWriter) pair.
|
|
87
|
+
|
|
88
|
+
Data written to the returned *StreamWriter* can be read from the returned
|
|
89
|
+
*StreamReader*.
|
|
90
|
+
"""
|
|
91
|
+
reader = asyncio.StreamReader()
|
|
92
|
+
transport = _MemoryTransport(reader)
|
|
93
|
+
protocol = asyncio.StreamReaderProtocol(asyncio.StreamReader())
|
|
94
|
+
writer = asyncio.StreamWriter(transport, protocol, reader, asyncio.get_running_loop())
|
|
95
|
+
return reader, writer
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# HTTPConnectionBridge
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
_SSE_QUEUE_MAX_SIZE = 1024
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class HTTPConnectionBridge:
|
|
106
|
+
"""Bridges a single HTTP client connection to ``acp.run_agent()``
|
|
107
|
+
via in-memory asyncio stream pairs.
|
|
108
|
+
|
|
109
|
+
Lifecycle:
|
|
110
|
+
1. ``start()`` — creates pipes and launches ``run_agent`` in a background
|
|
111
|
+
task plus an output-reader task.
|
|
112
|
+
2. ``send_message(msg)`` — feeds a JSON-RPC message (from HTTP POST) into
|
|
113
|
+
the request pipe so the agent can process it.
|
|
114
|
+
3. The output-reader task routes agent responses/notifications to either
|
|
115
|
+
``_init_response`` (for the synchronous *initialize* round-trip) or
|
|
116
|
+
``_sse_queue`` (for SSE streaming).
|
|
117
|
+
4. ``close()`` — cancels background tasks and releases resources.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self) -> None:
|
|
121
|
+
self.connection_id: str = str(uuid.uuid4())
|
|
122
|
+
self.server: ACPServer = ACPServer()
|
|
123
|
+
|
|
124
|
+
# Pipe: HTTP POST -> run_agent (requests)
|
|
125
|
+
# request_reader is the output_stream param for run_agent
|
|
126
|
+
self._request_reader: asyncio.StreamReader | None = None
|
|
127
|
+
|
|
128
|
+
# Pipe: run_agent -> SSE (responses / notifications)
|
|
129
|
+
# response_reader is read by _read_output; response_writer is
|
|
130
|
+
# the input_stream param for run_agent
|
|
131
|
+
self._response_reader: asyncio.StreamReader | None = None
|
|
132
|
+
self._response_writer: asyncio.StreamWriter | None = None
|
|
133
|
+
|
|
134
|
+
self._agent_task: asyncio.Task[None] | None = None
|
|
135
|
+
self._output_task: asyncio.Task[None] | None = None
|
|
136
|
+
self._sse_queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=_SSE_QUEUE_MAX_SIZE)
|
|
137
|
+
self._initialized: asyncio.Event = asyncio.Event()
|
|
138
|
+
self._init_response: str | None = None
|
|
139
|
+
self._closed: bool = False
|
|
140
|
+
self._pending_close_tasks: set[asyncio.Task[None]] = set()
|
|
141
|
+
|
|
142
|
+
# -- lifecycle -----------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
async def start(self) -> None:
|
|
145
|
+
"""Create pipes and start ``run_agent`` in a background task."""
|
|
146
|
+
# Pipe for requests: HTTP POST writes -> agent reads
|
|
147
|
+
self._request_reader = asyncio.StreamReader()
|
|
148
|
+
|
|
149
|
+
# Pipe for responses: agent writes -> SSE/init reader reads
|
|
150
|
+
self._response_reader, self._response_writer = _create_memory_stream_pair()
|
|
151
|
+
|
|
152
|
+
# Start the agent background task
|
|
153
|
+
self._agent_task = asyncio.create_task(self._run_agent(), name=f"acp-agent-{self.connection_id[:8]}")
|
|
154
|
+
|
|
155
|
+
# Start the output reader
|
|
156
|
+
self._output_task = asyncio.create_task(self._read_output(), name=f"acp-output-{self.connection_id[:8]}")
|
|
157
|
+
|
|
158
|
+
async def send_message(self, message: str) -> None:
|
|
159
|
+
"""Feed a JSON-RPC message (from HTTP POST) into the request pipe."""
|
|
160
|
+
if self._request_reader is None:
|
|
161
|
+
raise RuntimeError("Connection not started")
|
|
162
|
+
# ACP SDK uses newline-delimited JSON framing
|
|
163
|
+
data = message.strip() + "\n"
|
|
164
|
+
self._request_reader.feed_data(data.encode("utf-8"))
|
|
165
|
+
|
|
166
|
+
async def close(self) -> None:
|
|
167
|
+
"""Shut down the connection and release all resources."""
|
|
168
|
+
if self._closed:
|
|
169
|
+
return
|
|
170
|
+
self._closed = True
|
|
171
|
+
|
|
172
|
+
# Signal SSE stream to close
|
|
173
|
+
await self._sse_queue.put(None)
|
|
174
|
+
|
|
175
|
+
# Close the request pipe so run_agent's readline() returns empty
|
|
176
|
+
if self._request_reader is not None:
|
|
177
|
+
self._request_reader.feed_eof()
|
|
178
|
+
|
|
179
|
+
# Cancel background tasks
|
|
180
|
+
for task in (self._agent_task, self._output_task):
|
|
181
|
+
if task is not None:
|
|
182
|
+
task.cancel()
|
|
183
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
184
|
+
await task
|
|
185
|
+
|
|
186
|
+
# Close the response writer
|
|
187
|
+
if self._response_writer is not None:
|
|
188
|
+
self._response_writer.close()
|
|
189
|
+
|
|
190
|
+
# Cancel any pending close tasks (avoid recursive await of self)
|
|
191
|
+
for t in list(self._pending_close_tasks):
|
|
192
|
+
t.cancel()
|
|
193
|
+
for t in list(self._pending_close_tasks):
|
|
194
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
195
|
+
await t
|
|
196
|
+
self._pending_close_tasks.clear()
|
|
197
|
+
|
|
198
|
+
# Shut down the ACP server (cleanup loop etc.)
|
|
199
|
+
await self.server.shutdown()
|
|
200
|
+
logger.info("Connection %s closed", self.connection_id[:8])
|
|
201
|
+
|
|
202
|
+
# -- internal ------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
async def _run_agent(self) -> None:
|
|
205
|
+
"""Run ``acp.run_agent`` with bridged streams."""
|
|
206
|
+
import acp
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
await acp.run_agent(
|
|
210
|
+
self.server,
|
|
211
|
+
# input_stream (StreamWriter): agent writes responses here
|
|
212
|
+
input_stream=self._response_writer,
|
|
213
|
+
# output_stream (StreamReader): agent reads requests from here
|
|
214
|
+
output_stream=self._request_reader,
|
|
215
|
+
use_unstable_protocol=True,
|
|
216
|
+
)
|
|
217
|
+
except asyncio.CancelledError:
|
|
218
|
+
raise
|
|
219
|
+
except Exception:
|
|
220
|
+
logger.exception("run_agent failed for connection %s", self.connection_id[:8])
|
|
221
|
+
finally:
|
|
222
|
+
# Ensure SSE is notified even on unexpected exit
|
|
223
|
+
if not self._closed:
|
|
224
|
+
try:
|
|
225
|
+
self._sse_queue.put_nowait(None)
|
|
226
|
+
except asyncio.QueueFull:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
async def _read_output(self) -> None:
|
|
230
|
+
"""Read agent output pipe and route messages to SSE queue or init response."""
|
|
231
|
+
if self._response_reader is None:
|
|
232
|
+
return
|
|
233
|
+
fatal_error = False
|
|
234
|
+
try:
|
|
235
|
+
while not self._closed:
|
|
236
|
+
line = await self._response_reader.readline()
|
|
237
|
+
if not line:
|
|
238
|
+
break
|
|
239
|
+
message = line.decode("utf-8").strip()
|
|
240
|
+
if not message:
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
# The first response is the initialize reply — deliver it
|
|
244
|
+
# synchronously so the POST handler can return it.
|
|
245
|
+
if not self._initialized.is_set():
|
|
246
|
+
self._init_response = message
|
|
247
|
+
self._initialized.set()
|
|
248
|
+
else:
|
|
249
|
+
try:
|
|
250
|
+
self._sse_queue.put_nowait(message)
|
|
251
|
+
except asyncio.QueueFull:
|
|
252
|
+
logger.warning("SSE queue full for connection %s, discarding message", self.connection_id[:8])
|
|
253
|
+
except asyncio.CancelledError:
|
|
254
|
+
raise
|
|
255
|
+
except Exception:
|
|
256
|
+
# An unrecoverable error occurred while reading from the agent.
|
|
257
|
+
# Continuing would leak the connection and leave clients waiting
|
|
258
|
+
# forever, so tear it down and let the SSE side observe EOF.
|
|
259
|
+
logger.exception("Output reader failed for connection %s", self.connection_id[:8])
|
|
260
|
+
fatal_error = True
|
|
261
|
+
finally:
|
|
262
|
+
if not self._closed:
|
|
263
|
+
try:
|
|
264
|
+
self._sse_queue.put_nowait(None)
|
|
265
|
+
except asyncio.QueueFull:
|
|
266
|
+
pass
|
|
267
|
+
if fatal_error and not self._closed:
|
|
268
|
+
# Drop this connection from the global pool as well so a new
|
|
269
|
+
# initialize is required to recover. close() is idempotent.
|
|
270
|
+
_connections.pop(self.connection_id, None)
|
|
271
|
+
# Schedule close so we don't await self from inside our own task.
|
|
272
|
+
task = asyncio.create_task(
|
|
273
|
+
self.close(),
|
|
274
|
+
name=f"acp-close-after-error-{self.connection_id[:8]}",
|
|
275
|
+
)
|
|
276
|
+
self._pending_close_tasks.add(task)
|
|
277
|
+
task.add_done_callback(self._pending_close_tasks.discard)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Connection pool
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
_connections: dict[str, HTTPConnectionBridge] = {}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# Starlette route handlers
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
_ACP_CONN_HEADER = "Acp-Connection-Id"
|
|
292
|
+
_INIT_TIMEOUT = 30.0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def _handle_post(request: Request) -> Response:
|
|
296
|
+
"""Handle ``POST /acp`` — JSON-RPC requests from the client."""
|
|
297
|
+
body = await request.body()
|
|
298
|
+
message = body.decode("utf-8")
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
parsed: dict[str, Any] = json.loads(message)
|
|
302
|
+
except json.JSONDecodeError:
|
|
303
|
+
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
|
304
|
+
|
|
305
|
+
method = parsed.get("method", "")
|
|
306
|
+
conn_id = request.headers.get(_ACP_CONN_HEADER.lower())
|
|
307
|
+
|
|
308
|
+
if method == "initialize":
|
|
309
|
+
# Create a new connection bridge
|
|
310
|
+
bridge = HTTPConnectionBridge()
|
|
311
|
+
await bridge.start()
|
|
312
|
+
await bridge.send_message(message)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
await asyncio.wait_for(bridge._initialized.wait(), timeout=_INIT_TIMEOUT)
|
|
316
|
+
except asyncio.TimeoutError:
|
|
317
|
+
await bridge.close()
|
|
318
|
+
return JSONResponse({"error": "Initialize timeout"}, status_code=504)
|
|
319
|
+
|
|
320
|
+
_connections[bridge.connection_id] = bridge
|
|
321
|
+
logger.info("New HTTP connection %s", bridge.connection_id[:8])
|
|
322
|
+
|
|
323
|
+
init_response = bridge._init_response
|
|
324
|
+
if init_response is None:
|
|
325
|
+
await bridge.close()
|
|
326
|
+
return JSONResponse({"error": "Initialize produced no response"}, status_code=502)
|
|
327
|
+
|
|
328
|
+
return JSONResponse(
|
|
329
|
+
content=json.loads(init_response),
|
|
330
|
+
headers={_ACP_CONN_HEADER: bridge.connection_id},
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Non-initialize requests require an existing connection
|
|
334
|
+
if not conn_id or conn_id not in _connections:
|
|
335
|
+
return JSONResponse(
|
|
336
|
+
{"error": "Connection not found. Send 'initialize' first or provide a valid Acp-Connection-Id header."},
|
|
337
|
+
status_code=400,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
bridge = _connections[conn_id]
|
|
341
|
+
await bridge.send_message(message)
|
|
342
|
+
|
|
343
|
+
# Return 202 Accepted — the real response arrives over the SSE stream
|
|
344
|
+
return Response(status_code=202)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def _handle_get(request: Request) -> Response:
|
|
348
|
+
"""Handle ``GET /acp`` — SSE stream for server-initiated messages."""
|
|
349
|
+
conn_id = request.headers.get(_ACP_CONN_HEADER.lower())
|
|
350
|
+
if not conn_id or conn_id not in _connections:
|
|
351
|
+
return JSONResponse(
|
|
352
|
+
{"error": "Connection not found. Provide a valid Acp-Connection-Id header."},
|
|
353
|
+
status_code=400,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
bridge = _connections[conn_id]
|
|
357
|
+
|
|
358
|
+
async def event_stream(): # type: ignore[return]
|
|
359
|
+
event_id = 0
|
|
360
|
+
try:
|
|
361
|
+
while True:
|
|
362
|
+
message = await bridge._sse_queue.get()
|
|
363
|
+
if message is None:
|
|
364
|
+
break
|
|
365
|
+
event_id += 1
|
|
366
|
+
yield f"event: message\ndata: {message}\nid: {event_id}\nretry: 5000\n\n"
|
|
367
|
+
finally:
|
|
368
|
+
logger.debug("SSE stream closed for connection %s", conn_id[:8])
|
|
369
|
+
|
|
370
|
+
from starlette.responses import StreamingResponse
|
|
371
|
+
|
|
372
|
+
return StreamingResponse(
|
|
373
|
+
event_stream(),
|
|
374
|
+
media_type="text/event-stream",
|
|
375
|
+
headers={
|
|
376
|
+
"Cache-Control": "no-cache",
|
|
377
|
+
"Connection": "keep-alive",
|
|
378
|
+
"X-Accel-Buffering": "no",
|
|
379
|
+
},
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
async def _handle_delete(request: Request) -> Response:
|
|
384
|
+
"""Handle ``DELETE /acp`` — close a connection."""
|
|
385
|
+
conn_id = request.headers.get(_ACP_CONN_HEADER.lower())
|
|
386
|
+
if conn_id and conn_id in _connections:
|
|
387
|
+
bridge = _connections.pop(conn_id)
|
|
388
|
+
await bridge.close()
|
|
389
|
+
return Response(status_code=200)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
async def _handle_health(request: Request) -> Response:
|
|
393
|
+
"""Handle ``GET /health`` — simple health check endpoint."""
|
|
394
|
+
return JSONResponse({"status": "healthy"})
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
# Bearer-token authentication middleware
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class _BearerTokenMiddleware(BaseHTTPMiddleware):
|
|
403
|
+
"""Reject requests when ``IACCODE_ACP_HTTP_TOKEN`` is set and the
|
|
404
|
+
``Authorization: Bearer <token>`` header doesn't match.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
async def dispatch(self, request: Request, call_next): # type: ignore[override]
|
|
408
|
+
token = os.environ.get("IACCODE_ACP_HTTP_TOKEN")
|
|
409
|
+
if token:
|
|
410
|
+
auth = request.headers.get("authorization", "")
|
|
411
|
+
# Constant-time comparison to mitigate timing attacks.
|
|
412
|
+
if not auth.startswith("Bearer ") or not hmac.compare_digest(auth[7:], token):
|
|
413
|
+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
|
414
|
+
return await call_next(request)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
# App factory
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
async def _cleanup_connections() -> None:
|
|
423
|
+
"""Close every open connection and shut down their servers."""
|
|
424
|
+
for bridge in list(_connections.values()):
|
|
425
|
+
await bridge.close()
|
|
426
|
+
_connections.clear()
|
|
427
|
+
logger.info("All HTTP connections cleaned up during shutdown")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def create_app() -> Starlette:
|
|
431
|
+
"""Create a Starlette ASGI application for the HTTP+SSE transport."""
|
|
432
|
+
from contextlib import asynccontextmanager
|
|
433
|
+
|
|
434
|
+
@asynccontextmanager
|
|
435
|
+
async def _lifespan(app: Starlette):
|
|
436
|
+
yield
|
|
437
|
+
await _cleanup_connections()
|
|
438
|
+
|
|
439
|
+
routes = [
|
|
440
|
+
Route("/acp", _handle_post, methods=["POST"]),
|
|
441
|
+
Route("/acp", _handle_get, methods=["GET"]),
|
|
442
|
+
Route("/acp", _handle_delete, methods=["DELETE"]),
|
|
443
|
+
Route("/health", _handle_health, methods=["GET"]),
|
|
444
|
+
]
|
|
445
|
+
|
|
446
|
+
app = Starlette(routes=routes, lifespan=_lifespan)
|
|
447
|
+
app.add_middleware(_BearerTokenMiddleware)
|
|
448
|
+
return app
|
iac_code/acp/mcp.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""MCP server configuration conversion module.
|
|
2
|
+
|
|
3
|
+
Converts MCP server configurations from the ACP client into the internal format.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from iac_code.acp.types import MCPServer
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def convert_mcp_configs(mcp_servers: list[MCPServer]) -> list[dict[str, Any]]:
|
|
17
|
+
"""Convert ACP MCP server configurations to the internal format.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
mcp_servers: List of MCP server configurations from the ACP SDK.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
List of converted internal MCP configurations.
|
|
24
|
+
"""
|
|
25
|
+
configs: list[dict[str, Any]] = []
|
|
26
|
+
for server in mcp_servers:
|
|
27
|
+
config = _convert_single_server(server)
|
|
28
|
+
if config:
|
|
29
|
+
configs.append(config)
|
|
30
|
+
return configs
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _convert_single_server(server: MCPServer) -> dict[str, Any] | None:
|
|
34
|
+
"""Convert a single MCP server configuration."""
|
|
35
|
+
import acp
|
|
36
|
+
|
|
37
|
+
if isinstance(server, acp.schema.McpServerStdio):
|
|
38
|
+
return {
|
|
39
|
+
"type": "stdio",
|
|
40
|
+
"command": server.command,
|
|
41
|
+
"args": list(server.args),
|
|
42
|
+
"env": {v.name: v.value for v in server.env} if server.env else {},
|
|
43
|
+
"name": server.name,
|
|
44
|
+
}
|
|
45
|
+
elif isinstance(server, (acp.schema.SseMcpServer, acp.schema.HttpMcpServer)):
|
|
46
|
+
return {
|
|
47
|
+
"type": getattr(server, "type", "sse"),
|
|
48
|
+
"url": server.url,
|
|
49
|
+
"headers": {h.name: h.value for h in server.headers} if server.headers else {},
|
|
50
|
+
"name": server.name,
|
|
51
|
+
}
|
|
52
|
+
else:
|
|
53
|
+
logger.warning("Unsupported MCP server type: %s", type(server).__name__)
|
|
54
|
+
return None
|
iac_code/acp/metrics.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""ACP Server basic runtime metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ACPMetrics:
|
|
11
|
+
"""ACP Server basic runtime metrics.
|
|
12
|
+
|
|
13
|
+
Thread-safety note: this class is designed for single-threaded async use
|
|
14
|
+
within one event loop. All mutations happen from coroutine code on the
|
|
15
|
+
same loop, so no locking is required.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
start_time: float = field(default_factory=time.monotonic)
|
|
19
|
+
total_sessions: int = 0
|
|
20
|
+
active_sessions: int = 0
|
|
21
|
+
total_prompts: int = 0
|
|
22
|
+
total_errors: int = 0
|
|
23
|
+
_prompt_durations: list[float] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
# -- mutators ------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def record_session_created(self) -> None:
|
|
28
|
+
"""Record that a new session was created."""
|
|
29
|
+
self.total_sessions += 1
|
|
30
|
+
self.active_sessions += 1
|
|
31
|
+
|
|
32
|
+
def record_session_closed(self) -> None:
|
|
33
|
+
"""Record that a session was closed."""
|
|
34
|
+
if self.active_sessions > 0:
|
|
35
|
+
self.active_sessions -= 1
|
|
36
|
+
|
|
37
|
+
def record_prompt(self, duration_ms: float) -> None:
|
|
38
|
+
"""Record a completed prompt with its duration in milliseconds."""
|
|
39
|
+
self.total_prompts += 1
|
|
40
|
+
self._prompt_durations.append(duration_ms)
|
|
41
|
+
|
|
42
|
+
def record_error(self) -> None:
|
|
43
|
+
"""Record an error occurrence."""
|
|
44
|
+
self.total_errors += 1
|
|
45
|
+
|
|
46
|
+
# -- computed properties -------------------------------------------------
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def avg_prompt_duration_ms(self) -> float:
|
|
50
|
+
"""Average prompt duration in milliseconds, or 0.0 if no prompts."""
|
|
51
|
+
if not self._prompt_durations:
|
|
52
|
+
return 0.0
|
|
53
|
+
return sum(self._prompt_durations) / len(self._prompt_durations)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def uptime_seconds(self) -> float:
|
|
57
|
+
"""Seconds since the metrics tracker was created."""
|
|
58
|
+
return time.monotonic() - self.start_time
|
|
59
|
+
|
|
60
|
+
# -- snapshot ------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def snapshot(self) -> dict:
|
|
63
|
+
"""Return a JSON-serialisable snapshot of all metrics."""
|
|
64
|
+
return {
|
|
65
|
+
"uptime_seconds": round(self.uptime_seconds, 2),
|
|
66
|
+
"total_sessions": self.total_sessions,
|
|
67
|
+
"active_sessions": self.active_sessions,
|
|
68
|
+
"total_prompts": self.total_prompts,
|
|
69
|
+
"total_errors": self.total_errors,
|
|
70
|
+
"avg_prompt_duration_ms": round(self.avg_prompt_duration_ms, 2),
|
|
71
|
+
}
|