Add a New Agent Kind¶
How to add a new conversational agent to the Backend API. A kind is one agent behavior — its prompt, tools and structured output — plugged into the generic agent framework. Concepts first: Agent Sessions.
Architecture overview¶
chat message
└─▶ chat lifecycle hook
└─▶ AgentSessionService (start / drain)
└─▶ transport (chat | sync)
└─▶ runner (one LLM turn)
└─▶ AgentSpec ◀── you implement this
Key locations
| Path | Purpose |
|---|---|
app/constants/agents.py |
Closed enums: family, kind, composed spec id, transport type, memory scope |
app/services/agents/base.py |
The seam: AgentSpec, AgentState, and every optional Supports* capability |
app/services/agents/registry.py |
family.kind → spec registry |
app/services/agents/implementations/kinds/ |
Concrete kinds (requirements_gathering/, assistant/) |
app/services/agents/implementations/transports/chat/bindings.py |
Chat context → kind binding |
app/services/agents/bootstrap.py |
ensure_registered() — the single registration entrypoint |
The framework layer imports no implementations; bootstrap is the only exempt module, and a purity test fails the build if that rule is broken.
Step 1: Declare the ids¶
Add the family (if new), the kind, and the composed spec id. All three are StrEnum members over plain varchar columns — no migration is needed.
# app/constants/agents.py
class AgentFamily(StrEnum):
REQUIREMENTS_GATHERING = "requirements_gathering"
ASSISTANT = "assistant"
class AgentKind(StrEnum):
SET_DESIGN_BRIEF = "set_design_brief"
ORG_ASSISTANT = "org_assistant"
MY_NEW_KIND = "my_new_kind" # <-- new
class AgentSpecId(StrEnum):
MY_NEW_KIND = f"{AgentFamily.REQUIREMENTS_GATHERING}.{AgentKind.MY_NEW_KIND}"
Step 2: Write the spec¶
For a gathering kind, subclass GatheringSpec and declare the contract — the base supplies the state lifecycle, the grounded system prompt, the two-turn finish handshake and the scope default.
# app/services/agents/implementations/kinds/requirements_gathering/my_kind.py
class MyBrief(BaseModel):
palette: str
reference_images: list[uuid.UUID] = Field(default_factory=list)
class MyKindSpec(GatheringSpec, MediaReferenceMixin):
id = AgentSpecId.MY_NEW_KIND
subject = "the colour brief for a shooting"
greeting = "Tell me the palette you have in mind."
output_model = MyBrief
required = ("palette",)
uuid_fields = ("reference_images",)
# MediaReferenceMixin wiring (only if the kind gathers uploaded images)
reference_image_field = "reference_images"
reference_images_tool = "set_reference_images"
def gathering_tools(self) -> list[Tool]:
return [Tool(set_palette), Tool(set_reference_images)]
register(MyKindSpec())
Declarations are validated at class-definition time — a missing attribute, an output field that does not exist, or a media tool name that is not in tools() raises at import, not on the first live turn.
Tools mutate state, they do not touch the database
A tool receives the accumulator through the run context and records validated values on it. Anything that must hit the database is buffered on the state and applied by the service after the turn succeeds — a failed turn then persists nothing.
Step 3: Opt into the capabilities you need¶
Each capability is a small protocol satisfied by a plain method. A kind that does not declare one simply does not get that behavior.
| Capability | Gives you |
|---|---|
SupportsMediaNote / SupportsReferenceMedia |
Attached-image handling and completion re-attachment (both come with MediaReferenceMixin) |
SupportsScopeGate |
Custom topicality classification |
SupportsOpenScope |
Skip topicality, keep a safety gate (open-topic kinds) |
SupportsAwaitingAnswer |
Safety-only gating while the user is answering your question |
SupportsTurnStart |
A hook before every LLM run |
SupportsMemory |
Declare the durable-memory allowlist and its coercion |
SupportsRuntimeBinding |
Receive per-turn runtime (organization id, memory service, subject access) |
SupportsDynamicGreeting |
Open from what is already known instead of re-asking |
SupportsCompletionMessage |
A deterministic closing message |
SupportsMessageOptions / SupportsStructuredInputs |
Offer pressable options and apply a press deterministically |
SupportsMemoryEffects / SupportsOptionLockEffects |
Buffer writes on the state, applied after a successful turn |
Step 4: Register the module¶
Registration is explicit — importing the package is what populates the registry:
# app/services/agents/implementations/kinds/requirements_gathering/__init__.py
from . import my_kind # noqa: F401 (registration side effect)
ensure_registered() is called from the API lifespan, the Celery tasks and the test fixtures. It is idempotent.
Step 5: Bind a chat context (optional)¶
To run the kind in a chat, add one line to the binding map:
# app/services/agents/implementations/transports/chat/bindings.py
CONTEXT_AGENT_BINDINGS = {
ChatContextType.SET_DESIGN: AgentSpecId.SET_DESIGN_BRIEF,
ChatContextType.ORG_ASSISTANT: AgentSpecId.ORG_ASSISTANT,
ChatContextType.MY_RESOURCE: AgentSpecId.MY_NEW_KIND, # <-- new
}
If the resource itself is new, add the chat context type and its validation entry first — see Chat.
Step 6: Test offline¶
Everything runs without network or credentials — inject a fake model into the turn:
turn = await run_turn(
MyKindSpec(),
state,
history,
[AgentInput(text="warm neutrals")],
model=TestModel(), # main model
scope_model=TestModel(), # the guardrail classifier
)
Cover at minimum: the happy gathering path to completion, the two-turn finish handshake, a rejected off-topic message, and — if the kind uses them — media recording and option locking.
Rules that are enforced, not suggested¶
- No
fastapiimports underapp/services/agents/— transports raise agent-domain exceptions and the route maps them to HTTP. - A transport never calls a service (e.g. the chat service) — that would reintroduce an import cycle and surrender the turn's transaction ownership. Depend on repositories.
- Media is stored as ids, never URLs. Image URLs are stripped from the message history before it is persisted.
- The framework never imports an implementation — a purity test enforces it.