fix(agent): reject argless action in normalization; add boundary test
核心算法 #10(Agent Loop):修复 Codex 质量审查 Critical——
_normalize_action 仅在除 tool 外至少存在一个平铺参数键时才收拢,
{"tool": "x"} 无参结构不再被静默升级为空 args 合法结构,照旧
返回 None 走 retry 追问路径。补边界测试 + 测试辅助方法类型注解
与中文 docstring。
This commit is contained in:
+5
-2
@@ -310,16 +310,19 @@ class AgentLoop:
|
|||||||
|
|
||||||
deepseek 等模型稳定输出变体: 工具参数平铺在 action 下(缺 args
|
deepseek 等模型稳定输出变体: 工具参数平铺在 action 下(缺 args
|
||||||
嵌套),确定性收拢为标准 {"tool": ..., "args": {...}} 结构。
|
嵌套),确定性收拢为标准 {"tool": ..., "args": {...}} 结构。
|
||||||
标准嵌套结构与非法结构均原样返回,由调用方校验。
|
仅当除 tool 外至少存在一个平铺参数键时才收拢;无参结构
|
||||||
|
(如 {"tool": "x"})原样返回交由调用方校验拒绝,避免把缺参
|
||||||
|
错误静默升级为空 args 合法结构。标准嵌套与非法结构同样原样返回。
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
action: 从 LLM 输出解析出的 action 字段(任意类型)。
|
action: 从 LLM 输出解析出的 action 字段(任意类型)。
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
归一化后的 action(仅平铺变体被改写,其余原样返回)。
|
归一化后的 action(仅带平铺参数的变体被改写,其余原样返回)。
|
||||||
"""
|
"""
|
||||||
if isinstance(action, dict) and "tool" in action and "args" not in 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"}
|
flat_args = {k: v for k, v in action.items() if k != "tool"}
|
||||||
|
if flat_args:
|
||||||
return {"tool": action["tool"], "args": flat_args}
|
return {"tool": action["tool"], "args": flat_args}
|
||||||
return action
|
return action
|
||||||
|
|
||||||
|
|||||||
@@ -263,7 +263,8 @@ _REAL_FLAT_FENCED = """{
|
|||||||
class TestParseNormalization:
|
class TestParseNormalization:
|
||||||
"""deepseek 输出变体(args 平铺 + ```json 围栏)归一化。"""
|
"""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)
|
loop = AgentLoop(llm=AsyncMock(), max_steps=10)
|
||||||
return loop._parse_response(_make_response(content))
|
return loop._parse_response(_make_response(content))
|
||||||
|
|
||||||
@@ -279,6 +280,7 @@ class TestParseNormalization:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def test_leading_json_fence(self) -> None:
|
def test_leading_json_fence(self) -> None:
|
||||||
|
"""开头 ```json 围栏 + 尾部围栏包裹的标准结构可正常解析。"""
|
||||||
content = '```json\n{"reflect": {}, "plan": {}, "action": {"tool": "submit_answer", "args": {"answer": "A"}}}\n```'
|
content = '```json\n{"reflect": {}, "plan": {}, "action": {"tool": "submit_answer", "args": {"answer": "A"}}}\n```'
|
||||||
parsed = self._parse(content)
|
parsed = self._parse(content)
|
||||||
assert parsed is not None
|
assert parsed is not None
|
||||||
@@ -291,8 +293,14 @@ class TestParseNormalization:
|
|||||||
assert parsed[4] == {"tool": "submit_answer", "args": {"answer": "B"}}
|
assert parsed[4] == {"tool": "submit_answer", "args": {"answer": "B"}}
|
||||||
|
|
||||||
def test_action_missing_tool_still_rejected(self) -> None:
|
def test_action_missing_tool_still_rejected(self) -> None:
|
||||||
|
"""action 缺 tool 键的结构仍被拒绝。"""
|
||||||
content = json.dumps({"reflect": {}, "plan": {}, "action": {"node_id": "x"}})
|
content = json.dumps({"reflect": {}, "plan": {}, "action": {"node_id": "x"}})
|
||||||
assert self._parse(content) is None
|
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:
|
def test_empty_content_still_rejected(self) -> None:
|
||||||
assert self._parse("") is None
|
assert self._parse("") is None
|
||||||
|
|||||||
Reference in New Issue
Block a user