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

Lab 05 · 약 20분

서브에이전트 = 컨텍스트 포크

1 개념

원문: 07-subagents.md

무엇이 깨지나

조사 과정(파일 수십 개 읽기)이 부모 배열을 다 채워버린다.

패치

agent 도구가 호출되면 새 배열로 같은 루프를 돌리고, 부모에게는 최종 보고서 텍스트만 돌려준다. 서브에이전트에게는 agent 도구를 주지 않는다.

비용

서브에이전트는 부모의 맥락을 모른다 → 위임 메시지(task)를 자기완결적으로 써야 한다. 호출 비용도 따로 든다.

할 일 (exercise.py)

python run_tests.py 5 -v

생각해볼 것

2 따라 해보기

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

예제 1 조사는 인턴에게, 나는 보고서만

비유 — 팀장이 서류 더미를 직접 다 읽으면 책상(컨텍스트)이 서류로 덮입니다. 인턴에게 "지급 관련 규정 찾아서 한 줄로 보고해 줘"라고 맡기면, 팀장 책상엔 보고서 한 장만 남습니다.

아래 표에서 서브에이전트는 문서를 읽느라 배열이 커지지만, 부모 배열은 작게 유지되는 걸 보세요.

import solution as lab
from common.brain import ScriptedBrain, reply, text, tool_use
from common.show import show

script = ScriptedBrain([
    # 부모: 직접 읽지 않고 위임
    reply(tool_use("agent", {"task": "data/procurement/docs 에서 '지급' 규정 파일을 찾아 핵심을 한 줄로 보고하라"}, id="p1")),
    # 서브: 목록 → 읽기 → 보고
    reply(tool_use("run_command", {"command": "ls data/procurement/docs"}, id="s1")),
    reply(tool_use("read_file", {"path": "data/procurement/docs/06_대금지급_규정.md"}, id="s2")),
    reply(text("보고: 06_대금지급_규정.md — 삼자대사가 끝난 송장만 계좌로 지급, 어음 불가.")),
    # 부모: 보고서로 답변
    reply(text("어음 지급은 안 됩니다. 삼자대사가 끝난 송장만 계좌 송금합니다.")),
])
log = []


def watched(request):
    who = "서브" if "서브에이전트" in request["system"] else "부모"
    size = len(str(request["messages"]))
    log.append((who, len(request["messages"]), size))
    return script(request)


messages = [{"role": "user", "content": "어음으로 줘도 돼?"}]
print("답:", lab.run_turn(watched, messages))
print("\n호출  누구   메시지 수   배열 크기(글자)")
for i, (who, n, size) in enumerate(log, 1):
    print(f"{i:>3}   {who}   {n:>6}      {size:>7,}")
show(messages, "부모 배열 — 서브의 중간 과정은 없다")
실행 결과
답: 어음 지급은 안 됩니다. 삼자대사가 끝난 송장만 계좌 송금합니다.

호출  누구   메시지 수   배열 크기(글자)
  1   부모        1           43
  2   서브        1           86
  3   서브        3          433
  4   서브        5          828
  5   부모        3          345
── 부모 배열 — 서브의 중간 과정은 없다 ──
[0] user      "어음으로 줘도 돼?"
[1] assistant tool_use[p1] agent({"task": "data/procurement/docs 에서 '지급' 규정 …)
[2] user      tool_result[p1] "보고: 06_대금지급_규정.md — 삼자대사가 끝난 송장만 계좌로 지급…"
[3] assistant text "어음 지급은 안 됩니다. 삼자대사가 끝난 송장만 계좌 송금합니다."

💡 바꿔 보기: 서브에이전트가 파일을 하나 더 읽게 대본을 늘려 보세요. 부모 배열 길이는 그대로입니다.

예제 2 위임 메시지는 '맥락 없이도' 통해야 한다

서브에이전트는 빈 배열에서 시작합니다. 부모가 무슨 대화를 했는지 모릅니다. 그래서 "그거 찾아줘" 같은 위임은 실패합니다. 아래는 서브에이전트가 실제로 받는 첫 요청입니다.

import solution as lab
from common.brain import ScriptedBrain, reply, text

for task in ["그거 찾아줘", "data/procurement/docs 에서 '검수' 기준 파일을 찾아 판정 종류를 보고하라"]:
    sub = ScriptedBrain([reply(text("…"))])
    lab.run_subagent(sub, task)
    first = sub.requests[0]
    print("서브가 받은 messages:", first["messages"])
    print("서브가 가진 도구   :", [t["name"] for t in first["tools"]], "← agent 없음(무한 위임 방지)\n")
실행 결과
서브가 받은 messages: [{'role': 'user', 'content': '그거 찾아줘'}]
서브가 가진 도구   : ['read_file', 'write_file', 'run_command'] ← agent 없음(무한 위임 방지)

서브가 받은 messages: [{'role': 'user', 'content': "data/procurement/docs 에서 '검수' 기준 파일을 찾아 판정 종류를 보고하라"}]
서브가 가진 도구   : ['read_file', 'write_file', 'run_command'] ← agent 없음(무한 위임 방지)

3 확인 퀴즈

Q1 서브에이전트의 첫 요청 messages 는?

Q2 서브에이전트를 쓰는 가장 큰 이유는?

4 실습: exercise.py 채우기

TODO 표시 부분을 채우고 채점하세요. 입력 내용은 이 기기에 자동 저장됩니다.

5 정답과 테스트

정답 코드 보기 (solution.py)
#!/usr/bin/env python3
"""Lab 5 — 서브에이전트 = '새 배열'로 같은 루프를 한 번 더 돌리는 것.

한 배열에 모든 탐색 과정을 쌓으면 금방 찹니다. 서브에이전트는
빈 배열에서 시작해 일하고, 부모에게는 '최종 보고서 한 줄'만 돌려줍니다.
부모의 배열에는 중간 과정이 전혀 남지 않습니다 (컨텍스트 포크).

실행:  python labs/05_subagents/solution.py   (FakeBrain: 'ask read README.md')
"""
import os
import sys

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
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

SYSTEM = "너는 엔지니어링 에이전트다. 독립적인 조사는 agent 도구로 위임하라."
SUB_SYSTEM = "너는 집중형 서브에이전트다. 도구로 작업을 끝내고, 파일 경로를 인용한 독립적인 보고서로 마무리하라."

AGENT_TOOL = {
    "name": "agent",
    "description": "독립적인 하위 작업을 새 컨텍스트의 서브에이전트에게 맡기고 보고서를 받는다.",
    "input_schema": {"type": "object",
                     "properties": {"task": {"type": "string"}},
                     "required": ["task"]},
}
PARENT_TOOLS = BASE_TOOLS + [AGENT_TOOL]


def run_subagent(brain, task, tools=BASE_TOOLS, execute=execute_tool):
    """새 배열 [user: task] 로 SUB_SYSTEM 루프를 돌리고 최종 텍스트만 반환.
    서브에이전트의 도구 목록에는 agent 가 없어야 한다 (무한 위임 방지)."""
    # >>> TODO: 새 messages 를 만들고 run_loop(brain, messages, tools, SUB_SYSTEM, execute) 를 호출하세요
    sub_tools = [t for t in tools if t["name"] != "agent"]
    messages = [{"role": "user", "content": task}]
    return run_loop(brain, messages, sub_tools, SUB_SYSTEM, execute=execute)
    # <<< TODO


def make_executor(brain):
    """부모용 실행기: agent 는 서브에이전트로, 나머지는 기본 도구로 보낸다."""
    # >>> TODO: execute(name, args) 함수를 만들어 반환하세요
    def execute(name, args):
        if name == "agent":
            return run_subagent(brain, args["task"])
        return execute_tool(name, args)
    return execute
    # <<< TODO


def run_turn(brain, messages, on_event=None):
    return run_loop(brain, messages, PARENT_TOOLS, SYSTEM,
                    execute=make_executor(brain), on_event=on_event)


def main():
    brain, messages = get_brain(), []
    while True:
        try:
            user_text = input("\nyou> ").strip()
        except EOFError:
            break
        if user_text in ("/quit", ""):
            break
        messages.append({"role": "user", "content": user_text})
        run_turn(brain, messages, on_event=printer)


if __name__ == "__main__":
    main()
채점 테스트 보기 (test_lab.py)
import json
import os
import sys
import unittest
from unittest import mock

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from common.labkit import load_target  # noqa: E402
from common.brain import ScriptedBrain, reply, text, tool_use  # noqa: E402

lab = load_target(__file__)


class Lab5(unittest.TestCase):
    def test_fork_context(self):
        brain = ScriptedBrain([
            # 부모: 위임
            reply(tool_use("agent", {"task": "설정 파일 찾아줘"}, id="P1")),
            # 서브: 도구 하나 쓰고 보고
            reply(tool_use("run_command", {"command": "echo SECRET_DETAIL"}, id="S1")),
            reply(text("보고서: config.yaml 에 있음")),
            # 부모: 마무리
            reply(text("config.yaml 이네요")),
        ])
        msgs = [{"role": "user", "content": "설정 어딨어?"}]
        out = lab.run_turn(brain, msgs)
        self.assertEqual(out, "config.yaml 이네요")

        sub_first = brain.requests[1]
        self.assertEqual(sub_first["system"], lab.SUB_SYSTEM)
        self.assertEqual(sub_first["messages"], [{"role": "user", "content": "설정 파일 찾아줘"}],
                         "서브에이전트는 '새 배열'에서 시작해야 합니다")
        self.assertNotIn("agent", [t["name"] for t in sub_first["tools"]],
                         "서브에이전트에게 agent 도구를 주면 무한 위임이 가능해집니다")

        parent_result = msgs[2]["content"][0]
        self.assertEqual(parent_result["tool_use_id"], "P1")
        self.assertEqual(parent_result["content"], "보고서: config.yaml 에 있음")
        self.assertNotIn("SECRET_DETAIL", json.dumps(msgs, ensure_ascii=False),
                         "서브에이전트의 중간 과정이 부모 배열에 새면 안 됩니다")

    def test_executor_routes_other_tools(self):
        with mock.patch.object(lab, "execute_tool", return_value="BASE") as m:
            ex = lab.make_executor(ScriptedBrain([]))
            self.assertEqual(ex("read_file", {"path": "x"}), "BASE")
            m.assert_called_once()


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

📚 더 읽을거리

교재 전체 목록 →