super-code-assistant 3.3.6__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 (61) hide show
  1. commands/__init__.py +859 -0
  2. core/__init__.py +0 -0
  3. core/config.py +263 -0
  4. core/config_template.json +7 -0
  5. core/context.py +271 -0
  6. core/engine.py +635 -0
  7. core/file_state.py +279 -0
  8. core/llm.py +309 -0
  9. core/model_capabilities.py +45 -0
  10. core/permissions.py +204 -0
  11. core/sandbox/__init__.py +15 -0
  12. core/sandbox/blacklist.py +176 -0
  13. core/sandbox/config.py +38 -0
  14. core/sandbox/network.py +136 -0
  15. core/sandbox/path_protection.py +126 -0
  16. core/session.py +295 -0
  17. core/tool.py +45 -0
  18. features/__init__.py +0 -0
  19. features/compact.py +945 -0
  20. features/coordinator.py +105 -0
  21. features/cost_tracker.py +184 -0
  22. features/extract_memories.py +326 -0
  23. features/find_relevant_memories.py +376 -0
  24. features/git_ai.py +256 -0
  25. features/memory.py +531 -0
  26. features/memory_age.py +66 -0
  27. features/memory_scan.py +153 -0
  28. features/memory_types.py +34 -0
  29. features/plan.py +327 -0
  30. features/skills.py +300 -0
  31. features/worker_manager.py +232 -0
  32. mcp/__init__.py +0 -0
  33. mcp/client.py +112 -0
  34. mcp/loader.py +80 -0
  35. mcp/tool_proxy.py +59 -0
  36. super_code_assistant-3.3.6.dist-info/METADATA +45 -0
  37. super_code_assistant-3.3.6.dist-info/RECORD +61 -0
  38. super_code_assistant-3.3.6.dist-info/WHEEL +5 -0
  39. super_code_assistant-3.3.6.dist-info/entry_points.txt +2 -0
  40. super_code_assistant-3.3.6.dist-info/top_level.txt +7 -0
  41. tools/__init__.py +21 -0
  42. tools/agent.py +132 -0
  43. tools/ask_user.py +111 -0
  44. tools/bash.py +77 -0
  45. tools/file_edit.py +269 -0
  46. tools/file_read.py +206 -0
  47. tools/file_write.py +78 -0
  48. tools/glob_tool.py +81 -0
  49. tools/grep_tool.py +134 -0
  50. tools/plan_tools.py +75 -0
  51. tools/skill.py +108 -0
  52. tools/tool.py +44 -0
  53. tools/web_fetch.py +129 -0
  54. tools/web_search.py +220 -0
  55. tui/__init__.py +0 -0
  56. tui/app.py +726 -0
  57. tui/clipboard_image.py +42 -0
  58. tui/keylistener.py +140 -0
  59. tui/prompt.py +752 -0
  60. tui/query.py +200 -0
  61. tui/rendering.py +135 -0
core/engine.py ADDED
@@ -0,0 +1,635 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ from typing import Iterator, Any
6
+
7
+ from core.llm import LLMClient
8
+ from core.tool import Tool, ToolResult
9
+ from core.permissions import PermissionChecker
10
+ from features.compact import (estimate_tokens, get_context_window,
11
+ prune_tool_results, PRUNE_RECENT_THRESHOLD_CHARS,
12
+ PRUNE_THRESHOLD_CHARS, reclaim_stale_read_results)
13
+
14
+
15
+ # Windows 终端粘贴 UTF-16 剪贴板时可能把代理对当成两个独立码点喂进 stdin,
16
+ # 后续 json.dumps(..., ensure_ascii=False) 写 UTF-8 JSONL 会抛
17
+ # UnicodeEncodeError: 'utf-8' codec can't encode ... : surrogates not allowed。
18
+ # 在 Engine.submit 入口统一替换为 U+FFFD,保证 _messages、磁盘、LLM 请求三处一致。
19
+ _LONE_SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
20
+
21
+ # 工具被拒文案。关键词 "STOP what you are doing and wait for the user" 让模型读完该 tool_result
22
+ # 后自然结束本轮、不再换其它工具继续骚扰用户——配合 deny → tool_result 路径
23
+ # 替代旧的 raise AbortedError + cancel_turn(旧行为会把整轮历史包括用户输入一起截掉)。
24
+ _REJECT_MESSAGE = (
25
+ "The user doesn't want to proceed with this tool use. The tool use was rejected "
26
+ "(eg. if it was a file edit, the new_string was NOT written to the file). "
27
+ "STOP what you are doing and wait for the user to tell you how to proceed."
28
+ )
29
+ # 同一批 tool_use 里某个被拒后,其余未处理 tool_use 走该文案——避免连续弹多次确认,
30
+ # 同时保证 tool_use ↔ tool_result 一一配对(不配对下一轮 LLM 调用会 400)。
31
+ _SIBLING_REJECT_MESSAGE = (
32
+ "Tool execution skipped because the user rejected an earlier tool call in this batch. "
33
+ "STOP what you are doing and wait for the user to tell you how to proceed."
34
+ )
35
+
36
+
37
+ # 轮内压缩触发比例:当估算 token 数达到 context window 的此比例时,在 while 循环
38
+ # 内紧急压缩历史消息。0.9 留 10% 余量给压缩后的 compact prompt。
39
+ _INTRA_TURN_COMPACT_TRIGGER_RATIO = 0.9
40
+
41
+
42
+ class AbortedError(Exception):
43
+ """Raised when the current turn is aborted by the user (Esc / Ctrl+C)."""
44
+
45
+
46
+ class Engine:
47
+ def __init__(self, tools: list[Tool], system_prompt: str,
48
+ permission_checker: PermissionChecker,
49
+ provider: str = "openai",
50
+ model: str = "gpt-4o",
51
+ max_tokens: int | None = None,
52
+ api_key: str | None = None,
53
+ base_url: str | None = None,
54
+ effort: str | None = None,
55
+ session_store=None,
56
+ cost_tracker=None,
57
+ repo_dir: str = "",
58
+ agent_session_id: str = "",
59
+ timeout: float = 300.0,
60
+ model_profiles: dict | None = None):
61
+ self._model = model
62
+ self._max_tokens = max_tokens or 131072
63
+ self._model_profiles = model_profiles or {}
64
+ self._client = LLMClient(provider=provider, api_key=api_key, base_url=base_url,
65
+ timeout=timeout, model_profiles=self._model_profiles)
66
+ self._tools = {t.name: t for t in tools}
67
+ self._system_prompt = system_prompt
68
+ self._permissions = permission_checker
69
+ self._messages: list[dict] = []
70
+ self._aborted = False
71
+ self._turn_start_len: int | None = None
72
+ self._active_stream = None
73
+ self._session_store = session_store
74
+ self._cost_tracker = cost_tracker # 费用追踪器,记录每次 API 调用的 token 用量
75
+ # git-ai 钩子参数:worker Engine 不挂 session_store,需要显式传;主 Engine 不传则回退 session_store
76
+ self._repo_dir_override = repo_dir
77
+ self._agent_session_id_override = agent_session_id
78
+ # 一次性回调列表:在每轮 tool_results append 到 _messages 之后触发并清空。
79
+ # 用于 plan_manager.exit() 延迟执行历史清理(避免在工具执行中途清理导致时序问题)。
80
+ self._post_tool_hooks: list = [] # 存储的是callable对象
81
+ # 轮内压缩服务:由 app.py 注入,用于在工具调用链中紧急压缩历史
82
+ self._compact_service = None
83
+ # worker 通知回调:由 app.py 注入,每轮工具执行完成后 drain 通知队列
84
+ self._on_after_tools = None
85
+ # Phase 3: 向 Edit/Read/Write 工具注入当前会话 ID
86
+ self._inject_session_id()
87
+
88
+ def get_messages(self) -> list[dict]:
89
+ return list(self._messages)
90
+
91
+ def last_assistant_text(self) -> str:
92
+ """返回最后一条 assistant 消息的纯文本内容,用于提取 <system_reminder> 标签。"""
93
+ for msg in reversed(self._messages):
94
+ if msg.get("role") != "assistant":
95
+ continue
96
+ content = msg.get("content", "")
97
+ if isinstance(content, list):
98
+ return " ".join(
99
+ b.get("text", "") for b in content
100
+ if isinstance(b, dict) and b.get("type") == "text"
101
+ )
102
+ return str(content) if content else ""
103
+ return ""
104
+
105
+ def set_messages(self, messages: list[dict]) -> None:
106
+ self._messages = []
107
+ for m in messages:
108
+ new_msg = {"role": m["role"], "content": m.get("content", "")}
109
+ # 保留 reasoning_content:DeepSeek 等思考模型要求原样带回下一轮
110
+ # (AGENTS.md 约定)。此前丢失会导致 resume/compact/fork 恢复后
111
+ # 上下文骤减(ctx 占比"下降"假象)且模型丢失历史思考链。
112
+ if m.get("reasoning_content"):
113
+ new_msg["reasoning_content"] = m["reasoning_content"]
114
+ self._messages.append(new_msg)
115
+
116
+ def set_session_store(self, session_store) -> None:
117
+ self._session_store = session_store
118
+ self._inject_session_id()
119
+
120
+ def set_compact_service(self, compact_service) -> None:
121
+ """注入 CompactService,供轮内紧急压缩使用。"""
122
+ self._compact_service = compact_service
123
+
124
+ def set_on_after_tools(self, callback) -> None:
125
+ """注入 worker 通知回调:每轮工具执行完成后调用,返回通知文本注入 _messages。"""
126
+ self._on_after_tools = callback
127
+
128
+ def _inject_session_id(self) -> None:
129
+ """Phase 3: 向支持 set_session_id 的工具注入当前会话 ID。"""
130
+ sid = ""
131
+ if self._session_store is not None:
132
+ sid = getattr(self._session_store, "session_id", "")
133
+ if not sid:
134
+ sid = self._agent_session_id_override or ""
135
+ for t in self._tools.values():
136
+ injector = getattr(t, "set_session_id", None)
137
+ if injector is not None:
138
+ injector(sid)
139
+
140
+ def rebuild_snippets_from_messages(self) -> int:
141
+ """Phase 3: 从当前 _messages 中扫描 tool_result metadata,重建 snippet 注册表。
142
+
143
+ 用于 /resume 恢复会话时还原文件状态和 snippet 缓存。
144
+ 返回重建的 snippet 数量。
145
+ """
146
+ from core.file_state import record_file_state, rebuild_snippet
147
+ count = 0
148
+ session_id = ""
149
+ if self._session_store is not None:
150
+ session_id = getattr(self._session_store, "session_id", "")
151
+ if not session_id:
152
+ session_id = self._agent_session_id_override or ""
153
+ if not session_id:
154
+ return 0
155
+
156
+ for msg in self._messages:
157
+ content = msg.get("content", "")
158
+ if not isinstance(content, list):
159
+ continue
160
+ for block in content:
161
+ if not isinstance(block, dict):
162
+ continue
163
+ if block.get("type") != "tool_result":
164
+ continue
165
+ meta = block.get("metadata")
166
+ if not isinstance(meta, dict):
167
+ continue
168
+
169
+ snippet_id = meta.get("snippet_id") or meta.get("new_snippet_id")
170
+ if not snippet_id:
171
+ continue
172
+
173
+ fp = meta.get("file_path", "")
174
+ sl = meta.get("start_line")
175
+ el = meta.get("end_line")
176
+ st = meta.get("scope_type", "full")
177
+ if not fp or sl is None or el is None:
178
+ continue
179
+
180
+ # record_file_state 从当前磁盘重建(如果文件存在)
181
+ from pathlib import Path as _Path
182
+ p = _Path(fp)
183
+ if p.exists() and p.is_file():
184
+ try:
185
+ stat = p.stat()
186
+ content_text = p.read_text(encoding="utf-8", errors="replace")
187
+ record_file_state(session_id, fp, content_text, stat.st_mtime)
188
+ except Exception:
189
+ pass
190
+
191
+ rebuild_snippet(session_id, snippet_id, fp, int(sl), int(el), str(st))
192
+ count += 1
193
+
194
+ return count
195
+
196
+ def set_tools(self, tools: list[Tool]) -> None:
197
+ self._tools = {t.name: t for t in tools}
198
+
199
+ @property
200
+ def system_prompt(self) -> str:
201
+ return self._system_prompt
202
+
203
+ @system_prompt.setter
204
+ def system_prompt(self, value: str) -> None:
205
+ self._system_prompt = value or ""
206
+
207
+ def abort(self):
208
+ self._aborted = True
209
+ if self._active_stream is not None:
210
+ try:
211
+ self._active_stream.close()
212
+ except Exception:
213
+ pass
214
+
215
+ def cancel_turn(self):
216
+ if self._turn_start_len is not None:
217
+ del self._messages[self._turn_start_len:]
218
+ self._turn_start_len = None
219
+ # 同步把磁盘 JSONL 截回 turn 开始时记录的 checkpoint:避免被 Ctrl+C 中断的轮次
220
+ # 在磁盘留下孤立 tool_use(缺对应 tool_result),导致下次 /resume 报
221
+ # 'Messages with role tool must be a response to a preceding message with tool_calls'
222
+ if self._session_store:
223
+ self._session_store.rollback_to_checkpoint()
224
+
225
+ def _intra_turn_compact(self) -> bool:
226
+ """压缩 _turn_start_len 之前的历史消息,保留本轮消息原封不动。
227
+
228
+ 仅在 _compact_service 已注入、且有足够历史消息时才执行压缩。
229
+ 成功后更新 _turn_start_len 指向新 messages 中本轮开始的位置,
230
+ 确保 cancel_turn() 仍能正确截断。
231
+
232
+ Returns:
233
+ True 如果压缩成功执行,False 如果跳过(无压缩服务/历史不足/压缩失败)。
234
+ """
235
+ if self._compact_service is None:
236
+ return False
237
+ if self._turn_start_len is None or self._turn_start_len <= 0:
238
+ return False
239
+
240
+ history = self._messages[:self._turn_start_len]
241
+ current_turn = self._messages[self._turn_start_len:]
242
+ did_something = False
243
+
244
+ # 先裁本轮超长 tool_result:无论历史多少条都执行,零 LLM 调用
245
+ # 修复:巨型 Bash/Grep 输出堆在 current_turn 里,原先永远不被裁剪
246
+ pruned_ct, ct_stats = prune_tool_results(
247
+ current_turn, threshold_chars=PRUNE_RECENT_THRESHOLD_CHARS
248
+ )
249
+ if ct_stats["pruned"] > 0:
250
+ current_turn = pruned_ct
251
+ self._messages = history + current_turn
252
+ did_something = True
253
+
254
+ # 历史消息太少,不值得走 LLM 摘要;但上面的 current_turn 裁剪仍会执行
255
+ if len(history) < 10:
256
+ return did_something
257
+
258
+ try:
259
+ new_history, _summary = self._compact_service.compact(
260
+ messages=history,
261
+ system_prompt=self._system_prompt,
262
+ # 轮内紧急压缩传 True:剪枝后若已低于自动触发阈值(0.8×窗口),
263
+ # 跳过 LLM 摘要直接返回剪枝结果——轮内场景多为"单轮读大文件"导致,
264
+ # 剪枝往往已经够用,无需再付一次摘要调用。
265
+ skip_if_under_threshold=True,
266
+ )
267
+ self._messages = new_history + current_turn
268
+ # 更新 _turn_start_len,保证 cancel_turn() 截断到正确位置
269
+ self._turn_start_len = len(new_history)
270
+ did_something = True
271
+ except Exception:
272
+ # 压缩失败时静默继续——下一次 LLM 调用可能因超 context window 失败,
273
+ # 但至少不因压缩异常而中断用户操作。
274
+ pass
275
+ return did_something
276
+
277
+ def submit(self, user_input: str | list) -> Iterator[tuple]:
278
+ # 清洗 lone surrogate(仅 str 路径,list 路径由内部构造不会含非法码点)
279
+ if isinstance(user_input, str):
280
+ user_input = _LONE_SURROGATE_RE.sub("�", user_input)
281
+ self._aborted = False
282
+ self._turn_start_len = len(self._messages)
283
+ # 记录本轮 JSONL 的字节位置作为 checkpoint;和 _turn_start_len 配对:
284
+ # 一个守内存、一个守磁盘。本调用必须在 user_msg 持久化之前,否则截不掉 user_msg。
285
+ if self._session_store:
286
+ self._session_store.mark_checkpoint()
287
+ user_msg = {"role": "user", "content": user_input}
288
+ self._messages.append(user_msg)
289
+ if self._session_store:
290
+ self._session_store.append_message(user_msg)
291
+
292
+ try:
293
+ _compact_done = False # 每次 LLM 调用最多触发一次轮内压缩,防止重复循环
294
+ while True:
295
+ if self._aborted:
296
+ raise AbortedError()
297
+
298
+ # ── 轮内令牌守卫:消息量接近窗口上限时紧急压缩历史 ──
299
+ # 弥补轮间 compact 无法覆盖「单轮内连续读大文件导致消息暴涨」的盲区。
300
+ # 阈值设 0.9(而非 1.0),给压缩后的 compact prompt 留余量。
301
+ estimated = estimate_tokens(self._messages)
302
+ threshold = int(get_context_window(self._model) * _INTRA_TURN_COMPACT_TRIGGER_RATIO)
303
+ if estimated > threshold and not _compact_done:
304
+ yield ("compact",)
305
+ self._intra_turn_compact()
306
+ _compact_done = True
307
+ continue # RC4:压缩后重新估算,避免压完仍超限就直接发请求
308
+
309
+ # RC4 兜底:continue 重估后仍超限(估算失真场景),对 current_turn 用更激进的
310
+ # 阈值强剪——每条 tool_result 最多保留头尾 ~5K 字符,确保不裸奔发超限请求
311
+ if estimated > threshold and self._turn_start_len is not None:
312
+ hard_pruned, _ = prune_tool_results(
313
+ self._messages[self._turn_start_len:],
314
+ threshold_chars=PRUNE_THRESHOLD_CHARS,
315
+ )
316
+ self._messages = self._messages[:self._turn_start_len] + hard_pruned
317
+
318
+ # 进入实际 LLM 调用,重置标志(下一轮工具迭代可再次压缩)
319
+ _compact_done = False
320
+ tool_uses = []
321
+ tools_schema = [t.to_api_schema() for t in self._tools.values()] if self._tools else None
322
+
323
+ # ── 过期 Read 结果回收(发送副本,不污染 _messages)──
324
+ # 文件被后续 Edit/Write 修改过的旧 Read tool_result 对模型已无用(旧版本),
325
+ # 替换为短标记(保留 snippet 定位信息)。省 token 且避免模型基于旧版本思考。
326
+ # 只作用于发送副本:_messages 与磁盘 JSONL 保留原始完整内容,/resume 可重放。
327
+ # fail-closed:无 session_id 或内部查询异常 → 原样发送。
328
+ _send_messages = self._messages
329
+ _reclaim_sid = self._agent_session_id_override or (
330
+ self._session_store.session_id if self._session_store else "")
331
+ if _reclaim_sid:
332
+ try:
333
+ _send_messages, _reclaim_stats = reclaim_stale_read_results(
334
+ self._messages, _reclaim_sid)
335
+ if _reclaim_stats["reclaimed"] > 0:
336
+ # 有实际回收 → yield 事件让 UI 显示一行话术(用户可感知)
337
+ yield ("stale_reclaim", _reclaim_stats)
338
+ except Exception:
339
+ _send_messages = self._messages # 回收失败不影响主流程
340
+
341
+ with self._client.stream(
342
+ model=self._model,
343
+ system_prompt=self._system_prompt,
344
+ messages=_send_messages,
345
+ tools=tools_schema,
346
+ max_tokens=self._max_tokens,
347
+ ) as stream:
348
+ self._active_stream = stream
349
+ got_text = False
350
+ waiting_sent = False
351
+ # Esc 路径:abort() 在子线程关 stream → 主线程 for 循环里抛
352
+ # httpx.RemoteProtocolError 等网络异常。仅当 _aborted=True 时
353
+ # 翻译为 AbortedError,让 query.py 走干净的取消路径;
354
+ # 非 abort 情况下的真实网络故障保持原样抛出。
355
+ try:
356
+ for text in stream:
357
+ if self._aborted:
358
+ raise AbortedError()
359
+ if text.startswith("\x00thinking\x00"):
360
+ yield ("thinking",)
361
+ continue
362
+ if text.startswith("\x00toolgen\x00"):
363
+ if got_text and not waiting_sent:
364
+ yield ("waiting",)
365
+ waiting_sent = True
366
+ continue
367
+ got_text = True
368
+ yield ("text", text)
369
+ except AbortedError:
370
+ raise
371
+ except Exception:
372
+ if self._aborted:
373
+ raise AbortedError()
374
+ raise
375
+
376
+ if self._aborted:
377
+ raise AbortedError()
378
+ if got_text and not waiting_sent:
379
+ yield ("waiting",)
380
+
381
+ final = stream.final()
382
+ # 记录本次 API 调用的 token 用量
383
+ if self._cost_tracker and final.usage:
384
+ self._cost_tracker.add_usage(self._model, final.usage)
385
+ if final.content and isinstance(final.content, list):
386
+ for block in final.content:
387
+ if _block_type(block) == "tool_use":
388
+ tool_uses.append(block)
389
+
390
+ self._active_stream = None
391
+ asst_msg: dict = {"role": "assistant", "content": final.content}
392
+ # 保留 reasoning_content,DeepSeek 等思考模型要求下一轮原样带回
393
+ if final.reasoning_content:
394
+ asst_msg["reasoning_content"] = final.reasoning_content
395
+ self._messages.append(asst_msg)
396
+ if self._session_store:
397
+ self._session_store.append_message(asst_msg)
398
+
399
+ if not tool_uses:
400
+ break
401
+
402
+ tool_results = []
403
+ # 本轮是否发生过 deny。任一 batch 出现 deny 即置 True,本轮 LLM 回复
404
+ # 完成后立即 break 外层 while——硬切断"模型不听 REJECT_MESSAGE 里 STOP
405
+ # 指令、继续换工具骚扰用户"的路径(DeepSeek 等指令服从度较弱的模型实测
406
+ # 会这样)。模型仍然能在被拒的下一轮回一句自然语言(用户看到"好的已取消"),
407
+ # 但永远没机会再调任何工具。
408
+ turn_had_deny = False
409
+ batches: list[tuple[bool, list[Any]]] = []
410
+ for tu in tool_uses:
411
+ t = self._tools.get(_block_name(tu))
412
+ is_concurrent = t is not None and t.is_read_only()
413
+ if batches and batches[-1][0] == is_concurrent and is_concurrent:
414
+ batches[-1][1].append(tu)
415
+ else:
416
+ batches.append((is_concurrent, [tu]))
417
+
418
+ for is_concurrent, batch in batches: # 依次处理batches里面的每个元素
419
+ if self._aborted:
420
+ raise AbortedError()
421
+ # 同 turn 内之前的 batch 已经出现 deny → 后续所有 batch 全部 sibling reject
422
+ # 处理,跳过权限确认。原因:串行工具按 batch 合并规则每个 tool_use 占独立
423
+ # batch(is_concurrent=False 不合并),sibling_rejected 标志只在 batch 内
424
+ # 有效,跨 batch 失效——必须在外层用 turn_had_deny 兜底,否则模型一次
425
+ # 生成 [Bash1, Bash2, Bash3]、用户对 Bash2 点 No 后,Bash3 还会再弹一次。
426
+ if turn_had_deny:
427
+ for tu in batch:
428
+ tid, tn, ti = _block_id(tu), _block_name(tu), _block_input(tu)
429
+ tool = self._tools.get(tn)
430
+ act = tool.get_activity_description(**ti) if tool else None
431
+ yield ("tool_call", tn, ti, act, tid)
432
+ result = ToolResult(_SIBLING_REJECT_MESSAGE, is_error=True)
433
+ yield ("tool_result", tn, ti, result, tid)
434
+ tool_results.append({"type": "tool_result", "tool_use_id": tid,
435
+ "content": result.content, "is_error": result.is_error,
436
+ "metadata": result.metadata})
437
+ continue
438
+
439
+ if is_concurrent and len(batch) > 1:
440
+ approved = []
441
+ denied_results: dict[str, ToolResult] = {}
442
+ # 一旦本批出现 deny,剩余 tool_use 全部直接标记 sibling reject,
443
+ # 不再弹权限确认——既保证 tool_use ↔ tool_result 配对(不配对 LLM 400),
444
+ # 又避免用户连续被弹多次"是否允许"对话框。
445
+ sibling_rejected = False
446
+ for tu in batch:
447
+ tid, tn, ti = _block_id(tu), _block_name(tu), _block_input(tu)
448
+ tool = self._tools.get(tn)
449
+ act = tool.get_activity_description(**ti) if tool else None
450
+ # 事件 tuple 第5位加入 tool_use_id,供 TUI 用唯一 id 追踪工具状态,
451
+ # 避免同名工具(如两个 Grep)因 key 碰撞导致 pending_tools 无法清空
452
+ yield ("tool_call", tn, ti, act, tid)
453
+ if sibling_rejected:
454
+ denied_results[tid] = ToolResult(_SIBLING_REJECT_MESSAGE, is_error=True)
455
+ continue
456
+ if tool and self._permissions.check(tool, ti) == "deny":
457
+ # 旧行为是 raise AbortedError() → cancel_turn() 截掉整轮历史
458
+ # (含用户原始输入),UX 上像"系统失忆"。改为构造 is_error 的
459
+ # tool_result 让 turn 自然走完一轮:模型读到 REJECT_MESSAGE
460
+ # 里的 "STOP and wait for the user" 会自然结束本轮。
461
+ denied_results[tid] = ToolResult(_REJECT_MESSAGE, is_error=True)
462
+ sibling_rejected = True
463
+ turn_had_deny = True
464
+ else:
465
+ approved.append((tu, tool, act))
466
+
467
+ executed_results: dict[str, ToolResult] = {}
468
+ if approved:
469
+ for tu, tool, act in approved:
470
+ yield ("tool_executing", _block_name(tu), _block_input(tu), act, _block_id(tu))
471
+ with ThreadPoolExecutor(max_workers=min(len(approved), 10)) as pool:
472
+ futures = {pool.submit(self._execute_tool, tu): tu for tu, _, _ in approved}
473
+ for f in as_completed(futures):
474
+ tu = futures[f]
475
+ try:
476
+ executed_results[_block_id(tu)] = f.result()
477
+ except Exception as exc:
478
+ executed_results[_block_id(tu)] = ToolResult(f"Tool execution error: {exc}", is_error=True)
479
+
480
+ for tu in batch:
481
+ tid, tn, ti = _block_id(tu), _block_name(tu), _block_input(tu)
482
+ result = denied_results.get(tid) or executed_results.get(tid) or ToolResult("No result", is_error=True)
483
+ yield ("tool_result", tn, ti, result, tid)
484
+ tool_results.append({"type": "tool_result", "tool_use_id": tid,
485
+ "content": result.content, "is_error": result.is_error,
486
+ "metadata": result.metadata})
487
+ else:
488
+ # 串行批次:用 sibling_rejected 标志跟并发批次同语义——一旦本批
489
+ # 出现 deny,剩下 tool_use 全部生成 sibling reject 占位 tool_result,
490
+ # 保证 tool_use ↔ tool_result 配对,同时不再弹后续确认。
491
+ sibling_rejected = False
492
+ for tu in batch:
493
+ if self._aborted:
494
+ raise AbortedError()
495
+ tid, tn, ti = _block_id(tu), _block_name(tu), _block_input(tu)
496
+ tool = self._tools.get(tn)
497
+ act = tool.get_activity_description(**ti) if tool else None
498
+ yield ("tool_call", tn, ti, act, tid)
499
+ if sibling_rejected:
500
+ result = ToolResult(_SIBLING_REJECT_MESSAGE, is_error=True)
501
+ elif tool and self._permissions.check(tool, ti) == "deny":
502
+ # 见并发分支同位置注释:把 raise AbortedError 替换成构造
503
+ # is_error tool_result,让 turn 自然走完一轮、保留历史。
504
+ result = ToolResult(_REJECT_MESSAGE, is_error=True)
505
+ sibling_rejected = True
506
+ turn_had_deny = True
507
+ else:
508
+ yield ("tool_executing", tn, ti, act, tid)
509
+ result = self._execute_tool(tu)
510
+ yield ("tool_result", tn, ti, result, tid)
511
+ tool_results.append({"type": "tool_result", "tool_use_id": tid,
512
+ "content": result.content, "is_error": result.is_error,
513
+ "metadata": result.metadata})
514
+
515
+ # 防御性配对兜底:上面所有分支都应保证 tool_uses ↔ tool_results 一一对齐,
516
+ # 但任何后续 refactor 漏一条分支就会让下一轮 LLM 调用收到
517
+ # "tool_use 缺对应 tool_result" 的 400 错误、整个会话死锁(CC 也踩过同类坑,
518
+ # 见 utils/messages.ts:ensureToolResultPairing 注释 CC-1212)。这里做一次
519
+ # 廉价兜底:发现缺失就补占位 tool_result,让会话存活;正常路径走不到。
520
+ _expected_ids = {_block_id(_tu) for _tu in tool_uses}
521
+ _actual_ids = {_tr["tool_use_id"] for _tr in tool_results}
522
+ for _mid in _expected_ids - _actual_ids:
523
+ tool_results.append({"type": "tool_result", "tool_use_id": _mid,
524
+ "content": "Tool execution skipped (internal sibling cancellation).",
525
+ "is_error": True, "metadata": None})
526
+
527
+ self._messages.append({"role": "user", "content": tool_results})
528
+ if self._session_store:
529
+ self._session_store.append_message({"role": "user", "content": tool_results})
530
+ # 触发一次性 post_tool_hooks(如 plan_manager 的延迟历史清理)
531
+ # 必须在 tool_results append 之后执行,确保清理逻辑能看到完整的本轮记录
532
+ if self._post_tool_hooks:
533
+ for hook in self._post_tool_hooks:
534
+ hook()
535
+ self._post_tool_hooks.clear()
536
+
537
+ # ── Mid-turn worker 通知注入 ──
538
+ # 在工具执行完成后、下一轮 LLM 调用前,检查 worker 完成通知。
539
+ # 有通知则作为 user message 注入 _messages,while 循环自然继续,
540
+ # 下一轮 LLM 调用自动看到通知内容。不需要递归 run_query。
541
+ if self._on_after_tools is not None:
542
+ injected = self._on_after_tools()
543
+ if injected:
544
+ self._messages.append({"role": "user", "content": injected})
545
+ if self._session_store:
546
+ self._session_store.append_message(
547
+ {"role": "user", "content": injected})
548
+ yield ("notification", injected)
549
+
550
+ # 硬约束:本轮发生过 deny → 立即 break 出 while,不再走下一轮 LLM 调用。
551
+ # 专治 DeepSeek 等模型不听 REJECT_MESSAGE 里 STOP 指令、继续换工具骚扰
552
+ # 用户的情况。tool_result 已 append 进 _messages,下一次用户输入时模型
553
+ # 自然会看到本轮被拒的上下文。用户拒绝的视觉反馈靠 ✗ 红字行已足够,
554
+ # 不需要再让模型生成"好的已取消"——后者反而占 token + 让 DeepSeek 有
555
+ # 机会调更多工具。yield 一个事件让 TUI 打印一行"已取消"作为收尾提示,
556
+ # 避免用户看到一堆 ✗ 后突然回到输入框的疑惑感。
557
+ if turn_had_deny:
558
+ yield ("turn_aborted_by_deny",)
559
+ break
560
+ except AbortedError:
561
+ self.cancel_turn()
562
+ raise
563
+ finally:
564
+ self._active_stream = None
565
+
566
+ def _execute_tool(self, tool_use) -> ToolResult:
567
+ tool = self._tools.get(_block_name(tool_use))
568
+ if tool is None:
569
+ return ToolResult(f"Unknown tool: {_block_name(tool_use)}", is_error=True)
570
+
571
+ tool_name = _block_name(tool_use)
572
+ tool_input = _block_input(tool_use)
573
+
574
+ # 只对写操作工具触发 git-ai checkpoint,读操作跳过
575
+ from features.git_ai import WRITE_TOOLS, before_edit, after_edit
576
+ is_write = tool_name in WRITE_TOOLS
577
+ file_path = tool_input.get("file_path", "") if is_write else ""
578
+ repo_dir = self._repo_dir_override or (
579
+ self._session_store.cwd if self._session_store else "")
580
+
581
+ # Edit 的 file_path 是可选字段,LLM 通常只传 snippet_id 不传 file_path。
582
+ # 若缺失则通过 snippet_id 查文件状态拿到真实路径,否则 checkpoint 会发
583
+ # edited_filepaths=[] 导致 git-ai 无法归属本次修改。
584
+ if is_write and not file_path and tool_name == "Edit":
585
+ _snippet_id = tool_input.get("snippet_id", "")
586
+ if _snippet_id:
587
+ from core.file_state import get_snippet
588
+ _sess = self._agent_session_id_override or (
589
+ self._session_store.session_id if self._session_store else "unknown")
590
+ _snip = get_snippet(_sess, _snippet_id)
591
+ if _snip:
592
+ file_path = _snip.file_path
593
+
594
+ if is_write and repo_dir:
595
+ before_edit(repo_dir, file_path)
596
+
597
+ try:
598
+ result = tool.execute(**tool_input)
599
+ except Exception as e:
600
+ return ToolResult(f"Tool error: {e}", is_error=True)
601
+
602
+ if is_write and repo_dir and not result.is_error:
603
+ # 兜底:Edit 成功时 metadata 里也带有 file_path
604
+ resolved_path = file_path or (result.metadata or {}).get("file_path", "")
605
+ session_id = self._agent_session_id_override or (
606
+ self._session_store.session_id if self._session_store else "unknown")
607
+ after_edit(repo_dir, resolved_path, self._messages, self._model, session_id)
608
+
609
+ return result
610
+
611
+
612
+ def _block_type(block: Any) -> str | None:
613
+ if isinstance(block, dict):
614
+ return block.get("type")
615
+ return getattr(block, "type", None)
616
+
617
+
618
+ def _block_name(block: Any) -> str:
619
+ if isinstance(block, dict):
620
+ return str(block.get("name", ""))
621
+ return str(getattr(block, "name", ""))
622
+
623
+
624
+ def _block_id(block: Any) -> str:
625
+ if isinstance(block, dict):
626
+ return str(block.get("id", ""))
627
+ return str(getattr(block, "id", ""))
628
+
629
+
630
+ def _block_input(block: Any) -> dict[str, Any]:
631
+ if isinstance(block, dict):
632
+ value = block.get("input", {})
633
+ else:
634
+ value = getattr(block, "input", {})
635
+ return value if isinstance(value, dict) else {}