coding-agent.dev
2026-03-18 · 55 min read · 소스 분석

Pi Framework 엔진 해부: OpenClaw를 움직이는 ReAct 이중 루프의 비밀

OpenClaw의 심장 pi-mono를 소스 레벨로 분석합니다. 15개 LLM 통합, ReAct 이중 루프, 7개 패키지 구조, Extension 시스템까지.

들어가며

OpenClaw라는 이름을 들어본 적이 있는가? libGDX의 창시자이자 게임 개발 커뮤니티의 전설적인 인물 Mario Zechner(badlogic)가 만든 AI 코딩 에이전트다. Claude Code, Cursor, Windsurf 같은 상용 제품들과 어깨를 나란히 하면서도 완전 오픈소스로 공개되어 있다.

그런데 OpenClaw의 진짜 핵심은 눈에 보이는 UI가 아니라, 그 아래에서 돌아가는 **Pi Framework (pi-mono)**라는 엔진이다. TypeScript/Node.js로 작성된 이 AI Agent Framework는 15개 이상의 LLM 프로바이더를 단일 인터페이스로 통합하고, ReAct 패턴의 이중 루프 구조로 에이전트를 구동하며, 강력한 Extension 시스템을 통해 무한 확장이 가능하다.

이 글에서는 pi-mono의 소스 코드를 한 줄 한 줄 파헤쳐, 아키텍처의 핵심 설계 결정부터 ReAct 루프의 내부 동작까지 모든 것을 분석한다.


1. 왜 Pi Framework인가

1.1 OpenClaw의 엔진

Pi Framework는 OpenClaw에 임베딩되어 그 AI 엔진 역할을 한다. OpenClaw이 "코딩 에이전트 제품"이라면, Pi Framework는 그 제품을 가능하게 하는 "에이전트 런타임"이다. 이 관계를 이해하는 것이 중요하다. Pi Framework 자체는 범용 AI 에이전트 프레임워크이고, OpenClaw은 그 위에 코딩 특화 기능을 얹은 구현체다.

1.2 Claude Code의 대안

Claude Code가 Anthropic 모델에 최적화되어 있다면, Pi Framework는 **프로바이더 독립적(provider-agnostic)**이다. OpenAI, Anthropic, Google, Mistral, xAI, Groq 등 15개 이상의 LLM 프로바이더를 지원하며, 심지어 대화 중간에 모델을 전환할 수도 있다. 이것은 단순한 기능 차별화가 아니라, 근본적인 아키텍처 철학의 차이다.

1.3 의도적인 설계 결정

Pi Framework가 특히 흥미로운 이유는 의도적으로 빠진 것들 때문이다:

  • NO MCP (Model Context Protocol) -- 자체 Extension 시스템으로 대체
  • NO Sub-Agents -- 단일 에이전트 루프로 복잡성 제거
  • NO Permission Popups -- Extension 기반 승인 게이트로 대체
  • NO Plan Mode -- ReAct 패턴이 곧 계획이자 실행

이 결정들은 단순히 "아직 구현 안 함"이 아니라, 복잡성을 의도적으로 줄이면서 Extension으로 필요한 기능을 구현할 수 있게 한 설계 철학이다.


2. 5계층 아키텍처

Pi Framework는 명확한 5개 계층으로 구성된 Layered Architecture with Event-Driven Communication 패턴을 따른다.

2.1 전체 아키텍처 다이어그램

┌─────────────────────────────────────────────────────────────┐
│                   PRESENTATION LAYER                         │
│  • Terminal UI (TUI)                                         │
│  • CLI Interface                                             │
│  • RPC Server                                                │
│  • SDK API                                                   │
└─────────────────────────────────────────────────────────────┘
                            ↓↑ Events
┌─────────────────────────────────────────────────────────────┐
│                   APPLICATION LAYER                          │
│  • AgentSession (Session Management)                         │
│  • Extension System                                          │
│  • Command Processing                                        │
│  • Resource Loading (Skills, Prompts, Themes)               │
└─────────────────────────────────────────────────────────────┘
                            ↓↑ Events
┌─────────────────────────────────────────────────────────────┐
│                   DOMAIN LAYER                               │
│  • Agent (State Management)                                  │
│  • AgentLoop (ReAct Pattern Implementation)                 │
│  • Tool Registry & Execution                                 │
│  • Message Queue (Steering & Follow-up)                     │
└─────────────────────────────────────────────────────────────┘
                            ↓↑ Streams
┌─────────────────────────────────────────────────────────────┐
│                   INTEGRATION LAYER                          │
│  • StreamSimple (Unified LLM Interface)                      │
│  • Provider Registry                                         │
│  • API Adapters (OpenAI, Anthropic, Google, etc.)          │
│  • Token & Cost Tracking                                    │
└─────────────────────────────────────────────────────────────┘
                            ↓↑ HTTPS/WS
┌─────────────────────────────────────────────────────────────┐
│                   EXTERNAL SERVICES                          │
│  • OpenAI API                                                │
│  • Anthropic API                                             │
│  • Google Gemini API                                         │
│  • Other LLM Providers (15+)                                │
└─────────────────────────────────────────────────────────────┘

2.2 각 계층 상세 설명

Presentation Layer (프레젠테이션 계층)

사용자와 직접 상호작용하는 최상위 계층이다. 4가지 실행 모드를 지원한다:

모드설명사용 시나리오
Interactive TUI터미널 기반 대화형 UI일반적인 코딩 작업
CLI단일 프롬프트 실행 후 종료CI/CD, 스크립트
RPCJSON-RPC 서버 모드에디터 통합 (Emacs, Vim)
SDK프로그래밍 API애플리케이션 임베딩

핵심은 이 4가지 모드가 모두 동일한 Domain Layer를 공유한다는 점이다. UI 로직과 비즈니스 로직이 완벽하게 분리되어 있다.

Application Layer (애플리케이션 계층)

AgentSession이 이 계층의 중심이다. 세션 관리, Extension 로딩, 프롬프트 템플릿 확장, 리소스(Skills, Themes) 로딩을 담당한다. Domain Layer의 Agent를 래핑하여 더 풍부한 기능을 제공하는 Facade 역할을 한다.

Domain Layer (도메인 계층)

Pi Framework의 심장이다. Agent 클래스가 상태를 관리하고, AgentLoop이 ReAct 패턴을 구현하며, Tool Registry가 도구를 관리한다. 가장 중요한 Message Queue(Steering & Follow-up)가 이 계층에 있다.

Integration Layer (통합 계층)

15개 이상의 LLM 프로바이더를 Strategy Pattern으로 추상화한다. streamSimple() 함수 하나로 어떤 프로바이더든 동일하게 호출할 수 있다.

External Services (외부 서비스)

실제 LLM API 호출이 이루어지는 계층이다. HTTPS 또는 WebSocket을 통해 SSE(Server-Sent Events) 스트리밍으로 응답을 수신한다.

2.3 계층 간 통신

계층 간 통신은 이벤트 스트림으로 이루어진다. 이것이 핵심이다. 각 계층은 이벤트를 발행(emit)하고, 상위 계층이 이를 구독(subscribe)한다.

type AgentEvent =
  | { type: "agent_start" }
  | { type: "agent_end"; messages: AgentMessage[] }
  | { type: "turn_start" }
  | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
  | { type: "message_start"; message: AgentMessage }
  | { type: "message_update"; message: AgentMessage; assistantMessageEvent }
  | { type: "message_end"; message: AgentMessage }
  | { type: "tool_execution_start"; toolCallId, toolName, args }
  | { type: "tool_execution_update"; partialResult }
  | { type: "tool_execution_end"; result, isError }

이 이벤트 기반 통신의 장점은 명확하다:

  • UI와 로직 분리: TUI가 바뀌어도 Agent 로직은 그대로
  • 다중 구독자 지원: TUI, RPC, SDK가 동시에 같은 이벤트를 수신 가능
  • 디버깅 용이: 모든 이벤트가 로깅 가능

3. 7개 패키지 구조

Pi Framework는 monorepo로 구성되어 있으며, 7개의 패키지가 명확한 의존 관계를 형성한다.

3.1 패키지 디렉토리 구조

pi-mono/
├── packages/
│   ├── ai/                # LLM 통합 레이어
│   ├── agent/             # 에이전트 코어 런타임
│   ├── coding-agent/      # 대화형 코딩 에이전트
│   ├── tui/               # 터미널 UI 라이브러리
│   ├── web-ui/            # 웹 컴포넌트
│   ├── mom/               # Slack 봇
│   └── pods/              # vLLM 배포 CLI

3.2 패키지 상세

패키지npm 이름역할핵심 모듈
pi-ai@mariozechner/pi-aiLLM 통합 레이어ApiRegistry, ModelRegistry, streamSimple
pi-agent-core@mariozechner/pi-agent-core에이전트 런타임Agent, AgentLoop, Tool Execution
pi-coding-agent@mariozechner/pi-coding-agent코딩 에이전트AgentSession, Extensions, Compaction
pi-tui@mariozechner/pi-tui터미널 UIRenderer, Box, Input
pi-web-ui@mariozechner/pi-web-ui웹 UI브라우저 빌드
pi-mom@mariozechner/pi-momSlack 봇MOM Server
pi-pods@mariozechner/pi-podsvLLM 배포GPU Pod 관리

3.3 의존성 그래프

pi-coding-agent
    ↓ depends on
    ├─ pi-agent-core
    │     ↓ depends on
    │     └─ pi-ai
    └─ pi-tui

pi-mom
    ↓ depends on
    └─ pi-coding-agent (spawns as subprocess)

pi-web-ui
    ↓ depends on
    └─ pi-ai (browser build)

pi-pods
    (standalone CLI)

핵심 의존 체인은 이것이다:

pi-coding-agent ──[SDK]──> pi-agent-core ──[API]──> pi-ai ──[HTTPS]──> LLM Providers

3.4 각 패키지 상세 분석

pi-ai: LLM 통합의 심장

pi-ai는 15개 이상의 LLM 프로바이더를 단일 인터페이스(streamSimple)로 통합하는 패키지다.

API Registry는 각 프로바이더를 Strategy Pattern으로 등록한다:

interface ApiProvider<TApi extends string, TOptions> {
  api: TApi;
  stream: StreamFunction<TOptions>;
  streamSimple?: SimpleStreamFunction;
}
 
class ApiRegistry {
  private providers = new Map<string, ApiProvider<any, any>>();
 
  register<TApi, TOptions>(provider: ApiProvider<TApi, TOptions>): void;
  get<TApi>(api: TApi): ApiProvider<TApi, any> | undefined;
  list(): string[];
}

Model Registry는 모든 모델의 메타데이터를 관리한다:

interface Model<TApi extends Api = Api> {
  id: string;                    // Model identifier
  name: string;                  // Human-readable name
  api: TApi;                     // API to use
  provider: KnownProvider;       // Provider name
  baseUrl?: string;              // Custom base URL
  reasoning: boolean;            // Supports thinking/reasoning
  input: ("text" | "image")[];   // Input modalities
  cost: {                        // Pricing (per 1M tokens)
    input: number;
    output: number;
    cacheRead: number;
    cacheWrite: number;
  };
  contextWindow: number;         // Max context tokens
  maxTokens: number;             // Max output tokens
  headers?: Record<string, string>;
  compat?: CompatSettings;       // Provider-specific quirks
}

스트리밍 이벤트 타입은 모든 프로바이더에서 공통으로 사용된다:

type AssistantMessageEvent =
  | { type: "start"; partial: AssistantMessage }
  | { type: "text_start"; contentIndex: number }
  | { type: "text_delta"; delta: string; contentIndex: number; partial: AssistantMessage }
  | { type: "text_end"; content: TextContent; contentIndex: number }
  | { type: "thinking_start"; contentIndex: number }
  | { type: "thinking_delta"; delta: string; contentIndex: number; partial: AssistantMessage }
  | { type: "thinking_end"; content: ThinkingContent; contentIndex: number }
  | { type: "toolcall_start"; contentIndex: number }
  | { type: "toolcall_delta"; delta: string; contentIndex: number; partial: AssistantMessage }
  | { type: "toolcall_end"; toolCall: ToolCall; contentIndex: number }
  | { type: "done"; reason: StopReason; message: AssistantMessage }
  | { type: "error"; reason: "error" | "aborted"; error: AssistantMessage };

pi-agent-core: 에이전트 런타임

Agent 클래스가 여기에 있다. 상태 관리, 이벤트 발행, 큐 관리를 담당한다:

export class Agent {
  private _state: AgentState;
  private listeners: Set<(e: AgentEvent) => void>;
  private steeringQueue: AgentMessage[];
  private followUpQueue: AgentMessage[];
  private abortController?: AbortController;
 
  // State accessors
  get state(): AgentState;
  setSystemPrompt(v: string): void;
  setModel(m: Model<any>): void;
  setThinkingLevel(l: ThinkingLevel): void;
  setTools(t: AgentTool<any>[]): void;
 
  // Queue management
  steer(m: AgentMessage): void;          // Interrupt
  followUp(m: AgentMessage): void;        // After completion
 
  // Control
  async prompt(input: string | AgentMessage, images?: ImageContent[]): Promise<void>;
  async continue(): Promise<void>;
  abort(): void;
 
  // Events
  subscribe(fn: (e: AgentEvent) => void): () => void;
}

pi-coding-agent: 완전체

AgentSession이 Core Agent를 래핑하여 세션 관리, Extension, 컴팩션 등 고수준 기능을 제공한다:

export class AgentSession {
  private agent: Agent;
  private sessionManager: SessionManager;
  private extensionRunner: ExtensionRunner;
  private modelRegistry: ModelRegistry;
  private resourceLoader: ResourceLoader;
 
  async prompt(input: string, options?: PromptOptions): Promise<void>;
  async switchSession(sessionId: string): Promise<void>;
  async createBranch(entryId: string): Promise<void>;
  async compact(customInstructions?: string): Promise<CompactionResult>;
  async setModel(model: Model<any>, thinkingLevel?: ThinkingLevel): Promise<void>;
  cycleModel(direction: "forward" | "backward"): ModelCycleResult;
  subscribe(listener: AgentSessionEventListener): () => void;
}

pi-tui: 차등 렌더링 터미널 UI

pi-tui는 자체 구현한 터미널 UI 라이브러리다. 핵심은 차등 렌더링(Differential Rendering) -- 변경된 셀만 업데이트한다:

// Box: Container component
interface Box {
  x: number; y: number;
  width: number; height: number;
  style?: Style;
  content?: string;
  children?: Box[];
}
 
class Renderer {
  render(root: Box): void;
  clear(): void;
  resize(width: number, height: number): void;
}

pi-mom: Slack 통합

Slack 메시지를 pi coding agent에게 위임하는 봇이다:

Slack App (Bot)
    ↓ Slack Events API
MOM Server (Express)
    ↓ Spawn pi process
Pi Coding Agent (RPC mode)
    ↓ Response
MOM Server
    ↓ Slack Web API
Slack Channel

pi-pods: vLLM 배포

GPU 포드에서 vLLM 배포를 관리하는 CLI 도구다. 자체 호스팅 LLM을 위한 인프라 도구로, pi-ai의 커스텀 baseUrl 기능과 연계된다.


4. ReAct 이중 루프 -- 핵심 중의 핵심

이 섹션이 Pi Framework 분석의 하이라이트다. ReAct (Reasoning + Acting) 패턴의 이중 루프 구조는 이 프레임워크의 가장 독창적인 부분이다.

4.1 ReAct 패턴이란

ReAct는 LLM이 **사고(Reasoning)**와 **행동(Acting)**을 반복하며 문제를 해결하는 패턴이다:

┌──────────────────────────────────────────────────────────┐
│                    ReAct Cycle                           │
└──────────────────────────────────────────────────────────┘

User Prompt
    ↓
┌────────────────────┐
│ 1. REASONING       │ ← LLM thinks about the problem
│   (Thinking Block) │   (optional, if model supports reasoning)
└────────┬───────────┘
         │
         ↓
┌────────────────────┐
│ 2. ACTION          │ ← LLM decides which tool(s) to use
│   (Tool Call)      │   and generates arguments
└────────┬───────────┘
         │
         ↓
┌────────────────────┐
│ 3. OBSERVATION     │ ← Agent executes tool and returns result
│   (Tool Result)    │
└────────┬───────────┘
         │
         ↓
    Decision Point
         │
         ├─ More tools needed? → Go to step 1
         │
         ├─ Error occurred? → Error handling
         │
         └─ Task complete? → Final response
                ↓
         ┌────────────────┐
         │ 4. FINAL ANSWER│ ← LLM provides final response
         │   (Text)       │
         └────────────────┘

4.2 이중 루프 구조

Pi의 ReAct 구현이 다른 프레임워크와 차별화되는 핵심은 이중 루프(Dual Loop) 구조다:

┌─────────────────────────────────────────────────────────┐
│              OUTER LOOP (Follow-up Messages)            │
│  ┌───────────────────────────────────────────────────┐  │
│  │         INNER LOOP (Tool Calls + Steering)        │  │
│  │                                                    │  │
│  │  1. Inject pending messages (steering)            │  │
│  │  2. Stream LLM response (thinking + tool calls)   │  │
│  │  3. Execute tool calls                            │  │
│  │  4. Check for steering messages                   │  │
│  │  5. Repeat if more tools or steering              │  │
│  │                                                    │  │
│  └───────────────────────────────────────────────────┘  │
│                                                          │
│  6. Check for follow-up messages                        │
│  7. If present, restart inner loop                      │
│  8. If none, exit                                       │
│                                                          │
└─────────────────────────────────────────────────────────┘

왜 이중 루프인가? 이유는 두 가지 종류의 사용자 개입을 동시에 지원하기 위해서다:

  • Steering (조향): 에이전트가 작업 중일 때 끼어드는 것. "멈춰! 이걸 대신 해줘."
  • Follow-up (후속): 에이전트가 작업을 완료한 후 추가 작업을 요청하는 것. "결과도 요약해줘."

4.3 Outer Loop: Follow-up Message Handler

Outer Loop는 에이전트가 모든 작업을 완료한 후 follow-up 메시지가 있는지 확인한다:

async function runLoop(
  currentContext: AgentContext,
  newMessages: AgentMessage[],
  config: AgentLoopConfig,
  signal: AbortSignal | undefined,
  stream: EventStream<AgentEvent, AgentMessage[]>,
  streamFn?: StreamFn
): Promise<void> {
  let firstTurn = true;
  let pendingMessages: AgentMessage[] = await config.getSteeringMessages?.() || [];
 
  // ==================== OUTER LOOP ====================
  while (true) {
    let hasMoreToolCalls = true;
    let steeringAfterTools: AgentMessage[] | null = null;
 
    // ==================== INNER LOOP ====================
    while (hasMoreToolCalls || pendingMessages.length > 0) {
      // [Inner loop implementation]
    }
 
    // ==================== FOLLOW-UP CHECK ====================
    const followUpMessages = await config.getFollowUpMessages?.() || [];
    if (followUpMessages.length > 0) {
      pendingMessages = followUpMessages;
      continue; // Restart inner loop
    }
 
    break; // No more work, exit outer loop
  }
 
  stream.push({ type: "agent_end", messages: newMessages });
  stream.end(newMessages);
}

Follow-up의 동작 시퀀스를 보자:

User: "Read config.json"
    (Agent is working...)
User: "Also summarize the result" [Queued as follow-up]
    ↓
Agent: Execute read config.json ✅
Agent: LLM responds with file contents
Agent: No more tool calls
    ↓ Check follow-up → Found "Also summarize the result"
Agent: Inject follow-up message
    ↓
LLM: "Here's a summary: ..."

4.4 Inner Loop: Tool Execution + Steering

Inner Loop는 ReAct의 핵심 사이클을 구현한다. LLM 응답 스트리밍 -> 도구 실행 -> 조향 체크를 반복한다:

// ==================== INNER LOOP ====================
while (hasMoreToolCalls || pendingMessages.length > 0) {
  if (!firstTurn) {
    stream.push({ type: "turn_start" });
  } else {
    firstTurn = false;
  }
 
  // ==================== STEP 1: INJECT PENDING MESSAGES ====================
  if (pendingMessages.length > 0) {
    for (const message of pendingMessages) {
      stream.push({ type: "message_start", message });
      stream.push({ type: "message_end", message });
      currentContext.messages.push(message);
      newMessages.push(message);
    }
    pendingMessages = [];
  }
 
  // ==================== STEP 2: STREAM LLM RESPONSE ====================
  const message = await streamAssistantResponse(
    currentContext, config, signal, stream, streamFn
  );
  newMessages.push(message);
 
  // ==================== STEP 3: ERROR HANDLING ====================
  if (message.stopReason === "error" || message.stopReason === "aborted") {
    stream.push({ type: "turn_end", message, toolResults: [] });
    stream.push({ type: "agent_end", messages: newMessages });
    stream.end(newMessages);
    return; // Exit immediately
  }
 
  // ==================== STEP 4: TOOL EXECUTION ====================
  const toolCalls = message.content.filter(c => c.type === "toolCall");
  hasMoreToolCalls = toolCalls.length > 0;
 
  const toolResults: ToolResultMessage[] = [];
  if (hasMoreToolCalls) {
    const toolExecution = await executeToolCalls(
      currentContext.tools, message, signal, stream,
      config.getSteeringMessages
    );
    toolResults.push(...toolExecution.toolResults);
    steeringAfterTools = toolExecution.steeringMessages ?? null;
 
    for (const result of toolResults) {
      currentContext.messages.push(result);
      newMessages.push(result);
    }
  }
 
  stream.push({ type: "turn_end", message, toolResults });
 
  // ==================== STEP 5: STEERING CHECK ====================
  if (steeringAfterTools && steeringAfterTools.length > 0) {
    pendingMessages = steeringAfterTools;
    steeringAfterTools = null;
  } else {
    pendingMessages = await config.getSteeringMessages?.() || [];
  }
}

Steering의 동작 시퀀스가 특히 인상적이다. 도구 실행 사이사이에 사용자 메시지를 체크하고, 발견 시 나머지 도구를 건너뛴다:

LLM Response contains 3 tool calls: [read, bash, write]
    ↓
┌──────────────────────────────────┐
│ Tool 1: read                     │
├──────────────────────────────────┤
│ 1. Emit tool_execution_start     │
│ 2. Validate arguments            │
│ 3. Execute tool.execute()        │
│ 4. Emit tool_execution_end       │
│ 5. Create ToolResultMessage      │
│ 6. Check for steering            │ ← User can interrupt here
└──────────────────────────────────┘
    ↓ No steering, continue
┌──────────────────────────────────┐
│ Tool 2: bash                     │
├──────────────────────────────────┤
│ (same steps as Tool 1)           │
│ 6. Check for steering            │ ← Steering detected!
│    └─ User typed "Stop!"         │
└──────────────────────────────────┘
    ↓ Skip remaining tools
┌──────────────────────────────────┐
│ Tool 3: write (SKIPPED)          │
├──────────────────────────────────┤
│ 1. Emit tool_execution_start     │
│ 2. Create error result:          │
│    "Skipped due to queued        │
│     user message."               │
│ 3. Emit tool_execution_end       │
│    (isError: true)               │
└──────────────────────────────────┘
    ↓
Return tool results + steering messages
    ↓
agentLoop injects steering messages
    ↓
Call LLM again with steering message

4.5 Queue 모드

Steering과 Follow-up 큐는 각각 두 가지 모드를 지원한다:

┌─────────────────────────────────────────────────────────┐
│                  Message Queues                         │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  Steering Queue (Interrupt)                             │
│  ┌─────────────────────────────────────────────┐        │
│  │ Message 1 → Message 2 → Message 3           │        │
│  └─────────────────────────────────────────────┘        │
│  • Delivered after current tool execution               │
│  • Skips remaining tools                                │
│  • Mode: "one-at-a-time" or "all"                       │
│                                                          │
│  Follow-up Queue (After completion)                     │
│  ┌─────────────────────────────────────────────┐        │
│  │ Message 1 → Message 2                       │        │
│  └─────────────────────────────────────────────┘        │
│  • Delivered after agent finishes all work              │
│  • Mode: "one-at-a-time" or "all"                       │
│                                                          │
└─────────────────────────────────────────────────────────┘
  • one-at-a-time: 큐에서 메시지를 하나씩 꺼내서 처리
  • all: 큐의 모든 메시지를 한 번에 컨텍스트에 주입

4.6 LLM 응답 스트리밍

streamAssistantResponse 함수는 LLM 호출의 전체 파이프라인을 관리한다:

async function streamAssistantResponse(
  context: AgentContext,
  config: AgentLoopConfig,
  signal: AbortSignal | undefined,
  stream: EventStream<AgentEvent, AgentMessage[]>,
  streamFn?: StreamFn
): Promise<AssistantMessage> {
  // STEP 1: TRANSFORM CONTEXT (Optional)
  let messages = context.messages;
  if (config.transformContext) {
    messages = await config.transformContext(messages, signal);
  }
 
  // STEP 2: CONVERT TO LLM FORMAT
  const llmMessages = await config.convertToLlm(messages);
 
  // STEP 3: BUILD LLM CONTEXT
  const llmContext: Context = {
    systemPrompt: context.systemPrompt,
    messages: llmMessages,
    tools: context.tools,
  };
 
  // STEP 4: RESOLVE API KEY
  const resolvedApiKey =
    (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined)
    || config.apiKey;
 
  // STEP 5: STREAM FROM LLM
  const streamFunction = streamFn || streamSimple;
  const response = await streamFunction(config.model, llmContext, {
    ...config,
    apiKey: resolvedApiKey,
    signal,
  });
 
  // STEP 6: PROCESS EVENTS
  for await (const event of response) {
    switch (event.type) {
      case "start":
        // Initialize partial message
        break;
      case "text_delta":
      case "thinking_delta":
      case "toolcall_delta":
        // Update partial message, emit message_update
        break;
      case "done":
      case "error":
        // Finalize message, emit message_end
        return finalMessage;
    }
  }
}

이벤트 변환 흐름이 깔끔하다:

LLM Provider Event (Anthropic)
{ type: "content_block_delta", delta: { type: "text_delta", text: "Hello" } }
    ↓ Transformed to AssistantMessageEvent
{ type: "text_delta", delta: "Hello", contentIndex: 0, partial: AssistantMessage }
    ↓ Wrapped as AgentEvent
{ type: "message_update", message: AgentMessage, assistantMessageEvent: {...} }

4.7 도구 실행 상세

도구는 항상 **순차적(sequential)**으로 실행된다. 이것은 의도적인 설계 결정이다:

async function executeToolCalls(
  tools: AgentTool<any>[] | undefined,
  assistantMessage: AssistantMessage,
  signal: AbortSignal | undefined,
  stream: EventStream<AgentEvent, AgentMessage[]>,
  getSteeringMessages?: () => Promise<AgentMessage[]>
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
  const toolCalls = assistantMessage.content.filter(c => c.type === "toolCall");
  const results: ToolResultMessage[] = [];
  let steeringMessages: AgentMessage[] | undefined;
 
  for (let i = 0; i < toolCalls.length; i++) {
    const toolCall = toolCalls[i];
    const tool = tools?.find(t => t.name === toolCall.name);
 
    stream.push({
      type: "tool_execution_start",
      toolCallId: toolCall.id,
      toolName: toolCall.name,
      args: toolCall.arguments,
    });
 
    let result: AgentToolResult<any>;
    let isError = false;
 
    try {
      if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
      const validatedArgs = validateToolArguments(tool, toolCall);
 
      result = await tool.execute(
        toolCall.id,
        validatedArgs,
        signal,
        (partialResult) => {
          stream.push({
            type: "tool_execution_update",
            toolCallId: toolCall.id,
            toolName: toolCall.name,
            args: toolCall.arguments,
            partialResult,
          });
        }
      );
    } catch (e) {
      result = {
        content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
        details: {},
      };
      isError = true;
    }
 
    // Create ToolResultMessage and check for steering
    // ...
 
    if (getSteeringMessages) {
      const steering = await getSteeringMessages();
      if (steering.length > 0) {
        steeringMessages = steering;
        // Skip remaining tools
        const remaining = toolCalls.slice(i + 1);
        for (const skipped of remaining) {
          results.push(skipToolCall(skipped, stream));
        }
        break;
      }
    }
  }
 
  return { toolResults: results, steeringMessages };
}

순차 실행의 이유:

  • 단순한 에러 처리
  • 예측 가능한 실행 순서
  • 도구 간 종속성 지원 (이전 도구의 결과에 의존할 수 있음)
  • 도구 실행 사이에 Steering 체크 가능

Extension을 통해 병렬 실행 패턴도 구현 가능하다 -- 이것이 Pi Framework 설계의 핵심 철학이다.

4.8 Thinking/Reasoning 지원

Pi Framework는 모델별 Thinking/Reasoning을 지원한다:

type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";

프로바이더별 매핑:

// OpenAI (reasoning effort)
if (model.provider === "openai") {
  options.reasoningEffort = thinkingLevel; // "low" | "medium" | "high"
}
 
// Anthropic (thinking enabled + token budget)
if (model.provider === "anthropic") {
  options.thinkingEnabled = thinkingLevel !== "off";
  options.thinkingBudgetTokens = thinkingBudgets[thinkingLevel]; // 128, 512, 1024, 2048
}
 
// Google (thinking config)
if (model.provider === "google") {
  options.thinking = {
    enabled: thinkingLevel !== "off",
    budgetTokens: thinkingBudgets[thinkingLevel],
  };
}

Thinking이 활성화된 경우의 이벤트 흐름:

LLM starts responding
    ↓
thinking_start
    ↓
thinking_delta "Let me analyze..."
thinking_delta "The user wants..."
thinking_delta "I should use read tool..."
    ↓
thinking_end
    ↓
toolcall_start
toolcall_delta (streaming tool arguments)
toolcall_end
    ↓
done

4.9 ReAct 성능 메트릭

PhaseDurationFactors
LLM Thinking500ms - 2sModel, thinking level, context size
Tool SelectionIncluded in LLMN/A
Tool Execution10ms - 5sTool type (read: fast, bash: slow)
Result Parsing<10msJSON parsing
Context Update<10msIn-memory operation
Total Cycle1s - 10sDepends on tool count and LLM speed

LLM Latency가 사이클 시간의 80-90%를 차지한다. 이것은 모든 에이전트 프레임워크의 공통 병목이다.


5. Agent 타입과 구현

5.1 Agent 분류 체계

Pi Framework는 단일 Agent 클래스를 제공하지만, 사용 패턴에 따라 다양한 타입으로 동작한다:

┌─────────────────────────────────────────────────────────┐
│                  Pi Agent Taxonomy                      │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  1. Core Agent (pi-agent-core)                          │
│     └─ Base stateful agent with tool execution         │
│                                                          │
│  2. Coding Agent (pi-coding-agent)                      │
│     └─ Session-aware agent with extensions             │
│                                                          │
│  3. Extended Agents (via Extension System)              │
│     ├─ Sub-Agent (parallel execution)                   │
│     ├─ Planning Agent (task decomposition)              │
│     ├─ Approval Agent (confirmation gates)              │
│     └─ Custom Agent (user-defined behavior)             │
│                                                          │
└─────────────────────────────────────────────────────────┘

5.2 Agent 상태 머신

┌─────────────────────────────────────────────────────────┐
│                  Agent State Diagram                    │
└─────────────────────────────────────────────────────────┘

    [Idle]
      │
      │ prompt() / continue()
      ↓
   [Streaming] ─────────────────────┐
      │                             │
      │ text_delta                  │ abort()
      │ thinking_delta              │
      │ toolcall_end                ↓
      │                        [Aborting]
      │                             │
   [Executing Tools] ────────────────┤
      │                             │
      │ tool_execution_start        │
      │ tool_execution_update       │
      │ tool_execution_end          │
      │                             │
      │ All tools done              │
      │ OR steering message         │
      │                             │
      ↓                             │
   [Streaming] ←────────────────────┘
      │
      │ Final response
      │ No more tool calls
      │ No follow-up messages
      ↓
    [Idle]

5.3 Agent State 구조

export interface AgentState {
  // Configuration
  systemPrompt: string;
  model: Model<any>;
  thinkingLevel: ThinkingLevel; // "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
  tools: AgentTool<any>[];
 
  // Conversation
  messages: AgentMessage[]; // Full history
 
  // Runtime status
  isStreaming: boolean;
  streamMessage: AgentMessage | null;
  pendingToolCalls: Set<string>;
  error?: string;
}

5.4 Core Agent vs Coding Agent vs Extended Agent

FeatureCore AgentCoding AgentExtended Agent (via Extensions)
Tool ExecutionYesYesYes (custom tools)
Session PersistenceNoYes (JSONL)Yes (via AgentSession)
CompactionNoYes (auto/manual)Yes (customizable)
ExtensionsNoYesYes (full API)
TUINoYesYes (customizable)
CLI ModesNoYes (interactive, print, RPC, SDK)Yes
Sub-AgentsNoNoYes (extension pattern)
PlanningNoNoYes (extension pattern)
Approval GatesNoNoYes (extension pattern)

5.5 Extension 기반 Agent 패턴

Sub-Agent Pattern

Extension으로 Sub-Agent를 구현하는 패턴이다. 내장은 아니지만, Extension API를 통해 깔끔하게 구현할 수 있다:

export default function subAgentExtension(api: ExtensionAPI) {
  api.registerTool({
    name: "spawn_sub_agent",
    label: "Spawn Sub-Agent",
    description: "Create a sub-agent for parallel task execution",
    parameters: Type.Object({
      task: Type.String({ description: "Task for sub-agent" }),
      model: Type.Optional(Type.String({ description: "Model override" })),
    }),
    execute: async (id, params, signal, onUpdate) => {
      const subAgent = new Agent({
        initialState: {
          systemPrompt: `You are a sub-agent. Task: ${params.task}`,
          model: params.model ? getModel(...) : api.getAgent().state.model,
          tools: api.getAgent().state.tools,
        },
      });
 
      const result = await new Promise<string>((resolve) => {
        subAgent.subscribe((event) => {
          if (event.type === "agent_end") {
            const lastMessage = event.messages[event.messages.length - 1];
            resolve(extractText(lastMessage));
          }
        });
        subAgent.prompt(params.task);
      });
 
      return {
        content: [{ type: "text", text: result }],
        details: { task: params.task },
      };
    },
  });
}

Approval Agent Pattern

Extension의 wrapTool로 기존 도구에 승인 게이트를 추가한다:

export default function approvalExtension(api: ExtensionAPI) {
  api.wrapTool("bash", (originalTool) => {
    return {
      ...originalTool,
      execute: async (id, params, signal, onUpdate) => {
        const command = params.command;
        const dangerous = /rm|del|format|shutdown/.test(command);
 
        if (dangerous) {
          const confirmed = await api.showConfirmation(
            `Execute dangerous command?\n${command}`
          );
          if (!confirmed) {
            throw new Error("User cancelled operation");
          }
        }
 
        return await originalTool.execute(id, params, signal, onUpdate);
      },
    };
  });
}

이것이 "NO Permission Popups"의 비밀이다. 권한 팝업이 없는 게 아니라, Extension으로 원하는 수준의 승인 게이트를 직접 구현할 수 있다.


6. LLM 프로바이더 통합

6.1 Strategy Pattern 기반 아키텍처

Pi Framework의 LLM 통합은 전형적인 Strategy Pattern이다:

// Provider Strategy Interface
interface ProviderStream {
  (model: Model, context: Context, options: StreamOptions):
    AsyncGenerator<AssistantMessageEvent>
}
 
// Concrete Strategies
const providers: Record<Api, ProviderStream> = {
  "openai-completions": streamOpenAICompletions,
  "anthropic-messages": streamAnthropic,
  "google-generative-ai": streamGoogle,
  // ... 15+ more providers
};
 
// Unified interface
async function* streamSimple(
  model: Model,
  context: Context,
  options?: SimpleStreamOptions
): AssistantMessageEventStream {
  const provider = providers[model.api];
  yield* provider(model, context, options);
}

6.2 등록된 API 프로바이더

API ID프로바이더설명
openai-completionsOpenAIChat Completions API
openai-responsesOpenAIResponses API (reasoning models)
openai-codex-responsesOpenAICodex API (ChatGPT Plus/Pro)
azure-openai-responsesAzureAzure OpenAI Responses API
anthropic-messagesAnthropicMessages API
google-generative-aiGoogleGemini API
google-vertexGoogleVertex AI API
google-gemini-cliGoogleCloud Code Assist
mistral-conversationsMistralConversations API
bedrock-converse-streamAWSAmazon Bedrock Converse API

그리고 xAI, Groq 등 추가 프로바이더가 있다.

6.3 스트리밍 인터페이스

모든 프로바이더는 동일한 스트리밍 인터페이스를 통해 접근된다:

export async function* streamSimple(
  model: Model<any>,
  context: Context,
  options?: SimpleStreamOptions
): AssistantMessageEventStream {
  // 1. Select provider
  const api = apiRegistry.get(model.api);
 
  // 2. Resolve API key
  const apiKey = options?.apiKey || getEnvApiKey(model.provider);
 
  // 3. Map options to provider format
  const providerOptions = mapOptionsForApi(model.api, options);
 
  // 4. Stream from provider
  yield* api.stream(model, context, providerOptions);
}

6.4 프로바이더 어댑터 예시: Anthropic

export async function* streamAnthropic(
  model: Model<"anthropic-messages">,
  context: Context,
  options: AnthropicOptions
): AssistantMessageEventStream {
  // 1. Transform messages to Anthropic format
  const anthropicMessages = transformMessages(context.messages);
 
  // 2. Build request payload
  const payload = {
    model: model.id,
    messages: anthropicMessages,
    system: context.systemPrompt,
    tools: context.tools?.map(transformTool),
    thinking: options.thinkingEnabled ? { type: "enabled" } : undefined,
    max_tokens: model.maxTokens,
    stream: true,
  };
 
  // 3. Send request
  const response = await fetch(`${model.baseUrl}/v1/messages`, {
    method: "POST",
    headers: {
      "x-api-key": options.apiKey,
      "anthropic-version": "2023-06-01",
      "content-type": "application/json",
    },
    body: JSON.stringify(payload),
  });
 
  // 4. Parse streaming response
  for await (const event of parseSSE(response.body)) {
    switch (event.type) {
      case "content_block_delta":
        if (event.delta.type === "text_delta") {
          yield { type: "text_delta", delta: event.delta.text, ... };
        }
        break;
      case "message_stop":
        yield { type: "done", reason: "stop", message: ... };
        break;
    }
  }
}

6.5 Cross-Provider 메시지 변환

Pi Framework의 가장 강력한 기능 중 하나는 대화 중간에 모델을 전환할 수 있다는 것이다. 이를 가능하게 하는 것이 Cross-Provider 메시지 변환이다:

function transformMessages(
  messages: Message[],
  targetApi: Api,
  sourceApi?: Api
): ProviderMessage[] {
  return messages.map(msg => {
    // User messages: pass through
    if (msg.role === "user") {
      return transformUserMessage(msg, targetApi);
    }
 
    // Assistant messages from same provider/API: preserve
    if (msg.role === "assistant" && msg.api === targetApi) {
      return msg;
    }
 
    // Assistant messages from different providers:
    // Convert thinking blocks to <thinking> tagged text
    if (msg.role === "assistant") {
      return transformCrossProviderAssistant(msg, targetApi);
    }
 
    // Tool results: pass through
    if (msg.role === "toolResult") {
      return transformToolResult(msg, targetApi);
    }
  });
}

핵심 포인트: 다른 프로바이더의 thinking block은 <thinking> 태그가 붙은 텍스트로 변환된다. 이렇게 하면 Anthropic 모델이 생성한 thinking block을 OpenAI 모델이 이해할 수 있다.

6.6 메시지 변환 파이프라인

전체 변환 파이프라인은 이렇다:

AgentMessage[] (App-level, includes custom types)
    ↓
[transformContext] (Optional: pruning, injection)
    ↓
AgentMessage[] (Transformed)
    ↓
[convertToLlm] (Required: filter custom types, convert to LLM format)
    ↓
Message[] (LLM-compatible: user, assistant, toolResult)
    ↓
LLM Provider API

Declaration Merging을 활용한 커스텀 메시지 타입도 지원한다:

declare module "@mariozechner/pi-agent-core" {
  interface CustomAgentMessages {
    notification: { role: "notification"; text: string; timestamp: number };
    artifact: { role: "artifact"; artifactId: string; content: string; timestamp: number };
  }
}

convertToLlm 콜백에서 이런 커스텀 타입을 필터링하여 LLM에는 표준 메시지만 전달한다.

6.7 도구 지원

도구 정의는 TypeBox 스키마를 사용한다:

interface Tool<TParameters extends TSchema = TSchema> {
  name: string;
  description: string;
  parameters: TParameters; // TypeBox schema
}
 
interface ToolCall {
  id: string;
  name: string;
  arguments: Record<string, any>;
}
 
interface ToolResultMessage {
  role: "toolResult";
  toolCallId: string;
  toolName: string;
  content: (TextContent | ImageContent)[];
  isError: boolean;
  timestamp: number;
}

도구 인자 검증은 AJV를 사용한다:

function validateToolCall(tools: Tool[], toolCall: ToolCall): any {
  const tool = tools.find(t => t.name === toolCall.name);
  if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
 
  const validator = ajv.compile(tool.parameters);
  if (!validator(toolCall.arguments)) {
    throw new Error(`Invalid arguments: ${ajv.errorsText(validator.errors)}`);
  }
 
  return toolCall.arguments;
}

7. Extension 시스템

Extension 시스템은 Pi Framework의 확장성의 핵심이다. Inversion of Control (IoC) 패턴을 사용하여, Extension이 프레임워크의 거의 모든 동작을 수정할 수 있다.

7.1 Extension 시스템 아키텍처

┌──────────────────────────────────────────────────────────┐
│                    Extension System                       │
├──────────────────────────────────────────────────────────┤
│                                                           │
│  ┌────────────────────────────────────────────────┐      │
│  │         Extension Factory Function             │      │
│  │  export default function(pi: ExtensionAPI)     │      │
│  └─────────────────┬──────────────────────────────┘      │
│                    │                                      │
│                    ▼                                      │
│  ┌─────────────────────────────────────────────────┐     │
│  │             ExtensionAPI                        │     │
│  │  • Event Subscription (pi.on)                   │     │
│  │  • Tool Registration (pi.registerTool)          │     │
│  │  • Command Registration (pi.registerCommand)    │     │
│  │  • Shortcut Registration (pi.registerShortcut)  │     │
│  │  • Message Sending (pi.sendMessage)             │     │
│  │  • State Persistence (pi.appendEntry)           │     │
│  └─────────────────┬───────────────────────────────┘     │
│                    │                                      │
│                    ▼                                      │
│  ┌─────────────────────────────────────────────────┐     │
│  │          ExtensionContext (ctx)                 │     │
│  │  • UI Context (ctx.ui)                          │     │
│  │  • Session Manager (ctx.sessionManager)         │     │
│  │  • Model Registry (ctx.modelRegistry)           │     │
│  │  • CWD, Model, isIdle(), abort()                │     │
│  └─────────────────────────────────────────────────┘     │
│                                                           │
└──────────────────────────────────────────────────────────┘

7.2 Extension 라이프사이클

┌─────────────────────────────────────────────────┐
│  1. Extension Discovery                          │
│     • ~/.pi/agent/extensions/                    │
│     • .pi/extensions/                            │
│     • settings.json packages                     │
└──────────────────┬──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────┐
│  2. Extension Loading (jiti)                     │
│     • TypeScript compilation                     │
│     • Module resolution                          │
│     • Dependency loading                         │
└──────────────────┬──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────┐
│  3. Factory Execution                            │
│     • Call default export function               │
│     • Pass ExtensionAPI instance                 │
│     • Register event listeners, tools, commands  │
└──────────────────┬──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────┐
│  4. Runtime Operation                            │
│     • Event emission                             │
│     • Tool execution                             │
│     • Command invocation                         │
│     • State persistence                          │
└──────────────────┬──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────┐
│  5. Shutdown                                     │
│     • session_shutdown event                     │
│     • Cleanup resources                          │
│     • Final state save                           │
└─────────────────────────────────────────────────┘

Extension은 jiti를 통해 TypeScript를 런타임에 컴파일한다. 빌드 단계가 필요 없다.

7.3 ExtensionAPI 전체 인터페이스

interface ExtensionAPI {
  // ========== Event Subscription ==========
  on<T extends ExtensionEventType>(
    event: T,
    handler: ExtensionEventHandler<T>
  ): void;
 
  // ========== Tool Registration ==========
  registerTool<TParams extends TSchema = TSchema, TDetails = unknown>(
    definition: ToolDefinition<TParams, TDetails>
  ): void;
  getAllTools(): ToolInfo[];
  getActiveTools(): string[];
  setActiveTools(names: string[]): void;
 
  // ========== Command Registration ==========
  registerCommand(
    name: string,
    options: {
      description: string;
      handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
      getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null;
    }
  ): void;
 
  // ========== Shortcut Registration ==========
  registerShortcut(
    shortcut: string,
    options: {
      description: string;
      handler: (ctx: ExtensionContext) => Promise<void>;
    }
  ): void;
 
  // ========== Message Injection ==========
  sendMessage(
    message: {
      customType: string;
      content: string | (TextContent | ImageContent)[];
      display: boolean;
      details?: any;
    },
    options?: {
      triggerTurn?: boolean;
      deliverAs?: "steer" | "followUp" | "nextTurn";
    }
  ): Promise<void>;
 
  sendUserMessage(
    content: string | (TextContent | ImageContent)[],
    options?: {
      deliverAs?: "steer" | "followUp";
    }
  ): Promise<void>;
 
  // ========== State Persistence ==========
  appendEntry(customType: string, data?: any): void;
  setSessionName(name: string): void;
  setLabel(entryId: string, label: string | undefined): void;
 
  // ========== Rendering ==========
  registerMessageRenderer(
    customType: string,
    renderer: (message: CustomMessage, options, theme: Theme) => Component | undefined
  ): void;
 
  // ========== Model Management ==========
  setModel(model: Model): Promise<boolean>;
  getThinkingLevel(): ThinkingLevel;
  setThinkingLevel(level: ThinkingLevel): void;
 
  // ========== Provider Registration ==========
  registerProvider(name: string, config: ProviderConfig): void;
  unregisterProvider(name: string): void;
 
  // ========== Utility ==========
  exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
  readonly events: EventBus;
}

7.4 이벤트 시스템 상세

Extension이 구독할 수 있는 이벤트는 크게 5가지 카테고리로 나뉜다:

type ExtensionEvent =
  // Session Events
  | SessionStartEvent
  | SessionBeforeSwitchEvent
  | SessionSwitchEvent
  | SessionBeforeCompactEvent
  | SessionCompactEvent
  | SessionShutdownEvent
  | SessionBeforeTreeEvent
  | SessionTreeEvent
 
  // Agent Events
  | BeforeAgentStartEvent
  | AgentStartEvent
  | AgentEndEvent
  | TurnStartEvent
  | TurnEndEvent
  | MessageStartEvent
  | MessageUpdateEvent
  | MessageEndEvent
  | ToolExecutionStartEvent
  | ToolExecutionUpdateEvent
  | ToolExecutionEndEvent
  | ContextEvent
 
  // Tool Events
  | ToolCallEvent
  | ToolResultEvent
 
  // User Events
  | UserBashEvent
  | InputEvent
 
  // Model Events
  | ModelSelectEvent
 
  // Resource Events
  | ResourcesDiscoverEvent;

가장 강력한 이벤트들:

before_agent_start -- 시스템 프롬프트를 수정하거나 커스텀 메시지를 주입할 수 있다:

pi.on("before_agent_start", async (event, ctx) => {
  return {
    message: {
      customType: "my-ext:context",
      content: "Additional context for this turn",
      display: false
    },
    systemPrompt: event.systemPrompt + "\n\nExtra instructions..."
  };
});

tool_call -- 도구 호출을 검증하거나 차단할 수 있다:

pi.on("tool_call", async (event, ctx) => {
  if (event.toolName === "bash") {
    const command = event.input.command;
    if (command.includes("rm -rf")) {
      const ok = await ctx.ui.confirm("Dangerous Command", `Allow: ${command}?`);
      if (!ok) {
        return { block: true, reason: "Blocked by user" };
      }
    }
  }
});

context -- LLM에 전달되는 컨텍스트를 필터링할 수 있다:

pi.on("context", async (event, ctx) => {
  const filtered = event.messages.filter(msg => {
    if (msg.role === "user" && msg.timestamp) {
      const age = Date.now() - msg.timestamp;
      return age < 3600000; // 1시간 이내
    }
    return true;
  });
  return { messages: filtered };
});

7.5 도구 등록, 래핑, 교체

Extension API는 세 가지 수준의 도구 커스터마이징을 제공한다:

1. 새 도구 등록 (registerTool)

pi.registerTool({
  name: "calculator",
  label: "Calculator",
  description: "Perform basic arithmetic operations",
  parameters: Type.Object({
    operation: StringEnum(["add", "subtract", "multiply", "divide"] as const),
    a: Type.Number({ description: "First operand" }),
    b: Type.Number({ description: "Second operand" }),
  }),
  async execute(toolCallId, params, signal, onUpdate, ctx) {
    // implementation
    return {
      content: [{ type: "text", text: `${params.a} ${params.operation} ${params.b} = ${result}` }],
      details: { result }
    };
  }
});

2. 도구 래핑 (wrapTool) -- 기존 도구의 동작을 감싸서 전후 처리를 추가

3. 도구 교체 (replaceTool) -- 기존 도구를 완전히 대체

7.6 Skills, Themes, Prompt Templates

Extension 시스템은 세 가지 리소스 타입도 지원한다:

리소스경로용도
Skills~/.pi/agent/skills/, .pi/skills/특정 작업에 대한 전문 지식
Themes~/.pi/agent/themes/, .pi/themes/TUI 외관 커스터마이징
Prompt Templates~/.pi/agent/prompts/, .pi/prompts/재사용 가능한 프롬프트

7.7 고급 Extension 패턴

Planning Agent Pattern

export default function planningExtension(api: ExtensionAPI) {
  api.registerCommand("plan", {
    description: "Create a plan for the given task",
    handler: async (args, ctx) => {
      const task = args.join(" ");
 
      const planningAgent = new Agent({
        initialState: {
          systemPrompt: `You are a planning agent. Break down tasks into steps.
Output format:
1. Step 1
2. Step 2
...`,
          model: api.getAgent().state.model,
          tools: [],
        },
      });
 
      let plan = "";
      planningAgent.subscribe((event) => {
        if (event.type === "message_update" &&
            event.assistantMessageEvent.type === "text_delta") {
          plan += event.assistantMessageEvent.delta;
        }
      });
 
      await planningAgent.prompt(`Create a plan for: ${task}`);
      await fs.writeFile(".pi/plan.md", plan);
      ctx.notify(`Plan created in .pi/plan.md`);
    },
  });
}

8. 세션 관리

8.1 JSONL 기반 저장

세션은 JSONL (JSON Lines) 형식으로 저장된다. 각 라인이 하나의 메시지다:

{"id":"1","parentId":null,"role":"user","content":"Hello","timestamp":1709712000000}
{"id":"2","parentId":"1","role":"assistant","content":"Hi!","timestamp":1709712001000}
{"id":"3","parentId":"2","role":"user","content":"Help","timestamp":1709712010000}
{"id":"4","parentId":"3","role":"assistant","content":"Sure!","timestamp":1709712011000}

저장 경로: ~/.pi/agent/sessions/{project-hash}/{session-id}.jsonl

8.2 트리 구조 브랜칭

JSONL의 parentId 필드를 통해 트리 구조를 형성한다:

1 (user) ─── 2 (assistant) ─── 3 (user) ─── 4 (assistant) ─── 5 (toolResult) ─── 6 (assistant)
                                   │
                                   └─── 3b (user, branch) ─── 4b (assistant)

같은 부모 메시지에서 여러 브랜치가 분기될 수 있다. 이것은 "다른 접근을 시도해보자"라는 사용자의 워크플로우를 자연스럽게 지원한다.

8.3 세션 상태 머신

              ┌────────────┐
              │   LOADING  │
              │   SESSION  │
              └─────┬──────┘
                    │
          Load from JSONL file
                    │
                    ↓
              ┌────────────┐
         ┌────┤   READY    ├────┐
         │    │  (waiting  │    │
         │    │ for input) │    │
         │    └────────────┘    │
         │                      │
    prompt()              switchSession()
         │                      │
         ↓                      ↓
  ┌────────────┐         ┌────────────┐
  │  RUNNING   │         │  SWITCHING │
  │  (agent    │         │   SESSION  │
  │  working)  │         └─────┬──────┘
  └─────┬──────┘               │
        │                Load new session
        │                      │
        ↓                      ↓
  ┌────────────┐         ┌────────────┐
  │   READY    │◄────────┤   READY    │
  └────────────┘         └────────────┘

8.4 Compaction (컴팩션)

컨텍스트 윈도우 관리의 핵심 메커니즘이다. 두 가지 트리거가 있다:

트리거조건동작
Proactive80% 컨텍스트 사용 시자동 압축
Reactive컨텍스트 오버플로우 에러압축 후 재시도

컴팩션 프로세스:

export async function compact(
  agent: Agent,
  context: AgentContext,
  config: CompactionConfig
): Promise<CompactionResult> {
  // 1. Prepare compaction
  const prep = await prepareCompaction(context, config);
  if (!prep.shouldCompact) return { compacted: false };
 
  // 2. Build compaction prompt
  const compactionContext: Context = {
    systemPrompt: buildCompactionSystemPrompt(config.instructions),
    messages: [{
      role: "user",
      content: buildCompactionPrompt(prep.messagesToCompact),
      timestamp: Date.now(),
    }],
  };
 
  // 3. Call LLM for summary
  const summary = await complete(config.model, compactionContext);
  const summaryText = extractText(summary);
 
  // 4. Replace old messages with summary
  const compactedMessages: AgentMessage[] = [
    {
      role: "user",
      content: `<compaction>\n${summaryText}\n</compaction>`,
      timestamp: Date.now(),
    },
    ...prep.messagesToKeep,
  ];
 
  // 5. Update agent context
  agent.replaceMessages(compactedMessages);
 
  return {
    compacted: true,
    summary: summaryText,
    removedMessages: prep.messagesToCompact.length,
    keptMessages: prep.messagesToKeep.length,
  };
}

메모리 타입 정리:

메모리 타입저장 위치특성
Short-TermIn-Memory (AgentState.messages)휘발성, 빠름, 컨텍스트 윈도우 제한
Long-TermJSONL 파일영속적, 검색 가능, 브랜칭
CompactedSummary 메시지손실 압축, 원본 보존, 70-90% 토큰 절감

9. 데이터 흐름

9.1 End-to-End 시퀀스

User    TUI    AgentSession    Agent    AgentLoop    pi-ai    LLM Provider    Tool
 │       │           │           │           │          │           │          │
 │ Input │           │           │           │          │           │          │
 ├──────>│           │           │           │          │           │          │
 │       │ prompt()  │           │           │          │           │          │
 │       ├──────────>│           │           │          │           │          │
 │       │           │ expand    │           │          │           │          │
 │       │           │ templates │           │          │           │          │
 │       │           │ prompt()  │           │          │           │          │
 │       │           ├──────────>│           │          │           │          │
 │       │           │           │ agentLoop()│         │           │          │
 │       │           │           ├─────────>│          │           │          │
 │       │           │           │           │ streamSimple()       │          │
 │       │           │           │           ├─────────────────────>│          │
 │       │           │           │           │          │  HTTPS    │          │
 │       │           │           │           │          ├──────────>│          │
 │       │           │           │           │          │ SSE       │          │
 │       │           │           │           │          │<──────────┤          │
 │       │           │           │           │ text_delta│           │          │
 │       │           │           │           │<──────────│           │          │
 │       │           │           │ message_update        │           │          │
 │       │           │           │<──────────┤           │           │          │
 │       │           │ AgentEvent│           │           │           │          │
 │       │           │<──────────┤           │           │           │          │
 │       │ Event     │           │           │           │           │          │
 │       │<──────────┤           │           │           │           │          │
 │ Render│           │           │           │           │           │          │
 │<──────┤           │           │           │           │           │          │
 │       │           │           │           │ toolcall_end│         │          │
 │       │           │           │           │<──────────│           │          │
 │       │           │           │ execute   │           │           │          │
 │       │           │           │ ToolCalls │           │           │          │
 │       │           │           │<──────────┤           │           │          │
 │       │           │           │           │           │           │ execute()│
 │       │           │           │           │           │           ├─────────>│
 │       │           │           │           │           │           │ result   │
 │       │           │           │           │           │           │<─────────┤
 │       │           │           │ tool_execution_end    │           │          │
 │       │           │           │<──────────┤           │           │          │
 │       │           │ AgentEvent│           │           │           │          │
 │       │           │<──────────┤           │           │           │          │
 │       │ Event     │           │           │           │           │          │
 │       │<──────────┤           │           │           │           │          │
 │ Render│           │           │           │           │           │          │
 │<──────┤           │           │           │           │           │          │

9.2 스트리밍 데이터 흐름

LLM Provider
    ↓ SSE/WebSocket
Provider Adapter (parseStream)
    ↓ AssistantMessageEvent
EventStream (pi-ai)
    ↓ for await (const event of stream)
AgentLoop (event processing)
    ↓ AgentEvent
Agent.emit()
    ↓ Listener callbacks
UI Layer (render)

9.3 메시지 변환 파이프라인

User Input
    ↓
AgentMessage[] (App-level, may include custom types)
    │ { role: "user", content: "Hello" }
    │ { role: "notification", text: "File saved" } ← Custom type
    │ { role: "assistant", content: [...] }
    ↓
┌─────────────────────────────────────────┐
│ transformContext (Optional)             │
│ - Prune old messages                    │
│ - Inject external context               │
│ - Reorder messages                      │
└─────────────────────────────────────────┘
    ↓
AgentMessage[] (Transformed)
    ↓
┌─────────────────────────────────────────┐
│ convertToLlm (Required)                 │
│ - Filter custom types                   │
│ - Convert to LLM format                 │
│ - Handle cross-provider messages        │
└─────────────────────────────────────────┘
    ↓
Message[] (LLM-compatible)
    ↓
Provider Adapter
    │ Transform to provider-specific format
    ↓
Provider Request Payload
    │ { model: "...", messages: [...], tools: [...] }
    ↓
LLM Provider API

9.4 도구 실행 데이터 흐름

LLM Response
    ↓
Provider Adapter: Parse tool_call event
    │ { type: "tool", name: "read", arguments_json: "{...}" }
    ↓
Parse JSON arguments (partial during streaming)
    │ { path: "/home/user/file.txt" }
    ↓
Agent: Collect tool calls
    │ [{ id: "1", name: "read", arguments: {...} }]
    ↓
AgentLoop: Execute tools
    │
    ├─> Validate arguments (TypeBox + AJV)
    │       │ Success: { path: "/home/user/file.txt" }
    │       │ Error: throw ValidationError
    │       ↓
    ├─> Call tool.execute(id, validatedArgs, signal, onUpdate)
    │       │
    │       ├─> Tool reads file
    │       ├─> Tool calls onUpdate({ content: [...], details: {...} })
    │       │       └─> Emit tool_execution_update event
    │       └─> Tool returns { content: [...], details: {...} }
    │       ↓
    └─> Create ToolResultMessage
            │ { role: "toolResult", toolCallId: "1", content: [...], isError: false }
            ↓
Add to context
    │ messages.push(toolResultMessage)
    ↓
Call LLM again with tool results

10. 의도적으로 빠진 것들

Pi Framework의 가장 흥미로운 설계 결정은 "무엇을 넣었는가"가 아니라 **"무엇을 의도적으로 빼었는가"**이다.

10.1 NO MCP (Model Context Protocol)

MCP는 Anthropic이 제안한 표준 프로토콜로, 외부 시스템과의 통합을 정규화한다. 하지만 Pi Framework는 MCP를 채택하지 않았다.

이유: Extension 시스템이 MCP가 제공하는 모든 것을 더 유연하게 제공한다. MCP는 도구 정의와 실행을 표준화하지만, Pi의 Extension API는 도구뿐만 아니라 이벤트, 커맨드, UI, 컨텍스트 변환까지 모두 커스터마이징할 수 있다. MCP의 표준화 장점보다 Extension의 유연성을 택한 것이다.

10.2 NO Sub-Agents (내장)

Claude Code는 Agent/Task 도구를 통해 서브 에이전트를 생성할 수 있다. Pi Framework는 이것을 내장하지 않았다.

이유: 단일 에이전트 루프의 단순성이 디버깅과 예측 가능성에서 큰 이점을 제공한다. 서브 에이전트가 필요한 경우, Extension으로 구현할 수 있다 (앞서 본 Sub-Agent Pattern). 내장하면 복잡성이 프레임워크 코어에 들어가지만, Extension으로 구현하면 필요한 사람만 그 복잡성을 가져간다.

10.3 NO Permission Popups

Claude Code는 파일 쓰기, bash 실행 시 사용자 확인을 요구한다. Pi Framework는 이런 권한 팝업이 없다.

이유: Extension의 tool_call 이벤트를 통해 원하는 수준의 승인 게이트를 직접 구현할 수 있다. "무조건 물어보기" 보다 "필요한 것만 물어보기"를 선호하는 설계다. rm -rf는 차단하되 일반 파일 쓰기는 허용하는 등, 프로젝트별 맞춤 정책이 가능하다.

10.4 NO Plan Mode

Claude Code의 Plan Mode는 실행 전에 계획을 세우고 사용자 승인을 받는 모드다. Pi Framework는 이것이 없다.

이유: ReAct 패턴 자체가 "계획하면서 실행"하는 패턴이다. Thinking Block으로 LLM이 사고하고, 도구를 실행하고, 결과를 보고 다시 사고한다. 별도의 Plan Mode보다 이 인터리빙된 사고-실행이 더 효과적이라는 철학이다. 필요시 Extension으로 Plan-First 패턴을 구현할 수 있다.


11. Claude Code와의 비교

측면Pi FrameworkClaude Code
LLM 지원15+ 프로바이더 (프로바이더 독립적)Anthropic 모델 전용
아키텍처Layered + Event-Driven비공개
Agent 패턴ReAct 이중 루프ReAct (추정)
서브 에이전트Extension으로 구현Agent/Task 도구 내장
MCP 지원없음 (Extension으로 대체)내장 지원
권한 관리Extension 기반Permission Popup 내장
Plan Mode없음 (ReAct가 대체)내장 지원
확장 시스템Extension API (도구, 이벤트, UI, 커맨드)Skills, Commands, Hooks, Agents
세션 관리JSONL + 트리 브랜칭JSONL (추정)
오픈소스완전 오픈소스클라이언트 오픈소스, 코어 비공개
모델 전환대화 중 실시간 전환 가능불가
UITUI + Web + RPC + SDKTUI + VS Code
도구 실행순차적 (Extension으로 병렬 가능)순차적
Thinking프로바이더별 매핑Extended Thinking

핵심 차이는 철학에 있다. Claude Code는 "좋은 기본값 + 필요한 것은 내장"이고, Pi Framework는 "최소한의 코어 + Extension으로 무한 확장"이다.


12. 천재적인 부분과 한계

12.1 천재적인 부분

1. 이중 루프 설계

Steering과 Follow-up을 분리한 이중 루프는 사용자 경험의 근본적인 문제를 해결한다. "에이전트가 일하는 동안 사용자가 할 수 있는 것"이 많아진다.

2. convertToLlm + Declaration Merging

커스텀 메시지 타입을 앱 레벨에서 자유롭게 사용하면서, LLM 호출 시점에만 표준 형식으로 변환하는 설계는 타입 안전성과 유연성을 동시에 달성한다.

3. Cross-Provider 메시지 변환

대화 중간에 프로바이더를 전환할 수 있다는 것은 놀라운 기능이다. Anthropic의 Thinking Block을 <thinking> 태그로 변환하여 OpenAI 모델에게 전달하는 아이디어는 간단하지만 강력하다.

4. Extension 시스템의 깊이

도구 등록(registerTool), 래핑(wrapTool), 교체(replaceTool) 세 단계를 제공하고, 이벤트 시스템으로 거의 모든 동작에 훅을 걸 수 있다. MCP, Sub-Agent, Permission Popup을 모두 Extension으로 구현할 수 있다는 것은 아키텍처의 승리다.

5. 순차 도구 실행 + Steering 체크

도구를 순차 실행하면서 각 도구 실행 후 Steering 메시지를 체크하는 설계는, 사용자가 "잠깐, 멈춰!"라고 할 때 즉시 반응할 수 있게 한다. 병렬 실행이면 이것이 불가능하다.

12.2 한계

1. Extension 사이 샌드박싱 없음

Extension은 같은 프로세스에서 실행된다. 악의적인 Extension이 전체 시스템을 손상시킬 수 있다. 코드 리뷰가 필수다.

2. 단일 스레드 병목

Node.js 이벤트 루프에 의존하므로, CPU 집약적인 도구 실행이 전체 시스템을 블로킹할 수 있다.

3. MCP 에코시스템 미활용

MCP를 채택하지 않아 MCP 서버 에코시스템(GitHub, Slack, 데이터베이스 등)을 직접 활용할 수 없다. Extension으로 MCP 브릿지를 만들어야 한다.

4. 서브 에이전트의 상태 공유

Extension으로 서브 에이전트를 구현하면, 메인 에이전트와 서브 에이전트 간의 상태 공유가 수동적이다. 내장 서브 에이전트 시스템이 이 부분에서는 더 깔끔할 수 있다.

5. 도구 병렬 실행 미지원 (내장)

순차 실행의 장점이 있지만, 독립적인 읽기 작업 등은 병렬이 더 빠르다. Extension으로 가능하지만, 코어에 옵션으로 제공하면 더 좋을 것이다.


13. 내 프로젝트에 적용할 패턴

Pi Framework의 소스 분석에서 얻은, 자신의 프로젝트에 바로 적용할 수 있는 패턴들이다.

13.1 이벤트 소싱 패턴 (JSONL + 트리)

대화 기록을 JSONL로 저장하되 parentId를 두어 트리 구조를 형성하는 패턴은, 어떤 대화형 시스템에도 적용할 수 있다. Undo/Redo보다 강력한 "브랜치"를 제공한다.

13.2 이중 루프 패턴

비동기 작업을 처리하는 시스템에서, Inner Loop(작업 실행 + 인터럽트)와 Outer Loop(완료 후 후속 작업)로 분리하는 패턴은 범용적이다.

13.3 Strategy Pattern + 통합 스트리밍 인터페이스

여러 외부 API를 통합할 때, 각 API를 Strategy로 구현하고 AsyncGenerator로 통합 스트리밍 인터페이스를 제공하는 패턴이다. Pi Framework의 streamSimple()이 정확히 이것이다.

13.4 Extension API 3단계 (등록, 래핑, 교체)

플러그인 시스템을 설계할 때:

  • registerTool: 새로운 기능 추가
  • wrapTool: 기존 기능에 전후 처리 추가
  • replaceTool: 기존 기능 완전 대체

이 3단계를 제공하면 대부분의 커스터마이징 요구를 만족시킬 수 있다.

13.5 convertToLlm 패턴

앱 내부에서는 풍부한 타입 시스템을 사용하되, 외부 시스템(LLM, 데이터베이스 등)과 통신할 때는 변환 함수를 통해 호환 형식으로 변환하는 패턴이다. Declaration Merging과 결합하면 타입 안전성도 유지할 수 있다.

13.6 Compaction 패턴

제한된 리소스(컨텍스트 윈도우, 메모리, 저장소)를 관리할 때, Proactive(80% 임계치) + Reactive(오버플로우 시 재시도) 이중 전략은 어디에나 적용할 수 있다.


에필로그

Pi Framework는 "기능의 많음"이 아니라 "아키텍처의 우아함"으로 승부하는 프레임워크다. 핵심을 단단하게 만들고, 나머지는 Extension으로 열어둔다. MCP가 없어도, Sub-Agent가 없어도, Permission Popup이 없어도 -- Extension API를 통해 모든 것을 구현할 수 있다.

이 분석을 통해 얻은 가장 큰 교훈은 이것이다: 좋은 아키텍처는 기능을 추가하는 것이 아니라, 기능을 추가할 수 있는 구조를 만드는 것이다.

Mario Zechner가 libGDX에서 보여준 "최소한의 코어 + 무한 확장"이라는 철학이, Pi Framework에서도 그대로 빛나고 있다.


참고 자료

# Comments

0/2000

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.