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.
Files changed (184) hide show
  1. iac_code/__init__.py +2 -0
  2. iac_code/acp/__init__.py +97 -0
  3. iac_code/acp/convert.py +423 -0
  4. iac_code/acp/http_sse.py +448 -0
  5. iac_code/acp/mcp.py +54 -0
  6. iac_code/acp/metrics.py +71 -0
  7. iac_code/acp/server.py +662 -0
  8. iac_code/acp/session.py +446 -0
  9. iac_code/acp/slash_registry.py +125 -0
  10. iac_code/acp/state.py +99 -0
  11. iac_code/acp/tools.py +112 -0
  12. iac_code/acp/types.py +13 -0
  13. iac_code/acp/version.py +26 -0
  14. iac_code/agent/__init__.py +19 -0
  15. iac_code/agent/agent_loop.py +640 -0
  16. iac_code/agent/agent_tool.py +269 -0
  17. iac_code/agent/agent_types.py +87 -0
  18. iac_code/agent/message.py +153 -0
  19. iac_code/agent/system_prompt.py +313 -0
  20. iac_code/cli/__init__.py +3 -0
  21. iac_code/cli/headless.py +114 -0
  22. iac_code/cli/main.py +246 -0
  23. iac_code/cli/output_formats.py +125 -0
  24. iac_code/commands/__init__.py +93 -0
  25. iac_code/commands/auth.py +1055 -0
  26. iac_code/commands/clear.py +34 -0
  27. iac_code/commands/compact.py +43 -0
  28. iac_code/commands/debug.py +45 -0
  29. iac_code/commands/effort.py +116 -0
  30. iac_code/commands/exit.py +10 -0
  31. iac_code/commands/help.py +49 -0
  32. iac_code/commands/model.py +130 -0
  33. iac_code/commands/registry.py +245 -0
  34. iac_code/commands/resume.py +49 -0
  35. iac_code/commands/tasks.py +41 -0
  36. iac_code/config.py +304 -0
  37. iac_code/i18n/__init__.py +141 -0
  38. iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
  39. iac_code/memory/__init__.py +1 -0
  40. iac_code/memory/memory_manager.py +92 -0
  41. iac_code/memory/memory_tools.py +88 -0
  42. iac_code/providers/__init__.py +1 -0
  43. iac_code/providers/anthropic_provider.py +284 -0
  44. iac_code/providers/base.py +128 -0
  45. iac_code/providers/dashscope_provider.py +47 -0
  46. iac_code/providers/deepseek_provider.py +36 -0
  47. iac_code/providers/manager.py +399 -0
  48. iac_code/providers/openai_provider.py +344 -0
  49. iac_code/providers/retry.py +58 -0
  50. iac_code/providers/stream_watchdog.py +47 -0
  51. iac_code/providers/thinking.py +164 -0
  52. iac_code/services/__init__.py +1 -0
  53. iac_code/services/agent_factory.py +127 -0
  54. iac_code/services/cloud_credentials.py +22 -0
  55. iac_code/services/context_manager.py +221 -0
  56. iac_code/services/providers/__init__.py +1 -0
  57. iac_code/services/providers/aliyun.py +232 -0
  58. iac_code/services/session_index.py +281 -0
  59. iac_code/services/session_storage.py +245 -0
  60. iac_code/services/telemetry/__init__.py +66 -0
  61. iac_code/services/telemetry/attributes.py +84 -0
  62. iac_code/services/telemetry/client.py +330 -0
  63. iac_code/services/telemetry/config.py +76 -0
  64. iac_code/services/telemetry/constants.py +75 -0
  65. iac_code/services/telemetry/content_serializer.py +124 -0
  66. iac_code/services/telemetry/events.py +42 -0
  67. iac_code/services/telemetry/fallback.py +59 -0
  68. iac_code/services/telemetry/identity.py +73 -0
  69. iac_code/services/telemetry/metrics.py +62 -0
  70. iac_code/services/telemetry/names.py +199 -0
  71. iac_code/services/telemetry/sanitize.py +88 -0
  72. iac_code/services/telemetry/sink.py +67 -0
  73. iac_code/services/telemetry/tracing.py +38 -0
  74. iac_code/services/telemetry/types.py +13 -0
  75. iac_code/services/token_budget.py +54 -0
  76. iac_code/services/token_counter.py +76 -0
  77. iac_code/skills/__init__.py +1 -0
  78. iac_code/skills/bundled/__init__.py +94 -0
  79. iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
  80. iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
  81. iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
  82. iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
  83. iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
  84. iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
  85. iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
  86. iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
  87. iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
  88. iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
  89. iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
  90. iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
  91. iac_code/skills/bundled/simplify.py +28 -0
  92. iac_code/skills/discovery.py +136 -0
  93. iac_code/skills/frontmatter.py +119 -0
  94. iac_code/skills/listing.py +92 -0
  95. iac_code/skills/loader.py +42 -0
  96. iac_code/skills/processor.py +81 -0
  97. iac_code/skills/renderer.py +157 -0
  98. iac_code/skills/skill_definition.py +82 -0
  99. iac_code/skills/skill_tool.py +261 -0
  100. iac_code/state/__init__.py +5 -0
  101. iac_code/state/app_state.py +122 -0
  102. iac_code/tasks/__init__.py +1 -0
  103. iac_code/tasks/notification_queue.py +28 -0
  104. iac_code/tasks/task_state.py +66 -0
  105. iac_code/tasks/task_tools.py +114 -0
  106. iac_code/tools/__init__.py +8 -0
  107. iac_code/tools/base.py +226 -0
  108. iac_code/tools/bash.py +133 -0
  109. iac_code/tools/cloud/__init__.py +0 -0
  110. iac_code/tools/cloud/aliyun/__init__.py +0 -0
  111. iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
  112. iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
  113. iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
  114. iac_code/tools/cloud/aliyun/ros_client.py +56 -0
  115. iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
  116. iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
  117. iac_code/tools/cloud/base_api.py +162 -0
  118. iac_code/tools/cloud/base_stack.py +242 -0
  119. iac_code/tools/cloud/registry.py +20 -0
  120. iac_code/tools/cloud/types.py +105 -0
  121. iac_code/tools/edit_file.py +121 -0
  122. iac_code/tools/glob.py +103 -0
  123. iac_code/tools/grep.py +254 -0
  124. iac_code/tools/list_files.py +104 -0
  125. iac_code/tools/read_file.py +127 -0
  126. iac_code/tools/result_storage.py +39 -0
  127. iac_code/tools/tool_executor.py +165 -0
  128. iac_code/tools/web_fetch.py +177 -0
  129. iac_code/tools/write_file.py +88 -0
  130. iac_code/types/__init__.py +40 -0
  131. iac_code/types/permissions.py +26 -0
  132. iac_code/types/skill_source.py +11 -0
  133. iac_code/types/stream_events.py +227 -0
  134. iac_code/ui/__init__.py +5 -0
  135. iac_code/ui/banner.py +110 -0
  136. iac_code/ui/components/__init__.py +0 -0
  137. iac_code/ui/components/dialog.py +142 -0
  138. iac_code/ui/components/divider.py +20 -0
  139. iac_code/ui/components/fuzzy_picker.py +308 -0
  140. iac_code/ui/components/progress_bar.py +54 -0
  141. iac_code/ui/components/search_box.py +165 -0
  142. iac_code/ui/components/select.py +319 -0
  143. iac_code/ui/components/status_icon.py +42 -0
  144. iac_code/ui/components/tabs.py +128 -0
  145. iac_code/ui/core/__init__.py +0 -0
  146. iac_code/ui/core/in_place_render.py +129 -0
  147. iac_code/ui/core/input_history.py +118 -0
  148. iac_code/ui/core/key_event.py +41 -0
  149. iac_code/ui/core/prompt_input.py +507 -0
  150. iac_code/ui/core/raw_input.py +302 -0
  151. iac_code/ui/core/screen.py +80 -0
  152. iac_code/ui/dialogs/__init__.py +0 -0
  153. iac_code/ui/dialogs/global_search.py +178 -0
  154. iac_code/ui/dialogs/history_search.py +100 -0
  155. iac_code/ui/dialogs/model_picker.py +280 -0
  156. iac_code/ui/dialogs/quick_open.py +108 -0
  157. iac_code/ui/dialogs/resume_picker.py +749 -0
  158. iac_code/ui/keybindings/__init__.py +0 -0
  159. iac_code/ui/keybindings/manager.py +124 -0
  160. iac_code/ui/renderer.py +1535 -0
  161. iac_code/ui/repl.py +772 -0
  162. iac_code/ui/spinner.py +112 -0
  163. iac_code/ui/suggestions/__init__.py +0 -0
  164. iac_code/ui/suggestions/aggregator.py +171 -0
  165. iac_code/ui/suggestions/command_provider.py +43 -0
  166. iac_code/ui/suggestions/directory_provider.py +95 -0
  167. iac_code/ui/suggestions/file_provider.py +121 -0
  168. iac_code/ui/suggestions/shell_history_provider.py +108 -0
  169. iac_code/ui/suggestions/token_extractor.py +77 -0
  170. iac_code/ui/suggestions/types.py +45 -0
  171. iac_code/ui/transcript_view.py +199 -0
  172. iac_code/utils/__init__.py +0 -0
  173. iac_code/utils/background_housekeeping.py +53 -0
  174. iac_code/utils/cleanup.py +68 -0
  175. iac_code/utils/json_utils.py +60 -0
  176. iac_code/utils/log.py +150 -0
  177. iac_code/utils/project_paths.py +74 -0
  178. iac_code/utils/tool_input_parser.py +62 -0
  179. iac_code-0.1.0.dist-info/LICENSE +201 -0
  180. iac_code-0.1.0.dist-info/METADATA +64 -0
  181. iac_code-0.1.0.dist-info/RECORD +184 -0
  182. iac_code-0.1.0.dist-info/WHEEL +5 -0
  183. iac_code-0.1.0.dist-info/entry_points.txt +2 -0
  184. 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. &amp; -> &).
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"]
@@ -0,0 +1,11 @@
1
+ """Skill source enum — shared by commands/ and skills/ to avoid circular imports."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class SkillSource(str, Enum):
7
+ """Where a skill was loaded from."""
8
+
9
+ BUNDLED = "bundled"
10
+ USER = "user"
11
+ PROJECT = "project"