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 — registered and live. Same observable behaviour (control transfers; the specialist’s answer is the answer), built over the existing A2A client rather than MAF’s HandoffWorkflowBuilder, which needs an in-process agent adapter .NET does not have yet | 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. MAF .NET ships MagenticWorkflowBuilder, so this is unbuilt rather than unavailable | 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.cs — StoreMemory 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/) | All 34 chapters have Python tests (non-integration suite green in CI) | Real code + tests: ch01–11. Real code, no tests: ch12–20 (sequential, concurrent, handoff, group chat, magentic, HITL, checkpoints, declarative, visualization). Stub only: ch20b (README only, no .csproj), ch21 (.gitkeep only). Absent entirely: ch22–32 (eleven chapters — Python-only, includes evals, cost-control, RAG, guardrails, and more) | Partial | P3 | #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 no custom metrics — its metrics value 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 | 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 | — | — |
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: handoff and group-chat modes, the evals harness, long-term-memory writes, MCP client consumption, and telemetry depth. Magentic is not on that list — 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.