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.
Files changed (184) hide show
  1. iac_code/__init__.py +2 -0
  2. iac_code/acp/__init__.py +97 -0
  3. iac_code/acp/convert.py +423 -0
  4. iac_code/acp/http_sse.py +448 -0
  5. iac_code/acp/mcp.py +54 -0
  6. iac_code/acp/metrics.py +71 -0
  7. iac_code/acp/server.py +662 -0
  8. iac_code/acp/session.py +446 -0
  9. iac_code/acp/slash_registry.py +125 -0
  10. iac_code/acp/state.py +99 -0
  11. iac_code/acp/tools.py +112 -0
  12. iac_code/acp/types.py +13 -0
  13. iac_code/acp/version.py +26 -0
  14. iac_code/agent/__init__.py +19 -0
  15. iac_code/agent/agent_loop.py +640 -0
  16. iac_code/agent/agent_tool.py +269 -0
  17. iac_code/agent/agent_types.py +87 -0
  18. iac_code/agent/message.py +153 -0
  19. iac_code/agent/system_prompt.py +313 -0
  20. iac_code/cli/__init__.py +3 -0
  21. iac_code/cli/headless.py +114 -0
  22. iac_code/cli/main.py +246 -0
  23. iac_code/cli/output_formats.py +125 -0
  24. iac_code/commands/__init__.py +93 -0
  25. iac_code/commands/auth.py +1055 -0
  26. iac_code/commands/clear.py +34 -0
  27. iac_code/commands/compact.py +43 -0
  28. iac_code/commands/debug.py +45 -0
  29. iac_code/commands/effort.py +116 -0
  30. iac_code/commands/exit.py +10 -0
  31. iac_code/commands/help.py +49 -0
  32. iac_code/commands/model.py +130 -0
  33. iac_code/commands/registry.py +245 -0
  34. iac_code/commands/resume.py +49 -0
  35. iac_code/commands/tasks.py +41 -0
  36. iac_code/config.py +304 -0
  37. iac_code/i18n/__init__.py +141 -0
  38. iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
  39. iac_code/memory/__init__.py +1 -0
  40. iac_code/memory/memory_manager.py +92 -0
  41. iac_code/memory/memory_tools.py +88 -0
  42. iac_code/providers/__init__.py +1 -0
  43. iac_code/providers/anthropic_provider.py +284 -0
  44. iac_code/providers/base.py +128 -0
  45. iac_code/providers/dashscope_provider.py +47 -0
  46. iac_code/providers/deepseek_provider.py +36 -0
  47. iac_code/providers/manager.py +399 -0
  48. iac_code/providers/openai_provider.py +344 -0
  49. iac_code/providers/retry.py +58 -0
  50. iac_code/providers/stream_watchdog.py +47 -0
  51. iac_code/providers/thinking.py +164 -0
  52. iac_code/services/__init__.py +1 -0
  53. iac_code/services/agent_factory.py +127 -0
  54. iac_code/services/cloud_credentials.py +22 -0
  55. iac_code/services/context_manager.py +221 -0
  56. iac_code/services/providers/__init__.py +1 -0
  57. iac_code/services/providers/aliyun.py +232 -0
  58. iac_code/services/session_index.py +281 -0
  59. iac_code/services/session_storage.py +245 -0
  60. iac_code/services/telemetry/__init__.py +66 -0
  61. iac_code/services/telemetry/attributes.py +84 -0
  62. iac_code/services/telemetry/client.py +330 -0
  63. iac_code/services/telemetry/config.py +76 -0
  64. iac_code/services/telemetry/constants.py +75 -0
  65. iac_code/services/telemetry/content_serializer.py +124 -0
  66. iac_code/services/telemetry/events.py +42 -0
  67. iac_code/services/telemetry/fallback.py +59 -0
  68. iac_code/services/telemetry/identity.py +73 -0
  69. iac_code/services/telemetry/metrics.py +62 -0
  70. iac_code/services/telemetry/names.py +199 -0
  71. iac_code/services/telemetry/sanitize.py +88 -0
  72. iac_code/services/telemetry/sink.py +67 -0
  73. iac_code/services/telemetry/tracing.py +38 -0
  74. iac_code/services/telemetry/types.py +13 -0
  75. iac_code/services/token_budget.py +54 -0
  76. iac_code/services/token_counter.py +76 -0
  77. iac_code/skills/__init__.py +1 -0
  78. iac_code/skills/bundled/__init__.py +94 -0
  79. iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
  80. iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
  81. iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
  82. iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
  83. iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
  84. iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
  85. iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
  86. iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
  87. iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
  88. iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
  89. iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
  90. iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
  91. iac_code/skills/bundled/simplify.py +28 -0
  92. iac_code/skills/discovery.py +136 -0
  93. iac_code/skills/frontmatter.py +119 -0
  94. iac_code/skills/listing.py +92 -0
  95. iac_code/skills/loader.py +42 -0
  96. iac_code/skills/processor.py +81 -0
  97. iac_code/skills/renderer.py +157 -0
  98. iac_code/skills/skill_definition.py +82 -0
  99. iac_code/skills/skill_tool.py +261 -0
  100. iac_code/state/__init__.py +5 -0
  101. iac_code/state/app_state.py +122 -0
  102. iac_code/tasks/__init__.py +1 -0
  103. iac_code/tasks/notification_queue.py +28 -0
  104. iac_code/tasks/task_state.py +66 -0
  105. iac_code/tasks/task_tools.py +114 -0
  106. iac_code/tools/__init__.py +8 -0
  107. iac_code/tools/base.py +226 -0
  108. iac_code/tools/bash.py +133 -0
  109. iac_code/tools/cloud/__init__.py +0 -0
  110. iac_code/tools/cloud/aliyun/__init__.py +0 -0
  111. iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
  112. iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
  113. iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
  114. iac_code/tools/cloud/aliyun/ros_client.py +56 -0
  115. iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
  116. iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
  117. iac_code/tools/cloud/base_api.py +162 -0
  118. iac_code/tools/cloud/base_stack.py +242 -0
  119. iac_code/tools/cloud/registry.py +20 -0
  120. iac_code/tools/cloud/types.py +105 -0
  121. iac_code/tools/edit_file.py +121 -0
  122. iac_code/tools/glob.py +103 -0
  123. iac_code/tools/grep.py +254 -0
  124. iac_code/tools/list_files.py +104 -0
  125. iac_code/tools/read_file.py +127 -0
  126. iac_code/tools/result_storage.py +39 -0
  127. iac_code/tools/tool_executor.py +165 -0
  128. iac_code/tools/web_fetch.py +177 -0
  129. iac_code/tools/write_file.py +88 -0
  130. iac_code/types/__init__.py +40 -0
  131. iac_code/types/permissions.py +26 -0
  132. iac_code/types/skill_source.py +11 -0
  133. iac_code/types/stream_events.py +227 -0
  134. iac_code/ui/__init__.py +5 -0
  135. iac_code/ui/banner.py +110 -0
  136. iac_code/ui/components/__init__.py +0 -0
  137. iac_code/ui/components/dialog.py +142 -0
  138. iac_code/ui/components/divider.py +20 -0
  139. iac_code/ui/components/fuzzy_picker.py +308 -0
  140. iac_code/ui/components/progress_bar.py +54 -0
  141. iac_code/ui/components/search_box.py +165 -0
  142. iac_code/ui/components/select.py +319 -0
  143. iac_code/ui/components/status_icon.py +42 -0
  144. iac_code/ui/components/tabs.py +128 -0
  145. iac_code/ui/core/__init__.py +0 -0
  146. iac_code/ui/core/in_place_render.py +129 -0
  147. iac_code/ui/core/input_history.py +118 -0
  148. iac_code/ui/core/key_event.py +41 -0
  149. iac_code/ui/core/prompt_input.py +507 -0
  150. iac_code/ui/core/raw_input.py +302 -0
  151. iac_code/ui/core/screen.py +80 -0
  152. iac_code/ui/dialogs/__init__.py +0 -0
  153. iac_code/ui/dialogs/global_search.py +178 -0
  154. iac_code/ui/dialogs/history_search.py +100 -0
  155. iac_code/ui/dialogs/model_picker.py +280 -0
  156. iac_code/ui/dialogs/quick_open.py +108 -0
  157. iac_code/ui/dialogs/resume_picker.py +749 -0
  158. iac_code/ui/keybindings/__init__.py +0 -0
  159. iac_code/ui/keybindings/manager.py +124 -0
  160. iac_code/ui/renderer.py +1535 -0
  161. iac_code/ui/repl.py +772 -0
  162. iac_code/ui/spinner.py +112 -0
  163. iac_code/ui/suggestions/__init__.py +0 -0
  164. iac_code/ui/suggestions/aggregator.py +171 -0
  165. iac_code/ui/suggestions/command_provider.py +43 -0
  166. iac_code/ui/suggestions/directory_provider.py +95 -0
  167. iac_code/ui/suggestions/file_provider.py +121 -0
  168. iac_code/ui/suggestions/shell_history_provider.py +108 -0
  169. iac_code/ui/suggestions/token_extractor.py +77 -0
  170. iac_code/ui/suggestions/types.py +45 -0
  171. iac_code/ui/transcript_view.py +199 -0
  172. iac_code/utils/__init__.py +0 -0
  173. iac_code/utils/background_housekeeping.py +53 -0
  174. iac_code/utils/cleanup.py +68 -0
  175. iac_code/utils/json_utils.py +60 -0
  176. iac_code/utils/log.py +150 -0
  177. iac_code/utils/project_paths.py +74 -0
  178. iac_code/utils/tool_input_parser.py +62 -0
  179. iac_code-0.1.0.dist-info/LICENSE +201 -0
  180. iac_code-0.1.0.dist-info/METADATA +64 -0
  181. iac_code-0.1.0.dist-info/RECORD +184 -0
  182. iac_code-0.1.0.dist-info/WHEEL +5 -0
  183. iac_code-0.1.0.dist-info/entry_points.txt +2 -0
  184. iac_code-0.1.0.dist-info/top_level.txt +1 -0
@@ -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
@@ -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
+ }