diff --git a/apps/server/test/e2e/e15-agent-quality.test.ts b/apps/server/test/e2e/e15-agent-quality.test.ts new file mode 100644 index 00000000..0dfa6e40 --- /dev/null +++ b/apps/server/test/e2e/e15-agent-quality.test.ts @@ -0,0 +1,196 @@ +import type { FakeScript, FakeToolResult } from "@umt/agent"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { resolveBusiness } from "../../src/modules/auth-settings/engine-business.ts"; +import { createE2E, type E2E, pgAvailable, runEvents, runRow, seedBase, seedMovieTarget, waitFor } from "./harness.ts"; + +// E15 Agent 质量(AG-3,来自 AGENT-REAL 真实上游验证): +// - 只读摘要能力 subscription.list / decision.list / download.inspect(按 id 或 hash);task.inspect 收到下载任务 ID +// 时明确报错并指向 download.inspect; +// - download.plan 的 requiresApproval 按授权范围判定(覆盖本作品的持续授权 → false;范围外 / 没有 → true); +// - 超预算:run = budget_exceeded,任务页(引擎 run 的 business.outcome)显示超出预算而不是完成; +// 被预算截断时中间过程文字不作为汇报;同一目标模板的超预算合并到同一条待处理事项。 + +// biome-ignore lint/suspicious/noExplicitAny: 断言里按输出结构取值 +type Any = any; +const hasPg = await pgAvailable(); +const HASH = "ab".repeat(20); +const SURVEY = "盘点订阅、待处理与下载"; +const LOOP = (at: string) => `巡检库存(${at}):逐个作品查看版本目标`; + +describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => { + const scripts: FakeScript[] = []; + const results = new Map(); + let e: E2E; + let target: Awaited>; + let downloadTaskId: string; + + beforeAll(async () => { + e = await createE2E("e15", { fakeScripts: scripts }); + const base = await seedBase(e); + target = await seedMovieTarget(e, base.siteId); + downloadTaskId = ( + await e.db + .insertInto("download_task") + .values({ + instance_id: base.instanceId, + torrent_hash: HASH, + infohash_v1: HASH, + source_release_id: target.r1080, + name: "Dune.Part.Two.2024.1080p.BluRay.x264-GRP", + status: "failed", + last_error: JSON.stringify({ message: "qB 状态 missingFiles" }), + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + await e.db + .insertInto("decision") + .values({ + kind: "download_failed", + title: "下载异常:Dune.Part.Two", + subject_type: "download_task", + subject_id: downloadTaskId, + dedupe_key: `download_failed:${downloadTaskId}`, + options: JSON.stringify([{ key: "retry", label: "重试" }]), + }) + .execute(); + scripts.push( + { + match: SURVEY, + steps: [ + { text: "先列订阅。", toolCalls: [{ capability: "subscription.list", input: { groupBy: "status" } }] }, + { text: "再看待处理。", toolCalls: [{ capability: "decision.list", input: {} }] }, + { + text: "按 hash 查下载。", + toolCalls: [{ capability: "download.inspect", input: { hash: HASH.toUpperCase() } }], + }, + // 常见误用:把下载任务 ID 当业务任务 ID + { text: "看任务时间线。", toolCalls: [{ capability: "task.inspect", input: { taskId: downloadTaskId } }] }, + ], + finalText: (r) => { + results.set(SURVEY, r); + return "## 盘点\n\n- 订阅 1 个\n- 待处理 1 条\n\n| 下载 | 状态 |\n|---|---|\n| Dune | failed |"; + }, + }, + { + match: /^巡检库存/, + steps: Array.from({ length: 6 }, () => ({ + text: "Let me check the inventory once more.", + toolCalls: [{ capability: "inventory.query", input: { query: "沙丘2" } }], + })), + finalText: "不该到这里", + }, + ); + }, 120_000); + + afterAll(async () => { + await e?.close(); + }, 60_000); + + const runToEnd = async (goal: string) => { + const r = await e.api("POST", "/agent/runs", { goal }); + expect(r.status).toBe(201); + const runId = r.body.data.id as string; + const run = await waitFor( + async () => { + const x = await runRow(e, runId); + return ["succeeded", "failed", "waiting", "budget_exceeded", "cancelled"].includes(x.status) ? x : null; + }, + 60_000, + `run 结束:${goal}`, + ); + return { runId, run }; + }; + + it("只读摘要能力:订阅分组、待处理含 options、下载按 hash;task.inspect 收到下载任务 ID 时指向 download.inspect", async () => { + const { run } = await runToEnd(SURVEY); + expect(run.status).toBe("succeeded"); + const r = results.get(SURVEY)!; + const byCap = (id: string) => r.find((x) => x.capabilityId === id)!; + expect(byCap("subscription.list")).toMatchObject({ isError: false }); + expect(byCap("subscription.list").json).toMatchObject({ + total: 1, + groups: [{ key: "active", count: 1 }], + items: [{ title: "沙丘2", mediaEntityId: target.entityId, slots: { total: 1, satisfied: 0 } }], + }); + expect(byCap("decision.list").json).toMatchObject({ + total: 1, + byKind: [{ kind: "download_failed", count: 1 }], + items: [{ kind: "download_failed", subjectId: downloadTaskId, options: [{ key: "retry", label: "重试" }] }], + }); + expect(byCap("download.inspect").json).toMatchObject({ + downloadTask: { id: downloadTaskId, torrentHash: HASH }, + release: { id: target.r1080 }, + decisions: [{ kind: "download_failed" }], + otherMatches: [], + }); + expect(byCap("task.inspect")).toMatchObject({ isError: true }); + expect(byCap("task.inspect").text).toContain("download.inspect"); + + // HTTP 同一路径:按 id 查;不存在的下载任务 404 类错误 + const inv = await e.api("POST", "/capabilities/download.inspect/invoke", { input: { downloadTaskId } }); + expect(inv.status).toBe(200); + expect(JSON.stringify(inv.body)).toContain(HASH); + const bad = await e.api("POST", "/capabilities/release.search/invoke", { input: { query: "a" } }); + expect(bad.status).toBeGreaterThanOrEqual(400); + expect(JSON.stringify(bad.body)).toContain("检索词太短"); + }, 90_000); + + it("download.plan 的 requiresApproval 按授权范围判定", async () => { + const plan = async () => { + const r = await e.api("POST", "/capabilities/download.plan/invoke", { + input: { slotId: target.slotId, sourceReleaseId: target.r1080 }, + }); + expect(r.status).toBe(200); + return r.body.data.output ?? r.body.data; + }; + expect((await plan()).requiresApproval).toBe(true); + // 范围外的持续授权不算覆盖 + const other = await e.api("POST", "/automation-grants", { + action: "download.submit", + scope: { mediaEntityIds: ["00000000-0000-4000-8000-000000000001"] }, + reason: "e2e 范围外授权", + }); + expect(other.status).toBe(201); + expect((await plan()).requiresApproval).toBe(true); + const g = await e.api("POST", "/automation-grants", { + action: "download.submit", + scope: { mediaEntityIds: [target.entityId] }, + reason: "e2e 授权", + }); + expect(g.status).toBe(201); + expect((await plan()).requiresApproval).toBe(false); + }, 60_000); + + it("超预算:状态为超出预算(任务页同样),中间过程不当汇报,同一目标模板只一条待处理事项", async () => { + await e.settings({ agent: { maxToolCalls: 3 } }); + const a = await runToEnd(LOOP("2026-09-29T01:00:00.000Z")); + expect(a.run.status).toBe("budget_exceeded"); + expect(a.run.summary).not.toContain("Let me check"); + expect(a.run.summary).not.toContain("模型汇报"); + // 预算将尽提醒进了工具结果 + const ends = (await runEvents(e, a.runId)).filter((x) => x.type === "tool_call_end"); + expect(ends.length).toBeGreaterThan(0); + + // 引擎 run 正常跑完,业务结果是超出预算:任务页按 business.outcome 显示「超出预算」而不是「完成」 + const task = await e.db + .selectFrom("task") + .select(["engine_run_id", "status", "output"]) + .where("id", "=", a.run.task_id!) + .executeTakeFirstOrThrow(); + expect(task.status).toBe("failed"); + expect((task.output as Any).status).toBe("budget_exceeded"); + const biz = await resolveBusiness(e.db, e.ow, [task.engine_run_id!]); + const row = { business: biz.get(task.engine_run_id!) }; + expect(row?.business).toMatchObject({ kind: "agent", outcome: "budget_exceeded" }); + + const b = await runToEnd(LOOP("2026-09-29T02:00:00.000Z")); + expect(b.run.status).toBe("budget_exceeded"); + const ds = await e.db.selectFrom("decision").selectAll().where("dedupe_key", "like", "agent_budget:%").execute(); + expect(ds).toHaveLength(1); + expect(ds[0]!.dedupe_key).toMatch(/^agent_budget:goal:/); + expect(ds[0]!.agent_run_id).toBe(b.runId); + expect((ds[0]!.evidence as Any).runIds.sort()).toEqual([a.runId, b.runId].sort()); + expect(ds[0]!.attempts).toBeGreaterThanOrEqual(1); + }, 120_000); +}); diff --git a/docs/plan/00-first-version-plan.md b/docs/plan/00-first-version-plan.md index 1606ff9b..a89c2e3c 100644 --- a/docs/plan/00-first-version-plan.md +++ b/docs/plan/00-first-version-plan.md @@ -151,3 +151,4 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏 - 追加(用户 2026-09-29):对比 Webtop 与浏览器镜像合一 vs 与本体镜像合一(协调者倾向前者:同会话、生命周期隔离、安全面、镜像大小);**视觉自动化闭环**:Agent 截图(多模态喂视觉模型)→ 坐标点击/输入 → 再截图,有界步数与预算,用于登录/验证码/交互式挑战;二次验证与本人设备确认不自动过;每步证据入待处理,失败转人工接管。能力:`browser.screenshot`(图片内容)、`browser.click_at`、`browser.type_at`、`browser.solve_challenge`(有界子流程)。 - **上游模型列表一键获取(用户 2026-09-29)**:`POST /pi/upstreams/{id}/models/refresh` 按 provider 调远程模型列表接口(OpenAI 兼容 /models、Anthropic /v1/models 等),存库带时间,UI 可搜索选择 + 手填,失败回退静态表。用户已接入 OpenCode Go 订阅,Agent 能力测试统一用 DeepSeek V4.1 Flash(视觉部分需视觉模型)。 - **AG-3 Agent 质量(来自 AGENT-REAL 真实上游验证,2026-09-29)**:补只读能力 subscription.list / decision.list / download.inspect(task.inspect 不再接收 download_task id);`download.plan` 的 requiresApproval 按授权范围判定;测试补全带 sessionId(OpenCode 需 x-opencode-session);等待期间 token/费用显示;超预算 run 在任务页标"超预算";同根因超预算不重复建待处理;汇报 Markdown 渲染;系统提示:禁止空/单字探查查询、参数类型(externalId 字符串)、子会话预算不超父剩余、中文思考与汇报、被中断时输出中文小结;重跑 AGENT-REAL 七场景。 + - 进度(2026-09-29):除真机重跑外已完成——38 个能力(新增 3 个只读摘要,已进后台调查 / 巡检白名单);`coveringGrant` 与授权器共用;预算将尽提醒、被打断时丢弃中间过程文字;等待期间用量写回;任务页按 `business.outcome` 显示超出预算;超预算待处理按根因合并;汇报 Markdown 安全子集渲染;fake 模型 e2e 见 `apps/server/test/e2e/e15-agent-quality.test.ts`。开发库没有真实上游(只有 fake),AGENT-REAL a/e/g 真机重跑留给协调者重建用户栈后做。 diff --git a/docs/runbook/agent.md b/docs/runbook/agent.md index 2a2e085b..4b267cf2 100644 --- a/docs/runbook/agent.md +++ b/docs/runbook/agent.md @@ -6,7 +6,25 @@ ## 能力 -启动时装配 15 个能力(`catalog.lookup`、`release.search`、`release.inspect`、`identity.resolve`、`download.plan`、`download.submit`、`download.recheck`、`organize.plan`、`organize.execute`、`task.inspect`、`task.retry`、`task.cancel`、`policy.preview`、`policy.apply`、`inventory.query`)。注册数不等于 `CAPABILITY_IDS.length` 时进程启动失败。网页搜索 `web.search` / `web.fetch` 见 [websearch.md](websearch.md)。Web(`POST /capabilities/{id}/invoke`)、Pi 工具和后台任务都走 `ctx.capabilities.invoke` 这一条路径。 +启动时装配 38 个能力,注册数不等于 `CAPABILITY_IDS.length` 时进程启动失败。Web(`POST /capabilities/{id}/invoke`)、Pi 工具和后台任务都走 `ctx.capabilities.invoke` 这一条路径。描述符在 `packages/contracts/src/capability.ts`,每条描述都带一句"何时用 / 何时不用"。 + +| 分组 | 能力 | +|---|---| +| 查询(read) | `catalog.lookup`、`release.search`、`release.inspect`、`inventory.query`、`subscription.list`、`decision.list`、`download.inspect`、`task.inspect`、`site.list`、`site.user_data` | +| 计划(preview / propose) | `identity.resolve`、`download.plan`、`organize.plan`、`policy.preview` | +| 执行(execute) | `download.submit`、`download.recheck`、`organize.execute`、`task.retry`、`task.cancel`、`policy.apply`、`site.health_check`、`site.refresh_login` | +| 原站浏览器 | `browser.snapshot`、`browser.navigate`、`browser.interact`、`browser.download`、`browser.screenshot`、`browser.click_at`、`browser.type_at`、`browser.scroll`、`browser.wait`、`browser.solve_challenge`(见 [browser.md](browser.md)) | +| 会话 | `agent.plan`、`agent.propose_options`、`agent.spawn`、`agent.await` | +| 网页搜索 | `web.search`、`web.fetch`(见 [websearch.md](websearch.md)) | + +AG-3 补的只读摘要(类别 read,scope `read`,都在后台调查 / 巡检白名单里): + +- `subscription.list {status?, mediaKind?, query?, groupBy=status|mediaKind|none, limit≤200}`:订阅分组计数 + 条目(作品、模式、状态、优先级、版本目标总数 / 已满足数、上次 / 下次搜索)。查单个作品的文件与版本目标仍用 `inventory.query`。 +- `decision.list {status?, kind?, limit≤100}`:待处理事项摘要,缺省只列 open / investigating / awaiting_user,按严重度排序;每条带可选处理 `options`。只读,处理仍由用户在待处理中心选。 +- `download.inspect {downloadTaskId? | hash?}`:按下载任务 ID 或种子 hash(v1 / v2,大小写均可)读下载任务、关联计划、资源、业务任务(提交下载 / 核验入库,ID 可交给 `task.inspect` / `task.retry`)、整理记录与待处理事项;同 hash 在多个下载器实例时其余 ID 放 `otherMatches`。 +- `task.inspect` 只接受业务任务(`task` 表)ID;传入下载任务 ID 时返回 `invalid_input`:"这是下载任务 ID…用 download.inspect 查看"。 + +检索词(`catalog.lookup` / `release.search` / `inventory.query` / `subscription.list` 的 `query`)去掉首尾空白后不能为空、不能是单个英文字母 / 数字 / 符号、不能只有通配符与标点(`invalid_input`:"检索词太短…");中文单字片名允许。`catalog.lookup.externalId.externalId` 一律是字符串。 - **授权**(`agent/authorize.ts`):propose / approve / execute 类必须满足以下之一: - 单次审批 `approval`:状态为 approved,绑定计划类型 + 计划 ID + payload hash + revision,且未过期。执行成功后审批标记为 consumed。 @@ -15,7 +33,7 @@ 模型参数里写"用户已同意"不起作用。输入里的 `approvalId` 只是一个指针,授权结果以库里的审批记录为准。控制台管理员从 Web 手动调用(actor=`user:*`)算人工授权,API Key、Agent 和后台任务都必须有授权记录。自动化总开关关闭或处于只读对账时,自动来源的 execute 一律拒绝。 - **审计**:每次调用都写一条 `audit_event`,action 为 `capability.invoke` 或 `capability.denied`,payload 包含 outcome、授权 ID、幂等键和脱敏后的输入摘要。 - **幂等键与操作账本**:有副作用的调用由服务端生成幂等键,格式为 `id:v版本::sha256(规范化输入)`,并记入 `capability_invocation`。状态为 succeeded 时直接返回记录的结果。状态为 running(进程在调用途中崩溃)时,用同一个键重跑处理器来对账,处理器本身是幂等的。 -- `download.plan` 只生成 **draft** 计划:不预留 Slot,不计入预算,也不会被后台巡检补提交。`download.submit` 在授权通过后先把 draft 转成 reserved(Slot revision 必须与生成计划时一致),再入队 `submitDownload`,和自动路径共用幂等键 `plan:`。 +- `download.plan` 只生成 **draft** 计划:不预留 Slot,不计入预算,也不会被后台巡检补提交。`requiresApproval` 按授权范围判定:有覆盖本计划的持续授权(action 覆盖 `download.submit`、对象 / 类型范围含目标作品、绑定策略仍发布、每日次数 / 字节预算有余量,与 authorize 同一函数 `coveringGrant`)时为 false,否则为 true;没生成计划时按 Slot 的作品判定。`download.submit` 在授权通过后先把 draft 转成 reserved(Slot revision 必须与生成计划时一致),再入队 `submitDownload`,和自动路径共用幂等键 `plan:`。 - `organize.plan` 把冻结的整理计划存成 artifact(kind=plan),`payloadHash` 就是计划指纹。`organize.execute` 执行前会重新冻结一次,指纹不一致就返回 conflict。 ## 会话 API @@ -49,10 +67,15 @@ - 入库完成 → `succeeded`,汇报带整理记录 ID; - 仍在等待 → `waiting`; - 失败 → `failed`; - - 超预算 → `budget_exceeded`,同时开一条待处理事项 `agent_budget:`; + - 超预算 → `budget_exceeded`,同时开一条待处理事项。同根因只保留一条未关闭事项(`attempts`+1,`evidence.runIds` 累积,`agent_run_id` 指向最新一次):去重键为 `agent_budget:root:<被调查事项的去重键>`(后台调查)、`agent_budget:schedule:<定时任务>`(巡检)或 `agent_budget:goal:<目标模板指纹>`(去掉时间戳 / UUID / 数字后的目标 sha256 前 16 位)。业务任务行记 `failed`,`output.status=budget_exceeded`;任务页按引擎 run 的 `business.outcome`(= `agent_run.status`)显示「超出预算」,不显示「完成」; - 取消 → `cancelled`。 - 模型的最终文本只附在汇报后面,标注"模型汇报(未经核查)"。 + 模型的最终文本只附在汇报后面,标注"模型汇报(未经核查)"。被预算或中止打断时,最后一次工具调用之前的文字只是中间过程,不作为模型汇报(`finalText` 置空,会话页显示服务端的中文汇总)。 + + **预算提醒**:工具调用只剩 ≤2 次,或 token / 费用 / 时长用到 85% 时,运行时在工具结果末尾追加「【预算提醒】…停止探查,下一条回复直接用中文写小结」,系统提示要求模型见到后直接收尾。 + **等待期间的用量**:会话挂起(审批 / 选方案 / 等子会话)时没有 `run_completed`,drive 退出前把运行时累计用量按字段取较大值写回 `agent_run.usage`,会话页和列表在等待期间照样显示已用 token / 费用。 + + 模型汇报在会话页按 Markdown 安全子集渲染(标题、列表、表格、引用、代码、粗体 / 斜体 / 链接;原文 HTML 按文字显示,链接只放行 http(s) 与站内路径),流式输出中仍按原文显示。 worker 崩溃后,新 worker 在引擎租约到期后(约 30s)接手,`drive` 步骤会从头重跑。已经完成的工具调用不会重放。副作用调用的结果模型会看到"结果未知",模型需要重新核对;同参数再调用一次,会直接拿到账本里的结果。 @@ -154,7 +177,7 @@ runAgent 按 `runAgent{upstreamId}` → `agent_run.upstream_id` → 默认上游 | `GET /pi/providers?models=false` | 能力表:authKinds(第一个是推荐项)、`oauth.modes`(browser / device)、`termsNote`(条款风险)、`baseUrlOverridable`、`envFields`、静态模型表 | | `GET /pi/upstreams`、`GET /pi/upstreams/{id}` | 不含密钥,只给 `hasCredential`、`credentialMask`(前 4 后 4)、`account`、`oauthExpiresAt`、`isDefault` | | `POST /pi/upstreams`、`PATCH /pi/upstreams/{id}`、`DELETE …` | `apiKey` 只写(PATCH 传 null 清除);provider 不可改;改 authKind 会删掉旧凭据;删除连同 secret | -| `POST /pi/upstreams/{id}/test {mode?: auth\|completion, model?}` | 缺省 `auth`:解析凭据(OAuth 必要时刷新)+ 校验模型在目录中,**不产生费用**;`completion` 再发一次最多 16 token 的补全 | +| `POST /pi/upstreams/{id}/test {mode?: auth\|completion, model?}` | 缺省 `auth`:解析凭据(OAuth 必要时刷新)+ 校验模型在目录中,**不产生费用**;`completion` 再发一次最多 16 token 的补全,带一次性 `sessionId`(OpenCode Zen / Go 据此加 `x-opencode-session` 头,缺了会被拒) | | `GET /pi/upstreams/{id}/models` | `{source: remote\|static, fetchedAt, models[], remoteSupported, error, errorKind}`:拉过远程列表 → 远程与静态目录合并去重(同 id 远程优先,远程缺的字段用静态补,远程在前);没拉过 → 静态目录(已配置凭据时按 provider 过滤,如 Copilot) | | `POST /pi/upstreams/{id}/models/refresh` | 「获取模型列表」:用该上游的凭据调 provider 的模型列表接口,成功存 `pi_upstream.models` + `models_fetched_at`(迁移 0044)。失败不动已存列表,返回当前列表 + 一句话 `error`,`errorKind` = auth(401/403)/ network / unsupported / config / other。改 Base URL 或 `extra.api` 会清掉已存列表 | | `POST /pi/models/probe {provider, authKind, apiKey?, baseUrl?, extra?}` | 新建上游前试拉(凭据只用这一次,不落库);新建向导创建后会用已存凭据再拉一次并存库 |