Advanced AI Agents
A deep-dive walkthrough of building agents that survive production — covering the ReAct architecture, planning and execution loops with replanning, tool selection at scale, tool schema design (including two-phase tools), state management with event sourcing, short- and long-term memory, workflow orchestration, durable agent execution with Azure Durable Functions, checkpointing and resumability, human approval workflows, multi-agent collaboration, supervisor-worker architectures, agent handoffs, communication protocols (MCP and A2A), failure recovery, infinite-loop prevention, budgets and iteration limits, and a decision framework for deterministic workflows versus autonomous agents.
Table of Contents
- Introduction
- ReAct Architecture
- Planning and Execution Loops
- Tool Selection
- Tool Schema Design
- State Management
- Short-Term and Long-Term Memory
- Workflow Orchestration
- Durable Agent Execution
- Checkpointing and Resumability
- Human Approval Workflows
- Multi-Agent Collaboration
- Supervisor-Worker Architecture
- Agent Handoffs
- Agent Communication Protocols
- Agent Failure Recovery
- Infinite-Loop Prevention
- Budget and Iteration Limits
- Deterministic Workflows vs. Autonomous Agents
- Testing and Observing Advanced Agents
- Putting It Together
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
The earlier AI Agents guide in this series covered what an agent is: a model, a control loop, tools, and guardrails, with budgets, security, and evaluation. That is enough to build a demo that works. It is not enough to build an agent that runs for twenty minutes without losing its place, survives a deployment in the middle of a task, waits three days for a manager's approval, coordinates five specialists without talking in circles, and never spends $400 chasing a bug in its own plan. Those are the problems of advanced agents, and almost all of them turn out to be distributed-systems and workflow problems wearing an AI costume: state, durability, idempotency, retries, ordering, timeouts, and contracts between components.
DEMO AGENT PRODUCTION AGENT one process, one request runs across crashes, deployments, and days state = the message list in memory state = typed, versioned, persisted, replayable tools = a handful of functions tools = dozens, schema-validated, idempotent, permission-aware stop when the model says "done" stop on success criteria — OR budgets, loop detectors, approvals, or failure policy one agent supervisors, workers, handoffs, typed contracts "retry if it breaks" classified failures, repair loops, compensation, escalation The thesis of this guide, in one line: put the intelligence in the model and the reliability in the code. The model decides what to try next; deterministic code owns state, limits, retries, persistence, permissions, and the shape of every message. Section 18 turns that into a decision framework for when to use a fixed workflow, when to use an autonomous agent, and — most often — how to combine them.
This guide builds on AI Agents, LLM Application Development, Semantic Kernel, Microsoft.Extensions.AI, AI Security, AI Observability, and AI Production / MLOps; it avoids repeating them and goes deeper on structure and reliability. Code uses C#, Microsoft.Extensions.AI (IChatClient, AIFunction), and, for durability, Azure Durable Functions (Durable Task) alongside a framework-neutral checkpointing design you can host anywhere.
A note on frameworks and versions. Agent frameworks are among the fastest-moving areas of the .NET AI stack (Semantic Kernel's agent and process frameworks, the Microsoft Agent Framework and its workflow/checkpointing features, hosted agent services, Durable Task, Temporal, Dapr Workflow, and Python-first tools like LangGraph). Protocol standards such as MCP and A2A are also evolving. The architecture and reliability principles are the durable part; verify API shapes, package names, and feature status against current documentation, and treat the code as templates. Thresholds and budgets are starting points to tune against your own evaluation set.
1. ReAct Architecture
Reason, act, observe — repeat
ReAct (Reason + Act, from a 2022 research paper) is the pattern behind most tool-using agents: the model alternates between reasoning about what to do, acting by calling a tool, and observing the result, feeding the observation into the next round of reasoning, until it can answer.
┌──────────────────────────────────────────────────────────────────────────────┐ │ THOUGHT "I need the customer's last order before I can check the refund policy."│ │ ACTION get_orders(customer_id = 7721, limit = 1) │ │ OBSERVATION { order: 10482, total: 89.00, status: "delivered", delivered: "Oct 3" }│ │ THOUGHT "Delivered 12 days ago; the policy allows refunds within 30 days. I should confirm that policy."│ │ ACTION search_policy("refund window for delivered orders") │ │ OBSERVATION "[policy-4:2] Refunds are allowed within 30 days of delivery..." │ │ THOUGHT "I have what I need." │ │ ANSWER "Yes — order 10482 is eligible for a refund until Nov 2. [policy-4:2]" │ └──────────────────────────────────────────────────────────────────────────────┘ Why it works
GROUNDING Each step is anchored by a real observation instead of the model's guess — fewer hallucinated facts. ADAPTIVITY The next action depends on what the last one returned (a tool error, an empty result, a surprising value). TRANSPARENCY The trace shows what was tried and why — invaluable for debugging, audit, and evaluation. GENERALITY Any capability that can be wrapped as a tool becomes available to the same loop. Two ways to implement it
1. NATIVE TOOL CALLING (the modern default) The model API returns structured tool calls (name + JSON arguments); your loop executes them and returns tool results. The "thought" may be hidden (reasoning models), emitted as ordinary text before the call, or absent. + Reliable structure (schemas, parallel calls), less parsing fragility, supported across providers via IChatClient. - Reasoning content may not be exposed; behavior is shaped by the provider's tool-use training. 2. TEXT-FORMAT ReAct (for models without tool calling, or when you want explicit visible reasoning) The prompt teaches a format — "Thought: ... / Action: tool_name / Action Input: {json} / Observation: ..." — and YOUR code parses the model's text, runs the tool, appends an "Observation:", and calls the model again. + Works with any text model; fully inspectable. - Brittle parsing; the model must be stopped before it hallucinates an Observation; no native parallel calls. The native loop, with explicit step records
public sealed record AgentStep(int Index, string? Thought, string? ToolName, string? ArgumentsJson, string? Observation, long Tokens, TimeSpan Duration); public sealed class ReActAgent(IChatClient llm, IReadOnlyList<AIFunction> tools, IToolPolicy policy, RunBudget budget, LoopDetector loops) { public async Task<AgentRunResult> RunAsync(string goal, string system, CancellationToken ct) { var messages = new List<ChatMessage> { new(ChatRole.System, system), new(ChatRole.User, goal) }; var steps = new List<AgentStep>(); var options = new ChatOptions { Tools = [.. tools], ToolMode = ChatToolMode.Auto, Temperature = 0.1f, MaxOutputTokens = 800 }; for (int i = 1; ; i++) { budget.Charge(steps: 1); // throws BudgetExceededException when a hard limit is hit (Section 17) var sw = Stopwatch.StartNew(); ChatResponse response = await llm.GetResponseAsync(messages, options, ct); // THINK budget.Charge(tokens: response.Usage?.TotalTokenCount ?? 0); messages.AddRange(response.Messages); var calls = response.Messages.SelectMany(m => m.Contents).OfType<FunctionCallContent>().ToList(); if (calls.Count == 0) return AgentRunResult.Completed(response.Text, steps); // no action requested: the agent believes it is done foreach (FunctionCallContent call in calls) // ACT + OBSERVE (through the policy gate) { loops.Observe(call.Name, call.Arguments); // detect repeated identical calls (Section 16) string observation = await ExecuteAsync(call, ct); messages.Add(new ChatMessage(ChatRole.Tool, [new FunctionResultContent(call.CallId, observation)])); steps.Add(new AgentStep(i, response.Text, call.Name, JsonSerializer.Serialize(call.Arguments), Truncate(observation, 500), response.Usage?.TotalTokenCount ?? 0, sw.Elapsed)); } } } } A text-format ReAct parser (when you need one)
// Expected model output (the prompt instructs: stop after "Action Input" and wait for the Observation): // Thought: I should look up the order. // Action: get_order // Action Input: {"orderNumber": 10482} // or: // Thought: I now have enough information. // Final Answer: Order 10482 is eligible for a refund. static readonly Regex ActionRx = new(@"Action:\s*(?<tool>[\w\-]+)\s*Action Input:\s*(?<input>\{.*\})", RegexOptions.Singleline | RegexOptions.Compiled); static readonly Regex FinalRx = new(@"Final Answer:\s*(?<answer>.+)$", RegexOptions.Singleline | RegexOptions.Compiled); static ParsedTurn Parse(string text) { if (FinalRx.Match(text) is { Success: true } f) return ParsedTurn.Final(f.Groups["answer"].Value.Trim()); if (ActionRx.Match(text) is { Success: true } a) { // Parse and VALIDATE the JSON — the model's text is untrusted input. Reject anything that isn't an allow-listed tool with schema-valid arguments. return ParsedTurn.Action(a.Groups["tool"].Value, a.Groups["input"].Value); } return ParsedTurn.Malformed(text); // feed an error back ("Respond in the required format") — bounded retries (Section 15) } // Configure the model call with a STOP SEQUENCE of "Observation:" so it cannot invent the tool's result. Variants worth knowing
PLAN-AND-EXECUTE Plan first, then execute (Section 2). More predictable than step-by-step improvisation. ReWOO "Reasoning WithOut Observation": the model writes the WHOLE tool plan upfront with placeholders for results (#E1, #E2), the system executes it, and the model is called again only to synthesize. Far fewer model calls and tokens than ReAct, at the cost of adaptivity (the plan can't react to surprises). REFLEXION After a failed attempt, the agent writes a short self-critique ("I searched by name but the field needs an ID") that is carried into the next attempt as memory. Improves repeated-attempt tasks. CodeAct The agent's "actions" are snippets of code run in a sandbox, composing many operations per step. Powerful for data tasks; requires strict sandboxing (see AI Security, Section 9). TREE SEARCH Explore several candidate action sequences and pick the best (LATS-style); expensive, for hard reasoning/planning tasks. What ReAct does not give you
RELIABILITY Nothing in the pattern prevents loops, runaway cost, wrong tools, or lost state — those are the rest of this guide. TRUSTWORTHY THOUGHTS A model's visible "reasoning" is a generated narrative, not a faithful record of its computation. Use it for debugging and UX, never as evidence that a decision was correct, and never as the security boundary. Don't log or expose hidden reasoning content carelessly (it can contain sensitive material). COST CONTROL Every iteration re-sends a growing context; long ReAct runs get expensive and degrade as the context fills (see Advanced LLM Engineering, Section 5). Compact observations; cap steps. 2. Planning and Execution Loops
Think about the whole task before touching anything
Pure ReAct decides one step at a time. For longer or riskier tasks, plan-and-execute separates concerns: a planner produces an explicit plan (data), validation code checks it, an executor runs the steps (mostly deterministically), and a monitor decides whether to continue, replan, or stop.
GOAL ──> PLAN (model, structured output) ──> VALIDATE (code: tools allowed? DAG valid? within budget?) ──> [HUMAN REVIEW if sensitive] ^ │ │ replan (bounded) v └─────────── MONITOR <── OBSERVE <── EXECUTE steps (parallel where independent; model only where judgment is needed) │ └─ goal met? ──> VERIFY against actual state ──> FINISH (or fail with a partial result) The plan as data: a dependency graph
public sealed record PlanStep( string Id, // "s1", "s2"... string Description, string Tool, // must be an allow-listed tool (or a sub-agent role) JsonElement Arguments, // may reference earlier results with placeholders like "{{s1.result}}" IReadOnlyList<string> DependsOn); // steps that must complete first public sealed record Plan(string Goal, IReadOnlyList<PlanStep> Steps); public sealed record StepResult(string StepId, bool Succeeded, string Output, string? Error); Validate the plan before running anything
public static class PlanValidator { public static IReadOnlyList<string> Validate(Plan plan, IReadOnlySet<string> allowedTools, int maxSteps) { var errors = new List<string>(); var ids = plan.Steps.Select(s => s.Id).ToList(); if (plan.Steps.Count == 0 || plan.Steps.Count > maxSteps) errors.Add($"Plan must have between 1 and {maxSteps} steps."); if (ids.Distinct().Count() != ids.Count) errors.Add("Step ids must be unique."); foreach (PlanStep s in plan.Steps) { if (!allowedTools.Contains(s.Tool)) errors.Add($"{s.Id}: tool '{s.Tool}' is not allowed."); foreach (string dep in s.DependsOn) if (!ids.Contains(dep)) errors.Add($"{s.Id}: unknown dependency '{dep}'."); } if (HasCycle(plan)) errors.Add("The plan contains a dependency cycle."); return errors; // empty = valid; non-empty goes BACK to the planner as feedback (bounded retries) } private static bool HasCycle(Plan plan) { var state = new Dictionary<string, int>(); // 0 = unvisited, 1 = visiting, 2 = done var byId = plan.Steps.ToDictionary(s => s.Id); bool Visit(string id) { if (state.GetValueOrDefault(id) == 1) return true; // back-edge: cycle if (state.GetValueOrDefault(id) == 2) return false; state[id] = 1; foreach (string dep in byId[id].DependsOn) if (byId.ContainsKey(dep) && Visit(dep)) return true; state[id] = 2; return false; } return plan.Steps.Any(s => Visit(s.Id)); } } Executing the DAG: parallel where independent
public sealed class PlanExecutor(IToolRunner tools, int maxParallel = 4) { public async Task<Dictionary<string, StepResult>> ExecuteAsync(Plan plan, RunBudget budget, CancellationToken ct) { var results = new Dictionary<string, StepResult>(); var pending = plan.Steps.ToDictionary(s => s.Id); using var gate = new SemaphoreSlim(maxParallel); while (pending.Count > 0) { // Steps whose dependencies have all succeeded are READY; run them concurrently (bounded) var ready = pending.Values.Where(s => s.DependsOn.All(d => results.TryGetValue(d, out var r) && r.Succeeded)).ToList(); if (ready.Count == 0) break; // blocked: a dependency failed -> let the monitor decide (replan or fail) var batch = await Task.WhenAll(ready.Select(async step => { await gate.WaitAsync(ct); try { budget.Charge(toolCalls: 1); string args = Substitute(step.Arguments.GetRawText(), results); // fill {{s1.result}} placeholders (ReWOO-style), escaping values safely return await tools.RunAsync(step.Id, step.Tool, args, ct); // returns a StepResult; tool exceptions become failed StepResults, not crashes } finally { gate.Release(); } })); foreach (StepResult r in batch) { results[r.StepId] = r; pending.Remove(r.StepId); } } return results; } } The monitor and the replan loop
public async Task<AgentRunResult> RunAsync(string goal, RunBudget budget, CancellationToken ct) { const int MaxReplans = 2; Plan plan = await planner.CreatePlanAsync(goal, previousFailures: [], ct); for (int attempt = 0; ; attempt++) { IReadOnlyList<string> problems = PlanValidator.Validate(plan, allowedTools, maxSteps: 12); if (problems.Count > 0) { if (attempt >= MaxReplans) return AgentRunResult.Failed("No valid plan: " + string.Join("; ", problems)); plan = await planner.CreatePlanAsync(goal, problems, ct); // feed the validation errors back continue; } if (RequiresApproval(plan)) await approvals.RequestPlanApprovalAsync(plan, ct); // approve the PLAN when it contains sensitive steps (Section 10) var results = await executor.ExecuteAsync(plan, budget, ct); var failed = results.Values.Where(r => !r.Succeeded).ToList(); if (failed.Count == 0 && results.Count == plan.Steps.Count) return await VerifyAndFinishAsync(goal, plan, results, ct); // check the ACTUAL end state, not just "steps ran" if (attempt >= MaxReplans) return AgentRunResult.Partial(results, failed); // bounded: report what was done and what wasn't plan = await planner.CreatePlanAsync(goal, failed.Select(f => $"{f.StepId}: {f.Error}").ToList(), ct, completed: results); // REPLAN around what already succeeded } } When to plan, when not to
PLAN EXPLICITLY WHEN the task has many steps, dependencies, parallelizable parts, side effects needing review, or a cost you want to estimate up front; or you need an auditable artifact ("here's what the agent intended"). IMPROV...
