microsoft / microsoft/agent-framework
.NET: [Feature] Support per-invocation ModelId override on DurableAIAgent
- Dominant language
- Python
- Stars
- 13.6k
- Forks
- 2.3k
- Avg merge
- 2d 45m
- Merged PRs (30d)
- 358
Description
## Summary
`DurableAIAgent.RunAsync()` does not support per-invocation model override. The underlying `ChatClientAgent` already honors per-request `ChatOptions.ModelId` (via `CreateConfiguredChatOptions` line 493: `requestChatOptions.ModelId ??= agentDefault`), but the durable pipeline drops the `ModelId` at the serialization boundary — `RunRequest` has no `ModelId` field.
This means consumers who register a single `DurableAIAgent` entity cannot vary the LLM model per-request. The model is baked in at DI registration time and applies to all invocations.
## Current Behavior (Traced Through Source)
### 1. Orchestration Side — `DurableAIAgent.RunCoreAsync` (DurableAIAgent.cs:109-132)
```csharp
// Extracts from DurableAgentRunOptions:
enableToolCalls = durableOptions.EnableToolCalls;
enableToolNames = durableOptions.EnableToolNames;
// ⚠️ No ModelId extraction
// Builds RunRequest WITHOUT ModelId:
RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames)
{
OrchestrationId = this._context.InstanceId
};
```
If `ChatClientAgentRunOptions` is passed instead (line 117-121), only `ResponseFormat` is extracted — `ChatOptions.ModelId` is ignored:
```csharp
else if (options is ChatClientAgentRunOptions chatClientOptions
&& chatClientOptions.ChatOptions?.Tools != null)
{
responseFormat = chatClientOptions.ChatOptions?.ResponseFormat;
// ⚠️ ModelId NOT extracted
}
```
### 2. DTS Boundary — `RunRequest` (RunRequest.cs)
`RunRequest` carries: `Messages`, `ResponseFormat`, `EnableToolCalls`, `EnableToolNames`, `CorrelationId`, `OrchestrationId`.
**No `ModelId` field.** This is the serialization boundary — anything not on `RunRequest` cannot cross to the entity side.
### 3. Entity Side — `AgentEntity.Run` (AgentEntity.cs:32-72)
```csharp
AIAgent agent = this.GetAgent(sessionId); // Returns agent with original SessionConfig
EntityAgentWrapper agentWrapper = new(agent, this.Context, request, this._services);
agentWrapper.RunStreamingAsync(history, session, options: null, ct); // options always null
```
### 4. Entity Side — `EntityAgentWrapper.GetAgentEntityRunOptions` (EntityAgentWrapper.cs:71-124)
```csharp
return builder.ConfigureOptions(newOptions =>
{
if (this._runRequest.ResponseFormat is not null)
newOptions.ResponseFormat = this._runRequest.ResponseFormat;
// Tool filtering...
// ⚠️ No ModelId override
}).Build();
```
### 5. Base Class — `ChatClientAgent.CreateConfiguredChatOptions` (ChatClientAgent.cs:493)
```csharp
requestChatOptions.ModelId ??= this._agentOptions.ChatOptions.ModelId;
```
**This already supports per-request override** — if `ModelId` were set on the request `ChatOptions`, it would take priority.
## Proposed Solution
Add `ModelId` to the durable agent pipeline, following the existing pattern for `ResponseFormat` and `EnableToolCalls`. This is a small, backward-compatible change across 4 files (~15 lines added):
### 1. `RunRequest.cs` — Add serializable field
```csharp
///
/// Gets the optional model ID override for this request.
/// When set, overrides the agent's default model for this invocation only.
///
public string? ModelId { get; init; }
```
### 2. `DurableAgentRunOptions.cs` — Add caller-facing property
```csharp
///
/// Gets or sets the model ID to use for this request, overriding the agent's default.
///
public string? ModelId { get; set; }
```
### 3. `DurableAIAgent.cs` (~line 112) — Extract and pipe through
```csharp
string? modelId = null;
if (options is DurableAgentRunOptions durableOptions)
{
enableToolCalls = durableOptions.EnableToolCalls;
enableToolNames = durableOptions.EnableToolNames;
modelId = durableOptions.ModelId; // NEW
}
RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames)
{
OrchestrationId = this._context.InstanceId,
ModelId = modelId, // NEW
};
```
### 4. `EntityAgentWrapper.cs` (~line 99) — Apply in ConfigureOptions
```csharp
return builder.ConfigureOptions(newOptions =>
{
if (this._runRequest.ResponseFormat is not null)
newOptions.ResponseFormat = this._runRequest.ResponseFormat;
if (this._runRequest.ModelId is not null) // NEW
newOptions.ModelId = this._runRequest.ModelId; // NEW
// ... existing tool filtering ...
}).Build();
```
### Usage
```csharp
DurableAIAgent worker = context.GetAgent("WorkerAgent");
AgentSession session = await worker.CreateSessionAsync();
var options = new DurableAgentRunOptions { ModelId = "gpt-5.3-codex" };
AgentResponse response = await worker.RunAsync(prompt, session, options: options);
```
## Alternatives Considered
| Approach | Drawback |
|----------|----------|
| Register multiple agents per model | Entity sprawl, doesn't scale |
| Use `AddAIAgentFactory` with ambient state | Factory only receives `IServiceProvider`, no per-request context |
| Convert to non-durable `AIAgent` | Loses DTS durability, checkpointing, and retry benefits |
| Set model at registration time | Static default only — all requests use same model |
Contributor guide
Research direction
Start with DurableAIAgent.cs and DurableAgentRunOptions.cs, then trace the existing ResponseFormat and tool-option flow through RunRequest.cs and EntityAgentWrapper.cs to ChatClientAgent.cs. Verify that a per-invocation ModelId can cross the durable boundary and override the default without changing requests that omit it.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- ai-infra-agents, backend
- Issue type
- Feature
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100