|
| 1 | +--- |
| 2 | +title: 'We Measured the Runtime Swap: Three Non-LangGraph Backends, One Angular Contract' |
| 3 | +description: 'Strands, Microsoft Agent Framework, and Mastra against the neutral Agent contract. Messages, tools, and state ported. Both defects we found were ours.' |
| 4 | +date: 2026-08-31 |
| 5 | +tags: [ag-ui, angular, architecture, portability, agentic-ui] |
| 6 | +author: brian |
| 7 | +featured: false |
| 8 | +draft: false |
| 9 | +--- |
| 10 | + |
| 11 | +The previous post in this series ended on an admission rather than a conclusion. |
| 12 | + |
| 13 | +I had spent several thousand words showing that swapping the agent runtime under an Angular app costs one import line at the component level, and then I had to point out that every AG-UI backend in our repository was itself a LangGraph graph. |
| 14 | +A transport swap over one runtime. |
| 15 | +Not a runtime swap. |
| 16 | +I wrote that I believed the neutral contract would hold against a genuinely different runtime, and that belief is not measurement. |
| 17 | + |
| 18 | +So we measured it. |
| 19 | + |
| 20 | +Three runtimes that have no LangGraph anywhere in them: AWS Strands, Microsoft Agent Framework, and Mastra. |
| 21 | +Two languages. |
| 22 | +Real servers, driven by real model calls, with the Server-Sent Events captured off the wire and replayed through the shipped client. |
| 23 | + |
| 24 | +The short version is that the contract held where it mattered and broke in two places, and both of the breaks were ours. |
| 25 | + |
| 26 | +## What was actually run |
| 27 | + |
| 28 | +Each runtime got a server standing on its upstream AG-UI integration, not on anything we wrote to make the numbers look good. |
| 29 | +Microsoft Agent Framework runs behind `agent-framework-ag-ui`. |
| 30 | +Strands runs behind the community `ag-ui-strands` bridge, pinned to a git reference because the published release crashes on multi-agent routes. |
| 31 | +Mastra needed about two hundred lines of Node hosting code, because the upstream package ships an in-process bridge and a CopilotKit mount rather than a plain HTTP endpoint. |
| 32 | + |
| 33 | +Every server was driven with live model calls until it produced each surface we cared about: streaming text, a tool call, shared state, and a human approval. |
| 34 | +The raw event stream from each of those runs was saved. |
| 35 | +Those captures then went back through schema validation and the protocol client's own `verifyEvents`, and through the adapter reducer, event for event. |
| 36 | + |
| 37 | +That last step is the one that matters for a portability claim. |
| 38 | +A screenshot of a working demo proves that one path worked once. |
| 39 | +A committed transcript replayed through the real client proves what the runtime put on the wire, and it keeps proving it every time the test suite runs. |
| 40 | + |
| 41 | +## The result |
| 42 | + |
| 43 | +| Runtime | Messages | Tool calls | State | Interrupts | Subagents | |
| 44 | +| --- | --- | --- | --- | --- | --- | |
| 45 | +| LangGraph (baseline) | Yes | Yes | Yes | Yes | Yes | |
| 46 | +| AWS Strands | Yes | Yes | Partial | Yes | No | |
| 47 | +| Microsoft Agent Framework | Yes | Yes | Yes | Yes | No | |
| 48 | +| Mastra | Yes | Yes | Yes | Yes | No | |
| 49 | + |
| 50 | +Messages, tool calls, and shared state crossed three non-LangGraph runtimes with zero changes to the adapter. |
| 51 | +Not one line. |
| 52 | +The Angular side of each example is the same `injectAgent()` call and the same `<chat>` element that the LangGraph demos use. |
| 53 | + |
| 54 | +For me, that is the claim I actually wanted to test, and it is now a measurement rather than an argument from design. |
| 55 | + |
| 56 | +One honest note on the Mastra interrupt cell. |
| 57 | +It was verified live against a real model in the development harness, and its resume round trip is covered by tests that run the captured transcripts through the shipped protocol client. |
| 58 | +The production service for it does not exist yet. |
| 59 | +I am not going to call that cell production-proven, because it is not. |
| 60 | + |
| 61 | +## Both real failures were ours |
| 62 | + |
| 63 | +This is the part of the exercise I would keep if I had to throw the rest away. |
| 64 | + |
| 65 | +We went in expecting the negative cells to indict the protocol or the upstream bridges. |
| 66 | +The two hard failures indicted us. |
| 67 | + |
| 68 | +### We keyed interrupts on a convention, not on the protocol |
| 69 | + |
| 70 | +Our AG-UI adapter detected a human-in-the-loop pause by watching for a `CUSTOM` event with the name `on_interrupt`. |
| 71 | +I flagged that in the previous post as a fragility, and I was right about the fragility and wrong about the shape of the risk. |
| 72 | +I assumed a runtime that did not adopt the convention would simply have no interrupt support. |
| 73 | + |
| 74 | +What actually happens is worse. |
| 75 | + |
| 76 | +Strands and Microsoft Agent Framework both signal an interrupt the way the protocol says to: the run-finished event carries an outcome object naming the pending interrupts. |
| 77 | +Neither of them emits the custom event at all. |
| 78 | +Our handler for that event read the status and ignored the outcome. |
| 79 | + |
| 80 | +So the run did not fail. |
| 81 | +It finalized as a success, with a tool call sitting there waiting for an approval that the UI never asked for, and with the adapter's `interrupt` signal still undefined. |
| 82 | +A silent wrong answer beats a loud failure only in the sense that it takes longer to notice. |
| 83 | + |
| 84 | +The fix was small once the cause was clear, and it landed in [#888](https://github.com/cacheplane/angular-agent-framework/pull/888). |
| 85 | +Both conventions are now recognized, and within one run the first signal to arrive wins, because Mastra emits both and we do not want a doubled interrupt. |
| 86 | + |
| 87 | +I want to name the mistake precisely, because "we had a bug" is not the lesson. |
| 88 | +We built a protocol adapter and then tested it exclusively against one bridge implementation of that protocol. |
| 89 | +A bridge convention and a protocol primitive are different things, and a test suite that only ever sees one backend cannot tell you which of the two you depend on. |
| 90 | + |
| 91 | +### Our resume payload was shaped like LangGraph |
| 92 | + |
| 93 | +The second defect is the same mistake wearing different clothes. |
| 94 | + |
| 95 | +When the user approves the pending action, the adapter sends the resume value back. |
| 96 | +It sent exactly the shape the LangGraph bridge reads, nested under a command object in the forwarded properties. |
| 97 | +That works for LangGraph. |
| 98 | +It is a coincidence anywhere else. |
| 99 | + |
| 100 | +Mastra needs the interrupt's identity carried with the value, specifically the tool-call id and the run id of the suspended run, or it cannot find the suspension to resume. |
| 101 | +Microsoft Agent Framework reads a top-level resume array before it looks at the forwarded properties, and it wants an entry addressing every pending interrupt. |
| 102 | +Send it nothing addressable and it raises an error rather than guessing. |
| 103 | + |
| 104 | +Three runtimes, three answers to the question of how a resume identifies itself. |
| 105 | + |
| 106 | +The adapter now derives the outgoing shape from how the interrupt arrived, which is the only signal available that is not a hardcoded runtime name. |
| 107 | +That work is in [#889](https://github.com/cacheplane/angular-agent-framework/pull/889), and [#891](https://github.com/cacheplane/angular-agent-framework/pull/891) upgraded the protocol packages so the standard top-level resume array became sendable at all. |
| 108 | +The application-facing call did not change. |
| 109 | +You still write `submit({ resume })` and the adapter decides what goes on the wire. |
| 110 | + |
| 111 | +Both defects share a root. |
| 112 | +Neither would have been found by another demo, another end-to-end test, or another review of our own code. |
| 113 | +They were only findable by pointing the adapter at software written by people who had never heard of us. |
| 114 | + |
| 115 | +## What stayed red, and why that is not a bug list |
| 116 | + |
| 117 | +Two cells did not go green, and I would rather explain them than quietly leave them off the page. |
| 118 | + |
| 119 | +**Shared state on Strands is partial.** |
| 120 | +Its bridge sends whole-document snapshots and never sends a patch, and a tool only contributes state if it opts in through a per-tool hook. |
| 121 | +Because a snapshot replaces the document rather than merging into it, every hook has to return the entire state object. |
| 122 | +A hook that returns just the key it touched will erase its siblings. |
| 123 | +That is a real hazard, it is documented in the example, and it is a property of the upstream bridge rather than of the protocol or of us. |
| 124 | + |
| 125 | +**Subagents are red for all three.** |
| 126 | +This one surprised me, and it is the finding I would most want another framework author to read. |
| 127 | + |
| 128 | +It is tempting to write that as three bugs. |
| 129 | +It is not. |
| 130 | +Strands models delegation as a handoff custom event alongside step boundaries. |
| 131 | +Mastra treats the activity event family as belonging to background work and observational memory, which is a defensible reading of the same specification. |
| 132 | +Microsoft Agent Framework does emit activity snapshots, but at the granularity of an executor, and it never emits the incremental form. |
| 133 | + |
| 134 | +Three runtimes, three different mental models of what a subagent is, and none of them wrong on their own terms. |
| 135 | +Meanwhile our own projection keys on an activity type of `subagent`, which is a convention our demo backends adopt and nobody else has any reason to. |
| 136 | +The protocol grew dedicated subagent events in version 0.0.59, and as of this measurement no runtime emits them. |
| 137 | + |
| 138 | +I think that is an ecosystem finding, not a defect report. |
| 139 | +Delegation is the surface where the AG-UI vocabulary is agreed on in syntax and not yet in meaning. |
| 140 | +If you are building on server-declared subagents today, build on a backend you control, and know that you are ahead of the ecosystem rather than portable across it. |
| 141 | + |
| 142 | +## The finding nobody asked for |
| 143 | + |
| 144 | +Measuring the deploy path surfaced something unrelated and much worse than anything in the matrix. |
| 145 | + |
| 146 | +One of our AG-UI demo topics had been dead in production for two and a half months. |
| 147 | +Its imports were written in a form that resolves when the topic is served from its own directory in development, and does not resolve in the aggregated deployment layout, so every image built since the middle of June crashed on startup. |
| 148 | +The deploy command uploads and detaches, which means it reports success at upload time. |
| 149 | +The platform kept serving the last image that booted. |
| 150 | +Nothing was red anywhere, and the route had been returning a not-found for months. |
| 151 | + |
| 152 | +The fix in [#899](https://github.com/cacheplane/angular-agent-framework/pull/899) was two import statements. |
| 153 | +The part worth keeping is the second half: the deploy workflow now installs the exact dependency set, imports the server, and fails the job if it cannot boot. |
| 154 | + |
| 155 | +Verify the route, not the exit code. |
| 156 | +A deploy step that exits zero has told you that a file was uploaded. |
| 157 | +It has not told you that anything is running, and if your platform keeps the previous image alive on failure, the absence of an alarm is not evidence. |
| 158 | + |
| 159 | +## Where this leaves the claim |
| 160 | + |
| 161 | +The neutral `Agent` contract is portable across runtimes for messages, tool calls, and state, and that is now measured against three implementations in two languages rather than asserted from the shape of the types. |
| 162 | + |
| 163 | +Interrupts are portable as of these fixes, and they were not before, in a way no amount of internal testing would have revealed. |
| 164 | + |
| 165 | +Subagents are not portable today, and the reason is upstream disagreement rather than a missing feature on anyone's list. |
| 166 | + |
| 167 | +The matrix now lives in the [adapter guide](/docs/choosing-an-adapter) with a cause column on every gap, split three ways: the protocol cannot express it, the upstream bridge does not emit it, or our adapter failed to consume it. |
| 168 | +That third category is the one I care about keeping honest, and it is currently empty. |
| 169 | + |
| 170 | +The transcripts are committed as test fixtures, verbatim from the wire. |
| 171 | +Interrupt detection and resume shaping are now pinned to what three real runtimes actually sent us on a specific day. |
| 172 | +The next regression in either one fails a test. |
| 173 | + |
| 174 | +That is the difference between a portability claim and a portability guarantee, and it is the only part of this work I would insist on. |
0 commit comments