Claude Code, Cursor, Aider 같은 코딩 에이전트의 작동 원리를 살펴보고 싶다면 파이썬 파일 하나로 핵심 구조를 익힐 수 있다. 이 예제는 requests만 필수 의존성으로 사용해 코드 읽기와 수정, 테스트 실행이 가능한 에이전트를 만든다.

전체 코드는 Fazel94/agent_500_lines 저장소의 agent.py에서 볼 수 있다. 예제 에이전트에는 파일 읽기·쓰기와 검색, 셸 명령 실행, 사용자에게 질문하기, 웹 검색·페이지 읽기 등 아홉 가지 도구가 들어 있다. OpenAI 호환 API를 지원하므로 환경 변수로 API 제공자와 모델을 지정할 수 있다.

작동의 중심은 모델에 도구 목록을 보내고, 모델이 요청한 도구를 실행해 결과를 다시 전달하는 반복문이다. 모델이 도구 호출 없이 답하면 반복을 끝낸다. 파일 경로가 프로젝트 바깥으로 벗어나지 않게 검사하고, 파일을 쓰거나 셸 명령을 실행하기 전에는 사용자 확인을 받는다. --yes를 사용하면 이 확인 절차를 건너뛴다.

파일 수정 도구 edit_file은 바꿀 문자열이 정확히 한 번 나타날 때만 교체한다. 문자열을 찾지 못하거나 여러 번 나타나면 오류를 모델에 돌려보내 다시 시도하게 한다. 도구 실행 중 생긴 오류도 대화에 메시지로 전달되므로 모델이 이를 읽고 다음 방법을 시도할 수 있다.

다만 이 코드는 교육용 예제로, 제작자는 프로덕션에서 사용하지 말라고 명시한다. bash는 모델이 요청하는 명령을 현재 사용자 권한으로 실행하며, 안전장치는 확인 질문뿐이다. --yes는 그 질문도 끈다. 프로젝트 내부 경로 검사는 파일 도구에만 적용되고 bash에는 적용되지 않는다. 웹 도구도 기본적으로 확인 대상이 아니며, URL에 데이터가 포함될 수 있으므로 유출을 우려하는 코드에 사용할 때는 web_search와 fetch_url도 확인 대상에 넣으라고 안내한다. 테스트도 제공되지 않아 컨테이너나 커밋해 둔 프로젝트 복사본에서 시도할 것을 권한다.

이 예제에는 스트리밍, 재시도, 저장된 세션, 계획 모드, pytest를 항상 허용하는 권한 설정이 없다. 후속 글에서는 스트리밍과 재시도, 세션 저장, 긴 대화 요약, Ctrl-C 처리를 121줄의 코드로 추가한다고 소개한다: What makes a coding agent usable.

프로젝트 폴더로 이동한 뒤 agent.py를 실행한다. OpenAI 호환 API 주소, API 키, 모델은 각각 AGENT_BASE_URL, AGENT_API_KEY, AGENT_MODEL 환경 변수로 지정할 수 있다. 기본 API 주소는 https://api.openai.com/v1, 기본 모델은 gpt-4o-mini이며, API 키는 AGENT_API_KEY가 비어 있으면 OPENAI_API_KEY를 사용한다. 로컬 모델 서버에서는 키를 비워 둘 수 있다.

부록: 원문 예시

text
def chat(messages: list, tools: list) -> dict:     try:         r = requests.post(             f"{BASE_URL}/chat/completions",             timeout=120,             headers={"Authorization":                      f"Bearer {API_KEY}"},             json={"model": MODEL,                   "messages": messages,                   "tools": tools,                   "tool_choice": "auto"})         r.raise_for_status()         return (r.json()["choices"][0]                 ["message"])     except requests.HTTPError:         raise RuntimeError(...)     except requests.RequestException as e:         raise RuntimeError(...) 
text
@tool("List files recursively, optionally "       "filtered by a glob on the filename.",       path="directory to list (default '.')",       pattern="glob matched against the file "               "name, e.g. '*.py'") def list_files(path: str = ".",                pattern: str = "*"):     base = safe_path(path)     if not base.is_dir():         return "error: not a directory"     hits = [str(f.relative_to(ROOT))             for f in walk(base)             if fnmatch.fnmatch(f.name,                                pattern)]     out = "\n".join(hits[:500])     if len(hits) > 500:         out += (f"\n...and {len(hits) - 500}"                 f" more")     return out or "(no matches)" 
text
@tool("Create or overwrite a file with exact "       "content.",       path="file path relative to the "            "workspace root",       content="full file content") def write_file(path: str, content: str):     f = safe_path(path)     f.parent.mkdir(parents=True,                    exist_ok=True)     f.write_text(content)     return (f"wrote {len(content.encode())} "             f"bytes to {path}") 
text
@tool("Run a shell command in the workspace "       "root. 60s timeout.",       command="shell command") def bash(command: str):     try:         p = subprocess.run(             command, shell=True, cwd=ROOT,             text=True, capture_output=True,             timeout=60)     except subprocess.TimeoutExpired:         return "error: timed out after 60s"     return (f"{p.stdout}{p.stderr}\n"             f"[exit {p.returncode}]")