Iterative Agent Loop¶
The iterative agent loop (IIterativeAgentLoop) is an alternative execution model for agentic LLM workloads that eliminates the O(n²) token accumulation inherent in FunctionInvokingChatClient's conversation-history approach.
Instead of appending every tool call and result to a growing conversation, the iterative loop constructs a fresh prompt each iteration from workspace files. The workspace IS the memory — not the conversation.
The Problem: O(n²) Token Cost¶
When an agent makes tool calls through FunctionInvokingChatClient (FIC), every call and result is appended to the conversation history. Each subsequent LLM call re-sends the entire history:
| LLM Call | Tokens Sent | Cumulative |
|---|---|---|
| 1 | ~1,000 | 1,000 |
| 2 | ~2,000 | 3,000 |
| 3 | ~3,000 | 6,000 |
| ... | ... | ... |
| 30 | ~30,000 | 465,000 |
For complex agentic workloads (article writers, code generators, trip planners) this produces catastrophic token bills. A real-world article pipeline measured 2.26 million tokens in a single writer stage.
The Solution: Workspace-Driven Iterations¶
The iterative loop decouples agent memory from conversation history:
┌─────────────────────────────────────────────────┐
│ Iteration N │
│ │
│ 1. PromptFactory reads workspace files │
│ 2. Builds fresh [system, user] messages │
│ 3. LLM responds with tool calls │
│ 4. Tools execute, update workspace │
│ 5. (Optional) Results sent back to model │
│ 6. Iteration ends — conversation discarded │
│ │
│ Next iteration starts with a FRESH conversation │
└─────────────────────────────────────────────────┘
Each iteration's input token count is bounded by the workspace size, not the conversation history. Total cost grows linearly with work done.
Quick Start¶
using Microsoft.Extensions.AI;
using NexusLabs.Needlr.AgentFramework.Iterative;
using NexusLabs.Needlr.Workflows;
// 1. Create a workspace (files = agent memory)
var workspace = new InMemoryWorkspace();
workspace.WriteFile("config.json", """{"topic": "async patterns in C#"}""");
// 2. Define tools
var tools = new List<AITool>
{
AIFunctionFactory.Create((string query) =>
{
// search implementation
return $"Results for: {query}";
}, new AIFunctionFactoryOptions { Name = "search" }),
};
// 3. Configure the loop
var options = new IterativeLoopOptions
{
LoopName = "article-writer",
Instructions = "You are a technical writer. Use tools to research and write.",
Tools = tools,
PromptFactory = ctx =>
{
var config = ctx.Workspace.ReadFile("config.json");
var article = ctx.Workspace.FileExists("article.md")
? ctx.Workspace.ReadFile("article.md")
: "(not started)";
return $"""
Config: {config}
Current article:
{article}
Continue working on the article.
""";
},
MaxIterations = 10,
IsComplete = ctx => ctx.Workspace.FileExists("done.txt"),
};
// 4. Run
var context = new IterativeContext { Workspace = workspace };
var result = await iterativeLoop.RunAsync(options, context);
Console.WriteLine($"Completed: {result.Succeeded}");
Console.WriteLine($"Iterations: {result.Iterations.Count}");
Console.WriteLine($"Total tokens: {result.Diagnostics?.AggregateTokenUsage.TotalTokens}");
DI Registration¶
IIterativeAgentLoop is automatically registered when you use the Agent Framework syringe:
var services = new Syringe()
.UsingReflection()
.UsingAgentFramework(af => af
.UsingChatClient(chatClient)
.AddAgentFunctionGroupsFromAssemblies([typeof(MyTools).Assembly]))
.BuildServiceProvider(configuration);
var loop = services.GetRequiredService<IIterativeAgentLoop>();
The loop depends on IChatClientAccessor (registered automatically). It also accepts optional dependencies that are injected when available:
| Optional Dependency | Purpose |
|---|---|
IAgentDiagnosticsWriter |
Publishes run diagnostics to IAgentDiagnosticsAccessor |
IAgentExecutionContextAccessor |
Bridges workspace to DI-resolved tools |
Tool Resolution¶
Use IAgentFactory.ResolveTools() to get DI-wired tool instances instead of hand-wiring AIFunctionFactory.Create():
var agentFactory = services.GetRequiredService<IAgentFactory>();
// Resolve all tools in a function group (discovered by source generator)
var tools = agentFactory.ResolveTools(opts =>
opts.FunctionGroups = ["trip-planner"]);
// Or resolve all registered tools
var allTools = agentFactory.ResolveTools();
This resolves tool classes through DI, so constructor-injected services (like IAgentExecutionContextAccessor) are available inside tool methods.
Source generator vs. reflection
The source generator (NexusLabs.Needlr.AgentFramework.Generators) emits a [ModuleInitializer] that auto-registers [AgentFunctionGroup] types with AgentFrameworkGeneratedBootstrap. Add the generator as an analyzer reference in your .csproj to enable this. For projects that cannot use source generation, call AddAgentFunctionGroupsFromAssemblies() as a reflection-based fallback.
Configuration Reference¶
IterativeLoopOptions¶
| Property | Type | Default | Description |
|---|---|---|---|
LoopName |
string |
"iterative-loop" |
Human-readable name for diagnostics |
Instructions |
string |
(required) | System prompt — constant across iterations |
Tools |
IReadOnlyList<AITool> |
(required) | Available tool functions |
PromptFactory |
Func<IterativeContext, string> |
(required) | Builds the user message each iteration |
MaxIterations |
int |
25 |
Hard stop — loop terminates after this many iterations |
IsComplete |
Func<IterativeContext, bool>? |
null |
Domain-specific termination predicate |
ToolResultMode |
ToolResultMode |
OneRoundTrip |
How tool results feed back within an iteration |
MaxToolRoundsPerIteration |
int |
5 |
Safety valve for MultiRound mode |
CheckCompletionAfterToolCalls |
ToolCompletionCheckMode |
None |
When to check IsComplete relative to tool calls (see below) |
OnIterationStart |
Func<int, IterativeContext, Task>? |
null |
Async callback fired before each iteration's prompt factory |
OnToolCall |
Func<int, ToolCallResult, Task>? |
null |
Async callback fired after each tool executes (includes iteration number) |
OnIterationEnd |
Func<IterationRecord, Task>? |
null |
Async callback fired after each iteration completes |
ExecutionContext |
IAgentExecutionContext? |
null |
Explicit execution context (auto-created from workspace if omitted) |
ToolResultMode¶
| Mode | LLM Calls / Iteration | Description |
|---|---|---|
SingleCall |
1 | Tool results are NOT sent back. Stored in LastToolResults for the next iteration's prompt factory. Maximum cost control. |
OneRoundTrip |
≤ 2 | Tool results sent back once. Model gets one follow-up chance. Recommended default. |
MultiRound |
≤ MaxToolRoundsPerIteration + 1 |
Tool results sent back repeatedly until model stops requesting tools. Bounded by MaxToolRoundsPerIteration. |
CheckCompletionAfterToolCalls (ToolCompletionCheckMode)¶
Controls when IsComplete is evaluated relative to tool calls within an iteration. By default (None), IsComplete only runs between iterations — after the round loop finishes. This means a tool call that satisfies the completion condition (e.g., writing a file to the workspace) still triggers one more ChatCompletion call before the loop notices.
| Mode | Description |
|---|---|
None |
Default. IsComplete checked only between iterations. Preserves backward-compatible behavior. |
AfterToolRounds |
IsComplete checked after each round's batch of tool calls completes. All tool calls in the round execute, but the next ChatCompletion call is avoided. Terminates with CompletedEarlyAfterToolCall. |
AfterEachToolCall |
IsComplete checked after each individual tool call. Remaining tool calls in the batch are skipped if the predicate returns true. Terminates with CompletedEarlyAfterToolCall. |
When to use¶
AfterToolRounds— Best for most scenarios. Captures ~100% of the wastedChatCompletioncost (input tokens saved) with minimal behavioral change. All tool calls the model requested still execute.AfterEachToolCall— Use when tool execution itself is expensive (e.g., web requests, database writes) and you want to skip unnecessary tool calls once the goal is met.
Example¶
var options = new IterativeLoopOptions
{
Instructions = "Generate a research brief...",
Tools = [readTool, writeTool],
PromptFactory = ctx => BuildPrompt(ctx),
IsComplete = ctx => ctx.Workspace.FileExists("research/brief.md"),
ToolResultMode = ToolResultMode.MultiRound,
CheckCompletionAfterToolCalls = ToolCompletionCheckMode.AfterToolRounds,
};
Precedence¶
When CheckCompletionAfterToolCalls is active and a tool call simultaneously satisfies both IsComplete and MaxTotalToolCalls, completion wins (CompletedEarlyAfterToolCall rather than MaxToolCallsReached). The agent achieved its goal — the budget guard is secondary.
The Prompt Factory¶
The prompt factory is the core extensibility point. It receives an IterativeContext with:
Workspace— theIWorkspacecontaining all agent filesLastToolResults— results from the previous iteration's tool calls (if any)Iteration— current iteration index (0-based)State— aDictionary<string, object?>for arbitrary cross-iteration data
Design principles¶
- Read workspace files to understand current state — these are the agent's memory.
- Include only relevant context — don't dump every file. Summarize or select.
- Guide the model toward the next action — nudge, don't over-prescribe.
- Cap unbounded content — if research notes grow, show only the last N entries.
Example: phase-aware prompt¶
PromptFactory = ctx =>
{
var sb = new StringBuilder();
var status = JsonSerializer.Deserialize<Status>(
ctx.Workspace.ReadFile("status.json"));
sb.AppendLine($"## Current State ({status.Phase} phase)");
sb.AppendLine($"Budget remaining: ${status.BudgetRemaining}");
if (ctx.Workspace.FileExists("itinerary.json"))
sb.AppendLine($"Itinerary: {ctx.Workspace.ReadFile("itinerary.json")}");
// Phase-specific nudges
if (status.Phase == "research")
sb.AppendLine(">>> Search for options before committing. <<<");
else if (status.Phase == "fix")
sb.AppendLine(">>> Validation failed. Fix the issues above. <<<");
return sb.ToString();
}
Lifecycle Hooks¶
The iterative loop provides async lifecycle hooks for real-time progress reporting. These are essential for scenarios like sending updates over SignalR or updating a UI.
var options = new IterativeLoopOptions
{
// ... other options ...
OnIterationStart = async (iteration, ctx) =>
{
await hub.SendAsync("IterationStarted", iteration);
},
OnToolCall = async (iteration, toolResult) =>
{
await hub.SendAsync("ToolExecuted", new
{
Iteration = iteration,
Tool = toolResult.FunctionName,
Succeeded = toolResult.Succeeded,
});
},
OnIterationEnd = async (record) =>
{
await hub.SendAsync("IterationCompleted", new
{
record.Iteration,
ToolCount = record.ToolCalls.Count,
record.Duration,
});
},
};
Hook behavior¶
- Hooks are async (
Func<..., Task>) — await I/O operations safely. - Hook exceptions propagate to the caller — they are not swallowed by the loop's error handling. If a hook throws, the loop terminates with that exception.
- Null hooks are safe — omitting any hook has no effect.
OnToolCallreceives the iteration number as the first parameter, so progress reporters know which iteration a tool call belongs to.
Diagnostics Accessor¶
The loop automatically publishes diagnostics to IAgentDiagnosticsAccessor when the service is registered. This eliminates the need to inspect IterationRecord.Tokens directly.
Setup¶
Call BeginCapture() before running the loop so the diagnostics are visible to the caller after the run completes:
var diagnosticsAccessor = services.GetRequiredService<IAgentDiagnosticsAccessor>();
// Create a capture scope — diagnostics written inside RunAsync will be
// visible via LastRunDiagnostics after the run completes.
using var scope = diagnosticsAccessor.BeginCapture();
var result = await loop.RunAsync(options, context);
// Read diagnostics from the accessor (same data as result.Diagnostics)
var diag = diagnosticsAccessor.LastRunDiagnostics!;
Console.WriteLine($"LLM calls: {diag.ChatCompletions.Count}");
Console.WriteLine($"Tool calls: {diag.ToolCalls.Count}");
Console.WriteLine($"Tokens: {diag.AggregateTokenUsage.TotalTokens}");
BeginCapture is required
Without BeginCapture(), LastRunDiagnostics will be null after the run. The loop writes diagnostics into an AsyncLocal<T> holder — BeginCapture() creates the shared holder that both the loop and the caller can access.
Execution Context Bridge¶
DI-resolved tools often need access to the workspace. The iterative loop automatically bridges its IterativeContext.Workspace to IAgentExecutionContextAccessor so that tool classes can read/write workspace files.
How it works¶
When IAgentExecutionContextAccessor is available via DI, the loop:
- Creates an
AgentExecutionContextwith the workspace fromIterativeContext - Calls
accessor.BeginScope(context)before the first iteration - Disposes the scope after the loop completes
DI-resolved tool example¶
[AgentFunctionGroup("my-tools")]
public class MyTools
{
private readonly IAgentExecutionContextAccessor _contextAccessor;
public MyTools(IAgentExecutionContextAccessor contextAccessor)
{
_contextAccessor = contextAccessor;
}
[AgentFunction]
public string ReadConfig()
{
var workspace = _contextAccessor.Current!.GetRequiredWorkspace();
return workspace.ReadFile("config.json");
}
}
Explicit context¶
If you need custom UserId or OrchestrationId values, provide an explicit execution context:
var options = new IterativeLoopOptions
{
// ... other options ...
ExecutionContext = new AgentExecutionContext(
UserId: "user-123",
OrchestrationId: "trip-planner-run-42",
Workspace: workspace),
};
Results and Diagnostics¶
IterativeLoopResult provides full introspection:
var result = await loop.RunAsync(options, context);
// Overall status
Console.WriteLine($"Success: {result.Succeeded}");
Console.WriteLine($"Error: {result.ErrorMessage}");
// Per-iteration detail
foreach (var iter in result.Iterations)
{
Console.WriteLine($"Iteration {iter.Iteration}: " +
$"{iter.ToolCalls.Count} tools, " +
$"{iter.Tokens.InputTokenCount} in / {iter.Tokens.OutputTokenCount} out, " +
$"{iter.Duration.TotalSeconds:F1}s");
foreach (var tool in iter.ToolCalls)
{
var status = tool.Succeeded ? "✓" : "✗";
Console.WriteLine($" {status} {tool.FunctionName} ({tool.Duration.TotalMilliseconds:F0}ms)");
}
}
// Aggregate diagnostics
var diag = result.Diagnostics;
Console.WriteLine($"Total tokens: {diag?.AggregateTokenUsage.TotalTokens}");
IterationRecord fields¶
| Field | Type | Description |
|---|---|---|
Iteration |
int |
0-based iteration index |
ToolCalls |
IReadOnlyList<ToolCallResult> |
All tool calls made this iteration |
ResponseText |
string? |
Final model text response (if any) |
Tokens |
TokenUsage |
Input/output token counts for this iteration |
Duration |
TimeSpan |
Wall-clock time for this iteration |
LlmCallCount |
int |
Number of LLM API calls made this iteration |
ToolCallResult fields¶
| Field | Type | Description |
|---|---|---|
FunctionName |
string |
Tool function name |
Arguments |
IReadOnlyDictionary<string, object?> |
Arguments passed to the tool |
Result |
object? |
Return value from the tool |
Duration |
TimeSpan |
Tool execution time |
Succeeded |
bool |
Whether the tool executed without error |
ErrorMessage |
string? |
Error message if Succeeded is false |
When to Use the Iterative Loop¶
Good fit¶
- Multi-step agentic workloads — research → plan → execute → validate → fix
- Budget-sensitive deployments — token cost must stay predictable
- Complex tool workflows — many tool calls across many iterations
- Workspace-centric tasks — the output is files (articles, code, plans, itineraries)
Not ideal for¶
- Conversational agents — where conversation history IS the product
- Simple single-tool workflows — where FIC overhead is negligible
- Stateless Q&A — no workspace needed, one LLM call suffices
Example: Trip Planner¶
The IterativeTripPlannerApp example in src/Examples/AgentFramework/ demonstrates the full pattern — matching the architecture of production consumers like BrandGhost:
This example plans a multi-stop trip from New York to Tokyo on a tight budget with constraints (3.5★ minimum hotel rating, 2+ intermediate stops). It demonstrates:
- DI-resolved tools —
TripPlannerFunctionsclass with[AgentFunctionGroup("trip-planner")], resolved viaIAgentFactory.ResolveTools() - Workspace access via DI — tools read/write workspace through
IAgentExecutionContextAccessor(not captured closures) - Lifecycle hooks — progress output driven by
OnIterationStart,OnToolCall,OnIterationEnd - Diagnostics accessor — aggregate metrics read from
IAgentDiagnosticsAccessor.LastRunDiagnosticsafter the run - Execution context bridge — workspace automatically available to DI-resolved tools
- Budget failures — the preferred European route exceeds the budget
- Route pivots — the model discovers cheaper US West Coast alternatives
- Fix cycles — validation failures trigger leg removal, hotel swaps, and replanning
- Full diagnostics — per-iteration token counts, tool call logs, and O(n²) comparison
Configure via appsettings.json:
{
"TripPlanner": {
"Origin": "New York",
"Destination": "Tokyo",
"Budget": "1600",
"MaxStops": 3
}
}
Set UseMockClient to false and provide Azure OpenAI credentials in appsettings.Development.json for real LLM execution.
See Also¶
- AI Integrations — agent framework overview and function discovery
- Progress Reporting — real-time event tracking for agent runs