Middleware Events
Emit events and build interactive middleware with request/response patterns.
Overview
Middleware can communicate via events:
- One-way events - Fire-and-forget notifications
- Request/response - Interactive workflows (permissions, approvals)
- Typed events - Compile-time safety
- Async responses - Wait for user input mid-execution
Quick Example
csharp
public class PermissionMiddleware : IAgentMiddleware
{
public async Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
{
// Emit permission request
var request = new PermissionRequestEvent
{
FunctionName = context.Function.Name,
Arguments = context.Arguments,
RequestId = Guid.NewGuid().ToString()
};
context.Emit(request);
// Wait for user response
var response = await context.WaitForResponseAsync<PermissionResponseEvent>(
request.RequestId,
ct
);
if (!response.Approved)
{
context.BlockExecution = true;
context.OverrideResult = "User denied permission";
}
}
}One-Way Events
Emitting Events
csharp
public Task BeforeIterationAsync(BeforeIterationContext context, CancellationToken ct)
{
// Emit text for UI
context.Emit(new TextDeltaEvent
{
Text = "🔍 Analyzing query..."
});
// Emit custom telemetry
context.Emit(new CustomTelemetryEvent
{
MetricName = "iteration_started",
Value = context.Iteration
});
return Task.CompletedTask;
}Built-in Event Types
csharp
// Text output
context.Emit(new TextDeltaEvent { Text = "Processing..." });
// Tool call started
context.Emit(new ToolCallStartedEvent
{
ToolName = "SearchWeb",
CallId = callId
});
// Tool call completed
context.Emit(new ToolCallCompletedEvent
{
ToolName = "SearchWeb",
CallId = callId,
Result = result
});
// Custom events
context.Emit(new MyCustomEvent { /* ... */ });Request/Response Pattern
Step 1: Define Events
csharp
// Request event
public class PermissionRequestEvent : AgentEvent
{
public required string FunctionName { get; init; }
public required IReadOnlyDictionary<string, object?> Arguments { get; init; }
public required string RequestId { get; init; }
}
// Response event
public class PermissionResponseEvent : AgentEvent
{
public required string RequestId { get; init; }
public required bool Approved { get; init; }
public string? DenialReason { get; init; }
}Step 2: Emit Request and Wait
csharp
public async Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
{
var requestId = Guid.NewGuid().ToString();
// Emit request
var request = new PermissionRequestEvent
{
FunctionName = context.Function.Name,
Arguments = context.Arguments,
RequestId = requestId
};
context.Emit(request);
// Wait for response (blocks middleware until user responds)
var response = await context.WaitForResponseAsync<PermissionResponseEvent>(
requestId,
ct
);
// Act on response
if (!response.Approved)
{
context.BlockExecution = true;
context.OverrideResult = response.DenialReason ?? "Permission denied";
}
}Step 3: User Responds
In your UI/host code:
csharp
await foreach (var evt in agent.RunAsync("Search for flights", ct))
{
if (evt is PermissionRequestEvent permReq)
{
// Show dialog to user
var approved = await ShowPermissionDialog(permReq.FunctionName);
// Send response back to middleware
await agent.EmitEventAsync(new PermissionResponseEvent
{
RequestId = permReq.RequestId,
Approved = approved,
DenialReason = approved ? null : "User declined"
});
}
}Timeout Handling
Always use CancellationToken with timeouts:
csharp
public async Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
{
var requestId = Guid.NewGuid().ToString();
context.Emit(new PermissionRequestEvent
{
FunctionName = context.Function.Name,
RequestId = requestId
});
try
{
// Wait up to 30 seconds
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30));
var response = await context.WaitForResponseAsync<PermissionResponseEvent>(
requestId,
cts.Token
);
if (!response.Approved)
{
context.BlockExecution = true;
}
}
catch (OperationCanceledException)
{
// Timeout - deny by default
context.BlockExecution = true;
context.OverrideResult = "Permission request timed out";
}
}Human-in-the-Loop Pattern
Complete example with UI integration:
Middleware:
csharp
public class HumanApprovalMiddleware : IAgentMiddleware
{
public async Task BeforeParallelBatchAsync(
BeforeParallelBatchContext context,
CancellationToken ct)
{
var requestId = Guid.NewGuid().ToString();
// Request batch approval
context.Emit(new BatchApprovalRequestEvent
{
Functions = context.ParallelFunctions
.Select(f => f.Name ?? "_unknown")
.ToList(),
RequestId = requestId
});
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(60));
try
{
var response = await context.WaitForResponseAsync<BatchApprovalResponseEvent>(
requestId,
cts.Token
);
// Store approvals in state for BeforeFunctionAsync to check
context.UpdateState(s => s with
{
MiddlewareState = s.MiddlewareState.WithBatchApprovals(
response.ApprovedFunctions.ToHashSet()
)
});
}
catch (OperationCanceledException)
{
// Timeout - deny all
context.UpdateState(s => s with
{
MiddlewareState = s.MiddlewareState.WithBatchApprovals(new HashSet<string>())
});
}
}
public Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
{
var isApproved = context.Analyze(s =>
s.MiddlewareState.BatchApprovals?.Contains(context.Function.Name) ?? false
);
if (!isApproved)
{
context.BlockExecution = true;
context.OverrideResult = "User did not approve this function";
}
return Task.CompletedTask;
}
}UI Code:
csharp
var agent = new AgentBuilder()
.WithMiddleware(new HumanApprovalMiddleware())
.Build();
await foreach (var evt in agent.RunAsync("Book a flight to NYC", ct))
{
switch (evt)
{
case BatchApprovalRequestEvent req:
// Show dialog
var approved = await ShowBatchApprovalDialog(req.Functions);
// Respond
await agent.EmitEventAsync(new BatchApprovalResponseEvent
{
RequestId = req.RequestId,
ApprovedFunctions = approved
});
break;
case TextDeltaEvent text:
Console.Write(text.Text);
break;
}
}Progress Updates
Show progress during long operations:
csharp
public async Task<ModelResponse> WrapModelCallAsync(
ModelRequest request,
Func<ModelRequest, Task<ModelResponse>> handler,
CancellationToken ct)
{
context.Emit(new TextDeltaEvent { Text = "🤔 Thinking..." });
var response = await handler(request);
context.Emit(new TextDeltaEvent { Text = " Response ready\n" });
return response;
}Custom Event Types
Define your own events:
csharp
public class TokenUsageEvent : AgentEvent
{
public required int PromptTokens { get; init; }
public required int CompletionTokens { get; init; }
public required decimal Cost { get; init; }
}
public class AuditLogEvent : AgentEvent
{
public required string Action { get; init; }
public required string UserId { get; init; }
public required DateTime Timestamp { get; init; }
}Usage:
csharp
public async Task<ModelResponse> WrapModelCallAsync(
ModelRequest request,
Func<ModelRequest, Task<ModelResponse>> handler,
CancellationToken ct)
{
var startTime = DateTime.UtcNow;
var response = await handler(request);
var duration = DateTime.UtcNow - startTime;
// Emit telemetry
context.Emit(new TokenUsageEvent
{
PromptTokens = response.Usage?.PromptTokens ?? 0,
CompletionTokens = response.Usage?.CompletionTokens ?? 0,
Cost = CalculateCost(response.Usage)
});
// Emit audit log
var userId = request.State.Analyze(s => s.UserId);
context.Emit(new AuditLogEvent
{
Action = "llm_call",
UserId = userId,
Timestamp = DateTime.UtcNow
});
return response;
}Multi-Step Workflows
Chain multiple request/response interactions:
csharp
public async Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
{
// Step 1: Request permission
var permRequestId = Guid.NewGuid().ToString();
context.Emit(new PermissionRequestEvent
{
FunctionName = context.Function.Name,
RequestId = permRequestId
});
var permResponse = await context.WaitForResponseAsync<PermissionResponseEvent>(
permRequestId,
ct
);
if (!permResponse.Approved)
{
context.BlockExecution = true;
return;
}
// Step 2: Request additional parameters (if needed)
if (NeedsMoreInfo(context.Arguments))
{
var paramRequestId = Guid.NewGuid().ToString();
context.Emit(new ParameterRequestEvent
{
FunctionName = context.Function.Name,
MissingParameters = GetMissingParams(context.Arguments),
RequestId = paramRequestId
});
var paramResponse = await context.WaitForResponseAsync<ParameterResponseEvent>(
paramRequestId,
ct
);
// Merge parameters
MergeParameters(context.Arguments, paramResponse.Parameters);
}
// Function now has permission and all required parameters
}Event Filtering
Filter events in your UI:
csharp
await foreach (var evt in agent.RunAsync("Search flights", ct))
{
switch (evt)
{
// Only handle specific event types
case TextDeltaEvent text:
Console.Write(text.Text);
break;
case PermissionRequestEvent req:
await HandlePermissionRequest(req);
break;
case TokenUsageEvent usage:
UpdateCostDisplay(usage.Cost);
break;
// Ignore all other events
default:
break;
}
}Best Practices
1. Always Use Request IDs
csharp
// GOOD: Unique request ID
var requestId = Guid.NewGuid().ToString();
context.Emit(new MyRequestEvent { RequestId = requestId });
var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, ct);
// BAD: Hardcoded or predictable ID
context.Emit(new MyRequestEvent { RequestId = "request1" });2. Set Timeouts
csharp
// GOOD: Timeout to prevent hanging
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30));
try
{
var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, cts.Token);
}
catch (OperationCanceledException)
{
// Handle timeout
}
// BAD: No timeout
var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, ct);3. Handle Cancellation
csharp
// GOOD: Graceful cancellation handling
try
{
var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, ct);
// Process response
}
catch (OperationCanceledException)
{
context.BlockExecution = true;
context.OverrideResult = "Operation cancelled";
}
// BAD: Let exception propagate
var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, ct);4. Validate Responses
csharp
// GOOD: Validate response
var response = await context.WaitForResponseAsync<PermissionResponseEvent>(requestId, ct);
if (response == null || string.IsNullOrEmpty(response.RequestId))
{
throw new InvalidOperationException("Invalid response received");
}
if (response.RequestId != requestId)
{
throw new InvalidOperationException("Response ID mismatch");
}
// BAD: Trust response blindly
var response = await context.WaitForResponseAsync<PermissionResponseEvent>(requestId, ct);
context.BlockExecution = !response.Approved;Next Steps
- 05.1 Middleware Lifecycle - All middleware hooks
- 05.2 Middleware State - Store approvals in state
- 05.4 Built-in Middleware - See event usage in practice
- 05.5 Custom Middleware - Build interactive middleware