illusion-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 (214) hide show
  1. illusion/__init__.py +24 -0
  2. illusion/__main__.py +15 -0
  3. illusion/_frontend/dist/index.mjs +39208 -0
  4. illusion/_frontend/package.json +27 -0
  5. illusion/_frontend/src/App.tsx +624 -0
  6. illusion/_frontend/src/components/CommandPicker.tsx +98 -0
  7. illusion/_frontend/src/components/Composer.tsx +55 -0
  8. illusion/_frontend/src/components/ComposerController.tsx +128 -0
  9. illusion/_frontend/src/components/ConversationView.tsx +750 -0
  10. illusion/_frontend/src/components/Footer.tsx +25 -0
  11. illusion/_frontend/src/components/MarkdownContent.tsx +537 -0
  12. illusion/_frontend/src/components/MarkdownTable.tsx +245 -0
  13. illusion/_frontend/src/components/ModalHost.tsx +425 -0
  14. illusion/_frontend/src/components/MultilineTextInput.tsx +250 -0
  15. illusion/_frontend/src/components/PromptInput.tsx +64 -0
  16. illusion/_frontend/src/components/SelectModal.tsx +78 -0
  17. illusion/_frontend/src/components/SidePanel.tsx +175 -0
  18. illusion/_frontend/src/components/Spinner.tsx +77 -0
  19. illusion/_frontend/src/components/StatusBar.tsx +142 -0
  20. illusion/_frontend/src/components/SwarmPanel.tsx +141 -0
  21. illusion/_frontend/src/components/TodoPanel.tsx +126 -0
  22. illusion/_frontend/src/components/ToolCallDisplay.tsx +202 -0
  23. illusion/_frontend/src/components/TranscriptPane.tsx +79 -0
  24. illusion/_frontend/src/components/WelcomeBanner.tsx +37 -0
  25. illusion/_frontend/src/hooks/useBackendSession.ts +468 -0
  26. illusion/_frontend/src/hooks/useTerminalSize.ts +9 -0
  27. illusion/_frontend/src/i18n.ts +78 -0
  28. illusion/_frontend/src/index.tsx +42 -0
  29. illusion/_frontend/src/theme/ThemeContext.tsx +19 -0
  30. illusion/_frontend/src/theme/builtinThemes.ts +89 -0
  31. illusion/_frontend/src/types.ts +110 -0
  32. illusion/_frontend/src/utils/markdown.ts +33 -0
  33. illusion/_frontend/src/utils/thinking.ts +191 -0
  34. illusion/_frontend/tsconfig.json +13 -0
  35. illusion/_web_dist/assets/index-BseIw-ik.css +10 -0
  36. illusion/_web_dist/assets/index-C_0ZWMuW.js +82 -0
  37. illusion/_web_dist/index.html +16 -0
  38. illusion/api/__init__.py +36 -0
  39. illusion/api/client.py +568 -0
  40. illusion/api/codex_client.py +563 -0
  41. illusion/api/compat.py +138 -0
  42. illusion/api/effort.py +128 -0
  43. illusion/api/errors.py +57 -0
  44. illusion/api/openai_client.py +819 -0
  45. illusion/api/provider.py +148 -0
  46. illusion/api/registry.py +479 -0
  47. illusion/api/usage.py +45 -0
  48. illusion/auth/__init__.py +50 -0
  49. illusion/auth/copilot.py +419 -0
  50. illusion/auth/external.py +612 -0
  51. illusion/auth/flows.py +58 -0
  52. illusion/auth/manager.py +214 -0
  53. illusion/auth/storage.py +372 -0
  54. illusion/bridge/__init__.py +38 -0
  55. illusion/bridge/manager.py +190 -0
  56. illusion/bridge/session_runner.py +84 -0
  57. illusion/bridge/types.py +113 -0
  58. illusion/bridge/work_secret.py +131 -0
  59. illusion/cli.py +1228 -0
  60. illusion/commands/__init__.py +32 -0
  61. illusion/commands/registry.py +1934 -0
  62. illusion/config/__init__.py +39 -0
  63. illusion/config/i18n.py +522 -0
  64. illusion/config/paths.py +259 -0
  65. illusion/config/settings.py +564 -0
  66. illusion/coordinator/__init__.py +41 -0
  67. illusion/coordinator/agent_definitions.py +1093 -0
  68. illusion/coordinator/coordinator_mode.py +127 -0
  69. illusion/engine/__init__.py +95 -0
  70. illusion/engine/cost_tracker.py +55 -0
  71. illusion/engine/messages.py +369 -0
  72. illusion/engine/query.py +632 -0
  73. illusion/engine/query_engine.py +343 -0
  74. illusion/engine/stream_events.py +169 -0
  75. illusion/hooks/__init__.py +67 -0
  76. illusion/hooks/events.py +43 -0
  77. illusion/hooks/executor.py +397 -0
  78. illusion/hooks/hot_reload.py +74 -0
  79. illusion/hooks/loader.py +133 -0
  80. illusion/hooks/schemas.py +121 -0
  81. illusion/hooks/types.py +86 -0
  82. illusion/mcp/__init__.py +104 -0
  83. illusion/mcp/client.py +377 -0
  84. illusion/mcp/config.py +140 -0
  85. illusion/mcp/types.py +175 -0
  86. illusion/memory/__init__.py +36 -0
  87. illusion/memory/manager.py +94 -0
  88. illusion/memory/memdir.py +58 -0
  89. illusion/memory/paths.py +57 -0
  90. illusion/memory/scan.py +120 -0
  91. illusion/memory/search.py +83 -0
  92. illusion/memory/types.py +43 -0
  93. illusion/output_styles/__init__.py +15 -0
  94. illusion/output_styles/loader.py +64 -0
  95. illusion/permissions/__init__.py +39 -0
  96. illusion/permissions/checker.py +174 -0
  97. illusion/permissions/modes.py +38 -0
  98. illusion/platforms.py +148 -0
  99. illusion/plugins/__init__.py +71 -0
  100. illusion/plugins/bundled/__init__.py +0 -0
  101. illusion/plugins/installer.py +59 -0
  102. illusion/plugins/loader.py +301 -0
  103. illusion/plugins/schemas.py +51 -0
  104. illusion/plugins/types.py +56 -0
  105. illusion/prompts/__init__.py +29 -0
  106. illusion/prompts/claudemd.py +74 -0
  107. illusion/prompts/context.py +187 -0
  108. illusion/prompts/environment.py +189 -0
  109. illusion/prompts/system_prompt.py +155 -0
  110. illusion/py.typed +0 -0
  111. illusion/sandbox/__init__.py +29 -0
  112. illusion/sandbox/adapter.py +174 -0
  113. illusion/services/__init__.py +59 -0
  114. illusion/services/compact/__init__.py +1015 -0
  115. illusion/services/cron.py +338 -0
  116. illusion/services/cron_scheduler.py +715 -0
  117. illusion/services/file_history.py +258 -0
  118. illusion/services/lsp/__init__.py +455 -0
  119. illusion/services/session_storage.py +237 -0
  120. illusion/services/token_estimation.py +72 -0
  121. illusion/skills/__init__.py +60 -0
  122. illusion/skills/bundled/__init__.py +110 -0
  123. illusion/skills/bundled/content/batch.md +86 -0
  124. illusion/skills/bundled/content/coding-guidelines.md +70 -0
  125. illusion/skills/bundled/content/debug.md +38 -0
  126. illusion/skills/bundled/content/loop.md +82 -0
  127. illusion/skills/bundled/content/remember.md +105 -0
  128. illusion/skills/bundled/content/simplify.md +53 -0
  129. illusion/skills/bundled/content/skillify.md +113 -0
  130. illusion/skills/bundled/content/stuck.md +54 -0
  131. illusion/skills/bundled/content/update-config.md +329 -0
  132. illusion/skills/bundled/content/verify.md +74 -0
  133. illusion/skills/loader.py +219 -0
  134. illusion/skills/registry.py +40 -0
  135. illusion/skills/types.py +24 -0
  136. illusion/state/__init__.py +18 -0
  137. illusion/state/app_state.py +67 -0
  138. illusion/state/store.py +93 -0
  139. illusion/swarm/__init__.py +71 -0
  140. illusion/swarm/agent_executor.py +857 -0
  141. illusion/swarm/in_process.py +259 -0
  142. illusion/swarm/subprocess_backend.py +136 -0
  143. illusion/swarm/team_helpers.py +123 -0
  144. illusion/swarm/types.py +159 -0
  145. illusion/swarm/worktree.py +347 -0
  146. illusion/tasks/__init__.py +33 -0
  147. illusion/tasks/local_agent_task.py +42 -0
  148. illusion/tasks/local_shell_task.py +27 -0
  149. illusion/tasks/manager.py +377 -0
  150. illusion/tasks/stop_task.py +21 -0
  151. illusion/tasks/types.py +88 -0
  152. illusion/tools/__init__.py +126 -0
  153. illusion/tools/agent_tool.py +388 -0
  154. illusion/tools/ask_user_question_tool.py +186 -0
  155. illusion/tools/base.py +149 -0
  156. illusion/tools/bash_tool.py +413 -0
  157. illusion/tools/config_tool.py +90 -0
  158. illusion/tools/cron_tool.py +473 -0
  159. illusion/tools/enter_plan_mode_tool.py +147 -0
  160. illusion/tools/enter_worktree_tool.py +188 -0
  161. illusion/tools/exit_plan_mode_tool.py +69 -0
  162. illusion/tools/exit_worktree_tool.py +225 -0
  163. illusion/tools/file_edit_tool.py +283 -0
  164. illusion/tools/file_read_tool.py +294 -0
  165. illusion/tools/file_write_tool.py +184 -0
  166. illusion/tools/glob_tool.py +165 -0
  167. illusion/tools/grep_tool.py +190 -0
  168. illusion/tools/list_mcp_resources_tool.py +80 -0
  169. illusion/tools/lsp_tool.py +333 -0
  170. illusion/tools/mcp_auth_tool.py +100 -0
  171. illusion/tools/mcp_tool.py +75 -0
  172. illusion/tools/notebook_edit_tool.py +242 -0
  173. illusion/tools/powershell_tool.py +334 -0
  174. illusion/tools/read_mcp_resource_tool.py +63 -0
  175. illusion/tools/repl_tool.py +100 -0
  176. illusion/tools/send_message_tool.py +112 -0
  177. illusion/tools/shell_common.py +187 -0
  178. illusion/tools/skill_tool.py +86 -0
  179. illusion/tools/sleep_tool.py +62 -0
  180. illusion/tools/structured_output_tool.py +58 -0
  181. illusion/tools/task_create_tool.py +98 -0
  182. illusion/tools/task_get_tool.py +94 -0
  183. illusion/tools/task_list_tool.py +94 -0
  184. illusion/tools/task_output_tool.py +55 -0
  185. illusion/tools/task_stop_tool.py +52 -0
  186. illusion/tools/task_update_tool.py +224 -0
  187. illusion/tools/team_create_tool.py +236 -0
  188. illusion/tools/team_delete_tool.py +104 -0
  189. illusion/tools/todo_write_tool.py +198 -0
  190. illusion/tools/tool_search_tool.py +156 -0
  191. illusion/tools/web_fetch_tool.py +264 -0
  192. illusion/tools/web_search_tool.py +186 -0
  193. illusion/ui/__init__.py +23 -0
  194. illusion/ui/app.py +258 -0
  195. illusion/ui/backend_host.py +1180 -0
  196. illusion/ui/input.py +86 -0
  197. illusion/ui/output.py +363 -0
  198. illusion/ui/permission_dialog.py +47 -0
  199. illusion/ui/permission_store.py +99 -0
  200. illusion/ui/protocol.py +384 -0
  201. illusion/ui/react_launcher.py +280 -0
  202. illusion/ui/runtime.py +787 -0
  203. illusion/ui/textual_app.py +603 -0
  204. illusion/ui/web/__init__.py +10 -0
  205. illusion/ui/web/server.py +87 -0
  206. illusion/ui/web/ws_host.py +1197 -0
  207. illusion/utils/__init__.py +0 -0
  208. illusion/utils/ripgrep.py +299 -0
  209. illusion/utils/shell.py +248 -0
  210. illusion_code-0.1.0.dist-info/METADATA +1159 -0
  211. illusion_code-0.1.0.dist-info/RECORD +214 -0
  212. illusion_code-0.1.0.dist-info/WHEEL +4 -0
  213. illusion_code-0.1.0.dist-info/entry_points.txt +2 -0
  214. illusion_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,632 @@
1
+ """核心工具感知查询循环。
2
+
3
+ 本模块实现与模型交互的核心查询循环,支持工具调用和自动压缩功能。
4
+
5
+ 主要功能:
6
+ - 管理对话轮次和工具执行
7
+ - 支持单工具和多工具调用
8
+ - 自动压缩长对话历史
9
+ - 执行权限检查和钩子
10
+
11
+ 主要类和函数:
12
+ - QueryContext: 查询上下文数据类
13
+ - run_query: 异步生成器,运行对话循环
14
+ - MaxTurnsExceeded: 超出最大轮次异常
15
+
16
+ 使用示例:
17
+ >>> from illusion.engine.query import QueryContext, run_query
18
+ >>> async for event, usage in run_query(context, messages):
19
+ ... print(event)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any, AsyncIterator, Awaitable, Callable
28
+
29
+ from illusion.api.client import (
30
+ ApiMessageCompleteEvent,
31
+ ApiMessageRequest,
32
+ ApiRetryEvent,
33
+ ApiTextDeltaEvent,
34
+ ApiToolCallStartedEvent,
35
+ SupportsStreamingMessages,
36
+ )
37
+ from illusion.api.effort import EffortLevel
38
+ from illusion.api.usage import UsageSnapshot
39
+ from illusion.engine.messages import ConversationMessage, ToolResultBlock, _build_tool_result_content
40
+ from illusion.engine.stream_events import (
41
+ AssistantTextDelta,
42
+ AssistantTurnComplete,
43
+ ErrorEvent,
44
+ StatusEvent,
45
+ StreamEvent,
46
+ ToolChainCompleted,
47
+ ToolChainStarted,
48
+ ToolExecutionCompleted,
49
+ ToolExecutionStarted,
50
+ )
51
+ from illusion.hooks import HookEvent, HookExecutor
52
+ from illusion.permissions.checker import PermissionChecker
53
+ from illusion.tools.base import ToolExecutionContext
54
+ from illusion.tools.base import ToolRegistry
55
+
56
+
57
+ # 权限提示回调类型:工具名称 -> 是否允许
58
+ PermissionPrompt = Callable[[str, str], Awaitable[bool]]
59
+ # 用户询问回调类型:问题 -> 回答
60
+ AskUserPrompt = Callable[[str], Awaitable[str]]
61
+
62
+
63
+ class MaxTurnsExceeded(RuntimeError):
64
+ """当智能体超出配置的最大轮次时抛出。
65
+
66
+ Attributes:
67
+ max_turns: 配置的最大轮次数量
68
+ """
69
+
70
+ def __init__(self, max_turns: int) -> None:
71
+ super().__init__(f"Exceeded maximum turn limit ({max_turns})")
72
+ self.max_turns = max_turns
73
+
74
+
75
+ class PermissionDenied(RuntimeError):
76
+ """当用户拒绝工具权限时抛出,用于终止当前查询循环。
77
+
78
+ Attributes:
79
+ tool_name: 被拒绝的工具名称
80
+ message: 拒绝原因描述
81
+ """
82
+
83
+ def __init__(self, tool_name: str, message: str = "") -> None:
84
+ self.tool_name = tool_name
85
+ super().__init__(message or f"Permission denied for {tool_name}")
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # 后台代理完成通知
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ @dataclass
94
+ class BgAgentCompletion:
95
+ """后台代理完成通知。
96
+
97
+ 当后台代理完成执行时,通过 BackgroundAgentTracker 传递给主查询循环。
98
+
99
+ Attributes:
100
+ agent_id: 代理 ID
101
+ notification_xml: 格式化的任务通知 XML
102
+ """
103
+
104
+ agent_id: str
105
+ notification_xml: str
106
+
107
+
108
+ class BackgroundAgentTracker:
109
+ """追踪后台代理的完成状态,实现事件驱动的唤醒机制。
110
+
111
+ 当主 agent 派发后台代理后,无需轮询检查状态,而是通过
112
+ asyncio.Event 等待后台代理完成通知,避免浪费 token。
113
+
114
+ 使用示例:
115
+ >>> tracker = BackgroundAgentTracker()
116
+ >>> tracker.register("agent_abc123")
117
+ >>> # 后台代理完成时:
118
+ >>> tracker.notify_completed("agent_abc123", "<task-notification>...</task-notification>")
119
+ >>> # 主查询循环中:
120
+ >>> completed = await tracker.wait_for_completion()
121
+ """
122
+
123
+ def __init__(self) -> None:
124
+ self._wake_event: asyncio.Event = asyncio.Event()
125
+ self._completions: list[BgAgentCompletion] = []
126
+ self._pending_count: int = 0
127
+
128
+ def register(self, agent_id: str) -> None:
129
+ """注册一个待处理的后台代理。
130
+
131
+ Args:
132
+ agent_id: 代理 ID
133
+ """
134
+ self._pending_count += 1
135
+
136
+ def notify_completed(self, agent_id: str, notification_xml: str) -> None:
137
+ """通知后台代理已完成。
138
+
139
+ Args:
140
+ agent_id: 代理 ID
141
+ notification_xml: 格式化的任务通知 XML
142
+ """
143
+ self._completions.append(BgAgentCompletion(agent_id=agent_id, notification_xml=notification_xml))
144
+ self._pending_count -= 1
145
+ self._wake_event.set()
146
+
147
+ def has_pending(self) -> bool:
148
+ """是否有待处理或已完成但未消费的后台代理。"""
149
+ return self._pending_count > 0 or bool(self._completions)
150
+
151
+ def _drain_completions(self) -> list[BgAgentCompletion]:
152
+ """取出所有已完成的通知并重置唤醒事件。"""
153
+ completions = list(self._completions)
154
+ self._completions.clear()
155
+ self._wake_event.clear()
156
+ return completions
157
+
158
+ async def wait_for_completion(self) -> list[BgAgentCompletion]:
159
+ """等待任意后台代理完成,返回所有已完成的通知。
160
+
161
+ 如果已有完成的通知则立即返回,否则阻塞等待。
162
+
163
+ Returns:
164
+ list[BgAgentCompletion]: 已完成的后台代理通知列表
165
+ """
166
+ if self._completions:
167
+ return self._drain_completions()
168
+ if self._pending_count <= 0:
169
+ return []
170
+ await self._wake_event.wait()
171
+ return self._drain_completions()
172
+
173
+
174
+ @dataclass
175
+ class QueryContext:
176
+ """跨查询运行的共享上下文。
177
+
178
+ 包含执行查询所需的所有配置信息,包括API客户端、
179
+ 工具注册表、权限检查器等。
180
+
181
+ Attributes:
182
+ api_client: 支持流式消息的API客户端
183
+ tool_registry: 工具注册表
184
+ permission_checker: 权限检查器
185
+ cwd: 当前工作目录
186
+ model: 模型名称
187
+ system_prompt: 系统提示词
188
+ max_tokens: 最大令牌数
189
+ permission_prompt: 权限提示回调(可选)
190
+ ask_user_prompt: 用户询问回调(可选)
191
+ max_turns: 最大轮次限制(可选)
192
+ hook_executor: 钩子执行器(可选)
193
+ tool_metadata: 工具元数据(可选)
194
+ effort: 推理强度级别(可选)
195
+ """
196
+
197
+ api_client: SupportsStreamingMessages
198
+ tool_registry: ToolRegistry
199
+ permission_checker: PermissionChecker
200
+ cwd: Path
201
+ model: str
202
+ system_prompt: str
203
+ max_tokens: int
204
+ permission_prompt: PermissionPrompt | None = None
205
+ ask_user_prompt: AskUserPrompt | None = None
206
+ max_turns: int | None = 200
207
+ hook_executor: HookExecutor | None = None
208
+ tool_metadata: dict[str, object] | None = None
209
+ effort: EffortLevel | None = None
210
+ bg_agent_tracker: BackgroundAgentTracker | None = None
211
+ compact_state: Any = None # AutoCompactState,从 QueryEngine 传入
212
+ # 文件历史回调:工具执行前调用,参数为 (工具名称, 工具输入)
213
+ on_before_tool_execute: Callable[[str, dict], None] | None = None
214
+
215
+
216
+ async def run_query(
217
+ context: QueryContext,
218
+ messages: list[ConversationMessage],
219
+ ) -> AsyncIterator[tuple[StreamEvent, UsageSnapshot | None]]:
220
+ """运行对话循环直到模型停止请求工具。
221
+
222
+ 在每个轮次开始时检查自动压缩。当估计的令牌数超过
223
+ 模型的自动压缩阈值时,引擎首先尝试廉价的微压缩
224
+ (清除旧的工具结果内容),如果还不够,则执行基于LLM
225
+ 的旧消息摘要。
226
+
227
+ Args:
228
+ context: 查询上下文
229
+ messages: 对话消息列表
230
+
231
+ Yields:
232
+ tuple[StreamEvent, UsageSnapshot | None]: 流事件和可选的使用量快照
233
+
234
+ 使用示例:
235
+ >>> context = QueryContext(...)
236
+ >>> messages = [ConversationMessage.from_user_text("你好")]
237
+ >>> async for event, usage in run_query(context, messages):
238
+ ... print(event)
239
+ """
240
+ from illusion.config.i18n import t
241
+ from illusion.services.compact import (
242
+ AutoCompactState,
243
+ auto_compact_if_needed,
244
+ calculate_token_warning_state,
245
+ reactive_compact,
246
+ )
247
+
248
+ # 使用从 QueryEngine 传入的持久化压缩状态
249
+ compact_state: AutoCompactState = context.compact_state or AutoCompactState()
250
+
251
+ turn_count = 0 # 轮次计数器
252
+ while context.max_turns is None or turn_count < context.max_turns:
253
+ turn_count += 1
254
+
255
+ # --- 上下文警告检查 ---------------
256
+ if not compact_state.warning_suppressed:
257
+ warning = calculate_token_warning_state(messages, context.model)
258
+ if warning.is_above_warning_threshold and not warning.is_above_autocompact_threshold:
259
+ pct = int(warning.estimated_tokens * 100 / warning.context_window) if warning.context_window > 0 else 0
260
+ yield StatusEvent(
261
+ message=t("compact_warning_approaching", pct=pct)
262
+ ), None
263
+ # 压缩后重置警告抑制(下次微压缩时清除)
264
+ if compact_state.warning_suppressed:
265
+ compact_state.warning_suppressed = False
266
+
267
+ # --- 调用模型前检查自动压缩 ---------------
268
+ messages, was_compacted = await auto_compact_if_needed(
269
+ messages,
270
+ api_client=context.api_client,
271
+ model=context.model,
272
+ system_prompt=context.system_prompt,
273
+ state=compact_state,
274
+ )
275
+ if was_compacted:
276
+ yield StatusEvent(message=t("compact_compacted")), None
277
+ # ---------------------------------------------------------------
278
+
279
+ final_message: ConversationMessage | None = None
280
+ usage = UsageSnapshot()
281
+
282
+ try:
283
+ # 流式请求模型响应
284
+ async for event in context.api_client.stream_message(
285
+ ApiMessageRequest(
286
+ model=context.model,
287
+ messages=messages,
288
+ system_prompt=context.system_prompt,
289
+ max_tokens=context.max_tokens,
290
+ tools=context.tool_registry.to_api_schema(),
291
+ effort=context.effort,
292
+ )
293
+ ):
294
+ if isinstance(event, ApiTextDeltaEvent):
295
+ # 输出助手文本增量事件
296
+ yield AssistantTextDelta(
297
+ text=event.text,
298
+ reasoning=event.reasoning,
299
+ ), None
300
+ continue
301
+ if isinstance(event, ApiRetryEvent):
302
+ # 输出状态事件:重试信息
303
+ yield StatusEvent(
304
+ message=(
305
+ f"Request failed; retrying in {event.delay_seconds:.1f}s "
306
+ f"(attempt {event.attempt + 1} of {event.max_attempts}): {event.message}"
307
+ )
308
+ ), None
309
+ continue
310
+ if isinstance(event, ApiToolCallStartedEvent):
311
+ # 模型开始生成工具调用时立即通知前端,无需等待完整参数
312
+ yield ToolExecutionStarted(
313
+ tool_name=event.tool_name,
314
+ tool_input={},
315
+ tool_use_id=event.tool_use_id,
316
+ ), None
317
+ continue
318
+
319
+ if isinstance(event, ApiMessageCompleteEvent):
320
+ final_message = event.message
321
+ usage = event.usage
322
+ except Exception as exc:
323
+ error_msg = str(exc)
324
+ error_lower = error_msg.lower()
325
+
326
+ # --- 响应式压缩:prompt-too-long 时尝试压缩重试 ---
327
+ if "prompt" in error_lower and "long" in error_lower:
328
+ yield StatusEvent(message=t("compact_overflow_detected")), None
329
+ messages, was_compacted = await reactive_compact(
330
+ messages,
331
+ api_client=context.api_client,
332
+ model=context.model,
333
+ system_prompt=context.system_prompt,
334
+ )
335
+ if was_compacted:
336
+ yield StatusEvent(message=t("compact_reactive_success")), None
337
+ # 重试当前轮次(不增加 turn_count)
338
+ turn_count -= 1
339
+ continue
340
+ # 压缩也失败,报错
341
+ yield ErrorEvent(message=t("compact_overflow_failed", error=error_msg)), None
342
+ return
343
+
344
+ # 检查是否为网络相关错误
345
+ if "connect" in error_lower or "timeout" in error_lower or "network" in error_lower:
346
+ yield ErrorEvent(message=t("compact_network_error", error=error_msg)), None
347
+ else:
348
+ yield ErrorEvent(message=t("compact_api_error", error=error_msg)), None
349
+ return
350
+
351
+ if final_message is None:
352
+ raise RuntimeError("Model stream finished without a final message")
353
+
354
+ # 添加助手消息到历史记录
355
+ messages.append(final_message)
356
+
357
+ yield AssistantTurnComplete(message=final_message, usage=usage), usage
358
+
359
+ # 如果没有工具调用,检查是否有待处理的后台代理
360
+ if not final_message.tool_uses:
361
+ tracker = context.bg_agent_tracker
362
+ if tracker is not None and tracker.has_pending():
363
+ # 发出等待状态事件(显示在 shimmer 区域)
364
+ from illusion.config.i18n import t as _t
365
+ yield StatusEvent(message=_t("bg_agent_waiting"), bg_agent=True), None
366
+ # 等待任意后台代理完成(不消耗 token)
367
+ completed = await tracker.wait_for_completion()
368
+ if completed:
369
+ # 将完成通知注入为用户消息,触发模型继续处理
370
+ notification_parts = [c.notification_xml for c in completed]
371
+ notification_text = "\n\n".join(notification_parts)
372
+ messages.append(ConversationMessage.from_user_text(notification_text))
373
+ yield StatusEvent(message=_t("bg_agent_resuming"), bg_agent=True), None
374
+ continue
375
+ return
376
+
377
+ tool_calls = final_message.tool_uses
378
+
379
+ # 输出工具链开始事件
380
+ yield ToolChainStarted(tool_count=len(tool_calls)), None
381
+
382
+ try:
383
+ if len(tool_calls) == 1:
384
+ # 单个工具:顺序执行
385
+ tc = tool_calls[0]
386
+ # 始终发送带完整 tool_input 的 ToolExecutionStarted,
387
+ # 由下游(backend_host)通过 tool_use_id 去重避免前端重复显示
388
+ yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input, tool_use_id=tc.id), None
389
+ result = await _execute_tool_call(context, tc.name, tc.id, tc.input)
390
+ yield ToolExecutionCompleted(
391
+ tool_name=tc.name,
392
+ output=result.text_content,
393
+ is_error=result.is_error,
394
+ tool_use_id=tc.id,
395
+ ), None
396
+ tool_results = [result]
397
+ else:
398
+ # 多个工具:并发执行
399
+ for tc in tool_calls:
400
+ # 始终发送带完整 tool_input 的 ToolExecutionStarted,
401
+ # 由下游(backend_host)通过 tool_use_id 去重避免前端重复显示
402
+ yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input, tool_use_id=tc.id), None
403
+
404
+ async def _safe_run(idx: int, tc):
405
+ """并发执行单个工具,捕获非权限异常转为错误结果。"""
406
+ try:
407
+ result = await _execute_tool_call(context, tc.name, tc.id, tc.input)
408
+ return idx, result
409
+ except PermissionDenied:
410
+ raise
411
+ except Exception as exc:
412
+ return idx, ToolResultBlock(
413
+ tool_use_id=tc.id,
414
+ content=f"Tool {tc.name} failed: {exc}",
415
+ is_error=True,
416
+ )
417
+
418
+ # 并发执行所有工具调用,每个工具完成后立即发送完成事件
419
+ tool_results: list[ToolResultBlock] = [None] * len(tool_calls)
420
+ for coro in asyncio.as_completed(
421
+ [_safe_run(i, tc) for i, tc in enumerate(tool_calls)]
422
+ ):
423
+ idx, result = await coro
424
+ tool_results[idx] = result
425
+ yield ToolExecutionCompleted(
426
+ tool_name=tool_calls[idx].name,
427
+ output=result.text_content,
428
+ is_error=result.is_error,
429
+ tool_use_id=tool_calls[idx].id,
430
+ ), None
431
+ except PermissionDenied as exc:
432
+ from illusion.config.i18n import t
433
+ yield ErrorEvent(message=t("permission_denied_stopped", tool=exc.tool_name)), None
434
+ return
435
+
436
+ # 输出工具链完成事件
437
+ yield ToolChainCompleted(
438
+ results_summary=[
439
+ {"name": tc.name, "is_error": result.is_error}
440
+ for tc, result in zip(tool_calls, tool_results)
441
+ ]
442
+ ), None
443
+
444
+ # 将工具结果作为用户消息添加到历史记录
445
+ messages.append(ConversationMessage(role="user", content=tool_results))
446
+
447
+ # 超出最大轮次限制
448
+ if context.max_turns is not None:
449
+ raise MaxTurnsExceeded(context.max_turns)
450
+ raise RuntimeError("Query loop exited without a max_turns limit or final response")
451
+
452
+
453
+ async def _execute_tool_call(
454
+ context: QueryContext,
455
+ tool_name: str,
456
+ tool_use_id: str,
457
+ tool_input: dict[str, object],
458
+ ) -> ToolResultBlock:
459
+ """执行单个工具调用。
460
+
461
+ 执行权限检查、参数验证和钩子处理,然后调用工具并返回结果。
462
+
463
+ Args:
464
+ context: 查询上下文
465
+ tool_name: 工具名称
466
+ tool_use_id: 工具调用ID
467
+ tool_input: 工具输入参数
468
+
469
+ Returns:
470
+ ToolResultBlock: 工具执行结果
471
+ """
472
+ # 执行预工具钩子
473
+ if context.hook_executor is not None:
474
+ pre_hooks = await context.hook_executor.execute(
475
+ HookEvent.PRE_TOOL_USE,
476
+ {"tool_name": tool_name, "tool_input": tool_input, "event": HookEvent.PRE_TOOL_USE.value},
477
+ )
478
+ if pre_hooks.blocked:
479
+ return ToolResultBlock(
480
+ tool_use_id=tool_use_id,
481
+ content=pre_hooks.reason or f"pre_tool_use hook blocked {tool_name}",
482
+ is_error=True,
483
+ )
484
+
485
+ # 从注册表获取工具
486
+ tool = context.tool_registry.get(tool_name)
487
+ if tool is None:
488
+ return ToolResultBlock(
489
+ tool_use_id=tool_use_id,
490
+ content=f"Unknown tool: {tool_name}",
491
+ is_error=True,
492
+ )
493
+
494
+ # 验证工具输入参数
495
+ try:
496
+ parsed_input = tool.input_model.model_validate(tool_input)
497
+ except Exception as exc:
498
+ return ToolResultBlock(
499
+ tool_use_id=tool_use_id,
500
+ content=f"Invalid input for {tool_name}: {exc}",
501
+ is_error=True,
502
+ )
503
+
504
+ # 在权限检查前规范化通用工具输入,以便路径规则一致地应用于使用 `file_path` 或 `path` 的内置工具
505
+ _file_path = _resolve_permission_file_path(context.cwd, tool_input, parsed_input)
506
+ _command = _extract_permission_command(tool_input, parsed_input)
507
+ # 评估权限
508
+ decision = context.permission_checker.evaluate(
509
+ tool_name,
510
+ is_read_only=tool.is_read_only(parsed_input),
511
+ file_path=_file_path,
512
+ command=_command,
513
+ )
514
+ if not decision.allowed:
515
+ # 系统自动阻止(如计划模式):返回错误结果给模型,不终止查询循环
516
+ if decision.auto_blocked:
517
+ return ToolResultBlock(
518
+ tool_use_id=tool_use_id,
519
+ content=f"[Permission blocked] {decision.reason or f'{tool_name} is not allowed in current mode'}",
520
+ is_error=True,
521
+ )
522
+ # 需要用户确认
523
+ if decision.requires_confirmation and context.permission_prompt is not None:
524
+ confirmed = await context.permission_prompt(tool_name, decision.reason)
525
+ if not confirmed:
526
+ raise PermissionDenied(tool_name, f"Permission denied for {tool_name}")
527
+ else:
528
+ raise PermissionDenied(tool_name, decision.reason or f"Permission denied for {tool_name}")
529
+
530
+ # 文件历史:工具执行前回调(备份即将被修改的文件)
531
+ if context.on_before_tool_execute is not None:
532
+ context.on_before_tool_execute(tool_name, tool_input)
533
+
534
+ # 执行工具
535
+ result = await tool.execute(
536
+ parsed_input,
537
+ ToolExecutionContext(
538
+ cwd=context.cwd,
539
+ metadata={
540
+ "tool_registry": context.tool_registry,
541
+ "ask_user_prompt": context.ask_user_prompt,
542
+ **(context.tool_metadata or {}),
543
+ },
544
+ ),
545
+ )
546
+ # 处理工具请求的 CWD 切换(如 enter_worktree)
547
+ if result.metadata.get("new_cwd"):
548
+ context.cwd = Path(result.metadata["new_cwd"])
549
+ tool_result = ToolResultBlock(
550
+ tool_use_id=tool_use_id,
551
+ content=_build_tool_result_content(result.output, result.metadata),
552
+ is_error=result.is_error,
553
+ )
554
+ # 执行后工具钩子
555
+ if context.hook_executor is not None:
556
+ await context.hook_executor.execute(
557
+ HookEvent.POST_TOOL_USE,
558
+ {
559
+ "tool_name": tool_name,
560
+ "tool_input": tool_input,
561
+ "tool_output": tool_result.text_content,
562
+ "tool_is_error": tool_result.is_error,
563
+ "event": HookEvent.POST_TOOL_USE.value,
564
+ },
565
+ )
566
+ return tool_result
567
+
568
+
569
+ def _resolve_permission_file_path(
570
+ cwd: Path,
571
+ raw_input: dict[str, object],
572
+ parsed_input: object,
573
+ ) -> str | None:
574
+ """解析权限检查所需的文件路径。
575
+
576
+ 尝试从原始输入和解析后的输入中提取文件路径。
577
+
578
+ Args:
579
+ cwd: 当前工作目录
580
+ raw_input: 原始工具输入
581
+ parsed_input: 解析后的工具输入
582
+
583
+ Returns:
584
+ str | None: 解析后的绝对文件路径,如果没有则返回None
585
+ """
586
+ # 首先检查原始输入中的 file_path 或 path
587
+ for key in ("file_path", "path"):
588
+ value = raw_input.get(key)
589
+ if isinstance(value, str) and value.strip():
590
+ path = Path(value).expanduser()
591
+ if not path.is_absolute():
592
+ path = cwd / path
593
+ return str(path.resolve())
594
+
595
+ # 然后检查解析后输入的属性
596
+ for attr in ("file_path", "path"):
597
+ value = getattr(parsed_input, attr, None)
598
+ if isinstance(value, str) and value.strip():
599
+ path = Path(value).expanduser()
600
+ if not path.is_absolute():
601
+ path = cwd / path
602
+ return str(path.resolve())
603
+
604
+ return None
605
+
606
+
607
+ def _extract_permission_command(
608
+ raw_input: dict[str, object],
609
+ parsed_input: object,
610
+ ) -> str | None:
611
+ """提取权限检查所需的命令。
612
+
613
+ 尝试从原始输入和解析后的输入中提取命令。
614
+
615
+ Args:
616
+ raw_input: 原始工具输入
617
+ parsed_input: 解析后的工具输入
618
+
619
+ Returns:
620
+ str | None: 命令字符串,如果没有则返回None
621
+ """
622
+ # 首先检查原始输入中的 command
623
+ value = raw_input.get("command")
624
+ if isinstance(value, str) and value.strip():
625
+ return value
626
+
627
+ # 然后检查解析后输入的 command 属性
628
+ value = getattr(parsed_input, "command", None)
629
+ if isinstance(value, str) and value.strip():
630
+ return value
631
+
632
+ return None