3. Tool katmanı¶
Üç sorumluluk, üç dosya — ve onları ayrı tutmak buradaki asıl tasarım kararı:
| Soru | Nerede cevaplanıyor |
|---|---|
| Hangi tool'lar var, kim görebilir? | decorator.py, registry.py |
| Her provider nasıl söylenmesini istiyor? | export.py |
| Bir tool çalıştığında ne oluyor? | executor.py |
Bunları tek sınıfta toplamak alışılmış kestirme yol, ve tool yetkilendirmesinin neden bu kadar sık ajan döngüsünün içinde bittiğinin sebebi — orada koşu başlatmadan test edilemez.
Burada ne yaptık
@tool dekoratörü (imzadan JSON Schema), allowlist'li ToolRegistry, üç
provider için dışa aktarım, beş kapılı ToolExecutor, idempotency
imzaları, beş tool'luk gerçekçi bir örnek ve seçim doğruluğunu ölçen bir
golden set.
Şema imzadan geliyor¶
Fonksiyonu ve JSON Schema'sını ayrı yazmak, ikisinin ayrışmasını garanti eder. Biri bir parametreyi yeniden adlandırır, şema hâlâ eski adı ilan eder, model artık bağlanmayan argümanlar göndermeye devam eder. Hiçbir şey hata vermez; tool sadece varsayılanları alır.
@tool şemayı inspect.signature'dan türetiyor, yani bu hata sınıfı yapısal
olarak imkânsız.
from datetime import date
from typing import Annotated, Literal
from pydantic import Field
from aimai_kit.tools import tool
@tool
def find_orders(
customer_id: Annotated[str, Field(description="Customer id, e.g. c-100.")],
status: Literal["open", "shipped", "cancelled", "any"] = "any",
since: date | None = None,
limit: int = 20,
) -> list[dict]:
"""List a customer's orders, most recent first.
Use this when you need a LIST of orders. To fetch ONE order whose id you
already know, use get_order instead.
Read-only.
"""
...
Üretilen şema:
{
"type": "object",
"properties": {
"customer_id": {"type": "string", "description": "Customer id, e.g. c-100."},
"status": {"type": "string", "enum": ["open", "shipped", "cancelled", "any"]},
"since": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"limit": {"type": "integer"}
},
"required": ["customer_id", "status", "since", "limit"],
"additionalProperties": false
}
Desteklenen tipler bilerek kısıtlı: str, int, float, bool, date,
Literal ve bunların listeleri. Desteklenmeyen bir annotation import anında
hata veriyor, provider'ın çalışma anında reddedeceği bir şema üretmek yerine.
Bu tekniğin sınırı değil, tasarım tercihi: derin iç içe argüman nesnesi
gereken bir tool neredeyse her zaman iki tool'dur.
Boş docstring hata. Açıklama, modelin bir tool'u ne zaman çağıracağına karar vermek için elindeki tek şey.
Yan etki bayrakları şemada değil, spec'te¶
side_effect ve requires_approval tool'un özellikleri, argümanları değil.
Şemaya koymak, modelin kendi çağrısında requires_approval=false ayarlamasına
izin vermek demek — ki bu bir onay kapısı değildir.
@tool(side_effect=True, requires_approval=True)
def cancel_order(ctx: CallContext, order_id: str, reason: str) -> dict:
"""Cancel an order. THIS CHANGES DATA and requires human approval."""
...
spec = cancel_order.tool_spec
assert spec.requires_approval is True
assert "requires_approval" not in spec.parameters["properties"] # şemada yok
Allowlist ilanı filtreliyor¶
registry.visible(allowlist) neyin ilan edildiğini filtreliyor, sadece
neyin çalışmasına izin verildiğini değil.
Kullanıcının kullanamayacağı bir tool yine de modele ilan edilirse, model eninde sonunda onu çağırır, executor reddeder ve önlenebilir bir reddediş için bir adım harcanır. Daha kötüsü, reddediş mesajı modele o tool'un var olduğunu öğretir.
registry.names() # ['build_report', 'cancel_order', 'find_orders', ...]
[t.name for t in registry.visible(["find_orders", "get_order"])]
# ['find_orders', 'get_order'] — model diğerlerini hiç görmüyor
Bu aynı zamanda "yirmi tool'um olunca ne olacak" sorusunun da cevabı: yirmi tane göndermiyorsun. Bu isteğin kullanmasına izin verilen alt kümeyi gönderiyorsun.
visible() ada göre sıralıyor. Süs değil — tool ilanları cache'lenmiş
prompt önekinin içinde duruyor ve küme sıralaması her süreç yeniden
başladığında farklı byte üretirdi.
Üç dışa aktarım, tek şema¶
Providerlar iki şeyde anlaşamıyor, başka hiçbir şeyde:
| Provider | Şekil | Şema anahtarı |
|---|---|---|
| OpenAI Responses | düz | parameters |
| OpenAI Chat | function altında iç içe |
parameters |
| Anthropic | düz | input_schema |
from aimai_kit.tools import export_for
export_for("openai", registry.visible())[0]
# {'type': 'function', 'name': 'find_orders', 'description': '...',
# 'parameters': {...}}
export_for("anthropic", registry.visible())[0]
# {'name': 'find_orders', 'description': '...', 'input_schema': {...}}
Bu tam olarak, soyutlanmadığında her çağrı yerine kopyalanan türden bir fark.
Tek iç temsil, üç ince dışa aktarıcı — ve export_for adaptör katmanının
üstünde provider kimliğine bakan tek yer, o da bir isme bakıyor, bir tipe
değil.
Beş kapı¶
Her kapı farklı bir soruya cevap veriyor ve modelin üzerine iş yapabileceği bir mesaj üretiyor, asla bir stack trace değil:
| Kapı | Kod | Retry? | Mesaj |
|---|---|---|---|
| Tool var mı? | no_such_tool |
evet | mevcutları listeler |
| İzin var mı? | not_allowed |
hayır | tool'un adını vermez |
| Geçerli JSON mu? | bad_json |
evet | ayrıştırmanın nerede koptuğunu söyler |
| Şemaya uyuyor mu? | bad_args |
evet | hatalı alanı adlandırır |
| Onay gerekiyor mu? | needs_approval |
hayır | döngüyü temiz durdurur |
from aimai_kit.tools import CallContext, ToolExecutor
executor = ToolExecutor(registry)
ctx = CallContext(user_id="u-1", tenant_id="t-1")
executor.call("get_ordr", "{}", ctx).content
# "There is no tool named 'get_ordr'. Available tools: build_report,
# cancel_order, find_orders, get_customer, get_order. Pick one of those or
# answer without a tool."
executor.call("cancel_order", '{"order_id":"1"}', ctx, allowlist=["get_order"]).content
# "You are not permitted to use that tool in this context. Continue with the
# tools you have been given." <- 'cancel_order' adı geçmiyor
executor.call("get_order", '{"wrong": 1}', ctx).content
# "The arguments for 'get_order' do not match its schema. order_id: Field
# required. Fix those fields and call the tool again."
- ve 2. kapı arasındaki asimetri bilinçli. 1. kapı mevcut tool'ları listeler,
çünkü
fetch_customeryerineget_customerçağıran bir model kendini bir sonraki turda düzeltir. 2. kapı listelemez, çünkü yetkisiz bir çağırana hangi tool'ların var olduğunu söylemek bir ifşadır — ve bir tool'un var olduğunu öğrenen model onu denemeye devam eder.
retryable, ok ile aynı şey değil. Şema hatası retry edilebilir: model
kendi argümanlarını düzeltebilir. İzin hatası edilemez: tekrar denemek hiçbir
şeyi değiştirmez ve döngü adım harcamak yerine durmalıdır.
Kapılardan sonra¶
Sunucu bağlamı çağrıdan enjekte ediliyor. user_id ve tenant_id oturumdan
geliyor ve şemadan çıkarılıyor:
@tool
def get_order(ctx: CallContext, order_id: str) -> dict:
"""Fetch a single order by its id."""
# ctx.tenant_id sorguya girer; model onu göremez, gönderemez
...
# Model kiracı göndermeye çalışsa bile şema fazladan alanı reddeder.
executor.call("get_order", '{"order_id":"1", "tenant_id":"t-evil"}', ctx)
Timeout bir worker thread ile uygulanıyor. Asılı kalan bir tool ajanı
askıya almıyor; döngünün üzerine düşünebileceği bir timeout sonucu üretiyor.
Thread çalışmaya devam ediyor, çünkü Python bir thread'i öldüremez — sert
iptal gereken bir tool alt sürece taşınmalı.
İki thread havuzu var, bir tane değil. call_many fan-out havuzuna gönderiyor
ve o çağrıların her biri kendi timeout'unu uygulamak için tekrar gönderiyor.
Tek havuz paylaşılırsa dış görevler bütün worker'ları doldurur ve iç görevler
arkalarında kuyruğa girer — yani timeout'lar hiç başlamamış işler için tetiklenir.
Bu hatayı sonuç sırasını doğrulayan bir test yakaladı.
Kırpma duyuruluyor. 200 KB JSON dönen bir tool hata vermez — sessizce pencereyi yer ve sonraki turlar görünür bir sebep olmadan bozulur.
result = executor.call("build_report", "{}", ctx)
print(result.content[-120:])
# ...[truncated: 194201 more characters were omitted. Narrow the query or
# request a specific section.]
raw context'e girmiyor. İz kaydına gidiyor, böylece hata ayıklama tam
yükü görürken model yalnızca content'i görüyor.
Paralel okuma, seri yazma¶
Salt okunur çağrılar tanımı gereği bağımsız, o yüzden arka arkaya beklemek güvenlik kazancı olmadan zaman kaybı.
Yan etkili çağrılar modelin ilan ettiği sırayla seri çalışıyor, çünkü birbirlerine bağımlı olabilirler ("siparişi iptal et, sonra müşteriye haber ver") ve kısmi bir başarısızlık bilinen bir sırada çok daha kolay yorumlanır.
Bir çağrının patlaması diğerlerini asla kaybettirmiyor:
results = executor.call_many([
("get_order", '{"order_id": "1"}'),
("get_order", "{broken"),
("get_order", '{"order_id": "3"}'),
], ctx)
[r.ok for r in results] # [True, False, True]
[r.error_code for r in results] # [None, 'bad_json', None]
Modele sormadan idempotency¶
Çağrı imzası tool adı + normalize argümanlar, yani {"a":1,"b":2} ile
{"b":2,"a":1} aynı çağrı olarak tanınıyor.
from aimai_kit.tools.idempotency import call_signature
call_signature("charge", {"a": 1, "b": 2}) == call_signature("charge", {"b": 2, "a": 1})
# True
Anahtar modelden istenmiyor. İdempotency anahtarını modelden istemek, garantinin ancak model kararlı bir anahtar göndermeyi hatırladığı sıklıkta geçerli olması demektir — ki bu bir garanti değildir.
Yan etkili sonuçlar imzayla saklanıyor, böylece devam eden bir koşu kartı iki kez çekmiyor. Sınır açıkça yazılı: süreç, tool çalıştıktan sonra ve sonuç yazılmadan önce ölebilir. Bu pencereyi daraltır; kapatan tek şey tool'un kendisinin idempotent olmasıdır.
Salt okunur çağrılar bilinçli olarak cache'lenmiyor — bir okumayı cache'lemek, çağrılar arasında değişen veriyi gizlerdi.
Açıklamaların değeri, ölçülmüş¶
Örnek tool'ların her açıklaması üç şey taşıyor, ve insanların atladığı üçüncüsü:
- tool ne yapar,
- ne zaman kullanılmaz, komşu tool'a yönlendirerek,
- yan etkisi var mı.
scripts/tool_description_experiment.py aynı golden set'i aynı registry'nin
üç varyantına karşı koşuyor — şemalar özdeş, yalnızca açıklama metni farklı:
| Varyant | Seçim doğruluğu | Yasak tool oranı |
|---|---|---|
| tam | %90,9 | %0,0 |
| sınır cümleleri kaldırılmış | %90,9 | %0,0 |
| yalnızca ilk cümle | %90,9 | %4,5 |
Beklenen sonuç çıkmadı, ve raporlanmaya değer olan da bu: bu seçiciyle açıklama kalitesi seçim doğruluğunu hiç oynatmadı. Güvenlik sayısını oynattı. Açıklamaları tek cümleye indirmek ajanın doğru tool'u seçme olasılığını aynı bıraktı ve yıkıcı olana uzanma olasılığını ölçülebilir şekilde artırdı.
Bu, panoya hangi metriğin konacağı hakkında bir şey söylüyor. Seçim doğruluğu bir kalite sinyali; yasak tool oranı bir güvenlik sinyali — ve önce o bozuldu.
Checklist¶
Bu katman proda çıkmadan önce:
- Şema imzadan geliyor. Tek doğruluk kaynağı; elle yazılmış bir şema ilk refactor'da fonksiyondan ayrışır.
- Yan etkiler spec'te bildirilmiş — idempotent mi, yıkıcı mı, onay ister mi — açıklamadan çıkarılmıyor.
- Allowlist bildirimi filtreliyor. Çağıranın kullanamayacağı bir tool modele hiç gösterilmiyor, dolayısıyla çağrılıp sonra reddedilemiyor.
-
not_allowedtool'un adını vermiyor. Bir tool'un var olduğunu doğrulayan hata mesajı bir enumeration oracle'dır. - Server context çağrıdan enjekte ediliyor.
user_idvetenant_idsenin oturumundan geliyor, asla modelin verdiği bir argümandan değil. Bütün mesele bu. - Her reddin ayrı bir kodu ve modelin üzerine iş yapabileceği bir mesajı var — hangi alan, ne bekleniyordu.
- Timeout'lar bir worker thread'de koşuyor. Takılan bir tool döngüyü takmamalı.
- Kırpma sonuçta bildiriliyor. 200 KB dönen bir tool, sessizce bir önek dönmek yerine kırpıldığını söylemeli.
- Yazmalar sıralı, okumalar paralel. Eşzamanlılık tool'un özelliği, executor'ın değil.
- Idempotency anahtarı çağrı imzasından türetiliyor, modelden istenmiyor. Modelden anahtar istemek, retry ettiğini fark etmesine güvenmek demek.
- Açıklamadaki sınır cümleleri duruyor. Tool golden set'inde ölçüldü: açıklamayı ilk cümlesine indirmek seçim doğruluğunu %90.9'da bıraktı, yasak çağrı oranını %0.0'dan %4.5'e taşıdı. Açıklamalar bir güvenlik kontrolü ve doğruluk metriği bunu göstermiyor.
Gerisi — tool'lar, budget'lar, çıkışın kendisi — ve PR template'ine yapıştırılacak hâli: proda çıkmadan checklist.