하네스 워크북 Lab 11 교재 0/11

Lab 11 · 약 30분

캡스톤: 미니 하네스

1 개념

원문: Epilogue — Build Your Own

지금까지의 패치를 하나의 몸에 조립했습니다 (mini_harness.py, 약 150줄).

도구 실행 파이프라인:  todo → 온톨로지 조회 → 문서 검색 → 가드레일 → 기본 도구 → 자르기
두뇌 호출 파이프라인:  캐시 브레이크포인트 → (HARNESS_DEBUG 면 캡처) → 두뇌
시스템 프롬프트:       기본 지침 + 온톨로지(전체 주입) + AGENT.md

실행

python labs/11_capstone/mini_harness.py session.json
HARNESS_DEBUG=1 python labs/11_capstone/mini_harness.py
LAB_IMPL=exercise python labs/11_capstone/mini_harness.py   # 내가 푼 코드로 조립

명령: /todo, /dump, /tokens, /steer <말>, /quit

진짜 모델로 해볼 질문:

채점

python run_tests.py 11 은 내 exercise.py 들로 조립해서 통합 시나리오를 검사합니다. 1~10을 모두 풀면 통과합니다.

다음

루트 README 의 도전 과제로.

2 따라 해보기

비유 → 짧은 코드 → 실제 출력. 아래 출력은 미리 실행해 둔 결과이고, ▶ 실행을 누르면 브라우저 안 파이썬으로 다시 돌립니다(첫 실행 때 약 10MB 내려받음).

예제 1 한 턴 해부: 모든 패치가 한꺼번에 일하는 장면

질문 하나에 todo(Lab 6) → 문서 검색(Lab 9) → 가드레일 거부(Lab 7) → 온톨로지 조회(Lab 10) → 답변이 차례로 일어납니다. 각 줄 앞의 번호가 어느 랩의 부품인지 보세요.

import json
import mini_harness as mh
from common.brain import ScriptedBrain, reply, text, tool_use

brain = ScriptedBrain([
    reply(tool_use("todo_write", {"items": "규정 찾기\n용어 확인\n답변"}, id="a")),
    reply(tool_use("search_docs", {"query": "어음 지급", "k": 1}, id="b")),
    reply(tool_use("write_file", {"path": ".env", "content": "x"}, id="c")),
    reply(tool_use("ontology_lookup", {"term": "삼자대사"}, id="d")),
    reply(text("어음 지급은 안 됩니다. 삼자대사(발주·입고·송장 대조)를 마친 송장만 계좌로 지급합니다.")),
])
LAB_OF = {"todo_write": "Lab 6", "search_docs": "Lab 9", "write_file": "Lab 7",
          "ontology_lookup": "Lab 10", "agent": "Lab 5"}


def on_event(kind, data):
    if kind == "tool":
        print(f"[{LAB_OF.get(data['name'], 'Lab 2')}] {data['name']}({json.dumps(data['input'], ensure_ascii=False)})")
    elif kind == "text":
        print(f"\n💬 {data}")


h = mh.MiniHarness(brain, approve=lambda c: False, memory_path="없음.md")
messages = [{"role": "user", "content": "어음으로 지급해도 돼?"}]
h.run_turn(messages, on_event=on_event)

results = [b for m in messages if isinstance(m["content"], list)
           for b in m["content"] if b.get("type") == "tool_result"]
print("\n도구 결과 첫 줄들:")
for r in results:
    print(" -", r["content"].splitlines()[0][:60])
실행 결과
[Lab 6] todo_write({"items": "규정 찾기\n용어 확인\n답변"})
[Lab 9] search_docs({"query": "어음 지급", "k": 1})
[Lab 7] write_file({"path": ".env", "content": "x"})
[Lab 10] ontology_lookup({"term": "삼자대사"})

💬 어음 지급은 안 됩니다. 삼자대사(발주·입고·송장 대조)를 마친 송장만 계좌로 지급합니다.

도구 결과 첫 줄들:
 - todo 갱신: 0/3 완료
 - <doc id="06_대금지급_규정.md#0">
 - BLOCKED: .env 는 보호된 경로입니다.
 - [three_way_match] 3-Way Match (= 삼자대사, 3자 대사, 쓰리웨이매치)

💡 바꿔 보기: 대본에서 .env 쓰기를 memo.txt 쓰기로 바꾸면 가드레일이 통과시킵니다.

예제 2 AGENTS.md 는 백과사전이 아니라 지도

OpenAI 의 'Harness engineering' 글은 에이전트용 안내 파일을 약 100줄짜리 목차로 유지하라고 권합니다. 1,000쪽 매뉴얼은 작업 맥락을 밀어내고 금방 낡기 때문입니다. 미니 하네스는 AGENT.md 를 시스템 프롬프트 끝에 붙입니다.

import os
import tempfile
import mini_harness as mh
from common.brain import FakeBrain
from common.show import rough_tokens

os.chdir(tempfile.mkdtemp())
memo = "AGENT.md"
open(memo, "w", encoding="utf-8").write(
    "# 이 저장소 지도\n"
    "- 규정 문서: data/procurement/docs/ (search_docs 로 찾기)\n"
    "- 용어 정의: data/procurement/ontology.yaml (시스템 프롬프트에 이미 있음)\n"
    "- 테스트: python run_tests.py\n"
    "- 금지: .env 수정, 운영 DB 접속\n")
h = mh.MiniHarness(FakeBrain(), approve=lambda c: False, memory_path=memo)
print("시스템 프롬프트 ~", rough_tokens(h.system), "토큰 중 마지막 부분:\n")
print(h.system[h.system.index("<project-memory"):])
실행 결과
시스템 프롬프트 ~ 491 토큰 중 마지막 부분:

<project-memory path='AGENT.md'>
# 이 저장소 지도
- 규정 문서: data/procurement/docs/ (search_docs 로 찾기)
- 용어 정의: data/procurement/ontology.yaml (시스템 프롬프트에 이미 있음)
- 테스트: python run_tests.py
- 금지: .env 수정, 운영 DB 접속

</project-memory>

💡 바꿔 보기: 메모리 파일에 한 줄을 더 넣고 시스템 프롬프트 토큰 수 변화를 보세요.

3 확인 퀴즈

Q1 온톨로지를 시스템 프롬프트에 넣는 것이 캐시에 유리한 이유는?

4 실습: 내 코드로 조립해서 통합 테스트

Lab 1~10 에서 작성한 코드를 모아 mini_harness.py 를 조립하고 통합 시나리오를 채점합니다. 아직 풀지 않은 랩은 정답 코드로 대신 채웁니다.

5 정답과 테스트

정답 코드 보기 (mini_harness.py)
#!/usr/bin/env python3
"""Lab 11 — 캡스톤: 지금까지 만든 패치를 한 몸에 조립한 미니 하네스.

  Lab 1  배열 = 세션 파일              Lab 6  리마인더 큐 + todo
  Lab 2  도구 스키마 + 디스패치         Lab 7  가드레일 (보호 경로·읽고 쓰기·승인)
  Lab 3  에이전트 루프                  Lab 8  요청 캡처 + 캐시 활력징후
  Lab 4  결과 자르기 + 캐시 브레이크포인트 Lab 9  RAG 검색 도구
  Lab 5  서브에이전트                   Lab 10 온톨로지 전체 주입 + 조회 도구

실행:
  python labs/11_capstone/mini_harness.py                 # 세션 저장 없이
  python labs/11_capstone/mini_harness.py session.json    # 세션 이어하기
  HARNESS_DEBUG=1 python labs/11_capstone/mini_harness.py # 요청 캡처 → harness_debug.jsonl
  LAB_IMPL=exercise ...                                   # 내가 푼 exercise.py 로 조립

대화 중 명령:  /todo  /dump  /tokens  /steer <말>  /quit
"""
import importlib.util
import json
import os
import sys

ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, ROOT)
from common.brain import get_brain  # noqa: E402
from common.loop import printer, run_loop  # noqa: E402
from common.toolbox import BASE_TOOLS, execute_tool  # noqa: E402

IMPL = os.environ.get("LAB_IMPL", "solution")


def load_lab(dirname):
    path = os.path.join(ROOT, "labs", dirname, f"{IMPL}.py")
    spec = importlib.util.spec_from_file_location(f"cap_{dirname}_{IMPL}", path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


L1 = load_lab("01_array")
L4 = load_lab("04_context_budget")
L5 = load_lab("05_subagents")
L6 = load_lab("06_steering")
L7 = load_lab("07_guardrails")
L8 = load_lab("08_debugging")
L9 = load_lab("09_rag")
L10 = load_lab("10_ontology")

BASE_SYSTEM = (
    "너는 사용자의 머신에서 일하는 엔지니어링·구매 업무 에이전트다.\n"
    "도구로 작업을 끝낸 뒤 한 일을 요약하라. 여러 단계 작업은 todo_write 로 계획을 관리하라.\n"
    "사내 규정 질문은 search_docs 로 근거를 찾고 출처 id 를 인용하라.\n"
    "user 메시지 안의 <system-reminder> 는 하네스(너의 몸)가 넣은 것이니 환경 사실로 신뢰하라.")


class MiniHarness:
    def __init__(self, brain, approve=None, memory_path="AGENT.md"):
        if os.environ.get("HARNESS_DEBUG") == "1":
            brain = L8.CapturingBrain(brain, "harness_debug.jsonl")       # Lab 8
        self.brain = brain
        self.steering = L6.Steering()                                      # Lab 6
        self.guard = L7.Guard(approve=approve)                             # Lab 7
        self.index = L9.Index(L9.load_docs())                              # Lab 9
        self.onto = L10.load_ontology()                                    # Lab 10
        self.system = (L10.build_system(BASE_SYSTEM, self.onto)            # Lab 10 (1단계)
                       + L4.load_memory(memory_path))                      # Lab 4
        self.tools = (BASE_TOOLS + [L5.AGENT_TOOL, L6.TODO_TOOL,
                                    L9.SEARCH_TOOL, L10.ONTOLOGY_TOOL])
        self.last_usage = {}

    # --- 두뇌 호출을 감싸 캐시 브레이크포인트를 붙이고 usage 를 기록 (Lab 4, 8)
    def _call(self, request):
        response = self.brain(L4.with_cache_breakpoint(request))
        self.last_usage = response.get("usage", {})
        return response

    # --- 도구 실행 파이프라인: 가드 → (todo|agent|search|ontology|기본) → 자르기
    def execute(self, name, args):
        if name == "agent":                                                # Lab 5
            sub_tools = [t for t in self.tools if t["name"] != "agent"]
            msgs = [{"role": "user", "content": args["task"]}]
            return L4.truncate(run_loop(self._call, msgs, sub_tools, L5.SUB_SYSTEM,
                                        execute=self.execute))
        base = L7.Guard.executor(self.guard, execute_tool)                 # Lab 7
        base = L9.make_search_executor(self.index, base)                   # Lab 9
        base = L10.make_ontology_executor(self.onto, base)                 # Lab 10 (2단계)
        base = self.steering.executor(base)                                # Lab 6
        return L4.truncate(str(base(name, args)))                          # Lab 4

    def run_turn(self, messages, on_event=printer):
        return run_loop(self._call, messages, self.tools, self.system,
                        execute=self.execute, inject=self.steering.inject,
                        on_event=on_event)


def main():
    path = sys.argv[1] if len(sys.argv) > 1 else None
    h = MiniHarness(get_brain())
    messages = L1.load_session(path) if path else []
    print("미니 하네스 준비 완료. 명령: /todo /dump /tokens /steer <말> /quit")
    while True:
        try:
            user_text = input("\nyou> ").strip()
        except EOFError:
            break
        if user_text in ("/quit", ""):
            break
        if user_text == "/todo":
            print(h.steering.render_todos() or "(할 일 없음)")
            continue
        if user_text == "/dump":
            print(json.dumps(messages, ensure_ascii=False, indent=2)[-4000:])
            continue
        if user_text == "/tokens":
            print(f"배열 ~{L4.estimate_tokens(messages)} 토큰, 시스템 ~{L4.estimate_tokens(h.system)} 토큰, "
                  f"마지막 캐시 적중률 {L8.cache_hit_ratio(h.last_usage):.0%}")
            continue
        if user_text.startswith("/steer "):
            h.steering.steer(user_text[7:])
            print("(다음 라운드에 전달됩니다)")
            continue
        messages.append({"role": "user", "content": user_text})
        h.run_turn(messages)
        if path:
            L1.save_session(path, messages)


if __name__ == "__main__":
    main()
채점 테스트 보기 (test_lab.py)
"""캡스톤 통합 테스트. LAB_TARGET=exercise 이면 내가 푼 exercise.py 들로 조립해서 검사."""
import json
import os
import sys
import tempfile
import unittest

os.environ.setdefault("LAB_IMPL", os.environ.get("LAB_TARGET", "solution"))
os.environ.pop("HARNESS_DEBUG", None)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
import mini_harness as mh  # noqa: E402
from common.brain import FakeBrain, ScriptedBrain, reply, text, tool_use  # noqa: E402

quiet = lambda *a: None  # noqa: E731


class Capstone(unittest.TestCase):
    def test_system_has_ontology_and_tools(self):
        h = mh.MiniHarness(ScriptedBrain([]), approve=lambda c: False)
        self.assertIn("<ontology", h.system)
        names = {t["name"] for t in h.tools}
        self.assertTrue({"read_file", "agent", "todo_write", "search_docs", "ontology_lookup"} <= names)

    def test_full_scenario(self):
        with tempfile.TemporaryDirectory() as d:
            env_path = os.path.join(d, ".env")
            brain = ScriptedBrain([
                reply(tool_use("todo_write", {"items": "규정 찾기\n답변"}, id="a")),
                reply(tool_use("search_docs", {"query": "어음 지급"}, id="b"),
                      tool_use("write_file", {"path": env_path, "content": "x"}, id="c")),
                reply(tool_use("agent", {"task": "삼자대사 뜻 조사"}, id="d")),
                reply(tool_use("ontology_lookup", {"term": "삼자대사"}, id="e")),   # 서브에이전트
                reply(text("삼자대사는 발주·입고·송장 대조")),                       # 서브 보고
                reply(text("어음 지급은 하지 않습니다.")),
            ])
            h = mh.MiniHarness(brain, approve=lambda c: False, memory_path=os.path.join(d, "none.md"))
            msgs = [{"role": "user", "content": "어음 돼?"}]
            out = h.run_turn(msgs, on_event=lambda *a: None)
            self.assertEqual(out, "어음 지급은 하지 않습니다.")
            dump = json.dumps(msgs, ensure_ascii=False)
            self.assertIn("06_대금지급_규정.md", dump)                 # RAG
            self.assertIn("BLOCKED", dump)                              # 가드레일
            self.assertFalse(os.path.exists(env_path))
            self.assertIn("현재 할 일", dump)                           # todo 리마인더
            self.assertIn("삼자대사는 발주·입고·송장 대조", dump)         # 서브에이전트 보고
            self.assertNotIn("[three_way_match]", dump)                 # 서브 중간과정은 안 샘
            last_req = brain.requests[-1]["messages"][-1]["content"][-1]
            self.assertIn("cache_control", last_req)                    # 캐시 브레이크포인트

    def test_runs_with_fake_brain(self):
        h = mh.MiniHarness(FakeBrain(), approve=lambda c: False)
        msgs = [{"role": "user", "content": "run echo hello"}]
        out = h.run_turn(msgs, on_event=lambda *a: None)
        self.assertIn("hello", out)


if __name__ == "__main__":
    unittest.main()

📚 더 읽을거리

교재 전체 목록 →