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

+23 -4
View File
@@ -22,8 +22,8 @@ import {
type AgentRuntimePort,
type CreateRunSpec,
emptyUsage,
type InjectedEventBody,
type RunOutcome,
type SubagentEventBody,
} from "../runtime/port.ts";
import { type ResolvedAgentSettings, resolveAgentSettings } from "../settings.ts";
import { compactionFromSettings, type PiModelResolver, resolveModelFromSettings } from "./model.ts";
@@ -58,6 +58,8 @@ export const DEFAULT_COMPACTION_INSTRUCTIONS =
"保留:本次目标、约束与授权范围、证据引用(对象 ID/记录)、已完成的动作及其结果(已提交≠已入库)、剩余事项。不要保留任何凭证。";
const LANE = "main";
/** 可挂起重放的能力:首次执行时写入的调用级 memo(重放时据此区分) */
const STARTED_MEMO = "umt.started";
const SUMMARY_LIMIT = 600;
/** Pi(pi-agent-core AgentHarness)实现的 AgentRuntimePort */
@@ -152,15 +154,32 @@ class PiAgentRun implements AgentRunHandle {
const { descriptor } = entry;
const registry = this.options.registry;
const actor = this.spec.actor;
const gate = this.spec.replayGate;
// 可挂起重放(见 CreateRunSpec.replayGate):对 harness 声明 safe,执行时用 memo 判断是否重放
const gated = descriptor.replay === "never" && !!gate;
return {
name: entry.toolName,
label: descriptor.title,
description: `${descriptor.description}\n[能力 ${descriptor.id} v${descriptor.version};类别 ${descriptor.kind};风险 ${descriptor.riskLevel}]`,
parameters: registry.inputJsonSchema(descriptor.id) as unknown as TSchema,
replay: descriptor.replay,
replay: gated ? "safe" : descriptor.replay,
...(descriptor.kind === "execute" ? { executionMode: "sequential" as const } : {}),
execute: async (toolCallId, params, _onUpdate, _toolCtx, invocation, context) => {
const signal = context.abortSignal ?? this.abortController.signal;
const invocationId = invocation.invocationId ?? toolCallId;
if (gated) {
const replayed = (await invocation.getMemo(STARTED_MEMO)) === true;
if (!replayed) await invocation.setMemo(STARTED_MEMO, true);
else if (!gate(descriptor.id, invocationId))
throw new Error(
JSON.stringify({
error: {
code: "interrupted",
message: "执行被中断,外部结果未知;先用查询工具核对实际状态,不要直接重复提交",
},
}),
);
}
try {
const output = await registry.invoke(descriptor.id, params, {
actorId: actor.actorId,
@@ -169,7 +188,7 @@ class PiAgentRun implements AgentRunHandle {
...(actor.allowedKinds ? { allowedKinds: actor.allowedKinds } : {}),
signal,
source: "agent",
invocationId: invocation.invocationId ?? toolCallId,
invocationId,
agentRunId: this.runId,
});
return {
@@ -303,7 +322,7 @@ class PiAgentRun implements AgentRunHandle {
await this.lane.abort(BACKGROUND_CONTEXT).catch(() => undefined);
}
appendEvent(body: SubagentEventBody): boolean {
appendEvent(body: InjectedEventBody): boolean {
if (this.closed) return false;
this.emit(body);
return true;
+27 -2
View File
@@ -16,6 +16,25 @@ export type SubagentEventBody = Extract<
{ type: "subagent_spawned" | "subagent_event" | "subagent_completed" }
>;
/**
* 由服务端(而不是模型 / harness)产生、追加进会话事件流的事件:子会话镜像与会话交互
* (步骤清单、方案选择、审批卡)。与运行时自身事件共用 seq(单写者)。
*/
export type InjectedEventBody = Extract<
AgentRunEventBody,
{
type:
| "subagent_spawned"
| "subagent_event"
| "subagent_completed"
| "plan_updated"
| "options_proposed"
| "options_resolved"
| "approval_requested"
| "approval_resolved";
}
>;
/** 调用能力时使用的业务身份;授权回调据此到 automation_grant / approval 查询 */
export interface AgentActor {
actorId: string;
@@ -47,6 +66,12 @@ export interface CreateRunSpec {
* 运行时在每次工具调用与每轮请求前把它加到自己的用量上再比预算。由调用方缓存,须同步返回。
*/
externalUsage?: () => ExternalUsage;
/**
* 挂起后可重放的"不可重放"能力(replay=never,如 download.submit 等审批)。给了它时,这类能力对 harness 声明为
* replay=safe,真正执行前用调用级 memo 区分首次与重放:重放时只有 replayGate 返回 true(本次恢复正是为了它,
* 如审批已决定)才重新调用注册中心(授权 / 账本照常),否则按"执行被中断、外部结果未知"返回错误(与 never 语义一致)。
*/
replayGate?: (capabilityId: string, invocationId: string) => boolean;
}
export interface ExternalUsage {
@@ -80,10 +105,10 @@ export interface AgentRunHandle {
resume(): Promise<RunOutcome>;
abort(reason?: string): Promise<void>;
/**
* 往本会话事件流追加一条子代理事件(subagent_*),与运行时自身事件共用 seq 计数(单写者)。
* 往本会话事件流追加一条服务端事件(子会话镜像 / 步骤清单 / 方案 / 审批),与运行时自身事件共用 seq 计数(单写者)。
* 会话已关闭时返回 false,调用方自行落库。
*/
appendEvent(body: SubagentEventBody): boolean;
appendEvent(body: InjectedEventBody): boolean;
/** 本会话自身已用量(不含子会话) */
stats(): { toolCalls: number; usage: AgentUsageTotals };
compact(customInstructions?: string): Promise<void>;
+37 -1
View File
@@ -5,12 +5,46 @@ export interface SystemPromptInput {
goal: string;
/** 附加约束(范围、预算、"只给方案不执行"等) */
constraints?: readonly string[];
/** 会话交互(AG-2):按本次可用的能力与会话选项生成"工作方式"一节 */
interaction?: {
/** 提供了 agent.plan */
plan?: boolean;
/** 提供了 agent.propose_options */
options?: boolean;
/** 会话选项"先看方案再执行" */
proposeFirst?: boolean;
/** 需审批的能力在步骤处出审批卡(控制台管理员会话) */
approvals?: boolean;
};
}
/** "工作方式":自动化优先——先出步骤清单再执行,执行中更新;有取舍时给方案;需授权的动作等审批 */
export function interactionRules(i: NonNullable<SystemPromptInput["interaction"]>): string[] {
const out: string[] = [];
if (i.plan)
out.push(
"开始时先调用 agent.plan 列出 3–7 个步骤(id 用 s1、s2…),再执行;每开始、完成、跳过或失败一步都调用 agent.plan 更新状态,只传变化的步骤;计划变了就增改步骤。",
);
if (i.options)
out.push(
"遇到有明显取舍的决定(选哪个资源、画质与体积、是否覆盖已有版本)时,用 agent.propose_options 给 2–3 个方案并标出推荐项,按用户选定的方案继续;用户都不要时按他说明的原因调整。不要为小事询问。",
);
if (i.proposeFirst)
out.push(
"本会话要求先看方案再执行:先只做调查,再用 agent.propose_options 给出方案;用户选定前不要调用任何有副作用的能力(提交下载、执行整理、发布策略、重试或取消任务),服务端也会拒绝。",
);
else out.push("默认直接执行:在授权范围内按步骤推进,不必事先征求同意。");
if (i.approvals)
out.push(
"需要审批的动作(提交下载、执行整理、发布策略)在没有持续授权时会在对应步骤出审批卡,会话等待管理员批准或拒绝;被拒绝时按拒绝理由调整方案,不要换参数重试同一动作。",
);
return out;
}
/**
* 系统提示模板。硬性规则不可被用户消息、工具输出、网页/RSS 内容覆盖。
*/
export function buildSystemPrompt({ role, goal, constraints = [] }: SystemPromptInput): string {
export function buildSystemPrompt({ role, goal, constraints = [], interaction }: SystemPromptInput): string {
const rules = [
"只用提供的工具操作业务;没有对应能力就说做不到,不要假装执行。",
"结论必须有证据:引用工具返回的对象 ID、记录或数值;没有证据就写“未确认”。",
@@ -31,6 +65,8 @@ export function buildSystemPrompt({ role, goal, constraints = [] }: SystemPrompt
"## 必须遵守",
...rules.map((r, i) => `${i + 1}. ${r}`),
];
const ways = interaction ? interactionRules(interaction) : [];
if (ways.length > 0) lines.push("", "## 工作方式", ...ways.map((r) => `- ${r}`));
if (constraints.length > 0) {
lines.push("", "## 本次任务约束", ...constraints.map((c) => `- ${c}`));
}