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,640 @@
1
+ """Agent Loop - the core execution loop using ProviderManager and concurrent tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import time
8
+ import uuid
9
+ from collections.abc import AsyncGenerator
10
+ from dataclasses import dataclass
11
+ from typing import Any, Literal
12
+
13
+ from loguru import logger
14
+
15
+ from iac_code.agent.message import TextBlock, ThinkingBlock, ToolResultBlock, ToolUseBlock
16
+ from iac_code.services.context_manager import ContextManager
17
+ from iac_code.tools.base import ToolContext, ToolRegistry, ToolResult
18
+ from iac_code.tools.result_storage import ResultStorage
19
+ from iac_code.tools.tool_executor import ToolCallRequest, ToolExecutor
20
+ from iac_code.types.stream_events import (
21
+ CompactionEvent,
22
+ MessageEndEvent,
23
+ PermissionRequestEvent,
24
+ StackInstancesProgressEvent,
25
+ StackProgressEvent,
26
+ StreamEvent,
27
+ SubAgentToolEvent,
28
+ TextDeltaEvent,
29
+ ThinkingDeltaEvent,
30
+ TombstoneEvent,
31
+ ToolResultEvent,
32
+ ToolUseEndEvent,
33
+ ToolUseStartEvent,
34
+ )
35
+
36
+
37
+ @dataclass
38
+ class CompactResult:
39
+ """Outcome of a manual /compact invocation.
40
+
41
+ ``status`` distinguishes between meaningful no-ops ("empty",
42
+ "too_short") and real failures so the UI can show an accurate message
43
+ instead of lumping them together.
44
+ """
45
+
46
+ status: Literal["success", "empty", "too_short", "failed"]
47
+ original_tokens: int = 0
48
+ compacted_tokens: int = 0
49
+ preserve_recent_turns: int = 0
50
+
51
+
52
+ class AgentLoop:
53
+ """The main agent execution loop.
54
+
55
+ Uses ProviderManager for LLM calls, ToolExecutor for concurrent tool execution,
56
+ and yields fine-grained StreamEvents for the UI layer.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ provider_manager: Any, # ProviderManager (avoid circular import)
62
+ system_prompt: str,
63
+ tool_registry: ToolRegistry,
64
+ max_turns: int = 100,
65
+ session_storage: Any = None, # SessionStorage
66
+ session_id: str | None = None,
67
+ resume_messages: list | None = None,
68
+ cwd: str | None = None,
69
+ ) -> None:
70
+ self._provider_manager = provider_manager
71
+ self.system_prompt = system_prompt
72
+ self.tool_registry = tool_registry
73
+ self._max_turns = max_turns
74
+ self._session_storage = session_storage
75
+ self._session_id = session_id or str(uuid.uuid4())[:8]
76
+ self._cwd = cwd or os.getcwd()
77
+ self._current_git_branch: str | None = None
78
+
79
+ model_name = ""
80
+ if hasattr(provider_manager, "get_model_name"):
81
+ model_name = provider_manager.get_model_name()
82
+
83
+ self.context_manager = ContextManager(system_prompt=system_prompt, model=model_name)
84
+ if resume_messages:
85
+ self.context_manager.load_messages(resume_messages)
86
+ self._tool_executor = ToolExecutor(registry=tool_registry)
87
+ from iac_code.config import get_config_dir
88
+
89
+ self._result_storage = ResultStorage(
90
+ storage_dir=os.path.join(str(get_config_dir()), "tool-results", self._session_id),
91
+ )
92
+
93
+ def set_provider(self, provider_manager: Any, system_prompt: str | None = None) -> None:
94
+ """Swap the provider manager in place, preserving conversation history.
95
+
96
+ Updates the tokenizer/context-window config when the model name changes.
97
+ Optionally refreshes the system prompt — useful when memory or skill
98
+ listing has changed since the loop was constructed.
99
+ """
100
+ self._provider_manager = provider_manager
101
+ new_model = provider_manager.get_model_name() if hasattr(provider_manager, "get_model_name") else ""
102
+ self.context_manager.set_model(new_model)
103
+ if system_prompt is not None:
104
+ self.system_prompt = system_prompt
105
+ self.context_manager.set_system_prompt(system_prompt)
106
+
107
+ def _get_tool_definitions(self):
108
+ """Convert tool registry to provider ToolDefinition format."""
109
+ from iac_code.providers.base import ToolDefinition
110
+
111
+ tools = []
112
+ for tool in self.tool_registry.list_tools():
113
+ tools.append(
114
+ ToolDefinition(
115
+ name=tool.name,
116
+ description=tool.description,
117
+ input_schema=tool.input_schema,
118
+ )
119
+ )
120
+ return tools
121
+
122
+ def _get_provider_messages(self):
123
+ """Convert context manager messages to provider Message format."""
124
+ from iac_code.providers.base import ContentBlock
125
+ from iac_code.providers.base import Message as ProviderMessage
126
+
127
+ api_messages = self.context_manager.get_api_messages()
128
+ provider_messages = []
129
+ for msg in api_messages:
130
+ role = msg["role"]
131
+ content = msg["content"]
132
+ if isinstance(content, str):
133
+ provider_messages.append(ProviderMessage(role=role, content=content))
134
+ elif isinstance(content, list):
135
+ blocks = []
136
+ for block in content:
137
+ if isinstance(block, dict):
138
+ block_type = block.get("type", "text")
139
+ text_value = block.get("thinking") if block_type == "thinking" else block.get("text")
140
+ blocks.append(
141
+ ContentBlock(
142
+ type=block_type,
143
+ text=text_value,
144
+ tool_use_id=block.get("tool_use_id") or block.get("id"),
145
+ name=block.get("name"),
146
+ input=block.get("input"),
147
+ content=block.get("content"),
148
+ is_error=block.get("is_error", False),
149
+ )
150
+ )
151
+ provider_messages.append(ProviderMessage(role=role, content=blocks))
152
+ return provider_messages
153
+
154
+ async def run(self, user_input: str) -> str:
155
+ """Non-streaming execution. Returns final text."""
156
+ final_text = ""
157
+ async for event in self.run_streaming(user_input):
158
+ if isinstance(event, TextDeltaEvent):
159
+ final_text += event.text
160
+ return final_text
161
+
162
+ async def run_streaming(self, user_input: str) -> AsyncGenerator[StreamEvent, None]:
163
+ """Streaming execution yielding fine-grained StreamEvents.
164
+
165
+ Flow:
166
+ 1. Add user message to context
167
+ 2. Call provider.stream() -> yields StreamEvents
168
+ 3. Collect tool_use from events
169
+ 4. Execute tools concurrently via ToolExecutor
170
+ 5. Yield ToolResultEvents
171
+ 6. Loop back to step 2 if tools were called
172
+ """
173
+ from iac_code.services.telemetry import add_metric, get_session_id, get_user_id, log_event, start_span
174
+ from iac_code.services.telemetry.config import should_capture_content_on_span
175
+ from iac_code.services.telemetry.content_serializer import serialize_output_messages
176
+ from iac_code.services.telemetry.names import (
177
+ FRAMEWORK_IAC_CODE,
178
+ Events,
179
+ GenAiAttr,
180
+ GenAiOperationName,
181
+ GenAiSpanKind,
182
+ Metrics,
183
+ Spans,
184
+ )
185
+
186
+ entry_attrs: dict[str, Any] = {
187
+ GenAiAttr.SPAN_KIND: GenAiSpanKind.ENTRY,
188
+ GenAiAttr.OPERATION_NAME: GenAiOperationName.ENTER,
189
+ GenAiAttr.SESSION_ID: get_session_id(),
190
+ GenAiAttr.USER_ID: get_user_id(),
191
+ GenAiAttr.FRAMEWORK: FRAMEWORK_IAC_CODE,
192
+ }
193
+ if should_capture_content_on_span():
194
+ from iac_code.services.telemetry.content_serializer import (
195
+ serialize_system_instructions,
196
+ serialize_user_input,
197
+ )
198
+
199
+ entry_attrs[GenAiAttr.INPUT_MESSAGES] = serialize_user_input(user_input)
200
+ entry_attrs[GenAiAttr.SYSTEM_INSTRUCTIONS] = serialize_system_instructions(self.system_prompt)
201
+
202
+ with start_span(Spans.ENTRY, entry_attrs) as entry_span:
203
+ interaction_started = time.monotonic()
204
+ first_token_received = False
205
+ final_text_chunks: list[str] = []
206
+ final_stop_reason = "stop"
207
+ try:
208
+ # Refresh the git branch once per turn — branch may change
209
+ # between turns (user runs git checkout via Bash tool), but
210
+ # is treated as stable within a single in-flight request.
211
+ self._refresh_git_branch()
212
+ self.context_manager.add_user_message(user_input)
213
+ if self._session_storage:
214
+ from iac_code.agent.message import Message
215
+
216
+ self._session_storage.append(
217
+ self._cwd,
218
+ self._session_id,
219
+ Message(role="user", content=user_input),
220
+ git_branch=self._current_git_branch,
221
+ )
222
+ try:
223
+ async for event in self._run_streaming_inner(user_input):
224
+ if isinstance(event, TextDeltaEvent) and not first_token_received:
225
+ first_token_received = True
226
+ ttft_ns = int((time.monotonic() - interaction_started) * 1_000_000_000)
227
+ entry_span.set_attribute(GenAiAttr.RESPONSE_TIME_TO_FIRST_TOKEN, ttft_ns)
228
+ entry_span.set_attribute(GenAiAttr.USER_TIME_TO_FIRST_TOKEN, ttft_ns)
229
+ if isinstance(event, TextDeltaEvent):
230
+ final_text_chunks.append(event.text)
231
+ if isinstance(event, MessageEndEvent):
232
+ final_stop_reason = event.stop_reason
233
+ yield event
234
+ except asyncio.CancelledError:
235
+ log_event(Events.SESSION_CANCELLED, {"stage": "in_query"})
236
+ raise
237
+ finally:
238
+ elapsed = time.monotonic() - interaction_started
239
+ add_metric(Metrics.ACTIVE_TIME_TOTAL, int(elapsed), {})
240
+ if should_capture_content_on_span() and final_text_chunks:
241
+ entry_span.set_attribute(
242
+ GenAiAttr.OUTPUT_MESSAGES,
243
+ serialize_output_messages("".join(final_text_chunks), final_stop_reason),
244
+ )
245
+
246
+ async def _run_streaming_inner(self, user_input: str) -> AsyncGenerator[StreamEvent, None]:
247
+ """Inner streaming loop (called from run_streaming inside the ENTRY span)."""
248
+ from iac_code.services.telemetry import start_span
249
+ from iac_code.services.telemetry.names import GenAiAttr, GenAiOperationName, GenAiSpanKind, Spans
250
+
251
+ tool_definitions = self._get_tool_definitions()
252
+
253
+ for _turn in range(self._max_turns):
254
+ # Auto-compact if needed
255
+ if self.context_manager.needs_compaction():
256
+ compact_event = await self._auto_compact()
257
+ if compact_event:
258
+ yield compact_event
259
+
260
+ step_attrs = {
261
+ GenAiAttr.SPAN_KIND: GenAiSpanKind.STEP,
262
+ GenAiAttr.OPERATION_NAME: GenAiOperationName.REACT,
263
+ GenAiAttr.REACT_ROUND: _turn + 1,
264
+ }
265
+
266
+ with start_span(Spans.REACT_STEP, step_attrs) as step_span:
267
+ # Collect tool uses from this turn (keyed by tool_use_id)
268
+ pending_tool_uses_by_id: dict[str, dict[str, Any]] = {}
269
+ text_chunks: list[str] = []
270
+ thinking_chunks: list[str] = []
271
+ message_ended = False
272
+
273
+ # Stream from provider
274
+ async for event in self._provider_manager.stream(
275
+ messages=self._get_provider_messages(),
276
+ system=self.system_prompt,
277
+ tools=tool_definitions if self.tool_registry.list_tools() else None,
278
+ ):
279
+ yield event # Forward all provider events to UI
280
+
281
+ # Collect data from events
282
+ if isinstance(event, TextDeltaEvent):
283
+ text_chunks.append(event.text)
284
+ elif isinstance(event, ThinkingDeltaEvent):
285
+ thinking_chunks.append(event.text)
286
+ elif isinstance(event, ToolUseStartEvent):
287
+ pending_tool_uses_by_id.setdefault(event.tool_use_id, {})
288
+ pending_tool_uses_by_id[event.tool_use_id]["id"] = event.tool_use_id
289
+ pending_tool_uses_by_id[event.tool_use_id]["name"] = event.name
290
+ elif isinstance(event, ToolUseEndEvent):
291
+ pending_tool_uses_by_id.setdefault(event.tool_use_id, {})
292
+ pending_tool_uses_by_id[event.tool_use_id]["id"] = event.tool_use_id
293
+ pending_tool_uses_by_id[event.tool_use_id]["input"] = event.input
294
+ elif isinstance(event, TombstoneEvent):
295
+ pending_tool_uses_by_id.clear()
296
+ text_chunks.clear()
297
+ thinking_chunks.clear()
298
+ elif isinstance(event, MessageEndEvent):
299
+ message_ended = True
300
+
301
+ if not message_ended:
302
+ step_span.set_attribute(GenAiAttr.REACT_FINISH_REASON, "error")
303
+ break
304
+
305
+ # Build assistant message for context
306
+ assistant_blocks = []
307
+ full_thinking = "".join(thinking_chunks)
308
+ if full_thinking:
309
+ assistant_blocks.append(ThinkingBlock(thinking=full_thinking))
310
+ full_text = "".join(text_chunks)
311
+ if full_text:
312
+ assistant_blocks.append(TextBlock(text=full_text))
313
+
314
+ # Collect completed tool uses (those with both name and input)
315
+ completed_tools = []
316
+ for tu in pending_tool_uses_by_id.values():
317
+ if "name" in tu and "input" in tu:
318
+ completed_tools.append(tu)
319
+ assistant_blocks.append(ToolUseBlock(id=tu["id"], name=tu["name"], input=tu.get("input", {})))
320
+
321
+ if assistant_blocks:
322
+ self.context_manager.add_assistant_message(assistant_blocks)
323
+ if self._session_storage:
324
+ from iac_code.agent.message import Message
325
+
326
+ self._session_storage.append(
327
+ self._cwd,
328
+ self._session_id,
329
+ Message(role="assistant", content=assistant_blocks),
330
+ git_branch=self._current_git_branch,
331
+ )
332
+
333
+ # No tool calls -> end turn
334
+ if not completed_tools:
335
+ step_span.set_attribute(GenAiAttr.REACT_FINISH_REASON, "stop")
336
+ break
337
+
338
+ step_span.set_attribute(GenAiAttr.REACT_FINISH_REASON, "tool_calls")
339
+
340
+ # Execute tools (concurrent read-only, serial writes)
341
+ tools_with_progress = {"agent", "ros_stack", "ros_stack_instances"}
342
+ requests = []
343
+ event_queues: dict[str, asyncio.Queue] = {}
344
+ for tu in completed_tools:
345
+ queue = None
346
+ if tu["name"] in tools_with_progress:
347
+ queue = asyncio.Queue()
348
+ event_queues[tu["id"]] = queue
349
+ requests.append(
350
+ ToolCallRequest(
351
+ id=tu["id"],
352
+ name=tu["name"],
353
+ input=tu.get("input", {}),
354
+ event_queue=queue,
355
+ )
356
+ )
357
+ context = ToolContext(cwd=self._cwd)
358
+
359
+ allowed_requests: list[ToolCallRequest] = []
360
+ denied_results: list[tuple[ToolCallRequest, ToolResult]] = []
361
+ for request in requests:
362
+ tool = self.tool_registry.get(request.name)
363
+ if tool is None:
364
+ allowed_requests.append(request)
365
+ continue
366
+
367
+ permission = await tool.check_permissions(request.input, {"cwd": context.cwd})
368
+ if permission.behavior == "allow":
369
+ allowed_requests.append(request)
370
+ continue
371
+ if permission.behavior == "deny":
372
+ denied_results.append((request, ToolResult.error(permission.message or "Permission denied.")))
373
+ continue
374
+
375
+ response_future: asyncio.Future[bool] = asyncio.get_running_loop().create_future()
376
+ yield PermissionRequestEvent(
377
+ tool_name=request.name,
378
+ tool_input=request.input,
379
+ tool_use_id=request.id,
380
+ response_future=response_future,
381
+ )
382
+ if await response_future:
383
+ allowed_requests.append(request)
384
+ else:
385
+ denied_results.append((request, ToolResult.error("Permission denied.")))
386
+
387
+ for request, result in denied_results:
388
+ yield ToolResultEvent(
389
+ tool_use_id=request.id,
390
+ tool_name=request.name,
391
+ result=result.content,
392
+ is_error=True,
393
+ )
394
+
395
+ if not allowed_requests:
396
+ if denied_results:
397
+ denied_blocks: list[ToolResultBlock] = [
398
+ ToolResultBlock(
399
+ tool_use_id=request.id,
400
+ content=result.content,
401
+ is_error=True,
402
+ )
403
+ for request, result in denied_results
404
+ ]
405
+ self.context_manager.add_tool_results(denied_blocks)
406
+ if self._session_storage:
407
+ from iac_code.agent.message import ContentBlock, Message
408
+
409
+ denied_content: list[ContentBlock] = list(denied_blocks)
410
+ self._session_storage.append(
411
+ self._cwd, self._session_id, Message(role="user", content=denied_content)
412
+ )
413
+ continue
414
+
415
+ requests = allowed_requests
416
+
417
+ # Start tool execution
418
+ exec_task = asyncio.create_task(self._tool_executor.execute_batch(requests, context))
419
+
420
+ # Poll event queues while tools execute
421
+ async def poll_event_queues():
422
+ while not exec_task.done():
423
+ for req_id, queue in event_queues.items():
424
+ try:
425
+ while True:
426
+ item = queue.get_nowait()
427
+ if item is None:
428
+ break
429
+ if isinstance(item, (StackProgressEvent, StackInstancesProgressEvent)):
430
+ yield item
431
+ elif isinstance(item, dict):
432
+ yield SubAgentToolEvent(
433
+ parent_tool_use_id=req_id,
434
+ child_tool_name=item["child_tool_name"],
435
+ child_tool_input=item.get("child_tool_input", {}),
436
+ is_done=item.get("is_done", False),
437
+ is_error=item.get("is_error", False),
438
+ )
439
+ except asyncio.QueueEmpty:
440
+ pass
441
+ await asyncio.sleep(0.05)
442
+ # Final drain
443
+ for req_id, queue in event_queues.items():
444
+ while not queue.empty():
445
+ item = queue.get_nowait()
446
+ if item is None:
447
+ continue
448
+ if isinstance(item, (StackProgressEvent, StackInstancesProgressEvent)):
449
+ yield item
450
+ elif isinstance(item, dict):
451
+ yield SubAgentToolEvent(
452
+ parent_tool_use_id=req_id,
453
+ child_tool_name=item["child_tool_name"],
454
+ child_tool_input=item.get("child_tool_input", {}),
455
+ is_done=item.get("is_done", False),
456
+ is_error=item.get("is_error", False),
457
+ )
458
+
459
+ async for sub_event in poll_event_queues():
460
+ yield sub_event
461
+
462
+ results = await exec_task
463
+
464
+ # Process results and yield ToolResultEvents
465
+ tool_result_blocks: list[ToolResultBlock] = [
466
+ ToolResultBlock(
467
+ tool_use_id=request.id,
468
+ content=result.content,
469
+ is_error=True,
470
+ )
471
+ for request, result in denied_results
472
+ ]
473
+ for req, result in zip(requests, results):
474
+ processed = self._result_storage.process(req.id, result.content)
475
+
476
+ yield ToolResultEvent(
477
+ tool_use_id=req.id,
478
+ tool_name=req.name,
479
+ result=processed.content,
480
+ is_error=result.is_error,
481
+ )
482
+
483
+ tool_result_blocks.append(
484
+ ToolResultBlock(
485
+ tool_use_id=req.id,
486
+ content=processed.content,
487
+ is_error=result.is_error,
488
+ )
489
+ )
490
+
491
+ self.context_manager.add_tool_results(tool_result_blocks)
492
+ if self._session_storage:
493
+ from iac_code.agent.message import ContentBlock, Message
494
+
495
+ result_content: list[ContentBlock] = list(tool_result_blocks)
496
+ self._session_storage.append(
497
+ self._cwd,
498
+ self._session_id,
499
+ Message(role="user", content=result_content),
500
+ git_branch=self._current_git_branch,
501
+ )
502
+
503
+ for req, result in zip(requests, results):
504
+ if result.new_messages:
505
+ for msg in result.new_messages:
506
+ self.context_manager.add_raw_message(msg)
507
+ if result.context_modifier is not None:
508
+ self._apply_context_modifier(result.context_modifier)
509
+
510
+ def _apply_context_modifier(self, modifier: Any) -> None:
511
+ """Apply a context modifier from a ToolResult to the current execution context."""
512
+ current_ctx: dict[str, Any] = {
513
+ "allowed_tool_rules": getattr(self, "_allowed_tool_rules", []),
514
+ "model_override": getattr(self, "_model_override", None),
515
+ "effort_override": getattr(self, "_effort_override", None),
516
+ }
517
+ modified = modifier(current_ctx)
518
+ self._allowed_tool_rules = modified.get("allowed_tool_rules", [])
519
+ self._model_override = modified.get("model_override")
520
+ self._effort_override = modified.get("effort_override")
521
+
522
+ async def _auto_compact(self) -> CompactionEvent | None:
523
+ """Perform automatic context compaction via provider."""
524
+ from iac_code.services.telemetry import log_event
525
+ from iac_code.services.telemetry.names import Events
526
+
527
+ compaction_prompt = self.context_manager.build_compaction_prompt()
528
+ if not compaction_prompt:
529
+ return None
530
+ started = time.monotonic()
531
+ try:
532
+ from iac_code.providers.base import Message as ProviderMessage
533
+
534
+ response = await self._provider_manager.complete(
535
+ messages=[ProviderMessage.user(compaction_prompt)],
536
+ system="You are a helpful assistant that summarizes conversations concisely.",
537
+ )
538
+ if response.text:
539
+ original, new = self.context_manager.apply_compaction(response.text)
540
+ duration_ms = int((time.monotonic() - started) * 1000)
541
+ log_event(
542
+ Events.MEMORY_COMPACT_SUCCEEDED,
543
+ {
544
+ "rounds": 1,
545
+ "from_tokens": original,
546
+ "to_tokens": new,
547
+ "duration_ms": duration_ms,
548
+ },
549
+ )
550
+ return CompactionEvent(original_tokens=original, compacted_tokens=new)
551
+ except Exception as e:
552
+ log_event(
553
+ Events.MEMORY_COMPACT_FAILED,
554
+ {
555
+ "rounds": 1,
556
+ "error_type": type(e).__name__,
557
+ },
558
+ )
559
+ logger.error(f"Auto-compaction failed: {e}", exc_info=True)
560
+ return None
561
+
562
+ async def compact(self) -> CompactResult:
563
+ """Manual compaction for /compact command."""
564
+ if not self.context_manager.get_messages():
565
+ return CompactResult(status="empty")
566
+ compaction_prompt = self.context_manager.build_compaction_prompt()
567
+ if not compaction_prompt:
568
+ return CompactResult(
569
+ status="too_short",
570
+ preserve_recent_turns=self.context_manager.preserve_recent_turns,
571
+ )
572
+ try:
573
+ from iac_code.providers.base import Message as ProviderMessage
574
+
575
+ response = await self._provider_manager.complete(
576
+ messages=[ProviderMessage.user(compaction_prompt)],
577
+ system="You are a helpful assistant that summarizes conversations concisely.",
578
+ )
579
+ if response.text:
580
+ original, compacted = self.context_manager.apply_compaction(response.text)
581
+ return CompactResult(
582
+ status="success",
583
+ original_tokens=original,
584
+ compacted_tokens=compacted,
585
+ )
586
+ except Exception as e:
587
+ logger.error(f"Manual compaction failed: {e}", exc_info=True)
588
+ return CompactResult(status="failed")
589
+
590
+ def stamp_last_turn_elapsed(self, elapsed: float) -> None:
591
+ """Record turn duration on the last assistant message and persist it."""
592
+ msgs = self.context_manager.get_messages()
593
+ for msg in reversed(msgs):
594
+ if msg.role == "assistant":
595
+ msg.elapsed_seconds = elapsed
596
+ if self._session_storage:
597
+ self._session_storage.save(
598
+ self._cwd,
599
+ self._session_id,
600
+ msgs,
601
+ git_branch=self._current_git_branch,
602
+ )
603
+ break
604
+
605
+ def replace_session(self, session_id: str, resume_messages: list | None) -> None:
606
+ """Swap the active session in-place, preserving provider/tools.
607
+
608
+ Resets the conversation context to ``resume_messages`` (or empty),
609
+ repoints the session id, and rebuilds the per-session ResultStorage
610
+ directory. Used by the /resume command for in-process hot-swap.
611
+ """
612
+ from iac_code.config import get_config_dir
613
+
614
+ self._session_id = session_id
615
+ self._current_git_branch = None
616
+ self.context_manager.reset()
617
+ if resume_messages:
618
+ self.context_manager.load_messages(resume_messages)
619
+ self._result_storage = ResultStorage(
620
+ storage_dir=os.path.join(str(get_config_dir()), "tool-results", session_id),
621
+ )
622
+
623
+ def _refresh_git_branch(self) -> None:
624
+ """Probe ``git`` once per turn and cache the result.
625
+
626
+ Failures (no git, not a repo, timeout) silently leave the cache
627
+ as ``None`` so the storage layer omits the field.
628
+ """
629
+ from iac_code.utils.project_paths import get_git_branch
630
+
631
+ try:
632
+ self._current_git_branch = get_git_branch(self._cwd)
633
+ except Exception:
634
+ self._current_git_branch = None
635
+
636
+ def reset(self) -> None:
637
+ self.context_manager.reset()
638
+
639
+ def get_context_usage(self) -> dict:
640
+ return self.context_manager.get_usage()