feat(agent-runtime): 补全测试替身编排闭环

This commit is contained in:
bruce
2026-09-16 17:31:12 +08:00
parent d3763633c2
commit 1e56a55399
10 changed files with 302 additions and 11 deletions
+38
View File
@@ -0,0 +1,38 @@
"""Agent 运行配置边界。"""
from dataclasses import dataclass
from django.conf import settings
from common.exceptions import AgentConfigurationError
@dataclass(frozen=True)
class AgentRuntimeConfig:
"""集中表达真实 SDK 执行所需配置,避免业务服务直接读取环境变量。"""
api_key: str
model: str
agent_name: str
@classmethod
def from_settings(cls) -> "AgentRuntimeConfig":
"""从 Django 设置构建配置;这里只读取,不在日志或异常中回显秘密。"""
return cls(
api_key=str(settings.OPENAI_API_KEY or "").strip(),
model=str(settings.OPENAI_MODEL or "").strip(),
agent_name=str(settings.AGENT_DEFAULT_NAME or "job_research").strip(),
)
def require_real_execution(self) -> "AgentRuntimeConfig":
"""真实执行前快速失败;非 Agent 页面无需调用此方法。"""
missing = []
if not self.api_key:
missing.append("OPENAI_API_KEY")
if not self.model:
missing.append("OPENAI_MODEL")
if missing:
raise AgentConfigurationError(f"真实 Agent 执行缺少配置:{', '.join(missing)}。")
return self
+51
View File
@@ -0,0 +1,51 @@
"""Gateway 执行与本地 Agent Run 持久化之间的编排服务。"""
import logging
from common.logging import sanitize_summary
from .gateway import AgentExecutionRequest, AgentRunnerGateway
from .models import AgentRun, RunStatus
from .services import transition_run
logger = logging.getLogger(__name__)
def execute_run(run_id, gateway: AgentRunnerGateway) -> AgentRun:
"""使用注入的 Gateway 驱动一次运行,并确保所有结果都进入稳定终态。
外部调用发生在两个短事务之间:开始状态先提交,Gateway 返回或抛错后再用
独立事务保存成功或失败结果。该函数不重试,避免第一阶段产生重复外部副作用。
"""
running = transition_run(run_id, RunStatus.RUNNING)
request = AgentExecutionRequest(
agent_run_id=str(running.id),
user_id=running.owner_id,
input_summary=sanitize_summary(running.input_summary),
)
try:
result = gateway.run(request)
except Exception as exc: # Gateway 是外部边界,必须把未知异常转换为可审计失败。
# 外部异常消息可能夹带请求参数或认证信息,因此日志和数据库只保留异常类型。
error_type = type(exc).__name__
logger.error(
"Agent Gateway 执行异常,异常类型=%s",
error_type,
extra={"agent_run_id": str(running.id)},
)
return transition_run(
running.id,
RunStatus.FAILED,
error_code="gateway_error",
error_summary=f"Agent Gateway 执行异常:{error_type}",
)
if result.succeeded:
return transition_run(running.id, RunStatus.SUCCEEDED, output=result.output_summary)
return transition_run(
running.id,
RunStatus.FAILED,
error_code=result.error_code or "agent_failed",
error_summary=result.error_summary,
)
+110 -4
View File
@@ -1,11 +1,29 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.db import IntegrityError, transaction
from django.test import SimpleTestCase, TestCase, override_settings
from django.urls import reverse
from common.exceptions import InvalidStateTransition
from common.exceptions import AgentConfigurationError, InvalidStateTransition, PermissionDenied
from .models import RunStatus
from .services import create_run, finish_tool_call, start_tool_call, transition_run
from .config import AgentRuntimeConfig
from .gateway import AgentExecutionResult, StubAgentRunnerGateway
from .models import ApprovalStatus, RunStatus, ToolCallStatus
from .orchestration import execute_run
from .services import (
create_run,
finish_tool_call,
request_approval,
resolve_approval,
start_tool_call,
transition_run,
)
class ExplodingGateway:
"""模拟外部边界抛错,并故意在异常文本中携带不应落库的秘密。"""
def run(self, request):
raise RuntimeError("api_key=should-not-be-stored")
class AgentRunTests(TestCase):
@@ -32,6 +50,17 @@ class AgentRunTests(TestCase):
response = self.client.get(reverse("agent_runtime:run-detail", args=(run.id,)))
self.assertEqual(response.status_code, 404)
def test_run_list_only_contains_current_user_data(self):
create_run(self.alice, "爱丽丝的运行")
create_run(self.bob, "鲍勃的运行")
self.client.force_login(self.alice)
response = self.client.get(reverse("agent_runtime:run-list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "爱丽丝的运行")
self.assertNotContains(response, "鲍勃的运行")
def test_tool_call_is_recorded_and_sanitized(self):
run = create_run(self.alice, "工具运行")
transition_run(run.id, RunStatus.RUNNING)
@@ -40,3 +69,80 @@ class AgentRunTests(TestCase):
finished = finish_tool_call(call.id, result={"count": 1})
self.assertEqual(finished.status, "succeeded")
self.assertEqual(finished.result_summary, {"count": 1})
def test_tool_call_failure_and_duplicate_finish_are_recorded(self):
run = create_run(self.alice, "失败工具运行")
transition_run(run.id, RunStatus.RUNNING)
call = start_tool_call(run.id, "call-1", "demo_tool", idempotency_key="same-operation")
failed = finish_tool_call(call.id, error_code="timeout", error_summary="请求超时")
self.assertEqual(failed.status, ToolCallStatus.FAILED)
self.assertEqual(failed.error_code, "timeout")
with self.assertRaises(InvalidStateTransition):
finish_tool_call(call.id, result={"unexpected": True})
def test_duplicate_tool_idempotency_key_is_rejected(self):
run = create_run(self.alice, "幂等工具运行")
transition_run(run.id, RunStatus.RUNNING)
start_tool_call(run.id, "call-1", "demo_tool", idempotency_key="same-operation")
with self.assertRaises(IntegrityError), transaction.atomic():
start_tool_call(run.id, "call-2", "demo_tool", idempotency_key="same-operation")
def test_approval_can_be_approved_once_by_owner(self):
run = create_run(self.alice, "确认运行")
transition_run(run.id, RunStatus.RUNNING)
approval = request_approval(run.id, "approval-1", "external_action", {"token": "secret"})
self.assertEqual(approval.request_summary["token"], "***")
resolved = resolve_approval(self.alice, approval.id, True, {"reason": "允许"})
self.assertEqual(resolved.status, ApprovalStatus.APPROVED)
run.refresh_from_db()
self.assertEqual(run.status, RunStatus.RUNNING)
with self.assertRaises(InvalidStateTransition):
resolve_approval(self.alice, approval.id, True)
def test_approval_rejects_cross_user_and_can_cancel_run(self):
run = create_run(self.alice, "拒绝确认运行")
transition_run(run.id, RunStatus.RUNNING)
approval = request_approval(run.id, "approval-1", "external_action")
with self.assertRaises(PermissionDenied):
resolve_approval(self.bob, approval.id, False)
resolve_approval(self.alice, approval.id, False)
run.refresh_from_db()
self.assertEqual(run.status, RunStatus.CANCELLED)
def test_stub_gateway_drives_successful_persistent_run(self):
run = create_run(self.alice, "Stub 成功运行", {"query": "Python"})
gateway = StubAgentRunnerGateway(AgentExecutionResult(True, {"count": 2}))
finished = execute_run(run.id, gateway)
self.assertEqual(finished.status, RunStatus.SUCCEEDED)
self.assertEqual(finished.output_summary, {"count": 2})
self.assertEqual(finished.events.count(), 3)
def test_stub_gateway_failure_and_exception_reach_failed_state(self):
failed_run = create_run(self.alice, "Stub 失败运行")
failed_gateway = StubAgentRunnerGateway(
AgentExecutionResult(False, error_code="model_error", error_summary="模型失败")
)
failed = execute_run(failed_run.id, failed_gateway)
self.assertEqual(failed.status, RunStatus.FAILED)
self.assertEqual(failed.error_code, "model_error")
exploding_run = create_run(self.alice, "Stub 异常运行")
exploded = execute_run(exploding_run.id, ExplodingGateway())
self.assertEqual(exploded.status, RunStatus.FAILED)
self.assertNotIn("should-not-be-stored", exploded.error_summary)
class AgentRuntimeConfigTests(SimpleTestCase):
"""验证真实执行配置只在调用边界检查,不影响其他 Django 功能。"""
@override_settings(OPENAI_API_KEY="", OPENAI_MODEL="", AGENT_DEFAULT_NAME="job_research")
def test_real_execution_requires_key_and_model(self):
with self.assertRaises(AgentConfigurationError):
AgentRuntimeConfig.from_settings().require_real_execution()
@override_settings(
OPENAI_API_KEY="test-key", OPENAI_MODEL="test-model", AGENT_DEFAULT_NAME="job_research"
)
def test_complete_configuration_is_accepted_without_external_call(self):
config = AgentRuntimeConfig.from_settings().require_real_execution()
self.assertEqual(config.model, "test-model")