Agent 工具箱:AI 查得到什麼、寫得動什麼
AI 助理是透過自己的一組工具存取自訂表格,不是走 REST。這組工具長期以來比 REST 窄,而那道落差呈現出來的是「錯的答案」而不是「錯誤訊息」:沒有相對日期運算子,模型只能自己算「最近 30 天」,偏偏它連今天是哪一天都不知道;沒有計算欄位過濾,它只能翻頁把 rollup 的值放在腦裡比,一過第一頁就是錯的;沒有 upsert,它只能先找再寫,只要找漏了就多一列重複資料。
現在讀取端在相對日期、計算欄位、全域文字搜尋這三件事上與 REST 齊平,寫入端則多了 upsert、樂觀鎖與原子批次。下面的上限與拒絕跟能力一樣重要:多數是直接回錯誤的硬拒絕,不是回一份比較小的答案的降級。
誰在跑哪個工具
面對使用者的主 agent 擁有下列工具面:
custom_tables_get_instructions與custom_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_record/update_record/delete_record/bulk_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_overview、custom_tables_profile_column、custom_tables_query_records、custom_tables_aggregate_records、custom_tables_correlation 與自己的 custom_tables_cheat_sheet。custom_tables_resolve_principal 只會加給已認證的內部 principal——外部 client 永遠沒有,解不出 user id 的內部通道 session 也沒有。只有 scope 內至少還有兩張可讀表時才會加入 custom_tables_join_aggregate;只有可存取的 link 連起 scope 內的資料表時才加入 custom_tables_traverse_links。主 agent 無法直接呼叫這些底層工具。
custom_tables_get_instructions 與 custom_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_tables 與 custom_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_id,data裡面沒有任何東西可以識別一列資料。 - 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 與舊版比對,看到的差異就是這一點。
有界的探索、link schema 查找與分析
custom_tables_get_instructions 是必須先呼叫的入口。它回的是即時頁面,不是把所有 schema 無上限地塞進一份快照:預設 10 張、最多 20 張。name_contains(1–120 字元)與 table_ids(最多 20 個精確 id)只能擇一;offset 為 0–100,000,limit 為 1–20。每頁都有機器可讀的 matching_table_count、shown_table_count、offset 與 next_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_last、within_next、older_than
這三個運算子在 custom_tables_query_records 與 custom_tables_aggregate_records 中,可用於 date 與 datetime 欄位,以及 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 但沒有整數 amount | Operator '<op>' window dict needs an integer 'amount', got <v>. |
dict 但 unit 不合法,或 amount <= 0 | Operator '<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_records 的 computed_filters
custom_tables_query_records 接受 computed_filters: [{column, op, value}],最多 3 個述語,與 filters、any_of、q 以 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" }
]
}運算子是 eq、neq、gt、gte、lt、lte、in、is_null、is_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 firstsorting 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。運算樹裡含有 IF、AND、OR 節點的 formula,在過濾與排序兩條路上都被拒絕:IF/AND/OR formulas cannot be sorted/filtered yet; sort/filter is available on plain arithmetic/comparison/UPPER/LOWER/LEN formulas。
以比較為根、輸出布林的 formula 可以過濾,但只能用 eq、neq、is_null、is_not_null,而且值必須是真正的 true/false:
computed filter on '<c>': boolean-output formulas take eq/neq/is_null/is_not_null onlycomputed 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 個字元。它是不分大小寫的子字串比對,在所有可見的 string/text 欄位上以 OR 相接,再與 filters、any_of、computed_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_by 與 group_by_paths 合計仍以 3 個欄位為上限。
有兩件事會咬到讀者:
- 分組鍵是目標 record id,不是名稱。 顯示名稱要 caller 自己回目標表解。
- 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 欄位分組,分組依據是儲存的帶標籤儲存格,而那個原始字串就是分組鍵本身,所以它穩定、也能直接當成 eq/in 的過濾值再用。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 字並視為「客戶撰寫的資料」而非指令;舊有的 user/social_client 欄位以及 created_by/created_by_client 也由同一輪一起標註。
有兩個行為要先想清楚。存活狀態不是過濾條件:指向已刪除使用者或聊天室的分組鍵仍然會被標註,因為分組鍵指的是「當初這些列被寫給誰」。而解不出來的 label 就是不存在——跨租戶或已消失的對象,或早於帶標籤儲存格文法的舊鍵,都不會產生任何條目;某一組若沒有任何欄位解得出來,那一組根本不會有 labels key。沒有佔位字串、也沒有 null label,所以請防禦性地讀 labels,解不到就退回原始 ref。
在外部通道上——LINE(含群組與聊天室變體)、Messenger、Instagram,以及能解析成存活 client 的 Web 訪客——principal 欄位完全不會被標註。room: label 是內部聊天室名稱,屬於外部契約連在 enriched cell 上都不交出的組織結構;user:/smc: label 指的也是那個 caller 從來沒被交付過的人。外部 caller 只會拿到原始的帶標籤鍵,沒有別的。舊有的 user/social_client 與 created_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 | 必填,只能是 user、social_client 或 chatroom |
limit | 1–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 欄位過濾
只有五個運算子能編譯:eq、neq、in、is_null、is_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 會靜靜回一個什麼都不符合的 200,neq 則什麼都符合。舊有的 user 與 social_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_eq/name_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_record、custom_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 |
user 與 social_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 工具同時出現。
- Prepare。 Roster 只暴露
custom_tables_prepare_insert_record、custom_tables_prepare_update_record、custom_tables_prepare_delete_record、custom_tables_prepare_bulk_record_actions(write command 則暴露對應的 prepare 工具)。助理送出一般 business arguments;工具若需要table_id、record_id等識別值,這些一般參數仍會對模型可見。此時完全不寫入。ToolMessage 包含status: "pending_confirmation"、pending_confirmation: true、staged_request(action加上 canonicalpayload)、32 位 hextoken、invalidated_tokens、expires_in_seconds: 900,以及 customer-interactiontip。使用者永遠看不到這份 JSON。Pipeline 用有界自然語言取代模型自行撰寫的文字,完整列出 business change。 - 可見性。 只有在那則精確的自然語言
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。 - Execute。 使用者在之後的一則 Human 訊息給出沒有歧義的肯定答覆(例如
確認)後,roster 只暴露對應的 token-only 工具:custom_tables_execute_staged_insert_record、custom_tables_execute_staged_update_record、custom_tables_execute_staged_delete_record、custom_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 PREPARED、ARMED 或 EXECUTING proposal/execution。它的 16-entry 儲存上限還會計入 COMPLETED replay guards,直到各自的 replay horizon 結束。CANCELED、已過期的 inert PREPARED,以及超過該 horizon 的 completed entry 可被淘汰;若 protected entries 佔滿容量,在容量釋放前會拒絕新 proposal。
關閉確認時(預設)
requires_confirmation_for_ct_action 為 false 時,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 都被後來那次寫入重複寫了一遍。只有 denied 與 failed 的 step 會這樣被丟掉;conflict 或其他結果不確定的一律照常渲染。站得住腳的拒絕會渲染成「這項變更沒有執行,資料沒有被改動。」/This change was not made, and nothing in your data was altered.
明確沒有寫入的 command 拒絕會說明原因
有五個 command 結果代碼屬於明確沒有寫入——assertion_failed、cap_exceeded、cardinality_failed、command_input_invalid、command_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 passassert 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 |
|---|---|
error 以 scp_ 開頭的 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_record、custom_tables_update_record、custom_tables_delete_record 與 custom_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": "…"
}一張表要被治理,三個條件必須同時成立:
- 作者主動開啟。 表的
settings帶著agent_writes_via_commands_only: true,而且必須是布林true;truthy 的1或"true"都不算。它是表的 JSON settings blob 裡的自由 key,可在 REST 建表時設定,也可透過 IaCtable行的settings傳遞。它不在 REST 的更新 payload 上(那裡只能改name與description),所以既有的表要改走 IaC 而不是PATCH。沒有這個 key,轉導永遠不會啟動——這是逐表選擇加入,不是對「剛好有 command 的房間」做推論。 - 房間確實跑著 command 通道。 轉導只有在房間的 job 清單含有 Custom Table Commands job、且該輪的異動工具政策完整時,才會被 tool loader 安裝。沒有 command 通道的房間,對同一張表仍然保有一般的原始寫入。
- 這張表是某個可見、已啟用 agent 的 command 的寫入目標。 治理集合每輪由 agent 可見的 command roster 計算一次,同時涵蓋 chatroom、department 與 company 三個 scope。所有寫入 step 都算——版本 1 依其
action,版本 2 依insert/update/deletekind——而selectstep 與整個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_record 的 match_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. link、rollup、lookup、formula、json、attachment、multi_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_record 的 expected_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_actions;requires_confirmation_for_ct_action 為 true 的房間才暴露 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_permission 與 custom_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 維持唯讀。見跨部門授權與洞察。
相關頁面
- 查詢模型——運算子集合與各型別的規則
- AI 找不到資料時可以怎麼回答——讀到零列時附帶的 absence 判定
- 連結、彙總與查找——rollup、lookup、formula 的格子裡實際上是什麼
- 批次操作——agent 的 50 動作工具所對應的 REST 批次
- Row policy token——
$me與$me.department,以及它們在 principal 欄位上的 SET 語意 - 自訂資料表 command——受 command 治理的資料表把原始寫入導向的那條通道