From e6ad1cd8ede9c6fad031b5717db8cc4692d65ce1 Mon Sep 17 00:00:00 2001 From: ymq Date: Fri, 18 Sep 2026 14:21:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent=5Floop):=20M4a=E6=AD=BB=E4=BA=A1?= =?UTF-8?q?=E8=9E=BA=E6=97=8B=E4=B8=A4=E7=BC=BA=E9=99=B7=E6=A0=B9=E6=B2=BB?= =?UTF-8?q?(2026-09-18=20pbls=E5=AE=9E=E9=94=A4)=E2=80=94=E2=80=94?= =?UTF-8?q?=E2=91=A0=E7=A9=BAfallback=E4=BA=A4=E4=BB=98=E4=BB=B6=E7=A1=AC?= =?UTF-8?q?=E6=8B=A6=E6=88=AA:30=E8=BD=AE=E8=80=97=E5=B0=BD=E9=9B=B6?= =?UTF-8?q?=E4=BA=A7=E5=87=BA=E6=97=B6=E5=8E=9F=E5=AE=9E=E7=8E=B0=E6=8A=8A?= =?UTF-8?q?77=E5=AD=97=E8=8A=82=E3=80=8C=E6=9C=AA=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E5=88=B0write=5Ffile=E3=80=8D=E7=A9=BA=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E5=BD=93=E4=BA=A4=E4=BB=98=E4=BB=B6submit,=E4=B8=94=E6=8C=89cr?= =?UTF-8?q?eated=5Fat=E6=9C=80=E6=96=B0=E8=A6=86=E7=9B=96=E4=B8=8A?= =?UTF-8?q?=E8=BD=AE=E7=9C=9F=E5=AE=9E=E4=BA=A4=E4=BB=98=E4=BB=B6=E6=88=90?= =?UTF-8?q?=E4=B8=BAQC=E5=AE=A1=E6=9F=A5=E5=AF=B9=E8=B1=A1;QC=E9=9D=A2?= =?UTF-8?q?=E5=AF=B9=E8=87=AA=E7=9B=B8=E7=9F=9B=E7=9B=BE=E5=AF=B9=E8=B1=A1?= =?UTF-8?q?(=E4=BA=A4=E4=BB=98=E4=BB=B6=E8=AF=B4=E6=9C=AA=E4=BA=A7?= =?UTF-8?q?=E5=87=BA/=E7=A3=81=E7=9B=98=E6=9C=89git=20commit)16=E8=BD=AE?= =?UTF-8?q?=E5=85=A8=E7=83=A7=E5=8F=96=E8=AF=81=E6=97=A0=E6=B3=95=E8=A3=81?= =?UTF-8?q?=E5=86=B3=E2=86=92no-decision=20infra=E5=9B=9E=E7=BD=AE3?= =?UTF-8?q?=E8=BF=9E=E8=B4=A5=E2=86=92fault+pause;=E6=AD=A3=E8=A7=A3:?= =?UTF-8?q?=E9=9B=B6=E4=BA=A7=E5=87=BA=3D=E6=9C=AC=E8=BD=AE=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=A4=B1=E8=B4=A5mark=5Ffailed(=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=96=87=E6=A1=88=E5=B8=A6=E3=80=8C=E7=9E=AC=E6=97=B6/?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E3=80=8Dhint=E8=B5=B0retry=E5=88=86=E7=B1=BB?= =?UTF-8?q?),=E7=BB=9D=E4=B8=8D=E5=88=B6=E9=80=A0=E7=A9=BA=E5=A3=B3?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=E4=BB=B6=E2=91=A1=5Fparse=5Fagent=5Faction?= =?UTF-8?q?=E5=BD=92=E4=B8=80=E5=8C=96:LLM=E8=BE=93=E5=87=BA{"action":"run?= =?UTF-8?q?=5Fshell",...}=E6=8A=8A=E5=B7=A5=E5=85=B7=E5=90=8D=E5=86=99?= =?UTF-8?q?=E8=BF=9Baction=E5=AD=97=E6=AE=B5(M4a=20QC=20turn=3D15=E5=AE=9E?= =?UTF-8?q?=E9=94=A4),=E5=88=86=E5=8F=91=E5=8F=AA=E8=AE=A4review=5F*/tool?= =?UTF-8?q?=5Fcall/deliver/ask=E2=86=92=E8=90=BD=E3=80=8C=E6=9C=AA?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E3=80=8D=E7=BA=A0=E9=94=99=E5=88=86=E6=94=AF?= =?UTF-8?q?=E7=99=BD=E7=83=A7=E5=AE=A1=E6=A0=B8=E8=BD=AE=E6=AC=A1;?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=5Fnormalize=5Faction=5Fdict:action=3D?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=90=8D+params/tool=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E2=86=92=E5=BD=92=E4=B8=80tool=5Fcall,submit/finish=E2=86=92de?= =?UTF-8?q?liver,ask=5Fquestion=E2=86=92ask,=E5=91=BD=E4=B8=ADTOOL=5FSCHEM?= =?UTF-8?q?AS=E5=AE=BD=E6=9D=BE=E5=85=9C=E5=BA=95;=E5=BD=92=E4=B8=80?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=8E=9F=E6=A0=B7=E8=BF=94=E5=9B=9E=E4=BF=9D?= =?UTF-8?q?=E6=8C=81=E8=AF=9A=E5=AE=9E=E7=BA=A0=E9=94=99=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pipeline_service/agent_loop.py | 64 +++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/pipeline_service/agent_loop.py b/pipeline_service/agent_loop.py index a35fe7e..442c23f 100644 --- a/pipeline_service/agent_loop.py +++ b/pipeline_service/agent_loop.py @@ -2085,6 +2085,45 @@ def _parse_form_schema(v): return None +_KNOWN_ACTIONS = {'deliver', 'ask', 'tool_call', 'review_approve', 'review_reject', + 'review_complete', 'review_rollback'} + + +def _normalize_action_dict(d): + """动作 dict 归一化(2026-09-18 pbls M4a QC 白烧轮次实锤)。 + + 实测缺陷:LLM 输出 {"action":"run_shell","params":{...}}——把工具名直接写进 + action 字段而非 "tool_call"。调用方分发只认 _KNOWN_ACTIONS → 落入「未识别输出」 + 分支纠错重试,白烧审核轮次(QC 16 轮预算里 turn=15 实锤)。 + 规则:action 不是已知动作但形似工具调用(带 params/tool 字段,或 action 值 + 出现在工具 schema 里)→ 归一为 tool_call;deliver/ask 同义别名同样归一。 + 归一失败原样返回(保持「未识别→纠错重试」诚实路径,不伪造动作)。 + """ + if not isinstance(d, dict): + return d + act = str(d.get('action') or '').strip() + if act in _KNOWN_ACTIONS: + return d + if act in ('deliver_result', 'submit', 'finish'): + return {**d, 'action': 'deliver'} + if act in ('ask_question', 'ask_user'): + return {**d, 'action': 'ask', 'question': d.get('question') or d.get('params', {}).get('question', '') if isinstance(d.get('params'), dict) else d.get('question', '')} + # action=工具名形态:{"action":"run_shell","params":{...}} + if act and ('params' in d or 'tool' in d): + return {**d, 'action': 'tool_call', 'tool': d.get('tool') or act, + 'params': d.get('params') or {}} + # 宽松兜底:action 值命中已注册工具 schema 名(capability_tools.TOOL_SCHEMAS) + if act: + try: + from .capability_tools import TOOL_SCHEMAS + if act in TOOL_SCHEMAS: + return {**d, 'action': 'tool_call', 'tool': act, + 'params': d.get('params') or {}} + except Exception: + pass + return d + + def _parse_agent_action(raw): raw = (raw or "").strip() if raw.startswith("```"): @@ -2133,7 +2172,7 @@ def _parse_agent_action(raw): try: d = json.loads(raw) if isinstance(d, dict) and 'action' in d: - return d + return _normalize_action_dict(d) except (json.JSONDecodeError, ValueError): pass @@ -2145,7 +2184,7 @@ def _parse_agent_action(raw): except (json.JSONDecodeError, ValueError): continue if isinstance(d, dict) and 'action' in d: - return d + return _normalize_action_dict(d) # 兼容 {"tool":..,"params":..} 无 action 字段的写法 if isinstance(d, dict) and 'tool' in d: return {"action": "tool_call", "tool": d.get("tool", ""), "params": d.get("params") or {}} @@ -2167,7 +2206,7 @@ def _parse_agent_action(raw): _idx = _b + 1 continue if isinstance(_d, dict) and 'action' in _d: - return _d + return _normalize_action_dict(_d) if isinstance(_d, dict) and 'tool' in _d: return {"action": "tool_call", "tool": _d.get("tool", ""), "params": _d.get("params") or {}} _idx = _b + max(_end, 1) @@ -2853,7 +2892,9 @@ async def _build_fallback_deliverable(space_dir, written_files, role, task_id='' except Exception: pass if not lines: - lines.append("(本轮未检测到 write_file 或 git 变更——agent 确实未产出)") + # 2026-09-18 pbls M4a:零产出返回 None(调用方 mark_failed),不再返回 + # 「未检测到 write_file」空说明——空说明会被当交付件送 QC 制造矛盾审查对象。 + return None return { "result": "\n".join(lines), "summary": "Agent 循环结束未调用 deliver,以下为实际产出检测结果(供 QC 判断是否合格)", @@ -3355,6 +3396,21 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): if not deliverable: # 兜底:循环结束未 deliver,检测 write_file/git 实际产出构造真实交付件(而非占位符) deliverable = await _build_fallback_deliverable(space_dir, written_files, role, task_id=task_id) + if not deliverable: + # 空兜底硬拦截(2026-09-18 pbls M4a 死亡螺旋根治):兜底检测「零产出」时 + # 原实现仍把 77 字节空说明当交付件 submit → QC 拿到自相矛盾的对象(交付件 + # 说未产出,磁盘上却有真实 git commit)→ 16 轮全烧在取证调和矛盾、无法 + # 裁决 → no-decision infra 回置 3 连败 → fault+pause;且空交付件按 + # created_at 最新覆盖上一轮真实交付件成为 QC 审查对象(_get_deliverable_content + # 取最新一条)。正解:零产出=本轮执行失败,mark_failed 走重试/冒泡, + # 绝不制造空壳交付件。 + from .task_capability import mark_failed + _err = ("执行轮次耗尽且零产出(瞬时执行失败可重试):agent 未调用 deliver," + "引擎兜底检测无 write_file 写入、无 git 变更。本轮判执行失败" + "(不生成空壳交付件送审),自动重试。") + await mark_failed(task_id, project_id, who=role, agent_id=agent_id, error=_err) + logger.error(f"role_agent_run empty fallback rejected: task={task_id} role={role}") + return {"status": "failed", "task_id": task_id, "error": _err} # ── 处理产出 ── from appPublic.uniqueID import getID