| """语义级编排:在 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 "" |
| |
| 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 |
| |
| |
| |
| |
| 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) |
| |
| 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() |
| |
| 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() |
| |
| last_tools = stripped.rfind("\n[tools]") |
| if last_tools < 0 and not stripped.startswith("[tools]"): |
| return False |
| last_user = stripped.rfind("\n[user]") |
| |
| 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"} |
| 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) |
|
|
| |
| |
| |
| |
| 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 运行命令看结果,然后直接给出分析/结论。" |
| ) |
|
|
| |
| 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 跑出结果后直接看输出),基于读到的内容给出最终分析/结论。" |
| ) |
|
|
| |
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| |
| if settings.upstream_strategy == "native" and has_tools: |
| known = {t.name for t in tools} |
| |
| |
| retries = settings.tool_call_retries or 2 |
| max_attempts = 1 + retries |
| prompt = base_prompt |
| |
| |
| counts = _count_history_tool_calls(base_prompt, known) |
| |
| |
| read_count = sum(counts.get(r, 0) for r in |
| {"read", "readfile", "fs_open_file", "Read"}) |
| has_real = read_count >= 2 |
| |
| |
| |
| |
| 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], |
| ) |
| |
| |
| |
| 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") |
| |
| |
| |
| |
| 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, |
| ) |
| |
| |
| |
| |
| |
| |
| forge_limit = int(getattr(settings, "tool_forge_limit", 8) or 0) |
| disabled: set[str] = set() |
| if forge_limit > 0: |
| |
| |
| |
| 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): |
| |
| 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)] |
| |
| 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 |
|
|
| |
| 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 |
| |
| |
| |
| |
| return |
|
|
| |
| 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 |
|
|