feat(graph): add diagnosis routing skeleton

This commit is contained in:
zhuyongxin
2026-07-17 11:17:13 +08:00
parent 581daffdad
commit 42ba204532
26 changed files with 2455 additions and 0 deletions
+1
View File
@@ -4,6 +4,7 @@
| 日期 | slug | 说明 | 领域 | 关键词 | 关联 OpenSpec | 状态 |
|---|---|---|---|---|---|---|
| 2026-07-17 | chat-diagnosis-stategraph-routing-skeleton | 实现未接生产入口的 Diagnosis StateGraph 骨架、有限路由和 Fake Node 测试。 | Chat diagnosis orchestration/graph | StateGraph, fake node, conditional edge, retry counter, orchestration events, trace builder | openspec/changes/archive/2026-07-17-chat-diagnosis-stategraph-routing-skeleton | archived |
| 2026-07-17 | chat-diagnosis-stategraph-design-freeze | 冻结 ISS-011 的 Graph State、条件边、有限重试、安全降级、审计和测试迁移边界。 | Chat diagnosis orchestration/design | StateGraph, runId, Gatekeeper, verified evidence, fallback, orchestration trace, test migration | openspec/changes/archive/2026-07-17-chat-diagnosis-stategraph-design-freeze | archived |
| 2026-07-10 | session-run-trace-isolation | 拆分会话态和运行态,引入 runId 隔离 Trace、Feedback、AIOps 和 demo 链路。 | Trace/session/run isolation | chat_session, diagnosis_run, runId, trace exact run, feedback fallback, AIOps SSE metadata, baseline drift | openspec/changes/archive/2026-07-10-session-run-trace-isolation | archived |
| 2026-07-09 | interview-demo-quality-audit | 增加面试演示前置质量审计,覆盖 prompt、Gatekeeper 和评测基线。 | Agent eval/demo/Prompt audit | interview demo preflight, prompt_audit, gatekeeper rules, diagnosis baseline, 12 fixtures | openspec/changes/archive/2026-07-09-interview-demo-quality-audit | archived |
@@ -0,0 +1,69 @@
# Chat Diagnosis StateGraph Routing Skeleton Acceptance
## 结果
已接受。阶段 1 完成未接生产入口的 Graph 骨架和 Fake Node 路由体系。
## 验证
### 静态验证
- 命令:`git diff --check`
- 结果:passed。
- 检查:production refs、forbidden deps、23 changed paths 白名单。
- 结果:passed,outside refs=0,forbidden refs=0,out-of-scope=0。
### 脚本验证
- 命令:`mvn -q "-Dtest=DiagnosisGraphRoutingTest,DiagnosisOrchestrationTraceBuilderTest" test`
- 结果:passed,35 tests(29 routing + 6 trace),0 failure/error。
- 覆盖:正常、三类技术 retry、Executor 不重试、Gatekeeper、evidence retry、Composer、unknown fail-closed、threadId、Append events 和 trace。
- 命令:`mvn -q "-DskipTests" test`
- 结果:passed。
- 覆盖:main/test compilation。
- 命令:`openspec validate chat-diagnosis-stategraph-routing-skeleton --type change --strict --json`
- 结果:passed,1/1。
- 命令:`openspec validate --specs --strict --json`
- 结果:passed,12/12(归档前)。
### 浏览器/人工验证
- 结果:not run。
- 原因:无 UI 或生产入口变化。
### 未验证
- Maven E2E:not run,用户要求仅阶段 5 全部实现后统一执行。
- `logs/`:not inspected,保留到阶段 5。
- 数据库:not queried,保留到阶段 5。
- 真实模型/Agent:not invoked,属于阶段 2。
## 已完成范围
- 显式 Graph Core direct dependency。
- 26 state keys、typed status、topology/route/reason constants。
- 八个 config-aware action ports 和真实 CompiledGraph factory。
- Planner/Verifier/Composer 技术计数、一次 evidence retry、recursion limit 32。
- fail-closed router、events Append 和 trace builder。
- 35 个 Fake Node/trace tests。
## 已知限制
- Skeleton 没有生产消费者,阶段 3 才切换 ChatService。
- Fake Node 只证明控制流,不证明真实 Agent JSON、Gatekeeper 或安全输入映射。
- orchestration trace 尚未持久化或通过 API 暴露。
## Bug 修复和诊断
- 架构审计发现并修正 Graph Core 传递依赖所有权,改为 BOM 管理的直接依赖。
- 自查补齐全部正常节点 threadId、Verifier REJECT 和缺失 effective verdict 场景。
## 交接
- 下一步:阶段 1 Git commit;完成后才能创建阶段 2 change。
- Delta sync:新增 `chat-diagnosis-stategraph-routing-skeleton` 主 spec,共 6 requirements。
- OpenSpec 归档确认:用户已要求每阶段 archive,授权已存在。
- OpenSpec 归档结果:已同步主 spec,并归档到 `openspec/changes/archive/2026-07-17-chat-diagnosis-stategraph-routing-skeleton/`。
@@ -0,0 +1,21 @@
# Chat Diagnosis StateGraph Routing Skeleton Brief
## 背景
- 用户目标:ISS-011 阶段 1 独立完成 Graph 骨架和 Fake Node 路由测试,archive/commit 后才能进入真实 Node 阶段。
- 当前问题:阶段 0 只有设计基线,仓库此前没有可编译 StateGraph 或条件边验证。
- 关联 OpenSpec:`openspec/changes/chat-diagnosis-stategraph-routing-skeleton/`
- devflow 分档:complex
- 前置基线:`openspec/changes/archive/2026-07-17-chat-diagnosis-stategraph-design-freeze/`
## 范围
- 本次要做:Graph Core 直接依赖、状态/枚举、config-aware action ports、deterministic router、Graph factory、有限计数、events/trace builder、Fake Node tests。
- 本次不做:真实 Agent/Gatekeeper、ChatService、Hook、DB、Trace API、旧实现清理、Maven E2E。
- 影响区域:`pom.xml`、`com.superbiz.agent.graph.diagnosis`、对应 test 包。
## OpenSpec 对齐
- proposal 覆盖状态:已覆盖 skeleton-only 目标、L2 边界、测试与 E2E 非目标。
- specs 覆盖状态:6 个 requirements 覆盖编译、Planner、Executor/Gatekeeper、Verifier、Composer、trace。
- tasks 覆盖状态:12/12 完成。
@@ -0,0 +1,112 @@
# Chat Diagnosis StateGraph Routing Skeleton Decisions
## Question Pool
| # | 维度 | 问题 | 模式 | 状态 |
|---|---|---|---|---|
| Q1 | 术语 | 阶段 1 的 Graph State、event、trace 含义从哪里继承? | evidence-driven | 已解决 |
| Q2 | 边界 | 阶段 1 是否接入 ChatService 或真实 Agent/Service? | evidence-driven | 已解决 |
| Q3 | 技术 | 锁定 1.1.2.0 是否支持所需 Node/Edge、策略和 recursion limit? | evidence-driven | 已解决 |
| Q4 | 技术 | Node action port 是否需要 RunnableConfig? | evidence-driven | 已解决 |
| Q5 | 验收 | 阶段 1 是否有必要添加单元测试? | evidence-driven + user rule | 已解决 |
| Q6 | 接口 | 新骨架的接口影响等级与消费者是什么? | evidence-driven | 已解决 |
| Q7 | 循环 | recursion limit 应取多少,是否替代业务计数? | evidence-driven | 已解决 |
| Q8 | 阶段 | 是否在本 change 实现真实 nodes、DB、Trace API 或旧代码清理? | user-interview(已由六阶段口径确认) | 已解决 |
## Evidence-driven
| 结论 | 证据来源 | 是否已汇报用户 |
|---|---|---|
| 阶段 1 必须完整继承阶段 0 baseline | archived design/spec/ADR | 已汇报 |
| 本阶段只做 Fake Node 骨架,不接真实模型 | ISS-011 阶段 1/2 边界 | 已汇报 |
| 1.1.2.0 支持 AsyncNodeActionWithConfig、AsyncEdgeActionWithConfig、conditional edges、Replace/Append、recursionLimit 和 threadId | 本地 JAR `javap` / `javap -c` | 已汇报 |
| AppendStrategy 将 list/collection 追加为有序列表,可用于 node terminal events | 本地 AppendStrategy bytecode | 已汇报 |
| 路由是新增行为且分支多,单元测试有必要 | 阶段 1 完成标准与用户“必要则加”规则 | 已汇报 |
| 最坏合法路径少于 20 次 Node 执行,limit 32 有安全余量 | 冻结路由矩阵的路径计数 | 已汇报 |
| 当前仓库没有 Graph 包或实现 | `rg` / package 目录核查 | 已汇报 |
| 生产代码将直接 import Graph Core,必须从传递依赖提升为 BOM 管理的直接依赖 | pom 与 dependency tree / 架构审计 | 已汇报 |
## User-interview
| 问题原文 | 用户原话 | 确认状态 | OpenSpec 回写 |
|---|---|---|---|
| 阶段 1 是否应独立执行 sm-flow? | “iss-011里每个阶段,都是一个sm-flow” | 已确认 | 本 change 独立边界 |
| 阶段 1 是否执行 E2E? | “端到端只在最后阶段全部完成后才验证” | 已确认 | Out of Scope / Acceptance |
| 阶段 1 是否添加单元测试? | “如果有必要添加单元测试验收的话,就加” | 已确认规则;本阶段判定必要 | Acceptance |
| 是否可提前实现阶段 2–5? | “每个阶段需要归档完并提交才能进入下一个阶段” | 已确认不可提前 | Out of Scope |
## Context And Handoff
- 前置 archive:`openspec/changes/archive/2026-07-17-chat-diagnosis-stategraph-design-freeze/`
- 前置 commit:`581daff`
- 当前 change:`chat-diagnosis-stategraph-routing-skeleton`
- 后续 change:`chat-diagnosis-stategraph-real-nodes`,只能在本阶段 archive + commit 后创建。
## Technical Decisions
### Package and API
- 新包:`com.superbiz.agent.graph.diagnosis`。
- `pom.xml` 显式声明 Graph Core,版本继续由现有 BOM 管理。
- Graph 工厂接收 config-aware Node action ports,不依赖 Spring Bean 或真实 Agent。
- Fake actions 只放 `src/test`。
- 状态读取集中在 typed helper/router,避免各 edge 复制字符串解析。
### Retry ownership
- Planner/Verifier/Composer Node wrapper 根据进入节点前的上次技术失败状态增加各自 retry count。
- 第一次失败时 count=0,允许 self-loop;重入后 count=1,第二次失败直接 Fallback。
- Evidence Retry Node 将 run-level evidence count 增加一次并重置 planner retry count。
- Edge 只选择 route,不隐式修改状态。
### Event and trace
- 每个 Fake/后续真实 Node 返回一个 event list,AppendStrategy 负责累积。
- event 包含 node/outcome/reasonCode/attempt,不包含 payload。
- trace builder 由相邻 events 生成 transition;final node/reason 来自最后 event。
- events 为空时拒绝构造 trace,避免伪造路径。
### Interface impact
- 等级:L2 internal interface。
- 新消费者:阶段 2 Node adapters、阶段 3 ChatService orchestrator、阶段 4 tests。
- 外部 API/DB/运行路径:无变化。
- 回滚:revert 本阶段提交即可;因为未接生产入口,没有数据迁移。
- 兼容:后续 action adapters 必须实现已冻结 ports,不得传递父 State 全量数据。
## Risks Accepted
- Stage 1 skeleton 会暂时存在但未被生产调用,这是阶段边界要求,不是死代码最终状态。
- 真实 Agent 状态映射尚未验证,由阶段 2 独立 change 负责。
- Maven E2E 不运行,生产路径完全未改变。
## Apply Evidence
- Task 1.1:显式声明 BOM 管理的 Graph Core;新增 26 个 state keys、状态枚举、拓扑/route/reason 常量、默认 Replace + events Append 策略和安全 typed reads。
- Task 1.2:新增不可变 event/transition/trace records,构造期拒绝空 routing metadata 和非法计数,map 输出只有冻结字段。
- Task 2.1:新增八个 non-null config-aware action ports,Fake/真实 Node 共用同一 Graph 接入面。
- Task 2.2:新增纯 router;所有未知/缺失状态 fail closed,LOW_CONFID guard 只读 facts_checked,不构造 retry context。
- Task 2.3:Graph Factory 注册八节点和全部条件边;wrapper 独占 retry/evidence control state,compile recursion limit=32。
- 首模块 Maven compile:passed(31s),锁定 1.1.2.0 API 假设成立。
- Task 3.1:trace builder 从 event 单向派生 transitions/final reason/degraded/evidence count,空或异类 events 显式失败。
- Task 4.1:新增严格 FIFO Fake Node fixture,记录 sequence/calls/threadId,每 attempt 只追加一个 terminal event,意外调用立即失败。
- Task 4.2:真实 CompiledGraph normal/Planner/Executor/Gatekeeper focused tests 首轮通过(Maven exit 0,约 67s)。
- Task 4.3:Verifier/evidence/Composer 路由与独立计数测试通过(Maven exit 0,约 12s)。
- Task 4.4:routing + trace focused suite 通过(Maven exit 0,约 28s),覆盖事件顺序、degraded、map 白名单、不可变性和非法输入。
- Task 5.1:35 tests(29 routing + 6 trace)全通过;Maven test compilation、change strict 1/1、主 specs 12/12、diff check 均通过。
- Task 5.2:现有 production refs=0,新 Graph 对真实 Service/DB/Trace refs=0,23 个 changed paths 全部命中阶段白名单;Maven E2E/log/DB 按用户口径保留到阶段 5。
- Review:补齐所有正常节点 threadId 传播、Verifier REJECT→Composer 和 completed-without-verdict fail-closed 用例;增强后 focused suite exit 0。
## Cross-Artifact 对齐检查
| 上游 → 下游 | 检查内容 | 状态 |
|---|---|---|
| brief/prd → proposal | 阶段 1 目标、Fake Node 边界、测试口径和 E2E 非目标 | 已对齐 |
| proposal → 设计产物 | direct dependency、状态、ports、router、counter、trace、测试架构 | 已对齐 |
| 设计产物 → specs/tasks | 所有可观察路由、终止、安全默认和实现模块 | 已对齐 |
| specs → tasks | 编译、全路由、trace、focused tests、生产隔离检查 | 已对齐 |
Gap:无。
## Architecture Audit
输入是 run-scoped 初始 state 与 `RunnableConfig`,处理链是 CompiledGraph → config-aware action ports → deterministic router/counters,输出是最终 state 与纯路由 trace;阶段 1 没有 Controller/Service/DB 消费者。Graph State 由单次 invoke 所有,events 只由 Node append,transitions 只由 builder 派生,避免双写。阶段 2 只实现 action ports,阶段 3 才将 orchestrator 交给 ChatService,因此当前未接生产入口是刻意生命周期边界。审计发现的唯一缺口是 Graph Core 直接依赖所有权,已回写 proposal/design/tasks。与阶段 0 archive 无冲突,L2 风险可由 Fake Node CompiledGraph tests 和 revert 单提交控制。
@@ -0,0 +1,31 @@
# Chat Diagnosis StateGraph Routing Skeleton Evidence
## 证据
| 来源 | 证据 | 结论 | 是否已汇报 |
|---|---|---|---|
| 阶段 0 archive/ADR | 冻结 26 个 state keys、完整路由、四类计数、run audit | 阶段 1 实现未偏离基线 | 是 |
| 本地 Graph Core 1.1.2.0 `javap` | config-aware Node/Edge、conditional edge、recursion limit、threadId 存在 | 使用公共锁定 API 可编译 | 是 |
| AppendStrategy bytecode | list/collection 按顺序追加 | 每 Node 返回单 event list 可形成实际路径 | 是 |
| Maven compile | `mvn -q "-DskipTests" compile` exit 0 | direct dependency 和 Factory API 编译成立 | 是 |
| Fake Node CompiledGraph tests | 29 routing tests,0 failure/error | 全条件边、retry、threadId、unknown fail-closed 成立 | 是 |
| Trace tests | 6 tests,0 failure/error | transitions、degraded、map 白名单、不可变和非法输入成立 | 是 |
| Test compilation | `mvn -q "-DskipTests" test` exit 0 | 全测试源可编译 | 是 |
| OpenSpec validation | change 1/1、主 specs 12/12 strict | artifacts 与既有规格无回归 | 是 |
| 生产隔离检查 | outside Graph refs=0,Graph 对真实 Service/DB/Trace refs=0 | 阶段 1 未接生产入口 | 是 |
| Git 路径白名单 | 23 changed paths,out-of-scope=0 | 无跨阶段文件混入 | 是 |
## Evidence-driven 结论
- 结论:直接声明 Graph Core 是正确依赖所有权。
- 证据:生产代码直接 import Graph Core;依赖原先仅由 Agent Framework 传递。
- 风险:BOM 升级仍需重新跑真实 Graph tests。
- 用户确认:不需要,属于构建稳健性。
- 结论:recursion limit 32 足够且没有替代业务计数。
- 证据:最坏合法路径低于 20;第二次技术失败和第二次 LOW_CONFID tests 均终止。
- 风险:未来新增循环必须重算。
- 用户确认:不需要,冻结业务上限未变。
- 结论:单元测试必要,E2E 不必要。
- 证据:阶段新增条件边行为但未接生产入口;35 tests 直接验证 Graph。
- 风险:真实 Agent 映射仍留给阶段 2。
- 用户确认:符合用户按必要性和最终阶段 E2E 规则。
@@ -0,0 +1,3 @@
ready_at: 2026-07-17
devflow: devflow/projects/2026-07-17-chat-diagnosis-stategraph-routing-skeleton
authorization: user-requested-per-stage-archive
@@ -0,0 +1,3 @@
committed_at: 2026-07-17
scope: iss-011-stage-1-routing-skeleton
validation: openspec-strict-pass
@@ -0,0 +1,169 @@
## Context
阶段 0 已归档 StateGraph 设计基线,当前仓库仍没有 Graph 实现。阶段 1 只引入可编译、可用 Fake Node 执行的内部骨架,不连接 Spring Bean、ChatService、真实 Agent 或数据库。
锁定依赖 `spring-ai-alibaba-graph-core:1.1.2.0` 已通过本地 JAR 验证:
- `StateGraph.addNode(... AsyncNodeActionWithConfig)`
- `StateGraph.addConditionalEdges(... AsyncEdgeActionWithConfig, mappings)`
- `CompileConfig.builder().recursionLimit(...)`
- `KeyStrategyFactoryBuilder`、`ReplaceStrategy`、`AppendStrategy`
- `CompiledGraph.invoke(input, RunnableConfig)`
- `RunnableConfig.builder().threadId(...)`
阶段 2 将消费 Node action ports,阶段 3 将消费 compiled graph。阶段 1 自身没有生产调用方。
## Goals / Non-Goals
**Goals:**
- 建立明确的状态、状态枚举、节点、route 和 reason code 常量。
- 建立 config-aware Node action ports 和可编译 Graph 工厂。
- 以冻结计数和 guard 实现完整条件边。
- 验证 AppendStrategy 和实际 CompiledGraph 行为。
- 从有界 events 构造确定性的 orchestration trace。
- 用 Fake Node 测试所有路由和终止边界。
**Non-Goals:**
- 不接 ReactAgent、Gatekeeper Service、ToolTrace 或 Prompt。
- 不修改 ChatService、Hook、ThreadLocal 或生产 Spring 配置。
- 不持久化 trace,不修改 DB/DTO/API。
- 不删除旧 Sequential 实现或测试。
- 不运行 Maven E2E。
## Decisions
### 1. Package and class boundaries
新代码位于 `com.superbiz.agent.graph.diagnosis`:
| 类型 | 职责 |
|---|---|
| `DiagnosisGraphState` | 26 个 state key、默认 Replace + events Append strategy、typed reads |
| `DiagnosisGraphStatus` | Planner/Executor/Gatekeeper/Verifier/Composer status、Verdict、PlannerMode |
| `DiagnosisGraphTopology` | Node id、route key、reason code 常量 |
| `DiagnosisGraphActions` | 八个 `AsyncNodeActionWithConfig` ports |
| `DiagnosisGraphRouter` | 纯确定性 edge guard,不修改 state |
| `DiagnosisGraphFactory` | 注册 Node/Edge、retry wrapper、evidence retry wrapper、recursion limit |
| `OrchestrationEvent` | node/outcome/reasonCode/attempt |
| `OrchestrationTransition` | from/to/reasonCode/attempt |
| `DiagnosisOrchestrationTrace` | version/transitions/finalNode/terminationReason/degraded/evidenceRetryCount |
| `DiagnosisOrchestrationTraceBuilder` | events → transitions/summary |
Fake Node 和 script fixture 只存在于 test 源集。
替代方案:把 router 和状态读取放进 ChatService 或每个 Adapter。拒绝,因为会复制 guard 并阻碍 Fake Node 独立验证。
### 1.1 Direct dependency ownership
代码直接 import Graph Core API,因此 `pom.xml` 显式声明 `com.alibaba.cloud.ai:spring-ai-alibaba-graph-core`。版本继续由现有 Spring AI Alibaba BOM 管理为 1.1.2.0,不重复写版本。依赖 Agent Framework 的传递依赖虽可编译,但会让上游依赖图调整无意中破坏本模块,拒绝。
### 2. State strategy and typed reads
`KeyStrategyFactoryBuilder.defaultStrategy(new ReplaceStrategy())`,仅对 `orchestration_events` 使用 `new AppendStrategy()`。Node 每次返回 `List.of(event)`,Graph 合并后保持有序列表。
typed reads 对 absent、null、错误类型和未知 enum 返回安全默认,不抛出边路由异常。未知 status/verdict 的路由默认 Fallback。
### 3. Config-aware action ports
所有 port 使用 `AsyncNodeActionWithConfig`,即使 Fake Node 当前只需要 state。原因是阶段 2 必须读取 `RunnableConfig.threadId/metadata` 并向 Agent 传递 run context;现在锁定接口可以避免随后重写 Graph topology。
`DiagnosisGraphActions` 构造时对八个 action 做 non-null 校验。
### 4. Retry counter ownership
Edge 只选 route,不修改 state。Factory 用 wrapper 管理计数:
- Planner 重入前,如果上次 `planner_status` 是 INVALID_OUTPUT/RETRYABLE_FAILED,count + 1。
- Verifier/Composer 同理。
- 第一次失败后的 state count 为 0,允许 self-loop;重入执行后 count 为 1,再失败则 Fallback。
- Evidence Retry Node 完成时将 `evidence_retry_count + 1`、`planner_retry_count=0`、`planner_mode=EVIDENCE_GAP_ONLY`。
- Verified Input Node 完成时重置 `verifier_retry_count=0`,因为它产生新的 verified input。
- 技术重试不修改 evidence count。
替代方案:由每个 Fake/real action 自行维护计数。拒绝,因为遗漏会造成无限 self-loop,骨架必须拥有控制计数。
### 5. Conditional routing
| Source | Route keys |
|---|---|
| Planner | executor / retry_planner / fallback |
| Executor | gatekeeper / fallback |
| Gatekeeper | verified_input / fallback |
| Verifier | composer / retry_verifier / evidence_retry / fallback |
| Composer | end / retry_composer / fallback |
Verified Input 固定到 Verifier,Evidence Retry 固定到 Planner,Fallback 固定到 END。
Verifier evidence-retry guard 同时要求:
- `verifier_status=COMPLETED`
- `effective_verdict=LOW_CONFID`
- `verifier_verdict_ceiling=PASS`
- `verifier_output.facts_checked` 至少一项具有非空 fact 且 verification 为 `no_evidence` 或 `indirect_support`
- `evidence_retry_count < 1`
否则 LOW_CONFID 到 Composer。router 只检查现有 Verifier 输出,不选择工具或构造 retry context;后者属于阶段 2 Evidence Retry Node。
### 6. Recursion limit
合法最坏路径低于 20 次 Node 执行;compile recursion limit 固定 32。测试断言 compiled graph 的 max iterations/compile config,并覆盖第二次技术失败和第二次 LOW_CONFID 均终止。
recursion limit 是最后保险,不替代业务 guard。
### 7. Event and trace contract
`OrchestrationEvent` 构造时拒绝 blank node/outcome/reason 或 attempt<1。每个 Fake/真实 Node attempt 返回一个 event;AppendStrategy 累积实际路径。
Builder:
1. 要求 events 非空且类型正确。
2. 相邻 events 生成 transition,transition reason/attempt 取 source event。
3. 最后 event 决定 finalNode 和 terminationReason。
4. final node 为 Fallback 时 degraded=true;其他质量由 effective verdict 表达。
5. evidence retry count 从 state 读取。
6. `toMap()` 使用冻结 snake_case 字段,transition 同样提供 map。
不从应用日志推导路径,也不在 state 维护第二份 transitions。
### 8. Test architecture
`DiagnosisGraphRoutingTest` 使用真实 `CompiledGraph` + scriptable Fake actions:
- 每个 node 有 outcome 队列和调用计数。
- Fake action 只输出本 node 的状态字段、必要 guard payload 和一个 event。
- 每个用例使用唯一 `RunnableConfig.threadId`。
- 断言 node sequence、调用次数、最终 state、events、trace transitions。
- 参数化覆盖同类技术失败,独立用例覆盖 evidence retry/counter reset 和 unknown fail-closed。
`DiagnosisOrchestrationTraceBuilderTest` 覆盖空 events、非法 event、顺序、fallback degraded 和 map shape。
## Interface Impact
- 等级:L2 internal interface。
- 新接口消费者:阶段 2 adapters、阶段 3 orchestrator、阶段 4 test suite。
- 构建消费者:Maven 直接声明 Graph Core,版本仍由既有 BOM 统一管理。
- 当前生产消费者:无。
- 外部 API/DTO/数据库/状态:无变化。
- 兼容策略:后续 actions 实现 ports;Graph State 不直接传给 Agent。
- 回滚:revert 本阶段提交,无数据迁移。
## Risks / Trade-offs
- [Graph merge 行为与假设不同] → 使用真实 CompiledGraph 单元测试,不 mock StateGraph。
- [计数 wrapper 与 action 更新冲突] → wrapper 最后写入控制计数,actions 不拥有 retry counters。
- [Verifier gap 解析过早耦合] → router 只识别现有 facts_checked 最小字段,retry context 构造留到阶段 2。
- [未接生产入口被误认为完成] → proposal/spec/acceptance 明确 skeleton-only,阶段 3 才切换。
- [事件无限追加] → 所有业务循环有硬上限且 recursion limit=32。
## Migration Plan
1. 添加纯 Java 状态、router、actions、factory 和 trace types。
2. 先用 Fake actions 编译和执行完整 Graph。
3. 阶段 1 archive + commit 后,阶段 2 基于 ports 实现真实 nodes。
4. 如需回滚,revert 阶段 1 commit;当前生产路径不受影响。
## Open Questions
无。
@@ -0,0 +1,77 @@
# Chat Diagnosis StateGraph Routing Skeleton
## Why
阶段 0 已归档 ISS-011 的 StateGraph 设计基线,但仓库尚无可编译的 StateGraph 实现,也没有证据证明锁定的 Graph Core `1.1.2.0` 能按冻结条件边、追加事件策略和循环上限工作。阶段 1 需要用 Fake Node 建立不接真实模型、不会进入生产入口的路由骨架,为后续真实 Node 接入提供稳定内部接口。
## What Changes
- 新增 Diagnosis Graph State key、Node、route、status/verdict 常量和类型。
- 在现有 Spring AI Alibaba BOM 管理下显式声明 Graph Core 直接依赖,避免依赖 Agent Framework 的传递关系。
- 新增可注入 config-aware Node action ports 的 StateGraph 编译工厂。
- 实现阶段 0 冻结的完整条件边、三类技术重试上限、一次 evidence retry 和 recursion limit。
- 为 `orchestration_events` 配置 AppendStrategy,其余字段默认 ReplaceStrategy。
- 新增有界 event、transition 和 orchestration trace builder。
- 使用 Fake Node 单元测试覆盖正常、重试、失败、Gatekeeper、LOW_CONFID、Fallback、循环上限和 trace 顺序。
本 change 不接入 ChatService 或任何真实 Agent/Service。
## Capabilities
### New Capabilities
- `chat-diagnosis-stategraph-routing-skeleton`:提供未接生产入口的 StateGraph 状态、拓扑、路由、有限循环和编排 trace 构造能力。
### Modified Capabilities
- 无。阶段 0 设计基线保持不变;如果实现发现 API 冲突,必须先回写本 change,而不是静默改变基线语义。
## Scope
### In Scope
- 包 `com.superbiz.agent.graph.diagnosis` 下的纯 Java Graph 骨架。
- `pom.xml` 中 Graph Core 的直接编译依赖;版本继续由现有 BOM 锁定为 1.1.2.0。
- Planner、Executor、Gatekeeper、Verified Input、Verifier、Evidence Retry、Composer、Fallback Node action ports。
- `orchestration_events` Append 策略和紧凑 trace builder。
- Fake Node route tests,使用 `RunnableConfig.threadId` 执行。
- recursion limit=32;业务计数仍是主要终止机制。
### Out of Scope
- ReactAgent Adapter、Prompt、ToolCallback、Skill 或模型调用。
- 调用 `ExecutorGatekeeperService` 或读取数据库 ToolInvocation。
- 修改 `ChatService`、`VerifierInputHook`、`VerifierContextHolder`。
- Flyway、`DiagnosisRun`、Trace DTO/API 或持久化。
- 删除旧 Sequential 实现或测试。
- Maven E2E、`logs/` 和数据库核验。
## Context Constraints
- 必须引用阶段 0 archive,不得改变其路由、安全或 run ownership 语义。
- 使用本地已验证的 Graph Core `1.1.2.0` API,不依赖未锁定版本示例。
- Graph action ports 使用 `AsyncNodeActionWithConfig`,为阶段 2 显式传递 run config 留出接口。
- Executor 不重试;Planner/Verifier/Composer 各最多一次技术重试;整个 Run 最多一次 evidence retry。
- Unknown/null status 必须 fail closed 到 Fallback。
- Fake nodes 只存在于测试代码,生产骨架不得包含模拟业务输出。
- 阶段 1 不改变任何外部 API 或当前生产路径。
## Acceptance
- Graph 可编译,默认 Replace、events Append,recursion limit 为 32。
- PASS 正常路径到 Composer/END,events 与 transitions 顺序一致。
- Planner INVALID_OUTPUT/RETRYABLE_FAILED 首次自重试,第二次或 NON_RETRYABLE_FAILED Fallback。
- Executor COMPLETED 进入 Gatekeeper;INVALID_OUTPUT/TOOL_BLOCKED/FAILED Fallback 且从不重试。
- Gatekeeper PASS 和有 binding 的 LOW_CONFID 进入 Verified Input;REJECT、unknown、零 binding LOW_CONFID Fallback。
- Verifier 技术失败首次自重试;PASS/REJECT 到 Composer;满足全部 guard 的 LOW_CONFID 只补证据一次;其余 LOW_CONFID 到 Composer。
- Composer 技术失败首次自重试,耗尽或不可重试进入 Fallback。
- Evidence retry 进入新 Planner 阶段时重置 planner retry count,且不影响其他技术计数。
- 每个测试路径能构造 bounded orchestration trace,无未定义条件边或无限循环。
- focused unit tests 和 Maven test-compile 通过;不运行 Maven E2E。
## Risks
- Graph Core 的 state merge/conditional-edge 细节可能与 API 签名表面不同;用真实 CompiledGraph Fake Node tests 验证。
- Node action 若忘记写 status/event,路由必须 fail closed,测试覆盖 null/unknown。
- 通用骨架若掺入真实 Agent 语义会污染阶段边界;本 change 只定义 ports 和确定性路由。
- 32 次 recursion limit 是保险,不替代显式计数。
@@ -0,0 +1,151 @@
## ADDED Requirements
### Requirement: Diagnosis Graph skeleton SHALL compile against the locked Graph API
The system SHALL provide an internal Diagnosis StateGraph skeleton using Graph Core 1.1.2.0 config-aware node actions, conditional edges, explicit key strategies, and a recursion limit of 32. The skeleton SHALL NOT be wired to the current production Chat path.
#### Scenario: Skeleton is compiled
- **WHEN** valid node action ports are supplied
- **THEN** the Graph SHALL compile with all frozen nodes and conditional edges
- **AND** `orchestration_events` SHALL use Append semantics while all other state uses Replace semantics
- **AND** the compiled Graph SHALL enforce recursion limit 32
#### Scenario: Run config is supplied
- **WHEN** a Fake Node Graph is invoked with a `RunnableConfig.threadId`
- **THEN** config-aware node ports SHALL receive that config
- **AND** the final state SHALL remain scoped to that invocation
#### Scenario: Production path is inspected
- **WHEN** stage 1 is accepted
- **THEN** ChatService, real Agents, Gatekeeper service, database, and Trace API SHALL NOT invoke the new skeleton
### Requirement: Planner routing SHALL allow one technical retry per Planner stage
The skeleton SHALL route Planner COMPLETED to Executor. INVALID_OUTPUT and RETRYABLE_FAILED SHALL self-retry only while `planner_retry_count=0`; NON_RETRYABLE_FAILED, unknown status, or a second technical failure SHALL route to Fallback.
#### Scenario: Planner first technical failure
- **WHEN** Planner first returns INVALID_OUTPUT or RETRYABLE_FAILED
- **THEN** only Planner SHALL run again
- **AND** the re-entered Planner state SHALL have `planner_retry_count=1`
#### Scenario: Planner retry is exhausted
- **WHEN** Planner returns a technical failure after retry count reaches 1
- **THEN** the Graph SHALL route to Fallback
- **AND** Executor SHALL NOT run
#### Scenario: Planner fails non-retryably
- **WHEN** Planner returns NON_RETRYABLE_FAILED or an unknown status
- **THEN** the Graph SHALL route directly to Fallback
### Requirement: Executor and Gatekeeper routing SHALL fail closed
Executor SHALL route only COMPLETED output to Gatekeeper and SHALL never retry. Gatekeeper SHALL route PASS and LOW_CONFID with verified bindings to Verified Input; REJECT, unknown status, or LOW_CONFID with zero bindings SHALL route to Fallback.
#### Scenario: Executor completes legal output
- **WHEN** Executor returns COMPLETED, including a legal no-evidence output or legal output after tool errors
- **THEN** Gatekeeper SHALL run exactly once
#### Scenario: Executor cannot complete contract
- **WHEN** Executor returns INVALID_OUTPUT, TOOL_BLOCKED, FAILED, or unknown status
- **THEN** the Graph SHALL route directly to Fallback
- **AND** Gatekeeper and Verifier SHALL NOT run
#### Scenario: Gatekeeper permits verification
- **WHEN** Gatekeeper returns PASS or LOW_CONFID with `verified_binding_count>0`
- **THEN** Verified Input SHALL run before Verifier
#### Scenario: Gatekeeper blocks verification
- **WHEN** Gatekeeper returns REJECT, unknown status, or LOW_CONFID with zero verified bindings
- **THEN** the Graph SHALL route to Fallback
- **AND** Verifier SHALL NOT run
### Requirement: Verifier routing SHALL separate technical retry from evidence retry
Verifier INVALID_OUTPUT and RETRYABLE_FAILED SHALL self-retry once with the same verified input. COMPLETED PASS or REJECT SHALL route to Composer. COMPLETED LOW_CONFID SHALL route to one Evidence Retry only when all frozen guards are true; otherwise it SHALL route to Composer. Other outcomes SHALL fail closed.
#### Scenario: Verifier first technical failure
- **WHEN** Verifier first returns INVALID_OUTPUT or RETRYABLE_FAILED
- **THEN** only Verifier SHALL run again
- **AND** Gatekeeper, Executor, and tools SHALL NOT rerun
#### Scenario: Verifier retry is exhausted
- **WHEN** Verifier returns a technical failure after `verifier_retry_count=1`
- **THEN** the Graph SHALL route to Fallback
#### Scenario: Verifier verdict reaches Composer
- **WHEN** Verifier completes with effective PASS or REJECT
- **THEN** Composer SHALL run
#### Scenario: LOW_CONFID qualifies for evidence retry
- **WHEN** Verifier completes LOW_CONFID with ceiling PASS, valid no_evidence or indirect_support facts, and `evidence_retry_count=0`
- **THEN** Evidence Retry SHALL run once and return to a new Planner stage
- **AND** `planner_retry_count` SHALL reset to 0
- **AND** `evidence_retry_count` SHALL become 1
#### Scenario: LOW_CONFID does not qualify for evidence retry
- **WHEN** ceiling is LOW_CONFID, facts contain no valid gap, or evidence retry count is already 1
- **THEN** Composer SHALL run without another Planner cycle
### Requirement: Composer routing SHALL allow one technical retry and then terminate safely
Composer COMPLETED SHALL terminate at END. INVALID_OUTPUT and RETRYABLE_FAILED SHALL self-retry once; NON_RETRYABLE_FAILED, unknown status, or a second technical failure SHALL route to Fallback and then END.
#### Scenario: Composer first technical failure
- **WHEN** Composer first returns INVALID_OUTPUT or RETRYABLE_FAILED
- **THEN** only Composer SHALL run again
- **AND** Verifier and preceding nodes SHALL NOT rerun
#### Scenario: Composer retry is exhausted
- **WHEN** Composer fails technically after `composer_retry_count=1`
- **THEN** Fallback SHALL run exactly once
- **AND** the Graph SHALL terminate
#### Scenario: Composer succeeds
- **WHEN** Composer returns COMPLETED
- **THEN** the Graph SHALL terminate without invoking Fallback
### Requirement: Orchestration events SHALL produce an exact bounded trace
Each node attempt SHALL append one terminal orchestration event. The trace builder SHALL derive transitions from adjacent events, final node and termination reason from the last event, degraded state from Fallback termination, and evidence retry count from state.
#### Scenario: Normal path trace is built
- **WHEN** Fake nodes execute Planner, Executor, Gatekeeper, Verified Input, Verifier, and Composer
- **THEN** events and transitions SHALL preserve that exact order
- **AND** the trace SHALL terminate at Composer without degradation
#### Scenario: Fallback path trace is built
- **WHEN** a route terminates through Fallback
- **THEN** the trace final node SHALL be Fallback
- **AND** `degraded` SHALL be true
- **AND** its termination reason SHALL come from the Fallback event
#### Scenario: Trace input is invalid
- **WHEN** no events exist or an event has blank node, outcome, reason code, or attempt below 1
- **THEN** trace construction SHALL fail explicitly rather than fabricate a path
#### Scenario: Event payload is inspected
- **WHEN** orchestration events and trace maps are produced
- **THEN** they SHALL contain only routing metadata
- **AND** they SHALL NOT contain Prompt, model reasoning, tool output, or Graph State snapshots
@@ -0,0 +1,26 @@
## 1. Define Graph State And Audit Types
- [x] 1.1 Declare the BOM-managed Graph Core direct dependency; add diagnosis state keys, typed status/verdict enums, node/route/reason constants, and Replace/Append key strategies.
- [x] 1.2 Add validated orchestration event, transition, and trace value types with bounded routing-only map output.
## 2. Build The Deterministic Graph Skeleton
- [x] 2.1 Add non-null config-aware node action ports for Planner, Executor, Gatekeeper, Verified Input, Verifier, Evidence Retry, Composer, and Fallback.
- [x] 2.2 Implement fail-closed deterministic routers for every frozen Planner, Executor, Gatekeeper, Verifier, and Composer outcome.
- [x] 2.3 Build and compile the StateGraph with all nodes/edges, retry counter wrappers, evidence retry resets, Append events, and recursion limit 32.
## 3. Build The Trace Summary
- [x] 3.1 Implement orchestration trace construction from ordered events, including transitions, final reason, degraded flag, evidence retry count, and explicit invalid-input errors.
## 4. Verify With Fake Nodes
- [x] 4.1 Create reusable scriptable config-aware Fake Node fixtures that emit one terminal event per attempt and expose node order/call counts.
- [x] 4.2 Add real CompiledGraph tests for normal, Planner, Executor, and Gatekeeper routes including unknown fail-closed behavior.
- [x] 4.3 Add real CompiledGraph tests for Verifier, evidence retry, counter reset/independence, Composer, and recursion termination routes.
- [x] 4.4 Add trace builder and AppendStrategy assertions for exact event/transition order, fallback degradation, map shape, and invalid events.
## 5. Validate The Stage
- [x] 5.1 Run focused routing/trace tests, Maven test compilation, strict OpenSpec validation, and diff checks; resolve all regressions.
- [x] 5.2 Verify ChatService, real Agents, Gatekeeper service, database, and Trace API do not reference the skeleton; record that Maven E2E/log/DB checks are reserved for stage 5.
@@ -0,0 +1,154 @@
# chat-diagnosis-stategraph-routing-skeleton Specification
## Purpose
提供未接生产入口的 Diagnosis StateGraph 状态、config-aware Node ports、确定性条件边、有限重试、编排事件和 trace 构造能力,并以 Fake Node 测试锁定 Graph Core 1.1.2.0 的实际行为。
## Requirements
### Requirement: Diagnosis Graph skeleton SHALL compile against the locked Graph API
The system SHALL provide an internal Diagnosis StateGraph skeleton using Graph Core 1.1.2.0 config-aware node actions, conditional edges, explicit key strategies, and a recursion limit of 32. The skeleton SHALL NOT be wired to the current production Chat path.
#### Scenario: Skeleton is compiled
- **WHEN** valid node action ports are supplied
- **THEN** the Graph SHALL compile with all frozen nodes and conditional edges
- **AND** `orchestration_events` SHALL use Append semantics while all other state uses Replace semantics
- **AND** the compiled Graph SHALL enforce recursion limit 32
#### Scenario: Run config is supplied
- **WHEN** a Fake Node Graph is invoked with a `RunnableConfig.threadId`
- **THEN** config-aware node ports SHALL receive that config
- **AND** the final state SHALL remain scoped to that invocation
#### Scenario: Production path is inspected
- **WHEN** stage 1 is accepted
- **THEN** ChatService, real Agents, Gatekeeper service, database, and Trace API SHALL NOT invoke the new skeleton
### Requirement: Planner routing SHALL allow one technical retry per Planner stage
The skeleton SHALL route Planner COMPLETED to Executor. INVALID_OUTPUT and RETRYABLE_FAILED SHALL self-retry only while `planner_retry_count=0`; NON_RETRYABLE_FAILED, unknown status, or a second technical failure SHALL route to Fallback.
#### Scenario: Planner first technical failure
- **WHEN** Planner first returns INVALID_OUTPUT or RETRYABLE_FAILED
- **THEN** only Planner SHALL run again
- **AND** the re-entered Planner state SHALL have `planner_retry_count=1`
#### Scenario: Planner retry is exhausted
- **WHEN** Planner returns a technical failure after retry count reaches 1
- **THEN** the Graph SHALL route to Fallback
- **AND** Executor SHALL NOT run
#### Scenario: Planner fails non-retryably
- **WHEN** Planner returns NON_RETRYABLE_FAILED or an unknown status
- **THEN** the Graph SHALL route directly to Fallback
### Requirement: Executor and Gatekeeper routing SHALL fail closed
Executor SHALL route only COMPLETED output to Gatekeeper and SHALL never retry. Gatekeeper SHALL route PASS and LOW_CONFID with verified bindings to Verified Input; REJECT, unknown status, or LOW_CONFID with zero bindings SHALL route to Fallback.
#### Scenario: Executor completes legal output
- **WHEN** Executor returns COMPLETED, including a legal no-evidence output or legal output after tool errors
- **THEN** Gatekeeper SHALL run exactly once
#### Scenario: Executor cannot complete contract
- **WHEN** Executor returns INVALID_OUTPUT, TOOL_BLOCKED, FAILED, or unknown status
- **THEN** the Graph SHALL route directly to Fallback
- **AND** Gatekeeper and Verifier SHALL NOT run
#### Scenario: Gatekeeper permits verification
- **WHEN** Gatekeeper returns PASS or LOW_CONFID with `verified_binding_count>0`
- **THEN** Verified Input SHALL run before Verifier
#### Scenario: Gatekeeper blocks verification
- **WHEN** Gatekeeper returns REJECT, unknown status, or LOW_CONFID with zero verified bindings
- **THEN** the Graph SHALL route to Fallback
- **AND** Verifier SHALL NOT run
### Requirement: Verifier routing SHALL separate technical retry from evidence retry
Verifier INVALID_OUTPUT and RETRYABLE_FAILED SHALL self-retry once with the same verified input. COMPLETED PASS or REJECT SHALL route to Composer. COMPLETED LOW_CONFID SHALL route to one Evidence Retry only when all frozen guards are true; otherwise it SHALL route to Composer. Other outcomes SHALL fail closed.
#### Scenario: Verifier first technical failure
- **WHEN** Verifier first returns INVALID_OUTPUT or RETRYABLE_FAILED
- **THEN** only Verifier SHALL run again
- **AND** Gatekeeper, Executor, and tools SHALL NOT rerun
#### Scenario: Verifier retry is exhausted
- **WHEN** Verifier returns a technical failure after `verifier_retry_count=1`
- **THEN** the Graph SHALL route to Fallback
#### Scenario: Verifier verdict reaches Composer
- **WHEN** Verifier completes with effective PASS or REJECT
- **THEN** Composer SHALL run
#### Scenario: LOW_CONFID qualifies for evidence retry
- **WHEN** Verifier completes LOW_CONFID with ceiling PASS, valid no_evidence or indirect_support facts, and `evidence_retry_count=0`
- **THEN** Evidence Retry SHALL run once and return to a new Planner stage
- **AND** `planner_retry_count` SHALL reset to 0
- **AND** `evidence_retry_count` SHALL become 1
#### Scenario: LOW_CONFID does not qualify for evidence retry
- **WHEN** ceiling is LOW_CONFID, facts contain no valid gap, or evidence retry count is already 1
- **THEN** Composer SHALL run without another Planner cycle
### Requirement: Composer routing SHALL allow one technical retry and then terminate safely
Composer COMPLETED SHALL terminate at END. INVALID_OUTPUT and RETRYABLE_FAILED SHALL self-retry once; NON_RETRYABLE_FAILED, unknown status, or a second technical failure SHALL route to Fallback and then END.
#### Scenario: Composer first technical failure
- **WHEN** Composer first returns INVALID_OUTPUT or RETRYABLE_FAILED
- **THEN** only Composer SHALL run again
- **AND** Verifier and preceding nodes SHALL NOT rerun
#### Scenario: Composer retry is exhausted
- **WHEN** Composer fails technically after `composer_retry_count=1`
- **THEN** Fallback SHALL run exactly once
- **AND** the Graph SHALL terminate
#### Scenario: Composer succeeds
- **WHEN** Composer returns COMPLETED
- **THEN** the Graph SHALL terminate without invoking Fallback
### Requirement: Orchestration events SHALL produce an exact bounded trace
Each node attempt SHALL append one terminal orchestration event. The trace builder SHALL derive transitions from adjacent events, final node and termination reason from the last event, degraded state from Fallback termination, and evidence retry count from state.
#### Scenario: Normal path trace is built
- **WHEN** Fake nodes execute Planner, Executor, Gatekeeper, Verified Input, Verifier, and Composer
- **THEN** events and transitions SHALL preserve that exact order
- **AND** the trace SHALL terminate at Composer without degradation
#### Scenario: Fallback path trace is built
- **WHEN** a route terminates through Fallback
- **THEN** the trace final node SHALL be Fallback
- **AND** `degraded` SHALL be true
- **AND** its termination reason SHALL come from the Fallback event
#### Scenario: Trace input is invalid
- **WHEN** no events exist or an event has blank node, outcome, reason code, or attempt below 1
- **THEN** trace construction SHALL fail explicitly rather than fabricate a path
#### Scenario: Event payload is inspected
- **WHEN** orchestration events and trace maps are produced
- **THEN** they SHALL contain only routing metadata
- **AND** they SHALL NOT contain Prompt, model reasoning, tool output, or Graph State snapshots
+4
View File
@@ -86,6 +86,10 @@
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-agent-framework</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-graph-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
@@ -0,0 +1,27 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.action.AsyncNodeActionWithConfig;
import java.util.Objects;
public record DiagnosisGraphActions(
AsyncNodeActionWithConfig planner,
AsyncNodeActionWithConfig executor,
AsyncNodeActionWithConfig gatekeeper,
AsyncNodeActionWithConfig verifiedInput,
AsyncNodeActionWithConfig verifier,
AsyncNodeActionWithConfig evidenceRetry,
AsyncNodeActionWithConfig composer,
AsyncNodeActionWithConfig fallback) {
public DiagnosisGraphActions {
planner = Objects.requireNonNull(planner, "planner");
executor = Objects.requireNonNull(executor, "executor");
gatekeeper = Objects.requireNonNull(gatekeeper, "gatekeeper");
verifiedInput = Objects.requireNonNull(verifiedInput, "verifiedInput");
verifier = Objects.requireNonNull(verifier, "verifier");
evidenceRetry = Objects.requireNonNull(evidenceRetry, "evidenceRetry");
composer = Objects.requireNonNull(composer, "composer");
fallback = Objects.requireNonNull(fallback, "fallback");
}
}
@@ -0,0 +1,171 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.CompileConfig;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.AsyncEdgeAction;
import com.alibaba.cloud.ai.graph.action.AsyncNodeActionWithConfig;
import com.alibaba.cloud.ai.graph.exception.GraphStateException;
import com.superbiz.agent.graph.diagnosis.DiagnosisGraphStatus.PlannerMode;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
public final class DiagnosisGraphFactory {
public CompiledGraph compile(DiagnosisGraphActions actions) throws GraphStateException {
Objects.requireNonNull(actions, "actions");
StateGraph graph = new StateGraph(
DiagnosisGraphTopology.GRAPH_NAME,
DiagnosisGraphState.keyStrategies());
graph.addNode(DiagnosisGraphTopology.Node.PLANNER,
withTechnicalRetryCounter(
actions.planner(),
DiagnosisGraphState.PLANNER_STATUS,
DiagnosisGraphState.PLANNER_RETRY_COUNT))
.addNode(DiagnosisGraphTopology.Node.EXECUTOR, actions.executor())
.addNode(DiagnosisGraphTopology.Node.GATEKEEPER, actions.gatekeeper())
.addNode(DiagnosisGraphTopology.Node.VERIFIED_INPUT,
withControlState(actions.verifiedInput(),
Map.of(DiagnosisGraphState.VERIFIER_RETRY_COUNT, 0)))
.addNode(DiagnosisGraphTopology.Node.VERIFIER,
withTechnicalRetryCounter(
actions.verifier(),
DiagnosisGraphState.VERIFIER_STATUS,
DiagnosisGraphState.VERIFIER_RETRY_COUNT))
.addNode(DiagnosisGraphTopology.Node.EVIDENCE_RETRY,
withEvidenceRetryState(actions.evidenceRetry()))
.addNode(DiagnosisGraphTopology.Node.COMPOSER,
withTechnicalRetryCounter(
actions.composer(),
DiagnosisGraphState.COMPOSER_STATUS,
DiagnosisGraphState.COMPOSER_RETRY_COUNT))
.addNode(DiagnosisGraphTopology.Node.FALLBACK, actions.fallback());
graph.addEdge(StateGraph.START, DiagnosisGraphTopology.Node.PLANNER)
.addConditionalEdges(
DiagnosisGraphTopology.Node.PLANNER,
AsyncEdgeAction.edge_async(DiagnosisGraphRouter::afterPlanner),
Map.of(
DiagnosisGraphTopology.Route.EXECUTOR,
DiagnosisGraphTopology.Node.EXECUTOR,
DiagnosisGraphTopology.Route.RETRY_PLANNER,
DiagnosisGraphTopology.Node.PLANNER,
DiagnosisGraphTopology.Route.FALLBACK,
DiagnosisGraphTopology.Node.FALLBACK))
.addConditionalEdges(
DiagnosisGraphTopology.Node.EXECUTOR,
AsyncEdgeAction.edge_async(DiagnosisGraphRouter::afterExecutor),
Map.of(
DiagnosisGraphTopology.Route.GATEKEEPER,
DiagnosisGraphTopology.Node.GATEKEEPER,
DiagnosisGraphTopology.Route.FALLBACK,
DiagnosisGraphTopology.Node.FALLBACK))
.addConditionalEdges(
DiagnosisGraphTopology.Node.GATEKEEPER,
AsyncEdgeAction.edge_async(DiagnosisGraphRouter::afterGatekeeper),
Map.of(
DiagnosisGraphTopology.Route.VERIFIED_INPUT,
DiagnosisGraphTopology.Node.VERIFIED_INPUT,
DiagnosisGraphTopology.Route.FALLBACK,
DiagnosisGraphTopology.Node.FALLBACK))
.addEdge(
DiagnosisGraphTopology.Node.VERIFIED_INPUT,
DiagnosisGraphTopology.Node.VERIFIER)
.addConditionalEdges(
DiagnosisGraphTopology.Node.VERIFIER,
AsyncEdgeAction.edge_async(DiagnosisGraphRouter::afterVerifier),
Map.of(
DiagnosisGraphTopology.Route.COMPOSER,
DiagnosisGraphTopology.Node.COMPOSER,
DiagnosisGraphTopology.Route.RETRY_VERIFIER,
DiagnosisGraphTopology.Node.VERIFIER,
DiagnosisGraphTopology.Route.EVIDENCE_RETRY,
DiagnosisGraphTopology.Node.EVIDENCE_RETRY,
DiagnosisGraphTopology.Route.FALLBACK,
DiagnosisGraphTopology.Node.FALLBACK))
.addEdge(
DiagnosisGraphTopology.Node.EVIDENCE_RETRY,
DiagnosisGraphTopology.Node.PLANNER)
.addConditionalEdges(
DiagnosisGraphTopology.Node.COMPOSER,
AsyncEdgeAction.edge_async(DiagnosisGraphRouter::afterComposer),
Map.of(
DiagnosisGraphTopology.Route.END,
StateGraph.END,
DiagnosisGraphTopology.Route.RETRY_COMPOSER,
DiagnosisGraphTopology.Node.COMPOSER,
DiagnosisGraphTopology.Route.FALLBACK,
DiagnosisGraphTopology.Node.FALLBACK))
.addEdge(DiagnosisGraphTopology.Node.FALLBACK, StateGraph.END);
return graph.compile(CompileConfig.builder()
.recursionLimit(DiagnosisGraphTopology.RECURSION_LIMIT)
.build());
}
private AsyncNodeActionWithConfig withTechnicalRetryCounter(
AsyncNodeActionWithConfig delegate,
String statusKey,
String countKey) {
return (state, config) -> {
int currentCount = DiagnosisGraphState.intValue(state, countKey);
String previousStatus = DiagnosisGraphState.stringValue(state, statusKey);
int nextCount = isTechnicalFailure(previousStatus)
? currentCount + 1
: currentCount;
return mergeControlState(delegate.apply(state, config),
Map.of(countKey, nextCount));
};
}
private AsyncNodeActionWithConfig withEvidenceRetryState(
AsyncNodeActionWithConfig delegate) {
return (state, config) -> {
int nextEvidenceRetryCount = Math.min(
DiagnosisGraphState.intValue(
state, DiagnosisGraphState.EVIDENCE_RETRY_COUNT) + 1,
1);
Map<String, Object> controlState = new LinkedHashMap<>();
controlState.put(DiagnosisGraphState.EVIDENCE_RETRY_COUNT,
nextEvidenceRetryCount);
controlState.put(DiagnosisGraphState.PLANNER_RETRY_COUNT, 0);
controlState.put(DiagnosisGraphState.PLANNER_MODE,
PlannerMode.EVIDENCE_GAP_ONLY.name());
return mergeControlState(delegate.apply(state, config), controlState);
};
}
private AsyncNodeActionWithConfig withControlState(
AsyncNodeActionWithConfig delegate,
Map<String, Object> controlState) {
return (state, config) ->
mergeControlState(delegate.apply(state, config), controlState);
}
private CompletableFuture<Map<String, Object>> mergeControlState(
CompletableFuture<Map<String, Object>> updateFuture,
Map<String, Object> controlState) {
Objects.requireNonNull(updateFuture, "node update future");
return updateFuture.thenApply(update -> {
Map<String, Object> merged = new LinkedHashMap<>(
Objects.requireNonNull(update, "node update"));
merged.putAll(controlState);
return merged;
});
}
private boolean isTechnicalFailure(String status) {
if (status == null) {
return false;
}
String normalized = status.trim().toUpperCase(Locale.ROOT);
return "INVALID_OUTPUT".equals(normalized)
|| "RETRYABLE_FAILED".equals(normalized);
}
}
@@ -0,0 +1,144 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.superbiz.agent.graph.diagnosis.DiagnosisGraphStatus.ComposerStatus;
import com.superbiz.agent.graph.diagnosis.DiagnosisGraphStatus.ExecutorStatus;
import com.superbiz.agent.graph.diagnosis.DiagnosisGraphStatus.GatekeeperStatus;
import com.superbiz.agent.graph.diagnosis.DiagnosisGraphStatus.PlannerStatus;
import com.superbiz.agent.graph.diagnosis.DiagnosisGraphStatus.Verdict;
import com.superbiz.agent.graph.diagnosis.DiagnosisGraphStatus.VerifierStatus;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
public final class DiagnosisGraphRouter {
private DiagnosisGraphRouter() {
}
public static String afterPlanner(OverAllState state) {
Optional<PlannerStatus> status = DiagnosisGraphState.enumValue(
state, DiagnosisGraphState.PLANNER_STATUS, PlannerStatus.class);
if (status.isEmpty()) {
return DiagnosisGraphTopology.Route.FALLBACK;
}
return switch (status.get()) {
case COMPLETED -> DiagnosisGraphTopology.Route.EXECUTOR;
case INVALID_OUTPUT, RETRYABLE_FAILED ->
DiagnosisGraphState.intValue(state, DiagnosisGraphState.PLANNER_RETRY_COUNT) < 1
? DiagnosisGraphTopology.Route.RETRY_PLANNER
: DiagnosisGraphTopology.Route.FALLBACK;
case NON_RETRYABLE_FAILED -> DiagnosisGraphTopology.Route.FALLBACK;
};
}
public static String afterExecutor(OverAllState state) {
return DiagnosisGraphState.enumValue(
state, DiagnosisGraphState.EXECUTOR_STATUS, ExecutorStatus.class)
.filter(status -> status == ExecutorStatus.COMPLETED)
.map(status -> DiagnosisGraphTopology.Route.GATEKEEPER)
.orElse(DiagnosisGraphTopology.Route.FALLBACK);
}
public static String afterGatekeeper(OverAllState state) {
Optional<GatekeeperStatus> status = DiagnosisGraphState.enumValue(
state, DiagnosisGraphState.GATEKEEPER_STATUS, GatekeeperStatus.class);
if (status.isEmpty()) {
return DiagnosisGraphTopology.Route.FALLBACK;
}
return switch (status.get()) {
case PASS -> DiagnosisGraphTopology.Route.VERIFIED_INPUT;
case LOW_CONFID ->
DiagnosisGraphState.intValue(state, DiagnosisGraphState.VERIFIED_BINDING_COUNT) > 0
? DiagnosisGraphTopology.Route.VERIFIED_INPUT
: DiagnosisGraphTopology.Route.FALLBACK;
case REJECT -> DiagnosisGraphTopology.Route.FALLBACK;
};
}
public static String afterVerifier(OverAllState state) {
Optional<VerifierStatus> status = DiagnosisGraphState.enumValue(
state, DiagnosisGraphState.VERIFIER_STATUS, VerifierStatus.class);
if (status.isEmpty()) {
return DiagnosisGraphTopology.Route.FALLBACK;
}
if (status.get() == VerifierStatus.INVALID_OUTPUT
|| status.get() == VerifierStatus.RETRYABLE_FAILED) {
return DiagnosisGraphState.intValue(state, DiagnosisGraphState.VERIFIER_RETRY_COUNT) < 1
? DiagnosisGraphTopology.Route.RETRY_VERIFIER
: DiagnosisGraphTopology.Route.FALLBACK;
}
if (status.get() == VerifierStatus.NON_RETRYABLE_FAILED) {
return DiagnosisGraphTopology.Route.FALLBACK;
}
Optional<Verdict> verdict = DiagnosisGraphState.enumValue(
state, DiagnosisGraphState.EFFECTIVE_VERDICT, Verdict.class);
if (verdict.isEmpty()) {
return DiagnosisGraphTopology.Route.FALLBACK;
}
if (verdict.get() == Verdict.PASS || verdict.get() == Verdict.REJECT) {
return DiagnosisGraphTopology.Route.COMPOSER;
}
return qualifiesForEvidenceRetry(state)
? DiagnosisGraphTopology.Route.EVIDENCE_RETRY
: DiagnosisGraphTopology.Route.COMPOSER;
}
public static String afterComposer(OverAllState state) {
Optional<ComposerStatus> status = DiagnosisGraphState.enumValue(
state, DiagnosisGraphState.COMPOSER_STATUS, ComposerStatus.class);
if (status.isEmpty()) {
return DiagnosisGraphTopology.Route.FALLBACK;
}
return switch (status.get()) {
case COMPLETED -> DiagnosisGraphTopology.Route.END;
case INVALID_OUTPUT, RETRYABLE_FAILED ->
DiagnosisGraphState.intValue(state, DiagnosisGraphState.COMPOSER_RETRY_COUNT) < 1
? DiagnosisGraphTopology.Route.RETRY_COMPOSER
: DiagnosisGraphTopology.Route.FALLBACK;
case NON_RETRYABLE_FAILED -> DiagnosisGraphTopology.Route.FALLBACK;
};
}
static boolean qualifiesForEvidenceRetry(OverAllState state) {
if (DiagnosisGraphState.intValue(state, DiagnosisGraphState.EVIDENCE_RETRY_COUNT) >= 1) {
return false;
}
Optional<Verdict> ceiling = DiagnosisGraphState.enumValue(
state, DiagnosisGraphState.VERIFIER_VERDICT_CEILING, Verdict.class);
return ceiling.filter(value -> value == Verdict.PASS).isPresent()
&& hasValidEvidenceGap(DiagnosisGraphState.mapValue(
state, DiagnosisGraphState.VERIFIER_OUTPUT));
}
private static boolean hasValidEvidenceGap(Map<String, Object> verifierOutput) {
Object factsValue = verifierOutput.get("facts_checked");
if (!(factsValue instanceof List<?> facts)) {
return false;
}
for (Object factValue : facts) {
if (!(factValue instanceof Map<?, ?> fact)) {
continue;
}
String statement = text(fact.get("fact"));
String verification = text(fact.get("verification"));
if (statement != null && verification != null
&& ("no_evidence".equals(verification)
|| "indirect_support".equals(verification))) {
return true;
}
}
return false;
}
private static String text(Object value) {
if (value == null) {
return null;
}
String text = String.valueOf(value).trim().toLowerCase(Locale.ROOT);
return text.isEmpty() ? null : text;
}
}
@@ -0,0 +1,107 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
import com.alibaba.cloud.ai.graph.KeyStrategyFactoryBuilder;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.state.strategy.AppendStrategy;
import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
public final class DiagnosisGraphState {
public static final String DIAGNOSIS_CONTEXT = "diagnosis_context";
public static final String PLANNER_PLAN = "planner_plan";
public static final String PLANNER_STATUS = "planner_status";
public static final String PLANNER_RETRY_COUNT = "planner_retry_count";
public static final String PLANNER_MODE = "planner_mode";
public static final String EXECUTOR_OUTPUT = "executor_output";
public static final String EXECUTOR_STATUS = "executor_status";
public static final String GATEKEEPER_RESULT = "gatekeeper_result";
public static final String GATEKEEPER_STATUS = "gatekeeper_status";
public static final String VERIFIED_EXECUTOR_OUTPUT = "verified_executor_output";
public static final String VERIFIED_EVIDENCE = "verified_evidence";
public static final String VERIFIED_BINDING_COUNT = "verified_binding_count";
public static final String VERIFIER_VERDICT_CEILING = "verifier_verdict_ceiling";
public static final String VERIFIER_OUTPUT = "verifier_output";
public static final String VERIFIER_STATUS = "verifier_status";
public static final String VERIFIER_RETRY_COUNT = "verifier_retry_count";
public static final String VERIFIER_MODEL_VERDICT = "verifier_model_verdict";
public static final String EFFECTIVE_VERDICT = "effective_verdict";
public static final String COMPOSER_OUTPUT = "composer_output";
public static final String COMPOSER_STATUS = "composer_status";
public static final String COMPOSER_RETRY_COUNT = "composer_retry_count";
public static final String EVIDENCE_RETRY_COUNT = "evidence_retry_count";
public static final String RETRY_CONTEXT = "retry_context";
public static final String ORCHESTRATION_EVENTS = "orchestration_events";
public static final String FINAL_ANSWER = "final_answer";
public static final String FAILURE_REASON = "failure_reason";
private DiagnosisGraphState() {
}
public static KeyStrategyFactory keyStrategies() {
return new KeyStrategyFactoryBuilder()
.defaultStrategy(new ReplaceStrategy())
.addStrategy(ORCHESTRATION_EVENTS, new AppendStrategy())
.build();
}
public static int intValue(OverAllState state, String key) {
Object value = state.value(key).orElse(null);
if (value instanceof Number number) {
return number.intValue();
}
if (value instanceof String text) {
try {
return Integer.parseInt(text);
} catch (NumberFormatException ignored) {
return 0;
}
}
return 0;
}
public static String stringValue(OverAllState state, String key) {
return state.value(key)
.map(String::valueOf)
.map(String::trim)
.filter(value -> !value.isEmpty())
.orElse(null);
}
public static <E extends Enum<E>> Optional<E> enumValue(
OverAllState state, String key, Class<E> type) {
Object value = state.value(key).orElse(null);
if (type.isInstance(value)) {
return Optional.of(type.cast(value));
}
if (value == null) {
return Optional.empty();
}
try {
return Optional.of(Enum.valueOf(type,
String.valueOf(value).trim().toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
@SuppressWarnings("unchecked")
public static Map<String, Object> mapValue(OverAllState state, String key) {
Object value = state.value(key).orElse(null);
if (value instanceof Map<?, ?> map) {
return (Map<String, Object>) map;
}
return Collections.emptyMap();
}
public static List<?> listValue(OverAllState state, String key) {
Object value = state.value(key).orElse(null);
return value instanceof List<?> list ? list : List.of();
}
}
@@ -0,0 +1,52 @@
package com.superbiz.agent.graph.diagnosis;
public final class DiagnosisGraphStatus {
private DiagnosisGraphStatus() {
}
public enum PlannerStatus {
COMPLETED,
INVALID_OUTPUT,
RETRYABLE_FAILED,
NON_RETRYABLE_FAILED
}
public enum ExecutorStatus {
COMPLETED,
INVALID_OUTPUT,
TOOL_BLOCKED,
FAILED
}
public enum GatekeeperStatus {
PASS,
LOW_CONFID,
REJECT
}
public enum VerifierStatus {
COMPLETED,
INVALID_OUTPUT,
RETRYABLE_FAILED,
NON_RETRYABLE_FAILED
}
public enum ComposerStatus {
COMPLETED,
INVALID_OUTPUT,
RETRYABLE_FAILED,
NON_RETRYABLE_FAILED
}
public enum Verdict {
PASS,
LOW_CONFID,
REJECT
}
public enum PlannerMode {
NORMAL,
EVIDENCE_GAP_ONLY
}
}
@@ -0,0 +1,59 @@
package com.superbiz.agent.graph.diagnosis;
public final class DiagnosisGraphTopology {
public static final String GRAPH_NAME = "chat-diagnosis-stategraph";
public static final int RECURSION_LIMIT = 32;
private DiagnosisGraphTopology() {
}
public static final class Node {
public static final String PLANNER = "planner";
public static final String EXECUTOR = "executor";
public static final String GATEKEEPER = "gatekeeper";
public static final String VERIFIED_INPUT = "verified_input";
public static final String VERIFIER = "verifier";
public static final String EVIDENCE_RETRY = "evidence_retry";
public static final String COMPOSER = "composer";
public static final String FALLBACK = "fallback";
private Node() {
}
}
public static final class Route {
public static final String EXECUTOR = "executor";
public static final String RETRY_PLANNER = "retry_planner";
public static final String GATEKEEPER = "gatekeeper";
public static final String VERIFIED_INPUT = "verified_input";
public static final String VERIFIER = "verifier";
public static final String RETRY_VERIFIER = "retry_verifier";
public static final String EVIDENCE_RETRY = "evidence_retry";
public static final String COMPOSER = "composer";
public static final String RETRY_COMPOSER = "retry_composer";
public static final String FALLBACK = "fallback";
public static final String END = "end";
private Route() {
}
}
public static final class Reason {
public static final String COMPLETED = "completed";
public static final String INVALID_OUTPUT = "invalid_output";
public static final String RETRYABLE_FAILED = "retryable_failed";
public static final String NON_RETRYABLE_FAILED = "non_retryable_failed";
public static final String TOOL_BLOCKED = "tool_blocked";
public static final String FAILED = "failed";
public static final String GATEKEEPER_REJECT = "gatekeeper_reject";
public static final String GATEKEEPER_LOW_CONFID_NO_BINDINGS =
"gatekeeper_low_confid_no_bindings";
public static final String UNKNOWN_STATUS = "unknown_status";
public static final String EVIDENCE_RETRY = "evidence_retry";
public static final String FALLBACK_COMPLETED = "fallback_completed";
private Reason() {
}
}
}
@@ -0,0 +1,48 @@
package com.superbiz.agent.graph.diagnosis;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public record DiagnosisOrchestrationTrace(
String version,
List<OrchestrationTransition> transitions,
String finalNode,
String terminationReason,
boolean degraded,
int evidenceRetryCount) {
public DiagnosisOrchestrationTrace {
if (version == null || version.isBlank()) {
throw new IllegalArgumentException("version must not be blank");
}
version = version.trim();
transitions = List.copyOf(Objects.requireNonNull(transitions, "transitions"));
if (finalNode == null || finalNode.isBlank()) {
throw new IllegalArgumentException("finalNode must not be blank");
}
finalNode = finalNode.trim();
if (terminationReason == null || terminationReason.isBlank()) {
throw new IllegalArgumentException("terminationReason must not be blank");
}
terminationReason = terminationReason.trim();
if (evidenceRetryCount < 0) {
throw new IllegalArgumentException("evidenceRetryCount must not be negative");
}
}
public Map<String, Object> toMap() {
Map<String, Object> value = new LinkedHashMap<>();
value.put("version", version);
value.put("transitions", transitions.stream()
.map(OrchestrationTransition::toMap)
.toList());
value.put("final_node", finalNode);
value.put("termination_reason", terminationReason);
value.put("degraded", degraded);
value.put("evidence_retry_count", evidenceRetryCount);
return Collections.unmodifiableMap(value);
}
}
@@ -0,0 +1,58 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.OverAllState;
import java.util.ArrayList;
import java.util.List;
public final class DiagnosisOrchestrationTraceBuilder {
public static final String VERSION = "stategraph-v1";
public DiagnosisOrchestrationTrace build(OverAllState state) {
List<?> values = DiagnosisGraphState.listValue(
state, DiagnosisGraphState.ORCHESTRATION_EVENTS);
if (values.isEmpty()) {
throw new IllegalStateException(
"orchestration events must not be empty");
}
List<OrchestrationEvent> events = new ArrayList<>(values.size());
for (Object value : values) {
if (!(value instanceof OrchestrationEvent event)) {
throw new IllegalArgumentException(
"orchestration events contain unsupported value: "
+ valueType(value));
}
events.add(event);
}
List<OrchestrationTransition> transitions = new ArrayList<>(
Math.max(0, events.size() - 1));
for (int index = 0; index + 1 < events.size(); index++) {
OrchestrationEvent source = events.get(index);
OrchestrationEvent target = events.get(index + 1);
transitions.add(new OrchestrationTransition(
source.node(),
target.node(),
source.reasonCode(),
source.attempt()));
}
OrchestrationEvent last = events.get(events.size() - 1);
boolean degraded = DiagnosisGraphTopology.Node.FALLBACK.equals(last.node());
int evidenceRetryCount = DiagnosisGraphState.intValue(
state, DiagnosisGraphState.EVIDENCE_RETRY_COUNT);
return new DiagnosisOrchestrationTrace(
VERSION,
transitions,
last.node(),
last.reasonCode(),
degraded,
evidenceRetryCount);
}
private String valueType(Object value) {
return value == null ? "null" : value.getClass().getName();
}
}
@@ -0,0 +1,37 @@
package com.superbiz.agent.graph.diagnosis;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public record OrchestrationEvent(
String node,
String outcome,
String reasonCode,
int attempt) {
public OrchestrationEvent {
node = requireText(node, "node");
outcome = requireText(outcome, "outcome");
reasonCode = requireText(reasonCode, "reasonCode");
if (attempt < 1) {
throw new IllegalArgumentException("attempt must be at least 1");
}
}
public Map<String, Object> toMap() {
Map<String, Object> value = new LinkedHashMap<>();
value.put("node", node);
value.put("outcome", outcome);
value.put("reason_code", reasonCode);
value.put("attempt", attempt);
return Collections.unmodifiableMap(value);
}
private static String requireText(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value.trim();
}
}
@@ -0,0 +1,37 @@
package com.superbiz.agent.graph.diagnosis;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public record OrchestrationTransition(
String from,
String to,
String reasonCode,
int attempt) {
public OrchestrationTransition {
from = requireText(from, "from");
to = requireText(to, "to");
reasonCode = requireText(reasonCode, "reasonCode");
if (attempt < 1) {
throw new IllegalArgumentException("attempt must be at least 1");
}
}
public Map<String, Object> toMap() {
Map<String, Object> value = new LinkedHashMap<>();
value.put("from", from);
value.put("to", to);
value.put("reason_code", reasonCode);
value.put("attempt", attempt);
return Collections.unmodifiableMap(value);
}
private static String requireText(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value.trim();
}
}
@@ -0,0 +1,609 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.superbiz.agent.graph.diagnosis.DiagnosisGraphStatus.ExecutorStatus;
import com.superbiz.agent.graph.diagnosis.DiagnosisGraphStatus.GatekeeperStatus;
import com.superbiz.agent.graph.diagnosis.DiagnosisGraphStatus.PlannerMode;
import com.superbiz.agent.graph.diagnosis.DiagnosisGraphStatus.PlannerStatus;
import com.superbiz.agent.graph.diagnosis.DiagnosisGraphStatus.Verdict;
import com.superbiz.agent.graph.diagnosis.DiagnosisGraphStatus.VerifierStatus;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
class DiagnosisGraphRoutingTest {
private static final String THREAD_ID = "run-routing-test";
@Test
void normalPassPathUsesCompiledGraphAndPreservesEventOrder() throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
planner(script, PlannerStatus.COMPLETED);
executor(script, ExecutorStatus.COMPLETED);
gatekeeper(script, GatekeeperStatus.PASS, 1);
verifiedInput(script);
verifier(script, VerifierStatus.COMPLETED, Verdict.PASS,
Verdict.PASS, Map.of());
composerCompleted(script);
OverAllState state = run(script);
assertEquals(List.of(
DiagnosisGraphTopology.Node.PLANNER,
DiagnosisGraphTopology.Node.EXECUTOR,
DiagnosisGraphTopology.Node.GATEKEEPER,
DiagnosisGraphTopology.Node.VERIFIED_INPUT,
DiagnosisGraphTopology.Node.VERIFIER,
DiagnosisGraphTopology.Node.COMPOSER),
script.sequence());
script.sequence().forEach(node -> assertEquals(
List.of(THREAD_ID),
script.threadIds(node),
"threadId for " + node));
assertEquals(6, DiagnosisGraphState.listValue(
state, DiagnosisGraphState.ORCHESTRATION_EVENTS).size());
DiagnosisOrchestrationTrace trace =
new DiagnosisOrchestrationTraceBuilder().build(state);
assertEquals(DiagnosisGraphTopology.Node.COMPOSER, trace.finalNode());
assertFalse(trace.degraded());
assertEquals(5, trace.transitions().size());
}
@ParameterizedTest
@ValueSource(strings = {"INVALID_OUTPUT", "RETRYABLE_FAILED"})
void plannerTechnicalFailureRetriesOnlyPlannerOnce(String firstStatus)
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
planner(script, firstStatus);
planner(script, PlannerStatus.COMPLETED);
appendNormalAfterPlanner(script);
OverAllState state = run(script);
assertEquals(List.of(
DiagnosisGraphTopology.Node.PLANNER,
DiagnosisGraphTopology.Node.PLANNER,
DiagnosisGraphTopology.Node.EXECUTOR,
DiagnosisGraphTopology.Node.GATEKEEPER,
DiagnosisGraphTopology.Node.VERIFIED_INPUT,
DiagnosisGraphTopology.Node.VERIFIER,
DiagnosisGraphTopology.Node.COMPOSER),
script.sequence());
assertEquals(1, DiagnosisGraphState.intValue(
state, DiagnosisGraphState.PLANNER_RETRY_COUNT));
assertEquals(2, script.calls(DiagnosisGraphTopology.Node.PLANNER));
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.EXECUTOR));
}
@Test
void plannerSecondTechnicalFailureFallsBackWithoutExecutor()
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
planner(script, PlannerStatus.INVALID_OUTPUT);
planner(script, PlannerStatus.RETRYABLE_FAILED);
fallback(script);
OverAllState state = run(script);
assertEquals(List.of(
DiagnosisGraphTopology.Node.PLANNER,
DiagnosisGraphTopology.Node.PLANNER,
DiagnosisGraphTopology.Node.FALLBACK),
script.sequence());
assertEquals(1, DiagnosisGraphState.intValue(
state, DiagnosisGraphState.PLANNER_RETRY_COUNT));
assertEquals(0, script.calls(DiagnosisGraphTopology.Node.EXECUTOR));
assertFallbackTrace(state);
}
@ParameterizedTest
@ValueSource(strings = {"NON_RETRYABLE_FAILED", "UNKNOWN"})
void plannerNonRetryableOrUnknownStatusFailsClosed(String status)
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
planner(script, status);
fallback(script);
OverAllState state = run(script);
assertEquals(List.of(
DiagnosisGraphTopology.Node.PLANNER,
DiagnosisGraphTopology.Node.FALLBACK),
script.sequence());
assertEquals(0, script.calls(DiagnosisGraphTopology.Node.EXECUTOR));
assertFallbackTrace(state);
}
@ParameterizedTest
@ValueSource(strings = {
"INVALID_OUTPUT", "TOOL_BLOCKED", "FAILED", "UNKNOWN"
})
void executorFailureNeverRetriesOrInvokesGatekeeper(String status)
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
planner(script, PlannerStatus.COMPLETED);
executor(script, status);
fallback(script);
OverAllState state = run(script);
assertEquals(List.of(
DiagnosisGraphTopology.Node.PLANNER,
DiagnosisGraphTopology.Node.EXECUTOR,
DiagnosisGraphTopology.Node.FALLBACK),
script.sequence());
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.EXECUTOR));
assertEquals(0, script.calls(DiagnosisGraphTopology.Node.GATEKEEPER));
assertEquals(0, script.calls(DiagnosisGraphTopology.Node.VERIFIER));
assertFallbackTrace(state);
}
@Test
void executorCompletedNoEvidenceStillInvokesGatekeeper() throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
planner(script, PlannerStatus.COMPLETED);
script.step(
DiagnosisGraphTopology.Node.EXECUTOR,
ExecutorStatus.COMPLETED.name(),
"legal_no_evidence",
Map.of(
DiagnosisGraphState.EXECUTOR_STATUS,
ExecutorStatus.COMPLETED.name(),
DiagnosisGraphState.EXECUTOR_OUTPUT,
Map.of("claims", List.of())));
gatekeeper(script, GatekeeperStatus.PASS, 0);
verifiedInput(script);
verifier(script, VerifierStatus.COMPLETED, Verdict.PASS,
Verdict.PASS, Map.of());
composerCompleted(script);
run(script);
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.GATEKEEPER));
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.VERIFIER));
}
@ParameterizedTest
@MethodSource("gatekeeperFallbackCases")
void gatekeeperUnsafeOutcomesSkipVerifier(
String status,
int verifiedBindingCount) throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
planner(script, PlannerStatus.COMPLETED);
executor(script, ExecutorStatus.COMPLETED);
gatekeeper(script, status, verifiedBindingCount);
fallback(script);
OverAllState state = run(script);
assertEquals(0, script.calls(DiagnosisGraphTopology.Node.VERIFIED_INPUT));
assertEquals(0, script.calls(DiagnosisGraphTopology.Node.VERIFIER));
assertFallbackTrace(state);
}
@Test
void gatekeeperLowConfidenceWithVerifiedBindingRunsVerifiedInput()
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
planner(script, PlannerStatus.COMPLETED);
executor(script, ExecutorStatus.COMPLETED);
gatekeeper(script, GatekeeperStatus.LOW_CONFID, 1);
verifiedInput(script);
verifier(script, VerifierStatus.COMPLETED, Verdict.LOW_CONFID,
Verdict.LOW_CONFID, Map.of());
composerCompleted(script);
OverAllState state = run(script);
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.VERIFIED_INPUT));
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.VERIFIER));
assertEquals(0, script.calls(DiagnosisGraphTopology.Node.EVIDENCE_RETRY));
assertEquals(Verdict.LOW_CONFID,
DiagnosisGraphState.enumValue(
state,
DiagnosisGraphState.EFFECTIVE_VERDICT,
Verdict.class)
.orElseThrow());
}
@ParameterizedTest
@ValueSource(strings = {"INVALID_OUTPUT", "RETRYABLE_FAILED"})
void verifierTechnicalFailureRetriesOnlyVerifier(String firstStatus)
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
appendThroughVerifiedInput(script);
verifierFailure(script, firstStatus);
verifier(script, VerifierStatus.COMPLETED, Verdict.PASS,
Verdict.PASS, Map.of());
composerCompleted(script);
OverAllState state = run(script);
assertEquals(2, script.calls(DiagnosisGraphTopology.Node.VERIFIER));
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.GATEKEEPER));
assertEquals(1, DiagnosisGraphState.intValue(
state, DiagnosisGraphState.VERIFIER_RETRY_COUNT));
assertEquals(0, DiagnosisGraphState.intValue(
state, DiagnosisGraphState.EVIDENCE_RETRY_COUNT));
}
@Test
void verifierSecondTechnicalFailureFallsBack() throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
appendThroughVerifiedInput(script);
verifierFailure(script, VerifierStatus.INVALID_OUTPUT.name());
verifierFailure(script, VerifierStatus.RETRYABLE_FAILED.name());
fallback(script);
OverAllState state = run(script);
assertEquals(2, script.calls(DiagnosisGraphTopology.Node.VERIFIER));
assertEquals(0, script.calls(DiagnosisGraphTopology.Node.COMPOSER));
assertEquals(1, DiagnosisGraphState.intValue(
state, DiagnosisGraphState.VERIFIER_RETRY_COUNT));
assertFallbackTrace(state);
}
@ParameterizedTest
@ValueSource(strings = {"NON_RETRYABLE_FAILED", "UNKNOWN"})
void verifierNonRetryableOrUnknownStatusFailsClosed(String status)
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
appendThroughVerifiedInput(script);
verifierFailure(script, status);
fallback(script);
OverAllState state = run(script);
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.VERIFIER));
assertEquals(0, script.calls(DiagnosisGraphTopology.Node.COMPOSER));
assertFallbackTrace(state);
}
@Test
void evidenceRetryRunsOnceAndResetsPlannerStageCounter()
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
planner(script, PlannerStatus.INVALID_OUTPUT);
planner(script, PlannerStatus.COMPLETED);
appendExecutorThroughVerifiedInput(script);
verifier(script, VerifierStatus.COMPLETED, Verdict.LOW_CONFID,
Verdict.PASS, verifierOutputWithGap());
evidenceRetry(script);
planner(script, PlannerStatus.INVALID_OUTPUT);
planner(script, PlannerStatus.COMPLETED);
appendExecutorThroughVerifiedInput(script);
verifier(script, VerifierStatus.COMPLETED, Verdict.LOW_CONFID,
Verdict.PASS, verifierOutputWithGap());
composerCompleted(script);
OverAllState state = run(script);
assertEquals(4, script.calls(DiagnosisGraphTopology.Node.PLANNER));
assertEquals(2, script.calls(DiagnosisGraphTopology.Node.EXECUTOR));
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.EVIDENCE_RETRY));
assertEquals(1, DiagnosisGraphState.intValue(
state, DiagnosisGraphState.EVIDENCE_RETRY_COUNT));
assertEquals(1, DiagnosisGraphState.intValue(
state, DiagnosisGraphState.PLANNER_RETRY_COUNT));
assertEquals(0, DiagnosisGraphState.intValue(
state, DiagnosisGraphState.VERIFIER_RETRY_COUNT));
assertEquals(PlannerMode.EVIDENCE_GAP_ONLY,
DiagnosisGraphState.enumValue(
state,
DiagnosisGraphState.PLANNER_MODE,
PlannerMode.class)
.orElseThrow());
}
@Test
void verifierLowConfidenceWithoutGapDoesNotRetryEvidence()
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
appendThroughVerifiedInput(script);
verifier(script, VerifierStatus.COMPLETED, Verdict.LOW_CONFID,
Verdict.PASS, Map.of("facts_checked", List.of(
Map.of("fact", "cpu is high",
"verification", "direct_support"))));
composerCompleted(script);
run(script);
assertEquals(0, script.calls(DiagnosisGraphTopology.Node.EVIDENCE_RETRY));
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.COMPOSER));
}
@Test
void verifierRejectStillRunsComposerWithSafeMaterial() throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
appendThroughVerifiedInput(script);
verifier(script, VerifierStatus.COMPLETED, Verdict.REJECT,
Verdict.PASS, Map.of());
composerCompleted(script);
run(script);
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.COMPOSER));
assertEquals(0, script.calls(DiagnosisGraphTopology.Node.FALLBACK));
}
@Test
void verifierCompletedWithoutEffectiveVerdictFailsClosed()
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
appendThroughVerifiedInput(script);
verifier(script, VerifierStatus.COMPLETED, null,
Verdict.PASS, Map.of());
fallback(script);
OverAllState state = run(script);
assertEquals(0, script.calls(DiagnosisGraphTopology.Node.COMPOSER));
assertFallbackTrace(state);
}
@ParameterizedTest
@ValueSource(strings = {"INVALID_OUTPUT", "RETRYABLE_FAILED"})
void composerTechnicalFailureRetriesOnlyComposer(String firstStatus)
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
appendThroughCompletedVerifier(script);
composer(script, firstStatus);
composerCompleted(script);
OverAllState state = run(script);
assertEquals(2, script.calls(DiagnosisGraphTopology.Node.COMPOSER));
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.VERIFIER));
assertEquals(1, DiagnosisGraphState.intValue(
state, DiagnosisGraphState.COMPOSER_RETRY_COUNT));
}
@Test
void composerSecondTechnicalFailureFallsBackAndTerminates()
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
appendThroughCompletedVerifier(script);
composer(script, "INVALID_OUTPUT");
composer(script, "RETRYABLE_FAILED");
fallback(script);
OverAllState state = run(script);
assertEquals(2, script.calls(DiagnosisGraphTopology.Node.COMPOSER));
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.FALLBACK));
assertEquals(1, DiagnosisGraphState.intValue(
state, DiagnosisGraphState.COMPOSER_RETRY_COUNT));
assertFallbackTrace(state);
}
@ParameterizedTest
@ValueSource(strings = {"NON_RETRYABLE_FAILED", "UNKNOWN"})
void composerNonRetryableOrUnknownStatusFailsClosed(String status)
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
appendThroughCompletedVerifier(script);
composer(script, status);
fallback(script);
OverAllState state = run(script);
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.COMPOSER));
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.FALLBACK));
assertFallbackTrace(state);
}
private static Stream<Arguments> gatekeeperFallbackCases() {
return Stream.of(
Arguments.of(GatekeeperStatus.REJECT.name(), 1),
Arguments.of(GatekeeperStatus.LOW_CONFID.name(), 0),
Arguments.of("UNKNOWN", 1));
}
private OverAllState run(ScriptedDiagnosisGraphActions script)
throws Exception {
CompiledGraph graph = new DiagnosisGraphFactory().compile(script.actions());
assertEquals(DiagnosisGraphTopology.RECURSION_LIMIT,
graph.getMaxIterations());
return graph.invoke(
initialState(),
RunnableConfig.builder().threadId(THREAD_ID).build())
.orElseThrow();
}
private Map<String, Object> initialState() {
return Map.of(
DiagnosisGraphState.PLANNER_RETRY_COUNT, 0,
DiagnosisGraphState.VERIFIER_RETRY_COUNT, 0,
DiagnosisGraphState.COMPOSER_RETRY_COUNT, 0,
DiagnosisGraphState.EVIDENCE_RETRY_COUNT, 0,
DiagnosisGraphState.PLANNER_MODE, PlannerMode.NORMAL.name());
}
private void appendNormalAfterPlanner(
ScriptedDiagnosisGraphActions script) {
executor(script, ExecutorStatus.COMPLETED);
gatekeeper(script, GatekeeperStatus.PASS, 1);
verifiedInput(script);
verifier(script, VerifierStatus.COMPLETED, Verdict.PASS,
Verdict.PASS, Map.of());
composerCompleted(script);
}
private void appendThroughVerifiedInput(
ScriptedDiagnosisGraphActions script) {
planner(script, PlannerStatus.COMPLETED);
appendExecutorThroughVerifiedInput(script);
}
private void appendExecutorThroughVerifiedInput(
ScriptedDiagnosisGraphActions script) {
executor(script, ExecutorStatus.COMPLETED);
gatekeeper(script, GatekeeperStatus.PASS, 1);
verifiedInput(script);
}
private void appendThroughCompletedVerifier(
ScriptedDiagnosisGraphActions script) {
appendThroughVerifiedInput(script);
verifier(script, VerifierStatus.COMPLETED, Verdict.PASS,
Verdict.PASS, Map.of());
}
private void planner(
ScriptedDiagnosisGraphActions script,
PlannerStatus status) {
planner(script, status.name());
}
private void planner(
ScriptedDiagnosisGraphActions script,
String status) {
script.step(
DiagnosisGraphTopology.Node.PLANNER,
status,
reason(status),
Map.of(DiagnosisGraphState.PLANNER_STATUS, status));
}
private void executor(
ScriptedDiagnosisGraphActions script,
ExecutorStatus status) {
executor(script, status.name());
}
private void executor(
ScriptedDiagnosisGraphActions script,
String status) {
script.step(
DiagnosisGraphTopology.Node.EXECUTOR,
status,
reason(status),
Map.of(DiagnosisGraphState.EXECUTOR_STATUS, status));
}
private void gatekeeper(
ScriptedDiagnosisGraphActions script,
GatekeeperStatus status,
int verifiedBindingCount) {
gatekeeper(script, status.name(), verifiedBindingCount);
}
private void gatekeeper(
ScriptedDiagnosisGraphActions script,
String status,
int verifiedBindingCount) {
script.step(
DiagnosisGraphTopology.Node.GATEKEEPER,
status,
reason(status),
Map.of(
DiagnosisGraphState.GATEKEEPER_STATUS, status,
DiagnosisGraphState.VERIFIED_BINDING_COUNT,
verifiedBindingCount));
}
private void verifiedInput(ScriptedDiagnosisGraphActions script) {
script.step(
DiagnosisGraphTopology.Node.VERIFIED_INPUT,
"COMPLETED",
"verified_input_completed",
Map.of());
}
private void verifier(
ScriptedDiagnosisGraphActions script,
VerifierStatus status,
Verdict verdict,
Verdict ceiling,
Map<String, Object> verifierOutput) {
Map<String, Object> update = new java.util.LinkedHashMap<>();
update.put(DiagnosisGraphState.VERIFIER_STATUS, status.name());
if (verdict != null) {
update.put(DiagnosisGraphState.EFFECTIVE_VERDICT, verdict.name());
}
if (ceiling != null) {
update.put(DiagnosisGraphState.VERIFIER_VERDICT_CEILING,
ceiling.name());
}
update.put(DiagnosisGraphState.VERIFIER_OUTPUT, verifierOutput);
script.step(
DiagnosisGraphTopology.Node.VERIFIER,
status.name(),
reason(status.name()),
update);
}
private void verifierFailure(
ScriptedDiagnosisGraphActions script,
String status) {
script.step(
DiagnosisGraphTopology.Node.VERIFIER,
status,
reason(status),
Map.of(DiagnosisGraphState.VERIFIER_STATUS, status));
}
private void composerCompleted(ScriptedDiagnosisGraphActions script) {
composer(script, "COMPLETED");
}
private void composer(
ScriptedDiagnosisGraphActions script,
String status) {
script.step(
DiagnosisGraphTopology.Node.COMPOSER,
status,
"composer_" + reason(status),
Map.of(DiagnosisGraphState.COMPOSER_STATUS, status));
}
private void evidenceRetry(ScriptedDiagnosisGraphActions script) {
script.step(
DiagnosisGraphTopology.Node.EVIDENCE_RETRY,
"COMPLETED",
DiagnosisGraphTopology.Reason.EVIDENCE_RETRY,
Map.of(DiagnosisGraphState.RETRY_CONTEXT,
Map.of("mode", PlannerMode.EVIDENCE_GAP_ONLY.name())));
}
private Map<String, Object> verifierOutputWithGap() {
return Map.of("facts_checked", List.of(
Map.of(
"fact", "database saturation remains unverified",
"verification", "no_evidence")));
}
private void fallback(ScriptedDiagnosisGraphActions script) {
script.step(
DiagnosisGraphTopology.Node.FALLBACK,
"COMPLETED",
DiagnosisGraphTopology.Reason.FALLBACK_COMPLETED,
Map.of(DiagnosisGraphState.FINAL_ANSWER, "safe fallback"));
}
private void assertFallbackTrace(OverAllState state) {
DiagnosisOrchestrationTrace trace =
new DiagnosisOrchestrationTraceBuilder().build(state);
assertEquals(DiagnosisGraphTopology.Node.FALLBACK, trace.finalNode());
assertEquals(DiagnosisGraphTopology.Reason.FALLBACK_COMPLETED,
trace.terminationReason());
assertEquals(true, trace.degraded());
}
private String reason(String status) {
return status.toLowerCase(java.util.Locale.ROOT);
}
}
@@ -0,0 +1,155 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DiagnosisOrchestrationTraceBuilderTest {
private final DiagnosisOrchestrationTraceBuilder builder =
new DiagnosisOrchestrationTraceBuilder();
@Test
void buildsExactTransitionsAndRoutingOnlyMap() {
OverAllState state = stateWithEvents(List.of(
event("planner", "COMPLETED", "planner_completed", 1),
event("executor", "COMPLETED", "executor_completed", 1),
event("gatekeeper", "PASS", "gatekeeper_pass", 1),
event("verifier", "LOW_CONFID", "verifier_low_confid", 1),
event("evidence_retry", "COMPLETED", "evidence_retry", 1),
event("planner", "COMPLETED", "planner_completed", 2),
event("composer", "COMPLETED", "composer_completed", 1)
), 1);
DiagnosisOrchestrationTrace trace = builder.build(state);
assertEquals("stategraph-v1", trace.version());
assertEquals("composer", trace.finalNode());
assertEquals("composer_completed", trace.terminationReason());
assertEquals(1, trace.evidenceRetryCount());
assertFalse(trace.degraded());
assertEquals(List.of(
"planner->executor",
"executor->gatekeeper",
"gatekeeper->verifier",
"verifier->evidence_retry",
"evidence_retry->planner",
"planner->composer"),
trace.transitions().stream()
.map(value -> value.from() + "->" + value.to())
.toList());
assertEquals("verifier_low_confid",
trace.transitions().get(3).reasonCode());
Map<String, Object> traceMap = trace.toMap();
assertEquals(Set.of(
"version",
"transitions",
"final_node",
"termination_reason",
"degraded",
"evidence_retry_count"),
traceMap.keySet());
@SuppressWarnings("unchecked")
Map<String, Object> transitionMap =
((List<Map<String, Object>>) traceMap.get("transitions")).get(0);
assertEquals(Set.of("from", "to", "reason_code", "attempt"),
transitionMap.keySet());
assertFalse(traceMap.toString().contains("prompt"));
assertFalse(traceMap.toString().contains("tool_output"));
}
@Test
void fallbackFinalEventMarksTraceDegraded() {
OverAllState state = stateWithEvents(List.of(
event("planner", "NON_RETRYABLE_FAILED",
"planner_non_retryable_failed", 1),
event("fallback", "COMPLETED", "fallback_completed", 1)
), 0);
DiagnosisOrchestrationTrace trace = builder.build(state);
assertEquals("fallback", trace.finalNode());
assertEquals("fallback_completed", trace.terminationReason());
assertTrue(trace.degraded());
}
@Test
void emptyEventsFailExplicitly() {
OverAllState state = new OverAllState(Map.of());
IllegalStateException error = assertThrows(
IllegalStateException.class,
() -> builder.build(state));
assertEquals("orchestration events must not be empty",
error.getMessage());
}
@Test
void unsupportedEventValueFailsExplicitly() {
OverAllState state = new OverAllState(Map.of(
DiagnosisGraphState.ORCHESTRATION_EVENTS,
List.of(Map.of("node", "planner"))));
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> builder.build(state));
assertTrue(error.getMessage().contains("unsupported value"));
}
@Test
void eventRejectsBlankRoutingMetadataAndInvalidAttempt() {
assertThrows(IllegalArgumentException.class,
() -> event("", "COMPLETED", "completed", 1));
assertThrows(IllegalArgumentException.class,
() -> event("planner", " ", "completed", 1));
assertThrows(IllegalArgumentException.class,
() -> event("planner", "COMPLETED", null, 1));
assertThrows(IllegalArgumentException.class,
() -> event("planner", "COMPLETED", "completed", 0));
}
@Test
void traceAndTransitionCopiesAreImmutable() {
DiagnosisOrchestrationTrace trace = builder.build(stateWithEvents(
List.of(
event("planner", "COMPLETED", "completed", 1),
event("composer", "COMPLETED", "completed", 1)),
0));
assertThrows(UnsupportedOperationException.class,
() -> trace.transitions().add(
new OrchestrationTransition("x", "y", "z", 1)));
assertThrows(UnsupportedOperationException.class,
() -> trace.toMap().put("prompt", "forbidden"));
assertThrows(UnsupportedOperationException.class,
() -> trace.transitions().get(0).toMap()
.put("tool_output", "forbidden"));
}
private OverAllState stateWithEvents(
List<OrchestrationEvent> events,
int evidenceRetryCount) {
return new OverAllState(Map.of(
DiagnosisGraphState.ORCHESTRATION_EVENTS, events,
DiagnosisGraphState.EVIDENCE_RETRY_COUNT, evidenceRetryCount));
}
private OrchestrationEvent event(
String node,
String outcome,
String reasonCode,
int attempt) {
return new OrchestrationEvent(node, outcome, reasonCode, attempt);
}
}
@@ -0,0 +1,130 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.alibaba.cloud.ai.graph.action.AsyncNodeActionWithConfig;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
final class ScriptedDiagnosisGraphActions {
private final Map<String, ScriptedNode> nodes = new LinkedHashMap<>();
private final List<String> sequence = new ArrayList<>();
ScriptedDiagnosisGraphActions() {
nodes.put(DiagnosisGraphTopology.Node.PLANNER,
new ScriptedNode(DiagnosisGraphTopology.Node.PLANNER));
nodes.put(DiagnosisGraphTopology.Node.EXECUTOR,
new ScriptedNode(DiagnosisGraphTopology.Node.EXECUTOR));
nodes.put(DiagnosisGraphTopology.Node.GATEKEEPER,
new ScriptedNode(DiagnosisGraphTopology.Node.GATEKEEPER));
nodes.put(DiagnosisGraphTopology.Node.VERIFIED_INPUT,
new ScriptedNode(DiagnosisGraphTopology.Node.VERIFIED_INPUT));
nodes.put(DiagnosisGraphTopology.Node.VERIFIER,
new ScriptedNode(DiagnosisGraphTopology.Node.VERIFIER));
nodes.put(DiagnosisGraphTopology.Node.EVIDENCE_RETRY,
new ScriptedNode(DiagnosisGraphTopology.Node.EVIDENCE_RETRY));
nodes.put(DiagnosisGraphTopology.Node.COMPOSER,
new ScriptedNode(DiagnosisGraphTopology.Node.COMPOSER));
nodes.put(DiagnosisGraphTopology.Node.FALLBACK,
new ScriptedNode(DiagnosisGraphTopology.Node.FALLBACK));
}
ScriptedDiagnosisGraphActions step(
String node,
String outcome,
String reasonCode,
Map<String, Object> update) {
node(node).steps.addLast(new Step(outcome, reasonCode, Map.copyOf(update)));
return this;
}
DiagnosisGraphActions actions() {
return new DiagnosisGraphActions(
node(DiagnosisGraphTopology.Node.PLANNER).action(),
node(DiagnosisGraphTopology.Node.EXECUTOR).action(),
node(DiagnosisGraphTopology.Node.GATEKEEPER).action(),
node(DiagnosisGraphTopology.Node.VERIFIED_INPUT).action(),
node(DiagnosisGraphTopology.Node.VERIFIER).action(),
node(DiagnosisGraphTopology.Node.EVIDENCE_RETRY).action(),
node(DiagnosisGraphTopology.Node.COMPOSER).action(),
node(DiagnosisGraphTopology.Node.FALLBACK).action());
}
List<String> sequence() {
return List.copyOf(sequence);
}
int calls(String node) {
return node(node).calls;
}
List<String> threadIds(String node) {
return List.copyOf(node(node).threadIds);
}
private ScriptedNode node(String name) {
ScriptedNode node = nodes.get(name);
if (node == null) {
throw new IllegalArgumentException("unknown node: " + name);
}
return node;
}
private final class ScriptedNode {
private final String name;
private final Deque<Step> steps = new ArrayDeque<>();
private final List<String> threadIds = new ArrayList<>();
private int calls;
private ScriptedNode(String name) {
this.name = name;
}
private AsyncNodeActionWithConfig action() {
return this::apply;
}
private CompletableFuture<Map<String, Object>> apply(
OverAllState state,
RunnableConfig config) {
if (steps.isEmpty()) {
throw new IllegalStateException(
"unexpected invocation of node " + name);
}
Step step = steps.removeFirst();
calls++;
sequence.add(name);
threadIds.add(config.threadId().orElse(null));
Map<String, Object> update = new LinkedHashMap<>(step.update());
update.put(DiagnosisGraphState.ORCHESTRATION_EVENTS,
List.of(new OrchestrationEvent(
name,
step.outcome(),
step.reasonCode(),
calls)));
return CompletableFuture.completedFuture(update);
}
}
private record Step(
String outcome,
String reasonCode,
Map<String, Object> update) {
private Step {
Objects.requireNonNull(outcome, "outcome");
Objects.requireNonNull(reasonCode, "reasonCode");
Objects.requireNonNull(update, "update");
}
}
}