최첨단 코딩 에이전트는 수많은 파일 도구를 대체하는 셸 워크플로를 직접 짜낼 수 있다. Bash가 주요 실행 인터페이스로 자리잡으면 무엇이 달라지는지 짚어본다.
코딩 에이전트를 써본 적 있다면, 기존에 read, edit, search 같은 전용 도구가 맡던 작업을 에이전트가 Bash로 처리하기 시작했다는 걸 눈치챘을 것이다. 모델이 Bash에서 초인적인 수준에 도달하면서, 전용 도구로는 부족하다고 판단하게 된 것이다.
나는 몇 달마다 에이전트 하네스(agent harness)를 처음부터 다시 만들어, 무엇이 바뀌었고 무엇을 덜어낼 수 있는지 직접 확인한다. 이번에는 bash와 media 뷰어를 제외한 모든 모델 대면 도구를 제거했다.
결과는 평소 사용에서 이미 확인했던 것과 일치했다. 작업 완료율은 비슷한 범위를 유지했고, 에이전트는 도구 경계 없이 복잡한 작업과 검증을 더 매끄럽게 처리했다.
최첨단 모델은 Bash에서 초인적인 수준에 이르렀다. 좁은 의미에서 하는 말이다. 대부분의 개발자가 조합하고 검증하는 데 훨씬 긴 시간이 필요한 일회용 커맨드라인 프로그램을, 이 모델들은 몇 초 만에 만들어낸다.
git, rg, jq, Python, 임시 파일, 프로세스 치환(process substitution), 테스트 러너 같은 개별 요소들은 대부분 알고 있을 것이다. 하지만 시간 압박 속에서 이것들을 조합해 40줄짜리 일회용 워크플로를 작성하는 건 전혀 다른 일이다. 대부분은 단계마다 문법과 상태를 확인하며 인터랙티브하게 문제를 풀어나간다.
코딩 에이전트는 POSIX 유틸리티, 프로그래밍 언어, 빌드 시스템, 공개 소스 코드에서 학습한 패턴으로 이런 워크플로를 조합한다. 물론 실수도 한다. 차이점은 한 번에 얼마나 많은 정확한 오케스트레이션을 시도할 수 있느냐다.
Bash는 여기서 라우팅 레이어 역할을 한다. 실제 무거운 작업은 Python, Git, SQLite, 컴파일러, 또는 프로젝트별 CLI에서 처리될 수 있다. 셸 인터페이스 하나로 에이전트는 이 모든 것을 조합할 수 있고, 런타임 설계자가 유용한 모든 작업을 미리 예측할 필요도 없다.
아래 예시들은 내 개인 에이전트 트레이스에서 가져온 것으로, 단순화한 내용이다.
프로젝트에서 구현 파일, 호출부, 테스트 전체에 걸쳐 변수 이름을 바꾸고 싶다고 해보자.
python3 <<'PY'
from pathlib import Path
changes = {
"src/services/billing.py": [
(
"def charge_customer(customer_id: str, amount: int):",
"def charge_customer(customer_id: str, amount_cents: int):",
1,
),
(
"client.charge(token, amount)",
"client.charge(token, amount_cents)",
1,
),
],
"src/api/checkout.py": [
(
"charge_customer(order.customer_id, order.total)",
"charge_customer(order.customer_id, order.total_cents)",
1,
),
],
"src/jobs/retry_payment.py": [
(
"charge_customer(job.customer_id, job.amount)",
"charge_customer(job.customer_id, job.amount_cents)",
1,
),
],
"tests/test_billing.py": [
(
"charge_customer(customer.id, 1200)",
"charge_customer(customer.id, amount_cents=1200)",
2,
),
],
}
prepared = {}
for filename, replacements in changes.items():
path = Path(filename)
text = path.read_text()
for old, new, expected in replacements:
found = text.count(old)
if found != expected:
raise SystemExit(
f"{filename}: expected {expected} occurrences of {old!r}, found {found}"
)
text = text.replace(old, new)
prepared[path] = text
for path, text in prepared.items():
path.write_text(text)
PY
ruff format src/services/billing.py src/api/checkout.py src/jobs/retry_payment.py tests/test_billing.py
ruff check src/services/billing.py src/api/checkout.py src/jobs/retry_payment.py tests/test_billing.py
pyright src/services/billing.py src/api/checkout.py src/jobs/retry_payment.py
pytest -q tests/test_billing.py tests/test_checkout.py tests/test_retry_payment.py
git diff --check
git diff --stat -- src/services/billing.py src/api/checkout.py src/jobs/retry_payment.py tests/test_billing.py스크립트는 먼저 기존 코드 조각의 개수를 센다. 파일에서 일치하는 수가 맞지 않으면, 아무것도 쓰기 전에 종료한다. 그다음 네 파일의 이름 변경을 한 번에 적용해, 절반만 업데이트된 상태에 빠지는 일이 없도록 한다. 쓰기가 끝나면 포맷, 린트, 타입 체크, 관련 테스트를 실행하고 간결한 diff를 출력한다. 실패한 검사 결과는 워킹 트리에 남아, 다음 턴에서 확인하고 수정할 수 있다. 이 '완료 전 검증' 사이클이 내부 루프(inner loop)다.
간헐적으로만 실패하는 테스트가 있다. 현재 체크아웃을 유지하면서, 해당 플레이크(flake)가 처음 발생한 커밋을 찾고 싶다.
set -euo pipefail
test -n "${GOOD_REV:-}"
repo=$(pwd -P)
scratch=$(mktemp -d)
cleanup() {
git -C "$repo" worktree remove --force "$scratch/repo" >/dev/null 2>&1 || true
rm -rf "$scratch"
}
trap cleanup EXIT
git worktree add --detach "$scratch/repo" HEAD >/dev/null
cd "$scratch/repo"
git bisect start HEAD "$GOOD_REV"
git bisect run bash -c '
failures=0
for seed in 11 29 47 71 101; do
if ! TEST_SEED=$seed pytest -q tests/test_checkout_race.py; then
failures=$((failures + 1))
fi
done
test "$failures" -lt 3
' >"$scratch/bisect.log" 2>&1
bad=$(git rev-parse HEAD)
git bisect reset >/dev/null
printf 'first bad commit: %s\n' "$bad"
git show --no-ext-diff --format=fuller --stat "$bad"
git diff --no-ext-diff "$bad^" "$bad" -- src/checkout tests/test_checkout_race.py | sed -n '1,220p'
printf '\nlast bisect events:\n'
tail -n 40 "$scratch/bisect.log"별도 워크트리를 사용하면 로컬 파일이 그대로 유지된다. 각 커밋은 다섯 개의 시드로 분류되고, 실패 횟수가 세 번 미만인 경우에만 정상(good)으로 판정해 운 나쁜 플레이크 하나가 이분 탐색을 망치는 걸 방지한다. 완료되면 스크립트는 첫 번째 문제 커밋, 의심 변경 사항의 축약된 diff, 로그 말미를 출력한다. 프롬프트에 모든 테스트 실행 결과를 쏟아내는 게 아니라, 진단 결과만 돌려주는 것이다.
모델에 로드하기엔 너무 큰 압축 프로덕션 로그가 있다. 실패 엔드포인트, 에러 클래스, P95 지연 시간을 알고 싶다.
python3 <<'PY'
import gzip
import glob
import json
import sqlite3
import tempfile
from pathlib import Path
def records(pattern):
for name in sorted(glob.glob(pattern)):
path = Path(name)
opener = gzip.open if path.suffix == ".gz" else open
with opener(path, "rt", encoding="utf-8") as stream:
for line in stream:
try:
yield json.loads(line)
except json.JSONDecodeError:
continue
with tempfile.TemporaryDirectory() as directory:
db = sqlite3.connect(Path(directory) / "logs.sqlite")
db.executescript("""
PRAGMA journal_mode = OFF;
PRAGMA synchronous = OFF;
PRAGMA temp_store = FILE;
CREATE TABLE access (
req_id TEXT PRIMARY KEY,
path TEXT NOT NULL,
status INTEGER NOT NULL,
latency_ms REAL NOT NULL
);
CREATE TABLE errors (
req_id TEXT PRIMARY KEY,
kind TEXT NOT NULL
);
""")
access_batch = []
for row in records("logs/access.jsonl*"):
access_batch.append((
row["req_id"],
row["path"],
int(row["status"]),
float(row["latency_ms"]),
))
if len(access_batch) == 10_000:
db.executemany("INSERT OR REPLACE INTO access VALUES (?, ?, ?, ?)", access_batch)
access_batch.clear()
db.executemany("INSERT OR REPLACE INTO access VALUES (?, ?, ?, ?)", access_batch)
error_batch = []
for row in records("logs/error.jsonl*"):
error_batch.append((row["req_id"], row["error_class"]))
if len(error_batch) == 10_000:
db.executemany("INSERT OR REPLACE INTO errors VALUES (?, ?)", error_batch)
error_batch.clear()
db.executemany("INSERT OR REPLACE INTO errors VALUES (?, ?)", error_batch)
db.executescript("""
CREATE INDEX access_status_req ON access(status, req_id);
CREATE INDEX errors_req ON errors(req_id);
""")
rows = db.execute("""
WITH matched AS (
SELECT a.path, a.latency_ms, e.kind
FROM access AS a
JOIN errors AS e USING (req_id)
WHERE a.status >= 500
),
ranked AS (
SELECT
path,
kind,
latency_ms,
row_number() OVER (
PARTITION BY path ORDER BY latency_ms
) AS position,
count(*) OVER (
PARTITION BY path
) AS samples
FROM matched
)
SELECT
path,
count(*) AS failures,
round(avg(latency_ms), 1) AS average_ms,
max(
CASE
WHEN position = CAST((samples * 95 + 99) / 100 AS INTEGER)
THEN latency_ms
END
) AS p95_ms,
group_concat(DISTINCT kind) AS error_classes
FROM ranked
GROUP BY path
ORDER BY failures DESC
LIMIT 5
""").fetchall()
print(json.dumps([
{
"endpoint": path,
"failures": failures,
"average_ms": average,
"p95_ms": p95,
"error_classes": kinds.split(","),
}
for path, failures, average, p95, kinds in rows
], indent=2))
PYPython은 로테이션된 파일을 배치로 읽고, 프롬프트에 덤프하는 일은 절대 없다. SQLite가 조인, P95 순위 정렬, 에러 클래스 집계를 처리한다. 돌아오는 건 엔드포인트, 실패 횟수, 평균 지연 시간, P95, 에러 클래스를 담은 JSON 레코드 다섯 개다. 컨텍스트 오프로딩(Context offloading)은 중간 데이터를 환경에 두고, 요약본만 모델에 전달한다.
몇 달 전, 나는 코딩 에이전트가 견고한 아토믹 도구로 시작해야 하며 cat, sed, echo 같은 셸 명령은 피해야 한다고 주장했다. 부주의한 명령 하나가 컨텍스트 윈도를 범람시키거나, 불투명한 에러를 반환하거나, 잘못된 인용부로 편집을 망칠 수 있기 때문이다. 텍스트 출력으로는 비전 모델에 시각 정보를 전달할 수도 없다.
이 한계들은 계측되지 않은 셸에서는 여전히 적용된다. 하지만 파운데이션 모델은 이제 소형 Python 패처, Git 명령, 인용된 히어독(heredoc), 집중된 테스트 조합에 훨씬 능숙해졌다. 하네스도 아토믹 도구를 유용하게 만들었던 보호 장치들을 흡수한다.
한 가지 예외 — 멀티모달 입력:
텍스트 출력으로는 스크린샷이나 이미지를 보이게 할 수 없다. 셸 명령으로 페이지를 렌더링하거나, 차트를 캡처하거나, 비디오 프레임을 저장할 수는 있지만, 픽셀 데이터는 여전히 멀티모달 채널을 통해 모델에 전달돼야 한다.
셸 중심 구성과, 파일 읽기·쓰기·편집·검색 전용 도구를 따로 노출하는 구성을 동일한 코딩 작업 세트와 동일한 조건에서 비교했다. 셸 중심 구성이 동등하거나 더 나은 성능을 보였다.
이것이 하네스 설계에 적용된 쓴 교훈(Bitter Lesson)이다. 연산과 함께 확장되는 범용 방법이 손으로 만든 지름길을 이긴다. Bash가 바로 그 범용 연산 레이어다.
좋은 에이전트라면 특정 기능에 더 나은 인터페이스를 제공하는 경우 그에 맞는 도구를 갖춰야 한다. 도구를 쓸 때 모델이 더 잘할 수 있다면, 그 도구를 써야 한다. 브라우저 제어가 좋은 예다.
브라우저 도구는 하나의 호출로 탐색, 클릭, 입력, 페이지 안정화 대기, 스크린샷 반환까지 처리한다. Bash로 이를 하려면 CLI를 실행하고, 캡처된 결과물을 찾고, 두 번째 단계에서 view_media를 통해 전달해야 한다. 서비스 통합도 비슷한 방식으로 이점을 얻거나, mcp-cli 같은 도구를 써서 셸에 머물면서 스키마가 프롬프트에 포함되는 일을 없앨 수 있다.
시도해 볼 것들:
목표는 더 작은 인터페이스로 더 넓은 행동 공간을 확보하는 것이다.