Python vs .NET Feature Parity Matrix

This repo ships two complete backends over the same domain: agents/python/ (Microsoft Agent Framework’s Python SDK) and agents/dotnet/ (the .NET/C# SDK). Development here is Python-first — new capabilities land in Python first, and the .NET backend follows on a prioritized backlog rather than in lockstep. This matrix replaces an earlier, less accurate “feature-parity” claim in the root README.md with an honest, per-concept breakdown of what’s actually implemented on each side today, verified directly against the code (not carried forward from an older description).

See also docs/agent-audit-matrix.md for the security-specific breakdown (injection defense, role enforcement, eval/red-team coverage) — this document covers the wider feature surface; the two overlap on guardrails and cross-link rather than duplicate.

Status legend

Status Meaning
Full Implemented and wired into the live request path on both sides
Partial Present but incomplete, or present in one form and not the equivalent form (noted inline)
Python-first — planned Python has it; .NET doesn’t yet, and it’s on the backlog below
Not supported by MAF .NET Blocked on the underlying .NET SDK, not a gap this repo can close alone

Priority reflects the backlog order in issue #11’s linked gaps — P1 first.


Matrix

# Concept Python .NET Status Priority Issue
1 Middleware / context-provider pipeline attached to agents shared/middleware.py’s build_specialist_middleware() wired into every specialist’s Agent(...) construction Shared/Agents/SpecialistPipeline.cs composes AgentRunLogger, ToolAuditMiddleware, and PiiRedactor via AIAgentBuilder’s .Use(...) pipeline (agent-run + function-invocation seams, MAF .NET 1.18+); ContextEnricher is attached via a new EcommerceContextProvider : AIContextProvider on ChatClientAgentOptions.AIContextProviders. SpecialistAgentFactory.Create() applies both whenever a caller passes its IServiceProvider, which all 6 agent-construction call sites (5 specialists + orchestrator) now do Full #12 (closed)
2 MCP server protocol Two real FastMCP servers (packages/mcp-product, packages/mcp-inventory) — streamable-HTTP transport, real JSON-RPC ECommerceAgents.Mcp now uses the official ModelContextProtocol.AspNetCore SDK — real JSON-RPC over streamable HTTP (MapMcp("/mcp")), [McpServerToolType]/[McpServerTool] tools, verified live (initialize + tools/list + tools/call over the wire) and via an end-to-end McpClient test Full #13 (closed)
3 Streaming chat to specialists (/message:stream) shared/agent_host.py exposes both /message:send and /message:stream (SSE) on every specialist; orchestrator/agent.py’s call_specialist_agent consumes the specialist’s stream and forwards live event: delta frames while the tool call is in flight AgentHost.cs now maps POST /message:stream on every specialist (RunAgentWithHistoryStreamingAsync); A2AClient.StreamAsync consumes it; OrchestratorTools.CallSpecialistAgent forwards each delta into a request-scoped Channel<string> (RequestContext.StreamScope, the .NET analog of Python’s current_stream_queue) that ChatRoutes.StreamAsync drains concurrently into event: delta frames on the outer SSE response — same live-preview behavior as Python’s tool mode. Scoped to the tool orchestration mode only; .NET has no other orchestration modes to extend this to (see rows 11-13) Full #14 (closed)
4 Inbound prompt-injection detection shared/guardrails/injection_middleware.py, attached via the shared middleware pipeline Shared/Guardrails/Sanitize.cs (patterns ported verbatim) + SpecialistPipeline’s combined guardrail gate, built on the AIAgentBuilder.Use(runFunc, streamingFunc) seam so it can fully short-circuit before the chat client. Observe-only by default (flags via RequestContext.CurrentGuardrailFlags); GUARDRAILS_BLOCK_ON_INJECTION escalates to a hard refusal, same as Python Full #15 (closed)
5 Stored-content sanitization (tool results re-entering the model) shared/guardrails/output_middleware.py Shared/Guardrails/OutputSanitizer.cs — reflection-based recursive walk of a tool’s returned record (the .NET twin of Python’s dict-key-based neutralize_value, since .NET tools return strongly-typed records, not dicts), gated by a new SanitizeToolsConfig.SanitizeTools allowlist (tool name → property names) covering the same three specialists Python’s table does, wired into the function-invocation seam alongside ToolAuditMiddleware Full #15 (closed)
6 Output moderation (self-harm / hate / violence phrase screening) shared/guardrails/moderation.py + moderation_middleware.py Shared/Guardrails/Moderation.cs (patterns ported verbatim), checked on the final response text by the same combined guardrail gate as row 4. OUTPUT_MODERATION_MODE=enforce replaces a non-streaming response; a streamed response can only be flagged post-hoc (chunks already on the wire) — same documented trade-off as Python Full #15 (closed)
7 Step recorder → live agentic timeline shared/agent_observability.py’s StepRecorderMiddleware, attached to every agent, drained per-request into SSE event: step frames and the /runs UI SpecialistPipeline’s RecordSteps stage (function-invocation seam, unconditional like Python’s) appends one ExecutionStep per tool call to a new RequestContext.CurrentSteps. A specialist returns its own steps over A2A (AgentResponse.Steps on /message:send; an event: steps bulk SSE frame on /message:stream); A2AClient merges them into the orchestrator’s own timeline, tagged with the specialist’s name. ChatRoutes now calls the previously-unused UsageRecorder.LogExecutionStepAsync() per step after persisting the turn, and StreamAsync emits one event: step SSE frame per step (same wire shape web/src/lib/api.ts’s AgentStep parser already expects) — so /runs and the live timeline now populate for the .NET backend too Full #16 (closed)
8 Fan-out/fan-in + sequential HITL-gated workflow construction workflows/pre_purchase.py and workflows/return_replace.py both use MAF’s WorkflowBuilder — real executor graphs; return_replace.py’s HITL gate uses ctx.request_info to pause Both PrePurchaseWorkflow.cs and ReturnAndReplaceWorkflow.cs now build real WorkflowBuilder graphs (Microsoft.Agents.AI.Workflows 1.18.0), executor ids matching Python’s 1:1. PrePurchaseWorkflow’s fan-in barrier collects the three upstream messages one at a time (MAF delivers them separately, not batched) via a MergeStates merge, since .NET executors can’t share one mutable state object across parallel branches the way the old Task.WhenAll version did. ReturnAndReplaceWorkflow’s HITL gate uses a RequestPort (MAF .NET has no ctx.request_info-equivalent ad-hoc pause from inside an arbitrary executor — pausing requires a dedicated port node), with a GateDecisionExecutor routing to the port or straight past it by explicit targetId depending on order value. Python’s two-call execute()/resume-via-responses={...} contract maps to one long-lived StreamingRun cached across the pause (verified empirically before implementing: break out of the event stream on RequestInfoEvent without disposing the run, later call SendResponseAsync and open a fresh WatchStreamAsync() on the same run — resumes correctly), keyed by order id so one workflow instance can have multiple orders paused concurrently. One deliberate improvement over Python: the resumed state keeps the full original WorkflowState object rather than Python’s narrower ReturnApprovalRequest-only rehydration, so return_id/replacement_products/user_email survive the pause where Python’s don’t. Both stacks now wire these to live routes: Python registers them in orchestrator/modes/, .NET in ModeRegistry (PrePurchaseMode, ReturnReplaceMode). .NET additionally checkpoints through MAF’s own ICheckpointStore and can resume a paused run from storage rather than from a live in-process object, so a pending approval survives an orchestrator restart Full #17 (closed)
9 Human-in-the-loop as middleware shared/hitl.py intercepts five destructive tools at the middleware layer, independent of each tool’s own body HitlGate is now a real interception layer, wired into SpecialistPipeline’s function-invocation pipeline (the same seam ToolAuditMiddleware/OutputSanitizer use) — CancelOrder/ModifyOrder/PlaceBackorder no longer know or need to know they’re gated; a gated call is short-circuited before the tool method ever runs, matching Python’s “don’t call call_next()” exactly, with the same generic {status, message, request_id} result shape Python returns (rather than trying to preserve each tool’s typed result for the pending case). Also fixes a real bug found during the port: the old call-site wrapper failed open on a DB error — contradicting Python’s own fail-closed behavior — now fails closed Full #17 (closed)
10 Shared tool library shared/tools/ — 8 modules, 1,473 lines, imported by whichever specialists need them (cart_tools.py, return_tools.py, seller_tools.py, loyalty_tools.py, inventory_tools.py, user_tools.py, memory_tools.py, pricing_tools.py) Shared/Tools/ now holds the cross-cutting modules — ProductLookupTools, UserProfileTools, StockLookupTools, PriceHistoryTools, LoyaltyTools, ReturnTools — registered by whichever specialists need them, the same shape as Python’s shared/tools/. Domain-specific logic stays in each specialist’s own Tools/ folder on both sides Full #18
11 Handoff orchestration orchestrator/handoff.py’s HandoffBuilder mesh over RemoteSpecialistChatClient Modes/HandoffMode.cs — a real AgentWorkflowBuilder.CreateHandoffBuilderWith mesh. It was previously a hand-rolled router over the A2A client, because MAF’s handoff orchestration takes AIAgent participants and a specialist lives behind an HTTP hop; RemoteSpecialistChatClient is the adapter that closes that gap — an IChatClient whose “model” is the far side of an A2A call, and the direct twin of Python’s shared/remote_agent.py. Both stacks now start from a tool-free triage agent: seeding the mesh with the tool-calling orchestrator meant it routed and answered instead of handing off (23,637 characters from orchestrator with no specialist speaking at all) Full #19
12 Group chat orchestration workflows/group_chat.py — two agent panelists + a moderator Modes/GroupChatMode.cs — same two panelists and moderator, same prompts, same sequential-transcript shape Full #19
13 Magentic orchestration Not present. orchestrator/modes/ holds base, tool_router, handoff_mode, workflow_mode and group_chat_mode — there is no magentic_mode.py; an earlier version of this row cited one Not present, and not available: Magentic is Python-only in MAF v1. tutorials/16-magentic-orchestration/dotnet/tests/MagenticTests.cs reflects over Microsoft.Agents.AI.Workflows and asserts that no Magentic type is exported, that MagenticBuilder and StandardMagenticManager are both null, and — as a control against a failed assembly load looking like the same thing — that the sibling orchestration builders are present. It is green in CI, so the gap is measured rather than assumed. An earlier version of this row claimed .NET shipped MagenticWorkflowBuilder; no such type exists Neither stack — not a parity gap #19
14 Eval harness evals/harness.py’s ProductionRunner, real scorers, committed baselines, CI-gated smoke suite Not present Python-first — planned P3 #19
15 Long-term memory — write path shared/tools/memory_tools.py — agent-callable save/update tools Shared/Tools/MemoryTools.csStoreMemory and RecallMemories, registered on product-discovery and review-sentiment exactly as Python registers them, alongside the existing read paths (ContextEnricher, ProfileRoutes). Identity comes from RequestContext, so the model cannot write onto another user’s profile Partial P3 #19
16 Tutorial chapter test coverage (tutorials/*/dotnet/) 32 chapters ship code, all with tests (non-integration suite green in CI) 31 chapters ship code, all with tests. Not ported: ch20b — Microsoft.Agents.AI.DevUI is prerelease-only and this repo pins 1.1.0 stable. Ch16’s .NET is a documented status stub (Magentic is Python-only in MAF v1) whose tests are a tripwire over the shipped assembly. Ch21 is planned on both sides. Parity #20
17 Server-side grounding shared/grounding/{ledger,extractor,verifier,middleware}.py — three tiers (per-request tool-result ledger, batched DB lookup, consistency check), GROUNDING_MODE off/observe/annotate/enforce Shared/Grounding/{ClaimExtractor,GroundingVerifier}.cs with the DB and consistency tiers and the same off/observe/annotate modes. No ledger tier: Python records facts from tool results inside the specialist processes, so an orchestrator-side port needs them carried back over A2A. Prose figures are instead checked against the DB rows the answer’s own cards cite. Python’s enforce is refused at startup rather than silently behaving like annotate Partial P2 #33
18 Idempotency on money paths shared/idempotency.py + idempotency_keys, applied to initiate_return, process_refund, execute_approved_action and checkout Shared/Idempotency/IdempotencyGuard.cs over the same table, applied at the same four sites. Reserve via ON CONFLICT DO NOTHING, replay a completed reservation, refuse a live duplicate, reclaim one older than 60s, release on failure Full
19 Rate limiting shared/rate_limit.py — Redis sliding window on both chat routes, keyed by user and by IP for anonymous traffic Shared/RateLimiting/SlidingWindowRateLimiter.cs — the same Lua script, applied to both chat routes via an endpoint filter. Fails open Full
20 Cost estimation and budget ceiling shared/cost.py + cost_budget_middleware.py, COST_BUDGET_MODE default observe Shared/Cost/CostEstimator.cs plus a ceiling in SpecialistPipeline, same default Full
21 Telemetry depth shared/telemetry.py — traces, metrics and logs to Aspire; auto-instrumentation for httpx, asyncpg and OpenAI; invoke_agent GenAI span convention; trace_id correlated into usage_logs. Emits one custom instrument this repo owns — ecommerce.llm.cost.usd, plus tokens split by direction, from the same per-turn estimate the budget ceiling already computes. Everything else is auto-instrumentation Shared/Telemetry/TelemetrySetup.cs — traces, metrics and logs to Aspire; auto-instrumentation for ASP.NET Core, HttpClient and Npgsql; same invoke_agent convention and session/conversation enrichment; the same ecommerce.llm.cost.usd and ecommerce.llm.tokens instruments under the same meter name, so one dashboard covers both stacks Full, except the optional Langfuse sink P3 #19
22 Orchestration modes registered Five: tool, handoff, workflow:pre-purchase, workflow:return-replace, group-chat The same five. Verified live against a running stack: one question answered in three modes gives the orchestrator’s own composition, the specialist’s unedited answer, and a panel transcript respectively Full
23 /api/orchestration/* routes modes, modes/{name}/graph, compare, {run_id}/resume All four. resume restores the paused workflow from its checkpoint and claims the pending row before executing — Python updates it afterwards, which leaves a window where two clicks both release a refund Full
24 MCP consumption (client side) product-discovery and inventory-fulfillment can swap their direct-asyncpg tools for MCPStreamableHTTPTool against the two FastMCP servers, gated by MCP_ENABLED .NET ships an MCP server (ECommerceAgents.Mcp, real JSON-RPC) but no specialist wires an MCP client, so there is no equivalent swap Partial P3 #19
25 Session + checkpoint backends actually used MAF_SESSION_BACKEND and MAF_CHECKPOINT_BACKEND drive real providers Both registered in Program.cs, and checkpointing is now genuinely exercised: workflows write through MAF’s CheckpointManager during a run, which is what makes GET /api/runs/{id}/checkpoints return anything. Registration alone was not enough — the DI entry existed for a while with no consumer Full
26 Seeder and auth-server scripts/seed.py (deterministic, random.seed(42)) and the Python auth_server image The same two Python images, by design. docker-compose.dotnet.yml builds both from ./agents/python. The seeder is the single source of demo data — a second implementation would have to produce byte-identical rows or the two stacks diverge in catalogue content, and the dual-backend Playwright suite asserts against seeded data, so divergence there destroys the parity gate that having two backends exists to provide. OAuth2 is protocol-standard: both stacks validate against a JWKS endpoint and the issuer’s language is invisible to the consumer. Neither service is an agent, so neither demonstrates anything about Microsoft Agent Framework. Cost, stated honestly: the .NET stack cannot start unless the Python image builds By design plan 16 F5
27 Anonymous multi-turn memory Not persisted — anonymous storefront conversations have no context at any tier Same By design plan 20
28 Langfuse sink Optional additive exporter alongside OTel Deliberately not ported. OTel is the primary sink on both stacks and already carries GenAI spans to Aspire; a second exporter would be additive-only and duplicate what is already exported By design plan 20

What’s not on this list

Both backends implement the domain fully — an orchestrator plus five specialist agents, A2A routing, the tool-router mode, Postgres-backed checkpointing, OAuth2/JWT auth, guardrails, idempotency on the money paths, rate limiting, and the Aspire-based telemetry pipeline. This matrix tracks the gaps; the shared foundation isn’t repeated here row by row.

Two claims this document used to make and no longer does, because both were false when checked against the code: that neither stack wired its workflows to a live route (both do), and that Python has a magentic_mode.py (neither stack has one).

Out of scope for now

.NET readers should treat the P3 rows as capabilities this backend doesn’t have yet rather than capabilities arriving soon: the evals harness, long-term-memory writes, MCP client consumption, and the optional Langfuse sink. Handoff and group-chat are no longer on that list — both are registered, live and built on MAF’s own orchestration. Magentic is not on it either — it exists in neither stack, so it is not a parity gap at all.

The P2 row is grounding’s missing ledger tier, which is bounded by a real constraint rather than effort: the facts it would check against are produced inside the specialist processes and would have to be carried back over A2A.

Everything else above is Full on both sides. Where .NET is stricter than Python it is noted inline rather than smoothed over — the resume route’s claim-before-execute is the current example.


Source: docs/parity-matrix.md — this page is generated from the repository.