相对第一版 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(含失败关闭反证)。
88 lines
4.3 KiB
Python
88 lines
4.3 KiB
Python
"""示例业务 Agent:查询场内基金行情,演示组员接入底座的完整生产路径。
|
||
|
||
这份实现刻意只做三件事:声明 `AgentDefinition`、实现 `handle()`、通过底座方法
|
||
`self.call_tool(...)` 调用公共只读工具。鉴权、发布配置解析、记忆召回、意图分类、
|
||
工具白名单、来源引用、合规审查、审计和持久化全部由底座完成,业务代码不重复实现。
|
||
|
||
接入要点(照做即可复用):
|
||
|
||
1. 意图码必须同时出现在三处:`AgentDefinition.supported_intents`、当前 **active**
|
||
的 `config_release` 中 `namespace=agent_tools` 的 `config_key=<agent_type>:<intent>`,
|
||
以及 `self.call_tool(..., intent=...)` 的实参;
|
||
2. 工具名必须同时出现在两处:`AgentDefinition.allowed_tools`(代码上限)与该意图的
|
||
白名单配置(发布配置只能缩小、不能放大代码声明);
|
||
3. 工具返回值只做展示与摘要,不得改写为成交、委托或持仓语义;`degraded=true` 时
|
||
必须显式提示数据降级。
|
||
"""
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
from app.core.contracts import AgentDefinition, AgentRequest, CoreResult, RequestContext
|
||
from app.service.agent.base import BaseAgent
|
||
|
||
# 意图码:与 AgentDefinition.supported_intents、发布版工具白名单的 key 必须完全一致。
|
||
INTENT_FUND_QUOTE = "fund_quote"
|
||
# 工具名:必须在 bootstrap 的 ToolRegistry 中已注册,且权限/角色满足调用方身份。
|
||
TOOL_NAME = "query_fund_quote"
|
||
# 消息里没有六位代码时的默认查询对象:深交所场内 ETF(在行情工具默认允许代码表内)。
|
||
DEFAULT_FUND_CODE = "159382"
|
||
# 单次运行最多查询的代码数量,避免消息里的数字被无边界地当作代码使用。
|
||
MAX_FUND_CODES = 3
|
||
# 六位数字代码识别:用前后界防止把「2026-09-10」这类数字串切出假代码。
|
||
CODE_PATTERN = re.compile(r"(?<!\d)(\d{6})(?!\d)")
|
||
|
||
|
||
def extract_fund_codes(message: str) -> tuple[str, ...]:
|
||
"""从用户消息里提取基金代码;没有命中时回退到默认代码。"""
|
||
found = tuple(dict.fromkeys(CODE_PATTERN.findall(message)))
|
||
return found[:MAX_FUND_CODES] or (DEFAULT_FUND_CODE,)
|
||
|
||
|
||
def describe_quote(quote: dict[str, Any]) -> str:
|
||
"""把行情字典整理成一行可读结论,保留来源与降级标记。"""
|
||
code = str(quote.get("fund_code", ""))
|
||
name = str(quote.get("fund_name") or f"基金 {code}")
|
||
nav = quote.get("nav")
|
||
nav_text = f"净值 {nav}" if nav is not None else "净值暂缺"
|
||
nav_date = str(quote.get("nav_date") or "无")
|
||
source = str(quote.get("quote_source") or "unknown")
|
||
degraded = bool(quote.get("degraded"))
|
||
if degraded:
|
||
tail = "(数据已降级,仅供参考,不构成交易依据)"
|
||
else:
|
||
tail = "(行情仅供查询参考,不构成交易依据)"
|
||
return f"{code} {name}:{nav_text},净值日期 {nav_date},来源 {source}{tail}"
|
||
|
||
|
||
class FundQueryDemoAgent(BaseAgent):
|
||
"""最小可用的业务 Agent:一个意图 + 一个公共只读工具。"""
|
||
|
||
definition = AgentDefinition(
|
||
agent_type="fund_query_demo",
|
||
version="1.0.0",
|
||
allowed_roles=("customer", "advisor", "operator", "admin"),
|
||
allowed_portals=("api",),
|
||
# 代码上限:实际可用范围由发布配置的意图白名单收窄。
|
||
allowed_tools=(TOOL_NAME,),
|
||
supported_intents=(INTENT_FUND_QUOTE,),
|
||
)
|
||
|
||
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
|
||
codes = extract_fund_codes(request.message)
|
||
output = await self.call_tool(
|
||
TOOL_NAME,
|
||
{"fund_codes": list(codes), "limit": len(codes)},
|
||
intent=INTENT_FUND_QUOTE,
|
||
context=context,
|
||
)
|
||
quotes: list[dict[str, Any]] = (
|
||
[item for item in output if isinstance(item, dict)] if isinstance(output, list) else []
|
||
)
|
||
if not quotes:
|
||
joined = "、".join(codes)
|
||
return CoreResult(text=f"未查询到 {joined} 的可用行情,请稍后重试或转人工核实。")
|
||
# tool_calls 与 source_references 由底座在 handle() 返回后统一附加,
|
||
# 业务代码不得自行伪造来源引用。
|
||
return CoreResult(text="\n".join(describe_quote(quote) for quote in quotes))
|