gajae-code · 에이전트 실행 루프 (Agent Loop)
한 줄 요약
AI가 “생각 → 도구 사용 → 결과 확인 → 다시 생각”을 스스로 끝까지 반복하게 만드는 심장 박동기다.
왜 배우나: 에이전트가 어떻게 멈추지 않고 일을 끝까지 해내는지, 그 반복의 뼈대를 알아야 나머지 모든 기능(도구·훅·취소·영수증)이 어디에 붙는지 보이기 때문이다.
그림
flowchart TD A[사용자 질문 도착] --> B["runLoop 시작 / 전체 추적 시작"] B --> C{"안쪽 루프: 할 일 남았나"} C --> D[끼어든 메시지 먼저 주입] D --> E[어시스턴트 응답 스트림 받기] E --> E1["transformContext: 대화 다듬기"] E1 --> E2["convertToLlm: 앱 언어를 LLM 언어로 번역"] E2 --> E3["LLM 호출 + 토큰/비용 기록"] E3 -->|취소 신호| X[깔끔히 정리하고 자리 채우기] E3 -->|Harmony 찌꺼기 누출| H{잘라내 복구 가능한가} H -->|가능| HR[찌꺼기 잘라낸 깨끗한 답으로 진행] H -->|불가능| HA[온도 살짝 올려 재시도] E3 --> F{도구를 쓰라고 했나} F -->|예| G[도구 실행] G --> G1["검증 → 사전훅 → 실행 → 결과 보정 → 사후훅"] G1 --> C F -->|아니오| I{"끼어들기·후속질문·일시정지 있나"} I -->|있음| C I -->|없음| Z["영수증 작성: 요약 + 커버리지"] Z --> END[종료 이벤트 발화]
쉽게 풀기
에이전트 루프를 유능한 비서가 한 가지 부탁을 끝까지 처리하는 과정에 비유해 보자.
-
부탁을 비서의 말로 번역한다. 우리가 쓰는 메시지(
AgentMessage)는 앱 화면에 친화적인 형태다. 하지만 LLM은 자기만의 양식(Message[])으로만 알아듣는다. 그래서 LLM에게 보내기 직전 단 한 곳에서만 번역한다. 이 외길 번역소가convertToLlm이다. 화면 장식용 메시지처럼 LLM이 이해 못 할 것은 여기서 빈 손([])으로 걸러진다. -
비서가 “이 도구 쓸게요”라고 답하면 실제로 도구를 실행한다. LLM이 “echo 도구를 호출하라”고 답하면, 루프가 그 도구를 진짜로 돌리고(
execute), 결과를 다시 LLM에게 돌려준다. 도구는 여러 개를 한꺼번에 돌릴 수도 있고(shared), “나는 혼자 써야 해”라고 선언한 도구(exclusive)는 다른 도구가 끝날 때까지 줄을 세운다. -
이 사이클을 “이제 됐어요”라고 할 때까지 반복한다. 도구를 더 쓸 일이 있으면 안쪽 루프가 계속 돈다. 비서가 다 끝냈다고 하면, 혹시 추가 부탁(
getFollowUpMessages)이 큐에 있는지 바깥 루프가 한 번 더 확인하고, 없으면 종료한다. -
중간에 끼어들거나 취소하면 우아하게 대응한다. 사용자가 일하는 도중 새 지시를 던지면(steering) 남은 도구는 건너뛰고 새 지시를 먼저 본다. 아예 취소(abort)하면, 진행 중이던 도구 호출에 “취소됨”이라는 자리표(placeholder)를 채워 넣는다. LLM API는 “도구를 부른 기록”과 “그 도구의 결과”가 짝을 이뤄야 하므로, 취소해도 짝을 깨지 않으려는 안전장치다.
-
GPT-5가 내부 프로토콜 찌꺼기를 토하면 잘라낸다. GPT-5(openai-codex)는 가끔 “Harmony”라는 내부 양식의 찌꺼기를 답에 섞어 흘린다. 루프는 이를 감지해 ① 찌꺼기만 잘라내 깨끗한 답으로 진행하거나(
truncate_resume) ② 안 되면 온도를 0.05 올려 다시 시도한다(abort_retry). 2번 실패하면 더 끌지 않고 에러로 올린다(escalate). -
마지막에 영수증을 건넨다. 토큰을 얼마나 썼는지, 비용은 얼마인지, 어떤 도구를 몇 번 불렀고 성공/실패/차단/취소가 각각 몇 번인지를 한 장(
AgentRunSummary+AgentRunCoverage)으로 모아 종료 이벤트에 실어 준다.
가장 중요한 설계 한 줄
대화는 끝까지
AgentMessage(앱 친화 타입)로 들고 다니고, 오직 LLM에게 보내기 직전 그 한 경계에서만Message[]로 변환한다. 번역소를 한 곳으로 좁혀 두면 어디서 무엇이 바뀌는지 추적이 쉬워진다.
핵심 정리
루프를 떠받치는 네 개의 큰 그림.
| 구성 요소 | 한마디로 | 켜지는 효과 |
|---|---|---|
convertToLlm | 단 하나의 번역 경계 | 앱 메시지 → LLM 메시지, 못 쓸 것은 []로 제거 |
| 두 겹 루프 | 안쪽=도구 반복, 바깥=후속질문 재진입 | 일이 끝나도 큐에 남은 부탁을 한 번 더 처리 |
| 도구 실행 사이클 | 검증→훅→실행→보정→훅 | 일관된 결과 형식과 차단/취소 짝 맞춤 보장 |
| 영수증(run-collector) | 토큰·비용·도구 성적표 | 종료 이벤트에 요약+커버리지 동봉 |
세 가지 진입점(상황에 맞게 골라 부름):
-
agentLoop— 새 프롬프트를 컨텍스트에 추가하며 처음 시작할 때 -
agentLoopContinue— 프롬프트 추가 없이 이어서 실행(재시도). 마지막 메시지가assistant면 API 짝 위반 방지로 throw -
agentLoopDetailed/...ContinueDetailed— 위와 같되 run 단위 요약·커버리지를 추가로 돌려줌
자주 헷갈리는 옵션
interruptMode: 도구 실행 중 끼어들기를 언제 볼지. 기본immediate(즉시),wait(현재 도구 끝나고).concurrency: "exclusive": 그 도구는 단독 실행, 다른 도구는 대기.nonAbortable: true: 취소 신호를 무시하고 끝까지 실행(execute에 signal을 안 넘김).intentTracing: true: 도구 스키마에 의도 설명 필드(_i)를 몰래 끼워 넣어 모호성을 거른 뒤, 실행 전 인자에서 다시 제거.telemetry: {}: 빈 객체만 줘도 추적 span과 영수증이 켜짐.undefined면 추적 0회.
실제 예시
1) 최소 동작 에이전트 루프 호출
// my-agent.ts — 최소 동작 에이전트 루프 호출 예시
import { agentLoopDetailed } from "@gajae-code/agent";
import type { AgentLoopConfig, AgentTool, AgentContext, AgentMessage } from "@gajae-code/agent";
const echoTool: AgentTool = {
name: "echo",
label: "Echo",
description: "Echo text back",
parameters: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
concurrency: "shared",
intent: "require", // 스키마에 _i 주입
async execute(_id, params) {
return { content: [{ type: "text", text: String((params as any).text) }], details: {} };
},
};
const context: AgentContext = {
systemPrompt: ["You are a helper."],
messages: [],
tools: [echoTool],
};
const config: AgentLoopConfig = {
model: { provider: "anthropic", id: "claude-...", api: "messages", baseUrl: undefined } as any,
// 단 하나의 번역 경계: 변환 불가 메시지는 [] 로 필터
convertToLlm: (messages: AgentMessage[]) =>
messages.flatMap(m => ("role" in m && (m.role === "user" || m.role === "assistant" || m.role === "toolResult") ? [m] : [])),
intentTracing: true, // 모호성 게이팅 ON
telemetry: {}, // OTEL span + 영수증 ON
maxTokens: 4096,
};
const ac = new AbortController();
const prompts: AgentMessage[] = [{ role: "user", content: [{ type: "text", text: "echo hi" }], timestamp: Date.now() } as any];
const { stream, detailed } = agentLoopDetailed(prompts, context, config, ac.signal);
for await (const ev of stream) {
if (ev.type === "tool_execution_end") console.log("tool done:", ev.toolName, ev.isError);
if (ev.type === "message_end") console.log("message:", (ev.message as any).role);
}
const { messages, telemetry, coverage } = await detailed();
console.log("영수증:", telemetry?.tools, coverage?.toolsUnused);2) 취소를 1회만 등록하는 abort 경쟁 (루프 핵심)
// packages/agent/src/agent-loop.ts
/** Sentinel returned by the abort race in `streamAssistantResponse`. */
const ABORTED: unique symbol = Symbol("agent-loop-aborted");
// 리스너를 이벤트마다 add/remove 하지 않고 스트림당 1회만 등록 후
// 같은 race 프로미스를 모든 iterator.next()에 재사용한다.
let abortRacePromise: Promise<typeof ABORTED> | undefined;
let detachAbortListener: (() => void) | undefined;
if (requestSignal) {
if (requestSignal.aborted) {
const aborted = emitAbortedAssistantMessage(partialMessage, addedPartial, context, config, stream);
await finishChat(aborted);
return aborted;
}
const { promise, resolve } = Promise.withResolvers<typeof ABORTED>();
const onAbort = () => resolve(ABORTED);
requestSignal.addEventListener("abort", onAbort, { once: true });
abortRacePromise = promise;
detachAbortListener = () => requestSignal.removeEventListener("abort", onAbort);
}
try {
while (true) {
let next: IteratorResult<AssistantMessageEvent>;
if (abortRacePromise) {
const result = await Promise.race([responseIterator.next(), abortRacePromise]);
if (result === ABORTED) { // unique symbol이라 일반 값과 절대 안 부딪힘
responseIterator.return?.()?.catch(() => {});
const aborted = emitAbortedAssistantMessage(partialMessage, addedPartial, context, config, stream);
await finishChat(aborted);
return aborted;
}
next = result;
} else {
next = await responseIterator.next();
}
// ...3) Harmony 누출 복구 (GPT-5 전용)
// packages/agent/src/agent-loop.ts
} catch (err) {
if (!(err instanceof HarmonyLeakInterruption)) throw err;
if (err.recovered) {
if (harmonyTruncateResumeCount >= 2) {
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
throw new Error(`GPT-5 Harmony leak recurred after truncate-and-resume recovery (...).`);
}
harmonyTruncateResumeCount++;
recovered = err.recovered;
message = recovered.message; // 잘라낸 깨끗한 메시지로 진행
await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
} else {
if (harmonyRetryAttempt >= 2) {
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
throw new Error(`GPT-5 Harmony leak persisted after ${harmonyRetryAttempt} retries (...).`);
}
await emitHarmonyAudit(config, err, "abort_retry", harmonyRetryAttempt);
harmonyRetryAttempt++;
continue; // 같은 모델 호출을 온도 +0.05로 재시도
}
}4) 직접 만들 때 체크리스트
-
convertToLlm을 단일 번역 경계로 유지하고 UI 전용 메시지는[]로 필터했는가. - 도구
execute가 항상{ content: [...] }를 반환하는가(누락 시 coerce가 isError로 강등). - 취소를 위해
signal을 전달했는가.nonAbortable도구는 abort를 무시함을 인지했는가. -
exclusive도구를 쓰면 다른 도구가 대기함을 의도했는가. -
beforeToolCall로 차단 시ToolCallBlockedError가 영수증의blocked로 잡힘을 확인했는가. -
intentTracing을 켰다면 도구별intent(omit/optional/함수) 정책을 설정했는가. -
telemetry: {}로 영수증을 받고, 종료 이벤트의stopReason(completed/paused)을 처리했는가. - GPT-5(openai-codex) 사용 시 Harmony 탐지가 자동 ON이며 2회 재시도 후 escalate됨을 인지했는가.
더 깊이 — 스키마와 내부 동작
번역 경계 안에서 일어나는 순서 (
streamAssistantResponse): ①transformContext(앱→앱 다듬기) → ②convertToLlm(앱→LLM, 단 한 곳) → ③normalizeMessagesForProvider(cerebras면 thinking 블록 제거) → ④appendOnlyContext있으면 append-only 로그로 prefix 캐시 빌드, 없으면 즉석 Context(여기서_i주입) → ⑤streamFn || streamSimple호출.도구 실행 사이클 (
executeToolCalls): 도구 매칭은name우선·없으면customWireName→intentTracing이면_i추출 →validateToolArguments(lenient면 raw 통과) →beforeToolCall(block 시ToolCallBlockedError) →execute→coerceToolResult(content 배열 강제 보정) →afterToolCall→ 결과 push. 어시스턴트가 aborted/error로 끝나면 각 도구에 placeholder 결과를 채워tool_use/tool_result짝을 유지하고, tail sweep이 결과 미생성 도구를 단 한 번 보정한다.영수증 집계:
startInvokeAgentSpan(전체) ⊃startChatSpan(스텝마다) /startExecuteToolSpan(도구마다). 마지막에buildAgentEndEvent가collector.snapshot()으로 요약+커버리지를 만든다.markRunEnded()는 멱등 — 성공/에러 경로 중 먼저 도달한 쪽만onRunEnd를 발화한다.
요약 & 셀프체크
3줄 요약:
- 에이전트 루프는 “생각→도구→결과→다시 생각”을 종료 신호가 날 때까지 반복하는 두 겹 루프다.
- 대화는 앱 타입(
AgentMessage)으로 들고 다니다가 LLM 직전convertToLlm한 곳에서만 번역한다. - 취소·끼어들기·Harmony 누출을 우아하게 처리하고, 끝에 토큰·비용·도구 성적표(영수증)를 동봉한다.
스스로 답해 보기:
- 화면 장식용 메시지를 LLM에게 안 보내려면 어디를 손봐야 하나? (힌트: 번역 경계)
- 사용자가 도중에 취소했는데도
tool_use/tool_result짝이 안 깨지는 이유는? telemetry를{}로 줄 때와undefined로 줄 때 무엇이 달라지나?
연결
근거 파일 (소스 교차 확인 지점)
- /home/seunghyeong/harness-work/gajae-code/packages/agent/src/agent-loop.ts
- /home/seunghyeong/harness-work/gajae-code/packages/agent/src/types.ts
- /home/seunghyeong/harness-work/gajae-code/packages/agent/src/harmony-leak.ts
- /home/seunghyeong/harness-work/gajae-code/packages/agent/src/run-collector.ts
- /home/seunghyeong/harness-work/gajae-code/packages/agent/src/telemetry.ts (startChatSpan/startExecuteToolSpan/recordSkippedTool/resolveTelemetry/AgentTelemetry 시그니처)
- /home/seunghyeong/harness-work/gajae-code/packages/agent/src/agent.ts (onAssistantMessageEvent 래핑 확인)
- 참고:
detectHarmonyLeakInAssistantMessage/recoverHarmonyToolCall은 agent-loop 외부(streamFn 래퍼/테스트)에서 호출되며, agent-loop는HarmonyLeakInterruption을 처리만 한다(packages/agent/test/harmony-leak.test.ts에서만 직접 호출 확인).- 대상지침 디렉터리 실재 확인: crates/, packages/, schemas/(config.schema.json, models.schema.json), AGENTS.md, python/(gjc-rpc, robogjc).