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,157 @@
|
|
|
1
|
+
"""Prompt rendering pipeline — argument substitution, variable replacement, shell execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
|
|
9
|
+
from iac_code.skills.skill_definition import SkillContext, SkillDefinition
|
|
10
|
+
|
|
11
|
+
# Shell command matching patterns
|
|
12
|
+
BLOCK_PATTERN = re.compile(r"```!\s*\n?([\s\S]*?)\n?```")
|
|
13
|
+
INLINE_PATTERN = re.compile(r"(?:^|\s)!`([^`]+)`")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def render_skill_prompt(
|
|
17
|
+
skill: SkillDefinition,
|
|
18
|
+
args: str,
|
|
19
|
+
context: SkillContext,
|
|
20
|
+
) -> str:
|
|
21
|
+
"""Complete rendering pipeline for a skill prompt."""
|
|
22
|
+
content = skill.content
|
|
23
|
+
|
|
24
|
+
# Step 1: Base directory prefix
|
|
25
|
+
if skill.skill_root:
|
|
26
|
+
content = f"Base directory for this skill: {skill.skill_root}\n\n{content}"
|
|
27
|
+
|
|
28
|
+
# Step 2: Argument substitution
|
|
29
|
+
content = substitute_arguments(
|
|
30
|
+
content, args, append_if_no_placeholder=True, argument_names=skill.frontmatter.arguments
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Step 3: Built-in variable substitution
|
|
34
|
+
content = content.replace("${SKILL_DIR}", context.skill_dir or "")
|
|
35
|
+
content = content.replace("${SESSION_ID}", context.session_id or "")
|
|
36
|
+
|
|
37
|
+
# Step 4: Shell command execution
|
|
38
|
+
content = await execute_shell_commands(content, cwd=context.cwd)
|
|
39
|
+
|
|
40
|
+
return content
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def substitute_arguments(
|
|
44
|
+
content: str,
|
|
45
|
+
args: str,
|
|
46
|
+
*,
|
|
47
|
+
append_if_no_placeholder: bool = True,
|
|
48
|
+
argument_names: list[str] | None = None,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Substitute argument placeholders in skill content.
|
|
51
|
+
|
|
52
|
+
Supports:
|
|
53
|
+
- Named arguments: $argName
|
|
54
|
+
- Indexed arguments: $ARGUMENTS[0], $ARGUMENTS[1]
|
|
55
|
+
- Short indexed: $0, $1
|
|
56
|
+
- Full arguments: $ARGUMENTS
|
|
57
|
+
"""
|
|
58
|
+
if not args:
|
|
59
|
+
return content
|
|
60
|
+
|
|
61
|
+
original = content
|
|
62
|
+
parsed_args = _parse_arguments(args)
|
|
63
|
+
argument_names = argument_names or []
|
|
64
|
+
|
|
65
|
+
# 1. Named argument substitution: $foo, $bar
|
|
66
|
+
for i, name in enumerate(argument_names):
|
|
67
|
+
if i < len(parsed_args):
|
|
68
|
+
content = re.sub(
|
|
69
|
+
rf"\${re.escape(name)}(?![\[\w])",
|
|
70
|
+
parsed_args[i],
|
|
71
|
+
content,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# 2. Indexed argument substitution: $ARGUMENTS[0], $ARGUMENTS[1]
|
|
75
|
+
content = re.sub(
|
|
76
|
+
r"\$ARGUMENTS\[(\d+)\]",
|
|
77
|
+
lambda m: parsed_args[int(m.group(1))] if int(m.group(1)) < len(parsed_args) else "",
|
|
78
|
+
content,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# 3. Short indexed: $0, $1
|
|
82
|
+
content = re.sub(
|
|
83
|
+
r"\$(\d+)(?!\w)",
|
|
84
|
+
lambda m: parsed_args[int(m.group(1))] if int(m.group(1)) < len(parsed_args) else "",
|
|
85
|
+
content,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# 4. Full arguments substitution: $ARGUMENTS
|
|
89
|
+
content = content.replace("$ARGUMENTS", args)
|
|
90
|
+
|
|
91
|
+
# 5. Append if no placeholder was used
|
|
92
|
+
if content == original and append_if_no_placeholder and args:
|
|
93
|
+
content += f"\n\nARGUMENTS: {args}"
|
|
94
|
+
|
|
95
|
+
return content
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_arguments(args: str) -> list[str]:
|
|
99
|
+
"""Parse space-separated arguments, respecting quotes."""
|
|
100
|
+
try:
|
|
101
|
+
return shlex.split(args)
|
|
102
|
+
except ValueError:
|
|
103
|
+
return args.split()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def execute_shell_commands(content: str, *, cwd: str = "") -> str:
|
|
107
|
+
"""Execute inline shell commands in skill content and replace with output.
|
|
108
|
+
|
|
109
|
+
Two syntaxes:
|
|
110
|
+
- Inline: !`command` -> replaced with stdout (trimmed)
|
|
111
|
+
- Block: ```!\\ncommand\\n``` -> replaced with stdout
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
async def _replace_block(match: re.Match) -> str:
|
|
115
|
+
cmd = match.group(1).strip()
|
|
116
|
+
return await _run_shell(cmd, cwd=cwd)
|
|
117
|
+
|
|
118
|
+
async def _replace_inline(match: re.Match) -> str:
|
|
119
|
+
cmd = match.group(1).strip()
|
|
120
|
+
output = await _run_shell(cmd, cwd=cwd)
|
|
121
|
+
return output.strip()
|
|
122
|
+
|
|
123
|
+
# Replace block commands
|
|
124
|
+
content = await _replace_async(content, BLOCK_PATTERN, _replace_block)
|
|
125
|
+
|
|
126
|
+
# Replace inline commands
|
|
127
|
+
content = await _replace_async(content, INLINE_PATTERN, _replace_inline)
|
|
128
|
+
|
|
129
|
+
return content
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def _run_shell(cmd: str, *, cwd: str = "", timeout: float = 30.0) -> str:
|
|
133
|
+
"""Run a shell command and return its stdout."""
|
|
134
|
+
try:
|
|
135
|
+
proc = await asyncio.create_subprocess_shell(
|
|
136
|
+
cmd,
|
|
137
|
+
stdout=asyncio.subprocess.PIPE,
|
|
138
|
+
stderr=asyncio.subprocess.PIPE,
|
|
139
|
+
cwd=cwd or None,
|
|
140
|
+
)
|
|
141
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
142
|
+
return stdout.decode("utf-8", errors="replace")
|
|
143
|
+
except (asyncio.TimeoutError, OSError) as e:
|
|
144
|
+
return f"[shell error: {e}]"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _replace_async(text: str, pattern: re.Pattern, replacer) -> str:
|
|
148
|
+
"""Async version of re.sub — awaits the replacer coroutine for each match."""
|
|
149
|
+
result_parts: list[str] = []
|
|
150
|
+
last_end = 0
|
|
151
|
+
for match in pattern.finditer(text):
|
|
152
|
+
result_parts.append(text[last_end : match.start()])
|
|
153
|
+
replacement = await replacer(match)
|
|
154
|
+
result_parts.append(replacement)
|
|
155
|
+
last_end = match.end()
|
|
156
|
+
result_parts.append(text[last_end:])
|
|
157
|
+
return "".join(result_parts)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Core skill definition types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from iac_code.skills.frontmatter import SkillFrontmatter
|
|
9
|
+
from iac_code.types.skill_source import SkillSource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SkillPromptProvider(Protocol):
|
|
13
|
+
"""Protocol for skill prompt generation."""
|
|
14
|
+
|
|
15
|
+
async def get_prompt(self, args: str, context: SkillContext) -> str:
|
|
16
|
+
"""Generate the final prompt content for this skill."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SkillContext:
|
|
22
|
+
"""Context available during skill prompt generation."""
|
|
23
|
+
|
|
24
|
+
cwd: str
|
|
25
|
+
session_id: str = ""
|
|
26
|
+
skill_dir: str = ""
|
|
27
|
+
skill_root: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SkillDefinition:
|
|
32
|
+
"""Complete definition of a skill."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
description: str
|
|
36
|
+
frontmatter: SkillFrontmatter
|
|
37
|
+
content: str
|
|
38
|
+
source: SkillSource = SkillSource.PROJECT
|
|
39
|
+
file_path: str = ""
|
|
40
|
+
skill_root: str = ""
|
|
41
|
+
content_length: int = 0
|
|
42
|
+
|
|
43
|
+
# Bundled skills can provide a custom prompt generator
|
|
44
|
+
_prompt_provider: SkillPromptProvider | None = field(default=None, repr=False)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_user_invocable(self) -> bool:
|
|
48
|
+
return self.frontmatter.user_invocable
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def allowed_tools(self) -> list[str]:
|
|
52
|
+
return self.frontmatter.allowed_tools
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def model_override(self) -> str:
|
|
56
|
+
return self.frontmatter.model
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def effort_override(self) -> str:
|
|
60
|
+
return self.frontmatter.effort
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def context_mode(self) -> str:
|
|
64
|
+
"""'inline' or 'fork'"""
|
|
65
|
+
return self.frontmatter.context
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def agent_type(self) -> str:
|
|
69
|
+
return self.frontmatter.agent
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def when_to_use(self) -> str:
|
|
73
|
+
return self.frontmatter.when_to_use
|
|
74
|
+
|
|
75
|
+
async def get_prompt(self, args: str, context: SkillContext) -> str:
|
|
76
|
+
"""Generate the final prompt content."""
|
|
77
|
+
if self._prompt_provider is not None:
|
|
78
|
+
return await self._prompt_provider.get_prompt(args, context)
|
|
79
|
+
|
|
80
|
+
from iac_code.skills.renderer import render_skill_prompt
|
|
81
|
+
|
|
82
|
+
return await render_skill_prompt(self, args, context)
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""SkillTool — registered in ToolRegistry, allows the model to invoke skills."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from iac_code.i18n import _
|
|
10
|
+
from iac_code.tools.base import Tool, ToolContext, ToolResult
|
|
11
|
+
from iac_code.types.skill_source import SkillSource
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from iac_code.commands.registry import CommandRegistry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SkillTool(Tool):
|
|
18
|
+
"""Tool that allows the model to invoke skills during conversation.
|
|
19
|
+
|
|
20
|
+
Looks up skills from the unified CommandRegistry (PromptCommand instances).
|
|
21
|
+
Supports two execution modes:
|
|
22
|
+
- inline: Expands skill content into the current conversation context.
|
|
23
|
+
- fork: Runs skill in an isolated sub-agent.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
command_registry: CommandRegistry,
|
|
29
|
+
*,
|
|
30
|
+
session_id: str = "",
|
|
31
|
+
cwd: str = "",
|
|
32
|
+
provider_manager: Any = None,
|
|
33
|
+
tool_registry: Any = None,
|
|
34
|
+
system_prompt: str = "",
|
|
35
|
+
) -> None:
|
|
36
|
+
self._command_registry = command_registry
|
|
37
|
+
self._session_id = session_id
|
|
38
|
+
self._cwd = cwd
|
|
39
|
+
self._provider_manager = provider_manager
|
|
40
|
+
self._tool_registry = tool_registry
|
|
41
|
+
self._system_prompt = system_prompt
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str:
|
|
45
|
+
return "skill"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def description(self) -> str:
|
|
49
|
+
return "Execute a skill within the current conversation."
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def input_schema(self) -> dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"properties": {
|
|
56
|
+
"skill": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"description": "The skill name to execute.",
|
|
59
|
+
},
|
|
60
|
+
"args": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "Optional arguments for the skill.",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
"required": ["skill"],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
|
|
69
|
+
import time
|
|
70
|
+
|
|
71
|
+
from iac_code.services.telemetry import log_event
|
|
72
|
+
from iac_code.services.telemetry.names import Events
|
|
73
|
+
from iac_code.services.telemetry.sanitize import sanitize_skill_name
|
|
74
|
+
|
|
75
|
+
skill_name = self._normalize_name(tool_input["skill"])
|
|
76
|
+
args = tool_input.get("args", "")
|
|
77
|
+
|
|
78
|
+
from iac_code.commands.registry import PromptCommand
|
|
79
|
+
|
|
80
|
+
command = self._command_registry.get(skill_name)
|
|
81
|
+
if not isinstance(command, PromptCommand):
|
|
82
|
+
return ToolResult.error(f"Skill not found: '{skill_name}'")
|
|
83
|
+
|
|
84
|
+
# Record usage
|
|
85
|
+
self._command_registry.record_skill_usage(skill_name)
|
|
86
|
+
|
|
87
|
+
skill = command.skill
|
|
88
|
+
if skill is None:
|
|
89
|
+
return ToolResult.error(f"Skill definition missing for: '{skill_name}'")
|
|
90
|
+
|
|
91
|
+
# Emit skill invoked event
|
|
92
|
+
safe_name = sanitize_skill_name(skill_name)
|
|
93
|
+
log_event(
|
|
94
|
+
Events.SKILL_INVOKED,
|
|
95
|
+
{
|
|
96
|
+
"skill_name": safe_name,
|
|
97
|
+
"invocation_source": "explicit",
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
started = time.monotonic()
|
|
101
|
+
|
|
102
|
+
# Execute based on context mode
|
|
103
|
+
if skill.context_mode == "fork":
|
|
104
|
+
return await self._execute_forked(command, args, context, safe_name, started)
|
|
105
|
+
else:
|
|
106
|
+
return await self._execute_inline(command, args, safe_name, started)
|
|
107
|
+
|
|
108
|
+
async def _execute_inline(
|
|
109
|
+
self, command: Any, args: str, safe_name: str | None = None, started: float | None = None
|
|
110
|
+
) -> ToolResult:
|
|
111
|
+
"""Inline mode: expand skill content into current conversation context."""
|
|
112
|
+
import time
|
|
113
|
+
|
|
114
|
+
from iac_code.services.telemetry import log_event
|
|
115
|
+
from iac_code.services.telemetry.names import Events
|
|
116
|
+
from iac_code.skills.processor import process_prompt_command
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
result = await process_prompt_command(command, args, session_id=self._session_id)
|
|
120
|
+
if safe_name is not None and started is not None:
|
|
121
|
+
log_event(
|
|
122
|
+
Events.SKILL_COMPLETED,
|
|
123
|
+
{
|
|
124
|
+
"skill_name": safe_name,
|
|
125
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
126
|
+
"outcome": "success",
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
return ToolResult(
|
|
130
|
+
content=_("Skill '{name}' loaded (inline).").format(name=result.skill_name),
|
|
131
|
+
is_error=False,
|
|
132
|
+
new_messages=result.new_messages,
|
|
133
|
+
context_modifier=result.context_modifier,
|
|
134
|
+
)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
if safe_name is not None and started is not None:
|
|
137
|
+
log_event(
|
|
138
|
+
Events.SKILL_COMPLETED,
|
|
139
|
+
{
|
|
140
|
+
"skill_name": safe_name,
|
|
141
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
142
|
+
"outcome": "error",
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
logger.exception("Skill inline execution failed: %s", command.name)
|
|
146
|
+
return ToolResult.error(f"Skill execution failed: {e}")
|
|
147
|
+
|
|
148
|
+
async def _execute_forked(
|
|
149
|
+
self,
|
|
150
|
+
command: Any,
|
|
151
|
+
args: str,
|
|
152
|
+
tool_context: ToolContext,
|
|
153
|
+
safe_name: str | None = None,
|
|
154
|
+
started: float | None = None,
|
|
155
|
+
) -> ToolResult:
|
|
156
|
+
"""Fork mode: run skill in an isolated sub-agent."""
|
|
157
|
+
import time
|
|
158
|
+
|
|
159
|
+
from iac_code.agent.agent_tool import run_sub_agent
|
|
160
|
+
from iac_code.services.telemetry import log_event
|
|
161
|
+
from iac_code.services.telemetry.names import Events
|
|
162
|
+
from iac_code.skills.processor import process_prompt_command
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
result = await process_prompt_command(command, args, session_id=self._session_id)
|
|
166
|
+
skill = command.skill
|
|
167
|
+
result_text, progress = await run_sub_agent(
|
|
168
|
+
prompt=result.prompt_content,
|
|
169
|
+
agent_type=skill.agent_type,
|
|
170
|
+
cwd=tool_context.cwd,
|
|
171
|
+
parent_provider_manager=self._provider_manager,
|
|
172
|
+
parent_tool_registry=self._tool_registry,
|
|
173
|
+
parent_system_prompt=self._system_prompt,
|
|
174
|
+
)
|
|
175
|
+
if safe_name is not None and started is not None:
|
|
176
|
+
log_event(
|
|
177
|
+
Events.SKILL_COMPLETED,
|
|
178
|
+
{
|
|
179
|
+
"skill_name": safe_name,
|
|
180
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
181
|
+
"outcome": "success",
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
return ToolResult.success(
|
|
185
|
+
f"{result_text}\n\n"
|
|
186
|
+
f"[Skill '{skill.name}' completed: "
|
|
187
|
+
f"{progress.tool_use_count} tool calls, "
|
|
188
|
+
f"{progress.token_count} tokens]"
|
|
189
|
+
)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
if safe_name is not None and started is not None:
|
|
192
|
+
log_event(
|
|
193
|
+
Events.SKILL_COMPLETED,
|
|
194
|
+
{
|
|
195
|
+
"skill_name": safe_name,
|
|
196
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
197
|
+
"outcome": "error",
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
logger.exception("Skill forked execution failed: %s", command.name)
|
|
201
|
+
return ToolResult.error(f"Skill forked execution failed: {e}")
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _normalize_name(name: str) -> str:
|
|
205
|
+
"""Normalize skill name: strip leading /, lowercase."""
|
|
206
|
+
return name.lstrip("/").strip().lower()
|
|
207
|
+
|
|
208
|
+
# --- UI rendering ---
|
|
209
|
+
|
|
210
|
+
def render_tool_use_message(self, input: dict, *, verbose: bool = False) -> str | None:
|
|
211
|
+
return input.get("skill", "") or None
|
|
212
|
+
|
|
213
|
+
def user_facing_name(self, input: dict | None = None) -> str:
|
|
214
|
+
return _("Skill")
|
|
215
|
+
|
|
216
|
+
def is_read_only(self, input: dict | None = None) -> bool:
|
|
217
|
+
return True # Skill itself is just prompt injection
|
|
218
|
+
|
|
219
|
+
def is_concurrency_safe(self, tool_input: dict[str, Any]) -> bool:
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
# --- Permission check ---
|
|
223
|
+
|
|
224
|
+
async def check_permissions(self, input: dict, context: dict | None = None) -> Any:
|
|
225
|
+
"""Skill permission check:
|
|
226
|
+
|
|
227
|
+
1. Bundled skill -> auto-allow
|
|
228
|
+
2. Skill with only safe properties -> auto-allow
|
|
229
|
+
3. Others -> ask user
|
|
230
|
+
"""
|
|
231
|
+
from iac_code.commands.registry import PromptCommand
|
|
232
|
+
from iac_code.types.permissions import PermissionResult
|
|
233
|
+
|
|
234
|
+
skill_name = self._normalize_name(input.get("skill", ""))
|
|
235
|
+
command = self._command_registry.get(skill_name)
|
|
236
|
+
if not isinstance(command, PromptCommand):
|
|
237
|
+
return PermissionResult(behavior="deny", message=f"Skill not found: {skill_name}")
|
|
238
|
+
skill = command.skill
|
|
239
|
+
|
|
240
|
+
# Bundled skills are fully trusted
|
|
241
|
+
if command.source == SkillSource.BUNDLED:
|
|
242
|
+
return PermissionResult(behavior="allow")
|
|
243
|
+
|
|
244
|
+
# Safe-only skills auto-allow
|
|
245
|
+
if self._has_only_safe_properties(skill):
|
|
246
|
+
return PermissionResult(behavior="allow")
|
|
247
|
+
|
|
248
|
+
# Others ask user
|
|
249
|
+
return PermissionResult(
|
|
250
|
+
behavior="ask",
|
|
251
|
+
message=f"Allow skill '{skill_name}' (source: {skill.source if skill else 'unknown'})?",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _has_only_safe_properties(skill: Any) -> bool:
|
|
256
|
+
"""Check if a skill only has safe properties (no tools, no shell commands)."""
|
|
257
|
+
if skill.frontmatter.allowed_tools:
|
|
258
|
+
return False
|
|
259
|
+
if "!`" in skill.content or "```!" in skill.content:
|
|
260
|
+
return False
|
|
261
|
+
return True
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Application state management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from iac_code.types.permissions import PermissionDecision, PermissionMode
|
|
11
|
+
|
|
12
|
+
_PERMISSION_CACHE_MAX_SIZE = 128
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def lookup_permission(
|
|
16
|
+
cache: OrderedDict[str, PermissionDecision] | None,
|
|
17
|
+
tool_name: str,
|
|
18
|
+
) -> PermissionDecision | None:
|
|
19
|
+
"""Look up a sticky permission decision and mark it as most-recent.
|
|
20
|
+
|
|
21
|
+
Returns None when the cache is missing or the tool has no recorded
|
|
22
|
+
decision. When found, the entry is moved to the end so LRU eviction
|
|
23
|
+
preserves recency-of-use.
|
|
24
|
+
"""
|
|
25
|
+
if cache is None:
|
|
26
|
+
return None
|
|
27
|
+
decision = cache.get(tool_name)
|
|
28
|
+
if decision is None:
|
|
29
|
+
return None
|
|
30
|
+
cache.move_to_end(tool_name)
|
|
31
|
+
return decision
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def record_permission(
|
|
35
|
+
cache: OrderedDict[str, PermissionDecision] | None,
|
|
36
|
+
tool_name: str,
|
|
37
|
+
decision: PermissionDecision,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Record a sticky permission decision, enforcing the LRU size cap."""
|
|
40
|
+
if cache is None:
|
|
41
|
+
return
|
|
42
|
+
cache[tool_name] = decision
|
|
43
|
+
cache.move_to_end(tool_name)
|
|
44
|
+
_evict_lru(cache, _PERMISSION_CACHE_MAX_SIZE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _evict_lru(
|
|
48
|
+
cache: OrderedDict[str, PermissionDecision],
|
|
49
|
+
max_size: int,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Drop oldest entries until ``cache`` fits within ``max_size``."""
|
|
52
|
+
while len(cache) > max_size:
|
|
53
|
+
cache.popitem(last=False)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class AppState:
|
|
58
|
+
"""Global application state."""
|
|
59
|
+
|
|
60
|
+
model: str = ""
|
|
61
|
+
cwd: str = field(default_factory=os.getcwd)
|
|
62
|
+
permission_mode: PermissionMode = PermissionMode.DEFAULT
|
|
63
|
+
messages: list = field(default_factory=list) # list[Message]
|
|
64
|
+
is_busy: bool = False
|
|
65
|
+
always_allow_rules: OrderedDict[str, PermissionDecision] = field(default_factory=OrderedDict)
|
|
66
|
+
spinner_text: str = ""
|
|
67
|
+
context_usage_percent: float = 0.0
|
|
68
|
+
effort_level: Any | None = None # EffortLevel enum or None (avoid circular import)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AppStateStore:
|
|
72
|
+
"""State store.
|
|
73
|
+
|
|
74
|
+
Provides get/set/subscribe interfaces, UI components listen to state changes via subscribe.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, initial_state: AppState | None = None) -> None:
|
|
78
|
+
self._state = initial_state or AppState()
|
|
79
|
+
self._listeners: list[Callable[[AppState], None]] = []
|
|
80
|
+
|
|
81
|
+
def get_state(self) -> AppState:
|
|
82
|
+
"""Get current state"""
|
|
83
|
+
return self._state
|
|
84
|
+
|
|
85
|
+
def set_state(self, updater: Callable[[AppState], AppState] | None = None, **kwargs) -> None:
|
|
86
|
+
"""Update state
|
|
87
|
+
|
|
88
|
+
Two usage patterns:
|
|
89
|
+
1. store.set_state(lambda s: dataclasses.replace(s, is_busy=True))
|
|
90
|
+
2. store.set_state(is_busy=True) # Shortcut
|
|
91
|
+
"""
|
|
92
|
+
import dataclasses
|
|
93
|
+
|
|
94
|
+
if updater is not None:
|
|
95
|
+
self._state = updater(self._state)
|
|
96
|
+
elif kwargs:
|
|
97
|
+
self._state = dataclasses.replace(self._state, **kwargs)
|
|
98
|
+
self._notify()
|
|
99
|
+
|
|
100
|
+
def subscribe(self, listener: Callable[[AppState], None]) -> Callable[[], None]:
|
|
101
|
+
"""Subscribe to state changes, return unsubscribe function"""
|
|
102
|
+
self._listeners.append(listener)
|
|
103
|
+
|
|
104
|
+
def unsubscribe():
|
|
105
|
+
if listener in self._listeners:
|
|
106
|
+
self._listeners.remove(listener)
|
|
107
|
+
|
|
108
|
+
return unsubscribe
|
|
109
|
+
|
|
110
|
+
def _notify(self) -> None:
|
|
111
|
+
"""Notify all listeners"""
|
|
112
|
+
for listener in self._listeners:
|
|
113
|
+
listener(self._state)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
"AppState",
|
|
118
|
+
"AppStateStore",
|
|
119
|
+
"_PERMISSION_CACHE_MAX_SIZE",
|
|
120
|
+
"lookup_permission",
|
|
121
|
+
"record_permission",
|
|
122
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Task management package.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Notification queue for background agent completion events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class TaskNotification:
|
|
11
|
+
task_id: str
|
|
12
|
+
message: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NotificationQueue:
|
|
16
|
+
def __init__(self, max_pending: int = 100):
|
|
17
|
+
self._queue: deque[TaskNotification] = deque(maxlen=max_pending)
|
|
18
|
+
|
|
19
|
+
def enqueue(self, task_id: str, message: str) -> None:
|
|
20
|
+
self._queue.append(TaskNotification(task_id=task_id, message=message))
|
|
21
|
+
|
|
22
|
+
def dequeue(self) -> TaskNotification | None:
|
|
23
|
+
if self._queue:
|
|
24
|
+
return self._queue.popleft()
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
def has_pending(self) -> bool:
|
|
28
|
+
return len(self._queue) > 0
|