mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72a193086e |
@@ -1,19 +1,15 @@
|
||||
{
|
||||
"name": "Python 3",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.14-bookworm",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.13-bullseye",
|
||||
"features": {
|
||||
"ghcr.io/va-h/devcontainers-features/uv:1": {},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:3": {},
|
||||
"ghcr.io/devcontainers/features/azure-cli:1.2.9": {},
|
||||
"ghcr.io/devcontainers/features/copilot-cli:1": {}
|
||||
"ghcr.io/devcontainers/features/azure-cli:1.2.8": {}
|
||||
},
|
||||
"postCreateCommand": "bash ./devsetup.sh",
|
||||
"workspaceFolder": "/workspaces/agent-framework/python/",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"GitHub.copilot",
|
||||
"GitHub.vscode-github-actions",
|
||||
"ms-python.python",
|
||||
"ms-windows-ai-studio.windows-ai-studio",
|
||||
"littlefoxteam.vscode-python-test-adapter"
|
||||
|
||||
@@ -8,7 +8,7 @@ ignorePatterns:
|
||||
- pattern: "./blob"
|
||||
- pattern: "./issues"
|
||||
- pattern: "./discussions"
|
||||
- pattern: "./pull"
|
||||
- pattern: "./pulls"
|
||||
- pattern: "https:\/\/platform.openai.com"
|
||||
- pattern: "http:\/\/localhost"
|
||||
- pattern: "http:\/\/127.0.0.1"
|
||||
|
||||
@@ -359,9 +359,6 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/HostedAgentSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/DurableAgents/" />
|
||||
<Folder Name="/Samples/04-hosting/DurableAgents/AzureFunctions/">
|
||||
<File Path="samples/04-hosting/DurableAgents/AzureFunctions/.editorconfig" />
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj",
|
||||
"src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj",
|
||||
"src\\Microsoft.Agents.AI.Tools.Shell\\Microsoft.Agents.AI.Tools.Shell.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Declarative.Foundry\\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.7.0</VersionPrefix>
|
||||
<VersionPrefix>1.6.2</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260526</DateSuffix>
|
||||
<DateSuffix>260521</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.7.0</GitTag>
|
||||
<GitTag>1.6.2</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -24,7 +24,6 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM
|
||||
var skillsProvider = new AgentSkillsProvider(
|
||||
Path.Combine(AppContext.BaseDirectory, "skills"),
|
||||
SubprocessScriptRunner.RunAsync);
|
||||
|
||||
// --- Agent Setup ---
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.GetResponsesClient()
|
||||
|
||||
@@ -51,7 +51,7 @@ Console.WriteLine($"Agent: {response.Text}");
|
||||
/// Properties annotated with <see cref="AgentSkillResourceAttribute"/> are automatically
|
||||
/// discovered as skill resources, and methods annotated with <see cref="AgentSkillScriptAttribute"/>
|
||||
/// are automatically discovered as skill scripts. Alternatively,
|
||||
/// <see cref="AgentClassSkill{TSelf}.Resources"/> and <see cref="AgentClassSkill{TSelf}.Scripts"/> can be overridden.
|
||||
/// <see cref="AgentSkill.Resources"/> and <see cref="AgentSkill.Scripts"/> can be overridden.
|
||||
/// </remarks>
|
||||
internal sealed class UnitConverterSkill : AgentClassSkill<UnitConverterSkill>
|
||||
{
|
||||
|
||||
@@ -40,8 +40,8 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
foreach (string line in props.Title.Split('\n'))
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
Console.Write(line);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
row++;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
for (int i = 0; i < totalItems; i++)
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
|
||||
bool isSelected = i == props.SelectedIndex;
|
||||
bool isCustomTextOption = props.CustomTextPlaceholder != null && i == props.Items.Count;
|
||||
@@ -71,7 +72,6 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
}
|
||||
|
||||
Console.Write(props.Items[i]);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
@@ -101,7 +101,6 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
}
|
||||
|
||||
Console.Write(props.CustomText);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
@@ -122,7 +121,6 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
|
||||
Console.Write(" ");
|
||||
Console.Write(props.CustomTextPlaceholder);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,19 +17,16 @@ public record TextScrollPanelProps : ConsoleReactiveProps
|
||||
/// <summary>
|
||||
/// State for <see cref="TextScrollPanel"/>.
|
||||
/// </summary>
|
||||
public record TextScrollPanelState : ConsoleReactiveState;
|
||||
/// <param name="RenderedCount">The number of items already rendered.</param>
|
||||
public record TextScrollPanelState(int RenderedCount = 0) : ConsoleReactiveState;
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders pre-rendered string items within a scroll area.
|
||||
/// The last rendered item is considered dynamic and will be re-rendered on each call.
|
||||
/// All prior items are considered finalized and are not re-rendered.
|
||||
/// Use <see cref="Invalidate"/> to force a full re-render.
|
||||
/// All items are considered finalized — only new items since the last render are output.
|
||||
/// Use <see cref="Reset"/> to force a full re-render.
|
||||
/// </summary>
|
||||
public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, TextScrollPanelState>
|
||||
{
|
||||
private int _renderedCount;
|
||||
private int _lastItemOffsetFromBottom;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextScrollPanel"/> class.
|
||||
/// </summary>
|
||||
@@ -38,12 +35,12 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
|
||||
this.State = new TextScrollPanelState();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Invalidate()
|
||||
/// <summary>
|
||||
/// Resets the panel so all items will be re-rendered on the next Render call.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
this._renderedCount = 0;
|
||||
this._lastItemOffsetFromBottom = 0;
|
||||
base.Invalidate();
|
||||
this.State = new TextScrollPanelState();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -54,59 +51,16 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
|
||||
return;
|
||||
}
|
||||
|
||||
int bottomRow = props.Y + props.Height - 1;
|
||||
// Move cursor to the bottom of the scroll area
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + props.Height - 1, props.X));
|
||||
|
||||
// Determine the first item to render. If we previously rendered items,
|
||||
// re-render the last one (it may have changed/grown) from its stored position.
|
||||
int startIndex = this._renderedCount > 0 ? this._renderedCount - 1 : 0;
|
||||
|
||||
if (this._renderedCount > 0 && this._lastItemOffsetFromBottom > 0)
|
||||
{
|
||||
// Reposition cursor to where the last rendered item began
|
||||
Console.Write(AnsiEscapes.MoveCursor(bottomRow - this._lastItemOffsetFromBottom, props.X));
|
||||
}
|
||||
else
|
||||
{
|
||||
// First render — position at the bottom of the scroll area
|
||||
Console.Write(AnsiEscapes.MoveCursor(bottomRow, props.X));
|
||||
}
|
||||
|
||||
// Render from startIndex onwards
|
||||
for (int i = startIndex; i < props.Items.Count; i++)
|
||||
// Output only new items since last rendered
|
||||
for (int i = state.RenderedCount; i < props.Items.Count; i++)
|
||||
{
|
||||
Console.Write(props.Items[i]);
|
||||
}
|
||||
|
||||
// Calculate the offset from bottom for the start of the new last item
|
||||
int lastItemLines = CountLines(props.Items[^1]);
|
||||
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;
|
||||
// Update state to track what we've rendered
|
||||
this.State = new TextScrollPanelState(props.Items.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.ConsoleReactiveFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Caches the result of a mapping function and only recomputes when the input changes.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of the input value.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of the mapped output value.</typeparam>
|
||||
public class ConsoleReactiveMemo<TInput, TOutput>
|
||||
{
|
||||
private TInput? _previousInput;
|
||||
private TOutput? _cachedOutput;
|
||||
private bool _hasValue;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached output if <paramref name="input"/> equals the previously stored input;
|
||||
/// otherwise invokes <paramref name="mapper"/> to compute and cache a new output.
|
||||
/// </summary>
|
||||
/// <param name="input">The current input value.</param>
|
||||
/// <param name="mapper">A function that maps the input to an output value.</param>
|
||||
/// <returns>The cached or newly computed output.</returns>
|
||||
public TOutput Map(TInput input, Func<TInput, TOutput> mapper)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mapper);
|
||||
|
||||
if (!this._hasValue || !EqualityComparer<TInput>.Default.Equals(input, this._previousInput))
|
||||
{
|
||||
this._previousInput = input;
|
||||
this._cachedOutput = mapper(input);
|
||||
this._hasValue = true;
|
||||
}
|
||||
|
||||
return this._cachedOutput!;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
private readonly ListSelection _listSelection = new();
|
||||
private readonly TextInput _textInput = new();
|
||||
private readonly TextScrollPanel _textScrollPanel = new();
|
||||
private readonly TextPanel _textPanel = new();
|
||||
private readonly TextPanel _queuedPanel = new();
|
||||
private readonly AgentStatus _agentStatus = new();
|
||||
private readonly AgentModeAndHelp _modeAndHelp = new();
|
||||
@@ -340,6 +341,16 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the text panel height for the last scroll item
|
||||
IReadOnlyList<string> lastItems = state.ScrollAreaContentItems.Count > 0
|
||||
? [state.ScrollAreaContentItems[^1]]
|
||||
: [];
|
||||
int textPanelHeight = TextPanel.CalculateHeight(lastItems);
|
||||
if (textPanelHeight > 0)
|
||||
{
|
||||
textPanelHeight++; // Extra line for spacing between text panel and rule
|
||||
}
|
||||
|
||||
// Calculate queued items panel height
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
|
||||
|
||||
@@ -433,7 +444,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
int modeAndHelpHeight = showStatusAndHelp ? AgentModeAndHelp.CalculateHeight(modeAndHelpProps) : 0;
|
||||
|
||||
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
|
||||
int nonScrollHeight = ruleHeight + agentStatusHeight + queuedPanelHeight + modeAndHelpHeight + 1; // +1 for bottom padding
|
||||
int nonScrollHeight = ruleHeight + textPanelHeight + agentStatusHeight + queuedPanelHeight + modeAndHelpHeight + 1; // +1 for bottom padding
|
||||
int scrollBottom = Math.Max(1, state.ConsoleHeight - nonScrollHeight);
|
||||
|
||||
// If scroll region changed or a clear is needed, reset everything
|
||||
@@ -444,36 +455,52 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
System.Console.Write(AnsiEscapes.ResetScrollRegion);
|
||||
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
this._textScrollPanel.Reset();
|
||||
this._resizedSinceLastRender = false;
|
||||
|
||||
// Invalidate all children so they re-render even if props haven't changed
|
||||
this._rule.Invalidate();
|
||||
this._textScrollPanel.Invalidate();
|
||||
this._textPanel.Invalidate();
|
||||
this._queuedPanel.Invalidate();
|
||||
this._agentStatus.Invalidate();
|
||||
this._modeAndHelp.Invalidate();
|
||||
this._textInput.Invalidate();
|
||||
this._listSelection.Invalidate();
|
||||
|
||||
this._resizedSinceLastRender = false;
|
||||
}
|
||||
|
||||
this._scrollRegionBottom = scrollBottom;
|
||||
|
||||
System.Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom));
|
||||
|
||||
// Render text scroll panel in the scroll area
|
||||
// Render text scroll panel in the scroll area (all items except the last)
|
||||
IReadOnlyList<string> scrollItems = state.ScrollAreaContentItems.Count > 1
|
||||
? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList()
|
||||
: [];
|
||||
|
||||
this._textScrollPanel.Props = new TextScrollPanelProps
|
||||
{
|
||||
X = 1,
|
||||
Y = 1,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = scrollBottom,
|
||||
Items = state.ScrollAreaContentItems,
|
||||
Items = scrollItems,
|
||||
};
|
||||
this._textScrollPanel.Render();
|
||||
|
||||
// Render queued input items between scroll area and agent status
|
||||
int queuedPanelY = scrollBottom + 1;
|
||||
// Render the text panel for the last (dynamic) item just below the scroll region
|
||||
this._textPanel.Props = new TextPanelProps
|
||||
{
|
||||
X = 1,
|
||||
Y = scrollBottom + 1,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = textPanelHeight,
|
||||
Items = lastItems,
|
||||
};
|
||||
this._textPanel.Render();
|
||||
|
||||
// Render queued input items between text panel and agent status
|
||||
int queuedPanelY = scrollBottom + textPanelHeight + 1;
|
||||
this._queuedPanel.Props = new TextPanelProps
|
||||
{
|
||||
X = 1,
|
||||
|
||||
+3
-3
@@ -5,17 +5,17 @@ using Microsoft.Extensions.AI;
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>mode_*</c> tool calls, showing the target mode for Set operations.
|
||||
/// Formats <c>AgentMode_*</c> tool calls, showing the target mode for Set operations.
|
||||
/// </summary>
|
||||
public sealed class ModeToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("mode_", StringComparison.Ordinal);
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("AgentMode_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"mode_set" => FormatStringArg(call, "mode"),
|
||||
"AgentMode_Set" => FormatStringArg(call, "mode"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AGENT_NAME=hosted-agent-skills
|
||||
SKILL_NAMES=support-style,escalation-policy
|
||||
# Set to true to provision sample skills to Foundry on startup (first-run convenience).
|
||||
# In production, skills are provisioned externally — leave this unset or false.
|
||||
PROVISION_SAMPLE_SKILLS=true
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
# When running outside the Foundry platform the platform-injected isolation keys are absent.
|
||||
# These two variables provide fallback values for local Docker debugging only.
|
||||
HOSTED_USER_ISOLATION_KEY=local-dev-user
|
||||
HOSTED_CHAT_ISOLATION_KEY=local-dev-chat
|
||||
@@ -1,26 +0,0 @@
|
||||
# Dockerfile for end-users consuming the Agent Framework via NuGet packages.
|
||||
#
|
||||
# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container,
|
||||
# which only succeeds when the project references its dependencies via PackageReference (see the
|
||||
# commented-out section in HostedAgentSkills.csproj). Contributors building from the
|
||||
# agent-framework repository source must use Dockerfile.contributor instead because
|
||||
# ProjectReference dependencies live outside this folder and cannot be restored from inside
|
||||
# this build context.
|
||||
#
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedAgentSkills.dll"]
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI source,
|
||||
# which means a standard multi-stage Docker build cannot resolve dependencies outside
|
||||
# this folder. Instead, pre-publish the app targeting the container runtime and copy
|
||||
# the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-agent-skills .
|
||||
# docker run --rm -p 8088:8088 \
|
||||
# -e AGENT_NAME=hosted-agent-skills \
|
||||
# -e HOSTED_USER_ISOLATION_KEY=alice \
|
||||
# -e HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 \
|
||||
# --env-file .env hosted-agent-skills
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedAgentSkills.dll"]
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedAgentSkills</RootNamespace>
|
||||
<AssemblyName>HostedAgentSkills</AssemblyName>
|
||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001;AAIP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
<!-- Include the skills/ directory in the publish output so the sample can provision them -->
|
||||
<ItemGroup>
|
||||
<None Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-215
@@ -1,215 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Hosted-AgentSkills
|
||||
//
|
||||
// Demonstrates how to host an agent that loads its behavioral guidelines from Foundry Skills at
|
||||
// startup. Skills are authored as SKILL.md files, uploaded to Foundry via the Skills REST API,
|
||||
// and downloaded by the agent on boot so guideline updates ship without code changes.
|
||||
//
|
||||
// The agent uses AgentSkillsProvider from the Agent Framework which implements the progressive
|
||||
// disclosure pattern from the Agent Skills specification (https://agentskills.io/):
|
||||
// 1. Advertise — skill names and descriptions are injected into the system prompt.
|
||||
// 2. Load — the model calls load_skill to retrieve the full SKILL.md body on demand.
|
||||
//
|
||||
// IMPORTANT: In production, skill provisioning (uploading SKILL.md files to Foundry) is an
|
||||
// external concern — it is NOT the hosted agent's responsibility. The provisioning helper below
|
||||
// is included for sample convenience only, so the sample is self-contained and runnable without
|
||||
// a separate setup step. A real deployment pipeline would provision skills separately (e.g., via
|
||||
// a CI/CD step, a CLI script, or a management portal).
|
||||
|
||||
#pragma warning disable AAIP001 // ProjectAgentSkills is experimental
|
||||
|
||||
using System.ClientModel;
|
||||
using System.IO.Compression;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
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-4o";
|
||||
string skillNames = Environment.GetEnvironmentVariable("SKILL_NAMES")
|
||||
?? throw new InvalidOperationException("SKILL_NAMES is not set. Provide a comma-separated list of skill names (e.g., support-style,escalation-policy).");
|
||||
|
||||
string[] requestedSkills = skillNames.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (requestedSkills.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("SKILL_NAMES must list at least one skill name.");
|
||||
}
|
||||
|
||||
// Validate skill names to prevent path traversal.
|
||||
foreach (string name in requestedSkills)
|
||||
{
|
||||
if (name.Contains('.') || name.Contains('/') || name.Contains('\\') || Path.IsPathRooted(name))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Invalid skill name '{name}': skill names must not contain path separators or dots.");
|
||||
}
|
||||
}
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
AIProjectClient projectClient = new(new Uri(endpoint), credential);
|
||||
ProjectAgentSkills skillsClient = projectClient.AgentAdministrationClient.GetAgentSkills();
|
||||
|
||||
// ── Provision skills (sample convenience only — NOT a production pattern) ─────
|
||||
// In production, skills are provisioned externally (e.g., via CI/CD or a management script).
|
||||
// This helper ensures the sample's SKILL.md files exist in Foundry so the sample is runnable
|
||||
// out of the box without a separate setup step. Set PROVISION_SAMPLE_SKILLS=true to enable.
|
||||
string sourceSkillsDir = Path.Combine(AppContext.BaseDirectory, "skills");
|
||||
bool provisionEnabled = string.Equals(
|
||||
Environment.GetEnvironmentVariable("PROVISION_SAMPLE_SKILLS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (provisionEnabled && Directory.Exists(sourceSkillsDir))
|
||||
{
|
||||
await EnsureSkillsProvisionedAsync(skillsClient, sourceSkillsDir, requestedSkills);
|
||||
}
|
||||
|
||||
// ── Download skills from Foundry ─────────────────────────────────────────────
|
||||
// Pull the latest copy of each skill from Foundry into a runtime-only folder.
|
||||
// This directory is recreated on every startup so the agent always picks up
|
||||
// the latest version of each skill.
|
||||
string downloadedSkillsDir = Path.Combine(AppContext.BaseDirectory, "downloaded_skills");
|
||||
await DownloadSkillsAsync(skillsClient, requestedSkills, downloadedSkillsDir);
|
||||
|
||||
// ── Wire skills into the agent ───────────────────────────────────────────────
|
||||
// AgentSkillsProvider implements progressive disclosure: skill names and descriptions
|
||||
// are advertised in the system prompt (~100 tokens per skill), and the full SKILL.md
|
||||
// body is loaded on demand when the model calls the load_skill tool.
|
||||
AgentSkillsProvider skillsProvider = new(downloadedSkillsDir);
|
||||
|
||||
ChatClientAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-agent-skills",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deploymentName,
|
||||
Instructions = "You are a customer-support assistant for Contoso Outdoors.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider]
|
||||
});
|
||||
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Downloads each named skill from Foundry and extracts the ZIP archive into a
|
||||
// separate subdirectory under the target directory.
|
||||
static async Task DownloadSkillsAsync(ProjectAgentSkills skillsClient, string[] skillNames, string targetDir)
|
||||
{
|
||||
if (Directory.Exists(targetDir))
|
||||
{
|
||||
Directory.Delete(targetDir, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(targetDir);
|
||||
|
||||
foreach (string name in skillNames)
|
||||
{
|
||||
Console.WriteLine($"Downloading skill '{name}' from Foundry...");
|
||||
BinaryData zipData = await skillsClient.DownloadSkillAsync(name);
|
||||
|
||||
string skillDir = Path.Combine(targetDir, name);
|
||||
Directory.CreateDirectory(skillDir);
|
||||
|
||||
using var zipStream = zipData.ToStream();
|
||||
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read);
|
||||
SafeExtractZip(archive, skillDir);
|
||||
|
||||
if (!File.Exists(Path.Combine(skillDir, "SKILL.md")))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Downloaded archive for '{name}' did not contain a SKILL.md at the root.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extracts a ZIP archive into a destination directory, rejecting entries that would
|
||||
// escape the target path (zip-slip guard).
|
||||
static void SafeExtractZip(ZipArchive archive, string destinationDir)
|
||||
{
|
||||
string destRoot = Path.GetFullPath(destinationDir);
|
||||
string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
|
||||
? destRoot
|
||||
: destRoot + Path.DirectorySeparatorChar;
|
||||
|
||||
// Use ordinal comparison on Unix (case-sensitive FS) and ordinal-ignore-case on Windows.
|
||||
var comparison = OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
foreach (ZipArchiveEntry entry in archive.Entries)
|
||||
{
|
||||
string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
|
||||
if (!entryPath.StartsWith(destRootWithSep, comparison)
|
||||
&& !string.Equals(entryPath, destRoot, comparison))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(entry.Name))
|
||||
{
|
||||
// Directory entry — ensure it exists.
|
||||
Directory.CreateDirectory(entryPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
|
||||
entry.ExtractToFile(entryPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensures each requested skill is provisioned in Foundry. For each skill name, checks whether
|
||||
// the skill exists and uploads it from the local source directory if it does not.
|
||||
//
|
||||
// This is a sample convenience helper — in production, skill provisioning is an external concern.
|
||||
static async Task EnsureSkillsProvisionedAsync(ProjectAgentSkills skillsClient, string sourceDir, string[] skillNames)
|
||||
{
|
||||
foreach (string name in skillNames)
|
||||
{
|
||||
string skillPath = Path.Combine(sourceDir, name);
|
||||
if (!Directory.Exists(skillPath) || !File.Exists(Path.Combine(skillPath, "SKILL.md")))
|
||||
{
|
||||
continue; // No local source for this skill — skip provisioning.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await skillsClient.GetSkillAsync(name);
|
||||
Console.WriteLine($"Skill '{name}' already exists in Foundry.");
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
Console.WriteLine($"Provisioning skill '{name}' from {skillPath}...");
|
||||
AgentsSkill imported = await skillsClient.CreateSkillFromPackageAsync(skillPath);
|
||||
Console.WriteLine($" Imported skill '{imported.Name}' (id={imported.SkillId}, has_blob={imported.HasBlob}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
An [Agent Framework](https://github.com/microsoft/agent-framework) agent that loads its behavioral guidelines from [**Foundry Skills**](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/skills) at startup, hosted using the **Responses protocol**. Skills are authored once as `SKILL.md` files, uploaded to your Foundry project through the Skills REST API, and downloaded by the agent on boot so updates ship without code changes.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Authoring skills
|
||||
|
||||
Each skill is a Markdown file with a YAML front matter block. This sample ships two source skills under [`skills/`](skills/):
|
||||
|
||||
| Skill | Purpose |
|
||||
|---|---|
|
||||
| [`support-style`](skills/support-style/SKILL.md) | Voice, formatting, and signature rules for Contoso Outdoors support replies. |
|
||||
| [`escalation-policy`](skills/escalation-policy/SKILL.md) | When and how to escalate a customer ticket. |
|
||||
|
||||
Each `SKILL.md` includes a unique `*-CANARY-*` token that the model is asked to echo, so you can prove the skill was loaded from Foundry (not hallucinated) by checking the response.
|
||||
|
||||
> The `name` and `description` values in the YAML front matter must be **unquoted** — quoting them causes the Skills REST API to return HTTP 500 on import.
|
||||
|
||||
### Uploading skills
|
||||
|
||||
The sample includes a convenience provisioning step that checks whether each skill exists in Foundry and uploads it if not, gated behind the `PROVISION_SAMPLE_SKILLS=true` env var. **In production, skill provisioning is an external concern** — it is NOT the hosted agent's responsibility. A real deployment pipeline would provision skills separately (e.g., via a CI/CD step, a CLI script, or a management portal).
|
||||
|
||||
The provisioning uses `ProjectAgentSkills.CreateSkillFromPackageAsync(directoryPath)` from the `Azure.AI.Projects.Agents` SDK. The method packages the `SKILL.md` file as a ZIP and uploads it to Foundry.
|
||||
|
||||
### Downloading skills at agent startup
|
||||
|
||||
[`Program.cs`](Program.cs) reads the comma-separated `SKILL_NAMES` env var and for each skill name downloads the ZIP archive from Foundry via `ProjectAgentSkills.DownloadSkillAsync(name)`, then unpacks it into a **separate runtime directory** at `downloaded_skills/<name>/` (kept distinct from the static `skills/` source folder).
|
||||
|
||||
An [`AgentSkillsProvider`](../../../../../src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs) is then built over `downloaded_skills/` and attached to the agent as a context provider. The provider follows the [Agent Skills](https://agentskills.io/) progressive-disclosure pattern:
|
||||
|
||||
1. **Advertise** — skill names and descriptions are injected into the system prompt at session start (~100 tokens per skill).
|
||||
2. **Load** — the model calls the `load_skill` tool when it decides a skill is relevant to the user's turn, and the full `SKILL.md` body is returned.
|
||||
|
||||
This means the model only pays the token cost for a skill's full body when it actually needs it, and updating a skill in Foundry + restarting the agent is enough to pick up the change — no code redeploy required.
|
||||
|
||||
> **Note:** This sample supports instruction-only and resource-based skills. If your downloaded skills contain scripts, add a script runner when constructing the `AgentSkillsProvider`.
|
||||
|
||||
### Agent Hosting
|
||||
|
||||
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the Responses API hosting layer (`AddFoundryResponses` / `MapFoundryResponses`).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
### Required RBAC
|
||||
|
||||
Your identity (or the Managed Identity running the container in production) needs **Azure AI User** on the Foundry project scope. This single role covers both authoring skills and downloading them.
|
||||
|
||||
## Running the Agent Host
|
||||
|
||||
Set the required environment variables and run the sample with `dotnet run`:
|
||||
|
||||
```bash
|
||||
export AZURE_AI_PROJECT_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>"
|
||||
export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o"
|
||||
export SKILL_NAMES="support-style,escalation-policy"
|
||||
export PROVISION_SAMPLE_SKILLS="true" # First run only — provisions skills to Foundry
|
||||
```
|
||||
|
||||
Or in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:SKILL_NAMES="support-style,escalation-policy"
|
||||
$env:PROVISION_SAMPLE_SKILLS="true" # First run only — provisions skills to Foundry
|
||||
```
|
||||
|
||||
You can also place these in a `.env` file next to `Program.cs` — see [`.env.example`](.env.example).
|
||||
|
||||
On startup you should see:
|
||||
|
||||
```text
|
||||
Skill 'support-style' already exists in Foundry.
|
||||
Skill 'escalation-policy' already exists in Foundry.
|
||||
Downloading skill 'support-style' from Foundry...
|
||||
Downloading skill 'escalation-policy' from Foundry...
|
||||
```
|
||||
|
||||
The downloaded `SKILL.md` files land under `downloaded_skills/<name>/SKILL.md` next to the published output. This directory is recreated from scratch on every run, so deleting it manually is never necessary.
|
||||
|
||||
## Interacting with the agent
|
||||
|
||||
> Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Hi, I am Alex. I just want to confirm I can return my tent within 30 days."}'
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "I want a $750 refund on Order #A-1042 right now or I am calling my lawyer."}'
|
||||
```
|
||||
|
||||
| Prompt mentions | Skill that should drive the response |
|
||||
|---|---|
|
||||
| Routine return / shipping / care question | Model loads `support-style` (canary `STYLE-CANARY-3318`) — no escalation. |
|
||||
| Injury, legal threat, press, or refund > $500 | Model loads `escalation-policy` (canary `ESC-CANARY-7742`) **and** `support-style`. |
|
||||
|
||||
Because skills are loaded on demand, the canary token in a response also proves the model actually invoked `load_skill` for the matching skill (not just saw its name in the advertised list).
|
||||
|
||||
## Deploying the Agent to Foundry
|
||||
|
||||
When deploying to Foundry, make sure `SKILL_NAMES` is set in your `azd` environment so it gets injected into the hosted container per [`agent.manifest.yaml`](agent.manifest.yaml):
|
||||
|
||||
```bash
|
||||
azd env set SKILL_NAMES "support-style,escalation-policy"
|
||||
```
|
||||
|
||||
The deployed agent's Managed Identity needs **Azure AI User** on the Foundry project to download skills at startup.
|
||||
|
||||
> The `skills/` source folder is **not** deployed to Foundry — only the downloaded skills are used at runtime. The provisioning step must have been run against the same Foundry project before the agent can download the skills.
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-agent-skills
|
||||
displayName: "Hosted Agent Skills"
|
||||
|
||||
description: >
|
||||
An Agent Framework agent that downloads its behavioral guidelines from the Foundry
|
||||
Skills REST API at startup, demonstrating how to decouple behavioral guidelines
|
||||
(tone, escalation policy, etc.) from agent code using AgentSkillsProvider.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Agent Framework
|
||||
- Agent Skills
|
||||
- Foundry Skills
|
||||
|
||||
template:
|
||||
name: hosted-agent-skills
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: SKILL_NAMES
|
||||
value: "{{SKILL_NAMES}}"
|
||||
parameters:
|
||||
properties:
|
||||
- name: SKILL_NAMES
|
||||
secret: false
|
||||
description: Comma-separated list of Foundry skill names to download at startup (e.g., support-style,escalation-policy)
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4.1-mini
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
@@ -1,14 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-agent-skills
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
- name: SKILL_NAMES
|
||||
value: ${SKILL_NAMES}
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
#requires -Version 7
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Local smoke test for the Hosted-AgentSkills sample.
|
||||
.DESCRIPTION
|
||||
Publishes the sample, builds the contributor Docker image, runs the container, drives
|
||||
two conversations via curl invocations, and asserts that the agent loaded the correct
|
||||
Foundry Skill for each prompt (verified via canary tokens in the response).
|
||||
Exits non-zero on failure.
|
||||
|
||||
Prerequisites:
|
||||
- Docker
|
||||
- az login (token is fetched from the host)
|
||||
- .env populated with AZURE_AI_PROJECT_ENDPOINT and model deployment
|
||||
- Skills provisioned to Foundry (set PROVISION_SAMPLE_SKILLS=true on first run)
|
||||
.NOTES
|
||||
This script is for local Docker debugging only. The Foundry platform supplies the
|
||||
isolation keys for every inbound request in production and the dev fallback used here
|
||||
must not be enabled in production deployments.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$Port = 8088,
|
||||
[string]$ImageName = 'hosted-agent-skills-smoke',
|
||||
[string]$ContainerName = 'hosted-agent-skills-smoke'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location -Path $PSScriptRoot/..
|
||||
|
||||
if (-not (Test-Path .env)) {
|
||||
throw '.env not found. Copy .env.example to .env and fill in AZURE_AI_PROJECT_ENDPOINT.'
|
||||
}
|
||||
|
||||
Write-Host '==> Publishing sample for linux-musl-x64 ...'
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out --tl:off | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' }
|
||||
|
||||
Write-Host '==> Building docker image ...'
|
||||
docker build -f Dockerfile.contributor -t $ImageName . | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'docker build failed.' }
|
||||
|
||||
Write-Host '==> Fetching bearer token ...'
|
||||
$bearer = az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv
|
||||
if (-not $bearer) { throw 'Failed to obtain bearer token. Run az login.' }
|
||||
|
||||
function Start-Container {
|
||||
docker rm -f $ContainerName 2>$null | Out-Null
|
||||
docker run -d --name $ContainerName -p ${Port}:8088 `
|
||||
-e AGENT_NAME=hosted-agent-skills `
|
||||
-e AZURE_BEARER_TOKEN=$bearer `
|
||||
-e HOSTED_USER_ISOLATION_KEY=smoke-user `
|
||||
-e HOSTED_CHAT_ISOLATION_KEY=smoke-chat-1 `
|
||||
--env-file .env `
|
||||
$ImageName | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "docker run failed." }
|
||||
# Wait for the server to start and download skills from Foundry.
|
||||
Write-Host ' Waiting for startup (skill download + server ready) ...'
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
|
||||
function Invoke-Agent([string]$Prompt, [string]$PreviousResponseId = $null) {
|
||||
$body = @{ input = $Prompt; model = 'hosted-agent-skills' }
|
||||
if ($PreviousResponseId) { $body['previous_response_id'] = $PreviousResponseId }
|
||||
$json = $body | ConvertTo-Json -Compress
|
||||
$resp = Invoke-RestMethod -Method Post -Uri "http://localhost:$Port/responses" -ContentType 'application/json' -Body $json
|
||||
return $resp
|
||||
}
|
||||
|
||||
function Get-ResponseText($response) {
|
||||
return ($response.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' '
|
||||
}
|
||||
|
||||
function Assert-Contains([string]$Haystack, [string]$Needle, [string]$Label) {
|
||||
if ($Haystack -notmatch [regex]::Escape($Needle)) {
|
||||
throw "FAILED [$Label]: expected response to contain '$Needle' but got: $Haystack"
|
||||
}
|
||||
Write-Host "PASS [$Label]: response contains '$Needle'."
|
||||
}
|
||||
|
||||
try {
|
||||
Start-Container
|
||||
|
||||
Write-Host '==> Test 1: Routine support question -> support-style skill ...'
|
||||
$r1 = Invoke-Agent -Prompt 'Hi, I am Alex. I just want to confirm I can return my tent within 30 days.'
|
||||
$text1 = Get-ResponseText $r1
|
||||
Assert-Contains $text1 'STYLE-CANARY-3318' 'routine question: support-style canary'
|
||||
|
||||
Write-Host '==> Test 2: Escalation trigger -> escalation-policy skill ...'
|
||||
$r2 = Invoke-Agent -Prompt 'I want a $750 refund on Order #A-1042 right now or I am calling my lawyer.'
|
||||
$text2 = Get-ResponseText $r2
|
||||
Assert-Contains $text2 'ESC-CANARY-7742' 'escalation trigger: escalation-policy canary'
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '==> All smoke assertions passed.'
|
||||
}
|
||||
finally {
|
||||
docker rm -f $ContainerName 2>$null | Out-Null
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
---
|
||||
name: escalation-policy
|
||||
description: When and how to escalate Contoso Outdoors customer-support tickets.
|
||||
---
|
||||
|
||||
# Contoso Outdoors Escalation Policy
|
||||
|
||||
You must follow this escalation policy on every conversation.
|
||||
|
||||
## Escalate immediately when the customer
|
||||
|
||||
- Reports an injury, allergic reaction, or other safety incident.
|
||||
- Mentions legal action, regulators, or the press.
|
||||
- Has waited more than 14 days for a refund that was already approved.
|
||||
- Requests a refund larger than $500.
|
||||
|
||||
## How to escalate
|
||||
|
||||
1. Acknowledge the issue in one sentence.
|
||||
2. Tell the customer you are escalating to a senior specialist.
|
||||
3. Provide the escalation reference `ESC-CANARY-7742` and the SLA: a senior
|
||||
specialist will reply within 1 business day.
|
||||
4. Do not promise a specific outcome (refund, replacement, compensation) on
|
||||
escalated tickets — only the senior specialist can commit to one.
|
||||
|
||||
## Do not escalate
|
||||
|
||||
- Routine returns within the standard 30-day window.
|
||||
- Shipping status questions.
|
||||
- Product care and usage questions.
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
---
|
||||
name: support-style
|
||||
description: Contoso Outdoors customer-support tone and formatting guidelines.
|
||||
---
|
||||
|
||||
# Contoso Outdoors Support Style
|
||||
|
||||
You are speaking on behalf of Contoso Outdoors customer support.
|
||||
|
||||
## Voice
|
||||
|
||||
- Warm, concise, and confident — never apologetic in a hand-wringing way.
|
||||
- Use the customer's name when it is known.
|
||||
- Sign every response with `— Contoso Outdoors Support`.
|
||||
|
||||
## Formatting
|
||||
|
||||
- Keep replies to 1–3 short paragraphs unless the customer asks for detail.
|
||||
- Use bullet lists only when enumerating concrete steps or options.
|
||||
- Always reference order numbers as `Order #<id>` (e.g. `Order #A-1042`).
|
||||
|
||||
## Canary
|
||||
|
||||
To prove this skill was loaded, include the token `STYLE-CANARY-3318` on a
|
||||
separate line at the bottom of every response, prefixed with `# `.
|
||||
+3
-3
@@ -27,9 +27,9 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -26,8 +26,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+3
-3
@@ -26,9 +26,9 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+5
-5
@@ -32,11 +32,11 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+4
-4
@@ -27,10 +27,10 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
@@ -20,28 +20,8 @@ internal static class AGUIChatMessageExtensions
|
||||
this IEnumerable<AGUIMessage> aguiMessages,
|
||||
JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
// Coalesce consecutive AGUIAssistantMessages that carry tool_calls into a single
|
||||
// ChatMessage. The AG-UI client (e.g. @ag-ui/client) creates a separate assistant
|
||||
// message per tool call when ToolCallStartEvent.parentMessageId is empty, but
|
||||
// OpenAI's chat-completion API requires every assistant message with tool_calls
|
||||
// to be IMMEDIATELY followed by tool responses for each of its tool_call_ids.
|
||||
// Sending two consecutive single-tool-call assistant messages before any tool
|
||||
// result triggers HTTP 400 "tool_call_ids did not have response messages".
|
||||
List<AIContent>? pendingContents = null;
|
||||
string? pendingId = null;
|
||||
|
||||
foreach (var message in aguiMessages)
|
||||
{
|
||||
bool isAssistantWithToolCalls =
|
||||
message is AGUIAssistantMessage am && am.ToolCalls is { Length: > 0 };
|
||||
|
||||
if (pendingContents is not null && !isAssistantWithToolCalls)
|
||||
{
|
||||
yield return new ChatMessage(ChatRole.Assistant, pendingContents) { MessageId = pendingId };
|
||||
pendingContents = null;
|
||||
pendingId = null;
|
||||
}
|
||||
|
||||
var role = MapChatRole(message.Role);
|
||||
|
||||
switch (message)
|
||||
@@ -104,14 +84,14 @@ internal static class AGUIChatMessageExtensions
|
||||
|
||||
case AGUIAssistantMessage assistantMessage when assistantMessage.ToolCalls is { Length: > 0 }:
|
||||
{
|
||||
pendingContents ??= new List<AIContent>();
|
||||
pendingId ??= message.Id;
|
||||
var contents = new List<AIContent>();
|
||||
|
||||
if (!string.IsNullOrEmpty(assistantMessage.Content))
|
||||
{
|
||||
pendingContents.Add(new TextContent(assistantMessage.Content));
|
||||
contents.Add(new TextContent(assistantMessage.Content));
|
||||
}
|
||||
|
||||
// Add tool calls
|
||||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||||
{
|
||||
Dictionary<string, object?>? arguments = null;
|
||||
@@ -122,12 +102,16 @@ internal static class AGUIChatMessageExtensions
|
||||
jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>)));
|
||||
}
|
||||
|
||||
pendingContents.Add(new FunctionCallContent(
|
||||
contents.Add(new FunctionCallContent(
|
||||
toolCall.Id,
|
||||
toolCall.Function.Name,
|
||||
arguments));
|
||||
}
|
||||
|
||||
yield return new ChatMessage(role, contents)
|
||||
{
|
||||
MessageId = message.Id
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -150,12 +134,6 @@ internal static class AGUIChatMessageExtensions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining pending assistant-tool-call entry at end of stream.
|
||||
if (pendingContents is not null)
|
||||
{
|
||||
yield return new ChatMessage(ChatRole.Assistant, pendingContents) { MessageId = pendingId };
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<AGUIMessage> AsAGUIMessages(
|
||||
|
||||
@@ -448,36 +448,24 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
};
|
||||
|
||||
string? currentMessageId = null;
|
||||
string? textStreamingFallback = null;
|
||||
bool textInFallback = false;
|
||||
string? streamingMessageId = null;
|
||||
string? currentReasoningBaseId = null;
|
||||
string? currentReasoningId = null;
|
||||
string? currentReasoningMessageId = null;
|
||||
await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// The text-event surface (TextMessageStart/Content/End) requires a non-empty
|
||||
// MessageId to be valid AGUI. Generate a fallback scoped to a contiguous run of
|
||||
// null/empty-MessageId chunks (one logical text message). Leave the raw
|
||||
// chatResponse.MessageId untouched so the tool-call surface below uses the raw
|
||||
// provider value — collapsing parallel tool calls under a synthetic shared parent
|
||||
// would make the FE render them as one assistant-message bubble instead of
|
||||
// distinct rows.
|
||||
string? textMessageId = chatResponse.MessageId;
|
||||
if (string.IsNullOrWhiteSpace(textMessageId))
|
||||
// Generate a fallback MessageId when the provider doesn't supply one.
|
||||
// This ensures all AGUI events have a valid messageId regardless of agent type.
|
||||
if (string.IsNullOrWhiteSpace(chatResponse.MessageId))
|
||||
{
|
||||
textStreamingFallback ??= Guid.NewGuid().ToString("N");
|
||||
textMessageId = textStreamingFallback;
|
||||
textInFallback = true;
|
||||
}
|
||||
else if (textInFallback)
|
||||
{
|
||||
textStreamingFallback = null;
|
||||
textInFallback = false;
|
||||
chatResponse.MessageId = ContainsToolResult(chatResponse)
|
||||
? Guid.NewGuid().ToString("N")
|
||||
: (streamingMessageId ??= Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
|
||||
if (chatResponse is { Contents.Count: > 0 } &&
|
||||
chatResponse.Contents[0] is TextContent &&
|
||||
!string.Equals(currentMessageId, textMessageId, StringComparison.Ordinal))
|
||||
!string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal))
|
||||
{
|
||||
// Close any open reasoning block before opening a text message, so AG-UI
|
||||
// events are properly bracketed. MEAI providers share one MessageId across
|
||||
@@ -510,11 +498,11 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
// Start the new message
|
||||
yield return new TextMessageStartEvent
|
||||
{
|
||||
MessageId = textMessageId!,
|
||||
MessageId = chatResponse.MessageId!,
|
||||
Role = chatResponse.Role!.Value.Value
|
||||
};
|
||||
|
||||
currentMessageId = textMessageId;
|
||||
currentMessageId = chatResponse.MessageId;
|
||||
}
|
||||
|
||||
// Emit text content if present
|
||||
@@ -589,15 +577,9 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
currentReasoningMessageId = null;
|
||||
}
|
||||
|
||||
// Each tool result is a distinct tool-role message on the AGUI wire.
|
||||
// MEAI's FunctionInvokingChatClient shares one synthetic MessageId
|
||||
// across all FunctionResultContent items, but the FE keys messages
|
||||
// by id, so emitting them with the same id collapses them in React
|
||||
// reconciliation. Derive a unique, deterministic per-result id from
|
||||
// the (LLM-assigned) call id.
|
||||
yield return new ToolCallResultEvent
|
||||
{
|
||||
MessageId = $"result-{functionResultContent.CallId}",
|
||||
MessageId = chatResponse.MessageId,
|
||||
ToolCallId = functionResultContent.CallId,
|
||||
Content = SerializeResultContent(functionResultContent, jsonSerializerOptions) ?? "",
|
||||
Role = AGUIRoles.Tool
|
||||
@@ -692,7 +674,7 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
// Text content event
|
||||
yield return new TextMessageContentEvent
|
||||
{
|
||||
MessageId = textMessageId!,
|
||||
MessageId = chatResponse.MessageId!,
|
||||
#if !NET
|
||||
Delta = Encoding.UTF8.GetString(dataContent.Data.ToArray())
|
||||
#else
|
||||
@@ -744,4 +726,17 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
_ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ContainsToolResult(ChatResponseUpdate chatResponse)
|
||||
{
|
||||
foreach (AIContent content in chatResponse.Contents)
|
||||
{
|
||||
if (content is FunctionResultContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
## v1.0.0-preview.260219.1
|
||||
|
||||
- [BREAKING] Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806))
|
||||
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays #3803
|
||||
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803))
|
||||
- Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973))
|
||||
|
||||
## v1.0.0-preview.260212.1
|
||||
|
||||
@@ -22,11 +22,6 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
|
||||
public static string End(string id) => $"{id}_{nameof(End)}";
|
||||
}
|
||||
|
||||
// State keys for checkpoint persistence of iteration progress.
|
||||
private const string IndexStateKey = nameof(_index);
|
||||
private const string ValuesStateKey = nameof(_values);
|
||||
private const string HasValueStateKey = nameof(HasValue);
|
||||
|
||||
private int _index;
|
||||
private FormulaValue[] _values;
|
||||
|
||||
@@ -98,45 +93,4 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
|
||||
await context.QueueStateResetAsync(this.Model.Index, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Persists the iteration cursor (<see cref="_index"/>), the materialized item snapshot
|
||||
/// (<see cref="_values"/> as <see cref="PortableValue"/>[]), and <see cref="HasValue"/> so a
|
||||
/// foreach loop can resume mid-iteration after a checkpoint (e.g. when a <c>Question</c>
|
||||
/// inside the loop body pauses the workflow and the executor is re-instantiated on resume).
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
PortableValue[] portableValues = [.. this._values.Select(value => new PortableValue(value.AsPortable()))];
|
||||
|
||||
await context.QueueStateUpdateAsync(IndexStateKey, this._index, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await context.QueueStateUpdateAsync(ValuesStateKey, portableValues, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await context.QueueStateUpdateAsync(HasValueStateKey, this.HasValue, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Restores the iteration cursor, item snapshot, and <see cref="HasValue"/> recorded by
|
||||
/// <see cref="OnCheckpointingAsync"/>. The presence of the values snapshot is the source of
|
||||
/// truth for "this foreach was previously checkpointed"; if it is absent the executor keeps
|
||||
/// its constructor defaults (fresh-start semantics).
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
PortableValue[]? savedValues =
|
||||
await context.ReadStateAsync<PortableValue[]>(ValuesStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (savedValues is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._values = [.. savedValues.Select(value => value.ToFormula())];
|
||||
this._index = await context.ReadStateAsync<int>(IndexStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
this.HasValue = await context.ReadStateAsync<bool>(HasValueStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,27 +43,6 @@
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -127,27 +106,6 @@
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -211,27 +169,6 @@
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -295,27 +232,6 @@
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -379,27 +295,6 @@
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -421,13 +316,6 @@
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
@@ -435,13 +323,6 @@
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
@@ -449,13 +330,6 @@
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
@@ -463,13 +337,6 @@
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
@@ -477,13 +344,6 @@
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
|
||||
@@ -29,8 +29,8 @@ namespace Microsoft.Agents.AI;
|
||||
/// <para>
|
||||
/// This provider exposes the following tools to the agent:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>mode_set</c> — Switch the agent's operating mode.</description></item>
|
||||
/// <item><description><c>mode_get</c> — Retrieve the agent's current operating mode.</description></item>
|
||||
/// <item><description><c>AgentMode_Set</c> — Switch the agent's operating mode.</description></item>
|
||||
/// <item><description><c>AgentMode_Get</c> — Retrieve the agent's current operating mode.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -49,8 +49,8 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
- You must check the current mode after any user input, since the user may have changed the mode themselves,
|
||||
e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution.
|
||||
|
||||
Use the mode_get tool to check your current operating mode.
|
||||
Use the mode_set tool to switch between modes as your work progresses. Only use mode_set if the user explicitly instructs/allows you to change modes.
|
||||
Use the AgentMode_Get tool to check your current operating mode.
|
||||
Use the AgentMode_Set tool to switch between modes as your work progresses. Only use AgentMode_Set if the user explicitly instructs/allows you to change modes.
|
||||
|
||||
You are currently operating in the {current_mode} mode.
|
||||
|
||||
@@ -79,7 +79,7 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user.
|
||||
5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes.
|
||||
6. Present the plan to the user and ask for approval to switch to execute mode and process the plan.
|
||||
7. When approval is granted, always switch to execute mode (using the `mode_set` tool), and follow the steps for *Execute mode*.
|
||||
7. When approval is granted, always switch to execute mode (using the `AgentMode_Set` tool), and follow the steps for *Execute mode*.
|
||||
"""),
|
||||
new(
|
||||
"execute",
|
||||
@@ -263,7 +263,7 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "mode_set",
|
||||
Name = "AgentMode_Set",
|
||||
Description = $"Switch the agent's operating mode. Supported modes: \"{this._modeNamesDisplay}\".",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -272,7 +272,7 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
() => state.CurrentMode,
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "mode_get",
|
||||
Name = "AgentMode_Get",
|
||||
Description = "Get the agent's current operating mode.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -35,44 +34,29 @@ public abstract class AgentSkill
|
||||
/// <summary>
|
||||
/// Gets the full skill content.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// <remarks>
|
||||
/// 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>
|
||||
public abstract ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default);
|
||||
/// </remarks>
|
||||
public abstract string Content { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a resource owned by this skill by name.
|
||||
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <param name="name">The resource name (e.g. an identifier or a relative path referenced inside the skill content).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The <see cref="AgentSkillResource"/>, or <see langword="null"/> when no resource with the given name exists.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The default implementation returns <see langword="null"/>. Override in derived classes that
|
||||
/// expose resources.
|
||||
/// The default implementation returns <see langword="null"/>.
|
||||
/// Override this property in derived classes to provide skill-specific resources.
|
||||
/// </remarks>
|
||||
public virtual ValueTask<AgentSkillResource?> GetResourceAsync(
|
||||
string name,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
public virtual IReadOnlyList<AgentSkillResource>? Resources => null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a script owned by this skill by name.
|
||||
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The <see cref="AgentSkillScript"/>, or <see langword="null"/> when no script with the given name exists.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The default implementation returns <see langword="null"/>. Override in derived classes that
|
||||
/// expose scripts.
|
||||
/// The default implementation returns <see langword="null"/>.
|
||||
/// Override this property in derived classes to provide skill-specific scripts.
|
||||
/// </remarks>
|
||||
public virtual ValueTask<AgentSkillScript?> GetScriptAsync(
|
||||
string name,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
public virtual IReadOnlyList<AgentSkillScript>? Scripts => null;
|
||||
}
|
||||
|
||||
@@ -186,10 +186,13 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
bool hasScripts = skills.Any(s => s.Scripts is { Count: > 0 });
|
||||
bool hasResources = skills.Any(s => s.Resources is { Count: > 0 });
|
||||
|
||||
return new AIContext
|
||||
{
|
||||
Instructions = this.BuildSkillsInstructions(skills),
|
||||
Tools = this.BuildTools(skills),
|
||||
Instructions = this.BuildSkillsInstructions(skills, includeScriptInstructions: hasScripts, hasResources),
|
||||
Tools = this.BuildTools(skills, hasScripts, hasResources),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,20 +219,29 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
}
|
||||
}
|
||||
|
||||
private IList<AIFunction> BuildTools(IList<AgentSkill> skills)
|
||||
private IList<AIFunction> BuildTools(IList<AgentSkill> skills, bool hasScripts, bool hasResources)
|
||||
{
|
||||
IList<AIFunction> tools =
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
(string skillName, CancellationToken cancellationToken) => this.LoadSkillAsync(skills, skillName, cancellationToken),
|
||||
(string skillName) => this.LoadSkill(skills, skillName),
|
||||
name: "load_skill",
|
||||
description: "Loads the full content of a specific skill"),
|
||||
AIFunctionFactory.Create(
|
||||
];
|
||||
|
||||
if (hasResources)
|
||||
{
|
||||
tools.Add(AIFunctionFactory.Create(
|
||||
(string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) =>
|
||||
this.ReadSkillResourceAsync(skills, skillName, resourceName, serviceProvider, cancellationToken),
|
||||
name: "read_skill_resource",
|
||||
description: "Reads a resource associated with a skill, such as references, assets, or dynamic data."),
|
||||
];
|
||||
description: "Reads a resource associated with a skill, such as references, assets, or dynamic data."));
|
||||
}
|
||||
|
||||
if (!hasScripts)
|
||||
{
|
||||
return tools;
|
||||
}
|
||||
|
||||
AIFunction scriptFunction = AIFunctionFactory.Create(
|
||||
(string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) =>
|
||||
@@ -245,7 +257,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
return [.. tools, scriptFunction];
|
||||
}
|
||||
|
||||
private string? BuildSkillsInstructions(IList<AgentSkill> skills)
|
||||
private string? BuildSkillsInstructions(IList<AgentSkill> skills, bool includeScriptInstructions, bool includeResourceInstructions)
|
||||
{
|
||||
string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
|
||||
|
||||
@@ -258,29 +270,32 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
sb.AppendLine(" </skill>");
|
||||
}
|
||||
|
||||
const string ResourceInstruction =
|
||||
"""
|
||||
string resourceInstruction = includeResourceInstructions
|
||||
? """
|
||||
- Use `read_skill_resource` to read any referenced resources, using the name exactly as listed
|
||||
(e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`).
|
||||
""";
|
||||
"""
|
||||
: string.Empty;
|
||||
|
||||
const string ScriptInstruction = "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed.";
|
||||
string scriptInstruction = includeScriptInstructions
|
||||
? "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed."
|
||||
: string.Empty;
|
||||
|
||||
return new StringBuilder(promptTemplate)
|
||||
.Replace(SkillsPlaceholder, sb.ToString().TrimEnd())
|
||||
.Replace(ResourceInstructionsPlaceholder, ResourceInstruction)
|
||||
.Replace(ScriptInstructionsPlaceholder, ScriptInstruction)
|
||||
.Replace(ResourceInstructionsPlaceholder, resourceInstruction)
|
||||
.Replace(ScriptInstructionsPlaceholder, scriptInstruction)
|
||||
.ToString();
|
||||
}
|
||||
|
||||
private async Task<string> LoadSkillAsync(IList<AgentSkill> skills, string skillName, CancellationToken cancellationToken)
|
||||
private string LoadSkill(IList<AgentSkill> skills, string skillName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skillName))
|
||||
{
|
||||
return "Error: Skill name cannot be empty.";
|
||||
}
|
||||
|
||||
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
if (skill == null)
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
@@ -288,7 +303,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
|
||||
LogSkillLoading(this._logger, skillName);
|
||||
|
||||
return await skill.GetContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
return skill.Content;
|
||||
}
|
||||
|
||||
private async Task<object?> ReadSkillResourceAsync(IList<AgentSkill> skills, string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default)
|
||||
@@ -303,20 +318,20 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
return "Error: Resource name cannot be empty.";
|
||||
}
|
||||
|
||||
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
if (skill == null)
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
}
|
||||
|
||||
var resource = skill.Resources?.FirstOrDefault(resource => resource.Name == resourceName);
|
||||
if (resource is null)
|
||||
{
|
||||
return $"Error: Resource '{resourceName}' not found in skill '{skillName}'.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var resource = await skill.GetResourceAsync(resourceName, cancellationToken).ConfigureAwait(false);
|
||||
if (resource is null)
|
||||
{
|
||||
return $"Error: Resource '{resourceName}' not found in skill '{skillName}'.";
|
||||
}
|
||||
|
||||
return await resource.ReadAsync(serviceProvider, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -338,20 +353,20 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
return "Error: Script name cannot be empty.";
|
||||
}
|
||||
|
||||
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
if (skill == null)
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
}
|
||||
|
||||
var script = skill.Scripts?.FirstOrDefault(resource => resource.Name == scriptName);
|
||||
if (script is null)
|
||||
{
|
||||
return $"Error: Script '{scriptName}' not found in skill '{skillName}'.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var script = await skill.GetScriptAsync(scriptName, cancellationToken).ConfigureAwait(false);
|
||||
if (script is null)
|
||||
{
|
||||
return $"Error: Script '{scriptName}' not found in skill '{skillName}'.";
|
||||
}
|
||||
|
||||
return await script.RunAsync(skill, arguments, serviceProvider, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -53,12 +50,11 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
/// block is appended with a per-script entry describing the expected argument format.
|
||||
/// The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
public override string Content
|
||||
{
|
||||
var content = this._content ??= this._scripts is { Count: > 0 }
|
||||
get => this._content ??= this._scripts is { Count: > 0 }
|
||||
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts)
|
||||
: this._originalContent;
|
||||
return new(content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -67,16 +63,8 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
public string Path { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resource = this._resources.FirstOrDefault(r => r.Name == name);
|
||||
return new(resource);
|
||||
}
|
||||
public override IReadOnlyList<AgentSkillResource> Resources => this._resources;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSkillScript?> GetScriptAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var script = this._scripts.FirstOrDefault(s => s.Name == name);
|
||||
return new(script);
|
||||
}
|
||||
public override IReadOnlyList<AgentSkillScript> Scripts => this._scripts;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
@@ -36,9 +34,9 @@ namespace Microsoft.Agents.AI;
|
||||
/// discovered via reflection on <typeparamref name="TSelf"/>. This approach is compatible with Native AOT.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Explicit override:</b> Override <see cref="Resources"/> and <see cref="Scripts"/>, using <see cref="CreateResource(string, object, string?)"/>,
|
||||
/// <see cref="CreateResource(string, Delegate, string?, JsonSerializerOptions?)"/>, and <see cref="CreateScript"/> to define
|
||||
/// inline resources and scripts. This approach is also compatible with Native AOT.
|
||||
/// <b>Explicit override:</b> Override <see cref="AgentSkill.Resources"/> and <see cref="AgentSkill.Scripts"/>, using
|
||||
/// <see cref="CreateResource(string, object, string?)"/>, <see cref="CreateResource(string, Delegate, string?, JsonSerializerOptions?)"/>,
|
||||
/// and <see cref="CreateScript"/> to define inline resources and scripts. This approach is also compatible with Native AOT.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
@@ -99,24 +97,11 @@ public abstract class AgentClassSkill<
|
||||
{
|
||||
private const BindingFlags DiscoveryBindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
||||
|
||||
private readonly Lazy<IReadOnlyList<AgentSkillResource>?> _resources;
|
||||
private readonly Lazy<IReadOnlyList<AgentSkillScript>?> _scripts;
|
||||
private readonly Lazy<string> _content;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentClassSkill{TSelf}"/> class.
|
||||
/// </summary>
|
||||
protected AgentClassSkill()
|
||||
{
|
||||
this._resources = new Lazy<IReadOnlyList<AgentSkillResource>?>(this.DiscoverResources);
|
||||
this._scripts = new Lazy<IReadOnlyList<AgentSkillScript>?>(this.DiscoverScripts);
|
||||
this._content = new Lazy<string>(() => AgentInlineSkillContentBuilder.Build(
|
||||
this.Frontmatter.Name,
|
||||
this.Frontmatter.Description,
|
||||
this.Instructions,
|
||||
this.Resources,
|
||||
this.Scripts));
|
||||
}
|
||||
private string? _content;
|
||||
private bool _resourcesDiscovered;
|
||||
private bool _scriptsDiscovered;
|
||||
private IReadOnlyList<AgentSkillResource>? _reflectedResources;
|
||||
private IReadOnlyList<AgentSkillScript>? _reflectedScripts;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw instructions text for this skill.
|
||||
@@ -141,44 +126,53 @@ public abstract class AgentClassSkill<
|
||||
/// Returns a synthesized XML document containing name, description, instructions, resources, and scripts.
|
||||
/// The result is cached after the first access. Override to provide custom content.
|
||||
/// </remarks>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default) => new(this._content.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation returns resources discovered via reflection by scanning
|
||||
/// <typeparamref name="TSelf"/> for members annotated with <see cref="AgentSkillResourceAttribute"/>.
|
||||
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// Override this property in derived classes to provide skill-specific resources.
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillResource>? Resources => this._resources.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation returns scripts discovered via reflection by scanning
|
||||
/// <typeparamref name="TSelf"/> for methods annotated with <see cref="AgentSkillScriptAttribute"/>.
|
||||
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// Override this property in derived classes to provide skill-specific scripts.
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillScript>? Scripts => this._scripts.Value;
|
||||
public override string Content => this._content ??= AgentInlineSkillContentBuilder.Build(
|
||||
this.Frontmatter.Name,
|
||||
this.Frontmatter.Description,
|
||||
this.Instructions,
|
||||
this.Resources,
|
||||
this.Scripts);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public sealed override ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
|
||||
/// <remarks>
|
||||
/// Returns resources discovered via reflection by scanning <typeparamref name="TSelf"/> for
|
||||
/// members annotated with <see cref="AgentSkillResourceAttribute"/>. This discovery is
|
||||
/// compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources
|
||||
{
|
||||
var resource = this.Resources?.FirstOrDefault(r => r.Name == name);
|
||||
return new(resource);
|
||||
get
|
||||
{
|
||||
if (!this._resourcesDiscovered)
|
||||
{
|
||||
this._reflectedResources = this.DiscoverResources();
|
||||
this._resourcesDiscovered = true;
|
||||
}
|
||||
|
||||
return this._reflectedResources;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public sealed override ValueTask<AgentSkillScript?> GetScriptAsync(string name, CancellationToken cancellationToken = default)
|
||||
/// <remarks>
|
||||
/// Returns scripts discovered via reflection by scanning <typeparamref name="TSelf"/> for
|
||||
/// methods annotated with <see cref="AgentSkillScriptAttribute"/>. This discovery is
|
||||
/// compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts
|
||||
{
|
||||
var script = this.Scripts?.FirstOrDefault(s => s.Name == name);
|
||||
return new(script);
|
||||
get
|
||||
{
|
||||
if (!this._scriptsDiscovered)
|
||||
{
|
||||
this._reflectedScripts = this.DiscoverScripts();
|
||||
this._scriptsDiscovered = true;
|
||||
}
|
||||
|
||||
return this._reflectedScripts;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
@@ -19,9 +16,9 @@ namespace Microsoft.Agents.AI;
|
||||
/// <remarks>
|
||||
/// All calls to <see cref="AddResource(string, object, string?)"/>,
|
||||
/// <see cref="AddResource(string, Delegate, string?, JsonSerializerOptions?)"/>, and <see cref="AddScript"/>
|
||||
/// must be made before the skill's <see cref="GetContentAsync"/> is first called.
|
||||
/// must be made before the skill's <see cref="Content"/> is first accessed.
|
||||
/// Calls made after that point will not be reflected in the generated
|
||||
/// content. In typical usage, this means configuring all
|
||||
/// <see cref="Content"/>. In typical usage, this means configuring all
|
||||
/// resources and scripts before registering the skill with an
|
||||
/// <see cref="AgentSkillsProvider"/> or <see cref="AgentSkillsProviderBuilder"/>.
|
||||
/// </remarks>
|
||||
@@ -93,24 +90,13 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts));
|
||||
}
|
||||
public override string Content => this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resource = this._resources?.FirstOrDefault(r => r.Name == name);
|
||||
return new(resource);
|
||||
}
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSkillScript?> GetScriptAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var script = this._scripts?.FirstOrDefault(s => s.Name == name);
|
||||
return new(script);
|
||||
}
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a static resource with this skill.
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This attribute is compatible with Native AOT when used with <see cref="AgentClassSkill{TSelf}"/>.
|
||||
/// Alternatively, override <see cref="AgentClassSkill{TSelf}.Resources"/> and use
|
||||
/// Alternatively, override the <see cref="AgentSkill.Resources"/> property and use
|
||||
/// <see cref="AgentClassSkill{TSelf}.CreateResource(string, object, string?)"/> instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This attribute is compatible with Native AOT when used with <see cref="AgentClassSkill{TSelf}"/>.
|
||||
/// Alternatively, override <see cref="AgentClassSkill{TSelf}.Scripts"/> and use
|
||||
/// Alternatively, override the <see cref="AgentSkill.Scripts"/> property and use
|
||||
/// <see cref="AgentClassSkill{TSelf}.CreateScript"/> instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
|
||||
-2
@@ -27,13 +27,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.Search.Documents" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
|
||||
@@ -38,7 +38,6 @@ AIAgent agent = scenario switch
|
||||
"memory" => await CreateMemoryAgentAsync(projectClient, deployment).ConfigureAwait(false),
|
||||
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
|
||||
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
|
||||
"agent-skills" => CreateAgentSkillsAgent(projectClient, deployment),
|
||||
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
|
||||
};
|
||||
|
||||
@@ -210,77 +209,6 @@ static async Task<AIAgent> CreateMemoryAgentAsync(AIProjectClient client, string
|
||||
});
|
||||
}
|
||||
|
||||
// Agent skills scenario. Uses AgentSkillsProvider with two bundled Contoso Outdoors skills
|
||||
// (support-style + escalation-policy). Skills are loaded from embedded SKILL.md files on disk,
|
||||
// simulating the download-from-Foundry pattern used by the Hosted-AgentSkills sample. When the
|
||||
// container starts, it writes the skills to a temp directory and wires AgentSkillsProvider over it.
|
||||
#pragma warning disable MEAI001 // AgentSkillsProvider is experimental
|
||||
static AIAgent CreateAgentSkillsAgent(AIProjectClient client, string deployment)
|
||||
{
|
||||
string skillsDir = Path.Combine(Path.GetTempPath(), "it-agent-skills-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Path.Combine(skillsDir, "support-style"));
|
||||
Directory.CreateDirectory(Path.Combine(skillsDir, "escalation-policy"));
|
||||
|
||||
File.WriteAllText(Path.Combine(skillsDir, "support-style", "SKILL.md"),
|
||||
"""
|
||||
---
|
||||
name: support-style
|
||||
description: Contoso Outdoors customer-support tone and formatting guidelines.
|
||||
---
|
||||
|
||||
# Contoso Outdoors Support Style
|
||||
|
||||
You are speaking on behalf of Contoso Outdoors customer support.
|
||||
|
||||
## Voice
|
||||
|
||||
- Warm, concise, and confident.
|
||||
- Use the customer's name when known.
|
||||
- Sign every response with `— Contoso Outdoors Support`.
|
||||
|
||||
## Canary
|
||||
|
||||
To prove this skill was loaded, include the token `STYLE-CANARY-3318` on a
|
||||
separate line at the bottom of every response, prefixed with `# `.
|
||||
""");
|
||||
|
||||
File.WriteAllText(Path.Combine(skillsDir, "escalation-policy", "SKILL.md"),
|
||||
"""
|
||||
---
|
||||
name: escalation-policy
|
||||
description: When and how to escalate Contoso Outdoors customer-support tickets.
|
||||
---
|
||||
|
||||
# Contoso Outdoors Escalation Policy
|
||||
|
||||
## Escalate immediately when the customer
|
||||
|
||||
- Reports an injury or safety incident.
|
||||
- Mentions legal action, regulators, or the press.
|
||||
- Requests a refund larger than $500.
|
||||
|
||||
## How to escalate
|
||||
|
||||
1. Acknowledge the issue.
|
||||
2. Tell the customer you are escalating to a senior specialist.
|
||||
3. Provide the escalation reference `ESC-CANARY-7742`.
|
||||
""");
|
||||
|
||||
var skillsProvider = new AgentSkillsProvider(skillsDir, scriptRunner: null);
|
||||
|
||||
return client.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "agent-skills-agent",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deployment,
|
||||
Instructions = "You are a customer-support assistant for Contoso Outdoors.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider]
|
||||
});
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
|
||||
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that exercise the Agent Skills pattern in a hosted agent container.
|
||||
/// The container uses <see cref="Microsoft.Agents.AI.AgentSkillsProvider"/> with two
|
||||
/// Contoso Outdoors skills (support-style, escalation-policy) to verify the progressive
|
||||
/// disclosure flow: skills are advertised in the system prompt and loaded on demand via
|
||||
/// the <c>load_skill</c> tool when the model decides they are relevant.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class AgentSkillsHostedAgentTests(AgentSkillsHostedAgentFixture fixture) : IClassFixture<AgentSkillsHostedAgentFixture>
|
||||
{
|
||||
private readonly AgentSkillsHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task RoutineQuestion_LoadsSupportStyleSkillAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act — ask a routine support question that should trigger the support-style skill
|
||||
var response = await agent.RunAsync(
|
||||
"Hi, I am Alex. I just want to confirm I can return my tent within 30 days.");
|
||||
|
||||
// Assert — response should contain the canary token proving the skill was loaded
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("STYLE-CANARY-3318", response.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task EscalationTrigger_LoadsEscalationPolicySkillAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act — trigger an escalation (legal threat + refund > $500)
|
||||
var response = await agent.RunAsync(
|
||||
"I want a $750 refund on Order #A-1042 right now or I am calling my lawyer.");
|
||||
|
||||
// Assert — response should contain the escalation canary token
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("ESC-CANARY-7742", response.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task SkillsAreAdvertised_LoadSkillToolIsAvailableAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act — ask the model what skills are available (triggers system prompt inspection)
|
||||
var response = await agent.RunAsync(
|
||||
"List the skills you have access to. Just give me their names.");
|
||||
|
||||
// Assert — both skills should be mentioned (they are advertised in the system prompt)
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("support-style", response.Text);
|
||||
Assert.Contains("escalation-policy", response.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task LoadSkill_InvokesToolAndReturnsContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act — ask a question that should load a specific skill
|
||||
var response = await agent.RunAsync(
|
||||
"I need to know the escalation policy for customer tickets. Load the escalation-policy skill and tell me the rules.");
|
||||
|
||||
// Assert — the response should reference the load_skill tool invocation
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.True(
|
||||
response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any(fc => fc.Name == "load_skill")),
|
||||
"Expected at least one load_skill FunctionCallContent in the response messages.");
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=agent-skills</c> mode.
|
||||
/// The container creates two Contoso Outdoors skills (support-style, escalation-policy) on disk
|
||||
/// and wires them into <see cref="Microsoft.Agents.AI.AgentSkillsProvider"/> so the model can
|
||||
/// discover and load skills via the progressive disclosure pattern.
|
||||
/// </summary>
|
||||
public sealed class AgentSkillsHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "agent-skills";
|
||||
}
|
||||
@@ -199,7 +199,6 @@ human-only operation; CI only adds and deletes versions under existing agents.
|
||||
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
|
||||
| `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. |
|
||||
| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. |
|
||||
| `AgentSkillsHostedAgentFixture` | `agent-skills` | `it-agent-skills` | Agent skills via `AgentSkillsProvider`: advertises two Contoso Outdoors skills (support-style, escalation-policy) in the system prompt, loads them on demand via `load_skill`, verifies canary tokens prove the skill was loaded. |
|
||||
|
||||
The placeholder scenarios will be wired up in the test container `Program.cs` once the
|
||||
relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize.
|
||||
|
||||
@@ -47,8 +47,7 @@ $Scenarios = @(
|
||||
'custom-storage',
|
||||
'memory',
|
||||
'azure-search-rag',
|
||||
'session-files',
|
||||
'agent-skills'
|
||||
'session-files'
|
||||
)
|
||||
|
||||
# Resolve project ARM scope from the endpoint.
|
||||
|
||||
@@ -914,147 +914,4 @@ public sealed class AGUIChatMessageExtensionsTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consecutive Assistant-Tool-Call Coalescing
|
||||
|
||||
/// <summary>
|
||||
/// Bug #3 reproduction: consecutive AGUIAssistantMessages with ToolCalls should
|
||||
/// be coalesced into a single ChatMessage with multiple FunctionCallContent
|
||||
/// entries. Without coalescing, Azure OpenAI rejects the history with HTTP 400.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsChatMessages_ConsecutiveAssistantToolCallMessages_CoalesceIntoOneChatMessage()
|
||||
{
|
||||
// Arrange — 3 consecutive assistant messages with tool calls (no intervening tool msg)
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIUserMessage { Id = "user-1", Content = "Run 3 queries" },
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-1",
|
||||
Content = "",
|
||||
ToolCalls =
|
||||
[
|
||||
new AGUIToolCall { Id = "call_A", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"1\"}" } }
|
||||
]
|
||||
},
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-2",
|
||||
Content = "",
|
||||
ToolCalls =
|
||||
[
|
||||
new AGUIToolCall { Id = "call_B", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"2\"}" } }
|
||||
]
|
||||
},
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-3",
|
||||
Content = "",
|
||||
ToolCalls =
|
||||
[
|
||||
new AGUIToolCall { Id = "call_C", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"3\"}" } }
|
||||
]
|
||||
},
|
||||
new AGUIToolMessage { Id = "tool-1", ToolCallId = "call_A", Content = "\"result1\"" },
|
||||
new AGUIToolMessage { Id = "tool-2", ToolCallId = "call_B", Content = "\"result2\"" },
|
||||
new AGUIToolMessage { Id = "tool-3", ToolCallId = "call_C", Content = "\"result3\"" },
|
||||
new AGUIUserMessage { Id = "user-2", Content = "Run it again" },
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert — the 3 consecutive assistant-tool-call messages should coalesce into 1
|
||||
List<ChatMessage> assistantWithToolCalls = chatMessages
|
||||
.Where(m => m.Role == ChatRole.Assistant && m.Contents.OfType<FunctionCallContent>().Any())
|
||||
.ToList();
|
||||
|
||||
Assert.Single(assistantWithToolCalls);
|
||||
|
||||
// The single coalesced message should contain all 3 FunctionCallContent entries
|
||||
List<FunctionCallContent> functionCalls = assistantWithToolCalls[0].Contents
|
||||
.OfType<FunctionCallContent>().ToList();
|
||||
Assert.Equal(3, functionCalls.Count);
|
||||
Assert.Equal("call_A", functionCalls[0].CallId);
|
||||
Assert.Equal("call_B", functionCalls[1].CallId);
|
||||
Assert.Equal("call_C", functionCalls[2].CallId);
|
||||
|
||||
// MessageId should be from the first message in the coalesced group
|
||||
Assert.Equal("asst-1", assistantWithToolCalls[0].MessageId);
|
||||
|
||||
// Total messages: user + coalesced assistant + 3 tools + user = 6
|
||||
Assert.Equal(6, chatMessages.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single assistant message with tool calls (not consecutive) should still
|
||||
/// produce one ChatMessage — no behavior change from coalescing logic.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsChatMessages_SingleAssistantToolCallMessage_ProducesOneChatMessage()
|
||||
{
|
||||
// Arrange
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-1",
|
||||
Content = "Here are the results",
|
||||
ToolCalls =
|
||||
[
|
||||
new AGUIToolCall { Id = "call_A", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{}" } },
|
||||
new AGUIToolCall { Id = "call_B", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{}" } },
|
||||
]
|
||||
},
|
||||
new AGUIToolMessage { Id = "tool-1", ToolCallId = "call_A", Content = "\"r1\"" },
|
||||
new AGUIToolMessage { Id = "tool-2", ToolCallId = "call_B", Content = "\"r2\"" },
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert — single assistant message, not coalesced from multiple
|
||||
Assert.Equal(3, chatMessages.Count);
|
||||
Assert.Equal(ChatRole.Assistant, chatMessages[0].Role);
|
||||
List<FunctionCallContent> calls = chatMessages[0].Contents.OfType<FunctionCallContent>().ToList();
|
||||
Assert.Equal(2, calls.Count);
|
||||
Assert.Equal("asst-1", chatMessages[0].MessageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When consecutive assistant-tool-call messages are at the END of the stream
|
||||
/// (no subsequent non-tool-call message to trigger flush), they should still
|
||||
/// be coalesced and flushed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsChatMessages_ConsecutiveAssistantToolCallsAtEndOfStream_FlushesCorrectly()
|
||||
{
|
||||
// Arrange — stream ends with consecutive assistant tool-call messages
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIUserMessage { Id = "user-1", Content = "Do things" },
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-1",
|
||||
ToolCalls = [new AGUIToolCall { Id = "call_X", Type = "function", Function = new AGUIFunctionCall { Name = "fn", Arguments = "{}" } }]
|
||||
},
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-2",
|
||||
ToolCalls = [new AGUIToolCall { Id = "call_Y", Type = "function", Function = new AGUIFunctionCall { Name = "fn", Arguments = "{}" } }]
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert — should be user + 1 coalesced assistant = 2 messages
|
||||
Assert.Equal(2, chatMessages.Count);
|
||||
Assert.Equal(ChatRole.User, chatMessages[0].Role);
|
||||
Assert.Equal(ChatRole.Assistant, chatMessages[1].Role);
|
||||
Assert.Equal(2, chatMessages[1].Contents.OfType<FunctionCallContent>().Count());
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -109,13 +109,11 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When ChatResponseUpdate has empty string MessageId, the AGUI layer passes
|
||||
/// through the raw provider value for ToolCallStartEvent.ParentMessageId.
|
||||
/// Tool-call chunks should NOT receive the text-event fallback GUID — that
|
||||
/// would collapse parallel tool calls into one assistant message in the FE.
|
||||
/// When ChatResponseUpdate has empty string MessageId, the AGUI layer generates
|
||||
/// a fallback so ToolCallStartEvent.ParentMessageId is valid.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ToolCalls_EmptyMessageId_DoesNotGenerateFallbackParentMessageIdAsync()
|
||||
public async Task ToolCalls_EmptyMessageId_GeneratesFallbackParentMessageIdAsync()
|
||||
{
|
||||
// Arrange - ChatResponseUpdate with a tool call but empty MessageId
|
||||
FunctionCallContent functionCall = new("call_abc123", "GetWeather")
|
||||
@@ -141,14 +139,14 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
aguiEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — ParentMessageId should be empty (raw provider value, no synthetic fallback)
|
||||
// Assert — ParentMessageId should have a generated fallback
|
||||
ToolCallStartEvent? toolCallStart = aguiEvents.OfType<ToolCallStartEvent>().FirstOrDefault();
|
||||
Assert.NotNull(toolCallStart);
|
||||
Assert.Equal("call_abc123", toolCallStart.ToolCallId);
|
||||
Assert.Equal("GetWeather", toolCallStart.ToolCallName);
|
||||
Assert.True(
|
||||
Assert.False(
|
||||
string.IsNullOrEmpty(toolCallStart.ParentMessageId),
|
||||
"ParentMessageId should be empty when provider omits MessageId (raw pass-through)");
|
||||
"ParentMessageId should have a generated fallback for empty provider MessageId");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -185,13 +183,10 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
|
||||
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
|
||||
|
||||
// Tool-call ParentMessageId should NOT leak the text fallback GUID
|
||||
Assert.NotEqual(textStart.MessageId, toolCallStart.ParentMessageId);
|
||||
Assert.Equal(textStart.MessageId, toolCallStart.ParentMessageId);
|
||||
Assert.Equal("call_abc123", toolCallResult.ToolCallId);
|
||||
Assert.False(string.IsNullOrEmpty(toolCallResult.MessageId));
|
||||
Assert.NotEqual(textStart.MessageId, toolCallResult.MessageId);
|
||||
// Result MessageId should be deterministic based on CallId
|
||||
Assert.Equal("result-call_abc123", toolCallResult.MessageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -235,11 +230,10 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
|
||||
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
|
||||
|
||||
// Tool-call ParentMessageId should NOT leak the text fallback GUID
|
||||
Assert.NotEqual(textStarts[0].MessageId, toolCallStart.ParentMessageId);
|
||||
Assert.Equal(textStarts[0].MessageId, toolCallStart.ParentMessageId);
|
||||
Assert.NotEqual(textStarts[0].MessageId, toolCallResult.MessageId);
|
||||
// Result MessageId should be deterministic based on CallId
|
||||
Assert.Equal("result-call_abc123", toolCallResult.MessageId);
|
||||
Assert.Equal(toolCallResult.MessageId, toolText.MessageId);
|
||||
Assert.Equal(textStarts[^1].MessageId, toolCallResult.MessageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -280,86 +274,6 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
Assert.Equal(2, contentEvents.Count);
|
||||
Assert.All(contentEvents, e => Assert.Equal("chatcmpl-abc123", e.MessageId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bug #1 reproduction: parallel tool calls with empty MessageId should NOT all
|
||||
/// share the same synthetic ParentMessageId. Each should pass through the raw
|
||||
/// provider value (empty), allowing the FE to render them as distinct cards.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ParallelToolCalls_EmptyMessageId_DoNotShareParentMessageIdAsync()
|
||||
{
|
||||
// Arrange — 3 parallel tool calls with empty MessageId (real OpenAI behavior)
|
||||
List<ChatResponseUpdate> providerUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Let me run those queries.") { MessageId = "chatcmpl-real" },
|
||||
new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_A", "query") { Arguments = new Dictionary<string, object?> { ["q"] = "1" } }] },
|
||||
new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_B", "query") { Arguments = new Dictionary<string, object?> { ["q"] = "2" } }] },
|
||||
new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_C", "query") { Arguments = new Dictionary<string, object?> { ["q"] = "3" } }] },
|
||||
];
|
||||
|
||||
// Act
|
||||
List<BaseEvent> aguiEvents = [];
|
||||
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
|
||||
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
aguiEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — all 3 tool calls should have empty ParentMessageId (raw provider value),
|
||||
// NOT the text fallback GUID
|
||||
List<ToolCallStartEvent> toolCallStarts = aguiEvents.OfType<ToolCallStartEvent>().ToList();
|
||||
Assert.Equal(3, toolCallStarts.Count);
|
||||
Assert.All(toolCallStarts, tc => Assert.True(string.IsNullOrEmpty(tc.ParentMessageId)));
|
||||
|
||||
// Text events should still have a valid fallback MessageId
|
||||
TextMessageStartEvent textStart = Assert.Single(aguiEvents.OfType<TextMessageStartEvent>());
|
||||
Assert.False(string.IsNullOrEmpty(textStart.MessageId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bug #2 reproduction: tool results batched into one ChatResponseUpdate with a
|
||||
/// shared MEAI MessageId should each get a unique deterministic MessageId.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ToolCallResults_SharedMeaiMessageId_HaveUniqueMessageIdsPerCallAsync()
|
||||
{
|
||||
// Arrange — MEAI batches all FunctionResultContent into one update with shared id
|
||||
List<ChatResponseUpdate> providerUpdates =
|
||||
[
|
||||
new ChatResponseUpdate
|
||||
{
|
||||
Role = ChatRole.Tool,
|
||||
MessageId = "meai-shared-id",
|
||||
Contents =
|
||||
[
|
||||
new FunctionResultContent("call_A", "result1"),
|
||||
new FunctionResultContent("call_B", "result2"),
|
||||
new FunctionResultContent("call_C", "result3"),
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
List<BaseEvent> aguiEvents = [];
|
||||
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
|
||||
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
aguiEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — each result should have a unique MessageId
|
||||
List<ToolCallResultEvent> toolResults = aguiEvents.OfType<ToolCallResultEvent>().ToList();
|
||||
Assert.Equal(3, toolResults.Count);
|
||||
|
||||
string?[] distinctIds = toolResults.Select(r => r.MessageId).Distinct().ToArray();
|
||||
Assert.Equal(3, distinctIds.Length);
|
||||
|
||||
// Verify deterministic format
|
||||
Assert.Equal("result-call_A", toolResults[0].MessageId);
|
||||
Assert.Equal("result-call_B", toolResults[1].MessageId);
|
||||
Assert.Equal("result-call_C", toolResults[2].MessageId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
public sealed class AgentClassSkillTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MinimalClassSkill_HasNullOverrides_AndSynthesizesContentAsync()
|
||||
public void MinimalClassSkill_HasNullOverrides_AndSynthesizesContent()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new MinimalClassSkill();
|
||||
@@ -26,17 +26,18 @@ public sealed class AgentClassSkillTests
|
||||
// Act & Assert — null overrides
|
||||
Assert.Equal("minimal", skill.Frontmatter.Name);
|
||||
Assert.Null(skill.Resources);
|
||||
Assert.Null(skill.Scripts);
|
||||
|
||||
// Act & Assert — synthesized XML content
|
||||
Assert.Contains("<name>minimal</name>", await skill.GetContentAsync());
|
||||
Assert.Contains("<description>A minimal skill.</description>", await skill.GetContentAsync());
|
||||
Assert.Contains("<instructions>", await skill.GetContentAsync());
|
||||
Assert.Contains("Minimal skill body.", await skill.GetContentAsync());
|
||||
Assert.Contains("</instructions>", await skill.GetContentAsync());
|
||||
Assert.Contains("<name>minimal</name>", skill.Content);
|
||||
Assert.Contains("<description>A minimal skill.</description>", skill.Content);
|
||||
Assert.Contains("<instructions>", skill.Content);
|
||||
Assert.Contains("Minimal skill body.", skill.Content);
|
||||
Assert.Contains("</instructions>", skill.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullClassSkill_ReturnsOverriddenLists_AndCachesContentAsync()
|
||||
public void FullClassSkill_ReturnsOverriddenLists_AndCachesContent()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
@@ -49,11 +50,11 @@ public sealed class AgentClassSkillTests
|
||||
Assert.Equal("TestScript", skill.Scripts![0].Name);
|
||||
|
||||
// Act & Assert — Content is cached
|
||||
Assert.Same(await skill.GetContentAsync(), await skill.GetContentAsync());
|
||||
Assert.Same(skill.Content, skill.Content);
|
||||
|
||||
// Act & Assert — Content includes parameter schema from typed script
|
||||
Assert.Contains("parameters_schema", await skill.GetContentAsync());
|
||||
Assert.Contains("value", await skill.GetContentAsync());
|
||||
Assert.Contains("parameters_schema", skill.Content);
|
||||
Assert.Contains("value", skill.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -116,116 +117,6 @@ public sealed class AgentClassSkillTests
|
||||
Assert.Single(scriptOnly.Scripts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_ExistingName_ReturnsResourceAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("test-resource");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resource);
|
||||
Assert.Equal("test-resource", resource!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_NonExistingName_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_NoResources_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new MinimalClassSkill();
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("anything");
|
||||
|
||||
// Assert
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_ExistingName_ReturnsScriptAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("TestScript");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("TestScript", script!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_NonExistingName_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_NoScripts_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new MinimalClassSkill();
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("anything");
|
||||
|
||||
// Assert
|
||||
Assert.Null(script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentAccess_ToReflectedResourcesScriptsAndContent_InvokesDiscoveryOnceAsync()
|
||||
{
|
||||
// Regression test for thread-safety of Lazy<T> initialization in AgentClassSkill<TSelf>.
|
||||
// AttributedFullSkill uses attribute-based discovery (no override), so it exercises
|
||||
// the base class's Lazy<T> fields rather than a subclass's own caching.
|
||||
var skill = new AttributedFullSkill();
|
||||
|
||||
const int Concurrency = 32;
|
||||
var resourcesResults = new IReadOnlyList<AgentSkillResource>?[Concurrency];
|
||||
var scriptsResults = new IReadOnlyList<AgentSkillScript>?[Concurrency];
|
||||
var contentResults = new string[Concurrency];
|
||||
|
||||
// Act — invoke all three accessors concurrently from many threads.
|
||||
await Task.WhenAll(Enumerable.Range(0, Concurrency).Select(i => Task.Run(async () =>
|
||||
{
|
||||
resourcesResults[i] = skill.Resources;
|
||||
scriptsResults[i] = skill.Scripts;
|
||||
contentResults[i] = await skill.GetContentAsync();
|
||||
})));
|
||||
|
||||
// Assert — every thread observed the same cached instances (no torn state).
|
||||
for (int i = 1; i < Concurrency; i++)
|
||||
{
|
||||
Assert.Same(resourcesResults[0], resourcesResults[i]);
|
||||
Assert.Same(scriptsResults[0], scriptsResults[i]);
|
||||
Assert.Same(contentResults[0], contentResults[i]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateScriptAndResource_WithSerializerOptions_HandleCustomTypesAsync()
|
||||
{
|
||||
@@ -260,14 +151,17 @@ public sealed class AgentClassSkillTests
|
||||
// Arrange
|
||||
var skill = new AttributedScriptsSkill();
|
||||
|
||||
// Act & Assert — all scripts discovered with correct metadata
|
||||
Assert.NotNull(skill.Scripts);
|
||||
Assert.Equal(4, skill.Scripts!.Count);
|
||||
Assert.Contains(skill.Scripts, s => s.Name == "do-work");
|
||||
Assert.Contains(skill.Scripts, s => s.Name == "DefaultNamed");
|
||||
Assert.Contains(skill.Scripts, s => s.Name == "append");
|
||||
// Act
|
||||
var scripts = skill.Scripts;
|
||||
|
||||
var processScript = skill.Scripts.First(s => s.Name == "process");
|
||||
// Assert — all scripts discovered with correct metadata
|
||||
Assert.NotNull(scripts);
|
||||
Assert.Equal(4, scripts!.Count);
|
||||
Assert.Contains(scripts, s => s.Name == "do-work");
|
||||
Assert.Contains(scripts, s => s.Name == "DefaultNamed");
|
||||
Assert.Contains(scripts, s => s.Name == "append");
|
||||
|
||||
var processScript = scripts.First(s => s.Name == "process");
|
||||
Assert.Equal("Processes the input.", processScript.Description);
|
||||
}
|
||||
|
||||
@@ -378,16 +272,16 @@ public sealed class AgentClassSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttributedFullSkill_IncludesContentWithSchema_AndCachesMembersAsync()
|
||||
public void AttributedFullSkill_IncludesContentWithSchema_AndCachesMembers()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AttributedFullSkill();
|
||||
|
||||
// Act & Assert — Content includes reflected resources and scripts
|
||||
Assert.Contains("<resources>", await skill.GetContentAsync());
|
||||
Assert.Contains("conversion-table", await skill.GetContentAsync());
|
||||
Assert.Contains("<scripts>", await skill.GetContentAsync());
|
||||
Assert.Contains("convert", await skill.GetContentAsync());
|
||||
Assert.Contains("<resources>", skill.Content);
|
||||
Assert.Contains("conversion-table", skill.Content);
|
||||
Assert.Contains("<scripts>", skill.Content);
|
||||
Assert.Contains("convert", skill.Content);
|
||||
|
||||
// Act & Assert — discovered members are cached
|
||||
Assert.Same(skill.Resources, skill.Resources);
|
||||
@@ -405,33 +299,33 @@ public sealed class AgentClassSkillTests
|
||||
// Arrange — skill with no attributes and no overrides; base discovery returns null (not empty list)
|
||||
var skill = new NoAttributesNoOverridesSkill();
|
||||
var baseType = typeof(AgentClassSkill<NoAttributesNoOverridesSkill>);
|
||||
var resourcesField = baseType.GetField("_resources", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var scriptsField = baseType.GetField("_scripts", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var resourcesDiscoveredField = baseType.GetField("_resourcesDiscovered", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var scriptsDiscoveredField = baseType.GetField("_scriptsDiscovered", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var reflectedResourcesField = baseType.GetField("_reflectedResources", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var reflectedScriptsField = baseType.GetField("_reflectedScripts", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(resourcesField);
|
||||
Assert.NotNull(scriptsField);
|
||||
|
||||
var resourcesLazy = (Lazy<IReadOnlyList<AgentSkillResource>?>)resourcesField!.GetValue(skill)!;
|
||||
var scriptsLazy = (Lazy<IReadOnlyList<AgentSkillScript>?>)scriptsField!.GetValue(skill)!;
|
||||
|
||||
Assert.False(resourcesLazy.IsValueCreated);
|
||||
Assert.False(scriptsLazy.IsValueCreated);
|
||||
Assert.NotNull(resourcesDiscoveredField);
|
||||
Assert.NotNull(scriptsDiscoveredField);
|
||||
Assert.NotNull(reflectedResourcesField);
|
||||
Assert.NotNull(reflectedScriptsField);
|
||||
Assert.False((bool)resourcesDiscoveredField!.GetValue(skill)!);
|
||||
Assert.False((bool)scriptsDiscoveredField!.GetValue(skill)!);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Null(skill.Resources);
|
||||
Assert.Null(skill.Scripts);
|
||||
Assert.True(resourcesLazy.IsValueCreated);
|
||||
Assert.True(scriptsLazy.IsValueCreated);
|
||||
Assert.Null(resourcesLazy.Value);
|
||||
Assert.Null(scriptsLazy.Value);
|
||||
Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!);
|
||||
Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!);
|
||||
Assert.Null(reflectedResourcesField!.GetValue(skill));
|
||||
Assert.Null(reflectedScriptsField!.GetValue(skill));
|
||||
|
||||
// Repeated access should not re-trigger discovery even when discovered value is null.
|
||||
Assert.Null(skill.Resources);
|
||||
Assert.Null(skill.Scripts);
|
||||
Assert.True(resourcesLazy.IsValueCreated);
|
||||
Assert.True(scriptsLazy.IsValueCreated);
|
||||
Assert.Null(resourcesLazy.Value);
|
||||
Assert.Null(scriptsLazy.Value);
|
||||
Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!);
|
||||
Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!);
|
||||
Assert.Null(reflectedResourcesField.GetValue(skill));
|
||||
Assert.Null(reflectedScriptsField.GetValue(skill));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -488,7 +382,7 @@ public sealed class AgentClassSkillTests
|
||||
var jso = SkillTestJsonContext.Default.Options;
|
||||
|
||||
// Act & Assert — script with custom JSO
|
||||
var script = skill.Scripts!.First(s => s.Name == "lookup");
|
||||
var script = skill.Scripts![0];
|
||||
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
@@ -504,13 +398,13 @@ public sealed class AgentClassSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesDescription_ForReflectedResourcesAsync()
|
||||
public void Content_IncludesDescription_ForReflectedResources()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AttributedResourcePropertiesSkill();
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert — descriptions from [Description] attribute appear in synthesized content
|
||||
Assert.Contains("Some important data.", content);
|
||||
|
||||
@@ -105,7 +105,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_WithScripts_AppendsPerScriptEntriesAsync()
|
||||
public void Content_WithScripts_AppendsPerScriptEntries()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
@@ -118,7 +118,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
scripts: [script1, script2]);
|
||||
|
||||
// Act
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
var content = fileSkill.Content;
|
||||
|
||||
// Assert — content starts with original and appends per-script entries
|
||||
Assert.StartsWith("Original content", content);
|
||||
@@ -130,7 +130,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_WithoutScripts_ReturnsOriginalContentAsync()
|
||||
public void Content_WithoutScripts_ReturnsOriginalContent()
|
||||
{
|
||||
// Arrange
|
||||
var fileSkill = new AgentFileSkill(
|
||||
@@ -139,14 +139,14 @@ public sealed class AgentFileSkillScriptTests
|
||||
"/skills/my-skill");
|
||||
|
||||
// Act
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
var content = fileSkill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Original content only", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_WithScripts_IsCachedAsync()
|
||||
public void Content_WithScripts_IsCached()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
@@ -158,8 +158,8 @@ public sealed class AgentFileSkillScriptTests
|
||||
scripts: [script]);
|
||||
|
||||
// Act
|
||||
var content1 = await fileSkill.GetContentAsync();
|
||||
var content2 = await fileSkill.GetContentAsync();
|
||||
var content1 = fileSkill.Content;
|
||||
var content2 = fileSkill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Same(content1, content2);
|
||||
@@ -232,7 +232,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_WithScripts_ContainsDefaultParametersSchemaAsync()
|
||||
public void Content_WithScripts_ContainsDefaultParametersSchema()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
@@ -244,7 +244,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
scripts: [script]);
|
||||
|
||||
// Act
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
var content = fileSkill.Content;
|
||||
|
||||
// Assert — the appended block contains the actual default schema from AgentFileSkillScript
|
||||
Assert.Contains("""{"type":"array","items":{"type":"string"}}""", content);
|
||||
|
||||
+25
-25
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -45,9 +46,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
var script = await skill.GetScriptAsync("scripts/convert.py");
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("scripts/convert.py", script!.Name);
|
||||
Assert.NotNull(skill.Scripts);
|
||||
Assert.Single(skill.Scripts!);
|
||||
Assert.Equal("scripts/convert.py", skill.Scripts![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -68,13 +69,14 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
// Assert — verify all expected scripts are discoverable
|
||||
foreach (var name in (string[])["scripts/run.cs", "scripts/run.csx", "scripts/run.js", "scripts/run.ps1", "scripts/run.py", "scripts/run.sh"])
|
||||
{
|
||||
var script = await skills[0].GetScriptAsync(name);
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal(name, script!.Name);
|
||||
}
|
||||
var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
|
||||
Assert.Equal(6, scriptNames.Count);
|
||||
Assert.Contains("scripts/run.cs", scriptNames);
|
||||
Assert.Contains("scripts/run.csx", scriptNames);
|
||||
Assert.Contains("scripts/run.js", scriptNames);
|
||||
Assert.Contains("scripts/run.ps1", scriptNames);
|
||||
Assert.Contains("scripts/run.py", scriptNames);
|
||||
Assert.Contains("scripts/run.sh", scriptNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -92,7 +94,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Null(await skills[0].GetScriptAsync("scripts/data.txt"));
|
||||
Assert.Empty(skills[0].Scripts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -107,7 +109,8 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Null(await skills[0].GetScriptAsync("any-script"));
|
||||
Assert.NotNull(skills[0].Scripts);
|
||||
Assert.Empty(skills[0].Scripts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -125,7 +128,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert — neither file is in the default scripts/ directory, so no scripts are discovered
|
||||
Assert.Single(skills);
|
||||
Assert.Null(await skills[0].GetScriptAsync("convert.py"));
|
||||
Assert.Empty(skills[0].Scripts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -147,7 +150,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
var scriptResult = await (await skills[0].GetScriptAsync("scripts/test.py"))!.RunAsync(skills[0], null, null, CancellationToken.None);
|
||||
var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], null, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(executorCalled);
|
||||
@@ -172,7 +175,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Act — discovery succeeds even without a runner
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
var script = (await skills[0].GetScriptAsync("scripts/run.sh"))!;
|
||||
var script = skills[0].Scripts![0];
|
||||
|
||||
// Assert — running the script throws because no runner was provided
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => script.RunAsync(skills[0], null, null, CancellationToken.None));
|
||||
@@ -192,9 +195,8 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var rbScript = await skills[0].GetScriptAsync("scripts/run.rb");
|
||||
Assert.NotNull(rbScript);
|
||||
Assert.Equal("scripts/run.rb", rbScript!.Name);
|
||||
Assert.Single(skills[0].Scripts!);
|
||||
Assert.Equal("scripts/run.rb", skills[0].Scripts![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -215,7 +217,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
using var argumentsDoc = JsonDocument.Parse("""{"value":26.2,"factor":1.60934}""");
|
||||
var arguments = argumentsDoc.RootElement;
|
||||
await (await skills[0].GetScriptAsync("scripts/test.py"))!.RunAsync(skills[0], arguments, null, CancellationToken.None);
|
||||
await skills[0].Scripts![0].RunAsync(skills[0], arguments, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedArgs);
|
||||
@@ -238,9 +240,8 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert — script file inside the deeply nested directory is discovered
|
||||
Assert.Single(skills);
|
||||
var nestedScript = await skills[0].GetScriptAsync("f1/f2/f3/run.py");
|
||||
Assert.NotNull(nestedScript);
|
||||
Assert.Equal("f1/f2/f3/run.py", nestedScript!.Name);
|
||||
Assert.Single(skills[0].Scripts!);
|
||||
Assert.Equal("f1/f2/f3/run.py", skills[0].Scripts![0].Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -266,12 +267,11 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert — scripts are discovered with names identical to using directories without "./"
|
||||
Assert.Single(skills);
|
||||
Assert.Equal(directories.Length, skills[0].Scripts!.Count);
|
||||
foreach (string directory in directories)
|
||||
{
|
||||
string expectedName = $"{directory.Substring(2)}/run.py";
|
||||
var script = await skills[0].GetScriptAsync(expectedName);
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal(expectedName, script!.Name);
|
||||
Assert.Contains(skills[0].Scripts!, s => s.Name == expectedName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,13 +105,13 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ContainsNameDescriptionAndInstructionsAsync()
|
||||
public void Content_ContainsNameDescriptionAndInstructions()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Do the thing.");
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<name>my-skill</name>", content);
|
||||
@@ -120,13 +120,13 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_EscapesXmlCharactersAsync()
|
||||
public void Content_EscapesXmlCharacters()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "x<y>z\"w & it's more", "1 & 2 < 3");
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<name>my-skill</name>", content);
|
||||
@@ -135,28 +135,28 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IsCachedAcrossAccessesAsync()
|
||||
public void Content_IsCachedAcrossAccesses()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var first = await skill.GetContentAsync();
|
||||
var second = await skill.GetContentAsync();
|
||||
var first = skill.Content;
|
||||
var second = skill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesResourcesAddedBeforeFirstAccessAsync()
|
||||
public void Content_IncludesResourcesAddedBeforeFirstAccess()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("config", "value1", "A config resource.");
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
@@ -164,14 +164,14 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesDelegateResourcesAddedBeforeFirstAccessAsync()
|
||||
public void Content_IncludesDelegateResourcesAddedBeforeFirstAccess()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("dynamic", () => "hello");
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
@@ -179,14 +179,14 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesScriptsAddedBeforeFirstAccessAsync()
|
||||
public void Content_IncludesScriptsAddedBeforeFirstAccess()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("run", () => "result", "Runs something.");
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<scripts>", content);
|
||||
@@ -194,22 +194,22 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IsCachedAndNotRebuiltAsync()
|
||||
public void Content_IsCachedAndNotRebuilt()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("r1", "v1");
|
||||
|
||||
// Act
|
||||
var first = await skill.GetContentAsync();
|
||||
var second = await skill.GetContentAsync();
|
||||
var first = skill.Content;
|
||||
var second = skill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesResourcesAndScriptsAddedBeforeFirstAccessAsync()
|
||||
public void Content_IncludesResourcesAndScriptsAddedBeforeFirstAccess()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -217,7 +217,7 @@ public sealed class AgentInlineSkillTests
|
||||
skill.AddScript("s1", () => "ok");
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
@@ -227,14 +227,14 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ParametersSchema_IsXmlEscapedAsync()
|
||||
public void Content_ParametersSchema_IsXmlEscaped()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("search", (string query, int limit) => $"found {limit} results for {query}");
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert — JSON schema should be present and XML content chars escaped
|
||||
Assert.Contains("parameters_schema", content);
|
||||
@@ -280,103 +280,17 @@ public sealed class AgentInlineSkillTests
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Null(skill.GetTestResources());
|
||||
Assert.Null(skill.Resources);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Scripts_WhenNoneAdded_ReturnsNullAsync()
|
||||
public void Scripts_WhenNoneAdded_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Null(await skill.GetScriptAsync("nonexistent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_ExistingName_ReturnsResourceAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("r1", "v1");
|
||||
skill.AddResource("r2", "v2");
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("r2");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resource);
|
||||
Assert.Equal("r2", resource!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_NonExistingName_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("r1", "v1");
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_NoResourcesAdded_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_ExistingName_ReturnsScriptAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("s1", () => "first");
|
||||
skill.AddScript("s2", () => "second");
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("s2");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("s2", script!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_NonExistingName_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("s1", () => "ok");
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_NoScriptsAdded_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(script);
|
||||
Assert.Null(skill.Scripts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -419,13 +333,13 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTagsAsync()
|
||||
public void Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTags()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
@@ -433,58 +347,58 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ResourcesAddedAfterCaching_AreNotIncludedAsync()
|
||||
public void Content_ResourcesAddedAfterCaching_AreNotIncluded()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
_ = await skill.GetContentAsync(); // trigger caching
|
||||
_ = skill.Content; // trigger caching
|
||||
skill.AddResource("late-resource", "late-value");
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert — the late resource should not appear because content was cached
|
||||
Assert.DoesNotContain("late-resource", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ScriptsAddedAfterCaching_AreNotIncludedAsync()
|
||||
public void Content_ScriptsAddedAfterCaching_AreNotIncluded()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
_ = await skill.GetContentAsync(); // trigger caching
|
||||
_ = skill.Content; // trigger caching
|
||||
skill.AddScript("late-script", () => "late");
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert — the late script should not appear because content was cached
|
||||
Assert.DoesNotContain("late-script", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ScriptWithDescription_IncludesDescriptionAttributeAsync()
|
||||
public void Content_ScriptWithDescription_IncludesDescriptionAttribute()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("my-script", () => "ok", "Runs something.");
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Contains("description=\"Runs something.\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ScriptWithoutParametersOrDescription_UsesSelfClosingTagAsync()
|
||||
public void Content_ScriptWithoutParametersOrDescription_UsesSelfClosingTag()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("simple", () => "ok");
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert — parameterless Action delegates still produce a schema, so this
|
||||
// verifies the script is at least included in the output
|
||||
@@ -492,7 +406,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ResourceWithDescription_IncludesDescriptionAttributeAsync()
|
||||
public void Content_ResourceWithDescription_IncludesDescriptionAttribute()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -500,7 +414,7 @@ public sealed class AgentInlineSkillTests
|
||||
skill.AddResource("no-desc", "value");
|
||||
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Contains("description=\"A described resource.\"", content);
|
||||
@@ -523,7 +437,7 @@ public sealed class AgentInlineSkillTests
|
||||
var args = argsDoc.RootElement;
|
||||
|
||||
// Act
|
||||
var result = await (await skill.GetScriptAsync("lookup"))!.RunAsync(skill, args, null, CancellationToken.None);
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert — the custom input was deserialized via skill-level JSO and response was produced
|
||||
Assert.NotNull(result);
|
||||
@@ -547,7 +461,7 @@ public sealed class AgentInlineSkillTests
|
||||
var args = argsDoc.RootElement;
|
||||
|
||||
// Act
|
||||
var result = await (await skill.GetScriptAsync("lookup"))!.RunAsync(skill, args, null, CancellationToken.None);
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert — per-script JSO takes effect and custom types are properly marshaled
|
||||
Assert.NotNull(result);
|
||||
@@ -563,7 +477,7 @@ public sealed class AgentInlineSkillTests
|
||||
skill.AddResource("config", () => new SkillConfig { Theme = "dark", Verbose = true });
|
||||
|
||||
// Act
|
||||
var result = await skill.GetTestResources()![0].ReadAsync();
|
||||
var result = await skill.Resources![0].ReadAsync();
|
||||
|
||||
// Assert — the custom type was returned successfully via skill-level JSO
|
||||
Assert.NotNull(result);
|
||||
@@ -580,7 +494,7 @@ public sealed class AgentInlineSkillTests
|
||||
skill.AddResource("config", () => new SkillConfig { Theme = "dark", Verbose = true }, serializerOptions: resourceJso);
|
||||
|
||||
// Act
|
||||
var result = await skill.GetTestResources()![0].ReadAsync();
|
||||
var result = await skill.Resources![0].ReadAsync();
|
||||
|
||||
// Assert — per-resource JSO takes effect and custom type is properly marshaled
|
||||
Assert.NotNull(result);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only helpers that peek at the underlying resource list of a skill via reflection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The public <see cref="AgentSkill"/> API exposes resources only through
|
||||
/// <see cref="AgentSkill.GetResourceAsync"/>.
|
||||
/// These helpers exist purely to allow unit tests for <see cref="AgentFileSkill"/> and
|
||||
/// <see cref="AgentInlineSkill"/> to inspect the concrete enumerated list a skill carries.
|
||||
/// </remarks>
|
||||
internal static class AgentSkillTestExtensions
|
||||
{
|
||||
public static IReadOnlyList<AgentSkillResource>? GetTestResources(this AgentSkill skill)
|
||||
{
|
||||
// AgentFileSkill / AgentInlineSkill: private "_resources" field.
|
||||
for (var type = skill.GetType(); type is not null; type = type.BaseType)
|
||||
{
|
||||
var field = type.GetField("_resources", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (field is not null)
|
||||
{
|
||||
return UnwrapList(field.GetValue(skill));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AgentSkillResource>? UnwrapList(object? value) =>
|
||||
value switch
|
||||
{
|
||||
null => null,
|
||||
IReadOnlyList<AgentSkillResource> list => list,
|
||||
IEnumerable<AgentSkillResource> seq => seq.ToList(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
+10
-282
@@ -68,12 +68,11 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
Assert.Contains("provider-skill", result.Instructions);
|
||||
Assert.Contains("Provider skill test", result.Instructions);
|
||||
|
||||
// Should have load_skill, read_skill_resource, and run_skill_script tools
|
||||
// Should have load_skill tool (no resources, so no read_skill_resource)
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
Assert.Contains("run_skill_script", toolNames);
|
||||
Assert.DoesNotContain("read_skill_resource", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -317,7 +316,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_WithoutScripts_StillIncludesAllToolsAsync()
|
||||
public async Task InvokingCoreAsync_WithoutScripts_NoRunSkillScriptToolAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("no-script-skill", "No scripts", "Body.");
|
||||
@@ -329,12 +328,10 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — all tools are always included regardless of skill content
|
||||
// Assert
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
Assert.Contains("run_skill_script", toolNames);
|
||||
Assert.DoesNotContain("run_skill_script", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -419,7 +416,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var fileSkill = Assert.IsType<AgentFileSkill>(skills[0]);
|
||||
Assert.All(fileSkill.GetTestResources()!, r => Assert.EndsWith(".json", r.Name));
|
||||
Assert.All(fileSkill.Resources, r => Assert.EndsWith(".json", r.Name));
|
||||
}
|
||||
|
||||
private void CreateSkill(string name, string description, string body)
|
||||
@@ -448,279 +445,6 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
Assert.Contains("Skill body.", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkill_EmptySkillName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("any-skill", "Test", "Body.");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "" }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkill_SkillNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("only-skill", "Test", "Body.");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "non-existent" }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_WithResources_IncludesReadSkillResourceToolAsync()
|
||||
{
|
||||
// Arrange — inline skill with a resource
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "value1", "A config resource.");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_ReturnsResourceContentAsync()
|
||||
{
|
||||
// Arrange — inline skill with a resource
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "resource-value");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "res-skill",
|
||||
["resourceName"] = "config",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("resource-value", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_EmptySkillName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "v");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "",
|
||||
["resourceName"] = "config",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_EmptyResourceName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "v");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "res-skill",
|
||||
["resourceName"] = "",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Resource name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_SkillNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "v");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "non-existent",
|
||||
["resourceName"] = "config",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_ResourceNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "v");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "res-skill",
|
||||
["resourceName"] = "missing",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Resource 'missing' not found in skill 'res-skill'.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunSkillScript_EmptySkillName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "err-script-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script-skill\ndescription: Test\n---\nBody.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "",
|
||||
["scriptName"] = "scripts/run.py",
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunSkillScript_EmptyScriptName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "err-script2-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script2-skill\ndescription: Test\n---\nBody.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "err-script2-skill",
|
||||
["scriptName"] = "",
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Script name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunSkillScript_SkillNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "err-script3-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script3-skill\ndescription: Test\n---\nBody.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "non-existent",
|
||||
["scriptName"] = "scripts/run.py",
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunSkillScript_ScriptNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "err-script4-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script4-skill\ndescription: Test\n---\nBody.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "err-script4-skill",
|
||||
["scriptName"] = "scripts/missing.py",
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Script 'scripts/missing.py' not found in skill 'err-script4-skill'.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Builder_UseFileScriptRunnerAfterUseFileSkills_RunnerIsUsedAsync()
|
||||
{
|
||||
@@ -1274,5 +998,9 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
protected override string Instructions => this._instructions;
|
||||
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources => null;
|
||||
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
|
||||
}
|
||||
}
|
||||
|
||||
+42
-44
@@ -281,9 +281,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Equal(2, skill.GetTestResources()!.Count);
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("references/FAQ.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("assets/data.json", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(2, skill.Resources!.Count);
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/FAQ.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("assets/data.json", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -306,8 +306,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("references/data.json", skill.GetTestResources()![0].Name);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("references/data.json", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -329,8 +329,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("references/notes.md", skill.GetTestResources()![0].Name);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("references/notes.md", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -355,9 +355,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only the file directly in references/ is discovered; the nested file is not
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("references/top.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(skill.GetTestResources()!, r => r.Name.Contains("deep.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/top.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(skill.Resources!, r => r.Name.Contains("deep.md", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -380,8 +380,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only .custom files should be discovered, not .json
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("references/data.custom", skill.GetTestResources()![0].Name);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("references/data.custom", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -406,7 +406,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — default extensions include .md
|
||||
var skills = await source.GetSkillsAsync();
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Single(skills[0].Resources!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -442,7 +442,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — root-level files are NOT discovered unless "." is in ResourceDirectories
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
Assert.Empty(skills[0].Resources!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -465,9 +465,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — both root-level resource files (and SKILL.md excluded) should be discovered
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Equal(2, skill.GetTestResources()!.Count);
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(2, skill.Resources!.Count);
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -488,7 +488,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — non-spec directories are not scanned by default
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
Assert.Empty(skills[0].Resources!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -514,8 +514,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only docs/ is scanned; references/ is NOT scanned
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("docs/readme.md", skill.GetTestResources()![0].Name);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("docs/readme.md", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -530,7 +530,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
Assert.Empty(skills[0].Resources!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -588,7 +588,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var resource = skills[0].GetTestResources()!.First(r => r.Name == "references/doc.md");
|
||||
var resource = skills[0].Resources!.First(r => r.Name == "references/doc.md");
|
||||
|
||||
// Act
|
||||
var content = await resource.ReadAsync();
|
||||
@@ -672,8 +672,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — skill should still load, the symlinked references/ is skipped, assets/legit.md is found
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("assets/legit.md", skill.GetTestResources()![0].Name);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -714,8 +714,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only assets/legit.md is found; the symlinked references/ directory is skipped entirely
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-directory-skip");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("assets/legit.md", skill.GetTestResources()![0].Name);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -751,7 +751,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — skill loads but scripts from the symlinked directory are not discovered
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-script-skip");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Null(await skill.GetScriptAsync("any-script"));
|
||||
Assert.Empty(skill.Scripts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -791,7 +791,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — the symlinked intermediate segment causes the directory to be skipped
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-intermediate");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Empty(skill.GetTestResources()!);
|
||||
Assert.Empty(skill.Resources!);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1020,8 +1020,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — only one copy of the resource despite two equivalent directory entries
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal("references/FAQ.md", skills[0].GetTestResources()![0].Name);
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Equal("references/FAQ.md", skills[0].Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1043,8 +1043,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — trailing slash variant deduplicated
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal("references/data.json", skills[0].GetTestResources()![0].Name);
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Equal("references/data.json", skills[0].Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1066,9 +1066,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — backslash variant deduplicated
|
||||
Assert.Single(skills);
|
||||
var script = await skills[0].GetScriptAsync("scripts/run.py");
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("scripts/run.py", script!.Name);
|
||||
Assert.Single(skills[0].Scripts!);
|
||||
Assert.Equal("scripts/run.py", skills[0].Scripts![0].Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -1094,8 +1093,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — the resource is discovered with a name identical to using the directory without "./"
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].GetTestResources()![0].Name);
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].Resources![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1118,8 +1117,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — resource file inside the deeply nested directory is discovered
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("f1/f2/f3/data.json", skill.GetTestResources()![0].Name);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("f1/f2/f3/data.json", skill.Resources![0].Name);
|
||||
}
|
||||
|
||||
private string CreateSkillDirectory(string name, string description, string body)
|
||||
@@ -1189,9 +1188,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — script at the skill root should be discovered
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "root-script-skill");
|
||||
Assert.NotNull(skill);
|
||||
var script = await skill.GetScriptAsync("run.py");
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("run.py", script!.Name);
|
||||
Assert.Single(skill.Scripts!);
|
||||
Assert.Equal("run.py", skill.Scripts![0].Name);
|
||||
}
|
||||
|
||||
#if NET
|
||||
@@ -1231,8 +1229,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only legit.md should be discovered; the symlinked leak.md is skipped
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-file-skill");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("references/legit.md", skill.GetTestResources()![0].Name);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("references/legit.md", skill.Resources![0].Name);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
-301
@@ -1,301 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests that verify the Hosted-AgentSkills sample patterns: ZIP extraction with
|
||||
/// zip-slip guard, skill name validation, and AgentSkillsProvider loading from
|
||||
/// downloaded skill directories (the Foundry download → extract → wire-into-provider flow).
|
||||
/// </summary>
|
||||
public sealed class HostedAgentSkillsPatternTests : IDisposable
|
||||
{
|
||||
private readonly string _testRoot;
|
||||
private readonly TestAIAgent _agent = new();
|
||||
|
||||
public HostedAgentSkillsPatternTests()
|
||||
{
|
||||
this._testRoot = Path.Combine(Path.GetTempPath(), "hosted-skills-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(this._testRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(this._testRoot))
|
||||
{
|
||||
Directory.Delete(this._testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ZIP extraction tests ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SafeExtractZip_ValidArchive_ExtractsToDestination()
|
||||
{
|
||||
// Arrange
|
||||
string destDir = Path.Combine(this._testRoot, "valid-extract");
|
||||
Directory.CreateDirectory(destDir);
|
||||
byte[] zip = CreateZipWithEntry("SKILL.md", "---\nname: test\ndescription: Test\n---\nBody.");
|
||||
|
||||
// Act
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
SafeExtractZip(archive, destDir);
|
||||
|
||||
// Assert
|
||||
Assert.True(File.Exists(Path.Combine(destDir, "SKILL.md")));
|
||||
string content = File.ReadAllText(Path.Combine(destDir, "SKILL.md"));
|
||||
Assert.Contains("name: test", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeExtractZip_ZipSlipAttempt_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
string destDir = Path.Combine(this._testRoot, "zipslip-test");
|
||||
Directory.CreateDirectory(destDir);
|
||||
byte[] zip = CreateZipWithEntry("../../../evil.txt", "malicious content");
|
||||
|
||||
// Act & Assert
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => SafeExtractZip(archive, destDir));
|
||||
Assert.Contains("outside of", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeExtractZip_SiblingPrefixAttack_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange — sibling path that starts with the dest dir name
|
||||
string destDir = Path.Combine(this._testRoot, "target");
|
||||
Directory.CreateDirectory(destDir);
|
||||
byte[] zip = CreateZipWithEntry("../target-evil/payload.txt", "exploit");
|
||||
|
||||
// Act & Assert
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => SafeExtractZip(archive, destDir));
|
||||
Assert.Contains("outside of", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeExtractZip_DirectoryEntry_CreatesDirectory()
|
||||
{
|
||||
// Arrange
|
||||
string destDir = Path.Combine(this._testRoot, "dir-entry");
|
||||
Directory.CreateDirectory(destDir);
|
||||
byte[] zip = CreateZipWithDirectoryEntry("subdir/");
|
||||
|
||||
// Act
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
SafeExtractZip(archive, destDir);
|
||||
|
||||
// Assert
|
||||
Assert.True(Directory.Exists(Path.Combine(destDir, "subdir")));
|
||||
}
|
||||
|
||||
// ── Skill name validation tests ──────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("../escape")]
|
||||
[InlineData("path/traversal")]
|
||||
[InlineData("path\\traversal")]
|
||||
[InlineData("has.dots")]
|
||||
public void ValidateSkillName_InvalidNames_Rejected(string name)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.True(IsInvalidSkillName(name), $"Expected '{name}' to be rejected.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("support-style")]
|
||||
[InlineData("escalation-policy")]
|
||||
[InlineData("my-skill-123")]
|
||||
public void ValidateSkillName_ValidNames_Accepted(string name)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.False(IsInvalidSkillName(name), $"Expected '{name}' to be accepted.");
|
||||
}
|
||||
|
||||
// ── AgentSkillsProvider integration with downloaded skill directories ─────
|
||||
|
||||
[Fact]
|
||||
public async Task AgentSkillsProvider_WithDownloadedSkills_AdvertisesAndLoadsAsync()
|
||||
{
|
||||
// Arrange — simulate the Foundry download + extract flow
|
||||
string downloadDir = Path.Combine(this._testRoot, "downloaded_skills");
|
||||
Directory.CreateDirectory(downloadDir);
|
||||
|
||||
CreateDownloadedSkill(downloadDir, "support-style",
|
||||
"---\nname: support-style\ndescription: Contoso Outdoors customer-support tone and formatting guidelines.\n---\n\n# Contoso Outdoors Support Style\n\nYou are speaking on behalf of Contoso Outdoors.\n\n## Canary\n\nInclude STYLE-CANARY-3318.");
|
||||
CreateDownloadedSkill(downloadDir, "escalation-policy",
|
||||
"---\nname: escalation-policy\ndescription: When and how to escalate Contoso Outdoors customer-support tickets.\n---\n\n# Escalation Policy\n\nProvide ESC-CANARY-7742.");
|
||||
|
||||
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
|
||||
var inputContext = new AIContext
|
||||
{
|
||||
Instructions = "You are a customer-support assistant for Contoso Outdoors."
|
||||
};
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — skills are advertised in instructions
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("support-style", result.Instructions);
|
||||
Assert.Contains("escalation-policy", result.Instructions);
|
||||
Assert.Contains("Contoso Outdoors customer-support tone", result.Instructions);
|
||||
|
||||
// Assert — load_skill tool is available
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
// All tools are always included regardless of whether skills have resources or scripts
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
Assert.Contains("run_skill_script", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkill_ReturnsFullContentWithCanaryAsync()
|
||||
{
|
||||
// Arrange
|
||||
string downloadDir = Path.Combine(this._testRoot, "canary_skills");
|
||||
Directory.CreateDirectory(downloadDir);
|
||||
CreateDownloadedSkill(downloadDir, "support-style",
|
||||
"---\nname: support-style\ndescription: Contoso tone guidelines.\n---\n\nInclude STYLE-CANARY-3318 at the bottom.");
|
||||
|
||||
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
||||
Assert.NotNull(loadSkillTool);
|
||||
|
||||
// Act
|
||||
var content = await loadSkillTool!.InvokeAsync(
|
||||
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?> { ["skillName"] = "support-style" }));
|
||||
|
||||
// Assert
|
||||
var text = content!.ToString()!;
|
||||
Assert.Contains("STYLE-CANARY-3318", text);
|
||||
Assert.Contains("name: support-style", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkill_UnknownName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string downloadDir = Path.Combine(this._testRoot, "error_skills");
|
||||
Directory.CreateDirectory(downloadDir);
|
||||
CreateDownloadedSkill(downloadDir, "support-style",
|
||||
"---\nname: support-style\ndescription: Test\n---\nBody.");
|
||||
|
||||
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await loadSkillTool!.InvokeAsync(
|
||||
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?> { ["skillName"] = "nonexistent-skill" }));
|
||||
|
||||
// Assert
|
||||
var text = content!.ToString()!;
|
||||
Assert.Contains("Error", text);
|
||||
Assert.Contains("not found", text);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a downloaded skill directory with a SKILL.md file — simulating what
|
||||
/// the Foundry download + ZIP extract flow produces.
|
||||
/// </summary>
|
||||
private static void CreateDownloadedSkill(string parentDir, string name, string content)
|
||||
{
|
||||
string skillDir = Path.Combine(parentDir, name);
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ZIP archive in memory containing a single file entry.
|
||||
/// </summary>
|
||||
private static byte[] CreateZipWithEntry(string entryName, string content)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var entry = archive.CreateEntry(entryName);
|
||||
using var writer = new StreamWriter(entry.Open());
|
||||
writer.Write(content);
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ZIP archive in memory containing a single directory entry.
|
||||
/// </summary>
|
||||
private static byte[] CreateZipWithDirectoryEntry(string directoryName)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
// Directory entries in ZIPs have an empty name portion and end with /
|
||||
archive.CreateEntry(directoryName);
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the zip-slip guard from the Hosted-AgentSkills sample Program.cs.
|
||||
/// </summary>
|
||||
private static void SafeExtractZip(ZipArchive archive, string destinationDir)
|
||||
{
|
||||
string destRoot = Path.GetFullPath(destinationDir);
|
||||
string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
|
||||
? destRoot
|
||||
: destRoot + Path.DirectorySeparatorChar;
|
||||
|
||||
var comparison = OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
foreach (ZipArchiveEntry entry in archive.Entries)
|
||||
{
|
||||
string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
|
||||
if (!entryPath.StartsWith(destRootWithSep, comparison)
|
||||
&& !string.Equals(entryPath, destRoot, comparison))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(entry.Name))
|
||||
{
|
||||
Directory.CreateDirectory(entryPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
|
||||
entry.ExtractToFile(entryPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the skill name validation from the Hosted-AgentSkills sample Program.cs.
|
||||
/// </summary>
|
||||
private static bool IsInvalidSkillName(string name) =>
|
||||
name.Contains('.') || name.Contains('/') || name.Contains('\\') || Path.IsPathRooted(name);
|
||||
}
|
||||
@@ -31,7 +31,13 @@ internal sealed class TestAgentSkill : AgentSkill
|
||||
public override AgentSkillFrontmatter Frontmatter => this._frontmatter;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default) => new(this._content);
|
||||
public override string Content => this._content;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources => null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+11
-11
@@ -73,7 +73,7 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction setMode = GetTool(tools, "mode_set");
|
||||
AIFunction setMode = GetTool(tools, "AgentMode_Set");
|
||||
|
||||
// Act
|
||||
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
@@ -90,7 +90,7 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction setMode = GetTool(tools, "mode_set");
|
||||
AIFunction setMode = GetTool(tools, "AgentMode_Set");
|
||||
|
||||
// Act
|
||||
object? result = await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
@@ -107,8 +107,8 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, provider, session) = await CreateToolsWithProviderAndSessionAsync();
|
||||
AIFunction setMode = GetTool(tools, "mode_set");
|
||||
AIFunction getMode = GetTool(tools, "mode_get");
|
||||
AIFunction setMode = GetTool(tools, "AgentMode_Set");
|
||||
AIFunction getMode = GetTool(tools, "AgentMode_Get");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
@@ -131,7 +131,7 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction getMode = GetTool(tools, "mode_get");
|
||||
AIFunction getMode = GetTool(tools, "AgentMode_Get");
|
||||
|
||||
// Act
|
||||
object? result = await getMode.InvokeAsync(new AIFunctionArguments());
|
||||
@@ -148,8 +148,8 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction setMode = GetTool(tools, "mode_set");
|
||||
AIFunction getMode = GetTool(tools, "mode_get");
|
||||
AIFunction setMode = GetTool(tools, "AgentMode_Set");
|
||||
AIFunction getMode = GetTool(tools, "AgentMode_Get");
|
||||
|
||||
// Act
|
||||
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
@@ -236,7 +236,7 @@ public class AgentModeProviderTests
|
||||
|
||||
// Act
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction getMode = GetTool(result.Tools!, "mode_get");
|
||||
AIFunction getMode = GetTool(result.Tools!, "AgentMode_Get");
|
||||
object? modeResult = await getMode.InvokeAsync(new AIFunctionArguments());
|
||||
|
||||
// Assert
|
||||
@@ -264,12 +264,12 @@ public class AgentModeProviderTests
|
||||
|
||||
// Act — first invocation changes mode
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction setMode = GetTool(result1.Tools!, "mode_set");
|
||||
AIFunction setMode = GetTool(result1.Tools!, "AgentMode_Set");
|
||||
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
|
||||
// Second invocation should see the updated mode
|
||||
AIContext result2 = await provider.InvokingAsync(context);
|
||||
AIFunction getMode = GetTool(result2.Tools!, "mode_get");
|
||||
AIFunction getMode = GetTool(result2.Tools!, "AgentMode_Get");
|
||||
object? modeResult = await getMode.InvokeAsync(new AIFunctionArguments());
|
||||
|
||||
// Assert
|
||||
@@ -579,7 +579,7 @@ public class AgentModeProviderTests
|
||||
|
||||
// First call to initialize
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction setMode = GetTool(result1.Tools!, "mode_set");
|
||||
AIFunction setMode = GetTool(result1.Tools!, "AgentMode_Set");
|
||||
|
||||
// Change mode via the tool (agent-initiated)
|
||||
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<Compile Remove="EvaluationTests.cs" />
|
||||
<Compile Remove="AgentSkills\HostedAgentSkillsPatternTests.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
-221
@@ -1,10 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
@@ -202,131 +198,6 @@ public sealed class ForeachExecutorTest(ITestOutputHelper output) : WorkflowActi
|
||||
indexName: "CurrentIndex");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for GH-5009: a <see cref="ForeachExecutor"/> that is re-instantiated
|
||||
/// during checkpoint restore (e.g. cross-process resume after a <c>Question</c> inside the
|
||||
/// loop body) must continue iterating from where it left off, not exit after the first
|
||||
/// iteration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ForeachStateRestoredAcrossCheckpointAsync()
|
||||
{
|
||||
// Arrange — a 3-item source table and a freshly-bound foreach executor (instance A).
|
||||
const string SourceVariableName = "SourceArray";
|
||||
this.SetVariableState("CurrentValue");
|
||||
this.State.Set(
|
||||
SourceVariableName,
|
||||
FormulaValue.NewTable(
|
||||
RecordType.Empty(),
|
||||
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10))),
|
||||
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(20))),
|
||||
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(30)))));
|
||||
|
||||
Foreach model = this.CreateModel(
|
||||
displayName: nameof(ForeachStateRestoredAcrossCheckpointAsync),
|
||||
items: ValueExpression.Variable(PropertyPath.TopicVariable(SourceVariableName)),
|
||||
valueName: "CurrentValue",
|
||||
indexName: null);
|
||||
|
||||
ForeachExecutor instanceA = new(model, this.State);
|
||||
|
||||
// Drive instance A through ExecuteAsync (initializes _values/_index) and one TakeNextAsync
|
||||
// so that _index advances to 1 and HasValue is true — the state at the point a Question
|
||||
// inside the loop body would pause the workflow and trigger a checkpoint.
|
||||
await this.ExecuteAsync(instanceA, ForeachExecutor.Steps.Next(instanceA.Id), instanceA.TakeNextAsync);
|
||||
Assert.True(instanceA.HasValue, "Instance A should have a current item after the first TakeNextAsync.");
|
||||
|
||||
// Act 1 — instance A persists checkpoint state.
|
||||
InMemoryWorkflowContext checkpoint = new();
|
||||
await InvokeOnCheckpointingAsync(instanceA, checkpoint);
|
||||
|
||||
// Act 2 — a fresh instance B (simulating cross-process resume) restores from the checkpoint.
|
||||
ForeachExecutor instanceB = new(model, this.State);
|
||||
await InvokeOnCheckpointRestoredAsync(instanceB, checkpoint);
|
||||
|
||||
// Assert — HasValue carries over so the routing predicate after loopId continues to take
|
||||
// the "loop body" edge instead of falling through to the loop continuation.
|
||||
Assert.True(instanceB.HasValue, "Restored instance should report HasValue == true at the checkpointed cursor.");
|
||||
|
||||
// Drive iteration 2 and 3 through instance B; both should succeed.
|
||||
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
|
||||
Assert.True(instanceB.HasValue, "Restored instance should advance to iteration 2 (value=20).");
|
||||
|
||||
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
|
||||
Assert.True(instanceB.HasValue, "Restored instance should advance to iteration 3 (value=30).");
|
||||
|
||||
// Driving past the end exits the loop normally.
|
||||
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
|
||||
Assert.False(instanceB.HasValue, "Restored instance should report HasValue == false after exhausting all items.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When no checkpoint state has been written for the executor (e.g. first run), the restore
|
||||
/// hook must be a no-op and leave constructor defaults in place.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ForeachRestoreWithNoSavedStateAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetVariableState("CurrentValue");
|
||||
Foreach model = this.CreateModel(
|
||||
displayName: nameof(ForeachRestoreWithNoSavedStateAsync),
|
||||
items: ValueExpression.Literal(DataValue.EmptyTable),
|
||||
valueName: "CurrentValue",
|
||||
indexName: null);
|
||||
|
||||
ForeachExecutor executor = new(model, this.State);
|
||||
InMemoryWorkflowContext emptyContext = new();
|
||||
|
||||
// Act — restoring against an empty context must not throw and must leave the executor
|
||||
// in its constructor-default state.
|
||||
await InvokeOnCheckpointRestoredAsync(executor, emptyContext);
|
||||
|
||||
// Assert
|
||||
Assert.False(executor.HasValue);
|
||||
|
||||
// A subsequent TakeNextAsync (without a prior ExecuteAsync) should report no value
|
||||
// because _values is still the empty constructor default.
|
||||
await executor.TakeNextAsync(emptyContext, _: null, CancellationToken.None);
|
||||
Assert.False(executor.HasValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checkpoint/restore around a foreach over an empty source must roundtrip cleanly
|
||||
/// (zero-length <c>PortableValue[]</c> snapshot).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ForeachStateSurvivesEmptyValuesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetVariableState("CurrentValue");
|
||||
Foreach model = this.CreateModel(
|
||||
displayName: nameof(ForeachStateSurvivesEmptyValuesAsync),
|
||||
items: ValueExpression.Literal(DataValue.EmptyTable),
|
||||
valueName: "CurrentValue",
|
||||
indexName: null);
|
||||
|
||||
ForeachExecutor instanceA = new(model, this.State);
|
||||
|
||||
// Run ExecuteAsync (which sets _values = []) followed by one TakeNextAsync (which sets
|
||||
// HasValue = false on an empty source).
|
||||
await this.ExecuteAsync(instanceA, ForeachExecutor.Steps.Next(instanceA.Id), instanceA.TakeNextAsync);
|
||||
Assert.False(instanceA.HasValue);
|
||||
|
||||
// Act — checkpoint and restore into a fresh instance.
|
||||
InMemoryWorkflowContext checkpoint = new();
|
||||
await InvokeOnCheckpointingAsync(instanceA, checkpoint);
|
||||
|
||||
ForeachExecutor instanceB = new(model, this.State);
|
||||
await InvokeOnCheckpointRestoredAsync(instanceB, checkpoint);
|
||||
|
||||
// Assert — restored instance must agree that the source is empty and HasValue is false.
|
||||
Assert.False(instanceB.HasValue);
|
||||
|
||||
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
|
||||
Assert.False(instanceB.HasValue);
|
||||
}
|
||||
|
||||
private void SetVariableState(string valueName, string? indexName = null, FormulaValue? valueState = null)
|
||||
{
|
||||
this.State.Set(valueName, valueState ?? FormulaValue.New("something"));
|
||||
@@ -441,96 +312,4 @@ public sealed class ForeachExecutorTest(ITestOutputHelper output) : WorkflowActi
|
||||
|
||||
return AssignParent<Foreach>(actionBuilder);
|
||||
}
|
||||
|
||||
// Reflection helpers used to invoke the `protected internal` checkpoint hooks on the executor
|
||||
// base class from this test project (which is in a different assembly than Microsoft.Agents.AI.Workflows
|
||||
// and is not granted InternalsVisibleTo there).
|
||||
private static Task InvokeOnCheckpointingAsync(Executor executor, IWorkflowContext context) =>
|
||||
InvokeProtectedCheckpointHookAsync(executor, context, methodName: "OnCheckpointingAsync");
|
||||
|
||||
private static Task InvokeOnCheckpointRestoredAsync(Executor executor, IWorkflowContext context) =>
|
||||
InvokeProtectedCheckpointHookAsync(executor, context, methodName: "OnCheckpointRestoredAsync");
|
||||
|
||||
private static async Task InvokeProtectedCheckpointHookAsync(Executor executor, IWorkflowContext context, string methodName)
|
||||
{
|
||||
MethodInfo method = typeof(Executor).GetMethod(
|
||||
methodName,
|
||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
binder: null,
|
||||
types: new[] { typeof(IWorkflowContext), typeof(CancellationToken) },
|
||||
modifiers: null) ?? throw new InvalidOperationException($"Could not locate {methodName} on Executor.");
|
||||
|
||||
ValueTask invocation = (ValueTask)method.Invoke(executor, new object[] { context, CancellationToken.None })!;
|
||||
await invocation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal in-memory <see cref="IWorkflowContext"/> implementation used to drive the
|
||||
/// checkpoint/restore overrides on <see cref="ForeachExecutor"/> directly from a unit test.
|
||||
/// Records state writes in a (scope, key) dictionary and serves matching reads back. Only the
|
||||
/// state-related members are exercised by the checkpoint hooks; the other members are stubbed.
|
||||
/// </summary>
|
||||
private sealed class InMemoryWorkflowContext : IWorkflowContext
|
||||
{
|
||||
private readonly Dictionary<(string? scope, string key), object?> _store = [];
|
||||
|
||||
public bool ConcurrentRunsEnabled => false;
|
||||
|
||||
public IReadOnlyDictionary<string, string>? TraceContext => null;
|
||||
|
||||
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._store[(scopeName, key)] = value;
|
||||
return default;
|
||||
}
|
||||
|
||||
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._store.TryGetValue((scopeName, key), out object? stored) && stored is T typed)
|
||||
{
|
||||
return new ValueTask<T?>(typed);
|
||||
}
|
||||
|
||||
return new ValueTask<T?>(default(T));
|
||||
}
|
||||
|
||||
public ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._store.TryGetValue((scopeName, key), out object? stored) && stored is T typed)
|
||||
{
|
||||
return new ValueTask<T>(typed);
|
||||
}
|
||||
|
||||
T initial = initialStateFactory();
|
||||
this._store[(scopeName, key)] = initial;
|
||||
return new ValueTask<T>(initial);
|
||||
}
|
||||
|
||||
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
HashSet<string> keys = new(
|
||||
this._store.Keys
|
||||
.Where(slot => string.Equals(slot.scope, scopeName, StringComparison.Ordinal))
|
||||
.Select(slot => slot.key));
|
||||
return new ValueTask<HashSet<string>>(keys);
|
||||
}
|
||||
|
||||
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach ((string? scope, string key) slot in this._store.Keys.Where(slot => string.Equals(slot.scope, scopeName, StringComparison.Ordinal)).ToArray())
|
||||
{
|
||||
this._store.Remove(slot);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) => default;
|
||||
|
||||
public ValueTask SendMessageAsync(object message, string? targetId, CancellationToken cancellationToken = default) => default;
|
||||
|
||||
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) => default;
|
||||
|
||||
public ValueTask RequestHaltAsync() => default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,21 +21,6 @@ When making changes to a package, check if the following need updates:
|
||||
- The package's `AGENTS.md` file (adding/removing/renaming public APIs, architecture changes, import path changes)
|
||||
- The agent skills in `.github/skills/` if conventions, commands, or workflows change
|
||||
|
||||
At the end of every run, re-read `AGENTS.md` and the relevant skill files and
|
||||
update any guidance that the conversation revealed to be out of date,
|
||||
incomplete, or misleading (renamed files, changed commands, new conventions
|
||||
the user confirmed, etc.). **Before adding a new principle or rule, ask the
|
||||
user whether they want it captured as a durable principle** — do not invent
|
||||
team norms from a single conversation without explicit confirmation.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Avoid "GA" for Agent Framework code.** Reserve *GA* for hosted services
|
||||
(e.g. "the Foundry service is GA"). For Agent Framework packages, features,
|
||||
and APIs use **"released"** or **"stable"** depending on context — these
|
||||
match the feature-lifecycle stages documented in the
|
||||
`python-feature-lifecycle` skill.
|
||||
|
||||
## Pull Request Description Guidance
|
||||
|
||||
When preparing a PR description:
|
||||
|
||||
@@ -45,7 +45,6 @@ from ._compaction import (
|
||||
CharacterEstimatorTokenizer,
|
||||
CompactionProvider,
|
||||
CompactionStrategy,
|
||||
ContextWindowCompactionStrategy,
|
||||
SelectiveToolCallCompactionStrategy,
|
||||
SlidingWindowStrategy,
|
||||
SummarizationStrategy,
|
||||
@@ -80,16 +79,6 @@ from ._evaluation import (
|
||||
tool_calls_present,
|
||||
)
|
||||
from ._feature_stage import ExperimentalFeature, ReleaseCandidateFeature
|
||||
from ._harness._agent import (
|
||||
DEFAULT_HARNESS_INSTRUCTIONS,
|
||||
create_harness_agent,
|
||||
)
|
||||
from ._harness._background_agents import (
|
||||
DEFAULT_BACKGROUND_AGENTS_SOURCE_ID,
|
||||
BackgroundAgentsProvider,
|
||||
BackgroundTaskInfo,
|
||||
BackgroundTaskStatus,
|
||||
)
|
||||
from ._harness._memory import (
|
||||
DEFAULT_MEMORY_SOURCE_ID,
|
||||
MemoryContextProvider,
|
||||
@@ -308,8 +297,6 @@ __all__ = [
|
||||
"AGENT_FRAMEWORK_USER_AGENT",
|
||||
"APP_INFO",
|
||||
"COMPACTION_STATE_KEY",
|
||||
"DEFAULT_BACKGROUND_AGENTS_SOURCE_ID",
|
||||
"DEFAULT_HARNESS_INSTRUCTIONS",
|
||||
"DEFAULT_MAX_ITERATIONS",
|
||||
"DEFAULT_MEMORY_SOURCE_ID",
|
||||
"DEFAULT_MODE_SOURCE_ID",
|
||||
@@ -345,9 +332,6 @@ __all__ = [
|
||||
"AgentSession",
|
||||
"AggregatingSkillsSource",
|
||||
"Annotation",
|
||||
"BackgroundAgentsProvider",
|
||||
"BackgroundTaskInfo",
|
||||
"BackgroundTaskStatus",
|
||||
"BaseAgent",
|
||||
"BaseChatClient",
|
||||
"BaseEmbeddingClient",
|
||||
@@ -368,7 +352,6 @@ __all__ = [
|
||||
"CompactionStrategy",
|
||||
"Content",
|
||||
"ContextProvider",
|
||||
"ContextWindowCompactionStrategy",
|
||||
"ContinuationToken",
|
||||
"ConversationSplit",
|
||||
"ConversationSplitter",
|
||||
@@ -516,7 +499,6 @@ __all__ = [
|
||||
"apply_compaction",
|
||||
"chat_middleware",
|
||||
"create_edge_runner",
|
||||
"create_harness_agent",
|
||||
"detect_media_type_from_base64",
|
||||
"evaluate_agent",
|
||||
"evaluate_workflow",
|
||||
|
||||
@@ -1277,121 +1277,6 @@ class CompactionProvider(ContextProvider):
|
||||
# whether excluded messages are loaded on the next turn.
|
||||
|
||||
|
||||
class ContextWindowCompactionStrategy:
|
||||
"""Token-budget compaction derived from a model's context window size.
|
||||
|
||||
Computes an input budget from the model's context window and output token
|
||||
limits, then applies a two-phase compaction pipeline:
|
||||
|
||||
1. **Tool result eviction** — collapses older tool-call groups into summaries
|
||||
when included tokens exceed ``tool_eviction_threshold`` of the input budget.
|
||||
2. **Truncation** — removes oldest non-system groups when included tokens
|
||||
exceed ``truncation_threshold`` of the input budget.
|
||||
|
||||
The class uses two independent :class:`TokenBudgetComposedStrategy`
|
||||
instances — one per phase — so each fires only when its own threshold
|
||||
is exceeded.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import ContextWindowCompactionStrategy, CompactionProvider
|
||||
|
||||
strategy = ContextWindowCompactionStrategy(
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
provider = CompactionProvider(before_strategy=strategy)
|
||||
"""
|
||||
|
||||
DEFAULT_TOOL_EVICTION_THRESHOLD: float = 0.5
|
||||
"""Default fraction of input budget at which tool result eviction triggers."""
|
||||
|
||||
DEFAULT_TRUNCATION_THRESHOLD: float = 0.8
|
||||
"""Default fraction of input budget at which truncation triggers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_context_window_tokens: int,
|
||||
max_output_tokens: int,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
tool_eviction_threshold: float = DEFAULT_TOOL_EVICTION_THRESHOLD,
|
||||
truncation_threshold: float = DEFAULT_TRUNCATION_THRESHOLD,
|
||||
keep_last_tool_call_groups: int = 4,
|
||||
) -> None:
|
||||
"""Create a context-window compaction strategy.
|
||||
|
||||
Keyword Args:
|
||||
max_context_window_tokens: The model's maximum context window size
|
||||
in tokens (e.g. 128,000).
|
||||
max_output_tokens: The model's maximum output tokens per response
|
||||
(e.g. 16,384).
|
||||
tokenizer: Token counter for measuring message sizes. Defaults to
|
||||
:class:`CharacterEstimatorTokenizer` (4 chars/token heuristic).
|
||||
tool_eviction_threshold: Fraction of input budget (0.0, 1.0] at
|
||||
which tool result eviction triggers. Defaults to 0.5.
|
||||
truncation_threshold: Fraction of input budget (0.0, 1.0] at which
|
||||
truncation triggers. Must be ≥ ``tool_eviction_threshold``.
|
||||
Defaults to 0.8.
|
||||
keep_last_tool_call_groups: Number of most recent tool-call groups
|
||||
to retain verbatim during tool eviction. Older groups are
|
||||
collapsed into summaries. Defaults to 4.
|
||||
|
||||
Raises:
|
||||
ValueError: If thresholds are out of range or inconsistent.
|
||||
"""
|
||||
if max_context_window_tokens <= 0:
|
||||
raise ValueError("max_context_window_tokens must be positive.")
|
||||
if max_output_tokens < 0 or max_output_tokens >= max_context_window_tokens:
|
||||
raise ValueError("max_output_tokens must be >= 0 and < max_context_window_tokens.")
|
||||
if not (0.0 < tool_eviction_threshold <= 1.0):
|
||||
raise ValueError("tool_eviction_threshold must be in (0.0, 1.0].")
|
||||
if not (0.0 < truncation_threshold <= 1.0):
|
||||
raise ValueError("truncation_threshold must be in (0.0, 1.0].")
|
||||
if truncation_threshold < tool_eviction_threshold:
|
||||
raise ValueError("truncation_threshold must be >= tool_eviction_threshold.")
|
||||
|
||||
resolved_tokenizer = tokenizer or CharacterEstimatorTokenizer()
|
||||
input_budget = max_context_window_tokens - max_output_tokens
|
||||
tool_eviction_tokens = int(input_budget * tool_eviction_threshold)
|
||||
truncation_tokens = int(input_budget * truncation_threshold)
|
||||
|
||||
self.max_context_window_tokens = max_context_window_tokens
|
||||
self.max_output_tokens = max_output_tokens
|
||||
self.input_budget_tokens = input_budget
|
||||
self.tool_eviction_threshold = tool_eviction_threshold
|
||||
self.truncation_threshold = truncation_threshold
|
||||
|
||||
self._tool_eviction = TokenBudgetComposedStrategy(
|
||||
token_budget=tool_eviction_tokens,
|
||||
tokenizer=resolved_tokenizer,
|
||||
strategies=[
|
||||
ToolResultCompactionStrategy(keep_last_tool_call_groups=keep_last_tool_call_groups),
|
||||
],
|
||||
)
|
||||
self._truncation = TokenBudgetComposedStrategy(
|
||||
token_budget=truncation_tokens,
|
||||
tokenizer=resolved_tokenizer,
|
||||
strategies=[
|
||||
TruncationStrategy(
|
||||
max_n=truncation_tokens,
|
||||
compact_to=tool_eviction_tokens,
|
||||
tokenizer=resolved_tokenizer,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
async def __call__(self, messages: list[Message]) -> bool:
|
||||
"""Apply the two-phase compaction pipeline.
|
||||
|
||||
Returns:
|
||||
True if compaction changed message inclusion; otherwise False.
|
||||
"""
|
||||
changed = await self._tool_eviction(messages)
|
||||
return (await self._truncation(messages)) or changed
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COMPACTION_STATE_KEY",
|
||||
"EXCLUDED_KEY",
|
||||
@@ -1408,7 +1293,6 @@ __all__ = [
|
||||
"CharacterEstimatorTokenizer",
|
||||
"CompactionProvider",
|
||||
"CompactionStrategy",
|
||||
"ContextWindowCompactionStrategy",
|
||||
"GroupKind",
|
||||
"SelectiveToolCallCompactionStrategy",
|
||||
"SlidingWindowStrategy",
|
||||
|
||||
@@ -58,7 +58,6 @@ class ExperimentalFeature(str, Enum):
|
||||
FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS"
|
||||
HARNESS = "HARNESS"
|
||||
SKILLS = "SKILLS"
|
||||
TO_PROMPT_AGENT = "TO_PROMPT_AGENT"
|
||||
|
||||
|
||||
class ReleaseCandidateFeature(str, Enum):
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Harness agent factory: a pre-configured bundled agent with batteries included.
|
||||
|
||||
This module provides :func:`create_harness_agent`, a factory function that assembles
|
||||
the full agent pipeline from a chat client, wiring up function invocation,
|
||||
per-service-call history persistence, compaction, and a rich set of default
|
||||
context providers (todo, mode, memory, skills).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .._agents import Agent
|
||||
from .._clients import SupportsWebSearchTool
|
||||
from .._compaction import CompactionProvider, ContextWindowCompactionStrategy, ToolResultCompactionStrategy
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._sessions import ContextProvider, HistoryProvider, InMemoryHistoryProvider
|
||||
from .._skills import SkillsProvider
|
||||
from ._memory import MemoryContextProvider, MemoryStore
|
||||
from ._mode import AgentModeProvider
|
||||
from ._todo import TodoProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .._clients import SupportsChatGetResponse
|
||||
from .._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from .._middleware import MiddlewareTypes
|
||||
from .._tools import ToolTypes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_HARNESS_INSTRUCTIONS = """\
|
||||
You are a helpful AI assistant that uses tools to complete tasks.
|
||||
|
||||
## General guidelines
|
||||
|
||||
- Think through the task before acting. Break complex work into clear steps.
|
||||
- Use the tools available to you to gather information, perform actions, and verify results.
|
||||
- Explain your reasoning and thought process as you work through tasks.
|
||||
- Explain what you learned and what you are going to do next between tool calls, \
|
||||
so the user can follow along with your thought process.
|
||||
- Avoid making more than 4 tool calls in a row without explaining what you are doing.
|
||||
- If a tool call fails or returns unexpected results, adapt your approach rather than \
|
||||
repeating the same call.
|
||||
- When you have completed the task, present a clear and concise summary of what you did \
|
||||
and what you found.
|
||||
"""
|
||||
|
||||
|
||||
def _assemble_instructions(
|
||||
harness_instructions: str | None,
|
||||
agent_instructions: str | None,
|
||||
) -> str | None:
|
||||
"""Assemble final instructions from harness + agent instructions."""
|
||||
harness = harness_instructions if harness_instructions is not None else DEFAULT_HARNESS_INSTRUCTIONS
|
||||
|
||||
return f"{harness}\n\n{agent_instructions or ''}".strip() or None
|
||||
|
||||
|
||||
def _assemble_compaction_provider(
|
||||
*,
|
||||
disable_compaction: bool,
|
||||
max_context_window_tokens: int,
|
||||
max_output_tokens: int,
|
||||
history_source_id: str,
|
||||
before_compaction_strategy: CompactionStrategy | None,
|
||||
after_compaction_strategy: CompactionStrategy | None,
|
||||
tokenizer: TokenizerProtocol | None,
|
||||
) -> CompactionProvider | None:
|
||||
"""Build the compaction provider from parameters or defaults."""
|
||||
if disable_compaction:
|
||||
return None
|
||||
|
||||
before_strategy = before_compaction_strategy or ContextWindowCompactionStrategy(
|
||||
max_context_window_tokens=max_context_window_tokens,
|
||||
max_output_tokens=max_output_tokens,
|
||||
tokenizer=tokenizer,
|
||||
)
|
||||
after_strategy = after_compaction_strategy or ToolResultCompactionStrategy(keep_last_tool_call_groups=2)
|
||||
|
||||
return CompactionProvider(
|
||||
before_strategy=before_strategy,
|
||||
after_strategy=after_strategy,
|
||||
tokenizer=tokenizer,
|
||||
history_source_id=history_source_id,
|
||||
)
|
||||
|
||||
|
||||
def _assemble_context_providers(
|
||||
*,
|
||||
history_provider: HistoryProvider,
|
||||
compaction_provider: CompactionProvider | None,
|
||||
disable_todo: bool,
|
||||
todo_provider: TodoProvider | None,
|
||||
disable_mode: bool,
|
||||
mode_provider: AgentModeProvider | None,
|
||||
disable_memory: bool,
|
||||
memory_store: MemoryStore | None,
|
||||
skills_provider: SkillsProvider | None,
|
||||
skills_paths: Sequence[str] | None,
|
||||
extra_context_providers: Sequence[ContextProvider] | None,
|
||||
) -> list[ContextProvider]:
|
||||
"""Assemble the ordered list of context providers."""
|
||||
providers: list[ContextProvider] = []
|
||||
|
||||
# History first so other providers can access loaded messages.
|
||||
providers.append(history_provider)
|
||||
|
||||
# Compaction runs after history loads messages.
|
||||
if compaction_provider is not None:
|
||||
providers.append(compaction_provider)
|
||||
|
||||
if not disable_todo:
|
||||
providers.append(todo_provider or TodoProvider())
|
||||
|
||||
if not disable_mode:
|
||||
providers.append(mode_provider or AgentModeProvider())
|
||||
|
||||
if not disable_memory and memory_store is not None:
|
||||
providers.append(MemoryContextProvider(store=memory_store))
|
||||
|
||||
# Skills are opt-in: only added when skills_provider or skills_paths is provided.
|
||||
if skills_provider:
|
||||
providers.append(skills_provider)
|
||||
if skills_paths:
|
||||
providers.append(SkillsProvider.from_paths(*skills_paths))
|
||||
|
||||
# Append any user-supplied additional providers.
|
||||
if extra_context_providers:
|
||||
providers.extend(extra_context_providers)
|
||||
|
||||
return providers
|
||||
|
||||
|
||||
HARNESS_AGENT_PROVIDER_NAME = "microsoft.agent_framework.harness"
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
def create_harness_agent(
|
||||
client: SupportsChatGetResponse[Any],
|
||||
*,
|
||||
id: str | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
harness_instructions: str | None = None,
|
||||
agent_instructions: str | None = None,
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
|
||||
max_context_window_tokens: int,
|
||||
max_output_tokens: int,
|
||||
history_provider: HistoryProvider | None = None,
|
||||
disable_compaction: bool = False,
|
||||
before_compaction_strategy: CompactionStrategy | None = None,
|
||||
after_compaction_strategy: CompactionStrategy | None = None,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
disable_todo: bool = False,
|
||||
todo_provider: TodoProvider | None = None,
|
||||
disable_mode: bool = False,
|
||||
mode_provider: AgentModeProvider | None = None,
|
||||
disable_memory: bool = False,
|
||||
memory_store: MemoryStore | None = None,
|
||||
skills_provider: SkillsProvider | None = None,
|
||||
skills_paths: Sequence[str] | None = None,
|
||||
disable_web_search: bool = False,
|
||||
otel_provider_name: str | None = None,
|
||||
context_providers: Sequence[ContextProvider] | None = None,
|
||||
middleware: Sequence[MiddlewareTypes] | None = None,
|
||||
default_options: Mapping[str, Any] | None = None,
|
||||
) -> Agent[Any]:
|
||||
"""Create a pre-configured agent with batteries included.
|
||||
|
||||
Assembles an :class:`~agent_framework.Agent` from a chat client, automatically wiring:
|
||||
|
||||
- **Function invocation** — automatic tool calling loop
|
||||
- **Per-service-call history persistence** — persists history after every model call
|
||||
- **Compaction** — context-window compaction before/after each run
|
||||
- **TodoProvider** — todo list management
|
||||
- **AgentModeProvider** — plan/execute mode tracking
|
||||
- **MemoryContextProvider** — file-based durable memory (when ``memory_store`` provided)
|
||||
- **SkillsProvider** — skill discovery and progressive loading
|
||||
- **OpenTelemetry** — observability via ``AgentTelemetryLayer``
|
||||
|
||||
Each feature can be disabled or customized via keyword arguments.
|
||||
|
||||
Examples:
|
||||
Basic usage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import create_harness_agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
agent = create_harness_agent(
|
||||
OpenAIChatClient(model="gpt-4o"),
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
session = agent.create_session()
|
||||
response = await agent.run("Plan a weekend trip to Seattle", session=session)
|
||||
|
||||
With customization:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=client,
|
||||
max_context_window_tokens=200_000,
|
||||
max_output_tokens=32_000,
|
||||
name="research-agent",
|
||||
agent_instructions="Focus on academic sources.",
|
||||
disable_todo=True,
|
||||
skills_paths=["./skills", "./custom-skills"],
|
||||
)
|
||||
|
||||
Args:
|
||||
client: The chat client providing access to the underlying AI model.
|
||||
|
||||
Keyword Args:
|
||||
id: Optional agent ID (auto-generated UUID if omitted).
|
||||
name: Optional agent name.
|
||||
description: Optional agent description.
|
||||
harness_instructions: Override the default harness-level system instructions that
|
||||
govern agent behavior (how to use tools, report progress, structure responses).
|
||||
These provide general "operating guidelines" independent of any specific task.
|
||||
When None, ``DEFAULT_HARNESS_INSTRUCTIONS`` is used. Set to empty string ``""``
|
||||
to omit harness instructions entirely.
|
||||
agent_instructions: Domain or task-specific instructions appended after harness
|
||||
instructions. Use this for the agent's purpose, persona, or specialization
|
||||
(e.g., "You are a research assistant focused on academic sources.").
|
||||
tools: Additional tools to include in the agent's toolset.
|
||||
max_context_window_tokens: Maximum tokens the model's context window supports.
|
||||
max_output_tokens: Maximum output tokens per response.
|
||||
history_provider: Custom history provider. When None, an InMemoryHistoryProvider is used.
|
||||
disable_compaction: When True, skip compaction provider setup.
|
||||
before_compaction_strategy: Custom before-run compaction strategy.
|
||||
Defaults to ContextWindowCompactionStrategy (token-budget aware).
|
||||
after_compaction_strategy: Custom after-run compaction strategy.
|
||||
Defaults to ToolResultCompactionStrategy.
|
||||
tokenizer: Custom tokenizer for compaction strategies.
|
||||
disable_todo: When True, skip the TodoProvider.
|
||||
todo_provider: Custom TodoProvider instance. Ignored when disable_todo is True.
|
||||
disable_mode: When True, skip the AgentModeProvider.
|
||||
mode_provider: Custom AgentModeProvider instance. Ignored when disable_mode is True.
|
||||
disable_memory: When True, skip the MemoryContextProvider.
|
||||
memory_store: Memory store instance. When provided (and disable_memory is False),
|
||||
a MemoryContextProvider is added.
|
||||
skills_provider: Custom SkillsProvider instance for code-defined skills.
|
||||
Can be combined with ``skills_paths`` to aggregate file and code-based skills.
|
||||
skills_paths: Paths for file-based skill discovery (looks for SKILL.md files).
|
||||
Can be combined with ``skills_provider``. When neither ``skills_provider``
|
||||
nor ``skills_paths`` is provided, no SkillsProvider is added.
|
||||
disable_web_search: When True, skip automatic web search tool inclusion.
|
||||
When False (default), the web search tool is automatically added if the
|
||||
client implements SupportsWebSearchTool. A warning is logged if the client
|
||||
does not support web search.
|
||||
otel_provider_name: Custom OpenTelemetry provider/source name for telemetry.
|
||||
context_providers: Additional context providers to include after the built-in ones.
|
||||
middleware: Additional middleware to include.
|
||||
default_options: Provider-specific chat options (temperature, max_tokens, etc.).
|
||||
|
||||
Returns:
|
||||
A fully configured :class:`~agent_framework.Agent` instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If max_context_window_tokens <= 0 or max_output_tokens < 0
|
||||
or max_output_tokens >= max_context_window_tokens.
|
||||
"""
|
||||
if max_context_window_tokens <= 0:
|
||||
raise ValueError("max_context_window_tokens must be positive.")
|
||||
if max_output_tokens < 0:
|
||||
raise ValueError("max_output_tokens must be non-negative.")
|
||||
if max_output_tokens >= max_context_window_tokens:
|
||||
raise ValueError("max_output_tokens must be less than max_context_window_tokens.")
|
||||
|
||||
# Build history provider.
|
||||
resolved_history = history_provider or InMemoryHistoryProvider()
|
||||
|
||||
# Build compaction provider.
|
||||
compaction_provider = _assemble_compaction_provider(
|
||||
disable_compaction=disable_compaction,
|
||||
max_context_window_tokens=max_context_window_tokens,
|
||||
max_output_tokens=max_output_tokens,
|
||||
history_source_id=resolved_history.source_id,
|
||||
before_compaction_strategy=before_compaction_strategy,
|
||||
after_compaction_strategy=after_compaction_strategy,
|
||||
tokenizer=tokenizer,
|
||||
)
|
||||
|
||||
# Build context providers.
|
||||
assembled_providers = _assemble_context_providers(
|
||||
history_provider=resolved_history,
|
||||
compaction_provider=compaction_provider,
|
||||
disable_todo=disable_todo,
|
||||
todo_provider=todo_provider,
|
||||
disable_mode=disable_mode,
|
||||
mode_provider=mode_provider,
|
||||
disable_memory=disable_memory,
|
||||
memory_store=memory_store,
|
||||
skills_provider=skills_provider,
|
||||
skills_paths=skills_paths,
|
||||
extra_context_providers=context_providers,
|
||||
)
|
||||
|
||||
# Build instructions.
|
||||
instructions = _assemble_instructions(harness_instructions, agent_instructions)
|
||||
|
||||
# Assemble tools, auto-adding web search if supported.
|
||||
assembled_tools: list[ToolTypes | Callable[..., Any]] = []
|
||||
if not disable_web_search:
|
||||
if isinstance(client, SupportsWebSearchTool):
|
||||
assembled_tools.append(client.get_web_search_tool())
|
||||
else:
|
||||
logger.warning(
|
||||
"Web search tool not available: client %r does not implement SupportsWebSearchTool. "
|
||||
"Set disable_web_search=True to suppress this warning.",
|
||||
type(client).__name__,
|
||||
)
|
||||
if tools is not None:
|
||||
if isinstance(tools, Sequence):
|
||||
assembled_tools.extend(tools) # pyright: ignore[reportUnknownArgumentType]
|
||||
else:
|
||||
assembled_tools.append(tools)
|
||||
final_tools: list[ToolTypes | Callable[..., Any]] | None = assembled_tools or None
|
||||
|
||||
# Build default options dict.
|
||||
default_opts: dict[str, Any] = dict(default_options) if default_options else {}
|
||||
default_opts.setdefault("max_tokens", max_output_tokens)
|
||||
|
||||
agent = Agent(
|
||||
client,
|
||||
instructions,
|
||||
id=id,
|
||||
name=name,
|
||||
description=description,
|
||||
tools=final_tools,
|
||||
default_options=default_opts, # type: ignore[arg-type]
|
||||
context_providers=assembled_providers,
|
||||
middleware=list(middleware) if middleware else None,
|
||||
require_per_service_call_history_persistence=True,
|
||||
)
|
||||
|
||||
# Set the telemetry provider name after construction.
|
||||
agent.otel_provider_name = otel_provider_name or HARNESS_AGENT_PROVIDER_NAME
|
||||
|
||||
return agent
|
||||
@@ -1,521 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""BackgroundAgentsProvider: enables an agent to delegate work to background sub-agents asynchronously.
|
||||
|
||||
This module provides :class:`BackgroundAgentsProvider`, a context provider that allows
|
||||
a parent agent to start background tasks on child agents, wait for their completion,
|
||||
and retrieve results. Each background task runs in its own session concurrently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, MutableMapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, ClassVar, cast
|
||||
|
||||
from .._agents import SupportsAgentRun
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._serialization import SerializationMixin
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from .._tools import tool
|
||||
from .._types import AgentResponse, Message
|
||||
|
||||
DEFAULT_BACKGROUND_AGENTS_SOURCE_ID = "background_agents"
|
||||
|
||||
DEFAULT_BACKGROUND_AGENTS_INSTRUCTIONS = """\
|
||||
## Background Agents
|
||||
|
||||
You have access to background agents that can perform work on your behalf.
|
||||
|
||||
- Use the `background_agents_*` tools to start tasks on background agents and check their results.
|
||||
- Creating a background task does not block, and background tasks run concurrently.
|
||||
- Important: Always wait for outstanding tasks to finish before you finish processing.
|
||||
- Important: After retrieving results from a completed task, clear it with \
|
||||
background_agents_clear_completed_task to free memory, unless you plan to continue it with \
|
||||
background_agents_continue_task.
|
||||
|
||||
{background_agents}"""
|
||||
|
||||
|
||||
class BackgroundTaskStatus(str, Enum):
|
||||
"""Status of a background task."""
|
||||
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
LOST = "lost"
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class BackgroundTaskInfo(SerializationMixin):
|
||||
"""Metadata for a single background task."""
|
||||
|
||||
DEFAULT_EXCLUDE: ClassVar[set[str]] = set()
|
||||
|
||||
id: int
|
||||
agent_name: str
|
||||
description: str
|
||||
status: BackgroundTaskStatus
|
||||
result_text: str | None
|
||||
error_text: str | None
|
||||
__slots__ = ("agent_name", "description", "error_text", "id", "result_text", "status")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: int,
|
||||
agent_name: str,
|
||||
description: str,
|
||||
status: BackgroundTaskStatus = BackgroundTaskStatus.RUNNING,
|
||||
result_text: str | None = None,
|
||||
error_text: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize a background task info entry."""
|
||||
self.id = id
|
||||
self.agent_name = agent_name
|
||||
self.description = description
|
||||
self.status = status
|
||||
self.result_text = result_text
|
||||
self.error_text = error_text
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Serialize for session state persistence."""
|
||||
del exclude
|
||||
data: dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"agent_name": self.agent_name,
|
||||
"description": self.description,
|
||||
"status": self.status.value,
|
||||
}
|
||||
if not exclude_none or self.result_text is not None:
|
||||
data["result_text"] = self.result_text
|
||||
if not exclude_none or self.error_text is not None:
|
||||
data["error_text"] = self.error_text
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: MutableMapping[str, Any], **kwargs: Any) -> BackgroundTaskInfo:
|
||||
"""Deserialize from session state."""
|
||||
return cls(
|
||||
id=data["id"],
|
||||
agent_name=data["agent_name"],
|
||||
description=data["description"],
|
||||
status=BackgroundTaskStatus(data["status"]),
|
||||
result_text=data.get("result_text"),
|
||||
error_text=data.get("error_text"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RuntimeState:
|
||||
"""Non-serializable per-session runtime state for background tasks."""
|
||||
|
||||
in_flight_tasks: dict[int, asyncio.Task[AgentResponse[Any]]] = field(
|
||||
default_factory=lambda: {} # pyright: ignore[reportUnknownLambdaType]
|
||||
)
|
||||
background_sessions: dict[int, AgentSession] = field(
|
||||
default_factory=lambda: {} # pyright: ignore[reportUnknownLambdaType]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level helper functions (following ModeProvider pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _run_agent(awaitable: Awaitable[AgentResponse[Any]]) -> AgentResponse[Any]:
|
||||
"""Wrap an Awaitable in a proper coroutine for use with asyncio.create_task."""
|
||||
return await awaitable
|
||||
|
||||
|
||||
def _validate_and_build_agent_dict(agents: Sequence[SupportsAgentRun]) -> dict[str, SupportsAgentRun]:
|
||||
"""Validate agents and build a case-insensitive lookup dict.
|
||||
|
||||
Raises:
|
||||
ValueError: If agents is empty, an agent has no name, or names are not unique.
|
||||
"""
|
||||
if not agents:
|
||||
raise ValueError("At least one background agent must be provided.")
|
||||
|
||||
agent_dict: dict[str, SupportsAgentRun] = {}
|
||||
for agent in agents:
|
||||
name = agent.name
|
||||
if not name or not name.strip():
|
||||
raise ValueError("All background agents must have a non-empty name.")
|
||||
key = name.lower()
|
||||
if key in agent_dict:
|
||||
raise ValueError(
|
||||
f"Duplicate background agent name: '{name}'. Agent names must be unique (case-insensitive)."
|
||||
)
|
||||
agent_dict[key] = agent
|
||||
return agent_dict
|
||||
|
||||
|
||||
def _build_agent_list_text(agents: dict[str, SupportsAgentRun]) -> str:
|
||||
"""Build text listing available background agents."""
|
||||
lines = ["Available background agents:"]
|
||||
for agent in agents.values():
|
||||
line = f"- {agent.name}"
|
||||
if agent.description:
|
||||
line += f": {agent.description}"
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _get_provider_state(session: AgentSession, *, source_id: str) -> dict[str, Any]:
|
||||
"""Load or initialize serializable provider state from session."""
|
||||
state = session.state.get(source_id)
|
||||
if state is None:
|
||||
initial: dict[str, Any] = {"next_task_id": 1, "tasks": []}
|
||||
session.state[source_id] = initial
|
||||
return initial
|
||||
return cast(dict[str, Any], state)
|
||||
|
||||
|
||||
def _save_provider_state(session: AgentSession, state: dict[str, Any], *, source_id: str) -> None:
|
||||
"""Persist serializable state to session."""
|
||||
session.state[source_id] = state
|
||||
|
||||
|
||||
def _get_tasks(state: dict[str, Any]) -> list[BackgroundTaskInfo]:
|
||||
"""Parse task list from state dict."""
|
||||
return [BackgroundTaskInfo.from_dict(t) for t in state.get("tasks", [])]
|
||||
|
||||
|
||||
def _save_tasks(state: dict[str, Any], tasks: list[BackgroundTaskInfo]) -> None:
|
||||
"""Serialize task list back to state dict."""
|
||||
state["tasks"] = [t.to_dict() for t in tasks]
|
||||
|
||||
|
||||
def _finalize_task(
|
||||
task_info: BackgroundTaskInfo,
|
||||
completed_task: asyncio.Task[AgentResponse[Any]],
|
||||
runtime: _RuntimeState,
|
||||
) -> None:
|
||||
"""Extract results from a completed asyncio task and update task info."""
|
||||
if completed_task.cancelled():
|
||||
task_info.status = BackgroundTaskStatus.FAILED
|
||||
task_info.error_text = "Task was canceled."
|
||||
else:
|
||||
exception = completed_task.exception()
|
||||
if exception is not None:
|
||||
task_info.status = BackgroundTaskStatus.FAILED
|
||||
task_info.error_text = str(exception)
|
||||
else:
|
||||
task_info.status = BackgroundTaskStatus.COMPLETED
|
||||
task_info.result_text = completed_task.result().text
|
||||
runtime.in_flight_tasks.pop(task_info.id, None)
|
||||
|
||||
|
||||
def _refresh_task_state(
|
||||
session: AgentSession, state: dict[str, Any], runtime: _RuntimeState, *, source_id: str
|
||||
) -> list[BackgroundTaskInfo]:
|
||||
"""Refresh status of in-flight tasks and return updated task list."""
|
||||
tasks = _get_tasks(state)
|
||||
changed = False
|
||||
|
||||
for task_info in tasks:
|
||||
if task_info.status != BackgroundTaskStatus.RUNNING:
|
||||
continue
|
||||
|
||||
in_flight = runtime.in_flight_tasks.get(task_info.id)
|
||||
if in_flight is None:
|
||||
task_info.status = BackgroundTaskStatus.LOST
|
||||
changed = True
|
||||
continue
|
||||
|
||||
if in_flight.done():
|
||||
_finalize_task(task_info, in_flight, runtime)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
_save_tasks(state, tasks)
|
||||
_save_provider_state(session, state, source_id=source_id)
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class BackgroundAgentsProvider(ContextProvider):
|
||||
"""Context provider that enables an agent to delegate work to background sub-agents.
|
||||
|
||||
The ``BackgroundAgentsProvider`` allows a parent agent to start background tasks on child agents,
|
||||
wait for their completion, and retrieve results. Each background task runs in its own session and
|
||||
executes concurrently.
|
||||
|
||||
This provider exposes the following tools to the agent:
|
||||
|
||||
- ``background_agents_start_task`` — Start a background task on a named agent with text input.
|
||||
- ``background_agents_wait_for_first_completion`` — Block until the first of the specified tasks completes.
|
||||
- ``background_agents_get_task_results`` — Retrieve the text output of a completed background task.
|
||||
- ``background_agents_get_all_tasks`` — List all background tasks with their IDs, statuses, and descriptions.
|
||||
- ``background_agents_continue_task`` — Send follow-up input to a completed task's session to resume work.
|
||||
- ``background_agents_clear_completed_task`` — Remove a completed task and release its session.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agents: Sequence[SupportsAgentRun],
|
||||
*,
|
||||
source_id: str = DEFAULT_BACKGROUND_AGENTS_SOURCE_ID,
|
||||
instructions: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the background agents provider.
|
||||
|
||||
Args:
|
||||
agents: Collection of background agents available for delegation.
|
||||
Each agent must have a non-empty, unique name (case-insensitive).
|
||||
|
||||
Keyword Args:
|
||||
source_id: Unique source ID for serializable task state in session.
|
||||
instructions: Optional instruction override. May include ``{background_agents}``
|
||||
placeholder which will be replaced with the agent listing.
|
||||
|
||||
Raises:
|
||||
ValueError: If agents is empty, an agent has no name, or names are not unique.
|
||||
"""
|
||||
super().__init__(source_id)
|
||||
|
||||
self._agents = _validate_and_build_agent_dict(agents)
|
||||
|
||||
# Build instructions with agent listing.
|
||||
base_instructions = instructions if instructions is not None else DEFAULT_BACKGROUND_AGENTS_INSTRUCTIONS
|
||||
agent_list_text = _build_agent_list_text(self._agents)
|
||||
self._instructions = base_instructions.replace("{background_agents}", agent_list_text)
|
||||
|
||||
# Per-session runtime state (non-serializable), keyed by session_id.
|
||||
# Note: Runtime state (in-flight asyncio.Task objects, child AgentSession handles)
|
||||
# is inherently non-serializable and cannot survive process restarts. If the provider
|
||||
# instance is lost, _refresh_task_state() marks orphaned tasks as LOST.
|
||||
self._runtime: dict[str, _RuntimeState] = {}
|
||||
|
||||
def _get_runtime(self, session: AgentSession) -> _RuntimeState:
|
||||
"""Get or create runtime state for a session."""
|
||||
session_id = session.session_id
|
||||
if session_id not in self._runtime:
|
||||
self._runtime[session_id] = _RuntimeState()
|
||||
return self._runtime[session_id]
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inject background agent tools and instructions before the model runs."""
|
||||
del agent, state
|
||||
|
||||
provider_state = _get_provider_state(session, source_id=self.source_id)
|
||||
runtime = self._get_runtime(session)
|
||||
source_id = self.source_id
|
||||
|
||||
@tool(name="background_agents_start_task", approval_mode="never_require")
|
||||
def background_agents_start_task(agent_name: str, input: str, description: str) -> str:
|
||||
"""Start a background task on a named agent. Returns a confirmation with the task ID."""
|
||||
key = agent_name.lower()
|
||||
if key not in self._agents:
|
||||
available = ", ".join(a.name or "" for a in self._agents.values())
|
||||
return f"Error: No background agent found with name '{agent_name}'. Available agents: {available}"
|
||||
|
||||
bg_agent = self._agents[key]
|
||||
task_id = provider_state.get("next_task_id", 1)
|
||||
provider_state["next_task_id"] = task_id + 1
|
||||
|
||||
task_info = BackgroundTaskInfo(
|
||||
id=task_id,
|
||||
agent_name=agent_name,
|
||||
description=description,
|
||||
)
|
||||
tasks = _get_tasks(provider_state)
|
||||
tasks.append(task_info)
|
||||
_save_tasks(provider_state, tasks)
|
||||
|
||||
# Create a dedicated session for this background task.
|
||||
sub_session = bg_agent.create_session()
|
||||
|
||||
# Start the task concurrently.
|
||||
async_task = asyncio.create_task(_run_agent(bg_agent.run(input, session=sub_session)))
|
||||
runtime.in_flight_tasks[task_id] = async_task
|
||||
runtime.background_sessions[task_id] = sub_session
|
||||
|
||||
_save_provider_state(session, provider_state, source_id=source_id)
|
||||
return f"Background task {task_id} started on agent '{agent_name}'."
|
||||
|
||||
@tool(name="background_agents_wait_for_first_completion", approval_mode="never_require")
|
||||
async def background_agents_wait_for_first_completion(task_ids: list[int]) -> str:
|
||||
"""Block until the first of the specified background tasks completes. Returns the completed task's ID."""
|
||||
if not task_ids:
|
||||
return "Error: No task IDs provided."
|
||||
|
||||
# Collect in-flight tasks matching the requested IDs.
|
||||
waitable: list[tuple[int, asyncio.Task[AgentResponse[Any]]]] = []
|
||||
for tid in task_ids:
|
||||
in_flight = runtime.in_flight_tasks.get(tid)
|
||||
if in_flight is not None:
|
||||
waitable.append((tid, in_flight))
|
||||
|
||||
if not waitable:
|
||||
# Refresh state to catch any that completed.
|
||||
tasks = _refresh_task_state(session, provider_state, runtime, source_id=source_id)
|
||||
already_complete = next(
|
||||
(t for t in tasks if t.id in task_ids and t.status != BackgroundTaskStatus.RUNNING), None
|
||||
)
|
||||
if already_complete is not None:
|
||||
return (
|
||||
f"Task {already_complete.id} is not running; current status: {already_complete.status.value}."
|
||||
)
|
||||
return "Error: None of the specified task IDs correspond to running tasks."
|
||||
|
||||
# Wait for the first one to complete.
|
||||
done, _ = await asyncio.wait(
|
||||
[t for _, t in waitable],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Find which ID completed.
|
||||
completed_id: int | None = None
|
||||
for tid, task in waitable:
|
||||
if task in done:
|
||||
completed_id = tid
|
||||
break
|
||||
|
||||
# Finalize the completed task.
|
||||
tasks = _get_tasks(provider_state)
|
||||
task_info = next((t for t in tasks if t.id == completed_id), None)
|
||||
if task_info is not None and completed_id is not None:
|
||||
completed_task = runtime.in_flight_tasks.get(completed_id)
|
||||
if completed_task is not None:
|
||||
_finalize_task(task_info, completed_task, runtime)
|
||||
_save_tasks(provider_state, tasks)
|
||||
_save_provider_state(session, provider_state, source_id=source_id)
|
||||
|
||||
status_str = task_info.status.value if task_info else "Unknown"
|
||||
return f"Task {completed_id} finished with status: {status_str}."
|
||||
|
||||
@tool(name="background_agents_get_task_results", approval_mode="never_require")
|
||||
def background_agents_get_task_results(task_id: int) -> str:
|
||||
"""Get the text output of a background task by its ID."""
|
||||
tasks = _refresh_task_state(session, provider_state, runtime, source_id=source_id)
|
||||
task_info = next((t for t in tasks if t.id == task_id), None)
|
||||
|
||||
if task_info is None:
|
||||
return f"Error: No task found with ID {task_id}."
|
||||
|
||||
if task_info.status == BackgroundTaskStatus.COMPLETED:
|
||||
return task_info.result_text or "(no output)"
|
||||
if task_info.status == BackgroundTaskStatus.FAILED:
|
||||
return f"Task failed: {task_info.error_text or 'Unknown error'}"
|
||||
if task_info.status == BackgroundTaskStatus.LOST:
|
||||
return "Task state was lost (reference unavailable)."
|
||||
if task_info.status == BackgroundTaskStatus.RUNNING:
|
||||
return f"Task {task_id} is still running."
|
||||
return f"Task {task_id} has status: {task_info.status.value}."
|
||||
|
||||
@tool(name="background_agents_get_all_tasks", approval_mode="never_require")
|
||||
def background_agents_get_all_tasks() -> str:
|
||||
"""List all background tasks with their IDs, statuses, agent names, and descriptions."""
|
||||
tasks = _refresh_task_state(session, provider_state, runtime, source_id=source_id)
|
||||
|
||||
if not tasks:
|
||||
return "No tasks."
|
||||
|
||||
lines = ["Tasks:"]
|
||||
for t in tasks:
|
||||
lines.append(f"- Task {t.id} [{t.status.value}] ({t.agent_name}): {t.description}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@tool(name="background_agents_continue_task", approval_mode="never_require")
|
||||
def background_agents_continue_task(task_id: int, text: str) -> str:
|
||||
"""Send follow-up input to a completed or failed task to resume its work."""
|
||||
tasks = _refresh_task_state(session, provider_state, runtime, source_id=source_id)
|
||||
task_info = next((t for t in tasks if t.id == task_id), None)
|
||||
|
||||
if task_info is None:
|
||||
return f"Error: No task found with ID {task_id}."
|
||||
|
||||
if task_info.status == BackgroundTaskStatus.LOST:
|
||||
return (
|
||||
f"Error: Task {task_id} cannot be continued because its session was lost. Start a new task instead."
|
||||
)
|
||||
|
||||
if task_info.status == BackgroundTaskStatus.RUNNING:
|
||||
return f"Error: Task {task_id} is still running. Wait for it to complete before continuing."
|
||||
|
||||
key = task_info.agent_name.lower()
|
||||
if key not in self._agents:
|
||||
return f"Error: Agent '{task_info.agent_name}' is no longer available."
|
||||
|
||||
sub_session = runtime.background_sessions.get(task_id)
|
||||
if sub_session is None:
|
||||
return f"Error: Session for task {task_id} is no longer available."
|
||||
|
||||
bg_agent = self._agents[key]
|
||||
|
||||
# Reset task state and start a new run on the existing session.
|
||||
task_info.status = BackgroundTaskStatus.RUNNING
|
||||
task_info.result_text = None
|
||||
task_info.error_text = None
|
||||
_save_tasks(provider_state, tasks)
|
||||
|
||||
async_task = asyncio.create_task(_run_agent(bg_agent.run(text, session=sub_session)))
|
||||
runtime.in_flight_tasks[task_id] = async_task
|
||||
|
||||
_save_provider_state(session, provider_state, source_id=source_id)
|
||||
return f"Task {task_id} continued with new input."
|
||||
|
||||
@tool(name="background_agents_clear_completed_task", approval_mode="never_require")
|
||||
def background_agents_clear_completed_task(task_id: int) -> str:
|
||||
"""Remove a completed or failed task and release its session to free memory."""
|
||||
tasks = _refresh_task_state(session, provider_state, runtime, source_id=source_id)
|
||||
task_info = next((t for t in tasks if t.id == task_id), None)
|
||||
|
||||
if task_info is None:
|
||||
return f"Error: No task found with ID {task_id}."
|
||||
|
||||
if task_info.status == BackgroundTaskStatus.RUNNING:
|
||||
return f"Error: Task {task_id} is still running. Wait for it to complete before clearing."
|
||||
|
||||
# Remove the task from state.
|
||||
tasks = [t for t in tasks if t.id != task_id]
|
||||
_save_tasks(provider_state, tasks)
|
||||
|
||||
# Clean up runtime references.
|
||||
runtime.in_flight_tasks.pop(task_id, None)
|
||||
runtime.background_sessions.pop(task_id, None)
|
||||
|
||||
_save_provider_state(session, provider_state, source_id=source_id)
|
||||
return f"Task {task_id} cleared."
|
||||
|
||||
# Inject instructions and current task status.
|
||||
context.extend_instructions(self.source_id, [self._instructions])
|
||||
context.extend_tools(
|
||||
self.source_id,
|
||||
[
|
||||
background_agents_start_task,
|
||||
background_agents_wait_for_first_completion,
|
||||
background_agents_get_task_results,
|
||||
background_agents_get_all_tasks,
|
||||
background_agents_continue_task,
|
||||
background_agents_clear_completed_task,
|
||||
],
|
||||
)
|
||||
|
||||
# Include current task status as context message if there are tasks.
|
||||
# Refresh first to get accurate statuses for any tasks that completed between turns.
|
||||
tasks = _refresh_task_state(session, provider_state, runtime, source_id=source_id)
|
||||
if tasks:
|
||||
status_lines = ["### Current background tasks"]
|
||||
for t in tasks:
|
||||
status_lines.append(f"- Task {t.id} [{t.status.value}] ({t.agent_name}): {t.description}")
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[Message(role="user", contents=["\n".join(status_lines)])],
|
||||
)
|
||||
@@ -14,19 +14,14 @@ from .._types import Message
|
||||
DEFAULT_MODE_SOURCE_ID = "agent_mode"
|
||||
DEFAULT_MODE_INSTRUCTIONS = (
|
||||
"## Agent Mode\n\n"
|
||||
"- You can operate in different modes. Depending on the mode you are in, "
|
||||
"you will be required to follow different processes.\n"
|
||||
"- You must check the current mode after any user input, since the user may have changed the mode themselves, "
|
||||
"e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, "
|
||||
"meaning they want to review a plan first before execution.\n\n"
|
||||
"Use the mode_get tool to check your current operating mode.\n"
|
||||
"Use the mode_set tool to switch between modes as your work progresses. "
|
||||
"Only use mode_set if the user explicitly instructs/allows you to change modes.\n\n"
|
||||
"You are currently operating in the {current_mode} mode.\n\n"
|
||||
"### Mandatory Mode based Workflow\n\n"
|
||||
"For every new substantive user request, including short factual questions, "
|
||||
"your behavior is determined by the mode you are in.\n\n"
|
||||
"You can operate in different modes. Depending on the mode you are in, "
|
||||
"you will be required to follow different processes.\n\n"
|
||||
"Use the get_mode tool to check your current operating mode.\n"
|
||||
"Use the set_mode tool to switch between modes as your work progresses. "
|
||||
"Only use set_mode if the user explicitly instructs/allows you to change modes.\n\n"
|
||||
"{available_modes}\n"
|
||||
"\n"
|
||||
"You are currently operating in the {current_mode} mode.\n"
|
||||
)
|
||||
DEFAULT_MODE_CHANGE_NOTIFICATION = (
|
||||
'[Mode changed: The operating mode has been switched from "{previous_mode}" to "{current_mode}". '
|
||||
@@ -36,37 +31,13 @@ DEFAULT_MODE_DESCRIPTIONS: dict[str, str] = {
|
||||
"plan": (
|
||||
"Use this mode when analyzing requirements, breaking down tasks, and creating plans. "
|
||||
"This is the interactive mode — ask clarifying questions, discuss options, and get user approval before "
|
||||
"proceeding.\n\n"
|
||||
"Process to follow when in plan mode:\n"
|
||||
"1. Analyze the request with the purpose of building a research plan.\n"
|
||||
"2. Create a list of todo items.\n"
|
||||
"3. If needed, use the provided tools to do some exploratory checks to help build a plan and determine "
|
||||
"what clarifying questions you may need from the user.\n"
|
||||
"4. Ask for clarifications from the user where needed.\n"
|
||||
" 1. Ask each clarification one by one.\n"
|
||||
" 2. When asking for clarification and you have specific options in mind, present them to the user, "
|
||||
"so they can choose the option instead of having to retype the entire response.\n"
|
||||
" 3. Do not proceed until you have received all the needed clarifications.\n"
|
||||
" 4. Do short exploratory research if it helps with being able to ask sensible clarifications from "
|
||||
"the user.\n"
|
||||
"5. Write the plan to a memory file, so that it is retained even if compaction happens. "
|
||||
"Make sure to update the plan file if the user requests changes.\n"
|
||||
"6. Present the plan to the user and ask for approval to switch to execute mode and process the plan.\n"
|
||||
"7. When approval is granted, always switch to execute mode (using the `mode_set` tool), "
|
||||
"and follow the steps for *Execute mode*."
|
||||
"proceeding."
|
||||
),
|
||||
"execute": (
|
||||
"Use this mode when carrying out approved plans. Work autonomously using your best judgment — do not ask "
|
||||
"the user questions or wait for feedback.\n\n"
|
||||
"Process to follow when in execute mode:\n"
|
||||
"1. If you don't have a plan or tasks yet, analyze the user request and create tasks and a plan. "
|
||||
"(**Skip this step if you came from plan mode**)\n"
|
||||
"2. Work autonomously — use your best judgment to make decisions and keep progressing without asking "
|
||||
"the user questions. The goal is to have a complete, useful result ready when the user returns.\n"
|
||||
"3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable "
|
||||
"option, note your choice, and keep going.\n"
|
||||
"4. Mark tasks as completed as you finish them.\n"
|
||||
"5. Continue working, thinking and calling tools until you have the research result for the user."
|
||||
"Use this mode when carrying out approved plans. Work autonomously using your best judgement — do not ask "
|
||||
"the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, "
|
||||
"useful result when the user returns. If you encounter ambiguity, choose the most reasonable option and note "
|
||||
"your choice."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -208,8 +179,8 @@ class AgentModeProvider(ContextProvider):
|
||||
``"plan"`` (interactive planning) and ``"execute"`` (autonomous execution).
|
||||
|
||||
This provider exposes the following tools to the agent:
|
||||
- ``mode_set``: Switch the agent's operating mode.
|
||||
- ``mode_get``: Retrieve the agent's current operating mode.
|
||||
- ``set_mode``: Switch the agent's operating mode.
|
||||
- ``get_mode``: Retrieve the agent's current operating mode.
|
||||
|
||||
Public helper functions ``get_agent_mode`` and ``set_agent_mode`` allow external code to programmatically read
|
||||
and change the mode.
|
||||
@@ -252,7 +223,7 @@ class AgentModeProvider(ContextProvider):
|
||||
def _build_instructions(self, current_mode: str) -> str:
|
||||
"""Build the mode guidance injected for the current session."""
|
||||
mode_lines = "".join(
|
||||
f"#### {self._mode_display_names[mode]}\n\n{description}\n\n"
|
||||
f'- "{self._mode_display_names[mode]}": {description}\n'
|
||||
for mode, description in self.mode_descriptions.items()
|
||||
)
|
||||
instructions = self.instructions or DEFAULT_MODE_INSTRUCTIONS
|
||||
@@ -286,8 +257,8 @@ class AgentModeProvider(ContextProvider):
|
||||
provider_state = _get_mode_state(session, source_id=self.source_id)
|
||||
previous_mode = provider_state.pop(_PREVIOUS_MODE_STATE_KEY, None)
|
||||
|
||||
@tool(name="mode_set", approval_mode="never_require")
|
||||
def mode_set(mode: str) -> str:
|
||||
@tool(name="set_mode", approval_mode="never_require")
|
||||
def set_mode(mode: str) -> str:
|
||||
"""Switch the agent's operating mode."""
|
||||
# The agent invoked the tool itself, so it knows the mode just changed — bypass
|
||||
# ``set_agent_mode`` to avoid triggering a notification message on the next turn.
|
||||
@@ -296,8 +267,8 @@ class AgentModeProvider(ContextProvider):
|
||||
tool_state["current_mode"] = normalized_mode
|
||||
return json.dumps({"mode": normalized_mode, "message": f"Mode changed to '{normalized_mode}'."})
|
||||
|
||||
@tool(name="mode_get", approval_mode="never_require")
|
||||
def mode_get() -> str:
|
||||
@tool(name="get_mode", approval_mode="never_require")
|
||||
def get_mode() -> str:
|
||||
"""Get the agent's current operating mode."""
|
||||
current_mode_value = get_agent_mode(
|
||||
session,
|
||||
@@ -311,11 +282,11 @@ class AgentModeProvider(ContextProvider):
|
||||
self.source_id,
|
||||
[self._build_instructions(current_mode)],
|
||||
)
|
||||
context.extend_tools(self.source_id, [mode_set, mode_get])
|
||||
context.extend_tools(self.source_id, [set_mode, get_mode])
|
||||
if isinstance(previous_mode, str) and previous_mode != current_mode:
|
||||
# Inject a user-role message announcing the external mode change. System instructions
|
||||
# always render first in the chat history, so the agent can otherwise stay anchored to
|
||||
# the most recent ``mode_set`` tool call rather than the new mode.
|
||||
# the most recent ``set_mode`` tool call rather than the new mode.
|
||||
previous_display = self._mode_display_names.get(previous_mode, previous_mode)
|
||||
current_display = self._mode_display_names.get(current_mode, current_mode)
|
||||
notification = DEFAULT_MODE_CHANGE_NOTIFICATION.format(
|
||||
|
||||
@@ -54,8 +54,6 @@ class WorkflowAgent(BaseAgent):
|
||||
|
||||
# Class variable for the request info function name
|
||||
REQUEST_INFO_FUNCTION_NAME: ClassVar[str] = "request_info"
|
||||
_SESSION_STATE_KEY: ClassVar[str] = "workflow_agent"
|
||||
_PENDING_REQUESTS_STATE_KEY: ClassVar[str] = "pending_request_info_events"
|
||||
|
||||
@dataclass
|
||||
class RequestInfoFunctionArgs:
|
||||
@@ -260,7 +258,6 @@ class WorkflowAgent(BaseAgent):
|
||||
An AgentResponse representing the workflow execution results.
|
||||
"""
|
||||
input_messages = normalize_messages_input(messages)
|
||||
self._restore_pending_requests_from_session(session)
|
||||
|
||||
if (
|
||||
not any(
|
||||
@@ -294,11 +291,10 @@ class WorkflowAgent(BaseAgent):
|
||||
)
|
||||
# combine the messages
|
||||
session_messages: list[Message] = session_context.get_messages(include_input=True)
|
||||
workflow_input_messages = input_messages if bool(self.pending_requests) else session_messages
|
||||
|
||||
output_events: list[WorkflowEvent[Any]] = []
|
||||
async for event in self._run_core(
|
||||
workflow_input_messages,
|
||||
session_messages,
|
||||
checkpoint_id,
|
||||
checkpoint_storage,
|
||||
streaming=False,
|
||||
@@ -315,7 +311,6 @@ class WorkflowAgent(BaseAgent):
|
||||
session_context._response = result # type: ignore[assignment]
|
||||
|
||||
await self._run_after_providers(session=provider_session, context=session_context)
|
||||
self._persist_pending_requests_to_session(session)
|
||||
return result
|
||||
|
||||
async def _run_stream_impl(
|
||||
@@ -343,7 +338,6 @@ class WorkflowAgent(BaseAgent):
|
||||
AgentResponseUpdate objects representing the workflow execution progress.
|
||||
"""
|
||||
input_messages = normalize_messages_input(messages)
|
||||
self._restore_pending_requests_from_session(session)
|
||||
|
||||
if (
|
||||
not any(
|
||||
@@ -378,10 +372,9 @@ class WorkflowAgent(BaseAgent):
|
||||
# combine the messages
|
||||
|
||||
session_messages: list[Message] = session_context.get_messages(include_input=True)
|
||||
workflow_input_messages = input_messages if bool(self.pending_requests) else session_messages
|
||||
all_updates: list[AgentResponseUpdate] = []
|
||||
async for event in self._run_core(
|
||||
workflow_input_messages,
|
||||
session_messages,
|
||||
checkpoint_id,
|
||||
checkpoint_storage,
|
||||
streaming=True,
|
||||
@@ -399,7 +392,6 @@ class WorkflowAgent(BaseAgent):
|
||||
session_context._response = AgentResponse.from_updates(all_updates) # type: ignore[assignment]
|
||||
|
||||
await self._run_after_providers(session=provider_session, context=session_context)
|
||||
self._persist_pending_requests_to_session(session)
|
||||
|
||||
async def _run_core(
|
||||
self,
|
||||
@@ -433,8 +425,6 @@ class WorkflowAgent(BaseAgent):
|
||||
async for event in self.workflow.run(
|
||||
responses=function_responses,
|
||||
stream=True,
|
||||
checkpoint_id=checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
@@ -442,8 +432,6 @@ class WorkflowAgent(BaseAgent):
|
||||
else:
|
||||
for event in await self.workflow.run(
|
||||
responses=function_responses,
|
||||
checkpoint_id=checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
@@ -496,60 +484,6 @@ class WorkflowAgent(BaseAgent):
|
||||
|
||||
# endregion Run Methods
|
||||
|
||||
def _restore_pending_requests_from_session(self, session: AgentSession | None) -> None:
|
||||
"""Load pending request-info events from the session state."""
|
||||
if session is None:
|
||||
return
|
||||
|
||||
agent_state = session.state.get(self._SESSION_STATE_KEY)
|
||||
if not isinstance(agent_state, dict):
|
||||
self.pending_requests.clear()
|
||||
return
|
||||
|
||||
pending_requests_payload = agent_state.get(self._PENDING_REQUESTS_STATE_KEY)
|
||||
if not isinstance(pending_requests_payload, dict):
|
||||
self.pending_requests.clear()
|
||||
return
|
||||
|
||||
restored_pending: dict[str, WorkflowEvent[Any]] = {}
|
||||
for request_id, request_payload in pending_requests_payload.items():
|
||||
if isinstance(request_payload, WorkflowEvent):
|
||||
restored_pending[request_id] = request_payload
|
||||
continue
|
||||
|
||||
if not isinstance(request_payload, dict):
|
||||
logger.warning("Skipping malformed pending request payload for request_id '%s'.", request_id)
|
||||
continue
|
||||
|
||||
try:
|
||||
restored_pending[request_id] = WorkflowEvent.from_dict(request_payload)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
"Failed to restore pending request payload for request_id '%s': %s",
|
||||
request_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
self.pending_requests.clear()
|
||||
self.pending_requests.update(restored_pending)
|
||||
|
||||
def _persist_pending_requests_to_session(self, session: AgentSession | None) -> None:
|
||||
"""Persist pending request-info events to the session state."""
|
||||
if session is None:
|
||||
return
|
||||
|
||||
agent_state = session.state.setdefault(self._SESSION_STATE_KEY, {})
|
||||
if not isinstance(agent_state, dict):
|
||||
logger.warning(
|
||||
"Skipping pending request persistence because '%s' is not a mapping.",
|
||||
self._SESSION_STATE_KEY,
|
||||
)
|
||||
return
|
||||
|
||||
agent_state[self._PENDING_REQUESTS_STATE_KEY] = {
|
||||
request_id: event.to_dict() for request_id, event in self.pending_requests.items()
|
||||
}
|
||||
|
||||
def _process_pending_requests(self, input_messages: Sequence[Message]) -> dict[str, Any]:
|
||||
"""Process pending requests by extracting function responses and updating state.
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ _IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"RawFoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"evaluate_foundry_target": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"evaluate_traces": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"to_prompt_agent": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ from agent_framework_foundry import (
|
||||
RawFoundryEmbeddingClient,
|
||||
evaluate_foundry_target,
|
||||
evaluate_traces,
|
||||
to_prompt_agent,
|
||||
)
|
||||
from agent_framework_foundry_local import (
|
||||
FoundryLocalChatOptions,
|
||||
@@ -59,5 +58,4 @@ __all__ = [
|
||||
"RawFoundryEmbeddingClient",
|
||||
"evaluate_foundry_target",
|
||||
"evaluate_traces",
|
||||
"to_prompt_agent",
|
||||
]
|
||||
|
||||
@@ -19,7 +19,6 @@ from agent_framework import (
|
||||
ChatResponse,
|
||||
CompactionProvider,
|
||||
Content,
|
||||
ContextWindowCompactionStrategy,
|
||||
Message,
|
||||
SelectiveToolCallCompactionStrategy,
|
||||
SlidingWindowStrategy,
|
||||
@@ -953,159 +952,3 @@ async def test_in_memory_history_provider_default_loads_all() -> None:
|
||||
|
||||
loaded = await provider.get_messages(session_id="test", state=state)
|
||||
assert len(loaded) == 3
|
||||
|
||||
|
||||
# --- ContextWindowCompactionStrategy tests ---
|
||||
|
||||
|
||||
async def test_context_window_strategy_noop_under_threshold() -> None:
|
||||
"""No compaction when total tokens are below 50% of input budget."""
|
||||
# input_budget = 1000 - 200 = 800; tool eviction threshold = 50% = 400 tokens
|
||||
# CharacterEstimatorTokenizer: 4 chars/token
|
||||
# Each short message ~4-5 tokens, total well under 400
|
||||
messages = [
|
||||
Message(role="system", contents=["sys"]),
|
||||
Message(role="user", contents=["hello"]),
|
||||
Message(role="assistant", contents=["hi"]),
|
||||
]
|
||||
strategy = ContextWindowCompactionStrategy(
|
||||
max_context_window_tokens=1000,
|
||||
max_output_tokens=200,
|
||||
)
|
||||
|
||||
changed = await strategy(messages)
|
||||
|
||||
assert changed is False
|
||||
assert len(included_messages(messages)) == 3
|
||||
|
||||
|
||||
async def test_context_window_strategy_tool_eviction_triggers_at_threshold() -> None:
|
||||
"""Tool eviction fires when tokens exceed 50% but truncation does not."""
|
||||
# input_budget = 20000 - 200 = 19800
|
||||
# tool eviction at 50% = 9900 tokens; truncation at 80% = 15840 tokens
|
||||
# CharacterEstimatorTokenizer: 4 chars/token
|
||||
# Each tool result: "x" * 8000 = 8000 chars = 2000 tokens
|
||||
# 5 groups * ~2000 = ~10000+ tokens (exceeds 9900, under 15840)
|
||||
# Tool eviction collapses older groups; truncation threshold not reached.
|
||||
messages = [
|
||||
Message(role="system", contents=["system prompt"]),
|
||||
Message(role="user", contents=["u1"]),
|
||||
_assistant_function_call("c1"),
|
||||
_tool_result("c1", "x" * 8000),
|
||||
Message(role="user", contents=["u2"]),
|
||||
_assistant_function_call("c2"),
|
||||
_tool_result("c2", "x" * 8000),
|
||||
Message(role="user", contents=["u3"]),
|
||||
_assistant_function_call("c3"),
|
||||
_tool_result("c3", "x" * 8000),
|
||||
Message(role="user", contents=["u4"]),
|
||||
_assistant_function_call("c4"),
|
||||
_tool_result("c4", "x" * 8000),
|
||||
Message(role="user", contents=["u5"]),
|
||||
_assistant_function_call("c5"),
|
||||
_tool_result("c5", "x" * 8000),
|
||||
]
|
||||
strategy = ContextWindowCompactionStrategy(
|
||||
max_context_window_tokens=20000,
|
||||
max_output_tokens=200,
|
||||
keep_last_tool_call_groups=2,
|
||||
)
|
||||
|
||||
changed = await strategy(messages)
|
||||
|
||||
assert changed is True
|
||||
projected = included_messages(messages)
|
||||
# Verify that tool results were compacted (summary messages present).
|
||||
summary_msgs = [m for m in projected if m.text and "[Tool results:" in m.text]
|
||||
assert len(summary_msgs) > 0
|
||||
# Verify that the truncation phase did NOT fire — no messages excluded with "truncation" reason.
|
||||
from agent_framework._compaction import EXCLUDE_REASON_KEY
|
||||
|
||||
truncation_excluded = [m for m in messages if m.additional_properties.get(EXCLUDE_REASON_KEY) == "truncation"]
|
||||
assert len(truncation_excluded) == 0
|
||||
|
||||
|
||||
async def test_context_window_strategy_truncation_triggers_above_80_pct() -> None:
|
||||
"""Truncation fires when tokens exceed 80% of input budget."""
|
||||
# input_budget = 1000 - 100 = 900
|
||||
# tool eviction at 50% = 450 tokens; truncation at 80% = 720 tokens
|
||||
# We'll create messages with no tool calls (so tool eviction does nothing)
|
||||
# but exceeding 720 tokens total (>2880 chars)
|
||||
messages = [
|
||||
Message(role="system", contents=["sys"]),
|
||||
Message(role="user", contents=["u1 " * 400]), # ~1200 chars = 300 tokens
|
||||
Message(role="assistant", contents=["a1 " * 400]), # ~1200 chars = 300 tokens
|
||||
Message(role="user", contents=["u2 " * 400]), # ~1200 chars = 300 tokens
|
||||
Message(role="assistant", contents=["a2 " * 400]), # ~1200 chars = 300 tokens
|
||||
]
|
||||
strategy = ContextWindowCompactionStrategy(
|
||||
max_context_window_tokens=1000,
|
||||
max_output_tokens=100,
|
||||
)
|
||||
|
||||
changed = await strategy(messages)
|
||||
|
||||
assert changed is True
|
||||
projected = included_messages(messages)
|
||||
# System message should always be preserved
|
||||
assert projected[0].role == "system"
|
||||
# Some messages should have been excluded
|
||||
assert len(projected) < 5
|
||||
|
||||
|
||||
async def test_context_window_strategy_keep_last_tool_call_groups_respected() -> None:
|
||||
"""The keep_last_tool_call_groups parameter controls how many groups are retained."""
|
||||
# Create enough tokens to trigger tool eviction (>50% of input budget)
|
||||
# input_budget = 1000 - 100 = 900; threshold = 450 tokens
|
||||
messages = [
|
||||
Message(role="system", contents=["sys"]),
|
||||
Message(role="user", contents=["u1"]),
|
||||
_assistant_function_call("c1"),
|
||||
_tool_result("c1", "r1 " * 200),
|
||||
Message(role="user", contents=["u2"]),
|
||||
_assistant_function_call("c2"),
|
||||
_tool_result("c2", "r2 " * 200),
|
||||
Message(role="user", contents=["u3"]),
|
||||
_assistant_function_call("c3"),
|
||||
_tool_result("c3", "r3 " * 200),
|
||||
]
|
||||
# keep_last_tool_call_groups=1: only the last group (c3) should be kept verbatim
|
||||
strategy = ContextWindowCompactionStrategy(
|
||||
max_context_window_tokens=1000,
|
||||
max_output_tokens=100,
|
||||
keep_last_tool_call_groups=1,
|
||||
)
|
||||
|
||||
changed = await strategy(messages)
|
||||
|
||||
assert changed is True
|
||||
projected = included_messages(messages)
|
||||
# The last tool call group (c3) should be in the projected messages
|
||||
has_c3 = any(
|
||||
c.call_id == "c3" for m in projected for c in m.contents if c.type in ("function_call", "function_result")
|
||||
)
|
||||
assert has_c3
|
||||
|
||||
|
||||
def test_context_window_strategy_validates_thresholds() -> None:
|
||||
"""Invalid threshold combinations raise ValueError."""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="max_context_window_tokens must be positive"):
|
||||
ContextWindowCompactionStrategy(max_context_window_tokens=0, max_output_tokens=0)
|
||||
|
||||
with pytest.raises(ValueError, match="max_output_tokens must be >= 0"):
|
||||
ContextWindowCompactionStrategy(max_context_window_tokens=1000, max_output_tokens=1000)
|
||||
|
||||
with pytest.raises(ValueError, match="tool_eviction_threshold must be in"):
|
||||
ContextWindowCompactionStrategy(
|
||||
max_context_window_tokens=1000, max_output_tokens=100, tool_eviction_threshold=0.0
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="truncation_threshold must be >= tool_eviction_threshold"):
|
||||
ContextWindowCompactionStrategy(
|
||||
max_context_window_tokens=1000,
|
||||
max_output_tokens=100,
|
||||
tool_eviction_threshold=0.8,
|
||||
truncation_threshold=0.5,
|
||||
)
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
AgentSession,
|
||||
ChatResponse,
|
||||
CompactionProvider,
|
||||
InMemoryHistoryProvider,
|
||||
Message,
|
||||
SkillsProvider,
|
||||
TodoProvider,
|
||||
create_harness_agent,
|
||||
)
|
||||
from agent_framework._harness._agent import DEFAULT_HARNESS_INSTRUCTIONS, _assemble_instructions
|
||||
from agent_framework._harness._mode import AgentModeProvider
|
||||
from agent_framework._sessions import ContextProvider
|
||||
|
||||
|
||||
class _FakeChatClient:
|
||||
"""Minimal chat client stub for testing assembly."""
|
||||
|
||||
model = "test-model"
|
||||
|
||||
async def get_response(
|
||||
self,
|
||||
*,
|
||||
messages: list[Message],
|
||||
options: Mapping[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
return ChatResponse(messages=[Message(role="assistant", contents=["Hello"])])
|
||||
|
||||
async def get_streaming_response(
|
||||
self,
|
||||
*,
|
||||
messages: list[Message],
|
||||
options: Mapping[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[Any]:
|
||||
yield Message(role="assistant", contents=["Hello"]) # pragma: no cover
|
||||
|
||||
|
||||
# --- Assembly Tests ---
|
||||
|
||||
|
||||
def test_create_harness_agent_with_defaults() -> None:
|
||||
"""create_harness_agent should assemble successfully with default options."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
assert agent.id is not None
|
||||
|
||||
|
||||
def test_create_harness_agent_includes_all_default_providers() -> None:
|
||||
"""Default assembly should include history, compaction, todo, mode (no skills by default)."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
providers = agent.context_providers
|
||||
provider_types = [type(p) for p in providers]
|
||||
|
||||
assert InMemoryHistoryProvider in provider_types
|
||||
assert CompactionProvider in provider_types
|
||||
assert TodoProvider in provider_types
|
||||
assert AgentModeProvider in provider_types
|
||||
assert SkillsProvider not in provider_types
|
||||
|
||||
|
||||
def test_create_harness_agent_disable_todo() -> None:
|
||||
"""disable_todo=True should exclude TodoProvider."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_todo=True,
|
||||
)
|
||||
provider_types = [type(p) for p in agent.context_providers]
|
||||
assert TodoProvider not in provider_types
|
||||
|
||||
|
||||
def test_create_harness_agent_disable_mode() -> None:
|
||||
"""disable_mode=True should exclude AgentModeProvider."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_mode=True,
|
||||
)
|
||||
provider_types = [type(p) for p in agent.context_providers]
|
||||
assert AgentModeProvider not in provider_types
|
||||
|
||||
|
||||
def test_create_harness_agent_disable_memory() -> None:
|
||||
"""disable_memory=True should exclude MemoryContextProvider even when memory_store is provided."""
|
||||
from agent_framework import MemoryContextProvider
|
||||
from agent_framework._harness._memory import MemoryStore
|
||||
|
||||
class _FakeMemoryStore(MemoryStore):
|
||||
def list_topics(self, session, *, source_id):
|
||||
return []
|
||||
|
||||
def get_topic(self, session, *, source_id, topic):
|
||||
raise NotImplementedError
|
||||
|
||||
def write_topic(self, session, record, *, source_id):
|
||||
pass
|
||||
|
||||
def delete_topic(self, session, *, source_id, topic):
|
||||
pass
|
||||
|
||||
def get_index_text(self, session, *, source_id):
|
||||
return ""
|
||||
|
||||
def get_transcripts_directory(self, session, *, source_id):
|
||||
return ""
|
||||
|
||||
def read_state(self, session, *, source_id):
|
||||
return {}
|
||||
|
||||
def rebuild_index(self, session, *, source_id):
|
||||
pass
|
||||
|
||||
def search_transcripts(self, session, *, source_id, query):
|
||||
return []
|
||||
|
||||
def write_state(self, session, state, *, source_id):
|
||||
pass
|
||||
|
||||
# With memory_store provided and disable_memory=False, MemoryContextProvider should be present.
|
||||
agent_with_memory = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
memory_store=_FakeMemoryStore(),
|
||||
)
|
||||
provider_types = [type(p) for p in agent_with_memory.context_providers]
|
||||
assert MemoryContextProvider in provider_types
|
||||
|
||||
# With memory_store provided and disable_memory=True, MemoryContextProvider should be absent.
|
||||
agent_disabled = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
memory_store=_FakeMemoryStore(),
|
||||
disable_memory=True,
|
||||
)
|
||||
provider_types = [type(p) for p in agent_disabled.context_providers]
|
||||
assert MemoryContextProvider not in provider_types
|
||||
|
||||
|
||||
def test_create_harness_agent_skills_paths_adds_provider() -> None:
|
||||
"""skills_paths should add a SkillsProvider."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
skills_paths=["./test-skills"],
|
||||
)
|
||||
provider_types = [type(p) for p in agent.context_providers]
|
||||
assert SkillsProvider in provider_types
|
||||
|
||||
|
||||
def test_create_harness_agent_disable_compaction() -> None:
|
||||
"""disable_compaction=True should exclude CompactionProvider."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_compaction=True,
|
||||
)
|
||||
provider_types = [type(p) for p in agent.context_providers]
|
||||
assert CompactionProvider not in provider_types
|
||||
|
||||
|
||||
def test_create_harness_agent_returns_full_agent() -> None:
|
||||
"""Factory should return an Agent instance (with telemetry)."""
|
||||
from agent_framework._agents import Agent as FullAgent
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
assert isinstance(agent, FullAgent)
|
||||
|
||||
|
||||
# --- Validation Tests ---
|
||||
|
||||
|
||||
def test_create_harness_agent_rejects_invalid_context_tokens() -> None:
|
||||
"""max_context_window_tokens must be positive."""
|
||||
with pytest.raises(ValueError, match="max_context_window_tokens must be positive"):
|
||||
create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=0,
|
||||
max_output_tokens=100,
|
||||
)
|
||||
|
||||
|
||||
def test_create_harness_agent_rejects_negative_output_tokens() -> None:
|
||||
"""max_output_tokens must be non-negative."""
|
||||
with pytest.raises(ValueError, match="max_output_tokens must be non-negative"):
|
||||
create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=1000,
|
||||
max_output_tokens=-1,
|
||||
)
|
||||
|
||||
|
||||
def test_create_harness_agent_rejects_output_gte_context() -> None:
|
||||
"""max_output_tokens must be less than max_context_window_tokens."""
|
||||
with pytest.raises(ValueError, match="max_output_tokens must be less than"):
|
||||
create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=1000,
|
||||
max_output_tokens=1000,
|
||||
)
|
||||
|
||||
|
||||
# --- Instructions Tests ---
|
||||
|
||||
|
||||
def test_default_instructions() -> None:
|
||||
"""None args should produce default harness instructions."""
|
||||
result = _assemble_instructions(None, None)
|
||||
assert result == DEFAULT_HARNESS_INSTRUCTIONS.strip()
|
||||
|
||||
|
||||
def test_custom_agent_instructions_appended() -> None:
|
||||
"""Agent instructions should be appended after harness instructions."""
|
||||
result = _assemble_instructions(None, "Focus on code review.")
|
||||
assert DEFAULT_HARNESS_INSTRUCTIONS in result # type: ignore[operator]
|
||||
assert "Focus on code review." in result # type: ignore[operator]
|
||||
|
||||
|
||||
def test_empty_harness_instructions_uses_agent_only() -> None:
|
||||
"""Empty harness_instructions should return agent instructions only."""
|
||||
result = _assemble_instructions("", "Custom only.")
|
||||
assert result == "Custom only."
|
||||
|
||||
|
||||
# --- Identity Tests ---
|
||||
|
||||
|
||||
def test_create_harness_agent_custom_identity() -> None:
|
||||
"""Custom id, name, description should propagate."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
id="my-agent-id",
|
||||
name="my-agent",
|
||||
description="A test agent",
|
||||
)
|
||||
assert agent.id == "my-agent-id"
|
||||
assert agent.name == "my-agent"
|
||||
assert agent.description == "A test agent"
|
||||
|
||||
|
||||
# --- Session Tests ---
|
||||
|
||||
|
||||
def test_create_harness_agent_create_session() -> None:
|
||||
"""create_session should return an AgentSession."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
session = agent.create_session()
|
||||
assert isinstance(session, AgentSession)
|
||||
|
||||
|
||||
def test_create_harness_agent_create_session_with_id() -> None:
|
||||
"""create_session should accept a custom session_id."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
session = agent.create_session(session_id="custom-id")
|
||||
assert session.session_id == "custom-id"
|
||||
|
||||
|
||||
async def test_create_harness_agent_run_returns_response() -> None:
|
||||
"""agent.run() should return a response."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
session = agent.create_session()
|
||||
response = await agent.run("hello", session=session)
|
||||
assert response.messages
|
||||
assert response.messages[-1].role == "assistant"
|
||||
|
||||
|
||||
# --- Protocol Tests ---
|
||||
|
||||
|
||||
def test_create_harness_agent_satisfies_protocol() -> None:
|
||||
"""Returned agent should satisfy SupportsAgentRun protocol."""
|
||||
from agent_framework import SupportsAgentRun
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
assert isinstance(agent, SupportsAgentRun)
|
||||
|
||||
|
||||
# --- Additional providers ---
|
||||
|
||||
|
||||
def test_create_harness_agent_extra_context_providers() -> None:
|
||||
"""Additional context_providers should be appended."""
|
||||
|
||||
class _CustomProvider(ContextProvider):
|
||||
pass
|
||||
|
||||
custom = _CustomProvider("custom")
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
context_providers=[custom],
|
||||
)
|
||||
assert custom in agent.context_providers
|
||||
|
||||
|
||||
# --- Web Search Tool Tests ---
|
||||
|
||||
|
||||
class _FakeWebSearchClient(_FakeChatClient):
|
||||
"""Fake client that supports web search tool."""
|
||||
|
||||
def get_web_search_tool(self, **kwargs: Any) -> str:
|
||||
return "web_search_tool_instance"
|
||||
|
||||
|
||||
def test_create_harness_agent_auto_adds_web_search_tool() -> None:
|
||||
"""Web search tool should be auto-added when client supports it."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeWebSearchClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
tools = agent.default_options.get("tools", [])
|
||||
assert "web_search_tool_instance" in tools
|
||||
|
||||
|
||||
def test_create_harness_agent_disable_web_search() -> None:
|
||||
"""disable_web_search=True should skip auto-adding the web search tool."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeWebSearchClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_web_search=True,
|
||||
)
|
||||
tools = agent.default_options.get("tools", [])
|
||||
assert "web_search_tool_instance" not in tools
|
||||
|
||||
|
||||
def test_create_harness_agent_no_web_search_when_unsupported() -> None:
|
||||
"""Web search tool should NOT be added when client does not support it."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
tools = agent.default_options.get("tools", [])
|
||||
assert "web_search_tool_instance" not in tools
|
||||
|
||||
|
||||
def test_create_harness_agent_logs_warning_when_no_web_search(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A warning should be logged when client doesn't support web search."""
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="agent_framework._harness._agent"):
|
||||
create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
assert any("SupportsWebSearchTool" in msg for msg in caplog.messages)
|
||||
@@ -1,538 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentSession,
|
||||
BackgroundAgentsProvider,
|
||||
BackgroundTaskInfo,
|
||||
BackgroundTaskStatus,
|
||||
Message,
|
||||
)
|
||||
from agent_framework._sessions import SessionContext
|
||||
|
||||
# Suppress "coroutine was never awaited" warnings from task cancellation in tests.
|
||||
# This occurs when cancelling tasks that wrap coroutines through _run_agent().
|
||||
pytestmark = pytest.mark.filterwarnings("ignore::RuntimeWarning:asyncio")
|
||||
|
||||
# --- Test Helpers ---
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
"""Minimal agent stub for testing background agent delegation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
*,
|
||||
response_text: str = "done",
|
||||
delay: float = 0.0,
|
||||
should_fail: bool = False,
|
||||
):
|
||||
self.id = f"agent-{name}"
|
||||
self.name = name
|
||||
self.description = description
|
||||
self._response_text = response_text
|
||||
self._delay = delay
|
||||
self._should_fail = should_fail
|
||||
|
||||
def create_session(self, *, session_id: str | None = None) -> AgentSession:
|
||||
return AgentSession(session_id=session_id)
|
||||
|
||||
def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession:
|
||||
return AgentSession(service_session_id=service_session_id, session_id=session_id)
|
||||
|
||||
async def run(
|
||||
self, messages: Any = None, *, stream: bool = False, session: Any = None, **kwargs: Any
|
||||
) -> AgentResponse[Any]:
|
||||
if self._delay > 0:
|
||||
await asyncio.sleep(self._delay)
|
||||
if self._should_fail:
|
||||
raise RuntimeError("Agent execution failed")
|
||||
return AgentResponse(messages=[Message(role="assistant", contents=[self._response_text])])
|
||||
|
||||
|
||||
def _make_provider(*agents: _FakeAgent) -> BackgroundAgentsProvider:
|
||||
"""Create a provider with given agents."""
|
||||
return BackgroundAgentsProvider(agents)
|
||||
|
||||
|
||||
def _make_session() -> AgentSession:
|
||||
"""Create a session for testing."""
|
||||
return AgentSession()
|
||||
|
||||
|
||||
async def _get_tools(provider: BackgroundAgentsProvider, session: AgentSession) -> dict[str, Any]:
|
||||
"""Run before_run and return tools by name."""
|
||||
context = SessionContext(input_messages=[])
|
||||
await provider.before_run(agent=None, session=session, context=context, state={})
|
||||
tools_by_name: dict[str, Any] = {}
|
||||
for t in context.tools:
|
||||
tools_by_name[t.name if hasattr(t, "name") else str(t)] = t
|
||||
return tools_by_name
|
||||
|
||||
|
||||
async def _invoke_tool(tool_obj: Any, **kwargs: Any) -> str:
|
||||
"""Invoke a FunctionTool and return the raw result string."""
|
||||
return await tool_obj.invoke(arguments=kwargs, skip_parsing=True)
|
||||
|
||||
|
||||
# --- Constructor Tests ---
|
||||
|
||||
|
||||
def test_constructor_requires_at_least_one_agent() -> None:
|
||||
"""Should reject empty agent list."""
|
||||
with pytest.raises(ValueError, match="At least one background agent"):
|
||||
BackgroundAgentsProvider([])
|
||||
|
||||
|
||||
def test_constructor_requires_agent_names() -> None:
|
||||
"""Should reject agents with no name."""
|
||||
agent = _FakeAgent("")
|
||||
with pytest.raises(ValueError, match="non-empty name"):
|
||||
BackgroundAgentsProvider([agent])
|
||||
|
||||
|
||||
def test_constructor_rejects_duplicate_names() -> None:
|
||||
"""Should reject duplicate agent names (case-insensitive)."""
|
||||
agent1 = _FakeAgent("Research")
|
||||
agent2 = _FakeAgent("research")
|
||||
with pytest.raises(ValueError, match="Duplicate background agent name"):
|
||||
BackgroundAgentsProvider([agent1, agent2])
|
||||
|
||||
|
||||
def test_constructor_valid_agents() -> None:
|
||||
"""Should succeed with valid unique agents."""
|
||||
provider = BackgroundAgentsProvider([_FakeAgent("Alpha"), _FakeAgent("Beta")])
|
||||
assert provider.source_id == "background_agents"
|
||||
|
||||
|
||||
def test_constructor_custom_source_id() -> None:
|
||||
"""Should accept custom source_id."""
|
||||
provider = BackgroundAgentsProvider([_FakeAgent("Agent1")], source_id="custom_bg")
|
||||
assert provider.source_id == "custom_bg"
|
||||
|
||||
|
||||
# --- Tool Injection Tests ---
|
||||
|
||||
|
||||
async def test_before_run_injects_six_tools() -> None:
|
||||
"""before_run should inject exactly 6 tools."""
|
||||
provider = _make_provider(_FakeAgent("Worker"))
|
||||
tools = await _get_tools(provider, _make_session())
|
||||
assert len(tools) == 6
|
||||
expected_names = {
|
||||
"background_agents_start_task",
|
||||
"background_agents_wait_for_first_completion",
|
||||
"background_agents_get_task_results",
|
||||
"background_agents_get_all_tasks",
|
||||
"background_agents_continue_task",
|
||||
"background_agents_clear_completed_task",
|
||||
}
|
||||
assert set(tools.keys()) == expected_names
|
||||
|
||||
|
||||
async def test_before_run_injects_instructions() -> None:
|
||||
"""before_run should inject instructions mentioning agent names."""
|
||||
provider = _make_provider(_FakeAgent("ResearchBot", "Does research"))
|
||||
context = SessionContext(input_messages=[])
|
||||
session = _make_session()
|
||||
await provider.before_run(agent=None, session=session, context=context, state={})
|
||||
all_instructions = " ".join(context.instructions)
|
||||
assert "ResearchBot" in all_instructions
|
||||
assert "Does research" in all_instructions
|
||||
|
||||
|
||||
# --- Start Task Tests ---
|
||||
|
||||
|
||||
async def test_start_task_success() -> None:
|
||||
"""Should start a task and return confirmation."""
|
||||
provider = _make_provider(_FakeAgent("Worker", response_text="result"))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_start_task"],
|
||||
agent_name="Worker",
|
||||
input="do something",
|
||||
description="test task",
|
||||
)
|
||||
assert "task 1 started" in result.lower()
|
||||
assert "Worker" in result
|
||||
|
||||
|
||||
async def test_start_task_unknown_agent() -> None:
|
||||
"""Should return error for unknown agent name."""
|
||||
provider = _make_provider(_FakeAgent("Worker"))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_start_task"],
|
||||
agent_name="NonExistent",
|
||||
input="do something",
|
||||
description="test",
|
||||
)
|
||||
assert "Error" in result
|
||||
assert "NonExistent" in result
|
||||
|
||||
|
||||
async def test_start_task_increments_ids() -> None:
|
||||
"""Task IDs should increment sequentially."""
|
||||
provider = _make_provider(_FakeAgent("Worker"))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
r1 = await _invoke_tool(
|
||||
tools["background_agents_start_task"],
|
||||
agent_name="Worker",
|
||||
input="task 1",
|
||||
description="first",
|
||||
)
|
||||
r2 = await _invoke_tool(
|
||||
tools["background_agents_start_task"],
|
||||
agent_name="Worker",
|
||||
input="task 2",
|
||||
description="second",
|
||||
)
|
||||
assert "task 1 started" in r1.lower()
|
||||
assert "task 2 started" in r2.lower()
|
||||
|
||||
|
||||
# --- Get All Tasks Tests ---
|
||||
|
||||
|
||||
async def test_get_all_tasks_empty() -> None:
|
||||
"""Should return 'No tasks.' when no tasks exist."""
|
||||
provider = _make_provider(_FakeAgent("Worker"))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
result = await _invoke_tool(tools["background_agents_get_all_tasks"])
|
||||
assert "No tasks" in result
|
||||
|
||||
|
||||
async def test_get_all_tasks_shows_tasks() -> None:
|
||||
"""Should list all tasks with status and description."""
|
||||
provider = _make_provider(_FakeAgent("Worker"))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
await _invoke_tool(
|
||||
tools["background_agents_start_task"],
|
||||
agent_name="Worker",
|
||||
input="hello",
|
||||
description="my task",
|
||||
)
|
||||
result = await _invoke_tool(tools["background_agents_get_all_tasks"])
|
||||
assert "my task" in result
|
||||
assert "Worker" in result
|
||||
|
||||
|
||||
# --- Wait for Completion Tests ---
|
||||
|
||||
|
||||
async def test_wait_for_first_completion() -> None:
|
||||
"""Should wait and return when a task completes."""
|
||||
provider = _make_provider(_FakeAgent("Fast", response_text="fast result", delay=0.01))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
await _invoke_tool(
|
||||
tools["background_agents_start_task"],
|
||||
agent_name="Fast",
|
||||
input="go",
|
||||
description="fast task",
|
||||
)
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_wait_for_first_completion"],
|
||||
task_ids=[1],
|
||||
)
|
||||
assert "finished" in result.lower()
|
||||
assert "completed" in result.lower()
|
||||
|
||||
|
||||
async def test_wait_empty_task_ids() -> None:
|
||||
"""Should return error for empty task_ids."""
|
||||
provider = _make_provider(_FakeAgent("Worker"))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_wait_for_first_completion"],
|
||||
task_ids=[],
|
||||
)
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
async def test_wait_no_running_tasks() -> None:
|
||||
"""Should return error when no specified tasks are running."""
|
||||
provider = _make_provider(_FakeAgent("Worker"))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_wait_for_first_completion"],
|
||||
task_ids=[999],
|
||||
)
|
||||
assert "Error" in result or "not running" in result.lower()
|
||||
|
||||
|
||||
# --- Get Task Results Tests ---
|
||||
|
||||
|
||||
async def test_get_task_results_completed() -> None:
|
||||
"""Should return result text for completed task."""
|
||||
provider = _make_provider(_FakeAgent("Worker", response_text="the answer", delay=0.01))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
await _invoke_tool(
|
||||
tools["background_agents_start_task"],
|
||||
agent_name="Worker",
|
||||
input="query",
|
||||
description="test",
|
||||
)
|
||||
# Wait for completion.
|
||||
await _invoke_tool(
|
||||
tools["background_agents_wait_for_first_completion"],
|
||||
task_ids=[1],
|
||||
)
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_get_task_results"],
|
||||
task_id=1,
|
||||
)
|
||||
assert result == "the answer"
|
||||
|
||||
|
||||
async def test_get_task_results_running() -> None:
|
||||
"""Should indicate task is still running."""
|
||||
provider = _make_provider(_FakeAgent("Slow", delay=10.0))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
await _invoke_tool(
|
||||
tools["background_agents_start_task"],
|
||||
agent_name="Slow",
|
||||
input="query",
|
||||
description="slow task",
|
||||
)
|
||||
try:
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_get_task_results"],
|
||||
task_id=1,
|
||||
)
|
||||
assert "still running" in result.lower()
|
||||
finally:
|
||||
runtime = provider._get_runtime(session)
|
||||
for task in list(runtime.in_flight_tasks.values()):
|
||||
task.cancel()
|
||||
await asyncio.gather(*runtime.in_flight_tasks.values(), return_exceptions=True)
|
||||
|
||||
|
||||
async def test_get_task_results_failed() -> None:
|
||||
"""Should return error text for failed task."""
|
||||
provider = _make_provider(_FakeAgent("Broken", should_fail=True, delay=0.01))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
await _invoke_tool(
|
||||
tools["background_agents_start_task"],
|
||||
agent_name="Broken",
|
||||
input="query",
|
||||
description="will fail",
|
||||
)
|
||||
await _invoke_tool(
|
||||
tools["background_agents_wait_for_first_completion"],
|
||||
task_ids=[1],
|
||||
)
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_get_task_results"],
|
||||
task_id=1,
|
||||
)
|
||||
assert "failed" in result.lower()
|
||||
|
||||
|
||||
async def test_get_task_results_not_found() -> None:
|
||||
"""Should return error for non-existent task."""
|
||||
provider = _make_provider(_FakeAgent("Worker"))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_get_task_results"],
|
||||
task_id=999,
|
||||
)
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# --- Continue Task Tests ---
|
||||
|
||||
|
||||
async def test_continue_task_after_completion() -> None:
|
||||
"""Should be able to continue a completed task."""
|
||||
provider = _make_provider(_FakeAgent("Worker", response_text="first result", delay=0.01))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
await _invoke_tool(
|
||||
tools["background_agents_start_task"],
|
||||
agent_name="Worker",
|
||||
input="first input",
|
||||
description="continuable",
|
||||
)
|
||||
await _invoke_tool(
|
||||
tools["background_agents_wait_for_first_completion"],
|
||||
task_ids=[1],
|
||||
)
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_continue_task"],
|
||||
task_id=1,
|
||||
text="follow up",
|
||||
)
|
||||
assert "continued" in result.lower()
|
||||
|
||||
|
||||
async def test_continue_task_still_running() -> None:
|
||||
"""Should return error if task is still running."""
|
||||
provider = _make_provider(_FakeAgent("Slow", delay=10.0))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
await _invoke_tool(
|
||||
tools["background_agents_start_task"],
|
||||
agent_name="Slow",
|
||||
input="input",
|
||||
description="running",
|
||||
)
|
||||
try:
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_continue_task"],
|
||||
task_id=1,
|
||||
text="follow up",
|
||||
)
|
||||
assert "still running" in result.lower()
|
||||
finally:
|
||||
runtime = provider._get_runtime(session)
|
||||
for task in list(runtime.in_flight_tasks.values()):
|
||||
task.cancel()
|
||||
await asyncio.gather(*runtime.in_flight_tasks.values(), return_exceptions=True)
|
||||
|
||||
|
||||
async def test_continue_task_not_found() -> None:
|
||||
"""Should return error for non-existent task."""
|
||||
provider = _make_provider(_FakeAgent("Worker"))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_continue_task"],
|
||||
task_id=999,
|
||||
text="hello",
|
||||
)
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# --- Clear Task Tests ---
|
||||
|
||||
|
||||
async def test_clear_completed_task() -> None:
|
||||
"""Should clear a completed task."""
|
||||
provider = _make_provider(_FakeAgent("Worker", response_text="done", delay=0.01))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
await _invoke_tool(
|
||||
tools["background_agents_start_task"],
|
||||
agent_name="Worker",
|
||||
input="task",
|
||||
description="clearable",
|
||||
)
|
||||
await _invoke_tool(
|
||||
tools["background_agents_wait_for_first_completion"],
|
||||
task_ids=[1],
|
||||
)
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_clear_completed_task"],
|
||||
task_id=1,
|
||||
)
|
||||
assert "cleared" in result.lower()
|
||||
|
||||
# Verify task is gone.
|
||||
all_tasks = await _invoke_tool(tools["background_agents_get_all_tasks"])
|
||||
assert "No tasks" in all_tasks
|
||||
|
||||
|
||||
async def test_clear_running_task_error() -> None:
|
||||
"""Should return error when clearing a running task."""
|
||||
provider = _make_provider(_FakeAgent("Slow", delay=10.0))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
await _invoke_tool(
|
||||
tools["background_agents_start_task"],
|
||||
agent_name="Slow",
|
||||
input="task",
|
||||
description="still going",
|
||||
)
|
||||
try:
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_clear_completed_task"],
|
||||
task_id=1,
|
||||
)
|
||||
assert "still running" in result.lower()
|
||||
finally:
|
||||
runtime = provider._get_runtime(session)
|
||||
for task in list(runtime.in_flight_tasks.values()):
|
||||
task.cancel()
|
||||
await asyncio.gather(*runtime.in_flight_tasks.values(), return_exceptions=True)
|
||||
|
||||
|
||||
async def test_clear_not_found() -> None:
|
||||
"""Should return error for non-existent task."""
|
||||
provider = _make_provider(_FakeAgent("Worker"))
|
||||
session = _make_session()
|
||||
tools = await _get_tools(provider, session)
|
||||
|
||||
result = await _invoke_tool(
|
||||
tools["background_agents_clear_completed_task"],
|
||||
task_id=999,
|
||||
)
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# --- BackgroundTaskInfo Tests ---
|
||||
|
||||
|
||||
def test_task_info_serialization() -> None:
|
||||
"""BackgroundTaskInfo should round-trip through to_dict/from_dict."""
|
||||
info = BackgroundTaskInfo(
|
||||
id=1,
|
||||
agent_name="Worker",
|
||||
description="test task",
|
||||
status=BackgroundTaskStatus.COMPLETED,
|
||||
result_text="hello",
|
||||
)
|
||||
data = info.to_dict()
|
||||
restored = BackgroundTaskInfo.from_dict(data)
|
||||
assert restored.id == 1
|
||||
assert restored.agent_name == "Worker"
|
||||
assert restored.status == BackgroundTaskStatus.COMPLETED
|
||||
assert restored.result_text == "hello"
|
||||
assert restored.error_text is None
|
||||
|
||||
|
||||
def test_task_status_enum_values() -> None:
|
||||
"""BackgroundTaskStatus should have expected values."""
|
||||
assert BackgroundTaskStatus.RUNNING == "running"
|
||||
assert BackgroundTaskStatus.COMPLETED == "completed"
|
||||
assert BackgroundTaskStatus.FAILED == "failed"
|
||||
assert BackgroundTaskStatus.LOST == "lost"
|
||||
@@ -95,10 +95,8 @@ async def test_agent_mode_context_provider_normalizes_custom_modes(
|
||||
)
|
||||
instructions = options["instructions"]
|
||||
assert isinstance(instructions, str)
|
||||
assert "#### Draft" in instructions
|
||||
assert "Draft it." in instructions
|
||||
assert "#### Final" in instructions
|
||||
assert "Finalize it." in instructions
|
||||
assert '"Draft": Draft it.' in instructions
|
||||
assert '"Final": Finalize it.' in instructions
|
||||
assert "You are currently operating in the draft mode." in instructions
|
||||
|
||||
assert (
|
||||
@@ -127,8 +125,8 @@ async def test_agent_mode_context_provider_serializes_tool_outputs_as_json(
|
||||
)
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
get_mode_tool = _tool_by_name(tools, "mode_get")
|
||||
set_mode_tool = _tool_by_name(tools, "mode_set")
|
||||
get_mode_tool = _tool_by_name(tools, "get_mode")
|
||||
set_mode_tool = _tool_by_name(tools, "set_mode")
|
||||
|
||||
initial_mode = await get_mode_tool.invoke()
|
||||
assert json.loads(initial_mode[0].text) == {"mode": mode_name}
|
||||
@@ -154,13 +152,13 @@ async def test_agent_mode_context_provider_updates_agent_mode(
|
||||
instructions = options["instructions"]
|
||||
assert isinstance(instructions, str)
|
||||
assert "## Agent Mode" in instructions
|
||||
assert "Use the mode_set tool to switch between modes as your work progresses." in instructions
|
||||
assert "Use the set_mode tool to switch between modes as your work progresses." in instructions
|
||||
assert "ask clarifying questions, discuss options, and get user approval before proceeding" in instructions
|
||||
assert "If you encounter ambiguity" in instructions
|
||||
assert "If you encounter ambiguity, choose the most reasonable option and note your choice" in instructions
|
||||
assert "You are currently operating in the plan mode." in instructions
|
||||
|
||||
get_mode_tool = _tool_by_name(tools, "mode_get")
|
||||
set_mode_tool = _tool_by_name(tools, "mode_set")
|
||||
get_mode_tool = _tool_by_name(tools, "get_mode")
|
||||
set_mode_tool = _tool_by_name(tools, "set_mode")
|
||||
|
||||
initial_mode = await get_mode_tool.invoke()
|
||||
assert json.loads(initial_mode[0].text) == {"mode": "plan"}
|
||||
@@ -220,13 +218,13 @@ async def test_agent_mode_provider_injects_user_message_after_external_change(
|
||||
provider = AgentModeProvider()
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
# First run: agent uses mode_set tool to switch to execute. The tool path must NOT queue a
|
||||
# First run: agent uses set_mode tool to switch to execute. The tool path must NOT queue a
|
||||
# notification because the agent already saw its own tool call in the chat history.
|
||||
_, first_options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Plan first."])],
|
||||
)
|
||||
set_mode_tool = _tool_by_name(first_options["tools"], "mode_set")
|
||||
set_mode_tool = _tool_by_name(first_options["tools"], "set_mode")
|
||||
await set_mode_tool.invoke(arguments={"mode": "execute"})
|
||||
assert "previous_mode_for_notification" not in session.state[provider.source_id]
|
||||
|
||||
|
||||
@@ -293,94 +293,6 @@ class TestWorkflowAgent:
|
||||
# Verify cleanup - pending requests should be cleared after function response handling
|
||||
assert len(agent.pending_requests) == 0
|
||||
|
||||
async def test_request_info_resume_after_session_restore_with_checkpoint(self):
|
||||
"""Pending request metadata in AgentSession should resume the same request_id after restore."""
|
||||
from agent_framework import InMemoryCheckpointStorage
|
||||
|
||||
simple_executor = SimpleExecutor(id="simple", response_text="SimpleResponse", streaming=False)
|
||||
requesting_executor = RequestingExecutor(id="requester", streaming=False)
|
||||
checkpoint_storage = InMemoryCheckpointStorage()
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=simple_executor, checkpoint_storage=checkpoint_storage)
|
||||
.add_edge(simple_executor, requesting_executor)
|
||||
.build()
|
||||
)
|
||||
agent = WorkflowAgent(workflow=workflow, name="Request Restore Test Agent")
|
||||
session = AgentSession()
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("Start request", stream=True, session=session):
|
||||
updates.append(update)
|
||||
|
||||
approval_update = next(
|
||||
(
|
||||
update
|
||||
for update in updates
|
||||
if any(content.type == "function_approval_request" for content in update.contents)
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert approval_update is not None, "Should have received a request_info approval request"
|
||||
|
||||
function_call = next(content for content in approval_update.contents if content.type == "function_call")
|
||||
approval_request = next(
|
||||
content for content in approval_update.contents if content.type == "function_approval_request"
|
||||
)
|
||||
request_id = approval_request.id
|
||||
assert request_id is not None
|
||||
assert function_call.call_id == request_id
|
||||
|
||||
checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
|
||||
checkpoint_with_request = next(
|
||||
(checkpoint for checkpoint in checkpoints if request_id in checkpoint.pending_request_info_events),
|
||||
None,
|
||||
)
|
||||
assert checkpoint_with_request is not None
|
||||
|
||||
serialized_session = session.to_dict()
|
||||
workflow_agent_state = serialized_session["state"].get("workflow_agent", {})
|
||||
pending_state = workflow_agent_state.get("pending_request_info_events", {})
|
||||
assert request_id in pending_state
|
||||
|
||||
restored_session = AgentSession.from_dict(serialized_session)
|
||||
|
||||
restored_simple_executor = SimpleExecutor(id="simple", response_text="SimpleResponse", streaming=False)
|
||||
restored_requesting_executor = RequestingExecutor(id="requester", streaming=False)
|
||||
restored_workflow = (
|
||||
WorkflowBuilder(start_executor=restored_simple_executor, checkpoint_storage=checkpoint_storage)
|
||||
.add_edge(restored_simple_executor, restored_requesting_executor)
|
||||
.build()
|
||||
)
|
||||
restored_agent = WorkflowAgent(workflow=restored_workflow, name="Request Restore Test Agent")
|
||||
|
||||
response_args = WorkflowAgent.RequestInfoFunctionArgs(
|
||||
request_id=request_id,
|
||||
data="User provided answer",
|
||||
).to_dict()
|
||||
approval_response = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id=request_id,
|
||||
function_call=Content.from_function_call(
|
||||
call_id=request_id,
|
||||
name=WorkflowAgent.REQUEST_INFO_FUNCTION_NAME,
|
||||
arguments=response_args,
|
||||
),
|
||||
)
|
||||
response_message = Message(role="user", contents=[approval_response])
|
||||
|
||||
continuation_result = await restored_agent.run(
|
||||
response_message,
|
||||
session=restored_session,
|
||||
checkpoint_id=checkpoint_with_request.checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
)
|
||||
|
||||
assert isinstance(continuation_result, AgentResponse)
|
||||
response_texts = [message.text for message in continuation_result.messages if message.text]
|
||||
assert any("Request completed with response: User provided answer" in text for text in response_texts)
|
||||
assert len(restored_agent.pending_requests) == 0
|
||||
|
||||
def test_workflow_as_agent_method(self) -> None:
|
||||
"""Test that Workflow.as_agent() creates a properly configured WorkflowAgent."""
|
||||
# Create a simple workflow
|
||||
|
||||
+3
-128
@@ -27,15 +27,12 @@ from __future__ import annotations
|
||||
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal as _Decimal
|
||||
from enum import Enum
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from agent_framework import (
|
||||
@@ -61,100 +58,6 @@ else:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeclarativeEnvConfig:
|
||||
"""Configuration that populates the PowerFx ``Env`` symbol for a workflow.
|
||||
|
||||
Configuration values are always exposed under ``Env.<name>``;
|
||||
``os.environ`` is consulted only when ``restrict_to_configuration``
|
||||
is ``False`` AND the YAML literally references the name in a PowerFx
|
||||
expression (the allowlist enforced via ``referenced_names``).
|
||||
|
||||
Attributes:
|
||||
values: Caller-supplied configuration resolved by name when the
|
||||
workflow YAML references ``=Env.NAME``. Always exposed in
|
||||
the ``Env`` symbol regardless of ``restrict_to_configuration``.
|
||||
restrict_to_configuration: When ``True`` (default), the ``Env``
|
||||
symbol is populated exclusively from ``values``; ``os.environ``
|
||||
is never consulted. Set to ``False`` to additionally fall back
|
||||
to ``os.environ`` for names absent from ``values`` that the
|
||||
workflow YAML explicitly references.
|
||||
referenced_names: The set of ``Env.NAME`` symbols discovered in
|
||||
PowerFx expressions inside the workflow definition. The
|
||||
``os.environ`` fallback is constrained to this allowlist so
|
||||
unrelated environment variables never enter the PowerFx scope.
|
||||
"""
|
||||
|
||||
values: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
|
||||
restrict_to_configuration: bool = True
|
||||
referenced_names: frozenset[str] = field(default_factory=lambda: frozenset[str]())
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Defensive snapshots so the frozen guarantee extends to the
|
||||
# contents of ``values`` / ``referenced_names``: caller mutations
|
||||
# to the original objects after construction cannot leak into
|
||||
# ``resolve()``.
|
||||
object.__setattr__(self, "values", MappingProxyType(dict(self.values)))
|
||||
object.__setattr__(self, "referenced_names", frozenset(self.referenced_names))
|
||||
|
||||
def resolve(self) -> dict[str, str]:
|
||||
"""Return the resolved ``Env`` symbol mapping for the workflow.
|
||||
|
||||
Configuration values are always included (stringified).
|
||||
``os.environ`` is consulted only when ``restrict_to_configuration``
|
||||
is ``False`` and the name appears in ``referenced_names``, so
|
||||
unrelated environment variables never enter the PowerFx scope.
|
||||
Configuration values always win over the environment fallback.
|
||||
"""
|
||||
resolved = {name: str(value) for name, value in self.values.items()}
|
||||
if self.restrict_to_configuration:
|
||||
return resolved
|
||||
for name in self.referenced_names.difference(resolved):
|
||||
env_value = os.environ.get(name)
|
||||
if env_value is not None:
|
||||
resolved[name] = env_value
|
||||
return resolved
|
||||
|
||||
|
||||
def discover_env_references(node: Any) -> set[str]:
|
||||
"""Discover ``Env.NAME`` references in PowerFx expressions inside ``node``.
|
||||
|
||||
Walks any nested ``Mapping``/``list``/scalar structure and inspects every
|
||||
string value. To avoid false positives from doc/description fields that
|
||||
happen to mention ``Env.SOMETHING`` as plain text, the scan only inspects
|
||||
strings that begin with ``=`` (PowerFx expression marker, matching the
|
||||
convention enforced by :meth:`DeclarativeWorkflowState.eval`).
|
||||
|
||||
Args:
|
||||
node: A parsed workflow definition (typically the dict produced by
|
||||
``yaml.safe_load``).
|
||||
|
||||
Returns:
|
||||
The set of ``Env`` identifier names referenced in PowerFx
|
||||
expressions inside ``node``.
|
||||
"""
|
||||
names: set[str] = set()
|
||||
|
||||
def visit(value: Any) -> None:
|
||||
if isinstance(value, str):
|
||||
if value.startswith("="):
|
||||
names.update(_ENV_REFERENCE_RE.findall(value))
|
||||
return
|
||||
if isinstance(value, Mapping):
|
||||
for inner in cast(Mapping[Any, Any], value).values(): # type: ignore[redundant-cast]
|
||||
visit(inner)
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for item in cast(list[Any], value): # type: ignore[redundant-cast]
|
||||
visit(item)
|
||||
|
||||
visit(node)
|
||||
return names
|
||||
|
||||
|
||||
class ConversationData(TypedDict):
|
||||
"""Structure for conversation-related state data.
|
||||
|
||||
@@ -266,18 +169,13 @@ class DeclarativeWorkflowState:
|
||||
- Conversation: Conversation history
|
||||
"""
|
||||
|
||||
def __init__(self, state: State, env_config: DeclarativeEnvConfig | None = None):
|
||||
def __init__(self, state: State):
|
||||
"""Initialize with a State instance.
|
||||
|
||||
Args:
|
||||
state: The workflow's state for persistence
|
||||
env_config: Configuration that populates the PowerFx ``Env``
|
||||
symbol when ``_to_powerfx_symbols`` is called. Defaults to
|
||||
an empty configuration which results in no ``Env`` binding,
|
||||
matching the safe default of the :class:`WorkflowFactory`.
|
||||
"""
|
||||
self._state = state
|
||||
self._env_config = env_config if env_config is not None else DeclarativeEnvConfig()
|
||||
|
||||
def initialize(self, inputs: Mapping[str, Any] | None = None) -> None:
|
||||
"""Initialize the declarative state with inputs.
|
||||
@@ -816,14 +714,6 @@ class DeclarativeWorkflowState:
|
||||
# Custom namespaces
|
||||
**state_data.get("Custom", {}),
|
||||
}
|
||||
# Resolve the ``Env`` symbol from the workflow-level
|
||||
# :class:`DeclarativeEnvConfig`. When both ``values`` and the
|
||||
# ``os.environ`` allowlist produce no entries the symbol is
|
||||
# omitted so ``=Env.X`` falls back to the literal expression
|
||||
# string (preserving the legacy "unbound identifier" behaviour).
|
||||
env_bound = self._env_config.resolve()
|
||||
if env_bound:
|
||||
symbols["Env"] = env_bound
|
||||
# Debug log the Local symbols to help diagnose type issues
|
||||
if local_data:
|
||||
for key, value in local_data.items():
|
||||
@@ -977,11 +867,6 @@ class DeclarativeActionExecutor(Executor):
|
||||
action_id = id or action_def.get("id") or f"{action_def.get('kind', 'action')}_{hash(str(action_def)) % 10000}"
|
||||
super().__init__(id=action_id, defer_discovery=True)
|
||||
self._action_def = action_def
|
||||
# The active :class:`DeclarativeEnvConfig` is stamped onto the
|
||||
# executor by :class:`DeclarativeWorkflowBuilder` after construction.
|
||||
# Defaults to an empty configuration so direct ``DeclarativeActionExecutor``
|
||||
# construction (e.g. in unit tests) doesn't expose ``os.environ``.
|
||||
self._declarative_env_config: DeclarativeEnvConfig = DeclarativeEnvConfig()
|
||||
|
||||
# Manually register handlers after initialization
|
||||
self._handlers = {}
|
||||
@@ -989,16 +874,6 @@ class DeclarativeActionExecutor(Executor):
|
||||
self._discover_handlers()
|
||||
self._discover_response_handlers()
|
||||
|
||||
def set_declarative_env_config(self, env_config: DeclarativeEnvConfig) -> None:
|
||||
"""Set the workflow-level :class:`DeclarativeEnvConfig` for this executor.
|
||||
|
||||
Called by :class:`DeclarativeWorkflowBuilder` after each executor is
|
||||
created so that ``_to_powerfx_symbols`` populates the ``Env`` symbol
|
||||
according to the caller-supplied configuration on the
|
||||
:class:`WorkflowFactory`.
|
||||
"""
|
||||
self._declarative_env_config = env_config
|
||||
|
||||
@property
|
||||
def action_def(self) -> dict[str, Any]:
|
||||
"""Get the action definition."""
|
||||
@@ -1011,7 +886,7 @@ class DeclarativeActionExecutor(Executor):
|
||||
|
||||
def _get_state(self, state: State) -> DeclarativeWorkflowState:
|
||||
"""Get the declarative workflow state wrapper."""
|
||||
return DeclarativeWorkflowState(state, env_config=self._declarative_env_config)
|
||||
return DeclarativeWorkflowState(state)
|
||||
|
||||
async def _ensure_state_initialized(
|
||||
self,
|
||||
|
||||
-16
@@ -24,7 +24,6 @@ from agent_framework import (
|
||||
from ._declarative_base import (
|
||||
ConditionResult,
|
||||
DeclarativeActionExecutor,
|
||||
DeclarativeEnvConfig,
|
||||
LoopIterationResult,
|
||||
)
|
||||
from ._errors import DeclarativeWorkflowError
|
||||
@@ -141,7 +140,6 @@ class DeclarativeWorkflowBuilder:
|
||||
max_iterations: int | None = None,
|
||||
http_request_handler: HttpRequestHandler | None = None,
|
||||
mcp_tool_handler: MCPToolHandler | None = None,
|
||||
env_config: DeclarativeEnvConfig | None = None,
|
||||
):
|
||||
"""Initialize the builder.
|
||||
|
||||
@@ -160,10 +158,6 @@ class DeclarativeWorkflowBuilder:
|
||||
mcp_tool_handler: Handler used to dispatch InvokeMcpTool calls.
|
||||
Must be supplied when the workflow contains any InvokeMcpTool;
|
||||
otherwise build raises ``DeclarativeWorkflowError``.
|
||||
env_config: Optional :class:`DeclarativeEnvConfig` controlling
|
||||
how the ``Env`` PowerFx symbol is populated for every
|
||||
executor built by this builder. Defaults to an empty
|
||||
configuration (``Env`` not exposed).
|
||||
"""
|
||||
self._yaml_def = yaml_definition
|
||||
self._workflow_id = workflow_id or yaml_definition.get("name", "declarative_workflow")
|
||||
@@ -177,7 +171,6 @@ class DeclarativeWorkflowBuilder:
|
||||
self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection
|
||||
self._http_request_handler = http_request_handler
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
self._env_config: DeclarativeEnvConfig = env_config if env_config is not None else DeclarativeEnvConfig()
|
||||
# Resolve max_iterations: explicit arg > YAML maxTurns > core default
|
||||
resolved = max_iterations if max_iterations is not None else yaml_definition.get("maxTurns")
|
||||
if resolved is not None and (not isinstance(resolved, int) or resolved <= 0):
|
||||
@@ -228,15 +221,6 @@ class DeclarativeWorkflowBuilder:
|
||||
# Resolve pending gotos (back-edges for loops, forward-edges for jumps)
|
||||
self._resolve_pending_gotos(builder)
|
||||
|
||||
# Stamp the resolved DeclarativeEnvConfig onto every executor so they
|
||||
# expose the configured Env binding through their _get_state(). This
|
||||
# happens after _create_executors_for_actions and _resolve_pending_gotos
|
||||
# so it covers the entry node, join nodes, evaluators, foreach
|
||||
# init/next/exit nodes, and goto placeholders.
|
||||
for executor in self._executors.values():
|
||||
if isinstance(executor, DeclarativeActionExecutor):
|
||||
executor.set_declarative_env_config(self._env_config)
|
||||
|
||||
return builder.build()
|
||||
|
||||
def _validate_workflow(self, actions: list[dict[str, Any]]) -> None:
|
||||
|
||||
@@ -26,7 +26,6 @@ from agent_framework import (
|
||||
)
|
||||
|
||||
from .._loader import AgentFactory
|
||||
from ._declarative_base import DeclarativeEnvConfig, discover_env_references
|
||||
from ._declarative_builder import DeclarativeWorkflowBuilder
|
||||
from ._errors import DeclarativeWorkflowError
|
||||
from ._http_handler import HttpRequestHandler
|
||||
@@ -94,8 +93,6 @@ class WorkflowFactory:
|
||||
max_iterations: int | None = None,
|
||||
http_request_handler: HttpRequestHandler | None = None,
|
||||
mcp_tool_handler: MCPToolHandler | None = None,
|
||||
configuration: Mapping[str, str] | None = None,
|
||||
restrict_env_to_configuration: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the workflow factory.
|
||||
|
||||
@@ -122,23 +119,6 @@ class WorkflowFactory:
|
||||
for a default backed by :class:`agent_framework.MCPStreamableHTTPTool`,
|
||||
or supply your own implementation to enforce SSRF guards, allowlisting,
|
||||
or auth/connection resolution.
|
||||
configuration: Optional mapping that populates the PowerFx ``Env``
|
||||
symbol referenced from workflow YAML expressions (e.g.
|
||||
``=Env.MY_KEY``). Keys supplied here are always exposed
|
||||
under ``Env.<key>``; the process ``os.environ`` is consulted
|
||||
only when ``restrict_env_to_configuration`` is ``False``.
|
||||
When neither source produces a value the ``Env`` symbol is
|
||||
omitted so ``=Env.X`` evaluates to the literal expression
|
||||
string.
|
||||
restrict_env_to_configuration: When ``True`` (default), the
|
||||
``Env`` PowerFx symbol is populated exclusively from
|
||||
``configuration``; ``os.environ`` is never consulted. Set to
|
||||
``False`` to additionally fall back to ``os.environ`` for
|
||||
names absent from ``configuration`` that the workflow YAML
|
||||
explicitly references. The fallback is constrained to names
|
||||
discovered in PowerFx expressions inside the workflow
|
||||
definition so unrelated environment variables never enter
|
||||
the PowerFx scope.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
@@ -171,18 +151,6 @@ class WorkflowFactory:
|
||||
checkpoint_storage=FileCheckpointStorage("./checkpoints"),
|
||||
env_file=".env",
|
||||
)
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
# Inject named values for =Env.* references in the workflow YAML
|
||||
factory = WorkflowFactory(
|
||||
configuration={
|
||||
"MY_SERVER_URL": "https://example.com",
|
||||
"MY_TOOL_NAME": "search",
|
||||
},
|
||||
)
|
||||
"""
|
||||
self._agent_factory = agent_factory or AgentFactory(env_file_path=env_file)
|
||||
self._agents: dict[str, SupportsAgentRun | AgentExecutor] = dict(agents) if agents else {}
|
||||
@@ -192,8 +160,6 @@ class WorkflowFactory:
|
||||
self._max_iterations = max_iterations
|
||||
self._http_request_handler = http_request_handler
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
self._configuration: dict[str, str] = dict(configuration) if configuration else {}
|
||||
self._restrict_env_to_configuration = restrict_env_to_configuration
|
||||
|
||||
def create_workflow_from_yaml_path(
|
||||
self,
|
||||
@@ -428,16 +394,6 @@ class WorkflowFactory:
|
||||
if description:
|
||||
normalized_def["description"] = description
|
||||
|
||||
# Build the DeclarativeEnvConfig from the factory's configuration and the
|
||||
# set of Env references actually used in the workflow PowerFx expressions.
|
||||
# The referenced-name allowlist constrains ``os.environ`` fallback (when
|
||||
# enabled) so unrelated variables never enter the PowerFx scope.
|
||||
env_config = DeclarativeEnvConfig(
|
||||
values=dict(self._configuration),
|
||||
restrict_to_configuration=self._restrict_env_to_configuration,
|
||||
referenced_names=frozenset(discover_env_references(normalized_def)),
|
||||
)
|
||||
|
||||
# Build the graph-based workflow, passing agents and tools for specialized executors
|
||||
try:
|
||||
graph_builder = DeclarativeWorkflowBuilder(
|
||||
@@ -449,7 +405,6 @@ class WorkflowFactory:
|
||||
max_iterations=self._max_iterations,
|
||||
http_request_handler=self._http_request_handler,
|
||||
mcp_tool_handler=self._mcp_tool_handler,
|
||||
env_config=env_config,
|
||||
)
|
||||
workflow = graph_builder.build()
|
||||
except ValueError as e:
|
||||
|
||||
@@ -33,7 +33,7 @@ import logging
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, runtime_checkable
|
||||
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -194,21 +194,6 @@ class DefaultMCPToolHandler:
|
||||
Defaults to ``32``.
|
||||
"""
|
||||
|
||||
LIST_TOOLS_TOOL_NAME: ClassVar[str] = "tools/list"
|
||||
"""Reserved ``tool_name`` that maps an :class:`MCPToolHandler` invocation
|
||||
to the MCP protocol ``tools/list`` discovery operation.
|
||||
|
||||
The constant matches the underlying MCP method name so a single
|
||||
string travels unchanged through host code, YAML, and the protocol
|
||||
wire. When this handler receives an invocation with this name it
|
||||
pages through ``session.list_tools()`` and returns the catalog as a
|
||||
single ``TextContent`` containing JSON of shape
|
||||
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
|
||||
Workflows can reference this name from an ``InvokeMcpTool`` declarative
|
||||
action to introspect a server's tool surface without an extra round-trip
|
||||
from host code.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -232,27 +217,10 @@ class DefaultMCPToolHandler:
|
||||
self._closed = False
|
||||
|
||||
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server.
|
||||
|
||||
The reserved name :attr:`LIST_TOOLS_TOOL_NAME` (``"tools/list"``) is
|
||||
intercepted client-side: instead of being forwarded as a tool call,
|
||||
it is translated to an MCP ``session.list_tools()`` discovery
|
||||
operation (paginated automatically) and returned as a single
|
||||
``TextContent`` containing a JSON tool catalog.
|
||||
"""
|
||||
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server."""
|
||||
from agent_framework import Content
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
# Reserved-name args validation runs before connect: rejecting bad
|
||||
# input shouldn't require establishing an MCP session.
|
||||
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME and invocation.arguments:
|
||||
message = f"The reserved MCP '{self.LIST_TOOLS_TOOL_NAME}' operation does not accept tool arguments."
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
try:
|
||||
entry = await self._get_or_create_entry(invocation)
|
||||
except Exception as exc:
|
||||
@@ -272,8 +240,6 @@ class DefaultMCPToolHandler:
|
||||
)
|
||||
|
||||
try:
|
||||
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME:
|
||||
return await self._invoke_list_tools(entry)
|
||||
raw = await entry.tool.call_tool(invocation.tool_name, **invocation.arguments)
|
||||
except ToolExecutionException as exc:
|
||||
logger.info(
|
||||
@@ -318,59 +284,6 @@ class DefaultMCPToolHandler:
|
||||
outputs = list(raw)
|
||||
return MCPToolResult(outputs=outputs)
|
||||
|
||||
@staticmethod
|
||||
async def _invoke_list_tools(entry: _CacheEntry) -> MCPToolResult:
|
||||
"""Handle the reserved :attr:`LIST_TOOLS_TOOL_NAME` invocation.
|
||||
|
||||
Pages through ``session.list_tools()`` (mirroring the pagination loop
|
||||
in :meth:`agent_framework.MCPTool.load_tools`) and serialises the
|
||||
full catalog as a single ``TextContent`` containing JSON of shape
|
||||
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
|
||||
|
||||
The output shape, property names, and property order are stable so
|
||||
downstream PowerFx expressions can rely on the schema. ``indent=2``
|
||||
produces human-readable JSON for the conversation log;
|
||||
``allow_nan=False`` guards against producing non-conformant JSON
|
||||
``NaN``/``Infinity`` tokens if a misbehaving server returns such
|
||||
values in a schema.
|
||||
"""
|
||||
from agent_framework import Content
|
||||
|
||||
session = getattr(entry.tool, "session", None)
|
||||
if session is None:
|
||||
message = "MCP session is not connected; cannot list tools."
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
# Lazy import keeps ``mcp`` types out of module import time.
|
||||
from mcp import types as mcp_types
|
||||
|
||||
collected: list[Any] = []
|
||||
params: mcp_types.PaginatedRequestParams | None = None
|
||||
while True:
|
||||
tool_list = await session.list_tools(params=params)
|
||||
collected.extend(tool_list.tools)
|
||||
next_cursor = getattr(tool_list, "nextCursor", None)
|
||||
if not next_cursor:
|
||||
break
|
||||
params = mcp_types.PaginatedRequestParams(cursor=next_cursor)
|
||||
|
||||
payload = {
|
||||
"tools": [
|
||||
{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"inputSchema": tool.inputSchema,
|
||||
"outputSchema": tool.outputSchema,
|
||||
}
|
||||
for tool in collected
|
||||
],
|
||||
}
|
||||
return MCPToolResult(outputs=[Content.from_text(json.dumps(payload, indent=2, allow_nan=False))])
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close all cached MCP clients and the owned httpx clients.
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ owned-vs-caller httpx close semantics.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
@@ -34,55 +33,6 @@ pytestmark = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
class FakeListToolsResult: # noqa: B903 - mimics ``mcp.types.ListToolsResult`` shape, not a value type
|
||||
"""Stand-in for ``mcp.types.ListToolsResult`` returned by ``session.list_tools()``."""
|
||||
|
||||
def __init__(self, tools: list[Any], next_cursor: str | None = None) -> None:
|
||||
self.tools = tools
|
||||
self.nextCursor = next_cursor
|
||||
|
||||
|
||||
class FakeMcpTool:
|
||||
"""Stand-in for an MCP ``Tool`` (subset used by ``_invoke_list_tools``)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
inputSchema: dict[str, Any] | None = None,
|
||||
outputSchema: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.inputSchema = inputSchema if inputSchema is not None else {"type": "object", "properties": {}}
|
||||
self.outputSchema = outputSchema
|
||||
|
||||
|
||||
class FakeMcpSession:
|
||||
"""Stand-in for ``mcp.ClientSession``.
|
||||
|
||||
``list_tools_pages`` lets a test enqueue multiple paginated responses;
|
||||
when None (default), an empty single-page result is returned. ``list_tools_error``
|
||||
raises a synthetic error on the next call when set.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.list_tools_pages: list[FakeListToolsResult] | None = None
|
||||
self.list_tools_calls: list[Any] = []
|
||||
self.list_tools_error: BaseException | None = None
|
||||
|
||||
async def list_tools(self, params: Any = None) -> FakeListToolsResult:
|
||||
self.list_tools_calls.append(params)
|
||||
if self.list_tools_error is not None:
|
||||
raise self.list_tools_error
|
||||
if self.list_tools_pages is None:
|
||||
return FakeListToolsResult(tools=[])
|
||||
index = len(self.list_tools_calls) - 1
|
||||
if index >= len(self.list_tools_pages):
|
||||
return FakeListToolsResult(tools=[])
|
||||
return self.list_tools_pages[index]
|
||||
|
||||
|
||||
class FakeTool:
|
||||
"""Stand-in for ``MCPStreamableHTTPTool``.
|
||||
|
||||
@@ -100,7 +50,6 @@ class FakeTool:
|
||||
self.connect_error: BaseException | None = None
|
||||
self.call_handler: Any = lambda **_a: [Content.from_text("ok")]
|
||||
self._httpx_client: httpx.AsyncClient | None = None
|
||||
self.session: FakeMcpSession | None = None
|
||||
# Mimic MCPStreamableHTTPTool: when no caller client AND header_provider
|
||||
# is set, lazily allocate an owned httpx client during connect.
|
||||
FakeTool.instances.append(self)
|
||||
@@ -114,9 +63,6 @@ class FakeTool:
|
||||
# Mimic lazy httpx allocation when no client provided AND header_provider set.
|
||||
if self.kwargs.get("http_client") is None and self.kwargs.get("header_provider") is not None:
|
||||
self._httpx_client = httpx.AsyncClient()
|
||||
# Mimic MCPStreamableHTTPTool: a live session becomes available after connect.
|
||||
if self.session is None:
|
||||
self.session = FakeMcpSession()
|
||||
|
||||
async def close(self) -> None:
|
||||
self.close_count += 1
|
||||
@@ -595,185 +541,3 @@ class TestCacheKey:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "Bearer-A"})
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "bearer-a"})
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
# ---------- tools/list reserved name --------------------------------------
|
||||
|
||||
|
||||
class TestListTools:
|
||||
"""Exercise the reserved :attr:`DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME` interception path."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_returns_json_catalog(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
# Prime the cache so the FakeTool session exists.
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(
|
||||
tools=[
|
||||
FakeMcpTool(
|
||||
name="search",
|
||||
description="Search docs",
|
||||
inputSchema={"type": "object", "properties": {"q": {"type": "string"}}},
|
||||
outputSchema={"type": "object"},
|
||||
),
|
||||
FakeMcpTool(name="echo", description=None, outputSchema=None),
|
||||
],
|
||||
),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert result.is_error is False
|
||||
assert len(result.outputs) == 1
|
||||
payload = json.loads(result.outputs[0].text) # type: ignore[reportAttributeAccessIssue]
|
||||
assert payload == {
|
||||
"tools": [
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search docs",
|
||||
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}},
|
||||
"outputSchema": {"type": "object"},
|
||||
},
|
||||
{
|
||||
"name": "echo",
|
||||
"description": None,
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"outputSchema": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_property_order_is_stable(self) -> None:
|
||||
"""JSON property order is stable: name, description, inputSchema, outputSchema."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="t1", description="d")]),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
|
||||
name_idx = text.find('"name"')
|
||||
desc_idx = text.find('"description"')
|
||||
input_idx = text.find('"inputSchema"')
|
||||
output_idx = text.find('"outputSchema"')
|
||||
assert 0 <= name_idx < desc_idx < input_idx < output_idx
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_indented_output(self) -> None:
|
||||
"""Output is JSON with a 2-space indent so the conversation log is human-readable."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="t1")]),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
|
||||
# Indented output contains newlines and a 2-space indented key.
|
||||
assert "\n " in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_rejects_arguments(self) -> None:
|
||||
"""Reserved name does NOT accept tool arguments. Fails fast before connect."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
result = await handler.invoke_tool(
|
||||
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={"q": "test"}),
|
||||
)
|
||||
assert result.is_error is True
|
||||
assert "does not accept tool arguments" in (result.error_message or "")
|
||||
# Args validation runs before connect, so no tool was instantiated.
|
||||
assert FakeTool.instances == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_empty_args_dict_is_accepted(self) -> None:
|
||||
"""An empty arguments dict is equivalent to no arguments."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
result = await handler.invoke_tool(
|
||||
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={}),
|
||||
)
|
||||
assert result.is_error is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_paginates(self) -> None:
|
||||
"""Pagination loop calls list_tools repeatedly until nextCursor is empty."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="a")], next_cursor="cursor1"),
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="b")], next_cursor="cursor2"),
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="c")], next_cursor=None),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
payload = json.loads(result.outputs[0].text) # type: ignore[reportAttributeAccessIssue]
|
||||
assert [t["name"] for t in payload["tools"]] == ["a", "b", "c"]
|
||||
session = FakeTool.instances[0].session
|
||||
assert session is not None
|
||||
assert len(session.list_tools_calls) == 3
|
||||
# First call has no cursor; second/third use the cursor from the prior page.
|
||||
assert session.list_tools_calls[0] is None
|
||||
assert getattr(session.list_tools_calls[1], "cursor", None) == "cursor1"
|
||||
assert getattr(session.list_tools_calls[2], "cursor", None) == "cursor2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_shares_cache_with_call_tool(self) -> None:
|
||||
"""tools/list reuses the same cached MCP session as a regular call_tool."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(tool_name="search"))
|
||||
await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].connect_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_propagates_session_errors_as_error_result(self) -> None:
|
||||
"""Errors raised by session.list_tools become MCPToolResult(is_error=True), not crashes."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_error = httpx.ReadTimeout("read timed out") # type: ignore[union-attr]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert result.is_error is True
|
||||
assert "ReadTimeout" in (result.error_message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_returns_error_when_session_is_none(self) -> None:
|
||||
"""If somehow the cached tool has no session, return a clear error rather than crashing."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session = None
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert result.is_error is True
|
||||
assert "not connected" in (result.error_message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_does_not_call_call_tool(self) -> None:
|
||||
"""The reserved name is intercepted; the inner call_tool path is bypassed."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
call_tool_invoked = False
|
||||
|
||||
def fail(**_a: Any) -> Any:
|
||||
nonlocal call_tool_invoked
|
||||
call_tool_invoked = True
|
||||
raise AssertionError("call_tool should not run for tools/list")
|
||||
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].call_handler = fail
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(tools=[]),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert call_tool_invoked is False
|
||||
assert result.is_error is False
|
||||
|
||||
def test_class_attribute_value(self) -> None:
|
||||
# Constant must equal the MCP protocol method name so a single
|
||||
# string travels unchanged through host code, YAML, and the wire.
|
||||
assert DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME == "tools/list"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -40,22 +40,11 @@ import type {
|
||||
ExtendedResponseStreamEvent,
|
||||
} from "@/types";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
import { loadStreamingState, type StreamingState } from "@/services/streaming-state";
|
||||
import { loadStreamingState } from "@/services/streaming-state";
|
||||
|
||||
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
|
||||
|
||||
const ASSISTANT_TEXT_RENDER_INTERVAL_MS = 50;
|
||||
const STREAMING_PREVIEW_PREFIX = "[Earlier streaming content omitted after refresh]\n\n";
|
||||
|
||||
function getRestoredStreamingText(state: StreamingState): string {
|
||||
if (!state.accumulatedText) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return state.accumulatedTextIsPreview
|
||||
? `${STREAMING_PREVIEW_PREFIX}${state.accumulatedText}`
|
||||
: state.accumulatedText;
|
||||
}
|
||||
|
||||
interface AgentViewProps {
|
||||
selectedAgent: AgentInfo;
|
||||
@@ -694,14 +683,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const state = loadStreamingState(mostRecent.id);
|
||||
|
||||
if (state && !state.completed) {
|
||||
const restoredText = getRestoredStreamingText(state);
|
||||
accumulatedTextRef.current = restoredText;
|
||||
accumulatedTextRef.current = state.accumulatedText || "";
|
||||
// Add assistant message with resumed text
|
||||
const assistantMsg: import("@/types/openai").ConversationMessage = {
|
||||
id: state.lastMessageId || `assistant-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: restoredText ? [{ type: "text", text: restoredText }] : [],
|
||||
content: state.accumulatedText ? [{ type: "text", text: state.accumulatedText }] : [],
|
||||
status: "in_progress",
|
||||
};
|
||||
setChatItems([...allItems as import("@/types/openai").ConversationItem[], assistantMsg]);
|
||||
@@ -1000,14 +988,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// Check for incomplete stream and restore accumulated text
|
||||
const state = loadStreamingState(conversationId);
|
||||
if (state?.accumulatedText) {
|
||||
const restoredText = getRestoredStreamingText(state);
|
||||
accumulatedTextRef.current = restoredText;
|
||||
accumulatedTextRef.current = state.accumulatedText;
|
||||
// Add assistant message with resumed text - streaming will continue automatically
|
||||
const assistantMsg: import("@/types/openai").ConversationMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: restoredText }],
|
||||
content: [{ type: "output_text", text: state.accumulatedText }],
|
||||
status: "in_progress",
|
||||
};
|
||||
setChatItems([...items, assistantMsg]);
|
||||
|
||||
@@ -49,28 +49,6 @@ interface WorkflowViewProps {
|
||||
onDebugEvent: DebugEventHandler;
|
||||
}
|
||||
|
||||
function getWorkflowEventTimestamp(event: ExtendedResponseStreamEvent): number | undefined {
|
||||
if ("created_at" in event && typeof event.created_at === "number" && event.created_at) {
|
||||
return event.created_at;
|
||||
}
|
||||
|
||||
const response = "response" in event ? event.response : undefined;
|
||||
if (response && typeof response === "object" && "created_at" in response) {
|
||||
const createdAt = response.created_at;
|
||||
if (typeof createdAt === "number") {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
const data = "data" in event ? event.data : undefined;
|
||||
if (data && typeof data === "object" && "timestamp" in data && typeof data.timestamp === "string") {
|
||||
const milliseconds = new Date(data.timestamp).getTime();
|
||||
return Number.isFinite(milliseconds) ? milliseconds / 1000 : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// TODO: CheckpointSelector is not currently used but may be needed for checkpoint resumption feature
|
||||
// Smart Run Workflow Button Component moved to separate file
|
||||
|
||||
@@ -603,7 +581,20 @@ export function WorkflowView({
|
||||
// 2. response.created_at (response.created / lifecycle events)
|
||||
// 3. data.timestamp (response.workflow_event.completed ISO string)
|
||||
// Fall back to a synthesized timestamp only when none is present.
|
||||
const eventTimestamp = getWorkflowEventTimestamp(openAIEvent);
|
||||
const anyEvent = openAIEvent as Record<string, unknown>;
|
||||
const eventTimestamp: number | undefined =
|
||||
typeof anyEvent["created_at"] === "number" && anyEvent["created_at"]
|
||||
? (anyEvent["created_at"] as number)
|
||||
: typeof (anyEvent["response"] as Record<string, unknown> | undefined)?.["created_at"] === "number"
|
||||
? ((anyEvent["response"] as Record<string, number>)["created_at"] as number)
|
||||
: (() => {
|
||||
const ts = (anyEvent["data"] as Record<string, unknown> | undefined)?.["timestamp"];
|
||||
if (typeof ts !== "string") return undefined;
|
||||
const ms = new Date(ts).getTime();
|
||||
// Guard against NaN: Python isoformat() emits microseconds without Z,
|
||||
// which some JS engines cannot parse. Number.isFinite rejects NaN.
|
||||
return Number.isFinite(ms) ? ms / 1000 : undefined;
|
||||
})();
|
||||
const baseTimestamp = Math.floor(Date.now() / 1000);
|
||||
const lastTimestamp =
|
||||
prev.length > 0
|
||||
@@ -1027,7 +1018,20 @@ export function WorkflowView({
|
||||
// 2. response.created_at (response.created / lifecycle events)
|
||||
// 3. data.timestamp (response.workflow_event.completed ISO string)
|
||||
// Fall back to a synthesized timestamp only when none is present.
|
||||
const eventTimestamp = getWorkflowEventTimestamp(openAIEvent);
|
||||
const anyEvent = openAIEvent as Record<string, unknown>;
|
||||
const eventTimestamp: number | undefined =
|
||||
typeof anyEvent["created_at"] === "number" && anyEvent["created_at"]
|
||||
? (anyEvent["created_at"] as number)
|
||||
: typeof (anyEvent["response"] as Record<string, unknown> | undefined)?.["created_at"] === "number"
|
||||
? ((anyEvent["response"] as Record<string, number>)["created_at"] as number)
|
||||
: (() => {
|
||||
const ts = (anyEvent["data"] as Record<string, unknown> | undefined)?.["timestamp"];
|
||||
if (typeof ts !== "string") return undefined;
|
||||
const ms = new Date(ts).getTime();
|
||||
// Guard against NaN: Python isoformat() emits microseconds without Z,
|
||||
// which some JS engines cannot parse. Number.isFinite rejects NaN.
|
||||
return Number.isFinite(ms) ? ms / 1000 : undefined;
|
||||
})();
|
||||
const baseTimestamp = Math.floor(Date.now() / 1000);
|
||||
const lastTimestamp =
|
||||
prev.length > 0
|
||||
|
||||
@@ -600,10 +600,7 @@ function EventItem({ event }: EventItemProps) {
|
||||
event.type === "error";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-l-2 border-muted pl-3 py-2 hover:bg-muted/50 transition-colors"
|
||||
data-devui-debug-event={eventType}
|
||||
>
|
||||
<div className="border-l-2 border-muted pl-3 py-2 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||
<Icon className={`h-3 w-3 ${colorClass}`} />
|
||||
<span className="font-mono">{timestamp}</span>
|
||||
@@ -1091,17 +1088,18 @@ function EventExpandedContent({
|
||||
|
||||
function EventsTab({
|
||||
events,
|
||||
processedEvents,
|
||||
isStreaming,
|
||||
}: {
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
processedEvents: ExtendedResponseStreamEvent[];
|
||||
isStreaming?: boolean;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Process events to accumulate tool calls and reduce noise
|
||||
const processedEvents = processEventsForDisplay(events);
|
||||
|
||||
// Add separators between message rounds
|
||||
const eventsWithSeparators = useMemo(() => addSeparatorsToEvents(processedEvents), [processedEvents]);
|
||||
const eventsWithSeparators = addSeparatorsToEvents(processedEvents);
|
||||
|
||||
// Reverse events so latest appears at top
|
||||
const reversedEvents = [...eventsWithSeparators].reverse();
|
||||
@@ -1567,13 +1565,10 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsTab({
|
||||
events,
|
||||
processedEvents,
|
||||
}: {
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
processedEvents: ExtendedResponseStreamEvent[];
|
||||
}) {
|
||||
function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
// Process events first to get clean tool calls
|
||||
const processedEvents = processEventsForDisplay(events);
|
||||
|
||||
// Create call->result pairs in chronological order
|
||||
const toolEvents: ExtendedResponseStreamEvent[] = [];
|
||||
const functionCalls = processedEvents.filter(
|
||||
@@ -1760,16 +1755,15 @@ export function DebugPanel({
|
||||
const activeTab = useDevUIStore((state) => state.debugPanelTab);
|
||||
const setActiveTab = useDevUIStore((state) => state.setDebugPanelTab);
|
||||
|
||||
const processedEvents = useMemo(() => processEventsForDisplay(events), [events]);
|
||||
|
||||
// Compute counts once for tab badges (memoized to avoid perf hits)
|
||||
const counts = useMemo(() => {
|
||||
const processedEvents = processEventsForDisplay(events);
|
||||
const eventsCount = processedEvents.length;
|
||||
const tracesCount = events.filter(e => e.type === "response.trace.completed").length;
|
||||
const toolsCount = processedEvents.filter(e => e.type === "response.function_call.complete").length
|
||||
+ events.filter(e => getFunctionResultFromEvent(e) !== null).length;
|
||||
return { eventsCount, tracesCount, toolsCount };
|
||||
}, [events, processedEvents]);
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 border-l flex flex-col min-h-0">
|
||||
@@ -1815,7 +1809,7 @@ export function DebugPanel({
|
||||
</div>
|
||||
|
||||
<TabsContent value="events" className="flex-1 mt-0 overflow-hidden">
|
||||
<EventsTab events={events} processedEvents={processedEvents} isStreaming={isStreaming} />
|
||||
<EventsTab events={events} isStreaming={isStreaming} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="traces" className="flex-1 mt-0 overflow-hidden">
|
||||
@@ -1823,7 +1817,7 @@ export function DebugPanel({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tools" className="flex-1 mt-0 overflow-hidden">
|
||||
<ToolsTab events={events} processedEvents={processedEvents} />
|
||||
<ToolsTab events={events} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -519,7 +519,6 @@ class ApiClient {
|
||||
lastMessageId,
|
||||
lastSequenceNumber,
|
||||
accumulatedText: storedState?.accumulatedText,
|
||||
accumulatedTextIsPreview: storedState?.accumulatedTextIsPreview,
|
||||
}),
|
||||
event,
|
||||
currentResponseId,
|
||||
|
||||
@@ -15,13 +15,11 @@ export interface StreamingState {
|
||||
lastSequenceNumber: number;
|
||||
timestamp: number; // When this state was last updated
|
||||
completed: boolean; // Whether the stream completed successfully
|
||||
accumulatedText?: string; // Bounded tail preview for refresh restoration
|
||||
accumulatedTextIsPreview?: boolean;
|
||||
accumulatedText?: string; // Accumulated text content for quick restoration
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = "devui_streaming_state_";
|
||||
const STATE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const MAX_ACCUMULATED_TEXT_PREVIEW_CHARS = 16 * 1024;
|
||||
|
||||
interface CreateStreamingStateOptions {
|
||||
conversationId: string;
|
||||
@@ -29,7 +27,6 @@ interface CreateStreamingStateOptions {
|
||||
lastMessageId?: string;
|
||||
lastSequenceNumber?: number;
|
||||
accumulatedText?: string;
|
||||
accumulatedTextIsPreview?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,21 +36,6 @@ function getStorageKey(conversationId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${conversationId}`;
|
||||
}
|
||||
|
||||
function normalizeAccumulatedTextPreview(state: StreamingState): StreamingState {
|
||||
if (
|
||||
state.accumulatedText === undefined ||
|
||||
state.accumulatedText.length <= MAX_ACCUMULATED_TEXT_PREVIEW_CHARS
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
accumulatedText: state.accumulatedText.slice(-MAX_ACCUMULATED_TEXT_PREVIEW_CHARS),
|
||||
accumulatedTextIsPreview: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read raw streaming state from storage, including completed entries.
|
||||
*/
|
||||
@@ -74,7 +56,7 @@ function readStreamingState(conversationId: string): StreamingState | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeAccumulatedTextPreview(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,9 +68,8 @@ export function createStreamingState({
|
||||
lastMessageId,
|
||||
lastSequenceNumber = -1,
|
||||
accumulatedText,
|
||||
accumulatedTextIsPreview = false,
|
||||
}: CreateStreamingStateOptions): StreamingState {
|
||||
return normalizeAccumulatedTextPreview({
|
||||
return {
|
||||
conversationId,
|
||||
responseId,
|
||||
lastMessageId,
|
||||
@@ -96,8 +77,7 @@ export function createStreamingState({
|
||||
timestamp: Date.now(),
|
||||
completed: false,
|
||||
accumulatedText,
|
||||
accumulatedTextIsPreview,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,15 +108,7 @@ export function applyStreamingEventToState(
|
||||
typeof event.delta === "string" &&
|
||||
event.delta.length > 0
|
||||
) {
|
||||
const accumulatedText = `${state.accumulatedText ?? ""}${event.delta}`;
|
||||
const isPreview =
|
||||
state.accumulatedTextIsPreview ||
|
||||
accumulatedText.length > MAX_ACCUMULATED_TEXT_PREVIEW_CHARS;
|
||||
|
||||
nextState.accumulatedText = isPreview
|
||||
? accumulatedText.slice(-MAX_ACCUMULATED_TEXT_PREVIEW_CHARS)
|
||||
: accumulatedText;
|
||||
nextState.accumulatedTextIsPreview = isPreview;
|
||||
nextState.accumulatedText = `${state.accumulatedText ?? ""}${event.delta}`;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
@@ -148,7 +120,7 @@ export function applyStreamingEventToState(
|
||||
export function saveStreamingState(state: StreamingState): void {
|
||||
try {
|
||||
const key = getStorageKey(state.conversationId);
|
||||
const data = JSON.stringify(normalizeAccumulatedTextPreview(state));
|
||||
const data = JSON.stringify(state);
|
||||
localStorage.setItem(key, data);
|
||||
} catch (error) {
|
||||
console.error("Failed to save streaming state:", error);
|
||||
@@ -157,7 +129,7 @@ export function saveStreamingState(state: StreamingState): void {
|
||||
clearExpiredStreamingStates();
|
||||
// Try again
|
||||
const key = getStorageKey(state.conversationId);
|
||||
const data = JSON.stringify(normalizeAccumulatedTextPreview(state));
|
||||
const data = JSON.stringify(state);
|
||||
localStorage.setItem(key, data);
|
||||
} catch {
|
||||
console.error("Failed to save streaming state even after cleanup");
|
||||
|
||||
@@ -18,26 +18,6 @@ import type {
|
||||
import type { ConversationItem } from "@/types/openai";
|
||||
import type { AttachmentItem } from "@/components/ui/attachment-gallery";
|
||||
|
||||
const MAX_DEBUG_EVENTS = 1000;
|
||||
const MAX_DEBUG_TEXT_DELTA_CHARS = 2048;
|
||||
|
||||
function prepareDebugEvent(event: ExtendedResponseStreamEvent): ExtendedResponseStreamEvent {
|
||||
if (
|
||||
event.type !== "response.output_text.delta" ||
|
||||
!("delta" in event) ||
|
||||
typeof event.delta !== "string" ||
|
||||
event.delta.length <= MAX_DEBUG_TEXT_DELTA_CHARS
|
||||
) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const omittedChars = event.delta.length - MAX_DEBUG_TEXT_DELTA_CHARS;
|
||||
return {
|
||||
...event,
|
||||
delta: `${event.delta.slice(0, MAX_DEBUG_TEXT_DELTA_CHARS)}\n...[${omittedChars} chars omitted from debug view]`,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// State Interface
|
||||
// ========================================
|
||||
@@ -415,7 +395,6 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
setStreamingEnabled: (enabled) => set({ streamingEnabled: enabled }),
|
||||
addDebugEvent: (event) =>
|
||||
set((state) => {
|
||||
const eventForStorage = prepareDebugEvent(event);
|
||||
// Generate unique timestamp for each event
|
||||
// Use current time + small increment to ensure uniqueness even for rapid events
|
||||
const baseTimestamp = Math.floor(Date.now() / 1000);
|
||||
@@ -425,19 +404,16 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
const lastTimestamp = lastEvent?._uiTimestamp ?? 0;
|
||||
// Ensure new timestamp is always greater than the last one
|
||||
const uniqueTimestamp = Math.max(baseTimestamp, lastTimestamp + 1);
|
||||
const retainedEvents = state.debugEvents.length >= MAX_DEBUG_EVENTS
|
||||
? state.debugEvents.slice(-(MAX_DEBUG_EVENTS - 1))
|
||||
: state.debugEvents;
|
||||
|
||||
return {
|
||||
debugEvents: [
|
||||
...retainedEvents,
|
||||
...state.debugEvents,
|
||||
{
|
||||
...eventForStorage,
|
||||
...event,
|
||||
// Add UI display timestamp when event is received (Unix seconds)
|
||||
// Each event gets a unique timestamp to preserve chronological order
|
||||
_uiTimestamp: ('created_at' in eventForStorage && eventForStorage.created_at)
|
||||
? eventForStorage.created_at
|
||||
_uiTimestamp: ('created_at' in event && event.created_at)
|
||||
? event.created_at
|
||||
: uniqueTimestamp,
|
||||
} as ExtendedResponseStreamEvent & { _uiTimestamp: number },
|
||||
],
|
||||
|
||||
@@ -79,8 +79,6 @@ _POST_SEND_DELAY_S = 1.0
|
||||
_SAMPLE_INTERVAL_S = 0.5
|
||||
_SAMPLE_WINDOW_S = 12.0
|
||||
_MAX_RENDERER_GROWTH_MB = 500.0
|
||||
_MAX_STREAMING_STATE_STORAGE_BYTES = 64 * 1024
|
||||
_MAX_DEBUG_EVENT_DOM_ITEMS = 1000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -91,13 +89,6 @@ class _BrowserProcessRow:
|
||||
command: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _BrowserMemoryProbe:
|
||||
streaming_state_storage_bytes: int
|
||||
debug_event_dom_items: int
|
||||
js_heap_bytes: int | None
|
||||
|
||||
|
||||
class MemoryStressAgent(BaseAgent):
|
||||
"""Agent that emits many small streaming chunks."""
|
||||
|
||||
@@ -439,52 +430,6 @@ def _sample_peak_renderer_rss_mb(root_pid: int, profile_dir: str) -> float:
|
||||
return round((max(renderer_rss_kb, default=0)) / 1024, 2)
|
||||
|
||||
|
||||
async def _sample_browser_memory_probe(client: _CDPClient, *, session_id: str) -> _BrowserMemoryProbe:
|
||||
value = await client.evaluate(
|
||||
"""
|
||||
(() => {
|
||||
const storagePrefix = "devui_streaming_state_";
|
||||
const textEncoder = new TextEncoder();
|
||||
let streamingStateStorageBytes = 0;
|
||||
for (let index = 0; index < localStorage.length; index += 1) {
|
||||
const key = localStorage.key(index);
|
||||
if (!key || !key.startsWith(storagePrefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = localStorage.getItem(key) || "";
|
||||
streamingStateStorageBytes += textEncoder.encode(key).length + textEncoder.encode(item).length;
|
||||
}
|
||||
|
||||
return {
|
||||
streamingStateStorageBytes,
|
||||
debugEventDomItems: document.querySelectorAll("[data-devui-debug-event]").length,
|
||||
jsHeapBytes: performance.memory ? performance.memory.usedJSHeapSize : null,
|
||||
};
|
||||
})()
|
||||
""",
|
||||
session_id=session_id,
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
raise AssertionError(f"Expected browser memory probe object, got: {type(value).__name__}")
|
||||
|
||||
streaming_state_storage_bytes = value.get("streamingStateStorageBytes")
|
||||
debug_event_dom_items = value.get("debugEventDomItems")
|
||||
js_heap_bytes = value.get("jsHeapBytes")
|
||||
if not isinstance(streaming_state_storage_bytes, int):
|
||||
raise AssertionError("Browser memory probe did not return streamingStateStorageBytes")
|
||||
if not isinstance(debug_event_dom_items, int):
|
||||
raise AssertionError("Browser memory probe did not return debugEventDomItems")
|
||||
if js_heap_bytes is not None and not isinstance(js_heap_bytes, int):
|
||||
raise AssertionError("Browser memory probe returned invalid jsHeapBytes")
|
||||
|
||||
return _BrowserMemoryProbe(
|
||||
streaming_state_storage_bytes=streaming_state_storage_bytes,
|
||||
debug_event_dom_items=debug_event_dom_items,
|
||||
js_heap_bytes=js_heap_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _terminate_browser_processes(root_pid: int, profile_dir: str) -> None:
|
||||
browser_rows = _collect_browser_process_rows(root_pid, profile_dir)
|
||||
browser_pids = sorted({row.pid for row in browser_rows} | {root_pid}, reverse=True)
|
||||
@@ -782,7 +727,6 @@ async def test_devui_streaming_renderer_memory_is_bounded(
|
||||
|
||||
peak_renderer_rss_mb = start_renderer_rss_mb
|
||||
samples: list[tuple[float, float]] = [(0.0, start_renderer_rss_mb)]
|
||||
probe_samples: list[tuple[float, _BrowserMemoryProbe]] = []
|
||||
start_time = time.monotonic()
|
||||
|
||||
while time.monotonic() - start_time < _SAMPLE_WINDOW_S:
|
||||
@@ -793,10 +737,6 @@ async def test_devui_streaming_renderer_memory_is_bounded(
|
||||
elapsed_s = round(time.monotonic() - start_time, 2)
|
||||
samples.append((elapsed_s, current_sample))
|
||||
peak_renderer_rss_mb = max(peak_renderer_rss_mb, current_sample)
|
||||
probe_samples.append((
|
||||
elapsed_s,
|
||||
await _sample_browser_memory_probe(client, session_id=session_id),
|
||||
))
|
||||
|
||||
if peak_renderer_rss_mb - start_renderer_rss_mb > _MAX_RENDERER_GROWTH_MB:
|
||||
break
|
||||
@@ -804,44 +744,13 @@ async def test_devui_streaming_renderer_memory_is_bounded(
|
||||
await asyncio.sleep(_SAMPLE_INTERVAL_S)
|
||||
|
||||
renderer_growth_mb = round(peak_renderer_rss_mb - start_renderer_rss_mb, 2)
|
||||
max_streaming_state_storage_bytes = max(
|
||||
(probe.streaming_state_storage_bytes for _, probe in probe_samples),
|
||||
default=0,
|
||||
)
|
||||
max_debug_event_dom_items = max(
|
||||
(probe.debug_event_dom_items for _, probe in probe_samples),
|
||||
default=0,
|
||||
)
|
||||
assert renderer_growth_mb <= _MAX_RENDERER_GROWTH_MB, (
|
||||
"DevUI renderer memory grew too much during a ~1.5 MB streaming response. "
|
||||
f"start={start_renderer_rss_mb:.2f}MB "
|
||||
f"peak={peak_renderer_rss_mb:.2f}MB "
|
||||
f"growth={renderer_growth_mb:.2f}MB "
|
||||
f"budget={_MAX_RENDERER_GROWTH_MB:.2f}MB "
|
||||
f"samples={samples} "
|
||||
f"probe_samples={probe_samples}"
|
||||
)
|
||||
assert max_streaming_state_storage_bytes <= _MAX_STREAMING_STATE_STORAGE_BYTES, (
|
||||
"DevUI streaming resume state retained too much text in browser storage. "
|
||||
f"peak={max_streaming_state_storage_bytes} bytes "
|
||||
f"budget={_MAX_STREAMING_STATE_STORAGE_BYTES} bytes "
|
||||
f"probe_samples={probe_samples}"
|
||||
)
|
||||
assert max_streaming_state_storage_bytes > 0, (
|
||||
"DevUI streaming state storage was never written during the stress run "
|
||||
"(cap assertion would be vacuous). "
|
||||
f"probe_samples={probe_samples}"
|
||||
)
|
||||
assert max_debug_event_dom_items <= _MAX_DEBUG_EVENT_DOM_ITEMS, (
|
||||
"DevUI debug panel rendered too many retained streaming events. "
|
||||
f"peak={max_debug_event_dom_items} "
|
||||
f"budget={_MAX_DEBUG_EVENT_DOM_ITEMS} "
|
||||
f"probe_samples={probe_samples}"
|
||||
)
|
||||
assert max_debug_event_dom_items > 0, (
|
||||
"DevUI debug panel rendered zero events during the stress run "
|
||||
"(cap assertion would be vacuous). "
|
||||
f"probe_samples={probe_samples}"
|
||||
f"samples={samples}"
|
||||
)
|
||||
finally:
|
||||
_shutdown_browser_process(browser_process, profile_dir=profile_dir)
|
||||
|
||||
@@ -106,129 +106,3 @@ Generally available factories: `get_code_interpreter_tool`,
|
||||
| `get_browser_automation_tool(connection_id)` | `BrowserAutomationPreviewTool` |
|
||||
| `get_bing_custom_search_tool(connection_id, instance_name, ...)` | `BingCustomSearchPreviewTool` |
|
||||
| `get_a2a_tool(base_url=..., project_connection_id=..., ...)` | `A2APreviewTool` |
|
||||
## Publishing an agent as a Foundry prompt agent
|
||||
|
||||
> **Experimental — `ExperimentalFeature.TO_PROMPT_AGENT`.** `to_prompt_agent`
|
||||
> is a preview API and may change before reaching GA. The warning fires the
|
||||
> first time the `TO_PROMPT_AGENT` feature is exercised in a process and is
|
||||
> then deduplicated.
|
||||
|
||||
`to_prompt_agent(agent)` converts an `Agent` whose chat client is a
|
||||
`FoundryChatClient` into a Foundry `PromptAgentDefinition` that can be
|
||||
published with `AIProjectClient.agents.create_version(...)`. The model is read
|
||||
from `default_options["model"]` first and falls back to the bound
|
||||
`FoundryChatClient.model` (matching `Agent.__init__`'s resolution order), so
|
||||
the same agent definition you run locally can be published as a hosted prompt
|
||||
agent without restating the model deployment name.
|
||||
|
||||
Every generation parameter that has an Agent Framework equivalent is sourced
|
||||
from `agent.default_options` and translated into the matching Foundry shape by
|
||||
`_prepare_prompt_agent_options` (a module-private helper in
|
||||
`agent_framework_foundry._to_prompt_agent` that reuses the chat client's own
|
||||
request-path helpers):
|
||||
|
||||
| `default_options` key | `PromptAgentDefinition` field |
|
||||
|---|---|
|
||||
| `temperature` | `temperature` |
|
||||
| `top_p` | `top_p` |
|
||||
| `tool_choice` (dropped when no tools) | `tool_choice` (`str` / `ToolChoiceFunction` / `ToolChoiceAllowed`) |
|
||||
| `reasoning` (dict or `Reasoning`) | `reasoning` |
|
||||
| `response_format` (dict or `BaseModel`) | `text.format` |
|
||||
| `verbosity` | `text.verbosity` |
|
||||
| `text` | merged into `text` |
|
||||
|
||||
This keeps the `Agent` as the single source of truth for everything it can
|
||||
already express. Only Foundry-specific fields with no Agent Framework
|
||||
equivalent are accepted as keyword arguments on `to_prompt_agent`:
|
||||
|
||||
- `structured_inputs` — `dict[str, StructuredInputDefinition]`
|
||||
- `rai_config` — `RaiConfig`
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient, to_prompt_agent
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
credential = AzureCliCredential()
|
||||
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
|
||||
|
||||
agent = Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=project_endpoint,
|
||||
model="gpt-4o",
|
||||
credential=credential,
|
||||
),
|
||||
name="travel-agent",
|
||||
description="Helps Contoso employees book travel.",
|
||||
instructions="You are a helpful travel assistant.",
|
||||
tools=[
|
||||
FoundryChatClient.get_web_search_tool(),
|
||||
FoundryChatClient.get_code_interpreter_tool(),
|
||||
],
|
||||
# Generation parameters set on the Agent flow through automatically.
|
||||
default_options={
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.95,
|
||||
"reasoning": {"effort": "medium"},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
project_client = AIProjectClient(endpoint=project_endpoint, credential=credential)
|
||||
created = await project_client.agents.create_version(
|
||||
agent_name=agent.name,
|
||||
definition=definition,
|
||||
description=agent.description,
|
||||
)
|
||||
print(f"Published {created.name} v{created.version}")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- `agent.client` must be a `FoundryChatClient` (or subclass) — otherwise the
|
||||
converter raises `TypeError`.
|
||||
- The bound client must have a `model` set — otherwise the converter raises
|
||||
`ValueError`.
|
||||
- Foundry SDK tool instances returned by `FoundryChatClient.get_*_tool()` are
|
||||
passed through unchanged.
|
||||
- AF `FunctionTool` instances (and `@tool`-decorated callables) are emitted as
|
||||
Foundry `FunctionTool` **declarations** — the prompt agent receives the
|
||||
schema only, not the Python implementation. To execute the function when
|
||||
invoking the deployed prompt agent, connect with `FoundryAgent` and pass the
|
||||
same callable via `tools=`:
|
||||
|
||||
```python
|
||||
from agent_framework.foundry import FoundryAgent
|
||||
|
||||
deployed = FoundryAgent(
|
||||
project_endpoint=project_endpoint,
|
||||
agent_name="travel-agent",
|
||||
credential=credential,
|
||||
tools=[book_hotel], # same @tool-decorated callable used at publish time
|
||||
)
|
||||
result = await deployed.run("Book me a hotel in Seattle for 3 nights.")
|
||||
```
|
||||
|
||||
`FoundryAgent` runs the function locally when the prompt agent calls it, so
|
||||
the declaration on the server and the implementation on the client stay in
|
||||
sync via the shared `@tool` definition.
|
||||
- Local Agent Framework MCP tools cannot be published as prompt-agent tools —
|
||||
the converter raises `ValueError` and points at
|
||||
`FoundryChatClient.get_mcp_tool(...)` for hosted MCP servers.
|
||||
|
||||
See the runnable example under `samples/02-agents/providers/foundry/`:
|
||||
|
||||
- [`foundry_prompt_agents.py`](../../samples/02-agents/providers/foundry/foundry_prompt_agents.py)
|
||||
— publish with `to_prompt_agent`, then connect back with `FoundryAgent` and
|
||||
execute the same local `@tool` callable that the deployed prompt agent
|
||||
invokes by name.
|
||||
|
||||
@@ -16,7 +16,6 @@ from ._foundry_evals import (
|
||||
evaluate_traces,
|
||||
)
|
||||
from ._memory_provider import FoundryMemoryProvider
|
||||
from ._to_prompt_agent import to_prompt_agent
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
@@ -40,5 +39,4 @@ __all__ = [
|
||||
"__version__",
|
||||
"evaluate_foundry_target",
|
||||
"evaluate_traces",
|
||||
"to_prompt_agent",
|
||||
]
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Convert an Agent Framework agent into a Foundry ``PromptAgentDefinition``.
|
||||
|
||||
The converter accepts an :class:`agent_framework.Agent` whose chat client is a
|
||||
:class:`agent_framework_foundry.FoundryChatClient` (or a subclass) and returns
|
||||
a ``PromptAgentDefinition`` ready to publish via
|
||||
``AIProjectClient.agents.create_version(...)``.
|
||||
|
||||
The model is lifted from the bound ``FoundryChatClient`` so the same ``Agent``
|
||||
definition used for local execution can be published as a hosted prompt agent
|
||||
without restating the model deployment name. Generation parameters
|
||||
(``temperature``, ``top_p``, ``tool_choice``, ``reasoning``,
|
||||
``response_format`` / ``text`` / ``verbosity``) are translated from
|
||||
``agent.default_options`` by the local ``_prepare_prompt_agent_options``
|
||||
helper, which reuses the chat client's own request-path helpers so they stay
|
||||
consistent with the agent's local execution.
|
||||
|
||||
Parameters with no Agent Framework equivalent (``structured_inputs``,
|
||||
``rai_config``) are accepted as keyword arguments only.
|
||||
|
||||
Function tools derived from local Python callables are translated to Foundry
|
||||
``FunctionTool`` *declarations* only. Prompt agents are server-side, so the
|
||||
deployed agent will receive the schema for these tools but cannot execute the
|
||||
underlying Python; wiring server-side execution is the caller's responsibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from agent_framework import FunctionTool
|
||||
from agent_framework._feature_stage import ExperimentalFeature, experimental
|
||||
from agent_framework._mcp import MCPTool
|
||||
|
||||
from ._chat_client import RawFoundryChatClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent
|
||||
from azure.ai.projects.models import (
|
||||
PromptAgentDefinition,
|
||||
RaiConfig,
|
||||
StructuredInputDefinition,
|
||||
Tool,
|
||||
)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.TO_PROMPT_AGENT)
|
||||
def to_prompt_agent(
|
||||
agent: Agent,
|
||||
*,
|
||||
structured_inputs: Mapping[str, StructuredInputDefinition] | None = None,
|
||||
rai_config: RaiConfig | None = None,
|
||||
) -> PromptAgentDefinition:
|
||||
"""Convert an ``Agent`` into a Foundry ``PromptAgentDefinition``.
|
||||
|
||||
The agent's chat client must be a :class:`FoundryChatClient` (or any
|
||||
subclass). The model deployment name is lifted from the bound client.
|
||||
|
||||
All generation parameters that have an Agent Framework equivalent
|
||||
(``temperature``, ``top_p``, ``tool_choice``, ``reasoning``,
|
||||
``response_format`` / ``text`` / ``verbosity``) are sourced from
|
||||
``agent.default_options`` and translated by ``_prepare_prompt_agent_options``.
|
||||
The agent is the single source of truth for these; configure them on the
|
||||
``Agent`` (or pass ``default_options={...}`` to its constructor) rather
|
||||
than here.
|
||||
|
||||
Args:
|
||||
agent: An Agent Framework agent whose client is a ``FoundryChatClient``.
|
||||
|
||||
Keyword Args:
|
||||
structured_inputs: Mapping of structured input names to
|
||||
``StructuredInputDefinition`` entries. Foundry-only; no
|
||||
``ChatOptions`` equivalent.
|
||||
rai_config: Foundry ``RaiConfig`` to attach to the definition.
|
||||
Foundry-only; no ``ChatOptions`` equivalent.
|
||||
|
||||
Returns:
|
||||
A ``PromptAgentDefinition`` carrying the agent's model, instructions,
|
||||
tools, and generation parameters. Pass it to
|
||||
``AIProjectClient.agents.create_version(...)`` to publish.
|
||||
"""
|
||||
if not isinstance(agent.client, RawFoundryChatClient):
|
||||
raise TypeError(
|
||||
"Creating a Foundry Prompt Agent requires an Agent whose client is a FoundryChatClient; "
|
||||
f"got {type(agent.client).__name__!r}."
|
||||
)
|
||||
|
||||
# Match the resolution order Agent.__init__ uses when building default_options:
|
||||
# an agent-level model override in default_options wins over the bound client's model.
|
||||
model = agent.default_options.get("model") or agent.client.model
|
||||
if not model:
|
||||
raise ValueError(
|
||||
"Agent has no model. Set 'model' on the FoundryChatClient (via the FOUNDRY_MODEL "
|
||||
"environment variable or the model= argument), or pass default_options={'model': ...} "
|
||||
"to the Agent before converting."
|
||||
)
|
||||
|
||||
instructions = agent.default_options.get("instructions")
|
||||
tools = _convert_tools(
|
||||
agent.default_options.get("tools", []),
|
||||
getattr(agent, "mcp_tools", []),
|
||||
)
|
||||
|
||||
translated = _prepare_prompt_agent_options(
|
||||
agent.client,
|
||||
agent.default_options,
|
||||
has_tools=bool(tools),
|
||||
)
|
||||
|
||||
from azure.ai.projects.models import PromptAgentDefinition
|
||||
|
||||
kwargs: dict[str, Any] = {"model": model}
|
||||
if instructions is not None:
|
||||
kwargs["instructions"] = instructions
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
kwargs.update(translated)
|
||||
if structured_inputs is not None:
|
||||
kwargs["structured_inputs"] = dict(structured_inputs)
|
||||
if rai_config is not None:
|
||||
kwargs["rai_config"] = rai_config
|
||||
|
||||
return PromptAgentDefinition(**kwargs)
|
||||
|
||||
|
||||
def _prepare_prompt_agent_options(
|
||||
client: RawFoundryChatClient[Any],
|
||||
default_options: Mapping[str, Any],
|
||||
*,
|
||||
has_tools: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Translate ``default_options`` into ``PromptAgentDefinition`` field kwargs.
|
||||
|
||||
Reuses the chat client's own request-path helpers
|
||||
(``validate_tool_mode``, ``client._prepare_response_and_text_format``,
|
||||
``type_to_text_format_param``) so a published prompt agent stays
|
||||
consistent with the agent's local execution.
|
||||
|
||||
Only fields with a direct ``PromptAgentDefinition`` counterpart are
|
||||
translated: ``temperature``, ``top_p``, ``reasoning``, ``tool_choice``,
|
||||
``response_format`` / ``text`` / ``verbosity``. Other ``OpenAIChatOptions``
|
||||
keys (``include``, ``prompt``, ``store``, etc.) have no prompt-agent
|
||||
equivalent and are intentionally ignored. The input mapping is never
|
||||
mutated.
|
||||
|
||||
Args:
|
||||
client: The bound ``FoundryChatClient`` (used to reuse its
|
||||
``_prepare_response_and_text_format`` for dict-shaped
|
||||
``response_format`` values).
|
||||
default_options: The agent's ``default_options`` mapping.
|
||||
|
||||
Keyword Args:
|
||||
has_tools: When ``False``, ``tool_choice`` is dropped (no point
|
||||
emitting a tool selection policy when the definition has no
|
||||
tools), mirroring the regular request path in
|
||||
``_prepare_options``.
|
||||
|
||||
Returns:
|
||||
A dict ready to splat into ``PromptAgentDefinition(**...)``. Unset
|
||||
fields are omitted.
|
||||
"""
|
||||
from agent_framework._types import validate_tool_mode
|
||||
from azure.ai.projects.models import (
|
||||
PromptAgentDefinitionTextOptions,
|
||||
Reasoning,
|
||||
ToolChoiceAllowed,
|
||||
ToolChoiceFunction,
|
||||
)
|
||||
from openai.lib._parsing._responses import ( # type: ignore[reportPrivateImportUsage]
|
||||
type_to_text_format_param,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
if (temperature := default_options.get("temperature")) is not None:
|
||||
result["temperature"] = temperature
|
||||
if (top_p := default_options.get("top_p")) is not None:
|
||||
result["top_p"] = top_p
|
||||
|
||||
if (reasoning := default_options.get("reasoning")) is not None:
|
||||
if isinstance(reasoning, Reasoning):
|
||||
result["reasoning"] = reasoning
|
||||
elif isinstance(reasoning, Mapping):
|
||||
result["reasoning"] = Reasoning(**dict(cast("Mapping[str, Any]", reasoning)))
|
||||
else:
|
||||
result["reasoning"] = reasoning
|
||||
|
||||
if has_tools and (tool_choice := default_options.get("tool_choice")) is not None:
|
||||
tool_mode = validate_tool_mode(tool_choice)
|
||||
if tool_mode is not None:
|
||||
mode = tool_mode.get("mode")
|
||||
func_name = tool_mode.get("required_function_name")
|
||||
allowed = tool_mode.get("allowed_tools")
|
||||
if mode == "required" and func_name is not None:
|
||||
result["tool_choice"] = ToolChoiceFunction(name=func_name)
|
||||
elif mode == "auto" and allowed is not None:
|
||||
result["tool_choice"] = ToolChoiceAllowed(
|
||||
mode="auto",
|
||||
tools=[{"type": "function", "name": name} for name in allowed],
|
||||
)
|
||||
else:
|
||||
result["tool_choice"] = mode
|
||||
|
||||
existing_text = default_options.get("text")
|
||||
text_config: dict[str, Any] | None = (
|
||||
dict(cast("Mapping[str, Any]", existing_text)) if isinstance(existing_text, Mapping) else None
|
||||
)
|
||||
response_format = default_options.get("response_format")
|
||||
if response_format is not None or text_config is not None:
|
||||
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
|
||||
format_config = dict(type_to_text_format_param(response_format))
|
||||
text_config = dict(text_config) if text_config else {}
|
||||
if "format" in text_config and text_config["format"] != format_config:
|
||||
raise ValueError("Conflicting response_format definitions detected.")
|
||||
text_config["format"] = format_config
|
||||
elif response_format is not None:
|
||||
response_format_model, text_config = client._prepare_response_and_text_format( # pyright: ignore[reportPrivateUsage]
|
||||
response_format=response_format, text_config=text_config
|
||||
)
|
||||
if response_format_model is not None:
|
||||
raise ValueError(
|
||||
"response_format must be a Pydantic BaseModel subclass or a mapping when "
|
||||
"converting to a PromptAgentDefinition."
|
||||
)
|
||||
if (verbosity := default_options.get("verbosity")) is not None:
|
||||
text_config = dict(text_config) if text_config else {}
|
||||
text_config["verbosity"] = verbosity
|
||||
if text_config:
|
||||
result["text"] = PromptAgentDefinitionTextOptions(text_config)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _convert_tools(
|
||||
tools: Iterable[Any] | None,
|
||||
mcp_tools: Iterable[MCPTool] | None,
|
||||
) -> list[Tool]:
|
||||
"""Map AF agent tools to Foundry ``PromptAgentDefinition`` tool entries.
|
||||
|
||||
Tool sources walked, in order:
|
||||
|
||||
* ``agent.default_options["tools"]`` — function tools and hosted Foundry SDK
|
||||
tool instances (returned by ``FoundryChatClient.get_*_tool()``).
|
||||
* ``agent.mcp_tools`` — local Agent Framework MCP servers (split off from
|
||||
the tools list by ``normalize_tools()``). These cannot be published as
|
||||
prompt-agent tools; the caller must use the hosted MCP factory instead.
|
||||
|
||||
Hosted SDK tool instances are passed through unchanged. Mapping/dict tools
|
||||
are passed through after light validation. Anything else raises
|
||||
``ValueError`` with a message that names the offending type.
|
||||
"""
|
||||
from azure.ai.projects.models import Tool as ProjectsTool
|
||||
|
||||
converted: list[Tool] = []
|
||||
|
||||
for tool_item in tools or ():
|
||||
if isinstance(tool_item, ProjectsTool):
|
||||
converted.append(tool_item)
|
||||
continue
|
||||
if isinstance(tool_item, FunctionTool):
|
||||
converted.append(_function_tool_to_foundry(tool_item))
|
||||
continue
|
||||
if isinstance(tool_item, Mapping):
|
||||
converted.append(_validate_mapping_tool(cast("Mapping[str, Any]", tool_item)))
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Unsupported tool type for PromptAgentDefinition: {type(tool_item).__name__}. "
|
||||
"Use FoundryChatClient.get_*_tool() helpers, a callable / FunctionTool, "
|
||||
"or a dict matching the Foundry tool schema."
|
||||
)
|
||||
|
||||
for mcp_tool in mcp_tools or ():
|
||||
raise ValueError(
|
||||
f"Local MCP tool {mcp_tool.name!r} cannot be published as a prompt-agent tool. "
|
||||
"Use FoundryChatClient.get_mcp_tool(...) to register a hosted MCP server instead."
|
||||
)
|
||||
|
||||
return converted
|
||||
|
||||
|
||||
def _function_tool_to_foundry(tool_item: FunctionTool) -> Tool:
|
||||
"""Build a Foundry ``FunctionTool`` declaration from an AF ``FunctionTool``.
|
||||
|
||||
The result carries only the schema (name, description, parameters). It is a
|
||||
declaration of the tool the prompt agent may call; server-side execution
|
||||
must be wired separately by the caller.
|
||||
"""
|
||||
try:
|
||||
from azure.ai.projects.models import FunctionTool as ProjectsFunctionTool
|
||||
except ImportError as exc: # pragma: no cover - sanity guard
|
||||
raise ImportError(
|
||||
"FunctionTool is not available in the installed azure-ai-projects. Upgrade azure-ai-projects."
|
||||
) from exc
|
||||
|
||||
return ProjectsFunctionTool(
|
||||
name=tool_item.name,
|
||||
description=tool_item.description or "",
|
||||
parameters=tool_item.parameters(),
|
||||
strict=True,
|
||||
)
|
||||
|
||||
|
||||
def _validate_mapping_tool(tool_item: Mapping[str, Any]) -> Tool:
|
||||
"""Validate a dict-shaped tool and instantiate a Foundry ``Tool``.
|
||||
|
||||
The Foundry SDK can rehydrate a tool model from its raw JSON mapping via
|
||||
the discriminator on ``type``. We require the ``type`` field so the
|
||||
failure mode is obvious; everything else is dispatched through the SDK's
|
||||
``Tool._deserialize`` entry point so the concrete subclass
|
||||
(e.g. ``FunctionTool``, ``WebSearchTool``) is materialized rather than a
|
||||
generic ``Tool`` instance.
|
||||
"""
|
||||
from azure.ai.projects.models import Tool as ProjectsTool
|
||||
|
||||
if "type" not in tool_item:
|
||||
raise ValueError("Dict-shaped tools must include a 'type' field matching a Foundry tool discriminator.")
|
||||
# ``_deserialize`` is the SDK's discriminator-aware entry point. It is marked
|
||||
# protected by convention but is the standard way to rehydrate polymorphic
|
||||
# azure-sdk-for-python models from a raw mapping.
|
||||
return cast("Tool", ProjectsTool._deserialize(dict(tool_item), [])) # type: ignore[no-untyped-call] # pyright: ignore[reportPrivateUsage, reportUnknownMemberType]
|
||||
@@ -1,664 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agent_framework import Agent, MCPStdioTool, tool
|
||||
from agent_framework._feature_stage import ExperimentalFeature
|
||||
from azure.ai.projects.models import (
|
||||
CodeInterpreterTool,
|
||||
PromptAgentDefinition,
|
||||
PromptAgentDefinitionTextOptions,
|
||||
RaiConfig,
|
||||
Reasoning,
|
||||
StructuredInputDefinition,
|
||||
ToolChoiceAllowed,
|
||||
ToolChoiceFunction,
|
||||
WebSearchTool,
|
||||
)
|
||||
from azure.ai.projects.models import (
|
||||
FunctionTool as ProjectsFunctionTool,
|
||||
)
|
||||
from azure.ai.projects.models import (
|
||||
MCPTool as FoundryMCPTool,
|
||||
)
|
||||
from azure.ai.projects.models import (
|
||||
Tool as ProjectsTool,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_foundry import (
|
||||
FoundryChatClient,
|
||||
RawFoundryChatClient,
|
||||
to_prompt_agent,
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: Annotated[str, "City name"]) -> str:
|
||||
"""Get the weather for a location."""
|
||||
return f"sunny in {location}"
|
||||
|
||||
|
||||
def _make_foundry_chat_client(model: str | None = "gpt-4o-mini") -> FoundryChatClient:
|
||||
"""Build a FoundryChatClient backed by a mocked project client."""
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
return FoundryChatClient(project_client=mock_project, model=model or "placeholder")
|
||||
|
||||
|
||||
def _make_agent(client: Any, **agent_kwargs: Any) -> Agent:
|
||||
"""Build an Agent without entering the async context manager."""
|
||||
return Agent(client=client, **agent_kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core conversion: model resolution and client-type guarding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_minimal() -> None:
|
||||
"""An agent with only model + instructions produces a valid PromptAgentDefinition."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="Be helpful.")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition, PromptAgentDefinition)
|
||||
assert definition.model == "gpt-4o-mini"
|
||||
assert definition.instructions == "Be helpful."
|
||||
assert definition.tools is None
|
||||
|
||||
|
||||
def test_to_prompt_agent_serializes_cleanly() -> None:
|
||||
"""The PromptAgentDefinition serializes to a dict that includes ``kind: prompt``."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="Hi.")
|
||||
|
||||
payload = to_prompt_agent(agent).as_dict()
|
||||
|
||||
assert payload["model"] == "gpt-4o-mini"
|
||||
assert payload["instructions"] == "Hi."
|
||||
assert payload["kind"] == "prompt"
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_non_foundry_client() -> None:
|
||||
"""A non-FoundryChatClient client raises TypeError."""
|
||||
|
||||
class NotFoundryChatClient:
|
||||
"""Stand-in for a different chat client implementation."""
|
||||
|
||||
agent = _make_agent(NotFoundryChatClient())
|
||||
|
||||
with pytest.raises(TypeError, match="FoundryChatClient"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_missing_model() -> None:
|
||||
"""When neither default_options nor the client has a model, ValueError is raised."""
|
||||
client = _make_foundry_chat_client()
|
||||
client.model = ""
|
||||
agent = _make_agent(client)
|
||||
agent.default_options.pop("model", None)
|
||||
|
||||
with pytest.raises(ValueError, match="Agent has no model"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_no_instructions() -> None:
|
||||
"""A tool-only agent (no instructions) produces a definition with instructions=None."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
tools=[WebSearchTool()],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "gpt-4o-mini"
|
||||
assert definition.instructions is None
|
||||
payload = definition.as_dict()
|
||||
assert "instructions" not in payload
|
||||
|
||||
|
||||
def test_to_prompt_agent_prefers_default_options_model() -> None:
|
||||
"""default_options['model'] wins over the bound client's model."""
|
||||
client = _make_foundry_chat_client(model="client-model")
|
||||
agent = _make_agent(client, instructions="x", default_options={"model": "agent-override"})
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "agent-override"
|
||||
|
||||
|
||||
def test_to_prompt_agent_falls_back_to_client_model() -> None:
|
||||
"""When the agent has no model override, the bound client's model is used."""
|
||||
agent = _make_agent(_make_foundry_chat_client(model="client-model"), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "client-model"
|
||||
|
||||
|
||||
def test_to_prompt_agent_works_with_raw_foundry_chat_client() -> None:
|
||||
"""to_prompt_agent accepts subclasses too — RawFoundryChatClient works."""
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
raw_client = RawFoundryChatClient(project_client=mock_project, model="gpt-4o")
|
||||
agent = _make_agent(raw_client, instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "gpt-4o"
|
||||
|
||||
|
||||
def test_to_prompt_agent_is_marked_experimental() -> None:
|
||||
"""to_prompt_agent carries the TO_PROMPT_AGENT experimental metadata."""
|
||||
assert getattr(to_prompt_agent, "__feature_stage__", None) == "experimental"
|
||||
assert getattr(to_prompt_agent, "__feature_id__", None) == ExperimentalFeature.TO_PROMPT_AGENT.value
|
||||
|
||||
|
||||
def test_to_prompt_agent_does_not_mutate_default_options() -> None:
|
||||
"""Conversion never mutates the translatable option values in ``agent.default_options``."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.5,
|
||||
"reasoning": {"effort": "low"},
|
||||
"response_format": {"type": "json_object"},
|
||||
"verbosity": "low",
|
||||
},
|
||||
tools=[get_weather],
|
||||
)
|
||||
reasoning_before = dict(agent.default_options["reasoning"]) # type: ignore[index]
|
||||
response_format_before = dict(agent.default_options["response_format"]) # type: ignore[index]
|
||||
tool_choice_before = agent.default_options.get("tool_choice")
|
||||
|
||||
to_prompt_agent(agent)
|
||||
|
||||
assert dict(agent.default_options["reasoning"]) == reasoning_before # type: ignore[index]
|
||||
assert dict(agent.default_options["response_format"]) == response_format_before # type: ignore[index]
|
||||
assert agent.default_options.get("tool_choice") == tool_choice_before
|
||||
assert "text" not in agent.default_options
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_passes_through_sdk_tool_instances() -> None:
|
||||
"""Foundry SDK tool instances (e.g. WebSearchTool) are passed through unchanged."""
|
||||
ws = WebSearchTool()
|
||||
ci = CodeInterpreterTool(container={"type": "auto"})
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[ws, ci])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 2
|
||||
assert definition.tools[0] is ws
|
||||
assert definition.tools[1] is ci
|
||||
|
||||
|
||||
def test_to_prompt_agent_converts_function_tool() -> None:
|
||||
"""An AF FunctionTool from @tool emerges as a Foundry FunctionTool declaration."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[get_weather])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
fn = definition.tools[0]
|
||||
assert isinstance(fn, ProjectsFunctionTool)
|
||||
assert fn.name == "get_weather"
|
||||
assert fn.description == "Get the weather for a location."
|
||||
assert fn.strict is True
|
||||
parameters = fn.parameters
|
||||
assert parameters["type"] == "object"
|
||||
assert "location" in parameters["properties"]
|
||||
assert parameters["required"] == ["location"]
|
||||
|
||||
|
||||
def test_to_prompt_agent_preserves_mixed_tool_order() -> None:
|
||||
"""A mix of hosted SDK tools and function tools is preserved in definition order."""
|
||||
ws = WebSearchTool()
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[ws, get_weather],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert definition.tools[0] is ws
|
||||
assert isinstance(definition.tools[1], ProjectsFunctionTool)
|
||||
assert definition.tools[1].name == "get_weather"
|
||||
|
||||
|
||||
def test_to_prompt_agent_passes_through_hosted_mcp_tool() -> None:
|
||||
"""A hosted MCP tool from FoundryChatClient.get_mcp_tool() is passed through."""
|
||||
hosted_mcp = FoundryChatClient.get_mcp_tool(
|
||||
name="github",
|
||||
url="https://mcp.example.com",
|
||||
)
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[hosted_mcp])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
assert isinstance(definition.tools[0], FoundryMCPTool)
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_local_mcp_tool() -> None:
|
||||
"""A local MCP tool in agent.mcp_tools raises a ValueError pointing at get_mcp_tool."""
|
||||
local_mcp = MCPStdioTool(name="local_fs", command="echo")
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[local_mcp])
|
||||
|
||||
with pytest.raises(ValueError, match="get_mcp_tool"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_unknown_tool_type() -> None:
|
||||
"""An arbitrary object in tools that isn't a known shape raises ValueError."""
|
||||
|
||||
class NotATool:
|
||||
pass
|
||||
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[NotATool()],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="NotATool"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_accepts_dict_tool() -> None:
|
||||
"""A dict with a 'type' discriminator is rehydrated through the SDK Tool model."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[{"type": "web_search"}],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
tool_obj = definition.tools[0]
|
||||
# The SDK discriminator on ``type`` should materialize the concrete subclass
|
||||
# (here ``WebSearchTool``), not a generic ``Tool``.
|
||||
assert isinstance(tool_obj, WebSearchTool)
|
||||
assert isinstance(tool_obj, ProjectsTool)
|
||||
assert tool_obj.type == "web_search"
|
||||
|
||||
|
||||
def test_to_prompt_agent_accepts_dict_function_tool() -> None:
|
||||
"""A dict with ``type='function'`` rehydrates to a Foundry ``FunctionTool``."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"description": "Look up a value.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
tool_obj = definition.tools[0]
|
||||
assert isinstance(tool_obj, ProjectsFunctionTool)
|
||||
assert tool_obj.name == "lookup"
|
||||
assert tool_obj.description == "Look up a value."
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_dict_tool_without_type() -> None:
|
||||
"""A dict missing the 'type' field raises ValueError."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[{"name": "missing_type"}],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="type"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generation parameters sourced from default_options
|
||||
# (translated by _prepare_prompt_agent_options in _to_prompt_agent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_temperature_top_p_unset_by_default() -> None:
|
||||
"""Without default_options entries, temperature/top_p are unset on the definition."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.temperature is None
|
||||
assert definition.top_p is None
|
||||
payload = definition.as_dict()
|
||||
assert "temperature" not in payload
|
||||
assert "top_p" not in payload
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_temperature_top_p_from_default_options() -> None:
|
||||
"""temperature/top_p in default_options flow through to the definition."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"temperature": 0.42, "top_p": 0.8},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.temperature == 0.42
|
||||
assert definition.top_p == 0.8
|
||||
|
||||
|
||||
def test_to_prompt_agent_temperature_zero_is_honored() -> None:
|
||||
"""A literal ``0.0`` in default_options is treated as explicit, not as unset."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"temperature": 0.0, "top_p": 0.0},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.temperature == 0.0
|
||||
assert definition.top_p == 0.0
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_omitted_when_no_tools() -> None:
|
||||
"""``tool_choice`` is dropped when the definition has no tools.
|
||||
|
||||
Mirrors RawOpenAIChatClient._prepare_options behavior. This also keeps
|
||||
Agent.__init__'s default ``tool_choice="auto"`` from polluting tool-less
|
||||
prompt agents.
|
||||
"""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tool_choice is None
|
||||
assert "tool_choice" not in definition.as_dict()
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_auto_with_tools() -> None:
|
||||
"""When tools are present, the default ``tool_choice="auto"`` flows through."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[get_weather])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tool_choice == "auto"
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_required_string_with_tools() -> None:
|
||||
"""A string ``tool_choice="required"`` flows through when tools are present."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[get_weather],
|
||||
default_options={"tool_choice": "required"},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tool_choice == "required"
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_required_function_dict() -> None:
|
||||
"""tool_choice mode=required with a function name → ToolChoiceFunction."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[get_weather],
|
||||
default_options={
|
||||
"tool_choice": {"mode": "required", "required_function_name": "get_weather"},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.tool_choice, ToolChoiceFunction)
|
||||
assert definition.tool_choice.name == "get_weather"
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_auto_allowed_tools() -> None:
|
||||
"""tool_choice mode=auto with allowed_tools → ToolChoiceAllowed."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[get_weather],
|
||||
default_options={
|
||||
"tool_choice": {"mode": "auto", "allowed_tools": ["get_weather"]},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.tool_choice, ToolChoiceAllowed)
|
||||
assert definition.tool_choice.mode == "auto"
|
||||
assert definition.tool_choice.tools == [{"type": "function", "name": "get_weather"}]
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_reasoning_dict_from_default_options() -> None:
|
||||
"""A reasoning dict in default_options becomes a Foundry ``Reasoning`` model."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"reasoning": {"effort": "high", "summary": "concise"}},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.reasoning, Reasoning)
|
||||
assert definition.reasoning.effort == "high"
|
||||
assert definition.reasoning.summary == "concise"
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_reasoning_model_from_default_options() -> None:
|
||||
"""A pre-built ``Reasoning`` model in default_options is passed through."""
|
||||
reasoning = Reasoning(effort="medium")
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"reasoning": reasoning},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.reasoning is reasoning
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_response_format_dict_to_text() -> None:
|
||||
"""A ``response_format`` dict in default_options becomes ``text.format``."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "weather",
|
||||
"schema": {"type": "object", "properties": {"temp": {"type": "number"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
format_dict = definition.text["format"]
|
||||
assert format_dict is not None
|
||||
assert format_dict["type"] == "json_schema"
|
||||
assert format_dict["name"] == "weather"
|
||||
assert format_dict["schema"] == {"type": "object", "properties": {"temp": {"type": "number"}}}
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_response_format_pydantic_to_text() -> None:
|
||||
"""A Pydantic ``BaseModel`` response_format becomes ``text.format`` json_schema."""
|
||||
|
||||
class WeatherReply(BaseModel):
|
||||
location: str
|
||||
condition: str
|
||||
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"response_format": WeatherReply},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
format_dict = definition.text["format"]
|
||||
assert format_dict is not None
|
||||
assert format_dict["type"] == "json_schema"
|
||||
assert format_dict["name"] == "WeatherReply"
|
||||
assert "schema" in format_dict
|
||||
assert "location" in format_dict["schema"]["properties"]
|
||||
|
||||
|
||||
def test_to_prompt_agent_merges_verbosity_into_text() -> None:
|
||||
"""A ``verbosity`` entry merges into the ``text`` config."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"verbosity": "low"},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
# PromptAgentDefinitionTextOptions only declares ``format``, but its
|
||||
# mapping-init preserves extra keys for server-side use.
|
||||
assert dict(definition.text).get("verbosity") == "low"
|
||||
|
||||
|
||||
def test_to_prompt_agent_raises_on_conflicting_response_format_and_text_format() -> None:
|
||||
"""Pydantic ``response_format`` + a different ``text.format`` mapping must fail loudly."""
|
||||
|
||||
class WeatherReply(BaseModel):
|
||||
location: str
|
||||
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"response_format": WeatherReply,
|
||||
"text": {"format": {"type": "json_object"}},
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Conflicting response_format"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_passes_through_text_dict_from_default_options() -> None:
|
||||
"""A ``text`` dict in default_options flows through to the definition."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"text": {"format": {"type": "text"}, "verbosity": "high"}},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
assert definition.text["format"] == {"type": "text"}
|
||||
assert dict(definition.text).get("verbosity") == "high"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Foundry-specific kwargs (no AF ChatOptions equivalent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_kwarg_only_fields_unset_by_default() -> None:
|
||||
"""structured_inputs and rai_config are absent from the payload when unset."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
payload = to_prompt_agent(agent).as_dict()
|
||||
|
||||
assert "structured_inputs" not in payload
|
||||
assert "rai_config" not in payload
|
||||
|
||||
|
||||
def test_to_prompt_agent_forwards_structured_inputs_kwarg() -> None:
|
||||
"""A ``structured_inputs`` mapping is forwarded (and copied to a new dict)."""
|
||||
inputs = {"city": StructuredInputDefinition(description="Target city.")}
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent, structured_inputs=inputs)
|
||||
|
||||
assert definition.structured_inputs is not None
|
||||
assert set(definition.structured_inputs) == {"city"}
|
||||
assert definition.structured_inputs["city"] is inputs["city"]
|
||||
inputs["other"] = StructuredInputDefinition(description="x")
|
||||
assert "other" not in definition.structured_inputs
|
||||
|
||||
|
||||
def test_to_prompt_agent_forwards_rai_config_kwarg() -> None:
|
||||
"""A ``RaiConfig`` kwarg is forwarded to the definition."""
|
||||
rai_config = RaiConfig()
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent, rai_config=rai_config)
|
||||
|
||||
assert definition.rai_config is rai_config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Combined integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_combines_all_sources() -> None:
|
||||
"""Generation params from default_options + Foundry-only kwargs combine cleanly."""
|
||||
rai_config = RaiConfig()
|
||||
structured = {"q": StructuredInputDefinition(description="query")}
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.95,
|
||||
"tool_choice": "auto",
|
||||
"reasoning": {"effort": "medium"},
|
||||
"verbosity": "low",
|
||||
},
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(
|
||||
agent,
|
||||
structured_inputs=structured,
|
||||
rai_config=rai_config,
|
||||
)
|
||||
|
||||
assert definition.temperature == 0.3
|
||||
assert definition.top_p == 0.95
|
||||
assert definition.tool_choice == "auto"
|
||||
assert isinstance(definition.reasoning, Reasoning)
|
||||
assert definition.reasoning.effort == "medium"
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
assert dict(definition.text).get("verbosity") == "low"
|
||||
assert definition.rai_config is rai_config
|
||||
assert definition.structured_inputs is not None and "q" in definition.structured_inputs
|
||||
assert definition.tools is not None and len(definition.tools) == 1
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user