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,446 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import contextvars
6
+ import json
7
+ import logging
8
+ import time
9
+ import uuid
10
+ from collections import OrderedDict
11
+ from typing import Any
12
+
13
+ import acp
14
+
15
+ from iac_code.acp.convert import ACPEventConverter, acp_blocks_to_prompt_text
16
+ from iac_code.acp.metrics import ACPMetrics
17
+ from iac_code.acp.slash_registry import ACPSlashRegistry
18
+ from iac_code.acp.state import TurnState
19
+ from iac_code.acp.tools import ACPTerminalBashTool
20
+ from iac_code.acp.types import ACPContentBlock
21
+ from iac_code.agent.message import Message, TextBlock, ThinkingBlock, ToolResultBlock, ToolUseBlock
22
+ from iac_code.state.app_state import lookup_permission, record_permission
23
+ from iac_code.types.permissions import PermissionDecision
24
+ from iac_code.types.stream_events import PermissionRequestEvent
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _current_turn_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("_current_turn_id", default=None)
29
+
30
+
31
+ def _is_auth_error(exc: Exception) -> bool:
32
+ """Detect authentication / credential configuration errors."""
33
+ # Provider not configured (ValueError from create_provider)
34
+ if isinstance(exc, ValueError):
35
+ msg = str(exc).lower()
36
+ if "provider" in msg or "configure" in msg or "/auth" in msg:
37
+ return True
38
+
39
+ # SDK-level authentication errors (openai / anthropic)
40
+ exc_type_name = type(exc).__name__
41
+ if exc_type_name == "AuthenticationError":
42
+ return True
43
+
44
+ # HTTP 401 status from provider SDKs
45
+ status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
46
+ if status == 401:
47
+ return True
48
+
49
+ return False
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # History replay — convert Message objects to ACP session_update events
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def _history_message_to_updates(msg: Message) -> list[Any]:
58
+ """Convert a single persisted *Message* to a list of ACP session updates.
59
+
60
+ * **user** messages become ``UserMessageUpdate`` (ACP "user_message").
61
+ * **assistant** text / thinking become ``AgentMessageChunk`` / ``AgentThoughtChunk``.
62
+ * **assistant** tool-use blocks become ``ToolCallStart`` then a completed
63
+ ``ToolCallProgress``.
64
+ * **user** tool-result blocks are emitted as completed ``ToolCallProgress``.
65
+ """
66
+ updates: list[Any] = []
67
+ content = msg.content
68
+
69
+ if msg.role == "user":
70
+ # Simple text prompt
71
+ if isinstance(content, str):
72
+ updates.append(
73
+ acp.schema.UserMessageChunk(
74
+ session_update="user_message_chunk",
75
+ content=acp.schema.TextContentBlock(type="text", text=content),
76
+ )
77
+ )
78
+ return updates
79
+
80
+ # Tool-result blocks from a user message
81
+ for block in content:
82
+ if isinstance(block, ToolResultBlock):
83
+ status = "failed" if block.is_error else "completed"
84
+ updates.append(
85
+ acp.schema.ToolCallProgress(
86
+ session_update="tool_call_update",
87
+ tool_call_id=block.tool_use_id,
88
+ status=status,
89
+ content=[
90
+ acp.schema.ContentToolCallContent(
91
+ type="content",
92
+ content=acp.schema.TextContentBlock(type="text", text=block.content),
93
+ )
94
+ ],
95
+ )
96
+ )
97
+ return updates
98
+
99
+ # role == "assistant"
100
+ if isinstance(content, str):
101
+ updates.append(
102
+ acp.schema.AgentMessageChunk(
103
+ session_update="agent_message_chunk",
104
+ content=acp.schema.TextContentBlock(type="text", text=content),
105
+ )
106
+ )
107
+ return updates
108
+
109
+ for block in content:
110
+ if isinstance(block, TextBlock):
111
+ updates.append(
112
+ acp.schema.AgentMessageChunk(
113
+ session_update="agent_message_chunk",
114
+ content=acp.schema.TextContentBlock(type="text", text=block.text),
115
+ )
116
+ )
117
+ elif isinstance(block, ThinkingBlock):
118
+ updates.append(
119
+ acp.schema.AgentThoughtChunk(
120
+ session_update="agent_thought_chunk",
121
+ content=acp.schema.TextContentBlock(type="text", text=block.thinking),
122
+ )
123
+ )
124
+ elif isinstance(block, ToolUseBlock):
125
+ updates.append(
126
+ acp.schema.ToolCallStart(
127
+ session_update="tool_call",
128
+ tool_call_id=block.id,
129
+ title=block.name,
130
+ status="completed",
131
+ )
132
+ )
133
+ input_text = json.dumps(block.input, ensure_ascii=False) if block.input else ""
134
+ updates.append(
135
+ acp.schema.ToolCallProgress(
136
+ session_update="tool_call_update",
137
+ tool_call_id=block.id,
138
+ status="completed",
139
+ content=[
140
+ acp.schema.ContentToolCallContent(
141
+ type="content",
142
+ content=acp.schema.TextContentBlock(type="text", text=input_text),
143
+ )
144
+ ],
145
+ )
146
+ )
147
+ return updates
148
+
149
+
150
+ # Permission option IDs used in request_permission and cache lookups.
151
+ _OPTION_ALLOW_ONCE = "allow_once"
152
+ _OPTION_ALLOW_ALWAYS = "allow_always"
153
+ _OPTION_REJECT_ONCE = "reject_once"
154
+ _OPTION_REJECT_ALWAYS = "reject_always"
155
+
156
+
157
+ class ACPSession:
158
+ def __init__(
159
+ self,
160
+ session_id: str,
161
+ agent_loop,
162
+ conn: acp.Client,
163
+ mcp_configs: list[dict] | None = None,
164
+ metrics: ACPMetrics | None = None,
165
+ ) -> None:
166
+ self.id = session_id
167
+ self.agent_loop = agent_loop
168
+ self._conn = conn
169
+ self._current_task: asyncio.Task | None = None
170
+ self._replay_task: asyncio.Task[None] | None = None
171
+ self._current_turn: TurnState | None = None
172
+ self.last_active: float = time.monotonic()
173
+ # Per-session permission memory: tool_name -> "always_allow" | "always_deny".
174
+ # Bounded LRU to avoid unbounded growth on long-running sessions; oldest
175
+ # decisions are evicted once ``_PERMISSION_CACHE_MAX_SIZE`` is reached.
176
+ self._permission_cache: OrderedDict[str, PermissionDecision] = OrderedDict()
177
+ # Auto-detect tool names whose output is already displayed via ACP terminal.
178
+ self._terminal_tool_names: set[str] = self._detect_terminal_tools()
179
+ # MCP server configs passed from the client (internal dict format)
180
+ # TODO: Wire into agent tool registry when MCP tool integration is implemented
181
+ self.mcp_configs: list[dict] = mcp_configs or []
182
+ # Dynamic session configuration (temperature, max_tokens, etc.)
183
+ self._config: dict[str, Any] = {}
184
+ # Whether this session has been closed
185
+ self._closed: bool = False
186
+ # Optional metrics collector (shared with ACPServer)
187
+ self._metrics: ACPMetrics | None = metrics
188
+
189
+ def _detect_terminal_tools(self) -> set[str]:
190
+ """Inspect the agent_loop tool registry for ACP terminal tools."""
191
+ names: set[str] = set()
192
+ registry = getattr(self.agent_loop, "tool_registry", None)
193
+ if registry is None:
194
+ return names
195
+ for tool in registry.list_tools():
196
+ if isinstance(tool, ACPTerminalBashTool):
197
+ names.add(tool.name)
198
+ return names
199
+
200
+ def _context_snapshot(self) -> tuple[int, int]:
201
+ """Return ``(used_tokens, context_window_size)`` for this session.
202
+
203
+ Used by :class:`ACPEventConverter` to emit ACP ``UsageUpdate`` events
204
+ carrying current context-window occupancy. Returns ``(0, 0)`` if the
205
+ underlying ``agent_loop`` does not expose a ``context_manager``.
206
+ """
207
+ ctx = getattr(self.agent_loop, "context_manager", None)
208
+ if ctx is None:
209
+ return (0, 0)
210
+ return (ctx.get_total_tokens(), ctx.context_window)
211
+
212
+ def touch(self) -> None:
213
+ """Update last active timestamp."""
214
+ self.last_active = time.monotonic()
215
+
216
+ async def replay_history(self, messages: list[Message]) -> None:
217
+ """Replay persisted history as ACP session_update events.
218
+
219
+ Converts stored :class:`Message` objects into ACP ``session_update``
220
+ notifications so the client can rebuild its UI state after
221
+ ``load_session`` or ``fork_session``.
222
+ """
223
+ replay_batch_size = 50
224
+ for i, msg in enumerate(messages):
225
+ updates = _history_message_to_updates(msg)
226
+ for update in updates:
227
+ await self._conn.session_update(session_id=self.id, update=update)
228
+ if (i + 1) % replay_batch_size == 0:
229
+ await asyncio.sleep(0)
230
+
231
+ def update_config(self, config: dict[str, Any]) -> None:
232
+ """Update dynamic session configuration.
233
+
234
+ Merges *config* into the current session config. Keys like
235
+ ``temperature``, ``max_tokens`` etc. can be used by the agent loop
236
+ when supported.
237
+ """
238
+ self._config.update(config)
239
+
240
+ @property
241
+ def config(self) -> dict[str, Any]:
242
+ """Return a read-only snapshot of the current dynamic config."""
243
+ return dict(self._config)
244
+
245
+ @property
246
+ def is_closed(self) -> bool:
247
+ """Whether this session has been closed."""
248
+ return self._closed
249
+
250
+ async def close(self) -> None:
251
+ """Release all resources associated with this session.
252
+
253
+ This method is **idempotent**: calling it on an already-closed session
254
+ is a no-op.
255
+ """
256
+ if self._closed:
257
+ return
258
+
259
+ # Cancel any running prompt task
260
+ if self._current_task is not None and not self._current_task.done():
261
+ self._current_task.cancel()
262
+ with contextlib.suppress(asyncio.CancelledError):
263
+ await self._current_task
264
+ self._current_task = None
265
+
266
+ # Cancel any running replay task
267
+ if self._replay_task is not None and not self._replay_task.done():
268
+ self._replay_task.cancel()
269
+ with contextlib.suppress(asyncio.CancelledError):
270
+ await self._replay_task
271
+ self._replay_task = None
272
+
273
+ # Clean up turn state
274
+ self._current_turn = None
275
+
276
+ # Clear permission cache and config
277
+ self._permission_cache.clear()
278
+ self._config.clear()
279
+
280
+ self._closed = True
281
+ logger.info("Session %s closed", self.id)
282
+
283
+ async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse:
284
+ if self._closed:
285
+ raise acp.RequestError.internal_error({"error": "Session is closed"})
286
+ self.touch()
287
+
288
+ # Intercept slash commands before sending to agent loop
289
+ prompt_text = acp_blocks_to_prompt_text(prompt)
290
+ slash_registry = ACPSlashRegistry()
291
+ if slash_registry.is_slash_command(prompt_text):
292
+ result = await slash_registry.execute(prompt_text, self.agent_loop)
293
+ await self._conn.session_update(
294
+ session_id=self.id,
295
+ update=acp.schema.AgentMessageChunk(
296
+ session_update="agent_message_chunk",
297
+ content=acp.schema.TextContentBlock(type="text", text=result),
298
+ ),
299
+ )
300
+ return acp.PromptResponse(stop_reason="end_turn")
301
+
302
+ converter: ACPEventConverter | None = None
303
+
304
+ async def _run() -> None:
305
+ nonlocal converter
306
+ turn_id = str(uuid.uuid4())
307
+ _current_turn_id.set(turn_id)
308
+ self._current_turn = TurnState(turn_id=turn_id)
309
+ converter = ACPEventConverter(
310
+ turn_id=turn_id,
311
+ turn_state=self._current_turn,
312
+ terminal_tool_names=self._terminal_tool_names,
313
+ context_snapshot=self._context_snapshot,
314
+ )
315
+ logger.debug("Prompt started, session_id=%s, turn_id=%s", self.id, turn_id)
316
+ async for event in self.agent_loop.run_streaming(prompt_text):
317
+ if isinstance(event, PermissionRequestEvent):
318
+ allowed = await self._request_permission(event)
319
+ if event.response_future is not None and not event.response_future.done():
320
+ event.response_future.set_result(allowed)
321
+ continue
322
+
323
+ for update in converter.event_to_updates(event):
324
+ await self._conn.session_update(session_id=self.id, update=update)
325
+
326
+ prompt_start = time.monotonic()
327
+ self._current_task = asyncio.create_task(_run())
328
+ try:
329
+ await self._current_task
330
+ except asyncio.CancelledError:
331
+ elapsed_ms = int((time.monotonic() - prompt_start) * 1000)
332
+ logger.info("Prompt cancelled, session_id=%s, elapsed_ms=%d", self.id, elapsed_ms)
333
+ return acp.PromptResponse(stop_reason="cancelled")
334
+ except Exception as exc:
335
+ if self._metrics is not None:
336
+ self._metrics.record_error()
337
+ if _is_auth_error(exc):
338
+ logger.warning("ACP session %s: authentication error: %s", self.id, exc)
339
+ raise acp.RequestError.internal_error(
340
+ {
341
+ "error": "Authentication required. Please configure your API credentials.",
342
+ "code": "auth_required",
343
+ }
344
+ ) from exc
345
+ logger.error("ACP session %s: unhandled error: %s", self.id, exc, exc_info=True)
346
+ raise acp.RequestError.internal_error({"error": str(exc)}) from exc
347
+ finally:
348
+ self._current_task = None
349
+ duration_ms = (time.monotonic() - prompt_start) * 1000
350
+ if self._metrics is not None:
351
+ self._metrics.record_prompt(duration_ms)
352
+
353
+ self.touch()
354
+
355
+ # Build _meta with timing and token usage
356
+ elapsed_ms = int((time.monotonic() - prompt_start) * 1000)
357
+ meta: dict[str, Any] = {"timing": {"elapsed_ms": elapsed_ms}}
358
+ if converter is not None and converter._last_usage is not None:
359
+ usage = converter._last_usage
360
+ meta["usage"] = {
361
+ "input_tokens": usage.input_tokens,
362
+ "output_tokens": usage.output_tokens,
363
+ "total_tokens": usage.total_tokens,
364
+ }
365
+ logger.debug("Prompt completed, session_id=%s, elapsed_ms=%d", self.id, elapsed_ms)
366
+
367
+ response = acp.PromptResponse(stop_reason="end_turn")
368
+ response.field_meta = meta
369
+ return response
370
+
371
+ async def cancel(self) -> None:
372
+ if self._current_task is not None and not self._current_task.done():
373
+ logger.info("Session %s cancel requested", self.id)
374
+ self._current_task.cancel()
375
+
376
+ async def _request_permission(self, event: PermissionRequestEvent) -> bool:
377
+ tool_name = event.tool_name
378
+
379
+ # Check permission cache first; helper marks the entry as recently-used.
380
+ cached = lookup_permission(self._permission_cache, tool_name)
381
+ if cached == "always_allow":
382
+ logger.debug("Permission auto-allowed for tool %s (cached)", tool_name)
383
+ return True
384
+ if cached == "always_deny":
385
+ logger.debug("Permission auto-denied for tool %s (cached)", tool_name)
386
+ return False
387
+
388
+ response = await self._conn.request_permission(
389
+ [
390
+ acp.schema.PermissionOption(
391
+ option_id=_OPTION_ALLOW_ONCE,
392
+ name="Allow once",
393
+ kind="allow_once",
394
+ ),
395
+ acp.schema.PermissionOption(
396
+ option_id=_OPTION_ALLOW_ALWAYS,
397
+ name="Always allow",
398
+ kind="allow_always",
399
+ ),
400
+ acp.schema.PermissionOption(
401
+ option_id=_OPTION_REJECT_ONCE,
402
+ name="Reject once",
403
+ kind="reject_once",
404
+ ),
405
+ acp.schema.PermissionOption(
406
+ option_id=_OPTION_REJECT_ALWAYS,
407
+ name="Always reject",
408
+ kind="reject_always",
409
+ ),
410
+ ],
411
+ self.id,
412
+ acp.schema.ToolCallUpdate(
413
+ tool_call_id=f"permission/{event.tool_use_id}",
414
+ title=event.tool_name,
415
+ content=[
416
+ acp.schema.ContentToolCallContent(
417
+ type="content",
418
+ content=acp.schema.TextContentBlock(
419
+ type="text",
420
+ text=f"Approve tool call {event.tool_name} with input: {event.tool_input}",
421
+ ),
422
+ )
423
+ ],
424
+ ),
425
+ )
426
+
427
+ # Interpret the outcome and update the permission cache
428
+ if isinstance(response.outcome, acp.schema.AllowedOutcome):
429
+ option_id = response.outcome.option_id
430
+ if option_id == _OPTION_ALLOW_ALWAYS:
431
+ self._cache_permission(tool_name, "always_allow")
432
+ return True
433
+
434
+ # DeniedOutcome — the ACP SDK DeniedOutcome has no option_id field,
435
+ # so clients that want to signal "reject_always" should set
436
+ # _meta={"option_id": "reject_always"} on the *response* envelope.
437
+ if isinstance(response.outcome, acp.schema.DeniedOutcome):
438
+ resp_meta = getattr(response, "field_meta", None) or {}
439
+ if resp_meta.get("option_id") == _OPTION_REJECT_ALWAYS:
440
+ self._cache_permission(tool_name, "always_deny")
441
+
442
+ return False
443
+
444
+ def _cache_permission(self, tool_name: str, decision: PermissionDecision) -> None:
445
+ """Record a sticky permission decision via the shared helper."""
446
+ record_permission(self._permission_cache, tool_name, decision)
@@ -0,0 +1,125 @@
1
+ """ACP slash command registry.
2
+
3
+ Manages commands supported over the ACP protocol.
4
+ Only /compact, /clear, and /debug are allowed;
5
+ all other slash commands are rejected with a clear message.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from iac_code.i18n import _
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ ACP_SUPPORTED_COMMANDS: frozenset[str] = frozenset({"compact", "clear", "debug"})
17
+
18
+
19
+ class ACPSlashRegistry:
20
+ """Registry for ACP-supported slash commands.
21
+
22
+ Parses incoming text for slash command patterns, validates them against the
23
+ supported set, dispatches execution, and returns plain-text results.
24
+ """
25
+
26
+ def is_slash_command(self, text: str) -> bool:
27
+ """Return True if *text* starts with a slash command pattern."""
28
+ stripped = text.strip()
29
+ return stripped.startswith("/") and len(stripped) > 1 and not stripped.startswith("//")
30
+
31
+ async def execute(self, text: str, agent_loop, **context) -> str:
32
+ """Execute a slash command and return the result text.
33
+
34
+ If the command is not in :data:`ACP_SUPPORTED_COMMANDS`, returns a
35
+ rejection message listing available commands.
36
+ """
37
+ stripped = text.strip()
38
+ parts = stripped[1:].split(None, 1)
39
+ cmd_name = parts[0].lower() if parts else ""
40
+ args_str = parts[1] if len(parts) > 1 else ""
41
+
42
+ if cmd_name not in ACP_SUPPORTED_COMMANDS:
43
+ supported = ", ".join(f"/{c}" for c in sorted(ACP_SUPPORTED_COMMANDS))
44
+ return _("Command '/{cmd_name}' is not supported over ACP. Supported commands: {supported}").format(
45
+ cmd_name=cmd_name, supported=supported
46
+ )
47
+
48
+ if cmd_name == "compact":
49
+ return await self._handle_compact(agent_loop)
50
+ if cmd_name == "clear":
51
+ return await self._handle_clear(agent_loop)
52
+ if cmd_name == "debug":
53
+ return self._handle_debug(args_str)
54
+
55
+ # Should not reach here
56
+ return _("Command '/{cmd_name}' handler not implemented.").format(cmd_name=cmd_name) # pragma: no cover
57
+
58
+ # ------------------------------------------------------------------
59
+ # Individual command handlers
60
+ # ------------------------------------------------------------------
61
+
62
+ async def _handle_compact(self, agent_loop) -> str:
63
+ """Invoke agent_loop.compact() and return a summary."""
64
+ try:
65
+ result = await agent_loop.compact()
66
+ except Exception as exc:
67
+ logger.warning("ACP /compact failed: %s", exc)
68
+ return _("Compaction failed: {error}").format(error=exc)
69
+
70
+ if result.status == "empty":
71
+ return _("Nothing to compact: conversation is empty.")
72
+ if result.status == "too_short":
73
+ return _(
74
+ "Conversation too short to compact: all messages are within "
75
+ "the recent {turns}-turn preservation window."
76
+ ).format(turns=result.preserve_recent_turns)
77
+ if result.status == "failed":
78
+ return _("Compaction failed. See logs for details.")
79
+
80
+ usage_after = agent_loop.get_context_usage()
81
+ percent = (1 - result.compacted_tokens / result.original_tokens) * 100 if result.original_tokens > 0 else 0
82
+ return _(
83
+ "Context compacted: {original} → {compacted} tokens ({percent} reduction). Context usage: {usage}"
84
+ ).format(
85
+ original=result.original_tokens,
86
+ compacted=result.compacted_tokens,
87
+ percent=f"{percent:.0f}%",
88
+ usage=f"{usage_after['usage_percent']:.0f}%",
89
+ )
90
+
91
+ async def _handle_clear(self, agent_loop) -> str:
92
+ """Clear the agent_loop conversation history."""
93
+ try:
94
+ agent_loop.context_manager.reset()
95
+ except Exception as exc:
96
+ logger.warning("ACP /clear failed: %s", exc)
97
+ return _("Clear failed: {error}").format(error=exc)
98
+ return _("Conversation history cleared.")
99
+
100
+ def _handle_debug(self, args: str) -> str:
101
+ """Toggle debug logging based on args ('on'/'off'/empty)."""
102
+ from iac_code.utils.log import (
103
+ current_log_file,
104
+ disable_debug_at_runtime,
105
+ enable_debug_at_runtime,
106
+ is_debug_enabled,
107
+ )
108
+
109
+ action = args.strip().lower()
110
+
111
+ if action in ("", "status"):
112
+ if is_debug_enabled():
113
+ log_path = current_log_file()
114
+ return _("Debug logging is on. Log file: {path}").format(path=log_path)
115
+ return _("Debug logging is off.")
116
+
117
+ if action == "on":
118
+ log_path = enable_debug_at_runtime("acp")
119
+ return _("Debug logging enabled. Log file: {path}").format(path=log_path)
120
+
121
+ if action == "off":
122
+ disable_debug_at_runtime()
123
+ return _("Debug logging disabled.")
124
+
125
+ return _("Usage: /debug [on|off]")
iac_code/acp/state.py ADDED
@@ -0,0 +1,99 @@
1
+ """TurnState / ToolCallState — track per-turn and per-tool-call state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from dataclasses import dataclass, field
8
+
9
+
10
+ @dataclass
11
+ class ToolCallState:
12
+ """Track the streaming state of a single tool call."""
13
+
14
+ tool_call_id: str
15
+ tool_name: str
16
+ accumulated_input: str = ""
17
+ title: str = ""
18
+ start_time: float = 0.0
19
+
20
+ def __post_init__(self) -> None:
21
+ if self.start_time == 0.0:
22
+ self.start_time = time.monotonic()
23
+
24
+ @property
25
+ def elapsed_ms(self) -> int:
26
+ """Milliseconds elapsed since the tool call started."""
27
+ return int((time.monotonic() - self.start_time) * 1000)
28
+
29
+ def update_input(self, delta: str) -> None:
30
+ self.accumulated_input += delta
31
+ self._update_title()
32
+
33
+ def _update_title(self) -> None:
34
+ """Compute a display title from tool name + streamed arguments."""
35
+ subtitle = _extract_key_argument(self.tool_name, self.accumulated_input)
36
+ self.title = f"{self.tool_name}: {subtitle}" if subtitle else self.tool_name
37
+
38
+
39
+ @dataclass
40
+ class TurnState:
41
+ """Track all state within a single prompt turn."""
42
+
43
+ turn_id: str
44
+ tool_calls: dict[str, ToolCallState] = field(default_factory=dict)
45
+
46
+ def start_tool_call(self, tool_call_id: str, tool_name: str) -> ToolCallState:
47
+ state = ToolCallState(tool_call_id=tool_call_id, tool_name=tool_name)
48
+ state.title = tool_name
49
+ self.tool_calls[tool_call_id] = state
50
+ return state
51
+
52
+ def get_tool_call(self, tool_call_id: str) -> ToolCallState | None:
53
+ return self.tool_calls.get(tool_call_id)
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Helpers
58
+ # ---------------------------------------------------------------------------
59
+
60
+ # Map of tool name -> JSON key whose value makes a good subtitle.
61
+ _KEY_ARG_MAP: dict[str, str] = {
62
+ "bash": "command",
63
+ "read_file": "file_path",
64
+ "write_file": "file_path",
65
+ "edit_file": "file_path",
66
+ "glob": "pattern",
67
+ "grep": "pattern",
68
+ "list_files": "path",
69
+ "web_fetch": "url",
70
+ }
71
+
72
+ _SUBTITLE_MAX_LEN = 60
73
+
74
+
75
+ def _extract_key_argument(tool_name: str, raw_json: str) -> str:
76
+ """Best-effort extraction of a subtitle from partial/complete JSON args."""
77
+ key = _KEY_ARG_MAP.get(tool_name)
78
+ if not key:
79
+ return ""
80
+ try:
81
+ obj = json.loads(raw_json)
82
+ value = obj.get(key, "")
83
+ if isinstance(value, str) and value:
84
+ return value[:_SUBTITLE_MAX_LEN]
85
+ except (json.JSONDecodeError, TypeError, AttributeError):
86
+ # Partial JSON — fall back to naive substring search.
87
+ marker = f'"{key}"'
88
+ idx = raw_json.find(marker)
89
+ if idx == -1:
90
+ return ""
91
+ # Skip past `"key": "` to grab value chars.
92
+ rest = raw_json[idx + len(marker) :]
93
+ rest = rest.lstrip(": ")
94
+ if rest.startswith('"'):
95
+ rest = rest[1:]
96
+ end = rest.find('"')
97
+ snippet = rest[:end] if end != -1 else rest
98
+ return snippet[:_SUBTITLE_MAX_LEN]
99
+ return ""