AgentScope Java 2.0.4 在 10 月 8 日发布,这一版把 RuntimeContext 改成了显式传递,每次调用有了自己的 AgentRun 句柄,工具配置也能按请求单独给。代价是 RuntimeContextAware、ToolInjection 等几处公开 API 被删掉,从 2.0.3 升上来的项目要改代码。HttpTransportConfig 的默认 HTTP 版本也变了,这一处编译能过,但行为不同。

下面先讲哪些地方会编译不过、怎么改,再用一个 Spring Boot 小项目把 AgentRun、RunControl 和请求级工具配置跑一遍。

升级会碰到的破坏性变更

这部分对照了 v2.0.4 发布说明,以及 v2.0.3 和 v2.0.4 两个 tag 的源码。

RuntimeContextAware 被删除

2.0.3 里它在 io.agentscope.core.hook 包下,已经标了 @Deprecated(forRemoval = true, since = "2.0.0")。Hook 或工具实现它,框架在调用前通过 setRuntimeContext 塞进来,调用后清空。

2.0.4 里这个接口没了,同时被删的还有 Agent 级的 getRuntimeContext() 和内部的 activeRc。发布说明写的是,RuntimeContext 现在通过 middleware 和工具参数显式传递(#3279)。

迁移的办法是在工具方法上直接加一个 RuntimeContext 类型的参数,2.0.4 的 ToolMethodInvoker 按参数类型识别并注入它。这个参数不加 @ToolParam,生成工具 schema 时只收带 @ToolParam 的参数,所以模型看不到它。middleware 的回调签名本来就带 RuntimeContext ctx,从参数里取即可。

// 2.0.4 写法:上下文作为参数传入
@Tool(name = "whoami", description = "返回当前会话信息")
public String whoami(RuntimeContext ctx) {
    return "user=" + ctx.getUserId() + ", session=" + ctx.getSessionId();
}

工具和 Skill 的共享状态 API 被删除

这部分来自 #3283。被删的公开类型有 RegisteredSkill、ToolkitAware、AG-UI 的 agui.model.ToolMergeMode 和 ToolInjection,被删的成员有 setInternalChunkCallback、syncToolGroupStates、isSkillActive、replaceMetaActiveGroups。

以前 AG-UI 适配层的做法是借用共享 Toolkit、改一下、再还原,并发请求之间会互相干扰。现在换成 core 里的 ToolRequestConfig,每次调用自己带一份,在调用时和 Agent 的注册表组合。用过 ToolInjection 的代码,要改成构造 ToolRequestConfig 放进 RuntimeContext,后面的实战部分有完整例子。

Harness 这边也去掉了 repository context supplier 和不带上下文的 skill 提升重载,skill 的查找、创建、编辑都要显式带上下文。不带上下文的仓库操作会落到默认命名空间。

HttpTransportConfig#getHttpVersion() 默认返回 null

2.0.3 里 Builder 的默认值是 HttpVersion.HTTP_2,2.0.4 改成了 null,含义是自动选择,明文请求走 HTTP/1.1,https 通过 ALPN 协商 HTTP/2。源码注释给了原因:在明文 URL 上显式用 HTTP/2 会触发 h2c 升级,vLLM、uvicorn 这类服务不一定支持(#3214)。

自己调用了这个 getter 的代码,拿到的可能是 null,不做空值判断会 NPE。本来依赖明文 h2c 的,要把版本显式写回去:

HttpTransportConfig config = HttpTransportConfig.builder()
        .httpVersion(HttpVersion.HTTP_2)
        .build();

https 地址不受影响。按源码注释,这个选项只有 JdkHttpTransport 会读。

其余几处

发布说明里还列了这些。消息的 role 参数被移除(#3045)。DashScopeMessage 和 OpenAIMessage 不再有 cacheControl 的 getter、setter 和 builder 方法,消息级的 cache_control 不再发出,缓存控制挪到了内容块上(#2878)。Gemini 的工具参数改为直接传原生 JSON Schema(#3051)。

在 Spring Boot 里跑 AgentRun 和请求级工具配置

项目用 Spring Boot 4.0.5、JDK 21。AgentScope 依赖都在 Maven Central,groupId 是 io.agentscope:

<dependency>
  <groupId>io.agentscope</groupId>
  <artifactId>agentscope-core</artifactId>
  <version>2.0.4</version>
</dependency>
<dependency>
  <groupId>io.agentscope</groupId>
  <artifactId>agentscope-extensions-model-openai-official</artifactId>
  <version>2.0.4</version>
</dependency>

第二个依赖只用来编译后文 Responses API 模型的构造代码,启动流程里没有用到它。

模型用的是一个本地桩模型,自己实现 io.agentscope.core.model.Model 接口,不联网,整个过程没有调用任何模型 API。它做三件事:记下每次请求带过来的工具名;输入以 whoami 开头时发起一次工具调用;可以按提示词挂一个闸门,闸门不开就一直不返回,用来制造正在运行的状态。下面是完整代码,import 省去:

public class StubModel implements Model {

    final Map<String, Set<String>> toolsSeen = new ConcurrentHashMap<>();
    final Map<String, Sinks.Empty<Void>> gates = new ConcurrentHashMap<>();

    public Sinks.Empty<Void> gate(String prompt) {
        return gates.computeIfAbsent(prompt, k -> Sinks.empty());
    }

    @Override
    public Flux<ChatResponse> stream(List<Msg> messages, List<ToolSchema> tools, GenerateOptions options) {
        String prompt = lastUserText(messages);
        Set<String> names = new TreeSet<>();
        if (tools != null) tools.forEach(t -> names.add(t.getName()));
        toolsSeen.put(prompt, names);

        boolean hasToolResult = messages.stream()
                .anyMatch(m -> m.getContent().stream().anyMatch(b -> b instanceof ToolResultBlock));
        if (prompt.startsWith("whoami") && !hasToolResult) {
            ToolUseBlock use = ToolUseBlock.builder()
                    .id("call-" + prompt).name("whoami").input(Map.of()).content("{}").build();
            return Flux.just(ChatResponse.builder().content(List.of(use))
                    .usage(new ChatUsage(1, 1, 0)).build());
        }
        String reply = hasToolResult ? "tool said: " + lastToolText(messages) : "stub reply to " + prompt;
        Flux<ChatResponse> out = Flux.just(ChatResponse.builder()
                .content(List.of(TextBlock.builder().text(reply).build()))
                .usage(new ChatUsage(1, 1, 0)).build());
        Sinks.Empty<Void> gate = gates.get(prompt);
        return gate == null ? out : gate.asMono().thenMany(out);
    }

    @Override
    public String getModelName() {
        return "local-stub";
    }

    private static String lastUserText(List<Msg> messages) {
        for (int i = messages.size() - 1; i >= 0; i--) {
            Msg m = messages.get(i);
            if (m.getRole() == MsgRole.USER) return m.getTextContent();
        }
        return "";
    }

    private static String lastToolText(List<Msg> messages) {
        for (int i = messages.size() - 1; i >= 0; i--) {
            for (var b : messages.get(i).getContent()) {
                if (b instanceof ToolResultBlock r) {
                    return r.getOutput().stream().filter(o -> o instanceof TextBlock)
                            .map(o -> ((TextBlock) o).getText()).findFirst().orElse("");
                }
            }
        }
        return "";
    }
}

Agent 注册成 Bean,工具箱里放两个工具,order_status 和上面那个 whoami:

@Bean
ReActAgent supportAgent(StubModel model) {
    Toolkit toolkit = new Toolkit();
    toolkit.registerTool(new DemoTools());
    return ReActAgent.builder().name("support").model(model).toolkit(toolkit).build();
}

同一个 Agent,两个会话看到不同的工具

ToolRequestConfig 是个 record,有两个字段。externalTools 是工具名到 SchemaOnlyTool 的映射,mergeMode 是合并方式。ToolMergeMode.EXTERNAL_ONLY 表示这次请求只暴露外部传进来的工具,Agent 自己注册的都藏起来。ToolRequestConfig.NONE 表示原样用共享 Toolkit。

SchemaOnlyTool crm = new SchemaOnlyTool(ToolSchema.builder()
        .name("crm_lookup").description("前端提供的 CRM 查询")
        .parameters(Map.of("type", "object", "properties", Map.of())).build());
ToolRequestConfig onlyCrm = new ToolRequestConfig(Map.of("crm_lookup", crm), ToolMergeMode.EXTERNAL_ONLY);

AgentRun<Msg> a = agent.prepareCall(List.of(user("tools-A")), ctx("alice", "s-A", onlyCrm));
AgentRun<Msg> b = agent.prepareCall(List.of(user("tools-B")), ctx("bob", "s-B", null));
Mono.zip(a.stream().single(), b.stream().single()).block(Duration.ofSeconds(10));

ctx 是个小工具方法,用 RuntimeContext.builder().userId(..).sessionId(..).toolRequestConfig(..) 拼出上下文。两次调用并发执行。

同一会话排队、取消、协作中断

prepareCall 返回 AgentRun<Msg>,prepareRun 返回 AgentRun<AgentEvent>。两者都只是准备,订阅 stream() 才开始执行,而且一个 AgentRun 只允许订阅一次。状态有 CREATED、QUEUED、RUNNING、COMPLETED、FAILED、CANCELLED 六种。

cancel() 立即取消订阅,排队中的也能取消。interrupt() 对运行中的 run 是协作式中断,对还没开始的等同于取消。这些行为在 RunControl 里实现,每个 AgentRun 持有一个。

model.gate("run-1");
model.gate("run-3");
AgentRun<Msg> r1 = agent.prepareCall(List.of(user("run-1")), ctx("carol", "s-C", null));
AgentRun<Msg> r2 = agent.prepareCall(List.of(user("run-2")), ctx("carol", "s-C", null));
AgentRun<Msg> r3 = agent.prepareCall(List.of(user("run-3")), ctx("carol", "s-C", null));

CompletableFuture<Msg> f1 = future(r1);
CompletableFuture<Msg> f2 = future(r2);
CompletableFuture<Msg> f3 = future(r3);

r2.cancel();                          // 取消排队中的 r2
model.gate("run-1").tryEmitEmpty();   // 放行 r1
f1.join();
r3.interrupt(user("stop"));           // r3 运行中,协作中断
model.gate("run-3").tryEmitEmpty();
f3.join();

future(run) 就是 run.stream().single().toFuture()。三个 run 用同一个 userId 和 sessionId。

第三段很短,发一句 whoami please,桩模型发起 whoami 工具调用,看工具里拿到的 RuntimeContext。

运行结果

mvn -B package -DskipTests
java -jar target/agentscope-run-demo-0.0.1.jar

构建输出 BUILD SUCCESS。日志级别调到了 WARN,下面是程序打印的全部内容:

[tools] session s-A sees [crm_lookup]
[tools] session s-B sees [order_status, whoami]
[tools] a=COMPLETED b=COMPLETED
[run] before subscribe: r1=CREATED runId-length=36
[run] subscribed: r1=RUNNING r2=QUEUED r3=QUEUED
[run] r2.cancel() -> true
[run] r2 future failed with CancellationException
[run] after cancel: r1=RUNNING r2=CANCELLED r3=QUEUED
[run] r1 reply: stub reply to run-1 status=COMPLETED
[run] r3 now RUNNING
[run] r3.interrupt() -> true
[run] r3 reason=INTERRUPTED status=COMPLETED
[run] r1.cancel() after completion -> false
[ctx] tool said: "user=dave, session=s-D"

前三行是工具隔离。s-A 只看到前端传进来的 crm_lookup,s-B 看到的是 Agent 自己注册的两个工具,两边互不影响。

中间那段是 AgentRun 的状态流转。同一会话的三个调用,只有 r1 在跑,r2、r3 排队。取消 r2 之后,它的订阅方收到 CancellationException,r1 不受影响,r3 也没有提前开始。r1 跑完,r3 才进入 RUNNING。已经结束的 r1 再调 cancel() 返回 false。

r3 那一行的 reason=INTERRUPTED status=COMPLETED 看着矛盾,其实是两层东西。RunControl.interrupt() 对运行中的 run 只是触发一个中断信号,不取消订阅。ReActAgent 观察到这个信号后,会给未完成的工具调用补上结果,然后返回一条正常的助手消息,消息的 GenerateReason 设为 INTERRUPTED。这条消息作为结果正常发出,AgentBase 在成功回调里把 run 标为 COMPLETED。所以 run 的状态说的是这次调用正常结束了,有没有被打断要看返回消息的 GenerateReason。

最后一行是工具从参数里拿到的会话信息,也就是 RuntimeContextAware 删掉之后的写法。

每个 AgentRun 有自己的 runId(),cancel() 和 interrupt() 作用于单次调用,不影响同一会话里的其他调用。

OpenAI Responses API 模块

2.0.4 新增了 agentscope-extensions-model-openai-official 模块(#3078),基于 OpenAI 官方的 openai-java SDK,2.0.3 的模型扩展目录里还没有它。核心类是 OpenAIResponsesChatModel,Builder 上有 apiKey、baseUrl、modelName、stream、generateOptions、contextWindowSize、strictTools、strictJsonSchema、formatter、additionalHeaders、proxy 等方法,消息格式化用配套的 ResponsesMultiAgentFormatter。

OpenAIResponsesChatModel model = OpenAIResponsesChatModel.builder()
        .apiKey(System.getenv("OPENAI_API_KEY"))
        .modelName(System.getenv("OPENAI_MODEL"))
        .stream(true)
        .build();

这段代码在示例项目里只参与编译,运行时没有调用。

MCP 元数据透传

#3255 让 MCP 工具调用可以带上 meta 字段。用法是往 RuntimeContext 里按类型放一个 McpMeta:

RuntimeContext ctx = RuntimeContext.builder()
        .userId("u").sessionId("s")
        .put(McpMeta.class, new McpMeta(Map.of("traceId", "abc-123")))
        .build();

McpTool 发 CallToolRequest 时,会把这些条目连同框架的 tool-call id 一起放进 meta。源码注释写明,只有以 McpMeta.class 为键注册的对象会被提取,上下文里的其他对象不会被带出去。多个来源可以用 McpMeta.merge 合并,同名键后者覆盖前者。

透传默认开启,有三个开关。连接级是 McpClientBuilder.propagateMeta(boolean)。在 Toolkit 注册 MCP 客户端时,propagateMeta(boolean) 设这个客户端的默认值,propagateMeta(String, boolean) 按工具名单独设置。Toolkit 里这个方法的 Javadoc 写的是:工具级设置优先于客户端默认值,二者再与 MCP 客户端 wrapper 上的连接级开关在调用时取逻辑与。连接级开关在每次调用时实时读取,它为 false 时任何工具都不会发出 meta。

这次更新还有重新设计的 AgentScope Service、个人微信 iLink 和钉钉 HTTP 回调渠道、MongoDB 存储扩展等,完整列表见 v2.0.3...v2.0.4 的变更记录。

— 感谢阅读 —

一起交流

分享你的思考,让讨论更进一步。