본문 바로가기

개발/AI

Spring AI로 Pi 스타일 에이전트 하네스 만들기 (13) - SSE는 실시간 관찰, JSONL은 기록

이번 글의 목적

M12에서는 tool safety를 다시 점검했습니다.

write, edit, bash가 위험한 요청을 막는지 확인했고, 거부된 tool call이 SSE와 JSONL history에 남는지도 테스트로 고정했습니다.

이번 M13에서는 그 기록 자체를 더 잘 다룹니다.

코딩 에이전트는 단순히 답변만 만들지 않습니다.

사용자의 메시지를 받고, 모델을 호출하고, 필요하면 tool을 실행하고, 그 결과를 다시 모델에게 넘깁니다.

이 과정이 보이지 않으면 디버깅하기 어렵습니다.

방금 agent가 무슨 일을 했는가?
실시간으로는 무엇이 흘렀는가?
나중에 다시 열어볼 수 있는 기록은 어디에 있는가?
README만 보고 이 흐름을 따라갈 수 있는가?

 

M13은 이 질문에 답하기 위해 session history 조회 흐름을 정리한 단계입니다.

M13에서 만들지 않는 것

M13은 replay라는 말을 쓰지만, 복잡한 replay engine을 만든 단계가 아닙니다.

DB를 붙이지 않는다.
branch/fork를 구현하지 않는다.
compaction을 구현하지 않는다.
Spring AI ChatMemory를 session history 대체재로 쓰지 않는다.
새 production dependency를 추가하지 않는다.

 

여기서 중요한 것은 ChatMemory를 쓰지 않았다는 점입니다.

Spring AI의 memory 기능은 대화 맥락을 모델에게 다시 넣는 데 유용합니다.

하지만 이 프로젝트에서 필요한 것은 조금 다릅니다.

사용자가 나중에 직접 열어보고, 어떤 tool call이 있었고 어떤 event가 흘렀는지 확인할 수 있는 기록입니다.

그래서 M13은 새로운 저장소나 추상화를 붙이지 않았습니다.

이미 만든 JSONL session store와 SSE event stream을 그대로 사용합니다.

대신 그것을 조회하고 설명할 수 있는 API와 문서를 더했습니다.

두 종류의 기록

이 프로젝트에는 서로 다른 성격의 기록이 있습니다.

message: 대화와 tool result의 기록
event: 실행 과정에서 관찰 가능한 사건

 

message는 conversation history에 가깝습니다.

예를 들면 다음과 같습니다.

{"kind":"user","text":"프로젝트 구조를 설명해줘"}
{"kind":"tool_result","toolName":"ls","resultText":"README.md\nsrc\n...","error":false}
{"kind":"assistant","text":"이 프로젝트는 Spring AI 기반의..."}

 

event는 실행 trace에 가깝습니다.

{"type":"agent_started"}
{"type":"tool_call_started","tool":"ls","callId":"tc_1"}
{"type":"tool_call_finished","tool":"ls","callId":"tc_1","error":false}
{"type":"agent_completed"}

 

둘을 섞지 않는 이유는 간단합니다.

message는 모델에게 다시 보여줄 수 있는 대화 기록입니다.

event는 사람이 실행 흐름을 관찰하기 위한 기록입니다.

둘을 분리해 두면 나중에 debugging, replay, demo 설명이 쉬워집니다.

SSE와 JSONL의 역할

M8에서 SSE endpoint를 만들었습니다.

사용자가 메시지를 보내면 text/event-stream으로 agent event가 흘러옵니다.

event:agent_event
id:e_...
data:{"type":"agent_started",...}

 

SSE는 실시간 관찰에 좋습니다.

브라우저나 클라이언트는 agent가 시작됐는지, tool call이 시작됐는지, 응답이 끝났는지 바로 볼 수 있습니다.

하지만 SSE만으로는 부족합니다.

실시간 stream은 지나가고 나면 끝입니다.

개발자는 나중에 다시 보고 싶습니다.

어떤 prompt에서 문제가 생겼나?
write tool이 왜 거부됐나?
bash command는 실행됐나, policy에서 막혔나?
assistant message는 어떤 parent를 가졌나?

 

이때 JSONL session history가 필요합니다.

.pi-spring-ai/sessions/{sessionId}/
  session.json
  messages.jsonl
  events.jsonl

 

SSE는 지금 보는 화면이고, JSONL은 나중에 열어보는 기록입니다.

M13은 이 관계를 README에 명확히 적고, API로도 같은 history를 확인할 수 있게 했습니다.

Session history 조회 API

M11 README에서는 session 조회 API를 아직 만들지 않았다고 적었습니다.

M13에서는 이 부분을 바꿨습니다.

GET /api/sessions/{sessionId}

 

응답은 세 부분으로 나뉩니다.

{
  "session": {
    "id": "s_...",
    "workspaceRoot": "C:\\path\\to\\project",
    "createdAt": "...",
    "updatedAt": "...",
    "title": null
  },
  "messages": [
    {"kind": "user", "text": "..."},
    {"kind": "tool_result", "toolName": "ls", "error": false}
  ],
  "events": [
    {"type": "agent_started"},
    {"type": "tool_call_started"},
    {"type": "agent_completed"}
  ]
}

 

컨트롤러 코드는 작습니다.

@GetMapping("/{sessionId}")
public Mono<SessionHistoryResponse> getSession(@PathVariable String sessionId) {
    return Mono.fromCallable(() -> {
                AgentSession session = findSession(sessionId);
                return SessionHistoryResponse.from(
                        session,
                        sessionStore.loadMessages(session.id()),
                        sessionStore.loadEvents(session.id()));
            })
            .subscribeOn(Schedulers.boundedElastic());
}

 

여기서도 기존 패턴을 유지합니다.

SessionStore는 파일을 읽습니다. 파일 IO는 blocking 작업입니다.

그래서 Mono.fromCallable(...)로 감싸고 Schedulers.boundedElastic()에서 실행합니다.

M8에서 session 생성과 stream 시작 전 session 조회에 적용했던 방식과 같습니다.

AgentRuntime loop는 건드리지 않았습니다.

Spring AI adapter도 건드리지 않았습니다.

M13에서 필요한 것은 이미 저장된 기록을 읽는 API입니다.

그래서 컨트롤러와 응답 DTO만 추가했습니다.

왜 별도 DTO를 두었나

처음 보면 AgentSession을 그대로 응답으로 내려도 될 것처럼 보입니다.

하지만 API 응답은 내부 도메인 객체를 그대로 노출하는 곳이 아닙니다.

특히 workspaceRootPath입니다.

JSON 응답에서는 사용자가 이해하기 쉬운 문자열이면 충분합니다.

그래서 작은 DTO를 하나 둡니다.

public record SessionHistoryResponse(
        SessionSummary session,
        List<AgentMessage> messages,
        List<AgentEvent> events
) {
    public static SessionHistoryResponse from(
            AgentSession session,
            List<AgentMessage> messages,
            List<AgentEvent> events) {
        return new SessionHistoryResponse(
                SessionSummary.from(session),
                List.copyOf(messages),
                List.copyOf(events));
    }
}

 

SessionSummary는 API에 필요한 값만 담습니다.

public record SessionSummary(
        String id,
        String workspaceRoot,
        Instant createdAt,
        Instant updatedAt,
        String title
) {
    private static SessionSummary from(AgentSession session) {
        return new SessionSummary(
                session.id(),
                session.workspaceRoot().toString(),
                session.createdAt(),
                session.updatedAt(),
                session.title());
    }
}

 

이 정도 DTO는 과한 추상화가 아닙니다.

오히려 API 모양을 안정적으로 만듭니다.

내부에서 AgentSession의 표현이 바뀌어도 외부 응답은 id, workspaceRoot, createdAt, updatedAt, title로 유지할 수 있습니다.

테스트로 고정한 것

M13의 테스트는 controller smoke test에 가깝습니다.

핵심은 GET /api/sessions/{sessionId}가 replay에 필요한 정보를 같은 응답으로 돌려주는지 확인하는 것입니다.

@Test
void getSessionReturnsSessionMessagesAndEventsForReplay() {
    webTestClient.get()
            .uri("/api/sessions/s_existing")
            .exchange()
            .expectStatus().isOk()
            .expectBody()
            .jsonPath("$.session.id").isEqualTo("s_existing")
            .jsonPath("$.messages[0].kind").isEqualTo("user")
            .jsonPath("$.messages[1].kind").isEqualTo("tool_result")
            .jsonPath("$.messages[1].toolName").isEqualTo("read")
            .jsonPath("$.messages[1].error").isEqualTo(false)
            .jsonPath("$.messages[2].kind").isEqualTo("assistant")
            .jsonPath("$.events[0].type").isEqualTo("agent_started")
            .jsonPath("$.events[1].type").isEqualTo("tool_call_started")
            .jsonPath("$.events[1].callId").isEqualTo("tc_1")
            .jsonPath("$.events[2].type").isEqualTo("tool_call_finished")
            .jsonPath("$.events[3].type").isEqualTo("agent_completed");
}

 

단순히 session metadata만 확인하지 않았습니다.

replay/debug에서 중요한 것은 message와 event의 순서입니다.

그래서 user message, tool result, assistant message가 순서대로 내려오는지 봅니다.

event도 agent_started, tool_call_started, tool_call_finished, agent_completed 순서를 확인합니다.

없는 session도 확인합니다.

@Test
void getSessionReturnsNotFoundForMissingSession() {
    webTestClient.get()
            .uri("/api/sessions/s_missing")
            .exchange()
            .expectStatus().isNotFound();
}

 

 

history 조회 API는 demo와 debugging에서 자주 쓰일 endpoint입니다.

없는 session을 조회했을 때 조용히 빈 history를 돌려주면 안 됩니다.

session 자체가 없다는 사실을 명확히 알려야 합니다.

데모 흐름

README에는 M13 demo를 하나 추가했습니다.

먼저 session을 만듭니다.

$session = Invoke-RestMethod `
  -Method Post `
  -Uri "http://localhost:8080/api/sessions" `
  -ContentType "application/json" `
  -Body '{"workspaceRoot":"."}'

 

그 session으로 메시지를 보냅니다.

Invoke-WebRequest `
  -Method Post `
  -Uri "http://localhost:8080/api/sessions/$($session.sessionId)/messages/stream" `
  -ContentType "application/json" `
  -Headers @{ Accept = "text/event-stream" } `
  -Body '{"text":"프로젝트 구조를 간단히 설명해줘","allowWrite":false,"allowBash":false}'

 

실시간으로는 SSE event가 보입니다.

그 뒤 같은 session을 다시 조회합니다.

Invoke-RestMethod `
  -Method Get `
  -Uri "http://localhost:8080/api/sessions/$($session.sessionId)"

 

확인할 것은 네 가지입니다.

messages에는 user, tool result, assistant 기록이 실행 순서대로 남는다.
events에는 SSE로 흘렀던 agent_started, tool event, agent_completed가 남는다.
messages.jsonl과 events.jsonl을 직접 열어도 같은 흐름을 볼 수 있다.
이 API는 replay/debug용이며 branch/fork, compaction, DB 저장을 구현하지 않는다.

 

 

사용자가 한 번의 agent run을 실시간 stream과 저장된 history 양쪽에서 설명할 수 있게 하는 것입니다.

왜 DB가 아닌가

M13에서 DB를 붙일 수도 있었습니다.

session history를 조회한다고 하면 자연스럽게 table, query, pagination 같은 이야기가 나옵니다.

하지만 이 프로젝트의 현재 목표는 Spring 개발자가 agent runtime의 기본 구조를 이해하는 단계입니다.

AgentRuntime이 event를 만든다.
SessionStore가 message와 event를 append한다.
SSE는 event를 실시간으로 흘린다.
JSONL은 같은 흐름을 파일에 남긴다.
GET API는 그 기록을 다시 읽어 보여준다.

 

 

DB, pagination, branch/fork는 나중에 붙일 수 있습니다.

지금 붙이면 글의 초점이 흐려집니다.

M13의 주제는 persistence technology가 아니라 agent trace를 설명 가능한 형태로 남기는 일입니다.

Spring AI ChatMemory와의 차이

Spring AI의 ChatMemory는 모델에게 다시 전달할 대화 맥락을 다루는 데 적합합니다. 

하지만 M13의 목표는 전체 agent 실행 흐름을 사람이 다시 열어볼 수 있게 남기는 것이므로, JSONL 기반 SessionStore를 유지했습니다.

ChatMemory: 모델에게 다시 줄 대화 맥락
Session history: 사람이 다시 볼 실행 기록

 

물론 둘은 나중에 연결될 수 있습니다.

하지만 M13에서는 대체 관계로 보지 않았습니다.

특히 tool event, policy denied reason, timeout, bash execution metadata 같은 정보는 일반적인 chat memory보다 audit log에 가깝습니다.

그래서 지금은 JSONL session history를 유지합니다.

Spring AI는 model 호출과 tool calling을 맡고, 이 프로젝트의 SessionStore는 실행 기록을 사람이 읽을 수 있게 남깁니다.

테스트 흐름

M13에서는 먼저 controller test로 좁게 확인했습니다.

./gradlew test --tests cohttp://m.example.pispringai.api.AgentControllerTest

 

그 다음 JSONL load 흐름이 깨지지 않았는지 session store 테스트를 확인합니다.

./gradlew test --tests com.example.pispringai.session.JsonlSessionStoreTest

 

마지막에는 전체 테스트를 실행합니다.

./gradlew test

 

마무리

M13은 큰 기능을 추가한 단계가 아닙니다.

하지만 코딩 에이전트에서는 기록을 다시 볼 수 있다는 점이 중요합니다.

agent가 파일을 읽고, tool을 실행하고, 명령을 거부하고, 응답을 만드는 과정은 사용자가 믿고 확인할 수 있어야 합니다.

실시간으로는 SSE를 봅니다.

나중에는 JSONL history를 엽니다.

API는 그 둘 사이를 이어줍니다.

이번 단계에서 확인한 것은 세 가지입니다.

SSE는 지금 실행 중인 agent를 관찰하는 통로다.
JSONL은 나중에 다시 열어보는 session 기록이다.
history 조회 API는 이 기록을 demo와 debugging에 쓰기 쉽게 만든다.

 

다음 단계인 M14에서는 MCP client integration으로 넘어갑니다.

다만 방향은 같습니다.

MCP를 붙이더라도 기존 ToolRegistry, ToolPolicy, SSE, JSONL session history를 우회하지 않습니다.

새로운 도구가 들어와도 agent runtime의 경계는 그대로 지켜야 합니다.

 

[codex] M13 session history replay polish by dd3ok · Pull Request #16 · dd3ok/pi-spring-ai