docs(harness): annotate progress core classes and add code-level learning notes

- Annotate DiagnosisProgressTracker, HarnessToolInterceptor, DiagnosisProgressProjector,
  DiagnosisReleaseUseCase, ToolBoundary, ToolBoundaryResult, CanonicalToolInvocation
- Add progress code learning note: interceptor gates, tracker state machine,
  canonical lifecycle, execution gate, projection/release pipeline
- Update learning roadmap: progress marked as deeply learned, next is tool domain
This commit is contained in:
zhuyongxin
2026-08-03 18:37:23 +08:00
parent e564863c43
commit 7844bcea40
9 changed files with 952 additions and 8 deletions
@@ -52,19 +52,39 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
}
/**
* 拦截器名称(框架注册用)。
*/
@Override
public String getName() {
return "harness_evidence_tool_interceptor";
}
/**
* 拦截框架 ReAct loop 的每次 Tool Call——progress 协议的主战场。
*
* <p>只拦截证据类 Tool(RAG / 日志 / MySQL),其余 Tool 原样放行。对证据 Tool 依次执行:
* <ol>
* <li>已停止检查:停止指令交付后仍请求 → 受控停止;</li>
* <li>解析 + 评价:严格解析 Envelope,应用模型对上一轮的 GAINED/NO_GAIN;</li>
* <li>重复检测:参数级规范化 scope,backend 执行前拒绝重复查询;</li>
* <li>执行:交给 ToolBoundary(预算 / canonical / 审计统一门禁),并对结果做双源交叉验证;</li>
* <li>收尾:NO_EVIDENCE 自动计 NO_GAIN,饱和时交付一次 STOP_REQUIRED,返回有界 observation。</li>
* </ol>
*
* <p>模型侧观察(observation)共有三副面孔:正常执行结果、STOP_REQUIRED(含 reason)、
* 可修复协议错误(repair_required)。
*/
@Override
public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandler handler) {
Objects.requireNonNull(request, "request must not be null");
Objects.requireNonNull(handler, "handler must not be null");
// 只拦截证据类 Tool(RAG/日志/MySQL);其余 Tool 原样放行
if (!evidenceTools.supports(request.getToolName())) {
return handler.call(request);
}
// ── 第一道门:停止指令已交付后,任何证据 Tool 请求直接受控停止 ──
DiagnosisProgressSnapshotState before = context.progress().snapshot();
if (before.collectionState() == DiagnosisCollectionState.SATURATED
&& before.stopInstructionDelivered()) {
@@ -75,27 +95,34 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
ParsedAgentToolCall call;
String normalizedScope;
try {
// ── 第二道门:严格解析 Envelope + 应用模型对上一轮的评价 ──
call = evidenceTools.parse(request.getToolName(), request.getArguments(), objectMapper);
context.progress().applyPreviousObservation(call.previousObservation());
DiagnosisProgressSnapshotState evaluated = context.progress().snapshot();
// 审计:模型回传的评价进入 Trace(producer=MODEL)
recordModelProgress(before, call, evaluated);
// 评价导致饱和(连续 NO_GAIN 达阈值)→ 本工具不执行,交付停止指令
if (evaluated.collectionState() == DiagnosisCollectionState.SATURATED) {
recordRejection(request, "INFORMATION_SATURATED");
return stopRequired(request, evaluated.stopReason());
}
// 规范化当前轮业务输入 → 稳定 scope(判重指纹)
normalizedScope = scopeNormalizer.normalize(request.getToolName(), call.businessInput());
} catch (ProgressProtocolViolationException exception) {
// 协议违规:记录违规并返回可修复的 error observation(达阈值则饱和)
return handleProgressProtocolViolation(request, exception);
} catch (IllegalArgumentException | IllegalStateException exception) {
// 解析/规范化失败(非法 JSON、缺 input 等)统一按 INVALID_ENVELOPE 违规处理
return handleProgressProtocolViolation(request,
new ProgressProtocolViolationException(
ProgressProtocolViolationType.INVALID_ENVELOPE,
"Tool Call Envelope is invalid", null, null, exception));
}
// ── 第三道门:参数级重复检测(backend 执行前拒绝)──
if (context.progress().isDuplicate(request.getToolName(), normalizedScope)) {
recordRejection(request, "DUPLICATE_SCOPE");
context.progress().recordDuplicateScope();
context.progress().recordDuplicateScope(); // 重复直接累计 NO_GAIN
DiagnosisProgressSnapshotState duplicate = context.progress().snapshot();
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
InformationGain.NO_GAIN, "HARNESS", duplicate);
@@ -105,14 +132,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
return duplicateScope(request);
}
// ── 第四道门:真正执行 Tool(ToolBoundary 统一门禁:预算/canonical/审计)──
ToolBoundaryResult result = evidenceTools.invoke(
context, request.getToolName(), request.getToolCallId(), call.businessArguments());
if (result.status() == InvocationStatus.READY) {
// 双源交叉验证:从 agent_result 重算的 evidence status 必须与声明的值一致
ToolControlView control = viewProjector.controlView(result.agentResult());
if (control.evidenceStatus() != result.evidenceStatus()) {
recordRejection(request, "OBSERVATION_CONTRACT_MISMATCH");
return safeError(request, "OBSERVATION_CONTRACT_MISMATCH");
}
// 记录完成:NO_EVIDENCE 立即累计 NO_GAIN;EVIDENCE_FOUND 挂 pending 等模型评价
context.progress().recordCompleted(
new CompletedToolCall(
request.getToolCallId(), request.getToolName(), normalizedScope),
@@ -123,17 +153,20 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
InformationGain.NO_GAIN, "HARNESS", completed);
}
// 完成后若饱和:领取一次停止指令(STOP_REQUIRED 只交付一次),观察里带 stop_required
boolean stopRequired = completed.collectionState() == DiagnosisCollectionState.SATURATED
&& context.progress().claimStopInstruction();
if (stopRequired) {
traceRecorder.record(TraceAuditEvents.collectionStop(
context, request.getToolCallId(), request.getToolName(), completed));
}
// 有界 observation 返回给模型(含停止指令/停止原因)
String observation = viewProjector.modelObservation(
request.getToolName(), result.agentResult(), normalizedScope,
stopRequired, completed.stopReason());
return ToolCallResponse.of(request.getToolCallId(), request.getToolName(), observation);
}
// Tool 预算耗尽:标记停止原因(BUDGET_LIMIT_REACHED),其余错误返回稳定 error observation
if ("BUDGET_EXHAUSTED".equals(result.errorCode())) {
context.progress().markBudgetLimitReached();
traceRecorder.record(TraceAuditEvents.collectionStop(
@@ -149,8 +182,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
.build();
}
/**
* 交付「必须停止」观察(observation 三副面孔之一)。
*
* <p>调用前必须已 SATURATED。先领取一次停止指令(STOP_REQUIRED 只交付一次);
* 若已被领过(说明上一轮已交付、模型却继续请求 Tool),直接抛
* {@link DiagnosisCollectionStoppedException} 把受控停止穿出框架 ReAct loop。
* 正常时返回带 stop_required=true + reason 的观察,并记录 collectionStop Trace。
*/
private ToolCallResponse stopRequired(ToolCallRequest request, DiagnosisStopReason reason) {
if (!context.progress().claimStopInstruction()) {
// 停止指令已被交付过 → 模型未听指令,受控停止穿出框架 loop
throw new DiagnosisCollectionStoppedException(reason);
}
traceRecorder.record(TraceAuditEvents.collectionStop(
@@ -164,6 +206,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
request.getToolCallId(), request.getToolName(), writeObservation(observation));
}
/**
* 重复 scope 的观察:告诉模型本次调用被判定为参数级重复、记为 NO_GAIN,
* 但 stop_required=false(单次重复不一定饱和,模型可换查询继续)。
*/
private ToolCallResponse duplicateScope(ToolCallRequest request) {
Map<String, Object> observation = new LinkedHashMap<>();
observation.put("tool_call_id", request.getToolCallId());
@@ -174,6 +220,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
request.getToolCallId(), request.getToolName(), writeObservation(observation));
}
/**
* 协议违规统一入口:记录一次违规(独立计数,达阈值 → SATURATED +
* PROGRESS_PROTOCOL_VIOLATED)。未饱和时返回可修复错误观察,饱和时交付停止指令。
*/
private ToolCallResponse handleProgressProtocolViolation(
ToolCallRequest request,
ProgressProtocolViolationException exception) {
@@ -188,6 +238,11 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
return repairableProtocolError(request, exception, state);
}
/**
* 可修复协议错误观察(observation 三副面孔之一):
* 返回 violation_type / 缺失字段 / 期望的上一轮 ID / 允许的增益值 / 指令,
* 让模型有机会在下一轮修正,而不是直接失败(repair_required=true)。
*/
private ToolCallResponse repairableProtocolError(
ToolCallRequest request,
ProgressProtocolViolationException exception,
@@ -221,6 +276,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
.build();
}
/**
* 安全错误观察:只回稳定错误码(不泄露 raw/敏感信息),status=error。
* 用于契约不一致等非协议类拒绝。
*/
private ToolCallResponse safeError(ToolCallRequest request, String errorCode) {
Map<String, Object> observation = new LinkedHashMap<>();
observation.put("evidence_status", "ERROR");
@@ -235,10 +294,19 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
.build();
}
/**
* 简化版拒绝记录:仅错误码(非协议类拒绝)。
*/
private void recordRejection(ToolCallRequest request, String errorCode) {
recordRejection(request, errorCode, null, false, context.progress().snapshot());
}
/**
* 完整版拒绝记录:写入 Trace 的 TOOL_REQUEST_REJECTED 事件。
*
* <p>与 TOOL_INVOCATION 区分:被拒绝的 Tool 从未调用 backend,
* 不消耗 Tool 预算、不计入实际执行数。
*/
private void recordRejection(
ToolCallRequest request,
String errorCode,
@@ -250,6 +318,9 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
violationType, repairPromptDelivered, state));
}
/**
* 观察序列化:失败时返回稳定的 SERIALIZATION_ERROR 观察(fail closed)。
*/
private String writeObservation(Map<String, Object> observation) {
try {
return objectMapper.writeValueAsString(observation);
@@ -258,6 +329,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
}
}
/**
* 把模型回传的评价写入 Trace(producer=MODEL):在 before 的已完成调用里
* 找到被评价的那次,记录其 tool_call_id + information_gain + 评价后的状态。
*/
private void recordModelProgress(
DiagnosisProgressSnapshotState before,
ParsedAgentToolCall call,
@@ -274,6 +349,9 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
call.previousObservation().informationGain(), "MODEL", after));
}
/**
* 记录一次信息增益事件(producer 区分 HARNESS 判定 / MODEL 评价)。
*/
private void recordProgress(
String toolCallId,
String toolName,
@@ -286,10 +364,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
informationGain, producer, state));
}
/**
* scope 摘要:只记录 toolName + 规范化 scope 的哈希指纹,
* 不把完整查询/参数写进 Trace(避免敏感正文落审计)。
*/
private String scopeSummary(String toolName, String normalizedScope) {
return toolName + "#" + String.format("%08x", normalizedScope.hashCode());
}
/**
* Tool 非 READY 时的错误观察:稳定错误码,不包含 raw 或敏感正文。
*/
private String errorObservation(ToolBoundaryResult result) {
Map<String, Object> observation = new LinkedHashMap<>();
observation.put("evidence_status", result.evidenceStatus());
@@ -16,16 +16,42 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 停止后的「安全投影」:把 Tracker 里保存的调用 identity 回读 Canonical Store 的
* READY 记录,投影成可发布的有界快照 {@link DiagnosisProgressSnapshot}。
*
* <p>设计要点:
* <ul>
* <li>输入:Tracker 只存 identity(toolCallId + toolName + normalizedScope),无 payload;
* 完整事实按 key(runId + toolCallId)从 Canonical Store 回读——避免出现第二份 Tool 真相;</li>
* <li>三重校验(isReferencableBy / toolCallId / toolName)通过才发布;
* 无法验真、不可读、格式非法的记录一律排除,只形成 limitation;</li>
* <li>空结果也投影为有界事实(「该范围内未发现」)——限定范围的空查询是有价值信息;</li>
* <li>硬截断:最多 12 条事实、摘要/范围各 320 字符、来源 160 字符,绝不输出 raw。</li>
* </ul>
*
* <p>产出被 {@code DiagnosisReleaseUseCase} 消费,是发布 INSUFFICIENT_EVIDENCE 类
* FALLBACK 的全部原料(progress 与 release 的交汇点)。
*/
public final class DiagnosisProgressProjector implements DiagnosisProgressProjection {
/** 投影事实条数上限:超出记 limitation 并停止投影。 */
private static final int MAX_FACTS = 12;
/** 每条事实摘要的最大字符数。 */
private static final int MAX_SUMMARY_CHARS = 320;
/** 查询范围(scope)的最大字符数。 */
private static final int MAX_SCOPE_CHARS = 320;
/** Canonical 事实存储:按 key 回读 READY 记录(唯一真相源)。 */
private final CanonicalInvocationStore store;
/** 生成 canonical key(runId + toolCallId)。 */
private final ToolCallKeyFactory keyFactory;
/** JSON 解析:agent_result 反序列化 + normalizedScope 解析。 */
private final ObjectMapper objectMapper;
/**
* 全参构造:三个依赖全部必填(null 直接 NPE 暴露装配错误)。
*/
public DiagnosisProgressProjector(CanonicalInvocationStore store,
ToolCallKeyFactory keyFactory,
ObjectMapper objectMapper) {
@@ -34,6 +60,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
}
/**
* 主入口:遍历 Tracker 的已完成调用列表,逐个回读 canonical 并投影;
* 返回不可变的 ProgressSnapshot(verified sources + observed facts +
* limitations + stopReason),供 Release 发布。
*/
@Override
public DiagnosisProgressSnapshot project(RunContext context) {
Objects.requireNonNull(context, "context must not be null");
@@ -44,11 +75,13 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
for (CompletedToolCall completed : state.completedToolCalls()) {
CanonicalToolInvocation invocation = resolve(context, completed, limitations);
if (invocation == null) {
// 无法验真/不可读:已记 limitation,跳过
continue;
}
try {
projectInvocation(completed, invocation, sources, facts);
} catch (RuntimeException exception) {
// 投影异常(agent_result 非合法对象等):排除并记 limitation,不发布 raw
addLimitation(limitations, "部分已完成的工具结果格式无法验证,未纳入已检查事实");
}
if (facts.size() >= MAX_FACTS) {
@@ -63,6 +96,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
state.stopReason());
}
/**
* 按 identity 回读 canonical 记录,做三重校验:
* isReferencableBy(READY + 同 run + agentResult 非空 + evidence 合法)、
* toolCallId 一致、toolName 一致——任何一项不过即排除并记 limitation。
*/
private CanonicalToolInvocation resolve(RunContext context,
CompletedToolCall completed,
List<String> limitations) {
@@ -78,11 +116,16 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
return invocation;
} catch (RuntimeException exception) {
// Store 不可读(如 TTL 过期/后端异常):记 limitation,不中断整体投影
addLimitation(limitations, "部分已完成的工具记录暂时不可读取,未纳入已检查事实");
return null;
}
}
/**
* 按 Tool 类型分派投影:先把 agent_result 解析为 JSON 对象并计算公开 scope,
* 再交给对应 Tool 的投影逻辑(RAG / 日志 / MySQL)。
*/
private void projectInvocation(CompletedToolCall completed,
CanonicalToolInvocation invocation,
Map<String, SafeFallback.VerifiedSource> sources,
@@ -97,12 +140,17 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/**
* RAG 投影:evidence 数组空 → 有界事实「未发现可用文档证据」;
* 非空 → 逐条投影 source(source/title/document_id 取其一)+ excerpt。
*/
private void projectRag(JsonNode root,
String scope,
Map<String, SafeFallback.VerifiedSource> sources,
Map<String, SafeFallback.ObservedFact> facts) {
JsonNode evidence = root.path("evidence");
if (!evidence.isArray() || evidence.isEmpty()) {
// 空结果是有价值信息:限定范围的空查询也是「已检查」的证明
addFact(sources, facts, "RAG", "knowledge_base", scope,
"该知识检索范围内未发现可用文档证据");
return;
@@ -115,6 +163,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/**
* 日志投影:events 空 → 「未发现匹配事件」;非空 → 逐条 message。
* source 取自 source_kind(缺省 logs)。
*/
private void projectLogs(JsonNode root,
String scope,
Map<String, SafeFallback.VerifiedSource> sources,
@@ -132,6 +184,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/**
* MySQL 投影:rows 空 → 「未发现匹配记录」;非空 → 逐行 toString。
* source 从公开 scope 的 data_source 提取。
*/
private void projectMysql(JsonNode root,
String scope,
Map<String, SafeFallback.VerifiedSource> sources,
@@ -148,6 +204,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/**
* 公开 scope:从 agent_result / normalizedScope 提取对外展示的查询范围,
* 按 Tool 类型不同(RAG=query、LOG=scope 对象、MYSQL=data_source),
* 统一截断到 MAX_SCOPE_CHARS——不泄露完整参数。
*/
private String publicScope(String toolName, String normalizedScope, JsonNode result) {
if (AgentToolContracts.LOOKUP_KNOWLEDGE.equals(toolName)) {
return bounded("query=" + text(result, "query"), MAX_SCOPE_CHARS);
@@ -164,11 +225,17 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/** 从公开 scope("data_source=xxx")中提取 MySQL 数据源名。 */
private String mysqlSource(String scope) {
int separator = scope.indexOf('=');
return separator < 0 ? "mysql" : scope.substring(separator + 1);
}
/**
* 添加一条有界事实:三重截断(source 160 / scope 320 / summary 320)后
* 写入去重 Map——同「来源类型 + 来源 + 范围」只发布一次 source,
* 同「sourceKey + 摘要」只发布一次 fact(LinkedHashMap 保持顺序)。
*/
private void addFact(Map<String, SafeFallback.VerifiedSource> sources,
Map<String, SafeFallback.ObservedFact> facts,
String sourceType,
@@ -189,6 +256,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
new SafeFallback.ObservedFact(sourceType, safeSource, safeScope, safeSummary));
}
/**
* 解析 canonical agent_result:必须是合法 JSON 对象,否则抛异常
* (由调用方捕获后记为 limitation,不发布)。
*/
private JsonNode readObject(String value) {
try {
JsonNode root = objectMapper.readTree(value);
@@ -201,12 +272,14 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/** 追加 limitation(按文案去重,避免同一条限制重复出现)。 */
private static void addLimitation(List<String> limitations, String value) {
if (!limitations.contains(value)) {
limitations.add(value);
}
}
/** 取第一个非空值,全空返回 "unknown"。 */
private static String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
@@ -216,11 +289,13 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
return "unknown";
}
/** 安全取 JSON 字段文本:缺失/null 返回空串。 */
private static String text(JsonNode node, String field) {
JsonNode value = node == null ? null : node.get(field);
return value == null || value.isNull() ? "" : value.asText("");
}
/** 截断到 max 字符(null 视为空串)。 */
private static String bounded(String value, int max) {
String safe = value == null ? "" : value;
return safe.length() <= max ? safe : safe.substring(0, max);
@@ -7,23 +7,60 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Progress 层的核心状态机:判定「继续收集证据是否还有价值」。
*
* <p>与 RunBudget 的分工(双停止机制):
* <ul>
* <li>RunBudget 管「能不能花」——模型次数 / Tool 次数 / Token / bytes 等硬资源上限;</li>
* <li>本 Tracker 管「继续查有没有价值」——连续 NO_GAIN、重复 scope、协议违规都会推动
* 收集状态走向 SATURATED,进而在硬预算之前让 Agent 受控停止。</li>
* </ul>
*
* <p>设计要点:
* <ul>
* <li>只保存做停止决策需要的最小状态(identity + 计数),不保存 request / raw /
* agent result,避免出现第二份 Tool 真相(完整事实在 Canonical Store);</li>
* <li>所有状态读写 synchronized,是 RunContext 中的线程安全单一所有者;</li>
* <li>Tool 提供客观结果,模型判断语义增益(GAINED/NO_GAIN),但最终停止权归 Harness。</li>
* </ul>
*/
public final class DiagnosisProgressTracker {
/** 连续 NO_GAIN 达到该阈值 → SATURATED + INFORMATION_SATURATED(默认 2)。 */
private final int stopAfterConsecutiveNoGain;
/** 连续 progress 协议违规达到该阈值 → SATURATED + PROGRESS_PROTOCOL_VIOLATED(默认 2)。 */
private final int stopAfterConsecutiveProgressProtocolViolations;
/** 已完成调用的去重集合:toolName + normalizedScope,backend 执行前判重。 */
private final Set<ToolScopeIdentity> completedScopes = new LinkedHashSet<>();
/** 已完成调用的 identity 列表(无 payload),供结束时 Projector 回读 canonical。 */
private final List<CompletedToolCall> completedToolCalls = new ArrayList<>();
/** 连续无增益次数;GAINED 清零。 */
private int consecutiveNoGain;
/** 连续协议违规次数;一次合法评价(或无 pending 的合法调用)后清零。 */
private int consecutiveProgressProtocolViolations;
/** 收集状态机:COLLECTING(可继续收集)→ SATURATED(已饱和,只能停止)。 */
private DiagnosisCollectionState collectionState = DiagnosisCollectionState.COLLECTING;
/** 停止原因:INFORMATION_SATURATED / BUDGET_LIMIT_REACHED / PROGRESS_PROTOCOL_VIOLATED。 */
private DiagnosisStopReason stopReason;
/** 等待模型评价的 tool_call_id;同一时刻最多一个 pending。 */
private String pendingToolCallId;
/** 一次 STOP_REQUIRED 指令是否已交付(claimStopInstruction 只成功一次)。 */
private boolean stopInstructionDelivered;
/**
* 单参数构造:连续 NO_GAIN 阈值显式指定,协议违规阈值使用默认值 2。
*/
public DiagnosisProgressTracker(int stopAfterConsecutiveNoGain) {
this(stopAfterConsecutiveNoGain, 2);
}
/**
* 全参构造:两个连续停止阈值都必须为正数(不允许 0 或负数)。
*
* @param stopAfterConsecutiveNoGain 连续 NO_GAIN 达到该次数即饱和
* @param stopAfterConsecutiveProgressProtocolViolations 连续协议违规达到该次数即饱和
*/
public DiagnosisProgressTracker(
int stopAfterConsecutiveNoGain,
int stopAfterConsecutiveProgressProtocolViolations) {
@@ -39,6 +76,22 @@ public final class DiagnosisProgressTracker {
stopAfterConsecutiveProgressProtocolViolations;
}
/**
* 应用模型在下次 Tool Call 中回传的对上一轮观察的评价。
*
* <p>三类协议违规会被拒绝并抛 {@link ProgressProtocolViolationException}:
* <ul>
* <li>{@link ProgressProtocolViolationType#UNEXPECTED_PREVIOUS_OBSERVATION}——没有 pending
* 时却带了 previous_observation(如首次调用);</li>
* <li>{@link ProgressProtocolViolationType#MISSING_PREVIOUS_OBSERVATION}——有 pending 却
* 没带评价;</li>
* <li>{@link ProgressProtocolViolationType#OUT_OF_ORDER_PREVIOUS_OBSERVATION}——带的
* tool_call_id 与 pending 不符(乱序/指向未知调用)。</li>
* </ul>
*
* <p>校验通过后才清协议违规计数并应用 GAINED/NO_GAIN。协议错误与无增益是两件事:
* 前者 Tool 根本没执行,后者 Tool 执行了但没推进诊断,因此必须分开统计。
*/
public synchronized void applyPreviousObservation(PreviousObservation observation) {
if (pendingToolCallId == null) {
if (observation != null) {
@@ -48,6 +101,7 @@ public final class DiagnosisProgressTracker {
"previous_observation",
null);
}
// 没有 pending 且没带评价:正常(如首次调用),顺带清协议违规计数
clearProtocolViolations();
return;
}
@@ -65,20 +119,40 @@ public final class DiagnosisProgressTracker {
"previous_observation.tool_call_id",
pendingToolCallId);
}
// 校验通过:清空 pending,评价生效
pendingToolCallId = null;
clearProtocolViolations();
applyGain(observation.informationGain());
}
/**
* 重复检测:toolName + normalizedScope 是否已被本 Run 完成过(backend 执行前调用)。
*/
public synchronized boolean isDuplicate(String toolName, String normalizedScope) {
return completedScopes.contains(new ToolScopeIdentity(toolName, normalizedScope));
}
/**
* 记录一次被判重的调用:Harness 直接判定为 NO_GAIN(backend 未被调用)。
*/
public synchronized void recordDuplicateScope() {
clearProtocolViolations();
applyGain(InformationGain.NO_GAIN);
}
/**
* 记录一次成功的 Tool 完成。
*
* <ul>
* <li>SATURATED 后禁止再记录完成(饱和即停止收集);</li>
* <li>只接受 {@link EvidenceStatus#EVIDENCE_FOUND} 或 {@link EvidenceStatus#NO_EVIDENCE};
* 失败走技术失败流程,不进入进度统计;</li>
* <li>重复 scope 抛 IllegalStateException(应在此之前被 isDuplicate 拦截);</li>
* <li>{@link EvidenceStatus#NO_EVIDENCE}:空结果由 Harness 直接判 NO_GAIN,不需要模型评价;</li>
* <li>{@link EvidenceStatus#EVIDENCE_FOUND}:设置 pendingToolCallId,等模型在下次
* Tool Call 的 previous_observation 中评价语义增益。</li>
* </ul>
*/
public synchronized void recordCompleted(CompletedToolCall call, EvidenceStatus evidenceStatus) {
if (collectionState == DiagnosisCollectionState.SATURATED) {
throw new IllegalStateException("Cannot record Tool completion after saturation");
@@ -93,15 +167,24 @@ public final class DiagnosisProgressTracker {
}
completedToolCalls.add(call);
if (evidenceStatus == EvidenceStatus.NO_EVIDENCE) {
// 空结果无需模型评价:立即累计 NO_GAIN
clearProtocolViolations();
applyGain(InformationGain.NO_GAIN);
} else {
// 非空结果:挂起等待模型在下一轮评价语义增益
pendingToolCallId = call.toolCallId();
}
}
/**
* 记录一次 progress 协议违规,返回最新快照。
*
* <p>协议违规(缺评价/乱序/非法 Envelope)不计入 NO_GAIN——那是 Tool 执行了却没增益,
* 而违规时 backend 从未执行。连续违规达到独立阈值后进入 SATURATED。
*/
public synchronized DiagnosisProgressSnapshotState recordProgressProtocolViolation() {
if (collectionState == DiagnosisCollectionState.SATURATED) {
// 已饱和:不再累计,直接返回当前快照
return snapshot();
}
consecutiveProgressProtocolViolations++;
@@ -113,6 +196,13 @@ public final class DiagnosisProgressTracker {
return snapshot();
}
/**
* 领取一次停止指令(STOP_REQUIRED)。
*
* <p>只有 SATURATED 且尚未交付过时返回 true——给模型一次合法完成机会(输出 Draft),
* 而不是立即抛错;之后模型仍请求 Tool 时由上层抛
* {@code DiagnosisCollectionStoppedException} 穿出框架 ReAct loop。
*/
public synchronized boolean claimStopInstruction() {
if (collectionState != DiagnosisCollectionState.SATURATED) {
return false;
@@ -124,12 +214,22 @@ public final class DiagnosisProgressTracker {
return true;
}
/**
* 标记预算触顶(由 RunBudget 侧调用)。
*
* <p>只在尚无 stopReason 时设置 BUDGET_LIMIT_REACHED,不覆盖已有的
* INFORMATION_SATURATED / PROGRESS_PROTOCOL_VIOLATED——三种停止原因必须分开,
* 信息饱和不能伪装成预算耗尽。
*/
public synchronized void markBudgetLimitReached() {
if (stopReason == null) {
stopReason = DiagnosisStopReason.BUDGET_LIMIT_REACHED;
}
}
/**
* 返回内部控制快照(计数、pending、停止指令状态与已完成调用列表)。
*/
public synchronized DiagnosisProgressSnapshotState snapshot() {
return new DiagnosisProgressSnapshotState(
consecutiveNoGain,
@@ -149,6 +249,14 @@ public final class DiagnosisProgressTracker {
return stopAfterConsecutiveProgressProtocolViolations;
}
/**
* 应用单次增益判定(核心状态迁移):
* <ul>
* <li>GAINED:清零连续 NO_GAIN——一次早期空查不能使后续有效取证被过早停止;</li>
* <li>NO_GAIN:累加,达到阈值 → SATURATED + INFORMATION_SATURATED。</li>
* </ul>
* 饱和后禁止再次应用(停止权只行使一次)。
*/
private void applyGain(InformationGain gain) {
if (collectionState == DiagnosisCollectionState.SATURATED) {
throw new IllegalStateException("Collection is already saturated");
@@ -164,6 +272,10 @@ public final class DiagnosisProgressTracker {
}
}
/**
* 清空协议违规计数:一次合法评价(或没有 pending 的合法调用)都会重置,
* 避免历史违规累积导致误饱和(协议违规只按「连续」计数)。
*/
private void clearProtocolViolations() {
consecutiveProgressProtocolViolations = 0;
}
@@ -24,14 +24,41 @@ import com.superbiz.agent.harness.retry.RetryFailure;
import java.util.Objects;
import java.util.List;
/**
* 对外结果的「唯一发布点」:把 Agent 执行结果(Draft / 受控停止 / 非法 Draft)
* 裁决为 {@code SUCCESS / FALLBACK},并保证任何对外发布内容都经过
* 证据验证(EvidenceGuard)+ 语义裁决(SemanticGuard)。
*
* <p>决策树(与 progress 的衔接在这里):
* <pre>
* execute(execution)
* ├─ draft == null → 受控停止(progress + stopReason)→ INSUFFICIENT_EVIDENCE
* ├─ draft.conclusion == null → 无结论 Draft → 有已验真事实 ? INSUFFICIENT_EVIDENCE
* │ : missing_info ? MISSING_REQUIRED_CONTEXT
* │ : fail closed(抛异常)
* └─ 有结论 Draft → evidenceGuard 验引用 → repair 重试 → semanticGuard 裁决
* → SUPPORTED ? SUCCESS : FALLBACK(SEMANTIC_UNSUPPORTED)
* </pre>
*
* <p>任何 FALLBACK 都通过 SafeFallbackFactory 构造有界安全回退(不泄露 raw/敏感正文),
* 终态异常(取消/预算耗尽)向上传播不吞掉。
*/
public final class DiagnosisReleaseUseCase {
/** 验引用真实性:EvidenceGuard 机械校验 evidence_ref 是否真实可引用。 */
private final EvidenceGuard evidenceGuard;
/** 引用修复:只修引用不修结论(证据安全链的一环)。 */
private final EvidenceRepair evidenceRepair;
/** 结论支持度裁决:隔离判断结论是否被已验证证据支持。 */
private final SemanticGuard semanticGuard;
/** 有界安全回退工厂:构造 INSUFFICIENT_EVIDENCE 等 FALLBACK。 */
private final SafeFallbackFactory fallbackFactory;
/** Trace 记录器:release 阶段的决策事件(evidence/semantic/release)。 */
private final DiagnosisTraceRecorder traceRecorder;
/**
* 四参构造:Trace 记录器使用 noop(测试/无审计场景)。
*/
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
EvidenceRepair evidenceRepair,
SemanticGuard semanticGuard,
@@ -40,6 +67,9 @@ public final class DiagnosisReleaseUseCase {
DiagnosisTraceRecorder.noop());
}
/**
* 全参构造:五个依赖全部必填(null 直接 NPE 暴露配置错误)。
*/
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
EvidenceRepair evidenceRepair,
SemanticGuard semanticGuard,
@@ -53,12 +83,27 @@ public final class DiagnosisReleaseUseCase {
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
}
/**
* 简化入口:正常收尾(模型输出了合法 Draft)时调用——
* 包装成 completed execution(progress 为空),走完整裁决。
*/
public DiagnosisReleaseResult execute(RunContext context, String query, DiagnosisDraft draft) {
return execute(context, query, DiagnosisAgentExecution.completed(
Objects.requireNonNull(draft, "draft must not be null"),
DiagnosisProgressSnapshot.empty()));
}
/**
* 主入口:按执行结果三分支裁决(对外结果的唯一出口)。
*
* <ul>
* <li>draft == null:受控停止(信息饱和 / 预算耗尽 / 协议违规),
* 只凭 progress 快照发布 INSUFFICIENT_EVIDENCE;</li>
* <li>conclusion == null:模型明确无结论,校验其引用后按
* 有无已验真事实 / missing_info 决定发布类型;</li>
* <li>有结论:走完整证据验证 + 语义裁决链。</li>
* </ul>
*/
public DiagnosisReleaseResult execute(
RunContext context, String query, DiagnosisAgentExecution execution) {
Objects.requireNonNull(context, "context must not be null");
@@ -69,19 +114,27 @@ public final class DiagnosisReleaseUseCase {
DiagnosisDraft draft = execution.draft();
if (draft == null) {
// 受控停止:没有 Draft,只能靠已完成检查的 progress 快照发布
return releaseControlledStop(context, execution.progress(), execution.stopReason());
}
if (draft.conclusion() == null) {
// 无结论 Draft(含 conclusion=null 合法收尾):查引用 + 按进展发布
return releaseNoConclusion(context, draft, execution.progress());
}
// 有结论 Draft:证据验证 →(必要时)修复 → 语义裁决
return releaseConclusion(context, query, draft);
}
/**
* 非法 Draft 专用发布:模型输出不符合 Schema 时,若本 Run 已有可发布事实,
* 降级为 INSUFFICIENT_EVIDENCE Fallback(非法 draft 正文永不出现在对外 content)。
*/
public DiagnosisReleaseResult releaseInvalidDraft(
RunContext context, DiagnosisProgressSnapshot progress) {
Objects.requireNonNull(context, "context must not be null");
Objects.requireNonNull(progress, "progress must not be null");
if (!progress.hasObservedFacts()) {
// 无安全事实 → fail closed:调用方保持原异常(DiagnosisChatExecutor 里再上抛)
throw new IllegalStateException(
"Invalid Diagnosis Draft has no verified publishable progress");
}
@@ -90,6 +143,12 @@ public final class DiagnosisReleaseUseCase {
FallbackType.INSUFFICIENT_EVIDENCE);
}
/**
* 有结论 Draft 的完整发布链:
* ① EvidenceGuard 验引用真实性 → ② 不过则 EvidenceRepair 只修引用再验
* → ③ SemanticGuard 裁决结论支持度 → SUPPORTED ? SUCCESS : SEMANTIC_UNSUPPORTED FALLBACK。
* 任何环节的终态异常(取消/预算)向上传播,不吞掉。
*/
private DiagnosisReleaseResult releaseConclusion(
RunContext context, String query, DiagnosisDraft draft) {
DiagnosisDraft candidate = draft;
@@ -98,6 +157,7 @@ public final class DiagnosisReleaseUseCase {
context, TraceEventType.EVIDENCE_GUARD_INITIAL, evidence, candidate));
if (!evidence.valid()) {
try {
// 引用不真实:只修引用(EvidenceRepair),不替模型改结论
candidate = evidenceRepair.repair(context, query, draft, evidence.violations());
evidence = evidenceGuard.validate(context, candidate);
traceRecorder.record(TraceAuditEvents.evidenceValidation(
@@ -107,6 +167,7 @@ public final class DiagnosisReleaseUseCase {
return evidenceFailure(context, evidence);
}
if (!evidence.valid()) {
// 修复后仍不真实 → 证据验证失败 Fallback(不发布模型原文结论)
return evidenceFailure(context, evidence);
}
}
@@ -117,6 +178,7 @@ public final class DiagnosisReleaseUseCase {
decision = semanticGuard.review(
context, SemanticGuardInput.from(query, candidate, snapshot));
} catch (RuntimeException exception) {
// 语义裁决不可用(如模型超时)→ 降级为 SEMANTIC_UNAVAILABLE Fallback
propagateTerminal(exception);
traceRecorder.record(TraceAuditEvents.semanticUnavailable(context));
traceRecorder.record(TraceAuditEvents.releaseDecision(
@@ -127,16 +189,25 @@ public final class DiagnosisReleaseUseCase {
}
traceRecorder.record(TraceAuditEvents.semanticDecision(context, decision.verdict()));
if (decision.verdict() == SemanticVerdict.SUPPORTED) {
// 引用真实 + 结论被支持 → 唯一的 SUCCESS 出口
traceRecorder.record(TraceAuditEvents.releaseDecision(
context, com.superbiz.agent.harness.contract.ReleaseOutcome.SUCCESS, null));
return DiagnosisReleaseResult.success(candidate, snapshot);
}
// 引用真实但结论不支持 → 语义不支持 Fallback(保留已验证快照)
traceRecorder.record(TraceAuditEvents.releaseDecision(
context, com.superbiz.agent.harness.contract.ReleaseOutcome.FALLBACK,
FallbackType.SEMANTIC_UNSUPPORTED));
return DiagnosisReleaseResult.fallback(fallbackFactory.semanticUnsupported(snapshot));
}
/**
* 无结论 Draft 路径(conclusion=null 是合法收尾,不是失败):
* 先验引用,再按「有无已验真事实 / 是否声明缺失上下文」发布:
* 有事实 → INSUFFICIENT_EVIDENCE(展示已查内容 + 缺失项);
* 无事实但声明 missing_info → MISSING_REQUIRED_CONTEXT;
* 都没有 → 不变量被破坏,fail closed 抛异常。
*/
private DiagnosisReleaseResult releaseNoConclusion(
RunContext context, DiagnosisDraft draft, DiagnosisProgressSnapshot progress) {
EvidenceGuardResult evidence = evidenceGuard.validateNoConclusionReferences(context, draft);
@@ -148,19 +219,27 @@ public final class DiagnosisReleaseUseCase {
List<String> missingInfo = missingInfo(draft);
if (progress.hasObservedFacts()) {
// 已查过一些内容:诚实展示「查了什么、都是空的」+ 下一步所需信息
return progressFallback(context,
fallbackFactory.insufficientEvidence(progress, missingInfo),
FallbackType.INSUFFICIENT_EVIDENCE);
}
if (!missingInfo.isEmpty()) {
// 零 Tool 直接声明缺上下文:合法,不强制空查
return progressFallback(context,
fallbackFactory.missingRequiredContext(missingInfo),
FallbackType.MISSING_REQUIRED_CONTEXT);
}
// 既无进展又无缺失声明 → 状态非法,fail closed
throw new IllegalStateException(
"No-conclusion Diagnosis has neither verified progress nor missing context");
}
/**
* 受控停止路径(draft == null 时进入):三种停止原因(信息饱和 / 预算 / 协议违规)
* 都必须有已验真事实才能发布 INSUFFICIENT_EVIDENCE;
* 无安全进展 → fail closed(不能把「没查到」伪装成业务结果)。
*/
private DiagnosisReleaseResult releaseControlledStop(
RunContext context,
DiagnosisProgressSnapshot progress,
@@ -171,14 +250,19 @@ public final class DiagnosisReleaseUseCase {
throw new IllegalStateException("Unsupported Diagnosis stop reason");
}
if (!progress.hasObservedFacts()) {
// 没有已验证进展 → fail closed(对外不可发布任何结论)
throw new IllegalStateException(
"Controlled Diagnosis stop has no verified publishable progress");
}
// 有进展:把「已查过这些、都无增益」作为诚实的业务结果发布
return progressFallback(context,
fallbackFactory.insufficientEvidence(progress, List.of()),
FallbackType.INSUFFICIENT_EVIDENCE);
}
/**
* 统一的 FALLBACK 出口:记录 release 决策 Trace 并返回安全回退结果。
*/
private DiagnosisReleaseResult progressFallback(
RunContext context,
com.superbiz.agent.harness.contract.SafeFallback fallback,
@@ -188,11 +272,18 @@ public final class DiagnosisReleaseUseCase {
return DiagnosisReleaseResult.fallback(fallback);
}
/**
* 提取 Draft 声明的缺失上下文(limitations.missingInfo),供发布类型判定。
*/
private List<String> missingInfo(DiagnosisDraft draft) {
return draft.limitations() == null
? List.of() : draft.limitations().missingInfo();
}
/**
* 证据验证失败出口:引用无法验真 → EVIDENCE_VALIDATION_FAILED Fallback
* (违规明细进 fallback,不发布模型原文)。
*/
private DiagnosisReleaseResult evidenceFailure(
RunContext context, EvidenceGuardResult evidence) {
traceRecorder.record(TraceAuditEvents.releaseDecision(
@@ -202,6 +293,12 @@ public final class DiagnosisReleaseUseCase {
fallbackFactory.evidenceValidationFailed(evidence.violations()));
}
/**
* 终态异常透传:取消(RunAbortedException / RetryFailure.CANCELLED)和
* 预算耗尽(BudgetExceededException / RetryFailure.BUDGET_EXHAUSTED)不能被
* release 吞掉——它们是 Run 的终态事实,必须向上传播到 Application 层。
* 其余运行时异常(修复/裁决的内部失败)则不拦截,由调用方按降级处理。
*/
private void propagateTerminal(RuntimeException exception) {
if (exception instanceof RunAbortedException
|| exception instanceof BudgetExceededException) {
@@ -25,18 +25,46 @@ import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
/**
* 所有证据 Tool 的统一执行门卫(tool 域核心):每个 Adapter 不再自己实现
* 授权、预算、store 和审计,而是统一走这里。
*
* <p>职责(对一次 Tool 执行):
* <ol>
* <li>preflight:run 匹配 / 授权 / 只读意图 / JSON 合法性 / key 生成;</li>
* <li>预算门禁:Tool 预算(beforeToolCall)+ Run bytes 预留(request → raw → agent_result 三笔);</li>
* <li>canonical 状态机:begin(PROJECTING) → 执行/投影 → markReady(READY) 或 markError(ERROR);</li>
* <li>审计:best-effort 记录 ToolInvocationAuditEvent(失败不阻断业务)。</li>
* </ol>
*
* <p>边界(明确不做):
* <ul>
* <li>不理解 Tool 业务内容——raw → agent_result 的投影由调用方传入的
* {@code ToolResultProjector} 完成;</li>
* <li>不做信息增益判断(那是 progress 层的事);</li>
* <li>只允许 READY / ERROR 离开(PROJECTING 不对外暴露)。</li>
* </ul>
*/
public final class ToolBoundary {
private static final Logger log = LoggerFactory.getLogger(ToolBoundary.class);
/** Harness 核心:Run bytes 预留(reserveRunBytes)、Tool 预算检查(beforeToolCall)。 */
private final DiagnosisHarnessCore core;
/** canonical key 生成(runId + toolCallId)。 */
private final ToolCallKeyFactory keyFactory;
/** canonical 持久化(唯一真相源)。 */
private final CanonicalInvocationStore store;
/** request JSON 合法性校验。 */
private final ObjectMapper objectMapper;
/** 时间戳(begin/ready/error/audit 统一时钟)。 */
private final Clock clock;
/** 持久化审计 sink(可 noop)。 */
private final ToolInvocationAuditSink auditSink;
/** 当前 step id 追踪(可 null:无 step 场景不记录)。 */
private final AgentStepAuditTracker stepTracker;
/** 最简构造:审计 noop + stepTracker null(测试/轻量场景)。 */
public ToolBoundary(DiagnosisHarnessCore core,
ToolCallKeyFactory keyFactory,
CanonicalInvocationStore store,
@@ -45,6 +73,7 @@ public final class ToolBoundary {
this(core, keyFactory, store, objectMapper, clock, ToolInvocationAuditSink.noop(), null);
}
/** 带审计构造:持久化 Tool 审计,无 stepTracker。 */
public ToolBoundary(DiagnosisHarnessCore core,
ToolCallKeyFactory keyFactory,
CanonicalInvocationStore store,
@@ -54,6 +83,7 @@ public final class ToolBoundary {
this(core, keyFactory, store, objectMapper, clock, auditSink, null);
}
/** 全参构造:七个依赖全部必填(null 直接 NPE 暴露装配错误)。 */
public ToolBoundary(DiagnosisHarnessCore core,
ToolCallKeyFactory keyFactory,
CanonicalInvocationStore store,
@@ -70,6 +100,12 @@ public final class ToolBoundary {
this.stepTracker = stepTracker;
}
/**
* 统一执行入口:计算耗时 → 执行 canonical 状态机 → best-effort 审计 → 返回结果。
*
* @param executor 具体 backend 的 raw 执行函数(如 MySQL/日志 adapter)
* @param projector 该 Tool 的投影器(raw → 有界 agent_result + evidence status)
*/
public ToolBoundaryResult execute(RunContext context,
ToolCallRequestEnvelope request,
ToolExecutor executor,
@@ -80,6 +116,11 @@ public final class ToolBoundary {
return outcome.result();
}
/**
* canonical 状态机主流程:所有成功/失败路径都映射为
* ToolBoundaryResult.ready / ToolBoundaryResult.error,中间状态不外泄;
* 任何异常都先尝试 markError 落库(best-effort),再返回稳定错误码。
*/
private ExecutionOutcome executeCanonical(RunContext context,
ToolCallRequestEnvelope request,
ToolExecutor executor,
@@ -87,10 +128,12 @@ public final class ToolBoundary {
String toolCallId = request == null ? null : request.toolCallId();
String key;
try {
// ── 阶段一:preflight + Tool 预算 + request bytes 预留,通过后写 PROJECTING ──
key = preflight(context, request);
core.beforeToolCall(context, request.toolName());
long requestBytes = store.limits().utf8Bytes(request.requestJson());
if (requestBytes > store.limits().maxRecordBytes()) {
// 请求体超限:不落库直接拒绝
return ExecutionOutcome.of(errorAndNoRecord(toolCallId, ToolBoundaryErrorCode.RESULT_TOO_LARGE));
}
core.reserveRunBytes(context, requestBytes);
@@ -98,18 +141,22 @@ public final class ToolBoundary {
request.toolCallId(), request.runId(), request.toolName(),
request.requestJson(), clock.instant()));
} catch (DuplicateInvocationException e) {
// 同 key 重复 begin(同一 run+toolCallId 调两次)→ 拒绝
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.DUPLICATE_TOOL_CALL));
} catch (RunAbortedException | BudgetExceededException e) {
// Run 已取消/预算耗尽:对应终态错误码
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId,
e instanceof BudgetExceededException
? ToolBoundaryErrorCode.BUDGET_EXHAUSTED
: ToolBoundaryErrorCode.RUN_INACTIVE));
} catch (IllegalArgumentException e) {
// preflight 非法:按异常类型归类错误码
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, classifyPreflightError(e)));
} catch (CanonicalStoreException e) {
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.STORE_ERROR));
}
// ── 阶段二:真正执行 backend(raw 响应)──
String rawResponse;
try {
rawResponse = Objects.requireNonNull(executor, "executor must not be null")
@@ -118,10 +165,12 @@ public final class ToolBoundary {
throw new IllegalArgumentException("executor returned null");
}
} catch (Exception e) {
// backend 执行失败:落 ERROR(无 raw)
markErrorSafely(key, null, ToolBoundaryErrorCode.TOOL_EXECUTION_ERROR);
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.TOOL_EXECUTION_ERROR));
}
// ── 阶段三:raw 大小校验 + Run bytes 预留 ──
try {
store.limits().validateRawCandidate(request.requestJson(), rawResponse);
core.reserveRunBytes(context, store.limits().utf8Bytes(rawResponse));
@@ -139,6 +188,7 @@ public final class ToolBoundary {
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE), rawResponse);
}
// ── 阶段四:投影(raw → 有界 agent_result,Projector 计算 evidence status)──
ProjectedToolResult projected;
try {
projected = Objects.requireNonNull(projector, "projector must not be null")
@@ -147,11 +197,13 @@ public final class ToolBoundary {
throw new IllegalArgumentException("projector returned null");
}
} catch (Exception e) {
// 投影失败(raw 无法解析等):落 ERROR
markErrorSafely(key, rawResponse, ToolBoundaryErrorCode.PROJECTION_ERROR);
return new ExecutionOutcome(
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.PROJECTION_ERROR), rawResponse);
}
// ── 阶段五:agent_result 校验 + bytes 预留 → 迁移 READY(唯一成功出口)──
try {
store.limits().validateAgentResult(projected.agentResult());
core.reserveRunBytes(context, store.limits().utf8Bytes(projected.agentResult()));
@@ -172,6 +224,7 @@ public final class ToolBoundary {
return new ExecutionOutcome(
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE), rawResponse);
} catch (CanonicalStoreException e) {
// store 持久化失败:错误码归类(结果过大 vs 投影问题)
ToolBoundaryErrorCode code = e instanceof ResultTooLargeException
? ToolBoundaryErrorCode.RESULT_TOO_LARGE
: ToolBoundaryErrorCode.PROJECTION_ERROR;
@@ -180,17 +233,24 @@ public final class ToolBoundary {
}
}
/**
* 执行前门禁:run 匹配 / 授权 / 只读意图 / 字段与 JSON 合法性 / key 生成。
* 任何一项不过都会抛特定异常,由调用方归类为稳定错误码。
*/
private String preflight(RunContext context, ToolCallRequestEnvelope request) {
if (context == null || request == null) {
throw new IllegalArgumentException("request/context must not be null");
}
if (!context.runId().equals(request.runId())) {
// 信封 run 与当前 context 不一致 → RUN_MISMATCH
throw new RunMismatchException();
}
if (!request.authorized()) {
// 未授权调用 → UNAUTHORIZED
throw new UnauthorizedException();
}
if (!request.readOnly()) {
// 非只读意图(诊断 Tool 必须是只读)→ NOT_READ_ONLY
throw new NotReadOnlyException();
}
if (request.toolName() == null || request.toolName().isBlank()
@@ -198,6 +258,7 @@ public final class ToolBoundary {
throw new IllegalArgumentException("tool name and request must not be blank");
}
try {
// request 必须是合法 JSON 对象
JsonNode root = objectMapper.readTree(request.requestJson());
if (root == null || !root.isObject()) {
throw new IllegalArgumentException("request must be a JSON object");
@@ -208,10 +269,14 @@ public final class ToolBoundary {
try {
return keyFactory.create(request.runId(), request.toolCallId());
} catch (IllegalArgumentException e) {
// toolCallId 非法 → INVALID_TOOL_CALL_ID
throw new InvalidToolCallIdException(e);
}
}
/**
* 安全落 ERROR(best-effort):持久化失败只记 warn 日志,不阻断返回错误结果。
*/
private void markErrorSafely(String key, String rawResponse, ToolBoundaryErrorCode code) {
try {
store.markError(key, rawResponse, code.name(), clock.instant());
@@ -220,10 +285,12 @@ public final class ToolBoundary {
}
}
/** 请求体超限等「无需落库」的错误出口(尚未 begin,无记录可标记)。 */
private ToolBoundaryResult errorAndNoRecord(String toolCallId, ToolBoundaryErrorCode code) {
return ToolBoundaryResult.error(toolCallId, code);
}
/** 把 preflight 抛出的 IllegalArgumentException 归类为稳定错误码。 */
private ToolBoundaryErrorCode classifyPreflightError(IllegalArgumentException exception) {
if (exception instanceof InvalidToolCallIdException) {
return ToolBoundaryErrorCode.INVALID_TOOL_CALL_ID;
@@ -240,6 +307,10 @@ public final class ToolBoundary {
return ToolBoundaryErrorCode.INVALID_REQUEST;
}
/**
* 持久化 Tool 审计事件(best-effort):记录调用元数据(status/耗时/bytes/enrichments),
* 不保存完整 request/raw/agent result(敏感正文不进审计);失败只记 warn 不阻断业务。
*/
private void auditSafely(RunContext context,
ToolCallRequestEnvelope request,
ToolBoundaryResult result,
@@ -268,38 +339,45 @@ public final class ToolBoundary {
}
}
/** UTF-8 字节数(null 视为 0),用于 bytes 预算与审计。 */
private static int utf8Bytes(String value) {
return value == null ? 0 : saturatingInt(value.getBytes(StandardCharsets.UTF_8).length);
}
/** 防溢出的 int 封顶转换。 */
private static int saturatingInt(long value) {
return value >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) value;
}
/** 执行结果 + 原始响应(审计用),rawResponse 仅成功/已产出时携带。 */
private record ExecutionOutcome(ToolBoundaryResult result, String rawResponse) {
static ExecutionOutcome of(ToolBoundaryResult result) {
return new ExecutionOutcome(result, null);
}
}
/** preflight 专用异常:toolCallId 非法。 */
private static final class InvalidToolCallIdException extends IllegalArgumentException {
private InvalidToolCallIdException(Throwable cause) {
super("Invalid tool call ID", cause);
}
}
/** preflight 专用异常:信封 runId 与 context 不一致。 */
private static final class RunMismatchException extends IllegalArgumentException {
private RunMismatchException() {
super("Run ID does not match context");
}
}
/** preflight 专用异常:调用未授权。 */
private static final class UnauthorizedException extends IllegalArgumentException {
private UnauthorizedException() {
super("Tool call is not authorized");
}
}
/** preflight 专用异常:意图非只读(诊断 Tool 只读红线)。 */
private static final class NotReadOnlyException extends IllegalArgumentException {
private NotReadOnlyException() {
super("Tool call is not read-only");
@@ -4,6 +4,23 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.contract.InvocationStatus;
/**
* ToolBoundary 向上(Harness)返回的结果:只允许 READY 或 ERROR 两种状态离开 Boundary。
*
* <p>状态-字段约束(构造时强校验):
* <ul>
* <li>READY:必须携带有界 agent_result + 合法证据语义(FOUND/NO_EVIDENCE),
* 不得带 error_code;</li>
* <li>ERROR:evidence_status 必须 ERROR + 稳定 error_code 必填,
* 不得带 agent_result(错误时没有可发布的投影结果);</li>
* <li>PROJECTING 等中间状态绝不对外暴露(Boundary 内部状态机专用)。</li>
* </ul>
*
* <p>两个工厂方法对应两条出口:{@link #ready}(成功投影后)与 {@link #error}(失败)。
*
* <p>拦截器拿到它后还会做双源交叉验证(ToolResultViewProjector 重算 evidence status),
* 因此 READY 的声明值与内容必须一致。
*/
public record ToolBoundaryResult(
@JsonProperty("status") InvocationStatus status,
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
@@ -12,6 +29,7 @@ public record ToolBoundaryResult(
@JsonProperty("error_code") String errorCode) {
public ToolBoundaryResult {
// READY:必须有界证据结果(agent_result + FOUND/NO_EVIDENCE),禁止带错误码
if (status == InvocationStatus.READY) {
if (agentResult == null || evidenceStatus == null
|| (evidenceStatus != EvidenceStatus.EVIDENCE_FOUND
@@ -22,6 +40,7 @@ public record ToolBoundaryResult(
throw new IllegalArgumentException("READY result must not contain errorCode");
}
} else if (status == InvocationStatus.ERROR) {
// ERROR:稳定错误码必填 + evidence 必须 ERROR,禁止带投影结果
if (evidenceStatus != EvidenceStatus.ERROR || errorCode == null || errorCode.isBlank()) {
throw new IllegalArgumentException("ERROR result requires errorCode and ERROR evidence status");
}
@@ -29,10 +48,15 @@ public record ToolBoundaryResult(
throw new IllegalArgumentException("ERROR result must not contain agent result");
}
} else {
// PROJECTING 等中间状态不允许离开 Boundary
throw new IllegalArgumentException("ToolBoundaryResult must be READY or ERROR");
}
}
/**
* READY 出口:投影成功后调用,携带有界 agent_result 与客观证据语义
* (evidence 由 Projector 按「证据数组空不空」计算)。
*/
public static ToolBoundaryResult ready(String toolCallId,
String agentResult,
EvidenceStatus evidenceStatus) {
@@ -40,6 +64,9 @@ public record ToolBoundaryResult(
InvocationStatus.READY, evidenceStatus, toolCallId, agentResult, null);
}
/**
* ERROR 出口:失败时调用,携带稳定错误码(不携带 raw/agent_result)。
*/
public static ToolBoundaryResult error(String toolCallId, ToolBoundaryErrorCode errorCode) {
return new ToolBoundaryResult(
InvocationStatus.ERROR, EvidenceStatus.ERROR, toolCallId, null, errorCode.name());
@@ -7,6 +7,28 @@ import com.superbiz.agent.harness.contract.InvocationStatus;
import java.time.Instant;
import java.util.Objects;
/**
* Canonical Store 中的「唯一真相」Tool 调用记录(不可变 record)。
*
* <p>状态机(每次迁移返回新实例,原实例不变):
* <pre>
* PROJECTING ──markReady──▶ READY (成功:raw + agent_result + evidence FOUND/NO_EVIDENCE)
* PROJECTING ──markError──▶ ERROR (失败:error_code,无 agent_result)
* </pre>
*
* <p>字段分工:
* <ul>
* <li>tool_call_id / run_id / tool_name:调用身份(key = runId + toolCallId);</li>
* <li>request / raw_response:请求与原始返回(完整真相,供审计/验真,不外发);</li>
* <li>agent_result:Projector 投影后的有界结果(对外可见的唯一形态);</li>
* <li>evidence_status:证据语义(FOUND/NO_EVIDENCE/ERROR);</li>
* <li>error_code:仅 ERROR 状态携带;</li>
* <li>started_at / completed_at:生命周期时间戳。</li>
* </ul>
*
* <p>构造时 {@link #validateState} 强制状态-字段一致性:每个状态只允许携带
* 该状态合法的字段组合(如 PROJECTING 不得有终态字段、ERROR 不得有 agent_result)。
*/
public record CanonicalToolInvocation(
@JsonProperty("tool_call_id") String toolCallId,
@JsonProperty("run_id") String runId,
@@ -21,15 +43,21 @@ public record CanonicalToolInvocation(
@JsonProperty("completed_at") Instant completedAt) {
public CanonicalToolInvocation {
// 身份/请求字段必填;状态与开始时间必填
requireText(toolCallId, "toolCallId");
requireText(runId, "runId");
requireText(toolName, "toolName");
requireText(request, "request");
Objects.requireNonNull(status, "status must not be null");
Objects.requireNonNull(startedAt, "startedAt must not be null");
// 状态-字段一致性:非法组合直接拒绝入库
validateState(status, evidenceStatus, rawResponse, agentResult, errorCode, completedAt);
}
/**
* 创建 PROJECTING 记录:执行开始时调用,仅携带身份 + request + 开始时间
* (终态字段全部为 null)。
*/
public static CanonicalToolInvocation projecting(String toolCallId,
String runId,
String toolName,
@@ -40,6 +68,10 @@ public record CanonicalToolInvocation(
InvocationStatus.PROJECTING, null, null, startedAt, null);
}
/**
* PROJECTING → READY 迁移:成功路径(Projector 投影完成后调用)。
* 必须携带 raw_response + agent_result + evidence(FOUND/NO_EVIDENCE)+ 完成时间。
*/
public CanonicalToolInvocation markReady(String rawResponse,
String agentResult,
EvidenceStatus evidenceStatus,
@@ -50,6 +82,10 @@ public record CanonicalToolInvocation(
InvocationStatus.READY, evidenceStatus, null, startedAt, completedAt);
}
/**
* PROJECTING → ERROR 迁移:失败路径(backend 执行/投影/预算等异常时调用)。
* 携带 raw_response(尽力而为)+ 稳定 error_code + 完成时间;agent_result 禁止出现。
*/
public CanonicalToolInvocation markError(String rawResponse,
String errorCode,
Instant completedAt) {
@@ -59,6 +95,11 @@ public record CanonicalToolInvocation(
InvocationStatus.ERROR, EvidenceStatus.ERROR, errorCode, startedAt, completedAt);
}
/**
* 验真条件(EvidenceGuard / ProgressProjector 都依赖它):
* READY + 属于同一 Run + agent_result 非空 + evidence 是合法证据语义
* (FOUND / NO_EVIDENCE)——即该记录可被引用为真实证据。
*/
public boolean isReferencableBy(String expectedRunId) {
return status == InvocationStatus.READY
&& Objects.equals(runId, expectedRunId)
@@ -67,12 +108,17 @@ public record CanonicalToolInvocation(
|| evidenceStatus == EvidenceStatus.NO_EVIDENCE);
}
/** 状态机防御:只有 PROJECTING 允许迁移(READY/ERROR 是终态,不可再变)。 */
private void requireProjecting() {
if (status != InvocationStatus.PROJECTING) {
throw new InvocationStateException("Only PROJECTING invocation can transition");
}
}
/**
* 状态-字段一致性校验:每种状态只允许合法的字段组合,
* 非法组合(如 PROJECTING 带终态字段、ERROR 带 agent_result)直接拒绝构造。
*/
private static void validateState(InvocationStatus status,
EvidenceStatus evidenceStatus,
String rawResponse,
@@ -81,14 +127,17 @@ public record CanonicalToolInvocation(
Instant completedAt) {
switch (status) {
case PROJECTING -> {
// 投影中:不得携带任何终态字段(evidence/agent_result/error/完成时间)
if (evidenceStatus != null || agentResult != null || errorCode != null || completedAt != null) {
throw new InvocationStateException("PROJECTING invocation contains terminal fields");
}
}
case READY -> {
// 就绪:必须三者齐全(raw + agent_result + 完成时间)
if (rawResponse == null || agentResult == null || completedAt == null) {
throw new InvocationStateException("READY invocation requires raw, agent result and completion");
}
// 就绪:evidence 只能是合法证据语义,且不得带错误码
if (evidenceStatus != EvidenceStatus.EVIDENCE_FOUND
&& evidenceStatus != EvidenceStatus.NO_EVIDENCE) {
throw new InvocationStateException("READY invocation has invalid evidence status");
@@ -98,6 +147,7 @@ public record CanonicalToolInvocation(
}
}
case ERROR -> {
// 错误:evidence 必须 ERROR + 完成时间 + 错误码必填,agent_result 禁止
if (evidenceStatus != EvidenceStatus.ERROR || completedAt == null) {
throw new InvocationStateException("ERROR invocation requires ERROR evidence status and completion");
}
@@ -109,6 +159,7 @@ public record CanonicalToolInvocation(
}
}
/** 非空校验(用于身份、请求、错误码等必填字段)。 */
private static void requireText(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");