File size: 23,290 Bytes
fa1140b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 | """语义级编排:在 client 与 adapter 之间拼接 tool directive,可选拒绝重试。
有 tools 时 prompt 结构固定为:
[TOOL PROTOCOL 全文 + tools 列表] ← 始终最顶端
[base_prompt: system / history / user]
[TOOL PROTOCOL REMINDER] ← 文末再钉一次,抗长历史 recency
拒绝检测(``refusal_detect``)默认关:真流式透传。
开启后:整轮 buffer → 检测拒绝 → 换 retry 变体重试(与 deps 账号换号正交)。
"""
from __future__ import annotations
import logging
import sys
from collections.abc import AsyncIterator
from typing import Any
from app.events import IREvent
from app.refusal import is_refusal
from app.tools import (
ToolDef,
build_tool_directive,
build_tool_tail_reminder,
missing_required,
parse_tool_calls,
)
logger = logging.getLogger(__name__)
def _compose_prompt(base_prompt: str, tools: list[ToolDef], *, variant: str = "default") -> str:
"""有 tools:directive 置顶 + base + tail;无 tools:原样。"""
if not tools:
return base_prompt
head = build_tool_directive(tools, variant=variant)
tail = build_tool_tail_reminder(tools) if variant == "default" else ""
# retry 变体用整段替换头,仍钉 tail 提醒
if variant != "default":
tail = build_tool_tail_reminder(tools)
return f"{head}\n\n{base_prompt}{tail}"
async def _collect_round(
client: Any, prompt: str, model_id: str | None, tools: list[ToolDef] | None = None,
**kw: Any,
) -> tuple[list[IREvent], str, bool]:
"""跑一轮 client.stream,收集全部 IREvent 并拼接 text。``kw`` 透传上游。"""
events: list[IREvent] = []
parts: list[str] = []
had_error = False
# 2026-08-08 实测(生图链路):response.completed(→finish 事件)后流还没结束,
# 图片 markdown 在更晚的 response.output_text.done 顶层 text 里。若在 finish 就
# break,图片 URL 全被截断(只有 tool_call 围栏无媒体)。故 finish 只计数,
# 继续消费到 async for 自然耗尽(客户端流永远不会无限:上游 SSE 有明确收尾)。
async for ir in client.stream(prompt, model_id=model_id, tools=tools, **kw):
events.append(ir)
if ir.kind == "error":
had_error = True
break
if ir.kind == "text" and ir.text:
parts.append(ir.text)
# finish 不 break;图片 done 事件在其后
return events, "".join(parts), had_error
def _count_history_tool_calls(base_prompt: str, known: set[str]) -> dict[str, int]:
"""扫 base_prompt 里 ``[tools]`` 块的 ``name: <工具名>`` 行,统计每个已知名出现次数。
extract_user_prompt 把每轮工具调用拍成 ``[tools]`` 块,块内每条形如:
[call_x]
name: todowrite
arguments: {...}
这里只数 ``name:`` 行(不计 result 行),用于判断"历史里某工具被连调了几次"。
opencode 死循环现场:历史清一色 todowrite,无 glob/read/bash 等干活工具。
"""
counts: dict[str, int] = {name: 0 for name in known}
for line in base_prompt.splitlines():
s = line.strip()
# ``name: X`` 形态([tools] 块内调用条目);排除结果块可能的杂行
if s.startswith("name:") and s.split(":", 1)[1].strip() in counts:
name = s.split(":", 1)[1].strip()
counts[name] += 1
return counts
def _latest_is_tool_result_only(base_prompt: str) -> bool:
"""最新一轮是工具结果、其后无新的 ``[user]`` 请求块?
extract_user_prompt 拍出的历史块顺序为各 ``[system]/[user]/[assistant]/[tools]`` 段;
找最后出现的 ``[user]`` 顶层标记,若它出现在最后一个 ``[tools]`` 块**之前**(或根本没有
``[user]``),说明末尾是工具结果、没有用户新需求 —— 模型应"继续收尾"而非"重启原任务"。
注意只认顶层 ``\\n[user]``(块首),避开 args 里偶然出现的同类字符串(arguments 是单行 JSON,
其后跟 ``\\n---`` 不会以裸 ``\\n[user]\\n`` 起块)。
"""
stripped = base_prompt.rstrip()
# 末尾不是 [tools] 块(比如以 [assistant] 文本结尾)则不算"纯工具结果轮"
last_tools = stripped.rfind("\n[tools]")
if last_tools < 0 and not stripped.startswith("[tools]"):
return False
last_user = stripped.rfind("\n[user]")
# 最后的 [user] 必须在最后的 [tools] 之后才算"有新请求";否则视为末尾是工具结果
return last_user < last_tools or (last_tools >= 0 and last_user < 0)
def _dup_call_correction(counts: dict[str, int], known: set[str], dup_limit: int) -> str:
"""判定"只写计划/只探查、不实质推进"的死循环,返回纠正提示,否则空串。
两类死循环都治:
1. 全程没干过活(连探查都没有),却反复写计划(todowrite 等) >=limit -> 纠正"别写计划了
用 read/glob/bash 干活"。
2. 已经在探查(glob/grep/list 反复)但从未实质读取/读取结果(read/bash/edit/write
一次都没有)-> 纠正"别再 glob 了,挑文件用 read 读或看 bash 结果,直接给结论"。
gpt-5.5 卡在反复 glob/git status 列目录、不读文件内容、反烧 token,这一类专治它。
"""
if dup_limit <= 0:
return ""
survey = {"glob", "grep", "list", "bash", "task"} # 列目录/搜文件名/跑命令列状态——探查性质,不等于读到文件内容
real = {"read", "edit", "write", "str_replace_editor"} # 真正读取/写入文件原始内容的工具(read 是核心信号)
planish = {"todowrite", "todo", "plan"}
has_any = any(counts.get(h, 0) > 0 for h in survey | real)
has_real = any(counts.get(h, 0) > 0 for h in real)
plan_count = sum(counts.get(p, 0) for p in planish)
real_count = sum(counts.get(r, 0) for r in real)
survey_count = sum(counts.get(s, 0) for s in survey)
# 情况零(兜底之王):计划工具计数远超"所有非计划动作之和" -> 不管有没有 read,计划已刷爆、
# 实际推进几乎为零。专治 gpt-5.5 的"狂写 todowrite 50 次 + 象征性 glob 2 次、从不 read"形态:
# 情况一(要 has_any=False)因象征性探查漏判、情况二(要 survey>=4)因探查太少漏判、
# 情况三(要 has_real)因从不 read 漏判 —— 三道全漏,唯有这里按"计划占绝对多数"兜底。
non_plan_total = real_count + survey_count
if plan_count >= max(dup_limit + 3, 5) and plan_count > non_plan_total + 2:
lead_plan = "todowrite"
for p in planish:
if counts.get(p, 0) == plan_count and counts.get(p, 0) > 0:
lead_plan = p
break
read_hint = "用 read 读取核心文件内容" if real_count == 0 else "基于已读到的内容"
return (
"\n[system]\n"
f"提醒:`{lead_plan}` 已累计被调用 {plan_count} 次,而真正读取/执行的动作"
f"(read/edit/write/bash/glob 等合计)仅 {non_plan_total} 次。计划早已完成,"
f"继续写 `{lead_plan}` 是空转烧 token,不会再推进任务。\n"
f"立即停止调用 `{lead_plan}` 及任何计划类工具——{read_hint},"
f"然后直接给出最终分析/结论/回答,不要再输出任何 `{lead_plan}` 调用。"
)
# 情况一:全程没干过活(连探查都没),只有计划工具反复 -> 停止写计划、去干活
if not has_any:
dup_name = None
for name, c in counts.items():
if name in planish and c >= dup_limit:
dup_name = name
break
if dup_name:
n = counts[dup_name]
return (
"\n[system]\n"
f"提醒:历史中已多次成功调用 `{dup_name}`({n} 次),"
f"任务清单/计划已经建立并完成,不要再重复调用 `{dup_name}`,也不要再写计划或重述步骤。\n"
f"立刻执行真正的任务:用 read/glob/grep/bash 等工具读取文件、搜索代码、运行命令,"
f"拿到结果后直接给出最终分析/结论。不要输出任何新的 `{dup_name}` 调用。"
)
dup_name = None
for name, c in counts.items():
if c >= dup_limit:
dup_name = name
break
if dup_name:
n = counts[dup_name]
return (
"\n[system]\n"
f"提醒:`{dup_name}` 已被连续调用 {n} 次且没有实质进展。"
f"不要再重复调用 `{dup_name}`——换个实际动作:用 read 读取关键文件、"
f"用 bash 运行命令看结果,然后直接给出分析/结论。"
)
# 情况二:已在探查(glob/grep/list 反复)但从未实质读取(read/bash 一次都没) -> 别再列了去读
survey_total = sum(counts.get(s, 0) for s in survey)
g = counts.get("glob", 0)
if survey_total >= max(dup_limit + 2, 3) and not has_real:
lead = "glob" if g == max(counts.get(s, 0) for s in survey) else "列目录/搜索"
return (
"\n[system]\n"
f"提醒:已反复调用探查类工具({lead} 等,累计 {survey_total} 次)列出目录/搜索,"
f"但从未用 read 读取任何文件内容、也没用 bash 运行命令看结果。\n"
f"目录结构已经清楚,不要再继续 glob/搜索——立刻从已列出的文件里挑 2-3 个核心文件"
f"用 read 读取其内容(或用 bash 跑出结果后直接看输出),基于读到的内容给出最终分析/结论。"
)
# 情况三:已干过活(read 等真实工作)却又大量反复重写计划 -> 读完了就给结论,别再回去重写计划。
# 实战现场:gpt-5.5 read 完 3 个文件后又切回 todowrite 重写计划,read 弄到的内容白读。
# 情况一要"无任何 survey/real"才触发、情况二要"无 real"才触发,二者在"已 read 但仍狂写计划"
# 时都漏判 —— 这一情况专兜底:已真实工作过、计划工具计数远超实际工作 → 读完了给结论。
if has_real and plan_count >= dup_limit + 3 and plan_count > real_count + 1:
lead_plan = "todowrite"
for p in planish:
if counts.get(p, 0) == plan_count and counts.get(p, 0) > 0:
lead_plan = p
break
return (
"\n[system]\n"
f"提醒:你已用 read/bash 等实际读取/执行过内容({real_count} 次真实工作),"
f"但 `{lead_plan}` 等计划工具仍累计被调用 {plan_count} 次。\n"
f"文件内容已经读到,不要再回去重写或更新计划清单——立即基于刚才读到的内容"
f"直接给出最终分析/结论/回答。不要再输出任何新的 {lead_plan} / 计划类调用。"
)
return ""
async def stream_with_retry(
client: Any,
base_prompt: str,
tools: list[ToolDef],
model_id: str | None = None,
*,
max_retries: int | None = None,
**kw: Any,
) -> AsyncIterator[IREvent]:
"""拼 tool directive 后驱动 client.stream;可选拒绝检测换变体重试。
- ``base_prompt`` **不含** directive;有 tools 时 head+base+tail。
- ``refusal_detect=false``(默认)或无 tools:真流式透传。
- ``refusal_detect=true`` 且有 tools:buffer 一轮;命中拒绝则换 retry 变体重试。
- ``**kw``(如 ``image_model``)原样透传给上游 client(生图链路)。
"""
has_tools = bool(tools)
if max_retries is None:
from app.config import get_settings
settings = get_settings()
max_retries = settings.tool_call_retries if settings.refusal_detect else 0
# native 模式:上游原生支持 function-calling → 不注入 directive,tools 直通上游。
# 上游服务端还会自动注入自己的工具(AnumaSearchMCP-*、memory_vault_* 等),客户端并不认识;
# 且模型会发出 arguments 为 {} 的空壳调用(缺 schema 必填字段,opencode 校验即报错)。
# 缓冲整轮:非客户端工具名 / 全坏调用(缺必填参数)拦截掉,注入错误提示重试。
if settings.upstream_strategy == "native" and has_tools:
known = {t.name for t in tools}
# 注意:函数开头的 max_retries 已被 refusal_detect 逻辑压成 0(refusal_detect=false 时),
# 不能用于 native 分支;这里重新从配置读取独立重试预算。
retries = settings.tool_call_retries or 2
max_attempts = 1 + retries
prompt = base_prompt
# 历史工具调用计数:扫 base_prompt 里 [tools] 块的 name 行。dup_limit 软纠正与
# forge_limit 硬禁用两道防线共用这一份 counts,不重复计算。
counts = _count_history_tool_calls(base_prompt, known)
# 真实推进判定:read ≥2 次才算真干活。1 次 read 可以是试探糊弄——实战见模型
# read 1 次后继续狂刷 glob/bash 25+ 次、has_real=True 让硬禁整块停火,必须堵上。
read_count = sum(counts.get(r, 0) for r in
{"read", "readfile", "fs_open_file", "Read"})
has_real = read_count >= 2
# 历史重复调用纠正:扫历史里 [tools] 块的 name 行;若某个工具(尤其在
# opencode 里反复写的 todowrite)历史已累计 ≥ dup_limit 次且没干过活,
# 追加"停止重复、立即干活"提示,破 gpt-5.5 的 todowrite 死循环。
# 第一轮/正常多轮历史这里返回 "",不注入。
dup_limit = int(getattr(settings, "tool_call_dup_limit", 2) or 0)
if dup_limit > 0:
corr = _dup_call_correction(counts, known, dup_limit)
if corr:
prompt = base_prompt + corr
dup_name = max(counts, key=counts.get)
logger.warning(
"history has repeated tool call `%s` (x%d); "
"injecting 'stop repeating, do real work' correction",
dup_name, counts[dup_name],
)
# STATE NOTICE(借 qwen2API 思路):最新一轮是工具结果、其后无 [user] 新请求时,
# 钉一句"这是工具结果不是新请求,从结果继续收尾、别重启原任务"——专治模型把结果误
# 当新任务从头来 / 读完文件又回 todowrite 重写计划去。与情况三正交、协同封死回路。
if _latest_is_tool_result_only(base_prompt):
prompt = prompt + (
"\n[system]\n"
"提示:最新输入是工具调用的结果,不是用户的新请求。请基于该结果继续当前任务或"
"直接收尾给结论,不要把结果当成新任务从头开始,也不要重新建立/重写计划清单。"
)
logger.info("latest context is a tool result; injecting 'continue, don't restart' notice")
# 贪读收口(soft):read 是真实工作不硬禁,但子代理会"贪读不收尾"——一直 read 不返回结论
# 给上层,烧光上下文照样卡死。read 累计远超其它动作(>=read_cap 且 >= non_read×2)时注入
# "读够了立即给结论"软纠正。 配额天花板把 bash/glob/grep 剥光后 non_read 趋近 0,此条件自然
# 命中——正好兜底"剥光只剩 read"的终态。
read_cap = int(getattr(settings, "tool_read_cap", 30) or 0)
if read_cap > 0 and read_count >= read_cap:
non_read = sum(
c for n, c in counts.items()
if n not in {"read", "readfile", "fs_open_file", "Read"}
)
if read_count >= non_read * 2:
prompt = prompt + (
"\n[system]\n"
f"提醒:你已用 read 累计读取 {read_count} 个文件(远超其它动作合计 {non_read} 次)。"
"信息已经足够,继续 read 是空转烧 token、不会带来新结论。\n"
"立即停止读取——基于已读到的文件内容直接给出最终分析/结论/回答,"
"不要再发起新的 read 或任何探查工具调用,把结论返回给上层。"
)
logger.warning(
"history shows greedy read (read=%d, non_read=%d, >=tool_read_cap %d); "
"injecting 'stop reading, give conclusion' correction",
read_count, non_read, read_cap,
)
# 硬禁用(tool forge limit,物理防线):某工具历史空刷 ≥ forge_limit 次且无真实推进
# (read/edit/write 一次都没成功)-> 本轮从 tools 列表物理剔除它,上游拿不到 schema、
# 模型物理上无法再生成该调用;known 集合也剔,模型若仍文本输出 -> 进 unknown 拦截重试,
# 文案"工具不存在、可用工具仅限 read/..."信号最硬。逐步勒索:逃到别的工具,那个工具也会
# 涨到阈值被依次禁,最终只剩 read。has_real=True(真有读/写推进)时一律不禁,避免误伤
# 合理多步任务(如 bash 跑测试 8 次)。
forge_limit = int(getattr(settings, "tool_forge_limit", 8) or 0)
disabled: set[str] = set()
if forge_limit > 0:
# read 给的"边读边刷"配额:read×3,但设天花板 forge_limit×2。
# 无天花板时模型用少量 read 无限买配额(实战:read 9 撑 27 配额、glob/bash 贴线
# 无限刷)——天花板保证任何工具刷过 2×forge_limit 必被禁,read 再多也救不了。
quota = min(read_count * 3, forge_limit * 2)
for name, c in counts.items():
if name in {"read", "readfile", "fs_open_file", "Read",
"edit", "write", "str_replace_editor"}:
continue # 真实工作类工具永不硬禁
if c >= forge_limit and (not has_real or c > quota):
# 要么全程无真实读取;要么"边 read 边狂刷"——该工具空刷远超配额(>quota)
disabled.add(name)
if disabled:
logger.warning(
"history shows empty-spin tool(s) %s (>=tool_forge_limit %d, read=%d); "
"removing them from this round's tools",
sorted(disabled), forge_limit, read_count,
)
active_tools = [t for t in tools if t.name not in disabled]
active_known = {t.name for t in active_tools}
else:
active_tools = tools
active_known = known
for attempt in range(max_attempts):
events, full_text, had_error = await _collect_round(
client, prompt, model_id, tools=active_tools, **kw)
if had_error:
for ev in events:
yield ev
return
calls = parse_tool_calls(full_text, known_names=active_known)
unknown = [c for c in calls if c.name not in active_known]
known_calls = [c for c in calls if c.name in active_known]
bad = [c for c in known_calls if missing_required(c, active_tools)]
good = [c for c in known_calls if not missing_required(c, active_tools)]
# 好坏混合时透出整轮(adapter 侧过滤空壳调用);全坏或无 unknown 且无 good 才重试
if not unknown and (good or not bad) or attempt + 1 >= max_attempts:
for ev in events:
yield ev
return
if unknown:
names = sorted({c.name for c in unknown})
print(
f"[orchestrator] blocked upstream-injected tool(s): {names}; retry "
f"{attempt + 1}/{max_attempts - 1}",
file=sys.stderr,
)
err = (
"\n[user]\n"
"你刚才调用的工具不存在于当前环境,调用失败且不会有任何结果:"
f"{'、'.join(names)}。\n"
f"本环境可用工具仅限:{', '.join(sorted(active_known))}。\n"
"需要读文件、跑命令、搜代码或做其他操作时,请立即改用上述可用工具调用,"
"不要再次调用不存在的工具。"
)
else:
names = sorted({c.name for c in bad})
print(
f"[orchestrator] blocked empty-args tool call(s): {names}; retry "
f"{attempt + 1}/{max_attempts - 1}",
file=sys.stderr,
)
err = (
"\n[user]\n"
"你刚才发出的工具调用参数不完整(缺少必填字段),调用失败且不会有任何结果:"
f"{'、'.join(names)}。\n"
"请根据工具的 JSON schema 补全 arguments 中的必填字段后重新调用,"
"不要发出空的 arguments 占位。"
)
prompt = prompt + err
return
# 默认路径:不 buffer,流式透传
if not has_tools or max_retries <= 0:
prompt = _compose_prompt(base_prompt, tools, variant="default")
async for ir in client.stream(prompt, model_id=model_id, **kw):
yield ir
if ir.kind == "error":
return
# 2026-08-08 实测(生图链路):response.completed(→finish)后流还没结束,
# 图片 markdown 在更晚的 response.output_text.done 顶层 text 里。生图时若在
# finish 就 return,图片 URL 全被截断(只剩 tool_call 围栏无媒体)。finish 不
# 截断,继续消费到 async for 自然耗尽(与 _collect_round 同款逻辑)。
return
# 拒绝检测路径:buffer + 换变体重试
known = {t.name for t in tools}
max_attempts = 1 + max_retries
chosen: list[IREvent] = []
for attempt in range(max_attempts):
variant = "retry" if attempt > 0 else "default"
prompt = _compose_prompt(base_prompt, tools, variant=variant)
events, full_text, had_error = await _collect_round(client, prompt, model_id)
if had_error:
for ev in events:
yield ev
return
chosen = events
if parse_tool_calls(full_text, known_names=known):
break
if not is_refusal(full_text, has_tools=True):
break
if attempt + 1 >= max_attempts:
break
print(f"[orchestrator] refusal detected (variant={variant}); retry", file=sys.stderr)
for ev in chosen:
yield ev
|