diff --git a/apps/api/.env.example b/apps/api/.env.example
index 4a01c6d8d..7f51ce287 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -94,6 +94,10 @@ RETRIEVAL_WALLET_PER_RETRIEVE_STEP_BUDGET=40000
RETRIEVAL_WALLET_PER_SYNTHESIZE_STEP_BUDGET=6000
RETRIEVAL_WORKFLOW_PARALLEL_MAX=3
+# Agentic retrieval (LLM-driven hierarchical navigation).
+# Set to false to fall back to legacy 3-channel RRF mode.
+RETRIEVAL_AGENTIC_ENABLED=true
+
# File handling defaults
SUPPORTED_EXTENSIONS=.doc,.docx,.pdf,.txt,.xls,.xlsx,.csv,.pptx,.jpg,.jpeg,.png,.md
MAX_FILE_SIZE=104857600
@@ -128,4 +132,4 @@ ILOVEAPI_TIMEOUT=120
# Legacy parser compatibility fields.
ALL_DF_COLS=content,path,type,length,keywords,summary,know_id,tokens,connectto,addtime,page_nums
-SPLIT_CHAR=-->
+SPLIT_CHAR=/
diff --git a/apps/worker/.env.example b/apps/worker/.env.example
index 6c46d9df7..a57b458fc 100644
--- a/apps/worker/.env.example
+++ b/apps/worker/.env.example
@@ -94,6 +94,10 @@ RETRIEVAL_WALLET_PER_RETRIEVE_STEP_BUDGET=40000
RETRIEVAL_WALLET_PER_SYNTHESIZE_STEP_BUDGET=6000
RETRIEVAL_WORKFLOW_PARALLEL_MAX=3
+# Agentic retrieval (LLM-driven hierarchical navigation).
+# Set to false to fall back to legacy 3-channel RRF mode.
+RETRIEVAL_AGENTIC_ENABLED=true
+
# Required for specific features: billing and analytics
BILLING_ENABLED=false
STRIPE_SECRET_KEY=
@@ -118,5 +122,5 @@ MAX_FILE_SIZE=104857600
# Legacy parser compatibility fields.
ALL_DF_COLS=content,path,type,length,keywords,summary,know_id,tokens,connectto,addtime,page_nums
-SPLIT_CHAR=-->
+SPLIT_CHAR=/
diff --git a/docs/agentic-rag-audit-20260513.md b/docs/agentic-rag-audit-20260513.md
new file mode 100644
index 000000000..fe09703ce
--- /dev/null
+++ b/docs/agentic-rag-audit-20260513.md
@@ -0,0 +1,353 @@
+# Agentic RAG 流程审计更新
+
+日期:2026-05-13
+
+范围:基于 `AGENTS.md`、`.agent/skills/agentic_debug_patterns/SKILL.md`、既有 trace 目录 `/Users/wuchengke/Desktop/agentic_e2e_traces/20260513_183340`,以及额外运行的典型用例结果。
+
+额外 trace 输出目录:
+
+- `/Users/wuchengke/Desktop/agentic_e2e_traces/20260513_190749_extra`
+- `/Users/wuchengke/Desktop/agentic_e2e_traces/20260513_210646_extra_batch`
+
+## 结论摘要
+
+这套 agentic RAG 的总体方向是成立的:外层 workflow 可以把复杂问题拆成多个 retrieve/synthesize step 并并发执行;内层 retrieval agent 能基于 KG 选文档、树导航、发现补充路径,并把 connected image/table 内嵌回证据树。尤其是全局图片/图表类问题,已有路径可以通过 `connect_to` 找回资源所属文本 section。
+
+但从 harness 工程师和真实用户视角看,目前仍有几个核心逻辑风险:
+
+1. 图表资源的“证据渲染归属”和“返回引用归属”不一致。渲染树里通常能用 `connect_to` 找到底层 owner section,但 `referenced_chunks`/citation 仍可能显示物理路径 `Root`,这会直接破坏用户理解图表出处。
+2. discovery merge 过于积极。即使 BFS 已经 `STOP`,后置 discovery 仍会把深层或邻近年份路径并入证据,导致 outline 类问题和窄 section 问题出现噪声。
+3. 预算和状态分类混淆。无证据问题会被包装成 `budget_stop`,掩盖真实原因;同时 bootstrap/revision 小预算耗尽时,整体 wallet 仍可能很充足,用户看到的失败原因不准确。
+4. 多文件/多 step 并发在外层有效,但单 step 内的多 doc 导航仍偏串行;更重要的是 `discovery_auto` 会把弱相关文档强行并入,容易在跨年份、跨主题问题上污染预算和证据。
+5. 回答 JSON 解析不够稳健。`attempt_answer` 返回含换行的 JSON-like 文本时会解析失败,单步用户可能看到 JSON wrapper。
+6. trace DB schema 与 ORM 不一致,导致 agentic trace 入库失败,削弱 harness 可观测性。
+
+## 本次补跑用例
+
+### T1_Outline_Extra
+
+Query:
+
+> 民生证券这份利率专题研报的整体结构是什么?包含哪些主要章节?
+
+结果:
+
+- Router:`workflow_single_step`
+- LLM calls:4
+- refs:13
+- elapsed:约 13s
+- action:`kg_document_select -> navigate -> discovery_select -> attempt_answer`
+
+观察:
+
+- `navigate` 在 root 层正确选择 `STOP`,这对“整体结构/主要章节”类问题是合理的。
+- 但后续 `discovery_select` 又选入了深层路径,如 `2 阶段性调整.../2.1.1 基本面企稳` 和 `5、2024:“资产荒”的极致演绎`。
+- 最终 evidence 约 7395 chars,answer 只有约 149 chars,说明证据明显过量。
+- `referenced_chunks` 里部分 image/table 的 section 显示为 `Root`,但 evidence tree 实际把它们挂在更具体 leaf section 下。
+
+判断:
+
+这是 discovery merge 策略的问题。对 root outline 查询,BFS 已经完成任务后,不应默认再并入深层 discovery 结果。否则用户问“目录结构”,结果引用中会混入某些深层图表,影响可信度。
+
+### T2_Deep_Section_Extra
+
+Query:
+
+> 2016年债市走牛的几个阶段中,机构行为是如何推动行情演绎的?有哪些相关图表说明?
+
+结果:
+
+- Router:`workflow_single_step`
+- LLM calls:4
+- refs:51
+- elapsed:约 29s
+- evidence:约 22960 chars
+- wallet context:`TIGHT`
+
+观察:
+
+- `navigate` 选择了 `NAVIGATE`,并带 `FIND_IMAGES`、`FIND_TABLES`,方向正确。
+- 但 root scope 的 asset tool 拉入了过多全局资源;同时 discovery 又选中父级 `1、2016:机构行为助推行情演绎`,导致 hydration 范围扩大。
+- evidence 里实际有图2、图3、图4、图5等图题和图片描述,但模型回答中仍说“未提供图表具体标题/编号”。
+- refs 达到 51,包含不少 2018、2019、2023、2024 等非目标年份资源。
+- 2016 相关图片在引用元数据中仍有 `section=Root` 的情况,虽然它们通过 `connect_to` 在 evidence 中被放回了具体 section。
+
+判断:
+
+这是窄 section + 图表问题的典型失败形态:导航方向正确,但工具作用域过宽、discovery 过宽、证据渲染噪声大,导致模型虽然拿到了图表,却没有稳定提取图题和归属。
+
+### T3_Compare_Extra
+
+Query:
+
+> 对比2024年和2025年AI安全市场规模,并结合证据给出变化原因。
+
+结果:
+
+- Router:`workflow_decomposed`
+- LLM calls:27
+- refs:1
+- elapsed:约 38s
+- plan:s1 查 2024 市场规模,s2 查 2025 市场规模,s3 查变化原因,s4 synthesize
+
+观察:
+
+- 外层 workflow 确认可以并发执行多个 retrieve step,三个 retrieve step 的 KG select 和 navigate 调用是交错发生的。
+- 三个 retrieve step 最终都进入 revision,然后以 `budget_stop` 结束。
+- 总体 wallet 仍有大量剩余,但 bootstrap/revision 局部预算先被耗尽,最终对用户呈现为“预算停止”。
+- 实际语义更接近:KB 中缺少可支撑 2024/2025 AI 安全市场规模对比的证据。
+- `discovery_auto` 因年份词匹配,把债券研报等弱相关文档带入候选,造成预算消耗和路径污染。
+
+判断:
+
+这是预算状态和无证据状态混淆。对用户来说,“知识库没有足够证据”和“预算不够”是两类完全不同的反馈;当前状态分类会误导用户,也会误导 harness 判断。
+
+## 核心问题清单
+
+### 1. 图表资源归属在 citation 层丢失
+
+涉及核心设计:
+
+- 每个独立图表/图片/表格都应通过 `connect_to` 找到底层 section 归属。
+- 物理资源 chunk 的 `path` 可能是 `images/...` 或 `tables/...`,甚至 DB section 可能挂在 `Root`。
+- 逻辑归属应以 text chunk 的 `metadata.connect_to[].target` 为准,`target` 指向 image/table chunk_id。
+
+当前表现:
+
+- evidence tree 渲染阶段多数情况下能用 owner path 把资源挂回 leaf section。
+- 但最终 `referenced_chunks`/citation 仍可能使用资源 chunk 自身的 `section_path`,因此显示 `Root`。
+
+影响:
+
+- 用户看到图表出处为 `Root`,无法判断它属于哪个章节。
+- 对图表比较、章节归因、报告复核非常不友好。
+- 这和“每个独立图表都有 `connect_to` 找到一个底层 section 归属”的设计要求冲突。
+
+建议:
+
+- citation/ref 组装时优先使用 `owner_section_path`,只有不存在时才回退到物理 `section_path`。
+- 返回结构中建议同时保留:
+ - `owner_section_path`:逻辑归属,用于用户展示和排序。
+ - `physical_section_path`:数据库/资源物理挂载位置,用于调试。
+ - `connect_to_source_chunk_id`:是哪一个 text chunk 证明了该资源归属。
+- 对 image/table 引用增加断言:若存在 `connect_to` owner,则展示 section 不应为 `Root`。
+
+### 2. discovery merge 对 STOP 和 outline 查询缺少门控
+
+当前表现:
+
+- T1 root outline 查询已经由 `navigate` 正确 `STOP`。
+- 后续 `discovery_select` 仍并入深层路径和资源。
+
+影响:
+
+- 简单结构问题证据膨胀。
+- 引用混入深层内容,用户会怀疑答案是不是依据了错误章节。
+- 预算被无谓消耗。
+
+建议:
+
+- 对 outline/structure/catalogue 类意图设置 discovery gate:
+ - 若 root STOP 且问题不要求“细节/图表/数据”,跳过 discovery hydration。
+ - 或只允许 discovery 返回 top-level structural sections,不 hydrate leaf content/assets。
+- `discovery_select` 的 prompt 应明确区分:
+ - structure query:只补结构遗漏。
+ - evidence query:可补 leaf 内容。
+ - asset query:可补 image/table。
+
+### 3. 图表工具作用域过宽
+
+当前表现:
+
+- T2 中 `NAVIGATE + FIND_IMAGES/FIND_TABLES` 方向正确,但 root 或父级 scope asset extraction 拉入大量非目标年份图表。
+- 后续 trimming 虽然会删一部分,但已经消耗 context 和模型注意力。
+
+影响:
+
+- 窄问题变成大范围 evidence dump。
+- 模型可能拿到正确图题却没有稳定使用,反而回答“没有具体标题”。
+- refs 过多,前端引用列表不可读。
+
+建议:
+
+- 当 action 为 `NAVIGATE` 且有 selected leaf paths 时,asset tools 默认只对 selected paths 或其 owner-linked assets 生效。
+- 只有 action 为 root `STOP` 且 query 明确要求“列出全部图表/图片/表格”时,才允许文档级全量 asset pull。
+- 对 `FIND_IMAGES/FIND_TABLES` 的输出增加 owner filter:资源必须能通过 `connect_to` 归属到当前 selected subtree。
+
+### 4. 预算分配与状态管理需要区分技术预算和语义失败
+
+当前表现:
+
+- T3 三个 retrieve step 最终都是 `budget_stop`。
+- 但总 wallet 明显还有剩余,真正失败原因是没有足够证据。
+- bootstrap/revision 局部预算耗尽被升级成 step 级 budget stop。
+
+影响:
+
+- 用户会以为“系统钱/上下文不够”,而不是“知识库无证据”。
+- harness 也难以判断是预算策略问题、检索召回问题还是 KB 数据缺失。
+
+建议:
+
+- step status 拆分:
+ - `not_found_no_evidence`
+ - `not_found_low_confidence`
+ - `budget_exhausted_bootstrap`
+ - `budget_exhausted_context`
+ - `budget_exhausted_total`
+- synthesize 时保留每个 retrieve step 的 semantic reason,不要只看 stop_reason 字符串。
+- revision loop 中,如果第一轮和第二轮文档选择高度重复且 verdict 是“KB 缺证据”,应提前停止,避免继续烧 bootstrap。
+- `BudgetWallet` 的 reclaimed budget 如果暂不重分配,snapshot 文案应避免暗示这些预算已重新可用。
+
+### 5. Planner 缺少 KB inventory,导致 plan reasoning 误报
+
+当前表现:
+
+- T4 中 planner reasoning 出现 “knowledge base is empty”。
+- 实际 trace 中 KB 并不为空。
+
+判断:
+
+`QueryPlanner.plan()` 支持 `kb_total_docs/kb_total_chunks` 参数,但 workflow 调用路径没有传入真实 inventory,默认值为 0。
+
+影响:
+
+- plan reasoning 不可信。
+- 对调试和用户解释都很危险。
+
+建议:
+
+- `_load_or_plan()` 前读取当前 namespace 的 KB inventory,并传给 planner。
+- workflow plan cache key 应包含 KB version 或文档集合 fingerprint,否则 KB 更新后可能复用旧 plan。
+
+### 6. `attempt_answer` JSON 解析不稳健
+
+当前表现:
+
+- T4 中 `attempt_answer` 返回 JSON-like 内容,但因 raw newline 或不合规转义导致 parse 失败。
+- parse 失败后逻辑把原始字符串当作 DONE answer。
+
+影响:
+
+- 单步用户可能看到 `{"status":"DONE","answer":...}` wrapper。
+- synth step 可能能“洗掉”问题,但 single-step 场景会暴露。
+
+建议:
+
+- 增加 tolerant JSON repair,只修复回答字段中的裸换行/控制字符。
+- 如果解析失败且文本明显以 JSON object 开头,不应直接 `DONE raw`,而应降级重试或抽取 `answer` 字段。
+
+### 7. trace DB schema 与 ORM 不一致
+
+当前表现:
+
+- `retrieval_runs.parent_run_id/workflow_step_id/workflow_plan` 在 ORM 中存在。
+- alembic migration 中未创建这些列。
+- trace create_run 报 `UndefinedColumnError`。
+
+影响:
+
+- DB trace 不可用。
+- harness 只能依赖 Markdown trace,无法做结构化聚合和回归分析。
+
+建议:
+
+- 补 migration。
+- 增加一个轻量 schema contract test,覆盖 `RetrievalTraceRecorder.create_run()`。
+
+### 8. 多文件并发导航的现状
+
+已确认:
+
+- 外层 workflow retrieve steps 使用 topological batch 并发执行。
+- T3 中多个 retrieve step 的 KG select/navigate 调用交错,说明并发有效。
+
+风险:
+
+- 单个 retrieve step 内 selected docs 仍偏串行。
+- `discovery_auto` 追加的弱相关文档没有足够 domain guard,T3 因年份匹配引入了债券研报。
+
+建议:
+
+- 对 `discovery_auto` 文档追加设置最低 domain relevance:
+ - 文档 title/summary/keywords 至少命中主题实体。
+ - 或要求 bottom chunk 与 query 的非时间词、非通用词有足够 overlap。
+- 单 step 多 doc 可考虑并发,但要先修好 doc relevance guard,否则并发只会更快地放大噪声。
+
+## 遗留与冗余代码观察
+
+### Legacy retrieval 路径仍和 agentic 路径混杂
+
+`run_retrieval_query()` 中同时存在 agentic workflow 和 legacy 3-channel RRF 排序/graph routing。若 agentic 已是主路径,建议把 legacy 路径隔离为明确 fallback,避免后续改动时误改两套逻辑。
+
+### 旧 graph/discovery helper 有疑似未使用分支
+
+`agentic/orchestrator.py` 附近存在 `_grep_discover_document_ids`、`_expand_by_edges` 等老式发现逻辑痕迹。若主流程已经切到 bottom discovery + KG select,应确认这些 helper 是否仍被调用;未调用则标记删除或迁移到测试辅助。
+
+### path dedup 当前依赖“一叶一文本 chunk”隐含前提
+
+当前 `_hydrate_paths_to_rows` 用 path-level `seen_paths` 是安全的,因为解析模型近似保持“一 leaf section 一个 text chunk”。但如果未来 parser 把一个 leaf section 拆成多个 text chunks,path-level dedup 会丢内容。
+
+建议:
+
+- 在注释和测试中写明该前提。
+- 或把 dedup key 改成 `(document_id, section_path, chunk_id)`,再在 render 层控制同 section 合并。
+
+## 建议优先级
+
+P0:
+
+1. 修复 image/table citation 归属:优先展示 `connect_to` owner section,不再把有 owner 的图表显示成 `Root`。
+2. 修复 trace DB migration,恢复 harness 结构化观测。
+3. 修复 `attempt_answer` JSON parse fallback,避免把 wrapper 暴露给用户。
+
+P1:
+
+1. 对 root STOP/outline query 增加 discovery gate。
+2. 收紧 asset tool 作用域:`NAVIGATE + selected paths` 时只找 selected subtree 的 connected assets。
+3. 拆分 `budget_stop` 与 `not_found` 状态,synthesize 阶段保留真实失败原因。
+
+P2:
+
+1. 给 planner 传真实 KB inventory,并把 KB fingerprint 纳入 plan cache key。
+2. 给 `discovery_auto` 增加 domain relevance guard。
+3. 清理 legacy helper 和未使用 discovery/graph 分支。
+4. 为 path dedup 增加未来多 chunk leaf 的保护测试。
+
+## 建议回归用例
+
+1. Outline STOP 不应 hydrate 深层 leaf:
+ - Query:`民生证券这份利率专题研报的整体结构是什么?包含哪些主要章节?`
+ - 断言:refs 中不应出现大量 image/table;深层 section 不应被 discovery 自动并入。
+
+2. 2016 section 图表归属:
+ - Query:`2016年债市走牛的几个阶段中,机构行为是如何推动行情演绎的?有哪些相关图表说明?`
+ - 断言:所有相关 image/table citation 的展示 section 应为 2016 底层 section,而不是 `Root`。
+
+3. 全量图表查询:
+ - Query:`列出AI安全大模型报告中所有的图表和图片,并简要描述每张图的内容。`
+ - 断言:允许 root/global asset pull,但每个独立图表仍应有 owner section;确实无底层 owner 的封面/前言图要显式标记为 document-level。
+
+4. KB 无证据查询:
+ - Query:`对比2024年和2025年AI安全市场规模,并结合证据给出变化原因。`
+ - 断言:返回状态应是 no evidence / insufficient evidence,而不是 generic `budget_stop`。
+
+5. Planner inventory:
+ - 构造非空 KB。
+ - 断言 planner reasoning 不得出现 “knowledge base is empty”。
+
+## 代码落点索引
+
+- Workflow orchestration:`packages/shared-python/shared/services/retrieval/workflow/orchestrator.py`
+- Planner:`packages/shared-python/shared/services/retrieval/workflow/planner.py`
+- Workflow budget wallet:`packages/shared-python/shared/services/retrieval/workflow/wallet.py`
+- Inner agent orchestrator:`packages/shared-python/shared/services/retrieval/agentic/orchestrator.py`
+- Inner agent tools:`packages/shared-python/shared/services/retrieval/agentic/tools.py`
+- Answer policy / JSON parse:`packages/shared-python/shared/services/retrieval/agentic/policy.py`
+- Navigation tree render:`packages/shared-python/shared/services/retrieval/agent_navigate.py`
+- Retrieval entry / hydration:`packages/shared-python/shared/services/retrieval/app_service.py`
+- Retrieval trace:`packages/shared-python/shared/services/retrieval/agentic/trace.py`
+- ORM retrieval tables:`packages/shared-python/shared/models/database/document.py`
+- Migration:`apps/api/alembic/versions/e5f6a7b8c9d0_add_agentic_retrieval_tables.py`
+- Debug harness:`apps/worker/debug_agentic_e2e.py`
+
diff --git a/docs/external-services.md b/docs/external-services.md
deleted file mode 100644
index 32ecb405f..000000000
--- a/docs/external-services.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# External Service Dependencies
-
-Knowhere API is the backend for . Public product
-documentation can also link to when deeper setup
-references are helpful.
-
-## Required For Local Startup
-
-Needs these dependencies to run the backend
-surface locally:
-
-- PostgreSQL for the application database
-- Redis for Celery and short-lived state
-- S3-compatible storage for uploads and result assets
-- one OpenAI-compatible LLM provider key for retrieval and parsing flows
-
-The repo-managed `deploy/local-dev` stack provides PostgreSQL, Redis, and
-LocalStack so the default `env.example` files can use a coherent local baseline.
-
-## Required Only For Specific Features
-
-- MinerU:
- required only if you want MinerU-backed document parsing flows
-- iLoveAPI:
- required only for conversion paths such as PPTX-to-PDF
-- QStash:
- required only if you want queued outbound webhook delivery
-- Stripe:
- required only if you enable billing and checkout flows
-- OAuth provider credentials:
- required only if you run dashboard-linked auth flows
-
-## Optional Observability And Analytics
-
-- Logfire for distributed tracing export
-
-These integrations are intentionally optional. Leaving them empty should not
-block a local backend bootstrap.
-
-## Minimum Viable Local Configuration
-
-The smallest supported local setup is:
-
-1. copy `apps/api/env.example` and `apps/worker/env.example`
-2. keep the default local PostgreSQL, Redis, and LocalStack values
-3. add one real LLM provider key such as `DS_KEY`
-4. run `deploy/local-dev/start-dev.sh`
-5. start the API and worker with `uv run`
-
-That path is the baseline public developer workflow. Additional providers should
-only be configured when you need the matching feature set.
diff --git a/docs/testing-guidance.md b/docs/testing-guidance.md
deleted file mode 100644
index 8d6df6b49..000000000
--- a/docs/testing-guidance.md
+++ /dev/null
@@ -1,82 +0,0 @@
-# Testing Guidance
-
-## Goal
-
-Keep the main test suites focused on the project surface rather than internal implementation details.
-
-The important things to verify are:
-
-- HTTP contract: request shape, response shape, status codes, and headers
-- Observable side effects: database writes, database updates, Redis state, and queued work
-- Runtime guarantees: auth behavior, rate limiting, conflict handling, and validation handling
-- Migration and persistence correctness: schema, constraints, and SQL-backed behavior
-
-The important things to avoid are:
-
-- Internal function boundary assertions
-- Repository or service call-sequence assertions
-- Mock-heavy tests that change the behavior under test in a material way
-
-## Test Taxonomy
-
-- `apps/api/tests/contract`
- API endpoint and surface specifications
-- `apps/api/tests/support`
- app bootstrap, test environment, database reset, Redis reset, and seed helpers
-- `apps/api/tests/migrations`
- Alembic and schema guarantees
-- `apps/worker/tests/contract`
- worker entrypoint, queued-work boundary, and durable side-effect specifications
-- `apps/api/tests` and `apps/worker/tests`
- narrow app-level component tests only when they protect pure logic or deterministic edge-case parsing
-
-Do not add a standalone shared-package test tree for behavior that belongs to the API or worker surface.
-
-## Contract Test Rules
-
-- A contract test must call the project surface, not an internal helper.
-- A contract test must assert an externally visible result or durable side effect.
-- API contract tests should use the real FastAPI lifespan.
-- Contract tests should use real PostgreSQL where SQL behavior depends on it.
-- API and worker contract tests use `fakeredis` for Redis behavior while keeping the same Redis service interfaces.
-- Mock only hard-to-control external boundaries such as third-party HTTP, storage providers, time, or filesystem edges.
-
-## Naming Rules
-
-- File names should follow the surface area being specified.
-- Contract test functions should prefer `test_should_`.
-- Test names should describe user-visible behavior, not implementation details.
-
-## Fixture Boundaries
-
-- `apps/api/tests/support` owns API bootstrap, environment setup, lifespan control, and seed data.
-- API contract tests should not override core dependencies such as auth, database access, or rate limiting for the behavior under test.
-- Worker contract tests own worker task entrypoints, queued-work boundaries, and durable task outcomes.
-
-## Coverage Expectations
-
-- API contract coverage should track every mounted router group in `apps/api/app/api/v1/api_v1.py`.
-- Worker contract coverage should track every registered Celery task in `apps/worker/app/core/tasks`.
-- When a stronger contract test replaces an old mock-heavy test, remove the weaker test or reduce it to a narrow component test.
-
-## Local Environment
-
-- API and worker contract tests require PostgreSQL server binaries and contrib extensions for `pytest-postgresql`.
-- API and worker contract tests do not require a running local PostgreSQL or Redis service.
-- API and worker contract tests use isolated `pytest-postgresql` processes and `fakeredis`.
-
-## Commands
-
-- `uv run python apps/api/scripts/ensure_test_environment.py --install`
-- `uv run python apps/api/scripts/ensure_test_environment.py`
-- `uv run pytest apps/api/tests/contract -q`
-- `uv run pytest apps/api/tests/migrations -q`
-- `uv run pytest apps/api/tests -q`
-- `uv run pytest apps/worker/tests/contract -q`
-- `uv run pytest apps/api/tests apps/worker/tests/contract -q`
-
-## Failure Triage
-
-- Re-run the smallest affected suite first.
-- Re-run from a clean `pytest-postgresql` process when the failure depends on database side effects.
-- Prefer fixing the harness or production behavior over adding more mocks.
diff --git a/packages/shared-python/shared/services/retrieval/app_service.py b/packages/shared-python/shared/services/retrieval/app_service.py
index dc14c055a..42166ea28 100644
--- a/packages/shared-python/shared/services/retrieval/app_service.py
+++ b/packages/shared-python/shared/services/retrieval/app_service.py
@@ -1100,7 +1100,7 @@ async def run_retrieval_query(
return await _to_public_response(response)
# ══ Route: agentic (unified workflow) vs legacy ══
- _agentic_enabled = os.environ.get('RETRIEVAL_AGENTIC_ENABLED', 'false') == 'true'
+ _agentic_enabled = os.environ.get('RETRIEVAL_AGENTIC_ENABLED', 'true') == 'true'
if _agentic_enabled:
# ── Unified agentic path via WorkflowOrchestrator ──
# Simple queries: planner returns a single-step plan (no decomposition).
diff --git a/packages/shared-python/shared/tests/test_workflow_orchestrator.py b/packages/shared-python/shared/tests/test_workflow_orchestrator.py
deleted file mode 100644
index 9189558a7..000000000
--- a/packages/shared-python/shared/tests/test_workflow_orchestrator.py
+++ /dev/null
@@ -1,67 +0,0 @@
-from __future__ import annotations
-
-import os
-
-os.environ.setdefault("DS_KEY", "test")
-os.environ.setdefault("DS_URL", "https://example.com")
-os.environ.setdefault("S3_BUCKET_NAME", "test")
-os.environ.setdefault("S3_ACCESS_KEY_ID", "test")
-os.environ.setdefault("S3_SECRET_ACCESS_KEY", "test")
-os.environ.setdefault("S3_TEMP_PATH", "/tmp")
-os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://root:root123@localhost:5432/Knowhere")
-os.environ.setdefault("TMP_PATH", "/tmp")
-
-from shared.services.retrieval.workflow.synthesizer import compose_final_answer
-from shared.services.retrieval.workflow.types import PlannedStep, QueryPlan, StepResult
-
-
-def test_compose_final_answer_concat_final_parts():
- plan = QueryPlan(
- original_query="q",
- steps=[
- PlannedStep(id="s1", sub_query="q1", output_role="final_part"),
- PlannedStep(id="s2", sub_query="q2", output_role="intermediate"),
- PlannedStep(id="s3", sub_query="q3", output_role="final_part"),
- ],
- final_strategy="concat_final_parts",
- )
- results = {
- "s1": StepResult("s1", "q1", "retrieve", [], "final_part", answer_text="A1"),
- "s2": StepResult("s2", "q2", "retrieve", [], "intermediate", answer_text="A2"),
- "s3": StepResult("s3", "q3", "retrieve", [], "final_part", answer_text="A3"),
- }
-
- assert compose_final_answer(plan, results) == "A1\n\nA3"
-
-
-def test_compose_final_answer_last_synthesize():
- plan = QueryPlan(
- original_query="q",
- steps=[
- PlannedStep(id="s1", sub_query="q1", output_role="consumed_by_synthesis"),
- PlannedStep(id="s2", sub_query="q2", step_kind="synthesize", depends_on=["s1"], output_role="final_part"),
- ],
- final_strategy="last_synthesize",
- )
- results = {
- "s1": StepResult("s1", "q1", "retrieve", [], "consumed_by_synthesis", answer_text="A1"),
- "s2": StepResult("s2", "q2", "synthesize", ["s1"], "final_part", answer_text="FINAL"),
- }
-
- assert compose_final_answer(plan, results) == "FINAL"
-
-
-def test_query_plan_topological_batches():
- plan = QueryPlan(
- original_query="q",
- steps=[
- PlannedStep(id="s1", sub_query="q1"),
- PlannedStep(id="s2", sub_query="q2"),
- PlannedStep(id="s3", sub_query="q3", step_kind="synthesize", depends_on=["s1", "s2"]),
- ],
- )
-
- batches = plan.topological_batches()
-
- assert [step.id for step in batches[0]] == ["s1", "s2"]
- assert [step.id for step in batches[1]] == ["s3"]
diff --git a/packages/shared-python/shared/tests/test_workflow_planner.py b/packages/shared-python/shared/tests/test_workflow_planner.py
deleted file mode 100644
index 0f1aaae6e..000000000
--- a/packages/shared-python/shared/tests/test_workflow_planner.py
+++ /dev/null
@@ -1,89 +0,0 @@
-from __future__ import annotations
-
-import os
-
-os.environ.setdefault("DS_KEY", "test")
-os.environ.setdefault("DS_URL", "https://example.com")
-os.environ.setdefault("S3_BUCKET_NAME", "test")
-os.environ.setdefault("S3_ACCESS_KEY_ID", "test")
-os.environ.setdefault("S3_SECRET_ACCESS_KEY", "test")
-os.environ.setdefault("S3_TEMP_PATH", "/tmp")
-os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://root:root123@localhost:5432/Knowhere")
-os.environ.setdefault("TMP_PATH", "/tmp")
-
-import pytest
-
-from shared.services.retrieval.agentic.budget import BudgetLedger
-from shared.services.retrieval.workflow.planner import QueryPlanner
-
-
-@pytest.mark.asyncio
-async def test_planner_parses_multi_step_plan():
- async def llm(_prompt):
- return """
- {
- "reasoning_summary": "compare two years then synthesize",
- "steps": [
- {"id": "s1", "sub_query": "2024 market size", "step_kind": "retrieve", "depends_on": [], "output_role": "consumed_by_synthesis"},
- {"id": "s2", "sub_query": "2025 market size", "step_kind": "retrieve", "depends_on": [], "output_role": "consumed_by_synthesis"},
- {"id": "s3", "sub_query": "compare s1 and s2", "step_kind": "synthesize", "depends_on": ["s1", "s2"], "output_role": "final_part"}
- ],
- "final_strategy": "last_synthesize"
- }
- """
-
- planner = QueryPlanner(
- llm_fn=llm,
- planner_ledger=BudgetLedger(total=4000, planning_ratio=0.0, bootstrap=4000),
- max_steps=5,
- total_budget=200000,
- per_step_budget=40000,
- )
- plan = await planner.plan(query="compare 2024 and 2025")
-
- assert len(plan.steps) == 3
- assert plan.steps[2].depends_on == ["s1", "s2"]
- assert plan.final_strategy == "last_synthesize"
-
-
-@pytest.mark.asyncio
-async def test_planner_falls_back_to_single_step_on_bad_json():
- async def llm(_prompt):
- return "not json"
-
- planner = QueryPlanner(
- llm_fn=llm,
- planner_ledger=None,
- max_steps=5,
- total_budget=200000,
- per_step_budget=40000,
- )
- plan = await planner.plan(query="hello")
-
- assert plan.planner_status == "fallback"
- assert len(plan.steps) == 1
- assert plan.steps[0].sub_query == "hello"
-
-
-@pytest.mark.asyncio
-async def test_planner_falls_back_when_too_many_steps():
- async def llm(_prompt):
- return {
- "steps": [
- {"id": f"s{i}", "sub_query": f"q{i}", "step_kind": "retrieve", "depends_on": [], "output_role": "final_part"}
- for i in range(1, 7)
- ],
- "final_strategy": "concat_final_parts",
- }.__repr__().replace("'", '"')
-
- planner = QueryPlanner(
- llm_fn=llm,
- planner_ledger=None,
- max_steps=3,
- total_budget=200000,
- per_step_budget=40000,
- )
- plan = await planner.plan(query="too broad")
-
- assert plan.planner_status == "fallback"
- assert len(plan.steps) == 1
diff --git a/packages/shared-python/shared/tests/test_workflow_wallet.py b/packages/shared-python/shared/tests/test_workflow_wallet.py
deleted file mode 100644
index 5d9f2d252..000000000
--- a/packages/shared-python/shared/tests/test_workflow_wallet.py
+++ /dev/null
@@ -1,80 +0,0 @@
-from __future__ import annotations
-
-import os
-
-os.environ.setdefault("DS_KEY", "test")
-os.environ.setdefault("DS_URL", "https://example.com")
-os.environ.setdefault("S3_BUCKET_NAME", "test")
-os.environ.setdefault("S3_ACCESS_KEY_ID", "test")
-os.environ.setdefault("S3_SECRET_ACCESS_KEY", "test")
-os.environ.setdefault("S3_TEMP_PATH", "/tmp")
-os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://root:root123@localhost:5432/Knowhere")
-os.environ.setdefault("TMP_PATH", "/tmp")
-
-import pytest
-
-from shared.services.retrieval.workflow.types import PlannedStep, QueryPlan
-from shared.services.retrieval.workflow.wallet import BudgetWallet
-
-
-@pytest.mark.asyncio
-async def test_wallet_allocates_ledgers_per_step():
- plan = QueryPlan(
- original_query="q",
- steps=[
- PlannedStep(id="s1", sub_query="q1", step_kind="retrieve"),
- PlannedStep(id="s2", sub_query="q2", step_kind="synthesize", depends_on=["s1"]),
- ],
- )
- wallet = BudgetWallet(
- total=50000,
- per_retrieve_step_default=40000,
- per_synthesize_step_default=6000,
- )
-
- ledgers = await wallet.allocate(plan)
-
- assert set(ledgers) == {"s1", "s2"}
- assert wallet.snapshot()["allocated"] == 46000
-
-
-@pytest.mark.asyncio
-async def test_wallet_scales_down_under_total_cap():
- plan = QueryPlan(
- original_query="q",
- steps=[
- PlannedStep(id="s1", sub_query="q1", step_kind="retrieve"),
- PlannedStep(id="s2", sub_query="q2", step_kind="retrieve"),
- PlannedStep(id="s3", sub_query="q3", step_kind="synthesize", depends_on=["s1", "s2"]),
- ],
- )
- wallet = BudgetWallet(
- total=30000,
- per_retrieve_step_default=40000,
- per_synthesize_step_default=6000,
- )
-
- await wallet.allocate(plan)
- snapshot = wallet.snapshot()
-
- assert snapshot["allocated"] <= 30000
- assert snapshot["allocations"]["s1"] >= 4000
- assert snapshot["allocations"]["s3"] >= 1500
-
-
-@pytest.mark.asyncio
-async def test_wallet_reclaim_records_unused_capacity():
- plan = QueryPlan(
- original_query="q",
- steps=[PlannedStep(id="s1", sub_query="q1", step_kind="retrieve")],
- )
- wallet = BudgetWallet(
- total=40000,
- per_retrieve_step_default=40000,
- per_synthesize_step_default=6000,
- )
- ledgers = await wallet.allocate(plan)
-
- await wallet.reclaim("s1", ledgers["s1"])
-
- assert wallet.snapshot()["reclaimed"]["s1"] == 40000