常駐センシングとシグナルウェイクアップ
外部データストリームがルールに命中したときだけAI社員を起こす。
DuDuClawのRustゲートウェイ層はもともと24時間常駐しています(heartbeat、autopilot イベントバス、CEP、OS感知)。本ドキュメントは、その上に新しく追加された部分を説明 します:外部データストリーム(相場ポーリング、ログファイル、任意コマンドの出力、 WebSocketリアルタイムストリーム)を同じバスに接続し、安価なdeterministicルールに 常駐で見張らせ、本当にシグナルに命中したときだけ高価なクラウドAI社員を起こす仕組み です。
一言で言うと:外部データストリームがautopilotイベントバスに入り、deterministic ルール(オプションでCEP時系列判定)が24時間全量を見張り、命中後はさらにローカル 小型モデルによる二次スクリーニングを挟むこともでき、シグナルが本当に見る価値がある ときだけクラウドAI社員へ委任します。このパス全体はデフォルトで無効であり、ソースを 1つも設定しなければ、この機能が存在しないときと完全に同じ挙動になります。
ウェイクアップ型プラットフォームにこの層が必要な理由
Section titled “ウェイクアップ型プラットフォームにこの層が必要な理由”DuDuClawの中核的な対話モデルは「AI社員があなたのメッセージに応答する」ですが、 逆方向が必要なシナリオもあります——AI社員が絶えず変動する外部の数値(株価、在庫量、 あるプログラムの出力)を見張り、本当に異常が現れたときだけ自分から発言する、という ものです。これを実現する方法はこれまで2つしかなく、どちらも良くありませんでした:
方法1:クラウドLLM自身に定期ポーリングさせる。 ポーリングのたびに1回のLLM呼び 出しが発生し、ほとんどの場合データは変化していないか閾値を超えていません。何も 起きていない画面をAI社員にお金を払って見張らせているのと同じです。
方法2:独立したスクリプトをハードコードして別途動かす。 判定ロジックがAI社員の ルールシステムから切り離され、既存のautopilotサーキットブレーカーで保護できず、 統一された観測インターフェースもなく、問題が起きたら自分でログを調べるしかありま せん。
哲学的には、これはSystem 1/System 2の分業に対応します:Rustルール+オプションの
ローカル小型モデルは、安価で常駐し疲れないSystem 1であり、ルールが「これは注目に
値する」と判定したときだけ、高価で推論能力のあるクラウドのSystem 2を起動します。
cep_matcher.rsの既存原則は変わりません——時系列判定は常にdeterministicなプログラム
ロジックであり、LLMに推測させることはありません。
アーキテクチャ
Section titled “アーキテクチャ”TickSource(http_poll / command / file_tail / websocket、ソースごとに1つのtokioタスク) │ json_fieldsを抽出 → deltaフィールドを導出 → リングバッファに書き込み ▼autopilotイベントバス(AutopilotEvent::Tick、task/channel/cronなどのイベントと同一バス) │ ▼deterministicルールマッチング(all/any + eq/neq/in/not_in/gt/gte/lt/lte/contains) │ CEP時系列ルールも使用可(「価格がラインを割った後60秒以内に回復シグナルなし」 │ のようなイベント横断の時系列判定) ▼サーキットブレーカー(既存の三状態ブレーカーを流用。高頻度の設定ミスでも爆発しない) │ ▼(オプション)ローカルモデルによるスクリーニング —— YES/NOだけを尋ね、絶対にクラウドへ出ない │ NO / タイムアウト / 判定不能 → on_unavailableポリシーに従い通過または遮断 ▼delegateがクラウドAI社員を起こす(プロンプトに直近の観測ウィンドウを添付可能)各段階は独立して無効化できます:ソースを設定しなければこのバスは完全に沈黙します。
screenを付けなければルール命中で直接起こします(他のイベントタイプのルールと同じ
挙動)。screenがNOと判定すればウェイクアップはそこで止まり、delegateすら実行
されません。
設定:config.toml [tick]
Section titled “設定:config.toml [tick]”マスタースイッチはデフォルトで無効。4種類のソースをそれぞれ1つずつ例示します:
[tick]enabled = false # マスタースイッチ、デフォルト無効。有効にしなければ既存インストールに一切影響しないallow_command_sources = false # commandソースのグローバルゲート、fail-closeddns_ttl_secs = 60 # 内部ネットワークチェックを通過したDNS解決結果を再利用できる秒数。0 = 毎回再解決
# ── http_poll:定期的にURLをGETする ──────────────────────────[[tick.sources]]id = "twse-2330" # ^[a-z0-9][a-z0-9-]{0,63}$kind = "http_poll"enabled = trueinterval_secs = 10 # 下限1秒。それ未満の値は引き上げられるurl = "https://example.invalid/quote" # 既存のSSRFチェックを通過する(localhost/内部ネットワーク/クラウドmetadataを拒否)headers = { "X-API-Key" = "キーをここに直接書く" } # オプション、最大8個。値はいかなるログ・APIにも現れないjson_fields = { price = "/data/price", vol = "/data/volume" } # フィールド名 → JSON pointeremit_unchanged = false # 内容が変わらなければイベントを発行しない(デフォルト)max_events_per_minute = 120 # ソースごとのレート上限。超過分はドロップされカウントされるpersist_every_n = 0 # 0 = events.dbに書かない(デフォルト)。N = Nティックごとに1件の監査記録を保存baseline_max_age_secs = 3600 # 変化量比較ベースラインの有効期限(秒)。0 = 無期限
# ── command:コマンドを実行し、stdoutをpayloadとして扱う ──────────[[tick.sources]]id = "custom-feed"kind = "command"enabled = trueinterval_secs = 30command = ["sh", "-c", "curl -s https://example.invalid/api"] # argv配列、shellの文字列解析を経由しないjson_fields = { level = "/level" }max_events_per_minute = 60persist_every_n = 0
# ── file_tail:ファイルに追記された行を追跡する ─────────────────────────[[tick.sources]]id = "trade-log"kind = "file_tail"enabled = trueinterval_secs = 5path = "~/logs/trades.jsonl" # 読み取り時にcanonicalizeされる。パスは実在しなければならないjson_fields = { symbol = "/symbol", qty = "/qty" }max_events_per_minute = 120persist_every_n = 0
# ── websocket:接続を張りっぱなしにし、テキストメッセージ1件が観測1件 ────────[[tick.sources]]id = "quote-stream"kind = "websocket"enabled = trueurl = "wss://example.invalid/stream" # ローカル以外のホストは必ずwss://(下記説明を参照)interval_secs = 5 # websocketはポーリングしない。この値は「再接続バックオフの起点秒数」として使われるsubscribe = ['{"op":"subscribe","topic":"quotes"}'] # 接続後に順番に送信される原文メッセージ、最大8件、1件≤4KBheaders = { "X-API-Key" = "キーをここに直接書く" } # オプション、WebSocketアップグレードリクエストに付与されるping_interval_secs = 30 # 30秒間何のメッセージも受信しなければ自発的にpingを送る。0 = 無効、それ以外は最小5idle_timeout_secs = 300 # pongを含め300秒間何も受信しなければ接続を回収して再接続。0 = 無効、それ以外は最小30json_fields = { price = "/data/price" }max_events_per_minute = 120 # ストリームは最も溢れさせやすいソース。この上限は必ず残すことpersist_every_n = 0id/json_fieldsのフィールド名には予約語があります:event/source/ts/
kindは使えず、prev_/delta_/pct_で始まる名前も使えません(この3つの
プレフィックスは下記D2自動導出フィールド専用です)。違反したソースは設定読み込み時に
無効化されwarnが1件記録されますが、ゲートウェイ全体の起動を巻き込むことはありま
せん——他の合法なソースは通常どおり動作します。
websocketソースについて知っておくべき6つのこと
Section titled “websocketソースについて知っておくべき6つのこと”最初の3種類のソースはいずれも「時間が来たら1回取りに行く」方式ですが、websocketは
接続を張りっぱなしにして相手のプッシュを待ちます。システムに入った後の処理は完全に
同じです:テキストメッセージ1件がpayload 1件であり、同じパイプライン——JSON解析
→ json_fields抽出 → delta導出 → 重複排除 → レート上限 → リングバッファ+イベント
バス——を通ります。違いはデータの取得方法と、以下の6点だけです:
- URLルールはhttp_pollより厳しい。
ws://とwss://のみ受け付けます。ローカル マシン以外のホストは必ずwss://で、平文ws://は127.0.0.1/localhost/::1のみ許可されます(ローカルアダプタープロセスのシナリオ)。schemeチェック通過後、 ホストはhttp_pollと同一のSSRF検証をもう一度通ります(内部ネットワーク帯、 link-local、クラウドmetadataホストは一律拒否)。不合格のソースは設定読み込み時に 無効化されます。 interval_secsはバックオフの起点になる。 接続切断後はmax(1, interval_secs)秒待って再接続し、失敗するたびに倍増、上限60秒、さらに最大25%のランダムジッター が加わります。接続が60秒持続すれば健全とみなされ、次の切断時のバックオフは起点 から再計算されます。接続できない、または接続中にエラーが起きた場合はfetch_errorドロップカウントに計上されます。- バイナリメッセージは一律ドロップしてカウントする。 このパイプラインはテキスト
しか食べません。binary frameを受信すると
non_textドロップが1件記録され (ダッシュボードとtick_dropped_totalの両方で見えます)、接続自体は通常どおり 継続します。回線全体にデータが来ないのにnon_textが増え続けるなら、ソースが バイナリ形式をプッシュしているということです。 subscribeは原文であり、テンプレート置換はしない。 接続後に順番に送信され、 最大8件、1件4096 bytesです。tokenを載せたい場合は文字列に直接書いてください (config.toml自体が機密ファイルです)。- アイドルウォッチドッグが「つながっているがデータがない」を捕まえる。 TCP接続は
相手がプッシュを止めた後も何時間も「開いた」状態を維持でき、接続状態だけ見ると
完全に正常で、tickだけが止まります。そのため2つの時計が同時に動いています:
ping_interval_secs(デフォルト30、0で無効)——連続してこの秒数何のメッセージも 受信しなければ自発的にWebSocket pingを送る。idle_timeout_secs(デフォルト300、0で無効)——pongを含め一切の受信メッセージがこの秒数なければ、warnを1件記録 して接続を回収し、即座に再接続する(バックオフを待たない。再接続に失敗した 場合のみ上記2のバックオフに入る)。あらゆる受信メッセージ(テキスト、バイナリ、 ping、pong)は両方の時計をゼロに戻します。両方を有効にする場合、idle_timeout_secsはping_interval_secsより大きくなければなりません。さもないと pingへの応答が届く前にアイドル判定されてしまいます——逆に設定したソースは設定 読み込み時に無効化されます。さらに両者には下限があります:ping_interval_secsは 非0時最小5、idle_timeout_secsは非0時最小30。下限未満のソースは無効化され、 自動的に引き上げられることはありません——回収パスは即時再接続であり(静かな feedは失敗ではないためバックオフに入らない)、タイムアウト値そのものが「相手が 永久に沈黙しているときの再接続間隔」の唯一の上界だからです。1秒に設定するのは 自分で再接続ファンを作るのと同じであり、黙って30に引き上げてしまえば、自分の設定 ミスに気づけなくなるだけです。 headersはアップグレードリクエストに付与される。Authorization/X-API-Keyのような認証が必要なfeedは、ソース設定に直接書けば(ルールは次節を参照)、自分で ローカルアダプタープロセスを立てる必要はもうありません。
1件あたり64KB上限とmax_events_per_minuteレート上限は、他のソースと同一の実装を
共有します。ストリームは数秒で数万件を流し込みやすい最たるソースです。レート上限は
ハードな前提条件として扱ってください。
カスタムheaders(http_pollとwebsocket)
Section titled “カスタムheaders(http_pollとwebsocket)”headers = { "X-API-Key" = "…" }は、毎回のhttp_pollのGETとwebsocketの
アップグレードリクエストに付与されます。commandとfile_tailには付与すべき
リクエストがないため、書いても設定読み込み時に除去されます(クレデンシャルが理由も
なく持ち回られるのを防ぐため)。制限は以下のとおりで、違反したソースは即無効化され
ます:
| 項目 | 制限 |
|---|---|
| 個数 | 最大8個 |
| 名前 | ^[A-Za-z0-9-]{1,64}$ |
| 予約名 | Host/Content-Length/Connection/Upgrade/Transfer-Encoding/Sec-WebSocket-*は一律拒否(これらはトランスポート層が自ら生成するもので、上書きされると接続を直接壊すかハンドシェイクを偽造してしまう) |
| 値 | 最長1024 bytes、可視ASCIIのみ——CR/LF、制御文字、非ASCIIは一律拒否(CR/LFはリクエストに2組目のheaderを挿入でき、典型的なheader injectionである) |
値は一律クレデンシャルとして扱われます:いかなるログにも書かれず(debugレベル
でさえ)、ticks.sources APIにも現れず(そこではheaders_countという個数だけを
返す)、ウェイクアッププロンプトにも持ち込まれません。設定が拒否されたときのwarn
メッセージはheader名のみに言及し、値をエコーバックしません。
例外ルールが2つあります:User-Agentは自分で指定できます(デフォルトの
DuDuClaw/1.0を上書きします)が、Metadata-Flavor: noneは常に強制付与されます——
これはクラウドmetadataエンドポイントを防ぐ防御であり、設定で上書きできる利便性
オプションではありません。
ネットワークセキュリティ:SSRFチェックとDNS re-pin
Section titled “ネットワークセキュリティ:SSRFチェックとDNS re-pin”http_pollのURLとwebsocketのローカル以外のホストは、設定読み込み時に一度SSRF
チェック(localhost/内部ネットワーク帯/link-local/クラウドmetadataホストを拒否)
を通るのに加え、リクエスト送信または接続確立のたびにその場でDNSをもう一度解決
します:
- そのホストが解決したすべてのIPが公開アドレスでなければならず、1つでも内部 ネットワーク/loopback/link-localに落ちれば一式まるごと拒否します(「公開の方を 選んで接続する」ではありません——半分だけの成功は防御していないのと同じです)。
- 通過後、接続はたった今検証されたそれらのIPに直接固定(pin)されます:
http_pollは reqwestのアドレスピンニングを使い、websocketはそれらのIPへ直接TCP接続を確立し、 TLSのSNIと証明書検証は元のホスト名のまま行われます(したがってピンニングを証明書 チェックの回避に使うことはできません)。 - 解決に失敗した、または内部ネットワークIPが含まれる場合:リクエストを送信せず、
fetch_errorを1件計上します。
防いでいるのはDNS rebindingです——設定時には公開IPに解決されるのに、実際の接続時には
169.254.169.254を指すように切り替える攻撃です。さらにhttp_pollはHTTPリダイレクト
に一切追従しません(チェックを通過したURLが内部ネットワークへ302するのが最も典型的な
迂回路です)。コネクションプールは「解決結果が変わっていない」ことを条件に再利用
されるため、毎回再検証しながらも毎秒TLS接続を作り直すことはありません。
ローカルのws://127.0.0.1という平文パスはre-pinを通りません:そのアドレスはもともと
内部ネットワークであり、rebindされる対象もありません。これはローカルアダプター
プロセスのシナリオのために意図的に残された特権です。
解決結果はdns_ttl_secs秒キャッシュされます(グローバル設定、デフォルト60。
0 = 毎回再解決)。 1秒ポーリングのソースが毎回DNSを引くと1日8万回超のクエリに
なりますが、返ってくる答えは1時間以内ならほぼ同じです。そこで各ソースのタスクは
「host:port → 内部ネットワークチェックを通過したアドレス集合+有効期限」を自分で
1部保持します(タスク内プライベート、ロック不要):TTL内はピン済みアドレスを直接
再利用し(http_pollはピン済みclientの再構築すら不要、websocketは再接続時に1回の
解決待ちを節約)、期限が切れて初めて再解決・再スクリーニングします。
これはrebinding防御を弱めません。方向はむしろ逆です:解決の答えをひっくり返す
には新鮮な解決が一度必要であり、キャッシュはその機会を増やすのではなく減らします。
しかもキャッシュされているのは検証済みの公開アドレス集合であって、検証待ちの
名前ではありません。最悪のケースは相手が本当に引っ越した場合で、旧アドレスへの接続が
失敗し、次のラウンドで再解決されます。このTTLは監視ソースにのみ作用し、web_fetchの
既存セマンティクスには影響しません。
抽出値が数値文字列なら自動的に数値へ変換
Section titled “抽出値が数値文字列なら自動的に数値へ変換”主流の相場feed(Kraken、Binance)の価格フィールドの型は文字列です。例えば
"last": "63669.60000"。抽出層は「クリーンな数値文字列」をJSON数値に変換します。
これによってprice gt 60000のようなルールが書けるようになり、下記の変化量
フィールドも計算できるようになります。
変換は保守的です。識別子を壊すくらいなら変換しない方を選びます:
| 入力 | 結果 |
|---|---|
"63669.60000"、"42"、"-3"、"0"、"0.5"、" 7.5 "、"1e3" |
数値に変換(整数はi64範囲内なら整数のまま。eq 42は引き続き成立) |
"007"、"-007"、"00.5" |
変換しない——先頭ゼロは通常ゼロ埋めの注文番号 |
"+5" |
変換しない——feedが価格をこう書くことはない |
"inf"、"NaN"、"1e400" |
変換しない——非有限値は以後のすべての比較を汚染する |
"12 USD"、"1,000.5"、"0x10"、".5"、"台積電" |
変換しない——文字列全体が完全に数値として解析できなければならない |
もともと数値、真偽値、nullのフィールドは一律原形のまま保持され、このルールの影響を 受けません。
設定フィールドを1つも解決できないメッセージは観測として扱わない
Section titled “設定フィールドを1つも解決できないメッセージは観測として扱わない”json_fieldsを設定し、payloadも確かにJSONであるのに、設定したpointerが1つも一致
しない場合、そのpayloadはドロップされ、新しいno_fieldsドロップ理由に計上されます:
イベントバスに入らず、リングバッファを占有せず、ウェイクアップウィンドウに混ざり
ません。実際のfeedの制御メッセージはまさにこの形をしています——Krakenの
{"channel":"heartbeat"}は相場の間に挟まっており、それ自体に価格はありません。初期
バージョンはこれを空フィールドの観測として発行してしまい、その結果、内容のない
イベントにルールが繰り返し起こされ、リングバッファがハートビートで埋め尽くされ
ました。
影響を受けないケースが2つあります:
json_fieldsを設定していないソースの挙動は完全に不変です(従来どおりraw_len+原文ダイジェストを発行)——もともとフィールド抽出で動くソースでは ありません。- payloadがそもそもJSONでない場合(例えばエンドポイントがHTMLエラーページを
返した場合)も従来どおり
raw_len+原文ダイジェストを発行し、no_fieldsには 計上されません。これは意図的です:「あなたのfeedがJSONを吐かなくなった」ことは ダッシュボード上で内容として見えなければならず、カウンターが増えるだけになっては いけません。
ドロップカウントは従来どおりtick_dropped_total{reason="no_fields"}とダッシュ
ボードのドロップ内訳に現れます。no_fieldsが増え続けるのにevents_emittedが動か
ない場合、通常はpointerパスの書き間違いか、このソースがほとんどの時間制御メッセージ
をプッシュしていることを意味します。
自動導出される変化量フィールド(新しい演算子を学ぶ必要なし)
Section titled “自動導出される変化量フィールド(新しい演算子を学ぶ必要なし)”数値型の抽出フィールドごとに、前回出現したときの値が残ってさえいれば、システムは
自動的に3つのフィールドを追加します:prev_price(前回の値)、delta_price(差分)、
pct_price(変化率%)。一度も出現したことのないフィールドには比較対象がないため、
この3フィールドは0ではなく「欠席」になります——欠席したフィールドはいかなる比較
条件も満たさないので、ルールを誤トリガーしません。prevが0のときはpct_
フィールドも欠席になります(ゼロ除算の回避)。
計算される2つのフィールド(delta_/pct_)はどちらも小数第6位に丸められ、
prev_は原値を保持します。 浮動小数点の減算は末尾数桁にノイズを残します:
63724.8 - 63724.7の生の結果は-0.10000000000582077であり、この値がルールに入る
と、delta_price gt 0.1がノイズによって命中に反転してしまいます——実際にはわずか
0.1動いただけなのに。丸めた後はクリーンな-0.1です。小数6桁は意図的に残した予算で
あり、0.000002のような本当に小さな変動も完全に保持されます。prev_はfeed自身が
報告した値であり、一切加工しません。整数フィールドのdelta_は整数を維持し
(delta_vol eq 200は引き続き成立)、浮動小数点に変換されることはありません。
比較ベースラインは「フィールドごと」であり、「前のtick」ではありません。
prev_priceが取るのは、priceというフィールドが前回実際に値を持っていた回であり、
間にpriceを持たない観測が何件挟まっても影響しません。これは実際の相場ストリームでは
決定的です:Krakenは相場の間に{"channel":"heartbeat"}を挟んできます。初期
バージョンは「レコード全体を上書きする」ベースラインを使っていたため、ハートビートが
来るたびに価格の比較ベースラインが洗い流され、実測で9割のtickが変化量を計算でき
ませんでした。同じ理屈で、priceだけを持つ観測はvolのベースラインを動かさず、
その逆も同様です。
ベースラインは期限切れになります:baseline_max_age_secs(デフォルト3600秒、
0 = 無期限)。 フィールドごとのベースラインには副作用があります——あるフィールドが
丸一日報告を止めると、翌日の最初の1件は一日前の古い値を基準に巨大な偽の変動を計算
してしまい、ルールには「本物の急騰」と「途中で一日切断されていた」の区別がつきま
せん。そこで各フィールドのベースラインにはタイムスタンプが付き、有効期限を超えると
まるごと破棄されます:その1件のprev_/delta_/pct_はすべて欠席になり(最初の
tickと完全に同じセマンティクス)、現在の値でベースラインを立て直し、次の1件から通常
の比較に戻ります。期限切れもフィールドごとに計算され、priceのベースラインが期限
切れになってもvolには影響しません。
有効期限に下限はありませんが、次の2つの設定は設定読み込み時にwarnが1件記録
されます(あくまで注意喚起であり、ソースは通常どおり有効化されます):
- 60秒未満:ほぼ永遠に変化量を計算できないソースが出来上がります。機能的には 合法ですが(超高頻度feedが直近数秒だけを比較したいことは確かにあり得ます)、 その壊れた姿が「設定の書き間違い」と見分けがつかないため、静かに起きることを許し ません。
interval_secsより短い(http_poll/command/file_tail):上記の必然 バージョンです——今回のポーリングで立てたベースラインが、次のラウンドが来る前に もう期限切れになっているため、すべてのtickにprev_/delta_/pct_が付き ません。1日1回ポーリングするソースにデフォルトの1時間有効期限を組み合わせると これを踏みます。修正方法は、有効期限をポーリング間隔より大きく設定するか、0に して期限切れを無効にすることです。websocketはこのチェックを行いません:そのinterval_secsは再接続バックオフの起点であって観測間隔ではないため、比較しても 意味がありません。
本当に期限切れを無効にしたいなら、明示的に0と書いてください。
Autopilotルールの例
Section titled “Autopilotルールの例”「上昇率が2%を超えたらトレーダーエージェントを起こす」ルールです。conditionsは
導出されたpct_priceフィールドを直接使い、文字列処理は一切書きません:
{ "name": "twse-2330 上昇率アラート", "enabled": true, "trigger_event": "tick", "conditions": { "all": [ { "field": "source", "op": "eq", "value": "twse-2330" }, { "field": "pct_price", "op": "gt", "value": 2 } ] }, "action": { "type": "delegate", "target_agent": "trader", "prompt": "TSMCの株価が短時間で2%超上昇しました。直近の値動きを確認し、行動が必要か判断してください。", "context_ticks": 15, "screen": { "mode": "local", "prompt": "これが本当に異常な変動である場合(通常の日中の揺れではない場合)のみYESと答えてください", "on_unavailable": "pass", "timeout_secs": 10 } }}context_ticks(任意、デフォルト10、上限50、0で無効):delegateでウェイク アップする際、プロンプト末尾にそのソースの直近N件の観測値のコンパクトなダイジェスト を添付します。AI社員が見るのは孤立した1つの数値ではなくトレンドです。screen(任意):ルール命中・サーキットブレーカー通過の後、まずローカル小型 モデルにYES/NOの質問を1つ尋ね、YES(またはローカルモデル利用不可時のon_unavailableポリシーによる通過)の場合のみ実際にdelegateを実行します。詳細は 下記「ローカルスクリーニング層」の節を参照。screenはtick専用ではありません——ルールレベルの汎用フィールドであり、どのtrigger_eventにも付けられます。本ドキュメントがtickシナリオを中心にしている だけです。
ダッシュボードでの観測
Section titled “ダッシュボードでの観測”システム設定 → Autopilotタブに読み取り専用カード「リアルタイム監視ソース」(15秒 ごとに自動更新)が追加されました。裏側はadmin専用のRPC 2つです:
ticks.sources:現在設定されている各ソース(kind/enabled/interval_secs/max_events_per_minuteを含む)とリアルタイム状態 (last_tick_ts、おおよそのevents/分、累計発行数、6種類のドロップ理由別カウント:rate_cap/unchanged/oversize/fetch_error/non_text/no_fields)、 さらにグローバルなローカルスクリーニングの通過/遮断/判定不能の3カウントを返し ます。カスタムheadersはheaders_countという個数だけを返します——headerの値は クレデンシャルであり、いかなるAPIも吐き出しません。ソースは存在するがこの プロセス内で一度もtickを発行していない場合は、全ゼロのスナップショットが返り、 エラーにはなりません。ticks.recent:単一ソースの直近の観測値を取得します(source必須、limit上限50件)。時系列の古い順に並び、カードを展開すると各件のタイムスタンプと フィールド内容が見られます。
カード自体は読み取り専用です——ソースの追加や有効化はconfig.tomlを編集する操作で
あり、ダッシュボード上で押せるボタンではありません。
Prometheusメトリクス
Section titled “Prometheusメトリクス”既存の/metricsエンドポイント配下で提供されます:
| メトリクス | ラベル | 説明 |
|---|---|---|
tick_events_total |
source |
正常に発行されたtickイベントの累計数 |
tick_dropped_total |
source, reason |
拒否されたpayload。reasonはrate_cap/unchanged/oversize/fetch_error/non_text(websocketのバイナリメッセージ)/no_fields(設定フィールドを1つも解決できなかった)のいずれか |
tick_screen_total |
outcome |
ローカルスクリーニング結果。outcomeはpass/drop/unavailableのいずれか |
tick_wakes_total |
rule |
そのルールがtickでトリガーされ実際にアクションを実行した(サーキットブレーカーとスクリーニングを通過した)回数。ルール名ではなくrule_idを使用(ユーザー定義テキストがエスケープされないPrometheusラベルになるのを防ぐため) |
ローカルスクリーニング層
Section titled “ローカルスクリーニング層”ルール命中・サーキットブレーカー実行許可の後、action.screenが存在する場合、実際に
delegate/notify/run_skillを呼ぶ前に、まずローカル推論エンジンに二者択一の
質問を1回尋ねます。この層の存在目的はクラウド呼び出しの費用を節約することなので、
いくつかのハードな制限があります:
-
ローカル推論のみを通り、絶対にクラウドへ出ない。 この層はアカウント ローテーターにもクラウドAPIクライアントにも決して触れません。クラウドの判断が 不要なら、そもそも
screenを設定せず、ルール命中で直接delegateさせてください。 -
判定文字列は非常に厳格。 応答の最初の空白区切りtokenだけを見て、先頭末尾の ASCII句読点を剥がした後、(大文字小文字を区別せず)
yesまたはnoに完全一致 しなければなりません。したがってNO.、**YES**、"yes"は解析できます。I think yes(判定語が最初のtokenではない)、maybe、句読点のみ、CJK引用符で 囲まれた「YES」は、一律「解析不能」とみなされます。この厳格ルールは2026-08-11の 受け入れ検証時に修正されたものです:初期バージョンは小型モデルが付けるピリオド すら判定できず、毎回fail-openに落ちて、本来節約するはずだったクラウド呼び出しを 起こしてしまっていました。 -
判定不能時はデフォルトで通過(fail-open)。 ローカルエンジンが未接続、タイム アウト、または応答からYES/NOを解析できない場合、デフォルトでは一律「通過」として 扱います。deterministic条件はすでに命中しており、スクリーニングは節約のための 第二ゲートに過ぎないからです。保守的に変えたい(判定不能なら起こさない)場合は、
on_unavailableを"drop"に設定してください。 -
timeout_secsの範囲は1–60秒、デフォルト10秒。
-
OpenAI互換バックエンドを使う場合、
inference.tomlのトップレベルdefault_modelを必ず一緒に設定すること。[openai_compat]セクション内にmodelを書くだけでは 不十分です——推論エンジンが取るのはトップレベルのdefault_modelであり、未設定だとNoModelLoadedが返り、スクリーニングは「利用不可」と判定され、fail-openデフォルト に従ってまるごと通過します。この一部始終はdebugログ1行しか残さず、外見上は スクリーニングを通過したのと完全に同じですが、実際にはモデルに一度も尋ねていま せん。設定は次のようになります:inference.toml default_model = "qwen2.5:7b" # ← この行がないと、スクリーニングは永遠に静かに利用不可になる[openai_compat]base_url = "http://127.0.0.1:11434/v1"model = "qwen2.5:7b"スクリーニングが本当に動いているか確認するには、ダッシュボード「リアルタイム 監視ソース」カードのスクリーニング3カウントを見てください:ずっと「判定不能」に 落ちているなら、まさにこの問題です。
運用上の注意
Section titled “運用上の注意”file_tailは起動時にファイル末尾(EOF)から数え始め、履歴を再生しない。 これは 意図的な設計です:ゲートウェイの再起動が既存ログ全体を新しいtickの波として炸裂 させるべきではありません。代償として、ゲートウェイ停止中に追記された行は読み戻され ません。- tickイベントはデフォルトで
events.dbに書かれない。 1秒1件は1日8万行超になり、 汚すぎます。直近の履歴はメモリ内リングバッファ(ソースごとに256件)にのみ生き、 ゲートウェイ再起動でクリアされます。監査証跡を残したいソースは、自分でpersist_every_nを設定してください(例えば10なら10件ごとに1件保存)。 - アイドル回収はドロップではなく、ドロップカウントに現れない。 WebSocketの
アイドルタイムアウト回収が記録するのは
warn1行(recycles_totalを含む)であり、 意図的に新しいカウンターを追加せず、fetch_errorも借用しません——静かなfeedと 壊れたエンドポイントは別物であり、同じ数字に混ぜると誤解を招くだけです。ソースが 空転しているか判断するには、ダッシュボードのlast_tick_tsを見てください。 commandソースにはグローバルスイッチが必要。 あるソースにkind = "command"と 書くだけでは有効になりません——[tick] allow_command_sourcesを明示的にtrueに 設定する必要があります。これはfail-closed設計です:単一ソースの設定だけでは任意 プログラムの実行を認可するのに不十分です。- ローカルスクリーニングが有料エンドポイントを指していれば、やはり費用が発生する。
D4が保証するのは「このコードパスがアカウントローテーターやクラウドAPIクライアント
を決して呼ばない」ことですが、ローカル推論エンジンがサポートするOpenAI互換HTTP
バックエンド(
inference.toml [openai_compat] base_url)自体は、オペレーターが 任意の互換エンドポイント——本物の有料クラウドAPIを含む——へ向けられます。このbase_urlを課金サービスに向ければ、スクリーニングの呼び出しごとに本当にお金が かかり、「限界費用ゼロ」という設計前提は成り立たなくなります。ローカルバックエンド が本当にローカルまたは自前ホストのサービスであることを確認してください。 - スクリーニング判定は厳格なYES/NO先頭語しか認めない(前節を参照)。オペレーター プロンプトがモデルに長い説明を書かせやすい場合、スクリーニングは実際に遮断する のではなく「判定不能」に頻繁に落ちます。プロンプトには「一語だけで答える」ことを 直接要求してください。
- ウェイクアップウィンドウは「直近N件の観測」であり、「トリガー時点のスナップ
ショット」ではない。 同じポーリングラウンドで複数行を読み込んだ場合(
file_tailが一度に数行読む、commandが一度に複数件吐くなど)、それらの行は連続してリング バッファに書き込まれるため、ウェイクアッププロンプトに添付されるウィンドウには 「トリガーした件より後に到着した」tickがすでに含まれている可能性があります。これは 意図的なセマンティクスの選択です:起こされたAI社員が見るのは最新の現況であって、 数秒古くなった値ではありません。「どの値がルールをトリガーしたのか」が必要なら、 ルールテンプレートフィールドで取得してください(例:{price}、{pct_price})—— テンプレートは常にトリガーイベント自体のフィールドをレンダリングします。
未実装の部分
Section titled “未実装の部分”- クエリ署名式認証:
headersはAuthorization/X-API-Keyのようなheader認証を カバーしますが、「秘密鍵でquery stringに署名し、署名に有効期限がある」ことを要求 するfeed(一部の取引所相場API)は、依然として自分でローカルにアダプタープロセスを 立て、ws://127.0.0.1:...で接続する必要があります。 - スクリーニング層のクラウドfallback:明確にやりません——この層の存在目的はクラウド コストを防ぐことであり、クラウドへの退路を加えるのは自己否定になります。
- 相場専用パーサー:現状は
commandで既存ツールを接続しjson_fieldsで抽出すれば 十分であり、取引所固有の組み込み解析ロジックは別途作っていません。
関連ドキュメント
Section titled “関連ドキュメント”- Autopilotルールエンジン本体:
23-autopilot-engine.md