Merge branch 'agui3'

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-29 22:43:07 +08:00
commit 0055db6cf9
35 files changed
+3034 -1912

No files matched your search

+10 -3
View File
@@ -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<string, readonly string[]> = {
const CHALLENGE_STEP_CAPS = new Set(["browser.click_at", "browser.type_at", "browser.scroll"]);
/** 单次审批绑定的计划类型与输入里的计划 ID 字段 */
const APPROVAL_PLAN: Record<string, { planType: string; field: string }> = {
export const APPROVAL_PLAN: Record<string, { planType: string; field: string }> = {
"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());
@@ -292,6 +292,7 @@ async function runChild(
const systemPrompt = buildSystemPrompt({
role: "网页挑战处理员",
goal,
audience: "agent",
constraints: [
`只能用这些工具:${VISION_CAPABILITIES.join(", ")};每个工具都要传 sessionId = ${input.sessionId}。`,
"先 browser.screenshot 看页面;坐标一律以最近一张截图的像素为准,点击 / 输入 / 滚动之后会返回新截图,按新截图继续。",
+224 -20
View File
@@ -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<string, OptionBinding>;
}
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<DB>, planType: string, planId: string): Promise<string | null> {
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<DB>, capabilityId: string, planId: string): Promise<boolean> {
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<DB>,
options: AgentOptionItem[],
): Promise<{ views: AgentOptionView[]; bindings: Record<string, OptionBinding> }> {
const views: AgentOptionView[] = [];
const bindings: Record<string, OptionBinding> = {};
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<string | null> {
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<DB>,
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<boolean>`scope->>'via' = 'option'`)
.where(sql<boolean>`scope->>'capabilityId' = ${capabilityId}`)
.where(sql<boolean>`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<boolean>`scope->>'agentRunId' = ${runId}`)
.where("status", "=", "pending")
.execute();
// 选定即批准的审批还没用掉:会话停了就撤销(只绑本会话,不留给别处)
await ctx.db
.updateTable("approval")
.set({ status: "revoked", reason: "会话已停止" })
.where(sql<boolean>`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),
+15 -5
View File
@@ -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);
});
+2
View File
@@ -291,6 +291,8 @@ function systemPromptFor(
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化智能体",
goal: run.goal,
constraints,
// 子会话汇报给父会话汇总(可带对象 ID);其余会话按「汇报格式」写给用户
audience: run.parent_run_id ? "agent" : "user",
// 自动化优先:用户会话默认执行,先出步骤清单;有取舍时给方案;"先看方案再执行"是会话选项
...(canAskUser(run)
? {
+26 -23
View File
@@ -315,18 +315,20 @@ export async function finalize(
content_hash: sha(JSON.stringify({ runId, report })),
})
.execute();
await ctx.db
.updateTable("agent_run")
.set({
status,
summary: summary.slice(0, 4000),
...(status === "waiting" ? {} : { finished_at: new Date() }),
...(res.status === "failed"
? { error: JSON.stringify({ reason: res.reason, budget: res.budget, message: res.message }) }
: {}),
})
.where("id", "=", runId)
.execute();
if (status === "budget_exceeded") {
// 同根因(同一待处理事项 / 同一定时巡检 / 同一目标模板)超预算合并到已有事项,不重复新建。
// 先写事项再写终态:观察者看到 budget_exceeded 时,事项已指向这次运行(之前顺序相反,
// 负载下读到终态时事项还指向上一次运行,e15 偶发失败)
await writeDecision(ctx, {
kind: "other",
title: "智能体运行超出预算",
summary: res.message ?? "超出预算",
subjectId: runId,
dedupeKey: await budgetDedupeKey(ctx, run),
evidence: { runId, runIds: [runId], budget: res.budget, message: res.message, goal: run.goal },
});
}
// 任务行同样先于会话终态写:看到会话终态时任务页已是同一结论
if (run.task_id) {
const taskStatus =
status === "cancelled"
@@ -349,17 +351,18 @@ export async function finalize(
.where("id", "=", run.task_id)
.execute();
}
if (status === "budget_exceeded") {
// 同根因(同一待处理事项 / 同一定时巡检 / 同一目标模板)超预算合并到已有事项,不重复新建
await writeDecision(ctx, {
kind: "other",
title: "智能体运行超出预算",
summary: res.message ?? "超出预算",
subjectId: runId,
dedupeKey: await budgetDedupeKey(ctx, run),
evidence: { runId, runIds: [runId], budget: res.budget, message: res.message, goal: run.goal },
});
}
await ctx.db
.updateTable("agent_run")
.set({
status,
summary: summary.slice(0, 4000),
...(status === "waiting" ? {} : { finished_at: new Date() }),
...(res.status === "failed"
? { error: JSON.stringify({ reason: res.reason, budget: res.budget, message: res.message }) }
: {}),
})
.where("id", "=", runId)
.execute();
// 后台调查:结论回写待处理事项,交还人工(仍可被新证据重新唤醒)
if (run.dedupe_key?.startsWith("investigate:")) {
const decisionId = run.dedupe_key.slice("investigate:".length);