Agent Scope Java 2.x 系列【34】Harness:计划模式入门案例

文章目录

  • [1. 概述](#1. 概述)
    • [1.1 需求背景](#1.1 需求背景)
    • [1.2 核心功能](#1.2 核心功能)
    • [1.3 工作流程](#1.3 工作流程)
    • [1.4 本文 Demo](#1.4 本文 Demo)
  • [2. 环境搭建](#2. 环境搭建)
    • [2.1 引入依赖](#2.1 引入依赖)
    • [2.2 智能体配置](#2.2 智能体配置)
      • [2.2.1 模型配置](#2.2.1 模型配置)
      • [2.2.2 HarnessAgent 配置](#2.2.2 HarnessAgent 配置)
      • [2.2.3 AGENT.md 配置](#2.2.3 AGENT.md 配置)
      • [2.2.4 application.yml 配置文件](#2.2.4 application.yml 配置文件)
  • [3. 实现对话接口](#3. 实现对话接口)
    • [3.1 SSE 对话](#3.1 SSE 对话)
      • [服务端:把事件流写进 SseEmitter](#服务端:把事件流写进 SseEmitter)
      • [前端:用 fetch 读取 POST 形式的 SSE](#前端:用 fetch 读取 POST 形式的 SSE)
    • [3.2 手动开启计划模式](#3.2 手动开启计划模式)
    • [3.3 人工审批](#3.3 人工审批)
  • [4. 任务测试](#4. 任务测试)
    • [4.1 制定计划](#4.1 制定计划)
    • [4.2 人工审批](#4.2 人工审批)
      • [4.2.1 拒绝](#4.2.1 拒绝)
      • [4.2.2 批准执行](#4.2.2 批准执行)

1. 概述

AgentScope Java 2.xHarness 模块在 ReActAgent 之上封装了一层"开箱即用的工程化能力",其中之一就是本文的主角:计划模式(Plan Mode)

1.1 需求背景

让大模型直接对代码库动手,是一件危险的事:模型可能"边想边改",在还没想清楚整体方案时就开始写文件、执行命令,一旦任务复杂、步骤多,往往改到一半才发现方向错了,留下一堆难以回滚的痕迹。我们真正想要的协作方式是:

先规划、后执行------复杂任务先产出一份书面方案,由人确认无误后,再放开权限进入执行阶段。

这正是计划模式要解决的问题。它把一次 Agent 任务拆成两个阶段:

  • 规划阶段(PLAN) :只读沙箱。模型只能调研(读文件、grep、查时间等只读工具)和记录方案,所有写操作(写文件、改文件、执行命令)一律被拦截
  • 执行阶段(BUILD):经人工审批通过后,解除限制,模型才可以真正落地方案。

1.2 核心功能

计划模式由三个"模型可见"的工具 + 一个中间件 + 一个待办清单工具组成:

工具 作用
plan_enter 进入只读规划阶段
plan_write 把完整方案写入 PLAN.md(专用写入 API,规避通用写文件的安全风险)
plan_exit 结束规划、请求人工审批;审批通过才解除写权限
todo_write 把方案拆分成多条有序待办任务,逐条执行

其中 PlanModeMiddleware 是关键:当计划模式激活时,它会在系统提示里注入一段约束 banner,并逐一检查每个工具调用 ------只放行 plan_enter/plan_write/plan_exit 与只读工具,其余全部拦截并返回拒绝提示,迫使模型回到规划动作上。

1.3 工作流程

完整的一轮交互如下:

1.4 本文 Demo

本文基于一个 Spring Boot 3.5 + agentscope-harness 2.0.0-RC4 + DashScope qwen-plus 的最小工程,最终交付一个带 SSE 流式输出 + 人工审批弹窗 + 手动进入计划模式按钮的网页对话界面。读完即可在本地把整套计划模式跑通。


2. 环境搭建

2.1 引入依赖

计划模式来自 agentscope-harness,它依赖 agentscope-core(提供 ReActAgentModel/DashScopeChatModelToolkit、事件/权限体系)。对外暴露 HTTP/SSE 接口再加一个 spring-boot-starter-web 即可。

xml 复制代码
<dependencies>
    <!-- AgentScope 2.0 核心:ReActAgent / Model / Toolkit / 事件 / 权限 -->
    <dependency>
        <groupId>io.agentscope</groupId>
        <artifactId>agentscope-core</artifactId>
        <version>2.0.0-RC4</version>
    </dependency>

    <!-- AgentScope Harness:HarnessAgent、计划模式、文件系统/技能/子智能体中间件 -->
    <dependency>
        <groupId>io.agentscope</groupId>
        <artifactId>agentscope-harness</artifactId>
        <version>2.0.0-RC4</version>
    </dependency>

    <!-- 暴露 SSE 对话接口 + 提供静态页面 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

HarnessAgent.stream*() 返回的是 ReactorFluxreactor-core 已由 agentscope-core 传递依赖带入,无需单独声明。

2.2 智能体配置

我们用一个 @Configuration 装配两个 Bean模型HarnessAgent

2.2.1 模型配置

ModelDashScopeChatModel 构建,API Keyapplication.yml 读取。这里特意打开了 thinking(思考模式)

java 复制代码
/** DashScope 大模型。开启 thinking:思考型输出更倾向于真正调用 plan_enter/plan_write/plan_exit 工具。 */
@Bean
public Model dashScopeModel(
        @Value("${spring.ai.dashscope.api-key}") String apiKey,
        @Value("${agent.model:qwen-plus}") String modelName) {
    return DashScopeChatModel.builder()
            .apiKey(apiKey)
            .modelName(modelName)
            .enableThinking(true)   // 开启思考;注意:thinking 会强制走 streaming
            .build();
}

为什么开 thinking? 是否进入计划模式(即是否调用 plan_enter)本质上是模型自己的决策。实践中发现:关闭思考时,qwen-plus 有时会"用文字描述一遍计划"却不真正调用工具,导致计划模式根本没被触发。打开思考后,模型在推理阶段更容易"决定动用工具",触发更稳定。

需要注意的一点是:DashScopeChatModelenableThinking(true) 时会自动把 stream 置为 true,这与我们后面用 SSE 流式输出天然契合。

2.2.2 HarnessAgent 配置

这是核心。通过 HarnessAgent.builder() 开启计划模式并注册工具:

java 复制代码
/**
 * 计划模式智能体。PlanChatController 注入此 Bean。
 *
 * @param dashScopeModel 大模型
 * @param workspace      工作空间根目录(计划文件、只读文件工具均以此为根),
 *                       默认 ${user.dir}/.agentscope/workspace
 */
@Bean
public HarnessAgent planAgent(
        Model dashScopeModel,
        @Value("${agent.workspace:${user.dir}/.agentscope/workspace}") String workspace) {

    // 注册项目自带的只读工具(时间查询)。HarnessAgent 默认还会注册只读文件工具
    // (read_file / grep_files / list_files / glob_files),用于计划模式下的代码库调研。
    Toolkit toolkit = new Toolkit();
    toolkit.registerTool(new TimeQueryTool());

    Path workspacePath = Paths.get(workspace);

    return HarnessAgent.builder()
            .name("plan_agent")
            .model(dashScopeModel)
            .workspace(workspacePath)
            .toolkit(toolkit)
            // ---- 计划模式 ----
            .enablePlanMode()                 // 开启总开关,注册 plan_enter/plan_write/plan_exit
            .planFileDirectory("plans")       // 计划文件目录:<workspace>/plans/PLAN.md
            .enableTaskList()                 // 开启待办清单(todo_write)
            // ---- 精简:本 demo 聚焦计划模式,关闭子智能体 / 记忆 / 动态技能以减少外部依赖 ----
            .disableSubagents()
            .disableDynamicSubagents()
            .disableMemoryTools()
            .disableMemoryHooks()
            .disableDynamicSkills()
            .disableDefaultWorkspaceSkills()
            .build();
}

HarnessAgent 构建器与计划模式相关的配置项:

方法 默认值 功能说明
enablePlanMode() false 开启规划模式总开关
planFileDirectory(path) plans 计划文件存放目录(相对工作空间)
allowShellInPlanMode() false 放开规划阶段 shell 命令执行权限
enableTaskList() false 开启待办清单,每轮推理前置展示 todo

几点补充说明:

  • 只读 shell 默认关闭 :shell(execute)是 dual-use,无法按名称判定为只读,因此计划模式下默认禁用。若需要用 cat/grep/git log 之类做更真实的调研,可追加 .allowShellInPlanMode()------此时仍然只放开 executewrite_file/edit_file 依旧严格禁止,建议配合沙箱隔离。
  • disableXxx() 是为了精简HarnessAgent 默认还会装配子智能体、长期记忆、动态技能等能力,本 demo 只演示计划模式,关掉它们可以避免额外的外部依赖(如嵌入模型、技能目录)。生产中按需保留。
  • 工具的注解很关键Toolkit.registerTool(obj) 扫描的是 AgentScope 自己的 @io.agentscope.core.tool.Tool 注解,不是 Spring AI 的 @Tool。因此自定义工具要用 AgentScope 的注解,并建议标注 readOnly = true,这样它在计划模式(只读阶段)下也能被调用:
java 复制代码
public class TimeQueryTool {

    private static final DateTimeFormatter FORMATTER =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    // 注意:这是 io.agentscope.core.tool.Tool,readOnly=true 使其在计划模式下仍可调用
    @Tool(name = "getCurrentTime", description = "获取当前的系统时间,包含年月日时分秒", readOnly = true)
    public String getCurrentTime() {
        return LocalDateTime.now().format(FORMATTER);
    }
    // 省略 getCurrentDate / getCurrentWeekDay / getCurrentZonedTime ...
}

启动时日志可以看到工具被注册(含计划模式三件套与待办工具):

复制代码
Registered tool 'read_file' ...
Registered tool 'plan_enter' / 'plan_write' / 'plan_exit'
Registered tool 'todo_write'
HarnessAgent 'plan_agent' built [workspace=...\.agentscope\workspace, subagents=false]

2.2.3 AGENT.md 配置

HarnessAgent 会读取工作空间根目录下的上下文文件 ,把它作为该智能体的"身份/约定"注入。这是一种比硬编码 sysPrompt 更轻、可随项目走的方式。

📌 重要约定 :Harness 按惯例读取的文件名是 AGENTS.md(复数) 。若只创建 AGENT.md(单数),运行时不会加载它。启动日志中那条 Please create it and add AGENTS.md 就是提示这一点。

demo 把智能体定位为一个"AI 编程助手",内容保持克制(不写死流程,规划逻辑交给计划模式与工具描述本身):

text 复制代码
# AGENTS.md

你是一个 AI 编程助手。

## 职责
- 理解用户的编程需求,提供清晰、可运行的代码与解释。
- 阅读和分析代码库,回答关于实现、架构与用法的问题。
- 协助调试、重构,补充测试与文档。

## 风格
- 回答简洁、准确,必要时给出最小可复现示例。
- 遵循项目既有的语言、框架与代码风格。
- 不确定时主动说明假设或提出澄清问题。

文件放在工作空间根目录,例如 D:\java\ai\demo-aiai\.agentscope\workspace\AGENTS.md。同时建议保留一份让 WorkspaceManager 不再告警。

2.2.4 application.yml 配置文件

最后是配置文件。关键是 DashScopeapi-key;模型名与工作空间目录都给了带默认值的可选项(agent.modelagent.workspace),不写也能跑。

yaml 复制代码
server:
  port: 8086              # 应用端口;页面地址 http://localhost:8086/plan.html

spring:
  application:
    name: demo-aiai
  ai:
    dashscope:
      api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx   # 替换为你的阿里云百炼 API Key

# 可选项(带默认值,可不配):
# agent:
#   model: qwen-plus                                # 对应 @Value("${agent.model:qwen-plus}")
#   workspace: D:/java/ai/demo-aiai/.agentscope/workspace

小贴士:若希望计划模式下的只读文件工具能读到本项目源码,可以把 agent.workspace 直接指向项目根目录 D:/java/ai/demo-aiai,模型就能 read_file/grep_files 整个工程来产出更贴合实际的方案。


3. 实现对话接口

我们用一个 @RestController 暴露三个端点,全部挂在 /api/plan 下:

端点 方法 作用
/api/plan/run_sse POST 下发任务,SSE 流式推送 Agent 事件
/api/plan/enter_plan_mode POST 手动进入计划模式(运行时开关)
/api/plan/confirm POST 人工审批,续跑被 plan_exit 暂停的 Agent

控制器骨架(注入 HarnessAgentObjectMapper,并用一个进程内 Map 暂存待审批的工具调用):

java 复制代码
@RestController
@RequestMapping("/api/plan")
public class PlanChatController {

    private final HarnessAgent agent;
    private final ObjectMapper objectMapper;

    /** sessionId -> 等待人工确认的工具调用(来自 RequireUserConfirmEvent)。 */
    private final Map<String, List<ToolUseBlock>> pendingConfirms = new ConcurrentHashMap<>();

    public PlanChatController(HarnessAgent agent, ObjectMapper objectMapper) {
        this.agent = agent;
        this.objectMapper = objectMapper;
    }

    public record RunRequest(String sessionId, String userId, String message) {}
    public record ConfirmRequest(String sessionId, String userId, boolean approved) {}
    public record ModeRequest(String sessionId, String userId) {}
    // ... 端点见下文
}

关键约定:Agent 的计划模式状态由 HarnessAgent(userId, sessionId) 持久化在其 state store 中。因此 run_sseenter_plan_modeconfirm 必须使用同一个 sessionId ,否则就不是"同一次会话"。pendingConfirms 是进程内缓存,仅适用于单实例;分布式部署需替换为共享存储。

3.1 SSE 对话

服务端:把事件流写进 SseEmitter

run_sse 端点把用户消息包成 UserMessage,构造 RuntimeContext(userId, sessionId),调用 agent.streamEvents(...) 得到 Flux<AgentEvent>,再逐个事件序列化为 JSON 推给前端:

java 复制代码
@PostMapping(value = "/run_sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter run(@RequestBody RunRequest req) {
    String sessionId = orElse(req.sessionId(), () -> "sess-" + UUID.randomUUID());
    String userId = orElse(req.userId(), () -> "anonymous");
    String text = req.message() == null ? "" : req.message();

    // 是否进入计划模式由模型自行决定(调用 plan_enter 工具);此处不强制。
    RuntimeContext ctx = RuntimeContext.builder().userId(userId).sessionId(sessionId).build();
    Flux<AgentEvent> events = agent.streamEvents(new UserMessage(text), ctx);
    return stream(events, sessionId);
}

// ---- 把 Flux<AgentEvent> 桥接到 SseEmitter ----

private SseEmitter stream(Flux<AgentEvent> events, String sessionId) {
    return bind(events, sessionId, newEmitter());
}

/** 订阅事件流并推送到给定 emitter,记录待确认事件。 */
private SseEmitter bind(Flux<AgentEvent> events, String sessionId, SseEmitter emitter) {
    events.subscribe(
            event -> onEvent(event, sessionId, emitter),                  // onNext:逐事件发送
            err -> { log.warn("plan stream error", err);                  // onError
                     emitter.completeWithError(err); },
            emitter::complete);                                           // onComplete:关闭 SSE
    return emitter;
}

private void onEvent(AgentEvent event, String sessionId, SseEmitter emitter) {
    try {
        if (event instanceof RequireUserConfirmEvent rc) {
            // 暂存待审批的工具调用(主要是 plan_exit),供 /confirm 续跑使用。
            pendingConfirms.put(sessionId, rc.getToolCalls());
        }
        // SSE 事件名 = 事件类型;data = 事件的 JSON
        emitter.send(SseEmitter.event()
                .name(event.getType().name())
                .data(objectMapper.writeValueAsString(event), MediaType.APPLICATION_JSON));
    } catch (IOException e) {
        emitter.completeWithError(e);
    }
}

private static SseEmitter newEmitter() {
    // 0L = 不超时;计划模式可能等待较长时间的人工审批。
    return new SseEmitter(0L);
}

这里有两个值得注意的工程细节:

  1. SseEmitter + 手动 subscribeSpring MVC(非 WebFlux)下,把 Reactor 的 Flux 桥接到 SSE 最稳妥的方式就是新建 SseEmitter,在 subscribe 的三个回调里分别"发事件 / 报错 / 关闭"。
  2. 超时设为 0 :计划模式会停在 plan_exit 等人审批,可能很久,所以禁用超时,避免 emitter 被提前关闭。

AgentEvent 的类型很丰富,前端按需挑选渲染。常用的有:TEXT_BLOCK_DELTA(正文增量)、THINKING_BLOCK_DELTA(思考增量)、TOOL_CALL_END(工具调用,字段 toolCallName)、TOOL_RESULT_TEXT_DELTA(工具结果增量,字段 delta/toolCallId)、REQUIRE_USER_CONFIRM(请求人工确认)。

前端:用 fetch 读取 POST 形式的 SSE

由于我们的 SSE 端点是 POST (浏览器原生 EventSource 只支持 GET),前端改用 fetch + ReadableStream 手动按空行切分 SSE 帧:

javascript 复制代码
// 通过 fetch 读取 POST 形式的 SSE 流
async function streamSse(url, body) {
  setBusy(true);
  try {
    const resp = await fetch(url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    const reader = resp.body.getReader();
    const decoder = new TextDecoder();
    let buffer = "";
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      // SSE 以空行(\n\n)分隔事件
      let idx;
      while ((idx = buffer.indexOf("\n\n")) >= 0) {
        const raw = buffer.slice(0, idx);
        buffer = buffer.slice(idx + 2);
        parseSseBlock(raw);
      }
    }
  } catch (e) {
    add("msg tool", "⚠️ 连接错误: " + e.message);
  } finally {
    setBusy(false);
  }
}

// 解析单个 SSE 帧:取出 event: 名称与 data: 内容
function parseSseBlock(raw) {
  let event = "message";
  const dataLines = [];
  for (const line of raw.split("\n")) {
    if (line.startsWith("event:")) event = line.slice(6).trim();
    else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
  }
  if (dataLines.length) handleEvent(event, dataLines.join("\n"));
}

按事件类型渲染(正文/思考/工具调用/工具结果各用不同样式的气泡):

javascript 复制代码
const toolResultBuf = {}; // toolCallId -> 累积的工具结果文本

function handleEvent(type, payload) {
  let ev = {};
  try { ev = JSON.parse(payload); } catch (e) { /* 非 JSON(如 ERROR)*/ }

  switch (type) {
    case "TEXT_BLOCK_DELTA":                 // 正文增量
      appendAssistant(ev.delta || ""); break;
    case "THINKING_BLOCK_DELTA":             // 思考增量(开了 enableThinking 才有)
      appendThinking(ev.delta || ""); break;
    case "TOOL_CALL_END":                    // 工具调用:字段是 toolCallName
      add("msg tool", "🔧 调用工具: " + (ev.toolCallName || "tool")); break;
    case "TOOL_RESULT_TEXT_DELTA": {         // 工具结果是增量推送,按 toolCallId 累积
      const id = ev.toolCallId || "_";
      toolResultBuf[id] = (toolResultBuf[id] || "") + (ev.delta || ""); break;
    }
    case "TOOL_RESULT_END": {                // 结果结束:把累积文本输出
      const id = ev.toolCallId || "_";
      const txt = (toolResultBuf[id] || "").trim();
      delete toolResultBuf[id];
      if (txt) add(ev.toolCallName?.startsWith("plan") ? "msg plan" : "msg tool",
                   "📄 [" + (ev.toolCallName || "") + "] " + txt);
      break;
    }
    case "REQUIRE_USER_CONFIRM":             // 请求人工审批 → 弹出按钮(见 3.3)
      renderConfirm(ev); break;
  }
}

踩坑提醒:TOOL_CALL_END 的工具名字段是 toolCallName 而非 name;工具结果文本不在 TOOL_RESULT_END 里,而是通过 TOOL_RESULT_TEXT_DELTA 增量推送,需要按 toolCallId 自行累积。字段名取错就会出现"工具名永远显示成 tool、结果为空"的现象。

3.2 手动开启计划模式

是否进入计划模式默认"由模型决定"。但有时我们希望确定性地 强制本次会话进入只读规划------例如做演示、或对高风险会话强约束。HarnessHarnessAgent 上提供了运行时开关:

java 复制代码
agent.enterPlanMode(userId, sessionId);   // 手动进入规划阶段
agent.exitPlanMode(userId, sessionId);    // 程序退出(不会触发 HITL 弹窗)
agent.isPlanModeActive(userId, sessionId);// 查询当前是否处于计划模式

据此提供一个非 SSE 的控制端点,点一下就把该会话切进计划模式:

java 复制代码
/**
 * 手动进入计划(PLAN)模式:程序化开启计划模式。
 * 等价于运行时开关 agent.enterPlanMode(userId, sessionId)。
 * 之后该会话的写操作会被拦截,直至模型调用 plan_exit 审批通过。
 */
@PostMapping(value = "/enter_plan_mode", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> enterPlanMode(@RequestBody ModeRequest req) {
    String sessionId = orElse(req.sessionId(), () -> "anonymous");
    String userId = orElse(req.userId(), () -> "anonymous");
    agent.enterPlanMode(userId, sessionId);
    log.info("manually entered PLAN mode (session={})", sessionId);
    return Map.of(
            "sessionId", sessionId,
            "planModeActive", agent.isPlanModeActive(userId, sessionId));
}

前端在页头放一个按钮,点击后调用该接口:

javascript 复制代码
async function enterPlanMode() {
  const resp = await fetch("/api/plan/enter_plan_mode", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ sessionId, userId: "web-user" }),
  });
  const data = await resp.json();
  add("event", data.planModeActive
    ? "🗺️ 已进入计划模式:本会话写操作将被拦截,直至 plan_exit 审批通过。"
    : "(计划模式未激活)");
}
planBtn.onclick = enterPlanMode;

进入计划模式后,PlanModeMiddleware 立即生效:系统提示中被注入约束 banner,任何写操作工具被拦截。此后只要再发一条消息,模型就会在只读约束下完成调研与 plan_write,并最终调用 plan_exit,进入下一节的人工审批环节。

3.3 人工审批

plan_exit 工具内部对权限做了一个强制 ASK 的自检(PermissionDecision.ask(...)):离开计划模式是一次需要人确认的"交接"。因此当模型调用 plan_exit 时,Agent 不会立即退出 ,而是暂停 ,并在事件流中发出一个 RequireUserConfirmEvent,其中携带待确认的 ToolUseBlock(即这次的 plan_exit 调用)。

我们在 3.1onEvent 里已经把这些待确认工具调用按 sessionId 存进了 pendingConfirms。审批端点要做的就是:取出它们,构造 ConfirmResult,通过一条Msg.METADATA_CONFIRM_RESULTS 元数据的消息 续跑 Agent

java 复制代码
@PostMapping(value = "/confirm", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter confirm(@RequestBody ConfirmRequest req) {
    String sessionId = req.sessionId();
    String userId = orElse(req.userId(), () -> "anonymous");

    SseEmitter emitter = newEmitter();
    // 取出本会话暂停时记录的待确认工具调用
    List<ToolUseBlock> pending = sessionId == null ? null : pendingConfirms.remove(sessionId);
    if (pending == null || pending.isEmpty()) {
        // 没有待确认项:可能 session 不匹配,或上一次运行未暂停在 plan_exit。
        try {
            emitter.send(SseEmitter.event().name("ERROR")
                    .data("No pending confirmation for sessionId=" + sessionId));
        } catch (IOException ignored) {}
        emitter.complete();
        return emitter;
    }

    // 为每个待确认工具调用生成审批结果(approved=true 批准 / false 拒绝)
    List<ConfirmResult> results = pending.stream()
            .map(tc -> new ConfirmResult(req.approved(), tc))
            .toList();

    // 关键:把审批结果放进消息的 METADATA_CONFIRM_RESULTS 元数据,作为"续跑"信号
    UserMessage resume = UserMessage.builder()
            .metadata(Map.of(Msg.METADATA_CONFIRM_RESULTS, results))
            .build();

    RuntimeContext ctx = RuntimeContext.builder().userId(userId).sessionId(sessionId).build();
    Flux<AgentEvent> events = agent.streamEvents(resume, ctx);  // 用同一 sessionId 续跑
    return bind(events, sessionId, emitter);
}

续跑后的行为:

  • 批准(approved=trueplan_exit 通过,Agent 切到 BUILD 模式,写操作解禁,并被引导用 todo_write 把方案拆成任务、逐条执行。
  • 拒绝(approved=falseAgent 留在计划模式,应当根据反馈修订方案。

前端在收到 REQUIRE_USER_CONFIRM 事件时渲染审批弹窗(批准 / 拒绝两个按钮):

javascript 复制代码
function renderConfirm(ev) {
  const box = document.createElement("div");
  box.className = "msg confirm";
  box.appendChild(Object.assign(document.createElement("div"),
      { textContent: "🛑 Agent 已完成规划,请求审批后进入执行阶段(plan_exit)。" }));

  const approve = Object.assign(document.createElement("button"),
      { className: "approve", textContent: "✅ 批准执行" });
  const reject  = Object.assign(document.createElement("button"),
      { className: "reject",  textContent: "❌ 拒绝(继续规划)" });

  // 点击后调用 /confirm 续跑(同一 sessionId)
  approve.onclick = () => { box.remove(); confirm(true); };
  reject.onclick  = () => { box.remove(); confirm(false); };

  box.appendChild(approve);
  box.appendChild(reject);
  logEl.appendChild(box);
}

// 审批 = 再发起一次 SSE,走 /confirm 端点
function confirm(approved) {
  add("event", approved ? "→ 已批准,进入执行阶段..." : "→ 已拒绝,继续停留在计划模式...");
  streamSse("/api/plan/confirm", { sessionId, userId: "web-user", approved });
}

至此,一条完整的计划模式链路就跑通了:

复制代码
发消息 → (模型/按钮) 进入 PLAN → 调研 → plan_write 写 PLAN.md
       → plan_exit 暂停 → 前端弹出审批 → 批准 → BUILD 执行

4. 任务测试

4.1 制定计划

点击【进入计划模式】:

输入【你好】会提示当前处于计划模式:

输入一个较为复杂的任务【集成 RAG、国产数据库】,提示【明确需求】:

调用 plan_write 工具写入计划:

PLAN.md 文件中可以看到详细计划内容:

4.2 人工审批

计划规划完成后,调用工具 plan_exit 进入人工审批:

4.2.1 拒绝

拒绝后,继续进行规划:

4.2.2 批准执行

批准执行后,进入执行阶段,调用 todo_write 工具:

待办任务列表保存在内存会话状态::AgentState -> TaskContextState

智能体按照计划执行任务,直到完成结束。