fix(agent_loop): M4a死亡螺旋两缺陷根治(2026-09-18 pbls实锤)——①空fallback交付件硬拦截:30轮耗尽零产出时原实现把77字节「未检测到write_file」空说明当交付件submit,且按created_at最新覆盖上轮真实交付件成为QC审查对象;QC面对自相矛盾对象(交付件说未产出/磁盘有git commit)16轮全烧取证无法裁决→no-decision infra回置3连败→fault+pause;正解:零产出=本轮执行失败mark_failed(错误文案带「瞬时/重试」hint走retry分类),绝不制造空壳交付件②_parse_agent_action归一化:LLM输出{"action":"run_shell",...}把工具名写进action字段(M4a QC turn=15实锤),分发只认review_*/tool_call/deliver/ask→落「未识别」纠错分支白烧审核轮次;新增_normalize_action_dict:action=工具名+params/tool字段→归一tool_call,submit/finish→deliver,ask_question→ask,命中TOOL_SCHEMAS宽松兜底;归一失败原样返回保持诚实纠错路径

This commit is contained in:
ymq 2026-09-18 14:21:14 +08:00
parent 82257290ee
commit e6ad1cd8ed

View File

@ -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