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