mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a35926518a | ||
|
|
e44be6611f |
@@ -60,8 +60,9 @@ jobs:
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
@@ -95,10 +96,10 @@ jobs:
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
defaults:
|
||||
run:
|
||||
@@ -125,9 +126,7 @@ jobs:
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/openai/tests/openai/test_openai_chat_completion_client_azure.py
|
||||
packages/openai/tests/openai/test_openai_chat_client_azure.py
|
||||
packages/openai/tests/openai/test_openai_embedding_client_azure.py
|
||||
packages/azure-ai/tests/azure_openai
|
||||
--ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
@@ -203,16 +202,15 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
UV_PYTHON: "3.11"
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
|
||||
FUNCTIONS_WORKER_RUNTIME: "python"
|
||||
DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
|
||||
AzureWebJobsStorage: "UseDevelopmentStorage=true"
|
||||
@@ -250,19 +248,17 @@ jobs:
|
||||
--timeout=360 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
|
||||
# Foundry integration tests
|
||||
python-tests-foundry:
|
||||
name: Python Integration Tests - Foundry
|
||||
# Azure AI integration tests
|
||||
python-tests-azure-ai:
|
||||
name: Python Integration Tests - Azure AI
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }}
|
||||
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
|
||||
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
|
||||
defaults:
|
||||
run:
|
||||
@@ -286,14 +282,9 @@ jobs:
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest
|
||||
timeout-minutes: 15
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py
|
||||
packages/foundry/tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
run: |
|
||||
uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
uv run --directory packages/foundry poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
python-tests-cosmos:
|
||||
@@ -350,7 +341,7 @@ jobs:
|
||||
python-tests-azure-openai,
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-azure-ai,
|
||||
python-tests-cosmos
|
||||
]
|
||||
steps:
|
||||
|
||||
@@ -141,8 +141,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
@@ -194,10 +195,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
defaults:
|
||||
run:
|
||||
@@ -222,9 +223,7 @@ jobs:
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/openai/tests/openai/test_openai_chat_completion_client_azure.py
|
||||
packages/openai/tests/openai/test_openai_chat_client_azure.py
|
||||
packages/openai/tests/openai/test_openai_embedding_client_azure.py
|
||||
packages/azure-ai/tests/azure_openai
|
||||
--ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
@@ -334,16 +333,15 @@ jobs:
|
||||
environment: integration
|
||||
env:
|
||||
UV_PYTHON: "3.11"
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
|
||||
FUNCTIONS_WORKER_RUNTIME: "python"
|
||||
DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
|
||||
AzureWebJobsStorage: "UseDevelopmentStorage=true"
|
||||
@@ -389,8 +387,8 @@ jobs:
|
||||
fail-on-empty: false
|
||||
title: Functions integration test results
|
||||
|
||||
python-tests-foundry:
|
||||
name: Python Integration Tests - Foundry
|
||||
python-tests-azure-ai:
|
||||
name: Python Tests - Azure AI
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
@@ -403,10 +401,8 @@ jobs:
|
||||
env:
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }}
|
||||
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
|
||||
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
|
||||
defaults:
|
||||
run:
|
||||
@@ -428,14 +424,9 @@ jobs:
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest
|
||||
timeout-minutes: 15
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py
|
||||
packages/foundry/tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
run: |
|
||||
uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
uv run --directory packages/foundry poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
working-directory: ./python
|
||||
- name: Test Azure AI samples
|
||||
timeout-minutes: 10
|
||||
@@ -522,7 +513,7 @@ jobs:
|
||||
python-tests-azure-openai,
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-azure-ai,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
steps:
|
||||
|
||||
@@ -1,960 +0,0 @@
|
||||
status: proposed
|
||||
date: 2026-03-23
|
||||
contact: sergeymenshykh
|
||||
deciders: rbarreto, westey-m, eavanvalkenburg
|
||||
---
|
||||
|
||||
# Agent Skills: Multi-Source Architecture
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The Agent Framework needs a skills system that lets agents discover and use domain-specific knowledge, reference documents, and executable scripts. Skills can originate from different sources — filesystem directories (SKILL.md files), inline C# code, or reusable class libraries — and the framework must support all three uniformly while allowing extensibility, composition, and filtering.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Skills must be definable from multiple sources: filesystem, inline code, reusable classes, etc
|
||||
- Common abstractions are needed so the provider and builder work uniformly regardless of skill origin
|
||||
- File-based scripts must support user-defined executors, enabling custom runtimes and languages; code/class-based scripts execute in-process as C# delegates
|
||||
- Skills must be filterable so consumers can include or exclude specific skills based on defined criteria
|
||||
- Multiple skill sources must be composable into a single provider
|
||||
- It must be possible to add custom skill sources (e.g., databases, REST APIs, package registries) by implementing a common abstraction
|
||||
|
||||
## Architecture
|
||||
|
||||
### Model-Facing Tools
|
||||
|
||||
Skills are presented to the model as up to three tools that progressively disclose skill content. The system prompt lists available skill names and descriptions; the model then calls these tools on demand:
|
||||
|
||||
- **`load_skill(skillName)`** — returns the full skill body (instructions, listed resources, listed scripts)
|
||||
- **`read_skill_resource(skillName, resourceName)`** — reads a supplementary resource (file-based or code-defined) associated with a skill
|
||||
- **`run_skill_script(skillName, scriptName, arguments?)`** — executes a script associated with a skill; only registered when at least one skill contains scripts
|
||||
|
||||
Each tool delegates to the corresponding method on the resolved `AgentSkill` — calling `Resource.ReadAsync()` or `Script.RunAsync()` respectively.
|
||||
|
||||
If skills have no scripts defined, the `run_skill_script` tool is **not advertised** to the model and instructions related to script execution are **not included** in the default skills instructions.
|
||||
|
||||
### Abstract Base Types
|
||||
|
||||
The architecture defines four abstract base types that all skill variants implement:
|
||||
|
||||
```csharp
|
||||
public abstract class AgentSkill
|
||||
{
|
||||
public abstract AgentSkillFrontmatter Frontmatter { get; }
|
||||
public abstract string Content { get; }
|
||||
public abstract IReadOnlyList<AgentSkillResource>? Resources { get; }
|
||||
public abstract IReadOnlyList<AgentSkillScript>? Scripts { get; }
|
||||
}
|
||||
|
||||
public abstract class AgentSkillResource
|
||||
{
|
||||
public string Name { get; }
|
||||
public string? Description { get; }
|
||||
public abstract Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public abstract class AgentSkillScript
|
||||
{
|
||||
public string Name { get; }
|
||||
public string? Description { get; }
|
||||
public abstract Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public abstract class AgentSkillsSource
|
||||
{
|
||||
public abstract Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
Skill metadata is captured via `AgentSkillFrontmatter`:
|
||||
|
||||
```csharp
|
||||
public sealed class AgentSkillFrontmatter
|
||||
{
|
||||
public AgentSkillFrontmatter(string name, string description) { ... }
|
||||
|
||||
public string Name { get; }
|
||||
public string Description { get; }
|
||||
public string? License { get; set; }
|
||||
public string? Compatibility { get; set; }
|
||||
public string? AllowedTools { get; set; }
|
||||
public AdditionalPropertiesDictionary? Metadata { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
The type hierarchy at a glance:
|
||||
|
||||
```
|
||||
AgentSkill (abstract) AgentSkillsSource (abstract)
|
||||
├── AgentFileSkill ├── AgentFileSkillsSource (public)
|
||||
└── [Programmatic] ├── AgentInMemorySkillsSource (public)
|
||||
├── AgentInlineSkill ├── AggregatingAgentSkillsSource (public)
|
||||
└── AgentClassSkill (abstract) └── DelegatingAgentSkillsSource (abstract, public)
|
||||
├── FilteringAgentSkillsSource (public)
|
||||
AgentSkillResource (abstract) ├── CachingAgentSkillsSource (public)
|
||||
├── AgentFileSkillResource └── DeduplicatingAgentSkillsSource (public)
|
||||
└── AgentInlineSkillResource
|
||||
AgentSkillScript (abstract)
|
||||
├── AgentFileSkillScript
|
||||
└── AgentInlineSkillScript
|
||||
```
|
||||
|
||||
There are two top-level categories of skills:
|
||||
|
||||
1. **File-Based Skills** — discovered from `SKILL.md` files on the filesystem. Resources and scripts are files in subdirectories.
|
||||
2. **Programmatic Skills** — defined in C# code. These are further divided into:
|
||||
- **Inline Skills** — built at runtime via the `AgentInlineSkill` class and its fluent API. Ideal for quick, agent-specific skill definitions.
|
||||
- **Class-Based Skills** — defined as reusable C# classes that subclass `AgentClassSkill`. Ideal for packaging skills as shared libraries or NuGet packages.
|
||||
|
||||
Both programmatic skill types use `AgentInlineSkillResource` and `AgentInlineSkillScript` for their resources and scripts. They are typically served by `AgentInMemorySkillsSource`, which accepts any `AgentSkill` and is not limited to programmatic skills.
|
||||
|
||||
### File-Based Skills
|
||||
|
||||
File-based skills are authored as `SKILL.md` files on disk. Resources and scripts are discovered from corresponding subfolders within the skill directory.
|
||||
|
||||
**`AgentFileSkill`** — A filesystem-based skill discovered from a directory containing a `SKILL.md` file. Parsed from YAML frontmatter; content is the raw markdown body. Resources and scripts are discovered from files in corresponding subfolders:
|
||||
|
||||
```csharp
|
||||
public sealed class AgentFileSkill : AgentSkill
|
||||
{
|
||||
internal AgentFileSkill(
|
||||
AgentSkillFrontmatter frontmatter, string content, string path,
|
||||
IReadOnlyList<AgentSkillResource>? resources = null,
|
||||
IReadOnlyList<AgentSkillScript>? scripts = null) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**`AgentFileSkillResource`** — A file-based skill resource. Reads content from a file on disk relative to the skill directory:
|
||||
|
||||
```csharp
|
||||
internal sealed class AgentFileSkillResource : AgentSkillResource
|
||||
{
|
||||
public AgentFileSkillResource(string name, string fullPath) { ... }
|
||||
|
||||
public string FullPath { get; }
|
||||
|
||||
public override Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return File.ReadAllTextAsync(FullPath, Encoding.UTF8, cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`AgentFileSkillScript`** — A file-based skill script that represents a script file on disk. Delegates execution to an external `AgentFileSkillScriptRunner` callback (e.g., runs Python/shell via `Process.Start`). Throws `NotSupportedException` if no executor is configured:
|
||||
|
||||
```csharp
|
||||
public delegate Task<object?> AgentFileSkillScriptRunner(
|
||||
AgentFileSkill skill, AgentFileSkillScript script,
|
||||
AIFunctionArguments arguments, CancellationToken cancellationToken);
|
||||
|
||||
public sealed class AgentFileSkillScript : AgentSkillScript
|
||||
{
|
||||
private readonly AgentFileSkillScriptRunner _executor;
|
||||
|
||||
internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner executor)
|
||||
: base(name) { ... }
|
||||
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, ...)
|
||||
{
|
||||
|
||||
return await _executor(fileSkill, this, arguments, cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The executor can be provided at the **provider level** via `AgentSkillsProviderBuilder.UseFileScriptRunner(executor)` and optionally overridden for a **particular file skill** or for a **set of skills** at the file skill source level, giving fine-grained control over how different scripts are executed.
|
||||
|
||||
**`AgentFileSkillsSource`** — A skill source that discovers skills from filesystem directories containing `SKILL.md` files. Recursively scans directories (max 2 levels), validates frontmatter, and enforces path traversal and symlink security checks:
|
||||
|
||||
```csharp
|
||||
public sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
{
|
||||
public AgentFileSkillsSource(
|
||||
IEnumerable<string> skillPaths,
|
||||
AgentFileSkillScriptRunner scriptRunner,
|
||||
AgentFileSkillsSourceOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**`AgentFileSkillsSourceOptions`** — Configuration options for `AgentFileSkillsSource`. Allows customizing the allowed file extensions for resources and scripts without adding constructor parameters:
|
||||
|
||||
```csharp
|
||||
public sealed class AgentFileSkillsSourceOptions
|
||||
{
|
||||
public IEnumerable<string>? AllowedResourceExtensions { get; set; }
|
||||
public IEnumerable<string>? AllowedScriptExtensions { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Example** — A file-based skill on disk and how it is added to a source:
|
||||
|
||||
```
|
||||
skills/
|
||||
└── unit-converter/
|
||||
├── SKILL.md # frontmatter + instructions
|
||||
├── resources/
|
||||
│ └── conversion-table.csv # discovered as a resource
|
||||
└── scripts/
|
||||
└── convert.py # discovered as a script
|
||||
```
|
||||
|
||||
```csharp
|
||||
var source = new AgentFileSkillsSource(skillPaths: ["./skills"], scriptRunner: SubprocessScriptRunner.RunAsync);
|
||||
|
||||
var provider = new AgentSkillsProvider(source);
|
||||
|
||||
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
AIContextProviders = [provider],
|
||||
});
|
||||
```
|
||||
|
||||
### Programmatic Skills
|
||||
|
||||
Programmatic skills are defined in C# code rather than discovered from the filesystem. There are two kinds: **inline** and **class-based**. Both use `AgentInlineSkillResource` and `AgentInlineSkillScript` for resources and scripts, and are held by a single `AgentInMemorySkillsSource`.
|
||||
|
||||
**`AgentInMemorySkillsSource`** — A general-purpose skill source that holds any `AgentSkill` instances in memory. Although commonly used for programmatic skills (`AgentInlineSkill` and `AgentClassSkill`), it accepts any `AgentSkill` subclass and is not restricted to code-defined skills:
|
||||
|
||||
```csharp
|
||||
public sealed class AgentInMemorySkillsSource : AgentSkillsSource
|
||||
{
|
||||
public AgentInMemorySkillsSource(
|
||||
IEnumerable<AgentSkill> skills,
|
||||
ILoggerFactory? loggerFactory = null) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Inline Skills
|
||||
|
||||
Inline skills are built at runtime via the `AgentInlineSkill` class and its fluent API. They are ideal for quick, agent-specific skill definitions where a full class hierarchy would be overkill.
|
||||
|
||||
**`AgentInlineSkill`** — A skill defined entirely in code. Resources can be static values or functions; scripts are always functions. Constructed with name, description, and instructions, then extended with resources and scripts:
|
||||
|
||||
```csharp
|
||||
public sealed class AgentInlineSkill : AgentSkill
|
||||
{
|
||||
public AgentInlineSkill(string name, string description, string instructions, string? license = null, string? compatibility = null, ...) { ... }
|
||||
public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions) { ... }
|
||||
|
||||
public AgentInlineSkill AddResource(object value, string name, string? description = null);
|
||||
public AgentInlineSkill AddResource(Delegate handler, string name, string? description = null);
|
||||
public AgentInlineSkill AddScript(Delegate handler, string name, string? description = null);
|
||||
}
|
||||
```
|
||||
|
||||
**`AgentInlineSkillResource`** — A skill resource that wraps a static value:
|
||||
|
||||
```csharp
|
||||
public sealed class AgentInlineSkillResource : AgentSkillResource
|
||||
{
|
||||
public AgentInlineSkillResource(object value, string name, string? description = null)
|
||||
: base(name, description)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public override Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<object?>(_value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`AgentInlineSkillResource`** — A skill resource backed by a delegate. The delegate is invoked via an `AIFunction` each time `ReadAsync` is called, producing a dynamic (computed) value:
|
||||
|
||||
```csharp
|
||||
public sealed class AgentInlineSkillResource : AgentSkillResource
|
||||
{
|
||||
public AgentInlineSkillResource(Delegate handler, string name, string? description = null)
|
||||
: base(name, description)
|
||||
{
|
||||
_function = AIFunctionFactory.Create(handler, name: name);
|
||||
}
|
||||
|
||||
public override async Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _function.InvokeAsync(new AIFunctionArguments() { Services = serviceProvider }, cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`AgentInlineSkillScript`** — A skill script backed by a delegate via an `AIFunction`:
|
||||
|
||||
```csharp
|
||||
public sealed class AgentInlineSkillScript : AgentSkillScript
|
||||
{
|
||||
private readonly AIFunction _function;
|
||||
|
||||
public AgentInlineSkillScript(Delegate handler, string name, string? description = null)
|
||||
: base(name, description)
|
||||
{
|
||||
_function = AIFunctionFactory.Create(handler, name: name);
|
||||
}
|
||||
|
||||
public JsonElement? ParametersSchema => _function.JsonSchema;
|
||||
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, ...)
|
||||
{
|
||||
return await _function.InvokeAsync(arguments, cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example** — Creating an inline skill with a resource and script, then adding it to a source:
|
||||
|
||||
```csharp
|
||||
var skill = new AgentInlineSkill(
|
||||
name: "unit-converter",
|
||||
description: "Converts between measurement units.",
|
||||
instructions: """
|
||||
Use this skill to convert values between metric and imperial units.
|
||||
Refer to the conversion-table resource for supported unit pairs.
|
||||
Run the convert script to perform conversions.
|
||||
"""
|
||||
)
|
||||
.AddResource("kg=2.205lb, m=3.281ft, L=0.264gal", "conversion-table", "Supported unit pairs")
|
||||
.AddScript(Convert, "convert", "Converts a value between units");
|
||||
|
||||
var source = new AgentInMemorySkillsSource([skill]);
|
||||
|
||||
var provider = new AgentSkillsProvider(source);
|
||||
|
||||
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
AIContextProviders = [provider],
|
||||
});
|
||||
|
||||
static string Convert(double value, double factor)
|
||||
=> JsonSerializer.Serialize(new { result = Math.Round(value * factor, 4) });
|
||||
```
|
||||
|
||||
#### Class-Based Skills
|
||||
|
||||
Class-based skills are designed for packaging skills as reusable libraries. Users subclass `AgentClassSkill` and override properties. Unlike inline skills, class-based skills are self-contained, can live in shared libraries or NuGet packages, and are well-suited for dependency injection.
|
||||
|
||||
**`AgentClassSkill`** — An abstract base class for defining skills as reusable C# classes that bundle all skill components (frontmatter, instructions, resources, scripts) together. Designed for packaging skills as distributable libraries:
|
||||
|
||||
```csharp
|
||||
public abstract class AgentClassSkill : AgentSkill
|
||||
{
|
||||
public abstract string Instructions { get; }
|
||||
|
||||
// Content is auto-synthesized from Frontmatter + Instructions + Resources + Scripts
|
||||
public override string Content =>
|
||||
SkillContentBuilder.BuildContent(Frontmatter.Name, Frontmatter.Description,
|
||||
SkillContentBuilder.BuildBody(Instructions, Resources, Scripts));
|
||||
}
|
||||
```
|
||||
|
||||
**Example** — Defining a class-based skill and adding it to a source:
|
||||
|
||||
```csharp
|
||||
public class UnitConverterSkill : AgentClassSkill
|
||||
{
|
||||
public override AgentSkillFrontmatter Frontmatter { get; } =
|
||||
new("unit-converter", "Converts between measurement units.");
|
||||
|
||||
public override string Instructions => """
|
||||
Use this skill to convert values between metric and imperial units.
|
||||
Refer to the conversion-table resource for supported unit pairs.
|
||||
Run the convert script to perform conversions.
|
||||
""";
|
||||
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources { get; } =
|
||||
[
|
||||
new AgentInlineSkillResource("kg=2.205lb, m=3.281ft", "conversion-table"),
|
||||
];
|
||||
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts { get; } =
|
||||
[
|
||||
new AgentInlineSkillScript(Convert, "convert"),
|
||||
];
|
||||
|
||||
private static string Convert(double value, double factor)
|
||||
=> JsonSerializer.Serialize(new { result = Math.Round(value * factor, 4) });
|
||||
}
|
||||
|
||||
var source = new AgentInMemorySkillsSource([new UnitConverterSkill()]);
|
||||
|
||||
var provider = new AgentSkillsProvider(source);
|
||||
|
||||
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
AIContextProviders = [provider],
|
||||
});
|
||||
```
|
||||
|
||||
## Filtering, Caching, and Deduplication
|
||||
|
||||
The following subsections present alternative approaches for handling filtering, caching, and deduplication of skills across multiple sources.
|
||||
|
||||
### Via Composition
|
||||
|
||||
In this approach, the `AgentSkillsProvider` accepts a **single** `AgentSkillsSource`. Multiple sources are composed externally via an aggregate source, and cross-cutting concerns like filtering, caching, and deduplication are implemented as **source decorators** — subclasses of `DelegatingAgentSkillsSource` that intercept `GetSkillsAsync()`.
|
||||
|
||||
**`FilteringAgentSkillsSource`** — A decorator that applies filter logic before returning results. The decorator pattern keeps filtering orthogonal to source implementations and allows composing multiple filters:
|
||||
|
||||
```csharp
|
||||
public sealed class FilteringAgentSkillsSource : DelegatingAgentSkillsSource
|
||||
{
|
||||
private readonly Func<AgentSkill, bool> _predicate;
|
||||
|
||||
public FilteringAgentSkillsSource(AgentSkillsSource innerSource, Func<AgentSkill, bool> predicate)
|
||||
: base(innerSource)
|
||||
{
|
||||
_predicate = predicate;
|
||||
}
|
||||
|
||||
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var skills = await this.InnerSource.GetSkillsAsync(cancellationToken);
|
||||
return skills.Where(_predicate).ToList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`CachingAgentSkillsSource`** — A decorator that caches skills after the first load, keeping the provider stateless and giving consumers control over caching granularity per source. For example, file-based skills (expensive to discover) can be cached while code-defined skills remain uncached:
|
||||
|
||||
```csharp
|
||||
public sealed class CachingAgentSkillsSource : DelegatingAgentSkillsSource
|
||||
{
|
||||
private IList<AgentSkill>? _cached;
|
||||
|
||||
public CachingAgentSkillsSource(AgentSkillsSource innerSource)
|
||||
: base(innerSource)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _cached ??= await this.InnerSource.GetSkillsAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Deduplication** is similarly implemented as a decorator (`DeduplicatingAgentSkillsSource`) that deduplicates by name (case-insensitive, first-one-wins) and logs a warning for skipped duplicates.
|
||||
|
||||
**Example** — Combining file-based and code-defined sources with filtering and caching:
|
||||
|
||||
```csharp
|
||||
var fileSource = new CachingAgentSkillsSource(new AgentFileSkillsSource(["./skills"]));
|
||||
var codeSource = new AgentInMemorySkillsSource([myCodeSkill]);
|
||||
|
||||
var compositeSource = new FilteringAgentSkillsSource(
|
||||
new AggregatingAgentSkillsSource([fileSource, codeSource]),
|
||||
filter: s => s.Frontmatter.Name != "internal");
|
||||
|
||||
var provider = new AgentSkillsProvider(compositeSource);
|
||||
|
||||
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
AIContextProviders = [provider],
|
||||
});
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Clean single-responsibility: the provider serves skills, sources provide them.
|
||||
- Caching, filtering, and deduplication are composable as source decorators — each concern is a separate, testable wrapper.
|
||||
|
||||
**Cons:**
|
||||
- DI is less flexible: multiple `AgentSkillsSource` implementations registered in the container cannot be auto-injected into the provider. The consumer must manually compose them via an aggregate source.
|
||||
- Increased public API surface: requires additional public classes (aggregate source, caching decorators, filtering decorators) that consumers need to learn and use.
|
||||
|
||||
### Via AgentSkillsProvider
|
||||
|
||||
In this approach, the `AgentSkillsProvider` accepts **`IEnumerable<AgentSkillsSource>`** and handles aggregation, filtering, caching, and deduplication internally.
|
||||
|
||||
The provider aggregates skills from all registered sources, deduplicates by name (case-insensitive, first-one-wins), caches the result after the first load, and optionally applies filtering via a predicate on `AgentSkillsProviderOptions`. Duplicate skill names are logged as warnings.
|
||||
|
||||
**Example** — Registering multiple sources directly with the provider:
|
||||
|
||||
```csharp
|
||||
// Conceptual example — in practice, use AgentSkillsProviderBuilder
|
||||
var fileSource = new AgentFileSkillsSource(["./skills"]);
|
||||
var codeSource = new AgentInMemorySkillsSource([myCodeSkill]);
|
||||
|
||||
var provider = new AgentSkillsProvider(
|
||||
sources: [fileSource, codeSource],
|
||||
options: new AgentSkillsProviderOptions
|
||||
{
|
||||
Filter = s => s.Frontmatter.Name != "internal",
|
||||
});
|
||||
|
||||
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
AIContextProviders = [provider],
|
||||
});
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- DI-friendly: register multiple `AgentSkillsSource` implementations in the container, and they are all auto-injected into `AgentSkillsProvider` via `IEnumerable<AgentSkillsSource>`.
|
||||
- Smaller public API surface: no need for aggregate source, caching decorators, or filtering decorator classes — these concerns are handled internally by the provider.
|
||||
|
||||
**Cons:**
|
||||
- The provider takes on multiple responsibilities — aggregation, caching, deduplication, and filtering.
|
||||
- Less granular caching control: caching is all-or-nothing across sources rather than per-source as with decorators.
|
||||
- Less extensible: new behaviors (e.g., ordering, TTL expiration) require modifying the provider rather than adding a decorator.
|
||||
|
||||
### Builder Pattern
|
||||
|
||||
**`AgentSkillsProviderBuilder`** provides a fluent API for composing skills from multiple sources. The builder centralizes configuration — script executors, approval callbacks, prompt templates, and filtering — so consumers don't need to know the underlying source types.
|
||||
|
||||
The builder internally decides how to wire up the object graph: it creates the appropriate source instances, applies caching and filtering, and returns a fully configured `AgentSkillsProvider`. This keeps the setup code concise while still allowing fine-grained control when needed.
|
||||
|
||||
**Example** — Using the builder to combine multiple source types with configuration:
|
||||
|
||||
```csharp
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseFileSkill("./skills") // file-based source
|
||||
.UseInlineSkills(codeSkill) // code-defined source
|
||||
.UseClassSkills(new ClassSkill()) // class-based source
|
||||
.UseFileScriptRunner(SubprocessScriptRunner.RunAsync) // script runner
|
||||
.UseScriptApproval() // optional human-in-the-loop
|
||||
.UsePromptTemplate(customTemplate) // optional prompt customization
|
||||
.UseFilter(s => s.Frontmatter.Name != "internal") // optional skill filtering
|
||||
.Build();
|
||||
|
||||
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
AIContextProviders = [provider],
|
||||
});
|
||||
```
|
||||
|
||||
## Adding a Custom Skill Type
|
||||
|
||||
The skills framework is designed for extensibility. While file-based and inline skills cover common
|
||||
scenarios, you can introduce entirely new skill types by subclassing the four base classes:
|
||||
|
||||
| Base class | Purpose |
|
||||
|-----------------------|-----------------------------------------------------|
|
||||
| `AgentSkillsSource` | Discovers and loads skills from a particular origin |
|
||||
| `AgentSkill` | Holds metadata, content, resources, and scripts |
|
||||
| `AgentSkillResource` | Provides supplementary content to a skill |
|
||||
| `AgentSkillScript` | Represents an executable action within a skill |
|
||||
|
||||
The example below implements a **cloud-based skill type** where skills, resources, and scripts are
|
||||
all stored in and executed through a remote cloud service (e.g., Azure Blob Storage + Azure Functions).
|
||||
|
||||
### Step 1 — Define a custom resource
|
||||
|
||||
A `CloudSkillResource` reads resource content from a cloud storage endpoint instead of the local
|
||||
filesystem:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// A skill resource backed by a cloud storage endpoint.
|
||||
/// </summary>
|
||||
public sealed class CloudSkillResource : AgentSkillResource
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public CloudSkillResource(string name, Uri blobUri, HttpClient httpClient, string? description = null)
|
||||
: base(name, description)
|
||||
{
|
||||
BlobUri = blobUri ?? throw new ArgumentNullException(nameof(blobUri));
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URI of the cloud blob that holds this resource's content.
|
||||
/// </summary>
|
||||
public Uri BlobUri { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> ReadAsync(
|
||||
IServiceProvider? serviceProvider = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _httpClient.GetStringAsync(BlobUri, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2 — Define a custom script
|
||||
|
||||
A `CloudSkillScript` executes a script by calling a cloud function endpoint, passing arguments as
|
||||
the request body:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// A skill script executed via a cloud function endpoint.
|
||||
/// </summary>
|
||||
public sealed class CloudSkillScript : AgentSkillScript
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public CloudSkillScript(string name, Uri functionUri, HttpClient httpClient, string? description = null)
|
||||
: base(name, description)
|
||||
{
|
||||
FunctionUri = functionUri ?? throw new ArgumentNullException(nameof(functionUri));
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URI of the cloud function that runs this script.
|
||||
/// </summary>
|
||||
public Uri FunctionUri { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> RunAsync(
|
||||
AgentSkill skill,
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(arguments);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var response = await _httpClient.PostAsync(FunctionUri, content, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3 — Define a custom skill
|
||||
|
||||
A `CloudSkill` bundles cloud-specific metadata (e.g., the base endpoint) with the standard skill
|
||||
shape:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// An <see cref="AgentSkill"/> whose content, resources, and scripts are stored in a cloud service.
|
||||
/// </summary>
|
||||
public sealed class CloudSkill : AgentSkill
|
||||
{
|
||||
public CloudSkill(
|
||||
AgentSkillFrontmatter frontmatter,
|
||||
string content,
|
||||
Uri endpoint,
|
||||
IReadOnlyList<AgentSkillResource>? resources = null,
|
||||
IReadOnlyList<AgentSkillScript>? scripts = null)
|
||||
{
|
||||
Frontmatter = frontmatter ?? throw new ArgumentNullException(nameof(frontmatter));
|
||||
Content = content ?? throw new ArgumentNullException(nameof(content));
|
||||
Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint));
|
||||
Resources = resources;
|
||||
Scripts = scripts;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Content { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base cloud endpoint for this skill.
|
||||
/// </summary>
|
||||
public Uri Endpoint { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts { get; }
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4 — Define a custom source
|
||||
|
||||
A `CloudSkillsSource` discovers skills from a cloud catalog API and constructs `CloudSkill`
|
||||
instances with their associated resources and scripts:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// A skill source that discovers and loads skills from a cloud catalog API.
|
||||
/// </summary>
|
||||
public sealed class CloudSkillsSource : AgentSkillsSource
|
||||
{
|
||||
private readonly Uri _catalogUri;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public CloudSkillsSource(Uri catalogUri, HttpClient httpClient)
|
||||
{
|
||||
_catalogUri = catalogUri ?? throw new ArgumentNullException(nameof(catalogUri));
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<AgentSkill>> GetSkillsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Fetch the skill catalog from the cloud service.
|
||||
var json = await _httpClient.GetStringAsync(_catalogUri, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var catalog = JsonSerializer.Deserialize<CloudSkillCatalog>(json)!;
|
||||
|
||||
var skills = new List<AgentSkill>();
|
||||
|
||||
foreach (var entry in catalog.Skills)
|
||||
{
|
||||
var frontmatter = new AgentSkillFrontmatter(entry.Name, entry.Description);
|
||||
|
||||
// Build cloud-backed resources.
|
||||
var resources = entry.Resources
|
||||
.Select(r => new CloudSkillResource(r.Name, r.BlobUri, _httpClient, r.Description))
|
||||
.ToList<AgentSkillResource>();
|
||||
|
||||
// Build cloud-backed scripts.
|
||||
var scripts = entry.Scripts
|
||||
.Select(s => new CloudSkillScript(s.Name, s.FunctionUri, _httpClient, s.Description))
|
||||
.ToList<AgentSkillScript>();
|
||||
|
||||
skills.Add(new CloudSkill(frontmatter, entry.Content, entry.Endpoint, resources, scripts));
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5 — Register with the builder
|
||||
|
||||
Use `UseSource` to wire the custom source into the provider:
|
||||
|
||||
```csharp
|
||||
var httpClient = new HttpClient();
|
||||
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseSource(new CloudSkillsSource(
|
||||
new Uri("https://my-service.example.com/skills/catalog"),
|
||||
httpClient))
|
||||
// Mix with other source types if needed:
|
||||
.UseFileSkill("/local/skills", scriptRunner)
|
||||
.UseInlineSkills(someInlineSkill)
|
||||
.Build();
|
||||
```
|
||||
|
||||
The `AgentSkillsProvider` handles all skill types uniformly — any combination of file-based, inline,
|
||||
class-based, and custom skills can coexist in the same provider. Custom skills automatically
|
||||
participate in the model-facing tools (`load_skill`, `read_skill_resource`, `run_skill_script`),
|
||||
filtering, deduplication, and caching — no additional integration work is required.
|
||||
|
||||
## Script Representation: `AgentSkillScript` vs `AIFunction`
|
||||
|
||||
Two approaches were considered for representing executable scripts within skills:
|
||||
|
||||
### Option A — Custom `AgentSkillScript` abstract base class (original design)
|
||||
|
||||
Scripts are modeled as a custom `AgentSkillScript` abstract class with `Name`, `Description`, and
|
||||
`RunAsync(AgentSkill, AIFunctionArguments, CancellationToken)`. Concrete implementations:
|
||||
`AgentInlineSkillScript` (wraps a delegate/`AIFunction`) and `AgentFileSkillScript` (wraps a file path + executor delegate).
|
||||
|
||||
```csharp
|
||||
// Base type
|
||||
public abstract class AgentSkillScript
|
||||
{
|
||||
public string Name { get; }
|
||||
public string? Description { get; }
|
||||
public abstract Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
// AgentSkill exposes scripts as:
|
||||
public abstract IReadOnlyList<AgentSkillScript>? Scripts { get; }
|
||||
|
||||
// Inline script wraps an AIFunction internally
|
||||
var script = new AgentInlineSkillScript(ConvertUnits, "convert");
|
||||
|
||||
// Pre-built AIFunction must be wrapped
|
||||
var script = new AgentInlineSkillScript(myAIFunction);
|
||||
|
||||
// Class-based skill declares scripts as:
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts { get; } =
|
||||
[
|
||||
new AgentInlineSkillScript(ConvertUnits, "convert"),
|
||||
];
|
||||
|
||||
// Provider executes scripts by passing the owning skill:
|
||||
await script.RunAsync(skill, arguments, cancellationToken);
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- **Explicit skill context at execution time.** `RunAsync` receives the owning `AgentSkill`, so any script can access skill metadata or resources during execution without requiring construction-time wiring.
|
||||
- **Self-contained abstraction.** A dedicated type communicates clearly that scripts are a skills-framework concept, separate from general-purpose AI functions.
|
||||
- **Easier extensibility for custom script types.** Third-party implementations can subclass `AgentSkillScript` and access the owning skill in `RunAsync` without special setup.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- **Wrapper overhead.** `AgentInlineSkillScript` is a thin pass-through around `AIFunction` — it adds a class, a constructor, and an indirection layer for no behavioral difference.
|
||||
- **Parallel abstraction.** `AgentSkillScript` and `AIFunction` serve overlapping purposes (named callable with arguments), creating two parallel hierarchies for the same concept.
|
||||
- **Friction for consumers.** Users who already have `AIFunction` instances must wrap them in `AgentInlineSkillScript` to use them as scripts, adding ceremony.
|
||||
|
||||
### Option B — Reuse `AIFunction` directly
|
||||
|
||||
Scripts are represented as `AIFunction` (from `Microsoft.Extensions.AI`). `AgentSkill.Scripts` returns
|
||||
`IReadOnlyList<AIFunction>?`. `AgentInlineSkillScript` is eliminated entirely — callers use
|
||||
`AIFunctionFactory.Create(delegate, name: ...)` or pass `AIFunction` instances directly.
|
||||
`AgentFileSkillScript` becomes an `AIFunction` subclass that captures its owning `AgentFileSkill` via
|
||||
an internal back-reference set during construction.
|
||||
|
||||
```csharp
|
||||
// AgentSkill exposes scripts as AIFunction directly:
|
||||
public abstract IReadOnlyList<AIFunction>? Scripts { get; }
|
||||
|
||||
// Inline scripts use AIFunctionFactory — no wrapper class needed
|
||||
var skill = new AgentInlineSkill("my-skill", "desc", "instructions");
|
||||
skill.AddScript(ConvertUnits, "convert"); // delegate
|
||||
skill.AddScript(myAIFunction); // pre-built AIFunction — no wrapping
|
||||
|
||||
// Class-based skill declares scripts as:
|
||||
public override IReadOnlyList<AIFunction>? Scripts { get; } =
|
||||
[
|
||||
AIFunctionFactory.Create(ConvertUnits, name: "convert"),
|
||||
];
|
||||
|
||||
// Provider executes scripts via standard AIFunction invocation:
|
||||
await script.InvokeAsync(arguments, cancellationToken);
|
||||
|
||||
// File-based scripts extend AIFunction and capture the owning skill internally:
|
||||
public sealed class AgentFileSkillScript : AIFunction
|
||||
{
|
||||
internal AgentFileSkill? Skill { get; set; } // set by AgentFileSkill constructor
|
||||
|
||||
protected override async ValueTask<object?> InvokeCoreAsync(
|
||||
AIFunctionArguments arguments, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _executor(Skill!, this, arguments, cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- **Fewer types.** Eliminates `AgentSkillScript` and `AgentInlineSkillScript`, reducing the public API surface by two classes.
|
||||
- **Seamless interop.** Any `AIFunction` — whether from `AIFunctionFactory`, a custom subclass, or an external library — can be used as a skill script with zero wrapping.
|
||||
- **Consistent with `Microsoft.Extensions.AI` ecosystem.** Scripts share the same type as tool functions used by `IChatClient` and `FunctionInvokingChatClient`, reducing conceptual overhead for developers already familiar with the ecosystem.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- **No owning-skill context in invocation signature.** `AIFunction.InvokeAsync` does not accept an `AgentSkill` parameter, so `AgentFileSkillScript` must capture its owning skill via an internal setter during construction. This adds a construction-order dependency: the skill must set the back-reference on its scripts.
|
||||
- **Custom script types lose automatic skill access.** Third-party `AIFunction` subclasses that need the owning skill must implement their own mechanism (e.g., constructor injection, closure capture) instead of receiving it as a method parameter.
|
||||
- **Semantic overloading.** `AIFunction` now means both "a tool the model can call" and "a script within a skill", which could blur the distinction for framework users.
|
||||
|
||||
## Resource Representation: `AgentSkillResource` vs `AIFunction`
|
||||
|
||||
Two approaches were considered for representing skill resources (supplementary content such as references, assets, or dynamic data):
|
||||
|
||||
### Option A — Custom `AgentSkillResource` abstract base class (original design)
|
||||
|
||||
Resources are modeled as a custom `AgentSkillResource` abstract class with `Name`, `Description`, and
|
||||
`ReadAsync(IServiceProvider?, CancellationToken)`. Concrete implementations:
|
||||
`AgentInlineSkillResource` (static value, delegate, or `AIFunction` wrapper) and `AgentFileSkillResource` (reads file content from disk).
|
||||
|
||||
```csharp
|
||||
// Base type
|
||||
public abstract class AgentSkillResource
|
||||
{
|
||||
public string Name { get; }
|
||||
public string? Description { get; }
|
||||
public abstract Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
// AgentSkill exposes resources as:
|
||||
public abstract IReadOnlyList<AgentSkillResource>? Resources { get; }
|
||||
|
||||
// Static resource
|
||||
var resource = new AgentInlineSkillResource("static content", "my-resource");
|
||||
|
||||
// Dynamic resource (delegate)
|
||||
var resource = new AgentInlineSkillResource((IServiceProvider sp) => GetData(sp), "my-resource");
|
||||
|
||||
// Pre-built AIFunction must be wrapped
|
||||
var resource = new AgentInlineSkillResource(myAIFunction);
|
||||
|
||||
// Class-based skill declares resources as:
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources { get; } =
|
||||
[
|
||||
new AgentInlineSkillResource("# Conversion Tables\n...", "conversion-table"),
|
||||
];
|
||||
|
||||
// Provider reads resources via:
|
||||
await resource.ReadAsync(serviceProvider, cancellationToken);
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- **Clear semantic distinction.** A dedicated `AgentSkillResource` type distinguishes resources (data providers) from scripts (executable actions), making the API self-documenting.
|
||||
- **Purpose-built API.** `ReadAsync` communicates intent better than `InvokeAsync` for a data-access operation.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- **Wrapper overhead.** `AgentInlineSkillResource` wraps `AIFunction` internally for delegate/function cases — adding a class and indirection for no behavioral difference.
|
||||
- **Parallel abstraction.** `AgentSkillResource` and `AIFunction` serve overlapping purposes (named callable that returns data), creating two parallel hierarchies.
|
||||
- **Friction for consumers.** Users who already have `AIFunction` instances must wrap them in `AgentInlineSkillResource`, adding ceremony.
|
||||
|
||||
### Option B — Reuse `AIFunction` directly
|
||||
|
||||
Resources are represented as `AIFunction`. `AgentSkill.Resources` returns `IReadOnlyList<AIFunction>?`.
|
||||
`AgentInlineSkillResource` becomes an `AIFunction` subclass (retained as a convenience for the static-value
|
||||
pattern: `new AgentInlineSkillResource("data", "name")`). `AgentFileSkillResource` becomes an `AIFunction`
|
||||
subclass that reads file content.
|
||||
|
||||
```csharp
|
||||
// AgentSkill exposes resources as AIFunction directly:
|
||||
public abstract IReadOnlyList<AIFunction>? Resources { get; }
|
||||
|
||||
// Static resource — AgentInlineSkillResource is retained as a convenience AIFunction subclass
|
||||
var resource = new AgentInlineSkillResource("static content", "my-resource");
|
||||
|
||||
// Dynamic resource — AgentInlineSkillResource wraps delegate as AIFunction
|
||||
var resource = new AgentInlineSkillResource((IServiceProvider sp) => GetData(sp), "my-resource");
|
||||
|
||||
// Pre-built AIFunction can be used directly — no wrapping needed
|
||||
skill.AddResource(myAIFunction);
|
||||
|
||||
// Class-based skill declares resources as:
|
||||
public override IReadOnlyList<AIFunction>? Resources { get; } =
|
||||
[
|
||||
new AgentInlineSkillResource("# Conversion Tables\n...", "conversion-table"),
|
||||
];
|
||||
|
||||
// Provider reads resources via standard AIFunction invocation:
|
||||
await resource.InvokeAsync(arguments, cancellationToken);
|
||||
|
||||
// File-based resources extend AIFunction directly:
|
||||
internal sealed class AgentFileSkillResource : AIFunction
|
||||
{
|
||||
public string FullPath { get; }
|
||||
|
||||
protected override async ValueTask<object?> InvokeCoreAsync(
|
||||
AIFunctionArguments arguments, CancellationToken cancellationToken)
|
||||
{
|
||||
return await File.ReadAllTextAsync(FullPath, Encoding.UTF8, cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- **Fewer base types.** Eliminates the `AgentSkillResource` abstract class, reducing the public API surface.
|
||||
- **Seamless interop.** Any `AIFunction` can be used as a skill resource with zero wrapping.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- **Loss of semantic distinction.** Resources and scripts are now both `AIFunction`, which could make it less obvious which list a function belongs to when reading code.
|
||||
- **Static values require a wrapper.** Unlike the original `ReadAsync` which could return a stored value directly, `AIFunction.InvokeAsync` implies invocation. `AgentInlineSkillResource` is retained as a convenience subclass to handle the static-value case, so this is not eliminated — just moved to a different class.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
### 1. Keep `AgentSkillResource` and `AgentSkillScript` (Option A for both sections)
|
||||
|
||||
We are staying with the custom `AgentSkillResource` and `AgentSkillScript` model classes instead of reusing `AIFunction`:
|
||||
|
||||
- **Resources have no parameters.** If a consumer provides an `AIFunction` with parameters, those parameters will never be advertised to the LLM, and the resulting call will fail.
|
||||
- **Approval breaks for `AIFunction`-based representations.** When a resource or script represented by an `AIFunction` is configured with approval, the second approval invocation will not work correctly.
|
||||
- **Injecting the owning skill into an `AIFunction`-based script is problematic.** Constructor injection would introduce a circular reference between the skill and the script. An internal property setter is possible but adds coupling.
|
||||
|
||||
### 2. Make all agent skill classes internal
|
||||
|
||||
All agent-skill-related classes are made `internal` to minimize the public API surface while the feature matures. We can reconsider and promote types to `public` later based on community signal.
|
||||
|
||||
This leaves two public entry points:
|
||||
|
||||
- **`AgentSkillsProvider`** — use directly when all skills come from a single source and filtering is not needed.
|
||||
- **`AgentSkillsProviderBuilder`** — use when mixing skill types or when filtering support is required.
|
||||
|
||||
### 3. Caching at provider level
|
||||
|
||||
Caching of tools and instructions is implemented inside `AgentSkillsProvider` rather than as an external decorator. Recreating tools and instructions on every provider call is wasteful, and a caching decorator sitting outside the provider would not have the information needed to cache them effectively.
|
||||
@@ -1,116 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: westey-m
|
||||
date: 2026-03-23
|
||||
deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub
|
||||
consulted:
|
||||
informed:
|
||||
---
|
||||
|
||||
# Chat History Persistence Consistency
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
When using `ChatClientAgent` with tools, the `FunctionInvokingChatClient` (FIC) loops multiple times — service call → tool execution → service call → … — before producing a final response. There are two points of discrepancy between how chat history is stored by the framework's `ChatHistoryProvider` and how the underlying AI service stores chat history (e.g., OpenAI Responses with `store=true`):
|
||||
|
||||
1. **Persistence timing**: The AI service persists messages after *each* service call within the FIC loop. The `ChatHistoryProvider` currently persists messages only once, at the *end* of the full agent run (after all FIC loop iterations complete).
|
||||
|
||||
2. **Trailing `FunctionResultContent` storage**: When tool calling is terminated mid-loop (e.g., via `FunctionInvokingChatClient` termination filters), the final response from the agent may contain `FunctionResultContent` that was never sent to a subsequent service call. The AI service never stores this trailing `FunctionResultContent`, but the `ChatHistoryProvider` currently stores all response content, including the trailing `FunctionResultContent`.
|
||||
|
||||
These discrepancies mean that a `ChatHistoryProvider`-managed conversation and a service-managed conversation can diverge in content and structure, even when processing the same interactions.
|
||||
|
||||
### Practical Impact: Resuming After Tool-Call Termination
|
||||
|
||||
Today, users of `AIAgent` get different behaviors depending on whether chat history is stored service-side or in a `ChatHistoryProvider`. This creates concrete challenges — for example, when the function call loop is terminated and the user wants to resume the conversation in a subsequent run. With service-stored history, the trailing `FunctionResultContent` is never persisted, so the last stored message is the `FunctionCallContent` from the service. With `ChatHistoryProvider`-stored history, the trailing `FunctionResultContent` *is* persisted. The user cannot know whether the last `FunctionResultContent` is in the chat history or not without inspecting the storage mechanism, making it difficult to write resumption logic that works correctly regardless of the storage backend.
|
||||
|
||||
### Relationship Between the Two Discrepancies
|
||||
|
||||
The persistence timing and `FunctionResultContent` trimming behaviors are interrelated:
|
||||
|
||||
- **Per-service-call persistence**: When messages are persisted after each individual service call, trailing `FunctionResultContent` trimming is unnecessary. If tool calling is terminated, the `FunctionResultContent` from the terminated call was never sent to a subsequent service call, so it is never persisted. The per-service-call approach naturally matches the service's behavior.
|
||||
|
||||
- **Per-run persistence**: When messages are batched and persisted at the end of the full run, trailing `FunctionResultContent` trimming becomes necessary to match the service's behavior. Without trimming, the stored history contains `FunctionResultContent` that the service would never have stored.
|
||||
|
||||
This means the trimming feature (introduced in [PR #4792](https://github.com/microsoft/agent-framework/pull/4792)) is primarily needed as a complement to per-run persistence. The `PersistChatHistoryAtEndOfRun` setting (introduced in [PR #4762](https://github.com/microsoft/agent-framework/pull/4762)) inverts the default so that per-service-call persistence is the standard behavior, and per-run persistence is opt-in.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **A. Consistency**: The default behavior of `ChatHistoryProvider` should produce stored history that closely matches what the underlying AI service would store, minimizing surprise when switching between framework-managed and service-managed chat history.
|
||||
- **B. Atomicity**: A run that fails mid-way through a multi-step tool-calling loop should not leave chat history in a partially-updated state, unless the user explicitly opts into that behavior.
|
||||
- **C. Recoverability**: For long-running tool-calling loops, it should be possible to recover intermediate progress if the process is interrupted, rather than losing all work from the current run.
|
||||
- **D. Simplicity**: The default behavior should be easy to understand and predict for most users, without requiring knowledge of the FIC loop internals.
|
||||
- **E. Flexibility**: Regardless of the chosen default, users should be able to opt into the alternative behavior.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Option 1: Default to per-run persistence with `FunctionResultContent` trimming (opt-in to per-service-call)
|
||||
- Option 2: Default to per-service-call persistence (opt-in to per-run)
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Option 1: Default to per-run persistence with `FunctionResultContent` trimming
|
||||
|
||||
Keep the current default behavior of persisting chat history only at the end of the full agent run. Add `FunctionResultContent` trimming as the default to improve consistency with service storage. Provide an opt-in setting for users who want per-service-call persistence.
|
||||
|
||||
Settings:
|
||||
- `PersistChatHistoryAtEndOfRun` = `true`
|
||||
|
||||
- Good, because runs are atomic — chat history is only updated when the full run succeeds, satisfying driver B.
|
||||
- Good, because the mental model is simple: one run = one history update, satisfying driver D.
|
||||
- Good, because trimming trailing `FunctionResultContent` improves consistency with service storage, partially satisfying driver A.
|
||||
- Good, because users can opt in to per-service-call persistence for checkpointing/recovery scenarios, satisfying drivers C and E.
|
||||
- Bad, because the default persistence timing still differs from the service's behavior (per-run vs. per-service-call), only partially satisfying driver A.
|
||||
- Bad, because if the process crashes mid-loop, all intermediate progress from the current run is lost, not satisfying driver C by default.
|
||||
|
||||
### Option 2: Default to per-service-call persistence
|
||||
|
||||
Change the default to persist chat history after each individual service call within the FIC loop, matching the AI service's behavior. Trailing `FunctionResultContent` trimming is unnecessary with this approach (it is naturally handled). Provide an opt-in setting for users who want per-run atomicity with trimming.
|
||||
|
||||
Settings:
|
||||
- `PersistChatHistoryAtEndOfRun` = `false` (default)
|
||||
|
||||
- Good, because the stored history matches the service's behavior by default for both timing and content, fully satisfying driver A.
|
||||
- Good, because intermediate progress is preserved if the process is interrupted, satisfying driver C.
|
||||
- Good, because no separate `FunctionResultContent` trimming logic is needed, reducing complexity.
|
||||
- Bad, because chat history may be left in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), not satisfying driver B. A subsequent run cannot proceed without manually providing the missing `FunctionResultContent`.
|
||||
- Bad, because the mental model is more complex: a single run may produce multiple history updates, partially failing driver D.
|
||||
- Neutral, because users can opt out to per-run persistence if they prefer atomicity, satisfying driver E.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: **Option 2 — Default to per-service-call persistence**, because it fully satisfies the consistency driver (A), naturally handles `FunctionResultContent` trimming without additional logic, and provides better recoverability for long-running tool-calling loops. Per-run persistence remains available via the `PersistChatHistoryAtEndOfRun` setting for users who prefer atomic run semantics.
|
||||
|
||||
### Configuration Matrix
|
||||
|
||||
The behavior depends on the combination of `UseProvidedChatClientAsIs` and `PersistChatHistoryAtEndOfRun`:
|
||||
|
||||
| `UseProvidedChatClientAsIs` | `PersistChatHistoryAtEndOfRun` | Behavior |
|
||||
|---|---|---|
|
||||
| `false` (default) | `false` (default) | **Per-service-call persistence.** A `ChatHistoryPersistingChatClient` middleware is automatically injected into the chat client pipeline between `FunctionInvokingChatClient` and the leaf `IChatClient`. Messages are persisted after each service call. |
|
||||
| `true` | `false` | **User responsibility.** No middleware is injected because the user has provided a custom chat client stack. The user is responsible for ensuring correct persistence behavior (e.g., by including their own persisting middleware). |
|
||||
| `false` | `true` | **Per-run persistence with marking.** A `ChatHistoryPersistingChatClient` middleware is injected, but configured to *mark* messages with metadata rather than store them immediately. At the end of the run, marked messages are stored. Trailing `FunctionResultContent` is trimmed. |
|
||||
| `true` | `true` | **Per-run persistence with warning.** The system checks whether the custom chat client stack includes a `ChatHistoryPersistingChatClient`. If not, a warning is emitted (particularly relevant for workflow handoff scenarios where trimming cannot be guaranteed). If no `ChatHistoryPersistingChatClient` is preset, all messages are stored at the end of the run, otherwise marked messages are stored. |
|
||||
|
||||
### Consequences
|
||||
|
||||
- Good, because the stored history matches the service's behavior by default for both timing and content, fully satisfying consistency (driver A).
|
||||
- Good, because intermediate progress is preserved if the process is interrupted, satisfying recoverability (driver C).
|
||||
- Good, because no separate `FunctionResultContent` trimming logic is needed in the default path, reducing complexity.
|
||||
- Good, because marking persisted messages with metadata enables deduplication and aids debugging.
|
||||
- Good, because warnings for custom chat client configurations without the persisting middleware help prevent silent failures in workflow handoff scenarios.
|
||||
- Bad, because chat history may be left in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), requiring manual recovery in rare cases.
|
||||
- Bad, because the mental model is more complex for the default path: a single run may produce multiple history updates.
|
||||
- Neutral, because users who prefer atomic run semantics can opt in to per-run persistence via `PersistChatHistoryAtEndOfRun = true`.
|
||||
- Neutral, because increased write frequency from per-service-call persistence may impact performance for some storage backends; this can be mitigated with a caching decorator.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
#### Conversation ID Consistency
|
||||
|
||||
The `ChatHistoryPersistingChatClient` middleware must also update the session's `ConversationId` consistently for both response-based and conversation-based service interactions, ensuring the session always reflects the latest service-provided identifier.
|
||||
|
||||
## More Information
|
||||
|
||||
- [PR #4762: Persist messages during function call loop](https://github.com/microsoft/agent-framework/pull/4762) — introduces `PersistChatHistoryAfterEachServiceCall` option and `ChatHistoryPersistingChatClient` decorator
|
||||
- [PR #4792: Trim final FRC to match service storage](https://github.com/microsoft/agent-framework/pull/4792) — introduces `StoreFinalFunctionResultContent` option and `FilterFinalFunctionResultContent` logic
|
||||
- [Issue #2889](https://github.com/microsoft/agent-framework/issues/2889) — original issue tracking chat history persistence during function call loops
|
||||
@@ -104,7 +104,7 @@
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentSkills/">
|
||||
<File Path="samples/02-agents/AgentSkills/README.md" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
|
||||
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
|
||||
|
||||
@@ -18,7 +18,6 @@ using OpenTelemetry.Trace;
|
||||
|
||||
#region Setup Telemetry
|
||||
|
||||
// Source name for this sample's custom ActivitySource and Meter; other instrumentation uses their own sources/categories.
|
||||
const string SourceName = "OpenTelemetryAspire.ConsoleApp";
|
||||
const string ServiceName = "AgentOpenTelemetry";
|
||||
|
||||
@@ -41,6 +40,7 @@ var resource = ResourceBuilder.CreateDefault()
|
||||
var tracerProviderBuilder = Sdk.CreateTracerProviderBuilder()
|
||||
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0"))
|
||||
.AddSource(SourceName) // Our custom activity source
|
||||
.AddSource("*Microsoft.Agents.AI") // Agent Framework telemetry
|
||||
.AddHttpClientInstrumentation() // Capture HTTP calls to OpenAI
|
||||
.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint));
|
||||
|
||||
@@ -54,7 +54,8 @@ using var tracerProvider = tracerProviderBuilder.Build();
|
||||
// Setup metrics with resource and instrument name filtering
|
||||
using var meterProvider = Sdk.CreateMeterProviderBuilder()
|
||||
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0"))
|
||||
.AddMeter(SourceName) // Our custom meter source
|
||||
.AddMeter(SourceName) // Our custom meter
|
||||
.AddMeter("*Microsoft.Agents.AI") // Agent Framework metrics
|
||||
.AddHttpClientInstrumentation() // HTTP client metrics
|
||||
.AddRuntimeInstrumentation() // .NET runtime metrics
|
||||
.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint))
|
||||
@@ -127,7 +128,7 @@ var agent = new ChatClientAgent(instrumentedChatClient,
|
||||
instructions: "You are a helpful assistant that provides concise and informative responses.",
|
||||
tools: [AIFunctionFactory.Create(GetWeatherAsync)])
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
|
||||
.UseOpenTelemetry(SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
|
||||
.Build();
|
||||
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
-4
@@ -14,10 +14,6 @@
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\SubprocessScriptRunner.cs" Link="SubprocessScriptRunner.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use Agent Skills with a ChatClientAgent.
|
||||
// Agent Skills are modular packages of instructions and resources that extend an agent's capabilities.
|
||||
// Skills follow the progressive disclosure pattern: advertise -> load -> read resources.
|
||||
//
|
||||
// This sample includes the expense-report skill:
|
||||
// - Policy-based expense filing with references and assets
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
// --- Configuration ---
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// --- Skills Provider ---
|
||||
// Discovers skills from the 'skills' directory and makes them available to the agent
|
||||
var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills"));
|
||||
|
||||
// --- Agent Setup ---
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.GetResponsesClient()
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "SkillsAgent",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = "You are a helpful assistant.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider],
|
||||
},
|
||||
model: deploymentName);
|
||||
|
||||
// --- Example 1: Expense policy question (loads FAQ resource) ---
|
||||
Console.WriteLine("Example 1: Checking expense policy FAQ");
|
||||
Console.WriteLine("---------------------------------------");
|
||||
AgentResponse response1 = await agent.RunAsync("Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered.");
|
||||
Console.WriteLine($"Agent: {response1.Text}\n");
|
||||
|
||||
// --- Example 2: Filing an expense report (multi-turn with template asset) ---
|
||||
Console.WriteLine("Example 2: Filing an expense report");
|
||||
Console.WriteLine("---------------------------------------");
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
AgentResponse response2 = await agent.RunAsync("I had 3 client dinners and a $1,200 flight last week. Return a draft expense report and ask about any missing details.",
|
||||
session);
|
||||
Console.WriteLine($"Agent: {response2.Text}\n");
|
||||
@@ -0,0 +1,63 @@
|
||||
# Agent Skills Sample
|
||||
|
||||
This sample demonstrates how to use **Agent Skills** with a `ChatClientAgent` in the Microsoft Agent Framework.
|
||||
|
||||
## What are Agent Skills?
|
||||
|
||||
Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern:
|
||||
|
||||
1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill)
|
||||
2. **Load**: Full instructions are loaded on-demand via `load_skill` tool
|
||||
3. **Resources**: References and other files loaded via `read_skill_resource` tool
|
||||
|
||||
## Skills Included
|
||||
|
||||
### expense-report
|
||||
Policy-based expense filing with spending limits, receipt requirements, and approval workflows.
|
||||
- `references/POLICY_FAQ.md` — Detailed expense policy Q&A
|
||||
- `assets/expense-report-template.md` — Submission template
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Agent_Step01_BasicSkills/
|
||||
├── Program.cs
|
||||
├── Agent_Step01_BasicSkills.csproj
|
||||
└── skills/
|
||||
└── expense-report/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ └── POLICY_FAQ.md
|
||||
└── assets/
|
||||
└── expense-report-template.md
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
### Prerequisites
|
||||
- .NET 10.0 SDK
|
||||
- Azure OpenAI endpoint with a deployed model
|
||||
|
||||
### Setup
|
||||
1. Set environment variables:
|
||||
```bash
|
||||
export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
|
||||
export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
|
||||
```
|
||||
|
||||
2. Run the sample:
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
The sample runs two examples:
|
||||
|
||||
1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource
|
||||
2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Agent Skills Specification](https://agentskills.io/)
|
||||
- [Microsoft Agent Framework Documentation](../../../../../docs/)
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: expense-report
|
||||
description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories.
|
||||
metadata:
|
||||
author: contoso-finance
|
||||
version: "2.1"
|
||||
---
|
||||
|
||||
# Expense Report
|
||||
|
||||
## Categories and Limits
|
||||
|
||||
| Category | Limit | Receipt | Approval |
|
||||
|---|---|---|---|
|
||||
| Meals — solo | $50/day | >$25 | No |
|
||||
| Meals — team/client | $75/person | Always | Manager if >$200 total |
|
||||
| Lodging | $250/night | Always | Manager if >3 nights |
|
||||
| Ground transport | $100/day | >$15 | No |
|
||||
| Airfare | Economy | Always | Manager; VP if >$1,500 |
|
||||
| Conference/training | $2,000/event | Always | Manager + L&D |
|
||||
| Office supplies | $100 | Yes | No |
|
||||
| Software/subscriptions | $50/month | Yes | Manager if >$200/year |
|
||||
|
||||
## Filing Process
|
||||
|
||||
1. Collect receipts — must show vendor, date, amount, payment method.
|
||||
2. Categorize per table above.
|
||||
3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md).
|
||||
4. For client/team meals: list attendee names and business purpose.
|
||||
5. Submit — auto-approved if <$500; manager if $500–$2,000; VP if >$2,000.
|
||||
6. Reimbursement: 10 business days via direct deposit.
|
||||
|
||||
## Policy Rules
|
||||
|
||||
- Submit within 30 days of transaction.
|
||||
- Alcohol is never reimbursable.
|
||||
- Foreign currency: convert to USD at transaction-date rate; note original currency and amount.
|
||||
- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes.
|
||||
- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter.
|
||||
- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
# Expense Report Template
|
||||
|
||||
| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached |
|
||||
|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------|
|
||||
| | | | | | | | | | Yes or No |
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Expense Policy — Frequently Asked Questions
|
||||
|
||||
## Meals
|
||||
|
||||
**Q: Can I expense coffee or snacks during the workday?**
|
||||
A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal.
|
||||
|
||||
**Q: What if a team dinner exceeds the per-person limit?**
|
||||
A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP.
|
||||
|
||||
**Q: Do I need to list every attendee?**
|
||||
A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list.
|
||||
|
||||
## Travel
|
||||
|
||||
**Q: Can I book a premium economy or business class flight?**
|
||||
A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation.
|
||||
|
||||
**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?**
|
||||
A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people.
|
||||
|
||||
**Q: Are tips reimbursable?**
|
||||
A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification.
|
||||
|
||||
## Lodging
|
||||
|
||||
**Q: What if the $250/night limit isn't enough for the city I'm visiting?**
|
||||
A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking.
|
||||
|
||||
**Q: Can I stay with friends/family instead and get a per-diem?**
|
||||
A: No. Contoso reimburses actual lodging costs only, not per-diems.
|
||||
|
||||
## Subscriptions and Software
|
||||
|
||||
**Q: Can I expense a personal productivity tool?**
|
||||
A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing.
|
||||
|
||||
**Q: What about annual subscriptions?**
|
||||
A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report.
|
||||
|
||||
## Receipts and Documentation
|
||||
|
||||
**Q: My receipt is faded/damaged. What do I do?**
|
||||
A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter.
|
||||
|
||||
**Q: Do I need a receipt for parking meters or tolls?**
|
||||
A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required.
|
||||
|
||||
## Approval and Reimbursement
|
||||
|
||||
**Q: My manager is on leave. Who approves my report?**
|
||||
A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system.
|
||||
|
||||
**Q: Can I submit expenses from a previous quarter?**
|
||||
A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval.
|
||||
@@ -1,48 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use file-based Agent Skills with a ChatClientAgent.
|
||||
// Skills are discovered from SKILL.md files on disk and follow the progressive disclosure pattern:
|
||||
// 1. Advertise — skill names and descriptions in the system prompt
|
||||
// 2. Load — full instructions loaded on demand via load_skill tool
|
||||
// 3. Read resources — reference files read via read_skill_resource tool
|
||||
// 4. Run scripts — scripts executed via run_skill_script tool with a subprocess executor
|
||||
//
|
||||
// This sample uses a unit-converter skill that converts between miles, kilometers, pounds, and kilograms.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
// --- Configuration ---
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// --- Skills Provider ---
|
||||
// Discovers skills from the 'skills' directory containing SKILL.md files.
|
||||
// The script runner runs file-based scripts (e.g. Python) as local subprocesses.
|
||||
var skillsProvider = new AgentSkillsProvider(
|
||||
Path.Combine(AppContext.BaseDirectory, "skills"),
|
||||
SubprocessScriptRunner.RunAsync);
|
||||
// --- Agent Setup ---
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.GetResponsesClient()
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "UnitConverterAgent",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = "You are a helpful assistant that can convert units.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider],
|
||||
},
|
||||
model: deploymentName);
|
||||
|
||||
// --- Example: Unit conversion ---
|
||||
Console.WriteLine("Converting units with file-based skills");
|
||||
Console.WriteLine(new string('-', 60));
|
||||
|
||||
AgentResponse response = await agent.RunAsync(
|
||||
"How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?");
|
||||
|
||||
Console.WriteLine($"Agent: {response.Text}");
|
||||
@@ -1,51 +0,0 @@
|
||||
# File-Based Agent Skills Sample
|
||||
|
||||
This sample demonstrates how to use **file-based Agent Skills** with a `ChatClientAgent`.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- Discovering skills from `SKILL.md` files on disk via `AgentFileSkillsSource`
|
||||
- The progressive disclosure pattern: advertise → load → read resources → run scripts
|
||||
- Using the `AgentSkillsProvider` constructor with a skill directory path and script executor
|
||||
- Running file-based scripts (Python) via a subprocess-based executor
|
||||
|
||||
## Skills Included
|
||||
|
||||
### unit-converter
|
||||
|
||||
Converts between common units (miles↔km, pounds↔kg) using a multiplication factor.
|
||||
|
||||
- `references/conversion-table.md` — Conversion factor table
|
||||
- `scripts/convert.py` — Python script that performs the conversion
|
||||
|
||||
## Running the Sample
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 10.0 SDK
|
||||
- Azure OpenAI endpoint with a deployed model
|
||||
- Python 3 installed and available as `python3` on your PATH
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
|
||||
export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
### Expected Output
|
||||
|
||||
```
|
||||
Converting units with file-based skills
|
||||
------------------------------------------------------------
|
||||
Agent: Here are your conversions:
|
||||
|
||||
1. **26.2 miles → 42.16 km** (a marathon distance)
|
||||
2. **75 kg → 165.35 lbs**
|
||||
```
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: unit-converter
|
||||
description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
When the user requests a unit conversion:
|
||||
1. First, review `references/conversion-table.md` to find the correct factor
|
||||
2. Run the `scripts/convert.py` script with `--value <number> --factor <factor>` (e.g. `--value 26.2 --factor 1.60934`)
|
||||
3. Present the converted value clearly with both units
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
# Conversion Tables
|
||||
|
||||
Formula: **result = value Ă— factor**
|
||||
|
||||
| From | To | Factor |
|
||||
|-------------|-------------|----------|
|
||||
| miles | kilometers | 1.60934 |
|
||||
| kilometers | miles | 0.621371 |
|
||||
| pounds | kilograms | 0.453592 |
|
||||
| kilograms | pounds | 2.20462 |
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
# Unit conversion script
|
||||
# Converts a value using a multiplication factor: result = value Ă— factor
|
||||
#
|
||||
# Usage:
|
||||
# python scripts/convert.py --value 26.2 --factor 1.60934
|
||||
# python scripts/convert.py --value 75 --factor 2.20462
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert a value using a multiplication factor.",
|
||||
epilog="Examples:\n"
|
||||
" python scripts/convert.py --value 26.2 --factor 1.60934\n"
|
||||
" python scripts/convert.py --value 75 --factor 2.20462",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.")
|
||||
parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = round(args.value * args.factor, 4)
|
||||
print(json.dumps({"value": args.value, "factor": args.factor, "result": result}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,4 +4,4 @@ Samples demonstrating Agent Skills capabilities.
|
||||
|
||||
| Sample | Description |
|
||||
|--------|-------------|
|
||||
| [Agent_Step01_FileBasedSkills](Agent_Step01_FileBasedSkills/) | Define skills as `SKILL.md` files on disk with reference documents. Uses a unit-converter skill. |
|
||||
| [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources |
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Sample subprocess-based skill script runner.
|
||||
// Executes file-based skill scripts as local subprocesses.
|
||||
// This is provided for demonstration purposes only.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Executes file-based skill scripts as local subprocesses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This runner uses the script's absolute path, converts the arguments
|
||||
/// to CLI flags, and returns captured output. It is intended for
|
||||
/// demonstration purposes only.
|
||||
/// </remarks>
|
||||
internal static class SubprocessScriptRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs a skill script as a local subprocess.
|
||||
/// </summary>
|
||||
public static async Task<object?> RunAsync(
|
||||
AgentFileSkill skill,
|
||||
AgentFileSkillScript script,
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(script.FullPath))
|
||||
{
|
||||
return $"Error: Script file not found: {script.FullPath}";
|
||||
}
|
||||
|
||||
string extension = Path.GetExtension(script.FullPath);
|
||||
string? interpreter = extension switch
|
||||
{
|
||||
".py" => "python3",
|
||||
".js" => "node",
|
||||
".sh" => "bash",
|
||||
".ps1" => "pwsh",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".",
|
||||
};
|
||||
|
||||
if (interpreter is not null)
|
||||
{
|
||||
startInfo.FileName = interpreter;
|
||||
startInfo.ArgumentList.Add(script.FullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
startInfo.FileName = script.FullPath;
|
||||
}
|
||||
|
||||
if (arguments is not null)
|
||||
{
|
||||
foreach (var (key, value) in arguments)
|
||||
{
|
||||
if (value is bool boolValue)
|
||||
{
|
||||
if (boolValue)
|
||||
{
|
||||
startInfo.ArgumentList.Add(NormalizeKey(key));
|
||||
}
|
||||
}
|
||||
else if (value is not null)
|
||||
{
|
||||
startInfo.ArgumentList.Add(NormalizeKey(key));
|
||||
startInfo.ArgumentList.Add(value.ToString()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process? process = null;
|
||||
try
|
||||
{
|
||||
process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return $"Error: Failed to start process for script '{script.Name}'.";
|
||||
}
|
||||
|
||||
Task<string> outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
Task<string> errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string output = await outputTask.ConfigureAwait(false);
|
||||
string error = await errorTask.ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
output += $"\nStderr:\n{error}";
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
output += $"\nScript exited with code {process.ExitCode}";
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(output) ? "(no output)" : output.Trim();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Kill the process on cancellation to avoid leaving orphaned subprocesses.
|
||||
process?.Kill(entireProcessTree: true);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: Failed to execute script '{script.Name}': {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
process?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a parameter key to a consistent --flag format.
|
||||
/// Models may return keys with or without leading dashes (e.g., "value" vs "--value").
|
||||
/// </summary>
|
||||
private static string NormalizeKey(string key) => "--" + key.TrimStart('-');
|
||||
}
|
||||
+4
-16
@@ -73,28 +73,16 @@ foreach (ClientResult result in getConversationItemsResults.GetRawPages())
|
||||
using JsonDocument getConversationItemsResultAsJson = JsonDocument.Parse(result.GetRawResponse().Content.ToString());
|
||||
foreach (JsonElement element in getConversationItemsResultAsJson.RootElement.GetProperty("data").EnumerateArray())
|
||||
{
|
||||
// Skip non-message items (e.g. tool calls, reasoning) that lack a "role" property
|
||||
if (!element.TryGetProperty("role"u8, out var roleElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string messageId = element.GetProperty("id"u8).ToString();
|
||||
string messageRole = roleElement.ToString();
|
||||
string messageRole = element.GetProperty("role"u8).ToString();
|
||||
Console.WriteLine($" Message ID: {messageId}");
|
||||
Console.WriteLine($" Message Role: {messageRole}");
|
||||
|
||||
if (element.TryGetProperty("content"u8, out var contentElement))
|
||||
foreach (var content in element.GetProperty("content").EnumerateArray())
|
||||
{
|
||||
foreach (var content in contentElement.EnumerateArray())
|
||||
{
|
||||
if (content.TryGetProperty("text"u8, out var textElement))
|
||||
{
|
||||
Console.WriteLine($" Message Text: {textElement}");
|
||||
}
|
||||
}
|
||||
string messageContentText = content.GetProperty("text"u8).ToString();
|
||||
Console.WriteLine($" Message Text: {messageContentText}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
-6
@@ -16,11 +16,5 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Assets\walkway.jpg">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB |
@@ -22,7 +22,7 @@ var agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential(
|
||||
|
||||
ChatMessage message = new(ChatRole.User, [
|
||||
new TextContent("What do you see in this image?"),
|
||||
await DataContent.LoadFromAsync("Assets/walkway.jpg"),
|
||||
new UriContent("https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", "image/jpeg")
|
||||
]);
|
||||
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
@@ -24,7 +24,7 @@ AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: VisionName, model
|
||||
|
||||
ChatMessage message = new(ChatRole.User, [
|
||||
new TextContent("What do you see in this image?"),
|
||||
await DataContent.LoadFromAsync("Assets/walkway.jpg"),
|
||||
await DataContent.LoadFromAsync("assets/walkway.jpg"),
|
||||
]);
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
+5
-6
@@ -35,15 +35,13 @@ public static class FunctionTriggers
|
||||
int iterationCount = 0;
|
||||
while (iterationCount++ < input.MaxReviewAttempts)
|
||||
{
|
||||
// NOTE: CustomStatus has a 16 KB UTF-16 limit in Durable Functions.
|
||||
// Only include short metadata here - the full content is passed via activity inputs/outputs.
|
||||
context.SetCustomStatus(
|
||||
new
|
||||
{
|
||||
message = "Requesting human feedback.",
|
||||
approvalTimeoutHours = input.ApprovalTimeoutHours,
|
||||
iterationCount,
|
||||
contentTitle = content.Title,
|
||||
content
|
||||
});
|
||||
|
||||
// Step 2: Notify user to review the content
|
||||
@@ -65,6 +63,7 @@ public static class FunctionTriggers
|
||||
{
|
||||
message = $"Human approval timed out after {input.ApprovalTimeoutHours} hour(s). Treating as rejection.",
|
||||
iterationCount,
|
||||
content
|
||||
});
|
||||
throw new TimeoutException($"Human approval timed out after {input.ApprovalTimeoutHours} hour(s).");
|
||||
}
|
||||
@@ -74,7 +73,7 @@ public static class FunctionTriggers
|
||||
context.SetCustomStatus(new
|
||||
{
|
||||
message = "Content approved by human reviewer. Publishing content...",
|
||||
contentTitle = content.Title,
|
||||
content
|
||||
});
|
||||
|
||||
// Step 4: Publish the approved content
|
||||
@@ -84,7 +83,7 @@ public static class FunctionTriggers
|
||||
{
|
||||
message = $"Content published successfully at {context.CurrentUtcDateTime:s}",
|
||||
humanFeedback = humanResponse,
|
||||
contentTitle = content.Title,
|
||||
content
|
||||
});
|
||||
return new { content = content.Content };
|
||||
}
|
||||
@@ -93,7 +92,7 @@ public static class FunctionTriggers
|
||||
{
|
||||
message = "Content rejected by human reviewer. Incorporating feedback and regenerating...",
|
||||
humanFeedback = humanResponse,
|
||||
contentTitle = content.Title,
|
||||
content
|
||||
});
|
||||
|
||||
// Incorporate human feedback and regenerate
|
||||
|
||||
@@ -77,15 +77,13 @@ static async Task<object> RunOrchestratorAsync(TaskOrchestrationContext context,
|
||||
int iterationCount = 0;
|
||||
while (iterationCount++ < input.MaxReviewAttempts)
|
||||
{
|
||||
// NOTE: CustomStatus has a 16 KB UTF-16 limit in Durable Functions.
|
||||
// Only include short metadata here - the full content is passed via activity inputs/outputs.
|
||||
context.SetCustomStatus(
|
||||
new
|
||||
{
|
||||
message = "Requesting human feedback.",
|
||||
approvalTimeoutHours = input.ApprovalTimeoutHours,
|
||||
iterationCount,
|
||||
contentTitle = content.Title,
|
||||
content
|
||||
});
|
||||
|
||||
// Step 2: Notify user to review the content
|
||||
@@ -107,6 +105,7 @@ static async Task<object> RunOrchestratorAsync(TaskOrchestrationContext context,
|
||||
{
|
||||
message = $"Human approval timed out after {input.ApprovalTimeoutHours} hour(s). Treating as rejection.",
|
||||
iterationCount,
|
||||
content
|
||||
});
|
||||
throw new TimeoutException($"Human approval timed out after {input.ApprovalTimeoutHours} hour(s).");
|
||||
}
|
||||
@@ -116,7 +115,7 @@ static async Task<object> RunOrchestratorAsync(TaskOrchestrationContext context,
|
||||
context.SetCustomStatus(new
|
||||
{
|
||||
message = "Content approved by human reviewer. Publishing content...",
|
||||
contentTitle = content.Title,
|
||||
content
|
||||
});
|
||||
|
||||
// Step 4: Publish the approved content
|
||||
@@ -126,7 +125,7 @@ static async Task<object> RunOrchestratorAsync(TaskOrchestrationContext context,
|
||||
{
|
||||
message = $"Content published successfully at {context.CurrentUtcDateTime:s}",
|
||||
humanFeedback = humanResponse,
|
||||
contentTitle = content.Title,
|
||||
content
|
||||
});
|
||||
return new { content = content.Content };
|
||||
}
|
||||
@@ -135,7 +134,7 @@ static async Task<object> RunOrchestratorAsync(TaskOrchestrationContext context,
|
||||
{
|
||||
message = "Content rejected by human reviewer. Incorporating feedback and regenerating...",
|
||||
humanFeedback = humanResponse,
|
||||
contentTitle = content.Title,
|
||||
content
|
||||
});
|
||||
|
||||
// Incorporate human feedback and regenerate
|
||||
|
||||
@@ -285,7 +285,6 @@ async Task ReadStreamTask(string conversationId, string? cursor, CancellationTok
|
||||
if (chunk.Text != null)
|
||||
{
|
||||
Console.Write(chunk.Text);
|
||||
Console.Out.Flush();
|
||||
}
|
||||
|
||||
// Always update lastCursor to track the latest entry ID, even if text is null
|
||||
|
||||
@@ -59,7 +59,7 @@ internal static class HostAgentFactory
|
||||
PushNotifications = false,
|
||||
};
|
||||
|
||||
var invoiceQuery = new A2A.AgentSkill()
|
||||
var invoiceQuery = new AgentSkill()
|
||||
{
|
||||
Id = "id_invoice_agent",
|
||||
Name = "InvoiceQuery",
|
||||
@@ -91,7 +91,7 @@ internal static class HostAgentFactory
|
||||
PushNotifications = false,
|
||||
};
|
||||
|
||||
var policyQuery = new A2A.AgentSkill()
|
||||
var policyQuery = new AgentSkill()
|
||||
{
|
||||
Id = "id_policy_agent",
|
||||
Name = "PolicyAgent",
|
||||
@@ -123,7 +123,7 @@ internal static class HostAgentFactory
|
||||
PushNotifications = false,
|
||||
};
|
||||
|
||||
var logisticsQuery = new A2A.AgentSkill()
|
||||
var logisticsQuery = new AgentSkill()
|
||||
{
|
||||
Id = "id_logistics_agent",
|
||||
Name = "LogisticsQuery",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Azure.AI.Extensions.OpenAI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="ProjectResponsesClient"/>
|
||||
/// to simplify the creation of AI agents that work with Azure AI services.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class ProjectResponsesClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an <see cref="IChatClient"/> for use with this <see cref="ProjectResponsesClient"/> that does not store responses for later retrieval.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This corresponds to setting the "store" property in the JSON representation to false.
|
||||
/// </remarks>
|
||||
/// <param name="responseClient">The client.</param>
|
||||
/// <param name="deploymentName">Optional deployment name (model) to use for requests.</param>
|
||||
/// <param name="includeReasoningEncryptedContent">
|
||||
/// Includes an encrypted version of reasoning tokens in reasoning item outputs.
|
||||
/// This enables reasoning items to be used in multi-turn conversations when using the Responses API statelessly
|
||||
/// (like when the store parameter is set to false, or when an organization is enrolled in the zero data retention program).
|
||||
/// Defaults to <see langword="true"/>.
|
||||
/// </param>
|
||||
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="ProjectResponsesClient"/> that does not store responses for later retrieval.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="responseClient"/> is <see langword="null"/>.</exception>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static IChatClient AsIChatClientWithStoredOutputDisabled(this ProjectResponsesClient responseClient, string? deploymentName = null, bool includeReasoningEncryptedContent = true)
|
||||
{
|
||||
return Throw.IfNull(responseClient)
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsBuilder()
|
||||
.ConfigureOptions(x =>
|
||||
{
|
||||
var previousFactory = x.RawRepresentationFactory;
|
||||
x.RawRepresentationFactory = state =>
|
||||
{
|
||||
var responseOptions = previousFactory?.Invoke(state) as CreateResponseOptions ?? new CreateResponseOptions();
|
||||
|
||||
responseOptions.StoredOutputEnabled = false;
|
||||
|
||||
if (includeReasoningEncryptedContent &&
|
||||
!responseOptions.IncludedProperties.Contains(IncludedResponseProperty.ReasoningEncryptedContent))
|
||||
{
|
||||
responseOptions.IncludedProperties.Add(IncludedResponseProperty.ReasoningEncryptedContent);
|
||||
}
|
||||
|
||||
return responseOptions;
|
||||
};
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
+3
-7
@@ -39,16 +39,14 @@ internal abstract record ChatCompletionRequestMessage
|
||||
/// <exception cref="InvalidOperationException">Thrown when the content is neither text nor AI contents.</exception>
|
||||
public virtual ChatMessage ToChatMessage()
|
||||
{
|
||||
var role = new ChatRole(this.Role);
|
||||
|
||||
if (this.Content.IsText)
|
||||
{
|
||||
return new(role, this.Content.Text);
|
||||
return new(ChatRole.User, this.Content.Text);
|
||||
}
|
||||
else if (this.Content.IsContents)
|
||||
{
|
||||
var aiContents = this.Content.Contents.Select(MessageContentPartConverter.ToAIContent).Where(c => c is not null).ToList();
|
||||
return new ChatMessage(role, aiContents!);
|
||||
return new ChatMessage(ChatRole.User, aiContents!);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("MessageContent has no value");
|
||||
@@ -167,11 +165,9 @@ internal sealed record FunctionMessage : ChatCompletionRequestMessage
|
||||
/// <exception cref="InvalidOperationException">Thrown when the content is not text.</exception>
|
||||
public override ChatMessage ToChatMessage()
|
||||
{
|
||||
var role = new ChatRole(this.Role);
|
||||
|
||||
if (this.Content.IsText)
|
||||
{
|
||||
return new(role, this.Content.Text);
|
||||
return new(ChatRole.User, this.Content.Text);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("FunctionMessage Content must be text");
|
||||
|
||||
@@ -105,7 +105,7 @@ public static class OpenAIResponseClientExtensions
|
||||
/// This corresponds to setting the "store" property in the JSON representation to false.
|
||||
/// </remarks>
|
||||
/// <param name="responseClient">The client.</param>
|
||||
/// <param name="model">Optional default model ID to use for requests.</param>
|
||||
/// <param name="model">Optional default model ID to use for requests. Required when using a plain <see cref="ResponsesClient"/> (not via Azure OpenAI).</param>
|
||||
/// <param name="includeReasoningEncryptedContent">
|
||||
/// Includes an encrypted version of reasoning tokens in reasoning item outputs.
|
||||
/// This enables reasoning items to be used in multi-turn conversations when using the Responses API statelessly
|
||||
|
||||
@@ -145,7 +145,7 @@ public static partial class AgentWorkflowBuilder
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
/// <summary>Creates a new <see cref="HandoffWorkflowBuilder"/> using <paramref name="initialAgent"/> as the starting agent in the workflow.</summary>
|
||||
/// <summary>Creates a new <see cref="HandoffsWorkflowBuilder"/> using <paramref name="initialAgent"/> as the starting agent in the workflow.</summary>
|
||||
/// <param name="initialAgent">The agent that will receive inputs provided to the workflow.</param>
|
||||
/// <returns>The builder for creating a workflow based on handoffs.</returns>
|
||||
/// <remarks>
|
||||
@@ -154,7 +154,7 @@ public static partial class AgentWorkflowBuilder
|
||||
/// The <see cref="AIAgent"/> must be capable of understanding those <see cref="AgentRunOptions"/> provided. If the agent
|
||||
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
|
||||
/// </remarks>
|
||||
public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
|
||||
public static HandoffsWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
|
||||
{
|
||||
Throw.IfNull(initialAgent);
|
||||
return new(initialAgent);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
@@ -9,21 +8,10 @@ using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <inheritdoc/>
|
||||
[Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")]
|
||||
public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore<HandoffsWorkflowBuilder>(initialAgent)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore<HandoffWorkflowBuilder>(initialAgent)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow.
|
||||
/// </summary>
|
||||
public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
|
||||
public sealed class HandoffsWorkflowBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`,
|
||||
@@ -38,13 +26,12 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
private bool _emitAgentResponseEvents;
|
||||
private bool _emitAgentResponseUpdateEvents;
|
||||
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
private bool _returnToPrevious;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
|
||||
/// </summary>
|
||||
/// <param name="initialAgent">The first agent to be invoked (prior to any handoff).</param>
|
||||
internal HandoffWorkflowBuilderCore(AIAgent initialAgent)
|
||||
internal HandoffsWorkflowBuilder(AIAgent initialAgent)
|
||||
{
|
||||
this._initialAgent = initialAgent;
|
||||
this._allAgents.Add(initialAgent);
|
||||
@@ -76,10 +63,10 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
/// <see cref="FunctionPrefix"/> constant.
|
||||
/// </remarks>
|
||||
/// <param name="instructions">The instructions to provide, or <see langword="null"/> to restore the default instructions.</param>
|
||||
public TBuilder WithHandoffInstructions(string? instructions)
|
||||
public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
|
||||
{
|
||||
this.HandoffInstructions = instructions ?? DefaultHandoffInstructions;
|
||||
return (TBuilder)this;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -88,10 +75,10 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
/// </summary>
|
||||
/// <param name="emitAgentResponseUpdateEvents"></param>
|
||||
/// <returns></returns>
|
||||
public TBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
|
||||
public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
|
||||
{
|
||||
this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
|
||||
return (TBuilder)this;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -99,10 +86,10 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
/// </summary>
|
||||
/// <param name="emitAgentResponseEvents"></param>
|
||||
/// <returns></returns>
|
||||
public TBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
|
||||
public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
|
||||
{
|
||||
this._emitAgentResponseEvents = emitAgentResponseEvents;
|
||||
return (TBuilder)this;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -110,21 +97,10 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
/// <see cref="ChatMessage"/>s flowing through the handoff workflow. Defaults to <see cref="HandoffToolCallFilteringBehavior.HandoffOnly"/>.
|
||||
/// </summary>
|
||||
/// <param name="behavior">The filtering behavior to apply.</param>
|
||||
public TBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
|
||||
public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
|
||||
{
|
||||
this._toolCallFilteringBehavior = behavior;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the workflow so that subsequent user turns route directly back to the specialist agent
|
||||
/// that handled the previous turn, rather than always routing through the initial (coordinator) agent.
|
||||
/// </summary>
|
||||
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
|
||||
public TBuilder EnableReturnToPrevious()
|
||||
{
|
||||
this._returnToPrevious = true;
|
||||
return (TBuilder)this;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -134,7 +110,7 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
/// <param name="to">The target agents to add as handoff targets for the source agent.</param>
|
||||
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
|
||||
/// <remarks>The handoff reason for each target in <paramref name="to"/> is derived from that agent's description or name.</remarks>
|
||||
public TBuilder WithHandoffs(AIAgent from, IEnumerable<AIAgent> to)
|
||||
public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable<AIAgent> to)
|
||||
{
|
||||
Throw.IfNull(from);
|
||||
Throw.IfNull(to);
|
||||
@@ -149,7 +125,7 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
this.WithHandoff(from, target);
|
||||
}
|
||||
|
||||
return (TBuilder)this;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -162,7 +138,7 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
/// If <see langword="null"/>, the reason is derived from <paramref name="to"/>'s description or name.
|
||||
/// </param>
|
||||
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
|
||||
public TBuilder WithHandoffs(IEnumerable<AIAgent> from, AIAgent to, string? handoffReason = null)
|
||||
public HandoffsWorkflowBuilder WithHandoffs(IEnumerable<AIAgent> from, AIAgent to, string? handoffReason = null)
|
||||
{
|
||||
Throw.IfNull(from);
|
||||
Throw.IfNull(to);
|
||||
@@ -177,7 +153,7 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
this.WithHandoff(source, to, handoffReason);
|
||||
}
|
||||
|
||||
return (TBuilder)this;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -190,7 +166,7 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
/// If <see langword="null"/>, the reason is derived from <paramref name="to"/>'s description or name.
|
||||
/// </param>
|
||||
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
|
||||
public TBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null)
|
||||
public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null)
|
||||
{
|
||||
Throw.IfNull(from);
|
||||
Throw.IfNull(to);
|
||||
@@ -220,7 +196,7 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
Throw.InvalidOperationException($"A handoff from agent '{from.Name ?? from.Id}' to agent '{to.Name ?? to.Id}' has already been registered.");
|
||||
}
|
||||
|
||||
return (TBuilder)this;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -230,8 +206,8 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
/// <returns>The workflow built based on the handoffs in the builder.</returns>
|
||||
public Workflow Build()
|
||||
{
|
||||
HandoffsStartExecutor start = new(this._returnToPrevious);
|
||||
HandoffsEndExecutor end = new(this._returnToPrevious);
|
||||
HandoffsStartExecutor start = new();
|
||||
HandoffsEndExecutor end = new();
|
||||
WorkflowBuilder builder = new(start);
|
||||
|
||||
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
|
||||
@@ -239,31 +215,11 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
this._emitAgentResponseUpdateEvents,
|
||||
this._toolCallFilteringBehavior);
|
||||
|
||||
// Create an AgentExecutor for each agent.
|
||||
// Create an AgentExecutor for each again.
|
||||
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));
|
||||
|
||||
// Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled).
|
||||
if (this._returnToPrevious)
|
||||
{
|
||||
string initialAgentId = this._initialAgent.Id;
|
||||
builder.AddSwitch(start, sb =>
|
||||
{
|
||||
foreach (var agent in this._allAgents)
|
||||
{
|
||||
if (agent.Id != initialAgentId)
|
||||
{
|
||||
string agentId = agent.Id;
|
||||
sb.AddCase<HandoffState>(state => state?.CurrentAgentId == agentId, executors[agentId]);
|
||||
}
|
||||
}
|
||||
|
||||
sb.WithDefault(executors[initialAgentId]);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.AddEdge(start, executors[this._initialAgent.Id]);
|
||||
}
|
||||
// Connect the start executor to the initial agent.
|
||||
builder.AddEdge(start, executors[this._initialAgent.Id]);
|
||||
|
||||
// Initialize each executor with its handoff targets to the other executors.
|
||||
foreach (var agent in this._allAgents)
|
||||
|
||||
@@ -42,7 +42,7 @@ internal sealed class HandoffMessagesFilter
|
||||
|
||||
internal static bool IsHandoffFunctionName(string name)
|
||||
{
|
||||
return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
|
||||
return name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public IEnumerable<ChatMessage> FilterMessages(List<ChatMessage> messages)
|
||||
@@ -173,7 +173,6 @@ internal sealed class HandoffAgentExecutor(
|
||||
|
||||
private readonly AIAgent _agent = agent;
|
||||
private readonly HashSet<string> _handoffFunctionNames = [];
|
||||
private readonly Dictionary<string, string> _handoffFunctionToAgentId = [];
|
||||
private ChatClientAgentRunOptions? _agentOptions;
|
||||
|
||||
public void Initialize(
|
||||
@@ -200,10 +199,9 @@ internal sealed class HandoffAgentExecutor(
|
||||
foreach (HandoffTarget handoff in handoffs)
|
||||
{
|
||||
index++;
|
||||
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
|
||||
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffsWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
|
||||
|
||||
this._handoffFunctionNames.Add(handoffFunc.Name);
|
||||
this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id;
|
||||
|
||||
this._agentOptions.ChatOptions.Tools.Add(handoffFunc);
|
||||
|
||||
@@ -269,11 +267,7 @@ internal sealed class HandoffAgentExecutor(
|
||||
|
||||
roleChanges.ResetUserToAssistantForChangedRoles();
|
||||
|
||||
string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId)
|
||||
? targetAgentId
|
||||
: this._agent.Id;
|
||||
|
||||
return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId);
|
||||
return new(message.TurnToken, requestedHandoff, allMessages);
|
||||
|
||||
async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -8,5 +8,4 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
internal sealed record class HandoffState(
|
||||
TurnToken TurnToken,
|
||||
string? InvokedHandoff,
|
||||
List<ChatMessage> Messages,
|
||||
string? CurrentAgentId = null);
|
||||
List<ChatMessage> Messages);
|
||||
|
||||
@@ -1,35 +1,20 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
|
||||
internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
|
||||
internal sealed class HandoffsEndExecutor() : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
public const string ExecutorId = "HandoffEnd";
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
|
||||
protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>((handoff, context, cancellationToken) =>
|
||||
this.HandleAsync(handoff, context, cancellationToken)))
|
||||
context.YieldOutputAsync(handoff.Messages, cancellationToken)))
|
||||
.YieldsOutput<List<ChatMessage>>();
|
||||
|
||||
private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (returnToPrevious)
|
||||
{
|
||||
await context.QueueStateUpdateAsync<string?>(HandoffConstants.CurrentAgentTrackerKey,
|
||||
handoff.CurrentAgentId,
|
||||
HandoffConstants.CurrentAgentTrackerScope,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public ValueTask ResetAsync() => default;
|
||||
}
|
||||
|
||||
@@ -7,14 +7,8 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
internal static class HandoffConstants
|
||||
{
|
||||
internal const string CurrentAgentTrackerKey = "LastAgentId";
|
||||
internal const string CurrentAgentTrackerScope = "HandoffOrchestration";
|
||||
}
|
||||
|
||||
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
|
||||
internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
|
||||
internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
internal const string ExecutorId = "HandoffStart";
|
||||
|
||||
@@ -28,25 +22,7 @@ internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtoco
|
||||
base.ConfigureProtocol(protocolBuilder).SendsMessage<HandoffState>();
|
||||
|
||||
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (returnToPrevious)
|
||||
{
|
||||
return context.InvokeWithStateAsync(
|
||||
async (string? currentAgentId, IWorkflowContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
HandoffState handoffState = new(new(emitEvents), null, messages, currentAgentId);
|
||||
await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return currentAgentId;
|
||||
},
|
||||
HandoffConstants.CurrentAgentTrackerKey,
|
||||
HandoffConstants.CurrentAgentTrackerScope,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
HandoffState handoff = new(new(emitEvents), null, messages);
|
||||
return context.SendMessageAsync(handoff, cancellationToken);
|
||||
}
|
||||
=> context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken);
|
||||
|
||||
public new ValueTask ResetAsync() => base.ResetAsync();
|
||||
}
|
||||
|
||||
@@ -788,13 +788,6 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
chatOptions.ConversationId = typedSession.ConversationId;
|
||||
}
|
||||
|
||||
// When per-service-call persistence is active, set a sentinel conversation ID so that
|
||||
// FunctionInvokingChatClient treats locally-persisted history the same as service-managed
|
||||
// history. This prevents it from adding duplicate FunctionCallContent messages into the
|
||||
// request when processing approval responses — the loaded history already contains them.
|
||||
// ChatHistoryPersistingChatClient strips the sentinel before forwarding to the inner client.
|
||||
chatOptions = this.SetLocalHistoryConversationIdIfNeeded(chatOptions);
|
||||
|
||||
// Materialize the accumulated messages once at the end of the provider pipeline, reusing the existing list if possible.
|
||||
List<ChatMessage> messagesList = inputMessagesForChatClient as List<ChatMessage> ?? inputMessagesForChatClient.ToList();
|
||||
|
||||
@@ -936,26 +929,6 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the <see cref="ChatHistoryPersistingChatClient.LocalHistoryConversationId"/> sentinel on
|
||||
/// <paramref name="chatOptions"/> when per-service-call persistence is active and no real
|
||||
/// conversation ID is present.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// The (possibly new) <see cref="ChatOptions"/> with the sentinel set, or the original
|
||||
/// <paramref name="chatOptions"/> if no sentinel is needed.
|
||||
/// </returns>
|
||||
private ChatOptions? SetLocalHistoryConversationIdIfNeeded(ChatOptions? chatOptions)
|
||||
{
|
||||
if (this.PersistsChatHistoryPerServiceCall && string.IsNullOrWhiteSpace(chatOptions?.ConversationId))
|
||||
{
|
||||
chatOptions ??= new ChatOptions();
|
||||
chatOptions.ConversationId = ChatHistoryPersistingChatClient.LocalHistoryConversationId;
|
||||
}
|
||||
|
||||
return chatOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the agent has a <see cref="ChatHistoryPersistingChatClient"/>
|
||||
/// decorator in mark-only mode, which marks messages for later persistence at the end of the run.
|
||||
|
||||
@@ -50,26 +50,6 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
|
||||
/// </summary>
|
||||
internal const string PersistedMarkerKey = "_chatHistoryPersisted";
|
||||
|
||||
/// <summary>
|
||||
/// A sentinel value set on <see cref="ChatOptions.ConversationId"/> by <see cref="ChatClientAgent"/>
|
||||
/// when per-service-call persistence is active and no real conversation ID exists.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This signals to <see cref="FunctionInvokingChatClient"/> that the chat history is being managed
|
||||
/// externally (by this decorator), which prevents it from adding duplicate <see cref="FunctionCallContent"/>
|
||||
/// messages into the request during approval-response processing. Without this sentinel,
|
||||
/// <see cref="FunctionInvokingChatClient"/> would reconstruct function-call messages from approval
|
||||
/// responses and append them to the original messages — but the loaded history already contains
|
||||
/// those same function calls, causing duplicate tool-call entries that the model rejects.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This decorator strips the sentinel before forwarding requests to the inner client, so the
|
||||
/// underlying model never sees it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal const string LocalHistoryConversationId = "_agent_local_history";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryPersistingChatClient"/> class.
|
||||
/// </summary>
|
||||
@@ -107,7 +87,6 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (agent, session) = GetRequiredAgentAndSession();
|
||||
options = StripLocalHistoryConversationId(options);
|
||||
|
||||
ChatResponse response;
|
||||
try
|
||||
@@ -151,7 +130,6 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (agent, session) = GetRequiredAgentAndSession();
|
||||
options = StripLocalHistoryConversationId(options);
|
||||
|
||||
List<ChatResponseUpdate> responseUpdates = [];
|
||||
|
||||
@@ -332,20 +310,4 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the <paramref name="options"/> carry the <see cref="LocalHistoryConversationId"/> sentinel,
|
||||
/// returns a clone with the conversation ID cleared so the inner client never sees it.
|
||||
/// Otherwise returns the original <paramref name="options"/> unchanged.
|
||||
/// </summary>
|
||||
private static ChatOptions? StripLocalHistoryConversationId(ChatOptions? options)
|
||||
{
|
||||
if (options?.ConversationId == LocalHistoryConversationId)
|
||||
{
|
||||
options = options.Clone();
|
||||
options.ConversationId = null;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for all agent skills.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A skill represents a domain-specific capability with instructions, resources, and scripts.
|
||||
/// Concrete implementations include <see cref="AgentFileSkill"/> (filesystem-backed).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Skill metadata follows the <see href="https://agentskills.io/specification">Agent Skills specification</see>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public abstract class AgentSkill
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the frontmatter metadata for this skill.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Contains the L1 discovery metadata (name, description, license, compatibility, etc.)
|
||||
/// as defined by the <see href="https://agentskills.io/specification">Agent Skills specification</see>.
|
||||
/// </remarks>
|
||||
public abstract AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full skill content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For file-based skills this is the raw SKILL.md file content.
|
||||
/// </remarks>
|
||||
public abstract string Content { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation returns <see langword="null"/>.
|
||||
/// Override this property in derived classes to provide skill-specific resources.
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillResource>? Resources => null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation returns <see langword="null"/>.
|
||||
/// Override this property in derived classes to provide skill-specific scripts.
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillScript>? Scripts => null;
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the YAML frontmatter metadata parsed from a SKILL.md file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Frontmatter is the L1 (discovery) layer of the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
|
||||
/// It contains the minimal metadata needed to advertise a skill in the system prompt
|
||||
/// without loading the full skill content.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The constructor validates the name and description against specification rules
|
||||
/// and throws <see cref="ArgumentException"/> if either value is invalid.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class AgentSkillFrontmatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum allowed length for the skill name.
|
||||
/// </summary>
|
||||
internal const int MaxNameLength = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed length for the skill description.
|
||||
/// </summary>
|
||||
internal const int MaxDescriptionLength = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed length for the compatibility field.
|
||||
/// </summary>
|
||||
internal const int MaxCompatibilityLength = 500;
|
||||
|
||||
// Validates skill names per the Agent Skills specification (https://agentskills.io/specification#frontmatter):
|
||||
// lowercase letters, numbers, and hyphens only; must not start or end with a hyphen; must not contain consecutive hyphens.
|
||||
private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled);
|
||||
|
||||
private string? _compatibility;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentSkillFrontmatter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">Skill name in kebab-case.</param>
|
||||
/// <param name="description">Skill description for discovery.</param>
|
||||
/// <param name="compatibility">Optional compatibility information (max 500 chars).</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when <paramref name="name"/>, <paramref name="description"/>, or <paramref name="compatibility"/> violates the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see> rules.
|
||||
/// </exception>
|
||||
public AgentSkillFrontmatter(string name, string description, string? compatibility = null)
|
||||
{
|
||||
if (!ValidateName(name, out string? reason) ||
|
||||
!ValidateDescription(description, out reason) ||
|
||||
!ValidateCompatibility(compatibility, out reason))
|
||||
{
|
||||
throw new ArgumentException(reason);
|
||||
}
|
||||
|
||||
this.Name = name;
|
||||
this.Description = description;
|
||||
this._compatibility = compatibility;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the skill name. Lowercase letters, numbers, and hyphens only; no leading, trailing, or consecutive hyphens.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the skill description. Used for discovery in the system prompt.
|
||||
/// </summary>
|
||||
public string Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional license name or reference.
|
||||
/// </summary>
|
||||
public string? License { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets optional compatibility information (max 500 chars).
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when the value exceeds <see cref="MaxCompatibilityLength"/> characters.
|
||||
/// </exception>
|
||||
public string? Compatibility
|
||||
{
|
||||
get => this._compatibility;
|
||||
set
|
||||
{
|
||||
if (!ValidateCompatibility(value, out string? reason))
|
||||
{
|
||||
throw new ArgumentException(reason);
|
||||
}
|
||||
|
||||
this._compatibility = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets optional space-delimited list of pre-approved tools.
|
||||
/// </summary>
|
||||
public string? AllowedTools { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the arbitrary key-value metadata for this skill.
|
||||
/// </summary>
|
||||
public AdditionalPropertiesDictionary? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates a skill name against specification rules.
|
||||
/// </summary>
|
||||
/// <param name="name">The skill name to validate (may be <see langword="null"/>).</param>
|
||||
/// <param name="reason">When validation fails, contains a human-readable description of the failure.</param>
|
||||
/// <returns><see langword="true"/> if the name is valid; otherwise, <see langword="false"/>.</returns>
|
||||
public static bool ValidateName(
|
||||
string? name,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
reason = "Skill name is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.Length > MaxNameLength)
|
||||
{
|
||||
reason = $"Skill name must be {MaxNameLength} characters or fewer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!s_validNameRegex.IsMatch(name))
|
||||
{
|
||||
reason = "Skill name must use only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a skill description against specification rules.
|
||||
/// </summary>
|
||||
/// <param name="description">The skill description to validate (may be <see langword="null"/>).</param>
|
||||
/// <param name="reason">When validation fails, contains a human-readable description of the failure.</param>
|
||||
/// <returns><see langword="true"/> if the description is valid; otherwise, <see langword="false"/>.</returns>
|
||||
public static bool ValidateDescription(
|
||||
string? description,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
reason = "Skill description is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (description.Length > MaxDescriptionLength)
|
||||
{
|
||||
reason = $"Skill description must be {MaxDescriptionLength} characters or fewer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an optional skill compatibility value against specification rules.
|
||||
/// </summary>
|
||||
/// <param name="compatibility">The optional compatibility value to validate (may be <see langword="null"/>).</param>
|
||||
/// <param name="reason">When validation fails, contains a human-readable description of the failure.</param>
|
||||
/// <returns><see langword="true"/> if the value is valid; otherwise, <see langword="false"/>.</returns>
|
||||
public static bool ValidateCompatibility(
|
||||
string? compatibility,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (compatibility?.Length > MaxCompatibilityLength)
|
||||
{
|
||||
reason = $"Skill compatibility must be {MaxCompatibilityLength} characters or fewer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for skill resources. A resource provides supplementary content (references, assets) to a skill.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public abstract class AgentSkillResource
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentSkillResource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">The resource name (e.g., relative path or identifier).</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
protected AgentSkillResource(string name, string? description = null)
|
||||
{
|
||||
this.Name = Throw.IfNullOrWhitespace(name);
|
||||
this.Description = description;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resource name.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional resource description.
|
||||
/// </summary>
|
||||
public string? Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reads the resource content asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">Optional service provider for dependency injection.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The resource content.</returns>
|
||||
public abstract Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for skill scripts. A script represents an executable action associated with a skill.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public abstract class AgentSkillScript
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentSkillScript"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="description">An optional description of the script.</param>
|
||||
protected AgentSkillScript(string name, string? description = null)
|
||||
{
|
||||
this.Name = Throw.IfNullOrWhitespace(name);
|
||||
this.Description = description;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the script name.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional script description.
|
||||
/// </summary>
|
||||
public string? Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Runs the script with the given arguments.
|
||||
/// </summary>
|
||||
/// <param name="skill">The skill that owns this script.</param>
|
||||
/// <param name="arguments">Arguments for script execution.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The script execution result.</returns>
|
||||
public abstract Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIContextProvider"/> that exposes agent skills from one or more <see cref="AgentSkillsSource"/> instances.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This provider implements the progressive disclosure pattern from the
|
||||
/// <see href="https://agentskills.io/">Agent Skills specification</see>:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description><strong>Advertise</strong> — skill names and descriptions are injected into the system prompt.</description></item>
|
||||
/// <item><description><strong>Load</strong> — the full skill body is returned via the <c>load_skill</c> tool.</description></item>
|
||||
/// <item><description><strong>Read resources</strong> — supplementary content is read on demand via the <c>read_skill_resource</c> tool.</description></item>
|
||||
/// <item><description><strong>Run scripts</strong> — scripts are executed via the <c>run_skill_script</c> tool (when scripts exist).</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Placeholder token for the generated skills list in the prompt template.
|
||||
/// </summary>
|
||||
private const string SkillsPlaceholder = "{skills}";
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder token for the script instructions in the prompt template.
|
||||
/// </summary>
|
||||
private const string ScriptInstructionsPlaceholder = "{script_instructions}";
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder token for the resource instructions in the prompt template.
|
||||
/// </summary>
|
||||
private const string ResourceInstructionsPlaceholder = "{resource_instructions}";
|
||||
|
||||
private const string DefaultSkillsInstructionPrompt =
|
||||
"""
|
||||
You have access to skills containing domain-specific knowledge and capabilities.
|
||||
Each skill provides specialized instructions, reference documents, and assets for specific tasks.
|
||||
|
||||
<available_skills>
|
||||
{skills}
|
||||
</available_skills>
|
||||
|
||||
When a task aligns with a skill's domain, follow these steps in exact order:
|
||||
- Use `load_skill` to retrieve the skill's instructions.
|
||||
- Follow the provided guidance.
|
||||
{resource_instructions}
|
||||
{script_instructions}
|
||||
Only load what is needed, when it is needed.
|
||||
""";
|
||||
|
||||
private readonly AgentSkillsSource _source;
|
||||
private readonly AgentSkillsProviderOptions? _options;
|
||||
private readonly ILogger<AgentSkillsProvider> _logger;
|
||||
private Task<AIContext>? _contextTask;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
|
||||
/// that discovers file-based skills from a single directory.
|
||||
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
|
||||
/// </summary>
|
||||
/// <param name="skillPath">Path to search for skills.</param>
|
||||
/// <param name="scriptRunner">Optional delegate that runs file-based scripts. Required only when skills contain scripts.</param>
|
||||
/// <param name="fileOptions">Optional options that control skill discovery behavior.</param>
|
||||
/// <param name="options">Optional provider configuration.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public AgentSkillsProvider(
|
||||
string skillPath,
|
||||
AgentFileSkillScriptRunner? scriptRunner = null,
|
||||
AgentFileSkillsSourceOptions? fileOptions = null,
|
||||
AgentSkillsProviderOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
: this([Throw.IfNull(skillPath)], scriptRunner, fileOptions, options, loggerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
|
||||
/// that discovers file-based skills from multiple directories.
|
||||
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
|
||||
/// </summary>
|
||||
/// <param name="skillPaths">Paths to search for skills.</param>
|
||||
/// <param name="scriptRunner">Optional delegate that runs file-based scripts. Required only when skills contain scripts.</param>
|
||||
/// <param name="fileOptions">Optional options that control skill discovery behavior.</param>
|
||||
/// <param name="options">Optional provider configuration.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public AgentSkillsProvider(
|
||||
IEnumerable<string> skillPaths,
|
||||
AgentFileSkillScriptRunner? scriptRunner = null,
|
||||
AgentFileSkillsSourceOptions? fileOptions = null,
|
||||
AgentSkillsProviderOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
: this(
|
||||
new DeduplicatingAgentSkillsSource(
|
||||
new AgentFileSkillsSource(skillPaths, scriptRunner, fileOptions, loggerFactory),
|
||||
loggerFactory),
|
||||
options,
|
||||
loggerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
|
||||
/// from a custom <see cref="AgentSkillsSource"/>. Unlike other constructors, this one does not
|
||||
/// apply automatic deduplication, allowing callers to customize deduplication behavior via the source pipeline.
|
||||
/// </summary>
|
||||
/// <param name="source">The skill source providing skills.</param>
|
||||
/// <param name="options">Optional configuration.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public AgentSkillsProvider(AgentSkillsSource source, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
this._source = Throw.IfNull(source);
|
||||
this._options = options;
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentSkillsProvider>();
|
||||
|
||||
if (options?.SkillsInstructionPrompt is string prompt)
|
||||
{
|
||||
ValidatePromptTemplate(prompt, nameof(options));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._options?.DisableCaching == true)
|
||||
{
|
||||
return await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await this.GetOrCreateContextAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<AIContext> CreateContextAsync(InvokingContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var skills = await this._source.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (skills is not { Count: > 0 })
|
||||
{
|
||||
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, includeScriptInstructions: hasScripts, hasResources),
|
||||
Tools = this.BuildTools(skills, hasScripts, hasResources),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<AIContext> GetOrCreateContextAsync(InvokingContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<AIContext>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
if (Interlocked.CompareExchange(ref this._contextTask, tcs.Task, null) is { } existing)
|
||||
{
|
||||
return await existing.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
tcs.SetResult(result);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._contextTask = null;
|
||||
tcs.TrySetException(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private IList<AIFunction> BuildTools(IList<AgentSkill> skills, bool hasScripts, bool hasResources)
|
||||
{
|
||||
IList<AIFunction> tools =
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
(string skillName) => this.LoadSkill(skills, skillName),
|
||||
name: "load_skill",
|
||||
description: "Loads the full content of a specific skill"),
|
||||
];
|
||||
|
||||
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."));
|
||||
}
|
||||
|
||||
if (!hasScripts)
|
||||
{
|
||||
return tools;
|
||||
}
|
||||
|
||||
AIFunction scriptFunction = AIFunctionFactory.Create(
|
||||
(string skillName, string scriptName, IDictionary<string, object?>? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) =>
|
||||
this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken),
|
||||
name: "run_skill_script",
|
||||
description: "Runs a script associated with a skill.");
|
||||
|
||||
if (this._options?.ScriptApproval == true)
|
||||
{
|
||||
return [.. tools, new ApprovalRequiredAIFunction(scriptFunction)];
|
||||
}
|
||||
|
||||
return [.. tools, scriptFunction];
|
||||
}
|
||||
|
||||
private string? BuildSkillsInstructions(IList<AgentSkill> skills, bool includeScriptInstructions, bool includeResourceInstructions)
|
||||
{
|
||||
string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var skill in skills.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal))
|
||||
{
|
||||
sb.AppendLine(" <skill>");
|
||||
sb.AppendLine($" <name>{SecurityElement.Escape(skill.Frontmatter.Name)}</name>");
|
||||
sb.AppendLine($" <description>{SecurityElement.Escape(skill.Frontmatter.Description)}</description>");
|
||||
sb.AppendLine(" </skill>");
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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)
|
||||
.ToString();
|
||||
}
|
||||
|
||||
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);
|
||||
if (skill == null)
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
}
|
||||
|
||||
LogSkillLoading(this._logger, skillName);
|
||||
|
||||
return skill.Content;
|
||||
}
|
||||
|
||||
private async Task<object?> ReadSkillResourceAsync(IList<AgentSkill> skills, string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skillName))
|
||||
{
|
||||
return "Error: Skill name cannot be empty.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resourceName))
|
||||
{
|
||||
return "Error: Resource name cannot be empty.";
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return await resource.ReadAsync(serviceProvider, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogResourceReadError(this._logger, skillName, resourceName, ex);
|
||||
return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object?> RunSkillScriptAsync(IList<AgentSkill> skills, string skillName, string scriptName, IDictionary<string, object?>? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skillName))
|
||||
{
|
||||
return "Error: Skill name cannot be empty.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scriptName))
|
||||
{
|
||||
return "Error: Script name cannot be empty.";
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogScriptExecutionError(this._logger, skillName, scriptName, ex);
|
||||
return $"Error: Failed to execute script '{scriptName}' from skill '{skillName}'.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a custom prompt template contains the required placeholder tokens.
|
||||
/// </summary>
|
||||
private static void ValidatePromptTemplate(string template, string paramName)
|
||||
{
|
||||
if (template.IndexOf(SkillsPlaceholder, StringComparison.Ordinal) < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The custom prompt template must contain the '{SkillsPlaceholder}' placeholder for the generated skills list.",
|
||||
paramName);
|
||||
}
|
||||
|
||||
if (template.IndexOf(ResourceInstructionsPlaceholder, StringComparison.Ordinal) < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The custom prompt template must contain the '{ResourceInstructionsPlaceholder}' placeholder for resource instructions.",
|
||||
paramName);
|
||||
}
|
||||
|
||||
if (template.IndexOf(ScriptInstructionsPlaceholder, StringComparison.Ordinal) < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The custom prompt template must contain the '{ScriptInstructionsPlaceholder}' placeholder for script instructions.",
|
||||
paramName);
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")]
|
||||
private static partial void LogSkillLoading(ILogger logger, string skillName);
|
||||
|
||||
[LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")]
|
||||
private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception);
|
||||
|
||||
[LoggerMessage(LogLevel.Error, "Failed to execute script '{ScriptName}' from skill '{SkillName}'")]
|
||||
private static partial void LogScriptExecutionError(ILogger logger, string skillName, string scriptName, Exception exception);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for constructing an <see cref="AgentSkillsProvider"/> backed by a composite source.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <code>
|
||||
/// var provider = new AgentSkillsProviderBuilder()
|
||||
/// .UseFileSkills("/path/to/skills")
|
||||
/// .Build();
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class AgentSkillsProviderBuilder
|
||||
{
|
||||
private readonly List<Func<AgentFileSkillScriptRunner?, ILoggerFactory?, AgentSkillsSource>> _sourceFactories = [];
|
||||
private AgentSkillsProviderOptions? _options;
|
||||
private ILoggerFactory? _loggerFactory;
|
||||
private AgentFileSkillScriptRunner? _scriptRunner;
|
||||
private Func<AgentSkill, bool>? _filter;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a file-based skill source that discovers skills from a filesystem directory.
|
||||
/// </summary>
|
||||
/// <param name="skillPath">Path to search for skills.</param>
|
||||
/// <param name="options">Optional options that control skill discovery behavior.</param>
|
||||
/// <param name="scriptRunner">
|
||||
/// Optional runner for file-based scripts. When provided, overrides the builder-level runner
|
||||
/// set via <see cref="UseFileScriptRunner"/>.
|
||||
/// </param>
|
||||
/// <returns>This builder instance for chaining.</returns>
|
||||
public AgentSkillsProviderBuilder UseFileSkill(string skillPath, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null)
|
||||
{
|
||||
return this.UseFileSkills([skillPath], options, scriptRunner);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a file-based skill source that discovers skills from multiple filesystem directories.
|
||||
/// </summary>
|
||||
/// <param name="skillPaths">Paths to search for skills.</param>
|
||||
/// <param name="options">Optional options that control skill discovery behavior.</param>
|
||||
/// <param name="scriptRunner">
|
||||
/// Optional runner for file-based scripts. When provided, overrides the builder-level runner
|
||||
/// set via <see cref="UseFileScriptRunner"/>.
|
||||
/// </param>
|
||||
/// <returns>This builder instance for chaining.</returns>
|
||||
public AgentSkillsProviderBuilder UseFileSkills(IEnumerable<string> skillPaths, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null)
|
||||
{
|
||||
this._sourceFactories.Add((builderScriptRunner, loggerFactory) =>
|
||||
{
|
||||
var resolvedRunner = scriptRunner
|
||||
?? builderScriptRunner
|
||||
?? throw new InvalidOperationException($"File-based skill sources require a script runner. Call {nameof(this.UseFileScriptRunner)} or pass a runner to {nameof(this.UseFileSkill)}/{nameof(this.UseFileSkills)}.");
|
||||
return new AgentFileSkillsSource(skillPaths, resolvedRunner, options, loggerFactory);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom skill source.
|
||||
/// </summary>
|
||||
/// <param name="source">The custom skill source.</param>
|
||||
/// <returns>This builder instance for chaining.</returns>
|
||||
public AgentSkillsProviderBuilder UseSource(AgentSkillsSource source)
|
||||
{
|
||||
_ = Throw.IfNull(source);
|
||||
this._sourceFactories.Add((_, _) => source);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a custom system prompt template.
|
||||
/// </summary>
|
||||
/// <param name="promptTemplate">The prompt template with <c>{skills}</c> placeholder for the skills list,
|
||||
/// <c>{resource_instructions}</c> for optional resource instructions,
|
||||
/// and <c>{script_instructions}</c> for optional script instructions.</param>
|
||||
/// <returns>This builder instance for chaining.</returns>
|
||||
public AgentSkillsProviderBuilder UsePromptTemplate(string promptTemplate)
|
||||
{
|
||||
this.GetOrCreateOptions().SkillsInstructionPrompt = promptTemplate;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables or disables the script approval gate.
|
||||
/// </summary>
|
||||
/// <param name="enabled">Whether script execution requires approval.</param>
|
||||
/// <returns>This builder instance for chaining.</returns>
|
||||
public AgentSkillsProviderBuilder UseScriptApproval(bool enabled = true)
|
||||
{
|
||||
this.GetOrCreateOptions().ScriptApproval = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the runner for file-based skill scripts.
|
||||
/// </summary>
|
||||
/// <param name="runner">The delegate that runs file-based scripts.</param>
|
||||
/// <returns>This builder instance for chaining.</returns>
|
||||
public AgentSkillsProviderBuilder UseFileScriptRunner(AgentFileSkillScriptRunner runner)
|
||||
{
|
||||
this._scriptRunner = Throw.IfNull(runner);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the logger factory.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <returns>This builder instance for chaining.</returns>
|
||||
public AgentSkillsProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory)
|
||||
{
|
||||
this._loggerFactory = loggerFactory;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a filter predicate that controls which skills are included.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Skills for which the predicate returns <see langword="true"/> are kept;
|
||||
/// others are excluded. Only one filter is supported; calling this method
|
||||
/// again replaces any previously set filter.
|
||||
/// </remarks>
|
||||
/// <param name="predicate">A predicate that determines which skills to include.</param>
|
||||
/// <returns>This builder instance for chaining.</returns>
|
||||
public AgentSkillsProviderBuilder UseFilter(Func<AgentSkill, bool> predicate)
|
||||
{
|
||||
_ = Throw.IfNull(predicate);
|
||||
this._filter = predicate;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the <see cref="AgentSkillsProviderOptions"/> using the provided delegate.
|
||||
/// </summary>
|
||||
/// <param name="configure">A delegate to configure the options.</param>
|
||||
/// <returns>This builder instance for chaining.</returns>
|
||||
public AgentSkillsProviderBuilder UseOptions(Action<AgentSkillsProviderOptions> configure)
|
||||
{
|
||||
_ = Throw.IfNull(configure);
|
||||
configure(this.GetOrCreateOptions());
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="AgentSkillsProvider"/>.
|
||||
/// </summary>
|
||||
/// <returns>A configured <see cref="AgentSkillsProvider"/>.</returns>
|
||||
public AgentSkillsProvider Build()
|
||||
{
|
||||
var resolvedSources = new List<AgentSkillsSource>(this._sourceFactories.Count);
|
||||
foreach (var factory in this._sourceFactories)
|
||||
{
|
||||
resolvedSources.Add(factory(this._scriptRunner, this._loggerFactory));
|
||||
}
|
||||
|
||||
AgentSkillsSource source;
|
||||
if (resolvedSources.Count == 1)
|
||||
{
|
||||
source = resolvedSources[0];
|
||||
}
|
||||
else
|
||||
{
|
||||
source = new AggregatingAgentSkillsSource(resolvedSources);
|
||||
}
|
||||
|
||||
// Apply user-specified filter, then dedup.
|
||||
if (this._filter != null)
|
||||
{
|
||||
source = new FilteringAgentSkillsSource(source, this._filter, this._loggerFactory);
|
||||
}
|
||||
|
||||
source = new DeduplicatingAgentSkillsSource(source, this._loggerFactory);
|
||||
|
||||
return new AgentSkillsProvider(source, this._options, this._loggerFactory);
|
||||
}
|
||||
|
||||
private AgentSkillsProviderOptions GetOrCreateOptions()
|
||||
{
|
||||
return this._options ??= new AgentSkillsProviderOptions();
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="AgentSkillsProvider"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class AgentSkillsProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a custom system prompt template for advertising skills.
|
||||
/// The template must contain <c>{skills}</c> as the placeholder for the generated skills list,
|
||||
/// <c>{resource_instructions}</c> for resource instructions,
|
||||
/// and <c>{script_instructions}</c> for script instructions.
|
||||
/// When <see langword="null"/>, a default template is used.
|
||||
/// </summary>
|
||||
public string? SkillsInstructionPrompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether script execution requires approval.
|
||||
/// When <see langword="true"/>, script execution is blocked until approved.
|
||||
/// Defaults to <see langword="false"/>.
|
||||
/// </summary>
|
||||
public bool ScriptApproval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether caching of tools and instructions is disabled.
|
||||
/// When <see langword="false"/> (the default), the provider caches the tools and instructions
|
||||
/// after the first build and returns the cached instance on subsequent calls.
|
||||
/// Set to <see langword="true"/> to rebuild tools and instructions on every invocation.
|
||||
/// </summary>
|
||||
public bool DisableCaching { get; set; }
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for skill sources. A skill source provides skills from a specific origin
|
||||
/// (filesystem, remote server, database, in-memory, etc.).
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public abstract class AgentSkillsSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the skills provided by this source.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A collection of skills from this source.</returns>
|
||||
public abstract Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// 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;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A skill source that aggregates multiple child sources, preserving their registration order.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Skills from each child source are returned in the order the sources were registered,
|
||||
/// with each source's skills appended sequentially. No deduplication or filtering is applied.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
internal sealed class AggregatingAgentSkillsSource : AgentSkillsSource
|
||||
{
|
||||
private readonly IEnumerable<AgentSkillsSource> _sources;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AggregatingAgentSkillsSource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="sources">The child sources to aggregate.</param>
|
||||
public AggregatingAgentSkillsSource(IEnumerable<AgentSkillsSource> sources)
|
||||
{
|
||||
this._sources = Throw.IfNull(sources);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allSkills = new List<AgentSkill>();
|
||||
foreach (var source in this._sources)
|
||||
{
|
||||
var skills = await source.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
|
||||
allSkills.AddRange(skills);
|
||||
}
|
||||
|
||||
return allSkills;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A skill source decorator that removes duplicate skills by name, keeping only the first occurrence.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
internal sealed partial class DeduplicatingAgentSkillsSource : DelegatingAgentSkillsSource
|
||||
{
|
||||
private readonly ILogger<DeduplicatingAgentSkillsSource> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeduplicatingAgentSkillsSource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerSource">The inner source to deduplicate.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public DeduplicatingAgentSkillsSource(AgentSkillsSource innerSource, ILoggerFactory? loggerFactory = null)
|
||||
: base(innerSource)
|
||||
{
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<DeduplicatingAgentSkillsSource>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var deduplicated = new List<AgentSkill>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var skill in allSkills)
|
||||
{
|
||||
if (seen.Add(skill.Frontmatter.Name))
|
||||
{
|
||||
deduplicated.Add(skill);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogDuplicateSkillName(this._logger, skill.Frontmatter.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return deduplicated;
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': subsequent skill skipped in favor of first occurrence")]
|
||||
private static partial void LogDuplicateSkillName(ILogger logger, string skillName);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// 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;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for skill sources that delegate operations to an inner source
|
||||
/// while allowing for extensibility and customization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="DelegatingAgentSkillsSource"/> implements the decorator pattern for <see cref="AgentSkillsSource"/>,
|
||||
/// enabling the creation of source pipelines where each layer can add functionality (caching, deduplication,
|
||||
/// filtering, etc.) while delegating core operations to an underlying source.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
internal abstract class DelegatingAgentSkillsSource : AgentSkillsSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAgentSkillsSource"/> class with the specified inner source.
|
||||
/// </summary>
|
||||
/// <param name="innerSource">The underlying skill source that will handle the core operations.</param>
|
||||
protected DelegatingAgentSkillsSource(AgentSkillsSource innerSource)
|
||||
{
|
||||
this.InnerSource = Throw.IfNull(innerSource);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the inner skill source that receives delegated operations.
|
||||
/// </summary>
|
||||
protected AgentSkillsSource InnerSource { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
=> this.InnerSource.GetSkillsAsync(cancellationToken);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A skill source decorator that filters skills using a caller-supplied predicate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Skills for which the predicate returns <see langword="true"/> are included in the result;
|
||||
/// skills for which it returns <see langword="false"/> are excluded and logged at debug level.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
internal sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource
|
||||
{
|
||||
private readonly Func<AgentSkill, bool> _predicate;
|
||||
private readonly ILogger<FilteringAgentSkillsSource> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FilteringAgentSkillsSource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerSource">The inner source whose skills will be filtered.</param>
|
||||
/// <param name="predicate">
|
||||
/// A predicate that determines which skills to include. Skills for which the predicate
|
||||
/// returns <see langword="true"/> are kept; others are excluded.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public FilteringAgentSkillsSource(
|
||||
AgentSkillsSource innerSource,
|
||||
Func<AgentSkill, bool> predicate,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
: base(innerSource)
|
||||
{
|
||||
this._predicate = Throw.IfNull(predicate);
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<FilteringAgentSkillsSource>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var filtered = new List<AgentSkill>();
|
||||
foreach (var skill in allSkills)
|
||||
{
|
||||
if (this._predicate(skill))
|
||||
{
|
||||
filtered.Add(skill);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogSkillFiltered(this._logger, skill.Frontmatter.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Debug, "Skill '{SkillName}' excluded by filter predicate")]
|
||||
private static partial void LogSkillFiltered(ILogger logger, string skillName);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AgentSkill"/> discovered from a filesystem directory backed by a SKILL.md file.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class AgentFileSkill : AgentSkill
|
||||
{
|
||||
private readonly IReadOnlyList<AgentSkillResource> _resources;
|
||||
private readonly IReadOnlyList<AgentSkillScript> _scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFileSkill"/> class.
|
||||
/// </summary>
|
||||
/// <param name="frontmatter">The parsed frontmatter metadata for this skill.</param>
|
||||
/// <param name="content">The full raw SKILL.md file content including YAML frontmatter.</param>
|
||||
/// <param name="path">Absolute path to the directory containing this skill.</param>
|
||||
/// <param name="resources">Resources discovered for this skill.</param>
|
||||
/// <param name="scripts">Scripts discovered for this skill.</param>
|
||||
internal AgentFileSkill(
|
||||
AgentSkillFrontmatter frontmatter,
|
||||
string content,
|
||||
string path,
|
||||
IReadOnlyList<AgentSkillResource>? resources = null,
|
||||
IReadOnlyList<AgentSkillScript>? scripts = null)
|
||||
{
|
||||
this.Frontmatter = Throw.IfNull(frontmatter);
|
||||
this.Content = Throw.IfNull(content);
|
||||
this.Path = Throw.IfNullOrWhitespace(path);
|
||||
this._resources = resources ?? [];
|
||||
this._scripts = scripts ?? [];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Content { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory path where the skill was discovered.
|
||||
/// </summary>
|
||||
public string Path { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillResource> Resources => this._resources;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillScript> Scripts => this._scripts;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A file-path-backed skill resource. Reads content from a file on disk relative to the skill directory.
|
||||
/// </summary>
|
||||
internal sealed class AgentFileSkillResource : AgentSkillResource
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFileSkillResource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">The resource name (relative path within the skill directory).</param>
|
||||
/// <param name="fullPath">The absolute file path to the resource.</param>
|
||||
public AgentFileSkillResource(string name, string fullPath)
|
||||
: base(name)
|
||||
{
|
||||
this.FullPath = Throw.IfNullOrWhitespace(fullPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the absolute file path to the resource.
|
||||
/// </summary>
|
||||
public string FullPath { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
return await File.ReadAllTextAsync(this.FullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
|
||||
#else
|
||||
using var reader = new StreamReader(this.FullPath, Encoding.UTF8);
|
||||
return await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A file-path-backed skill script. Represents a script file on disk that requires an external runner to run.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class AgentFileSkillScript : AgentSkillScript
|
||||
{
|
||||
private readonly AgentFileSkillScriptRunner? _runner;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFileSkillScript"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="fullPath">The absolute file path to the script.</param>
|
||||
/// <param name="runner">Optional external runner for running the script. An <see cref="InvalidOperationException"/> is thrown from <see cref="RunAsync"/> if no runner is provided.</param>
|
||||
internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner? runner = null)
|
||||
: base(name)
|
||||
{
|
||||
this.FullPath = Throw.IfNullOrWhitespace(fullPath);
|
||||
this._runner = runner;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the absolute file path to the script.
|
||||
/// </summary>
|
||||
public string FullPath { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (skill is not AgentFileSkill fileSkill)
|
||||
{
|
||||
throw new InvalidOperationException($"File-based script '{this.Name}' requires an {nameof(AgentFileSkill)} but received '{skill.GetType().Name}'.");
|
||||
}
|
||||
|
||||
if (this._runner is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Script '{this.Name}' cannot be executed because no {nameof(AgentFileSkillScriptRunner)} was provided. " +
|
||||
$"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution.");
|
||||
}
|
||||
|
||||
return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Delegate for running file-based skill scripts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment).
|
||||
/// </remarks>
|
||||
/// <param name="skill">The skill that owns the script.</param>
|
||||
/// <param name="script">The file-based script to run.</param>
|
||||
/// <param name="arguments">Optional arguments for the script, provided by the agent/LLM.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The script execution result.</returns>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public delegate Task<object?> AgentFileSkillScriptRunner(
|
||||
AgentFileSkill skill,
|
||||
AgentFileSkillScript script,
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken);
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for file-based skill sources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this class to configure file-based skill discovery without relying on
|
||||
/// positional constructor or method parameters. New options can be added here
|
||||
/// without breaking existing callers.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class AgentFileSkillsSourceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the allowed file extensions for skill resources.
|
||||
/// When <see langword="null"/>, defaults to <c>.md</c>, <c>.json</c>, <c>.yaml</c>,
|
||||
/// <c>.yml</c>, <c>.csv</c>, <c>.xml</c>, <c>.txt</c>.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? AllowedResourceExtensions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the allowed file extensions for skill scripts.
|
||||
/// When <see langword="null"/>, defaults to <c>.py</c>, <c>.js</c>, <c>.sh</c>,
|
||||
/// <c>.ps1</c>, <c>.cs</c>, <c>.csx</c>.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? AllowedScriptExtensions { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a loaded Agent Skill discovered from a filesystem directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each skill is backed by a <c>SKILL.md</c> file containing YAML frontmatter (name and description)
|
||||
/// and a markdown body with instructions. Resource files referenced in the body are validated at
|
||||
/// discovery time and read from disk on demand.
|
||||
/// </remarks>
|
||||
internal sealed class FileAgentSkill
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkill"/> class.
|
||||
/// </summary>
|
||||
/// <param name="frontmatter">Parsed YAML frontmatter (name and description).</param>
|
||||
/// <param name="body">The SKILL.md content after the closing <c>---</c> delimiter.</param>
|
||||
/// <param name="sourcePath">Absolute path to the directory containing this skill.</param>
|
||||
/// <param name="resourceNames">Relative paths of resource files referenced in the skill body.</param>
|
||||
public FileAgentSkill(
|
||||
SkillFrontmatter frontmatter,
|
||||
string body,
|
||||
string sourcePath,
|
||||
IReadOnlyList<string>? resourceNames = null)
|
||||
{
|
||||
this.Frontmatter = Throw.IfNull(frontmatter);
|
||||
this.Body = Throw.IfNull(body);
|
||||
this.SourcePath = Throw.IfNullOrWhitespace(sourcePath);
|
||||
this.ResourceNames = resourceNames ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parsed YAML frontmatter (name and description).
|
||||
/// </summary>
|
||||
public SkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SKILL.md body content (without the YAML frontmatter).
|
||||
/// </summary>
|
||||
public string Body { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory path where the skill was discovered.
|
||||
/// </summary>
|
||||
public string SourcePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md").
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ResourceNames { get; }
|
||||
}
|
||||
+158
-208
@@ -2,135 +2,151 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A skill source that discovers skills from filesystem directories containing SKILL.md files.
|
||||
/// Discovers, parses, and validates SKILL.md files from filesystem directories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Searches directories recursively (up to 2 levels deep) for SKILL.md files.
|
||||
/// Each file is validated for YAML frontmatter. Resource and script files are discovered by scanning the skill
|
||||
/// Searches directories recursively (up to <see cref="MaxSearchDepth"/> levels) for SKILL.md files.
|
||||
/// Each file is validated for YAML frontmatter. Resource files are discovered by scanning the skill
|
||||
/// directory for files with matching extensions. Invalid resources are skipped with logged warnings.
|
||||
/// Resource and script paths are checked against path traversal and symlink escape attacks.
|
||||
/// Resource paths are checked against path traversal and symlink escape attacks.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
internal sealed partial class FileAgentSkillLoader
|
||||
{
|
||||
private const string SkillFileName = "SKILL.md";
|
||||
private const int MaxSearchDepth = 2;
|
||||
|
||||
private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"];
|
||||
private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"];
|
||||
private const int MaxNameLength = 64;
|
||||
private const int MaxDescriptionLength = 1024;
|
||||
|
||||
// Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters.
|
||||
// Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block.
|
||||
// The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend.
|
||||
// Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n"
|
||||
private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Matches top-level YAML "key: value" lines. Group 1 = key (supports hyphens for keys like allowed-tools),
|
||||
// Group 2 = quoted value, Group 3 = unquoted value.
|
||||
// Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value.
|
||||
// Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values.
|
||||
private static readonly Regex s_yamlKeyValueRegex = new(@"^([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
// Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _),
|
||||
// "description: \"A skill\"" → (description, A skill, _)
|
||||
private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Matches a "metadata:" line followed by indented sub-key/value pairs.
|
||||
// Group 1 captures the entire indented block beneath the metadata key.
|
||||
private static readonly Regex s_yamlMetadataBlockRegex = new(@"^metadata\s*:\s*$\n((?:[ \t]+\S.*\n?)+)", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
// Validates skill names: lowercase letters, numbers, and hyphens only;
|
||||
// must not start or end with a hyphen; must not contain consecutive hyphens.
|
||||
// Examples: "my-skill" âś“, "skill123" âś“, "-bad" âś—, "bad-" âś—, "Bad" âś—, "my--skill" âś—
|
||||
private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled);
|
||||
|
||||
// Matches indented YAML "key: value" lines within a metadata block.
|
||||
// Group 1 = key (supports hyphens), Group 2 = quoted value, Group 3 = unquoted value.
|
||||
private static readonly Regex s_yamlIndentedKeyValueRegex = new(@"^\s+([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
|
||||
private readonly IEnumerable<string> _skillPaths;
|
||||
private readonly HashSet<string> _allowedResourceExtensions;
|
||||
private readonly HashSet<string> _allowedScriptExtensions;
|
||||
private readonly AgentFileSkillScriptRunner? _scriptRunner;
|
||||
private readonly ILogger _logger;
|
||||
private readonly HashSet<string> _allowedResourceExtensions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFileSkillsSource"/> class.
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkillLoader"/> class.
|
||||
/// </summary>
|
||||
/// <param name="skillPath">Path to search for skills.</param>
|
||||
/// <param name="scriptRunner">Optional runner for file-based scripts. Required only when skills contain scripts.</param>
|
||||
/// <param name="options">Optional options that control skill discovery behavior.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public AgentFileSkillsSource(
|
||||
string skillPath,
|
||||
AgentFileSkillScriptRunner? scriptRunner = null,
|
||||
AgentFileSkillsSourceOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
: this([skillPath], scriptRunner, options, loggerFactory)
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="allowedResourceExtensions">File extensions to recognize as skill resources. When <see langword="null"/>, defaults are used.</param>
|
||||
internal FileAgentSkillLoader(ILogger logger, IEnumerable<string>? allowedResourceExtensions = null)
|
||||
{
|
||||
}
|
||||
this._logger = logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFileSkillsSource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="skillPaths">Paths to search for skills.</param>
|
||||
/// <param name="scriptRunner">Optional runner for file-based scripts. Required only when skills contain scripts.</param>
|
||||
/// <param name="options">Optional options that control skill discovery behavior.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public AgentFileSkillsSource(
|
||||
IEnumerable<string> skillPaths,
|
||||
AgentFileSkillScriptRunner? scriptRunner = null,
|
||||
AgentFileSkillsSourceOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
this._skillPaths = Throw.IfNull(skillPaths);
|
||||
|
||||
var resolvedOptions = options ?? new AgentFileSkillsSourceOptions();
|
||||
|
||||
ValidateExtensions(resolvedOptions.AllowedResourceExtensions);
|
||||
ValidateExtensions(resolvedOptions.AllowedScriptExtensions);
|
||||
ValidateExtensions(allowedResourceExtensions);
|
||||
|
||||
this._allowedResourceExtensions = new HashSet<string>(
|
||||
resolvedOptions.AllowedResourceExtensions ?? s_defaultResourceExtensions,
|
||||
allowedResourceExtensions ?? [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
this._allowedScriptExtensions = new HashSet<string>(
|
||||
resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
this._scriptRunner = scriptRunner;
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentFileSkillsSource>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
/// <summary>
|
||||
/// Discovers skill directories and loads valid skills from them.
|
||||
/// </summary>
|
||||
/// <param name="skillPaths">Paths to search for skills. Each path can point to an individual skill folder or a parent folder.</param>
|
||||
/// <returns>A dictionary of loaded skills keyed by skill name.</returns>
|
||||
internal Dictionary<string, FileAgentSkill> DiscoverAndLoadSkills(IEnumerable<string> skillPaths)
|
||||
{
|
||||
var discoveredPaths = DiscoverSkillDirectories(this._skillPaths);
|
||||
var skills = new Dictionary<string, FileAgentSkill>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var discoveredPaths = DiscoverSkillDirectories(skillPaths);
|
||||
|
||||
LogSkillsDiscovered(this._logger, discoveredPaths.Count);
|
||||
|
||||
var skills = new List<AgentSkill>();
|
||||
|
||||
foreach (string skillPath in discoveredPaths)
|
||||
{
|
||||
AgentFileSkill? skill = this.ParseSkillDirectory(skillPath);
|
||||
FileAgentSkill? skill = this.ParseSkillFile(skillPath);
|
||||
if (skill is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
skills.Add(skill);
|
||||
if (skills.TryGetValue(skill.Frontmatter.Name, out FileAgentSkill? existing))
|
||||
{
|
||||
LogDuplicateSkillName(this._logger, skill.Frontmatter.Name, skillPath, existing.SourcePath);
|
||||
|
||||
// Skip duplicate skill names, keeping the first one found.
|
||||
continue;
|
||||
}
|
||||
|
||||
skills[skill.Frontmatter.Name] = skill;
|
||||
|
||||
LogSkillLoaded(this._logger, skill.Frontmatter.Name);
|
||||
}
|
||||
|
||||
LogSkillsLoadedTotal(this._logger, skills.Count);
|
||||
|
||||
return Task.FromResult(skills as IList<AgentSkill>);
|
||||
return skills;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a resource file from disk with path traversal and symlink guards.
|
||||
/// </summary>
|
||||
/// <param name="skill">The skill that owns the resource.</param>
|
||||
/// <param name="resourceName">Relative path of the resource within the skill directory.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The UTF-8 text content of the resource file.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The resource is not registered, resolves outside the skill directory, or does not exist.
|
||||
/// </exception>
|
||||
internal async Task<string> ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
resourceName = NormalizeResourcePath(resourceName);
|
||||
|
||||
if (!skill.ResourceNames.Any(r => r.Equals(resourceName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new InvalidOperationException($"Resource '{resourceName}' not found in skill '{skill.Frontmatter.Name}'.");
|
||||
}
|
||||
|
||||
string fullPath = Path.GetFullPath(Path.Combine(skill.SourcePath, resourceName));
|
||||
string normalizedSourcePath = Path.GetFullPath(skill.SourcePath) + Path.DirectorySeparatorChar;
|
||||
|
||||
if (!IsPathWithinDirectory(fullPath, normalizedSourcePath))
|
||||
{
|
||||
throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory.");
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'.");
|
||||
}
|
||||
|
||||
if (HasSymlinkInPath(fullPath, normalizedSourcePath))
|
||||
{
|
||||
throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory.");
|
||||
}
|
||||
|
||||
LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name);
|
||||
|
||||
#if NET
|
||||
return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
|
||||
#else
|
||||
return await Task.FromResult(File.ReadAllText(fullPath, Encoding.UTF8)).ConfigureAwait(false);
|
||||
#endif
|
||||
}
|
||||
|
||||
private static List<string> DiscoverSkillDirectories(IEnumerable<string> skillPaths)
|
||||
@@ -169,30 +185,30 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
}
|
||||
}
|
||||
|
||||
private AgentFileSkill? ParseSkillDirectory(string skillDirectoryFullPath)
|
||||
private FileAgentSkill? ParseSkillFile(string skillDirectoryFullPath)
|
||||
{
|
||||
string skillFilePath = Path.Combine(skillDirectoryFullPath, SkillFileName);
|
||||
|
||||
string content = File.ReadAllText(skillFilePath, Encoding.UTF8);
|
||||
|
||||
if (!this.TryParseFrontmatter(content, skillFilePath, out AgentSkillFrontmatter? frontmatter))
|
||||
if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resources = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name);
|
||||
var scripts = this.DiscoverScriptFiles(skillDirectoryFullPath, frontmatter.Name);
|
||||
List<string> resourceNames = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name);
|
||||
|
||||
return new AgentFileSkill(
|
||||
return new FileAgentSkill(
|
||||
frontmatter: frontmatter,
|
||||
content: content,
|
||||
path: skillDirectoryFullPath,
|
||||
resources: resources,
|
||||
scripts: scripts);
|
||||
body: body,
|
||||
sourcePath: skillDirectoryFullPath,
|
||||
resourceNames: resourceNames);
|
||||
}
|
||||
|
||||
private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullWhen(true)] out AgentSkillFrontmatter? frontmatter)
|
||||
private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body)
|
||||
{
|
||||
frontmatter = null;
|
||||
frontmatter = null!;
|
||||
body = null!;
|
||||
|
||||
Match match = s_frontmatterRegex.Match(content);
|
||||
if (!match.Success)
|
||||
@@ -201,13 +217,10 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
return false;
|
||||
}
|
||||
|
||||
string yamlContent = match.Groups[1].Value.Trim();
|
||||
|
||||
string? name = null;
|
||||
string? description = null;
|
||||
string? license = null;
|
||||
string? compatibility = null;
|
||||
string? allowedTools = null;
|
||||
|
||||
string yamlContent = match.Groups[1].Value.Trim();
|
||||
|
||||
foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent))
|
||||
{
|
||||
@@ -222,62 +235,50 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
{
|
||||
description = value;
|
||||
}
|
||||
else if (string.Equals(key, "license", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
license = value;
|
||||
}
|
||||
else if (string.Equals(key, "compatibility", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
compatibility = value;
|
||||
}
|
||||
else if (string.Equals(key, "allowed-tools", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
allowedTools = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse metadata block (indented key-value pairs under "metadata:").
|
||||
AdditionalPropertiesDictionary? metadata = null;
|
||||
Match metadataMatch = s_yamlMetadataBlockRegex.Match(yamlContent);
|
||||
if (metadataMatch.Success)
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
metadata = [];
|
||||
foreach (Match kvMatch in s_yamlIndentedKeyValueRegex.Matches(metadataMatch.Groups[1].Value))
|
||||
{
|
||||
metadata[kvMatch.Groups[1].Value] = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!AgentSkillFrontmatter.ValidateName(name, out string? validationReason) ||
|
||||
!AgentSkillFrontmatter.ValidateDescription(description, out validationReason))
|
||||
{
|
||||
LogInvalidFieldValue(this._logger, skillFilePath, "frontmatter", validationReason);
|
||||
LogMissingFrontmatterField(this._logger, skillFilePath, "name");
|
||||
return false;
|
||||
}
|
||||
|
||||
frontmatter = new AgentSkillFrontmatter(name!, description!, compatibility)
|
||||
if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name))
|
||||
{
|
||||
License = license,
|
||||
AllowedTools = allowedTools,
|
||||
Metadata = metadata,
|
||||
};
|
||||
LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// skillFilePath is e.g. "/skills/my-skill/SKILL.md".
|
||||
// GetDirectoryName strips the filename → "/skills/my-skill".
|
||||
// GetFileName then extracts the last segment → "my-skill".
|
||||
// This gives us the skill's parent directory name to validate against the frontmatter name.
|
||||
string directoryName = Path.GetFileName(Path.GetDirectoryName(skillFilePath)) ?? string.Empty;
|
||||
if (!string.Equals(frontmatter.Name, directoryName, StringComparison.Ordinal))
|
||||
if (!string.Equals(name, directoryName, StringComparison.Ordinal))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Error))
|
||||
{
|
||||
LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), frontmatter.Name, SanitizePathForLog(directoryName));
|
||||
LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), name, SanitizePathForLog(directoryName));
|
||||
}
|
||||
|
||||
frontmatter = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
LogMissingFrontmatterField(this._logger, skillFilePath, "description");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (description.Length > MaxDescriptionLength)
|
||||
{
|
||||
LogInvalidFieldValue(this._logger, skillFilePath, "description", $"Must be {MaxDescriptionLength} characters or fewer.");
|
||||
return false;
|
||||
}
|
||||
|
||||
frontmatter = new SkillFrontmatter(name, description);
|
||||
body = content.Substring(match.Index + match.Length).TrimStart();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -286,15 +287,15 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Recursively walks <paramref name="skillDirectoryFullPath"/> and collects files whose extension
|
||||
/// matches the allowed set, excluding <c>SKILL.md</c> itself. Each candidate
|
||||
/// matches <see cref="_allowedResourceExtensions"/>, excluding <c>SKILL.md</c> itself. Each candidate
|
||||
/// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with
|
||||
/// a warning.
|
||||
/// </remarks>
|
||||
private List<AgentFileSkillResource> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
|
||||
private List<string> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
|
||||
{
|
||||
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
|
||||
|
||||
var resources = new List<AgentFileSkillResource>();
|
||||
var resources = new List<string>();
|
||||
|
||||
#if NET
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
@@ -325,21 +326,21 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
{
|
||||
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize the enumerated path to guard against non-canonical forms
|
||||
// (redundant separators, 8.3 short names, etc.) that would produce
|
||||
// malformed relative resource names.
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Path containment check
|
||||
if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
|
||||
if (!IsPathWithinDirectory(resolvedFilePath, normalizedSkillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -350,86 +351,30 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
{
|
||||
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute relative path and normalize to forward slashes
|
||||
string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));
|
||||
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
|
||||
string relativePath = resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length);
|
||||
resources.Add(NormalizeResourcePath(relativePath));
|
||||
}
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans a skill directory for script files matching the configured extensions.
|
||||
/// Checks that <paramref name="fullPath"/> is under <paramref name="normalizedDirectoryPath"/>,
|
||||
/// guarding against path traversal attacks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Recursively walks the skill directory and collects files whose extension
|
||||
/// matches the allowed set. Each candidate is validated against path-traversal
|
||||
/// and symlink-escape checks; unsafe files are skipped with a warning.
|
||||
/// </remarks>
|
||||
private List<AgentFileSkillScript> DiscoverScriptFiles(string skillDirectoryFullPath, string skillName)
|
||||
private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath)
|
||||
{
|
||||
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
|
||||
var scripts = new List<AgentFileSkillScript>();
|
||||
|
||||
#if NET
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions))
|
||||
#else
|
||||
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories))
|
||||
#endif
|
||||
{
|
||||
// Filter by extension
|
||||
string extension = Path.GetExtension(filePath);
|
||||
if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize the enumerated path to guard against non-canonical forms
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Path containment check
|
||||
if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Symlink check
|
||||
if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute relative path and normalize to forward slashes
|
||||
string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));
|
||||
scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
|
||||
}
|
||||
|
||||
return scripts;
|
||||
return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether any segment in the path (relative to the directory) is a symlink.
|
||||
/// Checks whether any segment in <paramref name="fullPath"/> (relative to
|
||||
/// <paramref name="normalizedDirectoryPath"/>) is a symlink (reparse point).
|
||||
/// Uses <see cref="FileAttributes.ReparsePoint"/> which is available on all target frameworks.
|
||||
/// </summary>
|
||||
private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath)
|
||||
{
|
||||
@@ -454,10 +399,11 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a relative path by replacing backslashes with forward slashes
|
||||
/// and trimming a leading "./" prefix.
|
||||
/// Normalizes a relative resource path by trimming a leading <c>./</c> prefix and replacing
|
||||
/// backslashes with forward slashes so that <c>./refs/doc.md</c> and <c>refs/doc.md</c> are
|
||||
/// treated as the same resource.
|
||||
/// </summary>
|
||||
private static string NormalizePath(string path)
|
||||
private static string NormalizeResourcePath(string path)
|
||||
{
|
||||
if (path.IndexOf('\\') >= 0)
|
||||
{
|
||||
@@ -473,7 +419,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces control characters in a file path with '?' to prevent log injection.
|
||||
/// Replaces control characters in a file path with '?' to prevent log injection
|
||||
/// via crafted filenames (e.g., filenames containing newlines on Linux).
|
||||
/// </summary>
|
||||
private static string SanitizePathForLog(string path)
|
||||
{
|
||||
@@ -502,7 +449,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
if (string.IsNullOrWhiteSpace(ext) || !ext.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
#pragma warning disable CA2208 // Instantiate argument exceptions correctly
|
||||
throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", "allowedResourceExtensions");
|
||||
throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", nameof(FileAgentSkillsProviderOptions.AllowedResourceExtensions));
|
||||
#pragma warning restore CA2208 // Instantiate argument exceptions correctly
|
||||
}
|
||||
}
|
||||
@@ -520,6 +467,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")]
|
||||
private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath);
|
||||
|
||||
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' is missing a '{FieldName}' field in frontmatter")]
|
||||
private static partial void LogMissingFrontmatterField(ILogger logger, string skillFilePath, string fieldName);
|
||||
|
||||
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")]
|
||||
private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason);
|
||||
|
||||
@@ -529,15 +479,15 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")]
|
||||
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")]
|
||||
private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")]
|
||||
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath);
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")]
|
||||
private static partial void LogResourceReading(ILogger logger, string fileName, string skillName);
|
||||
|
||||
[LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")]
|
||||
private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' references a path outside the skill directory")]
|
||||
private static partial void LogScriptPathTraversal(ILogger logger, string skillName, string scriptPath);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")]
|
||||
private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIContextProvider"/> that discovers and exposes Agent Skills from filesystem directories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This provider implements the progressive disclosure pattern from the
|
||||
/// <see href="https://agentskills.io/">Agent Skills specification</see>:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description><strong>Advertise</strong> — skill names and descriptions are injected into the system prompt (~100 tokens per skill).</description></item>
|
||||
/// <item><description><strong>Load</strong> — the full SKILL.md body is returned via the <c>load_skill</c> tool.</description></item>
|
||||
/// <item><description><strong>Read resources</strong> — supplementary files are read from disk on demand via the <c>read_skill_resource</c> tool.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Skills are discovered by searching the configured directories for <c>SKILL.md</c> files.
|
||||
/// Referenced resources are validated at initialization; invalid skills are excluded and logged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security:</strong> this provider only reads static content. Skill metadata is XML-escaped
|
||||
/// before prompt embedding, and resource reads are guarded against path traversal and symlink escape.
|
||||
/// Only use skills from trusted sources.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
{
|
||||
private const string DefaultSkillsInstructionPrompt =
|
||||
"""
|
||||
You have access to skills containing domain-specific knowledge and capabilities.
|
||||
Each skill provides specialized instructions, reference documents, and assets for specific tasks.
|
||||
|
||||
<available_skills>
|
||||
{0}
|
||||
</available_skills>
|
||||
|
||||
When a task aligns with a skill's domain:
|
||||
1. Use `load_skill` to retrieve the skill's instructions
|
||||
2. Follow the provided guidance
|
||||
3. Use `read_skill_resource` to read any references or other files mentioned by the skill
|
||||
|
||||
Only load what is needed, when it is needed.
|
||||
""";
|
||||
|
||||
private readonly Dictionary<string, FileAgentSkill> _skills;
|
||||
private readonly ILogger<FileAgentSkillsProvider> _logger;
|
||||
private readonly FileAgentSkillLoader _loader;
|
||||
private readonly AITool[] _tools;
|
||||
private readonly string? _skillsInstructionPrompt;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkillsProvider"/> class that searches a single directory for skills.
|
||||
/// </summary>
|
||||
/// <param name="skillPath">Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories.</param>
|
||||
/// <param name="options">Optional configuration for prompt customization.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
: this([skillPath], options, loggerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkillsProvider"/> class that searches multiple directories for skills.
|
||||
/// </summary>
|
||||
/// <param name="skillPaths">Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories.</param>
|
||||
/// <param name="options">Optional configuration for prompt customization.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public FileAgentSkillsProvider(IEnumerable<string> skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(skillPaths);
|
||||
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<FileAgentSkillsProvider>();
|
||||
|
||||
this._loader = new FileAgentSkillLoader(this._logger, options?.AllowedResourceExtensions);
|
||||
this._skills = this._loader.DiscoverAndLoadSkills(skillPaths);
|
||||
|
||||
this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills);
|
||||
|
||||
this._tools =
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
this.LoadSkill,
|
||||
name: "load_skill",
|
||||
description: "Loads the full instructions for a specific skill."),
|
||||
AIFunctionFactory.Create(
|
||||
this.ReadSkillResourceAsync,
|
||||
name: "read_skill_resource",
|
||||
description: "Reads a file associated with a skill, such as references or assets."),
|
||||
];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._skills.Count == 0)
|
||||
{
|
||||
return base.ProvideAIContextAsync(context, cancellationToken);
|
||||
}
|
||||
|
||||
return new ValueTask<AIContext>(new AIContext
|
||||
{
|
||||
Instructions = this._skillsInstructionPrompt,
|
||||
Tools = this._tools
|
||||
});
|
||||
}
|
||||
|
||||
private string LoadSkill(string skillName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skillName))
|
||||
{
|
||||
return "Error: Skill name cannot be empty.";
|
||||
}
|
||||
|
||||
if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill))
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
}
|
||||
|
||||
LogSkillLoading(this._logger, skillName);
|
||||
|
||||
return skill.Body;
|
||||
}
|
||||
|
||||
private async Task<string> ReadSkillResourceAsync(string skillName, string resourceName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skillName))
|
||||
{
|
||||
return "Error: Skill name cannot be empty.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resourceName))
|
||||
{
|
||||
return "Error: Resource name cannot be empty.";
|
||||
}
|
||||
|
||||
if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill))
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await this._loader.ReadSkillResourceAsync(skill, resourceName, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogResourceReadError(this._logger, skillName, resourceName, ex);
|
||||
return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'.";
|
||||
}
|
||||
}
|
||||
|
||||
private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary<string, FileAgentSkill> skills)
|
||||
{
|
||||
string promptTemplate = DefaultSkillsInstructionPrompt;
|
||||
|
||||
if (options?.SkillsInstructionPrompt is { } optionsInstructions)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = string.Format(optionsInstructions, string.Empty);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The provided SkillsInstructionPrompt is not a valid format string.",
|
||||
nameof(options),
|
||||
ex);
|
||||
}
|
||||
|
||||
if (optionsInstructions.IndexOf("{0}", StringComparison.Ordinal) < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The provided SkillsInstructionPrompt must contain a '{0}' placeholder for the generated skills list.",
|
||||
nameof(options));
|
||||
}
|
||||
|
||||
promptTemplate = optionsInstructions;
|
||||
}
|
||||
|
||||
if (skills.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Order by name for deterministic prompt output across process restarts
|
||||
// (Dictionary enumeration order is not guaranteed and varies with hash randomization).
|
||||
foreach (var skill in skills.Values.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal))
|
||||
{
|
||||
sb.AppendLine(" <skill>");
|
||||
sb.AppendLine($" <name>{SecurityElement.Escape(skill.Frontmatter.Name)}</name>");
|
||||
sb.AppendLine($" <description>{SecurityElement.Escape(skill.Frontmatter.Description)}</description>");
|
||||
sb.AppendLine(" </skill>");
|
||||
}
|
||||
|
||||
return string.Format(promptTemplate, sb.ToString().TrimEnd());
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")]
|
||||
private static partial void LogSkillLoading(ILogger logger, string skillName);
|
||||
|
||||
[LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")]
|
||||
private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="FileAgentSkillsProvider"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileAgentSkillsProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a custom system prompt template for advertising skills.
|
||||
/// Use <c>{0}</c> as the placeholder for the generated skills list.
|
||||
/// When <see langword="null"/>, a default template is used.
|
||||
/// </summary>
|
||||
public string? SkillsInstructionPrompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the file extensions recognized as discoverable skill resources.
|
||||
/// Each value must start with a <c>'.'</c> character (for example, <c>.md</c>), and
|
||||
/// extension comparisons are performed in a case-insensitive manner.
|
||||
/// Files in the skill directory (and its subdirectories) whose extension matches
|
||||
/// one of these values will be automatically discovered as resources.
|
||||
/// When <see langword="null"/>, a default set of extensions is used
|
||||
/// (<c>.md</c>, <c>.json</c>, <c>.yaml</c>, <c>.yml</c>, <c>.csv</c>, <c>.xml</c>, <c>.txt</c>).
|
||||
/// </summary>
|
||||
public IEnumerable<string>? AllowedResourceExtensions { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description.
|
||||
/// </summary>
|
||||
internal sealed class SkillFrontmatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SkillFrontmatter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">Skill name.</param>
|
||||
/// <param name="description">Skill description.</param>
|
||||
public SkillFrontmatter(string name, string description)
|
||||
{
|
||||
this.Name = Throw.IfNullOrWhitespace(name);
|
||||
this.Description = Throw.IfNullOrWhitespace(description);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the skill name. Lowercase letters, numbers, and hyphens only.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the skill description. Used for discovery in the system prompt.
|
||||
/// </summary>
|
||||
public string Description { get; }
|
||||
}
|
||||
@@ -188,134 +188,6 @@ public class AIProjectClientCreateTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that an agent version created with an OpenAPI tool definition via the native
|
||||
/// Azure.AI.Projects SDK and then wrapped with <c>AsAIAgent(agentVersion)</c> correctly
|
||||
/// invokes the server-side OpenAPI function through <c>RunAsync</c>.
|
||||
/// Regression test for https://github.com/microsoft/agent-framework/issues/4883.
|
||||
/// </summary>
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = "For manual testing only")]
|
||||
public async Task AsAIAgent_WithOpenAPITool_NativeSDKCreation_InvokesServerSideToolAsync()
|
||||
{
|
||||
// Arrange — create agent version with OpenAPI tool using native Azure.AI.Projects SDK types.
|
||||
string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("OpenAPITestAgent");
|
||||
const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code.";
|
||||
|
||||
const string CountriesOpenApiSpec = """
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "REST Countries API",
|
||||
"description": "Retrieve information about countries by currency code",
|
||||
"version": "v3.1"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://restcountries.com/v3.1"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/currency/{currency}": {
|
||||
"get": {
|
||||
"description": "Get countries that use a specific currency code (e.g., USD, EUR, GBP)",
|
||||
"operationId": "GetCountriesByCurrency",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "currency",
|
||||
"in": "path",
|
||||
"description": "Currency code (e.g., USD, EUR, GBP)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response with list of countries",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No countries found for the currency"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Step 1: Create the OpenAPI function definition and agent version using native SDK types.
|
||||
var openApiFunction = new OpenApiFunctionDefinition(
|
||||
"get_countries",
|
||||
BinaryData.FromString(CountriesOpenApiSpec),
|
||||
new OpenAPIAnonymousAuthenticationDetails())
|
||||
{
|
||||
Description = "Retrieve information about countries by currency code"
|
||||
};
|
||||
|
||||
var definition = new PromptAgentDefinition(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = { (ResponseTool)AgentTool.CreateOpenApiTool(openApiFunction) }
|
||||
};
|
||||
|
||||
AgentVersionCreationOptions creationOptions = new(definition);
|
||||
AgentVersion agentVersion = await this._client.Agents.CreateAgentVersionAsync(AgentName, creationOptions);
|
||||
|
||||
try
|
||||
{
|
||||
// Step 2: Wrap the agent version using AsAIAgent extension.
|
||||
ChatClientAgent agent = this._client.AsAIAgent(agentVersion);
|
||||
|
||||
// Assert the agent was created correctly and retains version metadata.
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal(AgentName, agent.Name);
|
||||
var retrievedVersion = agent.GetService<AgentVersion>();
|
||||
Assert.NotNull(retrievedVersion);
|
||||
|
||||
// Step 3: Call RunAsync to trigger the server-side OpenAPI function.
|
||||
var result = await agent.RunAsync("What countries use the Euro (EUR) as their currency? Please list them.");
|
||||
|
||||
// Step 4: Validate the OpenAPI tool was invoked server-side.
|
||||
// Note: Server-side OpenAPI tools (executed within the Responses API via AgentReference)
|
||||
// do not surface as FunctionCallContent in the MEAI abstraction — the API handles the full
|
||||
// tool loop internally. We validate tool invocation by asserting the response contains
|
||||
// multiple specific country names that the model would need API data to enumerate accurately.
|
||||
var text = result.ToString();
|
||||
Assert.NotEmpty(text);
|
||||
|
||||
// The response must mention multiple well-known Eurozone countries — requiring several
|
||||
// correct entries makes it highly unlikely the model answered purely from parametric knowledge.
|
||||
int matchCount = 0;
|
||||
foreach (var country in new[] { "Germany", "France", "Italy", "Spain", "Portugal", "Netherlands", "Belgium", "Austria", "Ireland", "Finland" })
|
||||
{
|
||||
if (text.Contains(country, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
matchCount >= 3,
|
||||
$"Expected response to list at least 3 Eurozone countries from the OpenAPI tool, but found {matchCount}. Response: {text}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup.
|
||||
await this._client.Agents.DeleteAgentAsync(AgentName);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism)
|
||||
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.AzureAI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="ProjectResponsesClientExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class ProjectResponsesClientExtensionsTests
|
||||
{
|
||||
private static ProjectResponsesClient CreateTestClient()
|
||||
{
|
||||
return new ProjectResponsesClient(new FakeAuthenticationTokenProvider());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled throws ArgumentNullException when client is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsIChatClientWithStoredOutputDisabled_WithNullClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
((ProjectResponsesClient)null!).AsIChatClientWithStoredOutputDisabled());
|
||||
|
||||
Assert.Equal("responseClient", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled wraps the original ProjectResponsesClient,
|
||||
/// which remains accessible via the service chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsIChatClientWithStoredOutputDisabled_InnerResponsesClientIsAccessible()
|
||||
{
|
||||
// Arrange
|
||||
var responseClient = CreateTestClient();
|
||||
|
||||
// Act
|
||||
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
|
||||
|
||||
// Assert - the inner ProjectResponsesClient should be accessible via GetService
|
||||
var innerClient = chatClient.GetService<ResponsesClient>();
|
||||
Assert.NotNull(innerClient);
|
||||
Assert.Same(responseClient, innerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent false
|
||||
/// wraps the original ProjectResponsesClient, which remains accessible via the service chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_InnerResponsesClientIsAccessible()
|
||||
{
|
||||
// Arrange
|
||||
var responseClient = CreateTestClient();
|
||||
|
||||
// Act
|
||||
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: false);
|
||||
|
||||
// Assert - the inner ProjectResponsesClient should be accessible via GetService
|
||||
var innerClient = chatClient.GetService<ResponsesClient>();
|
||||
Assert.NotNull(innerClient);
|
||||
Assert.Same(responseClient, innerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled with default parameter (includeReasoningEncryptedContent = true)
|
||||
/// configures StoredOutputEnabled to false and includes ReasoningEncryptedContent in IncludedProperties.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsIChatClientWithStoredOutputDisabled_Default_ConfiguresStoredOutputDisabledWithReasoningEncryptedContent()
|
||||
{
|
||||
// Arrange
|
||||
var responseClient = CreateTestClient();
|
||||
|
||||
// Act
|
||||
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
|
||||
|
||||
// Assert
|
||||
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient);
|
||||
Assert.NotNull(createResponseOptions);
|
||||
Assert.False(createResponseOptions.StoredOutputEnabled);
|
||||
Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent explicitly set to true
|
||||
/// configures StoredOutputEnabled to false and includes ReasoningEncryptedContent in IncludedProperties.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningTrue_ConfiguresStoredOutputDisabledWithReasoningEncryptedContent()
|
||||
{
|
||||
// Arrange
|
||||
var responseClient = CreateTestClient();
|
||||
|
||||
// Act
|
||||
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: true);
|
||||
|
||||
// Assert
|
||||
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient);
|
||||
Assert.NotNull(createResponseOptions);
|
||||
Assert.False(createResponseOptions.StoredOutputEnabled);
|
||||
Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent set to false
|
||||
/// configures StoredOutputEnabled to false and does not include ReasoningEncryptedContent in IncludedProperties.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_ConfiguresStoredOutputDisabledWithoutReasoningEncryptedContent()
|
||||
{
|
||||
// Arrange
|
||||
var responseClient = CreateTestClient();
|
||||
|
||||
// Act
|
||||
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: false);
|
||||
|
||||
// Assert
|
||||
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient);
|
||||
Assert.NotNull(createResponseOptions);
|
||||
Assert.False(createResponseOptions.StoredOutputEnabled);
|
||||
Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled works with an optional deployment name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsIChatClientWithStoredOutputDisabled_WithDeploymentName_ConfiguresStoredOutputDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var responseClient = CreateTestClient();
|
||||
|
||||
// Act
|
||||
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(deploymentName: "my-deployment");
|
||||
|
||||
// Assert
|
||||
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient);
|
||||
Assert.NotNull(createResponseOptions);
|
||||
Assert.False(createResponseOptions.StoredOutputEnabled);
|
||||
Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the <see cref="CreateResponseOptions"/> produced by the ConfigureOptions pipeline
|
||||
/// by using reflection to access the configure action and invoking it on a test <see cref="ChatOptions"/>.
|
||||
/// </summary>
|
||||
private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient)
|
||||
{
|
||||
var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(configureField);
|
||||
|
||||
var configureAction = configureField.GetValue(chatClient) as Action<ChatOptions>;
|
||||
Assert.NotNull(configureAction);
|
||||
|
||||
var options = new ChatOptions();
|
||||
configureAction(options);
|
||||
|
||||
Assert.NotNull(options.RawRepresentationFactory);
|
||||
return options.RawRepresentationFactory(chatClient) as CreateResponseOptions;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -35,7 +35,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
.Build();
|
||||
|
||||
private static bool s_infrastructureStarted;
|
||||
private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(2);
|
||||
private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(1);
|
||||
|
||||
// In CI, `dotnet run` builds the Functions project from scratch before the host starts, so 60s is not enough.
|
||||
private static readonly TimeSpan s_functionsReadyTimeout = TimeSpan.FromSeconds(180);
|
||||
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ChatCompletionRequestMessage.ToChatMessage() role preservation.
|
||||
/// Verifies that each message type correctly maps its role to the corresponding ChatRole.
|
||||
/// </summary>
|
||||
public sealed class ChatCompletionRequestMessageToChatMessageTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("system", """{"role":"system","content":"You are a helpful assistant."}""")]
|
||||
[InlineData("developer", """{"role":"developer","content":"Follow these rules."}""")]
|
||||
[InlineData("user", """{"role":"user","content":"Hello!"}""")]
|
||||
[InlineData("assistant", """{"role":"assistant","content":"Hi there!"}""")]
|
||||
[InlineData("tool", """{"role":"tool","content":"result","tool_call_id":"call_123"}""")]
|
||||
public void ToChatMessage_PreservesRole_ForTextContent(string expectedRole, string json)
|
||||
{
|
||||
// Arrange
|
||||
ChatCompletionRequestMessage message = JsonSerializer.Deserialize(
|
||||
json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!;
|
||||
|
||||
// Act
|
||||
ChatMessage chatMessage = message.ToChatMessage();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedRole, message.Role);
|
||||
Assert.Equal(new ChatRole(expectedRole), chatMessage.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessage_FunctionMessage_PreservesRole()
|
||||
{
|
||||
// Arrange
|
||||
const string Json = """{"role":"function","name":"get_weather","content":"sunny"}""";
|
||||
ChatCompletionRequestMessage message = JsonSerializer.Deserialize(
|
||||
Json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!;
|
||||
|
||||
// Act
|
||||
ChatMessage chatMessage = message.ToChatMessage();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("function", message.Role);
|
||||
Assert.Equal(new ChatRole("function"), chatMessage.Role);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("system")]
|
||||
[InlineData("developer")]
|
||||
[InlineData("user")]
|
||||
[InlineData("assistant")]
|
||||
public void ToChatMessage_PreservesRole_ForMultiPartContent(string expectedRole)
|
||||
{
|
||||
// Arrange
|
||||
string json = $$"""{"role":"{{expectedRole}}","content":[{"type":"text","text":"Hello!"}]}""";
|
||||
ChatCompletionRequestMessage message = JsonSerializer.Deserialize(
|
||||
json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!;
|
||||
|
||||
// Act
|
||||
ChatMessage chatMessage = message.ToChatMessage();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedRole, message.Role);
|
||||
Assert.Equal(new ChatRole(expectedRole), chatMessage.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessage_MultiTurnConversation_PreservesAllRoles()
|
||||
{
|
||||
// Arrange - simulate a multi-turn conversation
|
||||
string[] jsons =
|
||||
[
|
||||
"""{"role":"system","content":"You are a helpful assistant."}""",
|
||||
"""{"role":"user","content":"Hello!"}""",
|
||||
"""{"role":"assistant","content":"Hi there! How can I help?"}""",
|
||||
"""{"role":"user","content":"What did I just say?"}"""
|
||||
];
|
||||
|
||||
string[] expectedRoles = ["system", "user", "assistant", "user"];
|
||||
|
||||
// Act
|
||||
ChatMessage[] chatMessages = jsons
|
||||
.Select(j => JsonSerializer.Deserialize(
|
||||
j, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!)
|
||||
.Select(m => m.ToChatMessage())
|
||||
.ToArray();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedRoles.Length, chatMessages.Length);
|
||||
for (int i = 0; i < expectedRoles.Length; i++)
|
||||
{
|
||||
Assert.Equal(new ChatRole(expectedRoles[i]), chatMessages[i].Role);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessage_PreservesTextContent()
|
||||
{
|
||||
// Arrange
|
||||
const string Json = """{"role":"system","content":"You are a helpful assistant."}""";
|
||||
ChatCompletionRequestMessage message = JsonSerializer.Deserialize(
|
||||
Json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!;
|
||||
|
||||
// Act
|
||||
ChatMessage chatMessage = message.ToChatMessage();
|
||||
|
||||
// Assert
|
||||
Assert.Contains(chatMessage.Contents, c => c is TextContent tc && tc.Text == "You are a helpful assistant.");
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentFileSkillScript"/>.
|
||||
/// </summary>
|
||||
public sealed class AgentFileSkillScriptTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult<object?>("result");
|
||||
var script = CreateScript("test-script", "/path/to/script.py", RunnerAsync);
|
||||
var nonFileSkill = new TestAgentSkill("my-skill", "A skill", "Instructions.");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => script.RunAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithAgentFileSkill_DelegatesToRunnerAsync()
|
||||
{
|
||||
// Arrange
|
||||
var runnerCalled = false;
|
||||
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct)
|
||||
{
|
||||
runnerCalled = true;
|
||||
return Task.FromResult<object?>("executed");
|
||||
}
|
||||
var script = CreateScript("run-me", "/scripts/run-me.sh", runnerAsync);
|
||||
var fileSkill = new AgentFileSkill(
|
||||
new AgentSkillFrontmatter("my-skill", "A file skill"),
|
||||
"---\nname: my-skill\n---\nContent",
|
||||
"/skills/my-skill");
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(runnerCalled);
|
||||
Assert.Equal("executed", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_RunnerReceivesCorrectArgumentsAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentFileSkill? capturedSkill = null;
|
||||
AgentFileSkillScript? capturedScript = null;
|
||||
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct)
|
||||
{
|
||||
capturedSkill = skill;
|
||||
capturedScript = scriptArg;
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
var script = CreateScript("capture", "/scripts/capture.py", runnerAsync);
|
||||
var fileSkill = new AgentFileSkill(
|
||||
new AgentSkillFrontmatter("owner-skill", "Owner"),
|
||||
"Content",
|
||||
"/skills/owner-skill");
|
||||
|
||||
// Act
|
||||
await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Same(fileSkill, capturedSkill);
|
||||
Assert.Same(script, capturedScript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Script_HasCorrectNameAndPath()
|
||||
{
|
||||
// Arrange & Act
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
var script = CreateScript("my-script", "/path/to/my-script.py", RunnerAsync);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("my-script", script.Name);
|
||||
Assert.Equal("/path/to/my-script.py", script.FullPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to create an <see cref="AgentFileSkillScript"/> via reflection since the constructor is internal.
|
||||
/// </summary>
|
||||
private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptRunner executor)
|
||||
{
|
||||
var ctor = typeof(AgentFileSkillScript).GetConstructor(
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance,
|
||||
null,
|
||||
[typeof(string), typeof(string), typeof(AgentFileSkillScriptRunner)],
|
||||
null) ?? throw new InvalidOperationException("Could not find internal constructor.");
|
||||
|
||||
return (AgentFileSkillScript)ctor.Invoke([name, fullPath, executor]);
|
||||
}
|
||||
}
|
||||
-255
@@ -1,255 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for script discovery and execution in <see cref="AgentFileSkillsSource"/>.
|
||||
/// </summary>
|
||||
public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
{
|
||||
private static readonly string[] s_rubyExtension = new[] { ".rb" };
|
||||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult<object?>(null);
|
||||
|
||||
private readonly string _testRoot;
|
||||
|
||||
public AgentFileSkillsSourceScriptTests()
|
||||
{
|
||||
this._testRoot = Path.Combine(Path.GetTempPath(), "skills-source-script-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(this._testRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(this._testRoot))
|
||||
{
|
||||
Directory.Delete(this._testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_WithScriptFiles_DiscoversScriptsAsync()
|
||||
{
|
||||
// Arrange
|
||||
CreateSkillWithScript(this._testRoot, "my-skill", "A test skill", "Body.", "scripts/convert.py", "print('hello')");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.NotNull(skill.Scripts);
|
||||
Assert.Single(skill.Scripts!);
|
||||
Assert.Equal("scripts/convert.py", skill.Scripts![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_WithMultipleScriptExtensions_DiscoversAllAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = CreateSkillDir(this._testRoot, "multi-ext-skill", "Multi-extension skill", "Body.");
|
||||
CreateFile(skillDir, "scripts/run.py", "print('py')");
|
||||
CreateFile(skillDir, "scripts/run.sh", "echo 'sh'");
|
||||
CreateFile(skillDir, "scripts/run.js", "console.log('js')");
|
||||
CreateFile(skillDir, "scripts/run.ps1", "Write-Host 'ps'");
|
||||
CreateFile(skillDir, "scripts/run.cs", "Console.WriteLine();");
|
||||
CreateFile(skillDir, "scripts/run.csx", "Console.WriteLine();");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
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]
|
||||
public async Task GetSkillsAsync_NonScriptExtensionsAreNotDiscoveredAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = CreateSkillDir(this._testRoot, "no-script-skill", "Non-script skill", "Body.");
|
||||
CreateFile(skillDir, "scripts/data.txt", "text data");
|
||||
CreateFile(skillDir, "scripts/config.json", "{}");
|
||||
CreateFile(skillDir, "scripts/notes.md", "# Notes");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Scripts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_NoScriptFiles_ReturnsEmptyScriptsAsync()
|
||||
{
|
||||
// Arrange
|
||||
CreateSkillDir(this._testRoot, "no-scripts", "No scripts skill", "Body.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.NotNull(skills[0].Scripts);
|
||||
Assert.Empty(skills[0].Scripts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreAlsoDiscoveredAsync()
|
||||
{
|
||||
// Arrange — scripts at any depth in the skill directory are discovered
|
||||
string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body.");
|
||||
CreateFile(skillDir, "convert.py", "print('root')");
|
||||
CreateFile(skillDir, "tools/helper.sh", "echo 'helper'");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
|
||||
Assert.Equal(2, scriptNames.Count);
|
||||
Assert.Contains("convert.py", scriptNames);
|
||||
Assert.Contains("tools/helper.sh", scriptNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_WithRunner_ScriptsCanRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
CreateSkillWithScript(this._testRoot, "exec-skill", "Executor test", "Body.", "scripts/test.py", "print('ok')");
|
||||
var executorCalled = false;
|
||||
var source = new AgentFileSkillsSource(
|
||||
this._testRoot,
|
||||
(skill, script, args, ct) =>
|
||||
{
|
||||
executorCalled = true;
|
||||
Assert.Equal("exec-skill", skill.Frontmatter.Name);
|
||||
Assert.Equal("scripts/test.py", script.Name);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(this._testRoot, "exec-skill", "scripts", "test.py")), script.FullPath);
|
||||
return Task.FromResult<object?>("executed");
|
||||
});
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(executorCalled);
|
||||
Assert.Equal("executed", scriptResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullExecutor_DoesNotThrow()
|
||||
{
|
||||
// Arrange & Act & Assert — null runner is allowed when skills have no scripts
|
||||
var source = new AgentFileSkillsSource(this._testRoot, null);
|
||||
Assert.NotNull(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ScriptsWithNoRunner_ThrowsOnRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = CreateSkillDir(this._testRoot, "no-runner-skill", "No runner", "Body.");
|
||||
CreateFile(skillDir, "scripts/run.sh", "echo 'hello'");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, scriptRunner: null);
|
||||
|
||||
// Act — discovery succeeds even without a runner
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
var script = skills[0].Scripts![0];
|
||||
|
||||
// Assert — running the script throws because no runner was provided
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => script.RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_CustomScriptExtensions_OnlyDiscoversMatchingAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = CreateSkillDir(this._testRoot, "custom-ext-skill", "Custom extensions", "Body.");
|
||||
CreateFile(skillDir, "scripts/run.py", "print('py')");
|
||||
CreateFile(skillDir, "scripts/run.rb", "puts 'rb'");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedScriptExtensions = s_rubyExtension });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Scripts!);
|
||||
Assert.Equal("scripts/run.rb", skills[0].Scripts![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ExecutorReceivesArgumentsAsync()
|
||||
{
|
||||
// Arrange
|
||||
CreateSkillWithScript(this._testRoot, "args-skill", "Args test", "Body.", "scripts/test.py", "print('ok')");
|
||||
AIFunctionArguments? capturedArgs = null;
|
||||
var source = new AgentFileSkillsSource(
|
||||
this._testRoot,
|
||||
(skill, script, args, ct) =>
|
||||
{
|
||||
capturedArgs = args;
|
||||
return Task.FromResult<object?>("done");
|
||||
});
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
var arguments = new AIFunctionArguments
|
||||
{
|
||||
["value"] = 26.2,
|
||||
["factor"] = 1.60934
|
||||
};
|
||||
await skills[0].Scripts![0].RunAsync(skills[0], arguments, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedArgs);
|
||||
Assert.Equal(26.2, capturedArgs["value"]);
|
||||
Assert.Equal(1.60934, capturedArgs["factor"]);
|
||||
}
|
||||
|
||||
private static string CreateSkillDir(string root, string name, string description, string body)
|
||||
{
|
||||
string skillDir = Path.Combine(root, name);
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
$"---\nname: {name}\ndescription: {description}\n---\n{body}");
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
private static void CreateSkillWithScript(string root, string name, string description, string body, string scriptRelativePath, string scriptContent)
|
||||
{
|
||||
string skillDir = CreateSkillDir(root, name, description, body);
|
||||
CreateFile(skillDir, scriptRelativePath, scriptContent);
|
||||
}
|
||||
|
||||
private static void CreateFile(string root, string relativePath, string content)
|
||||
{
|
||||
string fullPath = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
File.WriteAllText(fullPath, content);
|
||||
}
|
||||
}
|
||||
-260
@@ -1,260 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentSkillFrontmatter"/> validation.
|
||||
/// </summary>
|
||||
public sealed class AgentSkillFrontmatterValidatorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("my-skill")]
|
||||
[InlineData("a")]
|
||||
[InlineData("skill123")]
|
||||
[InlineData("a1b2c3")]
|
||||
public void ValidateName_ValidName_ReturnsTrue(string name)
|
||||
{
|
||||
// Act
|
||||
bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Null(reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("-leading-hyphen")]
|
||||
[InlineData("trailing-hyphen-")]
|
||||
[InlineData("has spaces")]
|
||||
[InlineData("UPPERCASE")]
|
||||
[InlineData("consecutive--hyphens")]
|
||||
[InlineData("special!chars")]
|
||||
public void ValidateName_InvalidName_ReturnsFalse(string name)
|
||||
{
|
||||
// Act
|
||||
bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.NotNull(reason);
|
||||
Assert.Contains("name", reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateName_NameExceedsMaxLength_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
string longName = new('a', 65);
|
||||
|
||||
// Act
|
||||
bool result = AgentSkillFrontmatter.ValidateName(longName, out string? reason);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void ValidateName_NullOrWhitespace_ReturnsFalse(string? name)
|
||||
{
|
||||
// Act
|
||||
bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateDescription_ValidDescription_ReturnsTrue()
|
||||
{
|
||||
// Act
|
||||
bool result = AgentSkillFrontmatter.ValidateDescription("A valid description.", out string? reason);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Null(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateDescription_DescriptionExceedsMaxLength_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
string longDesc = new('x', 1025);
|
||||
|
||||
// Act
|
||||
bool result = AgentSkillFrontmatter.ValidateDescription(longDesc, out string? reason);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void ValidateDescription_NullOrWhitespace_ReturnsFalse(string? description)
|
||||
{
|
||||
// Act
|
||||
bool result = AgentSkillFrontmatter.ValidateDescription(description, out string? reason);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCompatibility_Null_ReturnsTrue()
|
||||
{
|
||||
// Act
|
||||
bool result = AgentSkillFrontmatter.ValidateCompatibility(null, out string? reason);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Null(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCompatibility_WithinMaxLength_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
string compatibility = new('x', 500);
|
||||
|
||||
// Act
|
||||
bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Null(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCompatibility_ExceedsMaxLength_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
string compatibility = new('x', 501);
|
||||
|
||||
// Act
|
||||
bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("UPPERCASE")]
|
||||
[InlineData("-leading")]
|
||||
[InlineData("trailing-")]
|
||||
[InlineData("consecutive--hyphens")]
|
||||
public void Constructor_InvalidName_ThrowsArgumentException(string name)
|
||||
{
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter(name, "A valid description."));
|
||||
Assert.Contains("name", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NameExceedsMaxLength_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
string longName = new('a', 65);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter(longName, "A valid description."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DescriptionExceedsMaxLength_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
string longDesc = new('x', 1025);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter("valid-name", longDesc));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Constructor_NullOrWhitespaceName_ThrowsArgumentException(string? name)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter(name!, "A valid description."));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Constructor_NullOrWhitespaceDescription_ThrowsArgumentException(string? description)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter("valid-name", description!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compatibility_ExceedsMaxLength_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.");
|
||||
string longCompatibility = new('x', 501);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => frontmatter.Compatibility = longCompatibility);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compatibility_WithinMaxLength_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.");
|
||||
string compatibility = new('x', 500);
|
||||
|
||||
// Act
|
||||
frontmatter.Compatibility = compatibility;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(compatibility, frontmatter.Compatibility);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compatibility_Null_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.");
|
||||
|
||||
// Act
|
||||
frontmatter.Compatibility = null;
|
||||
|
||||
// Assert
|
||||
Assert.Null(frontmatter.Compatibility);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithCompatibility_SetsValue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.", "Requires Python 3.10+");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Requires Python 3.10+", frontmatter.Compatibility);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CompatibilityExceedsMaxLength_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
string longCompatibility = new('x', 501);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter("valid-name", "A valid description.", longCompatibility));
|
||||
}
|
||||
}
|
||||
-229
@@ -1,229 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentSkillsProviderBuilder"/>.
|
||||
/// </summary>
|
||||
public sealed class AgentSkillsProviderBuilderTests
|
||||
{
|
||||
private readonly TestAIAgent _agent = new();
|
||||
|
||||
private AIContextProvider.InvokingContext CreateInvokingContext()
|
||||
{
|
||||
return new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NoSourceConfigured_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new AgentSkillsProviderBuilder();
|
||||
|
||||
// Act
|
||||
var provider = builder.Build();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithCustomSource_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var source = new TestAgentSkillsSource(
|
||||
new TestAgentSkill("custom", "Custom skill", "Instructions."));
|
||||
var builder = new AgentSkillsProviderBuilder()
|
||||
.UseSource(source);
|
||||
|
||||
// Act
|
||||
var provider = builder.Build();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseSource_NullSource_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new AgentSkillsProviderBuilder();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => builder.UseSource(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseFilter_NullPredicate_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new AgentSkillsProviderBuilder();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => builder.UseFilter(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseFileScriptRunner_NullRunner_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new AgentSkillsProviderBuilder();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => builder.UseFileScriptRunner(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseOptions_NullConfigure_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new AgentSkillsProviderBuilder();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => builder.UseOptions(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_WithFilter_AppliesFilterToSkillsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var source = new TestAgentSkillsSource(
|
||||
new TestAgentSkill("keep-me", "Keep", "Instructions."),
|
||||
new TestAgentSkill("drop-me", "Drop", "Instructions."));
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseSource(source)
|
||||
.UseFilter(skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase))
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(
|
||||
this.CreateInvokingContext(), CancellationToken.None);
|
||||
|
||||
// Assert — the instructions should mention "keep-me" but not "drop-me"
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("keep-me", result.Instructions);
|
||||
Assert.DoesNotContain("drop-me", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_WithCacheDisabled_ReloadsOnEachCallAsync()
|
||||
{
|
||||
// Arrange
|
||||
var countingSource = new CountingSource(
|
||||
new TestAgentSkill("skill-a", "A", "Instructions."));
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseSource(countingSource)
|
||||
.UseOptions(o => o.DisableCaching = true)
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None);
|
||||
await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None);
|
||||
|
||||
// Assert — inner source should be called each time (dedup still calls through)
|
||||
Assert.True(countingSource.CallCount >= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_WithCacheEnabled_CachesSkillsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var countingSource = new CountingSource(
|
||||
new TestAgentSkill("skill-a", "A", "Instructions."));
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseSource(countingSource)
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None);
|
||||
await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None);
|
||||
|
||||
// Assert — inner source should only be called once due to caching
|
||||
Assert.Equal(1, countingSource.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_FluentChaining_ReturnsSameBuilder()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new AgentSkillsProviderBuilder();
|
||||
var source = new TestAgentSkillsSource(
|
||||
new TestAgentSkill("test", "Test", "Instructions."));
|
||||
|
||||
// Act — all fluent methods should return the same builder
|
||||
var result = builder
|
||||
.UseSource(source)
|
||||
.UseScriptApproval(false)
|
||||
.UsePromptTemplate("Skills:\n{skills}\n{resource_instructions}\n{script_instructions}");
|
||||
|
||||
// Assert
|
||||
Assert.Same(builder, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_UseOptions_ConfiguresOptions()
|
||||
{
|
||||
// Arrange
|
||||
var source = new TestAgentSkillsSource(
|
||||
new TestAgentSkill("test", "Test", "Instructions."));
|
||||
|
||||
// Act — UseOptions should not throw and successfully configure
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseSource(source)
|
||||
.UseOptions(opts => opts.ScriptApproval = true)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_WithMultipleCustomSources_AggregatesAllAsync()
|
||||
{
|
||||
// Arrange
|
||||
var source1 = new TestAgentSkillsSource(
|
||||
new TestAgentSkill("from-one", "Source 1", "Instructions 1."));
|
||||
var source2 = new TestAgentSkillsSource(
|
||||
new TestAgentSkill("from-two", "Source 2", "Instructions 2."));
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseSource(source1)
|
||||
.UseSource(source2)
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(
|
||||
this.CreateInvokingContext(), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("from-one", result.Instructions);
|
||||
Assert.Contains("from-two", result.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test source that counts how many times GetSkillsAsync is called.
|
||||
/// </summary>
|
||||
private sealed class CountingSource : AgentSkillsSource
|
||||
{
|
||||
private readonly AgentSkill[] _skills;
|
||||
private int _callCount;
|
||||
|
||||
public CountingSource(params AgentSkill[] skills)
|
||||
{
|
||||
this._skills = skills;
|
||||
}
|
||||
|
||||
public int CallCount => this._callCount;
|
||||
|
||||
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref this._callCount);
|
||||
return Task.FromResult<IList<AgentSkill>>(this._skills);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,765 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AgentSkillsProvider"/> class with <see cref="AgentFileSkillsSource"/>.
|
||||
/// </summary>
|
||||
public sealed class AgentSkillsProviderTests : IDisposable
|
||||
{
|
||||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult<object?>(null);
|
||||
private readonly string _testRoot;
|
||||
private readonly TestAIAgent _agent = new();
|
||||
|
||||
public AgentSkillsProviderTests()
|
||||
{
|
||||
this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(this._testRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(this._testRoot))
|
||||
{
|
||||
Directory.Delete(this._testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var inputContext = new AIContext { Instructions = "Original instructions" };
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Original instructions", result.Instructions);
|
||||
Assert.Null(result.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body.");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var inputContext = new AIContext { Instructions = "Base instructions" };
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("Base instructions", result.Instructions);
|
||||
Assert.Contains("provider-skill", result.Instructions);
|
||||
Assert.Contains("Provider skill test", result.Instructions);
|
||||
|
||||
// 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.DoesNotContain("read_skill_resource", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("null-instr-skill", "Null instruction test", "Body.");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("null-instr-skill", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body.");
|
||||
var options = new AgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "Custom template: {skills}\n{resource_instructions}\n{script_instructions}"
|
||||
};
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options);
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.StartsWith("Custom template:", result.Instructions);
|
||||
Assert.Contains("custom-prompt-skill", result.Instructions);
|
||||
Assert.Contains("Custom prompt", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PromptWithoutSkillsPlaceholder_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var options = new AgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "No skills placeholder here {resource_instructions} {script_instructions}"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options));
|
||||
Assert.Contains("{skills}", ex.Message);
|
||||
Assert.Equal("options", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PromptWithoutRunnerInstructionsPlaceholder_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var options = new AgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "Has skills {skills} but no runner instructions {resource_instructions}"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options));
|
||||
Assert.Contains("{script_instructions}", ex.Message);
|
||||
Assert.Equal("options", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PromptWithBothPlaceholders_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var options = new AgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "Skills: {skills}\nResources: {resource_instructions}\nRunner: {script_instructions}"
|
||||
};
|
||||
|
||||
// Act — should not throw
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PromptWithoutResourceInstructionsPlaceholder_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var options = new AgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "Has skills {skills} and runner {script_instructions} but no resource instructions"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options));
|
||||
Assert.Contains("{resource_instructions}", ex.Message);
|
||||
Assert.Equal("options", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync()
|
||||
{
|
||||
// Arrange — description with XML-sensitive characters
|
||||
string skillDir = Path.Combine(this._testRoot, "xml-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: xml-skill\ndescription: Uses <tags> & \"quotes\"\n---\nBody.");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("<tags>", result.Instructions);
|
||||
Assert.Contains("&", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync()
|
||||
{
|
||||
// Arrange
|
||||
string dir1 = Path.Combine(this._testRoot, "dir1");
|
||||
string dir2 = Path.Combine(this._testRoot, "dir2");
|
||||
CreateSkillIn(dir1, "skill-a", "Skill A", "Body A.");
|
||||
CreateSkillIn(dir2, "skill-b", "Skill B", "Body B.");
|
||||
|
||||
// Act
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(new[] { dir1, dir2 }, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Assert
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("skill-a", result.Instructions);
|
||||
Assert.Contains("skill-b", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("tools-skill", "Tools test", "Body.");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
|
||||
var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool.");
|
||||
var inputContext = new AIContext { Tools = new[] { existingTool } };
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — existing tool should be preserved alongside the new skill tools
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("existing_tool", toolNames);
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync()
|
||||
{
|
||||
// Arrange — create skills in reverse alphabetical order
|
||||
this.CreateSkill("zulu-skill", "Zulu skill", "Body Z.");
|
||||
this.CreateSkill("alpha-skill", "Alpha skill", "Body A.");
|
||||
this.CreateSkill("mike-skill", "Mike skill", "Body M.");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — skills should appear in alphabetical order in the prompt
|
||||
Assert.NotNull(result.Instructions);
|
||||
int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal);
|
||||
int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal);
|
||||
int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal);
|
||||
Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill");
|
||||
Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContextAsync_ConcurrentCalls_LoadsSkillsOnlyOnceAsync()
|
||||
{
|
||||
// Arrange
|
||||
var source = new CountingAgentSkillsSource(
|
||||
[
|
||||
new TestAgentSkill("concurrent-skill", "Concurrent test", "Body.")
|
||||
]);
|
||||
var provider = new AgentSkillsProvider(source);
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act — invoke concurrently from multiple threads
|
||||
var tasks = Enumerable.Range(0, 10)
|
||||
.Select(_ => provider.InvokingAsync(invokingContext, CancellationToken.None).AsTask())
|
||||
.ToArray();
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert — GetSkillsAsync should have been called exactly once (provider-level caching)
|
||||
Assert.Equal(1, source.GetSkillsCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_WithScripts_IncludesRunSkillScriptToolAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "script-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: script-skill\ndescription: Skill with scripts\n---\nBody.");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "scripts", "test.py"),
|
||||
"print('hello')");
|
||||
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
var provider = new AgentSkillsProvider(source);
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// 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("run_skill_script", toolNames);
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_WithoutScripts_NoRunSkillScriptToolAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("no-script-skill", "No scripts", "Body.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
var provider = new AgentSkillsProvider(source);
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.DoesNotContain("run_skill_script", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithFileSkillsButNoExecutor_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new AgentSkillsProviderBuilder()
|
||||
.UseFileSkill(this._testRoot);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => builder.Build());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Builder_UseFileSkillWithOptions_DiscoverSkillsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("opts-skill", "Options skill", "Options body.");
|
||||
var options = new AgentFileSkillsSourceOptions();
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseFileSkill(this._testRoot, options)
|
||||
.UseFileScriptRunner(s_noOpExecutor)
|
||||
.UseOptions(o => o.DisableCaching = true)
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("opts-skill", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Builder_UseFileSkillsWithOptions_DiscoverMultipleSkillsAsync()
|
||||
{
|
||||
// Arrange
|
||||
string dir1 = Path.Combine(this._testRoot, "multi-opts-1");
|
||||
string dir2 = Path.Combine(this._testRoot, "multi-opts-2");
|
||||
CreateSkillIn(dir1, "skill-x", "Skill X", "Body X.");
|
||||
CreateSkillIn(dir2, "skill-y", "Skill Y", "Body Y.");
|
||||
|
||||
var options = new AgentFileSkillsSourceOptions();
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseFileSkills(new[] { dir1, dir2 }, options)
|
||||
.UseFileScriptRunner(s_noOpExecutor)
|
||||
.UseOptions(o => o.DisableCaching = true)
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("skill-x", result.Instructions);
|
||||
Assert.Contains("skill-y", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Builder_UseFileSkillWithOptionsResourceFilter_FiltersResourcesAsync()
|
||||
{
|
||||
// Arrange — create a skill with both .md and .json resources
|
||||
string skillDir = Path.Combine(this._testRoot, "res-filter-opts");
|
||||
CreateSkillIn(skillDir, "filter-skill", "Filter test", "Filter body.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}", System.Text.Encoding.UTF8);
|
||||
File.WriteAllText(Path.Combine(skillDir, "notes.txt"), "notes", System.Text.Encoding.UTF8);
|
||||
|
||||
// Only allow .json resources
|
||||
var options = new AgentFileSkillsSourceOptions
|
||||
{
|
||||
AllowedResourceExtensions = [".json"],
|
||||
};
|
||||
var source = new AgentFileSkillsSource(skillDir, s_noOpExecutor, options);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var fileSkill = Assert.IsType<AgentFileSkill>(skills[0]);
|
||||
Assert.All(fileSkill.Resources, r => Assert.EndsWith(".json", r.Name));
|
||||
}
|
||||
|
||||
private void CreateSkill(string name, string description, string body)
|
||||
{
|
||||
CreateSkillIn(this._testRoot, name, description, body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkill_DefaultOptions_ReturnsFullContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("content-skill", "Content test", "Skill body.");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
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 Dictionary<string, object?> { ["skillName"] = "content-skill" }));
|
||||
|
||||
// Assert — should contain frontmatter and body
|
||||
var text = content!.ToString()!;
|
||||
Assert.Contains("---", text);
|
||||
Assert.Contains("name: content-skill", text);
|
||||
Assert.Contains("Skill body.", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Builder_UseFileScriptRunnerAfterUseFileSkills_RunnerIsUsedAsync()
|
||||
{
|
||||
// Arrange — create a skill with a script file
|
||||
string skillDir = Path.Combine(this._testRoot, "builder-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: builder-skill\ndescription: Builder test\n---\nBody.");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "scripts", "run.py"),
|
||||
"print('ok')");
|
||||
|
||||
var executorCalled = false;
|
||||
|
||||
// Act — call UseFileScriptRunner AFTER UseFileSkill (the bug scenario)
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseFileSkill(this._testRoot)
|
||||
.UseFileScriptRunner((skill, script, args, ct) =>
|
||||
{
|
||||
executorCalled = true;
|
||||
return Task.FromResult<object?>("executed");
|
||||
})
|
||||
.Build();
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — run_skill_script tool should be present and executor should work
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("run_skill_script", toolNames);
|
||||
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "builder-skill",
|
||||
["scriptName"] = "scripts/run.py",
|
||||
}));
|
||||
|
||||
Assert.True(executorCalled);
|
||||
}
|
||||
|
||||
private static void CreateSkillIn(string root, string name, string description, string body)
|
||||
{
|
||||
string skillDir = Path.Combine(root, name);
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
$"---\nname: {name}\ndescription: {description}\n---\n{body}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_WithCachingDisabled_ReloadsSkillsOnEachCallAsync()
|
||||
{
|
||||
// Arrange
|
||||
var source = new CountingAgentSkillsSource(
|
||||
[
|
||||
new TestAgentSkill("no-cache-skill", "No cache test", "Body.")
|
||||
]);
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseSource(source)
|
||||
.UseOptions(o => o.DisableCaching = true)
|
||||
.Build();
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — source should be called more than once since caching is disabled
|
||||
Assert.True(source.GetSkillsCallCount > 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_WithCachingEnabled_CachesSkillsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var source = new CountingAgentSkillsSource(
|
||||
[
|
||||
new TestAgentSkill("cached-skill", "Cached test", "Body.")
|
||||
]);
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseSource(source)
|
||||
.Build();
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — source should be called exactly once (caching is on by default)
|
||||
Assert.Equal(1, source.GetSkillsCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_DefaultOptions_CachesSkillsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var source = new CountingAgentSkillsSource(
|
||||
[
|
||||
new TestAgentSkill("default-skill", "Default test", "Body.")
|
||||
]);
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseSource(source)
|
||||
.Build();
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — default behavior caches
|
||||
Assert.Equal(1, source.GetSkillsCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_WithScriptsAndScriptApproval_WrapsRunScriptToolAsync()
|
||||
{
|
||||
// Arrange — create a skill with a script and enable ScriptApproval
|
||||
string skillDir = Path.Combine(this._testRoot, "approval-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: approval-skill\ndescription: Approval test\n---\nBody.");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "scripts", "run.py"),
|
||||
"print('hello')");
|
||||
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
var options = new AgentSkillsProviderOptions { ScriptApproval = true };
|
||||
var provider = new AgentSkillsProvider(source, options);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — run_skill_script tool should be wrapped in ApprovalRequiredAIFunction
|
||||
Assert.NotNull(result.Tools);
|
||||
var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script");
|
||||
Assert.NotNull(scriptTool);
|
||||
Assert.IsType<ApprovalRequiredAIFunction>(scriptTool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_WithScriptsNoScriptApproval_DoesNotWrapRunScriptToolAsync()
|
||||
{
|
||||
// Arrange — create a skill with a script, default options (no approval)
|
||||
string skillDir = Path.Combine(this._testRoot, "no-approval-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: no-approval-skill\ndescription: No approval test\n---\nBody.");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "scripts", "run.py"),
|
||||
"print('hello')");
|
||||
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
var provider = new AgentSkillsProvider(source);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — run_skill_script tool should NOT be wrapped
|
||||
Assert.NotNull(result.Tools);
|
||||
var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script");
|
||||
Assert.NotNull(scriptTool);
|
||||
Assert.IsNotType<ApprovalRequiredAIFunction>(scriptTool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreSharedWhenCachedAsync()
|
||||
{
|
||||
// Arrange — with default caching, tools should be the same reference
|
||||
this.CreateSkill("cached-tools-skill", "Cached tools test", "Body.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
var provider = new AgentSkillsProvider(source);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — tool lists should be the same reference (cached)
|
||||
Assert.NotNull(result1.Tools);
|
||||
Assert.NotNull(result2.Tools);
|
||||
Assert.Same(result1.Tools, result2.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreNotSharedWhenCachingDisabledAsync()
|
||||
{
|
||||
// Arrange — with caching disabled, tools should be rebuilt per invocation
|
||||
this.CreateSkill("fresh-tools-skill", "Fresh tools test", "Body.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
var options = new AgentSkillsProviderOptions { DisableCaching = true };
|
||||
var provider = new AgentSkillsProvider(source, options);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — tool lists should not be the same reference
|
||||
Assert.NotNull(result1.Tools);
|
||||
Assert.NotNull(result2.Tools);
|
||||
Assert.NotSame(result1.Tools, result2.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_SingleDirectory_DiscoverFileSkillsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("file-ctor-skill", "File ctor test", "File body.");
|
||||
var provider = new AgentSkillsProvider(this._testRoot, s_noOpExecutor);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("file-ctor-skill", result.Instructions);
|
||||
Assert.NotNull(result.Tools);
|
||||
Assert.Contains(result.Tools!, t => t.Name == "load_skill");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_MultipleDirectories_DiscoverFileSkillsAsync()
|
||||
{
|
||||
// Arrange
|
||||
string dir1 = Path.Combine(this._testRoot, "dir1");
|
||||
string dir2 = Path.Combine(this._testRoot, "dir2");
|
||||
Directory.CreateDirectory(dir1);
|
||||
Directory.CreateDirectory(dir2);
|
||||
CreateSkillIn(dir1, "skill-a", "Skill A", "Body A.");
|
||||
CreateSkillIn(dir2, "skill-b", "Skill B", "Body B.");
|
||||
|
||||
var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("skill-a", result.Instructions);
|
||||
Assert.Contains("skill-b", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_MultipleDirectories_DeduplicatesSkillsByNameAsync()
|
||||
{
|
||||
// Arrange — same skill name in two directories
|
||||
string dir1 = Path.Combine(this._testRoot, "dup1");
|
||||
string dir2 = Path.Combine(this._testRoot, "dup2");
|
||||
Directory.CreateDirectory(dir1);
|
||||
Directory.CreateDirectory(dir2);
|
||||
CreateSkillIn(dir1, "dup-skill", "First", "Body 1.");
|
||||
CreateSkillIn(dir2, "dup-skill", "Second", "Body 2.");
|
||||
|
||||
var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
||||
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "dup-skill" }));
|
||||
|
||||
// Assert — only first occurrence should survive
|
||||
Assert.NotNull(content);
|
||||
Assert.Contains("Body 1.", content!.ToString()!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test skill source that counts how many times <see cref="GetSkillsAsync"/> is called.
|
||||
/// </summary>
|
||||
private sealed class CountingAgentSkillsSource : AgentSkillsSource
|
||||
{
|
||||
private readonly IList<AgentSkill> _skills;
|
||||
private int _callCount;
|
||||
|
||||
public CountingAgentSkillsSource(IList<AgentSkill> skills)
|
||||
{
|
||||
this._skills = skills;
|
||||
}
|
||||
|
||||
public int GetSkillsCallCount => this._callCount;
|
||||
|
||||
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref this._callCount);
|
||||
return Task.FromResult(this._skills);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAgentSkill : AgentSkill
|
||||
{
|
||||
private readonly string _content;
|
||||
|
||||
public TestAgentSkill(string name, string description, string content)
|
||||
{
|
||||
this.Frontmatter = new AgentSkillFrontmatter(name, description);
|
||||
this._content = content;
|
||||
}
|
||||
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
public override string Content => this._content;
|
||||
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources => null;
|
||||
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
|
||||
}
|
||||
}
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DeduplicatingAgentSkillsSource"/>.
|
||||
/// </summary>
|
||||
public sealed class DeduplicatingAgentSkillsSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inner = new TestAgentSkillsSource(
|
||||
new TestAgentSkill("skill-a", "A", "Instructions A."),
|
||||
new TestAgentSkill("skill-b", "B", "Instructions B."));
|
||||
var source = new DeduplicatingAgentSkillsSource(inner);
|
||||
|
||||
// Act
|
||||
var result = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skills = new AgentSkill[]
|
||||
{
|
||||
new TestAgentSkill("dupe", "First", "Instructions 1."),
|
||||
new TestAgentSkill("dupe", "Second", "Instructions 2."),
|
||||
new TestAgentSkill("unique", "Unique", "Instructions 3."),
|
||||
};
|
||||
var inner = new TestAgentSkillsSource(skills);
|
||||
var source = new DeduplicatingAgentSkillsSource(inner);
|
||||
|
||||
// Act
|
||||
var result = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("First", result.First(s => s.Frontmatter.Name == "dupe").Frontmatter.Description);
|
||||
Assert.Contains(result, s => s.Frontmatter.Name == "unique");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync()
|
||||
{
|
||||
// Arrange — use a custom source that returns skills with same name but different casing
|
||||
var inner = new FakeDuplicateCaseSource();
|
||||
var source = new DeduplicatingAgentSkillsSource(inner);
|
||||
|
||||
// Act
|
||||
var result = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("First", result[0].Frontmatter.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inner = new TestAgentSkillsSource(System.Array.Empty<AgentSkill>());
|
||||
var source = new DeduplicatingAgentSkillsSource(inner);
|
||||
|
||||
// Act
|
||||
var result = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fake source that returns skills with names differing only by case.
|
||||
/// </summary>
|
||||
private sealed class FakeDuplicateCaseSource : AgentSkillsSource
|
||||
{
|
||||
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// AgentSkillFrontmatter validates names must be lowercase, so we build
|
||||
// two skills with the same lowercase name to test case-insensitive dedup.
|
||||
var skills = new List<AgentSkill>
|
||||
{
|
||||
new TestAgentSkill("my-skill", "First", "Instructions 1."),
|
||||
new TestAgentSkill("my-skill", "Second", "Instructions 2."),
|
||||
};
|
||||
return Task.FromResult<IList<AgentSkill>>(skills);
|
||||
}
|
||||
}
|
||||
}
|
||||
+228
-266
@@ -4,25 +4,25 @@ using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AgentFileSkillsSource"/> skill discovery and parsing logic.
|
||||
/// Unit tests for the <see cref="FileAgentSkillLoader"/> class.
|
||||
/// </summary>
|
||||
public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
{
|
||||
private static readonly string[] s_customExtensions = [".custom"];
|
||||
private static readonly string[] s_validExtensions = [".md", ".json", ".custom"];
|
||||
private static readonly string[] s_mixedValidInvalidExtensions = [".md", "json"];
|
||||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult<object?>(null);
|
||||
private static readonly string[] s_traversalResource = new[] { "../secret.txt" };
|
||||
|
||||
private readonly string _testRoot;
|
||||
private readonly FileAgentSkillLoader _loader;
|
||||
|
||||
public FileAgentSkillLoaderTests()
|
||||
{
|
||||
this._testRoot = Path.Combine(Path.GetTempPath(), "agent-skills-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(this._testRoot);
|
||||
this._loader = new FileAgentSkillLoader(NullLogger.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -34,23 +34,23 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ValidSkill_ReturnsSkillAsync()
|
||||
public void DiscoverAndLoadSkills_ValidSkill_ReturnsSkill()
|
||||
{
|
||||
// Arrange
|
||||
_ = this.CreateSkillDirectory("my-skill", "A test skill", "Use this skill to do things.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Equal("my-skill", skills[0].Frontmatter.Name);
|
||||
Assert.Equal("A test skill", skills[0].Frontmatter.Description);
|
||||
Assert.True(skills.ContainsKey("my-skill"));
|
||||
Assert.Equal("A test skill", skills["my-skill"].Frontmatter.Description);
|
||||
Assert.Equal("Use this skill to do things.", skills["my-skill"].Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_QuotedFrontmatterValues_ParsesCorrectlyAsync()
|
||||
public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "quoted-skill");
|
||||
@@ -58,35 +58,33 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: 'quoted-skill'\ndescription: \"A quoted description\"\n---\nBody text.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Equal("quoted-skill", skills[0].Frontmatter.Name);
|
||||
Assert.Equal("A quoted description", skills[0].Frontmatter.Description);
|
||||
Assert.Equal("quoted-skill", skills["quoted-skill"].Frontmatter.Name);
|
||||
Assert.Equal("A quoted description", skills["quoted-skill"].Frontmatter.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_MissingFrontmatter_ExcludesSkillAsync()
|
||||
public void DiscoverAndLoadSkills_MissingFrontmatter_ExcludesSkill()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "bad-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "No frontmatter here.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_MissingNameField_ExcludesSkillAsync()
|
||||
public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "no-name");
|
||||
@@ -94,17 +92,16 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\ndescription: A skill without a name\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_MissingDescriptionField_ExcludesSkillAsync()
|
||||
public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "no-desc");
|
||||
@@ -112,10 +109,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: no-desc\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
@@ -127,7 +123,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
[InlineData("trailing-hyphen-")]
|
||||
[InlineData("has spaces")]
|
||||
[InlineData("consecutive--hyphens")]
|
||||
public async Task GetSkillsAsync_InvalidName_ExcludesSkillAsync(string invalidName)
|
||||
public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName)
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, invalidName);
|
||||
@@ -140,17 +136,16 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
$"---\nname: {invalidName}\ndescription: A skill\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_DuplicateNames_KeepsFirstOnlyAsync()
|
||||
public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly()
|
||||
{
|
||||
// Arrange
|
||||
string dir1 = Path.Combine(this._testRoot, "dupe");
|
||||
@@ -167,37 +162,34 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(nestedDir, "SKILL.md"),
|
||||
"---\nname: dupe\ndescription: Second\n---\nSecond body.");
|
||||
var fileSource = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
var source = new DeduplicatingAgentSkillsSource(fileSource);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert – filesystem enumeration order is not guaranteed, so we only
|
||||
// verify that exactly one of the two duplicates was kept.
|
||||
Assert.Single(skills);
|
||||
string desc = skills[0].Frontmatter.Description;
|
||||
string desc = skills["dupe"].Frontmatter.Description;
|
||||
Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_NameMismatchesDirectory_ExcludesSkillAsync()
|
||||
public void DiscoverAndLoadSkills_NameMismatchesDirectory_ExcludesSkill()
|
||||
{
|
||||
// Arrange — directory name differs from the frontmatter name
|
||||
_ = this.CreateSkillDirectoryWithRawContent(
|
||||
"wrong-dir-name",
|
||||
"---\nname: actual-skill-name\ndescription: A skill\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync()
|
||||
public void DiscoverAndLoadSkills_FilesWithMatchingExtensions_DiscoveredAsResources()
|
||||
{
|
||||
// Arrange — create resource files in the skill directory
|
||||
string skillDir = Path.Combine(this._testRoot, "resource-skill");
|
||||
@@ -208,21 +200,20 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: resource-skill\ndescription: Has resources\n---\nSee docs for details.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Equal(2, skill.Resources!.Count);
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase));
|
||||
var skill = skills["resource-skill"];
|
||||
Assert.Equal(2, skill.ResourceNames.Count);
|
||||
Assert.Contains(skill.ResourceNames, r => r.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.ResourceNames, r => r.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync()
|
||||
public void DiscoverAndLoadSkills_FilesWithNonMatchingExtensions_NotDiscovered()
|
||||
{
|
||||
// Arrange — create a file with an extension not in the default list
|
||||
string skillDir = Path.Combine(this._testRoot, "ext-skill");
|
||||
@@ -232,20 +223,19 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: ext-skill\ndescription: Extension test\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("data.json", skill.Resources![0].Name);
|
||||
var skill = skills["ext-skill"];
|
||||
Assert.Single(skill.ResourceNames);
|
||||
Assert.Equal("data.json", skill.ResourceNames[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_SkillMdFile_NotIncludedAsResourceAsync()
|
||||
public void DiscoverAndLoadSkills_SkillMdFile_NotIncludedAsResource()
|
||||
{
|
||||
// Arrange — the SKILL.md file itself should not be in the resource list
|
||||
string skillDir = Path.Combine(this._testRoot, "selfref-skill");
|
||||
@@ -254,20 +244,19 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: selfref-skill\ndescription: Self ref test\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("notes.md", skill.Resources![0].Name);
|
||||
var skill = skills["selfref-skill"];
|
||||
Assert.Single(skill.ResourceNames);
|
||||
Assert.Equal("notes.md", skill.ResourceNames[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync()
|
||||
public void DiscoverAndLoadSkills_NestedResourceFiles_Discovered()
|
||||
{
|
||||
// Arrange — resource files in nested subdirectories
|
||||
string skillDir = Path.Combine(this._testRoot, "nested-res-skill");
|
||||
@@ -277,22 +266,26 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: nested-res-skill\ndescription: Nested resources\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase));
|
||||
var skill = skills["nested-res-skill"];
|
||||
Assert.Single(skill.ResourceNames);
|
||||
Assert.Contains(skill.ResourceNames, r => r.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static readonly string[] s_customExtensions = new[] { ".custom" };
|
||||
private static readonly string[] s_validExtensions = new[] { ".md", ".json", ".custom" };
|
||||
private static readonly string[] s_mixedValidInvalidExtensions = new[] { ".md", "json" };
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync()
|
||||
public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery()
|
||||
{
|
||||
// Arrange — use a source with custom extensions
|
||||
// Arrange — use a loader with custom extensions
|
||||
var customLoader = new FileAgentSkillLoader(NullLogger.Instance, s_customExtensions);
|
||||
string skillDir = Path.Combine(this._testRoot, "custom-ext-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "data.custom"), "custom data");
|
||||
@@ -300,16 +293,15 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: custom-ext-skill\ndescription: Custom extensions\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_customExtensions });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = customLoader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert — only .custom files should be discovered, not .json
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("data.custom", skill.Resources![0].Name);
|
||||
var skill = skills["custom-ext-skill"];
|
||||
Assert.Single(skill.ResourceNames);
|
||||
Assert.Equal("data.custom", skill.ResourceNames[0]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -319,39 +311,39 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
public void Constructor_InvalidExtension_ThrowsArgumentException(string badExtension)
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = new string[] { badExtension } }));
|
||||
Assert.Throws<ArgumentException>(() => new FileAgentSkillLoader(NullLogger.Instance, new[] { badExtension }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_NullExtensions_UsesDefaultsAsync()
|
||||
public void Constructor_NullExtensions_UsesDefaults()
|
||||
{
|
||||
// Arrange & Act
|
||||
var loader = new FileAgentSkillLoader(NullLogger.Instance, null);
|
||||
string skillDir = this.CreateSkillDirectory("null-ext", "A skill", "Body.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Assert — default extensions include .md
|
||||
var skills = await source.GetSkillsAsync();
|
||||
Assert.Single(skills[0].Resources!);
|
||||
var skills = loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
Assert.Single(skills["null-ext"].ResourceNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidExtensions_DoesNotThrow()
|
||||
{
|
||||
// Arrange & Act & Assert — should not throw
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_validExtensions });
|
||||
Assert.NotNull(source);
|
||||
var loader = new FileAgentSkillLoader(NullLogger.Instance, s_validExtensions);
|
||||
Assert.NotNull(loader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_MixOfValidAndInvalidExtensions_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act & Assert — one bad extension in the list should cause failure
|
||||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_mixedValidInvalidExtensions }));
|
||||
Assert.Throws<ArgumentException>(() => new FileAgentSkillLoader(NullLogger.Instance, s_mixedValidInvalidExtensions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredAsync()
|
||||
public void DiscoverAndLoadSkills_ResourceInSkillRoot_Discovered()
|
||||
{
|
||||
// Arrange — resource file directly in the skill directory (not in a subdirectory)
|
||||
string skillDir = Path.Combine(this._testRoot, "root-resource-skill");
|
||||
@@ -361,62 +353,54 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: root-resource-skill\ndescription: Root resources\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert — both root-level resource files should be discovered
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
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));
|
||||
var skill = skills["root-resource-skill"];
|
||||
Assert.Equal(2, skill.ResourceNames.Count);
|
||||
Assert.Contains(skill.ResourceNames, r => r.Equals("guide.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.ResourceNames, r => r.Equals("config.json", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_NoResourceFiles_ReturnsEmptyResourcesAsync()
|
||||
public void DiscoverAndLoadSkills_NoResourceFiles_ReturnsEmptyResourceNames()
|
||||
{
|
||||
// Arrange — skill with no resource files
|
||||
_ = this.CreateSkillDirectory("no-resources", "A skill", "No resources here.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Resources!);
|
||||
Assert.Empty(skills["no-resources"].ResourceNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_EmptyPaths_ReturnsEmptyListAsync()
|
||||
public void DiscoverAndLoadSkills_EmptyPaths_ReturnsEmptyDictionary()
|
||||
{
|
||||
// Arrange
|
||||
var source = new AgentFileSkillsSource(Enumerable.Empty<string>(), s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(Enumerable.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_NonExistentPath_ReturnsEmptyListAsync()
|
||||
public void DiscoverAndLoadSkills_NonExistentPath_ReturnsEmptyDictionary()
|
||||
{
|
||||
// Arrange
|
||||
var source = new AgentFileSkillsSource(Path.Combine(this._testRoot, "does-not-exist"), s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { Path.Combine(this._testRoot, "does-not-exist") });
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_NestedSkillDirectory_DiscoveredWithinDepthLimitAsync()
|
||||
public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimit()
|
||||
{
|
||||
// Arrange — nested 1 level deep (MaxSearchDepth = 2, so depth 0 = testRoot, depth 1 = level1)
|
||||
string nestedDir = Path.Combine(this._testRoot, "level1", "nested-skill");
|
||||
@@ -424,14 +408,13 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(nestedDir, "SKILL.md"),
|
||||
"---\nname: nested-skill\ndescription: Nested\n---\nNested body.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Equal("nested-skill", skills[0].Frontmatter.Name);
|
||||
Assert.True(skills.ContainsKey("nested-skill"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -442,19 +425,54 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
string refsDir = Path.Combine(skillDir, "refs");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
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].Resources!.First(r => r.Name == "refs/doc.md");
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
var skill = skills["read-skill"];
|
||||
|
||||
// Act
|
||||
var content = await resource.ReadAsync();
|
||||
string content = await this._loader.ReadSkillResourceAsync(skill, "refs/doc.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Document content here.", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_NameExceedsMaxLength_ExcludesSkillAsync()
|
||||
public async Task ReadSkillResourceAsync_UnregisteredResource_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = this.CreateSkillDirectory("simple-skill", "A skill", "No resources.");
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
var skill = skills["simple-skill"];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => this._loader.ReadSkillResourceAsync(skill, "unknown.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResourceAsync_PathTraversal_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange — skill with a legitimate resource, then try to read a traversal path at read time
|
||||
string skillDir = this.CreateSkillDirectory("traverse-read", "A skill", "See docs.");
|
||||
string refsDir = Path.Combine(skillDir, "refs");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "legit");
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
var skill = skills["traverse-read"];
|
||||
|
||||
// Manually construct a skill with the traversal resource in its list to bypass discovery validation
|
||||
var tampered = new FileAgentSkill(
|
||||
skill.Frontmatter,
|
||||
skill.Body,
|
||||
skill.SourcePath,
|
||||
s_traversalResource);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => this._loader.ReadSkillResourceAsync(tampered, "../secret.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill()
|
||||
{
|
||||
// Arrange — name longer than 64 characters
|
||||
string longName = new('a', 65);
|
||||
@@ -463,17 +481,16 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
$"---\nname: {longName}\ndescription: A skill\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_DescriptionExceedsMaxLength_ExcludesSkillAsync()
|
||||
public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill()
|
||||
{
|
||||
// Arrange — description longer than 1024 characters
|
||||
string longDesc = new('x', 1025);
|
||||
@@ -482,18 +499,71 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
$"---\nname: long-desc\ndescription: {longDesc}\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResourceAsync_DotSlashPrefix_MatchesNormalizedResourceAsync()
|
||||
{
|
||||
// Arrange — skill loaded with bare path, caller uses ./ prefix
|
||||
string skillDir = this.CreateSkillDirectory("dotslash-read", "A skill", "See docs.");
|
||||
string refsDir = Path.Combine(skillDir, "refs");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content.");
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
var skill = skills["dotslash-read"];
|
||||
|
||||
// Act — caller passes ./refs/doc.md which should match refs/doc.md
|
||||
string content = await this._loader.ReadSkillResourceAsync(skill, "./refs/doc.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Document content.", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResourceAsync_BackslashSeparator_MatchesNormalizedResourceAsync()
|
||||
{
|
||||
// Arrange — skill loaded with forward-slash path, caller uses backslashes
|
||||
string skillDir = this.CreateSkillDirectory("backslash-read", "A skill", "See docs.");
|
||||
string refsDir = Path.Combine(skillDir, "refs");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Backslash content.");
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
var skill = skills["backslash-read"];
|
||||
|
||||
// Act — caller passes refs\doc.md which should match refs/doc.md
|
||||
string content = await this._loader.ReadSkillResourceAsync(skill, "refs\\doc.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Backslash content.", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResourceAsync_DotSlashWithBackslash_MatchesNormalizedResourceAsync()
|
||||
{
|
||||
// Arrange — skill loaded with forward-slash path, caller uses .\ prefix with backslashes
|
||||
string skillDir = this.CreateSkillDirectory("mixed-sep-read", "A skill", "See docs.");
|
||||
string refsDir = Path.Combine(skillDir, "refs");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Mixed separator content.");
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
var skill = skills["mixed-sep-read"];
|
||||
|
||||
// Act — caller passes .\refs\doc.md which should match refs/doc.md
|
||||
string content = await this._loader.ReadSkillResourceAsync(skill, ".\\refs\\doc.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Mixed separator content.", content);
|
||||
}
|
||||
|
||||
#if NET
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync()
|
||||
public void DiscoverAndLoadSkills_SymlinkInPath_SkipsSymlinkedResources()
|
||||
{
|
||||
// Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory
|
||||
string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill");
|
||||
@@ -518,179 +588,71 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: symlink-escape-skill\ndescription: Symlinked directory escape\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert — skill should still load, but symlinked resources should be excluded
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("legit.md", skill.Resources![0].Name);
|
||||
Assert.True(skills.ContainsKey("symlink-escape-skill"));
|
||||
var skill = skills["symlink-escape-skill"];
|
||||
Assert.Single(skill.ResourceNames);
|
||||
Assert.Equal("legit.md", skill.ResourceNames[0]);
|
||||
}
|
||||
|
||||
private static readonly string[] s_symlinkResource = ["refs/data.md"];
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResourceAsync_SymlinkInPath_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange — build a skill with a symlinked subdirectory
|
||||
string skillDir = Path.Combine(this._testRoot, "symlink-read-skill");
|
||||
string refsDir = Path.Combine(skillDir, "refs");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
|
||||
string outsideDir = Path.Combine(this._testRoot, "outside-read");
|
||||
Directory.CreateDirectory(outsideDir);
|
||||
File.WriteAllText(Path.Combine(outsideDir, "data.md"), "external data");
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateSymbolicLink(refsDir, outsideDir);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Symlink creation requires elevation on some platforms; skip gracefully.
|
||||
return;
|
||||
}
|
||||
|
||||
// Manually construct a skill that bypasses discovery validation
|
||||
var frontmatter = new SkillFrontmatter("symlink-read-skill", "A skill");
|
||||
var skill = new FileAgentSkill(
|
||||
frontmatter: frontmatter,
|
||||
body: "See [doc](refs/data.md).",
|
||||
sourcePath: skillDir,
|
||||
resourceNames: s_symlinkResource);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => this._loader.ReadSkillResourceAsync(skill, "refs/data.md"));
|
||||
}
|
||||
#endif
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_FileWithUtf8Bom_ParsesSuccessfullyAsync()
|
||||
public void DiscoverAndLoadSkills_FileWithUtf8Bom_ParsesSuccessfully()
|
||||
{
|
||||
// Arrange — prepend a UTF-8 BOM (\uFEFF) before the frontmatter
|
||||
_ = this.CreateSkillDirectoryWithRawContent(
|
||||
"bom-skill",
|
||||
"\uFEFF---\nname: bom-skill\ndescription: Skill with BOM\n---\nBody content.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Equal("bom-skill", skills[0].Frontmatter.Name);
|
||||
Assert.Equal("Skill with BOM", skills[0].Frontmatter.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_LicenseField_ParsedCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
_ = this.CreateSkillDirectoryWithRawContent(
|
||||
"licensed-skill",
|
||||
"---\nname: licensed-skill\ndescription: A skill with license\nlicense: MIT\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Equal("MIT", skills[0].Frontmatter.License);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_CompatibilityField_ParsedCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
_ = this.CreateSkillDirectoryWithRawContent(
|
||||
"compat-skill",
|
||||
"---\nname: compat-skill\ndescription: A skill with compatibility\ncompatibility: Requires Node.js 18+\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Equal("Requires Node.js 18+", skills[0].Frontmatter.Compatibility);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_AllowedToolsField_ParsedCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
_ = this.CreateSkillDirectoryWithRawContent(
|
||||
"tools-skill",
|
||||
"---\nname: tools-skill\ndescription: A skill with tools\nallowed-tools: grep glob bash\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Equal("grep glob bash", skills[0].Frontmatter.AllowedTools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_MetadataField_ParsedCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
_ = this.CreateSkillDirectoryWithRawContent(
|
||||
"meta-skill",
|
||||
"---\nname: meta-skill\ndescription: A skill with metadata\nmetadata:\n author: test-user\n version: 1.0\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.NotNull(skills[0].Frontmatter.Metadata);
|
||||
Assert.Equal("test-user", skills[0].Frontmatter.Metadata!["author"]?.ToString());
|
||||
Assert.Equal("1.0", skills[0].Frontmatter.Metadata!["version"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_MetadataWithQuotedValues_ParsedCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
_ = this.CreateSkillDirectoryWithRawContent(
|
||||
"quoted-meta",
|
||||
"---\nname: quoted-meta\ndescription: Metadata with quotes\nmetadata:\n key1: 'single quoted'\n key2: \"double quoted\"\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.NotNull(skills[0].Frontmatter.Metadata);
|
||||
Assert.Equal("single quoted", skills[0].Frontmatter.Metadata!["key1"]?.ToString());
|
||||
Assert.Equal("double quoted", skills[0].Frontmatter.Metadata!["key2"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_AllOptionalFields_ParsedCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
string content = string.Join(
|
||||
"\n",
|
||||
"---",
|
||||
"name: full-skill",
|
||||
"description: A skill with all fields",
|
||||
"license: Apache-2.0",
|
||||
"compatibility: Requires Python 3.10+",
|
||||
"allowed-tools: grep glob view",
|
||||
"metadata:",
|
||||
" org: contoso",
|
||||
" tier: premium",
|
||||
"---",
|
||||
"Full body content.");
|
||||
_ = this.CreateSkillDirectoryWithRawContent("full-skill", content);
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var fm = skills[0].Frontmatter;
|
||||
Assert.Equal("full-skill", fm.Name);
|
||||
Assert.Equal("A skill with all fields", fm.Description);
|
||||
Assert.Equal("Apache-2.0", fm.License);
|
||||
Assert.Equal("Requires Python 3.10+", fm.Compatibility);
|
||||
Assert.Equal("grep glob view", fm.AllowedTools);
|
||||
Assert.NotNull(fm.Metadata);
|
||||
Assert.Equal("contoso", fm.Metadata!["org"]?.ToString());
|
||||
Assert.Equal("premium", fm.Metadata!["tier"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_NoOptionalFields_DefaultsToNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
_ = this.CreateSkillDirectory("basic-skill", "A basic skill", "Body.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var fm = skills[0].Frontmatter;
|
||||
Assert.Null(fm.License);
|
||||
Assert.Null(fm.Compatibility);
|
||||
Assert.Null(fm.AllowedTools);
|
||||
Assert.Null(fm.Metadata);
|
||||
Assert.True(skills.ContainsKey("bom-skill"));
|
||||
Assert.Equal("Skill with BOM", skills["bom-skill"].Frontmatter.Description);
|
||||
Assert.Equal("Body content.", skills["bom-skill"].Body);
|
||||
}
|
||||
|
||||
private string CreateSkillDirectory(string name, string description, string body)
|
||||
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="FileAgentSkillsProvider"/> class.
|
||||
/// </summary>
|
||||
public sealed class FileAgentSkillsProviderTests : IDisposable
|
||||
{
|
||||
private readonly string _testRoot;
|
||||
private readonly TestAIAgent _agent = new();
|
||||
|
||||
public FileAgentSkillsProviderTests()
|
||||
{
|
||||
this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(this._testRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(this._testRoot))
|
||||
{
|
||||
Directory.Delete(this._testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot);
|
||||
var inputContext = new AIContext { Instructions = "Original instructions" };
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Original instructions", result.Instructions);
|
||||
Assert.Null(result.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body.");
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot);
|
||||
var inputContext = new AIContext { Instructions = "Base instructions" };
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("Base instructions", result.Instructions);
|
||||
Assert.Contains("provider-skill", result.Instructions);
|
||||
Assert.Contains("Provider skill test", result.Instructions);
|
||||
|
||||
// Should have load_skill and read_skill_resource tools
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("null-instr-skill", "Null instruction test", "Body.");
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot);
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("null-instr-skill", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body.");
|
||||
var options = new FileAgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "Custom template: {0}"
|
||||
};
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot, options);
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.StartsWith("Custom template:", result.Instructions);
|
||||
Assert.Contains("custom-prompt-skill", result.Instructions);
|
||||
Assert.Contains("Custom prompt", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidPromptTemplate_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange — template with unescaped braces and no valid {0} placeholder
|
||||
var options = new FileAgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "Bad template with {unescaped} braces"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<ArgumentException>(() => new FileAgentSkillsProvider(this._testRoot, options));
|
||||
Assert.Contains("SkillsInstructionPrompt", ex.Message);
|
||||
Assert.Equal("options", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PromptWithoutPlaceholder_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange -- valid format string but missing the required placeholder
|
||||
var options = new FileAgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "No placeholder here"
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new FileAgentSkillsProvider(this._testRoot, options));
|
||||
Assert.Contains("{0}", ex.Message);
|
||||
Assert.Equal("options", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_PromptWithPlaceholder_AppliesCustomTemplateAsync()
|
||||
{
|
||||
// Arrange — valid custom template with {0} placeholder
|
||||
this.CreateSkill("custom-tpl-skill", "Custom template skill", "Body.");
|
||||
var options = new FileAgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "== Skills ==\n{0}\n== End =="
|
||||
};
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot, options);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — the custom template wraps the skill list
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.StartsWith("== Skills ==", result.Instructions);
|
||||
Assert.Contains("custom-tpl-skill", result.Instructions);
|
||||
Assert.Contains("== End ==", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync()
|
||||
{
|
||||
// Arrange — description with XML-sensitive characters
|
||||
string skillDir = Path.Combine(this._testRoot, "xml-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: xml-skill\ndescription: Uses <tags> & \"quotes\"\n---\nBody.");
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot);
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("<tags>", result.Instructions);
|
||||
Assert.Contains("&", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync()
|
||||
{
|
||||
// Arrange
|
||||
string dir1 = Path.Combine(this._testRoot, "dir1");
|
||||
string dir2 = Path.Combine(this._testRoot, "dir2");
|
||||
CreateSkillIn(dir1, "skill-a", "Skill A", "Body A.");
|
||||
CreateSkillIn(dir2, "skill-b", "Skill B", "Body B.");
|
||||
|
||||
// Act
|
||||
var provider = new FileAgentSkillsProvider(new[] { dir1, dir2 });
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Assert
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("skill-a", result.Instructions);
|
||||
Assert.Contains("skill-b", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("tools-skill", "Tools test", "Body.");
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot);
|
||||
|
||||
var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool.");
|
||||
var inputContext = new AIContext { Tools = new[] { existingTool } };
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — existing tool should be preserved alongside the new skill tools
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("existing_tool", toolNames);
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync()
|
||||
{
|
||||
// Arrange — create skills in reverse alphabetical order
|
||||
this.CreateSkill("zulu-skill", "Zulu skill", "Body Z.");
|
||||
this.CreateSkill("alpha-skill", "Alpha skill", "Body A.");
|
||||
this.CreateSkill("mike-skill", "Mike skill", "Body M.");
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot);
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — skills should appear in alphabetical order in the prompt
|
||||
Assert.NotNull(result.Instructions);
|
||||
int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal);
|
||||
int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal);
|
||||
int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal);
|
||||
Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill");
|
||||
Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill");
|
||||
}
|
||||
|
||||
private void CreateSkill(string name, string description, string body)
|
||||
{
|
||||
CreateSkillIn(this._testRoot, name, description, body);
|
||||
}
|
||||
|
||||
private static void CreateSkillIn(string root, string name, string description, string body)
|
||||
{
|
||||
string skillDir = Path.Combine(root, name);
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
$"---\nname: {name}\ndescription: {description}\n---\n{body}");
|
||||
}
|
||||
}
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="FilteringAgentSkillsSource"/>.
|
||||
/// </summary>
|
||||
public sealed class FilteringAgentSkillsSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inner = new TestAgentSkillsSource(
|
||||
new TestAgentSkill("skill-a", "A", "Instructions A."),
|
||||
new TestAgentSkill("skill-b", "B", "Instructions B."));
|
||||
var source = new FilteringAgentSkillsSource(inner, _ => true);
|
||||
|
||||
// Act
|
||||
var result = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inner = new TestAgentSkillsSource(
|
||||
new TestAgentSkill("skill-a", "A", "Instructions A."),
|
||||
new TestAgentSkill("skill-b", "B", "Instructions B."));
|
||||
var source = new FilteringAgentSkillsSource(inner, _ => false);
|
||||
|
||||
// Act
|
||||
var result = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inner = new TestAgentSkillsSource(
|
||||
new TestAgentSkill("keep-me", "Keep", "Instructions."),
|
||||
new TestAgentSkill("drop-me", "Drop", "Instructions."),
|
||||
new TestAgentSkill("keep-also", "KeepAlso", "Instructions."));
|
||||
var source = new FilteringAgentSkillsSource(
|
||||
inner,
|
||||
skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Act
|
||||
var result = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, s => Assert.StartsWith("keep", s.Frontmatter.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inner = new TestAgentSkillsSource(Array.Empty<AgentSkill>());
|
||||
var source = new FilteringAgentSkillsSource(inner, _ => true);
|
||||
|
||||
// Act
|
||||
var result = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullPredicate_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var inner = new TestAgentSkillsSource(Array.Empty<AgentSkill>());
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new FilteringAgentSkillsSource(inner, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullInnerSource_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new FilteringAgentSkillsSource(null!, _ => true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_PreservesOrderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inner = new TestAgentSkillsSource(
|
||||
new TestAgentSkill("alpha", "Alpha", "Instructions."),
|
||||
new TestAgentSkill("beta", "Beta", "Instructions."),
|
||||
new TestAgentSkill("gamma", "Gamma", "Instructions."),
|
||||
new TestAgentSkill("delta", "Delta", "Instructions."));
|
||||
|
||||
// Keep only alpha and gamma
|
||||
var source = new FilteringAgentSkillsSource(
|
||||
inner,
|
||||
skill => skill.Frontmatter.Name is "alpha" or "gamma");
|
||||
|
||||
// Act
|
||||
var result = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("alpha", result[0].Frontmatter.Name);
|
||||
Assert.Equal("gamma", result[1].Frontmatter.Name);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// A simple in-memory <see cref="AgentSkill"/> implementation for unit tests.
|
||||
/// </summary>
|
||||
internal sealed class TestAgentSkill : AgentSkill
|
||||
{
|
||||
private readonly AgentSkillFrontmatter _frontmatter;
|
||||
private readonly string _content;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TestAgentSkill"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">Kebab-case skill name.</param>
|
||||
/// <param name="description">Skill description.</param>
|
||||
/// <param name="content">Full skill content (body text).</param>
|
||||
public TestAgentSkill(string name, string description, string content)
|
||||
{
|
||||
this._frontmatter = new AgentSkillFrontmatter(name, description);
|
||||
this._content = content;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AgentSkillFrontmatter Frontmatter => this._frontmatter;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Content => this._content;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources => null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple in-memory <see cref="AgentSkillsSource"/> implementation for unit tests.
|
||||
/// </summary>
|
||||
internal sealed class TestAgentSkillsSource : AgentSkillsSource
|
||||
{
|
||||
private readonly IList<AgentSkill> _skills;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TestAgentSkillsSource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="skills">The skills to return.</param>
|
||||
public TestAgentSkillsSource(IList<AgentSkill> skills)
|
||||
{
|
||||
this._skills = skills;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TestAgentSkillsSource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="skills">The skills to return.</param>
|
||||
public TestAgentSkillsSource(params AgentSkill[] skills)
|
||||
{
|
||||
this._skills = skills;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(this._skills);
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared test helper for <see cref="ChatClientAgent"/> integration tests that verify
|
||||
/// end-to-end behavior with <see cref="ChatHistoryPersistingChatClient"/> and
|
||||
/// <see cref="FunctionInvokingChatClient"/>.
|
||||
/// </summary>
|
||||
internal static class ChatClientAgentTestHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents an expected service call during a test: an optional input verifier and the response to return.
|
||||
/// </summary>
|
||||
/// <param name="Response">The <see cref="ChatResponse"/> the mock service should return for this call.</param>
|
||||
/// <param name="VerifyInput">Optional callback to verify the messages sent to the service on this call.</param>
|
||||
#pragma warning disable CA1812 // Instantiated by test classes
|
||||
public sealed record ServiceCallExpectation(
|
||||
ChatResponse Response,
|
||||
Action<List<ChatMessage>>? VerifyInput = null);
|
||||
#pragma warning restore CA1812
|
||||
|
||||
/// <summary>
|
||||
/// Describes the expected shape of a message in the persisted history for structural comparison.
|
||||
/// </summary>
|
||||
/// <param name="Role">The expected role of the message.</param>
|
||||
/// <param name="TextContains">Optional substring that the message text should contain.</param>
|
||||
/// <param name="ContentTypes">Optional array of expected <see cref="AIContent"/> types in the message.</param>
|
||||
#pragma warning disable CA1812 // Instantiated by test classes
|
||||
public sealed record ExpectedMessage(
|
||||
ChatRole Role,
|
||||
string? TextContains = null,
|
||||
Type[]? ContentTypes = null);
|
||||
#pragma warning restore CA1812
|
||||
|
||||
/// <summary>
|
||||
/// The result of a RunAsync invocation, containing the response, session, agent,
|
||||
/// captured service inputs, and call counts for detailed verification.
|
||||
/// </summary>
|
||||
public sealed record RunResult(
|
||||
AgentResponse Response,
|
||||
ChatClientAgentSession Session,
|
||||
ChatClientAgent Agent,
|
||||
Mock<IChatClient> MockService,
|
||||
int TotalServiceCalls,
|
||||
List<List<ChatMessage>> CapturedServiceInputs);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock <see cref="IChatClient"/> that returns responses in sequence,
|
||||
/// captures input messages, and optionally verifies inputs.
|
||||
/// </summary>
|
||||
/// <param name="expectations">The ordered sequence of expected service calls.</param>
|
||||
/// <param name="callIndex">Shared call index counter (allows reuse across multiple RunAsync calls).</param>
|
||||
/// <param name="capturedInputs">List that captured service inputs are appended to.</param>
|
||||
/// <returns>The configured mock.</returns>
|
||||
public static Mock<IChatClient> CreateSequentialMock(
|
||||
List<ServiceCallExpectation> expectations,
|
||||
Ref<int> callIndex,
|
||||
List<List<ChatMessage>> capturedInputs)
|
||||
{
|
||||
Mock<IChatClient> mock = new();
|
||||
mock.Setup(s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
|
||||
{
|
||||
int idx = callIndex.Value++;
|
||||
var messageList = msgs.ToList();
|
||||
capturedInputs.Add(messageList);
|
||||
|
||||
if (idx >= expectations.Count)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Mock received unexpected service call #{idx + 1}. Only {expectations.Count} call(s) were expected.");
|
||||
}
|
||||
|
||||
var expectation = expectations[idx];
|
||||
expectation.VerifyInput?.Invoke(messageList);
|
||||
return Task.FromResult(expectation.Response);
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with the given inputs, automatically verifying service call count
|
||||
/// and optional expected history, and returns the result for further assertions.
|
||||
/// </summary>
|
||||
/// <param name="inputMessages">Messages to pass to RunAsync.</param>
|
||||
/// <param name="serviceCallExpectations">Ordered service call expectations for the mock.</param>
|
||||
/// <param name="agentOptions">Options for configuring the agent. If null, defaults are used.</param>
|
||||
/// <param name="existingSession">An existing session to reuse (for multi-turn tests). If null, a new session is created.</param>
|
||||
/// <param name="existingAgent">An existing agent to reuse (for multi-turn tests). If null, a new agent is created.</param>
|
||||
/// <param name="existingMock">An existing mock to reuse (for multi-turn tests). If null, a new mock is created.</param>
|
||||
/// <param name="callIndex">Shared call index for multi-turn tests. If null, a new counter is created.</param>
|
||||
/// <param name="capturedInputs">Shared captured inputs list for multi-turn tests. If null, a new list is created.</param>
|
||||
/// <param name="initialChatHistory">Optional initial chat history to pre-populate in <see cref="InMemoryChatHistoryProvider"/>.</param>
|
||||
/// <param name="runOptions">Optional <see cref="AgentRunOptions"/> to pass to RunAsync.</param>
|
||||
/// <param name="expectedServiceCallCount">
|
||||
/// If provided, asserts the total number of service calls matches.
|
||||
/// For multi-turn tests, pass null and verify after the final turn.
|
||||
/// </param>
|
||||
/// <param name="expectedHistory">
|
||||
/// If provided, asserts that the persisted history matches these expected messages.
|
||||
/// For multi-turn tests, pass null and verify after the final turn.
|
||||
/// </param>
|
||||
/// <returns>A <see cref="RunResult"/> containing the response, session, agent, mock, and captured inputs.</returns>
|
||||
public static async Task<RunResult> RunAsync(
|
||||
List<ChatMessage> inputMessages,
|
||||
List<ServiceCallExpectation> serviceCallExpectations,
|
||||
ChatClientAgentOptions? agentOptions = null,
|
||||
ChatClientAgentSession? existingSession = null,
|
||||
ChatClientAgent? existingAgent = null,
|
||||
Mock<IChatClient>? existingMock = null,
|
||||
Ref<int>? callIndex = null,
|
||||
List<List<ChatMessage>>? capturedInputs = null,
|
||||
List<ChatMessage>? initialChatHistory = null,
|
||||
AgentRunOptions? runOptions = null,
|
||||
int? expectedServiceCallCount = null,
|
||||
List<ExpectedMessage>? expectedHistory = null)
|
||||
{
|
||||
callIndex ??= new Ref<int>(0);
|
||||
capturedInputs ??= [];
|
||||
var mock = existingMock ?? CreateSequentialMock(serviceCallExpectations, callIndex, capturedInputs);
|
||||
agentOptions ??= new ChatClientAgentOptions();
|
||||
|
||||
var agent = existingAgent ?? new ChatClientAgent(
|
||||
mock.Object,
|
||||
options: agentOptions,
|
||||
services: new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
var session = existingSession ?? (await agent.CreateSessionAsync() as ChatClientAgentSession)!;
|
||||
|
||||
// Pre-populate initial chat history if provided.
|
||||
if (initialChatHistory is not null)
|
||||
{
|
||||
(agent.ChatHistoryProvider as InMemoryChatHistoryProvider)
|
||||
?.SetMessages(session, new List<ChatMessage>(initialChatHistory));
|
||||
}
|
||||
|
||||
var response = await agent.RunAsync(inputMessages, session, runOptions);
|
||||
|
||||
var result = new RunResult(response, session, agent, mock, callIndex.Value, capturedInputs);
|
||||
|
||||
// Auto-verify service call count if specified.
|
||||
if (expectedServiceCallCount.HasValue)
|
||||
{
|
||||
Assert.Equal(expectedServiceCallCount.Value, callIndex.Value);
|
||||
}
|
||||
|
||||
// Auto-verify persisted history if specified.
|
||||
if (expectedHistory is not null)
|
||||
{
|
||||
var history = GetPersistedHistory(agent, session);
|
||||
AssertMessagesMatch(history, expectedHistory);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the actual message list matches the expected message patterns structurally.
|
||||
/// Checks message count, roles, optional text content, and optional content types.
|
||||
/// </summary>
|
||||
/// <param name="actual">The actual messages to verify.</param>
|
||||
/// <param name="expected">The expected message patterns.</param>
|
||||
public static void AssertMessagesMatch(List<ChatMessage> actual, List<ExpectedMessage> expected)
|
||||
{
|
||||
Assert.True(
|
||||
expected.Count == actual.Count,
|
||||
$"Expected {expected.Count} message(s) but found {actual.Count}.\nActual messages:\n{FormatMessages(actual)}");
|
||||
|
||||
for (int i = 0; i < expected.Count; i++)
|
||||
{
|
||||
var exp = expected[i];
|
||||
var act = actual[i];
|
||||
|
||||
Assert.True(
|
||||
exp.Role == act.Role,
|
||||
$"Message [{i}]: expected role {exp.Role} but found {act.Role}.\nActual messages:\n{FormatMessages(actual)}");
|
||||
|
||||
if (exp.TextContains is not null)
|
||||
{
|
||||
Assert.Contains(exp.TextContains, act.Text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (exp.ContentTypes is not null)
|
||||
{
|
||||
AssertContentTypes(act.Contents, exp.ContentTypes, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the persisted chat history from the agent's <see cref="InMemoryChatHistoryProvider"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent whose history provider to query.</param>
|
||||
/// <param name="session">The session to get history for.</param>
|
||||
/// <returns>The list of persisted messages, or an empty list if no provider is available.</returns>
|
||||
public static List<ChatMessage> GetPersistedHistory(ChatClientAgent agent, AgentSession session)
|
||||
{
|
||||
var provider = agent.ChatHistoryProvider as InMemoryChatHistoryProvider;
|
||||
return provider?.GetMessages(session) ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats the contents of a message list as a diagnostic string for test failure messages.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to format.</param>
|
||||
/// <returns>A human-readable representation of the messages.</returns>
|
||||
public static string FormatMessages(IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int index = 0;
|
||||
foreach (var msg in messages)
|
||||
{
|
||||
sb.AppendLine($" [{index}] Role={msg.Role}, Text=\"{msg.Text}\", Contents=[{string.Join(", ", msg.Contents.Select(c => c.GetType().Name))}]");
|
||||
index++;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple mutable reference wrapper for value types, allowing shared state across callbacks.
|
||||
/// </summary>
|
||||
public sealed class Ref<T>(T value) where T : struct
|
||||
{
|
||||
public T Value { get; set; } = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that a message's content collection contains the expected content types.
|
||||
/// </summary>
|
||||
private static void AssertContentTypes(IList<AIContent> contents, Type[] expectedTypes, int messageIndex)
|
||||
{
|
||||
Assert.True(
|
||||
contents.Count >= expectedTypes.Length,
|
||||
$"Message [{messageIndex}]: expected at least {expectedTypes.Length} content(s) but found {contents.Count}. " +
|
||||
$"Actual types: [{string.Join(", ", contents.Select(c => c.GetType().Name))}]");
|
||||
|
||||
foreach (var expectedType in expectedTypes)
|
||||
{
|
||||
Assert.True(
|
||||
contents.Any(c => expectedType.IsInstanceOfType(c)),
|
||||
$"Message [{messageIndex}]: expected content of type {expectedType.Name} but found [{string.Join(", ", contents.Select(c => c.GetType().Name))}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,23 +379,18 @@ public partial class ChatClientAgentTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync passes ChatOptions with null ConversationId when using regular AgentRunOptions.
|
||||
/// When per-service-call persistence is active (default), the sentinel conversation ID is set on ChatOptions
|
||||
/// and then stripped by ChatHistoryPersistingChatClient before reaching the inner client.
|
||||
/// Verify that RunAsync passes null ChatOptions when using regular AgentRunOptions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncPassesChatOptionsWithNullConversationIdWhenUsingRegularAgentRunOptionsAsync()
|
||||
public async Task RunAsyncPassesNullChatOptionsWhenUsingRegularAgentRunOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatOptions? capturedOptions = null;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
null,
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object);
|
||||
var runOptions = new AgentRunOptions();
|
||||
@@ -403,9 +398,13 @@ public partial class ChatClientAgentTests
|
||||
// Act
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], options: runOptions);
|
||||
|
||||
// Assert — the inner client receives ChatOptions with null ConversationId (sentinel was stripped)
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Null(capturedOptions!.ConversationId);
|
||||
// Assert
|
||||
mockService.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
null,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
-306
@@ -1,306 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains unit tests that verify the end-to-end approval flow behavior of the
|
||||
/// <see cref="ChatClientAgent"/> class with <see cref="ChatHistoryPersistingChatClient"/>,
|
||||
/// ensuring that chat history is correctly persisted across multi-turn approval interactions.
|
||||
/// </summary>
|
||||
public class ChatClientAgent_ApprovalsTests
|
||||
{
|
||||
#region Per-Service-Call Persistence Approval Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with per-service-call persistence and an approval-required tool,
|
||||
/// a two-turn approval flow persists the correct final history:
|
||||
/// Turn 1: user asks → model returns FCC → FICC converts to ToolApprovalRequestContent → returned to caller.
|
||||
/// Turn 2: caller sends ToolApprovalResponseContent → FICC processes approval, invokes function, calls model again.
|
||||
/// Final history: [user, assistant(FCC), tool(FRC), assistant(final)].
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ApprovalRequired_PerServiceCallPersistence_PersistsCorrectHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(tool);
|
||||
|
||||
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
|
||||
var capturedInputs = new List<List<ChatMessage>>();
|
||||
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
|
||||
{
|
||||
// Turn 1: model returns a function call (FICC will convert to approval request)
|
||||
new(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
|
||||
// Turn 2: after approval, FICC invokes the function and calls the model again
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
|
||||
};
|
||||
|
||||
// Act — Turn 1: initial request
|
||||
var result1 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "What's the weather?")],
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [approvalTool] },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
},
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs);
|
||||
|
||||
// Verify Turn 1 returns exactly one approval request
|
||||
var approvalRequests = result1.Response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.ToList();
|
||||
Assert.Single(approvalRequests);
|
||||
Assert.Equal(1, result1.TotalServiceCalls);
|
||||
|
||||
// Verify service received user message on first call
|
||||
Assert.Single(capturedInputs);
|
||||
Assert.Contains(capturedInputs[0], m => m.Role == ChatRole.User && m.Text == "What's the weather?");
|
||||
|
||||
// Act — Turn 2: send approval response
|
||||
var approvalResponseMessages = approvalRequests.ConvertAll(req =>
|
||||
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)]));
|
||||
|
||||
await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: approvalResponseMessages,
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
existingSession: result1.Session,
|
||||
existingAgent: result1.Agent,
|
||||
existingMock: result1.MockService,
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs,
|
||||
expectedServiceCallCount: 2,
|
||||
expectedHistory:
|
||||
[
|
||||
new(ChatRole.User, TextContains: "What's the weather?"),
|
||||
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
|
||||
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
|
||||
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
|
||||
]);
|
||||
|
||||
// Verify second service call received the full conversation (user + FCC + FRC)
|
||||
Assert.Equal(2, capturedInputs.Count);
|
||||
Assert.Contains(capturedInputs[1], m => m.Contents.OfType<FunctionCallContent>().Any());
|
||||
Assert.Contains(capturedInputs[1], m => m.Contents.OfType<FunctionResultContent>().Any());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region End-of-Run Persistence Approval Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with end-of-run persistence and an approval-required tool,
|
||||
/// a two-turn approval flow persists the correct final history.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ApprovalRequired_EndOfRunPersistence_PersistsCorrectHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(tool);
|
||||
|
||||
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
|
||||
var capturedInputs = new List<List<ChatMessage>>();
|
||||
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
|
||||
{
|
||||
new(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
|
||||
};
|
||||
|
||||
// Act — Turn 1
|
||||
var result1 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "What's the weather?")],
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [approvalTool] },
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
},
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs);
|
||||
|
||||
var approvalRequests = result1.Response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.ToList();
|
||||
Assert.Single(approvalRequests);
|
||||
|
||||
// Act — Turn 2
|
||||
var approvalResponseMessages = approvalRequests.ConvertAll(req =>
|
||||
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)]));
|
||||
|
||||
var result2 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: approvalResponseMessages,
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
existingSession: result1.Session,
|
||||
existingAgent: result1.Agent,
|
||||
existingMock: result1.MockService,
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs,
|
||||
expectedServiceCallCount: 2,
|
||||
expectedHistory:
|
||||
[
|
||||
// End-of-run persistence retains the approval request from Turn 1
|
||||
new(ChatRole.User, TextContains: "What's the weather?"),
|
||||
new(ChatRole.Assistant, ContentTypes: [typeof(ToolApprovalRequestContent)]),
|
||||
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
|
||||
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
|
||||
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
|
||||
]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Service-Stored History Approval Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with service-stored history (ConversationId returned) and an approval-required tool,
|
||||
/// the two-turn approval flow completes without errors and the session gets the ConversationId.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ApprovalRequired_ServiceStoredHistory_CompletesWithoutErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ConversationId = "thread-456";
|
||||
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(tool);
|
||||
|
||||
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
|
||||
var capturedInputs = new List<List<ChatMessage>>();
|
||||
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
|
||||
{
|
||||
new(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])
|
||||
{
|
||||
ConversationId = ConversationId,
|
||||
}),
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])
|
||||
{
|
||||
ConversationId = ConversationId,
|
||||
}),
|
||||
};
|
||||
|
||||
// Act — Turn 1
|
||||
var result1 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "What's the weather?")],
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [approvalTool] },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
},
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs);
|
||||
|
||||
var approvalRequests = result1.Response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.ToList();
|
||||
Assert.Single(approvalRequests);
|
||||
Assert.Equal(ConversationId, result1.Session.ConversationId);
|
||||
|
||||
// Act — Turn 2
|
||||
var approvalResponseMessages = approvalRequests.ConvertAll(req =>
|
||||
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)]));
|
||||
|
||||
var result2 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: approvalResponseMessages,
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
existingSession: result1.Session,
|
||||
existingAgent: result1.Agent,
|
||||
existingMock: result1.MockService,
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs,
|
||||
expectedServiceCallCount: 2);
|
||||
|
||||
// Assert — session should retain the ConversationId, response should be correct
|
||||
Assert.Equal(ConversationId, result2.Session.ConversationId);
|
||||
Assert.Contains(result2.Response.Messages, m => m.Text == "The weather in Amsterdam is sunny and 22°C.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Approval Rejected Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when an approval is rejected, the rejection result is persisted in the history
|
||||
/// and the model receives the rejection information.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ApprovalRejected_PersistsRejectionInHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(tool);
|
||||
|
||||
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
|
||||
var capturedInputs = new List<List<ChatMessage>>();
|
||||
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
|
||||
{
|
||||
// Turn 1: model requests function call
|
||||
new(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
|
||||
// Turn 2: after rejection, model gets the rejection info and responds accordingly
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "I'm sorry, I cannot check the weather without your approval.")])),
|
||||
};
|
||||
|
||||
// Act — Turn 1
|
||||
var result1 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "What's the weather?")],
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [approvalTool] },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
},
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs);
|
||||
|
||||
var approvalRequests = result1.Response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.ToList();
|
||||
Assert.Single(approvalRequests);
|
||||
|
||||
// Act — Turn 2: reject the approval
|
||||
var rejectionMessages = approvalRequests.ConvertAll(req =>
|
||||
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: false, reason: "User declined")]));
|
||||
|
||||
var result2 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: rejectionMessages,
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
existingSession: result1.Session,
|
||||
existingAgent: result1.Agent,
|
||||
existingMock: result1.MockService,
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs,
|
||||
expectedServiceCallCount: 2);
|
||||
|
||||
// Assert — history should contain the rejection result (FRC with rejection)
|
||||
var history = ChatClientAgentTestHelper.GetPersistedHistory(result2.Agent, result2.Session);
|
||||
Assert.True(
|
||||
history.Count >= 3,
|
||||
$"Expected at least 3 messages in history, got {history.Count}.\n{ChatClientAgentTestHelper.FormatMessages(history)}");
|
||||
Assert.Contains(history, m => m.Role == ChatRole.User && m.Text == "What's the weather?");
|
||||
Assert.Contains(history, m => m.Contents.OfType<FunctionResultContent>().Any(
|
||||
frc => frc.Result?.ToString()?.Contains("rejected") == true));
|
||||
Assert.Contains(history, m => m.Role == ChatRole.Assistant &&
|
||||
m.Text == "I'm sorry, I cannot check the weather without your approval.");
|
||||
|
||||
// Verify the second service call received the rejection FRC
|
||||
Assert.Equal(2, capturedInputs.Count);
|
||||
Assert.Contains(capturedInputs[1], m => m.Contents.OfType<FunctionResultContent>().Any(
|
||||
frc => frc.Result?.ToString()?.Contains("rejected") == true));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
-154
@@ -500,158 +500,4 @@ public class ChatClientAgent_ChatHistoryManagementTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region End-to-End Chat History Persistence Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with per-service-call persistence (default), a simple request/response
|
||||
/// results in the correct chat history being persisted: [user, assistant].
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PerServiceCallPersistence_SimpleResponse_PersistsCorrectHistoryAsync()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "Hello")],
|
||||
serviceCallExpectations:
|
||||
[
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])),
|
||||
],
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "Be helpful" },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
},
|
||||
expectedServiceCallCount: 1,
|
||||
expectedHistory:
|
||||
[
|
||||
new(ChatRole.User, TextContains: "Hello"),
|
||||
new(ChatRole.Assistant, TextContains: "Hi there"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with per-service-call persistence and a function calling loop,
|
||||
/// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)].
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PerServiceCallPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
|
||||
|
||||
// Act & Assert
|
||||
await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "What's the weather?")],
|
||||
serviceCallExpectations:
|
||||
[
|
||||
// First call: model requests a function call
|
||||
new(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
|
||||
// Second call: model returns final response after seeing function result
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
|
||||
],
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [tool] },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
},
|
||||
expectedServiceCallCount: 2,
|
||||
expectedHistory:
|
||||
[
|
||||
new(ChatRole.User, TextContains: "What's the weather?"),
|
||||
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
|
||||
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
|
||||
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with end-of-run persistence, a simple request/response
|
||||
/// results in the correct chat history being persisted: [user, assistant].
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_EndOfRunPersistence_SimpleResponse_PersistsCorrectHistoryAsync()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "Hello")],
|
||||
serviceCallExpectations:
|
||||
[
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])),
|
||||
],
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "Be helpful" },
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
},
|
||||
expectedServiceCallCount: 1,
|
||||
expectedHistory:
|
||||
[
|
||||
new(ChatRole.User, TextContains: "Hello"),
|
||||
new(ChatRole.Assistant, TextContains: "Hi there"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with end-of-run persistence and a function calling loop,
|
||||
/// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)].
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_EndOfRunPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
|
||||
|
||||
// Act & Assert
|
||||
await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "What's the weather?")],
|
||||
serviceCallExpectations:
|
||||
[
|
||||
new(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
|
||||
],
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [tool] },
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
},
|
||||
expectedServiceCallCount: 2,
|
||||
expectedHistory:
|
||||
[
|
||||
new(ChatRole.User, TextContains: "What's the weather?"),
|
||||
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
|
||||
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
|
||||
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the service returns a ConversationId (service-stored history),
|
||||
/// the session gets the ConversationId and no errors occur during the run.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ServiceStoredHistory_SetsConversationIdAndCompletesWithoutErrorAsync()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "Hello")],
|
||||
serviceCallExpectations:
|
||||
[
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "Hi there")]) { ConversationId = "thread-123" }),
|
||||
],
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "Be helpful" },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
},
|
||||
expectedServiceCallCount: 1);
|
||||
|
||||
// Assert — session should have the conversation id from the service
|
||||
Assert.Equal("thread-123", result.Session.ConversationId);
|
||||
Assert.Contains(result.Response.Messages, m => m.Text == "Hi there");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
+5
-8
@@ -176,12 +176,10 @@ public class ChatClientAgent_ChatOptionsMergingTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ChatOptions merging returns a non-null ChatOptions instance with null ConversationId
|
||||
/// when both agent and request have no ChatOptions. The sentinel conversation ID is set for
|
||||
/// per-service-call persistence and stripped before reaching the inner client.
|
||||
/// Verify that ChatOptions merging returns null when both agent and request have no ChatOptions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingReturnsChatOptionsWithNullConversationIdWhenBothAgentAndRequestHaveNoneAsync()
|
||||
public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
@@ -191,7 +189,7 @@ public class ChatClientAgent_ChatOptionsMergingTests
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, opts, ct) =>
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
@@ -201,9 +199,8 @@ public class ChatClientAgent_ChatOptionsMergingTests
|
||||
// Act
|
||||
await agent.RunAsync(messages);
|
||||
|
||||
// Assert — ChatOptions is non-null because the sentinel was set, but ConversationId is null (stripped)
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.Null(capturedChatOptions!.ConversationId);
|
||||
// Assert
|
||||
Assert.Null(capturedChatOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
-167
@@ -763,171 +763,4 @@ public class ChatHistoryPersistingChatClientTests
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active and no real conversation ID exists,
|
||||
/// <see cref="ChatClientAgent"/> sets the <see cref="ChatHistoryPersistingChatClient.LocalHistoryConversationId"/>
|
||||
/// sentinel on the chat options and <see cref="ChatHistoryPersistingChatClient"/> strips it before
|
||||
/// forwarding to the inner client.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatOptions? capturedOptions = null;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test" },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([new(ChatRole.User, "test")]);
|
||||
|
||||
// Assert — the inner client should NOT see the sentinel conversation ID
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Null(capturedOptions!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the sentinel is NOT set when end-of-run persistence is enabled
|
||||
/// (mark-only mode), since the issue only applies to per-service-call persistence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DoesNotSetSentinel_WhenEndOfRunPersistenceEnabledAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatOptions? capturedOptions = null;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test" },
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
});
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([new(ChatRole.User, "test")]);
|
||||
|
||||
// Assert — the inner client should see options but NOT the sentinel conversation ID
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Null(capturedOptions!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the sentinel is NOT set when a real conversation ID is already present
|
||||
/// on the session (indicating server-side history management).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DoesNotSetSentinel_WhenRealConversationIdExistsAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string RealConversationId = "real-conv-123";
|
||||
ChatOptions? capturedOptions = null;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
|
||||
{
|
||||
ConversationId = RealConversationId,
|
||||
});
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Create a session with a real conversation ID.
|
||||
var session = await agent.CreateSessionAsync(RealConversationId);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — the inner client should see the real conversation ID, not the sentinel
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Equal(RealConversationId, capturedOptions!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the sentinel is set and stripped correctly in the streaming path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatOptions? capturedOptions = null;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.Returns(CreateAsyncEnumerableAsync(new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test" },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")]))
|
||||
{
|
||||
// Consume the stream.
|
||||
}
|
||||
|
||||
// Assert — the inner client should NOT see the sentinel conversation ID
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Null(capturedOptions!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the session's conversation ID is NOT set to the sentinel after the run.
|
||||
/// The sentinel should only exist transiently on the ChatOptions for the pipeline.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_SentinelDoesNotLeakToSession_WhenPerServiceCallPersistenceActiveAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — session should NOT have the sentinel conversation ID
|
||||
Assert.Null(session!.ConversationId);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-199
@@ -147,7 +147,7 @@ public class AgentWorkflowBuilderTests
|
||||
for (int iter = 0; iter < 3; iter++)
|
||||
{
|
||||
const string UserInput = "abc";
|
||||
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
|
||||
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(numAgents + 1, result.Count);
|
||||
@@ -225,7 +225,7 @@ public class AgentWorkflowBuilderTests
|
||||
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
remaining.Value = 2;
|
||||
|
||||
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
Assert.NotEmpty(updateText);
|
||||
Assert.NotNull(result);
|
||||
|
||||
@@ -258,7 +258,7 @@ public class AgentWorkflowBuilderTests
|
||||
}), description: "nop"))
|
||||
.Build();
|
||||
|
||||
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
|
||||
Assert.Equal("Hello from agent1", updateText);
|
||||
Assert.NotNull(result);
|
||||
@@ -296,7 +296,7 @@ public class AgentWorkflowBuilderTests
|
||||
.WithHandoff(initialAgent, nextAgent)
|
||||
.Build();
|
||||
|
||||
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
|
||||
Assert.Equal("Hello from agent2", updateText);
|
||||
Assert.NotNull(result);
|
||||
@@ -406,7 +406,7 @@ public class AgentWorkflowBuilderTests
|
||||
.WithHandoff(secondAgent, thirdAgent)
|
||||
.Build();
|
||||
|
||||
(string updateText, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
(string updateText, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
|
||||
Assert.Contains("Hello from agent3", updateText);
|
||||
|
||||
@@ -604,7 +604,7 @@ public class AgentWorkflowBuilderTests
|
||||
.WithHandoff(secondAgent, thirdAgent)
|
||||
.Build();
|
||||
|
||||
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
|
||||
Assert.Equal("Hello from agent3", updateText);
|
||||
Assert.NotNull(result);
|
||||
@@ -651,7 +651,7 @@ public class AgentWorkflowBuilderTests
|
||||
for (int iter = 0; iter < 3; iter++)
|
||||
{
|
||||
const string UserInput = "abc";
|
||||
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
|
||||
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(maxIterations + 1, result.Count);
|
||||
@@ -680,211 +680,36 @@ public class AgentWorkflowBuilderTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_ReturnToPrevious_DisabledByDefault_SecondTurnRoutesViaCoordinatorAsync()
|
||||
private static async Task<(string UpdateText, List<ChatMessage>? Result)> RunWorkflowAsync(
|
||||
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
|
||||
{
|
||||
int coordinatorCallCount = 0;
|
||||
StringBuilder sb = new();
|
||||
|
||||
var coordinator = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
coordinatorCallCount++;
|
||||
if (coordinatorCallCount == 1)
|
||||
{
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
|
||||
}
|
||||
return new(new ChatMessage(ChatRole.Assistant, "coordinator responded on turn 2"));
|
||||
}), name: "coordinator");
|
||||
|
||||
var specialist = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
new(new ChatMessage(ChatRole.Assistant, "specialist responded"))),
|
||||
name: "specialist", description: "The specialist agent");
|
||||
|
||||
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator)
|
||||
.WithHandoff(coordinator, specialist)
|
||||
.Build();
|
||||
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep;
|
||||
|
||||
// Turn 1: coordinator hands off to specialist
|
||||
WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager);
|
||||
Assert.Equal(1, coordinatorCallCount);
|
||||
|
||||
// Turn 2: without ReturnToPrevious, coordinator should be invoked again
|
||||
_ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint);
|
||||
Assert.Equal(2, coordinatorCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_ReturnToPrevious_Enabled_SecondTurnRoutesDirectlyToSpecialistAsync()
|
||||
{
|
||||
int coordinatorCallCount = 0;
|
||||
int specialistCallCount = 0;
|
||||
|
||||
var coordinator = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
coordinatorCallCount++;
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
|
||||
}), name: "coordinator");
|
||||
|
||||
var specialist = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
specialistCallCount++;
|
||||
return new(new ChatMessage(ChatRole.Assistant, "specialist responded"));
|
||||
}), name: "specialist", description: "The specialist agent");
|
||||
|
||||
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator)
|
||||
.WithHandoff(coordinator, specialist)
|
||||
.EnableReturnToPrevious()
|
||||
.Build();
|
||||
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep;
|
||||
|
||||
// Turn 1: coordinator hands off to specialist
|
||||
WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager);
|
||||
Assert.Equal(1, coordinatorCallCount);
|
||||
Assert.Equal(1, specialistCallCount);
|
||||
|
||||
// Turn 2: with ReturnToPrevious, specialist should be invoked directly, coordinator should NOT be called again
|
||||
_ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint);
|
||||
Assert.Equal(1, coordinatorCallCount); // coordinator NOT called again
|
||||
Assert.Equal(2, specialistCallCount); // specialist called again
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_ReturnToPrevious_Enabled_BeforeAnyHandoff_RoutesViaInitialAgentAsync()
|
||||
{
|
||||
int coordinatorCallCount = 0;
|
||||
|
||||
var coordinator = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
coordinatorCallCount++;
|
||||
return new(new ChatMessage(ChatRole.Assistant, "coordinator responded"));
|
||||
}), name: "coordinator");
|
||||
|
||||
var specialist = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
Assert.Fail("Specialist should not be invoked.");
|
||||
return new();
|
||||
}), name: "specialist", description: "The specialist agent");
|
||||
|
||||
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator)
|
||||
.WithHandoff(coordinator, specialist)
|
||||
.EnableReturnToPrevious()
|
||||
.Build();
|
||||
|
||||
// First turn with no prior handoff: should route to initial (coordinator) agent
|
||||
_ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")]);
|
||||
Assert.Equal(1, coordinatorCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_ReturnToPrevious_Enabled_AfterHandoffBackToCoordinator_NextTurnRoutesViaCoordinatorAsync()
|
||||
{
|
||||
int coordinatorCallCount = 0;
|
||||
int specialistCallCount = 0;
|
||||
|
||||
var coordinator = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
coordinatorCallCount++;
|
||||
if (coordinatorCallCount == 1)
|
||||
{
|
||||
// First call: hand off to specialist
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
|
||||
}
|
||||
// Subsequent calls: respond without handoff
|
||||
return new(new ChatMessage(ChatRole.Assistant, "coordinator responded"));
|
||||
}), name: "coordinator");
|
||||
|
||||
var specialist = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
specialistCallCount++;
|
||||
// Specialist hands back to coordinator
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)]));
|
||||
}), name: "specialist", description: "The specialist agent");
|
||||
|
||||
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator)
|
||||
.WithHandoff(coordinator, specialist)
|
||||
.WithHandoff(specialist, coordinator)
|
||||
.EnableReturnToPrevious()
|
||||
.Build();
|
||||
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep;
|
||||
|
||||
// Turn 1: coordinator → specialist → coordinator (specialist hands back)
|
||||
WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager);
|
||||
Assert.Equal(2, coordinatorCallCount); // called twice: initial handoff + receiving handback
|
||||
Assert.Equal(1, specialistCallCount); // specialist called once, then handed back
|
||||
|
||||
// Turn 2: after handoff back to coordinator, should route to coordinator (not specialist)
|
||||
_ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "never mind")], Environment, checkpointManager, result.LastCheckpoint);
|
||||
Assert.Equal(3, coordinatorCallCount); // coordinator called again on turn 2
|
||||
Assert.Equal(1, specialistCallCount); // specialist NOT called
|
||||
}
|
||||
|
||||
private sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint);
|
||||
|
||||
private static Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
|
||||
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null)
|
||||
{
|
||||
InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment()
|
||||
.WithCheckpointing(checkpointManager);
|
||||
|
||||
return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint);
|
||||
}
|
||||
|
||||
private static async Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
|
||||
Workflow workflow, List<ChatMessage> input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null)
|
||||
{
|
||||
await using StreamingRun run =
|
||||
fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint)
|
||||
: await environment.OpenStreamingAsync(workflow);
|
||||
|
||||
await run.TrySendMessageAsync(input);
|
||||
InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment();
|
||||
await using StreamingRun run = await environment.RunStreamingAsync(workflow, input);
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
|
||||
|
||||
StringBuilder sb = new();
|
||||
WorkflowOutputEvent? output = null;
|
||||
CheckpointInfo? lastCheckpoint = null;
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
|
||||
{
|
||||
switch (evt)
|
||||
if (evt is AgentResponseUpdateEvent executorComplete)
|
||||
{
|
||||
case AgentResponseUpdateEvent executorComplete:
|
||||
sb.Append(executorComplete.Data);
|
||||
break;
|
||||
|
||||
case WorkflowOutputEvent e:
|
||||
output = e;
|
||||
break;
|
||||
|
||||
case WorkflowErrorEvent errorEvent:
|
||||
Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}");
|
||||
break;
|
||||
|
||||
case SuperStepCompletedEvent stepCompleted:
|
||||
lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
|
||||
break;
|
||||
sb.Append(executorComplete.Data);
|
||||
}
|
||||
else if (evt is WorkflowOutputEvent e)
|
||||
{
|
||||
output = e;
|
||||
break;
|
||||
}
|
||||
else if (evt is WorkflowErrorEvent errorEvent)
|
||||
{
|
||||
Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}");
|
||||
}
|
||||
}
|
||||
|
||||
return new(sb.ToString(), output?.As<List<ChatMessage>>(), lastCheckpoint);
|
||||
return (sb.ToString(), output?.As<List<ChatMessage>>());
|
||||
}
|
||||
|
||||
private static Task<WorkflowRunResult> RunWorkflowAsync(
|
||||
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
|
||||
=> RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment());
|
||||
|
||||
private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
|
||||
{
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ internal sealed class HandoffTestEchoAgent(string id, string name, string prefix
|
||||
{
|
||||
IEnumerable<AITool>? handoffs = chatClientOptions.ChatOptions
|
||||
.Tools?
|
||||
.Where(tool => tool.Name?.StartsWith(HandoffWorkflowBuilder.FunctionPrefix,
|
||||
.Where(tool => tool.Name?.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix,
|
||||
StringComparison.OrdinalIgnoreCase) is true);
|
||||
|
||||
if (handoffs != null)
|
||||
@@ -58,7 +58,7 @@ internal static class Step12EntryPoint
|
||||
.Select(i => new HandoffTestEchoAgent($"{EchoAgentIdPrefix}{i}", $"{EchoAgentNamePrefix}{i}", EchoPrefixForAgent(i)))
|
||||
.ToArray();
|
||||
|
||||
return new HandoffWorkflowBuilder(echoAgents[0])
|
||||
return new HandoffsWorkflowBuilder(echoAgents[0])
|
||||
.WithHandoff(echoAgents[0], echoAgents[1])
|
||||
.Build();
|
||||
}
|
||||
|
||||
@@ -794,7 +794,7 @@ public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase
|
||||
{
|
||||
// Arrange
|
||||
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
|
||||
Workflow handoffWorkflow = new HandoffWorkflowBuilder(agent).Build();
|
||||
Workflow handoffWorkflow = new HandoffsWorkflowBuilder(agent).Build();
|
||||
return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# Azure AI
|
||||
FOUNDRY_PROJECT_ENDPOINT=""
|
||||
FOUNDRY_MODEL=""
|
||||
AZURE_AI_PROJECT_ENDPOINT=""
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=""
|
||||
# Bing connection for web search (optional, used by samples with web search)
|
||||
BING_CONNECTION_ID=""
|
||||
# Azure AI Search (optional, used by AzureAISearchContextProvider samples)
|
||||
@@ -13,8 +13,8 @@ AZURE_SEARCH_KNOWLEDGE_BASE_NAME=""
|
||||
# (different from AZURE_AI_PROJECT_ENDPOINT - Knowledge Base needs OpenAI endpoint for model calls)
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=""
|
||||
OPENAI_CHAT_MODEL=""
|
||||
OPENAI_RESPONSES_MODEL=""
|
||||
OPENAI_CHAT_MODEL_ID=""
|
||||
OPENAI_RESPONSES_MODEL_ID=""
|
||||
# Azure OpenAI
|
||||
AZURE_OPENAI_ENDPOINT=""
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=""
|
||||
|
||||
+2
-2
@@ -108,10 +108,10 @@ Content of `.env` or `openai.env`:
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=""
|
||||
OPENAI_MODEL="gpt-4o-mini"
|
||||
OPENAI_CHAT_MODEL_ID="gpt-4o-mini"
|
||||
```
|
||||
|
||||
You will then configure the ChatClient class with the keyword argument `env_file_path` (alternatively you can use `load_dotenv` in your code):
|
||||
You will then configure the ChatClient class with the keyword argument `env_file_path`:
|
||||
|
||||
```python
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
+5
-15
@@ -47,7 +47,7 @@ Set as environment variables, or create a .env file at your project root:
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=sk-...
|
||||
OPENAI_MODEL=...
|
||||
OPENAI_CHAT_MODEL_ID=...
|
||||
...
|
||||
AZURE_OPENAI_API_KEY=...
|
||||
AZURE_OPENAI_ENDPOINT=...
|
||||
@@ -57,25 +57,15 @@ FOUNDRY_PROJECT_ENDPOINT=...
|
||||
FOUNDRY_MODEL=...
|
||||
```
|
||||
|
||||
For the generic OpenAI clients (`OpenAIChatClient` and `OpenAIChatCompletionClient`), configuration
|
||||
resolves in this order:
|
||||
|
||||
1. Explicit Azure inputs such as `credential` or `azure_endpoint`
|
||||
2. `OPENAI_API_KEY` / explicit OpenAI API-key parameters
|
||||
3. Azure environment fallback such as `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_API_KEY`
|
||||
|
||||
This means mixed shells default to OpenAI when `OPENAI_API_KEY` is present. To force Azure routing,
|
||||
pass an explicit Azure input such as `credential=AzureCliCredential()`.
|
||||
|
||||
You can also override environment variables by explicitly passing configuration parameters to the chat client constructor:
|
||||
|
||||
```python
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
|
||||
client = OpenAIChatClient(
|
||||
client = AzureOpenAIChatClient(
|
||||
api_key='',
|
||||
azure_endpoint='',
|
||||
model='',
|
||||
endpoint='',
|
||||
deployment_name='',
|
||||
api_version='',
|
||||
)
|
||||
```
|
||||
|
||||
@@ -313,7 +313,6 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
self._map_a2a_stream(
|
||||
a2a_stream,
|
||||
background=background,
|
||||
emit_intermediate=stream,
|
||||
session=provider_session,
|
||||
session_context=session_context,
|
||||
),
|
||||
@@ -328,7 +327,6 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
a2a_stream: AsyncIterable[A2AStreamItem],
|
||||
*,
|
||||
background: bool = False,
|
||||
emit_intermediate: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
session_context: SessionContext | None = None,
|
||||
) -> AsyncIterable[AgentResponseUpdate]:
|
||||
@@ -341,10 +339,6 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
background: When False, in-progress task updates are silently
|
||||
consumed (the stream keeps iterating until a terminal state).
|
||||
When True, they are yielded with a continuation token.
|
||||
emit_intermediate: When True, in-progress status updates that
|
||||
carry message content are yielded to the caller. Typically
|
||||
set for streaming callers so non-streaming consumers only
|
||||
receive terminal task outputs.
|
||||
session: The agent session for context providers.
|
||||
session_context: The session context for context providers.
|
||||
"""
|
||||
@@ -379,11 +373,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
yield update
|
||||
elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Task):
|
||||
task, _update_event = item
|
||||
for update in self._updates_from_task(
|
||||
task,
|
||||
background=background,
|
||||
emit_intermediate=emit_intermediate,
|
||||
):
|
||||
for update in self._updates_from_task(task, background=background):
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
@@ -399,26 +389,15 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
# Task helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _updates_from_task(
|
||||
self,
|
||||
task: Task,
|
||||
*,
|
||||
background: bool = False,
|
||||
emit_intermediate: bool = False,
|
||||
) -> list[AgentResponseUpdate]:
|
||||
def _updates_from_task(self, task: Task, *, background: bool = False) -> list[AgentResponseUpdate]:
|
||||
"""Convert an A2A Task into AgentResponseUpdate(s).
|
||||
|
||||
Terminal tasks produce updates from their artifacts/history.
|
||||
In-progress tasks produce a continuation token update when
|
||||
``background=True``. When ``emit_intermediate=True`` (typically
|
||||
set for streaming callers), any message content attached to an
|
||||
in-progress status update is surfaced; otherwise the update is
|
||||
silently skipped so the caller keeps consuming the stream until
|
||||
completion.
|
||||
In-progress tasks produce a continuation token update only when
|
||||
``background=True``; otherwise they are silently skipped so the
|
||||
caller keeps consuming the stream until completion.
|
||||
"""
|
||||
status = task.status
|
||||
|
||||
if status.state in TERMINAL_TASK_STATES:
|
||||
if task.status.state in TERMINAL_TASK_STATES:
|
||||
task_messages = self._parse_messages_from_task(task)
|
||||
if task_messages:
|
||||
return [
|
||||
@@ -433,7 +412,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
]
|
||||
return [AgentResponseUpdate(contents=[], role="assistant", response_id=task.id, raw_representation=task)]
|
||||
|
||||
if background and status.state in IN_PROGRESS_TASK_STATES:
|
||||
if background and task.status.state in IN_PROGRESS_TASK_STATES:
|
||||
token = self._build_continuation_token(task)
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
@@ -445,26 +424,6 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
)
|
||||
]
|
||||
|
||||
# Surface message content from in-progress status updates (e.g. working state)
|
||||
# Only emitted when the caller opts in (streaming), so non-streaming
|
||||
# consumers keep receiving only terminal task outputs.
|
||||
if (
|
||||
emit_intermediate
|
||||
and status.state in IN_PROGRESS_TASK_STATES
|
||||
and status.message is not None
|
||||
and status.message.parts
|
||||
):
|
||||
contents = self._parse_contents_from_a2a(status.message.parts)
|
||||
if contents:
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=contents,
|
||||
role="assistant" if status.message.role == A2ARole.agent else "user",
|
||||
response_id=task.id,
|
||||
raw_representation=task,
|
||||
)
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -91,18 +91,9 @@ class MockA2AClient:
|
||||
task_id: str,
|
||||
context_id: str = "test-context",
|
||||
state: TaskState = TaskState.working,
|
||||
text: str | None = None,
|
||||
role: A2ARole = A2ARole.agent,
|
||||
) -> None:
|
||||
"""Add a mock in-progress Task response (non-terminal)."""
|
||||
message = None
|
||||
if text is not None:
|
||||
message = A2AMessage(
|
||||
message_id=str(uuid4()),
|
||||
role=role,
|
||||
parts=[Part(root=TextPart(text=text))],
|
||||
)
|
||||
status = TaskStatus(state=state, message=message)
|
||||
status = TaskStatus(state=state, message=None)
|
||||
task = Task(id=task_id, context_id=context_id, status=status)
|
||||
client_event = (task, None)
|
||||
self.responses.append(client_event)
|
||||
@@ -111,10 +102,9 @@ class MockA2AClient:
|
||||
"""Mock send_message method that yields responses."""
|
||||
self.call_count += 1
|
||||
|
||||
# All queued responses are delivered as a single streaming batch per call.
|
||||
for response in self.responses:
|
||||
if self.responses:
|
||||
response = self.responses.pop(0)
|
||||
yield response
|
||||
self.responses.clear()
|
||||
|
||||
async def resubscribe(self, request: Any) -> AsyncIterator[Any]:
|
||||
"""Mock resubscribe method that yields responses."""
|
||||
@@ -366,7 +356,7 @@ def test_get_uri_data_invalid_uri() -> None:
|
||||
def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None:
|
||||
"""Test A2A parts to contents conversion."""
|
||||
|
||||
agent = A2AAgent(name="Test Agent", client=MockA2AClient(), http_client=None)
|
||||
agent = A2AAgent(name="Test Agent", client=MockA2AClient(), _http_client=None)
|
||||
|
||||
# Create A2A parts
|
||||
parts = [Part(root=TextPart(text="First part")), Part(root=TextPart(text="Second part"))]
|
||||
@@ -485,7 +475,7 @@ async def test_context_manager_no_cleanup_when_no_http_client() -> None:
|
||||
|
||||
mock_a2a_client = MagicMock()
|
||||
|
||||
agent = A2AAgent(client=mock_a2a_client, http_client=None)
|
||||
agent = A2AAgent(client=mock_a2a_client, _http_client=None)
|
||||
|
||||
# This should not raise any errors
|
||||
async with agent:
|
||||
@@ -495,7 +485,7 @@ async def test_context_manager_no_cleanup_when_no_http_client() -> None:
|
||||
def test_prepare_message_for_a2a_with_multiple_contents() -> None:
|
||||
"""Test conversion of Message with multiple contents."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
agent = A2AAgent(client=MagicMock(), _http_client=None)
|
||||
|
||||
# Create message with multiple content types
|
||||
message = Message(
|
||||
@@ -523,7 +513,7 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None:
|
||||
def test_prepare_message_for_a2a_forwards_context_id() -> None:
|
||||
"""Test conversion of Message preserves context_id without duplicating it in metadata."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
agent = A2AAgent(client=MagicMock(), _http_client=None)
|
||||
|
||||
message = Message(
|
||||
role="user",
|
||||
@@ -540,7 +530,7 @@ def test_prepare_message_for_a2a_forwards_context_id() -> None:
|
||||
def test_parse_contents_from_a2a_with_data_part() -> None:
|
||||
"""Test conversion of A2A DataPart."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
agent = A2AAgent(client=MagicMock(), _http_client=None)
|
||||
|
||||
# Create DataPart
|
||||
data_part = Part(root=DataPart(data={"key": "value", "number": 42}, metadata={"source": "test"}))
|
||||
@@ -556,7 +546,7 @@ def test_parse_contents_from_a2a_with_data_part() -> None:
|
||||
|
||||
def test_parse_contents_from_a2a_unknown_part_kind() -> None:
|
||||
"""Test error handling for unknown A2A part kind."""
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
agent = A2AAgent(client=MagicMock(), _http_client=None)
|
||||
|
||||
# Create a mock part with unknown kind
|
||||
mock_part = MagicMock()
|
||||
@@ -569,7 +559,7 @@ def test_parse_contents_from_a2a_unknown_part_kind() -> None:
|
||||
def test_prepare_message_for_a2a_with_hosted_file() -> None:
|
||||
"""Test conversion of Message with HostedFileContent to A2A message."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
agent = A2AAgent(client=MagicMock(), _http_client=None)
|
||||
|
||||
# Create message with hosted file content
|
||||
message = Message(
|
||||
@@ -595,7 +585,7 @@ def test_prepare_message_for_a2a_with_hosted_file() -> None:
|
||||
def test_parse_contents_from_a2a_with_hosted_file_uri() -> None:
|
||||
"""Test conversion of A2A FilePart with hosted file URI back to UriContent."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
agent = A2AAgent(client=MagicMock(), _http_client=None)
|
||||
|
||||
# Create FilePart with hosted file URI (simulating what A2A would send back)
|
||||
file_part = Part(
|
||||
@@ -1049,144 +1039,3 @@ async def test_run_with_continuation_token_does_not_require_messages(mock_a2a_cl
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Streaming with in-progress message content
|
||||
|
||||
|
||||
async def test_streaming_working_updates_yield_message_content(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that streaming working updates with status.message yield content."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-w", context_id="ctx-w", text="Processing step 1...")
|
||||
mock_a2a_client.add_in_progress_task_response("task-w", context_id="ctx-w", text="Processing step 2...")
|
||||
mock_a2a_client.add_task_response("task-w", [{"id": "art-w", "content": "Final result"}])
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 3
|
||||
assert updates[0].contents[0].text == "Processing step 1..."
|
||||
assert updates[1].contents[0].text == "Processing step 2..."
|
||||
assert updates[2].contents[0].text == "Final result"
|
||||
|
||||
|
||||
async def test_streaming_single_working_update_with_message(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that a single working update with message content is not dropped."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-s", context_id="ctx-s", text="Thinking...")
|
||||
mock_a2a_client.add_task_response("task-s", [{"id": "art-s", "content": "Done"}])
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 2
|
||||
assert updates[0].contents[0].text == "Thinking..."
|
||||
assert updates[0].role == "assistant"
|
||||
assert updates[1].contents[0].text == "Done"
|
||||
|
||||
|
||||
async def test_streaming_working_update_without_message_is_skipped(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that working updates without status.message are still silently skipped."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-n", context_id="ctx-n")
|
||||
mock_a2a_client.add_task_response("task-n", [{"id": "art-n", "content": "Result"}])
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 1
|
||||
assert updates[0].contents[0].text == "Result"
|
||||
|
||||
|
||||
async def test_streaming_working_update_user_role_mapping(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that A2ARole.user in status message maps to role='user'."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-u", context_id="ctx-u", text="User echo", role=A2ARole.user)
|
||||
mock_a2a_client.add_task_response("task-u", [{"id": "art-u", "content": "Done"}])
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 2
|
||||
assert updates[0].contents[0].text == "User echo"
|
||||
assert updates[0].role == "user"
|
||||
|
||||
|
||||
async def test_background_with_status_message_yields_continuation_token(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that background=True takes precedence over status message content."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-bg", context_id="ctx-bg", text="Should be ignored")
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True, background=True):
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 1
|
||||
assert updates[0].continuation_token is not None
|
||||
assert updates[0].continuation_token["task_id"] == "task-bg"
|
||||
assert updates[0].contents == []
|
||||
|
||||
|
||||
async def test_non_streaming_does_not_surface_intermediate_messages(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that run(stream=False) does not include intermediate status messages."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-ns", context_id="ctx-ns", text="Intermediate")
|
||||
mock_a2a_client.add_task_response("task-ns", [{"id": "art-ns", "content": "Final"}])
|
||||
|
||||
response = await a2a_agent.run("Hello")
|
||||
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].text == "Final"
|
||||
|
||||
|
||||
async def test_terminal_no_artifacts_after_working_with_content(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that a terminal task with no artifacts after working-state messages does not re-emit the working content."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-t", context_id="ctx-t", text="Working on it...")
|
||||
# Terminal task with no artifacts and no history
|
||||
status = TaskStatus(state=TaskState.completed, message=None)
|
||||
task = Task(id="task-t", context_id="ctx-t", status=status)
|
||||
mock_a2a_client.responses.append((task, None))
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 2
|
||||
assert updates[0].contents[0].text == "Working on it..."
|
||||
# Terminal task with no artifacts yields an empty-contents update
|
||||
assert updates[1].contents == []
|
||||
|
||||
|
||||
async def test_streaming_working_update_with_empty_parts_is_skipped(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that a working update with status.message but empty parts list is skipped."""
|
||||
# Construct a message with an empty parts list (distinct from message=None)
|
||||
message = A2AMessage(
|
||||
message_id=str(uuid4()),
|
||||
role=A2ARole.agent,
|
||||
parts=[],
|
||||
)
|
||||
status = TaskStatus(state=TaskState.working, message=message)
|
||||
task = Task(id="task-ep", context_id="ctx-ep", status=status)
|
||||
mock_a2a_client.responses.append((task, None))
|
||||
mock_a2a_client.add_task_response("task-ep", [{"id": "art-ep", "content": "Result"}])
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 1
|
||||
assert updates[0].contents[0].text == "Result"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -684,10 +684,6 @@ def _build_messages_snapshot(
|
||||
}
|
||||
)
|
||||
|
||||
# Add reasoning messages so frontends that reconcile state from
|
||||
# MESSAGES_SNAPSHOT retain reasoning content after streaming ends.
|
||||
all_messages.extend(flow.reasoning_messages)
|
||||
|
||||
return MessagesSnapshotEvent(messages=all_messages) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@@ -1065,9 +1061,7 @@ async def run_agent_stream(
|
||||
|
||||
# Emit MessagesSnapshotEvent if we have tool calls or results
|
||||
# Feature #5: Suppress intermediate snapshots for predictive tools without confirmation
|
||||
should_emit_snapshot = (
|
||||
flow.pending_tool_calls or flow.tool_results or flow.accumulated_text or flow.reasoning_messages
|
||||
)
|
||||
should_emit_snapshot = flow.pending_tool_calls or flow.tool_results or flow.accumulated_text
|
||||
if should_emit_snapshot:
|
||||
# Check if we should suppress for predictive tool
|
||||
last_tool_name = None
|
||||
|
||||
@@ -604,10 +604,6 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Mes
|
||||
# Handle standard tool result messages early (role="tool") to preserve provider invariants
|
||||
# This path maps AG‑UI tool messages to function_result content with the correct tool_call_id
|
||||
role_str = normalize_agui_role(msg.get("role", "user"))
|
||||
if role_str == "reasoning":
|
||||
# Reasoning messages are UI-only state carried in MESSAGES_SNAPSHOT.
|
||||
# They should not be forwarded to the LLM provider.
|
||||
continue
|
||||
if role_str == "tool":
|
||||
# Prefer explicit tool_call_id fields; fall back to backend fields only if necessary
|
||||
tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId")
|
||||
@@ -1024,11 +1020,6 @@ def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dic
|
||||
elif "toolCallId" not in normalized_msg:
|
||||
normalized_msg["toolCallId"] = ""
|
||||
|
||||
# Normalize encrypted_value to encryptedValue for reasoning messages
|
||||
if normalized_msg.get("role") == "reasoning" and "encrypted_value" in normalized_msg:
|
||||
normalized_msg["encryptedValue"] = normalized_msg["encrypted_value"]
|
||||
del normalized_msg["encrypted_value"]
|
||||
|
||||
result.append(normalized_msg)
|
||||
|
||||
return result
|
||||
|
||||
@@ -126,8 +126,6 @@ class FlowState:
|
||||
tool_results: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
||||
tool_calls_ended: set[str] = field(default_factory=set) # pyright: ignore[reportUnknownVariableType]
|
||||
interrupts: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
||||
reasoning_messages: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
||||
accumulated_reasoning: dict[str, str] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
|
||||
|
||||
def get_tool_name(self, call_id: str | None) -> str | None:
|
||||
"""Get tool name by call ID."""
|
||||
@@ -462,7 +460,7 @@ def _emit_mcp_tool_result(
|
||||
return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler)
|
||||
|
||||
|
||||
def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> list[BaseEvent]:
|
||||
def _emit_text_reasoning(content: Content) -> list[BaseEvent]:
|
||||
"""Emit AG-UI reasoning events for text_reasoning content.
|
||||
|
||||
Uses the protocol-defined reasoning event types so that AG-UI consumers
|
||||
@@ -472,10 +470,6 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis
|
||||
``content.protected_data`` is present it is emitted as a
|
||||
``ReasoningEncryptedValueEvent`` so that consumers can persist encrypted
|
||||
reasoning for state continuity without conflating it with display text.
|
||||
|
||||
When *flow* is provided the reasoning message is persisted into
|
||||
``flow.reasoning_messages`` so that ``_build_messages_snapshot`` can
|
||||
include it in the final ``MESSAGES_SNAPSHOT``.
|
||||
"""
|
||||
text = content.text or ""
|
||||
if not text and content.protected_data is None:
|
||||
@@ -504,36 +498,6 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis
|
||||
|
||||
events.append(ReasoningEndEvent(message_id=message_id))
|
||||
|
||||
# Persist reasoning into flow state for MESSAGES_SNAPSHOT.
|
||||
# Accumulate reasoning text per message_id, similar to flow.accumulated_text,
|
||||
# so that incremental deltas build the full reasoning string.
|
||||
if flow is not None:
|
||||
if text:
|
||||
previous_text = flow.accumulated_reasoning.get(message_id, "")
|
||||
flow.accumulated_reasoning[message_id] = previous_text + text
|
||||
full_text = flow.accumulated_reasoning.get(message_id, text or "")
|
||||
|
||||
# Update existing reasoning entry for this message_id if present; otherwise append a new one.
|
||||
existing_entry: dict[str, Any] | None = None
|
||||
for entry in flow.reasoning_messages:
|
||||
if isinstance(entry, dict) and entry.get("id") == message_id:
|
||||
existing_entry = entry
|
||||
break
|
||||
|
||||
if existing_entry is None:
|
||||
reasoning_entry: dict[str, Any] = {
|
||||
"id": message_id,
|
||||
"role": "reasoning",
|
||||
"content": full_text,
|
||||
}
|
||||
if content.protected_data is not None:
|
||||
reasoning_entry["encryptedValue"] = content.protected_data
|
||||
flow.reasoning_messages.append(reasoning_entry)
|
||||
else:
|
||||
existing_entry["content"] = full_text
|
||||
if content.protected_data is not None:
|
||||
existing_entry["encryptedValue"] = content.protected_data
|
||||
|
||||
return events
|
||||
|
||||
|
||||
@@ -563,6 +527,6 @@ def _emit_content(
|
||||
if content_type == "mcp_server_tool_result":
|
||||
return _emit_mcp_tool_result(content, flow, predictive_handler)
|
||||
if content_type == "text_reasoning":
|
||||
return _emit_text_reasoning(content, flow)
|
||||
return _emit_text_reasoning(content)
|
||||
logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type)
|
||||
return []
|
||||
|
||||
@@ -27,7 +27,7 @@ FRAMEWORK_TO_AGUI_ROLE: dict[str, str] = {
|
||||
"system": "system",
|
||||
}
|
||||
|
||||
ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool", "reasoning"}
|
||||
ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool"}
|
||||
|
||||
|
||||
def generate_event_id() -> str:
|
||||
@@ -82,7 +82,7 @@ def normalize_agui_role(raw_role: Any) -> str:
|
||||
raw_role: Raw role value from AG-UI message
|
||||
|
||||
Returns:
|
||||
Normalized role string (user, assistant, system, tool, or reasoning)
|
||||
Normalized role string (user, assistant, system, or tool)
|
||||
"""
|
||||
if not isinstance(raw_role, str):
|
||||
return "user"
|
||||
|
||||
@@ -1669,94 +1669,3 @@ def test_agui_fresh_approval_is_still_processed():
|
||||
assert len(approval_contents) == 1, "Fresh approval should produce function_approval_response"
|
||||
assert approval_contents[0].approved is True
|
||||
assert approval_contents[0].function_call.name == "get_datetime"
|
||||
|
||||
|
||||
class TestReasoningRoundTrip:
|
||||
"""Tests for reasoning message handling in inbound/outbound adapters."""
|
||||
|
||||
def test_reasoning_skipped_on_inbound(self):
|
||||
"""Reasoning messages from prior snapshot are not forwarded to the LLM."""
|
||||
messages_input = [
|
||||
{"id": "u1", "role": "user", "content": "Hello"},
|
||||
{"id": "r1", "role": "reasoning", "content": "Thinking..."},
|
||||
{"id": "a1", "role": "assistant", "content": "Hi there"},
|
||||
]
|
||||
|
||||
result = agui_messages_to_agent_framework(messages_input)
|
||||
|
||||
roles = [m.role if hasattr(m.role, "value") else str(m.role) for m in result]
|
||||
assert "reasoning" not in roles
|
||||
assert len(result) == 2
|
||||
|
||||
def test_reasoning_preserved_in_snapshot_format(self):
|
||||
"""Reasoning messages retain their role through snapshot normalization."""
|
||||
messages_input = [
|
||||
{"id": "u1", "role": "user", "content": "Hello"},
|
||||
{"id": "r1", "role": "reasoning", "content": "Thinking about this..."},
|
||||
{"id": "a1", "role": "assistant", "content": "Answer"},
|
||||
]
|
||||
|
||||
result = agui_messages_to_snapshot_format(messages_input)
|
||||
|
||||
reasoning_msgs = [m for m in result if m.get("role") == "reasoning"]
|
||||
assert len(reasoning_msgs) == 1
|
||||
assert reasoning_msgs[0]["content"] == "Thinking about this..."
|
||||
|
||||
def test_reasoning_with_encrypted_value_in_snapshot_format(self):
|
||||
"""Reasoning with encryptedValue passes through snapshot normalization."""
|
||||
messages_input = [
|
||||
{
|
||||
"id": "r1",
|
||||
"role": "reasoning",
|
||||
"content": "visible",
|
||||
"encryptedValue": "secret-data",
|
||||
},
|
||||
]
|
||||
|
||||
result = agui_messages_to_snapshot_format(messages_input)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "reasoning"
|
||||
assert result[0]["encryptedValue"] == "secret-data"
|
||||
|
||||
def test_reasoning_encrypted_value_snake_case_normalized(self):
|
||||
"""Snake-case encrypted_value is normalized to encryptedValue in snapshot format."""
|
||||
messages_input = [
|
||||
{
|
||||
"id": "r1",
|
||||
"role": "reasoning",
|
||||
"content": "visible",
|
||||
"encrypted_value": "snake-case-data",
|
||||
},
|
||||
]
|
||||
|
||||
result = agui_messages_to_snapshot_format(messages_input)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["encryptedValue"] == "snake-case-data"
|
||||
assert "encrypted_value" not in result[0]
|
||||
|
||||
def test_multi_turn_with_reasoning_in_prior_snapshot(self):
|
||||
"""Second turn with reasoning from prior snapshot does not corrupt messages."""
|
||||
messages_input = [
|
||||
{"id": "u1", "role": "user", "content": "First question"},
|
||||
{"id": "r1", "role": "reasoning", "content": "Prior reasoning"},
|
||||
{"id": "a1", "role": "assistant", "content": "First answer"},
|
||||
{"id": "u2", "role": "user", "content": "Follow-up question"},
|
||||
]
|
||||
|
||||
result = agui_messages_to_agent_framework(messages_input)
|
||||
|
||||
roles = [m.role if hasattr(m.role, "value") else str(m.role) for m in result]
|
||||
# Reasoning is filtered out, other messages preserved in order
|
||||
assert roles == ["user", "assistant", "user"]
|
||||
# Content not corrupted
|
||||
texts = []
|
||||
for m in result:
|
||||
for c in m.contents or []:
|
||||
if hasattr(c, "text") and c.text:
|
||||
texts.append(c.text)
|
||||
assert "First question" in texts
|
||||
assert "First answer" in texts
|
||||
assert "Follow-up question" in texts
|
||||
assert "Prior reasoning" not in texts
|
||||
|
||||
@@ -1346,158 +1346,3 @@ class TestEmitContentMcpRouting:
|
||||
|
||||
assert len(events) == 5
|
||||
assert isinstance(events[0], ReasoningStartEvent)
|
||||
|
||||
|
||||
class TestReasoningInSnapshot:
|
||||
"""Tests for reasoning message inclusion in MESSAGES_SNAPSHOT."""
|
||||
|
||||
def test_reasoning_persisted_to_flow_state(self):
|
||||
"""_emit_text_reasoning with flow persists reasoning into flow.reasoning_messages."""
|
||||
flow = FlowState()
|
||||
content = Content.from_text_reasoning(
|
||||
id="reason_persist",
|
||||
text="Let me think step by step.",
|
||||
)
|
||||
|
||||
_emit_text_reasoning(content, flow)
|
||||
|
||||
assert len(flow.reasoning_messages) == 1
|
||||
assert flow.reasoning_messages[0]["id"] == "reason_persist"
|
||||
assert flow.reasoning_messages[0]["role"] == "reasoning"
|
||||
assert flow.reasoning_messages[0]["content"] == "Let me think step by step."
|
||||
assert "encryptedValue" not in flow.reasoning_messages[0]
|
||||
|
||||
def test_reasoning_with_encrypted_value_persisted(self):
|
||||
"""Reasoning with protected_data preserves encryptedValue in flow state."""
|
||||
flow = FlowState()
|
||||
content = Content.from_text_reasoning(
|
||||
id="reason_enc",
|
||||
text="visible reasoning",
|
||||
protected_data="encrypted-data-123",
|
||||
)
|
||||
|
||||
_emit_text_reasoning(content, flow)
|
||||
|
||||
assert len(flow.reasoning_messages) == 1
|
||||
assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-data-123"
|
||||
|
||||
def test_snapshot_includes_reasoning(self):
|
||||
"""_build_messages_snapshot includes reasoning messages from flow state."""
|
||||
from agent_framework_ag_ui._agent_run import _build_messages_snapshot
|
||||
|
||||
flow = FlowState()
|
||||
flow.accumulated_text = "Here is my answer."
|
||||
flow.reasoning_messages = [
|
||||
{"id": "r1", "role": "reasoning", "content": "Thinking..."},
|
||||
]
|
||||
|
||||
snapshot = _build_messages_snapshot(flow, [])
|
||||
|
||||
roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in snapshot.messages]
|
||||
assert "reasoning" in roles
|
||||
|
||||
def test_snapshot_preserves_reasoning_encrypted_value(self):
|
||||
"""Snapshot reasoning with encryptedValue is preserved end-to-end."""
|
||||
from agent_framework_ag_ui._agent_run import _build_messages_snapshot
|
||||
|
||||
flow = FlowState()
|
||||
content = Content.from_text_reasoning(
|
||||
id="reason_e2e",
|
||||
text="visible",
|
||||
protected_data="secret-data",
|
||||
)
|
||||
_emit_text_reasoning(content, flow)
|
||||
|
||||
text_content = Content.from_text("Final answer.")
|
||||
_emit_text(text_content, flow)
|
||||
|
||||
snapshot = _build_messages_snapshot(flow, [])
|
||||
|
||||
reasoning_msgs = [
|
||||
m
|
||||
for m in snapshot.messages
|
||||
if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "reasoning"
|
||||
]
|
||||
assert len(reasoning_msgs) == 1
|
||||
msg = reasoning_msgs[0]
|
||||
if isinstance(msg, dict):
|
||||
assert msg["content"] == "visible"
|
||||
assert msg["encryptedValue"] == "secret-data"
|
||||
|
||||
def test_emit_content_routes_reasoning_with_flow(self):
|
||||
"""_emit_content passes flow to _emit_text_reasoning for persistence."""
|
||||
flow = FlowState()
|
||||
content = Content.from_text_reasoning(text="routed reasoning")
|
||||
|
||||
_emit_content(content, flow)
|
||||
|
||||
assert len(flow.reasoning_messages) == 1
|
||||
assert flow.reasoning_messages[0]["content"] == "routed reasoning"
|
||||
|
||||
def test_reasoning_without_flow_does_not_error(self):
|
||||
"""Calling _emit_text_reasoning without flow still works (backward compat)."""
|
||||
content = Content.from_text_reasoning(text="no flow")
|
||||
|
||||
events = _emit_text_reasoning(content)
|
||||
|
||||
assert len(events) == 5
|
||||
assert isinstance(events[0], ReasoningStartEvent)
|
||||
|
||||
def test_snapshot_reasoning_ordering(self):
|
||||
"""Reasoning messages appear after assistant text in snapshot."""
|
||||
from agent_framework_ag_ui._agent_run import _build_messages_snapshot
|
||||
|
||||
flow = FlowState()
|
||||
reasoning_content = Content.from_text_reasoning(id="r1", text="Thinking...")
|
||||
_emit_text_reasoning(reasoning_content, flow)
|
||||
|
||||
text_content = Content.from_text("Answer")
|
||||
_emit_text(text_content, flow)
|
||||
|
||||
snapshot = _build_messages_snapshot(flow, [{"id": "u1", "role": "user", "content": "Hi"}])
|
||||
|
||||
# user -> assistant text -> reasoning
|
||||
assert len(snapshot.messages) == 3
|
||||
roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in snapshot.messages]
|
||||
assert roles == ["user", "assistant", "reasoning"]
|
||||
|
||||
def test_reasoning_accumulates_incremental_deltas(self):
|
||||
"""Multiple reasoning deltas with the same id accumulate into one entry."""
|
||||
flow = FlowState()
|
||||
content1 = Content.from_text_reasoning(id="reason_inc", text="First ")
|
||||
content2 = Content.from_text_reasoning(id="reason_inc", text="second ")
|
||||
content3 = Content.from_text_reasoning(id="reason_inc", text="third.")
|
||||
|
||||
_emit_text_reasoning(content1, flow)
|
||||
_emit_text_reasoning(content2, flow)
|
||||
_emit_text_reasoning(content3, flow)
|
||||
|
||||
assert len(flow.reasoning_messages) == 1
|
||||
assert flow.reasoning_messages[0]["id"] == "reason_inc"
|
||||
assert flow.reasoning_messages[0]["content"] == "First second third."
|
||||
|
||||
def test_reasoning_accumulates_distinct_message_ids(self):
|
||||
"""Reasoning entries with different ids are stored separately."""
|
||||
flow = FlowState()
|
||||
content_a = Content.from_text_reasoning(id="a", text="alpha")
|
||||
content_b = Content.from_text_reasoning(id="b", text="beta")
|
||||
|
||||
_emit_text_reasoning(content_a, flow)
|
||||
_emit_text_reasoning(content_b, flow)
|
||||
|
||||
assert len(flow.reasoning_messages) == 2
|
||||
assert flow.reasoning_messages[0]["content"] == "alpha"
|
||||
assert flow.reasoning_messages[1]["content"] == "beta"
|
||||
|
||||
def test_reasoning_encrypted_value_updated_on_later_delta(self):
|
||||
"""encryptedValue is set even when it arrives with a later delta."""
|
||||
flow = FlowState()
|
||||
content1 = Content.from_text_reasoning(id="enc_late", text="part1 ")
|
||||
content2 = Content.from_text_reasoning(id="enc_late", text="part2", protected_data="encrypted-payload")
|
||||
|
||||
_emit_text_reasoning(content1, flow)
|
||||
_emit_text_reasoning(content2, flow)
|
||||
|
||||
assert len(flow.reasoning_messages) == 1
|
||||
assert flow.reasoning_messages[0]["content"] == "part1 part2"
|
||||
assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-payload"
|
||||
|
||||
@@ -450,7 +450,6 @@ def test_normalize_agui_role_valid():
|
||||
assert normalize_agui_role("assistant") == "assistant"
|
||||
assert normalize_agui_role("system") == "system"
|
||||
assert normalize_agui_role("tool") == "tool"
|
||||
assert normalize_agui_role("reasoning") == "reasoning"
|
||||
|
||||
|
||||
def test_normalize_agui_role_invalid():
|
||||
|
||||
@@ -445,8 +445,6 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
|
||||
|
||||
# Merge tools: convert agent's hosted tools + user-provided function tools
|
||||
merged_tools = self._merge_tools(agent.tools, provided_tools)
|
||||
merged_default_options: dict[str, Any] = dict(default_options) if default_options is not None else {}
|
||||
merged_default_options.setdefault("model_id", agent.model)
|
||||
|
||||
return Agent( # type: ignore[return-value]
|
||||
client=client,
|
||||
@@ -454,8 +452,9 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
|
||||
name=agent.name,
|
||||
description=agent.description,
|
||||
instructions=agent.instructions,
|
||||
model_id=agent.model,
|
||||
tools=merged_tools,
|
||||
default_options=cast(Any, merged_default_options),
|
||||
default_options=default_options, # type: ignore[arg-type]
|
||||
middleware=middleware,
|
||||
context_providers=context_providers,
|
||||
)
|
||||
|
||||
@@ -603,6 +603,11 @@ class RawAzureAIClient(RawOpenAIChatClient[AzureAIClientOptionsT], Generic[Azure
|
||||
|
||||
return transformed
|
||||
|
||||
@override
|
||||
def _get_current_conversation_id(self, options: Mapping[str, Any], **kwargs: Any) -> str | None:
|
||||
"""Get the current conversation ID from chat options or kwargs."""
|
||||
return options.get("conversation_id") or kwargs.get("conversation_id") or self.conversation_id
|
||||
|
||||
@override
|
||||
def _parse_response_from_openai(
|
||||
self,
|
||||
|
||||
@@ -13,7 +13,6 @@ import json
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from copy import copy
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, cast
|
||||
from urllib.parse import urljoin, urlparse
|
||||
@@ -24,10 +23,7 @@ from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT, APP_INFO, pre
|
||||
from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer
|
||||
from agent_framework._types import Annotation, Content
|
||||
from agent_framework.observability import ChatTelemetryLayer, EmbeddingTelemetryLayer
|
||||
from agent_framework_openai._assistants_client import (
|
||||
OpenAIAssistantsClient, # type: ignore[reportDeprecated]
|
||||
OpenAIAssistantsOptions,
|
||||
)
|
||||
from agent_framework_openai._assistants_client import OpenAIAssistantsClient, OpenAIAssistantsOptions
|
||||
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
|
||||
from agent_framework_openai._chat_completion_client import OpenAIChatCompletionOptions, RawOpenAIChatCompletionClient
|
||||
from agent_framework_openai._embedding_client import OpenAIEmbeddingOptions, RawOpenAIEmbeddingClient
|
||||
@@ -113,12 +109,6 @@ def _apply_azure_defaults(
|
||||
settings["token_endpoint"] = default_token_endpoint
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _prefer_single_azure_endpoint_env(*, endpoint: str | None, base_url: str | None) -> Any:
|
||||
"""Preserve the legacy call shape without mutating process-wide environment state."""
|
||||
yield
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
@@ -325,8 +315,6 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
"or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable."
|
||||
)
|
||||
|
||||
endpoint_value = azure_openai_settings.get("endpoint")
|
||||
client_base_url = azure_openai_settings.get("base_url")
|
||||
if not async_client:
|
||||
# Create the Azure OpenAI client directly
|
||||
merged_headers = dict(copy(default_headers)) if default_headers else {}
|
||||
@@ -344,7 +332,9 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
if not api_key_secret and not ad_token_provider:
|
||||
raise ValueError("Please provide either api_key, credential, or a client.")
|
||||
|
||||
if not endpoint_value and not client_base_url:
|
||||
client_endpoint = azure_openai_settings.get("endpoint")
|
||||
client_base_url = azure_openai_settings.get("base_url")
|
||||
if not client_endpoint and not client_base_url:
|
||||
raise ValueError("Please provide an endpoint or a base_url")
|
||||
|
||||
client_args: dict[str, Any] = {"default_headers": merged_headers}
|
||||
@@ -356,8 +346,8 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
client_args["api_key"] = api_key_secret.get_secret_value()
|
||||
if client_base_url:
|
||||
client_args["base_url"] = str(client_base_url)
|
||||
if endpoint_value and not client_base_url:
|
||||
client_args["azure_endpoint"] = str(endpoint_value)
|
||||
if client_endpoint and not client_base_url:
|
||||
client_args["azure_endpoint"] = str(client_endpoint)
|
||||
if responses_deployment_name:
|
||||
client_args["azure_deployment"] = responses_deployment_name
|
||||
if "websocket_base_url" in kwargs:
|
||||
@@ -370,19 +360,16 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
self.api_version = azure_openai_settings.get("api_version") or ""
|
||||
self.deployment_name = responses_deployment_name
|
||||
|
||||
with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=client_base_url):
|
||||
super().__init__(
|
||||
async_client=async_client,
|
||||
model=responses_deployment_name,
|
||||
azure_endpoint=str(endpoint_value) if endpoint_value else None,
|
||||
base_url=str(client_base_url) if client_base_url else None,
|
||||
api_version=azure_openai_settings.get("api_version"),
|
||||
instruction_role=instruction_role,
|
||||
default_headers=default_headers,
|
||||
middleware=middleware, # type: ignore[arg-type]
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
**kwargs,
|
||||
)
|
||||
super().__init__(
|
||||
async_client=async_client,
|
||||
model=responses_deployment_name,
|
||||
api_version=azure_openai_settings.get("api_version"),
|
||||
instruction_role=instruction_role,
|
||||
default_headers=default_headers,
|
||||
middleware=middleware, # type: ignore[arg-type]
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_client_from_project(
|
||||
@@ -543,8 +530,6 @@ class AzureOpenAIChatClient( # type: ignore[misc]
|
||||
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
|
||||
)
|
||||
|
||||
endpoint_value = azure_openai_settings.get("endpoint")
|
||||
base_url_value = azure_openai_settings.get("base_url")
|
||||
if not async_client:
|
||||
# Create the Azure OpenAI client directly
|
||||
merged_headers = dict(copy(default_headers)) if default_headers else {}
|
||||
@@ -562,6 +547,8 @@ class AzureOpenAIChatClient( # type: ignore[misc]
|
||||
if not api_key_secret and not ad_token_provider:
|
||||
raise ValueError("Please provide either api_key, credential, or a client.")
|
||||
|
||||
endpoint_value = azure_openai_settings.get("endpoint")
|
||||
base_url_value = azure_openai_settings.get("base_url")
|
||||
if not endpoint_value and not base_url_value:
|
||||
raise ValueError("Please provide an endpoint or a base_url")
|
||||
|
||||
@@ -586,19 +573,16 @@ class AzureOpenAIChatClient( # type: ignore[misc]
|
||||
self.api_version = azure_openai_settings.get("api_version") or ""
|
||||
self.deployment_name = chat_deployment_name
|
||||
|
||||
with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value):
|
||||
super().__init__(
|
||||
async_client=async_client,
|
||||
model=chat_deployment_name,
|
||||
azure_endpoint=str(endpoint_value) if endpoint_value else None,
|
||||
base_url=str(base_url_value) if base_url_value else None,
|
||||
api_version=azure_openai_settings.get("api_version"),
|
||||
instruction_role=instruction_role,
|
||||
default_headers=default_headers,
|
||||
additional_properties=additional_properties,
|
||||
middleware=middleware, # type: ignore[arg-type]
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
)
|
||||
super().__init__(
|
||||
async_client=async_client,
|
||||
model=chat_deployment_name,
|
||||
api_version=azure_openai_settings.get("api_version"),
|
||||
instruction_role=instruction_role,
|
||||
default_headers=default_headers,
|
||||
additional_properties=additional_properties,
|
||||
middleware=middleware, # type: ignore[arg-type]
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
)
|
||||
|
||||
@override
|
||||
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None:
|
||||
@@ -676,8 +660,7 @@ AzureOpenAIAssistantsOptions = OpenAIAssistantsOptions
|
||||
"Use OpenAIAssistantsClient (also deprecated) or migrate to OpenAIChatClient."
|
||||
)
|
||||
class AzureOpenAIAssistantsClient(
|
||||
OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], # type: ignore[reportDeprecated]
|
||||
Generic[AzureOpenAIAssistantsOptionsT],
|
||||
OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], Generic[AzureOpenAIAssistantsOptionsT]
|
||||
):
|
||||
"""Deprecated Azure OpenAI Assistants client. Use OpenAIAssistantsClient or migrate to OpenAIChatClient."""
|
||||
|
||||
@@ -859,8 +842,6 @@ class AzureOpenAIEmbeddingClient(
|
||||
"or 'AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME' environment variable."
|
||||
)
|
||||
|
||||
endpoint_value = azure_openai_settings.get("endpoint")
|
||||
base_url_value = azure_openai_settings.get("base_url")
|
||||
if not async_client:
|
||||
# Create the Azure OpenAI client directly
|
||||
merged_headers = dict(copy(default_headers)) if default_headers else {}
|
||||
@@ -878,6 +859,8 @@ class AzureOpenAIEmbeddingClient(
|
||||
if not api_key_secret and not ad_token_provider:
|
||||
raise ValueError("Please provide either api_key, credential, or a client.")
|
||||
|
||||
endpoint_value = azure_openai_settings.get("endpoint")
|
||||
base_url_value = azure_openai_settings.get("base_url")
|
||||
if not endpoint_value and not base_url_value:
|
||||
raise ValueError("Please provide an endpoint or a base_url")
|
||||
|
||||
@@ -902,15 +885,11 @@ class AzureOpenAIEmbeddingClient(
|
||||
self.api_version = azure_openai_settings.get("api_version") or ""
|
||||
self.deployment_name = embedding_deployment_name
|
||||
|
||||
with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value):
|
||||
super().__init__(
|
||||
async_client=async_client,
|
||||
model=embedding_deployment_name,
|
||||
azure_endpoint=str(endpoint_value) if endpoint_value else None,
|
||||
base_url=str(base_url_value) if base_url_value else None,
|
||||
api_version=azure_openai_settings.get("api_version"),
|
||||
default_headers=default_headers,
|
||||
)
|
||||
super().__init__(
|
||||
async_client=async_client,
|
||||
model=embedding_deployment_name,
|
||||
default_headers=default_headers,
|
||||
)
|
||||
if otel_provider_name is not None:
|
||||
self.OTEL_PROVIDER_NAME = otel_provider_name # type: ignore[misc]
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping, MutableMapping, Sequence
|
||||
from typing import Any, Generic, cast
|
||||
from typing import Any, Generic
|
||||
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
@@ -398,8 +398,6 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
# from_azure_ai_tools converts hosted tools (MCP, code interpreter, file search, web search)
|
||||
# but function tools need the actual implementations from provided_tools
|
||||
merged_tools = self._merge_tools(details.definition.tools, provided_tools)
|
||||
merged_default_options: dict[str, Any] = dict(default_options) if default_options is not None else {}
|
||||
merged_default_options.setdefault("model_id", details.definition.model)
|
||||
|
||||
return Agent( # type: ignore[return-value]
|
||||
client=client,
|
||||
@@ -407,8 +405,9 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
name=details.name,
|
||||
description=details.description,
|
||||
instructions=details.definition.instructions,
|
||||
model_id=details.definition.model,
|
||||
tools=merged_tools,
|
||||
default_options=cast(Any, merged_default_options),
|
||||
default_options=default_options, # type: ignore[arg-type]
|
||||
middleware=middleware,
|
||||
context_providers=context_providers,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user