Bir kodlama ajanı repo'nda bir dosyayı değiştirdiğinde o dosyayı modelin etrafındaki sıradan bir kod açıyor, model sadece hangi dosyanın açılacağını yazıyor. nanoagent'ı bunu açıkta görebilmek için yazdım. Birkaç yüz satır Python'dan oluşan bu küçük ajan dosya okuyup değiştirebiliyor, shell'de komut çalıştırabiliyor ve arada framework olmadan Anthropic API'siyle konuşuyor. Kodu github.com/AhmetErenLapanta/nanoagent adresinde, README'ye göre de içinde üç parça var: bir döngü, bir tool tablosu ve bir olay akışı.
Denemek için Python 3.12, uv ve bir Anthropic API anahtarı yeter:
git clone https://github.com/AhmetErenLapanta/nanoagent.git
cd nanoagent
uv sync
cp .env.example .env
Kopyaladığın .env dosyasına ANTHROPIC_API_KEY değerini yaz. bash tool'u modelin istediği her komutu sana sormadan çalıştırıyor, o yüzden nanoagent'ı repo'nun içinde boş bir klasörde başlat:
mkdir deneme && cd deneme
uv run nanoagent
Göreli yollar bu klasöre düşer, ama tool'lar mutlak yol da alıyor ve bash için her klasör bir cd uzaklıkta.
Model diske hiç dokunmuyor
Dil modeli metin alıp metin veren bir fonksiyon ve tek başına bir dosyayı bile açamaz. Ürettiği metnin bir kısmı ise bir tool isteği. Model read_file tool'unu belli bir dosya yoluyla çağırmak istediğini yazıyor, dosyayı açıp sonucu konuşmaya ekleyen benim kodum. Bir tool istendiğinde API metin üretmeyi bırakıp cevabın stop_reason alanına tool_use yazar. Kod ne yapacağına karar verene kadar başka hiçbir şey olmuyor ve ajan dediğimiz şey tam olarak o arada çalışan kod.
Döngü tek bir if'e bakıyor
Senin her mesajın yeni bir tur demek. loop.py içindeki run() bir turu yürütüyor ve döngüsü bu:
for _ in range(max_iterations):
yield Thinking()
response = client.messages.create(
model=model,
system=system,
max_tokens=4096,
tools=schemas,
messages=messages,
)
events, blocks, calls = _process_response(response)
yield from events
messages.append({"role": "assistant", "content": blocks})
if response.stop_reason != "tool_use":
yield TurnDone()
return
results = [dispatch(call) for call in calls]
yield from results
messages.append(
{"role": "user", "content": [_to_result_block(r) for r in results]}
)
Her iteration'da (döngünün her dönüşü) model o ana kadarki konuşmayla ve altı tool'un şemasıyla çağrılıyor, cevabı da konuşmaya ekleniyor. Ajanın bütün kontrol akışı bu if satırında. Model tool istemediyse tur orada biter, istediyse her çağrının sonucu bir sonraki mesaj olarak eklenip yeni bir iteration başlar.
Modelin bir cevabında hem metin hem birden fazla tool isteği olabilir, _process_response bunları ayırır ve döngü o iteration'daki bütün tool çağrılarını sırayla çalıştırır. Modelin tek hafızası da sonuna mesaj eklenen messages listesi, nanoagent eski mesajlara dokunmuyor ve onları hiç özetlemiyor. Liste sınırsız büyür, kısa bir işte bunu fark etmezsin ama bütün bir uygulama yazdırmaya kalktığında ilk çarpacağın duvar bu.
Döngü ekrana tek satır yazmıyor. Olan biten her şeyi altı olay tipinden biriyle dışarı verir: Thinking, AssistantText, ToolCall, ToolResult, TurnDone ve AgentError. Bence tasarımın en iyi tarafı bu ayrım, çünkü önüne yeni bir arayüz koyarken döngüye dokunmana gerek kalmaz. Terminaldeki ekranı render.py çiziyor: Thinking gelince bir spinner dönüyor ve yanında marinating ya da overthinking gibi rastgele bir kelime çıkıyor, tool çağrıları ayrı bir stille, modelin cevabı bir panelde görünüyor.
Hangi tool'un çağrılacağını model seçiyor, döngü sadece devam etmeye ya da durmaya karar veriyor. Geri kalan davranış cli.py içindeki system prompt'ta düz İngilizce cümlelerle yazılı ve bir ajanı ayarlamak çoğunlukla bu düzyazıyı yazmak demek:
- Çok adımlı bir işte önce 1-3 satırlık bir plan.
- Her tool çağrısını anlatmadan, sonunda tek bir özet.
- Var olan bir dosyada
edit_file.write_filesadece yeni bir dosya ya da tamamen değişen bir dosya için. - Birbirine bağlı shell komutları tek komutta,
cd foo && pytestgibi. rm -rfya dagit reset --hardgibi yıkıcı bir komuttan önce ne yapılacağını ve nedenini söyleyen tek bir satır.
Tool'ların yarısı sadece okuyor
nanoagent'ın altı tool'u var: read_file, write_file, edit_file, ls, grep_files ve bash. Altısı da kodun içinde sabit yazılı ve nanoagent dışarıdan hazır tool getiren bir MCP server'a bağlanamaz. Her tool'un argümanlarını anlatan bir Pydantic sınıfı ve işi yapan bir fonksiyonu var, en küçüğü read_file:
class ReadFileInput(BaseModel):
path: str = Field(
description="Path to the file to read (relative to cwd or absolute)."
)
def _read_file(args: ReadFileInput) -> str:
return Path(args.path).read_text()
Sınıf JSON Schema'ya (API'nin anladığı argüman tarifi) çevrilip gönderiliyor ve model tool'u nasıl çağıracağını oradan öğreniyor. Yedinci bir tool için sınıfla fonksiyonun yanına bir açıklama da yazman gerekir, üçü birlikte TOOLS sözlüğüne bir _Tool kaydı olarak girer:
@dataclass(frozen=True, slots=True)
class _Tool:
description: str
input_model: type[BaseModel]
runner: Callable[[Any], str]
Açıklama modele tool'un ne zaman seçileceğini de söyler. read_file açıklaması mesela dosya içinde arama için grep_files'ı, klasörü listelemek için ls'i öneriyor, yani okuma işi bu üç tool'un arasında paylaşılmış. README'deki örnek oturumda projeyi özetlemesi istenen ajan önce src/nanoagent/loop.py dosyasını read_file ile açıyor. grep_files bir Python regex'i ve *.py gibi bir glob filtresiyle arıyor, en fazla 100 eşleşme döndürüyor, gerisi için sadece bir sayı. Model 101. eşleşmeyi hiç görmüyor, aramayı daraltmak ona kalıyor.
Düzenlemenin bir şartı var
edit_file dosyadaki bir parçayı yenisiyle değiştirir ve aradığı parçanın dosyada tam bir kez geçmesi şart. İki kez geçerse tool old_str appears 2 times diye başlayan bir hata fırlatıp tek geçen bir parça istiyor. dispatch bu hatayı yakalayıp is_error işaretli bir sonuca çeviriyor, model de bir sonraki iteration'da daha uzun ve tek geçen bir parçayla tekrar deneyebilir. Bilinmeyen bir tool adı ya da şemaya uymayan bir argüman da aynı yoldan modele gider. Bütün dosyayı baştan yazan write_file üst klasörü kendisi oluşturmuyor, klasör yoksa onun hatası da aynı yolda. Program çökmüyor, hata konuşmaya sıradan bir mesaj olarak giriyor ve bir ajanı sağlam hissettiren şeyin çoğu buradan geliyor.
Her bash çağrısı sıfırdan başlıyor
bash tool'u komutu /bin/sh ile ve 30 saniyelik zaman aşımıyla çalıştırıyor, modele dönen şey çıkış kodu, stdout ve stderr. Süre dolarsa o ana kadar biriken çıktı exit timeout (30s) satırıyla gider.
Her çağrı yeni bir shell açtığı için bir önceki çağrıdaki cd bir sonrakinde yok, prompt'taki && kuralı da bu yüzden var. Kodda komutu durduran bir şey yok, _bash gelen her komutu çalıştırır. Prompt'taki yıkıcı komut kuralı ise modele rm -rf'ten önce tek satır söyletiyor, döngü de senden onay beklemeden devam ediyor.
Ne zaman duruyor
Bir tur iki yerde biter. Model tool istemeyi bıraktığında TurnDone geliyor, 25 iteration'da hala bitmediyse döngü max_iterations (25) exceeded hatasıyla çıkıyor, kafası karışmış bir model sonsuza kadar dönemesin diye. Sen de durdurabilirsin: Ctrl-C'ye bastığında CLI turdan önce not ettiği uzunluğa geri döner, beklenmeyen bir hatada da aynısını yapar.
snapshot = len(messages)
messages.append({"role": "user", "content": line})
try:
for event in run(messages, client=client, model=MODEL, system=SYSTEM):
renderer.event(event)
except KeyboardInterrupt:
del messages[snapshot:]
renderer.interrupted()
del messages[snapshot:] yarım kalan turu konuşmadan siliyor. O turda write_file, edit_file ya da bash diskte ne değiştirdiyse yerinde duruyor, ama modelin artık bundan haberi yok.
Bir ajanla ilk uygulamanı adım adım çıkarmak istersen /learn sayfasından başla.