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
+2 -1
View File
@@ -15,9 +15,10 @@ POSTGRES_PORT=5432
POSTGRES_SSLMODE=prefer POSTGRES_SSLMODE=prefer
POSTGRES_CONN_MAX_AGE=60 POSTGRES_CONN_MAX_AGE=60
# OpenAI Agents SDK:第一阶段仅预留配置,不执行真实模型调用 # OpenAI Agents SDK:第一阶段使用测试替身验证持久化编排,不执行真实模型调用
OPENAI_API_KEY=请替换为OpenAI_API_Key OPENAI_API_KEY=请替换为OpenAI_API_Key
OPENAI_MODEL= OPENAI_MODEL=
AGENT_DEFAULT_NAME=job_research
# 生产安全配置 # 生产安全配置
DJANGO_SECURE_SSL_REDIRECT=true DJANGO_SECURE_SSL_REDIRECT=true
+16 -2
View File
@@ -87,7 +87,12 @@ WSGI_APPLICATION = "JobRadar.wsgi.application"
ASGI_APPLICATION = "JobRadar.asgi.application" ASGI_APPLICATION = "JobRadar.asgi.application"
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, {
"NAME": (
"django.contrib.auth.password_validation."
"UserAttributeSimilarityValidator"
)
},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
@@ -110,13 +115,22 @@ LOGIN_URL = "accounts:login"
LOGIN_REDIRECT_URL = "agent_runtime:run-list" LOGIN_REDIRECT_URL = "agent_runtime:run-list"
LOGOUT_REDIRECT_URL = "accounts:login" LOGOUT_REDIRECT_URL = "accounts:login"
# Agent 配置只从运行环境读取。第一阶段的自动化测试使用 Stub Gateway,
# 因此缺少真实密钥不能影响登录、资料和运行记录查询等非 Agent 功能。
OPENAI_API_KEY = env("OPENAI_API_KEY", "")
OPENAI_MODEL = env("OPENAI_MODEL", "")
AGENT_DEFAULT_NAME = env("AGENT_DEFAULT_NAME", "job_research")
# 日志只输出可关联的结构化键值,不记录请求正文、Cookie 或认证头。 # 日志只输出可关联的结构化键值,不记录请求正文、Cookie 或认证头。
LOGGING = { LOGGING = {
"version": 1, "version": 1,
"disable_existing_loggers": False, "disable_existing_loggers": False,
"formatters": { "formatters": {
"structured": { "structured": {
"format": "time={asctime} level={levelname} logger={name} request_id={request_id} message={message}", "format": (
"time={asctime} level={levelname} logger={name} "
"request_id={request_id} message={message}"
),
"style": "{", "style": "{",
} }
}, },
+1 -1
View File
@@ -220,4 +220,4 @@ JobRadar 仅用于个人岗位信息整理与求职辅助。使用前应确认
当前版本:`0.1.0-dev` 当前版本:`0.1.0-dev`
当前阶段:架构与 Agent 契约已确定,阶段一的配置与依赖基线已落盘,下一步是用户认证与数据归属设计。 当前阶段:第一阶段工程基线及 SDK 测试替身编排闭环已经落地,正在完成质量工具环境同步、生产配置检查、页面人工验收和 PostgreSQL 集成验证;通过全部验收后进入第二阶段最小 Agent 闭环及真实 Agents SDK 接入。
+26 -2
View File
@@ -7,7 +7,10 @@ class ProfileTests(TestCase):
"""验证资料显式创建、认证保护和更新边界。""" """验证资料显式创建、认证保护和更新边界。"""
def setUp(self): def setUp(self):
self.user = get_user_model().objects.create_user(username="alice", password="safe-pass-123") self.user = get_user_model().objects.create_user(
username="alice",
password="safe-pass-123",
)
def test_profile_requires_login(self): def test_profile_requires_login(self):
response = self.client.get(reverse("accounts:profile")) response = self.client.get(reverse("accounts:profile"))
@@ -15,7 +18,28 @@ class ProfileTests(TestCase):
def test_profile_is_created_and_updated(self): def test_profile_is_created_and_updated(self):
self.client.force_login(self.user) self.client.force_login(self.user)
response = self.client.post(reverse("accounts:profile"), {"display_name": "爱丽丝", "timezone": "Asia/Shanghai"}) response = self.client.post(
reverse("accounts:profile"),
{"display_name": "爱丽丝", "timezone": "Asia/Shanghai"},
)
self.assertRedirects(response, reverse("accounts:profile")) self.assertRedirects(response, reverse("accounts:profile"))
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertEqual(self.user.profile.display_name, "爱丽丝") self.assertEqual(self.user.profile.display_name, "爱丽丝")
def test_profile_rejects_unknown_timezone(self):
self.client.force_login(self.user)
response = self.client.post(
reverse("accounts:profile"),
{"display_name": "爱丽丝", "timezone": "Unknown/Timezone"},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "选择一个有效的选项")
def test_login_and_post_logout_flow(self):
login_response = self.client.post(
reverse("accounts:login"),
{"username": "alice", "password": "safe-pass-123"},
)
self.assertRedirects(login_response, reverse("agent_runtime:run-list"))
logout_response = self.client.post(reverse("accounts:logout"))
self.assertRedirects(logout_response, reverse("accounts:login"))
+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.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 django.urls import reverse
from common.exceptions import InvalidStateTransition from common.exceptions import AgentConfigurationError, InvalidStateTransition, PermissionDenied
from .models import RunStatus from .config import AgentRuntimeConfig
from .services import create_run, finish_tool_call, start_tool_call, transition_run 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): class AgentRunTests(TestCase):
@@ -32,6 +50,17 @@ class AgentRunTests(TestCase):
response = self.client.get(reverse("agent_runtime:run-detail", args=(run.id,))) response = self.client.get(reverse("agent_runtime:run-detail", args=(run.id,)))
self.assertEqual(response.status_code, 404) 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): def test_tool_call_is_recorded_and_sanitized(self):
run = create_run(self.alice, "工具运行") run = create_run(self.alice, "工具运行")
transition_run(run.id, RunStatus.RUNNING) transition_run(run.id, RunStatus.RUNNING)
@@ -40,3 +69,80 @@ class AgentRunTests(TestCase):
finished = finish_tool_call(call.id, result={"count": 1}) finished = finish_tool_call(call.id, result={"count": 1})
self.assertEqual(finished.status, "succeeded") self.assertEqual(finished.status, "succeeded")
self.assertEqual(finished.result_summary, {"count": 1}) 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")
+4
View File
@@ -15,3 +15,7 @@ class DuplicateOperation(DomainError):
class PermissionDenied(DomainError): class PermissionDenied(DomainError):
"""操作者无权执行领域命令。""" """操作者无权执行领域命令。"""
class AgentConfigurationError(DomainError):
"""真实 Agent 执行所需配置缺失或不合法。"""
+10 -1
View File
@@ -12,6 +12,15 @@ request_id_context: contextvars.ContextVar[str] = contextvars.ContextVar(
SENSITIVE_KEYS = {"api_key", "authorization", "cookie", "password", "secret", "token"} SENSITIVE_KEYS = {"api_key", "authorization", "cookie", "password", "secret", "token"}
def _is_sensitive_key(key: object) -> bool:
"""识别常见秘密字段,同时避免把 `token_count` 等统计字段误判为秘密。"""
normalized = str(key).strip().lower().replace("-", "_")
if normalized in SENSITIVE_KEYS:
return True
return normalized.endswith(("_api_key", "_password", "_secret", "_cookie", "_token"))
class RequestContextFilter(logging.Filter): class RequestContextFilter(logging.Filter):
"""向每条日志补充请求关联标识,后台任务没有请求时使用短横线。""" """向每条日志补充请求关联标识,后台任务没有请求时使用短横线。"""
@@ -25,7 +34,7 @@ def sanitize_summary(value: Any) -> Any:
if isinstance(value, Mapping): if isinstance(value, Mapping):
return { return {
str(key): "***" if str(key).lower() in SENSITIVE_KEYS else sanitize_summary(item) str(key): "***" if _is_sensitive_key(key) else sanitize_summary(item)
for key, item in value.items() for key, item in value.items()
} }
if isinstance(value, list): if isinstance(value, list):
+44
View File
@@ -0,0 +1,44 @@
"""公共日志与请求上下文测试。"""
from django.test import SimpleTestCase
from django.urls import reverse
from .logging import request_id_context, sanitize_summary
class LoggingTests(SimpleTestCase):
"""验证敏感摘要递归脱敏,且统计字段不会被误删。"""
def test_nested_sensitive_values_are_masked(self):
result = sanitize_summary(
{
"access_token": "secret",
"nested": {"password": "secret", "token_count": 12},
"items": [{"api-key": "secret"}],
}
)
self.assertEqual(result["access_token"], "***")
self.assertEqual(result["nested"]["password"], "***")
self.assertEqual(result["nested"]["token_count"], 12)
self.assertEqual(result["items"][0]["api-key"], "***")
class RequestContextMiddlewareTests(SimpleTestCase):
"""验证入站关联标识的复用、非法值替换和请求结束后的上下文清理。"""
def test_valid_request_id_is_returned(self):
response = self.client.get(
reverse("accounts:login"),
headers={"X-Request-ID": "request-123"},
)
self.assertEqual(response.headers["X-Request-ID"], "request-123")
self.assertEqual(request_id_context.get(), "-")
def test_invalid_request_id_is_replaced(self):
response = self.client.get(
reverse("accounts:login"),
headers={"X-Request-ID": "invalid value"},
)
generated = response.headers["X-Request-ID"]
self.assertNotEqual(generated, "invalid value")
self.assertEqual(len(generated), 32)