Free lesson · GenAI Application Engineering
Build a Pydantic AI regeneration agent with typed tools
You will build a RegenerationAgent class in agents/regeneration_agent.py using pydantic_ai.Agent. The agent registers three typed tools via @agent.tool: adjust_temperature(ctx, delta: float) -> TemperatureResult, switch_model(ctx, target_model: str) -> ModelSwitchResult, and modify_system_prompt(ctx, instruction: str) -> PromptResult. The agent's result_type is RegenerationPlan with fields: new_temperature, new_model, prompt_additions, reasoning. A FastAPI endpoint POST /api/v1/regenerate accepts a RegenerateRequest, runs agent.run() with conversation context and feedback, then executes the plan by calling LiteLLM completion with adjusted parameters. The endpoint returns RegenerateResponse including the new text and variation metadata. Dependencies are injected via AgentDeps Pydantic model.
Course: Full-Stack GenAI Applications · Chapter 5 · Feedback, Regeneration & Edit APIs
Free to read — no subscription required.
Introduction
When a user thumbs-down a generated response, the simplest recovery is to re-run the same prompt and hope for better output—but the same model, temperature, and system prompt will produce statistically similar text, and the silent retry teaches users that feedback does nothing. Teams that ship LLM products quickly find that meaningful regeneration requires reasoning about why the response failed and adjusting generation parameters accordingly. By the end of this lesson you'll be able to build a Pydantic AI agent that reads a structured feedback signal, selects typed corrective tools (temperature delta, model switch, system-prompt append), and dispatches the regeneration through LiteLLM—producing auditable adjustments instead of indistinguishable retries.
Key Terminology
- FeedbackSignal: an enum that classifies a negative rating's reason (e.g.
TOO_GENERIC,WRONG_TONE,TOO_SHORT) and drives which corrective tool the agent selects. - RegenerationDeps: the Pydantic AI dependency container that injects per-request state—database session, conversation history, feedback signal, mutable
current_params—into every tool call. - Typed tool: a function registered on the agent with
@agent.toolwhose parameters are validated by Pydantic; the regeneration agent exposes three (adjust_temperature,switch_model,modify_system_prompt) as the only legal corrective actions.
Concepts
Why an Agent Instead of a Function
A conventional approach would encode regeneration logic in a chain of if/elif blocks: if the rating is negative and the comment mentions "too short," raise max_tokens; if the comment mentions "wrong tone," swap the system prompt. This works for three or four rules but collapses under combinatorial pressure. What happens when a message is simultaneously "too short" and "wrong tone" and the user wants a different model? You need a reasoning layer that can compose multiple adjustments in a single pass, which is exactly what a Pydantic AI agent provides. The agent receives a natural-language planning prompt enriched with structured feedback data, and it calls one or more typed tools—adjust_temperature, switch_model, modify_system_prompt—in whatever combination the situation requires. Each tool call is validated by Pydantic models, logged for analytics, and reversible for A/B comparison workflows described later in this chapter.
Code Walkthrough
Building on the agent-versus-function reasoning above, the next step is to wire that reasoning layer into code: a dependency container that carries feedback context into every tool call, the three typed tools the agent composes, and the end-to-end flow that turns a thumbs-down into a parameter-adjusted regeneration.
The Dependency Injection Context
Before the agent can make decisions, it needs access to the feedback record, the original message, and the set of available models. Pydantic AI's deps_type mechanism injects this context into every tool call without global state or closures. The following code defines the RegenerationDeps dataclass that encapsulates all request-scoped data the agent needs. The feedback_signal field carries the structured feedback collected by the PostgreSQL-backed storage models described in another goal—a FeedbackSignal enum value—alongside the free-text feedback_comment. The available_models list is populated from the application's configuration and determines which providers the switch_model tool can select. The current_params dictionary holds the mutable generation parameters that tools will modify in place before the final LiteLLM call.
Code snippet python
1from __future__ import annotations 2 3import enum 4from dataclasses import dataclass, field 5from sqlalchemy.ext.asyncio import AsyncSession 6 7class FeedbackSignal(enum.Enum): 8 TOO_GENERIC = "too_generic" 9 FACTUALLY_WRONG = "factually_wrong" 10 WRONG_TONE = "wrong_tone" 11 TOO_LONG = "too_long" 12 TOO_SHORT = "too_short" 13 OTHER = "other" 14 15@dataclass 16class RegenerationDeps: 17 db_session: AsyncSession 18 message_id: str 19 conversation_history: list[dict[str, str]] 20 feedback_signal: FeedbackSignal 21 feedback_comment: str 22 available_models: list[str] = field( 23 default_factory=lambda: [ 24 "gpt-4o", 25 "gemini/gemini-2.5-flash", 26 "anthropic/claude-sonnet-4-20250514", 27 ] 28 ) 29 current_params: dict = field( 30 default_factory=lambda: { 31 "temperature": 0.7, 32 "model": "gpt-4o", 33 "system_prompt": "", 34 "max_tokens": 2048, 35 } 36 ) 37 applied_adjustments: list[str] = field(default_factory=list)
- Lines 1-4: Import annotations for forward references, the
enummodule for the feedback signal enum,dataclassandfieldfor the dependency container, andAsyncSessionfor the database handle. - Lines 6-12: Define
FeedbackSignalas an enum with six categories that map to corrective strategies. These values are derived from the feedback classification logic in another goal's structured feedback collection layer. - Lines 14-15: Declare
RegenerationDepsas a dataclass. Pydantic AI requires thedeps_typeto be a class it can thread throughRunContext. - Lines 16-18: Store the database session, the originating message ID, and the full conversation history as a list of role/content dictionaries matching the format expected by LiteLLM.
- Lines 19-20: Carry the classified feedback signal and the raw comment text so tools can reason about both the category and the user's specific language.
- Lines 21-27: Default the available models to the three providers used in the concurrent A/B comparison endpoint—GPT-4o, Gemini 2.5 Flash, and Claude Sonnet. This list is the source of truth for the
switch_modeltool. - Lines 28-34: Initialize
current_paramswith sensible defaults. Tools mutate this dictionary, and the final regeneration call reads from it. Theapplied_adjustmentslist logs every tool action for auditability and for feeding into the materialized views that track regeneration success rates.
Registering Typed Tools on the Agent
With the dependency context defined, the next step is building the agent and registering the three corrective tools. Each tool receives a RunContext[RegenerationDeps] as its first parameter, giving it typed access to the mutable current_params dictionary. The adjust_temperature tool clamps the new value between 0.0 and 2.0 to prevent invalid API calls. The switch_model tool validates that the requested model exists in available_models and raises a ValueError if it does not—Pydantic AI surfaces this as a tool error the agent can recover from. The modify_system_prompt tool appends behavioral instructions rather than replacing the entire prompt, preserving the original context while steering the regeneration. All three tools return a confirmation string that the agent uses to plan subsequent actions.
Code snippet python
1from pydantic_ai import Agent, RunContext 2 3regeneration_agent = Agent( 4 "gpt-4o", 5 deps_type=RegenerationDeps, 6 system_prompt=( 7 "You are a regeneration controller. Analyze the feedback signal " 8 "and comment, then call one or more tools to adjust generation " 9 "parameters before the message is regenerated. Always explain " 10 "your reasoning before calling tools." 11 ), 12) 13 14@regeneration_agent.tool 15async def adjust_temperature( 16 ctx: RunContext[RegenerationDeps], delta: float 17) -> str: 18 current = ctx.deps.current_params["temperature"] 19 new_temp = max(0.0, min(2.0, current + delta)) 20 ctx.deps.current_params["temperature"] = new_temp 21 adjustment = f"Temperature: {current:.2f} -> {new_temp:.2f}" 22 ctx.deps.applied_adjustments.append(adjustment) 23 return adjustment 24 25@regeneration_agent.tool 26async def switch_model( 27 ctx: RunContext[RegenerationDeps], model_name: str 28) -> str: 29 if model_name not in ctx.deps.available_models: 30 raise ValueError( 31 f"{model_name} not in {ctx.deps.available_models}" 32 ) 33 old = ctx.deps.current_params["model"] 34 ctx.deps.current_params["model"] = model_name 35 adjustment = f"Model: {old} -> {model_name}" 36 ctx.deps.applied_adjustments.append(adjustment) 37 return adjustment 38 39@regeneration_agent.tool 40async def modify_system_prompt( 41 ctx: RunContext[RegenerationDeps], instruction: str 42) -> str: 43 existing = ctx.deps.current_params["system_prompt"] 44 separator = "\n\n" if existing else "" 45 ctx.deps.current_params["system_prompt"] = ( 46 f"{existing}{separator}{instruction}" 47 ) 48 adjustment = f"System prompt appended: {instruction[:80]}" 49 ctx.deps.applied_adjustments.append(adjustment) 50 return adjustment
- Lines 1: Import the
Agentclass and the genericRunContexttype from thepydantic_aipackage. - Lines 3-11: Instantiate the agent with
gpt-4oas the planning model (not the regeneration model—those are separate concerns). Thedeps_typeparameter tells the framework to expectRegenerationDepsin every run. The system prompt focuses the agent exclusively on parameter adjustment, not on generating the final user-facing response. - Lines 13-23: Register
adjust_temperatureas a typed tool. Thedeltaparameter is a float the agent decides at runtime—positive for more creativity, negative for more determinism. Line 19 clamps the result to the valid API range. The adjustment string is appended toapplied_adjustmentsfor downstream analytics. - Lines 25-37: Register
switch_modelwith a str parameter. The guard on line 29 raises ValueError if the agent hallucinates a model name not in the roster. Pydantic AI catches this, reports the error back to the agent, and the agent can retry with a valid name. This fail-safe prevents silent misrouting of requests to nonexistent providers. - Lines 39-50: Register
modify_system_prompt. Instead of overwriting, it appends to the existing system prompt with a double-newline separator, preserving any base instructions the application sets. The logged adjustment truncates to 80 characters to keep analytics storage manageable.
Agent Execution and Regeneration Flow
The following diagram shows how a feedback event flows through the agent into a regenerated response. The agent sits between the feedback ingestion layer from another goal and the LiteLLM dispatch layer used by the concurrent A/B comparison in another goal. This positioning allows the same regeneration logic to feed into both single-model regeneration and multi-model comparison workflows.
Code snippet mermaid
Loading diagram...
- Line 1: Declares this as a Mermaid sequence diagram, which visualizes interactions between components over time.
- Lines 2-7: Define the six participants (actors) in the system:
User,FastAPI Endpoint(aliased as API),Feedback Storebacked by PostgreSQL (aliased as FB),RegenerationAgent(aliased as Agent),Typed Tools(aliased as Tools), andLiteLLM Router(aliased as LLM). - Line 9: The user initiates the flow by sending a POST request to the
/messages/{id}/regenerateendpoint, triggering message regeneration for a specific message ID. - Lines 10-11: The API queries the PostgreSQL feedback store to load the feedback record and original message, which responds back with the feedback record and full conversation history.
- Line 12: The API invokes the RegenerationAgent by calling agent.run(), passing the prompt and a RegenerationDeps dependency object that carries context needed for regeneration.
- Lines 13-14: The agent calls the
adjust_temperaturetool with a delta of-0.3, which lowers the LLM sampling temperature from0.70to0.40to produce a more deterministic/precise response. - Lines 15-16: The agent calls the
modify_system_prompttool to append additional instructions ("Be more precise...") to the system prompt, refining the regeneration behavior based on feedback. - Line 17: The agent returns its result back to the API, including the current_params dictionary modified by the tool calls (updated temperature, system prompt, etc.).
- Line 18: The API sends the modified parameters to the LiteLLM Router via litellm.acompletion(), which handles model routing and executes the actual LLM completion call asynchronously.
- Line 19: The LiteLLM Router returns the regenerated response back to the API.
- Line 20: The API persists the new message to the PostgreSQL feedback store, linking it to the adjustments (temperature change, prompt modification) that were applied.
- Line 21: The API returns the regenerated message along with adjustment metadata (what parameters were changed and why) back to the user.
The sequence makes three architectural decisions explicit. First, the agent does not call LiteLLM itself—it only adjusts parameters. This separation of concerns means the same current_params dictionary can be passed to the single-model litellm.acompletion call or fanned out to the concurrent multi-model comparison endpoint from another goal. Second, the adjustment metadata is persisted alongside the new message, enabling the materialized views from another goal to compute regeneration success rates partitioned by adjustment type. Third, the conversation history loaded from PostgreSQL follows the branching model from another goal: if the user edited a message before requesting regeneration, the history reflects the pruned branch, not the stale original.
Wiring the Agent into the Endpoint
To plug the agent into a FastAPI route, load the original Message and its Feedback row from PostgreSQL, classify the comment to a FeedbackSignal (keyword matching is enough to demonstrate the typed-tool architecture; an embedding-based classifier slots in later), reconstruct the active branch's conversation history, and build a RegenerationDeps seeded with the original message's system_prompt and model_used. Calling await regeneration_agent.run(...) with a prompt that names the signal, the user comment, and the current model lets the agent mutate deps.current_params through its typed tools. Then await litellm.acompletion(**deps.current_params, messages=[...]) produces the regenerated text; persist the new Message with regeneration_of=message_id and adjustments_applied=deps.applied_adjustments, and return both the new content and the adjustment list so the UI can show "Regenerated with lower temperature and modified instructions" instead of an opaque retry.
You'll know it works when a thumbs-down on a too-generic answer triggers a regeneration whose adjustments payload includes at least one tool invocation (e.g. Temperature: 0.70 -> 0.40) and the new message body differs meaningfully from the original.
Do's and Don'ts
Do's
- ✓Do declare
RegenerationDepsas thedeps_typeon the agent and acceptRunContext[RegenerationDeps]as the first parameter of every tool — this gives each tool typed, request-scoped access tocurrent_paramsandapplied_adjustmentswithout closures or global state, so parameter mutations stay isolated to a single regeneration request and remain fully auditable. - ✓Do clamp the result inside
adjust_temperaturewithmax(0.0, min(2.0, current + delta))before writing back tocurrent_params["temperature"]— the agent supplies an unconstrained float delta, and a value outside [0.0, 2.0] causes an invalid LiteLLM API call that surfaces as an unrecoverable dispatch error rather than a typed tool error the agent can reason about and correct. - ✓Do append behavioral instructions in
modify_system_promptrather than replacingcurrent_params["system_prompt"]— overwriting the original system prompt discards the context the first response was built on; appending a targeted instruction (e.g., a tone or factual-accuracy directive) steers the regeneration without invalidating the original framing.
Don'ts
- ✗Don't skip the
available_modelsguard inswitch_modeland write an arbitrary string directly intocurrent_params["model"]— without validating against the known provider list, an unrecognized or malformed model name (e.g., missing thegemini/prefix) silently reaches the LiteLLM dispatch call and raises an unrecoverable provider error instead of a typedValueErrorthe agent can catch and retry with a valid alternative. - ✗Don't regenerate a thumbs-down response by re-dispatching the original
current_paramswithout any tool invocation — the same temperature, model, and system prompt produce statistically similar output, and a silent retry with no parameter change teaches users that submitting feedback does nothing, undermining the entire feedback-driven regeneration loop. - ✗Don't mutate
current_paramsdirectly in application code outside the registered tool functions — changes made outsideadjust_temperature,switch_model, ormodify_system_promptbypass theapplied_adjustments.append(...)call in each tool, breaking the audit trail that records which parameters were adjusted and by how much for downstream regeneration success tracking.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.
From · cancel anytime · Already a subscriber? Sign in →
More free lessons in Full-Stack GenAI Applications
- Ch 3Build a context window composer with token budgets
- Ch 3Implement Anthropic prompt caching with cache_control markers
- Ch 4Build a code validator with Gemini ToolCodeExecution
- Ch 5Build a Pydantic AI regeneration agent with typed toolsYou are here
- Ch 6Build a document chunking pipeline (recursive, semantic, token-aware)
- Ch 7Build a vision analysis API with GPT-4o + Gemini concurrently
- Ch 7Build a Gemini grounded-generation endpoint with Google Search