文章目录
- [1. 概述](#1. 概述)
-
- [1.1 需求背景](#1.1 需求背景)
- [1.2 核心功能](#1.2 核心功能)
- [1.3 工作流程](#1.3 工作流程)
- [1.4 本文 Demo](#1.4 本文 Demo)
- [2. 环境搭建](#2. 环境搭建)
- [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.x 的 Harness 模块在 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(提供 ReActAgent、Model/DashScopeChatModel、Toolkit、事件/权限体系)。对外暴露 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*()返回的是Reactor的Flux,reactor-core已由agentscope-core传递依赖带入,无需单独声明。
2.2 智能体配置
我们用一个 @Configuration 装配两个 Bean:模型 与 HarnessAgent。
2.2.1 模型配置
Model 用 DashScopeChatModel 构建,API Key 从 application.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 有时会"用文字描述一遍计划"却不真正调用工具,导致计划模式根本没被触发。打开思考后,模型在推理阶段更容易"决定动用工具",触发更稳定。
需要注意的一点是:DashScopeChatModel 在 enableThinking(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()------此时仍然只放开execute,write_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 配置文件
最后是配置文件。关键是 DashScope 的 api-key;模型名与工作空间目录都给了带默认值的可选项(agent.model、agent.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 |
控制器骨架(注入 HarnessAgent 与 ObjectMapper,并用一个进程内 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_sse、enter_plan_mode、confirm必须使用同一个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);
}
这里有两个值得注意的工程细节:
- 用
SseEmitter+ 手动subscribe:Spring MVC(非WebFlux)下,把 Reactor 的Flux桥接到SSE最稳妥的方式就是新建SseEmitter,在subscribe的三个回调里分别"发事件 / 报错 / 关闭"。 - 超时设为 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 手动开启计划模式
是否进入计划模式默认"由模型决定"。但有时我们希望确定性地 强制本次会话进入只读规划------例如做演示、或对高风险会话强约束。Harness 在 HarnessAgent 上提供了运行时开关:
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.1 的 onEvent 里已经把这些待确认工具调用按 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=true) :plan_exit通过,Agent切到BUILD模式,写操作解禁,并被引导用todo_write把方案拆成任务、逐条执行。 - 拒绝(
approved=false) :Agent留在计划模式,应当根据反馈修订方案。
前端在收到 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。
智能体按照计划执行任务,直到完成结束。