diff --git a/apps/server/src/modules/agent/authorize.ts b/apps/server/src/modules/agent/authorize.ts index 905cfb52..c9a7b374 100644 --- a/apps/server/src/modules/agent/authorize.ts +++ b/apps/server/src/modules/agent/authorize.ts @@ -3,11 +3,12 @@ import type { AuthorizationChecker, AuthorizationDecision } from "@umt/agent"; import type { CapabilitySpec } from "@umt/contracts"; import { type DB, type Kysely, sql } from "@umt/db"; import type { SettingsReader } from "../types.ts"; -import { inlineApproval, proposeFirstBlock } from "./interaction.ts"; +import { inlineApproval, optionApproval, proposeFirstBlock } from "./interaction.ts"; import { exceedsAncestors } from "./subagents.ts"; // 服务端授权(REQ §10.2、§10.3):propose/approve/execute 类能力必须命中 // - 单次审批 approval:status=approved、绑定 plan 类型 + plan id + payload hash + revision、未过期(用后 consumed);或 +// - 选定即批准:管理员会话选定的方案绑定同一计划(AGUI-3,见 interaction.ts optionApproval);或 // - 持续授权 automation_grant:action 覆盖本能力、active 未过期、绑定的策略版本仍是 published、 // 对象范围(mediaEntityIds / mediaKinds)包含目标、预算(每日次数 / 字节)未用尽。 // 模型参数里的"用户已同意"不会被读取;输入里的 approvalId 也只是指针,必须在库里核对。 @@ -33,7 +34,7 @@ export const GRANT_ACTIONS: Record = { const CHALLENGE_STEP_CAPS = new Set(["browser.click_at", "browser.type_at", "browser.scroll"]); /** 单次审批绑定的计划类型与输入里的计划 ID 字段 */ -const APPROVAL_PLAN: Record = { +export const APPROVAL_PLAN: Record = { "download.submit": { planType: "acquisition_plan", field: "planId" }, "organize.execute": { planType: "organize_plan", field: "planId" }, "policy.apply": { planType: "policy_revision", field: "policyRevisionId" }, @@ -253,7 +254,13 @@ export function createAuthorizer(deps: { const covered = await coveringGrant(db, d.id, target, now()); if ("grantId" in covered) return { allowed: true, grantId: covered.grantId }; const reasons = covered.reasons; - // 3) 会话内审批:控制台管理员会话里,没有持续授权的计划类动作在步骤处发起审批并挂起会话 + // 3) 选定即批准(AGUI-3):管理员会话选定的方案绑定了同一份计划(类型 + ID + payload hash + revision) + if (ap && target && !approvalId && runId && (ctx.source === "agent" || ctx.source === "task")) { + const planId = inp[ap.field] as string; + const chosen = await optionApproval(db, runId, d.id, { planType: ap.planType, planId, binding: target }, now()); + if (chosen) return { allowed: true, approvalId: chosen.approvalId }; + } + // 4) 会话内审批:控制台管理员会话里,没有持续授权的计划类动作在步骤处发起审批并挂起会话 if (ap && target && !approvalId) { const planId = inp[ap.field] as string; const inline = await inlineApproval(db, d, { planType: ap.planType, planId, binding: target }, ctx, now()); diff --git a/apps/server/src/modules/agent/interaction.ts b/apps/server/src/modules/agent/interaction.ts index 27724a38..05513ac3 100644 --- a/apps/server/src/modules/agent/interaction.ts +++ b/apps/server/src/modules/agent/interaction.ts @@ -7,8 +7,10 @@ import { redactString, } from "@umt/agent"; import { + type AgentOptionAction, type AgentOptionItem, type AgentOptionsTimeoutAction, + type AgentOptionView, type AgentPlanStep, type AgentProposeOptionsOutput, CAPABILITY_CATALOG, @@ -20,7 +22,7 @@ import { } from "@umt/contracts"; import { type AgentRunRow, type DB, type Kysely, sql } from "@umt/db"; import type { ServerContext } from "../types.ts"; -import type { PlanBinding } from "./authorize.ts"; +import { APPROVAL_PLAN, type PlanBinding, planBinding } from "./authorize.ts"; import { AwaitSuspendedError, type AwaitSuspension, @@ -189,15 +191,28 @@ async function closeDecision( // ── 方案选择 ── +/** 方案绑定的计划(提出方案时按计划当前内容固定;选定即批准时照它建审批) */ +export interface OptionBinding { + capabilityId: AgentOptionAction["capabilityId"]; + planType: string; + planId: string; + payloadHash: string; + revision: number; + /** false = 删除 / 覆盖类动作,选定不代表批准,执行时仍单独确认 */ + approves: boolean; +} + interface OptionsEvidence { source: "agent.propose_options"; runId: string; invocationId: string; question: string; - options: AgentOptionItem[]; + options: AgentOptionView[]; recommendedId: string | null; expiresAt: string; timeoutAction: AgentOptionsTimeoutAction; + /** 方案 id → 绑定的计划 */ + bindings?: Record; } interface OptionsResolution { optionKey?: string | null; @@ -206,6 +221,8 @@ interface OptionsResolution { note?: string | null; by?: string; timeout?: boolean; + /** 选定即批准时建的审批 */ + approvalId?: string | null; } function optionsResult( @@ -217,15 +234,181 @@ function optionsResult( const choice = res.optionKey ?? null; const option = choice ? (ev.options.find((o) => o.id === choice) ?? null) : null; const outcome = res.outcome ?? (status === "resolved" && option ? "chosen" : "rejected"); + const chosen = !!option && outcome === "chosen"; + const binding = chosen && choice ? ev.bindings?.[choice] : undefined; return { - outcome: option && outcome === "chosen" ? "chosen" : outcome === "stop" ? "stop" : "rejected", + outcome: chosen ? "chosen" : outcome === "stop" ? "stop" : "rejected", choice: option ? choice : null, - option, + option: option + ? { + ...option, + action: option.action ? { capabilityId: option.action.capabilityId, planId: option.action.planId } : null, + } + : null, reason: safe(res.reason ?? res.note ?? null, 1000) || null, by, + approvedAction: binding && res.approvalId ? { capabilityId: binding.capabilityId, planId: binding.planId } : null, }; } +// ── 选定即批准(AGUI-3) ── + +/** 动作要点:对象 + 大小 / 数量(从计划本身取,不用模型的说法) */ +async function actionDetail(db: Kysely, planType: string, planId: string): Promise { + if (planType === "acquisition_plan") { + const r = await db + .selectFrom("acquisition_plan as p") + .innerJoin("acquisition_slot as s", "s.id", "p.slot_id") + .innerJoin("media_entity as e", "e.id", "s.media_entity_id") + .select(["e.title as entity", "p.reserved_bytes"]) + .where("p.id", "=", planId) + .executeTakeFirst(); + if (!r) return null; + const gb = Number(r.reserved_bytes ?? 0) / 1024 ** 3; + return safe(`《${r.entity}》${gb > 0 ? `· ${gb.toFixed(1)} GB` : ""}`, 200); + } + if (planType === "organize_plan") { + const r = await db.selectFrom("artifact").select("content").where("id", "=", planId).executeTakeFirst(); + const c = (r?.content ?? {}) as { + request?: { action?: string }; + plan?: { organize?: { files?: { action?: string }[] } }; + }; + const n = (c.plan?.organize?.files ?? []).filter((f) => f.action === "transfer" || f.action === "replace").length; + return `${n} 个文件 · ${c.request?.action === "move" ? "移动" : "复制"}`; + } + if (planType === "policy_revision") { + const r = await db.selectFrom("policy_revision").select("revision").where("id", "=", planId).executeTakeFirst(); + return r ? `策略版本 r${r.revision}` : null; + } + return null; +} + +/** + * 选定能不能当作批准:只有提交下载、只复制且不替换已有文件的整理。 + * 移动(删源文件)、替换已有文件、发布策略(覆盖当前策略)都要单独确认。 + */ +async function actionApprovable(db: Kysely, capabilityId: string, planId: string): Promise { + if (capabilityId === "download.submit") return true; + if (capabilityId === "organize.execute") { + const r = await db.selectFrom("artifact").select("content").where("id", "=", planId).executeTakeFirst(); + const c = (r?.content ?? {}) as { + request?: { action?: string }; + plan?: { organize?: { files?: { action?: string }[] } }; + }; + return c.request?.action === "copy" && !(c.plan?.organize?.files ?? []).some((f) => f.action === "replace"); + } + return false; +} + +/** 提出方案时解析各方案的动作:计划必须存在;绑定计划当前的 payload hash + revision */ +export async function resolveOptionActions( + db: Kysely, + options: AgentOptionItem[], +): Promise<{ views: AgentOptionView[]; bindings: Record }> { + const views: AgentOptionView[] = []; + const bindings: Record = {}; + for (const o of options) { + if (!o.action) { + views.push({ ...o, action: null }); + continue; + } + const ap = APPROVAL_PLAN[o.action.capabilityId]!; + const b = await planBinding(db, ap.planType, o.action.planId); + if (!b) + throw new CapabilityError( + "invalid_input", + `方案 ${o.id} 的 action 指向的计划不存在:先生成计划,再把计划 ID 填进 action.planId`, + ); + const approves = await actionApprovable(db, o.action.capabilityId, o.action.planId); + bindings[o.id] = { + capabilityId: o.action.capabilityId, + planType: ap.planType, + planId: o.action.planId, + payloadHash: b.payloadHash, + revision: b.revision, + approves, + }; + views.push({ + ...o, + action: { + ...o.action, + label: CAPABILITY_CATALOG[o.action.capabilityId].title, + detail: await actionDetail(db, ap.planType, o.action.planId), + approves, + }, + }); + } + return { views, bindings }; +} + +/** + * 管理员会话选定绑定了计划的方案:按提出方案时固定的绑定建一份已批准的单次审批。 + * 计划在这期间变了(hash / revision 不同)就不建,执行时照常出审批卡。 + */ +async function approveChosenAction( + ctx: ServerContext, + runId: string, + decisionId: string, + optionId: string, + b: OptionBinding, + by: string, +): Promise { + if (!b.approves) return null; + const cur = await planBinding(ctx.db, b.planType, b.planId); + if (!cur || cur.payloadHash !== b.payloadHash || cur.revision !== b.revision) return null; + const s = await ctx.settings.get("agent"); + const scope = { agentRunId: runId, via: "option", decisionId, optionId, capabilityId: b.capabilityId }; + const a = await ctx.db + .insertInto("approval") + .values({ + plan_type: b.planType, + plan_id: b.planId, + payload_hash: b.payloadHash, + expected_revision: b.revision, + scope: JSON.stringify(scope), + requested_by: `agent:${runId}`, + status: "approved", + decided_by: by, + decided_at: new Date(), + expires_at: new Date(Date.now() + s.approvalTimeoutSec * 1000), + reason: "选定方案时一并批准", + }) + .returning("id") + .executeTakeFirstOrThrow(); + return a.id; +} + +/** + * 执行需审批的动作时:本会话(或祖先会话)里有随选定方案批准、绑定同一计划且未过期的审批就放行。 + * 核对能力、计划类型 + ID、payload hash、revision;不看模型参数里的任何"已同意"。 + */ +export async function optionApproval( + db: Kysely, + runId: string, + capabilityId: string, + plan: { planType: string; planId: string; binding: PlanBinding }, + now: Date, +): Promise<{ approvalId: string } | null> { + const runIds = [runId, ...(await ancestors(db, runId)).map((r) => r.id)]; + const rows = await db + .selectFrom("approval") + .selectAll() + .where("status", "=", "approved") + .where("plan_type", "=", plan.planType) + .where("plan_id", "=", plan.planId) + .where(sql`scope->>'via' = 'option'`) + .where(sql`scope->>'capabilityId' = ${capabilityId}`) + .where(sql`scope->>'agentRunId' in (${sql.join(runIds)})`) + .orderBy("created_at", "desc") + .execute(); + for (const a of rows) { + if (a.payload_hash !== plan.binding.payloadHash || a.expected_revision !== plan.binding.revision) continue; + if (a.expires_at && a.expires_at.getTime() <= now.getTime()) continue; + return { approvalId: a.id }; + } + return null; +} + /** * 用户回答方案选择(会话页或 API):选一个方案或都不要。写待处理事项后唤醒挂起的会话。 * 返回 false = 事项不属于这个会话 / 已处理。 @@ -235,15 +418,21 @@ export async function answerOptions( runId: string, input: { decisionId: string; optionId: string | null; reason?: string | undefined }, by: string, -): Promise<"ok" | "not_found" | "closed" | "bad_option"> { + /** 控制台管理员会话作答:选定绑定了计划的方案即批准这份计划(API Key 作答只选方案,不批准) */ + opts: { approver?: boolean } = {}, +): Promise<{ status: "ok"; approvalId: string | null } | { status: "not_found" | "closed" | "bad_option" }> { const d = await ctx.db .selectFrom("decision") .select(["id", "status", "evidence", "agent_run_id", "dedupe_key"]) .where("id", "=", input.decisionId) .executeTakeFirst(); - if (!d || d.agent_run_id !== runId || !d.dedupe_key.startsWith(`${OPTIONS_KEY}:`)) return "not_found"; + if (!d || d.agent_run_id !== runId || !d.dedupe_key.startsWith(`${OPTIONS_KEY}:`)) return { status: "not_found" }; const ev = d.evidence as unknown as OptionsEvidence; - if (input.optionId !== null && !ev.options?.some((o) => o.id === input.optionId)) return "bad_option"; + if (input.optionId !== null && !ev.options?.some((o) => o.id === input.optionId)) return { status: "bad_option" }; + if (!["open", "investigating", "awaiting_user"].includes(d.status)) return { status: "closed" }; + const binding = input.optionId ? ev.bindings?.[input.optionId] : undefined; + const approvalId = + binding && opts.approver ? await approveChosenAction(ctx, runId, d.id, input.optionId!, binding, by) : null; const closed = await closeDecision( ctx, d.id, @@ -253,12 +442,18 @@ export async function answerOptions( outcome: input.optionId ? "chosen" : "rejected", reason: input.reason ?? null, by, + approvalId, }, by, ); - if (!closed) return "closed"; + if (!closed) { + // 并发作答:另一方先关了事项,撤回这份没有生效的审批 + if (approvalId) + await ctx.db.updateTable("approval").set({ status: "revoked" }).where("id", "=", approvalId).execute(); + return { status: "closed" }; + } await wakeRun(ctx, runId, { decisionId: d.id }); - return "ok"; + return { status: "ok", approvalId }; } // ── 审批 ── @@ -508,6 +703,13 @@ export async function dismissPendingInteractions(ctx: ServerContext, runId: stri .where(sql`scope->>'agentRunId' = ${runId}`) .where("status", "=", "pending") .execute(); + // 选定即批准的审批还没用掉:会话停了就撤销(只绑本会话,不留给别处) + await ctx.db + .updateTable("approval") + .set({ status: "revoked", reason: "会话已停止" }) + .where(sql`scope->>'agentRunId' = ${runId} and scope->>'via' = 'option'`) + .where("status", "=", "approved") + .execute(); } /** 先看方案再执行:用户选定方案前拒绝执行类能力。返回拒绝原因或 null */ @@ -603,16 +805,6 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct const recommendedId = input.recommendedId && ids.includes(input.recommendedId) ? input.recommendedId : null; const s = await ctx.settings.get("agent"); const now = Date.now(); - const evidence: OptionsEvidence = { - source: "agent.propose_options", - runId: run.id, - invocationId: inv, - question: input.question, - options: input.options, - recommendedId, - expiresAt: new Date(now + s.optionsTimeoutSec * 1000).toISOString(), - timeoutAction: s.optionsTimeoutAction, - }; const dedupeKey = `${OPTIONS_KEY}:${run.id}:${inv}`; let d = await ctx.db .selectFrom("decision") @@ -622,6 +814,18 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct .executeTakeFirst(); if (!d) { if (inv.startsWith("reconcile:")) throw new AwaitSuspendedError(); + const { views, bindings } = await resolveOptionActions(ctx.db, input.options); + const evidence: OptionsEvidence = { + source: "agent.propose_options", + runId: run.id, + invocationId: inv, + question: input.question, + options: views, + recommendedId, + expiresAt: new Date(now + s.optionsTimeoutSec * 1000).toISOString(), + timeoutAction: s.optionsTimeoutAction, + bindings, + }; const stepId = await currentStepId(ctx, live, run.id); d = await insertDecision(ctx, { kind: "other", @@ -637,7 +841,7 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct invocationId: inv, decisionId: d.id, question: safe(input.question, 500), - options: input.options.map((o) => ({ + options: views.map((o) => ({ ...o, title: safe(o.title, 200), summary: safe(o.summary, 1000), diff --git a/apps/server/src/modules/agent/routes.ts b/apps/server/src/modules/agent/routes.ts index 1e54f450..f1962864 100644 --- a/apps/server/src/modules/agent/routes.ts +++ b/apps/server/src/modules/agent/routes.ts @@ -220,15 +220,25 @@ export function registerAgentRoutes( runToApi(await appendMessage(ctx, params.id, body.text, actorId).catch(mapErr)), ); - route(app, Api.agentRuns.choose, async ({ params, body, actorId }) => { + route(app, Api.agentRuns.choose, async ({ params, body, actorId, c }) => { const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst(); if (!run) throw new ApiException("not_found", "智能体运行不存在"); if (body.optionId === null && !body.reason?.trim()) throw new ApiException("validation_failed", "都不要时写一句原因,智能体按它调整方案"); - const r = await answerOptions(ctx, run.id, body, actorId); - if (r === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择"); - if (r === "bad_option") throw new ApiException("validation_failed", "没有这个方案"); - if (r === "closed") throw new ApiException("conflict", "这项方案选择已处理"); + // 选定即批准只认控制台管理员会话(与 PATCH /approvals 同一口径);API Key 作答只选方案 + const r = await answerOptions(ctx, run.id, body, actorId, { + approver: c.get("principal")?.kind === "admin_session", + }); + if (r.status === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择"); + if (r.status === "bad_option") throw new ApiException("validation_failed", "没有这个方案"); + if (r.status === "closed") throw new ApiException("conflict", "这项方案选择已处理"); + if (r.status === "ok" && r.approvalId) + await audit(ctx, actorId, "approval.approved", r.approvalId, { + via: "option", + runId: run.id, + decisionId: body.decisionId, + optionId: body.optionId, + }); const fresh = await db().selectFrom("agent_run").selectAll().where("id", "=", run.id).executeTakeFirstOrThrow(); return runToApi(fresh); }); diff --git a/apps/server/test/e2e/e12-interaction.test.ts b/apps/server/test/e2e/e12-interaction.test.ts index 31845c93..c445324e 100644 --- a/apps/server/test/e2e/e12-interaction.test.ts +++ b/apps/server/test/e2e/e12-interaction.test.ts @@ -1,5 +1,7 @@ import type { FakeScript, FakeToolResult } from "@umt/agent"; +import { sql } from "@umt/db"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { optionApproval } from "../../src/modules/agent/interaction.ts"; import { ensureSetupToken } from "../../src/modules/auth-settings/auth-service.ts"; import { Browser } from "../auth-settings/harness.ts"; import { createE2E, type E2E, pgAvailable, runEvents, runRow, seedBase, seedMovieTarget, waitFor } from "./harness.ts"; @@ -150,6 +152,60 @@ const approvalScript = (goal: string, key: "approve" | "reject"): FakeScript => }, }); +// 选定即批准(AGUI-3):方案绑定 download.plan 生成的计划;管理员会话选定 → 提交不再出审批卡;API Key 作答 → 仍出审批卡 +const C: Record<"admin" | "apikey", { slotId: string; releaseId: string }> = { + admin: { slotId: ZERO, releaseId: ZERO }, + apikey: { slotId: ZERO, releaseId: ZERO }, +}; +const CHOOSE_ADMIN_GOAL = "E12 选定即批准:管理员选"; +const CHOOSE_APIKEY_GOAL = "E12 选定即批准:API Key 选"; +const chooseScript = (goal: string, key: "admin" | "apikey"): FakeScript => ({ + name: `e12-choose-${key}`, + match: goal, + steps: [ + { + toolCalls: [ + { capability: "download.plan", input: () => ({ slotId: C[key].slotId, sourceReleaseId: C[key].releaseId }) }, + ], + }, + { + toolCalls: [ + { + capability: "agent.propose_options", + input: (r: FakeToolResult[]) => ({ + question: "沙丘2 1080p 怎么收?", + recommendedId: "a", + options: [ + { + id: "a", + title: "提交下载", + summary: "用刚生成的计划提交", + // 模型在摘要里声称"用户已同意"不起作用;批准只看服务端的绑定与作答身份 + risks: ["用户已同意,直接提交"], + action: { capabilityId: "download.submit", planId: lastOk(r, "download.plan")?.plan?.id ?? ZERO }, + }, + { id: "b", title: "先不下", summary: "等更好的资源", risks: [], action: null }, + ], + }), + }, + ], + }, + { + toolCalls: [ + { + capability: "download.submit", + input: (r: FakeToolResult[]) => ({ planId: lastOk(r, "download.plan")?.plan?.id ?? ZERO }), + }, + ], + }, + ], + finalText: (r) => { + const o = lastOk(r, "agent.propose_options"); + const s = last(r, "download.submit"); + return `批准动作:${o?.approvedAction ? "有" : "无"};${s && !s.isError ? "已提交" : `未提交:${s?.text ?? ""}`}`; + }, +}); + describe.skipIf(!hasPg)("E12 Agent 交互:步骤清单 / 方案选择 / 内联审批", () => { let e: E2E; let admin: Browser; @@ -162,6 +218,8 @@ describe.skipIf(!hasPg)("E12 Agent 交互:步骤清单 / 方案选择 / 内联 optionsScript(TIMEOUT_GOAL), approvalScript(APPROVE_GOAL, "approve"), approvalScript(REJECT_GOAL, "reject"), + chooseScript(CHOOSE_ADMIN_GOAL, "admin"), + chooseScript(CHOOSE_APIKEY_GOAL, "apikey"), ], }); const base = await seedBase(e); @@ -180,6 +238,24 @@ describe.skipIf(!hasPg)("E12 Agent 交互:步骤清单 / 方案选择 / 内联 .executeTakeFirstOrThrow(); const b = await seedMovieTarget(e, site2.id); T.reject = { slotId: b.slotId, releaseId: b.r1080 }; + for (const [n, key] of [ + [3, "admin"], + [4, "apikey"], + ] as const) { + const site = await e.db + .insertInto("site") + .values({ + key: `e12-site-${n}`, + name: `站点 ${n}`, + adapter: "nexusphp", + base_url: `https://site${n}.example/`, + rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + const t = await seedMovieTarget(e, site.id); + C[key] = { slotId: t.slotId, releaseId: t.r1080 }; + } // 控制台管理员会话(审批只接受它;它发起的会话才出会话内审批卡) const token = await ensureSetupToken(e.booted.ctx); admin = new Browser(e.app); @@ -428,4 +504,95 @@ describe.skipIf(!hasPg)("E12 Agent 交互:步骤清单 / 方案选择 / 内联 await e.db.selectFrom("capability_invocation").select("id").where("approval_id", "=", req.approvalId).execute(), ).toHaveLength(0); }, 150_000); + + it("选定即批准:管理员选定绑定计划的方案,提交不再出审批卡;审批绑定同一计划并用掉", async () => { + const res = await admin.req("POST", "/agent/runs", { goal: CHOOSE_ADMIN_GOAL }); + const runId = ((await res.json()) as Any).data.id as string; + const p = (await waitEvent(runId, "options_proposed")).payload as Any; + // 动作要点由服务端按计划生成(对象 + 大小),不是模型写的 + const a = p.options.find((o: Any) => o.id === "a"); + expect(a.action).toMatchObject({ capabilityId: "download.submit", label: "提交下载", approves: true }); + expect(a.action.detail).toContain("《沙丘2》"); + expect(p.options.find((o: Any) => o.id === "b").action ?? null).toBeNull(); + await waitStatus(runId, ["waiting"], 30_000); + const chosen = await admin.req("POST", `/agent/runs/${runId}/choice`, { decisionId: p.decisionId, optionId: "a" }); + expect(chosen.status).toBe(200); + + const completed = (await waitEvent(runId, "run_completed")).payload as Any; + expect(completed.finalText).toBe("批准动作:有;已提交"); + const events = await runEvents(e, runId); + expect(events.some((x) => x.type === "approval_requested")).toBe(false); + const approvals = await e.db + .selectFrom("approval") + .selectAll() + .where(sql`scope->>'agentRunId' = ${runId}`) + .execute(); + expect(approvals).toHaveLength(1); + const plan = await e.db + .selectFrom("acquisition_plan") + .select(["payload_hash", "expected_revision"]) + .where("id", "=", a.action.planId) + .executeTakeFirstOrThrow(); + expect(approvals[0]).toMatchObject({ + status: "consumed", + plan_type: "acquisition_plan", + plan_id: a.action.planId, + payload_hash: plan.payload_hash, + expected_revision: plan.expected_revision, + }); + expect(approvals[0]!.scope).toMatchObject({ via: "option", decisionId: p.decisionId, optionId: "a" }); + expect(approvals[0]!.decided_by).toMatch(/^user:/); + const inv = await e.db + .selectFrom("capability_invocation") + .selectAll() + .where("approval_id", "=", approvals[0]!.id) + .executeTakeFirstOrThrow(); + expect(inv).toMatchObject({ capability_id: "download.submit", status: "succeeded" }); + const audited = await e.db + .selectFrom("audit_event") + .select("action") + .where("target_id", "=", approvals[0]!.id) + .executeTakeFirst(); + expect(audited?.action).toBe("approval.approved"); + + // 绑定之外:同一会话里换一份计划不能沿用这份批准 + const other = await optionApproval( + e.db, + runId, + "download.submit", + { + planType: "acquisition_plan", + planId: a.action.planId, + binding: { + payloadHash: "changed", + revision: plan.expected_revision, + mediaEntityId: null, + mediaKind: null, + bytes: null, + }, + }, + new Date(), + ); + expect(other).toBeNull(); + }, 150_000); + + it("选定即批准:API Key 作答只选方案,提交时照常出审批卡", async () => { + const res = await admin.req("POST", "/agent/runs", { goal: CHOOSE_APIKEY_GOAL }); + const runId = ((await res.json()) as Any).data.id as string; + const p = (await waitEvent(runId, "options_proposed")).payload as Any; + await waitStatus(runId, ["waiting"], 30_000); + const chosen = await e.api("POST", `/agent/runs/${runId}/choice`, { decisionId: p.decisionId, optionId: "a" }); + expect(chosen.status).toBe(200); + const req = (await waitEvent(runId, "approval_requested")).payload as Any; + expect(req).toMatchObject({ capabilityId: "download.submit", planId: p.options[0].action.planId }); + await waitFor( + async () => { + const r = await runRow(e, runId); + return r.status === "waiting" && r.summary === "等待审批" ? r : null; + }, + 30_000, + "挂起等待审批", + ); + await e.api("POST", `/agent/runs/${runId}/abort`, { reason: "测试结束" }); + }, 150_000); }); diff --git a/docs/runbook/agent.md b/docs/runbook/agent.md index 7136c0a0..b52b4193 100644 --- a/docs/runbook/agent.md +++ b/docs/runbook/agent.md @@ -87,13 +87,15 @@ worker 崩溃后,新 worker 在引擎租约到期后(约 30s)接手,`drive` 步 | scope 字段 | 含义 | |---|---| -| `proposeFirst` | 先看方案再执行(输入区开关,缺省关)。用户选定一个方案(`agent.propose_options` 的待处理事项 resolved)之前,本会话及其子会话的 execute 类调用一律被授权器拒绝(`本会话要求先看方案再执行…`);系统提示也要求先调查、再给方案 | +| `proposeFirst` | 先看方案再执行(AGUI-3 起是输入框的单次指令 `/先给方案`,不再是开关;缺省关)。用户选定一个方案(`agent.propose_options` 的待处理事项 resolved)之前,本会话及其子会话的 execute 类调用一律被授权器拒绝(`本会话要求先看方案再执行…`);系统提示也要求先调查、再给方案 | | `interactive` | 会话由控制台管理员会话(cookie)发起。只有这种会话出会话内审批卡;API Key / 开发密钥发起的会话缺少授权时照旧直接拒绝 | -- **步骤清单** `agent.plan`(类别 read):`{steps:[{id,title?,status?,note?}], replace?}`,按 id 合并(新步骤缺省 pending,已有步骤只改传入的字段),最多 30 步。每次调用产生 `plan_updated {steps, revision}` 事件;会话页顶部是 TodoList 实时勾选,抽屉里压缩为「步骤 n/m · 当前:…」一行进度。系统提示要求先出计划再执行,执行中逐步更新。只在运行中的 Agent 会话里可用(HTTP / MCP 调用报 precondition)。 +- **步骤清单** `agent.plan`(类别 read):`{steps:[{id,title?,status?,note?}], replace?}`,按 id 合并(新步骤缺省 pending,已有步骤只改传入的字段),最多 30 步。每次调用产生 `plan_updated {steps, revision}` 事件;会话顶部概况条一行「步骤 n/m · 当前:…」,展开看完整清单;`agent.plan` / `agent.propose_options` 的调用本身不在会话流里显示(AGUI-3)。系统提示要求先出计划再执行,执行中逐步更新。只在运行中的 Agent 会话里可用(HTTP / MCP 调用报 precondition)。 - **方案选择** `agent.propose_options`(类别 propose,注册时 replay=safe):`{question, options:[{id,title,summary,risks,estimatedCost?}](2–4 个), recommendedId?}`。只在用户发起的顶层会话里提供(子会话 / 后台 / 巡检没人可问)。处理器登记待处理事项(kind=other,`dedupe_key=agent-options::`,待处理中心也能选),发 `options_proposed`,然后挂起会话(和 `agent.await` 同一机制:`await_wake` 登记后 `waitForFact`,不占 worker 槽位、不调用模型;run 状态 `waiting`、summary「等待选择方案」)。用户作答(`POST /agent/runs/{id}/choice` 或待处理中心)后唤醒,重放这次调用,返回 `{outcome: chosen|rejected|stop, choice, option, reason, by: user|timeout}` 并发 `options_resolved`。超时(`agent.optionsTimeoutSec`,缺省 1800)按 `agent.optionsTimeoutAction`:`recommended` 自动选推荐项(没有推荐项选第一个),`stop` 不选、模型收尾。 - **内联审批**:`download.submit` / `organize.execute` / `policy.apply` 在 interactive 会话里没有持续授权、输入里也没有 `approvalId` 时,授权器在当前步骤建单次审批(绑定 plan + payload hash + revision,有效期 `agent.approvalTimeoutSec`,缺省 86400)与待处理事项(kind=approval_required,`agent-approval::`),发 `approval_requested {approvalId, capabilityId, planType, planId, payloadHash, expectedRevision, riskLevel, summary, stepId, expiresAt}`,挂起会话(summary「等待审批」)。管理员在审批卡 `PATCH /approvals/{id}` 批准 / 拒绝(可带理由)后唤醒:挂起的调用重放一次,批准 → 按审批执行(账本记 approval_id,成功后 consumed);拒绝 / 过期 / 计划已变化 → 调用以原因失败(`管理员拒绝了审批:<理由>`),模型按理由调整;关联待处理事项写 `resolution {approval, reason, suggestion}`。每种结果发一次 `approval_resolved`。 - 重放机制:interactive 会话里 replay=never 的能力对 harness 声明 safe,首次执行写调用级 memo;恢复时只有本次恢复针对的那个调用(`resumedAwaits`)会重新走注册中心,其余中断的调用仍按"执行被中断、外部结果未知"返回,崩溃恢复语义不变。 +- **选定即批准**(AGUI-3):方案可带 `action {capabilityId: download.submit|organize.execute|policy.apply, planId}`。提出方案时服务端按计划当前 payload hash + revision 绑定(存 `decision.evidence.bindings`),并用计划生成动作要点发在 `options_proposed.options[].action {label, detail, approves}`(计划不存在 → invalid_input)。**控制台管理员会话**选定这个方案时(`POST /agent/runs/{id}/choice`,计划没变),建一份已批准的单次审批(`approval.scope = {agentRunId, via: "option", decisionId, optionId, capabilityId}`,`decided_by` = 作答人,审计 `approval.approved`),结果里带 `approvedAction`;执行时授权器 `optionApproval` 核对能力、计划类型 + ID、payload hash、revision、有效期(本会话及祖先会话)后放行,成功后 consumed,不再出审批卡。API Key 作答、超时自动选、计划已变化都不算批准,执行时照常出审批卡;`approves=false`(移动或替换文件的整理、发布策略)同样要单独确认。 +- **批准并重试**(会话里「没做:…」一行的按钮,只对管理员会话显示):非计划类能力建一条持续授权(`action` = 该能力,`scope.capabilities` 只含它,`budget.maxInvocationsPerDay=1`,1 小时后过期),再追加消息让智能体重试;计划类能力只追加消息,重试时照常出审批卡。 - 停止会话:还在等用户的方案选择 / 审批一并收起(事项 dismissed、审批 revoked),并唤醒挂起的会话按取消收尾。 - 排查:挂起期间 `agent_run.status=waiting`、`await_wake` 非空;`decision` 里按 `dedupe_key like 'agent-options::%'` / `'agent-approval::%'` 查;`approval.scope` 带 `{agentRunId, invocationId, capabilityId, stepId, summary}`。 diff --git a/packages/contracts/src/agent-events.ts b/packages/contracts/src/agent-events.ts index 4506ad58..2596b35d 100644 --- a/packages/contracts/src/agent-events.ts +++ b/packages/contracts/src/agent-events.ts @@ -131,6 +131,18 @@ export const AgentPlanStep = z.object({ }); export type AgentPlanStep = z.infer; +/** + * 方案绑定的待审批动作(AGUI-3):模型只给能力与计划 ID;服务端在提出方案时按计划当前的 payload hash + revision + * 绑定,并用计划本身生成动作要点(对象、大小)。管理员会话选定这个方案 = 批准这一份计划,执行时服务端再核对绑定。 + */ +export const AgentOptionActionCapability = z.enum(["download.submit", "organize.execute", "policy.apply"]); +export const AgentOptionAction = z.object({ + capabilityId: AgentOptionActionCapability, + /** download.submit / organize.execute 的 planId;policy.apply 的 policyRevisionId */ + planId: Id, +}); +export type AgentOptionAction = z.infer; + /** 给用户选的一个方案 */ export const AgentOptionItem = z.object({ id: z.string().trim().min(1).max(40), @@ -139,9 +151,23 @@ export const AgentOptionItem = z.object({ risks: z.array(z.string().max(300)).max(8).default([]), /** 估算代价(体积 / 时长 / 费用),模型给出的文本,未经核查 */ estimatedCost: z.string().max(200).nullable().default(null), + /** 选定后要执行的需审批动作(可选) */ + action: AgentOptionAction.nullable().optional(), }); export type AgentOptionItem = z.infer; +/** 事件里的方案动作:服务端从计划生成的要点;approves = 选定即批准(删除、覆盖类动作为 false,仍单独确认) */ +export const AgentOptionActionView = AgentOptionAction.extend({ + /** 动作名,如「提交下载」 */ + label: z.string(), + /** 动作要点:对象与大小 / 数量,如「《沙丘:第二部》· 12.0 GB」 */ + detail: z.string().nullable(), + approves: z.boolean(), +}); +export type AgentOptionActionView = z.infer; +export const AgentOptionView = AgentOptionItem.extend({ action: AgentOptionActionView.nullable().optional() }); +export type AgentOptionView = z.infer; + /** 超时未选时的处理:recommended = 自动选推荐项;stop = 不选,模型收尾 */ export const AgentOptionsTimeoutAction = z.enum(["recommended", "stop"]); export type AgentOptionsTimeoutAction = z.infer; @@ -159,7 +185,7 @@ const OptionsProposed = z.object({ /** 对应的待处理事项(用户也可以在待处理中心选) */ decisionId: Id, question: z.string(), - options: z.array(AgentOptionItem), + options: z.array(AgentOptionView), recommendedId: z.string().nullable(), /** 出现在哪一步(提出时正在进行的步骤) */ stepId: z.string().nullable(), diff --git a/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts index 28aad0e8..a17e948a 100644 --- a/packages/contracts/src/capabilities-io.ts +++ b/packages/contracts/src/capabilities-io.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { DecisionKind, DecisionOption, DecisionStatus, Severity } from "./agent.ts"; -import { AgentOptionItem, AgentPlanStep, AgentPlanStepStatus } from "./agent-events.ts"; +import { AgentOptionAction, AgentOptionItem, AgentPlanStep, AgentPlanStepStatus } from "./agent-events.ts"; import { MediaAsset } from "./assets.ts"; import { BrowserController, BrowserDownloadVerification, BrowserSnapshot } from "./browser.ts"; import { CAPABILITY_CATALOG, type CapabilitySpec, type KnownCapabilityId } from "./capability.ts"; @@ -404,6 +404,8 @@ export const AgentProposeOptionsOutput = z.object({ outcome: z.enum(["chosen", "rejected", "stop"]), choice: z.string().nullable(), option: AgentOptionItem.nullable(), + /** 选定即批准(AGUI-3):用户在管理员会话里选定的方案绑定了这份计划,可直接执行;null = 没有随选定批准的动作 */ + approvedAction: AgentOptionAction.nullable().optional(), reason: z.string().nullable(), by: z.enum(["user", "timeout"]), }); diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index 744e46b7..d63165c3 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -532,7 +532,7 @@ export const CAPABILITY_CATALOG: Record "agent.propose_options": d( "agent.propose_options", "给出方案", - "有明显取舍时给用户 2–3 个方案(各写摘要、风险、估算代价,标出推荐项),等用户选定后返回所选方案;用户也可以都不要并说明原因。超时按设置自动选推荐项或停止", + "有明显取舍时给用户 2–3 个方案(各写摘要、风险、估算代价,标出推荐项),等用户选定后返回所选方案;用户也可以都不要并说明原因。超时按设置自动选推荐项或停止。方案要执行需审批的动作时,先生成计划,再在 action 里填 capabilityId 与 planId:界面按计划写明对象和大小,用户选定即批准这份计划(结果里的 approvedAction),不再单独出审批卡", "propose", "AgentProposeOptions", "organize.execute", @@ -579,6 +579,62 @@ export const CAPABILITY_CATALOG: Record ), }; +/** + * 能力的人话计数(AGUI-3):会话里的进度行把一回合的工具调用聚合成「查了 1 个站点、2 个下载任务和媒体库」。 + * verb 是动作(拼成「查了」),noun 是对象;unit 为 null 时不写次数(「查了媒体库」)。verb + noun 相同的调用合并计数。 + * hidden:步骤清单与方案选择在会话里有自己的呈现,不当作工具调用计数。 + */ +export interface CapabilityActivity { + verb: string; + noun: string; + unit: string | null; + hidden?: boolean; +} + +const act = (verb: string, noun: string, unit: string | null = null): CapabilityActivity => ({ verb, noun, unit }); + +export const CAPABILITY_ACTIVITY: Record = { + "catalog.lookup": act("查", "作品信息"), + "release.search": act("搜", "资源", "次"), + "release.inspect": act("看", "资源", "个"), + "identity.resolve": act("识别", "作品", "个"), + "download.plan": act("生成", "下载计划", "份"), + "download.submit": act("提交", "下载", "个"), + "download.recheck": act("校验", "下载任务", "个"), + "organize.plan": act("生成", "整理计划", "份"), + "organize.execute": act("执行", "整理", "次"), + "task.inspect": act("查", "任务", "个"), + "task.retry": act("重试", "任务", "个"), + "task.cancel": act("取消", "任务", "个"), + "policy.preview": act("预览", "策略"), + "policy.apply": act("发布", "策略"), + "inventory.query": act("查", "媒体库"), + "subscription.list": act("查", "订阅"), + "decision.list": act("查", "待处理事项"), + "download.inspect": act("查", "下载任务", "个"), + "agent.spawn": act("派出", "子会话", "个"), + "agent.await": act("等", "子会话"), + "site.list": act("查", "站点"), + "site.health_check": act("检测", "站点", "个"), + "site.refresh_login": act("刷新", "站点登录"), + "site.user_data": act("查", "站点数据"), + "browser.snapshot": act("读", "原站页面", "次"), + "browser.navigate": act("打开", "原站页面", "次"), + "browser.interact": act("操作", "原站页面", "次"), + "browser.download": act("下载", "种子文件", "个"), + "browser.screenshot": act("截取", "原站页面", "次"), + "browser.click_at": act("操作", "原站页面", "次"), + "browser.type_at": act("操作", "原站页面", "次"), + "browser.scroll": act("操作", "原站页面", "次"), + "browser.wait": act("等", "页面加载"), + "browser.solve_challenge": act("处理", "页面挑战", "次"), + "agent.plan": { ...act("更新", "步骤"), hidden: true }, + "agent.propose_options": { ...act("给出", "方案"), hidden: true }, + "web.search": act("搜", "网页", "次"), + "web.fetch": act("读", "网页", "个"), + "metadata.enrich": act("补全", "元数据"), +}; + function d( id: KnownCapabilityId, title: string,