Evidence-Driven Replanning: A Bounded .NET Agent State Machine
A bounded evidence-driven controller uses real Microsoft AI function invocation with a scripted provider, while keeping progress and stop conditions in deterministic application code.
Explore the mechanism ↓
Reading tools Listen or translate
Read aloud
Uses an installed voice on your device.
Translate article
Machine translation. Code and evidence notes stay in English.
Original English text.
START WITH THE MECHANISM
Interactive companion and laboratory update: 12 November 2026. The editorial date places the topic on the timeline; this current explanation and its measured evidence retain their actual preparation and execution dates.
THE FULL INVESTIGATION
Evidence-Driven Replanning: A Bounded .NET Agent State Machine
Dates and technology context
This is a retrospective editorial timeline. Topic context: 23 October 2025. First publicly available: 12 November 2026. Current text, visuals and laboratory revisions were prepared on 12 November 2026.
MEAI11.0.0 was published10Nov2026. Current integration code,32 assertions and controller timings require12Nov2026 update labels.
A planning agent can produce a new plan without learning anything new. It can rename a step, increase a revision number and explain the revised approach persuasively while remaining blocked by exactly the same missing fact. If the application treats every new plan as progress, a fluent loop can look like useful work.
The alternative is to define progress outside the plan's prose. What observation changed? Which prerequisite now holds? Which previous step lost its justification? What is the finite responsibility of this run if no new information arrives?
This article implements a small, deterministic state machine for those questions. It connects the controller to real Microsoft.Extensions.AI interfaces and automatic function invocation, using an explicitly scripted local chat client. The provider is not a language model. It supplies controlled tool requests so the integration can be tested without confusing model quality with the correctness of the surrounding application.
The laboratory uses SDK 10.0.400, runtime 10.0.15 and Microsoft.Extensions.AI 11.0.0. The package's public registration records publication on 10 November 2026 and includes a net10.0 asset; its major version does not mean the application was run on .NET 11. Package restoration and the actual function calls succeeded in the pinned environment. Microsoft.Extensions.AI 11.0.0.
The accepted shared suite passed 32 assertions, including controller transitions, a nine-case comparison with a fixed decision function, SDK tool invocation and a separate mutation experiment described in the companion article. These are local engineering results, not evidence that an autonomous model reliably solves an import problem.
Put knowledge and intention in different types
01 / See the idea
Plans and observations have different authority
The proposed plan cannot mark its own prerequisites as passed. Observations enter through the trusted fixture boundary, while the model-facing function requests a plan.
Interpretation and evidence
- A proposal is not evidence.
- Unknown is distinct from failed.
- The host owns observation ingestion.
The fixture's task is a synthetic import. Before it may commit, the controller needs affirmative evidence that the parser and access prerequisites pass. A hypothesis that the parser is responsible does not establish that it failed, and the absence of an access result does not establish that access is denied.
The basic types keep that distinction explicit:
public enum EvidenceState { Unknown, Passed, Failed }
public sealed record Observation(
string Key, int Version, EvidenceState State, string Source);
public sealed record Plan(
string Id, int Revision, int EvidenceRevision,
string Step, string Resource);
An observation records a keyed fact from a named fixture source. A plan records a proposed action and the evidence revision on which it depends. The plan does not get to assign itself a passed prerequisite simply because completing the action would be convenient.
The model-facing tools can propose a plan and ask for a step to execute. Observation ingestion is not exposed as another model-controlled function. In this reference, the host injects observations from controlled diagnostic fixtures. A production tool adapter would need to authenticate and validate its own results before presenting them as observations.
This is an important trust boundary. Adding a Source string is not authentication. A malicious or confused caller can type the name of a trusted tool. The code's public methods are components of a local test program, not secured network endpoints. The intended deployment boundary must keep evidence ingestion under trusted application control.
The first benefit is conceptual clarity. Unknown calls for obtaining information. Failed calls for a response to adverse information, possibly escalation. Passed permits consideration of a dependent action. None of these states independently grants resource authority; the companion executor handles that separate concern.
Let evidence revisions invalidate pending plans
The controller assigns its own monotonically increasing evidence revision when an observation changes the represented state. A proposed plan must cite the current revision. If its premise becomes stale, execution returns StalePlan rather than treating the plan's earlier approval as timeless.
The implemented invalidation is intentionally conservative: any meaningful evidence change clears the pending plan. It is not a full dependency graph that surgically invalidates only one branch. That simpler choice makes the first correctness boundary easy to inspect. A larger planner can later track per-step dependencies, but it must preserve the same rule that stale premises cannot silently authorize execution.
The proposal gate also requires a newer plan revision, a known step and the permitted resource:
if (p.Revision <= PlanRevision
|| p.EvidenceRevision != EvidenceRevision)
return false;
if (p.Resource != "input-A"
|| p.Step is not ("inspect-parser" or "inspect-access" or "commit-import"))
return false;
Revision numbers are not evidence of progress by themselves. They only express ordering within this run. A proposal with a larger number can still rely on old observations, request an unknown action or repeat completed work. Those conditions need their own checks.
The fixture rejects proposing a completed plan identity again. A repeated execution request for a completed identity returns Replayed without incrementing the step count. That makes a transport-level repeat different from a new unit of work, while avoiding a second execution for the same accepted step.
This replay record lives in memory. It does not survive a process restart and does not establish exactly-once behavior for a remote effect. Persistent workflow recovery is a separate problem. Calling this controller durable merely because it writes a trace afterward would confuse an explanatory artifact with a transactional execution ledger.
New versions do not necessarily contain new information
03 / A concrete example
A new version can still mean no new information
The test submits three successive versions of an unknown access result. The evidence state does not change, so the controller stops instead of treating version increments as progress.
Interpretation and evidence
- Compare meaning as well as identity.
- Repeated unknown does not become passed.
- Stop with NoNewEvidence.
The controller keeps both the latest observation per key and the content associated with every observed key-and-version identity. An identical repeat is a duplicate. A different payload under the same observation revision is a conflict and throws. An older revision cannot overwrite a newer state.
There is a second test beyond revision identity. If a newer observation has the same evidence state, the fixture updates its latest observation record but does not advance the evidence revision used for planning. Merely renumbering Unknown does not turn it into useful progress.
This is a deliberately coarse definition of information. It counts changes among Unknown, Passed and Failed, not every detail a richer diagnostic result could contain. In a real parser investigation, two failed results might carry different error locations and therefore genuinely change the next decision. That application would need a richer normalized fact representation and an explicit definition of meaningful change.
The benefit of the coarse reference is that its limit is testable. The suite submits one unknown observation followed by two higher versions of the same unknown state. The controller reaches NoNewEvidence while its meaningful evidence revision remains unchanged. It does not reward the producer for increasing a counter.
Repeated identical deliveries also contribute to the no-progress threshold in this small fixture. That is appropriate for demonstrating a stalled observation loop, but it is not universally the right transport policy. A production receiver may want to deduplicate delivery noise before counting failed attempts to acquire information. Otherwise a broker redelivery could prematurely end a useful run. The article's controller makes the selected policy visible rather than claiming it is inherent to agent planning.
This is where application judgment belongs: in the definition of a state change that can alter the next action. A model's explanation can help a person understand a proposal, but it should not be the sole evidence that the run has advanced.
Bound proposals as well as executed steps
Counting tool executions alone does not bound a planner that keeps proposing invalid plans. The reference therefore has separate limits for executed steps and considered proposals. The default allows four executed steps and at most twice that many considered proposals. These are synthetic control settings for the fixture, not account quotas or measurements of any external service.
Every proposal considered while the run is active consumes a proposal slot before validation. An invalid proposal therefore cannot be generated indefinitely without consequence. The run stops with PlanLimit when the finite allowance is exhausted.
The execution path checks its own limit before performing a new step. It also checks cancellation and the current evidence revision. A pending commit requires both prerequisites to be passed:
if (Steps >= maxSteps)
{
Stop("StepLimit");
return "StepLimit";
}
if (plan.Step == "commit-import"
&& (State("parser") != EvidenceState.Passed
|| State("access") != EvidenceState.Passed))
return "PreconditionDenied";
These limits address different failure modes. A no-progress threshold detects repeated unhelpful observations. A proposal bound detects repeated plan generation or invalid requests. A step bound limits accepted work even if every step appears different. A cancellation request allows the owner to end the active run.
The first terminal stop reason is preserved. Later observations do not automatically revive the run. If an operator resolves a missing prerequisite after a terminal stop, starting a new authorized run is a clearer contract than silently resurrecting an old execution context.
The code is single-owner state, and SDK function invocation is configured serially. It is not a concurrent actor system. Multiple threads mutating the same controller would need serialization or another atomic state transition mechanism. A finite counter does not become a concurrency-safe budget merely because its type is an integer.
Use real function invocation with a deliberately fake provider
The integration uses IChatClient, FunctionInvokingChatClient and AIFunctionFactory. The local provider returns a FunctionCallContent requesting propose_plan, then requests execute_step after receiving the function result. It finally returns an ordinary assistant message. Those three provider rounds are deterministic.
The application registers named local functions rather than letting the provider choose arbitrary methods:
bool ProposePlan(string planId, int revision, int evidenceRevision,
string step, string resource) =>
controller.Propose(new(
planId, revision, evidenceRevision, step, resource));
string ExecuteStep(string stepId) => controller.Execute(stepId);
var options = new ChatOptions
{
Tools =
[
AIFunctionFactory.Create(
(Func<string, int, int, string, string, bool>)ProposePlan,
"propose_plan"),
AIFunctionFactory.Create(
(Func<string, string>)ExecuteStep,
"execute_step")
]
};
The Microsoft abstraction supplies chat and tool-call plumbing. It does not supply this controller's domain policy. IChatClient implementations expose responses through a common interface; applications remain responsible for how those responses are used. Use the IChatClient interface.
The function schema matters. During development, an actual integration check failed because the generated parameter name and the scripted request disagreed. Registering a local function with the explicit stepId parameter aligned the schema with the request. The correction was made at the interface contract, not by replacing the SDK invocation with a direct call that would make the test pass while bypassing the problem.
AIFunctionFactory can derive function metadata and parameter binding from a method. That convenience makes the method signature part of the model-facing interface, which is why the integration test must execute the generated function rather than only check that a delegate exists. AIFunctionFactory.Create documentation.
The wrapper sets MaximumIterationsPerRequest to four and AllowConcurrentInvocation to false. Those members were checked in the restored package's XML documentation and exercised by the compiled code. Four SDK rounds are a separate bound from four domain steps: one round can ask for tools, and not every proposed tool call is accepted as a domain step.
No claim is made that a real model would choose the same sequence. The scripted provider makes the boundary deterministic so a failure can be assigned to integration or policy rather than hidden inside probabilistic output.
Follow the executed replan trace
02 / Watch the mechanism
New evidence invalidates the old plan
Static view. Play to see how the mechanism unfolds.
The executed trace moves from parser unknown to access failed, then to an owner-confirmed passed observation. A plan based on earlier evidence cannot authorize the final commit.
Interpretation and evidence
- Keep the evidence revision.
- Clear the pending plan when facts change.
- Only a newly based plan may proceed.
The recorded trace begins with parser state Unknown at evidence revision one. The scripted provider proposes plan p1 to inspect the parser, citing that revision. The SDK invokes the proposal function, then the execution function, and the controller records one executed diagnostic step.
The host subsequently supplies a passed parser observation and a failed access observation. Meaningful evidence reaches revision three. This contradicts an assumption that fixing the parser is sufficient. The pending plan is cleared.
A proposal citing evidence revision one is rejected as stale. A newer plan p2 requests commit-import against revision three, but execution returns PreconditionDenied because access is Failed. The step count does not increase. A well-formed newer plan is still not an acceptable action when its prerequisite fails.
The fixture then supplies an explicitly owner-confirmed access observation at a newer version. This is controlled test input, not permission invented by the planner. It advances evidence to revision four and invalidates p2. Attempting to execute p2 now returns StalePlan.
Finally, p3 cites evidence revision four and requests commit. Both prerequisites pass, so the controller accepts the step and stops with Completed. The recorded episode contains two executed steps, four considered proposals and three scripted provider calls in the initial SDK interaction.
The exact trace is stored in evidence/replan-trace.json. Not every rejected attempt is a trace line in this compact implementation; the assertions separately record that those calls returned the expected rejection. The trace is an explanation aid, not a complete audit log that should be mistaken for transactional history.
Notice the narrow meaning of completion. Here it means the controller accepted the synthetic commit step. It does not mean a file was imported, a database transaction committed or a user validated a business result. Connecting that step to a real executor would add another acceptance boundary, which the companion mutation laboratory examines separately.
Compare the decision with a fixed workflow
04 / A concrete example
The fixed workflow agrees on commit permission
All nine combinations of parser and access evidence were checked. Only passed plus passed permits commit; this is decision equivalence, not evidence that a model improves the workflow.
Interpretation and evidence
- Keep a simple baseline.
- Compare the same permission boundary.
- Do not confuse extra steps with added value.
A planner should not be credited with a capability that a small deterministic function already provides. The laboratory therefore includes a fixed decision function over the same parser and access states:
public static string Decide(EvidenceState parser, EvidenceState access) =>
parser == EvidenceState.Failed || access == EvidenceState.Failed
? "NeedsOwner"
: parser == EvidenceState.Passed && access == EvidenceState.Passed
? "ReadyToCommit"
: "Inspect";
The suite evaluates all nine combinations of the two three-valued prerequisites. For each combination it checks whether the bounded controller allows commit and whether the fixed workflow says ReadyToCommit. Those decisions agree in the accepted run.
This comparison establishes one shared boundary, not equivalent behavior in every respect. The fixed function does not track observation revisions, stop reasons or repeated plan proposals. The controller does. Conversely, the controller's plan selection does not produce better answers simply because it has more state.
For this known, tiny problem, a fixed workflow may be the better production design. Dynamic planning becomes more interesting when the available diagnostic steps and their dependencies cannot be expressed economically as a short fixed decision tree. Even then, the deterministic acceptance boundary remains useful.
The comparison also prevents a misleading reading of the SDK demonstration. Three provider rounds are not evidence of more intelligence than a direct condition. They are the cost and behavior of one selected integration path. A future model evaluation would need representative tasks, independent expected outcomes and a reason to believe model-selected steps improve the workflow.
Verify the controller without a model-quality story
The accepted checks establish several concrete properties. A stale evidence revision cannot install the proposed plan. New evidence invalidates a pending plan. Failed prerequisites prevent a commit. A later legitimate observation permits a newly based plan. Replaying a completed identity does not execute another step.
The suite also tests repeated identical observations, higher versions with unchanged state, the step limit, the proposal limit, cancellation and conflicting observation content. These are different ways a run can fail to deserve further work. They should not collapse into a single generic “agent failed” message.
The broader shared suite contains an executor mutation experiment, so its headline assertion count is not a count of replanning cases alone. The evidence files keep the replan trace, fixed-workflow comparison and mutation matrix separate. The two articles share the same source hash and test run; they do not represent two independently repeated experiments.
The controller has important limits. Its facts are coarse, its invalidation is global, its state is in memory and its replay protection lasts only for the object's lifetime. Its source labels are not authentication. These limits make the example understandable but must be addressed before using it as a durable production orchestrator.
Those qualifications do not make the reference useless. They define exactly which problem it solves: keeping a finite local run from treating ungrounded intention as executable progress.
Measure controller work separately from chat integration
05 / See the idea
Measure the controller, not imaginary model latency
These are all three measured workloads from the shared harness. None includes a live model, network call or SDK middleware in its timed loop.
Interpretation and evidence
- Report the exact measured boundary.
- Retain ranges across samples.
- Model quality needs another experiment.
The shared performance harness measures three in-memory episode types: a successful replan-controller episode, an authorized executor episode and a denied executor episode. The replanning case creates a fresh controller, supplies two passed observations, proposes a matching commit and checks that execution succeeds.
Each measured sample contains one thousand episodes. Seven samples follow two warm-ups. Object creation, dictionaries, trace strings and the final guard are inside the replanning boundary. The fixture does not call IChatClient or automatic function middleware inside the timed loop.
That separation is deliberate. The test suite already verifies the SDK boundary. Timing the scripted provider would measure a local fake, not the latency of a hosted model, so the report avoids attaching an “AI response time” label to it. Similarly, none of the synthetic step limits is read from an account or provider quota.
The measurements use current-thread managed allocation and a stopwatch. They are a small single-process experiment, not a contention benchmark or a retained-memory profile. No competing planner is timed. The fixed-workflow comparison checks decisions, not a performance claim about model planning.
The integrator ran the shared measurement on 12 November 2026 at 14:01 UTC, on .NET 10.0.15, with exit code zero. The table retains every configured workload. These are seven within-process samples per workload, expressed per fresh in-memory episode; they are not percentiles of user request latency.
| Episode | Median µs | Observed µs range | Allocated bytes/episode |
|---|---|---|---|
| replan-controller | 2.181 | 1.703–2.324 | 1424–1424.42 |
| authorized-executor | 3.751 | 3.643–3.915 | 1808–1808 |
| denied-executor | 1.164 | 1.151–1.218 | 768–768 |
The successful replanning episode had a median of 2.181 microseconds and an observed range from 1.703 to 2.324 microseconds. Its allocation was normally 1,424 bytes per episode, with one amortized sample value of 1,424.416. That tiny additional batch allocation is retained rather than rounded away to imply identical allocation in every run.
The allocation is not a bug by definition. This reference constructs state dictionaries, observations, a plan and trace strings so the transition is easy to inspect. A production optimization might reduce those allocations, but it would have to preserve revision checks, terminal reasons and replay behavior. The benchmark does not establish which allocation dominates an actual application's cost.
The two executor rows belong to the same shared library and appear here to avoid hiding other measured paths. Their different timing does not compare alternative planning strategies. In particular, the fixed decision function was checked for compatible commit decisions but was not timed against a real model planner.
There is no evidence here of a faster AI application, a better answer or a reduction in external calls. The useful result is a measured local mechanism with a known correctness boundary. Any future live-model experiment must separately record the model, prompts, task set and quality judgment instead of reusing these microseconds as a proxy.
Distinguish an unresolved prerequisite from an adverse result
An unknown access result and a failed access result both prevent this controller from committing. That does not make them the same state. Unknown means the next useful step may be an inspection. Failed means an inspection already produced adverse evidence, and further work may require an owner who can legitimately change the condition.
The distinction prevents a planner from turning lack of permission into a prompt-engineering challenge. In the executed trace, the transition from failed access to passed access comes from an explicitly owner-confirmed fixture observation. The scripted provider does not invent the observation, grant itself access or relabel failure as uncertainty.
A practical application should make that provenance visible in its handoff. An operator needs to know whether the run never obtained a result, received a denial or received conflicting results. Those situations lead to different next actions. A single generic retry button can conceal the difference and encourage repeated work that cannot resolve the prerequisite.
The current evidence vocabulary is intentionally small. It cannot express partial confidence, competing sources or a stale-but-usable cached result. Adding those concepts should start with their consequences for allowed actions, not with more elaborate status names. A fourth state is useful only if it changes a decision the controller can enforce.
Keep replay from becoming accidental new authority
The completed-step set exists to prevent duplicate execution of a known plan identity during one run. It does not authorize a different plan that reuses the same name. The proposal gate refuses completed identities, while a direct repeat of the completed execution request returns Replayed without adding another step.
This distinction is especially important around cancellation. A repeat can report that work was already completed without doing new work after cancellation. It should not be interpreted as reviving the run or granting permission for another effect. In this fixture, completion records are local facts about the controller, not proof of any remote transaction.
A larger system might need a digest binding the operation identity to its arguments, a durable receipt and an authenticated reader. The companion executor provides a small content-binding example, but neither in-memory component is a distributed workflow engine. Keeping that boundary explicit prevents a useful local replay check from being promoted into an exactly-once claim.
Let a stopped run remain an honest result
The most useful outcome can be NoNewEvidence, StepLimit, PlanLimit or Canceled. A run that stops at a known boundary gives its caller something actionable: a missing observation, an exhausted scope or a request that needs a different owner.
By contrast, endlessly generating another plan hides the same uncertainty behind fresh text. It consumes attention and resources while making it harder to see what would actually change the next decision. A planner that cannot stop is not more autonomous; it has an incomplete completion contract.
A production handoff should retain the last accepted facts, the pending prerequisite, the stop reason and enough history to explain why further automatic work was not justified. It should not ask a human to reconstruct the entire conversation merely to discover that access was denied at the beginning.
The repository can be reproduced with its local SDK pin and restored package lockfile:
dotnet restore --locked-mode
dotnet run -c Release -- check ../evidence
dotnet bin/Release/net10.0/ControlsLab.dll measure ../benchmark-results.json
The first command establishes the dependency graph, the second executes the shared correctness suite and SDK interaction, and the third belongs in the exclusive measurement lane. The scripted provider must remain visibly identified as such when the demonstration is presented.
The design principle is simple enough to survive a different framework: proposals express what might be done; trusted observations describe what is known; deterministic transitions decide what may happen next. Replanning becomes useful when a changed observation changes an allowed action. Until then, a new plan is only another proposal.
Source code and execution evidence
Download the complete lab, source record and raw execution results. This package supports the specific execution claims in this article. It is not a production certification.