# 业务 Agent 接入实操(示例验证版) > ⚠️ **内容仍有效,仅命令路径陈旧**:文中用 `D:\\conda\\envs\\jr_py313\\python.exe`,本机实际为 `.\\.venv\\Scripts\\python.exe`。示例 `FundQueryDemoAgent` 确在 `bootstrap.py` 注册。 > > (此批注由 2026-09-11 只读审计加入;原文未改动。详见 `docs/superpowers/ARCHIVE-2026-09-11-文档清理归档.md` §3) > 版本:v1.0|适用对象:要在本底座上开发业务 Agent 的组员 > 本文所有步骤均在**真实环境**执行过:真实 MySQL、真实 JWT、真实模型端点(`deepseek-flash`)、 > 真实外部行情接口。示例 Agent 已注册进生产装配,端到端与反证测试全部通过。 > 权威接口定义以《05-接口文档.md》为准,数据库以《00-新数据库基线设计.md》为准。 ## 0. 先看结论:这次验证的是什么 | 项目 | 值 | | --- | --- | | 示例 Agent | `app/service/agent/implementations/fund_query_demo.py`,类 `FundQueryDemoAgent` | | `agent_type` | `fund_query_demo` | | 意图 | `fund_quote`(只有一个意图,避免意图分类返回未声明值) | | 工具声明 | `allowed_tools=("query_fund_quote",)`,只读、权限 `fund:quote:read` | | 注册位置 | `app/service/agent/bootstrap.py` 的 `register_business_agents(factory)` | | 配置读取路径 | 当前 `status='active'` 的 `config_release` + `namespace='agent_tools'` + `config_key=':'`,与代码 `allowed_tools` **取交集** | | 一键复现 | `python tools/demo_agent_e2e.py`(发布配置 → 正常路径 → 反证 → 恢复 → 清理) | 一键脚本实测结果:**9 项断言全 PASS,0 FAIL**。 ## 1. 写 Agent 代码(1 个文件) 新建 `app/service/agent/implementations/<你的 Agent>.py`,只做三件事:声明定义、实现 `handle()`、 通过 `self.call_tool(...)` 调公共工具。**不要**覆盖 `execute()`/`resolve_config()`/`call_tool()` 等治理方法 (`BaseAgent.__init_subclass__` 会直接抛 `TypeError`)。 ```python class FundQueryDemoAgent(BaseAgent): definition = AgentDefinition( agent_type="fund_query_demo", version="1.0.0", allowed_roles=("customer", "advisor", "operator", "admin"), allowed_portals=("api",), # JWT 身份解析后的 portal 固定是 api allowed_tools=("query_fund_quote",), # 代码上限,发布配置只能缩小 supported_intents=("fund_quote",), # 必须与配置里的 config_key 后缀一致 ) async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult: output = await self.call_tool( "query_fund_quote", {"fund_codes": list(extract_fund_codes(request.message)), "limit": 1}, intent="fund_quote", # 必须来自 supported_intents context=context, ) return CoreResult(text=...) # tool_calls / source_references 由底座附加,不要伪造 ``` 要点: - `allowed_tools` 与 `supported_intents` 是**代码上限**,数据库配置只能收窄; - 工具返回 `degraded=true` 时必须在文案里显式提示降级,不得表述为成交/委托/持仓; - 业务代码不导入 `httpx`、不连数据库、不读环境变量密钥。 ## 2. 在 bootstrap 注册(1 行) 编辑 `app/service/agent/bootstrap.py`,在构造 `AgentFactory` 之后统一登记: ```python def register_business_agents(factory: AgentFactory) -> None: factory.register( FundQueryDemoAgent.definition, lambda _context: FundQueryDemoAgent(FundQueryDemoAgent.definition), ) ``` HTTP 服务与 Worker 共用 `get_agent_factory()`(`lru_cache` 单例),注册一次两边同时生效。 未注册的 `agent_type` 受理时返回 **404 `AGENT_TYPE_NOT_FOUND`**。 ## 3. 发布配置(这一步最容易漏,漏了就工具必然被拒) 工具白名单**不在代码里**,而在「当前 active 的 `config_release`」里。管理 API 的写入口是 `/api/v1/admin/...`,全部需要 JWT + `Idempotency-Key`(16–128 位 ASCII);状态转换还需要 `If-Match`(值取自上一次响应的 `ETag`,或 `GET` 单条资源时的 `ETag`)。 ### 3.1 五个调用(顺序不能变) ```text POST /api/v1/admin/config-releases # 建 draft 版本 POST /api/v1/admin/config-releases/{id}/platform-config-items # 写工具白名单 POST /api/v1/admin/config-releases/{id}/validations # 提交复核(构造人本人) POST /api/v1/admin/config-releases/{id}/reviews # 复核(必须是别人!) POST /api/v1/admin/config-releases/{id}/activations # 激活 ``` 请求体(真实可复制): ```json { "release_no": "demo-fund-<随机>", "title": "示例 Agent 接入配置", "change_summary": "为示例 Agent 发布意图工具白名单" } { "namespace": "agent_tools", "item_key": "fund_query_demo:fund_quote", "value_json": { "allowed_tools": ["query_fund_quote"] }, "schema_version": "1" } { "decision": "approved", "comment": "接入验证" } ``` 意图配置(可选,走 `POST /api/v1/admin/agent-intent-configs`):`agent_type`、`intent_code`、 `intent_name`、`examples`、`allowed_tools`。写出的记录是 `draft`,需要再走 `.../{config_id}/reviews` → `.../{config_id}/activations` 才能生效(该表状态集只有 `draft/approved/active/archived`,**没有 disabled**,停用走 `archivals`)。**激活后运行期会真正读取它**: 配置里的意图名、描述、示例和 `classifier_instruction` 会拼进分类 prompt, `needs_clarification` 的判定阈值也取自该意图的 `confidence_threshold`(无配置时回落 `0.65`)。 也就是说它**会改变分类行为**——示例写得与该意图不符,分类结果就会跟着偏。 ### 3.2 审核:单管理员可直接自审(已取消双人复核) `ConfigReleaseService.approve` 不再要求"审核人不同于创建人":单管理员部署下创建人可以审核 自己创建的版本,且 `reviewer_id` 会**如实写成自己**(数据库侧原本的 `chk_config_release_separation` 约束已由迁移 `20260910_drop_review_separation` 撤下)。 但要注意**"审核"这个节点本身不能跳过**:草稿必须先 `submit` 到 `pending_review` 才能 `approve`,否则报 `release is not pending review`;未 `approved` 的版本也不能 `activate`。 因此接入验证**不需要**第二个 admin 身份,只用常驻的 `9003` 就能走完 `create → submit → review → activate`(`tools/demo_agent_e2e.py` 现在就是这么跑的)。 早先文档要求临时启用 `9004` 当复核人,那个做法**已不再需要**,相关临时身份也已禁用。 ### 3.3 核对配置是否真的生效(只读 SQL) ```sql SELECT r.id, r.release_no, r.status FROM config_release r WHERE r.status='active'; SELECT i.release_id, i.namespace, i.config_key, i.value_json FROM platform_config_item i JOIN config_release r ON r.id = i.release_id WHERE r.status='active' AND i.namespace='agent_tools'; ``` 期望看到唯一 active 版本,且 `config_key='fund_query_demo:fund_quote'`、 `value_json={"allowed_tools": ["query_fund_quote"]}`。 ## 4. 端到端验证 **先停掉常驻 Worker**:`WorkerRuntime.run_once` 会在共享的 `agent_run` 队列上抢 run; 若它抢先执行,你会看到与本次验证无关的执行路径(探针/旧注册),得到假失败。 ```powershell # 一键:发布配置 → 正常路径 → 反证 → 恢复 → 清理 run 数据 D:\conda\envs\jr_py313\python.exe tools\demo_agent_e2e.py # 只跑一次真实 run(受理 202 → 手动执行 → 打印工具调用与审计) D:\conda\envs\jr_py313\python.exe C:\Users\...\e2e_run_check.py --label 手工验证 ``` 单条链路的手工命令(客户身份 9001,JWT 用配置项 `JWT_PRIVATE_KEY_PATH` 指向的私钥按 RS256 自签, 参考 `tools/acceptance_check.py::token()`)。还没有密钥就先跑一次 `python tools/generate_jwt_keys.py --out-dir config/jwt/dev`(详见 `docs/26-JWT密钥管理与轮换.md`): ```text POST /api/v1/agent-runs { "agent_type":"fund_query_demo", "message":"帮我看下 159382 这只场内基金的行情", "session_id":"demo-fund-", "idempotency_key":"<16-128 位 ASCII>" } → 202 + run_id/trace_id GET /api/v1/agent-runs/{run_id} → status=succeeded,result.tool_calls.calls[0].status=succeeded GET /api/v1/agent-runs/{run_id}/events → event: start / tools / replace / done ``` 本次实测证据(`tools/demo_agent_e2e.py` 输出): ```text 正常路径:受理 202 → status=succeeded 工具调用 {"calls":[{"status":"succeeded","tool_name":"query_fund_quote",...}]} 来源引用 [{"source_type":"tool","source_id":":query_fund_quote",...}] 审计 interaction_audit 1 条:agent.tool_executed detail.status=succeeded reason=ok ``` ## 5. 反证测试:证明配置是必需的,而不是碰巧能跑 新建并激活一个**不含** `agent_tools` 配置项的 release(其余步骤与 3.1 完全相同,只是跳过写配置项), 再跑同一条链路: ```text 受理仍为 202(受理阶段不看工具白名单) run 终态 failed,error_code=AGENT_PERMISSION_DENIED interaction_audit 1 条:agent.tool_executed detail.status=denied reason="工具不在当前意图白名单" ``` 即:**没有 active 发布版本或没有对应配置项时,工具白名单为空 → 工具被拒绝(失败关闭)**。 反证完成后务必重新激活带白名单的版本,否则线上工具会一直不可用。 一键脚本会自动做「反证 → 恢复」两步。 ## 6. 常见坑(全部实测踩过) | 坑 | 现象 | 处理 | | --- | --- | --- | | 工具白名单必须在 **active** release 里 | 工具调用被拒、run `failed / AGENT_PERMISSION_DENIED`,审计 `denied` | 确认 `config_release.status='active'` 且该版本有 `agent_tools/:`;draft/approved 版本不算 | | 只能改 draft 版本 | 对 active 版本调 PUT 返回 `InvalidStateError`(只能编辑草稿或停用版本) | 改配置必须**新建版本**再走复核激活,不要试图改 active 行 | | 状态转换缺 `If-Match` | `409 CONFIG_VERSION_CONFLICT` | 每次转换前先 `GET` 拿最新 `ETag`;复核后 ETag 会变 | | 写接口缺 `Idempotency-Key` | `必须提供 16-128 位 ASCII Idempotency-Key` | 每次写请求带新的随机 key | | ~~双人复核~~(**已取消**) | 旧行为会报 `creator cannot review own release` | 现在单管理员可自审(见 3.2);但**审核节点仍不可跳过**:草稿必须先 `submit` 到 `pending_review` | | **SSE 未带 `Accept`** | **不是 406**:未带(或空)`Accept` 被视为可接受,返回 200 `text/event-stream`;显式写 `Accept: application/json` 才返回 406 `SSE_NOT_ACCEPTABLE` | 客户端若要 JSON,请不要去请求 `/events`;SSE 客户端建议显式带 `Accept: text/event-stream` | | 验收/联调前必须停 Worker | 常驻 Worker 与脚本共享 `agent_run` 队列,抢走 run 后用另一条执行路径,导致假失败 | 先停常驻 Worker,再手动 `WorkerRuntime().execute(run_id)` | | `assigned_at` 时区/舍入 | 角色分配写“当前时间”可能因秒级进位落在未来,身份解析拿不到任何权限(roles 为空,而非报错) | 种子/授权写入用 `NOW(6) - INTERVAL 5 SECOND`(参考 `tools/seed_test_rbac.py` 的 `now - 5s`) | | 意图分类会真实调用模型 | 每次 run 都会有一次模型调用(当前按 active `model_endpoint_config` 解析,不区分 task_type);模型若返回未声明的意图会抛 `ValidationAgentError` → run `failed` | 只声明你会处理的少量意图;`needs_clarification=true` 时不要当成确定意图 | | `fund_market` 配置没有管理 API 入口 | `ItemPayload.namespace` 只接受 `agent_tools/memory/relationship/runtime`;`admin_service` 里虽支持 `fund_market` 字段校验,但 HTTP 层进不来 | 行情工具在配置缺失时使用安全默认代码表(含 `159382` 等场内 ETF),不改配置即可用;若必须调整白名单,只能核对后直接写库并在变更记录中说明 | | `interaction_audit.detail` 读出来是字符串 | 直接 `.get()` 会 `AttributeError` | 用 `JSON_UNQUOTE(JSON_EXTRACT(detail,'$.trace_id'))` 过滤,取回后 `json.loads` | | 清理测试数据时别删配置 | 删掉 `config_release`/`platform_config_item` 会让工具立刻失败关闭 | 只删 `agent_run`/`conversation_message`/`domain_event_outbox`/`outbox_delivery`/`request_idempotency`/本 run 的 `interaction_audit`。**更隐蔽的陷阱**:测试期间激活临时版本会把平台原有的 active 顶成 `superseded`,清理时若只删自己创建的版本,平台就停在"零个 active 版本"——工具白名单随之变空集、所有工具被拒,而且**全程没有任何报错**。删完必须把原先生效的版本恢复为 `active`(见 `tests/integration/test_config_release_mysql.py` 的 `finally`) | ## 7. 提交前检查 ```powershell D:\conda\envs\jr_py313\python.exe -m pytest -q tests/unit tests/contract D:\conda\envs\jr_py313\python.exe -m pytest -q tests/integration D:\conda\envs\jr_py313\python.exe -m ruff check app tests D:\conda\envs\jr_py313\python.exe -m mypy app D:\conda\envs\jr_py313\python.exe tools\acceptance_check.py --production D:\conda\envs\jr_py313\python.exe tools\demo_agent_e2e.py ``` 示例 Agent 的接入契约测试见 `tests/contract/test_fund_query_demo_agent_contract.py` (注册可扫描、意图/工具声明自洽、白名单失败关闭、成功调用带出记录与可校验来源引用)。 ## 8. 未解决项(需要底座负责人决定) 1. ~~**常驻第二个 admin**~~:**已解决**。双人复核已取消,单管理员 `9003` 即可完成 `create → submit → review → activate` 全流程(见 3.2)。 2. **`fund_market` 无管理入口**:控制台无法通过 API 调整行情允许代码表;当前依赖工具内的安全默认值。 3. ~~**`agent_intent_config` 不可激活**~~:**已解决**。已补激活入口 (`reviews`/`activations`/`archivals`)且运行期读取生效(见 3.1)。 4. **`docs/05` §9.5 未收录 `agent-intent-configs` 的 `GET` 详情路径**:该接口实际存在(用于获取 `If-Match` 所需的 ETag),但权威接口文档未列出,属文档待补项。 5. **没有登录接口(已决定推迟,不是遗漏)**:当前没有"账号密码换令牌"的接口,令牌由外部按 RS256 用私钥自签(见 `docs/26-JWT密钥管理与轮换.md`)。**决定:等业务 Agent 开发阶段 结束后再补**。补的时候只需动签发侧(新增 `POST /auth/login`:校验密码 → 用私钥签令牌), 验签侧 `JwtAuthenticator` 与身份解析侧 `IdentityService` 都不需要改。 代价与约束:开发阶段凡拿到私钥者都能以 `9001/9002/9003` 身份调用(实测**无法**伪造不存在的 用户、**无法**使用已禁用账号,边界是"签名有效 + 用户存在且启用"),因此私钥按 `docs/26` 第 7 节的红线管理;同时业务代码必须始终只从 `RequestContext` 取身份,否则后补登录接口会 从"增量"变成"翻遍所有业务代码"。