mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into copilot/update-microsoft-extensions-ai-packages
This commit is contained in:
@@ -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" />
|
||||
|
||||
+22
@@ -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>
|
||||
+93
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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 (0x40–0x7E).
|
||||
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 <= 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;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -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,
|
||||
|
||||
+7
-5
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+88
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user