Merge branch 'main' into copilot/add-unit-tests-edge-types

This commit is contained in:
Jacob Alber
2026-05-14 09:02:18 -04:00
committed by GitHub
Unverified
28 changed files with 2689 additions and 803 deletions
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.6.0</VersionPrefix>
<VersionPrefix>1.6.1</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260512</DateSuffix>
<DateSuffix>260514</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.6.0</GitTag>
<GitTag>1.6.1</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -51,9 +51,7 @@ internal sealed class DevUIAuthFilter : IEndpointFilter
if (!isLoopback && !this._options.AllowRemoteAccess)
{
this._logger.LogWarning(
"Rejected non-loopback DevUI request from {RemoteIp}. Set DevUIOptions.AllowRemoteAccess to permit remote callers.",
remoteIp);
DevUILog.RejectedNonLoopbackRequest(this._logger, remoteIp);
return Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "DevUI access denied",
@@ -100,10 +100,7 @@ public static class DevUIExtensions
if (options.AllowRemoteAccess && !tokenConfigured && options.ConfigureEndpoints is null)
{
logger.LogWarning(
"DevUI is configured with AllowRemoteAccess=true and no authentication. " +
"Set DevUIOptions.AuthToken, the {EnvVar} environment variable, or attach an authorization policy via ConfigureEndpoints.",
DevUIOptions.AuthTokenEnvironmentVariable);
DevUILog.InsecurelyExposed(logger, DevUIOptions.AuthTokenEnvironmentVariable);
}
}
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net;
namespace Microsoft.Agents.AI.DevUI;
internal static partial class DevUILog
{
[LoggerMessage(
EventId = 1,
Level = LogLevel.Warning,
Message = "Rejected non-loopback DevUI request from {RemoteIp}. Set DevUIOptions.AllowRemoteAccess to permit remote callers.")]
public static partial void RejectedNonLoopbackRequest(ILogger logger, IPAddress? remoteIp);
[LoggerMessage(
EventId = 2,
Level = LogLevel.Warning,
Message = "DevUI is configured with AllowRemoteAccess=true and no authentication. Set DevUIOptions.AuthToken, the {EnvVar} environment variable, or attach an authorization policy via ConfigureEndpoints.")]
public static partial void InsecurelyExposed(ILogger logger, string envVar);
}
@@ -122,6 +122,7 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
}
var files = Directory.GetFiles(fullDir)
.Where(f => (File.GetAttributes(f) & FileAttributes.ReparsePoint) == 0)
.Select(Path.GetFileName)
.Where(name => name is not null)
.ToList();
@@ -157,6 +158,12 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
foreach (string filePath in Directory.GetFiles(fullDir))
{
// Skip files that are symlinks/reparse points to prevent reading outside the root.
if ((File.GetAttributes(filePath) & FileAttributes.ReparsePoint) != 0)
{
continue;
}
string? fileName = Path.GetFileName(filePath);
if (fileName is null)
{
@@ -231,7 +238,7 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
/// <summary>
/// Resolves a relative file path to a safe absolute path under the root directory.
/// Rejects paths that would escape the root via traversal or rooted paths.
/// Rejects paths that would escape the root via traversal, rooted paths, or symbolic links.
/// </summary>
private string ResolveSafePath(string relativePath)
{
@@ -250,9 +257,55 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
nameof(relativePath));
}
// Reject symlinks/reparse points in any path segment to prevent escaping the root.
ThrowIfContainsSymlink(fullPath, this._rootPath);
return fullPath;
}
/// <summary>
/// Checks each path segment between the trusted root and the resolved path for symbolic links
/// or reparse points. Throws <see cref="ArgumentException"/> if any segment is a symlink.
/// Stops checking at the first segment that does not exist on disk (for write scenarios).
/// Uses <see cref="File.GetAttributes(string)"/> directly so that dangling symlinks (whose targets
/// do not exist) are still detected via their <see cref="FileAttributes.ReparsePoint"/> flag.
/// </summary>
private static void ThrowIfContainsSymlink(string fullPath, string rootPath)
{
string rootTrimmed = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
string relative = fullPath.Substring(rootTrimmed.Length);
string[] segments = relative.Split(
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
StringSplitOptions.RemoveEmptyEntries);
string current = rootTrimmed;
foreach (string segment in segments)
{
current = Path.Combine(current, segment);
FileAttributes attributes;
try
{
attributes = File.GetAttributes(current);
}
catch (FileNotFoundException)
{
// Segment does not exist on disk (write scenario); stop checking.
break;
}
catch (DirectoryNotFoundException)
{
break;
}
if ((attributes & FileAttributes.ReparsePoint) != 0)
{
throw new ArgumentException(
"Invalid path: the resolved path contains a symbolic link or reparse point.");
}
}
}
/// <summary>
/// Resolves a relative directory path to a safe absolute path under the root directory.
/// An empty string resolves to the root directory itself.
@@ -218,7 +218,7 @@ public class DevUIIntegrationTests
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-workflow" && e.Type == "workflow");
}
[Fact]
[Fact(Skip = "Flaky in merge_group; see https://github.com/microsoft/agent-framework/issues/5845")]
public async Task TestServerWithDevUI_ResolvesMixedAgentsAndWorkflows_AllRegistrationsAsync()
{
// Arrange
@@ -334,4 +334,475 @@ public sealed class FileSystemAgentFileStoreTests : IDisposable
}
#endregion
#region Symlink Escape Rejection
#if NET
/// <summary>
/// Attempts to create a file symlink. Returns false if the platform does not support
/// symlink creation (e.g., Windows without developer mode) or if creation fails.
/// </summary>
private static bool TryCreateFileSymbolicLink(string linkPath, string targetPath)
{
try
{
File.CreateSymbolicLink(linkPath, targetPath);
}
catch (IOException)
{
return false;
}
// Verify the symlink was actually created as a reparse point.
return File.Exists(linkPath)
&& (File.GetAttributes(linkPath) & FileAttributes.ReparsePoint) != 0;
}
/// <summary>
/// Attempts to create a directory symlink. Returns false if the platform does not support
/// symlink creation (e.g., Windows without developer mode) or if creation fails.
/// </summary>
private static bool TryCreateDirectorySymbolicLink(string linkPath, string targetPath)
{
try
{
Directory.CreateSymbolicLink(linkPath, targetPath);
}
catch (IOException)
{
return false;
}
// Verify the symlink was actually created as a reparse point.
return Directory.Exists(linkPath)
&& (File.GetAttributes(linkPath) & FileAttributes.ReparsePoint) != 0;
}
[Fact]
public async Task ReadFileAsync_SymlinkedFile_ThrowsAsync()
{
// Arrange — create a file outside the root and symlink to it from inside.
string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_target_read_" + Guid.NewGuid().ToString("N") + ".txt");
File.WriteAllText(outsideFile, "SECRET_OUTSIDE_ROOT");
string linkPath = Path.Combine(this._rootDir, "leak.txt");
try
{
if (!TryCreateFileSymbolicLink(linkPath, outsideFile))
{
return; // Cannot create symlinks in this environment; skip.
}
// Act & Assert — reading through the symlink should be rejected.
await Assert.ThrowsAsync<ArgumentException>(() => this._store.ReadFileAsync("leak.txt"));
}
finally
{
if (File.Exists(linkPath))
{
File.Delete(linkPath);
}
File.Delete(outsideFile);
}
}
[Fact]
public async Task WriteFileAsync_SymlinkedFile_ThrowsAsync()
{
// Arrange — create a file outside the root and symlink to it from inside.
string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_target_write_" + Guid.NewGuid().ToString("N") + ".txt");
File.WriteAllText(outsideFile, "ORIGINAL_CONTENT");
string linkPath = Path.Combine(this._rootDir, "overwrite.txt");
try
{
if (!TryCreateFileSymbolicLink(linkPath, outsideFile))
{
return;
}
// Act & Assert — writing through the symlink should be rejected.
await Assert.ThrowsAsync<ArgumentException>(() => this._store.WriteFileAsync("overwrite.txt", "EVIL_CONTENT"));
// Verify the outside file was NOT modified.
Assert.Equal("ORIGINAL_CONTENT", await File.ReadAllTextAsync(outsideFile));
}
finally
{
if (File.Exists(linkPath))
{
File.Delete(linkPath);
}
File.Delete(outsideFile);
}
}
[Fact]
public async Task DeleteFileAsync_SymlinkedFile_ThrowsAsync()
{
// Arrange
string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_target_delete_" + Guid.NewGuid().ToString("N") + ".txt");
File.WriteAllText(outsideFile, "DO_NOT_DELETE");
string linkPath = Path.Combine(this._rootDir, "trap.txt");
try
{
if (!TryCreateFileSymbolicLink(linkPath, outsideFile))
{
return;
}
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.DeleteFileAsync("trap.txt"));
// Verify the outside file still exists.
Assert.True(File.Exists(outsideFile));
}
finally
{
if (File.Exists(linkPath))
{
File.Delete(linkPath);
}
File.Delete(outsideFile);
}
}
[Fact]
public async Task FileExistsAsync_SymlinkedFile_ThrowsAsync()
{
// Arrange
string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_target_exists_" + Guid.NewGuid().ToString("N") + ".txt");
File.WriteAllText(outsideFile, "EXISTS_OUTSIDE");
string linkPath = Path.Combine(this._rootDir, "phantom.txt");
try
{
if (!TryCreateFileSymbolicLink(linkPath, outsideFile))
{
return;
}
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.FileExistsAsync("phantom.txt"));
}
finally
{
if (File.Exists(linkPath))
{
File.Delete(linkPath);
}
File.Delete(outsideFile);
}
}
[Fact]
public async Task WriteFileAsync_DanglingSymlink_ThrowsAsync()
{
// Arrange — create a symlink pointing to a non-existent target.
string nonExistentTarget = Path.Combine(Path.GetTempPath(), "dangling_target_" + Guid.NewGuid().ToString("N") + ".txt");
string linkPath = Path.Combine(this._rootDir, "dangling.txt");
try
{
if (!TryCreateFileSymbolicLink(linkPath, nonExistentTarget))
{
return;
}
// Act & Assert — even a dangling symlink must be rejected.
await Assert.ThrowsAsync<ArgumentException>(() => this._store.WriteFileAsync("dangling.txt", "CONTENT"));
// Verify the target was NOT created by following the dangling link.
Assert.False(File.Exists(nonExistentTarget));
}
finally
{
// Dangling symlinks: File.Exists returns false, but the link entry still exists.
// Use FileInfo to delete the link itself.
var linkInfo = new FileInfo(linkPath);
if (linkInfo.Exists || (linkInfo.Attributes & FileAttributes.ReparsePoint) != 0)
{
linkInfo.Delete();
}
}
}
[Fact]
public async Task ListFilesAsync_SymlinkedDirectory_ThrowsAsync()
{
// Arrange — create a directory outside root and symlink a directory inside root to it.
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_target_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outsideDir);
File.WriteAllText(Path.Combine(outsideDir, "secret.txt"), "SECRET");
string linkDir = Path.Combine(this._rootDir, "linked-dir");
try
{
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
{
return;
}
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.ListFilesAsync("linked-dir"));
}
finally
{
if (Directory.Exists(linkDir))
{
Directory.Delete(linkDir);
}
Directory.Delete(outsideDir, recursive: true);
}
}
[Fact]
public async Task SearchFilesAsync_SymlinkedDirectory_ThrowsAsync()
{
// Arrange
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_search_target_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outsideDir);
File.WriteAllText(Path.Combine(outsideDir, "data.txt"), "SENSITIVE_DATA");
string linkDir = Path.Combine(this._rootDir, "search-link");
try
{
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
{
return;
}
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.SearchFilesAsync("search-link", "SENSITIVE"));
}
finally
{
if (Directory.Exists(linkDir))
{
Directory.Delete(linkDir);
}
Directory.Delete(outsideDir, recursive: true);
}
}
[Fact]
public async Task ReadFileAsync_ThroughDirectorySymlink_ThrowsAsync()
{
// Arrange — directory symlink inside root pointing outside; read a file through it.
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_read_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outsideDir);
File.WriteAllText(Path.Combine(outsideDir, "secret.txt"), "DIR_SYMLINK_SECRET");
string linkDir = Path.Combine(this._rootDir, "linked-output");
try
{
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
{
return;
}
// Act & Assert — reading through a directory symlink should be rejected.
await Assert.ThrowsAsync<ArgumentException>(() => this._store.ReadFileAsync("linked-output/secret.txt"));
}
finally
{
if (Directory.Exists(linkDir))
{
Directory.Delete(linkDir);
}
Directory.Delete(outsideDir, recursive: true);
}
}
[Fact]
public async Task WriteFileAsync_ThroughDirectorySymlink_ThrowsAsync()
{
// Arrange — directory symlink; attempt to create/overwrite a file through it.
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_write_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outsideDir);
string linkDir = Path.Combine(this._rootDir, "linked-output");
try
{
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
{
return;
}
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.WriteFileAsync("linked-output/created-by-agent.txt", "CONTENT"));
// Verify no file was created outside.
Assert.False(File.Exists(Path.Combine(outsideDir, "created-by-agent.txt")));
}
finally
{
if (Directory.Exists(linkDir))
{
Directory.Delete(linkDir);
}
Directory.Delete(outsideDir, recursive: true);
}
}
[Fact]
public async Task DeleteFileAsync_ThroughDirectorySymlink_ThrowsAsync()
{
// Arrange — directory symlink; attempt to delete a file through it.
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_delete_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outsideDir);
string outsideFile = Path.Combine(outsideDir, "delete-me.txt");
File.WriteAllText(outsideFile, "DO_NOT_DELETE");
string linkDir = Path.Combine(this._rootDir, "linked-output");
try
{
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
{
return;
}
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.DeleteFileAsync("linked-output/delete-me.txt"));
// Verify the outside file was NOT deleted.
Assert.True(File.Exists(outsideFile));
}
finally
{
if (Directory.Exists(linkDir))
{
Directory.Delete(linkDir);
}
Directory.Delete(outsideDir, recursive: true);
}
}
[Fact]
public async Task CreateDirectoryAsync_ThroughDirectorySymlink_ThrowsAsync()
{
// Arrange — directory symlink; attempt to create a subdirectory through it.
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_mkdir_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outsideDir);
string linkDir = Path.Combine(this._rootDir, "linked-output");
try
{
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
{
return;
}
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.CreateDirectoryAsync("linked-output/created-directory"));
// Verify no directory was created outside.
Assert.False(Directory.Exists(Path.Combine(outsideDir, "created-directory")));
}
finally
{
if (Directory.Exists(linkDir))
{
Directory.Delete(linkDir);
}
Directory.Delete(outsideDir, recursive: true);
}
}
[Fact]
public async Task SearchFilesAsync_RootWithSymlinkedFile_DoesNotLeakContentAsync()
{
// Arrange — symlinked file at root level; search should not return its content.
string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_search_root_" + Guid.NewGuid().ToString("N") + ".txt");
File.WriteAllText(outsideFile, "ROOT_LEVEL_SECRET_CONTENT");
string linkPath = Path.Combine(this._rootDir, "env-link.txt");
try
{
if (!TryCreateFileSymbolicLink(linkPath, outsideFile))
{
return;
}
// Also add a normal file to confirm search still works for non-symlinks.
await this._store.WriteFileAsync("normal.txt", "NORMAL_CONTENT");
// Act — search at root should skip the symlinked file.
var results = await this._store.SearchFilesAsync("", "SECRET_CONTENT");
// Assert — no results from the symlinked file.
Assert.Empty(results);
}
finally
{
if (File.Exists(linkPath))
{
File.Delete(linkPath);
}
File.Delete(outsideFile);
}
}
[Fact]
public async Task ListFilesAsync_RootWithSymlinkedFile_ExcludesSymlinkAsync()
{
// Arrange — symlinked file at root level; listing should not include it.
string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_list_root_" + Guid.NewGuid().ToString("N") + ".txt");
File.WriteAllText(outsideFile, "OUTSIDE");
string linkPath = Path.Combine(this._rootDir, "hidden-link.txt");
try
{
if (!TryCreateFileSymbolicLink(linkPath, outsideFile))
{
return;
}
// Also add a normal file.
await this._store.WriteFileAsync("visible.txt", "VISIBLE");
// Act
var files = await this._store.ListFilesAsync("");
// Assert — symlinked file should not appear in listing.
Assert.DoesNotContain("hidden-link.txt", files);
Assert.Contains("visible.txt", files);
}
finally
{
if (File.Exists(linkPath))
{
File.Delete(linkPath);
}
File.Delete(outsideFile);
}
}
#endif
#endregion
}
+2 -1
View File
@@ -69,7 +69,8 @@ agent_framework/
### Skills (`_skills.py`)
- **`Skill`** - A skill definition bundling instructions (`content`) with metadata, resources, and scripts. Supports `@skill.resource` and `@skill.script` decorators for adding components.
- **`Skill`** - Abstract base for a skill definition bundling instructions (`content`) with frontmatter metadata, resources, and scripts. Concrete subclasses (`InlineSkill`, `FileSkill`, `ClassSkill`) accept a `frontmatter=SkillFrontmatter(...)` argument carrying the spec fields. Adding new spec fields is done in one place — on `SkillFrontmatter` — keeping the subclass constructors stable.
- **`SkillFrontmatter`** - L1 discovery metadata for a skill (`name`, `description`, `license`, `compatibility`, `allowed_tools`, `metadata`). All fields are mutable plain attributes; the constructor validates `name`, `description`, and `compatibility` against the spec but post-construction assignments are not re-validated. Spec fields are reachable on every skill via `skill.frontmatter`.
- **`SkillResource`** - Named supplementary content attached to a skill; holds either static `content` or a dynamic `function` (sync or async). Exactly one must be provided.
- **`SkillScript`** - An executable script attached to a skill; holds either an inline `function` (code-defined, runs in-process) or a `path` to a file on disk (file-based, delegated to a runner). Exactly one must be provided.
- **`SkillScriptRunner`** - Protocol for file-based script execution. Any callable matching `(skill, script, args) -> Any` satisfies it. Code-defined scripts do not use a runner.
@@ -147,6 +147,7 @@ from ._skills import (
InlineSkillScript,
InMemorySkillsSource,
Skill,
SkillFrontmatter,
SkillResource,
SkillScript,
SkillScriptRunner,
@@ -432,6 +433,7 @@ __all__ = [
"SessionContext",
"SingleEdgeGroup",
"Skill",
"SkillFrontmatter",
"SkillResource",
"SkillScript",
"SkillScriptRunner",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -429,7 +429,12 @@ class _FullHistoryReplayCoordinator(Executor):
@pytest.mark.xfail(
reason="reset_service_session support not yet implemented — see #4047",
reason=(
"Tracks the executor-layer half of #3295: AgentExecutor should clear service_session_id "
"when handed a full prior conversation. The wire-level 'Duplicate item' API error is "
"already closed by the chat-client strip in #3295; this xfail covers the defense-in-depth "
"follow-up that makes the executor wiring reflect intent."
),
strict=True,
)
async def test_run_request_with_full_history_clears_service_session_id() -> None:
@@ -96,7 +96,7 @@ def serve(
ui_enabled: bool = True,
instrumentation_enabled: bool = False,
mode: str = "developer",
auth_enabled: bool = False,
auth_enabled: bool = True,
auth_token: str | None = None,
) -> None:
"""Launch Agent Framework DevUI with simple API.
@@ -126,52 +126,29 @@ def serve(
if not isinstance(port, int) or not (1 <= port <= 65535):
raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535")
# Security check: Warn if network-exposed without authentication
# Security check: warn loudly when network-exposed without authentication.
if host not in ("127.0.0.1", "localhost") and not auth_enabled:
logger.warning("⚠️ WARNING: Exposing DevUI to network without authentication!")
logger.warning("⚠️ This is INSECURE - anyone on your network can access your agents")
logger.warning("💡 For network exposure, add --auth flag: devui --host 0.0.0.0 --auth")
logger.warning("WARNING: Exposing DevUI to the network with --no-auth.")
logger.warning("Anyone on your network can read agent metadata and trigger requests.")
logger.warning("Drop --no-auth and DevUI will require Bearer tokens.")
# Handle authentication configuration
if auth_enabled:
# Refuse to auto-generate a token for network-exposed binds. Auto-generated tokens
# are fine for localhost convenience; for anything else, require an explicit token.
if auth_enabled and not auth_token:
import os
import secrets
# Check if token is in environment variable first
if not auth_token:
auth_token = os.environ.get("DEVUI_AUTH_TOKEN")
# Auto-generate token if STILL not provided
if not auth_token:
# Check if we're in a production-like environment
env_token = os.environ.get("DEVUI_AUTH_TOKEN")
if not env_token:
is_production = (
host not in ("127.0.0.1", "localhost") # Exposed to network
or os.environ.get("CI") == "true" # Running in CI
or os.environ.get("KUBERNETES_SERVICE_HOST") # Running in k8s
host not in ("127.0.0.1", "localhost")
or os.environ.get("CI") == "true"
or os.environ.get("KUBERNETES_SERVICE_HOST")
)
if is_production:
# REFUSE to start without explicit token
logger.error("❌ Authentication enabled but no token provided")
logger.error("❌ Auto-generated tokens are NOT secure for network-exposed deployments")
logger.error("💡 Set token: export DEVUI_AUTH_TOKEN=<your-secure-token>")
logger.error("💡 Or pass: serve(entities=[...], auth_token='your-token')")
logger.error("Authentication required but no token provided.")
logger.error("Set DEVUI_AUTH_TOKEN env var or pass auth_token='...' to serve().")
raise ValueError("DEVUI_AUTH_TOKEN required when host is not localhost")
# Development mode: auto-generate and show
auth_token = secrets.token_urlsafe(32)
logger.info("🔒 Authentication enabled with auto-generated token")
logger.info("\n" + "=" * 70)
logger.info("🔑 DEV TOKEN (localhost only, shown once):")
logger.info(f" {auth_token}")
logger.info("=" * 70 + "\n")
else:
logger.info("🔒 Authentication enabled with provided token")
# Set environment variable for server to use
os.environ["AUTH_REQUIRED"] = "true"
os.environ["DEVUI_AUTH_TOKEN"] = auth_token
# Enable instrumentation if requested
if instrumentation_enabled:
from agent_framework.observability import enable_instrumentation
@@ -187,6 +164,8 @@ def serve(
cors_origins=cors_origins,
ui_enabled=ui_enabled,
mode=mode,
auth_enabled=auth_enabled,
auth_token=auth_token,
)
# Register in-memory entities if provided
@@ -79,15 +79,15 @@ Examples:
)
parser.add_argument(
"--auth",
"--no-auth",
action="store_true",
help="Enable authentication via Bearer token (required for deployed environments)",
help="Disable Bearer token authentication. DevUI is auth-enabled by default; use this to opt out.",
)
parser.add_argument(
"--auth-token",
type=str,
help="Custom authentication token (auto-generated if not provided with --auth)",
help="Custom Bearer token. Auto-generated and logged at startup when omitted.",
)
parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}")
@@ -184,7 +184,7 @@ def main() -> None:
ui_enabled=ui_enabled,
instrumentation_enabled=args.instrumentation,
mode=mode,
auth_enabled=args.auth,
auth_enabled=not args.no_auth,
auth_token=args.auth_token, # Pass through explicit token only
)
@@ -75,6 +75,8 @@ class DevServer:
cors_origins: list[str] | None = None,
ui_enabled: bool = True,
mode: str = "developer",
auth_enabled: bool = True,
auth_token: str | None = None,
) -> None:
"""Initialize the development server.
@@ -85,20 +87,26 @@ class DevServer:
cors_origins: List of allowed CORS origins
ui_enabled: Whether to enable the UI
mode: Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)
auth_enabled: Whether to require Bearer token auth on /v1/* endpoints. Defaults to True.
auth_token: Bearer token. If None and auth_enabled, falls back to the DEVUI_AUTH_TOKEN
environment variable, then to an auto-generated token (logged at startup).
"""
self.entities_dir = entities_dir
self.port = port
self.host = host
# Smart CORS defaults: permissive for localhost, restrictive for network-exposed deployments
# CORS default is same-origin only (empty allowlist) on every host. The
# previous wildcard-on-localhost default let any webpage the developer
# visited read DevUI's responses cross-origin. Callers who need a real
# cross-origin dev frontend pass an explicit allowlist.
if cors_origins is None:
# Localhost development: allow cross-origin for dev tools (e.g., frontend dev server)
# Network-exposed: empty list (same-origin only, no CORS)
cors_origins = ["*"] if host in ("127.0.0.1", "localhost") else []
cors_origins = []
self.cors_origins = cors_origins
self.ui_enabled = ui_enabled
self.mode = mode
self.auth_enabled = auth_enabled
self.auth_token = self._resolve_auth_token(auth_enabled, auth_token)
self.executor: AgentFrameworkExecutor | None = None
self.openai_executor: OpenAIExecutor | None = None
self.deployment_manager = DeploymentManager()
@@ -110,6 +118,37 @@ class DevServer:
"""Set in-memory entities to register on startup."""
self._pending_entities = entities
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "[::1]", "::1"})
def _loopback_allowed_hosts(self) -> frozenset[str] | None:
"""Return the Host-header allowlist when bound to a loopback interface, else None.
Returning None disables Host-header enforcement (e.g. for 0.0.0.0 / public binds,
where the operator is intentionally exposing the service).
"""
host = self.host.lower()
if host not in self._LOOPBACK_HOSTS:
return None
return self._LOOPBACK_HOSTS
@staticmethod
def _resolve_auth_token(auth_enabled: bool, auth_token: str | None) -> str | None:
"""Resolve the active Bearer token. Returns None when auth is disabled."""
if not auth_enabled:
return None
if auth_token:
return auth_token
env_token = os.getenv("DEVUI_AUTH_TOKEN")
if env_token:
return env_token
generated = secrets.token_urlsafe(32)
logger.info("=" * 70)
logger.info("DevUI authentication enabled with auto-generated token:")
logger.info(f" {generated}")
logger.info("Pass it as: Authorization: Bearer <token>")
logger.info("=" * 70)
return generated
def _is_dev_mode(self) -> bool:
"""Check if running in developer mode.
@@ -336,6 +375,11 @@ class DevServer:
lifespan=lifespan,
)
# Middleware registration order matters: Starlette wraps later-added
# middleware around earlier-added ones, so the LAST registered runs
# outermost (sees the request first). We want Host-header enforcement
# to run before CORS/auth, so it is registered last below.
# Add CORS middleware
# Note: allow_credentials cannot be True when allow_origins is ["*"]
# For localhost dev with wildcard origins, credentials are disabled
@@ -350,29 +394,24 @@ class DevServer:
allow_headers=["*"],
)
# Add authentication middleware using decorator pattern
# Auth is enabled by presence of DEVUI_AUTH_TOKEN
auth_token = os.getenv("DEVUI_AUTH_TOKEN", "")
auth_required = bool(auth_token)
if auth_required:
# Bearer-token authentication. Enabled by default; opt out via
# DevServer(auth_enabled=False) for embedded/test scenarios.
if self.auth_enabled and self.auth_token:
expected_token = self.auth_token
logger.info("Authentication middleware enabled")
@app.middleware("http")
async def auth_middleware(request: Request, call_next: Callable[[Request], Awaitable[Any]]) -> Any:
"""Validate Bearer token authentication.
Skips authentication for health, meta, static UI endpoints, and OPTIONS requests.
Skips authentication for health, the UI shell, static assets, and OPTIONS preflight.
"""
# Skip auth for OPTIONS (CORS preflight) requests
if request.method == "OPTIONS":
return await call_next(request)
# Skip auth for health checks, meta endpoint, and static files
if request.url.path in ["/health", "/meta", "/"] or request.url.path.startswith("/assets"):
if request.url.path in ["/health", "/"] or request.url.path.startswith("/assets"):
return await call_next(request)
# Check Authorization header
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return JSONResponse(
@@ -388,9 +427,8 @@ class DevServer:
},
)
# Extract and validate token
token = auth_header.replace("Bearer ", "", 1).strip()
if not secrets.compare_digest(token, auth_token):
if not secrets.compare_digest(token, expected_token):
return JSONResponse(
status_code=401,
content={
@@ -402,11 +440,40 @@ class DevServer:
},
)
# Token valid, proceed
return await call_next(request)
_ = auth_middleware
# Host-header allowlist for loopback binds: on a loopback interface, only
# accept requests whose Host header names a loopback address. Registered LAST
# so it runs outermost, rejecting non-loopback Host values before CORS/auth
# (and before CORS can short-circuit a preflight on a rebound request).
allowed_hosts = self._loopback_allowed_hosts()
if allowed_hosts is not None:
expected_hosts = allowed_hosts
@app.middleware("http")
async def host_header_middleware(request: Request, call_next: Callable[[Request], Awaitable[Any]]) -> Any:
host_header = request.headers.get("host", "")
hostname = host_header.split(":", 1)[0].lower()
if hostname and hostname not in expected_hosts:
return JSONResponse(
status_code=400,
content={
"error": {
"message": (
f"Invalid Host header '{host_header}'. DevUI is bound to a "
"loopback interface and only accepts requests addressed to it."
),
"type": "invalid_host",
"code": "host_not_allowed",
}
},
)
return await call_next(request)
_ = host_header_middleware
self._register_routes(app)
self._mount_ui(app)
@@ -427,8 +494,6 @@ class DevServer:
@app.get("/meta", response_model=MetaResponse)
async def get_meta() -> MetaResponse:
"""Get server metadata and configuration."""
import os
# Ensure executors are initialized to check capabilities
openai_executor = await self._ensure_openai_executor()
@@ -442,7 +507,7 @@ class DevServer:
"openai_proxy": openai_executor.is_configured,
"deployment": True, # Deployment feature is available
},
auth_required=bool(os.getenv("DEVUI_AUTH_TOKEN")),
auth_required=self.auth_enabled,
)
@app.get("/v1/entities", response_model=DiscoveryResponse)
@@ -750,7 +815,6 @@ class DevServer:
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
},
)
return await openai_executor.execute_sync(request)
@@ -794,7 +858,6 @@ class DevServer:
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"X-Response-ID": response_id, # Include ID for debugging/tracking
},
)
@@ -3,11 +3,15 @@
"""Focused tests for server functionality."""
import asyncio
import inspect
import tempfile
from pathlib import Path
import pytest
from conftest import MockAgent
from fastapi.testclient import TestClient
import agent_framework_devui
from agent_framework_devui import DevServer
from agent_framework_devui._utils import extract_executor_message_types, select_primary_input_type
from agent_framework_devui.models._openai_custom import AgentFrameworkRequest
@@ -99,11 +103,11 @@ async def test_server_execution_streaming(test_entities_dir):
def test_configuration():
"""Test basic configuration."""
server = DevServer(entities_dir="test", port=9000, host="localhost")
server = DevServer(entities_dir="test", port=9000, host="localhost", auth_enabled=False)
assert server.port == 9000
assert server.host == "localhost"
assert server.entities_dir == "test"
assert server.cors_origins == ["*"]
assert server.cors_origins == []
assert server.ui_enabled
@@ -252,15 +256,18 @@ async def test_api_restrictions_in_user_mode():
"""Test that developer APIs are restricted in user mode."""
from fastapi.testclient import TestClient
# Create servers with different modes
dev_server = DevServer(mode="developer")
user_server = DevServer(mode="user")
# Create servers with different modes. auth_enabled=False isolates this test
# to mode behavior — auth has its own dedicated suite.
dev_server = DevServer(mode="developer", auth_enabled=False)
user_server = DevServer(mode="user", auth_enabled=False)
dev_app = dev_server.create_app()
user_app = user_server.create_app()
dev_client = TestClient(dev_app)
user_client = TestClient(user_app)
# base_url sets the Host header to a loopback alias so the loopback
# host-header allowlist accepts the request.
dev_client = TestClient(dev_app, base_url="http://127.0.0.1")
user_client = TestClient(user_app, base_url="http://127.0.0.1")
# Test 1: Health endpoint should work in both modes
assert dev_client.get("/health").status_code == 200
@@ -403,3 +410,171 @@ async def test_checkpoint_api_endpoints(test_entities_dir):
# Test delete non-existent checkpoint
deleted = await storage.delete("nonexistent")
assert deleted is False
# =============================================================================
# Security posture: default CORS, auth, host-header, and streaming headers.
# =============================================================================
def _server_with_mock_agent(**kwargs) -> DevServer:
"""Build a DevServer with one in-memory mock agent registered."""
server = DevServer(**kwargs)
server.set_pending_entities([MockAgent(id="mock", name="Mock", response_text="hi")])
return server
def test_streaming_response_does_not_hardcode_acao_header():
"""A streaming /v1/responses must not set Access-Control-Allow-Origin itself.
The endpoint previously hardcoded `Access-Control-Allow-Origin: *` on the
StreamingResponse, bypassing CORSMiddleware. With no Origin header on the
request, CORSMiddleware never adds ACAO — so any ACAO we see proves the
streaming handler is still setting it.
"""
server = _server_with_mock_agent(auth_token="s3cret")
app = server.get_app()
with TestClient(app, base_url="http://127.0.0.1") as client:
response = client.post(
"/v1/responses",
json={"metadata": {"entity_id": "mock"}, "input": "hello", "stream": True},
headers={"Authorization": "Bearer s3cret"},
)
assert "access-control-allow-origin" not in {k.lower() for k in response.headers}, (
"Streaming response sets ACAO directly, bypassing CORSMiddleware"
)
def test_cors_default_does_not_allow_arbitrary_origin_even_on_localhost():
"""Default CORS must not echo Access-Control-Allow-Origin to arbitrary origins.
Previous default was `["*"]` on localhost binds, which let any webpage the
developer visited read DevUI's responses. Default is now `[]` — opt in by
passing `cors_origins=[...]` explicitly.
"""
server = _server_with_mock_agent(host="127.0.0.1", auth_token="s3cret")
app = server.get_app()
with TestClient(app, base_url="http://127.0.0.1") as client:
preflight = client.options(
"/v1/entities",
headers={
"Origin": "https://evil.example",
"Access-Control-Request-Method": "GET",
},
)
assert preflight.headers.get("access-control-allow-origin") not in ("*", "https://evil.example")
actual = client.get(
"/v1/entities",
headers={"Origin": "https://evil.example", "Authorization": "Bearer s3cret"},
)
assert actual.headers.get("access-control-allow-origin") not in ("*", "https://evil.example")
def test_devserver_requires_auth_by_default(monkeypatch):
"""A bare DevServer() must reject unauthenticated /v1/* requests.
Previously auth was opt-in via DEVUI_AUTH_TOKEN env var; the new default is
auth-on so a bare `devui ./agents` invocation does not expose an open API.
"""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
server = DevServer()
app = server.get_app()
with TestClient(app, base_url="http://127.0.0.1") as client:
response = client.get("/v1/entities")
assert response.status_code == 401
def test_devserver_auth_can_be_explicitly_disabled(monkeypatch):
"""Callers can opt out of auth with auth_enabled=False (escape hatch for tests / trusted hosts)."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
server = _server_with_mock_agent(auth_enabled=False)
app = server.get_app()
with TestClient(app, base_url="http://127.0.0.1") as client:
response = client.get("/v1/entities")
assert response.status_code == 200
def test_devserver_accepts_request_with_valid_bearer_token(monkeypatch):
"""When auth is on, supplying the configured Bearer token grants access."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
server = DevServer(auth_token="s3cret")
app = server.get_app()
with TestClient(app, base_url="http://127.0.0.1") as client:
response = client.get("/v1/entities", headers={"Authorization": "Bearer s3cret"})
assert response.status_code == 200
def test_meta_endpoint_requires_auth(monkeypatch):
"""/meta exposes capability flags (deployment, instrumentation, version) — gate it behind auth.
Previously /meta was in the auth-bypass list alongside /health and /, so any
unauthenticated caller could read the deployment's capability flags.
"""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
server = DevServer(auth_token="s3cret")
app = server.get_app()
with TestClient(app, base_url="http://127.0.0.1") as client:
unauth = client.get("/meta")
assert unauth.status_code == 401
ok = client.get("/meta", headers={"Authorization": "Bearer s3cret"})
assert ok.status_code == 200
def test_loopback_bind_rejects_non_allowlisted_host_header(monkeypatch):
"""A loopback-bound server must reject requests with a non-loopback Host header.
On a loopback bind, only Host values that name a loopback address are valid;
anything else (e.g. an external hostname that happens to resolve to 127.0.0.1)
is rejected before any handler runs.
"""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
server = DevServer(host="127.0.0.1", auth_enabled=False)
app = server.get_app()
with TestClient(app, base_url="http://127.0.0.1") as client:
rebound = client.get("/health", headers={"Host": "evil.example"})
assert rebound.status_code == 400
ok = client.get("/health", headers={"Host": "127.0.0.1"})
assert ok.status_code == 200
ok_localhost = client.get("/health", headers={"Host": "localhost:8080"})
assert ok_localhost.status_code == 200
def test_serve_defaults_to_auth_enabled():
"""`serve()`'s public signature must default to auth_enabled=True."""
sig = inspect.signature(agent_framework_devui.serve)
assert sig.parameters["auth_enabled"].default is True, (
"serve() must default to auth_enabled=True so `devui ./agents` is secure out of the box"
)
def test_cli_enables_auth_by_default_and_supports_no_auth_optout():
"""`devui ./agents` must produce auth-enabled config; `--no-auth` is the explicit escape hatch."""
from agent_framework_devui._cli import create_cli_parser
parser = create_cli_parser()
default_args = parser.parse_args([])
assert default_args.no_auth is False, "Default CLI invocation should leave auth on"
optout_args = parser.parse_args(["--no-auth"])
assert optout_args.no_auth is True
@@ -582,7 +582,7 @@ def test_sample_peak_renderer_rss_mb_uses_browser_process_tree(
def memory_regression_server() -> Generator[tuple[str, str]]:
"""Start DevUI with a synthetic streaming agent and yield the base URL plus entity ID."""
server = DevServer(host="127.0.0.1", port=0)
server = DevServer(host="127.0.0.1", port=0, auth_enabled=False)
server.register_entities([
MemoryStressAgent(
id="memory-stream-agent",
@@ -435,7 +435,7 @@ async def test_chat_message_parsing_with_function_calls() -> None:
Message(role="tool", contents=[function_result]),
]
prepared_messages = client._prepare_messages_for_openai(messages)
prepared_messages = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
assert prepared_messages == [
{
@@ -1409,29 +1409,31 @@ class RawOpenAIChatClient( # type: ignore[misc]
}
additional_properties = message.additional_properties
replays_local_storage = "_attribution" in additional_properties
uses_service_side_storage = request_uses_service_side_storage and not replays_local_storage
# Reasoning items are only valid in input when they directly preceded a function_call
# in the same response. Including a reasoning item that preceded a text response
# (i.e. no function_call in the same message) causes an API error:
# "reasoning was provided without its required following item."
#
# Local storage is stricter: response-scoped reasoning items (rs_*) cannot be replayed
# back to the service unless that message is using service-side storage.
# In that mode we omit reasoning items and rely on function call + tool output replay.
has_function_call = any(c.type == "function_call" for c in message.contents)
# Server-issued response item identities (function_call fc_*, reasoning rs_*, approval IDs,
# local-shell-call IDs) must not be re-sent inline when the request carries
# previous_response_id / conversation_id / conversation: the server already has them via
# the prior response and rejects duplicates with "Duplicate item found with id ...".
# function_result keeps its call_id and the server pairs it to the prior function_call via
# that key. See microsoft/agent-framework#3295. The strip is gated on the request-level
# flag, not a message-level one: HistoryProvider-attributed messages
# (replays_local_storage) still need stripping when the request also carries a continuation
# marker, since the server-stored items would otherwise duplicate the inline ones. Without
# storage, standalone reasoning items are invalid per the API ("reasoning was provided
# without its required following item"), so the reasoning branch always drops.
for content in message.contents:
match content.type:
case "text_reasoning":
if not uses_service_side_storage or not has_function_call:
continue # reasoning not followed by a function_call is invalid in input
reasoning = self._prepare_content_for_openai(
message.role,
content,
replays_local_storage=replays_local_storage,
)
if reasoning:
all_messages.append(reasoning)
continue
case "function_result":
if request_uses_service_side_storage:
props = content.additional_properties or {}
# Local-shell variant serializes as `local_shell_call` carrying a server-issued id;
# plain function_call_output pairs by call_id and is safe under storage.
if (
props.get(OPENAI_SHELL_OUTPUT_TYPE_KEY) == OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL
and props.get(OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY)
):
continue
new_args: dict[str, Any] = {}
new_args.update(
self._prepare_content_for_openai(
@@ -1443,6 +1445,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
if new_args:
all_messages.append(new_args)
case "function_call":
if request_uses_service_side_storage:
continue
function_call = self._prepare_content_for_openai(
message.role,
content,
@@ -1451,6 +1455,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
if function_call:
all_messages.append(function_call)
case "function_approval_response" | "function_approval_request":
if request_uses_service_side_storage:
continue
prepared = self._prepare_content_for_openai(
message.role,
content,
@@ -1463,6 +1469,12 @@ class RawOpenAIChatClient( # type: ignore[misc]
# top-level mcp_call input item; the result side emits an
# internal marker that `_prepare_messages_for_openai`
# coalesces onto the matching call (or drops if unmatched).
# The mcp_call item carries the model-emitted call_id as its
# server-side `id`, so under continuation it would duplicate
# the prior response's items (#3295). Drop the call here; the
# orphan result is dropped by the coalesce step that follows.
if request_uses_service_side_storage:
continue
prepared_mcp = self._prepare_content_for_openai(
message.role,
content,
@@ -1,6 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import base64
import inspect
import json
@@ -121,15 +120,6 @@ async def create_vector_store(
if result.last_error is not None:
raise Exception(f"Vector store file processing failed with status: {result.last_error.message}")
# Wait for the vector store index to be fully searchable.
# create_and_poll confirms file processing, but the search index is eventually consistent.
for _ in range(10):
vs = await client.client.vector_stores.retrieve(vector_store.id)
if vs.file_counts.completed >= 1 and vs.file_counts.in_progress == 0:
break
await asyncio.sleep(1)
await asyncio.sleep(2)
return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id)
@@ -343,76 +333,6 @@ async def test_get_response_with_all_parameters() -> None:
assert run_options["input"][1]["content"][0]["text"] == "Test message"
def test_openai_chat_options_declares_verbosity_field() -> None:
"""OpenAIChatOptions declares verbosity as a typed Literal field."""
from typing import get_args, get_type_hints
from agent_framework_openai import OpenAIChatOptions
annotations = get_type_hints(OpenAIChatOptions)
assert "verbosity" in annotations
assert {"low", "medium", "high"} <= set(get_args(annotations["verbosity"]))
async def test_verbosity_option_translates_to_text_field() -> None:
"""Top-level verbosity is translated to text.verbosity for the Responses API."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
_, run_options, _ = await client._prepare_request(
messages=[Message(role="user", contents=["Test message"])],
options={"verbosity": "low"},
)
assert "verbosity" not in run_options
assert run_options["text"] == {"verbosity": "low"}
async def test_verbosity_option_merges_with_response_format() -> None:
"""Verbosity merges into text config alongside response_format-derived format."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
_, run_options, _ = await client._prepare_request(
messages=[Message(role="user", contents=["Test message"])],
options={
"verbosity": "high",
"response_format": OutputStruct,
},
)
assert "verbosity" not in run_options
assert run_options["text"]["verbosity"] == "high"
assert run_options["text_format"] is OutputStruct
async def test_verbosity_option_top_level_overrides_nested_text_verbosity() -> None:
"""When both top-level and text['verbosity'] are set, the top-level value wins."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
_, run_options, _ = await client._prepare_request(
messages=[Message(role="user", contents=["Test message"])],
options={
"verbosity": "high",
"text": {"verbosity": "low"},
},
)
assert "verbosity" not in run_options
assert run_options["text"]["verbosity"] == "high"
async def test_verbosity_option_merges_with_explicit_text_config() -> None:
"""Verbosity merges into a user-provided text config without overwriting other keys."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
_, run_options, _ = await client._prepare_request(
messages=[Message(role="user", contents=["Test message"])],
options={
"verbosity": "medium",
"text": {"format": {"type": "text"}},
},
)
assert "verbosity" not in run_options
assert run_options["text"]["verbosity"] == "medium"
assert run_options["text"]["format"] == {"type": "text"}
@pytest.mark.asyncio
async def test_web_search_tool_with_location() -> None:
"""Test web search tool with location parameters."""
@@ -518,7 +438,7 @@ async def test_chat_message_parsing_with_function_calls() -> None:
Message(role="tool", contents=[function_result]),
]
prepared_messages = client._prepare_messages_for_openai(messages)
prepared_messages = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
assert prepared_messages == [
{
@@ -1834,7 +1754,7 @@ def test_prepare_message_for_openai_with_function_approval_response() -> None:
message = Message(role="user", contents=[approval_response])
result = client._prepare_message_for_openai(message)
result = client._prepare_message_for_openai(message, request_uses_service_side_storage=False)
# FunctionApprovalResponseContent is added directly, not nested in args with role
assert len(result) == 1
@@ -1866,16 +1786,20 @@ def test_prepare_message_for_openai_includes_reasoning_with_function_call() -> N
message = Message(role="assistant", contents=[reasoning, function_call])
result = client._prepare_message_for_openai(message)
# Storage-on path strips both server-issued reasoning (rs_*) and function_call items
# because the server already has them via previous_response_id (#3295).
storage_on_result = client._prepare_message_for_openai(message, request_uses_service_side_storage=True)
storage_on_types = [item["type"] for item in storage_on_result]
assert "reasoning" not in storage_on_types
assert "function_call" not in storage_on_types
# Both reasoning and function_call should be present as top-level items
types = [item["type"] for item in result]
assert "reasoning" in types, "Reasoning items must be included for reasoning models"
assert "function_call" in types
reasoning_item = next(item for item in result if item["type"] == "reasoning")
assert reasoning_item["summary"][0]["text"] == "Let me analyze the request"
assert reasoning_item["id"] == "rs_abc123", "Reasoning id must be preserved for the API"
# Storage-off path keeps function_call inline so the server sees the call. Reasoning items
# cannot be replayed inline against a server that has no record of the prior response, so
# they remain dropped on this path as well.
storage_off_result = client._prepare_message_for_openai(message, request_uses_service_side_storage=False)
storage_off_types = [item["type"] for item in storage_off_result]
assert "function_call" in storage_off_types
assert "reasoning" not in storage_off_types
def test_prepare_messages_for_openai_full_conversation_with_reasoning() -> None:
@@ -1920,27 +1844,20 @@ def test_prepare_messages_for_openai_full_conversation_with_reasoning() -> None:
),
]
result = client._prepare_messages_for_openai(messages)
# Storage-off path: function_call kept inline (server has no record of it),
# function_call_output kept. Reasoning is still dropped because rs_* response-scoped IDs
# cannot be replayed against a server that has no record of the originating response.
result = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
types = [item.get("type") for item in result]
assert "message" in types, "User/assistant messages should be present"
assert "reasoning" in types, "Reasoning items must be present"
assert "function_call" in types, "Function call items must be present"
assert "function_call" in types, "Function call items must be present without storage"
assert "function_call_output" in types, "Function call output must be present"
# Verify reasoning has id
reasoning_items = [item for item in result if item.get("type") == "reasoning"]
assert reasoning_items[0]["id"] == "rs_test123"
# Verify function_call has id
fc_items = [item for item in result if item.get("type") == "function_call"]
assert fc_items[0]["id"] == "fc_test456"
# Verify correct ordering: reasoning before function_call
reasoning_idx = types.index("reasoning")
fc_idx = types.index("function_call")
assert reasoning_idx < fc_idx, "Reasoning must come before function_call"
def test_prepare_message_for_openai_filters_error_content() -> None:
"""Test that error content in messages is handled properly."""
@@ -4082,7 +3999,13 @@ async def test_prepare_options_store_false_omits_reasoning_items_for_stateless_r
assert any(item.get("type") == "function_call_output" for item in options["input"])
async def test_prepare_options_with_conversation_id_keeps_reasoning_items() -> None:
async def test_prepare_options_with_conversation_id_strips_server_issued_items() -> None:
"""When the request continues via conversation_id / previous_response_id, server-issued
response items (reasoning rs_*, function_call fc_*) must not be re-sent inline. The server
already has them via the prior response and rejects duplicates with
'Duplicate item found with id ...'. The function_result keeps its call_id so the server
pairs result-to-call. See microsoft/agent-framework#3295. (Originally added in #5250 with
the opposite expectation; field reports proved that path 400s on the wire.)"""
client = OpenAIChatClient(model="test-model", api_key="test-key")
messages = [
Message(role="user", contents=[Content.from_text(text="search for hotels")]),
@@ -4118,13 +4041,16 @@ async def test_prepare_options_with_conversation_id_keeps_reasoning_items() -> N
ChatOptions(store=False, conversation_id="resp_prev123"), # type: ignore[arg-type]
)
reasoning_items = [item for item in options["input"] if item.get("type") == "reasoning"]
assert len(reasoning_items) == 1
assert reasoning_items[0]["id"] == "rs_test123"
types = [item.get("type") for item in options["input"]]
assert "reasoning" not in types
assert "function_call" not in types
assert "function_call_output" in types
output_item = next(item for item in options["input"] if item.get("type") == "function_call_output")
assert output_item["call_id"] == "call_1"
assert options["previous_response_id"] == "resp_prev123"
async def test_prepare_options_with_conversation_id_omits_reasoning_items_for_attributed_replay() -> None:
async def test_prepare_options_with_conversation_id_strips_server_items_for_mixed_history_and_live() -> None:
client = OpenAIChatClient(model="test-model", api_key="test-key")
messages = [
Message(role="user", contents=[Content.from_text(text="search for hotels")]),
@@ -4186,19 +4112,18 @@ async def test_prepare_options_with_conversation_id_omits_reasoning_items_for_at
ChatOptions(store=False, conversation_id="resp_prev123"), # type: ignore[arg-type]
)
reasoning_items = [item for item in options["input"] if item.get("type") == "reasoning"]
assert [item["id"] for item in reasoning_items] == ["rs_live123"]
assert any(
item.get("type") == "function_call" and item.get("call_id") == "call_history" for item in options["input"]
)
assert any(item.get("type") == "function_call" and item.get("call_id") == "call_live" for item in options["input"])
assert any(
item.get("type") == "function_call_output" and item.get("call_id") == "call_history"
for item in options["input"]
)
assert any(
item.get("type") == "function_call_output" and item.get("call_id") == "call_live" for item in options["input"]
)
# Under continuation (request_uses_service_side_storage=True), the strip rule fires for
# every server-issued item type regardless of message attribution: history-attributed items
# would duplicate the prior response stored at resp_prev123, and live items would also
# eventually duplicate items stored on the response this request produces. Function results
# are kept; the server pairs them to prior function_calls via call_id (#3295).
types = [item.get("type") for item in options["input"]]
assert "reasoning" not in types
assert "function_call" not in types
output_call_ids = {
item["call_id"] for item in options["input"] if item.get("type") == "function_call_output"
}
assert output_call_ids == {"call_history", "call_live"}
assert options["previous_response_id"] == "resp_prev123"
@@ -4465,6 +4390,10 @@ async def test_integration_web_search() -> None:
assert response.text is not None
@pytest.mark.skip(
reason="Unreliable due to OpenAI vector store indexing potential "
"race condition. See https://github.com/microsoft/agent-framework/issues/1669"
)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_openai_integration_tests_disabled
@@ -4474,29 +4403,31 @@ async def test_integration_file_search() -> None:
assert isinstance(openai_responses_client, SupportsChatGetResponse)
file_id, vector_store = await create_vector_store(openai_responses_client)
try:
# Use static method for file search tool
file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
# Test that the client will use the file search tool
response = await openai_responses_client.get_response(
messages=[
Message(
role="user",
contents=["What is the weather today? Do a file search to find the answer."],
)
],
options={
"tool_choice": "auto",
"tools": [file_search_tool],
},
)
# Use static method for file search tool
file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
# Test that the client will use the file search tool
response = await openai_responses_client.get_response(
messages=[
Message(
role="user",
contents=["What is the weather today? Do a file search to find the answer."],
)
],
options={
"tool_choice": "auto",
"tools": [file_search_tool],
},
)
assert "sunny" in response.text.lower()
assert "75" in response.text
finally:
await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id)
await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id)
assert "sunny" in response.text.lower()
assert "75" in response.text
@pytest.mark.skip(
reason="Unreliable due to OpenAI vector store indexing "
"potential race condition. See https://github.com/microsoft/agent-framework/issues/1669"
)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_openai_integration_tests_disabled
@@ -4506,37 +4437,35 @@ async def test_integration_streaming_file_search() -> None:
assert isinstance(openai_responses_client, SupportsChatGetResponse)
file_id, vector_store = await create_vector_store(openai_responses_client)
try:
# Use static method for file search tool
file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
# Test that the client will use the file search tool
response = openai_responses_client.get_response(
messages=[
Message(
role="user",
contents=["What is the weather today? Do a file search to find the answer."],
)
],
stream=True,
options={
"tool_choice": "auto",
"tools": [file_search_tool],
},
)
# Use static method for file search tool
file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
# Test that the client will use the web search tool
response = openai_responses_client.get_streaming_response(
messages=[
Message(
role="user",
contents=["What is the weather today? Do a file search to find the answer."],
)
],
options={
"tool_choice": "auto",
"tools": [file_search_tool],
},
)
assert response is not None
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if content.type == "text" and content.text:
full_message += content.text
assert response is not None
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if content.type == "text" and content.text:
full_message += content.text
assert "sunny" in full_message.lower()
assert "75" in full_message
finally:
await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id)
await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id)
assert "sunny" in full_message.lower()
assert "75" in full_message
@pytest.mark.flaky
@@ -5059,7 +4988,10 @@ async def test_prepare_messages_for_openai_does_not_replay_fc_id_when_loaded_fro
next_turn_input = Message(role="user", contents=[Content.from_text(text="Book the cheapest one")])
live_result = client._prepare_messages_for_openai([*session.state[provider.source_id]["messages"], next_turn_input])
live_result = client._prepare_messages_for_openai(
[*session.state[provider.source_id]["messages"], next_turn_input],
request_uses_service_side_storage=False,
)
live_function_call = next(item for item in live_result if item.get("type") == "function_call")
assert live_function_call["id"] == "fc_provider123"
@@ -5072,7 +5004,8 @@ async def test_prepare_messages_for_openai_does_not_replay_fc_id_when_loaded_fro
) # type: ignore[arg-type]
loaded_result = client._prepare_messages_for_openai(
context.get_messages(sources={provider.source_id}, include_input=True)
context.get_messages(sources={provider.source_id}, include_input=True),
request_uses_service_side_storage=False,
)
loaded_function_call = next(item for item in loaded_result if item.get("type") == "function_call")
assert loaded_function_call["id"] == "fc_call_1"
@@ -5091,7 +5024,8 @@ async def test_prepare_messages_for_openai_does_not_replay_fc_id_when_loaded_fro
) # type: ignore[arg-type]
restored_result = client._prepare_messages_for_openai(
restored_context.get_messages(sources={provider.source_id}, include_input=True)
restored_context.get_messages(sources={provider.source_id}, include_input=True),
request_uses_service_side_storage=False,
)
restored_function_call = next(item for item in restored_result if item.get("type") == "function_call")
assert restored_function_call["id"] == "fc_call_1"
@@ -5125,7 +5059,9 @@ def test_prepare_messages_for_openai_keeps_live_fc_id_separate_from_replayed_his
],
)
result = client._prepare_messages_for_openai([history_message, live_message])
result = client._prepare_messages_for_openai(
[history_message, live_message], request_uses_service_side_storage=False
)
function_calls = [item for item in result if item.get("type") == "function_call"]
assert [item["id"] for item in function_calls] == ["fc_call_1", "fc_live123"]
@@ -5163,7 +5099,7 @@ def test_prepare_messages_for_openai_filters_empty_fc_id() -> None:
),
]
result = client._prepare_messages_for_openai(messages)
result = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
# Find the function_call items in the result
fc_items = [item for item in result if item.get("type") == "function_call"]
@@ -5198,7 +5134,7 @@ def test_prepare_messages_for_openai_filters_none_fc_id() -> None:
),
]
result = client._prepare_messages_for_openai(messages)
result = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
# Find the function_call item
fc_items = [item for item in result if item.get("type") == "function_call"]
@@ -5233,7 +5169,7 @@ def test_prepare_messages_for_openai_serializes_mcp_server_tool_call_as_mcp_call
),
]
result = client._prepare_messages_for_openai(messages)
result = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
mcp_items = [item for item in result if isinstance(item, dict) and item.get("type") == "mcp_call"]
assert len(mcp_items) == 1, f"expected exactly one mcp_call item; got result={result}"
@@ -5276,7 +5212,7 @@ def test_prepare_messages_for_openai_coalesces_mcp_call_and_result_into_single_i
),
]
result = client._prepare_messages_for_openai(messages)
result = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
mcp_items = [item for item in result if isinstance(item, dict) and item.get("type") == "mcp_call"]
assert len(mcp_items) == 1, f"expected one coalesced mcp_call item carrying both arguments and output; got {result}"
@@ -5310,7 +5246,7 @@ def test_prepare_messages_for_openai_drops_orphan_mcp_server_tool_result() -> No
),
]
result = client._prepare_messages_for_openai(messages)
result = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
fco_items = [item for item in result if isinstance(item, dict) and item.get("type") == "function_call_output"]
assert fco_items == [], f"orphan mcp_server_tool_result must not serialize as function_call_output; got {fco_items}"
@@ -5342,4 +5278,170 @@ def test_stringify_mcp_output_falls_back_to_json_for_non_text_dict_entries() ->
# endregion
# region: strip server-issued item IDs under storage (issue #3295)
def _strip_rule_messages() -> list[Message]:
return [
Message(role="user", contents=[Content.from_text(text="search hotels in Paris")]),
Message(
role="assistant",
contents=[
Content.from_function_call(
call_id="call_1",
name="search_hotels",
arguments='{"city": "Paris"}',
additional_properties={"fc_id": "fc_server_issued"},
),
],
),
Message(
role="tool",
contents=[Content.from_function_result(call_id="call_1", result="Found 3 hotels in Paris")],
),
]
def test_prepare_messages_strips_function_call_under_storage() -> None:
"""Regression for #3295: when previous_response_id / conversation_id is in flight, the chat
client must not re-send server-issued function_call items inline. The server already has them
via the prior response and rejects duplicates with 'Duplicate item found with id fc_...'.
The function_result keeps its call_id so the server can pair result-to-call."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
result = client._prepare_messages_for_openai(_strip_rule_messages(), request_uses_service_side_storage=True)
types = [item.get("type") for item in result]
assert "function_call" not in types
assert "function_call_output" in types
output_item = next(item for item in result if item.get("type") == "function_call_output")
assert output_item["call_id"] == "call_1"
def test_prepare_messages_keeps_function_call_without_storage() -> None:
"""Without storage there is no previous_response_id, so inline function_call items are the
only source of truth for the server. Behavior is byte-identical to pre-#3295."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
result = client._prepare_messages_for_openai(_strip_rule_messages(), request_uses_service_side_storage=False)
types = [item.get("type") for item in result]
assert "function_call" in types
assert "function_call_output" in types
fc_item = next(item for item in result if item.get("type") == "function_call")
assert fc_item["call_id"] == "call_1"
assert fc_item["id"] == "fc_server_issued"
output_item = next(item for item in result if item.get("type") == "function_call_output")
assert output_item["call_id"] == "call_1"
def test_prepare_messages_strips_approval_items_under_storage() -> None:
"""Approval request/response items also carry server-issued IDs and must be stripped under
storage. Without storage they are kept (#3295)."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
function_call = Content.from_function_call(
call_id="mcp_1",
name="sensitive_action",
arguments='{"action": "delete"}',
)
approval_request = Content.from_function_approval_request(
id="approval_req_1",
function_call=function_call,
)
approval_response = Content.from_function_approval_response(
approved=True,
id="approval_req_1",
function_call=function_call,
)
messages = [
Message(role="assistant", contents=[approval_request]),
Message(role="user", contents=[approval_response]),
]
storage_on = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=True)
storage_on_types = [item.get("type") for item in storage_on]
assert "mcp_approval_request" not in storage_on_types
assert "mcp_approval_response" not in storage_on_types
storage_off = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
storage_off_types = [item.get("type") for item in storage_off]
assert "mcp_approval_request" in storage_off_types
assert "mcp_approval_response" in storage_off_types
def test_prepare_messages_strips_local_shell_call_under_storage() -> None:
"""Local-shell-call function_results carry a server-issued local_shell_call_item_id and must
be stripped under storage. Plain function_results (no shell ID) are kept either way (#3295)."""
from agent_framework_openai._chat_client import (
OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY,
OPENAI_SHELL_OUTPUT_TYPE_KEY,
OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL,
)
client = OpenAIChatClient(model="test-model", api_key="test-key")
shell_result = Content.from_function_result(
call_id="shell_1",
result="ok",
additional_properties={
OPENAI_SHELL_OUTPUT_TYPE_KEY: OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL,
OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY: "lsh_server_issued",
},
)
plain_result = Content.from_function_result(call_id="plain_1", result="plain")
message = Message(role="tool", contents=[shell_result, plain_result])
storage_on = client._prepare_message_for_openai(message, request_uses_service_side_storage=True)
types_on = [item.get("type") for item in storage_on]
assert OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL not in types_on
assert "function_call_output" in types_on
storage_off = client._prepare_message_for_openai(message, request_uses_service_side_storage=False)
types_off = [item.get("type") for item in storage_off]
assert OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL in types_off
assert "function_call_output" in types_off
def test_prepare_messages_strips_mcp_items_under_storage() -> None:
"""Hosted-MCP tool call items carry server-issued IDs (the call_id surfaces as `id` on the
wire mcp_call item), so they must be stripped under storage. The orphan mcp_server_tool_result
is then dropped by the existing coalesce logic (#5581). Without storage, the call/result pair
coalesces normally into a single mcp_call wire item (#3295)."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
messages = [
Message(
role="assistant",
contents=[
Content.from_mcp_server_tool_call(
call_id="mcp_abc123",
tool_name="search",
server_name="api_specs",
arguments='{"q": "cats"}',
)
],
),
Message(
role="tool",
contents=[
Content.from_mcp_server_tool_result(
call_id="mcp_abc123",
output=[Content.from_text(text="found 10 cats")],
)
],
),
]
storage_on = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=True)
storage_on_types = [item.get("type") for item in storage_on]
assert "mcp_call" not in storage_on_types
storage_off = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
storage_off_types = [item.get("type") for item in storage_off]
assert "mcp_call" in storage_off_types
# endregion
# endregion
@@ -11,7 +11,7 @@ import os
from textwrap import dedent
from typing import Any
from agent_framework import Agent, InlineSkill, InlineSkillResource, SkillsProvider
from agent_framework import Agent, InlineSkill, InlineSkillResource, SkillFrontmatter, SkillsProvider
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
@@ -47,8 +47,9 @@ load_dotenv()
# 1. Static Resources — inline content passed at construction time
# ---------------------------------------------------------------------------
unit_converter_skill = InlineSkill(
name="unit-converter",
description="Convert between common units using a conversion factor",
frontmatter=SkillFrontmatter(
name="unit-converter", description="Convert between common units using a conversion factor"
),
instructions=dedent("""\
Use this skill when the user asks to convert between units.
@@ -1,6 +1,12 @@
---
name: unit-converter
description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.
license: MIT
compatibility: Works with any model that supports tool use.
allowed-tools: convert
metadata:
author: agent-framework-samples
version: "1.0"
---
## Usage
@@ -21,6 +21,7 @@ from agent_framework import (
FileSkillsSource,
InlineSkill,
InMemorySkillsSource,
SkillFrontmatter,
SkillsProvider,
)
from agent_framework.foundry import FoundryChatClient
@@ -73,8 +74,9 @@ load_dotenv()
# ---------------------------------------------------------------------------
volume_converter_skill = InlineSkill(
name="volume-converter",
description="Convert between gallons and liters using a conversion factor",
frontmatter=SkillFrontmatter(
name="volume-converter", description="Convert between gallons and liters using a conversion factor"
),
instructions=dedent("""\
Use this skill when the user asks to convert between gallons and liters.
@@ -118,6 +120,7 @@ def convert_volume(value: float, factor: float) -> str:
# 2. Define a class-based skill for temperature conversion
# ---------------------------------------------------------------------------
class TemperatureConverterSkill(ClassSkill):
"""A temperature-converter skill defined as a Python class.
@@ -127,8 +130,10 @@ class TemperatureConverterSkill(ClassSkill):
def __init__(self) -> None:
super().__init__(
name="temperature-converter",
description="Convert between temperature scales (Fahrenheit, Celsius, Kelvin).",
frontmatter=SkillFrontmatter(
name="temperature-converter",
description="Convert between temperature scales (Fahrenheit, Celsius, Kelvin).",
)
)
@property
@@ -178,6 +183,7 @@ class TemperatureConverterSkill(ClassSkill):
# 3. Wire everything together and run the agent
# ---------------------------------------------------------------------------
async def main() -> None:
"""Run the combined skills demo."""
endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
@@ -1,6 +1,12 @@
---
name: unit-converter
description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.
license: MIT
compatibility: Works with any model that supports tool use.
allowed-tools: convert
metadata:
author: agent-framework-samples
version: "1.0"
---
## Usage
@@ -9,7 +9,7 @@ import os
# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning)
from textwrap import dedent
from agent_framework import Agent, InlineSkill, SkillsProvider
from agent_framework import Agent, InlineSkill, SkillFrontmatter, SkillsProvider
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
@@ -43,8 +43,9 @@ load_dotenv()
# Define a code skill with a script that performs a sensitive operation
deployment_skill = InlineSkill(
name="deployment",
description="Tools for deploying application versions to production",
frontmatter=SkillFrontmatter(
name="deployment", description="Tools for deploying application versions to production"
),
instructions=dedent("""\
Use this skill when the user asks to deploy an application.
@@ -75,7 +75,7 @@ async def main() -> None:
FilteringSkillsSource(
FileSkillsSource(str(skills_dir), script_runner=subprocess_script_runner),
# Only keep the volume-converter skill
predicate=lambda s: s.name != "length-converter",
predicate=lambda s: s.frontmatter.name != "length-converter",
)
)
@@ -1,6 +1,12 @@
---
name: length-converter
description: Convert between common length units (miles, km, feet, meters) using a multiplication factor.
license: MIT
compatibility: Works with any model that supports tool use.
allowed-tools: convert
metadata:
author: agent-framework-samples
version: "1.0"
---
## Usage
@@ -1,6 +1,12 @@
---
name: volume-converter
description: Convert between gallons and liters using a conversion factor.
license: MIT
compatibility: Works with any model that supports tool use.
allowed-tools: convert
metadata:
author: agent-framework-samples
version: "1.0"
---
## Usage