AI 에이전트 하네스 (Pi Coding Agent) 뜯어보기

AI 에이전트 하네스 (Pi Coding Agent) 뜯어보기
Photo by Solen Feyissa / Unsplash

1.개요

하네스, 루프 엔지니어링. 요즘 많이들 듣는 말이기도 하고 또 사용하기도 많이 사용 하는 도구들이다.

나도 좋다고하는 이런 저런 툴들을 사용하고 있기는 한데 내가 토큰을 24시간 돌릴 일도 없고 그저 취미삼아 만드는 앱을 만드는데 주로 쓰고 있는데, 사용 성향이나 패턴으로 볼때 좀 과하다 싶은 부분들이 있다.

가벼운 수정에 지나지 않는데 장황하게 에이전트 저 혼자 고민하고 여기저기 소스를 들쑤시고 나서야 불완전한 코드를 완성하기 일쑤이거나, 세션 로그 때문인지 모르겠으나 해당 디렉토리의 크기가 1GB 를 넘어갈 일인가 싶기도 하다. (아마도 npm 패키지 때문에 그런게 아닌가 한다)

어쨌거나 유명한 경량 하네스 중 하나인 Pi Agent 를 가져다가 Extension 형태도 만들어보고 유명한 스킬 몇개를 가져다가 Agent 에 강제도 해보기로 했다.

1. MCP

먼저 Pi Agent 로 나만의 하네스를 만들기 위한 필수적인 MCP 를 다음과 같이 먼저 설치했다.

1) mcp, codegraph

npm install -g @colbymchenry/codegraph

codegraph는 에이전트의 코드 이해 속도를 높이기 위한 도구이고 빠른 속도와 정확한 컨텍스트를 확보하여 전달해준다.

GitHub - colbymchenry/codegraph: Pre-indexed code knowledge graph, auto syncs on code changes, for Claude Code, Codex, Gemini, Cursor, OpenCode, AntiGravity, Kiro, and Hermes Agent — fewer tokens, fewer tool calls, 100% local
Pre-indexed code knowledge graph, auto syncs on code changes, for Claude Code, Codex, Gemini, Cursor, OpenCode, AntiGravity, Kiro, and Hermes Agent — fewer tokens, fewer tool calls, 100% local - co…

2) pi-mcp-adapter

pi install npm:pi-mcp-adapter

MCP 서버를 일반적인 방식으로 붙이면 각 MCP 서버의 tool 정의가 모델 context에 포함되는 경우가 많다. 이 tool 정의에는 이름, 설명, 파라미터 스키마가 들어가기 때문에 MCP 서버 몇 개만 연결해도 context window를 상당히 소모할 수 있다. 이런 비용 때문에 Pi Agent는 core를 minimal하게 유지하고, MCP를 기본 내장 기능으로 무겁게 포함하기보다는 pi-mcp-adapter 같은 extension/package를 통해 필요한 MCP 서버만 연결하는 방식을 채택하고 있다.

먼저 Pi Agent 로 나만의 하네스를 만들기 위한 필수적인 MCP 를 다음과 같이 먼저 설치했다.

2. SKILLS

Skills 도 주로 쓰는 것들을 주입하기로 했다. grill-me 야 워낙 유명하기도 하고 이제거의 필수 인것 같은데 grill-with-docs 가 더 체계적이고 히스토리 관리에 더 나은 선택인듯해서 grill-with-docs 를 설치해 보기로 했다.

npx skills add mattpocock/skills --skill=grill-with-docs

3. 테스트환경 세팅

먼저 개발환경 세팅을 해보자.


  1) pi CLI 설치
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent

  2) 확장 개발용 작업 디렉터리 준비 (타입 자동완성을 위해 @earendil-works/pi-coding-agent를 로컬 devDependency로 추가하는 걸 추천)

  3) 나는 아래처럼 extension 디렉토리를 만들고 익스텐션 먼저 만들어봤다.
  cd ~/.pi/agent
  mkdir my-pi-extension && cd my-pi-extension
  npm init -y
  npm install -D @earendil-works/pi-coding-agent typebox

  4) 파일 작성 후 익스텐션 즉시 테스트 (빌드 스텝 없음, jiti가 TS를 그대로 실행)
  pi -e ./index.ts

2.Extensions 구성 및 기타 Pi Agent SDK 요소

pi-coding-agent 가 어떻게 구성 됐는지 이해를 해야 Loop 엔지니어링을 하든 하네스를 짜든 할 듯해서 소스를 조금 뜯어보기로 했다.

1) registerCommand

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {

  pi.registerCommand("hello", {
  
    description: "Prints a greeting message",
    handler: async (args, ctx) => {
      console.log("Hello from my extension!");
      ctx.ui.notify("Hello from my extension! notify");
    }
    
  });
  
}

에이전트에 명령어를 넣는 단순한 함수다. /hello 라고 치면 핸들러 안에 있는 기능을 수행한다.

2) registerTool

함수명에서 알 수 있듯 에이전트가 쓸 도구를 등록 정의하는 함수다. pi 자신이 이 파일들에서 bash, read 같은 도구를 바로 registerTool과 같은 AgentTool 형태(같은 name/description/parameters/execute 인터페이스)로 정의해두고, 확장 시스템과 동일한 방식으로 등록해둔 것이다. 즉 pi 코어 자체가 "자기 자신의 확장 API를 내부적으로 사용하는" 구조입니다.

예제 1 — 파라미터 없는 도구 (현재 시간 조회)

  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
  import { Type } from "typebox";

  export default function (pi: ExtensionAPI) {
    pi.registerTool({
      name: "current_time",
      label: "Current Time",
      description: "Get the current server time",
      parameters: Type.Object({}),
      async execute() {
        return {
          content: [{ type: "text", text: new Date().toISOString() }],
          details: {},
        };
      },
    });
  }

parameters: Type.Object({})처럼 빈 스키마를 주면 LLM이 인자 없이 호출한다. 사용자가 "지금 몇 시야?"라고 물으면 LLM이 이 도구를 인자 없이 호출하게 된다.

예제 2 — LLM 호출과 직접 호출을 모두 지원하는 도구

"도구를 직접 호출하기" 패턴을 실제로 보여주는 예제이다. 파일 라인 수를 세는 도구를 만들고 /linecount 명령에서 LLM을 거치지 않고 같은 도구를 재사용한다.

  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
  import { Type } from "typebox";
  import { readFile } from "node:fs/promises";

  const lineCountTool = {
    name: "line_count",
    label: "Line Count",
    description: "Count the number of lines in a file",
    parameters: Type.Object({
      path: Type.String({ description: "File path to count lines in" }),
    }),
    async execute(_toolCallId: string, params: { path: string }) {
      const text = await readFile(params.path, "utf-8");
      const count = text.split("\n").length;
      return {
        content: [{ type: "text", text: `${count} lines` }],
        details: { count },
      };
    },
  };

  export default function (pi: ExtensionAPI) {
    // LLM이 필요하다고 판단하면 호출
    pi.registerTool(lineCountTool);

    // 사용자가 /linecount <path>로 직접 호출 (LLM 왕복 없음)
    pi.registerCommand("linecount", {
      description: "Count lines in a file directly",
      handler: async (args, ctx) => {
        const result = await lineCountTool.execute("manual", { path: args.trim() });
        ctx.ui.notify(result.content[0].text, "info");
      },
    });
  }

3) sendMessage

LLM에게 컨텍스트를 주입하는 함수다. 실제 사용자가 타이핑한 것처럼 보이지 않는 확장이 직접 만든 커스텀한 타입을 목록에 끼워넣는다.

예를 들어 파일 변경, 빌드 실패, 웹훅 수신처럼 사용자가 말한 게 아니라 시스템에서 발생한 이벤트 LLM 컨텍스트에 자연스럽게 끼워넣고 싶을 때 쓴다.

예제 1 - 파일 변경 감지 시 컨텍스트만 주입

파일 워처가 변경을 감지해도 즉시 LLM을 호출하지 않고, 다음에 에이전트가 뭔가 할 때 참고할 컨텍스트로만 쌓아둔다

  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
  import { watch } from "node:fs";

  export default function (pi: ExtensionAPI) {

    # 세션 시작 이벤트, 하단에서 이벤트를 설명한다.
    pi.on("session_start", (_event, ctx) => {
      watch(ctx.cwd, { recursive: true }, (_eventType, filename) => {
      
        pi.sendMessage({
          customType: "file-watcher",
          content: `파일이 변경됨: ${filename}`,
          display: true,
        });
        
        // triggerTurn을 안 줬으므로 즉시 LLM을 호출하지 않고 세션에만 쌓임
      });
    });
    
  }

예제 2 - 빌드 실패 시 즉시 LLM 에 전달 하고 응답 유도 (TriggerTurn: true)

이번엔 반대로, 에이전트가 idle 상태라면 즉시 응답을 받고 싶은 경우의 예제이다.

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
  import { exec } from "node:child_process";

  export default function (pi: ExtensionAPI) {
  
    pi.registerCommand("watch-build", {
      description: "Run build and alert the agent if it fails",
      handler: async (_args, ctx) => {
      
        # npm run build 할때 발생하는 오류를 LLM 에 전달한다. 
        exec("npm run build", (error, _stdout, stderr) => {
          if (error) {
            pi.sendMessage(
              {
                customType: "build-alert",
                content: `빌드 실패:\n${stderr}`,
                display: true,
              },
              { triggerTurn: true }, // idle 상태면 즉시 LLM 호출
            );
          }
        });
        
        
        ctx.ui.notify("Build started in background", "info");
      },
    });
    
  }

4) sendUserMessage

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

  export default function (pi: ExtensionAPI) {
  
    pi.registerCommand("explain", {
      description: "Ask the agent to explain the last change",
      handler: async () => {
        pi.sendUserMessage("방금 수정한 코드를 초보자도 이해할 수 있게 설명해줘.");
      },
    });
    
  }

/explain을 치면 실제로 사용자가 타이핑한 것처럼 질문이 들어가고, 항상 새 턴이 시작된다.

  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

  export default function (pi: ExtensionAPI) {
  
    pi.registerCommand("focus-errors", {
      description: "Steer the agent to focus on error handling mid-turn",
      handler: async () => {
        pi.sendUserMessage("에러 처리 부분에 좀 더 집중해줘.", { deliverAs: "steer" });
      },
    });
    
  }

5) on event handler

다음은 이벤트 핸들러의 예시이다. tool calling 할때 이벤트를 잡아서 제어하는 예시를 들었다.

  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
  import { isToolCallEventType } from "@earendil-works/pi-coding-agent";

  export default function myExtension(pi: ExtensionAPI) {

    # 도구 호출 이벤트 
    pi.on("tool_call", async (event, ctx) => {
    
      // bash 도구 호출을 감지해서 위험한 명령을 확인받는다
      if (isToolCallEventType("bash", event)) {
      
        // event.input은 { command: string; timeout?: number } 형태 (mutable)
        if (event.input.command.includes("rm -rf")) {
          const ok = await ctx.ui.confirm("위험한 명령", "rm -rf를 실행할까요?");
          if (!ok) {
            return { block: true, reason: "사용자가 차단함" };
          }
        }
        
      }

      // read 도구 호출은 그냥 로그만 남긴다
      if (isToolCallEventType("read", event)) {
        ctx.ui.notify(`읽는 중: ${event.input.path}`, "info");
      }
      
    });
    
  }

다음 Repo. 는 학습을 위한 개발 버전의 하네스 입니다.

GitHub - breezymind/tiny-agent
Contribute to breezymind/tiny-agent development by creating an account on GitHub.

4. Pi Agetnt 의 Life Cycle

이런저런 예제를 보면 Pi Agent 가 제공하는 이벤트가 정말 많다. 제대로 된 하네스를 구성하기 위해서는 Pi Agent 라이프사이클을 간단하게나마 알 필요가 있어서 이벤트 값과 같이 정리했다.

다음에는 코드를 구성하고 실제 업무에서 하네스를 돌려볼까 싶다.

1. pi 시작
   │
   ├─► project_trust
   │     └─ 프로젝트의 .pi, skills, extension 등을 신뢰할지 확인
   │
   ├─► session_start { reason: "startup" }
   │     └─ 새 세션 시작
   │        대화 기록, 모델 상태, 세션 리소스 초기화
   │
   └─► resources_discover { reason: "startup" }
         └─ 사용 가능한 도구, MCP, extension resource 등을 탐색


────────────────────────────────────────────────────────────

2. 사용자가 프롬프트 입력
   │
   ├─► user message_start
   │     └─ 사용자 메시지가 세션에 기록되기 시작
   │
   ├─► user message_end
   │     └─ 사용자 메시지가 최종 확정되어 세션에 저장됨
   │
   ├─► extension command 확인
   │     └─ /명령어 또는 extension command인지 먼저 확인
   │
   │     └─ 처리되면:
   │          └─ input / agent 실행 생략
   │
   ├─► input
   │     └─ 사용자 입력을 agent에 넘기기 전 가로챌 수 있는 구간
   │
   │     ├─ continue
   │     │   └─ 입력을 그대로 다음 단계로 진행
   │     │
   │     ├─ transform
   │     │   └─ 사용자 입력을 수정한 뒤 다음 단계로 진행
   │     │      예: 프롬프트 보강, prefix 추가, 금지어 정리
   │     │
   │     └─ handled
   │         └─ extension이 직접 처리하고 agent 실행 생략
   │            예: 커스텀 명령어 응답, 로컬 처리
   │
   ├─► skill / template expansion
   │     └─ skill, prompt template, command template 등이 적용됨
   │        사용자 입력이 실제 agent용 프롬프트로 확장되는 구간
   │
   ├─► before_agent_start
   │     └─ agent가 시작되기 직전에 개입하는 구간
   │
   │     ├─ 메시지 주입 가능
   │     │   └─ 예: 프로젝트 규칙, 최근 요약, Logic Ledger 내용 추가
   │     │
   │     └─ system prompt 수정 가능
   │         └─ 예: 응답 스타일, 금지 규칙, 작업 절차 추가
   │
   └─► agent_start
         └─ 실제 agent 실행 시작
            이후 LLM 호출, 도구 호출, 반복 turn이 진행됨


────────────────────────────────────────────────────────────

3. Agent 실행 루프
   │
   ┌─────────────────────────────────────────────────────┐
   │ turn 반복                                            │
   │ LLM이 도구 호출을 계속하거나 추가 판단이 필요하면 반복 │
   └─────────────────────────────────────────────────────┘
   │
   ├─► turn_start
   │     └─ 하나의 LLM 응답/도구 호출 사이클 시작
   │
   ├─► context
   │     └─ LLM에 전달할 messages를 조정할 수 있는 구간
   │
   │     └─ 사용 예:
   │          - 오래된 메시지 제거
   │          - 프로젝트 규칙 추가
   │          - Logic Ledger 내용 주입
   │          - 특정 파일/도구 사용 지침 추가
   │
   ├─► before_provider_request
   │     └─ 실제 모델 provider로 요청을 보내기 직전
   │
   │     └─ 사용 예:
   │          - provider payload 검사
   │          - model, temperature, max_tokens 조정
   │          - 요청 차단
   │          - payload 교체
   │
   ├─► LLM 호출
   │     └─ 모델이 응답을 생성
   │        이때 일반 텍스트 응답 또는 tool call이 나올 수 있음
   │
   ├─► after_provider_response
   │     └─ provider 응답 직후 실행
   │
   │     └─ 사용 예:
   │          - HTTP status 확인
   │          - header 확인
   │          - latency / usage / error 로깅
   │
   ├─► assistant message_start
   │     └─ assistant 응답 메시지가 생성되기 시작
   │
   ├─► assistant message_update
   │     └─ assistant 응답이 스트리밍으로 갱신되는 구간
   │
   │     └─ 사용 예:
   │          - 터미널 실시간 출력
   │          - 웹 UI 말풍선 갱신
   │          - JSON event stream 전달
   │
   ├─► assistant message_end
   │     └─ assistant 최종 응답이 확정됨
   │
   │     └─ 사용 예:
   │          - 최종 응답 로깅
   │          - 출력 포맷 정리
   │          - Logic Ledger 후보 추출
   │          - 응답 품질 테스트
   │
   ├─► 도구 호출이 없으면
   │     └─ turn_end로 이동
   │
   ├─► 도구 호출이 있으면
   │
   │     ├─► tool_execution_start
   │     │     └─ 도구 실행 시작
   │     │        예: shell, file edit, search, MCP tool 등
   │     │
   │     ├─► tool_call
   │     │     └─ 도구가 실제 실행되기 전 개입 가능
   │     │
   │     │     └─ 사용 예:
   │     │          - 위험한 명령 차단
   │     │          - 특정 파일 수정 금지
   │     │          - tool input 수정
   │     │          - 승인 없는 명령 실행 방지
   │     │
   │     ├─► tool_execution_update
   │     │     └─ 도구 실행 중간 상태 업데이트
   │     │
   │     │     └─ 사용 예:
   │     │          - 장시간 실행 명령 진행 표시
   │     │          - stdout / stderr 일부 스트리밍
   │     │
   │     ├─► tool_result
   │     │     └─ 도구 실행 결과를 LLM에 넘기기 전 수정 가능
   │     │
   │     │     └─ 사용 예:
   │     │          - 민감정보 마스킹
   │     │          - 너무 긴 결과 요약
   │     │          - 불필요한 로그 제거
   │     │          - 에러 메시지 정규화
   │     │
   │     ├─► tool_execution_end
   │     │     └─ 도구 실행 종료
   │     │
   │     ├─► toolResult message_start
   │     │     └─ 도구 결과 메시지가 세션에 기록되기 시작
   │     │
   │     └─► toolResult message_end
   │           └─ 도구 결과 메시지가 세션에 최종 저장됨
   │
   ├─► turn_end
   │     └─ 하나의 LLM 응답/도구 호출 사이클 종료
   │
   └─► 다음 판단
         │
         ├─ 도구 결과를 보고 추가 작업이 필요하면
         │   └─ 다시 turn_start로 반복
         │
         └─ 더 이상 작업이 없으면
             └─ agent_end로 이동


────────────────────────────────────────────────────────────

4. Agent 종료
   │
   └─► agent_end
         └─ 사용자 프롬프트 하나에 대한 agent 실행 종료
            최종 응답이 사용자에게 표시됨


────────────────────────────────────────────────────────────

5. 세션 변경 / 종료
   │
   ├─► /new 또는 /resume
   │     └─ 새 세션 생성 또는 기존 세션 재개
   │
   │     ├─► session_before_switch
   │     │     └─ 현재 세션을 다른 세션으로 전환하기 직전
   │     │
   │     ├─► session_shutdown
   │     │     └─ 현재 세션 종료 처리
   │     │
   │     ├─► session_start { reason: "new" | "resume" }
   │     │     └─ 새 세션 또는 재개 세션 시작
   │     │
   │     └─► resources_discover
   │           └─ 새 세션 기준으로 사용 가능한 리소스 다시 탐색
   │
   ├─► /fork 또는 /clone
   │     └─ 현재 세션을 복제하거나 분기
   │
   │     ├─► session_before_fork
   │     │     └─ 세션을 fork/clone 하기 직전
   │     │
   │     ├─► session_shutdown
   │     │     └─ 기존 세션 종료 처리
   │     │
   │     ├─► session_start { reason: "fork" }
   │     │     └─ 분기된 새 세션 시작
   │     │
   │     └─► resources_discover
   │           └─ 분기된 세션 기준으로 리소스 재탐색
   │
   ├─► /compact 또는 auto-compaction
   │     └─ 세션 컨텍스트가 길어졌을 때 요약/압축
   │
   │     ├─► session_before_compact
   │     │     └─ compact 실행 직전
   │     │        압축 대상이나 요약 방식을 조정할 수 있음
   │     │
   │     └─► session_compact
   │           └─ 세션 요약/압축 완료
   │              이후 더 짧은 context로 agent가 계속 동작
   │
   └─► exit
         └─► session_shutdown
               └─ pi 종료 시 세션 정리
                  로그 저장, 리소스 정리, extension 종료 처리