Series note — Reference appendix to MAF v1: Python and .NET. Sits after Ch21 — Putting it all together. The asymmetries below are documented in the chapters where they first matter; this page collects them in one place so a reader who’s porting a Python chapter to .NET (or vice versa) doesn’t have to remember which chapter explained which footgun.
Why this page#
After 23 chapters of “same concept, both languages,” the headline is that MAF’s Python and .NET surfaces are 95% interchangeable. That last 5% is where you get burned during a port. Six asymmetries account for nearly all of it. Each one has a single-line diagnostic (“the symptom”), the same-paragraph workaround, and a pointer back to the chapter where it first showed up.
Read this when:
- You’re carrying a Python pattern over to the .NET twin and the test suite goes red.
- You’re writing a chapter and want to call out the cross-language pitfall once without re-explaining it everywhere.
- You’re reviewing a PR that touches both stacks and you want to remember which one needs the extra ceremony.
At a glance#
| # | Asymmetry | Python behaviour | .NET behaviour | Chapter |
|---|---|---|---|---|
| 1 | Streaming type | AsyncIterable[AgentResponseUpdate] | IAsyncEnumerable<AgentRunResponseUpdate> | Ch03 |
| 2 | Middleware shape | middleware=[...] flat list on Agent | Chained DelegatingChatClient builder | Ch06 |
| 3 | Function middleware methods | One unified async handler | Both RunAsync and RunStreamingAsync required | Ch06 |
| 4 | WorkflowContext generics | Bare WorkflowContext silently mis-routes | Compile-time WorkflowContext<TIn, TOut> enforced | Ch17 |
| 5 | RequestPort identity | Opaque handle, no telemetry surface | Port id becomes executor id (visible in spans) | Ch17 |
| 6 | Workflow output routing | Every yield_output surfaces by default | Per-executor WithOutputFrom(...) required | Ch19 |
Below is the same list with the actual code shapes — the table is for orientation; the sections are for action.
1. Streaming type — AsyncIterable vs IAsyncEnumerable#
The symptom. Your Python chapter does async for update in agent.run_stream(...) and yields AgentResponseUpdate deltas. Porting to .NET, you write await foreach (var update in agent.RunStreamingAsync(...)) — and the compiler asks you to await using the enumerator, await each MoveNextAsync, and dispose explicitly if you exit the loop early.
Why it differs. Python papers over the sync vs async distinction at the call site — async def makes everything awaitable, the runtime threads the event loop. .NET’s IAsyncEnumerable<T> is explicit: every MoveNextAsync is a ValueTask, every enumerator implements IAsyncDisposable. The runtime gives you no syntactic shortcut, but in exchange you get cancellation-token plumbing for free.
Port checklist:
- Wrap streaming consumers in
await foreach (var update in stream.WithCancellation(ct))so cancellation propagates. - If you
breakout of the loop early, theawait foreachstill disposes correctly. If you store the enumerator manually (rare), you mustawait using. - Token-by-token delta types are named differently:
AgentResponseUpdate(Python) vsAgentRunResponseUpdate(.NET). Same payload, just the class name.
2. Middleware shape — flat list vs DelegatingChatClient chain#
The symptom. In Python you write Agent(client=client, middleware=[Logger(), Audit(), Pii()]) and the order is left-to-right (outermost to innermost). In .NET you compose client.AsBuilder().Use(new LoggerMiddleware()).Use(new AuditMiddleware()).Use(new PiiMiddleware()).Build() — the order is also outermost to innermost, but you read it in the opposite syntactic direction.
Why it differs. Python treats middleware as a list of objects with hook methods that the runtime traverses in order. .NET adopts the standard DelegatingHandler / DelegatingChatClient pattern from Microsoft.Extensions.AI — every middleware wraps the next one in a chain, like ASP.NET request pipelines.
Port checklist:
- The order you intend is the order you write — both languages put outermost first. The .NET reading-direction confusion is real but only at first glance.
- A no-op middleware in .NET is just
protected override Task<...> RunAsync(...) => InnerClient.RunAsync(...);— explicitly forwarding toInnerClient. Python’s no-op is “don’t define the hook.” - Configuration via DI: in .NET, register middleware as services and let the chain assemble through
IChatClient. In Python, instantiate at agent-construction time.
3. Function middleware — one method vs two#
The symptom. You write a FunctionMiddleware in .NET that intercepts tool calls. The non-streaming case works. Your tests for the streaming agent path fail because the middleware never runs.
The reason. .NET’s FunctionMiddleware requires you to override both RunAsync and RunStreamingAsync. Even if your interception logic is identical for the two paths, you must implement them separately — there is no shared base method. Python’s middleware exposes a single async def __call__ that the runtime invokes for both streaming and non-streaming runs.
Port checklist:
- When porting a Python
FunctionMiddlewareto .NET, write a private helper that does the interception, then call it from bothRunAsyncandRunStreamingAsync. - If you only override
RunAsync, the streaming path silently bypasses your middleware. The compiler won’t warn you. The chapter test suite will (Ch06’s tests cover both).
public sealed class ToolAuditMiddleware : FunctionMiddleware
{
private void Audit(FunctionInvocationContext ctx) { /* shared logic */ }
public override Task<object?> RunAsync(FunctionInvocationContext ctx, CancellationToken ct)
{
Audit(ctx);
return base.RunAsync(ctx, ct);
}
public override IAsyncEnumerable<object?> RunStreamingAsync(FunctionInvocationContext ctx, CancellationToken ct)
{
Audit(ctx);
return base.RunStreamingAsync(ctx, ct);
}
}4. WorkflowContext generics — bare type silently mis-routes#
The symptom. Your Python executor receives a typed message and emits a typed output. You write the same in .NET as class MyExecutor : IExecutor<string, int> — fine. You then write the handler with async ValueTask Handle(WorkflowContext ctx, ...) (no generics) and the workflow runs but the next executor receives null.
The reason. In .NET, WorkflowContext without type parameters is a non-generic base type. The runtime can’t bind the handler’s input/output edges to the workflow graph because it has no type information. The handler runs, but ctx.SendAsync(value) doesn’t route to the typed downstream edge.
The fix. Always declare the handler with explicit generics: async ValueTask Handle(WorkflowContext<TIn, TOut> ctx, TIn input, CancellationToken ct). The compiler doesn’t enforce it (the bare overload exists for legitimate dynamic-routing scenarios), so this is purely a discipline issue.
Python doesn’t have the same trap — Python’s WorkflowContext is not parametrized at the type-system level; the runtime introspects handler signatures via type hints. As long as your __call__(self, ctx: WorkflowContext, value: MyInput) -> MyOutput annotates the input type, routing works.
Port checklist:
- Grep your .NET workflow code for
WorkflowContext ctx(no<...>) — every match is a potential silent mis-route. - The Python signature with type hints is the authoritative spec; make the .NET generics match.
5. RequestPort — visible id in .NET, opaque in Python#
The symptom. You wire human-in-the-loop in Python by attaching a RequestPort to an executor; the port has no name you ever see. You port to .NET; the equivalent RequestPort constructor takes an id parameter, and that id shows up in your OpenTelemetry spans as the executor id.
Why it differs. .NET’s request-port mechanism reuses the executor-id surface to keep the workflow graph addressable for resume. The id is a feature, not a leak — but it does mean two ports with the same id silently collide.
Port checklist:
- Pick a stable, descriptive id when constructing a
RequestPortin .NET —"return_approval_gate"not"port". - The id appears in span attributes as
workflow.executor.id. Use it for filtering — “show me every paused approval” becomeswhere workflow.executor.id == "return_approval_gate". - Python’s port handle is opaque; the executor’s class name is what you’ll see in spans there. The trade-off is that two executors of the same class can’t be told apart in spans without explicit attributes — pick your poison.
6. Workflow outputs — auto-surface vs explicit WithOutputFrom#
The symptom. In Python, every executor that calls ctx.yield_output(value) surfaces that value to the caller via workflow.run_stream(). You port to .NET, write await ctx.YieldOutputAsync(value), and the caller’s await foreach over WorkflowEvents never sees the output.
The reason. .NET requires you to opt each executor in, on the builder, with .WithOutputFrom<MyExecutor>(). Without it, the executor still produces the value internally, but the workflow doesn’t expose it to consumers. This is intentional: it lets a complex workflow declare which executors are “user-facing” vs internal.
Port checklist:
- For every
yield_outputin a Python executor, find the corresponding executor in .NET and ensureWithOutputFrom<TheExecutor>()is in the builder chain. - A common port mistake: register only the final executor for output, miss intermediate progress events. The Python equivalent surfaces them all by default; the .NET equivalent needs each one explicit.
var workflow = new WorkflowBuilder<MyInput>()
.Start<FanOutExecutor>()
.Then<ProgressExecutor>()
.Then<MergeExecutor>()
.WithOutputFrom<ProgressExecutor>() // intermediate updates
.WithOutputFrom<MergeExecutor>() // final result
.Build();What’s the pattern?#
The six asymmetries split cleanly into two camps:
- Verbosity gaps (#1, #2, #3, #6) — .NET asks for more explicit ceremony than Python. Streaming requires explicit cancellation/dispose; middleware requires both methods; outputs require explicit opt-in. The trade-off is the explicit form catches mistakes at compile time more often.
- Type-system gaps (#4, #5) — Python’s runtime introspection vs .NET’s compile-time generics. Python catches less at compile time but is more forgiving when you forget a type hint; .NET catches more but the bare overload of
WorkflowContextis a footgun precisely because it doesn’t enforce.
There is one larger meta-pattern: the .NET stack tends to surface workflow internals on the telemetry / type system, the Python stack tends to keep them opaque. Both choices are defensible; you just need to know which side of the line you’re on.
What this page is not#
This is not a list of features one stack has and the other doesn’t — for that, see the chapter-level “Series note” boxes (Ch16 Magentic and Ch20b DevUI flag .NET-coming-soon). This page covers asymmetries where both stacks ship the feature but the surface differs.
Two future entries that will land here when their respective MAF surfaces stabilise:
- DevUI server registration — when the .NET DevUI ships, the entity-discovery model (filesystem vs programmatic) may differ from the Python
serve(entities=[...])shape. - Magentic facts ledger — when
Microsoft.Agents.AI.Workflows.Magenticships, the ledger persistence shape may differ from the Python in-memory default.
See also#
- Ch03 — Streaming and Multi-turn — original context for asymmetry #1.
- Ch06 — Middleware — original context for asymmetries #2 and #3.
- Ch17 — Human-in-the-Loop — original context for asymmetries #4 and #5.
- Ch19 — Declarative Workflows — original context for asymmetry #6.
- Ch21 — Putting it all together — every concept on this page in the live e-commerce repo.

