From 8a5f47849ac7b26cd65be3c4315e5792651b89c8 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 1 Nov 2026 22:27:40 +0800 Subject: [PATCH 01/10] feat: switch to HarnessAgent with Redis sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent 由 ReActAgent 换成 HarnessAgent,会话历史落到 Redis, 并补上日志中间件和装配期的权限收紧。 - HarnessAgentFactory:2.0.4 的 HarnessAgent.Builder 每次 build 都会 往内部追加中间件,不能共享,所以工厂只共享模型、存储、配置和中间件, Builder 每请求现建,仍用 Mono.using/Flux.using 保证关闭 - 会话:userId 可选(缺省 default),userId 与 sessionId 受 SafeId 约束, 避免拼槽位时撞车;状态走 RedisDistributedStore,启动期 ping 失败即 快速失败,连接串在报错和日志中一律脱敏 - 压缩:按条数触发,阈值走 yoke.compaction 配置,非法值回落默认 - 中间件:LoggingMiddleware 记录 agentId、sessionId、耗时和工具名, 不记录工具参数与输出 - 权限:装配期关闭文件系统、shell、联网工具和子 Agent (子 Agent 不继承联网工具的关闭设置) - 新增 docker-compose.yml(Redis),测试通过 Testcontainers 读取同一份 文件拉起真实 Redis,本机无 Docker 时跳过,CI 中改为失败 - 排除 agentscope-extensions-redis 带入的 redisson 与 lettuce - 补充历史、重启恢复、快速失败、压缩、权限、脱敏等测试,README 中英同步 --- README.md | 49 +++++-- README_CN.md | 49 +++++-- build.gradle | 11 ++ docker-compose.yml | 14 ++ gradle/libs.versions.toml | 4 + src/main/java/com/yoke/api/ChatApi.java | 44 ++++-- .../com/yoke/config/AgentConfiguration.java | 55 ++++--- .../com/yoke/config/HarnessAgentFactory.java | 67 +++++++++ .../com/yoke/config/RedisConfiguration.java | 66 +++++++++ .../java/com/yoke/config/YokeProperties.java | 71 +++++++++ .../com/yoke/controller/ChatController.java | 29 ++-- .../yoke/middleware/LoggingMiddleware.java | 76 ++++++++++ src/main/java/com/yoke/model/ChatRequest.java | 13 +- src/main/java/com/yoke/model/SafeId.java | 42 ++++++ src/main/resources/application-local.yml | 5 + src/main/resources/application-prod.yml | 5 + src/main/resources/application-test.yml | 4 + src/main/resources/application.yml | 15 +- src/test/java/com/yoke/ArchitectureTests.java | 16 ++- src/test/java/com/yoke/CompactionTests.java | 78 ++++++++++ src/test/java/com/yoke/ProfilesTests.java | 34 ++++- .../java/com/yoke/RedisFailFastTests.java | 68 +++++++++ .../java/com/yoke/RestartRecoveryTests.java | 54 +++++++ .../java/com/yoke/ToolPermissionTests.java | 83 +++++++++++ .../java/com/yoke/VirtualThreadsTests.java | 3 +- .../java/com/yoke/YokeApplicationTests.java | 31 +++- .../yoke/config/AgentConfigurationTests.java | 60 ++++++++ .../yoke/config/RedisConfigurationTests.java | 78 ++++++++++ .../com/yoke/config/YokePropertiesTests.java | 98 +++++++++++++ .../yoke/controller/ChatControllerTests.java | 57 +++++++- .../controller/ChatControllerWebTests.java | 63 +++++++- .../com/yoke/controller/ChatHistoryTests.java | 136 ++++++++++-------- .../middleware/LoggingMiddlewareTests.java | 118 +++++++++++++++ .../java/com/yoke/support/RecordingModel.java | 46 ++++++ .../java/com/yoke/support/RedisCompose.java | 72 ++++++++++ .../yoke/support/RedisIntegrationTest.java | 19 +++ .../java/com/yoke/support/RequiresDocker.java | 28 ++++ .../com/yoke/support/RequiresDockerTests.java | 30 ++++ .../yoke/support/StubModelConfiguration.java | 14 ++ 39 files changed, 1647 insertions(+), 158 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/main/java/com/yoke/config/HarnessAgentFactory.java create mode 100644 src/main/java/com/yoke/config/RedisConfiguration.java create mode 100644 src/main/java/com/yoke/config/YokeProperties.java create mode 100644 src/main/java/com/yoke/middleware/LoggingMiddleware.java create mode 100644 src/main/java/com/yoke/model/SafeId.java create mode 100644 src/test/java/com/yoke/CompactionTests.java create mode 100644 src/test/java/com/yoke/RedisFailFastTests.java create mode 100644 src/test/java/com/yoke/RestartRecoveryTests.java create mode 100644 src/test/java/com/yoke/ToolPermissionTests.java create mode 100644 src/test/java/com/yoke/config/AgentConfigurationTests.java create mode 100644 src/test/java/com/yoke/config/RedisConfigurationTests.java create mode 100644 src/test/java/com/yoke/config/YokePropertiesTests.java create mode 100644 src/test/java/com/yoke/middleware/LoggingMiddlewareTests.java create mode 100644 src/test/java/com/yoke/support/RecordingModel.java create mode 100644 src/test/java/com/yoke/support/RedisCompose.java create mode 100644 src/test/java/com/yoke/support/RedisIntegrationTest.java create mode 100644 src/test/java/com/yoke/support/RequiresDocker.java create mode 100644 src/test/java/com/yoke/support/RequiresDockerTests.java create mode 100644 src/test/java/com/yoke/support/StubModelConfiguration.java diff --git a/README.md b/README.md index 2289648..1094b85 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ 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. -- A temporary agent is built for every request and closed afterwards; conversation history lives in a shared state store and is isolated per session. +- A temporary `HarnessAgent` is built for every request and closed afterwards; conversation history lives in Redis, so it survives restarts and is shared by all replicas. History is isolated per `(userId, sessionId)`, and long conversations are compacted automatically. +- The agent has no file, shell or web tools and sub-agents are switched off, and a logging middleware records a timing trace for every call. - 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. - `local` / `test` / `prod` profiles. In production, a missing API key fails the startup instead of shipping with an empty key. - Virtual threads are enabled by default, for both Tomcat and Spring's default thread pool. @@ -28,9 +29,10 @@ A yoke is the wooden frame that makes oxen and horses pull in the direction you ## Getting Started -Requires JDK 25. +Requires JDK 25 and Docker (for the local Redis). ```bash +docker compose up -d # start Redis first; the app refuses to start without it sdk env # switch to JDK 25 (if you use sdkman) export OPENAI_API_KEY=sk-... ./gradlew bootRun # the local profile is active by default @@ -43,7 +45,18 @@ 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. Run the tests with `./gradlew build`; tests that need a real Redis start it from the same `docker-compose.yml` through Testcontainers, and are skipped with a clear reason when Docker is not available (with `CI=true` they fail instead). + +### Sessions + +History is stored per `(userId, sessionId)`. Both are optional in the request body (`{"userId": "alice", "sessionId": "s-1", "message": "Hello"}`), and the server always makes sure both have a value before the agent sees them: + +- both given: that pair +- only `sessionId`: it belongs to the `default` user (same as older clients) +- only `userId`: a new session is created for that user, and its id is returned in the response +- neither: a new session under the `default` user + +A half-empty pair is never passed on to the framework, because in AgentScope 2.0.4 a missing `sessionId` falls into one shared session named after the agent, and a missing `userId` falls into an anonymous user, which would mix everyone's history together. Both ids may only contain letters, digits, `_`, `.` and `-` (1 to 64 characters, not all dots, so `.` and `..` are rejected); anything else returns 400. The framework joins them as `userId/sessionId`, so allowing `/` would let different pairs collide on one slot, and `userId` also becomes a workspace directory name. There is no authentication yet: `userId` is whatever the caller says, so it is not an identity check. ### Streaming @@ -86,18 +99,18 @@ The event names form a closed set: 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. > **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. +> Concurrent requests for the same session are not serialized, so avoid sending them at the same time. `HarnessAgent.close()` waits for the framework's global async-write quiescence (up to 5 seconds each for two waits) inside the per-request cleanup, so requests are coupled through it; this has not been load-tested. ## Configuration Configuration is split by profile. The `local` profile is active when none is specified; switch with the `SPRING_PROFILES_ACTIVE=prod` environment variable or the `--spring.profiles.active=prod` command-line argument. -- `application.yml`: Shared by all profiles: model, agent, virtual threads, log format -- `application-local.yml`: Local development: API key from the environment (may be empty), `com.yoke` logs at debug -- `application-test.yml`: Automated tests: a fake API key, the model is never really called -- `application-prod.yml`: Production: the API key must come from the environment, startup fails without it; graceful shutdown +- `application.yml`: Shared by all profiles: model, agent, virtual threads, log format, workspace, key prefix and compaction +- `application-local.yml`: Local development: API key from the environment (may be empty), Redis at `redis://127.0.0.1:6379` (the one from `docker-compose.yml`), `com.yoke` logs at debug +- `application-test.yml`: Automated tests: a fake API key, the model is never really called; the Redis address is injected by the tests +- `application-prod.yml`: Production: the API key and the Redis address (`YOKE_REDIS_URI`) must come from the environment, startup fails without them; graceful shutdown -The model is created by the official AgentScope starter from `agentscope.*`, and the agent is built per request by a shared builder (the official singleton agent is turned off): +The model is created by the official AgentScope starter from `agentscope.*`, and a `HarnessAgent` is built per request by `HarnessAgentFactory` (the official singleton agent is turned off): | Property | Description | |---|---| @@ -107,6 +120,18 @@ The model is created by the official AgentScope starter from `agentscope.*`, and | `agentscope.agent.sys-prompt` | System prompt | | `agentscope.agent.max-iters` | Maximum number of ReAct iterations | +Yoke's own settings use the `yoke.*` prefix: + +| Property | Description | +|---|---| +| `yoke.redis.uri` | Redis connection string. No default in `prod` (`YOKE_REDIS_URI`); the application fails at startup if Redis cannot be reached | +| `yoke.redis.key-prefix` | Prefix of every Redis key, default `yoke:` | +| `yoke.compaction.trigger-messages` | Compact the conversation once it reaches this many messages, default 30 | +| `yoke.compaction.keep-messages` | Messages kept verbatim after a compaction, default 10. Values `<= 0`, or a keep count not smaller than the trigger count, fall back to the defaults | +| `yoke.workspace` | `HarnessAgent` workspace for local files (offloaded messages, memory), default `~/.yoke/workspace`; it stays on the machine and is not shared between replicas | + +The agent is built without file, shell or web tools, and sub-agents are disabled as a whole: reading the 2.0.4 source shows that a sub-agent inherits only the file and shell switches, not the web switch, so leaving sub-agents on would let the model spawn one that can fetch arbitrary URLs. What remains is limited to the memory and session tools (`memory_save` only appends to the fixed `MEMORY.md` and `memory/.md` in the workspace, without a size limit), `load_skill_through_path` and `wait_async_results`. A `MiddlewareBase` bean is attached to every new agent; `LoggingMiddleware` logs agent, model and tool timings without tool arguments or outputs. OpenTelemetry tracing is not attached by default: add an OpenTelemetry SDK and exporter and register an `OtelTracingMiddleware` bean when you need it. + To use another model, replace `agentscope-openai-spring-boot-starter` in `build.gradle` with the matching starter (dashscope, anthropic, gemini or ollama) and change `agentscope.model.provider`. ## Project Structure @@ -116,10 +141,12 @@ 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) +├── config/ Agent and Redis configuration (HarnessAgentFactory, YokeProperties) ├── controller/ Contract implementation (ChatController) +├── middleware/ Agent middlewares (LoggingMiddleware) └── model/ Request, response and stream event objects (ChatRequest, ChatResponse, ChatEvent) src/main/resources/ application.yml and the per-profile configuration +docker-compose.yml Local Redis, also used by the integration tests docs/brand/ Logo ``` @@ -129,7 +156,7 @@ docs/brand/ Logo ## Contributing -Issues and pull requests are welcome. AgentScope types must not leak out of `codec`; `ArchitectureTests` enforces this. Before submitting, run `./gradlew build` and make sure all tests pass. Code comments are written in Chinese. +Issues and pull requests are welcome. AgentScope message and event types must not leak out of `codec` (and `middleware`, whose hook signatures need them); `ArchitectureTests` enforces this. Before submitting, run `./gradlew build` and make sure all tests pass. Code comments are written in Chinese. ## License diff --git a/README_CN.md b/README_CN.md index 544b956..ea6cad0 100644 --- a/README_CN.md +++ b/README_CN.md @@ -20,7 +20,8 @@ Yoke(轭)是套在牛马身上、让它们听使唤拉车的木架;Yoke 则是套在模型外面、让它能稳定干活的那层运行框架。它把 AgentScope 的 Agent 能力包装成一个开箱即用的 HTTP 服务,让你专注于业务,而不是胶水代码: - 一个接口完成多轮对话:`POST /api/chat`;另有 `POST /api/chat/stream`,通过 SSE 边推理边推送。 -- 每次请求新建临时 Agent,用完即关闭;对话历史保存在共享的状态存储里,按会话隔离。 +- 每次请求新建临时 `HarnessAgent`,用完即关闭;对话历史保存在 Redis 里,重启不丢、多副本共享,按 `(userId, sessionId)` 隔离,对话过长时自动压缩。 +- Agent 不开放文件、命令和联网工具,子 Agent 整体关闭,日志中间件为每次调用记录耗时轨迹。 - 通过 AgentScope 官方 starter 接入各种模型:OpenAI 兼容协议(可对接 DashScope、DeepSeek 等)、Anthropic、Gemini、Ollama,换依赖和配置即可。 - local / test / prod 多环境配置;生产环境缺少密钥时启动即失败,避免带着空密钥上线。 - 默认开启虚拟线程,Tomcat 和 Spring 默认线程池都使用虚拟线程。 @@ -28,9 +29,10 @@ Yoke(轭)是套在牛马身上、让它们听使唤拉车的木架;Yoke ## 快速开始 -需要 JDK 25。 +需要 JDK 25 和 Docker(用于本地 Redis)。 ```bash +docker compose up -d # 先启动 Redis;没有 Redis 应用无法启动 sdk env # 切换到 JDK 25(使用 sdkman 时) export OPENAI_API_KEY=sk-... ./gradlew bootRun # 默认激活 local 环境 @@ -43,7 +45,18 @@ curl -X POST localhost:8080/api/chat \ # {"sessionId":"…","reply":"…"} ``` -后续请求带上返回的 `sessionId`,即可延续同一段对话;不同 `sessionId` 的对话历史互相隔离。运行测试:`./gradlew build`。 +后续请求带上返回的 `sessionId`,即可延续同一段对话。运行测试:`./gradlew build`;需要真实 Redis 的测试会通过 Testcontainers 读取同一份 `docker-compose.yml` 拉起,Docker 不可用时会明确跳过并写明原因(`CI=true` 时改为失败)。 + +### 会话 + +对话历史按 `(userId, sessionId)` 存放。请求体里两者都是可选的(`{"userId": "alice", "sessionId": "s-1", "message": "你好"}`),服务端保证二者到达 Agent 时都有值: + +- 两个都传:用这一对 +- 只传 `sessionId`:归属 `default` 用户(与旧版客户端行为一致) +- 只传 `userId`:为该用户新建一个会话,新的会话 ID 在响应里返回 +- 都不传:`default` 用户下新建会话 + +不会把「只给其一」直接交给框架:AgentScope 2.0.4 里缺 `sessionId` 会落到以 Agent 名称命名的共享会话,缺 `userId` 会落到匿名用户,所有调用方的历史会混在一起。两个标识只能包含字母、数字、`_`、`.`、`-`,长度 1 到 64,且不能全是点(`.`、`..` 会被拒绝),否则返回 400。原因是框架把槽位拼成 `userId/sessionId`,允许 `/` 会让不同的一对撞到同一个槽位,`userId` 还会成为工作区的目录名。目前没有鉴权,`userId` 由调用方自报,不是身份校验。 ### 流式调用 @@ -89,18 +102,18 @@ data:{"sessionId":"demo","text":"北京今天晴,25 度。","usage":{"inputTok 每次流都以 `done` 或 `error` 结尾。`message` 为空白时在建立流之前就返回 400。流正常结束、出错或客户端断开时,临时 Agent 都会被关闭。 > **注意** -> 默认的内存状态存储只适合单进程,服务重启后历史会丢失;多副本部署时需要换成 Redis 或 JDBC 实现。同一会话的并发请求不会被串行化,请避免同时发送。 +> 同一会话的并发请求不会被串行化,请避免同时发送。`HarnessAgent.close()` 会在每个请求的清理回调里等待框架全局的异步写入静默(两处各最长 5 秒),请求之间因此耦合;尚未做并发压测。 ## 配置 配置按环境拆分,没有显式指定环境时默认激活 `local`;部署时用环境变量 `SPRING_PROFILES_ACTIVE=prod` 或命令行参数 `--spring.profiles.active=prod` 切换。 -- `application.yml`:各环境通用:模型、Agent、虚拟线程、日志格式 -- `application-local.yml`:本地开发:密钥读环境变量(可为空),`com.yoke` 日志为 debug -- `application-test.yml`:自动化测试:假密钥,不会真正调用模型 -- `application-prod.yml`:生产:密钥必须由环境变量提供,缺失则启动失败;优雅停机 +- `application.yml`:各环境通用:模型、Agent、虚拟线程、日志格式、工作区、键前缀和压缩配置 +- `application-local.yml`:本地开发:密钥读环境变量(可为空),Redis 连 `redis://127.0.0.1:6379`(即 `docker-compose.yml` 里的那个),`com.yoke` 日志为 debug +- `application-test.yml`:自动化测试:假密钥,不会真正调用模型;Redis 地址由测试注入 +- `application-prod.yml`:生产:密钥和 Redis 地址(`YOKE_REDIS_URI`)必须由环境变量提供,缺失则启动失败;优雅停机 -模型由 AgentScope 官方 starter 根据 `agentscope.*` 创建,Agent 由共享 Builder 每次请求新建(官方的单例 Agent 已关闭): +模型由 AgentScope 官方 starter 根据 `agentscope.*` 创建,`HarnessAgent` 由 `HarnessAgentFactory` 每次请求新建(官方的单例 Agent 已关闭): | 配置 | 说明 | |---|---| @@ -110,6 +123,18 @@ data:{"sessionId":"demo","text":"北京今天晴,25 度。","usage":{"inputTok | `agentscope.agent.sys-prompt` | 系统提示词 | | `agentscope.agent.max-iters` | ReAct 最大迭代次数 | +Yoke 自己的配置使用 `yoke.*` 前缀: + +| 配置 | 说明 | +|---|---| +| `yoke.redis.uri` | Redis 连接串。`prod` 下没有默认值(`YOKE_REDIS_URI`);启动时连不上 Redis,应用直接启动失败 | +| `yoke.redis.key-prefix` | 所有 Redis 键的前缀,默认 `yoke:` | +| `yoke.compaction.trigger-messages` | 对话消息达到这个条数就压缩,默认 30 | +| `yoke.compaction.keep-messages` | 压缩后原样保留的最近条数,默认 10。小于等于 0,或保留条数不小于触发条数时,回落到默认值 | +| `yoke.workspace` | `HarnessAgent` 的工作区,放本地文件(转存的消息、记忆),默认 `~/.yoke/workspace`;只在本机,副本之间不共享 | + +Agent 创建时不开放文件、命令和联网工具,并且整体关闭子 Agent:读 2.0.4 源码发现子 Agent 只继承文件和 shell 的关闭、不继承联网工具的关闭,开着子 Agent 就能让模型派生出能抓取任意 URL 的子 Agent。关闭后剩下的只有记忆和会话类工具(`memory_save` 只往工作区固定的 `MEMORY.md` 与 `memory/<日期>.md` 追加,内容没有大小限制)、`load_skill_through_path` 和 `wait_async_results`。容器里的 `MiddlewareBase` Bean 会挂到每个新建的 Agent 上;`LoggingMiddleware` 记录 Agent、模型、工具的耗时,不记录工具的参数和输出。OpenTelemetry 追踪默认不挂载:需要时自行引入 OpenTelemetry SDK 和导出器,再注册一个 `OtelTracingMiddleware` Bean。 + 要换成其他模型,把 `build.gradle` 里的 `agentscope-openai-spring-boot-starter` 换成对应的 starter(dashscope、anthropic、gemini、ollama),并修改 `agentscope.model.provider`。 ## 项目结构 @@ -119,10 +144,12 @@ src/main/java/com/yoke/ ├── YokeApplication.java 应用入口 ├── api/ HTTP 接口契约(ChatApi,路径、校验和文档都在这里) ├── codec/ 领域类型与框架类型之间的转换(MessageCodec、EventCodec) -├── config/ Agent 共享配置(Builder、状态存储) +├── config/ Agent 与 Redis 配置(HarnessAgentFactory、YokeProperties) ├── controller/ 接口实现(ChatController) +├── middleware/ Agent 中间件(LoggingMiddleware) └── model/ 请求、响应与流式事件对象(ChatRequest、ChatResponse、ChatEvent) src/main/resources/ application.yml 及各环境配置 +docker-compose.yml 本地 Redis,集成测试也用它 docs/brand/ logo ``` @@ -133,7 +160,7 @@ docs/brand/ logo ## 参与贡献 -欢迎提交 Issue 和 Pull Request。提交前请运行 `./gradlew build` 确保全部测试通过;代码注释使用中文。AgentScope 的类型不能泄漏到 `codec` 之外,`ArchitectureTests` 会检查这一点。 +欢迎提交 Issue 和 Pull Request。提交前请运行 `./gradlew build` 确保全部测试通过;代码注释使用中文。AgentScope 的消息和事件类型不能泄漏到 `codec` 之外(`middleware` 除外,它的钩子签名本身就要用这些类型),`ArchitectureTests` 会检查这一点。 ## 许可证 diff --git a/build.gradle b/build.gradle index 59bb083..a599f60 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,14 @@ dependencies { implementation libs.spring.boot.starter.validation // 官方核心 starter:根据 agentscope.* 配置自动创建 ReActAgent implementation libs.agentscope.starter + // Harness 工程层:HarnessAgent 在 ReActAgent 之上补齐工作区、压缩、中间件等生产能力 + implementation libs.agentscope.harness + // Redis 分布式存储(RedisDistributedStore,基于 Jedis):会话历史放在进程之外,重启和多副本都能接上 + implementation(libs.agentscope.redis) { + // 扩展同时带进 Redisson 和 Lettuce 两套客户端,Yoke 只用 Jedis,排除后体积更小,也避免 Lettuce 版本被 Boot 的 BOM 改写 + exclude group: 'org.redisson' + exclude group: 'io.lettuce' + } // 模型 starter:这里选 OpenAI 兼容协议,改 base-url 即可接入 DashScope、DeepSeek 等;换模型只需替换这一行 implementation libs.agentscope.openai.starter @@ -35,6 +43,9 @@ dependencies { testImplementation libs.spring.boot.starter.webmvc.test // 用测试守住包之间的依赖边界(框架类型不外泄) testImplementation libs.archunit + // 用 docker-compose.yml 拉起真实 Redis 做集成测试 + testImplementation libs.testcontainers + testImplementation libs.testcontainers.junit testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0aec8b0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +# 本地研发依赖:先执行 docker compose up -d,再启动应用(./gradlew bootRun)。 +# 集成测试(Testcontainers)读取的也是这同一份文件,所以改这里会同时影响本地和测试。 +services: + redis: + # 固定版本,不用 latest,避免不同机器拉到不同的 Redis + image: redis:7.4-alpine + ports: + # 宿主机端口默认 6379;被占用时可以 REDIS_PORT=6380 docker compose up -d,应用侧同步改 yoke.redis.uri + - "127.0.0.1:${REDIS_PORT:-6379}:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 2s + retries: 15 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 21b3b65..d76c9ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,11 @@ archunit = "1.4.1" 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-harness = { module = "io.agentscope:agentscope-harness" } +agentscope-redis = { module = "io.agentscope:agentscope-extensions-redis" } agentscope-openai-starter = { module = "io.agentscope:agentscope-openai-spring-boot-starter" } +testcontainers = { module = "org.testcontainers:testcontainers" } +testcontainers-junit = { module = "org.testcontainers:testcontainers-junit-jupiter" } spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation" } lombok = { module = "org.projectlombok:lombok" } archunit = { module = "com.tngtech.archunit:archunit-junit5", version.ref = "archunit" } diff --git a/src/main/java/com/yoke/api/ChatApi.java b/src/main/java/com/yoke/api/ChatApi.java index 4a4c207..840bcf8 100644 --- a/src/main/java/com/yoke/api/ChatApi.java +++ b/src/main/java/com/yoke/api/ChatApi.java @@ -16,12 +16,39 @@ /** * 与 Agent 对话的 HTTP 接口契约。 * - *

每次请求都会新建一个临时 Agent,用完即关闭;对话历史保存在共享的状态存储中, - * 按会话 ID 隔离,所以新建的 Agent 仍能接上同一会话之前的对话。 + *

每次请求都会新建一个临时 HarnessAgent,用完即关闭;对话历史保存在 Redis 里, + * 按 {@code (userId, sessionId)} 隔离,所以新建的 Agent 仍能接上同一会话之前的对话,服务重启后历史也还在。 * 路径和参数注解都声明在这里,实现类只负责业务逻辑。 * - *

实现说明:暂时没有用户体系,所有会话共用同一个用户标识;默认的状态存储在内存中,服务重启后历史丢失; + *

会话规则:{@code userId} 和 {@code sessionId} 必须成对非空才对应一个会话槽位,服务端保证二者到达 Agent 时都有值: + *

+ * 不能把「只给其一」当成无会话直接交给框架:2.0.4 里缺 {@code sessionId} 会落到以 Agent 名称命名的共享会话, + * 缺 {@code userId} 会落到匿名用户,所有调用方的历史都会混在一起。 + * + *

标识格式:{@code userId} 和 {@code sessionId} 只能包含字母、数字、下划线、点和短横线,长度 1 到 64, + * 且不能全是点(排除 {@code .}、{@code ..}),不合法返回 400;缺失或空白不算不合法(分别用 {@code default} 和新建会话)。 + * 这样限制是因为框架把槽位拼成 {@code userId/sessionId},允许斜杠会让不同的一对撞到同一个槽位, + * 而 userId 还会成为工作区的目录名。 + * + *

实现说明:暂时没有鉴权,{@code userId} 由调用方自报,不可信,不要把它当成身份校验; * 同一个会话的并发请求不会被串行化,由调用方保证同一会话同一时间只发一个请求。 + * + *

工具权限:Agent 不开放文件读写、命令执行和联网工具,也整体关闭了子 Agent。 + * 读 2.0.4 源码发现子 Agent 只继承文件和 shell 的关闭、不继承联网工具的关闭, + * 开着子 Agent 就能让模型通过 {@code agent_spawn} 派生出能联网的子 Agent,所以整体关闭。 + * 关闭后模型仍能用的只有 {@code memory_save}、{@code memory_get}、{@code memory_search}、{@code session_history}、 + * {@code session_list}、{@code session_search}、{@code load_skill_through_path} 和 {@code wait_async_results}: + * 联网路径已经没有;{@code memory_save} 是唯一写盘的,只往工作区里固定的 {@code MEMORY.md} 和 + * {@code memory/<日期>.md} 追加,路径不由模型控制,但追加的内容没有大小限制。 + * + *

已知限制:{@code HarnessAgent.close()} 会等待框架里全局的异步写入静默 + * ({@code awaitMirrorQuiescence} 和 {@code awaitQuiescence},各最长 5 秒),它在每个请求的清理回调里执行, + * 因此是跨请求耦合的:别的请求的在途写入会让这次关闭变慢。目前没有把关闭挪到专用线程,也没做并发压测。 */ @RequestMapping("/api/chat") public interface ChatApi { @@ -29,12 +56,13 @@ public interface ChatApi { /** * 向 Agent 发送一条消息并返回回复。 * - *

同一个 {@code sessionId} 共享对话历史;不传 {@code sessionId} 时由服务端新建会话, - * 并在响应里返回新的会话 ID,后续请求带上它即可延续对话。 + *

同一个 {@code (userId, sessionId)} 共享对话历史;不传 {@code sessionId} 时由服务端新建会话, + * 并在响应里返回新的会话 ID,后续请求带上它即可延续对话;不传 {@code userId} 时使用 {@code default}。 + * 完整规则见类说明。 * *

{@code message} 为必填:缺失、为 null、空串或纯空白时都由参数校验拦截,返回 400。 * - * @param request 对话请求,包含可选的会话 ID 和必填的用户输入 + * @param request 对话请求,包含可选的用户标识、可选的会话 ID 和必填的用户输入 * @return Agent 的回复,以及本次对话所属的会话 ID */ @PostMapping @@ -43,7 +71,7 @@ public interface ChatApi { /** * 向 Agent 发送一条消息,以 SSE({@code text/event-stream})边推理边推送事件。 * - *

请求体和会话规则与 {@link #chat} 相同:同一个 {@code sessionId} 共享对话历史, + *

请求体和会话规则与 {@link #chat} 相同:同一个 {@code (userId, sessionId)} 共享对话历史, * 不传时由服务端新建会话,新的会话 ID 出现在最后的 {@code done} 事件里;{@code message} 缺失、 * 为 null、空串或纯空白时在建立流之前就被参数校验拦截,返回 400。 * @@ -69,7 +97,7 @@ public interface ChatApi { *

已知限制:与 {@link #chat} 一样,同一个会话的并发请求不会被串行化, * 由调用方保证同一会话同一时间只发一个请求;取消时已经产生的部分对话是否写入历史,取决于框架的保存时机。 * - * @param request 对话请求,包含可选的会话 ID 和必填的用户输入 + * @param request 对话请求,包含可选的用户标识、可选的会话 ID 和必填的用户输入 * @return 事件流,最后一个事件是 {@code done} 或 {@code error} */ @PostMapping(path = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) diff --git a/src/main/java/com/yoke/config/AgentConfiguration.java b/src/main/java/com/yoke/config/AgentConfiguration.java index cf56bc6..ba445e6 100644 --- a/src/main/java/com/yoke/config/AgentConfiguration.java +++ b/src/main/java/com/yoke/config/AgentConfiguration.java @@ -1,50 +1,45 @@ package com.yoke.config; -import io.agentscope.core.ReActAgent; +import java.nio.file.Path; +import io.agentscope.core.middleware.MiddlewareBase; import io.agentscope.core.model.Model; -import io.agentscope.core.state.AgentStateStore; -import io.agentscope.core.state.InMemoryAgentStateStore; +import io.agentscope.harness.agent.DistributedStore; import io.agentscope.spring.boot.properties.AgentProperties; import io.agentscope.spring.boot.properties.AgentscopeProperties; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * Agent 的共享配置。 * - *

这里只配置 {@link ReActAgent.Builder},不创建 Agent 实例:每次请求调用 {@code build()} 新建一个, - * 用完关闭。因此官方 starter 的单例 Agent 在 application.yml 里关闭了(agentscope.agent.enabled=false), - * 但名称、提示词、迭代次数仍沿用 agentscope.agent.* 的配置。 + *

这里不创建 Agent 实例,只组装 {@link HarnessAgentFactory}:每次请求调用一次 {@code create()} + * 新建一个 HarnessAgent,用完关闭。因此官方 starter 的单例 Agent 在 application.yml 里关闭了 + * (agentscope.agent.enabled=false),但名称、提示词、迭代次数仍沿用 agentscope.agent.* 的配置。 + * + *

对话历史存在 {@link DistributedStore}(Redis)里,见 {@link RedisConfiguration}; + * 容器里所有 {@link MiddlewareBase} Bean 都会按顺序挂到每个新建的 Agent 上,一个都没有也不影响启动。 */ @Configuration(proxyBeanMethods = false) public class AgentConfiguration { - /** - * 对话历史的共享存储。每次请求都是新的 Agent 实例,历史必须放在实例之外才能跨请求保留。 - * - *

内存实现只适合单进程,重启即丢失;多副本部署时替换成 Redis 或 JDBC 实现即可, - * 自定义的 {@link AgentStateStore} Bean 会让这里自动退让。 - */ - @Bean - @ConditionalOnMissingBean - public AgentStateStore agentStateStore() { - return new InMemoryAgentStateStore(); - } - - /** - * 共享的 Agent Builder,在启动时配置完成,请求期间只负责 {@code build()}。 - * 用户、会话等每次请求不同的信息通过 RuntimeContext 传入,不会修改这个 Builder。 - */ @Bean - public ReActAgent.Builder agentBuilder(Model model, AgentStateStore stateStore, AgentscopeProperties properties) { + public HarnessAgentFactory harnessAgentFactory( + Model model, + DistributedStore distributedStore, + AgentscopeProperties properties, + YokeProperties yoke, + ObjectProvider middlewares) { AgentProperties agent = properties.getAgent(); - return ReActAgent.builder() - .name(agent.getName()) - .sysPrompt(agent.getSysPrompt()) - .maxIters(agent.getMaxIters()) - .model(model) - .stateStore(stateStore); + return new HarnessAgentFactory( + agent.getName(), + agent.getSysPrompt(), + agent.getMaxIters(), + model, + distributedStore, + Path.of(yoke.workspace()), + yoke.compaction(), + middlewares.orderedStream().toList()); } } diff --git a/src/main/java/com/yoke/config/HarnessAgentFactory.java b/src/main/java/com/yoke/config/HarnessAgentFactory.java new file mode 100644 index 0000000..0259ae3 --- /dev/null +++ b/src/main/java/com/yoke/config/HarnessAgentFactory.java @@ -0,0 +1,67 @@ +package com.yoke.config; + +import java.nio.file.Path; +import java.util.List; + +import io.agentscope.core.middleware.MiddlewareBase; +import io.agentscope.core.model.Model; +import io.agentscope.harness.agent.DistributedStore; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.memory.compaction.CompactionConfig; +import lombok.RequiredArgsConstructor; + +/** + * 每次调用 {@link #create()} 都新建一个 HarnessAgent,调用方用完负责 {@code close()}。 + * + *

为什么不像 ReActAgent 那样共享一个 Builder:2.0.4 的 {@code HarnessAgent.Builder.build()} + * 会往内部持有的 ReActAgent.Builder 里追加一批装配期中间件,同一个 Builder 重复 build, + * 中间件会越积越多(实测每次多 11 个),所以这里只共享不可变的部分(模型、存储、配置、中间件实例), + * Builder 每次现建。 + */ +@RequiredArgsConstructor +public class HarnessAgentFactory { + + private final String name; + private final String sysPrompt; + private final int maxIters; + private final Model model; + private final DistributedStore distributedStore; + private final Path workspace; + private final YokeProperties.Compaction compaction; + private final List middlewares; + + public HarnessAgent create() { + return HarnessAgent.builder() + .name(name) + .sysPrompt(sysPrompt) + .maxIters(maxIters) + .model(model) + .workspace(workspace) + // 会话状态由它提供:没有显式 stateStore 时,Harness 用 distributedStore 的 AgentStateStore + .distributedStore(distributedStore) + .compaction(compactionConfig()) + .middlewares(middlewares) + // 权限取舍:对外服务的 Agent 不给读写文件和执行命令的能力,联网搜索同理 + .disableFilesystemTools() + .disableShellTool() + .disableWebTools() + // 子 Agent 只继承文件和 shell 的关闭,不继承 web 的关闭(2.0.4 的 HarnessAgentBuilderSupport 只传这两个), + // 开着就能让模型通过 agent_spawn 派生出带 web_fetch 的子 Agent 联网;第 03 轮专门引入子 Agent 时再按需打开 + .disableSubagents() + .build(); + } + + /** + * 只按条数触发压缩。{@code triggerTokens} 在 2.0.4 里 0 表示「按模型上下文窗口动态计算」而不是关闭, + * 所以用 {@link Integer#MAX_VALUE} 让 token 阈值实际上永远不会先触发; + * {@code keepTokens} 为 0 才会改用 keepMessages(默认 -1 是动态)。 + */ + CompactionConfig compactionConfig() { + return CompactionConfig.builder() + .triggerMessages(compaction.triggerMessages()) + .triggerTokens(Integer.MAX_VALUE) + .keepTokens(0) + .keepMessages(compaction.keepMessages()) + .build(); + } +} diff --git a/src/main/java/com/yoke/config/RedisConfiguration.java b/src/main/java/com/yoke/config/RedisConfiguration.java new file mode 100644 index 0000000..edde052 --- /dev/null +++ b/src/main/java/com/yoke/config/RedisConfiguration.java @@ -0,0 +1,66 @@ +package com.yoke.config; + +import java.util.regex.Pattern; + +import io.agentscope.extensions.redis.RedisDistributedStore; +import io.agentscope.harness.agent.DistributedStore; +import lombok.extern.slf4j.Slf4j; +import redis.clients.jedis.RedisClient; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Redis 分布式存储:会话历史放在进程之外,重启和多副本都能按 (userId, sessionId) 接上。 + * + *

启动时就 ping 一次:连不上 Redis 时应用直接启动失败,而不是等到第一个请求才发现历史存不下来。 + */ +@Slf4j +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(YokeProperties.class) +public class RedisConfiguration { + + private static final Pattern SCHEME = Pattern.compile("rediss?://.+", Pattern.CASE_INSENSITIVE); + + /** 单机 Jedis 客户端(内部是连接池);容器关闭时由 Spring 调用 {@code close()} 释放连接。 */ + @Bean(destroyMethod = "close") + public RedisClient jedis(YokeProperties properties) { + String uri = properties.redis().uri(); + if (!SCHEME.matcher(uri).matches()) { + throw new IllegalStateException("yoke.redis.uri 必须是 redis:// 或 rediss:// 开头的连接串;" + + "生产环境通过环境变量 YOKE_REDIS_URI 提供。当前值:" + redact(uri)); + } + RedisClient jedis = null; + try { + jedis = RedisClient.create(uri); + jedis.ping(); + } catch (RuntimeException e) { + if (jedis != null) { + jedis.close(); + } + // 不把原始异常作为 cause:Jedis 对格式非法的连接串会在异常消息里原样回显,可能带出密码 + throw new IllegalStateException("无法连接 Redis(" + redact(uri) + + "),请确认 Redis 已启动:本地研发先执行 docker compose up -d," + + "其他环境检查 yoke.redis.uri(原因:" + e.getClass().getSimpleName() + ")"); + } + log.info("Redis 连接正常:{}", redact(uri)); + return jedis; + } + + /** 同一个连接同时提供会话状态、文件键值等存储;HarnessAgent 的 {@code distributedStore} 用它。 */ + @Bean + public DistributedStore distributedStore(RedisClient jedis, YokeProperties properties) { + return RedisDistributedStore.fromJedis(jedis, properties.redis().keyPrefix()); + } + + /** + * 报错信息和日志里不带用户名和密码。 + * + *

不依赖 URI 解析:密码里未编码的 {@code @}、{@code /} 之类笔误会让 URI 解析认不出用户信息。 + * 这里把 {@code ://} 之后到最后一个 {@code @} 之间的内容整段替换,宁可多遮一点。 + */ + static String redact(String uri) { + return uri.replaceFirst("(?<=://).*@", "***@"); + } +} diff --git a/src/main/java/com/yoke/config/YokeProperties.java b/src/main/java/com/yoke/config/YokeProperties.java new file mode 100644 index 0000000..a9f4fcf --- /dev/null +++ b/src/main/java/com/yoke/config/YokeProperties.java @@ -0,0 +1,71 @@ +package com.yoke.config; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.validation.annotation.Validated; + +/** + * Yoke 自己的配置(前缀 {@code yoke}),与 AgentScope 官方的 {@code agentscope.*} 分开。 + * + * @param redis Redis 连接与键前缀 + * @param compaction 对话压缩的触发与保留条数 + * @param workspace HarnessAgent 的工作区目录,放压缩前转存的消息、记忆等本地文件 + */ +@Validated +@ConfigurationProperties(prefix = "yoke") +public record YokeProperties( + @Valid @DefaultValue Redis redis, + @DefaultValue Compaction compaction, + @NotBlank String workspace) { + + /** + * Redis 配置。 + * + * @param uri 连接串,例如 {@code redis://127.0.0.1:6379};没有默认值,缺失时绑定失败、应用无法启动。 + * 环境变量未设置时 {@code ${YOKE_REDIS_URI}} 会原样留在值里(不会报占位符错误),所以在 RedisConfiguration 里校验格式。 + * 不用 {@code @Pattern}:绑定失败的分析信息会原样打印属性值,连接串里的密码会被带出来 + * @param keyPrefix 所有键的统一前缀,多个应用共用一个 Redis 时靠它隔离 + */ + public record Redis( + @NotBlank String uri, @DefaultValue("yoke:") String keyPrefix) { + + /** record 自动生成的 toString 会原样带出连接串,日志或调试输出里就泄漏密码,所以这里脱敏。 */ + @Override + public String toString() { + return "Redis[uri=" + (uri == null ? null : RedisConfiguration.redact(uri)) + ", keyPrefix=" + keyPrefix + "]"; + } + } + + /** + * 对话压缩配置,只按消息条数触发。 + * + *

非法值(小于等于 0)回落到默认值;保留条数必须小于触发条数,否则一次压缩什么也切不出来, + * 这种组合同样整体回落到默认值。 + * + * @param triggerMessages 对话消息达到这个条数就触发压缩 + * @param keepMessages 压缩后原样保留最近多少条 + */ + public record Compaction( + @DefaultValue("30") int triggerMessages, + @DefaultValue("10") int keepMessages) { + + private static final Logger log = LoggerFactory.getLogger(Compaction.class); + + public static final int DEFAULT_TRIGGER_MESSAGES = 30; + public static final int DEFAULT_KEEP_MESSAGES = 10; + + public Compaction { + if (triggerMessages <= 0 || keepMessages <= 0 || keepMessages >= triggerMessages) { + log.warn("压缩配置不合法(trigger-messages={}, keep-messages={}),回落到默认值 {} / {}", + triggerMessages, keepMessages, DEFAULT_TRIGGER_MESSAGES, DEFAULT_KEEP_MESSAGES); + triggerMessages = DEFAULT_TRIGGER_MESSAGES; + keepMessages = DEFAULT_KEEP_MESSAGES; + } + } + } +} diff --git a/src/main/java/com/yoke/controller/ChatController.java b/src/main/java/com/yoke/controller/ChatController.java index ee5412f..d4563f6 100644 --- a/src/main/java/com/yoke/controller/ChatController.java +++ b/src/main/java/com/yoke/controller/ChatController.java @@ -6,11 +6,12 @@ import com.yoke.api.ChatApi; import com.yoke.codec.EventCodec; import com.yoke.codec.MessageCodec; +import com.yoke.config.HarnessAgentFactory; import com.yoke.model.ChatEvent; import com.yoke.model.ChatRequest; import com.yoke.model.ChatResponse; -import io.agentscope.core.ReActAgent; import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.HarnessAgent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Flux; @@ -27,31 +28,33 @@ public class ChatController implements ChatApi { private static final String DEFAULT_USER_ID = "default"; - private final ReActAgent.Builder agentBuilder; + private final HarnessAgentFactory agentFactory; @Override public Mono chat(ChatRequest request) { + String userId = resolveUserId(request); String sessionId = resolveSessionId(request); return Mono.using( - agentBuilder::build, - agent -> agent.call(MessageCodec.toInput(request.message()), contextOf(sessionId)) + agentFactory::create, + agent -> agent.call(MessageCodec.toInput(request.message()), contextOf(userId, sessionId)) .map(reply -> new ChatResponse(sessionId, MessageCodec.toText(reply))), - ReActAgent::close); + HarnessAgent::close); } @Override public Flux> stream(ChatRequest request) { + String userId = resolveUserId(request); String sessionId = resolveSessionId(request); return Flux.using( - agentBuilder::build, + agentFactory::create, agent -> { EventCodec codec = new EventCodec(sessionId); - return agent.streamEvents(MessageCodec.toInput(request.message()), contextOf(sessionId)) + return agent.streamEvents(MessageCodec.toInput(request.message()), contextOf(userId, sessionId)) .concatMapIterable(codec::decode); }, - ReActAgent::close) + HarnessAgent::close) .onErrorResume(error -> { log.warn("流式对话出错,sessionId={}", sessionId, error); return Flux.just(new ChatEvent.Error(describe(error))); @@ -65,9 +68,15 @@ private static String resolveSessionId(ChatRequest request) { .orElseGet(() -> UUID.randomUUID().toString()); } - private static RuntimeContext contextOf(String sessionId) { + private static String resolveUserId(ChatRequest request) { + return Optional.ofNullable(request.userId()) + .filter(StringUtils::hasText) + .orElse(DEFAULT_USER_ID); + } + + private static RuntimeContext contextOf(String userId, String sessionId) { return RuntimeContext.builder() - .userId(DEFAULT_USER_ID) + .userId(userId) .sessionId(sessionId) .build(); } diff --git a/src/main/java/com/yoke/middleware/LoggingMiddleware.java b/src/main/java/com/yoke/middleware/LoggingMiddleware.java new file mode 100644 index 0000000..fb8e137 --- /dev/null +++ b/src/main/java/com/yoke/middleware/LoggingMiddleware.java @@ -0,0 +1,76 @@ +package com.yoke.middleware; + +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.middleware.ActingInput; +import io.agentscope.core.middleware.AgentInput; +import io.agentscope.core.middleware.MiddlewareBase; +import io.agentscope.core.middleware.ModelCallInput; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Flux; +import reactor.core.publisher.SignalType; + +import org.springframework.stereotype.Component; + +/** + * 记录一次对话的耗时轨迹:整次调用、每次模型调用、每批工具调用各一条日志。 + * + *

只记录 agentId、userId、sessionId、耗时和工具名,不记录消息内容,也不记录工具的参数和输出, + * 因为这些里面可能有用户隐私或密钥。出错只记 warn 和异常类型,异常本身继续向上抛,不吞掉。 + * + *

2.0.4 里 {@link MiddlewareBase} 是接口,钩子都有默认实现,这里只覆盖三个。 + * 钩子返回的是事件流,所以计时必须放进 {@code Flux.defer}:订阅时才开始计,而不是构造流的时候。 + */ +@Slf4j +@Component +public class LoggingMiddleware implements MiddlewareBase { + + @Override + public Flux onAgent( + Agent agent, RuntimeContext ctx, AgentInput input, Function> next) { + return traced("agent", agent, ctx, "", () -> next.apply(input)); + } + + @Override + public Flux onModelCall( + Agent agent, RuntimeContext ctx, ModelCallInput input, Function> next) { + String model = input.model() == null ? "unknown" : input.model().getModelName(); + return traced("model", agent, ctx, "model=" + model, () -> next.apply(input)); + } + + @Override + public Flux onActing( + Agent agent, RuntimeContext ctx, ActingInput input, Function> next) { + List tools = input.toolCalls().stream().map(ToolUseBlock::getName).toList(); + return traced("acting", agent, ctx, "tools=" + tools, () -> next.apply(input)); + } + + private static Flux traced( + String phase, Agent agent, RuntimeContext ctx, String detail, Supplier> call) { + return Flux.defer(() -> { + long start = System.nanoTime(); + String who = "agent=" + agent.getName() + " agentId=" + agent.getAgentId() + " userId=" + ctx.getUserId() + " sessionId=" + ctx.getSessionId(); + return call.get() + .doOnError(error -> log.warn("{} 出错 {} {} 耗时={}ms 异常={}", + phase, who, detail, elapsedMillis(start), error.getClass().getSimpleName())) + // 用 doFinally 而不是 doOnComplete + doOnCancel:下游拿到结果后常常会再 cancel 一次, + // doOnCancel 会在完成之后又打一条「被取消」;doFinally 保证每次订阅只收尾一次 + .doFinally(signal -> { + if (signal != SignalType.ON_ERROR) { + log.debug("{} {} {} {} 耗时={}ms", phase, + signal == SignalType.CANCEL ? "被取消" : "完成", who, detail, elapsedMillis(start)); + } + }); + }); + } + + private static long elapsedMillis(long startNanos) { + return (System.nanoTime() - startNanos) / 1_000_000; + } +} diff --git a/src/main/java/com/yoke/model/ChatRequest.java b/src/main/java/com/yoke/model/ChatRequest.java index 4f7d410..610c063 100644 --- a/src/main/java/com/yoke/model/ChatRequest.java +++ b/src/main/java/com/yoke/model/ChatRequest.java @@ -5,8 +5,17 @@ /** * 对话请求。 * - * @param sessionId 会话 ID,同一个 ID 共享对话历史;不传则由服务端新建会话,所以允许为空 + * @param sessionId 会话 ID,同一个 (userId, sessionId) 共享对话历史;不传则由服务端新建会话,所以允许为空;格式见 {@link SafeId} * @param message 用户输入,必填 + * @param userId 用户标识,不传时使用 {@code default},格式见 {@link SafeId};暂时没有鉴权,调用方自行保证它可信 */ -public record ChatRequest(String sessionId, @NotBlank(message = "message 不能为空") String message) { +public record ChatRequest( + @SafeId String sessionId, + @NotBlank(message = "message 不能为空") String message, + @SafeId String userId) { + + /** 不指定用户,落在 {@code default} 用户下。 */ + public ChatRequest(String sessionId, String message) { + this(sessionId, message, null); + } } 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..296f8c5 --- /dev/null +++ b/src/main/java/com/yoke/model/SafeId.java @@ -0,0 +1,42 @@ +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 jakarta.validation.Constraint; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.Payload; + +/** + * 用户标识和会话标识的格式约束:1 到 64 个字母、数字、下划线、点、短横线,且不能全是点(排除 {@code .}、{@code ..})。 + * + *

框架把槽位拼成 {@code userId + "/" + sessionId},允许斜杠会让 (a, "b/c") 与 ("a/b", c) 撞到同一个槽位; + * userId 还会成为工作区目录名,{@code ..} 会越出目录。null 和空白不在这里拦截,由服务端补默认值或新建。 + */ +@Documented +@Constraint(validatedBy = SafeId.Validator.class) +@Target({ElementType.RECORD_COMPONENT, ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface SafeId { + + String message() default "只能包含字母、数字、下划线、点和短横线,长度 1 到 64,且不能全是点"; + + Class[] groups() default {}; + + Class[] payload() default {}; + + class Validator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.isBlank()) { + return true; + } + return value.matches("[A-Za-z0-9_.-]{1,64}") && !value.matches("\\.+"); + } + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ef321b7..baef002 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -3,6 +3,11 @@ agentscope: openai: api-key: ${OPENAI_API_KEY:} +yoke: + redis: + # 对应 docker-compose.yml 里的 Redis,本地研发先 docker compose up -d + uri: ${YOKE_REDIS_URI:redis://127.0.0.1:6379} + logging: level: com.yoke: debug diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7157363..e64d1d2 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -6,6 +6,11 @@ agentscope: # 生产环境必须通过环境变量提供密钥,没有设置时启动即失败,避免带着空密钥上线 api-key: ${OPENAI_API_KEY} +yoke: + redis: + # 生产环境必须通过环境变量提供 Redis 连接串(含密码时用 redis://:密码@主机:端口),没有设置时启动即失败 + uri: ${YOKE_REDIS_URI} + logging: level: com.yoke: info diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 4b01fa5..f86f233 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -2,3 +2,7 @@ agentscope: openai: api-key: sk-test + +yoke: + # 测试用独立的工作区;Redis 连接串由测试基类注入(Testcontainers 拉起的容器),这里故意不写 + workspace: ${java.io.tmpdir}/yoke-test-workspace diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 456094f..a2a4df1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -13,6 +13,17 @@ logging: pattern: console: "%red(%d{yyyy-MM-dd HH:mm:ss}) %green([%thread]) %highlight(%-5level) %green(%X{traceId:-},%X{spanId:-}) %boldMagenta(%logger{50}) - %cyan(%msg%n)" +yoke: + # HarnessAgent 的工作区:压缩前转存的消息、记忆文件等放在这里,只在本机,不随会话历史进 Redis + workspace: ${user.home}/.yoke/workspace + redis: + # 所有 Redis 键的统一前缀;连接串 uri 见各环境文件,没有默认值 + key-prefix: "yoke:" + compaction: + # 对话消息达到 trigger-messages 条就压缩,压缩后原样保留最近 keep-messages 条;<=0 的非法值回落到 30 / 10 + trigger-messages: 30 + keep-messages: 10 + # Agent 与模型由 AgentScope 官方 starter 根据下面的配置自动创建 # 这里只放各环境通用的配置,密钥和环境差异见 application-{profile}.yml agentscope: @@ -23,8 +34,8 @@ agentscope: # 使用其他 OpenAI 兼容服务时设置 base-url,例如 DashScope: # base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 agent: - # 关闭官方的单例 Agent:Agent 由 AgentConfiguration 的共享 Builder 在每次请求时创建 - # name、sys-prompt、max-iters 仍然生效,被 Builder 读取 + # 关闭官方的单例 Agent:Agent 由 AgentConfiguration 里的 HarnessAgentFactory 在每次请求时新建 + # (Builder 也是每次现建,不共享)。name、sys-prompt、max-iters 仍然生效,被工厂读取 enabled: false name: Yoke sys-prompt: 你是一个乐于助人的 AI 助手,请用简洁清晰的中文回答。 diff --git a/src/test/java/com/yoke/ArchitectureTests.java b/src/test/java/com/yoke/ArchitectureTests.java index 9d08008..c62fa72 100644 --- a/src/test/java/com/yoke/ArchitectureTests.java +++ b/src/test/java/com/yoke/ArchitectureTests.java @@ -19,10 +19,12 @@ @AnalyzeClasses(packages = "com.yoke", importOptions = ImportOption.DoNotIncludeTests.class) class ArchitectureTests { - /** 控制器为了拿到装配好的 Agent,只允许依赖这几个框架类型,其余(Msg、各种事件)一律不行。 */ + /** + * 控制器为了使用每请求新建的 Agent,只允许依赖这几个框架类型,其余(Msg、各种事件)一律不行。 + * HarnessAgent 取代了第 01 轮的 ReActAgent(Builder 已由 HarnessAgentFactory 封装,不再出现在控制器里)。 + */ private static final Set CONTROLLER_ALLOWED = Set.of( - "io.agentscope.core.ReActAgent", - "io.agentscope.core.ReActAgent$Builder", + "io.agentscope.harness.agent.HarnessAgent", "io.agentscope.core.agent.RuntimeContext", "io.agentscope.core.agent.RuntimeContext$Builder"); @@ -48,8 +50,12 @@ public boolean test(JavaClass input) { static final ArchRule 控制器只能依赖Agent装配相关的框架类型 = noClasses().that().resideInAPackage("com.yoke.controller..") .should().dependOnClassesThat(FRAMEWORK_OUTSIDE_ALLOWED_LIST); + /** + * 消息和事件类型只允许出现在 codec,以及实现框架中间件 SPI 的 middleware: + * MiddlewareBase 的钩子签名本身就是 Flux<AgentEvent>,实现它不可避免会碰到这些类型。 + */ @ArchTest - static final ArchRule 框架的消息和事件类型只能出现在codec里 = noClasses() - .that().resideOutsideOfPackage("com.yoke.codec..") + static final ArchRule 框架的消息和事件类型只能出现在codec和middleware里 = noClasses() + .that().resideOutsideOfPackages("com.yoke.codec..", "com.yoke.middleware..") .should().dependOnClassesThat().resideInAnyPackage("io.agentscope.core.message..", "io.agentscope.core.event.."); } diff --git a/src/test/java/com/yoke/CompactionTests.java b/src/test/java/com/yoke/CompactionTests.java new file mode 100644 index 0000000..a27c151 --- /dev/null +++ b/src/test/java/com/yoke/CompactionTests.java @@ -0,0 +1,78 @@ +package com.yoke; + +import java.util.UUID; + +import com.yoke.controller.ChatController; +import com.yoke.model.ChatRequest; +import com.yoke.support.RecordingModel; +import com.yoke.support.RedisIntegrationTest; +import com.yoke.support.StubModelConfiguration; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 把触发条数调小,验证配置真的传到了 HarnessAgent:对话变长后会多出一次摘要调用, + * 之后喂给模型的历史被压短。摘要调用也走假模型,所以能直接在它收到的消息里看到摘要提示词。 + */ +@SpringBootTest(properties = {"yoke.compaction.trigger-messages=6", "yoke.compaction.keep-messages=2"}) +@ActiveProfiles("test") +@Import(StubModelConfiguration.class) +class CompactionTests extends RedisIntegrationTest { + + @Autowired + ChatController controller; + + @Autowired + RecordingModel model; + + @Test + void 对话达到触发条数后被压缩() { + String session = "compaction-" + UUID.randomUUID(); + int before = model.received.size(); + + for (int round = 1; round <= 6; round++) { + controller.chat(new ChatRequest(session, "第" + round + "轮提问")).block(); + } + + var calls = model.received.subList(before, model.received.size()); + assertThat(calls).as("出现过摘要调用").anyMatch(messages -> messages.stream() + .anyMatch(msg -> msg.getTextContent() != null && msg.getTextContent().contains("SESSION INTENT"))); + } + + /** + * 取某一轮推理调用的消息数。回复之后框架还会异步发起记忆整理这类不带工具的调用, + * 所以只看带工具清单的那次(receivedTools 与 received 按调用顺序一一对应)。 + */ + private int reasoningSize(int from, String text) { + int size = -1; + for (int i = from; i < model.received.size(); i++) { + var messages = model.received.get(i); + boolean mentionsText = messages.stream().anyMatch(m -> m.getTextContent() != null && m.getTextContent().contains(text)); + if (mentionsText && !model.receivedTools.get(i).isEmpty()) { + size = messages.size(); + } + } + return size; + } + + @Test + void 压缩后的推理输入会变短而不是一直增长() { + String session = "compaction-len-" + UUID.randomUUID(); + java.util.List sizes = new java.util.ArrayList<>(); + + for (int round = 1; round <= 8; round++) { + int before = model.received.size(); + controller.chat(new ChatRequest(session, "第" + round + "轮提问")).block(); + sizes.add(reasoningSize(before, "第" + round + "轮提问")); + } + + // 不压缩时每一轮的输入都比上一轮长;压缩发生后最后一轮应比之前出现过的最大值短 + assertThat(sizes.getLast()).as("各轮推理输入的消息数 %s", sizes).isLessThan(java.util.Collections.max(sizes)); + } +} diff --git a/src/test/java/com/yoke/ProfilesTests.java b/src/test/java/com/yoke/ProfilesTests.java index e8f151e..9dfd835 100644 --- a/src/test/java/com/yoke/ProfilesTests.java +++ b/src/test/java/com/yoke/ProfilesTests.java @@ -1,11 +1,18 @@ package com.yoke; +import java.io.IOException; +import java.util.List; + +import com.yoke.support.RedisIntegrationTest; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.env.YamlPropertySourceLoader; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.core.env.Environment; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ClassPathResource; import org.springframework.test.context.ActiveProfiles; import static org.assertj.core.api.Assertions.assertThat; @@ -14,8 +21,31 @@ * 验证各环境的配置文件按预期加载。 * *

local 和 prod 的密钥来自环境变量 OPENAI_API_KEY,这里用同名属性代替,避免依赖本机环境。 + * 启动完整上下文需要 Redis,连接串由共享的 Testcontainers 容器注入;Redis 连接串在各环境文件里的写法 + * 会被注入的值覆盖,所以单独直接读文件来断言。 */ -class ProfilesTests { +class ProfilesTests extends RedisIntegrationTest { + + private static Object yamlValue(String file, String key) throws IOException { + List> sources = new YamlPropertySourceLoader().load(file, new ClassPathResource(file)); + return sources.getFirst().getProperty(key); + } + + @Test + void local的Redis连接串有本机默认值() throws IOException { + assertThat(yamlValue("application-local.yml", "yoke.redis.uri")) + .isEqualTo("${YOKE_REDIS_URI:redis://127.0.0.1:6379}"); + } + + @Test + void prod的Redis连接串来自环境变量且没有默认值() throws IOException { + assertThat(yamlValue("application-prod.yml", "yoke.redis.uri")).isEqualTo("${YOKE_REDIS_URI}"); + } + + @Test + void test环境不写Redis连接串而是由测试注入() throws IOException { + assertThat(yamlValue("application-test.yml", "yoke.redis.uri")).isNull(); + } @Nested @SpringBootTest(properties = "OPENAI_API_KEY=sk-test") @@ -43,6 +73,8 @@ class 测试环境 { void 使用假密钥() { assertThat(environment.getProperty("agentscope.openai.api-key")).isEqualTo("sk-test"); assertThat(environment.getProperty("logging.level.com.yoke")).isNull(); + assertThat(environment.getProperty("yoke.redis.uri")).startsWith("redis://"); + assertThat(environment.getProperty("yoke.redis.key-prefix")).isEqualTo("yoke:"); } } diff --git a/src/test/java/com/yoke/RedisFailFastTests.java b/src/test/java/com/yoke/RedisFailFastTests.java new file mode 100644 index 0000000..b1198c8 --- /dev/null +++ b/src/test/java/com/yoke/RedisFailFastTests.java @@ -0,0 +1,68 @@ +package com.yoke; + +import com.yoke.support.RedisCompose; +import com.yoke.support.RequiresDocker; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ConfigurableApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Redis 配置有问题时应用必须在启动期失败,而不是等到第一个请求才暴露。 + * 这些测试不依赖 Docker:连的是一个没人监听的端口,或者干脆不给连接串。 + */ +class RedisFailFastTests { + + private static SpringApplicationBuilder app(String profile) { + return new SpringApplicationBuilder(YokeApplication.class) + .web(WebApplicationType.NONE) + .profiles(profile) + .properties("OPENAI_API_KEY=sk-test"); + } + + @Test + void Redis不可达时上下文启动失败且提示清晰() { + assertThatThrownBy(() -> app("test").properties("yoke.redis.uri=redis://:s3cret@127.0.0.1:1").run()) + .hasStackTraceContaining("无法连接 Redis") + .hasStackTraceContaining("docker compose up -d") + .as("报错信息里不能带出密码") + .satisfies(error -> assertThat(rootMessages(error)).doesNotContain("s3cret")); + } + + @Test + void scheme不合法且带密码时启动失败信息也不含密码() { + assertThatThrownBy(() -> app("test").properties("yoke.redis.uri=http://:Pw9x@h").run()) + .hasStackTraceContaining("必须是 redis:// 或 rediss:// 开头的连接串") + .satisfies(error -> assertThat(rootMessages(error)).doesNotContain("Pw9x")); + } + + @Test + void 生产环境没有给Redis连接串时启动失败() { + // prod 的连接串来自环境变量 YOKE_REDIS_URI,没有默认值 + assertThatThrownBy(() -> app("prod").run()) + .hasStackTraceContaining("yoke.redis.uri") + .hasStackTraceContaining("必须是 redis:// 或 rediss:// 开头的连接串") + .hasStackTraceContaining("YOKE_REDIS_URI"); + } + + @Test + @ExtendWith(RequiresDocker.class) + void Redis可达时正常启动() { + try (ConfigurableApplicationContext context = app("test").properties("yoke.redis.uri=" + RedisCompose.uri()).run()) { + assertThat(context.isRunning()).isTrue(); + } + } + + private static String rootMessages(Throwable error) { + StringBuilder all = new StringBuilder(); + for (Throwable t = error; t != null; t = t.getCause()) { + all.append(t.getMessage()).append('\n'); + } + return all.toString(); + } +} diff --git a/src/test/java/com/yoke/RestartRecoveryTests.java b/src/test/java/com/yoke/RestartRecoveryTests.java new file mode 100644 index 0000000..54fd774 --- /dev/null +++ b/src/test/java/com/yoke/RestartRecoveryTests.java @@ -0,0 +1,54 @@ +package com.yoke; + +import java.util.UUID; + +import com.yoke.controller.ChatController; +import com.yoke.model.ChatRequest; +import com.yoke.support.RecordingModel; +import com.yoke.support.RedisCompose; +import com.yoke.support.RequiresDocker; +import com.yoke.support.StubModelConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ConfigurableApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 用两个互相独立的 Spring 上下文模拟「进程重启」:第一个上下文关闭后,第二个全新的上下文 + * (新的 Redis 连接、新的 Agent、新的内存)仍然能读到第一个写下的历史。 + */ +@ExtendWith(RequiresDocker.class) +class RestartRecoveryTests { + + private static ConfigurableApplicationContext start() { + return new SpringApplicationBuilder(YokeApplication.class, StubModelConfiguration.class) + .web(WebApplicationType.NONE) + .profiles("test") + .properties("yoke.redis.uri=" + RedisCompose.uri()) + .run(); + } + + @Test + void 重启后同一会话的历史仍在() { + String session = "restart-" + UUID.randomUUID(); + + try (ConfigurableApplicationContext before = start()) { + before.getBean(ChatController.class).chat(new ChatRequest(session, "重启之前说的话", "dave")).block(); + } + + assertThat(RedisCompose.keys("yoke:session:dave/" + session + ":*")).as("第一个上下文关闭后,历史留在 Redis 里").isNotEmpty(); + + try (ConfigurableApplicationContext after = start()) { + RecordingModel model = after.getBean(RecordingModel.class); + assertThat(model.received).as("全新的上下文里模型还没被调用过").isEmpty(); + + after.getBean(ChatController.class).chat(new ChatRequest(session, "重启之后说的话", "dave")).block(); + + assertThat(model.lastCallContains("重启之前说的话")).isTrue(); + } + } +} diff --git a/src/test/java/com/yoke/ToolPermissionTests.java b/src/test/java/com/yoke/ToolPermissionTests.java new file mode 100644 index 0000000..229ba22 --- /dev/null +++ b/src/test/java/com/yoke/ToolPermissionTests.java @@ -0,0 +1,83 @@ +package com.yoke; + +import java.nio.file.Path; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; + +import com.yoke.controller.ChatController; +import com.yoke.model.ChatRequest; +import com.yoke.support.RecordingModel; +import com.yoke.support.RedisIntegrationTest; +import com.yoke.support.StubModelConfiguration; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.state.InMemoryAgentStateStore; +import io.agentscope.harness.agent.HarnessAgent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 权限取舍:对外服务的 Agent 看不到文件读写、命令执行和联网工具。 + * 以「什么都不关的 HarnessAgent 比线上多出哪些工具」作为参照,避免硬编码工具名后框架改名而悄悄失效。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Import(StubModelConfiguration.class) +class ToolPermissionTests extends RedisIntegrationTest { + + @Autowired + ChatController controller; + + @Autowired + RecordingModel model; + + private static Set toolsOfUnrestrictedAgent(Path workspace) { + RecordingModel reference = new RecordingModel(); + try (HarnessAgent agent = HarnessAgent.builder().name("reference").model(reference) + .workspace(workspace).stateStore(new InMemoryAgentStateStore()).build()) { + agent.call("hi", RuntimeContext.builder().userId("u").sessionId("s").build()).block(); + } + return new TreeSet<>(reference.receivedTools.getFirst()); + } + + /** + * 被关掉的工具:文件读写、命令执行、联网,以及子 Agent 相关的(子 Agent 不继承联网工具的关闭)。 + * 名字来自上面参照 Agent 的实测清单,由本类的第二个测试守着。 + */ + private static final Set FORBIDDEN = Set.of( + "read_file", "write_file", "edit_file", "list_files", "glob_files", "grep_files", + "execute", "web_search", "web_fetch", + "agent_spawn", "agent_send", "agent_list", "task_cancel", "task_list", "task_output"); + + @Test + void 模型看不到文件_命令_联网和子Agent工具() { + controller.chat(new ChatRequest("tools-" + UUID.randomUUID(), "你有哪些工具")).block(); + + // 回复之后框架还会异步发起记忆整理之类不带工具的模型调用,所以取最后一次带工具的调用 + Set exposed = model.receivedTools.stream().filter(tools -> !tools.isEmpty()) + .reduce((first, second) -> second).map(TreeSet::new).orElseThrow(); + + assertThat(exposed).as("模型确实拿到了工具清单").isNotEmpty(); + assertThat(exposed).doesNotContainAnyElementsOf(FORBIDDEN); + } + + @Test + void 被关掉的工具恰好是不加限制时多出来的那一批(@TempDir Path workspace) { + controller.chat(new ChatRequest("tools-" + UUID.randomUUID(), "你有哪些工具")).block(); + Set exposed = model.receivedTools.stream().filter(tools -> !tools.isEmpty()) + .reduce((first, second) -> second).map(TreeSet::new).orElseThrow(); + + Set removed = toolsOfUnrestrictedAgent(workspace); + removed.removeAll(exposed); + + // 框架以后新增了别的文件类工具时,这里会提醒我们把它也关掉 + assertThat(removed).isEqualTo(FORBIDDEN); + } +} diff --git a/src/test/java/com/yoke/VirtualThreadsTests.java b/src/test/java/com/yoke/VirtualThreadsTests.java index 443f0de..6a81828 100644 --- a/src/test/java/com/yoke/VirtualThreadsTests.java +++ b/src/test/java/com/yoke/VirtualThreadsTests.java @@ -5,6 +5,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import com.yoke.support.RedisIntegrationTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -23,7 +24,7 @@ */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") -class VirtualThreadsTests { +class VirtualThreadsTests extends RedisIntegrationTest { /** 仅测试使用:返回处理当前请求的线程是不是虚拟线程。 */ @TestConfiguration(proxyBeanMethods = false) diff --git a/src/test/java/com/yoke/YokeApplicationTests.java b/src/test/java/com/yoke/YokeApplicationTests.java index 95ea8e8..711790f 100644 --- a/src/test/java/com/yoke/YokeApplicationTests.java +++ b/src/test/java/com/yoke/YokeApplicationTests.java @@ -1,7 +1,11 @@ package com.yoke; +import com.yoke.config.HarnessAgentFactory; +import com.yoke.middleware.LoggingMiddleware; +import com.yoke.support.RedisIntegrationTest; import io.agentscope.core.ReActAgent; import io.agentscope.core.model.Model; +import io.agentscope.harness.agent.HarnessAgent; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -12,23 +16,42 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * 验证应用能启动,并且配置文件能驱动官方 starter 创建出模型和 Agent(不会真正调用模型)。 + * 验证应用能启动,并且配置文件能驱动官方 starter 创建出模型、工厂能构建出 HarnessAgent(不会真正调用模型)。 */ @SpringBootTest @ActiveProfiles("test") -class YokeApplicationTests { +class YokeApplicationTests extends RedisIntegrationTest { @Autowired ApplicationContext context; @Test - void 能创建模型并由共享Builder构建Agent() { + void 能创建模型并由工厂构建HarnessAgent() { assertThat(context.getBean(Model.class)).isNotNull(); - try (ReActAgent agent = context.getBean(ReActAgent.Builder.class).build()) { + try (HarnessAgent agent = context.getBean(HarnessAgentFactory.class).create()) { assertThat(agent.getName()).isEqualTo("Yoke"); } } + @Test + void 日志中间件挂在每个新建的Agent上且不会越积越多() { + HarnessAgentFactory factory = context.getBean(HarnessAgentFactory.class); + LoggingMiddleware logging = context.getBean(LoggingMiddleware.class); + + int sizes[] = new int[3]; + for (int i = 0; i < sizes.length; i++) { + try (HarnessAgent agent = factory.create()) { + var middlewares = agent.getDelegate().getMiddlewares(); + assertThat(middlewares).containsOnlyOnce(logging); + sizes[i] = middlewares.size(); + } + } + + // 如果像 ReActAgent 那样共享同一个 HarnessAgent.Builder,2.0.4 每次 build 都会多追加一批中间件 + assertThat(sizes[1]).isEqualTo(sizes[0]); + assertThat(sizes[2]).isEqualTo(sizes[0]); + } + @Test void 官方的单例Agent已关闭() { assertThat(context.getBeansOfType(ReActAgent.class)).isEmpty(); diff --git a/src/test/java/com/yoke/config/AgentConfigurationTests.java b/src/test/java/com/yoke/config/AgentConfigurationTests.java new file mode 100644 index 0000000..2323ef9 --- /dev/null +++ b/src/test/java/com/yoke/config/AgentConfigurationTests.java @@ -0,0 +1,60 @@ +package com.yoke.config; + +import io.agentscope.core.middleware.MiddlewareBase; +import io.agentscope.core.model.Model; +import io.agentscope.harness.agent.DistributedStore; +import io.agentscope.spring.boot.properties.AgentscopeProperties; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class AgentConfigurationTests { + + @Configuration(proxyBeanMethods = false) + @Import(AgentConfiguration.class) + static class Base { + + @Bean + Model model() { + return mock(Model.class); + } + + @Bean + DistributedStore distributedStore() { + return mock(DistributedStore.class); + } + + @Bean + AgentscopeProperties agentscopeProperties() { + return new AgentscopeProperties(); + } + + @Bean + YokeProperties yokeProperties() { + return new YokeProperties(new YokeProperties.Redis("redis://127.0.0.1:6379", "yoke:"), + new YokeProperties.Compaction(30, 10), "/tmp/yoke-test"); + } + } + + private final ApplicationContextRunner runner = new ApplicationContextRunner().withUserConfiguration(Base.class); + + @Test + void 容器里没有任何中间件也能启动() { + runner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(HarnessAgentFactory.class); + }); + } + + @Test + void 有中间件Bean时也能启动() { + runner.withBean(MiddlewareBase.class, () -> new MiddlewareBase() { }) + .run(context -> assertThat(context).hasNotFailed()); + } +} diff --git a/src/test/java/com/yoke/config/RedisConfigurationTests.java b/src/test/java/com/yoke/config/RedisConfigurationTests.java new file mode 100644 index 0000000..67ce696 --- /dev/null +++ b/src/test/java/com/yoke/config/RedisConfigurationTests.java @@ -0,0 +1,78 @@ +package com.yoke.config; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RedisConfigurationTests { + + private static YokeProperties withUri(String uri) { + return new YokeProperties(new YokeProperties.Redis(uri, "yoke:"), new YokeProperties.Compaction(30, 10), "/tmp/yoke"); + } + + private static String allMessages(Throwable error) { + StringBuilder all = new StringBuilder(); + for (Throwable t = error; t != null; t = t.getCause()) { + all.append(t.getClass().getName()).append(": ").append(t.getMessage()).append('\n'); + } + return all.toString(); + } + + @Test + void 格式非法的连接串报错时不带出密码() { + assertThatThrownBy(() -> new RedisConfiguration().jedis(withUri("redis://user:s3cret@[bad host:6379"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("无法连接 Redis") + .satisfies(error -> assertThat(allMessages(error)).doesNotContain("s3cret")); + } + + @Test + void 连不上时报错也不带出密码() { + assertThatThrownBy(() -> new RedisConfiguration().jedis(withUri("redis://:s3cret@127.0.0.1:1"))) + .isInstanceOf(IllegalStateException.class) + .satisfies(error -> assertThat(allMessages(error)).doesNotContain("s3cret")); + } + + @Test + void 脱敏只替换用户信息() { + assertThat(RedisConfiguration.redact("redis://:s3cret@host:6379")).isEqualTo("redis://***@host:6379"); + assertThat(RedisConfiguration.redact("redis://host:6379")).isEqualTo("redis://host:6379"); + } + + @Test + void 密码里有未编码的at符号和斜杠等笔误时脱敏仍然有效() { + for (String uri : new String[] { + "redis://:p@ss@host:6379", "redis://user:p@ss@host:6379", "redis://:pa/ss@host:6379", + "redis://:pa?ss@host:6379", "redis://:pa#ss@host:6379", "redis://:p@ss@host:6379/0?timeout=1"}) { + String redacted = RedisConfiguration.redact(uri); + assertThat(redacted).as(uri).startsWith("redis://***@").doesNotContain("ss", "pa", "user"); + } + } + + @Test + void 这些笔误连接串在连不上时报错信息也不带出密码片段() { + for (String uri : new String[] {"redis://:Pw9x@ss@127.0.0.1:1", "redis://u:Pw9x/ss@127.0.0.1:1", "redis://:Pw9x?ss@127.0.0.1:1"}) { + assertThatThrownBy(() -> new RedisConfiguration().jedis(withUri(uri))) + .isInstanceOf(IllegalStateException.class) + .satisfies(error -> assertThat(allMessages(error)).as(uri).doesNotContain("Pw9x")); + } + } + + @Test + void scheme不合法时报错且不带出密码() { + assertThatThrownBy(() -> new RedisConfiguration().jedis(withUri("http://:Pw9x@host:6379"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("必须是 redis:// 或 rediss:// 开头的连接串") + .hasMessageContaining("YOKE_REDIS_URI") + .satisfies(error -> assertThat(allMessages(error)).doesNotContain("Pw9x")); + } + + @Test + void Redis配置的toString不带出密码() { + var redis = new YokeProperties.Redis("redis://user:Pw9x@ss@127.0.0.1:6379", "yoke:"); + + assertThat(redis.toString()).doesNotContain("Pw9x", "user").contains("127.0.0.1:6379"); + assertThat(withUri("redis://:Pw9x@h").toString()).doesNotContain("Pw9x"); + } +} diff --git a/src/test/java/com/yoke/config/YokePropertiesTests.java b/src/test/java/com/yoke/config/YokePropertiesTests.java new file mode 100644 index 0000000..36fd58f --- /dev/null +++ b/src/test/java/com/yoke/config/YokePropertiesTests.java @@ -0,0 +1,98 @@ +package com.yoke.config; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.agentscope.harness.agent.memory.compaction.CompactionConfig; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +class YokePropertiesTests { + + private static YokeProperties bind(Map properties) { + return new Binder(new MapConfigurationPropertySource(properties)).bind("yoke", YokeProperties.class).get(); + } + + private static Map required() { + Map properties = new HashMap<>(); + properties.put("yoke.redis.uri", "redis://127.0.0.1:6379"); + properties.put("yoke.workspace", "/tmp/yoke"); + return properties; + } + + @Test + void 压缩配置缺省时使用默认值() { + YokeProperties.Compaction compaction = bind(required()).compaction(); + + assertThat(compaction.triggerMessages()).isEqualTo(30); + assertThat(compaction.keepMessages()).isEqualTo(10); + } + + @Test + void 压缩配置合法时按配置生效() { + Map properties = required(); + properties.put("yoke.compaction.trigger-messages", "50"); + properties.put("yoke.compaction.keep-messages", "20"); + + YokeProperties.Compaction compaction = bind(properties).compaction(); + + assertThat(compaction.triggerMessages()).isEqualTo(50); + assertThat(compaction.keepMessages()).isEqualTo(20); + } + + @Test + void 触发条数小于等于0时回落到默认值() { + assertThat(new YokeProperties.Compaction(0, 10)).isEqualTo(new YokeProperties.Compaction(30, 10)); + assertThat(new YokeProperties.Compaction(-5, 3)).isEqualTo(new YokeProperties.Compaction(30, 10)); + } + + @Test + void 保留条数小于等于0时回落到默认值() { + assertThat(new YokeProperties.Compaction(40, 0)).isEqualTo(new YokeProperties.Compaction(30, 10)); + assertThat(new YokeProperties.Compaction(40, -1)).isEqualTo(new YokeProperties.Compaction(30, 10)); + } + + @Test + void 保留条数不小于触发条数时也回落到默认值() { + assertThat(new YokeProperties.Compaction(10, 10)).isEqualTo(new YokeProperties.Compaction(30, 10)); + assertThat(new YokeProperties.Compaction(10, 20)).isEqualTo(new YokeProperties.Compaction(30, 10)); + } + + @Test + void 配置里的非法值经过绑定也会回落() { + Map properties = required(); + properties.put("yoke.compaction.trigger-messages", "-1"); + properties.put("yoke.compaction.keep-messages", "0"); + + assertThat(bind(properties).compaction()).isEqualTo(new YokeProperties.Compaction(30, 10)); + } + + @Test + void Redis键前缀默认是yoke冒号且可以覆盖() { + assertThat(bind(required()).redis().keyPrefix()).isEqualTo("yoke:"); + + Map properties = required(); + properties.put("yoke.redis.key-prefix", "demo:"); + assertThat(bind(properties).redis().keyPrefix()).isEqualTo("demo:"); + } + + @Test + void 只按条数触发压缩且token阈值不会先触发() { + HarnessAgentFactory factory = new HarnessAgentFactory( + "Yoke", "prompt", 10, null, null, null, new YokeProperties.Compaction(30, 10), List.of()); + + CompactionConfig config = factory.compactionConfig(); + + assertThat(config.getTriggerMessages()).isEqualTo(30); + assertThat(config.getKeepMessages()).isEqualTo(10); + // 0 在 2.0.4 里是「按上下文窗口动态计算」,不是关闭;用最大值让 token 阈值实际上不会先触发 + assertThat(config.getTriggerTokens()).isEqualTo(Integer.MAX_VALUE); + // 0 才改用 keepMessages,默认 -1 是动态 token + assertThat(config.getKeepTokens()).isZero(); + } +} diff --git a/src/test/java/com/yoke/controller/ChatControllerTests.java b/src/test/java/com/yoke/controller/ChatControllerTests.java index 44488f2..c3e6d4f 100644 --- a/src/test/java/com/yoke/controller/ChatControllerTests.java +++ b/src/test/java/com/yoke/controller/ChatControllerTests.java @@ -6,7 +6,7 @@ import com.yoke.model.ChatEvent; import com.yoke.model.ChatRequest; import com.yoke.model.ChatResponse; -import io.agentscope.core.ReActAgent; +import com.yoke.config.HarnessAgentFactory; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; @@ -15,6 +15,7 @@ import io.agentscope.core.message.AssistantMessage; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; +import io.agentscope.harness.agent.HarnessAgent; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import reactor.core.Disposable; @@ -35,12 +36,12 @@ class ChatControllerTests { - private final ReActAgent agent = mock(ReActAgent.class); - private final ReActAgent.Builder agentBuilder = mock(ReActAgent.Builder.class); - private final ChatController controller = new ChatController(agentBuilder); + private final HarnessAgent agent = mock(HarnessAgent.class); + private final HarnessAgentFactory agentFactory = mock(HarnessAgentFactory.class); + private final ChatController controller = new ChatController(agentFactory); ChatControllerTests() { - when(agentBuilder.build()).thenReturn(agent); + when(agentFactory.create()).thenReturn(agent); } private static Msg reply(String text) { @@ -82,6 +83,52 @@ private static Msg reply(String text) { assertThat(response.sessionId()).isNotBlank().isNotEqualTo(" "); } + @Test + void 不传用户时落在default用户下() { + when(agent.call(anyList(), any(RuntimeContext.class))).thenReturn(Mono.just(reply("ok"))); + + controller.chat(new ChatRequest("s-1", "hi")).block(); + + ArgumentCaptor context = ArgumentCaptor.forClass(RuntimeContext.class); + verify(agent).call(anyList(), context.capture()); + assertThat(context.getValue().getUserId()).isEqualTo("default"); + } + + @Test + void 带用户ID时使用该用户() { + when(agent.call(anyList(), any(RuntimeContext.class))).thenReturn(Mono.just(reply("ok"))); + + controller.chat(new ChatRequest("s-1", "hi", "alice")).block(); + + ArgumentCaptor context = ArgumentCaptor.forClass(RuntimeContext.class); + verify(agent).call(anyList(), context.capture()); + assertThat(context.getValue().getUserId()).isEqualTo("alice"); + assertThat(context.getValue().getSessionId()).isEqualTo("s-1"); + } + + @Test + void 只给用户ID时为该用户新建会话而不是无会话() { + when(agent.call(anyList(), any(RuntimeContext.class))).thenReturn(Mono.just(reply("ok"))); + + ChatResponse response = controller.chat(new ChatRequest(null, "hi", "alice")).block(); + + ArgumentCaptor context = ArgumentCaptor.forClass(RuntimeContext.class); + verify(agent).call(anyList(), context.capture()); + assertThat(context.getValue().getUserId()).isEqualTo("alice"); + assertThat(context.getValue().getSessionId()).isNotBlank().isEqualTo(response.sessionId()); + } + + @Test + void 用户ID为空白时回落到default用户() { + when(agent.call(anyList(), any(RuntimeContext.class))).thenReturn(Mono.just(reply("ok"))); + + controller.chat(new ChatRequest("s-1", "hi", " ")).block(); + + ArgumentCaptor context = ArgumentCaptor.forClass(RuntimeContext.class); + verify(agent).call(anyList(), context.capture()); + assertThat(context.getValue().getUserId()).isEqualTo("default"); + } + @Test void 调用完成后关闭本次创建的Agent() { when(agent.call(anyList(), any(RuntimeContext.class))).thenReturn(Mono.just(reply("ok"))); diff --git a/src/test/java/com/yoke/controller/ChatControllerWebTests.java b/src/test/java/com/yoke/controller/ChatControllerWebTests.java index a9bb50e..9aa2c87 100644 --- a/src/test/java/com/yoke/controller/ChatControllerWebTests.java +++ b/src/test/java/com/yoke/controller/ChatControllerWebTests.java @@ -1,6 +1,6 @@ package com.yoke.controller; -import io.agentscope.core.ReActAgent; +import com.yoke.config.HarnessAgentFactory; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentResultEvent; @@ -8,8 +8,10 @@ import io.agentscope.core.message.AssistantMessage; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; +import io.agentscope.harness.agent.HarnessAgent; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -21,6 +23,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -41,14 +44,14 @@ class ChatControllerWebTests { MockMvc mvc; @MockitoBean - ReActAgent.Builder agentBuilder; + HarnessAgentFactory agentFactory; @MockitoBean - ReActAgent agent; + HarnessAgent agent; @BeforeEach - void 让Builder返回模拟的Agent() { - when(agentBuilder.build()).thenReturn(agent); + void 让工厂返回模拟的Agent() { + when(agentFactory.create()).thenReturn(agent); } @Test @@ -73,6 +76,56 @@ class ChatControllerWebTests { } } + @Test + void 请求体里的userId会传给Agent() throws Exception { + Msg reply = Msg.builder().role(MsgRole.ASSISTANT).textContent("你好呀").build(); + when(agent.call(anyList(), any(RuntimeContext.class))).thenReturn(Mono.just(reply)); + + mvc.perform(post("/api/chat").contentType(MediaType.APPLICATION_JSON) + .content("{\"userId\":\"alice\",\"sessionId\":\"s-1\",\"message\":\"你好\"}")) + .andExpect(status().isOk()); + + ArgumentCaptor context = ArgumentCaptor.forClass(RuntimeContext.class); + verify(agent).call(anyList(), context.capture()); + assertThat(context.getValue().getUserId()).isEqualTo("alice"); + } + + @Test + void 非法的userId或sessionId返回400() throws Exception { + String[][] illegal = { + {"userId", "a/b"}, {"userId", ".."}, {"userId", "."}, {"userId", "含中文"}, + {"userId", "x".repeat(65)}, {"userId", "a b"}, + {"sessionId", "b/c"}, {"sessionId", ".."}, {"sessionId", "../etc"}, {"sessionId", "x".repeat(65)}}; + for (String[] field : illegal) { + mvc.perform(post("/api/chat").contentType(MediaType.APPLICATION_JSON) + .content("{\"" + field[0] + "\":\"" + field[1] + "\",\"message\":\"你好\"}")) + .andExpect(status().isBadRequest()); + } + } + + @Test + void 流式接口同样拦截非法标识() throws Exception { + mvc.perform(post("/api/chat/stream").contentType(MediaType.APPLICATION_JSON) + .content("{\"userId\":\"a/b\",\"message\":\"你好\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void 合法标识和缺失_空白的标识都放行() throws Exception { + Msg reply = Msg.builder().role(MsgRole.ASSISTANT).textContent("ok").build(); + when(agent.call(anyList(), any(RuntimeContext.class))).thenReturn(Mono.just(reply)); + + String[] bodies = { + "{\"userId\":\"Alice_1.x-y\",\"sessionId\":\"" + "s".repeat(64) + "\",\"message\":\"你好\"}", + "{\"userId\":\"..a\",\"message\":\"你好\"}", + "{\"userId\":\" \",\"sessionId\":\" \",\"message\":\"你好\"}", + "{\"message\":\"你好\"}"}; + for (String body : bodies) { + mvc.perform(post("/api/chat").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()); + } + } + @Test void 只有message时正常返回() throws Exception { Msg reply = Msg.builder().role(MsgRole.ASSISTANT).textContent("你好呀").build(); diff --git a/src/test/java/com/yoke/controller/ChatHistoryTests.java b/src/test/java/com/yoke/controller/ChatHistoryTests.java index 900f9d7..54f6f69 100644 --- a/src/test/java/com/yoke/controller/ChatHistoryTests.java +++ b/src/test/java/com/yoke/controller/ChatHistoryTests.java @@ -1,69 +1,33 @@ package com.yoke.controller; import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; +import java.util.UUID; import com.yoke.model.ChatEvent; import com.yoke.model.ChatRequest; -import io.agentscope.core.message.Msg; -import io.agentscope.core.message.TextBlock; -import io.agentscope.core.model.ChatResponse; -import io.agentscope.core.model.GenerateOptions; -import io.agentscope.core.model.Model; -import io.agentscope.core.model.ToolSchema; +import com.yoke.model.ChatResponse; +import com.yoke.support.RecordingModel; +import com.yoke.support.RedisCompose; +import com.yoke.support.RedisIntegrationTest; +import com.yoke.support.StubModelConfiguration; import org.junit.jupiter.api.Test; -import reactor.core.publisher.Flux; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; import org.springframework.http.codec.ServerSentEvent; import org.springframework.test.context.ActiveProfiles; import static org.assertj.core.api.Assertions.assertThat; /** - * 每次请求都是新建的 Agent,这里验证对话历史确实通过共享的状态存储跨请求保留,并且按会话隔离。 - * 用记录消息的假模型代替真实模型,所以不会产生任何网络调用。 + * 每次请求都是新建的 Agent,这里验证对话历史确实通过 Redis 跨请求保留,并且按 (userId, sessionId) 隔离。 + * 用记录消息的假模型代替真实模型,所以不会产生任何模型调用;Redis 是 docker-compose.yml 拉起的真实实例。 */ @SpringBootTest @ActiveProfiles("test") -class ChatHistoryTests { - - /** 记录每次收到的消息,并固定回复 ok。 */ - static class RecordingModel implements Model { - - /** 输入里出现这个文字时模型抛错,用来走真实 Agent 的出错路径。 */ - static final String FAIL_TRIGGER = "触发模型错误"; - - final List> received = new CopyOnWriteArrayList<>(); - - @Override - public Flux stream(List messages, List tools, GenerateOptions options) { - received.add(List.copyOf(messages)); - if (messages.stream().anyMatch(msg -> msg.getTextContent() != null && msg.getTextContent().contains(FAIL_TRIGGER))) { - return Flux.error(new IllegalStateException("模型服务不可用")); - } - return Flux.just(ChatResponse.builder() - .content(List.of(TextBlock.builder().text("ok").build())) - .build()); - } - - @Override - public String getModelName() { - return "recording"; - } - } - - @TestConfiguration(proxyBeanMethods = false) - static class StubModelConfiguration { - - @Bean - RecordingModel recordingModel() { - return new RecordingModel(); - } - } +@Import(StubModelConfiguration.class) +class ChatHistoryTests extends RedisIntegrationTest { @Autowired ChatController controller; @@ -71,38 +35,84 @@ RecordingModel recordingModel() { @Autowired RecordingModel model; - private boolean lastCallContains(String text) { - List last = model.received.getLast(); - return last.stream().anyMatch(msg -> msg.getTextContent() != null && msg.getTextContent().contains(text)); + /** 每个测试用独立的会话 ID,Redis 是整个测试进程共享的,不能靠固定 ID 互相隔离。 */ + private static String uniqueId(String prefix) { + return prefix + "-" + UUID.randomUUID(); } @Test void 同一会话的历史跨请求保留且不同会话互相隔离() { - controller.chat(new ChatRequest("history-a", "第一句话")).block(); - controller.chat(new ChatRequest("history-a", "第二句话")).block(); - assertThat(lastCallContains("第一句话")).as("同一会话的第二次请求应能看到第一句").isTrue(); + String a = uniqueId("history-a"); + controller.chat(new ChatRequest(a, "第一句话")).block(); + controller.chat(new ChatRequest(a, "第二句话")).block(); + assertThat(model.lastCallContains("第一句话")).as("同一会话的第二次请求应能看到第一句").isTrue(); + + controller.chat(new ChatRequest(uniqueId("history-b"), "另一个会话")).block(); + assertThat(model.lastCallContains("第一句话")).as("不同会话不应看到别人的历史").isFalse(); + } + + @Test + void 历史真的写进了Redis而不是本机文件() { + String session = uniqueId("in-redis"); + + controller.chat(new ChatRequest(session, "写进Redis的一句话", "erin")).block(); + + // 键的形态:{key-prefix}session:{userId}/{sessionId}:{state-key} + assertThat(RedisCompose.keys("yoke:session:erin/" + session + ":*")).isNotEmpty(); + } + + @Test + void 同一会话ID下不同用户互相隔离() { + String session = uniqueId("shared-session"); + controller.chat(new ChatRequest(session, "爱丽丝的秘密", "alice")).block(); + + controller.chat(new ChatRequest(session, "鲍勃来了", "bob")).block(); + assertThat(model.lastCallContains("爱丽丝的秘密")).as("同名会话换了用户就是另一个槽位").isFalse(); + + controller.chat(new ChatRequest(session, "爱丽丝回来了", "alice")).block(); + assertThat(model.lastCallContains("爱丽丝的秘密")).as("同一用户同一会话仍能接上").isTrue(); + } + + @Test + void 只给会话ID时归属default用户() { + String session = uniqueId("default-user"); + controller.chat(new ChatRequest(session, "默认用户的第一句")).block(); + + controller.chat(new ChatRequest(session, "显式default", "default")).block(); + + assertThat(model.lastCallContains("默认用户的第一句")).isTrue(); + } + + @Test + void 只给用户ID时每次都是新会话且不会混进别人的历史() { + ChatResponse first = controller.chat(new ChatRequest(null, "只有用户的第一句", "carol")).block(); + ChatResponse second = controller.chat(new ChatRequest(null, "只有用户的第二句", "carol")).block(); + + assertThat(second.sessionId()).isNotEqualTo(first.sessionId()); + assertThat(model.lastCallContains("只有用户的第一句")).as("新会话看不到上一个新会话").isFalse(); - controller.chat(new ChatRequest("history-b", "另一个会话")).block(); - assertThat(lastCallContains("第一句话")).as("不同会话不应看到别人的历史").isFalse(); + controller.chat(new ChatRequest(first.sessionId(), "接上第一个", "carol")).block(); + assertThat(model.lastCallContains("只有用户的第一句")).as("带回返回的会话ID就能接上").isTrue(); } @Test void 流式调用后同一会话的下一轮能看到上一轮内容() { - controller.stream(new ChatRequest("stream-a", "流式第一句")).blockLast(); - controller.stream(new ChatRequest("stream-a", "流式第二句")).blockLast(); - assertThat(lastCallContains("流式第一句")).as("流式的第二轮应能看到第一句").isTrue(); + String a = uniqueId("stream-a"); + controller.stream(new ChatRequest(a, "流式第一句")).blockLast(); + controller.stream(new ChatRequest(a, "流式第二句")).blockLast(); + assertThat(model.lastCallContains("流式第一句")).as("流式的第二轮应能看到第一句").isTrue(); - controller.chat(new ChatRequest("stream-a", "同步第三句")).block(); - assertThat(lastCallContains("流式第二句")).as("同步与流式共用同一份历史").isTrue(); + controller.chat(new ChatRequest(a, "同步第三句")).block(); + assertThat(model.lastCallContains("流式第二句")).as("同步与流式共用同一份历史").isTrue(); - controller.stream(new ChatRequest("stream-b", "另一个会话")).blockLast(); - assertThat(lastCallContains("流式第一句")).as("不同会话不应看到别人的历史").isFalse(); + controller.stream(new ChatRequest(uniqueId("stream-b"), "另一个会话")).blockLast(); + assertThat(model.lastCallContains("流式第一句")).as("不同会话不应看到别人的历史").isFalse(); } @Test void 模型抛错时真实Agent的流以error事件收尾且没有done() { List> events = controller - .stream(new ChatRequest("stream-err", RecordingModel.FAIL_TRIGGER)).collectList().block(); + .stream(new ChatRequest(uniqueId("stream-err"), RecordingModel.FAIL_TRIGGER)).collectList().block(); assertThat(events).extracting(ServerSentEvent::event).doesNotContain("done"); assertThat(events.getLast().event()).isEqualTo("error"); diff --git a/src/test/java/com/yoke/middleware/LoggingMiddlewareTests.java b/src/test/java/com/yoke/middleware/LoggingMiddlewareTests.java new file mode 100644 index 0000000..ddc2c1a --- /dev/null +++ b/src/test/java/com/yoke/middleware/LoggingMiddlewareTests.java @@ -0,0 +1,118 @@ +package com.yoke.middleware; + +import java.util.List; +import java.util.Map; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.middleware.ActingInput; +import io.agentscope.core.middleware.AgentInput; +import io.agentscope.core.middleware.ModelCallInput; +import io.agentscope.core.model.Model; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LoggingMiddlewareTests { + + private final LoggingMiddleware middleware = new LoggingMiddleware(); + private final Agent agent = mock(Agent.class); + private final RuntimeContext ctx = RuntimeContext.builder().userId("alice").sessionId("s-1").build(); + private final ListAppender logs = new ListAppender<>(); + private final Logger logger = (Logger) LoggerFactory.getLogger(LoggingMiddleware.class); + private Level originalLevel; + + @BeforeEach + void 挂上日志收集器() { + when(agent.getAgentId()).thenReturn("agent-1"); + when(agent.getName()).thenReturn("Yoke"); + originalLevel = logger.getLevel(); + logger.setLevel(Level.DEBUG); + logs.start(); + logger.addAppender(logs); + } + + @AfterEach + void 还原日志() { + logger.detachAppender(logs); + logger.setLevel(originalLevel); + } + + private List messages() { + return logs.list.stream().map(ILoggingEvent::getFormattedMessage).toList(); + } + + @Test + void 整次调用完成时记录agentId和会话以及耗时() { + middleware.onAgent(agent, ctx, new AgentInput(List.of()), in -> Flux.empty()).blockLast(); + + assertThat(messages()).singleElement().satisfies(message -> assertThat(message) + .contains("agent 完成", "agent=Yoke", "agentId=agent-1", "userId=alice", "sessionId=s-1", "耗时=")); + } + + @Test + void 模型调用记录模型名() { + Model model = mock(Model.class); + when(model.getModelName()).thenReturn("gpt-test"); + + middleware.onModelCall(agent, ctx, new ModelCallInput(List.of(), List.of(), null, model), + in -> Flux.empty()).blockLast(); + + assertThat(messages()).singleElement().satisfies(message -> assertThat(message) + .contains("model 完成", "model=gpt-test")); + } + + @Test + void 工具调用只记录工具名不记录参数() { + ToolUseBlock call = ToolUseBlock.builder().id("c1").name("memory_search") + .input(Map.of("query", "我的银行卡密码是123456")).build(); + + middleware.onActing(agent, ctx, new ActingInput(List.of(call)), in -> Flux.empty()).blockLast(); + + assertThat(messages()).singleElement().satisfies(message -> assertThat(message) + .contains("acting 完成", "tools=[memory_search]") + .doesNotContain("123456", "银行卡", "query")); + } + + @Test + void 出错时记录warn且异常继续向上抛() { + assertThatThrownBy(() -> middleware.onAgent(agent, ctx, new AgentInput(List.of()), + in -> Flux.error(new IllegalStateException("密钥是 sk-secret"))).blockLast()) + .hasMessage("密钥是 sk-secret"); + + assertThat(logs.list).singleElement().satisfies(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).contains("agent 出错", "IllegalStateException") + .as("只记异常类型,不记可能含敏感信息的异常消息").doesNotContain("sk-secret"); + }); + } + + @Test + void 被取消时记录被取消() { + Flux flux = middleware.onAgent(agent, ctx, new AgentInput(List.of()), in -> Flux.never()); + + flux.subscribe().dispose(); + + assertThat(messages()).singleElement().satisfies(message -> assertThat(message).contains("agent 被取消")); + } + + @Test + void 订阅之前不计时也不打日志() { + middleware.onAgent(agent, ctx, new AgentInput(List.of()), in -> Flux.empty()); + + assertThat(logs.list).isEmpty(); + } +} diff --git a/src/test/java/com/yoke/support/RecordingModel.java b/src/test/java/com/yoke/support/RecordingModel.java new file mode 100644 index 0000000..4210282 --- /dev/null +++ b/src/test/java/com/yoke/support/RecordingModel.java @@ -0,0 +1,46 @@ +package com.yoke.support; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import reactor.core.publisher.Flux; + +/** 记录每次收到的消息和工具清单,并固定回复 ok 的假模型,不产生任何网络调用。 */ +public class RecordingModel implements Model { + + /** 输入里出现这个文字时模型抛错,用来走真实 Agent 的出错路径。 */ + public static final String FAIL_TRIGGER = "触发模型错误"; + + public final List> received = new CopyOnWriteArrayList<>(); + + public final List> receivedTools = new CopyOnWriteArrayList<>(); + + @Override + public Flux stream(List messages, List tools, GenerateOptions options) { + received.add(List.copyOf(messages)); + receivedTools.add(tools == null ? List.of() : tools.stream().map(ToolSchema::getName).toList()); + if (messages.stream().anyMatch(msg -> msg.getTextContent() != null && msg.getTextContent().contains(FAIL_TRIGGER))) { + return Flux.error(new IllegalStateException("模型服务不可用")); + } + return Flux.just(ChatResponse.builder() + .content(List.of(TextBlock.builder().text("ok").build())) + .build()); + } + + @Override + public String getModelName() { + return "recording"; + } + + /** 最近一次调用的输入里有没有出现某段文字。 */ + public boolean lastCallContains(String text) { + return received.getLast().stream() + .anyMatch(msg -> msg.getTextContent() != null && msg.getTextContent().contains(text)); + } +} diff --git a/src/test/java/com/yoke/support/RedisCompose.java b/src/test/java/com/yoke/support/RedisCompose.java new file mode 100644 index 0000000..87695ff --- /dev/null +++ b/src/test/java/com/yoke/support/RedisCompose.java @@ -0,0 +1,72 @@ +package com.yoke.support; + +import java.io.File; +import java.io.IOException; +import java.net.ServerSocket; +import java.util.Set; + +import redis.clients.jedis.RedisClient; + +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +/** + * 读取根目录的 docker-compose.yml 拉起 Redis,整个测试 JVM 共用同一个容器实例。 + * + *

容器第一次用到时才启动,JVM 退出时停止;Docker 不可用时 {@link #dockerAvailable()} 返回 false, + * 依赖它的测试会被明确跳过(见 {@link RequiresDocker})。 + */ +public final class RedisCompose { + + private static final String SERVICE = "redis"; + private static final int REDIS_PORT = 6379; + + private static ComposeContainer compose; + private static String uri; + + private RedisCompose() { + } + + public static boolean dockerAvailable() { + try { + return DockerClientFactory.instance().isDockerAvailable(); + } catch (RuntimeException | Error e) { + return false; + } + } + + /** 共享 Redis 的连接串,形如 {@code redis://127.0.0.1:54321}。 */ + public static synchronized String uri() { + if (uri == null) { + start(); + } + return uri; + } + + /** 绕过应用,直接用独立的客户端查 Redis 里匹配的键,用来证明历史真的落在 Redis 而不是别处。 */ + public static Set keys(String pattern) { + try (RedisClient client = RedisClient.create(uri())) { + return client.keys(pattern); + } + } + + private static void start() { + int hostPort = freePort(); + // compose 文件里宿主机端口来自 REDIS_PORT,这里换成空闲端口,避免和本机正在跑的 Redis 冲突 + compose = new ComposeContainer(new File("docker-compose.yml")) + .withEnv("REDIS_PORT", String.valueOf(hostPort)) + .withExposedService(SERVICE, REDIS_PORT, Wait.forHealthcheck()); + compose.start(); + Runtime.getRuntime().addShutdownHook(new Thread(compose::stop)); + uri = "redis://" + compose.getServiceHost(SERVICE, REDIS_PORT) + ":" + compose.getServicePort(SERVICE, REDIS_PORT); + } + + private static int freePort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } catch (IOException e) { + throw new IllegalStateException("找不到空闲端口", e); + } + } +} diff --git a/src/test/java/com/yoke/support/RedisIntegrationTest.java b/src/test/java/com/yoke/support/RedisIntegrationTest.java new file mode 100644 index 0000000..79484e2 --- /dev/null +++ b/src/test/java/com/yoke/support/RedisIntegrationTest.java @@ -0,0 +1,19 @@ +package com.yoke.support; + +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +/** + * 需要真实 Redis 的测试的基类:把共享容器的地址注入 {@code yoke.redis.uri}。 + * Docker 不可用时整个类被跳过。 + */ +@ExtendWith(RequiresDocker.class) +public abstract class RedisIntegrationTest { + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("yoke.redis.uri", RedisCompose::uri); + } +} diff --git a/src/test/java/com/yoke/support/RequiresDocker.java b/src/test/java/com/yoke/support/RequiresDocker.java new file mode 100644 index 0000000..1dadc77 --- /dev/null +++ b/src/test/java/com/yoke/support/RequiresDocker.java @@ -0,0 +1,28 @@ +package com.yoke.support; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; + +/** + * Docker 不可用时跳过测试类,并在报告里写明原因,而不是让整个构建失败; + * 但环境变量 {@code CI=true} 时改为失败,避免 CI 上集成测试被静默跳过。 + */ +public class RequiresDocker implements ExecutionCondition { + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + return evaluate(RedisCompose.dockerAvailable(), "true".equalsIgnoreCase(System.getenv("CI"))); + } + + static ConditionEvaluationResult evaluate(boolean dockerAvailable, boolean ci) { + if (dockerAvailable) { + return ConditionEvaluationResult.enabled("Docker 可用"); + } + if (ci) { + // CI 里静默跳过会让整套集成测试悄悄不跑,宁可让构建失败 + throw new IllegalStateException("CI 环境(CI=true)里 Docker 不可用:需要真实 Redis 的测试不允许被跳过"); + } + return ConditionEvaluationResult.disabled("Docker 不可用,跳过需要真实 Redis 的测试"); + } +} diff --git a/src/test/java/com/yoke/support/RequiresDockerTests.java b/src/test/java/com/yoke/support/RequiresDockerTests.java new file mode 100644 index 0000000..51823e9 --- /dev/null +++ b/src/test/java/com/yoke/support/RequiresDockerTests.java @@ -0,0 +1,30 @@ +package com.yoke.support; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RequiresDockerTests { + + @Test + void Docker可用时放行() { + assertThat(RequiresDocker.evaluate(true, true).isDisabled()).isFalse(); + assertThat(RequiresDocker.evaluate(true, false).isDisabled()).isFalse(); + } + + @Test + void 本地没有Docker时跳过并写明原因() { + var result = RequiresDocker.evaluate(false, false); + + assertThat(result.isDisabled()).isTrue(); + assertThat(result.getReason()).hasValueSatisfying(reason -> assertThat(reason).contains("Docker 不可用")); + } + + @Test + void CI里没有Docker时失败而不是跳过() { + assertThatThrownBy(() -> RequiresDocker.evaluate(false, true)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("CI=true"); + } +} diff --git a/src/test/java/com/yoke/support/StubModelConfiguration.java b/src/test/java/com/yoke/support/StubModelConfiguration.java new file mode 100644 index 0000000..9d0b491 --- /dev/null +++ b/src/test/java/com/yoke/support/StubModelConfiguration.java @@ -0,0 +1,14 @@ +package com.yoke.support; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +/** 用 {@link RecordingModel} 顶替真实模型,测试里 {@code @Import} 即可。 */ +@TestConfiguration(proxyBeanMethods = false) +public class StubModelConfiguration { + + @Bean + RecordingModel recordingModel() { + return new RecordingModel(); + } +} From c74c69ff2b1d37bbafbcca4630742e0176cb9e75 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 5 Nov 2026 12:16:07 +0800 Subject: [PATCH 02/10] refactor: inject HarnessAgentFactory and align code with style rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HarnessAgentFactory 改为组件,由容器注入模型、存储、配置和中间件,删除只剩一个 Bean 的 AgentConfiguration - WeatherTool 直接作为组件,双参构造器交给 Lombok 生成 - 按 CLAUDE.md 统一判空写法:Objects.isNull/nonNull 与 Optional - 注释里的具体版本号 2.0.4 改为 AgentScope,并调顺相关句子 - application.yml 补充 Redis 连接串的写法示例(注释) - README 中英文补充 Redis 里会话状态的键结构和内容(真实模型实测) --- README.md | 11 ++++ README_CN.md | 11 ++++ src/main/java/com/yoke/api/ChatApi.java | 4 +- .../com/yoke/config/AgentConfiguration.java | 57 ------------------- .../com/yoke/config/AguiConfiguration.java | 2 +- .../com/yoke/config/HarnessAgentFactory.java | 45 +++++++++++++-- .../com/yoke/config/RedisConfiguration.java | 3 +- .../java/com/yoke/config/YokeProperties.java | 4 +- .../yoke/middleware/LoggingMiddleware.java | 6 +- src/main/java/com/yoke/tool/WeatherTool.java | 22 ++++--- src/main/resources/application.yml | 10 +++- src/test/java/com/yoke/CompactionTests.java | 5 +- .../java/com/yoke/RedisFailFastTests.java | 4 +- .../java/com/yoke/YokeApplicationTests.java | 2 +- ...sts.java => HarnessAgentFactoryTests.java} | 5 +- .../yoke/config/RedisConfigurationTests.java | 4 +- .../com/yoke/config/YokePropertiesTests.java | 12 +++- .../java/com/yoke/support/RecordingModel.java | 8 ++- .../java/com/yoke/support/RedisCompose.java | 3 +- 19 files changed, 121 insertions(+), 97 deletions(-) delete mode 100644 src/main/java/com/yoke/config/AgentConfiguration.java rename src/test/java/com/yoke/config/{AgentConfigurationTests.java => HarnessAgentFactoryTests.java} (93%) diff --git a/README.md b/README.md index b171952..eff1958 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,17 @@ History is stored per `(userId, sessionId)`. Both are optional in the request bo A half-empty pair is never passed on to the framework, because in AgentScope 2.0.4 a missing `sessionId` falls into one shared session named after the agent, and a missing `userId` falls into an anonymous user, which would mix everyone's history together. Both ids may only contain letters, digits, `_`, `.` and `-` (1 to 64 characters, not all dots, so `.` and `..` are rejected); anything else returns 400. The framework joins them as `userId/sessionId`, so allowing `/` would let different pairs collide on one slot, and `userId` also becomes a workspace directory name. There is no authentication yet: `userId` is whatever the caller says, so it is not an identity check. +What is stored in Redis, per session (checked against a real model, two turns with one tool call): + +| Key | Type | Content | +|---|---|---| +| `yoke:session:{userId}/{sessionId}:agent_state` | String | One JSON document with the whole agent state. The conversation is its `context` array (one entry per message: `USER`, `ASSISTANT` with `tool_use`, `TOOL` with `tool_result`, ...), next to `summary` (filled by compaction) and a few empty context objects. About 3.3 KB for two turns | +| `yoke:session:{userId}/{sessionId}:agent_state:ver` | String | Version number used for optimistic concurrency, bumped on every save | +| `yoke:session:{userId}/{sessionId}:_keys` | Set | Names of all state keys of the session (only `agent_state` here), used to delete a whole session | + +The model's `thinking` blocks and per-message token usage are stored too, and the whole document is read and written back on every turn, so it grows with the conversation. There is no separate message list key. + + ### Streaming (AG-UI) `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: diff --git a/README_CN.md b/README_CN.md index 56bc3aa..1b56c91 100644 --- a/README_CN.md +++ b/README_CN.md @@ -59,6 +59,17 @@ curl -X POST localhost:8080/api/chat \ 不会把「只给其一」直接交给框架:AgentScope 2.0.4 里缺 `sessionId` 会落到以 Agent 名称命名的共享会话,缺 `userId` 会落到匿名用户,所有调用方的历史会混在一起。两个标识只能包含字母、数字、`_`、`.`、`-`,长度 1 到 64,且不能全是点(`.`、`..` 会被拒绝),否则返回 400。原因是框架把槽位拼成 `userId/sessionId`,允许 `/` 会让不同的一对撞到同一个槽位,`userId` 还会成为工作区的目录名。目前没有鉴权,`userId` 由调用方自报,不是身份校验。 +每个会话在 Redis 里存了什么(用真实模型实测:两轮对话,其中一轮调用了工具): + +| 键 | 类型 | 内容 | +|---|---|---| +| `yoke:session:{userId}/{sessionId}:agent_state` | String | 一份 JSON,是整个 Agent 状态。对话在它的 `context` 数组里(一条消息一项:`USER`、带 `tool_use` 的 `ASSISTANT`、带 `tool_result` 的 `TOOL` 等),旁边是 `summary`(压缩后的摘要落点)和几个空的上下文对象。两轮约 3.3 KB | +| `yoke:session:{userId}/{sessionId}:agent_state:ver` | String | 乐观并发用的版本号,每次保存递增 | +| `yoke:session:{userId}/{sessionId}:_keys` | Set | 该会话下所有状态键的名字(这里只有 `agent_state`),删除整个会话时靠它 | + +模型的 `thinking` 内容和每条消息的 token 用量也会一并存下;每一轮都是整份读出、整份写回,所以会随对话变长而变大。没有单独的消息列表键。 + + ### 流式调用(AG-UI) `POST /api/chat/stream` 使用 [AG-UI](https://docs.ag-ui.com/concepts/events) 协议:请求体是标准的 `RunAgentInput`,响应是 `text/event-stream`,Agent 推理的同时就把 AG-UI 事件推送出来。用 `curl -N` 关闭缓冲: diff --git a/src/main/java/com/yoke/api/ChatApi.java b/src/main/java/com/yoke/api/ChatApi.java index b2f6756..2bc02ae 100644 --- a/src/main/java/com/yoke/api/ChatApi.java +++ b/src/main/java/com/yoke/api/ChatApi.java @@ -26,7 +26,7 @@ *

  • 只传 {@code userId}:为该用户新建一个会话,新的会话 ID 在响应里返回;
  • *
  • 都不传:{@code default} 用户下新建会话。
  • * - * 不能把「只给其一」当成无会话直接交给框架:2.0.4 里缺 {@code sessionId} 会落到以 Agent 名称命名的共享会话, + * 不能把「只给其一」当成无会话直接交给框架:AgentScope 在缺 {@code sessionId} 时会落到以 Agent 名称命名的共享会话, * 缺 {@code userId} 会落到匿名用户,所有调用方的历史都会混在一起。 * *

    标识格式:{@code userId} 和 {@code sessionId} 只能包含字母、数字、下划线、点和短横线,长度 1 到 64, @@ -38,7 +38,7 @@ * 同一个会话的并发请求不会被串行化,由调用方保证同一会话同一时间只发一个请求。 * *

    工具权限:Agent 不开放文件读写、命令执行和联网工具,也整体关闭了子 Agent。 - * 读 2.0.4 源码发现子 Agent 只继承文件和 shell 的关闭、不继承联网工具的关闭, + * 读 AgentScope 源码发现,子 Agent 只继承文件和 shell 的关闭、不继承联网工具的关闭, * 开着子 Agent 就能让模型通过 {@code agent_spawn} 派生出能联网的子 Agent,所以整体关闭。 * 关闭后模型仍能用的只有 {@code memory_save}、{@code memory_get}、{@code memory_search}、{@code session_history}、 * {@code session_list}、{@code session_search}、{@code load_skill_through_path} 和 {@code wait_async_results}: diff --git a/src/main/java/com/yoke/config/AgentConfiguration.java b/src/main/java/com/yoke/config/AgentConfiguration.java deleted file mode 100644 index 7185edf..0000000 --- a/src/main/java/com/yoke/config/AgentConfiguration.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.yoke.config; - -import java.nio.file.Path; -import com.yoke.tool.WeatherTool; -import io.agentscope.core.middleware.MiddlewareBase; -import io.agentscope.core.model.Model; -import io.agentscope.core.tool.Toolkit; -import io.agentscope.harness.agent.DistributedStore; -import io.agentscope.spring.boot.properties.AgentProperties; -import io.agentscope.spring.boot.properties.AgentscopeProperties; - -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * Agent 的共享配置。 - * - *

    这里不创建 Agent 实例,只组装 {@link HarnessAgentFactory}:每次请求调用一次 {@code create()} - * 新建一个 HarnessAgent,用完关闭。因此官方 starter 的单例 Agent 在 application.yml 里关闭了 - * (agentscope.agent.enabled=false),但名称、提示词、迭代次数仍沿用 agentscope.agent.* 的配置。 - * - *

    对话历史存在 {@link DistributedStore}(Redis)里,见 {@link RedisConfiguration}; - * 容器里所有 {@link MiddlewareBase} Bean 都会按顺序挂到每个新建的 Agent 上,一个都没有也不影响启动。 - */ -@Configuration(proxyBeanMethods = false) -public class AgentConfiguration { - - /** 工具类被所有请求共享,必须无请求态字段。 */ - @Bean - public WeatherTool weatherTool() { - return new WeatherTool(); - } - - @Bean - public HarnessAgentFactory harnessAgentFactory( - Model model, - DistributedStore distributedStore, - AgentscopeProperties properties, - YokeProperties yoke, - WeatherTool weatherTool, - ObjectProvider middlewares) { - AgentProperties agent = properties.getAgent(); - Toolkit toolkit = new Toolkit(); - toolkit.registerTool(weatherTool); - return new HarnessAgentFactory( - agent.getName(), - agent.getSysPrompt(), - agent.getMaxIters(), - model, - toolkit, - distributedStore, - Path.of(yoke.workspace()), - yoke.compaction(), - middlewares.orderedStream().toList()); - } -} diff --git a/src/main/java/com/yoke/config/AguiConfiguration.java b/src/main/java/com/yoke/config/AguiConfiguration.java index c4deb24..8fc7d0f 100644 --- a/src/main/java/com/yoke/config/AguiConfiguration.java +++ b/src/main/java/com/yoke/config/AguiConfiguration.java @@ -18,7 +18,7 @@ public class AguiConfiguration { * 适配器配置。Yoke 不做状态同步,也不输出思考过程;token 用量以 {@code CUSTOM token_usage} 事件输出; * 出错时只发 {@code RUN_ERROR},不再补 {@code RUN_FINISHED}。 * - *

    {@code runTimeout} 保持默认:2.0.4 里这个配置只有 getter,适配器并没有读它,所以不要指望它能限制运行时长。 + *

    {@code runTimeout} 保持默认:AgentScope 里这个配置只有 getter,适配器并没有读它,所以不要指望它能限制运行时长。 * *

    工具只用服务端注册的({@code AGENT_ONLY})。codec 已经丢弃了客户端的 {@code tools},这里再兜一层: * 默认的 {@code MERGE_EXTERNAL_PRIORITY} 下,一旦客户端工具被透传进来,就会盖掉同名的服务端工具。 diff --git a/src/main/java/com/yoke/config/HarnessAgentFactory.java b/src/main/java/com/yoke/config/HarnessAgentFactory.java index 9d2d589..b72da92 100644 --- a/src/main/java/com/yoke/config/HarnessAgentFactory.java +++ b/src/main/java/com/yoke/config/HarnessAgentFactory.java @@ -3,23 +3,31 @@ import java.nio.file.Path; import java.util.List; +import com.yoke.tool.WeatherTool; import io.agentscope.core.middleware.MiddlewareBase; import io.agentscope.core.model.Model; import io.agentscope.core.tool.Toolkit; import io.agentscope.harness.agent.DistributedStore; import io.agentscope.harness.agent.HarnessAgent; import io.agentscope.harness.agent.memory.compaction.CompactionConfig; -import lombok.RequiredArgsConstructor; +import io.agentscope.spring.boot.properties.AgentProperties; +import io.agentscope.spring.boot.properties.AgentscopeProperties; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; /** * 每次调用 {@link #create()} 都新建一个 HarnessAgent,调用方用完负责 {@code close()}。 * - *

    为什么不像 ReActAgent 那样共享一个 Builder:2.0.4 的 {@code HarnessAgent.Builder.build()} + *

    为什么不像 ReActAgent 那样共享一个 Builder:AgentScope 的 {@code HarnessAgent.Builder.build()} * 会往内部持有的 ReActAgent.Builder 里追加一批装配期中间件,同一个 Builder 重复 build, * 中间件会越积越多(实测每次多 11 个),所以这里只共享不可变的部分(模型、存储、配置、中间件实例), * Builder 每次现建。 + * + *

    官方 starter 的单例 Agent 在 application.yml 里关闭了(agentscope.agent.enabled=false), + * 对话历史存在 {@link DistributedStore}(Redis)里,见 {@link RedisConfiguration}。 */ -@RequiredArgsConstructor +@Component public class HarnessAgentFactory { private final String name; @@ -32,13 +40,38 @@ public class HarnessAgentFactory { private final YokeProperties.Compaction compaction; private final List middlewares; + /** + * 依赖都由容器注入:名称、提示词、迭代次数沿用 agentscope.agent.*(官方的单例 Agent 已关闭,但配置仍然有效); + * 共享的 Toolkit 只放自定义工具(目前是天气);容器里所有 {@link MiddlewareBase} Bean 按顺序挂到每个新建的 Agent 上, + * 一个都没有也不影响启动。 + */ + public HarnessAgentFactory( + Model model, + DistributedStore distributedStore, + AgentscopeProperties properties, + YokeProperties yoke, + WeatherTool weatherTool, + ObjectProvider middlewares) { + AgentProperties agent = properties.getAgent(); + this.name = agent.getName(); + this.sysPrompt = agent.getSysPrompt(); + this.maxIters = agent.getMaxIters(); + this.model = model; + this.toolkit = new Toolkit(); + this.toolkit.registerTool(weatherTool); + this.distributedStore = distributedStore; + this.workspace = Path.of(yoke.workspace()); + this.compaction = yoke.compaction(); + this.middlewares = middlewares.orderedStream().toList(); + } + public HarnessAgent create() { return HarnessAgent.builder() .name(name) .sysPrompt(sysPrompt) .maxIters(maxIters) .model(model) - // 共享的 Toolkit 只放自定义工具(目前是天气),内置工具由下面的 disable* 开关决定 + // 内置工具由下面的 disable* 开关决定 .toolkit(toolkit) .workspace(workspace) // 会话状态由它提供:没有显式 stateStore 时,Harness 用 distributedStore 的 AgentStateStore @@ -49,14 +82,14 @@ public HarnessAgent create() { .disableFilesystemTools() .disableShellTool() .disableWebTools() - // 子 Agent 只继承文件和 shell 的关闭,不继承 web 的关闭(2.0.4 的 HarnessAgentBuilderSupport 只传这两个), + // 子 Agent 只继承文件和 shell 的关闭,不继承 web 的关闭(AgentScope 的 HarnessAgentBuilderSupport 只传这两个), // 开着就能让模型通过 agent_spawn 派生出带 web_fetch 的子 Agent 联网;第 03 轮专门引入子 Agent 时再按需打开 .disableSubagents() .build(); } /** - * 只按条数触发压缩。{@code triggerTokens} 在 2.0.4 里 0 表示「按模型上下文窗口动态计算」而不是关闭, + * 只按条数触发压缩。AgentScope 中 {@code triggerTokens} 为 0 表示「按模型上下文窗口动态计算」而不是关闭, * 所以用 {@link Integer#MAX_VALUE} 让 token 阈值实际上永远不会先触发; * {@code keepTokens} 为 0 才会改用 keepMessages(默认 -1 是动态)。 */ diff --git a/src/main/java/com/yoke/config/RedisConfiguration.java b/src/main/java/com/yoke/config/RedisConfiguration.java index edde052..5e8a366 100644 --- a/src/main/java/com/yoke/config/RedisConfiguration.java +++ b/src/main/java/com/yoke/config/RedisConfiguration.java @@ -1,5 +1,6 @@ package com.yoke.config; +import java.util.Objects; import java.util.regex.Pattern; import io.agentscope.extensions.redis.RedisDistributedStore; @@ -36,7 +37,7 @@ public RedisClient jedis(YokeProperties properties) { jedis = RedisClient.create(uri); jedis.ping(); } catch (RuntimeException e) { - if (jedis != null) { + if (Objects.nonNull(jedis)) { jedis.close(); } // 不把原始异常作为 cause:Jedis 对格式非法的连接串会在异常消息里原样回显,可能带出密码 diff --git a/src/main/java/com/yoke/config/YokeProperties.java b/src/main/java/com/yoke/config/YokeProperties.java index a9f4fcf..4d20617 100644 --- a/src/main/java/com/yoke/config/YokeProperties.java +++ b/src/main/java/com/yoke/config/YokeProperties.java @@ -1,5 +1,7 @@ package com.yoke.config; +import java.util.Optional; + import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import org.slf4j.Logger; @@ -37,7 +39,7 @@ public record Redis( /** record 自动生成的 toString 会原样带出连接串,日志或调试输出里就泄漏密码,所以这里脱敏。 */ @Override public String toString() { - return "Redis[uri=" + (uri == null ? null : RedisConfiguration.redact(uri)) + ", keyPrefix=" + keyPrefix + "]"; + return "Redis[uri=" + Optional.ofNullable(uri).map(RedisConfiguration::redact).orElse(null) + ", keyPrefix=" + keyPrefix + "]"; } } diff --git a/src/main/java/com/yoke/middleware/LoggingMiddleware.java b/src/main/java/com/yoke/middleware/LoggingMiddleware.java index fb8e137..4085b14 100644 --- a/src/main/java/com/yoke/middleware/LoggingMiddleware.java +++ b/src/main/java/com/yoke/middleware/LoggingMiddleware.java @@ -1,6 +1,7 @@ package com.yoke.middleware; import java.util.List; +import java.util.Optional; import java.util.function.Function; import java.util.function.Supplier; @@ -12,6 +13,7 @@ import io.agentscope.core.middleware.AgentInput; import io.agentscope.core.middleware.MiddlewareBase; import io.agentscope.core.middleware.ModelCallInput; +import io.agentscope.core.model.Model; import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Flux; import reactor.core.publisher.SignalType; @@ -24,7 +26,7 @@ *

    只记录 agentId、userId、sessionId、耗时和工具名,不记录消息内容,也不记录工具的参数和输出, * 因为这些里面可能有用户隐私或密钥。出错只记 warn 和异常类型,异常本身继续向上抛,不吞掉。 * - *

    2.0.4 里 {@link MiddlewareBase} 是接口,钩子都有默认实现,这里只覆盖三个。 + *

    在 AgentScope 中 {@link MiddlewareBase} 是接口,钩子都有默认实现,这里只覆盖三个。 * 钩子返回的是事件流,所以计时必须放进 {@code Flux.defer}:订阅时才开始计,而不是构造流的时候。 */ @Slf4j @@ -40,7 +42,7 @@ public Flux onAgent( @Override public Flux onModelCall( Agent agent, RuntimeContext ctx, ModelCallInput input, Function> next) { - String model = input.model() == null ? "unknown" : input.model().getModelName(); + String model = Optional.ofNullable(input.model()).map(Model::getModelName).orElse("unknown"); return traced("model", agent, ctx, "model=" + model, () -> next.apply(input)); } diff --git a/src/main/java/com/yoke/tool/WeatherTool.java b/src/main/java/com/yoke/tool/WeatherTool.java index 0f93dbe..2da46d9 100644 --- a/src/main/java/com/yoke/tool/WeatherTool.java +++ b/src/main/java/com/yoke/tool/WeatherTool.java @@ -16,11 +16,14 @@ import cn.hutool.core.util.StrUtil; import io.agentscope.core.tool.Tool; import io.agentscope.core.tool.ToolParam; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tools.jackson.core.JacksonException; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; +import org.springframework.stereotype.Component; + /** * 天气查询工具,数据来自开源的 Open-Meteo(无需密钥):先用地名换经纬度,再查当前天气。 * @@ -30,6 +33,8 @@ * 工具方法不抛异常:失败时返回一句人话,让模型据此回答用户,而不是让整次对话报错。 */ @Slf4j +@Component +@RequiredArgsConstructor public class WeatherTool { static final String GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"; @@ -53,7 +58,11 @@ public class WeatherTool { private final String forecastUrl; - private final HttpClient httpClient; + // 地址是写死的,不需要跟随重定向,避免被引到别处 + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); private final JsonMapper mapper = JsonMapper.builder().build(); @@ -61,17 +70,6 @@ public WeatherTool() { this(GEOCODING_URL, FORECAST_URL); } - /** 上游地址可注入,测试里指向本机的假服务,不访问真实网络。 */ - public WeatherTool(String geocodingUrl, String forecastUrl) { - this.geocodingUrl = geocodingUrl; - this.forecastUrl = forecastUrl; - // 地址是写死的,不需要跟随重定向,避免被引到别处 - this.httpClient = HttpClient.newBuilder() - .connectTimeout(CONNECT_TIMEOUT) - .followRedirects(HttpClient.Redirect.NEVER) - .build(); - } - @Tool(name = "get_weather", readOnly = true, concurrencySafe = true, description = "当用户问某个城市现在的天气、气温、冷不冷、要不要带伞时调用。" + "模型自己不知道实时天气,不要凭印象回答。只返回当前实况,不含未来预报。" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a2a4df1..3ccc395 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -19,6 +19,14 @@ yoke: redis: # 所有 Redis 键的统一前缀;连接串 uri 见各环境文件,没有默认值 key-prefix: "yoke:" + # uri 的写法示例(各环境文件里通过 YOKE_REDIS_URI 提供,下面都是注释,不会生效): + # 本机无密码: redis://127.0.0.1:6379 + # 只有密码: redis://:your-password@redis.example.com:6379 + # 用户名加密码(ACL): redis://yoke:your-password@redis.example.com:6379 + # 指定 DB(默认 0): redis://127.0.0.1:6379/2 + # 密码加 DB: redis://:your-password@redis.example.com:6379/2 + # TLS: rediss://:your-password@redis.example.com:6380 + # 密码里有 @ / : 等特殊字符时需要 URL 编码,例如 p@ss 写成 p%40ss compaction: # 对话消息达到 trigger-messages 条就压缩,压缩后原样保留最近 keep-messages 条;<=0 的非法值回落到 30 / 10 trigger-messages: 30 @@ -34,7 +42,7 @@ agentscope: # 使用其他 OpenAI 兼容服务时设置 base-url,例如 DashScope: # base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 agent: - # 关闭官方的单例 Agent:Agent 由 AgentConfiguration 里的 HarnessAgentFactory 在每次请求时新建 + # 关闭官方的单例 Agent:Agent 由 HarnessAgentFactory 在每次请求时新建 # (Builder 也是每次现建,不共享)。name、sys-prompt、max-iters 仍然生效,被工厂读取 enabled: false name: Yoke diff --git a/src/test/java/com/yoke/CompactionTests.java b/src/test/java/com/yoke/CompactionTests.java index a27c151..09e9e33 100644 --- a/src/test/java/com/yoke/CompactionTests.java +++ b/src/test/java/com/yoke/CompactionTests.java @@ -1,5 +1,6 @@ package com.yoke; +import java.util.Objects; import java.util.UUID; import com.yoke.controller.ChatController; @@ -42,7 +43,7 @@ class CompactionTests extends RedisIntegrationTest { var calls = model.received.subList(before, model.received.size()); assertThat(calls).as("出现过摘要调用").anyMatch(messages -> messages.stream() - .anyMatch(msg -> msg.getTextContent() != null && msg.getTextContent().contains("SESSION INTENT"))); + .anyMatch(msg -> Objects.nonNull(msg.getTextContent()) && msg.getTextContent().contains("SESSION INTENT"))); } /** @@ -53,7 +54,7 @@ private int reasoningSize(int from, String text) { int size = -1; for (int i = from; i < model.received.size(); i++) { var messages = model.received.get(i); - boolean mentionsText = messages.stream().anyMatch(m -> m.getTextContent() != null && m.getTextContent().contains(text)); + boolean mentionsText = messages.stream().anyMatch(m -> Objects.nonNull(m.getTextContent()) && m.getTextContent().contains(text)); if (mentionsText && !model.receivedTools.get(i).isEmpty()) { size = messages.size(); } diff --git a/src/test/java/com/yoke/RedisFailFastTests.java b/src/test/java/com/yoke/RedisFailFastTests.java index b1198c8..0e64efa 100644 --- a/src/test/java/com/yoke/RedisFailFastTests.java +++ b/src/test/java/com/yoke/RedisFailFastTests.java @@ -1,5 +1,7 @@ package com.yoke; +import java.util.Objects; + import com.yoke.support.RedisCompose; import com.yoke.support.RequiresDocker; import org.junit.jupiter.api.Test; @@ -60,7 +62,7 @@ private static SpringApplicationBuilder app(String profile) { private static String rootMessages(Throwable error) { StringBuilder all = new StringBuilder(); - for (Throwable t = error; t != null; t = t.getCause()) { + for (Throwable t = error; Objects.nonNull(t); t = t.getCause()) { all.append(t.getMessage()).append('\n'); } return all.toString(); diff --git a/src/test/java/com/yoke/YokeApplicationTests.java b/src/test/java/com/yoke/YokeApplicationTests.java index 711790f..186045e 100644 --- a/src/test/java/com/yoke/YokeApplicationTests.java +++ b/src/test/java/com/yoke/YokeApplicationTests.java @@ -47,7 +47,7 @@ class YokeApplicationTests extends RedisIntegrationTest { } } - // 如果像 ReActAgent 那样共享同一个 HarnessAgent.Builder,2.0.4 每次 build 都会多追加一批中间件 + // 如果像 ReActAgent 那样共享同一个 HarnessAgent.Builder,AgentScope 的每次 build 都会多追加一批中间件 assertThat(sizes[1]).isEqualTo(sizes[0]); assertThat(sizes[2]).isEqualTo(sizes[0]); } diff --git a/src/test/java/com/yoke/config/AgentConfigurationTests.java b/src/test/java/com/yoke/config/HarnessAgentFactoryTests.java similarity index 93% rename from src/test/java/com/yoke/config/AgentConfigurationTests.java rename to src/test/java/com/yoke/config/HarnessAgentFactoryTests.java index 2323ef9..8c554d2 100644 --- a/src/test/java/com/yoke/config/AgentConfigurationTests.java +++ b/src/test/java/com/yoke/config/HarnessAgentFactoryTests.java @@ -1,5 +1,6 @@ package com.yoke.config; +import com.yoke.tool.WeatherTool; import io.agentscope.core.middleware.MiddlewareBase; import io.agentscope.core.model.Model; import io.agentscope.harness.agent.DistributedStore; @@ -14,10 +15,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; -class AgentConfigurationTests { +class HarnessAgentFactoryTests { @Configuration(proxyBeanMethods = false) - @Import(AgentConfiguration.class) + @Import({WeatherTool.class, HarnessAgentFactory.class}) static class Base { @Bean diff --git a/src/test/java/com/yoke/config/RedisConfigurationTests.java b/src/test/java/com/yoke/config/RedisConfigurationTests.java index 67ce696..6d9ccc6 100644 --- a/src/test/java/com/yoke/config/RedisConfigurationTests.java +++ b/src/test/java/com/yoke/config/RedisConfigurationTests.java @@ -1,5 +1,7 @@ package com.yoke.config; +import java.util.Objects; + import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -13,7 +15,7 @@ private static YokeProperties withUri(String uri) { private static String allMessages(Throwable error) { StringBuilder all = new StringBuilder(); - for (Throwable t = error; t != null; t = t.getCause()) { + for (Throwable t = error; Objects.nonNull(t); t = t.getCause()) { all.append(t.getClass().getName()).append(": ").append(t.getMessage()).append('\n'); } return all.toString(); diff --git a/src/test/java/com/yoke/config/YokePropertiesTests.java b/src/test/java/com/yoke/config/YokePropertiesTests.java index 75299a1..9a5ab5b 100644 --- a/src/test/java/com/yoke/config/YokePropertiesTests.java +++ b/src/test/java/com/yoke/config/YokePropertiesTests.java @@ -1,12 +1,15 @@ package com.yoke.config; import java.util.HashMap; -import java.util.List; import java.util.Map; +import com.yoke.tool.WeatherTool; +import io.agentscope.core.middleware.MiddlewareBase; import io.agentscope.harness.agent.memory.compaction.CompactionConfig; +import io.agentscope.spring.boot.properties.AgentscopeProperties; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; @@ -84,13 +87,16 @@ private static Map required() { @Test void 只按条数触发压缩且token阈值不会先触发() { HarnessAgentFactory factory = new HarnessAgentFactory( - "Yoke", "prompt", 10, null, null, null, null, new YokeProperties.Compaction(30, 10), List.of()); + null, null, new AgentscopeProperties(), + new YokeProperties(new YokeProperties.Redis("redis://127.0.0.1:6379", "yoke:"), + new YokeProperties.Compaction(30, 10), "/tmp/yoke-test"), + new WeatherTool(), new DefaultListableBeanFactory().getBeanProvider(MiddlewareBase.class)); CompactionConfig config = factory.compactionConfig(); assertThat(config.getTriggerMessages()).isEqualTo(30); assertThat(config.getKeepMessages()).isEqualTo(10); - // 0 在 2.0.4 里是「按上下文窗口动态计算」,不是关闭;用最大值让 token 阈值实际上不会先触发 + // AgentScope 中 0 表示「按上下文窗口动态计算」,不是关闭;用最大值让 token 阈值实际上不会先触发 assertThat(config.getTriggerTokens()).isEqualTo(Integer.MAX_VALUE); // 0 才改用 keepMessages,默认 -1 是动态 token assertThat(config.getKeepTokens()).isZero(); diff --git a/src/test/java/com/yoke/support/RecordingModel.java b/src/test/java/com/yoke/support/RecordingModel.java index 4210282..2dbca7d 100644 --- a/src/test/java/com/yoke/support/RecordingModel.java +++ b/src/test/java/com/yoke/support/RecordingModel.java @@ -1,6 +1,8 @@ package com.yoke.support; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.concurrent.CopyOnWriteArrayList; import io.agentscope.core.message.Msg; @@ -24,8 +26,8 @@ public class RecordingModel implements Model { @Override public Flux stream(List messages, List tools, GenerateOptions options) { received.add(List.copyOf(messages)); - receivedTools.add(tools == null ? List.of() : tools.stream().map(ToolSchema::getName).toList()); - if (messages.stream().anyMatch(msg -> msg.getTextContent() != null && msg.getTextContent().contains(FAIL_TRIGGER))) { + receivedTools.add(Optional.ofNullable(tools).map(list -> list.stream().map(ToolSchema::getName).toList()).orElse(List.of())); + if (messages.stream().anyMatch(msg -> Objects.nonNull(msg.getTextContent()) && msg.getTextContent().contains(FAIL_TRIGGER))) { return Flux.error(new IllegalStateException("模型服务不可用")); } return Flux.just(ChatResponse.builder() @@ -41,6 +43,6 @@ public String getModelName() { /** 最近一次调用的输入里有没有出现某段文字。 */ public boolean lastCallContains(String text) { return received.getLast().stream() - .anyMatch(msg -> msg.getTextContent() != null && msg.getTextContent().contains(text)); + .anyMatch(msg -> Objects.nonNull(msg.getTextContent()) && msg.getTextContent().contains(text)); } } diff --git a/src/test/java/com/yoke/support/RedisCompose.java b/src/test/java/com/yoke/support/RedisCompose.java index 87695ff..c3e0534 100644 --- a/src/test/java/com/yoke/support/RedisCompose.java +++ b/src/test/java/com/yoke/support/RedisCompose.java @@ -3,6 +3,7 @@ import java.io.File; import java.io.IOException; import java.net.ServerSocket; +import java.util.Objects; import java.util.Set; import redis.clients.jedis.RedisClient; @@ -38,7 +39,7 @@ public static boolean dockerAvailable() { /** 共享 Redis 的连接串,形如 {@code redis://127.0.0.1:54321}。 */ public static synchronized String uri() { - if (uri == null) { + if (Objects.isNull(uri)) { start(); } return uri; From 8e2fe22756336746c3859a422f2f8caf3ea0fabc Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 5 Nov 2026 12:35:55 +0800 Subject: [PATCH 03/10] docs: rewrite README intro with teaching hooks and feature list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 介绍改为友客AI的开源项目,说明大家一起学习 AgentScope Java V2 与 Spring 并做出 Agent 服务 - 新增 4 句钩子,讲用 AgentScope 做 Agent 服务时会遇到的问题 - 原来的要点列表整理为「特性」小节,去掉请求参数校验一条 - 中英文同步 --- README.md | 10 ++++++++-- README_CN.md | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eff1958..4fee20a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,14 @@ > A Spring-based agent harness. -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: +Yoke is an open-source project of 友客AI (YouKe AI): a community learning AgentScope Java V2 and the Spring source code and usage together, by building an agent service that really works. A yoke is the wooden frame that makes oxen and horses pull in the direction you want, and Yoke does the same for models: it is the runtime layer wrapped around a model so it can do real work reliably, turning AgentScope's agent capabilities into a ready-to-use HTTP service. + +- Using AgentScope Java to build an agent service: the demo runs in a dozen lines, but "ship it to users" raises real questions. One agent per request, or one global agent? Where does the conversation history live? +- Streaming, tool calls, multi-turn sessions and tight permissions are scattered across the official examples. How do you put them into one Spring Boot project, how should it be layered, and which boundaries must hold? +- The official starters are one line away, but their endpoints have no authentication and the agent is a singleton. What happens if you ship them as they are, and what should you use or write yourself? +- This project starts from an endpoint that can hold a conversation and adds one layer per lesson, each solving one engineering problem. The code is open source and runnable, and the limits that were not verified and the pitfalls hit along the way are written down honestly. + +### Features - 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). @@ -26,7 +33,6 @@ A yoke is the wooden frame that makes oxen and horses pull in the direction you - 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. - `local` / `test` / `prod` profiles. In production, a missing API key fails the startup instead of shipping with an empty key. - Virtual threads are enabled by default, for both Tomcat and Spring's default thread pool. -- Request validation: a missing `message` returns 400. ## Getting Started diff --git a/README_CN.md b/README_CN.md index 1b56c91..812403b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -17,7 +17,14 @@ > A Spring-based agent harness. -Yoke(轭)是套在牛马身上、让它们听使唤拉车的木架;Yoke 则是套在模型外面、让它能稳定干活的那层运行框架。它把 AgentScope 的 Agent 能力包装成一个开箱即用的 HTTP 服务,让你专注于业务,而不是胶水代码: +Yoke 是友客AI的开源项目:大家一起学习 AgentScope Java V2 和 Spring 的源码与用法,在实践中开发出一个真正能用的 Agent 服务。Yoke(轭)原指套在牛马身上、让它们朝着正确方向使劲的木架,Yoke 也是如此:它是套在模型外面、让模型稳定干活的那层运行框架,把 AgentScope 的 Agent 能力包装成开箱即用的 HTTP 服务。 + +- 想用 AgentScope Java 做个 Agent 服务,Demo 十几行就能跑,可一到"要给用户用",问题就来了:每个请求一个 Agent 还是全局一个?对话历史放哪? +- 流式输出、工具调用、多轮会话、权限收紧,这些在官方文档里是分散的例子,拼到一个 Spring Boot 项目里,到底该怎么分层、守哪些边界? +- 官方的 starter 能一行引入,可它的入口没有认证、Agent 是单例,直接上线会出什么事?哪些该用,哪些该自己写? +- 这个项目从一个能对话的接口开始,一课加一层,每一课只解决一个工程问题,代码开源、可以直接运行,也会如实写下没验证的限制和踩过的坑。 + +### 特性 - 一个接口完成多轮对话:`POST /api/chat`;另有 `POST /api/chat/stream`,按 AG-UI 协议通过 SSE 边推理边推送。 - 内置 `get_weather` 工具,通过开源的 [Open-Meteo](https://open-meteo.com) 接口查询当前天气(无需密钥,免费版仅限非商业使用)。 @@ -26,7 +33,6 @@ Yoke(轭)是套在牛马身上、让它们听使唤拉车的木架;Yoke - 通过 AgentScope 官方 starter 接入各种模型:OpenAI 兼容协议(可对接 DashScope、DeepSeek 等)、Anthropic、Gemini、Ollama,换依赖和配置即可。 - local / test / prod 多环境配置;生产环境缺少密钥时启动即失败,避免带着空密钥上线。 - 默认开启虚拟线程,Tomcat 和 Spring 默认线程池都使用虚拟线程。 -- 请求参数校验,缺少 `message` 时返回 400。 ## 快速开始 From e6ea57db0c7740d6781eb35d1038138b235c63bd Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 5 Nov 2026 12:49:26 +0800 Subject: [PATCH 04/10] docs: polish README intro wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 精简介绍段,保留轭的由来,钩子改写得更直接 - 特性小节措辞微调,中英文同步 --- README.md | 16 ++++++++-------- README_CN.md | 18 +++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 4fee20a..5d0f310 100644 --- a/README.md +++ b/README.md @@ -17,19 +17,19 @@ > A Spring-based agent harness. -Yoke is an open-source project of 友客AI (YouKe AI): a community learning AgentScope Java V2 and the Spring source code and usage together, by building an agent service that really works. A yoke is the wooden frame that makes oxen and horses pull in the direction you want, and Yoke does the same for models: it is the runtime layer wrapped around a model so it can do real work reliably, turning AgentScope's agent capabilities into a ready-to-use HTTP service. +Yoke is an open-source project of 友客AI (YouKe AI). We read the AgentScope Java V2 and Spring source code together and learn by building an agent service that really runs. A yoke is the wooden frame that makes oxen and horses pull in one direction. Yoke wraps around the model and keeps it working reliably. -- Using AgentScope Java to build an agent service: the demo runs in a dozen lines, but "ship it to users" raises real questions. One agent per request, or one global agent? Where does the conversation history live? -- Streaming, tool calls, multi-turn sessions and tight permissions are scattered across the official examples. How do you put them into one Spring Boot project, how should it be layered, and which boundaries must hold? -- The official starters are one line away, but their endpoints have no authentication and the agent is a singleton. What happens if you ship them as they are, and what should you use or write yourself? -- This project starts from an endpoint that can hold a conversation and adds one layer per lesson, each solving one engineering problem. The code is open source and runnable, and the limits that were not verified and the pitfalls hit along the way are written down honestly. +- Building an agent with AgentScope Java: the demo runs in a dozen lines. Serving real users is where it gets hard. Should each request get a new agent, or share one global agent? Where does the conversation history live? +- Streaming, tool calls, multi-turn sessions and tight permissions are scattered across the official examples. Fitting them into one Spring Boot project, deciding how to layer it and which boundaries must hold, is left to you. +- The official starters are one line away, but their endpoints have no authentication and the agent is a singleton, so shipping them as they are mixes sessions of different users. This project gives a judgement on what to use and what to write yourself. +- The project starts from an endpoint that can hold a conversation and solves one engineering problem per lesson. The code is open source and runs as is. Limits that were not verified and pitfalls hit along the way are written down too. ### Features - 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 `HarnessAgent` is built for every request and closed afterwards; conversation history lives in Redis, so it survives restarts and is shared by all replicas. History is isolated per `(userId, sessionId)`, and long conversations are compacted automatically. -- Apart from `get_weather`, the agent has no tools: no file, shell or web access, and sub-agents are switched off. A logging middleware records a timing trace for every call. +- A built-in `get_weather` tool looks up current weather through the open-source [Open-Meteo](https://open-meteo.com) API, no key needed (the free tier is for non-commercial use only). +- Every request builds a temporary `HarnessAgent` and closes it afterwards. Conversation history lives in Redis, so it survives restarts and is shared by all replicas, isolated per `(userId, sessionId)`, and compacted automatically when a conversation gets long. +- Apart from `get_weather` the agent has no tools: no file, shell or web access, and sub-agents are switched off. A logging middleware records a timing trace for every call. - 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. - `local` / `test` / `prod` profiles. In production, a missing API key fails the startup instead of shipping with an empty key. - Virtual threads are enabled by default, for both Tomcat and Spring's default thread pool. diff --git a/README_CN.md b/README_CN.md index 812403b..0f66d53 100644 --- a/README_CN.md +++ b/README_CN.md @@ -17,21 +17,21 @@ > A Spring-based agent harness. -Yoke 是友客AI的开源项目:大家一起学习 AgentScope Java V2 和 Spring 的源码与用法,在实践中开发出一个真正能用的 Agent 服务。Yoke(轭)原指套在牛马身上、让它们朝着正确方向使劲的木架,Yoke 也是如此:它是套在模型外面、让模型稳定干活的那层运行框架,把 AgentScope 的 Agent 能力包装成开箱即用的 HTTP 服务。 +Yoke 是友客AI的开源项目。大家一起读 AgentScope Java V2 和 Spring 的源码,边学边做,做出一个能真正跑起来的 Agent 服务。Yoke(轭)原指套在牛马身上、让它们朝一个方向使劲的木架。Yoke 套在模型外面,让它稳定干活。 -- 想用 AgentScope Java 做个 Agent 服务,Demo 十几行就能跑,可一到"要给用户用",问题就来了:每个请求一个 Agent 还是全局一个?对话历史放哪? -- 流式输出、工具调用、多轮会话、权限收紧,这些在官方文档里是分散的例子,拼到一个 Spring Boot 项目里,到底该怎么分层、守哪些边界? -- 官方的 starter 能一行引入,可它的入口没有认证、Agent 是单例,直接上线会出什么事?哪些该用,哪些该自己写? -- 这个项目从一个能对话的接口开始,一课加一层,每一课只解决一个工程问题,代码开源、可以直接运行,也会如实写下没验证的限制和踩过的坑。 +- 用 AgentScope Java 做 Agent,十几行代码就能跑通 Demo。真要给用户用,麻烦才开始:Agent 每个请求新建一个,还是全局共用一个?对话历史放哪? +- 流式输出、工具调用、多轮会话、权限收紧,官方文档里都是零散的例子。拼进一个 Spring Boot 项目,怎么分层、哪些边界必须守住,只能自己摸索。 +- 官方 starter 一行就能引入,但它的入口没有认证,Agent 又是单例,原样上线,不同用户的会话会串在一起。哪些能用、哪些得自己写,这个项目会给出判断。 +- 项目从一个能对话的接口起步,每课只解决一个工程问题,代码开源、能直接运行。没验证过的限制和踩过的坑,也写在文章里。 ### 特性 - 一个接口完成多轮对话:`POST /api/chat`;另有 `POST /api/chat/stream`,按 AG-UI 协议通过 SSE 边推理边推送。 -- 内置 `get_weather` 工具,通过开源的 [Open-Meteo](https://open-meteo.com) 接口查询当前天气(无需密钥,免费版仅限非商业使用)。 -- 每次请求新建临时 `HarnessAgent`,用完即关闭;对话历史保存在 Redis 里,重启不丢、多副本共享,按 `(userId, sessionId)` 隔离,对话过长时自动压缩。 -- 除 `get_weather` 外 Agent 没有其他工具:不开放文件、命令和联网,子 Agent 整体关闭;日志中间件为每次调用记录耗时轨迹。 +- 内置 `get_weather` 工具,通过开源的 [Open-Meteo](https://open-meteo.com) 查当前天气,免密钥(免费版仅限非商业使用)。 +- 每次请求新建一个临时 `HarnessAgent`,用完即关闭。对话历史存在 Redis 里,重启不丢,多个副本共享,按 `(userId, sessionId)` 隔离,对话过长时自动压缩。 +- 除 `get_weather` 外,Agent 没有其他工具:不开放文件、命令和联网,子 Agent 整体关闭。日志中间件为每次调用记录耗时轨迹。 - 通过 AgentScope 官方 starter 接入各种模型:OpenAI 兼容协议(可对接 DashScope、DeepSeek 等)、Anthropic、Gemini、Ollama,换依赖和配置即可。 -- local / test / prod 多环境配置;生产环境缺少密钥时启动即失败,避免带着空密钥上线。 +- 有 local / test / prod 多环境配置;生产环境缺少密钥时启动即失败,避免带着空密钥上线。 - 默认开启虚拟线程,Tomcat 和 Spring 默认线程池都使用虚拟线程。 ## 快速开始 From 3366b534353da2e385eb0b4e4ad0f3e7f68cf7fd Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 5 Nov 2026 14:53:50 +0800 Subject: [PATCH 05/10] feat: create default AGENTS.md in workspace on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentScope 的 WorkspaceManager 每次请求都会在工作区根目录找 AGENTS.md, 找不到就打 WARN。新增 WorkspaceInitializer,启动时缺文件就写一份最小的默认内容, 已存在的文件不覆盖。同时把 YokeProperties.Compaction 的手写 logger 换成 @Slf4j。 --- .../com/yoke/config/WorkspaceInitializer.java | 53 +++++++++++++++++++ .../java/com/yoke/config/YokeProperties.java | 6 +-- .../config/WorkspaceInitializerTests.java | 36 +++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/yoke/config/WorkspaceInitializer.java create mode 100644 src/test/java/com/yoke/config/WorkspaceInitializerTests.java diff --git a/src/main/java/com/yoke/config/WorkspaceInitializer.java b/src/main/java/com/yoke/config/WorkspaceInitializer.java new file mode 100644 index 0000000..5a3addd --- /dev/null +++ b/src/main/java/com/yoke/config/WorkspaceInitializer.java @@ -0,0 +1,53 @@ +package com.yoke.config; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Component; + +/** + * 启动时保证工作区里有 AGENTS.md,没有就写一份默认的,已经存在的绝不覆盖。 + * + *

    AgentScope 的 WorkspaceManager 每次请求都会找工作区根目录下的 AGENTS.md(人设和本地约定), + * 找不到就打 WARN。这里只放最小内容,真正的人设仍然来自 agentscope.agent.sys-prompt, + * 要改约定直接编辑这个文件,不需要重启代码。 + */ +@Slf4j +@Component +public class WorkspaceInitializer implements InitializingBean { + + static final String DEFAULT_AGENTS_MD = """ + # AGENTS.md + + 这个文件定义 Agent 的人设和本地约定,由 Yoke 在工作区为空时自动创建,可以直接修改。 + + - 用简洁清晰的中文回答。 + - 不确定的事情直接说不确定,不要编造。 + """; + + private final Path workspace; + + public WorkspaceInitializer(YokeProperties yoke) { + this.workspace = Path.of(yoke.workspace()); + } + + @Override + public void afterPropertiesSet() { + Path agentsMd = workspace.resolve("AGENTS.md"); + try { + Files.createDirectories(workspace); + if (Files.notExists(agentsMd)) { + Files.writeString(agentsMd, DEFAULT_AGENTS_MD, StandardCharsets.UTF_8); + log.info("工作区缺少 AGENTS.md,已创建默认文件 {}", agentsMd); + } + } catch (IOException e) { + throw new UncheckedIOException("无法初始化工作区 " + workspace, e); + } + } +} diff --git a/src/main/java/com/yoke/config/YokeProperties.java b/src/main/java/com/yoke/config/YokeProperties.java index 4d20617..6ffcc2f 100644 --- a/src/main/java/com/yoke/config/YokeProperties.java +++ b/src/main/java/com/yoke/config/YokeProperties.java @@ -4,8 +4,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.bind.DefaultValue; @@ -52,12 +51,11 @@ public String toString() { * @param triggerMessages 对话消息达到这个条数就触发压缩 * @param keepMessages 压缩后原样保留最近多少条 */ + @Slf4j public record Compaction( @DefaultValue("30") int triggerMessages, @DefaultValue("10") int keepMessages) { - private static final Logger log = LoggerFactory.getLogger(Compaction.class); - public static final int DEFAULT_TRIGGER_MESSAGES = 30; public static final int DEFAULT_KEEP_MESSAGES = 10; diff --git a/src/test/java/com/yoke/config/WorkspaceInitializerTests.java b/src/test/java/com/yoke/config/WorkspaceInitializerTests.java new file mode 100644 index 0000000..8652867 --- /dev/null +++ b/src/test/java/com/yoke/config/WorkspaceInitializerTests.java @@ -0,0 +1,36 @@ +package com.yoke.config; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +class WorkspaceInitializerTests { + + @TempDir + Path dir; + + private static YokeProperties props(Path workspace) { + return new YokeProperties(new YokeProperties.Redis("redis://127.0.0.1:6379", "yoke:"), + new YokeProperties.Compaction(30, 10), workspace.toString()); + } + + @Test + void 工作区没有AGENTS_md时创建默认文件() throws Exception { + Path ws = dir.resolve("ws"); + new WorkspaceInitializer(props(ws)).afterPropertiesSet(); + + assertThat(Files.readString(ws.resolve("AGENTS.md"))).isEqualTo(WorkspaceInitializer.DEFAULT_AGENTS_MD); + } + + @Test + void 已有的AGENTS_md不会被覆盖() throws Exception { + Files.writeString(dir.resolve("AGENTS.md"), "我自己的约定"); + new WorkspaceInitializer(props(dir)).afterPropertiesSet(); + + assertThat(Files.readString(dir.resolve("AGENTS.md"))).isEqualTo("我自己的约定"); + } +} From eb7abe59e3d9d55d12d123d52e3314a66515cad5 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 5 Nov 2026 14:56:57 +0800 Subject: [PATCH 06/10] refactor: drop redundant condition in Compaction validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keepMessages 大于 0 且小于 triggerMessages 已经蕴含 triggerMessages 大于 0, 去掉多余的 triggerMessages <= 0 判断,逻辑不变。 --- src/main/java/com/yoke/config/YokeProperties.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/yoke/config/YokeProperties.java b/src/main/java/com/yoke/config/YokeProperties.java index 6ffcc2f..7233cd1 100644 --- a/src/main/java/com/yoke/config/YokeProperties.java +++ b/src/main/java/com/yoke/config/YokeProperties.java @@ -60,7 +60,8 @@ public record Compaction( public static final int DEFAULT_KEEP_MESSAGES = 10; public Compaction { - if (triggerMessages <= 0 || keepMessages <= 0 || keepMessages >= triggerMessages) { + // keepMessages > 0 且 keepMessages < triggerMessages 已经蕴含 triggerMessages > 0,所以不用单独判断触发条数 + if (keepMessages <= 0 || keepMessages >= triggerMessages) { log.warn("压缩配置不合法(trigger-messages={}, keep-messages={}),回落到默认值 {} / {}", triggerMessages, keepMessages, DEFAULT_TRIGGER_MESSAGES, DEFAULT_KEEP_MESSAGES); triggerMessages = DEFAULT_TRIGGER_MESSAGES; From 602ed7b190db817a16c66ca42491ffe163e64786 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 5 Nov 2026 15:28:09 +0800 Subject: [PATCH 07/10] refactor: clear IDEA warnings in main and test code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 IDEA 检查结果清理警告:重写方法的参数加 JSpecify @NonNull, 没用到的 lambda 参数写成 _,去掉多余的 concurrencySafe 默认值, 测试里改用菱形、方法引用、getFirst() 等写法,Redis.toString 返回值标注 @NonNull。 ArchUnit 规则字段、反射调用的工具方法等误报保持原样。 --- .../com/yoke/config/SseCharsetConfiguration.java | 4 +++- src/main/java/com/yoke/config/YokeProperties.java | 3 ++- .../java/com/yoke/controller/ChatController.java | 2 +- src/main/java/com/yoke/tool/WeatherTool.java | 2 +- src/test/java/com/yoke/ArchitectureTests.java | 4 ++-- src/test/java/com/yoke/ToolPermissionTests.java | 6 +++--- src/test/java/com/yoke/YokeApplicationTests.java | 2 +- .../java/com/yoke/codec/AguiStreamEventTests.java | 5 ++--- .../com/yoke/controller/ChatControllerTests.java | 2 +- .../java/com/yoke/controller/ChatHistoryTests.java | 3 ++- .../com/yoke/middleware/LoggingMiddlewareTests.java | 12 ++++++------ src/test/java/com/yoke/tool/WeatherToolTests.java | 4 ++-- 12 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/yoke/config/SseCharsetConfiguration.java b/src/main/java/com/yoke/config/SseCharsetConfiguration.java index 14d3fb6..88a62eb 100644 --- a/src/main/java/com/yoke/config/SseCharsetConfiguration.java +++ b/src/main/java/com/yoke/config/SseCharsetConfiguration.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; import org.springframework.context.annotation.Configuration; import org.springframework.http.MediaType; @@ -25,7 +26,8 @@ public class SseCharsetConfiguration implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new HandlerInterceptor() { @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, + @NonNull Object handler) { response.setContentType(SSE_UTF8); return true; } diff --git a/src/main/java/com/yoke/config/YokeProperties.java b/src/main/java/com/yoke/config/YokeProperties.java index 7233cd1..2a1cc24 100644 --- a/src/main/java/com/yoke/config/YokeProperties.java +++ b/src/main/java/com/yoke/config/YokeProperties.java @@ -5,6 +5,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NonNull; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.bind.DefaultValue; @@ -37,7 +38,7 @@ public record Redis( /** record 自动生成的 toString 会原样带出连接串,日志或调试输出里就泄漏密码,所以这里脱敏。 */ @Override - public String toString() { + public @NonNull String toString() { return "Redis[uri=" + Optional.ofNullable(uri).map(RedisConfiguration::redact).orElse(null) + ", keyPrefix=" + keyPrefix + "]"; } } diff --git a/src/main/java/com/yoke/controller/ChatController.java b/src/main/java/com/yoke/controller/ChatController.java index 58bee70..06ff0df 100644 --- a/src/main/java/com/yoke/controller/ChatController.java +++ b/src/main/java/com/yoke/controller/ChatController.java @@ -58,7 +58,7 @@ public Flux> stream(String body) { agentFactory::create, agent -> aguiCodec.stream(agent, request, contextOf(DEFAULT_USER_ID, request.threadId())), HarnessAgent::close) - .doOnNext(data -> started.set(true)) + .doOnNext(_ -> started.set(true)) .onErrorResume(error -> { log.warn("流式对话出错,threadId={}", request.threadId(), error); return aguiCodec.encodeError(request, error, started.get()); diff --git a/src/main/java/com/yoke/tool/WeatherTool.java b/src/main/java/com/yoke/tool/WeatherTool.java index 2da46d9..ac89085 100644 --- a/src/main/java/com/yoke/tool/WeatherTool.java +++ b/src/main/java/com/yoke/tool/WeatherTool.java @@ -70,7 +70,7 @@ public WeatherTool() { this(GEOCODING_URL, FORECAST_URL); } - @Tool(name = "get_weather", readOnly = true, concurrencySafe = true, + @Tool(name = "get_weather", readOnly = true, description = "当用户问某个城市现在的天气、气温、冷不冷、要不要带伞时调用。" + "模型自己不知道实时天气,不要凭印象回答。只返回当前实况,不含未来预报。" + "找不到城市或服务不可用时会返回原因,请如实告诉用户。") diff --git a/src/test/java/com/yoke/ArchitectureTests.java b/src/test/java/com/yoke/ArchitectureTests.java index a35af50..c0b32fb 100644 --- a/src/test/java/com/yoke/ArchitectureTests.java +++ b/src/test/java/com/yoke/ArchitectureTests.java @@ -35,7 +35,7 @@ class ArchitectureTests { private static final DescribedPredicate AGUI_OUTSIDE_CONFIG_ALLOWED_LIST = DescribedPredicate .and(resideInAPackage("io.agentscope.core.agui.."), - DescribedPredicate.not(new DescribedPredicate( + DescribedPredicate.not(new DescribedPredicate<>( "the config allowed types " + CONFIG_AGUI_ALLOWED) { @Override public boolean test(JavaClass input) { @@ -45,7 +45,7 @@ public boolean test(JavaClass input) { private static final DescribedPredicate FRAMEWORK_OUTSIDE_ALLOWED_LIST = DescribedPredicate .and(resideInAPackage("io.agentscope.."), - DescribedPredicate.not(new DescribedPredicate( + DescribedPredicate.not(new DescribedPredicate<>( "the controller allowed types " + CONTROLLER_ALLOWED) { @Override public boolean test(JavaClass input) { diff --git a/src/test/java/com/yoke/ToolPermissionTests.java b/src/test/java/com/yoke/ToolPermissionTests.java index 0f25b2b..fc2a6a3 100644 --- a/src/test/java/com/yoke/ToolPermissionTests.java +++ b/src/test/java/com/yoke/ToolPermissionTests.java @@ -62,7 +62,7 @@ private static Set toolsOfUnrestrictedAgent(Path workspace) { // 回复之后框架还会异步发起记忆整理之类不带工具的模型调用,所以取最后一次带工具的调用 Set exposed = model.receivedTools.stream().filter(tools -> !tools.isEmpty()) - .reduce((first, second) -> second).map(TreeSet::new).orElseThrow(); + .reduce((_, second) -> second).map(TreeSet::new).orElseThrow(); assertThat(exposed).as("模型确实拿到了工具清单").isNotEmpty(); assertThat(exposed).doesNotContainAnyElementsOf(FORBIDDEN); @@ -72,7 +72,7 @@ private static Set toolsOfUnrestrictedAgent(Path workspace) { void 被关掉的工具恰好是不加限制时多出来的那一批(@TempDir Path workspace) { controller.chat(new ChatRequest("tools-" + UUID.randomUUID(), "你有哪些工具")).block(); Set exposed = model.receivedTools.stream().filter(tools -> !tools.isEmpty()) - .reduce((first, second) -> second).map(TreeSet::new).orElseThrow(); + .reduce((_, second) -> second).map(TreeSet::new).orElseThrow(); Set removed = toolsOfUnrestrictedAgent(workspace); removed.removeAll(exposed); @@ -89,7 +89,7 @@ private static Set toolsOfUnrestrictedAgent(Path workspace) { controller.chat(new ChatRequest("weather-" + UUID.randomUUID(), "你有哪些工具")).block(); Set exposed = model.receivedTools.stream().filter(tools -> !tools.isEmpty()) - .reduce((first, second) -> second).map(TreeSet::new).orElseThrow(); + .reduce((_, second) -> second).map(TreeSet::new).orElseThrow(); assertThat(exposed).contains("get_weather"); } } diff --git a/src/test/java/com/yoke/YokeApplicationTests.java b/src/test/java/com/yoke/YokeApplicationTests.java index 186045e..4d65743 100644 --- a/src/test/java/com/yoke/YokeApplicationTests.java +++ b/src/test/java/com/yoke/YokeApplicationTests.java @@ -38,7 +38,7 @@ class YokeApplicationTests extends RedisIntegrationTest { HarnessAgentFactory factory = context.getBean(HarnessAgentFactory.class); LoggingMiddleware logging = context.getBean(LoggingMiddleware.class); - int sizes[] = new int[3]; + int[] sizes = new int[3]; for (int i = 0; i < sizes.length; i++) { try (HarnessAgent agent = factory.create()) { var middlewares = agent.getDelegate().getMiddlewares(); diff --git a/src/test/java/com/yoke/codec/AguiStreamEventTests.java b/src/test/java/com/yoke/codec/AguiStreamEventTests.java index 08a59a8..d585693 100644 --- a/src/test/java/com/yoke/codec/AguiStreamEventTests.java +++ b/src/test/java/com/yoke/codec/AguiStreamEventTests.java @@ -123,9 +123,8 @@ private List run(Model model) { void 编码结果没有前导空格且是单行() { List events = run(new ScriptedModel(List.of(Flux.just(text("ok", null))))); - assertThat(events).allSatisfy(json -> { - assertThat(json).startsWith("{").doesNotContain("\n"); - }); + assertThat(events).allSatisfy(json -> + assertThat(json).startsWith("{").doesNotContain("\n")); } @Test diff --git a/src/test/java/com/yoke/controller/ChatControllerTests.java b/src/test/java/com/yoke/controller/ChatControllerTests.java index c9c4308..1b1b6d0 100644 --- a/src/test/java/com/yoke/controller/ChatControllerTests.java +++ b/src/test/java/com/yoke/controller/ChatControllerTests.java @@ -259,7 +259,7 @@ private List streamData(String body) { @Test void 流式接口中途出错时推送RUN_ERROR并关闭Agent() { when(agent.streamEvents(anyList(), any(RuntimeContext.class))) - .thenReturn(Flux.just(new AgentStartEvent("s-1", "r", "Yoke"), + .thenReturn(Flux.just(new AgentStartEvent("s-1", "r", "Yoke"), new TextBlockDeltaEvent("r", "text", "半")) .concatWith(Flux.error(new IllegalStateException("模型不可用")))); diff --git a/src/test/java/com/yoke/controller/ChatHistoryTests.java b/src/test/java/com/yoke/controller/ChatHistoryTests.java index ae758ab..c259b30 100644 --- a/src/test/java/com/yoke/controller/ChatHistoryTests.java +++ b/src/test/java/com/yoke/controller/ChatHistoryTests.java @@ -15,6 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; +import org.springframework.http.codec.ServerSentEvent; import org.springframework.test.context.ActiveProfiles; import static org.assertj.core.api.Assertions.assertThat; @@ -130,7 +131,7 @@ private static String uniqueId(String prefix) { void 模型抛错时真实Agent的流以RUN_ERROR收尾且没有RUN_FINISHED() { List events = controller .stream(AguiTestSupport.body(uniqueId("stream-err"), RecordingModel.FAIL_TRIGGER)) - .map(event -> event.data()).collectList().block(); + .map(ServerSentEvent::data).collectList().block(); assertThat(AguiTestSupport.types(events)).doesNotContain("RUN_FINISHED"); assertThat(AguiTestSupport.types(events).getLast()).isEqualTo("RUN_ERROR"); diff --git a/src/test/java/com/yoke/middleware/LoggingMiddlewareTests.java b/src/test/java/com/yoke/middleware/LoggingMiddlewareTests.java index ddc2c1a..3babbed 100644 --- a/src/test/java/com/yoke/middleware/LoggingMiddlewareTests.java +++ b/src/test/java/com/yoke/middleware/LoggingMiddlewareTests.java @@ -57,7 +57,7 @@ private List messages() { @Test void 整次调用完成时记录agentId和会话以及耗时() { - middleware.onAgent(agent, ctx, new AgentInput(List.of()), in -> Flux.empty()).blockLast(); + middleware.onAgent(agent, ctx, new AgentInput(List.of()), _ -> Flux.empty()).blockLast(); assertThat(messages()).singleElement().satisfies(message -> assertThat(message) .contains("agent 完成", "agent=Yoke", "agentId=agent-1", "userId=alice", "sessionId=s-1", "耗时=")); @@ -69,7 +69,7 @@ private List messages() { when(model.getModelName()).thenReturn("gpt-test"); middleware.onModelCall(agent, ctx, new ModelCallInput(List.of(), List.of(), null, model), - in -> Flux.empty()).blockLast(); + _ -> Flux.empty()).blockLast(); assertThat(messages()).singleElement().satisfies(message -> assertThat(message) .contains("model 完成", "model=gpt-test")); @@ -80,7 +80,7 @@ private List messages() { ToolUseBlock call = ToolUseBlock.builder().id("c1").name("memory_search") .input(Map.of("query", "我的银行卡密码是123456")).build(); - middleware.onActing(agent, ctx, new ActingInput(List.of(call)), in -> Flux.empty()).blockLast(); + middleware.onActing(agent, ctx, new ActingInput(List.of(call)), _ -> Flux.empty()).blockLast(); assertThat(messages()).singleElement().satisfies(message -> assertThat(message) .contains("acting 完成", "tools=[memory_search]") @@ -90,7 +90,7 @@ private List messages() { @Test void 出错时记录warn且异常继续向上抛() { assertThatThrownBy(() -> middleware.onAgent(agent, ctx, new AgentInput(List.of()), - in -> Flux.error(new IllegalStateException("密钥是 sk-secret"))).blockLast()) + _ -> Flux.error(new IllegalStateException("密钥是 sk-secret"))).blockLast()) .hasMessage("密钥是 sk-secret"); assertThat(logs.list).singleElement().satisfies(event -> { @@ -102,7 +102,7 @@ private List messages() { @Test void 被取消时记录被取消() { - Flux flux = middleware.onAgent(agent, ctx, new AgentInput(List.of()), in -> Flux.never()); + Flux flux = middleware.onAgent(agent, ctx, new AgentInput(List.of()), _ -> Flux.never()); flux.subscribe().dispose(); @@ -111,7 +111,7 @@ private List messages() { @Test void 订阅之前不计时也不打日志() { - middleware.onAgent(agent, ctx, new AgentInput(List.of()), in -> Flux.empty()); + middleware.onAgent(agent, ctx, new AgentInput(List.of()), _ -> Flux.empty()); assertThat(logs.list).isEmpty(); } diff --git a/src/test/java/com/yoke/tool/WeatherToolTests.java b/src/test/java/com/yoke/tool/WeatherToolTests.java index 5729fcb..aafbd34 100644 --- a/src/test/java/com/yoke/tool/WeatherToolTests.java +++ b/src/test/java/com/yoke/tool/WeatherToolTests.java @@ -82,7 +82,7 @@ private static void respond(com.sun.net.httpserver.HttpExchange exchange, int st tool.getWeather("北京"); assertThat(requests).hasSize(2); - assertThat(requests.get(0)).startsWith("name=%E5%8C%97%E4%BA%AC&count=1"); + assertThat(requests.getFirst()).startsWith("name=%E5%8C%97%E4%BA%AC&count=1"); assertThat(requests.get(1)).startsWith("latitude=39.9075&longitude=116.39723¤t="); } @@ -90,7 +90,7 @@ private static void respond(com.sun.net.httpserver.HttpExchange exchange, int st void 城市名里的特殊字符被转义不会注入额外参数() { tool.getWeather("a&b=c"); - assertThat(requests.get(0)).startsWith("name=a%26b%3Dc&count=1"); + assertThat(requests.getFirst()).startsWith("name=a%26b%3Dc&count=1"); } @Test From 45a1ddaef4ad147d5ac50296ce09f13922ae6f65 Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 6 Nov 2026 11:58:40 +0800 Subject: [PATCH 08/10] chore: bump version to 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 02 轮换成 HarnessAgent + Redis 会话,对外行为变化较大,版本号升到 2.0.0。 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 10db481..bdc847c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ group=com.yoke -version=1.0.1 +version=2.0.0 org.gradle.parallel=true org.gradle.caching=true org.gradle.configuration-cache=true From c9d6abceb183433a2c6def28764b78dad665786f Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 6 Nov 2026 12:00:12 +0800 Subject: [PATCH 09/10] docs: correct summary field and tool visibility in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 压缩后 summary 字段实测一直为空,摘要放在 context 的第一条消息里;同步更正工具可见范围、流式请求体 content 的限制说明,中英两份 README 保持一致。 --- README.md | 6 +++--- README_CN.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5d0f310..3ae84df 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ What is stored in Redis, per session (checked against a real model, two turns wi | Key | Type | Content | |---|---|---| -| `yoke:session:{userId}/{sessionId}:agent_state` | String | One JSON document with the whole agent state. The conversation is its `context` array (one entry per message: `USER`, `ASSISTANT` with `tool_use`, `TOOL` with `tool_result`, ...), next to `summary` (filled by compaction) and a few empty context objects. About 3.3 KB for two turns | +| `yoke:session:{userId}/{sessionId}:agent_state` | String | One JSON document with the whole agent state. The conversation is its `context` array (one entry per message: `USER`, `ASSISTANT` with `tool_use`, `TOOL` with `tool_result`, ...), next to a `summary` field and a few empty context objects. `summary` stayed empty in a real compaction run: the compaction summary is stored as the first message of `context` instead. About 3.3 KB for two turns | | `yoke:session:{userId}/{sessionId}:agent_state:ver` | String | Version number used for optimistic concurrency, bumped on every save | | `yoke:session:{userId}/{sessionId}:_keys` | Set | Names of all state keys of the session (only `agent_state` here), used to delete a whole session | @@ -91,7 +91,7 @@ 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` of the `default` user (`RunAgentInput` has no user field, so the streaming endpoint always runs as `default`) - `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) +- `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. `content` must be a string: content blocks (even text-only ones, or with images) are rejected - 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: @@ -166,7 +166,7 @@ Yoke's own settings use the `yoke.*` prefix: | `yoke.compaction.keep-messages` | Messages kept verbatim after a compaction, default 10. Values `<= 0`, or a keep count not smaller than the trigger count, fall back to the defaults | | `yoke.workspace` | `HarnessAgent` workspace for local files (offloaded messages, memory), default `~/.yoke/workspace`; it stays on the machine and is not shared between replicas | -The agent is built without file, shell or web tools, and sub-agents are disabled as a whole: reading the 2.0.4 source shows that a sub-agent inherits only the file and shell switches, not the web switch, so leaving sub-agents on would let the model spawn one that can fetch arbitrary URLs. What remains is limited to the memory and session tools (`memory_save` only appends to the fixed `MEMORY.md` and `memory/.md` in the workspace, without a size limit), `load_skill_through_path` and `wait_async_results`. A `MiddlewareBase` bean is attached to every new agent; `LoggingMiddleware` logs agent, model and tool timings without tool arguments or outputs. OpenTelemetry tracing is not attached by default: add an OpenTelemetry SDK and exporter and register an `OtelTracingMiddleware` bean when you need it. +The agent is built without file, shell or web tools, and sub-agents are disabled as a whole: reading the 2.0.4 source shows that a sub-agent inherits only the file and shell switches, not the web switch, so leaving sub-agents on would let the model spawn one that can fetch arbitrary URLs. The read-side memory and session tools (`memory_get`, `memory_search`, `session_history`, `session_list`, `session_search`) are denied too: `memory_get` only checks that a path stays inside the workspace root, not inside the current user's directory, and `session_history` puts model-supplied IDs straight into a path, so they would let the model read other users' memory and sessions. What remains is `get_weather`, `memory_save` (only appends to the fixed `MEMORY.md` and `memory/.md` in the workspace, without a size limit), `load_skill_through_path` and `wait_async_results`. The tool config is passed in explicitly, so a `tools.json` in the workspace has no effect. A `MiddlewareBase` bean is attached to every new agent; `LoggingMiddleware` logs agent, model and tool timings without tool arguments or outputs. OpenTelemetry tracing is not attached by default: add an OpenTelemetry SDK and exporter and register an `OtelTracingMiddleware` bean when you need it. To use another model, replace `agentscope-openai-spring-boot-starter` in `build.gradle` with the matching starter (dashscope, anthropic, gemini or ollama) and change `agentscope.model.provider`. diff --git a/README_CN.md b/README_CN.md index 0f66d53..d9cae98 100644 --- a/README_CN.md +++ b/README_CN.md @@ -69,7 +69,7 @@ curl -X POST localhost:8080/api/chat \ | 键 | 类型 | 内容 | |---|---|---| -| `yoke:session:{userId}/{sessionId}:agent_state` | String | 一份 JSON,是整个 Agent 状态。对话在它的 `context` 数组里(一条消息一项:`USER`、带 `tool_use` 的 `ASSISTANT`、带 `tool_result` 的 `TOOL` 等),旁边是 `summary`(压缩后的摘要落点)和几个空的上下文对象。两轮约 3.3 KB | +| `yoke:session:{userId}/{sessionId}:agent_state` | String | 一份 JSON,是整个 Agent 状态。对话在它的 `context` 数组里(一条消息一项:`USER`、带 `tool_use` 的 `ASSISTANT`、带 `tool_result` 的 `TOOL` 等),旁边是 `summary` 字段和几个空的上下文对象。真实压缩实测里 `summary` 一直是空的,压缩摘要是放在 `context` 的第一条消息里。两轮约 3.3 KB | | `yoke:session:{userId}/{sessionId}:agent_state:ver` | String | 乐观并发用的版本号,每次保存递增 | | `yoke:session:{userId}/{sessionId}:_keys` | Set | 该会话下所有状态键的名字(这里只有 `agent_state`),删除整个会话时靠它 | @@ -91,7 +91,7 @@ curl -N -X POST localhost:8080/api/chat/stream \ - `Content-Type` 必须是 `application/json`,否则返回 415。这也挡住了跨站页面用不触发 CORS 预检的 `text/plain` 请求发起对话 - `threadId` 就是会话 ID,必填:字母、数字、`_.-`,1~64 位,不能全是点。它与 `POST /api/chat` 的 `sessionId` 共用同一份历史(`default` 用户下;`RunAgentInput` 没有用户字段,流式接口固定以 `default` 用户运行) - `runId` 必填且不能为空白(`RunAgentInput` 要求非空) -- `messages` 不能为空,每条消息都要有 `id` 和 `role`,最后一条必须是 `user` 消息且有非空白的纯文本(不支持多模态内容块) +- `messages` 不能为空,每条消息都要有 `id` 和 `role`,最后一条必须是 `user` 消息且有非空白的纯文本。`content` 只能是字符串,内容块形式(全是文本块、带图片等)都会被拒绝 - 对话历史由服务端保存,只采用**最后一条** `user` 消息;前面的消息、`tools`、`context`、`state`、`forwardedProps`、`resume` 都会被忽略,客户端不能借此给 Agent 加工具或改写历史。这条消息也只取文本:它的 `id`、`toolCalls`、`toolCallId` 不会进入历史,消息 ID 由服务端生成。适配器还配置了 `ToolMergeMode.AGENT_ONLY`,只会用服务端注册的工具 示意输出(取值是编的,字段名来自桩模型的实际运行,较长的字段做了省略)。只发 `data:` 行,事件类型是 JSON 里的 `type` 字段,没有 SSE 的 `event:` 名: @@ -166,7 +166,7 @@ Yoke 自己的配置使用 `yoke.*` 前缀: | `yoke.compaction.keep-messages` | 压缩后原样保留的最近条数,默认 10。小于等于 0,或保留条数不小于触发条数时,回落到默认值 | | `yoke.workspace` | `HarnessAgent` 的工作区,放本地文件(转存的消息、记忆),默认 `~/.yoke/workspace`;只在本机,副本之间不共享 | -Agent 创建时不开放文件、命令和联网工具,并且整体关闭子 Agent:读 2.0.4 源码发现子 Agent 只继承文件和 shell 的关闭、不继承联网工具的关闭,开着子 Agent 就能让模型派生出能抓取任意 URL 的子 Agent。关闭后剩下的只有记忆和会话类工具(`memory_save` 只往工作区固定的 `MEMORY.md` 与 `memory/<日期>.md` 追加,内容没有大小限制)、`load_skill_through_path` 和 `wait_async_results`。容器里的 `MiddlewareBase` Bean 会挂到每个新建的 Agent 上;`LoggingMiddleware` 记录 Agent、模型、工具的耗时,不记录工具的参数和输出。OpenTelemetry 追踪默认不挂载:需要时自行引入 OpenTelemetry SDK 和导出器,再注册一个 `OtelTracingMiddleware` Bean。 +Agent 创建时不开放文件、命令和联网工具,并且整体关闭子 Agent:读 2.0.4 源码发现子 Agent 只继承文件和 shell 的关闭、不继承联网工具的关闭,开着子 Agent 就能让模型派生出能抓取任意 URL 的子 Agent。读类的记忆和会话工具(`memory_get`、`memory_search`、`session_history`、`session_list`、`session_search`)也被拒绝:`memory_get` 只校验路径不越出工作区根目录、不校验是否在当前用户目录下,`session_history` 把模型给的 ID 直接拼进路径,开着就能让模型读到别的用户的记忆和会话。剩下的只有 `get_weather`、`memory_save`(只往工作区固定的 `MEMORY.md` 与 `memory/<日期>.md` 追加,内容没有大小限制)、`load_skill_through_path` 和 `wait_async_results`。工具配置是显式传入的,工作区里的 `tools.json` 不生效。容器里的 `MiddlewareBase` Bean 会挂到每个新建的 Agent 上;`LoggingMiddleware` 记录 Agent、模型、工具的耗时,不记录工具的参数和输出。OpenTelemetry 追踪默认不挂载:需要时自行引入 OpenTelemetry SDK 和导出器,再注册一个 `OtelTracingMiddleware` Bean。 要换成其他模型,把 `build.gradle` 里的 `agentscope-openai-spring-boot-starter` 换成对应的 starter(dashscope、anthropic、gemini、ollama),并修改 `agentscope.model.provider`。 From 918e147cb141f103fc22b0abca6a27d32ca59a75 Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 6 Nov 2026 12:00:13 +0800 Subject: [PATCH 10/10] fix: deny cross-user read tools and close agent after result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 显式传入工具配置:拒绝 web_fetch、web_search 和读类的记忆与会话工具(memory_get、memory_search、session_history、session_list、session_search),避免模型读到别的用户的记忆和会话;工作区 tools.json 不再生效 - Mono.using / Flux.using 改为 eager=false:结果交给调用方之后再关闭 Agent,关闭失败只记日志,不把成功的对话变成 500,流式也不会在 RUN_FINISHED 之后追加 RUN_ERROR - 流式请求体的 content 是内容块形式时直接返回 400,并给出准确原因 - 补充对应测试(HarnessAgentToolsTests、AguiStreamCodecTests、ChatControllerTests),更新 ChatApi 文档 --- src/main/java/com/yoke/api/ChatApi.java | 14 ++-- .../java/com/yoke/codec/AguiStreamCodec.java | 6 ++ .../com/yoke/config/HarnessAgentFactory.java | 21 ++++++ .../com/yoke/controller/ChatController.java | 10 ++- .../java/com/yoke/ToolPermissionTests.java | 6 +- .../com/yoke/codec/AguiStreamCodecTests.java | 25 +++++++ .../yoke/config/HarnessAgentToolsTests.java | 67 +++++++++++++++++++ .../yoke/controller/ChatControllerTests.java | 22 ++++++ 8 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/yoke/config/HarnessAgentToolsTests.java diff --git a/src/main/java/com/yoke/api/ChatApi.java b/src/main/java/com/yoke/api/ChatApi.java index 2bc02ae..71d5852 100644 --- a/src/main/java/com/yoke/api/ChatApi.java +++ b/src/main/java/com/yoke/api/ChatApi.java @@ -40,10 +40,16 @@ *

    工具权限:Agent 不开放文件读写、命令执行和联网工具,也整体关闭了子 Agent。 * 读 AgentScope 源码发现,子 Agent 只继承文件和 shell 的关闭、不继承联网工具的关闭, * 开着子 Agent 就能让模型通过 {@code agent_spawn} 派生出能联网的子 Agent,所以整体关闭。 - * 关闭后模型仍能用的只有 {@code memory_save}、{@code memory_get}、{@code memory_search}、{@code session_history}、 - * {@code session_list}、{@code session_search}、{@code load_skill_through_path} 和 {@code wait_async_results}: - * 联网路径已经没有;{@code memory_save} 是唯一写盘的,只往工作区里固定的 {@code MEMORY.md} 和 + * 读类的记忆和会话工具({@code memory_get}、{@code memory_search}、{@code session_history}、{@code session_list}、 + * {@code session_search})也被拒绝:{@code memory_get} 只校验路径不越出工作区根目录、不校验是否在当前用户目录下, + * {@code session_history} 把模型给的 ID 直接拼进路径,开着就能让模型读到别的用户的记忆和会话。 + * 模型能用的只有 {@code get_weather}、{@code memory_save}、{@code load_skill_through_path} 和 {@code wait_async_results}: + * {@code memory_save} 是唯一写盘的,只往工作区里固定的 {@code MEMORY.md} 和 * {@code memory/<日期>.md} 追加,路径不由模型控制,但追加的内容没有大小限制。 + * 工具配置是显式传入的,工作区里的 {@code tools.json} 不生效。 + * + *

    Agent 在结果交给调用方之后才关闭,关闭时抛出的异常只记日志:不会把已经成功的对话变成 500, + * 流式接口也不会在 {@code RUN_FINISHED} 之后再追加 {@code RUN_ERROR}。 * *

    已知限制:{@code HarnessAgent.close()} 会等待框架里全局的异步写入静默 * ({@code awaitMirrorQuiescence} 和 {@code awaitQuiescence},各最长 5 秒),它在每个请求的清理回调里执行, @@ -76,7 +82,7 @@ public interface ChatApi { * (否则返回 415,也避免跨站页面用不触发预检的 {@code text/plain} 请求发起对话)。这里以字符串接收,由实现解析和校验, * 所以契约里不出现框架类型。校验失败(请求体不是合法 JSON、{@code messages} 为空、最后一条不是 user 消息、 * 最后一条 user 消息没有非空白的文本、{@code threadId} 缺失或不合规)都在建立流之前返回 400。 - * 目前只支持纯文本的 {@code content},多模态内容块不支持。 + * {@code content} 目前只支持字符串,内容块形式(包括全是文本块、带图片等)返回 400。 * *

    {@code threadId} 就是会话 ID,同一个 {@code threadId} 共享对话历史,也与 {@link #chat} 的 * {@code sessionId} 共用同一份历史。AG-UI 要求客户端自己生成 {@code threadId},所以这里必填, diff --git a/src/main/java/com/yoke/codec/AguiStreamCodec.java b/src/main/java/com/yoke/codec/AguiStreamCodec.java index 864c404..1b91bca 100644 --- a/src/main/java/com/yoke/codec/AguiStreamCodec.java +++ b/src/main/java/com/yoke/codec/AguiStreamCodec.java @@ -14,6 +14,7 @@ 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.MessageContent; import io.agentscope.core.agui.model.RunAgentInput; import io.agentscope.core.util.JsonUtils; import lombok.AccessLevel; @@ -69,6 +70,11 @@ public StreamRequest parse(String body) { if (Objects.isNull(last) || !last.isUserMessage()) { throw new InvalidStreamRequestException("messages 的最后一条必须是 user 消息"); } + // 内容块形式的 content(即使全是文本块)getTextContent 取不到文本,下面的空白检查本来就会拒绝, + // 这里提前拒绝只是为了给出准确的原因,而不是「没有文本」 + if (last.getContent() instanceof MessageContent.Blocks) { + throw new InvalidStreamRequestException("content 目前只支持字符串,不支持内容块(包括图片、音频、视频、文档)"); + } if (StrUtil.isBlank(last.getTextContent())) { throw new InvalidStreamRequestException("最后一条 user 消息必须有非空白的文本内容"); } diff --git a/src/main/java/com/yoke/config/HarnessAgentFactory.java b/src/main/java/com/yoke/config/HarnessAgentFactory.java index b72da92..7b756bb 100644 --- a/src/main/java/com/yoke/config/HarnessAgentFactory.java +++ b/src/main/java/com/yoke/config/HarnessAgentFactory.java @@ -10,6 +10,7 @@ import io.agentscope.harness.agent.DistributedStore; import io.agentscope.harness.agent.HarnessAgent; import io.agentscope.harness.agent.memory.compaction.CompactionConfig; +import io.agentscope.harness.agent.tools.ToolsConfig; import io.agentscope.spring.boot.properties.AgentProperties; import io.agentscope.spring.boot.properties.AgentscopeProperties; @@ -85,9 +86,29 @@ public HarnessAgent create() { // 子 Agent 只继承文件和 shell 的关闭,不继承 web 的关闭(AgentScope 的 HarnessAgentBuilderSupport 只传这两个), // 开着就能让模型通过 agent_spawn 派生出带 web_fetch 的子 Agent 联网;第 03 轮专门引入子 Agent 时再按需打开 .disableSubagents() + // 显式传入工具配置,工作区里的 tools.json 就不再生效(它能增删工具、声明 stdio MCP 进程) + .toolsConfig(toolsConfig()) .build(); } + /** + * 工具黑名单,每次新建:{@link ToolsConfig} 是可变的 JavaBean,不在请求间共享。 + * + *

    + */ + private static ToolsConfig toolsConfig() { + ToolsConfig config = new ToolsConfig(); + config.setDeny(List.of("web_fetch", "web_search", + "memory_get", "memory_search", "session_history", "session_list", "session_search")); + return config; + } + /** * 只按条数触发压缩。AgentScope 中 {@code triggerTokens} 为 0 表示「按模型上下文窗口动态计算」而不是关闭, * 所以用 {@link Integer#MAX_VALUE} 让 token 阈值实际上永远不会先触发; diff --git a/src/main/java/com/yoke/controller/ChatController.java b/src/main/java/com/yoke/controller/ChatController.java index 06ff0df..2eadbf3 100644 --- a/src/main/java/com/yoke/controller/ChatController.java +++ b/src/main/java/com/yoke/controller/ChatController.java @@ -37,11 +37,13 @@ public Mono chat(ChatRequest request) { String userId = resolveUserId(request); String sessionId = resolveSessionId(request); + // eager=false:回复先交给调用方再关闭 Agent,关闭失败只记日志,不把已经成功的对话变成 500 return Mono.using( agentFactory::create, agent -> agent.call(MessageCodec.toInput(request.message()), contextOf(userId, sessionId)) .map(reply -> new ChatResponse(sessionId, MessageCodec.toText(reply))), - HarnessAgent::close); + HarnessAgent::close, + false); } @Override @@ -54,10 +56,14 @@ public Flux> stream(String body) { // 放在 defer 里,每次订阅一份,重复订阅时不会沿用上一次的状态 AtomicBoolean started = new AtomicBoolean(); // AG-UI 的请求体里没有用户标识,流式接口固定归属 default 用户 + // eager=false:先把完成或出错信号交给下游,再关闭 Agent;关闭时抛出的异常由 Reactor 记日志丢弃, + // 不会变成 onError。否则正常发出 RUN_FINISHED 之后,下面的 onErrorResume 还会再补一个 RUN_ERROR, + // 打破二者互斥的承诺 return Flux.using( agentFactory::create, agent -> aguiCodec.stream(agent, request, contextOf(DEFAULT_USER_ID, request.threadId())), - HarnessAgent::close) + HarnessAgent::close, + false) .doOnNext(_ -> started.set(true)) .onErrorResume(error -> { log.warn("流式对话出错,threadId={}", request.threadId(), error); diff --git a/src/test/java/com/yoke/ToolPermissionTests.java b/src/test/java/com/yoke/ToolPermissionTests.java index fc2a6a3..742168e 100644 --- a/src/test/java/com/yoke/ToolPermissionTests.java +++ b/src/test/java/com/yoke/ToolPermissionTests.java @@ -48,13 +48,15 @@ private static Set toolsOfUnrestrictedAgent(Path workspace) { } /** - * 被关掉的工具:文件读写、命令执行、联网,以及子 Agent 相关的(子 Agent 不继承联网工具的关闭)。 + * 被关掉的工具:文件读写、命令执行、联网,子 Agent 相关的(子 Agent 不继承联网工具的关闭), + * 以及读类的记忆和会话工具(能跨用户读数据,保留只写当前用户的 memory_save)。 * 名字来自上面参照 Agent 的实测清单,由本类的第二个测试守着。 */ private static final Set FORBIDDEN = Set.of( "read_file", "write_file", "edit_file", "list_files", "glob_files", "grep_files", "execute", "web_search", "web_fetch", - "agent_spawn", "agent_send", "agent_list", "task_cancel", "task_list", "task_output"); + "agent_spawn", "agent_send", "agent_list", "task_cancel", "task_list", "task_output", + "memory_get", "memory_search", "session_history", "session_list", "session_search"); @Test void 模型看不到文件_命令_联网和子Agent工具() { diff --git a/src/test/java/com/yoke/codec/AguiStreamCodecTests.java b/src/test/java/com/yoke/codec/AguiStreamCodecTests.java index 5f4e904..e355bfd 100644 --- a/src/test/java/com/yoke/codec/AguiStreamCodecTests.java +++ b/src/test/java/com/yoke/codec/AguiStreamCodecTests.java @@ -3,7 +3,13 @@ import java.util.List; import com.yoke.config.AguiConfiguration; +import io.agentscope.core.agui.model.AguiMessage; +import io.agentscope.core.agui.model.ImageInputContent; +import io.agentscope.core.agui.model.InputContent; +import io.agentscope.core.agui.model.InputContentUrlSource; +import io.agentscope.core.agui.model.TextInputContent; import io.agentscope.core.tool.ToolMergeMode; +import io.agentscope.core.util.JsonUtils; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -30,6 +36,25 @@ private static String request(String threadId, String messages) { .isInstanceOf(InvalidStreamRequestException.class); } + /** 用库自己的序列化拼一条带内容块的 user 消息,不手写 JSON 格式。 */ + private static String userBlocks(InputContent... parts) { + return "[" + JsonUtils.getJsonCodec().toJson(AguiMessage.userMessage("m1", List.of(parts))) + "]"; + } + + @Test + void 内容块形式的content被拒绝并说明原因() { + // 带图片的、全是文本块的都拒绝:只支持字符串形式的 content + for (String messages : new String[] { + userBlocks(new TextInputContent("这张图是什么"), + new ImageInputContent(new InputContentUrlSource("https://example.com/a.png"), null)), + userBlocks(new TextInputContent("你好"), new TextInputContent("在吗"))}) { + assertThatThrownBy(() -> codec.parse(request("\"t-1\"", messages))) + .as(messages) + .isInstanceOf(InvalidStreamRequestException.class) + .hasMessageContaining("内容块"); + } + } + @Test void 合法请求解析出threadId和runId() { AguiStreamCodec.StreamRequest parsed = codec.parse(request("\"t-1\"", USER_HI)); diff --git a/src/test/java/com/yoke/config/HarnessAgentToolsTests.java b/src/test/java/com/yoke/config/HarnessAgentToolsTests.java new file mode 100644 index 0000000..a1c3e6d --- /dev/null +++ b/src/test/java/com/yoke/config/HarnessAgentToolsTests.java @@ -0,0 +1,67 @@ +package com.yoke.config; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.yoke.support.RedisIntegrationTest; +import com.yoke.support.StubModelConfiguration; +import io.agentscope.harness.agent.HarnessAgent; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 工厂新建的 Agent 实际挂了哪些工具:用真实的 Redis 和完整上下文,工作区里预先放一份会拒掉天气工具的 {@code tools.json}, + * 验证显式传入的 toolsConfig 让它不生效。工作区是本类独占的临时目录,不污染共享的测试工作区。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Import(StubModelConfiguration.class) +class HarnessAgentToolsTests extends RedisIntegrationTest { + + private static final Path WORKSPACE = workspaceWithToolsJson(); + + @Autowired + HarnessAgentFactory agentFactory; + + /** 不显式传 toolsConfig 时,这份 tools.json 会把 get_weather 拒掉。 */ + private static Path workspaceWithToolsJson() { + try { + Path workspace = Files.createTempDirectory("yoke-tools-json"); + Files.writeString(workspace.resolve("tools.json"), "{\"deny\":[\"get_weather\"]}"); + return workspace; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @DynamicPropertySource + static void workspaceProperty(DynamicPropertyRegistry registry) { + registry.add("yoke.workspace", WORKSPACE::toString); + } + + @Test + void 模型只能看到天气和必要的框架工具() { + // 精确集合:读类的记忆和会话工具能跨用户读数据,联网工具能把请求引到外部,多出任何一个都要变红 + try (HarnessAgent agent = agentFactory.create()) { + assertThat(agent.getToolkit().getToolNames()) + .containsExactlyInAnyOrder("get_weather", "memory_save", "load_skill_through_path", "wait_async_results"); + } + } + + @Test + void 工作区里的toolsJson不生效() { + try (HarnessAgent agent = agentFactory.create()) { + assertThat(agent.getToolkit().getToolNames()).contains("get_weather"); + } + } +} diff --git a/src/test/java/com/yoke/controller/ChatControllerTests.java b/src/test/java/com/yoke/controller/ChatControllerTests.java index 1b1b6d0..4cfd170 100644 --- a/src/test/java/com/yoke/controller/ChatControllerTests.java +++ b/src/test/java/com/yoke/controller/ChatControllerTests.java @@ -37,6 +37,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -256,6 +257,27 @@ private List streamData(String body) { verify(agent).close(); } + @Test + void 流式接口正常结束后关闭Agent失败也不追加RUN_ERROR() { + when(agent.streamEvents(anyList(), any(RuntimeContext.class))).thenReturn(frameworkEvents("ok")); + doThrow(new IllegalStateException("关闭失败")).when(agent).close(); + + List events = streamData(AguiTestSupport.body("s-1", "hi")); + + assertThat(AguiTestSupport.types(events).getLast()).isEqualTo("RUN_FINISHED"); + assertThat(AguiTestSupport.types(events)).doesNotContain("RUN_ERROR"); + verify(agent).close(); + } + + @Test + void 同步接口回复后关闭Agent失败也正常返回() { + when(agent.call(anyList(), any(RuntimeContext.class))).thenReturn(Mono.just(reply("ok"))); + doThrow(new IllegalStateException("关闭失败")).when(agent).close(); + + assertThat(controller.chat(new ChatRequest("s-1", "hi")).block()).isEqualTo(new ChatResponse("s-1", "ok")); + verify(agent).close(); + } + @Test void 流式接口中途出错时推送RUN_ERROR并关闭Agent() { when(agent.streamEvents(anyList(), any(RuntimeContext.class)))