From 1e56a55399906b00343900bea2ef6eae66b0775f Mon Sep 17 00:00:00 2001 From: bruce Date: Wed, 16 Sep 2026 17:31:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent-runtime):=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=9B=BF=E8=BA=AB=E7=BC=96=E6=8E=92=E9=97=AD?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 +- JobRadar/settings/base.py | 18 +++++- README.md | 2 +- accounts/tests.py | 28 +++++++- agent_runtime/config.py | 38 +++++++++++ agent_runtime/orchestration.py | 51 +++++++++++++++ agent_runtime/tests.py | 114 +++++++++++++++++++++++++++++++-- common/exceptions.py | 4 ++ common/logging.py | 11 +++- common/tests.py | 44 +++++++++++++ 10 files changed, 302 insertions(+), 11 deletions(-) create mode 100644 agent_runtime/config.py create mode 100644 agent_runtime/orchestration.py create mode 100644 common/tests.py diff --git a/.env.example b/.env.example index 83d344f..31b4a6f 100644 --- a/.env.example +++ b/.env.example @@ -15,9 +15,10 @@ POSTGRES_PORT=5432 POSTGRES_SSLMODE=prefer POSTGRES_CONN_MAX_AGE=60 -# OpenAI Agents SDK:第一阶段仅预留配置,不执行真实模型调用 +# OpenAI Agents SDK:第一阶段使用测试替身验证持久化编排,不执行真实模型调用 OPENAI_API_KEY=请替换为OpenAI_API_Key OPENAI_MODEL= +AGENT_DEFAULT_NAME=job_research # 生产安全配置 DJANGO_SECURE_SSL_REDIRECT=true diff --git a/JobRadar/settings/base.py b/JobRadar/settings/base.py index 8b230b3..aa26815 100644 --- a/JobRadar/settings/base.py +++ b/JobRadar/settings/base.py @@ -87,7 +87,12 @@ WSGI_APPLICATION = "JobRadar.wsgi.application" ASGI_APPLICATION = "JobRadar.asgi.application" 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.CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, @@ -110,13 +115,22 @@ LOGIN_URL = "accounts:login" LOGIN_REDIRECT_URL = "agent_runtime:run-list" 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 或认证头。 LOGGING = { "version": 1, "disable_existing_loggers": False, "formatters": { "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": "{", } }, diff --git a/README.md b/README.md index 154060b..aebe1bc 100644 --- a/README.md +++ b/README.md @@ -220,4 +220,4 @@ JobRadar 仅用于个人岗位信息整理与求职辅助。使用前应确认 当前版本:`0.1.0-dev` -当前阶段:架构与 Agent 契约已确定,阶段一的配置与依赖基线已落盘,下一步是用户认证与数据归属设计。 +当前阶段:第一阶段工程基线及 SDK 测试替身编排闭环已经落地,正在完成质量工具环境同步、生产配置检查、页面人工验收和 PostgreSQL 集成验证;通过全部验收后进入第二阶段最小 Agent 闭环及真实 Agents SDK 接入。 diff --git a/accounts/tests.py b/accounts/tests.py index 5f3e7c8..d906866 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -7,7 +7,10 @@ class ProfileTests(TestCase): """验证资料显式创建、认证保护和更新边界。""" 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): response = self.client.get(reverse("accounts:profile")) @@ -15,7 +18,28 @@ class ProfileTests(TestCase): def test_profile_is_created_and_updated(self): 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.user.refresh_from_db() 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")) diff --git a/agent_runtime/config.py b/agent_runtime/config.py new file mode 100644 index 0000000..4272b99 --- /dev/null +++ b/agent_runtime/config.py @@ -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 diff --git a/agent_runtime/orchestration.py b/agent_runtime/orchestration.py new file mode 100644 index 0000000..7aeb852 --- /dev/null +++ b/agent_runtime/orchestration.py @@ -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, + ) diff --git a/agent_runtime/tests.py b/agent_runtime/tests.py index 3e248b8..09a8516 100644 --- a/agent_runtime/tests.py +++ b/agent_runtime/tests.py @@ -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") diff --git a/common/exceptions.py b/common/exceptions.py index fa52b1e..5cbb84f 100644 --- a/common/exceptions.py +++ b/common/exceptions.py @@ -15,3 +15,7 @@ class DuplicateOperation(DomainError): class PermissionDenied(DomainError): """操作者无权执行领域命令。""" + + +class AgentConfigurationError(DomainError): + """真实 Agent 执行所需配置缺失或不合法。""" diff --git a/common/logging.py b/common/logging.py index 9a6f701..a5383c7 100644 --- a/common/logging.py +++ b/common/logging.py @@ -12,6 +12,15 @@ request_id_context: contextvars.ContextVar[str] = contextvars.ContextVar( 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): """向每条日志补充请求关联标识,后台任务没有请求时使用短横线。""" @@ -25,7 +34,7 @@ def sanitize_summary(value: Any) -> Any: if isinstance(value, Mapping): 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() } if isinstance(value, list): diff --git a/common/tests.py b/common/tests.py new file mode 100644 index 0000000..1d29fd3 --- /dev/null +++ b/common/tests.py @@ -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)