FR-170 Agent 架構重整:guidant-agent 模組化、相依斷層解除、守衛

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(17 張)、P1~P3 CM-3324(16 張)|取捨全文 docs/analysis/2026-10-29-agent-architecture-direction.md|架構圖 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 棒。