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