diff --git a/apps/server/src/modules/acquisition-organize/capabilities.ts b/apps/server/src/modules/acquisition-organize/capabilities.ts index f5e1da62..c37e1a25 100644 --- a/apps/server/src/modules/acquisition-organize/capabilities.ts +++ b/apps/server/src/modules/acquisition-organize/capabilities.ts @@ -10,7 +10,7 @@ import { type FrozenPlan, planImport } from "./services/organize.ts"; import { errMessage, hashJson, jsonb, toApi, toApiList } from "./util.ts"; // 本模块领域的能力处理器(规格一律取 contracts): -// inventory.query / download.plan / download.submit / organize.plan / organize.execute / policy.preview / policy.apply +// inventory.query / downloader.list / download.plan / download.submit / organize.plan / organize.execute / policy.preview / policy.apply // 授权(grant/approval)、幂等键与操作账本由注册中心统一处理(modules/agent/capability-registry.ts); // 这里的处理器只调本模块服务,并保证自身幂等(崩溃后同幂等键重跑用于对账)。 @@ -159,6 +159,57 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => } as never; }); + // FIX-7:已配置的下载器与媒体服务器(只读本地记录:启用、最近连通状态、管理的任务数;不含地址与凭证) + registry.register(capabilitySpec("downloader.list"), async (input) => { + const db = deps().ctx.db; + const [downloaders, counts, servers] = await Promise.all([ + db + .selectFrom("downloader_instance") + .select(["id", "name", "kind", "enabled", "health_status", "last_seen_at", "version_info"]) + .$if(!input.includeDisabled, (qb) => qb.where("enabled", "=", true)) + .orderBy("name") + .execute(), + sql<{ instance_id: string; total: number; active: number; failed: number }>` + select instance_id, count(*)::int as total, + count(*) filter (where status in ('queued', 'submitting', 'downloading', 'paused', 'stalled', 'importing'))::int as active, + count(*) filter (where status in ('failed', 'missing'))::int as failed + from download_task + where managed and removed_at is null + group by instance_id`.execute(db), + db + .selectFrom("media_server_instance") + .select(["id", "name", "kind", "enabled", "health_status", "last_seen_at"]) + .$if(!input.includeDisabled, (qb) => qb.where("enabled", "=", true)) + .orderBy("name") + .execute(), + ]); + const iso = (d: Date | null) => (d ? d.toISOString() : null); + return { + downloaders: downloaders.map((r) => { + const c = counts.rows.find((x) => x.instance_id === r.id); + const v = (r.version_info ?? {}) as { app?: unknown }; + return { + id: r.id, + name: r.name, + kind: r.kind, + enabled: r.enabled, + healthStatus: r.health_status as never, + lastSeenAt: iso(r.last_seen_at), + version: typeof v.app === "string" ? v.app : null, + tasks: { total: c?.total ?? 0, active: c?.active ?? 0, failed: c?.failed ?? 0 }, + }; + }), + mediaServers: servers.map((r) => ({ + id: r.id, + name: r.name, + kind: r.kind, + enabled: r.enabled, + healthStatus: r.health_status as never, + lastSeenAt: iso(r.last_seen_at), + })), + }; + }); + // AG-3:下载任务详情(按 id 或 hash);关联计划 / 资源 / 业务任务 / 整理记录 / 待处理事项 registry.register(capabilitySpec("download.inspect"), async (input) => { const db = deps().ctx.db; diff --git a/apps/server/src/modules/agent/runner.ts b/apps/server/src/modules/agent/runner.ts index d0ab3663..85aed158 100644 --- a/apps/server/src/modules/agent/runner.ts +++ b/apps/server/src/modules/agent/runner.ts @@ -60,6 +60,7 @@ export const INVESTIGATE_CAPABILITIES = [ "inventory.query", "task.inspect", "download.inspect", + "downloader.list", "decision.list", "subscription.list", "identity.resolve", diff --git a/apps/server/test/e2e/e15-agent-quality.test.ts b/apps/server/test/e2e/e15-agent-quality.test.ts index 449fbf87..7485fb35 100644 --- a/apps/server/test/e2e/e15-agent-quality.test.ts +++ b/apps/server/test/e2e/e15-agent-quality.test.ts @@ -4,7 +4,7 @@ import { resolveBusiness } from "../../src/modules/auth-settings/engine-business 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 +// - 只读摘要能力 subscription.list / decision.list / download.inspect(按 id 或 hash)/ downloader.list;task.inspect 收到下载任务 ID // 时明确报错并指向 download.inspect; // - download.plan 的 requiresApproval 按授权范围判定(覆盖本作品的持续授权 → false;范围外 / 没有 → true); // - 超预算:run = budget_exceeded,任务页(引擎 run 的 business.outcome)显示超出预算而不是完成; @@ -23,10 +23,12 @@ describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => { let e: E2E; let target: Awaited>; let downloadTaskId: string; + let instanceId: string; beforeAll(async () => { e = await createE2E("e15", { fakeScripts: scripts }); const base = await seedBase(e); + instanceId = base.instanceId; target = await seedMovieTarget(e, base.siteId); downloadTaskId = ( await e.db @@ -64,6 +66,7 @@ describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => { text: "按 hash 查下载。", toolCalls: [{ capability: "download.inspect", input: { hash: HASH.toUpperCase() } }], }, + { text: "看下载器。", toolCalls: [{ capability: "downloader.list", input: {} }] }, // 常见误用:把下载任务 ID 当业务任务 ID { text: "看任务时间线。", toolCalls: [{ capability: "task.inspect", input: { taskId: downloadTaskId } }] }, ], @@ -124,6 +127,14 @@ describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => { decisions: [{ kind: "download_failed" }], otherMatches: [], }); + // FIX-7:下载器是否接入由 downloader.list 回答(site.list 不含下载器),任务按实例计数,不含地址与凭证 + expect(byCap("downloader.list")).toMatchObject({ isError: false }); + expect(byCap("downloader.list").json).toMatchObject({ + // 种下的失败任务在假 qB 里不存在,同步后标 removed(见上面 download.inspect),已移除的不计入 + downloaders: [{ id: instanceId, kind: "qbittorrent", enabled: true, tasks: { total: 0, active: 0, failed: 0 } }], + mediaServers: [], + }); + expect(byCap("downloader.list").text).not.toMatch(/baseUrl|base_url|password|secret/i); expect(byCap("task.inspect")).toMatchObject({ isError: true }); expect(byCap("task.inspect").text).toContain("download.inspect"); diff --git a/apps/server/test/e2e/e2-agent-delegate.test.ts b/apps/server/test/e2e/e2-agent-delegate.test.ts index d4f0b1f8..abb318b8 100644 --- a/apps/server/test/e2e/e2-agent-delegate.test.ts +++ b/apps/server/test/e2e/e2-agent-delegate.test.ts @@ -45,8 +45,8 @@ describe.skipIf(!hasPg)("E2 Agent 委托闭环(fake 模型 + 真实能力/授权 return r.body.data.id as string; }; - it("启动断言:39 个能力全部注册,并在能力目录里可见", async () => { - expect(e.booted.ctx.capabilities.list()).toHaveLength(39); + it("启动断言:40 个能力全部注册,并在能力目录里可见", async () => { + expect(e.booted.ctx.capabilities.list()).toHaveLength(40); const r = await e.api("GET", "/capabilities"); expect(r.body.data.items.map((c: { id: string }) => c.id).sort()).toEqual( e.booted.ctx.capabilities diff --git a/apps/server/test/sources-identity/site-capabilities.test.ts b/apps/server/test/sources-identity/site-capabilities.test.ts index 3e2ef548..bb6ade85 100644 --- a/apps/server/test/sources-identity/site-capabilities.test.ts +++ b/apps/server/test/sources-identity/site-capabilities.test.ts @@ -15,8 +15,8 @@ const TiB = 1024 ** 4; const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const; describe("站点能力规格", () => { - it("进入首批能力目录(17 + 3 只读摘要 + 4 站点 + 10 浏览器 + 2 会话交互 + 2 网页搜索 + 1 元数据补全 = 39);类别 / 风险 / 重放按约定", () => { - expect(CAPABILITY_IDS).toHaveLength(39); + it("进入首批能力目录(17 + 4 只读摘要 + 4 站点 + 10 浏览器 + 2 会话交互 + 2 网页搜索 + 1 元数据补全 = 40);类别 / 风险 / 重放按约定", () => { + expect(CAPABILITY_IDS).toHaveLength(40); for (const id of SITE_IDS) expect(CAPABILITY_IDS).toContain(id); expect(capabilitySpec("site.list")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" }); expect(capabilitySpec("site.user_data")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" }); diff --git a/docs/runbook/agent.md b/docs/runbook/agent.md index b52b4193..9f894caa 100644 --- a/docs/runbook/agent.md +++ b/docs/runbook/agent.md @@ -10,7 +10,7 @@ | 分组 | 能力 | |---|---| -| 查询(read) | `catalog.lookup`、`release.search`、`release.inspect`、`inventory.query`、`subscription.list`、`decision.list`、`download.inspect`、`task.inspect`、`site.list`、`site.user_data` | +| 查询(read) | `catalog.lookup`、`release.search`、`release.inspect`、`inventory.query`、`subscription.list`、`decision.list`、`download.inspect`、`downloader.list`、`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)) | @@ -23,6 +23,7 @@ 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`。 +- `downloader.list {includeDisabled?}`(FIX-7):已配置的下载器与媒体服务器——名称、类型、启用、最近连通状态(`health_status` / `last_seen_at`)、版本,下载器附本工具管理的任务数(总数 / 进行中 / 失败)。只读本地记录,不连下载器,不含地址与凭证。此前智能体只能看 `site.list`(不含下载器),会把"没看到"汇报成"下载器不在系统内"。 - `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` 一律是字符串。 diff --git a/packages/agent/src/system-prompt.ts b/packages/agent/src/system-prompt.ts index 9ea09039..e29d49cb 100644 --- a/packages/agent/src/system-prompt.ts +++ b/packages/agent/src/system-prompt.ts @@ -57,6 +57,7 @@ export function interactionRules(i: NonNullable { "## 汇报格式 - 第一句写结论:做成了什么、没做成什么、为什么。 - 对象用名字:作品名、站点名、资源标题、文件名;不写函数名、工具名、字段名、参数、UUID 或 hash。 + - 数量以工具返回为准:派出几个子会话、提交几个下载,只数成功的调用,失败或被拒绝的不算。 - 不超过 10 行;不复述过程,不写下一步打算,除非需要用户做什么。 - 只有列举多个资源(3 个以上的资源、任务或文件)时才用表格,其余用短句或列表。 - 可以用 Markdown 列表、表格和粗体,不写标题,不写 HTML。 @@ -40,7 +41,7 @@ describe("汇报格式(AGUI-3)", () => { expect(p).toContain("写出查到的对象名称和关键数值"); // 旧的"最后用简短中文汇报…可以用 Markdown(标题…)"已换成汇报格式一节 expect(p).not.toContain("最后用简短中文汇报"); - expect(REPORT_FORMAT.length).toBe(5); + expect(REPORT_FORMAT.length).toBe(6); }); it("子会话 / 视觉子流程汇报给父会话:不加用户汇报格式", () => { diff --git a/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts index a17e948a..d98e3e7e 100644 --- a/packages/contracts/src/capabilities-io.ts +++ b/packages/contracts/src/capabilities-io.ts @@ -320,6 +320,37 @@ export const DownloadInspectOutput = z.object({ otherMatches: z.array(Id), }); +/** downloader.list(FIX-7):已配置的下载器与媒体服务器(只读本地记录,不含地址和凭证) */ +export const DownloaderListInput = z.object({ + includeDisabled: z.boolean().default(true), +}); +const InstanceHealth = z.enum(["unknown", "healthy", "degraded", "offline"]); +export const DownloaderListOutput = z.object({ + downloaders: z.array( + z.object({ + id: Id, + name: z.string(), + kind: z.string(), + enabled: z.boolean(), + healthStatus: InstanceHealth, + lastSeenAt: z.string().nullable(), + version: z.string().nullable(), + /** 本工具管理、未移除的下载任务 */ + tasks: z.object({ total: NonNegInt, active: NonNegInt, failed: NonNegInt }), + }), + ), + mediaServers: z.array( + z.object({ + id: Id, + name: z.string(), + kind: z.string(), + enabled: z.boolean(), + healthStatus: InstanceHealth, + lastSeenAt: z.string().nullable(), + }), + ), +}); + // ── 子代理(W7-B) ── /** 子会话预算:缺省按父会话剩余预算的一半划拨;超过剩余预算直接拒绝 */ @@ -842,6 +873,7 @@ export const CAPABILITY_IO = { "subscription.list": { input: SubscriptionListInput, output: SubscriptionListOutput }, "decision.list": { input: DecisionListInput, output: DecisionListOutput }, "download.inspect": { input: DownloadInspectInput, output: DownloadInspectOutput }, + "downloader.list": { input: DownloaderListInput, output: DownloaderListOutput }, "agent.spawn": { input: AgentSpawnInput, output: AgentSpawnOutput }, "agent.await": { input: AgentAwaitInput, output: AgentAwaitOutput }, "site.list": { input: SiteListInput, output: SiteListOutput }, @@ -918,6 +950,8 @@ export type DecisionListInput = z.infer; export type DecisionListOutput = z.infer; export type DownloadInspectInput = z.infer; export type DownloadInspectOutput = z.infer; +export type DownloaderListInput = z.infer; +export type DownloaderListOutput = z.infer; export type AgentSpawnInput = z.infer; export type AgentSpawnOutput = z.infer; export type AgentAwaitInput = z.infer; diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index d63165c3..6d3b60c2 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -69,6 +69,7 @@ export const CAPABILITY_IDS = [ "subscription.list", "decision.list", "download.inspect", + "downloader.list", "agent.spawn", "agent.await", "site.list", @@ -329,6 +330,18 @@ export const CAPABILITY_CATALOG: Record true, false, ), + // FIX-7:智能体原先只能从站点列表推断下载器是否接入(site.list 不含下载器),汇报成"下载器不在系统内" + "downloader.list": d( + "downloader.list", + "查看下载器", + "列出已配置的下载器和媒体服务器:名称、类型、是否启用、最近一次连通状态与时间、本工具管理的下载任务数(按进行中 / 失败计)。只读本地记录,不连下载器;问下载器或媒体服务器是否接入、是否正常时先用它,某个下载任务用 download.inspect", + "read", + "DownloaderList", + "read", + "low", + true, + false, + ), // 子代理(设置 pi.subagents 开启后才提供给模型):类别 propose——只建有界子会话,不产生外部副作用, // "先给方案"的会话也能派生(子会话同样只给方案);授权不高于父会话,由服务端校验 "agent.spawn": d( @@ -612,6 +625,7 @@ export const CAPABILITY_ACTIVITY: Record "subscription.list": act("查", "订阅"), "decision.list": act("查", "待处理事项"), "download.inspect": act("查", "下载任务", "个"), + "downloader.list": act("查", "下载器"), "agent.spawn": act("派出", "子会话", "个"), "agent.await": act("等", "子会话"), "site.list": act("查", "站点"), diff --git a/packages/contracts/src/reasons.ts b/packages/contracts/src/reasons.ts index e4200a28..9d9f2f01 100644 --- a/packages/contracts/src/reasons.ts +++ b/packages/contracts/src/reasons.ts @@ -48,6 +48,35 @@ const REASONS: Record = { not_imported_kind: { text: "这类文件不入库" }, target_root_not_managed: { text: "目标目录不是本工具管理的媒体库目录", action: "在设置 → 媒体库与目录里检查" }, download_removed: { text: "下载任务已从下载器删除" }, + // FIX-7:智能体汇报与工具结果里常见的码(能力错误、下载计划的空间判定、站点登录、待处理类型) + invalid_input: { text: "参数不对" }, + invalid_output: { text: "返回结果格式不对" }, + unauthorized: { text: "需要你批准" }, + forbidden: { text: "没有权限" }, + not_found: { text: "没找到" }, + precondition: { text: "前置条件不满足" }, + internal: { text: "内部错误" }, + volume_state_unknown: { text: "下载盘用量未知", action: "在下载面板检查下载盘" }, + hard_threshold_reached: { text: "下载盘已到暂停线" }, + projected_over_threshold: { text: "下完会超过下载盘暂停线" }, + awaiting_resume_watermark: { text: "等下载盘降到恢复线" }, + request_size_unknown: { text: "资源大小未知" }, + inflight_size_unknown: { text: "在下的任务大小未知" }, + budget_exceeded: { text: "超出下载额度" }, + budget_unknown_size: { text: "资源大小未知,无法核对下载额度" }, + no_downloader_instance: { text: "没有可用的下载器", action: "在设置 → 下载器里添加" }, + download_save_path_unmapped: { text: "下载目录没有路径映射", action: "在设置 → 下载器里添加路径映射" }, + site_out_of_scope: { text: "站点不在这次的范围内" }, + slot_changed_or_reserved: { text: "收录版本已变化或被占用" }, + auth_missing: { text: "站点没有配置 Cookie", action: "在站点账号里填写" }, + auth_expired: { text: "站点登录已过期", action: "刷新站点登录" }, + auth_invalid: { text: "站点登录无效", action: "检查站点账号" }, + source_unreachable: { text: "站点连不上" }, + download_failed: { text: "下载失败" }, + download_stalled: { text: "下载无进展" }, + space_insufficient: { text: "空间不足" }, + unidentifiable: { text: "没识别出作品" }, + user_deleted: { text: "已删除" }, }; /** @@ -69,7 +98,8 @@ export function reasonText(raw: string | null | undefined): ReasonText | null { ...(parts[0]?.action ? { action: parts[0].action } : {}), }; } - return REASONS[s] ?? REASONS[head] ?? null; + // 带分类前缀的码(`cache:volume_state_unknown`、`deferred:no_downloader_instance`)按冒号后的码查 + return REASONS[s] ?? REASONS[head] ?? (rest ? (REASONS[rest.trim()] ?? null) : null); } /** 只要一句话:"没识别出作品。指定作品后重新整理";不认识的码返回 fallback */ diff --git a/packages/contracts/test/reasons.test.ts b/packages/contracts/test/reasons.test.ts index 937c1589..6b87b593 100644 --- a/packages/contracts/test/reasons.test.ts +++ b/packages/contracts/test/reasons.test.ts @@ -18,6 +18,15 @@ describe("reasonText(DEAI-2 原因码人话化)", () => { for (const c of ["blocked_by_conflict", "main_failed", "not_imported_kind"]) expect(reasonText(c)).not.toBeNull(); }); + it("智能体汇报里的码(FIX-7):能力错误、带分类前缀的空间判定、站点登录", () => { + expect(reasonText("invalid_input")?.text).toBe("参数不对"); + expect(reasonText("cache:volume_state_unknown")?.text).toBe("下载盘用量未知"); + expect(reasonText("deferred:no_downloader_instance")?.text).toBe("没有可用的下载器"); + expect(reasonText("auth_missing")?.text).toContain("Cookie"); + expect(reasonText("unidentifiable")?.text).toBe("没识别出作品"); + expect(reasonText("cache:some_new_code")).toBeNull(); + }); + it("不认识的码返回 null;句子兜底不把码原样给人看", () => { expect(reasonText("some_new_code")).toBeNull(); expect(reasonSentence("some_new_code")).toBe("处理失败");