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(含失败关闭反证)。
This commit is contained in:
@@ -7,50 +7,114 @@ from starlette.responses import StreamingResponse
|
||||
|
||||
from app.api.dependencies.auth import build_request_context
|
||||
from app.api.dependencies.database import get_session
|
||||
from app.api.dependencies.rate_limit import enforce_rate_limit
|
||||
from app.api.schemas.agent_runs import (
|
||||
AgentRunAcceptedEnvelope,
|
||||
AgentRunAcceptedResponse,
|
||||
AgentRunCreateRequest,
|
||||
AgentRunStatusEnvelope,
|
||||
AgentRunStatusResponse,
|
||||
)
|
||||
from app.api.views.agent_run_sse import encode_events, recovery_events
|
||||
from app.core.config import get_settings
|
||||
from app.core.contracts import AgentRequest, RequestContext
|
||||
from app.core.errors import SseNotAcceptableError
|
||||
from app.service.agent_run_application_service import AgentRunApplicationService
|
||||
from app.service.run_query_service import RunQueryService
|
||||
|
||||
router = APIRouter(prefix="/api/v1/agent-runs", tags=["agent-runs"])
|
||||
router = APIRouter(prefix="/api/v1/agent-runs", tags=["agent-runs"],
|
||||
dependencies=[Depends(enforce_rate_limit)])
|
||||
|
||||
SSE_MEDIA_TYPE = "text/event-stream"
|
||||
|
||||
|
||||
@router.post("", response_model=AgentRunAcceptedResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||
def accepts_event_stream(accept: str | None) -> bool:
|
||||
"""`Accept` 是否接受 `text/event-stream`(文档 §3.2:该头**非必填**)。
|
||||
|
||||
- 未携带(`None` 或空串)→ 放行:文档写明"默认 `application/json`;SSE 为
|
||||
`text/event-stream`",即由接口自身决定响应类型,不是客户端错误;
|
||||
- 携带 `text/event-stream`、`text/*` 或 `*/*` 且 `q != 0` → 放行;
|
||||
- 显式携带但只接受其他类型(如 `application/json`)→ 拒绝,由调用方转
|
||||
`406 SSE_NOT_ACCEPTABLE`(文档 §3.5/§6.4)。
|
||||
|
||||
只做"是否可接受"的判定,不参与内容协商排序:SSE 端点只有一种表示。
|
||||
"""
|
||||
if accept is None or not accept.strip():
|
||||
return True
|
||||
for entry in accept.split(","):
|
||||
parts = entry.split(";")
|
||||
media_type = parts[0].strip().lower()
|
||||
if media_type not in {SSE_MEDIA_TYPE, "text/*", "*/*"}:
|
||||
continue
|
||||
quality = 1.0
|
||||
for parameter in parts[1:]:
|
||||
name, _, value = parameter.partition("=")
|
||||
if name.strip().lower() == "q":
|
||||
try:
|
||||
quality = float(value.strip())
|
||||
except ValueError:
|
||||
quality = 0.0
|
||||
if quality > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=AgentRunAcceptedEnvelope,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def create_agent_run(
|
||||
payload: AgentRunCreateRequest,
|
||||
request: Request,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> AgentRunAcceptedResponse:
|
||||
) -> AgentRunAcceptedEnvelope:
|
||||
request.state.request_context = context
|
||||
accepted = await AgentRunApplicationService(session).accept(
|
||||
AgentRequest(**payload.model_dump()), context)
|
||||
return AgentRunAcceptedResponse(
|
||||
run_id=accepted.run_id, trace_id=accepted.trace_id, status=accepted.status,
|
||||
status_url=f"/api/v1/agent-runs/{accepted.run_id}",
|
||||
events_url=f"/api/v1/agent-runs/{accepted.run_id}/events",
|
||||
return AgentRunAcceptedEnvelope(
|
||||
data=AgentRunAcceptedResponse(
|
||||
run_id=accepted.run_id, trace_id=accepted.trace_id, status=accepted.status,
|
||||
status_url=f"/api/v1/agent-runs/{accepted.run_id}",
|
||||
events_url=f"/api/v1/agent-runs/{accepted.run_id}/events",
|
||||
),
|
||||
meta={"trace_id": context.trace_id},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{run_id}", response_model=AgentRunStatusResponse)
|
||||
@router.get("/{run_id}", response_model=AgentRunStatusEnvelope)
|
||||
async def get_agent_run(
|
||||
run_id: str, context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
) -> AgentRunStatusResponse:
|
||||
return AgentRunStatusResponse(**asdict(await RunQueryService().get(run_id, context)))
|
||||
) -> AgentRunStatusEnvelope:
|
||||
"""查询运行(文档 §6.3)。
|
||||
|
||||
文档 §3.3 与 §6.3 都把成功响应定义为 `{data, meta:{trace_id}}` 信封;此前这里
|
||||
直接返回资源对象,客户端必须为这一个接口特判。**只改包装结构**:`data` 内的字段名
|
||||
与语义保持原样,`meta.trace_id` 用本次请求的 trace(`data.trace_id` 仍是运行自身的
|
||||
追踪标识,两者语义不同,不能互相替代)。
|
||||
"""
|
||||
snapshot = await RunQueryService().get(run_id, context)
|
||||
return AgentRunStatusEnvelope(
|
||||
data=AgentRunStatusResponse(**asdict(snapshot)),
|
||||
meta={"trace_id": context.trace_id},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{run_id}/events")
|
||||
async def stream_agent_run_events(
|
||||
run_id: str, context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
run_id: str,
|
||||
request: Request,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
) -> StreamingResponse:
|
||||
query = RunQueryService()
|
||||
# 顺序按文档 §6.4 的主要错误列举:RUN_NOT_FOUND(含 AGENT_PERMISSION_DENIED 同级的
|
||||
# 可见性判定)在前、SSE_NOT_ACCEPTABLE 在后。可见性先行(auth 依赖已先于本函数执行)
|
||||
# 才能保证"运行是否存在"不因 Accept 头而异:否则用任意 run_id + 非法 Accept 探测,
|
||||
# 406 与 404 的差异就等价于一次存在性枚举。
|
||||
initial = await query.get(run_id, context)
|
||||
if not accepts_event_stream(request.headers.get("Accept")):
|
||||
raise SseNotAcceptableError("Accept 必须接受 text/event-stream")
|
||||
|
||||
async def generate() -> AsyncIterator[str]:
|
||||
start_sent = False
|
||||
|
||||
Reference in New Issue
Block a user