Skip to content

Commit 5afb5a2

Browse files
authored
Updated Conversation functionality (with tools support) (#1606)
- Removed old conversation implementation and replaced with new approach with tool calling support. - Updated corresponding example. - Added unit tests for the new Enum extension methods --------- Signed-off-by: Whit Waldo <[email protected]>
1 parent 65d30ec commit 5afb5a2

33 files changed

+1286
-157
lines changed
Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Dapr.AI.Conversation;
2+
using Dapr.AI.Conversation.ConversationRoles;
23
using Dapr.AI.Conversation.Extensions;
34

45
var builder = WebApplication.CreateBuilder(args);
@@ -8,16 +9,46 @@
89
var app = builder.Build();
910

1011
var conversationClient = app.Services.GetRequiredService<DaprConversationClient>();
11-
var response = await conversationClient.ConverseAsync("conversation",
12-
new List<DaprConversationInput>
13-
{
14-
new DaprConversationInput(
15-
"Please write a witty haiku about the Dapr distributed programming framework at dapr.io",
16-
DaprConversationRole.Generic)
17-
});
12+
13+
// Send a message to the conversation service
14+
var response = await conversationClient.ConverseAsync(
15+
[
16+
new ConversationInput(
17+
new List<IConversationMessage>
18+
{
19+
new UserMessage
20+
{
21+
Name = "Test User",
22+
Content =
23+
[
24+
new MessageContent(
25+
"Please write a witty haiku about the Dapr distributed programming framework at dapr.io")
26+
]
27+
}
28+
}
29+
)
30+
],
31+
new ConversationOptions("conversation")
32+
);
1833

1934
Console.WriteLine("Received the following from the LLM:");
2035
foreach (var resp in response.Outputs)
2136
{
22-
Console.WriteLine($"\t{resp.Result}");
37+
foreach (var choice in resp.Choices)
38+
{
39+
Console.WriteLine($"{choice.Index} - Reason: {choice.FinishReason}");
40+
Console.WriteLine($"\tMesage: '{choice.Message.Content}'");
41+
Console.WriteLine("\tTools:");
42+
foreach (var tool in choice.Message.ToolCalls)
43+
{
44+
if (tool is CalledToolFunction calledToolFunction)
45+
{
46+
Console.WriteLine($"\t\tId: {calledToolFunction.Id}, Name: {calledToolFunction.Name}, Arguments: {calledToolFunction.JsonArguments}");
47+
}
48+
else
49+
{
50+
Console.WriteLine($"\t\tId: {tool.Id}");
51+
}
52+
}
53+
}
2354
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Dapr.AI.Conversation;
2+
3+
/// <summary>
4+
/// Documents a tool call by a function within the context of a conversation message.
5+
/// </summary>
6+
/// <param name="Name">The name of the tool called.</param>
7+
/// <param name="JsonArguments">The JSON arguments populated by the model. These might be hallucinated and invalid (e.g. format, values, etc.).</param>
8+
public record CalledToolFunction(string Name, string JsonArguments) : ToolCallBase;

src/Dapr.AI/Conversation/DaprConversationInput.cs renamed to src/Dapr.AI/Conversation/ConversationInput.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@
1111
// limitations under the License.
1212
// ------------------------------------------------------------------------
1313

14+
using Dapr.AI.Conversation.ConversationRoles;
15+
1416
namespace Dapr.AI.Conversation;
1517

1618
/// <summary>
1719
/// Represents an input for the Dapr Conversational API.
1820
/// </summary>
19-
/// <param name="Content">The content to send to the LLM.</param>
20-
/// <param name="Role">The role indicating the entity providing the message.</param>
21+
/// <param name="Messages">The message content to send to the LLM.</param>
2122
/// <param name="ScrubPII">If true, scrubs the data that goes into the LLM.</param>
22-
public sealed record DaprConversationInput(string Content, DaprConversationRole Role, bool ScrubPII = false);
23+
public sealed record ConversationInput(
24+
IReadOnlyList<IConversationMessage> Messages,
25+
bool? ScrubPII = null);

src/Dapr.AI/Conversation/ConversationOptions.cs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,61 @@
1111
// limitations under the License.
1212
// ------------------------------------------------------------------------
1313

14+
using Dapr.AI.Conversation.Tools;
1415
using Google.Protobuf.WellKnownTypes;
1516

1617
namespace Dapr.AI.Conversation;
1718

1819
/// <summary>
1920
/// Options used to configure the conversation operation.
2021
/// </summary>
21-
/// <param name="ConversationId">The identifier of the conversation this is a continuation of.</param>
22-
public sealed record ConversationOptions(string? ConversationId = null)
22+
/// <param name="ConversationComponentId">The ID of the conversation component to use.</param>
23+
public sealed record ConversationOptions(string ConversationComponentId)
2324
{
25+
/// <summary>
26+
/// The ID of an existing chat.
27+
/// </summary>
28+
public string? ContextId { get; init; }
29+
2430
/// <summary>
2531
/// Temperature for the LLM to optimize for creativity or predictability.
2632
/// </summary>
27-
public double Temperature { get; init; } = default;
33+
public double? Temperature { get; init; }
34+
2835
/// <summary>
2936
/// Flag that indicates whether data that comes back from the LLM should be scrubbed of PII data.
3037
/// </summary>
31-
public bool ScrubPII { get; init; } = default;
38+
public bool? ScrubPII { get; init; }
39+
3240
/// <summary>
3341
/// The metadata passing to the conversation components.
3442
/// </summary>
3543
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
44+
3645
/// <summary>
3746
/// Parameters for all custom fields.
3847
/// </summary>
3948
public IReadOnlyDictionary<string, Any> Parameters { get; init; } = new Dictionary<string, Any>();
49+
50+
/// <summary>
51+
/// Registers the tools available to be used by the LLM during the conversation. These are sent on a per-request
52+
/// basis.
53+
/// </summary>
54+
/// <remarks>
55+
/// The tools available during the first round of the conversation may be different than the tools specified later
56+
/// on.
57+
/// </remarks>
58+
public IReadOnlyList<ITool> Tools { get; init; } = [];
59+
60+
/// <summary>
61+
/// Controls which (if any) tool is called by the model.
62+
/// - 'none' means that the model will not call any tool and instead generate a message.
63+
/// - 'auto' means that the model can picked between generating a message or calling one or more tools.
64+
/// - Alternatively, a specific tool name may be used here and casing/syntax must match on the tool name.
65+
/// </summary>
66+
/// <remarks>
67+
/// - 'none' is the default when no tools are present.
68+
/// - 'auto' is the default if tools are present.
69+
/// </remarks>
70+
public ToolChoice? ToolChoice { get; init; }
4071
}
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
// ------------------------------------------------------------------------
2+
// Copyright 2025 The Dapr Authors
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
// Unless required by applicable law or agreed to in writing, software
8+
// distributed under the License is distributed on an "AS IS" BASIS,
9+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
// See the License for the specific language governing permissions and
11+
// limitations under the License.
12+
// ------------------------------------------------------------------------
13+
14+
using Dapr.AI.Conversation.ConversationRoles;
15+
using Dapr.AI.Conversation.Extensions;
16+
using Dapr.AI.Conversation.Tools;
17+
using Dapr.Common.Extensions;
18+
using Google.Protobuf.WellKnownTypes;
19+
using Autogenerated = Dapr.Client.Autogen.Grpc.v1;
20+
21+
namespace Dapr.AI.Conversation;
22+
23+
/// <summary>
24+
/// Prototype utilities used for mapping domain to protobuf types for the Conversation functionality.
25+
/// </summary>
26+
public static class ConversationProtoUtilities
27+
{
28+
/// <summary>
29+
/// Converts an <see cref="IEnumerable{MessageContent}"/> into its protobuf equivalent.
30+
/// </summary>
31+
/// <param name="contents">The contents to map.</param>
32+
/// <returns></returns>
33+
public static IEnumerable<Autogenerated.ConversationMessageContent> ToProtoContents(
34+
this IEnumerable<MessageContent> contents) =>
35+
contents.Select(c => new Autogenerated.ConversationMessageContent { Text = c.Text });
36+
37+
/// <summary>
38+
/// Converts an <see cref="IReadOnlyList{ConversationInput}"/> into its protobuf equivalent.
39+
/// </summary>
40+
/// <param name="inputs">The contents to map.</param>
41+
/// <returns></returns>
42+
private static IEnumerable<Autogenerated.ConversationInputAlpha2> ToProto(this IReadOnlyList<ConversationInput> inputs) =>
43+
inputs.Select(input =>
44+
{
45+
var messages = input.Messages.Select(message => message.ToProto()).ToRepeatedField();
46+
var output = new Autogenerated.ConversationInputAlpha2();
47+
output.Messages.AddRange(messages);
48+
49+
if (input.ScrubPII is not null)
50+
{
51+
output.ScrubPii = input.ScrubPII.Value;
52+
}
53+
54+
return output;
55+
});
56+
57+
/// <summary>
58+
/// Creates an <see cref="Autogenerated.ConversationRequestAlpha2"/> from the provided inputs and options.
59+
/// </summary>
60+
/// <param name="inputs">The conversation inputs.</param>
61+
/// <param name="options">The conversation options.</param>
62+
/// <returns></returns>
63+
/// <exception cref="NotSupportedException"></exception>
64+
public static Autogenerated.ConversationRequestAlpha2 CreateConversationInputRequest(IReadOnlyList<ConversationInput> inputs,
65+
ConversationOptions options)
66+
{
67+
var request = new Autogenerated.ConversationRequestAlpha2
68+
{
69+
Name = options.ConversationComponentId,
70+
ContextId = options.ContextId,
71+
ToolChoice = options.ToolChoice?.ToString(),
72+
};
73+
request.Inputs.AddRange(inputs.ToProto());
74+
75+
foreach (var p in options.Parameters)
76+
{
77+
request.Parameters.Add(p.Key, p.Value);
78+
}
79+
80+
foreach (var m in options.Metadata)
81+
{
82+
request.Metadata.Add(m.Key, m.Value);
83+
}
84+
85+
if (options.ScrubPII is not null)
86+
{
87+
request.ScrubPii = options.ScrubPII.Value;
88+
}
89+
90+
if (options.Temperature is not null)
91+
{
92+
request.Temperature = options.Temperature.Value;
93+
}
94+
95+
if (options.Tools.Count > 0)
96+
{
97+
var tools = options.Tools.Select(tool =>
98+
{
99+
switch (tool)
100+
{
101+
case ToolFunction toolF:
102+
{
103+
var toolFunction = new Autogenerated.ConversationToolsFunction
104+
{
105+
Name = toolF.Name, Description = toolF.Description,
106+
};
107+
108+
var parametersStruct = new Struct();
109+
foreach (var (k, v) in toolF.Parameters)
110+
{
111+
parametersStruct.Fields[k] = ProtobufHelpers.ToValue(v);
112+
}
113+
114+
toolFunction.Parameters = parametersStruct;
115+
116+
return new Autogenerated.ConversationTools { Function = toolFunction };
117+
}
118+
default:
119+
throw new NotSupportedException($"Unsupported tool type: {tool.GetType().FullName}");
120+
}
121+
}).ToRepeatedField();
122+
request.Tools.AddRange(tools);
123+
}
124+
125+
return request;
126+
}
127+
128+
/// <summary>
129+
/// Maps the <see cref="Autogenerated.ConversationResponseAlpha2"/> to a <see cref="ConversationResponse"/>.
130+
/// </summary>
131+
/// <param name="result">The result from the conversation API to parse.</param>
132+
/// <returns></returns>
133+
public static ConversationResponse ToDomain(this Autogenerated.ConversationResponseAlpha2 result)
134+
{
135+
string? contextId = result.ContextId;
136+
var conversationResults = result.Outputs.Select(convoResult =>
137+
{
138+
var choices = convoResult.Choices.Select(c =>
139+
{
140+
var didParseReason = c.FinishReason.TryParseEnumMember<FinishReason>(out var parsedFinishReason);
141+
var resultMessage = new ResultMessage(c.Message.Content)
142+
{
143+
ToolCalls = c.Message.ToolCalls
144+
.Select(ToolCallBase (tc) =>
145+
new CalledToolFunction(tc.Function.Name, tc.Function.Arguments)
146+
{
147+
Id = tc.Id
148+
})
149+
.ToList()
150+
};
151+
152+
return new ConversationResultChoice(didParseReason ? parsedFinishReason : null,
153+
c.Index, resultMessage);
154+
}).ToList();
155+
return new ConversationResponseResult(choices);
156+
}).ToList();
157+
158+
return new ConversationResponse(conversationResults, contextId);
159+
}
160+
161+
/// <summary>
162+
/// Converts an <see cref="IConversationMessage"/> into its protobuf equivalent.
163+
/// </summary>
164+
/// <param name="message">The message to convert.</param>
165+
/// <returns></returns>
166+
/// <exception cref="ArgumentException"></exception>
167+
/// <exception cref="NotImplementedException"></exception>
168+
private static Autogenerated.ConversationMessage ToProto(this IConversationMessage message)
169+
{
170+
var messageContents = message.Content
171+
.Select(msg => new Autogenerated.ConversationMessageContent { Text = msg.Text })
172+
.ToList()
173+
.ToRepeatedField();
174+
175+
switch (message)
176+
{
177+
case DeveloperMessage devMessage:
178+
{
179+
var output = new Autogenerated.ConversationMessageOfDeveloper { Name = devMessage.Name };
180+
output.Content.AddRange(messageContents);
181+
182+
return new Autogenerated.ConversationMessage { OfDeveloper = output };
183+
}
184+
case UserMessage userMessage:
185+
{
186+
var output = new Autogenerated.ConversationMessageOfUser { Name = userMessage.Name };
187+
output.Content.AddRange(messageContents);
188+
189+
return new Autogenerated.ConversationMessage { OfUser = output };
190+
}
191+
case AssistantMessage assistantMessage:
192+
{
193+
var output = new Autogenerated.ConversationMessageOfAssistant { Name = assistantMessage.Name };
194+
output.Content.AddRange(messageContents);
195+
196+
var toolContent = assistantMessage.ToolCalls.Select(toolCall =>
197+
{
198+
if (toolCall is CalledToolFunction funcToolCall)
199+
{
200+
return new Autogenerated.ConversationToolCalls
201+
{
202+
Id = funcToolCall.Id,
203+
Function = new Autogenerated.ConversationToolCallsOfFunction
204+
{
205+
Name = funcToolCall.Name, Arguments = funcToolCall.JsonArguments
206+
}
207+
};
208+
}
209+
210+
throw new ArgumentException($"Unrecognized tool call type for identifier '{toolCall.Id}'");
211+
});
212+
output.ToolCalls.AddRange(toolContent);
213+
214+
return new Autogenerated.ConversationMessage { OfAssistant = output };
215+
}
216+
case SystemMessage systemMessage:
217+
{
218+
var output = new Autogenerated.ConversationMessageOfSystem { Name = systemMessage.Name };
219+
output.Content.AddRange(messageContents);
220+
221+
return new Autogenerated.ConversationMessage { OfSystem = output };
222+
}
223+
case ToolMessage toolMessage:
224+
{
225+
var output = new Autogenerated.ConversationMessageOfTool
226+
{
227+
Name = toolMessage.Name, ToolId = toolMessage.Id
228+
};
229+
output.Content.AddRange(messageContents);
230+
231+
return new Autogenerated.ConversationMessage { OfTool = output };
232+
}
233+
default:
234+
throw new NotImplementedException("Message type not recognized.");
235+
}
236+
}
237+
}

src/Dapr.AI/Conversation/DaprConversationResponse.cs renamed to src/Dapr.AI/Conversation/ConversationResponse.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ namespace Dapr.AI.Conversation;
1818
/// </summary>
1919
/// <param name="Outputs">The collection of conversation results.</param>
2020
/// <param name="ConversationId">The identifier of an existing or newly created conversation.</param>
21-
public record DaprConversationResponse(IReadOnlyList<DaprConversationResult> Outputs, string? ConversationId = null);
21+
public record ConversationResponse(IReadOnlyList<ConversationResponseResult> Outputs, string? ConversationId = null);

0 commit comments

Comments
 (0)