Skip to content

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

@larohra

Description

@larohra

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)

// 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:

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)

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)

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)

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

/// <summary>
/// Gets the optional model ID override for this request.
/// When set, overrides the agent's default model for this invocation only.
/// </summary>
public string? ModelId { get; init; }

2. DurableAgentRunOptions.cs — Add caller-facing property

/// <summary>
/// Gets or sets the model ID to use for this request, overriding the agent's default.
/// </summary>
public string? ModelId { get; set; }

3. DurableAIAgent.cs (~line 112) — Extract and pipe through

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

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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    .NETazure-functionsIssues and PRs related to Azure FunctionsenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions