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:
@@ -0,0 +1,415 @@
|
|||||||
|
# Harness progress 代码学习笔记:从拦截器五道门到唯一发布点
|
||||||
|
|
||||||
|
**更新日期**:2026-08-03
|
||||||
|
**主题**:Progress 子系统的代码落地——拦截器五道门、Tracker 状态机、canonical 生命周期、执行门禁、投影与发布闭环
|
||||||
|
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
|
||||||
|
**设计视角**:[Harness 信息增益停止-让无证据诊断正常收敛](Harness信息增益停止-让无证据诊断正常收敛.md)(讲「为什么这样设计」)
|
||||||
|
**本文视角**:代码里怎么落地(类地图、调用链、生命周期、状态机、门禁、易错点、面试话术)
|
||||||
|
|
||||||
|
## 1. 定位:双停止机制与三方判断权
|
||||||
|
|
||||||
|
### 1.1 双停止机制(progress 存在的根本理由)
|
||||||
|
|
||||||
|
预算管「能不能花」,progress 管「值不值得继续查」。让预算充当正常停止策略,会把「当前证据不足」错误表达成「系统执行失败」——这是语义错误,不是资源问题。
|
||||||
|
|
||||||
|
| 机制 | 问的问题 | 归属 |
|
||||||
|
|---|---|---|
|
||||||
|
| RunBudget | 这次 Run 最多允许消耗多少? | core 域 |
|
||||||
|
| Progress | 继续查询是否仍可能推进当前诊断? | progress 域 |
|
||||||
|
|
||||||
|
### 1.2 三方判断权(信任边界)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
T["Tool / Projector<br/>客观结果"] -->|"evidence_status:空不空(代码判)"| H
|
||||||
|
M["Diagnosis Agent<br/>语义价值"] -->|"information_gain:有没有用(模型判)"| H
|
||||||
|
H["Harness<br/>最终停止权"] -->|"scope 重复 / 连续 NO_GAIN / 协议合规"| R["停止裁决"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **谁判空**:`EVIDENCE_FOUND / NO_EVIDENCE` 是 Harness 代码判(证据数组空不空),Projector 投影时客观计算,不需要模型。
|
||||||
|
- **谁判价值**:`GAINED / NO_GAIN` 的语义价值是模型判——非空结果「有没有用」只有结合诊断上下文才能判断。
|
||||||
|
- **谁决定停止**:Harness。模型可以主动结束(输出 Draft),但不能用继续发 Tool Call 绕过 Harness 已定的饱和状态。
|
||||||
|
|
||||||
|
**两层信任边界分开**:模型输出乱来(瞎报增益)时,Harness 手里仍有 evidenceStatus 这个与模型无关的事实层(canonical 记录、审计、验真都建立在它之上)。
|
||||||
|
|
||||||
|
## 2. 类地图与调用链
|
||||||
|
|
||||||
|
### 2.1 14 个文件分类
|
||||||
|
|
||||||
|
| 类别 | 类 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| 状态机核心 | `DiagnosisProgressTracker` | 记账(双计数/pending/identity)+ 停止裁决 |
|
||||||
|
| 判重 | `ToolScopeNormalizer` + `ToolScopeIdentity` | 业务输入 → 稳定 scope 指纹 |
|
||||||
|
| 投影 | `DiagnosisProgressProjector` + `DiagnosisProgressProjection` | identity → 回读 canonical → 有界快照 |
|
||||||
|
| 枚举 | `InformationGain` / `DiagnosisCollectionState` / `DiagnosisStopReason` / `ProgressProtocolViolationType` | GAINED·NO_GAIN / COLLECTING·SATURATED / 三种停止原因 / 五类违规 |
|
||||||
|
| record | `PreviousObservation` / `CompletedToolCall` / `DiagnosisProgressSnapshot` / `DiagnosisProgressSnapshotState` | 协议字段 / 完成 identity / 对外快照 / 内部快照 |
|
||||||
|
| 异常 | `ProgressProtocolViolationException` | 受控协议违规 |
|
||||||
|
|
||||||
|
### 2.2 一次 Tool Call 的完整链路
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant I as HarnessToolInterceptor(per-Run)
|
||||||
|
participant ET as HarnessEvidenceTools(注册表)
|
||||||
|
participant AD as Adapter(接线员)
|
||||||
|
participant TB as ToolBoundary(门禁)
|
||||||
|
participant S as CanonicalInvocationStore(真相)
|
||||||
|
participant P as Projector(投影)
|
||||||
|
|
||||||
|
I->>ET: invoke(context, toolName, toolCallId, args)
|
||||||
|
ET->>AD: bridge 包装的 invoker
|
||||||
|
AD->>TB: boundary.execute(context, envelope, executor, projector)
|
||||||
|
TB->>S: begin(PROJECTING)
|
||||||
|
TB->>TB: executor.execute(requestJson)(backend raw)
|
||||||
|
TB->>P: projector.project(raw) → agent_result + evidenceStatus
|
||||||
|
TB->>S: markReady(READY) 或 markError(ERROR)
|
||||||
|
TB-->>AD: ToolBoundaryResult(READY/ERROR)
|
||||||
|
AD-->>I: 一路 return
|
||||||
|
I->>I: 双源交叉验证 → recordCompleted → 返回有界 observation
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 三层关系
|
||||||
|
|
||||||
|
**持有关系**:
|
||||||
|
|
||||||
|
```
|
||||||
|
HarnessToolInterceptor ──持有──▶ RunContext(含 Tracker)/ HarnessEvidenceTools
|
||||||
|
HarnessEvidenceTools ──持有──▶ Map<toolName, EvidenceToolInvoker>(bridge 注册表)
|
||||||
|
Adapter ──持有──▶ ToolBoundary + 具体 backend + 具体 Projector
|
||||||
|
ToolBoundary ──持有──▶ DiagnosisHarnessCore / CanonicalInvocationStore / ToolCallKeyFactory
|
||||||
|
```
|
||||||
|
|
||||||
|
**接口-实现关系**:
|
||||||
|
|
||||||
|
| 接口 | 实现 |
|
||||||
|
|---|---|
|
||||||
|
| `EvidenceToolInvoker`(@FunctionalInterface) | `HarnessEvidenceTools.fromAdapters` 的 bridge |
|
||||||
|
| `AdapterCall`(内部 @FunctionalInterface) | 三个 Adapter 的 execute 方法 |
|
||||||
|
| `ToolExecutor`(@FunctionalInterface) | Adapter 里 `ignored -> legacyExecutor.execute(query)` |
|
||||||
|
| `ToolResultProjector` | `RagResultProjector` / `QueryLogsResultProjector` / `MysqlResultProjector` |
|
||||||
|
| `CanonicalInvocationStore` | `JpaCanonicalInvocationStore` 等 |
|
||||||
|
|
||||||
|
## 3. 生命周期:单例 vs per-Run
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph 应用启动(一次)
|
||||||
|
A["Spring 容器装配单例 bean"]
|
||||||
|
A --> B["DiagnosisHarnessCore / ToolBoundary / Adapter / HarnessEvidenceTools / DiagnosisAgentFactory"]
|
||||||
|
end
|
||||||
|
subgraph 每次请求(多次)
|
||||||
|
C["core.startRun() → RunContext(含 new DiagnosisProgressTracker)"]
|
||||||
|
C --> D["DiagnosisAgentFactory.create(context)"]
|
||||||
|
D --> E["new HarnessModelInterceptor / HarnessToolInterceptor(绑定本 Run context)"]
|
||||||
|
E --> F["ReactAgent.call() → ReAct loop"]
|
||||||
|
F --> G["Run 结束:拦截器/ReactAgent 变成垃圾"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
| 对象 | 生命周期 | 原因 |
|
||||||
|
|---|---|---|
|
||||||
|
| core / ToolBoundary / Adapter / EvidenceTools / AgentFactory | 单例 | 无状态,只存依赖与规则 |
|
||||||
|
| RunContext | per-Run | 状态容器(Tracker/Budget/Lifecycle) |
|
||||||
|
| 两个 Interceptor | per-Run | 持有本 Run 的 RunContext |
|
||||||
|
| ReactAgent | per-Run | 框架有状态对象(loop/memory) |
|
||||||
|
|
||||||
|
**无状态体现**:单例 bean 的字段全是构造注入的依赖/配置(创建后不变);可变状态全部外置到 RunContext 和持久化存储。方法一律以 `context` 参数显式传入——同一个 ToolBoundary 实例可并发服务多个 Run,各算各的账,**状态外置是并发正确性的硬要求**(若在 bean 里存「当前预算计数」,并发 Run 会互相覆盖)。
|
||||||
|
|
||||||
|
## 4. 拦截器五道门(代码核心)
|
||||||
|
|
||||||
|
### 4.1 门卫 + 执行者合一
|
||||||
|
|
||||||
|
证据工具的 `ToolCallback` 被**故意定义为直接抛异常**(`"Harness evidence Tools require the framework Tool interceptor"`)——如果框架绕过拦截器执行 `handler.call()`,直接爆炸。这从结构上保证:**执行不可绕过门禁,校验+执行是原子操作**。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
REQ["Tool Call 到达"] --> CHK{"evidenceTools.supports?"}
|
||||||
|
CHK -->|"非证据工具"| HANDLER["handler.call() 透传"]
|
||||||
|
CHK -->|"证据工具"| GATES["五道门(校验+执行+记账)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么不在 definition 里写**(四个原因):
|
||||||
|
1. 拦截器接管执行时**根本不经过 ToolCallback**——写在 definition 里永远不会执行;
|
||||||
|
2. ToolCallback 是纯函数(input→output),**拿不到 RunContext**,调不了 `context.progress()`;
|
||||||
|
3. ToolCallback 本身就是执行,**没有「执行前」时机**——判重必须在 backend 前,只有拦截器同时拥有执行前/后两个观察点;
|
||||||
|
4. progress 逻辑横跨所有证据工具,写在每工具 callback 会**复制漂移**。
|
||||||
|
|
||||||
|
### 4.2 五道门流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["① 已停止检查"] -->|"SATURATED && stopInstructionDelivered"| B["抛 DiagnosisCollectionStoppedException(硬停止)"]
|
||||||
|
A --> C["② 解析 + 协议校验"]
|
||||||
|
C -->|"违规"| D["recordProgressProtocolViolation → 可修复反馈 / 达阈值饱和"]
|
||||||
|
C -->|"评价导致饱和"| E["stopRequired(软停止)"]
|
||||||
|
C --> F["③ 判重:isDuplicate(toolName, normalizedScope)"]
|
||||||
|
F -->|"重复"| G["recordDuplicateScope(直接 NO_GAIN)→ 饱和? stopRequired : 返回 DUPLICATE_SCOPE 观察"]
|
||||||
|
F --> H["④ ToolBoundary 执行(预算/canonical/审计/checkActive)"]
|
||||||
|
H -->|"READY"| I["双源交叉验证 → recordCompleted → ⑤ 收尾(NO_EVIDENCE 立即 NO_GAIN / FOUND 挂 pending / 饱和交付 stop_required)"]
|
||||||
|
H -->|"非 READY"| J["error observation(BUDGET_EXHAUSTED 额外 markBudgetLimitReached)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 协议三类违规(第二道门)
|
||||||
|
|
||||||
|
校验的是 **Envelope 与 Tracker pending 状态的协议关系**(不是 JSON 结构——JSON 结构在 parse 用严格反序列化做了):
|
||||||
|
|
||||||
|
| 违规 | 含义 | 判定条件 |
|
||||||
|
|---|---|---|
|
||||||
|
| `UNEXPECTED_PREVIOUS_OBSERVATION` | 没欠账却带评价 | pending == null 且 observation != null |
|
||||||
|
| `MISSING_PREVIOUS_OBSERVATION` | 欠账不还 | pending != null 且 observation == null |
|
||||||
|
| `OUT_OF_ORDER_PREVIOUS_OBSERVATION` | 还错账 | observation.toolCallId() != pendingToolCallId |
|
||||||
|
|
||||||
|
另两类(parse 阶段):`MISSING_INPUT`(缺业务输入)、`INVALID_ENVELOPE`(JSON/字段非法)。
|
||||||
|
|
||||||
|
**可修复反馈**:首次违规(未达阈值)返回 `repair_required:true` 观察——带 `violation_type` / `missing_field` / `expected_previous_tool_call_id` / `allowed_information_gain` / `instruction`,让模型下一轮自愈;**连续**违规达独立阈值才饱和(PROGRESS_PROTOCOL_VIOLATED)。
|
||||||
|
|
||||||
|
### 4.4 双源交叉验证(第四道门,READY 后)
|
||||||
|
|
||||||
|
```text
|
||||||
|
源1:result.evidenceStatus() ← Projector 投影时计算并携带的声明值
|
||||||
|
源2:controlView.evidenceStatus() ← 从 agent_result 内容里解析 "evidence_status" 字段
|
||||||
|
比较:一致 ? 通过 : OBSERVATION_CONTRACT_MISMATCH 拒绝
|
||||||
|
```
|
||||||
|
|
||||||
|
本质是「自洽性防线」:同一状态被两处描述(结果对象字段 vs 内容 JSON 字段),必须一致——防止投影 bug / 数据损坏 / 构造不一致导致 progress 基于错误状态决策(如声明 FOUND 但内容空 → 挂 pending 等评价不存在的证据;声明 NO_EVIDENCE 但内容有证据 → 误记 NO_GAIN)。
|
||||||
|
|
||||||
|
### 4.5 三副 observation 面孔
|
||||||
|
|
||||||
|
| 面孔 | 触发 | 关键字段 |
|
||||||
|
|---|---|---|
|
||||||
|
| 正常执行结果 | READY 且未饱和 | 有界观察 + 可选 `stop_required` |
|
||||||
|
| `STOP_REQUIRED` | 饱和后交付一次 | `stop_required:true` + `reason` |
|
||||||
|
| 可修复协议错误 | 协议违规未达阈值 | `repair_required:true` + violation_type/missing_field/expected id/instruction |
|
||||||
|
|
||||||
|
## 5. Tracker 状态机
|
||||||
|
|
||||||
|
### 5.1 11 个字段
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `stopAfterConsecutiveNoGain` | 连续 NO_GAIN 阈值(默认 2,yml 可配) |
|
||||||
|
| `stopAfterConsecutiveProgressProtocolViolations` | 连续协议违规阈值(默认 2) |
|
||||||
|
| `completedScopes` | `toolName + normalizedScope` 去重集合 |
|
||||||
|
| `completedToolCalls` | 已完成调用 identity 列表(无 payload) |
|
||||||
|
| `consecutiveNoGain` | 连续无增益计数(GAINED 清零) |
|
||||||
|
| `consecutiveProgressProtocolViolations` | 连续协议违规计数(合法评价清零) |
|
||||||
|
| `collectionState` | `COLLECTING` / `SATURATED` |
|
||||||
|
| `stopReason` | `INFORMATION_SATURATED` / `BUDGET_LIMIT_REACHED` / `PROGRESS_PROTOCOL_VIOLATED` |
|
||||||
|
| `pendingToolCallId` | 待评价调用 ID(同一时刻最多一个) |
|
||||||
|
| `stopInstructionDelivered` | STOP_REQUIRED 是否已交付(只一次) |
|
||||||
|
|
||||||
|
### 5.2 两条独立计数(重点:协议违规 ≠ NO_GAIN)
|
||||||
|
|
||||||
|
```text
|
||||||
|
NO_GAIN 路径(applyGain)—— backend 执行了但没增益:
|
||||||
|
入口:applyPreviousObservation(NO_GAIN) / recordDuplicateScope() / recordCompleted(NO_EVIDENCE)
|
||||||
|
阈值:stopAfterConsecutiveNoGain(2) → SATURATED + INFORMATION_SATURATED
|
||||||
|
GAINED 清零连续计数
|
||||||
|
|
||||||
|
协议违规路径(recordProgressProtocolViolation)—— backend 根本没执行:
|
||||||
|
入口:拦截器 catch 分支
|
||||||
|
阈值:stopAfterConsecutiveProgressProtocolViolations(2) → SATURATED + PROGRESS_PROTOCOL_VIOLATED
|
||||||
|
一次合法评价(applyPreviousObservation 通过)清零
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么分开**:`NO_GAIN` 表示「Tool 执行了但没推进诊断」;协议错误表示「模型没守契约,Tool 根本没执行」。混淆会让真实空转无法归因(真实 E2E:9 次协议拒绝 + 13 轮模型调用空转)。
|
||||||
|
|
||||||
|
### 5.3 pending 协议(锚点)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["recordCompleted(EVIDENCE_FOUND)"] --> B["pendingToolCallId = toolCallId(挂账)"]
|
||||||
|
B --> C["模型下一轮回带 previous_observation"]
|
||||||
|
C --> D{"ID == pending ?"}
|
||||||
|
D -->|"是"| E["清 pending → applyGain"]
|
||||||
|
D -->|"否"| F["OUT_OF_ORDER 违规"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 只对**非空成功**结果设 pending;NO_EVIDENCE 不设(Harness 已自己判 NO_GAIN)。
|
||||||
|
- pending 是「唯一欠账」——保证协议逐轮、不乱序、不重评。
|
||||||
|
- 模型读完 observation 直接输出 Draft → pending 不消费也合法(不需要评价最后一轮)。
|
||||||
|
|
||||||
|
### 5.4 收集状态机
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> COLLECTING
|
||||||
|
COLLECTING --> COLLECTING: GAINED / consecutiveNoGain=0
|
||||||
|
COLLECTING --> COLLECTING: NO_GAIN 未达阈值
|
||||||
|
COLLECTING --> SATURATED: NO_GAIN 达阈值 / 协议违规达阈值
|
||||||
|
SATURATED --> SATURATED: claimStopInstruction 交付一次(软停止)
|
||||||
|
SATURATED --> [*]: 模型再请求 Tool → DiagnosisCollectionStoppedException(硬停止)
|
||||||
|
```
|
||||||
|
|
||||||
|
**软/硬停止两段式**(易错点:stopReason 设置 ≠ 软停止):
|
||||||
|
|
||||||
|
```text
|
||||||
|
饱和瞬间 → stopReason 已设置(状态事实)
|
||||||
|
软停止 = 第一次交付 stop_required 观察(claimStopInstruction 返回 true,流程不中断)
|
||||||
|
→ 给模型合法输出 Draft 的机会
|
||||||
|
硬停止 = 模型无视指令再次请求 Tool → 入口检查 SATURATED && delivered
|
||||||
|
→ 抛 DiagnosisCollectionStoppedException 穿出框架 loop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 为什么需要 Tracker(三层)
|
||||||
|
|
||||||
|
1. **progress 本身需要**:预算只止损,不能当正常停止策略;
|
||||||
|
2. **必须单一所有者**:「连续无增益」是跨轮次、跨入口(模型评价/代码判空/重复检测)的全局判断,分散记账会状态漂移;
|
||||||
|
3. **最小状态**:不存 payload(真相在 Canonical Store),避免第二份真相。
|
||||||
|
|
||||||
|
## 6. canonical 生命周期(tool 域衔接)
|
||||||
|
|
||||||
|
### 6.1 状态机
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[不存在] --> PROJECTING: store.begin(preflight+预算通过后)
|
||||||
|
PROJECTING --> READY: store.markReady(backend 成功+投影成功)
|
||||||
|
PROJECTING --> ERROR: store.markError(任何异常)
|
||||||
|
note right of PROJECTING: 仅身份+request+startedAt
|
||||||
|
note right of READY: raw + agent_result + evidence + completedAt
|
||||||
|
note right of ERROR: raw + error_code + completedAt(禁 agent_result)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 为什么分 begin/markReady/markError 三段
|
||||||
|
|
||||||
|
| 动机 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 崩溃恢复 | backend 执行可能耗时数秒,一次写入会丢「已开始」的事实;begin 先留痕(PROJECTING = WAL) |
|
||||||
|
| 审计耗时 | 需要 startedAt(begin 记)和 completedAt(迁移记)算调用耗时 |
|
||||||
|
| 状态合法 | 每种状态只允许合法字段组合,由构造校验兜底 |
|
||||||
|
| 防篡改 | record 不可变,迁移生成新实例——READY 一旦写入不可改(证据可验真的前提) |
|
||||||
|
| 幂等 | begin 抛 `DuplicateInvocationException`(同 key 重复 begin 拒绝)→ `DUPLICATE_TOOL_CALL` |
|
||||||
|
|
||||||
|
**两类失败不落库**:requestBytes 超限、preflight 非法、重复 begin——都发生在 begin 之前,canonical 里**无记录**。
|
||||||
|
|
||||||
|
### 6.3 Store vs Tracker 分层
|
||||||
|
|
||||||
|
```text
|
||||||
|
Store(事实层):request / raw_response / agent_result / 状态 / 时间戳 —— 完整真相
|
||||||
|
Tracker(判断层):identity(引用)+ 双计数 + pending + 状态 —— 决策状态
|
||||||
|
|
||||||
|
NO_GAIN 计数不是「第二份真相」:它无法从 Store 重建
|
||||||
|
(模型评价是瞬时的、不落 Store),是独立增量状态
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. ToolBoundary 执行门禁
|
||||||
|
|
||||||
|
### 7.1 executeCanonical 五阶段
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["阶段一:preflight + Tool 预算 + request bytes 预留 → begin(PROJECTING)"]
|
||||||
|
B["阶段二:executor.execute(requestJson)(backend raw)"]
|
||||||
|
C["阶段三:raw 大小校验 + Run bytes 预留"]
|
||||||
|
D["阶段四:projector.project(raw) → 有界 agent_result + evidenceStatus"]
|
||||||
|
E["阶段五:agent_result 校验 + Run bytes 预留 → markReady(READY)"]
|
||||||
|
A --> B --> C --> D --> E
|
||||||
|
```
|
||||||
|
|
||||||
|
**三笔 bytes 预留**:request(阶段一)/ raw(阶段三)/ agent_result(阶段五)——Run 的 bytes 预算分三个时间点消耗,任何一笔超限触发对应错误码。
|
||||||
|
|
||||||
|
### 7.2 失败路径错误码
|
||||||
|
|
||||||
|
| 失败点 | 是否已 begin | canonical | 错误码 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| requestBytes 超限 / preflight 非法 / 重复 begin / Run 终态 | 否 | 无记录 | `RESULT_TOO_LARGE` / 分类错误码 / `DUPLICATE_TOOL_CALL` / `BUDGET_EXHAUSTED`·`RUN_INACTIVE` |
|
||||||
|
| executor 抛错 | 是 | ERROR | `TOOL_EXECUTION_ERROR` |
|
||||||
|
| raw 过大 / 预算 / 取消 | 是 | ERROR | `RESULT_TOO_LARGE` / `BUDGET_EXHAUSTED` / `RUN_INACTIVE` |
|
||||||
|
| 投影失败 | 是 | ERROR | `PROJECTION_ERROR` |
|
||||||
|
| agent_result 过大 / store 失败 | 是 | ERROR | `RESULT_TOO_LARGE` / `PROJECTION_ERROR` / `STORE_ERROR` |
|
||||||
|
|
||||||
|
### 7.3 checkActive 在哪
|
||||||
|
|
||||||
|
progress 拦截器流程里看不到显式 checkActive——它在 **ToolBoundary/core 层**隐式执行:`core.beforeToolCall` / `core.reserveRunBytes` 内部若 Run 已终态,抛 `RunAbortedException` → 映射为 `RUN_INACTIVE` 错误码。
|
||||||
|
|
||||||
|
## 8. 投影与发布闭环
|
||||||
|
|
||||||
|
### 8.1 停止后的数据流
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
T["Tracker(identity 列表)"] --> P["DiagnosisProgressProjector"]
|
||||||
|
P --> S["CanonicalInvocationStore(回读 READY)"]
|
||||||
|
S --> P
|
||||||
|
P --> SN["ProgressSnapshot(verifiedSources + observedFacts + limitations + stopReason)"]
|
||||||
|
SN --> R["DiagnosisReleaseUseCase"]
|
||||||
|
R --> O["SUCCESS / FALLBACK(INSUFFICIENT_EVIDENCE 等)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Projector 有界投影
|
||||||
|
|
||||||
|
- 三重校验(`isReferencableBy` / toolCallId / toolName)通过才发布;无法验真/不可读/格式非法 → limitation,**绝不输出 raw**;
|
||||||
|
- 空结果也投影为有界事实(「该范围内未发现」)——限定范围的空查询是「已检查」的证明;
|
||||||
|
- 硬截断:`MAX_FACTS=12`、summary/scope 320 字符、source 160 字符;
|
||||||
|
- 去重:`sourceKey = type + source + scope`,`factKey = sourceKey + summary`(LinkedHashMap 保序)。
|
||||||
|
|
||||||
|
### 8.3 Release 决策树
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
EX["execute(execution)"] --> D1{"draft == null?"}
|
||||||
|
D1 -->|"是"| CS["releaseControlledStop<br/>需 hasObservedFacts → INSUFFICIENT_EVIDENCE"]
|
||||||
|
D1 -->|"否"| D2{"conclusion == null?"}
|
||||||
|
D2 -->|"是"| NC["releaseNoConclusion<br/>有事实 → INSUFFICIENT_EVIDENCE<br/>missing_info → MISSING_REQUIRED_CONTEXT<br/>都没有 → fail closed"]
|
||||||
|
D2 -->|"否"| C["releaseConclusion<br/>EvidenceGuard 验引用 → repair → SemanticGuard 裁决<br/>SUPPORTED ? SUCCESS : FALLBACK"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 易错点清单
|
||||||
|
|
||||||
|
| 易错 | 正确 |
|
||||||
|
|---|---|
|
||||||
|
| EVIDENCE_FOUND 累计 NO_GAIN | **NO_EVIDENCE**(空结果)才立即累计;FOUND 挂 pending 等模型评价 |
|
||||||
|
| 重复调用计入协议违规 | 重复走 **NO_GAIN** 路径(recordDuplicateScope),不是违规 |
|
||||||
|
| 软停止 = 设置 stopReason | stopReason 饱和时就设了;软停止是**交付 stop_required 观察** |
|
||||||
|
| checkActive 应该在拦截器 | 在 ToolBoundary/core 层,`RUN_INACTIVE` 错误码 |
|
||||||
|
| 拦截器直接 invoke = 绕过设计 | 是**唯一执行通道**(证据工具 handler 必炸) |
|
||||||
|
| 数据都在 Tracker | 判断层在 Tracker,**事实层在 Canonical Store** |
|
||||||
|
| 判重对比裸入参 | 对比的是**规范化指纹**(字段顺序/格式/缺省值统一后) |
|
||||||
|
| 首次协议违规直接终止 | 首次返回**可修复反馈**,连续违规才饱和 |
|
||||||
|
|
||||||
|
## 10. 面试话术合集(30 秒)
|
||||||
|
|
||||||
|
### 10.1 双停止机制
|
||||||
|
|
||||||
|
> "Harness 有两套停止机制。预算是资源门禁,管『能不能花』;Progress 是收敛门禁,管『继续查有没有价值』。核心设计是:Tool 只提供客观结果,模型负责判断语义增益,但停止权归 Harness。信息增益故意只保留 GAINED/NO_GAIN 两值,靠模型在下次 Tool Call 里用 previous_observation 回传评价;连续两次 NO_GAIN 就进入饱和,交付一次 STOP_REQUIRED 给模型合法收尾的机会,再纠缠就抛受控异常穿出框架。"
|
||||||
|
|
||||||
|
### 10.2 为什么需要 Tracker(单一所有者)
|
||||||
|
|
||||||
|
> "ProgressTracker 是 Run 内收敛控制的单一决策者:它不存证据内容,只保存最小账——已完成调用的 identity、连续 NO_GAIN 和协议违规两条计数、一个待评价的 pending ID、以及 COLLECTING/SATURATED 状态。三路输入(模型评价、代码判空、重复检测)汇入计数,GAINED 清零、NO_GAIN 累加,达阈值进入饱和,交付一次停止指令。所有方法 synchronized,是并发安全的单一所有者。"
|
||||||
|
|
||||||
|
### 10.3 拦截器为什么自己执行(必炸路径)
|
||||||
|
|
||||||
|
> "Harness 的证据工具不是『拦截后放行』——拦截器对它们既是门卫又是执行者。证据工具的 ToolCallback 被故意定义为直接抛异常,使 handler 路径成为必炸路径,从结构上保证执行不可绕过门禁;校验(判重/协议/预算)和执行(ToolBoundary/canonical)内联在同一个流程里,保证『执行前判重、执行后记录』的时机原子性,且模型拿到的永远是有界观察而不是 raw。"
|
||||||
|
|
||||||
|
### 10.4 为什么 begin/markReady/markError 三段式
|
||||||
|
|
||||||
|
> "Tool 执行是跨门禁、backend IO、校验、投影多个不可靠阶段的流程,所以 canonical 记录用 begin → markReady/markError 的追加式状态机:begin 先落 PROJECTING 保证已开始的调用必有痕迹,backend 成功且投影成功才 markReady 定案为 READY,任何异常 markError 收尾;三个状态各校验合法字段组合,record 不可变保证一旦写入不可篡改——这样崩溃可恢复、审计可对账(startedAt/completedAt 算耗时)、证据可验真(READY 不可造假),重复 begin 还能幂等拦截。"
|
||||||
|
|
||||||
|
### 10.5 谁判空谁判价值
|
||||||
|
|
||||||
|
> "空不空是客观事实,代码可判,所以 evidenceStatus 归 Harness 的 Projector;有没有用是语义判断,代码判不了(一段通用知识可能看似相关实则无用),所以 information_gain 归模型。Harness 绝不把客观状态交给模型声明,模型也绝不替 Harness 做停止裁决——两层的信任边界是分开的。"
|
||||||
|
|
||||||
|
### 10.6 无状态 bean
|
||||||
|
|
||||||
|
> "Harness 的单例 bean(core/ToolBoundary/Adapter)全部无状态:字段只有构造注入的依赖和配置,创建后不变;所有可变状态外置到每个 Run 的 RunContext(预算计数、进度 Tracker、生命周期)和持久化存储里。方法一律以 context 参数显式传入,所以同一个 bean 实例能并发服务多个 Run 而不串账——状态在参数里,不在实例里。"
|
||||||
|
|
||||||
|
## 11. 代码位置索引
|
||||||
|
|
||||||
|
| 类 | 文件 |
|
||||||
|
|---|---|
|
||||||
|
| `DiagnosisProgressTracker` | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisProgressTracker.java` |
|
||||||
|
| `ToolScopeNormalizer` | `src/main/java/com/superbiz/agent/harness/progress/ToolScopeNormalizer.java` |
|
||||||
|
| `DiagnosisProgressProjector` | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisProgressProjector.java` |
|
||||||
|
| `DiagnosisProgressProjection` | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisProgressProjection.java` |
|
||||||
|
| progress 枚举/record/异常 | `src/main/java/com/superbiz/agent/harness/progress/`(其余 9 个文件) |
|
||||||
|
| `HarnessToolInterceptor` | `src/main/java/com/superbiz/agent/harness/agent/HarnessToolInterceptor.java` |
|
||||||
|
| `HarnessEvidenceTools` | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java` |
|
||||||
|
| `DiagnosisAgentFactory` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentFactory.java` |
|
||||||
|
| `RagToolAdapter` | `src/main/java/com/superbiz/agent/harness/tool/adapter/RagToolAdapter.java` |
|
||||||
|
| `ToolBoundary` | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundary.java` |
|
||||||
|
| `ToolBoundaryResult` | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundaryResult.java` |
|
||||||
|
| `CanonicalToolInvocation` | `src/main/java/com/superbiz/agent/harness/tool/store/CanonicalToolInvocation.java` |
|
||||||
|
| `CanonicalInvocationStore` | `src/main/java/com/superbiz/agent/harness/tool/store/CanonicalInvocationStore.java` |
|
||||||
|
| `DiagnosisReleaseUseCase` | `src/main/java/com/superbiz/agent/harness/release/DiagnosisReleaseUseCase.java` |
|
||||||
|
| 装配 | `src/main/java/com/superbiz/agent/config/HarnessChatConfiguration.java` |
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
### 0.4 当前会话的起始上下文(供追溯)
|
### 0.4 当前会话的起始上下文(供追溯)
|
||||||
|
|
||||||
本次学习从 Harness 入口文档开始,已完整走过:入口导读 → 面试速查 → RunContext → 执行控制(budget/cancel/lifecycle/checkActive)→ 取消广播与打断机制 → RunBudget 深挖 → retry(设计+实现+超时+幂等性)→ 状态流预备(枚举归属)。当前停在「执行控制面已闭环,下一步 progress」的位置。
|
本次学习从 Harness 入口文档开始,已完整走过:入口导读 → 面试速查 → RunContext → 执行控制(budget/cancel/lifecycle/checkActive)→ 取消广播与打断机制 → RunBudget 深挖 → retry(设计+实现+超时+幂等性)→ progress(设计+代码双视角,含 ToolBoundary/canonical/Release 衔接)。当前停在「progress ✅ 已完成,下一步 tool」的位置。
|
||||||
|
|
||||||
### 0.5 面试准备策略(学习目标)
|
### 0.5 面试准备策略(学习目标)
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
| `guard` | **验证分离**:EvidenceGuard 机械验引用真实性 + SemanticGuard 隔离判结论支持度 | ⬜ 部分 | GuardModelCall(预算/超时/取消订阅) | — |
|
| `guard` | **验证分离**:EvidenceGuard 机械验引用真实性 + SemanticGuard 隔离判结论支持度 | ⬜ 部分 | GuardModelCall(预算/超时/取消订阅) | — |
|
||||||
| `release` | **唯一发布点**:SUCCESS / FALLBACK 裁决,EvidenceRepair 只修引用,SafeFallback 确定性构造 | ⬜ 部分 | DiagnosisReleaseResult(结果类型) | — |
|
| `release` | **唯一发布点**:SUCCESS / FALLBACK 裁决,EvidenceRepair 只修引用,SafeFallback 确定性构造 | ⬜ 部分 | DiagnosisReleaseResult(结果类型) | — |
|
||||||
| `tool` | **证据边界**:ToolBoundary 统一执行规则、canonical 保存真相、projector 有界投影、MySQL 只读沙箱 | ⬜ 空白 | JdbcMysqlReadOnlyExecutor(取消订阅) | — |
|
| `tool` | **证据边界**:ToolBoundary 统一执行规则、canonical 保存真相、projector 有界投影、MySQL 只读沙箱 | ⬜ 空白 | JdbcMysqlReadOnlyExecutor(取消订阅) | — |
|
||||||
| `progress` | **收敛控制**:信息增益(GAINED/NO_GAIN)、重复检测、饱和停止(预算之外的第二套停止机制) | ⬜ 空白 | — | — |
|
| `progress` | **收敛控制**:信息增益(GAINED/NO_GAIN)、重复检测、饱和停止(预算之外的第二套停止机制) | ✅ 深入 | 设计动机、Tracker 双计数/pending/软硬停止、拦截器五道门、canonical 生命周期、Projector 投影、Release 消费 | [代码学习笔记](Harness progress 代码学习笔记-从拦截器五道门到唯一发布点.md)(与[设计视角](Harness信息增益停止-让无证据诊断正常收敛.md)配套) |
|
||||||
|
|
||||||
图例:✅ 深入 = 已完整学透,能面试讲 2 分钟;⬜ 部分 = 接触过但没系统学;⬜ 空白 = 未开始
|
图例:✅ 深入 = 已完整学透,能面试讲 2 分钟;⬜ 部分 = 接触过但没系统学;⬜ 空白 = 未开始
|
||||||
|
|
||||||
@@ -100,13 +100,15 @@
|
|||||||
| [Harness 执行控制笔记-终态检查与取消广播](Harness执行控制笔记-终态检查与取消广播.md) | RunContext / checkActive / 两个 CAS / 取消广播 / 打断机制 | ✅ 已沉淀 |
|
| [Harness 执行控制笔记-终态检查与取消广播](Harness执行控制笔记-终态检查与取消广播.md) | RunContext / checkActive / 两个 CAS / 取消广播 / 打断机制 | ✅ 已沉淀 |
|
||||||
| [RunBudget 预算流程-一次 Run 的资源门禁时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) | RunBudget 时序图 / 字段组件 / 异常终态 / 三要素 | ✅ 已沉淀 |
|
| [RunBudget 预算流程-一次 Run 的资源门禁时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) | RunBudget 时序图 / 字段组件 / 异常终态 / 三要素 | ✅ 已沉淀 |
|
||||||
| [Retry 重试机制-显式可计量的 attempt 循环](Retry重试机制-显式可计量的attempt循环.md) | Retry 设计动机 / 分类裁决 / 剩余超时 / 幂等性 | ✅ 已沉淀 |
|
| [Retry 重试机制-显式可计量的 attempt 循环](Retry重试机制-显式可计量的attempt循环.md) | Retry 设计动机 / 分类裁决 / 剩余超时 / 幂等性 | ✅ 已沉淀 |
|
||||||
|
| [Harness 信息增益停止-让无证据诊断正常收敛](Harness信息增益停止-让无证据诊断正常收敛.md) | progress 设计视角:双停止机制 / 三方判断权 / 状态机 / 协议 / 真实问题 | ✅ 已沉淀(设计视角) |
|
||||||
|
| [Harness progress 代码学习笔记-从拦截器五道门到唯一发布点](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md) | progress 代码视角:类地图 / 五道门 / Tracker 状态机 / canonical 生命周期 / 门禁 / 投影发布 / 易错点 / 面试话术 | ✅ 已沉淀(代码视角) |
|
||||||
|
|
||||||
## 3. 一次请求的完整学习主线
|
## 3. 一次请求的完整学习主线
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
A["core<br/>执行控制 ✅"] --> B["retry<br/>重试 ✅"]
|
A["core<br/>执行控制 ✅"] --> B["retry<br/>重试 ✅"]
|
||||||
B --> C["progress<br/>信息增益 ⬜"]
|
B --> C["progress<br/>信息增益 ✅"]
|
||||||
C --> D["tool<br/>事实边界 ⬜"]
|
C --> D["tool<br/>事实边界 ⬜"]
|
||||||
D --> E["guard<br/>验证 ⬜"]
|
D --> E["guard<br/>验证 ⬜"]
|
||||||
E --> F["release<br/>发布 ⬜"]
|
E --> F["release<br/>发布 ⬜"]
|
||||||
@@ -117,13 +119,15 @@ flowchart LR
|
|||||||
## 4. 下一步规划
|
## 4. 下一步规划
|
||||||
|
|
||||||
```text
|
```text
|
||||||
下一个:progress(信息增益停止)——14 个文件,小而独立
|
progress ✅ 已完成(设计+代码双视角笔记沉淀,代码核心类全部加注释)
|
||||||
和刚学完的预算组成"双停止机制":预算管"能不能花",信息增益管"继续查有没有价值"
|
|
||||||
|
下一个:tool(49 个文件,按四层理解:Boundary → Canonical → Projector → Adapter)
|
||||||
|
—— 已在 progress 学习中顺带摸过 ToolBoundary / CanonicalInvocationStore / Adapter / Projector
|
||||||
|
—— 正式系统学时按四层主线走,把投影器、JPA store、MySQL 沙箱补齐
|
||||||
|
|
||||||
之后顺序:
|
之后顺序:
|
||||||
tool(49 个文件,按四层理解:Boundary → Canonical → Projector → Adapter)
|
|
||||||
guard(15 个文件:EvidenceGuard + SemanticGuard)
|
guard(15 个文件:EvidenceGuard + SemanticGuard)
|
||||||
release(6 个文件,小而关键:唯一发布点)
|
release(6 个文件,小而关键:唯一发布点——已接触 DiagnosisReleaseUseCase)
|
||||||
补 application(路由/执行器/SSE 收尾)和 audit(Trace 回放)
|
补 application(路由/执行器/SSE 收尾)和 audit(Trace 回放)
|
||||||
最后状态流(RunState ↔ ReleaseOutcome ↔ SseOutcome 正交全景)
|
最后状态流(RunState ↔ ReleaseOutcome ↔ SseOutcome 正交全景)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -52,19 +52,39 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
|||||||
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
|
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拦截器名称(框架注册用)。
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return "harness_evidence_tool_interceptor";
|
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
|
@Override
|
||||||
public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandler handler) {
|
public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandler handler) {
|
||||||
Objects.requireNonNull(request, "request must not be null");
|
Objects.requireNonNull(request, "request must not be null");
|
||||||
Objects.requireNonNull(handler, "handler must not be null");
|
Objects.requireNonNull(handler, "handler must not be null");
|
||||||
|
// 只拦截证据类 Tool(RAG/日志/MySQL);其余 Tool 原样放行
|
||||||
if (!evidenceTools.supports(request.getToolName())) {
|
if (!evidenceTools.supports(request.getToolName())) {
|
||||||
return handler.call(request);
|
return handler.call(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 第一道门:停止指令已交付后,任何证据 Tool 请求直接受控停止 ──
|
||||||
DiagnosisProgressSnapshotState before = context.progress().snapshot();
|
DiagnosisProgressSnapshotState before = context.progress().snapshot();
|
||||||
if (before.collectionState() == DiagnosisCollectionState.SATURATED
|
if (before.collectionState() == DiagnosisCollectionState.SATURATED
|
||||||
&& before.stopInstructionDelivered()) {
|
&& before.stopInstructionDelivered()) {
|
||||||
@@ -75,27 +95,34 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
|||||||
ParsedAgentToolCall call;
|
ParsedAgentToolCall call;
|
||||||
String normalizedScope;
|
String normalizedScope;
|
||||||
try {
|
try {
|
||||||
|
// ── 第二道门:严格解析 Envelope + 应用模型对上一轮的评价 ──
|
||||||
call = evidenceTools.parse(request.getToolName(), request.getArguments(), objectMapper);
|
call = evidenceTools.parse(request.getToolName(), request.getArguments(), objectMapper);
|
||||||
context.progress().applyPreviousObservation(call.previousObservation());
|
context.progress().applyPreviousObservation(call.previousObservation());
|
||||||
DiagnosisProgressSnapshotState evaluated = context.progress().snapshot();
|
DiagnosisProgressSnapshotState evaluated = context.progress().snapshot();
|
||||||
|
// 审计:模型回传的评价进入 Trace(producer=MODEL)
|
||||||
recordModelProgress(before, call, evaluated);
|
recordModelProgress(before, call, evaluated);
|
||||||
|
// 评价导致饱和(连续 NO_GAIN 达阈值)→ 本工具不执行,交付停止指令
|
||||||
if (evaluated.collectionState() == DiagnosisCollectionState.SATURATED) {
|
if (evaluated.collectionState() == DiagnosisCollectionState.SATURATED) {
|
||||||
recordRejection(request, "INFORMATION_SATURATED");
|
recordRejection(request, "INFORMATION_SATURATED");
|
||||||
return stopRequired(request, evaluated.stopReason());
|
return stopRequired(request, evaluated.stopReason());
|
||||||
}
|
}
|
||||||
|
// 规范化当前轮业务输入 → 稳定 scope(判重指纹)
|
||||||
normalizedScope = scopeNormalizer.normalize(request.getToolName(), call.businessInput());
|
normalizedScope = scopeNormalizer.normalize(request.getToolName(), call.businessInput());
|
||||||
} catch (ProgressProtocolViolationException exception) {
|
} catch (ProgressProtocolViolationException exception) {
|
||||||
|
// 协议违规:记录违规并返回可修复的 error observation(达阈值则饱和)
|
||||||
return handleProgressProtocolViolation(request, exception);
|
return handleProgressProtocolViolation(request, exception);
|
||||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||||
|
// 解析/规范化失败(非法 JSON、缺 input 等)统一按 INVALID_ENVELOPE 违规处理
|
||||||
return handleProgressProtocolViolation(request,
|
return handleProgressProtocolViolation(request,
|
||||||
new ProgressProtocolViolationException(
|
new ProgressProtocolViolationException(
|
||||||
ProgressProtocolViolationType.INVALID_ENVELOPE,
|
ProgressProtocolViolationType.INVALID_ENVELOPE,
|
||||||
"Tool Call Envelope is invalid", null, null, exception));
|
"Tool Call Envelope is invalid", null, null, exception));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 第三道门:参数级重复检测(backend 执行前拒绝)──
|
||||||
if (context.progress().isDuplicate(request.getToolName(), normalizedScope)) {
|
if (context.progress().isDuplicate(request.getToolName(), normalizedScope)) {
|
||||||
recordRejection(request, "DUPLICATE_SCOPE");
|
recordRejection(request, "DUPLICATE_SCOPE");
|
||||||
context.progress().recordDuplicateScope();
|
context.progress().recordDuplicateScope(); // 重复直接累计 NO_GAIN
|
||||||
DiagnosisProgressSnapshotState duplicate = context.progress().snapshot();
|
DiagnosisProgressSnapshotState duplicate = context.progress().snapshot();
|
||||||
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
|
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
|
||||||
InformationGain.NO_GAIN, "HARNESS", duplicate);
|
InformationGain.NO_GAIN, "HARNESS", duplicate);
|
||||||
@@ -105,14 +132,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
|||||||
return duplicateScope(request);
|
return duplicateScope(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 第四道门:真正执行 Tool(ToolBoundary 统一门禁:预算/canonical/审计)──
|
||||||
ToolBoundaryResult result = evidenceTools.invoke(
|
ToolBoundaryResult result = evidenceTools.invoke(
|
||||||
context, request.getToolName(), request.getToolCallId(), call.businessArguments());
|
context, request.getToolName(), request.getToolCallId(), call.businessArguments());
|
||||||
if (result.status() == InvocationStatus.READY) {
|
if (result.status() == InvocationStatus.READY) {
|
||||||
|
// 双源交叉验证:从 agent_result 重算的 evidence status 必须与声明的值一致
|
||||||
ToolControlView control = viewProjector.controlView(result.agentResult());
|
ToolControlView control = viewProjector.controlView(result.agentResult());
|
||||||
if (control.evidenceStatus() != result.evidenceStatus()) {
|
if (control.evidenceStatus() != result.evidenceStatus()) {
|
||||||
recordRejection(request, "OBSERVATION_CONTRACT_MISMATCH");
|
recordRejection(request, "OBSERVATION_CONTRACT_MISMATCH");
|
||||||
return safeError(request, "OBSERVATION_CONTRACT_MISMATCH");
|
return safeError(request, "OBSERVATION_CONTRACT_MISMATCH");
|
||||||
}
|
}
|
||||||
|
// 记录完成:NO_EVIDENCE 立即累计 NO_GAIN;EVIDENCE_FOUND 挂 pending 等模型评价
|
||||||
context.progress().recordCompleted(
|
context.progress().recordCompleted(
|
||||||
new CompletedToolCall(
|
new CompletedToolCall(
|
||||||
request.getToolCallId(), request.getToolName(), normalizedScope),
|
request.getToolCallId(), request.getToolName(), normalizedScope),
|
||||||
@@ -123,17 +153,20 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
|||||||
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
|
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
|
||||||
InformationGain.NO_GAIN, "HARNESS", completed);
|
InformationGain.NO_GAIN, "HARNESS", completed);
|
||||||
}
|
}
|
||||||
|
// 完成后若饱和:领取一次停止指令(STOP_REQUIRED 只交付一次),观察里带 stop_required
|
||||||
boolean stopRequired = completed.collectionState() == DiagnosisCollectionState.SATURATED
|
boolean stopRequired = completed.collectionState() == DiagnosisCollectionState.SATURATED
|
||||||
&& context.progress().claimStopInstruction();
|
&& context.progress().claimStopInstruction();
|
||||||
if (stopRequired) {
|
if (stopRequired) {
|
||||||
traceRecorder.record(TraceAuditEvents.collectionStop(
|
traceRecorder.record(TraceAuditEvents.collectionStop(
|
||||||
context, request.getToolCallId(), request.getToolName(), completed));
|
context, request.getToolCallId(), request.getToolName(), completed));
|
||||||
}
|
}
|
||||||
|
// 有界 observation 返回给模型(含停止指令/停止原因)
|
||||||
String observation = viewProjector.modelObservation(
|
String observation = viewProjector.modelObservation(
|
||||||
request.getToolName(), result.agentResult(), normalizedScope,
|
request.getToolName(), result.agentResult(), normalizedScope,
|
||||||
stopRequired, completed.stopReason());
|
stopRequired, completed.stopReason());
|
||||||
return ToolCallResponse.of(request.getToolCallId(), request.getToolName(), observation);
|
return ToolCallResponse.of(request.getToolCallId(), request.getToolName(), observation);
|
||||||
}
|
}
|
||||||
|
// Tool 预算耗尽:标记停止原因(BUDGET_LIMIT_REACHED),其余错误返回稳定 error observation
|
||||||
if ("BUDGET_EXHAUSTED".equals(result.errorCode())) {
|
if ("BUDGET_EXHAUSTED".equals(result.errorCode())) {
|
||||||
context.progress().markBudgetLimitReached();
|
context.progress().markBudgetLimitReached();
|
||||||
traceRecorder.record(TraceAuditEvents.collectionStop(
|
traceRecorder.record(TraceAuditEvents.collectionStop(
|
||||||
@@ -149,8 +182,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交付「必须停止」观察(observation 三副面孔之一)。
|
||||||
|
*
|
||||||
|
* <p>调用前必须已 SATURATED。先领取一次停止指令(STOP_REQUIRED 只交付一次);
|
||||||
|
* 若已被领过(说明上一轮已交付、模型却继续请求 Tool),直接抛
|
||||||
|
* {@link DiagnosisCollectionStoppedException} 把受控停止穿出框架 ReAct loop。
|
||||||
|
* 正常时返回带 stop_required=true + reason 的观察,并记录 collectionStop Trace。
|
||||||
|
*/
|
||||||
private ToolCallResponse stopRequired(ToolCallRequest request, DiagnosisStopReason reason) {
|
private ToolCallResponse stopRequired(ToolCallRequest request, DiagnosisStopReason reason) {
|
||||||
if (!context.progress().claimStopInstruction()) {
|
if (!context.progress().claimStopInstruction()) {
|
||||||
|
// 停止指令已被交付过 → 模型未听指令,受控停止穿出框架 loop
|
||||||
throw new DiagnosisCollectionStoppedException(reason);
|
throw new DiagnosisCollectionStoppedException(reason);
|
||||||
}
|
}
|
||||||
traceRecorder.record(TraceAuditEvents.collectionStop(
|
traceRecorder.record(TraceAuditEvents.collectionStop(
|
||||||
@@ -164,6 +206,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
|||||||
request.getToolCallId(), request.getToolName(), writeObservation(observation));
|
request.getToolCallId(), request.getToolName(), writeObservation(observation));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重复 scope 的观察:告诉模型本次调用被判定为参数级重复、记为 NO_GAIN,
|
||||||
|
* 但 stop_required=false(单次重复不一定饱和,模型可换查询继续)。
|
||||||
|
*/
|
||||||
private ToolCallResponse duplicateScope(ToolCallRequest request) {
|
private ToolCallResponse duplicateScope(ToolCallRequest request) {
|
||||||
Map<String, Object> observation = new LinkedHashMap<>();
|
Map<String, Object> observation = new LinkedHashMap<>();
|
||||||
observation.put("tool_call_id", request.getToolCallId());
|
observation.put("tool_call_id", request.getToolCallId());
|
||||||
@@ -174,6 +220,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
|||||||
request.getToolCallId(), request.getToolName(), writeObservation(observation));
|
request.getToolCallId(), request.getToolName(), writeObservation(observation));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协议违规统一入口:记录一次违规(独立计数,达阈值 → SATURATED +
|
||||||
|
* PROGRESS_PROTOCOL_VIOLATED)。未饱和时返回可修复错误观察,饱和时交付停止指令。
|
||||||
|
*/
|
||||||
private ToolCallResponse handleProgressProtocolViolation(
|
private ToolCallResponse handleProgressProtocolViolation(
|
||||||
ToolCallRequest request,
|
ToolCallRequest request,
|
||||||
ProgressProtocolViolationException exception) {
|
ProgressProtocolViolationException exception) {
|
||||||
@@ -188,6 +238,11 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
|||||||
return repairableProtocolError(request, exception, state);
|
return repairableProtocolError(request, exception, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可修复协议错误观察(observation 三副面孔之一):
|
||||||
|
* 返回 violation_type / 缺失字段 / 期望的上一轮 ID / 允许的增益值 / 指令,
|
||||||
|
* 让模型有机会在下一轮修正,而不是直接失败(repair_required=true)。
|
||||||
|
*/
|
||||||
private ToolCallResponse repairableProtocolError(
|
private ToolCallResponse repairableProtocolError(
|
||||||
ToolCallRequest request,
|
ToolCallRequest request,
|
||||||
ProgressProtocolViolationException exception,
|
ProgressProtocolViolationException exception,
|
||||||
@@ -221,6 +276,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全错误观察:只回稳定错误码(不泄露 raw/敏感信息),status=error。
|
||||||
|
* 用于契约不一致等非协议类拒绝。
|
||||||
|
*/
|
||||||
private ToolCallResponse safeError(ToolCallRequest request, String errorCode) {
|
private ToolCallResponse safeError(ToolCallRequest request, String errorCode) {
|
||||||
Map<String, Object> observation = new LinkedHashMap<>();
|
Map<String, Object> observation = new LinkedHashMap<>();
|
||||||
observation.put("evidence_status", "ERROR");
|
observation.put("evidence_status", "ERROR");
|
||||||
@@ -235,10 +294,19 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简化版拒绝记录:仅错误码(非协议类拒绝)。
|
||||||
|
*/
|
||||||
private void recordRejection(ToolCallRequest request, String errorCode) {
|
private void recordRejection(ToolCallRequest request, String errorCode) {
|
||||||
recordRejection(request, errorCode, null, false, context.progress().snapshot());
|
recordRejection(request, errorCode, null, false, context.progress().snapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整版拒绝记录:写入 Trace 的 TOOL_REQUEST_REJECTED 事件。
|
||||||
|
*
|
||||||
|
* <p>与 TOOL_INVOCATION 区分:被拒绝的 Tool 从未调用 backend,
|
||||||
|
* 不消耗 Tool 预算、不计入实际执行数。
|
||||||
|
*/
|
||||||
private void recordRejection(
|
private void recordRejection(
|
||||||
ToolCallRequest request,
|
ToolCallRequest request,
|
||||||
String errorCode,
|
String errorCode,
|
||||||
@@ -250,6 +318,9 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
|||||||
violationType, repairPromptDelivered, state));
|
violationType, repairPromptDelivered, state));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 观察序列化:失败时返回稳定的 SERIALIZATION_ERROR 观察(fail closed)。
|
||||||
|
*/
|
||||||
private String writeObservation(Map<String, Object> observation) {
|
private String writeObservation(Map<String, Object> observation) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.writeValueAsString(observation);
|
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(
|
private void recordModelProgress(
|
||||||
DiagnosisProgressSnapshotState before,
|
DiagnosisProgressSnapshotState before,
|
||||||
ParsedAgentToolCall call,
|
ParsedAgentToolCall call,
|
||||||
@@ -274,6 +349,9 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
|||||||
call.previousObservation().informationGain(), "MODEL", after));
|
call.previousObservation().informationGain(), "MODEL", after));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一次信息增益事件(producer 区分 HARNESS 判定 / MODEL 评价)。
|
||||||
|
*/
|
||||||
private void recordProgress(
|
private void recordProgress(
|
||||||
String toolCallId,
|
String toolCallId,
|
||||||
String toolName,
|
String toolName,
|
||||||
@@ -286,10 +364,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
|||||||
informationGain, producer, state));
|
informationGain, producer, state));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* scope 摘要:只记录 toolName + 规范化 scope 的哈希指纹,
|
||||||
|
* 不把完整查询/参数写进 Trace(避免敏感正文落审计)。
|
||||||
|
*/
|
||||||
private String scopeSummary(String toolName, String normalizedScope) {
|
private String scopeSummary(String toolName, String normalizedScope) {
|
||||||
return toolName + "#" + String.format("%08x", normalizedScope.hashCode());
|
return toolName + "#" + String.format("%08x", normalizedScope.hashCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool 非 READY 时的错误观察:稳定错误码,不包含 raw 或敏感正文。
|
||||||
|
*/
|
||||||
private String errorObservation(ToolBoundaryResult result) {
|
private String errorObservation(ToolBoundaryResult result) {
|
||||||
Map<String, Object> observation = new LinkedHashMap<>();
|
Map<String, Object> observation = new LinkedHashMap<>();
|
||||||
observation.put("evidence_status", result.evidenceStatus());
|
observation.put("evidence_status", result.evidenceStatus());
|
||||||
|
|||||||
@@ -16,16 +16,42 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
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 {
|
public final class DiagnosisProgressProjector implements DiagnosisProgressProjection {
|
||||||
|
|
||||||
|
/** 投影事实条数上限:超出记 limitation 并停止投影。 */
|
||||||
private static final int MAX_FACTS = 12;
|
private static final int MAX_FACTS = 12;
|
||||||
|
/** 每条事实摘要的最大字符数。 */
|
||||||
private static final int MAX_SUMMARY_CHARS = 320;
|
private static final int MAX_SUMMARY_CHARS = 320;
|
||||||
|
/** 查询范围(scope)的最大字符数。 */
|
||||||
private static final int MAX_SCOPE_CHARS = 320;
|
private static final int MAX_SCOPE_CHARS = 320;
|
||||||
|
|
||||||
|
/** Canonical 事实存储:按 key 回读 READY 记录(唯一真相源)。 */
|
||||||
private final CanonicalInvocationStore store;
|
private final CanonicalInvocationStore store;
|
||||||
|
/** 生成 canonical key(runId + toolCallId)。 */
|
||||||
private final ToolCallKeyFactory keyFactory;
|
private final ToolCallKeyFactory keyFactory;
|
||||||
|
/** JSON 解析:agent_result 反序列化 + normalizedScope 解析。 */
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全参构造:三个依赖全部必填(null 直接 NPE 暴露装配错误)。
|
||||||
|
*/
|
||||||
public DiagnosisProgressProjector(CanonicalInvocationStore store,
|
public DiagnosisProgressProjector(CanonicalInvocationStore store,
|
||||||
ToolCallKeyFactory keyFactory,
|
ToolCallKeyFactory keyFactory,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
@@ -34,6 +60,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
|||||||
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
|
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主入口:遍历 Tracker 的已完成调用列表,逐个回读 canonical 并投影;
|
||||||
|
* 返回不可变的 ProgressSnapshot(verified sources + observed facts +
|
||||||
|
* limitations + stopReason),供 Release 发布。
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public DiagnosisProgressSnapshot project(RunContext context) {
|
public DiagnosisProgressSnapshot project(RunContext context) {
|
||||||
Objects.requireNonNull(context, "context must not be null");
|
Objects.requireNonNull(context, "context must not be null");
|
||||||
@@ -44,11 +75,13 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
|||||||
for (CompletedToolCall completed : state.completedToolCalls()) {
|
for (CompletedToolCall completed : state.completedToolCalls()) {
|
||||||
CanonicalToolInvocation invocation = resolve(context, completed, limitations);
|
CanonicalToolInvocation invocation = resolve(context, completed, limitations);
|
||||||
if (invocation == null) {
|
if (invocation == null) {
|
||||||
|
// 无法验真/不可读:已记 limitation,跳过
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
projectInvocation(completed, invocation, sources, facts);
|
projectInvocation(completed, invocation, sources, facts);
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
|
// 投影异常(agent_result 非合法对象等):排除并记 limitation,不发布 raw
|
||||||
addLimitation(limitations, "部分已完成的工具结果格式无法验证,未纳入已检查事实");
|
addLimitation(limitations, "部分已完成的工具结果格式无法验证,未纳入已检查事实");
|
||||||
}
|
}
|
||||||
if (facts.size() >= MAX_FACTS) {
|
if (facts.size() >= MAX_FACTS) {
|
||||||
@@ -63,6 +96,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
|||||||
state.stopReason());
|
state.stopReason());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 identity 回读 canonical 记录,做三重校验:
|
||||||
|
* isReferencableBy(READY + 同 run + agentResult 非空 + evidence 合法)、
|
||||||
|
* toolCallId 一致、toolName 一致——任何一项不过即排除并记 limitation。
|
||||||
|
*/
|
||||||
private CanonicalToolInvocation resolve(RunContext context,
|
private CanonicalToolInvocation resolve(RunContext context,
|
||||||
CompletedToolCall completed,
|
CompletedToolCall completed,
|
||||||
List<String> limitations) {
|
List<String> limitations) {
|
||||||
@@ -78,11 +116,16 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
|||||||
}
|
}
|
||||||
return invocation;
|
return invocation;
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
|
// Store 不可读(如 TTL 过期/后端异常):记 limitation,不中断整体投影
|
||||||
addLimitation(limitations, "部分已完成的工具记录暂时不可读取,未纳入已检查事实");
|
addLimitation(limitations, "部分已完成的工具记录暂时不可读取,未纳入已检查事实");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 Tool 类型分派投影:先把 agent_result 解析为 JSON 对象并计算公开 scope,
|
||||||
|
* 再交给对应 Tool 的投影逻辑(RAG / 日志 / MySQL)。
|
||||||
|
*/
|
||||||
private void projectInvocation(CompletedToolCall completed,
|
private void projectInvocation(CompletedToolCall completed,
|
||||||
CanonicalToolInvocation invocation,
|
CanonicalToolInvocation invocation,
|
||||||
Map<String, SafeFallback.VerifiedSource> sources,
|
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,
|
private void projectRag(JsonNode root,
|
||||||
String scope,
|
String scope,
|
||||||
Map<String, SafeFallback.VerifiedSource> sources,
|
Map<String, SafeFallback.VerifiedSource> sources,
|
||||||
Map<String, SafeFallback.ObservedFact> facts) {
|
Map<String, SafeFallback.ObservedFact> facts) {
|
||||||
JsonNode evidence = root.path("evidence");
|
JsonNode evidence = root.path("evidence");
|
||||||
if (!evidence.isArray() || evidence.isEmpty()) {
|
if (!evidence.isArray() || evidence.isEmpty()) {
|
||||||
|
// 空结果是有价值信息:限定范围的空查询也是「已检查」的证明
|
||||||
addFact(sources, facts, "RAG", "knowledge_base", scope,
|
addFact(sources, facts, "RAG", "knowledge_base", scope,
|
||||||
"该知识检索范围内未发现可用文档证据");
|
"该知识检索范围内未发现可用文档证据");
|
||||||
return;
|
return;
|
||||||
@@ -115,6 +163,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志投影:events 空 → 「未发现匹配事件」;非空 → 逐条 message。
|
||||||
|
* source 取自 source_kind(缺省 logs)。
|
||||||
|
*/
|
||||||
private void projectLogs(JsonNode root,
|
private void projectLogs(JsonNode root,
|
||||||
String scope,
|
String scope,
|
||||||
Map<String, SafeFallback.VerifiedSource> sources,
|
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,
|
private void projectMysql(JsonNode root,
|
||||||
String scope,
|
String scope,
|
||||||
Map<String, SafeFallback.VerifiedSource> sources,
|
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) {
|
private String publicScope(String toolName, String normalizedScope, JsonNode result) {
|
||||||
if (AgentToolContracts.LOOKUP_KNOWLEDGE.equals(toolName)) {
|
if (AgentToolContracts.LOOKUP_KNOWLEDGE.equals(toolName)) {
|
||||||
return bounded("query=" + text(result, "query"), MAX_SCOPE_CHARS);
|
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) {
|
private String mysqlSource(String scope) {
|
||||||
int separator = scope.indexOf('=');
|
int separator = scope.indexOf('=');
|
||||||
return separator < 0 ? "mysql" : scope.substring(separator + 1);
|
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,
|
private void addFact(Map<String, SafeFallback.VerifiedSource> sources,
|
||||||
Map<String, SafeFallback.ObservedFact> facts,
|
Map<String, SafeFallback.ObservedFact> facts,
|
||||||
String sourceType,
|
String sourceType,
|
||||||
@@ -189,6 +256,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
|||||||
new SafeFallback.ObservedFact(sourceType, safeSource, safeScope, safeSummary));
|
new SafeFallback.ObservedFact(sourceType, safeSource, safeScope, safeSummary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 canonical agent_result:必须是合法 JSON 对象,否则抛异常
|
||||||
|
* (由调用方捕获后记为 limitation,不发布)。
|
||||||
|
*/
|
||||||
private JsonNode readObject(String value) {
|
private JsonNode readObject(String value) {
|
||||||
try {
|
try {
|
||||||
JsonNode root = objectMapper.readTree(value);
|
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) {
|
private static void addLimitation(List<String> limitations, String value) {
|
||||||
if (!limitations.contains(value)) {
|
if (!limitations.contains(value)) {
|
||||||
limitations.add(value);
|
limitations.add(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 取第一个非空值,全空返回 "unknown"。 */
|
||||||
private static String firstNonBlank(String... values) {
|
private static String firstNonBlank(String... values) {
|
||||||
for (String value : values) {
|
for (String value : values) {
|
||||||
if (value != null && !value.isBlank()) {
|
if (value != null && !value.isBlank()) {
|
||||||
@@ -216,11 +289,13 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 安全取 JSON 字段文本:缺失/null 返回空串。 */
|
||||||
private static String text(JsonNode node, String field) {
|
private static String text(JsonNode node, String field) {
|
||||||
JsonNode value = node == null ? null : node.get(field);
|
JsonNode value = node == null ? null : node.get(field);
|
||||||
return value == null || value.isNull() ? "" : value.asText("");
|
return value == null || value.isNull() ? "" : value.asText("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 截断到 max 字符(null 视为空串)。 */
|
||||||
private static String bounded(String value, int max) {
|
private static String bounded(String value, int max) {
|
||||||
String safe = value == null ? "" : value;
|
String safe = value == null ? "" : value;
|
||||||
return safe.length() <= max ? safe : safe.substring(0, max);
|
return safe.length() <= max ? safe : safe.substring(0, max);
|
||||||
|
|||||||
@@ -7,23 +7,60 @@ import java.util.LinkedHashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
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 {
|
public final class DiagnosisProgressTracker {
|
||||||
|
|
||||||
|
/** 连续 NO_GAIN 达到该阈值 → SATURATED + INFORMATION_SATURATED(默认 2)。 */
|
||||||
private final int stopAfterConsecutiveNoGain;
|
private final int stopAfterConsecutiveNoGain;
|
||||||
|
/** 连续 progress 协议违规达到该阈值 → SATURATED + PROGRESS_PROTOCOL_VIOLATED(默认 2)。 */
|
||||||
private final int stopAfterConsecutiveProgressProtocolViolations;
|
private final int stopAfterConsecutiveProgressProtocolViolations;
|
||||||
|
/** 已完成调用的去重集合:toolName + normalizedScope,backend 执行前判重。 */
|
||||||
private final Set<ToolScopeIdentity> completedScopes = new LinkedHashSet<>();
|
private final Set<ToolScopeIdentity> completedScopes = new LinkedHashSet<>();
|
||||||
|
/** 已完成调用的 identity 列表(无 payload),供结束时 Projector 回读 canonical。 */
|
||||||
private final List<CompletedToolCall> completedToolCalls = new ArrayList<>();
|
private final List<CompletedToolCall> completedToolCalls = new ArrayList<>();
|
||||||
|
/** 连续无增益次数;GAINED 清零。 */
|
||||||
private int consecutiveNoGain;
|
private int consecutiveNoGain;
|
||||||
|
/** 连续协议违规次数;一次合法评价(或无 pending 的合法调用)后清零。 */
|
||||||
private int consecutiveProgressProtocolViolations;
|
private int consecutiveProgressProtocolViolations;
|
||||||
|
/** 收集状态机:COLLECTING(可继续收集)→ SATURATED(已饱和,只能停止)。 */
|
||||||
private DiagnosisCollectionState collectionState = DiagnosisCollectionState.COLLECTING;
|
private DiagnosisCollectionState collectionState = DiagnosisCollectionState.COLLECTING;
|
||||||
|
/** 停止原因:INFORMATION_SATURATED / BUDGET_LIMIT_REACHED / PROGRESS_PROTOCOL_VIOLATED。 */
|
||||||
private DiagnosisStopReason stopReason;
|
private DiagnosisStopReason stopReason;
|
||||||
|
/** 等待模型评价的 tool_call_id;同一时刻最多一个 pending。 */
|
||||||
private String pendingToolCallId;
|
private String pendingToolCallId;
|
||||||
|
/** 一次 STOP_REQUIRED 指令是否已交付(claimStopInstruction 只成功一次)。 */
|
||||||
private boolean stopInstructionDelivered;
|
private boolean stopInstructionDelivered;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单参数构造:连续 NO_GAIN 阈值显式指定,协议违规阈值使用默认值 2。
|
||||||
|
*/
|
||||||
public DiagnosisProgressTracker(int stopAfterConsecutiveNoGain) {
|
public DiagnosisProgressTracker(int stopAfterConsecutiveNoGain) {
|
||||||
this(stopAfterConsecutiveNoGain, 2);
|
this(stopAfterConsecutiveNoGain, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全参构造:两个连续停止阈值都必须为正数(不允许 0 或负数)。
|
||||||
|
*
|
||||||
|
* @param stopAfterConsecutiveNoGain 连续 NO_GAIN 达到该次数即饱和
|
||||||
|
* @param stopAfterConsecutiveProgressProtocolViolations 连续协议违规达到该次数即饱和
|
||||||
|
*/
|
||||||
public DiagnosisProgressTracker(
|
public DiagnosisProgressTracker(
|
||||||
int stopAfterConsecutiveNoGain,
|
int stopAfterConsecutiveNoGain,
|
||||||
int stopAfterConsecutiveProgressProtocolViolations) {
|
int stopAfterConsecutiveProgressProtocolViolations) {
|
||||||
@@ -39,6 +76,22 @@ public final class DiagnosisProgressTracker {
|
|||||||
stopAfterConsecutiveProgressProtocolViolations;
|
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) {
|
public synchronized void applyPreviousObservation(PreviousObservation observation) {
|
||||||
if (pendingToolCallId == null) {
|
if (pendingToolCallId == null) {
|
||||||
if (observation != null) {
|
if (observation != null) {
|
||||||
@@ -48,6 +101,7 @@ public final class DiagnosisProgressTracker {
|
|||||||
"previous_observation",
|
"previous_observation",
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
|
// 没有 pending 且没带评价:正常(如首次调用),顺带清协议违规计数
|
||||||
clearProtocolViolations();
|
clearProtocolViolations();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -65,20 +119,40 @@ public final class DiagnosisProgressTracker {
|
|||||||
"previous_observation.tool_call_id",
|
"previous_observation.tool_call_id",
|
||||||
pendingToolCallId);
|
pendingToolCallId);
|
||||||
}
|
}
|
||||||
|
// 校验通过:清空 pending,评价生效
|
||||||
pendingToolCallId = null;
|
pendingToolCallId = null;
|
||||||
clearProtocolViolations();
|
clearProtocolViolations();
|
||||||
applyGain(observation.informationGain());
|
applyGain(observation.informationGain());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重复检测:toolName + normalizedScope 是否已被本 Run 完成过(backend 执行前调用)。
|
||||||
|
*/
|
||||||
public synchronized boolean isDuplicate(String toolName, String normalizedScope) {
|
public synchronized boolean isDuplicate(String toolName, String normalizedScope) {
|
||||||
return completedScopes.contains(new ToolScopeIdentity(toolName, normalizedScope));
|
return completedScopes.contains(new ToolScopeIdentity(toolName, normalizedScope));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一次被判重的调用:Harness 直接判定为 NO_GAIN(backend 未被调用)。
|
||||||
|
*/
|
||||||
public synchronized void recordDuplicateScope() {
|
public synchronized void recordDuplicateScope() {
|
||||||
clearProtocolViolations();
|
clearProtocolViolations();
|
||||||
applyGain(InformationGain.NO_GAIN);
|
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) {
|
public synchronized void recordCompleted(CompletedToolCall call, EvidenceStatus evidenceStatus) {
|
||||||
if (collectionState == DiagnosisCollectionState.SATURATED) {
|
if (collectionState == DiagnosisCollectionState.SATURATED) {
|
||||||
throw new IllegalStateException("Cannot record Tool completion after saturation");
|
throw new IllegalStateException("Cannot record Tool completion after saturation");
|
||||||
@@ -93,15 +167,24 @@ public final class DiagnosisProgressTracker {
|
|||||||
}
|
}
|
||||||
completedToolCalls.add(call);
|
completedToolCalls.add(call);
|
||||||
if (evidenceStatus == EvidenceStatus.NO_EVIDENCE) {
|
if (evidenceStatus == EvidenceStatus.NO_EVIDENCE) {
|
||||||
|
// 空结果无需模型评价:立即累计 NO_GAIN
|
||||||
clearProtocolViolations();
|
clearProtocolViolations();
|
||||||
applyGain(InformationGain.NO_GAIN);
|
applyGain(InformationGain.NO_GAIN);
|
||||||
} else {
|
} else {
|
||||||
|
// 非空结果:挂起等待模型在下一轮评价语义增益
|
||||||
pendingToolCallId = call.toolCallId();
|
pendingToolCallId = call.toolCallId();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一次 progress 协议违规,返回最新快照。
|
||||||
|
*
|
||||||
|
* <p>协议违规(缺评价/乱序/非法 Envelope)不计入 NO_GAIN——那是 Tool 执行了却没增益,
|
||||||
|
* 而违规时 backend 从未执行。连续违规达到独立阈值后进入 SATURATED。
|
||||||
|
*/
|
||||||
public synchronized DiagnosisProgressSnapshotState recordProgressProtocolViolation() {
|
public synchronized DiagnosisProgressSnapshotState recordProgressProtocolViolation() {
|
||||||
if (collectionState == DiagnosisCollectionState.SATURATED) {
|
if (collectionState == DiagnosisCollectionState.SATURATED) {
|
||||||
|
// 已饱和:不再累计,直接返回当前快照
|
||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
consecutiveProgressProtocolViolations++;
|
consecutiveProgressProtocolViolations++;
|
||||||
@@ -113,6 +196,13 @@ public final class DiagnosisProgressTracker {
|
|||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领取一次停止指令(STOP_REQUIRED)。
|
||||||
|
*
|
||||||
|
* <p>只有 SATURATED 且尚未交付过时返回 true——给模型一次合法完成机会(输出 Draft),
|
||||||
|
* 而不是立即抛错;之后模型仍请求 Tool 时由上层抛
|
||||||
|
* {@code DiagnosisCollectionStoppedException} 穿出框架 ReAct loop。
|
||||||
|
*/
|
||||||
public synchronized boolean claimStopInstruction() {
|
public synchronized boolean claimStopInstruction() {
|
||||||
if (collectionState != DiagnosisCollectionState.SATURATED) {
|
if (collectionState != DiagnosisCollectionState.SATURATED) {
|
||||||
return false;
|
return false;
|
||||||
@@ -124,12 +214,22 @@ public final class DiagnosisProgressTracker {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记预算触顶(由 RunBudget 侧调用)。
|
||||||
|
*
|
||||||
|
* <p>只在尚无 stopReason 时设置 BUDGET_LIMIT_REACHED,不覆盖已有的
|
||||||
|
* INFORMATION_SATURATED / PROGRESS_PROTOCOL_VIOLATED——三种停止原因必须分开,
|
||||||
|
* 信息饱和不能伪装成预算耗尽。
|
||||||
|
*/
|
||||||
public synchronized void markBudgetLimitReached() {
|
public synchronized void markBudgetLimitReached() {
|
||||||
if (stopReason == null) {
|
if (stopReason == null) {
|
||||||
stopReason = DiagnosisStopReason.BUDGET_LIMIT_REACHED;
|
stopReason = DiagnosisStopReason.BUDGET_LIMIT_REACHED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回内部控制快照(计数、pending、停止指令状态与已完成调用列表)。
|
||||||
|
*/
|
||||||
public synchronized DiagnosisProgressSnapshotState snapshot() {
|
public synchronized DiagnosisProgressSnapshotState snapshot() {
|
||||||
return new DiagnosisProgressSnapshotState(
|
return new DiagnosisProgressSnapshotState(
|
||||||
consecutiveNoGain,
|
consecutiveNoGain,
|
||||||
@@ -149,6 +249,14 @@ public final class DiagnosisProgressTracker {
|
|||||||
return stopAfterConsecutiveProgressProtocolViolations;
|
return stopAfterConsecutiveProgressProtocolViolations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用单次增益判定(核心状态迁移):
|
||||||
|
* <ul>
|
||||||
|
* <li>GAINED:清零连续 NO_GAIN——一次早期空查不能使后续有效取证被过早停止;</li>
|
||||||
|
* <li>NO_GAIN:累加,达到阈值 → SATURATED + INFORMATION_SATURATED。</li>
|
||||||
|
* </ul>
|
||||||
|
* 饱和后禁止再次应用(停止权只行使一次)。
|
||||||
|
*/
|
||||||
private void applyGain(InformationGain gain) {
|
private void applyGain(InformationGain gain) {
|
||||||
if (collectionState == DiagnosisCollectionState.SATURATED) {
|
if (collectionState == DiagnosisCollectionState.SATURATED) {
|
||||||
throw new IllegalStateException("Collection is already saturated");
|
throw new IllegalStateException("Collection is already saturated");
|
||||||
@@ -164,6 +272,10 @@ public final class DiagnosisProgressTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空协议违规计数:一次合法评价(或没有 pending 的合法调用)都会重置,
|
||||||
|
* 避免历史违规累积导致误饱和(协议违规只按「连续」计数)。
|
||||||
|
*/
|
||||||
private void clearProtocolViolations() {
|
private void clearProtocolViolations() {
|
||||||
consecutiveProgressProtocolViolations = 0;
|
consecutiveProgressProtocolViolations = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,41 @@ import com.superbiz.agent.harness.retry.RetryFailure;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.List;
|
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 {
|
public final class DiagnosisReleaseUseCase {
|
||||||
|
|
||||||
|
/** 验引用真实性:EvidenceGuard 机械校验 evidence_ref 是否真实可引用。 */
|
||||||
private final EvidenceGuard evidenceGuard;
|
private final EvidenceGuard evidenceGuard;
|
||||||
|
/** 引用修复:只修引用不修结论(证据安全链的一环)。 */
|
||||||
private final EvidenceRepair evidenceRepair;
|
private final EvidenceRepair evidenceRepair;
|
||||||
|
/** 结论支持度裁决:隔离判断结论是否被已验证证据支持。 */
|
||||||
private final SemanticGuard semanticGuard;
|
private final SemanticGuard semanticGuard;
|
||||||
|
/** 有界安全回退工厂:构造 INSUFFICIENT_EVIDENCE 等 FALLBACK。 */
|
||||||
private final SafeFallbackFactory fallbackFactory;
|
private final SafeFallbackFactory fallbackFactory;
|
||||||
|
/** Trace 记录器:release 阶段的决策事件(evidence/semantic/release)。 */
|
||||||
private final DiagnosisTraceRecorder traceRecorder;
|
private final DiagnosisTraceRecorder traceRecorder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 四参构造:Trace 记录器使用 noop(测试/无审计场景)。
|
||||||
|
*/
|
||||||
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
|
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
|
||||||
EvidenceRepair evidenceRepair,
|
EvidenceRepair evidenceRepair,
|
||||||
SemanticGuard semanticGuard,
|
SemanticGuard semanticGuard,
|
||||||
@@ -40,6 +67,9 @@ public final class DiagnosisReleaseUseCase {
|
|||||||
DiagnosisTraceRecorder.noop());
|
DiagnosisTraceRecorder.noop());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全参构造:五个依赖全部必填(null 直接 NPE 暴露配置错误)。
|
||||||
|
*/
|
||||||
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
|
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
|
||||||
EvidenceRepair evidenceRepair,
|
EvidenceRepair evidenceRepair,
|
||||||
SemanticGuard semanticGuard,
|
SemanticGuard semanticGuard,
|
||||||
@@ -53,12 +83,27 @@ public final class DiagnosisReleaseUseCase {
|
|||||||
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
|
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简化入口:正常收尾(模型输出了合法 Draft)时调用——
|
||||||
|
* 包装成 completed execution(progress 为空),走完整裁决。
|
||||||
|
*/
|
||||||
public DiagnosisReleaseResult execute(RunContext context, String query, DiagnosisDraft draft) {
|
public DiagnosisReleaseResult execute(RunContext context, String query, DiagnosisDraft draft) {
|
||||||
return execute(context, query, DiagnosisAgentExecution.completed(
|
return execute(context, query, DiagnosisAgentExecution.completed(
|
||||||
Objects.requireNonNull(draft, "draft must not be null"),
|
Objects.requireNonNull(draft, "draft must not be null"),
|
||||||
DiagnosisProgressSnapshot.empty()));
|
DiagnosisProgressSnapshot.empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主入口:按执行结果三分支裁决(对外结果的唯一出口)。
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>draft == null:受控停止(信息饱和 / 预算耗尽 / 协议违规),
|
||||||
|
* 只凭 progress 快照发布 INSUFFICIENT_EVIDENCE;</li>
|
||||||
|
* <li>conclusion == null:模型明确无结论,校验其引用后按
|
||||||
|
* 有无已验真事实 / missing_info 决定发布类型;</li>
|
||||||
|
* <li>有结论:走完整证据验证 + 语义裁决链。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
public DiagnosisReleaseResult execute(
|
public DiagnosisReleaseResult execute(
|
||||||
RunContext context, String query, DiagnosisAgentExecution execution) {
|
RunContext context, String query, DiagnosisAgentExecution execution) {
|
||||||
Objects.requireNonNull(context, "context must not be null");
|
Objects.requireNonNull(context, "context must not be null");
|
||||||
@@ -69,19 +114,27 @@ public final class DiagnosisReleaseUseCase {
|
|||||||
|
|
||||||
DiagnosisDraft draft = execution.draft();
|
DiagnosisDraft draft = execution.draft();
|
||||||
if (draft == null) {
|
if (draft == null) {
|
||||||
|
// 受控停止:没有 Draft,只能靠已完成检查的 progress 快照发布
|
||||||
return releaseControlledStop(context, execution.progress(), execution.stopReason());
|
return releaseControlledStop(context, execution.progress(), execution.stopReason());
|
||||||
}
|
}
|
||||||
if (draft.conclusion() == null) {
|
if (draft.conclusion() == null) {
|
||||||
|
// 无结论 Draft(含 conclusion=null 合法收尾):查引用 + 按进展发布
|
||||||
return releaseNoConclusion(context, draft, execution.progress());
|
return releaseNoConclusion(context, draft, execution.progress());
|
||||||
}
|
}
|
||||||
|
// 有结论 Draft:证据验证 →(必要时)修复 → 语义裁决
|
||||||
return releaseConclusion(context, query, draft);
|
return releaseConclusion(context, query, draft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非法 Draft 专用发布:模型输出不符合 Schema 时,若本 Run 已有可发布事实,
|
||||||
|
* 降级为 INSUFFICIENT_EVIDENCE Fallback(非法 draft 正文永不出现在对外 content)。
|
||||||
|
*/
|
||||||
public DiagnosisReleaseResult releaseInvalidDraft(
|
public DiagnosisReleaseResult releaseInvalidDraft(
|
||||||
RunContext context, DiagnosisProgressSnapshot progress) {
|
RunContext context, DiagnosisProgressSnapshot progress) {
|
||||||
Objects.requireNonNull(context, "context must not be null");
|
Objects.requireNonNull(context, "context must not be null");
|
||||||
Objects.requireNonNull(progress, "progress must not be null");
|
Objects.requireNonNull(progress, "progress must not be null");
|
||||||
if (!progress.hasObservedFacts()) {
|
if (!progress.hasObservedFacts()) {
|
||||||
|
// 无安全事实 → fail closed:调用方保持原异常(DiagnosisChatExecutor 里再上抛)
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"Invalid Diagnosis Draft has no verified publishable progress");
|
"Invalid Diagnosis Draft has no verified publishable progress");
|
||||||
}
|
}
|
||||||
@@ -90,6 +143,12 @@ public final class DiagnosisReleaseUseCase {
|
|||||||
FallbackType.INSUFFICIENT_EVIDENCE);
|
FallbackType.INSUFFICIENT_EVIDENCE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有结论 Draft 的完整发布链:
|
||||||
|
* ① EvidenceGuard 验引用真实性 → ② 不过则 EvidenceRepair 只修引用再验
|
||||||
|
* → ③ SemanticGuard 裁决结论支持度 → SUPPORTED ? SUCCESS : SEMANTIC_UNSUPPORTED FALLBACK。
|
||||||
|
* 任何环节的终态异常(取消/预算)向上传播,不吞掉。
|
||||||
|
*/
|
||||||
private DiagnosisReleaseResult releaseConclusion(
|
private DiagnosisReleaseResult releaseConclusion(
|
||||||
RunContext context, String query, DiagnosisDraft draft) {
|
RunContext context, String query, DiagnosisDraft draft) {
|
||||||
DiagnosisDraft candidate = draft;
|
DiagnosisDraft candidate = draft;
|
||||||
@@ -98,6 +157,7 @@ public final class DiagnosisReleaseUseCase {
|
|||||||
context, TraceEventType.EVIDENCE_GUARD_INITIAL, evidence, candidate));
|
context, TraceEventType.EVIDENCE_GUARD_INITIAL, evidence, candidate));
|
||||||
if (!evidence.valid()) {
|
if (!evidence.valid()) {
|
||||||
try {
|
try {
|
||||||
|
// 引用不真实:只修引用(EvidenceRepair),不替模型改结论
|
||||||
candidate = evidenceRepair.repair(context, query, draft, evidence.violations());
|
candidate = evidenceRepair.repair(context, query, draft, evidence.violations());
|
||||||
evidence = evidenceGuard.validate(context, candidate);
|
evidence = evidenceGuard.validate(context, candidate);
|
||||||
traceRecorder.record(TraceAuditEvents.evidenceValidation(
|
traceRecorder.record(TraceAuditEvents.evidenceValidation(
|
||||||
@@ -107,6 +167,7 @@ public final class DiagnosisReleaseUseCase {
|
|||||||
return evidenceFailure(context, evidence);
|
return evidenceFailure(context, evidence);
|
||||||
}
|
}
|
||||||
if (!evidence.valid()) {
|
if (!evidence.valid()) {
|
||||||
|
// 修复后仍不真实 → 证据验证失败 Fallback(不发布模型原文结论)
|
||||||
return evidenceFailure(context, evidence);
|
return evidenceFailure(context, evidence);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,6 +178,7 @@ public final class DiagnosisReleaseUseCase {
|
|||||||
decision = semanticGuard.review(
|
decision = semanticGuard.review(
|
||||||
context, SemanticGuardInput.from(query, candidate, snapshot));
|
context, SemanticGuardInput.from(query, candidate, snapshot));
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
|
// 语义裁决不可用(如模型超时)→ 降级为 SEMANTIC_UNAVAILABLE Fallback
|
||||||
propagateTerminal(exception);
|
propagateTerminal(exception);
|
||||||
traceRecorder.record(TraceAuditEvents.semanticUnavailable(context));
|
traceRecorder.record(TraceAuditEvents.semanticUnavailable(context));
|
||||||
traceRecorder.record(TraceAuditEvents.releaseDecision(
|
traceRecorder.record(TraceAuditEvents.releaseDecision(
|
||||||
@@ -127,16 +189,25 @@ public final class DiagnosisReleaseUseCase {
|
|||||||
}
|
}
|
||||||
traceRecorder.record(TraceAuditEvents.semanticDecision(context, decision.verdict()));
|
traceRecorder.record(TraceAuditEvents.semanticDecision(context, decision.verdict()));
|
||||||
if (decision.verdict() == SemanticVerdict.SUPPORTED) {
|
if (decision.verdict() == SemanticVerdict.SUPPORTED) {
|
||||||
|
// 引用真实 + 结论被支持 → 唯一的 SUCCESS 出口
|
||||||
traceRecorder.record(TraceAuditEvents.releaseDecision(
|
traceRecorder.record(TraceAuditEvents.releaseDecision(
|
||||||
context, com.superbiz.agent.harness.contract.ReleaseOutcome.SUCCESS, null));
|
context, com.superbiz.agent.harness.contract.ReleaseOutcome.SUCCESS, null));
|
||||||
return DiagnosisReleaseResult.success(candidate, snapshot);
|
return DiagnosisReleaseResult.success(candidate, snapshot);
|
||||||
}
|
}
|
||||||
|
// 引用真实但结论不支持 → 语义不支持 Fallback(保留已验证快照)
|
||||||
traceRecorder.record(TraceAuditEvents.releaseDecision(
|
traceRecorder.record(TraceAuditEvents.releaseDecision(
|
||||||
context, com.superbiz.agent.harness.contract.ReleaseOutcome.FALLBACK,
|
context, com.superbiz.agent.harness.contract.ReleaseOutcome.FALLBACK,
|
||||||
FallbackType.SEMANTIC_UNSUPPORTED));
|
FallbackType.SEMANTIC_UNSUPPORTED));
|
||||||
return DiagnosisReleaseResult.fallback(fallbackFactory.semanticUnsupported(snapshot));
|
return DiagnosisReleaseResult.fallback(fallbackFactory.semanticUnsupported(snapshot));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无结论 Draft 路径(conclusion=null 是合法收尾,不是失败):
|
||||||
|
* 先验引用,再按「有无已验真事实 / 是否声明缺失上下文」发布:
|
||||||
|
* 有事实 → INSUFFICIENT_EVIDENCE(展示已查内容 + 缺失项);
|
||||||
|
* 无事实但声明 missing_info → MISSING_REQUIRED_CONTEXT;
|
||||||
|
* 都没有 → 不变量被破坏,fail closed 抛异常。
|
||||||
|
*/
|
||||||
private DiagnosisReleaseResult releaseNoConclusion(
|
private DiagnosisReleaseResult releaseNoConclusion(
|
||||||
RunContext context, DiagnosisDraft draft, DiagnosisProgressSnapshot progress) {
|
RunContext context, DiagnosisDraft draft, DiagnosisProgressSnapshot progress) {
|
||||||
EvidenceGuardResult evidence = evidenceGuard.validateNoConclusionReferences(context, draft);
|
EvidenceGuardResult evidence = evidenceGuard.validateNoConclusionReferences(context, draft);
|
||||||
@@ -148,19 +219,27 @@ public final class DiagnosisReleaseUseCase {
|
|||||||
|
|
||||||
List<String> missingInfo = missingInfo(draft);
|
List<String> missingInfo = missingInfo(draft);
|
||||||
if (progress.hasObservedFacts()) {
|
if (progress.hasObservedFacts()) {
|
||||||
|
// 已查过一些内容:诚实展示「查了什么、都是空的」+ 下一步所需信息
|
||||||
return progressFallback(context,
|
return progressFallback(context,
|
||||||
fallbackFactory.insufficientEvidence(progress, missingInfo),
|
fallbackFactory.insufficientEvidence(progress, missingInfo),
|
||||||
FallbackType.INSUFFICIENT_EVIDENCE);
|
FallbackType.INSUFFICIENT_EVIDENCE);
|
||||||
}
|
}
|
||||||
if (!missingInfo.isEmpty()) {
|
if (!missingInfo.isEmpty()) {
|
||||||
|
// 零 Tool 直接声明缺上下文:合法,不强制空查
|
||||||
return progressFallback(context,
|
return progressFallback(context,
|
||||||
fallbackFactory.missingRequiredContext(missingInfo),
|
fallbackFactory.missingRequiredContext(missingInfo),
|
||||||
FallbackType.MISSING_REQUIRED_CONTEXT);
|
FallbackType.MISSING_REQUIRED_CONTEXT);
|
||||||
}
|
}
|
||||||
|
// 既无进展又无缺失声明 → 状态非法,fail closed
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"No-conclusion Diagnosis has neither verified progress nor missing context");
|
"No-conclusion Diagnosis has neither verified progress nor missing context");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 受控停止路径(draft == null 时进入):三种停止原因(信息饱和 / 预算 / 协议违规)
|
||||||
|
* 都必须有已验真事实才能发布 INSUFFICIENT_EVIDENCE;
|
||||||
|
* 无安全进展 → fail closed(不能把「没查到」伪装成业务结果)。
|
||||||
|
*/
|
||||||
private DiagnosisReleaseResult releaseControlledStop(
|
private DiagnosisReleaseResult releaseControlledStop(
|
||||||
RunContext context,
|
RunContext context,
|
||||||
DiagnosisProgressSnapshot progress,
|
DiagnosisProgressSnapshot progress,
|
||||||
@@ -171,14 +250,19 @@ public final class DiagnosisReleaseUseCase {
|
|||||||
throw new IllegalStateException("Unsupported Diagnosis stop reason");
|
throw new IllegalStateException("Unsupported Diagnosis stop reason");
|
||||||
}
|
}
|
||||||
if (!progress.hasObservedFacts()) {
|
if (!progress.hasObservedFacts()) {
|
||||||
|
// 没有已验证进展 → fail closed(对外不可发布任何结论)
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"Controlled Diagnosis stop has no verified publishable progress");
|
"Controlled Diagnosis stop has no verified publishable progress");
|
||||||
}
|
}
|
||||||
|
// 有进展:把「已查过这些、都无增益」作为诚实的业务结果发布
|
||||||
return progressFallback(context,
|
return progressFallback(context,
|
||||||
fallbackFactory.insufficientEvidence(progress, List.of()),
|
fallbackFactory.insufficientEvidence(progress, List.of()),
|
||||||
FallbackType.INSUFFICIENT_EVIDENCE);
|
FallbackType.INSUFFICIENT_EVIDENCE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的 FALLBACK 出口:记录 release 决策 Trace 并返回安全回退结果。
|
||||||
|
*/
|
||||||
private DiagnosisReleaseResult progressFallback(
|
private DiagnosisReleaseResult progressFallback(
|
||||||
RunContext context,
|
RunContext context,
|
||||||
com.superbiz.agent.harness.contract.SafeFallback fallback,
|
com.superbiz.agent.harness.contract.SafeFallback fallback,
|
||||||
@@ -188,11 +272,18 @@ public final class DiagnosisReleaseUseCase {
|
|||||||
return DiagnosisReleaseResult.fallback(fallback);
|
return DiagnosisReleaseResult.fallback(fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取 Draft 声明的缺失上下文(limitations.missingInfo),供发布类型判定。
|
||||||
|
*/
|
||||||
private List<String> missingInfo(DiagnosisDraft draft) {
|
private List<String> missingInfo(DiagnosisDraft draft) {
|
||||||
return draft.limitations() == null
|
return draft.limitations() == null
|
||||||
? List.of() : draft.limitations().missingInfo();
|
? List.of() : draft.limitations().missingInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 证据验证失败出口:引用无法验真 → EVIDENCE_VALIDATION_FAILED Fallback
|
||||||
|
* (违规明细进 fallback,不发布模型原文)。
|
||||||
|
*/
|
||||||
private DiagnosisReleaseResult evidenceFailure(
|
private DiagnosisReleaseResult evidenceFailure(
|
||||||
RunContext context, EvidenceGuardResult evidence) {
|
RunContext context, EvidenceGuardResult evidence) {
|
||||||
traceRecorder.record(TraceAuditEvents.releaseDecision(
|
traceRecorder.record(TraceAuditEvents.releaseDecision(
|
||||||
@@ -202,6 +293,12 @@ public final class DiagnosisReleaseUseCase {
|
|||||||
fallbackFactory.evidenceValidationFailed(evidence.violations()));
|
fallbackFactory.evidenceValidationFailed(evidence.violations()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 终态异常透传:取消(RunAbortedException / RetryFailure.CANCELLED)和
|
||||||
|
* 预算耗尽(BudgetExceededException / RetryFailure.BUDGET_EXHAUSTED)不能被
|
||||||
|
* release 吞掉——它们是 Run 的终态事实,必须向上传播到 Application 层。
|
||||||
|
* 其余运行时异常(修复/裁决的内部失败)则不拦截,由调用方按降级处理。
|
||||||
|
*/
|
||||||
private void propagateTerminal(RuntimeException exception) {
|
private void propagateTerminal(RuntimeException exception) {
|
||||||
if (exception instanceof RunAbortedException
|
if (exception instanceof RunAbortedException
|
||||||
|| exception instanceof BudgetExceededException) {
|
|| exception instanceof BudgetExceededException) {
|
||||||
|
|||||||
@@ -25,18 +25,46 @@ import java.time.Duration;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Objects;
|
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 {
|
public final class ToolBoundary {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(ToolBoundary.class);
|
private static final Logger log = LoggerFactory.getLogger(ToolBoundary.class);
|
||||||
|
|
||||||
|
/** Harness 核心:Run bytes 预留(reserveRunBytes)、Tool 预算检查(beforeToolCall)。 */
|
||||||
private final DiagnosisHarnessCore core;
|
private final DiagnosisHarnessCore core;
|
||||||
|
/** canonical key 生成(runId + toolCallId)。 */
|
||||||
private final ToolCallKeyFactory keyFactory;
|
private final ToolCallKeyFactory keyFactory;
|
||||||
|
/** canonical 持久化(唯一真相源)。 */
|
||||||
private final CanonicalInvocationStore store;
|
private final CanonicalInvocationStore store;
|
||||||
|
/** request JSON 合法性校验。 */
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
/** 时间戳(begin/ready/error/audit 统一时钟)。 */
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
/** 持久化审计 sink(可 noop)。 */
|
||||||
private final ToolInvocationAuditSink auditSink;
|
private final ToolInvocationAuditSink auditSink;
|
||||||
|
/** 当前 step id 追踪(可 null:无 step 场景不记录)。 */
|
||||||
private final AgentStepAuditTracker stepTracker;
|
private final AgentStepAuditTracker stepTracker;
|
||||||
|
|
||||||
|
/** 最简构造:审计 noop + stepTracker null(测试/轻量场景)。 */
|
||||||
public ToolBoundary(DiagnosisHarnessCore core,
|
public ToolBoundary(DiagnosisHarnessCore core,
|
||||||
ToolCallKeyFactory keyFactory,
|
ToolCallKeyFactory keyFactory,
|
||||||
CanonicalInvocationStore store,
|
CanonicalInvocationStore store,
|
||||||
@@ -45,6 +73,7 @@ public final class ToolBoundary {
|
|||||||
this(core, keyFactory, store, objectMapper, clock, ToolInvocationAuditSink.noop(), null);
|
this(core, keyFactory, store, objectMapper, clock, ToolInvocationAuditSink.noop(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 带审计构造:持久化 Tool 审计,无 stepTracker。 */
|
||||||
public ToolBoundary(DiagnosisHarnessCore core,
|
public ToolBoundary(DiagnosisHarnessCore core,
|
||||||
ToolCallKeyFactory keyFactory,
|
ToolCallKeyFactory keyFactory,
|
||||||
CanonicalInvocationStore store,
|
CanonicalInvocationStore store,
|
||||||
@@ -54,6 +83,7 @@ public final class ToolBoundary {
|
|||||||
this(core, keyFactory, store, objectMapper, clock, auditSink, null);
|
this(core, keyFactory, store, objectMapper, clock, auditSink, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 全参构造:七个依赖全部必填(null 直接 NPE 暴露装配错误)。 */
|
||||||
public ToolBoundary(DiagnosisHarnessCore core,
|
public ToolBoundary(DiagnosisHarnessCore core,
|
||||||
ToolCallKeyFactory keyFactory,
|
ToolCallKeyFactory keyFactory,
|
||||||
CanonicalInvocationStore store,
|
CanonicalInvocationStore store,
|
||||||
@@ -70,6 +100,12 @@ public final class ToolBoundary {
|
|||||||
this.stepTracker = stepTracker;
|
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,
|
public ToolBoundaryResult execute(RunContext context,
|
||||||
ToolCallRequestEnvelope request,
|
ToolCallRequestEnvelope request,
|
||||||
ToolExecutor executor,
|
ToolExecutor executor,
|
||||||
@@ -80,6 +116,11 @@ public final class ToolBoundary {
|
|||||||
return outcome.result();
|
return outcome.result();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* canonical 状态机主流程:所有成功/失败路径都映射为
|
||||||
|
* ToolBoundaryResult.ready / ToolBoundaryResult.error,中间状态不外泄;
|
||||||
|
* 任何异常都先尝试 markError 落库(best-effort),再返回稳定错误码。
|
||||||
|
*/
|
||||||
private ExecutionOutcome executeCanonical(RunContext context,
|
private ExecutionOutcome executeCanonical(RunContext context,
|
||||||
ToolCallRequestEnvelope request,
|
ToolCallRequestEnvelope request,
|
||||||
ToolExecutor executor,
|
ToolExecutor executor,
|
||||||
@@ -87,10 +128,12 @@ public final class ToolBoundary {
|
|||||||
String toolCallId = request == null ? null : request.toolCallId();
|
String toolCallId = request == null ? null : request.toolCallId();
|
||||||
String key;
|
String key;
|
||||||
try {
|
try {
|
||||||
|
// ── 阶段一:preflight + Tool 预算 + request bytes 预留,通过后写 PROJECTING ──
|
||||||
key = preflight(context, request);
|
key = preflight(context, request);
|
||||||
core.beforeToolCall(context, request.toolName());
|
core.beforeToolCall(context, request.toolName());
|
||||||
long requestBytes = store.limits().utf8Bytes(request.requestJson());
|
long requestBytes = store.limits().utf8Bytes(request.requestJson());
|
||||||
if (requestBytes > store.limits().maxRecordBytes()) {
|
if (requestBytes > store.limits().maxRecordBytes()) {
|
||||||
|
// 请求体超限:不落库直接拒绝
|
||||||
return ExecutionOutcome.of(errorAndNoRecord(toolCallId, ToolBoundaryErrorCode.RESULT_TOO_LARGE));
|
return ExecutionOutcome.of(errorAndNoRecord(toolCallId, ToolBoundaryErrorCode.RESULT_TOO_LARGE));
|
||||||
}
|
}
|
||||||
core.reserveRunBytes(context, requestBytes);
|
core.reserveRunBytes(context, requestBytes);
|
||||||
@@ -98,18 +141,22 @@ public final class ToolBoundary {
|
|||||||
request.toolCallId(), request.runId(), request.toolName(),
|
request.toolCallId(), request.runId(), request.toolName(),
|
||||||
request.requestJson(), clock.instant()));
|
request.requestJson(), clock.instant()));
|
||||||
} catch (DuplicateInvocationException e) {
|
} catch (DuplicateInvocationException e) {
|
||||||
|
// 同 key 重复 begin(同一 run+toolCallId 调两次)→ 拒绝
|
||||||
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.DUPLICATE_TOOL_CALL));
|
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.DUPLICATE_TOOL_CALL));
|
||||||
} catch (RunAbortedException | BudgetExceededException e) {
|
} catch (RunAbortedException | BudgetExceededException e) {
|
||||||
|
// Run 已取消/预算耗尽:对应终态错误码
|
||||||
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId,
|
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId,
|
||||||
e instanceof BudgetExceededException
|
e instanceof BudgetExceededException
|
||||||
? ToolBoundaryErrorCode.BUDGET_EXHAUSTED
|
? ToolBoundaryErrorCode.BUDGET_EXHAUSTED
|
||||||
: ToolBoundaryErrorCode.RUN_INACTIVE));
|
: ToolBoundaryErrorCode.RUN_INACTIVE));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
|
// preflight 非法:按异常类型归类错误码
|
||||||
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, classifyPreflightError(e)));
|
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, classifyPreflightError(e)));
|
||||||
} catch (CanonicalStoreException e) {
|
} catch (CanonicalStoreException e) {
|
||||||
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.STORE_ERROR));
|
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.STORE_ERROR));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 阶段二:真正执行 backend(raw 响应)──
|
||||||
String rawResponse;
|
String rawResponse;
|
||||||
try {
|
try {
|
||||||
rawResponse = Objects.requireNonNull(executor, "executor must not be null")
|
rawResponse = Objects.requireNonNull(executor, "executor must not be null")
|
||||||
@@ -118,10 +165,12 @@ public final class ToolBoundary {
|
|||||||
throw new IllegalArgumentException("executor returned null");
|
throw new IllegalArgumentException("executor returned null");
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
// backend 执行失败:落 ERROR(无 raw)
|
||||||
markErrorSafely(key, null, ToolBoundaryErrorCode.TOOL_EXECUTION_ERROR);
|
markErrorSafely(key, null, ToolBoundaryErrorCode.TOOL_EXECUTION_ERROR);
|
||||||
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.TOOL_EXECUTION_ERROR));
|
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.TOOL_EXECUTION_ERROR));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 阶段三:raw 大小校验 + Run bytes 预留 ──
|
||||||
try {
|
try {
|
||||||
store.limits().validateRawCandidate(request.requestJson(), rawResponse);
|
store.limits().validateRawCandidate(request.requestJson(), rawResponse);
|
||||||
core.reserveRunBytes(context, store.limits().utf8Bytes(rawResponse));
|
core.reserveRunBytes(context, store.limits().utf8Bytes(rawResponse));
|
||||||
@@ -139,6 +188,7 @@ public final class ToolBoundary {
|
|||||||
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE), rawResponse);
|
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE), rawResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 阶段四:投影(raw → 有界 agent_result,Projector 计算 evidence status)──
|
||||||
ProjectedToolResult projected;
|
ProjectedToolResult projected;
|
||||||
try {
|
try {
|
||||||
projected = Objects.requireNonNull(projector, "projector must not be null")
|
projected = Objects.requireNonNull(projector, "projector must not be null")
|
||||||
@@ -147,11 +197,13 @@ public final class ToolBoundary {
|
|||||||
throw new IllegalArgumentException("projector returned null");
|
throw new IllegalArgumentException("projector returned null");
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
// 投影失败(raw 无法解析等):落 ERROR
|
||||||
markErrorSafely(key, rawResponse, ToolBoundaryErrorCode.PROJECTION_ERROR);
|
markErrorSafely(key, rawResponse, ToolBoundaryErrorCode.PROJECTION_ERROR);
|
||||||
return new ExecutionOutcome(
|
return new ExecutionOutcome(
|
||||||
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.PROJECTION_ERROR), rawResponse);
|
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.PROJECTION_ERROR), rawResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 阶段五:agent_result 校验 + bytes 预留 → 迁移 READY(唯一成功出口)──
|
||||||
try {
|
try {
|
||||||
store.limits().validateAgentResult(projected.agentResult());
|
store.limits().validateAgentResult(projected.agentResult());
|
||||||
core.reserveRunBytes(context, store.limits().utf8Bytes(projected.agentResult()));
|
core.reserveRunBytes(context, store.limits().utf8Bytes(projected.agentResult()));
|
||||||
@@ -172,6 +224,7 @@ public final class ToolBoundary {
|
|||||||
return new ExecutionOutcome(
|
return new ExecutionOutcome(
|
||||||
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE), rawResponse);
|
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE), rawResponse);
|
||||||
} catch (CanonicalStoreException e) {
|
} catch (CanonicalStoreException e) {
|
||||||
|
// store 持久化失败:错误码归类(结果过大 vs 投影问题)
|
||||||
ToolBoundaryErrorCode code = e instanceof ResultTooLargeException
|
ToolBoundaryErrorCode code = e instanceof ResultTooLargeException
|
||||||
? ToolBoundaryErrorCode.RESULT_TOO_LARGE
|
? ToolBoundaryErrorCode.RESULT_TOO_LARGE
|
||||||
: ToolBoundaryErrorCode.PROJECTION_ERROR;
|
: ToolBoundaryErrorCode.PROJECTION_ERROR;
|
||||||
@@ -180,17 +233,24 @@ public final class ToolBoundary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行前门禁:run 匹配 / 授权 / 只读意图 / 字段与 JSON 合法性 / key 生成。
|
||||||
|
* 任何一项不过都会抛特定异常,由调用方归类为稳定错误码。
|
||||||
|
*/
|
||||||
private String preflight(RunContext context, ToolCallRequestEnvelope request) {
|
private String preflight(RunContext context, ToolCallRequestEnvelope request) {
|
||||||
if (context == null || request == null) {
|
if (context == null || request == null) {
|
||||||
throw new IllegalArgumentException("request/context must not be null");
|
throw new IllegalArgumentException("request/context must not be null");
|
||||||
}
|
}
|
||||||
if (!context.runId().equals(request.runId())) {
|
if (!context.runId().equals(request.runId())) {
|
||||||
|
// 信封 run 与当前 context 不一致 → RUN_MISMATCH
|
||||||
throw new RunMismatchException();
|
throw new RunMismatchException();
|
||||||
}
|
}
|
||||||
if (!request.authorized()) {
|
if (!request.authorized()) {
|
||||||
|
// 未授权调用 → UNAUTHORIZED
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
if (!request.readOnly()) {
|
if (!request.readOnly()) {
|
||||||
|
// 非只读意图(诊断 Tool 必须是只读)→ NOT_READ_ONLY
|
||||||
throw new NotReadOnlyException();
|
throw new NotReadOnlyException();
|
||||||
}
|
}
|
||||||
if (request.toolName() == null || request.toolName().isBlank()
|
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");
|
throw new IllegalArgumentException("tool name and request must not be blank");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// request 必须是合法 JSON 对象
|
||||||
JsonNode root = objectMapper.readTree(request.requestJson());
|
JsonNode root = objectMapper.readTree(request.requestJson());
|
||||||
if (root == null || !root.isObject()) {
|
if (root == null || !root.isObject()) {
|
||||||
throw new IllegalArgumentException("request must be a JSON object");
|
throw new IllegalArgumentException("request must be a JSON object");
|
||||||
@@ -208,10 +269,14 @@ public final class ToolBoundary {
|
|||||||
try {
|
try {
|
||||||
return keyFactory.create(request.runId(), request.toolCallId());
|
return keyFactory.create(request.runId(), request.toolCallId());
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
|
// toolCallId 非法 → INVALID_TOOL_CALL_ID
|
||||||
throw new InvalidToolCallIdException(e);
|
throw new InvalidToolCallIdException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全落 ERROR(best-effort):持久化失败只记 warn 日志,不阻断返回错误结果。
|
||||||
|
*/
|
||||||
private void markErrorSafely(String key, String rawResponse, ToolBoundaryErrorCode code) {
|
private void markErrorSafely(String key, String rawResponse, ToolBoundaryErrorCode code) {
|
||||||
try {
|
try {
|
||||||
store.markError(key, rawResponse, code.name(), clock.instant());
|
store.markError(key, rawResponse, code.name(), clock.instant());
|
||||||
@@ -220,10 +285,12 @@ public final class ToolBoundary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 请求体超限等「无需落库」的错误出口(尚未 begin,无记录可标记)。 */
|
||||||
private ToolBoundaryResult errorAndNoRecord(String toolCallId, ToolBoundaryErrorCode code) {
|
private ToolBoundaryResult errorAndNoRecord(String toolCallId, ToolBoundaryErrorCode code) {
|
||||||
return ToolBoundaryResult.error(toolCallId, code);
|
return ToolBoundaryResult.error(toolCallId, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 把 preflight 抛出的 IllegalArgumentException 归类为稳定错误码。 */
|
||||||
private ToolBoundaryErrorCode classifyPreflightError(IllegalArgumentException exception) {
|
private ToolBoundaryErrorCode classifyPreflightError(IllegalArgumentException exception) {
|
||||||
if (exception instanceof InvalidToolCallIdException) {
|
if (exception instanceof InvalidToolCallIdException) {
|
||||||
return ToolBoundaryErrorCode.INVALID_TOOL_CALL_ID;
|
return ToolBoundaryErrorCode.INVALID_TOOL_CALL_ID;
|
||||||
@@ -240,6 +307,10 @@ public final class ToolBoundary {
|
|||||||
return ToolBoundaryErrorCode.INVALID_REQUEST;
|
return ToolBoundaryErrorCode.INVALID_REQUEST;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持久化 Tool 审计事件(best-effort):记录调用元数据(status/耗时/bytes/enrichments),
|
||||||
|
* 不保存完整 request/raw/agent result(敏感正文不进审计);失败只记 warn 不阻断业务。
|
||||||
|
*/
|
||||||
private void auditSafely(RunContext context,
|
private void auditSafely(RunContext context,
|
||||||
ToolCallRequestEnvelope request,
|
ToolCallRequestEnvelope request,
|
||||||
ToolBoundaryResult result,
|
ToolBoundaryResult result,
|
||||||
@@ -268,38 +339,45 @@ public final class ToolBoundary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** UTF-8 字节数(null 视为 0),用于 bytes 预算与审计。 */
|
||||||
private static int utf8Bytes(String value) {
|
private static int utf8Bytes(String value) {
|
||||||
return value == null ? 0 : saturatingInt(value.getBytes(StandardCharsets.UTF_8).length);
|
return value == null ? 0 : saturatingInt(value.getBytes(StandardCharsets.UTF_8).length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 防溢出的 int 封顶转换。 */
|
||||||
private static int saturatingInt(long value) {
|
private static int saturatingInt(long value) {
|
||||||
return value >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) value;
|
return value >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 执行结果 + 原始响应(审计用),rawResponse 仅成功/已产出时携带。 */
|
||||||
private record ExecutionOutcome(ToolBoundaryResult result, String rawResponse) {
|
private record ExecutionOutcome(ToolBoundaryResult result, String rawResponse) {
|
||||||
static ExecutionOutcome of(ToolBoundaryResult result) {
|
static ExecutionOutcome of(ToolBoundaryResult result) {
|
||||||
return new ExecutionOutcome(result, null);
|
return new ExecutionOutcome(result, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** preflight 专用异常:toolCallId 非法。 */
|
||||||
private static final class InvalidToolCallIdException extends IllegalArgumentException {
|
private static final class InvalidToolCallIdException extends IllegalArgumentException {
|
||||||
private InvalidToolCallIdException(Throwable cause) {
|
private InvalidToolCallIdException(Throwable cause) {
|
||||||
super("Invalid tool call ID", cause);
|
super("Invalid tool call ID", cause);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** preflight 专用异常:信封 runId 与 context 不一致。 */
|
||||||
private static final class RunMismatchException extends IllegalArgumentException {
|
private static final class RunMismatchException extends IllegalArgumentException {
|
||||||
private RunMismatchException() {
|
private RunMismatchException() {
|
||||||
super("Run ID does not match context");
|
super("Run ID does not match context");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** preflight 专用异常:调用未授权。 */
|
||||||
private static final class UnauthorizedException extends IllegalArgumentException {
|
private static final class UnauthorizedException extends IllegalArgumentException {
|
||||||
private UnauthorizedException() {
|
private UnauthorizedException() {
|
||||||
super("Tool call is not authorized");
|
super("Tool call is not authorized");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** preflight 专用异常:意图非只读(诊断 Tool 只读红线)。 */
|
||||||
private static final class NotReadOnlyException extends IllegalArgumentException {
|
private static final class NotReadOnlyException extends IllegalArgumentException {
|
||||||
private NotReadOnlyException() {
|
private NotReadOnlyException() {
|
||||||
super("Tool call is not read-only");
|
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.EvidenceStatus;
|
||||||
import com.superbiz.agent.harness.contract.InvocationStatus;
|
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(
|
public record ToolBoundaryResult(
|
||||||
@JsonProperty("status") InvocationStatus status,
|
@JsonProperty("status") InvocationStatus status,
|
||||||
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
|
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
|
||||||
@@ -12,6 +29,7 @@ public record ToolBoundaryResult(
|
|||||||
@JsonProperty("error_code") String errorCode) {
|
@JsonProperty("error_code") String errorCode) {
|
||||||
|
|
||||||
public ToolBoundaryResult {
|
public ToolBoundaryResult {
|
||||||
|
// READY:必须有界证据结果(agent_result + FOUND/NO_EVIDENCE),禁止带错误码
|
||||||
if (status == InvocationStatus.READY) {
|
if (status == InvocationStatus.READY) {
|
||||||
if (agentResult == null || evidenceStatus == null
|
if (agentResult == null || evidenceStatus == null
|
||||||
|| (evidenceStatus != EvidenceStatus.EVIDENCE_FOUND
|
|| (evidenceStatus != EvidenceStatus.EVIDENCE_FOUND
|
||||||
@@ -22,6 +40,7 @@ public record ToolBoundaryResult(
|
|||||||
throw new IllegalArgumentException("READY result must not contain errorCode");
|
throw new IllegalArgumentException("READY result must not contain errorCode");
|
||||||
}
|
}
|
||||||
} else if (status == InvocationStatus.ERROR) {
|
} else if (status == InvocationStatus.ERROR) {
|
||||||
|
// ERROR:稳定错误码必填 + evidence 必须 ERROR,禁止带投影结果
|
||||||
if (evidenceStatus != EvidenceStatus.ERROR || errorCode == null || errorCode.isBlank()) {
|
if (evidenceStatus != EvidenceStatus.ERROR || errorCode == null || errorCode.isBlank()) {
|
||||||
throw new IllegalArgumentException("ERROR result requires errorCode and ERROR evidence status");
|
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");
|
throw new IllegalArgumentException("ERROR result must not contain agent result");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// PROJECTING 等中间状态不允许离开 Boundary
|
||||||
throw new IllegalArgumentException("ToolBoundaryResult must be READY or ERROR");
|
throw new IllegalArgumentException("ToolBoundaryResult must be READY or ERROR");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* READY 出口:投影成功后调用,携带有界 agent_result 与客观证据语义
|
||||||
|
* (evidence 由 Projector 按「证据数组空不空」计算)。
|
||||||
|
*/
|
||||||
public static ToolBoundaryResult ready(String toolCallId,
|
public static ToolBoundaryResult ready(String toolCallId,
|
||||||
String agentResult,
|
String agentResult,
|
||||||
EvidenceStatus evidenceStatus) {
|
EvidenceStatus evidenceStatus) {
|
||||||
@@ -40,6 +64,9 @@ public record ToolBoundaryResult(
|
|||||||
InvocationStatus.READY, evidenceStatus, toolCallId, agentResult, null);
|
InvocationStatus.READY, evidenceStatus, toolCallId, agentResult, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERROR 出口:失败时调用,携带稳定错误码(不携带 raw/agent_result)。
|
||||||
|
*/
|
||||||
public static ToolBoundaryResult error(String toolCallId, ToolBoundaryErrorCode errorCode) {
|
public static ToolBoundaryResult error(String toolCallId, ToolBoundaryErrorCode errorCode) {
|
||||||
return new ToolBoundaryResult(
|
return new ToolBoundaryResult(
|
||||||
InvocationStatus.ERROR, EvidenceStatus.ERROR, toolCallId, null, errorCode.name());
|
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.time.Instant;
|
||||||
import java.util.Objects;
|
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(
|
public record CanonicalToolInvocation(
|
||||||
@JsonProperty("tool_call_id") String toolCallId,
|
@JsonProperty("tool_call_id") String toolCallId,
|
||||||
@JsonProperty("run_id") String runId,
|
@JsonProperty("run_id") String runId,
|
||||||
@@ -21,15 +43,21 @@ public record CanonicalToolInvocation(
|
|||||||
@JsonProperty("completed_at") Instant completedAt) {
|
@JsonProperty("completed_at") Instant completedAt) {
|
||||||
|
|
||||||
public CanonicalToolInvocation {
|
public CanonicalToolInvocation {
|
||||||
|
// 身份/请求字段必填;状态与开始时间必填
|
||||||
requireText(toolCallId, "toolCallId");
|
requireText(toolCallId, "toolCallId");
|
||||||
requireText(runId, "runId");
|
requireText(runId, "runId");
|
||||||
requireText(toolName, "toolName");
|
requireText(toolName, "toolName");
|
||||||
requireText(request, "request");
|
requireText(request, "request");
|
||||||
Objects.requireNonNull(status, "status must not be null");
|
Objects.requireNonNull(status, "status must not be null");
|
||||||
Objects.requireNonNull(startedAt, "startedAt must not be null");
|
Objects.requireNonNull(startedAt, "startedAt must not be null");
|
||||||
|
// 状态-字段一致性:非法组合直接拒绝入库
|
||||||
validateState(status, evidenceStatus, rawResponse, agentResult, errorCode, completedAt);
|
validateState(status, evidenceStatus, rawResponse, agentResult, errorCode, completedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 PROJECTING 记录:执行开始时调用,仅携带身份 + request + 开始时间
|
||||||
|
* (终态字段全部为 null)。
|
||||||
|
*/
|
||||||
public static CanonicalToolInvocation projecting(String toolCallId,
|
public static CanonicalToolInvocation projecting(String toolCallId,
|
||||||
String runId,
|
String runId,
|
||||||
String toolName,
|
String toolName,
|
||||||
@@ -40,6 +68,10 @@ public record CanonicalToolInvocation(
|
|||||||
InvocationStatus.PROJECTING, null, null, startedAt, null);
|
InvocationStatus.PROJECTING, null, null, startedAt, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PROJECTING → READY 迁移:成功路径(Projector 投影完成后调用)。
|
||||||
|
* 必须携带 raw_response + agent_result + evidence(FOUND/NO_EVIDENCE)+ 完成时间。
|
||||||
|
*/
|
||||||
public CanonicalToolInvocation markReady(String rawResponse,
|
public CanonicalToolInvocation markReady(String rawResponse,
|
||||||
String agentResult,
|
String agentResult,
|
||||||
EvidenceStatus evidenceStatus,
|
EvidenceStatus evidenceStatus,
|
||||||
@@ -50,6 +82,10 @@ public record CanonicalToolInvocation(
|
|||||||
InvocationStatus.READY, evidenceStatus, null, startedAt, completedAt);
|
InvocationStatus.READY, evidenceStatus, null, startedAt, completedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PROJECTING → ERROR 迁移:失败路径(backend 执行/投影/预算等异常时调用)。
|
||||||
|
* 携带 raw_response(尽力而为)+ 稳定 error_code + 完成时间;agent_result 禁止出现。
|
||||||
|
*/
|
||||||
public CanonicalToolInvocation markError(String rawResponse,
|
public CanonicalToolInvocation markError(String rawResponse,
|
||||||
String errorCode,
|
String errorCode,
|
||||||
Instant completedAt) {
|
Instant completedAt) {
|
||||||
@@ -59,6 +95,11 @@ public record CanonicalToolInvocation(
|
|||||||
InvocationStatus.ERROR, EvidenceStatus.ERROR, errorCode, startedAt, completedAt);
|
InvocationStatus.ERROR, EvidenceStatus.ERROR, errorCode, startedAt, completedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验真条件(EvidenceGuard / ProgressProjector 都依赖它):
|
||||||
|
* READY + 属于同一 Run + agent_result 非空 + evidence 是合法证据语义
|
||||||
|
* (FOUND / NO_EVIDENCE)——即该记录可被引用为真实证据。
|
||||||
|
*/
|
||||||
public boolean isReferencableBy(String expectedRunId) {
|
public boolean isReferencableBy(String expectedRunId) {
|
||||||
return status == InvocationStatus.READY
|
return status == InvocationStatus.READY
|
||||||
&& Objects.equals(runId, expectedRunId)
|
&& Objects.equals(runId, expectedRunId)
|
||||||
@@ -67,12 +108,17 @@ public record CanonicalToolInvocation(
|
|||||||
|| evidenceStatus == EvidenceStatus.NO_EVIDENCE);
|
|| evidenceStatus == EvidenceStatus.NO_EVIDENCE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 状态机防御:只有 PROJECTING 允许迁移(READY/ERROR 是终态,不可再变)。 */
|
||||||
private void requireProjecting() {
|
private void requireProjecting() {
|
||||||
if (status != InvocationStatus.PROJECTING) {
|
if (status != InvocationStatus.PROJECTING) {
|
||||||
throw new InvocationStateException("Only PROJECTING invocation can transition");
|
throw new InvocationStateException("Only PROJECTING invocation can transition");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态-字段一致性校验:每种状态只允许合法的字段组合,
|
||||||
|
* 非法组合(如 PROJECTING 带终态字段、ERROR 带 agent_result)直接拒绝构造。
|
||||||
|
*/
|
||||||
private static void validateState(InvocationStatus status,
|
private static void validateState(InvocationStatus status,
|
||||||
EvidenceStatus evidenceStatus,
|
EvidenceStatus evidenceStatus,
|
||||||
String rawResponse,
|
String rawResponse,
|
||||||
@@ -81,14 +127,17 @@ public record CanonicalToolInvocation(
|
|||||||
Instant completedAt) {
|
Instant completedAt) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case PROJECTING -> {
|
case PROJECTING -> {
|
||||||
|
// 投影中:不得携带任何终态字段(evidence/agent_result/error/完成时间)
|
||||||
if (evidenceStatus != null || agentResult != null || errorCode != null || completedAt != null) {
|
if (evidenceStatus != null || agentResult != null || errorCode != null || completedAt != null) {
|
||||||
throw new InvocationStateException("PROJECTING invocation contains terminal fields");
|
throw new InvocationStateException("PROJECTING invocation contains terminal fields");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case READY -> {
|
case READY -> {
|
||||||
|
// 就绪:必须三者齐全(raw + agent_result + 完成时间)
|
||||||
if (rawResponse == null || agentResult == null || completedAt == null) {
|
if (rawResponse == null || agentResult == null || completedAt == null) {
|
||||||
throw new InvocationStateException("READY invocation requires raw, agent result and completion");
|
throw new InvocationStateException("READY invocation requires raw, agent result and completion");
|
||||||
}
|
}
|
||||||
|
// 就绪:evidence 只能是合法证据语义,且不得带错误码
|
||||||
if (evidenceStatus != EvidenceStatus.EVIDENCE_FOUND
|
if (evidenceStatus != EvidenceStatus.EVIDENCE_FOUND
|
||||||
&& evidenceStatus != EvidenceStatus.NO_EVIDENCE) {
|
&& evidenceStatus != EvidenceStatus.NO_EVIDENCE) {
|
||||||
throw new InvocationStateException("READY invocation has invalid evidence status");
|
throw new InvocationStateException("READY invocation has invalid evidence status");
|
||||||
@@ -98,6 +147,7 @@ public record CanonicalToolInvocation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ERROR -> {
|
case ERROR -> {
|
||||||
|
// 错误:evidence 必须 ERROR + 完成时间 + 错误码必填,agent_result 禁止
|
||||||
if (evidenceStatus != EvidenceStatus.ERROR || completedAt == null) {
|
if (evidenceStatus != EvidenceStatus.ERROR || completedAt == null) {
|
||||||
throw new InvocationStateException("ERROR invocation requires ERROR evidence status and completion");
|
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) {
|
private static void requireText(String value, String name) {
|
||||||
if (value == null || value.isBlank()) {
|
if (value == null || value.isBlank()) {
|
||||||
throw new IllegalArgumentException(name + " must not be blank");
|
throw new IllegalArgumentException(name + " must not be blank");
|
||||||
|
|||||||
Reference in New Issue
Block a user