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
iac_code/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ __version__ = "0.1.0"
2
+ __release_date__ = "2026-05-13"
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ def acp_main(*, debug: bool = False) -> None:
10
+ """Run iac-code as an ACP stdio server."""
11
+ import asyncio
12
+ import signal
13
+
14
+ import acp
15
+
16
+ from iac_code.acp.server import ACPServer
17
+ from iac_code.utils.log import setup_logging
18
+
19
+ # Configure logging *before* the event loop starts so startup-time
20
+ # messages obey the ``--debug`` flag too.
21
+ setup_logging(session_id="acp", debug=debug)
22
+ _apply_stdlib_log_level(debug)
23
+
24
+ async def _run() -> None:
25
+ server = ACPServer()
26
+ shutdown_event = asyncio.Event()
27
+
28
+ def _signal_handler() -> None:
29
+ logger.info("Received shutdown signal, initiating graceful shutdown...")
30
+ shutdown_event.set()
31
+
32
+ loop = asyncio.get_running_loop()
33
+ for sig in (signal.SIGINT, signal.SIGTERM):
34
+ loop.add_signal_handler(sig, _signal_handler)
35
+
36
+ agent_task = asyncio.create_task(acp.run_agent(server, use_unstable_protocol=True))
37
+ shutdown_task = asyncio.create_task(shutdown_event.wait())
38
+
39
+ try:
40
+ # Wait for either the agent to finish or a shutdown signal
41
+ done, pending = await asyncio.wait(
42
+ {agent_task, shutdown_task},
43
+ return_when=asyncio.FIRST_COMPLETED,
44
+ )
45
+
46
+ if shutdown_event.is_set() and not agent_task.done():
47
+ agent_task.cancel()
48
+ try:
49
+ await agent_task
50
+ except asyncio.CancelledError:
51
+ pass
52
+ finally:
53
+ if not shutdown_task.done():
54
+ shutdown_task.cancel()
55
+ with contextlib.suppress(asyncio.CancelledError):
56
+ await shutdown_task
57
+ await server.shutdown()
58
+ logger.info("ACP stdio server shut down. Metrics: %s", server.metrics.snapshot())
59
+
60
+ asyncio.run(_run())
61
+
62
+
63
+ def acp_main_http(*, host: str = "127.0.0.1", port: int = 8765, debug: bool = False) -> None:
64
+ """Start ACP server with HTTP+SSE transport."""
65
+ try:
66
+ import uvicorn
67
+ except ImportError:
68
+ raise SystemExit(
69
+ "HTTP transport requires extra dependencies. Install with: pip install iac-code[http]"
70
+ ) from None
71
+
72
+ from iac_code.utils.log import setup_logging
73
+
74
+ setup_logging(session_id="acp", debug=debug)
75
+ _apply_stdlib_log_level(debug)
76
+
77
+ from iac_code.acp.http_sse import create_app
78
+
79
+ app = create_app()
80
+ uvicorn.run(
81
+ app,
82
+ host=host,
83
+ port=port,
84
+ log_level="debug" if debug else "info",
85
+ )
86
+
87
+
88
+ def _apply_stdlib_log_level(debug: bool) -> None:
89
+ """Lower the stdlib root + iac_code.acp logger to DEBUG when requested.
90
+
91
+ ``setup_logging`` configures loguru, but the ACP modules use the stdlib
92
+ ``logging`` module. Without this the ``--debug`` flag would have no
93
+ visible effect on ACP-emitted logs.
94
+ """
95
+ level = logging.DEBUG if debug else logging.INFO
96
+ logging.getLogger().setLevel(level)
97
+ logging.getLogger("iac_code.acp").setLevel(level)
@@ -0,0 +1,423 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any, Literal
5
+
6
+ import acp
7
+
8
+ from iac_code.acp.state import TurnState
9
+ from iac_code.acp.types import ACPContentBlock
10
+ from iac_code.types.stream_events import (
11
+ CompactionEvent,
12
+ ErrorEvent,
13
+ MessageEndEvent,
14
+ PermissionRequestEvent,
15
+ PlanEvent,
16
+ StackInstancesProgressEvent,
17
+ StackProgressEvent,
18
+ StreamEvent,
19
+ SubAgentToolEvent,
20
+ TextDeltaEvent,
21
+ ThinkingDeltaEvent,
22
+ ToolInputDeltaEvent,
23
+ ToolResultEvent,
24
+ ToolUseEndEvent,
25
+ ToolUseStartEvent,
26
+ Usage,
27
+ )
28
+
29
+ # ``acp.schema`` exposes individual session-update message classes
30
+ # (``AgentMessageChunk``, ``ToolCallStart``, ...) but not a single
31
+ # ``SessionUpdate`` union alias. We use ``Any`` here to type the
32
+ # heterogeneous list returned by :meth:`ACPEventConverter.event_to_updates`.
33
+ SessionUpdate = Any
34
+
35
+ # Mapping from internal tool name to ACP ``ToolCallStart.kind`` value.
36
+ # Values come from the ACP 0.9.0 ``kind`` Literal enum:
37
+ # read | edit | delete | move | search | execute | think | fetch |
38
+ # switch_mode | other
39
+ # Clients (e.g. Zed) use this to pick icons and UI treatment.
40
+ ToolKind = Literal[
41
+ "read",
42
+ "edit",
43
+ "delete",
44
+ "move",
45
+ "search",
46
+ "execute",
47
+ "think",
48
+ "fetch",
49
+ "switch_mode",
50
+ "other",
51
+ ]
52
+
53
+ _TOOL_KIND_MAP: dict[str, ToolKind] = {
54
+ # Read operations
55
+ "read_file": "read",
56
+ "list_files": "read",
57
+ "read_memory": "read",
58
+ "task_list": "read",
59
+ "task_get": "read",
60
+ # Edit / write operations
61
+ "write_file": "edit",
62
+ "edit_file": "edit",
63
+ "write_memory": "edit",
64
+ # Search operations
65
+ "grep": "search",
66
+ "glob": "search",
67
+ # Execute operations
68
+ "bash": "execute",
69
+ "task_stop": "execute",
70
+ "ros_stack": "execute",
71
+ "ros_stack_instances": "execute",
72
+ # Fetch operations
73
+ "web_fetch": "fetch",
74
+ "aliyun_doc_search": "fetch",
75
+ }
76
+
77
+
78
+ def _tool_kind(tool_name: str) -> ToolKind:
79
+ """Return the ACP ``ToolCallStart.kind`` value for a tool name.
80
+
81
+ Falls back to suffix-based heuristics so dynamically-named cloud tools
82
+ (e.g. ``aliyun_api``, ``foo_doc_search``) still get a sensible kind,
83
+ and finally to ``"other"`` for unknown tools.
84
+ """
85
+ mapped = _TOOL_KIND_MAP.get(tool_name)
86
+ if mapped is not None:
87
+ return mapped
88
+ # Cloud provider API tools follow the ``{provider}_api`` naming convention.
89
+ if tool_name.endswith("_api"):
90
+ return "execute"
91
+ if tool_name.endswith("_doc_search"):
92
+ return "fetch"
93
+ return "other"
94
+
95
+
96
+ # Callable returning ``(used_tokens, context_window_size)`` for the current
97
+ # session. Used by :class:`ACPEventConverter` to emit ``UsageUpdate`` events.
98
+ ContextSnapshot = Callable[[], tuple[int, int]]
99
+
100
+
101
+ def acp_blocks_to_prompt_text(blocks: list[ACPContentBlock]) -> str:
102
+ parts: list[str] = []
103
+ for block in blocks:
104
+ match block:
105
+ case acp.schema.TextContentBlock():
106
+ parts.append(block.text)
107
+ case acp.schema.EmbeddedResourceContentBlock():
108
+ resource = block.resource
109
+ if isinstance(resource, acp.schema.TextResourceContents):
110
+ parts.append(f"<resource uri={resource.uri!r}>\n{resource.text}\n</resource>")
111
+ case acp.schema.ResourceContentBlock():
112
+ parts.append(f"<resource_link uri={block.uri!r} name={block.name!r} />")
113
+ case acp.schema.ImageContentBlock():
114
+ parts.append(f"[image: {block.mime_type}]")
115
+ case acp.schema.AudioContentBlock():
116
+ parts.append(f"[audio: {block.mime_type}]")
117
+ case _:
118
+ parts.append(f"[Unsupported ACP content block: {type(block).__name__}]")
119
+ return "\n\n".join(part for part in parts if part)
120
+
121
+
122
+ def acp_blocks_to_multimodal(
123
+ blocks: list[ACPContentBlock],
124
+ ) -> list[dict]:
125
+ """Convert ACP content blocks to a list of provider-compatible content parts.
126
+
127
+ Returns a list of dicts suitable for multi-modal LLM APIs:
128
+ - {"type": "text", "text": "..."}
129
+ - {"type": "image", "mime_type": "...", "data": "..."}
130
+
131
+ When all blocks are text, callers may flatten to a single string.
132
+ """
133
+ parts: list[dict] = []
134
+ for block in blocks:
135
+ match block:
136
+ case acp.schema.TextContentBlock():
137
+ parts.append({"type": "text", "text": block.text})
138
+ case acp.schema.EmbeddedResourceContentBlock():
139
+ resource = block.resource
140
+ if isinstance(resource, acp.schema.TextResourceContents):
141
+ parts.append(
142
+ {"type": "text", "text": f"<resource uri={resource.uri!r}>\n{resource.text}\n</resource>"}
143
+ )
144
+ case acp.schema.ResourceContentBlock():
145
+ parts.append({"type": "text", "text": f"<resource_link uri={block.uri!r} name={block.name!r} />"})
146
+ case acp.schema.ImageContentBlock():
147
+ parts.append({"type": "image", "mime_type": block.mime_type, "data": block.data})
148
+ case acp.schema.AudioContentBlock():
149
+ parts.append({"type": "audio", "mime_type": block.mime_type, "data": block.data})
150
+ case _:
151
+ parts.append({"type": "text", "text": f"[Unsupported ACP content block: {type(block).__name__}]"})
152
+ return parts
153
+
154
+
155
+ class ACPEventConverter:
156
+ def __init__(
157
+ self,
158
+ turn_id: str,
159
+ turn_state: TurnState | None = None,
160
+ terminal_tool_names: set[str] | None = None,
161
+ context_snapshot: ContextSnapshot | None = None,
162
+ ):
163
+ self._turn_id = turn_id
164
+ self._turn_state = turn_state
165
+ self._tool_inputs: dict[str, str] = {}
166
+ self._terminal_tool_names: set[str] = terminal_tool_names or set()
167
+ self._last_usage: Usage | None = None
168
+ self._context_snapshot = context_snapshot
169
+
170
+ def acp_tool_call_id(self, tool_use_id: str) -> str:
171
+ return f"{self._turn_id}/{tool_use_id}"
172
+
173
+ def event_to_updates(self, event: StreamEvent) -> list[SessionUpdate]:
174
+ match event:
175
+ case TextDeltaEvent(text=text):
176
+ return [
177
+ acp.schema.AgentMessageChunk(
178
+ session_update="agent_message_chunk",
179
+ content=acp.schema.TextContentBlock(type="text", text=text),
180
+ )
181
+ ]
182
+ case ThinkingDeltaEvent(text=text):
183
+ return [
184
+ acp.schema.AgentThoughtChunk(
185
+ session_update="agent_thought_chunk",
186
+ content=acp.schema.TextContentBlock(type="text", text=text),
187
+ )
188
+ ]
189
+ case ToolUseStartEvent(tool_use_id=tool_use_id, name=name):
190
+ if self._turn_state is not None:
191
+ self._turn_state.start_tool_call(tool_use_id, name)
192
+ return [
193
+ acp.schema.ToolCallStart(
194
+ session_update="tool_call",
195
+ tool_call_id=self.acp_tool_call_id(tool_use_id),
196
+ title=name,
197
+ kind=_tool_kind(name),
198
+ status="pending",
199
+ )
200
+ ]
201
+ case ToolInputDeltaEvent(tool_use_id=tool_use_id, partial_json=partial_json):
202
+ self._tool_inputs[tool_use_id] = self._tool_inputs.get(tool_use_id, "") + partial_json
203
+ tc_state = self._turn_state.get_tool_call(tool_use_id) if self._turn_state else None
204
+ if tc_state is not None:
205
+ tc_state.update_input(partial_json)
206
+ title = tc_state.title if tc_state else None
207
+ update = acp.schema.ToolCallProgress(
208
+ session_update="tool_call_update",
209
+ tool_call_id=self.acp_tool_call_id(tool_use_id),
210
+ status="pending",
211
+ content=[_text_tool_content(self._tool_inputs[tool_use_id])],
212
+ )
213
+ if title:
214
+ update.title = title
215
+ return [update]
216
+ case ToolUseEndEvent(tool_use_id=tool_use_id, input=input):
217
+ return [
218
+ acp.schema.ToolCallProgress(
219
+ session_update="tool_call_update",
220
+ tool_call_id=self.acp_tool_call_id(tool_use_id),
221
+ status="in_progress",
222
+ content=[_text_tool_content(str(input))],
223
+ )
224
+ ]
225
+ case ToolResultEvent(tool_use_id=tool_use_id, tool_name=tool_name, result=result, is_error=is_error):
226
+ content: list[
227
+ acp.schema.ContentToolCallContent
228
+ | acp.schema.FileEditToolCallContent
229
+ | acp.schema.TerminalToolCallContent
230
+ ] = [_text_tool_content(result)]
231
+ meta: dict[str, Any] | None = None
232
+ if tool_name in self._terminal_tool_names:
233
+ meta = {"already_displayed": True}
234
+ # Attach tool call elapsed time if available
235
+ tc_state = self._turn_state.get_tool_call(tool_use_id) if self._turn_state else None
236
+ if tc_state is not None:
237
+ if meta is None:
238
+ meta = {}
239
+ meta["timing"] = {"elapsed_ms": tc_state.elapsed_ms}
240
+ # Emit final progress update with tool output
241
+ progress = acp.schema.ToolCallProgress(
242
+ session_update="tool_call_update",
243
+ tool_call_id=self.acp_tool_call_id(tool_use_id),
244
+ status="in_progress",
245
+ content=content,
246
+ )
247
+ if meta is not None:
248
+ progress.field_meta = meta
249
+ # Emit terminal update marking tool call as completed/failed
250
+ end = acp.schema.ToolCallProgress(
251
+ session_update="tool_call_update",
252
+ tool_call_id=self.acp_tool_call_id(tool_use_id),
253
+ status="failed" if is_error else "completed",
254
+ )
255
+ return [progress, end]
256
+ case CompactionEvent(original_tokens=original, compacted_tokens=compacted):
257
+ return [
258
+ acp.schema.AgentMessageChunk(
259
+ session_update="agent_message_chunk",
260
+ content=acp.schema.TextContentBlock(
261
+ type="text",
262
+ text=f"[Context compacted: {original} -> {compacted} tokens]",
263
+ ),
264
+ )
265
+ ]
266
+ case ErrorEvent(error=error):
267
+ return [
268
+ acp.schema.AgentMessageChunk(
269
+ session_update="agent_message_chunk",
270
+ content=acp.schema.TextContentBlock(type="text", text=f"[Error] {error}"),
271
+ )
272
+ ]
273
+ case PlanEvent(steps=steps):
274
+ entries = [
275
+ acp.schema.PlanEntry(
276
+ content=step.content,
277
+ status=step.status,
278
+ priority=step.priority,
279
+ )
280
+ for step in steps
281
+ ]
282
+ return [
283
+ acp.schema.AgentPlanUpdate(
284
+ session_update="plan",
285
+ entries=entries,
286
+ )
287
+ ]
288
+ case MessageEndEvent(usage=usage):
289
+ self._last_usage = usage
290
+ # Emit an ACP ``UsageUpdate`` carrying current context-window
291
+ # occupancy. This is semantically different from the per-turn
292
+ # input/output token counts returned via
293
+ # ``PromptResponse.field_meta["usage"]``: ``UsageUpdate`` is
294
+ # the ACP-standard channel for clients to render context
295
+ # pressure / auto-compact hints.
296
+ if self._context_snapshot is None:
297
+ return []
298
+ try:
299
+ used, size = self._context_snapshot()
300
+ except Exception:
301
+ return []
302
+ if size <= 0 or used < 0:
303
+ return []
304
+ return [
305
+ acp.schema.UsageUpdate(
306
+ session_update="usage_update",
307
+ used=used,
308
+ size=size,
309
+ )
310
+ ]
311
+ case PermissionRequestEvent() | StackProgressEvent() | StackInstancesProgressEvent() | SubAgentToolEvent():
312
+ return []
313
+ case _:
314
+ return []
315
+
316
+
317
+ def _text_tool_content(text: str) -> acp.schema.ContentToolCallContent:
318
+ return acp.schema.ContentToolCallContent(
319
+ type="content",
320
+ content=acp.schema.TextContentBlock(type="text", text=text),
321
+ )
322
+
323
+
324
+ # ---------------------------------------------------------------------------
325
+ # Multimodal output helpers
326
+ # ---------------------------------------------------------------------------
327
+
328
+
329
+ def create_image_content_block(
330
+ data: str,
331
+ mime_type: str = "image/png",
332
+ *,
333
+ uri: str | None = None,
334
+ ) -> Any:
335
+ """Create an ACP ImageContentBlock for multimodal output.
336
+
337
+ Args:
338
+ data: Base64-encoded image data.
339
+ mime_type: MIME type of the image (default ``image/png``).
340
+ uri: Optional URI reference for the image.
341
+
342
+ Returns:
343
+ An ``acp.schema.ImageContentBlock`` instance.
344
+ """
345
+ return acp.schema.ImageContentBlock(type="image", data=data, mime_type=mime_type, uri=uri)
346
+
347
+
348
+ def create_audio_content_block(
349
+ data: str,
350
+ mime_type: str = "audio/wav",
351
+ ) -> Any:
352
+ """Create an ACP AudioContentBlock for multimodal output.
353
+
354
+ Args:
355
+ data: Base64-encoded audio data.
356
+ mime_type: MIME type of the audio (default ``audio/wav``).
357
+
358
+ Returns:
359
+ An ``acp.schema.AudioContentBlock`` instance.
360
+ """
361
+ return acp.schema.AudioContentBlock(type="audio", data=data, mime_type=mime_type)
362
+
363
+
364
+ def create_file_content_block(
365
+ data: str,
366
+ filename: str,
367
+ mime_type: str,
368
+ ) -> Any:
369
+ """Create an ACP EmbeddedResourceContentBlock wrapping binary file data.
370
+
371
+ This embeds file content as a ``BlobResourceContents`` resource inside an
372
+ ``EmbeddedResourceContentBlock``, which is the ACP-standard way to
373
+ transmit arbitrary file payloads.
374
+
375
+ Args:
376
+ data: Base64-encoded file data.
377
+ filename: Display name / URI for the file.
378
+ mime_type: MIME type of the file.
379
+
380
+ Returns:
381
+ An ``acp.schema.EmbeddedResourceContentBlock`` instance.
382
+ """
383
+ resource = acp.schema.BlobResourceContents(
384
+ uri=filename,
385
+ mime_type=mime_type,
386
+ blob=data,
387
+ )
388
+ return acp.schema.EmbeddedResourceContentBlock(
389
+ type="resource",
390
+ resource=resource,
391
+ )
392
+
393
+
394
+ def create_multimodal_message_chunk(
395
+ content_blocks: list[Any],
396
+ ) -> acp.schema.AgentMessageChunk:
397
+ """Wrap one or more content blocks into an ``AgentMessageChunk``.
398
+
399
+ This is a convenience helper for building session updates that carry
400
+ non-text (image / audio / file) payloads.
401
+
402
+ Args:
403
+ content_blocks: A list of ACP content block instances
404
+ (``ImageContentBlock``, ``AudioContentBlock``, etc.).
405
+
406
+ Returns:
407
+ An ``AgentMessageChunk`` ready to be yielded from
408
+ ``event_to_updates``.
409
+ """
410
+ # AgentMessageChunk.content accepts a single block; for multiple blocks
411
+ # we emit one chunk per block. When only one block is provided we
412
+ # return it directly for simplicity.
413
+ if len(content_blocks) == 1:
414
+ return acp.schema.AgentMessageChunk(
415
+ session_update="agent_message_chunk",
416
+ content=content_blocks[0],
417
+ )
418
+ # For multiple blocks, return the first one – callers that need to emit
419
+ # several blocks should call this helper per block or iterate themselves.
420
+ return acp.schema.AgentMessageChunk(
421
+ session_update="agent_message_chunk",
422
+ content=content_blocks[0],
423
+ )