PiのAgent Loopは、境界が比較的明確なAgent Runtimeのコアを観察するのに向いている。Session Tree、Plan、SubAgent、Workflowなどのプロダクト機能を低層ループに直接組み込むのではなく、コア実行チェーンは主にMessage、Model、Tool、Steering、Follow-up、ライフサイクルイベントを中心に構成される。Pi Coding Agentもminimal coding harnessを明示しており、ExtensionsとPackagesで機能を拡張できる。[1]
本文では、1回のPromptが辿る完全な実行経路だけを追う。クラスやファイルごとに順に説明することはしない。
分析では、以下の2本の線を並行して追う。
调用链
方法如何进入下一阶段
状态链
Agent State / Local Context 在每个阶段如何变化
呼び出しチェーンだけを追うと、関数は多く見えるものの、Runtimeがどう動くかはまだ分からない。Piのこの部分のソースコードで本当に観察する価値があるのは、低層Loop、今回の実行に使うLocal Context、そして長く保持されるAgent Stateの3者がどう連携するか。
Agentがなぜ低層Loopを包むのか
Piの低層実行ロジックはpackages/agent/src/agent-loop.tsにあり、外側のAgentはagent.tsにある。ソースコードはAgentを「low-level agent loopのstateful wrapper」と説明している。[2]
この文は、二層の役割分担をほぼ示している。
agent-loop.tsはより実行関数に近い。Context、Prompt、Tool、Signalといくつかのコールバックを受け取り、モデルとツールの処理を進める。
一方、Agentは長期的なオブジェクトで、現在の設定と実行状態を保持する。
現在のAgent Stateはおおむね次のとおり。
重要なのは、これらのフィールドが異なるライフサイクルを持ち、すべての状態がmessagesへまとめられているわけではない点だ。
状態は次の種類に分けられる。
ここにはRuntimeの基本原則が表れている。安定した事実と、まだ変化している実行状態は分けて表現すべきだ。
たとえばAssistantがストリーミング生成中の場合、streamingMessageは変化し続ける。この状態で各chunkが安定したtranscriptへ直接書き込まれると、UI、永続化、復旧のいずれでも、どのメッセージが完了したか判断しにくくなる。
Piは完成したMessageをmessagesへ追加し、実行中の未完成部分をstreamingMessageで表す。
Toolも同様で、pendingToolCallsは実行中のToolを表し、Tool Resultが完成してから安定したメッセージになる。
したがって外側のAgentは本质上Runtime State Holderである。
prompt()がなぜ2本目の能動的実行チェーンを禁止するのか
入口は非常にシンプルだ。
prompt()には明示的な稼働状態の制約があり、同じAgentに対して自由に並行呼び出しはできない。
現在の実装はactiveRunを確認する。Agentがすでに稼働中なら、prompt()をもう一度呼ぶとすぐエラーになり、呼び出し側にはsteer()、followUp()を使うか、現在の実行が終わるまで待つよう求められる。[2]
この制約は重要だ。
2つのprompt()を同時に実行すると次のようになる。
Prompt A ──→ Loop A ──→ Agent.messages
Prompt B ──→ Loop B ──→ Agent.messages
2つのLoopが同じAgent Stateを同時に読み書きする。JavaScriptレベルでは共有メモリのスレッド競合が起きなくても、論理的な競合は依然として生じる。
A 读取 messages = M0
B 读取 messages = M0
A 产生 assistant A
B 产生 assistant B
A 执行 tool A
B 执行 tool B
後続Contextの順序を誰が決めるか、Tool Resultをどの実行チェーンへ入れるか、SteeringがどのLoopへ影響するか。そのすべてに追加の調整が必要になる。
Piはよりシンプルなモデルを選んだ。
一个 Agent
同一时刻
只有一条 active processing chain
新しい入力を現在の作業へ入れるには、Steering / Follow-up Queueを通して明確な受け渡しセマンティクスを得る必要がある。
これによりAgent内部のtranscriptは単調に前進し、複数の実行分岐から並行して書き込むことをコアLoopで扱う必要がなくなる。
runWithLifecycle()が1回の実行区間を作る
入力はnormalizePromptInput()でAgentMessage[]に変換された後、次の段階へ進む。
runPromptMessages()はrunWithLifecycle()で包まれている。
この層が現在の実行区間を作り、次の処理を担う。
创建 AbortController
创建 active promise
设置 isStreaming = true
运行实际 Loop
统一处理异常
结束时清理临时 Runtime State
そのため、1回のPromptが実際にモデルを要求する前から、次の状態変化に入っている。
idle
↓ prompt()
activeRun created
isStreaming = true
↓
runAgentLoop()
Pi内部ではこの稼働オブジェクトをActiveRunと呼ぶ。一点注意点がある。これはRuntimeの現在の処理区間を制御するオブジェクトにすぎず、プロダクト層が永続化する可能性があるRun Entityと同じではない。
主に3つのニーズに答える。
知道当前 Agent 是否已有活动任务
允许 abort() 找到当前 AbortController
允许 whenIdle / promise 等待当前活动结束
前回はCore Active RunとApplication Runを区別したが、この区別のソースコード上の根拠となる。
Context Snapshot为什么要Loopへ入る前に作るのか
runPromptMessages()はまずAgent._stateからLoopに必要なContextを作り、Stateオブジェクト全体をそのまま低層Loopへ渡すことはしない。
現在の実装はごく小さい。
その後、次の呼び出しへ進む。
この分離は詳しく見る価値がある。
低層LoopがAgentを直接保持するなら、次のような構成になる。
Agent
├── messages
├── streamingMessage
├── tools
├── model
├── listeners
└── queues
この場合、Loopと外側のRuntimeオブジェクトが高密に結合する。Loopをテストするには完全なAgentが必要になり、Loopを再利用するときもState Ownerの差し替えが難しい。
PiはLoopに初期Context Snapshotを渡すという選択をした。
Long-lived Agent State
│
│ snapshot
▼
Initial AgentContext
│
▼
Local Loop Context
低層Loopは自分に必要な入力だけを扱う。
ここでslice()を使うことはSnapshotの意味を示している。配列コンテナが複製されるため、このLoopは自分のメッセージ列を展開し続けられ、同じ配列参照を通じて外側のAgent.messagesへ直接書き込むことはない。
実行されるのは浅い配列コピーで、Messageオブジェクト自体は参照を共有する可能性がある。重要なのは、メッセージ集合の進行をLoop Local Context自身が制御する点だ。
現在のPromptはいつContextへ入るのか
createContextSnapshot()がコピーするのは、実行開始前から存在するAgent.messagesだけだ。
今回のprompt()の新しい入力は、続いてrunAgentLoop()が追加する。
したがって初期状態は次のとおり。
Agent Stable Messages
+
Current Prompt
↓
Loop currentContext.messages
同時に、Loopはユーザーメッセージに関するライフサイクルイベントを発行する。
agent_start
turn_start
message_start(user)
message_end(user)
これらのイベントはEvent Sinkを通じて外側へ戻る。
Agent.processEvents()はmessage_endの後に完成済みのUser Messageを_state.messagesへ追加する。[2][3]
ここまでで非常に重要な構造が見えてきた。
同じUser Messageは、一方では今回の計算に使うためLoop Local Contextに存在し、他方ではイベントを通じて長期のAgent Stateに入る。
この2つの表現は役割が異なる。
- Local Contextは進行中の計算に連続したメッセージ列を提供する。
- Agent Stateは外側の観察者と後続の実行に安定した状態を提供する。
なぜLoopが毎回Agent Stateを読み直さないのか
別の実装も考えられる。モデルリクエストのたびにAgent.messagesから履歴全体を読み直す方式だ。
表面上はLocal Contextを減らせるが、新たなcouplingが生じる。
現在のTurnで生成されたTool Result、Steering Message、一時的なContext Transformは、まずAgent Stateへ同期される。次のModel Requestで初めて見える。こうすると、低層実行プロセスを外側の状態コミット時期に左右されてしまう。
PiのLocal Contextは、現在のLoop自身が連続性を保つことを可能にする。
Prompt
↓
Assistant
↓
Tool Result
↓
Steering
↓
Assistant
この計算列はLoop内で継続的に進み、外側のStateにはEventを通じて同じ安定結果へ段階的に反映される。
したがって、これは次に近い。
Local execution state machine
+
external state projection
後続のDSH Event Logでは、「実行事実→外部Projection」という関係をより徹底して扱う。
runLoop()が2層のループを持つ理由
Agentの進行を制御しているのはrunLoop()だ。
詳細を圧縮すると、構造はおおむね次のとおり。
この2層のループは、Piの実行境界に関する判断をそのまま実装している。[3]
Inner Loopは「現在の作業がなぜ続ける必要があるか」を担う。
模型产生了 Tool Call
→ 继续
有 Steering Message 等待消费
→ 继续
Outer Loopは「現在の作業は停止の準備ができているが、後続タスクが残っているか」を担う。
有 Follow-up
→ 进入后续工作
没有 Follow-up
→ agent_end
そこで3種類の入力は、それぞれ別の時点に置かれる。
ソースコードの構造から、Loopがどのタイミングで入力を消費するかに、Piの制御セマンティクスがそのまま埋め込まれ、APIドキュメントと実行構造が一致している。
createLoopConfig()は外側Runtimeが制御機能を注入する場所
runAgentLoop()は、Steering QueueやFollow-up QueueをAgentがどう保持しているか直接には知らない。外側はcreateLoopConfig()を通じて、これらの機能をコールバックとしてLoopへ渡す。[2]
概念的には次に近い。
この位置には依存方向が表れている。Loopが依存するのは「Steering Messageをどのように取得するか」という能力だけで、PendingMessageQueueの具体的な実装には依存しない。
そのため、上層がメッセージの供給元を変更したい場合はコールバックを差し替えられる。Steeringが不要なプロダクトなら空実装を提供すればよい。
この設計なら、agent-loop.tsがSessionManager、ExtensionManager、特定のQueue型を直接importするより再利用しやすい。低層実行は自分に必要な能力の契約だけを定義し、状態コンテナは引き続き外側が保持する。
アーキテクチャから見ると、createLoopConfig()とEvent Sinkは、それぞれ逆向きの2つのインターフェースを成す。
Outer Runtime ── config / callbacks ──→ Loop
Outer Runtime ←──── lifecycle events ── Loop
前者は実行カーネルに方針とリソースを供給し、後者は実行事実を外部へ返す。Loopは比較的閉じた実行セマンティクスを維持できる。
1回のModel Request前に有哪些変換が起きるか
Inner Loopが次のAssistant Responseを始めようとしたら、まずこのTurnでContextへ入る予定のメッセージを処理する。
その後、Contextは次の経路を通り得る。
currentContext
↓
transformContext
↓
convertToLlm
↓
Provider Context
この数層の分離は、Runtime MessageとProvider Messageを区別するのに向いている。
Agent Runtime内部では、より豊富なメッセージ種別を保持する必要がある。例:
用户消息
Assistant Message
Tool Result
内部扩展消息
自定义上下文消息
Providerがこれらの構造を必ず受け入れるとは限らない。
convertToLlm()の役割は、実際にモデルをリクエストする時点でプロトコルを収束させることだ。Piのデフォルト実装はuser、assistant、toolResultの3種類のメッセージを保持する。Coding Agentは独自の変換ロジックも提供できる。[2]
より正確なデータチェーンは次のとおり。
Agent Messages
↓
Context Transform
↓
LLM-compatible Messages
↓
Provider Adapter
複雑なAgentではこの境界が重要だ。Session、UI、Model Providerが同じメッセージモデルを強制的に共有すべきではない。
Streaming Messageが別に存在する理由
モデルがStreamingを始めた後、Loopは次を連続して生成する。
message_start
message_update*
message_end
外側のAgent.processEvents()はこれらのイベントをRuntime Stateへ集約する。
message_start / message_update
→ _state.streamingMessage = event.message
message_end
→ streamingMessage = undefined
→ _state.messages.push(event.message)
その結果、同じAssistant出力は2つの状態を経る。
Mutable / In-progress
streamingMessage
↓ message_end
Stable / Completed
messages[]
この境界はフロントエンドにとって非常に有用だ。
UIはmessage_updateを購読してtokenをリアルタイム表示し、履歴メッセージ一覧では完成済みMessageを安定した単位として扱える。
後々増分chunkを永続化する必要があれば、さらに外側でEvent Logを別に持てばよい。低層Agentはすべてのstreaming updateを安定したtranscriptとして扱う必要はない。
したがって、Piここでの選択は「実行中の増分+完成済みメッセージ」の2層表現である。
Assistant MessageがTool Executionを引き起こす仕組み
Assistant Messageが完了すると、LoopはそのcontentからTool Callsを取り出す。
概念的には、1つのTool Callは次の過程を経る。
tool_execution_start
↓
execute tool
↓
tool_execution_update*
↓
tool_execution_end
↓
ToolResultMessage
外側のAgentはtool_execution_startで対応するtoolCallIdをpendingToolCallsへ追加し、終了イベント後に削除する。[2]
これは、実行時に2つの問いに同時に答えられることを意味する。
历史里有哪些 Tool Result
当前还有哪些 Tool 没执行完
その後Tool ResultはcurrentContext.messagesに入り、現在のLoopが新しく生成したメッセージ集合にも加わる。
次のモデル呼び出しでは、Tool Call / Tool Resultの正しい関係を維持する必要がある。
Assistant
└── toolCall(id=1)
ToolResult(id=1)
Assistant
└── 根据 Tool Result 继续决策
これはProvider Contextのプロトコル制約であり、UIの表示とは無関係だ。
Pi関連のissueにも、孤立したTool ResultがProviderに拒否された事例が記録されている。[4] Runtimeにとってメッセージ履歴は「その内容を持つ」だけでなく、モデルプロトコルが要求する構造関係も維持しなければならない。
複数のTool Callにはバッチ境界も Involvedする
1つのAssistant Messageが複数のTool Callを同時に生成することもある。Runtimeは、そのToolを現在のStepでどう実行し、いつこのTool Workのバッチが終わったと判定するかを考える必要がある。
抽象化すると次のようになる。
具体的な実装が直列でも並列でも、次のモデルリクエストにはTool Callに対応する完全な結果集合、または明示的なエラー/中断結果が必要だ。
これはSteeringの受け渡しタイミングに影響する。現在のモデルが一度にA、B、Cの3つのTool Callを生成したなら、SteeringがAの完了直後にContextへ入ると、BとCは古い決定に従った実行を続けながら、モデルは後の判断で新しい制約を見ることになる。Piのドキュメントでは、現在のAssistant TurnのTool Callsの実行完了後にSteeringを次のLLM Callへ渡すとしている。[5]
したがってTool Batch自体も安定した境界である。Runtimeは前のモデル判断が生成した直接Effectを先に完了させ、その後に新しい入力を次の判断へ反映させる。
この種の境界はToolの並行実行に対応する場合に特に重要だ。並列化で実行時間を短縮できるが、Context内のTool Call / Tool Resultの対応関係とStepの完了条件を壊してはならない。
Tool Resultが先にLocal Contextへ入る理由
ツール実行が完了した後、最も直接に必要なのは次のModel Requestが直ちに結果を見られることだ。
そのためTool Resultはまず現在のLoopの計算状態に属する。
currentContext.messages += ToolResult
その後Eventの仕組みを通じて外側のstateへ同期される。
逆にTool Resultを先に永続化し、SessionからContextを再構築すると、低層Loopは永続化実装によってブロックされ、独立して使える実行カーネルになりにくい。
Piの設計では永続化を外側の利用側として扱える。
Tool Execution
↓
Tool Result
↓
Local Context continues
↓
Lifecycle Event
↓
Agent State / Session / UI
この順序で「現在の実行を継続できるか」と「外部が現在の状態をどう記録するか」を分離できる。
製品が厳格なcrash recoveryを要求する場合、外側はdurable Event Logをさらに導入し、重要なEventが処理を進める前にdiskへ書き込まれるよう要求できる。これはより高層の永続化セマンティクスであり、最小Loopに入れる必要はない。
SteeringがTool完了後に確認される理由
ツール実行中、新しいSteering MessageがQueueへ既に到着している可能性がある。
Piはツール実行の途中で現在のContextを直接変えることはない。Toolが完了すると、Inner Loopは次を再度呼び出す。
Agent.createLoopConfig()はこれをsteeringQueue.drain()へバインドしている。[2]
実際の受け渡し順序は次のとおり。
Pi RPCドキュメントも、Steerが現在のAssistant TurnのTool Calls実行完了後、次のLLM Call前に渡されると明記している。[5]
この設計は主に2つの安定した境界を守る。
第一に、開始済みのTool Callは、新しい自然言語入力のせいで急にパラメータセマンティクスが変わることがない。
第二に、次のModel RequestはTool ResultとSteering Messageを同時に見て、新しい完全な状態から判断を続けられる。
現在のToolを直ちに中止する必要がある場合はAbortを使う。Steerは後続の判断入力として残す。
これにより、「次の一手の方向を変える」と「進行中の作業を終了する」が独立した2つの制御操作として保たれる。
Event Sinkが構造全体の重要な接続点である理由
低層のrunLoop()はAgent._stateへ直接書き込まず、Event Sinkへイベントを発行する。
processEvents()は、外側のRuntime Stateを更新する一方、listenerへイベントを通知する。
その結果、同じ実行事実を複数の利用側が使える。
低層Loopはこれらの利用側が存在するかを知る必要はない。
Loop内で直接次を呼ぶより、
コアの境界を保ちやすい。
Pi Harness V2の設計でも、基盤のagent-loop building blocksはdurable stateを保持せず、Session、record、laneを理解する必要はないと強調されている。[6]
アーキテクチャから見ると、Event Sinkは実行カーネルと外部Harnessを結ぶ狭いインターフェースだ。
EventとEvent Sourcingの境界
ここでもう一つ、混同しやすい問題を区別しておく必要がある。
Pi LoopがライフサイクルEventを発行するだけでは、低層AgentがEvent Sourcingを採用したことになるわけではない。
Eventは単なる通知手段でもよい。
state mutation
+
event notification
Event SourcingではEventがdurable source of truthとなり、状態はEvent Logのprojectionで得られる。
Piの現在の低層Agentは、依然として_state.messages、streamingMessage、pendingToolCallsを直接維持している。Eventはこれらの状態変化を駆動し、通知する。
DeepSeek HarnessのSession Event LogはEventを永続的な事実源の階層まで引き上げる。これは第2单元で重点的に分析する設計差だ。
この区別を保つことで、emit(event)を見た途端にシステムをEvent Sourcingと分類してしまうことを避けられる。
Abortが現在の実行チェーンをどう贯穿するか
runWithLifecycle()が作成するAbortControllerはsignalを低層実行チェーンへ渡す。[2]
そのため、キャンセルセマンティクスをAgentの外層からモデル呼び出しとTool実行まで通せる。
理想的には次の経路になる。
agent.abort()
↓
AbortController.abort()
↓
signal aborted
↓
model / tool observes signal
↓
current active execution stops
個々のToolが適時にキャンセルへ応答できるかは、Toolの実装がSignalを正しく扱うかに依存する。Runtimeはキャンセル意図を伝播することしかできず、Signalを一切確認しない同期ブロッキング関数を即座に終了させることはできない。
これはAbortとSteeringの根本的な違いも示している。
Steeringは後続の判断を変える。Abortは現在の実行ライフサイクルへ直接作用する。
この2つの制御軸を混ぜると、ユーザー入力の挙動が予測しにくくなる。
Follow-upがOuter Loopに置かれる理由
Inner LoopにTool CallもSteering Messageも残らなくなったとき、現在の連続作業が自然な停止点へ到達する。
この時点でRuntimeは初めてFollow-up Queueを確認する。
そのためFollow-upは現在のTurnの直近のモデル判断には関与せず、現在の作業の完了後に後続作業となる。
このセマンティクスをSteeringと同じQueueで共有するなら、消費のたびに「このメッセージは現在の作業か次の作業か」を判定し直す必要がある。Piは2種類のQueueと2層のループでこれを直接表現し、コード構造とプロダクトセマンティクスを一致させている。
DeepSeek Harnessではさらに、この違いをnext-step / next-turn Targetとして抽象化している。次の Oriented Resource Controls Officeが比較を続ける。
1回のPromptの完全な状態チェーン
ここまででった局所的な過程をまとめると、完全な状態変化が得られる。
この図は、おおむねagent.ts + agent-loop.tsを読む主线として使える。
ソースコードを読む観点で、最後も残したい3つの境界
この一続きの呼び出しチェーンを読み終えたら、Piの具体的なメソッド名はいったん脇に置き、3つの構造境界のほうが覚えておく価値がある。
1つ目はStateとContextの境界。Agent._stateは長く保持され、LoopはSnapshotとLocal Contextで現在の計算を進める。
2つ目はCallbackとEventの境界。外側はLoop Configで方針とリソースを供給し、低層はLifecycle Eventで実行事実を外部へ返す。
3つ目はStep-likeとTurn-likeの境界。Tool / Steeringは内側で現在の作業を続け、Follow-upは停止境界の後に後続作業へ入る。
State ──snapshot──→ Local Context
Runtime ──callbacks──→ Loop ──events──→ Runtime
Inner Loop ──stop boundary──→ Outer Loop
この3つの境界は、特定の関数名だけを覚えるより安定しており、他のAgent Frameworkのソースコードを読むときにも移しやすい。
Pi Loopが保持するコアセマンティクス
ソースコードから、低層Agent Loopの責務を6種類にまとめられる。
Context Preparation
Model Invocation
Tool Execution
Input Delivery
Cancellation / Stop Condition
Lifecycle Events
Session Tree、長期永続化、Plan、SubAgent、Workflow、UIは、いずれもこの実行カーネルに固定されていない。
この境界には明らかな利点がある。同じLoopを異なるプロダクト層で再利用できる。Terminal Coding Agentは外側でSessionManagerとExtensionsを接続でき、他のアプリケーションは低層Agentだけを利用できる。
代償も存在する。crash recovery、durable run、branch、approval、複数Agentのorchestrationなど、多くの高度な機能は外側Runtimeが自分で補う必要がある。Piはコアを小さく保ち、これらの複雑さをより上層に残した。
ここまでで得られた設計判断は次のとおり。
再利用可能なAgent Loopは、まず実行セマンティクスを維持すべきだ。Context、Model、Tool、Input Delivery、Cancellation、Lifecycle Eventは比較的安定したコアを構成する。長期Session、Workflow、UIはそのコアの外側に構築できる。
次回はInput Deliveryを扱う。PiのSteering / Follow-up QueueがStep / Turnの境界へどう対応するか、DeepSeek HarnessがInboxのtarget + wakeupを通じてこれらのAPIを、より一般的な受け渡しモデルへまとめているかを分析する。
参考資料
[1] Pi Coding Agent README: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md
[2] Pi Agent: https://github.com/badlogic/pi-mono/blob/main/packages/agent/src/agent.ts
[3] Pi agent-loop.ts: https://github.com/badlogic/pi-mono/blob/main/packages/agent/src/agent-loop.ts
[4] Pi issue #2119: https://github.com/badlogic/pi-mono/issues/2119
[5] Pi RPC docs: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md
[6] Pi Harness V2 notes: https://github.com/badlogic/pi-mono/blob/main/packages/agent/docs/harness-v2.md