Chapter 05 — Context Providers
Why this chapter
You want your agent to know who it’s talking to without hard-coding “the user is Alice” into the system prompt. That doesn’t scale past a demo — the moment you have more than one user, string-formatting a prompt per request turns into ad-hoc glue code scattered across every agent. A ContextProvider gives you a clean hook instead: run some code before every LLM call, add instructions (or messages, or tools) to the context, and let the framework wire it in. One provider per concern, composed in a list, reused across every agent that needs it.
This is exactly the primitive the capstone’s specialist agents run on. Every one of the six agents (product discovery, orders, pricing, reviews, inventory, support) is built with a context_providers=[...] argument that injects the logged-in user’s profile, recent orders, and long-term memories before the LLM ever sees the request — see agents/python/shared/context_providers.py for the real implementation.
Prerequisites
- Completed Chapter 04 — Sessions
- Repo-root
.envwith working LLM credentials (OPENAI_API_KEY, or theAZURE_OPENAI_*set)
The concept
Python: subclass agent_framework.ContextProvider and override before_run(*, agent, session, context, state). Call context.extend_instructions("source-id", "...") to append to the system prompt for that run only, and optionally stash structured data in the state dict so your tools (Chapter 02’s pattern) can read it too. Register the provider via Agent(..., context_providers=[...]).
.NET: subclass Microsoft.Agents.AI.AIContextProvider and override the protected ProvideAIContextAsync(InvokingContext, CancellationToken). Return an AIContext { Instructions = "..." }. Register via ChatClientAgentOptions.AIContextProviders.
Both fire on every agent.run(...) / agent.RunAsync(...) — before the request reaches the LLM. The provider is free to read from a database, call an API, check a feature flag, whatever the current request needs. It’s the same shape as ASP.NET middleware or an Express interceptor, just scoped to “the next LLM call” instead of “the next HTTP request.”
%%{init: {'theme':'base', 'themeVariables': {
'primaryColor': '#2563eb','primaryTextColor': '#ffffff','primaryBorderColor': '#1e40af',
'lineColor': '#64748b','secondaryColor': '#f59e0b','tertiaryColor': '#10b981',
'background': 'transparent'}}}%%
flowchart LR
accTitle: The concept
classDef core fill:#2563eb,stroke:#1e40af,color:#ffffff
classDef external fill:#f59e0b,stroke:#b45309,color:#000000
classDef infra fill:#64748b,stroke:#334155,color:#ffffff
classDef success fill:#10b981,stroke:#047857,color:#ffffff
request([Agent.run request])
provider[[ContextProvider.before_run]]
db[(User profile / orders / memories)]
agent[Agent]
llm[(LLM)]
answer([Personalized answer])
request --> provider
provider -- "reads current user" --> db
provider -- "extend_instructions(...)" --> agent
agent -- "prompt + injected context" --> llm
llm -- "final text" --> agent
agent --> answer
class provider core
class db infra
class agent core
class llm external
class answer success
The provider never talks to the LLM directly — it only shapes what the agent sends on the next call. The LLM sees a single, already-composed system prompt; it has no idea a provider ran.
Python
Run from the repo root using the shared tutorials/ uv project (one uv sync covers every chapter):
uv sync --project tutorials
uv run --project tutorials python tutorials/05-context-providers/python/main.py
# Uses the default user (Alice). Pass email / name / tier to swap:
uv run --project tutorials python tutorials/05-context-providers/python/main.py bob@example.com Bob gold
The chapter’s UserProfileProvider in python/main.py:
class UserProfileProvider(ContextProvider):
"""Injects the current user's profile as additional instructions for each run."""
def __init__(self, *, email: str, name: str, loyalty_tier: str = "silver") -> None:
super().__init__(source_id="user-profile")
self.email = email
self.name = name
self.loyalty_tier = loyalty_tier
async def before_run(
self,
*,
agent: Any,
session: Any,
context: Any,
state: dict[str, Any],
) -> None:
context.extend_instructions(
"user-profile",
f"Current user: {self.name} ({self.email}). Loyalty tier: {self.loyalty_tier}.",
)
state["user"] = {"email": self.email, "name": self.name, "loyalty_tier": self.loyalty_tier}
build_agent() wires it in with context_providers=[provider], and main() reads email/name/tier from sys.argv so you can run the same script for different users without touching code. main.py also supports LLM_PROVIDER=replay (a canned, no-network chat client the tests use for CI) alongside openai and azure — see _default_client() for the full provider switch.
The source_id argument to both super().__init__(...) and extend_instructions(...) lets MAF dedupe and debug which provider injected what when several are chained together (as the capstone does — see below).
.NET
cd tutorials/05-context-providers/dotnet
dotnet run
The equivalent provider in dotnet/Program.cs:
public sealed class UserProfileProvider : AIContextProvider
{
public string Email { get; }
public string Name { get; }
public string LoyaltyTier { get; }
public UserProfileProvider(string email, string name, string loyaltyTier = "silver")
{
Email = email;
Name = name;
LoyaltyTier = loyaltyTier;
}
protected override ValueTask<AIContext> ProvideAIContextAsync(
InvokingContext context,
CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(new AIContext
{
Instructions = $"Current user: {Name} ({Email}). Loyalty tier: {LoyaltyTier}.",
});
}
}
BuildAgent() registers it via ChatClientAgentOptions.AIContextProviders = new[] { provider }, and Program.Main reads the same email/name/tier positional args from the command line as the Python version, so both sides of the chapter are runnable the same way.
Side-by-side differences
| Aspect | Python | .NET |
|---|---|---|
| Base class | ContextProvider | AIContextProvider |
| Override point | before_run(...) (public) | ProvideAIContextAsync(...) (protected) |
| Injecting instructions | context.extend_instructions(source_id, text) | return new AIContext { Instructions = "..." } |
| Shared state | state["..."] dict passed into before_run | No equivalent — use DI / a custom service |
| Registration | Agent(..., context_providers=[...]) | ChatClientAgentOptions.AIContextProviders = [...] |
| Also can add | messages, tools, middleware | messages, tools |
Gotchas
- Python requires
source_idon both__init__(viasuper().__init__(source_id=...)) andextend_instructions(source_id, text). Forgetting either raises at instantiation / call time, not silently. - .NET’s override point is
ProvideAIContextAsync, notInvokingAsync.InvokingAsyncis the base class’s own pipeline method that calls into your override internally — trying to override it directly is the wrong extension point; use the protectedProvideAIContextAsyncshown above. - Provider state is per-provider, not global. In Python, the
statedict passed tobefore_runis scoped to whichever provider chain the agent was built with. When you chain multiple providers (asECommerceContextProviderdoes — see below), later providers can read fields earlier ones set, but only within that same run’sstatedict. - The
agents/python/patch_maf.pyMAF packaging workaround is legacy. It patched an empty__init__.pyshipped byagent-framework-core==1.0.0; the repo now pins a version where that’s fixed upstream, so it’s a defensive no-op. Tutorial code doesn’t use it at all —tutorials/_shared/maf_bootstrap.pyis the sanctioned bootstrap that tutorials call instead, and it’s whatpython/main.pycalls before importingagent_framework.
Tests
uv run --project tutorials pytest tutorials/05-context-providers/python/tests -v
cd tutorials/05-context-providers/dotnet && dotnet test tests/ContextProviders.Tests.csproj
tutorials/05-context-providers/python/tests/test_context_provider.py covers: a unit test asserting the injected instructions reach a fake CannedChatClient (name, tier, email all present), a unit test asserting before_run populates state["user"] for downstream tools, a unit test proving two independently-built agents never leak each other’s user context, a replay-based test that plays back a recorded fixture (no network or credentials needed, safe for CI), and an integration test gated on real LLM credentials being present in .env.
tutorials/05-context-providers/dotnet/tests/ContextProvidersTests.cs mirrors that shape: three fast unit facts against the provider and BuildAgent(), plus two [Trait("Category", "Integration")] tests that hit a real LLM and are skipped (not failed) when no credentials are configured.
How this shows up in the capstone
agents/python/shared/context_providers.py:35—UserProfileProvider, the production equivalent of this chapter’s example: it queriesusersby the current request’s email (shared.context.current_user_email) and callscontext.extend_instructions("user-profile", ...)with name, role, loyalty tier, and total spend.- The same file also defines
RecentOrdersProviderandAgentMemoriesProvider(composable in the same way), andECommerceContextProvider— a back-compat composite that chains all three and reassembles their output into a singlestate["user_context"]string for the legacy tool loop. agents/python/product_discovery/agent.py:92—context_providers=[ECommerceContextProvider()]is the argument passed into every specialist agent’sAgent(...)constructor. Every one of the six specialist agents wires context providers the same way this chapter’sbuild_agent()does.
What’s next
- Next chapter: Chapter 06 — Middleware — intercepting the agent run, tool calls, and LLM calls themselves.
- Full source:
python/·dotnet/ - Shared: Mermaid style guide
Source: tutorials/05-context-providers/README.md — this page is generated from the repository.