fix: 实现 Agent 修复验证闭环

This commit is contained in:
cnbugs
2026-07-25 13:10:21 +08:00
parent 47abfef238
commit f2254646c1
6 changed files with 264 additions and 30 deletions
+55 -6
View File
@@ -29,19 +29,68 @@ class AgentApiTest(unittest.TestCase):
self.assertEqual(events[-1]["type"], "done")
self.assertEqual(events[-2]["type"], "final")
def test_config_view_is_read_only_and_does_not_require_approval(self):
from backend.agent_tools import classify_kubectl_command
decision = classify_kubectl_command("kubectl config view")
self.assertEqual(decision.mode, "read")
self.assertFalse(decision.requires_approval)
def test_execute_approved_command_continues_agent_with_command_result(self):
approval = {
"command": "kubectl rollout restart deployment/api -n prod",
"reason": "重启工作负载",
"run_id": "",
"report": "diagnosis report",
"question": "修复 API",
"execution_mode": "ai",
"messages": [{"role": "system", "content": "context"}],
"next_step": 2,
"max_steps": 30,
"command_counts": {"kubectl rollout restart deployment/api -n prod": 1},
}
async def fake_execute(command):
return {"command": command, "mode": "write", "returncode": 0, "stdout": "restarted", "stderr": ""}
async def fake_resume(pending, result):
self.assertEqual(result["stdout"], "restarted")
yield {"type": "thinking", "step": 2, "message": "继续验证"}
yield {"type": "command", "command": "kubectl get pods -n prod", "mode": "read", "reason": "验证恢复"}
yield {"type": "result", "command": "kubectl get pods -n prod", "mode": "read", "returncode": 0, "stdout": "Running", "stderr": ""}
yield {"type": "final", "content": "故障已恢复", "model": "test"}
async def run():
with patch.object(main, "take_pending_approval", return_value=approval), patch.object(main, "execute_kubectl", fake_execute), patch.object(main, "resume_diagnostic_agent", fake_resume):
response = await main.execute_approved_agent_command(main.AgentApprovalRequest(approval_id="abc", approved=True))
return [parse_sse(event) async for event in response.body_iterator]
events = asyncio.run(run())
self.assertEqual(events[0]["type"], "approved_result")
self.assertEqual(events[1]["type"], "thinking")
self.assertEqual(events[-2]["type"], "final")
self.assertEqual(events[-1]["type"], "done")
def test_execute_approved_command_once(self):
approval = {"command": "kubectl scale deployment/api --replicas=2", "reason": "恢复副本"}
async def fake_execute(command):
return {"command": command, "mode": "write", "returncode": 0, "stdout": "scaled", "stderr": ""}
async def run():
with patch.object(main, "take_pending_approval", return_value=approval), patch.object(main, "execute_kubectl", fake_execute):
return await main.execute_approved_agent_command(main.AgentApprovalRequest(approval_id="abc", approved=True))
async def fake_resume(pending, result):
yield {"type": "final", "content": "验证完成", "model": "test"}
result = asyncio.run(run())
self.assertEqual(result["returncode"], 0)
self.assertEqual(result["stdout"], "scaled")
async def run():
with patch.object(main, "take_pending_approval", return_value=approval), patch.object(main, "execute_kubectl", fake_execute), patch.object(main, "resume_diagnostic_agent", fake_resume):
response = await main.execute_approved_agent_command(main.AgentApprovalRequest(approval_id="abc", approved=True))
return [parse_sse(event) async for event in response.body_iterator]
events = asyncio.run(run())
self.assertEqual(events[0]["type"], "approved_result")
self.assertEqual(events[0]["returncode"], 0)
self.assertEqual(events[0]["stdout"], "scaled")
self.assertEqual(events[-1]["type"], "done")
if __name__ == "__main__":
+41 -3
View File
@@ -13,7 +13,7 @@ class DiagnosticAgentTest(unittest.TestCase):
def test_executes_read_only_tool_and_returns_final_answer(self):
responses = iter([
'{"action":"command","command":"kubectl get pods -A","reason":"查看异常 Pod"}',
'{"action":"final","analysis":"发现一个异常 Pod"}',
'{"action":"final","analysis":"发现一个异常 Pod","verified":true,"remaining_issues":0}',
])
async def fake_completion(messages):
@@ -90,7 +90,7 @@ class DiagnosticAgentTest(unittest.TestCase):
def test_ai_mode_forces_initial_read_only_probe_before_model_planning(self):
responses = iter([
'{"action":"final","analysis":"根据采集结果完成分析"}',
'{"action":"final","analysis":"根据采集结果完成分析","verified":true,"remaining_issues":0}',
])
async def fake_completion(messages):
@@ -131,7 +131,7 @@ class DiagnosticAgentTest(unittest.TestCase):
)
for index in range(command_count)
],
'{"action":"final","analysis":"已完成根因分析"}',
'{"action":"final","analysis":"已完成根因分析","verified":true,"remaining_issues":0}',
])
executed = []
@@ -168,6 +168,44 @@ class DiagnosticAgentTest(unittest.TestCase):
self.assertIn("安全上限", events[-1]["message"])
self.assertNotIn("人工排查", events[-1]["message"])
def test_agent_rejects_final_until_verification_reports_no_remaining_issues(self):
responses = iter([
'{"action":"final","analysis":"已经修复"}',
'{"action":"command","command":"kubectl get pods -A","reason":"执行全量复检"}',
'{"action":"final","analysis":"所有工作负载恢复正常","verified":true,"remaining_issues":0}',
])
executed = []
async def fake_completion(messages):
return next(responses)
async def fake_execute(command):
executed.append(command)
stdout = "node-a Ready" if command == "kubectl get nodes -o wide" else "all pods Running"
return {"command": command, "mode": "read", "returncode": 0, "stdout": stdout, "stderr": ""}
async def run():
with patch("backend.ai_agent._complete_chat", fake_completion), patch("backend.ai_agent.execute_kubectl", fake_execute):
return await collect_events(run_diagnostic_agent("report", execution_mode="ai", max_steps=5))
events = asyncio.run(run())
self.assertEqual(executed, ["kubectl get nodes -o wide", "kubectl get pods -A"])
self.assertEqual(events[-1]["type"], "final")
self.assertTrue(events[-1]["verified"])
self.assertEqual(events[-1]["remaining_issues"], 0)
def test_parse_final_requires_explicit_verification_fields(self):
from backend.ai_agent import _parse_agent_action
with self.assertRaises(ValueError):
_parse_agent_action('{"action":"final","analysis":"看起来正常"}')
action = _parse_agent_action(
'{"action":"final","analysis":"全部正常","verified":true,"remaining_issues":0}'
)
self.assertTrue(action["verified"])
self.assertEqual(action["remaining_issues"], 0)
def test_stops_when_model_returns_invalid_action(self):
async def fake_completion(messages):
return "not-json"