智能任务规划项目文档-AgentScope

本教程将带你从零开始构建一个基于 AgentScope Java 的结构化任务规划系统。通过这个项目,你将掌握如何使用 PlanNotebook 组件实现复杂的任务分解、进度跟踪和人机协作审查功能。

项目地址: https://gitee.com/CodeMao01/plan-notebook

一、项目介绍

1.1、项目概述

1.1.1、什么是 PlanNotebook?

PlanNotebook 是一个展示结构化任务规划模式的智能对话示例项目。在这个系统中,AI 助手能够将复杂任务分解为可管理的子任务,跟踪每个子任务的执行状态,并在关键节点暂停等待用户审查和确认。

1.1.2、应用场景

  • 📋 复杂项目管理:将大型项目分解为多个阶段和子任务
  • 🔍 分步任务执行:逐步完成任务并记录每个步骤的产出
  • 👥 人机协作审查:在计划变更时暂停,允许用户审查和修改
  • 📊 进度可视化:实时跟踪任务状态和完成情况
  • 💾 历史计划管理:保存和恢复历史计划用于参考

1.2、核心特性

1.2.1、结构化任务规划

支持创建包含多个子任务的计划,每个子任务都有明确的目标和预期产出。

1.2.2、实时 SSE 流式通信

使用 Server-Sent Events 实现服务端向客户端的实时消息推送,包括:

  • AI 思考过程(thinking)
  • 文本回复(text)
  • 工具调用结果(tool)
  • 模型上下文(ctx)
  • 计划状态变更(plan)

1.2.3、模型上下文检查

每次 LLM 调用都会发出 ctx SSE 负载,UI 可以显示:

  • JSON 格式的系统提示和记忆内容
  • 扁平化的对话转录本
  • 与前一次调用的差异对比

1.2.4、人机协作审查机制

用户可以请求 Agent 在执行下一个计划工具后暂停,以便审查计划变更。

1.2.5、 完整的计划生命周期管理

  • 创建计划(create_plan)
  • 更新计划信息(update_plan_info)
  • 添加/修改/删除子任务(revise_current_plan)
  • 更新子任务状态(update_subtask_state)
  • 完成子任务(finish_subtask)
  • 完成计划(finish_plan)
  • 查看历史计划(view_historical_plans)

1.2.6、安全的文件操作

提供沙箱化的文件读写和 Shell 命令执行功能,限制在指定的工作空间目录内。


1.3、技术栈

技术领域 技术选型 说明
后端框架 Spring Boot 4.x 轻量级 Web 应用框架
Web 引擎 Spring WebFlux 响应式 Web 框架,支持异步流处理
AI 框架 AgentScope Core 1.0.11-SNAPSHOT 阿里巴巴开源的多智能体协作框架
大模型 Qwen(通义千问) 通过 DashScope API 调用,支持思考模式
通信协议 SSE (Server-Sent Events) 单向实时消息推送
计划管理 PlanNotebook AgentScope 内置的结构化任务规划组件
前端技术 Vanilla JavaScript 原生 JS,无框架依赖
构建工具 Maven 项目依赖管理和构建

二、项目搭建

  1. pom.xml
xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.0</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>plan-notebook</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>plan-notebook</name>
    <description>plan-notebook</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <dependency>
            <groupId>io.agentscope</groupId>
            <artifactId>agentscope-core</artifactId>
            <version>1.0.10</version>
        </dependency>

        <dependency>
            <groupId>io.github.cdimascio</groupId>
            <artifactId>dotenv-java</artifactId>
            <version>3.2.0</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux-test</artifactId>
            <scope>test</scope>
        </dependency>

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

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <executions>
                    <execution>
                        <id>default-compile</id>
                        <phase>compile</phase>
                        <goals>
                            <goal>compile</goal>
                        </goals>
                        <configuration>
                            <annotationProcessorPaths>
                                <path>
                                    <groupId>org.projectlombok</groupId>
                                    <artifactId>lombok</artifactId>
                                </path>
                            </annotationProcessorPaths>
                        </configuration>
                    </execution>
                    <execution>
                        <id>default-testCompile</id>
                        <phase>test-compile</phase>
                        <goals>
                            <goal>testCompile</goal>
                        </goals>
                        <configuration>
                            <annotationProcessorPaths>
                                <path>
                                    <groupId>org.projectlombok</groupId>
                                    <artifactId>lombok</artifactId>
                                </path>
                            </annotationProcessorPaths>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>
  1. yaml
java 复制代码
spring:
  application:
    name: plan-notebook
server:
  port: 8804
  1. .env(补充自己的DASHSCOPE_API_KEY)
java 复制代码
BASE_URL=http://localhost:11434
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com
DASHSCOPE_MODEL_NAME=qwen3-max-2026-01-23
  1. 启动类
java 复制代码
package com.example.plannotebook;

import io.github.cdimascio.dotenv.Dotenv;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class PlanNotebookApplication {

    public static void main(String[] args) {
        // 方式一:
        // 加载.env文件内容
//        Dotenv.configure().ignoreIfMissing().systemProperties().load();
        // 方式二: 这种方式会加载系统的环境变量
        // 加载.env文件
        Dotenv load = Dotenv.configure().ignoreIfMissing().load();
        // 把.env变量设置成系统属性
        load.entries().forEach(entry -> System.setProperty(entry.getKey(), entry.getValue()));
        SpringApplication.run(PlanNotebookApplication.class, args);
    }

}
  1. 测试接口
java 复制代码
package com.example.plannotebook.controller;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

import java.util.List;

@RestController
public class ChatController {


    /**
     * produces = MediaType.TEXT_EVENT_STREAM_VALUE 相当于设置成SSE返回, 等价于Flux<ServerSentEvent<String>>
     *
     * @param message
     * @return
     */
    @GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chat(String message) {
        List<String> hello = List.of("hello", "world", "!");
        return Flux.fromIterable(hello);
    }
//    @GetMapping(value = "/chat")
//    public Flux<ServerSentEvent<String>> chat(String message) {
//        List<String> hello = List.of("hello", "world", "!");
//        return Flux.fromIterable(hello).map(v -> ServerSentEvent.<String>builder().data(v).build());
//    }
}

三、Sinks

3.1、什么是 Sink?为什么要使用?

3.1.1、核心概念

Sink(接收器/汇聚点) 是 Project Reactor 提供的一种手动控制数据发射的机制。它允许你在响应式流的外部,主动地向流中推送数据。

可以把 Sink 想象成一个水龙头

  • 传统响应式流:数据源自动流动(如数据库查询结果自动返回)
  • 使用 Sink:你手动拧开水龙头,决定何时放水、放多少水

3.1.2、为什么需要 Sink?

问题场景:响应式世界 vs 命令式世界

在响应式编程中,数据流通常是被动订阅的:

plain 复制代码
// 传统响应式:数据源驱动
Flux.fromIterable(list)           // 数据已经存在
    .map(item -> process(item))
    .subscribe(result -> System.out.println(result));

但现实中有大量场景需要主动推送数据

场景 1:外部事件触发
plain 复制代码
// ❌ 问题:WebSocket 收到消息时,如何推送到响应式流?
@OnMessage
public void onMessage(String message) {
    // 这里不是响应式上下文,如何通知订阅者?
}
场景 2:异步回调转换
plain 复制代码
// ❌ 问题:第三方 SDK 使用回调,如何转成响应式流?
thirdPartySDK.getData(new Callback() {
    @Override
    public void onSuccess(String data) {
        // 如何将这个回调结果变成 Flux/Mono?
    }
});
场景 3:多生产者单消费者
plain 复制代码
// ❌ 问题:多个线程同时产生数据,如何汇聚成一个流?
thread1.produceData();
thread2.produceData();
thread3.produceData();
// 如何让一个订阅者统一接收?

Sink 就是解决这些问题的桥梁!


3.1.3、Sink 的核心价值

3.1.3.1、桥接命令式与响应式代码
plain 复制代码
import reactor.core.publisher.Sinks;
import reactor.core.publisher.Flux;

public class BridgeExample {
    
    // 创建 Sink(命令式入口)
    private final Sinks.Many<String> sink = Sinks.many()
        .multicast()
        .onBackpressureBuffer();
    
    // 获取响应式流(响应式出口)
    public Flux<String> getDataStream() {
        return sink.asFlux();
    }
    
    // 命令式方法:随时可以调用
    public void pushData(String data) {
        sink.tryEmitNext(data);  // 主动推送
    }
}

使用方式

plain 复制代码
BridgeExample example = new BridgeExample();

// 响应式订阅
example.getDataStream()
    .subscribe(data -> System.out.println("收到: " + data));

// 命令式推送(可以在任何地方调用)
example.pushData("消息1");
example.pushData("消息2");

3.1.3.2、解耦生产者和消费者
plain 复制代码
import reactor.core.publisher.Sinks;
import java.util.ArrayList;
import java.util.List;

public class DecouplingExample {
    
    public static void main(String[] args) throws InterruptedException {
        Sinks.Many<String> sink = Sinks.many()
            .replay()
            .all();
        
        // === 生产者(完全不知道谁在消费)===
        new Thread(() -> {
            for (int i = 1; i <= 3; i++) {
                sink.tryEmitNext("[A] 数据-" + i);
                try { Thread.sleep(100); } catch (InterruptedException e) {}
            }
        }).start();
        
        new Thread(() -> {
            for (int i = 1; i <= 3; i++) {
                sink.tryEmitNext("[B] 数据-" + i);
                try { Thread.sleep(150); } catch (InterruptedException e) {}
            }
        }).start();
        
        Thread.sleep(200);
        
        // === 消费者(完全不知道谁在生产)===
        System.out.println("--- 消费者1 开始订阅 ---");
        sink.asFlux().subscribe(data -> 
            System.out.println("[消费者1] " + data)
        );
        
        Thread.sleep(500);
        
        System.out.println("\n--- 消费者2 开始订阅 ---");
        sink.asFlux().subscribe(data -> 
            System.out.println("[消费者2] " + data)
        );
        
        Thread.sleep(1000);
    }
}

优势

  • ✅ 生产者不需要知道有几个消费者
  • ✅ 消费者可以随时加入/退出
  • ✅ 支持动态扩展

3.1.3.3、实现广播和多播
plain 复制代码
import reactor.core.publisher.Sinks;

public class BroadcastExample {
    public static void main(String[] args) throws InterruptedException {
        Sinks.Many<String> sink = Sinks.many()
            .multicast()
            .onBackpressureBuffer();
        
        var flux = sink.asFlux();
        
        // 三个订阅者同时接收相同数据
        flux.subscribe(data -> System.out.println("[邮件通知] " + data));
        flux.subscribe(data -> System.out.println("[短信通知] " + data));
        flux.subscribe(data -> System.out.println("[APP推送] " + data));
        
        // 一次发射,三方接收
        sink.tryEmitNext("订单已发货");
        sink.tryEmitNext("预计明天送达");
        
        sink.tryEmitComplete();
        Thread.sleep(500);
    }
}

3.1.3.4、处理异步回调
plain 复制代码
import reactor.core.publisher.Sinks;
import reactor.core.publisher.Mono;

public class CallbackToReactiveExample {
    
    /**
     * 将回调风格的 API 转换为响应式 Mono
     */
    public Mono<String> fetchDataAsync() {
        Sinks.One<String> sink = Sinks.one();
        
        // 模拟异步回调(如 HTTP 请求、数据库查询)
        simulateAsyncOperation(new AsyncCallback() {
            @Override
            public void onSuccess(String result) {
                sink.tryEmitValue(result);  // 成功时发射值
            }
            
            @Override
            public void onError(Exception e) {
                sink.tryEmitError(e);  // 失败时发射错误
            }
        });
        
        return sink.asMono();  // 返回 Mono 供订阅
    }
    
    private void simulateAsyncOperation(AsyncCallback callback) {
        new Thread(() -> {
            try {
                Thread.sleep(1000);
                callback.onSuccess("数据加载完成");
            } catch (Exception e) {
                callback.onError(e);
            }
        }).start();
    }
    
    interface AsyncCallback {
        void onSuccess(String result);
        void onError(Exception e);
    }
}

3.1.3.5、实现事件总线(Event Bus)
plain 复制代码
import reactor.core.publisher.Sinks;
import reactor.core.publisher.Flux;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class EventBus {
    
    private final Map<String, Sinks.Many<Object>> topics = new ConcurrentHashMap<>();
    
    /**
     * 订阅某个主题
     */
    public <T> Flux<T> subscribe(String topic, Class<T> type) {
        Sinks.Many<Object> sink = topics.computeIfAbsent(topic, 
            t -> Sinks.many().replay().limit(10)
        );
        
        return sink.asFlux()
            .filter(type::isInstance)
            .cast(type);
    }
    
    /**
     * 发布事件到某个主题
     */
    public void publish(String topic, Object event) {
        Sinks.Many<Object> sink = topics.get(topic);
        if (sink != null) {
            sink.tryEmitNext(event);
        }
    }
}

3.1.4、Sink vs 其他方案对比

方案 缺点 Sink 的优势
BlockingQueue 阻塞线程,不符合响应式理念 非阻塞,背压感知
CompletableFuture 只能返回单次结果 支持流式多次发射
自定义观察者模式 需手动管理订阅、背压、线程安全 内置完整机制
Subject (RxJava) Reactor 不推荐使用 Subject Reactor 官方推荐方案

3.1.5、实际应用场景总结

  1. WebSocket 实时通信:服务端收到消息 → 通过 Sink 广播给所有客户端
  2. SSE(Server-Sent Events):持续向浏览器推送事件
  3. 监控指标收集:多个服务实例上报指标 → 汇聚处理
  4. 日志聚合:分布式日志收集
  5. 进度通知:长时间任务进度推送

3.1.6、核心理解要点

Sink 的本质

plain 复制代码
┌─────────────────────────────────────┐
│         命令式世界                    │
│  (任意线程、任意时机调用)             │
│                                     │
│   sink.tryEmitNext(data)  ← 手动推送 │
│                                     │
└──────────────┬──────────────────────┘
               │ Sink(桥梁)
               ▼
┌─────────────────────────────────────┐
│         响应式世界                    │
│  (背压感知、调度器管理、操作符链)      │
│                                     │
│   sink.asFlux() / asMono()          │
│       .map(...)                      │
│       .filter(...)                   │
│       .subscribe(...)                │
└─────────────────────────────────────┘

为什么要用 Sink?

  1. 打破响应式的封闭性:允许从外部注入数据
  2. 统一管理数据流:多生产者 → 单 Sink → 多消费者
  3. 保持响应式特性:背压、线程调度、错误处理
  4. 简化复杂场景:广播、重放、缓冲等开箱即用

什么时候不用 Sink?

不需要 Sink 的场景

plain 复制代码
// 数据源本身就是响应式的,无需 Sink
Flux.fromDatabase(query)
    .map(entity -> dto)
    .subscribe();

// 简单的同步转换
Mono.just(data)
    .map(this::process)
    .subscribe();

需要 Sink 的场景

  • 从非响应式代码推送数据到响应式流
  • 需要广播给多个订阅者
  • 需要缓存历史数据供新订阅者回放
  • 需要在运行时动态添加/移除数据源

3.1.6、一句话总结

Sink 是响应式世界的"手动挡" ------ 当你需要从外部主动控制数据流时,它就是连接命令式代码和响应式流的完美桥梁。


3.2、Sinks 完整分类体系

plain 复制代码
Sinks
├── Sinks.One<T>          // 单元素(类似 CompletableFuture)
│   └── emitOnce()        // 只能发射一个值或错误
│
└── Sinks.Many<T>         // 多元素(类似 Flux)
    ├── unicast()         // 单订阅者
    ├── multicast()       // 多订阅者(无历史回放)
    └── replay()          // 多订阅者(有历史回放)

3.2.1、Sinks.One

java 复制代码
@Test
void sinksOneTest() {
    Sinks.One<String> one = Sinks.one();
    Mono<String> mono = one.asMono();

    mono.subscribe(
            v -> System.out.println("Subscriber 1 received: " + v),
            e -> System.out.println("Subscriber 1 error: " + e),
            () -> System.out.println("Subscriber 1: complete")
    );
    mono.subscribe(
            v -> System.out.println("Subscriber 2 received: " + v),
            e -> System.out.println("Subscriber 2 error: " + e),
            () -> System.out.println("Subscriber 2: complete")
    );

    // 发送正常消息
//        Sinks.EmitResult result1 = one.tryEmitValue("hello");
//        System.out.println(result1);
//
//        Sinks.EmitResult result2 = one.tryEmitValue("world");
//        System.out.println(result2);

    // 发送异常消息
    Sinks.EmitResult result = one.tryEmitError(new RuntimeException("error"));
    System.out.println(result);

}

3.2.2、Sinks.Many

3.2.2.1、Sinks.Many 三大策略完整对比

对比矩阵

特性 unicast() multicast() replay()
订阅者数量 仅 1 个 多个 多个
历史回放
背压支持
新订阅者接收旧数据 N/A
典型场景 任务队列 实时事件广播 聊天室/状态同步
内存占用 高(需缓存历史)

3.2.2.1.1、案例 1:Unicast - 单订阅者队列
java 复制代码
import reactor.core.publisher.Sinks;
import reactor.core.publisher.Flux;

public class UnicastExample {
    public static void main(String[] args) throws InterruptedException {
        Sinks.Many<String> sink = Sinks.many()
            .unicast()
            .onBackpressureBuffer();

        Flux<String> flux = sink.asFlux();

        // ⚠️ 第一个订阅者
        flux.subscribe(
            data -> System.out.println("[订阅者A] 收到: " + data),
            error -> System.err.println("错误: " + error),
            () -> System.out.println("[订阅者A] 完成")
        );

        // 发射数据
        sink.tryEmitNext("任务1");
        sink.tryEmitNext("任务2");

        // ⚠️ 尝试第二个订阅者(会抛出异常)
        try {
            flux.subscribe(data -> System.out.println("[订阅者B] 收到: " + data));
        } catch (IllegalStateException e) {
            System.err.println("❌ 不允许第二个订阅者: " + e.getMessage());
        }

        sink.tryEmitComplete();
        Thread.sleep(500);
    }
}

输出

plain 复制代码
[订阅者A] 收到: 任务1
[订阅者A] 收到: 任务2
❌ 不允许第二个订阅者: UnicastSink allows only one Subscriber
[订阅者A] 完成

适用场景

  • 后台任务队列
  • 日志写入(单消费者)
  • 数据管道(一对一传输)

3.2.2.1.2、案例 2:Multicast - 实时广播(无历史)
java 复制代码
import reactor.core.publisher.Sinks;
import reactor.core.publisher.Flux;

public class MulticastExample {
    public static void main(String[] args) throws InterruptedException {
        Sinks.Many<String> sink = Sinks.many()
            .multicast()
            .onBackpressureBuffer();

        Flux<String> flux = sink.asFlux();

        // 订阅者 1(立即订阅)
        flux.subscribe(
            data -> System.out.println("[用户A] 📩 " + data)
        );

        // 发射前两条消息
        sink.tryEmitNext("欢迎加入聊天室");
        sink.tryEmitNext("今天天气不错");

        Thread.sleep(500);

        // 订阅者 2(延迟订阅,收不到之前的消息)
        System.out.println("\n--- 用户B 加入聊天室 ---\n");
        flux.subscribe(
            data -> System.out.println("[用户B] 📩 " + data)
        );

        // 继续发射
        sink.tryEmitNext("大家下午好");
        sink.tryEmitNext("有人在吗?");

        sink.tryEmitComplete();
        Thread.sleep(500);
    }
}

输出

plain 复制代码
[用户A] 📩 欢迎加入聊天室
[用户A] 📩 今天天气不错

--- 用户B 加入聊天室 ---

[用户A] 📩 大家下午好
[用户B] 📩 大家下午好    ← B 从这里开始接收
[用户A] 📩 有人在吗?
[用户B] 📩 有人在吗?

适用场景

  • 实时股票行情推送
  • 在线游戏状态同步
  • 实时监控告警

3.2.2.1.1、案例 3:Replay - 带历史回放

1、Replay All(回放所有历史)

java 复制代码
import reactor.core.publisher.Sinks;
import reactor.core.publisher.Flux;

public class ReplayAllExample {
    public static void main(String[] args) throws InterruptedException {
        Sinks.Many<String> sink = Sinks.many()
            .replay()
            .all();  // 回放所有历史数据

        Flux<String> flux = sink.asFlux();

        // 先发射一些数据
        sink.tryEmitNext("系统启动");
        sink.tryEmitNext("加载配置");
        sink.tryEmitNext("连接数据库");

        Thread.sleep(500);

        // 新订阅者能收到所有历史数据
        System.out.println("--- 监控服务启动 ---");
        flux.subscribe(
            data -> System.out.println("[监控] 📊 " + data)
        );

        Thread.sleep(500);

        // 继续发射新数据
        sink.tryEmitNext("服务就绪");
        sink.tryEmitComplete();
        
        Thread.sleep(500);
    }
}

输出

plain 复制代码
--- 监控服务启动 ---
[监控] 📊 系统启动      ← 回放历史
[监控] 📊 加载配置      ← 回放历史
[监控] 📊 连接数据库    ← 回放历史
[监控] 📊 服务就绪      ← 新数据

2、 Replay limit N(只回放最近 N 条)

java 复制代码
import reactor.core.publisher.Sinks;
import reactor.core.publisher.Flux;

public class ReplayLatestExample {
    public static void main(String[] args) throws InterruptedException {
        // 只保留最近 2 条数据
        Sinks.Many<String> sink = Sinks.many()
            .replay()
            .limit(2);

        Flux<String> flux = sink.asFlux();

        // 发射 5 条数据
        for (int i = 1; i <= 5; i++) {
            sink.tryEmitNext("消息-" + i);
            System.out.println("[生产者] 发射: 消息-" + i);
            Thread.sleep(100);
        }

        Thread.sleep(500);

        // 新订阅者只能收到最后 2 条
        System.out.println("\n--- 新订阅者加入 ---");
        flux.subscribe(
            data -> System.out.println("[订阅者] 收到: " + data)
        );

        sink.tryEmitComplete();
        Thread.sleep(500);
    }
}

输出

plain 复制代码
[生产者] 发射: 消息-1
[生产者] 发射: 消息-2
[生产者] 发射: 消息-3
[生产者] 发射: 消息-4
[生产者] 发射: 消息-5

--- 新订阅者加入 ---
[订阅者] 收到: 消息-4    ← 只回放最近 2 条
[订阅者] 收到: 消息-5

适用场景

  • 聊天室(新成员查看历史消息)
  • 配置中心(新服务获取最新配置)
  • 状态同步(客户端重连后恢复状态)

3.2.2.2、背压策略对比

背压策略矩阵

策略 缓冲区满时行为 数据丢失风险 适用场景
onBackpressureBuffer() 无限缓冲 ❌ 无(但可能 OOM) 数据不能丢
onBackpressureBuffer(n) 缓冲 n ⚠️ 中等 有限容错

3.2.2.2.1、案例 4:背压策略对比演示
java 复制代码
public void testBufferStrategy() throws InterruptedException {
    Sinks.Many<String> sink = Sinks.many()
            .multicast()
            .onBackpressureBuffer(3, true); // 最多缓冲 3 个

    // 快速生产者
    for (int i = 1; i <= 6; i++) {
        String msg = "任务-" + i;
        Sinks.EmitResult result = sink.tryEmitNext(msg);
        System.out.println("  [发射] " + msg + " → " + result);
        Thread.sleep(50);
    }

    Thread.sleep(1000);

    var flux = sink.asFlux();
    // 慢消费者
    flux.publishOn(Schedulers.boundedElastic())
            .subscribe(data -> {
                try {
                    Thread.sleep(1000);
                    System.out.println("  [处理] " + data);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });

    sink.tryEmitComplete();
    Thread.sleep(7000);
}

3.3、选择决策树

plain 复制代码
需要发射数据?
│
├─ 只发射一次?
│   └─ ✅ 使用 Sinks.One
│       ├─ 异步 RPC 结果
│       ├─ 数据库查询结果
│       └─ 单次计算结果
│
└─ 发射多次?
    │
    ├─ 几个订阅者?
    │   │
    │   ├─ 只有 1 个
    │   │   └─ ✅ Sinks.Many.unicast()
    │   │       ├─ 任务队列
    │   │       └─ 数据管道
    │   │
    │   └─ 多个订阅者
    │       │
    │       ├─ 需要历史数据?
    │       │   │
    │       │   ├─ 是 → ✅ Sinks.Many.replay()
    │       │   │       ├─ 全部历史: .replay().all()
    │       │   │       └─ 最近 1 条: .replay().latest()
    │       │   │       └─ 最近 N 条: .replay().limit(N)
    │       │   │
    │       │   └─ 否 → ✅ Sinks.Many.multicast()
    │       │           ├─ 实时行情
    │       │           └─ 在线通知
    │       │
    │       └─ 背压如何处理?
    │           ├─ 不能丢 → onBackpressureBuffer()
    │           └─ 要最新 → onBackpressureBuffer(N)

四、左侧聊天界面

4.1、流式输出

  1. 配置模型、工具集
java 复制代码
package com.example.plannotebook.config;

import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter;
import io.agentscope.core.formatter.ollama.OllamaChatFormatter;
import io.agentscope.core.model.DashScopeChatModel;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.model.OllamaChatModel;
import io.agentscope.core.model.ollama.OllamaOptions;
import io.agentscope.core.model.ollama.ThinkOption;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.core.tool.file.ReadFileTool;
import io.agentscope.core.tool.file.WriteFileTool;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AiConfig {
    @Bean
    public OllamaChatModel ollamaChatModel() {
        return OllamaChatModel.builder()
                .modelName("qwen3.5:0.8b")
                .defaultOptions(OllamaOptions.builder()
                        .thinkOption(ThinkOption.ThinkBoolean.ENABLED)
                        .build())
                // 默认也是这样的
                .formatter(new OllamaChatFormatter())
                .build();
    }

    @Bean
    public DashScopeChatModel dashScopeChatModel() {
        return DashScopeChatModel.builder()
                .stream(true)
                .enableThinking(true)
                .formatter(new DashScopeChatFormatter())
                .apiKey(System.getProperty("DASHSCOPE_KEY"))
                .modelName(System.getProperty("DASHSCOPE_MODEL_NAME"))
                .baseUrl(System.getProperty("DASHSCOPE_BASE_URL"))
                .defaultOptions(GenerateOptions.builder()
                        // 思考模式的token上线
                        .thinkingBudget(8196)
                        .build())
                .build();
    }

    /**
     * 通用 注册工具
     */
    @Bean
    public Toolkit toolkit() {
        Toolkit toolkit = new Toolkit();
        String workDir = "D:\\Desktop\\tmp";
        toolkit.registerTool(new ReadFileTool(workDir));
        toolkit.registerTool(new WriteFileTool(workDir));
        return toolkit;
    }
}
  1. 定义Agent
java 复制代码
package com.example.plannotebook.service;

import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.EventType;
import io.agentscope.core.message.Msg;
import io.agentscope.core.model.DashScopeChatModel;
import io.agentscope.core.model.OllamaChatModel;
import io.agentscope.core.model.ollama.OllamaOptions;
import io.agentscope.core.model.ollama.ThinkOption;
import io.agentscope.core.tool.Toolkit;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;

@Service
public class AgentService {

    @Autowired
    private OllamaChatModel ollamaChatModel;

    @Autowired
    private DashScopeChatModel dashScopeChatModel;

    // 也是一种注入方式
    @Setter(onMethod_ = @Autowired)
    private Toolkit toolkit;

    public Flux<String> chat(String message, String sessionId) {
        ReActAgent agent = ReActAgent.builder()
                .name("plan-notebook")
                .model(dashScopeChatModel)
                // 如果有自定义的工具可以在这获取到toolkit后 在补充注册工具
                .toolkit(toolkit.copy())
                .build();

        return agent.stream(Msg.builder().textContent(message).build()).
                map(event -> {
                    // 默认返回三词数据 第一个是流式输出、第二个是汇总的结果、第三个是结果
                    // 因为重复 只需要取一个就行
                    EventType type = event.getType();
                    if (type == EventType.REASONING && !event.isLast()) {
                        Msg msg = event.getMessage();
                        return msg.getTextContent();
                    }
                    return "";
                });
    }
}
  1. 测试接口返回
java 复制代码
package com.example.plannotebook.controller;

import com.example.plannotebook.service.AgentService;
import com.example.plannotebook.service.PlanService;
import lombok.AllArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

@AllArgsConstructor
@RequestMapping("/api")
@RestController
public class ChatController {

    private final PlanService planService;
    private final AgentService agentService;

    @GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chat(String message,
                             @RequestParam(required = false, defaultValue = "default") String sessionId) {
        return agentService.chat(message, sessionId);
    }
}

4.2、ReactAgent基础参数

工具集 + PlanNoteBook + 最大迭代次数

  1. 设置通用工具集(工作目录优化)
java 复制代码
package com.example.plannotebook.config;

import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter;
import io.agentscope.core.formatter.ollama.OllamaChatFormatter;
import io.agentscope.core.model.DashScopeChatModel;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.model.OllamaChatModel;
import io.agentscope.core.model.ollama.OllamaOptions;
import io.agentscope.core.model.ollama.ThinkOption;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.core.tool.file.ReadFileTool;
import io.agentscope.core.tool.file.WriteFileTool;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

@Configuration
public class AiConfig {
    @Bean
    public OllamaChatModel ollamaChatModel() {
        return OllamaChatModel.builder()
                .modelName("qwen3.5:0.8b")
                .defaultOptions(OllamaOptions.builder()
                        .thinkOption(ThinkOption.ThinkBoolean.ENABLED)
                        .build())
                // 默认也是这样的
                .formatter(new OllamaChatFormatter())
                .build();
    }

    @Bean
    public DashScopeChatModel dashScopeChatModel() {
        return DashScopeChatModel.builder()
                .stream(true)
                .enableThinking(true)
                .formatter(new DashScopeChatFormatter())
                .apiKey(System.getProperty("DASHSCOPE_KEY"))
                .modelName(System.getProperty("DASHSCOPE_MODEL_NAME"))
                .baseUrl(System.getProperty("DASHSCOPE_BASE_URL"))
                .defaultOptions(GenerateOptions.builder()
                        // 思考模式的token上线
                        .thinkingBudget(8196)
                        .build())
                .build();
    }

    /**
     * 通用 注册工具
     */
    @Bean
    public Toolkit toolkit() {
        Toolkit toolkit = new Toolkit();
        String workDir = resolveWorkspaceRoot();
        toolkit.registerTool(new ReadFileTool(workDir));
        toolkit.registerTool(new WriteFileTool(workDir));
        return toolkit;
    }

    private String resolveWorkspaceRoot() {
        // 获取操作系统变量
        String workspace = System.getenv("PLAN_NOTEBOOK_WORKSPACE");
        Path root = workspace != null && !workspace.isBlank()
                ? Path.of(workspace).toAbsolutePath().normalize()
                // "C:\Users\Administrator\.agentscope\plan-notebook\workspace"
                : Path.of(System.getProperty("user.home"), ".agentscope", "plan-notebook", "workspace").toAbsolutePath().normalize();
        try {
            Files.createDirectories(root);
        } catch (IOException e) {
            throw new RuntimeException("Failed to create workspace directory " + root, e);
        }
        return root.toString();
    }
}
  1. planservice补充plannotebook
java 复制代码
package com.example.plannotebook.service;

import io.agentscope.core.plan.PlanNotebook;
import lombok.Getter;
import lombok.Setter;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Sinks;

@Service
public class PlanService {

    @Getter
    @Setter
    private PlanNotebook planNotebook;

    // 创建一个Sinks对象, many: 发送多个元素, multicast: 可以有多个订阅者. onBackpressureBuffer: 背压处理, 生产者处理不过来可以先存入缓存
    @Getter
    private final Sinks.Many<String> sink = Sinks.many().multicast().onBackpressureBuffer();
}
  1. agent参数设置
java 复制代码
package com.example.plannotebook.service;

import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.EventType;
import io.agentscope.core.agent.StreamOptions;
import io.agentscope.core.memory.InMemoryMemory;
import io.agentscope.core.message.Msg;
import io.agentscope.core.model.DashScopeChatModel;
import io.agentscope.core.model.OllamaChatModel;
import io.agentscope.core.tool.Toolkit;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;

@Service
public class AgentService {

    @Autowired
    private OllamaChatModel ollamaChatModel;

    @Autowired
    private DashScopeChatModel dashScopeChatModel;

    // 也是一种注入方式
    @Setter(onMethod_ = @Autowired)
    private Toolkit toolkit;


    @Autowired
    private PlanService planService;

    public Flux<String> chat(String message, String sessionId) {
        ReActAgent agent = ReActAgent.builder()
                .name("plan-notebook")
                .model(dashScopeChatModel)
                // 如果有自定义的工具可以在这获取到toolkit后 在补充注册工具
                .toolkit(toolkit.copy())
                .memory(new InMemoryMemory())
                .planNotebook(planService.getPlanNotebook())
                .maxIters(50)
                .build();

        return agent.stream(Msg.builder().textContent(message).build(),
                        getStreamOptions())
                // 不阻塞主线程
                .subscribeOn(Schedulers.boundedElastic())
                .map(event -> {
                    // 默认返回三词数据 第一个是流式输出、第二个是汇总的结果、第三个是结果
                    // 因为重复 只需要取一个就行
                    EventType type = event.getType();
                    if (type == EventType.REASONING && !event.isLast()) {
                        Msg msg = event.getMessage();
                        return msg.getTextContent();
                    }
                    return "";
                });
    }

    private StreamOptions getStreamOptions() {
        return StreamOptions.builder()
                .eventTypes(EventType.REASONING, EventType.TOOL_RESULT, EventType.AGENT_RESULT)
                // 只发新增内容
                .incremental(true)
                // 隐藏执行过程
                .includeActingChunk(false)
                .build();
    }
}

4.3、添加Session并返回前端规定格式

java 复制代码
package com.example.plannotebook.service;

import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.EventType;
import io.agentscope.core.agent.StreamOptions;
import io.agentscope.core.memory.InMemoryMemory;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.ThinkingBlock;
import io.agentscope.core.model.DashScopeChatModel;
import io.agentscope.core.model.OllamaChatModel;
import io.agentscope.core.session.InMemorySession;
import io.agentscope.core.session.Session;
import io.agentscope.core.tool.Toolkit;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
import tools.jackson.databind.ObjectMapper;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
public class AgentService {

    @Autowired
    private OllamaChatModel ollamaChatModel;

    @Autowired
    private DashScopeChatModel dashScopeChatModel;

    // 也是一种注入方式
    @Setter(onMethod_ = @Autowired)
    private Toolkit toolkit;

    @Autowired
    private PlanService planService;

    private static final Session session = new InMemorySession();
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    public Flux<String> chat(String message, String sessionId) {
        ReActAgent agent = ReActAgent.builder()
                .sysPrompt("""
                        你是一位专业的系统化任务规划与执行助手,擅长处理复杂的多步骤任务。
                        
                        核心能力:
                        1. **结构化规划**: 接收任务后,先进行需求分析,制定清晰的执行路线图
                        2. **任务分解**: 将复杂目标拆解为逻辑清晰、可执行的子任务序列
                        3. **渐进式执行**: 按照优先级和依赖关系逐步推进,每步都验证结果
                        4. **动态调整**: 根据实际情况灵活调整计划,确保最终目标达成
                        5. **质量把控**: 在每个关键节点进行检查,保证输出质量
                        
                        工作方式:
                        - 主动澄清模糊需求,确保理解准确
                        - 提供透明的进度反馈和决策依据
                        - 在必要时提出优化建议或替代方案
                        - 保持与用户的持续沟通和对齐
                        """)
                .name("plan-notebook")
                .model(dashScopeChatModel)
                // 如果有自定义的工具可以在这获取到toolkit后 在补充注册工具
                .toolkit(toolkit.copy())
                .memory(new InMemoryMemory())
                .planNotebook(planService.getPlanNotebook())
                .maxIters(50)
                .build();
        agent.loadIfExists(session, sessionId);
        return agent.stream(Msg.builder().textContent(message).build(),
                        getStreamOptions())
                // 不阻塞主线程
                .subscribeOn(Schedulers.boundedElastic())
                .map(event -> {
                    // 默认返回三词数据 第一个是流式输出、第二个是汇总的结果、第三个是结果
                    // 因为重复 只需要取一个就行
                    EventType type = event.getType();
                    if (type == EventType.REASONING && !event.isLast()) {
                        Msg msg = event.getMessage();

                        List<ThinkingBlock> thinkingBlocks = msg.getContentBlocks(ThinkingBlock.class);
                        if (!thinkingBlocks.isEmpty()) {
                            String think = thinkingBlocks.stream()
                                    .map(ThinkingBlock::getThinking)
                                    .collect(Collectors.joining());
                            // 前端规定格式: t表示类型, d表示内容
                            return OBJECT_MAPPER.writeValueAsString(Map.of("t", "think", "d", think));

                        }

                        return OBJECT_MAPPER.writeValueAsString(Map.of("t", "text", "d", msg.getTextContent()));
                    }
                    return "";
                })
                .doFinally(signalType -> agent.saveTo(session, sessionId));
    }

    private StreamOptions getStreamOptions() {
        return StreamOptions.builder()
                .eventTypes(EventType.REASONING, EventType.TOOL_RESULT, EventType.AGENT_RESULT)
                // 只发新增内容
                .incremental(true)
                // 隐藏执行过程
                .includeActingChunk(false)
                .build();
    }
}

4.4、处理header及优化代码结构

  1. header格式优化工具
java 复制代码
package com.example.plannotebook.util;

import io.agentscope.core.message.*;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class FormatOutputUtil {

    private static final int CTX_FIELD_MAX_CHARS = 8_000;

    /**
     * 将消息列表转换为调试行格式用于 SSE 上下文。
     *
     * @param msgs 要转换的消息列表
     * @return 包含角色、名称(可选)和内容块的 Map 列表
     */
    public static List<Map<String, Object>> msgToDebugRows(List<Msg> msgs) {
        // 如果消息列表为空,则返回一个空列表
        if (msgs == null || msgs.isEmpty()) {
            return List.of();
        }
        return msgs.stream().map(FormatOutputUtil::msgToDebugRow).toList();
    }

    /**
     * 将消息转换为调试行格式用于 SSE 上下文。
     *
     * @param m 要转换的消息
     * @return 包含角色、名称(可选)和内容块的 Map
     */
    public static Map<String, Object> msgToDebugRow(Msg m) {
        Map<String, Object> row = new LinkedHashMap<>();
        row.put("role", m.getRole().name());
        if (m.getName() != null) {
            row.put("name", m.getName());
        }
        // 将每个内容块转换为调试 Map
        List<Object> parts = new ArrayList<>();
        for (ContentBlock b : m.getContent()) {
            parts.add(contentBlockToDebugMap(b));
        }
        row.put("content", parts);
        return row;
    }

    /**
     * 将内容块转换为调试 Map 格式。
     * 处理 TextBlock、ThinkingBlock、ToolUseBlock 和 ToolResultBlock。
     *
     * @param b 要转换的内容块
     * @return 包含类型特定字段的 Map 表示
     */
    private static Object contentBlockToDebugMap(ContentBlock b) {
        if (b instanceof TextBlock tb) {
            return Map.of(
                    "type",
                    "text",
                    "text",
                    truncateIfNeeded(
                            tb.getText() != null ? tb.getText() : "", CTX_FIELD_MAX_CHARS));
        }
        if (b instanceof ThinkingBlock th) {
            return Map.of(
                    "type",
                    "thinking",
                    "thinking",
                    truncateIfNeeded(
                            th.getThinking() != null ? th.getThinking() : "", CTX_FIELD_MAX_CHARS));
        }
        if (b instanceof ToolUseBlock tu) {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("type", "tool_use");
            m.put("id", tu.getId());
            m.put("name", tu.getName());
            m.put("input", tu.getInput() != null ? tu.getInput() : Map.of());
            String raw = tu.getContent();
            if (raw != null && !raw.isEmpty()) {
                m.put("raw", truncateIfNeeded(raw, CTX_FIELD_MAX_CHARS));
            }
            return m;
        }
        if (b instanceof ToolResultBlock tr) {
            return Map.of(
                    "type",
                    "tool_result",
                    "name",
                    tr.getName() != null ? tr.getName() : "",
                    "output",
                    truncateIfNeeded(flattenToolOutput(tr), CTX_FIELD_MAX_CHARS));
        }
        return Map.of(
                "type", "other", "repr", truncateIfNeeded(String.valueOf(b), CTX_FIELD_MAX_CHARS));
    }

    /**
     * 从消息列表构建扁平化的文本转录本。
     * 用于上下文调试和可视化。
     *
     * @param messages 要转换的消息列表
     * @return 格式化后的转录本字符串
     */
    public static String buildFlatTranscript(List<Msg> messages) {
        StringBuilder sb = new StringBuilder();
        for (Msg m : messages) {
            sb.append("--- ").append(m.getRole().name());
            if (m.getName() != null) {
                sb.append(" (").append(m.getName()).append(')');
            }
            sb.append(" ---\n");
            for (ContentBlock b : m.getContent()) {
                appendContentBlockForFlat(sb, b);
            }
            sb.append('\n');
        }
        return sb.toString();
    }

    /**
     * 将内容块追加到扁平化转录本构建器。
     * 适当地格式化不同类型的内容块。
     *
     * @param sb 要追加的 StringBuilder
     * @param o 要追加的内容块
     */
    private static void appendContentBlockForFlat(StringBuilder sb, ContentBlock o) {
        if (o instanceof TextBlock tb) {
            sb.append(tb.getText() != null ? tb.getText() : "");
        } else if (o instanceof ThinkingBlock th) {
            sb.append(th.getThinking() != null ? th.getThinking() : "");
        } else if (o instanceof ToolUseBlock tu) {
            sb.append("[tool_use ")
                    .append(tu.getName())
                    .append(" id=")
                    .append(tu.getId())
                    .append("] ");
            sb.append(tu.getInput() != null ? tu.getInput().toString() : "{}");
            String raw = tu.getContent();
            if (raw != null && !raw.isEmpty()) {
                sb.append(" raw=").append(raw);
            }
            sb.append('\n');
        } else if (o instanceof ToolResultBlock tr) {
            sb.append("[tool_result ").append(tr.getName()).append("]\n");
            sb.append(flattenToolOutput(tr));
            sb.append('\n');
        } else {
            sb.append(o);
        }
    }

    /**
     * 如果字符串超过最大长度则进行截断。
     *
     * @param s 要截断的字符串
     * @param max 允许的最大长度
     * @return 原始字符串或带有截断指示器的截断后字符串
     */
    public static String truncateIfNeeded(String s, int max) {
        if (s == null) {
            return "";
        }
        if (s.length() <= max) {
            return s;
        }
        return s.substring(0, max) + "\n...(truncated)";
    }

    /**
     * 将工具输出块扁平化为单个字符串。
     * 递归处理嵌套的内容块。
     *
     * @param block 要扁平化的工具结果块
     * @return 扁平化的输出字符串
     */
    public static String flattenToolOutput(ToolResultBlock block) {
        StringBuilder sb = new StringBuilder();
        for (ContentBlock o : block.getOutput()) {
            appendContentBlock(sb, o);
        }
        return sb.toString();
    }

    /**
     * 将内容块追加到 StringBuilder。
     * 递归处理 TextBlock、ThinkingBlock 和 ToolResultBlock。
     *
     * @param sb 要追加的 StringBuilder
     * @param o 要追加的内容块
     */
    private static void appendContentBlock(StringBuilder sb, ContentBlock o) {
        if (o instanceof TextBlock tb) {
            sb.append(tb.getText());
        } else if (o instanceof ThinkingBlock th) {
            sb.append(th.getThinking());
        } else if (o instanceof ToolResultBlock tr) {
            sb.append(flattenToolOutput(tr));
        } else {
            sb.append(o);
        }
    }

}
  1. 拦截推理前和摘要前的hook
java 复制代码
package com.example.plannotebook.hook;

import io.agentscope.core.hook.Hook;
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PreReasoningEvent;
import io.agentscope.core.hook.PreSummaryEvent;
import io.agentscope.core.message.Msg;
import org.apache.logging.log4j.util.TriConsumer;
import reactor.core.publisher.Mono;

import java.util.List;

public class PromptCaptureHook implements Hook {

    // 阶段, 模型名称, 内容
    private final TriConsumer<String, String, List<Msg>> consumer;

    public PromptCaptureHook(TriConsumer<String, String, List<Msg>> consumer) {
        this.consumer = consumer;
    }


    /**
     * 把思考前、摘要前的内容 添加consumer中
     * @param event
     * @param <T>
     * @return
     */
    @Override
    public <T extends HookEvent> Mono<T> onEvent(T event) {
        if (event instanceof PreReasoningEvent e) {
            // 推理前拦截
            consumer.accept("reasoning", e.getModelName(), e.getInputMessages());
        } else if (event instanceof PreSummaryEvent e) {
            // 摘要前拦截
            consumer.accept("summary", e.getModelName(), e.getInputMessages());
        }
        return Mono.just(event);
    }

    @Override
    public int priority() {
        return 1000;
    }
}
  1. 处理返回结果
java 复制代码
package com.example.plannotebook.service;

import com.example.plannotebook.hook.PromptCaptureHook;
import com.example.plannotebook.util.FormatOutputUtil;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.Event;
import io.agentscope.core.agent.EventType;
import io.agentscope.core.agent.StreamOptions;
import io.agentscope.core.memory.InMemoryMemory;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.ThinkingBlock;
import io.agentscope.core.model.DashScopeChatModel;
import io.agentscope.core.model.OllamaChatModel;
import io.agentscope.core.session.InMemorySession;
import io.agentscope.core.session.Session;
import io.agentscope.core.tool.Toolkit;
import lombok.Setter;
import org.jspecify.annotations.NonNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
import tools.jackson.databind.ObjectMapper;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

@Service
public class AgentService {

    @Autowired
    private OllamaChatModel ollamaChatModel;

    @Autowired
    private DashScopeChatModel dashScopeChatModel;

    // 也是一种注入方式
    @Setter(onMethod_ = @Autowired)
    private Toolkit toolkit;

    @Autowired
    private PlanService planService;

    private static final Session session = new InMemorySession();
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    private final AtomicInteger contextSeq = new AtomicInteger(0);
    private final ConcurrentLinkedQueue<String> contextLines = new ConcurrentLinkedQueue<>();
    private static final int CTX_FLAT_MAX_CHARS = 48_000;

    public Flux<String> chat(String message, String sessionId) {
        ReActAgent agent = getReActAgent(sessionId);
        return agent.stream(Msg.builder().textContent(message).build(),
                        getStreamOptions())
                // 不阻塞主线程
                .subscribeOn(Schedulers.boundedElastic())
                .concatMap(this::concatContextLines)
                .doFinally(signalType -> agent.saveTo(session, sessionId));
    }

    private @NonNull ReActAgent getReActAgent(String sessionId) {
        ReActAgent agent = ReActAgent.builder()
                .sysPrompt("""
                        你是一位专业的系统化任务规划与执行助手,擅长处理复杂的多步骤任务。
                        
                        核心能力:
                        1. **结构化规划**: 接收任务后,先进行需求分析,制定清晰的执行路线图
                        2. **任务分解**: 将复杂目标拆解为逻辑清晰、可执行的子任务序列
                        3. **渐进式执行**: 按照优先级和依赖关系逐步推进,每步都验证结果
                        4. **动态调整**: 根据实际情况灵活调整计划,确保最终目标达成
                        5. **质量把控**: 在每个关键节点进行检查,保证输出质量
                        
                        工作方式:
                        - 主动澄清模糊需求,确保理解准确
                        - 提供透明的进度反馈和决策依据
                        - 在必要时提出优化建议或替代方案
                        - 保持与用户的持续沟通和对齐
                        """)
                .name("plan-notebook")
                .model(dashScopeChatModel)
                // 如果有自定义的工具可以在这获取到toolkit后 在补充注册工具
                .toolkit(toolkit.copy())
                .memory(new InMemoryMemory())
                .planNotebook(planService.getPlanNotebook())
                .maxIters(50)
                .hook(new PromptCaptureHook(this::offerContextLines))
                .build();
        agent.loadIfExists(session, sessionId);
        return agent;
    }

    /**
     * 拼接流式输出结果 + header头信息
     * @param event
     * @return
     */
    private @NonNull Flux<String> concatContextLines(Event event) {
        // 获取header 然后先发送header 在发送原来的数据
        List<String> headers = pollContextLines();
        Flux<String> headerFlux = Flux.fromIterable(headers);
        // 流式输出返回结果 转换成String
        String body = convertEventToString(event);

        if (headers.isEmpty() && body.isEmpty()) {
            return Flux.empty();
        }

        if (body.isEmpty()) {
            return headerFlux;
        }
        // 先发header, 然后在返回s
        return headerFlux.concatWith(Flux.just(body));
    }

    private @NonNull List<String> pollContextLines() {
        String poll;
        List<String> headers = new ArrayList<>();
        while ((poll = contextLines.poll()) != null) {
            headers.add(poll);
        }
        return headers;
    }

    private static String convertEventToString(Event event) {
        // 默认返回三词数据 第一个是流式输出、第二个是汇总的结果、第三个是结果
        // 因为重复 只需要取一个就行
        EventType type = event.getType();
        if (type == EventType.REASONING && !event.isLast()) {
            Msg msg = event.getMessage();

            List<ThinkingBlock> thinkingBlocks = msg.getContentBlocks(ThinkingBlock.class);
            if (!thinkingBlocks.isEmpty()) {
                String think = thinkingBlocks.stream()
                        .map(ThinkingBlock::getThinking)
                        .collect(Collectors.joining());
                // 前端规定格式: t表示类型, d表示内容
                return OBJECT_MAPPER.writeValueAsString(Map.of("t", "think", "d", think));

            }

            String textContent = msg.getTextContent();
            if (textContent == null || textContent.isEmpty()) {
                return "";
            }
            return OBJECT_MAPPER.writeValueAsString(Map.of("t", "text", "d", textContent));
        }
        return "";
    }

    private void offerContextLines(String phase, String modelName, List<Msg> messages) {
        int seq = contextSeq.incrementAndGet();
        Map<String, Object> root = new LinkedHashMap<>();
        root.put("t", "ctx");
        root.put("phase", phase);
        root.put("seq", seq);
        root.put("model", modelName);
        root.put("messages", FormatOutputUtil.msgToDebugRows(messages));
        root.put("flat", FormatOutputUtil.truncateIfNeeded(
                FormatOutputUtil.buildFlatTranscript(messages), CTX_FLAT_MAX_CHARS
        ));
        contextLines.add(OBJECT_MAPPER.writeValueAsString(root));
    }

    private StreamOptions getStreamOptions() {
        return StreamOptions.builder()
                .eventTypes(EventType.REASONING, EventType.TOOL_RESULT, EventType.AGENT_RESULT)
                // 只发新增内容
                .incremental(true)
                // 隐藏执行过程
                .includeActingChunk(false)
                .build();
    }
}

五、当前计划

5.1、请求响应DTO

  1. 计划请求
java 复制代码
package com.example.plannotebook.dto;

import java.util.List;
import java.util.Map;

public record PlanRequest(
        String name,
        String description,
        String expectedOutcome,
        List<Map<String, Object>> subtasks
) {
}
  1. 计划响应
java 复制代码
package cn.da.shuai.agentscope.learn.plannotebook.dto;

import io.agentscope.core.plan.model.Plan;
import java.util.ArrayList;
import java.util.List;

/**
 * DTO for plan response.
 */
public class PlanResponse {

    private String id;
    private String name;
    private String description;
    private String expectedOutcome;
    private String state;
    private String createdAt;
    private List<SubTaskResponse> subtasks;

    public PlanResponse() {
        this.subtasks = new ArrayList<>();
    }

    public static PlanResponse fromPlan(Plan plan) {
        if (plan == null) {
            return null;
        }
        PlanResponse response = new PlanResponse();
        response.setId(plan.getId());
        response.setName(plan.getName());
        response.setDescription(plan.getDescription());
        response.setExpectedOutcome(plan.getExpectedOutcome());
        response.setState(plan.getState().getValue());
        response.setCreatedAt(plan.getCreatedAt());

        List<SubTaskResponse> subtaskResponses = new ArrayList<>();
        if (plan.getSubtasks() != null) {
            for (int i = 0; i < plan.getSubtasks().size(); i++) {
                subtaskResponses.add(SubTaskResponse.fromSubTask(plan.getSubtasks().get(i), i));
            }
        }
        response.setSubtasks(subtaskResponses);

        return response;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public String getExpectedOutcome() {
        return expectedOutcome;
    }

    public void setExpectedOutcome(String expectedOutcome) {
        this.expectedOutcome = expectedOutcome;
    }

    public String getState() {
        return state;
    }

    public void setState(String state) {
        this.state = state;
    }

    public String getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(String createdAt) {
        this.createdAt = createdAt;
    }

    public List<SubTaskResponse> getSubtasks() {
        return subtasks;
    }

    public void setSubtasks(List<SubTaskResponse> subtasks) {
        this.subtasks = subtasks;
    }
}
  1. 子计划请求
java 复制代码
package cn.da.shuai.agentscope.learn.plannotebook.dto;

/**
 * DTO for subtask creation/update request.
 */
public class SubTaskRequest {

    private String name;
    private String description;
    private String expectedOutcome;

    public SubTaskRequest() {}

    public SubTaskRequest(String name, String description, String expectedOutcome) {
        this.name = name;
        this.description = description;
        this.expectedOutcome = expectedOutcome;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public String getExpectedOutcome() {
        return expectedOutcome;
    }

    public void setExpectedOutcome(String expectedOutcome) {
        this.expectedOutcome = expectedOutcome;
    }
}
  1. 子计划响应
java 复制代码
package cn.da.shuai.agentscope.learn.plannotebook.dto;

import io.agentscope.core.plan.model.SubTask;

/**
 * 子任务响应 DTO。
 * 用于 API 响应中表示子任务的状态和信息。
 */
public class SubTaskResponse {

    /** 子任务在计划中的索引位置 */
    private int index;

    /** 子任务名称 */
    private String name;

    /** 子任务描述 */
    private String description;

    /** 期望的产出结果 */
    private String expectedOutcome;

    /** 子任务状态(todo, in_progress, done, abandoned) */
    private String state;

    /** 实际产出结果 */
    private String outcome;

    /** 创建时间 */
    private String createdAt;

    /**
     * 默认构造函数,用于 JSON 反序列化。
     */
    public SubTaskResponse() {}

    /**
     * 从 SubTask 领域对象转换为 SubTaskResponse DTO。
     *
     * @param subtask 子任务对象
     * @param index 子任务在计划中的索引
     * @return SubTaskResponse DTO 实例
     */
    public static SubTaskResponse fromSubTask(SubTask subtask, int index) {
        SubTaskResponse response = new SubTaskResponse();
        // 设置子任务的各个属性
        response.setIndex(index);
        response.setName(subtask.getName());
        response.setDescription(subtask.getDescription());
        response.setExpectedOutcome(subtask.getExpectedOutcome());
        response.setState(subtask.getState().getValue());
        response.setOutcome(subtask.getOutcome());
        response.setCreatedAt(subtask.getCreatedAt());
        return response;
    }

    /**
     * 获取子任务索引。
     *
     * @return 子任务在计划中的位置
     */
    public int getIndex() {
        return index;
    }

    /**
     * 设置子任务索引。
     *
     * @param index 子任务在计划中的位置
     */
    public void setIndex(int index) {
        this.index = index;
    }

    /**
     * 获取子任务名称。
     *
     * @return 子任务名称
     */
    public String getName() {
        return name;
    }

    /**
     * 设置子任务名称。
     *
     * @param name 子任务名称
     */
    public void setName(String name) {
        this.name = name;
    }

    /**
     * 获取子任务描述。
     *
     * @return 子任务描述
     */
    public String getDescription() {
        return description;
    }

    /**
     * 设置子任务描述。
     *
     * @param description 子任务描述
     */
    public void setDescription(String description) {
        this.description = description;
    }

    /**
     * 获取期望产出。
     *
     * @return 期望的产出结果
     */
    public String getExpectedOutcome() {
        return expectedOutcome;
    }

    /**
     * 设置期望产出。
     *
     * @param expectedOutcome 期望的产出结果
     */
    public void setExpectedOutcome(String expectedOutcome) {
        this.expectedOutcome = expectedOutcome;
    }

    /**
     * 获取子任务状态。
     *
     * @return 子任务状态(todo, in_progress, done, abandoned)
     */
    public String getState() {
        return state;
    }

    /**
     * 设置子任务状态。
     *
     * @param state 子任务状态
     */
    public void setState(String state) {
        this.state = state;
    }

    /**
     * 获取实际产出。
     *
     * @return 子任务的实际产出结果
     */
    public String getOutcome() {
        return outcome;
    }

    /**
     * 设置实际产出。
     *
     * @param outcome 子任务的实际产出结果
     */
    public void setOutcome(String outcome) {
        this.outcome = outcome;
    }

    /**
     * 获取创建时间。
     *
     * @return ISO 格式的时间戳
     */
    public String getCreatedAt() {
        return createdAt;
    }

    /**
     * 设置创建时间。
     *
     * @param createdAt ISO 格式的时间戳
     */
    public void setCreatedAt(String createdAt) {
        this.createdAt = createdAt;
    }
}

5.2、主计划

5.2.1、获取当前计划

  1. service
java 复制代码
package com.example.plannotebook.service;

import com.example.plannotebook.dto.PlanResponse;
import io.agentscope.core.plan.PlanNotebook;
import lombok.Getter;
import lombok.Setter;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;

@Service
public class PlanService {

    @Getter
    @Setter
    private PlanNotebook planNotebook;

    // 创建一个Sinks对象, many: 发送多个元素, multicast: 可以有多个订阅者. onBackpressureBuffer: 背压处理, 生产者处理不过来可以先存入缓存
    @Getter
    private final Sinks.Many<PlanResponse> sink = Sinks.many().multicast().onBackpressureBuffer();

    public PlanResponse getCurrentPlan() {
        if (planNotebook == null) {
            return new PlanResponse();
        }
        return PlanResponse.fromPlan(planNotebook.getCurrentPlan());
    }

    public Flux<PlanResponse> getCurrentPlanStream() {
        return Flux.concat(Flux.just(getCurrentPlan()), sink.asFlux());
    }
}
  1. controller
java 复制代码
package com.example.plannotebook.controller;

import com.example.plannotebook.dto.PlanResponse;
import com.example.plannotebook.service.PlanService;
import lombok.AllArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

@RestController
@AllArgsConstructor
@RequestMapping("/api/plan")
public class PlanController {
    private final PlanService planService;

    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<PlanResponse> getPlanStream() {
        return planService.getCurrentPlanStream();
    }
}

5.2.2、推送计划

  1. 当planNoteBook发生改变的时候推送消息
java 复制代码
private @NonNull ReActAgent getReActAgent(String sessionId) {
    PlanNotebook planNotebook = PlanNotebook.builder().build();
    // 当planNotebook发生变化 则调用该方法
    planNotebook.addChangeHook("planServiceBroadcastChange",
            (planNotebook1, plan) -> planService.broadcastPlanChant());
    planService.setPlanNotebook(planNotebook);

    ReActAgent agent = ReActAgent.builder()
            .sysPrompt("""
                    你是一位专业的系统化任务规划与执行助手,擅长处理复杂的多步骤任务。
                    
                    核心能力:
                    1. **结构化规划**: 接收任务后,先进行需求分析,制定清晰的执行路线图
                    2. **任务分解**: 将复杂目标拆解为逻辑清晰、可执行的子任务序列
                    3. **渐进式执行**: 按照优先级和依赖关系逐步推进,每步都验证结果
                    4. **动态调整**: 根据实际情况灵活调整计划,确保最终目标达成
                    5. **质量把控**: 在每个关键节点进行检查,保证输出质量
                    
                    工作方式:
                    - 主动澄清模糊需求,确保理解准确
                    - 提供透明的进度反馈和决策依据
                    - 在必要时提出优化建议或替代方案
                    - 保持与用户的持续沟通和对齐
                    """)
            .name("plan-notebook")
            .model(dashScopeChatModel)
            // 如果有自定义的工具可以在这获取到toolkit后 在补充注册工具
            .toolkit(toolkit.copy())
            .memory(new InMemoryMemory())
            .planNotebook(planNotebook)
            .maxIters(50)
            .hook(new PromptCaptureHook(this::offerContextLines))
            .build();
    agent.loadIfExists(session, sessionId);
    return agent;
}
  1. 具体推送方法
java 复制代码
package com.example.plannotebook.service;

import com.example.plannotebook.dto.PlanResponse;
import io.agentscope.core.plan.PlanNotebook;
import lombok.Getter;
import lombok.Setter;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;

@Service
public class PlanService {

    @Getter
    @Setter
    private PlanNotebook planNotebook;

    // 创建一个Sinks对象, many: 发送多个元素, multicast: 可以有多个订阅者. onBackpressureBuffer: 背压处理, 生产者处理不过来可以先存入缓存
    @Getter
    private final Sinks.Many<PlanResponse> sink = Sinks.many().multicast().onBackpressureBuffer();

    public PlanResponse getCurrentPlan() {
        if (planNotebook == null) {
            return new PlanResponse();
        }
        return PlanResponse.fromPlan(planNotebook.getCurrentPlan());
    }

    public Flux<PlanResponse> getCurrentPlanStream() {
        // 返回sink.asFlux是为了可以进行sse推送
        return Flux.concat(Flux.just(getCurrentPlan()), sink.asFlux());
    }

    public void broadcastPlanChant() {
        // 广播(推送) 当前计划
        sink.tryEmitNext(getCurrentPlan());
    }
}

5.2.3、创建计划

  1. 创建计划service
java 复制代码
package com.example.plannotebook.service;

import com.example.plannotebook.dto.PlanRequest;
import com.example.plannotebook.dto.PlanResponse;
import io.agentscope.core.plan.PlanNotebook;
import lombok.Getter;
import lombok.Setter;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;

@Service
public class PlanService {

    @Getter
    @Setter
    private PlanNotebook planNotebook;

    // 创建一个Sinks对象, many: 发送多个元素, multicast: 可以有多个订阅者. onBackpressureBuffer: 背压处理, 生产者处理不过来可以先存入缓存
    @Getter
    private final Sinks.Many<PlanResponse> sink = Sinks.many().multicast().onBackpressureBuffer();

    public PlanResponse getCurrentPlan() {
        if (planNotebook == null || planNotebook.getCurrentPlan() == null) {
            return new PlanResponse();
        }
        return PlanResponse.fromPlan(planNotebook.getCurrentPlan());
    }

    public Flux<PlanResponse> getCurrentPlanStream() {
        // 返回sink.asFlux是为了可以进行sse推送
        return Flux.concat(Flux.just(getCurrentPlan()), sink.asFlux());
    }

    public void broadcastPlanChant() {
        // 广播(推送) 当前计划
        sink.tryEmitNext(getCurrentPlan());
    }

    public Mono<String> createPlan(PlanRequest request) {
        // 解决没有聊天直接创建子任务 报planNotebook为null 方式一:
//        String sessionId = request.sessionId();
//        if (sessionId == null) {
//            sessionId = "default";
//        }
//
//        if (planNotebook == null) {
//            planNotebook = PlanNotebook.builder().build();
//        }
//
//        Mono<String> plan = planNotebook.createPlan(
//                request.name(),
//                request.description(),
//                request.expectedOutcome(),
//                request.subtasks()
//        );
//        planNotebook.saveTo(AgentService.session, sessionId);
//        return plan;

        return planNotebook.createPlan(
                request.name(),
                request.description(),
                request.expectedOutcome(),
                request.subtasks()
        );
    }
}
  1. 创建计划接口
java 复制代码
package com.example.plannotebook.controller;

import com.example.plannotebook.dto.PlanRequest;
import com.example.plannotebook.dto.PlanResponse;
import com.example.plannotebook.service.PlanService;
import lombok.AllArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
@AllArgsConstructor
@RequestMapping("/api/plan")
public class PlanController {
    private final PlanService planService;

    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<PlanResponse> getPlanStream() {
        return planService.getCurrentPlanStream();
    }

    @GetMapping
    public PlanResponse getCurrentPlan() {
        return planService.getCurrentPlan();
    }

    @PostMapping
    public Mono<String> createPlan(@RequestBody PlanRequest request) {
        if (request == null) {
            return Mono.just("请求体不能为空");
        }

        if (request.name() == null || request.name().isBlank()) {
            return Mono.just("计划名称不能为空");
        }

        return planService.createPlan(request);
    }
}
  1. 解决不聊天直接创建plannotebook为空的错误
java 复制代码
package com.example.plannotebook.dto;

import java.util.List;
import java.util.Map;

public record PlanRequest(
        String name,
        String description,
        String expectedOutcome,
        List<Map<String, Object>> subtasks,
        String sessionId
) {
}
java 复制代码
package com.example.plannotebook.service;

import com.example.plannotebook.hook.PromptCaptureHook;
import com.example.plannotebook.util.FormatOutputUtil;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.Event;
import io.agentscope.core.agent.EventType;
import io.agentscope.core.agent.StreamOptions;
import io.agentscope.core.memory.InMemoryMemory;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.ThinkingBlock;
import io.agentscope.core.model.DashScopeChatModel;
import io.agentscope.core.model.OllamaChatModel;
import io.agentscope.core.plan.PlanNotebook;
import io.agentscope.core.session.InMemorySession;
import io.agentscope.core.session.Session;
import io.agentscope.core.tool.Toolkit;
import jakarta.annotation.PostConstruct;
import lombok.Setter;
import org.jspecify.annotations.NonNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
import tools.jackson.databind.ObjectMapper;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

@Service
public class AgentService {

    @Autowired
    private OllamaChatModel ollamaChatModel;

    @Autowired
    private DashScopeChatModel dashScopeChatModel;

    // 也是一种注入方式
    @Setter(onMethod_ = @Autowired)
    private Toolkit toolkit;

    @Autowired
    private PlanService planService;

    public static final Session session = new InMemorySession();
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    private final AtomicInteger contextSeq = new AtomicInteger(0);
    private final ConcurrentLinkedQueue<String> contextLines = new ConcurrentLinkedQueue<>();
    private static final int CTX_FLAT_MAX_CHARS = 48_000;

    private ReActAgent agent;

    // // 解决没有聊天直接创建子任务 报planNotebook为null 方式二:
    @PostConstruct
    public void init() {
        agent = getReActAgent();
    }

    public Flux<String> chat(String message, String sessionId) {
//        ReActAgent agent = getReActAgent();
        agent.loadIfExists(session, sessionId);
        return agent.stream(Msg.builder().textContent(message).build(),
                        getStreamOptions())
                // 不阻塞主线程
                .subscribeOn(Schedulers.boundedElastic())
                .concatMap(this::concatContextLines)
                .doFinally(signalType -> agent.saveTo(session, sessionId));
    }

    private @NonNull ReActAgent getReActAgent() {
        PlanNotebook planNotebook = PlanNotebook.builder().build();
        // 当planNotebook发生变化 则调用该方法
        planNotebook.addChangeHook("planServiceBroadcastChange",
                (planNotebook1, plan) -> planService.broadcastPlanChant());
        planService.setPlanNotebook(planNotebook);

        ReActAgent agent = ReActAgent.builder()
                .sysPrompt("""
                        你是一位专业的系统化任务规划与执行助手,擅长处理复杂的多步骤任务。
                        
                        核心能力:
                        1. **结构化规划**: 接收任务后,先进行需求分析,制定清晰的执行路线图
                        2. **任务分解**: 将复杂目标拆解为逻辑清晰、可执行的子任务序列
                        3. **渐进式执行**: 按照优先级和依赖关系逐步推进,每步都验证结果
                        4. **动态调整**: 根据实际情况灵活调整计划,确保最终目标达成
                        5. **质量把控**: 在每个关键节点进行检查,保证输出质量
                        
                        工作方式:
                        - 主动澄清模糊需求,确保理解准确
                        - 提供透明的进度反馈和决策依据
                        - 在必要时提出优化建议或替代方案
                        - 保持与用户的持续沟通和对齐
                        """)
                .name("plan-notebook")
                .model(dashScopeChatModel)
                // 如果有自定义的工具可以在这获取到toolkit后 在补充注册工具
                .toolkit(toolkit.copy())
                .memory(new InMemoryMemory())
                .planNotebook(planNotebook)
                .maxIters(50)
                .hook(new PromptCaptureHook(this::offerContextLines))
                .build();
        return agent;
    }

    /**
     * 拼接流式输出结果 + header头信息
     * @param event
     * @return
     */
    private @NonNull Flux<String> concatContextLines(Event event) {
        // 获取header 然后先发送header 在发送原来的数据
        List<String> headers = pollContextLines();
        Flux<String> headerFlux = Flux.fromIterable(headers);
        // 流式输出返回结果 转换成String
        String body = convertEventToString(event);

        if (headers.isEmpty() && body.isEmpty()) {
            return Flux.empty();
        }

        if (body.isEmpty()) {
            return headerFlux;
        }
        // 先发header, 然后在返回s
        return headerFlux.concatWith(Flux.just(body));
    }

    private @NonNull List<String> pollContextLines() {
        String poll;
        List<String> headers = new ArrayList<>();
        while ((poll = contextLines.poll()) != null) {
            headers.add(poll);
        }
        return headers;
    }

    private static String convertEventToString(Event event) {
        // 默认返回三词数据 第一个是流式输出、第二个是汇总的结果、第三个是结果
        // 因为重复 只需要取一个就行
        EventType type = event.getType();
        if (type == EventType.REASONING && !event.isLast()) {
            Msg msg = event.getMessage();

            List<ThinkingBlock> thinkingBlocks = msg.getContentBlocks(ThinkingBlock.class);
            if (!thinkingBlocks.isEmpty()) {
                String think = thinkingBlocks.stream()
                        .map(ThinkingBlock::getThinking)
                        .collect(Collectors.joining());
                // 前端规定格式: t表示类型, d表示内容
                return OBJECT_MAPPER.writeValueAsString(Map.of("t", "think", "d", think));

            }

            String textContent = msg.getTextContent();
            if (textContent == null || textContent.isEmpty()) {
                return "";
            }
            return OBJECT_MAPPER.writeValueAsString(Map.of("t", "text", "d", textContent));
        }
        return "";
    }

    private void offerContextLines(String phase, String modelName, List<Msg> messages) {
        int seq = contextSeq.incrementAndGet();
        Map<String, Object> root = new LinkedHashMap<>();
        root.put("t", "ctx");
        root.put("phase", phase);
        root.put("seq", seq);
        root.put("model", modelName);
        root.put("messages", FormatOutputUtil.msgToDebugRows(messages));
        root.put("flat", FormatOutputUtil.truncateIfNeeded(
                FormatOutputUtil.buildFlatTranscript(messages), CTX_FLAT_MAX_CHARS
        ));
        contextLines.add(OBJECT_MAPPER.writeValueAsString(root));
    }

    private StreamOptions getStreamOptions() {
        return StreamOptions.builder()
                .eventTypes(EventType.REASONING, EventType.TOOL_RESULT, EventType.AGENT_RESULT)
                // 只发新增内容
                .incremental(true)
                // 隐藏执行过程
                .includeActingChunk(false)
                .build();
    }
}

5.2.4、更新计划

  1. service
java 复制代码
public Mono<String> updatePlan(PlanRequest request) {
    return planNotebook.updatePlanInfo(
            request.name(),
            request.description(),
            request.expectedOutcome()
    );
}
  1. controller
java 复制代码
@PutMapping
public Mono<String> updatePlan(@RequestBody PlanRequest request) {
    if (request == null) {
        return Mono.just("请求体不能为空");
    }

    if (request.name() == null || request.name().isBlank()) {
        return Mono.just("计划名称不能为空");
    }

    return planService.updatePlan(request);
}

5.3、子计划

5.3.1、服务类

java 复制代码
public Mono<String> addSubTask(Map<String, Object> request) {
    return planNotebook.reviseCurrentPlan((Integer) request.get("index"), "add", request);

}

public Mono<String> reviseSubTask(int index, SubTaskRequest request) {
    Map<String, Object> map = Map.of(
            "name", request.getName(),
            "description", request.getDescription(),
            "expected_outcome", request.getExpectedOutcome()
    );
    return planNotebook.reviseCurrentPlan(index, "revise", map);
}

public Mono<String> updateSubTaskState(int index, String state) {
    return planNotebook.updateSubtaskState(index, state);
}

public Mono<String> finishSubTask(int index, String outcome) {
    return planNotebook.finishSubtask(index, outcome);
}

public Mono<String> finishPlan(String state, String outcome) {
    return planNotebook.finishPlan(state, outcome);
}

public Mono<String> deleteSubTask(Integer index) {
    return planNotebook.reviseCurrentPlan(index, "delete", null);
}

5.3.2、接口

java 复制代码
@PostMapping("/subtasks")
public Mono<String> addSubTask(@RequestBody Map<String, Object> request) {
    Object expectedOutcome = request.get("expectedOutcome");
    if (expectedOutcome != null) {
        request.put("expected_outcome", expectedOutcome);
    }
    return planService.addSubTask(request);
}

@PutMapping("/subtasks/{index}")
public Mono<String> reviseSubTask(@PathVariable int index,
                                  @RequestBody SubTaskRequest request) {
    return planService.reviseSubTask(index, request);
}

@PatchMapping("/subtasks/{index}/state")
public Mono<String> updateSubTaskState(@PathVariable int index,
                                       @RequestBody Map<String, Object> request) {
    String state = (String) request.get("state");
    return planService.updateSubTaskState(index, state);
}

@PostMapping("/subtasks/{index}/finish")
public Mono<String> finishSubTask(@PathVariable int index,
                                  @RequestBody Map<String, Object> request) {
    String outcome = (String) request.get("outcome");
    return planService.finishSubTask(index, outcome);
}

@PostMapping("/finish")
public Mono<String> finishPlan(@RequestBody Map<String, Object> request) {
    String state = (String) request.get("state");
    String outcome = (String) request.get("outcome");
    return planService.finishPlan(state, outcome);
}

@DeleteMapping("/subtasks/{index}")
public Mono<String> deleteSubTask(@PathVariable Integer index) {
    return planService.deleteSubTask(index);
}

5.4、暂停执行计划

  1. 编写agent暂停的hook

遇到哪些工具暂停、设置一些变量方便通知

java 复制代码
package com.example.plannotebook.hook;

import io.agentscope.core.hook.Hook;
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostActingEvent;
import io.agentscope.core.message.ToolUseBlock;
import lombok.AllArgsConstructor;
import reactor.core.publisher.Mono;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

@AllArgsConstructor
public class PlanChangeHook implements Hook {

    private final ConcurrentLinkedQueue<Map<String, Object>> pendingToolInputs;

    private final AtomicBoolean requestedStopped;

    private final AtomicBoolean isPaused;

    private static final Set<String> PLAN_TOOL_NAMES =
            Set.of(
                    "create_plan",
                    "update_plan_info",
                    "revise_current_plan",
                    "update_subtask_state",
                    "finish_subtask",
                    "get_subtask_count",
                    "finish_plan",
                    "view_subtasks",
                    "view_historical_plans",
                    "recover_historical_plan");



    @Override
    public <T extends HookEvent> Mono<T> onEvent(T event) {
        if (event instanceof PostActingEvent e) {
            ToolUseBlock toolUse = e.getToolUse();
            Map<String, Object> inputs = new LinkedHashMap<>();
            if (toolUse != null && toolUse.getInput() != null) {
                inputs.putAll(toolUse.getInput());
            }
            pendingToolInputs.offer(inputs);

            if (toolUse != null
                    && PLAN_TOOL_NAMES.contains(toolUse.getName())
                    && requestedStopped.compareAndSet(true, false)) {
                isPaused.set(true);
                e.stopAgent();
            }
        }
        return Mono.just(event);
    }
}
  1. 服务类
java 复制代码
private final ConcurrentLinkedQueue<Map<String, Object>> pendingToolInputs = new ConcurrentLinkedQueue<>();

private final AtomicBoolean requestedStopped = new AtomicBoolean(false);

private final AtomicBoolean isPaused = new AtomicBoolean(false);

.hook(new PlanChangeHook(pendingToolInputs, requestedStopped, isPaused))



public boolean requestStop() {
    requestedStopped.set(true);
    return requestedStopped.get();
}

public boolean isRequestStopped() {
    return requestedStopped.get();
}

public boolean getPaused() {
    return isPaused.get();
}
  1. 暂停接口
java 复制代码
@PostMapping("/stop")
public Map<String, Object> requestStop(@RequestParam(defaultValue = "default") String sessionId) {
    return Map.of("message", "下一个工具执行完成后暂停", "stopRequested", agentService.requestStop());
}

@GetMapping("/stop-requested")
public Map<String, Object> getRequestStop(@RequestParam(defaultValue = "default") String sessionId) {
    return Map.of("stopRequested", agentService.isRequestStopped());
}

@GetMapping("/paused")
public Map<String, Object> paused(@RequestParam(defaultValue = "default") String sessionId) {
    return Map.of("message", "暂停成功", "pause", agentService.getPaused());
}

@GetMapping("/health")
public String health() {
    return "OK";
}

5.5、重置

  1. 重置服务类
java 复制代码
@PostConstruct
public void init() {
    contextLines.clear();
    pendingToolInputs.clear();
    contextSeq.set(0);
    agent = getReActAgent();
}

public String reset() {
    isPaused.set(false);
    requestedStopped.set(false);
    init();
    planService.broadcastPlanChant();
    return "ok";
}
  1. 重置接口类
java 复制代码
@PostMapping("/reset")
public String reset(@RequestParam(defaultValue = "default") String sessionId) {
    return agentService.reset();
}

5.6、工具结果返回

补充一个暂停结果返回

java 复制代码
 private String convertEventToString(Event event) {
        // 默认返回三词数据 第一个是流式输出、第二个是汇总的结果、第三个是结果
        // 因为重复 只需要取一个就行
        EventType type = event.getType();

        // 返回暂停结果
        if (type == EventType.AGENT_RESULT) {
            Msg msg = event.getMessage();
            if (msg != null && msg.getGenerateReason() == GenerateReason.ACTING_STOP_REQUESTED) {
                return OBJECT_MAPPER.writeValueAsString(Map.of("t", "paused"));
            }
            return "";
        }

        // 返回工具结果
        if(type == EventType.TOOL_RESULT) {
            Msg msg = event.getMessage();
            if (msg == null) {
                return "";
            }

            List<ToolResultBlock> toolResultBlocks = msg.getContentBlocks(ToolResultBlock.class);
            if (toolResultBlocks.isEmpty()) {
                return "";
            }
            ToolResultBlock toolResultBlock = toolResultBlocks.get(0);

            LinkedHashMap<Object, Object> toolResultMap = new LinkedHashMap<>();
            toolResultMap.put("n", msg.getName() == null ? "" : msg.getName());
            toolResultMap.put("t", "tool");
            toolResultMap.put("d", FormatOutputUtil.flattenToolOutput(toolResultBlock));
            Map<String, Object> poll = pendingToolInputs.poll();
            if (poll != null && !poll.isEmpty()) {
                toolResultMap.put("i", poll);
            }
            return OBJECT_MAPPER.writeValueAsString(toolResultMap);
        }

        if (type == EventType.REASONING && !event.isLast()) {
            Msg msg = event.getMessage();

            List<ThinkingBlock> thinkingBlocks = msg.getContentBlocks(ThinkingBlock.class);
            if (!thinkingBlocks.isEmpty()) {
                String think = thinkingBlocks.stream()
                        .map(ThinkingBlock::getThinking)
                        .collect(Collectors.joining());
                // 前端规定格式: t表示类型, d表示内容
                return OBJECT_MAPPER.writeValueAsString(Map.of("t", "think", "d", think));

            }

            String textContent = msg.getTextContent();
            if (textContent == null || textContent.isEmpty()) {
                return "";
            }
            return OBJECT_MAPPER.writeValueAsString(Map.of("t", "text", "d", textContent));
        }
        return "";
    }

5.7、恢复

  1. 服务类
java 复制代码
public Flux<String> resume(String sessionId) {
    if (isPaused.compareAndSet(true, false)) {
        pendingToolInputs.clear();
        contextSeq.set(0);
        contextLines.clear();
        return agent.stream(getStreamOptions())
                .subscribeOn(Schedulers.boundedElastic())
                .concatMap(this::concatContextLines)
                .doFinally(signalType -> agent.saveTo(session, sessionId));
    }
    return Flux.just("没有暂停或者已经恢复");
}
  1. 接口类
java 复制代码
/**
 * 恢复执行
 * @param sessionId
 * @return
 */
@GetMapping(value = "/resume", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> resume(@RequestParam(defaultValue = "default") String sessionId) {
    return agentService.resume(sessionId);
}