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,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class AgentFactoryOptions:
|
|
11
|
+
model: str
|
|
12
|
+
session_id: str | None = None
|
|
13
|
+
cwd: str | None = None
|
|
14
|
+
max_turns: int = 100
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class AgentRuntime:
|
|
19
|
+
agent_loop: Any
|
|
20
|
+
session_id: str
|
|
21
|
+
tool_registry: Any
|
|
22
|
+
provider_manager: Any
|
|
23
|
+
command_registry: Any
|
|
24
|
+
task_manager: Any
|
|
25
|
+
memory_manager: Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime:
|
|
29
|
+
from loguru import logger
|
|
30
|
+
|
|
31
|
+
from iac_code.agent.agent_loop import AgentLoop
|
|
32
|
+
from iac_code.agent.agent_tool import AgentTool
|
|
33
|
+
from iac_code.agent.system_prompt import build_system_prompt
|
|
34
|
+
from iac_code.commands import create_default_registry
|
|
35
|
+
from iac_code.commands.registry import PromptCommand
|
|
36
|
+
from iac_code.config import get_config_dir, load_credentials
|
|
37
|
+
from iac_code.memory.memory_manager import MemoryManager
|
|
38
|
+
from iac_code.memory.memory_tools import ReadMemoryTool, WriteMemoryTool
|
|
39
|
+
from iac_code.providers.manager import ProviderManager
|
|
40
|
+
from iac_code.services.cloud_credentials import CloudCredentials
|
|
41
|
+
from iac_code.services.session_storage import SessionStorage
|
|
42
|
+
from iac_code.skills.bundled import init_bundled_skills
|
|
43
|
+
from iac_code.skills.discovery import discover_all_skills, skill_to_command
|
|
44
|
+
from iac_code.skills.listing import build_skill_listing
|
|
45
|
+
from iac_code.skills.skill_tool import SkillTool
|
|
46
|
+
from iac_code.tasks.notification_queue import NotificationQueue
|
|
47
|
+
from iac_code.tasks.task_state import TaskManager
|
|
48
|
+
from iac_code.tasks.task_tools import TaskGetTool, TaskListTool, TaskStopTool
|
|
49
|
+
from iac_code.tools.base import ToolRegistry
|
|
50
|
+
from iac_code.tools.cloud.registry import register_cloud_tools
|
|
51
|
+
|
|
52
|
+
cwd = options.cwd or os.getcwd()
|
|
53
|
+
session_id = options.session_id or str(uuid.uuid4())[:8]
|
|
54
|
+
|
|
55
|
+
credentials = load_credentials()
|
|
56
|
+
|
|
57
|
+
provider_manager = ProviderManager(model=options.model, credentials=credentials)
|
|
58
|
+
|
|
59
|
+
tool_registry = ToolRegistry()
|
|
60
|
+
tool_registry.register_default_tools()
|
|
61
|
+
register_cloud_tools(tool_registry, CloudCredentials())
|
|
62
|
+
|
|
63
|
+
session_storage = SessionStorage()
|
|
64
|
+
|
|
65
|
+
memory_manager = MemoryManager(memory_dir=str(get_config_dir() / "memory"))
|
|
66
|
+
memory_content = memory_manager.get_prompt_content()
|
|
67
|
+
tool_registry.register(ReadMemoryTool(memory_manager))
|
|
68
|
+
tool_registry.register(WriteMemoryTool(memory_manager))
|
|
69
|
+
|
|
70
|
+
task_manager = TaskManager()
|
|
71
|
+
tool_registry.register(TaskListTool(task_manager))
|
|
72
|
+
tool_registry.register(TaskGetTool(task_manager))
|
|
73
|
+
tool_registry.register(TaskStopTool(task_manager))
|
|
74
|
+
|
|
75
|
+
notification_queue = NotificationQueue()
|
|
76
|
+
base_system_prompt = build_system_prompt(cwd=cwd, memory_content=memory_content)
|
|
77
|
+
tool_registry.register(
|
|
78
|
+
AgentTool(
|
|
79
|
+
task_manager=task_manager,
|
|
80
|
+
provider_manager=provider_manager,
|
|
81
|
+
tool_registry=tool_registry,
|
|
82
|
+
system_prompt=base_system_prompt,
|
|
83
|
+
notification_queue=notification_queue,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
init_bundled_skills()
|
|
88
|
+
command_registry = create_default_registry()
|
|
89
|
+
for skill in discover_all_skills(cwd):
|
|
90
|
+
cmd = skill_to_command(skill)
|
|
91
|
+
existing = command_registry.get(cmd.name)
|
|
92
|
+
if existing is not None and not isinstance(existing, PromptCommand):
|
|
93
|
+
logger.warning("Skill '{}' skipped: conflicts with built-in command", cmd.name)
|
|
94
|
+
continue
|
|
95
|
+
command_registry.register(cmd)
|
|
96
|
+
|
|
97
|
+
tool_registry.register(
|
|
98
|
+
SkillTool(
|
|
99
|
+
command_registry=command_registry,
|
|
100
|
+
session_id=session_id,
|
|
101
|
+
cwd=cwd,
|
|
102
|
+
provider_manager=provider_manager,
|
|
103
|
+
tool_registry=tool_registry,
|
|
104
|
+
system_prompt=base_system_prompt,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
skill_listing = build_skill_listing(command_registry.get_model_invocable_skills())
|
|
109
|
+
agent_loop = AgentLoop(
|
|
110
|
+
provider_manager=provider_manager,
|
|
111
|
+
system_prompt=build_system_prompt(cwd=cwd, memory_content=memory_content, skill_listing=skill_listing),
|
|
112
|
+
tool_registry=tool_registry,
|
|
113
|
+
session_storage=session_storage,
|
|
114
|
+
session_id=session_id,
|
|
115
|
+
max_turns=options.max_turns,
|
|
116
|
+
cwd=cwd,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return AgentRuntime(
|
|
120
|
+
agent_loop=agent_loop,
|
|
121
|
+
session_id=session_id,
|
|
122
|
+
tool_registry=tool_registry,
|
|
123
|
+
provider_manager=provider_manager,
|
|
124
|
+
command_registry=command_registry,
|
|
125
|
+
task_manager=task_manager,
|
|
126
|
+
memory_manager=memory_manager,
|
|
127
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from iac_code.services.providers.aliyun import AliyunCredential, AliyunCredentials
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CloudCredentials:
|
|
5
|
+
def __init__(self, aliyun_config_path: str | None = None) -> None:
|
|
6
|
+
self._aliyun_config_path = aliyun_config_path
|
|
7
|
+
|
|
8
|
+
def has_provider(self, name: str) -> bool:
|
|
9
|
+
if name == "aliyun":
|
|
10
|
+
return AliyunCredentials.is_configured(config_path=self._aliyun_config_path)
|
|
11
|
+
return False
|
|
12
|
+
|
|
13
|
+
def get_provider(self, name: str) -> AliyunCredential | None:
|
|
14
|
+
if name == "aliyun":
|
|
15
|
+
return AliyunCredentials.load(config_path=self._aliyun_config_path)
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
def list_providers(self) -> list[str]:
|
|
19
|
+
result: list[str] = []
|
|
20
|
+
if self.has_provider("aliyun"):
|
|
21
|
+
result.append("aliyun")
|
|
22
|
+
return result
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Context manager for conversation history, token tracking, and segmented compaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from iac_code.agent.message import ContentBlock, Conversation, Message, ToolResultBlock
|
|
11
|
+
from iac_code.services.token_counter import TokenCounter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ContextWindowConfig:
|
|
16
|
+
"""Model-specific context window configuration."""
|
|
17
|
+
|
|
18
|
+
context_window: int
|
|
19
|
+
max_output_tokens: int
|
|
20
|
+
compact_buffer: int
|
|
21
|
+
compact_threshold: float
|
|
22
|
+
preserve_recent_turns: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_MODEL_CONFIGS: dict[str, ContextWindowConfig] = {
|
|
26
|
+
"claude": ContextWindowConfig(200_000, 8_192, 20_000, 0.93, 3),
|
|
27
|
+
"gpt-5": ContextWindowConfig(200_000, 8_192, 20_000, 0.93, 3),
|
|
28
|
+
"gpt-4": ContextWindowConfig(128_000, 8_192, 15_000, 0.93, 3),
|
|
29
|
+
"qwen": ContextWindowConfig(131_072, 8_192, 15_000, 0.93, 3),
|
|
30
|
+
"qwq": ContextWindowConfig(131_072, 8_192, 15_000, 0.93, 3),
|
|
31
|
+
"o3": ContextWindowConfig(200_000, 8_192, 20_000, 0.93, 3),
|
|
32
|
+
"o4": ContextWindowConfig(200_000, 8_192, 20_000, 0.93, 3),
|
|
33
|
+
}
|
|
34
|
+
_DEFAULT_CONFIG = ContextWindowConfig(128_000, 8_192, 15_000, 0.93, 3)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_context_window_config(model: str) -> ContextWindowConfig:
|
|
38
|
+
model_lower = model.lower()
|
|
39
|
+
for prefix, config in _MODEL_CONFIGS.items():
|
|
40
|
+
if model_lower.startswith(prefix):
|
|
41
|
+
return config
|
|
42
|
+
return _DEFAULT_CONFIG
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ContextManager:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
system_prompt: str,
|
|
49
|
+
model: str = "",
|
|
50
|
+
) -> None:
|
|
51
|
+
self._system_prompt = system_prompt
|
|
52
|
+
self._conversation = Conversation()
|
|
53
|
+
self._model = model
|
|
54
|
+
self._token_counter = TokenCounter(model=model)
|
|
55
|
+
self._config = get_context_window_config(model)
|
|
56
|
+
self._system_prompt_tokens = self._token_counter.count_text(system_prompt)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def system_prompt(self) -> str:
|
|
60
|
+
return self._system_prompt
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def preserve_recent_turns(self) -> int:
|
|
64
|
+
return self._config.preserve_recent_turns
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def context_window(self) -> int:
|
|
68
|
+
"""Total context-window size in tokens for the current model."""
|
|
69
|
+
return self._config.context_window
|
|
70
|
+
|
|
71
|
+
def set_model(self, model: str) -> None:
|
|
72
|
+
"""Switch tokenizer/context-window config for a model change.
|
|
73
|
+
|
|
74
|
+
Recomputes cached token counts so compaction thresholds stay
|
|
75
|
+
accurate after a `/model` or `/auth` switch.
|
|
76
|
+
"""
|
|
77
|
+
if model == self._model:
|
|
78
|
+
return
|
|
79
|
+
self._model = model
|
|
80
|
+
self._token_counter = TokenCounter(model=model)
|
|
81
|
+
self._config = get_context_window_config(model)
|
|
82
|
+
self._system_prompt_tokens = self._token_counter.count_text(self._system_prompt)
|
|
83
|
+
for msg in self._conversation.messages:
|
|
84
|
+
msg.token_count = self._token_counter.count_message(msg.to_api_format())
|
|
85
|
+
|
|
86
|
+
def set_system_prompt(self, system_prompt: str) -> None:
|
|
87
|
+
"""Replace the system prompt and refresh its cached token count."""
|
|
88
|
+
if system_prompt == self._system_prompt:
|
|
89
|
+
return
|
|
90
|
+
self._system_prompt = system_prompt
|
|
91
|
+
self._system_prompt_tokens = self._token_counter.count_text(system_prompt)
|
|
92
|
+
|
|
93
|
+
def add_user_message(self, content: str) -> Message:
|
|
94
|
+
msg = self._conversation.add_user_message(content)
|
|
95
|
+
msg.token_count = self._token_counter.count_message(msg.to_api_format())
|
|
96
|
+
return msg
|
|
97
|
+
|
|
98
|
+
def add_assistant_message(self, content: str | list[ContentBlock]) -> Message:
|
|
99
|
+
msg = self._conversation.add_assistant_message(content)
|
|
100
|
+
msg.token_count = self._token_counter.count_message(msg.to_api_format())
|
|
101
|
+
return msg
|
|
102
|
+
|
|
103
|
+
def add_tool_results(self, tool_results: list[ToolResultBlock]) -> Message:
|
|
104
|
+
msg = self._conversation.add_tool_results(tool_results)
|
|
105
|
+
msg.token_count = self._token_counter.count_message(msg.to_api_format())
|
|
106
|
+
return msg
|
|
107
|
+
|
|
108
|
+
def add_raw_message(self, raw_msg: dict[str, Any]) -> Message:
|
|
109
|
+
"""Add a raw message dict (e.g. from ToolResult.new_messages) to the conversation."""
|
|
110
|
+
role = raw_msg.get("role", "user")
|
|
111
|
+
content = raw_msg.get("content", "")
|
|
112
|
+
msg = Message(role=role, content=content)
|
|
113
|
+
self._conversation.messages.append(msg)
|
|
114
|
+
msg.token_count = self._token_counter.count_message(msg.to_api_format())
|
|
115
|
+
return msg
|
|
116
|
+
|
|
117
|
+
def load_messages(self, messages: list[Message]) -> None:
|
|
118
|
+
"""Inject pre-existing messages (e.g. from a resumed session)."""
|
|
119
|
+
for msg in messages:
|
|
120
|
+
self._conversation.messages.append(msg)
|
|
121
|
+
if msg.token_count == 0:
|
|
122
|
+
msg.token_count = self._token_counter.count_message(msg.to_api_format())
|
|
123
|
+
|
|
124
|
+
def get_messages(self) -> list[Message]:
|
|
125
|
+
return self._conversation.messages
|
|
126
|
+
|
|
127
|
+
def get_api_messages(self) -> list[dict[str, Any]]:
|
|
128
|
+
return self._conversation.to_api_messages()
|
|
129
|
+
|
|
130
|
+
def get_total_tokens(self) -> int:
|
|
131
|
+
return self._system_prompt_tokens + self._conversation.get_total_tokens()
|
|
132
|
+
|
|
133
|
+
def get_usage(self) -> dict[str, Any]:
|
|
134
|
+
"""Return detailed token usage breakdown by category."""
|
|
135
|
+
user_tokens = 0
|
|
136
|
+
assistant_tokens = 0
|
|
137
|
+
tool_result_tokens = 0
|
|
138
|
+
|
|
139
|
+
for msg in self._conversation.messages:
|
|
140
|
+
if msg.role == "user":
|
|
141
|
+
if isinstance(msg.content, list) and any(isinstance(b, ToolResultBlock) for b in msg.content):
|
|
142
|
+
tool_result_tokens += msg.token_count
|
|
143
|
+
else:
|
|
144
|
+
user_tokens += msg.token_count
|
|
145
|
+
elif msg.role == "assistant":
|
|
146
|
+
assistant_tokens += msg.token_count
|
|
147
|
+
|
|
148
|
+
total = self._system_prompt_tokens + user_tokens + assistant_tokens + tool_result_tokens
|
|
149
|
+
return {
|
|
150
|
+
"system_prompt_tokens": self._system_prompt_tokens,
|
|
151
|
+
"user_message_tokens": user_tokens,
|
|
152
|
+
"assistant_message_tokens": assistant_tokens,
|
|
153
|
+
"tool_result_tokens": tool_result_tokens,
|
|
154
|
+
"total_tokens": total,
|
|
155
|
+
"context_window": self._config.context_window,
|
|
156
|
+
"usage_percent": (total / self._config.context_window * 100) if self._config.context_window > 0 else 0,
|
|
157
|
+
"message_count": len(self._conversation.messages),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def needs_compaction(self) -> bool:
|
|
161
|
+
total = self.get_total_tokens()
|
|
162
|
+
threshold = self._config.context_window * self._config.compact_threshold
|
|
163
|
+
return total > threshold
|
|
164
|
+
|
|
165
|
+
def _split_messages_for_compaction(self) -> tuple[list[Message], list[Message]]:
|
|
166
|
+
"""Split messages into [old_messages, recent_messages].
|
|
167
|
+
|
|
168
|
+
A "turn" is a user+assistant message pair. We preserve the last
|
|
169
|
+
`preserve_recent_turns` turns (counting from the end).
|
|
170
|
+
"""
|
|
171
|
+
messages = self._conversation.messages
|
|
172
|
+
preserve_count = self._config.preserve_recent_turns * 2
|
|
173
|
+
|
|
174
|
+
if len(messages) <= preserve_count:
|
|
175
|
+
return [], messages
|
|
176
|
+
|
|
177
|
+
split_point = len(messages) - preserve_count
|
|
178
|
+
return messages[:split_point], messages[split_point:]
|
|
179
|
+
|
|
180
|
+
def build_compaction_prompt(self) -> str:
|
|
181
|
+
"""Build compaction prompt from old messages only (recent are preserved)."""
|
|
182
|
+
old_messages, _recent = self._split_messages_for_compaction()
|
|
183
|
+
if not old_messages:
|
|
184
|
+
return ""
|
|
185
|
+
|
|
186
|
+
conversation_text = []
|
|
187
|
+
for msg in old_messages:
|
|
188
|
+
role = msg.role.upper()
|
|
189
|
+
text = msg.get_text()
|
|
190
|
+
if text:
|
|
191
|
+
conversation_text.append(f"{role}: {text}")
|
|
192
|
+
|
|
193
|
+
joined = "\n".join(conversation_text)
|
|
194
|
+
return (
|
|
195
|
+
"Please provide a concise summary of this conversation so far. "
|
|
196
|
+
"Focus on:\n"
|
|
197
|
+
"1. Key decisions made\n"
|
|
198
|
+
"2. Important code changes or file modifications\n"
|
|
199
|
+
"3. Current task status and next steps\n"
|
|
200
|
+
"4. Any errors encountered and how they were resolved\n\n"
|
|
201
|
+
"Keep the summary focused and actionable. Preserve specific file paths, "
|
|
202
|
+
"function names, and technical details that are needed to continue the work.\n\n"
|
|
203
|
+
f"Conversation:\n{joined}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def apply_compaction(self, summary: str) -> tuple[int, int]:
|
|
207
|
+
"""Replace old messages with summary, keep recent messages intact."""
|
|
208
|
+
original_tokens = self._conversation.get_total_tokens()
|
|
209
|
+
|
|
210
|
+
_old, recent = self._split_messages_for_compaction()
|
|
211
|
+
|
|
212
|
+
summary_msg = Message(role="user", content=f"[Conversation Summary]\n{summary}")
|
|
213
|
+
summary_msg.token_count = self._token_counter.count_message(summary_msg.to_api_format())
|
|
214
|
+
|
|
215
|
+
self._conversation.replace_messages([summary_msg] + recent)
|
|
216
|
+
new_tokens = self._conversation.get_total_tokens()
|
|
217
|
+
logger.info(f"Compaction: {original_tokens} -> {new_tokens} tokens")
|
|
218
|
+
return (original_tokens, new_tokens)
|
|
219
|
+
|
|
220
|
+
def reset(self) -> None:
|
|
221
|
+
self._conversation = Conversation()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from iac_code.config import _load_yaml, _save_yaml, get_cloud_credentials_path
|
|
8
|
+
|
|
9
|
+
DEFAULT_REGION = "cn-hangzhou"
|
|
10
|
+
DEFAULT_ALIYUN_CLI_CONFIG_PATH = os.path.expanduser("~/.aliyun/config.json")
|
|
11
|
+
|
|
12
|
+
# Credential modes matching aliyun CLI
|
|
13
|
+
CREDENTIAL_MODES = ["AK", "StsToken", "RamRoleArn"]
|
|
14
|
+
|
|
15
|
+
# Fields definition for each credential mode
|
|
16
|
+
# Each field: (name, label, sensitive)
|
|
17
|
+
MODE_FIELDS: dict[str, list[tuple[str, str, bool]]] = {
|
|
18
|
+
"AK": [
|
|
19
|
+
("access_key_id", "AccessKey ID", True),
|
|
20
|
+
("access_key_secret", "AccessKey Secret", True),
|
|
21
|
+
],
|
|
22
|
+
"StsToken": [
|
|
23
|
+
("access_key_id", "AccessKey ID", True),
|
|
24
|
+
("access_key_secret", "AccessKey Secret", True),
|
|
25
|
+
("sts_token", "STS Token", True),
|
|
26
|
+
],
|
|
27
|
+
"RamRoleArn": [
|
|
28
|
+
("access_key_id", "AccessKey ID", True),
|
|
29
|
+
("access_key_secret", "AccessKey Secret", True),
|
|
30
|
+
("ram_role_arn", "RAM Role ARN", False),
|
|
31
|
+
("ram_session_name", "Session Name", False),
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Display names for credential modes (English, translatable via i18n)
|
|
36
|
+
MODE_DISPLAY_NAMES: dict[str, str] = {
|
|
37
|
+
"AK": "AccessKey",
|
|
38
|
+
"StsToken": "STS Token",
|
|
39
|
+
"RamRoleArn": "RAM Role",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class AliyunCredential:
|
|
45
|
+
mode: str = "AK"
|
|
46
|
+
access_key_id: str = ""
|
|
47
|
+
access_key_secret: str = ""
|
|
48
|
+
region_id: str = field(default=DEFAULT_REGION)
|
|
49
|
+
sts_token: str = ""
|
|
50
|
+
ram_role_arn: str = ""
|
|
51
|
+
ram_session_name: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def mask_sensitive(value: str) -> str:
|
|
55
|
+
"""Mask a sensitive value with '*' characters of the same length."""
|
|
56
|
+
if not value:
|
|
57
|
+
return ""
|
|
58
|
+
return "*" * len(value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AliyunCredentials:
|
|
62
|
+
@staticmethod
|
|
63
|
+
def load(config_path: str | None = None) -> AliyunCredential | None:
|
|
64
|
+
"""Load credentials with priority: env vars > iac-code config > aliyun CLI config."""
|
|
65
|
+
# Try environment variables first
|
|
66
|
+
access_key_id = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_ID")
|
|
67
|
+
access_key_secret = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_SECRET")
|
|
68
|
+
if access_key_id and access_key_secret:
|
|
69
|
+
region_id = os.environ.get("ALIBABA_CLOUD_REGION_ID")
|
|
70
|
+
if not region_id:
|
|
71
|
+
# Env vars don't specify region — walk the file fallback chain:
|
|
72
|
+
# iac-code config → aliyun CLI config → DEFAULT_REGION.
|
|
73
|
+
iac_cred = AliyunCredentials._load_from_iac_code_config()
|
|
74
|
+
if iac_cred and iac_cred.region_id:
|
|
75
|
+
region_id = iac_cred.region_id
|
|
76
|
+
else:
|
|
77
|
+
cli_cred = AliyunCredentials._load_from_aliyun_cli(config_path)
|
|
78
|
+
region_id = (cli_cred.region_id if cli_cred else None) or DEFAULT_REGION
|
|
79
|
+
sts_token = os.environ.get("ALIBABA_CLOUD_SECURITY_TOKEN", "")
|
|
80
|
+
mode = "StsToken" if sts_token else "AK"
|
|
81
|
+
return AliyunCredential(
|
|
82
|
+
mode=mode,
|
|
83
|
+
access_key_id=access_key_id,
|
|
84
|
+
access_key_secret=access_key_secret,
|
|
85
|
+
region_id=region_id,
|
|
86
|
+
sts_token=sts_token,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Try iac-code config
|
|
90
|
+
if config_path is None:
|
|
91
|
+
cred = AliyunCredentials._load_from_iac_code_config()
|
|
92
|
+
if cred is not None:
|
|
93
|
+
return cred
|
|
94
|
+
|
|
95
|
+
# Fall back to aliyun CLI config
|
|
96
|
+
return AliyunCredentials._load_from_aliyun_cli(config_path)
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _load_from_iac_code_config() -> AliyunCredential | None:
|
|
100
|
+
"""Load credentials from ~/.iac-code/.cloud-credentials.yml."""
|
|
101
|
+
cloud_creds = _load_yaml(get_cloud_credentials_path())
|
|
102
|
+
aliyun_data = cloud_creds.get("aliyun")
|
|
103
|
+
if not aliyun_data or not isinstance(aliyun_data, dict):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
mode = aliyun_data.get("mode", "AK")
|
|
107
|
+
if mode not in CREDENTIAL_MODES:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
return AliyunCredential(
|
|
111
|
+
mode=mode,
|
|
112
|
+
access_key_id=aliyun_data.get("access_key_id", ""),
|
|
113
|
+
access_key_secret=aliyun_data.get("access_key_secret", ""),
|
|
114
|
+
region_id=aliyun_data.get("region_id", DEFAULT_REGION),
|
|
115
|
+
sts_token=aliyun_data.get("sts_token", ""),
|
|
116
|
+
ram_role_arn=aliyun_data.get("ram_role_arn", ""),
|
|
117
|
+
ram_session_name=aliyun_data.get("ram_session_name", ""),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _load_from_aliyun_cli(config_path: str | None = None) -> AliyunCredential | None:
|
|
122
|
+
"""Load credentials from aliyun CLI config file (~/.aliyun/config.json)."""
|
|
123
|
+
path = Path(config_path) if config_path else Path(DEFAULT_ALIYUN_CLI_CONFIG_PATH)
|
|
124
|
+
if not path.exists():
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
data = json.loads(path.read_text())
|
|
129
|
+
except (json.JSONDecodeError, OSError):
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
profiles = {p["name"]: p for p in data.get("profiles", [])}
|
|
133
|
+
profile = profiles.get("default")
|
|
134
|
+
if not profile:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
mode = profile.get("mode", "AK")
|
|
138
|
+
return AliyunCredential(
|
|
139
|
+
mode=mode,
|
|
140
|
+
access_key_id=profile.get("access_key_id", ""),
|
|
141
|
+
access_key_secret=profile.get("access_key_secret", ""),
|
|
142
|
+
region_id=profile.get("region_id", DEFAULT_REGION),
|
|
143
|
+
sts_token=profile.get("sts_token", ""),
|
|
144
|
+
ram_role_arn=profile.get("ram_role_arn", ""),
|
|
145
|
+
ram_session_name=profile.get("ram_session_name", ""),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def load_from_aliyun_cli(config_path: str | None = None) -> AliyunCredential | None:
|
|
150
|
+
"""Public method to load credentials from aliyun CLI config only.
|
|
151
|
+
|
|
152
|
+
Used by auth UI to display existing aliyun CLI config with masking.
|
|
153
|
+
"""
|
|
154
|
+
return AliyunCredentials._load_from_aliyun_cli(config_path)
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def save(
|
|
158
|
+
credential: AliyunCredential,
|
|
159
|
+
config_path: str | None = None,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Save credentials to ~/.iac-code/.cloud-credentials.yml."""
|
|
162
|
+
if config_path is not None:
|
|
163
|
+
# For testing: save to specified path in aliyun CLI format
|
|
164
|
+
AliyunCredentials._save_to_aliyun_cli_format(credential, config_path)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
path = get_cloud_credentials_path()
|
|
168
|
+
cloud_creds = _load_yaml(path)
|
|
169
|
+
|
|
170
|
+
aliyun_data: dict[str, Any] = {
|
|
171
|
+
"mode": credential.mode,
|
|
172
|
+
"region_id": credential.region_id,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Save fields relevant to the credential mode
|
|
176
|
+
mode_fields = MODE_FIELDS.get(credential.mode, [])
|
|
177
|
+
for field_name, _label, _sensitive in mode_fields:
|
|
178
|
+
aliyun_data[field_name] = getattr(credential, field_name, "")
|
|
179
|
+
|
|
180
|
+
cloud_creds["aliyun"] = aliyun_data
|
|
181
|
+
_save_yaml(path, cloud_creds)
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _save_to_aliyun_cli_format(credential: AliyunCredential, config_path: str) -> None:
|
|
185
|
+
"""Save credentials in aliyun CLI JSON format (for testing)."""
|
|
186
|
+
from typing import cast
|
|
187
|
+
|
|
188
|
+
path = Path(config_path)
|
|
189
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
|
|
191
|
+
data: dict[str, object] = {"current": "default", "profiles": []}
|
|
192
|
+
if path.exists():
|
|
193
|
+
try:
|
|
194
|
+
loaded = json.loads(path.read_text())
|
|
195
|
+
if isinstance(loaded, dict):
|
|
196
|
+
data = loaded
|
|
197
|
+
except (json.JSONDecodeError, OSError):
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
updated_profile: dict[str, str] = {
|
|
201
|
+
"name": "default",
|
|
202
|
+
"mode": credential.mode,
|
|
203
|
+
"access_key_id": credential.access_key_id,
|
|
204
|
+
"access_key_secret": credential.access_key_secret,
|
|
205
|
+
"region_id": credential.region_id,
|
|
206
|
+
"sts_token": credential.sts_token,
|
|
207
|
+
"ram_role_arn": credential.ram_role_arn,
|
|
208
|
+
"ram_session_name": credential.ram_session_name,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
raw_profiles = data.get("profiles")
|
|
212
|
+
profiles: list[dict[str, str]] = (
|
|
213
|
+
cast(list[dict[str, str]], raw_profiles) if isinstance(raw_profiles, list) else []
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
for i, profile in enumerate(profiles):
|
|
217
|
+
if isinstance(profile, dict) and profile.get("name") == "default":
|
|
218
|
+
profiles[i] = updated_profile
|
|
219
|
+
break
|
|
220
|
+
else:
|
|
221
|
+
profiles.append(updated_profile)
|
|
222
|
+
|
|
223
|
+
data["profiles"] = profiles
|
|
224
|
+
if "current" not in data:
|
|
225
|
+
data["current"] = "default"
|
|
226
|
+
|
|
227
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def is_configured(config_path: str | None = None) -> bool:
|
|
231
|
+
"""Check if credentials are available."""
|
|
232
|
+
return AliyunCredentials.load(config_path=config_path) is not None
|