メインコンテンツにスキップ
すべての記事
2026年9月24日にTechnical blogで公開

Chatsの舞台裏:Lovableのエージェントが連携する仕組み

Chatsの舞台裏:Lovableのエージェントが連携する仕組み

エージェント型のプロダクトづくりは、ひとつの会話から別の場所で作業を動かす必要が出てくると、さらに面白くなります。

あるエージェントが別のエージェントにタスクを委ねます。委ねられたエージェントには独自の履歴があり、すでに作業中かもしれません。タスクが終わる前に新しい指示が届くこともあります。そして結果は、作業を始めた会話へと戻ってこなければなりません。コンテキストを失わず、責任の所在を混同せず、やり取りのたびに特別扱いをせずに、これらをどうつなげればよいのでしょうか。

これらは、Chatsの裏側にあるエンジニアリング上の問いの一部です。Chatsは、Lovableのプロジェクトを見ながらアイデアを検討し、意思決定を進め、作業を片付けられる新しい方法です。まず会話から始め、準備ができたらビルドを確定すると、アイデアがアプリへの変更として形になります。

Chatsは、もともとエージェントとサブエージェントが連携するために開発したインフラの上に構築されています。平日の典型的な1日に、私たちのTrajectory Systemはおよそ5億件のイベントを追記しており、これは約260万回のユーザーターンに相当します。

この記事では、その規模を支えるエンジニアリング、私たちが選んだトレードオフ、そしてChatsがこの基盤を使って会話とアプリでの作業をどのようにつないでいるかを解説します。

要点

私たちのアーキテクチャは、混同されがちな3つのことを切り分けています。何が起きたのか、エージェントが何を知る必要があるのか、そしてエージェントをいつ実行すべきか、です。

何が起きたのかは、トラジェクトリと呼ぶ、追記専用でフォーク可能な履歴に記録します。各エージェントのモデルコンテキストは、履歴そのものをプロンプトとして扱うのではなく、その履歴から組み立てられます。エージェント同士は永続的なインボックスを通じてやり取りし、アクティベーションによってエージェントが起動され、新しい作業を受け取ります。

Chatsでは、この仕組みがアイデアについて話すことと、それを形にすることをつないでいます。変更を確定すると、Chatsはアプリを構築するエージェントに作業を引き渡し、その進捗を会話へと戻します。

ここからは、そのつながりをどのように構築したかを紹介します。

トラジェクトリ:Gitをモデルにしたイベントログ

Lovableにおけるエージェントとの会話は、すべてイベントのログです。LLMが生成するものはすべて、Lovable Agent Frameworkの粒度で記録しています。具体的には、UserMessage、エージェントの応答の前後を囲むAgentStartとAgentDone、各LLM呼び出しを囲むIterationStartとIterationEnd、モデルのthinking・content・tool_callの出力、そしてツールのライフサイクル全体(ToolParametersValidated、ToolExecutionStart、ToolExecutionEnd、さらに人間の判断が必要な場合のToolApprovalRequired)です。すべてのツール呼び出しは、その結果と対応づけられます。捨てられるものは何もありません。イベントはイミュータブルかつ追記専用で、取り消しでさえ何かを削除するのではなく、Revertイベントとして記録されます。

この設計は、Gitのコミットと履歴をモデルにしています。各イベントは親へのポインタをひとつだけ持ちます。親のチェーンを逆にたどれば、ログ全体が得られます。Gitのブランチのように、名前付きのヘッドがそれぞれの履歴の先端を指しています。

このひとつの性質だけで、強力な機能がおまけで手に入ります。それはどのイテレーションの境界からでもフォークできることです。会話内のどのIterationEndやAgentDoneからでも、そのイベントを指す新しいヘッドを作成できます。新しいスレッドはその時点までの履歴をすべて共有し、まさにGitのブランチと同じです。しかもイベントを一切コピーしないため、フォークのコストはほぼゼロです。フォーク上の最初のイベントであるThreadForkConfigが、フォーク地点を親として指定するだけです。

Gitとは意図的に異なる点がひとつあります。イベントの親は必ずひとつだけです。あるトラジェクトリを別のトラジェクトリにマージすることはありません。2つのエージェントの履歴をマージすることの意味が曖昧だからです。思考、ツール呼び出し、結果からなる独立した2つの流れを交互に並べることに、そもそもどんな意味があるのでしょうか。エージェント同士で結果を共有する必要があるときは、マージするのではなく、Agent Control Planeを通じてメッセージを送り合います。これについては記事の後半で改めて説明します。トラジェクトリは分岐するだけです。

One builder turn as a chain of events, with a forked trajectory branching off an IterationEnd
ビルダーの1ターン、イベントごとにひとつの親ポインタ。フォークとは、IterationEndまたはAgentDoneを指す新しいヘッドのことです。その最初のイベントであるThreadForkConfigがフォーク地点を指し返すため、新しいトラジェクトリはコピーすることなくそれ以前のすべてのイベントを引き継ぎます。

コンテキストはトラジェクトリの射影

トラジェクトリは信頼できる唯一の情報源ですが、モデルにそのまま送られるわけではありません。各イテレーションの開始時、IterationStartが書き込まれた直後に、プロンプトビルダーがエージェントのヘッドを逆にたどり、トラジェクトリからプロンプトを生成します。イベントログには起きたことがすべて記録されますが、そのうちどれだけを、どのような形でモデルに見せるかは、呼び出しごとに別途決定されます。

念のため補足すると、コンテキストは常にトラジェクトリから組み立てられます。利点はその作業を省けることではなく、状況に合わせて柔軟に組み立てられることにあります。その最もわかりやすい例がコンパクション(コンテキストの圧縮)で、これも他のすべてと同様にログに記録されます。エージェントがコンパクションを行うと決めると、自身のトラジェクトリにPromptCompactionStartを書き込み、そのまま処理を続けます。要約処理は、エージェントの履歴から分岐したサイドトラジェクトリ上で、小さな独立したエージェントループとしてバックグラウンドで実行されます。その結果がエージェントのトラジェクトリに直接書き込まれることはありません。サイドトラジェクトリはインボックスのように機能し、エージェントは次のイテレーションの境界で完成した要約を取り込むと判断し、自身のトラジェクトリにPromptCompactionEndとして記録します。次のプロンプトの組み立ては、履歴を逆にたどる処理です。対応するStartと組になったEndに出会うと、ビルダーは古いイベントの代わりに要約を採用し、コンパクション地点以降のすべてを通常の履歴として描画します。

イベントログとLLMのコンテキストをこのように分離することで、さまざまなパターンが可能になります。

  • 非同期のコンパクション。要約処理は、エージェントがイテレーションを続けている間に実行されます。エージェントがそのために一時停止することはなく、次のプロンプトを組み立てる際に完了済みのコンパクションを見つけて使うだけです。
  • 履歴を共有するバックグラウンドエージェント。フォークされたエージェントのプロンプトビルダーは、親のイベントを自身の履歴としてそのままたどります。フォークは元のエージェントとまったく同じ履歴をコストなしで参照でき、そこから独自のプロンプトを生成します。
  • ユースケースに特化したプロンプト。ビルダー、サブエージェント、その他のエージェントはすべて同じ種類のイベントからプロンプトを生成しますが、プリセットが異なります。どの通知を含めるか、コードベースのコンテキストを取り込むかどうか、引き継いだ履歴をどう位置づけるか、といった点です。
An agent trajectory with PromptCompactionStart and End events, and the LLM context rendered from the summary
コンパクションは、エージェントのトラジェクトリ上の2つのイベントで表されます。要約自体はサイドトラジェクトリ上で生成され、エージェントによって取り込まれます。プロンプトビルダーは現在のIterationStartから逆にたどり、Startと組になったPromptCompactionEndを見つけると、要約が対象とするすべての代わりにその要約を描画します。

ストリーミング:パーシャルイベントとフィールドの差分

イベントはイミュータブルで完結したものですが、LLMの回答は小さなチャンクの連続として届き、ユーザーはそれが表示されていく様子を見たいと考えます。contentイベントが完成するまで何も表示しなければ、エージェントが固まっているように感じられてしまいます。そこでTrajectory Systemには、まだ存在しないイベントのための別チャネルがあります。それがパーシャルです。

たとえばエージェントがモデルからthinkingブロックを受け取り始めると、パーシャルを開きます。これは、イベントの初期フィールドと一意のIDを持つPartialOpenedです。チャンクが流れてくるにつれて、エージェントはPartialDeltaを発行します。これは開いているパーシャルのひとつのフィールドに対する小さな編集で、多くの場合は「このテキストを追記する」というものです。各デルタはブラウザにリアルタイムでプッシュされ、ブラウザは処理中のコピーにそれを適用して、テキストを一語ずつ描画します。モデルがブロックを出力し終えると、エージェントは同じ一意のIDを持つ、完成した本物のthinkingイベントをトラジェクトリに追記します。この追記によってパーシャルが閉じられます。デルタを追っていた側は、それらが最終的にどの永続化されたイベントになるのかを知り、処理中のコピーを正式なイベントに置き換えます。

パーシャルはイベントではありません。トラジェクトリ内での位置も親も持たず、永続化されることもありません。永続化されるのは最終的なイベントだけです。これにより書き込みの経路はシンプルに保たれ(ストリームが終わったときに破棄やコンパクションをするものが何もありません)、リアルタイムの読み取りも正確に保たれます。応答の途中で接続したブラウザは、その時点までに永続化されたイベントと、デルタを反映済みの状態で一度だけ実体化された各パーシャルを受け取り、そこからリアルタイムのストリームを追います。再接続、リロード、複数タブのいずれも同じトラジェクトリに収束します。書き込まれたものはトラジェクトリだけだからです。

A persisted trajectory lane above a live side channel of PartialOpened and Delta chunks
ストリーミングはトラジェクトリの中ではなく、その横で行われます。PartialOpenedと一連のPartialDeltaによって、ブラウザは届いたそばからテキストを受け取ります。永続化されるのは、パーシャルと同じ一意のIDを持つ最終的なthinkingまたはcontentイベントだけです。

エージェントごとに2つのログ:インボックスとエージェントのトラジェクトリ

非常に重要だとわかった点があります。実は、各エージェントは2つのイベントログを持っています。

  1. インボックス。エージェント宛てに送られたものはすべてここに届きます。ユーザーのUserMessageや、他のエージェントまたはコントロールプレーンからのExternalAgentNotification(サブエージェントの完了、別のエージェントからのメッセージ、スケジュールされた起動など)です。
  2. エージェントのトラジェクトリ。エージェントが実際に積み重ねてきた履歴、つまり何を考え、何を言い、何をしたかです。

エージェントは実行されるたびにインボックスを確認し、まだ処理していないものを自身のトラジェクトリにコピーします。これは実行開始時に加え、イテレーションの境界ごとにも行われます。そのため、応答の途中で届いたメッセージは、応答全体が終わるのを待たずに、割り込みとして取り込まれます。

この分離によって、外部からの到着と実行が切り離されます。メッセージはいつでも、どこからでもインボックスに溜まっていきます(他のアクターが書き込むのはインボックスだけで、エージェントのトラジェクトリに書き込むことはありません)。一方でエージェントは、明確に定められたタイミングで、自分のペースでそれらを自身の履歴に取り込みます。トラジェクトリは、エージェントが実際に処理した内容の、整然とした順序付きの記録であり続けます。

An inbox log next to an agent trajectory, with an ExternalAgentNotification copied across
他のアクターはインボックスに追記します。エージェントは実行開始時と各イテレーションの境界で、未処理のイベントを自身のトラジェクトリにコピーします。コピーされたイベントは処理済みとみなされます。

Agent Control Plane

Trajectory Systemは、すべてのエージェントに永続的でフォーク可能な履歴を与えます。しかし、エージェントがいつ実行されるのか、どこで実行されるのか、あるエージェントの作業がどのように別のエージェントに届くのかについては何も定めていません。エージェントが複数になると、こうした問いが一気に押し寄せます。ビルダーは探索用のサブエージェントを生成し、その結果を受け取る必要があります。バックグラウンドのフォークは、セッションが落ち着いたときに実行されるべきです。あるエージェントが複数のエージェントに作業を渡し、その進捗を知りたいこともあります。スケジュールされたタスクが、何日も誰とも会話していないエージェントを起動する必要もあります。これらはいずれも少しずつ異なるオーケストレーションのパターン(親子関係、ファンアウトとファンイン、ファイア・アンド・フォーゲット、タイマー)であり、どれもエージェントループ自体の中に置くべきものではありません。

それを担うのが、Agent Control Plane(ACP)です。Trajectory Systemの上に位置するオーケストレーション層で、どのエージェントが存在し、それぞれがどのトラジェクトリ上で動いているかを把握しています。新しいエージェントを、新規のトラジェクトリでゼロから、あるいは既存のエージェントのフォークとして作成します。エージェント間でメッセージを届けます。そしてアクティベーションを発行することで、エージェントをいつ実行すべきかを決定します。アクティベーションとは起動シグナルのことで、私たちのフリート内のどのノードでもそれを受け取り、トラジェクトリとともにエージェントを起動して実行できます。

ACPが独立した層として存在する理由は、こうしたパターンをすべてひとつのプリミティブにまとめられるからです。エージェント間のあらゆるやり取り(生成、結果、進捗報告、あるエージェントから別のエージェントへの引き継ぎ、スケジュールされた起動)は、同じ2つのステップで構成されます。受信者のインボックスにメッセージを追記し、アクティベーションを送る、というものです。エージェント同士が直接呼び出し合ったり、接続を保持し合ったり、相手がどのマシン上にいるかを知ったりすることはありません。インボックスに書き込み、残りはACPに任せます。そのためインターフェースも小さく、SpawnAgent、ForkAndSendMessage、SendMessage、NotifyParents、StopAgentと、いくつかの読み取り操作だけです。

ここでは、そうしたパターンのひとつを最初から最後まで見てみましょう。サブエージェントが、自分を生成したビルダーに結果を報告する流れです。

  1. サブエージェントがAgentDoneに到達します。
  2. その完了処理のエンベロープがNotifyParentsを呼び出し、結果と最終ステータス(Completed、Failed、Cancelledのいずれか)を含むExternalAgentNotificationを作成します。
  3. SendMessageがそれを親のインボックスに追記します。この書き込みが永続化される部分であり、この時点で、この後何が起きてもメッセージは失われません。
  4. ACPがアクティベーションを送ります。親がスリープ中であれば、いずれかのノードがアクティベーションを受け取って実行を開始し、その実行がインボックス内の通知を見つけます。親がすでに実行中であれば、次のイテレーションの境界で親自身が通知を取り込むため、アクティベーションによって2つ目の実行が始まることはありません。実行はアクティベーションを確保し、各トラジェクトリには排他的な書き込み手がひとつしか存在しないため、重複したアクティベーションは受領確認されたうえで破棄されるだけです。メッセージはすでにインボックスにあるからです。

ポイントは疎結合です。送信側はインボックスに追記すれば完了で、受信側は次に実行されたときにインボックスを読みます。起動はあくまでシグナルであってペイロードではなく、信頼できる唯一の情報源はインボックスです。そのため、処理中のエージェントへのメッセージも、待機中のエージェントへのメッセージも同じ書き込みになり、どちらの側も相手と調整する必要がありません。

Sequence diagram of a builder spawning a subagent through ACP and receiving its completion notification
ACPを介した1回の往復。永続化されるステップは親のインボックスへの追記であり、アクティベーションは起動シグナルにすぎません。

サスペンドとレジューム:デプロイをまたいで動き続けるエージェント

このアーキテクチャがもたらす最も見事な効果のひとつが、エージェントをとても簡単にサスペンド(一時停止)し、レジューム(再開)できることです。

イテレーション境界、つまりIterationEndが書き込まれた直後の時点では、次のイテレーションに必要なものはすべてトラジェクトリ上のイベント履歴に揃っています。そのため、この境界でループはそのまま停止できます。実行はサスペンド状態で終了し、AgentDoneは書き込まれません。開いたままのエージェントブロックそのものが、続きの作業があることを示すシグナルになります。その後、ACPが再開理由を付けた新しいアクティベーションを送信します。フリート内のどのノードでもそれを受け取ってインボックスを確認し、何事もなかったかのように次のイテレーションを実行できます。

これにより、長時間実行されるエージェントをデプロイ基盤から切り離せます。フリートの再起動時には、各ノードが新しいアクティベーションの受け付けを止め、実行中の処理が終わるのを待ちます。ほとんどのターンはそのまま完了します。まだ続いているターンは、代わりに次の境界でサスペンドし、新しくデプロイされたノードで再開します。ターンの途中でサンドボックスが停止した場合も同じ経路が働きます。失敗したツール呼び出しがイテレーションを終了させ、実行はやみくもにリトライするのではなく、その境界でサスペンドします。こうして私たちは、長時間実行中の処理を一度も強制終了することなく、一日中アップデートをリリースし続けています。

サンドボックス基盤のおかげで、この仕組みはさらにすっきりしたものになっています。サンドボックスはエージェントのフリートとは別に動作しており、プロジェクトの永続的な状態はマシンではなくリポジトリにあります。再開したエージェントは自分のサンドボックスに再接続するか、リポジトリからサンドボックスを再構築します。エージェントのプロセスは文字どおり使い捨てで、作業内容はトラジェクトリとgitの中に残ります。

A draining node suspending at an iteration boundary and a fresh node resuming the same trajectory
イテレーション境界では、プロセスは使い捨てにできます。実行はエージェントブロックを開いたままサスペンド状態で終了し、ACPが再開用のアクティベーションを送信すると、新しいノードが同じトラジェクトリを引き継ぎます。サンドボックスは別に存在しています。

すべてを組み合わせる:Chats

これまでLovableは、プロジェクトに入り、そのプロジェクトのビルダーエージェントと対話する場所でした。Chatsでは、それとは異なるものをリリースします。特定のプロジェクトの外で、自由にチャットできるエージェントです。

Chatsを支えるチャットエージェントはワークスペースレベルで動作し、プロジェクトではなくワークスペース単位にスコープされた独自のトラジェクトリを持ちます。ビルド向けではなく、素早く自由度の高い会話向けに調整された別のモデル構成を使い、すべてのプロジェクトにアクセスできます。プロジェクトに入って何かを作るよう頼むことも、複数のプロジェクトを並行して更新するよう頼むこともできます。

ここで、これまで説明してきたプリミティブがChatsで活かされます。Chatsは、ほぼすべてがトラジェクトリ、インボックス、アクティベーションで構成されています。チャットエージェントが、あるプロジェクトで作業を行うべきだと判断すると、send_message_to_projectツールを呼び出します。内部的には、これは単なるSendMessageです。指示と添付ファイルを含むExternalAgentNotificationがそのプロジェクトのインボックスに追加され、アクティベーションによってビルダーが起動します。ビルダーは自身のトラジェクトリを中断したところからそのまま再開し、すでに作業中であれば、次のイテレーション境界でそのメッセージを取り込みます。自己完結したメッセージを書くのはチャットエージェントの役割です。チャットエージェントはプロジェクトのチャットや状態を読んで伝える内容を決められますが、ビルダーが目にするのは送られてきたメッセージだけです。両方のトラジェクトリが同じシステム上にあるため、逆方向も可能です。つまり、プロジェクトのエージェントが追加のコンテキストとしてチャットのスレッドを読むこともできます。現時点では、その役割はチャット側に持たせています。

進捗も同じプリミティブを通じて戻ってきます。ビルダーがターンの途中で進捗を報告すると、ACPが親に通知し、その更新はExternalAgentNotificationとしてチャットエージェントのインボックスに追加されます。ビルダーのターンが終わると、もう1つの通知が最終ステータスと結果の概要(使用したクレジット、変更されたファイル、ビルドのステータス)を届けます。これは、サブエージェントが自分を生成したエージェントに報告するときに使うのと同じNotifyParentsプリミティブを、上流に向けて使っているものです。進捗報告は別のチャネルではなく、インボックスに届くメッセージのひとつにすぎません。

A workspace-level chat agent fanning work out to three project builders and hearing back through their inboxes
Chatsは作業をプロジェクトのビルダーに振り分け、その結果を受け取ります。指示は下流へ、進捗と結果は上流へ、すべてインボックスに届く通知として流れます。1回のターンで複数のsend_message_to_project呼び出しを行うと、各プロジェクトが並行して実行されます。

この設計にした理由

なぜ状態のスナップショットではなくイベントログなのか?

スナップショットを採用すると、「会話」の表現がひとつに固定されてしまいます。ログなら、それぞれの利用者が独自の表現を導き出せます。ビルダーのプロンプト、チャットエージェントのプロンプト、ブラウザでの表示、評価用のリプレイが、すべて同じイベントから生成されます。コンパクション、取り消し、割り込みは書き換えではなく追記になるため、「エージェントがその判断をしたとき、実際に何を見ていたのか」にいつでも答えられます。デバッグや評価にはこの生の記録が欠かせません。スナップショットにしてしまうと、うまくいかなかったターンを説明する詳細が失われてしまいます。その代償として、読み取りは単純な参照ではなく走査になります。この負担は、ストレージのレイアウト、キャッシュ、そしてプロジェクションとしてのコンパクションで補っています。

なぜログを1つにせず、インボックスとエージェントトラジェクトリの2つに分けるのか?

書き込む側とエージェントでは、必要なものが異なります。ほかのアクターには、実行中のエージェントと競合せずにいつでも追記できる場所が必要です。一方エージェントには、自分だけが書き込み、自分で順序を制御できる履歴が必要です。各イテレーション境界で、エージェントはプロンプトを再構築する前に、未処理のインボックスメッセージを自分のトラジェクトリにコピーします。これにより、エージェントトラジェクトリはリプレイ可能な状態に保たれます。外部からの入力は明確に定められたタイミングでしか入らないため、モデルが見たすべてのプロンプトをその1つのログから再構築できるのです。さらに、起動の合図とペイロードを切り離せます。インボックスが信頼できる唯一の情報源なので、アクティベーションが失われたり重複したりしても問題ありません。これが、クラッシュからの復旧や再実行を安全に行える理由です。

なぜ単純な線形ログではなく、Gitをモデルにしたのか?

イベントごとに親へのポインタを持たせることで、フォークにはコストがかかりません。フォークとは既存のイベントを指す新しいヘッドにすぎず、コピーは不要で、フォークしたエージェントのプロンプトビルダーは親の履歴をそのままたどれます。名前付きのヘッドによって、それぞれの作業ラインに識別子が与えられます。エージェントの履歴、インボックス、サブエージェント、フォークはすべて同じストア内のヘッドにすぎないため、1つのストレージ層と1つの読み取り経路であらゆる種類のエージェントに対応できます。そして、このメンタルモデル自体も借りる価値がありました。エンジニアはすでにコミット、ブランチ、ヘッドという考え方に慣れているので、新しい用語を作る必要がなかったのです。

内部の仕組み

興味のある方のために、実装の詳細をいくつかご紹介します。

ストレージ。トラジェクトリは、イベントの連なりとしてBigtableに保存されています。ロウキーは1つのトラジェクトリのイベントが隣り合って並ぶように設計されており、履歴の読み取り(まさにコンテキストの構築そのものです)を、イベントごとに移動するのではなく高速なスキャンで行えます。ナレッジファイルやファイルの変更といった大きなペイロードは、コンテンツアドレス方式のブロブストアに切り出しているため、イベント自体は小さく保たれます。

アクティベーション。すべての起動はアクティベーションログに記録され、エージェントごとに順序付けられてPub/Subで配信されます。そのため、フリート内のどのノードでもそれを受け取り、トラジェクトリとともにエージェントを起動して実行できます。定期的に動くリコンサイラーが、配信されたものの解決されていないアクティベーションを再実行するため、起動の合図が失われても遅延が生じるだけで、メッセージが失われることはありません。

エージェントループそのものや、基盤となるLLM呼び出しをプロバイダー間でどのようにルーティングしているかについては、以前の記事「毎分数十億トークンをルーティングする仕組み」をご覧ください。

まとめ

次にChatsを使ってアイデアをアプリの変更に変えるときには、その裏側で何が起きているのか、よりはっきりとイメージできるはずです。エージェント同士がどのように作業を受け渡し、コンテキストを把握し、結果を会話に戻しているのかを。

ご自身でエージェント型のプロダクトを開発している方は、ここで紹介した設計上の選択をぜひ参考にしてください。エージェントの役割は違っても、エージェントが何を知る必要があるのか、どのように連携するのかといった同じ問いの多くに直面するはずです。私たちのアプローチを共有したのは、その土台となる具体的な手がかりにしていただくためです。

Chatsを試して、実際の動きをご覧ください。

アイデアから数秒でアプリへ

AIとチャットしながらアプリをつくれます。

無料で始める