小深:用 AgentScope Java 2.0 Harness 做私人助手(上)

本篇是系列 上篇 :为什么用官方 Harness、六层架构、装配层、Redis、记忆与多用户隔离。

下篇:小深-AgentScope-Java-2.0-Harness架构博客-02.md(工具、MCP/REST、SSE、前端、踩坑笔记)

项目:agentscope-assistant-java · 技术栈:Spring Boot 3.2.5 + AgentScope Java 2.0.1 HarnessAgent + MCP SDK 0.17.0

0. 写在前面

如果你做过 LLM Agent,大概经历过这些坑:

  • 长任务中途「忘了自己在干什么」
  • 调研过程把统筹上下文撑爆
  • 自己写一套记忆抽取,和框架后台 Consolidation 抢 MEMORY.md
  • 多副本部署后,工作区文件和对话状态各活各的
  • MCP SDK 和 json-schema-validator 版本一拧,启动直接 NoClassDefFoundError

AgentScope Java 2.0 的答案同样不是「再换一个更强的模型」,而是用官方 HarnessAgent :规划、文件系统、子 Agent、HITL、分层记忆、压缩、技能,都已经在框架里。应用层要做的是 接线,而不是再造一套中间件。

能力 作用 本项目怎么打开
规划(todos / Plan Mode) 多步任务显式化;复杂事先写方案 TodoTools + enablePlanMode()
文件系统(带隔离) 中间产物落盘;按用户分命名空间 LocalFilesystemSpec.isolationScope(USER)
子 Agent 专科活隔离上下文 不 disable;workspace/agents/ + agent_spawn
人工审批(HITL) 写文件中断,等人批准后再续跑 PermissionBehavior.ASK + /api/assistant/resume
分层记忆 日流水 Flush + 长期 Consolidation .memory(MemoryConfig),不要自己抽 JSON
上下文压缩 超长摘要前缀,原文卸到 jsonl .compaction(...) + session_search
技能 目录渐进加载;可自进化 enableSkillManageTool + enableSkillCurator
会话恢复 进程重启可续跑 JsonFileAgentStateStore 或 Redis
多用户 记忆 / 工作区互不可见 RuntimeContext.userId + IsolationScope.USER
多副本 状态和工作区共享 profile=distributed + Redis

本项目是这套思想的 Spring 接线示例:统筹助手「小深」+ 联网调研工具 + 官方 research/general-purpose 子 Agent,前端通过 SSE 看工具轨迹并处理审批卡片。

deepagents-assistant-java 的差别一句话:那边用 LangGraph4j 自己实现 Harness;这边把同类产品能力接到 AgentScope 官方 API 上。


1. 系统架构

1.1 六层结构

复制代码
┌─────────────────────────────────────────────────────────────────┐
│  L6  产品层                                                      │
│      AssistantController(SSE /chat + /resume,全部带 userId)     │
│      AssistantChatService(RuntimeContext + HITL pending map)     │
│      AgentEventMapper(官方 AgentEvent → token/tool/interrupt)    │
│      SessionStore(网页聊天 JSON,data/sessions/{userId}/)         │
├─────────────────────────────────────────────────────────────────┤
│  L5  状态可插拔层                                                │
│      默认:JsonFileAgentStateStore + LocalFilesystemSpec           │
│      distributed:RedisAgentStateStore + RedisBaseStore            │
│                 + RemoteFilesystemSpec + DistributedStore          │
├─────────────────────────────────────────────────────────────────┤
│  L4  统筹 HarnessAgent                                           │
│      ModelRegistry.resolve("openai:"+model) 流式                  │
│      maxIters=80 · PermissionMode.BYPASS + write_file ASK         │
│      MemoryConfig(Flush / Consolidation)                        │
│      CompactionConfig + ToolResultEviction + Plan Mode + Skills    │
├─────────────────────────────────────────────────────────────────┤
│  L3  工具                                                         │
│      官方:todo_write、filesystem、agent_spawn、memory_*、          │
│            session_search、skill_manage                           │
│      本项目:webSearch/webRead、calculate、getCurrentDateTime、     │
│            search_conversation_history                            │
├─────────────────────────────────────────────────────────────────┤
│  L2  子 Agent(框架内置 + 工作区 agents/)                         │
│      research-agent / general-purpose · 提示词在 workspace/        │
├─────────────────────────────────────────────────────────────────┤
│  L1  外部世界                                                     │
│      OpenAI 兼容大模型 · 智谱 MCP 0.17 · 可选本机 Redis            │
└─────────────────────────────────────────────────────────────────┘

1.2 一次用户消息怎么走

智谱 MCP / REST MemoryFlushMiddleware HarnessAgent AssistantChatService AssistantController 浏览器 智谱 MCP / REST MemoryFlushMiddleware HarnessAgent AssistantChatService AssistantController 浏览器 #mermaid-svg-Cd5uIUxngYh9Yl0N{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Cd5uIUxngYh9Yl0N .error-icon{fill:#552222;}#mermaid-svg-Cd5uIUxngYh9Yl0N .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Cd5uIUxngYh9Yl0N .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Cd5uIUxngYh9Yl0N .marker.cross{stroke:#333333;}#mermaid-svg-Cd5uIUxngYh9Yl0N svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Cd5uIUxngYh9Yl0N p{margin:0;}#mermaid-svg-Cd5uIUxngYh9Yl0N .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Cd5uIUxngYh9Yl0N text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Cd5uIUxngYh9Yl0N .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Cd5uIUxngYh9Yl0N .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Cd5uIUxngYh9Yl0N #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Cd5uIUxngYh9Yl0N .sequenceNumber{fill:white;}#mermaid-svg-Cd5uIUxngYh9Yl0N #sequencenumber{fill:#333;}#mermaid-svg-Cd5uIUxngYh9Yl0N #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Cd5uIUxngYh9Yl0N .messageText{fill:#333;stroke:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Cd5uIUxngYh9Yl0N .labelText,#mermaid-svg-Cd5uIUxngYh9Yl0N .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .loopText,#mermaid-svg-Cd5uIUxngYh9Yl0N .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Cd5uIUxngYh9Yl0N .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Cd5uIUxngYh9Yl0N .noteText,#mermaid-svg-Cd5uIUxngYh9Yl0N .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Cd5uIUxngYh9Yl0N .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Cd5uIUxngYh9Yl0N .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Cd5uIUxngYh9Yl0N .actorPopupMenu{position:absolute;}#mermaid-svg-Cd5uIUxngYh9Yl0N .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Cd5uIUxngYh9Yl0N .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Cd5uIUxngYh9Yl0N .actor-man circle,#mermaid-svg-Cd5uIUxngYh9Yl0N line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Cd5uIUxngYh9Yl0N :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 模型调 webSearch alt write_file 命中 ASK 流结束后后台 Flush → memory/YYYY-MM-DD.md POST /api/assistant/chat (SSE, userId) chat(userId, sessionId, message) SessionStore 落 user 消息 streamEvents(UserMessage, RuntimeContext) 注入 MEMORY.md / AGENTS.md / 技能 MCP tools/call 或 REST web_search 检索结果 RequireUserConfirmEvent SSE interrupt POST /api/assistant/resume resume(approved) ConfirmResult 续跑 TextBlockDelta / ToolCallStart ... SSE token / tool / plan / agent / done SessionStore 落 assistant

1.3 与官方 Harness / 原 Java 项目的对应

概念 AgentScope 官方 本项目 deepagents-assistant-java
工厂 HarnessAgent.builder() AgentScopeConfig CreateDeepAgent.create
运行时 Harness ReAct 循环 同一个 HarnessAgent LangGraph4j AgentExecutorEx
记忆 Flush + Consolidation .memory(MemoryConfig) 自研 MemoryStore + update_memory
文件 FilesystemSpec Local 或 Remote + 隔离 USER WorkspaceFileOperations
子 Agent agent_spawn 不 disable task / task_batch
HITL Permission ASK approval-tools: write_file,edit_file approvalOn(...)
压缩 CompactionConfig 显式打开 SummarizingConversationContextPolicy
状态 AgentStateStore JSON 文件或 Redis FileSystemSaver checkpoint
联网 MCP Client SDK 0.17.0 + REST 回退 MCP 0.14.1 路径

1.4 角色分工

角色 职责 典型工具
统筹(小深) 理解目标、规划、委派、对用户答复 todos、files、agent_spawn、memory_*、时间/计算、网页检索
research-agent 多角度联网调研 工作区 subagents/research-agent.md;主工具仍是 webSearch/webRead
网页用户 看自己的会话和档案 /api/history/search/api/memory,与官方 jsonl 不是同一份

两份「历史」不要混:

数据 路径 谁写 谁读
网页聊天 JSON data/sessions/{userId}/*.json SessionStore 侧栏、search_conversation_history
压缩卸载日志 workspace/.../sessions/*.log.jsonl 官方 Compaction 官方 session_search
日流水 memory/YYYY-MM-DD.md 官方 Flush 官方 memory_search、档案页
长期记忆 MEMORY.md 官方 Consolidator 每轮注入 prompt、档案页

1.5 关键运行配置

  • 端口:8089
  • 模型:OpenAI 兼容(llm.*,示例 qwen3.8-27b / 百炼 MaaS)
  • 思考模式llm.enable-thinking: false(Qwen 思考 + 流式时 tool_calls 容易不稳)
  • 联网:默认 MCP(mcp.zhipu.enabled=true),SDK 必须 0.17.0+ 才能和 agentscope 的 json-schema-validator:2.0.0 共存
  • 迭代:agent.max-iters=80
  • 审批:write_file / edit_file
  • 单机状态:data/agent-state/;工作区:workspace/IsolationScope.USER 后按用户分子目录)
  • 多副本:--spring.profiles.active=distributed,Redis 127.0.0.1:6379

${user.dir}JVM 工作目录 (IDEA 跑该模块时一般是 agentscope-assistant-java/):

配置 目录 用途
agent.workspace workspace/ AGENTS.mdMEMORY.md、skills、agents、plans
agent.sessions.dir data/sessions/ 网页会话 JSON(按 userId 分子目录)
agent.state-dir data/agent-state/ 单机 AgentState
Redis(distributed) as:state: / as:base: 跨副本状态与工作区文件

1.6 多用户与 HITL(必读)

(1)隔离靠二元组,不是靠「别传错 sessionId」

所有寻址都是 (userId, sessionId)

  • HTTP:POST body.userId;GET/DELETE 用 X-User-Id?userId=
  • 空白 userId → "local"(兼容原来的单用户)
  • UserIds / SessionIds 挡住 ..//、空字节,因为它们会变成目录名
  • Harness:RuntimeContext.userId + IsolationScope.USERMEMORY.md 按用户分命名空间
  • HITL:pendingApprovals 的 key 是 userId/sessionId,避免两人审批单串单

(2)写文件会停,必须 resume

权限默认 BYPASS,只给 write_file / edit_file 加 ASK。框架抛 RequireUserConfirmEvent → SSE interrupt → 前端点批准/拒绝 → ConfirmResult 续跑同一条统筹图。

(3)不要自己再抽一轮记忆

流结束后 MemoryFlushMiddleware 已经在后台写日流水。本项目的 HarnessMemoryCatalog 只读 。若再调模型覆盖 MEMORY.md,会和 MemoryConsolidator 打架。

1.7 用本文源码复现项目

仓库里的 Java 源文件 + pom.xml + application.yml + index.html 下文均有对应内容;Java 已省略 import

  1. 准备 JDK 17Maven 3.8+
  2. 建目录 agentscope-assistant-java/,把每个 ### \path`` 下的代码块存成该相对路径。
  3. 只改配置里的密钥与模型接入点(本文已脱敏,不能直接拿占位符去调模型 ):
    • llm.base-url / llm.model
    • export LLM_API_KEY=...(或写在 yml)
    • 智谱搜索:export MCP_API_KEY=...,保持 MCP 0.17.0
    • 多副本:先 redis-server,再 --spring.profiles.active=distributed
  4. mvn spring-boot:run,打开 http://localhost:8089 ,侧栏填「用户」。

workspace/data/ 启动时会自动建目录。免费额度耗尽时百炼会 403 insufficient_quota,与框架无关。


2. 模块详解与源码

下面按「阅读顺序」展开每个模块:先说明职责,再贴核心源码 (Java 已省略 import,与仓库逻辑一致;配置中的密钥已脱敏)。

一、项目入口与总览

src/main/java/cn/deepassistant/DeepAssistantApplication.java

作用: Spring Boot 启动入口。官方 Harness 能力都在 AgentScopeConfig 打开

java 复制代码
package cn.deepassistant;

/**
 * Spring Boot 入口。所有 AgentScope 官方能力都在 {@link cn.deepassistant.config.AgentScopeConfig} 里打开。
 */
@SpringBootApplication
public class DeepAssistantApplication {

    public static void main(String[] args) {
        SpringApplication.run(DeepAssistantApplication.class, args);
    }
}

二、Harness 装配层

src/main/java/cn/deepassistant/config/AgentScopeConfig.java

作用: 单机 HarnessAgent:模型、工作区、分层记忆、压缩、Plan Mode、技能、写文件 ASK

java 复制代码
package cn.deepassistant.config;

/**
 * 把 AgentScope Java 2.0 Harness 的官方能力接线到 Spring。
 *
 * <p>对照官方文档(<a href="https://java.agentscope.io/v2/zh/docs/harness/memory.html">记忆</a>),
 * 不要再自己写一套「对话结束调模型抽 JSON」------框架已经有完整管线。
 *
 * <h2>两种部署形态(用 Spring profile 切换)</h2>
 * <ul>
 *   <li><b>默认(单机)</b>:{@code JsonFileAgentStateStore} + {@code LocalFilesystemSpec}。
 *       AgentState 和工作区文件都落本地磁盘。见 {@link #agentStateStore()} / {@link #harnessAgent()}。</li>
 *   <li><b>分布式(profile=distributed)</b>:{@code RedisAgentStateStore} + {@code RedisBaseStore}
 *       + {@code RemoteFilesystemSpec} + {@code DistributedStore}。AgentState 和工作区文件都进 Redis,
 *       多副本共享。见 {@link DistributedAgentScopeConfig}。</li>
 * </ul>
 *
 * <h2>官方核心能力在本 Bean 里怎么打开</h2>
 * <ol>
 *   <li><b>分层记忆</b>:{@link #memoryConfig()}。每次对话结束后后台 Flush 到
 *       {@code memory/YYYY-MM-DD.md},再周期性 Consolidation 进 {@code MEMORY.md}。</li>
 *   <li><b>上下文压缩</b>:{@code compaction(...)}。消息太多时摘要前缀;压缩前会再 flush 一次,
 *       事实不会跟着摘要一起丢。</li>
 *   <li><b>大工具结果卸载</b>:{@code toolResultEviction}。单次工具输出太长时落盘,上下文只留预览。</li>
 *   <li><b>Plan Mode</b>:{@code enablePlanMode()}。只读规划,方案写到 {@code workspace/plans/}。</li>
 *   <li><b>技能仓库 / 自进化</b>:工作区 {@code skills/} 每轮按需加载;
 *       {@code enableSkillManageTool} 让模型能起草技能,{@code enableSkillCurator} 后台整理过期技能。</li>
 *   <li><b>子 Agent</b>:不 disable,工作区 {@code agents/} + {@code agent_spawn}。</li>
 *   <li><b>会话恢复</b>:状态存储按 userId + sessionId 落盘(单机 JSON / 分布式 Redis),进程重启可续跑。</li>
 *   <li><b>多用户隔离</b>:{@code IsolationScope.USER} 让框架把 memory/、sessions/ 等运行时数据
 *       按用户分命名空间,不同用户的 MEMORY.md 互不可见。</li>
 *   <li><b>权限三态</b>:写文件 ASK,其余 BYPASS。官方是 Allow / Ask / Deny。</li>
 * </ol>
 *
 * <p>刻意<b>不要</b>调用 {@code disableMemoryHooks()} / {@code disableMemoryTools()}。
 * 关掉之后就没有自动抽取,也没有 {@code memory_search} / {@code memory_get} /
 * {@code memory_save} / {@code session_search}。
 */
@Slf4j
@Configuration
public class AgentScopeConfig {

    @Value("${llm.api-key}")
    private String apiKey;
    @Value("${llm.base-url}")
    private String baseUrl;
    @Value("${llm.model}")
    private String modelName;
    @Value("${llm.temperature:0.3}")
    private double temperature;
    @Value("${llm.enable-thinking:false}")
    private boolean enableThinking;
    @Value("${agent.workspace}")
    private String workspace;
    @Value("${agent.state-dir}")
    private String stateDir;
    @Value("${agent.max-iters:80}")
    private int maxIters;
    @Value("#{'${agent.approval-tools:write_file,edit_file}'.split(',')}")
    private List<String> approvalTools;

    @Value("${agent.memory.flush-throttle-minutes:0}")
    private int flushThrottleMinutes;

    @Value("${agent.memory.consolidation-min-gap-minutes:30}")
    private int consolidationMinGapMinutes;

    @Value("${agent.memory.daily-retention-days:90}")
    private int dailyRetentionDays;

    @Bean
    public Model chatModel() {
        if (apiKey == null || apiKey.isBlank()) {
            throw new IllegalStateException("未配置 llm.api-key / LLM_API_KEY");
        }
        GenerateOptions options = GenerateOptions.builder()
                .temperature(temperature)
                .parallelToolCalls(false)
                .additionalBodyParam("enable_thinking", enableThinking)
                .build();
        ModelCreationContext context = ModelCreationContext.builder()
                .apiKey(apiKey)
                .baseUrl(baseUrl)
                .stream(true)
                .enableThinking(enableThinking)
                .component(GenerateOptions.class, options)
                .build();
        return ModelRegistry.resolve("openai:" + modelName, context);
    }

    /**
     * 单机状态存储:JSON 文件。仅默认 profile 用;
     * distributed profile 用 {@link cn.deepassistant.redis.RedisAgentStateStore}(见 {@link DistributedAgentScopeConfig})。
     */
    @Bean
    @Profile("!distributed")
    public AgentStateStore agentStateStore() throws Exception {
        Path state = Path.of(stateDir).toAbsolutePath().normalize();
        Files.createDirectories(state);
        return new JsonFileAgentStateStore(state);
    }

    /**
     * 官方两层记忆的开关。不调用 {@code .memory(...)} 时框架仍有历史默认行为;
     * 这里显式配一遍,是为了:中文抽取规则、可调节流、让你在配置文件里能看见这些参数。
     *
     * <p>自定义 consolidation prompt 必须恰好两个 {@code %d}(token 上限、字符上限),
     * 所以我们在官方默认模板后面追加中文说明,不去改那两个占位符。
     */
    @Bean
    public MemoryConfig memoryConfig() {
        MemoryConfig.FlushTrigger trigger = flushThrottleMinutes <= 0
                ? MemoryConfig.FlushTrigger.always()
                : MemoryConfig.FlushTrigger.throttled(Duration.ofMinutes(flushThrottleMinutes));
        return MemoryConfig.builder()
                .flushTrigger(trigger)
                .flushPrompt(MemoryFlushManager.DEFAULT_FLUSH_PROMPT + """

                        Additional project rules:
                        - Write every extracted bullet in Chinese.
                        - Prefer durable user preferences, project facts, and constraints.
                        - Never record API keys, passwords, or .env contents.
                        """)
                .consolidationPrompt(MemoryConsolidator.DEFAULT_CONSOLIDATION_PROMPT
                        + "\nWrite the complete MEMORY.md in Chinese markdown.\n")
                .consolidationMinGap(Duration.ofMinutes(Math.max(1, consolidationMinGapMinutes)))
                .dailyFileRetentionDays(Math.max(7, dailyRetentionDays))
                .sessionRetentionDays(180)
                .build();
    }

    /**
     * 单机 HarnessAgent:LocalFilesystem + JsonFile 状态。
     * distributed profile 在 {@link DistributedAgentScopeConfig#harnessAgent} 里覆盖。
     */
    @Bean(destroyMethod = "close")
    @Profile("!distributed")
    public HarnessAgent harnessAgent(Model chatModel,
                                     AgentStateStore agentStateStore,
                                     MemoryConfig memoryConfig,
                                     CommonTools commonTools,
                                     WebResearchTools webResearchTools,
                                     HistoryMemoryTools historyMemoryTools) throws Exception {
        Path ws = resolveAndInitWorkspace();
        HarnessAgent.Builder builder = HarnessAgent.builder()
                .name("xiao-shen")
                .sysPrompt("你是私人智能助手「小深」。人格与行为细则见工作区 AGENTS.md。"
                        + "跨会话事实以 MEMORY.md 为准;需要原文时用 memory_search / session_search / search_conversation_history。")
                .model(chatModel)
                .workspace(ws)
                // 单机:本地文件系统 + 按用户隔离
                .filesystem(new LocalFilesystemSpec().isolationScope(IsolationScope.USER))
                .stateStore(agentStateStore);
        return applyCommonAndBuild(builder, ws, memoryConfig, commonTools, webResearchTools, historyMemoryTools);
    }

    // ---- 两个 profile 共享的拼装逻辑 ----

    /** 解析工作区根目录,提前建好约定子目录(新手一眼能看到结构)。 */
    Path resolveAndInitWorkspace() throws Exception {
        Path ws = Path.of(workspace).toAbsolutePath().normalize();
        Files.createDirectories(ws);
        Files.createDirectories(ws.resolve("memory"));
        Files.createDirectories(ws.resolve("skills"));
        Files.createDirectories(ws.resolve("agents"));
        Files.createDirectories(ws.resolve("plans"));
        Files.createDirectories(ws.resolve("knowledge"));
        Files.createDirectories(ws.resolve("sessions"));
        return ws;
    }

    /** 注册工具 + 权限 + 记忆 + 压缩 + Plan + Skills,最后 build。两个 profile 共用。 */
    HarnessAgent applyCommonAndBuild(HarnessAgent.Builder builder,
                                     Path ws,
                                     MemoryConfig memoryConfig,
                                     CommonTools commonTools,
                                     WebResearchTools webResearchTools,
                                     HistoryMemoryTools historyMemoryTools) {
        Toolkit toolkit = new Toolkit();
        toolkit.registerTool(new TodoTools());
        toolkit.registerTool(commonTools);
        toolkit.registerTool(webResearchTools);
        // 额外工具:检索「网页侧栏那份」聊天 JSON。官方 session_search 搜的是 workspace/sessions/*.log.jsonl。
        toolkit.registerTool(historyMemoryTools);

        PermissionContextState.Builder perm = PermissionContextState.builder()
                .mode(PermissionMode.BYPASS);
        for (String toolName : approvalTools == null ? List.<String>of() : approvalTools) {
            if (toolName == null || toolName.isBlank()) {
                continue;
            }
            String name = toolName.trim();
            perm.addAskRule(name, new PermissionRule(name, null, PermissionBehavior.ASK, "policy"));
        }

        builder.toolkit(toolkit)
                .additionalContextFile("PREFERENCES.md")
                .permissionContext(perm.build())
                .maxIters(Math.max(25, maxIters))
                // ① 分层记忆:Flush + Consolidation + 自动注册 memory_* / session_search
                .memory(memoryConfig)
                // ② 上下文自动管理:超长则摘要;压缩前 flush + 把原文卸到 jsonl
                .compaction(CompactionConfig.builder()
                        .triggerMessages(30)
                        .keepMessages(10)
                        .flushBeforeCompact(true)
                        .offloadBeforeCompact(true)
                        .truncateArgs(CompactionConfig.TruncateArgsConfig.builder()
                                .maxArgLength(2000)
                                .truncationText("... [truncated] ...")
                                .build())
                        .build())
                // ③ 单次工具结果过大时落盘,避免撑爆上下文
                .toolResultEviction(ToolResultEvictionConfig.defaults())
                // ④ 复杂任务先写方案文件,再动手
                .enablePlanMode()
                // ⑤ 技能自进化:模型可起草技能;后台 curator 归档过期技能
                .enableSkillManageTool(SkillManageConfig.defaults())
                .enableSkillCurator(SkillCuratorConfig.defaults())
                // 本地个人助手先关掉无约束 Shell,避免模型乱执行系统命令
                .disableShellTool();

        HarnessAgent agent = builder.build();
        log.info("[Harness] 已构建 workspace={} flushTrigger={} approvalTools={}",
                ws,
                memoryConfig.flushTrigger(),
                approvalTools == null ? List.of() : new ArrayList<>(approvalTools));
        return agent;
    }
}

src/main/java/cn/deepassistant/config/DistributedAgentScopeConfig.java

作用: distributed profile:Redis 状态 + RemoteFilesystem + DistributedStore

java 复制代码
package cn.deepassistant.config;

/**
 * 分布式部署配置(Spring profile = {@code distributed})。
 *
 * <p>启动方式:{@code mvn spring-boot:run -Dspring-boot.run.profiles=distributed}
 * 或 {@code java -jar app.jar --spring.profiles.active=distributed}
 *
 * <h2>和单机的区别</h2>
 * <pre>
 *                  单机(默认)                     分布式(profile=distributed)
 *   AgentState     JsonFileAgentStateStore          RedisAgentStateStore      ← Redis
 *   工作区文件      LocalFilesystemSpec(本地磁盘)   RemoteFilesystemSpec      ← Redis (RedisBaseStore)
 *   状态后端        无 DistributedStore              DistributedStore.builder() 组合上面两个
 * </pre>
 *
 * <p>官方文档明确:用 {@code filesystem(RemoteFilesystemSpec)} 时框架会强制要求分布式状态存储,
 * 否则 {@code HarnessAgent.build()} 直接抛 IllegalStateException。这里用
 * {@link DistributedStore#builder()} 把 {@link RedisAgentStateStore} 和 {@link RedisBaseStore}
 * 组装成 {@link DistributedStore} 交给 {@code .distributedStore(...)},框架在 build() 时会:
 * <ol>
 *   <li>把 {@code distributedStore.agentStateStore()} 设为 ReActAgent 的状态后端</li>
 *   <li>把 {@code distributedStore.baseStore()} 注入 RemoteFilesystemSpec</li>
 * </ol>
 *
 * <h2>Redis 连接</h2>
 * 用 Lettuce 单连接(同步 API)。配置见 {@code application.yml} 的 {@code redis.*}:
 * <pre>
 *   redis:
 *     host: 127.0.0.1
 *     port: 6379
 *     password:        # 没密码留空
 *     database: 0
 * </pre>
 *
 * <p>多副本时每个副本用同一份 Redis,任意副本都能用 (userId, sessionId) 续跑同一段对话,
 * 也能读到同一份 MEMORY.md。
 */
@Slf4j
@Configuration
@Profile("distributed")
public class DistributedAgentScopeConfig {

    @Value("${redis.host:127.0.0.1}")
    private String redisHost;
    @Value("${redis.port:6379}")
    private int redisPort;
    @Value("${redis.password:}")
    private String redisPassword;
    @Value("${redis.database:0}")
    private int redisDatabase;

    /**
     * Lettuce 连接。{@code destroyMethod="close"} 保证 Spring 关闭时释放连接。
     * 连接本身是线程安全的,{@link #redisCommands()} 从中拿同步接口。
     */
    @Bean(destroyMethod = "close")
    public StatefulRedisConnection<String, String> redisConnection() {
        RedisURI.Builder uri = RedisURI.builder()
                .withHost(redisHost)
                .withPort(redisPort)
                .withDatabase(redisDatabase);
        if (redisPassword != null && !redisPassword.isBlank()) {
            uri.withPassword(redisPassword.toCharArray());
        }
        RedisClient client = RedisClient.create(uri.build());
        log.info("[Redis] 连接 {}:{} db={}", redisHost, redisPort, redisDatabase);
        return client.connect();
    }

    /** 同步 Redis 命令接口,两个 Redis 存储共用。 */
    @Bean
    public RedisCommands<String, String> redisCommands(StatefulRedisConnection<String, String> conn) {
        return conn.sync();
    }

    /** 分布式 AgentState 存储:状态进 Redis,多副本共享。 */
    @Bean
    public RedisAgentStateStore redisAgentStateStore(RedisCommands<String, String> redis) {
        return new RedisAgentStateStore(redis);
    }

    /** 分布式工作区文件存储:MEMORY.md / memory/ / sessions/ 进 Redis。 */
    @Bean
    public RedisBaseStore redisBaseStore(RedisCommands<String, String> redis) {
        return new RedisBaseStore(redis);
    }

    /**
     * 把 RedisAgentStateStore + RedisBaseStore 组装成官方 {@link DistributedStore}。
     * 交给 {@code .distributedStore(...)} 后,框架自动用前者做状态后端、把后者注入 RemoteFilesystem。
     */
    @Bean
    public DistributedStore distributedStore(RedisAgentStateStore stateStore,
                                             RedisBaseStore baseStore) {
        return DistributedStore.builder()
                .agentStateStore(stateStore)
                .baseStore(baseStore)
                .build();
    }

    /**
     * 分布式 HarnessAgent:RemoteFilesystem + DistributedStore。
     *
     * <p>注意:这里也声明了 {@code AgentStateStore} bean,覆盖单机的 JsonFile 版本,
     * 这样 {@link cn.deepassistant.service.AssistantChatService} 注入到的就是 Redis 版。
     */
    @Bean
    public AgentStateStore agentStateStore(RedisAgentStateStore redisAgentStateStore) {
        return redisAgentStateStore;
    }

    /**
     * 分布式 HarnessAgent。和单机版共用 {@link AgentScopeConfig#applyCommonAndBuild},
     * 区别只在前半段:RemoteFilesystem + distributedStore。
     */
    @Bean(destroyMethod = "close")
    public HarnessAgent harnessAgent(Model chatModel,
                                      DistributedStore distributedStore,
                                      MemoryConfig memoryConfig,
                                      CommonTools commonTools,
                                      WebResearchTools webResearchTools,
                                      HistoryMemoryTools historyMemoryTools,
                                      AgentScopeConfig base) throws Exception {
        Path ws = base.resolveAndInitWorkspace();
        HarnessAgent.Builder builder = HarnessAgent.builder()
                .name("xiao-shen")
                .sysPrompt("你是私人智能助手「小深」。人格与行为细则见工作区 AGENTS.md。"
                        + "跨会话事实以 MEMORY.md 为准;需要原文时用 memory_search / session_search / search_conversation_history。")
                .model(chatModel)
                .workspace(ws)
                // 分布式:工作区文件进 Redis(RemoteFilesystem + RedisBaseStore),按用户隔离
                .filesystem(new RemoteFilesystemSpec().isolationScope(IsolationScope.USER))
                // 状态 + 文件后端都来自 DistributedStore;build() 会自动接线
                .distributedStore(distributedStore);
        log.info("[Harness] 分布式模式:RemoteFilesystem + Redis ({}:{} db={})", redisHost, redisPort, redisDatabase);
        return base.applyCommonAndBuild(builder, ws, memoryConfig, commonTools, webResearchTools, historyMemoryTools);
    }
}

三、分布式状态(Redis)

src/main/java/cn/deepassistant/redis/package-info.java

作用: 为什么官方 RemoteFilesystem 必须配分布式 AgentState

java 复制代码
/**
 * Redis 版的分布式状态 / 文件存储。
 *
 * <p>多副本部署时启用(Spring profile = {@code distributed}):
 * <ul>
 *   <li>{@link RedisAgentStateStore} ------ 把 AgentState(对话上下文、权限、计划等)存进 Redis,
 *       任意副本都能用 (userId, sessionId) 续跑同一段对话</li>
 *   <li>{@link RedisBaseStore} ------ 把工作区文件(MEMORY.md、memory/日流水、sessions/*.log.jsonl)
 *       存进 Redis,配合官方 {@code RemoteFilesystemSpec} 实现跨副本共享</li>
 *   <li>{@link RedisDistributedStoreFactory} ------ 用 {@code DistributedStore.builder()} 把上面两个
 *       组装成 {@link io.agentscope.harness.agent.DistributedStore},交给
 *       {@code HarnessAgent.builder().distributedStore(...)},框架在 build() 时会自动把
 *       baseStore 注入 RemoteFilesystemSpec、把 agentStateStore 设为 ReActAgent 的状态后端</li>
 * </ul>
 *
 * <p>对照官方文档「Context &amp; AgentState」:用 {@code filesystem(RemoteFilesystemSpec)} 时
 * 框架会强制要求分布式状态存储,否则 {@code build()} 直接抛 IllegalStateException。
 * 这里两个 Redis 实现正好满足这个要求。
 *
 * <p>序列化复用官方 {@link io.agentscope.core.util.JsonUtils#getJsonCodec()},
 * 和 {@link io.agentscope.core.state.JsonFileAgentStateStore} 完全一致,
 * 所以单机 JSON 文件和 Redis 之间迁移不需要转格式。
 */
package cn.deepassistant.redis;

src/main/java/cn/deepassistant/redis/RedisAgentStateStore.java

作用: AgentState 存 Redis,按 (userId, sessionId) 隔离

java 复制代码
package cn.deepassistant.redis;

/**
 * Redis 版 {@link AgentStateStore}:把 AgentState 存进 Redis,多副本共享。
 *
 * <h3>Key 设计</h3>
 * <pre>
 *   单条状态:  as:state:{userId}:{sessionId}:{slotName}   →  JSON 字符串
 *   列表状态:  as:list:{userId}:{sessionId}:{slotName}    →  Redis LIST(每行一个 JSON)
 * </pre>
 * userId / sessionId 只允许 {@code [a-zA-Z0-9_-]}(见 UserIds / SessionIds),
 * 所以用 {@code :} 当分隔符不会撞。
 *
 * <p>序列化用 {@link JsonUtils#getJsonCodec()},和 {@link io.agentscope.core.state.JsonFileAgentStateStore}
 * 完全一致------单机 JSON 文件迁到 Redis 不用转格式,反之亦然。
 *
 * <p>列表状态用 Redis LIST(RPUSH 一行一个 JSON)。{@link #save} 列表时做全量重写
 * (DEL + RPUSH),比 JsonFile 的增量追加简单,量级不大时性能足够。
 */
@Slf4j
public class RedisAgentStateStore implements AgentStateStore {

    private static final String STATE_PREFIX = "as:state:";
    private static final String LIST_PREFIX = "as:list:";

    private final RedisCommands<String, String> redis;

    public RedisAgentStateStore(RedisCommands<String, String> redis) {
        this.redis = redis;
    }

    private static String stateKey(String userId, String sessionId, String slotName) {
        return STATE_PREFIX + userId + ":" + sessionId + ":" + slotName;
    }

    private static String listKey(String userId, String sessionId, String slotName) {
        return LIST_PREFIX + userId + ":" + sessionId + ":" + slotName;
    }

    /** 单条状态:序列化成 JSON 存一个 String key。 */
    @Override
    public void save(String userId, String sessionId, String slotName, State state) {
        String json = JsonUtils.getJsonCodec().toPrettyJson(state);
        redis.set(stateKey(userId, sessionId, slotName), json);
    }

    /** 列表状态:全量重写(DEL + RPUSH 每行一个 JSON)。 */
    @Override
    public void save(String userId, String sessionId, String slotName,
                     List<? extends State> states) {
        String key = listKey(userId, sessionId, slotName);
        redis.del(key);
        if (states == null || states.isEmpty()) {
            return;
        }
        String[] jsons = new String[states.size()];
        for (int i = 0; i < states.size(); i++) {
            jsons[i] = JsonUtils.getJsonCodec().toPrettyJson(states.get(i));
        }
        redis.rpush(key, jsons);
    }

    @Override
    public <T extends State> Optional<T> get(String userId, String sessionId, String slotName, Class<T> type) {
        String json = redis.get(stateKey(userId, sessionId, slotName));
        if (json == null || json.isBlank()) {
            return Optional.empty();
        }
        try {
            return Optional.of(JsonUtils.getJsonCodec().fromJson(json, type));
        } catch (Exception e) {
            log.warn("[RedisState] 反序列化失败 {}/{}/{}: {}", userId, sessionId, slotName, e.getMessage());
            return Optional.empty();
        }
    }

    @Override
    public <T extends State> List<T> getList(String userId, String sessionId, String slotName, Class<T> type) {
        List<String> jsons = redis.lrange(listKey(userId, sessionId, slotName), 0, -1);
        List<T> result = new ArrayList<>();
        for (String json : jsons) {
            if (json == null || json.isBlank()) {
                continue;
            }
            try {
                result.add(JsonUtils.getJsonCodec().fromJson(json, type));
            } catch (Exception e) {
                log.warn("[RedisState] 列表项反序列化失败 {}/{}/{}: {}", userId, sessionId, slotName, e.getMessage());
            }
        }
        return result;
    }

    /** 该 (userId, sessionId) 是否存过任何状态。 */
    @Override
    public boolean exists(String userId, String sessionId) {
        String stateMatch = STATE_PREFIX + userId + ":" + sessionId + ":*";
        String listMatch = LIST_PREFIX + userId + ":" + sessionId + ":*";
        return !scanKeys(stateMatch).isEmpty() || !scanKeys(listMatch).isEmpty();
    }

    /** 删整个会话:扫两种前缀全删。 */
    @Override
    public void delete(String userId, String sessionId) {
        String stateMatch = STATE_PREFIX + userId + ":" + sessionId + ":*";
        String listMatch = LIST_PREFIX + userId + ":" + sessionId + ":*";
        delAll(scanKeys(stateMatch));
        delAll(scanKeys(listMatch));
    }

    /** 删单个槽位:删单条 key + 列表 key。 */
    @Override
    public void delete(String userId, String sessionId, String slotName) {
        redis.del(stateKey(userId, sessionId, slotName));
        redis.del(listKey(userId, sessionId, slotName));
    }

    /** 列出某用户的所有 sessionId:扫 as:state:{userId}:* 和 as:list:{userId}:* 取第三段。 */
    @Override
    public Set<String> listSessionIds(String userId) {
        Set<String> ids = new TreeSet<>();
        String stateMatch = STATE_PREFIX + userId + ":*";
        String listMatch = LIST_PREFIX + userId + ":*";
        for (String key : scanKeys(stateMatch)) {
            ids.add(extractSessionId(key, STATE_PREFIX));
        }
        for (String key : scanKeys(listMatch)) {
            ids.add(extractSessionId(key, LIST_PREFIX));
        }
        return ids;
    }

    /** as:state:{userId}:{sessionId}:{slotName} → 取 sessionId 段。 */
    private static String extractSessionId(String key, String prefix) {
        String rest = key.substring(prefix.length());
        int first = rest.indexOf(':');
        int second = rest.indexOf(':', first + 1);
        return second < 0 ? rest.substring(first + 1) : rest.substring(first + 1, second);
    }

    private Set<String> scanKeys(String pattern) {
        Set<String> keys = new HashSet<>();
        ScanCursor cursor = ScanCursor.INITIAL;
        do {
            var scan = redis.scan(cursor, ScanArgs.Builder.matches(pattern).limit(200));
            keys.addAll(scan.getKeys());
            cursor = scan;
        } while (!cursor.isFinished());
        return keys;
    }

    private void delAll(Set<String> keys) {
        if (keys.isEmpty()) {
            return;
        }
        redis.del(keys.toArray(new String[0]));
    }
}

src/main/java/cn/deepassistant/redis/RedisBaseStore.java

作用: 工作区文件存 Redis Hash,Lua 做版本 CAS

java 复制代码
package cn.deepassistant.redis;

/**
 * Redis 版 {@link BaseStore}:给官方 {@code RemoteFilesystem} 当 KV 后端。
 *
 * <p>多副本时工作区文件(MEMORY.md、memory/日流水、sessions/*.log.jsonl)不再落本地磁盘,
 * 而是存进 Redis,所有副本共享同一份。
 *
 * <h3>Key 设计</h3>
 * <pre>
 *   as:base:{namespaceJoined}/{fileKey}   →  Redis Hash { value: &lt;JSON&gt;, version: &lt;long&gt; }
 * </pre>
 * namespace 是 {@link io.agentscope.harness.agent.filesystem.remote.store.NamespaceFactory#getNamespace} 的返回,
 * {@code IsolationScope.USER} 下就是 {@code ["alice"]},join 成 {@code alice}。
 * fileKey 是工作区相对路径,如 {@code MEMORY.md}、{@code memory/2024-01-01.md}。
 * 所以最终 key 形如 {@code as:base:alice/MEMORY.md}。
 *
 * <h3>version / 乐观锁</h3>
 * 用 Hash 的 {@code version} 字段做 {@link #putIfVersion} 乐观锁,靠 Lua 脚本保证原子。
 * {@link #put} 每次写都 {@code version+1}。
 *
 * <p>value 是 {@link StoreItem#value()}({@code Map<String, Object>})的 JSON,
 * 官方 {@code RemoteFilesystem.fileDataToStoreValue} 负责在 FileData ↔ Map 之间转换,
 * 本类只管忠实存取 Map,不关心里面是什么。
 */
@Slf4j
public class RedisBaseStore implements BaseStore {

    private static final String PREFIX = "as:base:";

    private final RedisCommands<String, String> redis;

    public RedisBaseStore(RedisCommands<String, String> redis) {
        this.redis = redis;
    }

    /** namespace 段用 / 连接,再拼上 fileKey,加全局前缀。 */
    private static String redisKey(List<String> namespace, String key) {
        String ns = String.join("/", namespace);
        return PREFIX + ns + "/" + key;
    }

    /** search 用的前缀:as:base:{namespace}/。 */
    private static String scanPrefix(List<String> namespace) {
        return PREFIX + String.join("/", namespace) + "/";
    }

    @Override
    public StoreItem get(List<String> namespace, String key) {
        String rk = redisKey(namespace, key);
        Map<String, String> fields = redis.hgetall(rk);
        if (fields == null || fields.isEmpty()) {
            return null;
        }
        String valueJson = fields.getOrDefault("value", "");
        long version = parseLong(fields.get("version"), 0L);
        Map<String, Object> value = JsonCodecHolder.fromJsonToMap(valueJson);
        return new StoreItem(key, value, version);
    }

    @Override
    public void put(List<String> namespace, String key, Map<String, Object> value) {
        String rk = redisKey(namespace, key);
        String valueJson = JsonCodecHolder.toJson(value);
        // Lua:version 不存在则置 1,存在则 +1;然后写 value。保证原子。
        String script =
                "local v = redis.call('HINCRBY', KEYS[1], 'version', 1) " +
                "redis.call('HSET', KEYS[1], 'value', ARGV[1]) " +
                "return v";
        redis.eval(script, ScriptOutputType.INTEGER, new String[]{rk}, valueJson);
    }

    @Override
    public boolean putIfVersion(List<String> namespace, String key,
                               Map<String, Object> value, long expectedVersion) {
        String rk = redisKey(namespace, key);
        String valueJson = JsonCodecHolder.toJson(value);
        // Lua:读当前 version,匹配 expectedVersion 才 version+1 并写 value,返回 1;否则返回 0。
        String script =
                "local cur = tonumber(redis.call('HGET', KEYS[1], 'version')) or 0 " +
                "if cur == tonumber(ARGV[1]) then " +
                "  redis.call('HINCRBY', KEYS[1], 'version', 1) " +
                "  redis.call('HSET', KEYS[1], 'value', ARGV[2]) " +
                "  return 1 " +
                "else " +
                "  return 0 " +
                "end";
        Long r = redis.eval(script, ScriptOutputType.INTEGER, new String[]{rk},
                String.valueOf(expectedVersion), valueJson);
        return r != null && r == 1L;
    }

    @Override
    public List<StoreItem> search(List<String> namespace, int limit, int offset) {
        String prefix = scanPrefix(namespace);
        List<String> keys = new ArrayList<>(scanKeys(prefix + "*"));
        keys.sort(Comparator.naturalOrder());
        int from = Math.min(offset, keys.size());
        int to = Math.min(from + limit, keys.size());
        List<String> page = keys.subList(from, to);
        List<StoreItem> items = new ArrayList<>();
        for (String rk : page) {
            Map<String, String> fields = redis.hgetall(rk);
            if (fields == null || fields.isEmpty()) {
                continue;
            }
            String valueJson = fields.getOrDefault("value", "");
            long version = parseLong(fields.get("version"), 0L);
            Map<String, Object> value = JsonCodecHolder.fromJsonToMap(valueJson);
            // itemKey = 去掉前缀 + namespace/,剩下的是 fileKey
            String itemKey = stripPrefix(rk, prefix);
            items.add(new StoreItem(itemKey, value, version));
        }
        return items;
    }

    @Override
    public void delete(List<String> namespace, String key) {
        redis.del(redisKey(namespace, key));
    }

    // ---- 工具 ----

    private static String stripPrefix(String rk, String prefix) {
        return rk.startsWith(prefix) ? rk.substring(prefix.length()) : rk;
    }

    private static long parseLong(String s, long def) {
        if (s == null || s.isBlank()) {
            return def;
        }
        try {
            return Long.parseLong(s.trim());
        } catch (NumberFormatException e) {
            return def;
        }
    }

    private Set<String> scanKeys(String pattern) {
        Set<String> keys = new HashSet<>();
        ScanCursor cursor = ScanCursor.INITIAL;
        do {
            var scan = redis.scan(cursor, ScanArgs.Builder.matches(pattern).limit(200));
            keys.addAll(scan.getKeys());
            cursor = scan;
        } while (!cursor.isFinished());
        return keys;
    }

    /** 延迟拿官方 JsonCodec,避免类加载早期 init 问题。 */
    private static final class JsonCodecHolder {
        static String toJson(Object o) {
            return io.agentscope.core.util.JsonUtils.getJsonCodec().toJson(o);
        }

        @SuppressWarnings("unchecked")
        static Map<String, Object> fromJsonToMap(String json) {
            if (json == null || json.isBlank()) {
                return Map.of();
            }
            try {
                return io.agentscope.core.util.JsonUtils.getJsonCodec()
                        .fromJson(json, new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {
                        });
            } catch (Exception e) {
                log.warn("[RedisBaseStore] value 反序列化失败: {}", e.getMessage());
                return Map.of();
            }
        }
    }
}

四、记忆、会话与多用户隔离

src/main/java/cn/deepassistant/memory/package-info.java

作用: 三层记忆心智模型:不要再自己写抽取器去覆盖 MEMORY.md

java 复制代码
/**
 * 记忆分三层,对应 AgentScope Java 2.0 官方文档,而不是原 LangGraph 项目自己的 MemoryStore。
 *
 * <pre>
 *  ① 短期:当前会话 messages(AgentState)+ 本项目 SessionStore(给网页侧栏用)
 *  ② 中期日流水:workspace/memory/YYYY-MM-DD.md
 *       由官方 MemoryFlushMiddleware 在每次 call 结束后后台抽取(只追加、不去重)
 *  ③ 长期策划:workspace/MEMORY.md
 *       由官方 MemoryConsolidator 周期性合并日流水、去重后整体重写
 *       每一轮推理都会注入 system prompt
 *
 *  压缩 Compaction:上下文太长时摘要前缀、保留尾部;压缩前会再 flush 一次
 *  原文卸载:压缩掉的消息写入 sessions/*.log.jsonl,官方 session_search 能搜到
 * </pre>
 *
 * 本包里的 Java 类只做两件事:<b>读</b>官方已经写好的文件,以及检索我们自己的聊天 JSON。
 * 不要再另写一套抽取器去覆盖 MEMORY.md,否则会和 MemoryConsolidator 抢文件。
 */
package cn.deepassistant.memory;

src/main/java/cn/deepassistant/memory/HarnessMemoryCatalog.java

作用: 只读官方 WorkspaceManager;用 ObjectProvider 解开循环依赖

java 复制代码
package cn.deepassistant.memory;

/**
 * 只读目录:把官方 Harness 已经写到磁盘上的记忆文件读出来给前端 / 工具用。
 *
 * <h3>多用户隔离</h3>
 * HarnessAgent 配了 {@code IsolationScope.USER},框架的 {@link WorkspaceManager} 会按
 * {@code RuntimeContext.userId} 把 {@code memory/} 和 {@code MEMORY.md} 路由到
 * 该用户的命名空间下。本类不自己拼路径,而是调
 * {@link WorkspaceManager#readMemoryMd(RuntimeContext)} /
 * {@link WorkspaceManager#listMemoryFilePaths(RuntimeContext)} /
 * {@link WorkspaceManager#readManagedWorkspaceFileUtf8(RuntimeContext, String)},
 * 保证和框架写入的路径完全一致------Local / Remote 文件系统都兼容。
 *
 * <h3>为什么用 {@link ObjectProvider} 而不是直接注入 {@link HarnessAgent}</h3>
 * 存在循环依赖:{@code harnessAgent (bean) → historyMemoryTools → harnessMemoryCatalog → harnessAgent}。
 * 直接构造注入会在 Spring 启动时死锁。{@code ObjectProvider<HarnessAgent>} 注入的是「延迟解析器」,
 * 真正调 {@code getObject()} 时(HTTP 请求进来时)harnessAgent 早已建好,环就断了。
 * 不能用 {@code @Lazy},因为 {@link HarnessAgent} 没有可见构造器,CGLIB 代理不了。
 *
 * <p>写文件的是框架,不是这个类:
 * <ul>
 *   <li>{@code MemoryFlushManager} → {@code memory/YYYY-MM-DD.md}</li>
 *   <li>{@code MemoryConsolidator} → {@code MEMORY.md}</li>
 * </ul>
 */
@Slf4j
@Component
public class HarnessMemoryCatalog {

    private final ObjectProvider<HarnessAgent> harnessAgentProvider;
    private volatile WorkspaceManager workspaceManager;

    public HarnessMemoryCatalog(ObjectProvider<HarnessAgent> harnessAgentProvider) {
        this.harnessAgentProvider = harnessAgentProvider;
    }

    /** 首次调用时从 harnessAgent 拿 WorkspaceManager,之后缓存。 */
    private WorkspaceManager workspaceManager() {
        WorkspaceManager wm = workspaceManager;
        if (wm == null) {
            synchronized (this) {
                wm = workspaceManager;
                if (wm == null) {
                    wm = harnessAgentProvider.getObject().getWorkspaceManager();
                    workspaceManager = wm;
                    log.info("[HarnessMemory] 拿到官方 WorkspaceManager workspace={}", wm.getWorkspace());
                }
            }
        }
        return wm;
    }

    /** 读指定用户的 MEMORY.md。 */
    public String readMemoryMarkdown(String userId) {
        String uid = UserIds.normalize(userId);
        return workspaceManager().readMemoryMd(runtimeContext(uid));
    }

    /** 列指定用户的日流水文件。用官方 WorkspaceManager 列路径,再逐个读内容------Local/Remote 文件系统都兼容。 */
    public List<DailyMemoryFile> listDailyLedgers(String userId) {
        String uid = UserIds.normalize(userId);
        RuntimeContext ctx = runtimeContext(uid);
        List<String> relPaths = workspaceManager().listMemoryFilePaths(ctx);
        List<DailyMemoryFile> files = new ArrayList<>();
        for (String relPath : relPaths) {
            // listMemoryFilePaths 会把 MEMORY.md 也列进来,只要 memory/ 下的日流水
            if (relPath == null || relPath.isBlank()) {
                continue;
            }
            String p = relPath.startsWith("/") ? relPath.substring(1) : relPath;
            if (!p.startsWith("memory/") || !p.endsWith(".md")) {
                continue;
            }
            String name = p.substring("memory/".length());
            if (name.startsWith(".")) {
                continue;
            }
            String content = workspaceManager().readManagedWorkspaceFileUtf8(ctx, p);
            files.add(DailyMemoryFile.builder()
                    .path("memory/" + name)
                    .lastModified(Instant.now())
                    .content(content == null ? "" : content)
                    .build());
        }
        files.sort(Comparator.comparing(DailyMemoryFile::getPath, Comparator.reverseOrder()));
        return files;
    }

    /**
     * 把指定用户 MEMORY.md 里的 {@code - 条目} 拆成列表,给档案页用。
     * 分类只能从当前小节标题猜,猜不到就叫 {@code fact}。
     */
    public List<MemoryFact> parseFactsFromMemoryMd(String userId) {
        return parseFacts(readMemoryMarkdown(userId));
    }

    /** 纯函数,方便单测:不碰磁盘。 */
    static List<MemoryFact> parseFacts(String md) {
        List<MemoryFact> facts = new ArrayList<>();
        if (md == null || md.isBlank()) {
            return facts;
        }
        String category = "fact";
        for (String rawLine : md.split("\n")) {
            String line = rawLine.trim();
            if (line.startsWith("## ")) {
                category = guessCategory(line.substring(3));
                continue;
            }
            if (!line.startsWith("- ")) {
                continue;
            }
            String content = line.substring(2).trim();
            if (content.isBlank() || content.startsWith("(")) {
                continue;
            }
            facts.add(MemoryFact.builder()
                    .id(UUID.randomUUID().toString())
                    .category(category)
                    .content(content)
                    .confidence(1.0)
                    .build());
        }
        return facts;
    }

    private static String guessCategory(String heading) {
        String h = heading.toLowerCase(Locale.ROOT);
        if (h.contains("偏好") || h.contains("preference")) {
            return "preference";
        }
        if (h.contains("习惯") || h.contains("style")) {
            return "working_style";
        }
        if (h.contains("用户") || h.contains("identity") || h.contains("关于")) {
            return "identity";
        }
        if (h.contains("项目") || h.contains("project") || h.contains("技术")) {
            return "project";
        }
        if (h.contains("约束") || h.contains("constraint")) {
            return "constraint";
        }
        return "fact";
    }

    private static RuntimeContext runtimeContext(String userId) {
        return RuntimeContext.builder()
                .userId(userId)
                .build();
    }
}

src/main/java/cn/deepassistant/memory/SessionStore.java

作用: 网页侧栏那份聊天 JSON,按 userId 分目录

java 复制代码
package cn.deepassistant.memory;

/**
 * 会话层存储:每一次聊天的「全文档案」,按用户隔离。
 *
 * <h3>磁盘布局</h3>
 * <pre>
 *   data/sessions/
 *     ├── alice/
 *     │   ├── sessions-index.json
 *     │   ├── {sessionId-A}.json
 *     │   └── {sessionId-B}.json
 *     └── bob/
 *         ├── sessions-index.json
 *         └── {sessionId-C}.json
 * </pre>
 *
 * <p>每个用户一个子目录,互不可见。{@code userId} 会直接变成目录名,
 * 所以走 {@link UserIds#requireValid} 挡住路径穿越。
 *
 * <p>长期偏好不放这里,见工作区 {@code MEMORY.md}(官方 Consolidation 产物,
 * 也按用户隔离------见 {@link HarnessMemoryCatalog})。
 *
 * <p>本类还承担「历史检索」:把指定用户所有会话的消息扫一遍,找出包含关键词的句子。
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionStore {

    @Value("${agent.sessions.dir}")
    private String sessionsDir;

    private final ObjectMapper mapper;

    private Path rootDir;
    private final Map<String, SessionDetail> cache = new ConcurrentHashMap<>();
    private final Map<String, Object> fileLocks = new ConcurrentHashMap<>();

    /** cache / lock 的 key:userId + "/" + sessionId,避免不同用户撞车。 */
    private static String key(String userId, String sessionId) {
        return userId + "/" + sessionId;
    }

    /** 某个用户的会话根目录:data/sessions/{userId}/ */
    private Path userDir(String userId) {
        Path resolved = rootDir.resolve(userId).normalize();
        if (!resolved.startsWith(rootDir)) {
            throw new SecurityException("禁止访问 sessions 目录之外的路径: " + userId);
        }
        return resolved;
    }

    private Path sessionFile(String userId, String sessionId) {
        return userDir(userId).resolve(sessionId + ".json");
    }

    private Path indexPath(String userId) {
        return userDir(userId).resolve("sessions-index.json");
    }

    private Object lockFor(String userId, String sessionId) {
        return fileLocks.computeIfAbsent(key(userId, sessionId), k -> new Object());
    }

    @PostConstruct
    public void init() throws IOException {
        rootDir = Path.of(sessionsDir).toAbsolutePath().normalize();
        Files.createDirectories(rootDir);
        log.info("[Session] 会话根目录: {}", rootDir);
    }

    /** 确保用户子目录存在,返回该目录。 */
    private Path ensureUserDir(String userId) {
        Path dir = userDir(userId);
        try {
            Files.createDirectories(dir);
        } catch (IOException e) {
            throw new RuntimeException("创建用户会话目录失败: " + userId, e);
        }
        return dir;
    }

    /**
     * 列出某个用户的所有会话。别的用户的会话不会出现。
     */
    public List<SessionSummary> listSessions(String userId) {
        String uid = UserIds.normalize(userId);
        Path index = indexPath(uid);
        if (!Files.exists(index)) {
            return List.of();
        }
        synchronized (indexLockFor(uid)) {
            return readIndex(index).stream()
                    .sorted(Comparator.comparing(SessionSummary::getUpdatedAt, Comparator.nullsLast(Comparator.reverseOrder())))
                    .collect(Collectors.toList());
        }
    }

    public SessionDetail getOrCreate(String userId, String sessionId, String firstUserMessage) {
        String uid = UserIds.normalize(userId);
        String id = SessionIds.normalizeOrCreate(sessionId);
        synchronized (lockFor(uid, id)) {
            ensureUserDir(uid);
            SessionDetail detail = load(uid, id);
            if (detail == null) {
                Instant now = Instant.now();
                String title = buildTitle(firstUserMessage);
                detail = SessionDetail.builder()
                        .id(id)
                        .title(title)
                        .createdAt(now)
                        .updatedAt(now)
                        .messages(new ArrayList<>())
                        .build();
                cache.put(key(uid, id), detail);
                persist(uid, detail);
                upsertIndex(uid, SessionSummary.builder()
                        .id(id)
                        .title(title)
                        .preview(previewOf(firstUserMessage))
                        .updatedAt(now)
                        .messageCount(0)
                        .build());
            }
            return detail;
        }
    }

    public SessionDetail get(String userId, String sessionId) {
        String uid = UserIds.normalize(userId);
        String id = SessionIds.requireValid(sessionId);
        synchronized (lockFor(uid, id)) {
            return load(uid, id);
        }
    }

    public void appendMessage(String userId, String sessionId, ChatMessageRecord record) {
        String uid = UserIds.normalize(userId);
        String id = SessionIds.requireValid(sessionId);
        synchronized (lockFor(uid, id)) {
            SessionDetail detail = load(uid, id);
            if (detail == null) {
                detail = getOrCreate(uid, id, record.getContent());
            }
            detail.getMessages().add(record);
            detail.setUpdatedAt(Instant.now());
            if ("user".equals(record.getRole())
                    && detail.getMessages().stream().filter(m -> "user".equals(m.getRole())).count() == 1) {
                detail.setTitle(buildTitle(record.getContent()));
            }
            persist(uid, detail);
            upsertIndex(uid, SessionSummary.builder()
                    .id(detail.getId())
                    .title(detail.getTitle())
                    .preview(previewOf(record.getContent()))
                    .updatedAt(detail.getUpdatedAt())
                    .messageCount(detail.getMessages().size())
                    .build());
        }
    }

    /**
     * 跨会话关键词检索,只搜指定用户的会话。
     */
    public List<HistorySearchHit> search(String userId, String query, int limit) {
        String uid = UserIds.normalize(userId);
        String needle = query == null ? "" : query.trim();
        if (needle.isBlank()) {
            return List.of();
        }
        String lower = needle.toLowerCase();
        int cap = Math.min(Math.max(limit, 1), 50);
        List<HistorySearchHit> hits = new ArrayList<>();
        for (SessionSummary summary : listSessions(uid)) {
            SessionDetail detail = get(uid, summary.getId());
            if (detail == null || detail.getMessages() == null) {
                continue;
            }
            for (ChatMessageRecord message : detail.getMessages()) {
                String content = message.getContent();
                if (content == null) {
                    continue;
                }
                int matches = countMatches(content.toLowerCase(), lower);
                if (matches == 0) {
                    continue;
                }
                hits.add(HistorySearchHit.builder()
                        .sessionId(detail.getId())
                        .sessionTitle(detail.getTitle())
                        .role(message.getRole())
                        .timestamp(message.getTimestamp())
                        .snippet(snippetAround(content, needle))
                        .matchCount(matches)
                        .build());
            }
        }
        hits.sort(Comparator
                .comparingInt(HistorySearchHit::getMatchCount).reversed()
                .thenComparing(HistorySearchHit::getTimestamp, Comparator.nullsLast(Comparator.reverseOrder())));
        if (hits.size() > cap) {
            return new ArrayList<>(hits.subList(0, cap));
        }
        return hits;
    }

    /**
     * 从会话索引 + 消息条数现算使用概况,只统计指定用户。
     */
    public UsageStats usageStats(String userId) {
        String uid = UserIds.normalize(userId);
        List<SessionSummary> sessions = listSessions(uid);
        int messages = 0;
        int userMessages = 0;
        Instant first = null;
        Instant last = null;
        for (SessionSummary summary : sessions) {
            SessionDetail detail = get(uid, summary.getId());
            if (detail == null) {
                continue;
            }
            if (detail.getCreatedAt() != null && (first == null || detail.getCreatedAt().isBefore(first))) {
                first = detail.getCreatedAt();
            }
            if (detail.getUpdatedAt() != null && (last == null || detail.getUpdatedAt().isAfter(last))) {
                last = detail.getUpdatedAt();
            }
            if (detail.getMessages() == null) {
                continue;
            }
            messages += detail.getMessages().size();
            for (ChatMessageRecord message : detail.getMessages()) {
                if ("user".equals(message.getRole())) {
                    userMessages++;
                }
            }
        }
        List<SessionSummary> recent = sessions.stream().limit(8).collect(Collectors.toList());
        return UsageStats.builder()
                .sessionCount(sessions.size())
                .messageCount(messages)
                .userMessageCount(userMessages)
                .firstSeenAt(first)
                .lastSeenAt(last)
                .recentSessions(recent)
                .build();
    }

    public boolean delete(String userId, String sessionId) {
        String uid = UserIds.normalize(userId);
        String id = SessionIds.requireValid(sessionId);
        synchronized (lockFor(uid, id)) {
            cache.remove(key(uid, id));
            try {
                Files.deleteIfExists(sessionFile(uid, id));
            } catch (IOException e) {
                log.warn("删除会话文件失败: {}", e.getMessage());
            }
            boolean removed;
            synchronized (indexLockFor(uid)) {
                List<SessionSummary> index = readIndex(indexPath(uid));
                removed = index.removeIf(s -> Objects.equals(s.getId(), id));
                writeIndex(indexPath(uid), index);
            }
            fileLocks.remove(key(uid, id));
            return removed;
        }
    }

    // ---- 内部方法 ----

    private SessionDetail load(String userId, String sessionId) {
        String cacheKey = key(userId, sessionId);
        SessionDetail cached = cache.get(cacheKey);
        if (cached != null) {
            return cached;
        }
        Path file = sessionFile(userId, sessionId);
        if (!Files.exists(file)) {
            return null;
        }
        try {
            SessionDetail detail = mapper.readValue(file.toFile(), SessionDetail.class);
            cache.put(cacheKey, detail);
            return detail;
        } catch (IOException e) {
            log.warn("读取会话失败 {}/{}: {}", userId, sessionId, e.getMessage());
            return null;
        }
    }

    private void persist(String userId, SessionDetail detail) {
        try {
            mapper.writerWithDefaultPrettyPrinter()
                    .writeValue(sessionFile(userId, detail.getId()).toFile(), detail);
        } catch (IOException e) {
            log.warn("写入会话失败: {}", e.getMessage());
        }
    }

    private List<SessionSummary> readIndex(Path index) {
        try {
            return mapper.readValue(index.toFile(), new TypeReference<>() {
            });
        } catch (IOException e) {
            return new ArrayList<>();
        }
    }

    private void writeIndex(Path index, List<SessionSummary> data) {
        try {
            mapper.writerWithDefaultPrettyPrinter().writeValue(index.toFile(), data);
        } catch (IOException e) {
            log.warn("写入会话索引失败: {}", e.getMessage());
        }
    }

    private void upsertIndex(String userId, SessionSummary summary) {
        Path index = indexPath(userId);
        ensureUserDir(userId);
        synchronized (indexLockFor(userId)) {
            List<SessionSummary> data = readIndex(index);
            data.removeIf(s -> Objects.equals(s.getId(), summary.getId()));
            data.add(summary);
            writeIndex(index, data);
        }
    }

    /** 每个用户一把索引锁,避免不同用户互相阻塞。 */
    private final Map<String, Object> indexLocks = new ConcurrentHashMap<>();

    private Object indexLockFor(String userId) {
        return indexLocks.computeIfAbsent(userId, k -> new Object());
    }

    private static String buildTitle(String message) {
        if (message == null || message.isBlank()) {
            return "新对话";
        }
        String t = message.replace('\n', ' ').trim();
        return t.length() > 24 ? t.substring(0, 24) + "..." : t;
    }

    private static String previewOf(String content) {
        if (content == null) {
            return "";
        }
        String t = content.replace('\n', ' ').trim();
        return t.length() > 60 ? t.substring(0, 60) + "..." : t;
    }

    /** 统计 needle 在 haystack 里出现几次。haystack 调用方已转成小写。 */
    static int countMatches(String haystackLower, String needleLower) {
        if (haystackLower == null || needleLower == null || needleLower.isEmpty()) {
            return 0;
        }
        int count = 0;
        int from = 0;
        while (true) {
            int at = haystackLower.indexOf(needleLower, from);
            if (at < 0) {
                return count;
            }
            count++;
            from = at + needleLower.length();
        }
    }

    /** 截一段含关键词的上下文,避免把整段长回复塞进搜索结果。 */
    static String snippetAround(String content, String needle) {
        String flat = content.replace('\n', ' ').trim();
        int at = flat.toLowerCase().indexOf(needle.toLowerCase());
        if (at < 0) {
            return flat.length() > 120 ? flat.substring(0, 120) + "..." : flat;
        }
        int start = Math.max(0, at - 40);
        int end = Math.min(flat.length(), at + needle.length() + 80);
        String snippet = flat.substring(start, end);
        if (start > 0) {
            snippet = "..." + snippet;
        }
        if (end < flat.length()) {
            snippet = snippet + "...";
        }
        return snippet;
    }
}

src/main/java/cn/deepassistant/service/UserMemoryQueryService.java

作用: 给前端档案页:usage + MEMORY.md + 日流水

java 复制代码
package cn.deepassistant.service;

/**
 * HTTP 门面:历史检索走 SessionStore,档案页读官方 MEMORY.md / 日流水。
 * 所有方法都按 userId 隔离,不同用户互不可见。
 */
@Service
public class UserMemoryQueryService {

    private final SessionStore sessionStore;
    private final HarnessMemoryCatalog memoryCatalog;

    public UserMemoryQueryService(SessionStore sessionStore, HarnessMemoryCatalog memoryCatalog) {
        this.sessionStore = sessionStore;
        this.memoryCatalog = memoryCatalog;
    }

    public HistorySearchResponse searchHistory(String userId, String query, int limit) {
        String uid = UserIds.normalize(userId);
        String q = query == null ? "" : query.trim();
        List<HistorySearchHit> hits = sessionStore.search(uid, q, limit);
        return HistorySearchResponse.builder()
                .query(q)
                .hits(hits)
                .hitCount(hits.size())
                .build();
    }

    public UserMemoryProfile profile(String userId) {
        String uid = UserIds.normalize(userId);
        return UserMemoryProfile.builder()
                .usage(sessionStore.usageStats(uid))
                .memoryMarkdown(memoryCatalog.readMemoryMarkdown(uid))
                .dailyLedgers(memoryCatalog.listDailyLedgers(uid))
                .facts(memoryCatalog.parseFactsFromMemoryMd(uid))
                .build();
    }
}

src/main/java/cn/deepassistant/util/UserIds.java

作用: userId 校验;空白回退 local;挡住路径穿越

java 复制代码
package cn.deepassistant.util;

/**
 * userId 校验,和 {@link SessionIds} 用同一套安全规则:
 * 只允许 {@code [a-zA-Z0-9][a-zA-Z0-9_-]{0,127}},禁止路径穿越。
 *
 * <p>多用户隔离靠 (userId, sessionId) 二元组寻址,userId 会直接变成磁盘目录名,
 * 所以必须挡住 {@code ..} / {@code /} / {@code \\} / {@code \0}。
 */
public final class UserIds {

    private static final Pattern SAFE =
            Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$");

    /** 单机个人助手没传 userId 时的默认值,保持向后兼容。 */
    public static final String DEFAULT_USER_ID = "local";

    private UserIds() {
    }

    /**
     * 校验 userId,空白时回退到 {@link #DEFAULT_USER_ID}。
     * 这样前端不传 userId 也能跑,等价于原来的单用户模式。
     */
    public static String normalize(String userId) {
        if (userId == null || userId.isBlank()) {
            return DEFAULT_USER_ID;
        }
        return requireValid(userId);
    }

    public static String requireValid(String userId) {
        if (userId == null || userId.isBlank()) {
            throw new IllegalArgumentException("userId 为空");
        }
        String id = userId.trim();
        if (id.contains("..") || id.contains("/") || id.contains("\\") || id.indexOf('\0') >= 0) {
            throw new IllegalArgumentException("非法 userId");
        }
        if (!SAFE.matcher(id).matches()) {
            throw new IllegalArgumentException("非法 userId 格式");
        }
        return id;
    }
}

src/main/java/cn/deepassistant/util/SessionIds.java

作用: sessionId 校验 / 空白则新建 UUID

java 复制代码
package cn.deepassistant.util;

public final class SessionIds {

    private static final Pattern SAFE =
            Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$");

    private SessionIds() {
    }

    public static String newId() {
        return UUID.randomUUID().toString();
    }

    public static String requireValid(String sessionId) {
        if (sessionId == null || sessionId.isBlank()) {
            throw new IllegalArgumentException("sessionId 为空");
        }
        String id = sessionId.trim();
        if (id.contains("..") || id.contains("/") || id.contains("\\") || id.indexOf('\0') >= 0) {
            throw new IllegalArgumentException("非法 sessionId");
        }
        if (!SAFE.matcher(id).matches()) {
            throw new IllegalArgumentException("非法 sessionId 格式");
        }
        return id;
    }

    public static String normalizeOrCreate(String sessionId) {
        if (sessionId == null || sessionId.isBlank()) {
            return newId();
        }
        return requireValid(sessionId);
    }
}

下篇: 小深-AgentScope-Java-2.0-Harness架构博客-02.md --- 工具、MCP/REST 联网、对话 SSE、前端与踩坑笔记。

相关推荐
captain3761 小时前
网络编程(1)
java·网络·ide·java-ee
yume_sibai1 小时前
正则表达式完全指南(基础语法 + 实战应用 + 性能优化 + 最佳实践)
开发语言·正则表达式·c#
AbandonForce1 小时前
简谈线程池
开发语言·c++·算法
李少兄1 小时前
JavaScript 运算符完全指南
java·开发语言·javascript
广州灵眸科技有限公司1 小时前
瑞芯微(EASY EAI)RV1126B display
开发语言·数据库·人工智能·科技·嵌入式硬件
极客互动API1 小时前
企业微信 AI 智能客服实战:Spring Boot+Vue 接入豆包、扣子、DeepSeek
java·人工智能·微信·企业微信
今天AI了吗1 小时前
Codex使用技巧:深度解析 Plan Mode 与 Goal Mode
java·网络·人工智能·架构·java-ee
Huangxy__1 小时前
java+ai 全栈项目
java·开发语言
Nil2081 小时前
leetcode 79单词搜索
开发语言·c#