コンテンツにスキップ

チャットブリッジ

チャットブリッジは、外部のチャット製品 — 現時点では Slack — を、Control Center ワークスペースへの第一級の入口にします。ボットにメンションすればエージェントが起動し、Slack スレッドで返信すれば会話が続き、スラッシュコマンドを入力すればチケットが起票されます。その土台にあるのは、デスクトップアプリが使うのと同じスペース、トランスクリプト、チケット、そして帰属です。チャットはワークスペース上のサーフェスであり、そのコピーではありません。

出荷されているプロバイダーは Slack だけです。 製品のどこにも Discord、Telegram、Teams、Matrix の実装はありません。この機能は意図的にプロバイダー非依存で書かれています — 2 つ目の製品を追加するのは、1 つの列挙値、1 つのアダプター、1 つのプラグインだけです — が、現時点で「チャット」といえば Slack を指します。

これを担う 2 つのサーフェスは、それぞれ別の利用者に属します。ワークスペースを Slack に接続することとボットをカスタマイズすることは、設定 → ワークスペース → 全般にある管理者の操作です。自分のチャットアカウントを自分の Control Center アイデンティティにリンクすることは、設定 → ワークスペース → プロフィールと識別情報にあるメンバーの操作です。

機能全体を形作る決定は 3 つあります。接続がどちらの方向に張られるか、外部から届いたメッセージを何が認可するか、そして製品固有の知識をどこに置いてよいかです。

ブリッジがプロバイダーからの接続を待ち受けることはありません。Slack へは Socket Mode で到達します。サーバーは Slack にソケット URL を要求して外向きに接続し、その接続を通じてメンション、DM、スラッシュコマンドを受け取ります。

その帰結こそが要点です。NAT の背後にあるノートパソコン、ドメインを持たない自宅サーバー、ネットワーク間を移動するマシン — そのいずれもが、インバウンドポートもトンネルも TLS 証明書も、変化のたびに更新すべき公開 URL も必要とせずにブリッジを実行できます。守るべき webhook エンドポイントが存在しないのは、そもそも webhook エンドポイントが存在しないからです。Control Center がアプリ定義を組み立てるとき、Socket Mode を表明し、リクエスト URL はすべて取り除きます。両方に設定されたアプリは半分しか動かないアプリになるからです。

その代償は、維持し続けるべき接続であり、これはサーバーの役割です。再接続は、30 秒を上限とするジッター付きの指数バックオフで行います。プロバイダー主導のソケットローテーションでは、そのバックオフをエスカレートさせずに再接続します。そして拒否された資格情報は終端です — 失効したトークンを永遠に再試行しても得られるのはレート制限だけなので、ブリッジは停止し、プロバイダー自身のエラーを設定画面にそのまま表示しながら、新しいトークンを待ちます。

チャットアプリを持たないワークスペースには、まったくコストがかかりません。ソケットもタイマーもトラフィックもありません。そして全体は、サーバーの準備完了バナーが表示された後に始まります。応答の遅いチャットアプリが起動を遅らせてはならないからです。

認可するのはメンバーシップであり、トークンの所持ではない

「認可するのはメンバーシップであり、トークンの所持ではない」というセクション

アプリをインストールした人物は、メッセージを送る人物ではありません。そのため、すべての受信イベントは何かが起こる前に プリンシパル に解決され、説明とともにフェイルクローズします。

  • リンクされていないチャットアカウントには、その書き込み先の Slack スレッドで、リンク方法が伝えられます。
  • リンク済みだが、そのユーザーがこのワークスペースのメンバーではないアカウントには、管理者に招待を依頼するよう伝えられます。
  • 読み取り専用のロール(viewer、guest)を持つメンバーには、そのロールでは作業を開始できないと伝えられます。

いずれも先には進みません。チャットメッセージが、送信していない人物に帰属されることは決してありません — これにより、監査証跡、プリンシパル単位のレート制限、チケットの報告者が正しさを保てます。ここで依拠しているメンバーシップモデルについては、マルチプレイヤー — アイデンティティ、メンバーシップ、プレゼンスを参照してください。

リンクはアイデンティティの解決であり、アクセス権の付与ではありません。メンバーシップはイベントごとに再確認されるため、その後削除された人物に属するリンクは、以前の権限ではなく拒否に解決されます。リンクを確立する経路は 2 つあります。メンバーがチャットで入力する、短命で単回使用のコードか、プロバイダーの検証済みメールアドレスとの自動照合です — メールによる経路でも、照合されたユーザーがすでにメンバーである必要があるため、たまたまサーバー上にアドレスが存在するだけの部外者は、何にもリンクされません。

コードは意図的にメモリ上に保持され、有効期間は 15 分です。永続化すると、消えるはずのもののために、資格情報の形をした耐久性のある行を作ってしまいます。サーバーの再起動で未使用のコードがすべて無効になるのは、退行ではなく正しい動作です — メンバーはもう一度「リンク」を押せばよいのです。

拒否は、メッセージが開いた Slack スレッド内で、本物の返信として答えます。スレッド内の返信は、返信先のメッセージにバッジを付け、それを必要とするただ一人に届き、しかも「リンクされていません」をチャンネル自体には出しません。メッセージの投稿は、どのプロバイダーも備えている唯一のケイパビリティでもあるため、説明がアプリにない機能に依存することはありません。

双方向にミラーリングするブリッジは、2 つの方向を見分ける仕組みがなければループします。ブリッジが取り込むすべてのメッセージには metadata['chat'] が刻まれます — プロバイダー、チーム、会話、スレッド、メッセージ、送信者です。外向きのミラーは、その刻印を持つメッセージをスキップします。

この単一のルールが、双方向ミラーリングを安全にします。Control Center で入力された人間のメッセージは、リンクされたスレッドへ外向きに中継され(作成者の名前が先頭に付きます)、一方、チャットから来たメッセージは入ってきた場所で止まります。

外向きに渡るものは意図的に狭く限定されています。エージェントの回答は、通常のチャットテキストとして中継されます。短い思考はカードの Thinking… 行に表示されることがあります。ツール出力は diff の壁であり、Control Center にとどまります — 切り詰められた返信が指し示す先もそこです。

カードは作業を報告し、テキストが依然としてメッセージ

「カードは作業を報告し、テキストが依然としてメッセージ」というセクション

プロバイダーがそれを描画する場合、1 つのターンは1 枚のタスクカードを伴い、ターンが実行されている間ずっとその場で編集され、説明対象の回答の上に位置します。

カードは 1 つのまとまったプランであり、ごちゃ混ぜの段落ではありません。Slack は同じタスク上の details と output を連結するため、行の詳細(Thinking… 上の思考)は 1 回だけ送信され、output がストリーミングされることはありません。セットアップは 1 行で、そのタイトルが置き換わります(Working on it…、Cloning …)。推論が始まると Thinking… 行が追加されます。各ツールはそれぞれ独自の行です(Read lib/main.dart)。回答はカードの下の通常のテキストです — Slack のプランビューはそれを、タスクに混ぜ込まれたチャンクからではなく、後続の markdown 追記から読み取ります。それ以外は何もありません。ツール出力はなく、短い許可リストにあるツール引数のみです。未知のツールの引数はファイル本文全体になり得ますが、行は 1 行だからです。

カードは、ターンより前の待ち時間もカバーします。最初のメンションが会話を作成し、その作成はエージェントが作業するすべてのリポジトリをクローンします — 実際のリポジトリではこれが数分かかり、その間エージェントにはまだ何も尋ねられておらず、スレッドからはボットが要求を無視したように見えます。そこでカードは、メンションが受け入れられた瞬間に開き(Working on it…)、その後セットアップ行がデスクトップと同じ文言で更新されます — Cloning acme/widgets…、Setting up Reviewer…、Starting the agent… — 続いてエージェントが推論を始めるとすぐに Thinking… 行、そしてツールごとに 1 行。1 枚のカード、1 つのメッセージで、「聞こえています」から、下に通常のテキストとして続く回答までです。セットアップが報告されるのは、チャットで誰かがそれを待っている場合だけです。アプリ内で準備中の会話、あるいはサーバー起動時に再開された取り残された会話は、カードなしでプロビジョニングされます。

カードは、チャットが読み手に与えられない唯一のもの、つまり返信の背後にある会話への View in Control Center リンクを運びます。チャット製品はリンク内で http(s) しか受け付けないため、ボタンはこのサーバーが配信する小さなページを指し、そのページがブラウザをデスクトップアプリの control-center:// リンクへと跳ね返します。このページは何も読み取らず、何も解決しません — URL 内の id はディープリンクへとそのまま渡されるだけで、それ以外のどこにも使われません — また、到達可能な公開 URL を持たないサーバーは、壊れたボタンではなく、単にボタンのないカードを生成します。

カードは、ほかのあらゆるものと同じく 1 つのケイパビリティです。カードを持たないプロバイダーは以前とまったく同じに、テキストのみで動作し、呼び出し時にカードを拒否するプロバイダーはストリームを維持します。返信は、それを説明するカードよりも常に優先されるからです。ストリーミングが完全にオフの場合、完成したカードは代わりに、投稿される 1 つのメッセージに載ります。起票されたチケットも同じ扱いです — /cc ticket の確認は完成したカードであり、その存在理由は、作成されたばかりのチケットへのリンクです。

チャット製品は描画できるものが異なるため、ブリッジは最も豊かなサーフェスを前提に書かれます — スレッド内でライブに伸びる返信、一時的な「考え中…」ステータス、タイトルのあるスレッド、スラッシュコマンドへの一時的な回答 — そしてケイパビリティを 1 つずつ縮退させます。どれも持たないプロバイダーでも動作します。返信が単に 1 つのメッセージとして届くだけです。

ケイパビリティを広告することは、それを約束することではありません。Slack のストリーミング返信には有料プランの機能が必要で、Slack は呼び出し時にしか拒否しません。恒久的と印付けられた拒否は、その接続の生涯にわたって記憶されます。ブリッジは要求をやめ、それ以降は返信をまとめて投稿し、設定画面もそれを明示します。「返信のストリーミングが止まった」はログを読まなくても答えられるべきだからです。タスクカードも、Slack がそれを拒否すれば同じように打ち切られます。

すべての上限は中継側ではなくプロバイダーに属します。メッセージの上限、ストリームチャンクの上限、ストリームにスレッドアンカーが必要かどうかは、いずれもアダプターが宣言し、長い返信は語の途中ではなく、段落、行、または語の境界で分割されます。

1 つのブリッジ、製品ごとに 1 つのアダプター

「1 つのブリッジ、製品ごとに 1 つのアダプター」というセクション

製品の形をしたものはすべて、1 つのポートの背後に置かれます。汎用的なコアは markdown、チャット会話、チャットスレッド、メンバーについて知っていますが、Slack のエンベロープ、mrkdwn、ゲートウェイインテントについては何も知りません。それを成立させるのは 3 つの部品です。

  • アダプターは 1 つの製品を正規化します。そのトランスポート、API 呼び出し、テキストコーデック、エンベロープの癖です。Slack のそれらは示唆に富みます — チャンネルでのメンションは 2 回届き(app_mention としてかつ message として)、ほとんどのメッセージサブタイプは新しい要求ではなく装飾です。どちらもアダプター内で解決されるため、コアが Slack の二重配信を知ることはありません。
  • ディスクリプタはプロバイダーの公開された形です。どの資格情報が必要か、その形式は何か、ユーザーがそれを見つけるのに役立つ URL はどれか、何ができるか、そしてそのアプリをここから作成または編集できるかどうかです。設定カード、接続ダイアログ、引数の検証はすべてそこから生成されます — クライアントはプロバイダーと、フィールド id をキーとするマップを送り返します。
  • プラグインはサーバー側の組み立てです。資格情報がどのように検証済みの接続になるか、アダプターがどのように構築されるか、プロバイダー側のアプリがどのように管理されるかです。

つまり、製品を追加するのは 1 つの列挙値、1 つのアダプター、1 つのプラグインだけです。新しい RPC 操作もスキーマ変更もクライアント変更もありません — そしてクライアントは正しくあり続けます。聞いたことのないフィールドでも、サーバーが提供したラベルで描画されるからです。

抽象化が上へ漏れ出さないようにする詳細が 2 つあります。プロバイダーに API がないアプリ管理の手順(トークンの生成、アプリのインストール、権限変更後の再インストール)は、それぞれ独自のリンクを持つ名前付きの手順として返されるため、セットアップ完了画面は、どのプロバイダーかを知らなくてもそれらを表示できます。そして、ブリッジが書き出すあらゆる説明は、プロバイダーと、アプリが実際に使うコマンドを名指しするので、コマンド名が変わっても 2 つ目のプロバイダーでも、新しい文字列なしで正しく読めます。

ワークスペースのチャットトークンは、そのワークスペースの独自のディレクトリ、データベースの隣に置かれます — <dataDir>/<workspaceId>/chat_credentials/<provider>.json にあるプレーンな JSON であり、データベース内ではなく、意図的に OS のキーチェーンにも入れません。後から失われがちな帰結が 2 つあります。ワークスペースを削除するとディレクトリがリンク解除され、トークンも一緒に消えます。また、ワークスペースをエクスポートまたはバックアップすると workspace.db がコピーされるため、他人に引き渡したワークスペースが、生きたボットトークンを内部に持ち込むことはありません。このファイルはサーバーホストのファイルシステムの信頼境界を引き継ぎます — データディレクトリへの読み取りアクセスを持つものはボットトークンを読めますが、これはデータベースが置かれているのと同じ境界です。

プロバイダーごとに 1 ファイルで、アトミックに、所有者のみが読み書きできる形で書き込まれます。失効したアプリは 1 つのファイルをリンク解除すれば削除され、壊れたファイルの犠牲はすべてのプロバイダーではなく 1 つで済みます。資格情報は接続操作で入ってくるだけで、決して外へ出ません — ステータスの読み取りは接続メタデータのみを返します。

  • 構造的なアプリ設定。 トランスポート、スコープ、イベント購読は表明されるものであり、公開されません。設定画面から設定して沈黙させられるアプリは、機能ではなくサポートチケットです。
  • 会話とスラッシュコマンド。 Slack のサーフェスは、メッセージ、スレッド返信、スラッシュコマンドです。
  • 第 2 の信頼できる情報源。 チャットは作業が保存される場所ではありません。Slack のスレッドはスペースであり、返信はトランスクリプトであり、チケットはチケットです — それらはすべてワークスペース内にあり、あらゆるクライアントから到達できます。