前言

AgentScope Java 的 Harness 模块是一个面向生产环境的多智能体运行时框架。本文基于官方文档,从其设计理念到具体代码实践,完整梳理 Harness 的核心架构与使用方式。

一、Harness 是什么

agentscope-harnessagentscope-coreReActAgent 之上,通过 HookToolkit 两个扩展点,装配出一套面向长期稳定运行的工程化基础设施。它的入口只有一个类:HarnessAgent

裸的 ReActAgent 只有"请求 — 推理 — 工具 — 回复"一轮循环。而 Harness 要回答的是一组更贴近生产的问题:

  • 下一轮怎么办?
  • 下一天怎么办?
  • 上下文爆了怎么办?
  • 状态丢了怎么办?
  • 任务太重怎么办?

它不替换推理循环,而是在循环的关键时机插入 hook、为模型补上一组基础工具,把这些问题的默认工程答案打包好。

快速开始

<dependency>
    <groupId>io.agentscope</groupId>
    <artifactId>agentscope-harness</artifactId>
    <version>${agentscope.version}</version>
</dependency>
public class QuickstartExample {
    public static void main(String[] args) throws Exception {
        // 1. 准备工作区
        Path workspace = Paths.get(".agentscope/workspace");
        initWorkspaceIfAbsent(workspace);

        // 2. 构建模型
        Model model = DashScopeChatModel.builder()
            .apiKey(System.getenv("DASHSCOPE_API_KEY"))
            .modelName("qwen-max")
            .stream(true)
            .build();

        // 3. 构建 HarnessAgent
        HarnessAgent agent = HarnessAgent.builder()
            .name("quickstart-agent")
            .sysPrompt("你是一个帮助用户做笔记的助手。")
            .model(model)
            .workspace(workspace)
            .compaction(CompactionConfig.builder()
                .triggerMessages(30)
                .keepMessages(10)
                .flushBeforeCompact(true)
                .build())
            .build();

        // 4. 两轮对话,同一 sessionId
        RuntimeContext ctx = RuntimeContext.builder()
            .sessionId("demo-session")
            .userId("alice")
            .build();

        Msg turn1 = agent.call(
            Msg.builder().role(MsgRole.USER)
                .textContent("我叫天宇,今天准备做一个关于 ReAct 的技术分享。")
                .build(),
            ctx).block();

        Msg turn2 = agent.call(
            Msg.builder().role(MsgRole.USER)
                .textContent("我叫什么?我今天要干什么?")
                .build(),
            ctx).block();
    }

    private static void initWorkspaceIfAbsent(Path workspace) throws Exception {
        Files.createDirectories(workspace);
        Path agentsMd = workspace.resolve("AGENTS.md");
        if (Files.exists(agentsMd)) return;
        Files.writeString(agentsMd, """
            # 笔记助手
            你是一个帮助用户整理笔记和知识的助手。
            ## 行为约定
            - 主动记录用户提到的关键事实(姓名、计划、偏好等)
            - 回答用简洁中文,必要时给出要点列表
            - 对不确定的内容要主动说明,不要臆造
            """);
    }
}

上述代码展示了 Harness 的三个核心价值:工作区驱动的人格会话持久化(同一 sessionId 的第二轮对话记得第一轮的内容)、显式启用对话压缩


二、架构设计

2.1 设计理念(三个核心决策)

决策一:薄包装,不替换推理循环

HarnessAgent 不是一个新的推理引擎,它只是 ReActAgent 的薄包装,自身只做两件额外的事:

  • bindRuntimeContext(ctx) — 每次 call() 开头,把 sessionId/userId 分发给关心它的 hook,并按需从 Session 恢复 Memory 状态
  • forceCompactAndRetry — 当模型返回 ContextOverflow 错误时,强制压缩并重试一次

其余所有能力——工作区注入、记忆管理、会话持久化、子 agent 编排——全部通过 ReActAgent 已有的 HookToolkit 扩展点注入。ReActAgent 的能力完整保留,harness 只叠加,不替换。

决策二:Hook 驱动,能力正交

每个 hook 只做一件事,通过 priority 在同一个事件上排好执行顺序。比如在 PreReasoningEvent 上:

  • CompactionHook(10) — 先检查是否需要压缩
  • SubagentsHook(80) — 注入子 agent 列表
  • WorkspaceContextHook(900) — 最后叠加工作区文件

Hook 之间不持有彼此的引用,只通过三个共享对象通信。每项能力都能独立开关。

决策三:共享对象是唯一耦合点

对象职责生命周期
RuntimeContext当次 call() 的身份:sessionId、userId、session 引用、extra 数据每次 call() 重新注入,不持久化
WorkspaceManager工作区无状态访问器:两层读(filesystem 优先 → 本地兜底)、写走 filesystem构建时创建,跨 call 复用
AbstractFilesystem存储后端:本地磁盘 / 沙箱 / KV Store构建时创建,跨 call 复用

2.2 顶层架构

调用方 → HarnessAgent(薄包装层)→ ReActAgent(推理内核)→ 共享对象层
  • 薄包装层:负责 per-call 身份绑定 + ContextOverflow 兜底
  • 推理内核:Hook 事件驱动 + ReAct 循环(reason → act → observe)+ 工具执行
  • 共享对象层:三个对象是所有 Hook 的协作底座

2.3 构建阶段

HarnessAgent agent = HarnessAgent.builder()
    .name("agent")
    .model(model)
    .workspace(workspace)
    .compaction(CompactionConfig.builder()
        .triggerMessages(50)
        .triggerTokens(80_000)
        .flushBeforeCompact(true)
        .build())
    .toolResultEviction(ToolResultEvictionConfig.defaults())
    .build();

Builder.build() 时一次性装配:

  1. 创建三个共享对象(WorkspaceManager、AbstractFilesystem、RuntimeContext ref)
  2. 按 priority 串好 Hook 链
  3. 追加内置工具到 Toolkit
  4. workspace/skills/ 装配 SkillBox
  5. 构造 ReActAgent delegate
  6. 启动后台 MemoryMaintenanceScheduler(守护线程,6h 周期)

三、Hook 事件管道

3.1 完整的事件矩阵

事件触发时机触发的 Hook(priority 升序)
PreCallEvent推理循环启动前AgentTraceHook(0)
PreReasoningEvent调用模型前Trace(0) → CompactionHook(10) → SubagentsHook(80) → WorkspaceContextHook(900)
PostReasoningEvent每次模型返回后Trace(0)
PreActingEvent每个工具调用前Trace(0)
PostActingEvent每个工具调用后EvictionHook(50)
PostCallEvent最终回复产出后Trace(0) → MemoryFlushHook(5) → MemoryMaintenanceHook(6) → SessionPersistenceHook(900)
ErrorEvent推理出现异常时SessionPersistenceHook(900)

3.2 call() 生命周期时序

① bindRuntimeContext(ctx) → 分发 ctx、恢复 Memory
② delegate.call(msg) → ReAct 循环
   ├── PreCallEvent → Trace
   ├── PreReasoningEvent → Compact → Subagents → WorkspaceCtx
   ├── stream(model)
   ├── PostReasoningEvent → Trace
   ├── [loop] PreActingEvent → invoke(tool) → PostActingEvent
   └── PostCallEvent → Trace → MemFlush → MemMaint → SessionPersistence
③ 失败路径: ErrorEvent → Session saveTo
④ ContextOverflow: forceCompactAndRetry → 重试

四、工作区(Workspace)

工作区是 HarnessAgent 的"地基":人格、长期记忆、领域知识、子 agent 声明、会话历史、技能定义统一以目录结构 + Markdown 的形式落地,不再散落在代码里。

4.1 触发时机

时机动作
HarnessAgent.build()WorkspaceManager.validate() 检查目录和 AGENTS.md 是否存在(缺失只 warn)
每次 call() 推理前WorkspaceContextHook 读 MEMORY.md、knowledge/ 等注入 system prompt
压缩 / 调用结束MemoryFlushHookSessionPersistenceHook 等写回 memory/、agents/…/sessions/

4.2 目录结构

workspace/                          ← 默认 .agentscope/workspace
├── AGENTS.md                       ← 人格 / 行为约定(每次注入全文)
├── MEMORY.md                       ← 整理过的长期记忆(每次注入,受 token 预算)
├── knowledge/
│   ├── KNOWLEDGE.md                ← 领域知识入口
│   └── *                           ← 其他参考文件,按需 read_file 打开
├── memory/
│   ├── YYYY-MM-DD.md               ← 每日记忆流水账(追加,由 MemoryFlushManager 写入)
│   └── .consolidation_state         ← MemoryConsolidator 内部状态
├── skills/<skill-name>/SKILL.md    ← 自定义技能
├── subagents/<id>.md               ← 子 agent 声明(文件名=agent_id,自动发现)
└── agents/<agentId>/
    ├── workspace/                  ← isolated 子 agent 的运行时根
    └── sessions/
        ├── sessions.json           ← 会话索引(id / summary / updatedAt)
        ├── <sessionId>.jsonl       ← LLM 可见的压缩上下文
        └── <sessionId>.log.jsonl   ← 完整对话日志(追加)

4.3 两层读写机制

WorkspaceManager 是无状态访问器,所有读写遵循同一规约:

读路径:AbstractFilesystem 优先 → 本地磁盘兜底
        让多租户场景对调用方透明

写路径:默认全部走 AbstractFilesystem
        未配置时 fallback 本地磁盘

List 操作(listKnowledgeFiles / listMemoryFilePaths / listSessionLogFiles)
        取两层并集去重,避免漏文件

4.4 System Prompt 注入内容

WorkspaceContextHook(priority 900)在 PreReasoningEvent 拼装一段固定结构的文本,合并到第一条 SYSTEM 消息:

段落来源Token 预算
## Context模板生成(日期、OS、workspace 路径、sessionId)不限
<loaded_context> XML 块内置模板
<agents_context>AGENTS.md 全文
<memory_context>MEMORY.mdmaxContextTokens 限制
<domain_knowledge_context>knowledge/KNOWLEDGE.md + listKnowledgeFiles() 路径目录全文 + 路径目录
<{rel_path}>每个 additionalContextFile默认 8000 chars

超出预算时按字符截断,并附尾注提示 agent 改走 memory_search

4.5 关键 API

WorkspaceManager wm = new WorkspaceManager(workspace, abstractFilesystem);

wm.readAgentsMd();                                   // 两层读
wm.readMemoryMd();
wm.readKnowledgeMd();                                // 读 knowledge/KNOWLEDGE.md
wm.readManagedWorkspaceFileUtf8(rel);                // 任意工作区相对路径(path traversal 校验)
wm.listKnowledgeFiles();                             // 两层并集
wm.listMemoryFilePaths();
wm.listSessionLogFiles();
wm.appendUtf8WorkspaceRelative(rel, content);        // 走 AbstractFilesystem
wm.updateSessionIndex(agentId, sessionId, summary);  // 维护 sessions.json
HarnessAgent agent = HarnessAgent.builder()
    .name("MyAgent")
    .model(model)
    .workspace(Paths.get(".agentscope/workspace"))  // 不传则用默认
    .additionalContextFile("SOUL.md")                // 任意工作区相对路径
    .additionalContextFile("PREFERENCES.md")
    .maxContextTokens(8000)                          // 控制 MEMORY 的注入上限
    .build();

AGENTS.md 缺失时 agent 仍可工作,但会丢失 persona 段。


五、记忆系统(Memory)

记忆要解决两个问题:跨会话记住事实(今天说"我叫天宇",明天重启进程还能记得)+ 上下文不无限增长(聊 100 轮不能全塞给模型)。

5.1 双层记忆模型

对话 messages(InMemoryMemory)
  │  超阈值(50条/80K tokens)
  ▼
ConversationCompactor
  ├── offload → sessions/<id>.log.jsonl(原始对话存档)
  └── flushMemories → MemoryFlushManager
       └── append + FTS5 index → memory/YYYY-MM-DD.md(每日流水账)
                                   ↓ 后台二次加工
                                 MemoryConsolidator
                                   ├── 读 MEMORY.md 去重
                                   └── 重写 MEMORY.md(每次推理注入)
层次文件特点
第一层·流水账memory/YYYY-MM-DD.md只追加、不去重,LLM 每次提取新事实写入
第二层·策划后长期记忆MEMORY.mdMemoryConsolidator 后台整体重写,每次注入 system prompt

第一层不直接入 system prompt(太原始),第二层才是每次推理喂给模型的"长期记忆"。

5.2 对话压缩流程

① 检查阈值(triggerMessages / triggerTokens)
② 找 cutoff 点(不切开 ASSISTANT/TOOL 对)
③ flushMemories(prefix) → LLM 提炼新事实 → 追加到今日流水账
④ offloadMessages → 原始消息追写到 sessions/<id>.log.jsonl
⑤ LLM distill summary → [summaryUserMsg] + tail 重装回 Memory

完整配置:

HarnessAgent agent = HarnessAgent.builder()
    .name("MyAgent")
    .model(model)
    .workspace(workspace)
    .compaction(CompactionConfig.builder()
        .triggerMessages(50)          // 按条数触发(0 = 关闭)
        .triggerTokens(80_000)        // 按 token 估算触发
        .keepMessages(20)             // 保留尾部条数
        .keepTokens(0)                // 非 0 时按 token 预算从后往前扫描
        .flushBeforeCompact(true)     // 压缩前提取记忆到今日流水账
        .offloadBeforeCompact(true)   // 压缩前将原始消息追写到 .log.jsonl
        .summaryPrompt(...)           // 四段式内置模板
        .truncateArgs(TruncateArgsConfig.builder()  // 轻量预处理(不走LLM)
            .triggerMessages(25)
            .triggerTokens(40_000)
            .maxParamChars(2000)      // write_file 等大入参截断
            .build())
        .build())
    .toolResultEviction(ToolResultEvictionConfig.defaults())  // 工具结果卸载
    .build();

5.3 记忆提取(flushMemories)

拿当前对话和今日流水账一起丢给 LLM,要求输出"仅新增的 bullet"。没有新东西则返回 NO_REPLY。写入位置固定是当天文件,不会动 MEMORY.md(防止二层被一层覆写)。

写完后立刻增量重建 FTS5 索引,并触发后台 consolidation 请求(30 分钟节流)。

5.4 二次合并(MemoryConsolidator)

后台线程读 mtime 超 watermark 的日流水账 + 当前 MEMORY.md,调 LLM 合并、去重、裁剪。默认输出限制 maxMemoryTokens=4000(约 16k 字符)。写后推进 watermark 存于 memory/.consolidation_state永不阻塞推理循环。

5.5 上下文溢出自动恢复

模型返回 context_length_exceeded 时,HarnessAgent 自动捕获、强制压缩、清空 Memory 后重试。前提是配置了 compaction,否则直接抛错。

5.6 后台维护

MemoryMaintenanceScheduler 默认每 6h 运行一轮:

任务行为默认值
expireDailyFiles超期日文件归档到 memory/archive/90 天
consolidateMemory合并去重到 MEMORY.md
pruneOldSessions删除超期会话文件180 天
reindex重建 FTS5 全文索引6h

5.7 memory_search 检索

MemoryIndex index = new MemoryIndex(workspaceDir);
index.open();
List<SearchHit> hits = index.search("数据库迁移");
// hit: { path, lineNumber, content, rank }

底层 SQLite FTS5,索引文件位于 <workspace_parent>/memory_index.db

5.8 与会话的区别

会话(Session)记忆(Memory)
存什么Memory 快照 + 原始对话 JSONLLLM 提炼的事实 bullet
跨调用恢复下一轮自动 loadIfExists通过 WorkspaceContextHook 注入 system prompt
检索session_search 翻原始日志memory_search 走 FTS5 索引
生命周期随会话过期清理(180天)持续积累到 MEMORY.md

六、文件系统(Filesystem)

AbstractFilesystem 把 agent 对文件和命令的访问从"一定是本机磁盘"抽象成统一接口。三种声明式模式可选。

6.1 三种模式一览

模式配置方法典型产物执行 Shell适用场景
1 — 复合+共享存储filesystem(RemoteFilesystemSpec)CompositeFilesystem(本地根 + 按前缀路由到 KV)无(设计上不开放 shell)多副本共享记忆、会话落盘到 Redis/OSS,不执行命令
2 — 沙箱filesystem(SandboxFilesystemSpec)SandboxBackedFilesystem有(在沙箱内执行)隔离执行、可恢复沙箱会话、快照
3 — 本机+shellfilesystem(LocalFilesystemSpec) / 默认LocalFilesystemWithShell有(直接在宿主执行)单进程测试、信任环境

6.2 三种模式详解

模式一:复合 + 共享存储(RemoteFilesystemSpec

构造出来的是一个 CompositeFilesystem,由两个部分组成:

  • 默认后端:纯 LocalFilesystem(工作区根目录,无 shell
  • 前缀路由后端RemoteFilesystem(KV Store,如 Redis/OSS)

读写文件时按最长前缀匹配决定路由到哪个后端:

// 等价构造逻辑
CompositeFilesystem(
    defaultBackend = LocalFilesystem(workspace/),     // 未匹配的路径走本地
    prefixBackends = {
        "agents/<agentId>/sessions/"  RemoteFilesystem(Redis),
        "memory/"                     RemoteFilesystem(Redis),
        // 还可 addSharedPrefix("knowledge/")
    }
)

路由示例:

操作路径匹配前缀路由到
AGENTS.md无匹配(默认)本地 LocalFilesystem
MEMORY.md无匹配(默认)本地 LocalFilesystem
memory/2026-05-20.mdmemory/RemoteFilesystem (Redis)
agents/myBot/sessions/demo.jsonlagents/...RemoteFilesystem (Redis)

为什么要这样设计:工作区根目录在本地磁盘,但记忆、会话等关键数据路由到共享 KV 存储。这样多副本部署时 MEMORY.md、memory/、sessions/ 在所有副本间一致,同时普通文件读写走本地不依赖网络。

HarnessAgent agent = HarnessAgent.builder()
    .name("store-agent")
    .model(model)
    .filesystem(RemoteFilesystemSpec.builder()
        .store(new RedisStore("redis-host", 6379))
        .isolationScope(IsolationScope.USER)  // user 作为 KV 命名空间前缀
        .build())
    .build();

模式一不提供 shell,因为默认后端是纯 LocalFilesystem(没有 execute 方法)。

模式二:沙箱(SandboxFilesystemSpec

对外仍是 AbstractFilesystem,但真实 IO 和进程运行在 SandboxClient 管理的隔离环境中。SandboxLifecycleHook 在每次 call 周围执行 acquire / persist / release。

HarnessAgent agent = HarnessAgent.builder()
    .name("sandbox-agent")
    .model(model)
    .filesystem(DockerFilesystemSpec.builder()
        .image("ubuntu:24.04")
        .snapshotSpec(OssSnapshotSpec.builder().bucket("my-bucket").build())
        .isolationScope(IsolationScope.SESSION)
        .build())
    .build();

详见前文的沙箱章节。

模式三:本机 + shell(LocalFilesystemSpec / 默认)

根目录为工作区,命令通过 ProcessBuilder 在宿主机执行。不配任何 filesystem(...) 时默认就是此模式。

// 默认等价,仅用于调整超时等参数
HarnessAgent agent = HarnessAgent.builder()
    .name("local-agent")
    .model(model)
    .filesystem(LocalFilesystemSpec.builder()
        .executeTimeoutSeconds(120)
        .build())
    .build();

逃生口

不想用三种模式之一,可以自己实现一整套 AbstractFilesystem:

HarnessAgent agent = HarnessAgent.builder()
    .name("custom-agent")
    .model(model)
    .abstractFilesystem(myCustomTree)
    .build();

6.3 类层次

AbstractFilesystem(接口)
  read / write / edit / grep / glob / upload / download
  │
  ├── LocalFilesystem                   仅本机文件,无执行(rootDir 防穿越)
  │    └── LocalFilesystemWithShell     本机 + 宿主 shell(模式三的核心)
  │
  ├── RemoteFilesystem                  KV 存储,无 shell
  │
  ├── CompositeFilesystem               最长前缀匹配多后端(模式一的核心)
  │
  └── AbstractSandboxFilesystem         增加 execute() 方法
       ├── BaseSandboxFilesystem        对接远程 Unix,多数方法用 execute 拼命令
       └── SandboxBackedFilesystem      SandboxManager 代理(模式二的核心)

read(filePath, offset, limit)limit=0 表示使用实现定义的"读默认行数"(本地与沙箱可能不同)。

6.4 多租户:NamespaceFactory

@FunctionalInterface
public interface NamespaceFactory {
    List<String> getNamespace(RuntimeContext ctx);
}

每次文件操作会调用,返回当前请求的路径段。例如 userId=alice 返回 ["users", "alice"],同一份 RemoteFilesystem 实例在不同用户下自动落在不同的存储子树:

Redis 里键的分布:
  users/alice/MEMORY.md
  users/alice/memory/2026-05-20.md
  users/bob/MEMORY.md
  users/bob/memory/2026-05-20.md

RemoteFilesystemSpec 上设置 isolationScope(SESSION/USER/AGENT/GLOBAL)即可控制命名空间粒度,无需额外代码。


6.5 各 Filesystem 实现速查

实现能力说明
LocalFilesystem仅本机文件,无执行rootDir 防穿越
LocalFilesystemWithShell本机文件 + 宿主 shell模式三核心,ProcessBuilder
RemoteFilesystem无 shell 的 KV 存储配合 Redis/OSS
CompositeFilesystem最长前缀匹配,组合多后端模式一核心
BaseSandboxFilesystem对接远程 Unix多数方法用 execute 拼命令实现
SandboxBackedFilesystemSandboxManager 代理模式二核心

注意:CompositeFilesystem 只实现 AbstractFilesystem,不实现 AbstractSandboxFilesystem,所以本身无 shell。若需组合路由又要 shell,需用 abstractFilesystem 逃生口自行提供含 shell 的后端,或选用模式二/三。


七、沙箱(Sandbox)— 隔离执行

7.1 隔离维度

范围持久化键来源典型场景
SESSION(默认)sessionKey.toIdentifier()每个会话独立的沙箱/记忆
USERRuntimeContext.userId同用户跨会话共享工作区
AGENTagent 名称(构建时固定)单 agent 全局共享
GLOBAL__global__全局共享
// SESSION 隔离:默认,每条对话独立沙箱
HarnessAgent agent = HarnessAgent.builder()
    .name("code-agent")
    .model(model)
    .filesystem(DockerFilesystemSpec.builder()
        .image("ubuntu:24.04")
        .snapshotSpec(OssSnapshotSpec.builder().build())
        .isolationScope(IsolationScope.SESSION)  // 默认,可省略
        .build())
    .build();

// 不同 sessionId → 独立沙箱
agent.call(msg, RuntimeContext.builder()
    .sessionId("session-1").userId("alice").build()).block();
agent.call(msg, RuntimeContext.builder()
    .sessionId("session-2").userId("alice").build()).block();  // 不同沙箱

// USER 隔离:同一用户跨 Pod 共享快照(多副本推荐)
HarnessAgent agent = HarnessAgent.builder()
    .name("assistant")
    .model(model)
    .filesystem(DockerFilesystemSpec.builder()
        .image("ubuntu:24.04")
        .snapshotSpec(OssSnapshotSpec.builder().build())
        .isolationScope(IsolationScope.USER)
        .build())
    .sandboxDistributed(SandboxDistributedOptions.builder()
        .redisSession(new RedisSession("redis-host", 6379))
        .ossSnapshotSpec(OssSnapshotSpec.builder().build())
        .build())
    .build();

// 同一 userId → 从同一快照恢复
agent.call(msg, RuntimeContext.builder()
    .sessionId("session-xyz").userId("alice").build()).block();

7.2 快照与 4-分支恢复

Sandbox.start() 通过 4 个分支决定如何初始化工作区:

  • Branch A: workspaceRootReady=true & 容器内目录仍存在 → 只重新应用 ephemeral 条目(热启动)
  • Branch B: workspaceRootReady=true & 容器内目录丢失 → 从快照还原 + 重新应用 ephemeral
  • Branch C: workspaceRootReady=false & 快照可用 → 从快照还原 + 全部重新应用
  • Branch D: workspaceRootReady=false & 无快照 → 全量初始化(冷启动)

7.3 并发控制

多副本下同一用户的沙箱状态需要保护:

RedisSandboxExecutionGuard guard = new RedisSandboxExecutionGuard(
    new JedisPooled("redis-host", 6379),
    Duration.ofMinutes(5),     // TTL,须大于最坏情况 call 耗时
    Duration.ofMillis(500),    // 轮询间隔
    "myapp:sandbox:lock:"     // key 前缀
);

HarnessAgent agent = HarnessAgent.builder()
    .name("shared-agent")
    .model(model)
    .filesystem(DockerFilesystemSpec.builder()
        .image("ubuntu:24.04")
        .snapshotSpec(OssSnapshotSpec.builder().build())
        .isolationScope(IsolationScope.AGENT)
        .sandboxExecutionGuard(guard)
        .executionGuardRequired(true)
        .build())
    .build();

7.4 自定义沙箱实现

// 1. 实现 SandboxClient 和 SandboxFilesystemSpec
public class MySandboxFilesystemSpec extends SandboxFilesystemSpec {
    @Override public SandboxClient<?> createClient() { return new MySandboxClient(); }
    @Override public SandboxClientOptions clientOptions() { return new MySandboxClientOptions(); }
    @Override public SnapshotSpec snapshotSpec() { return NoopSnapshotSpec.INSTANCE; }
    @Override public WorkspaceSpec workspaceSpec() { return new WorkspaceSpec(); }
}

// 2. 启用
HarnessAgent agent = HarnessAgent.builder()
    .name("my-agent")
    .model(model)
    .filesystem(new MySandboxFilesystemSpec())
    .build();

7.5 工作区投影

沙箱模式下,宿主工作区里的特定目录在每次启动时自动同步进沙箱:

宿主机 workspace/             沙箱内 /workspace/
  ├── AGENTS.md      ──投影──→  ├── AGENTS.md
  ├── skills/        ──投影──→  ├── skills/
  ├── subagents/     ──投影──→  ├── subagents/
  └── knowledge/     ──投影──→  └── knowledge/

宿主机器上文件更新后,下次沙箱启动时哈希变化,新版文件自动同步进去。Skill 脚本在沙箱内执行,pip installrm -rf 等操作只影响沙箱,宿主机无感。


八、会话(Session)

会话让 agent 能在跨请求、跨进程、多用户场景下恢复状态。一次 call() 结束后自动落盘两路产出。

8.1 双轨存储布局

workspace/agents/<agentId>/
├── context/                          ← WorkspaceSession 负责
│   └── <sessionId>/
│       ├── memory.json               ← ReActAgent.memory 快照
│       └── *.json                    ← 其他 StateModule 序列化产物
└── sessions/                         ← SessionTree + WorkspaceManager 负责
    ├── sessions.json                 ← 会话索引(sessionId / summary / updatedAt)
    ├── <sessionId>.jsonl             ← LLM 可见的压缩上下文
    └── <sessionId>.log.jsonl         ← 完整对话日志(append-only,不被压缩)

两条路径分工明确:

路径存储对象写入时机用途
WorkspaceSession(context/)StateModule 快照(memory.json)PostCallEvent / ErrorEvent下一轮 call() 通过 loadIfExists 恢复 Memory
SessionTree(sessions/)对话 JSONL压缩/offload 时追写.jsonl 供 LLM 读取,.log.jsonl 供审计和 session_search 检索

8.2 生命周期

第一轮 call(msg, ctx{sessionId="sess-A"})
  ├── bindRuntimeContext → loadIfExists → Memory 为空(首次)
  ├── ReAct 循环
  └── PostCallEvent → SessionPersistenceHook.saveTo → 写 context/sess-A/memory.json

第二轮 call(msg, ctx{sessionId="sess-A"})
  ├── bindRuntimeContext → loadIfExists → 读 context/sess-A/memory.json → 恢复第一轮
  ├── ReAct 循环(已知第一轮内容)
  └── PostCallEvent → saveTo → 覆盖写盘

8.3 RuntimeContext 如何串联

RuntimeContext ctx = RuntimeContext.builder()
    .sessionId("sess-001")
    .userId("alice")
    .build();

agent.call(msg, ctx).block();

bindRuntimeContext 做几件事:

  1. 补默认 Session — ctx.session 为空时使用构建时的 defaultSession(默认是 WorkspaceSession)
  2. 补 sessionKey — 依次试 SimpleSessionKey.of(sessionId)SimpleSessionKey.of(agentName)
  3. 传递到 hooks — 所有关心的 hook 同步该 ctx
  4. userId 注入 — userId 作为 NamespaceFactory 路径前缀,多租户透明隔离
  5. 预加载状态 — 存在则 loadIfExists 覆盖 Memory;不存在不动

8.4 配置方式

// 1. 默认:什么都不传 → WorkspaceSession(workspace, agentId)
HarnessAgent.builder()
    .name("MyAgent")
    .model(model)
    .workspace(workspace)
    .build();

// 2. 使用任意指定路径的 JsonSession
HarnessAgent.builder()
    .name("MyAgent")
    .model(model)
    .defaultSession(new JsonSession(Paths.get("/custom/sessions"), "MyAgent"))
    .build();

// 3. 调用时临时覆盖
RuntimeContext.builder()
    .session(new JsonSession(Paths.get("/tmp/sessions"), "MyAgent"))
    .sessionKey(SimpleSessionKey.of("sess-002"))
    .build();

8.5 多用户隔离

// 同一 agent 实例服务 alice / bob
agent.call(msg, RuntimeContext.builder()
    .sessionId("alice-1").userId("alice").build()).block();
agent.call(msg, RuntimeContext.builder()
    .sessionId("bob-1").userId("bob").build()).block();
  • 会话层隔离context/<sessionId>/sessions/<sessionId>.jsonl 彼此独立
  • 文件层隔离 — NamespaceFactory + IsolationScope 决定路径前缀,互不干扰

十、内置工具

Harness 默认为 agent 提供一套"足够走一个闭环"的内置工具,HarnessAgent.build() 时自动注册,无需手动配置。

10.1 注册路径

HarnessAgent.build()
  ├── FilesystemTool          必装
  ├── MemorySearchTool        必装
  ├── MemoryGetTool           必装
  ├── SessionSearchTool       必装
  ├── ShellExecuteTool        条件:backend instanceof AbstractSandboxFilesystem
  └── SubagentsHook.tools()   条件:非 leaf 且有 model
        ├── AgentSpawnTool
        └── TaskTool

10.2 工具一览

文件系统工具

所有路径经过 AbstractFilesystem,后端可能为本地磁盘、沙箱或远端 KV。

工具功能参数
read_file读文件内容path, offset(0-indexed), limit(0=读全)
write_file创建新文件(已存在报错)path, content
edit_file精确字符串替换path, old_string, new_string, replace_all
grep_files搜索字符串(非正则)pattern, glob
glob_files按 glob 查文件pattern
list_files列目录path

记忆工具

工具功能参数
memory_searchFTS5 全文检索,最多 30 条query
memory_get读记忆文件指定行范围,带行号path, startLine, endLine

会话工具

工具功能参数
session_search在会话 JSONL 中扫关键词agentId(可选), maxResults(默认10)
session_list列某个 agent 的会话agentId
session_history返某个会话最近 N 条消息sessionId, lastN(默认20)

子 Agent 工具

只在非 leaf 且有 model 时出现。session mode 下名称变为 sessions_spawnsessions_sendsessions_list

工具功能参数
agent_spawn创建临时子 agentagent_id(必填), task(可选), label, timeout_seconds(默认30, 0=后台, 上限600)
agent_send向已存在子 agent 发消息agent_id/agent_key, message
agent_list列当前子 agent
// 同步调用
agent_spawn(agent_id="research-analyst", task="调研主题 X")

// 后台异步(timeout_seconds=0)
agent_spawn(agent_id="auditor", task="全库安全审计", timeout_seconds=0)
// 后续轮询
task_output(task_id=..., block=false)

// 直接发送给已有子 agent
agent_send(agent_key=..., message="再查一下数据")

后台任务工具

工具功能参数
task_output拿后台任务结果task_id, block(默认true), timeout(默认30000ms,上限600000ms)
task_cancel取消任务(终态不生效)task_id
task_list列任务status_filter: running/completed/failed/cancelled/all

Shell 工具(条件性注册)

仅在 backend 是 AbstractSandboxFilesystem(含沙箱和本机+shell)时才注册。纯 LocalFilesystem 或 RemoteFilesystem 不会出现。

工具功能参数
execute执行 shell 命令,返 stdout + exit codecommand, working_directory(可选), timeout(秒,默认30)
execute("find . -name '*.java' | wc -l")
execute("mvn test", null, 300)
execute("git status", "app")   // 拼为 cd app && git status

十一、子 Agent(Subagent)

子 Agent 让主 agent 把"可独立处理、上下文重、可并行"的任务委派出去,避免主线程膨胀。每个子 agent 都是一个临时 HarnessAgent 实例,拥有独立子会话,结果通过工具回传。

11.1 何时启用

满足以下条件时自动装载 SubagentsHook(priority=80):

  • 不是 leaf subagent
  • 没有 disableSubagents()
  • 配了 model

在每轮 PreReasoningEvent 向 SYSTEM 注入子 agent 使用规则 + 当前可用 agent_id + 异步任务摘要。

11.2 声明来源(四种)

// 1. 编程声明
HarnessAgent.builder()
    .subagent(SubagentDeclaration.builder()
        .name("reviewer")
        .description("代码审查专家")
        .workspace(Paths.get("./defs/reviewer"))
        .workspaceMode(WorkspaceMode.ISOLATED)
        .modelConfig("qwen3-max")
        .maxIters(8)
        .allowedTools(List.of("read_file", "grep_files"))
        .build())
    .subagent(SubagentDeclaration.builder()
        .name("remote-researcher")
        .description("远端调研子 agent")
        .url("http://agent-task-server:8080")
        .headers(Map.of("Authorization", "Bearer xxx"))
        .build())
    .build();

11.3 声明文件格式

---
description: 代码评审专家
workspace:
  mode: isolated               # isolated | shared,默认 isolated
  path: ./defs/reviewer
model: openai:gpt-4o-mini
maxIters: 8                    # 默认 10
tools: [read_file, grep_files]
---

你是一个专注代码评审的子 agent。

文件名(不含 .md)就是 agent_id。扫描 subagents/ 第一层文件(非递归)。

11.4 运行时工作区模式

WorkspaceModesysPrompt base 来源runtime workspace
SHARED(默认)workspace.path/AGENTS.md(无则空)mainWorkspace(固定共享)
ISOLATEDworkspace.path/AGENTS.md(无则空)mainWorkspace/agents/<name>/workspace(自动创建)
INLINEinlineAgentsBody(...) / markdown body同 SHARED

11.5 Leaf Subagent(叶子节点)

一个 subagent 不能再 spawn 自己的 subagent——它是树上的叶子。代码里就是 asLeafSubagent(),不给它注册 AgentSpawnTool,手里没有 spawn 工具,自然生不出子节点。

同时还有双重保护:

  • 第一层(主动):所有声明/内置生成的子 agent 强制设为 leaf → 不注册 spawn 工具
  • 第二层(被动)MAX_SPAWN_DEPTH 是兜底——如果绕过第一层(比如自定义代码),最多嵌套到这个深度,超了就拒绝。正常 API 不会触及这个上限

提问:timeout_seconds=0 为何就是后台异步?

agent_spawn 的语义:

  • timeout_seconds=30同步:父 agent 等 30 秒,超时返回。其间子 agent 事件实时转发到 Flux
  • timeout_seconds=0异步:立即返回 task_id,不等待结果。父 agent 继续干自己的事,之后通过 task_output(task_id, block=false) 轮询

同步:父卡在那里等子出结果。异步:任务丢给 executor 去跑,主 agent 继续。

11.6 异步任务与存储分层

内存层:localTasks(ConcurrentHashMap)
        → 存 Future 句柄,快速查状态
        → 重启就丢

持久层:agents/<parentAgentId>/tasks/<sessionId>.json
        → 存任务状态真源
        → 重启还在

持久化存储了每个任务的 taskId、status、agentId、sessionId、result、时间戳等。跨节点部署时,Node A 发起的任务,Node B 通过读取同一个 workspace 下的 JSON 也能查到状态(但执行粘在创建节点),这就是"分布式语义"。

11.7 Remote Subagent

声明配 url(...) 时,工厂返回 RemoteSubagentStub(占位,不做本地推理),实际通过 HTTP 委派到远端服务。


十二、子 Agent 流式输出(Subagent Streaming)

12.1 要解决什么问题

call() 模式是"全部跑完给你一个回复";stream() 模式是"一边跑一边吐中间事件"。子 agent 也是 HarnessAgent,如果父用了 stream(),子 agent 产生的所有中间事件会被实时注入到父的 Flux<Event> 中,带着 EventSource 标识来源。无需任何额外配置。

12.2 工作原理

父调用方 stream()
  └─ 父 HarnessAgent.stream()
       ├─ 创建 FluxSink<Event>
       ├─ 构造 SubagentEventBus (= sink::next)
       └─ 通过 Reactor Context 注入 bus
       └─ ReActAgent 循环中调用 agent_spawn
            ├─ 从 Context 读取 SubagentEventBus
            ├─ 调子 HarnessAgent.stream()
            │    每个子 Event → map(withSource) → 打 EventSource 标签
            └─ doOnNext(bus::emit) → 实时推入父 FluxSink

12.3 什么是 Flux

Flux 是 Reactor 里的异步数据流,类比一根水管

普通 call():  调用方等 → 算完 → 拿到结果     [---- 阻塞等待 ----]
Flux stream():调用方 ← chunk1 ← chunk2 ← ... [--- 一边算一边吐 ---]

结合子 agent 的时间线:

父第1轮推理 → agent_spawn → 子推理 → 子结束 → 父第2轮推理
   ↑            ↑           ↑        ↑          ↑
REASONING     TOOL_USE   REASONING  AGENT_RESULT  REASONING
(parent)                  (child)   (child)       (parent)
                          ←── 这些都是父等的时候实时推的 ──→

关键理解:父的 ReAct 循环是串行的——

loop:
  ① Reason(推理)→ 决定调用 agent_spawn
  ② Act(执行)   → 调子 agent,同步等它返回
  ③ Observe(观察)→ 拿到结果拼进上下文
  ④ 回到 ①

父在第 ② 步是同步等待子 agent 全部跑完的。stream() 的作用不是让父不等,而是让调用方能实时看到"父等着的时候子里在干什么"。子 agent 的每个推理 chunk 都会实时从 Flux 推给调用方,不必等父整个 call() 结束。

12.4 EventSource 字段

字段含义示例
agentKey运行时实例句柄agent:researcher:550e8400-...
agentId子 agent 类型 IDresearcher
sessionId本次调用独立会话 IDsub-a1b2c3d4-...
parentSessionId父 agent 会话 IDsess-main-001
depth嵌套深度(父直接子=1,孙=2)1
path/ 分隔的调用路径sess-main-001/researcher

12.5 多级嵌套(孙 Agent)

孙 agent 的事件自动按深度叠加 path

parent.stream()
└─ child.stream()                    depth=1, path="sess/planner"
   └─ grandchild.stream()           depth=2, path="sess/planner/executor"

12.6 常见消费模式

// 按事件类型 + 来源分流渲染
events.groupBy(e -> e.getSource() == null ? "parent" : e.getSource().getAgentId())
    .flatMap(group -> group.doOnNext(event -> renderToPanel(group.key(), event)))

// 只等某子 agent 最终结果
String reply = events
    .filter(e -> "researcher".equals(e.getSource().getAgentId()))
    .filter(e -> e.getType() == EventType.AGENT_RESULT && e.isLast())
    .map(e -> e.getMessage().getTextContent())
    .blockFirst();

// SSE 转发给前端
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<?>> chat(@RequestParam String message) {
    return agent.stream(msgs, ctx).map(event -> {
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("type", event.getType().name());
        payload.put("text", event.getMessage().getTextContent());
        if (event.getSource() != null) {
            payload.put("agentId", event.getSource().getAgentId());
            payload.put("depth", event.getSource().getDepth());
        }
        return ServerSentEvent.builder(objectMapper.writeValueAsString(payload)).build();
    });
}

12.7 行为边界

场景子事件转发
同步本地子 agent(timeout>0)✅ 实时转发,带 EventSource
call() 模式(非 stream)❌ 无事件转发,结果以字符串返回
timeout_seconds=0 后台异步❌ 暂不支持流式转发
远程 subagent(Agent Protocol)❌ 同上
多级嵌套(孙 agent)✅ 自动按 depth/path 叠加

12.8 错误处理

子 agent 内部异常 → 框架捕获后写入 TOOL_RESULT 错误文本,不向父 onError,父流不受影响。父流自身致命错误(如模型调用失败)遵循标准 Reactor 语义。


十三、生产配置示例

将以上知识整合为一个生产级配置:

HarnessAgent agent = HarnessAgent.builder()
    .name("production-agent")
    .sysPrompt("你是专业助手。")
    .model(model)
    .workspace(Paths.get(".agentscope/workspace"))
    .additionalContextFile("KNOWLEDGE.md")
    .maxContextTokens(4000)
    .compaction(CompactionConfig.builder()
        .triggerMessages(50)
        .triggerTokens(80_000)
        .keepMessages(20)
        .flushBeforeCompact(true)
        .offloadBeforeCompact(true)
        .build())
    .toolResultEviction(ToolResultEvictionConfig.defaults())
    .filesystem(DockerFilesystemSpec.builder()
        .image("ubuntu:24.04")
        .snapshotSpec(OssSnapshotSpec.builder().bucket("my-bucket").build())
        .isolationScope(IsolationScope.USER)
        .sandboxExecutionGuard(new RedisSandboxExecutionGuard(...))
        .build())
    .sandboxDistributed(SandboxDistributedOptions.builder()
        .redisSession(new RedisSession("redis-host", 6379))
        .build())
    .build();

它同时具备:

  • 身份持续 — 工作区注入 + 双层记忆 + Skill 自动加载
  • 上下文可控 — 对话压缩(控制深度)+ 工具结果卸载(控制宽度)+ 溢出恢复(兜底)
  • 状态可恢复 — 会话持久化 + 沙箱快照 + 分布式 Redis 锁

十四、总结

AgentScope Java Harness 的核心就是一个ReAct 循环。整个架构可以拆成三层:

HarnessAgent
  │
  ├── 一层薄包装                  ← bindRuntimeContext + forceCompactAndRetry
  │
  └── ReActAgent                 ← 核心:一个循环
       │
       ├── Hook 链(一圈钩子)    ← 在循环的关键节点插逻辑
       │   PreReasoningEvent → 压缩记忆、注入工作区
       │   PostActingEvent   → 卸载大结果
       │   PostCallEvent     → 持久化会话、提取记忆
       │
       ├── Toolkit(一批工具)    ← 循环里调用的能力
       │   read_file、memory_search、agent_spawn、execute 等
       │
       └── 三个共享对象           ← Hook 和 Tool 的协作底座
           RuntimeContext、WorkspaceManager、AbstractFilesystem

循环本身就是:

loop:
  reason(想下一步干什么)
  act(调工具 / spawn 子 agent,同步等待返回)
  observe(把结果放回上下文)

你之前问的所有功能——工作区注入、双层记忆、会话持久化、对话压缩、沙箱隔离——全部是在这个循环的某个事件节点上插进去的 hook。没有这个循环,这些功能就没有挂载点。

Harness 的设计哲学可以总结为三个"不":

  1. 不替换推理循环 — HarnessAgent 是 ReActAgent 的薄包装,通过 Hook 和 Toolkit 扩展点叠加能力
  2. 不耦合 Hook — 每个 Hook 只做一件事,通过 priority 排序,通过三个共享对象通信
  3. 不绑定文件系统 — AbstractFilesystem 抽象让存储后端可插拔,从本机到沙箱到远端 KV 一键切换

这种设计使得 Harness 能够将独立的能力(工作区、记忆、工具、沙箱、会话)合成为一个"持续稳定的 Agent",为生产环境的多智能体应用提供了扎实的基础设施。

参考资料