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

+26 -4
View File
@@ -14,6 +14,7 @@ import type { ModuleApp, ServerContext } from "../types.ts";
import { planBinding } from "./authorize.ts";
import { cancelTask, retryTask, taskToApi } from "./capabilities.ts";
import { route } from "./http.ts";
import { afterApprovalDecided, answerOptions } from "./interaction.ts";
import { type InvokeLimiter, sharedInvokeLimiter, UpstreamBusyError, UpstreamTimeoutError } from "./invoke-limits.ts";
import { AgentRunError, abortRun, appendMessage, startRun } from "./service.ts";
import { descendants, descendantUsage } from "./subagents.ts";
@@ -56,7 +57,6 @@ export function runToApi(r: AgentRunRow) {
startedAt: toIso(r.started_at),
finishedAt: toIso(r.finished_at),
createdBy: r.created_by,
mode: r.mode,
dedupeKey: r.dedupe_key,
depth: r.depth,
spawnReason: r.spawn_reason,
@@ -73,7 +73,6 @@ export function runToNode(r: AgentRunRow) {
parentRunId: r.parent_run_id,
goal: r.goal,
status: r.status,
mode: r.mode,
depth: r.depth,
spawnReason: r.spawn_reason,
toolCallCount: r.tool_call_count,
@@ -196,13 +195,21 @@ export function registerAgentRoutes(
route(
app,
Api.agentRuns.create,
async ({ body, actorId }) => {
async ({ body, actorId, c }) => {
// 运行模式已取消:用户会话一律按授权执行;旧客户端传 mode=plan_only 按"先看方案再执行"处理
const proposeFirst = body.proposeFirst || body.mode === "plan_only";
if (body.mode) c.header("Deprecation", "true");
const r = await startRun(ctx, {
goal: body.goal,
mode: body.mode,
mode: "execute",
capabilityAllowlist: body.capabilityAllowlist,
trigger: "user",
createdBy: actorId,
session: {
// 控制台管理员会话发起:需审批的动作在步骤处出审批卡(只有管理员会话能批准)
interactive: c.get("principal")?.kind === "admin_session",
proposeFirst,
},
}).catch(mapErr);
return runToApi(r.run);
},
@@ -213,6 +220,19 @@ export function registerAgentRoutes(
runToApi(await appendMessage(ctx, params.id, body.text, actorId).catch(mapErr)),
);
route(app, Api.agentRuns.choose, async ({ params, body, actorId }) => {
const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!run) throw new ApiException("not_found", "Agent 运行不存在");
if (body.optionId === null && !body.reason?.trim())
throw new ApiException("validation_failed", "都不要时写一句原因,Agent 按它调整方案");
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", "这项方案选择已处理");
const fresh = await db().selectFrom("agent_run").selectAll().where("id", "=", run.id).executeTakeFirstOrThrow();
return runToApi(fresh);
});
route(app, Api.agentRuns.abort, async ({ params, body, actorId }) =>
runToApi(await abortRun(ctx, params.id, body.reason, actorId).catch(mapErr)),
);
@@ -353,6 +373,8 @@ export function registerAgentRoutes(
.executeTakeFirstOrThrow();
});
await audit(ctx, actorId, `approval.${body.status}`, r.id, { planType: r.plan_type, planId: r.plan_id });
// 会话内审批:写关联待处理事项的结果(拒绝带理由),唤醒挂起在这份审批上的会话
await afterApprovalDecided(ctx, r, actorId);
return camel(r);
});