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.
- commands/__init__.py +859 -0
- core/__init__.py +0 -0
- core/config.py +263 -0
- core/config_template.json +7 -0
- core/context.py +271 -0
- core/engine.py +635 -0
- core/file_state.py +279 -0
- core/llm.py +309 -0
- core/model_capabilities.py +45 -0
- core/permissions.py +204 -0
- core/sandbox/__init__.py +15 -0
- core/sandbox/blacklist.py +176 -0
- core/sandbox/config.py +38 -0
- core/sandbox/network.py +136 -0
- core/sandbox/path_protection.py +126 -0
- core/session.py +295 -0
- core/tool.py +45 -0
- features/__init__.py +0 -0
- features/compact.py +945 -0
- features/coordinator.py +105 -0
- features/cost_tracker.py +184 -0
- features/extract_memories.py +326 -0
- features/find_relevant_memories.py +376 -0
- features/git_ai.py +256 -0
- features/memory.py +531 -0
- features/memory_age.py +66 -0
- features/memory_scan.py +153 -0
- features/memory_types.py +34 -0
- features/plan.py +327 -0
- features/skills.py +300 -0
- features/worker_manager.py +232 -0
- mcp/__init__.py +0 -0
- mcp/client.py +112 -0
- mcp/loader.py +80 -0
- mcp/tool_proxy.py +59 -0
- super_code_assistant-3.3.6.dist-info/METADATA +45 -0
- super_code_assistant-3.3.6.dist-info/RECORD +61 -0
- super_code_assistant-3.3.6.dist-info/WHEEL +5 -0
- super_code_assistant-3.3.6.dist-info/entry_points.txt +2 -0
- super_code_assistant-3.3.6.dist-info/top_level.txt +7 -0
- tools/__init__.py +21 -0
- tools/agent.py +132 -0
- tools/ask_user.py +111 -0
- tools/bash.py +77 -0
- tools/file_edit.py +269 -0
- tools/file_read.py +206 -0
- tools/file_write.py +78 -0
- tools/glob_tool.py +81 -0
- tools/grep_tool.py +134 -0
- tools/plan_tools.py +75 -0
- tools/skill.py +108 -0
- tools/tool.py +44 -0
- tools/web_fetch.py +129 -0
- tools/web_search.py +220 -0
- tui/__init__.py +0 -0
- tui/app.py +726 -0
- tui/clipboard_image.py +42 -0
- tui/keylistener.py +140 -0
- tui/prompt.py +752 -0
- tui/query.py +200 -0
- 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 {}
|