From ff0752a16c0b0b6b7bb5e162a424c2e2b7636eca Mon Sep 17 00:00:00 2001 From: zhuyongxin Date: Tue, 4 Aug 2026 18:37:17 +0800 Subject: [PATCH] docs(harness): annotate tool domain classes and add tool chain learning notes - Annotate 43 tool domain classes (contract/projection/boundary/store/adapter/mysql) - Add tool registration and execution chain learning note - Add tool call chain runtime journey note (model decision to observation) --- ...ness Tool 调用链-一次工具调用的完整旅程.md | 203 ++++++++++ ...域代码学习笔记-工具的注册调用与执行链路.md | 382 ++++++++++++++++++ .../tool/adapter/MysqlToolAdapter.java | 16 +- .../tool/adapter/QueryLogsToolAdapter.java | 16 +- .../tool/boundary/ProjectedToolResult.java | 5 + .../boundary/ToolCallRequestEnvelope.java | 11 + .../harness/tool/boundary/ToolExecutor.java | 5 + .../tool/boundary/ToolResultProjector.java | 6 + .../tool/contract/AgentToolContracts.java | 12 + .../agent/harness/tool/contract/LogEvent.java | 3 + .../harness/tool/contract/LogPattern.java | 6 + .../harness/tool/contract/LogQueryScope.java | 4 + .../harness/tool/contract/LogSourceKind.java | 4 + .../agent/harness/tool/contract/LogTopic.java | 6 + .../harness/tool/contract/MysqlToolCall.java | 6 + .../tool/contract/MysqlToolRequest.java | 7 + .../tool/contract/MysqlToolResult.java | 9 + .../tool/contract/QueryLogsRequest.java | 7 + .../tool/contract/QueryLogsToolCall.java | 6 + .../tool/contract/QueryLogsToolResult.java | 13 + .../tool/contract/RagRelevanceLevel.java | 8 + .../harness/tool/contract/RagToolCall.java | 7 + .../harness/tool/contract/RagToolRequest.java | 4 + .../harness/tool/contract/RagToolResult.java | 11 + .../contract/ToolContractCollections.java | 7 + .../tool/mysql/JdbcMysqlReadOnlyExecutor.java | 19 +- .../tool/mysql/MysqlDataSourceDefinition.java | 12 +- .../harness/tool/mysql/MysqlQueryPlan.java | 8 + .../harness/tool/mysql/MysqlRawResult.java | 5 +- .../tool/mysql/MysqlReadOnlyExecutor.java | 5 + .../tool/mysql/MysqlResultProjector.java | 31 +- .../tool/mysql/MysqlSecurityException.java | 4 + .../harness/tool/mysql/MysqlSqlValidator.java | 40 +- .../harness/tool/mysql/MysqlToolLimits.java | 9 + .../projection/QueryLogsResultProjector.java | 39 +- .../tool/projection/RagResultProjector.java | 21 + .../tool/projection/ToolProjectionLimits.java | 13 +- .../tool/store/CanonicalInvocationLimits.java | 11 + .../tool/store/CanonicalInvocationStore.java | 9 + .../tool/store/CanonicalStoreException.java | 4 + .../store/DuplicateInvocationException.java | 4 + .../tool/store/InvocationStateException.java | 4 + .../store/RedisCanonicalInvocationStore.java | 16 + .../tool/store/ResultTooLargeException.java | 5 + .../tool/store/ToolCallKeyFactory.java | 7 + 45 files changed, 1021 insertions(+), 9 deletions(-) create mode 100644 mvp/engineering/harness/Harness Tool 调用链-一次工具调用的完整旅程.md create mode 100644 mvp/engineering/harness/Harness tool 域代码学习笔记-工具的注册调用与执行链路.md diff --git a/mvp/engineering/harness/Harness Tool 调用链-一次工具调用的完整旅程.md b/mvp/engineering/harness/Harness Tool 调用链-一次工具调用的完整旅程.md new file mode 100644 index 0000000..57c25fe --- /dev/null +++ b/mvp/engineering/harness/Harness Tool 调用链-一次工具调用的完整旅程.md @@ -0,0 +1,203 @@ +# Harness Tool 调用链:一次工具调用的完整旅程 + +**更新日期**:2026-08-03 +**主题**:从「模型决定调用工具」到「模型收到观察」的运行时完整链路——拦截器 → invoke → Adapter → ToolBoundary → 返回 → 二次加工 → ToolCallResponse +**结构篇**:[Harness tool 域代码学习笔记-工具的注册调用与执行链路](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(讲装配/注册/静态结构) +**本文**:动态时序(一次调用怎么跑完) + +## 1. 旅程全景(一张图) + +```mermaid +sequenceDiagram + participant M as 模型 + participant F as 框架 ReactAgent + participant I as HarnessToolInterceptor(per-Run) + participant ET as HarnessEvidenceTools(单例) + participant AD as RagToolAdapter(单例) + participant TB as ToolBoundary(单例) + participant P as DiagnosisProgressTracker + + rect rgb(240, 248, 255) + Note over M,I: 阶段 A:模型决定 → 拦截器(执行前) + M->>F: 输出 tool_call(工具名 + 参数 JSON) + F->>I: 回调 interceptToolCall(request, handler) + I->>I: ① supports 注册检查 + I->>ET: ② parse(typed 严格契约) + ET-->>I: ParsedAgentToolCall(previous_observation + input) + I->>P: ③ 协议校验(pending 评价)+ 判重 + end + + rect rgb(255, 250, 240) + Note over I,TB: 阶段 B:invoke → 执行(backend + 投影) + I->>ET: ④ invoke(context, toolName, toolCallId, args) + ET->>AD: bridge 闭包 → adapter.execute(context, envelope) + AD->>TB: boundary.execute(context, envelope, executor, projector) + TB->>TB: ⑤ 五阶段:preflight/预算/begin → executor 跑 backend → 校验 → projector 投影 → markReady + TB-->>I: ToolBoundaryResult(READY/ERROR) + end + + rect rgb(245, 255, 245) + Note over I,M: 阶段 C:返回 → 模型(执行后) + I->>I: ⑥ 双源校验(controlView 重读 evidence_status) + I->>P: ⑦ recordCompleted(NO_EVIDENCE 立即 NO_GAIN / FOUND 挂 pending) + I->>I: ⑧ modelObservation 加工(有界观察 + stop_required/reason) + I-->>F: ToolCallResponse.of(toolCallId, toolName, observation) + F-->>M: observation 作为本轮 tool 结果 + end +``` + +**三个阶段**:A 执行前(模型决定→门禁)→ B 执行中(invoke→backend→投影)→ C 执行后(校验→记账→成型)。 + +--- + +## 2. 阶段 A:模型决定 → 拦截器(执行前) + +### 2.1 模型怎么知道有这个工具 + +``` +模型 → callbacks 里看到工具(名字+描述+Schema)→ 决定调用 lookup_knowledge + → 输出 tool_call JSON(工具名 + 参数) +``` + +工具名是**模型决定的**——框架把模型输出包成 `ToolCallRequest`(含 toolName + arguments),回调拦截器。 + +### 2.2 拦截器的三道执行前门 + +```mermaid +flowchart LR + A["① supports(toolName)?"] -->|"否(非证据工具)"| X["handler.call 透传"] + A -->|"是"| B["② parse:typed 严格契约
FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS"] + B -->|"违规"| Y["协议处理(不执行)"] + B --> C["③ 协议校验(pending 评价)+ 判重"] + C -->|"重复"| Z["recordDuplicateScope(不执行)"] + C -->|"通过"| D["进入阶段 B:invoke"] +``` + +关键:**不是「拿到名字就执行」**——parse(模型输出必须精确匹配 `RagToolCall{previous_observation, input}`,多一个字段都炸 INVALID_ENVELOPE)、协议校验、判重,三道门不通过都不执行 backend。 + +--- + +## 3. 阶段 B:invoke → 执行(backend + 投影) + +### 3.1 invoke 的委托链 + +``` +I.invoke(context, "lookup_knowledge", "call-1", args) + → ET.invokers.get("lookup_knowledge") ← 注册表取 bridge 闭包 + → bridge lambda:adapter.execute(context, + new ToolCallRequestEnvelope(runId, "call-1", "lookup_knowledge", args, true, true)) + → ragAdapter.execute(context, envelope) + → boundary.execute(context, envelope, executor, projector) +``` + +**envelope 是 bridge 里现造的**:`authorized=true, readOnly=true` 写死——每个进 ToolBoundary 的信封都声明「已授权 + 只读」。 + +### 3.2 Adapter 组装两个函数(接线员) + +```java +return boundary.execute(context, envelope, + // executor:跑 backend 拿 raw(LookupResult 序列化成 JSON 文本) + ignored -> objectMapper.writeValueAsString(legacyExecutor.execute(request.query())), + // projector:raw → 有界契约 + evidenceStatus + raw -> projector.project(request, envelope.toolCallId(), raw)); +``` + +| 端口 | 干什么 | 产物 | +|---|---|---| +| `executor` | 调具体后端 | rawResponse(JSON 文本,执行链「货币」) | +| `projector` | 净化定型 | ProjectedToolResult(agentResult, evidenceStatus) | + +**模型永远看不到 raw**——raw 只用于校验、落 canonical、投影。 + +### 3.3 ToolBoundary 五阶段 + +```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 校验 + bytes → markReady(READY) 或 markError(ERROR)"] + A --> B --> C --> D --> E +``` + +返回 `ToolBoundaryResult(READY/ERROR)`——PROJECTING 永不外泄。 + +--- + +## 4. 阶段 C:返回 → 模型(执行后) + +### 4.1 拦截器的二次加工(不是直接返回) + +```mermaid +flowchart LR + A["ToolBoundaryResult"] --> B{"status == READY?"} + B -->|"否"| E1["error observation
BUDGET_EXHAUSTED 额外 markBudgetLimitReached"] + B -->|"是"| C["⑥ 双源校验:controlView 重读 evidence_status"] + C -->|"不一致"| E2["OBSERVATION_CONTRACT_MISMATCH 拒绝"] + C -->|"一致"| D["⑦ recordCompleted
NO_EVIDENCE → 立即 NO_GAIN
FOUND → 挂 pending"] + D --> F["⑧ modelObservation 加工
(有界观察 + stop_required/reason)"] + F --> G["ToolCallResponse.of(...) → 框架 → 模型"] +``` + +### 4.2 双源校验(自洽性防线) + +```text +源1:result.evidenceStatus() ← Projector 投影时计算的声明值 +源2:controlView(agentResult).evidenceStatus() ← 从 agent_result 内容重读 +一致 ? 通过 : OBSERVATION_CONTRACT_MISMATCH 拒绝 +``` + +防止「声明有证据但内容空 / 声明无证据但内容有」的不一致状态进入 progress 记账。 + +### 4.3 给模型的对象形态 + +``` +ToolCallResponse.of(toolCallId, toolName, observation) + observation = 有界观察: + 正常结果:脱敏后的契约内容(可能裁剪) + 饱和时: 附加 stop_required:true + reason + 协议错误:repair_required:true + violation_type/期望ID/指令 +``` + +框架把 observation 作为本轮 tool 结果给模型——**模型下一轮读取它,决定继续调用(带评价)还是输出 Draft 收尾**。 + +--- + +## 5. 旅程的衔接点(模型视角的闭环) + +```mermaid +flowchart LR + A["模型调工具"] --> B["观察(有界契约)"] + B --> C{"模型决定"} + C -->|"继续"| D["下次 Tool Call + previous_observation 评价"] + C -->|"收尾"| E["输出 Draft → Release 发布"] + D --> B +``` + +**progress 协议的闭环**:模型每次继续调用,都要在 Envelope 里回带对上一轮的 GAINED/NO_GAIN 评价——这就是拦截器 ③ 校验的 pending 逻辑(可回看 progress 笔记)。 + +--- + +## 6. 关键点总结 + +| 阶段 | 关键认知 | +|---|---| +| A 执行前 | 工具名是模型决定的;parse 是 typed 严格契约(输出必须匹配 Schema);三道门不通过不执行 | +| B 执行中 | executor/projector 是 Adapter 组装进 boundary 的**参数**;执行链货币是 JSON 文本;模型永远看不到 raw | +| C 执行后 | 拦截器不直接返回——双源校验 + progress 记账 + modelObservation 成型;ToolCallResponse 才是模型拿到的对象 | + +## 7. 面试 30 秒说法 + +> "一次工具调用的完整旅程分三段:执行前,模型从 callbacks 看到工具并决定调用,拦截器做 supports 分流、typed 严格 parse、协议校验和判重——三道门不通过都不执行 backend;执行中,invoke 经 bridge 到 Adapter,Adapter 把 executor(跑 backend 拿 raw)和 projector(raw 投影成有界脱敏契约)组装进 ToolBoundary 的五阶段门禁,返回 ToolBoundaryResult;执行后,拦截器不直接返回——先双源校验 evidence_status,再 recordCompleted 记进度,再 modelObservation 加工成有界观察,最后包装成 ToolCallResponse 给模型。模型看到的永远是脱敏后有界的观察,raw 只进 canonical 供审计验真。" + +## 8. 代码位置索引 + +| 环节 | 文件 | +|---|---| +| 拦截器(A/C 阶段) | `src/main/java/com/superbiz/agent/harness/agent/HarnessToolInterceptor.java` | +| 注册表 + parse + invoke | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java` | +| Adapter 组装(B 阶段) | `src/main/java/com/superbiz/agent/harness/tool/adapter/RagToolAdapter.java` | +| 五阶段门禁 | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundary.java` | +| 双源校验 + 观察成型 | `src/main/java/com/superbiz/agent/harness/agent/ToolResultViewProjector.java` | +| 模型观察形态 | `src/main/java/com/superbiz/agent/harness/agent/ToolControlView.java` | diff --git a/mvp/engineering/harness/Harness tool 域代码学习笔记-工具的注册调用与执行链路.md b/mvp/engineering/harness/Harness tool 域代码学习笔记-工具的注册调用与执行链路.md new file mode 100644 index 0000000..bcd22b7 --- /dev/null +++ b/mvp/engineering/harness/Harness tool 域代码学习笔记-工具的注册调用与执行链路.md @@ -0,0 +1,382 @@ +# Harness tool 域代码学习笔记:工具的注册、调用与执行链路 + +**更新日期**:2026-08-03 +**主题**:tool 域 49 个文件的完整链路——装配 → 注册 → 调用 → 执行 → 返回,拆分阶段讲,最后合并 +**设计视角**:[Harness 组件全景-职责-设计原因与边界](Harness组件全景-职责-设计原因与边界.md) §7 Tool +**代码视角**:[Harness progress 代码学习笔记](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md)(progress 域衔接,本笔记是 tool 域) + +## 1. 定位:tool 域管什么 + +**职责**:工具如何安全执行、保存真相并只暴露必要内容。 + +| 问题 | 不解决会怎样 | 催生的层 | +|---|---|---| +| 每个 Adapter 自己写授权/预算/审计 → 漂移 | 三个工具三种行为 | **Boundary**(统一门禁) | +| raw 结果直接给模型 | 敏感数据泄露、超大响应、无结构 | **Projector**(有界投影) | +| 模型可能编造证据 | 结论无法验真、审计黑洞 | **Store**(canonical 真相) | +| MySQL 查询不可控 | 写库、删库、危险 SQL | **MySQL 沙箱**(只读红线) | + +**49 文件分 6 组**: + +| 组 | 数量 | 角色 | +|---|---|---| +| Contract | 18 | 跨层类型化语言(Call/Request/Result) | +| Boundary | 7 | 统一门禁(ToolBoundary + 配套) | +| Projection | 3 | raw → 有界 agent 契约 | +| Store | 9 | canonical 真相持久化 | +| Adapter | 3 | 接线(boundary + 后端 + 投影器) | +| MySQL 沙箱 | 9 | 只读执行 + fail-closed 校验 | + +**核心设计**:几乎不用继承——用「接口 + 组合 + 函数式接口」三件套解耦。 + +--- + +## 2. 阶段一:装配(config → bean 注入链) + +### 2.1 注入链 + +```mermaid +flowchart TD + subgraph 底层 + R["RedisCanonicalInvocationStore"] + B["ToolBoundary"] + RP["RagResultProjector"] + LP["QueryLogsResultProjector"] + end + subgraph 中层 + RA["RagToolAdapter"] + QA["QueryLogsToolAdapter"] + MA["MysqlToolAdapter"] + end + subgraph 顶层 + ET["HarnessEvidenceTools"] + end + R --> B + B --> RA + B --> QA + B --> MA + RP --> RA + LP --> QA + ET --> RA + ET --> QA + ET --> MA +``` + +注入规律:所有 `@Bean` 方法参数 = 依赖注入点;**没有任何类 extends 别人**。 + +### 2.2 为什么不用继承 + +``` +❌ 继承方案(没采用): + abstract class BaseToolAdapter { ... } + RagToolAdapter extends BaseToolAdapter { ... } + → 加一个工具就得改基类,横切逻辑散落 + +✅ 组合方案(实际): + Adapter = ToolBoundary(门禁) + 后端(执行) + Projector(投影) + ↑ 构造注入持有引用,不是继承 + → 每个 Adapter 独立组装,改一个不影响其他 +``` + +组合的优势: +1. **ToolBoundary 对三种工具完全无感知**——只认 `ToolExecutor` / `ToolResultProjector` 两个端口,三个工具共用同一个实例; +2. **后端各不相同**(LookupKnowledgeTool / QueryLogsTools / JDBC),无法抽象成共同基类,用函数式接口适配; +3. **开闭原则**:加新工具 = 新写 Adapter + Projector + config 注册,**不动已有类**(门禁/真相/进度自动继承)。 + +--- + +## 3. 阶段二:注册(HarnessEvidenceTools 门面) + +### 3.1 两个平行的注册表 + +```mermaid +flowchart LR + subgraph fromAdapters + RAG["ragAdapter::execute"] + LOGS["logsAdapter::execute"] + MYSQL["mysqlAdapter::execute"] + end + subgraph HarnessEvidenceTools + direction TB + CALL["callbacks(List)
模型可见 Schema + 必炸"] + INV["invokers(Map)
toolName → bridge 闭包"] + end + RAG -->|bridge| INV + LOGS -->|bridge| INV + MYSQL -->|bridge| INV + INV -.同一个工具名串起.-> CALL + CALL --> MODEL["模型(可见工具目录)"] + INV --> INTERCEPTOR["拦截器(执行入口)"] +``` + +**同一工具名字符串串起两个表**:模型从 callbacks 决定调 `lookup_knowledge` → 拦截器用同一个名字去 invokers 取执行器。 + +### 3.2 bridge:方法引用绑定实际调用者 + +```java +private static EvidenceToolInvoker bridge(String toolName, AdapterCall adapter) { + // lambda 闭包捕获 adapter 实例 + 固定的 toolName + return (context, toolCallId, arguments) -> adapter.execute( + context, + new ToolCallRequestEnvelope( + context.runId(), toolCallId, toolName, arguments, true, true)); +} +``` + +关联链(三层绑定): + +``` +① config:new RagToolAdapter(boundary, mapper, projector, backend) + → adapter 实例已组合好 boundary + projector + backend +② fromAdapters:ragAdapter::execute 是「绑定实例的方法引用」 + → bridge lambda 捕获它 —— invoker 与 Adapter 的关联在此固化 +③ 构造方法:按工具名常量 put 进 invokers —— "lookup_knowledge" → 捕获了 ragAdapter 的 lambda +``` + +**invoker 与调用者的关联 = 方法引用绑定**:取出来直接 `adapter.execute(...)`,不需要再查表找调用者。 + +### 3.3 三个关键设计点 + +**① callbacks 的必炸保护**: + +```java +FunctionToolCallback.builder(name, ignored -> { + throw new IllegalStateException( + "Harness evidence Tools require the framework Tool interceptor"); +}) +``` + +| 场景 | callback 行为 | +|---|---| +| 正常(拦截器接管) | 不执行(拦截器直接 evidenceTools.invoke) | +| 异常(某处 handler.call / 直接调) | **抛异常** → 暴露「绕过门禁」的 bug | + +结构性保证:唯一能执行证据工具的路径 = 拦截器接管 → 门禁永远在线;绕过不可能静默成功(fail-fast)。 + +**② mysql 条件注册**: + +```java +// config +boolean mysqlEnabled = 数据源配置了 jdbcUrl ? true : false; +return fromAdapters(rag, logs, mysqlEnabled ? mysql : null); + +// fromAdapters +EvidenceToolInvoker mysql = mysqlAdapter == null ? null : bridge(QUERY_MYSQL, mysqlAdapter::execute); +// 构造方法里 null 也不注册 invokers / callbacks +``` + +没配数据源 → query_mysql 从模型视野和执行注册表**都消失**(不暴露「必死工具」)。RAG/日志后端内置,无条件注册。 + +**③ definition 的 typed 输入类**: + +```java +definition(AgentToolContracts.LOOKUP_KNOWLEDGE, ..., RagToolCall.class) +``` + +输入类型 = 模型必须匹配的 Schema——`RagToolCall{previous_observation, input}`。parse 时用 `FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS` 强制匹配,**模型输出多一个字段都炸**(INVALID_ENVELOPE)。 + +--- + +## 4. 阶段三:调用(拦截器 → 注册表) + +```mermaid +sequenceDiagram + participant M as 模型 + participant F as 框架 ReactAgent + participant I as HarnessToolInterceptor + participant ET as HarnessEvidenceTools + + M->>F: 决定调用 lookup_knowledge(输出 tool_call JSON) + F->>I: 回调 interceptToolCall(request, handler) + I->>I: supports(toolName) ? 注册检查 + I->>ET: parse(toolName, arguments, mapper) → typed Envelope + ET-->>I: ParsedAgentToolCall(previous_observation + input) + I->>I: 协议校验 / 判重(不通过不执行) + I->>ET: invoke(context, toolName, toolCallId, args) + ET->>I: bridge lambda → adapter.execute +``` + +调用链要点: + +| 点 | 说明 | +|---|---| +| **工具名是模型决定的** | `request.getToolName()` 来自模型输出,拦截器拿它查 invokers | +| **invoke 前有三道门** | supports 分流 → parse 严格契约 → 协议/判重——判重不通过不执行 | +| **envelope 现造** | bridge 里构造,`authorized=true, readOnly=true` 写死——每个进 ToolBoundary 的信封都声明「已授权 + 只读」 | +| **工具名被闭包捕获** | 即使调用方传错名字,envelope 里也是正确的工具名(防混淆) | + +--- + +## 5. 阶段四:执行(Adapter → ToolBoundary → 后端) + +### 5.1 Adapter = 接线员 + +```mermaid +flowchart LR + AD["Adapter.execute"] -->|"boundary.execute(context, envelope,"| TB["ToolBoundary"] + AD -->|"executor = ignored -> legacyExecutor.execute(query)"| TB + AD -->|"projector = raw -> projector.project(...)"| TB + TB -->|"executor 跑 backend"| BK["具体后端
LookupKnowledgeTool / QueryLogsTools / JDBC"] + TB -->|"projector 投影"| PR["RagResultProjector / QueryLogsResultProjector / MysqlResultProjector"] + TB -->|"markReady"| ST["CanonicalInvocationStore"] +``` + +**两个端口**:executor(跑 backend 拿 raw)+ projector(raw → 有界脱敏契约)。**模型永远看不到 raw**——这是执行链的核心目的。 + +### 5.2 LegacyExecutor vs 专用 Executor + +| | RAG/日志 | MySQL | +|---|---|---| +| 后端来源 | 重构前旧类(LookupKnowledgeTool / QueryLogsTools) | 全新实现(JdbcMysqlReadOnlyExecutor) | +| 端口位置 | Adapter **内部**定义 LegacyExecutor | mysql **包**里定义 MysqlReadOnlyExecutor | +| 注入方式 | `backend::lookupKnowledge` 方法引用 / lambda | 直接注入专用实现 | +| 为什么 | 复用成熟旧代码 | 新工具直接面向沙箱设计 | + +``` +RAG: Adapter → LegacyExecutor(Adapter内部) → LookupKnowledgeTool(旧后端) +MySQL: Adapter → MysqlReadOnlyExecutor(mysql包) → JdbcMysqlReadOnlyExecutor(新实现) +``` + +### 5.3 ToolBoundary 五阶段(执行门禁) + +```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 校验 + bytes → markReady(READY)"] + A --> B --> C --> D --> E +``` + +**三笔 bytes 预留**:request(①)/ raw(③)/ agent_result(⑤)。 + +### 5.4 脱敏与有界(投影器) + +- **日志**:sanitize 抹掉密码/token/主机/Pod/IP/PID/SQL 字面量;均匀采样 + 模式聚合; +- **MySQL**:敏感列(password/token/secret 等)单元格 → `[REDACTED]`;行数/字符/字节三重截断; +- **RAG**:chunk 级去重 + 摘录截断 + fitBudget 总字节兜底。 + +--- + +## 6. 阶段五:返回(双源校验 → 记账 → 有界观察) + +```mermaid +sequenceDiagram + participant TB as ToolBoundary + participant I as HarnessToolInterceptor + participant P as DiagnosisProgressTracker + participant M as 模型 + + TB-->>I: ToolBoundaryResult(READY/ERROR) + I->>I: 双源交叉验证(声明值 vs 内容重算 evidence_status) + alt 不一致 + I->>M: OBSERVATION_CONTRACT_MISMATCH(拒绝) + else 一致 + I->>P: recordCompleted(call, evidenceStatus) + P-->>I: 快照(NO_EVIDENCE 立即 NO_GAIN / FOUND 挂 pending) + I->>I: modelObservation 加工(含 stop_required / stopReason) + I-->>M: 有界 observation(模型永远看不到 raw) + end +``` + +**错误处理三种形态**: + +| 场景 | 处理 | +|---|---| +| Adapter 业务/参数异常 | catch → `INVALID_REQUEST`(不泄露内部细节) | +| MySQL 安全异常 | `MysqlSecurityException` 单独 catch → `INVALID_REQUEST` | +| 日志后端缺失 | `ObjectProvider.getIfAvailable()` → 返回空结果 JSON(不炸) | + +--- + +## 7. 全链路合起来 + +```mermaid +sequenceDiagram + participant C as config(启动) + participant ET as HarnessEvidenceTools(单例) + participant I as 拦截器(per-Run) + participant AD as Adapter(单例) + participant TB as ToolBoundary(单例) + participant ST as CanonicalStore(单例) + participant M as 模型 + + rect rgb(240, 248, 255) + Note over C,ET: ① 装配(应用启动一次) + C->>C: 建 boundary / 3 个 Adapter(注入 boundary+后端+投影器) + C->>ET: fromAdapters(rag, logs, mysql?) + ET->>ET: bridge → invokers + definition → callbacks(平行,同名串起) + end + + rect rgb(255, 250, 240) + Note over M,AD: ② 调用+执行(每次 Tool Call) + M->>I: 模型决定工具名 → 框架回调拦截器 + I->>I: supports 分流 → parse(typed 严格契约)→ 协议/判重 + I->>ET: invoke → invokers.get(名字) → bridge 闭包 + ET->>AD: adapter.execute(context, envelope[现造,授权只读写死]) + AD->>TB: boundary.execute(context, envelope, executor, projector) + TB->>ST: begin(PROJECTING) → executor 跑 raw → projector 投影 → markReady(READY) + TB-->>I: ToolBoundaryResult + I->>I: 双源校验 → recordCompleted → modelObservation + I-->>M: 有界 observation(无 raw) + end +``` + +**四阶段汇总**: + +| 阶段 | 做什么 | 关键类 | +|---|---|---| +| 装配 | Spring 组合依赖(无继承) | config / Adapter / ToolBoundary | +| 注册 | bridge 成 invoker + definition 成 callback(平行同名串起) | HarnessEvidenceTools | +| 调用 | supports → parse 严格契约 → 协议/判重 → invoke | Interceptor / HarnessEvidenceTools | +| 执行+返回 | boundary 五阶段 → 投影脱敏 → canonical → 双源校验 → 记账 → 有界观察 | Adapter / ToolBoundary / Projector / Store | + +--- + +## 8. 易错点 + +| 易错 | 正确 | +|---|---| +| 必炸 = 死工具 | 必炸是**保护**:模型通过拦截器正常执行,只有绕过路径才炸 | +| LegacyExecutor 是通用 executor | 它只服务于「复用旧后端」;新工具直接注入专用 Executor(如 MysqlReadOnlyExecutor) | +| callbacks 是执行器 | 它是「模型可见目录」+ 必炸占位;真执行走 invokers | +| 执行链只有 executor | 还有 **projector**(raw → 有界契约)——模型永远看不到 raw | +| MySQL 没有 tool 类 | `JdbcMysqlReadOnlyExecutor` 就是它的后端执行类,只是不叫 Tool | +| 工具注册是静态列表 | **配置驱动**:没配数据源 → query_mysql 从两表消失 | +| 返回就是 ToolBoundaryResult | 返回后还有双源校验 → recordCompleted → modelObservation | + +## 9. 面试话术合集(30 秒) + +### 9.1 为什么不用继承 + +> "tool 域刻意不用继承:ToolBoundary 通过 ToolExecutor/ToolResultProjector 两个函数式端口接收执行和投影逻辑,三个 Adapter 各自用构造注入组合 boundary + 后端 + projector,HarnessEvidenceTools 再用 bridge 把 Adapter 包成统一的 EvidenceToolInvoker 注册表。类图里没有 extends 箭头——全是 has-a(组合)和函数适配(函数式接口),扩展新工具不改任何已有类。" + +### 9.2 为什么必炸保护 + +> "必炸保护是执行不可绕过的结构性保证:证据工具的 ToolCallback 被故意定义为直接抛异常,使 handler 路径成为死路。唯一能执行证据工具的路径就是拦截器接管——预算、canonical、进度协议、脱敏门禁永远在线;任何绕过尝试要么抛异常暴露 bug(fail-fast),要么根本不执行(fail-closed)。非证据工具不需要门禁,所以拦截器放行、callback 正常。" + +### 9.3 LegacyExecutor 是什么 + +> "LegacyExecutor 是 Adapter 内部定义的旧后端端口:RAG 和日志是重构前就有的工具,后端实现(backend::lookupKnowledge)被方法引用注入复用,通过 Adapter 包进 Harness 门禁——『旧后端复用,新门禁外挂』。它和 ToolBoundary 的 ToolExecutor 是两层:LegacyExecutor 是具体后端怎么查,ToolExecutor 是边界统一端口,Adapter 把前者包成后者。MySQL 是全新工具,没有 legacy,直接用新写的 MysqlReadOnlyExecutor。" + +### 9.4 模型为什么看不到 raw + +> "执行链是两个端口:executor 跑 backend 拿 raw,projector 把 raw 投影成有界脱敏契约(截断 + 脱敏 + 冻结 Schema)。ToolBoundary 只让 READY/ERROR 离开,模型拿到的是 modelObservation 加工后的有界观察——raw 只进 canonical Store 供审计和验真,永远不进入模型上下文。" + +### 9.5 新工具怎么加(开闭原则) + +> "新工具按 MySQL 模板:写 Contract 三件套 + 专用执行器 + 投影器 + Adapter,config 注册。要改的只有 AgentToolContracts 常量、HarnessEvidenceTools 构造、config;不用改 ToolBoundary、canonical、拦截器——新工具自动获得预算门禁、真相记录、脱敏投影、进度收敛、证据验真全套管控。" + +## 10. 代码位置索引 + +| 类 | 文件 | +|---|---| +| `HarnessEvidenceTools` | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java`(agent 包,tool 域门面) | +| `RagToolAdapter` / `QueryLogsToolAdapter` / `MysqlToolAdapter` | `.../tool/adapter/` | +| `ToolBoundary` / `ToolBoundaryResult` / `ToolCallRequestEnvelope` / `ToolExecutor` / `ToolResultProjector` / `ProjectedToolResult` | `.../tool/boundary/` | +| `RagResultProjector` / `QueryLogsResultProjector` / `ToolProjectionLimits` | `.../tool/projection/` | +| `CanonicalInvocationStore` / `RedisCanonicalInvocationStore` / `CanonicalToolInvocation` / `ToolCallKeyFactory` / `CanonicalInvocationLimits` | `.../tool/store/` | +| Contract 18 个 | `.../tool/contract/` | +| `MysqlSqlValidator` / `JdbcMysqlReadOnlyExecutor` / `MysqlResultProjector` / `MysqlDataSourceDefinition` 等 | `.../tool/mysql/` | +| 装配 | `src/main/java/com/superbiz/agent/config/HarnessChatConfiguration.java` | diff --git a/src/main/java/com/superbiz/agent/harness/tool/adapter/MysqlToolAdapter.java b/src/main/java/com/superbiz/agent/harness/tool/adapter/MysqlToolAdapter.java index cc27c00..12d631c 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/adapter/MysqlToolAdapter.java +++ b/src/main/java/com/superbiz/agent/harness/tool/adapter/MysqlToolAdapter.java @@ -15,13 +15,20 @@ import com.superbiz.agent.harness.tool.mysql.MysqlSqlValidator; import java.util.Objects; -/** Validates and runs the logical MySQL Tool through the canonical boundary. */ +/** + * MySQL 逻辑工具的接线员:反序列化请求 → SQL 沙箱校验(fail-closed)→ + * 把只读执行器(executor)和投影器(projector)组装进 ToolBoundary 统一门禁。 + * 安全/参数异常映射为 INVALID_REQUEST(不泄露内部细节)。 + */ public final class MysqlToolAdapter { private final ToolBoundary boundary; private final ObjectMapper objectMapper; + /** SQL 沙箱:白名单表列 + fail-closed 策略。 */ private final MysqlSqlValidator validator; + /** 只读执行器(JDBC 只读连接 + 超时 + 行数 + 取消)。 */ private final MysqlReadOnlyExecutor executor; + /** 投影器:raw 行 → 有界脱敏契约。 */ private final MysqlResultProjector projector; public MysqlToolAdapter(ToolBoundary boundary, ObjectMapper objectMapper, @@ -34,12 +41,19 @@ public final class MysqlToolAdapter { this.projector = Objects.requireNonNull(projector, "projector must not be null"); } + /** + * 执行入口:解析请求 → SQL 沙箱校验(生成执行计划)→ 组装 executor/projector + * 交给 ToolBoundary。任何安全/参数异常统一映射 INVALID_REQUEST。 + */ public ToolBoundaryResult execute(RunContext context, ToolCallRequestEnvelope envelope) { try { MysqlToolRequest request = objectMapper.readValue(envelope.requestJson(), MysqlToolRequest.class); + // 沙箱校验:表列白名单 + fail-closed 策略 → 规范化执行计划 MysqlQueryPlan plan = validator.validate(request); return boundary.execute(context, envelope, + // executor:只读执行器返回 raw 行 JSON ignored -> objectMapper.writeValueAsString(executor.execute(plan, context)), + // projector:有界脱敏投影(用该数据源的限制) raw -> projector.project(request, envelope.toolCallId(), raw, plan.dataSource().limits())); } catch (MysqlSecurityException | IllegalArgumentException e) { return ToolBoundaryResult.error(envelope == null ? null : envelope.toolCallId(), diff --git a/src/main/java/com/superbiz/agent/harness/tool/adapter/QueryLogsToolAdapter.java b/src/main/java/com/superbiz/agent/harness/tool/adapter/QueryLogsToolAdapter.java index c321899..29c759f 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/adapter/QueryLogsToolAdapter.java +++ b/src/main/java/com/superbiz/agent/harness/tool/adapter/QueryLogsToolAdapter.java @@ -17,9 +17,14 @@ import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.Objects; -/** Bridges logical query-log requests and the existing Mock tool through ToolBoundary. */ +/** + * 逻辑日志请求与既有 Mock 工具的接线员: + * 反序列化请求 → 校验 topic/query/lookback → 构造查询范围 scope → + * 把 legacy executor 和投影器组装进 ToolBoundary 统一门禁执行。 + */ public final class QueryLogsToolAdapter { + /** legacy backend 执行端口(region + 旧主题 + 关键词 + 条数)。 */ @FunctionalInterface public interface LegacyExecutor { String execute(String region, String legacyTopic, String query, Integer limit) throws Exception; @@ -58,25 +63,33 @@ public final class QueryLogsToolAdapter { this.legacyLimit = legacyLimit; } + /** + * 执行入口:解析请求 → 校验 → 构造 scope → 组装 executor/projector 交给 ToolBoundary。 + * 业务参数非法返回 INVALID_REQUEST(不抛异常打断 ReAct)。 + */ public ToolBoundaryResult execute(RunContext context, ToolCallRequestEnvelope envelope) { try { QueryLogsRequest request = objectMapper.readValue(envelope.requestJson(), QueryLogsRequest.class); if (request.topic() == null || request.query() == null || request.query().isBlank()) { return ToolBoundaryResult.error(envelope.toolCallId(), ToolBoundaryErrorCode.INVALID_REQUEST); } + // 回看窗口:缺省 30 分钟,上限 24 小时 int lookback = request.lookbackMinutes() == null ? DEFAULT_LOOKBACK_MINUTES : request.lookbackMinutes(); if (lookback <= 0 || lookback > 24 * 60) { return ToolBoundaryResult.error(envelope.toolCallId(), ToolBoundaryErrorCode.INVALID_REQUEST); } Instant end = clock.instant(); + // 实际查询范围(审计/公开 scope 用) LogQueryScope scope = new LogQueryScope( request.topic(), request.query(), DateTimeFormatter.ISO_INSTANT.format(end.minus(Duration.ofMinutes(lookback))), DateTimeFormatter.ISO_INSTANT.format(end)); String legacyTopic = legacyTopic(request.topic()); return boundary.execute(context, envelope, + // executor:调用 legacy 日志 backend ignored -> legacyExecutor.execute(region, legacyTopic, request.query(), legacyLimit), + // projector:投影成冻结契约(含 scope) raw -> projector.project(request, envelope.toolCallId(), scope, raw)); } catch (Exception e) { return ToolBoundaryResult.error(envelope == null ? null : envelope.toolCallId(), @@ -84,6 +97,7 @@ public final class QueryLogsToolAdapter { } } + /** 逻辑主题 → legacy 日志主题名映射。 */ private static String legacyTopic(LogTopic topic) { return switch (topic) { case APPLICATION -> "application-logs"; diff --git a/src/main/java/com/superbiz/agent/harness/tool/boundary/ProjectedToolResult.java b/src/main/java/com/superbiz/agent/harness/tool/boundary/ProjectedToolResult.java index 961822d..2fde81a 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/boundary/ProjectedToolResult.java +++ b/src/main/java/com/superbiz/agent/harness/tool/boundary/ProjectedToolResult.java @@ -4,6 +4,10 @@ import com.superbiz.agent.harness.contract.EvidenceStatus; import java.util.Objects; +/** + * Projector 的产出:有界 agent_result 文本 + 客观 evidence status。 + * ToolBoundary 只接受 FOUND/NO_EVIDENCE(ERROR 走错误路径,不产生投影结果)。 + */ public record ProjectedToolResult(String agentResult, EvidenceStatus evidenceStatus) { public ProjectedToolResult { @@ -11,6 +15,7 @@ public record ProjectedToolResult(String agentResult, EvidenceStatus evidenceSta throw new IllegalArgumentException("agentResult must not be blank"); } Objects.requireNonNull(evidenceStatus, "evidenceStatus must not be null"); + // 投影结果只能是合法证据语义(空不空),错误状态不从这里出 if (evidenceStatus != EvidenceStatus.EVIDENCE_FOUND && evidenceStatus != EvidenceStatus.NO_EVIDENCE) { throw new IllegalArgumentException("projected result must be evidence or no-evidence"); diff --git a/src/main/java/com/superbiz/agent/harness/tool/boundary/ToolCallRequestEnvelope.java b/src/main/java/com/superbiz/agent/harness/tool/boundary/ToolCallRequestEnvelope.java index b95e847..5c8887c 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/boundary/ToolCallRequestEnvelope.java +++ b/src/main/java/com/superbiz/agent/harness/tool/boundary/ToolCallRequestEnvelope.java @@ -2,11 +2,22 @@ package com.superbiz.agent.harness.tool.boundary; import com.fasterxml.jackson.annotation.JsonProperty; +/** + * 一次 Tool 执行的内部信封:同时证明 Run、调用 ID、工具、参数、授权意图和只读意图。 + * 由 Adapter 在调 ToolBoundary 前构造(HarnessEvidenceTools 的 bridge 固定 authorized/readOnly=true)。 + * 与模型侧 progress Envelope(previous_observation + input)不同:这是边界内部信封。 + */ public record ToolCallRequestEnvelope( + /** 所属 Run。 */ @JsonProperty("run_id") String runId, + /** 本次调用 ID(canonical key 的一部分)。 */ @JsonProperty("tool_call_id") String toolCallId, + /** 工具名。 */ @JsonProperty("tool_name") String toolName, + /** 业务请求 JSON(纯业务参数,无协议字段)。 */ @JsonProperty("request") String requestJson, + /** 是否授权(bridge 恒为 true;策略拒绝走 UNAUTHORIZED)。 */ @JsonProperty("authorized") boolean authorized, + /** 是否只读意图(诊断 Tool 必须只读,否则 NOT_READ_ONLY)。 */ @JsonProperty("read_only") boolean readOnly) { } diff --git a/src/main/java/com/superbiz/agent/harness/tool/boundary/ToolExecutor.java b/src/main/java/com/superbiz/agent/harness/tool/boundary/ToolExecutor.java index 1e72822..6d5c0ec 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/boundary/ToolExecutor.java +++ b/src/main/java/com/superbiz/agent/harness/tool/boundary/ToolExecutor.java @@ -1,6 +1,11 @@ package com.superbiz.agent.harness.tool.boundary; +/** + * 具体 backend 的 raw 执行函数端口(函数式):输入业务请求 JSON,输出原始响应文本。 + * ToolBoundary 不依赖具体 backend,只认这个端口——Adapter 把各自 backend 接进来。 + */ @FunctionalInterface public interface ToolExecutor { + /** 执行 backend,返回原始响应(非 null);失败抛异常由边界映射错误码。 */ String execute(String requestJson) throws Exception; } diff --git a/src/main/java/com/superbiz/agent/harness/tool/boundary/ToolResultProjector.java b/src/main/java/com/superbiz/agent/harness/tool/boundary/ToolResultProjector.java index 9d7eb7e..0c6dfb3 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/boundary/ToolResultProjector.java +++ b/src/main/java/com/superbiz/agent/harness/tool/boundary/ToolResultProjector.java @@ -1,6 +1,12 @@ package com.superbiz.agent.harness.tool.boundary; +/** + * raw → 有界 agent 契约的投影端口(函数式):输入原始响应,输出投影结果 + * (有界 agent_result + 客观 evidence status)。每个 Tool 一个实现 + * (Rag/QueryLogs/Mysql ResultProjector),ToolBoundary 通过它解耦投影逻辑。 + */ @FunctionalInterface public interface ToolResultProjector { + /** 投影 raw:必须返回非 null 的有界结果;失败抛异常由边界映射 PROJECTION_ERROR。 */ ProjectedToolResult project(String rawResponse) throws Exception; } diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/AgentToolContracts.java b/src/main/java/com/superbiz/agent/harness/tool/contract/AgentToolContracts.java index b641ffa..130c2f9 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/AgentToolContracts.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/AgentToolContracts.java @@ -1,21 +1,33 @@ package com.superbiz.agent.harness.tool.contract; +/** + * 三个证据 Tool 的「名字 + 模型可见描述」的单一事实源(冻结契约)。 + * + *

所有地方(拦截器判重、Normalizer 分派、Projector 分派、注册表)都引用这里的 + * 常量而不是字符串字面量——避免工具名拼错导致跨层漂移。 + */ public final class AgentToolContracts { + /** 知识库检索工具名。 */ public static final String LOOKUP_KNOWLEDGE = "lookup_knowledge"; + /** 日志查询工具名。 */ public static final String QUERY_LOGS = "query_logs"; + /** MySQL 只读查询工具名。 */ public static final String QUERY_MYSQL = "query_mysql"; + /** 模型可见的 RAG 工具描述:稳定背景知识,不用于实时日志/指标。 */ public static final String LOOKUP_KNOWLEDGE_DESCRIPTION = "查询内部知识库中的文档、接口说明、错误码和排障手册。" + "适用于稳定背景知识,不用于查询实时日志、指标或数据库状态。" + "输入 query:需要查询的问题或关键词。"; + /** 模型可见的日志工具描述:应用错误/慢查询/系统事件,不用于指标或表。 */ public static final String QUERY_LOGS_DESCRIPTION = "查询指定逻辑日志主题在时间窗口内与目标相关的日志证据。" + "适用于应用错误、慢查询和系统事件,不用于查询指标或数据库表。" + "输入 topic、query、lookback_minutes。"; + /** 模型可见的 MySQL 工具描述:授权数据源的参数化只读 SELECT,禁止发现表结构/写操作。 */ public static final String QUERY_MYSQL_DESCRIPTION = "在授权的逻辑数据源上执行参数化只读查询,获取业务数据库事实。" + "只用于已知库表字段的 SELECT,不用于发现表结构或执行写操作。" diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/LogEvent.java b/src/main/java/com/superbiz/agent/harness/tool/contract/LogEvent.java index 5e5f3a1..c466856 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/LogEvent.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/LogEvent.java @@ -2,6 +2,9 @@ package com.superbiz.agent.harness.tool.contract; import com.fasterxml.jackson.annotation.JsonProperty; +/** + * 单条日志事件(冻结契约):时间/级别/服务/消息四元组。 + */ public record LogEvent( @JsonProperty("timestamp") String timestamp, @JsonProperty("level") String level, diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/LogPattern.java b/src/main/java/com/superbiz/agent/harness/tool/contract/LogPattern.java index 53440d2..89c87f4 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/LogPattern.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/LogPattern.java @@ -2,11 +2,17 @@ package com.superbiz.agent.harness.tool.contract; import com.fasterxml.jackson.annotation.JsonProperty; +/** + * 日志模式聚合(冻结契约):把相似事件压缩成一条「模式」, + * 供模型快速了解事件全貌而不用读每条原始事件。 + */ public record LogPattern( + /** 该模式出现次数。 */ @JsonProperty("count") long count, @JsonProperty("first_seen") String firstSeen, @JsonProperty("last_seen") String lastSeen, @JsonProperty("level") String level, @JsonProperty("service") String service, + /** 示例事件(有界)。 */ @JsonProperty("example") String example) { } diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/LogQueryScope.java b/src/main/java/com/superbiz/agent/harness/tool/contract/LogQueryScope.java index 5e73a05..9fa5f44 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/LogQueryScope.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/LogQueryScope.java @@ -2,6 +2,10 @@ package com.superbiz.agent.harness.tool.contract; import com.fasterxml.jackson.annotation.JsonProperty; +/** + * 实际日志查询范围(冻结契约):反映「这次到底查了什么」, + * 供审计、ProgressProjector 的公开 scope、以及重复检测参考。 + */ public record LogQueryScope( @JsonProperty("topic") LogTopic topic, @JsonProperty("query") String query, diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/LogSourceKind.java b/src/main/java/com/superbiz/agent/harness/tool/contract/LogSourceKind.java index bf304d2..933f9a8 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/LogSourceKind.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/LogSourceKind.java @@ -1,5 +1,9 @@ package com.superbiz.agent.harness.tool.contract; +/** + * 日志来源类型(冻结契约)。当前只有 MOCK(演示/评估环境), + * 后续可扩展 ES/ClickHouse 等真实来源。 + */ public enum LogSourceKind { MOCK } diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/LogTopic.java b/src/main/java/com/superbiz/agent/harness/tool/contract/LogTopic.java index f828426..e848215 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/LogTopic.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/LogTopic.java @@ -1,7 +1,13 @@ package com.superbiz.agent.harness.tool.contract; +/** + * 逻辑日志主题(冻结契约):模型只能在这三个主题内查询,不能自由指定任意来源。 + */ public enum LogTopic { + /** 应用错误/业务日志。 */ APPLICATION, + /** 数据库慢查询。 */ DATABASE_SLOW_QUERY, + /** 系统事件。 */ SYSTEM_EVENTS } diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolCall.java b/src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolCall.java index c292679..449cdb4 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolCall.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolCall.java @@ -3,7 +3,13 @@ package com.superbiz.agent.harness.tool.contract; import com.fasterxml.jackson.annotation.JsonProperty; import com.superbiz.agent.harness.progress.PreviousObservation; +/** + * 模型发起的 MySQL 只读查询 Tool 调用 Envelope(Agent-facing 冻结契约): + * 协议字段 previous_observation + 业务输入 input。 + */ public record MysqlToolCall( + /** 对上一轮观察的评价(首次调用可为 null)。 */ @JsonProperty("previous_observation") PreviousObservation previousObservation, + /** 业务输入:data_source + sql + params。 */ @JsonProperty("input") MysqlToolRequest input) { } diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolRequest.java b/src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolRequest.java index 7bae46a..5a27c3d 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolRequest.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolRequest.java @@ -4,9 +4,16 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; +/** + * MySQL 只读查询业务输入(冻结契约):授权逻辑数据源 + 参数化 SQL + 绑定参数。 + * 判重指纹 = {data_source, sql, params}。 + */ public record MysqlToolRequest( + /** 授权数据源名(不是任意 JDBC URL)。 */ @JsonProperty("data_source") String dataSource, + /** 参数化 SQL(只允许 SELECT,沙箱校验)。 */ @JsonProperty("sql") String sql, + /** 绑定参数(防注入)。 */ @JsonProperty("params") List params) { public MysqlToolRequest { diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolResult.java b/src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolResult.java index 4611a9f..22a2eb9 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolResult.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolResult.java @@ -6,12 +6,21 @@ import com.superbiz.agent.harness.contract.EvidenceStatus; import java.util.List; import java.util.Map; +/** + * MySQL 查询结果(冻结契约):Projector 投影后的有界结果。 + * 列名 + 行数据(嵌套不可变),供模型看事实、Harness 看 returned_count/truncated。 + */ public record MysqlToolResult( + /** 证据语义:rows 空不空(客观判定)。 */ @JsonProperty("evidence_status") EvidenceStatus evidenceStatus, @JsonProperty("tool_call_id") String toolCallId, + /** 列名列表(有界)。 */ @JsonProperty("columns") List columns, + /** 行数据(有界、截断过,每行不可变 Map)。 */ @JsonProperty("rows") List> rows, + /** 返回的行数。 */ @JsonProperty("returned_count") int returnedCount, + /** 是否因预算截断。 */ @JsonProperty("truncated") boolean truncated) { public MysqlToolResult { diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/QueryLogsRequest.java b/src/main/java/com/superbiz/agent/harness/tool/contract/QueryLogsRequest.java index 18f5531..d805abe 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/QueryLogsRequest.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/QueryLogsRequest.java @@ -2,8 +2,15 @@ package com.superbiz.agent.harness.tool.contract; import com.fasterxml.jackson.annotation.JsonProperty; +/** + * 日志查询业务输入(冻结契约):逻辑主题 + 关键词 + 回看窗口。 + * 判重指纹 = {topic, query, lookback_minutes(缺省 30)}。 + */ public record QueryLogsRequest( + /** 逻辑日志主题(APPLICATION / DATABASE_SLOW_QUERY / SYSTEM_EVENTS)。 */ @JsonProperty("topic") LogTopic topic, + /** 查询关键词。 */ @JsonProperty("query") String query, + /** 回看分钟数;null 时 Normalizer 用默认 30。 */ @JsonProperty("lookback_minutes") Integer lookbackMinutes) { } diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/QueryLogsToolCall.java b/src/main/java/com/superbiz/agent/harness/tool/contract/QueryLogsToolCall.java index df4673a..3c03ed1 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/QueryLogsToolCall.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/QueryLogsToolCall.java @@ -3,7 +3,13 @@ package com.superbiz.agent.harness.tool.contract; import com.fasterxml.jackson.annotation.JsonProperty; import com.superbiz.agent.harness.progress.PreviousObservation; +/** + * 模型发起的日志查询 Tool 调用 Envelope(Agent-facing 冻结契约): + * 协议字段 previous_observation + 业务输入 input。 + */ public record QueryLogsToolCall( + /** 对上一轮观察的评价(首次调用可为 null)。 */ @JsonProperty("previous_observation") PreviousObservation previousObservation, + /** 业务输入:topic + query + lookback_minutes。 */ @JsonProperty("input") QueryLogsRequest input) { } diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/QueryLogsToolResult.java b/src/main/java/com/superbiz/agent/harness/tool/contract/QueryLogsToolResult.java index e5d6b0c..726eef1 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/QueryLogsToolResult.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/QueryLogsToolResult.java @@ -5,15 +5,28 @@ import com.superbiz.agent.harness.contract.EvidenceStatus; import java.util.List; +/** + * 日志查询结果(冻结契约):Projector 投影后的有界结果。 + * 包含聚合 pattern(压缩)与原始 event(有界、截断过), + * 供模型看事件、Harness 看 match_count/truncated。 + */ public record QueryLogsToolResult( + /** 证据语义:events 空不空(客观判定)。 */ @JsonProperty("evidence_status") EvidenceStatus evidenceStatus, @JsonProperty("tool_call_id") String toolCallId, + /** 日志来源类型(当前 MOCK)。 */ @JsonProperty("source_kind") LogSourceKind sourceKind, + /** 实际查询范围(topic/query/时间窗)。 */ @JsonProperty("scope") LogQueryScope scope, + /** 匹配总数(可能大于 returned_count)。 */ @JsonProperty("match_count") long matchCount, + /** 实际返回的事件条数。 */ @JsonProperty("returned_count") int returnedCount, + /** 压缩后的模式聚合(有界)。 */ @JsonProperty("patterns") List patterns, + /** 事件明细(有界、截断过)。 */ @JsonProperty("events") List events, + /** 是否因预算截断。 */ @JsonProperty("truncated") boolean truncated) { public QueryLogsToolResult { diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/RagRelevanceLevel.java b/src/main/java/com/superbiz/agent/harness/tool/contract/RagRelevanceLevel.java index ed27052..415b46c 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/RagRelevanceLevel.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/RagRelevanceLevel.java @@ -1,7 +1,15 @@ package com.superbiz.agent.harness.tool.contract; +/** + * RAG 检索相关度(冻结契约):Projector 客观计算,供 Harness/Release 参考。 + * 注意:REFERENCE(一般相关)不能自动映射为信息 NO_GAIN——可能仍排除一个假设, + * 需要模型结合诊断上下文判断。 + */ public enum RagRelevanceLevel { + /** 精确匹配。 */ PRECISE, + /** 高度相关。 */ HIGHLY_RELEVANT, + /** 一般相关(参考级)。 */ REFERENCE } diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/RagToolCall.java b/src/main/java/com/superbiz/agent/harness/tool/contract/RagToolCall.java index 9c9cb3d..575c0f1 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/RagToolCall.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/RagToolCall.java @@ -3,7 +3,14 @@ package com.superbiz.agent.harness.tool.contract; import com.fasterxml.jackson.annotation.JsonProperty; import com.superbiz.agent.harness.progress.PreviousObservation; +/** + * 模型发起的 RAG 工具调用 Envelope(Agent-facing 冻结契约): + * 协议字段 previous_observation + 业务输入 input。 + * 拦截器解析后剥离 previous_observation,只把 input 传给业务执行。 + */ public record RagToolCall( + /** 对上一轮观察的评价(首次调用可为 null)。 */ @JsonProperty("previous_observation") PreviousObservation previousObservation, + /** 业务输入:检索 query。 */ @JsonProperty("input") RagToolRequest input) { } diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/RagToolRequest.java b/src/main/java/com/superbiz/agent/harness/tool/contract/RagToolRequest.java index 9e8794b..c1dded7 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/RagToolRequest.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/RagToolRequest.java @@ -2,6 +2,10 @@ package com.superbiz.agent.harness.tool.contract; import com.fasterxml.jackson.annotation.JsonProperty; +/** + * RAG 业务输入(冻结契约):单个检索查询关键词。 + */ public record RagToolRequest( + /** 检索 query(判重指纹的一部分)。 */ @JsonProperty("query") String query) { } diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/RagToolResult.java b/src/main/java/com/superbiz/agent/harness/tool/contract/RagToolResult.java index 563b03b..0efea6b 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/RagToolResult.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/RagToolResult.java @@ -6,20 +6,31 @@ import com.superbiz.agent.harness.contract.EvidenceStatus; import java.util.List; +/** + * RAG 工具结果(冻结契约):Projector 投影后的有界结果。 + * 是模型看到的 observation 与 canonical 记录的 agent_result 的统一形态。 + */ public record RagToolResult( + /** 证据语义:证据数组空不空(客观判定)。 */ @JsonProperty("evidence_status") EvidenceStatus evidenceStatus, @JsonProperty("tool_call_id") String toolCallId, + /** 原始检索 query。 */ @JsonProperty("query") String query, + /** 投影后的证据列表(有界、截断过)。 */ @JsonProperty("evidence") List evidence, + /** 返回的证据条数。 */ @JsonProperty("returned_count") int returnedCount, + /** 检索相关度(仅 EVIDENCE_FOUND 时有值;NO_EVIDENCE 时为 null 不序列化)。 */ @JsonProperty("relevance_level") @JsonInclude(JsonInclude.Include.NON_NULL) RagRelevanceLevel relevanceLevel, + /** 是否因预算截断。 */ @JsonProperty("truncated") boolean truncated) { public RagToolResult { evidence = ToolContractCollections.immutable(evidence); } + /** 便捷构造:无相关度(NO_EVIDENCE / 内部使用)。 */ public RagToolResult(EvidenceStatus evidenceStatus, String toolCallId, String query, diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/ToolContractCollections.java b/src/main/java/com/superbiz/agent/harness/tool/contract/ToolContractCollections.java index 0a4967b..15e012a 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/ToolContractCollections.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/ToolContractCollections.java @@ -5,15 +5,21 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +/** + * 契约对象的不可变集合工具(包私有):所有 Result 的列表字段统一用这里保证不可变, + * 防止投影层/消费方意外修改冻结契约。 + */ final class ToolContractCollections { private ToolContractCollections() { } + /** 列表不可变拷贝(null → 空列表)。 */ static List immutable(List values) { return values == null ? List.of() : List.copyOf(values); } + /** 行集合不可变拷贝:每行 Map 也做深拷贝(嵌套不可变)。 */ static List> immutableRows(List> rows) { if (rows == null) { return List.of(); @@ -23,6 +29,7 @@ final class ToolContractCollections { .toList(); } + /** 单行不可变拷贝(null → 空 Map)。 */ private static Map immutableRow(Map row) { if (row == null) { return Map.of(); diff --git a/src/main/java/com/superbiz/agent/harness/tool/mysql/JdbcMysqlReadOnlyExecutor.java b/src/main/java/com/superbiz/agent/harness/tool/mysql/JdbcMysqlReadOnlyExecutor.java index 366d69c..7592e34 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/mysql/JdbcMysqlReadOnlyExecutor.java +++ b/src/main/java/com/superbiz/agent/harness/tool/mysql/JdbcMysqlReadOnlyExecutor.java @@ -20,11 +20,15 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; -/** JDBC implementation with read-only, timeout, row and cancellation controls. */ +/** + * JDBC 只读执行器:连接强制只读 + 查询超时 + 行数上限 + Run 取消联动。 + * 是 MysqlReadOnlyExecutor 的唯一实现——沙箱的「执行侧」防线。 + */ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor { private static final Logger log = LoggerFactory.getLogger(JdbcMysqlReadOnlyExecutor.class); + /** 逻辑数据源 id → 真实 DataSource 映射(配置时注入)。 */ private final Map dataSources; private final Clock clock; @@ -41,13 +45,16 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor { } MysqlToolLimits limits = plan.dataSource().limits(); try (Connection connection = dataSource.getConnection()) { + // 强制只读连接(双保险:Validator 语义层 + JDBC 连接层) connection.setReadOnly(true); try (PreparedStatement statement = connection.prepareStatement( plan.normalizedSql(), ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { statement.setQueryTimeout(limits.queryTimeoutSeconds()); + // 多取一行用于检测截断 statement.setMaxRows(limits.maxRows() + 1); bind(statement, plan.params()); + // 注册取消回调:Run 取消时同步 cancel 正在执行的语句 AtomicReference statementRef = new AtomicReference<>(statement); context.cancellation().onCancel(ignored -> cancel(statementRef.get())); checkRun(context); @@ -61,6 +68,7 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor { java.util.ArrayList> rows = new java.util.ArrayList<>(); boolean truncated = false; while (resultSet.next()) { + // 每行前检查 Run 终态/取消/超时 checkRun(context); if (rows.size() >= limits.maxRows()) { truncated = true; @@ -74,6 +82,7 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor { truncated |= cell.truncated(); } rows.add(row); + // 结果字节预算:超限移除最后一行并标记截断 if (estimatedBytes(rows) > limits.maxResultBytes()) { rows.remove(rows.size() - 1); truncated = true; @@ -86,6 +95,7 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor { } } } catch (MysqlSecurityException e) { + // 安全异常原样穿出(Adapter 映射稳定错误码) throw e; } catch (SQLException e) { log.debug("MySQL read-only execution failed: sqlState={}", e.getSQLState()); @@ -93,18 +103,21 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor { } } + /** 绑定参数(PreparedStatement 参数化,防注入)。 */ private static void bind(PreparedStatement statement, List params) throws SQLException { for (int i = 0; i < params.size(); i++) { statement.setObject(i + 1, params.get(i)); } } + /** 执行前/每行检查:Run 已取消或过 deadline 则中止(与 core 终态联动)。 */ private void checkRun(RunContext context) throws SQLException { if (context.cancellation().isCancelled() || !clock.instant().isBefore(context.deadline())) { throw new SQLException("run cancelled or deadline exceeded"); } } + /** 取消正在执行的语句(Run 取消回调)。 */ private static void cancel(Statement statement) { if (statement == null) { return; @@ -116,6 +129,9 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor { } } + /** + * 单元格 JSON 安全化:数字/布尔原样;byte[] 转 Base64;字符串截断到 maxCellChars。 + */ private static CellValue jsonSafe(Object value, int maxCellChars) { if (value == null || value instanceof Number || value instanceof Boolean) { return new CellValue(value, false); @@ -131,6 +147,7 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor { : new CellValue(text.substring(0, maxCellChars), true); } + /** 行集预估字节数(结果预算用)。 */ private static int estimatedBytes(List> rows) { return rows.toString().getBytes(StandardCharsets.UTF_8).length; } diff --git a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlDataSourceDefinition.java b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlDataSourceDefinition.java index 45fef7c..d74035b 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlDataSourceDefinition.java +++ b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlDataSourceDefinition.java @@ -7,11 +7,18 @@ import java.util.Objects; import java.util.Set; import java.util.TreeSet; -/** Logical datasource metadata and exact schema/table/column authorization. */ +/** + * 逻辑数据源元数据 + 精确的 schema/表/列授权白名单: + * 模型只能查询白名单内的表与列——这是 MySQL 只读沙箱的「访问边界」。 + */ public record MysqlDataSourceDefinition( + /** 逻辑数据源 id(模型用这个,不暴露真实 JDBC)。 */ String id, + /** 默认 schema(必须出现在白名单里)。 */ String defaultSchema, + /** 授权白名单:schema → table → 允许的列集合。 */ Map>> allowedSchemas, + /** 该数据源的查询限制。 */ MysqlToolLimits limits) { public MysqlDataSourceDefinition { @@ -19,6 +26,7 @@ public record MysqlDataSourceDefinition( requireText(defaultSchema, "defaultSchema"); Objects.requireNonNull(allowedSchemas, "allowedSchemas must not be null"); Objects.requireNonNull(limits, "limits must not be null"); + // 深拷贝白名单(列集合 TreeSet 排序保证确定性),防止外部修改 Map>> schemas = new LinkedHashMap<>(); allowedSchemas.forEach((schema, tables) -> { requireText(schema, "schema"); @@ -35,10 +43,12 @@ public record MysqlDataSourceDefinition( } } + /** 表是否被授权。 */ public boolean allowsTable(String schema, String table) { return allowedSchemas.containsKey(schema) && allowedSchemas.get(schema).containsKey(table); } + /** 列是否被授权。 */ public boolean allowsColumn(String schema, String table, String column) { return allowsTable(schema, table) && allowedSchemas.get(schema).get(table).contains(column); } diff --git a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlQueryPlan.java b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlQueryPlan.java index e104d33..771e0b1 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlQueryPlan.java +++ b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlQueryPlan.java @@ -4,10 +4,18 @@ import com.superbiz.agent.harness.tool.contract.MysqlToolRequest; import java.util.List; +/** + * 一次 MySQL 查询的执行计划:请求 + 解析出的逻辑数据源 + 规范化 SQL + 绑定参数。 + * 由 MysqlToolAdapter 在 SQL 校验后构造,交给只读执行器执行。 + */ public record MysqlQueryPlan( + /** 模型原始请求(data_source/sql/params)。 */ MysqlToolRequest request, + /** 解析出的授权数据源定义(含 schema/表/列白名单与限制)。 */ MysqlDataSourceDefinition dataSource, + /** 校验并规范化后的 SQL(单条、无尾分号、禁注释等)。 */ String normalizedSql, + /** 绑定参数(防注入)。 */ List params) { public MysqlQueryPlan { diff --git a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlRawResult.java b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlRawResult.java index 3f78946..a674a2d 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlRawResult.java +++ b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlRawResult.java @@ -5,7 +5,10 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; -/** Harness-only raw query result; never returned directly to an Agent. */ +/** + * Harness-only raw 查询结果(不直接给 Agent):列 + 行 + 是否截断。 + * 之后由 MysqlResultProjector 投影成冻结的 MysqlToolResult 才对外。 + */ public record MysqlRawResult( List columns, List> rows, diff --git a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlReadOnlyExecutor.java b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlReadOnlyExecutor.java index 098c1c2..9942699 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlReadOnlyExecutor.java +++ b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlReadOnlyExecutor.java @@ -2,7 +2,12 @@ package com.superbiz.agent.harness.tool.mysql; import com.superbiz.agent.harness.core.RunContext; +/** + * MySQL 只读执行端口(函数式):输入已校验的查询计划 + Run 上下文,输出 raw 结果。 + * JdbcMysqlReadOnlyExecutor 是唯一实现(只读连接 + 超时 + 行数 + 取消控制)。 + */ @FunctionalInterface public interface MysqlReadOnlyExecutor { + /** 执行只读查询,返回 Harness-only raw 结果;失败抛异常(安全/超时/SQL)。 */ MysqlRawResult execute(MysqlQueryPlan plan, RunContext context) throws Exception; } diff --git a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlResultProjector.java b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlResultProjector.java index 08c37e1..26703e6 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlResultProjector.java +++ b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlResultProjector.java @@ -14,7 +14,16 @@ import java.util.List; import java.util.Locale; import java.util.Map; -/** Projects raw JDBC rows into the bounded Agent-facing MySQL contract. */ +/** + * 把 raw JDBC 行投影成有界、脱敏的 Agent 可见 MySQL 契约。 + * + *

关键职责: + *

    + *
  • 脱敏:列名含 password/token/secret/api_key 等敏感 token 时单元格置为 [REDACTED];
  • + *
  • 有界:行数(maxRows)+ 单元格字符(maxCellChars)+ 总字节(maxResultBytes)三重截断;
  • + *
  • 客观证据语义:rows 空不空 → NO_EVIDENCE / EVIDENCE_FOUND。
  • + *
+ */ public final class MysqlResultProjector { private static final List SENSITIVE_TOKENS = List.of( @@ -37,6 +46,12 @@ public final class MysqlResultProjector { return project(request, toolCallId, rawResponse, limits); } + /** + * 主入口:raw JDBC 行 JSON → 冻结的 MysqlToolResult。 + * + *

流程:校验 columns/rows 结构 → 列名去重 → 逐行逐列投影(敏感列脱敏 + 字符截断) + * → 行数/字节截断 → 判定 evidence status → fitBudget 总字节兜底。 + */ public ProjectedToolResult project(MysqlToolRequest request, String toolCallId, String rawResponse, MysqlToolLimits projectionLimits) throws Exception { if (request == null || toolCallId == null || toolCallId.isBlank()) { @@ -49,6 +64,7 @@ public final class MysqlResultProjector { } List columns = new ArrayList<>(); java.util.LinkedHashSet uniqueColumns = new java.util.LinkedHashSet<>(); + // 列名必须非空且唯一(避免歧义投影) root.path("columns").forEach(node -> { String column = node.asText(); if (column.isBlank() || !uniqueColumns.add(column)) { @@ -59,6 +75,7 @@ public final class MysqlResultProjector { List> rows = new ArrayList<>(); boolean truncated = root.path("truncated").asBoolean(false); for (JsonNode rowNode : root.path("rows")) { + // 行数上限:超出置 truncated 并停止 if (rows.size() >= projectionLimits.maxRows()) { truncated = true; break; @@ -71,12 +88,14 @@ public final class MysqlResultProjector { truncated |= cell.truncated(); } rows.add(row); + // 结果字节预算:超限移除最后一行并标记截断 if (utf8Bytes(rows.toString()) > projectionLimits.maxResultBytes()) { rows.remove(rows.size() - 1); truncated = true; break; } } + // 客观证据语义:行空不空 MysqlToolResult result = new MysqlToolResult( rows.isEmpty() ? EvidenceStatus.NO_EVIDENCE : EvidenceStatus.EVIDENCE_FOUND, toolCallId, columns, rows, rows.size(), truncated); @@ -84,6 +103,10 @@ public final class MysqlResultProjector { return new ProjectedToolResult(objectMapper.writeValueAsString(result), result.evidenceStatus()); } + /** + * 总字节兜底:超过 maxResultBytes 时逐行裁掉尾部;裁空则诚实降级为 NO_EVIDENCE; + * 仍超限则抛异常(fail closed)。 + */ private MysqlToolResult fitBudget(MysqlToolResult result, int maxResultBytes) throws Exception { MysqlToolResult current = result; while (utf8Bytes(objectMapper.writeValueAsString(current)) > maxResultBytes @@ -100,11 +123,16 @@ public final class MysqlResultProjector { return current; } + /** + * 单单元格投影:null 原样;敏感列 → [REDACTED](含脱敏标记); + * 数字/布尔原样;字符串截断到 maxCellChars。 + */ private CellProjection projectCell(String column, JsonNode value, int maxCellChars) { if (value == null || value.isNull()) { return new CellProjection(null, false); } if (isSensitive(column)) { + // 敏感列(password/token/secret 等):绝不把真实值给 Agent return new CellProjection("[REDACTED]", true); } if (value.isNumber()) { @@ -119,6 +147,7 @@ public final class MysqlResultProjector { : new CellProjection(text.substring(0, maxCellChars), true); } + /** 列名是否含敏感 token(password/passwd/token/secret/api_key/apikey/credential)。 */ private static boolean isSensitive(String column) { String normalized = column == null ? "" : column.toLowerCase(Locale.ROOT); return SENSITIVE_TOKENS.stream().anyMatch(normalized::contains); diff --git a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlSecurityException.java b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlSecurityException.java index 54d1d6b..911b11f 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlSecurityException.java +++ b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlSecurityException.java @@ -1,5 +1,9 @@ package com.superbiz.agent.harness.tool.mysql; +/** + * MySQL 沙箱安全异常:触发只读红线/未授权访问/危险 SQL 时抛出, + * 由 Adapter 映射为稳定的错误码(而非泄露内部细节)。 + */ public final class MysqlSecurityException extends RuntimeException { public MysqlSecurityException(String message) { diff --git a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlSqlValidator.java b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlSqlValidator.java index 5f818f8..28c5fdb 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlSqlValidator.java +++ b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlSqlValidator.java @@ -41,7 +41,18 @@ import java.util.Map; import java.util.Objects; import java.util.Set; -/** Fail-closed SQL policy for the Agent-facing MySQL Tool. */ +/** + * MySQL 工具的 fail-closed SQL 策略(沙箱的「语义层」防线): + * 用 JSqlParser 解析 AST,逐一拒绝所有不安全形态。 + * + *

禁止:非 SELECT / 多条语句 / WITH / 子查询 / 通配符投影(*)/ + * 窗口函数 / CASE / EXISTS / 分层查询 / 字面量(必须参数化)/ 未授权表/列 / + * 锁读 / 复杂子句(OFFSET/FETCH/TOP 等)。 + * + *

允许:白名单内表与列的 INNER/LEFT JOIN、聚合函数 + * (COUNT/SUM/AVG/MIN/MAX)、占位符参数——且占位符数量必须与 params 匹配。 + * 任何解析/校验异常统一转 MysqlSecurityException(fail closed,不泄露细节)。 + */ public final class MysqlSqlValidator { private static final Set ALLOWED_FUNCTIONS = Set.of("COUNT", "SUM", "AVG", "MIN", "MAX"); @@ -53,11 +64,15 @@ public final class MysqlSqlValidator { this.dataSources = Map.copyOf(dataSources); } + /** + * 校验并生成执行计划。全流程 fail-closed:任何一步不满足直接抛 MysqlSecurityException。 + */ public MysqlQueryPlan validate(MysqlToolRequest request) { if (request == null || request.dataSource() == null || request.dataSource().isBlank() || request.sql() == null || request.sql().isBlank()) { throw new MysqlSecurityException("data_source and sql are required"); } + // 数据源必须存在于授权映射 MysqlDataSourceDefinition dataSource = dataSources.get(request.dataSource()); if (dataSource == null) { throw new MysqlSecurityException("unknown logical data source"); @@ -66,6 +81,7 @@ public final class MysqlSqlValidator { throw new MysqlSecurityException("SQL exceeds policy length"); } try { + // 必须恰好一条语句,且是 SELECT Statements statements = CCJSqlParserUtil.parseStatements(request.sql()); if (statements.getStatements() == null || statements.getStatements().size() != 1) { throw new MysqlSecurityException("exactly one SQL statement is required"); @@ -78,6 +94,7 @@ public final class MysqlSqlValidator { throw new MysqlSecurityException("WITH is not allowed"); } SelectBody body = select.getSelectBody(); + // 只允许普通 PlainSelect(无集合操作/值语句) if (!(body instanceof PlainSelect plainSelect) || body instanceof SetOperationList || body instanceof ValuesStatement) { @@ -97,10 +114,12 @@ public final class MysqlSqlValidator { throw new MysqlSecurityException("unsupported SELECT clause"); } + // 表注册:FROM 必须是白名单内的实体表(禁子查询/非表来源),重复别名拒绝 Map tables = new LinkedHashMap<>(); registerTable(plainSelect.getFromItem(), dataSource, tables); List joins = plainSelect.getJoins() == null ? List.of() : plainSelect.getJoins(); for (Join join : joins) { + // 只允许 INNER/LEFT JOIN(禁 CROSS/RIGHT/FULL/OUTER) if (join.isCross() || join.isRight() || join.isFull() || join.isOuter() || (!join.isInner() && !join.isLeft())) { throw new MysqlSecurityException("only INNER/LEFT JOIN is allowed"); @@ -117,6 +136,7 @@ public final class MysqlSqlValidator { } } + // 投影必须显式(禁 * / t.*),所有表达式逐节点校验 if (plainSelect.getSelectItems() == null || plainSelect.getSelectItems().isEmpty()) { throw new MysqlSecurityException("projection must be explicit"); } @@ -129,6 +149,7 @@ public final class MysqlSqlValidator { } validateExpression(expressionItem.getExpression(), tables, dataSource); } + // WHERE/HAVING/GROUP BY/ORDER BY 全表达式校验 validateExpression(plainSelect.getWhere(), tables, dataSource); validateExpression(plainSelect.getHaving(), tables, dataSource); if (plainSelect.getGroupBy() != null) { @@ -140,6 +161,7 @@ public final class MysqlSqlValidator { plainSelect.getOrderByElements().forEach(order -> validateExpression(order.getExpression(), tables, dataSource)); } + // 占位符数量必须与 params 匹配(防止参数错位/少传) int placeholders = countPlaceholders(plainSelect); int provided = request.params() == null ? 0 : request.params().size(); if (placeholders != provided) { @@ -148,12 +170,15 @@ public final class MysqlSqlValidator { return new MysqlQueryPlan(request, dataSource, statement.toString(), request.params() == null ? List.of() : request.params()); } catch (MysqlSecurityException e) { + // 业务规则违规:原样穿出(已分类) throw e; } catch (Exception e) { + // 解析/其他异常:统一 fail closed(不泄露内部细节) throw new MysqlSecurityException("SQL cannot be safely validated", e); } } + /** 注册 FROM 表:必须是白名单内实体表,别名唯一。 */ private void registerTable(FromItem item, MysqlDataSourceDefinition dataSource, Map tables) { if (!(item instanceof Table table)) { @@ -174,6 +199,7 @@ public final class MysqlSqlValidator { } } + /** 表达式逐节点校验:列白名单 / 函数白名单 / 禁字面量、子查询、通配符、窗口函数等。 */ private void validateExpression(Expression expression, Map tables, MysqlDataSourceDefinition dataSource) { if (expression == null) { @@ -282,6 +308,10 @@ public final class MysqlSqlValidator { }); } + /** + * 列校验:限定表时查该表列白名单;未限定时必须在已注册表中唯一匹配 + * (否则视为歧义或未授权)。 + */ private void validateColumn(Column column, Map tables, MysqlDataSourceDefinition dataSource) { String name = column.getColumnName(); @@ -290,12 +320,14 @@ public final class MysqlSqlValidator { } Table table = column.getTable(); if (table != null && table.getName() != null && !table.getName().isBlank()) { + // 限定表:必须在白名单内 TableRef ref = tables.get(table.getName().toLowerCase(Locale.ROOT)); if (ref == null || !dataSource.allowsColumn(ref.schema(), ref.table(), name)) { throw new MysqlSecurityException("column is not allowlisted"); } return; } + // 未限定表:必须在已注册表中恰好一个白名单命中 List matches = tables.values().stream() .filter(ref -> dataSource.allowsColumn(ref.schema(), ref.table(), name)) .toList(); @@ -304,6 +336,10 @@ public final class MysqlSqlValidator { } } + /** + * 统计 AST 中的占位符数(必须与 params 数量一致): + * 只在 AST 接受后按节点计数,引号内的问号不会被误计。 + */ private static int countPlaceholders(PlainSelect plainSelect) { // Parser assigns JdbcParameter nodes; use the canonical SQL token count only after // the AST has been accepted, so quoted question marks are not counted. @@ -337,6 +373,7 @@ public final class MysqlSqlValidator { return counter.count; } + /** 占位符计数 visitor:统计 JdbcParameter;子查询继续拒绝。 */ private static final class PlaceholderCounter extends ExpressionVisitorAdapter { private int count; @@ -351,6 +388,7 @@ public final class MysqlSqlValidator { } } + /** 已注册表引用(schema + 表名),用于列白名单校验。 */ private record TableRef(String schema, String table) { } } diff --git a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlToolLimits.java b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlToolLimits.java index bdde47d..a922d73 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlToolLimits.java +++ b/src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlToolLimits.java @@ -1,9 +1,17 @@ package com.superbiz.agent.harness.tool.mysql; +/** + * MySQL 工具限制:行数 / 单元格字符 / 结果字节 / 查询超时。 + * 防止超大结果进 Agent 上下文,防长时间占用连接。 + */ public record MysqlToolLimits( + /** 最大返回行数。 */ int maxRows, + /** 单单元格最大字符数(超长截断)。 */ int maxCellChars, + /** 结果总字节上限。 */ int maxResultBytes, + /** 查询超时秒数。 */ int queryTimeoutSeconds) { public MysqlToolLimits { @@ -12,6 +20,7 @@ public record MysqlToolLimits( } } + /** 默认:100 行 / 2000 字单元 / 64KB 总字节 / 5 秒超时。 */ public static MysqlToolLimits defaults() { return new MysqlToolLimits(100, 2_000, 64 * 1024, 5); } diff --git a/src/main/java/com/superbiz/agent/harness/tool/projection/QueryLogsResultProjector.java b/src/main/java/com/superbiz/agent/harness/tool/projection/QueryLogsResultProjector.java index cc18a37..3af9797 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/projection/QueryLogsResultProjector.java +++ b/src/main/java/com/superbiz/agent/harness/tool/projection/QueryLogsResultProjector.java @@ -19,7 +19,17 @@ import java.util.List; import java.util.Map; import java.util.regex.Pattern; -/** Projects legacy Mock log JSON into the frozen query_logs contract. */ +/** + * 把 legacy Mock 日志 JSON 投影成冻结的 query_logs 契约。 + * + *

关键职责: + *

    + *
  • 脱敏:sanitize 抹掉消息里的密码/token/主机/Pod/IP/端口等敏感信息,不进入 Agent 上下文;
  • + *
  • 有界:条数截断(maxEvents)+ 均匀采样 + 总字节兜底(fitBudget);
  • + *
  • 聚合:按「级别+服务+规范化消息」压缩成 PatternAccumulator(模型看全貌不读每条);
  • + *
  • 客观证据语义:allEvents 空不空 → NO_EVIDENCE / EVIDENCE_FOUND。
  • + *
+ */ public final class QueryLogsResultProjector { private static final Pattern SECRET = Pattern.compile( @@ -41,6 +51,12 @@ public final class QueryLogsResultProjector { this.limits = limits; } + /** + * 主入口:legacy 日志 raw JSON → 冻结的 query_logs 契约。 + * + *

流程:校验 raw(success 标记)→ 逐条脱敏+聚合 → 均匀采样截断 → + * 生成 patterns 聚合 → 判定 evidence status → fitBudget 总字节兜底。 + */ public ProjectedToolResult project(QueryLogsRequest request, String toolCallId, LogQueryScope scope, String rawResponse) throws Exception { if (request == null || toolCallId == null || toolCallId.isBlank() || scope == null) { @@ -60,6 +76,7 @@ public final class QueryLogsResultProjector { List allEvents = new ArrayList<>(); Map aggregates = new LinkedHashMap<>(); for (JsonNode log : logs) { + // 逐条:字段截断 + 消息脱敏(敏感信息不进 Agent 上下文) String timestamp = bounded(text(log, "timestamp"), limits.maxMessageChars()); String level = bounded(text(log, "level"), 32); String service = bounded(text(log, "service"), 128); @@ -70,13 +87,16 @@ public final class QueryLogsResultProjector { } String example = message; allEvents.add(new LogEvent(nullable(timestamp), nullable(level), nullable(service), message)); + // 聚合键:级别+服务+规范化消息(数字→) String patternKey = level + "\u0000" + service + "\u0000" + normalizePattern(message); aggregates.computeIfAbsent(patternKey, ignored -> new PatternAccumulator(level, service, example)) .add(timestamp); } + // 均匀采样截断(避免只保留头部事件,牺牲时间分布) List events = sample(allEvents, limits.maxEvents()); truncated |= events.size() < allEvents.size(); + // 模式聚合按次数降序 + 示例升序,限制条数 List patterns = aggregates.values().stream() .sorted(Comparator.comparingLong(PatternAccumulator::count).reversed() .thenComparing(PatternAccumulator::example)) @@ -86,6 +106,7 @@ public final class QueryLogsResultProjector { truncated |= aggregates.size() > patterns.size(); long matchCount = root.path("total").canConvertToLong() ? root.path("total").asLong() : allEvents.size(); + // 客观证据语义:事件空不空 QueryLogsToolResult result = new QueryLogsToolResult( allEvents.isEmpty() ? EvidenceStatus.NO_EVIDENCE : EvidenceStatus.EVIDENCE_FOUND, toolCallId, @@ -100,6 +121,10 @@ public final class QueryLogsResultProjector { return new ProjectedToolResult(objectMapper.writeValueAsString(result), result.evidenceStatus()); } + /** + * 总字节兜底:超过 maxAgentUtf8Bytes 时先裁事件尾部、再裁模式尾部; + * 仍超限则抛异常(fail closed)。 + */ private QueryLogsToolResult fitBudget(QueryLogsToolResult result) throws Exception { QueryLogsToolResult current = result; while (bytes(objectMapper.writeValueAsString(current)) > limits.maxAgentUtf8Bytes() @@ -121,6 +146,9 @@ public final class QueryLogsResultProjector { return current; } + /** + * 均匀采样:超过 max 时按时间等距取 max 条(保留时间分布,而非只留头部)。 + */ private static List sample(List events, int max) { if (events.size() <= max) { return List.copyOf(events); @@ -133,6 +161,11 @@ public final class QueryLogsResultProjector { return sampled; } + /** + * 消息脱敏(敏感信息不进 Agent 上下文/审计): + * 密码/token/secret/api-key → REDACTED;Pod/主机/端口/PID/IP → REDACTED_*; + * SQL 字符串字面量 → '[REDACTED_LITERAL]';去掉 Java 堆栈尾部。 + */ private static String sanitize(String message) { String value = SECRET.matcher(message).replaceAll("$1=[REDACTED]"); value = POD.matcher(value).replaceAll("[REDACTED_POD]"); @@ -143,6 +176,7 @@ public final class QueryLogsResultProjector { return STACK_SUFFIX.matcher(value).replaceAll("").trim(); } + /** 模式归一化:数字(含小数)→ <n>,压缩空白——让相似消息聚合到同一模式。 */ private static String normalizePattern(String message) { return message.replaceAll("\\b\\d+(?:\\.\\d+)?\\b", "") .replaceAll("\\s+", " ").trim(); @@ -168,6 +202,7 @@ public final class QueryLogsResultProjector { return value.getBytes(StandardCharsets.UTF_8).length; } + /** 单个模式聚合器:同键(级别+服务+归一化消息)事件累加 count,记录首末时间。 */ private static final class PatternAccumulator { private final String level; private final String service; @@ -182,6 +217,7 @@ public final class QueryLogsResultProjector { this.example = example; } + /** 累加一条事件:count++ 并更新首末时间窗。 */ private PatternAccumulator add(String timestamp) { count++; if (firstSeen == null || timestamp.compareTo(firstSeen) < 0) { @@ -201,6 +237,7 @@ public final class QueryLogsResultProjector { return example; } + /** 转成冻结契约 LogPattern。 */ private LogPattern toPattern() { return new LogPattern(count, firstSeen, lastSeen, nullable(level), nullable(service), example); } diff --git a/src/main/java/com/superbiz/agent/harness/tool/projection/RagResultProjector.java b/src/main/java/com/superbiz/agent/harness/tool/projection/RagResultProjector.java index 4170c91..002c00a 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/projection/RagResultProjector.java +++ b/src/main/java/com/superbiz/agent/harness/tool/projection/RagResultProjector.java @@ -32,6 +32,12 @@ public final class RagResultProjector { this.limits = limits; } + /** + * 主入口:legacy raw JSON → 冻结的 Agent 可见 RAG 契约。 + * + *

流程:解析 evidenceBlocks → 按 chunk 级身份去重 → 截断摘录/条数 → + * 判定 evidence status(空不空)→ 计算相关度 → fitBudget 总字节兜底。 + */ public ProjectedToolResult project(RagToolRequest request, String toolCallId, String rawResponse) throws Exception { if (request == null || toolCallId == null || toolCallId.isBlank()) { @@ -52,10 +58,12 @@ public final class RagResultProjector { int ordinal = 0; for (JsonNode block : blocks) { ordinal++; + // 条数上限:超出置 truncated 并停止 if (evidence.size() >= limits.maxEvidence()) { truncated = true; break; } + // 摘录取 content(回退 excerpt),空则跳过该块 String excerpt = text(block, "content"); if (excerpt.isBlank()) { excerpt = text(block, "excerpt"); @@ -66,6 +74,7 @@ public final class RagResultProjector { String source = text(block, "source"); String title = text(block, "title"); // Chunk-scoped identity first; do not collapse on source alone. + // 证据身份优先级:evidenceKey → document_id → docId#chunk-idx → legacy 序号 String documentId = firstPresent( text(block, "evidenceKey"), text(block, "evidence_key"), @@ -75,6 +84,7 @@ public final class RagResultProjector { text(block, "chunkIndex"), text(block, "chunk_index")), "legacy-evidence-" + ordinal); if (!evidenceIds.add(documentId)) { + // 重复 chunk:跳过但标记 truncated(被去重) truncated = true; continue; } @@ -92,6 +102,7 @@ public final class RagResultProjector { } } + // 客观证据语义:证据数组空不空(不需要模型判断) EvidenceStatus status = evidence.isEmpty() ? EvidenceStatus.NO_EVIDENCE : EvidenceStatus.EVIDENCE_FOUND; @@ -103,6 +114,7 @@ public final class RagResultProjector { return new ProjectedToolResult(objectMapper.writeValueAsString(result), result.evidenceStatus()); } + /** 由 docId + chunkIndex 组合 chunk 级证据身份(两者都缺则返回 null)。 */ private static String composeChunkId(String docId, String docIdAlt, String chunkIndex, String chunkIndexAlt) { String id = firstPresentOrNull(docId, docIdAlt); String idx = firstPresentOrNull(chunkIndex, chunkIndexAlt); @@ -112,11 +124,13 @@ public final class RagResultProjector { return id + "#chunk-" + idx; } + /** 取第一个非空值,全空回退 "unknown-document"。 */ private static String firstPresent(String... values) { String found = firstPresentOrNull(values); return found == null ? "unknown-document" : found; } + /** 取第一个非空值,全空返回 null。 */ private static String firstPresentOrNull(String... values) { if (values == null) { return null; @@ -129,6 +143,10 @@ public final class RagResultProjector { return null; } + /** + * 总字节兜底:投影结果超过 maxAgentUtf8Bytes 时逐条裁掉尾部证据 + * (裁空则诚实降级为 NO_EVIDENCE);仍超限则抛异常(fail closed)。 + */ private RagToolResult fitBudget(RagToolResult result, boolean truncated) throws Exception { RagToolResult current = result; while (bytes(objectMapper.writeValueAsString(current)) > limits.maxAgentUtf8Bytes() @@ -155,6 +173,7 @@ public final class RagResultProjector { return value == null || value.isBlank() ? null : value; } + /** 从 raw 读取相关度(relevance_level / relevanceLevel),无法解析返回 null。 */ private static RagRelevanceLevel relevanceLevel(JsonNode root) { String value = text(root, "relevance_level"); if (value.isBlank()) { @@ -170,6 +189,7 @@ public final class RagResultProjector { } } + /** 截断到 max 字符(null 视为空串)。 */ private static String bounded(String value, int max) { if (value == null) { return ""; @@ -177,6 +197,7 @@ public final class RagResultProjector { return value.length() <= max ? value : value.substring(0, max); } + /** UTF-8 字节数(投影总预算用)。 */ private static int bytes(String value) { return value.getBytes(StandardCharsets.UTF_8).length; } diff --git a/src/main/java/com/superbiz/agent/harness/tool/projection/ToolProjectionLimits.java b/src/main/java/com/superbiz/agent/harness/tool/projection/ToolProjectionLimits.java index 634bcd3..366d71b 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/projection/ToolProjectionLimits.java +++ b/src/main/java/com/superbiz/agent/harness/tool/projection/ToolProjectionLimits.java @@ -1,13 +1,23 @@ package com.superbiz.agent.harness.tool.projection; -/** Bounds applied to Agent-facing projections. */ +/** + * 投影层的有界限制:raw → agent 契约投影时的全部硬上限。 + * 保证模型看到的任何结果都有界(数量/长度/字节),防止超大响应进入上下文。 + */ public record ToolProjectionLimits( + /** RAG 最大证据条数。 */ int maxEvidence, + /** RAG 单条摘录最大字符数。 */ int maxExcerptChars, + /** 日志最大模式聚合条数。 */ int maxPatterns, + /** 日志最大事件条数。 */ int maxEvents, + /** 日志单条消息最大字符数。 */ int maxMessageChars, + /** 查询关键词最大字符数。 */ int maxQueryChars, + /** 投影结果最大 UTF-8 字节数(总预算)。 */ int maxAgentUtf8Bytes) { public ToolProjectionLimits { @@ -18,6 +28,7 @@ public record ToolProjectionLimits( } } + /** 默认限制:8 条证据 / 1200 字摘录 / 12 模式 / 30 事件 / 1000 字消息 / 500 字查询 / 16KB 总字节。 */ public static ToolProjectionLimits defaults() { return new ToolProjectionLimits(8, 1200, 12, 30, 1000, 500, 16_384); } diff --git a/src/main/java/com/superbiz/agent/harness/tool/store/CanonicalInvocationLimits.java b/src/main/java/com/superbiz/agent/harness/tool/store/CanonicalInvocationLimits.java index 78a91de..4465865 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/store/CanonicalInvocationLimits.java +++ b/src/main/java/com/superbiz/agent/harness/tool/store/CanonicalInvocationLimits.java @@ -4,9 +4,16 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Objects; +/** + * canonical 记录的大小与 TTL 限制:执行门禁(ToolBoundary)和 Redis 实现共用。 + * 三条校验分别对应执行链的 request+raw、agent_result、序列化后的整条记录。 + */ public record CanonicalInvocationLimits( + /** 记录 TTL(过期后不可引用,ProgressProjector/EvidenceGuard 会排除)。 */ Duration ttl, + /** request + raw_response 合计字节上限。 */ long maxRecordBytes, + /** agent_result 字节上限(须 ≤ maxRecordBytes)。 */ long maxAgentResultBytes) { public CanonicalInvocationLimits { @@ -23,6 +30,7 @@ public record CanonicalInvocationLimits( ttl.toMillis(); } + /** 校验 request + raw_response 未超上限(阶段三)。 */ public void validateRawCandidate(String request, String rawResponse) { long actual = utf8Bytes(request) + utf8Bytes(rawResponse); if (actual > maxRecordBytes) { @@ -30,6 +38,7 @@ public record CanonicalInvocationLimits( } } + /** 校验 agent_result 未超上限(阶段五)。 */ public void validateAgentResult(String agentResult) { long actual = utf8Bytes(agentResult); if (actual > maxAgentResultBytes) { @@ -37,6 +46,7 @@ public record CanonicalInvocationLimits( } } + /** 校验序列化后的整条记录未超上限(Redis 写入前)。 */ public void validateSerializedRecord(String json) { long actual = utf8Bytes(json); if (actual > maxRecordBytes) { @@ -44,6 +54,7 @@ public record CanonicalInvocationLimits( } } + /** UTF-8 字节数(null 视为 0)。 */ public static long utf8Bytes(String value) { return value == null ? 0 : value.getBytes(StandardCharsets.UTF_8).length; } diff --git a/src/main/java/com/superbiz/agent/harness/tool/store/CanonicalInvocationStore.java b/src/main/java/com/superbiz/agent/harness/tool/store/CanonicalInvocationStore.java index d722920..487eea2 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/store/CanonicalInvocationStore.java +++ b/src/main/java/com/superbiz/agent/harness/tool/store/CanonicalInvocationStore.java @@ -5,20 +5,29 @@ import com.superbiz.agent.harness.contract.EvidenceStatus; import java.time.Instant; import java.util.Optional; +/** + * canonical 真相存储端口:一次 Tool 调用的完整生命周期(begin → markReady/markError → find)。 + * RedisCanonicalInvocationStore 是唯一实现;ToolBoundary 通过它落库,ProgressProjector/EvidenceGuard 通过它回读验真。 + */ public interface CanonicalInvocationStore { + /** 记录大小/TTL 限制(执行门禁与写入前校验共用)。 */ CanonicalInvocationLimits limits(); + /** 写入 PROJECTING 记录(同 key 重复 begin 抛 DuplicateInvocationException)。 */ void begin(String key, CanonicalToolInvocation invocation); + /** 按 key 查询记录(不存在或已过期返回 empty)。 */ Optional find(String key); + /** PROJECTING → READY 迁移:携带 raw + agent_result + 客观 evidence status + 完成时间。 */ CanonicalToolInvocation markReady(String key, String rawResponse, String agentResult, EvidenceStatus evidenceStatus, Instant completedAt); + /** PROJECTING → ERROR 迁移:携带 raw(尽力而为)+ 稳定错误码 + 完成时间。 */ CanonicalToolInvocation markError(String key, String rawResponse, String errorCode, diff --git a/src/main/java/com/superbiz/agent/harness/tool/store/CanonicalStoreException.java b/src/main/java/com/superbiz/agent/harness/tool/store/CanonicalStoreException.java index ccb8960..6b67b8b 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/store/CanonicalStoreException.java +++ b/src/main/java/com/superbiz/agent/harness/tool/store/CanonicalStoreException.java @@ -1,5 +1,9 @@ package com.superbiz.agent.harness.tool.store; +/** + * canonical 存储基础设施异常(非业务规则):序列化/反序列化/值类型错误等。 + * 由 ToolBoundary 映射为 STORE_ERROR 错误码。 + */ public class CanonicalStoreException extends RuntimeException { public CanonicalStoreException(String message) { diff --git a/src/main/java/com/superbiz/agent/harness/tool/store/DuplicateInvocationException.java b/src/main/java/com/superbiz/agent/harness/tool/store/DuplicateInvocationException.java index 5c125a3..2648904 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/store/DuplicateInvocationException.java +++ b/src/main/java/com/superbiz/agent/harness/tool/store/DuplicateInvocationException.java @@ -1,5 +1,9 @@ package com.superbiz.agent.harness.tool.store; +/** + * 同 key 重复 begin(幂等拦截):同一 runId+toolCallId 只允许 begin 一次。 + * ToolBoundary 映射为 DUPLICATE_TOOL_CALL 错误码。 + */ public final class DuplicateInvocationException extends CanonicalStoreException { public DuplicateInvocationException() { diff --git a/src/main/java/com/superbiz/agent/harness/tool/store/InvocationStateException.java b/src/main/java/com/superbiz/agent/harness/tool/store/InvocationStateException.java index a5e2768..b384dd0 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/store/InvocationStateException.java +++ b/src/main/java/com/superbiz/agent/harness/tool/store/InvocationStateException.java @@ -1,5 +1,9 @@ package com.superbiz.agent.harness.tool.store; +/** + * canonical 状态机违规:非法迁移(非 PROJECTING 时迁移)、记录缺失/已过期等。 + * 属于防御性异常(正常流程不应触发)。 + */ public final class InvocationStateException extends CanonicalStoreException { public InvocationStateException(String message) { diff --git a/src/main/java/com/superbiz/agent/harness/tool/store/RedisCanonicalInvocationStore.java b/src/main/java/com/superbiz/agent/harness/tool/store/RedisCanonicalInvocationStore.java index 05799d1..22fb303 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/store/RedisCanonicalInvocationStore.java +++ b/src/main/java/com/superbiz/agent/harness/tool/store/RedisCanonicalInvocationStore.java @@ -13,6 +13,16 @@ import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.function.UnaryOperator; +/** + * Redis 版 CanonicalInvocationStore:唯一真相源的持久化实现。 + * + *

关键点: + *

    + *
  • begin 用 setIfAbsent(原子)实现「同 key 只 begin 一次」→ 幂等拦截;
  • + *
  • 迁移(markReady/markError)用 update 读-改-写,保留剩余 TTL 续期;
  • + *
  • 记录过期(TTL)后 find 返回 empty——ProgressProjector/EvidenceGuard 据此排除。
  • + *
+ */ public final class RedisCanonicalInvocationStore implements CanonicalInvocationStore { private final RedisTemplate redisTemplate; @@ -43,6 +53,7 @@ public final class RedisCanonicalInvocationStore implements CanonicalInvocationS } String json = serialize(invocation); limits.validateSerializedRecord(json); + // 原子 SETNX:同 key 已存在 → 幂等拒绝(DUPLICATE_TOOL_CALL) Boolean created = values.setIfAbsent( key, json, limits.ttl().toMillis(), TimeUnit.MILLISECONDS); if (!Boolean.TRUE.equals(created)) { @@ -55,6 +66,7 @@ public final class RedisCanonicalInvocationStore implements CanonicalInvocationS requireKey(key); Object stored = values.get(key); if (stored == null) { + // 不存在或已过期(TTL 清除) return Optional.empty(); } if (!(stored instanceof String json)) { @@ -71,6 +83,7 @@ public final class RedisCanonicalInvocationStore implements CanonicalInvocationS Instant completedAt) { CanonicalToolInvocation existing = find(key) .orElseThrow(() -> new InvocationStateException("Canonical invocation is missing or expired")); + // 迁移前校验尺寸(raw + agent_result),不合法不落库 limits.validateRawCandidate(existing.request(), requireValue(rawResponse, "rawResponse")); limits.validateAgentResult(requireValue(agentResult, "agentResult")); return update(key, current -> current.markReady( @@ -85,6 +98,7 @@ public final class RedisCanonicalInvocationStore implements CanonicalInvocationS try { return update(key, current -> current.markError(rawResponse, errorCode, completedAt)); } catch (ResultTooLargeException e) { + // raw 过大:降级为不保存 raw 的 ERROR(仍保留错误事实) if (rawResponse == null) { throw e; } @@ -93,6 +107,7 @@ public final class RedisCanonicalInvocationStore implements CanonicalInvocationS } } + /** 读-迁移-写:保留剩余 TTL(从 begin 起的生命周期,不被迁移重置)。 */ private CanonicalToolInvocation update(String key, UnaryOperator transition) { CanonicalToolInvocation current = find(key) @@ -105,6 +120,7 @@ public final class RedisCanonicalInvocationStore implements CanonicalInvocationS return updated; } + /** 剩余 TTL:不存在/已过期则抛错(防止迁移写入已失效记录)。 */ private long remainingTtlMillis(String key) { Long remaining = redisTemplate.getExpire(key, TimeUnit.MILLISECONDS); if (remaining == null || remaining <= 0) { diff --git a/src/main/java/com/superbiz/agent/harness/tool/store/ResultTooLargeException.java b/src/main/java/com/superbiz/agent/harness/tool/store/ResultTooLargeException.java index 58cd620..7e98740 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/store/ResultTooLargeException.java +++ b/src/main/java/com/superbiz/agent/harness/tool/store/ResultTooLargeException.java @@ -1,7 +1,12 @@ package com.superbiz.agent.harness.tool.store; +/** + * 结果超过字节上限:raw_response / agent_result / 序列化后的整条记录。 + * ToolBoundary 映射为 RESULT_TOO_LARGE 错误码(或降级为不保存 raw 的 ERROR)。 + */ public final class ResultTooLargeException extends CanonicalStoreException { + /** 降级 ERROR 时使用的稳定错误码(raw 过大时替换为它)。 */ public static final String ERROR_CODE = "RESULT_TOO_LARGE"; public ResultTooLargeException(String field, long limit, long actual) { diff --git a/src/main/java/com/superbiz/agent/harness/tool/store/ToolCallKeyFactory.java b/src/main/java/com/superbiz/agent/harness/tool/store/ToolCallKeyFactory.java index b424103..9159983 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/store/ToolCallKeyFactory.java +++ b/src/main/java/com/superbiz/agent/harness/tool/store/ToolCallKeyFactory.java @@ -2,6 +2,10 @@ package com.superbiz.agent.harness.tool.store; import java.util.regex.Pattern; +/** + * canonical key 工厂:key = keyPrefix + ":" + runId + ":" + toolCallId。 + * runId/toolCallId 必须匹配安全字符集(防 key 注入/分隔符混淆/超长 key)。 + */ public final class ToolCallKeyFactory { private static final int MAX_SEGMENT_LENGTH = 128; @@ -13,12 +17,14 @@ public final class ToolCallKeyFactory { if (keyPrefix == null || keyPrefix.isBlank()) { throw new IllegalArgumentException("keyPrefix must not be blank"); } + // 前缀不允许空段(防 "a::b" 式歧义) if (keyPrefix.startsWith(":") || keyPrefix.endsWith(":") || keyPrefix.contains("::")) { throw new IllegalArgumentException("keyPrefix contains an empty segment"); } this.keyPrefix = keyPrefix; } + /** 生成 canonical key(runId + toolCallId 双段定位一次调用)。 */ public String create(String runId, String toolCallId) { requireSafeSegment(runId, "runId"); requireSafeSegment(toolCallId, "toolCallId"); @@ -29,6 +35,7 @@ public final class ToolCallKeyFactory { return keyPrefix; } + /** 段安全校验:非空、长度 ≤128、字符集 [A-Za-z0-9._-]。 */ private static void requireSafeSegment(String value, String name) { if (value == null || value.isBlank()) { throw new IllegalArgumentException(name + " must not be blank");