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