← 개발 로그 목록

local-agent: 파일 도구, 세션 지속성, 웹 UI 추가 및 에이전트 코어 리팩토링

/ 23분 분량 / 개발 로그

이번 커밋에서는 `local-agent` 프로젝트에 파일 시스템 접근 도구, 세션 데이터 저장 및 복구 기능, 그리고 사용자 친화적인 웹 채팅 인터페이스를 도입했습니다. 또한, 에이전트의 핵심 로직을 리팩토링하여 터미널 REPL과 웹 요청/응답 사이클 간의 코드 중복을 제거하고, 보다 유연하고 재개 가능한 제너레이터 형태로 전환했습니다.

요약

이 커밋은 local-agent의 기능을 확장하고 핵심 로직을 개선하는 데 중점을 두었습니다. 새롭게 추가된 기능은 다음과 같습니다:

  • 파일 시스템 도구: read_file 및 write_file 함수를 통해 파일 읽기 및 쓰기 기능을 에이전트가 사용할 수 있게 되었습니다.
  • 세션 지속성: 대화 기록을 JSON 파일로 저장하고 불러오는 기능을 구현하여 --session 및 --continue 플래그를 통해 이전 세션을 이어갈 수 있도록 했습니다.
  • 웹 채팅 UI: Flask 기반의 간단한 단일 페이지 채팅 UI를 --web 및 --port 플래그와 함께 제공합니다. 이 UI는 CLI와 동일한 에이전트 루프 및 확인 로직을 공유합니다.
  • run_agent_turn 리팩토링: 기존의 동기 함수를 재개 가능한 제너레이터로 변경하여, 대화 흐름 중에 일시 중지 및 재개를 지원합니다. 이 변경은 CLI와 웹 UI 모두에서 동일한 에이전트 로직을 공유할 수 있게 해줍니다.

총 532 라인이 추가되고 42 라인이 삭제되었습니다.

배경 및 목적

이전까지 local-agent는 주로 터미널 기반의 상호작용에 초점을 맞추고 있었습니다. 하지만 사용자의 편의성을 높이고 더 다양한 환경에서 에이전트를 활용하기 위해서는 다음과 같은 기능들이 필요했습니다.

  • 파일 접근: 에이전트가 정보를 읽거나 작성하기 위해 외부 파일 시스템에 접근할 수 있어야 합니다. 이는 코드 생성, 설정 파일 수정, 데이터 분석 등 다양한 작업에 필수적입니다.
  • 대화 기록 유지: 장시간의 대화나 중요한 정보를 잃지 않고 세션을 이어갈 수 있는 기능이 필요했습니다. 사용자가 언제든지 대화를 중단하고 다시 시작할 수 있어야 합니다.
  • 사용자 인터페이스 다양화: 터미널뿐만 아니라 웹 브라우저를 통해서도 에이전트와 쉽게 상호작용할 수 있는 방법을 제공하여 접근성을 높이고자 했습니다.
  • 코드 중복 제거 및 유연성 증대: 동일한 에이전트 로직이 터미널과 웹 UI에서 각각 구현되는 것은 비효율적이며 유지보수를 어렵게 만듭니다. 하나의 핵심 로직을 여러 환경에서 재사용할 수 있도록 개선이 필요했습니다.

이러한 문제들을 해결하기 위해 이번 커밋에서는 파일 시스템 도구, 세션 관리, 웹 UI 개발 및 에이전트 핵심 로직의 리팩토링을 진행했습니다.

구현 내용

이번 커밋의 핵심 변경 사항은 다음과 같습니다.

1. 파일 시스템 도구 추가 (local_agent/tools/files.py)

read_file 및 write_file 함수를 추가하여 에이전트가 파일 시스템과 상호작용할 수 있도록 했습니다.

  • read_file(path: str, max_chars: int = 20000): 지정된 경로의 텍스트 파일을 읽어옵니다. max_chars 인자를 통해 최대 읽어올 문자 수를 제한할 수 있으며, 기본값은 20000자입니다. 파일 내용을 반환하며, 잘렸을 경우 truncated 플래그를 True로 설정합니다.
  • write_file(path: str, content: str, mode: str = "overwrite"): 지정된 경로에 텍스트 내용을 씁니다. mode 인자는 "overwrite" (기본값) 또는 "append"를 지원합니다. 경로가 존재하지 않으면 부모 디렉토리를 생성합니다.
# local_agent/tools/files.py (일부 발췌)

from pathlib import Path

# ... (tool 데코레이터 및 import)

@tool(...)
def read_file(path: str, max_chars: int = 20000) -> dict:
    content = Path(path).read_text(encoding="utf-8", errors="replace")
    return {"content": content[:max_chars], "truncated": len(content) > max_chars}

@tool(...)
def write_file(path: str, content: str, mode: str = "overwrite") -> dict:
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    if mode == "append":
        with p.open("a", encoding="utf-8") as f:
            f.write(content)
    else:
        p.write_text(content, encoding="utf-8")
    return {"path": str(p), "bytes_written": len(content.encode("utf-8"))}

2. 세션 지속성 구현 (local_agent/session_store.py 및 local_agent/cli.py 연동)

local_agent/session_store.py 파일에 세션 데이터를 JSON으로 저장하고 불러오는 함수들이 추가되었습니다. local_agent/cli.py에서는 이 기능을 활용하여 --session 및 --continue 플래그를 통해 이전 대화 기록을 로드하고, 현재 대화 기록을 세션 파일에 저장합니다.

  • load(session_path): 지정된 경로에서 세션 데이터를 불러옵니다.
  • save(history_list, session_path): 현재 대화 기록을 JSON 형식으로 저장합니다.
  • named_session_path(name): 이름으로 세션 파일 경로를 생성합니다.
  • latest_session_path(): 가장 최근에 사용된 세션 파일 경로를 반환합니다.
  • new_session_path(): 새로운 세션 파일 경로를 생성합니다.

CLI 실행 시, --session 또는 --continue 플래그가 제공되면 해당 세션 파일을 로드하고, 대화가 종료되면 finally 블록에서 현재 세션 상태를 저장합니다.

3. 웹 채팅 UI 추가 (local_agent/web.py)

Flask를 사용하여 간단한 웹 채팅 인터페이스를 구현했습니다.

  • PAGE 변수에 HTML, CSS, JavaScript가 포함된 단일 페이지 애플리케이션 코드가 정의되어 있습니다.
  • / 경로로 접근하면 채팅 UI가 표시됩니다.
  • /api/history 엔드포인트는 이전 대화 기록을 반환합니다.
  • /api/chat 엔드포인트는 사용자의 메시지를 받아 에이전트에게 전달하고 응답을 처리합니다.
  • /api/confirm 엔드포인트는 도구 실행 확인에 대한 사용자 결정을 처리합니다.

CLI의 run_web 함수를 통해 --web 및 --port 플래그로 웹 UI를 실행할 수 있습니다.

# local_agent/web.py (일부 발췌)

from flask import Flask, jsonify, request

# ... (import)

PAGE = """<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>local-agent</title>
<style>
  body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; background: #0b0f14; color: #e6edf3; }
  /* ... (CSS 스타일) ... */
</style>
</head>
<body>
<h2>local-agent</h2>
<div id="log"></div>
<div id="inputRow">
  <input id="userInput" placeholder="메시지를 입력하세요" autofocus>
  <button id="sendBtn" onclick="send()">전송</button>
</div>
<div id="confirmBox">
  <strong>[확인 필요]</strong>
  <pre id="confirmDetail"></pre>
  <div class="confirmBtns">
    <button onclick="confirmDecision('yes')">승인</button>
    <button onclick="confirmDecision('no')">거부</button>
    <button onclick="confirmDecision('always')">이번 세션 항상 승인</button>
  </div>
</div>
<script>
// ... (JavaScript 코드) ...
</script>
</body>
</html>
"""

def create_app(settings: Settings) -> Flask:
    app = Flask(__name__)
    # ... (세션 설정 및 state 초기화) ...

    @app.get("/")
    def index():
        return PAGE

    # ... (API 엔드포인트 구현) ...

    return app

def run_web(settings: Settings, port: int = 8765) -> None:
    app = create_app(settings)
    print(f"local-agent web UI: http://127.0.0.1:{port}  (model={settings.model}, auto_approve={settings.auto_approve})")
    app.run(host="127.0.0.1", port=port)

4. run_agent_turn 리팩토링 및 drive_turn 추가 (local_agent/agent.py)

가장 중요한 변경 사항 중 하나는 run_agent_turn 함수를 제너레이터로 리팩토링한 것입니다. 이제 이 함수는 대화의 각 단계를 yield하여 제어 흐름을 외부로 넘길 수 있습니다.

  • run_agent_turn (제너레이터):
    • yield {"type": "message", "content": str}: 에이전트의 텍스트 응답을 전달합니다.
    • yield {"type": "confirm", "tool_name": str, "args": dict}: 도구 호출에 대한 확인이 필요할 때 이 값을 yield합니다. 호출자는 .send(True|False|"always")를 통해 제너레이터를 재개해야 합니다.
    • StopIteration.value를 통해 최종 auto_approve 플래그를 반환합니다.
  • drive_turn(gen, send_value=None):
    • 제너레이터 gen을 한 단계 진행시키거나, send_value를 전달하여 재개합니다.
    • 완료될 때까지 yield된 메시지를 모아서 반환합니다.
    • 확인 필요 시 {"status": "confirm", ...}을, 완료 시 {"status": "done", ...}을 반환합니다.

이러한 변경으로 CLI와 웹 UI 모두 동일한 run_agent_turn 제너레이터를 사용하여 에이전트의 추론 과정을 제어할 수 있게 되었습니다. 또한, 도구 호출 시 _normalize_tool_call 함수를 통해 Pydantic 객체를 일반 딕셔너리로 변환하여 JSON 직렬화 문제를 방지했습니다.

# local_agent/agent.py (일부 발췌)

def run_agent_turn(history: History, client: OllamaClient, auto_approve: bool, max_tool_hops: int = 8):
    """Runs one user turn as a resumable generator.

    Yields `{"type": "message", "content": str}` for assistant text, and
    `{"type": "confirm", "tool_name": str, "args": dict}` when a dangerous tool
    call needs a decision — the caller must `.send(True|False|"always")` to
    resume. This lets both the blocking terminal CLI and the request/response
    web UI drive the exact same loop without either one being baked in.

    Returns (via StopIteration.value) the final auto_approve flag.
    """
    # ... (기존 로직에서 yield로 변경) ...

def drive_turn(gen, send_value=None):
    """Advances `gen` until it needs a confirmation decision or finishes.

    Returns either {"status": "confirm", "pending": {...}, "messages": [...]}
    or {"status": "done", "auto_approve": bool, "messages": [...]}.
    """
    messages = []
    try:
        item = gen.send(send_value)
        while True:
            if item["type"] == "message":
                messages.append(item["content"])
                item = gen.send(None)
            else:  # "confirm"
                return {"status": "confirm", "pending": item, "messages": messages}
    except StopIteration as stop:
        return {"status": "done", "auto_approve": stop.value, "messages": messages}

5. 기타 변경 사항

  • local_agent/cli.py: --session, --continue, --web, --port 옵션이 추가되었고, 세션 관리 로직이 통합되었습니다.
  • tests/test_agent_loop.py: 리팩토링된 run_agent_turn 및 drive_turn 함수를 테스트하는 새로운 테스트 케이스들이 추가되었습니다.

기술적 의사결정

1. run_agent_turn을 제너레이터로 변경한 이유

선택: run_agent_turn 함수를 동기 함수에서 yield를 사용하는 제너레이터로 변경했습니다.

이유:

  • 단일 로직 재사용: 에이전트의 핵심적인 대화 처리 로직이 터미널 CLI와 웹 UI에서 중복 구현되는 것을 방지하고 싶었습니다. 제너레이터를 사용하면, 대화의 중간 단계에서 제어 흐름을 외부로 넘기고, 외부에서 사용자 입력을 받아 다시 제너레이터를 재개하는 패턴을 구현할 수 있습니다. 이는 CLI에서는 input() 함수를 통해, 웹 UI에서는 API 호출을 통해 이루어집니다.
  • 유연성 및 재개 가능성: 이전에는 에이전트의 한 턴(turn)이 완료될 때까지 블로킹되었지만, 제너레이터는 중간에 멈추고 다시 시작할 수 있는 능력이 있습니다. 이는 특히 도구 호출 시 사용자 확인이 필요한 경우에 유용합니다. 사용자의 결정이 있을 때까지 에이전트의 실행을 일시 중지했다가, 결정을 받으면 다시 이어서 실행할 수 있습니다.
  • 명확한 상태 관리: 제너레이터의 상태는 내부적으로 관리되므로, 외부에서는 단순히 send() 메소드를 호출하여 제어 흐름을 이어갈 수 있습니다. 이는 복잡한 상태 관리 코드를 줄여줍니다.

대안:

  • 콜백 함수 사용: run_agent_turn 함수가 도구 호출 시 콜백 함수를 등록하도록 하는 방식도 고려할 수 있었습니다.
  • 이벤트 기반 아키텍처: 좀 더 복잡한 이벤트 버스를 사용하여 상태 변화를 관리하는 방식도 가능했습니다.

비교 및 장단점:

  • 제너레이터:
    • 장점: 코드 구조가 명확하고, Python의 내장 기능을 활용하여 간결하게 구현할 수 있습니다. CLI와 웹 UI 모두에서 동일한 로직을 공유하기에 최적입니다.
    • 단점: 제너레이터의 동작 방식을 이해하는 데 약간의 학습이 필요할 수 있습니다. 디버깅 시 스택 추적이 동기 함수와 다르게 보일 수 있습니다.
  • 콜백 함수:
    • 장점: 특정 시점에 특정 함수를 실행한다는 점이 직관적일 수 있습니다.
    • 단점: 여러 단계의 콜백이 중첩되면 '콜백 지옥(callback hell)'에 빠지기 쉬우며, 복잡한 상태 관리가 필요해집니다. run_agent_turn과 같이 순차적인 로직에는 제너레이터가 더 적합하다고 판단했습니다.
  • 이벤트 기반 아키텍처:
    • 장점: 매우 복잡하고 분산된 시스템에서 유용할 수 있습니다.
    • 단점: 이 프로젝트의 규모에는 과도한 복잡성을 초래할 수 있으며, 구현 및 유지보수 비용이 높습니다.

결론적으로, run_agent_turn을 제너레이터로 변경하는 것이 현재 프로젝트의 요구사항(단일 로직 재사용, 유연한 제어 흐름)에 가장 잘 부합하는 선택이라고 판단했습니다.

2. 파일 시스템 접근 도구 설계

선택: read_file과 write_file을 일반 Python 함수로 구현하고, @tool 데코레이터를 사용하여 에이전트가 호출할 수 있도록 등록했습니다. tool_defs 및 REGISTRY를 통해 도구 명세와 실제 구현을 연결했습니다.

이유:

  • 표준적인 도구 통합 방식: local-agent 프레임워크에서 도구를 등록하고 사용하는 기존 방식을 따르는 것이 일관성을 유지하는 데 좋습니다.
  • 명확한 역할 분담: read_file과 write_file 함수는 파일 시스템 접근이라는 명확한 역할만 수행하며, 에이전트의 복잡한 추론 로직과는 분리됩니다.
  • 안전성 고려: read_file에는 max_chars 옵션을 두어 과도한 데이터 로드를 방지하고, write_file에는 mode 옵션을 두어 의도치 않은 데이터 덮어쓰기를 제어할 수 있게 했습니다. write_file 함수는 requires_confirmation=False로 설정하여, 도구 자체는 실행에 대한 별도의 확인이 필요하지 않음을 명시했습니다. (에이전트의 전반적인 auto_approve 설정에 따라 결정됩니다.)

대안:

  • @tool 데코레이터에 직접 파일 경로를 인자로 전달: read_file이나 write_file 함수 자체에 파일 경로를 직접 인자로 넘기는 대신, @tool 데코레이터 자체에서 파일 경로를 처리하도록 구현할 수도 있었습니다.

비교 및 장단점:

  • 현재 방식:
    • 장점: 도구의 API(함수 시그니처)와 에이전트가 사용하는 도구 명세(tool_defs)가 일치하여 이해하기 쉽습니다. 파일 접근 로직이 별도의 함수로 캡슐화되어 있어 테스트 및 유지보수가 용이합니다.
    • 단점: 파일 경로와 같은 인자 처리를 함수 내부에서 해야 합니다.
  • 대안 방식:
    • 장점: 에이전트가 직접적으로 파일 경로를 인지하지 않아도 될 수 있습니다.
    • 단점: 도구 데코레이터가 파일 시스템 접근이라는 부가적인 역할을 담당하게 되어, 도구의 명확성이 떨어질 수 있습니다. 또한, 파일 경로 처리 로직이 여러 곳에 분산될 가능성이 있습니다.

현재 방식이 도구의 역할과 구현을 명확하게 분리하고, local-agent의 기존 도구 통합 메커니즘과도 잘 맞기 때문에 이 방식을 선택했습니다.

배운 점 및 개선점

배운 점

  • 제너레이터의 강력함: Python 제너레이터가 비동기 프로그래밍이나 복잡한 제어 흐름을 구현하는 데 얼마나 강력하고 유용한 도구인지 다시 한번 깨달았습니다. 코드 중복을 제거하고 유연성을 높이는 데 핵심적인 역할을 했습니다.
  • CLI와 웹 UI 로직 통합의 중요성: 단일 로직을 여러 인터페이스에서 공유할 수 있도록 설계하는 것이 장기적인 유지보수성과 개발 효율성에 얼마나 큰 영향을 미치는지를 경험했습니다.
  • 세션 관리의 필요성: 사용자 경험 측면에서 대화 기록을 유지하는 것이 얼마나 중요한지, 그리고 이를 구현하는 기본적인 방법(JSON 직렬화)을 익혔습니다.

개선점 및 다음 단계 계획

  • 보안 강화: 현재 read_file과 write_file은 임의의 경로에 접근할 수 있습니다. 이는 악의적인 사용자가 시스템 파일에 접근하거나 중요한 파일을 덮어쓰는 등의 보안 위험을 초래할 수 있습니다. 향후에는 파일 접근 경로를 제한하거나, 특정 디렉토리 내에서만 작동하도록 하는 등의 추가적인 보안 조치가 필요합니다.
  • 웹 UI 기능 확장: 현재 웹 UI는 매우 기본적인 형태입니다. 사용자에게 더 나은 경험을 제공하기 위해 실시간 업데이트, 로딩 인디케이터, 에러 메시지 표시 개선, 스타일링 개선 등이 필요합니다.
  • 도구 오류 처리 개선: run_agent_turn에서 도구 호출 실패 시 나오는 메시지가 좀 더 사용자 친화적이고 구체적이었으면 좋겠습니다. 예를 들어, 어떤 종류의 오류인지, 어떻게 해결할 수 있는지에 대한 힌트를 제공할 수 있습니다.
  • 세션 데이터 관리: 현재는 단순히 JSON 파일을 저장하는 방식이지만, 대화량이 많아질 경우 성능 문제가 발생할 수 있습니다. 향후에는 데이터베이스를 활용하거나, 압축 등의 방법을 사용하여 세션 데이터를 효율적으로 관리하는 방안을 고려할 수 있습니다.
  • 테스트 커버리지 확대: 새로운 기능들이 추가된 만큼, 각 기능별로 더욱 꼼꼼한 테스트 케이스를 작성하여 안정성을 높여야 합니다. 특히 파일 시스템 접근이나 웹 API 호출에 대한 테스트를 강화할 필요가 있습니다.

이번 커밋은 local-agent를 더욱 강력하고 유연한 도구로 만드는 중요한 발걸음이었습니다. 앞으로 이러한 기능들을 바탕으로 더욱 발전시켜 나갈 계획입니다.

참고 자료