Solon v4.1.0

harness - 内置拦截器的修改及添加

</> markdown
2026年9月7日 上午11:24:36

solon-ai-harness 会为 Agent 准备三个内置拦截器:

  • ContextCompressionInterceptor:上下文压缩
  • HITLInterceptor:敏感工具调用的人工审批
  • StopLoopInterceptor:重复调用检测与纠偏

1、默认行为

HarnessEngine engine = HarnessEngine.of(workspace, harnessHome)
        .sessionProvider(sessionProvider)
        .build();

当前 Harness 默认配置如下:

  • 上下文压缩:maxMessages=100maxContextRatio=0.75D、重试次数取 modelRetries(默认 3)。消息数或估算 Token 数达到阈值时触发压缩;默认组合策略依次使用 KeyInfoExtractionStrategyHierarchicalCompressionStrategy
  • 防死循环:new StopLoopInterceptor(3, 8)
  • HITL:默认创建 HITLInterceptor,并为 bash 注册 BashToolStrategyhitlEnabled 默认关闭;Agent 的工具配置还必须包含 hitl,拦截器才会加入该 Agent 的拦截器链。

ContextCompressionInterceptor 自身的无参构造器使用 maxMessages=15maxContextRatio=0.75D;Harness 的内置默认实例不是用无参构造器创建,而是读取 HarnessOptions1000.75D

2、替换内置拦截器

Builder 上的三个方法是替换,不是叠加:

HarnessEngine engine = HarnessEngine.of(workspace, harnessHome)
        .sessionProvider(sessionProvider)
        .compressionInterceptor(new ContextCompressionInterceptor())
        .hitlInterceptor(new HITLInterceptor())
        .stopLoopInterceptor(new StopLoopInterceptor(5, 10))
        .build();

替换 HITLInterceptor 后,默认注册的 BashToolStrategy 和默认的 onApproved 回调也会一并被替换,需要按需重新配置。

3、上下文压缩拦截器

压缩判断发生在 onReasonStart;模型返回上下文超长错误时,onReasonRetry 可执行强制压缩。

3.1 构造与参数

// 拦截器自身的无参默认值:15、0.75D;compressionStrategy 为 null
ContextCompressionInterceptor compression =
        new ContextCompressionInterceptor();

// 指定消息窗口与策略
CompressionStrategy strategy = new CompositeCompressionStrategy()
        .addStrategy(new KeyInfoExtractionStrategy())
        .addStrategy(new HierarchicalCompressionStrategy());

compression = new ContextCompressionInterceptor(
        100,       // maxMessages,下限 10
        0.75D,     // maxContextRatio,范围 (0, 1]
        3,         // maxRetries
        strategy);

compression.setMessageTriggerFactor(1.5D);      // 消息数触发滞后系数,>= 1.0
compression.setMinReservedMessages(5);          // 显式最小保留数;<=0 恢复动态推导
compression.setPerMessageCap(8000);              // 单条消息 Token 上限;0 为自动推导
compression.setDefaultContextLength(128_000L);  // 模型未声明窗口时的回退值

应用到 Harness:

HarnessEngine engine = HarnessEngine.of(workspace, harnessHome)
        .sessionProvider(sessionProvider)
        .compressionInterceptor(compression)
        .build();

// 运行时同时更新 Harness 配置和当前压缩拦截器
engine.setCompressionThreshold(100, 0.75D);

3.2 保护机制

  • META_FIRST 标记的初心链消息不参与压缩。
  • Assistant 工具调用与对应 Tool observation 尽量作为原子关系保留。
  • Token 预算和最小保留窗口共同决定实际保留条数,因此结果不一定机械地等于 maxMessages
  • 单条消息超过 perMessageCap 时会执行头尾截断。
  • ContextSizeEvent 可报告消息数、估算 Token 数以及压缩前后统计。

4、HITL:挂起、决策与恢复

4.1 启用条件

对 Harness 创建的 Agent,以下条件要同时满足:

  1. Agent 工具配置包含 hitl
  2. hitlEnabled=true
  3. HITLInterceptor 为目标工具注册了 HITLStrategy
HarnessEngine engine = HarnessEngine.of(workspace, harnessHome)
        .sessionProvider(sessionProvider)
        .toolsAdd("read", "bash", "hitl")
        .hitlEnabled(true)
        .build();

运行时开关:

engine.setHitlEnabled(true);
engine.setHitlEnabled(false);

setHitlEnabled 会同时更新 Harness 配置与当前 HITLInterceptor 的 enabled 状态,但不会替代工具列表中的 hitl 配置。

4.2 注册策略

HITLInterceptor hitl = new HITLInterceptor()
        .onTool("bash", new BashToolStrategy())
        .onTool("write", new WriteToolStrategy("write"))
        .onTool("edit", new WriteToolStrategy("edit"))
        .onTool("webfetch", new WebToolStrategy("webfetch"))
        .onSensitiveTool("dangerous_tool");

HarnessEngine engine = HarnessEngine.of(workspace, harnessHome)
        .sessionProvider(sessionProvider)
        .toolsAdd("bash", "write", "edit", "webfetch", "hitl")
        .hitlInterceptor(hitl)
        .hitlEnabled(true)
        .build();

HITLStrategy.evaluate(trace, args) 返回 null 表示放行,返回非空字符串表示挂起并把该字符串作为审核原因。

4.3 pending 的真实事件顺序

HITL 在 Action 开始前执行批级预检。首次发现未决的敏感调用时,典型流为:

ReasonEndEvent
  -> HITLPendingEvent
  -> 本次不产生 ActionStartEvent / ToolCallStartEvent / ToolCallEndEvent / ActionEndEvent
  -> RunEndEvent(当前实现 isAbnormal=true,Session 仍为 pending)
  -> onComplete

挂起不是 Reactor onError。这里的 RunEndEvent本次请求的终态,不代表逻辑任务不可恢复。UI 应结合 HITLPendingEventsession.isPending() 展示“等待审批”。

HITLPendingEvent.getPendingTasks() 返回 List<HITLTask>。任务的真实字段为:

API含义
getCallUuid()工具调用实例标识;批量场景的决策主键
getToolName()工具名
getArgs()原始参数快照
getComment()拦截原因
if (event instanceof HITLPendingEvent) {
    HITLPendingEvent pending = (HITLPendingEvent) event;
    for (HITLTask task : pending.getPendingTasks()) {
        System.out.println(task.getCallUuid());
        System.out.println(task.getToolName());
        System.out.println(task.getArgs());
        System.out.println(task.getComment());
    }
}

4.4 同一 Session 以空 Prompt 恢复

先为当前批次提交决策,再复用原 Session,并调用无参 prompt();不要把恢复写成一个新的用户问题。

AgentSession session = engine.getSession(sessionId);
List<HITLTask> tasks = HITL.getPendingTasks(session);

Map<String, HITLDecision> decisions = new LinkedHashMap<>();
for (HITLTask task : tasks) {
    decisions.put(task.getCallUuid(), HITLDecision.approve());
}
HITL.submitAll(session, decisions);

// 空 Prompt 恢复:复用挂起前的 runId 和 lastReasonMessage,通常直接回到 Action
engine.prompt()
        .session(session)
        .stream()
        .subscribe(this::handleEvent, this::handleError);

恢复后的典型顺序是:

RunStartEvent(复用原 runId)
  -> HITLDecidedEvent+
  -> ActionStartEvent
  -> ToolCallStartEvent
  -> ToolCallEndEvent
  -> ActionEndEvent
  -> [下一轮 Reason ...]
  -> RunEndEvent
  -> onComplete

批准会执行工具;skip 会写入 observation 但不调用工具实现。单个敏感调用被 reject 时可能直接结束,因此 HITLDecidedEvent 后不能强制期待 Action 事件。

HITLDecidedEvent 的真实 API 为:

  • getCallId()getToolName()getArgs()getComment()getDecision()
  • isApproved()isRejected()isSkipped()

注意命名差异:待审任务使用 HITLTask.getCallUuid();决策事件使用 HITLDecidedEvent.getCallId(),后者可与工具 Start/End 事件对齐。

4.5 alwaysAllow 与构建后回调

onApproved 只在批准决策设置了 alwaysAllow=true 时调用。若回调需要访问 engine,应先完成构建,再通过 engine.getHitlInterceptor() 设置,避免 lambda 捕获尚未初始化的局部变量。

HarnessEngine engine = HarnessEngine.of(workspace, harnessHome)
        .sessionProvider(sessionProvider)
        .toolsAdd("bash", "hitl")
        .hitlEnabled(true)
        .build();

engine.getHitlInterceptor().onApproved((toolName, args) -> {
    Object value = args == null ? null : args.get("command");
    if (value instanceof String && !((String) value).trim().isEmpty()) {
        engine.addPermissionRule(
                PermissionRule.allow(toolName, (String) value));
    } else {
        engine.addPermissionRule(PermissionRule.allow(toolName));
    }
});

// 只有 alwaysAllow=true 才会触发上面的回调
HITL.submit(session, task,
        HITLDecision.approve(true).comment("管理员确认"));

5、防死循环拦截器

StopLoopInterceptor stopLoop = new StopLoopInterceptor(5, 10);

HarnessEngine engine = HarnessEngine.of(workspace, harnessHome)
        .sessionProvider(sessionProvider)
        .stopLoopInterceptor(stopLoop)
        .build();

检测到重复模式后,拦截器会注入纠偏提示,让模型在后续 Reason 中自行收敛;它不等同于强制终止或挂起。

6、通过 HarnessExtension 添加扩展

当前接口是三参数方法:

public interface HarnessExtension {
    void configure(HarnessEngine engine,
                   String agentName,
                   ReActAgent.Builder agentBuilder);
}

构建期添加:

HarnessExtension extension = new HarnessExtension() {
    @Override
    public void configure(HarnessEngine engine,
                          String agentName,
                          ReActAgent.Builder agentBuilder) {
        agentBuilder.defaultInterceptorAdd(new ReActInterceptor() {
            @Override
            public void onAgentStart(ReActTrace trace) {
                System.out.println("Agent started: " + agentName);
            }
        });
    }
};

HarnessEngine engine = HarnessEngine.of(workspace, harnessHome)
        .sessionProvider(sessionProvider)
        .extensionAdd(extension)
        .build();

运行时添加和移除;变更会重建主 Agent:

HarnessExtension extension = (engine, agentName, agentBuilder) ->
        agentBuilder.defaultInterceptorAdd(new MyCustomInterceptor());

engine.addExtension(extension);
engine.removeExtension(extension);

defaultInterceptorAdd(...) 是向 Agent 拦截器链追加实例;它与 Builder 上替换 Harness 内置实例的 compressionInterceptor(...)hitlInterceptor(...)stopLoopInterceptor(...) 不同。

7、组合示例

CompressionStrategy strategy = new CompositeCompressionStrategy()
        .addStrategy(new KeyInfoExtractionStrategy())
        .addStrategy(new HierarchicalCompressionStrategy());

ContextCompressionInterceptor compression =
        new ContextCompressionInterceptor(100, 0.75D, 3, strategy);

HITLInterceptor hitl = new HITLInterceptor()
        .onTool("bash", new BashToolStrategy())
        .onTool("write", new WriteToolStrategy("write"))
        .onTool("edit", new WriteToolStrategy("edit"));

HarnessEngine engine = HarnessEngine.of(workspace, harnessHome)
        .sessionProvider(sessionProvider)
        .compressionInterceptor(compression)
        .hitlInterceptor(hitl)
        .hitlEnabled(true)
        .stopLoopInterceptor(new StopLoopInterceptor(5, 12))
        .toolsAdd("read", "write", "edit", "bash", "hitl")
        .build();

// 构建后再捕获 engine
engine.getHitlInterceptor().onApproved((toolName, args) -> {
    Object command = args == null ? null : args.get("command");
    if (command instanceof String && !((String) command).trim().isEmpty()) {
        engine.addPermissionRule(
                PermissionRule.allow(toolName, (String) command));
    } else {
        engine.addPermissionRule(PermissionRule.allow(toolName));
    }
});

engine.setCompressionThreshold(100, 0.75D);
engine.setHitlEnabled(true);