iac-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iac_code/__init__.py +2 -0
- iac_code/acp/__init__.py +97 -0
- iac_code/acp/convert.py +423 -0
- iac_code/acp/http_sse.py +448 -0
- iac_code/acp/mcp.py +54 -0
- iac_code/acp/metrics.py +71 -0
- iac_code/acp/server.py +662 -0
- iac_code/acp/session.py +446 -0
- iac_code/acp/slash_registry.py +125 -0
- iac_code/acp/state.py +99 -0
- iac_code/acp/tools.py +112 -0
- iac_code/acp/types.py +13 -0
- iac_code/acp/version.py +26 -0
- iac_code/agent/__init__.py +19 -0
- iac_code/agent/agent_loop.py +640 -0
- iac_code/agent/agent_tool.py +269 -0
- iac_code/agent/agent_types.py +87 -0
- iac_code/agent/message.py +153 -0
- iac_code/agent/system_prompt.py +313 -0
- iac_code/cli/__init__.py +3 -0
- iac_code/cli/headless.py +114 -0
- iac_code/cli/main.py +246 -0
- iac_code/cli/output_formats.py +125 -0
- iac_code/commands/__init__.py +93 -0
- iac_code/commands/auth.py +1055 -0
- iac_code/commands/clear.py +34 -0
- iac_code/commands/compact.py +43 -0
- iac_code/commands/debug.py +45 -0
- iac_code/commands/effort.py +116 -0
- iac_code/commands/exit.py +10 -0
- iac_code/commands/help.py +49 -0
- iac_code/commands/model.py +130 -0
- iac_code/commands/registry.py +245 -0
- iac_code/commands/resume.py +49 -0
- iac_code/commands/tasks.py +41 -0
- iac_code/config.py +304 -0
- iac_code/i18n/__init__.py +141 -0
- iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
- iac_code/memory/__init__.py +1 -0
- iac_code/memory/memory_manager.py +92 -0
- iac_code/memory/memory_tools.py +88 -0
- iac_code/providers/__init__.py +1 -0
- iac_code/providers/anthropic_provider.py +284 -0
- iac_code/providers/base.py +128 -0
- iac_code/providers/dashscope_provider.py +47 -0
- iac_code/providers/deepseek_provider.py +36 -0
- iac_code/providers/manager.py +399 -0
- iac_code/providers/openai_provider.py +344 -0
- iac_code/providers/retry.py +58 -0
- iac_code/providers/stream_watchdog.py +47 -0
- iac_code/providers/thinking.py +164 -0
- iac_code/services/__init__.py +1 -0
- iac_code/services/agent_factory.py +127 -0
- iac_code/services/cloud_credentials.py +22 -0
- iac_code/services/context_manager.py +221 -0
- iac_code/services/providers/__init__.py +1 -0
- iac_code/services/providers/aliyun.py +232 -0
- iac_code/services/session_index.py +281 -0
- iac_code/services/session_storage.py +245 -0
- iac_code/services/telemetry/__init__.py +66 -0
- iac_code/services/telemetry/attributes.py +84 -0
- iac_code/services/telemetry/client.py +330 -0
- iac_code/services/telemetry/config.py +76 -0
- iac_code/services/telemetry/constants.py +75 -0
- iac_code/services/telemetry/content_serializer.py +124 -0
- iac_code/services/telemetry/events.py +42 -0
- iac_code/services/telemetry/fallback.py +59 -0
- iac_code/services/telemetry/identity.py +73 -0
- iac_code/services/telemetry/metrics.py +62 -0
- iac_code/services/telemetry/names.py +199 -0
- iac_code/services/telemetry/sanitize.py +88 -0
- iac_code/services/telemetry/sink.py +67 -0
- iac_code/services/telemetry/tracing.py +38 -0
- iac_code/services/telemetry/types.py +13 -0
- iac_code/services/token_budget.py +54 -0
- iac_code/services/token_counter.py +76 -0
- iac_code/skills/__init__.py +1 -0
- iac_code/skills/bundled/__init__.py +94 -0
- iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
- iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
- iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
- iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
- iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
- iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
- iac_code/skills/bundled/simplify.py +28 -0
- iac_code/skills/discovery.py +136 -0
- iac_code/skills/frontmatter.py +119 -0
- iac_code/skills/listing.py +92 -0
- iac_code/skills/loader.py +42 -0
- iac_code/skills/processor.py +81 -0
- iac_code/skills/renderer.py +157 -0
- iac_code/skills/skill_definition.py +82 -0
- iac_code/skills/skill_tool.py +261 -0
- iac_code/state/__init__.py +5 -0
- iac_code/state/app_state.py +122 -0
- iac_code/tasks/__init__.py +1 -0
- iac_code/tasks/notification_queue.py +28 -0
- iac_code/tasks/task_state.py +66 -0
- iac_code/tasks/task_tools.py +114 -0
- iac_code/tools/__init__.py +8 -0
- iac_code/tools/base.py +226 -0
- iac_code/tools/bash.py +133 -0
- iac_code/tools/cloud/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
- iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
- iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
- iac_code/tools/cloud/aliyun/ros_client.py +56 -0
- iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
- iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
- iac_code/tools/cloud/base_api.py +162 -0
- iac_code/tools/cloud/base_stack.py +242 -0
- iac_code/tools/cloud/registry.py +20 -0
- iac_code/tools/cloud/types.py +105 -0
- iac_code/tools/edit_file.py +121 -0
- iac_code/tools/glob.py +103 -0
- iac_code/tools/grep.py +254 -0
- iac_code/tools/list_files.py +104 -0
- iac_code/tools/read_file.py +127 -0
- iac_code/tools/result_storage.py +39 -0
- iac_code/tools/tool_executor.py +165 -0
- iac_code/tools/web_fetch.py +177 -0
- iac_code/tools/write_file.py +88 -0
- iac_code/types/__init__.py +40 -0
- iac_code/types/permissions.py +26 -0
- iac_code/types/skill_source.py +11 -0
- iac_code/types/stream_events.py +227 -0
- iac_code/ui/__init__.py +5 -0
- iac_code/ui/banner.py +110 -0
- iac_code/ui/components/__init__.py +0 -0
- iac_code/ui/components/dialog.py +142 -0
- iac_code/ui/components/divider.py +20 -0
- iac_code/ui/components/fuzzy_picker.py +308 -0
- iac_code/ui/components/progress_bar.py +54 -0
- iac_code/ui/components/search_box.py +165 -0
- iac_code/ui/components/select.py +319 -0
- iac_code/ui/components/status_icon.py +42 -0
- iac_code/ui/components/tabs.py +128 -0
- iac_code/ui/core/__init__.py +0 -0
- iac_code/ui/core/in_place_render.py +129 -0
- iac_code/ui/core/input_history.py +118 -0
- iac_code/ui/core/key_event.py +41 -0
- iac_code/ui/core/prompt_input.py +507 -0
- iac_code/ui/core/raw_input.py +302 -0
- iac_code/ui/core/screen.py +80 -0
- iac_code/ui/dialogs/__init__.py +0 -0
- iac_code/ui/dialogs/global_search.py +178 -0
- iac_code/ui/dialogs/history_search.py +100 -0
- iac_code/ui/dialogs/model_picker.py +280 -0
- iac_code/ui/dialogs/quick_open.py +108 -0
- iac_code/ui/dialogs/resume_picker.py +749 -0
- iac_code/ui/keybindings/__init__.py +0 -0
- iac_code/ui/keybindings/manager.py +124 -0
- iac_code/ui/renderer.py +1535 -0
- iac_code/ui/repl.py +772 -0
- iac_code/ui/spinner.py +112 -0
- iac_code/ui/suggestions/__init__.py +0 -0
- iac_code/ui/suggestions/aggregator.py +171 -0
- iac_code/ui/suggestions/command_provider.py +43 -0
- iac_code/ui/suggestions/directory_provider.py +95 -0
- iac_code/ui/suggestions/file_provider.py +121 -0
- iac_code/ui/suggestions/shell_history_provider.py +108 -0
- iac_code/ui/suggestions/token_extractor.py +77 -0
- iac_code/ui/suggestions/types.py +45 -0
- iac_code/ui/transcript_view.py +199 -0
- iac_code/utils/__init__.py +0 -0
- iac_code/utils/background_housekeeping.py +53 -0
- iac_code/utils/cleanup.py +68 -0
- iac_code/utils/json_utils.py +60 -0
- iac_code/utils/log.py +150 -0
- iac_code/utils/project_paths.py +74 -0
- iac_code/utils/tool_input_parser.py +62 -0
- iac_code-0.1.0.dist-info/LICENSE +201 -0
- iac_code-0.1.0.dist-info/METADATA +64 -0
- iac_code-0.1.0.dist-info/RECORD +184 -0
- iac_code-0.1.0.dist-info/WHEEL +5 -0
- iac_code-0.1.0.dist-info/entry_points.txt +2 -0
- iac_code-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|