mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* Add declarative HttpRequestAction support to workflows * Clean up response body for diagnostics and fix tests. * Fix merge with main. * Remove redundant fallback for request content headers. * Add declarative InvokeHttpRequest sample * Fix solution file and update sample yaml comments * Add final newline to sample class to fix formatting failure
329 lines
13 KiB
C#
329 lines
13 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
|
using Microsoft.Agents.ObjectModel;
|
|
using Microsoft.Extensions.AI;
|
|
using Microsoft.PowerFx.Types;
|
|
|
|
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
|
|
|
internal static class ChatMessageExtensions
|
|
{
|
|
public static RecordValue ToRecord(this ChatMessage message) =>
|
|
FormulaValue.NewRecordFromFields(message.GetMessageFields());
|
|
|
|
/// <summary>
|
|
/// Merges the user-authored <paramref name="input"/> with the round-tripped
|
|
/// <paramref name="inputMessage"/> returned by <c>AgentProvider.CreateMessageAsync</c>
|
|
/// to produce the value stored in <c>System.LastMessage</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The agent service often strips or alters <see cref="TextContent"/> on round-trip,
|
|
/// while replacing inline media (<see cref="DataContent"/>, <see cref="UriContent"/>)
|
|
/// with server-side references (typically <see cref="HostedFileContent"/>).
|
|
/// We want both: the original text (so <c>=System.LastMessage.Text</c> works) and
|
|
/// the server's media references (so subsequent actions don't re-upload large blobs).
|
|
/// <para>
|
|
/// Strategy: keep <paramref name="inputMessage"/> as the base — it has the server-generated
|
|
/// <see cref="ChatMessage.MessageId"/> and any provider-augmented metadata, and is forward-
|
|
/// compatible with new properties added on <see cref="ChatMessage"/> in the abstractions
|
|
/// layer. Only the <see cref="ChatMessage.Contents"/> list is mutated to substitute
|
|
/// original <see cref="TextContent"/> items in place (and append any extras the round-trip
|
|
/// dropped). Non-text content items returned by the service are left untouched so
|
|
/// server-side references survive.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static ChatMessage MergeForLastMessage(this ChatMessage input, ChatMessage? inputMessage)
|
|
{
|
|
if (inputMessage is null)
|
|
{
|
|
return input;
|
|
}
|
|
|
|
// Build a queue of the original text items, in order. Fall back to ChatMessage.Text
|
|
// if the input has no explicit TextContent entries.
|
|
Queue<TextContent> originalTexts = new(input.Contents.OfType<TextContent>());
|
|
if (originalTexts.Count == 0 && !string.IsNullOrEmpty(input.Text))
|
|
{
|
|
originalTexts.Enqueue(new TextContent(input.Text));
|
|
}
|
|
|
|
// Replace TextContent items in inputMessage.Contents with the originals, in order.
|
|
for (int i = 0; i < inputMessage.Contents.Count && originalTexts.Count > 0; i++)
|
|
{
|
|
if (inputMessage.Contents[i] is TextContent)
|
|
{
|
|
inputMessage.Contents[i] = originalTexts.Dequeue();
|
|
}
|
|
}
|
|
|
|
// Append any remaining original text items that the round-trip dropped entirely.
|
|
while (originalTexts.Count > 0)
|
|
{
|
|
inputMessage.Contents.Add(originalTexts.Dequeue());
|
|
}
|
|
|
|
return inputMessage;
|
|
}
|
|
|
|
public static TableValue ToTable(this IEnumerable<ChatMessage> messages) =>
|
|
FormulaValue.NewTable(TypeSchema.Message.RecordType, messages.Select(message => message.ToRecord()));
|
|
|
|
public static IEnumerable<ChatMessage>? ToChatMessages(this DataValue? messages)
|
|
{
|
|
if (messages is null or BlankDataValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (messages is TableDataValue table)
|
|
{
|
|
return table.ToChatMessages();
|
|
}
|
|
|
|
if (messages is RecordDataValue record)
|
|
{
|
|
return [record.ToChatMessage()];
|
|
}
|
|
|
|
if (messages is StringDataValue text)
|
|
{
|
|
return [text.ToChatMessage()];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static IEnumerable<ChatMessage> ToChatMessages(this TableDataValue messages)
|
|
{
|
|
foreach (RecordDataValue record in messages.Values)
|
|
{
|
|
DataValue sourceRecord = record;
|
|
if (record.Properties.Count == 1 && record.Properties.TryGetValue("Value", out DataValue? singleColumn))
|
|
{
|
|
sourceRecord = singleColumn;
|
|
}
|
|
ChatMessage? convertedMessage = sourceRecord.ToChatMessage();
|
|
if (convertedMessage is not null)
|
|
{
|
|
yield return convertedMessage;
|
|
}
|
|
}
|
|
}
|
|
|
|
public static ChatMessage? ToChatMessage(this DataValue message)
|
|
{
|
|
if (message is RecordDataValue record)
|
|
{
|
|
return record.ToChatMessage();
|
|
}
|
|
|
|
if (message is StringDataValue text)
|
|
{
|
|
return text.ToChatMessage();
|
|
}
|
|
|
|
if (message is BlankDataValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
throw new DeclarativeActionException($"Unable to convert {message.GetDataType()} to {nameof(ChatMessage)}.");
|
|
}
|
|
|
|
public static ChatMessage ToChatMessage(this RecordDataValue message) =>
|
|
new(message.GetRole(), [.. message.GetContent()])
|
|
{
|
|
MessageId = message.GetProperty<StringDataValue>(TypeSchema.Message.Fields.Id)?.Value,
|
|
AdditionalProperties = message.GetProperty<RecordDataValue>(TypeSchema.Message.Fields.Metadata).ToMetadata()
|
|
};
|
|
|
|
public static ChatMessage ToChatMessage(this StringDataValue message) => new(ChatRole.User, message.Value);
|
|
|
|
public static ChatMessage ToChatMessage(this IEnumerable<FunctionResultContent> functionResults) =>
|
|
new(ChatRole.Tool, [.. functionResults]);
|
|
|
|
public static AdditionalPropertiesDictionary? ToMetadata(this RecordDataValue? metadata)
|
|
{
|
|
if (metadata is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
AdditionalPropertiesDictionary properties = [];
|
|
|
|
foreach (KeyValuePair<string, DataValue> property in metadata.Properties)
|
|
{
|
|
properties[property.Key] = property.Value.ToObject();
|
|
}
|
|
|
|
return properties;
|
|
}
|
|
|
|
public static ChatRole ToChatRole(this AgentMessageRole role) =>
|
|
role switch
|
|
{
|
|
AgentMessageRole.Agent => ChatRole.Assistant,
|
|
AgentMessageRole.User => ChatRole.User,
|
|
_ => ChatRole.User
|
|
};
|
|
|
|
public static ChatRole ToChatRole(this AgentMessageRole? role) => role?.ToChatRole() ?? ChatRole.User;
|
|
|
|
public static AIContent? ToContent(this AgentMessageContentType contentType, string? contentValue, string? mediaType = null)
|
|
{
|
|
if (string.IsNullOrEmpty(contentValue))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return
|
|
contentType switch
|
|
{
|
|
AgentMessageContentType.ImageUrl => GetImageContent(contentValue, mediaType ?? InferMediaType(contentValue)),
|
|
AgentMessageContentType.ImageFile => new HostedFileContent(contentValue),
|
|
_ => new TextContent(contentValue)
|
|
};
|
|
}
|
|
|
|
private static ChatRole GetRole(this RecordDataValue message)
|
|
{
|
|
StringDataValue? roleValue = message.GetProperty<StringDataValue>(TypeSchema.Message.Fields.Role);
|
|
if (string.IsNullOrWhiteSpace(roleValue?.Value))
|
|
{
|
|
return ChatRole.User;
|
|
}
|
|
|
|
AgentMessageRole? role = null;
|
|
if (Enum.TryParse(roleValue.Value, out AgentMessageRole parsedRole))
|
|
{
|
|
role = parsedRole;
|
|
}
|
|
|
|
return role.ToChatRole();
|
|
}
|
|
|
|
private static IEnumerable<AIContent> GetContent(this RecordDataValue message)
|
|
{
|
|
TableDataValue? content = message.GetProperty<TableDataValue>(TypeSchema.Message.Fields.Content);
|
|
if (content is not null)
|
|
{
|
|
foreach (RecordDataValue contentItem in content.Values)
|
|
{
|
|
StringDataValue? contentValue = contentItem.GetProperty<StringDataValue>(TypeSchema.MessageContent.Fields.Value);
|
|
StringDataValue? mediaTypeValue = contentItem.GetProperty<StringDataValue>(TypeSchema.MessageContent.Fields.MediaType);
|
|
if (contentValue is null || string.IsNullOrWhiteSpace(contentValue.Value))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
yield return
|
|
contentItem.GetProperty<StringDataValue>(TypeSchema.MessageContent.Fields.Type)?.Value switch
|
|
{
|
|
TypeSchema.MessageContent.ContentTypes.ImageUrl => GetImageContent(contentValue.Value, mediaTypeValue?.Value ?? InferMediaType(contentValue.Value)),
|
|
TypeSchema.MessageContent.ContentTypes.ImageFile => new HostedFileContent(contentValue.Value),
|
|
_ => new TextContent(contentValue.Value)
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string InferMediaType(string value)
|
|
{
|
|
// Base64 encoded content includes media type
|
|
if (value.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
int semicolonIndex = value.IndexOf(';');
|
|
if (semicolonIndex > 5)
|
|
{
|
|
return value.Substring(5, semicolonIndex - 5);
|
|
}
|
|
}
|
|
|
|
// URL based input only supports image
|
|
string fileExtension = Path.GetExtension(value);
|
|
return
|
|
fileExtension.ToUpperInvariant() switch
|
|
{
|
|
".JPG" or ".JPEG" => "image/jpeg",
|
|
".PNG" => "image/png",
|
|
".GIF" => "image/gif",
|
|
".WEBP" => "image/webp",
|
|
_ => "image/*"
|
|
};
|
|
}
|
|
|
|
private static AIContent GetImageContent(string uriText, string mediaType) =>
|
|
uriText.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ?
|
|
new DataContent(uriText, mediaType) :
|
|
new UriContent(uriText, mediaType);
|
|
|
|
private static TValue? GetProperty<TValue>(this RecordDataValue record, string name)
|
|
where TValue : DataValue
|
|
{
|
|
if (record.Properties.TryGetValue(name, out DataValue? value) && value is TValue dataValue)
|
|
{
|
|
return dataValue;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static IEnumerable<NamedValue> GetMessageFields(this ChatMessage message)
|
|
{
|
|
yield return new NamedValue(TypeSchema.Discriminator, nameof(ChatMessage).ToFormula());
|
|
yield return new NamedValue(TypeSchema.Message.Fields.Id, message.MessageId.ToFormula());
|
|
yield return new NamedValue(TypeSchema.Message.Fields.Role, message.Role.Value.ToFormula());
|
|
yield return new NamedValue(TypeSchema.Message.Fields.Author, message.AuthorName.ToFormula());
|
|
yield return new NamedValue(TypeSchema.Message.Fields.Content, FormulaValue.NewTable(TypeSchema.MessageContent.RecordType, message.GetContentRecords()));
|
|
yield return new NamedValue(TypeSchema.Message.Fields.Text, message.Text.ToFormula());
|
|
yield return new NamedValue(TypeSchema.Message.Fields.Metadata, message.AdditionalProperties.ToRecord());
|
|
}
|
|
|
|
private static IEnumerable<RecordValue> GetContentRecords(this ChatMessage message) =>
|
|
message.Contents.Select(content => FormulaValue.NewRecordFromFields(content.GetContentFields()));
|
|
|
|
private static IEnumerable<NamedValue> GetContentFields(this AIContent content)
|
|
{
|
|
return
|
|
content switch
|
|
{
|
|
UriContent uriContent => CreateContentRecord(TypeSchema.MessageContent.ContentTypes.ImageUrl, uriContent.Uri.ToString()),
|
|
HostedFileContent fileContent => CreateContentRecord(TypeSchema.MessageContent.ContentTypes.ImageFile, fileContent.FileId),
|
|
TextContent textContent => CreateContentRecord(TypeSchema.MessageContent.ContentTypes.Text, textContent.Text),
|
|
DataContent dataContent => CreateContentRecord(TypeSchema.MessageContent.ContentTypes.ImageUrl, dataContent.Uri),
|
|
_ => []
|
|
};
|
|
|
|
static IEnumerable<NamedValue> CreateContentRecord(string type, string value, string? mediaType = null)
|
|
{
|
|
yield return new NamedValue(TypeSchema.MessageContent.Fields.Type, type.ToFormula());
|
|
yield return new NamedValue(TypeSchema.MessageContent.Fields.Value, value.ToFormula());
|
|
if (mediaType is not null)
|
|
{
|
|
yield return new NamedValue(TypeSchema.MessageContent.Fields.MediaType, mediaType.ToFormula());
|
|
}
|
|
}
|
|
}
|
|
|
|
private static RecordValue ToRecord(this AdditionalPropertiesDictionary? value)
|
|
{
|
|
return FormulaValue.NewRecordFromFields(GetFields());
|
|
|
|
IEnumerable<NamedValue> GetFields()
|
|
{
|
|
if (value is not null)
|
|
{
|
|
foreach (string key in value.Keys)
|
|
{
|
|
yield return new NamedValue(key, value[key].ToFormula());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|