---
title: FR-170 Agent 架構重整：guidant-agent 模組化、相依斷層解除、守衛
lede: "evidence-agent 從「主系統的手腳」變成「同一個產品的第二個宿主」。49 支平鋪檔收成八個模組（模組在外、層在內）；先解 jedi-common 版本斷層讓 Agent 能裝 kernel 庫，搭宿主殼與守衛，再分三期搬遷並抽出主系統 ↔ Agent 的契約套件。P0～P3 共 33 張卡，2026-10-31 全部合回主線；對外行為不變。"
---

# FR-170 Agent 架構重整

> 狀態：**P0～P3 完成，已合回三 repo `feature/FR-139`（Agent `3ab1253`、jedi `fc67330e`、主系統 `18642c6cf`）**｜日期：2026-10-29～10-31｜母卡 P0 [CM-3232](https://app.notion.com/p/FR-170-Agent-P0-jedi-common-guidant_agent-platform-8-4-408346da4cd08123a29bc54125ea758c)（17 張）、P1～P3 [CM-3324](https://app.notion.com/p/FR-170-Agent-P1-identity-sync-guidant_agent-5-4-12-15-409346da4cd0810a92abc026f7e8507f)（16 張）｜取捨全文 [`docs/analysis/2026-10-29-agent-architecture-direction.md`](../../analysis/2026-10-29-agent-architecture-direction.md)｜架構圖 [`architecture.html`](architecture.html)
> 開發 branch：Agent 與 jedi-python-package 2026-10-29 皆在 `feature/FR-139`；runner 開工 `git branch --show-current` 再確認，不對就停下問，不切。

## 變更紀錄

| 日期 | 變更 | 對應 |
|---|---|---|
| 2026-10-29 | 初版：定位改判、七模組、四條裁決、命名 guidant-agent、P0 八張卡四批 | 母卡 CM-3232，子卡 CM-3233～CM-3240 |
| 2026-10-29 | 改成無人值守可跑完：套件單卡發 beta、結案卡轉正式版（D10）；系統身分改每請求／每執行緒裝（D8）；守衛排在宿主殼之後、背景元件與顯示名拆批；十張卡八批 | 新增 CM-3288、CM-3289 |
| 2026-10-30 | P0 全部合回主線；P1 五張卡四批（批 12～15）建卡不放行：shared 收攏、identity 搬遷＋接 jedi-password、sync 通道與 observer 掛點、sync 搬遷、console 退聚合 | CM-3319、CM-3320、CM-3321、CM-3322、CM-3323 |
| 2026-10-30 | P2 五張（CM-3325，批 16～19）與 P3 四張（CM-3331，批 20～22）建卡不放行；三期母卡獨立；P2 真機驗證留測試期 | CM-3325、CM-3331、CM-3326～3330、CM-3332～3335 |
| 2026-10-31 | P1～P3 與收口清理全部完成（P2、P3 併入 CM-3324 單一工作坊無人值守跑完）；總驗收 2014 綠、49 條路由與重構前相同、reviewer 結論只搬不改；合回三 repo 主線；主系統接 jedi-agent-protocol、Agent 改釘正式版；§8 改終局 | CM-3324 全部子卡 Done；Agent `3ab1253`／`c99da8c`、jedi `fc67330e`、主系統 `18642c6cf`／`198074868` |

## 1. 一句話

Agent 要有自己的架構與守衛：**模組在外、層在內**，五個業務模組彼此只走 port，共用的只有不碰 DB 的 kernel 庫與通訊契約。

## 2. 目標結構

```
guidant_agent/
  platform/    宿主殼：啟動、DI 根、SQLite、migration 串接、背景元件 start/stop、完整性閘、錯誤回報
  identity/    本機帳號、離線操作者、LDAP、主控台 session
  sync/        註冊、心跳、outbox、profile、漏洞庫同步
  scan/        任務執行、connectors、工具帳密
  netdiag/     網路診斷
  files/       證據檔：blob API、儲存設定、完整性、瀏覽器直傳
  console/     主控台 API 聚合層，不碰 DB
  shared/      事件碼、錯誤碼、時間、路徑、archive_safety（零相依）

  每個業務模組內部：domain/ app/ ports/ adapters/ api/ migrations.py
```

依賴方向：`console → 業務模組 → platform → shared`，箭頭只能往右。五個業務模組彼此不直接 import，要跨模組走 platform 註冊的 port。模組內 `domain` 零 import SQLAlchemy／Flask／requests；SQL 字串只准在 `adapters/`；模組層級不准有可變全域。

## 3. 決策定案

| # | 題目 | 定案 |
|---|---|---|
| D1 | 架構形狀 | 模組在外、層在內（jedi 插件的垂直切片形狀）；不照抄主系統四層在外 |
| D2 | DB 通用範圍 | Postgres＋SQLite；不做其他 DB。jedi-common 核心方言中立、`jsonb_extras` 明標 Postgres 專屬、`psycopg` 改 extras |
| D3 | `files` 獨立 | 獨立模組；scan→files 走 `EvidenceStore` port |
| D4 | 工具帳密 | 歸 `scan`；加密保管底層抽成 `platform.SecretStore` port |
| D5 | 四支檔歸屬 | browser_plane_server→files；task_sinks→scan（雲端 sink 走 sync port）；archive_safety→shared；console/files 查詢→files/app |
| D6 | 模組間 import | 五個業務模組彼此禁止直接 import；`platform`、`shared` 可被任何模組 import |
| D7 | 命名 | `guidant-agent`／`guidant_agent`。畫面產品名走設定值 `product.display_name`；systemd／路徑／指令／repo／套件名與品牌脫鉤不再動 |
| D8 | 單租戶身分 | 固定系統身分（`tenant_id` 常數 1）以 `before_request` 每請求裝、背景執行緒入口各自包裝飾器——jedi-common 的身分是 ContextVar，每請求每執行緒各一份，啟動時設一次請求裡拿不到；`upload_files` 以 local_db v7 補欄 |
| D9 | 搬遷方式 | 絞殺式：一卡一模組、`git mv`、舊路徑留 re-export 殼一到兩週、功能卡與重構卡碰同模組就不同批 |
| D10 | 套件發版節奏 | 開發卡只發 beta（PEP 440 `bN`），正式版集中在結案卡 3-1；決策者按下結案批即視為確認套件清單。無人值守時單卡不得停下等人確認發版 |

### 3.x 溝通模式：先維持心跳輪詢，第二期改 WebSocket 出站推送（決策者 2026-10-29）

主系統與 Agent 之間有兩條路：Agent 出站的心跳（每 60 秒，拉派工單與各種同步狀態），以及主系統連進 Agent 資料面 8443 的直推（mTLS＋綁定 agent 身分的短效 JWT，測試連線、取證據檔、設定／清除工具帳密都走它，當場回結果）。落地版主系統與 Agent 同在客戶內網，直推一定通；SaaS 版主系統在雲端，要客戶開放 8443 進來才通，這是直推的唯一前提。心跳仍把狀態全量重送（實測 123 一跳：請求 5 KB，其中 `profiles` 3.3 KB、`configured_tools` 1 KB，沒變也送；回應 3 KB）。決策者方向：**狀態類改帶版本號有變才送；SaaS 不想要求客戶開 8443 時，才需要 Agent 出站建 WebSocket 常駐連線讓主系統往下推**。本案 P0 不含；開卡時沿用 BE 既有 SocketIO 8002。

## 4. 現況接入點盤點

| 元件 | 現況 | 本案動作 | 卡 |
|---|---|---|---|
| jedi-common pin | Agent 0.0.31，主系統 1.3.6；`sql_exception.py:3-4` 頂層 import psycopg | lazy 化、psycopg 進 `[postgres]` extras、發 beta 1.3.7b1；主系統自己列了 psycopg，不動 | 1-1、3-1 |
| jedi-file-upload pin | Agent 0.0.22，主系統 1.5.0；DTO 搬到 `domain/ports.py`；`upload_files.tenant_id NOT NULL` | `config/config.py:5-7` 改 import；每請求／每執行緒裝單租戶身分；`core/local_db/migrations.py` 加 v7 | 1-2、1-3 |
| 密碼政策 | jedi-iam `common/utils/common_util.py:32 validate_password_policy`；Agent `core/local_credentials.py:68` 另寫一份＋argon2 | 抽 `jedi-password`（零資料表零 Flask），jedi-iam 改 re-export；Agent 改接在 P1 | 1-4、3-1 |
| 守衛測試 | Agent 無；主系統 `test/test_module_boundaries.py` 為範本 | 六支守衛（import 方向、全域、SQL 位置、禁新檔進舊 core、行數、背景元件） | 2-1 |
| 宿主殼 | `core/app_factory.py` 一支做完所有接線；`di_containers/containers.py` 39 行 | `guidant_agent/platform/` 新開：bootstrap、根容器、模組 register 掛點、migration 串接 | 2-2 |
| 背景執行緒 | `core/enroll.py:355-370` 心跳迴圈內逐一呼叫 observers；9 支各自開 thread | `BackgroundComponent` 協定（start／stop／health），platform 統一起停 | 2-3 |
| 顯示名 | 寫死「evidence-agent」三處（`main_app.py:1`、`core/app_factory.py:88`、`core/local_state_cli.py:150`） | `product.display_name` 設定值，預設「Guidant AI Agent」 | 2-4 |

## 5. 拆分（四期共 33 張卡；P0 十張卡八批）

批＝可同時派的一組；一批一到兩張是刻意的，同批的卡不能改同一個檔。登記型檔只讓一張卡碰：Agent `pyproject.toml`（1-3、3-2 不同批）、`core/local_db/migrations.py`（1-2）、jedi-common `pyproject.toml`（1-1、3-1 不同批）、jedi-iam `pyproject.toml`（1-4、3-1 不同批）。`guidant_agent/platform/bootstrap.py` 由 2-2 建、2-3 與 2-4 各改一次，故三張各自一批。

| 批 | 卡 | repo | 做什麼 | 依賴 | reuse／extend／new |
|---|---|---|---|---|---|
| 1 | 1-1（CM-3233） | jedi-packages | jedi-common psycopg 改選配、sql_exception lazy 化、SQLite dialect 測試、發 beta 1.3.7b1 | — | extend 三處既有檔；new 一支測試 |
| 1 | 1-2（CM-3234） | agent | 系統身分 `before_request`＋背景執行緒裝飾器；local_db v7（`upload_files` 補 `tenant_id`／`owner_user_id`） | — | new `core/agent_identity.py`；extend migrations 尾巴加 v7 |
| 2 | 1-3（CM-3235） | agent | 升 `jedi-common==1.3.7b1`、`jedi-file-upload==1.5.0`；config.py 三行 import；全量測試；188 agent.db 複本升級排練 | 1-1、1-2 | extend pin 與 import |
| 2 | 1-4（CM-3236） | jedi-packages | 抽 `jedi-password` 0.1.0b1；jedi-iam 1.11.0b1 改 re-export；兩支發 beta | — | new 套件；extend jedi-iam 一支檔 |
| 3 | 2-2（CM-3238） | agent | `guidant_agent/platform/`：bootstrap、根容器、`register` 掛點、migration 串接；`core/local_db` 搬入；`core/app_factory.py` 退成一行 | 1-3 | new platform 四檔；extend 兩支退成 re-export |
| 4 | 2-1（CM-3237） | agent | `docs/ARCHITECTURE.md` 範圍憲章＋六支守衛測試＋超標白名單（凍結清單以 2-2 搬完後 HEAD 盤） | 2-2 | new 八檔 |
| 5 | 2-3（CM-3239） | agent | `BackgroundComponent` 協定；心跳、outbox、profile_sync、vulndb_sync 改元件由 platform 起停；三種時序全用測試模擬 | 2-1 | new background.py；extend 四支既有檔 |
| 6 | 2-4（CM-3240） | agent | `product.display_name` 設定值；三處寫死改讀設定；Agent CLAUDE.md 改寫 | 2-3 | new product.py；extend 五支既有檔 |
| 7 | 3-1（CM-3288） | jedi-packages | jedi-common 1.3.7、jedi-password 0.1.0、jedi-iam 1.11.0 由 beta 轉正式版發 Nexus | 2-4 | 只改版號 |
| 8 | 3-2（CM-3289） | agent | Agent pin 改釘正式版 `jedi-common==1.3.7`；全量測試 | 3-1 | 只改 pin |

**前置已成立**：FR-166.1（CM-3199）、FR-169（CM-3223）2026-10-29 皆 Done。本案期間 Agent repo 不開功能卡；FR-167 的 Agent 側兩張（P4-5、P4-3）會改 `core/enroll.py`，不與本案批 5 同時派。主系統改釘 jedi-common 1.3.7／jedi-iam 1.11.0 不在本案，P0 驗收後另開主 repo 卡。

**無人值守原則**：單卡不得停下等人——不 sudo、不拔網路、不開瀏覽器、不等人確認發版；遇到要人決定的事（版號撞號、前置產物不在、測試紅找不到原因）commit 已完成部分、回寫問題→建議→理由、狀態留 In progress。真機情境（拔網路、真 188 Agent 升級）留測試期由首腦操作。

### 5.1 P1（搬 identity、sync）——五張卡四批，批 12 起，建卡不放行（決策者 2026-10-30）

P1 母卡 **CM-3324**（獨立於 P0 母卡 CM-3232：P0 工作坊結案要求子卡全 Done，P1 掛同一張會擋結案）。要跑 P1 時新開工作坊選 CM-3324。

前提：發版與線上更新用 P0 版；P0 若要修先合主線再放行批 12。搬前先剪三條線：`audit()` 與 `LOCAL_ADMIN_SUB` 搬 `shared`（1-1）；對主系統的 HTTP 通道從 `task_executor` 抽成 sync 的 adapter、心跳 observer 改走 registry 掛點（1-3，P1 最危險的一張）。identity 這次順便改接 `jedi-password`，舊雜湊必須仍可登入、驗過即 rehash。identity↔sync 四條互依邊在 1-4 用 port 與 `platform/health.py` 解。殼只在 1-5 刪零命中的，其餘 P2。

| 批 | 卡 | repo | 做什麼 | 依賴 | model／effort |
|---|---|---|---|---|---|
| 12 | P1-1（CM-3319） | agent | shared 收攏 | — | sonnet／medium |
| 12 | P1-2（CM-3320） | agent | identity 模組 | — | opus／high |
| 13 | P1-3（CM-3321） | agent | sync 通道與 observer 掛點 | P1-1、P1-2 | opus／high |
| 14 | P1-4（CM-3322） | agent | sync 模組搬遷 | P1-3 | opus／high |
| 15 | P1-5（CM-3323） | agent | console 退聚合＋P1 收尾 | P1-4 | sonnet／medium |

拆卡底表 `handoff/cards_p1.py`；三條線的分析依據（AST 掃出的跨模組邊、各邊實際用到的符號）在 `handoff/FR-170-LOG.md` 第 4 棒。

### 5.2 P2（搬 scan、netdiag、files；console 退聚合；刪舊 core/）——批 16～19（含首腦加開的 P2-6 CM-3345）

AST 掃出的事實：scan → sync 13 條邊全是 `allowed_targets`／`tool_schemas`／`outbox`／`heartbeat_status` 四個 → 抽 `TargetAuthorizer`／`ToolSchemaCatalog`／`ResultOutbox` 三個 port 由 sync 註冊（P2-1）；scan → console 7 條全是重啟旗標（歸 platform）與表單驗證（歸 scan）；console 的 84 條出邊是退聚合不是剪線（只准 import 對方 `app/` 與 `ports/`）。agent.db 建表步驟從 legacy 一張清單拆回各模組 `migrations.py` 申報，版本號不變、攤平順序用測試鎖死。Fernet 加密保管下沉 `platform/secret_store.py`（D4 落實）。`sonarqube.py`、`inspec.py` 超標只搬不拆。無人值守：真機出包與主系統派工留測試期首腦做。

| 批 | 卡 | repo | 做什麼 | 依賴 | model／effort |
|---|---|---|---|---|---|
| 16 | P2-1（CM-3326） | agent | scan 的三個 port 與歸位 | — | opus／high |
| 17 | P2-2（CM-3327） | agent | scan 模組搬遷 | P2-1 | opus／high |
| 17 | P2-3（CM-3328） | agent | netdiag ＋ files 搬遷 | P2-1 | opus／medium |
| 18 | P2-4（CM-3329） | agent | console 退聚合 | P2-2、P2-3 | opus／high |
| 19 | P2-5（CM-3330） | agent | 刪舊 core/ | P2-4 | sonnet／medium |

### 5.3 P3（抽 `jedi-agent-protocol`）——批 20～22；收口清理 P3-5 CM-3357 批 23

三邊鏡像現況：主系統 `common/enum/event_code.py` 52 個 AGENT_*、`jedi-remote-agent/common/code.py` 44 個、Agent `event_codes.py` 40 個；心跳請求 7 鍵、回應 24 鍵散在各 observer 的 `data.get`。P3 收成零相依 kernel 套件（事件碼、錯誤碼元組、`HeartbeatRequest`／`HeartbeatResponse` TypedDict、派工單鍵），三邊 import；數值與鍵名一字不變，三邊既有不一致只列不修。套件卡只發 beta，結案卡轉正式；主系統改釘與 `event_code.py` 改 import 是 P3 後另開的主 repo 卡。

| 批 | 卡 | repo | 做什麼 | 依賴 | model／effort |
|---|---|---|---|---|---|
| 20 | P3-1（CM-3332） | jedi-packages | 抽 jedi-agent-protocol 0.1.0b1 | — | opus／medium |
| 21 | P3-2（CM-3333） | agent | Agent 接 jedi-agent-protocol | P3-1 | sonnet／medium |
| 21 | P3-3（CM-3334） | jedi-packages | jedi-remote-agent 1.11.0b1 接 jedi-agent-protocol | P3-1 | sonnet／medium |
| 22 | P3-4（CM-3335） | jedi-packages | 結案（套件側） | P3-2、P3-3 | sonnet／low |

拆卡底表 `handoff/cards_p2.py`、`handoff/cards_p3.py`。三期母卡各自獨立（CM-3324 → CM-3325 → CM-3331）：工作坊結案要求子卡全 Done，掛同一張會擋前一期結案。

## 6. 端到端驗收

1. Agent venv `pip show jedi-common` 為 1.3.7（3-2 後不帶 b），`pip show psycopg` 不存在（Agent 不裝 Postgres 驅動）；Nexus 上三支正式版存在。
2. 188 那台 Agent 的 agent.db 複本（WAL 模式，用 `.backup` 抓），跑 `local_db.upgrade` 到 v7 後 `upload_files` 每列 `tenant_id` 非 NULL；請求內與背景執行緒內上傳各一檔成功。
3. `pytest test/` 全綠含六支守衛；故意在 `guidant_agent/identity/domain/` 加一行 `import sqlalchemy` 守衛紅。
4. `python main_app.py` 起得來，log 第一行顯示「Guidant AI Agent app ready」；改 `product.display_name` 後重啟字串跟著變。
5. `core/app_factory.py` 行數 ≤ 30；`guidant_agent/platform/` 存在且 `di_containers/containers.py` 內容搬入根容器。
6. 心跳、outbox、profile_sync、vulndb_sync 四支在 `platform` 的元件清單裡，`stop()` 後 thread 數歸零；SIGTERM 子行程 5 秒內結束（測試斷言）。測試期首腦在 DEV Agent 真機拔網路一次。

## 7. 風險與陷阱

- **tenant_id 回填**：v7 對既有列給常數預設，已部署機的 agent.db 都要走一次；排練用 188 真實複本，不用空庫。
- **系統身分只設一次是假的**：ContextVar 每請求每執行緒各一份；`python -c` 單執行緒測會過、真 app 上傳照樣撞 NOT NULL。1-2 驗收要求請求內與新 thread 內都斷言。
- **beta 版號**：PEP 440 `1.3.7b1` 排在 `1.3.7` 之前，pin 要寫完整版號不用範圍；jedi-iam 相依 jedi-password 的下限要含 `b1` 才解得到。
- **psycopg 選配動到主系統同一支套件**：主系統自己列了 psycopg 兩行，純版對它零影響；套件清單在結案批由決策者按下即確認。
- **背景元件改協定會動到心跳迴圈**：2-3 是本案最容易弄壞線上行為的一張，驗收要拔網路、斷主系統、重啟三種情境各跑一次。
- **jedi-common 1.3.x 若有未包在 dialect 判斷的 Postgres 寫法**：探路 1,655 測試全綠已排除大部分；1-3 若撞到，回寫卡在哪、不自己繞。

## 8. 這份文件的定位

本文件描述 P0～P3 的定案與拆法，四期已全部完成。終局結構以 Agent repo `docs/ARCHITECTURE.md` 為準（守衛測試即憲章的機器可讀版）；取捨理由、被排除的方向、反悔條件在 `docs/analysis/2026-10-29-agent-architecture-direction.md`，無人值守派工與合回主線的教訓在 `docs/analysis/2026-10-31-fr170-unattended-workshop-and-merge-lessons.md`。下一期 P4（心跳瘦身、指令類改直推 8443）決策者裁暫緩，分析在 `handoff/FR-170-LOG.md` 第 4 棒。
