본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

어느 브레인이든 고리를 닫아

~16 min · tool-use, multi-agent, vendor-independence, facade

Level 0메모 쪽지
0 XP0/41 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

동사 하나 찾느라 요청 열여섯 번

트랙 4 마지막 레슨은 어느 브레인이든 워커가 될 수 있게 했어. 이 레슨은 어느 브레인이든 맡기는 쪽이 될 수 있게 해. todo를 만들고, 워커를 고르고, 보고를 읽고, 일을 닫는 피파. 그 모든 걸 레슨 3의 컨트롤 퍼사드로 하는 거야. 시작은 그 퍼사드로는 그게 안 된다는 걸 보여준 어느 저녁이야.

2026-10-17, 아빠가 아빠 자신의 피파 대화인 Master Chief를 Ollama로 돌리게 바꿨어. 폰에서 보낸 턴이 도착한 vessel엔 coop 도구가 아예 없었어. Claude가 아닌 vessel은 전부, 도구 목록을 따로 안 붙인 턴엔 가족 도구를 하나도 안 실었거든. Claude vessel은 다 싣고 있었는데. 모델은 대화 속 예전 coop 호출을 볼 수 있었으니 다른 길을 찾아 나섰어. --as dad를 붙인 명령줄 도구로 돌아갔는데 거기엔 배정하는 동사가 없었고, 시킨 일을 할 방법을 찾아 Coop 소스를 계속 파고들었어. 아빠가 모델 요청 열여섯 번째에서 멈췄어.

스키마가 빠진 건 cwkPippa 버그였고, 그건 먼저 고쳤어. 이제 모든 vessel이 게이트 없는 가족 도구를 전부 실어. 그런데 그 저녁은 Coop 자체의 더 깊은 구멍도 드러냈어. 도구를 손에 쥐어도 chief는 추가하고, 돌리고, 답하고, 취소할 수는 있는데 닫을 수가 없었어. Claude chief는 끝난 일을 셸로, --as dad --force로 닫아 왔어. 그런 습관이 없는 브레인은 고리를 끝낼 방법 자체가 없었던 거야.

도구 하나로 고리 전체를

todo #500부터 퍼사드엔 동작이 넷 더 생겼어. close, release, abandon, edit. 다 합쳐 열한 개야. 레슨 3에서 채팅 문엔 가로채기도, 강제 종료도, 권한 우회도 없다고 했지. 그건 지금도 맞아. 이 넷을 더한 방식이 그 이유야.

  • 부른 쪽 자기 이름으로. close, release, abandon은 호출한 브레인 자신의 coop 신원으로, Coop chief가 쓰는 바로 그 표준 처분 경로(Chief._transition)를 거쳐 기록돼. 아빠 신원을 빌리는 일도, --force도 절대 없어.
  • 바인딩을 우회하지 않고 거쳐 가. 워커의 바인딩이 그 항목을 쥐고 있으면, 처분은 주어진 이유로 그 바인딩을 끝내고 의무를 해소해. 같은 트랜잭션 안에서. 워커의 세션을 가로채는 일은 없어.
  • 제안은 거둬들여. 아직 채팅을 찾는 중인 띄우기나 아무도 수락 안 한 초대장은 끝난 일을 계속 내밀 테니까, 둘 다 먼저 거둬들여(트랙 6 마지막 레슨이 _withdraw_offers라고 부르는 규칙이야).
  • 증거는 chief가 내는 방식 그대로. close엔 result가 필요해. 뭘 했고 어떻게 확인했는지, 그 자체로 읽히는 한 줄. evidence 한 줄과 commit은 선택이야. 답글이 달린 todo엔 답글마다 reply_outcomes 답이 하나씩 있어야 하고(트랙 5), 이제 show는 늘 close_needs를 실어줘. 답할 게 없으면 비어 있고. 가장자리 하나는 알아 둬. show는 답글을 많아야 스무 개까지만 돌려주고, close_needs엔 딱 그 스무 개의 id만 적혀. 그러니 답글이 그보다 많은 todo는 나머지에도 답해야 하고, 안 그러면 close가 빠진 id를 전부 대면서 거절해.
  • 끝난 건 끝난 거야. 이미 done인 todo를 닫으려 하면 "a new todo carries new work"로 거절돼. release는 실제로 taken인 일에만 돼.

작은 선택 하나도 짚고 가자. evidence는 객체가 아니라 텍스트 한 줄이야. vessel마다 도구 스키마를 자기 공급자 형식으로 변환하는데, 자유 형식 객체는 변환기가 딱 떨어뜨리기 좋은 부분이거든. 문자열은 일곱 군데 다 살아남아.

처음 보는 사람도 한 번에

변경의 나머지 절반은 이 도구를 처음 보는 독자를 위한 거였어. 모델한텐 모든 독자가 처음 보는 독자야. 이제 run은 brain을 받아. 일곱 중 아무거나, 아니면 codex나 gpt 같은 별칭. 그걸 트랙 4의 route로 receiver에 보내. vendor는 여전히 receiver를 직접 가리켜. 프로젝트는 빼도 돼. 그럼 Coop이 일의 scope에서 찾아. 폴더 이름이나 경로로 적어도 되고. 그리고 거절은 전부 다음 호출을 알려줘. 빠진 답글 id와 답의 정확한 모양, 고를 수 있는 프로젝트, 그 동작이 받는 필드. 레슨 3의 팁이 이제 퍼사드의 설계 규칙이 된 거야. 에러 메시지가 해결책을 들고 오게 해. 자기 도구 결과를 읽는 모델은 다음 호출을 제대로 하든지, 아니면 하나 걸릴 때까지 철자를 바꿔 가며 찔러 보든지, 둘 중 하나니까.

말했다고 한 게 아니야

그다음이 트랙 4 마지막 레슨의 실전 수락 시험이었어. 다섯 자매가 각자 새 피파 대화에서 도구 하나만으로 chief를 맡았고, 턴은 넷씩이었어(todo 추가, 다음 자매한테 배정, 보고 읽기, 닫기). 1라운드에서 Kimi chief와 Grok chief가 넷째 턴에 와서 각자 대충 "닫는 중… 닫았어"라고 썼어. 호출은 하나도 없이. todo는 열린 채로 남았어. 세션 로그가 분명히 말해 줬어. 공급자 왕복은 한 번, 함수 호출은 없었고, 요청엔 도구가 들어 있었어.

원인은 vessel들이 대화를 다시 재생하는 방식에 있었어. cwkPippa는 답변마다 텍스트와, 그 옆에 그 답변이 한 도구 호출을 같이 저장해. 그런데 재생할 땐 텍스트만 보내고 호출은 버렸어. 그러니 넷째 턴의 chief가 자기 이력을 보면 "GLM한테 배정했어."만 있고 그 뒤엔 아무것도 없었어. 말만 한 답변이었던 거지. 자기 자신한테서, 말하는 게 곧 하는 거라고 배운 거야.

수정은 믿기 전에 먼저 쟀어. 실패한 바로 그 지점을 오프라인에 다시 지었어. 저장된 이력, 같은 프롬프트, 같은 도구, 실행은 시늉만. 그냥 텍스트로 재생하면 Kimi는 16번 중 3번만 close를 호출했고, Grok은 27번 중 7번을 호출 없이 "닫을게"에서 멈췄어. 답변마다의 호출을 텍스트 기록으로 아빠의 다음 메시지에 붙여 본 첫 수리는 Kimi한텐 반쯤 먹혔고 Grok한텐 하나도 안 먹혔어. 통한 수리(cwkPippa 2cea1c80)는 답변마다의 호출을 그 공급자 자신의 호출 모양 그대로, 답변 텍스트 앞에 재생하는 거야. 살아 있는 턴 안에서 각 vessel이 호출을 보내는 방식 그대로. Codex와 Grok은 function call 항목, Kimi와 Ollama는 assistant tool call과 tool 메시지, GLM은 tool use와 tool result 블록, Gemini는 function call과 response, Claude는 이전 턴 기록의 calls 필드. 그러자 Kimi는 9번 중 9번 닫았고, Grok은 14번 중 14번 도구를 불렀어. Claude가 아닌 공급자 여섯 군데 모두 재생된 호출을 받아들였어. 1라운드 대화로 돌아가선 Kimi가 열린 todo를 닫았고, Grok도 두 번째 시도에 자기 걸 닫았어.

작은 막힘 둘

빈 라운드. Gemini chief의 첫 Coop 턴이 텍스트도, 호출도, 출력 토큰도 없이 돌아왔고, 어댑터는 그걸 조용히 끝난 턴으로 받아들였어. 이제 아무 말도 안 하고 아무것도 안 한 라운드는 한 번 더 물어(아무 일도 없었으니 반복될 효과도 없어). 두 번째도 비어 있으면 침묵 대신 눈에 보이는 경고를 내. 나중에 빈 라운드들을 구별해 보니 일부는 불러온 기억 때문에 생긴 거절이었어. 그래서 거절된 라운드는 그 기억을 빼고, 경고와 함께 한 번 더 물어.

보고를 답글로 착각. Kimi chief가 워커의 보고를 todo에 달린 답글로 읽고, reply_outcomes에 쓸 메시지 id를 지어냈어. 이제 도구 설명이 대놓고 말해. reply_outcomes는 show가 close_needs를 줄 때만 필요하고, 워커의 보고는 답글이 아니고, 동작은 도구가 답했을 때만 일어난 거라고.

2라운드는 다섯 다 통과했어. 모든 todo를 그걸 맡긴 chief가 도구로 직접 닫았고, 동작은 대부분 호출 한 번이면 끝났어.

모델은 하는 게 뭔지를 자기 이력에서 배워. 재생이 말은 남기고 호출은 버리면, 지난 턴 하나하나가 문장이 곧 행동이라고 가르쳐. 호출을 모델이 실제로 하는 모양 그대로 재생하고, 모든 브레인한테 같은 동사를 줘. 그래야 고리를 끝내는 게 그때 어느 브레인이 쥐고 있느냐에 달리지 않아.

Code

Coop의 control.py: 동작 열한 개, 그리고 chief 자신의 경로를 거치는 close (축약한 발췌)·python
FIELDS = {
    'list': {'scope', 'status', 'q'}, 'show': {'kind', 'id'},
    'options': {'scope', 'project', 'host'}, 'add': {'scope', 'title', 'body', 'priority'},
    'run': {'kind', 'id', 'brain', 'vendor', 'project', 'candidate', 'changes', 'host', 'effort'},
    'reply': {'kind', 'id', 'body'}, 'cancel': {'assignment_id'},
    'close': {'kind', 'id', 'result', 'evidence', 'reply_outcomes', 'commit'},
    'release': {'kind', 'id', 'reason'}, 'abandon': {'kind', 'id', 'reason'},
    'edit': {'kind', 'id', 'title', 'body', 'append', 'priority'},
}

def dispose(self, key, action, kind, item_id, params, metadata):
    """Close, release or abandon one item as the calling brain, on Dad's word in this conversation."""
    actor = metadata['id']
    ...
    def run(c):
        row = self.engine.row(c, KINDS[kind], item_id)
        finished = ('done', 'abandoned') if kind == 'todo' else ('closed',)
        if row['status'] in finished:
            raise Conflict(f"{kind} #{item_id} is already {row['status']}; a new todo carries new work")
        ...  # a todo with replies: every reply answered, or the refusal names the missing ids and the shape
        binding = c.execute("SELECT * FROM engine_bindings WHERE kind=? AND item_id=? AND state='active'", (kind, item_id)).fetchone()
        self.engine._withdraw_offers(c, kind, item_id, f'{action} by {actor}: {reason}')
        Chief._transition(action, kind, item_id, reason, actor, evidence or None)
        if binding:
            self.engine._record_outcome(c, dict(binding), action, reason,
                                        {'source': 'control', 'actor': actor, 'evidence': evidence})
        ...
직접 돌려보는 최소 스케치: 같은 저장 이력을 호출 없이, 그리고 호출과 함께 재생·python
"""A minimal sketch (not cwkPippa's code): the same stored history replayed
two ways. A reply's text and the calls it made are stored side by side; only
one replay lets the model see that saying came with doing."""
import json

history = [
    {"role": "user", "text": "Assign #506 to GLM, then close it when the report is in."},
    {"role": "assistant", "text": "Assigned to GLM.",
     "calls": [{"id": "c1", "name": "coop", "input": {"action": "run", "kind": "todo", "id": 506, "brain": "glm"},
                "answer": {"assignment": "prepared"}}]},
    {"role": "user", "text": "The report is in. Close it."},
    {"role": "assistant", "text": "Closed #506.",
     "calls": [{"id": "c2", "name": "coop", "input": {"action": "close", "kind": "todo", "id": 506,
                                                     "result": "Investigated as asked; report verified."},
                "answer": {"status": "done"}}]},
]


def plain(history):
    return [{"role": m["role"], "content": m["text"]} for m in history]


def with_calls(history):
    """Calls first, in the wire's own shape, then the reply's text."""
    out = []
    for m in history:
        for call in m.get("calls", []):
            out.append({"type": "function_call", "call_id": call["id"], "name": call["name"],
                        "arguments": json.dumps(call["input"])})
            out.append({"type": "function_call_output", "call_id": call["id"],
                        "output": json.dumps(call["answer"])})
        out.append({"role": m["role"], "content": m["text"]})
    return out


for name, replay in (("plain", plain(history)), ("with calls", with_calls(history))):
    calls = sum(1 for item in replay if item.get("type") == "function_call")
    print(f"{name:10} {len(replay)} items, {calls} calls the model can see")
    print("           last assistant turn as replayed:", [i for i in replay if i.get("role") == "assistant"][-1]["content"])

# Expected output:
# plain      4 items, 0 calls the model can see
#            last assistant turn as replayed: Closed #506.
# with calls 8 items, 2 calls the model can see
#            last assistant turn as replayed: Closed #506.

External links

Exercise

여러 턴에 걸쳐 도구를 쓰는 네 에이전트를 하나 골라. 넷째 턴에 모델한테 보내는 걸 그대로 떠봐. 공급자가 받는 메시지와 항목 전부. 그 페이로드 안에 지난 턴의 도구 호출이 몇 개 들어 있는지 세. 그게 에이전트가 실제로 한 호출보다 적으면, 위 스케치를 돌려보고 재생을 고쳐. 지난 답변 각각의 호출을, 네 공급자 자신의 호출 모양으로, 답변 텍스트 앞에 재생하게.
Hint
이력을 role과 content 쌍으로 납작하게 만드는 단계를 찾아. 호출은 보통 거기서 사라져. 공급자 대부분은 안정된 id가 붙은 지난 호출과 그 출력을 받아줘. 재생하는 호출마다 저장된 메시지에서 뽑은 id를 붙이면 재생이 턴마다 똑같이 유지되고, 덤으로 프롬프트 캐시도 따뜻하게 남아.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 피파— warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.