이번 글의 목적
이번 단계에서 처음으로 에이전트답다는 느낌이 나옵니다.
M8의 목표는 AgentRuntime을 만들고, 진행 과정을 SSE로 스트리밍하는 것입니다.
사용자 prompt
-> AgentRuntime 시작
-> user message 저장
-> model 호출
-> tool event
-> tool result 저장
-> assistant response 저장
-> SSE event stream
M5와 M6에서는 내부 tool과 정책 계층을 만들었습니다.
M7에서는 그 구조를 Spring AI Tool Calling에 연결하는 adapter를 만들었습니다.
M8에서는 이 조각들을 실제 요청 흐름에 올립니다.
AgentRuntime은 무엇인가
AgentRuntime은 작업 흐름을 관리하는 중심 서비스입니다.
Spring 개발자에게 비유하면 다음과 같습니다.
| Spring MVC | Agent Runtime |
|---|---|
| DispatcherServlet | AgentRuntime |
| Controller | Agent API adapter |
| Service | ToolExecutionService |
| Repository | SessionStore |
| Response stream | SSE AgentEvent stream |
사용자의 요청은 Controller로 들어오지만, 실제 흐름을 조율하는 곳은 AgentRuntime입니다.
이번 구현에서는 DefaultAgentRuntime이 다음 일을 맡습니다.
1. 기존 session message를 읽는다
2. 새 user message를 SessionStore에 append한다
3. ModelClient를 호출한다
4. assistant delta를 event로 흘린다
5. tool call lifecycle을 event와 message로 남긴다
6. assistant message를 SessionStore에 append한다
7. agent_completed event로 stream을 닫는다
Controller는 얇게 유지합니다.
session을 찾고, 요청 DTO를 AgentPromptCommand로 바꾼 뒤, AgentRuntime.prompt(...)가 반환하는 Flux<AgentEvent>를 SSE로 내보냅니다.
왜 SSE가 좋은가
AI Agent는 응답이 오래 걸릴 수 있습니다.
파일을 읽고, 검색하고, model이 답을 만들고, 중간에 tool을 실행할 수도 있습니다.
사용자에게 최종 결과만 보여주면 어디에서 시간이 걸리는지 알기 어렵습니다.
그래서 중간 event를 보여주는 편이 좋습니다.
agent_started
user_message_appended
tool_call_started
tool_call_finished
assistant_delta
assistant_completed
agent_completed
WebFlux SSE를 쓰면 브라우저나 CLI에서 진행 상황을 실시간으로 볼 수 있습니다.
서버 입장에서도 Flux<AgentEvent>를 그대로 흘려보낼 수 있어 구조가 단순합니다.
실제 API 흐름
M8에서 추가된 API는 session 기반입니다.
먼저 session을 만듭니다.
POST /api/sessions
Content-Type: application/json
{
"workspaceRoot": "C:/path/to/project"
}
응답은 session id와 workspace root를 돌려줍니다.
{
"sessionId": "s_...",
"workspaceRoot": "C:/path/to/project"
}
그 다음 같은 session으로 message를 보냅니다.
POST /api/sessions/{sessionId}/messages/stream
Content-Type: application/json
Accept: text/event-stream
{
"text": "README를 읽고 프로젝트 구조를 설명해줘",
"allowWrite": false,
"allowBash": false
}
응답은 SSE입니다.
SSE event 이름은 공통으로 agent_event를 사용하고, 실제 event 종류는 JSON payload의 type 필드에 들어갑니다.
event: agent_event
data: {"type":"agent_started",...}
event: agent_event
data: {"type":"user_message_appended",...}
event: agent_event
data: {"type":"assistant_delta","text":"..."}
AgentEvent 예시
agent_started는 run이 시작됐다는 신호입니다.
{
"type": "agent_started",
"eventId": "e_...",
"sessionId": "s_...",
"createdAt": "2026-07-03T00:00:00Z"
}
user message가 저장되면 user_message_appended가 나갑니다.
{
"type": "user_message_appended",
"eventId": "e_...",
"sessionId": "s_...",
"createdAt": "2026-07-03T00:00:01Z",
"messageId": "m_..."
}
model이 tool을 호출하면 tool_call_started가 기록됩니다.
{
"type": "tool_call_started",
"eventId": "e_...",
"sessionId": "s_...",
"createdAt": "2026-07-03T00:00:02Z",
"callId": "tc_...",
"tool": "read"
}
tool 실행이 끝나면 tool_call_finished가 이어집니다.
{
"type": "tool_call_finished",
"eventId": "e_...",
"sessionId": "s_...",
"createdAt": "2026-07-03T00:00:03Z",
"callId": "tc_...",
"tool": "read",
"error": false
}
정책에서 거부된 tool call은 tool_call_denied로 남습니다.
tool 실행 중 문제가 생기면 agent_error가 나갈 수 있습니다.
이런 event가 있으면 디버깅이 쉬워집니다.
JSONL 파일을 열어도 어떤 순서로 일이 일어났는지 따라갈 수 있습니다.
Runtime loop는 작게 시작한다
처음부터 완전한 autonomous loop를 만들 필요는 없습니다.
M8의 loop는 작게 유지합니다.
사용자 message 1개
-> 기존 session history 로드
-> model streaming 호출 1회
-> 필요한 경우 read-only tool call
-> assistant response 저장
-> SSE 종료
여기서 중요한 점은 while loop를 직접 만들지 않았다는 것입니다.
tool calling은 Spring AI의 Tool Calling lifecycle을 사용합니다.
내부 tool 실행은 M7에서 만든 SpringAiToolCallbackAdapter를 통해 ToolExecutionService로 들어갑니다.
그래서 M8의 AgentRuntime은 tool을 직접 실행하는 세부 로직까지 알 필요가 없습니다.
복잡한 반복, compaction, branch, parallel tool execution orchestration은 나중에 붙여도 됩니다.
M8에서는 작지만 관찰 가능한 흐름을 만드는 것이 핵심입니다.
SessionStore와 연결
M8부터 M3의 SessionStore가 본격적으로 의미를 갖습니다.
DefaultAgentRuntime은 user message, tool result message, assistant message를 JSONL에 저장합니다.
user prompt
-> UserMessage append
-> ToolResultMessage append
-> AssistantMessage append
각 message에는 parentId가 있습니다.
M8에서는 새 message가 직전 message를 parent로 가리키도록 연결합니다.
아직 branch/fork를 구현하지는 않았지만, 나중에 replay나 branch를 붙일 수 있는 기본 형태는 갖춘 셈입니다.
또 하나 중요한 변화가 있습니다.
새 prompt를 받을 때 AgentRuntime은 기존 session message를 읽습니다.
그리고 새 user message까지 포함한 message history를 ModelClient에 넘깁니다.
SessionStore.loadMessages(sessionId)
-> 새 UserMessage append
-> AgentPromptCommand.messages에 history 포함
-> SpringAiModelClient가 ChatClient.messages(...)로 전달
이렇게 해야 같은 session에서 이어지는 질문을 model이 맥락으로 받을 수 있습니다.
session을 단순히 기록만 하는 것이 아니라, 다음 model 호출의 context로 다시 사용하는 구조입니다.
실제 패키지 구조
M8에서 중심이 되는 클래스는 다음과 같습니다.
agent/
AgentRuntime.java
DefaultAgentRuntime.java
AgentPromptCommand.java
api/
AgentController.java
AgentPromptRequest.java
CreateSessionRequest.java
CreateSessionResponse.java
model/
ModelClient.java
SpringAiModelClient.java
AgentRuntime은 Spring AI를 직접 많이 알지 않습니다.
model 호출은 ModelClient 뒤로 숨겨져 있습니다.
AgentRuntime
-> ModelClient
-> SpringAiModelClient
-> ChatClient
이 구조 덕분에 테스트에서는 실제 LLM API를 호출하지 않고 fake ModelClient를 넣을 수 있습니다.
테스트에서 확인한 것
M8 테스트는 API key 없이 통과해야 합니다.
이번 단계에서 확인한 핵심은 다음입니다.
- session 생성 API가 sessionId와 workspaceRoot를 반환한다
- stream endpoint가 SSE 형태로 AgentEvent를 반환한다
- user/assistant message가 SessionStore에 저장된다
- tool call event가 순서대로 발생한다
- tool result message가 SessionStore에 저장된다
- 같은 session의 후속 user message가 이전 message를 parent로 가진다
- 기존 session history가 model 요청에 포함된다
실제 model 호출은 테스트하지 않습니다.
대신 fake ModelClient와 mock ChatClient로 runtime과 adapter 경계를 확인합니다.
마무리
M8이 끝나면 프로젝트는 단순한 chat endpoint에서 Agent Harness로 넘어갑니다.
아직 기능은 작지만 중요한 구조는 보입니다.
상태를 저장하고
tool을 호출하고
진행 과정을 스트리밍한다
이 세 가지가 AgentRuntime의 뼈대입니다.
다음 단계에서는 이 runtime 위에 더 위험한 기능을 올리게 됩니다.
write/edit tool이나 bash tool은 편리하지만 위험합니다.
그래서 M8에서 먼저 session, event, policy 경계가 보이도록 만들어 두는 것이 중요합니다.
[codex] Add AgentRuntime SSE stream by dd3ok · Pull Request #11 · dd3ok/pi-spring-ai
'개발 > AI' 카테고리의 다른 글
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (10) - 위험한 bash 다루기 (0) | 2026.07.07 |
|---|---|
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (9) - write/edit 붙이기 (0) | 2026.07.06 |
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (7) - Tool Calling 감싸기 (1) | 2026.07.02 |
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (6) - Tool 실행 정책 (0) | 2026.07.01 |
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (5) - 읽기 Tool 달아주기 (0) | 2026.06.30 |