diff --git a/pipeline_service/agent_loop.py b/pipeline_service/agent_loop.py index 5f52b4a..c5b33e4 100644 --- a/pipeline_service/agent_loop.py +++ b/pipeline_service/agent_loop.py @@ -2993,6 +2993,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): deliverable = None ask_question = None ask_form = None # ask_question 附带的动态表单声明(form_schema) + _force_produce_sent = False written_files = [] # 本任务执行期间 write_file 实际写入的文件(无 deliver 时的产出兜底) # ── Tool Loop(原生 function calling)── @@ -3009,9 +3010,12 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): if _st and getattr(_st[0], 'state', '') == 'cancelled': logger.info(f"task cancelled mid-run: {task_id}") return {"status": "cancelled", "task_id": task_id} - # 强制产出:第 6 轮起注入,打断「了解现状/反复验证」探索死循环,强制转向 write_file + deliver - if turn >= _FORCE_PRODUCE_TURN: + # 强制产出:第 6 轮起注入一次(2026-09-17 修复:原 turn>=5 每轮重复追加, + # 30 轮预算下最多灌 25 条相同提示——上下文污染浪费 token 且挤压取证窗口。 + # 打断「了解现状/反复验证」探索死循环一次注入即够;真硬上限是 30 轮预算)。 + if turn == _FORCE_PRODUCE_TURN and not _force_produce_sent: msgs.append({"role": "user", "content": _FORCE_PRODUCE_HINT}) + _force_produce_sent = True try: resp = await asyncio.wait_for( llm_call_msgs_native(msgs, tools=tools_schema, model=model_name, temperature=0.4, org_id=org_id, project_id=project_id, session_id='task:%s' % task_id, timeout=_LLM_CLIENT_TIMEOUT), @@ -3710,6 +3714,7 @@ async def pm_review_run(project_id, agent_id=None, model_name=None): from .llm_bridge import llm_call_msgs decision = None + _pm_soft_hint_sent = False for turn in range(5): # 心跳:PM 审核期间持续标记,进程崩溃后由 stale 回收重置;COMMIT 使其对其他连接可见 await sor.sqlExe( @@ -3725,12 +3730,17 @@ async def pm_review_run(project_id, agent_id=None, model_name=None): # auto-inject 兜底:最后两轮强制要求给出 review_* 决策,禁止再 tool_call, # 否则 deepseek 会一直读文件/git_status 耗尽 5 轮 → 审核超时 → 打回重跑 → 死循环。 # 例外:允许 create_tasks(里程碑拆后续任务的「项目计划/任务分配」职责),派发后必须立即决策。 - if turn >= 3: + # 收尾软提示(2026-09-17 修复:与 QC 循环同款缺陷——原 turn>=3 每轮重复 + # 追加禁令污染上下文 + 工具结果被替换成注入文本。PM read_file/git_status + # 取证读到的是引擎指令而非真实内容,证据污染同款。改为只追加一次软提示, + # 工具全程真实执行;轮次上限是硬约束,耗尽走下方「审核超时智能处理」。 + if turn == 3 and not _pm_soft_hint_sent: msgs.append({"role": "user", "content": - "已检查足够信息。现在必须立即收尾:" + "提示:剩余轮次不多(第 4/5 轮)。" "若本任务审批通过后需拆分为多个后续任务,现在用 create_tasks 一次性派发;" - "随后必须立即输出 review_approve / review_reject / review_complete / review_rollback 之一," - "禁止再调用其它工具。"}) + "随后请尽快输出 review_approve / review_reject / review_complete / review_rollback 之一。" + "你仍可调用取证工具,引擎不会伪造工具结果。"}) + _pm_soft_hint_sent = True try: # timeout 必传(2026-09-16 pbls QC 超时事故):不传则 payload 无 # _timeout → inference 用供应商端点配置(百炼 120s)掐断上游, @@ -3781,16 +3791,14 @@ async def pm_review_run(project_id, agent_id=None, model_name=None): elif tool == 'load_skill': result = await _load_skill_by_name(sor, project_id, 'pm', org_id, params.get('name', ''), params.get('file_path') or None) else: - if turn >= 3: - # 硬约束:最后两轮拒绝执行探索类工具(read_file/git_status/list_files/run_shell 等), - # 强制 PM 立即输出决策——否则 deepseek 无视软提示持续检查、5 轮耗尽 → 审核超时。 - result = (f"已到最后收尾阶段(第 {turn + 1}/5 轮),拒绝执行探索类工具 {tool}。" - f"请立即输出 review_approve / review_reject / review_complete / review_rollback 之一,不要再调用工具。") - else: - result = await _exec_agent_tool(tool, params, space_dir, { - "project_id": project_id, "who": "agent.pm", - "agent_id": agent_id or "", "task_id": task_id, - "org_id": org_id or '0'}) + # 工具全程真实执行(2026-09-17 修复证据污染,与 QC 循环同款): + # 原 turn>=3 把 result 替换成「拒绝执行探索类工具」注入文本—— + # PM 取证读到的是引擎指令而非文件内容。轮次硬上限=5 轮预算, + # 耗尽走下方「审核超时智能处理」(有 create_tasks 视为认可,否则 rejected)。 + result = await _exec_agent_tool(tool, params, space_dir, { + "project_id": project_id, "who": "agent.pm", + "agent_id": agent_id or "", "task_id": task_id, + "org_id": org_id or '0'}) msgs.append({"role": "assistant", "content": raw}) msgs.append({"role": "user", "content": f"工具 {tool} 结果:\n{result}"}) else: @@ -4107,14 +4115,25 @@ async def qc_review_run(project_id, agent_id=None, model_name=None): # QC 实际只有 1~2 次取证机会,而评分协议要求「逐项 read_file 核验、未读取一律判不过」 # ——预算与协议结构性矛盾,QC 物理上无法完成核验 → 必然 reject → develop 做得再真 # 也被退到上限(pbls M1a 四轮退回全在说「未能完成逐个 read_file 核验」)。 + _soft_hint_sent = False for turn in range(16): await sor.sqlExe( "UPDATE pipeline_tasks SET updated_at=NOW() WHERE id=${tid}$ AND state='qc_review'", {"tid": task_id}) await sor.sqlExe("COMMIT", {}) - if turn >= 12: + # 收尾软提示(2026-09-17 修复:原 turn>=12 每轮重复追加禁令污染上下文, + # 且配套硬切断把工具结果替换成注入文本 → QC read_file 取证读到的是 + # 「禁止调用工具」而非文件内容,证据链被污染、被迫盲判 reject,QC 退回 + # 意见②实锤)。改为:只追加一次软提示(不冒充工具结果),取证工具全程 + # 正常执行;硬上限是 16 轮预算本身——耗尽走 no-decision 诚实出口 + # (requeue→mark_failed→fault 冒泡人工),绝不静默 approve、绝不污染证据。 + if turn == 13 and not _soft_hint_sent: msgs.append({"role": "user", "content": - "已检查足够信息。现在必须立即输出 review_approve 或 review_reject,禁止再调用其它工具。"}) + "提示:剩余取证轮次不多(第 14/16 轮)。请抓紧完成必要核验后输出 " + "review_approve 或 review_reject(含 score/passed/total 与未过项清单)。" + "你仍可调用 read_file 等取证工具,引擎不会伪造工具结果;若轮次耗尽" + "仍无决策,任务会转人工而非默认放行——宁可如实退回,勿盲判。"}) + _soft_hint_sent = True try: # timeout 必传(2026-09-16 pbls QC 超时事故,与 PM 审核同款): # 不传 → inference 按端点配置 120s 掐断 + 客户端缺省 330s,QC @@ -4149,12 +4168,11 @@ async def qc_review_run(project_id, agent_id=None, model_name=None): elif act.get('action') == 'tool_call': tool = act.get('tool', '') params = act.get('params', {}) - if turn >= 12 and tool != 'load_skill': - # 硬约束(对齐 PM 循环成熟模式):收尾阶段拒绝执行探索类工具, - # 强制 QC 立即输出决策——软提示实测会被无视、耗尽轮次。 - result = (f"已到最后收尾阶段(第 {turn + 1}/16 轮),拒绝执行探索类工具 {tool}。" - f"请立即输出 review_approve 或 review_reject(含 score/passed/total 与未过项清单),不要再调用工具。") - elif tool == 'load_skill': + # 工具全程真实执行(2026-09-17 修复证据污染):原 turn>=12 把 result + # 替换成「拒绝执行探索类工具」注入文本,QC read_file 拿到的是引擎指令 + # 而非文件内容 → 证据链污染 → 被迫盲判。现在取证工具任何时候都真跑, + # 收尾约束交给软提示 + 16 轮预算硬上限(耗尽转人工,不默认放行)。 + if tool == 'load_skill': result = await _load_skill_by_name(sor, project_id, 'qc', org_id, params.get('name', ''), params.get('file_path') or None) else: result = await _exec_agent_tool(tool, params, space_dir, capability_ctx) @@ -4366,14 +4384,20 @@ async def retrospective_run(project_id, agent_id=None, model_name=None): from .llm_bridge import llm_call_msgs deliverable = None proposals_made = 0 + _rt_soft_hint_sent = False for turn in range(10): await sor.sqlExe( "UPDATE pipeline_tasks SET updated_at=NOW() WHERE id=${tid}$ AND state='running'", {"tid": task_id}) await sor.sqlExe("COMMIT", {}) - if turn >= 7: + # 收尾软提示(2026-09-17 与 QC/PM 同款修复:原 turn>=7 每轮重复追加污染上下文; + # 工具本就真实执行无证据污染。只追加一次,轮次硬上限=10 轮预算, + # 耗尽走下方「轮次耗尽未产出交付件」mark_failed 诚实出口) + if turn == 7 and not _rt_soft_hint_sent: msgs.append({"role": "user", "content": - "已执行足够轮次。现在必须立即收尾:write_file 写复盘报告(若未写),然后输出 deliver 提交,禁止再调其它工具。"}) + "提示:剩余轮次不多(第 8/10 轮)。请尽快收尾:write_file 写复盘报告(若未写)," + "然后输出 deliver 提交。你仍可调用必要工具,引擎不会伪造工具结果。"}) + _rt_soft_hint_sent = True try: raw = await asyncio.wait_for( # timeout 必传(2026-09-16 pbls 同款预算错位第三处:复盘)