diff --git a/apps/server/src/modules/acquisition-organize/capabilities.ts b/apps/server/src/modules/acquisition-organize/capabilities.ts index ce03f5e5..0684a4ed 100644 --- a/apps/server/src/modules/acquisition-organize/capabilities.ts +++ b/apps/server/src/modules/acquisition-organize/capabilities.ts @@ -2,6 +2,7 @@ import { CapabilityError, type CapabilityRegistry } from "@umt/agent"; import { capabilitySpec } from "@umt/contracts"; import { sql } from "@umt/db"; import { normalizeTitle, transition } from "@umt/domain"; +import { coveringGrant, type PlanBinding, planBinding } from "../agent/authorize.ts"; import { previewPolicy, publishRevision } from "./routes/rules.ts"; import { evaluateAcquisition, publishedAcquisitionPolicies } from "./services/acquisition.ts"; import { type AoDeps, enqueueTracked, ensureTaskRow, markTask } from "./services/common.ts"; @@ -76,6 +77,178 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => return { items } as never; }); + // AG-3:订阅摘要(分组计数 + 条目),版本目标只给总数 / 已满足数,细节用 inventory.query + registry.register(capabilitySpec("subscription.list"), async (input) => { + const db = deps().ctx.db; + let q = db + .selectFrom("subscription as s") + .leftJoin("media_entity as e", "e.id", "s.media_entity_id") + .where((eb) => eb.or([eb("e.status", "is", null), eb("e.status", "<>", "merged")])); + if (input.status?.length) q = q.where("s.status", "in", input.status); + if (input.mediaKind) q = q.where("s.media_kind", "=", input.mediaKind); + if (input.query) { + const like = `%${input.query.replace(/[\\%_]/g, (m) => `\\${m}`)}%`; + q = q.where((eb) => + eb.or([ + eb("e.title", "ilike", like), + eb("e.original_title", "ilike", like), + eb("s.title_query", "ilike", like), + ]), + ); + } + const groupCol = input.groupBy === "mediaKind" ? "s.media_kind" : "s.status"; + const [groups, rows] = await Promise.all([ + input.groupBy === "none" + ? q.select(sql`'all'`.as("key")).select(sql`count(*)::int`.as("count")).execute() + : q + .select(sql`${sql.ref(groupCol)}::text`.as("key")) + .select(sql`count(*)::int`.as("count")) + .groupBy(groupCol) + .orderBy("count", "desc") + .execute(), + q + .select([ + "s.id", + "s.media_entity_id", + "s.title_query", + "s.media_kind", + "s.mode", + "s.status", + "s.priority", + "s.status_reason", + "s.last_searched_at", + "s.next_search_at", + "s.last_progress_at", + "e.title as entity_title", + ]) + .select((eb) => [ + eb + .selectFrom("acquisition_slot as a") + .select(sql`count(*)::int`.as("n")) + .whereRef("a.subscription_id", "=", "s.id") + .as("slot_total"), + eb + .selectFrom("acquisition_slot as a") + .select(sql`count(*)::int`.as("n")) + .whereRef("a.subscription_id", "=", "s.id") + .where("a.status", "=", "satisfied") + .as("slot_satisfied"), + ]) + .orderBy("s.updated_at", "desc") + .limit(input.limit) + .execute(), + ]); + const iso = (d: Date | null) => (d ? d.toISOString() : null); + return { + total: groups.reduce((n, g) => n + Number(g.count), 0), + groups: groups.map((g) => ({ key: g.key, count: Number(g.count) })), + items: rows.map((r) => ({ + id: r.id, + title: r.entity_title ?? r.title_query, + mediaEntityId: r.media_entity_id, + mediaKind: r.media_kind, + mode: r.mode, + status: r.status, + priority: r.priority, + statusReason: r.status_reason, + slots: { total: Number(r.slot_total ?? 0), satisfied: Number(r.slot_satisfied ?? 0) }, + lastSearchedAt: iso(r.last_searched_at), + nextSearchAt: iso(r.next_search_at), + lastProgressAt: iso(r.last_progress_at), + })), + } as never; + }); + + // AG-3:下载任务详情(按 id 或 hash);关联计划 / 资源 / 业务任务 / 整理记录 / 待处理事项 + registry.register(capabilitySpec("download.inspect"), async (input) => { + const db = deps().ctx.db; + const hash = input.hash?.toLowerCase(); + const found = await db + .selectFrom("download_task") + .selectAll() + .$if(!!input.downloadTaskId, (qb) => qb.where("id", "=", input.downloadTaskId!)) + .$if(!input.downloadTaskId && !!hash, (qb) => + qb.where((eb) => + eb.or([eb("torrent_hash", "=", hash!), eb("infohash_v1", "=", hash!), eb("infohash_v2", "=", hash!)]), + ), + ) + .orderBy("updated_at", "desc") + .limit(10) + .execute(); + const dt = found[0]; + if (!dt) { + if (input.downloadTaskId) { + const t = await db.selectFrom("task").select("id").where("id", "=", input.downloadTaskId).executeTakeFirst(); + if (t) throw new CapabilityError("not_found", "这是业务任务 ID,不是下载任务 ID;业务任务用 task.inspect 查看"); + } + throw new CapabilityError("not_found", "下载任务不存在"); + } + const plan = dt.acquisition_plan_id + ? await db + .selectFrom("acquisition_plan") + .select(["id", "status", "status_reason", "slot_id", "task_id"]) + .where("id", "=", dt.acquisition_plan_id) + .executeTakeFirst() + : undefined; + const [release, tasks, organization, decisions] = await Promise.all([ + dt.source_release_id + ? db + .selectFrom("source_release") + .select(["id", "title", "site_id"]) + .where("id", "=", dt.source_release_id) + .executeTakeFirst() + : Promise.resolve(undefined), + db + .selectFrom("task") + .select(["id", "kind", "status", "last_error"]) + .where((eb) => + eb.or([ + eb.and([eb("subject_type", "=", "download_task"), eb("subject_id", "=", dt.id)]), + ...(plan ? [eb.and([eb("subject_type", "=", "acquisition_plan"), eb("subject_id", "=", plan.id)])] : []), + ]), + ) + .orderBy("created_at", "desc") + .limit(20) + .execute(), + db + .selectFrom("organization_record") + .select(["id", "status", "target_path"]) + .where("download_task_id", "=", dt.id) + .orderBy("created_at", "desc") + .limit(20) + .execute(), + db + .selectFrom("decision") + .select(["id", "kind", "status", "title"]) + .where("subject_id", "=", dt.id) + .orderBy("created_at", "desc") + .limit(20) + .execute(), + ]); + return { + downloadTask: toApi(dt) as never, + plan: plan + ? { + id: plan.id, + status: plan.status, + statusReason: plan.status_reason, + slotId: plan.slot_id, + taskId: plan.task_id, + } + : null, + release: release ? { id: release.id, title: release.title, siteId: release.site_id } : null, + tasks: tasks.map((t) => ({ + id: t.id, + kind: t.kind, + status: t.status, + lastError: (t.last_error ?? null) as Record | null, + })), + organization: organization.map((o) => ({ id: o.id, status: o.status, targetPath: o.target_path })), + decisions: decisions.map((x) => ({ id: x.id, kind: x.kind, status: x.status, title: x.title })), + otherMatches: found.slice(1).map((x) => x.id), + } as never; + }); + registry.register(capabilitySpec("download.plan"), async (input) => { const d = deps(); const db = d.ctx.db; @@ -119,17 +292,19 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => .selectAll() .where("id", "=", input.slotId) .executeTakeFirstOrThrow(); - const grant = await db - .selectFrom("automation_grant") - .select("id") - .where("status", "=", "active") - .where("action", "in", ["download.submit", "policy.acquisition", "*"]) - .executeTakeFirst(); + // 按授权范围判定(AG-3):有覆盖本计划(作品 / 类型 / 每日预算)的持续授权 → 不需审批;否则需要 + const target = plan ? await planBinding(db, "acquisition_plan", plan.id) : null; + const covered = await coveringGrant( + db, + "download.submit", + target ?? (await slotBinding(db, fresh.media_entity_id)), + new Date(), + ); return { plan: plan ? (toApi(plan) as never) : null, slot: toApi(fresh) as never, rejected, - requiresApproval: !grant, + requiresApproval: !("grantId" in covered), }; }); @@ -412,3 +587,13 @@ function planHash(request: object, frozen: FrozenPlan): string { } const isUuid = (s: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); + +/** 没生成计划时按 Slot 的作品判定授权范围(字节预算按 0 计) */ +async function slotBinding(db: AoDeps["ctx"]["db"], mediaEntityId: string): Promise { + const e = await db + .selectFrom("media_entity") + .select(["id", "kind"]) + .where("id", "=", mediaEntityId) + .executeTakeFirst(); + return { payloadHash: "", revision: 0, mediaEntityId: e?.id ?? null, mediaKind: e?.kind ?? null, bytes: 0 }; +} diff --git a/apps/server/src/modules/agent/authorize.ts b/apps/server/src/modules/agent/authorize.ts index 5cd707df..e5fdd246 100644 --- a/apps/server/src/modules/agent/authorize.ts +++ b/apps/server/src/modules/agent/authorize.ts @@ -102,6 +102,76 @@ export async function planBinding(db: Kysely, planType: string, planId: stri return null; } +/** + * 持续授权是否覆盖本能力(不含单次审批与会话内审批):action 覆盖、active 未过期、绑定策略仍发布、 + * 对象范围与每日预算。download.plan 用它判定 requiresApproval,authorize 用它放行。 + */ +export async function coveringGrant( + db: Kysely, + capabilityId: string, + target: PlanBinding | null, + now: Date, +): Promise<{ grantId: string } | { reasons: string[] }> { + const d = { id: capabilityId }; + const actions = [...(GRANT_ACTIONS[d.id] ?? [d.id]), "*"]; + const grants = await db + .selectFrom("automation_grant as g") + .leftJoin("policy_revision as p", "p.id", "g.policy_revision_id") + .select(["g.id", "g.action", "g.scope", "g.budget", "g.policy_revision_id", "p.status as policy_status"]) + .where("g.status", "=", "active") + .where("g.action", "in", actions) + .where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", now)])) + .orderBy("g.created_at", "desc") + .execute(); + const reasons: string[] = []; + for (const g of grants) { + if (g.policy_revision_id && g.policy_status !== "published") { + reasons.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`); + continue; + } + const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] }; + if (scope.capabilities?.length && !scope.capabilities.includes(d.id)) { + reasons.push(`授权 ${g.id} 不含能力 ${d.id}`); + continue; + } + if ( + scope.mediaEntityIds?.length && + (!target?.mediaEntityId || !scope.mediaEntityIds.includes(target.mediaEntityId)) + ) { + reasons.push(`授权 ${g.id} 的对象范围不含目标作品`); + continue; + } + if (scope.mediaKinds?.length && target?.mediaKind && !kindCovered(scope.mediaKinds, target.mediaKind)) { + reasons.push(`授权 ${g.id} 的媒体类型范围不含 ${target.mediaKind}`); + continue; + } + const budget = (g.budget ?? {}) as { maxInvocationsPerDay?: number; maxBytesPerDay?: number }; + if (budget.maxInvocationsPerDay !== undefined || budget.maxBytesPerDay !== undefined) { + const day = new Date(now); + day.setHours(0, 0, 0, 0); + const used = await sql<{ n: number }>` + select count(*)::int as n from capability_invocation + where grant_id = ${g.id} and status = 'succeeded' and created_at >= ${day}`.execute(db); + if (budget.maxInvocationsPerDay !== undefined && (used.rows[0]?.n ?? 0) >= budget.maxInvocationsPerDay) { + reasons.push(`授权 ${g.id} 今日次数预算已用尽`); + continue; + } + if (budget.maxBytesPerDay !== undefined && d.id === "download.submit") { + const bytes = await sql<{ b: string }>` + select coalesce(sum(p.reserved_bytes), 0)::bigint as b + from capability_invocation ci join acquisition_plan p on p.id::text = ci.input->>'planId' + where ci.grant_id = ${g.id} and ci.status = 'succeeded' and ci.created_at >= ${day}`.execute(db); + if (Number(bytes.rows[0]?.b ?? 0) + (target?.bytes ?? 0) > budget.maxBytesPerDay) { + reasons.push(`授权 ${g.id} 今日字节预算不足`); + continue; + } + } + } + return { grantId: g.id }; + } + return { reasons }; +} + export function createAuthorizer(deps: { db: Kysely; settings: SettingsReader; @@ -168,62 +238,9 @@ export function createAuthorizer(deps: { } // 2) 持续授权 - const actions = [...(GRANT_ACTIONS[d.id] ?? [d.id]), "*"]; - const grants = await db - .selectFrom("automation_grant as g") - .leftJoin("policy_revision as p", "p.id", "g.policy_revision_id") - .select(["g.id", "g.action", "g.scope", "g.budget", "g.policy_revision_id", "p.status as policy_status"]) - .where("g.status", "=", "active") - .where("g.action", "in", actions) - .where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", now())])) - .orderBy("g.created_at", "desc") - .execute(); - const reasons: string[] = []; - for (const g of grants) { - if (g.policy_revision_id && g.policy_status !== "published") { - reasons.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`); - continue; - } - const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] }; - if (scope.capabilities?.length && !scope.capabilities.includes(d.id)) { - reasons.push(`授权 ${g.id} 不含能力 ${d.id}`); - continue; - } - if ( - scope.mediaEntityIds?.length && - (!target?.mediaEntityId || !scope.mediaEntityIds.includes(target.mediaEntityId)) - ) { - reasons.push(`授权 ${g.id} 的对象范围不含目标作品`); - continue; - } - if (scope.mediaKinds?.length && target?.mediaKind && !kindCovered(scope.mediaKinds, target.mediaKind)) { - reasons.push(`授权 ${g.id} 的媒体类型范围不含 ${target.mediaKind}`); - continue; - } - const budget = (g.budget ?? {}) as { maxInvocationsPerDay?: number; maxBytesPerDay?: number }; - if (budget.maxInvocationsPerDay !== undefined || budget.maxBytesPerDay !== undefined) { - const day = new Date(now()); - day.setHours(0, 0, 0, 0); - const used = await sql<{ n: number }>` - select count(*)::int as n from capability_invocation - where grant_id = ${g.id} and status = 'succeeded' and created_at >= ${day}`.execute(db); - if (budget.maxInvocationsPerDay !== undefined && (used.rows[0]?.n ?? 0) >= budget.maxInvocationsPerDay) { - reasons.push(`授权 ${g.id} 今日次数预算已用尽`); - continue; - } - if (budget.maxBytesPerDay !== undefined && d.id === "download.submit") { - const bytes = await sql<{ b: string }>` - select coalesce(sum(p.reserved_bytes), 0)::bigint as b - from capability_invocation ci join acquisition_plan p on p.id::text = ci.input->>'planId' - where ci.grant_id = ${g.id} and ci.status = 'succeeded' and ci.created_at >= ${day}`.execute(db); - if (Number(bytes.rows[0]?.b ?? 0) + (target?.bytes ?? 0) > budget.maxBytesPerDay) { - reasons.push(`授权 ${g.id} 今日字节预算不足`); - continue; - } - } - } - return { allowed: true, grantId: g.id }; - } + const covered = await coveringGrant(db, d.id, target, now()); + if ("grantId" in covered) return { allowed: true, grantId: covered.grantId }; + const reasons = covered.reasons; // 3) 会话内审批:控制台管理员会话里,没有持续授权的计划类动作在步骤处发起审批并挂起会话 if (ap && target && !approvalId) { const planId = inp[ap.field] as string; diff --git a/apps/server/src/modules/agent/capabilities.ts b/apps/server/src/modules/agent/capabilities.ts index 6f212b61..b44f0074 100644 --- a/apps/server/src/modules/agent/capabilities.ts +++ b/apps/server/src/modules/agent/capabilities.ts @@ -188,7 +188,20 @@ export async function cancelTask(ctx: ServerContext, taskId: string, reason: str export function registerTaskCapabilities(registry: CapabilityRegistry, ctx: ServerContext): void { registry.register(capabilitySpec("task.inspect"), async (input) => { const t = await ctx.db.selectFrom("task").selectAll().where("id", "=", input.taskId).executeTakeFirst(); - if (!t) throw new CapabilityError("not_found", "任务不存在"); + if (!t) { + // 模型常把下载任务 ID 当业务任务 ID 传进来:明确指出该用哪个能力 + const dt = await ctx.db + .selectFrom("download_task") + .select("id") + .where("id", "=", input.taskId) + .executeTakeFirst(); + if (dt) + throw new CapabilityError( + "invalid_input", + "这是下载任务 ID,不是业务任务 ID;下载任务用 download.inspect 查看(参数 downloadTaskId)", + ); + throw new CapabilityError("not_found", "任务不存在"); + } const children = await ctx.db .selectFrom("task") .selectAll() @@ -203,6 +216,44 @@ export function registerTaskCapabilities(registry: CapabilityRegistry, ctx: Serv }; }); + // AG-3:待处理事项摘要(含可选处理 options);缺省只列未关闭的 + registry.register(capabilitySpec("decision.list"), async (input) => { + const status = input.status?.length ? input.status : (["open", "investigating", "awaiting_user"] as const); + let q = ctx.db.selectFrom("decision").where("status", "in", [...status]); + if (input.kind?.length) q = q.where("kind", "in", input.kind); + const [byKind, rows] = await Promise.all([ + q + .select(["kind", sql`count(*)::int`.as("count")]) + .groupBy("kind") + .orderBy("count", "desc") + .execute(), + q + .selectAll() + .orderBy(sql`case severity when 'critical' then 0 when 'warning' then 1 else 2 end`) + .orderBy("updated_at", "desc") + .limit(input.limit) + .execute(), + ]); + return { + total: byKind.reduce((n, k) => n + Number(k.count), 0), + byKind: byKind.map((k) => ({ kind: k.kind, count: Number(k.count) })), + items: rows.map((d) => ({ + id: d.id, + kind: d.kind, + status: d.status, + severity: d.severity, + title: d.title, + summary: d.summary, + subjectType: d.subject_type, + subjectId: d.subject_id, + attempts: d.attempts, + options: (Array.isArray(d.options) ? d.options : []) as never, + createdAt: d.created_at.toISOString(), + updatedAt: d.updated_at.toISOString(), + })), + }; + }); + registry.register(capabilitySpec("task.retry"), async (input, c) => { const row = await retryTask(ctx, input.taskId, c.actorId, input.reason); return { taskId: row.id, status: row.status as TaskStatus }; diff --git a/apps/server/test/e2e/e2-agent-delegate.test.ts b/apps/server/test/e2e/e2-agent-delegate.test.ts index 6da56dff..a6b8ad15 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("启动断言:35 个能力全部注册,并在能力目录里可见", async () => { - expect(e.booted.ctx.capabilities.list()).toHaveLength(35); + it("启动断言:38 个能力全部注册,并在能力目录里可见", async () => { + expect(e.booted.ctx.capabilities.list()).toHaveLength(38); 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 be2d6b19..e557cb89 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 + 4 站点 + 10 浏览器 + 2 会话交互 + 2 网页搜索 = 35);类别 / 风险 / 重放按约定", () => { - expect(CAPABILITY_IDS).toHaveLength(35); + it("进入首批能力目录(17 + 3 只读摘要 + 4 站点 + 10 浏览器 + 2 会话交互 + 2 网页搜索 = 38);类别 / 风险 / 重放按约定", () => { + expect(CAPABILITY_IDS).toHaveLength(38); 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/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts index 7a59f889..65f99ff4 100644 --- a/packages/contracts/src/capabilities-io.ts +++ b/packages/contracts/src/capabilities-io.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { DecisionKind, DecisionOption, DecisionStatus, Severity } from "./agent.ts"; import { AgentOptionItem, AgentPlanStep, AgentPlanStepStatus } from "./agent-events.ts"; import { MediaAsset } from "./assets.ts"; import { BrowserController, BrowserDownloadVerification, BrowserSnapshot } from "./browser.ts"; @@ -9,7 +10,14 @@ import { AcquisitionPlan, DownloadStatus, DownloadTask, Task, TaskStatus } from import { ExternalEntityType, ExternalIdentity } from "./identity.ts"; import { ServerMapping } from "./ops.ts"; import { OrganizationAction, OrganizationRecord } from "./organization.ts"; -import { AcquisitionSlot, PolicyRevision } from "./policy.ts"; +import { + AcquisitionPriority, + AcquisitionSlot, + PolicyRevision, + SubscriptionMediaKind, + SubscriptionMode, + SubscriptionStatus, +} from "./policy.ts"; import { SiteAccountError, SiteAccountOverall, @@ -30,14 +38,33 @@ import { const Limit = z.number().int().min(1).max(100).default(20); +/** + * 探查用检索词(AG-3):去掉首尾空白后不能为空,不能是单个英文字母/数字/符号(如 "a"、"*"), + * 不能只有通配符与标点——这类查询只会拉回全表或噪声,浪费预算。中文单字片名(如「家」)允许。 + */ +export const ProbeQuery = z + .string() + .trim() + .min(1) + .max(200) + .refine((v) => !/^[ -~]$/.test(v), { message: "检索词太短:写作品名、别名或关键词,不要用单个字母或符号探查" }) + .refine((v) => !/^[\s*%?._\-+/\\|,;:!'"()[\]{}]+$/.test(v), { message: "检索词不能只有通配符或标点" }) + .describe("作品名、别名或关键词(至少一个完整词);不要用空串、单个字母或通配符探查"); + /** 写能力的通用受理结果:返回 task/plan ID,由任务页追踪(REQ §12.3) */ export const Accepted = z.object({ taskId: Id, planId: Id.nullable() }); export type Accepted = z.infer; export const CatalogLookupInput = z .object({ - query: z.string().min(1).max(200).optional(), - externalId: z.object({ provider: z.string(), entityType: ExternalEntityType, externalId: z.string() }).optional(), + query: ProbeQuery.optional(), + externalId: z + .object({ + provider: z.string().describe("如 tmdb、imdb、tvdb"), + entityType: ExternalEntityType, + externalId: z.string().describe('外部 ID 一律写成字符串,如 "693134"、"tt15239678"'), + }) + .optional(), kind: MediaKind.optional(), year: z.number().int().optional(), includeRemote: z.boolean().default(false), @@ -62,7 +89,7 @@ export const CatalogLookupOutput = z.object({ }); export const ReleaseSearchInput = z.object({ - query: z.string().min(1).max(200).optional(), + query: ProbeQuery.optional(), mediaEntityId: Id.optional(), siteIds: z.array(Id).optional(), /** 本地快照不足时是否实时补查 */ @@ -188,7 +215,8 @@ export const PolicyApplyOutput = z.object({ policy: PolicyRevision }); export const InventoryQueryInput = z.object({ mediaEntityId: Id.optional(), - query: z.string().max(200).optional(), + /** 不给 query 也不给 mediaEntityId 时按最近更新列出 */ + query: ProbeQuery.optional(), includeServerInventory: z.boolean().default(false), limit: Limit, }); @@ -205,6 +233,93 @@ export const InventoryQueryOutput = z.object({ ), }); +// ── AG-3 只读摘要 ── + +const oneOrMany = (t: T) => + z.union([t, z.array(t).min(1).max(10)]).transform((v) => (Array.isArray(v) ? v : [v]) as z.infer[]); + +export const SubscriptionListInput = z.object({ + /** 缺省列出全部状态 */ + status: oneOrMany(SubscriptionStatus).optional(), + mediaKind: SubscriptionMediaKind.optional(), + /** 按作品标题 / 检索词过滤 */ + query: ProbeQuery.optional(), + groupBy: z.enum(["status", "mediaKind", "none"]).default("status"), + limit: z.number().int().min(1).max(200).default(50), +}); +export const SubscriptionBrief = z.object({ + id: Id, + title: z.string().nullable(), + mediaEntityId: Id.nullable(), + mediaKind: SubscriptionMediaKind, + mode: SubscriptionMode, + status: SubscriptionStatus, + priority: AcquisitionPriority, + statusReason: z.string().nullable(), + /** 版本目标:总数 / 已满足数 */ + slots: z.object({ total: NonNegInt, satisfied: NonNegInt }), + lastSearchedAt: z.string().nullable(), + nextSearchAt: z.string().nullable(), + lastProgressAt: z.string().nullable(), +}); +export const SubscriptionListOutput = z.object({ + /** 过滤后的总数(items 可能被 limit 截断) */ + total: NonNegInt, + groups: z.array(z.object({ key: z.string(), count: NonNegInt })), + items: z.array(SubscriptionBrief), +}); + +export const DecisionListInput = z.object({ + /** 缺省只列未关闭的:open / investigating / awaiting_user */ + status: oneOrMany(DecisionStatus).optional(), + kind: oneOrMany(DecisionKind).optional(), + limit: z.number().int().min(1).max(100).default(20), +}); +export const DecisionBrief = z.object({ + id: Id, + kind: DecisionKind, + status: DecisionStatus, + severity: Severity, + title: z.string(), + summary: z.string().nullable(), + subjectType: z.string().nullable(), + subjectId: z.string().nullable(), + attempts: NonNegInt, + options: z.array(DecisionOption), + createdAt: z.string(), + updatedAt: z.string(), +}); +export const DecisionListOutput = z.object({ + total: NonNegInt, + byKind: z.array(z.object({ kind: DecisionKind, count: NonNegInt })), + items: z.array(DecisionBrief), +}); + +export const DownloadInspectInput = z + .object({ + downloadTaskId: Id.optional(), + /** 种子 infohash(v1 40 位 / v2 64 位十六进制,大小写均可) */ + hash: z + .string() + .trim() + .regex(/^[0-9a-fA-F]{40}$|^[0-9a-fA-F]{64}$/) + .optional(), + }) + .refine((v) => !!v.downloadTaskId || !!v.hash, { message: "downloadTaskId 与 hash 至少给一个" }); +export const DownloadInspectOutput = z.object({ + downloadTask: DownloadTask, + plan: z + .object({ id: Id, status: z.string(), statusReason: z.string().nullable(), slotId: Id, taskId: Id.nullable() }) + .nullable(), + release: z.object({ id: Id, title: z.string(), siteId: Id.nullable() }).nullable(), + /** 关联业务任务(提交下载 / 核验入库):任务 ID 可交给 task.inspect / task.retry */ + tasks: z.array(z.object({ id: Id, kind: z.string(), status: TaskStatus, lastError: JsonObject.nullable() })), + organization: z.array(z.object({ id: Id, status: z.string(), targetPath: z.string().nullable() })), + decisions: z.array(z.object({ id: Id, kind: DecisionKind, status: DecisionStatus, title: z.string() })), + /** 按 hash 查到多个下载器实例里的同一种子时,其余任务的 ID */ + otherMatches: z.array(Id), +}); + // ── 子代理(W7-B) ── /** 子会话预算:缺省按父会话剩余预算的一半划拨;超过剩余预算直接拒绝 */ @@ -220,7 +335,9 @@ export const AgentSpawnInput = z.object({ reason: z.string().max(500).optional(), /** 必须是父会话能力白名单的子集;缺省继承父会话白名单 */ capabilityAllowlist: z.array(z.string()).min(1).max(50).optional(), - budget: SubagentBudget.optional(), + budget: SubagentBudget.describe( + "各项都不能超过本会话剩余预算(工具调用、token、费用、时长),超过直接拒绝;不给则按剩余的一半划拨", + ).optional(), /** 不得高于父会话:plan_only 的父会话只能派生 plan_only 子会话;缺省同父会话 */ mode: z.enum(["execute", "plan_only"]).optional(), /** 覆盖子会话使用的 Agent 上游;缺省同父会话 */ @@ -688,6 +805,9 @@ export const CAPABILITY_IO = { "policy.preview": { input: PolicyPreviewInput, output: PolicyPreviewOutput }, "policy.apply": { input: PolicyApplyInput, output: PolicyApplyOutput }, "inventory.query": { input: InventoryQueryInput, output: InventoryQueryOutput }, + "subscription.list": { input: SubscriptionListInput, output: SubscriptionListOutput }, + "decision.list": { input: DecisionListInput, output: DecisionListOutput }, + "download.inspect": { input: DownloadInspectInput, output: DownloadInspectOutput }, "agent.spawn": { input: AgentSpawnInput, output: AgentSpawnOutput }, "agent.await": { input: AgentAwaitInput, output: AgentAwaitOutput }, "site.list": { input: SiteListInput, output: SiteListOutput }, @@ -757,6 +877,12 @@ export type PolicyApplyInput = z.infer; export type PolicyApplyOutput = z.infer; export type InventoryQueryInput = z.infer; export type InventoryQueryOutput = z.infer; +export type SubscriptionListInput = z.infer; +export type SubscriptionListOutput = z.infer; +export type DecisionListInput = z.infer; +export type DecisionListOutput = z.infer; +export type DownloadInspectInput = z.infer; +export type DownloadInspectOutput = 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 75332c0e..6366d06d 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -66,6 +66,9 @@ export const CAPABILITY_IDS = [ "policy.preview", "policy.apply", "inventory.query", + "subscription.list", + "decision.list", + "download.inspect", "agent.spawn", "agent.await", "site.list", @@ -129,7 +132,7 @@ export const CAPABILITY_CATALOG: Record "catalog.lookup": d( "catalog.lookup", "查作品", - "按标题/别名/外部 ID 查本地作品与元数据候选", + "按标题/别名/外部 ID 查本地作品与元数据候选。确认作品身份、拿作品 ID 时用;查库里已有文件与版本目标用 inventory.query", "read", "CatalogLookup", "read", @@ -140,7 +143,7 @@ export const CAPABILITY_CATALOG: Record "release.search": d( "release.search", "搜资源", - "先查本地资源索引与站点快照,不足时对已开启站点实时补查", + "先查本地资源索引与站点快照,不足时对已开启站点实时补查。找可下载的资源时用,检索词写作品名或关键词;已知作品时优先传 mediaEntityId", "read", "ReleaseSearch", "read", @@ -151,7 +154,7 @@ export const CAPABILITY_CATALOG: Record "release.inspect": d( "release.inspect", "看资源详情", - "读取资源详情、文件清单、促销与解析结果", + "读取资源详情、文件清单、促销与解析结果。看某条资源的文件清单、促销或解析细节时用;不要逐条遍历搜索结果", "read", "ReleaseInspect", "read", @@ -162,7 +165,7 @@ export const CAPABILITY_CATALOG: Record "identity.resolve": d( "identity.resolve", "识别身份", - "为观察/资源提出身份映射;证据充分时自动接受,有歧义时转为待处理事项", + "为观察/资源提出身份映射;证据充分时自动接受,有歧义时转为待处理事项。资源 / 观察还没对上作品时用;已识别的资源不要重复提交", "propose", "IdentityResolve", "organize.execute", @@ -173,7 +176,7 @@ export const CAPABILITY_CATALOG: Record "download.plan": d( "download.plan", "生成下载计划", - "评估候选,生成占用版本目标(slot)与预算预留的下载计划;不提交", + "评估候选,生成占用版本目标(slot)与预算预留的下载计划;不提交。选定资源后、提交前用;不会提交下载,也不预留版本目标", "preview", "DownloadPlan", "organize.execute", @@ -184,7 +187,7 @@ export const CAPABILITY_CATALOG: Record "download.submit": d( "download.submit", "提交下载", - "按已保存的计划提交到下载器;校验授权与硬阈值", + "按已保存的计划提交到下载器;校验授权与硬阈值。只提交 download.plan 生成的计划;结果未知时先用 download.inspect / task.inspect 核对,不要重复提交", "execute", "DownloadSubmit", "organize.execute", @@ -195,7 +198,7 @@ export const CAPABILITY_CATALOG: Record "download.recheck": d( "download.recheck", "重新校验/恢复下载", - "对已纳管的下载任务强制重新校验(action=recheck),或恢复非阈值、非人工的暂停(action=resume);不换源、不删除、不改保存路径", + "对已纳管的下载任务强制重新校验(action=recheck),或恢复非阈值、非人工的暂停(action=resume);不换源、不删除、不改保存路径。下载失败或文件缺失时用;换源、删除不在此能力内", "execute", "DownloadRecheck", "organize.execute", @@ -206,7 +209,7 @@ export const CAPABILITY_CATALOG: Record "organize.plan": d( "organize.plan", "生成整理计划", - "生成并保存整理计划:冻结源/目标路径、整理方式与前置条件;不执行", + "生成并保存整理计划:冻结源/目标路径、整理方式与前置条件;不执行。下载完成后整理入库前用;只生成计划不动文件", "preview", "OrganizePlan", "organize.read", @@ -217,7 +220,7 @@ export const CAPABILITY_CATALOG: Record "organize.execute": d( "organize.execute", "执行整理", - "执行已保存的整理计划(复制/移动),生成整理记录", + "执行已保存的整理计划(复制/移动),生成整理记录。只执行 organize.plan 生成且指纹未变的计划;指纹不一致时重新生成计划", "execute", "OrganizeExecute", "organize.execute", @@ -228,7 +231,7 @@ export const CAPABILITY_CATALOG: Record "task.inspect": d( "task.inspect", "查看任务", - "读取任务状态、业务时间线与错误", + "读取任务状态、业务时间线与错误。按业务任务 ID 查看;下载任务 ID 或种子 hash 用 download.inspect", "read", "TaskInspect", "read", @@ -239,7 +242,7 @@ export const CAPABILITY_CATALOG: Record "task.retry": d( "task.retry", "重试任务", - "对失败/死信任务重新排队;副作用经操作账本去重", + "对失败/死信任务重新排队;副作用经操作账本去重。只用于失败 / 死信的任务;同一任务不要反复重试", "execute", "TaskRetry", "organize.execute", @@ -250,7 +253,7 @@ export const CAPABILITY_CATALOG: Record "task.cancel": d( "task.cancel", "取消任务", - "停止未来步骤,不撤销已完成的外部动作", + "停止未来步骤,不撤销已完成的外部动作。用户要求停止或任务明显无意义时用", "execute", "TaskCancel", "organize.execute", @@ -261,7 +264,7 @@ export const CAPABILITY_CATALOG: Record "policy.preview": d( "policy.preview", "预览策略", - "用历史数据回放策略草案,展示新增命中与误排除", + "用历史数据回放策略草案,展示新增命中与误排除。发布策略前用,确认影响范围", "preview", "PolicyPreview", "read", @@ -272,7 +275,7 @@ export const CAPABILITY_CATALOG: Record "policy.apply": d( "policy.apply", "发布策略", - "发布策略版本(需授权);不直接执行 JavaScript", + "发布策略版本(需授权);不直接执行 JavaScript。只在用户明确要求发布策略时用", "execute", "PolicyApply", "admin", @@ -283,7 +286,7 @@ export const CAPABILITY_CATALOG: Record "inventory.query": d( "inventory.query", "查库存", - "查询本地资产、版本目标满足情况与外部库存证据", + "查询本地资产、版本目标满足情况与外部库存证据。查某个作品有哪些文件与版本目标时用;想盘点全部就不给 query,不要用单字母探查", "read", "InventoryQuery", "read", @@ -291,6 +294,40 @@ export const CAPABILITY_CATALOG: Record true, false, ), + // AG-3 只读摘要:订阅 / 待处理事项 / 下载任务。输出是摘要(不含凭证与私有链接),供模型盘点与调查 + "subscription.list": d( + "subscription.list", + "列出订阅", + "按状态或媒体类型分组列出订阅摘要(作品、模式、状态、版本目标满足数、上次/下次搜索)。盘点订阅、找缺版本时用;查单个作品的文件与版本目标用 inventory.query", + "read", + "SubscriptionList", + "read", + "low", + true, + false, + ), + "decision.list": d( + "decision.list", + "列出待处理事项", + "列出待处理事项摘要(种类、严重度、对象、尝试次数与可选处理 options),缺省只列未关闭的。巡检或汇总待处理时用;不要用它处理事项,处理由用户在待处理中心选择", + "read", + "DecisionList", + "read", + "low", + true, + false, + ), + "download.inspect": d( + "download.inspect", + "查看下载任务", + "按下载任务 ID 或种子 hash 读取下载任务状态、进度、错误、关联计划、资源、业务任务、整理记录与待处理事项。调查下载失败或无进展时用;业务任务 ID 用 task.inspect", + "read", + "DownloadInspect", + "read", + "low", + true, + false, + ), // 子代理(设置 pi.subagents 开启后才提供给模型):类别 propose——只建有界子会话,不产生外部副作用, // "先给方案"的会话也能派生(子会话同样只给方案);授权不高于父会话,由服务端校验 "agent.spawn": d( @@ -307,7 +344,7 @@ export const CAPABILITY_CATALOG: Record "agent.await": d( "agent.await", "等待子会话", - "等待本会话派生的子会话结束(等待期间不调用模型),返回各自状态、用量与汇报摘要;超时仍未结束的标为 pending", + "等待本会话派生的子会话结束(等待期间不调用模型),返回各自状态、用量与汇报摘要;超时仍未结束的标为 pending。派生子会话后汇总前用;没有派生子会话时不要调用", "read", "AgentAwait", "read", @@ -319,7 +356,7 @@ export const CAPABILITY_CATALOG: Record "site.list": d( "site.list", "列出站点", - "列出已登记站点的健康、登录状态与账号摘要(不含凭证)", + "列出已登记站点的健康、登录状态与账号摘要(不含凭证)。排查站点问题或确认站点可用时用", "read", "SiteList", "read", @@ -331,7 +368,7 @@ export const CAPABILITY_CATALOG: Record "site.health_check": d( "site.health_check", "检测站点", - "检测站点首页连通与登录态,写回站点健康状态", + "检测站点首页连通与登录态,写回站点健康状态。站点连通或登录态可疑时用;不要频繁检测", "execute", "SiteHealthCheck", "admin", @@ -343,7 +380,7 @@ export const CAPABILITY_CATALOG: Record "site.refresh_login": d( "site.refresh_login", "刷新站点登录", - "对站点账号重新登录或校验 Cookie / API Key,成功后拉取用户数据;需持续授权或审批", + "对站点账号重新登录或校验 Cookie / API Key,成功后拉取用户数据;需持续授权或审批。站点登录失效时用;验证码等人工步骤无法自动完成", "execute", "SiteLoginRefresh", "admin", @@ -354,7 +391,7 @@ export const CAPABILITY_CATALOG: Record "site.user_data": d( "site.user_data", "查站点用户数据", - "读取站点账号的最新用户数据(上传、下载、分享率、魔力、做种)与按天历史", + "读取站点账号的最新用户数据(上传、下载、分享率、魔力、做种)与按天历史。关心分享率、魔力或做种情况时用", "read", "SiteUserData", "read",