diff --git a/README.md b/README.md
index 77cbbba..3856206 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
A yoke is the wooden frame that makes oxen and horses pull in the direction you want. Yoke does the same for models: it is the runtime layer wrapped around a model so it can do real work reliably. It turns AgentScope's agent capabilities into a ready-to-use HTTP service, so you can focus on your business logic instead of glue code:
-- One endpoint for multi-turn conversations: `POST /api/chat`, plus `POST /api/chat/stream` that streams the reasoning process over SSE.
+- One endpoint for multi-turn conversations: `POST /api/chat`, plus `POST /api/chat/stream` that streams the reasoning process over SSE using the AG-UI protocol.
- A built-in `get_weather` tool queries current weather through the open-source [Open-Meteo](https://open-meteo.com) API (no key needed; its free tier is for non-commercial use only).
- A temporary agent is built for every request and closed afterwards; conversation history lives in a shared state store and is isolated per session.
- Models are plugged in through the official AgentScope starters: OpenAI-compatible APIs (works with DashScope, DeepSeek and others), Anthropic, Gemini and Ollama. Switching is a dependency and configuration change.
@@ -44,47 +44,65 @@ curl -X POST localhost:8080/api/chat \
# {"sessionId":"…","reply":"…"}
```
-Send the returned `sessionId` with later requests to continue the same conversation; conversations with different `sessionId`s are isolated from each other. Run the tests with `./gradlew build`.
+Send the returned `sessionId` with later requests to continue the same conversation; conversations with different `sessionId`s are isolated from each other. A `sessionId` you send yourself must follow the same rule as `threadId` below (letters, digits and `_.-`, 1 to 64 characters, not all dots), otherwise the request returns 400; a blank one is treated as missing. Run the tests with `./gradlew build`.
-### Streaming
+### Streaming (AG-UI)
-`POST /api/chat/stream` takes the same request body and pushes events (`text/event-stream`) while the agent is reasoning. Use `curl -N` to turn off buffering:
+`POST /api/chat/stream` speaks the [AG-UI](https://docs.ag-ui.com/concepts/events) protocol: the request body is a standard `RunAgentInput`, and the response is a `text/event-stream` of AG-UI events pushed while the agent is reasoning. Use `curl -N` to turn off buffering:
```bash
curl -N -X POST localhost:8080/api/chat/stream \
-H 'Content-Type: application/json' \
- -d '{"sessionId": "demo", "message": "What is the weather in Beijing?"}'
+ -d '{"threadId": "demo", "runId": "run-1", "messages": [{"id": "msg-1", "role": "user", "content": "What is the weather in Beijing?"}]}'
```
-Illustrative output (from a stub model):
+Request rules (any violation returns 400 before the stream starts):
+
+- `Content-Type` must be `application/json`, otherwise 415. This also stops a cross-site page from starting a run with a `text/plain` request that skips the CORS preflight
+- `threadId` is the session ID and is required: letters, digits and `_.-`, 1 to 64 characters, not all dots. It shares the same history as `sessionId` of `POST /api/chat`
+- `runId` is required and must not be blank (`RunAgentInput` requires it)
+- `messages` must not be empty, every message needs an `id` and a `role`, and the last one must be a `user` message with non-blank plain text (multimodal content blocks are not supported)
+- The server keeps the history itself and only uses the **last** `user` message. Earlier messages, `tools`, `context`, `state`, `forwardedProps` and `resume` are ignored, so a client cannot add tools to the agent or rewrite the history. Only the text of that message is kept: its `id`, `toolCalls` and `toolCallId` never reach the history, and the message ID is generated on the server. The adapter is also configured with `ToolMergeMode.AGENT_ONLY`, so only server-registered tools are ever used
+
+Illustrative output (values are made up, field names come from real runs against a stub model, long fields are elided). Only `data:` lines are sent; the event type is the `type` field in the JSON, there is no SSE `event:` name:
```
-event:textDelta
-data:{"text":"Let me check."}
+data:{"type":"RUN_STARTED","threadId":"demo","runId":"…","input":{…}}
+
+data:{"type":"TOOL_CALL_START","threadId":"demo","runId":"…","toolCallId":"call_1","toolCallName":"get_weather"}
+
+data:{"type":"TOOL_CALL_ARGS","threadId":"demo","runId":"…","toolCallId":"call_1","delta":"{\"city\":\"Beijing\"}"}
-event:toolCall
-data:{"id":"call_1","name":"get_weather","arguments":"{\"city\":\"Beijing\"}"}
+data:{"type":"TOOL_CALL_END","threadId":"demo","runId":"…","toolCallId":"call_1"}
-event:toolResult
-data:{"id":"call_1","name":"get_weather","state":"success","output":"\"Sunny, 25 C\""}
+data:{"type":"TOOL_CALL_RESULT","threadId":"demo","runId":"…","toolCallId":"call_1","content":"\"Sunny, 25 C\"","role":"tool","messageId":"…"}
-event:textDelta
-data:{"text":"It is sunny, 25 C."}
+data:{"type":"TEXT_MESSAGE_START","threadId":"demo","runId":"…","messageId":"…","role":"assistant"}
-event:done
-data:{"sessionId":"demo","text":"It is sunny, 25 C.","usage":{"inputTokens":83,"outputTokens":27,"totalTokens":110}}
+data:{"type":"TEXT_MESSAGE_CONTENT","threadId":"demo","runId":"…","messageId":"…","delta":"It is sunny, 25 C."}
+
+data:{"type":"TEXT_MESSAGE_END","threadId":"demo","runId":"…","messageId":"…"}
+
+data:{"type":"CUSTOM","threadId":"demo","runId":"…","name":"token_usage","value":{"delta":{…},"cumulative":{"inputTokens":83,"outputTokens":27,…},"modelCall":{…}}}
+
+data:{"type":"RUN_FINISHED","threadId":"demo","runId":"…"}
```
-The event names form a closed set:
+| Event `type` | Meaning |
+|---|---|
+| `RUN_STARTED` | Always the first event, carries `threadId` and `runId` |
+| `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` | Generated text; concatenate the `delta` of the `CONTENT` events in order to get the reply |
+| `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` | A tool the model decided to call; `TOOL_CALL_ARGS` carries fragments of the arguments JSON and only appears when the model streams the arguments in pieces |
+| `TOOL_CALL_RESULT` | The result of a tool, matched to the call by `toolCallId` |
+| `CUSTOM` with `name` = `token_usage` | Token usage after each model call, with `delta` and `cumulative` |
+| `CUSTOM` with `name` = `hint` | A hint the framework adds during reasoning, `value` has `source` and `text` |
+| `RUN_FINISHED` | The normal end |
+| `RUN_ERROR` | Reasoning failed; the HTTP status is still 200 and the stream ends normally instead of dropping the connection. The message is the raw exception message and is not redacted, so do not show it to end users directly |
-- `textDelta`: a chunk of generated text; concatenate them in order to get the reply
-- `toolCall`: a tool the model decided to call, with its name and arguments already assembled
-- `toolResult`: the result of a tool, matched to its `toolCall` by `id`
-- `hint`: a hint the framework adds during reasoning
-- `done`: the normal end, with the full reply, the `sessionId` (generated by the server if you did not send one) and the accumulated token usage
-- `error`: reasoning failed; the HTTP status is still 200 and the stream ends normally instead of dropping the connection. The message is the raw exception message and is not redacted, so do not show it to end users directly
+Every stream starts with `RUN_STARTED`, even when the agent cannot be created, and ends with either `RUN_FINISHED` or `RUN_ERROR`, never both. When the agent fails while running, the AG-UI adapter maps the exception to `code` (`TIMEOUT_ERROR`, `INTERRUPTED_ERROR`, `INVALID_INPUT_ERROR` or `INTERNAL_ERROR`); failures outside the adapter, such as the agent not being created, are always `INTERNAL_ERROR`. There is no `done` event and the final text is not repeated: the client concatenates `TEXT_MESSAGE_CONTENT`. The temporary agent is closed when the stream completes, fails or the client disconnects. The adapter's `runTimeout` setting is not read by AgentScope 2.0.4, so it does not limit the run time.
-Every stream ends with either `done` or `error`. A blank `message` returns 400 before the stream starts. The temporary agent is closed when the stream completes, fails or the client disconnects.
+> **Breaking change**
+> The earlier private events (`textDelta`, `toolCall`, `toolResult`, `hint`, `done`, `error`) and the `{"sessionId", "message"}` request body of the streaming endpoint are gone. The streaming endpoint now only accepts `Content-Type: application/json`; requests without a `Content-Type`, or with `text/plain` or `application/*+json`, get 415. `POST /api/chat` now validates `sessionId` with the same rule as `threadId` (non-blank values like `user:42` used to be accepted and now return 400); a blank `sessionId` still starts a new session.
> **Note**
> The default in-memory state store only suits a single process, and history is lost when the service restarts. For multi-instance deployments, switch to a Redis or JDBC implementation. Concurrent requests for the same session are not serialized, so avoid sending them at the same time.
@@ -116,10 +134,10 @@ To use another model, replace `agentscope-openai-spring-boot-starter` in `build.
src/main/java/com/yoke/
├── YokeApplication.java Application entry point
├── api/ HTTP contract (ChatApi: paths, validation and docs live here)
-├── codec/ Conversion between domain types and framework types (MessageCodec, EventCodec)
-├── config/ Shared agent configuration (builder, state store)
+├── codec/ Conversion between domain types and framework types (MessageCodec, AguiStreamCodec, HintEventConverter)
+├── config/ Shared agent configuration (builder, state store, AG-UI adapter)
├── controller/ Contract implementation (ChatController)
-└── model/ Request, response and stream event objects (ChatRequest, ChatResponse, ChatEvent)
+└── model/ Request and response objects (ChatRequest, ChatResponse)
src/main/resources/ application.yml and the per-profile configuration
docs/brand/ Logo
```
diff --git a/README_CN.md b/README_CN.md
index 7018c21..ee4b74b 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -19,7 +19,7 @@
Yoke(轭)是套在牛马身上、让它们听使唤拉车的木架;Yoke 则是套在模型外面、让它能稳定干活的那层运行框架。它把 AgentScope 的 Agent 能力包装成一个开箱即用的 HTTP 服务,让你专注于业务,而不是胶水代码:
-- 一个接口完成多轮对话:`POST /api/chat`;另有 `POST /api/chat/stream`,通过 SSE 边推理边推送。
+- 一个接口完成多轮对话:`POST /api/chat`;另有 `POST /api/chat/stream`,按 AG-UI 协议通过 SSE 边推理边推送。
- 内置 `get_weather` 工具,通过开源的 [Open-Meteo](https://open-meteo.com) 接口查询当前天气(无需密钥,免费版仅限非商业使用)。
- 每次请求新建临时 Agent,用完即关闭;对话历史保存在共享的状态存储里,按会话隔离。
- 通过 AgentScope 官方 starter 接入各种模型:OpenAI 兼容协议(可对接 DashScope、DeepSeek 等)、Anthropic、Gemini、Ollama,换依赖和配置即可。
@@ -44,50 +44,65 @@ curl -X POST localhost:8080/api/chat \
# {"sessionId":"…","reply":"…"}
```
-后续请求带上返回的 `sessionId`,即可延续同一段对话;不同 `sessionId` 的对话历史互相隔离。运行测试:`./gradlew build`。
+后续请求带上返回的 `sessionId`,即可延续同一段对话;不同 `sessionId` 的对话历史互相隔离。自己传的 `sessionId` 要符合下面 `threadId` 的同一条规则(字母、数字、`_.-`,1~64 位,不能全是点),否则返回 400;空白视同未传。运行测试:`./gradlew build`。
-### 流式调用
+### 流式调用(AG-UI)
-`POST /api/chat/stream` 的请求体与上面相同,Agent 推理的同时就把事件推送出来(`text/event-stream`)。用 `curl -N` 关闭缓冲:
+`POST /api/chat/stream` 使用 [AG-UI](https://docs.ag-ui.com/concepts/events) 协议:请求体是标准的 `RunAgentInput`,响应是 `text/event-stream`,Agent 推理的同时就把 AG-UI 事件推送出来。用 `curl -N` 关闭缓冲:
```bash
curl -N -X POST localhost:8080/api/chat/stream \
-H 'Content-Type: application/json' \
- -d '{"sessionId": "demo", "message": "北京天气怎么样"}'
+ -d '{"threadId": "demo", "runId": "run-1", "messages": [{"id": "msg-1", "role": "user", "content": "北京天气怎么样"}]}'
```
-示意输出(来自桩模型):
+请求规则(任何一条不满足,都会在建立流之前返回 400):
+
+- `Content-Type` 必须是 `application/json`,否则返回 415。这也挡住了跨站页面用不触发 CORS 预检的 `text/plain` 请求发起对话
+- `threadId` 就是会话 ID,必填:字母、数字、`_.-`,1~64 位,不能全是点。它与 `POST /api/chat` 的 `sessionId` 共用同一份历史
+- `runId` 必填且不能为空白(`RunAgentInput` 要求非空)
+- `messages` 不能为空,每条消息都要有 `id` 和 `role`,最后一条必须是 `user` 消息且有非空白的纯文本(不支持多模态内容块)
+- 对话历史由服务端保存,只采用**最后一条** `user` 消息;前面的消息、`tools`、`context`、`state`、`forwardedProps`、`resume` 都会被忽略,客户端不能借此给 Agent 加工具或改写历史。这条消息也只取文本:它的 `id`、`toolCalls`、`toolCallId` 不会进入历史,消息 ID 由服务端生成。适配器还配置了 `ToolMergeMode.AGENT_ONLY`,只会用服务端注册的工具
+
+示意输出(取值是编的,字段名来自桩模型的实际运行,较长的字段做了省略)。只发 `data:` 行,事件类型是 JSON 里的 `type` 字段,没有 SSE 的 `event:` 名:
```
-event:textDelta
-data:{"text":"我来查一下天气。"}
+data:{"type":"RUN_STARTED","threadId":"demo","runId":"…","input":{…}}
+
+data:{"type":"TOOL_CALL_START","threadId":"demo","runId":"…","toolCallId":"call_1","toolCallName":"get_weather"}
+
+data:{"type":"TOOL_CALL_ARGS","threadId":"demo","runId":"…","toolCallId":"call_1","delta":"{\"city\":\"北京\"}"}
-event:toolCall
-data:{"id":"call_1","name":"get_weather","arguments":"{\"city\":\"北京\"}"}
+data:{"type":"TOOL_CALL_END","threadId":"demo","runId":"…","toolCallId":"call_1"}
-event:toolResult
-data:{"id":"call_1","name":"get_weather","state":"success","output":"\"晴,25 度(北京)\""}
+data:{"type":"TOOL_CALL_RESULT","threadId":"demo","runId":"…","toolCallId":"call_1","content":"\"晴,25 度(北京)\"","role":"tool","messageId":"…"}
-event:textDelta
-data:{"text":"北京今天"}
+data:{"type":"TEXT_MESSAGE_START","threadId":"demo","runId":"…","messageId":"…","role":"assistant"}
-event:textDelta
-data:{"text":"晴,25 度。"}
+data:{"type":"TEXT_MESSAGE_CONTENT","threadId":"demo","runId":"…","messageId":"…","delta":"北京今天晴,25 度。"}
-event:done
-data:{"sessionId":"demo","text":"北京今天晴,25 度。","usage":{"inputTokens":83,"outputTokens":27,"totalTokens":110}}
+data:{"type":"TEXT_MESSAGE_END","threadId":"demo","runId":"…","messageId":"…"}
+
+data:{"type":"CUSTOM","threadId":"demo","runId":"…","name":"token_usage","value":{"delta":{…},"cumulative":{"inputTokens":83,"outputTokens":27,…},"modelCall":{…}}}
+
+data:{"type":"RUN_FINISHED","threadId":"demo","runId":"…"}
```
-事件名是一个封闭集合:
+| 事件 `type` | 含义 |
+|---|---|
+| `RUN_STARTED` | 总是第一个事件,带 `threadId` 和 `runId` |
+| `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` | 模型生成的文本;按顺序拼接 `CONTENT` 事件的 `delta` 即得到回复 |
+| `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` | 模型决定调用的工具;`TOOL_CALL_ARGS` 的 `delta` 是入参 JSON 的片段,只有模型分片返回入参时才会出现 |
+| `TOOL_CALL_RESULT` | 工具的执行结果,通过 `toolCallId` 与调用对应 |
+| `CUSTOM`,`name` 为 `token_usage` | 每次模型调用结束后的 token 用量,含 `delta` 和 `cumulative` |
+| `CUSTOM`,`name` 为 `hint` | 框架在推理过程中追加的提示,`value` 里有 `source` 和 `text` |
+| `RUN_FINISHED` | 正常结束 |
+| `RUN_ERROR` | 推理出错;HTTP 状态码仍是 200,流正常结束而不是断开连接。message 是原样的异常信息,未做脱敏,不要直接展示给终端用户 |
-- `textDelta`:模型生成的一段文本增量,按顺序拼接即得到回复
-- `toolCall`:模型决定调用的工具,名称和入参已经攒齐
-- `toolResult`:工具的执行结果,通过 `id` 与 `toolCall` 对应
-- `hint`:框架在推理过程中追加的提示
-- `done`:正常结束,带完整回复、`sessionId`(没传时由服务端生成)和累计 token 用量
-- `error`:推理出错;HTTP 状态码仍是 200,流正常结束而不是断开连接。message 是原样的异常信息,未做脱敏,不要直接展示给终端用户
+每次流都以 `RUN_STARTED` 开头(创建 Agent 失败时也一样),以 `RUN_FINISHED` 或 `RUN_ERROR` 结尾,二者不会同时出现。Agent 运行中出错时,`RUN_ERROR` 的 `code` 由 AG-UI 适配器按异常类型映射(`TIMEOUT_ERROR`、`INTERRUPTED_ERROR`、`INVALID_INPUT_ERROR` 或 `INTERNAL_ERROR`);适配器之外的失败(例如创建 Agent 失败)一律是 `INTERNAL_ERROR`。没有 `done` 事件,也不重复最终全文:由客户端拼接 `TEXT_MESSAGE_CONTENT`。流正常结束、出错或客户端断开时,临时 Agent 都会被关闭。适配器的 `runTimeout` 配置在 AgentScope 2.0.4 里没有被读取,所以它不能限制运行时长。
-每次流都以 `done` 或 `error` 结尾。`message` 为空白时在建立流之前就返回 400。流正常结束、出错或客户端断开时,临时 Agent 都会被关闭。
+> **不兼容变更**
+> 流式接口原先的私有事件(`textDelta`、`toolCall`、`toolResult`、`hint`、`done`、`error`)和 `{"sessionId", "message"}` 请求体已经移除。流式接口现在只接受 `Content-Type: application/json`,不带 `Content-Type`、或者用 `text/plain`、`application/*+json` 的请求都会返回 415。`POST /api/chat` 的 `sessionId` 现在按 `threadId` 的同一条规则校验(以前 `user:42` 这类非空白值能通过,现在返回 400);空白的 `sessionId` 仍然新建会话。
> **注意**
> 默认的内存状态存储只适合单进程,服务重启后历史会丢失;多副本部署时需要换成 Redis 或 JDBC 实现。同一会话的并发请求不会被串行化,请避免同时发送。
@@ -119,10 +134,10 @@ data:{"sessionId":"demo","text":"北京今天晴,25 度。","usage":{"inputTok
src/main/java/com/yoke/
├── YokeApplication.java 应用入口
├── api/ HTTP 接口契约(ChatApi,路径、校验和文档都在这里)
-├── codec/ 领域类型与框架类型之间的转换(MessageCodec、EventCodec)
-├── config/ Agent 共享配置(Builder、状态存储)
+├── codec/ 领域类型与框架类型之间的转换(MessageCodec、AguiStreamCodec、HintEventConverter)
+├── config/ Agent 共享配置(Builder、状态存储、AG-UI 适配器)
├── controller/ 接口实现(ChatController)
-└── model/ 请求、响应与流式事件对象(ChatRequest、ChatResponse、ChatEvent)
+└── model/ 请求与响应对象(ChatRequest、ChatResponse)
src/main/resources/ application.yml 及各环境配置
docs/brand/ logo
```
diff --git a/build.gradle b/build.gradle
index 8b3e7c0..4959931 100644
--- a/build.gradle
+++ b/build.gradle
@@ -22,6 +22,8 @@ dependencies {
implementation libs.spring.boot.starter.validation
// 官方核心 starter:根据 agentscope.* 配置自动创建 ReActAgent
implementation libs.agentscope.starter
+ // AG-UI 协议库:只要库本身(事件模型、适配器、编码器),不引官方的 agui starter(它的入口无认证、用单例 Agent)
+ implementation libs.agentscope.agui
// 模型 starter:这里选 OpenAI 兼容协议,改 base-url 即可接入 DashScope、DeepSeek 等;换模型只需替换这一行
implementation libs.agentscope.openai.starter
// 字符串判空统一用 Hutool 的 StrUtil(isBlank / isNotBlank),只引核心包,不用 hutool-all
diff --git a/gradle.properties b/gradle.properties
index 53c62e1..10db481 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,5 +1,5 @@
group=com.yoke
-version=1.0.0
+version=1.0.1
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7d5c732..fe31622 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,6 +8,7 @@ hutool = "5.8.48"
spring-boot-dependencies = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot" }
agentscope-bom = { module = "io.agentscope:agentscope-bom", version.ref = "agentscope" }
agentscope-starter = { module = "io.agentscope:agentscope-spring-boot-starter" }
+agentscope-agui = { module = "io.agentscope:agentscope-extensions-agui" }
agentscope-openai-starter = { module = "io.agentscope:agentscope-openai-spring-boot-starter" }
spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation" }
hutool-core = { module = "cn.hutool:hutool-core", version.ref = "hutool" }
diff --git a/src/main/java/com/yoke/api/ChatApi.java b/src/main/java/com/yoke/api/ChatApi.java
index 4a4c207..d7eacf8 100644
--- a/src/main/java/com/yoke/api/ChatApi.java
+++ b/src/main/java/com/yoke/api/ChatApi.java
@@ -1,6 +1,5 @@
package com.yoke.api;
-import com.yoke.model.ChatEvent;
import com.yoke.model.ChatRequest;
import com.yoke.model.ChatResponse;
import jakarta.validation.Valid;
@@ -33,6 +32,8 @@ public interface ChatApi {
* 并在响应里返回新的会话 ID,后续请求带上它即可延续对话。
*
*
{@code message} 为必填:缺失、为 null、空串或纯空白时都由参数校验拦截,返回 400。
+ * {@code sessionId} 为空白时视同未传;传了就要符合 {@link com.yoke.model.SafeId} 的规则
+ * (字母、数字、{@code _.-},1~64 位,不能全是点),否则返回 400。规则与 {@link #stream} 的 {@code threadId} 相同。
*
* @param request 对话请求,包含可选的会话 ID 和必填的用户输入
* @return Agent 的回复,以及本次对话所属的会话 ID
@@ -41,37 +42,62 @@ public interface ChatApi {
Mono chat(@Valid @RequestBody ChatRequest request);
/**
- * 向 Agent 发送一条消息,以 SSE({@code text/event-stream})边推理边推送事件。
+ * 以 AG-UI 协议向 Agent 发送一条消息,用 SSE({@code text/event-stream})边推理边推送 AG-UI 事件。
*
- * 请求体和会话规则与 {@link #chat} 相同:同一个 {@code sessionId} 共享对话历史,
- * 不传时由服务端新建会话,新的会话 ID 出现在最后的 {@code done} 事件里;{@code message} 缺失、
- * 为 null、空串或纯空白时在建立流之前就被参数校验拦截,返回 400。
+ *
请求体是标准的 AG-UI {@code RunAgentInput} JSON,{@code Content-Type} 必须是 {@code application/json}
+ * (否则返回 415,也避免跨站页面用不触发预检的 {@code text/plain} 请求发起对话)。这里以字符串接收,由实现解析和校验,
+ * 所以契约里不出现框架类型。校验失败(请求体不是合法 JSON、{@code messages} 为空、最后一条不是 user 消息、
+ * 最后一条 user 消息没有非空白的文本、{@code threadId} 缺失或不合规)都在建立流之前返回 400。
+ * 目前只支持纯文本的 {@code content},多模态内容块不支持。
*
- *
SSE 的 event 名与事件类型一一对应,data 是 JSON,一次对话可能依次出现:
+ *
{@code threadId} 就是会话 ID,同一个 {@code threadId} 共享对话历史,也与 {@link #chat} 的
+ * {@code sessionId} 共用同一份历史。AG-UI 要求客户端自己生成 {@code threadId},所以这里必填,
+ * 取值限制为字母、数字、{@code _.-},1~64 位,不能全是点,避免被用来拼出别的会话的存储位置。
+ * {@code runId} 必填(RunAgentInput 要求非空),缺失或空白返回 400。
+ *
+ *
对话历史保存在服务端的状态存储里,服务端只采用 {@code messages} 的最后一条 user 消息,
+ * 前面的消息、{@code tools}、{@code context}、{@code state}、{@code forwardedProps}、{@code resume}
+ * 都会被忽略,客户端不能借此给 Agent 增加工具或改写历史。这条 user 消息也只取文本:它的 {@code id}、
+ * {@code toolCalls}、{@code toolCallId} 不会进入历史,消息 ID 由服务端生成。
+ *
+ *
SSE 只发 {@code data:},不设 {@code event:} 名,事件类型在 JSON 的 {@code type} 字段里。
+ * 一次对话可能依次出现:
*
- * - {@code textDelta}:模型生成的一段文本增量,按顺序拼接即得到回复;
- * - {@code toolCall}:模型决定调用的工具,名称和入参已经攒齐;
- * - {@code toolResult}:工具的执行结果,与 {@code toolCall} 靠相同的 {@code id} 对应;
- * - {@code hint}:框架在推理过程中追加的提示;
- * - {@code done}:正常结束,带完整回复文本、会话 ID 和累计 token 用量;
- * - {@code error}:推理过程中出错。
+ * - {@code RUN_STARTED}:第一个事件,带 {@code threadId} 和 {@code runId};
+ * - {@code TEXT_MESSAGE_START}、{@code TEXT_MESSAGE_CONTENT}({@code delta} 是文本增量)、
+ * {@code TEXT_MESSAGE_END}:模型生成的文本,按顺序拼接 {@code delta} 即得到回复;
+ * - {@code TOOL_CALL_START}、{@code TOOL_CALL_ARGS}、{@code TOOL_CALL_END}、
+ * {@code TOOL_CALL_RESULT}:工具调用及其结果,靠相同的 {@code toolCallId} 对应;
+ * {@code TOOL_CALL_ARGS} 的 {@code delta} 是入参 JSON 的片段,只有模型分片返回入参时才会出现,
+ * 不要依赖它一定存在;
+ * - {@code CUSTOM},{@code name} 为 {@code token_usage}:每次模型调用结束后推送一次用量,
+ * {@code value} 里有本次增量和累计值;
+ * - {@code CUSTOM},{@code name} 为 {@code hint}:框架在推理过程中追加的提示,
+ * {@code value} 里是 {@code source} 和 {@code text};
+ * - {@code RUN_FINISHED}:正常结束;
+ * - {@code RUN_ERROR}:推理过程中出错。
*
- * 一次对话以 {@code done} 或 {@code error} 结尾,二者必有其一且只会出现一个。
- * 这个承诺依赖 2.0.4 框架在流结束时总会发出结束事件,实现里没有额外的兜底。
+ * 没有 {@code done} 事件,也不会重复最终全文:完整回复由客户端拼接 {@code TEXT_MESSAGE_CONTENT} 得到。
*
- * {@code error} 事件会原样带出异常信息,未做脱敏,调用方不要直接展示给终端用户。
+ *
一次对话以 {@code RUN_FINISHED} 或 {@code RUN_ERROR} 结尾,二者互斥,只会出现一个。
+ * 这个承诺依赖适配器的行为(出错时默认不补 {@code RUN_FINISHED})和 AgentScope 框架在流结束时总会发出结束事件,
+ * 实现里没有额外的兜底。{@code RUN_ERROR} 的 {@code message} 是原样的异常信息,未做脱敏,
+ * 调用方不要直接展示给终端用户。
*
- *
错误通过 {@code error} 事件体现,HTTP 状态码仍是 200 并正常结束流,而不是中途断开连接,
+ *
错误通过 {@code RUN_ERROR} 事件体现,HTTP 状态码仍是 200 并正常结束流,而不是中途断开连接,
* 所以调用方只需要根据最后一个事件判断成败。
*
*
每次请求新建一个临时 Agent:流正常结束、出错或被客户端取消(断开连接)时都会关闭它。
*
*
已知限制:与 {@link #chat} 一样,同一个会话的并发请求不会被串行化,
- * 由调用方保证同一会话同一时间只发一个请求;取消时已经产生的部分对话是否写入历史,取决于框架的保存时机。
+ * 由调用方保证同一会话同一时间只发一个请求;取消时已经产生的部分对话是否写入历史,取决于框架的保存时机;
+ * 运行超时未验证:AgentScope 的适配器配置里有 {@code runTimeout},但源码里没有任何地方读取它。
*
- * @param request 对话请求,包含可选的会话 ID 和必填的用户输入
- * @return 事件流,最后一个事件是 {@code done} 或 {@code error}
+ * @param body AG-UI {@code RunAgentInput} 的 JSON 文本
+ * @return 事件流,每个元素的 data 是一个 AG-UI 事件的 JSON,最后一个事件是 {@code RUN_FINISHED} 或 {@code RUN_ERROR}
*/
- @PostMapping(path = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
- Flux> stream(@Valid @RequestBody ChatRequest request);
+ @PostMapping(path = "/stream",
+ consumes = MediaType.APPLICATION_JSON_VALUE,
+ produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+ Flux> stream(@RequestBody String body);
}
diff --git a/src/main/java/com/yoke/codec/AguiStreamCodec.java b/src/main/java/com/yoke/codec/AguiStreamCodec.java
new file mode 100644
index 0000000..cd8a21f
--- /dev/null
+++ b/src/main/java/com/yoke/codec/AguiStreamCodec.java
@@ -0,0 +1,160 @@
+package com.yoke.codec;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+
+import cn.hutool.core.util.StrUtil;
+import com.yoke.model.SafeId;
+import io.agentscope.core.ReActAgent;
+import io.agentscope.core.agent.RuntimeContext;
+import io.agentscope.core.agui.adapter.AguiAdapterConfig;
+import io.agentscope.core.agui.adapter.AguiAgentAdapter;
+import io.agentscope.core.agui.encoder.AguiEventEncoder;
+import io.agentscope.core.agui.event.AguiEvent;
+import io.agentscope.core.agui.model.AguiMessage;
+import io.agentscope.core.agui.model.RunAgentInput;
+import io.agentscope.core.util.JsonUtils;
+import lombok.AccessLevel;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import reactor.core.publisher.Flux;
+
+/**
+ * 流式接口与 AG-UI 协议之间的翻译层:解析并校验 {@code RunAgentInput} 请求体,把 Agent 的事件流编成 SSE 的 data 文本。
+ *
+ * AG-UI 的请求与事件模型带的是 Jackson 2 注解,所以解析和序列化都交给 AgentScope 自带的 JSON 能力,
+ * 不经过 Spring 的 Jackson 3 转换器;对外只暴露字符串。
+ *
+ *
无请求态字段,可被所有请求共享。
+ */
+@RequiredArgsConstructor
+public final class AguiStreamCodec {
+
+ /** RUN_ERROR 的兜底错误码。与适配器自己对未知异常使用的取值一致,库里没有公开这个常量。 */
+ static final String INTERNAL_ERROR_CODE = "INTERNAL_ERROR";
+
+ @NonNull
+ private final AguiAdapterConfig config;
+
+ private final AguiEventEncoder encoder = new AguiEventEncoder();
+
+ /**
+ * 解析并校验请求体。
+ *
+ *
服务端只保留最后一条 user 消息:对话历史已经在状态存储里,适配器会把 {@code messages}
+ * 全部转成 Agent 的输入,不能把客户端带来的整段历史再喂一遍。{@code tools}、{@code context}、
+ * {@code state}、{@code forwardedProps}、{@code resume} 一律丢弃,客户端不能借此给 Agent 加工具。
+ *
+ * @throws InvalidStreamRequestException 请求体不是合法 JSON、缺少必要字段或 threadId 不合规
+ */
+ public StreamRequest parse(String body) {
+ if (StrUtil.isBlank(body)) {
+ throw new InvalidStreamRequestException("请求体不能为空");
+ }
+ RunAgentInput parsed = readInput(body);
+ if (!SafeId.Validator.isSafe(parsed.getThreadId())) {
+ throw new InvalidStreamRequestException("threadId 必须是 1~64 位的字母、数字或 _.-,且不能全是点");
+ }
+ if (StrUtil.isBlank(parsed.getRunId())) {
+ throw new InvalidStreamRequestException("runId 不能为空");
+ }
+
+ List messages = parsed.getMessages();
+ if (messages.isEmpty()) {
+ throw new InvalidStreamRequestException("messages 不能为空");
+ }
+ AguiMessage last = messages.getLast();
+ if (Objects.isNull(last) || !last.isUserMessage()) {
+ throw new InvalidStreamRequestException("messages 的最后一条必须是 user 消息");
+ }
+ if (StrUtil.isBlank(last.getTextContent())) {
+ throw new InvalidStreamRequestException("最后一条 user 消息必须有非空白的文本内容");
+ }
+ // 重建而不是直接用 parsed:适配器会把 messages 全部转成 Agent 的输入,历史已在状态存储里,
+ // 不能再喂一遍;tools、state 等客户端字段也不能带进去。RunAgentInput 不可变,只能用 builder 重新构造。
+ // 消息本身也重建,只取文本、id 由服务端生成:原样转交时,客户端带的 toolCalls/toolCallId
+ // 会被适配器转成 ToolUseBlock 存进历史,客户端给的 id 也会成为历史里的 Msg id
+ AguiMessage userMessage = AguiMessage.userMessage(UUID.randomUUID().toString(), last.getTextContent());
+ RunAgentInput input = RunAgentInput.builder()
+ .threadId(parsed.getThreadId())
+ .runId(parsed.getRunId())
+ .messages(List.of(userMessage))
+ .build();
+ return new StreamRequest(input);
+ }
+
+ /**
+ * 用适配器运行 Agent,并把 AG-UI 事件编成 SSE 的 data 文本(一个事件一行 JSON)。
+ * 不负责关闭 Agent,也不兜底异常,由调用方处理。
+ */
+ public Flux stream(ReActAgent agent, StreamRequest request, RuntimeContext context) {
+ return new AguiAgentAdapter(agent, config)
+ .run(request.input, context)
+ .map(this::encode);
+ }
+
+ /**
+ * 把适配器之外的异常(创建 Agent 失败、事件编码失败)编成错误事件,消息原样带出,未脱敏。
+ * Agent 运行中的异常由适配器自己转成 {@code RUN_ERROR},到不了这里。
+ *
+ * 走到这里时请求已经校验通过,所以错误一律是服务端的问题,{@code code} 固定为 {@code INTERNAL_ERROR},
+ * 不照搬适配器按异常类型的映射(那套映射会把配置错误抛出的 {@code IllegalStateException} 标成输入非法)。
+ *
+ *
尚未发出任何事件时先补一个 {@code RUN_STARTED},保证流总以它开头;它不回显 {@code input},这一点与适配器不同。
+ * 不读 {@code emitRunFinishedAfterError},出错后不补 {@code RUN_FINISHED}(Yoke 的配置也是关闭的)。
+ * 已经开始的文本消息不会补 {@code TEXT_MESSAGE_END}:只有编码中途失败才会出现,属于极少数情况。
+ *
+ * @param started 是否已经发出过事件
+ */
+ public Flux encodeError(StreamRequest request, Throwable error, boolean started) {
+ String message = Optional.ofNullable(error.getMessage())
+ .filter(StrUtil::isNotBlank)
+ .orElseGet(() -> error.getClass().getSimpleName());
+ String runError = encode(new AguiEvent.RunError(request.threadId(), request.runId(), message, INTERNAL_ERROR_CODE));
+ if (started) {
+ return Flux.just(runError);
+ }
+ return Flux.just(encode(new AguiEvent.RunStarted(request.threadId(), request.runId())), runError);
+ }
+
+ /** encodeToJson 在 JSON 前带一个空格(给 {@code data:} 凑成 {@code data: {...}}),这里去掉,SSE 规范允许冒号后无空格。 */
+ private String encode(AguiEvent event) {
+ return encoder.encodeToJson(event).trim();
+ }
+
+ /**
+ * 解析成 {@link RunAgentInput}。它的构造器要求 {@code threadId}、{@code runId} 非空,缺了会抛
+ * {@link NullPointerException}(经 Jackson 包装成别的异常),这里统一按 400 处理。
+ */
+ private RunAgentInput readInput(String body) {
+ RunAgentInput parsed;
+ try {
+ parsed = JsonUtils.getJsonCodec().fromJson(body, RunAgentInput.class);
+ } catch (RuntimeException e) {
+ throw new InvalidStreamRequestException("请求体不是合法的 RunAgentInput(threadId、runId、messages 必填)", e);
+ }
+ if (Objects.isNull(parsed)) {
+ throw new InvalidStreamRequestException("请求体不是合法的 JSON 对象");
+ }
+ return parsed;
+ }
+
+ /**
+ * 校验通过的流式请求,只带「最后一条 user 消息」。
+ */
+ @RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+ public static final class StreamRequest {
+
+ private final RunAgentInput input;
+
+ public String threadId() {
+ return input.getThreadId();
+ }
+
+ public String runId() {
+ return input.getRunId();
+ }
+ }
+}
diff --git a/src/main/java/com/yoke/codec/EventCodec.java b/src/main/java/com/yoke/codec/EventCodec.java
deleted file mode 100644
index 34acfa4..0000000
--- a/src/main/java/com/yoke/codec/EventCodec.java
+++ /dev/null
@@ -1,132 +0,0 @@
-package com.yoke.codec;
-
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-
-import cn.hutool.core.util.StrUtil;
-import com.yoke.model.ChatEvent;
-import io.agentscope.core.event.AgentEndEvent;
-import io.agentscope.core.event.AgentEvent;
-import io.agentscope.core.event.AgentResultEvent;
-import io.agentscope.core.event.HintBlockEvent;
-import io.agentscope.core.event.ModelCallEndEvent;
-import io.agentscope.core.event.TextBlockDeltaEvent;
-import io.agentscope.core.event.ToolCallDeltaEvent;
-import io.agentscope.core.event.ToolCallEndEvent;
-import io.agentscope.core.event.ToolCallStartEvent;
-import io.agentscope.core.event.ToolResultEndEvent;
-import io.agentscope.core.event.ToolResultStartEvent;
-import io.agentscope.core.event.ToolResultTextDeltaEvent;
-import io.agentscope.core.message.ToolResultState;
-import io.agentscope.core.model.ChatUsage;
-
-/**
- * 把框架拆得很细的 {@link AgentEvent} 流攒成对外的 {@link ChatEvent}。
- *
- * 框架把一次工具调用拆成 start、多个 delta、end:入参只出现在 delta 里,end 事件不带入参;
- * 工具名在 delta 上可能为空。所以这里按 toolCallId 缓存名称和碎片,等到 end 再产出完整事件。
- * 工具结果同理。用量分散在每次模型调用的结束事件上,这里负责累加。
- *
- *
有状态,非线程安全,每个请求新建一个,不能复用,否则缓存和用量会串到别的请求。
- * 框架保证同一个请求的事件按顺序逐个交付,所以单个实例内部不需要加锁。
- *
- *
思考过程、模型调用起止、人工确认等事件目前不对外暴露,直接忽略。
- */
-public final class EventCodec {
-
- private final String sessionId;
-
- /** toolCallId 到工具名,调用与结果共用,用来回填缺失的名字。 */
- private final Map toolNames = new HashMap<>();
-
- private final Map toolArguments = new HashMap<>();
-
- private final Map toolOutputs = new HashMap<>();
-
- private int inputTokens;
-
- private int outputTokens;
-
- private String replyText = "";
-
- public EventCodec(String sessionId) {
- this.sessionId = sessionId;
- }
-
- /**
- * 处理一个框架事件,返回需要对外推送的事件;碎片还没攒齐或不对外暴露时返回空列表。
- */
- public List decode(AgentEvent event) {
- return switch (event) {
- case TextBlockDeltaEvent e -> List.of(new ChatEvent.TextDelta(e.getDelta()));
- case ToolCallStartEvent e -> {
- rememberName(e.getToolCallId(), e.getToolCallName());
- toolArguments.put(e.getToolCallId(), new StringBuilder());
- yield List.of();
- }
- case ToolCallDeltaEvent e -> {
- rememberName(e.getToolCallId(), e.getToolCallName());
- toolArguments.computeIfAbsent(e.getToolCallId(), _ -> new StringBuilder()).append(e.getDelta());
- yield List.of();
- }
- case ToolCallEndEvent e -> {
- rememberName(e.getToolCallId(), e.getToolCallName());
- StringBuilder arguments = toolArguments.remove(e.getToolCallId());
- String json = Optional.ofNullable(arguments)
- .map(StringBuilder::toString)
- .filter(StrUtil::isNotBlank)
- .orElse("{}");
- yield List.of(new ChatEvent.ToolCall(e.getToolCallId(), nameOf(e.getToolCallId()), json));
- }
- case ToolResultStartEvent e -> {
- rememberName(e.getToolCallId(), e.getToolCallName());
- toolOutputs.put(e.getToolCallId(), new StringBuilder());
- yield List.of();
- }
- case ToolResultTextDeltaEvent e -> {
- rememberName(e.getToolCallId(), e.getToolCallName());
- toolOutputs.computeIfAbsent(e.getToolCallId(), _ -> new StringBuilder()).append(e.getDelta());
- yield List.of();
- }
- case ToolResultEndEvent e -> {
- rememberName(e.getToolCallId(), e.getToolCallName());
- StringBuilder output = toolOutputs.remove(e.getToolCallId());
- String state = Optional.ofNullable(e.getState()).map(ToolResultState::getValue).orElse(ToolResultState.SUCCESS.getValue());
- yield List.of(new ChatEvent.ToolResult(e.getToolCallId(), nameOf(e.getToolCallId()), state,
- Optional.ofNullable(output).map(StringBuilder::toString).orElse("")));
- }
- case HintBlockEvent e -> List.of(new ChatEvent.Hint(e.getHintSource(), e.getHint()));
- case ModelCallEndEvent e -> {
- accumulate(e.getUsage());
- yield List.of();
- }
- case AgentResultEvent e -> {
- replyText = Optional.ofNullable(e.getResult()).map(MessageCodec::toText).orElse("");
- yield List.of();
- }
- case AgentEndEvent ignored -> List.of(new ChatEvent.Done(sessionId, replyText,
- new ChatEvent.Usage(inputTokens, outputTokens, inputTokens + outputTokens)));
- default -> List.of();
- };
- }
-
- private void rememberName(String toolCallId, String name) {
- if (StrUtil.isNotBlank(name)) {
- toolNames.put(toolCallId, name);
- }
- }
-
- private String nameOf(String toolCallId) {
- return toolNames.getOrDefault(toolCallId, "");
- }
-
- private void accumulate(ChatUsage usage) {
- if (Objects.nonNull(usage)) {
- inputTokens += usage.getInputTokens();
- outputTokens += usage.getOutputTokens();
- }
- }
-}
diff --git a/src/main/java/com/yoke/codec/HintEventConverter.java b/src/main/java/com/yoke/codec/HintEventConverter.java
new file mode 100644
index 0000000..e3f43d9
--- /dev/null
+++ b/src/main/java/com/yoke/codec/HintEventConverter.java
@@ -0,0 +1,40 @@
+package com.yoke.codec;
+
+import java.util.Optional;
+import java.util.Set;
+
+import io.agentscope.core.agui.adapter.strategy.AgentEventConverter;
+import io.agentscope.core.agui.adapter.strategy.AguiStreamContext;
+import io.agentscope.core.agui.event.AguiEvent;
+import io.agentscope.core.event.AgentEvent;
+import io.agentscope.core.event.HintBlockEvent;
+
+/**
+ * 把框架追加的提示(例如迭代次数告警)转成 AG-UI 的 {@code CUSTOM} 事件,名称固定为 {@code hint}。
+ *
+ * 不注册的话,适配器会把 {@link HintBlockEvent} 落到 {@code RAW} 事件上。无状态,可被所有请求共享。
+ */
+public final class HintEventConverter implements AgentEventConverter {
+
+ /** CUSTOM 事件的 name,对外协议的一部分。 */
+ public static final String EVENT_NAME = "hint";
+
+ @Override
+ public Set> eventTypes() {
+ return Set.of(HintBlockEvent.class);
+ }
+
+ @Override
+ public void convert(AgentEvent event, AguiStreamContext context) {
+ HintBlockEvent hint = (HintBlockEvent) event;
+ // 提示来源和内容缺失时按空串处理,保证对外字段不为 null
+ HintPayload value = new HintPayload(
+ Optional.ofNullable(hint.getHintSource()).orElse(""),
+ Optional.ofNullable(hint.getHint()).orElse(""));
+ context.emit(new AguiEvent.Custom(context.getThreadId(), context.getRunId(), EVENT_NAME, value));
+ }
+
+ /** hint 事件的 value,字段名即对外 JSON 的字段名。 */
+ public record HintPayload(String source, String text) {
+ }
+}
diff --git a/src/main/java/com/yoke/codec/InvalidStreamRequestException.java b/src/main/java/com/yoke/codec/InvalidStreamRequestException.java
new file mode 100644
index 0000000..7489f16
--- /dev/null
+++ b/src/main/java/com/yoke/codec/InvalidStreamRequestException.java
@@ -0,0 +1,21 @@
+package com.yoke.codec;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+/**
+ * 流式请求体不合法。在建立流之前抛出,由 Spring MVC 转成 400,不会进入推理。
+ *
+ * 消息只描述哪一项不合法,不回显请求体内容。
+ */
+@ResponseStatus(HttpStatus.BAD_REQUEST)
+public class InvalidStreamRequestException extends IllegalArgumentException {
+
+ public InvalidStreamRequestException(String message) {
+ super(message);
+ }
+
+ public InvalidStreamRequestException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/com/yoke/config/AguiConfiguration.java b/src/main/java/com/yoke/config/AguiConfiguration.java
new file mode 100644
index 0000000..c4deb24
--- /dev/null
+++ b/src/main/java/com/yoke/config/AguiConfiguration.java
@@ -0,0 +1,41 @@
+package com.yoke.config;
+
+import com.yoke.codec.AguiStreamCodec;
+import com.yoke.codec.HintEventConverter;
+import io.agentscope.core.agui.adapter.AguiAdapterConfig;
+import io.agentscope.core.tool.ToolMergeMode;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 流式接口的 AG-UI 协议配置。只用官方的协议库,不引官方的 agui starter。
+ */
+@Configuration(proxyBeanMethods = false)
+public class AguiConfiguration {
+
+ /**
+ * 适配器配置。Yoke 不做状态同步,也不输出思考过程;token 用量以 {@code CUSTOM token_usage} 事件输出;
+ * 出错时只发 {@code RUN_ERROR},不再补 {@code RUN_FINISHED}。
+ *
+ *
{@code runTimeout} 保持默认:2.0.4 里这个配置只有 getter,适配器并没有读它,所以不要指望它能限制运行时长。
+ *
+ *
工具只用服务端注册的({@code AGENT_ONLY})。codec 已经丢弃了客户端的 {@code tools},这里再兜一层:
+ * 默认的 {@code MERGE_EXTERNAL_PRIORITY} 下,一旦客户端工具被透传进来,就会盖掉同名的服务端工具。
+ */
+ @Bean
+ public AguiAdapterConfig aguiAdapterConfig() {
+ return AguiAdapterConfig.builder()
+ .emitStateEvents(false)
+ .emitTokenUsage(true)
+ .enableReasoning(false)
+ .toolMergeMode(ToolMergeMode.AGENT_ONLY)
+ .addEventConverter(new HintEventConverter())
+ .build();
+ }
+
+ @Bean
+ public AguiStreamCodec aguiStreamCodec(AguiAdapterConfig config) {
+ return new AguiStreamCodec(config);
+ }
+}
diff --git a/src/main/java/com/yoke/controller/ChatController.java b/src/main/java/com/yoke/controller/ChatController.java
index 5fe1d77..4370d52 100644
--- a/src/main/java/com/yoke/controller/ChatController.java
+++ b/src/main/java/com/yoke/controller/ChatController.java
@@ -2,12 +2,12 @@
import java.util.Optional;
import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
import cn.hutool.core.util.StrUtil;
import com.yoke.api.ChatApi;
-import com.yoke.codec.EventCodec;
+import com.yoke.codec.AguiStreamCodec;
import com.yoke.codec.MessageCodec;
-import com.yoke.model.ChatEvent;
import com.yoke.model.ChatRequest;
import com.yoke.model.ChatResponse;
import io.agentscope.core.ReActAgent;
@@ -29,6 +29,8 @@ public class ChatController implements ChatApi {
private final ReActAgent.Builder agentBuilder;
+ private final AguiStreamCodec aguiCodec;
+
@Override
public Mono chat(ChatRequest request) {
String sessionId = resolveSessionId(request);
@@ -41,22 +43,25 @@ public Mono chat(ChatRequest request) {
}
@Override
- public Flux> stream(ChatRequest request) {
- String sessionId = resolveSessionId(request);
+ public Flux> stream(String body) {
+ // 校验失败在这里抛出,发生在建立流之前,所以是 400 而不是流内错误
+ AguiStreamCodec.StreamRequest request = aguiCodec.parse(body);
- return Flux.using(
- agentBuilder::build,
- agent -> {
- EventCodec codec = new EventCodec(sessionId);
- return agent.streamEvents(MessageCodec.toInput(request.message()), contextOf(sessionId))
- .concatMapIterable(codec::decode);
- },
- ReActAgent::close)
- .onErrorResume(error -> {
- log.warn("流式对话出错,sessionId={}", sessionId, error);
- return Flux.just(new ChatEvent.Error(describe(error)));
+ return Flux.defer(() -> {
+ // 记录是否已经发出过事件:没发出过(例如创建 Agent 失败)就要先补 RUN_STARTED。
+ // 放在 defer 里,每次订阅一份,重复订阅时不会沿用上一次的状态
+ AtomicBoolean started = new AtomicBoolean();
+ return Flux.using(
+ agentBuilder::build,
+ agent -> aguiCodec.stream(agent, request, contextOf(request.threadId())),
+ ReActAgent::close)
+ .doOnNext(data -> started.set(true))
+ .onErrorResume(error -> {
+ log.warn("流式对话出错,threadId={}", request.threadId(), error);
+ return aguiCodec.encodeError(request, error, started.get());
+ });
})
- .map(event -> ServerSentEvent.builder(event).event(event.eventName()).build());
+ .map(data -> ServerSentEvent.builder(data).build());
}
private static String resolveSessionId(ChatRequest request) {
@@ -71,10 +76,4 @@ private static RuntimeContext contextOf(String sessionId) {
.sessionId(sessionId)
.build();
}
-
- private static String describe(Throwable error) {
- return Optional.ofNullable(error.getMessage())
- .filter(StrUtil::isNotBlank)
- .orElseGet(() -> error.getClass().getSimpleName());
- }
}
diff --git a/src/main/java/com/yoke/model/ChatEvent.java b/src/main/java/com/yoke/model/ChatEvent.java
deleted file mode 100644
index d8edd97..0000000
--- a/src/main/java/com/yoke/model/ChatEvent.java
+++ /dev/null
@@ -1,90 +0,0 @@
-package com.yoke.model;
-
-/**
- * 流式对话对外推送的事件,是一个封闭集合:调用方只会收到下面这几种,新增种类必须修改这里。
- *
- * 这些类型只用 JDK 类型表达,不暴露 AgentScope 的任何类,框架升级时对外协议不受影响。
- * 框架把一次工具调用拆成 start、delta、end 多个细粒度事件,这里已经攒成一个完整事件再推送。
- */
-public sealed interface ChatEvent {
-
- /**
- * SSE 的 event 名,与事件类型一一对应。
- */
- default String eventName() {
- return switch (this) {
- case TextDelta ignored -> "textDelta";
- case ToolCall ignored -> "toolCall";
- case ToolResult ignored -> "toolResult";
- case Hint ignored -> "hint";
- case Done ignored -> "done";
- case Error ignored -> "error";
- };
- }
-
- /**
- * 模型生成的一段文本增量,按到达顺序拼接即得到回复。
- *
- * @param text 本次增量的文本
- */
- record TextDelta(String text) implements ChatEvent {
- }
-
- /**
- * 模型决定调用的一个工具,参数已经攒齐。
- *
- * @param id 工具调用 ID,与对应的 {@link ToolResult} 相同
- * @param name 工具名称
- * @param arguments 工具入参,JSON 文本;模型没有给参数时为 {@code {}}
- */
- record ToolCall(String id, String name, String arguments) implements ChatEvent {
- }
-
- /**
- * 一个工具的执行结果,输出已经攒齐。
- *
- * @param id 工具调用 ID,与对应的 {@link ToolCall} 相同
- * @param name 工具名称
- * @param state 执行状态,取值 success、error、interrupted、denied、running
- * @param output 工具返回值序列化后的文本(返回字符串时带引号);非文本输出(图片等)不包含在内
- */
- record ToolResult(String id, String name, String state, String output) implements ChatEvent {
- }
-
- /**
- * 框架在推理过程中追加的提示(例如迭代次数告警)。
- *
- * @param source 提示的来源
- * @param text 提示内容
- */
- record Hint(String source, String text) implements ChatEvent {
- }
-
- /**
- * 本轮对话正常结束,总是流里的最后一个事件。
- *
- * @param sessionId 本次对话所属的会话 ID
- * @param text 最终回复的完整文本,与同步接口返回的 reply 一致
- * @param usage 本轮所有模型调用累计的 token 用量
- */
- record Done(String sessionId, String text, Usage usage) implements ChatEvent {
- }
-
- /**
- * 本轮对话出错。出错后流正常结束而不是断开连接,所以它同样是最后一个事件。
- *
- * @param message 错误信息
- */
- record Error(String message) implements ChatEvent {
- }
-
- /**
- * token 用量。
- *
- * @param inputTokens 输入 token 数
- * @param outputTokens 输出 token 数
- * @param totalTokens 合计
- */
- record Usage(int inputTokens, int outputTokens, int totalTokens) {
- }
-}
diff --git a/src/main/java/com/yoke/model/ChatRequest.java b/src/main/java/com/yoke/model/ChatRequest.java
index 4f7d410..d84170b 100644
--- a/src/main/java/com/yoke/model/ChatRequest.java
+++ b/src/main/java/com/yoke/model/ChatRequest.java
@@ -5,8 +5,8 @@
/**
* 对话请求。
*
- * @param sessionId 会话 ID,同一个 ID 共享对话历史;不传则由服务端新建会话,所以允许为空
+ * @param sessionId 会话 ID,同一个 ID 共享对话历史;不传则由服务端新建会话,所以允许为空;传了就要符合 {@link SafeId}
* @param message 用户输入,必填
*/
-public record ChatRequest(String sessionId, @NotBlank(message = "message 不能为空") String message) {
+public record ChatRequest(@SafeId String sessionId, @NotBlank(message = "message 不能为空") String message) {
}
diff --git a/src/main/java/com/yoke/model/SafeId.java b/src/main/java/com/yoke/model/SafeId.java
new file mode 100644
index 0000000..193306e
--- /dev/null
+++ b/src/main/java/com/yoke/model/SafeId.java
@@ -0,0 +1,51 @@
+package com.yoke.model;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+import cn.hutool.core.util.StrUtil;
+import jakarta.validation.Constraint;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import jakarta.validation.Payload;
+
+/**
+ * 会话 ID 的取值规则:字母、数字、{@code _.-},1~64 位,不能全是点,避免被用来拼出别的会话的存储位置。
+ *
+ *
作为校验注解使用时,null 和空白放行:同步接口的 {@code sessionId} 可以不传,缺省时由服务端新建会话。
+ * 必填的入口(AG-UI 的 {@code threadId})直接调用 {@link Validator#isSafe}。
+ */
+@Documented
+@Constraint(validatedBy = SafeId.Validator.class)
+@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface SafeId {
+
+ String message() default "必须是 1~64 位的字母、数字或 _.-,且不能全是点";
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+
+ final class Validator implements ConstraintValidator {
+
+ private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9_.-]{1,64}");
+
+ private static final Pattern ALL_DOTS = Pattern.compile("\\.+");
+
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext context) {
+ return StrUtil.isBlank(value) || isSafe(value);
+ }
+
+ /** 非 null 且符合规则。 */
+ public static boolean isSafe(String id) {
+ return Objects.nonNull(id) && SAFE_ID.matcher(id).matches() && !ALL_DOTS.matcher(id).matches();
+ }
+ }
+}
diff --git a/src/test/java/com/yoke/ArchitectureTests.java b/src/test/java/com/yoke/ArchitectureTests.java
index 9d08008..50c3d17 100644
--- a/src/test/java/com/yoke/ArchitectureTests.java
+++ b/src/test/java/com/yoke/ArchitectureTests.java
@@ -26,6 +26,21 @@ class ArchitectureTests {
"io.agentscope.core.agent.RuntimeContext",
"io.agentscope.core.agent.RuntimeContext$Builder");
+ /** 配置类装配适配器配置所必需的 AG-UI 类型,其余 AG-UI 类型一律不行。 */
+ private static final Set CONFIG_AGUI_ALLOWED = Set.of(
+ "io.agentscope.core.agui.adapter.AguiAdapterConfig",
+ "io.agentscope.core.agui.adapter.AguiAdapterConfig$Builder");
+
+ private static final DescribedPredicate AGUI_OUTSIDE_CONFIG_ALLOWED_LIST = DescribedPredicate
+ .and(resideInAPackage("io.agentscope.core.agui.."),
+ DescribedPredicate.not(new DescribedPredicate(
+ "the config allowed types " + CONFIG_AGUI_ALLOWED) {
+ @Override
+ public boolean test(JavaClass input) {
+ return CONFIG_AGUI_ALLOWED.contains(input.getName());
+ }
+ }));
+
private static final DescribedPredicate FRAMEWORK_OUTSIDE_ALLOWED_LIST = DescribedPredicate
.and(resideInAPackage("io.agentscope.."),
DescribedPredicate.not(new DescribedPredicate(
@@ -52,4 +67,14 @@ public boolean test(JavaClass input) {
static final ArchRule 框架的消息和事件类型只能出现在codec里 = noClasses()
.that().resideOutsideOfPackage("com.yoke.codec..")
.should().dependOnClassesThat().resideInAnyPackage("io.agentscope.core.message..", "io.agentscope.core.event..");
+
+ @ArchTest
+ static final ArchRule AGUI协议类型只能出现在codec和config里 = noClasses()
+ .that().resideOutsideOfPackages("com.yoke.codec..", "com.yoke.config..")
+ .should().dependOnClassesThat().resideInAPackage("io.agentscope.core.agui..");
+
+ @ArchTest
+ static final ArchRule config只能依赖适配器配置这几个AGUI类型 = noClasses()
+ .that().resideInAPackage("com.yoke.config..")
+ .should().dependOnClassesThat(AGUI_OUTSIDE_CONFIG_ALLOWED_LIST);
}
diff --git a/src/test/java/com/yoke/codec/AguiStreamCodecTests.java b/src/test/java/com/yoke/codec/AguiStreamCodecTests.java
new file mode 100644
index 0000000..5f4e904
--- /dev/null
+++ b/src/test/java/com/yoke/codec/AguiStreamCodecTests.java
@@ -0,0 +1,111 @@
+package com.yoke.codec;
+
+import java.util.List;
+
+import com.yoke.config.AguiConfiguration;
+import io.agentscope.core.tool.ToolMergeMode;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * 流式请求体的解析与校验:任何一项不合规都要在建立流之前被拒绝。
+ */
+class AguiStreamCodecTests {
+
+ private final AguiConfiguration configuration = new AguiConfiguration();
+
+ private final AguiStreamCodec codec = configuration.aguiStreamCodec(configuration.aguiAdapterConfig());
+
+ private static String request(String threadId, String messages) {
+ return "{\"threadId\":" + threadId + ",\"runId\":\"r-1\",\"messages\":" + messages + "}";
+ }
+
+ private static final String USER_HI = "[{\"id\":\"m1\",\"role\":\"user\",\"content\":\"你好\"}]";
+
+ private void 应当被拒绝(String body) {
+ assertThatThrownBy(() -> codec.parse(body))
+ .as(body)
+ .isInstanceOf(InvalidStreamRequestException.class);
+ }
+
+ @Test
+ void 合法请求解析出threadId和runId() {
+ AguiStreamCodec.StreamRequest parsed = codec.parse(request("\"t-1\"", USER_HI));
+
+ assertThat(parsed.threadId()).isEqualTo("t-1");
+ assertThat(parsed.runId()).isEqualTo("r-1");
+ }
+
+ @Test
+ void runId缺失或为空白时被拒绝() {
+ for (String runIdField : new String[] {"", "\"runId\":null,", "\"runId\":\"\",", "\"runId\":\" \","}) {
+ 应当被拒绝("{\"threadId\":\"t-1\"," + runIdField + "\"messages\":" + USER_HI + "}");
+ }
+ }
+
+ @Test
+ void 非法JSON或空请求体被拒绝() {
+ for (String body : new String[] {null, "", " ", "{", "not json", "[]", "\"x\"", "null", "123"}) {
+ 应当被拒绝(body);
+ }
+ }
+
+ @Test
+ void messages缺失或为空被拒绝() {
+ 应当被拒绝("{\"threadId\":\"t-1\",\"runId\":\"r-1\"}");
+ 应当被拒绝(request("\"t-1\"", "[]"));
+ 应当被拒绝(request("\"t-1\"", "null"));
+ }
+
+ @Test
+ void 最后一条不是user消息被拒绝() {
+ 应当被拒绝(request("\"t-1\"", "[{\"id\":\"m1\",\"role\":\"user\",\"content\":\"你好\"},"
+ + "{\"id\":\"m2\",\"role\":\"assistant\",\"content\":\"你好呀\"}]"));
+ 应当被拒绝(request("\"t-1\"", "[{\"id\":\"m1\",\"role\":\"system\",\"content\":\"规则\"}]"));
+ 应当被拒绝(request("\"t-1\"", "[{\"id\":\"m1\",\"role\":\"tool\",\"content\":\"x\",\"toolCallId\":\"c\"}]"));
+ }
+
+ @Test
+ void messages里有null元素被拒绝而不是空指针() {
+ 应当被拒绝(request("\"t-1\"", "[null]"));
+ 应当被拒绝(request("\"t-1\"", "[{\"id\":\"m1\",\"role\":\"user\",\"content\":\"你好\"},null]"));
+ }
+
+ @Test
+ void 适配器只用服务端注册的工具() {
+ assertThat(configuration.aguiAdapterConfig().getToolMergeMode()).isEqualTo(ToolMergeMode.AGENT_ONLY);
+ }
+
+ @Test
+ void 最后一条user消息没有文本或只有空白被拒绝() {
+ for (String content : new String[] {"\"\"", "\" \"", "null", "[]"}) {
+ 应当被拒绝(request("\"t-1\"", "[{\"id\":\"m1\",\"role\":\"user\",\"content\":" + content + "}]"));
+ }
+ 应当被拒绝(request("\"t-1\"", "[{\"id\":\"m1\",\"role\":\"user\"}]"));
+ }
+
+ @Test
+ void 消息缺少id或role被拒绝() {
+ 应当被拒绝(request("\"t-1\"", "[{\"role\":\"user\",\"content\":\"你好\"}]"));
+ 应当被拒绝(request("\"t-1\"", "[{\"id\":\"m1\",\"content\":\"你好\"}]"));
+ }
+
+ @Test
+ void 非法threadId被拒绝() {
+ List illegal = List.of("null", "\"\"", "\" \"", "\"a b\"", "\"a/b\"", "\"../x\"", "\"..\"", "\".\"",
+ "\"...\"", "\"中文\"", "\"" + "a".repeat(65) + "\"", "{}", "\"a\\nb\"");
+ for (String threadId : illegal) {
+ 应当被拒绝(request(threadId, USER_HI));
+ }
+ 应当被拒绝("{\"runId\":\"r-1\",\"messages\":" + USER_HI + "}");
+ }
+
+ @Test
+ void 合法threadId的边界值通过() {
+ for (String threadId : new String[] {"a", "A_b-1.2", ".a", "a.", "..a", "a".repeat(64), "0"}) {
+ assertThat(codec.parse(request("\"" + threadId + "\"", USER_HI)).threadId()).isEqualTo(threadId);
+ }
+ }
+}
diff --git a/src/test/java/com/yoke/codec/AguiStreamEventTests.java b/src/test/java/com/yoke/codec/AguiStreamEventTests.java
new file mode 100644
index 0000000..08a59a8
--- /dev/null
+++ b/src/test/java/com/yoke/codec/AguiStreamEventTests.java
@@ -0,0 +1,172 @@
+package com.yoke.codec;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.yoke.config.AguiConfiguration;
+import com.yoke.support.AguiTestSupport;
+import io.agentscope.core.ReActAgent;
+import io.agentscope.core.agent.RuntimeContext;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ToolUseBlock;
+import io.agentscope.core.model.ChatResponse;
+import io.agentscope.core.model.ChatUsage;
+import io.agentscope.core.model.GenerateOptions;
+import io.agentscope.core.model.Model;
+import io.agentscope.core.model.ToolSchema;
+import io.agentscope.core.state.InMemoryAgentStateStore;
+import io.agentscope.core.tool.Tool;
+import io.agentscope.core.tool.ToolParam;
+import io.agentscope.core.tool.Toolkit;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
+
+import static com.yoke.support.AguiTestSupport.read;
+import static com.yoke.support.AguiTestSupport.types;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * 用真实的 ReActAgent 加脚本化的假模型,验证适配器输出的 AG-UI 事件序列和字段。
+ * 不访问任何网络。
+ */
+class AguiStreamEventTests {
+
+ private final AguiConfiguration configuration = new AguiConfiguration();
+
+ private final AguiStreamCodec codec = configuration.aguiStreamCodec(configuration.aguiAdapterConfig());
+
+ /** 按调用次数依次返回预设响应;预设用完后抛错。 */
+ static class ScriptedModel implements Model {
+
+ private final List> script;
+
+ private final AtomicInteger calls = new AtomicInteger();
+
+ ScriptedModel(List> script) {
+ this.script = script;
+ }
+
+ @Override
+ public Flux stream(List messages, List tools, GenerateOptions options) {
+ int index = calls.getAndIncrement();
+ return index < script.size() ? script.get(index) : Flux.error(new IllegalStateException("脚本用完了"));
+ }
+
+ @Override
+ public String getModelName() {
+ return "scripted";
+ }
+ }
+
+ /** 测试用的本地工具,不联网。 */
+ public static class CityTool {
+
+ @Tool(name = "city_info", description = "查询城市信息")
+ public String cityInfo(@ToolParam(name = "city", description = "城市名") String city) {
+ return city + ":晴,25 度";
+ }
+ }
+
+ private static ChatResponse text(String text, ChatUsage usage) {
+ return ChatResponse.builder().content(List.of(TextBlock.builder().text(text).build())).usage(usage).build();
+ }
+
+ private static ChatResponse toolUseChunk(String fragment) {
+ return ChatResponse.builder()
+ .content(List.of(new ToolUseBlock("call_1", "city_info", Map.of(), fragment, null)))
+ .build();
+ }
+
+ private List run(Model model) {
+ Toolkit toolkit = new Toolkit();
+ toolkit.registerTool(new CityTool());
+ ReActAgent agent = ReActAgent.builder()
+ .name("Yoke")
+ .model(model)
+ .toolkit(toolkit)
+ .stateStore(new InMemoryAgentStateStore())
+ .build();
+ AguiStreamCodec.StreamRequest request = codec.parse(AguiTestSupport.body("t-events", "你好"));
+ RuntimeContext context = RuntimeContext.builder().userId("default").sessionId("t-events").build();
+ List events = codec.stream(agent, request, context).collectList().block();
+ agent.close();
+ return events;
+ }
+
+ @Test
+ void 纯文本回复的事件序列() {
+ List events = run(new ScriptedModel(List.of(Flux.just(
+ text("你", null), text("好", null), text("呀", ChatUsage.builder().inputTokens(10).outputTokens(3).build())))));
+
+ List types = types(events);
+ assertThat(types.getFirst()).isEqualTo("RUN_STARTED");
+ assertThat(types.getLast()).isEqualTo("RUN_FINISHED");
+ assertThat(types).contains("TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END");
+ assertThat(types.indexOf("TEXT_MESSAGE_START")).isLessThan(types.indexOf("TEXT_MESSAGE_CONTENT"));
+ assertThat(types.lastIndexOf("TEXT_MESSAGE_CONTENT")).isLessThan(types.indexOf("TEXT_MESSAGE_END"));
+ assertThat(types.indexOf("TEXT_MESSAGE_END")).isLessThan(types.indexOf("RUN_FINISHED"));
+ assertThat(types).doesNotContain("RUN_ERROR");
+ // 每个事件都带同一组 threadId 和 runId,RUN_STARTED 是第一个
+ assertThat(events).allSatisfy(json -> assertThat(read(json)).containsEntry("threadId", "t-events"));
+ assertThat(read(events.getFirst())).containsEntry("runId", "run-test");
+ // 客户端拼接 delta 得到完整回复
+ String reply = events.stream().map(AguiTestSupport::read)
+ .filter(e -> "TEXT_MESSAGE_CONTENT".equals(e.get("type")))
+ .map(e -> (String) e.get("delta"))
+ .reduce("", String::concat);
+ assertThat(reply).isEqualTo("你好呀");
+ }
+
+ @Test
+ void 编码结果没有前导空格且是单行() {
+ List events = run(new ScriptedModel(List.of(Flux.just(text("ok", null)))));
+
+ assertThat(events).allSatisfy(json -> {
+ assertThat(json).startsWith("{").doesNotContain("\n");
+ });
+ }
+
+ @Test
+ void 工具调用的事件序列() {
+ // 真实模型流式返回工具调用时,入参 JSON 是分片到达的
+ ChatResponse first = toolUseChunk("{\"city\":\"北");
+ ChatResponse second = toolUseChunk("京\"}");
+ List events = run(new ScriptedModel(List.of(Flux.just(first, second), Flux.just(text("北京晴", null)))));
+
+ List types = types(events);
+ assertThat(types).contains("TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT");
+ assertThat(types.indexOf("TOOL_CALL_START")).isLessThan(types.indexOf("TOOL_CALL_ARGS"));
+ assertThat(types.indexOf("TOOL_CALL_ARGS")).isLessThan(types.indexOf("TOOL_CALL_END"));
+ assertThat(types.indexOf("TOOL_CALL_END")).isLessThan(types.indexOf("TOOL_CALL_RESULT"));
+ assertThat(types.getLast()).isEqualTo("RUN_FINISHED");
+ Map result = events.stream().map(AguiTestSupport::read)
+ .filter(e -> "TOOL_CALL_RESULT".equals(e.get("type"))).findFirst().orElseThrow();
+ assertThat(result).containsEntry("toolCallId", "call_1");
+ assertThat(String.valueOf(result.get("content"))).contains("北京:晴,25 度");
+ }
+
+ @Test
+ void 用量以CUSTOM_token_usage事件输出() {
+ List events = run(new ScriptedModel(List.of(Flux.just(
+ text("好", ChatUsage.builder().inputTokens(10).outputTokens(3).build())))));
+
+ List