feat(agent): downloader.list 能力与汇报计数规则(FIX-7)
智能体原先只能从 site.list 推断下载器,会把没看到汇报成下载器不在系统内。 新增只读能力 downloader.list(下载器与媒体服务器、连通状态、管理的任务数); 原因码表补能力错误、下载盘判定、站点登录与待处理类型,带分类前缀的码按后半段查; 汇报格式加「数量以工具返回为准」。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
68f53390f6
commit
1b22bb5131
12 files changed
+163
-9
No files matched your search
@@ -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;
|
||||
|
||||
@@ -60,6 +60,7 @@ export const INVESTIGATE_CAPABILITIES = [
|
||||
"inventory.query",
|
||||
"task.inspect",
|
||||
"download.inspect",
|
||||
"downloader.list",
|
||||
"decision.list",
|
||||
"subscription.list",
|
||||
"identity.resolve",
|
||||
|
||||
@@ -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<ReturnType<typeof seedMovieTarget>>;
|
||||
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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in new issue
Block a user