Skip to main content

Debug silent deadlocks in Microsoft Agent Framework workflows by preventing loop jams, controlling agent handoffs, reducing token waste, and improving multi-agent reliability

Debugging PlaybookAgent FrameworkMulti-AgentHandoff OrchestrationPython / .NET

The Multi-Agent Loop Jam: Debugging Silent Deadlocks
in Microsoft Agent Framework Workflows

Nothing crashes. No exception is thrown. No alert fires. Your triage agent hands off to refund, refund hands back to triage, and the two of them politely pass the same ticket between each other several hundred times while your token bill climbs. This is the loop jam — and because it is silent, the fix is never "catch the error." It is to bound the workflow before it ever starts.

The failure signature this guide resolves
# There is NO stack trace. That is the entire problem. What you see instead
# is a repeating cycle in the OpenTelemetry GenAI trace, and a flat token graph:

TIMESTAMP     SPAN                              agent.name   duration
09:14:02.114  invoke_agent triage               triage        1,204 ms
09:14:03.318  execute_tool handoff_to_refund    triage           12 ms
09:14:03.330  invoke_agent refund               refund        1,890 ms
09:14:05.220  execute_tool handoff_to_triage    refund           11 ms   <-- back
09:14:05.231  invoke_agent triage               triage        1,150 ms   <-- again
09:14:06.381  execute_tool handoff_to_refund    triage           12 ms
09:14:06.393  invoke_agent refund               refund        1,932 ms
...  (repeats 300+ times, identical, forever)

# If — and only if — a bound was configured, you finally get an honest failure:
WorkflowRunError: workflow did not converge: reached max_iterations (limit=25)

# With no bound configured, the run never terminates. It just spends.

Symptom: A workflow run that never returns, with steadily climbing token spend and no exception.  Failure point: A cycle in the handoff graph — agent A can hand off to B, and B can hand back to A, with no condition that terminates the exchange.  Default platform behaviour: The Agent Framework Runner executes a Pregel-style superstep loop that continues until convergence — meaning no more messages are in flight — or until max_iterations is reached. A ping-ponging pair of agents never stops producing messages, so it never converges. If you did not set a bound, there is nothing to stop it.

No exception
A loop jam is not a crash. Nothing throws, nothing alerts — which is precisely why it reaches production and runs for hours
Convergence
The Runner's superstep loop only ends when no messages remain in flight — or when an explicit iteration bound stops it
N × tokens
Every agent in the cycle bills independently. A two-agent ping-pong burns both agents' context on every single lap
4 bounds
Iteration count, termination condition, stall detection, and wall-clock timeout. Defence in depth — no single one is sufficient

Distributed systems engineers have a well-developed instinct for deadlock: two threads, two locks, neither yields, everything stops. A multi-agent loop jam is stranger and, in one important way, worse. Nothing stops. The system is alive — agents are reasoning, tools are firing, spans are streaming into your tracing backend, the workflow reports itself as healthy and running. It is simply not making any progress, and every lap of the loop costs you real money in tokens. There is no exception to catch, no timeout to fire, no failed health check. The workflow will happily do this until you kill the process or your quota runs out. Debugging it starts with accepting that you are not looking for an error — you are looking for a cycle.

Figure 1 — The cycle is in your graph: every bidirectional handoff pair is a potential loop
A TYPICAL SUPPORT HANDOFF GRAPH — drawn exactly as most teams build itTRIAGEcoordinatorREFUNDspecialistORDERspecialist⟳ CYCLE⟳ CYCLE⟳ CYCLE.add_handoff(triage, [refund]).add_handoff(triage, [order]).add_handoff(refund, [triage]).add_handoff(refund, [order]).add_handoff(order, [triage]).add_handoff(order, [refund])Every return edge closes a loop.Nothing here says when to STOP.The graph is not wrong — return edges are what make handoff useful. The bug is that no bound or termination condition constrains them.
This is a faithful rendering of a standard Agent Framework handoff workflow. Return edges (refund→triage, order→triage) are deliberate and necessary — a specialist must be able to hand a case back. But each one closes a cycle, and a cycle plus an autonomous interaction mode plus no termination condition equals an infinite loop. The graph is a feature; the missing bound is the defect.
01What a "Silent Deadlock" Actually IsConcept

The term is borrowed, and slightly wrong — which is useful, because the difference is the whole diagnosis. A classical deadlock halts. A multi-agent loop jam runs forever. It is closer to a livelock: the system is fully alive, doing work, making calls, and getting precisely nowhere.

To understand why nothing stops it, you need one fact about how Agent Framework executes a workflow. The Runner implements a Pregel-style superstep loop: in each superstep it delivers all pending messages to their target executors, runs those executors concurrently, and collects whatever new messages they emit. It repeats this until one of two things happens:

  • Convergence — a superstep produces no new messages, so there is nothing left to deliver. The workflow is done.
  • max_iterations is reached — an explicit bound stops the loop.

Now consider two agents in a handoff cycle. Triage emits a message to refund. Refund emits a message back to triage. Every superstep always produces a new message. Convergence is never reached. And if you did not configure an iteration bound, the second exit condition does not exist either. The loop has no way out — not because of a bug in the framework, but because you built a graph with a cycle and gave the runtime no instruction about when to stop traversing it.

Why it is so much worse than a crash

A crash is loud, fast, and free. A loop jam is quiet, slow, and metered. Your monitoring shows a healthy service. Your workflow reports "running." And every lap of the cycle invokes every agent in it, each consuming its own context window and billing its own tokens independently. A four-agent pipeline uses roughly four times the tokens of a single agent — and a jammed four-agent cycle keeps doing that, indefinitely. Teams routinely discover this from a billing alert rather than an application alert.

02The Four Ways a Handoff Workflow JamsDiagnosis

Not every jam has the same cause, and the four have genuinely different fixes. Identify which one you have before you reach for a bound, because bounding a workflow that is jamming for reason 3 just converts an infinite loop into a truncated failure — the underlying agent is still confused.

Jam typeWhat it looks like in the traceRoot causePrimary fix
Ping-pongA → B → A → B, identical spans, foreverA cycle in the handoff graph with no termination conditionTermination condition + max_iterations
Hot potatoA → B → C → A, a longer ring, no repetition of contentVague agent instructions — no agent believes the task is theirsSharpen instructions; make boundaries explicit
StallSame agent invoked repeatedly, output barely changesThe agent cannot make progress but keeps being re-selectedMagentic max_stall_count → auto-replan
Tool loopOne agent, the same tool call repeating with the same argsAgent retries a failing tool forever, or re-reads state it never changesTool-level retry cap + state clearing
The instruction-quality trap

The "hot potato" jam is the one that fools people, because it looks like a framework problem and is not. In handoff patterns, agents decide when to transfer based on their instructions. Vague instructions lead to agents trying to hand everything on rather than handle it — or, just as bad, trying to handle everything themselves. Be explicit about boundaries: "If the customer asks about X, transfer to Y_agent; otherwise resolve it yourself and end the conversation." No iteration bound fixes an agent that does not know what its job is; it just caps how long it flails.

03The SDK Split: Microscope vs FixCorrection

There is a widespread misconception worth clearing up before any code, because it sends people to the wrong library and wastes days: azure-ai-projects does not provide loop control. It has no step counter, no iteration cap, no termination primitive. If you go looking for one there, you will not find it.

The two SDKs do genuinely different jobs, and you need both:

PackageIts actual role hereWhat it gives you
agent_frameworkThe fix. All loop control lives heremax_iterations on the Runner; termination_condition on HandoffBuilder; max_round_count / max_stall_count / max_reset_count on MagenticBuilder; checkpointing
azure-ai-projectsThe microscope. How you see the loopAIProjectClient; project.telemetry.get_application_insights_connection_string() to wire OpenTelemetry traces into App Insights
azure-monitor-opentelemetryThe exporterconfigure_azure_monitor() — ships the GenAI spans that reveal the cycle
asyncio / CancellationTokenThe backstopWall-clock timeout around the workflow run — the bound that holds when all others fail
The right mental model

Use azure-ai-projects to find the loop — it connects your traces to Foundry observability so the repeating A→B→A pattern becomes visible in Application Insights. Then use agent_framework to bound the loop — its orchestration builders are where every guardrail actually lives. Diagnosis and remediation are in different packages, and conflating them is why so many teams get stuck.

Architectural Topology: Failing vs Remediated

Every jammed multi-agent workflow is missing the same four things. This is the target state the fixes below construct.

ControlFailing configuration (current)Remediated configuration (fix)
Iteration boundNone — Runner loops until convergence, which never comesmax_iterations set on the workflow Runner
Termination conditionAbsent — no function decides when the conversation is donetermination_condition passed to HandoffBuilder
Stall detectionNone — a stuck agent is re-selected indefinitelyMagentic max_stall_count triggers auto-replan
Wall-clock timeoutNone — the run can outlive the request, the pod, the dayasyncio.wait_for(...) / CancellationToken around the run
Agent instructionsVague — "help the customer" — so nobody owns the taskExplicit boundaries and an explicit "you may end the conversation"
ObservabilityNo tracing — the loop is invisible until the bill arrivesOTel GenAI spans → App Insights via AIProjectClient
Cost guardUnbounded token spend per runAlert on runs exceeding an iteration/token threshold
05Step 1 — Make the Loop Visible (OpenTelemetry + azure-ai-projects)Diagnosis

You cannot fix a cycle you cannot see, and a loop jam produces no error to look at. So the first move is always instrumentation. Agent Framework emits traces, logs, and metrics according to the OpenTelemetry GenAI semantic conventions, which means every agent invocation and every tool call becomes a span with standard attribute names. That is exactly what you need — the repeating invoke_agent / execute_tool pattern is the bug, rendered as data.

This is where azure-ai-projects earns its place: AIProjectClient hands you the Application Insights connection string attached to your Foundry project, so you do not have to plumb it manually.

Python — wire Agent Framework traces into App Insights via AIProjectClientimport os from azure.ai.projects import AIProjectClient from azure.identity import DefaultAzureCredential from azure.monitor.opentelemetry import configure_azure_monitor from opentelemetry import trace # Enable GenAI tracing instrumentation for agent applications os.environ["AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING"] = "true" endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] with ( DefaultAzureCredential() as credential, AIProjectClient(endpoint=endpoint, credential=credential) as project, ): # THIS is what azure-ai-projects is for: it hands you the App Insights # connection string already attached to the Foundry project. conn = project.telemetry.get_application_insights_connection_string() configure_azure_monitor(connection_string=conn) tracer = trace.get_tracer(__name__) # Wrap the whole workflow run in one parent span so every agent hop # and tool call is a child — the cycle becomes obvious in the timeline. with tracer.start_as_current_span("support-workflow-run"): await workflow.run(task)
Python — add your own loop counter as a metric (the cheapest early-warning system)from agent_framework.observability import get_tracer, get_meter meter = get_meter() handoff_counter = meter.create_counter("agent.handoff.count") # Increment on every handoff, tagged with the edge that was taken. # A healthy run shows a handful. A jam shows a straight diagonal line. handoff_counter.add(1, {"from": from_agent, "to": to_agent, "run_id": run_id})

Once spans are flowing, this KQL finds the jam. You are not looking for errors — you are looking for repetition.

KQL — find runs where the same agent was invoked far too many times// Any run that invokes a single agent more than a sane number of times // is looping. Tune the threshold to your workflow's real ceiling. dependencies | where timestamp > ago(24h) | where name startswith "invoke_agent" | extend runId = tostring(customDimensions["gen_ai.conversation.id"]), agent = tostring(customDimensions["gen_ai.agent.name"]) | summarize invocations = count(), agents = dcount(agent), firstSeen = min(timestamp), lastSeen = max(timestamp) by runId | extend runMinutes = datetime_diff('minute', lastSeen, firstSeen) | where invocations > 20 // suspicious | order by invocations desc
KQL — expose the actual cycle: which edge is repeating?// Counts each handoff edge per run. A ping-pong shows two edges with // near-identical high counts (A->B and B->A). That is your cycle. dependencies | where timestamp > ago(24h) | where name startswith "execute_tool" and name contains "handoff" | extend runId = tostring(customDimensions["gen_ai.conversation.id"]), edge = tostring(customDimensions["gen_ai.tool.name"]) | summarize hops = count() by runId, edge | where hops > 5 | order by runId, hops desc
Do not put raw prompts in production spans

Agent Framework can record message content in traces, and it is genuinely useful when debugging a loop — you can see the agents talking past each other. But content recording captures prompts, responses, and function-call arguments. Enable it in development or a scrubbed staging environment, not in production, or you will pipe customer PII straight into Application Insights. Diagnose the loop with content on in a repro; run production with metadata-only spans.

06Fix 1 — Bound the Runner with max_iterationsRemediation

This is the backstop, and it should be non-negotiable on every workflow you ship. It does not make your agents smarter — it converts an infinite loop into a bounded failure, which is the difference between an incident and a log line. Set it even when you are confident your graph is acyclic.

Recall the mechanic: the Runner loops supersteps until convergence or until max_iterations is reached. Setting it gives the second exit condition an actual value.

Python — bound the workflow, and fail loudly rather than silentlyfrom agent_framework import WorkflowBuilder workflow = ( WorkflowBuilder() .set_start_executor(triage_executor) .add_edge(triage_executor, refund_executor) .add_edge(refund_executor, triage_executor) # the return edge = the cycle .build() ) # The hard ceiling. If the graph does not converge in N supersteps, # stop and raise — do NOT let it run. result = await workflow.run(task, max_iterations=25)
.NET — the equivalent iteration ceiling// Microsoft.Agents.AI.Workflows — cap the orchestration. // MaximumIterationCount stops the workflow after N rounds, which is // what prevents an infinite loop of agents arguing with each other. var options = new GroupChatOptions { MaximumIterationCount = 10 };
Choose the number deliberately, then alert on it

Pick max_iterations from your workflow's real worst case — count the longest legitimate path through your graph and add headroom, rather than reaching for a round number. Then treat every hit as a defect, not a routine outcome: a workflow that regularly terminates at its iteration cap is not "protected," it is broken and being truncated. Alert on the cap being reached and go fix the underlying cycle.

07Fix 2 — Termination Conditions on Handoff OrchestrationRemediation

An iteration cap is a blunt instrument — it stops the loop but knows nothing about whether the work is done. A termination condition is the semantic fix: a function that examines the conversation and decides when the workflow should end. This is the mechanism the framework provides specifically to prevent infinite loops and ensure conversations reach a natural conclusion.

Crucially, HandoffBuilder accepts it as a first-class parameter. If you built a handoff workflow and left it out, this is your bug.

Python — a handoff workflow with a real termination conditionfrom agent_framework.orchestrations import HandoffBuilder def termination_condition(conversation: list) -> bool: """Return True when the workflow should STOP. Belt and braces: end on an explicit resolution signal, and also hard-stop on length so a confused agent cannot spin forever. """ if not conversation: return False # 1. Semantic exit — an agent declared the case resolved. last = conversation[-1] text = (getattr(last, "text", "") or "").upper() if "CASE_RESOLVED" in text or "ESCALATE_TO_HUMAN" in text: return True # 2. Structural exit — the conversation has simply gone on too long. if len(conversation) >= 20: return True return False builder = HandoffBuilder( name="support_workflow", participants=[triage, refund, order], termination_condition=termination_condition, # NOT optional in production ) ( builder .add_handoff(triage, [refund], description="Refunds and damaged-item claims.") .add_handoff(triage, [order], description="Replacements, exchanges, shipping.") .add_handoff(refund, [triage], description="Final case closure after refund.") .add_handoff(order, [triage], description="After replacement tasks complete.") ) workflow = builder.with_start_agent(triage).build()
The termination condition only works if an agent can actually trigger it

A condition that looks for CASE_RESOLVED is useless if no agent has been told to emit it. This is the single most common way this fix silently fails. Your agent instructions must close the loop explicitly — for example: "When the customer's issue is fully resolved, reply with CASE_RESOLVED and do not hand off to another agent." The termination condition and the agent instructions are one design, not two. Write them together.

Consider interactive mode for high-stakes graphs

Handoff workflows run in autonomous mode by default — agents operate without waiting for user input between turns, which is exactly what lets a cycle run unattended. Switching the interaction mode to interactive pauses for human input during the conversation, which naturally bounds the loop with a human in it. It is not right for every workflow, but for anything expensive or irreversible it turns an unattended jam into a prompt.

08Fix 3 — Magnetic Guardrails and Stall DetectionRemediation

The stall jam is different from the ping-pong, and no iteration cap addresses it properly. A stall is when the same agent keeps getting selected and keeps producing near-identical, non-advancing output. The loop is not bouncing between agents — it is grinding in place.

Magnetic orchestration is built for exactly this. A manager agent turns the task into a plan, assigns work to specialists, checks progress after each round, and revises the plan when the team stops moving. It ships with three distinct bounds, and they guard three genuinely different failure modes.

GuardrailWhat it boundsWhat happens on hit
max_round_countTotal rounds of manager-directed workHard stop — the ceiling on the whole orchestration
max_stall_countConsecutive rounds with no detectable progressTriggers an automatic re-plan rather than grinding on
max_reset_countHow many times the manager may re-planHard stop — prevents infinite re-planning loops
Python — MagenticBuilder with all three bounds set explicitlyfrom agent_framework import MagenticBuilder workflow = ( MagenticBuilder() .participants([researcher_agent, coder_agent, analyst_agent]) .with_manager( agent=manager_agent, max_round_count=10, # total rounds — the hard ceiling max_stall_count=3, # no progress for 3 rounds -> re-plan max_reset_count=2, # at most 2 re-plans, then give up ) .build() )

Note the layering: max_stall_count is a recovery mechanism (it re-plans and tries a different approach), while max_round_count and max_reset_count are termination mechanisms. Without max_reset_count, a manager that stalls, re-plans, stalls again, and re-plans again has simply moved the infinite loop up one level of abstraction.

Add human plan review for expensive workflows

Magentic supports .with_plan_review(), which emits the manager's plan as a request for human input before execution begins. For a workflow that will spend real money across many agents, reviewing the plan up front is dramatically cheaper than discovering after 300 laps that the manager's plan was circular from the start. It bounds the loop at the design stage rather than at runtime.

Figure 2 — Defence in depth: four independent bounds, because any one of them can be wrong
EACH LAYER CATCHES WHAT THE ONE BEFORE IT MISSED1 · SEMANTICtermination_condition"the work is DONE"Fails if no agentever signals done2 · PROGRESSmax_stall_count"we're not moving"Fails if the loopLOOKS productive3 · STRUCTURALmax_iterations"too many rounds"Fails if a singleround hangs forever4 · WALL CLOCKasyncio.wait_for"out of time"The bound thatalways holdsLayers 1–3 are semantic and can all be defeated by an agent that behaves in a way you did not anticipate.Layer 4 cannot be reasoned around — it is a clock. Never ship a multi-agent workflow without it.✗ NO BOUNDSRuns until the process diesor the quota does. Silent. Metered.✓ FOUR BOUNDSTerminates cleanly, raises loudly,and tells you WHICH bound caught it.
The four bounds are not alternatives — they are layers. A termination condition fails when no agent signals completion. Stall detection fails when the loop looks productive. An iteration cap fails when a single round hangs. Only the wall-clock timeout cannot be reasoned around by a misbehaving agent, which is why it is the one you must never omit.
09Fix 4 — Wall-Clock Timeouts and State ClearingBackstop

Every bound so far is semantic — it depends on the agents, the manager, or the graph behaving as you expect. The wall-clock timeout is the only one that does not. It is a clock. An agent cannot talk its way past it, and it is the bound that holds when your termination condition has a typo in it.

Bound the run in real time

Python — the outermost bound: a hard wall-clock timeoutimport asyncio async def run_support_workflow(task: str, run_id: str): try: # Nothing inside can outlive this. Not a stuck tool, not a hung # model call, not a cycle that defeated every other guardrail. return await asyncio.wait_for( workflow.run(task, max_iterations=25), timeout=120, # seconds — tune to your real p99 ) except asyncio.TimeoutError: # Fail LOUDLY. A silent jam becomes an alertable event. logger.error( "loop_jam_suspected", extra={"run_id": run_id, "timeout_s": 120}, ) await clear_run_state(run_id) # see below — do not leak the state raise
.NET — the same backstop with a CancellationTokenusing var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); try { var result = await workflow.RunAsync(task, cts.Token); } catch (OperationCanceledException) { // The wall-clock bound fired. Treat as a defect, not a normal path. logger.LogError("Loop jam suspected: workflow exceeded 120s for run {RunId}", runId); throw; }

Clear the state, or the next run inherits the jam

A jammed run leaves debris: a conversation thread bloated with hundreds of circular messages, and a checkpoint that encodes the looping state. If you resume from that checkpoint or reuse that thread, you resume the loop. Agent Framework's workflows support checkpointing so state can be saved and resumed — which is a feature for human-in-the-loop pauses and a liability after a jam.

Python — discard the poisoned state after a jamasync def clear_run_state(run_id: str) -> None: """After a loop jam, DESTROY the state. Never resume it. Resuming a checkpoint taken mid-cycle re-enters the cycle at the point it was interrupted — you have restarted the loop, not fixed it. """ # 1. Drop any checkpoint captured during the jammed run. await checkpoint_store.delete(run_id) # 2. Abandon the conversation thread — it now contains hundreds of # circular messages that will poison the context of any retry. await thread_store.delete(run_id) # 3. Retry, if you retry at all, from a CLEAN thread with the # original task — never from the accumulated conversation.
Never retry a loop jam by resuming the checkpoint

This is the failure that turns one bad run into a bad afternoon. The instinct after a timeout is to resume from the last checkpoint. But the checkpoint is the loop, frozen mid-lap. Resuming it re-enters the cycle with a conversation history that is now even longer — so the retry is slower, more expensive, and equally infinite. If you retry, retry from the original task with a clean thread, and only after you have changed something (a bound, an instruction, a graph edge). Retrying an unchanged jam is just paying twice.

Validation & Verification: Confirm the Fix

The whole failure mode is silence, so "it seemed fine" is not validation. You have to deliberately provoke the loop and prove that each bound catches it. Three steps: force a jam, confirm it terminates, then confirm it terminates loudly.

Step 1 — Provoke a jam on purpose with an adversarial task# A task that no single agent can own is the reliable way to force # a ping-pong. If your bounds work, this MUST terminate. import time, pytest @pytest.mark.asyncio async def test_loop_jam_is_bounded(): # Deliberately ambiguous: sits exactly between refund and order. task = "I want to return this but also keep it. Sort it out between you." start = time.monotonic() with pytest.raises((asyncio.TimeoutError, WorkflowRunError)): await run_support_workflow(task, run_id="loopjam-test-1") elapsed = time.monotonic() - start # PASS: it raised, AND it did so within the wall-clock bound. assert elapsed < 130, f"workflow ran {elapsed}s — a bound did not hold"
Step 2 — Assert the handoff count never exceeds the ceiling# Count real handoffs, not wall time. This catches a loop that is fast # enough to finish inside the timeout but is still clearly cycling. handoffs = [e for e in captured_events if e.type == "handoff"] assert len(handoffs) <= 25, ( f"handoff count {len(handoffs)} exceeded max_iterations — " "the iteration bound is not being enforced" ) # And assert no single EDGE was traversed repeatedly — that is the cycle. from collections import Counter edges = Counter((h.from_agent, h.to_agent) for h in handoffs) worst_edge, worst_count = edges.most_common(1)[0] assert worst_count <= 3, f"edge {worst_edge} traversed {worst_count}x — cycle present"
Step 3 — Confirm the jam is now VISIBLE in Application Insights (KQL)// After deploying the fix, no run should show a runaway invocation count. // Run this over the period following the change and compare to before. dependencies | where timestamp > ago(24h) | where name startswith "invoke_agent" | extend runId = tostring(customDimensions["gen_ai.conversation.id"]) | summarize invocations = count() by runId | summarize totalRuns = count(), runsOver25 = countif(invocations > 25), worstRun = max(invocations), medianRun = percentile(invocations, 50) // PASS: runsOver25 == 0, and worstRun sits at or below your max_iterations. // FAIL: any run exceeding the cap means a bound is not wired in.
What "fixed" actually means here

Be precise about the success criterion, because it is easy to declare victory too early. Bounds do not mean your agents now cooperate correctly — they mean a failure to cooperate is now finite, loud, and cheap instead of infinite, silent, and metered. A workflow that terminates at its iteration cap on every run is not fixed; it is being truncated, and you still have a broken graph or vague instructions underneath. The bounds are a safety net, not the design. Alert on every bound hit and treat each one as a bug to investigate — the day your caps stop firing is the day the workflow is actually correct.

Key Takeaways

A loop jam is a livelock, not a deadlock. Nothing halts and nothing throws — the system is fully alive, burning tokens, making no progress. You are not looking for an error; you are looking for a cycle.
The Runner only stops on convergence or max_iterations. Two agents handing off to each other never stop producing messages, so convergence never arrives. Without an iteration bound, there is no exit condition at all.
azure-ai-projects is the microscope, not the fix. It has no loop control. Use it to wire OpenTelemetry traces into App Insights so the cycle becomes visible; use agent_framework for every actual guardrail.
A termination condition is useless if no agent can trigger it. If your condition looks for CASE_RESOLVED, an agent must be instructed to emit it. The condition and the instructions are one design.
Layer four independent bounds. Termination condition, stall detection, iteration cap, and wall-clock timeout. The first three are semantic and can be defeated by an agent behaving unexpectedly; only the clock cannot be argued with.
Never resume a checkpoint taken during a jam. The checkpoint is the loop, frozen mid-lap. Resuming it re-enters the cycle with an even longer context. Clear the state and retry from the original task — after changing something.

Frequently Asked Questions

Why doesn't Microsoft Agent Framework stop an infinite handoff loop automatically?
Because from the runtime's point of view, nothing is wrong. The workflow Runner executes a superstep loop that continues until convergence — a superstep in which no new messages are produced — or until an explicit max_iterations bound is hit. Two agents handing a task back and forth produce a new message on every single superstep, so convergence is never reached, and if you did not configure an iteration bound then the second exit condition simply has no value. The framework is doing exactly what you asked: traversing the graph you gave it. The cycle is in your graph, and the missing bound is in your configuration.
Can I use the azure-ai-projects SDK to add step counters and timeouts?
No — this is a common and costly misconception. azure-ai-projects provides AIProjectClient for working with Foundry projects and, importantly for this problem, for retrieving the Application Insights connection string that lets you export OpenTelemetry traces. It contains no loop-control primitives whatsoever. Every guardrail — max_iterations, termination_condition, max_round_count, max_stall_count, max_reset_count — lives in the agent_framework package. Wall-clock timeouts come from your own host code via asyncio.wait_for or a CancellationToken. Use azure-ai-projects to see the loop and agent_framework to bound it.
What's the difference between max_round_count, max_stall_count, and max_reset_count?
They bound three different things in Magentic orchestration. max_round_count is the total number of manager-directed rounds — a hard ceiling on the whole orchestration. max_stall_count counts consecutive rounds in which the manager detects no progress; hitting it triggers an automatic re-plan rather than a stop, so it is a recovery mechanism. max_reset_count then caps how many times the manager is allowed to re-plan, which is what stops a stall-replan-stall-replan cycle from becoming an infinite loop one level up. You want all three: stall detection to recover, and the two counts to guarantee termination.
My workflow hits max_iterations on almost every run. Is it fixed?
No — it is being truncated, which is not the same thing. Bounds exist to make a failure finite and loud, not to be a normal part of the control flow. If the cap fires routinely, you still have the underlying defect: most likely a cycle in your handoff graph with no termination condition, or agent instructions so vague that no agent believes the task is theirs to complete. Treat every bound hit as an alertable defect and go read the trace to see which edge is repeating. The bounds are the safety net; the graph design and the agent instructions are the actual fix. You will know the workflow is genuinely correct when the caps stop firing on their own.

Popular posts from this blog

Learn how to use Azure Chaos Studio to simulate data center outages, test Azure OpenAI failover, and validate AI app resiliency using KQL and CLI workflows

Resiliency Testing Chaos Studio Zone Down Azure OpenAI Failover Testing AI Resiliency: Using Azure Chaos Studio to Simulate Data Center Outages on Your LLM Every multi-region Azure OpenAI architecture diagram has a failover arrow drawn on it. Almost none of them have ever actually been triggered. The arrow is a hypothesis, confirmed only by a real outage — unless you deliberately cause a controlled one first, on your own schedule, with a rollback plan, instead of finding out during an incident that the failover you designed never quite worked the way the diagram promised. The failure signature this guide resolves # The gap this article closes — a real architecture review finding: Design doc, page 4: "In the event of a regional outage, Azure Front Door automatically routes traffic to the secondary Azure OpenAI deployment in West Europe, with an expected failover time under 60 seconds." Verification performed to support this claim: NONE. Last time this path was ac...

Improve AI application performance by reducing latency, optimizing embeddings, and lowering cloud inference costs

Performance Fix Foundry Local 1.2 Linux ARM64 Embeddings Offline ASR The Edge Latency Drop: Fixing Latency Spikes by Offloading Embeddings to Foundry Local 1.2 You are paying a full cloud round trip — network, TLS, queue, throttle risk — to turn a twelve-word search query into a vector. That is the most expensive way possible to do one of the cheapest computations in your stack. Foundry Local 1.2 now runs on Linux ARM64, which means embeddings and speech recognition can happen on a Raspberry Pi, a Jetson, or a Graviton instance — offline, unmetered, and in single-digit milliseconds. The failure signature this guide resolves # Application Insights — the embedding call, not the LLM, is your tail latency: name p50 p95 p99 calls/day POST /embeddings (cloud) 89 ms 412 ms 3,847 ms 1,240,000 POST /chat/completions (cloud) 940 ms 1,720 ms 2,910 ms 38,000 ^^^^^^^^ ...

Learn how to select Azure Files and Blob storage tiers, avoid early deletion fees, model costs, and automate lifecycle management for large file migrations.

Choosing the Right Azure Storage Tier for Large File Migrations The complete decision framework for storage tier selection during large file migrations — Azure Files tiers, Blob tiers, cost modelling, early deletion traps, lifecycle automation, and the 2026 changes that affect every migration running today. By Francis Avorgbedor | Azure Engineer  ·  July 14, 2026  ·  18 min read  ·  Storage Tiers · Cost Optimisation · Migration FA Francis Avorgbedor Azure Engineer  ·  SEVENAI  ·  Azure Field Notes 9 Distinct Azure storage tiers across Files and Blob — most engineers know only 3 15hrs Archive tier rehydration time at standard priority — the delay teams forget to plan for 128KB Minimum billable object size for Cool/Cold/Archive from July 2026 — a 32× trap for small files 70% How much retrieval and transaction fees add to a theoretical Archive storage bill The most expensive mistake I see on large Azure file migrations is not choosing the w...

Find the hidden Windows 10 system files consuming up to 500GB, including hibernation, shadow copies, backups, and WinSxS, with safe cleanup steps.

  The 500GB System File That Eats Your Hard Drive Something on your Windows 10 drive is consuming hundreds of gigabytes and the normal tools cannot find it. This guide identifies every known culprit — from hibernation files and shadow copies to runaway backups and the Windows component store — and tells you exactly what is safe to delete, what to leave alone, and what the commands actually do.

Learn safe methods to reset Azure virtual machines using managed disks while preserving critical workloads

How to Reset an Azure Virtual Machine to Factory Settings Using a Managed Disk Azure does not have a single "factory reset" button. What it does have is something better: the OS Disk Swap — a method that swaps out the corrupted or misconfigured OS disk for a clean Windows Server managed disk without deleting the VM, its NICs, its IP addresses, or any attached data disks. Here is how it works, when to use it, and the exact steps to execute it safely. FA Francis Avorgbedor Azure Engineer July 16, 2026 15 min read Azure VMs · Windows Server · Real-World Fix 3 Methods to achieve a clean Windows Server installation on an existing Azure VM ~15min Typical OS Disk Swap duration — VM retains its NICs, IPs, and data disks throughout 0 Data disks affected by an OS Disk Swap — data disks remain attached and untouched 1 Snapshot of the original OS disk you must take before starting — no exceptions Introduction Why Azure Does Not Have a Simple Factory Reset — and What to Do Instead On a ph...

Determine Windows 11 compatibility, upgrade requirements, costs, and performance expectations on older hardware

Can I Update My Old Computer to Windows 11 — and How Much Will It Cost? Your i7, 16GB RAM, 512GB SSD machine is powerful enough to run Windows 11 comfortably. The TPM 2.0 and Secure Boot wall is a security checkbox, not a performance ceiling. Here are two proven ways to get past it, what each one costs, and what you are trading away by doing so. $0 Cost of the Windows 11 licence if your existing Windows 10 is genuine — the upgrade remains free in 2026 2 Proven methods to bypass TPM 2.0 and Secure Boot — Rufus (easy) and Registry edit (manual) 25H2 Current Windows 11 version — all known bypass methods tested and confirmed working as of July 2026 Oct 2025 Windows 10 end of life — no more security updates. Staying on Windows 10 now carries real risk. First — Check Your BIOS Before Anything Else You Might Not Actually Need a Bypass Before running any bypass, open your BIOS and look at two settings. Many computers that fail the Windows 11 compatibility check have TPM 2.0 present in the hard...

Solve common AKS issues with practical troubleshooting techniques for networking, scaling, upgrades, and workloads

Troubleshooting Guide AKS Kubernetes Real Solutions kubectl Azure Kubernetes Service (AKS) Troubleshooting Guide: Real Solutions to Common Problems CrashLoopBackOff at 2am. Pods stuck Pending with no obvious cause. Nodes going NotReady mid-deployment. DNS resolution silently failing in production. Every AKS engineer encounters these — the difference between engineers who panic and engineers who stay calm is knowing the exact sequence of diagnostic commands to run. This guide gives you that sequence, the root cause analysis for each failure mode, and the fix. 3 commands 90% of AKS problems are diagnosed with the same three kubectl commands: describe pod, logs --previous, and get events — in that order, every time Exit 137 The exit code that tells you everything: container killed by SIGKILL — either the Linux OOM killer (memory limit exceeded) or kubelet after grace period expired 5 min The CrashLoopBackOff ceiling: Kubernetes applies exponential backoff (10s → 20s → 40s → 80s → 160s → 3...

Step-by-step guide to deploying scalable AI chatbots on Azure with OpenAI and App Service

Step-by-Step Guide Azure OpenAI App Service Production Python How to Deploy an AI Chatbot on Azure Using Azure OpenAI and App Service From zero to a production-grade AI chatbot: provision Azure OpenAI, write a streaming Flask API backend, deploy it on Azure App Service with Managed Identity, wire in conversation history and content safety, and instrument it with Application Insights — all with complete code and Terraform IaC. No API keys in environment variables. No hardcoded secrets. No half-finished PoC patterns. 7 phases This guide covers the full deployment lifecycle: architecture design → resource provisioning → backend code → App Service deployment → streaming → security → monitoring Zero keys The chatbot authenticates to Azure OpenAI using Managed Identity and DefaultAzureCredential — no API keys stored in environment variables, Key Vault, or code SSE Server-Sent Events stream GPT tokens to the browser as they generate — the same token-by-token typing effect users expect from pr...