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
|
+
"""ReadFile tool - reads file contents with optional line range."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from iac_code.i18n import _
|
|
9
|
+
from iac_code.tools.base import Tool, ToolContext, ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ReadFileTool(Tool):
|
|
13
|
+
@property
|
|
14
|
+
def name(self) -> str:
|
|
15
|
+
return "read_file"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def description(self) -> str:
|
|
19
|
+
return (
|
|
20
|
+
"Read the contents of a file. You can optionally specify a line range "
|
|
21
|
+
"to read only a portion of the file. Use start_line and end_line for "
|
|
22
|
+
"large files to read specific sections."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def input_schema(self) -> dict[str, Any]:
|
|
27
|
+
return {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"path": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "The path to the file to read. Can be absolute or relative to working directory.",
|
|
33
|
+
},
|
|
34
|
+
"start_line": {
|
|
35
|
+
"type": "integer",
|
|
36
|
+
"description": "The starting line number to read from (1-based, inclusive). Optional.",
|
|
37
|
+
},
|
|
38
|
+
"end_line": {
|
|
39
|
+
"type": "integer",
|
|
40
|
+
"description": "The ending line number to read to (1-based, inclusive). Optional.",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
"required": ["path"],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def normalize_input(self, tool_input: dict[str, Any]) -> None:
|
|
47
|
+
if "file_path" in tool_input and "path" not in tool_input:
|
|
48
|
+
tool_input["path"] = tool_input.pop("file_path")
|
|
49
|
+
|
|
50
|
+
async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
|
|
51
|
+
path = tool_input["path"]
|
|
52
|
+
start_line = tool_input.get("start_line")
|
|
53
|
+
end_line = tool_input.get("end_line")
|
|
54
|
+
|
|
55
|
+
# Resolve relative paths
|
|
56
|
+
if not os.path.isabs(path):
|
|
57
|
+
path = os.path.join(context.cwd, path)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
with open(path, encoding="utf-8") as f:
|
|
61
|
+
lines = f.readlines()
|
|
62
|
+
except FileNotFoundError:
|
|
63
|
+
return ToolResult.error(f"File not found: {path}")
|
|
64
|
+
except PermissionError:
|
|
65
|
+
return ToolResult.error(f"Permission denied: {path}")
|
|
66
|
+
except UnicodeDecodeError:
|
|
67
|
+
return ToolResult.error(f"Cannot read binary file: {path}")
|
|
68
|
+
except Exception as e:
|
|
69
|
+
return ToolResult.error(f"Error reading file: {e}")
|
|
70
|
+
|
|
71
|
+
total_lines = len(lines)
|
|
72
|
+
|
|
73
|
+
# Apply line range if specified
|
|
74
|
+
if start_line is not None or end_line is not None:
|
|
75
|
+
start = (start_line or 1) - 1 # Convert to 0-based
|
|
76
|
+
end = end_line or total_lines
|
|
77
|
+
start = max(0, start)
|
|
78
|
+
end = min(total_lines, end)
|
|
79
|
+
selected = lines[start:end]
|
|
80
|
+
# Add line numbers
|
|
81
|
+
numbered = [f"{i + start + 1:>6}\t{line}" for i, line in enumerate(selected)]
|
|
82
|
+
content = "".join(numbered)
|
|
83
|
+
return ToolResult.success(f"File: {path} (lines {start + 1}-{end} of {total_lines})\n\n{content}")
|
|
84
|
+
|
|
85
|
+
# Return full file
|
|
86
|
+
if total_lines > 0:
|
|
87
|
+
numbered = [f"{i + 1:>6}\t{line}" for i, line in enumerate(lines)]
|
|
88
|
+
content = "".join(numbered)
|
|
89
|
+
else:
|
|
90
|
+
content = "(empty file)"
|
|
91
|
+
|
|
92
|
+
return ToolResult.success(f"File: {path} ({total_lines} lines)\n\n{content}")
|
|
93
|
+
|
|
94
|
+
# UI rendering methods
|
|
95
|
+
def render_tool_use_message(self, input: dict, *, verbose: bool = False):
|
|
96
|
+
path = input.get("path", "")
|
|
97
|
+
if not path:
|
|
98
|
+
return None
|
|
99
|
+
display_path = path if verbose else os.path.basename(path)
|
|
100
|
+
parts = [display_path]
|
|
101
|
+
if input.get("start_line") and input.get("end_line"):
|
|
102
|
+
parts.append(f"lines {input['start_line']}-{input['end_line']}")
|
|
103
|
+
elif input.get("start_line"):
|
|
104
|
+
parts.append(f"from line {input['start_line']}")
|
|
105
|
+
return " · ".join(parts)
|
|
106
|
+
|
|
107
|
+
def render_tool_result_message(self, output: str, *, is_error: bool = False, verbose: bool = False):
|
|
108
|
+
if is_error:
|
|
109
|
+
return output
|
|
110
|
+
lines = output.splitlines()
|
|
111
|
+
total = len(lines) - 2 # subtract header + blank line
|
|
112
|
+
if total < 0:
|
|
113
|
+
total = 0
|
|
114
|
+
if verbose:
|
|
115
|
+
return output.strip()
|
|
116
|
+
return _("Read {total} lines").format(total=total)
|
|
117
|
+
|
|
118
|
+
def user_facing_name(self, input: dict | None = None) -> str:
|
|
119
|
+
return _("Read")
|
|
120
|
+
|
|
121
|
+
def get_activity_description(self, input: dict | None = None) -> str:
|
|
122
|
+
if input:
|
|
123
|
+
return _("Reading {path}").format(path=input.get("path", ""))
|
|
124
|
+
return _("Reading file...")
|
|
125
|
+
|
|
126
|
+
def is_read_only(self, input: dict | None = None) -> bool:
|
|
127
|
+
return True
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Externalize large tool results to disk to preserve context window."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
DEFAULT_MAX_INLINE_CHARS = 50_000
|
|
9
|
+
DEFAULT_PREVIEW_CHARS = 2_000
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ProcessedResult:
|
|
14
|
+
content: str
|
|
15
|
+
is_externalized: bool = False
|
|
16
|
+
file_path: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ResultStorage:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
storage_dir: str,
|
|
23
|
+
max_inline_chars: int = DEFAULT_MAX_INLINE_CHARS,
|
|
24
|
+
preview_chars: int = DEFAULT_PREVIEW_CHARS,
|
|
25
|
+
):
|
|
26
|
+
self._storage_dir = storage_dir
|
|
27
|
+
self._max_inline_chars = max_inline_chars
|
|
28
|
+
self._preview_chars = preview_chars
|
|
29
|
+
|
|
30
|
+
def process(self, tool_use_id: str, content: str) -> ProcessedResult:
|
|
31
|
+
if len(content) <= self._max_inline_chars:
|
|
32
|
+
return ProcessedResult(content=content)
|
|
33
|
+
os.makedirs(self._storage_dir, exist_ok=True)
|
|
34
|
+
file_path = os.path.join(self._storage_dir, f"{tool_use_id}.txt")
|
|
35
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
36
|
+
f.write(content)
|
|
37
|
+
preview = content[: self._preview_chars]
|
|
38
|
+
preview += f"\n\n... [truncated — full output ({len(content)} chars) saved to {file_path}]"
|
|
39
|
+
return ProcessedResult(content=preview, is_externalized=True, file_path=file_path)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Concurrent tool execution engine with read/write partitioning and input validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, Protocol, cast
|
|
9
|
+
|
|
10
|
+
from iac_code.services.telemetry import add_metric, log_event, start_span
|
|
11
|
+
from iac_code.services.telemetry.config import should_capture_content_on_span
|
|
12
|
+
from iac_code.services.telemetry.content_serializer import serialize_tool_arguments, serialize_tool_result
|
|
13
|
+
from iac_code.services.telemetry.names import Events, GenAiAttr, GenAiOperationName, GenAiSpanKind, Metrics, Spans
|
|
14
|
+
from iac_code.services.telemetry.sanitize import sanitize_error_message, sanitize_tool_name
|
|
15
|
+
from iac_code.tools.base import ToolContext, ToolResult
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from iac_code.tools.base import ToolRegistry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _ToolWithEventQueue(Protocol):
|
|
22
|
+
_event_queue: asyncio.Queue | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ToolCallRequest:
|
|
27
|
+
id: str
|
|
28
|
+
name: str
|
|
29
|
+
input: dict
|
|
30
|
+
event_queue: asyncio.Queue | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ToolExecutor:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
registry: "ToolRegistry",
|
|
37
|
+
max_concurrency: int = 10,
|
|
38
|
+
tool_timeout: float = 120.0,
|
|
39
|
+
):
|
|
40
|
+
self._registry = registry
|
|
41
|
+
self._max_concurrency = max_concurrency
|
|
42
|
+
self._tool_timeout = tool_timeout
|
|
43
|
+
|
|
44
|
+
def partition(self, calls: list[ToolCallRequest]) -> tuple[list[ToolCallRequest], list[ToolCallRequest]]:
|
|
45
|
+
"""Partition calls into concurrent (read-only) and serial (write) batches."""
|
|
46
|
+
concurrent, serial = [], []
|
|
47
|
+
for call in calls:
|
|
48
|
+
tool = self._registry.get(call.name)
|
|
49
|
+
if tool and tool.is_concurrency_safe(call.input):
|
|
50
|
+
concurrent.append(call)
|
|
51
|
+
else:
|
|
52
|
+
serial.append(call)
|
|
53
|
+
return concurrent, serial
|
|
54
|
+
|
|
55
|
+
async def _validate_and_execute(self, call: ToolCallRequest, context: ToolContext) -> ToolResult:
|
|
56
|
+
"""Validate input then execute. Returns error ToolResult on validation failure."""
|
|
57
|
+
tool = self._registry.get(call.name)
|
|
58
|
+
if not tool:
|
|
59
|
+
return ToolResult.error(f"Unknown tool: {call.name}")
|
|
60
|
+
|
|
61
|
+
# Input validation
|
|
62
|
+
valid, error = tool.validate_input(call.input)
|
|
63
|
+
if not valid:
|
|
64
|
+
return ToolResult.error(
|
|
65
|
+
f"Invalid input for tool '{call.name}': {error}. "
|
|
66
|
+
f"Please provide all required parameters as defined in the tool schema."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Pass event_queue to tool if provided (for sub-agent event forwarding)
|
|
70
|
+
if call.event_queue is not None and hasattr(tool, "_event_queue"):
|
|
71
|
+
cast(_ToolWithEventQueue, tool)._event_queue = call.event_queue
|
|
72
|
+
|
|
73
|
+
# Pass event_queue from call to context for tools that emit progress events
|
|
74
|
+
if call.event_queue is not None:
|
|
75
|
+
context = ToolContext(cwd=context.cwd, event_queue=call.event_queue)
|
|
76
|
+
|
|
77
|
+
timeout = tool.timeout if tool.timeout is not None else self._tool_timeout
|
|
78
|
+
|
|
79
|
+
# Telemetry instrumentation
|
|
80
|
+
tool_name = sanitize_tool_name(call.name)
|
|
81
|
+
started = time.monotonic()
|
|
82
|
+
|
|
83
|
+
span_name = f"{Spans.TOOL_EXECUTE} {tool_name}"
|
|
84
|
+
span_attrs: dict = {
|
|
85
|
+
GenAiAttr.SPAN_KIND: GenAiSpanKind.TOOL,
|
|
86
|
+
GenAiAttr.OPERATION_NAME: GenAiOperationName.EXECUTE_TOOL,
|
|
87
|
+
GenAiAttr.TOOL_NAME: tool_name,
|
|
88
|
+
GenAiAttr.TOOL_TYPE: "function",
|
|
89
|
+
GenAiAttr.TOOL_CALL_ID: call.id,
|
|
90
|
+
}
|
|
91
|
+
if tool.description:
|
|
92
|
+
span_attrs[GenAiAttr.TOOL_DESCRIPTION] = tool.description
|
|
93
|
+
if should_capture_content_on_span():
|
|
94
|
+
span_attrs[GenAiAttr.TOOL_CALL_ARGUMENTS] = serialize_tool_arguments(call.input)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
with start_span(span_name, span_attrs) as span:
|
|
98
|
+
result = await asyncio.wait_for(
|
|
99
|
+
tool.execute(tool_input=call.input, context=context),
|
|
100
|
+
timeout=timeout,
|
|
101
|
+
)
|
|
102
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
103
|
+
if should_capture_content_on_span():
|
|
104
|
+
span.set_attribute(GenAiAttr.TOOL_CALL_RESULT, serialize_tool_result(result))
|
|
105
|
+
log_event(Events.TOOL_USE_SUCCEEDED, {"tool_name": tool_name, "duration_ms": duration_ms})
|
|
106
|
+
add_metric(Metrics.TOOL_USE_COUNT, 1, {"tool_name": tool_name, "outcome": "success"})
|
|
107
|
+
return result
|
|
108
|
+
except asyncio.TimeoutError:
|
|
109
|
+
log_event(
|
|
110
|
+
Events.TOOL_USE_FAILED,
|
|
111
|
+
{
|
|
112
|
+
"tool_name": tool_name,
|
|
113
|
+
"error_type": "TimeoutError",
|
|
114
|
+
"error_message": sanitize_error_message(f"Timeout after {timeout}s"),
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
add_metric(Metrics.TOOL_USE_COUNT, 1, {"tool_name": tool_name, "outcome": "error"})
|
|
118
|
+
return ToolResult.error(f"Tool '{call.name}' timed out after {timeout}s")
|
|
119
|
+
except Exception as e:
|
|
120
|
+
log_event(
|
|
121
|
+
Events.TOOL_USE_FAILED,
|
|
122
|
+
{
|
|
123
|
+
"tool_name": tool_name,
|
|
124
|
+
"error_type": type(e).__name__,
|
|
125
|
+
"error_message": sanitize_error_message(str(e)),
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
add_metric(Metrics.TOOL_USE_COUNT, 1, {"tool_name": tool_name, "outcome": "error"})
|
|
129
|
+
return ToolResult.error(f"Tool '{call.name}' failed: {e}")
|
|
130
|
+
|
|
131
|
+
async def _execute_concurrent(
|
|
132
|
+
self, calls: list[ToolCallRequest], context: ToolContext
|
|
133
|
+
) -> list[tuple[str, ToolResult]]:
|
|
134
|
+
if not calls:
|
|
135
|
+
return []
|
|
136
|
+
sem = asyncio.Semaphore(self._max_concurrency)
|
|
137
|
+
|
|
138
|
+
async def run(call: ToolCallRequest) -> tuple[str, ToolResult]:
|
|
139
|
+
async with sem:
|
|
140
|
+
result = await self._validate_and_execute(call, context)
|
|
141
|
+
return call.id, result
|
|
142
|
+
|
|
143
|
+
tasks = [asyncio.create_task(run(c)) for c in calls]
|
|
144
|
+
return list(await asyncio.gather(*tasks))
|
|
145
|
+
|
|
146
|
+
async def _execute_serial(self, calls: list[ToolCallRequest], context: ToolContext) -> list[tuple[str, ToolResult]]:
|
|
147
|
+
results = []
|
|
148
|
+
for call in calls:
|
|
149
|
+
result = await self._validate_and_execute(call, context)
|
|
150
|
+
results.append((call.id, result))
|
|
151
|
+
return results
|
|
152
|
+
|
|
153
|
+
async def execute_batch(self, calls: list[ToolCallRequest], context: ToolContext) -> list[ToolResult]:
|
|
154
|
+
"""Execute tool calls with read/write partitioning.
|
|
155
|
+
|
|
156
|
+
1. Partition into concurrent (read-only) and serial (write) batches
|
|
157
|
+
2. Execute concurrent batch in parallel (up to max_concurrency)
|
|
158
|
+
3. Execute serial batch sequentially
|
|
159
|
+
4. Return results in original request order
|
|
160
|
+
"""
|
|
161
|
+
concurrent, serial = self.partition(calls)
|
|
162
|
+
concurrent_results = await self._execute_concurrent(concurrent, context)
|
|
163
|
+
serial_results = await self._execute_serial(serial, context)
|
|
164
|
+
result_map = {call_id: result for call_id, result in concurrent_results + serial_results}
|
|
165
|
+
return [result_map[call.id] for call in calls]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""WebFetchTool - fetches web page content."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html as html_lib
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from iac_code.i18n import _
|
|
13
|
+
from iac_code.tools.base import Tool, ToolContext, ToolResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _extract_text_from_html(html: str) -> str:
|
|
17
|
+
"""Extract plain text from HTML by removing tags and decoding entities.
|
|
18
|
+
|
|
19
|
+
Steps:
|
|
20
|
+
1. Remove <script>...</script> blocks (including content).
|
|
21
|
+
2. Remove <style>...</style> blocks (including content).
|
|
22
|
+
3. Strip all remaining HTML tags.
|
|
23
|
+
4. Decode HTML entities (e.g. & -> &).
|
|
24
|
+
5. Collapse whitespace runs to a single space.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
html: Raw HTML string.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Plain text extracted from the HTML.
|
|
31
|
+
"""
|
|
32
|
+
if not html:
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
# Remove script tags and their content
|
|
36
|
+
text = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
|
37
|
+
|
|
38
|
+
# Remove style tags and their content
|
|
39
|
+
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
40
|
+
|
|
41
|
+
# Strip all remaining HTML tags
|
|
42
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
43
|
+
|
|
44
|
+
# Decode HTML entities
|
|
45
|
+
text = html_lib.unescape(text)
|
|
46
|
+
|
|
47
|
+
# Collapse whitespace (spaces, tabs, newlines) to single space
|
|
48
|
+
text = re.sub(r"[ \t]+", " ", text)
|
|
49
|
+
|
|
50
|
+
# Collapse multiple blank lines to a single newline
|
|
51
|
+
text = re.sub(r"\n\s*\n+", "\n", text)
|
|
52
|
+
|
|
53
|
+
return text.strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WebFetchTool(Tool):
|
|
57
|
+
"""Tool for fetching web page content via HTTP/HTTPS."""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def name(self) -> str:
|
|
61
|
+
return "web_fetch"
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def description(self) -> str:
|
|
65
|
+
return (
|
|
66
|
+
"Fetch the content of a web page. Supports HTTP and HTTPS URLs. "
|
|
67
|
+
"For HTML pages, the content is extracted as plain text (scripts and styles removed). "
|
|
68
|
+
"Returns the page content truncated to max_length characters."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def input_schema(self) -> dict[str, Any]:
|
|
73
|
+
return {
|
|
74
|
+
"type": "object",
|
|
75
|
+
"properties": {
|
|
76
|
+
"url": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"description": "The URL of the web page to fetch. Must include scheme (http:// or https://).",
|
|
79
|
+
},
|
|
80
|
+
"max_length": {
|
|
81
|
+
"type": "integer",
|
|
82
|
+
"description": "Maximum number of characters to return. Defaults to 50000.",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
"required": ["url"],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
|
|
89
|
+
url: str = tool_input.get("url", "")
|
|
90
|
+
max_length: int = int(tool_input.get("max_length", 50000))
|
|
91
|
+
|
|
92
|
+
# Validate URL is not empty
|
|
93
|
+
if not url or not url.strip():
|
|
94
|
+
return ToolResult.error(_("URL cannot be empty."))
|
|
95
|
+
|
|
96
|
+
# Validate URL has scheme and netloc
|
|
97
|
+
parsed = urlparse(url)
|
|
98
|
+
if not parsed.scheme:
|
|
99
|
+
return ToolResult.error(
|
|
100
|
+
_("Invalid URL: missing scheme (e.g. http:// or https://). Got: {url}").format(url=url)
|
|
101
|
+
)
|
|
102
|
+
if not parsed.netloc:
|
|
103
|
+
return ToolResult.error(_("Invalid URL: missing host/netloc. Got: {url}").format(url=url))
|
|
104
|
+
|
|
105
|
+
headers = {"User-Agent": ("Mozilla/5.0 (compatible; iac-code/1.0; +https://github.com/ros-group/iac-code)")}
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
async with httpx.AsyncClient(
|
|
109
|
+
timeout=30,
|
|
110
|
+
follow_redirects=True,
|
|
111
|
+
headers=headers,
|
|
112
|
+
) as client:
|
|
113
|
+
response = await client.get(url)
|
|
114
|
+
response.raise_for_status()
|
|
115
|
+
|
|
116
|
+
content_type = response.headers.get("content-type", "")
|
|
117
|
+
text = response.text
|
|
118
|
+
|
|
119
|
+
if "text/html" in content_type:
|
|
120
|
+
text = _extract_text_from_html(text)
|
|
121
|
+
|
|
122
|
+
# Truncate to max_length
|
|
123
|
+
if len(text) > max_length:
|
|
124
|
+
text = text[:max_length]
|
|
125
|
+
|
|
126
|
+
return ToolResult.success(text)
|
|
127
|
+
|
|
128
|
+
except httpx.HTTPStatusError as e:
|
|
129
|
+
return ToolResult.error(_("HTTP error {status}: {url}").format(status=e.response.status_code, url=url))
|
|
130
|
+
except httpx.HTTPError as e:
|
|
131
|
+
return ToolResult.error(_("Failed to fetch {url}: {error}").format(url=url, error=str(e)))
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return ToolResult.error(_("Unexpected error fetching {url}: {error}").format(url=url, error=str(e)))
|
|
134
|
+
|
|
135
|
+
# UI rendering methods
|
|
136
|
+
def render_tool_use_message(self, input: dict, *, verbose: bool = False):
|
|
137
|
+
url = input.get("url", "")
|
|
138
|
+
if not url:
|
|
139
|
+
return None
|
|
140
|
+
return url
|
|
141
|
+
|
|
142
|
+
def render_tool_result_message(self, output: str, *, is_error: bool = False, verbose: bool = False):
|
|
143
|
+
if is_error:
|
|
144
|
+
return output
|
|
145
|
+
lines = output.strip().splitlines()
|
|
146
|
+
char_count = len(output.strip())
|
|
147
|
+
summary = _("Fetched {chars} chars, {lines} lines").format(chars=char_count, lines=len(lines))
|
|
148
|
+
if verbose:
|
|
149
|
+
preview = "\n".join(lines[:50])
|
|
150
|
+
if len(lines) > 50:
|
|
151
|
+
preview += f"\n... ({len(lines) - 50} more lines)"
|
|
152
|
+
return f"{summary}\n{preview}"
|
|
153
|
+
return summary
|
|
154
|
+
|
|
155
|
+
def render_tool_use_error_message(self, error: str):
|
|
156
|
+
return error
|
|
157
|
+
|
|
158
|
+
def user_facing_name(self, input: dict | None = None) -> str:
|
|
159
|
+
return _("Fetch")
|
|
160
|
+
|
|
161
|
+
def get_activity_description(self, input: dict | None = None) -> str:
|
|
162
|
+
if input:
|
|
163
|
+
url = input.get("url", "")
|
|
164
|
+
short_url = url[:60] + "..." if len(url) > 60 else url
|
|
165
|
+
return _("Fetching {url}").format(url=short_url)
|
|
166
|
+
return _("Fetching web page...")
|
|
167
|
+
|
|
168
|
+
def get_tool_use_summary(self, input: dict | None = None) -> str | None:
|
|
169
|
+
if input:
|
|
170
|
+
return input.get("url", "")[:80]
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def is_read_only(self, input: dict | None = None) -> bool:
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
def is_destructive(self, input: dict | None = None) -> bool:
|
|
177
|
+
return False
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""WriteFile tool - creates or overwrites files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from iac_code.i18n import _
|
|
9
|
+
from iac_code.tools.base import Tool, ToolContext, ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WriteFileTool(Tool):
|
|
13
|
+
@property
|
|
14
|
+
def name(self) -> str:
|
|
15
|
+
return "write_file"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def description(self) -> str:
|
|
19
|
+
return (
|
|
20
|
+
"Write content to a file. Creates the file if it doesn't exist, or "
|
|
21
|
+
"overwrites it if it does. Creates parent directories as needed. "
|
|
22
|
+
"Use EditFile for making targeted changes to existing files."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def input_schema(self) -> dict[str, Any]:
|
|
27
|
+
return {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"path": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "The path to write the file to.",
|
|
33
|
+
},
|
|
34
|
+
"content": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": "The content to write to the file.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
"required": ["path", "content"],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def normalize_input(self, tool_input: dict[str, Any]) -> None:
|
|
43
|
+
if "file_path" in tool_input and "path" not in tool_input:
|
|
44
|
+
tool_input["path"] = tool_input.pop("file_path")
|
|
45
|
+
|
|
46
|
+
async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
|
|
47
|
+
path = tool_input["path"]
|
|
48
|
+
content = tool_input["content"]
|
|
49
|
+
|
|
50
|
+
if not os.path.isabs(path):
|
|
51
|
+
path = os.path.join(context.cwd, path)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
55
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
56
|
+
f.write(content)
|
|
57
|
+
except PermissionError:
|
|
58
|
+
return ToolResult.error(f"Permission denied: {path}")
|
|
59
|
+
except Exception as e:
|
|
60
|
+
return ToolResult.error(f"Error writing file: {e}")
|
|
61
|
+
|
|
62
|
+
lines = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
|
|
63
|
+
return ToolResult.success(_("Successfully wrote {lines} lines to {path}").format(lines=lines, path=path))
|
|
64
|
+
|
|
65
|
+
# UI rendering methods
|
|
66
|
+
def render_tool_use_message(self, input: dict, *, verbose: bool = False):
|
|
67
|
+
path = input.get("path", "")
|
|
68
|
+
if not path:
|
|
69
|
+
return None
|
|
70
|
+
return path
|
|
71
|
+
|
|
72
|
+
def render_tool_result_message(self, output: str, *, is_error: bool = False, verbose: bool = False):
|
|
73
|
+
# Write results are already short, show same in both modes
|
|
74
|
+
return output
|
|
75
|
+
|
|
76
|
+
def user_facing_name(self, input: dict | None = None) -> str:
|
|
77
|
+
return _("Write")
|
|
78
|
+
|
|
79
|
+
def get_activity_description(self, input: dict | None = None) -> str:
|
|
80
|
+
if input:
|
|
81
|
+
return _("Writing {path}").format(path=input.get("path", ""))
|
|
82
|
+
return _("Writing file...")
|
|
83
|
+
|
|
84
|
+
def is_read_only(self, input: dict | None = None) -> bool:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def is_destructive(self, input: dict | None = None) -> bool:
|
|
88
|
+
return True
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Type definitions module"""
|
|
2
|
+
|
|
3
|
+
from iac_code.types.permissions import PermissionMode, PermissionResult
|
|
4
|
+
from iac_code.types.stream_events import (
|
|
5
|
+
CompactionEvent,
|
|
6
|
+
ErrorEvent,
|
|
7
|
+
MessageEndEvent,
|
|
8
|
+
MessageStartEvent,
|
|
9
|
+
PermissionRequestEvent,
|
|
10
|
+
StreamEvent,
|
|
11
|
+
TaskNotificationEvent,
|
|
12
|
+
TextDeltaEvent,
|
|
13
|
+
ThinkingDeltaEvent,
|
|
14
|
+
TombstoneEvent,
|
|
15
|
+
ToolInputDeltaEvent,
|
|
16
|
+
ToolResultEvent,
|
|
17
|
+
ToolUseEndEvent,
|
|
18
|
+
ToolUseStartEvent,
|
|
19
|
+
Usage,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"CompactionEvent",
|
|
24
|
+
"ErrorEvent",
|
|
25
|
+
"MessageEndEvent",
|
|
26
|
+
"MessageStartEvent",
|
|
27
|
+
"PermissionMode",
|
|
28
|
+
"PermissionRequestEvent",
|
|
29
|
+
"PermissionResult",
|
|
30
|
+
"StreamEvent",
|
|
31
|
+
"TaskNotificationEvent",
|
|
32
|
+
"TextDeltaEvent",
|
|
33
|
+
"ThinkingDeltaEvent",
|
|
34
|
+
"TombstoneEvent",
|
|
35
|
+
"ToolInputDeltaEvent",
|
|
36
|
+
"ToolResultEvent",
|
|
37
|
+
"ToolUseEndEvent",
|
|
38
|
+
"ToolUseStartEvent",
|
|
39
|
+
"Usage",
|
|
40
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Permission types for the tool system."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PermissionMode(str, Enum):
|
|
11
|
+
"""Permission mode."""
|
|
12
|
+
|
|
13
|
+
DEFAULT = "default" # Write operations require user confirmation
|
|
14
|
+
PLAN = "plan" # Read-only operations auto-allowed
|
|
15
|
+
AUTO = "auto" # All operations auto-allowed
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class PermissionResult:
|
|
20
|
+
"""Permission check result."""
|
|
21
|
+
|
|
22
|
+
behavior: Literal["allow", "deny", "ask"]
|
|
23
|
+
message: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
PermissionDecision = Literal["always_allow", "always_deny"]
|