Skip to content

Commit 0204116

Browse files
authored
Add ChatOptions.Instructions (#6505)
More services (especially those focused on agents) are starting to support the notion of per-request instructions, effectively system messages that aren't stored as part of a persisted chat history. For existing stateless services that don't have their own notion of separate instructions, we can just translate instructions into a system message.
1 parent 4bea223 commit 0204116

File tree

9 files changed

+82
-27
lines changed

9 files changed

+82
-27
lines changed

src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ public class ChatOptions
1515
/// <related type="Article" href="https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai#stateless-vs-stateful-clients">Stateless vs. stateful clients.</related>
1616
public string? ConversationId { get; set; }
1717

18+
/// <summary>Gets or sets additional per-request instructions to be provided to the <see cref="IChatClient"/>.</summary>
19+
public string? Instructions { get; set; }
20+
1821
/// <summary>Gets or sets the temperature for generating chat responses.</summary>
1922
/// <remarks>
2023
/// This value controls the randomness of predictions made by the model. Use a lower value to decrease randomness in the response.
@@ -146,20 +149,21 @@ public virtual ChatOptions Clone()
146149
{
147150
ChatOptions options = new()
148151
{
152+
AdditionalProperties = AdditionalProperties?.Clone(),
153+
AllowMultipleToolCalls = AllowMultipleToolCalls,
149154
ConversationId = ConversationId,
150-
Temperature = Temperature,
151-
MaxOutputTokens = MaxOutputTokens,
152-
TopP = TopP,
153-
TopK = TopK,
154155
FrequencyPenalty = FrequencyPenalty,
156+
Instructions = Instructions,
157+
MaxOutputTokens = MaxOutputTokens,
158+
ModelId = ModelId,
155159
PresencePenalty = PresencePenalty,
156-
Seed = Seed,
160+
RawRepresentationFactory = RawRepresentationFactory,
157161
ResponseFormat = ResponseFormat,
158-
ModelId = ModelId,
159-
AllowMultipleToolCalls = AllowMultipleToolCalls,
162+
Seed = Seed,
163+
Temperature = Temperature,
160164
ToolMode = ToolMode,
161-
RawRepresentationFactory = RawRepresentationFactory,
162-
AdditionalProperties = AdditionalProperties?.Clone(),
165+
TopK = TopK,
166+
TopP = TopP,
163167
};
164168

165169
if (StopSequences is not null)

src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,10 @@
923923
"Member": "string? Microsoft.Extensions.AI.ChatOptions.ConversationId { get; set; }",
924924
"Stage": "Stable"
925925
},
926+
{
927+
"Member": "string? Microsoft.Extensions.AI.ChatOptions.Instructions { get; set; }",
928+
"Stage": "Stable"
929+
},
926930
{
927931
"Member": "float? Microsoft.Extensions.AI.ChatOptions.FrequencyPenalty { get; set; }",
928932
"Stage": "Stable"
@@ -2286,4 +2290,4 @@
22862290
]
22872291
}
22882292
]
2289-
}
2293+
}

src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ private static ChatRole ToChatRole(global::Azure.AI.Inference.ChatRole role) =>
283283
new(s);
284284

285285
private ChatCompletionsOptions CreateAzureAIOptions(IEnumerable<ChatMessage> chatContents, ChatOptions? options) =>
286-
new(ToAzureAIInferenceChatMessages(chatContents))
286+
new(ToAzureAIInferenceChatMessages(chatContents, options))
287287
{
288288
Model = options?.ModelId ?? _metadata.DefaultModelId ??
289289
throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options.")
@@ -299,7 +299,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable<ChatMessage> chatCon
299299

300300
if (options.RawRepresentationFactory?.Invoke(this) is ChatCompletionsOptions result)
301301
{
302-
result.Messages = ToAzureAIInferenceChatMessages(chatContents).ToList();
302+
result.Messages = ToAzureAIInferenceChatMessages(chatContents, options).ToList();
303303
result.Model ??= options.ModelId ?? _metadata.DefaultModelId ??
304304
throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options.");
305305
}
@@ -422,11 +422,16 @@ private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunction aiFunc
422422
}
423423

424424
/// <summary>Converts an Extensions chat message enumerable to an AzureAI chat message enumerable.</summary>
425-
private static IEnumerable<ChatRequestMessage> ToAzureAIInferenceChatMessages(IEnumerable<ChatMessage> inputs)
425+
private static IEnumerable<ChatRequestMessage> ToAzureAIInferenceChatMessages(IEnumerable<ChatMessage> inputs, ChatOptions? options)
426426
{
427427
// Maps all of the M.E.AI types to the corresponding AzureAI types.
428428
// Unrecognized or non-processable content is ignored.
429429

430+
if (options?.Instructions is { } instructions && !string.IsNullOrWhiteSpace(instructions))
431+
{
432+
yield return new ChatRequestSystemMessage(instructions);
433+
}
434+
430435
foreach (ChatMessage input in inputs)
431436
{
432437
if (input.Role == ChatRole.System)

src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,10 +307,20 @@ private static FunctionCallContent ToFunctionCallContent(OllamaFunctionToolCall
307307

308308
private OllamaChatRequest ToOllamaChatRequest(IEnumerable<ChatMessage> messages, ChatOptions? options, bool stream)
309309
{
310+
var requestMessages = messages.SelectMany(ToOllamaChatRequestMessages).ToList();
311+
if (options?.Instructions is string instructions)
312+
{
313+
requestMessages.Insert(0, new OllamaChatRequestMessage
314+
{
315+
Role = ChatRole.System.Value,
316+
Content = instructions,
317+
});
318+
}
319+
310320
OllamaChatRequest request = new()
311321
{
312322
Format = ToOllamaChatResponseFormat(options?.ResponseFormat),
313-
Messages = messages.SelectMany(ToOllamaChatRequestMessages).ToArray(),
323+
Messages = requestMessages,
314324
Model = options?.ModelId ?? _metadata.DefaultModelId ?? string.Empty,
315325
Stream = stream,
316326
Tools = options?.ToolMode is not NoneChatToolMode && options?.Tools is { Count: > 0 } tools ? tools.OfType<AIFunction>().Select(ToOllamaTool) : null,

src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace Microsoft.Extensions.AI;
99
internal sealed class OllamaChatRequest
1010
{
1111
public required string Model { get; set; }
12-
public required OllamaChatRequestMessage[] Messages { get; set; }
12+
public required IList<OllamaChatRequestMessage> Messages { get; set; }
1313
public JsonElement? Format { get; set; }
1414
public bool Stream { get; set; }
1515
public IEnumerable<OllamaTool>? Tools { get; set; }

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -348,8 +348,27 @@ void IDisposable.Dispose()
348348
}
349349
}
350350

351-
// Process ChatMessages.
351+
// Configure system instructions.
352352
StringBuilder? instructions = null;
353+
void AppendSystemInstructions(string? toAppend)
354+
{
355+
if (!string.IsNullOrEmpty(toAppend))
356+
{
357+
if (instructions is null)
358+
{
359+
instructions = new(toAppend);
360+
}
361+
else
362+
{
363+
_ = instructions.AppendLine().AppendLine(toAppend);
364+
}
365+
}
366+
}
367+
368+
AppendSystemInstructions(runOptions.AdditionalInstructions);
369+
AppendSystemInstructions(options?.Instructions);
370+
371+
// Process ChatMessages.
353372
List<FunctionResultContent>? functionResults = null;
354373
foreach (var chatMessage in messages)
355374
{
@@ -365,10 +384,9 @@ void IDisposable.Dispose()
365384
if (chatMessage.Role == ChatRole.System ||
366385
chatMessage.Role == OpenAIResponseChatClient.ChatRoleDeveloper)
367386
{
368-
instructions ??= new();
369387
foreach (var textContent in chatMessage.Contents.OfType<TextContent>())
370388
{
371-
_ = instructions.Append(textContent);
389+
AppendSystemInstructions(textContent.Text);
372390
}
373391

374392
continue;
@@ -409,10 +427,7 @@ void IDisposable.Dispose()
409427
}
410428
}
411429

412-
if (instructions is not null)
413-
{
414-
runOptions.AdditionalInstructions = instructions.ToString();
415-
}
430+
runOptions.AdditionalInstructions = instructions?.ToString();
416431

417432
return (runOptions, functionResults);
418433
}

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public async Task<ChatResponse> GetResponseAsync(
8080
{
8181
_ = Throw.IfNull(messages);
8282

83-
var openAIChatMessages = ToOpenAIChatMessages(messages, AIJsonUtilities.DefaultOptions);
83+
var openAIChatMessages = ToOpenAIChatMessages(messages, options, AIJsonUtilities.DefaultOptions);
8484
var openAIOptions = ToOpenAIOptions(options);
8585

8686
// Make the call to OpenAI.
@@ -95,7 +95,7 @@ public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
9595
{
9696
_ = Throw.IfNull(messages);
9797

98-
var openAIChatMessages = ToOpenAIChatMessages(messages, AIJsonUtilities.DefaultOptions);
98+
var openAIChatMessages = ToOpenAIChatMessages(messages, options, AIJsonUtilities.DefaultOptions);
9999
var openAIOptions = ToOpenAIOptions(options);
100100

101101
// Make the call to OpenAI.
@@ -111,11 +111,16 @@ void IDisposable.Dispose()
111111
}
112112

113113
/// <summary>Converts an Extensions chat message enumerable to an OpenAI chat message enumerable.</summary>
114-
private static IEnumerable<OpenAI.Chat.ChatMessage> ToOpenAIChatMessages(IEnumerable<ChatMessage> inputs, JsonSerializerOptions options)
114+
private static IEnumerable<OpenAI.Chat.ChatMessage> ToOpenAIChatMessages(IEnumerable<ChatMessage> inputs, ChatOptions? chatOptions, JsonSerializerOptions jsonOptions)
115115
{
116116
// Maps all of the M.E.AI types to the corresponding OpenAI types.
117117
// Unrecognized or non-processable content is ignored.
118118

119+
if (chatOptions?.Instructions is { } instructions && !string.IsNullOrWhiteSpace(instructions))
120+
{
121+
yield return new SystemChatMessage(instructions);
122+
}
123+
119124
foreach (ChatMessage input in inputs)
120125
{
121126
if (input.Role == ChatRole.System ||
@@ -139,7 +144,7 @@ void IDisposable.Dispose()
139144
{
140145
try
141146
{
142-
result = JsonSerializer.Serialize(resultContent.Result, options.GetTypeInfo(typeof(object)));
147+
result = JsonSerializer.Serialize(resultContent.Result, jsonOptions.GetTypeInfo(typeof(object)));
143148
}
144149
catch (NotSupportedException)
145150
{
@@ -167,7 +172,7 @@ void IDisposable.Dispose()
167172
case FunctionCallContent fc:
168173
(toolCalls ??= []).Add(
169174
ChatToolCall.CreateFunctionToolCall(fc.CallId, fc.Name, new(JsonSerializer.SerializeToUtf8Bytes(
170-
fc.Arguments, options.GetTypeInfo(typeof(IDictionary<string, object?>))))));
175+
fc.Arguments, jsonOptions.GetTypeInfo(typeof(IDictionary<string, object?>))))));
171176
break;
172177

173178
default:

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,12 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt
366366
result.TopP ??= options.TopP;
367367
result.Temperature ??= options.Temperature;
368368
result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls;
369+
if (options.Instructions is { } instructions)
370+
{
371+
result.Instructions = string.IsNullOrEmpty(result.Instructions) ?
372+
instructions :
373+
$"{result.Instructions}{Environment.NewLine}{instructions}";
374+
}
369375

370376
// Populate tools if there are any.
371377
if (options.Tools is { Count: > 0 } tools)

test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public void Constructor_Parameterless_PropsDefaulted()
1515
{
1616
ChatOptions options = new();
1717
Assert.Null(options.ConversationId);
18+
Assert.Null(options.Instructions);
1819
Assert.Null(options.Temperature);
1920
Assert.Null(options.MaxOutputTokens);
2021
Assert.Null(options.TopP);
@@ -33,6 +34,7 @@ public void Constructor_Parameterless_PropsDefaulted()
3334

3435
ChatOptions clone = options.Clone();
3536
Assert.Null(clone.ConversationId);
37+
Assert.Null(clone.Instructions);
3638
Assert.Null(clone.Temperature);
3739
Assert.Null(clone.MaxOutputTokens);
3840
Assert.Null(clone.TopP);
@@ -75,6 +77,7 @@ public void Properties_Roundtrip()
7577
Func<IChatClient, object?> rawRepresentationFactory = (c) => null;
7678

7779
options.ConversationId = "12345";
80+
options.Instructions = "Some instructions";
7881
options.Temperature = 0.1f;
7982
options.MaxOutputTokens = 2;
8083
options.TopP = 0.3f;
@@ -92,6 +95,7 @@ public void Properties_Roundtrip()
9295
options.AdditionalProperties = additionalProps;
9396

9497
Assert.Equal("12345", options.ConversationId);
98+
Assert.Equal("Some instructions", options.Instructions);
9599
Assert.Equal(0.1f, options.Temperature);
96100
Assert.Equal(2, options.MaxOutputTokens);
97101
Assert.Equal(0.3f, options.TopP);
@@ -144,6 +148,7 @@ public void JsonSerialization_Roundtrips()
144148
};
145149

146150
options.ConversationId = "12345";
151+
options.Instructions = "Some instructions";
147152
options.Temperature = 0.1f;
148153
options.MaxOutputTokens = 2;
149154
options.TopP = 0.3f;
@@ -170,6 +175,7 @@ public void JsonSerialization_Roundtrips()
170175
Assert.NotNull(deserialized);
171176

172177
Assert.Equal("12345", deserialized.ConversationId);
178+
Assert.Equal("Some instructions", deserialized.Instructions);
173179
Assert.Equal(0.1f, deserialized.Temperature);
174180
Assert.Equal(2, deserialized.MaxOutputTokens);
175181
Assert.Equal(0.3f, deserialized.TopP);

0 commit comments

Comments
 (0)