From 439dc29b3b31f10106ceb74d7a461e94eb75a0a6 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sat, 11 Jul 2026 08:28:59 -0400 Subject: [PATCH] fix(agent): reject argless action in normalization; add boundary test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心算法 #10(Agent Loop):修复 Codex 质量审查 Critical—— _normalize_action 仅在除 tool 外至少存在一个平铺参数键时才收拢, {"tool": "x"} 无参结构不再被静默升级为空 args 合法结构,照旧 返回 None 走 retry 追问路径。补边界测试 + 测试辅助方法类型注解 与中文 docstring。 --- core/agent/loop.py | 9 ++++++--- tests/unit/test_agent_loop.py | 10 +++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/core/agent/loop.py b/core/agent/loop.py index 07c8862..e98cf20 100644 --- a/core/agent/loop.py +++ b/core/agent/loop.py @@ -310,17 +310,20 @@ class AgentLoop: deepseek 等模型稳定输出变体: 工具参数平铺在 action 下(缺 args 嵌套),确定性收拢为标准 {"tool": ..., "args": {...}} 结构。 - 标准嵌套结构与非法结构均原样返回,由调用方校验。 + 仅当除 tool 外至少存在一个平铺参数键时才收拢;无参结构 + (如 {"tool": "x"})原样返回交由调用方校验拒绝,避免把缺参 + 错误静默升级为空 args 合法结构。标准嵌套与非法结构同样原样返回。 参数: action: 从 LLM 输出解析出的 action 字段(任意类型)。 返回: - 归一化后的 action(仅平铺变体被改写,其余原样返回)。 + 归一化后的 action(仅带平铺参数的变体被改写,其余原样返回)。 """ if isinstance(action, dict) and "tool" in action and "args" not in action: flat_args = {k: v for k, v in action.items() if k != "tool"} - return {"tool": action["tool"], "args": flat_args} + if flat_args: + return {"tool": action["tool"], "args": flat_args} return action async def _execute_tool( diff --git a/tests/unit/test_agent_loop.py b/tests/unit/test_agent_loop.py index 298b4cf..3d9ae7a 100644 --- a/tests/unit/test_agent_loop.py +++ b/tests/unit/test_agent_loop.py @@ -263,7 +263,8 @@ _REAL_FLAT_FENCED = """{ class TestParseNormalization: """deepseek 输出变体(args 平铺 + ```json 围栏)归一化。""" - def _parse(self, content: str): + def _parse(self, content: str) -> tuple[str, dict, dict, str, dict, str] | None: + """构造 AgentLoop 并解析给定 content,返回 _parse_response 结果。""" loop = AgentLoop(llm=AsyncMock(), max_steps=10) return loop._parse_response(_make_response(content)) @@ -279,6 +280,7 @@ class TestParseNormalization: } def test_leading_json_fence(self) -> None: + """开头 ```json 围栏 + 尾部围栏包裹的标准结构可正常解析。""" content = '```json\n{"reflect": {}, "plan": {}, "action": {"tool": "submit_answer", "args": {"answer": "A"}}}\n```' parsed = self._parse(content) assert parsed is not None @@ -291,8 +293,14 @@ class TestParseNormalization: assert parsed[4] == {"tool": "submit_answer", "args": {"answer": "B"}} def test_action_missing_tool_still_rejected(self) -> None: + """action 缺 tool 键的结构仍被拒绝。""" content = json.dumps({"reflect": {}, "plan": {}, "action": {"node_id": "x"}}) assert self._parse(content) is None + def test_argless_action_still_rejected(self) -> None: + """有 tool、无 args、无平铺参数键 → 不得收拢为空 args,必须拒绝。""" + content = json.dumps({"reflect": {}, "plan": {}, "action": {"tool": "submit_answer"}}) + assert self._parse(content) is None + def test_empty_content_still_rejected(self) -> None: assert self._parse("") is None