Skip to Content
核心概念Agent 工具箱

Agent 工具箱:AI 查得到什麼、寫得動什麼

AI 助理是透過自己的一組工具存取自訂表格,不是走 REST。這組工具長期以來比 REST 窄,而那道落差呈現出來的是「錯的答案」而不是「錯誤訊息」:沒有相對日期運算子,模型只能自己算「最近 30 天」,偏偏它連今天是哪一天都不知道;沒有計算欄位過濾,它只能翻頁把 rollup 的值放在腦裡比,一過第一頁就是錯的;沒有 upsert,它只能先找再寫,只要找漏了就多一列重複資料。

現在讀取端在相對日期、計算欄位、全域文字搜尋這三件事上與 REST 齊平,寫入端則多了 upsert、樂觀鎖與原子批次。下面的上限與拒絕跟能力一樣重要:多數是直接回錯誤的硬拒絕,不是回一份比較小的答案的降級。

誰在跑哪個工具

面對使用者的主 agent 擁有下列工具面:

  • custom_tables_get_instructionscustom_tables_cheat_sheet 是探索與操作指引的入口。只要至少有一個可見的 link 欄位,還會出現 custom_tables_lookup_link_schema
  • 至少有一張表可讀時,會取得 custom_tables_analyze 與輕量 id 解析器 custom_tables_find_records;存在可讀 attachment 欄位時再加上 custom_tables_view_attachment
  • 存在可寫 attachment 欄位時會有 custom_tables_list_attachable_blobs。Insert 家族需要 insert 權限,update/delete 需要 edit 權限。內部 principal 只要有 insert edit 任一權限,也會取得 bulk-record 家族;其中每一種 action 仍會獨立檢查所需權限。出現哪一組名稱由聊天室設定 requires_confirmation_for_ct_action 決定:預設值 false 暴露直接的 custom_tables_insert_recordupdate_recorddelete_recordbulk_record_actions,設為 true 則換成 custom_tables_prepare_* 後接 custom_tables_execute_staged_*。若資料表作者已選擇改走 command 治理寫入,這些工具一個都不會真的寫入——見受 command 治理的資料表拒絕原始 agent 寫入。確認協定另見兩輪確認協定與-direct-通道
  • 已啟用的自訂資料表 command 會動態生成為另外的工具。query 模式 command 唯讀;write 模式 command 使用下述同一套確認或 direct-policy roster。

上述可用工具清單描述的是一般的持久化文字 pipeline。Direct OpenAI/ElevenLabs realtime voice session 會刻意移除下述異動工具。

只有把「問題」委派給 custom_tables_analyze 才會抵達的分析子 agent,擁有 custom_tables_profile_overviewcustom_tables_profile_columncustom_tables_query_recordscustom_tables_aggregate_recordscustom_tables_correlation 與自己的 custom_tables_cheat_sheetcustom_tables_resolve_principal 只會加給已認證的內部 principal——外部 client 永遠沒有,解不出 user id 的內部通道 session 也沒有。只有 scope 內至少還有兩張可讀表時才會加入 custom_tables_join_aggregate;只有可存取的 link 連起 scope 內的資料表時才加入 custom_tables_traverse_links。主 agent 無法直接呼叫這些底層工具。

custom_tables_get_instructionscustom_tables_profile_overview 都是這套文件初次發布時就已存在的長期介面;舊版工具清單只是漏寫。請把舊頁面的缺項視為文件既有落差,不要推論後端當時沒有這兩個工具。

custom_tables_cheat_sheet 現在兩邊都註冊了。它以前只掛在主 agent 上,所以那份講運算子、json path 與查法配方的說明,從來沒到真正在挑運算子和 json path 的那一方手上。

分析子 agent 的 prompt 現在開頭帶一段 CURRENT DATE (UTC): <YYYY-MM-DD (Weekday)>,在請求當下由 datetime.now(timezone.utc) 算出。在這之前這個子 agent 根本沒有時鐘,「這週」「上個月」「Q3」只能猜或答不出來。要注意時區:錨點是 UTC 日期,不是使用者的日期。UTC+8 的使用者在早上 07:00 問「今天」,解出來的是前一個日曆日。

協定所承載的運作模型

工具介面只是助理所知的一半,另一半是房間啟用 Custom Tables job 時、整段對話都會載入的協定 prompt:custom_tablescustom_tables_department 兩個 job 共用同一份檔案——它們的差別只在「哪些表在視野內」,協定本身從不指名 scope——而 custom_table_commands 有自己的一份。它永遠不是 tool result,也永遠不會送到客戶眼前。它是模型在呼叫任何工具之前就已經知道的事,所以那些單一 tool result 教不會的行為,寫在這裡而不是寫在工具說明裡。

其中四個部分,可以解釋你光看 tool 輸出無法還原的 agent 行為:

  • Voice(語氣)。 協定管的是助理可以陳述什麼,聊天室的 persona、語言與語氣管的是怎麼說;在每一條 Custom Tables 路徑上都與其他對話輪一樣。LINE 上的育兒助理不會因為讀了一張表就切換成客服口吻。固定輸出契約——只回一個 JSON object、不附任何其他內容——只有在使用者明確要求機器可讀格式時才適用;否則由房間自己的語氣決定。
  • How A Table Works(一張表是怎麼運作的)。 用平台通用的方式描述工具所指涉的東西,讓模型不必再從 schema 頁自行腦補:scope 與可見性(你不能使用的表、列、欄位根本不會出現——工具箱說的是「找不到」,從不說「沒有權限」)、結構(型別化欄位加上記錄;不透明的 record_id、每次變更就前進的 version,以及以顯示名稱為鍵的 data)、每種欄位型別的值形狀,以及寫入會被當成一個整體驗證後原子套用——要嘛完整落地,要嘛什麼都不變。其中最吃重的一句是:身分只存在於 record_iddata 裡面沒有任何東西可以識別一列資料。
  • How A Command Works(一個 command 是怎麼運作的)。 對 command 通道做同樣的事(backend PR #1192):一個 command 是一項橫跨一或多張表、預先撰寫好的商業操作,只有在它碰到的每一張表對當事人都可見可讀時才會綁定;它在伺服器端以當事人的權限跑成一次全有全無的執行;succeeded 是寫入真的發生過的唯一證據;query 模式的 command 唯讀並以 next_cursor 分頁;而綁定工具的政策——直接執行、先 prepare 再於同意後另行 staged execute、或簽核暫停——是綁定的性質,從來不是一個參數。斷言失敗、定義拒絕的輸入、尺寸上限、列數守門與政策拒絕,全都代表什麼都沒寫入,也不該用相同輸入重試;只有逾時或服務不可用,才值得原樣重試一次。
  • Permissions(權限)只講後果。 協定只說一則拒絕對助理代表什麼,刻意完全不說權限是怎麼設定的(backend PR #1191):結果送到手上時已經依當事人收窄過,所以較短或空的結果可能代表「你看不到」而不是「不存在」,同一張表對不同的人合理地顯示不同的列與不同的計數。助理不從「這個人看起來是誰」推論權限,不解釋也不臆測某個授權是怎麼設的,遇到拒絕就照實轉述,不原樣重試、也不繞道。

以上沒有一項是逐表設定,也沒有一項會出現在 tool result 裡。它改變的是模型拿到同樣的 tool 輸出之後會做什麼。

由於兩個資料表 job 共用同一份檔案,同時啟用兩種 scope 的房間過去每一輪都會拿到那份檔案兩次——43,428 個字元,而單一 scope 只有 21,616,每個段落都重複一次——而且光是出現兩段 prompt 文字,就會讓兩者一起被降級塞進「Assigned Capabilities」外框。現在 prompt builder 會跳過已經加入過的相同文字(backend PR #1193),因此同時啟用兩種 scope 的房間,拿到的正是單一 scope 那份、沒有被降級的單一段落。Commands 協定是另一份檔案,仍然照常出現在旁邊一次。協定文字本身沒有任何改動;如果你拿擷取下來的 prompt 與舊版比對,看到的差異就是這一點。

custom_tables_get_instructions 是必須先呼叫的入口。它回的是即時頁面,不是把所有 schema 無上限地塞進一份快照:預設 10 張、最多 20 張。name_contains(1–120 字元)與 table_ids(最多 20 個精確 id)只能擇一;offset 為 0–100,000,limit 為 1–20。每頁都有機器可讀的 matching_table_countshown_table_countoffsetnext_offset。請求的 id 若不存在或已無權限,會刻意合併計數,因此回應不能被拿來當存在性 oracle。整份渲染結果上限 32,000 字元;遇到裁切時請取下一頁或用 table_ids 聚焦,絕不能猜被省略的欄位。

若需要 link 的字面契約,請用一個精確 table id 與一個精確、可見的顯示名稱呼叫 custom_tables_lookup_link_schema。成功 body 恰好是:

{"cardinality":"one","target_table_id":"<opaque-table-id>"}

cardinality 只會是 "one""many"。被隱藏與不存在的欄位都回 Column '<name>' not found.;可見但不是 link 的欄位回 Column '<name>' is not a link column.。這個工具每次都重新授權,而且只有主 agent 的 roster 中存在可見 link 欄位時才會出現,所以不要快取它是否可用。

custom_tables_analyze 接受 1–4,000 字元的問題,以及選填、最多 20 個精確 table_ids 的硬性 scope;id 必須從即時 instructions roster 複製。若目前可讀資料表超過 20 張而 caller 沒傳 table_ids,它會回 status: "partial" 並要求先選 id;付費的分析模型不會被呼叫。選定的 scope 會重新套在每個委派工具上,因此分析 agent 不能沿著間接 link 逃出 scope:host targeting、名稱解析、join、link traversal 與 link 展開全都受它約束,因為這些都會回傳另一張表的整列資料。計算欄位的儲存格值是刻意留下的唯一例外——見計算儲存格由 principal 授權決定,不由分析 scope 決定

分析工具永遠回有界的 JSON 信封:

{ "status": "ok|partial|denied|failed", "reason_code": null, "answer": "...", "evidence": [], "warnings": [], "invocation_sha256": null }

answer 最多保留 12,000 個原始字元;發生截斷時伺服器會再附加一個 ,因此回傳欄位連同此標記最多可有 12,001 個字元。evidence 最多 20 份憑據,每份憑據的 record_ids 最多 20 個,warnings 最多 10 條。憑據帶工具/資料表識別、計數與摘要,不帶 cell 值。每個委派工具的結果進入分析情境前會被限制在 64,000 字元;資料表 context 上限 32,000 字元,單一 schema hint 上限 4,000 字元。reason_code key 一律會序列化,但只有允許清單內的 provider 呼叫前拒絕(授權/scope、外部 provider 政策或預算)才是非 null;其他結果都帶 reason_code: null。Provider 與內部例外一律收斂成 status: "failed",不洩漏原始錯誤 body。

授權在每次呼叫時都是即時的

工具是否顯示、以及較早取得的 instructions 頁面都只供探索。每次會讀取資料或指向特定資料表的工具呼叫,都會開新 session,在解析目標之前重新載入即時 account、tenant、聊天室成員資格、可存取資料表 roster、row/column ACL 與 SCP binding。custom_tables_cheat_sheet 只是靜態指引:呼叫它不會開資料庫 session,也不會自行刷新授權。custom_tables_analyze 在組分析呼叫前先重新整理一次;它委派的每個資料型底層工具又各自開新 session、重新授權。動態 command 工具同樣會在伺服器重新檢查即時 principal 與 command。對話中途撤權後,受保護的操作會 fail closed;已渲染過的 schema 或已綁定的工具都不會延續權限,而 cheat sheet 的回應也不代表仍有存取權。

滾動時間窗:within_lastwithin_nextolder_than

這三個運算子在 custom_tables_query_recordscustom_tables_aggregate_records 中,可用於 datedatetime 欄位,以及 created_at / updated_at 這兩個中繼欄位。邊界在查詢當下由伺服器時鐘解出,走的是 REST/儲存過濾器那條 lane 自己的編譯 helper,所以兩個介面不會各算各的。

值是正整數天數,或一個 dict {"amount": N, "unit": "days"}unit 只能是 "days""hours",可另外帶 tz_offset_minutes

{ "conditions": [ { "column": "Due Date", "operator": "within_next", "value": 7 }, { "column": "created_at", "operator": "within_last", "value": { "amount": 48, "unit": "hours" } }, { "column": "Last Contact", "operator": "older_than", "value": 30 } ] }

型別閘門有兩個,訊息不一樣:

  • query_records / aggregate_records,非日期的資料欄位被拒絕為 Operator '<op>' works on date/datetime columns (and created_at/updated_at), but '<col>' is type '<t>'.
  • join_aggregate 的單邊過濾 lane 只接受這三個運算子用在 date/datetime 資料欄位上——它沒有中繼時間戳那一支,所以 created_at 的時間窗在那裡不存在,而它的拒絕訊息是 Operator '<op>' works on date/datetime columns, but '<col>' is type '<t>'.

格式錯誤的時間窗在任何 SQL 被組出來之前就被擋下,有五種不同訊息:

錯誤
null 或布林Operator '<op>' needs a window value: an integer number of days, or {"amount": N, "unit": "days"|"hours"}.
小於等於 0 的整數Operator '<op>' needs a positive window, got <v>.
dict 但沒有整數 amountOperator '<op>' window dict needs an integer 'amount', got <v>.
dict 但 unit 不合法,或 amount <= 0Operator '<op>' window must be a positive amount of 'days' or 'hours', got <v>.
其他任何型別,例如字串 "soon"Operator '<op>' value must be an integer number of days or {"amount": N, "unit": "days"|"hours"}, got <v>.

計算欄位可以過濾也可以排序——上限 50,000 筆存活資料

rollup、lookup、formula 的值以前在 agent 的輸出裡讀得到,也就只能讀得到。cheat sheet 直接這樣寫,分析 agent 也被要求自己翻頁比對。所以「終身價值超過 10000 的客戶」不是掃全表,就是答錯。

query_recordscomputed_filters

custom_tables_query_records 接受 computed_filters: [{column, op, value}]最多 3 個述語,與 filtersany_ofq 以 AND 相接。超過上限:computed_filters accepts at most 3 predicates.

{ "table_id": "…", "computed_filters": [ { "column": "Total Orders", "op": "gt", "value": 10000 }, { "column": "Account Manager", "op": "is_not_null" } ] }

運算子是 eqneqgtgteltlteinis_nullis_not_null。這個工具的 op 是封閉的 literal,所以 contains 在工具本體執行之前就會被參數驗證擋掉。REST 通道的 op 是自由字串,同樣的輸入在那裡有自己的訊息,不是通用的「無效運算子」:computed filter on '<c>': 'contains' is not supported on computed columns (rollup values are numeric or date strings)

這些述語編譯走的是 REST 用的同一套相關子查詢下推,所以列數守門、連結目標的 ACL 底線、以及 null 語意都一起跟過來。一個 null 的計算格——不論是因為沒有資料,還是因為 caller 對被把關的目標表沒有讀取權——只會被 is_null 命中,其他運算子都不命中。引用到被欄位 ACL 隱藏欄位的 formula 會編譯成 NULL 而不是真值,所以過濾不能被拿來當成探測管理者專屬輸入的 oracle。

三種不同的拒絕:被隱藏的欄位由統一的欄位 ACL 檢查擋下,回 Column '<name>' not found.——這跟任何不存在的欄位得到的字串一模一樣,這是刻意的,它不能成為 oracle。真的不在 schema 裡的名稱會落到下推層,得到 computed filter column '<name>' not found in table schema。指到一個儲存欄位的名稱則會一路編譯到能判斷型別,得到第三種訊息:computed filter column '<name>' is type <t> — use `filters` for stored columns; computed_filters takes rollup/lookup/formula columns

aggregate_records 沒有 computed_filters。在伺服器端對計算欄位做彙總,兩個工具都還是做不到。

計算欄位的 sort_column

sort_column 現在接受 rollup、cardinality 為 one 的 lookup、G3 picked-many lookup 與 formula 欄位,在伺服器端排序,用的是過濾 lane 的同一個 term builder。編譯出來的排序是 [<computed term>, record.id]——record id 被接在後面當穩定分頁的決勝鍵。null 的計算格維持 MySQL 預設位置:asc 排最前、desc 排最後。

link 欄位有自己的拒絕訊息,而且這次改過:Column '<c>' is a link column and cannot be sorted on — use custom_tables_traverse_links for link analysis.

這條路只支援單鍵。多鍵的 sort=[{column, order}, …] 參數仍然拒絕計算欄位:Column '<c>' is a <type> column and cannot be sorted on via multi-column sort — use sort_column. 也就是說,要用計算欄位當排序鍵,就不能再有第二個排序鍵。

50,000 筆存活資料的守門

兩種計算能力都從共用的下推層繼承了 PUSHDOWN_MAX_LIVE_ROWS = 50000。超過這個數量,呼叫直接被拒絕——不會降級、不會分頁、不會退回其他做法:

  • computed filters are refused on tables with more than 50000 live records (this table has <N>) — narrow the data with regular filters first
  • sorting by computed columns is refused on tables with more than 50000 live records (this table has <N>) — narrow the data with regular filters first

這句補救建議要小心讀,因為它會誤導人。守門是在任何述語被編譯之前,用整張表的存活列數判定的,所以再加 filters 也解不開。caller 傳什麼都解不開,只有表變小才行。在一張 60,000 列的表上,「終身價值前 10 名的客戶」直接失敗——而那正是讀者會以為這個功能是為它而生的那種表。

IF/AND/OR formula 仍然不行

齊平的是算術與比較 formula,不是所有 formula。運算樹裡含有 IFANDOR 節點的 formula,在過濾與排序兩條路上都被拒絕:IF/AND/OR formulas cannot be sorted/filtered yet; sort/filter is available on plain arithmetic/comparison/UPPER/LOWER/LEN formulas

以比較為根、輸出布林的 formula 可以過濾,但只能用 eqneqis_nullis_not_null,而且值必須是真正的 truefalse

  • computed filter on '<c>': boolean-output formulas take eq/neq/is_null/is_not_null only
  • computed filter on '<c>': op '<op>' requires true or false

計算儲存格由 principal 授權決定,不由分析 scope 決定

custom_tables_analyze 上的 table_ids模型自己挑的,因此它永遠只能是委派的聚焦範圍;真正的授權事實是 principal 當下的逐表權限清單。這個區分過去在計算欄位上被弄丟了:rollup 或 lookup 的來源表只要落在選定 scope 之外,就會被解成 can_read: "none",於是一張本來就有權限的 host 表上每個計算儲存格都渲染成 null,欄位也被標上 restricted。agent 接著就把真實數字回報成「空的、來源不可讀」,而 REST 回得出值、同一個 principal 也能直接打開來源表。

現在計算欄位的授權與 restricted 標註改為查詢 principal 的完整即時權限表。deny-default 的形狀沒有變,所以沒有任何放寬:讀取層級、row policy、SCP channel 底線與隱藏欄位全部照舊生效;principal 真的讀不到的來源,儲存格仍然是 null,欄位仍然帶標記。

這個標記值得認得。受限欄位在 schema 中會渲染成 , RESTRICTED: no read access on the gated table — cells render null for you,在精簡 roster 中則是 :RESTRICTED 後綴,例如 Owner name(lookup:Staff via Owner:RESTRICTED)。帶著這個標記的 null 代表缺少授權,不是數值零;而受限欄位也不會被宣告 lookup 的 fallback 子句。

computed_filters 與計算欄位的 sort_column 現在也查同一張權限表(backend PR #1173)。它們過去在分析 scope 下 deny-default,理由是它們對來源表編譯述詞、而不是渲染單一個受管制的儲存格——因此在一次窄 table_ids 呼叫裡,你可能讀得到某個 rollup 值,卻不能對它過濾或排序,而唯一的辦法是把 table_ids 放寬。現在這兩條通道都改用 principal 的完整即時權限表,與渲染儲存格完全一致:只要 principal 讀得到來源表,即使該來源不在所選的 table_ids 裡,也照樣可以對那個 rollup 過濾與排序;而且 filter、sort 與渲染儲存格共用同一個 term builder,三者不可能各自漂移。

deny-default 沒有改變,而且它是無聲的。principal 真的讀不到的來源仍然解成 can_read: "none",計算項會編譯成 SQL NULL:屆時每一個值運算子都比對到零列,is_null 會命中,排序鍵則落在 MySQL 預設的 NULL 位置——asc 在最前、desc 在最後。沒有錯誤,也沒有拒絕訊息;那就是渲染儲存格會看到的同一個 null。不要把「計算欄位過濾沒有結果」讀成「沒有這樣的資料列」。

全域文字搜尋:q

custom_tables_query_records 接受 q,上限 200 個字元。它是不分大小寫的子字串比對,在所有可見的 stringtext 欄位上以 OR 相接,再與 filtersany_ofcomputed_filters 以 AND 相接。它編譯走 REST lane 自己的 clause builder,所以兩個介面搜的是同一組欄位。

偽欄位 id 與每個被欄位 ACL 隱藏的欄位都被排除在 OR 集合外。如果這張表根本沒有可見的 string 或 text 欄位,clause 是一個明確的 false——查詢命中零列。它永遠不會變成靜默的全命中。

custom_tables_find_records 最多回 10{id, label, data},另有真正的 total。Label 由最多兩個字串欄位串起、各截到 80 字元;data 使用顯示欄名並補齊人員/附件資訊,只保留呼叫者可見的儲存欄位。此工具不能翻頁,但小型文字查找現在可直接回答;需要條件、分頁、彙總或跨表分析時仍走 analyst/query。

用 cardinality 為 one 的連結欄位 group_by

當一個 link 欄位的連結定義 cardinality == "one" 時,custom_tables_aggregate_records 接受它出現在 group_by。資料列依其存活的目標 record id 分組,由一個相關子查詢解出,該子查詢 join 目標時帶 is_deleted == false 以及 SCP/ACL 的底線條件。group_bygroup_by_paths 合計仍以 3 個欄位為上限。

有兩件事會咬到讀者:

  1. 分組鍵是目標 record id,不是名稱。 顯示名稱要 caller 自己回目標表解。
  2. NULL 那一組把三種不同狀況混在一起——這一列沒有連結、目標被軟刪除、目標在這個範圍被底線遮住——而且刻意做成無法區分。要能區分,分組鍵就等於把一張受治理的表的 id 列舉出來。所以「沒有客戶」那一組不能當成那些列真的沒有客戶的證據。

cardinality 為 many 的連結仍然被拒絕,現在訊息很明確:group_by link column '<c>' has cardinality "many" — only cardinality-"one" link columns can be grouped by; use custom_tables_traverse_links for many-link analysis. 而給計算欄位的那句拒絕改寫成 Column '<c>' is a <type> column and cannot be grouped by — computed cells are read via query_records.

principal 分組鍵會附上 label——外部通道除外

principal 欄位分組,分組依據是儲存的帶標籤儲存格,而那個原始字串就是分組鍵本身,所以它穩定、也能直接當成 eqin 的過濾值再用。custom_tables_aggregate_records 會另外在每一組上附加一個扁平、純附加的 labels 物件:

{ "group": { "Assignee": "room:22222222-2222-4222-8222-222222222222" }, "aggregates": { "count": 17 }, "labels": { "Assignee": "台北排班室" } }

標籤決定查哪一本目錄:user: 解析成員工的 nickname,沒有時退回 username;smc: 解析成該 client 的 per-channel profile 顯示名稱,agent 平台的 client 則橋接回內部使用者的 nickname;room: 解析成聊天室名稱。每次查詢都以公司為錨點,每個名稱都截到 64 字並視為「客戶撰寫的資料」而非指令;舊有的 usersocial_client 欄位以及 created_bycreated_by_client 也由同一輪一起標註。

有兩個行為要先想清楚。存活狀態不是過濾條件:指向已刪除使用者或聊天室的分組鍵仍然會被標註,因為分組鍵指的是「當初這些列被寫給誰」。而解不出來的 label 就是不存在——跨租戶或已消失的對象,或早於帶標籤儲存格文法的舊鍵,都不會產生任何條目;某一組若沒有任何欄位解得出來,那一組根本不會有 labels key。沒有佔位字串、也沒有 null label,所以請防禦性地讀 labels,解不到就退回原始 ref。

在外部通道上——LINE(含群組與聊天室變體)、Messenger、Instagram,以及能解析成存活 client 的 Web 訪客——principal 欄位完全不會被標註。room: label 是內部聊天室名稱,屬於外部契約連在 enriched cell 上都不交出的組織結構;user:smc: label 指的也是那個 caller 從來沒被交付過的人。外部 caller 只會拿到原始的帶標籤鍵,沒有別的。舊有的 usersocial_clientcreated_by 支線在那裡的行為不變,仍然會被標註;只有 principal 型別被扣住。

custom_tables_resolve_principal:帶標籤儲存格背後的目錄

一個 principal 儲存格是單一帶標籤字串——user:<id>smc:<id>room:<id>——而伺服器端沒有針對它的名稱搜尋。custom_tables_resolve_principal 就是「把名字變成 id」的那個工具。它掛在分析子 agent 的讀取工具箱上,不在主 agent 上,而且只有在正向確認呼叫者是已認證的內部 principal 時才會建立:外部 client 永遠拿不到它,解不出 user id 的內部通道 session 也拿不到。它不讀取任何資料列。

參數契約
query必填,2–64 字元。顯示名稱片段;%_ 會被轉義成字面值,不會當萬用字元
kind必填,只能是 usersocial_clientchatroom
limit1–20,預設 10
kind搜尋範圍候選物件形狀
user以 nickname/username 搜尋內部員工,全公司{ref, kind: "user", id, name, username, is_deleted}
social_client這個房間的 client,依 per-channel profile 名稱{ref, kind: "social_client", id, platform, name}
chatroom你所屬、存活、同公司的聊天室{ref, kind: "chatroom", id, name, department_id}

kind: "chatroom" 是成員資格目錄,不是全公司的聊天室查詢介面:查詢會 join 呼叫者的房間成員資格、以公司為錨點,並排除軟刪除的房間,因此它列不出任何「呼叫者自己的 $me principal SET 本來就不會帶到」的房間。所有候選物件都沒有 email,而 department_id 只出現在 chatroom 這一種,且是裸 id。

每個候選物件都帶著 ref——那正是寫入者要貼進 principal 儲存格的字串,也正是過濾器要收的運算元。原樣送回去;永遠不要自己拼 tag:id,也不要送裸 id 或整個讀回來的 dict。id 無法鑄成合法 ref 的候選會直接從結果中剔除,而不是給你一個壞掉的 ref。

回傳是一個有界信封:

{ "kind": "user", "total": 2, "truncated": false, "candidates": [ { "ref": "user:…", "kind": "user", "id": "…", "name": "…", "username": "…", "is_deleted": false } ], "note": "<untrusted_principal_names> … </untrusted_principal_names>" }

這裡沒有「符合太多」的拒絕:該通道會多抓一筆並回報 truncated: true。空結果也不是錯誤——total: 0、沒有候選,正是分析 agent 用來區分「查無此人」與「這個人沒有資料列」的方式。有四種失敗會以 {"error": …} 回傳:query needs at least 2 characters.principal lookup unavailable — no tenant anchor,以及下面兩則節流訊息。

對 principal 欄位過濾

只有五個運算子能編譯:eqneqinis_nullis_not_null。其他一律是 Operator '<op>' cannot be applied to principal column '<c>' — principal id cells support only: eq, in, is_not_null, is_null, neq.。在 principal 型別上,運算元必須是帶標籤儲存格in 則需要非空的清單:

{ "filters": [ { "column": "Assignee", "operator": "in", "value": ["user:u-1b2c3d", "room:r-90ff21"] } ] }

裸 id、讀回來的 enriched dict,或 IaC 的 $user: token 都會被拒絕:Filter value for principal column '<c>' must be a tagged principal cell (user:<id>, smc:<id> or room:<id>) — resolve the principal first and pass its ref. Reads return {ref,kind,id,name,…}: send value.get('ref') back verbatim, never the bare id or the whole dict.in 會改指清單:Every entry of the 'in' list on principal column '<c>' …)。之所以要拒絕,是因為裸 id 永遠不可能字串等於已儲存的帶標籤儲存格,不拒絕的話 eq 會靜靜回一個什麼都不符合的 200neq 則什麼都符合。舊有的 usersocial_client 欄位不受影響,仍然收裸 id。

名稱運算子在 principal 欄位上被直接拒絕,兩條通道都是。工具通道說 '<op>' is not supported on principal column '<c>' — use custom_tables_resolve_principal, then filter by eq/in on the ref;REST 說 Op '<op>' is not supported on principal column '<c>' — resolve the principal first and filter by eq/in on its ref。帶標籤儲存格有三本可能的目錄,而聊天室根本沒有名稱目錄,所以 name_eqname_contains 沒有任何一致的解析對象。

名稱解析有速率限制

每一次目錄呼叫、每一個不同的名稱運算子搜尋詞,都會消耗一格 fail-closed 的滑動視窗額度,並以 principal 與 acting room 分桶:內部 principal 每 300 秒 30 次,外部 10 次。用盡時回 name lookups are rate-limited — try again shortly;Redis 失效或缺少 principal/scope 錨點則回可區分的 name lookup temporarily unavailable,並拒絕這次查找,而不是放行一次未計量的查找。兩則訊息都刻意不含數字,好讓外部通道能原樣轉述。

principal 欄位上的 $me 跟著 acting room 走

principal 欄位上,$me$me.department 這兩個 row policy token 會展開成一帶標籤儲存格而不是單一 id;在 agent 通道上,這組值會被釘在 session 所在的房間:工具箱解析每一張表的權限時都帶著 acting chatroom,因此 $me 會變成呼叫者自己的 user: ref 加上 acting room 的 room: ref——而 room: 那一腿要留下來,acting room 必須通過該 token 自己的檢查:$me 要求呼叫者是該房間存活的同公司成員;$me.department 則要求該房間是呼叫者部門底下存活的同公司房間,不論呼叫者有沒有在裡面。因此一位同時屬於 A、B 兩室的業務,在 A 室發問時看得到指派給自己與 room:A 的資料列,但看不到指派給 room:B 的;同一個問題走 REST(不綁 acting room)則兩者都回。這是刻意的——渲染進共用房間 A 的答案不該夾帶 B 室的指派——所以,凡是把指派押在 room: ref 上的資料表,都必須把房間對應到 agent 實際運作的房間,否則 agent 會合理而且無聲地給出比網頁 UI 更窄的答案。Trigger 與 public callback 的寫入路徑帶著 channel scope 拒絕,只會拿到自身那一腿,完全沒有 room: 腿。完整文法見 row policy token

這兩個是讀取側的 token,而儲存用的 cell 文法依然既不接受 $ 也不接受第二個 :——沒有任何長得像 token 的東西會被寫進資料庫,REST 也仍然要求已解析的 ref。Agent 寫入通道有一個例外,而那是一次翻譯,不是文法放寬。

$me 也是 agent 通道上唯一可寫入的自我指稱

自 backend PR #1190 起,custom_tables_insert_recordcustom_tables_update_record,以及 custom_tables_bulk_record_actions 的 insert 與 update 兩個分支,都接受把 $me 這個 token——去除前後空白、不分大小寫、而且僅此一種寫法——寫進人員欄位。工具箱會在驗證跑起來之前先把它解析成當事人,所以驗證器、CRUD 歸屬閘門與資料庫看到的,都是一個普通的已解析 cell。token 本身永遠不會進到儲存層。

它解析成什麼,取決於欄位型別與 session 所在的通道:

欄位型別內部使用者通道外部 client 通道
principal帶標籤的 user:<id>帶標籤的 smc:<id>
user裸的使用者 id不解析
social_client不解析裸的 client id

usersocial_client 這兩個舊型別各自綁定單一種身分、只存一個裸 id,所以「我」只在對應的通道上存在。無法為該欄位解析時,token 會原樣留著——接著它就會被一般的形狀檢查擋下來,而不是無聲地寫成別人。

伺服器只認這個 token。它沒有同義詞表:「我」「me」「myself」以及其他任何說法,都是模型的責任,由整段對話都會載入的協定來教。其他規則也一項都沒有放寬——CRUD 仍然對解析後的 id 檢查租戶歸屬與存活狀態,所以 $me 寫不出跨租戶或已軟刪除的身分。

重點在於,人員可以在第一次寫入時就填進去。在這個 token 之前,「把我登記成負責人」需要模型先知道自己的呼叫者 id——而那是它絕不該被告知的東西——於是人員欄位只能先留空,之後再拿一個模型只能用猜的 id 去補。

一個 envelope 只有一個 id,而「找不到」會導向下一步

每一張表在 record.data 裡面都帶著一個自動產生的 id 主鍵欄位,它的 uuid 和每個讀寫工具真正定址用的 record_id 是不同的值。Schema 渲染器本來就把該欄位藏起來了,所以把它的值原樣回吐,等於讓模型在同一個 envelope 裡拿到兩個 id、卻無法分辨:2026-09-05 就有一個 agent 從 insert 結果複製了 data.id,把下一次 update 花在它上面,讀到「找不到」,然後在五位員工中的兩位身上把整列重新 insert 了一次——出現重複列,其中一列的人員欄位還是空的。

現在在 agent 通道上,insert 與 update 結果、custom_tables_query_records 的分頁,以及其中深度 1 的 link 展開,都會把那個 data.id cell 拿掉。REST 回應仍然保留它,因為前端就是用它來定址 cell——所以這是單一通道上的 envelope 變更,不是契約變更。

統一的「找不到」拒絕現在也會導向下一步。401、403、404 仍然收斂成同一句話,讓這條通道永遠不是存在性的 oracle;而後半句講的是「該怎麼做」,不是「發生了什麼」:

Record '<id>' not found or has been deleted. Re-read the record id from the insert or find result and retry the update with it; never re-insert the row.

它是固定文字,對每一種成因都相同,因此不會洩漏究竟是三者中的哪一種。

兩輪確認協定與 direct 通道

聊天室設定 requires_confirmation_for_ct_action 決定 Custom Table 異動如何進資料庫。它是 ChatSettingsPayload 上的 boolean,預設值是 false,也就是 direct 通道。只有 tool loader 會讀它;它從來不是 tool argument,也不能被 additional_args 或租戶額外參數覆寫。

這個預設值值得明講,因為 loader 過去與 schema 不一致:兩處防禦性 fallback 把「設定不存在」讀成 true,於是從未寫過這個值的房間,行為上等同開啟確認。現在兩處都讀 false,與 schema 和 DEFAULT_CHAT_SETTINGS 一直以來的宣告一致。想要保留 proposal 輪的租戶必須明確把旗標設為 true

涵蓋 insert、update、delete、bulk record actions,以及動態生成的 write command;適用 Chatroom、Agent、Web、LINE(含群組與聊天室)、Messenger 與 Instagram。Query 模式 command 維持唯讀,不走這套協定。

需要確認時

模型在同一份 roster 裡永遠看不到 prepare 工具與 execute 工具同時出現。

  1. Prepare。 Roster 只暴露 custom_tables_prepare_insert_recordcustom_tables_prepare_update_recordcustom_tables_prepare_delete_recordcustom_tables_prepare_bulk_record_actions(write command 則暴露對應的 prepare 工具)。助理送出一般 business arguments;工具若需要 table_idrecord_id 等識別值,這些一般參數仍會對模型可見。此時完全不寫入。ToolMessage 包含 status: "pending_confirmation"pending_confirmation: truestaged_requestaction 加上 canonical payload)、32 位 hex tokeninvalidated_tokensexpires_in_seconds: 900,以及 customer-interaction tip。使用者永遠看不到這份 JSON。Pipeline 用有界自然語言取代模型自行撰寫的文字,完整列出 business change。
  2. 可見性。 只有在那則精確的自然語言 AssistantMessage 已持久化並真正可見後,proposal 才能被確認。Web、chatroom 與 Agent 的一般文字通道以已 commit 的訊息為可見邊界。LINE(包含群組與聊天室)必須 reply 成功,或文件記載的 Invalid-reply-token push fallback 成功;Messenger 必須 send 成功;Instagram 的 reply-context send 或不帶 reply context 的 fallback 任一成功即可。Provider delivery 失敗或結果含糊時,proposal 保持 inert。
  3. Execute。 使用者在之後的一則 Human 訊息給出沒有歧義的肯定答覆(例如 確認)後,roster 暴露對應的 token-only 工具:custom_tables_execute_staged_insert_recordcustom_tables_execute_staged_update_recordcustom_tables_execute_staged_delete_recordcustom_tables_execute_staged_bulk_record_actions,或 custom_table_command_execute_staged_action。Write-command prepare 工具名稱是 custom_table_command_prepare_*。Schema 只有 { "token" }。Execute-tool description 會帶 token、action 與 staged payload,讓模型複製 token;它不得重建 business request。錯誤、跨身分或過期 token 回 confirmation_invalid,不寫入。同一輪確認會被拒絕。附帶條件、修改值、額外要求、讓釘住 schema 失效的 DDL,或其他非肯定答覆會作廢舊 token(staged contract 漂移時為 proposal_mismatch);變更後的要求必須重新完整 prepare 並再次確認。Direct policy 每次呼叫都重查 live schema,不釘住 DDL。

面對使用者的契約仍是自然語言。客戶不會被要求保存或輸入 token。模型只在 proposal 進入 ARMED 後,以 execute-tool argument 取得它。外觀為 UUID/digest 的 business 值仍是普通資料。

一個 Assistant turn 最多只能啟用一份不同的 business proposal。同一輪若產生兩份不同 proposal,兩份都不能確認。過大的 proposal——delimiter-safe staged JSON 必須能放進 8,000 UTF-8 bytes——不會建立 Redis state,並保持 inert。Proposal 15 分鐘後到期。同一個 identity-bound confirmation family 同時只允許一個 live PREPAREDARMEDEXECUTING proposal/execution。它的 16-entry 儲存上限還會計入 COMPLETED replay guards,直到各自的 replay horizon 結束。CANCELED、已過期的 inert PREPARED,以及超過該 horizon 的 completed entry 可被淘汰;若 protected entries 佔滿容量,在容量釋放前會拒絕新 proposal。

關閉確認時(預設)

requires_confirmation_for_ct_actionfalse 時,roster 只暴露直接工具(custom_tables_insert_record 與其兄弟,加上直接 write-command 工具)。一次呼叫就完成驗證並執行。在 MySQL 上會把讀取邊界重開到 READ COMMITTED、對精確 table 與目標列加 FOR UPDATE,並在寫入前重新授權——這是兩輪協定的 execute 半邊,只是拿掉 proposal 輪。這些工具出現前,loader 會在與 staged request 同一個 Redis family 取得保留的 direct-policy fence。Invocation 只驗證該 fence,不能重新取得它,也不能取消較新的 confirmation proposal。

共用上限、恢復與 receipt

恢復流程一律維持 fail-closed。若找不到相符 proposal、先前 proposal 從未持久化為可見內容並 armed,或 15 分鐘期限已過,單獨一句肯定答覆都不能執行異動。伺服器只會建立或替換成一份新的 inert PREPARED proposal;它必須先顯示給使用者,再於後續 turn 明確確認。相同 request 一旦到達 COMPLETED,其他 turn 的單獨肯定答覆會被視為對 receipt 的回應,並回傳 already_completed;既不重新開啟,也不重複寫入。若要再次執行相同 business change,使用者必須明確重述該要求;伺服器才會建立新 proposal,並從頭走完兩輪流程。

確認會綁定同一公司、對話與 channel、精確的已驗證內部使用者或伺服器解析出的 external client;LINE 群組/聊天室還必須是同一個 provider conversation。持久化的 proposal 訊息與後續確認 Human 訊息必須是那一組精確配對,而且中間不能插入同一 principal 的其他可見 Human turn。Redis state 本身永遠不是 authority。

對 record 寫入而言,伺服器私有狀態會釘住 table/record ids、canonical data/options、目標 record versions,以及資料表的寫入契約:schema、display mapping、rules 與 triggers。Upsert 還會釘住即時探測結果究竟是 create 還是 update。對動態 write command 工具而言,私有狀態會釘住 command inputs 與精確 command definition 的 SHA-256;adapter 在 token-only execute call 之後才把摘要當成 expected_contract_digest 轉送。Command 改動時,會在異動前回 HTTP 409 {"detail":{"error":"command_contract_mismatch"}}。上述伺服器端的結構性 ids、versions 與 digests 都不會出現在使用者可見 proposal 中;這不會遮蔽本身就是實際 business data、但外觀為 UUID/digest 的字串。任何 drift 都需要新 proposal。Query 模式 command 不走寫入確認。

一般 row mutation 會在資料異動的同一個 database transaction 內保存 durable result。Write command 則在 execution audit 上保留等價的私有 delivery marker,並在 reconciliation 時重用同一個 server-generated idempotency key。

用來確認的 Human 訊息會為該次 invocation 產生永久 execution identity。在自然語言結果抵達上述可見邊界之前,active delivery fence 會阻止相同 scope 的同一 business request 再次執行。Delivery acknowledgement 會釋放這個暫時 fence,讓未來真正獨立、但值相同的請求仍可執行。Provider 失敗不會回滾已 commit 的 mutation。若結果不確定或 receipt 尚未送達,先讀取目前資料,絕不能盲目重送相同變更;伺服器會沿用原本的 durable identity 進行 reconciliation,使用者與模型都不會攜帶該值。

寫入之後客戶會看到什麼

伺服器過去會把模型寫的成功或失敗敘述整段換掉,改成有界的自然語言 receipt。自 backend PR #1182 與 #1188 起,它改為保留模型自己的回答,並在下方空一行附上 receipt:模型的話在前,伺服器的事實在後。Receipt 仍然由伺服器撰寫,仍然不會露出 record UUID、version、execution id 或摘要。

你拿到哪一種 footer,取決於這一輪的情況:

這一輪的情況Footer
模型的敘述留下來了,而且每一個 mutation step 都套用成功一句括號附註——「(已為你新增 1 筆資料)」/「(已為你更新 3 筆資料)」/「(已為你刪除 1 筆資料)」,英文為 (1 record added for you)(3 records updated for you)(1 record deleted for you)
……而且這一輪既新增又更新一句合併附註,不會拆成兩句——「(已為你新增 1 筆、更新 1 筆資料)」,英文為 (1 record added, 1 updated for you)
有任何一個 step 沒有套用,或 receipt 就是整則訊息獨立句——「資料已新增完成。」/「資料已更新完成。」/「資料已刪除完成。」,英文為 The data was added successfully. 與同系列句子
兩個以上相同的已套用 step,且沒有留下敘述帶計數的句子——「已新增 3 筆資料。」,英文為 3 records were added.

第二欄要照字面讀:括號附註只有在模型自己的敘述留下來、而且這一輪每一個 step 都套用成功時才會使用。一個成功的 insert 旁邊有一個被拒絕的 update,渲染出來的是獨立句而不是附註,因為這一輪並非全部套用。語言由該輪對話文字的 CJK 偵測決定,不是由房間設定、也不是由通道型別決定。

模型敘述中只有與實際結果矛盾的句子會被拿掉。每一個 step 都套用成功時,沒有任何句子可能誤報狀態,敘述就整段保留。有東西沒套用時,pipeline 會把敘述切成句子,只丟掉「宣稱某種未發生的操作已完成」的句子、泛用的「已完成/成功」宣稱,以及像「發生內部錯誤」這類錯誤狀態宣稱。敘述被整段丟棄只有一種情況:這一輪並非全部套用、且同時綁定了 staged-execute 工具——因為那段敘述可能洩漏 confirmation token。

被同一輪後續一次成功寫入取代的拒絕或失敗嘗試,完全不會被渲染。取代的條件是:同一張表上的同一個 action,且兩者 record_id 相同——或者,對於寫錯 id 的重試,較早那次的每一個 cell 都被後來那次寫入重複寫了一遍。只有 deniedfailed 的 step 會這樣被丟掉;conflict 或其他結果不確定的一律照常渲染。站得住腳的拒絕會渲染成「這項變更沒有執行,資料沒有被改動。」/This change was not made, and nothing in your data was altered.

明確沒有寫入的 command 拒絕會說明原因

有五個 command 結果代碼屬於明確沒有寫入——assertion_failedcap_exceededcardinality_failedcommand_input_invalidcommand_rule_failed。每一個都伴隨 rollback 拋出,所以什麼都沒寫入,也沒有任何不確定。它們的 receipt 會講出原因,而不是停在「失敗了」(backend PR #1179):

zh-TW 這項變更沒有套用,資料沒有被改動:未通過商業規則檢查 en This change was not applied, and nothing in your data was altered: a business rule check did not pass

assert step 失敗時,原因就是 command 作者自己寫的 message,在 tool result 上與 "error": "assertion_failed" 並列成 reason,會被壓成單行純文字並限制在 200 字元。另外四個代碼沒有作者文字,一律使用該代碼的固定後備字串:「這次請求超出允許的規模上限」/the request exceeded an allowed size limit、「資料筆數不符合預期」/a row-count guard was hit、「輸入內容不正確」/an input was invalid、「商業規則阻擋了這項變更」/a business rule blocked it。provider 或資料庫的文字永遠不會送到客戶眼前。

這個分支刻意收得很窄:只有帶著上述五個代碼之一的 command mutation 才會進來,而且「結果不確定」的分支會先被檢查——可重試或 5xx 的失敗仍然使用「無法證明發生了什麼」的文案,因為它有可能已經 commit。

寫入通道上的 4xx 是終局拒絕,不是內部錯誤

從 CRUD 呼叫逃出來的 4xx,過去到 agent 手上會變成泛用的 An internal error occurred …——那讀起來像暫時性問題,會誘發一次不可能成功的重試。最常見的情況就是 row policy 的寫入後檢查:403 This record is outside your editable scope.。現在寫入工具會把這種逃逸對應成終局 envelope(backend PR #1184),依序如下:

拋出的東西Envelope
errorscp_ 開頭的 detail不變——SCP 拒絕保留自己的 scp_* 代碼,見 scope policy 拒絕
已淨化的 lock 409{"error":"write_conflict","code":"write_conflict","message":"Concurrent write conflict (lock); please retry the request."}——沒有 status key,而且不是 mutation_conflict
401/403/404{"status":"denied","code":"mutation_denied","error":<統一文字>}
其他任何 409{"status":"conflict","code":"mutation_conflict","error":"mutation_conflict","message":"The current record state conflicts with this change; nothing was modified. Review the record and try again."}
400/422淨化後的驗證文字,寫成 Write rejected: …,並帶著 "status": "failed" 重新輸出
其他任何情況——5xx,或沒有可渲染 detail 的 4xx不變;仍然是泛用的內部錯誤訊息

拒絕 envelope 上的 error 是依呼叫形狀統一,而不是依成因統一。有指名 record 的呼叫(update、delete)拿到的是上面那句會導向下一步的「找不到」;沒有指名 record 的呼叫(insert)拿到的是 This change is not allowed with the current table permissions.。兩種情況下,401、403、404 對呼叫端都無法分辨。

Customer-interaction tip

每一則 Custom Table ToolMessage——讀取、寫入、說明、拒絕——都以同一句提醒結尾,由各 factory 套用一次,而不是寫在個別 tool body。JSON object 會加上 tip key(若已有更豐富的 tip 則不覆寫);其他結果會在最後一行附加 Tip: …

Speak in the room's own voice and language; answer the person's question naturally. Never expose JSON, record/table ids, UUIDs, tokens, digests, URLs, or system headers — describe things by their business names and keep every business value exact.

這段文字在 backend PR #1187 改寫過,而且不是修辭調整。舊的提醒——「Reply to the customer in natural language only: translate tool, action, column, and parameter names into familiar business terms; keep every business value exact; never show JSON, record/table ids, UUIDs, tokens, digests, URLs, or system headers.」——讀起來像在指定語域,而它會跟著每一則 tool result 一起出現。LINE 上的育兒助理、或風格輕鬆的社群 bot,只因為讀了一張表,接下來整輪就會滑進客服口吻。這則提醒只談資料揭露;助理聽起來是什麼樣子,由聊天室的 persona 決定——協定的 Voice 段落講的就是同一件事。

當寫入保護層本身跑不起來時——沒有 Redis,或它要綁定的 assistant/user 訊息身分缺失——不會有任何寫入,agent 收到的是 {"status":"failed","action":"<action>","error":"confirmation_unavailable","message":"Write protection is temporarily unavailable. NO write was performed. Do NOT attempt this change through any other tool in this turn; retry later and never claim the change landed."}。這個結果面向客戶的文案刻意做成與通道無關,因為 direct 通道的房間根本沒有「確認」這個對使用者可見的概念可以指涉:

zh-TW 寫入保護暫時無法完成安全確認,因此這次沒有變更資料;請稍後再試。 en The write-safety check could not be completed, so nothing was changed. Please try again later.

請把它讀成「寫入前的安全檢查沒有完成」,而不是「確認設定被關掉了」。訊息裡叫模型別在同一輪改用別的工具,正是重點:催生這段文案的事故,就是一則同時「拒絕」又「寫入」的回覆——因為 command 通道被擋住時,agent 退回去用了原始的資料列工具。

Command input descriptions 會整段複製到 schema 上限 1024 字,沒有 160 字的 per-label 截斷。只有 identity:* 值會被隱藏。可見 confirmation renderer 的合計預算是 16,000 字、最多四份 proposal;超出時不寫入,並請使用者把變更拆小。鎖定 execute 路徑上的 MySQL deadlock 或 lock-wait 回 {"error":"write_conflict","code":"write_conflict","message":"Concurrent write conflict (lock); please retry the request."},不會用過期授權在內部重試。

Outer pipeline 仍是面對客戶的權威:它會從 receipt 拿掉 tip key,且必須只輸出自然的業務語言。以上保證描述的是 origin/master HEAD 已合併的 backend source;它們本身不代表某個 staging deployment 已經執行該 commit,也不代表真實 LINE 或 Meta delivery 已經驗證。

受 command 治理的資料表拒絕原始 agent 寫入

資料表可以被作者設定成:AI agent 只能透過為它撰寫的自訂資料表 command 來改動——也就是承載勞動法規則、跨表扇出與各種驗證的那些 command,而原始資料列工具對這些一無所知。一旦適用,custom_tables_insert_recordcustom_tables_update_recordcustom_tables_delete_recordcustom_tables_bulk_record_actions 都不會寫入,而是回一個確定的轉導:

{ "error": "table_governed_by_commands", "message": "Raw record writes are disabled for this table: its writes are managed by dedicated command tools that enforce business validation. Call the matching custom_table_command_* tool for this change instead. Reads and searches on the table remain available.", "table_id": "…" }

一張表要被治理,三個條件必須同時成立:

  1. 作者主動開啟。 表的 settings 帶著 agent_writes_via_commands_only: true,而且必須是布林 true;truthy 的 1"true" 都不算。它是表的 JSON settings blob 裡的自由 key,可在 REST 建表時設定,也可透過 IaC table 行的 settings 傳遞。它不在 REST 的更新 payload 上(那裡只能改 namedescription),所以既有的表要改走 IaC 而不是 PATCH。沒有這個 key,轉導永遠不會啟動——這是逐表選擇加入,不是對「剛好有 command 的房間」做推論。
  2. 房間確實跑著 command 通道。 轉導只有在房間的 job 清單含有 Custom Table Commands job、且該輪的異動工具政策完整時,才會被 tool loader 安裝。沒有 command 通道的房間,對同一張表仍然保有一般的原始寫入。
  3. 這張表是某個可見、已啟用 agent 的 command 的寫入目標。 治理集合每輪由 agent 可見的 command roster 計算一次,同時涵蓋 chatroom、department 與 company 三個 scope。所有寫入 step 都算——版本 1 依其 action,版本 2 依 insertupdatedelete kind——而 select step 與整個 mode: "query" command 都不算。已刪除、agent_enabled: false、tag 已不再涵蓋所有引用表,以及引用了呼叫者讀不到的表的 command,全都被排除,因為它們本來就不在 roster 裡。

這道閘門位於寫入工具內部,在資料表已依 id 解析之後——所以用名稱定址的寫入會先解析到同一張表,繞不過去;而讀不到的表仍然維持它原本「無法解析」的中性拒絕,不會變成存在性預言機。它也位於 staged pin 改寫之後,因此 prepare、execute-staged 與 direct 三條通道都會經過它;確認 token 不是繞道的方法。讀取、搜尋、彙總與分析完全不受影響,正如訊息本身所說。

列舉治理集合的動作fail open。若該查詢拋錯,轉導就單純不會被安裝、原始寫入照常可用,錯誤只記進 log。它是疊在 command 通道自身授權檢查之上的額外防護,不是替代品:一次短暫的讀取錯誤不該轉成整個房間的寫入中斷。同樣地,也不要把這個轉導讀成權限錯誤——產生它的過程從來沒有查過 principal 的 ACL。

這個轉導最初是對「所有帶 command 的房間」做的全面推論,而那會過度封鎖混合通道的房間——有些欄位由 command 驅動、有些只是一般資料:原本可行動的規則拒絕被換成一句籠統的轉導,沒有對應 command 的欄位也變成不可寫。由作者簽署的那個設定,就是把它收回到真正需要它的資料表上。

Direct realtime voice 維持唯讀

OpenAI/ElevenLabs realtime session 會把模型音訊直接串流到瀏覽器,因此 backend 無法證明 deterministic proposal 確實顯示,也無法證明後續工具呼叫真的發生在新的 Human turn。Custom Tables 在這個 direct WebRTC surface 上因此維持唯讀:instructions、analysis、record lookup 與已授權的 attachment viewing 仍可使用;row insert/update/delete/bulk、attachment write-input discovery、permission mutation,以及所有 Custom Table Commands 都不提供。助理必須請使用者回到一般文字聊天室完成變更。

這個邊界只適用 direct realtime voice。LINE 的 text-to-speech 回覆,以及社群 channel 把音訊轉錄後送進一般持久化文字 pipeline 的情況,仍使用上面的自然語言確認與 delivery 規則。

Upsert:insert_recordmatch_column

Insert 家族的選填 match_column 在預設的 direct policy 下位於 custom_tables_insert_record;房間把 requires_confirmation_for_ct_action 設為 true 時才在 custom_tables_prepare_insert_record。它會在 caller 自己看得到的資料列範圍內,用 data[match_column] 去探測那個欄位,接著在已確認的異動階段:

命中的存活資料列結果
剛好一列在 row lock 底下用 data 更新那一列(部分更新)
沒有新增一列
超過一列拒絕——什麼都不寫

只要傳了 match_column,回應就會帶 "upsert": "updated""upsert": "created",走更新分支時另外帶 "version"。這消掉了以前先找再寫會產生重複列的競爭窗口。

閘門依這個順序跑,錯誤各不相同:

  • 被隱藏的 match_column 先被欄位 ACL 檢查擋下,回統一的 Column '<c>' not found.——跟工具箱裡任何不存在欄位得到的字串相同。它不會說「被隱藏」。
  • 完全不在 schema 裡、或 json path 消毒失敗的名稱,得到 match_column '<c>' not found in table schema.
  • linkrolluplookupformulajsonattachmentmulti_select 欄位得到 match_column '<c>' is a <type> column — upsert matching needs a stored scalar column.
  • 沒有給探測值:data must include a value for match_column '<c>' to probe on.
  • 命中兩列以上:match_column '<c>' matched more than one record — refusing an ambiguous upsert. Add a unique rule on that column or update the intended record by id.

探測跑在 _base_record_query 裡,也就是 caller 自己受 row ACL 收窄的查詢,而更新分支接著會跑 edit 檢查。這帶來兩個後果:只有 insert 權限、沒有 edit 權限的主體在命中分支會被拒絕,而不是安靜地再插一列;被 caller 的 row ACL 遮住的資料列對探測不可見,所以 upsert 會新增,而不會覆蓋一列它本來就無權看到的孿生資料。設了 match_column 時必填欄位的驗證會延後,因為更新分支是部分更新。

update_recordexpected_version

預設的 direct policy(requires_confirmation_for_ct_action=false)把選填的 expected_version>= 1)放在 custom_tables_update_record;房間開啟確認時,同一個參數改放在 custom_tables_prepare_update_record。它是 agent 讀到的那個 version,在 row lock 底下驗證。不符時寫入被拒絕,agent 拿到的是機器可讀的 JSON,不是一段話:

{ "error": "version_conflict", "current_version": 7, "expected_version": 5 }

expected_version 保護的是 agent 較早讀取到提案呼叫之間的空窗:若 record 已經前進,就不會鑄出 proposal。Proposal 一旦存在,確認層即使在這個選填參數被省略時,也會另外釘住當下的 record version;確認輪會加鎖、再檢查一次,漂移時以 proposal_mismatch 拒絕。因此省略它不會讓使用者確認期間重新變成最後寫入者獲勝。Cheat sheet 要求 agent:只要使用者確認的是對話較早之前讀到的值,就傳 expected_version。REST 這一側的同一把鎖見併發與重試

custom_tables_bulk_record_actions

Bulk 家族可以在一張表上,把最多 50 個 insert/update/delete 動作當成單一原子批次套用。預設的 direct policy 暴露 custom_tables_bulk_record_actionsrequires_confirmation_for_ct_actiontrue 的房間才暴露 custom_tables_prepare_bulk_record_actions,再換成 custom_tables_execute_staged_bulk_record_actions。在它之前,「把這 N 筆都標成完成」只能迴圈呼叫單筆 update,中途失敗就留下改了一半的表,既沒有東西可以回報,也沒有東西可以回退。

{ "table_id": "…", "actions": [ { "action": "insert", "data": { "Item": "Pencil", "Quantity": 5 } }, { "action": "update", "record_id": "3333…", "data": { "Status": "Done" } }, { "action": "delete", "record_id": "4444…" } ] }

成功回 {"inserted": 1, "updated": 1, "deleted": 1}

這個工具不是對每個主體都註冊。 內部 principal 只要有 insert edit 任一權限就會看見,不需要兩者兼具。各 action kind 獨立檢查:只有 insert 權限的 principal 可以批次 insert;只有 edit 權限的 principal 可以批次 update/delete;混入未獲授權的種類會拒絕整批。批次只帶 changed_by、不帶 client 身分,所以外部 client 的批次會讓建立者歸屬、以及所有建立在它之上的 own-scope 規則失去依據——外部 client 保留單筆寫入工具。

權限是逐列檢查,不是逐表。 除了表層的 insert 與 edit 閘門之外,非管理者且 can_edit"own""filtered" 的主體,每一個 update 與 delete 目標都會再跑一次 edit 檢查。own-scope 的編輯者無法用批次改到單筆工具會拒絕的資料列。

失敗形態:

錯誤意義
{"error": "Batch rolled back: Action <i>: <reason>"}某一個動作驗證失敗;<i>0 起算,所以 Action 0 是第一個動作。整批都沒套用
{"error": "Write rejected: …"}規則或唯一性約束否決。沒有動作索引——引擎拒絕的是這次寫入,不是某個位置
{"error": "Table is currently locked for a schema migration. Please try again later."}遇到 migration lock
{"error": "An internal error occurred while applying the batch."}其他未分類狀況;整批已回退

批次動作裡的 data 必須用原生 JSON 型別。單筆寫入工具為了某些會把 dict 參數序列化成字串的模型而做的「JSON 字串自動還原」,在這裡不會跑。

在有核准閘門的表上,整批被當成一份簽核送審,訊息就是單筆那幾句換成批次措辭:送審時是 已送出簽核:這批變更需要核准才會生效(流程 <id>,規則「<label>」)。請等待簽核結果通知。,其中有目標已經有變更在審時是 批次中有記錄已有變更在簽核中(流程 <id>),核准或退回前無法再修改,整批已回退。

簽核佇列滿了是終局,不是可重試

當一張表待審核變更的上限被觸及時,核准層丟出 HTTP 429,detail 是 {"error": "staged_cap_exceeded", "scope": …, "limit": N}。工具箱的規則違反處理器只解讀 400、409、422,所以 429 穿過了每一個分支,回到 agent 手上變成通用的 An internal error occurred …。agent 把它讀成暫時性錯誤,於是不斷重試一個在簽核人清空佇列之前不可能成功的寫入——而使用者完全沒有被告知這跟核准有關。

insert、update、delete 與新的批次工具現在各自對它分支,回傳帶著上限數字的終局政策訊息:

  • 單筆:簽核佇列已滿:這張表待審核的變更已達上限(<limit>),新的變更暫時無法送出。請簽核人先核准或退回佇列中的變更後再試;重送相同內容不會成功。
  • 批次:簽核佇列已滿:這張表待審核的變更已達上限(<limit>),整批已回退。請簽核人先核准或退回佇列中的變更後再試。

於是 agent 在同一輪內不該重試的核准終局結果有三種:變更已被送審、該記錄已有變更在審、佇列已滿。見核准流程

外部 client 包含 Web 頻道

agent 協定以前把這一節命名為「Social Media Channel Access Tools」,並說那兩個存取工具只在「對話來自社群媒體頻道(LINE、Messenger、Instagram)」時出現、且「只提供給社群媒體 client」。但工具工廠一直以來是對任何解析出來、且 platform 不是 agent 的社群媒體 client 標記為外部 client,這包含 Web 頻道以及 LINE 的群組/聊天室變體。也就是說,手上明明握著 custom_tables_apply_permissioncustom_tables_use_passphrase 的 Web 頻道訪客,被自己的協定告知這些工具不是給它用的,於是那條取得存取權的路從來沒有被提出來。

custom_tables_use_passphrase 在(刻意很慢的)雜湊比對之前就先節流,依 client 與資料表分桶:每 300 秒 5 次,超過之後工具直接回一句純字串 Too many incorrect attempts. Please try again later.——節流儲存本身連不上時給的也是同一句,因為基礎設施故障必須是安全的拒絕,而不是無限猜測的旁路。額度內猜錯則是另一句 Incorrect passphrase.

這一節現在叫「External Client Access Tools」,明列 LINE(含群組/聊天室變體)、Messenger、Instagram 與 Web 頻道,並在結尾寫明:Web 頻道訪客拿到這些工具的方式和 LINE client 完全一樣。在一般文字 pipeline 上,契約其餘部分不變:內部使用者與 agent 頻道的權限自動解析,而這兩個工具不存在代表的是完整存取,不是被鎖在門外。Direct realtime voice 是明確例外:這些具異動能力的 access tools 在那裡不存在,是因為 Custom Tables surface 維持唯讀。見跨部門授權與洞察

相關頁面

Last updated on