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/server.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import acp
|
|
11
|
+
|
|
12
|
+
from iac_code import __version__
|
|
13
|
+
from iac_code.acp.metrics import ACPMetrics
|
|
14
|
+
from iac_code.acp.session import ACPSession, Message, _is_auth_error
|
|
15
|
+
from iac_code.acp.slash_registry import ACP_SUPPORTED_COMMANDS
|
|
16
|
+
from iac_code.acp.tools import replace_bash_with_acp_terminal
|
|
17
|
+
from iac_code.acp.types import ACPContentBlock, MCPServer
|
|
18
|
+
from iac_code.acp.version import negotiate_version
|
|
19
|
+
from iac_code.commands import LocalCommand, create_default_registry
|
|
20
|
+
from iac_code.config import DEFAULT_MODEL, get_active_provider_key, load_saved_model
|
|
21
|
+
from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime
|
|
22
|
+
from iac_code.services.session_storage import SessionStorage
|
|
23
|
+
|
|
24
|
+
SESSION_IDLE_TIMEOUT = 3600 # 1 hour
|
|
25
|
+
CLEANUP_INTERVAL = 300 # 5 minutes
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ACPServer:
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self.conn: acp.Client | None = None
|
|
33
|
+
self.client_capabilities: acp.schema.ClientCapabilities | None = None
|
|
34
|
+
self.sessions: dict[str, ACPSession] = {}
|
|
35
|
+
self._cleanup_task: asyncio.Task | None = None
|
|
36
|
+
self.metrics: ACPMetrics = ACPMetrics()
|
|
37
|
+
|
|
38
|
+
def on_connect(self, conn: acp.Client) -> None:
|
|
39
|
+
self.conn = conn
|
|
40
|
+
|
|
41
|
+
async def authenticate(self, method_id: str, **kwargs: Any) -> acp.schema.AuthenticateResponse | None:
|
|
42
|
+
"""Handle ACP ``authenticate`` requests.
|
|
43
|
+
|
|
44
|
+
iac-code performs authentication out-of-band (env vars / credentials
|
|
45
|
+
file), so this is a no-op acknowledgement that satisfies the
|
|
46
|
+
:class:`acp.Agent` protocol contract.
|
|
47
|
+
"""
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
"""Handle ACP extension method calls.
|
|
52
|
+
|
|
53
|
+
iac-code does not implement any custom extension methods; this stub
|
|
54
|
+
exists solely to satisfy the :class:`acp.Agent` protocol contract.
|
|
55
|
+
"""
|
|
56
|
+
raise acp.RequestError.method_not_found(method)
|
|
57
|
+
|
|
58
|
+
async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
59
|
+
"""Handle ACP extension notifications.
|
|
60
|
+
|
|
61
|
+
iac-code does not act on any extension notifications; the body is a
|
|
62
|
+
no-op for protocol-conformance purposes only.
|
|
63
|
+
"""
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
async def set_session_mode(
|
|
67
|
+
self,
|
|
68
|
+
mode_id: str,
|
|
69
|
+
session_id: str,
|
|
70
|
+
**kwargs: Any,
|
|
71
|
+
) -> acp.schema.SetSessionModeResponse | None:
|
|
72
|
+
"""Handle ACP ``session/set_mode`` requests.
|
|
73
|
+
|
|
74
|
+
iac-code does not currently expose user-selectable session modes;
|
|
75
|
+
the request is acknowledged but otherwise has no effect. The stub
|
|
76
|
+
exists to satisfy the :class:`acp.Agent` protocol contract.
|
|
77
|
+
"""
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
async def set_session_model(
|
|
81
|
+
self,
|
|
82
|
+
model_id: str,
|
|
83
|
+
session_id: str,
|
|
84
|
+
**kwargs: Any,
|
|
85
|
+
) -> acp.schema.SetSessionModelResponse | None:
|
|
86
|
+
"""Handle ACP ``session/set_model`` requests.
|
|
87
|
+
|
|
88
|
+
Models are configured via :func:`load_saved_model` / the auth flow,
|
|
89
|
+
so dynamic per-session model switching is a no-op for now. The
|
|
90
|
+
stub exists to satisfy the :class:`acp.Agent` protocol contract.
|
|
91
|
+
"""
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def _get_session(self, session_id: str) -> ACPSession:
|
|
95
|
+
session = self.sessions.get(session_id)
|
|
96
|
+
if session is None:
|
|
97
|
+
raise acp.RequestError.invalid_params({"session_id": "Session not found"})
|
|
98
|
+
return session
|
|
99
|
+
|
|
100
|
+
async def initialize(
|
|
101
|
+
self,
|
|
102
|
+
protocol_version: int,
|
|
103
|
+
client_capabilities: acp.schema.ClientCapabilities | None = None,
|
|
104
|
+
client_info: acp.schema.Implementation | None = None,
|
|
105
|
+
**kwargs: Any,
|
|
106
|
+
) -> acp.InitializeResponse:
|
|
107
|
+
negotiated = negotiate_version(protocol_version)
|
|
108
|
+
self.client_capabilities = client_capabilities
|
|
109
|
+
await self._start_cleanup_loop()
|
|
110
|
+
logger.info(
|
|
111
|
+
"ACP server initialized, protocol_version=%d, client=%s",
|
|
112
|
+
negotiated.protocol_version,
|
|
113
|
+
client_info.name if client_info else "unknown",
|
|
114
|
+
)
|
|
115
|
+
return acp.InitializeResponse(
|
|
116
|
+
protocol_version=negotiated.protocol_version,
|
|
117
|
+
agent_capabilities=acp.schema.AgentCapabilities(
|
|
118
|
+
load_session=True,
|
|
119
|
+
prompt_capabilities=acp.schema.PromptCapabilities(
|
|
120
|
+
embedded_context=True,
|
|
121
|
+
image=False,
|
|
122
|
+
audio=False,
|
|
123
|
+
),
|
|
124
|
+
mcp_capabilities=acp.schema.McpCapabilities(http=False, sse=False),
|
|
125
|
+
session_capabilities=acp.schema.SessionCapabilities(
|
|
126
|
+
close=acp.schema.SessionCloseCapabilities(),
|
|
127
|
+
list=acp.schema.SessionListCapabilities(),
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
auth_methods=_build_auth_methods(),
|
|
131
|
+
agent_info=acp.schema.Implementation(name="iac-code", version=__version__),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def new_session(
|
|
135
|
+
self,
|
|
136
|
+
cwd: str,
|
|
137
|
+
mcp_servers: list[MCPServer] | None = None,
|
|
138
|
+
**kwargs: Any,
|
|
139
|
+
) -> acp.NewSessionResponse:
|
|
140
|
+
if self.conn is None:
|
|
141
|
+
raise acp.RequestError.internal_error({"error": "ACP client not connected"})
|
|
142
|
+
|
|
143
|
+
# Convert MCP server configs from ACP protocol types to internal dicts
|
|
144
|
+
mcp_configs = _convert_mcp_servers(mcp_servers)
|
|
145
|
+
|
|
146
|
+
model = load_saved_model() or DEFAULT_MODEL
|
|
147
|
+
runtime = self._create_runtime_with_auth_check(model=model, cwd=cwd)
|
|
148
|
+
replace_bash_with_acp_terminal(
|
|
149
|
+
runtime.tool_registry,
|
|
150
|
+
self.client_capabilities,
|
|
151
|
+
self.conn,
|
|
152
|
+
runtime.session_id,
|
|
153
|
+
)
|
|
154
|
+
session = ACPSession(
|
|
155
|
+
runtime.session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics
|
|
156
|
+
)
|
|
157
|
+
self.sessions[session.id] = session
|
|
158
|
+
self.metrics.record_session_created()
|
|
159
|
+
logger.info("Session created, session_id=%s, model=%s", session.id, model)
|
|
160
|
+
|
|
161
|
+
# Build model state for the response
|
|
162
|
+
model_state = self._build_model_state(model)
|
|
163
|
+
|
|
164
|
+
response = acp.NewSessionResponse(
|
|
165
|
+
session_id=session.id,
|
|
166
|
+
models=model_state,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Push available commands to the client
|
|
170
|
+
await self._push_available_commands(session.id)
|
|
171
|
+
|
|
172
|
+
return response
|
|
173
|
+
|
|
174
|
+
async def prompt(
|
|
175
|
+
self,
|
|
176
|
+
prompt: list[ACPContentBlock],
|
|
177
|
+
session_id: str,
|
|
178
|
+
message_id: str | None = None,
|
|
179
|
+
**kwargs: Any,
|
|
180
|
+
) -> acp.PromptResponse:
|
|
181
|
+
session = self._get_session(session_id)
|
|
182
|
+
session.touch()
|
|
183
|
+
return await session.prompt(prompt)
|
|
184
|
+
|
|
185
|
+
async def close_session(self, session_id: str, **kwargs: Any) -> acp.schema.CloseSessionResponse:
|
|
186
|
+
"""Close a session, releasing all associated resources.
|
|
187
|
+
|
|
188
|
+
Idempotent: closing an already-removed session returns success.
|
|
189
|
+
"""
|
|
190
|
+
session = self.sessions.get(session_id)
|
|
191
|
+
if session is None:
|
|
192
|
+
# Already gone (cleaned up or previously closed) — return success.
|
|
193
|
+
return acp.schema.CloseSessionResponse()
|
|
194
|
+
|
|
195
|
+
# Cancel any running prompt, then release resources.
|
|
196
|
+
await session.close()
|
|
197
|
+
|
|
198
|
+
# Remove from active sessions.
|
|
199
|
+
self.sessions.pop(session_id, None)
|
|
200
|
+
self.metrics.record_session_closed()
|
|
201
|
+
logger.info("Session %s closed and removed via close_session", session_id)
|
|
202
|
+
return acp.schema.CloseSessionResponse()
|
|
203
|
+
|
|
204
|
+
async def set_config_option(
|
|
205
|
+
self,
|
|
206
|
+
config_id: str,
|
|
207
|
+
session_id: str,
|
|
208
|
+
value: str | bool,
|
|
209
|
+
**kwargs: Any,
|
|
210
|
+
) -> acp.schema.SetSessionConfigOptionResponse | None:
|
|
211
|
+
"""Handle dynamic config updates from the client.
|
|
212
|
+
|
|
213
|
+
Stores the *config_id* / *value* pair in the session's dynamic config
|
|
214
|
+
and returns the full list of current config options.
|
|
215
|
+
"""
|
|
216
|
+
session = self._get_session(session_id)
|
|
217
|
+
session.update_config({config_id: value})
|
|
218
|
+
logger.info("Session %s config updated: %s=%r", session_id, config_id, value)
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
async def cancel(self, session_id: str, **kwargs: Any) -> None:
|
|
222
|
+
session = self._get_session(session_id)
|
|
223
|
+
await session.cancel()
|
|
224
|
+
|
|
225
|
+
async def list_sessions(
|
|
226
|
+
self,
|
|
227
|
+
cursor: str | None = None,
|
|
228
|
+
cwd: str | None = None,
|
|
229
|
+
**kwargs: Any,
|
|
230
|
+
) -> acp.schema.ListSessionsResponse:
|
|
231
|
+
from iac_code.utils.project_paths import get_project_dir, get_projects_dir
|
|
232
|
+
|
|
233
|
+
session_ids: list[str] = []
|
|
234
|
+
if cwd:
|
|
235
|
+
project_dir = get_project_dir(cwd)
|
|
236
|
+
if project_dir.exists():
|
|
237
|
+
session_ids = [p.stem for p in project_dir.glob("*.jsonl")]
|
|
238
|
+
else:
|
|
239
|
+
projects_root = get_projects_dir()
|
|
240
|
+
if projects_root.exists():
|
|
241
|
+
session_ids = [p.stem for p in projects_root.glob("*/*.jsonl")]
|
|
242
|
+
return acp.schema.ListSessionsResponse(
|
|
243
|
+
sessions=[
|
|
244
|
+
acp.schema.SessionInfo(
|
|
245
|
+
session_id=session_id,
|
|
246
|
+
cwd=cwd or "",
|
|
247
|
+
title=session_id,
|
|
248
|
+
)
|
|
249
|
+
for session_id in session_ids
|
|
250
|
+
],
|
|
251
|
+
next_cursor=None,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
async def load_session(
|
|
255
|
+
self,
|
|
256
|
+
cwd: str,
|
|
257
|
+
session_id: str,
|
|
258
|
+
mcp_servers: list[MCPServer] | None = None,
|
|
259
|
+
**kwargs: Any,
|
|
260
|
+
) -> acp.LoadSessionResponse | None:
|
|
261
|
+
"""Load a persisted session and replay its history to the client.
|
|
262
|
+
|
|
263
|
+
If the session is already active in memory it is returned directly.
|
|
264
|
+
Otherwise the history is read from :class:`SessionStorage`, a fresh
|
|
265
|
+
agent runtime is created, and history events are replayed as ACP
|
|
266
|
+
``session_update`` notifications so the client can rebuild its UI.
|
|
267
|
+
"""
|
|
268
|
+
if self.conn is None:
|
|
269
|
+
raise acp.RequestError.internal_error({"error": "ACP client not connected"})
|
|
270
|
+
|
|
271
|
+
# 1. Already active in memory — return immediately
|
|
272
|
+
if session_id in self.sessions:
|
|
273
|
+
model = load_saved_model() or DEFAULT_MODEL
|
|
274
|
+
return acp.LoadSessionResponse(models=self._build_model_state(model))
|
|
275
|
+
|
|
276
|
+
# 2. Try to load from persistent storage
|
|
277
|
+
storage = SessionStorage()
|
|
278
|
+
if not storage.exists(cwd, session_id):
|
|
279
|
+
raise acp.RequestError.invalid_params({"session_id": "Session not found"})
|
|
280
|
+
|
|
281
|
+
history = storage.load(cwd, session_id)
|
|
282
|
+
history = SessionStorage.repair_interrupted(history)
|
|
283
|
+
|
|
284
|
+
mcp_configs = _convert_mcp_servers(mcp_servers)
|
|
285
|
+
|
|
286
|
+
# 3. Rebuild agent runtime with restored history
|
|
287
|
+
model = load_saved_model() or DEFAULT_MODEL
|
|
288
|
+
runtime = self._create_runtime_with_auth_check(model=model, session_id=session_id, cwd=cwd)
|
|
289
|
+
replace_bash_with_acp_terminal(
|
|
290
|
+
runtime.tool_registry,
|
|
291
|
+
self.client_capabilities,
|
|
292
|
+
self.conn,
|
|
293
|
+
runtime.session_id,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
if history:
|
|
297
|
+
runtime.agent_loop.context_manager.load_messages(history)
|
|
298
|
+
|
|
299
|
+
# 4. Register session
|
|
300
|
+
session = ACPSession(session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics)
|
|
301
|
+
self.sessions[session_id] = session
|
|
302
|
+
self.metrics.record_session_created()
|
|
303
|
+
logger.info("Session loaded, session_id=%s, history_messages=%d", session_id, len(history))
|
|
304
|
+
|
|
305
|
+
# 5. Replay history events asynchronously so the client can rebuild UI
|
|
306
|
+
if history:
|
|
307
|
+
session._replay_task = asyncio.create_task(self._replay_session_history(session, history))
|
|
308
|
+
|
|
309
|
+
# 6. Push available commands
|
|
310
|
+
await self._push_available_commands(session_id)
|
|
311
|
+
|
|
312
|
+
return acp.LoadSessionResponse(models=self._build_model_state(model))
|
|
313
|
+
|
|
314
|
+
async def fork_session(
|
|
315
|
+
self,
|
|
316
|
+
cwd: str,
|
|
317
|
+
session_id: str,
|
|
318
|
+
mcp_servers: list[MCPServer] | None = None,
|
|
319
|
+
**kwargs: Any,
|
|
320
|
+
) -> acp.schema.ForkSessionResponse:
|
|
321
|
+
"""Create a new session forked from an existing one.
|
|
322
|
+
|
|
323
|
+
The full history of the source session is copied into a brand-new
|
|
324
|
+
session with a fresh ``session_id``. The client can then continue
|
|
325
|
+
the conversation on the fork without affecting the original.
|
|
326
|
+
"""
|
|
327
|
+
if self.conn is None:
|
|
328
|
+
raise acp.RequestError.internal_error({"error": "ACP client not connected"})
|
|
329
|
+
|
|
330
|
+
# 1. Collect history from the source session
|
|
331
|
+
history: list[Message] = []
|
|
332
|
+
if session_id in self.sessions:
|
|
333
|
+
source = self.sessions[session_id]
|
|
334
|
+
ctx = getattr(source.agent_loop, "context_manager", None)
|
|
335
|
+
if ctx is not None:
|
|
336
|
+
history = list(ctx.get_messages())
|
|
337
|
+
else:
|
|
338
|
+
storage = SessionStorage()
|
|
339
|
+
if not storage.exists(cwd, session_id):
|
|
340
|
+
raise acp.RequestError.invalid_params({"session_id": "Source session not found"})
|
|
341
|
+
history = storage.load(cwd, session_id)
|
|
342
|
+
history = SessionStorage.repair_interrupted(history)
|
|
343
|
+
|
|
344
|
+
mcp_configs = _convert_mcp_servers(mcp_servers)
|
|
345
|
+
|
|
346
|
+
# 2. Create a new runtime for the fork
|
|
347
|
+
new_session_id = str(uuid.uuid4())
|
|
348
|
+
model = load_saved_model() or DEFAULT_MODEL
|
|
349
|
+
runtime = self._create_runtime_with_auth_check(model=model, session_id=new_session_id, cwd=cwd)
|
|
350
|
+
replace_bash_with_acp_terminal(
|
|
351
|
+
runtime.tool_registry,
|
|
352
|
+
self.client_capabilities,
|
|
353
|
+
self.conn,
|
|
354
|
+
runtime.session_id,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# 3. Inject history into the new runtime
|
|
358
|
+
if history:
|
|
359
|
+
runtime.agent_loop.context_manager.load_messages(history)
|
|
360
|
+
|
|
361
|
+
# 4. Register the forked session
|
|
362
|
+
session = ACPSession(
|
|
363
|
+
new_session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics
|
|
364
|
+
)
|
|
365
|
+
self.sessions[new_session_id] = session
|
|
366
|
+
self.metrics.record_session_created()
|
|
367
|
+
logger.info("Session forked, source_session_id=%s, new_session_id=%s", session_id, new_session_id)
|
|
368
|
+
|
|
369
|
+
# 5. Replay history so the client can show it
|
|
370
|
+
if history:
|
|
371
|
+
session._replay_task = asyncio.create_task(self._replay_session_history(session, history))
|
|
372
|
+
|
|
373
|
+
await self._push_available_commands(new_session_id)
|
|
374
|
+
|
|
375
|
+
return acp.schema.ForkSessionResponse(
|
|
376
|
+
session_id=new_session_id,
|
|
377
|
+
models=self._build_model_state(model),
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
async def resume_session(
|
|
381
|
+
self,
|
|
382
|
+
cwd: str,
|
|
383
|
+
session_id: str,
|
|
384
|
+
mcp_servers: list[MCPServer] | None = None,
|
|
385
|
+
**kwargs: Any,
|
|
386
|
+
) -> acp.schema.ResumeSessionResponse:
|
|
387
|
+
# 1. If session is still active in memory, return directly
|
|
388
|
+
if session_id in self.sessions:
|
|
389
|
+
await self._push_available_commands(session_id)
|
|
390
|
+
return acp.schema.ResumeSessionResponse()
|
|
391
|
+
|
|
392
|
+
if self.conn is None:
|
|
393
|
+
raise acp.RequestError.internal_error({"error": "ACP client not connected"})
|
|
394
|
+
|
|
395
|
+
# 2. Try to load persisted history from SessionStorage
|
|
396
|
+
storage = SessionStorage()
|
|
397
|
+
if not storage.exists(cwd, session_id):
|
|
398
|
+
raise acp.RequestError.invalid_params({"session_id": "Session not found"})
|
|
399
|
+
|
|
400
|
+
history = storage.load(cwd, session_id)
|
|
401
|
+
history = SessionStorage.repair_interrupted(history)
|
|
402
|
+
|
|
403
|
+
# Convert MCP server configs from ACP protocol types to internal dicts
|
|
404
|
+
mcp_configs = _convert_mcp_servers(mcp_servers)
|
|
405
|
+
|
|
406
|
+
# 3. Rebuild agent runtime with restored history
|
|
407
|
+
model = load_saved_model() or DEFAULT_MODEL
|
|
408
|
+
runtime = self._create_runtime_with_auth_check(model=model, session_id=session_id, cwd=cwd)
|
|
409
|
+
replace_bash_with_acp_terminal(
|
|
410
|
+
runtime.tool_registry,
|
|
411
|
+
self.client_capabilities,
|
|
412
|
+
self.conn,
|
|
413
|
+
runtime.session_id,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Inject restored history into the agent loop
|
|
417
|
+
if history:
|
|
418
|
+
runtime.agent_loop.context_manager.load_messages(history)
|
|
419
|
+
|
|
420
|
+
# 4. Register the resumed session
|
|
421
|
+
session = ACPSession(session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics)
|
|
422
|
+
self.sessions[session_id] = session
|
|
423
|
+
self.metrics.record_session_created()
|
|
424
|
+
await self._push_available_commands(session_id)
|
|
425
|
+
|
|
426
|
+
return acp.schema.ResumeSessionResponse()
|
|
427
|
+
|
|
428
|
+
# ------------------------------------------------------------------
|
|
429
|
+
# Runtime creation helper
|
|
430
|
+
# ------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
@staticmethod
|
|
433
|
+
def _create_runtime_with_auth_check(
|
|
434
|
+
*,
|
|
435
|
+
model: str,
|
|
436
|
+
cwd: str,
|
|
437
|
+
session_id: str | None = None,
|
|
438
|
+
):
|
|
439
|
+
"""Create an agent runtime, converting auth errors to ACP RequestError."""
|
|
440
|
+
try:
|
|
441
|
+
return create_agent_runtime(AgentFactoryOptions(model=model, session_id=session_id, cwd=cwd))
|
|
442
|
+
except Exception as exc:
|
|
443
|
+
if _is_auth_error(exc):
|
|
444
|
+
logger.warning("Authentication error during runtime creation: %s", exc)
|
|
445
|
+
raise acp.RequestError.internal_error(
|
|
446
|
+
{
|
|
447
|
+
"error": "Authentication required. Please configure your API credentials.",
|
|
448
|
+
"code": "auth_required",
|
|
449
|
+
}
|
|
450
|
+
) from exc
|
|
451
|
+
raise
|
|
452
|
+
|
|
453
|
+
# ------------------------------------------------------------------
|
|
454
|
+
# Model state & available commands helpers
|
|
455
|
+
# ------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
@staticmethod
|
|
458
|
+
def _build_model_state(model: str) -> acp.schema.SessionModelState:
|
|
459
|
+
"""Build SessionModelState from the active model identifier."""
|
|
460
|
+
provider_key = get_active_provider_key() or "dashscope"
|
|
461
|
+
return acp.schema.SessionModelState(
|
|
462
|
+
available_models=[
|
|
463
|
+
acp.schema.ModelInfo(
|
|
464
|
+
model_id=model,
|
|
465
|
+
name=model,
|
|
466
|
+
description=f"Active model via {provider_key}",
|
|
467
|
+
),
|
|
468
|
+
],
|
|
469
|
+
current_model_id=model,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
async def _replay_session_history(
|
|
473
|
+
self,
|
|
474
|
+
session: ACPSession,
|
|
475
|
+
history: list[Message],
|
|
476
|
+
) -> None:
|
|
477
|
+
"""Replay history events for a loaded/forked session.
|
|
478
|
+
|
|
479
|
+
Errors are logged but not propagated so that the session remains
|
|
480
|
+
usable even if a single replay event fails.
|
|
481
|
+
"""
|
|
482
|
+
try:
|
|
483
|
+
await session.replay_history(history)
|
|
484
|
+
except Exception:
|
|
485
|
+
logger.exception("Failed to replay history for session %s", session.id)
|
|
486
|
+
|
|
487
|
+
async def _push_available_commands(self, session_id: str) -> None:
|
|
488
|
+
"""Push the list of available slash commands to the client via session_update."""
|
|
489
|
+
if self.conn is None:
|
|
490
|
+
return
|
|
491
|
+
registry = create_default_registry()
|
|
492
|
+
commands = []
|
|
493
|
+
for cmd in registry.get_all():
|
|
494
|
+
if cmd.name not in ACP_SUPPORTED_COMMANDS:
|
|
495
|
+
continue
|
|
496
|
+
# Build input hint: prefer arg_hint, fall back to arg_names
|
|
497
|
+
hint = None
|
|
498
|
+
if isinstance(cmd, LocalCommand):
|
|
499
|
+
if cmd.arg_hint:
|
|
500
|
+
hint = cmd.arg_hint
|
|
501
|
+
elif cmd.arg_names:
|
|
502
|
+
hint = " ".join(f"[{name}]" for name in cmd.arg_names)
|
|
503
|
+
|
|
504
|
+
input_spec = (
|
|
505
|
+
acp.schema.AvailableCommandInput(root=acp.schema.UnstructuredCommandInput(hint=hint)) if hint else None
|
|
506
|
+
)
|
|
507
|
+
commands.append(
|
|
508
|
+
acp.schema.AvailableCommand(
|
|
509
|
+
name=cmd.name,
|
|
510
|
+
description=cmd.description,
|
|
511
|
+
input=input_spec,
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
if not commands:
|
|
515
|
+
return
|
|
516
|
+
await self.conn.session_update(
|
|
517
|
+
session_id=session_id,
|
|
518
|
+
update=acp.schema.AvailableCommandsUpdate(
|
|
519
|
+
session_update="available_commands_update",
|
|
520
|
+
available_commands=commands,
|
|
521
|
+
),
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
# ------------------------------------------------------------------
|
|
525
|
+
# Cleanup loop
|
|
526
|
+
# ------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
async def _start_cleanup_loop(self) -> None:
|
|
529
|
+
"""Start background cleanup loop for idle sessions."""
|
|
530
|
+
if self._cleanup_task is None:
|
|
531
|
+
self._cleanup_task = asyncio.create_task(self._cleanup_idle_sessions())
|
|
532
|
+
|
|
533
|
+
async def shutdown(self) -> None:
|
|
534
|
+
"""Gracefully shut down the server, stopping background tasks."""
|
|
535
|
+
await self.shutdown_all_sessions()
|
|
536
|
+
|
|
537
|
+
async def shutdown_all_sessions(self) -> None:
|
|
538
|
+
"""Close all active sessions and stop the cleanup loop."""
|
|
539
|
+
await self._stop_cleanup_loop()
|
|
540
|
+
for session_id in list(self.sessions):
|
|
541
|
+
session = self.sessions.pop(session_id)
|
|
542
|
+
await session.close()
|
|
543
|
+
self.metrics.record_session_closed()
|
|
544
|
+
logger.info("All sessions shut down. Metrics: %s", self.metrics.snapshot())
|
|
545
|
+
|
|
546
|
+
async def _stop_cleanup_loop(self) -> None:
|
|
547
|
+
"""Stop background cleanup loop."""
|
|
548
|
+
if self._cleanup_task is not None:
|
|
549
|
+
self._cleanup_task.cancel()
|
|
550
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
551
|
+
await self._cleanup_task
|
|
552
|
+
self._cleanup_task = None
|
|
553
|
+
|
|
554
|
+
async def _cleanup_idle_sessions(self) -> None:
|
|
555
|
+
"""Periodically remove idle sessions.
|
|
556
|
+
|
|
557
|
+
A session is only considered for cleanup when:
|
|
558
|
+
* it has been idle longer than ``SESSION_IDLE_TIMEOUT``, and
|
|
559
|
+
* it has no in-flight prompt task (``_current_task is None`` or done),
|
|
560
|
+
and
|
|
561
|
+
* it has not already been closed.
|
|
562
|
+
|
|
563
|
+
This prevents the cleanup loop from terminating an actively running
|
|
564
|
+
prompt mid-execution.
|
|
565
|
+
"""
|
|
566
|
+
while True:
|
|
567
|
+
await asyncio.sleep(CLEANUP_INTERVAL)
|
|
568
|
+
try:
|
|
569
|
+
now = time.monotonic()
|
|
570
|
+
expired: list[str] = []
|
|
571
|
+
for sid, session in self.sessions.items():
|
|
572
|
+
if session.is_closed:
|
|
573
|
+
expired.append(sid)
|
|
574
|
+
continue
|
|
575
|
+
if now - session.last_active <= SESSION_IDLE_TIMEOUT:
|
|
576
|
+
continue
|
|
577
|
+
task = session._current_task
|
|
578
|
+
if task is not None and not task.done():
|
|
579
|
+
# Active prompt in progress — leave it alone for now.
|
|
580
|
+
continue
|
|
581
|
+
expired.append(sid)
|
|
582
|
+
for sid in expired:
|
|
583
|
+
session = self.sessions.pop(sid, None)
|
|
584
|
+
if session is not None and not session.is_closed:
|
|
585
|
+
await session.close()
|
|
586
|
+
self.metrics.record_session_closed()
|
|
587
|
+
logger.info("Cleaned up idle session %s", sid)
|
|
588
|
+
except Exception:
|
|
589
|
+
logger.exception("Error during session cleanup")
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# ---------------------------------------------------------------------------
|
|
593
|
+
# MCP server config helper
|
|
594
|
+
# ---------------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _convert_mcp_servers(mcp_servers: list[MCPServer] | None) -> list[dict[str, Any]]:
|
|
598
|
+
"""Convert ACP MCP server configs to internal dicts, filtering unsupported types.
|
|
599
|
+
|
|
600
|
+
Tolerant by design: a malformed or unsupported entry from the client must
|
|
601
|
+
not abort ``new_session``. Conversion failures are logged and the offending
|
|
602
|
+
entry is skipped so the session can still start with whatever configs are
|
|
603
|
+
valid.
|
|
604
|
+
"""
|
|
605
|
+
if not mcp_servers:
|
|
606
|
+
return []
|
|
607
|
+
from iac_code.acp.mcp import convert_mcp_configs
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
configs = convert_mcp_configs(mcp_servers)
|
|
611
|
+
except Exception:
|
|
612
|
+
logger.exception(
|
|
613
|
+
"Failed to convert MCP server configs (%d entries); proceeding with no MCP servers",
|
|
614
|
+
len(mcp_servers),
|
|
615
|
+
)
|
|
616
|
+
return []
|
|
617
|
+
if configs:
|
|
618
|
+
logger.info("Received %d MCP server config(s): %s", len(configs), [c["name"] for c in configs])
|
|
619
|
+
return configs
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
# ---------------------------------------------------------------------------
|
|
623
|
+
# Auth methods declaration
|
|
624
|
+
# ---------------------------------------------------------------------------
|
|
625
|
+
|
|
626
|
+
# Supported provider environment variables for credentials.
|
|
627
|
+
_PROVIDER_ENV_VARS: list[tuple[str, str, str]] = [
|
|
628
|
+
("DASHSCOPE_API_KEY", "DashScope / Qwen API Key", "https://dashscope.console.aliyun.com/"),
|
|
629
|
+
("OPENAI_API_KEY", "OpenAI API Key", "https://platform.openai.com/api-keys"),
|
|
630
|
+
("ANTHROPIC_API_KEY", "Anthropic API Key", "https://console.anthropic.com/"),
|
|
631
|
+
("DEEPSEEK_API_KEY", "DeepSeek API Key", "https://platform.deepseek.com/"),
|
|
632
|
+
]
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _build_auth_methods() -> list[
|
|
636
|
+
acp.schema.EnvVarAuthMethod | acp.schema.TerminalAuthMethod | acp.schema.AuthMethodAgent
|
|
637
|
+
]:
|
|
638
|
+
"""Build the list of supported authentication methods for ACP initialize.
|
|
639
|
+
|
|
640
|
+
iac-code supports multiple LLM providers. Credentials can be provided via
|
|
641
|
+
environment variables or via the credentials config file
|
|
642
|
+
(~/.iac-code/.credentials.yml). The env-var method is the standard ACP
|
|
643
|
+
mechanism that clients can present to users.
|
|
644
|
+
"""
|
|
645
|
+
return [
|
|
646
|
+
acp.schema.EnvVarAuthMethod(
|
|
647
|
+
type="env_var",
|
|
648
|
+
id=f"env_{env_name.lower()}",
|
|
649
|
+
name=label,
|
|
650
|
+
description=f"Set {env_name} to authenticate with this provider.",
|
|
651
|
+
link=link,
|
|
652
|
+
vars=[
|
|
653
|
+
acp.schema.AuthEnvVar(
|
|
654
|
+
name=env_name,
|
|
655
|
+
label=label,
|
|
656
|
+
secret=True,
|
|
657
|
+
optional=False,
|
|
658
|
+
),
|
|
659
|
+
],
|
|
660
|
+
)
|
|
661
|
+
for env_name, label, link in _PROVIDER_ENV_VARS
|
|
662
|
+
]
|