mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into peibekwe/workflow-asagent-fix
This commit is contained in:
@@ -31,6 +31,7 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
|
||||
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
|
||||
AgentInvocationContext context,
|
||||
CreateResponse request,
|
||||
IReadOnlyList<ChatMessage>? conversationHistory = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Create options with properties from the request
|
||||
@@ -51,9 +52,14 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
|
||||
};
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
|
||||
// Convert input to chat messages
|
||||
// Convert input to chat messages, prepending conversation history if available
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
if (conversationHistory is not null)
|
||||
{
|
||||
messages.AddRange(conversationHistory);
|
||||
}
|
||||
|
||||
foreach (var inputMessage in request.Input.GetInputMessages())
|
||||
{
|
||||
messages.Add(inputMessage.ToChatMessage());
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Converts stored <see cref="ItemResource"/> objects back to <see cref="ChatMessage"/> objects
|
||||
/// for injecting conversation history into agent execution.
|
||||
/// </summary>
|
||||
internal static class ItemResourceConversions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a sequence of <see cref="ItemResource"/> items to a list of <see cref="ChatMessage"/> objects.
|
||||
/// Only converts message, function call, and function result items. Other item types are skipped.
|
||||
/// </summary>
|
||||
public static List<ChatMessage> ToChatMessages(IEnumerable<ItemResource> items)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case ResponsesUserMessageItemResource userMsg:
|
||||
messages.Add(new ChatMessage(ChatRole.User, ConvertContents(userMsg.Content)));
|
||||
break;
|
||||
|
||||
case ResponsesAssistantMessageItemResource assistantMsg:
|
||||
messages.Add(new ChatMessage(ChatRole.Assistant, ConvertContents(assistantMsg.Content)));
|
||||
break;
|
||||
|
||||
case ResponsesSystemMessageItemResource systemMsg:
|
||||
messages.Add(new ChatMessage(ChatRole.System, ConvertContents(systemMsg.Content)));
|
||||
break;
|
||||
|
||||
case ResponsesDeveloperMessageItemResource developerMsg:
|
||||
messages.Add(new ChatMessage(new ChatRole("developer"), ConvertContents(developerMsg.Content)));
|
||||
break;
|
||||
|
||||
case FunctionToolCallItemResource funcCall:
|
||||
var arguments = ParseArguments(funcCall.Arguments);
|
||||
messages.Add(new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)
|
||||
]));
|
||||
break;
|
||||
|
||||
case FunctionToolCallOutputItemResource funcOutput:
|
||||
messages.Add(new ChatMessage(ChatRole.Tool,
|
||||
[
|
||||
new FunctionResultContent(funcOutput.CallId, funcOutput.Output)
|
||||
]));
|
||||
break;
|
||||
|
||||
// Skip all other item types (reasoning, executor_action, web_search, etc.)
|
||||
// They are not relevant for conversation context.
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
private static List<AIContent> ConvertContents(List<ItemContent> contents)
|
||||
{
|
||||
var result = new List<AIContent>();
|
||||
foreach (var content in contents)
|
||||
{
|
||||
var aiContent = ItemContentConverter.ToAIContent(content);
|
||||
if (aiContent is not null)
|
||||
{
|
||||
result.Add(aiContent);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?>? ParseArguments(string? argumentsJson)
|
||||
{
|
||||
if (string.IsNullOrEmpty(argumentsJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(argumentsJson);
|
||||
var result = new Dictionary<string, object?>();
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
result[property.Name] = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.Value.GetString(),
|
||||
JsonValueKind.Number => property.Value.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => property.Value.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
|
||||
AgentInvocationContext context,
|
||||
CreateResponse request,
|
||||
IReadOnlyList<ChatMessage>? conversationHistory = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
string agentName = GetAgentName(request)!;
|
||||
@@ -105,6 +106,11 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
if (conversationHistory is not null)
|
||||
{
|
||||
messages.AddRange(conversationHistory);
|
||||
}
|
||||
|
||||
foreach (var inputMessage in request.Input.GetInputMessages())
|
||||
{
|
||||
messages.Add(inputMessage.ToChatMessage());
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
|
||||
|
||||
@@ -28,10 +29,12 @@ internal interface IResponseExecutor
|
||||
/// </summary>
|
||||
/// <param name="context">The agent invocation context containing the ID generator and other context information.</param>
|
||||
/// <param name="request">The create response request.</param>
|
||||
/// <param name="conversationHistory">Optional prior conversation messages to prepend to the agent's input.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of streaming response events.</returns>
|
||||
IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
|
||||
AgentInvocationContext context,
|
||||
CreateResponse request,
|
||||
IReadOnlyList<ChatMessage>? conversationHistory = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
+18
-1
@@ -425,11 +425,28 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
|
||||
// Create agent invocation context
|
||||
var context = new AgentInvocationContext(new IdGenerator(responseId: responseId, conversationId: state.Response?.Conversation?.Id));
|
||||
|
||||
// Load conversation history if a conversation ID is provided
|
||||
IReadOnlyList<Extensions.AI.ChatMessage>? conversationHistory = null;
|
||||
if (this._conversationStorage is not null && request.Conversation?.Id is not null)
|
||||
{
|
||||
var itemsResult = await this._conversationStorage.ListItemsAsync(
|
||||
request.Conversation.Id,
|
||||
limit: 100,
|
||||
order: SortOrder.Ascending,
|
||||
cancellationToken: linkedCts.Token).ConfigureAwait(false);
|
||||
|
||||
var history = ItemResourceConversions.ToChatMessages(itemsResult.Data);
|
||||
if (history.Count > 0)
|
||||
{
|
||||
conversationHistory = history;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect output items for conversation storage
|
||||
List<ItemResource> outputItems = [];
|
||||
|
||||
// Execute using the injected executor
|
||||
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, linkedCts.Token).ConfigureAwait(false))
|
||||
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, conversationHistory, linkedCts.Token).ConfigureAwait(false))
|
||||
{
|
||||
state.AddStreamingEvent(streamingEvent);
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ public sealed class GroupChatWorkflowBuilder
|
||||
{
|
||||
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory;
|
||||
private readonly HashSet<AIAgent> _participants = new(AIAgentIDEqualityComparer.Instance);
|
||||
private string _name = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
|
||||
internal GroupChatWorkflowBuilder(Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) =>
|
||||
this._managerFactory = managerFactory;
|
||||
@@ -42,6 +44,28 @@ public sealed class GroupChatWorkflowBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the human-readable name for the workflow.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the workflow.</param>
|
||||
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
|
||||
public GroupChatWorkflowBuilder WithName(string name)
|
||||
{
|
||||
this._name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the description for the workflow.
|
||||
/// </summary>
|
||||
/// <param name="description">The description of what the workflow does.</param>
|
||||
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
|
||||
public GroupChatWorkflowBuilder WithDescription(string description)
|
||||
{
|
||||
this._description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="Workflow"/> composed of agents that operate via group chat, with the next
|
||||
/// agent to process messages selected by the group chat manager.
|
||||
@@ -65,6 +89,16 @@ public sealed class GroupChatWorkflowBuilder
|
||||
ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost));
|
||||
WorkflowBuilder builder = new(host);
|
||||
|
||||
if (!string.IsNullOrEmpty(this._name))
|
||||
{
|
||||
builder = builder.WithName(this._name);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(this._description))
|
||||
{
|
||||
builder = builder.WithDescription(this._description);
|
||||
}
|
||||
|
||||
foreach (var participant in agentMap.Values)
|
||||
{
|
||||
builder
|
||||
|
||||
@@ -153,18 +153,52 @@ public static class WorkflowVisualizer
|
||||
|
||||
private static void EmitWorkflowMermaid(Workflow workflow, List<string> lines, string indent, string? ns = null)
|
||||
{
|
||||
string MapId(string id) => ns != null ? $"{ns}/{id}" : id;
|
||||
// Build a mapping from raw IDs to Mermaid-safe node aliases that preserve
|
||||
// as much of the original ID as possible for readability.
|
||||
// Mermaid node IDs cannot contain spaces, dots, pipes, or most special characters.
|
||||
var aliasMap = new Dictionary<string, string>();
|
||||
var usedAliases = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
string GetSafeId(string id)
|
||||
{
|
||||
var key = ns != null ? $"{ns}/{id}" : id;
|
||||
if (!aliasMap.TryGetValue(key, out var alias))
|
||||
{
|
||||
alias = SanitizeMermaidNodeId(key);
|
||||
|
||||
// Handle collisions by appending a numeric suffix
|
||||
if (!usedAliases.Add(alias))
|
||||
{
|
||||
var i = 2;
|
||||
while (!usedAliases.Add($"{alias}_{i}"))
|
||||
{
|
||||
if (i >= 10_000)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to generate a unique Mermaid node ID for '{key}'.");
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
alias = $"{alias}_{i}";
|
||||
}
|
||||
|
||||
aliasMap[key] = alias;
|
||||
}
|
||||
|
||||
return alias;
|
||||
}
|
||||
|
||||
// Add start node
|
||||
var startExecutorId = workflow.StartExecutorId;
|
||||
lines.Add($"{indent}{MapId(startExecutorId)}[\"{startExecutorId} (Start)\"];");
|
||||
lines.Add($"{indent}{GetSafeId(startExecutorId)}[\"{EscapeMermaidLabel(startExecutorId)} (Start)\"];");
|
||||
|
||||
// Add other executor nodes
|
||||
foreach (var executorId in workflow.ExecutorBindings.Keys)
|
||||
{
|
||||
if (executorId != startExecutorId)
|
||||
{
|
||||
lines.Add($"{indent}{MapId(executorId)}[\"{executorId}\"];");
|
||||
lines.Add($"{indent}{GetSafeId(executorId)}[\"{EscapeMermaidLabel(executorId)}\"];");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +209,7 @@ public static class WorkflowVisualizer
|
||||
lines.Add("");
|
||||
foreach (var (nodeId, _, _) in fanInDescriptors)
|
||||
{
|
||||
lines.Add($"{indent}{MapId(nodeId)}((fan-in))");
|
||||
lines.Add($"{indent}{GetSafeId(nodeId)}((fan-in))");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,9 +218,9 @@ public static class WorkflowVisualizer
|
||||
{
|
||||
foreach (var src in sources)
|
||||
{
|
||||
lines.Add($"{indent}{MapId(src)} --> {MapId(nodeId)};");
|
||||
lines.Add($"{indent}{GetSafeId(src)} --> {GetSafeId(nodeId)};");
|
||||
}
|
||||
lines.Add($"{indent}{MapId(nodeId)} --> {MapId(target)};");
|
||||
lines.Add($"{indent}{GetSafeId(nodeId)} --> {GetSafeId(target)};");
|
||||
}
|
||||
|
||||
// Emit normal edges
|
||||
@@ -197,17 +231,17 @@ public static class WorkflowVisualizer
|
||||
string effectiveLabel = label != null ? EscapeMermaidLabel(label) : "conditional";
|
||||
|
||||
// Conditional edge, with user label or default
|
||||
lines.Add($"{indent}{MapId(src)} -. {effectiveLabel} .--> {MapId(target)};");
|
||||
lines.Add($"{indent}{GetSafeId(src)} -. {effectiveLabel} .-> {GetSafeId(target)};");
|
||||
}
|
||||
else if (label != null)
|
||||
{
|
||||
// Regular edge with label
|
||||
lines.Add($"{indent}{MapId(src)} -->|{EscapeMermaidLabel(label)}| {MapId(target)};");
|
||||
lines.Add($"{indent}{GetSafeId(src)} -->|{EscapeMermaidLabel(label)}| {GetSafeId(target)};");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular edge without label
|
||||
lines.Add($"{indent}{MapId(src)} --> {MapId(target)};");
|
||||
lines.Add($"{indent}{GetSafeId(src)} --> {GetSafeId(target)};");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,6 +335,50 @@ public static class WorkflowVisualizer
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a raw node ID into a Mermaid-safe identifier that preserves as much
|
||||
/// of the original text as possible. ASCII letters, digits, and underscores are kept
|
||||
/// as-is (including existing consecutive underscores). All other characters (including
|
||||
/// non-ASCII letters) are replaced with underscores, with consecutive invalid characters
|
||||
/// collapsed into a single underscore. A leading digit gets a prefix.
|
||||
/// </summary>
|
||||
private static string SanitizeMermaidNodeId(string id)
|
||||
{
|
||||
Throw.IfNull(id);
|
||||
|
||||
var sb = new StringBuilder(id.Length);
|
||||
bool lastWasUnderscore = false;
|
||||
foreach (var ch in id)
|
||||
{
|
||||
bool isAsciiSafe = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_';
|
||||
if (isAsciiSafe)
|
||||
{
|
||||
sb.Append(ch);
|
||||
lastWasUnderscore = ch == '_';
|
||||
}
|
||||
else if (!lastWasUnderscore)
|
||||
{
|
||||
sb.Append('_');
|
||||
lastWasUnderscore = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Trim trailing underscore
|
||||
while (sb.Length > 0 && sb[sb.Length - 1] == '_')
|
||||
{
|
||||
sb.Length--;
|
||||
}
|
||||
|
||||
// Mermaid IDs must not start with a digit
|
||||
if (sb.Length > 0 && sb[0] >= '0' && sb[0] <= '9')
|
||||
{
|
||||
sb.Insert(0, "n_");
|
||||
}
|
||||
|
||||
// Guard against empty result (e.g. id was all special chars)
|
||||
return sb.Length == 0 ? "node" : sb.ToString();
|
||||
}
|
||||
|
||||
// Helper method to escape special characters in DOT labels
|
||||
private static string EscapeDotLabel(string label)
|
||||
{
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -15,8 +13,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// and a markdown body with instructions. Resource files referenced in the body are validated at
|
||||
/// discovery time and read from disk on demand.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileAgentSkill
|
||||
internal sealed class FileAgentSkill
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkill"/> class.
|
||||
@@ -25,8 +22,8 @@ public sealed class FileAgentSkill
|
||||
/// <param name="body">The SKILL.md content after the closing <c>---</c> delimiter.</param>
|
||||
/// <param name="sourcePath">Absolute path to the directory containing this skill.</param>
|
||||
/// <param name="resourceNames">Relative paths of resource files referenced in the skill body.</param>
|
||||
internal FileAgentSkill(
|
||||
FileAgentSkillFrontmatter frontmatter,
|
||||
public FileAgentSkill(
|
||||
SkillFrontmatter frontmatter,
|
||||
string body,
|
||||
string sourcePath,
|
||||
IReadOnlyList<string>? resourceNames = null)
|
||||
@@ -40,20 +37,20 @@ public sealed class FileAgentSkill
|
||||
/// <summary>
|
||||
/// Gets the parsed YAML frontmatter (name and description).
|
||||
/// </summary>
|
||||
public FileAgentSkillFrontmatter Frontmatter { get; }
|
||||
public SkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SKILL.md body content (without the YAML frontmatter).
|
||||
/// </summary>
|
||||
public string Body { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory path where the skill was discovered.
|
||||
/// </summary>
|
||||
public string SourcePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SKILL.md body content (without the YAML frontmatter).
|
||||
/// </summary>
|
||||
internal string Body { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md").
|
||||
/// </summary>
|
||||
internal IReadOnlyList<string> ResourceNames { get; }
|
||||
public IReadOnlyList<string> ResourceNames { get; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
@@ -10,7 +9,6 @@ using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
@@ -22,8 +20,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// Each file is validated for YAML frontmatter and resource integrity. Invalid skills are excluded
|
||||
/// with logged warnings. Resource paths are checked against path traversal and symlink escape attacks.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed partial class FileAgentSkillLoader
|
||||
internal sealed partial class FileAgentSkillLoader
|
||||
{
|
||||
private const string SkillFileName = "SKILL.md";
|
||||
private const int MaxSearchDepth = 2;
|
||||
@@ -36,16 +33,13 @@ public sealed partial class FileAgentSkillLoader
|
||||
// Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n"
|
||||
private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Matches resource file references in skill markdown. Group 1 = relative file path.
|
||||
// Supports two forms:
|
||||
// 1. Markdown links: [text](path/file.ext)
|
||||
// 2. Backtick-quoted paths: `path/file.ext`
|
||||
// Matches markdown links to local resource files. Group 1 = relative file path.
|
||||
// Supports optional ./ or ../ prefixes; excludes URLs (no ":" in the path character class).
|
||||
// Intentionally conservative: only matches paths with word characters, hyphens, dots,
|
||||
// and forward slashes. Paths with spaces or special characters are not supported.
|
||||
// Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", `./scripts/run.py` → "./scripts/run.py",
|
||||
// Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", [s](./s.json) → "./s.json",
|
||||
// [p](../shared/doc.txt) → "../shared/doc.txt"
|
||||
private static readonly Regex s_resourceLinkRegex = new(@"(?:\[.*?\]\(|`)(\.?\.?/?[\w][\w\-./]*\.\w+)(?:\)|`)", RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value.
|
||||
// Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values.
|
||||
@@ -117,7 +111,7 @@ public sealed partial class FileAgentSkillLoader
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The resource is not registered, resolves outside the skill directory, or does not exist.
|
||||
/// </exception>
|
||||
public async Task<string> ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default)
|
||||
internal async Task<string> ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
resourceName = NormalizeResourcePath(resourceName);
|
||||
|
||||
@@ -195,7 +189,7 @@ public sealed partial class FileAgentSkillLoader
|
||||
|
||||
string content = File.ReadAllText(skillFilePath, Encoding.UTF8);
|
||||
|
||||
if (!this.TryParseSkillDocument(content, skillFilePath, out FileAgentSkillFrontmatter frontmatter, out string body))
|
||||
if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -214,7 +208,7 @@ public sealed partial class FileAgentSkillLoader
|
||||
resourceNames: resourceNames);
|
||||
}
|
||||
|
||||
private bool TryParseSkillDocument(string content, string skillFilePath, out FileAgentSkillFrontmatter frontmatter, out string body)
|
||||
private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body)
|
||||
{
|
||||
frontmatter = null!;
|
||||
body = null!;
|
||||
@@ -270,7 +264,7 @@ public sealed partial class FileAgentSkillLoader
|
||||
return false;
|
||||
}
|
||||
|
||||
frontmatter = new FileAgentSkillFrontmatter(name, description);
|
||||
frontmatter = new SkillFrontmatter(name, description);
|
||||
body = content.Substring(match.Index + match.Length).TrimStart();
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to loaded skills and the skill loader for use by <see cref="FileAgentSkillScriptExecutor"/> implementations.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileAgentSkillScriptExecutionContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkillScriptExecutionContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="skills">The loaded skills dictionary.</param>
|
||||
/// <param name="loader">The skill loader for reading resources.</param>
|
||||
internal FileAgentSkillScriptExecutionContext(Dictionary<string, FileAgentSkill> skills, FileAgentSkillLoader loader)
|
||||
{
|
||||
this.Skills = skills;
|
||||
this.Loader = loader;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the loaded skills keyed by name.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, FileAgentSkill> Skills { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the skill loader for reading resources.
|
||||
/// </summary>
|
||||
public FileAgentSkillLoader Loader { get; }
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the tools and instructions contributed by a <see cref="FileAgentSkillScriptExecutor"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileAgentSkillScriptExecutionDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the additional instructions to provide to the agent for script execution.
|
||||
/// </summary>
|
||||
public string? Instructions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the additional tools to provide to the agent for script execution.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AITool>? Tools { get; set; }
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for skill script execution modes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A <see cref="FileAgentSkillScriptExecutor"/> provides the instructions and tools needed to enable
|
||||
/// script execution within an agent skill. Concrete implementations determine how scripts
|
||||
/// are executed (e.g., via the LLM's hosted code interpreter, an external executor, or a hybrid approach).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use the static factory methods to create instances:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="HostedCodeInterpreter"/> — executes scripts using the LLM provider's built-in code interpreter.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public abstract class FileAgentSkillScriptExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="FileAgentSkillScriptExecutor"/> that uses the LLM provider's hosted code interpreter for script execution.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="FileAgentSkillScriptExecutor"/> instance configured for hosted code interpreter execution.</returns>
|
||||
public static FileAgentSkillScriptExecutor HostedCodeInterpreter() => new HostedCodeInterpreterFileAgentSkillScriptExecutor();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the tools and instructions contributed by this executor.
|
||||
/// </summary>
|
||||
/// <param name="context">
|
||||
/// The execution context provided by the skills provider, containing the loaded skills
|
||||
/// and the skill loader for reading resources.
|
||||
/// </param>
|
||||
/// <returns>A <see cref="FileAgentSkillScriptExecutionDetails"/> containing the executor's tools and instructions.</returns>
|
||||
protected internal abstract FileAgentSkillScriptExecutionDetails GetExecutionDetails(FileAgentSkillScriptExecutionContext context);
|
||||
}
|
||||
@@ -48,21 +48,21 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
Each skill provides specialized instructions, reference documents, and assets for specific tasks.
|
||||
|
||||
<available_skills>
|
||||
{skills}
|
||||
{0}
|
||||
</available_skills>
|
||||
|
||||
When a task aligns with a skill's domain:
|
||||
- Use `load_skill` to retrieve the skill's instructions
|
||||
- Follow the provided guidance
|
||||
- Use `read_skill_resource` to read any references or other files mentioned by the skill, always using the full path as written (e.g. `references/FAQ.md`, not just `FAQ.md`)
|
||||
{executor_instructions}
|
||||
1. Use `load_skill` to retrieve the skill's instructions
|
||||
2. Follow the provided guidance
|
||||
3. Use `read_skill_resource` to read any references or other files mentioned by the skill
|
||||
|
||||
Only load what is needed, when it is needed.
|
||||
""";
|
||||
|
||||
private readonly Dictionary<string, FileAgentSkill> _skills;
|
||||
private readonly ILogger<FileAgentSkillsProvider> _logger;
|
||||
private readonly FileAgentSkillLoader _loader;
|
||||
private readonly IEnumerable<AITool> _tools;
|
||||
private readonly AITool[] _tools;
|
||||
private readonly string? _skillsInstructionPrompt;
|
||||
|
||||
/// <summary>
|
||||
@@ -91,13 +91,9 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
this._loader = new FileAgentSkillLoader(this._logger);
|
||||
this._skills = this._loader.DiscoverAndLoadSkills(skillPaths);
|
||||
|
||||
var executionDetails = options?.ScriptExecutor is { } executor
|
||||
? executor.GetExecutionDetails(new(this._skills, this._loader))
|
||||
: null;
|
||||
this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills);
|
||||
|
||||
this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills, executionDetails?.Instructions);
|
||||
|
||||
AITool[] baseTools =
|
||||
this._tools =
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
this.LoadSkill,
|
||||
@@ -108,10 +104,6 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
name: "read_skill_resource",
|
||||
description: "Reads a file associated with a skill, such as references or assets."),
|
||||
];
|
||||
|
||||
this._tools = executionDetails?.Tools is { Count: > 0 } executorTools
|
||||
? baseTools.Concat(executorTools)
|
||||
: baseTools;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -125,7 +117,7 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
return new ValueTask<AIContext>(new AIContext
|
||||
{
|
||||
Instructions = this._skillsInstructionPrompt,
|
||||
Tools = this._tools,
|
||||
Tools = this._tools
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,9 +166,25 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
}
|
||||
}
|
||||
|
||||
private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary<string, FileAgentSkill> skills, string? instructions)
|
||||
private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary<string, FileAgentSkill> skills)
|
||||
{
|
||||
string promptTemplate = options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
|
||||
string promptTemplate = DefaultSkillsInstructionPrompt;
|
||||
|
||||
if (options?.SkillsInstructionPrompt is { } optionsInstructions)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = string.Format(optionsInstructions, string.Empty);
|
||||
promptTemplate = optionsInstructions;
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').",
|
||||
nameof(options),
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
if (skills.Count == 0)
|
||||
{
|
||||
@@ -195,9 +203,7 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
sb.AppendLine(" </skill>");
|
||||
}
|
||||
|
||||
return promptTemplate
|
||||
.Replace("{skills}", sb.ToString().TrimEnd())
|
||||
.Replace("{executor_instructions}", instructions ?? "\n");
|
||||
return string.Format(promptTemplate, sb.ToString().TrimEnd());
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")]
|
||||
|
||||
@@ -13,20 +13,8 @@ public sealed class FileAgentSkillsProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a custom system prompt template for advertising skills.
|
||||
/// Use <c>{skills}</c> as the placeholder for the generated skills list and
|
||||
/// <c>{executor_instructions}</c> for executor-provided instructions.
|
||||
/// Use <c>{0}</c> as the placeholder for the generated skills list.
|
||||
/// When <see langword="null"/>, a default template is used.
|
||||
/// </summary>
|
||||
public string? SkillsInstructionPrompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the skill executor that enables script execution for loaded skills.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/> (the default), script execution is disabled and skills only provide
|
||||
/// instructions and resources. Set this to a <see cref="FileAgentSkillScriptExecutor"/> instance (e.g.,
|
||||
/// <see cref="FileAgentSkillScriptExecutor.HostedCodeInterpreter()"/>) to enable script execution with
|
||||
/// mode-specific instructions and tools.
|
||||
/// </remarks>
|
||||
public FileAgentSkillScriptExecutor? ScriptExecutor { get; set; }
|
||||
}
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="FileAgentSkillScriptExecutor"/> that uses the LLM provider's hosted code interpreter for script execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This executor directs the LLM to load scripts via <c>read_skill_resource</c> and execute them
|
||||
/// using the provider's built-in code interpreter. A <see cref="HostedCodeInterpreterTool"/> is
|
||||
/// registered to signal the provider to enable its code interpreter sandbox.
|
||||
/// </remarks>
|
||||
internal sealed class HostedCodeInterpreterFileAgentSkillScriptExecutor : FileAgentSkillScriptExecutor
|
||||
{
|
||||
private static readonly FileAgentSkillScriptExecutionDetails s_contribution = new()
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
|
||||
Some skills include executable scripts (e.g., Python files) in their resources.
|
||||
When a skill's instructions reference a script:
|
||||
1. Use `read_skill_resource` to load the script content
|
||||
2. Execute the script using the code interpreter
|
||||
|
||||
""",
|
||||
Tools = [new HostedCodeInterpreterTool()],
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
#pragma warning disable RCS1168 // Parameter name differs from base name
|
||||
protected internal override FileAgentSkillScriptExecutionDetails GetExecutionDetails(FileAgentSkillScriptExecutionContext _) => s_contribution;
|
||||
#pragma warning restore RCS1168 // Parameter name differs from base name
|
||||
}
|
||||
+3
-6
@@ -1,7 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -9,15 +7,14 @@ namespace Microsoft.Agents.AI;
|
||||
/// <summary>
|
||||
/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileAgentSkillFrontmatter
|
||||
internal sealed class SkillFrontmatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkillFrontmatter"/> class.
|
||||
/// Initializes a new instance of the <see cref="SkillFrontmatter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">Skill name.</param>
|
||||
/// <param name="description">Skill description.</param>
|
||||
internal FileAgentSkillFrontmatter(string name, string description)
|
||||
public SkillFrontmatter(string name, string description)
|
||||
{
|
||||
this.Name = Throw.IfNullOrWhitespace(name);
|
||||
this.Description = Throw.IfNullOrWhitespace(description);
|
||||
Reference in New Issue
Block a user