Skip to main content

MAF v1 — Python ↔ .NET asymmetries: a porting reference

Nitin Kumar Singh
Author
Nitin Kumar Singh
I build enterprise AI solutions and cloud-native systems. I write about architecture patterns, AI agents, Azure, and modern development practices — with full source code.
MAF v1 — Python ↔ .NET asymmetries: a porting reference
Table of Contents
MAF v1: Python and .NET - This article is part of a series.
Part 22: This Article

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
#

#AsymmetryPython behaviour.NET behaviourChapter
1Streaming typeAsyncIterable[AgentResponseUpdate]IAsyncEnumerable<AgentRunResponseUpdate>Ch03
2Middleware shapemiddleware=[...] flat list on AgentChained DelegatingChatClient builderCh06
3Function middleware methodsOne unified async handlerBoth RunAsync and RunStreamingAsync requiredCh06
4WorkflowContext genericsBare WorkflowContext silently mis-routesCompile-time WorkflowContext<TIn, TOut> enforcedCh17
5RequestPort identityOpaque handle, no telemetry surfacePort id becomes executor id (visible in spans)Ch17
6Workflow output routingEvery yield_output surfaces by defaultPer-executor WithOutputFrom(...) requiredCh19

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 break out of the loop early, the await foreach still disposes correctly. If you store the enumerator manually (rare), you must await using.
  • Token-by-token delta types are named differently: AgentResponseUpdate (Python) vs AgentRunResponseUpdate (.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 to InnerClient. 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 FunctionMiddleware to .NET, write a private helper that does the interception, then call it from both RunAsync and RunStreamingAsync.
  • 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 RequestPort in .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” becomes where 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_output in a Python executor, find the corresponding executor in .NET and ensure WithOutputFrom<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 WorkflowContext is 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.Magentic ships, the ledger persistence shape may differ from the Python in-memory default.

See also
#

MAF v1: Python and .NET - This article is part of a series.
Part 22: This Article

Related