Files
group_fqcd_jr/app/service/agent/base.py
T
lzf_0626 6516ccb385 feat: 第二版——接口契约对齐 docs/05,修复静默故障与数据库基线
相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。

一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
   approve→reviews(需 body decision)、activate→activations、
   rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
   UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
   RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
   输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
   {data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
   不再返回 FastAPI 默认的 {"detail": ...}。

二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。

三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
  顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
  但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
  召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
  .env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。

四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。

五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。

验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
2026-09-10 15:55:54 +08:00

176 lines
8.0 KiB
Python

import asyncio
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from app.core.contracts import (
AgentDefinition,
AgentRequest,
AgentResult,
CoreResult,
IntentResult,
RecalledMemory,
RequestContext,
ResolvedAgentConfig,
RunProgressEvent,
SourceReference,
ToolCallRecord,
)
from app.core.errors import RecoverableAgentError, UpstreamTimeoutError
from app.service.agent.authorizer import AgentAuthorizer
from app.service.agent.governance import AgentGovernance
from app.service.intent_classifier import IntentClassifier, IntentEndpointResolver
from app.service.model_gateway import ModelExecution, ModelGenerationService
from app.service.tool_executor import ToolExecutor
class BaseAgent(ABC):
definition: AgentDefinition
def __init__(self, definition: AgentDefinition) -> None:
self.definition = definition
self._governance: AgentGovernance | None = None
self.config: ResolvedAgentConfig | None = None
self.memories: tuple[RecalledMemory, ...] = ()
self._model_service: ModelGenerationService | None = None
self._tool_executor: ToolExecutor | None = None
self._tool_records: list[ToolCallRecord] = []
self._tool_references: list[SourceReference] = []
self._intent_classifier: IntentClassifier | None = None
self._intent_endpoint_resolver: IntentEndpointResolver | None = None
self._classified_intent: IntentResult | None = None
def bind_governance(self, governance: AgentGovernance) -> None:
if self._governance is not None:
raise TypeError("Agent instances must not be reused")
self._governance = governance
def bind_model_service(self, service: ModelGenerationService) -> None:
if self._model_service is not None:
raise TypeError("model service is already bound")
self._model_service = service
def bind_tool_executor(self, executor: ToolExecutor) -> None:
if self._tool_executor is not None:
raise TypeError("tool executor is already bound")
self._tool_executor = executor
def bind_intent_classifier(
self, classifier: IntentClassifier, resolver: IntentEndpointResolver
) -> None:
if self._intent_classifier is not None:
raise TypeError("intent classifier is already bound")
self._intent_classifier = classifier
self._intent_endpoint_resolver = resolver
async def call_tool(
self, name: str, arguments: dict[str, object], *, intent: str,
context: RequestContext,
) -> object:
if self._tool_executor is None or self.config is None:
raise RecoverableAgentError("工具执行器未由工厂注入")
execution = await self._tool_executor.execute(
name=name, arguments=arguments, intent=intent,
configured_tools=self.config.allowed_tools_by_intent, context=context,
)
self._tool_records.append(execution.record)
self._tool_references.extend(execution.references)
return execution.output
async def generate_with_model(
self, endpoints: list[object], prompt: str, *, max_attempts: int = 2
) -> ModelExecution:
if self._model_service is None:
raise RecoverableAgentError("模型服务未由工厂注入")
return await self._model_service.generate(endpoints, prompt, max_attempts=max_attempts)
def __init_subclass__(cls, **kwargs: object) -> None:
super().__init_subclass__(**kwargs)
forbidden = {"execute", "validate_input", "validate_access", "resolve_config",
"recall_memory", "check_compliance", "_execute_governed",
"bind_governance", "bind_model_service", "generate_with_model",
"bind_tool_executor", "call_tool", "bind_intent_classifier",
"classify_intent"}
overridden = forbidden.intersection(cls.__dict__)
if overridden:
raise TypeError(f"Agent cannot override governance methods: {sorted(overridden)}")
async def execute(
self, request: AgentRequest, context: RequestContext, run_id: str
) -> AsyncIterator[RunProgressEvent]:
self.validate_input(request)
await self.validate_access(request, context)
await self.resolve_config(context)
await self.recall_memory(request, context)
await self.classify_intent(request)
governance, config, memories = self._governance, self.config, self.memories
if governance is None or config is None:
raise RecoverableAgentError("治理初始化失败")
yield RunProgressEvent(event_type="start", run_id=run_id)
result = await self._execute_governed(request, context, run_id)
# Capture the trusted snapshot before entering business code.
result = await governance.review(result, context, config, memories)
yield RunProgressEvent(
event_type="done", run_id=run_id,
payload={"result": result.model_dump(mode="json")},
)
def validate_input(self, request: AgentRequest) -> None:
if request.agent_type != self.definition.agent_type:
raise ValueError("request agent_type does not match definition")
async def validate_access(self, request: AgentRequest, context: RequestContext) -> None:
AgentAuthorizer.ensure_allowed(self.definition, context)
async def resolve_config(self, context: RequestContext) -> None:
if self._governance is None:
raise RecoverableAgentError("Agent 未由工厂注入治理依赖")
self.config = await self._governance.resolve(self.definition, context)
async def recall_memory(self, request: AgentRequest, context: RequestContext) -> None:
if self._governance is None:
raise RecoverableAgentError("缺少记忆治理依赖")
self.memories = await self._governance.recall(context)
if any(memory.customer_id != context.user_id for memory in self.memories):
raise RecoverableAgentError("记忆召回越过客户范围")
async def classify_intent(self, request: AgentRequest) -> IntentResult | None:
if self._intent_classifier is None or self._intent_endpoint_resolver is None:
return None
endpoints = await self._intent_endpoint_resolver.resolve(
agent_type=self.definition.agent_type, task_type="intent_classification"
)
self._classified_intent = await self._intent_classifier.classify(
message=request.message,
supported_intents=self.definition.supported_intents,
endpoints=endpoints,
# 按 agent_type 读取该 Agent 当前生效的意图配置(描述/示例/阈值)。
agent_type=self.definition.agent_type,
)
return self._classified_intent
async def check_compliance(self, result: AgentResult, context: RequestContext) -> AgentResult:
if self._governance is None or self.config is None:
raise RecoverableAgentError("缺少合规治理依赖")
return await self._governance.review(result, context, self.config, self.memories)
async def _execute_governed(
self, request: AgentRequest, context: RequestContext, run_id: str
) -> AgentResult:
if self.config is None:
raise RecoverableAgentError("缺少运行配置")
try:
async with asyncio.timeout(self.config.timeout_seconds):
result = await self.handle(request, context)
except TimeoutError as exc:
raise UpstreamTimeoutError("Agent 执行超时") from exc
result = result.model_copy(update={
"intent": result.intent or self._classified_intent,
"tool_calls": tuple(self._tool_records),
"source_references": tuple(result.source_references) + tuple(self._tool_references),
})
return AgentResult(run_id=run_id, result=result)
@abstractmethod
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
"""Implement domain-specific intent handling here."""