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[종료 이벤트 발화]

쉽게 풀기

에이전트 루프를 유능한 비서가 한 가지 부탁을 끝까지 처리하는 과정에 비유해 보자.

  1. 부탁을 비서의 말로 번역한다. 우리가 쓰는 메시지(AgentMessage)는 앱 화면에 친화적인 형태다. 하지만 LLM은 자기만의 양식(Message[])으로만 알아듣는다. 그래서 LLM에게 보내기 직전 단 한 곳에서만 번역한다. 이 외길 번역소가 convertToLlm이다. 화면 장식용 메시지처럼 LLM이 이해 못 할 것은 여기서 빈 손([])으로 걸러진다.

  2. 비서가 “이 도구 쓸게요”라고 답하면 실제로 도구를 실행한다. LLM이 “echo 도구를 호출하라”고 답하면, 루프가 그 도구를 진짜로 돌리고(execute), 결과를 다시 LLM에게 돌려준다. 도구는 여러 개를 한꺼번에 돌릴 수도 있고(shared), “나는 혼자 써야 해”라고 선언한 도구(exclusive)는 다른 도구가 끝날 때까지 줄을 세운다.

  3. 이 사이클을 “이제 됐어요”라고 할 때까지 반복한다. 도구를 더 쓸 일이 있으면 안쪽 루프가 계속 돈다. 비서가 다 끝냈다고 하면, 혹시 추가 부탁(getFollowUpMessages)이 큐에 있는지 바깥 루프가 한 번 더 확인하고, 없으면 종료한다.

  4. 중간에 끼어들거나 취소하면 우아하게 대응한다. 사용자가 일하는 도중 새 지시를 던지면(steering) 남은 도구는 건너뛰고 새 지시를 먼저 본다. 아예 취소(abort)하면, 진행 중이던 도구 호출에 “취소됨”이라는 자리표(placeholder)를 채워 넣는다. LLM API는 “도구를 부른 기록”과 “그 도구의 결과”가 짝을 이뤄야 하므로, 취소해도 짝을 깨지 않으려는 안전장치다.

  5. GPT-5가 내부 프로토콜 찌꺼기를 토하면 잘라낸다. GPT-5(openai-codex)는 가끔 “Harmony”라는 내부 양식의 찌꺼기를 답에 섞어 흘린다. 루프는 이를 감지해 ① 찌꺼기만 잘라내 깨끗한 답으로 진행하거나(truncate_resume) ② 안 되면 온도를 0.05 올려 다시 시도한다(abort_retry). 2번 실패하면 더 끌지 않고 에러로 올린다(escalate).

  6. 마지막에 영수증을 건넨다. 토큰을 얼마나 썼는지, 비용은 얼마인지, 어떤 도구를 몇 번 불렀고 성공/실패/차단/취소가 각각 몇 번인지를 한 장(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 우선·없으면 customWireNameintentTracing이면 _i 추출 → validateToolArguments(lenient면 raw 통과) → beforeToolCall(block 시 ToolCallBlockedError) → executecoerceToolResult(content 배열 강제 보정) → afterToolCall → 결과 push. 어시스턴트가 aborted/error로 끝나면 각 도구에 placeholder 결과를 채워 tool_use/tool_result 짝을 유지하고, tail sweep이 결과 미생성 도구를 단 한 번 보정한다.

영수증 집계: startInvokeAgentSpan(전체) ⊃ startChatSpan(스텝마다) / startExecuteToolSpan(도구마다). 마지막에 buildAgentEndEventcollector.snapshot()으로 요약+커버리지를 만든다. markRunEnded()는 멱등 — 성공/에러 경로 중 먼저 도달한 쪽만 onRunEnd를 발화한다.

요약 & 셀프체크

3줄 요약:

  1. 에이전트 루프는 “생각→도구→결과→다시 생각”을 종료 신호가 날 때까지 반복하는 두 겹 루프다.
  2. 대화는 앱 타입(AgentMessage)으로 들고 다니다가 LLM 직전 convertToLlm 한 곳에서만 번역한다.
  3. 취소·끼어들기·Harmony 누출을 우아하게 처리하고, 끝에 토큰·비용·도구 성적표(영수증)를 동봉한다.

스스로 답해 보기:

  • 화면 장식용 메시지를 LLM에게 안 보내려면 어디를 손봐야 하나? (힌트: 번역 경계)
  • 사용자가 도중에 취소했는데도 tool_use/tool_result 짝이 안 깨지는 이유는?
  • telemetry{}로 줄 때와 undefined로 줄 때 무엇이 달라지나?

연결

GJ_개요 · _분석축_루브릭

근거 파일 (소스 교차 확인 지점)

  • /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).