feat(agent): automation-first sessions with step plan, option choice and inline approval

- agent.plan emits plan_updated; system prompt asks for a plan first and updates while executing
- agent.propose_options registers a decision and suspends the run (waitForFact, no worker slot);
  the answer or timeout (recommended/stop) resumes it as the tool result
- console-session runs get inline approvals for plan-bound capabilities: approve replays the call
  with the approval, reject fails it with the reason and records it on the decision
- proposeFirst blocks execute capabilities until an option is chosen; E12 covers all paths

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 21:07:01 +08:00
1 parent 74c2ed45aa
commit cf2c4ff0e6
14 files changed
+1360 -26

No files matched your search

+31 -3
View File
@@ -26,6 +26,7 @@ import { type AgentRunRow, type DownloaderInstanceRow, sql } from "@umt/db";
import { type RunUpstream, resolveRunUpstream } from "../pi/index.ts";
import type { ServerContext } from "../types.ts";
import { DEMO_SCRIPTS, parseFakeScripts } from "./fake-scripts.ts";
import { canAskUser, canRequestApproval, sessionOptions } from "./interaction.ts";
import { buildGoalReport } from "./report.ts";
import { type AwaitSuspension, attachLiveRun, type LiveRun, subagentSettings, wakeParent } from "./subagents.ts";
@@ -72,9 +73,12 @@ export interface RunScope {
capabilities: string[];
allowedKinds: CapabilityKind[] | null;
decisionId?: string | null;
/** 会话选项(见 interaction.ts RunSessionOptions) */
interactive?: boolean;
proposeFirst?: boolean;
}
/** 按模式确定能力白名单与类别上限;plan_only 下给了 execute 类能力直接报错 */
/** 按内部能力上限确定白名单与类别上限;plan_only(后台只读调查)下给了 execute 类能力直接报错 */
export function scopeFor(mode: "execute" | "plan_only", allowlist?: readonly string[]): RunScope {
const all = Object.values(CAPABILITY_CATALOG);
if (mode === "plan_only") {
@@ -251,7 +255,7 @@ function systemPromptFor(
`可用能力:${scope.capabilities.join(", ")}`,
"有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒时说明缺少哪项授权。",
];
if (run.mode === "plan_only") constraints.push("本次只给方案:只调查与规划,不提交下载、不改文件、不发布策略。");
if (run.mode === "plan_only") constraints.push("本次只读:只调查与规划,不提交下载、不改文件、不发布策略。");
if (sub.canSpawn)
constraints.push(
`可以用 agent.spawn 把彼此独立的有界子目标交给子会话(同时最多 ${sub.maxConcurrent} 个,派生层数上限 ${sub.maxDepth});` +
@@ -265,10 +269,22 @@ function systemPromptFor(
);
else if (run.trigger === "event")
constraints.push("本任务由后台事件触发,只做只读调查:收集证据,给出结论与建议,不要反复重试同一操作。");
const opts = sessionOptions(run);
return buildSystemPrompt({
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化 Agent",
goal: run.goal,
constraints,
// 自动化优先:用户会话默认执行,先出步骤清单;有取舍时给方案;"先看方案再执行"是会话选项
...(canAskUser(run)
? {
interaction: {
plan: scope.capabilities.includes("agent.plan"),
options: scope.capabilities.includes("agent.propose_options"),
proposeFirst: opts.proposeFirst === true,
approvals: canRequestApproval(run),
},
}
: {}),
});
}
@@ -389,9 +405,20 @@ export async function drive(
.select("id")
.where("parent_run_id", "=", runId)
.executeTakeFirst());
// 方案选择只在用户发起的顶层会话里提供(子会话 / 后台会话没有人可问)
const askUser = canAskUser(run);
const capabilities = (scope.capabilities?.length ? scope.capabilities : Object.keys(CAPABILITY_CATALOG)).filter(
(id) => (id === "agent.spawn" ? canSpawn : id === "agent.await" ? canSpawn || hasChildren : true),
(id) =>
id === "agent.spawn"
? canSpawn
: id === "agent.await"
? canSpawn || hasChildren
: id === "agent.propose_options"
? askUser
: true,
);
// 会话内审批:挂起的审批调用恢复后要重放(只放行本次恢复针对的那个调用)
const resumed = new Set(opts.resumedAwaits ?? []);
let external: LiveRun | null = null;
let handle: AgentRunHandle;
try {
@@ -405,6 +432,7 @@ export async function drive(
actor,
firstSeq: maxSeq?.m === null || maxSeq?.m === undefined ? 0 : Number(maxSeq.m) + 1,
externalUsage: () => external?.externalUsage() ?? { toolCalls: 0, totalTokens: 0, costUsd: 0 },
...(canRequestApproval(run) ? { replayGate: (_cap: string, inv: string) => resumed.has(inv) } : {}),
});
} catch (err) {
return { status: "failed", reason: "error", budget: null, message: (err as Error).message, finalText: "" };