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]
이 글은 Prompt 하나의 전체 실행 경로만 따라가며, 클래스나 파일별로 하나씩 풀어보지 않습니다:
분석에서는 두 가지를 동시에 추적합니다:
调用链
方法如何进入下一阶段
状态链
Agent State / Local Context 在每个阶段如何变化
호출 체인만 따라가면 수많은 함수는 보이지만 Runtime이 어떻게 동작하는지는 여전히 명확하지 않습니다. Pi의 이 부분 소스 코드를 관찰할 때 진짜 가치 있는 것은 저수준 Loop, 이번 실행의 Local Context, 오래 유지되는 Agent State가 어떻게 협업하는지입니다.
Agent는 왜 저수준 Loop 바깥에 있는가
Pi의 저수준 실행 로직은 packages/agent/src/agent-loop.ts에 있고, 외곽의 Agent는 agent.ts에 있습니다. 소스 코드는 Agent를 ‘저수준 에이전트 루프를 감싸는 상태 유지 래퍼’라고 설명합니다.[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()는 왜 두 번째 활성 실행 체인을 금지하는가
진입점은 간단합니다:
prompt()에는 명확한 활성 상태 제약이 있어서, 같은 Agent에서 임의로 동시에 호출할 수 없습니다.
현재 구현은 activeRun을 확인합니다. Agent가 이미 활성 상태라면 prompt()를 다시 호출했을 때 즉시 오류를 내며, 호출자에게 steer() 또는 followUp()을 사용하거나 현재 활동이 끝날 때까지 기다리도록 요구합니다.[2]
이 제약은 매우 중요합니다.
두 prompt()를 동시에 실행할 수 있다면:
Prompt A ──→ Loop A ──→ Agent.messages
Prompt B ──→ Loop B ──→ Agent.messages
두 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()은 한 번의 활동 구간을 설정합니다
입력은 normalizePromptInput()을 통해 AgentMessage[]로 변환된 뒤 다음으로 들어갑니다:
runPromptMessages()는 runWithLifecycle() 안에서 실행됩니다.
이 계층은 다음과 같은 현재 활동 구간을 설정합니다:
创建 AbortController
创建 active promise
设置 isStreaming = true
运行实际 Loop
统一处理异常
结束时清理临时 Runtime State
따라서 Prompt가 실제로 모델을 요청하기도 전에 이미 다음 상태 변화가 시작됩니다:
idle
↓ prompt()
activeRun created
isStreaming = true
↓
runAgentLoop()
Pi 내부에서는 이 활동 객체를 ActiveRun이라고 부릅니다. 다만 이는 Runtime의 현재 processing interval을 제어하는 객체일 뿐, 제품 계층에서 영속화할 수 있는 Run Entity와 같지는 않습니다.
주로 세 가지 요구를 충족합니다:
知道当前 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()는 messageend 이후 완료된 User Message를 state.messages에 추가합니다.[2][3]
여기서 매우 중요한 구조가 나타납니다:
동일한 User Message는 한쪽으로 Loop Local Context에 이미 존재해 이번 계산에 쓰이고, 다른 한쪽으로는 이벤트를 통해 장기 Agent State에 들어갑니다.
두 표현은 서로 다른 역할을 맡습니다:
- Local Context는 진행 중인 계산에 연속적인 메시지 시퀀스를 제공합니다;
- Agent State는 외곽 관찰자와 이후 실행에 안정적인 상태를 제공합니다.
왜 Loop이 매번 Agent State를 다시 읽게 하지 않는가
매 모델 요청마다 Agent.messages에서 전체 이력을 다시 읽는 구현도 생각할 수 있습니다.
표면上看 Local Context를 줄일 수 있지만 새로운 결합이 생깁니다.
현재 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()는 왜 두 겹의 루프를 갖는가
Agent의 전진을 실제로 제어하는 것은 runLoop()입니다.
세부사항을 압축하면 구조는 대략 다음과 같습니다:
이 두 겹의 루프는 Pi가 실행 경계를 판단하는 방식을 코드에 직접 담고 있습니다.[3]
Inner Loop은 ‘현재 작업이 왜 계속되어야 하는가’를 담당합니다:
模型产生了 Tool Call
→ 继续
有 Steering Message 等待消费
→ 继续
Outer Loop은 ‘현재 작업은 멈출 준비가 되었지만 뒤따를 작업이 남아 있는가’를 담당합니다:
有 Follow-up
→ 进入后续工作
没有 Follow-up
→ agent_end
따라서 세 종류의 입력은 서로 다른 시점에 놓입니다:
Tool Result
属于当前模型决策链的结果
Steering
尽快影响当前连续工作中的下一次模型决策
Follow-up
等当前连续工作结束后再继续
소스 코드 구조를 보면 Pi의 제어 의미는 Loop이 입력을 소비하는 위치에 그대로 구현되어 있으며, 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는 서로 반대 방향의 두 인터페이스를 이룹니다:
Outer Runtime ── config / callbacks ──→ Loop
Outer Runtime ←──── lifecycle events ── Loop
전자는 실행 커널에 정책과 자원을 제공하고, 후자는 실행 사실을 외곽으로 돌려줍니다. 덕분에 Loop은 비교적 닫힌 실행 의미를 유지할 수 있습니다.
한 번의 Model Request 전에 어떤 변환이 일어나는가
Inner Loop이 다음 Assistant Response를 준비할 때는 먼저 이번 라운드에 Context로 들어갈 메시지를 처리해야 합니다.
그다음 Context는 다음과 같이 변환될 수 있습니다:
currentContext
↓
transformContext
↓
convertToLlm
↓
Provider Context
이 계층들은 Runtime Message와 Provider Message를 구분하기에 매우 적합합니다.
Agent Runtime 내부에는 더 풍부한 메시지 타입을 저장해야 할 수 있습니다. 예를 들면 다음과 같습니다:
用户消息
Assistant Message
Tool Result
内部扩展消息
自定义上下文消息
Provider가 이 구조들을 모두 받아들이는 것은 아닙니다.
convertToLlm()의 역할은 실제로 모델을 요청할 때 프로토콜을 하나로 수렴시키는 것입니다. Pi의 기본 구현은 user, assistant, toolResult 세 종류의 메시지를 유지하며, 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 출력은 두 상태를 거칩니다:
Mutable / In-progress
streamingMessage
↓ message_end
Stable / Completed
messages[]
이 경계는 프론트엔드에 매우 유용합니다.
UI는 message_update를 구독해 token을 실시간으로 표시할 수 있고, 히스토리 메시지 목록은 완료된 Message를 안정적인 단위로 유지할 수 있습니다.
나중에 증분 chunk를 영속화해야 한다면 더 바깥쪽에서 별도의 Event Log를 두면 됩니다. 저수준 Agent는 모든 streaming update를 안정적인 transcript로 다룰 필요가 없습니다.
따라서 Pi는 여기서 ‘실행 상태의 증분 + 완료 상태의 메시지’라는 이중 표현을 선택합니다.
Assistant Message는 어떻게 Tool Execution을 시작하는가
Assistant Message가 완료되면 Loop는 그 content에서 Tool Calls를 추출합니다.
개념적으로 Tool Call은 다음과 같이 진행됩니다:
tool_execution_start
↓
execute tool
↓
tool_execution_update*
↓
tool_execution_end
↓
ToolResultMessage
외곽 Agent는 toolexecutionstart에서 해당 toolCallId를 pendingToolCalls에 넣고, 종료 이벤트 이후 제거합니다.[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에도 Provider가 고립된 Tool Result를 거부한 사례가 기록돼 있습니다.[4] Runtime 입장에서는 메시지 이력에 ‘이런 내용이 있다’는 것만으로는 부족하며, 모델 프로토콜이 요구하는 구조적 관계도 유지해야 합니다.
여러 Tool Call에는 배치 경계도 필요합니다
하나의 Assistant Message가 여러 Tool Call을 동시에 만들 수 있습니다. 이때 Runtime은 현재 Step에서 이 Tool들을 어떻게 실행할지, 그리고 언제 이 Tool Work 묶음이 끝났다고 판단할지 정해야 합니다.
다음처럼 추상화할 수 있습니다:
구체적인 구현이 직렬인지 병렬인지와 무관하게, 다음 모델 요청은 Tool Call에 대응하는 완전한 결과 집합을 받아야 합니다. 그렇지 않으면 명시적인 오류 또는 중단 결과를 받아야 합니다.
이는 Steering의 전달 시점에도 영향을 줍니다. 현재 모델이 A, B, C 세 Tool Call을 한 번에 만들고 A가 끝난 직후 Steering이 Context에 들어오면, B와 C는 여전히 이전 결정에 따라 실행되는 반면 모델은 이미 새로운 제약을 본 상태가 될 수 있습니다. Pi 문서에서는 Steering을 현재 Assistant Turn의 Tool Call 실행이 모두 끝난 뒤 다음 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 메커니즘을 통해 외곽 상태와 동기화됩니다.
반대로 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가 진행되기 전에 디스크에 기록되도록 요구할 수 있습니다. 이는 더 높은 계층의 영속화 의미이며 최소 Loop에 들어갈 필요가 없습니다.
Steering은 왜 Tool이 끝난 뒤에 확인하는가
도구 실행 중에는 새 Steering Message가 이미 Queue에 들어왔을 수 있습니다.
Pi는 도구가 실행되는 도중에 현재 Context를 바로 바꾸지는 않습니다. Tool이 완료되면 Inner Loop이 다시 다음을 호출합니다:
Agent.createLoopConfig()는 이를 steeringQueue.drain()에 연결합니다.[2]
따라서 실제 전달 순서는 다음과 같습니다:
Pi RPC 문서도 Steer를 현재 Assistant Turn의 Tool Call 실행이 완료된 뒤, 다음 LLM Call 전에 전달한다고 명시합니다.[5]
이 설계는 주로 두 가지 안정적인 경계를 지킵니다.
첫째, 이미 시작된 Tool Call이 새 자연어 입력 때문에 갑자기 매개변수 의미를 바꾸지 않습니다.
둘째, 다음 Model Request가 Tool Result와 Steering Message를 함께 보고 새로운 완전한 상태에서 결정을 이어갈 수 있습니다.
진행 중인 Tool을 즉시 종료해야 한다면 Abort를 사용해야 합니다. Steer는 이후 결정을 위한 입력으로 남겨 둡니다.
덕분에 ‘다음 진행 방향을 바꾸기’와 ‘진행 중인 작업을 종료하기’를 서로 다른 제어 동작으로 유지할 수 있습니다.
Event Sink는 왜 전체 구조의 핵심 연결점인가
저수준 runLoop()은 Agent._state에 직접 쓰지 않습니다. Event Sink로 이벤트를 보냅니다:
processEvents()는 외곽 Runtime State를 갱신하는 한편 이벤트를 listeners에게도 알립니다.
따라서 하나의 실행 사실이 여러 소비자를 상대할 수 있습니다:
저수준 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를 투영해 얻어져야 합니다.
Pi의 현재 저수준 Agent는 여전히 _state.messages, streamingMessage, pendingToolCalls를 직접 관리합니다. Event는 이러한 상태 변화를 유도하고 알리는 역할을 합니다.
DeepSeek Harness의 Session Event Log는 Event를 영속적인 사실 원천의 계층으로 승격시킵니다. 두 번째 단위에서 이 설계 차이를 중점적으로 분석하겠습니다.
이 구분을 유지하면 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는 현재 활성 수명 주기에 직접 작용합니다.
이 두 제어 차원을 섞으면 사용자 입력의 동작을 예측하기 어려워집니다.
Follow-up은 왜 Outer Loop에 두는가
Inner Loop에 Tool Call도 Steering Message도 더는 없고 현재의 연속 작업이 자연스러운 정지점에 도달하면 Runtime이 Follow-up Queue를 확인합니다.
이때의 흐름은 다음과 같습니다:
따라서 Follow-up은 현재 Turn의 가장 최근 모델 결정에 참여하지 않습니다. 현재 작업이 끝난 뒤에 후속 작업을 이룹니다.
이 의미를 Steering과 같은 Queue에 공유한다면, 메시지를 소비할 때마다 이것이 현재 작업에 속하는지 다음 작업에 속하는지 다시 판단해야 합니다. Pi는 두 종류의 Queue와 두 겹의 루프로 이를 직접 표현해, 코드 구조와 제품 의미가 일치하도록 합니다.
DeepSeek Harness는 이 차이를 next-step / next-turn Target으로 한 단계 더 추상화합니다. 다음 글에서 계속 비교하겠습니다.
Prompt 하나의 전체 상태 흐름
앞에서 다룬 국소적인 과정을 합치면 전체 상태 변화를 얻을 수 있습니다:
이 그림은 기본적으로 agent.ts + agent-loop.ts를 읽을 때의 주요 흐름이 될 수 있습니다.
소스 코드 관점에서 가장 유지할 가치가 있는 것은 세 가지 경계입니다
이 호출 체인을 모두 읽은 뒤에는 Pi의 구체적인 메서드 이름을 잠시 내려놓고, 세 가지 구조적 경계를 기억하는 편이 더 중요합니다.
첫째는 State와 Context의 경계입니다. Agent._state는 오래 유지되고, Loop은 Snapshot과 Local Context로 현재 계산을 전진시킵니다.
둘째는 Callback과 Event의 경계입니다. 외곽은 Loop Config를 통해 정책과 자원을 제공하고, 저수준은 Lifecycle Event로 실행 사실을 외부에 반환합니다.
셋째는 Step-like와 Turn-like의 경계입니다. Tool / Steering은 내부 루프에서 현재 작업을 계속하고, Follow-up은 정지 경계를 넘어선 뒤 후속 작업으로 들어갑니다.
State ──snapshot──→ Local Context
Runtime ──callbacks──→ Loop ──events──→ Runtime
Inner Loop ──stop boundary──→ Outer Loop
이 세 가지 경계는 특정 함수 이름만 기억하는 것보다 안정적이며, 다른 Agent Framework의 소스 코드를 읽을 때도 더 쉽게 옮겨 적용할 수 있습니다.
Pi Loop이 유지하는 핵심 의미
소스 코드에서 저수준 Agent Loop의 책임을 여섯 가지로 정리할 수 있습니다:
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 오케스트레이션 같은 고급 기능은 외곽 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 문서: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md
[6] Pi Harness V2 노트: https://github.com/badlogic/pi-mono/blob/main/packages/agent/docs/harness-v2.md