microsoft / microsoft/agent-framework

.NET: [Feature] Support per-invocation ModelId override on DurableAIAgent

Open
#4,489 0 comments 0 reactions 0 assignees View on GitHub
.NET hosting
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.