Microsoft Agent Framework (MAF) — SpendGuardMiddleware (both languages)
Microsoft Agent Framework (MAF) GA’d 2026-04 as the unified successor to Semantic Kernel + AutoGen (both upstream lines are now maintenance-only). It ships in both .NET (
Microsoft.Agents.AI+Microsoft.Extensions.AI) and Python (agent_framework), with a wire-stableChatMiddlewarecontract on the chat-client boundary. SpendGuard slots in at that boundary in both languages —IChatClient.UseSpendGuard(sp)on .NET,SpendGuardMiddleware(client=..., ...)on Python — so a single design document and a single demo matrix cover both halves.
Install
Section titled “Install”dotnet add package Spendguard.AgentFrameworkdotnet add package Microsoft.Agents.AI.Abstractionsdotnet add package Microsoft.Extensions.AI.AbstractionsSpendguard.AgentFramework targets net8.0. The middleware
extends Microsoft.Extensions.AI.DelegatingChatClient so it slots
into any IChatClient-shaped pipeline — both
Microsoft.Agents.AI.ChatAgent (the MAF GA agent) and hand-written
IChatClient consumers gate through the same seam.
Python
Section titled “Python”pip install 'spendguard-sdk[agent-framework]'agent-framework>=1.0,<2 is pulled in as an optional extra. The
SpendGuardMiddleware subclass of agent_framework.ChatMiddleware
is the integration’s public surface.
Quick start
Section titled “Quick start”using Microsoft.Extensions.AI;using Microsoft.Extensions.DependencyInjection;using Spendguard.AgentFramework.Extensions;using Spendguard.AgentFramework.Options;
var services = new ServiceCollection();services.AddLogging();services.AddSpendGuard(o =>{ o.TenantId = "00000000-0000-4000-8000-000000000001"; o.BudgetId = "44444444-4444-4444-8444-444444444444"; o.WindowInstanceId = "55555555-5555-4555-8555-555555555555"; o.SidecarSocketPath = "/var/run/spendguard/adapter.sock"; o.OnSidecarUnavailable = OnSidecarUnavailable.Deny;});
await using var sp = services.BuildServiceProvider();IChatClient inner = /* OpenAIChatClient / AzureChatClient / etc. */;IChatClient gated = inner.UseSpendGuard(sp);
var resp = await gated.GetResponseAsync(new[]{ new ChatMessage(ChatRole.User, "hello"),});The same gated IChatClient can be handed to a ChatAgent:
using Microsoft.Agents.AI;
var agent = new ChatAgent(gated, instructions: "Be brief.");var result = await agent.RunAsync("hello");Python
Section titled “Python”import asyncio
from agent_framework import ChatAgentfrom agent_framework.openai import OpenAIChatClient
from spendguard import SpendGuardClientfrom spendguard.integrations.agent_framework import ( SpendGuardAgentFrameworkOptions, SpendGuardMiddleware, run_context, RunContext,)from spendguard._proto.spendguard.common.v1 import common_pb2
async def main() -> None: client = SpendGuardClient( socket_path="/var/run/spendguard/adapter.sock", tenant_id="00000000-0000-4000-8000-000000000001", runtime_kind="microsoft-agent-framework-python", ) await client.connect() await client.handshake()
options = SpendGuardAgentFrameworkOptions( tenant_id="00000000-0000-4000-8000-000000000001", budget_id="44444444-4444-4444-8444-444444444444", window_instance_id="55555555-5555-4555-8555-555555555555", sidecar_socket_path="/var/run/spendguard/adapter.sock", )
def _claim_estimator(messages): claim = common_pb2.BudgetClaim() claim.budget_id = options.budget_id claim.window_instance_id = options.window_instance_id claim.amount_atomic = "1000000" # placeholder; real cost is from pricing claim.unit.unit_id = "usd_micros" return [claim]
chat_middleware = SpendGuardMiddleware( client=client, options=options, unit=common_pb2.UnitRef(unit_id="usd_micros"), pricing=common_pb2.PricingFreeze(pricing_version="prod-pricing-v1"), claim_estimator=_claim_estimator, )
agent = ChatAgent( chat_client=OpenAIChatClient(...), middleware=[chat_middleware], )
async with run_context(RunContext(run_id="run-123")): result = await agent.run("Hello!") print(result)
asyncio.run(main())What happens on each gated chat-client call
Section titled “What happens on each gated chat-client call”Both languages share the same lifecycle:
- The middleware derives a stable identity tuple
(decisionId, idempotencyKey, llmCallId, stepId)from the currentRunContextplus a content hash of the rendered messages + options. Same logical call → same idempotency key → sidecar dedupes retries. - The middleware calls
RequestDecision(LLM_CALL_PRE)against the sidecar UDS. OnCONTINUE/DEGRADEthe call proceeds; onSTOP/STOP_RUN_PROJECTION/ unknown-decision the middleware throws (SpendGuardDecisionDeniedExceptionon .NET,DecisionDeniedon Python); onREQUIRE_APPROVALit throws a pending-approval error. - Review-standards §3.1 + §5 enforced: when the middleware
throws, the inner
IChatClient/call_next()HTTP never fires. The substrate’s--mockdemo explicitly asserts this invariant. - On inner-call exception, the middleware releases the reservation before re-raising so the budget isn’t pinned by a failed call.
- After the inner call returns, the middleware emits an
LLM_CALL_POSTtrace event with the provider-reported usage (ChatResponse.Usageon .NET,ChatResponse.usage_detailson Python). TheLLM_CALL_POSTevent is what closes the audit chain for the call.
Configuration
Section titled “Configuration”The .NET and Python options surfaces are case-translated mirrors per
review-standards §2.3 P2:
| .NET (SpendGuardOptions) | Python (SpendGuardAgentFrameworkOptions) | Required | Default | Description |
| -------------------------- | ------------------------------------------ | -------- | ------- | ----------- |
| TenantId | tenant_id | YES | — | Tenant UUID the call is billed to. |
| BudgetId | budget_id | YES | — | Budget UUID the projected claim’s scope_id points at. |
| WindowInstanceId | window_instance_id | YES | — | Time-window scope on the budget. |
| SidecarSocketPath | sidecar_socket_path | YES | /var/run/spendguard/adapter.sock (.NET) / /var/run/spendguard/sidecar.sock (Python) | UDS path the middleware connects to. |
| OnSidecarUnavailable | on_sidecar_unavailable | NO | Deny / "deny" | Fail-closed default per design.md ADR-005. Allow / "allow" is opt-in fail-open with a logged warning. |
| SdkVersion | (set by SDK) | NO | matches package version | Handshake metadata. |
| RuntimeKind | (set by SDK) | NO | microsoft-agent-framework-dotnet / microsoft-agent-framework-python | Routes capability negotiation in the sidecar. |
The claim_estimator / claimEstimate callable lives on the
middleware constructor (Python) or on the ITokenEstimator DI
binding (.NET). It maps the ChatContext.messages /
IEnumerable<ChatMessage> to a BudgetClaim (Python) or token
count (.NET). v1 ships a SimpleTokenEstimator on .NET; Python
requires the caller to supply one explicitly (ADR-004).
Coexistence with AGT
Section titled “Coexistence with AGT”design.md §1.3 + ADR-006 — spendguard.integrations.agt is a
policy-engine hook above MAF, while this MAF middleware is the
framework-native chat-client hook. Both can be combined:
- An
AGTcomposite evaluator can run inside an MAF middleware delegate. - A single
SpendGuardClientshared between both surfaces produces one handshake + one reservation per LLM call regardless of which integration surface is responsible.
The composite path is smoke-tested by
DEMO_MODE=maf_python_with_agt; the dedicated demos
(DEMO_MODE=agent_real_agt for AGT, DEMO_MODE=maf_dotnet_real /
DEMO_MODE=maf_python_real for MAF) remain the load-bearing demos
for each integration.
Limitations
Section titled “Limitations”- Streaming-per-chunk gating is anti-scope for v0.1.x. The
IAsyncEnumerable<ChatResponseUpdate>boundary (.NET) and the streamingChatMiddlewareoverload (Python) are left as-is for a follow-up slice. The composite demo’s third step is ALLOW2 (a second non-streaming ALLOW) instead of STREAM — same shape as D04 / D06 / D08’s STREAM step from a load-bearing matrix perspective; the byte-level streaming gate is the follow-up. - Tool-level (function-call) gating is opt-in. ADR-002 — token
budget is separable from tool-cost budget. The opt-in second
middleware (
SpendGuardToolMiddlewareon Python; a parallelSpendGuardFunctionInvocationMiddlewareplanned on .NET) gates individual function invocations. - Sidecar dependency is fail-closed by default.
OnSidecarUnavailable = Allow/on_sidecar_unavailable="allow"is an explicit opt-in with a logged warning at every unreachable request. Reviewer Sec3 flags every use site. - .NET 7 / Framework 4.x are unsupported.
net8.0is the only supported TFM in this slice;netstandard2.1multi-targeting is deferred to a build-host upgrade (the in-tree build host carries only net8.0 reference packs at the time of writing).
Two load-bearing demo modes (one per language) plus a smoke composite:
# .NET 8 console app driving IChatClient.UseSpendGuard(sp)make demo-up DEMO_MODE=maf_dotnet_real
# Python driving SpendGuardMiddleware.process(...)make demo-up DEMO_MODE=maf_python_real
# composite smoke: MAF + AGT coexistence (optional)make demo-up DEMO_MODE=maf_python_with_agtEach mode boots the same base stack
(postgres + sidecar + ...) plus a counting-stub provider, then runs
three calls through the SpendGuard-wrapped boundary:
- ALLOW — small message within budget → upstream HTTP fires →
counter
+1. - DENY —
trigger-denymarker → sidecar contract evaluator emitsSPENDGUARD_DENY→ middleware throws before the inner call fires → counter+0. - ALLOW2 — second small message → upstream HTTP fires →
counter
+1. Replaces D04 / D06 / D08’s STREAM step (streaming gating is v0.1.x non-goal).
Success lines on a clean run (LOCKED — CI greps for the exact spelling):
[demo] maf_dotnet ALL 3 steps PASS (ALLOW + DENY + ALLOW2)[demo] maf_python ALL 3 steps PASS (ALLOW + DENY + ALLOW2)Full details and the verify-SQL gates are in
deploy/demo/maf_dotnet/README.md
and
deploy/demo/maf_python/README.md.
The standalone --mock modes (no sidecar required) run against the
same factories with in-process SpendGuardClient doubles:
dotnet run --project examples/maf-dotnet -- --mock
# Pythonpython examples/maf-python/run.py --mockThe mock modes explicitly assert the “DENY ⇒ inner is NEVER invoked” invariant (INV-1.6) and exit non-zero if violated.
Troubleshooting
Section titled “Troubleshooting”-
SpendGuardDecisionDeniedException(.NET) /DecisionDenied(Python). Expected behaviour when the budget is exhausted or a contract rule emitsSPENDGUARD_DENY. The middleware throws, the inner chat client’s HTTP never fires. Check the sidecar logs for the rule that matched. -
Budget gate not blocking — LLM call fires anyway. Almost always caused by the middleware not being threaded into the pipeline. Inspect the
IChatClient(.NET) returned byUseSpendGuard(sp)— it MUST be passed to theChatAgentconstructor / consumer code, not bypassed. Python: verify theSpendGuardMiddlewareinstance is in theChatAgent(middleware=[ ... ])list. -
SidecarUnavailableException/SidecarUnavailable(handshake timeout). The sidecar’s UDS path (SidecarSocketPath/sidecar_socket_path, default/var/run/spendguard/adapter.sock) is not reachable. Check that the sidecar container is up, the socket file exists, and your process has read/write permission on it. -
Token counts on commit don’t match what the provider reports. The .NET middleware reads
ChatResponse.Usage.TotalTokenCount; Python readsChatResponse.usage_details["total_token_count"]withinput_token_count + output_token_countfallback. If the provider’s response shape is non-standard, override theclaim_estimator(Python) orITokenEstimator(.NET) DI binding so the middleware uses your accurate count. -
Class identity check fails:
err instanceof DecisionDenied(Python). TheDecisionDeniedre-export is the same class object asspendguard.errors.DecisionDenied, so the check works regardless of which import you use. If the check fails, suspect a duplicatespendguardpackage in yoursite-packagestree.
Related
Section titled “Related”- Quickstart — full SpendGuard stack up in 5 minutes.
- Microsoft AGT — policy-engine framing one layer up.
- OpenAI Agents SDK (Python) — Python adapter for the OpenAI Agents SDK.
- OpenAI Agents SDK (TypeScript) — TS sibling.
- Inngest AgentKit (TypeScript) — durable
retry-dedup adapter for
step.ai. - Contract YAML reference — author allow/stop rules.
- Other adapter integrations: Pydantic-AI · LangChain & LangGraph (Python) · LangChain.js callback handler · Vercel AI SDK (covers Mastra) · LiteLLM proxy guardrail