Dodaj capability¶
Capability to jednostka, z której składa się agenta. To ona jest przełącznikiem pokazywanym przez Builder i to ją spec nazywa po id.
Celowo nie jest to „narzędzie”. Narzędzie to szczegół implementacyjny — „wyszukiwanie w wiedzy” to jedna decyzja osoby konfigurującej agenta, a to, czy wystawia ono dziś jedną funkcję, a jutro trzy, nie jest jej problemem. Capability obejmuje też rzeczy, które w ogóle nie są narzędziami: strażnika budżetu, bramkę approvalu, strategię kompaktowania. Jedno pojęcie pokrywa cały montaż zamiast dwóch, które niezgrabnie się nakładają.
Kod definiuje to, co istnieje; konfiguracja jedynie to komponuje
Nic, co wpisze operator, nie powoła do życia nowej capability, i to właśnie sprawia, że zbiór rzeczy, które agent może robić, da się przejrzeć.
Kształt¶
Jeden folder na capability pod backend/app/agents/capabilities/:
weather/
__init__.py registration — the id, the name the picker shows, the builder
_capability.py the AbstractCapability subclass
_toolset.py the tools, and the text the model reads before calling them
README.md why this exists and what it deliberately does not do
Układ jest egzekwowany, a jedna jego reguła jest cichą awarią
@register pojawia się w __init__.py i nigdzie indziej. Rejestracja w
podmodule odpala się tylko wtedy, gdy coś ten moduł importuje — i tak
właśnie capability znika z Buildera, przy wszystkich testach nadal na
zielono. To tests/test_capability_layout.py oblewa zamiast tego.
Ten układ nie jest sugestią — tests/test_capability_layout.py go egzekwuje.
Każda paczka ma _capability.py, a każda paczka oferująca własne narzędzia ma
_toolset.py. Capability bez narzędzi — clock, thinking — jest wymieniona w tym
teście wraz z powodem, zamiast nosić pusty moduł.
Narzędzia mieszkają osobno od klasy capability, bo nazwa i opis narzędzia są
promptem: model czyta je, zanim zdecyduje o wywołaniu, a autor agenta może
przepisać oba per agent. Zakopane w domknięciu get_toolset są znajdowalne tylko
przez tego, kto napisał klasę.
Przeczytaj clock/ jako najmniejszy kompletny przykład, a knowledge/ jako
przykład ze schematem konfiguracji, zasobami i scope'em.
1. Capability¶
_capability.py — dataclass rozszerzająca AbstractCapability, budująca swój
toolset leniwie:
"""Current weather for a place."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.tools import AgentDepsT
from pydantic_ai.toolsets import AbstractToolset, FunctionToolset
def _build_toolset(units: str) -> FunctionToolset[Any]:
async def current_weather(city: str) -> dict[str, str]:
"""Get the current weather for a city.
Use this when an answer depends on today's conditions rather than a
seasonal average.
"""
...
toolset: FunctionToolset[Any] = FunctionToolset()
toolset.add_function(current_weather, takes_ctx=False)
return toolset
@dataclass
class Weather(AbstractCapability[AgentDepsT]):
"""Gives an agent current conditions instead of a guess."""
units: str = "metric"
_toolset: AbstractToolset[Any] | None = field(
default=None, init=False, repr=False, compare=False
)
def get_toolset(self) -> AbstractToolset[Any]:
if self._toolset is None:
self._toolset = _build_toolset(self.units)
return self._toolset
Docstring narzędzia jest promptem. To on jest tym, co model czyta, decydując, czy
je wywołać, więc mówi kiedy tego użyć, a nie co funkcja robi — zobacz
~/.claude/standards/prompting.md.
2. Zarejestruj ją¶
__init__.py:
"""Weather capability — current conditions."""
from pydantic import BaseModel, Field
from app.agents.capabilities._registry import (
CapabilityBuildContext,
CapabilityToolInfo,
register,
)
from app.agents.capabilities.weather._capability import Weather
__all__ = ["Weather"]
class WeatherConfig(BaseModel):
units: str = Field(default="metric", pattern="^(metric|imperial)$")
@register(
id="weather",
name="Weather",
category="data",
description="Read current conditions for a place instead of assuming them.",
tools=(
CapabilityToolInfo(
id="current_weather",
description="Get the current weather for a city.",
),
),
config_schema=WeatherConfig,
scopes=("weather:read",),
)
def _build(ctx: CapabilityBuildContext) -> Weather | None:
"""Build the capability from its validated config."""
config = ctx.config if isinstance(ctx.config, WeatherConfig) else WeatherConfig()
return Weather(units=config.units)
idtrafia do każdego opublikowanego speca i jest tą jedną rzeczą, która nigdy nie może się zmienić. Nazwę zmieniaj dowolnie; id — nigdy.toolscelowo nie ma wartości domyślnej. Pominięcie tego argumentu toTypeError; capability naprawdę bez narzędzi mówitools=().idkażdego wpisu jest tym, na czym kluczują siętool_approvalitool_overridesspeca, a jegodescriptionpowinien być własnym podsumowaniem docstringa narzędzia — osoba wybierająca, co wymaga approvalu, i model wybierający, kiedy działać, powinni czytać ten sam tekst, a nie dwie rozjeżdżające się parafrazy.config_schemageneruje formularz w Builderze i jest walidowany przy publikacji, więc zła wartość oblewa, kiedy ktoś patrzy na formularz, a nie w środku runa.scopessą odmawiane na etapie budowania, gdy organizacja ich nie przyznała.side_effecting=Trueprzepuszcza narzędzia capability przez bramkę approvalu.- Zwrócenie
Noneznaczy „nie wnosi nic do tego agenta” i capability nie jest w ogóle podpinana.knowledgerobi tak, gdy nie związano żadnych kolekcji: narzędzie wyszukiwania, które zawsze zwraca pustkę, jest gorsze niż jego brak, bo model próbuje go dalej. ctx.resourcesniesie to, co rozwiązano z bazy dla tego runa — nazwy kolekcji, skille. Capability nigdy nie odpytuje o nie sama; model pyta, czego szukać, nigdy gdzie.
Niezadeklarowane narzędzie działa bez bramki i nic tego nie mówi
tools= jest tym, dla czego Builder oferuje approval per narzędzie, i tym,
na czym dopasowuje się bramka approvalu. Niebezpieczna połowa tej awarii jest
cicha: autor dodaje drugie narzędzie, mające skutki uboczne, zapomina je
zadeklarować, a ono działa bez nadzoru na zawsze.
tests/test_capability_registry.py porównuje zadeklarowaną listę z
narzędziami, które faktycznie dostaje model — i tylko to to wyłapuje.
Builder może zwrócić capability, której nie napisaliśmy. Jego sygnatura to
CapabilityBuildContext -> AbstractCapability[Any] | None, więc poprawnym
zwrotem jest wszystko, co dostarcza Pydantic AI — thinking/ rejestruje
pydantic_ai.capabilities.Thinking i w ogóle nie ma _capability.py. Opakowanie
jednej z ich capabilities, żeby uczynić ją „naszą”, dokłada tylko drugie miejsce,
w którym ustawia się tę samą wartość. Rejestr stempluje zwrócony obiekt id z
rejestru, a to właśnie na nim dopasowuje się bramka approvalu, więc obca
capability przybywa z tą samą tożsamością co lokalna.
Zawężanie przez isinstance, a nie przez rzutowanie, to sposób, w jaki robi to
każda wbudowana: ctx.config jest typowany jako model bazowy, bo rejestr nie
wie, jaki schemat zadeklarowała ta capability, a capability związana zupełnie bez
konfiguracji dostaje swoje wartości domyślne zamiast wysypki.
Potem dodaj moduł do load_builtins() w _registry.py
Moduł, którego nikt nie importuje, nie istnieje z punktu widzenia Buildera. To sprzężenie jest zamierzone — rejestracja jest importem, a nie skanowaniem.
3. Napisz README¶
Każdy folder capability ma swój. Powiedz, po co istnieje, czego celowo nie robi i jaką decyzję przyszły czytelnik inaczej by cofnął. To tutaj mieszka uzasadnienie, a nie w wiadomości commita.
4. Przetestuj to¶
app/agents/** jest na 100% pokrycia, egzekwowanym w CI
Nowa capability z nieprzetestowaną gałęzią oblewa build. Nie musisz
poszerzać dla niej bramki: obie listy w backend/pyproject.toml już niosą
glob app/agents/**, a
test_every_file_in_a_platform_package_is_gated istnieje po to, by tak
pozostało. Edytowanie tych list jest od nowej paczki platformowej, spoza
tych już objętych globem. Zobacz ## Testing w
CLAUDE.md, a tests/test_capability_registry.py — dla stylu.
Warto pokryć w szczególności:
- schemat konfiguracji odmawiający złej wartości, bo to jest bramka przy publikacji
- builder zwracający
None, gdy nie ma nic wnosić - odmowę na scope, jeśli capability jakiś deklaruje
- samo narzędzie, łącznie z tym, co robi, gdy to, co wywołuje, jest niedostępne
Gdzie się to pojawia¶
Nic więcej nie wymaga zmiany. GET /api/v1/agents/capabilities serwuje rejestr,
picker Buildera renderuje się z niego, a schema-form.tsx generuje formularz
konfiguracji z config_json_schema(). Capability dodana tutaj jest w produkcie
po następnym restarcie.
Dwie rzeczy, które czyta wygenerowany formularz, a które warto znać, pisząc schemat:
- Wartość domyślna pola jest rysowana jako jego wartość, a nie jako szary placeholder. Nic nie jest zapisywane, dopóki ktoś tego nie edytuje, więc pole nadal śledzi wartość domyślną, która później zmienia się w kodzie — ale to, co widzi człowiek, jest tym, co się stanie, jeśli zostawi je w spokoju. Daj każdemu opcjonalnemu polu sensowną wartość domyślną, a formularz jest wypełniony już na wejściu.
Literalrenderuje swoje surowe wartości, chyba że schemat mówi inaczej, a surowe wartości to format speca:clear_tool_resultsw liście rozwijanej jest wyborem, którego ktoś dokonuje, zgadując. Powiedz, co każda z nich robi, przezjson_schema_extra={"x-enum-labels": {value: "what it does"}}na polu — słowo kluczowe rozszerzenia, bo JSON Schema żadnego nie ma, trzymane przy definicji z tego samego powodu codescription.- Napis to jednoliniowe pole, chyba że schemat mówi inaczej, a prompt w takim
polu to pole, w którym nikt nie przeczyta, co edytuje.
json_schema_extra={"x-multiline": True}daje mu ten edytor Markdowna, który dostają własne instrukcje agenta — źródło albo podgląd, z odmową podpiętą do pola. Ten sam kształt rozszerzenia, ten sam powód.
Dodanie narzędzia do istniejącej capability¶
Zwykle właściwy ruch, gdy nowe zachowanie należy do decyzji, którą ktoś już podjął — drugi sposób czytania bazy wiedzy to nadal „wyszukiwanie w wiedzy”. Trzy kroki, a drugi jest tym zapominanym:
- Napisz funkcję w
_toolset.pyi dodaj ją do toolsetu. Jej docstring jest promptem; powiedz, kiedy po to sięgnąć, a nie co funkcja robi. - Zadeklaruj ją w krotce
tools=w__init__.py. Narzędzie, o którym rejestr nie wie, nie może zostać zatwierdzone, nie może zostać przemianowane per agent i nie pojawia się w Builderze — po prostu działa. - Sprawdź test rozjazdu.
tests/test_capability_registry.pybuduje każdą zarejestrowaną capability i porównuje zadeklarowaną listę z narzędziami, które faktycznie dostaje model. To on wyłapuje pominięcie kroku 2 i to on zgłasza dzień, w którym paczka z góry przemianuje któreś z narzędzi, które reeksportujemy — trzy narzędzia capabilityskillspochodzą zpydantic-ai-skills, więc ich nazwy należą do kogoś innego.
Narzędzie ze skutkami ubocznymi obok tylko-do-odczytu to sygnał
side_effecting jest per capability, więc ta capability jest teraz dwiema
decyzjami noszącymi jedną nazwę. Wolej drugą capability; approval per
narzędzie w specu pozwala autorowi agenta być surowszym niż domyślnie i nie
zastępuje zadeklarowania prawdy.
Dodanie narzędzia, którego nikt tutaj nie musi pisać¶
Jeśli narzędzie jest wywołaniem zewnętrznego API, które już publikuje serwer MCP, rozważ, czy w ogóle należy do kodu. Capability jest właściwa dla czegoś, co platforma musi zagwarantować; serwer utrzymywany przez dostawcę jest właściwy dla reszty. Zobacz MCP oraz Dodaj serwer do katalogu MCP.