Skip to content

Commit e5e8e20

Browse files
WilliamWilliam
authored andcommitted
Fix chat leak, repeat, silent thinking stalls, and result paging.
Keep unique-call and file-wrap protocol out of the user bubble, collapse drifting planning loops, and do not finalize on unread spilled handles or in-progress narration. LLM heartbeats and round caps use visible tokens only so reasoning floods cannot hide a live run; WebSocket idle wins only for real tombstones. Session-scoped result handles survive kernel process drop, Tavily runs as a builtin stdio MCP, and file_read is evidence work rather than a self-check wrap.
1 parent 11d47ab commit e5e8e20

87 files changed

Lines changed: 5327 additions & 340 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎backend/agent/context_pipeline.py‎

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,148 @@ def build_compact_continuation_message(
183183
return "\n".join(parts).strip()
184184

185185

186+
187+
_PRIOR_TURN_TOOLS_MARK = "[prior turn tools]"
188+
_PRIOR_TURN_MAX_LINES = 20
189+
190+
191+
def _tool_name_from_call(tc: Any) -> str:
192+
if not isinstance(tc, dict):
193+
return "tool"
194+
fn = tc.get("function") if isinstance(tc.get("function"), dict) else {}
195+
name = (fn or {}).get("name") or tc.get("name") or "tool"
196+
return str(name).strip() or "tool"
197+
198+
199+
def _one_line_outcome(content: Any, limit: int = 72) -> str:
200+
s = " ".join(str(content or "").split())
201+
if not s:
202+
return "ok"
203+
if len(s) > limit:
204+
return s[: limit - 1] + "…"
205+
return s
206+
207+
208+
def collapse_prior_turn_tool_traces(
209+
messages: list[dict[str, Any]],
210+
) -> tuple[list[dict[str, Any]], int]:
211+
"""Replace completed prior-turn assistant+tool pairs with a short summary.
212+
213+
Keeps the latest user turn (from the last ``user`` message on) intact so
214+
the in-flight tool chain stays valid. Earlier turns keep user text and
215+
final assistant replies; raw tool traces become
216+
``[prior turn tools] name: outcome``.
217+
"""
218+
if not messages or len(messages) < 4:
219+
return messages, 0
220+
last_user = -1
221+
for i, m in enumerate(messages):
222+
if isinstance(m, dict) and m.get("role") == "user":
223+
last_user = i
224+
if last_user <= 0:
225+
return messages, 0
226+
prior = messages[:last_user]
227+
latest = messages[last_user:]
228+
prior_tools = 0
229+
for m in prior:
230+
if not isinstance(m, dict):
231+
continue
232+
if m.get("role") == "tool" or (m.get("role") == "assistant" and m.get("tool_calls")):
233+
prior_tools += 1
234+
if prior_tools < 1:
235+
return messages, 0
236+
237+
out: list[dict[str, Any]] = []
238+
pending: list[tuple[str, str]] = []
239+
240+
def flush() -> None:
241+
nonlocal pending
242+
if not pending:
243+
return
244+
lines: list[str] = []
245+
extra = 0
246+
for name, outcome in pending:
247+
if len(lines) < _PRIOR_TURN_MAX_LINES:
248+
lines.append(f"- {name}: {outcome}")
249+
else:
250+
extra += 1
251+
if extra:
252+
lines.append(f"- … +{extra} more")
253+
out.append(
254+
{
255+
"role": "assistant",
256+
"content": (
257+
f"{_PRIOR_TURN_TOOLS_MARK} ×{len(pending)}\n" + "\n".join(lines)
258+
),
259+
}
260+
)
261+
pending = []
262+
263+
i = 0
264+
n_prior = len(prior)
265+
while i < n_prior:
266+
m = prior[i]
267+
if not isinstance(m, dict):
268+
flush()
269+
out.append(m) # type: ignore[arg-type]
270+
i += 1
271+
continue
272+
role = m.get("role")
273+
tcs = m.get("tool_calls")
274+
if role == "assistant" and tcs:
275+
id_to_name: dict[str, str] = {}
276+
if isinstance(tcs, dict):
277+
inner = tcs.get("calls") or tcs.get("tool_calls") or []
278+
tcs_iter = inner if isinstance(inner, list) else []
279+
else:
280+
tcs_iter = tcs if isinstance(tcs, list) else []
281+
for tc in tcs_iter:
282+
if not isinstance(tc, dict):
283+
continue
284+
name = _tool_name_from_call(tc)
285+
tid = tc.get("id")
286+
if tid:
287+
id_to_name[str(tid)] = name
288+
i += 1
289+
saw_result = False
290+
while i < n_prior:
291+
nxt = prior[i]
292+
if not isinstance(nxt, dict) or nxt.get("role") != "tool":
293+
break
294+
tid = nxt.get("tool_call_id")
295+
name = id_to_name.get(str(tid) if tid else "", "") or "tool"
296+
pending.append((name, _one_line_outcome(nxt.get("content"))))
297+
saw_result = True
298+
i += 1
299+
if not saw_result:
300+
names = list(id_to_name.values()) or [
301+
_tool_name_from_call(tc)
302+
for tc in tcs_iter
303+
if isinstance(tc, dict)
304+
]
305+
for name in names:
306+
pending.append((name, "(no result)"))
307+
continue
308+
if role == "tool":
309+
pending.append(
310+
(
311+
str(m.get("name") or m.get("tool_name") or "tool"),
312+
_one_line_outcome(m.get("content")),
313+
)
314+
)
315+
i += 1
316+
continue
317+
flush()
318+
out.append(m)
319+
i += 1
320+
flush()
321+
collapsed = out + list(latest)
322+
removed = max(0, len(messages) - len(collapsed))
323+
if removed <= 0:
324+
return messages, 0
325+
return collapsed, removed
326+
327+
186328
class PipelineContextEngine(ContextEngine):
187329
def __init__(self, *, profile: Any | None = None) -> None:
188330
self.profile = profile
@@ -502,9 +644,12 @@ def _hard_truncate(
502644
}
503645
return head + [marker] + tail, dropped
504646

647+
# ── prior-turn tool collapse (L1) ───────────────────────────────
648+
505649
# ── L1 ──────────────────────────────────────────────────────────
506650

507651
def _l1_budget(self, messages: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
652+
messages, collapsed_n = collapse_prior_turn_tool_traces(messages)
508653
limit = self.max_tool_output_chars
509654
# Mid-zone (outside protect tail) gets a tighter cap — Codex/Claude style
510655
mid_limit = max(800, min(limit, int(limit * 0.35) or 1500))
@@ -558,7 +703,7 @@ def _l1_budget(self, messages: list[dict[str, Any]]) -> tuple[list[dict[str, Any
558703
tcs.append(tc2)
559704
m = {**m, "tool_calls": tcs}
560705
out.append(m)
561-
return out, changed
706+
return out, changed + collapsed_n
562707

563708
# ── L3 (Claude Code microcompact) ───────────────────────────────
564709

‎backend/agent/exit_reasons.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@
6868
"重试同一问题,或换一种说法。",
6969
"warn",
7070
),
71+
"llm_visible_idle": (
72+
"思考时间过长",
73+
"模型思考时间过长,本轮先停。发送「继续」可接着做。",
74+
"可缩小上下文或拆小任务后重试。",
75+
"warn",
76+
),
7177
"max_tool_rounds": (
7278
"本轮步数已用完",
7379
"本轮步数已经用完,已根据现有结果给出答复。",

‎backend/agent/goal_facade.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ def goal_mode_tool_extras() -> list[str]:
237237
"llm_stream_error",
238238
"rust_diag",
239239
"same_tool_fail",
240+
# Controller wrap: another auto-resume just re-hits the same gate.
241+
"diag_enough",
242+
"no_write_ignored",
240243
}
241244
)
242245

‎backend/agent/grant_store.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,25 @@ def sync_catalog_from_kernel(kernel: Any | None = None) -> bool:
248248
return False
249249

250250

251+
def expand_implied_tool_caps(
252+
capabilities: list[str] | None,
253+
) -> list[str] | None:
254+
"""把抽象读权限展开成 court 可精确匹配的工具名。
255+
256+
旧 kernel catalog 可能没有 result_load→file_read;把 result_load 写进
257+
能力列表后,即使 host 只做 exact-name 匹配也会放行。不改档案库。
258+
"""
259+
if capabilities is None:
260+
return None
261+
caps = [str(x).strip() for x in capabilities if str(x).strip()]
262+
have = set(caps)
263+
if not have or "*" in have:
264+
return caps
265+
if "result_load" not in have and (have & {"file_read", "file_rw"}):
266+
caps.append("result_load")
267+
return caps
268+
269+
251270
def tool_matches_crew_caps(tool: str, capabilities: list[str] | set[str] | frozenset[str] | None) -> bool:
252271
"""工具名是否被编制能力集覆盖(抽象 cap 如 file_rw 可覆盖 file_read/glob/grep)。
253272
@@ -261,6 +280,11 @@ def tool_matches_crew_caps(tool: str, capabilities: list[str] | set[str] | froze
261280
abstract = TOOL_TO_CREW_CAP.get(tool)
262281
if abstract and abstract in caps:
263282
return True
283+
# result_load → file_read → file_rw:有读写面即可回读外置结果
284+
if abstract:
285+
broader = TOOL_TO_CREW_CAP.get(abstract)
286+
if broader and broader in caps:
287+
return True
264288
# 运行时 MCP 工具:prefix 规则(与 engineering.manage_mcp 对齐)
265289
t = str(tool or "")
266290
if t.startswith("mcp_") or t in {"mcp_call", "mcp"}:

0 commit comments

Comments
 (0)