Merge branch 'main' into copilot/update-microsoft-extensions-ai-packages

This commit is contained in:
Roger Barreto
2026-05-28 18:25:30 +01:00
committed by GitHub
Unverified
14 changed files with 838 additions and 468 deletions
+1
View File
@@ -174,6 +174,7 @@
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Agent_Step25_FoundryToolboxMcp.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Agent_Step26_FoundryToolboxMcpSkills.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Evaluation/">
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,93 @@
// Copyright (c) Microsoft. All rights reserved.
// Foundry Toolbox MCP Skills.
//
// Uses AgentSkillsProviderBuilder to discover MCP-based skills from a Foundry
// Toolbox endpoint and inject them as AIContextProviders so the agent can
// discover and use them at runtime.
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI;
using ModelContextProtocol.Client;
// --- Configuration ---
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
string toolboxMcpServerUrl = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_MCP_SERVER_URL")
?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_MCP_SERVER_URL is not set.");
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
TokenCredential credential = new DefaultAzureCredential();
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
{
InnerHandler = new HttpClientHandler(),
});
// --- Connect to the Foundry Toolbox MCP endpoint ---
await using McpClient mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(toolboxMcpServerUrl),
Name = "foundry_toolbox",
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string>
{
["Foundry-Features"] = "Toolboxes=V1Preview",
},
},
httpClient));
// --- Discover MCP-based skills ---
var skillsProvider = new AgentSkillsProviderBuilder()
.UseMcpSkills(mcpClient)
.Build();
// --- Create the agent ---
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
AIAgent agent = aiProjectClient.AsAIAgent(
options: new ChatClientAgentOptions
{
Name = "ToolboxMcpSkillsAgent",
ChatOptions = new()
{
ModelId = deploymentName,
Instructions = "You are a helpful assistant. Use available skills to answer the user.",
},
AIContextProviders = [skillsProvider],
});
// --- Interactive prompt ---
Console.Write("User: ");
string? query = Console.ReadLine();
if (string.IsNullOrWhiteSpace(query))
{
Console.WriteLine("No input provided.");
return;
}
Console.WriteLine($"Assistant: {await agent.RunAsync(query)}");
// ---------------------------------------------------------------------------
// DelegatingHandler: attaches a fresh Foundry bearer token to every request
// ---------------------------------------------------------------------------
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
{
private readonly TokenRequestContext _tokenContext = new([scope]);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,32 @@
# Foundry Toolbox MCP Skills
This sample uses
`AgentSkillsProviderBuilder` to discover MCP-based skills from a Foundry Toolbox endpoint
and inject them as `AIContextProviders` so the agent can discover and use them at runtime.
## What this sample demonstrates
- Connecting to a Foundry toolbox's MCP endpoint via Streamable HTTP transport
- Injecting a fresh Azure AI bearer token (`https://ai.azure.com/.default`) on every MCP request
- Using `AgentSkillsProviderBuilder.UseMcpSkills(client)` to discover skills from the toolbox
- Injecting the discovered skills into `AIProjectClient.AsAIAgent(...)` via `AIContextProviders`
## Prerequisites
- A Microsoft Foundry project with a toolbox already configured
- The toolbox MCP endpoint must expose `skill://index.json` with `skill-md` entries (SEP-2640). If the resource is absent, the sample runs but the skills provider will be empty.
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini"
$env:FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://your-foundry-service.services.ai.azure.com/api/projects/your-project/toolboxes/your-toolbox/mcp?api-version=v1"
```
## Run the sample
```powershell
dotnet run
```
@@ -74,6 +74,7 @@ Some samples require extra tool-specific environment variables. See each sample
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
| [Foundry toolbox via MCP](./Agent_Step25_FoundryToolboxMcp/) | Use a Foundry Toolbox from a non-hosted agent via its MCP endpoint |
| [Foundry toolbox MCP skills](./Agent_Step26_FoundryToolboxMcpSkills/) | Use a Foundry Toolbox with MCP-based skills discovery (SEP-2640) via AIContextProviders |
## Running the samples
@@ -72,6 +72,95 @@ public static class AnsiEscapes
/// </summary>
public static string ResetAttributes => "\x1b[0m";
/// <summary>
/// Returns the visible (printed) length of a string after stripping ANSI escape sequences.
/// Escape sequences are zero-width on screen but occupy characters in the raw string.
/// </summary>
/// <remarks>
/// This counts UTF-16 code units (chars) rather than terminal display cells. Emoji,
/// combining characters, variation selectors, and East Asian wide characters may be
/// measured incorrectly. For the console harness this is acceptable since content is
/// predominantly ASCII, and emoji are padded with surrounding spaces.
/// </remarks>
public static int VisibleLength(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int length = 0;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '[')
{
// Skip the ESC[ and all characters up to and including the final byte (0x400x7E).
i += 2;
while (i < text.Length && text[i] < 0x40)
{
i++;
}
// i now points to the final byte of the escape sequence; the for-loop will advance past it.
}
else if (text[i] != '\n' && text[i] != '\r')
{
length++;
}
}
return length;
}
/// <summary>
/// Counts the number of physical terminal rows a text item will occupy,
/// accounting for both explicit newlines and terminal line wrapping.
/// </summary>
/// <param name="text">The text to measure.</param>
/// <param name="terminalWidth">The terminal width in columns. If &lt;= 0, wrapping is ignored (1 row per logical line).</param>
/// <returns>The number of physical rows the text occupies.</returns>
public static int CountPhysicalLines(string text, int terminalWidth)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int physicalLines = 0;
int lineStart = 0;
for (int i = 0; i <= text.Length; i++)
{
if (i == text.Length || text[i] == '\n')
{
if (terminalWidth <= 0)
{
// No wrapping — each logical line is one physical row
physicalLines += 1;
}
else
{
string logicalLine = text[lineStart..i];
int visibleWidth = VisibleLength(logicalLine);
physicalLines += visibleWidth == 0
? 1
: (visibleWidth - 1) / terminalWidth + 1;
}
lineStart = i + 1;
}
}
// If text ends with a newline, don't count the trailing empty line
if (text[text.Length - 1] == '\n')
{
physicalLines--;
}
return physicalLines;
}
private static int ConsoleColorToAnsi(ConsoleColor color) => color switch
{
ConsoleColor.Black => 30,
@@ -23,16 +23,18 @@ public record TextPanelProps : ConsoleReactiveProps
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
{
/// <summary>
/// Calculates the height (in lines) needed to render all items.
/// Calculates the height (in lines) needed to render all items,
/// accounting for terminal line wrapping at the specified width.
/// </summary>
/// <param name="items">The items to measure.</param>
/// <returns>The total number of lines all items will occupy.</returns>
public static int CalculateHeight(IReadOnlyList<string> items)
/// <param name="terminalWidth">The terminal width in columns. When 0 or negative, wrapping is ignored.</param>
/// <returns>The total number of physical lines all items will occupy.</returns>
public static int CalculateHeight(IReadOnlyList<string> items, int terminalWidth = 0)
{
int total = 0;
for (int i = 0; i < items.Count; i++)
{
total += CountLines(items[i]);
total += AnsiEscapes.CountPhysicalLines(items[i], terminalWidth);
}
return total;
@@ -47,13 +49,20 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
{
string text = props.Items[i];
string[] lines = text.Split('\n');
int lineCount = CountLines(text);
int itemLineCount = AnsiEscapes.CountPhysicalLines(text, props.Width);
int itemRow = 0;
for (int j = 0; j < lineCount; j++)
for (int j = 0; j < lines.Length && itemRow < itemLineCount; j++)
{
int linePhysicalRows = props.Width > 0
? Math.Max(1, (AnsiEscapes.VisibleLength(lines[j]) - 1) / props.Width + 1)
: 1;
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
Console.Write(lines[j]);
currentRow++;
currentRow += linePhysicalRows;
itemRow += linePhysicalRows;
}
}
@@ -66,29 +75,4 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
}
}
}
private static int CountLines(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int count = 1;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\n')
{
count++;
}
}
// If text ends with a newline, don't count the trailing empty line
if (text[text.Length - 1] == '\n')
{
count--;
}
return count;
}
}
@@ -77,36 +77,12 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
Console.Write(props.Items[i]);
}
// Calculate the offset from bottom for the start of the new last item
int lastItemLines = CountLines(props.Items[^1]);
// Calculate the offset from bottom for the start of the new last item,
// accounting for terminal line wrapping at the available width.
int lastItemLines = AnsiEscapes.CountPhysicalLines(props.Items[^1], props.Width);
this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0;
// Update rendered count
this._renderedCount = props.Items.Count;
}
private static int CountLines(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int count = 1;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\n')
{
count++;
}
}
// If text ends with a newline, don't count the trailing empty line
if (text[text.Length - 1] == '\n')
{
count--;
}
return count;
}
}
@@ -13,6 +13,11 @@ public abstract class ConsoleReactiveComponent
{
}
/// <summary>
/// Gets the shared render lock across all component types to prevent ANSI escape sequence interleaving.
/// </summary>
protected static object RenderLock { get; } = new();
/// <summary>
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
/// Used by parent components to set layout (X, Y, Width, Height) on children without
@@ -40,7 +45,6 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
where TProps : ConsoleReactiveProps
where TState : ConsoleReactiveState
{
private readonly object _renderLock = new();
private TProps? _lastRenderedProps;
private TState? _lastRenderedState;
@@ -74,7 +78,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
/// </summary>
public override void Render()
{
lock (this._renderLock)
lock (RenderLock)
{
if (this.Props is null)
{
@@ -97,7 +101,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
/// <inheritdoc/>
public override void Invalidate()
{
lock (this._renderLock)
lock (RenderLock)
{
this._lastRenderedProps = default;
this._lastRenderedState = default;
@@ -28,6 +28,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
private int _scrollRegionBottom;
private bool _resizedSinceLastRender = true;
private bool _deactivated;
private BottomPanelMode _lastRenderedBottomPanelMode;
/// <summary>
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
@@ -341,7 +342,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
}
// Calculate queued items panel height
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems, state.ConsoleWidth);
// Build the bottom panel child based on mode
ConsoleReactiveComponent bottomChild;
@@ -406,6 +407,14 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
bottomChild = this._textInput;
}
// When the bottom panel mode changes, the new child must repaint even if its
// props haven't changed — the screen area was overwritten by the previous child.
if (state.Mode != this._lastRenderedBottomPanelMode)
{
bottomChild.Invalidate();
this._lastRenderedBottomPanelMode = state.Mode;
}
var ruleProps = new TopBottomRuleProps
{
Width = state.ConsoleWidth,
@@ -88,16 +88,17 @@ public sealed class PlanningOutputObserver : ConsoleObserver
{
planningResponse = JsonSerializer.Deserialize<PlanningResponse>(collectedText);
}
catch (JsonException ex)
catch (JsonException)
{
await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
// JSON parsing failed — fall back to rendering as regular text output.
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
return null;
}
if (planningResponse is null)
{
await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
// Null result — fall back to rendering as regular text output.
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
return null;
}
@@ -118,7 +119,8 @@ public sealed class PlanningOutputObserver : ConsoleObserver
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
}
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
// Unexpected type — fall back to rendering as regular text output.
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
return null;
}
@@ -2,6 +2,7 @@
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
using System.Text.Json;
using Harness.Shared.Console.Observers;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
@@ -53,9 +54,94 @@ public sealed class OpenAIResponsesErrorObserver : ConsoleObserver
case StreamingResponseIncompleteUpdate incompleteUpdate:
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
if (string.Equals(reason, "content_filter", StringComparison.OrdinalIgnoreCase))
{
string detail = GetContentFilterDetails(incompleteUpdate);
const string Message = "🛡️ The service's built-in content filter guardrails were triggered and the response was cut short.";
await ux.WriteInfoLineAsync(
string.IsNullOrEmpty(detail) ? Message : $"{Message}\n{detail}",
ConsoleColor.Yellow);
}
else
{
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
}
break;
}
}
/// <summary>
/// Extracts content filter details from the serialized response JSON and returns
/// a formatted string showing which specific categories were triggered.
/// Returns <see cref="string.Empty"/> if details cannot be extracted.
/// </summary>
private static string GetContentFilterDetails(StreamingResponseIncompleteUpdate incompleteUpdate)
{
try
{
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(incompleteUpdate);
using var doc = JsonDocument.Parse(data.ToString());
var root = doc.RootElement;
// Navigate into the nested response object if present.
JsonElement responseElement = root.TryGetProperty("response", out var resp) ? resp : root;
if (!responseElement.TryGetProperty("content_filters", out var filtersArray)
|| filtersArray.ValueKind != JsonValueKind.Array)
{
return string.Empty;
}
foreach (var filter in filtersArray.EnumerateArray())
{
if (!filter.TryGetProperty("content_filter_results", out var results)
|| results.ValueKind != JsonValueKind.Object)
{
continue;
}
// Collect category data for aligned output.
var categories = new List<(string Name, bool Filtered, string? Severity)>();
foreach (var category in results.EnumerateObject())
{
if (category.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
bool filtered = category.Value.TryGetProperty("filtered", out var f) && f.GetBoolean();
string? severity = category.Value.TryGetProperty("severity", out var s) ? s.GetString() : null;
categories.Add((category.Name, filtered, severity));
}
// Build all category lines into a single string.
int maxNameLen = categories.Count > 0 ? categories.Max(c => c.Name.Length) : 0;
var lines = new List<string>();
foreach (var (name, filtered, severity) in categories)
{
string paddedName = name.PadRight(maxNameLen);
string icon = filtered ? "❌" : "✅";
string statusText = filtered ? "Filtered " : "Not Filtered";
string severityText = severity is not null ? $" Severity: {severity}" : "";
lines.Add($" {icon} {paddedName} {statusText}{severityText}");
}
if (lines.Count > 0)
{
return string.Join("\n", lines);
}
}
return string.Empty;
}
catch
{
// Parsing not critical — skip silently if it fails.
return string.Empty;
}
}
}
+228 -201
View File
@@ -507,38 +507,45 @@ class Skill(ABC):
"""
...
@property
@abstractmethod
def content(self) -> str:
"""The full skill content.
async def get_content(self) -> str:
"""Get the full skill content.
For file-based skills this is the raw SKILL.md file content,
optionally augmented with a synthesized scripts block when scripts
are present. For code-defined skills this is a synthesized XML
document containing name, description, and body (instructions,
resources, scripts).
Returns:
The full skill content string.
"""
...
@property
def resources(self) -> list[SkillResource]:
"""Resources associated with this skill.
async def get_resource(self, name: str) -> SkillResource | None:
"""Get a resource owned by this skill by name.
The default implementation returns an empty list.
Override this property in derived classes to provide skill-specific
resources.
Args:
name: The resource name (e.g. an identifier or a relative path
referenced inside the skill content).
Returns:
The :class:`SkillResource`, or ``None`` when no resource with the
given name exists.
"""
return []
return None
@property
def scripts(self) -> list[SkillScript]:
"""Scripts associated with this skill.
async def get_script(self, name: str) -> SkillScript | None:
"""Get a script owned by this skill by name.
The default implementation returns an empty list.
Override this property in derived classes to provide skill-specific
scripts.
Args:
name: The script name.
Returns:
The :class:`SkillScript`, or ``None`` when no script with the
given name exists.
"""
return []
return None
@experimental(feature_id=ExperimentalFeature.SKILLS)
@@ -767,12 +774,14 @@ class InlineSkill(Skill):
"""The L1 discovery metadata for this skill."""
return self._frontmatter
@property
def content(self) -> str:
async def get_content(self) -> str:
"""Synthesized XML content with name, description, instructions, resources, and scripts.
The result is cached after the first access. Adding resources or
scripts after the first access will not be reflected.
Returns:
The synthesized XML content string.
"""
if self._cached_content is not None:
return self._cached_content
@@ -786,15 +795,31 @@ class InlineSkill(Skill):
)
return self._cached_content
@property
def resources(self) -> list[SkillResource]:
"""Mutable list of :class:`SkillResource` instances."""
return self._resources
async def get_resource(self, name: str) -> SkillResource | None:
"""Get a resource by name.
@property
def scripts(self) -> list[SkillScript]:
"""Mutable list of :class:`SkillScript` instances."""
return self._scripts
Args:
name: The resource name to look up (case-insensitive).
Returns:
The :class:`SkillResource`, or ``None`` when no resource with the
given name exists.
"""
name_lower = name.lower()
return next((r for r in self._resources if r.name.lower() == name_lower), None)
async def get_script(self, name: str) -> SkillScript | None:
"""Get a script by name.
Args:
name: The script name to look up (case-insensitive).
Returns:
The :class:`SkillScript`, or ``None`` when no script with the
given name exists.
"""
name_lower = name.lower()
return next((s for s in self._scripts if s.name.lower() == name_lower), None)
def resource(
self,
@@ -1318,11 +1343,13 @@ class ClassSkill(Skill, ABC):
self._cached_scripts = scripts
return list(self._cached_scripts)
@property
def content(self) -> str:
async def get_content(self) -> str:
"""Synthesized XML content containing name, description, instructions, resources, and scripts.
The result is cached after the first access.
Returns:
The synthesized XML content string.
"""
if self._cached_content is not None:
return self._cached_content
@@ -1336,6 +1363,32 @@ class ClassSkill(Skill, ABC):
)
return self._cached_content
async def get_resource(self, name: str) -> SkillResource | None:
"""Get a resource by name from the :attr:`resources` list.
Args:
name: The resource name to look up (case-insensitive).
Returns:
The :class:`SkillResource`, or ``None`` when no resource with the
given name exists.
"""
name_lower = name.lower()
return next((r for r in self.resources if r.name.lower() == name_lower), None)
async def get_script(self, name: str) -> SkillScript | None:
"""Get a script by name from the :attr:`scripts` list.
Args:
name: The script name to look up (case-insensitive).
Returns:
The :class:`SkillScript`, or ``None`` when no script with the
given name exists.
"""
name_lower = name.lower()
return next((s for s in self.scripts if s.name.lower() == name_lower), None)
@experimental(feature_id=ExperimentalFeature.SKILLS)
class FileSkill(Skill):
@@ -1378,8 +1431,7 @@ class FileSkill(Skill):
"""The L1 discovery metadata for this skill."""
return self._frontmatter
@property
def content(self) -> str:
async def get_content(self) -> str:
"""The skill content with appended scripts block.
When scripts are present, a ``<scripts>`` XML block is appended
@@ -1388,6 +1440,9 @@ class FileSkill(Skill):
The result is cached after the first access. Adding scripts
after the first access will not be reflected.
Returns:
The skill content string.
"""
if self._cached_content is not None:
return self._cached_content
@@ -1398,15 +1453,31 @@ class FileSkill(Skill):
self._cached_content = f"{self._content}\n\n<scripts>\n{script_lines}\n</scripts>"
return self._cached_content
@property
def resources(self) -> list[SkillResource]:
"""Resources discovered for this skill."""
return self._resources
async def get_resource(self, name: str) -> SkillResource | None:
"""Get a resource by name.
@property
def scripts(self) -> list[SkillScript]:
"""Scripts discovered for this skill."""
return self._scripts
Args:
name: The resource name to look up (case-insensitive).
Returns:
The :class:`SkillResource`, or ``None`` when no resource with the
given name exists.
"""
name_lower = name.lower()
return next((r for r in self._resources if r.name.lower() == name_lower), None)
async def get_script(self, name: str) -> SkillScript | None:
"""Get a script by name.
Args:
name: The script name to look up (case-insensitive).
Returns:
The :class:`SkillScript`, or ``None`` when no script with the
given name exists.
"""
name_lower = name.lower()
return next((s for s in self._scripts if s.name.lower() == name_lower), None)
# endregion
@@ -1734,13 +1805,13 @@ class SkillsProvider(ContextProvider):
Keyword Args:
instruction_template: Custom system-prompt template for
advertising skills. Must contain a ``{skills}`` placeholder for the
generated skills list. If the provider includes file-based script
execution instructions, the template must also contain
``{runner_instructions}``. If the provider includes resource-reading
instructions, the template must also contain
``{resource_instructions}``. Omitting any placeholder required by
the resolved skills configuration can raise :class:`ValueError` at
runtime. Uses a built-in template when ``None``.
generated skills list. May optionally contain
``{runner_instructions}`` and/or ``{resource_instructions}``
placeholders; when present, they are filled with built-in
guidance for script execution and resource reading respectively.
When omitted, those instructions are simply not included in the
rendered prompt (the corresponding tools are still registered).
Uses a built-in template when ``None``.
require_script_approval: When ``True``, skill script execution
requires explicit user approval before running. Instead of
executing immediately, the agent pauses and returns a
@@ -1867,29 +1938,20 @@ class SkillsProvider(ContextProvider):
def _create_instructions(
prompt_template: str | None,
skills: Sequence[Skill],
include_script_runner_instructions: bool = False,
include_resource_instructions: bool = False,
) -> str | None:
"""Create the system-prompt text that advertises available skills.
Generates an XML list of ``<skill>`` elements (sorted by name) and
inserts it into *prompt_template* at the ``{skills}`` placeholder.
When *include_script_runner_instructions* is ``True``, executor-provided
instructions are inserted at the ``{runner_instructions}`` placeholder.
When *include_resource_instructions* is ``True``, resource-reading
instructions are inserted at the ``{resource_instructions}`` placeholder.
Script-runner instructions are inserted at the
``{runner_instructions}`` placeholder and resource-reading
instructions at the ``{resource_instructions}`` placeholder.
Args:
prompt_template: Custom template string with ``{skills}`` and
optional ``{runner_instructions}`` and ``{resource_instructions}``
placeholders, or ``None`` to use the built-in default.
skills: Registered skills.
include_script_runner_instructions: When ``True``, include
script-runner instructions in the generated prompt.
Defaults to ``False``.
include_resource_instructions: When ``True``, include
resource-reading instructions in the generated prompt.
Defaults to ``False``.
Returns:
The formatted instruction string, or ``None`` when *skills* is empty.
@@ -1898,8 +1960,8 @@ class SkillsProvider(ContextProvider):
ValueError: If *prompt_template* is not a valid format string
(e.g. missing ``{skills}`` placeholder).
"""
runner_instructions = SCRIPT_RUNNER_INSTRUCTIONS if include_script_runner_instructions else None
resource_instructions = RESOURCE_INSTRUCTIONS if include_resource_instructions else None
runner_instructions = SCRIPT_RUNNER_INSTRUCTIONS
resource_instructions = RESOURCE_INSTRUCTIONS
template = DEFAULT_SKILLS_INSTRUCTION_PROMPT
if prompt_template is not None:
@@ -1921,16 +1983,6 @@ class SkillsProvider(ContextProvider):
raise ValueError(
"The provided instruction_template must contain a '{skills}' placeholder." # noqa: RUF027
)
if runner_instructions and "__EXEC_PROBE__" not in result:
raise ValueError(
"The provided instruction_template must contain an '{runner_instructions}' placeholder " # noqa: RUF027
"when a script runner is configured."
)
if resource_instructions and "__RES_PROBE__" not in result:
raise ValueError(
"The provided instruction_template must contain a '{resource_instructions}' placeholder " # noqa: RUF027
"when skills have resources."
)
template = prompt_template
if not skills:
@@ -1964,20 +2016,13 @@ class SkillsProvider(ContextProvider):
if not skills:
return skills, None, []
has_scripts = any(s.scripts for s in skills)
has_resources = any(s.resources for s in skills)
instructions = self._create_instructions(
prompt_template=self._instruction_template,
skills=skills,
include_script_runner_instructions=has_scripts,
include_resource_instructions=has_resources,
)
tools = self._create_tools(
skills=skills,
include_script_runner_tool=has_scripts,
include_resource_tool=has_resources,
require_script_approval=self._require_script_approval,
)
@@ -2046,23 +2091,15 @@ class SkillsProvider(ContextProvider):
def _create_tools(
self,
skills: Sequence[Skill],
include_script_runner_tool: bool,
include_resource_tool: bool,
require_script_approval: bool = False,
) -> list[FunctionTool]:
"""Create the tool definitions for skill interaction.
Always includes ``load_skill``. Conditionally includes
``read_skill_resource`` (when *include_resource_tool* is ``True``)
and ``run_skill_script`` (when *include_script_runner_tool* is
``True``).
Always includes ``load_skill``, ``read_skill_resource``, and
``run_skill_script``.
Args:
skills: The skills to bind to tool handlers.
include_script_runner_tool: Whether to include the
``run_skill_script`` tool in the returned list.
include_resource_tool: Whether to include the
``read_skill_resource`` tool in the returned list.
require_script_approval: When ``True``, the
``run_skill_script`` tool pauses for user approval
before each invocation.
@@ -2070,11 +2107,23 @@ class SkillsProvider(ContextProvider):
Returns:
A list of :class:`FunctionTool` instances.
"""
tools = [
async def _load(skill_name: str) -> str:
return await self._load_skill(skills, skill_name)
async def _read_resource(skill_name: str, resource_name: str, **kwargs: Any) -> Any:
return await self._read_skill_resource(skills, skill_name, resource_name, **kwargs)
async def _run_script(
skill_name: str, script_name: str, args: dict[str, Any] | list[str] | None = None, **kwargs: Any
) -> Any:
return await self._run_skill_script(skills, skill_name, script_name, args, **kwargs)
return [
FunctionTool(
name="load_skill",
description="Loads the full instructions for a specific skill.",
func=lambda skill_name: self._load_skill(skills, skill_name), # pyright: ignore[reportUnknownArgumentType, reportUnknownLambdaType]
func=_load,
input_model={
"type": "object",
"properties": {
@@ -2083,108 +2132,88 @@ class SkillsProvider(ContextProvider):
"required": ["skill_name"],
},
),
FunctionTool(
name="read_skill_resource",
description=(
"Reads a resource associated with a skill, such as references, assets, or dynamic data."
),
func=_read_resource,
input_model={
"type": "object",
"properties": {
"skill_name": {"type": "string", "description": "The name of the skill."},
"resource_name": {
"type": "string",
"description": "The name of the resource.",
},
},
"required": ["skill_name", "resource_name"],
},
),
FunctionTool(
name="run_skill_script",
description="Runs a script associated with a skill.",
func=_run_script,
approval_mode="always_require" if require_script_approval else "never_require",
input_model={
"type": "object",
"properties": {
"skill_name": {"type": "string", "description": "The name of the skill."},
"script_name": {
"type": "string",
"description": (
"The name of the script to run as listed in the skill, "
"preserving any directory prefix exactly as shown. "
"Do not add or remove path prefixes."
),
},
"args": {
"oneOf": [
{
"type": "object",
"additionalProperties": True,
"description": (
"Named arguments as key-value pairs "
'(e.g. {"length": 24, "uppercase": true}).'
),
},
{
"type": "array",
"items": {"type": "string"},
"description": (
"Positional CLI arguments as a string array "
'(e.g. ["input.docx", "--output", "result.idx"]).'
),
},
{"type": "null"},
],
"default": None,
"description": (
"Arguments to pass to the script. "
"Use an array of strings for CLI-style positional arguments "
'(e.g. ["input.docx", "--output", "result.idx"]), '
"or an object for named parameters "
'(e.g. {"length": 24, "uppercase": true}). '
"How these values are mapped to the underlying script "
"is determined by the script implementation or configured runner."
),
},
},
"required": ["skill_name", "script_name"],
},
),
]
if include_resource_tool:
async def _read_resource(skill_name: str, resource_name: str, **kwargs: Any) -> Any:
return await self._read_skill_resource(skills, skill_name, resource_name, **kwargs)
tools.append(
FunctionTool(
name="read_skill_resource",
description=(
"Reads a resource associated with a skill, such as references, assets, or dynamic data."
),
func=_read_resource,
input_model={
"type": "object",
"properties": {
"skill_name": {"type": "string", "description": "The name of the skill."},
"resource_name": {
"type": "string",
"description": "The name of the resource.",
},
},
"required": ["skill_name", "resource_name"],
},
)
)
if include_script_runner_tool:
async def _run_script(
skill_name: str, script_name: str, args: dict[str, Any] | list[str] | None = None, **kwargs: Any
) -> Any:
return await self._run_skill_script(skills, skill_name, script_name, args, **kwargs)
tools.append(
FunctionTool(
name="run_skill_script",
description="Runs a script associated with a skill.",
func=_run_script,
approval_mode="always_require" if require_script_approval else "never_require",
input_model={
"type": "object",
"properties": {
"skill_name": {"type": "string", "description": "The name of the skill."},
"script_name": {
"type": "string",
"description": (
"The name of the script to run as listed in the skill, "
"preserving any directory prefix exactly as shown. "
"Do not add or remove path prefixes."
),
},
"args": {
"oneOf": [
{
"type": "object",
"additionalProperties": True,
"description": (
"Named arguments as key-value pairs "
'(e.g. {"length": 24, "uppercase": true}).'
),
},
{
"type": "array",
"items": {"type": "string"},
"description": (
"Positional CLI arguments as a string array "
'(e.g. ["input.docx", "--output", "result.idx"]).'
),
},
{"type": "null"},
],
"default": None,
"description": (
"Arguments to pass to the script. "
"Use an array of strings for CLI-style positional arguments "
'(e.g. ["input.docx", "--output", "result.idx"]), '
"or an object for named parameters "
'(e.g. {"length": 24, "uppercase": true}). '
"How these values are mapped to the underlying script "
"is determined by the script implementation or configured runner."
),
},
},
"required": ["skill_name", "script_name"],
},
)
)
return tools
@staticmethod
def _find_skill(skills: Sequence[Skill], name: str) -> Skill | None:
"""Find a skill by name (case-insensitive linear scan)."""
name_lower = name.lower()
return next((s for s in skills if s.frontmatter.name.lower() == name_lower), None)
def _load_skill(self, skills: Sequence[Skill], skill_name: str) -> str:
async def _load_skill(self, skills: Sequence[Skill], skill_name: str) -> str:
"""Return the full content for the named skill.
Delegates to the skill's :attr:`~Skill.content` property, which
Delegates to the skill's :meth:`~Skill.get_content` method, which
handles format differences between file-based and code-defined skills.
Args:
@@ -2204,7 +2233,7 @@ class SkillsProvider(ContextProvider):
logger.info("Loading skill: %s", skill_name)
return skill.content
return await skill.get_content()
async def _run_skill_script(
self,
@@ -2243,7 +2272,7 @@ class SkillsProvider(ContextProvider):
if not skill:
return f"Error: Skill '{skill_name}' not found."
script = next((s for s in skill.scripts if s.name.lower() == script_name.lower()), None)
script = await skill.get_script(script_name)
if not script:
return f"Error: Script '{script_name}' not found in skill '{skill_name}'."
@@ -2284,12 +2313,8 @@ class SkillsProvider(ContextProvider):
if skill is None:
return f"Error: Skill '{skill_name}' not found."
# Find resource by name (case-insensitive)
resource_name_lower = resource_name.lower()
for resource in skill.resources:
if resource.name.lower() == resource_name_lower:
break
else:
resource = await skill.get_resource(resource_name)
if resource is None:
return f"Error: Resource '{resource_name}' not found in skill '{skill_name}'."
try:
@@ -2481,27 +2506,29 @@ class FileSkillsSource(SkillsSource):
)
continue
file_skill = FileSkill(
frontmatter=frontmatter,
content=content,
path=skill_path,
)
# Discover and attach file-based resources
# Discover file-based resources
resources: list[SkillResource] = []
for rn in FileSkillsSource._discover_resource_files(
skill_path, self._resource_extensions, self._resource_directories
):
resource_full_path = FileSkillsSource._get_validated_resource_path(skill_path, rn)
file_skill.resources.append(_FileSkillResource(name=rn, full_path=resource_full_path))
resources.append(_FileSkillResource(name=rn, full_path=resource_full_path))
# Discover and attach file-based scripts as SkillScript instances
# Discover file-based scripts
scripts: list[SkillScript] = []
for sn in FileSkillsSource._discover_script_files(
skill_path, self._script_extensions, self._script_directories
):
script_full_path = os.path.normpath(os.path.join(skill_path, sn)) # noqa: ASYNC240
file_skill.scripts.append(
FileSkillScript(name=sn, full_path=script_full_path, runner=self._script_runner)
)
scripts.append(FileSkillScript(name=sn, full_path=script_full_path, runner=self._script_runner))
file_skill = FileSkill(
frontmatter=frontmatter,
content=content,
path=skill_path,
resources=resources,
scripts=scripts,
)
skills[file_skill.frontmatter.name] = file_skill
logger.info("Loaded skill: %s", file_skill.frontmatter.name)
File diff suppressed because it is too large Load Diff