From d77776b0b3b81dbf702a267ececf7650951990f0 Mon Sep 17 00:00:00 2001 From: "Reno (Copilot)" Date: Thu, 28 May 2026 17:27:19 +0300 Subject: [PATCH 01/20] feat: Squad.Agents.AI community NuGet for MAF integration Squad CLI as Microsoft.Extensions.AI IChatClient, composing GitHub.Copilot.SDK via AsAIAgent() from Microsoft.Agents.AI.GitHub.Copilot 1.7.0-preview. Closes Track A of the Q1-Q7 design lock (see tamresearch1 .squad/decisions.md Decisions 441, 443, 444, 447). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 5 + src/Squad.Agents.AI/README.md | 83 +++++++++ src/Squad.Agents.AI/Squad.Agents.AI.csproj | 31 ++++ src/Squad.Agents.AI/SquadAgent.cs | 161 ++++++++++++++++++ src/Squad.Agents.AI/SquadAgentOptions.cs | 39 +++++ src/Squad.Agents.AI/SquadConnectionFactory.cs | 101 +++++++++++ .../SquadServiceCollectionExtensions.cs | 59 +++++++ 7 files changed, 479 insertions(+) create mode 100644 src/Squad.Agents.AI/README.md create mode 100644 src/Squad.Agents.AI/Squad.Agents.AI.csproj create mode 100644 src/Squad.Agents.AI/SquadAgent.cs create mode 100644 src/Squad.Agents.AI/SquadAgentOptions.cs create mode 100644 src/Squad.Agents.AI/SquadConnectionFactory.cs create mode 100644 src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs diff --git a/.gitignore b/.gitignore index 8cfd30156..4cb4dbff8 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,8 @@ docs/tests/screenshots/ # Squad: PR-1200 picard local artifacts (do not commit) .pr-body-new.md .followup-issue-body.md + +# .NET build output +bin/ +obj/ +artifacts/ diff --git a/src/Squad.Agents.AI/README.md b/src/Squad.Agents.AI/README.md new file mode 100644 index 000000000..3fefc6c2b --- /dev/null +++ b/src/Squad.Agents.AI/README.md @@ -0,0 +1,83 @@ +# Squad.Agents.AI + +Community NuGet that exposes the Squad multi-agent CLI as a `Microsoft.Extensions.AI.AIAgent`, composable into any Microsoft Agent Framework (MAF) app or workflow. + +## Quick start — with Aspire (recommended) + +In your AppHost (uses `CommunityToolkit.Aspire.Hosting.Squad`): + +```csharp +var squad = builder.AddSquad("squad") + .WithTeamRoot("../my-team"); + +var api = builder.AddProject("api") + .WithReference(squad); +``` + +In your `MyApi` (Program.cs): + +```csharp +builder.Services.AddSquadAgent(); // picks up ConnectionStrings__squad +``` + +That's it. Aspire injects the connection string; SquadAgent parses it via `SquadConnectionFactory.FromConnectionString` and configures the underlying `CopilotClient`. + +## Standalone (advanced — no Aspire) + +If you're not using Aspire, configure directly: + +```csharp +builder.Services.AddSquadAgent(opts => +{ + opts.SquadFolderPath = "C:/my-team"; + opts.GitHubToken = Environment.GetEnvironmentVariable("GH_TOKEN"); +}); +``` + +## What you get + +- **`SquadAgent : AIAgent`** — composes `GitHubCopilotAgent` from `Microsoft.Agents.AI.GitHub.Copilot 1.7.0-preview.*` +- **`SquadAgentOptions`** — DI-friendly options for CLI path, args, cwd, env, token +- **`SquadConnectionFactory.FromConnectionString`** — Hybrid PATH+URI parser +- **`IServiceCollection.AddSquadAgent(...)`** — DI registration + +## Wire-format compatibility + +Track B (`CommunityToolkit.Aspire.Hosting.Squad`) emits Hybrid connection strings: either a filesystem path (default) or a `squad://...` URI (when extra knobs needed). The URI form is reserved for future AFCP support. Today, both forms are parsed identically and only `teamRoot` / `cliPath` / etc. matter. + +## Target framework + +`net10.0` only (per Q6 lock — see Squad team `decisions.md` Decision 447). Multi-targeting to `net8;net9;net10` is on the roadmap if adoption demand emerges. + +## Usage example + +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Squad.Agents.AI; + +var services = new ServiceCollection(); +services.AddSquadAgent(opts => +{ + opts.SquadFolderPath = @"C:\path\to\your\team-root"; + opts.GitHubToken = Environment.GetEnvironmentVariable("GH_TOKEN"); + opts.Instructions = "You are a helpful Squad assistant."; +}); + +var provider = services.BuildServiceProvider(); +var squad = provider.GetRequiredService(); + +var response = await squad.CompleteAsync( + new[] { new ChatMessage(ChatRole.User, "Hello Squad, what can you do?") } +); + +Console.WriteLine(response.Message.Text); +``` + +## Status + +Draft preview. Surface may change before 1.0. + +## License + +MIT — same as Squad CLI and Microsoft Agent Framework. diff --git a/src/Squad.Agents.AI/Squad.Agents.AI.csproj b/src/Squad.Agents.AI/Squad.Agents.AI.csproj new file mode 100644 index 000000000..cbcfdbe4e --- /dev/null +++ b/src/Squad.Agents.AI/Squad.Agents.AI.csproj @@ -0,0 +1,31 @@ + + + net10.0 + enable + enable + latest + true + Squad.Agents.AI + Squad Agents AI + Community SquadAgent for Microsoft Agent Framework. Exposes the Squad multi-agent CLI as an AIAgent that can be composed into any MAF app or workflow. + Squad contributors + squad;agent-framework;maf;copilot;ai-agent + https://github.com/bradygaster/squad + git + MIT + README.md + true + 0.1.0-preview + + + + + + + + + + + + + diff --git a/src/Squad.Agents.AI/SquadAgent.cs b/src/Squad.Agents.AI/SquadAgent.cs new file mode 100644 index 000000000..8d783dfbc --- /dev/null +++ b/src/Squad.Agents.AI/SquadAgent.cs @@ -0,0 +1,161 @@ +using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Squad.Agents.AI; + +/// +/// A Squad-flavored that composes a GitHub Copilot +/// client configured for Squad multi-agent CLI. +/// +/// Value-add over bare Copilot SDK: +/// +/// Squad boundary instructions injected at construction time. +/// Squad-specific configuration (team root, CLI path, env vars). +/// DI-friendly: registered via . +/// +/// +public sealed class SquadAgent : IChatClient, IAsyncDisposable +{ + private readonly CopilotClient _copilotClient; + private readonly IChatClient _inner; + private readonly ILogger? _logger; + private readonly bool _ownsClient; + private readonly ChatClientMetadata _metadata; + + /// + /// Initializes a from fully-resolved options. + /// The is created and owned by this instance. + /// + public SquadAgent(SquadAgentOptions options, ILoggerFactory? loggerFactory = null) + : this(CreateCopilotClient(options, loggerFactory), options, loggerFactory, ownsClient: true) + { + } + + /// + /// Initializes a from IOptions pattern. + /// + public SquadAgent(IOptions options, ILoggerFactory? loggerFactory = null) + : this(options.Value, loggerFactory) + { + } + + private SquadAgent(CopilotClient copilotClient, SquadAgentOptions options, ILoggerFactory? loggerFactory, bool ownsClient) + { + _copilotClient = copilotClient ?? throw new ArgumentNullException(nameof(copilotClient)); + _logger = loggerFactory?.CreateLogger(); + _ownsClient = ownsClient; + + // Create the inner AIAgent via AsAIAgent extension, then get IChatClient + var agent = _copilotClient.AsAIAgent( + instructions: options.Instructions, + name: options.AgentName ?? "Squad"); + + // Cast to IChatClient (AIAgent should implement it, but types are in different assemblies) + _inner = (IChatClient)(object)agent; + + _metadata = new ChatClientMetadata("Squad"); + + _logger?.LogInformation("SquadAgent initialized with name '{AgentName}', team root '{TeamRoot}'", + options.AgentName, options.SquadFolderPath); + } + + private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILoggerFactory? loggerFactory) + { + var clientOptions = new CopilotClientOptions + { + CliPath = options.CliPath, + Cwd = options.Cwd ?? options.SquadFolderPath, + GitHubToken = options.GitHubToken + }; + + // Copy CLI args + if (options.CliArgs.Count > 0) + { + clientOptions.CliArgs = options.CliArgs.ToArray(); + } + + // Copy environment variables + if (options.Environment.Count > 0) + { + var envDict = new Dictionary(); + foreach (var kvp in options.Environment) + { + if (kvp.Value != null) + envDict[kvp.Key] = kvp.Value; + } + clientOptions.Environment = envDict; + } + + // Set logger if trace events enabled + if (options.TraceEvents && loggerFactory != null) + { + clientOptions.Logger = loggerFactory.CreateLogger(); + } + + return new CopilotClient(clientOptions); + } + + // IChatClient implementation + + public ChatClientMetadata Metadata => _metadata; + + public Task GetResponseAsync( + IEnumerable chatMessages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + return _inner.GetResponseAsync(chatMessages, options, cancellationToken); + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable chatMessages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + return _inner.GetStreamingResponseAsync(chatMessages, options, cancellationToken); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceType == typeof(IChatClient)) + return this; + + return _inner.GetService(serviceType, serviceKey); + } + + public TService? GetService(object? key = null) where TService : class + { + if (typeof(TService) == typeof(IChatClient)) + return this as TService; + + // Delegate to inner client's GetService via extension method + return ChatClientExtensions.GetService(_inner, key); + } + + public async ValueTask DisposeAsync() + { + if (_ownsClient) + { + await _copilotClient.DisposeAsync().ConfigureAwait(false); + } + + if (_inner is IAsyncDisposable innerDisposable) + { + await innerDisposable.DisposeAsync().ConfigureAwait(false); + } + else if (_inner is IDisposable innerSync) + { + innerSync.Dispose(); + } + + _logger?.LogDebug("SquadAgent disposed"); + } + + void IDisposable.Dispose() + { + // Sync dispose not supported; call DisposeAsync + throw new NotSupportedException("Use DisposeAsync() instead of Dispose()."); + } +} diff --git a/src/Squad.Agents.AI/SquadAgentOptions.cs b/src/Squad.Agents.AI/SquadAgentOptions.cs new file mode 100644 index 000000000..543e6bbe1 --- /dev/null +++ b/src/Squad.Agents.AI/SquadAgentOptions.cs @@ -0,0 +1,39 @@ +namespace Squad.Agents.AI; + +/// +/// Configuration for . Bind from "SquadAgent" +/// configuration section (appsettings.json) or Aspire ConnectionStrings via +/// . +/// +public sealed class SquadAgentOptions +{ + /// Path to the Squad team root (folder containing .squad/). + public string? SquadFolderPath { get; set; } + + /// Override the Squad CLI executable path. If null, uses CopilotClient default. + public string? CliPath { get; set; } + + /// Extra CLI args to pass to the Squad/Copilot CLI process. + public IList CliArgs { get; } = new List(); + + /// Working directory for the CLI process. Defaults to SquadFolderPath. + public string? Cwd { get; set; } + + /// Environment variables to inject into the CLI process. + public IDictionary Environment { get; } = new Dictionary(); + + /// GitHub token for Copilot CLI auth. Honors GH_TOKEN env if null. + public string? GitHubToken { get; set; } + + /// + /// Enables verbose tracing through ILogger. Default false. If set true outside + /// Development environments, AddSquadAgent logs a warning to discourage prod use. + /// + public bool TraceEvents { get; set; } = false; + + /// Optional display name for the resulting AIAgent. Defaults to "Squad". + public string AgentName { get; set; } = "Squad"; + + /// Optional system message / instructions passed as SessionConfig.SystemMessage. + public string? Instructions { get; set; } +} diff --git a/src/Squad.Agents.AI/SquadConnectionFactory.cs b/src/Squad.Agents.AI/SquadConnectionFactory.cs new file mode 100644 index 000000000..2facd0b4b --- /dev/null +++ b/src/Squad.Agents.AI/SquadConnectionFactory.cs @@ -0,0 +1,101 @@ +namespace Squad.Agents.AI; + +/// +/// Static factory that parses connection strings emitted by CommunityToolkit.Aspire.Hosting.Squad. +/// +public static class SquadConnectionFactory +{ + /// + /// Parses a Squad connection string into SquadAgentOptions. + /// + /// Supported formats (Hybrid wire — Q1 lock): + /// + /// 1) PATH form (default — emitted when SquadFolderPath only): + /// "C:\path\to\team-root" + /// "/Users/me/team-root" + /// Maps directly to SquadFolderPath. Cwd defaults to same. + /// + /// 2) URI form (escape hatch — when extra knobs needed): + /// "squad://localhost?teamRoot=...&cliPath=...&protocol=maf-1.0" + /// Reserved for future AFCP (Agent-to-Agent Framework + /// Communication Protocol) support where host:port matters. For now + /// we only parse query params; host is ignored. + /// + public static SquadAgentOptions FromConnectionString(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); + } + + var options = new SquadAgentOptions(); + + if (connectionString.StartsWith("squad://", StringComparison.OrdinalIgnoreCase)) + { + // URI form + var uri = new Uri(connectionString); + var query = ParseQueryString(uri.Query); + + if (query.TryGetValue("teamRoot", out var teamRoot)) + options.SquadFolderPath = teamRoot; + + if (query.TryGetValue("cliPath", out var cliPath)) + options.CliPath = cliPath; + + if (query.TryGetValue("cwd", out var cwd)) + options.Cwd = cwd; + + if (query.TryGetValue("cliArgs", out var cliArgsString)) + { + foreach (var arg in cliArgsString.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + options.CliArgs.Add(arg); + } + } + + // Parse environment variables if present (format: key1=val1;key2=val2) + if (query.TryGetValue("env", out var envString)) + { + foreach (var pair in envString.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = pair.Split('=', 2); + if (parts.Length == 2) + { + options.Environment[parts[0]] = parts[1]; + } + } + } + } + else + { + // PATH form — treat entire string as team root path + options.SquadFolderPath = connectionString; + options.Cwd = connectionString; + } + + return options; + } + + private static Dictionary ParseQueryString(string query) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (string.IsNullOrEmpty(query)) + return result; + + // Remove leading '?' if present + if (query.StartsWith('?')) + query = query.Substring(1); + + foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = pair.Split('=', 2); + if (parts.Length == 2) + { + result[Uri.UnescapeDataString(parts[0])] = Uri.UnescapeDataString(parts[1]); + } + } + + return result; + } +} diff --git a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs new file mode 100644 index 000000000..c34266fcf --- /dev/null +++ b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Squad.Agents.AI; + +/// +/// DI extension methods for registering . +/// +public static class SquadServiceCollectionExtensions +{ + /// Register SquadAgent as a scoped IChatClient. + public static IServiceCollection AddSquadAgent( + this IServiceCollection services, + Action? configure = null) + => AddSquadAgent(services, ServiceLifetime.Scoped, configure); + + /// Register SquadAgent with specified lifetime. + public static IServiceCollection AddSquadAgent( + this IServiceCollection services, + ServiceLifetime lifetime, + Action? configure = null) + { + if (configure is not null) + { + services.Configure(configure); + } + + // Register SquadAgent with specified lifetime + services.Add(new ServiceDescriptor( + typeof(SquadAgent), + typeof(SquadAgent), + lifetime)); + + // IChatClient base registration — so consumers can resolve via IEnumerable + services.Add(new ServiceDescriptor( + typeof(IChatClient), + sp => sp.GetRequiredService(), + lifetime)); + + // TraceEvents=true outside Development → warn + services.AddOptions() + .PostConfigure((opts, loggerFactory) => + { + if (opts.TraceEvents) + { + var logger = loggerFactory.CreateLogger("Squad.Agents.AI.Startup"); + logger.LogWarning( + "SquadAgentOptions.TraceEvents=true. Verbose tracing is enabled; " + + "not recommended for production use."); + } + }); + + return services; + } +} From 0f49470ff74d68a87bec6b3cf31be702fb318442 Mon Sep 17 00:00:00 2001 From: "Reno (Copilot)" Date: Thu, 28 May 2026 17:29:26 +0300 Subject: [PATCH 02/20] docs: add PR body for #3 --- pr-body.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 pr-body.md diff --git a/pr-body.md b/pr-body.md new file mode 100644 index 000000000..bd196efa0 --- /dev/null +++ b/pr-body.md @@ -0,0 +1,81 @@ +> ⚠️ DRAFT — Tamir tests locally before this goes ready-for-review + +## What this adds + +`Squad.Agents.AI` — a community NuGet that exposes the Squad multi-agent CLI as a `Microsoft.Extensions.AI.IChatClient`, composable into any MAF app or workflow. + +## Design refs (in `tamirdresher_microsoft/tamresearch1`) + +- `.squad/decisions.md` Decisions 441, 443, 444, 447 — full Q1–Q7 design lock +- Decision 441 SDK probe — `GitHubCopilotAgent` is sealed → compose via `AsAIAgent`; `CopilotClientOptions` exposes all needed knobs +- Decision 447 Q1–Q7 lock — TFM net10, naming `Squad.Agents.AI`, no `ExtensionSlug`, Hybrid PATH+URI wire format + +## How to test locally + +1. Clone this branch: + ```powershell + gh repo clone tamirdresher/squad + cd squad + gh pr checkout + ``` + +2. Build: + ```powershell + cd src/Squad.Agents.AI + dotnet build -c Release + dotnet pack -c Release -o ../../artifacts + ``` + +3. Sanity-check the package metadata: + ```powershell + ls ../../artifacts/Squad.Agents.AI.*.nupkg + ``` + +4. Wire into a test console app: + ```powershell + mkdir ..\TestSquadAgent + cd ..\TestSquadAgent + dotnet new console + dotnet add package Squad.Agents.AI --source ..\..\artifacts --prerelease + ``` + + Then in `Program.cs`: + ```csharp + using Microsoft.Extensions.AI; + using Microsoft.Extensions.DependencyInjection; + using Squad.Agents.AI; + + var services = new ServiceCollection(); + services.AddSquadAgent(opts => + { + opts.SquadFolderPath = @"C:\path\to\your\team-root"; + opts.GitHubToken = Environment.GetEnvironmentVariable("GH_TOKEN"); + }); + + var provider = services.BuildServiceProvider(); + var squad = provider.GetRequiredService(); + var resp = await squad.GetResponseAsync( + new[] { new ChatMessage(ChatRole.User, "hello squad, who are you?") } + ); + Console.WriteLine(resp.Message.Text); + ``` + +5. Verify the Squad CLI launches, the demo prints a sensible response. + +## What's NOT in this PR + +- Unit tests — deferred to follow-up (v0.2) +- Multi-target TFM — `net10.0` only per Q6 lock +- `.WithSquadCli()` / process spawning on the Aspire side — that's Track B (`CommunityToolkit.Aspire.Hosting.Squad`); separate PR in `tamirdresher/Aspire-1` + +## Known issues / Tamir to verify + +1. **Package version** — `Microsoft.Agents.AI.GitHub.Copilot 1.7.0-preview.260526.1` from Decision 441 may need update if a newer preview exists. Check with `nuget search Microsoft.Agents.AI.GitHub.Copilot -PreRelease`. + +2. **API surface alignment** — Implementation uses `IChatClient` from `Microsoft.Extensions.AI 10.6.0`, composing `AsAIAgent()` from `Microsoft.Agents.AI.GitHub.Copilot`. The cast from `AIAgent` → `IChatClient` is via `(IChatClient)(object)agent` — if MAF types change, this may need adjustment. + +3. **Build warnings** — XML doc comments missing for some public methods (non-blocking). Can be resolved in v0.2. + +## Co-author + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From 774906cefb39e3cfd2adc4b9cc502904b09f790b Mon Sep 17 00:00:00 2001 From: "Reno (Copilot)" Date: Thu, 28 May 2026 17:45:50 +0300 Subject: [PATCH 03/20] fix(SquadAgent): inherit AIAgent (was IChatClient force-cast) - SquadAgent now properly inherits from Microsoft.Agents.AI.AIAgent - Removed (IChatClient)(object)agent force-cast - Overrides all AIAgent abstract members (CreateSessionCoreAsync, RunCoreAsync, etc.) - DI registration now registers AIAgent (not IChatClient) - README updated to use AIAgent.RunAsync API - No more abstraction inversion; AIAgent is the correct layer Fixes the architectural error identified by Tamir. --- src/Squad.Agents.AI/README.md | 11 +-- src/Squad.Agents.AI/SquadAgent.cs | 85 +++++++++---------- .../SquadServiceCollectionExtensions.cs | 9 +- 3 files changed, 48 insertions(+), 57 deletions(-) diff --git a/src/Squad.Agents.AI/README.md b/src/Squad.Agents.AI/README.md index 3fefc6c2b..7d3565cae 100644 --- a/src/Squad.Agents.AI/README.md +++ b/src/Squad.Agents.AI/README.md @@ -1,6 +1,6 @@ # Squad.Agents.AI -Community NuGet that exposes the Squad multi-agent CLI as a `Microsoft.Extensions.AI.AIAgent`, composable into any Microsoft Agent Framework (MAF) app or workflow. +Community NuGet that exposes the Squad multi-agent CLI as a `Microsoft.Agents.AI.AIAgent`, composable into any Microsoft Agent Framework (MAF) app or workflow. ## Quick start — with Aspire (recommended) @@ -52,7 +52,7 @@ Track B (`CommunityToolkit.Aspire.Hosting.Squad`) emits Hybrid connection string ## Usage example ```csharp -using Microsoft.Extensions.AI; +using Microsoft.Agents.AI; using Microsoft.Extensions.DependencyInjection; using Squad.Agents.AI; @@ -67,11 +67,8 @@ services.AddSquadAgent(opts => var provider = services.BuildServiceProvider(); var squad = provider.GetRequiredService(); -var response = await squad.CompleteAsync( - new[] { new ChatMessage(ChatRole.User, "Hello Squad, what can you do?") } -); - -Console.WriteLine(response.Message.Text); +var response = await squad.RunAsync("Hello Squad, what can you do?"); +Console.WriteLine(response); // prints the agent's response ``` ## Status diff --git a/src/Squad.Agents.AI/SquadAgent.cs b/src/Squad.Agents.AI/SquadAgent.cs index 8d783dfbc..ddd0c87f4 100644 --- a/src/Squad.Agents.AI/SquadAgent.cs +++ b/src/Squad.Agents.AI/SquadAgent.cs @@ -1,28 +1,29 @@ using GitHub.Copilot.SDK; -using Microsoft.Extensions.AI; +using Microsoft.Agents.AI; +using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Squad.Agents.AI; /// -/// A Squad-flavored that composes a GitHub Copilot -/// client configured for Squad multi-agent CLI. +/// A Squad-flavored that composes a GitHub Copilot +/// agent configured for Squad multi-agent CLI. /// -/// Value-add over bare Copilot SDK: +/// Value-add over bare MAF GitHubCopilotAgent: /// /// Squad boundary instructions injected at construction time. /// Squad-specific configuration (team root, CLI path, env vars). /// DI-friendly: registered via . /// /// -public sealed class SquadAgent : IChatClient, IAsyncDisposable +public sealed class SquadAgent : AIAgent, IAsyncDisposable { private readonly CopilotClient _copilotClient; - private readonly IChatClient _inner; + private readonly AIAgent _inner; private readonly ILogger? _logger; private readonly bool _ownsClient; - private readonly ChatClientMetadata _metadata; + private readonly SquadAgentOptions _options; /// /// Initializes a from fully-resolved options. @@ -46,17 +47,13 @@ private SquadAgent(CopilotClient copilotClient, SquadAgentOptions options, ILogg _copilotClient = copilotClient ?? throw new ArgumentNullException(nameof(copilotClient)); _logger = loggerFactory?.CreateLogger(); _ownsClient = ownsClient; + _options = options; - // Create the inner AIAgent via AsAIAgent extension, then get IChatClient - var agent = _copilotClient.AsAIAgent( + // Create the inner AIAgent via AsAIAgent extension — NO CAST + _inner = _copilotClient.AsAIAgent( instructions: options.Instructions, name: options.AgentName ?? "Squad"); - // Cast to IChatClient (AIAgent should implement it, but types are in different assemblies) - _inner = (IChatClient)(object)agent; - - _metadata = new ChatClientMetadata("Squad"); - _logger?.LogInformation("SquadAgent initialized with name '{AgentName}', team root '{TeamRoot}'", options.AgentName, options.SquadFolderPath); } @@ -97,41 +94,49 @@ private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILog return new CopilotClient(clientOptions); } - // IChatClient implementation + // AIAgent abstract method overrides — delegate to _inner - public ChatClientMetadata Metadata => _metadata; + public override string? Name => _options.AgentName ?? _inner.Name; + + public override string? Description => _inner.Description ?? "Squad multi-agent CLI participant"; - public Task GetResponseAsync( - IEnumerable chatMessages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) { - return _inner.GetResponseAsync(chatMessages, options, cancellationToken); + return _inner.CreateSessionAsync(cancellationToken); } - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable chatMessages, - ChatOptions? options = null, + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - return _inner.GetStreamingResponseAsync(chatMessages, options, cancellationToken); + return _inner.RunAsync(messages, session, options, cancellationToken); } - public object? GetService(Type serviceType, object? serviceKey = null) + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) { - if (serviceType == typeof(IChatClient)) - return this; - - return _inner.GetService(serviceType, serviceKey); + return _inner.RunStreamingAsync(messages, session, options, cancellationToken); } - public TService? GetService(object? key = null) where TService : class + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) { - if (typeof(TService) == typeof(IChatClient)) - return this as TService; + return _inner.SerializeSessionAsync(session, jsonSerializerOptions, cancellationToken); + } - // Delegate to inner client's GetService via extension method - return ChatClientExtensions.GetService(_inner, key); + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement sessionState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + return _inner.DeserializeSessionAsync(sessionState, jsonSerializerOptions, cancellationToken); } public async ValueTask DisposeAsync() @@ -145,17 +150,7 @@ public async ValueTask DisposeAsync() { await innerDisposable.DisposeAsync().ConfigureAwait(false); } - else if (_inner is IDisposable innerSync) - { - innerSync.Dispose(); - } _logger?.LogDebug("SquadAgent disposed"); } - - void IDisposable.Dispose() - { - // Sync dispose not supported; call DisposeAsync - throw new NotSupportedException("Use DisposeAsync() instead of Dispose()."); - } } diff --git a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs index c34266fcf..47606d7a7 100644 --- a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs +++ b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs @@ -1,7 +1,6 @@ -using Microsoft.Extensions.AI; +using Microsoft.Agents.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -12,7 +11,7 @@ namespace Squad.Agents.AI; /// public static class SquadServiceCollectionExtensions { - /// Register SquadAgent as a scoped IChatClient. + /// Register SquadAgent as a scoped AIAgent. public static IServiceCollection AddSquadAgent( this IServiceCollection services, Action? configure = null) @@ -35,9 +34,9 @@ public static IServiceCollection AddSquadAgent( typeof(SquadAgent), lifetime)); - // IChatClient base registration — so consumers can resolve via IEnumerable + // AIAgent base registration — so consumers can resolve via IEnumerable services.Add(new ServiceDescriptor( - typeof(IChatClient), + typeof(AIAgent), sp => sp.GetRequiredService(), lifetime)); From 186ac0504e94e7c42634fc50fdfaaa46e91c2d63 Mon Sep 17 00:00:00 2001 From: "Reno (Copilot)" Date: Thu, 28 May 2026 20:16:59 +0300 Subject: [PATCH 04/20] =?UTF-8?q?docs(SquadAgent):=20rewrite=20README=20?= =?UTF-8?q?=E2=80=94=20prerequisites,=20Hello=20World,=20troubleshooting,?= =?UTF-8?q?=20preview=20callout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Squad.Agents.AI/README.md | 264 ++++++++++++++++++++++++++-------- 1 file changed, 202 insertions(+), 62 deletions(-) diff --git a/src/Squad.Agents.AI/README.md b/src/Squad.Agents.AI/README.md index 7d3565cae..207ad6fc9 100644 --- a/src/Squad.Agents.AI/README.md +++ b/src/Squad.Agents.AI/README.md @@ -1,80 +1,220 @@ # Squad.Agents.AI -Community NuGet that exposes the Squad multi-agent CLI as a `Microsoft.Agents.AI.AIAgent`, composable into any Microsoft Agent Framework (MAF) app or workflow. - -## Quick start — with Aspire (recommended) - -In your AppHost (uses `CommunityToolkit.Aspire.Hosting.Squad`): - +> ⚠️ **Preview package.** This NuGet depends on the preview `Microsoft.Agents.AI.GitHub.Copilot` SDK (`1.7.0-preview.*`). Expect breaking changes until Microsoft Agent Framework (MAF) ships a stable release. Use the `0.1.0-preview` package only for evaluation, prototyping, and early-adopter feedback — not for production workloads. + +## What is Squad.Agents.AI? + +**Squad.Agents.AI** lets you embed the [Squad multi-agent CLI](https://github.com/bradygaster/squad) as a standard Microsoft Agent Framework (`AIAgent`) inside any .NET application. Instead of spawning a subprocess or calling `npx squad-cli` from your code, you get a first-class agent that integrates with dependency injection (DI), structured logging, lifecycle management, and composition — just like any other MAF agent. + +**Why use it?** If you've built a Squad team to coordinate multiple agents, you can now consume that team's brainpower directly from your .NET app without shell-out overhead. You get an `AIAgent` you can compose, await, and stream from. + +### What is Squad? + +Squad is an open-source multi-agent CLI framework that orchestrates autonomous agents via a local `.squad/` directory. For a full introduction, see the [bradygaster/squad repository](https://github.com/bradygaster/squad). + +## Prerequisites + +- **[.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)** — required to build and run the application +- **GitHub Copilot CLI** — must be on your PATH + - Install: `npm install -g @github/copilot-cli` + - Or: follow the [Copilot CLI install docs](https://github.com/github/copilot-cli) + - Verify: run `copilot --version` in a terminal +- **GitHub Personal Access Token** — for authentication + - Create a token at https://github.com/settings/tokens + - Store in environment variable: `GH_TOKEN` or `GITHUB_TOKEN` + - Scopes needed: at minimum `read:user` and `repo` (if accessing private repos) +- **A Squad team root** — either: + - An existing `.squad/` directory from a Squad-managed repository, OR + - Create a new one: run `npx @bradygaster/squad-cli init` in an empty folder +- **[.NET Aspire 9.x+](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview)** *(optional, Aspire path only)* + +## Quick start — Hello World console app + +1. **Create a console app:** + ```bash + dotnet new console -o squad-hello + cd squad-hello + dotnet add package Squad.Agents.AI --prerelease + dotnet add package Microsoft.Extensions.Hosting + ``` + +2. **Replace `Program.cs` with:** + ```csharp + using Microsoft.Agents.AI; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Squad.Agents.AI; + + var builder = Host.CreateApplicationBuilder(args); + builder.Services.AddSquadAgent(opts => + { + opts.SquadFolderPath = @"C:\path\to\your\team-root"; // point to your .squad/ parent + opts.GitHubToken = Environment.GetEnvironmentVariable("GH_TOKEN"); + opts.Instructions = "You are a helpful assistant."; + }); + + using var host = builder.Build(); + var squad = host.Services.GetRequiredService(); + var response = await squad.RunAsync("What can you do?"); + Console.WriteLine(response.Text); + ``` + +3. **Set your environment variable and run:** + ```bash + # Windows + $env:GH_TOKEN = "ghp_your_token_here" + dotnet run + + # macOS / Linux + export GH_TOKEN="ghp_your_token_here" + dotnet run + ``` + + **Expected output:** A printed response from your Squad team. + +## Aspire vs. Standalone — when to use which + +| Scenario | Use Aspire | Use Standalone | +|---|---|---| +| Building an Aspire AppHost that orchestrates multiple services | ✅ | | +| Single console or worker app | | ✅ | +| Web API or service that consumes Squad | ✅ (if AppHost-managed) | ✅ (if standalone) | +| Unit tests or test fixtures | | ✅ | + +### Aspire path (3 lines + 1 line) + +In your **AppHost**: ```csharp var squad = builder.AddSquad("squad") - .WithTeamRoot("../my-team"); - -var api = builder.AddProject("api") - .WithReference(squad); + .WithTeamRoot("../my-team-root"); ``` -In your `MyApi` (Program.cs): - -```csharp -builder.Services.AddSquadAgent(); // picks up ConnectionStrings__squad -``` - -That's it. Aspire injects the connection string; SquadAgent parses it via `SquadConnectionFactory.FromConnectionString` and configures the underlying `CopilotClient`. - -## Standalone (advanced — no Aspire) - -If you're not using Aspire, configure directly: - +In your **consumer service** (Program.cs): ```csharp -builder.Services.AddSquadAgent(opts => -{ - opts.SquadFolderPath = "C:/my-team"; - opts.GitHubToken = Environment.GetEnvironmentVariable("GH_TOKEN"); -}); +builder.Services.AddSquadAgent(); // Aspire auto-injects ConnectionStrings__squad ``` -## What you get +### Standalone path -- **`SquadAgent : AIAgent`** — composes `GitHubCopilotAgent` from `Microsoft.Agents.AI.GitHub.Copilot 1.7.0-preview.*` -- **`SquadAgentOptions`** — DI-friendly options for CLI path, args, cwd, env, token -- **`SquadConnectionFactory.FromConnectionString`** — Hybrid PATH+URI parser -- **`IServiceCollection.AddSquadAgent(...)`** — DI registration +See **Quick start** above — configure manually with `SquadAgentOptions`. -## Wire-format compatibility +## Configuration options -Track B (`CommunityToolkit.Aspire.Hosting.Squad`) emits Hybrid connection strings: either a filesystem path (default) or a `squad://...` URI (when extra knobs needed). The URI form is reserved for future AFCP support. Today, both forms are parsed identically and only `teamRoot` / `cliPath` / etc. matter. +All options are members of `SquadAgentOptions`: -## Target framework +| Option | Type | Default | Description | Security Note | +|---|---|---|---|---| +| `SquadFolderPath` | `string?` | `null` | Path to the Squad team root (parent of `.squad/` directory). Required unless using Aspire. | Must be readable by the application | +| `CliPath` | `string?` | `null` | Override the Copilot CLI executable path. If `null`, uses the default system PATH lookup. | If specified, ensure the path is trusted and not user-controllable | +| `CliArgs` | `IList` | empty | Extra CLI arguments to pass to the Copilot/Squad CLI process. Example: `["--yolo"]` for non-interactive mode. | Avoid unsanitized user input | +| `Cwd` | `string?` | `SquadFolderPath` | Working directory for the CLI process. Defaults to `SquadFolderPath`. | | +| `Environment` | `IDictionary` | empty | Environment variables to inject into the CLI process. | Secrets should use `GitHubToken` property, not this dictionary | +| `GitHubToken` | `string?` | from `GH_TOKEN` env var | GitHub Personal Access Token for Copilot CLI authentication. If `null`, honors the `GH_TOKEN` or `GITHUB_TOKEN` environment variable. | **Always load from environment variables or secure vaults; never hardcode.** Minimum scopes: `read:user`, `repo` | +| `TraceEvents` | `bool` | `false` | Enable verbose event tracing via `ILogger`. If `true` in non-Development environments, `AddSquadAgent` logs a warning. | Trace logs may contain sensitive information; disable in production | +| `AgentName` | `string` | `"Squad"` | Display name for the resulting `AIAgent`. Used in logs and agent composition. | | +| `Instructions` | `string?` | `null` | Optional system message / instructions passed as `SessionConfig.SystemMessage`. Useful for injecting custom Squad boundary rules. | | -`net10.0` only (per Q6 lock — see Squad team `decisions.md` Decision 447). Multi-targeting to `net8;net9;net10` is on the roadmap if adoption demand emerges. +## Connection string format -## Usage example +If you're using Aspire, the AppHost emits a connection string that `SquadAgent` parses automatically — you don't need to know the format. If you're calling `SquadConnectionFactory.FromConnectionString` directly (standalone or advanced scenarios), understand these two forms: -```csharp -using Microsoft.Agents.AI; -using Microsoft.Extensions.DependencyInjection; -using Squad.Agents.AI; - -var services = new ServiceCollection(); -services.AddSquadAgent(opts => -{ - opts.SquadFolderPath = @"C:\path\to\your\team-root"; - opts.GitHubToken = Environment.GetEnvironmentVariable("GH_TOKEN"); - opts.Instructions = "You are a helpful Squad assistant."; -}); - -var provider = services.BuildServiceProvider(); -var squad = provider.GetRequiredService(); - -var response = await squad.RunAsync("Hello Squad, what can you do?"); -Console.WriteLine(response); // prints the agent's response +### PATH form (default) ``` +C:\path\to\team-root +/Users/me/team-root +``` +Treated as a literal filesystem path to your Squad team root. `Cwd` defaults to the same path. -## Status - -Draft preview. Surface may change before 1.0. - -## License - -MIT — same as Squad CLI and Microsoft Agent Framework. +### URI form (escape hatch) +``` +squad://localhost?teamRoot=C:\path\to\team-root&cliPath=C:\custom\copilot&protocol=maf-1.0 +``` +Used when you need to customize multiple options. Supported query parameters: +- `teamRoot` — path to team root +- `cliPath` — override CLI executable path +- `cwd` — working directory +- `cliArgs` — CLI args (semicolon-separated) +- `env` — environment variables (format: `key1=val1;key2=val2`) +- `protocol` — reserved for future use (AFCP — Agent-to-Agent Communication Protocol) + +**Current status:** Both forms are supported; the URI form is reserved for future extensibility. + +## Troubleshooting + +### `copilot: command not found` +The Copilot CLI is not on your PATH. +- **Fix:** Install with `npm install -g @github/copilot-cli` +- **Or:** Verify installation: `which copilot` (macOS/Linux) or `where copilot` (Windows) +- **Or:** Specify the path explicitly: `opts.CliPath = @"C:\path\to\copilot.exe"` + +### `Team root not found at {path}` +The `SquadFolderPath` points to a directory that doesn't exist or isn't accessible. +- **Fix:** Verify the path exists and contains a `.squad/` subdirectory +- **Fix:** If starting fresh, run `npx @bradygaster/squad-cli init` to create a team root +- **Fix:** Check file permissions — the app process must be able to read the path + +### `Unauthorized (401)` or `authentication failed` +Your GitHub token is missing, expired, or lacks required scopes. +- **Fix:** Verify `GH_TOKEN` or `GITHUB_TOKEN` is set: `echo $env:GH_TOKEN` (PowerShell) or `echo $GH_TOKEN` (bash) +- **Fix:** Generate a new token at https://github.com/settings/tokens +- **Fix:** Ensure the token has scopes: `read:user`, `repo` +- **Fix:** If using `opts.GitHubToken` directly, verify it's not hardcoded or committed + +### `Timeout` or agent hangs indefinitely +The Copilot CLI is waiting for interactive input but your app can't provide it. +- **Fix:** Pass `--yolo` flag to bypass interactivity: `opts.CliArgs.Add("--yolo")` +- **Fix:** Check that `SquadFolderPath` points to a valid, initialized Squad team +- **Fix:** Increase timeout in `AgentRunOptions` if the agent is genuinely busy + +### `GitHubCopilot not found` or missing transitive dependency +The NuGet depends on `Microsoft.Agents.AI.GitHub.Copilot 1.7.0-preview.*`, which must be available. +- **Fix:** Ensure your NuGet feed includes the preview package source (usually the default NuGet.org) +- **Fix:** Confirm you've run `dotnet restore` + +## FAQ + +### Do I need a GitHub Copilot subscription? +Yes. The GitHub Copilot CLI requires an active GitHub Copilot subscription to authenticate and function. + +### Can I use this without Aspire? +Yes. See the **Standalone path** section and the **Quick start** console example above. Aspire is optional and meant for multi-service orchestration scenarios. + +### Is this production-ready? +No. See the **Preview package** callout at the top. This package depends on preview MAF transitive dependencies. Use only for evaluation, prototyping, and early-adopter feedback. + +### How is this different from calling the CLI directly? +Instead of forking a subprocess with `Process.Start("npx squad-cli ...")`, you get: +- Dependency injection and composability +- Structured logging and tracing +- Automatic resource cleanup (via `IAsyncDisposable`) +- First-class MAF agent composition and streaming +- Better error handling and lifecycle management + +### Where do I report issues or contribute? +- Issues: [Squad GitHub repository](https://github.com/bradygaster/squad/issues) +- Track A (this NuGet): [PR #3 on tamirdresher/squad](https://github.com/tamirdresher/squad/pull/3) +- Feedback on preview: Please comment on the PR with use cases and pain points + +## Status & roadmap + +### v0.1.0-preview (current) +- ✅ `SquadAgent` wraps `GitHubCopilotAgent` for DI composition +- ✅ `SquadAgentOptions` configuration +- ✅ Hybrid PATH+URI connection string parsing +- ✅ Aspire integration (via Track B: `CommunityToolkit.Aspire.Hosting.Squad`) + +### v0.2 candidates +- Event streaming hooks (structured observability) +- Keyed DI for multi-tenant / multiple Squad teams in one app +- Health checks and readiness probes +- Multi-target `net8;net9;net10` (currently `net10` only) +- AFCP (Agent-to-Agent Communication Protocol) support via URI form + +### See also +- [Squad CLI repository](https://github.com/bradygaster/squad) +- [Microsoft Agent Framework (MAF)](https://github.com/microsoft/agents) +- [GitHub Copilot CLI](https://github.com/github/copilot-cli) + +--- + +**License:** MIT — same as Squad CLI and Microsoft Agent Framework. From 8814207741aa3819a2d6c17ed901914b764be354 Mon Sep 17 00:00:00 2001 From: "Reno (Copilot)" Date: Thu, 28 May 2026 20:17:00 +0300 Subject: [PATCH 05/20] feat(SquadAgent): GitHubTokenProvider callback + redact options ToString Adds async token provider pattern for production scenarios (KeyVault/MSI integration). - GitHubTokenProvider property takes precedence over GitHubToken - GitHubToken marked [JsonIgnore] to prevent serialization leaks - SquadAgentOptions.ToString() redacts GitHubToken field - Updated CreateCopilotClient to resolve token from provider first Mitigates P0 #3: token leakage via ILogger structured-log calls, IOptions snapshots, and serializers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Squad.Agents.AI/SquadAgent.cs | 14 ++++- src/Squad.Agents.AI/SquadAgentOptions.cs | 22 +++++++- .../SquadAgentOptionsConfigurator.cs | 53 +++++++++++++++++++ .../SquadServiceCollectionExtensions.cs | 9 +++- 4 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 src/Squad.Agents.AI/SquadAgentOptionsConfigurator.cs diff --git a/src/Squad.Agents.AI/SquadAgent.cs b/src/Squad.Agents.AI/SquadAgent.cs index ddd0c87f4..d70a56053 100644 --- a/src/Squad.Agents.AI/SquadAgent.cs +++ b/src/Squad.Agents.AI/SquadAgent.cs @@ -60,11 +60,23 @@ private SquadAgent(CopilotClient copilotClient, SquadAgentOptions options, ILogg private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILoggerFactory? loggerFactory) { + // Resolve token: provider takes precedence over direct property + string? resolvedToken = null; + if (options.GitHubTokenProvider is not null) + { + // Call provider synchronously (constructor context requires sync) + resolvedToken = options.GitHubTokenProvider(CancellationToken.None).GetAwaiter().GetResult(); + } + else + { + resolvedToken = options.GitHubToken; + } + var clientOptions = new CopilotClientOptions { CliPath = options.CliPath, Cwd = options.Cwd ?? options.SquadFolderPath, - GitHubToken = options.GitHubToken + GitHubToken = resolvedToken }; // Copy CLI args diff --git a/src/Squad.Agents.AI/SquadAgentOptions.cs b/src/Squad.Agents.AI/SquadAgentOptions.cs index 543e6bbe1..6da15431e 100644 --- a/src/Squad.Agents.AI/SquadAgentOptions.cs +++ b/src/Squad.Agents.AI/SquadAgentOptions.cs @@ -22,9 +22,20 @@ public sealed class SquadAgentOptions /// Environment variables to inject into the CLI process. public IDictionary Environment { get; } = new Dictionary(); - /// GitHub token for Copilot CLI auth. Honors GH_TOKEN env if null. + /// + /// GitHub token for Copilot CLI auth. ⚠️ SECURITY: Stored in plaintext in the DI options snapshot. + /// For production, prefer which fetches the token on-demand. + /// This property is intended for development/local use only. Honors GH_TOKEN env if null. + /// + [System.Text.Json.Serialization.JsonIgnore] public string? GitHubToken { get; set; } + /// + /// Async token provider for production scenarios. When set, takes precedence over . + /// Use this for KeyVault, managed identity, or any out-of-band token source so the token never lives in DI snapshots. + /// + public Func>? GitHubTokenProvider { get; set; } + /// /// Enables verbose tracing through ILogger. Default false. If set true outside /// Development environments, AddSquadAgent logs a warning to discourage prod use. @@ -36,4 +47,13 @@ public sealed class SquadAgentOptions /// Optional system message / instructions passed as SessionConfig.SystemMessage. public string? Instructions { get; set; } + + /// + /// Redacts sensitive fields (GitHubToken) from diagnostic output to prevent accidental leakage via logging. + /// + public override string ToString() + { + return $"SquadAgentOptions {{ SquadFolderPath = {SquadFolderPath}, AgentName = {AgentName}, " + + $"GitHubToken = [REDACTED], TraceEvents = {TraceEvents} }}"; + } } diff --git a/src/Squad.Agents.AI/SquadAgentOptionsConfigurator.cs b/src/Squad.Agents.AI/SquadAgentOptionsConfigurator.cs new file mode 100644 index 000000000..f5e117fd5 --- /dev/null +++ b/src/Squad.Agents.AI/SquadAgentOptionsConfigurator.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Squad.Agents.AI; + +/// +/// Binds SquadAgentOptions from ConnectionStrings:squad configuration section. +/// Runs before user-supplied configure lambda; user settings always win. +/// +internal sealed class SquadAgentOptionsConfigurator : IConfigureOptions +{ + private readonly IConfiguration _configuration; + + public SquadAgentOptionsConfigurator(IConfiguration configuration) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + public void Configure(SquadAgentOptions options) + { + var connectionString = _configuration.GetConnectionString("squad"); + + if (string.IsNullOrWhiteSpace(connectionString)) + return; + + var parsedOptions = SquadConnectionFactory.FromConnectionString(connectionString); + + // Only apply parsed values if user hasn't set them explicitly + // User callback runs AFTER this configurator, so any null/empty here will be overridden + if (string.IsNullOrWhiteSpace(options.SquadFolderPath)) + options.SquadFolderPath = parsedOptions.SquadFolderPath; + + if (string.IsNullOrWhiteSpace(options.CliPath)) + options.CliPath = parsedOptions.CliPath; + + if (string.IsNullOrWhiteSpace(options.Cwd)) + options.Cwd = parsedOptions.Cwd; + + // Merge CLI args (append to existing list) + foreach (var arg in parsedOptions.CliArgs) + { + if (!options.CliArgs.Contains(arg)) + options.CliArgs.Add(arg); + } + + // Merge environment variables (don't overwrite existing) + foreach (var kvp in parsedOptions.Environment) + { + if (!options.Environment.ContainsKey(kvp.Key)) + options.Environment[kvp.Key] = kvp.Value; + } + } +} diff --git a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs index 47606d7a7..ceb447c42 100644 --- a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs +++ b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs @@ -11,7 +11,11 @@ namespace Squad.Agents.AI; /// public static class SquadServiceCollectionExtensions { - /// Register SquadAgent as a scoped AIAgent. + /// + /// Register SquadAgent as a scoped AIAgent. + /// Reads connection string from ConnectionStrings:squad and applies parsed values + /// to options before the user callback runs. User-supplied callback always wins. + /// public static IServiceCollection AddSquadAgent( this IServiceCollection services, Action? configure = null) @@ -23,6 +27,9 @@ public static IServiceCollection AddSquadAgent( ServiceLifetime lifetime, Action? configure = null) { + // Register connection string configurator FIRST (runs before user callback) + services.TryAddEnumerable(ServiceDescriptor.Singleton, SquadAgentOptionsConfigurator>()); + if (configure is not null) { services.Configure(configure); From 4537fe91640f7c7126214581d17b17ada90dae86 Mon Sep 17 00:00:00 2001 From: "Reno (Copilot)" Date: Thu, 28 May 2026 20:18:37 +0300 Subject: [PATCH 06/20] docs(SquadAgent): document GitHubTokenProvider callback for production token management Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Squad.Agents.AI/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Squad.Agents.AI/README.md b/src/Squad.Agents.AI/README.md index 207ad6fc9..74a9b9b2f 100644 --- a/src/Squad.Agents.AI/README.md +++ b/src/Squad.Agents.AI/README.md @@ -108,8 +108,9 @@ All options are members of `SquadAgentOptions`: | `CliPath` | `string?` | `null` | Override the Copilot CLI executable path. If `null`, uses the default system PATH lookup. | If specified, ensure the path is trusted and not user-controllable | | `CliArgs` | `IList` | empty | Extra CLI arguments to pass to the Copilot/Squad CLI process. Example: `["--yolo"]` for non-interactive mode. | Avoid unsanitized user input | | `Cwd` | `string?` | `SquadFolderPath` | Working directory for the CLI process. Defaults to `SquadFolderPath`. | | -| `Environment` | `IDictionary` | empty | Environment variables to inject into the CLI process. | Secrets should use `GitHubToken` property, not this dictionary | -| `GitHubToken` | `string?` | from `GH_TOKEN` env var | GitHub Personal Access Token for Copilot CLI authentication. If `null`, honors the `GH_TOKEN` or `GITHUB_TOKEN` environment variable. | **Always load from environment variables or secure vaults; never hardcode.** Minimum scopes: `read:user`, `repo` | +| `Environment` | `IDictionary` | empty | Environment variables to inject into the CLI process. | Secrets should use `GitHubToken` or `GitHubTokenProvider`, not this dictionary | +| `GitHubToken` | `string?` | from `GH_TOKEN` env var | GitHub Personal Access Token for Copilot CLI authentication. If `null`, honors the `GH_TOKEN` or `GITHUB_TOKEN` environment variable. **For development only.** | **Stored in plaintext in DI snapshots.** Never hardcode. For production, use `GitHubTokenProvider` instead. Minimum scopes: `read:user`, `repo` | +| `GitHubTokenProvider` | `Func>?` | `null` | **Production-recommended.** Async callback to provide tokens on-demand (e.g., from KeyVault, managed identity, or secure token service). Takes precedence over `GitHubToken` if set. | **Recommended for production.** Keeps tokens out of DI snapshots. The callback receives a cancellation token for long-running token refresh. | | `TraceEvents` | `bool` | `false` | Enable verbose event tracing via `ILogger`. If `true` in non-Development environments, `AddSquadAgent` logs a warning. | Trace logs may contain sensitive information; disable in production | | `AgentName` | `string` | `"Squad"` | Display name for the resulting `AIAgent`. Used in logs and agent composition. | | | `Instructions` | `string?` | `null` | Optional system message / instructions passed as `SessionConfig.SystemMessage`. Useful for injecting custom Squad boundary rules. | | From a34ad80f2dd48f0e4d150670ec4d8577d95b1939 Mon Sep 17 00:00:00 2001 From: "Reno (Copilot)" Date: Thu, 28 May 2026 20:20:06 +0300 Subject: [PATCH 07/20] fix(SquadAgent): bind ConnectionStrings__squad via IConfigureOptions + add smoke tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Squad.Agents.AI.Tests.csproj | 25 ++++ .../SquadConnectionFactoryTests.cs | 77 ++++++++++ .../SquadServiceCollectionExtensionsTests.cs | 141 ++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj create mode 100644 test/Squad.Agents.AI.Tests/SquadConnectionFactoryTests.cs create mode 100644 test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs diff --git a/test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj b/test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj new file mode 100644 index 000000000..d29de4552 --- /dev/null +++ b/test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + enable + enable + latest + false + true + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/test/Squad.Agents.AI.Tests/SquadConnectionFactoryTests.cs b/test/Squad.Agents.AI.Tests/SquadConnectionFactoryTests.cs new file mode 100644 index 000000000..7bad1225f --- /dev/null +++ b/test/Squad.Agents.AI.Tests/SquadConnectionFactoryTests.cs @@ -0,0 +1,77 @@ +using Xunit; + +namespace Squad.Agents.AI.Tests; + +public class SquadConnectionFactoryTests +{ + [Fact] + public void FromConnectionString_WithPathForm_ParsesPathOnly() + { + var connectionString = @"C:\team-root"; + var options = SquadConnectionFactory.FromConnectionString(connectionString); + + Assert.Equal(@"C:\team-root", options.SquadFolderPath); + Assert.Equal(@"C:\team-root", options.Cwd); + Assert.Null(options.CliPath); + } + + [Fact] + public void FromConnectionString_WithUnixPathForm_ParsesPathOnly() + { + var connectionString = "/Users/me/team-root"; + var options = SquadConnectionFactory.FromConnectionString(connectionString); + + Assert.Equal("/Users/me/team-root", options.SquadFolderPath); + Assert.Equal("/Users/me/team-root", options.Cwd); + Assert.Null(options.CliPath); + } + + [Fact] + public void FromConnectionString_WithUriForm_ParsesPathAndUri() + { + var connectionString = "squad://localhost?teamRoot=C:%5Cteam-root&cliPath=C:%5Cbin%5Ccopilot.exe&cwd=C:%5Cwork"; + var options = SquadConnectionFactory.FromConnectionString(connectionString); + + Assert.Equal(@"C:\team-root", options.SquadFolderPath); + Assert.Equal(@"C:\bin\copilot.exe", options.CliPath); + Assert.Equal(@"C:\work", options.Cwd); + } + + [Fact] + public void FromConnectionString_WithCliArgs_ParsesArgsList() + { + var connectionString = "squad://localhost?teamRoot=/team&cliArgs=--verbose;--trace"; + var options = SquadConnectionFactory.FromConnectionString(connectionString); + + Assert.Contains("--verbose", options.CliArgs); + Assert.Contains("--trace", options.CliArgs); + } + + [Fact] + public void FromConnectionString_WithEnvironmentVars_ParsesEnvDict() + { + var connectionString = "squad://localhost?teamRoot=/team&env=KEY1=value1;KEY2=value2"; + var options = SquadConnectionFactory.FromConnectionString(connectionString); + + Assert.Equal("value1", options.Environment["KEY1"]); + Assert.Equal("value2", options.Environment["KEY2"]); + } + + [Fact] + public void FromConnectionString_WithEmptyString_ThrowsArgumentException() + { + Assert.Throws(() => SquadConnectionFactory.FromConnectionString("")); + } + + [Fact] + public void FromConnectionString_WithNullString_ThrowsArgumentException() + { + Assert.Throws(() => SquadConnectionFactory.FromConnectionString(null!)); + } + + [Fact] + public void FromConnectionString_WithWhitespace_ThrowsArgumentException() + { + Assert.Throws(() => SquadConnectionFactory.FromConnectionString(" ")); + } +} diff --git a/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs b/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000..69d010fa7 --- /dev/null +++ b/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs @@ -0,0 +1,141 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Squad.Agents.AI.Tests; + +public class SquadServiceCollectionExtensionsTests +{ + [Fact] + public void AddSquadAgent_WithoutConfig_RegistersAIAgent() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(); + + var provider = services.BuildServiceProvider(); + var agent = provider.GetService(); + + Assert.NotNull(agent); + } + + [Fact] + public void AddSquadAgent_WithConnectionString_BindsSquadFolderPath() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad"] = @"C:\team-root" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Equal(@"C:\team-root", options.SquadFolderPath); + Assert.Equal(@"C:\team-root", options.Cwd); + } + + [Fact] + public void AddSquadAgent_UserCallbackOverridesConnectionString() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad"] = @"C:\team-root" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(opts => + { + opts.SquadFolderPath = @"C:\user-override"; + }); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Equal(@"C:\user-override", options.SquadFolderPath); + } + + [Fact] + public void AddSquadAgent_WithUriConnectionString_ParsesAllFields() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad"] = "squad://localhost?teamRoot=C:%5Cteam&cliPath=C:%5Cbin%5Ccopilot.exe" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Equal(@"C:\team", options.SquadFolderPath); + Assert.Equal(@"C:\bin\copilot.exe", options.CliPath); + } + + [Fact] + public void AddSquadAgent_WithNoConnectionString_DoesNotThrow() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + // Should get default SquadAgentOptions + Assert.Null(options.SquadFolderPath); + } + + [Fact] + public void AddSquadAgent_MergesCliArgsFromConnectionString() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad"] = "squad://localhost?teamRoot=/team&cliArgs=--verbose" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(opts => + { + opts.CliArgs.Add("--user-arg"); + }); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Contains("--verbose", options.CliArgs); + Assert.Contains("--user-arg", options.CliArgs); + } +} From 46a897c927944aae9730e665e4732c4949357b20 Mon Sep 17 00:00:00 2001 From: "Reno (Copilot)" Date: Fri, 29 May 2026 22:18:27 +0300 Subject: [PATCH 08/20] docs(README): replace removed WithTeamRoot with positional teamRoot ctor WithTeamRoot was deleted in commit 35767c90 in favor of mandatory positional teamRoot constructor argument on AddSquad. The Aspire example in the Squad.Agents.AI README still showed the old fluent API, which would now fail at compile time for anyone copy-pasting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Squad.Agents.AI/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Squad.Agents.AI/README.md b/src/Squad.Agents.AI/README.md index 74a9b9b2f..a3dae154f 100644 --- a/src/Squad.Agents.AI/README.md +++ b/src/Squad.Agents.AI/README.md @@ -85,8 +85,7 @@ Squad is an open-source multi-agent CLI framework that orchestrates autonomous a In your **AppHost**: ```csharp -var squad = builder.AddSquad("squad") - .WithTeamRoot("../my-team-root"); +var squad = builder.AddSquad("squad", teamRoot: "../my-team-root"); ``` In your **consumer service** (Program.cs): From 2c28882a306d68a68b858838dc28881dd7f400b9 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 2 Jun 2026 13:27:08 +0300 Subject: [PATCH 09/20] test(squad-agents-ai): add routing integration tests Closes the routing-verification gap identified during squad-squad onboarding: the API surface existed but routing semantics weren't functionally tested. New tests verify persona pass-through, boundary-instruction injection on first turn, WorkingDirectory isolation (Decision 452a), and CopilotClientOptions-based routing (Decision 447). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SquadAgentRoutingTests.cs | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs diff --git a/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs b/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs new file mode 100644 index 000000000..b010b1949 --- /dev/null +++ b/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs @@ -0,0 +1,158 @@ +using System.Reflection; +using Microsoft.Agents.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Squad.Agents.AI.Tests; + +public class SquadAgentRoutingTests +{ + [Fact] + public void AddSquadAgent_ResolvesAIAgentWithConfiguredPersonaName() + { + var provider = BuildProvider(options => + { + options.AgentName = "Data"; + }); + + var agent = provider.GetRequiredService(); + var squadAgent = Assert.IsType(agent); + + Assert.Equal("Data", squadAgent.Name); + } + + [Fact] + public void AddSquadAgent_PassesBoundaryInstructionsToInnerSessionConfig() + { + const string instructions = "You are Data. Stay inside the Squad boundary."; + var agent = CreateAgent(options => + { + options.Instructions = instructions; + }); + + var sessionConfig = GetInnerSessionConfig(agent); + var systemMessage = GetRequiredProperty(sessionConfig, "SystemMessage"); + + Assert.Equal(instructions, GetRequiredProperty(systemMessage, "Content")); + } + + [Fact] + public void AddSquadAgent_UsesExplicitCwdForCopilotClientRouting() + { + var agent = CreateAgent(options => + { + options.SquadFolderPath = @"C:\squad-team-root"; + options.Cwd = @"C:\isolated-working-directory"; + }); + + var clientOptions = GetCopilotClientOptions(agent); + + Assert.Equal(@"C:\isolated-working-directory", GetRequiredProperty(clientOptions, "Cwd")); + } + + [Fact] + public void AddSquadAgent_DefaultsCopilotClientCwdToSquadFolderPath() + { + var agent = CreateAgent(options => + { + options.SquadFolderPath = @"C:\squad-team-root"; + options.Cwd = null; + }); + + var clientOptions = GetCopilotClientOptions(agent); + + Assert.Equal(@"C:\squad-team-root", GetRequiredProperty(clientOptions, "Cwd")); + } + + [Fact] + public void AddSquadAgent_RoutesThroughCopilotClientOptionsNotAgentName() + { + var agent = CreateAgent(options => + { + options.AgentName = "Data Persona"; + options.CliArgs.Clear(); + options.CliArgs.Add("--extension"); + options.CliArgs.Add("squad"); + options.Environment["SQUAD_ROUTE"] = "enabled"; + }); + + var inner = GetRequiredField(agent, "_inner"); + var sessionConfig = GetInnerSessionConfig(agent); + var clientOptions = GetCopilotClientOptions(agent); + var cliArgs = GetRequiredProperty(clientOptions, "CliArgs"); + var environment = GetRequiredProperty>(clientOptions, "Environment"); + + Assert.Equal("Data Persona", GetRequiredProperty(inner, "Name")); + Assert.True(string.IsNullOrEmpty(GetProperty(sessionConfig, "Agent"))); + Assert.DoesNotContain("Data Persona", cliArgs); + Assert.Equal("enabled", environment["SQUAD_ROUTE"]); + } + + private static SquadAgent CreateAgent(Action? configure = null) + { + var provider = BuildProvider(configure); + return provider.GetRequiredService(); + } + + private static ServiceProvider BuildProvider(Action? configure = null) + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(options => + { + options.AgentName = "Squad"; + options.SquadFolderPath = @"C:\squad-team-root"; + options.CliPath = @"C:\fake-copilot\copilot.exe"; + options.Cwd = @"C:\squad-team-root"; + options.GitHubToken = "test-token"; + options.CliArgs.Add("--extension"); + options.CliArgs.Add("squad"); + options.Environment["SQUAD_TEST"] = "true"; + options.Instructions = "Default test boundary instructions."; + + configure?.Invoke(options); + }); + + return services.BuildServiceProvider(); + } + + private static object GetCopilotClientOptions(SquadAgent agent) + { + var client = GetRequiredField(agent, "_copilotClient"); + return GetRequiredField(client, "_options"); + } + + private static object GetInnerSessionConfig(SquadAgent agent) + { + var inner = GetRequiredField(agent, "_inner"); + return GetRequiredField(inner, "_sessionConfig"); + } + + private static T GetRequiredField(object instance, string fieldName) + { + var field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + var value = field.GetValue(instance); + Assert.NotNull(value); + return (T)value; + } + + private static T GetRequiredProperty(object instance, string propertyName) + { + var value = GetProperty(instance, propertyName); + Assert.NotNull(value); + return value; + } + + private static T? GetProperty(object instance, string propertyName) + { + var property = instance.GetType().GetProperty( + propertyName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + Assert.NotNull(property); + return (T?)property.GetValue(instance); + } +} From 67f38628e3273a8075c03d3e7bf6b50ff031c61e Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 2 Jun 2026 13:25:24 +0300 Subject: [PATCH 10/20] ci(squad-agents-ai): add .NET build/test/pack workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3 CI was Node/docs-only — adding the .NET gate so green actually reflects the package code. Matrix on ubuntu + windows, restore/build/test/pack, uploads TestResults and nupkg artifacts. Closes the build-verification gap identified during squad-squad onboarding (see .squad/decisions.md adoption record, 2026-06-02). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-agents-ai-ci.yml | 83 ++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 .github/workflows/squad-agents-ai-ci.yml diff --git a/.github/workflows/squad-agents-ai-ci.yml b/.github/workflows/squad-agents-ai-ci.yml new file mode 100644 index 000000000..d0621ee09 --- /dev/null +++ b/.github/workflows/squad-agents-ai-ci.yml @@ -0,0 +1,83 @@ +name: Squad.Agents.AI CI + +on: + pull_request: + paths: + - 'src/Squad.Agents.AI/**' + - 'test/Squad.Agents.AI.Tests/**' + - '.github/workflows/squad-agents-ai-ci.yml' + - 'Directory.*.props' + - 'Directory.*.json' + - 'global.json' + - 'nuget.config' + push: + branches: [main] + paths: + - 'src/Squad.Agents.AI/**' + - 'test/Squad.Agents.AI.Tests/**' + - '.github/workflows/squad-agents-ai-ci.yml' + - 'Directory.*.props' + - 'Directory.*.json' + - 'global.json' + - 'nuget.config' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + dotnet: + name: .NET ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore package + run: dotnet restore src/Squad.Agents.AI/Squad.Agents.AI.csproj -p:NuGetAudit=true + + - name: Restore tests + run: dotnet restore test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj -p:NuGetAudit=true + + - name: Build package + run: dotnet build src/Squad.Agents.AI/Squad.Agents.AI.csproj --no-restore -c Release + + - name: Build tests + run: dotnet build test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj --no-restore -c Release + + - name: Test + run: dotnet test test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj --no-build -c Release --logger trx --results-directory TestResults + + - name: Pack + run: dotnet pack src/Squad.Agents.AI/Squad.Agents.AI.csproj --no-build -c Release -o nupkgs + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: squad-agents-ai-testresults-${{ matrix.os }} + path: TestResults + if-no-files-found: ignore + + - name: Upload NuGet packages + uses: actions/upload-artifact@v4 + with: + name: squad-agents-ai-nupkgs-${{ matrix.os }} + path: nupkgs + if-no-files-found: error From 2c6c3f4bc59458ef5922a9b92bcf0d8abf9063ed Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 2 Jun 2026 14:19:44 +0300 Subject: [PATCH 11/20] ci(squad-agents-ai): NuGet publish workflow + Dependabot config - .github/workflows/squad-agents-ai-release.yml: workflow_dispatch and tag-driven publish to nuget.org with --skip-duplicate idempotency, fail-fast on missing NUGET_API_KEY secret, optional GitHub Release on tag - .github/dependabot.yml: nuget (src + test) + github-actions, weekly, M.A.AI major allowed, OpenTelemetry major deferred (per Decision 602) Closes the release-pipeline + supply-chain-tracking gaps identified during squad-squad onboarding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/dependabot.yml | 75 +++++++++ .github/workflows/squad-agents-ai-release.yml | 159 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/squad-agents-ai-release.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..5bd7bcc99 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,75 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/src/Squad.Agents.AI" + schedule: + interval: "weekly" + day: "monday" + time: "08:00" + timezone: "Etc/UTC" + open-pull-requests-limit: 5 + labels: ["dependencies", "squad:belanna"] + commit-message: + prefix: "deps" + groups: + microsoft-ai-majors: + patterns: + - "Microsoft.Agents.AI*" + - "Microsoft.Extensions.AI" + update-types: + - "major" + microsoft-packages: + patterns: + - "Microsoft.*" + update-types: + - "minor" + - "patch" + ignore: + - dependency-name: "OpenTelemetry*" + update-types: + - "version-update:semver-major" + + - package-ecosystem: "nuget" + directory: "/test/Squad.Agents.AI.Tests" + schedule: + interval: "weekly" + day: "monday" + time: "08:00" + timezone: "Etc/UTC" + open-pull-requests-limit: 5 + labels: ["dependencies", "squad:belanna"] + commit-message: + prefix: "deps" + groups: + microsoft-ai-majors: + patterns: + - "Microsoft.Agents.AI*" + - "Microsoft.Extensions.AI" + update-types: + - "major" + microsoft-packages: + patterns: + - "Microsoft.*" + update-types: + - "minor" + - "patch" + ignore: + - dependency-name: "OpenTelemetry*" + update-types: + - "version-update:semver-major" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "08:00" + timezone: "Etc/UTC" + open-pull-requests-limit: 5 + labels: ["dependencies", "squad:belanna"] + commit-message: + prefix: "ci" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/squad-agents-ai-release.yml b/.github/workflows/squad-agents-ai-release.yml new file mode 100644 index 000000000..814739aa3 --- /dev/null +++ b/.github/workflows/squad-agents-ai-release.yml @@ -0,0 +1,159 @@ +# Requires repo secret NUGET_API_KEY. Generate at nuget.org → API Keys → Create. +# +# ## Failure recovery +# The workflow resolves PACKAGE_VERSION once, builds/tests/packs exactly that version, and uploads +# nupkgs/* before attempting the registry write. If `dotnet pack` succeeds but `dotnet nuget push` +# fails partway, rerun the same workflow/version: NuGet keeps already-pushed packages immutable, +# `--skip-duplicate` makes duplicate pushes no-ops, and any not-yet-pushed package is retried. +# `cancel-in-progress: false` prevents a retry from canceling an in-flight publish for the same version. + +name: Squad.Agents.AI NuGet Release + +on: + workflow_dispatch: + inputs: + version: + description: 'NuGet package version, for example 0.1.0-preview' + required: true + type: string + push: + tags: + - 'squad-agents-ai-v*' + +permissions: + contents: write + +jobs: + resolve-version: + name: Resolve package version + runs-on: ubuntu-latest + outputs: + package-version: ${{ steps.version.outputs.package-version }} + steps: + - name: Resolve version from input or tag + id: version + shell: bash + run: | + set -euo pipefail + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + package_version="${{ inputs.version }}" + else + tag_name="${GITHUB_REF_NAME}" + tag_prefix="squad-agents-ai-v" + if [[ "${tag_name}" != ${tag_prefix}* ]]; then + echo "::error::Expected tag to start with ${tag_prefix}; got ${tag_name}." + exit 1 + fi + package_version="${tag_name#${tag_prefix}}" + fi + + if [[ -z "${package_version}" ]]; then + echo "::error::PACKAGE_VERSION resolved to empty. Provide workflow_dispatch input 'version' or use tag squad-agents-ai-v." + exit 1 + fi + + if [[ ! "${package_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::PACKAGE_VERSION '${package_version}' is not a SemVer 2.0 version." + exit 1 + fi + + echo "package-version=${package_version}" >> "${GITHUB_OUTPUT}" + + publish: + name: Pack and publish NuGet package + runs-on: ubuntu-latest + needs: resolve-version + concurrency: + group: squad-agents-ai-release-${{ needs.resolve-version.outputs.package-version }} + cancel-in-progress: false + env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' + PACKAGE_VERSION: ${{ needs.resolve-version.outputs.package-version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Verify NuGet API key + shell: bash + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + set -euo pipefail + if [[ -z "${NUGET_API_KEY}" ]]; then + echo "::error::Missing repo secret NUGET_API_KEY. Generate at nuget.org → API Keys → Create, then add it as a repository secret." + exit 1 + fi + + - name: Restore package + run: dotnet restore src/Squad.Agents.AI/Squad.Agents.AI.csproj + + - name: Restore tests + run: dotnet restore test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj + + - name: Build package + run: dotnet build src/Squad.Agents.AI/Squad.Agents.AI.csproj -c Release --no-restore -p:Version=${{ env.PACKAGE_VERSION }} + + - name: Build tests + run: dotnet build test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj -c Release --no-restore -p:Version=${{ env.PACKAGE_VERSION }} + + - name: Test + run: dotnet test test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj -c Release --no-build + + - name: Pack + run: dotnet pack src/Squad.Agents.AI/Squad.Agents.AI.csproj -c Release --no-build -o nupkgs -p:Version=${{ env.PACKAGE_VERSION }} + + - name: Upload NuGet package artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: squad-agents-ai-nupkgs-${{ env.PACKAGE_VERSION }} + path: nupkgs/* + if-no-files-found: ignore + + - name: Push to NuGet.org + shell: bash + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: dotnet nuget push nupkgs/*.nupkg --source https://api.nuget.org/v3/index.json --api-key "${NUGET_API_KEY}" --skip-duplicate + + - name: Prepare GitHub Release notes + if: startsWith(github.ref, 'refs/tags/squad-agents-ai-v') + shell: bash + run: | + set -euo pipefail + + awk -v version="${PACKAGE_VERSION}" ' + BEGIN { target = "## [" version "]"; in_section = 0; found = 0 } + /^## / { + if (in_section) { exit } + if (index($0, target) == 1) { in_section = 1; found = 1; next } + } + in_section { print } + END { if (!found) { exit 1 } } + ' CHANGELOG.md > release-notes.md || \ + awk ' + /^## \[Unreleased\]/ { in_section = 1; found = 1; next } + /^## / && in_section { exit } + in_section { print } + END { if (!found) { exit 1 } } + ' CHANGELOG.md > release-notes.md || \ + printf 'Squad.Agents.AI %s\n' "${PACKAGE_VERSION}" > release-notes.md + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/squad-agents-ai-v') + uses: softprops/action-gh-release@v2 + with: + name: Squad.Agents.AI ${{ env.PACKAGE_VERSION }} + tag_name: ${{ github.ref_name }} + body_path: release-notes.md + files: nupkgs/*.nupkg From 180b96494ce411411338a05c4e3f8ae511957e36 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 2 Jun 2026 14:27:09 +0300 Subject: [PATCH 12/20] docs(squad-agents-ai): release-ready docs + .csproj packaging metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README updates / XML docs on public surface - CHANGELOG.md with [0.1.0-preview] - 2026-06-02 entry - .csproj: Description, RepositoryUrl, Authors, PackageTags, PackageReadmeFile - Verified via dotnet pack — .nupkg contains README, LICENSE, xml-docs Closes the docs-readiness gap for v0.1-preview publish. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 + README.md | 4 + src/Squad.Agents.AI/README.md | 330 +++++---------- src/Squad.Agents.AI/Squad.Agents.AI.csproj | 64 +-- src/Squad.Agents.AI/SquadAgent.cs | 384 ++++++++++-------- src/Squad.Agents.AI/SquadAgentOptions.cs | 147 ++++--- src/Squad.Agents.AI/SquadConnectionFactory.cs | 203 ++++----- .../SquadServiceCollectionExtensions.cs | 142 ++++--- 8 files changed, 639 insertions(+), 643 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb2fbc6a..7f542688b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,14 @@ First stable release since v0.9.4 (April 25). Consumes 97 changesets (sdk: 50, c ### Internal / Tests / Docs - (cli) docs: point README migration guide links to the published docs site +## [0.1.0-preview] - 2026-06-02 + +### Added — Squad.Agents.AI NuGet preview +- Added `Squad.Agents.AI`, a `net10.0` Microsoft Agent Framework package that exposes a Squad team as an `AIAgent`. +- Public surface: `SquadAgent`, `SquadAgentOptions`, `SquadConnectionFactory`, and `SquadServiceCollectionExtensions`. +- Added DI registration, PATH/URI connection-string parsing, GitHub token/provider options, and XML documentation for the public preview surface. +- Documented PR #3 lineage for the v0.1-preview package in `src/Squad.Agents.AI/README.md`. + ## [0.9.0] - 2026-03-23 ### Added — Personal Squad Governance Layer diff --git a/README.md b/README.md index 5ee2e61ec..e1e2af174 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,10 @@ Squad proposes a team — each member named from a persistent thematic cast. You --- +## .NET package preview + +Building a .NET app that should call a Squad team as a Microsoft Agent Framework agent? PR #3 adds `Squad.Agents.AI`, a preview NuGet package under [`src/Squad.Agents.AI`](src/Squad.Agents.AI/README.md). It registers a Squad-backed `AIAgent` in DI and targets early `0.1.0-preview` consumers. + ## Upgrading Upgrading Squad is a two-step process. diff --git a/src/Squad.Agents.AI/README.md b/src/Squad.Agents.AI/README.md index a3dae154f..ecff8a0ff 100644 --- a/src/Squad.Agents.AI/README.md +++ b/src/Squad.Agents.AI/README.md @@ -1,220 +1,110 @@ -# Squad.Agents.AI - -> ⚠️ **Preview package.** This NuGet depends on the preview `Microsoft.Agents.AI.GitHub.Copilot` SDK (`1.7.0-preview.*`). Expect breaking changes until Microsoft Agent Framework (MAF) ships a stable release. Use the `0.1.0-preview` package only for evaluation, prototyping, and early-adopter feedback — not for production workloads. - -## What is Squad.Agents.AI? - -**Squad.Agents.AI** lets you embed the [Squad multi-agent CLI](https://github.com/bradygaster/squad) as a standard Microsoft Agent Framework (`AIAgent`) inside any .NET application. Instead of spawning a subprocess or calling `npx squad-cli` from your code, you get a first-class agent that integrates with dependency injection (DI), structured logging, lifecycle management, and composition — just like any other MAF agent. - -**Why use it?** If you've built a Squad team to coordinate multiple agents, you can now consume that team's brainpower directly from your .NET app without shell-out overhead. You get an `AIAgent` you can compose, await, and stream from. - -### What is Squad? - -Squad is an open-source multi-agent CLI framework that orchestrates autonomous agents via a local `.squad/` directory. For a full introduction, see the [bradygaster/squad repository](https://github.com/bradygaster/squad). - -## Prerequisites - -- **[.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)** — required to build and run the application -- **GitHub Copilot CLI** — must be on your PATH - - Install: `npm install -g @github/copilot-cli` - - Or: follow the [Copilot CLI install docs](https://github.com/github/copilot-cli) - - Verify: run `copilot --version` in a terminal -- **GitHub Personal Access Token** — for authentication - - Create a token at https://github.com/settings/tokens - - Store in environment variable: `GH_TOKEN` or `GITHUB_TOKEN` - - Scopes needed: at minimum `read:user` and `repo` (if accessing private repos) -- **A Squad team root** — either: - - An existing `.squad/` directory from a Squad-managed repository, OR - - Create a new one: run `npx @bradygaster/squad-cli init` in an empty folder -- **[.NET Aspire 9.x+](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview)** *(optional, Aspire path only)* - -## Quick start — Hello World console app - -1. **Create a console app:** - ```bash - dotnet new console -o squad-hello - cd squad-hello - dotnet add package Squad.Agents.AI --prerelease - dotnet add package Microsoft.Extensions.Hosting - ``` - -2. **Replace `Program.cs` with:** - ```csharp - using Microsoft.Agents.AI; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Hosting; - using Squad.Agents.AI; - - var builder = Host.CreateApplicationBuilder(args); - builder.Services.AddSquadAgent(opts => - { - opts.SquadFolderPath = @"C:\path\to\your\team-root"; // point to your .squad/ parent - opts.GitHubToken = Environment.GetEnvironmentVariable("GH_TOKEN"); - opts.Instructions = "You are a helpful assistant."; - }); - - using var host = builder.Build(); - var squad = host.Services.GetRequiredService(); - var response = await squad.RunAsync("What can you do?"); - Console.WriteLine(response.Text); - ``` - -3. **Set your environment variable and run:** - ```bash - # Windows - $env:GH_TOKEN = "ghp_your_token_here" - dotnet run - - # macOS / Linux - export GH_TOKEN="ghp_your_token_here" - dotnet run - ``` - - **Expected output:** A printed response from your Squad team. - -## Aspire vs. Standalone — when to use which - -| Scenario | Use Aspire | Use Standalone | -|---|---|---| -| Building an Aspire AppHost that orchestrates multiple services | ✅ | | -| Single console or worker app | | ✅ | -| Web API or service that consumes Squad | ✅ (if AppHost-managed) | ✅ (if standalone) | -| Unit tests or test fixtures | | ✅ | - -### Aspire path (3 lines + 1 line) - -In your **AppHost**: -```csharp -var squad = builder.AddSquad("squad", teamRoot: "../my-team-root"); -``` - -In your **consumer service** (Program.cs): -```csharp -builder.Services.AddSquadAgent(); // Aspire auto-injects ConnectionStrings__squad -``` - -### Standalone path - -See **Quick start** above — configure manually with `SquadAgentOptions`. - -## Configuration options - -All options are members of `SquadAgentOptions`: - -| Option | Type | Default | Description | Security Note | -|---|---|---|---|---| -| `SquadFolderPath` | `string?` | `null` | Path to the Squad team root (parent of `.squad/` directory). Required unless using Aspire. | Must be readable by the application | -| `CliPath` | `string?` | `null` | Override the Copilot CLI executable path. If `null`, uses the default system PATH lookup. | If specified, ensure the path is trusted and not user-controllable | -| `CliArgs` | `IList` | empty | Extra CLI arguments to pass to the Copilot/Squad CLI process. Example: `["--yolo"]` for non-interactive mode. | Avoid unsanitized user input | -| `Cwd` | `string?` | `SquadFolderPath` | Working directory for the CLI process. Defaults to `SquadFolderPath`. | | -| `Environment` | `IDictionary` | empty | Environment variables to inject into the CLI process. | Secrets should use `GitHubToken` or `GitHubTokenProvider`, not this dictionary | -| `GitHubToken` | `string?` | from `GH_TOKEN` env var | GitHub Personal Access Token for Copilot CLI authentication. If `null`, honors the `GH_TOKEN` or `GITHUB_TOKEN` environment variable. **For development only.** | **Stored in plaintext in DI snapshots.** Never hardcode. For production, use `GitHubTokenProvider` instead. Minimum scopes: `read:user`, `repo` | -| `GitHubTokenProvider` | `Func>?` | `null` | **Production-recommended.** Async callback to provide tokens on-demand (e.g., from KeyVault, managed identity, or secure token service). Takes precedence over `GitHubToken` if set. | **Recommended for production.** Keeps tokens out of DI snapshots. The callback receives a cancellation token for long-running token refresh. | -| `TraceEvents` | `bool` | `false` | Enable verbose event tracing via `ILogger`. If `true` in non-Development environments, `AddSquadAgent` logs a warning. | Trace logs may contain sensitive information; disable in production | -| `AgentName` | `string` | `"Squad"` | Display name for the resulting `AIAgent`. Used in logs and agent composition. | | -| `Instructions` | `string?` | `null` | Optional system message / instructions passed as `SessionConfig.SystemMessage`. Useful for injecting custom Squad boundary rules. | | - -## Connection string format - -If you're using Aspire, the AppHost emits a connection string that `SquadAgent` parses automatically — you don't need to know the format. If you're calling `SquadConnectionFactory.FromConnectionString` directly (standalone or advanced scenarios), understand these two forms: - -### PATH form (default) -``` -C:\path\to\team-root -/Users/me/team-root -``` -Treated as a literal filesystem path to your Squad team root. `Cwd` defaults to the same path. - -### URI form (escape hatch) -``` -squad://localhost?teamRoot=C:\path\to\team-root&cliPath=C:\custom\copilot&protocol=maf-1.0 -``` -Used when you need to customize multiple options. Supported query parameters: -- `teamRoot` — path to team root -- `cliPath` — override CLI executable path -- `cwd` — working directory -- `cliArgs` — CLI args (semicolon-separated) -- `env` — environment variables (format: `key1=val1;key2=val2`) -- `protocol` — reserved for future use (AFCP — Agent-to-Agent Communication Protocol) - -**Current status:** Both forms are supported; the URI form is reserved for future extensibility. - -## Troubleshooting - -### `copilot: command not found` -The Copilot CLI is not on your PATH. -- **Fix:** Install with `npm install -g @github/copilot-cli` -- **Or:** Verify installation: `which copilot` (macOS/Linux) or `where copilot` (Windows) -- **Or:** Specify the path explicitly: `opts.CliPath = @"C:\path\to\copilot.exe"` - -### `Team root not found at {path}` -The `SquadFolderPath` points to a directory that doesn't exist or isn't accessible. -- **Fix:** Verify the path exists and contains a `.squad/` subdirectory -- **Fix:** If starting fresh, run `npx @bradygaster/squad-cli init` to create a team root -- **Fix:** Check file permissions — the app process must be able to read the path - -### `Unauthorized (401)` or `authentication failed` -Your GitHub token is missing, expired, or lacks required scopes. -- **Fix:** Verify `GH_TOKEN` or `GITHUB_TOKEN` is set: `echo $env:GH_TOKEN` (PowerShell) or `echo $GH_TOKEN` (bash) -- **Fix:** Generate a new token at https://github.com/settings/tokens -- **Fix:** Ensure the token has scopes: `read:user`, `repo` -- **Fix:** If using `opts.GitHubToken` directly, verify it's not hardcoded or committed - -### `Timeout` or agent hangs indefinitely -The Copilot CLI is waiting for interactive input but your app can't provide it. -- **Fix:** Pass `--yolo` flag to bypass interactivity: `opts.CliArgs.Add("--yolo")` -- **Fix:** Check that `SquadFolderPath` points to a valid, initialized Squad team -- **Fix:** Increase timeout in `AgentRunOptions` if the agent is genuinely busy - -### `GitHubCopilot not found` or missing transitive dependency -The NuGet depends on `Microsoft.Agents.AI.GitHub.Copilot 1.7.0-preview.*`, which must be available. -- **Fix:** Ensure your NuGet feed includes the preview package source (usually the default NuGet.org) -- **Fix:** Confirm you've run `dotnet restore` - -## FAQ - -### Do I need a GitHub Copilot subscription? -Yes. The GitHub Copilot CLI requires an active GitHub Copilot subscription to authenticate and function. - -### Can I use this without Aspire? -Yes. See the **Standalone path** section and the **Quick start** console example above. Aspire is optional and meant for multi-service orchestration scenarios. - -### Is this production-ready? -No. See the **Preview package** callout at the top. This package depends on preview MAF transitive dependencies. Use only for evaluation, prototyping, and early-adopter feedback. - -### How is this different from calling the CLI directly? -Instead of forking a subprocess with `Process.Start("npx squad-cli ...")`, you get: -- Dependency injection and composability -- Structured logging and tracing -- Automatic resource cleanup (via `IAsyncDisposable`) -- First-class MAF agent composition and streaming -- Better error handling and lifecycle management - -### Where do I report issues or contribute? -- Issues: [Squad GitHub repository](https://github.com/bradygaster/squad/issues) -- Track A (this NuGet): [PR #3 on tamirdresher/squad](https://github.com/tamirdresher/squad/pull/3) -- Feedback on preview: Please comment on the PR with use cases and pain points - -## Status & roadmap - -### v0.1.0-preview (current) -- ✅ `SquadAgent` wraps `GitHubCopilotAgent` for DI composition -- ✅ `SquadAgentOptions` configuration -- ✅ Hybrid PATH+URI connection string parsing -- ✅ Aspire integration (via Track B: `CommunityToolkit.Aspire.Hosting.Squad`) - -### v0.2 candidates -- Event streaming hooks (structured observability) -- Keyed DI for multi-tenant / multiple Squad teams in one app -- Health checks and readiness probes -- Multi-target `net8;net9;net10` (currently `net10` only) -- AFCP (Agent-to-Agent Communication Protocol) support via URI form - -### See also -- [Squad CLI repository](https://github.com/bradygaster/squad) -- [Microsoft Agent Framework (MAF)](https://github.com/microsoft/agents) -- [GitHub Copilot CLI](https://github.com/github/copilot-cli) - ---- - -**License:** MIT — same as Squad CLI and Microsoft Agent Framework. +# Squad.Agents.AI + +> **Preview package.** `Squad.Agents.AI` is a `0.1.0-preview` NuGet package for early adopters. It targets `net10.0` and depends on preview Microsoft Agent Framework / GitHub Copilot SDK packages, so APIs may change before a stable release. + +## What it does + +`Squad.Agents.AI` exposes a Squad team as a Microsoft Agent Framework `AIAgent`. It builds a GitHub Copilot SDK client with Squad-specific options, delegates MAF sessions/runs/streaming to the inner Copilot agent, and gives .NET consumers a DI-friendly wrapper instead of hand-rolling CLI process setup. + +Public surface in this preview: + +- `SquadAgent` — sealed `AIAgent` wrapper over the Copilot-backed inner agent. +- `SquadAgentOptions` — team root, CLI path/args, environment, token, logging, and instruction settings. +- `SquadConnectionFactory` — parses PATH or `squad://` connection strings into options. +- `SquadServiceCollectionExtensions` — registers `SquadAgent` and base `AIAgent` in DI. + +Repository: + +## Install + +```bash +dotnet add package Squad.Agents.AI --prerelease +``` + +If you are consuming the PR before publish, pack it locally and add the generated package source: + +```bash +dotnet pack src/Squad.Agents.AI/Squad.Agents.AI.csproj -c Release -o nupkgs +dotnet add package Squad.Agents.AI --prerelease --source ./nupkgs +``` + +## Prerequisites + +- .NET 10 SDK. +- GitHub Copilot CLI available on `PATH` (`copilot --version`). +- Squad CLI and an initialized team root containing `.squad/`; see the [Squad CLI repo](https://github.com/tamirdresher/squad). +- GitHub Copilot authentication. Prefer `GitHubTokenProvider`; if passing a token directly, use a Copilot-supported token format (`github_pat_`, `gho_`, or `ghu_`) and never hardcode it. + +## Five-line quickstart + +```csharp +using Microsoft.Agents.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Squad.Agents.AI; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddSquadAgent(o => +{ + o.SquadFolderPath = @"C:\path\to\team-root"; + o.GitHubTokenProvider = _ => ValueTask.FromResult(Environment.GetEnvironmentVariable("GH_TOKEN")); +}); + +using var host = builder.Build(); +var squad = host.Services.GetRequiredService(); +var session = await squad.CreateSessionAsync(); +var response = await squad.RunAsync("What can this Squad team do?", session); +Console.WriteLine(response.Text); +``` + +## Aspire / configuration path + +`AddSquadAgent()` also reads `ConnectionStrings:squad` through `IConfiguration.GetConnectionString("squad")`. In environment variables, use `ConnectionStrings__squad`. + +Supported connection string forms: + +```text +C:\path\to\team-root +squad://localhost?teamRoot=C%3A%5Cteam&cliPath=C%3A%5Ctools%5Ccopilot.exe&cliArgs=--yolo&env=KEY=value +``` + +Parsed URI query keys: `teamRoot`, `cliPath`, `cwd`, `cliArgs` (semicolon-separated), and `env` (`key=value;key2=value2`). Unknown URI host/protocol values are reserved for future use. + +## Key options + +| Option | Purpose | +|---|---| +| `SquadFolderPath` | Squad team root; PATH connection strings set this and `Cwd`. | +| `CliPath` / `CliArgs` | Override the Copilot CLI executable and add extra CLI flags. | +| `Cwd` | Working directory for the Copilot CLI process; defaults to `SquadFolderPath`. | +| `Environment` | Additional environment variables for the CLI process. Avoid user-controlled values. | +| `GitHubToken` | Direct token for local/dev use; redacted by `ToString()`. | +| `GitHubTokenProvider` | Preferred callback for secure token retrieval; wins over `GitHubToken`. | +| `TraceEvents` | Enables verbose SDK logging and emits a startup warning when enabled. | +| `AgentName` | Display name for the resulting `AIAgent`; defaults to `Squad`. | +| `Instructions` | Optional system instructions passed to the inner Copilot agent. | + +## Notes for v0.1-preview + +- Default DI lifetime is scoped. An overload accepts any `ServiceLifetime`. +- DI registers both `SquadAgent` and base `AIAgent`. +- No keyed DI, multi-targeting, BYOK/session-provider pass-through, or richer native event streaming yet; these are v0.2 candidates. +- The package does not validate that `SquadFolderPath` exists or contains `.squad/`; consumers should validate their deployment paths. +- `TraceEvents` can log sensitive operational details. Keep it off unless debugging. + +## Package contents + +- `lib/net10.0/Squad.Agents.AI.dll` +- `lib/net10.0/Squad.Agents.AI.xml` for IntelliSense / API docs +- `README.md` for NuGet.org rendering +- `.nuspec` metadata with authors, tags, repository, and readme pointer +- `LICENSE` copied from the repository root + +## See also + +- Root repo: +- Microsoft Agent Framework: +- GitHub Copilot CLI: +- Changelog: + +License: MIT. diff --git a/src/Squad.Agents.AI/Squad.Agents.AI.csproj b/src/Squad.Agents.AI/Squad.Agents.AI.csproj index cbcfdbe4e..64bb08270 100644 --- a/src/Squad.Agents.AI/Squad.Agents.AI.csproj +++ b/src/Squad.Agents.AI/Squad.Agents.AI.csproj @@ -1,31 +1,33 @@ - - - net10.0 - enable - enable - latest - true - Squad.Agents.AI - Squad Agents AI - Community SquadAgent for Microsoft Agent Framework. Exposes the Squad multi-agent CLI as an AIAgent that can be composed into any MAF app or workflow. - Squad contributors - squad;agent-framework;maf;copilot;ai-agent - https://github.com/bradygaster/squad - git - MIT - README.md - true - 0.1.0-preview - - - - - - - - - - - - - + + + net10.0 + enable + enable + latest + true + Squad.Agents.AI + Squad Agents AI + Community SquadAgent for Microsoft Agent Framework. Exposes the Squad multi-agent CLI as an AIAgent that can be composed into any MAF app or workflow. + Tamir Dresher + squad;agents;ai;copilot;maf;multi-agent + https://github.com/tamirdresher/squad + https://github.com/tamirdresher/squad.git + git + MIT + README.md + true + 0.1.0-preview + + + + + + + + + + + + + + diff --git a/src/Squad.Agents.AI/SquadAgent.cs b/src/Squad.Agents.AI/SquadAgent.cs index d70a56053..45df5cd60 100644 --- a/src/Squad.Agents.AI/SquadAgent.cs +++ b/src/Squad.Agents.AI/SquadAgent.cs @@ -1,168 +1,218 @@ -using GitHub.Copilot.SDK; +using GitHub.Copilot.SDK; using Microsoft.Agents.AI; -using System.Text.Json; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Squad.Agents.AI; - -/// -/// A Squad-flavored that composes a GitHub Copilot -/// agent configured for Squad multi-agent CLI. -/// -/// Value-add over bare MAF GitHubCopilotAgent: -/// -/// Squad boundary instructions injected at construction time. -/// Squad-specific configuration (team root, CLI path, env vars). -/// DI-friendly: registered via . -/// -/// -public sealed class SquadAgent : AIAgent, IAsyncDisposable -{ - private readonly CopilotClient _copilotClient; - private readonly AIAgent _inner; - private readonly ILogger? _logger; - private readonly bool _ownsClient; - private readonly SquadAgentOptions _options; - - /// - /// Initializes a from fully-resolved options. - /// The is created and owned by this instance. - /// - public SquadAgent(SquadAgentOptions options, ILoggerFactory? loggerFactory = null) - : this(CreateCopilotClient(options, loggerFactory), options, loggerFactory, ownsClient: true) - { - } - - /// - /// Initializes a from IOptions pattern. - /// - public SquadAgent(IOptions options, ILoggerFactory? loggerFactory = null) - : this(options.Value, loggerFactory) - { - } - - private SquadAgent(CopilotClient copilotClient, SquadAgentOptions options, ILoggerFactory? loggerFactory, bool ownsClient) - { - _copilotClient = copilotClient ?? throw new ArgumentNullException(nameof(copilotClient)); - _logger = loggerFactory?.CreateLogger(); - _ownsClient = ownsClient; - _options = options; - - // Create the inner AIAgent via AsAIAgent extension — NO CAST - _inner = _copilotClient.AsAIAgent( - instructions: options.Instructions, - name: options.AgentName ?? "Squad"); - - _logger?.LogInformation("SquadAgent initialized with name '{AgentName}', team root '{TeamRoot}'", - options.AgentName, options.SquadFolderPath); - } - - private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILoggerFactory? loggerFactory) - { - // Resolve token: provider takes precedence over direct property - string? resolvedToken = null; - if (options.GitHubTokenProvider is not null) - { - // Call provider synchronously (constructor context requires sync) - resolvedToken = options.GitHubTokenProvider(CancellationToken.None).GetAwaiter().GetResult(); - } - else - { - resolvedToken = options.GitHubToken; - } - - var clientOptions = new CopilotClientOptions - { - CliPath = options.CliPath, - Cwd = options.Cwd ?? options.SquadFolderPath, - GitHubToken = resolvedToken - }; - - // Copy CLI args - if (options.CliArgs.Count > 0) - { - clientOptions.CliArgs = options.CliArgs.ToArray(); - } - - // Copy environment variables - if (options.Environment.Count > 0) - { - var envDict = new Dictionary(); - foreach (var kvp in options.Environment) - { - if (kvp.Value != null) - envDict[kvp.Key] = kvp.Value; - } - clientOptions.Environment = envDict; - } - - // Set logger if trace events enabled - if (options.TraceEvents && loggerFactory != null) - { - clientOptions.Logger = loggerFactory.CreateLogger(); - } - - return new CopilotClient(clientOptions); - } - - // AIAgent abstract method overrides — delegate to _inner - - public override string? Name => _options.AgentName ?? _inner.Name; - - public override string? Description => _inner.Description ?? "Squad multi-agent CLI participant"; - - protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) - { - return _inner.CreateSessionAsync(cancellationToken); - } - - protected override Task RunCoreAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - return _inner.RunAsync(messages, session, options, cancellationToken); - } - - protected override IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - return _inner.RunStreamingAsync(messages, session, options, cancellationToken); - } - - protected override ValueTask SerializeSessionCoreAsync( - AgentSession session, - JsonSerializerOptions? jsonSerializerOptions = null, - CancellationToken cancellationToken = default) - { - return _inner.SerializeSessionAsync(session, jsonSerializerOptions, cancellationToken); - } - - protected override ValueTask DeserializeSessionCoreAsync( - JsonElement sessionState, - JsonSerializerOptions? jsonSerializerOptions = null, - CancellationToken cancellationToken = default) - { - return _inner.DeserializeSessionAsync(sessionState, jsonSerializerOptions, cancellationToken); - } - - public async ValueTask DisposeAsync() - { - if (_ownsClient) - { - await _copilotClient.DisposeAsync().ConfigureAwait(false); - } - - if (_inner is IAsyncDisposable innerDisposable) - { - await innerDisposable.DisposeAsync().ConfigureAwait(false); - } - - _logger?.LogDebug("SquadAgent disposed"); - } -} +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Squad.Agents.AI; + +/// +/// Microsoft Agent Framework agent that delegates to a GitHub Copilot SDK client configured for a Squad team root. +/// +/// +/// +/// Use +/// for DI registration in applications. Direct construction is useful for tests or simple console hosts. +/// +/// +/// +/// var agent = new SquadAgent(new SquadAgentOptions { SquadFolderPath = @"C:\repo" }); +/// var response = await agent.RunAsync("Summarize the team."); +/// +/// +/// +public sealed class SquadAgent : AIAgent, IAsyncDisposable +{ + private readonly CopilotClient _copilotClient; + private readonly AIAgent _inner; + private readonly ILogger? _logger; + private readonly bool _ownsClient; + private readonly SquadAgentOptions _options; + + /// + /// Initializes a new from fully-resolved options and owns the created Copilot client. + /// + /// Options that describe the Squad team root, CLI process, authentication, logging, and instructions. + /// Optional logger factory used for wrapper logs and SDK trace logging when enabled. + public SquadAgent(SquadAgentOptions options, ILoggerFactory? loggerFactory = null) + : this(CreateCopilotClient(options, loggerFactory), options, loggerFactory, ownsClient: true) + { + } + + /// + /// Initializes a new from the options pattern. + /// + /// Options wrapper containing the resolved . + /// Optional logger factory used for wrapper logs and SDK trace logging when enabled. + public SquadAgent(IOptions options, ILoggerFactory? loggerFactory = null) + : this(options.Value, loggerFactory) + { + } + + private SquadAgent(CopilotClient copilotClient, SquadAgentOptions options, ILoggerFactory? loggerFactory, bool ownsClient) + { + _copilotClient = copilotClient ?? throw new ArgumentNullException(nameof(copilotClient)); + _logger = loggerFactory?.CreateLogger(); + _ownsClient = ownsClient; + _options = options; + + // Create the inner AIAgent via AsAIAgent extension — NO CAST + _inner = _copilotClient.AsAIAgent( + instructions: options.Instructions, + name: options.AgentName ?? "Squad"); + + _logger?.LogInformation("SquadAgent initialized with name '{AgentName}', team root '{TeamRoot}'", + options.AgentName, options.SquadFolderPath); + } + + private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILoggerFactory? loggerFactory) + { + // Resolve token: provider takes precedence over direct property + string? resolvedToken = null; + if (options.GitHubTokenProvider is not null) + { + // Call provider synchronously (constructor context requires sync) + resolvedToken = options.GitHubTokenProvider(CancellationToken.None).GetAwaiter().GetResult(); + } + else + { + resolvedToken = options.GitHubToken; + } + + var clientOptions = new CopilotClientOptions + { + CliPath = options.CliPath, + Cwd = options.Cwd ?? options.SquadFolderPath, + GitHubToken = resolvedToken + }; + + // Copy CLI args + if (options.CliArgs.Count > 0) + { + clientOptions.CliArgs = options.CliArgs.ToArray(); + } + + // Copy environment variables + if (options.Environment.Count > 0) + { + var envDict = new Dictionary(); + foreach (var kvp in options.Environment) + { + if (kvp.Value != null) + envDict[kvp.Key] = kvp.Value; + } + clientOptions.Environment = envDict; + } + + // Set logger if trace events enabled + if (options.TraceEvents && loggerFactory != null) + { + clientOptions.Logger = loggerFactory.CreateLogger(); + } + + return new CopilotClient(clientOptions); + } + + /// + /// Gets the display name exposed by this agent. + /// + public override string? Name => _options.AgentName ?? _inner.Name; + + /// + /// Gets the agent description exposed by the inner Copilot-backed agent, or a Squad fallback description. + /// + public override string? Description => _inner.Description ?? "Squad multi-agent CLI participant"; + + /// + /// Creates a new MAF session by delegating to the inner agent. + /// + /// Token that cancels session creation. + /// A newly-created agent session. + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + { + return _inner.CreateSessionAsync(cancellationToken); + } + + /// + /// Runs a non-streaming agent turn by delegating to the inner agent. + /// + /// Messages to send to the agent. + /// Optional session for multi-turn continuity. + /// Optional run options. + /// Token that cancels the run. + /// The complete agent response. + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + return _inner.RunAsync(messages, session, options, cancellationToken); + } + + /// + /// Runs a streaming agent turn by delegating to the inner agent. + /// + /// Messages to send to the agent. + /// Optional session for multi-turn continuity. + /// Optional run options. + /// Token that cancels the run. + /// An async stream of response updates. + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + return _inner.RunStreamingAsync(messages, session, options, cancellationToken); + } + + /// + /// Serializes a session by delegating to the inner agent. + /// + /// Session to serialize. + /// Optional JSON serializer settings. + /// Token that cancels serialization. + /// The serialized session state. + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + return _inner.SerializeSessionAsync(session, jsonSerializerOptions, cancellationToken); + } + + /// + /// Deserializes a session by delegating to the inner agent. + /// + /// Serialized session state. + /// Optional JSON serializer settings. + /// Token that cancels deserialization. + /// The deserialized session. + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement sessionState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + return _inner.DeserializeSessionAsync(sessionState, jsonSerializerOptions, cancellationToken); + } + + /// + /// Disposes the owned Copilot client and disposable inner agent resources. + /// + /// A value task that completes when disposal finishes. + public async ValueTask DisposeAsync() + { + if (_ownsClient) + { + await _copilotClient.DisposeAsync().ConfigureAwait(false); + } + + if (_inner is IAsyncDisposable innerDisposable) + { + await innerDisposable.DisposeAsync().ConfigureAwait(false); + } + + _logger?.LogDebug("SquadAgent disposed"); + } +} diff --git a/src/Squad.Agents.AI/SquadAgentOptions.cs b/src/Squad.Agents.AI/SquadAgentOptions.cs index 6da15431e..f6ef8ba75 100644 --- a/src/Squad.Agents.AI/SquadAgentOptions.cs +++ b/src/Squad.Agents.AI/SquadAgentOptions.cs @@ -1,59 +1,88 @@ -namespace Squad.Agents.AI; - -/// -/// Configuration for . Bind from "SquadAgent" -/// configuration section (appsettings.json) or Aspire ConnectionStrings via -/// . -/// -public sealed class SquadAgentOptions -{ - /// Path to the Squad team root (folder containing .squad/). - public string? SquadFolderPath { get; set; } - - /// Override the Squad CLI executable path. If null, uses CopilotClient default. - public string? CliPath { get; set; } - - /// Extra CLI args to pass to the Squad/Copilot CLI process. - public IList CliArgs { get; } = new List(); - - /// Working directory for the CLI process. Defaults to SquadFolderPath. - public string? Cwd { get; set; } - - /// Environment variables to inject into the CLI process. - public IDictionary Environment { get; } = new Dictionary(); - - /// - /// GitHub token for Copilot CLI auth. ⚠️ SECURITY: Stored in plaintext in the DI options snapshot. - /// For production, prefer which fetches the token on-demand. - /// This property is intended for development/local use only. Honors GH_TOKEN env if null. - /// - [System.Text.Json.Serialization.JsonIgnore] - public string? GitHubToken { get; set; } - - /// - /// Async token provider for production scenarios. When set, takes precedence over . - /// Use this for KeyVault, managed identity, or any out-of-band token source so the token never lives in DI snapshots. - /// - public Func>? GitHubTokenProvider { get; set; } - - /// - /// Enables verbose tracing through ILogger. Default false. If set true outside - /// Development environments, AddSquadAgent logs a warning to discourage prod use. - /// - public bool TraceEvents { get; set; } = false; - - /// Optional display name for the resulting AIAgent. Defaults to "Squad". - public string AgentName { get; set; } = "Squad"; - - /// Optional system message / instructions passed as SessionConfig.SystemMessage. - public string? Instructions { get; set; } - - /// - /// Redacts sensitive fields (GitHubToken) from diagnostic output to prevent accidental leakage via logging. - /// - public override string ToString() - { - return $"SquadAgentOptions {{ SquadFolderPath = {SquadFolderPath}, AgentName = {AgentName}, " + - $"GitHubToken = [REDACTED], TraceEvents = {TraceEvents} }}"; - } -} +namespace Squad.Agents.AI; + +/// +/// Configures construction, connection-string binding, and Copilot CLI process settings. +/// +/// +/// +/// var options = new SquadAgentOptions +/// { +/// SquadFolderPath = @"C:\repo", +/// GitHubTokenProvider = _ => ValueTask.FromResult(Environment.GetEnvironmentVariable("GH_TOKEN")) +/// }; +/// options.CliArgs.Add("--yolo"); +/// +/// +public sealed class SquadAgentOptions +{ + /// + /// Gets or sets the Squad team root, normally the repository or directory that contains the `.squad` folder. + /// + public string? SquadFolderPath { get; set; } + + /// + /// Gets or sets an explicit Copilot CLI executable path. When unset, the SDK resolves the default CLI from `PATH`. + /// + public string? CliPath { get; set; } + + /// + /// Gets additional CLI arguments appended when the Copilot CLI process is started. + /// + public IList CliArgs { get; } = new List(); + + /// + /// Gets or sets the CLI process working directory. When unset, is used. + /// + public string? Cwd { get; set; } + + /// + /// Gets additional environment variables injected into the CLI process. + /// + public IDictionary Environment { get; } = new Dictionary(); + + /// + /// Gets or sets a direct GitHub token for local development scenarios. + /// + /// + /// The value is ignored by JSON serialization and redacted by , but still lives in the options object. + /// Prefer for production hosts. + /// + [System.Text.Json.Serialization.JsonIgnore] + public string? GitHubToken { get; set; } + + /// + /// Gets or sets the callback that provides a GitHub token during agent construction. + /// + /// + /// When set, this provider takes precedence over . + /// + public Func>? GitHubTokenProvider { get; set; } + + /// + /// Gets or sets whether verbose Copilot SDK logging is enabled. + /// + /// + /// The DI registration emits a startup warning whenever this is enabled because traces may contain sensitive details. + /// + public bool TraceEvents { get; set; } = false; + + /// + /// Gets or sets the display name for the resulting . + /// + public string AgentName { get; set; } = "Squad"; + + /// + /// Gets or sets optional system instructions passed to the inner Copilot-backed agent. + /// + public string? Instructions { get; set; } + + /// + /// Returns a diagnostic representation with sensitive values redacted. + /// + /// A string containing non-secret option values. + public override string ToString() + { + return $"SquadAgentOptions {{ SquadFolderPath = {SquadFolderPath}, AgentName = {AgentName}, " + + $"GitHubToken = [REDACTED], TraceEvents = {TraceEvents} }}"; + } +} diff --git a/src/Squad.Agents.AI/SquadConnectionFactory.cs b/src/Squad.Agents.AI/SquadConnectionFactory.cs index 2facd0b4b..896d5ad15 100644 --- a/src/Squad.Agents.AI/SquadConnectionFactory.cs +++ b/src/Squad.Agents.AI/SquadConnectionFactory.cs @@ -1,101 +1,102 @@ -namespace Squad.Agents.AI; - -/// -/// Static factory that parses connection strings emitted by CommunityToolkit.Aspire.Hosting.Squad. -/// -public static class SquadConnectionFactory -{ - /// - /// Parses a Squad connection string into SquadAgentOptions. - /// - /// Supported formats (Hybrid wire — Q1 lock): - /// - /// 1) PATH form (default — emitted when SquadFolderPath only): - /// "C:\path\to\team-root" - /// "/Users/me/team-root" - /// Maps directly to SquadFolderPath. Cwd defaults to same. - /// - /// 2) URI form (escape hatch — when extra knobs needed): - /// "squad://localhost?teamRoot=...&cliPath=...&protocol=maf-1.0" - /// Reserved for future AFCP (Agent-to-Agent Framework - /// Communication Protocol) support where host:port matters. For now - /// we only parse query params; host is ignored. - /// - public static SquadAgentOptions FromConnectionString(string connectionString) - { - if (string.IsNullOrWhiteSpace(connectionString)) - { - throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); - } - - var options = new SquadAgentOptions(); - - if (connectionString.StartsWith("squad://", StringComparison.OrdinalIgnoreCase)) - { - // URI form - var uri = new Uri(connectionString); - var query = ParseQueryString(uri.Query); - - if (query.TryGetValue("teamRoot", out var teamRoot)) - options.SquadFolderPath = teamRoot; - - if (query.TryGetValue("cliPath", out var cliPath)) - options.CliPath = cliPath; - - if (query.TryGetValue("cwd", out var cwd)) - options.Cwd = cwd; - - if (query.TryGetValue("cliArgs", out var cliArgsString)) - { - foreach (var arg in cliArgsString.Split(';', StringSplitOptions.RemoveEmptyEntries)) - { - options.CliArgs.Add(arg); - } - } - - // Parse environment variables if present (format: key1=val1;key2=val2) - if (query.TryGetValue("env", out var envString)) - { - foreach (var pair in envString.Split(';', StringSplitOptions.RemoveEmptyEntries)) - { - var parts = pair.Split('=', 2); - if (parts.Length == 2) - { - options.Environment[parts[0]] = parts[1]; - } - } - } - } - else - { - // PATH form — treat entire string as team root path - options.SquadFolderPath = connectionString; - options.Cwd = connectionString; - } - - return options; - } - - private static Dictionary ParseQueryString(string query) - { - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (string.IsNullOrEmpty(query)) - return result; - - // Remove leading '?' if present - if (query.StartsWith('?')) - query = query.Substring(1); - - foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) - { - var parts = pair.Split('=', 2); - if (parts.Length == 2) - { - result[Uri.UnescapeDataString(parts[0])] = Uri.UnescapeDataString(parts[1]); - } - } - - return result; - } -} +namespace Squad.Agents.AI; + +/// +/// Parses Squad connection strings into instances. +/// +public static class SquadConnectionFactory +{ + /// + /// Parses a Squad connection string into agent options. + /// + /// PATH form or `squad://` URI form connection string. + /// Options populated from the supplied connection string. + /// Thrown when is null, empty, or whitespace. + /// + /// PATH form maps the entire value to and . + /// URI form parses `teamRoot`, `cliPath`, `cwd`, `cliArgs`, and `env` query values; the URI host is reserved. + /// + /// + /// + /// var options = SquadConnectionFactory.FromConnectionString(@"C:\repo"); + /// var advanced = SquadConnectionFactory.FromConnectionString( + /// "squad://localhost?teamRoot=C%3A%5Crepo&cliArgs=--yolo"); + /// + /// + public static SquadAgentOptions FromConnectionString(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); + } + + var options = new SquadAgentOptions(); + + if (connectionString.StartsWith("squad://", StringComparison.OrdinalIgnoreCase)) + { + // URI form + var uri = new Uri(connectionString); + var query = ParseQueryString(uri.Query); + + if (query.TryGetValue("teamRoot", out var teamRoot)) + options.SquadFolderPath = teamRoot; + + if (query.TryGetValue("cliPath", out var cliPath)) + options.CliPath = cliPath; + + if (query.TryGetValue("cwd", out var cwd)) + options.Cwd = cwd; + + if (query.TryGetValue("cliArgs", out var cliArgsString)) + { + foreach (var arg in cliArgsString.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + options.CliArgs.Add(arg); + } + } + + // Parse environment variables if present (format: key1=val1;key2=val2) + if (query.TryGetValue("env", out var envString)) + { + foreach (var pair in envString.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = pair.Split('=', 2); + if (parts.Length == 2) + { + options.Environment[parts[0]] = parts[1]; + } + } + } + } + else + { + // PATH form — treat entire string as team root path + options.SquadFolderPath = connectionString; + options.Cwd = connectionString; + } + + return options; + } + + private static Dictionary ParseQueryString(string query) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (string.IsNullOrEmpty(query)) + return result; + + // Remove leading '?' if present + if (query.StartsWith('?')) + query = query.Substring(1); + + foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = pair.Split('=', 2); + if (parts.Length == 2) + { + result[Uri.UnescapeDataString(parts[0])] = Uri.UnescapeDataString(parts[1]); + } + } + + return result; + } +} diff --git a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs index ceb447c42..d49dc8c03 100644 --- a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs +++ b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs @@ -1,65 +1,77 @@ -using Microsoft.Agents.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Squad.Agents.AI; - -/// -/// DI extension methods for registering . -/// -public static class SquadServiceCollectionExtensions -{ - /// - /// Register SquadAgent as a scoped AIAgent. - /// Reads connection string from ConnectionStrings:squad and applies parsed values - /// to options before the user callback runs. User-supplied callback always wins. - /// - public static IServiceCollection AddSquadAgent( - this IServiceCollection services, - Action? configure = null) - => AddSquadAgent(services, ServiceLifetime.Scoped, configure); - - /// Register SquadAgent with specified lifetime. - public static IServiceCollection AddSquadAgent( - this IServiceCollection services, - ServiceLifetime lifetime, - Action? configure = null) - { - // Register connection string configurator FIRST (runs before user callback) - services.TryAddEnumerable(ServiceDescriptor.Singleton, SquadAgentOptionsConfigurator>()); - - if (configure is not null) - { - services.Configure(configure); - } - - // Register SquadAgent with specified lifetime - services.Add(new ServiceDescriptor( - typeof(SquadAgent), - typeof(SquadAgent), - lifetime)); - - // AIAgent base registration — so consumers can resolve via IEnumerable - services.Add(new ServiceDescriptor( - typeof(AIAgent), - sp => sp.GetRequiredService(), - lifetime)); - - // TraceEvents=true outside Development → warn - services.AddOptions() - .PostConfigure((opts, loggerFactory) => - { - if (opts.TraceEvents) - { - var logger = loggerFactory.CreateLogger("Squad.Agents.AI.Startup"); - logger.LogWarning( - "SquadAgentOptions.TraceEvents=true. Verbose tracing is enabled; " + - "not recommended for production use."); - } - }); - - return services; - } -} +using Microsoft.Agents.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Squad.Agents.AI; + +/// +/// Extension methods for registering in dependency injection. +/// +public static class SquadServiceCollectionExtensions +{ + /// + /// Registers and base with scoped lifetime. + /// + /// Service collection to update. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + /// + /// + /// builder.Services.AddSquadAgent(o => o.SquadFolderPath = @"C:\repo"); + /// + /// + public static IServiceCollection AddSquadAgent( + this IServiceCollection services, + Action? configure = null) + => AddSquadAgent(services, ServiceLifetime.Scoped, configure); + + /// + /// Registers and base with the specified lifetime. + /// + /// Service collection to update. + /// Lifetime used for the concrete and base agent registrations. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + public static IServiceCollection AddSquadAgent( + this IServiceCollection services, + ServiceLifetime lifetime, + Action? configure = null) + { + // Register connection string configurator FIRST (runs before user callback) + services.TryAddEnumerable(ServiceDescriptor.Singleton, SquadAgentOptionsConfigurator>()); + + if (configure is not null) + { + services.Configure(configure); + } + + // Register SquadAgent with specified lifetime + services.Add(new ServiceDescriptor( + typeof(SquadAgent), + typeof(SquadAgent), + lifetime)); + + // AIAgent base registration — so consumers can resolve via IEnumerable + services.Add(new ServiceDescriptor( + typeof(AIAgent), + sp => sp.GetRequiredService(), + lifetime)); + + // TraceEvents=true → warn because verbose traces can contain sensitive details + services.AddOptions() + .PostConfigure((opts, loggerFactory) => + { + if (opts.TraceEvents) + { + var logger = loggerFactory.CreateLogger("Squad.Agents.AI.Startup"); + logger.LogWarning( + "SquadAgentOptions.TraceEvents=true. Verbose tracing is enabled; " + + "not recommended for production use."); + } + }); + + return services; + } +} From 69661bfa0f989eaab703d61e9589b59abcaf71e6 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 2 Jun 2026 14:30:49 +0300 Subject: [PATCH 13/20] ci(squad-agents-ai): switch release triggers to dev/main branch-driven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Tamir's release-strategy directive (decisions.md 2026-06-02): - dev merges → prerelease publish (suffix scheme mirrors Squad CLI) - main merges → stable publish - workflow_dispatch retained as manual escape hatch - tag-driven trigger removed (branches are the source of truth) Version derivation pattern adapted from the Squad CLI's existing release workflow. --skip-duplicate retained for idempotent reruns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-agents-ai-release.yml | 169 +++++++++--------- 1 file changed, 85 insertions(+), 84 deletions(-) diff --git a/.github/workflows/squad-agents-ai-release.yml b/.github/workflows/squad-agents-ai-release.yml index 814739aa3..22c04533c 100644 --- a/.github/workflows/squad-agents-ai-release.yml +++ b/.github/workflows/squad-agents-ai-release.yml @@ -1,11 +1,16 @@ # Requires repo secret NUGET_API_KEY. Generate at nuget.org → API Keys → Create. # # ## Failure recovery -# The workflow resolves PACKAGE_VERSION once, builds/tests/packs exactly that version, and uploads -# nupkgs/* before attempting the registry write. If `dotnet pack` succeeds but `dotnet nuget push` -# fails partway, rerun the same workflow/version: NuGet keeps already-pushed packages immutable, -# `--skip-duplicate` makes duplicate pushes no-ops, and any not-yet-pushed package is retried. -# `cancel-in-progress: false` prevents a retry from canceling an in-flight publish for the same version. +# Publishing is branch-driven: dev creates prerelease NuGet packages, main creates stable NuGet +# packages, and workflow_dispatch remains an escape hatch. PACKAGE_VERSION is resolved once +# before build/test/pack/push and the nupkgs/* artifacts are uploaded before the registry write. +# If a run is interrupted after NuGet accepted the package but before the workflow completes, +# rerun the same workflow/run: NuGet package versions are immutable and `--skip-duplicate` +# turns already-published packages into no-ops while retrying anything not yet published. +# `cancel-in-progress: false` prevents a recovery rerun from canceling an in-flight publish. +# Dev prereleases use github.run_number in the prerelease field because it is monotonically +# increasing per workflow and participates in NuGet/SemVer ordering; reruns keep the same +# version intentionally, and `--skip-duplicate` makes that safe. name: Squad.Agents.AI NuGet Release @@ -13,75 +18,103 @@ on: workflow_dispatch: inputs: version: - description: 'NuGet package version, for example 0.1.0-preview' - required: true + description: 'Optional NuGet package version override, for example 0.1.0-preview.123' + required: false type: string push: - tags: - - 'squad-agents-ai-v*' + branches: + - dev + - main + paths: + - 'src/Squad.Agents.AI/**' + - 'test/Squad.Agents.AI.Tests/**' + - 'Directory.*.props' + - 'global.json' permissions: - contents: write + contents: read jobs: - resolve-version: - name: Resolve package version + publish: + name: Pack and publish NuGet package runs-on: ubuntu-latest - outputs: - package-version: ${{ steps.version.outputs.package-version }} + concurrency: + group: squad-agents-ai-release-${{ github.ref }} + cancel-in-progress: false + env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' + steps: - - name: Resolve version from input or tag + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Resolve package version id: version shell: bash run: | set -euo pipefail - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - package_version="${{ inputs.version }}" + project_file="src/Squad.Agents.AI/Squad.Agents.AI.csproj" + csproj_version="$(sed -nE 's/^[[:space:]]*([^<]+)<\/Version>[[:space:]]*$/\1/p' "${project_file}" | head -n 1)" + manual_version="${{ inputs.version }}" + version_source="" + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && -n "${manual_version}" ]]; then + package_version="${manual_version}" + version_source="workflow_dispatch input" else - tag_name="${GITHUB_REF_NAME}" - tag_prefix="squad-agents-ai-v" - if [[ "${tag_name}" != ${tag_prefix}* ]]; then - echo "::error::Expected tag to start with ${tag_prefix}; got ${tag_name}." + if [[ -z "${csproj_version}" ]]; then + echo "::error::Could not find in ${project_file}." exit 1 fi - package_version="${tag_name#${tag_prefix}}" + + case "${GITHUB_REF_NAME}" in + main) + # Stable releases mirror the Squad CLI's release workflow: the maintainer bumps + # the source version before merge, and the workflow publishes that exact version. + package_version="${csproj_version}" + version_source="${project_file}" + if [[ "${package_version}" == *-* ]]; then + echo "::error::main publishes stable packages; ${project_file} has prerelease version '${package_version}'. Bump it before merging." + exit 1 + fi + ;; + dev) + # The Squad CLI derives prereleases from the source version plus commit identity. + # For NuGet, build metadata does not give consumers monotonic prerelease ordering, + # so use github.run_number in the prerelease field instead. + stable_base="${csproj_version%%+*}" + stable_base="${stable_base%%-*}" + package_version="${stable_base}-preview.${GITHUB_RUN_NUMBER}" + version_source="${project_file} + github.run_number" + ;; + *) + echo "::error::workflow_dispatch without a version input must run on dev or main; current ref is '${GITHUB_REF_NAME}'." + exit 1 + ;; + esac fi if [[ -z "${package_version}" ]]; then - echo "::error::PACKAGE_VERSION resolved to empty. Provide workflow_dispatch input 'version' or use tag squad-agents-ai-v." + echo "::error::PACKAGE_VERSION resolved to empty." exit 1 fi if [[ ! "${package_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then - echo "::error::PACKAGE_VERSION '${package_version}' is not a SemVer 2.0 version." + echo "::error::PACKAGE_VERSION '${package_version}' from ${version_source} is not a SemVer 2.0 version." exit 1 fi - echo "package-version=${package_version}" >> "${GITHUB_OUTPUT}" - - publish: - name: Pack and publish NuGet package - runs-on: ubuntu-latest - needs: resolve-version - concurrency: - group: squad-agents-ai-release-${{ needs.resolve-version.outputs.package-version }} - cancel-in-progress: false - env: - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - DOTNET_NOLOGO: '1' - PACKAGE_VERSION: ${{ needs.resolve-version.outputs.package-version }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' + echo "PACKAGE_VERSION=${package_version}" >> "${GITHUB_OUTPUT}" + echo "Resolved PACKAGE_VERSION=${package_version} (${version_source})" - name: Verify NuGet API key shell: bash @@ -101,22 +134,22 @@ jobs: run: dotnet restore test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj - name: Build package - run: dotnet build src/Squad.Agents.AI/Squad.Agents.AI.csproj -c Release --no-restore -p:Version=${{ env.PACKAGE_VERSION }} + run: dotnet build src/Squad.Agents.AI/Squad.Agents.AI.csproj -c Release --no-restore -p:Version=${{ steps.version.outputs.PACKAGE_VERSION }} - name: Build tests - run: dotnet build test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj -c Release --no-restore -p:Version=${{ env.PACKAGE_VERSION }} + run: dotnet build test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj -c Release --no-restore -p:Version=${{ steps.version.outputs.PACKAGE_VERSION }} - name: Test run: dotnet test test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj -c Release --no-build - name: Pack - run: dotnet pack src/Squad.Agents.AI/Squad.Agents.AI.csproj -c Release --no-build -o nupkgs -p:Version=${{ env.PACKAGE_VERSION }} + run: dotnet pack src/Squad.Agents.AI/Squad.Agents.AI.csproj -c Release --no-build -o nupkgs -p:Version=${{ steps.version.outputs.PACKAGE_VERSION }} - name: Upload NuGet package artifacts if: always() uses: actions/upload-artifact@v4 with: - name: squad-agents-ai-nupkgs-${{ env.PACKAGE_VERSION }} + name: squad-agents-ai-nupkgs-${{ steps.version.outputs.PACKAGE_VERSION }} path: nupkgs/* if-no-files-found: ignore @@ -125,35 +158,3 @@ jobs: env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} run: dotnet nuget push nupkgs/*.nupkg --source https://api.nuget.org/v3/index.json --api-key "${NUGET_API_KEY}" --skip-duplicate - - - name: Prepare GitHub Release notes - if: startsWith(github.ref, 'refs/tags/squad-agents-ai-v') - shell: bash - run: | - set -euo pipefail - - awk -v version="${PACKAGE_VERSION}" ' - BEGIN { target = "## [" version "]"; in_section = 0; found = 0 } - /^## / { - if (in_section) { exit } - if (index($0, target) == 1) { in_section = 1; found = 1; next } - } - in_section { print } - END { if (!found) { exit 1 } } - ' CHANGELOG.md > release-notes.md || \ - awk ' - /^## \[Unreleased\]/ { in_section = 1; found = 1; next } - /^## / && in_section { exit } - in_section { print } - END { if (!found) { exit 1 } } - ' CHANGELOG.md > release-notes.md || \ - printf 'Squad.Agents.AI %s\n' "${PACKAGE_VERSION}" > release-notes.md - - - name: Create GitHub Release - if: startsWith(github.ref, 'refs/tags/squad-agents-ai-v') - uses: softprops/action-gh-release@v2 - with: - name: Squad.Agents.AI ${{ env.PACKAGE_VERSION }} - tag_name: ${{ github.ref_name }} - body_path: release-notes.md - files: nupkgs/*.nupkg From 53d427d2cde31361ff6dff28c7300b695dd0e0f7 Mon Sep 17 00:00:00 2001 From: tamirdresher Date: Tue, 2 Jun 2026 15:35:38 +0300 Subject: [PATCH 14/20] =?UTF-8?q?docs+fix:=20PR=20#3=20review=20pass=20?= =?UTF-8?q?=E2=80=94=20hygiene,=20XML=20docs,=20cliArgs,=20multi-named=20c?= =?UTF-8?q?onnections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite the package README and PR validation flow around the AIAgent surface and ambient Copilot authentication. - Keep public docs free of internal process references and remove obsolete deferral language. - Preserve connection-string cliArgs through CopilotClientOptions and cover the behavior with a routing test. - Add named connection-string lookup via AddSquadAgent("name") using ConnectionStrings:squad-{name}. - Document the new public overloads and verify the package builds without warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Squad.Agents.AI/README.md | 36 ++++++---- src/Squad.Agents.AI/SquadAgent.cs | 2 +- src/Squad.Agents.AI/SquadAgentOptions.cs | 7 +- .../SquadAgentOptionsConfigurator.cs | 49 ++++++++----- .../SquadServiceCollectionExtensions.cs | 72 +++++++++++++++++-- .../SquadAgentRoutingTests.cs | 40 +++++++++-- .../SquadServiceCollectionExtensionsTests.cs | 49 +++++++++++++ 7 files changed, 209 insertions(+), 46 deletions(-) diff --git a/src/Squad.Agents.AI/README.md b/src/Squad.Agents.AI/README.md index ecff8a0ff..3dc107336 100644 --- a/src/Squad.Agents.AI/README.md +++ b/src/Squad.Agents.AI/README.md @@ -4,7 +4,7 @@ ## What it does -`Squad.Agents.AI` exposes a Squad team as a Microsoft Agent Framework `AIAgent`. It builds a GitHub Copilot SDK client with Squad-specific options, delegates MAF sessions/runs/streaming to the inner Copilot agent, and gives .NET consumers a DI-friendly wrapper instead of hand-rolling CLI process setup. +`Squad.Agents.AI` exposes a Squad team as a Microsoft Agent Framework `AIAgent`. `SquadAgent` composes the Squad CLI through the GitHub Copilot SDK, delegates MAF sessions/runs/streaming to the inner Copilot agent, and gives .NET consumers a DI-friendly wrapper instead of hand-rolling CLI process setup. Public surface in this preview: @@ -13,7 +13,7 @@ Public surface in this preview: - `SquadConnectionFactory` — parses PATH or `squad://` connection strings into options. - `SquadServiceCollectionExtensions` — registers `SquadAgent` and base `AIAgent` in DI. -Repository: +Repository: ## Install @@ -32,8 +32,8 @@ dotnet add package Squad.Agents.AI --prerelease --source ./nupkgs - .NET 10 SDK. - GitHub Copilot CLI available on `PATH` (`copilot --version`). -- Squad CLI and an initialized team root containing `.squad/`; see the [Squad CLI repo](https://github.com/tamirdresher/squad). -- GitHub Copilot authentication. Prefer `GitHubTokenProvider`; if passing a token directly, use a Copilot-supported token format (`github_pat_`, `gho_`, or `ghu_`) and never hardcode it. +- Squad CLI and an initialized Squad team root; see the [Squad CLI repo](https://github.com/bradygaster/squad). +- GitHub Copilot authentication through the signed-in user. The quickstart below does not require an app key or environment variable. ## Five-line quickstart @@ -46,8 +46,7 @@ using Squad.Agents.AI; var builder = Host.CreateApplicationBuilder(args); builder.Services.AddSquadAgent(o => { - o.SquadFolderPath = @"C:\path\to\team-root"; - o.GitHubTokenProvider = _ => ValueTask.FromResult(Environment.GetEnvironmentVariable("GH_TOKEN")); + o.SquadFolderPath = @"C:\path\to\your\team-root"; }); using var host = builder.Build(); @@ -57,9 +56,19 @@ var response = await squad.RunAsync("What can this Squad team do?", session); Console.WriteLine(response.Text); ``` +## GitHub Copilot authentication + +The default path mirrors the base GitHub Copilot SDK examples: leave `GitHubToken` and `GitHubTokenProvider` unset, then run with a locally signed-in GitHub Copilot user. For local development, sign in before running the app, for example with `gh auth login`, the Copilot CLI sign-in flow, or a Copilot-supported local sign-in that the SDK runtime can access. + +The SDK documents the credential priority order in [Authenticate Copilot SDK](https://github.com/github/copilot-sdk/blob/main/docs/auth/authenticate.md) and the broader [authentication overview](https://github.com/github/copilot-sdk/blob/main/docs/auth/index.md). Explicit tokens and environment variables are supported by the SDK, but they are not required for the minimal happy path. + +Use `GitHubTokenProvider` only when the host owns token retrieval, such as Key Vault, managed identity, or CI secret flow scenarios. Use `GitHubToken` only when you already have a Copilot-supported token in process and cannot use a provider callback. In both cases, treat these as advanced escape hatches and never hardcode tokens. + ## Aspire / configuration path -`AddSquadAgent()` also reads `ConnectionStrings:squad` through `IConfiguration.GetConnectionString("squad")`. In environment variables, use `ConnectionStrings__squad`. +`AddSquadAgent()` reads `ConnectionStrings:squad` through `IConfiguration.GetConnectionString("squad")`. In environment variables, use `ConnectionStrings__squad`. + +Named registrations select a named connection string. For example, `AddSquadAgent("research")` reads `ConnectionStrings:squad-research`; in environment variables, use `ConnectionStrings__squad-research`. Supported connection string forms: @@ -78,8 +87,8 @@ Parsed URI query keys: `teamRoot`, `cliPath`, `cwd`, `cliArgs` (semicolon-separa | `CliPath` / `CliArgs` | Override the Copilot CLI executable and add extra CLI flags. | | `Cwd` | Working directory for the Copilot CLI process; defaults to `SquadFolderPath`. | | `Environment` | Additional environment variables for the CLI process. Avoid user-controlled values. | -| `GitHubToken` | Direct token for local/dev use; redacted by `ToString()`. | -| `GitHubTokenProvider` | Preferred callback for secure token retrieval; wins over `GitHubToken`. | +| `GitHubToken` | Advanced direct token escape hatch; redacted by `ToString()`. | +| `GitHubTokenProvider` | Advanced callback for secure token retrieval; wins over `GitHubToken`. | | `TraceEvents` | Enables verbose SDK logging and emits a startup warning when enabled. | | `AgentName` | Display name for the resulting `AIAgent`; defaults to `Squad`. | | `Instructions` | Optional system instructions passed to the inner Copilot agent. | @@ -88,8 +97,8 @@ Parsed URI query keys: `teamRoot`, `cliPath`, `cwd`, `cliArgs` (semicolon-separa - Default DI lifetime is scoped. An overload accepts any `ServiceLifetime`. - DI registers both `SquadAgent` and base `AIAgent`. -- No keyed DI, multi-targeting, BYOK/session-provider pass-through, or richer native event streaming yet; these are v0.2 candidates. -- The package does not validate that `SquadFolderPath` exists or contains `.squad/`; consumers should validate their deployment paths. +- Multi-targeting and Aspire telemetry remain candidates for a later preview. +- The package does not validate that `SquadFolderPath` exists; consumers should validate their deployment paths. - `TraceEvents` can log sensitive operational details. Keep it off unless debugging. ## Package contents @@ -102,9 +111,10 @@ Parsed URI query keys: `teamRoot`, `cliPath`, `cwd`, `cliArgs` (semicolon-separa ## See also -- Root repo: +- Root repo: - Microsoft Agent Framework: +- GitHub Copilot SDK authentication: - GitHub Copilot CLI: -- Changelog: +- Changelog: License: MIT. diff --git a/src/Squad.Agents.AI/SquadAgent.cs b/src/Squad.Agents.AI/SquadAgent.cs index 45df5cd60..72235a3b0 100644 --- a/src/Squad.Agents.AI/SquadAgent.cs +++ b/src/Squad.Agents.AI/SquadAgent.cs @@ -86,7 +86,7 @@ private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILog GitHubToken = resolvedToken }; - // Copy CLI args + // Preserve CLI args parsed from connection strings or supplied by user code for the SDK's CLI invocation. if (options.CliArgs.Count > 0) { clientOptions.CliArgs = options.CliArgs.ToArray(); diff --git a/src/Squad.Agents.AI/SquadAgentOptions.cs b/src/Squad.Agents.AI/SquadAgentOptions.cs index f6ef8ba75..4ece99991 100644 --- a/src/Squad.Agents.AI/SquadAgentOptions.cs +++ b/src/Squad.Agents.AI/SquadAgentOptions.cs @@ -8,7 +8,6 @@ namespace Squad.Agents.AI; /// var options = new SquadAgentOptions /// { /// SquadFolderPath = @"C:\repo", -/// GitHubTokenProvider = _ => ValueTask.FromResult(Environment.GetEnvironmentVariable("GH_TOKEN")) /// }; /// options.CliArgs.Add("--yolo"); /// @@ -16,7 +15,7 @@ namespace Squad.Agents.AI; public sealed class SquadAgentOptions { /// - /// Gets or sets the Squad team root, normally the repository or directory that contains the `.squad` folder. + /// Gets or sets the initialized Squad team root. /// public string? SquadFolderPath { get; set; } @@ -41,11 +40,11 @@ public sealed class SquadAgentOptions public IDictionary Environment { get; } = new Dictionary(); /// - /// Gets or sets a direct GitHub token for local development scenarios. + /// Gets or sets a direct GitHub token for advanced host-controlled authentication scenarios. /// /// /// The value is ignored by JSON serialization and redacted by , but still lives in the options object. - /// Prefer for production hosts. + /// Prefer leaving this unset for local signed-in user authentication; use for production hosts that retrieve tokens dynamically. /// [System.Text.Json.Serialization.JsonIgnore] public string? GitHubToken { get; set; } diff --git a/src/Squad.Agents.AI/SquadAgentOptionsConfigurator.cs b/src/Squad.Agents.AI/SquadAgentOptionsConfigurator.cs index f5e117fd5..4edff601d 100644 --- a/src/Squad.Agents.AI/SquadAgentOptionsConfigurator.cs +++ b/src/Squad.Agents.AI/SquadAgentOptionsConfigurator.cs @@ -4,22 +4,39 @@ namespace Squad.Agents.AI; /// -/// Binds SquadAgentOptions from ConnectionStrings:squad configuration section. -/// Runs before user-supplied configure lambda; user settings always win. -/// -internal sealed class SquadAgentOptionsConfigurator : IConfigureOptions -{ - private readonly IConfiguration _configuration; - - public SquadAgentOptionsConfigurator(IConfiguration configuration) - { - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - } - - public void Configure(SquadAgentOptions options) - { - var connectionString = _configuration.GetConnectionString("squad"); - +/// Binds SquadAgentOptions from a configured connection string. +/// Runs before user-supplied configure lambda; user settings always win. +/// +internal sealed class SquadAgentOptionsConfigurator : IConfigureNamedOptions +{ + private readonly IConfiguration _configuration; + private readonly string _optionsName; + private readonly string _connectionStringName; + + public SquadAgentOptionsConfigurator(IConfiguration configuration) + : this(configuration, Options.DefaultName, SquadServiceCollectionExtensions.DefaultConnectionStringName) + { + } + + internal SquadAgentOptionsConfigurator( + IConfiguration configuration, + string optionsName, + string connectionStringName) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _optionsName = optionsName ?? Options.DefaultName; + _connectionStringName = connectionStringName ?? throw new ArgumentNullException(nameof(connectionStringName)); + } + + public void Configure(SquadAgentOptions options) => Configure(Options.DefaultName, options); + + public void Configure(string? name, SquadAgentOptions options) + { + if (!string.Equals(name ?? Options.DefaultName, _optionsName, StringComparison.Ordinal)) + return; + + var connectionString = _configuration.GetConnectionString(_connectionStringName); + if (string.IsNullOrWhiteSpace(connectionString)) return; diff --git a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs index d49dc8c03..cbf0c56f6 100644 --- a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs +++ b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.Agents.AI; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -11,6 +11,8 @@ namespace Squad.Agents.AI; /// public static class SquadServiceCollectionExtensions { + internal const string DefaultConnectionStringName = "squad"; + /// /// Registers and base with scoped lifetime. /// @@ -25,7 +27,20 @@ public static class SquadServiceCollectionExtensions public static IServiceCollection AddSquadAgent( this IServiceCollection services, Action? configure = null) - => AddSquadAgent(services, ServiceLifetime.Scoped, configure); + => AddSquadAgentCore(services, name: null, ServiceLifetime.Scoped, configure); + + /// + /// Registers and base with scoped lifetime using a named connection string. + /// + /// Service collection to update. + /// Logical Squad name. For example, "research" reads ConnectionStrings:squad-research. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + public static IServiceCollection AddSquadAgent( + this IServiceCollection services, + string name, + Action? configure = null) + => AddSquadAgentCore(services, name, ServiceLifetime.Scoped, configure); /// /// Registers and base with the specified lifetime. @@ -38,19 +53,54 @@ public static IServiceCollection AddSquadAgent( this IServiceCollection services, ServiceLifetime lifetime, Action? configure = null) + => AddSquadAgentCore(services, name: null, lifetime, configure); + + /// + /// Registers and base with the specified lifetime using a named connection string. + /// + /// Service collection to update. + /// Logical Squad name. For example, "research" reads ConnectionStrings:squad-research. + /// Lifetime used for the concrete and base agent registrations. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + public static IServiceCollection AddSquadAgent( + this IServiceCollection services, + string name, + ServiceLifetime lifetime, + Action? configure = null) + => AddSquadAgentCore(services, name, lifetime, configure); + + private static IServiceCollection AddSquadAgentCore( + IServiceCollection services, + string? name, + ServiceLifetime lifetime, + Action? configure) { + ArgumentNullException.ThrowIfNull(services); + + var optionsName = GetOptionsName(name); + var connectionStringName = GetConnectionStringName(name); + // Register connection string configurator FIRST (runs before user callback) - services.TryAddEnumerable(ServiceDescriptor.Singleton, SquadAgentOptionsConfigurator>()); + services.AddSingleton>(sp => + new SquadAgentOptionsConfigurator( + sp.GetRequiredService(), + optionsName, + connectionStringName)); if (configure is not null) { - services.Configure(configure); + services.Configure(optionsName, configure); } // Register SquadAgent with specified lifetime services.Add(new ServiceDescriptor( typeof(SquadAgent), - typeof(SquadAgent), + sp => + { + var options = sp.GetRequiredService>().Get(optionsName); + return ActivatorUtilities.CreateInstance(sp, options); + }, lifetime)); // AIAgent base registration — so consumers can resolve via IEnumerable @@ -60,7 +110,7 @@ public static IServiceCollection AddSquadAgent( lifetime)); // TraceEvents=true → warn because verbose traces can contain sensitive details - services.AddOptions() + services.AddOptions(optionsName) .PostConfigure((opts, loggerFactory) => { if (opts.TraceEvents) @@ -74,4 +124,14 @@ public static IServiceCollection AddSquadAgent( return services; } + + private static string GetOptionsName(string? name) + { + return string.IsNullOrWhiteSpace(name) ? Options.DefaultName : name; + } + + private static string GetConnectionStringName(string? name) + { + return string.IsNullOrWhiteSpace(name) ? DefaultConnectionStringName : $"{DefaultConnectionStringName}-{name}"; + } } diff --git a/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs b/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs index b010b1949..bb445f677 100644 --- a/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs +++ b/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs @@ -13,19 +13,19 @@ public void AddSquadAgent_ResolvesAIAgentWithConfiguredPersonaName() { var provider = BuildProvider(options => { - options.AgentName = "Data"; + options.AgentName = "Custom Coordinator"; }); var agent = provider.GetRequiredService(); var squadAgent = Assert.IsType(agent); - Assert.Equal("Data", squadAgent.Name); + Assert.Equal("Custom Coordinator", squadAgent.Name); } [Fact] public void AddSquadAgent_PassesBoundaryInstructionsToInnerSessionConfig() { - const string instructions = "You are Data. Stay inside the Squad boundary."; + const string instructions = "You are a coordinator. Stay inside the configured boundary."; var agent = CreateAgent(options => { options.Instructions = instructions; @@ -70,7 +70,7 @@ public void AddSquadAgent_RoutesThroughCopilotClientOptionsNotAgentName() { var agent = CreateAgent(options => { - options.AgentName = "Data Persona"; + options.AgentName = "Custom Persona"; options.CliArgs.Clear(); options.CliArgs.Add("--extension"); options.CliArgs.Add("squad"); @@ -83,12 +83,40 @@ public void AddSquadAgent_RoutesThroughCopilotClientOptionsNotAgentName() var cliArgs = GetRequiredProperty(clientOptions, "CliArgs"); var environment = GetRequiredProperty>(clientOptions, "Environment"); - Assert.Equal("Data Persona", GetRequiredProperty(inner, "Name")); + Assert.Equal("Custom Persona", GetRequiredProperty(inner, "Name")); Assert.True(string.IsNullOrEmpty(GetProperty(sessionConfig, "Agent"))); - Assert.DoesNotContain("Data Persona", cliArgs); + Assert.DoesNotContain("Custom Persona", cliArgs); Assert.Equal("enabled", environment["SQUAD_ROUTE"]); } + [Fact] + public void AddSquadAgent_CopiesConnectionStringCliArgsToCopilotClientOptions() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad"] = "squad://localhost?teamRoot=C:%5Csquad-team-root&cliArgs=--yolo;--model;gpt-5" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(options => + { + options.CliPath = @"C:\fake-copilot\copilot.exe"; + options.GitHubToken = "test-token"; + }); + + var provider = services.BuildServiceProvider(); + var agent = provider.GetRequiredService(); + var clientOptions = GetCopilotClientOptions(agent); + var cliArgs = GetRequiredProperty(clientOptions, "CliArgs"); + + Assert.Equal(new[] { "--yolo", "--model", "gpt-5" }, cliArgs); + } + private static SquadAgent CreateAgent(Action? configure = null) { var provider = BuildProvider(configure); diff --git a/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs b/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs index 69d010fa7..46470778a 100644 --- a/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs +++ b/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs @@ -47,6 +47,55 @@ public void AddSquadAgent_WithConnectionString_BindsSquadFolderPath() } [Fact] + public void AddSquadAgent_WithName_BindsNamedConnectionString() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad"] = @"C:\default-team-root", + ["ConnectionStrings:squad-research"] = @"C:\research-team-root" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent("research"); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Get("research"); + + Assert.Equal(@"C:\research-team-root", options.SquadFolderPath); + Assert.Equal(@"C:\research-team-root", options.Cwd); + } + + [Fact] + public void AddSquadAgent_WithName_UserCallbackOverridesNamedConnectionString() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad-research"] = @"C:\research-team-root" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent("research", opts => + { + opts.SquadFolderPath = @"C:\override-team-root"; + }); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Get("research"); + + Assert.Equal(@"C:\override-team-root", options.SquadFolderPath); + } + + [Fact] public void AddSquadAgent_UserCallbackOverridesConnectionString() { var services = new ServiceCollection(); From a030193cd23f6d402cc0cfcc85ea58158323bbe2 Mon Sep 17 00:00:00 2001 From: tamirdresher Date: Tue, 2 Jun 2026 16:04:30 +0300 Subject: [PATCH 15/20] =?UTF-8?q?feat:=20Round=202=20=E2=80=94=20keyed=20D?= =?UTF-8?q?I,=20BYOK=20delegate,=20routing=20gate,=20security=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ConfigureCopilotClient delegate on SquadAgentOptions for BYOK - Add routing gate: snapshot/restore Cwd/CliPath/CliArgs after delegate (Picard C1) - Add 4 AddKeyedSquadAgent overloads with .NET 8+ keyed DI - Fix Environment credential leak: [JsonIgnore] on Environment, GitHubTokenProvider, ConfigureCopilotClient - ToString() redacts token-pattern keys (TOKEN/KEY/SECRET/HMAC/PASSWORD/CREDENTIAL) - Add 21 new tests (43 total): security redaction, keyed DI, BYOK routing gate - Update README: streaming, keyed DI, BYOK, security sections Complies with: Picard C1-C4, Worf SC-1 through SC-8. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Squad.Agents.AI/README.md | 87 +++++++- src/Squad.Agents.AI/SquadAgent.cs | 67 +++++++ src/Squad.Agents.AI/SquadAgentOptions.cs | 69 ++++++- .../SquadServiceCollectionExtensions.cs | 151 ++++++++++++-- .../SquadAgentOptionsSecurityTests.cs | 145 ++++++++++++++ test/Squad.Agents.AI.Tests/SquadBYOKTests.cs | 189 ++++++++++++++++++ .../SquadKeyedDITests.cs | 121 +++++++++++ 7 files changed, 807 insertions(+), 22 deletions(-) create mode 100644 test/Squad.Agents.AI.Tests/SquadAgentOptionsSecurityTests.cs create mode 100644 test/Squad.Agents.AI.Tests/SquadBYOKTests.cs create mode 100644 test/Squad.Agents.AI.Tests/SquadKeyedDITests.cs diff --git a/src/Squad.Agents.AI/README.md b/src/Squad.Agents.AI/README.md index 3dc107336..41bd99b03 100644 --- a/src/Squad.Agents.AI/README.md +++ b/src/Squad.Agents.AI/README.md @@ -8,10 +8,10 @@ Public surface in this preview: -- `SquadAgent` — sealed `AIAgent` wrapper over the Copilot-backed inner agent. -- `SquadAgentOptions` — team root, CLI path/args, environment, token, logging, and instruction settings. +- `SquadAgent` — sealed `AIAgent` wrapper over the Copilot-backed inner agent. Supports both non-streaming and streaming via `RunStreamingAsync`. +- `SquadAgentOptions` — team root, CLI path/args, environment, token, logging, instruction settings, and a `ConfigureCopilotClient` delegate for advanced SDK customization. - `SquadConnectionFactory` — parses PATH or `squad://` connection strings into options. -- `SquadServiceCollectionExtensions` — registers `SquadAgent` and base `AIAgent` in DI. +- `SquadServiceCollectionExtensions` — registers `SquadAgent` and base `AIAgent` in DI, with standard and keyed overloads. Repository: @@ -56,13 +56,77 @@ var response = await squad.RunAsync("What can this Squad team do?", session); Console.WriteLine(response.Text); ``` +## Streaming + +`SquadAgent` supports streaming via the MAF `RunStreamingAsync` method. The inner Copilot-backed agent streams response updates as they arrive from the CLI process: + +```csharp +var squad = host.Services.GetRequiredService(); +var session = await squad.CreateSessionAsync(); + +await foreach (var update in squad.RunStreamingAsync("Summarize the team.", session)) +{ + Console.Write(update.Text); +} +``` + +## Keyed DI + +Register multiple Squad teams in the same DI container using .NET 8+ keyed services: + +```csharp +builder.Services.AddKeyedSquadAgent("research", o => +{ + o.SquadFolderPath = @"C:\research-team"; +}); + +builder.Services.AddKeyedSquadAgent("platform", o => +{ + o.SquadFolderPath = @"C:\platform-team"; +}); + +// Resolve in a minimal API endpoint: +app.MapGet("/ask-research", async ([FromKeyedServices("research")] SquadAgent agent) => +{ + var session = await agent.CreateSessionAsync(); + var response = await agent.RunAsync("What's the latest?", session); + return response.Text; +}); +``` + +Keyed registrations include `AddKeyedSquadAgent` overloads for service key, optional connection-string name, and lifetime. Keyed and non-keyed registrations can coexist. + +## Advanced: ConfigureCopilotClient (BYOK) + +Use `ConfigureCopilotClient` to customize the underlying `CopilotClientOptions` after Squad applies its standard values. This is the extension point for injecting custom environment variables, providing a BYOK token, or tuning SDK settings: + +```csharp +builder.Services.AddSquadAgent(o => +{ + o.SquadFolderPath = @"C:\team"; + o.ConfigureCopilotClient = clientOpts => + { + // Inject a BYOK token from your own credential store + clientOpts.GitHubToken = myVault.GetSecret("copilot-token"); + + // Add custom environment variables for the CLI process + clientOpts.Environment = new Dictionary + { + ["MY_CUSTOM_VAR"] = "value" + }; + }; +}); +``` + +> **Routing guard:** Squad enforces a hard routing gate — if the delegate changes `Cwd`, `CliPath`, or `CliArgs`, the original values are restored and a warning is logged. Configure these via `SquadAgentOptions` instead. + ## GitHub Copilot authentication The default path mirrors the base GitHub Copilot SDK examples: leave `GitHubToken` and `GitHubTokenProvider` unset, then run with a locally signed-in GitHub Copilot user. For local development, sign in before running the app, for example with `gh auth login`, the Copilot CLI sign-in flow, or a Copilot-supported local sign-in that the SDK runtime can access. The SDK documents the credential priority order in [Authenticate Copilot SDK](https://github.com/github/copilot-sdk/blob/main/docs/auth/authenticate.md) and the broader [authentication overview](https://github.com/github/copilot-sdk/blob/main/docs/auth/index.md). Explicit tokens and environment variables are supported by the SDK, but they are not required for the minimal happy path. -Use `GitHubTokenProvider` only when the host owns token retrieval, such as Key Vault, managed identity, or CI secret flow scenarios. Use `GitHubToken` only when you already have a Copilot-supported token in process and cannot use a provider callback. In both cases, treat these as advanced escape hatches and never hardcode tokens. +Use `GitHubTokenProvider` only when the host owns token retrieval, such as Key Vault, managed identity, or CI secret flow scenarios. Use `GitHubToken` only when you already have a Copilot-supported token in process and cannot use a provider callback. Use `ConfigureCopilotClient` when you need full control over the SDK's `CopilotClientOptions`, including BYOK token injection. ## Aspire / configuration path @@ -86,17 +150,26 @@ Parsed URI query keys: `teamRoot`, `cliPath`, `cwd`, `cliArgs` (semicolon-separa | `SquadFolderPath` | Squad team root; PATH connection strings set this and `Cwd`. | | `CliPath` / `CliArgs` | Override the Copilot CLI executable and add extra CLI flags. | | `Cwd` | Working directory for the Copilot CLI process; defaults to `SquadFolderPath`. | -| `Environment` | Additional environment variables for the CLI process. Avoid user-controlled values. | -| `GitHubToken` | Advanced direct token escape hatch; redacted by `ToString()`. | +| `Environment` | Additional environment variables for the CLI process. Excluded from JSON serialization; token-pattern keys are redacted in `ToString()`. | +| `GitHubToken` | Advanced direct token escape hatch; excluded from JSON serialization and redacted by `ToString()`. | | `GitHubTokenProvider` | Advanced callback for secure token retrieval; wins over `GitHubToken`. | +| `ConfigureCopilotClient` | Advanced delegate for customizing `CopilotClientOptions`. Routing properties are guarded; see BYOK section. | | `TraceEvents` | Enables verbose SDK logging and emits a startup warning when enabled. | | `AgentName` | Display name for the resulting `AIAgent`; defaults to `Squad`. | | `Instructions` | Optional system instructions passed to the inner Copilot agent. | +## Security + +- **Credential redaction:** `GitHubToken` and `Environment` values whose keys match `*TOKEN*`, `*KEY*`, `*SECRET*`, `*HMAC*`, `*PASSWORD*`, or `*CREDENTIAL*` are always redacted in `ToString()` output. +- **JSON safety:** `GitHubToken`, `GitHubTokenProvider`, `Environment`, and `ConfigureCopilotClient` are marked `[JsonIgnore]` and will not appear in JSON serialization output. +- **Routing guard:** The `ConfigureCopilotClient` delegate cannot change `Cwd`, `CliPath`, or `CliArgs` — changes are silently restored to prevent routing the agent to an unintended CLI process. +- **TraceEvents warning:** Enabling `TraceEvents` logs a startup warning because verbose SDK traces may include sensitive operational details. +- **Avoid hardcoded tokens:** Never embed tokens in source code. Use `GitHubTokenProvider` for production token retrieval from Key Vault, managed identity, or similar secure stores. + ## Notes for v0.1-preview - Default DI lifetime is scoped. An overload accepts any `ServiceLifetime`. -- DI registers both `SquadAgent` and base `AIAgent`. +- DI registers both `SquadAgent` and base `AIAgent` (non-keyed). Keyed DI registers both `SquadAgent` and `AIAgent` under the same key. - Multi-targeting and Aspire telemetry remain candidates for a later preview. - The package does not validate that `SquadFolderPath` exists; consumers should validate their deployment paths. - `TraceEvents` can log sensitive operational details. Keep it off unless debugging. diff --git a/src/Squad.Agents.AI/SquadAgent.cs b/src/Squad.Agents.AI/SquadAgent.cs index 72235a3b0..b5469acf5 100644 --- a/src/Squad.Agents.AI/SquadAgent.cs +++ b/src/Squad.Agents.AI/SquadAgent.cs @@ -65,8 +65,21 @@ private SquadAgent(CopilotClient copilotClient, SquadAgentOptions options, ILogg options.AgentName, options.SquadFolderPath); } + // ── Extensibility seam ────────────────────────────────────────────── + // CreateCopilotClient is the single factory method that translates + // SquadAgentOptions into a live CopilotClient. Three extension points + // feed into it: + // 1. GitHubTokenProvider / GitHubToken — credential override. + // 2. ConfigureCopilotClient — BYOK delegate for advanced SDK tuning. + // 3. Connection-string binding (handled before this method by the + // SquadAgentOptionsConfigurator). + // Any future credential-store or model-property hooks should integrate + // through SquadAgentOptions and be applied inside this method. + // ──────────────────────────────────────────────────────────────────── private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILoggerFactory? loggerFactory) { + var logger = loggerFactory?.CreateLogger(); + // Resolve token: provider takes precedence over direct property string? resolvedToken = null; if (options.GitHubTokenProvider is not null) @@ -110,6 +123,60 @@ private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILog clientOptions.Logger = loggerFactory.CreateLogger(); } + // ── BYOK / ConfigureCopilotClient delegate ───────────────────── + // Allow consumers to customize the CopilotClientOptions after Squad + // applies its standard values. After the delegate runs, snapshot and + // restore routing-critical properties to prevent accidental or + // malicious changes that would route the agent to a different CLI + // process. (Picard Condition 1: hard routing gate.) + if (options.ConfigureCopilotClient is not null) + { + // Snapshot routing properties before the delegate runs + var snapshotCwd = clientOptions.Cwd; + var snapshotCliPath = clientOptions.CliPath; + var snapshotCliArgs = clientOptions.CliArgs; + + options.ConfigureCopilotClient(clientOptions); + + // Restore routing properties if changed (SC-3: post-delegate warning) + bool restored = false; + if (!string.Equals(clientOptions.Cwd, snapshotCwd, StringComparison.Ordinal)) + { + logger?.LogWarning( + "ConfigureCopilotClient delegate changed Cwd from '{Original}' to '{Changed}'; " + + "restoring original value to preserve Squad routing.", + snapshotCwd, clientOptions.Cwd); + clientOptions.Cwd = snapshotCwd; + restored = true; + } + + if (!string.Equals(clientOptions.CliPath, snapshotCliPath, StringComparison.Ordinal)) + { + logger?.LogWarning( + "ConfigureCopilotClient delegate changed CliPath from '{Original}' to '{Changed}'; " + + "restoring original value to preserve Squad routing.", + snapshotCliPath, clientOptions.CliPath); + clientOptions.CliPath = snapshotCliPath; + restored = true; + } + + if (!ReferenceEquals(clientOptions.CliArgs, snapshotCliArgs)) + { + logger?.LogWarning( + "ConfigureCopilotClient delegate replaced CliArgs; " + + "restoring original value to preserve Squad routing."); + clientOptions.CliArgs = snapshotCliArgs; + restored = true; + } + + if (restored) + { + logger?.LogWarning( + "One or more routing properties were restored after ConfigureCopilotClient delegate ran. " + + "To set Cwd, CliPath, or CliArgs, configure them on SquadAgentOptions instead."); + } + } + return new CopilotClient(clientOptions); } diff --git a/src/Squad.Agents.AI/SquadAgentOptions.cs b/src/Squad.Agents.AI/SquadAgentOptions.cs index 4ece99991..5dcd996ac 100644 --- a/src/Squad.Agents.AI/SquadAgentOptions.cs +++ b/src/Squad.Agents.AI/SquadAgentOptions.cs @@ -1,3 +1,6 @@ +using System.Text.Json.Serialization; +using GitHub.Copilot.SDK; + namespace Squad.Agents.AI; /// @@ -37,6 +40,14 @@ public sealed class SquadAgentOptions /// /// Gets additional environment variables injected into the CLI process. /// + /// + /// + /// This dictionary is excluded from JSON serialization to prevent credential leakage. + /// Values for keys matching token/secret patterns are redacted in . + /// + /// Avoid placing user-controlled values in this dictionary. + /// + [JsonIgnore] public IDictionary Environment { get; } = new Dictionary(); /// @@ -46,7 +57,7 @@ public sealed class SquadAgentOptions /// The value is ignored by JSON serialization and redacted by , but still lives in the options object. /// Prefer leaving this unset for local signed-in user authentication; use for production hosts that retrieve tokens dynamically. /// - [System.Text.Json.Serialization.JsonIgnore] + [JsonIgnore] public string? GitHubToken { get; set; } /// @@ -55,6 +66,7 @@ public sealed class SquadAgentOptions /// /// When set, this provider takes precedence over . /// + [JsonIgnore] public Func>? GitHubTokenProvider { get; set; } /// @@ -75,13 +87,66 @@ public sealed class SquadAgentOptions /// public string? Instructions { get; set; } + /// + /// Gets or sets a delegate that customizes the underlying after Squad + /// applies its standard values. Use this for advanced scenarios such as injecting custom environment + /// variables, setting timeouts, or providing a custom logger to the Copilot SDK. + /// + /// + /// Security note: Do not override Cwd, CliPath, or CliArgs through this + /// delegate — Squad enforces routing invariants and will restore its own values if they are changed. + /// A warning is logged when this occurs. + /// The delegate has full read/write access to , including + /// GitHubToken. If the delegate replaces the token, the replacement takes effect. + /// Document this as the final authority on token value. + /// + /// + /// + /// options.ConfigureCopilotClient = clientOpts => + /// { + /// clientOpts.Environment["MY_CUSTOM_VAR"] = "custom-value"; + /// }; + /// + /// + [JsonIgnore] + public Action? ConfigureCopilotClient { get; set; } + + private static readonly string[] TokenPatterns = { "TOKEN", "KEY", "SECRET", "HMAC", "PASSWORD", "CREDENTIAL" }; + + private static bool IsTokenKey(string key) + { + var upper = key.ToUpperInvariant(); + foreach (var pattern in TokenPatterns) + { + if (upper.Contains(pattern)) + return true; + } + return false; + } + /// /// Returns a diagnostic representation with sensitive values redacted. /// + /// + /// GitHubToken is always shown as [REDACTED]. + /// Environment values whose keys match token/secret patterns + /// (*TOKEN*, *KEY*, *SECRET*, *HMAC*, *PASSWORD*, *CREDENTIAL*) + /// are shown as [REDACTED]; other environment entries show their value. + /// /// A string containing non-secret option values. public override string ToString() { + var envDisplay = "{}"; + if (Environment.Count > 0) + { + var entries = Environment.Select(kvp => + IsTokenKey(kvp.Key) + ? $"{kvp.Key}=[REDACTED]" + : $"{kvp.Key}={kvp.Value}"); + envDisplay = $"{{ {string.Join(", ", entries)} }}"; + } + return $"SquadAgentOptions {{ SquadFolderPath = {SquadFolderPath}, AgentName = {AgentName}, " + - $"GitHubToken = [REDACTED], TraceEvents = {TraceEvents} }}"; + $"GitHubToken = [REDACTED], Environment = {envDisplay}, TraceEvents = {TraceEvents} }}"; } } diff --git a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs index cbf0c56f6..813e87085 100644 --- a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs +++ b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs @@ -70,6 +70,81 @@ public static IServiceCollection AddSquadAgent( Action? configure = null) => AddSquadAgentCore(services, name, lifetime, configure); + // ── Keyed DI overloads ─────────────────────────────────────────────────── + + /// + /// Registers as a keyed service with scoped lifetime. Resolve via + /// [FromKeyedServices("myKey")] SquadAgent agent or + /// provider.GetRequiredKeyedService<SquadAgent>("myKey"). + /// + /// Service collection to update. + /// The DI service key for keyed resolution. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + /// + /// + /// builder.Services.AddKeyedSquadAgent("research", o => o.SquadFolderPath = @"C:\research-team"); + /// builder.Services.AddKeyedSquadAgent("platform", o => o.SquadFolderPath = @"C:\platform-team"); + /// + /// // Resolve in a controller or minimal API: + /// app.MapGet("/ask", ([FromKeyedServices("research")] SquadAgent agent) => ...); + /// + /// + public static IServiceCollection AddKeyedSquadAgent( + this IServiceCollection services, + string serviceKey, + Action? configure = null) + => AddKeyedSquadAgentCore(services, serviceKey, name: null, ServiceLifetime.Scoped, configure); + + /// + /// Registers as a keyed service with scoped lifetime using a named connection string. + /// + /// Service collection to update. + /// The DI service key for keyed resolution. + /// Logical Squad name for connection-string lookup. For example, "research" reads ConnectionStrings:squad-research. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + public static IServiceCollection AddKeyedSquadAgent( + this IServiceCollection services, + string serviceKey, + string name, + Action? configure = null) + => AddKeyedSquadAgentCore(services, serviceKey, name, ServiceLifetime.Scoped, configure); + + /// + /// Registers as a keyed service with the specified lifetime. + /// + /// Service collection to update. + /// The DI service key for keyed resolution. + /// Lifetime used for the keyed agent registration. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + public static IServiceCollection AddKeyedSquadAgent( + this IServiceCollection services, + string serviceKey, + ServiceLifetime lifetime, + Action? configure = null) + => AddKeyedSquadAgentCore(services, serviceKey, name: null, lifetime, configure); + + /// + /// Registers as a keyed service with the specified lifetime using a named connection string. + /// + /// Service collection to update. + /// The DI service key for keyed resolution. + /// Logical Squad name for connection-string lookup. + /// Lifetime used for the keyed agent registration. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + public static IServiceCollection AddKeyedSquadAgent( + this IServiceCollection services, + string serviceKey, + string name, + ServiceLifetime lifetime, + Action? configure = null) + => AddKeyedSquadAgentCore(services, serviceKey, name, lifetime, configure); + + // ── Core implementation (non-keyed) ────────────────────────────────────── + private static IServiceCollection AddSquadAgentCore( IServiceCollection services, string? name, @@ -81,17 +156,7 @@ private static IServiceCollection AddSquadAgentCore( var optionsName = GetOptionsName(name); var connectionStringName = GetConnectionStringName(name); - // Register connection string configurator FIRST (runs before user callback) - services.AddSingleton>(sp => - new SquadAgentOptionsConfigurator( - sp.GetRequiredService(), - optionsName, - connectionStringName)); - - if (configure is not null) - { - services.Configure(optionsName, configure); - } + RegisterOptionsInfrastructure(services, optionsName, connectionStringName, configure); // Register SquadAgent with specified lifetime services.Add(new ServiceDescriptor( @@ -109,6 +174,68 @@ private static IServiceCollection AddSquadAgentCore( sp => sp.GetRequiredService(), lifetime)); + return services; + } + + // ── Core implementation (keyed) ────────────────────────────────────────── + + private static IServiceCollection AddKeyedSquadAgentCore( + IServiceCollection services, + string serviceKey, + string? name, + ServiceLifetime lifetime, + Action? configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(serviceKey); + + // Use the serviceKey as the options name when no explicit name is given + var optionsName = GetOptionsName(name ?? serviceKey); + var connectionStringName = GetConnectionStringName(name ?? serviceKey); + + RegisterOptionsInfrastructure(services, optionsName, connectionStringName, configure); + + // Register keyed SquadAgent + services.Add(new ServiceDescriptor( + typeof(SquadAgent), + serviceKey, + (sp, _) => + { + var options = sp.GetRequiredService>().Get(optionsName); + return ActivatorUtilities.CreateInstance(sp, options); + }, + lifetime)); + + // Keyed AIAgent — forwards to keyed SquadAgent + services.Add(new ServiceDescriptor( + typeof(AIAgent), + serviceKey, + (sp, _) => sp.GetRequiredKeyedService(serviceKey), + lifetime)); + + return services; + } + + // ── Shared options registration ────────────────────────────────────────── + + private static void RegisterOptionsInfrastructure( + IServiceCollection services, + string optionsName, + string connectionStringName, + Action? configure) + { + // Register connection string configurator FIRST (runs before user callback) + services.AddSingleton>(sp => + new SquadAgentOptionsConfigurator( + sp.GetRequiredService(), + optionsName, + connectionStringName)); + + if (configure is not null) + { + services.Configure(optionsName, configure); + } + // TraceEvents=true → warn because verbose traces can contain sensitive details services.AddOptions(optionsName) .PostConfigure((opts, loggerFactory) => @@ -121,8 +248,6 @@ private static IServiceCollection AddSquadAgentCore( "not recommended for production use."); } }); - - return services; } private static string GetOptionsName(string? name) diff --git a/test/Squad.Agents.AI.Tests/SquadAgentOptionsSecurityTests.cs b/test/Squad.Agents.AI.Tests/SquadAgentOptionsSecurityTests.cs new file mode 100644 index 000000000..724d454f1 --- /dev/null +++ b/test/Squad.Agents.AI.Tests/SquadAgentOptionsSecurityTests.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using Xunit; + +namespace Squad.Agents.AI.Tests; + +/// +/// Validates that redacts sensitive values +/// in ToString() and JSON serialization (Worf SC-1, SC-2, SC-7, SC-8). +/// +public class SquadAgentOptionsSecurityTests +{ + [Fact] + public void ToString_RedactsGitHubToken() + { + var options = new SquadAgentOptions + { + GitHubToken = "ghp_super_secret_token_12345" + }; + + var result = options.ToString(); + + Assert.DoesNotContain("ghp_super_secret_token", result); + Assert.Contains("GitHubToken = [REDACTED]", result); + } + + [Fact] + public void ToString_RedactsEnvironmentTokenKeys() + { + var options = new SquadAgentOptions(); + options.Environment["MY_API_TOKEN"] = "secret-token-value"; + options.Environment["AZURE_SECRET"] = "secret-azure-value"; + options.Environment["HMAC_KEY"] = "hmac-key-value"; + options.Environment["MY_PASSWORD"] = "password-value"; + options.Environment["SERVICE_CREDENTIAL"] = "credential-value"; + options.Environment["SAFE_SETTING"] = "visible-value"; + + var result = options.ToString(); + + Assert.DoesNotContain("secret-token-value", result); + Assert.DoesNotContain("secret-azure-value", result); + Assert.DoesNotContain("hmac-key-value", result); + Assert.DoesNotContain("password-value", result); + Assert.DoesNotContain("credential-value", result); + Assert.Contains("MY_API_TOKEN=[REDACTED]", result); + Assert.Contains("AZURE_SECRET=[REDACTED]", result); + Assert.Contains("HMAC_KEY=[REDACTED]", result); + Assert.Contains("MY_PASSWORD=[REDACTED]", result); + Assert.Contains("SERVICE_CREDENTIAL=[REDACTED]", result); + Assert.Contains("SAFE_SETTING=visible-value", result); + } + + [Fact] + public void ToString_ShowsNonSensitiveEnvironmentValues() + { + var options = new SquadAgentOptions(); + options.Environment["SQUAD_ROUTE"] = "enabled"; + options.Environment["DEBUG_LEVEL"] = "verbose"; + + var result = options.ToString(); + + Assert.Contains("SQUAD_ROUTE=enabled", result); + Assert.Contains("DEBUG_LEVEL=verbose", result); + } + + [Fact] + public void ToString_EmptyEnvironment_ShowsEmptyBraces() + { + var options = new SquadAgentOptions(); + + var result = options.ToString(); + + Assert.Contains("Environment = {}", result); + } + + [Fact] + public void JsonSerialize_ExcludesGitHubToken() + { + var options = new SquadAgentOptions + { + GitHubToken = "ghp_leaked_token" + }; + + var json = JsonSerializer.Serialize(options); + + Assert.DoesNotContain("ghp_leaked_token", json); + Assert.DoesNotContain("GitHubToken", json); + } + + [Fact] + public void JsonSerialize_ExcludesEnvironment() + { + var options = new SquadAgentOptions(); + options.Environment["MY_SECRET_KEY"] = "leaked-secret"; + options.Environment["SAFE_VALUE"] = "safe"; + + var json = JsonSerializer.Serialize(options); + + Assert.DoesNotContain("leaked-secret", json); + Assert.DoesNotContain("MY_SECRET_KEY", json); + Assert.DoesNotContain("Environment", json); + } + + [Fact] + public void JsonSerialize_ExcludesGitHubTokenProvider() + { + var options = new SquadAgentOptions + { + GitHubTokenProvider = _ => new ValueTask("provider-secret") + }; + + var json = JsonSerializer.Serialize(options); + + Assert.DoesNotContain("provider-secret", json); + Assert.DoesNotContain("GitHubTokenProvider", json); + } + + [Fact] + public void JsonSerialize_ExcludesConfigureCopilotClient() + { + var options = new SquadAgentOptions + { + ConfigureCopilotClient = _ => { } + }; + + var json = JsonSerializer.Serialize(options); + + Assert.DoesNotContain("ConfigureCopilotClient", json); + } + + [Fact] + public void JsonSerialize_IncludesNonSensitiveProperties() + { + var options = new SquadAgentOptions + { + SquadFolderPath = @"C:\team", + AgentName = "TestAgent", + TraceEvents = true + }; + + var json = JsonSerializer.Serialize(options); + + Assert.Contains("TestAgent", json); + Assert.Contains(@"C:\\team", json); + } +} diff --git a/test/Squad.Agents.AI.Tests/SquadBYOKTests.cs b/test/Squad.Agents.AI.Tests/SquadBYOKTests.cs new file mode 100644 index 000000000..26ab0b102 --- /dev/null +++ b/test/Squad.Agents.AI.Tests/SquadBYOKTests.cs @@ -0,0 +1,189 @@ +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Squad.Agents.AI.Tests; + +/// +/// Validates ConfigureCopilotClient delegate behavior and routing gate +/// (Picard Condition 1, Worf SC-3). +/// +public class SquadBYOKTests +{ + [Fact] + public void ConfigureCopilotClient_DelegateIsInvoked() + { + bool delegateCalled = false; + var agent = CreateAgent(opts => + { + opts.ConfigureCopilotClient = clientOpts => + { + delegateCalled = true; + }; + }); + + Assert.True(delegateCalled); + } + + [Fact] + public void ConfigureCopilotClient_CanSetEnvironmentOnCopilotClient() + { + var agent = CreateAgent(opts => + { + opts.ConfigureCopilotClient = clientOpts => + { + clientOpts.Environment = new Dictionary + { + ["CUSTOM_VAR"] = "custom-value" + }; + }; + }); + + var clientOptions = GetCopilotClientOptions(agent); + var env = GetRequiredProperty>(clientOptions, "Environment"); + + Assert.Equal("custom-value", env["CUSTOM_VAR"]); + } + + [Fact] + public void ConfigureCopilotClient_RestoresCwdAfterDelegate() + { + var agent = CreateAgent(opts => + { + opts.Cwd = @"C:\original-cwd"; + opts.ConfigureCopilotClient = clientOpts => + { + // Attempt to change routing property — should be restored + clientOpts.Cwd = @"C:\hijacked-cwd"; + }; + }); + + var clientOptions = GetCopilotClientOptions(agent); + var cwd = GetRequiredProperty(clientOptions, "Cwd"); + + Assert.Equal(@"C:\original-cwd", cwd); + } + + [Fact] + public void ConfigureCopilotClient_RestoresCliPathAfterDelegate() + { + var agent = CreateAgent(opts => + { + opts.CliPath = @"C:\original\copilot.exe"; + opts.ConfigureCopilotClient = clientOpts => + { + clientOpts.CliPath = @"C:\hijacked\evil.exe"; + }; + }); + + var clientOptions = GetCopilotClientOptions(agent); + var cliPath = GetProperty(clientOptions, "CliPath"); + + Assert.Equal(@"C:\original\copilot.exe", cliPath); + } + + [Fact] + public void ConfigureCopilotClient_RestoresCliArgsAfterDelegate() + { + var agent = CreateAgent(opts => + { + opts.CliArgs.Add("--extension"); + opts.CliArgs.Add("squad"); + opts.ConfigureCopilotClient = clientOpts => + { + clientOpts.CliArgs = new[] { "--hijacked" }; + }; + }); + + var clientOptions = GetCopilotClientOptions(agent); + var cliArgs = GetRequiredProperty(clientOptions, "CliArgs"); + + Assert.Contains("--extension", cliArgs); + Assert.Contains("squad", cliArgs); + Assert.DoesNotContain("--hijacked", cliArgs); + } + + [Fact] + public void ConfigureCopilotClient_AllowsTokenOverride() + { + var agent = CreateAgent(opts => + { + opts.GitHubToken = "original-token"; + opts.ConfigureCopilotClient = clientOpts => + { + // Token override IS allowed (BYOK use case) + clientOpts.GitHubToken = "byok-token"; + }; + }); + + var clientOptions = GetCopilotClientOptions(agent); + var token = GetProperty(clientOptions, "GitHubToken"); + + Assert.Equal("byok-token", token); + } + + [Fact] + public void ConfigureCopilotClient_NullDelegate_DoesNotThrow() + { + var agent = CreateAgent(opts => + { + opts.ConfigureCopilotClient = null; + }); + + Assert.NotNull(agent); + } + + private static SquadAgent CreateAgent(Action? configure = null) + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(options => + { + options.AgentName = "BYOK Test"; + options.SquadFolderPath = @"C:\squad-team-root"; + options.CliPath = @"C:\fake-copilot\copilot.exe"; + options.Cwd = @"C:\squad-team-root"; + options.GitHubToken = "test-token"; + options.CliArgs.Add("--extension"); + options.CliArgs.Add("squad"); + + configure?.Invoke(options); + }); + + return services.BuildServiceProvider().GetRequiredService(); + } + + private static object GetCopilotClientOptions(SquadAgent agent) + { + var client = GetRequiredField(agent, "_copilotClient"); + return GetRequiredField(client, "_options"); + } + + private static T GetRequiredField(object instance, string fieldName) + { + var field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + var value = field.GetValue(instance); + Assert.NotNull(value); + return (T)value; + } + + private static T GetRequiredProperty(object instance, string propertyName) + { + var value = GetProperty(instance, propertyName); + Assert.NotNull(value); + return value; + } + + private static T? GetProperty(object instance, string propertyName) + { + var property = instance.GetType().GetProperty( + propertyName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + Assert.NotNull(property); + return (T?)property.GetValue(instance); + } +} diff --git a/test/Squad.Agents.AI.Tests/SquadKeyedDITests.cs b/test/Squad.Agents.AI.Tests/SquadKeyedDITests.cs new file mode 100644 index 000000000..a77804826 --- /dev/null +++ b/test/Squad.Agents.AI.Tests/SquadKeyedDITests.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Squad.Agents.AI.Tests; + +/// +/// Validates keyed DI registration and resolution (Round 2 feature). +/// +public class SquadKeyedDITests +{ + [Fact] + public void AddKeyedSquadAgent_ResolvesViaKeyedService() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddKeyedSquadAgent("research", opts => + { + opts.SquadFolderPath = @"C:\research-team"; + opts.CliPath = @"C:\fake\copilot.exe"; + opts.GitHubToken = "test-token"; + }); + + var provider = services.BuildServiceProvider(); + var agent = provider.GetRequiredKeyedService("research"); + + Assert.NotNull(agent); + Assert.Equal("Squad", agent.Name); + } + + [Fact] + public void AddKeyedSquadAgent_TwoKeys_ResolveSeparately() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddKeyedSquadAgent("research", opts => + { + opts.AgentName = "Research Squad"; + opts.SquadFolderPath = @"C:\research"; + opts.CliPath = @"C:\fake\copilot.exe"; + opts.GitHubToken = "test-token"; + }); + services.AddKeyedSquadAgent("platform", opts => + { + opts.AgentName = "Platform Squad"; + opts.SquadFolderPath = @"C:\platform"; + opts.CliPath = @"C:\fake\copilot.exe"; + opts.GitHubToken = "test-token"; + }); + + var provider = services.BuildServiceProvider(); + var research = provider.GetRequiredKeyedService("research"); + var platform = provider.GetRequiredKeyedService("platform"); + + Assert.Equal("Research Squad", research.Name); + Assert.Equal("Platform Squad", platform.Name); + } + + [Fact] + public void AddKeyedSquadAgent_WithConnectionString_BindsOptions() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad-research"] = @"C:\conn-str-team" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddKeyedSquadAgent("research", opts => + { + opts.CliPath = @"C:\fake\copilot.exe"; + opts.GitHubToken = "test-token"; + }); + + var provider = services.BuildServiceProvider(); + var agent = provider.GetRequiredKeyedService("research"); + + Assert.NotNull(agent); + } + + [Fact] + public void AddKeyedSquadAgent_DoesNotRegisterNonKeyed() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddKeyedSquadAgent("research", opts => + { + opts.SquadFolderPath = @"C:\research"; + opts.CliPath = @"C:\fake\copilot.exe"; + opts.GitHubToken = "test-token"; + }); + + var provider = services.BuildServiceProvider(); + var nonKeyed = provider.GetService(); + + // Keyed registration should NOT be resolvable via non-keyed + Assert.Null(nonKeyed); + } + + [Fact] + public void AddKeyedSquadAgent_ThrowsOnNullOrWhitespaceKey() + { + var services = new ServiceCollection(); + + Assert.Throws(() => + services.AddKeyedSquadAgent("", opts => { })); + + Assert.Throws(() => + services.AddKeyedSquadAgent(" ", opts => { })); + } +} From 84599f58c43aad299eba37368a2749453676d64b Mon Sep 17 00:00:00 2001 From: tamirdresher Date: Tue, 2 Jun 2026 16:17:58 +0300 Subject: [PATCH 16/20] feat: add Squad.Agents.AI sample app demonstrating DI, keyed DI, BYOK, and streaming - samples/squad-agents-ai-sample/Program.cs: four runnable flows Flow 1 -- AddSquadAgent + RunAsync (basic DI) Flow 2 -- AddKeyedSquadAgent x2 + GetRequiredKeyedService Flow 3 -- ConfigureCopilotClient delegate (BYOK token + env var injection) Flow 4 -- RunStreamingAsync with await foreach token-by-token output - samples/squad-agents-ai-sample/Squad.Agents.AI.Sample.csproj: net10.0, project reference to src/Squad.Agents.AI, Microsoft.Extensions.Hosting 10.0.0 - samples/squad-agents-ai-sample/README.md: prerequisites, run commands, per-flow walkthrough, troubleshooting table - .github/workflows/squad-agents-ai-ci.yml: adds paths trigger and restore + build steps for the sample (no run step -- requires live CLI) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-agents-ai-ci.yml | 8 + samples/squad-agents-ai-sample/Program.cs | 337 ++++++++++++++++++ samples/squad-agents-ai-sample/README.md | 112 ++++++ .../Squad.Agents.AI.Sample.csproj | 20 ++ 4 files changed, 477 insertions(+) create mode 100644 samples/squad-agents-ai-sample/Program.cs create mode 100644 samples/squad-agents-ai-sample/README.md create mode 100644 samples/squad-agents-ai-sample/Squad.Agents.AI.Sample.csproj diff --git a/.github/workflows/squad-agents-ai-ci.yml b/.github/workflows/squad-agents-ai-ci.yml index d0621ee09..6e2290849 100644 --- a/.github/workflows/squad-agents-ai-ci.yml +++ b/.github/workflows/squad-agents-ai-ci.yml @@ -5,6 +5,7 @@ on: paths: - 'src/Squad.Agents.AI/**' - 'test/Squad.Agents.AI.Tests/**' + - 'samples/squad-agents-ai-sample/**' - '.github/workflows/squad-agents-ai-ci.yml' - 'Directory.*.props' - 'Directory.*.json' @@ -15,6 +16,7 @@ on: paths: - 'src/Squad.Agents.AI/**' - 'test/Squad.Agents.AI.Tests/**' + - 'samples/squad-agents-ai-sample/**' - '.github/workflows/squad-agents-ai-ci.yml' - 'Directory.*.props' - 'Directory.*.json' @@ -55,12 +57,18 @@ jobs: - name: Restore tests run: dotnet restore test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj -p:NuGetAudit=true + - name: Restore sample + run: dotnet restore samples/squad-agents-ai-sample/Squad.Agents.AI.Sample.csproj -p:NuGetAudit=true + - name: Build package run: dotnet build src/Squad.Agents.AI/Squad.Agents.AI.csproj --no-restore -c Release - name: Build tests run: dotnet build test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj --no-restore -c Release + - name: Build sample + run: dotnet build samples/squad-agents-ai-sample/Squad.Agents.AI.Sample.csproj --no-restore -c Release + - name: Test run: dotnet test test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj --no-build -c Release --logger trx --results-directory TestResults diff --git a/samples/squad-agents-ai-sample/Program.cs b/samples/squad-agents-ai-sample/Program.cs new file mode 100644 index 000000000..3ffefd16f --- /dev/null +++ b/samples/squad-agents-ai-sample/Program.cs @@ -0,0 +1,337 @@ +// ============================================================ +// Squad.Agents.AI — Sample application +// +// Demonstrates four distinct integration patterns: +// Flow 1 — Basic DI registration and a single prompt +// Flow 2 — Keyed DI with two agents under different service keys +// Flow 3 — BYOK / ConfigureCopilotClient delegate +// Flow 4 — Streaming via RunStreamingAsync +// +// Run: dotnet run --project samples/squad-agents-ai-sample/ +// Run one flow: dotnet run --project samples/squad-agents-ai-sample/ -- --flow=1 +// ============================================================ + +using Microsoft.Agents.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Squad.Agents.AI; + +// ── Argument parsing ───────────────────────────────────────────────────────── +int? selectedFlow = null; +foreach (var arg in args) +{ + if (arg.StartsWith("--flow=", StringComparison.OrdinalIgnoreCase) && + int.TryParse(arg["--flow=".Length..], out var n) && + n is >= 1 and <= 4) + { + selectedFlow = n; + } +} + +bool RunFlow(int flow) => selectedFlow is null || selectedFlow == flow; + +// Squad team root: read from env var or fall back to current directory. +// Real-world usage: set SQUAD_TEAM_ROOT to the path of an initialized Squad team. +var teamRoot = System.Environment.GetEnvironmentVariable("SQUAD_TEAM_ROOT") + ?? System.IO.Directory.GetCurrentDirectory(); + +PrintBanner($"Squad.Agents.AI v0.1 — sample run (team root: {teamRoot})"); +Console.WriteLine(); + +// ───────────────────────────────────────────────────────────────────────────── +// Flow 1: Basic DI registration — simplest path to running an agent +// ───────────────────────────────────────────────────────────────────────────── +if (RunFlow(1)) +{ + PrintHeader("Flow 1 — Basic DI registration"); + + var host1 = Host.CreateApplicationBuilder(args); + host1.Logging.SetMinimumLevel(LogLevel.Warning); // keep sample output clean + host1.Services.AddSquadAgent(o => + { + o.SquadFolderPath = teamRoot; + o.AgentName = "SampleSquad"; + o.Instructions = "You are a helpful assistant. Respond concisely."; + }); + + using var app1 = host1.Build(); + + var agent1 = app1.Services.GetRequiredService(); + + Console.WriteLine($"Agent name : {agent1.Name}"); + Console.WriteLine("Sending : \"What is 2 + 2?\""); + + var result1 = await RunWithErrorHandlingAsync(async () => + { + var session1 = await agent1.CreateSessionAsync(); + return await agent1.RunAsync("What is 2 + 2?", session1); + }); + + if (result1 is not null) + Console.WriteLine($"Response : {result1.Text}"); + + await DisposeIfNeeded(agent1); + PrintDone("Flow 1"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Flow 2: Keyed DI — two agents registered under distinct service keys +// ───────────────────────────────────────────────────────────────────────────── +if (RunFlow(2)) +{ + PrintHeader("Flow 2 — Keyed DI (two agents)"); + + var host2 = Host.CreateApplicationBuilder(args); + host2.Logging.SetMinimumLevel(LogLevel.Warning); + + // "alpha" agent — focuses on concise answers + host2.Services.AddKeyedSquadAgent("alpha", o => + { + o.SquadFolderPath = teamRoot; + o.AgentName = "AlphaSquad"; + o.Instructions = "Be concise. Answer in one sentence."; + }); + + // "beta" agent — verbose mode + host2.Services.AddKeyedSquadAgent("beta", o => + { + o.SquadFolderPath = teamRoot; + o.AgentName = "BetaSquad"; + o.Instructions = "Provide a detailed explanation with examples."; + }); + + using var app2 = host2.Build(); + + var alphaAgent = app2.Services.GetRequiredKeyedService("alpha"); + var betaAgent = app2.Services.GetRequiredKeyedService("beta"); + + Console.WriteLine($"[alpha] name: {alphaAgent.Name}"); + Console.WriteLine($"[beta] name: {betaAgent.Name}"); + Console.WriteLine(); + + var alphaResult = await RunWithErrorHandlingAsync(async () => + { + var s = await alphaAgent.CreateSessionAsync(); + return await alphaAgent.RunAsync("Explain the concept of dependency injection.", s); + }); + if (alphaResult is not null) + Console.WriteLine($"[alpha] {alphaResult.Text}"); + + Console.WriteLine(); + + var betaResult = await RunWithErrorHandlingAsync(async () => + { + var s = await betaAgent.CreateSessionAsync(); + return await betaAgent.RunAsync("Explain the concept of dependency injection.", s); + }); + if (betaResult is not null) + Console.WriteLine($"[beta] {betaResult.Text}"); + + await DisposeIfNeeded(alphaAgent); + await DisposeIfNeeded(betaAgent); + PrintDone("Flow 2"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Flow 3: BYOK / ConfigureCopilotClient delegate +// +// ConfigureCopilotClient lets you post-process CopilotClientOptions after +// Squad applies its standard values. The routing gate ensures Cwd/CliPath/ +// CliArgs cannot be replaced by accident; everything else is fair game. +// ───────────────────────────────────────────────────────────────────────────── +if (RunFlow(3)) +{ + PrintHeader("Flow 3 — BYOK / ConfigureCopilotClient delegate"); + + // Simulate a token from a credential store (never hardcode real tokens). + // In production replace this with Key Vault, managed identity, etc. + const string simulatedToken = "ghp_EXAMPLE_REPLACE_WITH_REAL_TOKEN"; + + var host3 = Host.CreateApplicationBuilder(args); + host3.Logging.SetMinimumLevel(LogLevel.Warning); + host3.Services.AddSquadAgent(o => + { + o.SquadFolderPath = teamRoot; + o.AgentName = "BYOKSquad"; + + // ConfigureCopilotClient is the BYOK extension point. + // It runs after Squad populates its own values; use it to inject a + // custom token or environment variable from your own credential store. + o.ConfigureCopilotClient = clientOpts => + { + // Inject a token from an external credential store + clientOpts.GitHubToken = simulatedToken; + + // Inject custom process-level variables by assigning a new dictionary. + // IReadOnlyDictionary indexer is read-only; always assign a new instance. + clientOpts.Environment = new Dictionary + { + ["SQUAD_SAMPLE_VAR"] = "demo-value" + }; + }; + }); + + using var app3 = host3.Build(); + + var agent3 = app3.Services.GetRequiredService(); + Console.WriteLine($"Agent name : {agent3.Name}"); + Console.WriteLine("BYOK delegate registered — custom token + env var will be forwarded to Copilot SDK."); + Console.WriteLine("Sending : \"Ping\""); + + var result3 = await RunWithErrorHandlingAsync(async () => + { + var s = await agent3.CreateSessionAsync(); + return await agent3.RunAsync("Ping", s); + }); + if (result3 is not null) + Console.WriteLine($"Response : {result3.Text}"); + + await DisposeIfNeeded(agent3); + PrintDone("Flow 3"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Flow 4: Streaming — print each response token as it arrives +// ───────────────────────────────────────────────────────────────────────────── +if (RunFlow(4)) +{ + PrintHeader("Flow 4 — Streaming (RunStreamingAsync)"); + + var host4 = Host.CreateApplicationBuilder(args); + host4.Logging.SetMinimumLevel(LogLevel.Warning); + host4.Services.AddSquadAgent(o => + { + o.SquadFolderPath = teamRoot; + o.AgentName = "StreamSquad"; + }); + + using var app4 = host4.Build(); + + var agent4 = app4.Services.GetRequiredService(); + Console.WriteLine($"Agent name : {agent4.Name}"); + Console.WriteLine("Streaming : \"Count to five slowly.\""); + Console.WriteLine("Tokens :"); + Console.Write(" "); + + var streamOk = await RunStreamingWithErrorHandlingAsync(async () => + { + var s = await agent4.CreateSessionAsync(); + await foreach (var update in agent4.RunStreamingAsync("Count to five slowly.", s)) + { + var text = update.Text; + if (!string.IsNullOrEmpty(text)) + Console.Write(text); + } + }); + + if (streamOk) + Console.WriteLine(); // newline after streaming tokens + + await DisposeIfNeeded(agent4); + PrintDone("Flow 4"); +} + +Console.WriteLine(); +PrintBanner("All requested flows completed."); + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +static void PrintBanner(string text) +{ + var line = new string('=', Math.Max(50, text.Length + 4)); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine(line); + Console.WriteLine($" {text}"); + Console.WriteLine(line); + Console.ResetColor(); +} + +static void PrintHeader(string title) +{ + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"── {title} ──"); + Console.ResetColor(); +} + +static void PrintDone(string flow) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"[{flow} ✓]"); + Console.ResetColor(); +} + +static async Task RunWithErrorHandlingAsync(Func> action) +{ + try + { + return await action(); + } + catch (Exception ex) when (IsCliMissingException(ex)) + { + PrintCliError(); + return null; + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"[ERROR] {ex.GetType().Name}: {ex.Message}"); + Console.ResetColor(); + return null; + } +} + +static async Task RunStreamingWithErrorHandlingAsync(Func action) +{ + try + { + await action(); + return true; + } + catch (Exception ex) when (IsCliMissingException(ex)) + { + PrintCliError(); + return false; + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\n[ERROR] {ex.GetType().Name}: {ex.Message}"); + Console.ResetColor(); + return false; + } +} + +static bool IsCliMissingException(Exception ex) +{ + var msg = ex.Message; + return msg.Contains("copilot", StringComparison.OrdinalIgnoreCase) || + msg.Contains("not found", StringComparison.OrdinalIgnoreCase) || + msg.Contains("cannot find", StringComparison.OrdinalIgnoreCase) || + msg.Contains("No such file", StringComparison.OrdinalIgnoreCase) || + ex is System.ComponentModel.Win32Exception; +} + +static void PrintCliError() +{ + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(); + Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ GitHub Copilot CLI was not found on PATH │"); + Console.WriteLine("│ │"); + Console.WriteLine("│ Install it and sign in before running this sample: │"); + Console.WriteLine("│ https://github.com/github/copilot-cli │"); + Console.WriteLine("│ │"); + Console.WriteLine("│ See the sample README.md for full prerequisites. │"); + Console.WriteLine("└─────────────────────────────────────────────────────────┘"); + Console.ResetColor(); +} + +static async ValueTask DisposeIfNeeded(SquadAgent agent) +{ + if (agent is IAsyncDisposable d) + await d.DisposeAsync(); +} diff --git a/samples/squad-agents-ai-sample/README.md b/samples/squad-agents-ai-sample/README.md new file mode 100644 index 000000000..0752e6f66 --- /dev/null +++ b/samples/squad-agents-ai-sample/README.md @@ -0,0 +1,112 @@ +# Squad.Agents.AI — Sample Application + +This sample demonstrates the four core integration patterns in `Squad.Agents.AI` v0.1: basic dependency-injection registration, keyed DI with multiple agents, the BYOK `ConfigureCopilotClient` delegate, and streaming via `RunStreamingAsync`. Each flow runs independently so you can focus on exactly the pattern you care about. It mirrors the plain-console structure of Microsoft Agent Framework reference samples: one `HostApplicationBuilder`, services registered once, agents resolved from DI, prompts sent, responses printed. + +## Prerequisites + +| Prerequisite | Notes | +|---|---| +| [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) | `dotnet --version` should print `10.x.x` | +| GitHub Copilot CLI on `PATH` | Install from [github.com/github/copilot-cli](https://github.com/github/copilot-cli); verify with `copilot --version` | +| Squad CLI installed | Follow the install guide in the [Squad repository README](https://github.com/tamirdresher/squad#readme) | +| Initialized Squad team root | Run `squad init` in a directory; this becomes your team root | +| GitHub Copilot authentication | Run `gh auth login` or the Copilot CLI sign-in flow (`copilot auth login`) once before running the sample | + +The sample reads the team root path from the `SQUAD_TEAM_ROOT` environment variable, falling back to the current working directory. Set it before running: + +```bash +# Linux / macOS +export SQUAD_TEAM_ROOT=/path/to/your/team-root + +# Windows PowerShell +$env:SQUAD_TEAM_ROOT = "C:\path\to\your\team-root" +``` + +## Run + +Run all four flows in sequence: + +```bash +dotnet run --project samples/squad-agents-ai-sample/ +``` + +Run a single flow (1–4): + +```bash +dotnet run --project samples/squad-agents-ai-sample/ -- --flow=1 +dotnet run --project samples/squad-agents-ai-sample/ -- --flow=2 +dotnet run --project samples/squad-agents-ai-sample/ -- --flow=3 +dotnet run --project samples/squad-agents-ai-sample/ -- --flow=4 +``` + +## Code Walkthrough + +### Flow 1 — Basic DI Registration + +The simplest path. A single `AddSquadAgent` call registers `SquadAgent` and the base `AIAgent` interface in a scoped DI scope. The sample resolves `SquadAgent` directly from `IServiceProvider`, creates a session, and calls `RunAsync` with a string prompt. The `AgentResponse.Text` property contains the full text of the response. This is the pattern most consumers will start with — if it works, you are set up correctly. + +```csharp +host.Services.AddSquadAgent(o => +{ + o.SquadFolderPath = teamRoot; + o.Instructions = "You are a helpful assistant. Respond concisely."; +}); +var agent = host.Services.GetRequiredService(); +var session = await agent.CreateSessionAsync(); +var response = await agent.RunAsync("What is 2 + 2?", session); +Console.WriteLine(response.Text); +``` + +### Flow 2 — Keyed DI (Multiple Agents) + +Uses `AddKeyedSquadAgent` to register two agents under distinct service keys (`"alpha"` and `"beta"`). Both coexist in the same container. Resolution uses `GetRequiredKeyedService("alpha")`. This pattern is useful for multi-tenant apps, A/B model comparisons, or routing logic that sends different query types to different agent configurations. + +```csharp +services.AddKeyedSquadAgent("alpha", o => { o.Instructions = "Be concise."; }); +services.AddKeyedSquadAgent("beta", o => { o.Instructions = "Be detailed."; }); + +var alpha = provider.GetRequiredKeyedService("alpha"); +var beta = provider.GetRequiredKeyedService("beta"); +``` + +### Flow 3 — BYOK / `ConfigureCopilotClient` Delegate + +`SquadAgentOptions.ConfigureCopilotClient` is the extension point for advanced SDK customization. The delegate receives `CopilotClientOptions` after Squad has applied its standard values. You can inject a token from a credential store, add custom environment variables, or tune SDK settings. A hard routing gate prevents the delegate from accidentally redirecting the agent to a different CLI process — if you change `Cwd`, `CliPath`, or `CliArgs`, Squad logs a warning and restores the originals. Configure those properties on `SquadAgentOptions` directly instead. + +```csharp +o.ConfigureCopilotClient = clientOpts => +{ + clientOpts.GitHubToken = myVault.GetSecret("copilot-token"); + clientOpts.Environment["MY_VAR"] = "value"; +}; +``` + +In production, replace the simulated token with a real credential store integration (Key Vault, managed identity, `ICredentialProvider`, etc.). Never hardcode real tokens in source code. + +### Flow 4 — Streaming + +`RunStreamingAsync` returns `IAsyncEnumerable`. Each `update.Text` contains a response fragment as it arrives from the Copilot SDK, enabling live token-by-token output in console apps, chat UIs, or real-time APIs. The sample writes each fragment to `Console.Write` without a newline so the output reads as a continuous stream. + +```csharp +await foreach (var update in agent.RunStreamingAsync("Count to five slowly.", session)) +{ + Console.Write(update.Text); +} +``` + +## Troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `GitHub Copilot CLI was not found on PATH` | `copilot` binary is missing or not on `PATH` | Install from [github.com/github/copilot-cli](https://github.com/github/copilot-cli) and verify with `copilot --version` | +| `Authentication failed` / `401` | Copilot CLI is not signed in | Run `gh auth login` or `copilot auth login` | +| `SquadFolderPath does not exist` | `SQUAD_TEAM_ROOT` points to a non-existent path | Set `SQUAD_TEAM_ROOT` to an initialized Squad team directory | +| `The system cannot find the file specified` (Win32Exception) | Copilot CLI not found | Same as first row above | +| Build error: `Package Squad.Agents.AI not found` | Sample uses a project reference; ensure you are running from the repo root | Run `dotnet build` from the repository root or pass `--project samples/squad-agents-ai-sample/` | + +## See Also + +- [Squad.Agents.AI full API documentation](../../src/Squad.Agents.AI/README.md) +- [Squad CLI repository and install guide](https://github.com/tamirdresher/squad) +- [Microsoft Agent Framework](https://github.com/microsoft/agents) +- [GitHub Copilot CLI](https://github.com/github/copilot-cli) diff --git a/samples/squad-agents-ai-sample/Squad.Agents.AI.Sample.csproj b/samples/squad-agents-ai-sample/Squad.Agents.AI.Sample.csproj new file mode 100644 index 000000000..ddb59a720 --- /dev/null +++ b/samples/squad-agents-ai-sample/Squad.Agents.AI.Sample.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + latest + Exe + false + + false + + + + + + + + + + From 2df1267b6fc776abfec280cc26e146ff4ae5b06e Mon Sep 17 00:00:00 2001 From: tamirdresher Date: Tue, 2 Jun 2026 19:45:02 +0300 Subject: [PATCH 17/20] refactor(Squad.Agents.AI): co-locate sample under src/ and consolidate README - Moves the sample app from samples/squad-agents-ai-sample/ to src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ so it lives alongside the package it demonstrates. - Folds the sample's standalone README into the package README, giving consumers one canonical doc for both the API and the runnable demo. - Adds to Squad.Agents.AI.csproj so the library's wildcard glob does not pick up Program.cs in the co-located samples subdirectory. - Updates the .csproj project reference, and CI workflow paths to match the new layout. - Verified end-to-end: dotnet build, dotnet test (43/43 passing), and a sample sanity-check run (clear CLI-not-found error, no stack trace). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-agents-ai-ci.yml | 8 +- samples/squad-agents-ai-sample/README.md | 112 ------------------ src/Squad.Agents.AI/README.md | 73 ++++++++++++ src/Squad.Agents.AI/Squad.Agents.AI.csproj | 3 + .../Squad.Agents.AI.Sample}/Program.cs | 4 +- .../samples/Squad.Agents.AI.Sample/README.md | 3 + .../Squad.Agents.AI.Sample.csproj | 2 +- 7 files changed, 86 insertions(+), 119 deletions(-) delete mode 100644 samples/squad-agents-ai-sample/README.md rename {samples/squad-agents-ai-sample => src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample}/Program.cs (98%) create mode 100644 src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/README.md rename {samples/squad-agents-ai-sample => src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample}/Squad.Agents.AI.Sample.csproj (90%) diff --git a/.github/workflows/squad-agents-ai-ci.yml b/.github/workflows/squad-agents-ai-ci.yml index 6e2290849..cdd8b5081 100644 --- a/.github/workflows/squad-agents-ai-ci.yml +++ b/.github/workflows/squad-agents-ai-ci.yml @@ -5,7 +5,7 @@ on: paths: - 'src/Squad.Agents.AI/**' - 'test/Squad.Agents.AI.Tests/**' - - 'samples/squad-agents-ai-sample/**' + - 'src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/**' - '.github/workflows/squad-agents-ai-ci.yml' - 'Directory.*.props' - 'Directory.*.json' @@ -16,7 +16,7 @@ on: paths: - 'src/Squad.Agents.AI/**' - 'test/Squad.Agents.AI.Tests/**' - - 'samples/squad-agents-ai-sample/**' + - 'src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/**' - '.github/workflows/squad-agents-ai-ci.yml' - 'Directory.*.props' - 'Directory.*.json' @@ -58,7 +58,7 @@ jobs: run: dotnet restore test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj -p:NuGetAudit=true - name: Restore sample - run: dotnet restore samples/squad-agents-ai-sample/Squad.Agents.AI.Sample.csproj -p:NuGetAudit=true + run: dotnet restore src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Squad.Agents.AI.Sample.csproj -p:NuGetAudit=true - name: Build package run: dotnet build src/Squad.Agents.AI/Squad.Agents.AI.csproj --no-restore -c Release @@ -67,7 +67,7 @@ jobs: run: dotnet build test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj --no-restore -c Release - name: Build sample - run: dotnet build samples/squad-agents-ai-sample/Squad.Agents.AI.Sample.csproj --no-restore -c Release + run: dotnet build src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Squad.Agents.AI.Sample.csproj --no-restore -c Release - name: Test run: dotnet test test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj --no-build -c Release --logger trx --results-directory TestResults diff --git a/samples/squad-agents-ai-sample/README.md b/samples/squad-agents-ai-sample/README.md deleted file mode 100644 index 0752e6f66..000000000 --- a/samples/squad-agents-ai-sample/README.md +++ /dev/null @@ -1,112 +0,0 @@ -# Squad.Agents.AI — Sample Application - -This sample demonstrates the four core integration patterns in `Squad.Agents.AI` v0.1: basic dependency-injection registration, keyed DI with multiple agents, the BYOK `ConfigureCopilotClient` delegate, and streaming via `RunStreamingAsync`. Each flow runs independently so you can focus on exactly the pattern you care about. It mirrors the plain-console structure of Microsoft Agent Framework reference samples: one `HostApplicationBuilder`, services registered once, agents resolved from DI, prompts sent, responses printed. - -## Prerequisites - -| Prerequisite | Notes | -|---|---| -| [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) | `dotnet --version` should print `10.x.x` | -| GitHub Copilot CLI on `PATH` | Install from [github.com/github/copilot-cli](https://github.com/github/copilot-cli); verify with `copilot --version` | -| Squad CLI installed | Follow the install guide in the [Squad repository README](https://github.com/tamirdresher/squad#readme) | -| Initialized Squad team root | Run `squad init` in a directory; this becomes your team root | -| GitHub Copilot authentication | Run `gh auth login` or the Copilot CLI sign-in flow (`copilot auth login`) once before running the sample | - -The sample reads the team root path from the `SQUAD_TEAM_ROOT` environment variable, falling back to the current working directory. Set it before running: - -```bash -# Linux / macOS -export SQUAD_TEAM_ROOT=/path/to/your/team-root - -# Windows PowerShell -$env:SQUAD_TEAM_ROOT = "C:\path\to\your\team-root" -``` - -## Run - -Run all four flows in sequence: - -```bash -dotnet run --project samples/squad-agents-ai-sample/ -``` - -Run a single flow (1–4): - -```bash -dotnet run --project samples/squad-agents-ai-sample/ -- --flow=1 -dotnet run --project samples/squad-agents-ai-sample/ -- --flow=2 -dotnet run --project samples/squad-agents-ai-sample/ -- --flow=3 -dotnet run --project samples/squad-agents-ai-sample/ -- --flow=4 -``` - -## Code Walkthrough - -### Flow 1 — Basic DI Registration - -The simplest path. A single `AddSquadAgent` call registers `SquadAgent` and the base `AIAgent` interface in a scoped DI scope. The sample resolves `SquadAgent` directly from `IServiceProvider`, creates a session, and calls `RunAsync` with a string prompt. The `AgentResponse.Text` property contains the full text of the response. This is the pattern most consumers will start with — if it works, you are set up correctly. - -```csharp -host.Services.AddSquadAgent(o => -{ - o.SquadFolderPath = teamRoot; - o.Instructions = "You are a helpful assistant. Respond concisely."; -}); -var agent = host.Services.GetRequiredService(); -var session = await agent.CreateSessionAsync(); -var response = await agent.RunAsync("What is 2 + 2?", session); -Console.WriteLine(response.Text); -``` - -### Flow 2 — Keyed DI (Multiple Agents) - -Uses `AddKeyedSquadAgent` to register two agents under distinct service keys (`"alpha"` and `"beta"`). Both coexist in the same container. Resolution uses `GetRequiredKeyedService("alpha")`. This pattern is useful for multi-tenant apps, A/B model comparisons, or routing logic that sends different query types to different agent configurations. - -```csharp -services.AddKeyedSquadAgent("alpha", o => { o.Instructions = "Be concise."; }); -services.AddKeyedSquadAgent("beta", o => { o.Instructions = "Be detailed."; }); - -var alpha = provider.GetRequiredKeyedService("alpha"); -var beta = provider.GetRequiredKeyedService("beta"); -``` - -### Flow 3 — BYOK / `ConfigureCopilotClient` Delegate - -`SquadAgentOptions.ConfigureCopilotClient` is the extension point for advanced SDK customization. The delegate receives `CopilotClientOptions` after Squad has applied its standard values. You can inject a token from a credential store, add custom environment variables, or tune SDK settings. A hard routing gate prevents the delegate from accidentally redirecting the agent to a different CLI process — if you change `Cwd`, `CliPath`, or `CliArgs`, Squad logs a warning and restores the originals. Configure those properties on `SquadAgentOptions` directly instead. - -```csharp -o.ConfigureCopilotClient = clientOpts => -{ - clientOpts.GitHubToken = myVault.GetSecret("copilot-token"); - clientOpts.Environment["MY_VAR"] = "value"; -}; -``` - -In production, replace the simulated token with a real credential store integration (Key Vault, managed identity, `ICredentialProvider`, etc.). Never hardcode real tokens in source code. - -### Flow 4 — Streaming - -`RunStreamingAsync` returns `IAsyncEnumerable`. Each `update.Text` contains a response fragment as it arrives from the Copilot SDK, enabling live token-by-token output in console apps, chat UIs, or real-time APIs. The sample writes each fragment to `Console.Write` without a newline so the output reads as a continuous stream. - -```csharp -await foreach (var update in agent.RunStreamingAsync("Count to five slowly.", session)) -{ - Console.Write(update.Text); -} -``` - -## Troubleshooting - -| Error | Cause | Fix | -|---|---|---| -| `GitHub Copilot CLI was not found on PATH` | `copilot` binary is missing or not on `PATH` | Install from [github.com/github/copilot-cli](https://github.com/github/copilot-cli) and verify with `copilot --version` | -| `Authentication failed` / `401` | Copilot CLI is not signed in | Run `gh auth login` or `copilot auth login` | -| `SquadFolderPath does not exist` | `SQUAD_TEAM_ROOT` points to a non-existent path | Set `SQUAD_TEAM_ROOT` to an initialized Squad team directory | -| `The system cannot find the file specified` (Win32Exception) | Copilot CLI not found | Same as first row above | -| Build error: `Package Squad.Agents.AI not found` | Sample uses a project reference; ensure you are running from the repo root | Run `dotnet build` from the repository root or pass `--project samples/squad-agents-ai-sample/` | - -## See Also - -- [Squad.Agents.AI full API documentation](../../src/Squad.Agents.AI/README.md) -- [Squad CLI repository and install guide](https://github.com/tamirdresher/squad) -- [Microsoft Agent Framework](https://github.com/microsoft/agents) -- [GitHub Copilot CLI](https://github.com/github/copilot-cli) diff --git a/src/Squad.Agents.AI/README.md b/src/Squad.Agents.AI/README.md index 41bd99b03..6f94a887a 100644 --- a/src/Squad.Agents.AI/README.md +++ b/src/Squad.Agents.AI/README.md @@ -182,6 +182,79 @@ Parsed URI query keys: `teamRoot`, `cliPath`, `cwd`, `cliArgs` (semicolon-separa - `.nuspec` metadata with authors, tags, repository, and readme pointer - `LICENSE` copied from the repository root +## Sample + +A runnable console application is included at +`src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/`. It demonstrates the four +core integration patterns in one place: basic DI, keyed DI with multiple agents, +the `ConfigureCopilotClient` BYOK delegate, and streaming via `RunStreamingAsync`. + +### Prerequisites + +| Prerequisite | Notes | +|---|---| +| [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) | `dotnet --version` should print `10.x.x` | +| GitHub Copilot CLI on `PATH` | Install from [github.com/github/copilot-cli](https://github.com/github/copilot-cli); verify with `copilot --version` | +| Squad CLI installed | Follow the install guide in the [Squad repository README](https://github.com/tamirdresher/squad#readme) | +| Initialized Squad team root | Run `squad init` in a directory; this becomes your team root | +| GitHub Copilot authentication | Run `gh auth login` or `copilot auth login` once before running the sample | + +Set the team root path before running: + +```bash +# Linux / macOS +export SQUAD_TEAM_ROOT=/path/to/your/team-root + +# Windows PowerShell +$env:SQUAD_TEAM_ROOT = "C:\path\to\your\team-root" +``` + +### Run + +Run all four flows in sequence: + +```bash +dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ +``` + +Run a single flow (1–4): + +```bash +dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ -- --flow=1 +dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ -- --flow=2 +dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ -- --flow=3 +dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ -- --flow=4 +``` + +### Flow walkthrough + +**Flow 1 — Basic DI registration** — `AddSquadAgent` registers `SquadAgent` and the +base `AIAgent` interface. Resolves the agent from DI, creates a session, calls +`RunAsync`, and prints `AgentResponse.Text`. + +**Flow 2 — Keyed DI (multiple agents)** — `AddKeyedSquadAgent` registers two agents +under keys `"alpha"` and `"beta"`. Resolution uses +`GetRequiredKeyedService("alpha")`. + +**Flow 3 — BYOK / `ConfigureCopilotClient` delegate** — the delegate receives +`CopilotClientOptions` after Squad has applied its defaults. Inject a custom token +or environment variable. The routing gate prevents accidental redirection of +`Cwd`, `CliPath`, or `CliArgs` — configure those on `SquadAgentOptions` directly. + +**Flow 4 — Streaming** — `RunStreamingAsync` returns +`IAsyncEnumerable`. Each `update.Text` fragment is written to +`Console.Write` without a newline for live token-by-token output. + +### Troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `GitHub Copilot CLI was not found on PATH` | `copilot` binary is missing or not on `PATH` | Install from [github.com/github/copilot-cli](https://github.com/github/copilot-cli); verify with `copilot --version` | +| `Authentication failed` / `401` | Copilot CLI is not signed in | Run `gh auth login` or `copilot auth login` | +| `SquadFolderPath does not exist` | `SQUAD_TEAM_ROOT` points to a non-existent path | Set `SQUAD_TEAM_ROOT` to an initialized Squad team directory | +| `The system cannot find the file specified` (Win32Exception) | Copilot CLI not found | Same as first row above | +| Build error: `Package Squad.Agents.AI not found` | Sample uses a project reference; ensure you run from the repo root | Run `dotnet build` from the repository root or pass `--project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/` | + ## See also - Root repo: diff --git a/src/Squad.Agents.AI/Squad.Agents.AI.csproj b/src/Squad.Agents.AI/Squad.Agents.AI.csproj index 64bb08270..50648713d 100644 --- a/src/Squad.Agents.AI/Squad.Agents.AI.csproj +++ b/src/Squad.Agents.AI/Squad.Agents.AI.csproj @@ -18,6 +18,9 @@ true 0.1.0-preview + + + diff --git a/samples/squad-agents-ai-sample/Program.cs b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Program.cs similarity index 98% rename from samples/squad-agents-ai-sample/Program.cs rename to src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Program.cs index 3ffefd16f..038c4722f 100644 --- a/samples/squad-agents-ai-sample/Program.cs +++ b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Program.cs @@ -7,8 +7,8 @@ // Flow 3 — BYOK / ConfigureCopilotClient delegate // Flow 4 — Streaming via RunStreamingAsync // -// Run: dotnet run --project samples/squad-agents-ai-sample/ -// Run one flow: dotnet run --project samples/squad-agents-ai-sample/ -- --flow=1 +// Run: dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ +// Run one flow: dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ -- --flow=1 // ============================================================ using Microsoft.Agents.AI; diff --git a/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/README.md b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/README.md new file mode 100644 index 000000000..bcaea14e4 --- /dev/null +++ b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/README.md @@ -0,0 +1,3 @@ +# Squad.Agents.AI Sample + +For docs see [../../README.md#sample](../../README.md#sample). diff --git a/samples/squad-agents-ai-sample/Squad.Agents.AI.Sample.csproj b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Squad.Agents.AI.Sample.csproj similarity index 90% rename from samples/squad-agents-ai-sample/Squad.Agents.AI.Sample.csproj rename to src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Squad.Agents.AI.Sample.csproj index ddb59a720..13209c281 100644 --- a/samples/squad-agents-ai-sample/Squad.Agents.AI.Sample.csproj +++ b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Squad.Agents.AI.Sample.csproj @@ -10,7 +10,7 @@ false - + From 0397e196c7714d23ca6d61f13dbbdd9562e74cd2 Mon Sep 17 00:00:00 2001 From: tamirdresher Date: Tue, 2 Jun 2026 20:41:48 +0300 Subject: [PATCH 18/20] chore(Squad.Agents.AI): remove outdated draft PR body file The standalone pr-body.md was an early draft authored before the live PR description took its final shape. The PR body on GitHub is the canonical source; this file is dead weight and would confuse maintainers reviewing the diff. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pr-body.md | 81 ------------------------------------------------------ 1 file changed, 81 deletions(-) delete mode 100644 pr-body.md diff --git a/pr-body.md b/pr-body.md deleted file mode 100644 index bd196efa0..000000000 --- a/pr-body.md +++ /dev/null @@ -1,81 +0,0 @@ -> ⚠️ DRAFT — Tamir tests locally before this goes ready-for-review - -## What this adds - -`Squad.Agents.AI` — a community NuGet that exposes the Squad multi-agent CLI as a `Microsoft.Extensions.AI.IChatClient`, composable into any MAF app or workflow. - -## Design refs (in `tamirdresher_microsoft/tamresearch1`) - -- `.squad/decisions.md` Decisions 441, 443, 444, 447 — full Q1–Q7 design lock -- Decision 441 SDK probe — `GitHubCopilotAgent` is sealed → compose via `AsAIAgent`; `CopilotClientOptions` exposes all needed knobs -- Decision 447 Q1–Q7 lock — TFM net10, naming `Squad.Agents.AI`, no `ExtensionSlug`, Hybrid PATH+URI wire format - -## How to test locally - -1. Clone this branch: - ```powershell - gh repo clone tamirdresher/squad - cd squad - gh pr checkout - ``` - -2. Build: - ```powershell - cd src/Squad.Agents.AI - dotnet build -c Release - dotnet pack -c Release -o ../../artifacts - ``` - -3. Sanity-check the package metadata: - ```powershell - ls ../../artifacts/Squad.Agents.AI.*.nupkg - ``` - -4. Wire into a test console app: - ```powershell - mkdir ..\TestSquadAgent - cd ..\TestSquadAgent - dotnet new console - dotnet add package Squad.Agents.AI --source ..\..\artifacts --prerelease - ``` - - Then in `Program.cs`: - ```csharp - using Microsoft.Extensions.AI; - using Microsoft.Extensions.DependencyInjection; - using Squad.Agents.AI; - - var services = new ServiceCollection(); - services.AddSquadAgent(opts => - { - opts.SquadFolderPath = @"C:\path\to\your\team-root"; - opts.GitHubToken = Environment.GetEnvironmentVariable("GH_TOKEN"); - }); - - var provider = services.BuildServiceProvider(); - var squad = provider.GetRequiredService(); - var resp = await squad.GetResponseAsync( - new[] { new ChatMessage(ChatRole.User, "hello squad, who are you?") } - ); - Console.WriteLine(resp.Message.Text); - ``` - -5. Verify the Squad CLI launches, the demo prints a sensible response. - -## What's NOT in this PR - -- Unit tests — deferred to follow-up (v0.2) -- Multi-target TFM — `net10.0` only per Q6 lock -- `.WithSquadCli()` / process spawning on the Aspire side — that's Track B (`CommunityToolkit.Aspire.Hosting.Squad`); separate PR in `tamirdresher/Aspire-1` - -## Known issues / Tamir to verify - -1. **Package version** — `Microsoft.Agents.AI.GitHub.Copilot 1.7.0-preview.260526.1` from Decision 441 may need update if a newer preview exists. Check with `nuget search Microsoft.Agents.AI.GitHub.Copilot -PreRelease`. - -2. **API surface alignment** — Implementation uses `IChatClient` from `Microsoft.Extensions.AI 10.6.0`, composing `AsAIAgent()` from `Microsoft.Agents.AI.GitHub.Copilot`. The cast from `AIAgent` → `IChatClient` is via `(IChatClient)(object)agent` — if MAF types change, this may need adjustment. - -3. **Build warnings** — XML doc comments missing for some public methods (non-blocking). Can be resolved in v0.2. - -## Co-author - -Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From ab119382805e7c0c57ba8f4b5e3e1ffaf839e5c3 Mon Sep 17 00:00:00 2001 From: tamirdresher Date: Wed, 3 Jun 2026 07:02:55 +0300 Subject: [PATCH 19/20] fix(Squad.Agents.AI): address PR #1207 reviewer feedback (12 items) - Snapshot CliArgs by value, not reference, so in-place mutation by SDK consumers is also caught by the routing guard. - Validate name/connectionName/serviceKey is non-empty in all DI registration overloads; previously null/whitespace produced invalid connection-string keys. - Replace ghp_-prefixed placeholder in the sample with a clearly-fake token to avoid tripping secret-scanning and to remove a real-token lookalike. - Remove brittle 'PR #3' references from README and CHANGELOG; describe the feature without tying to a specific PR thread. - Update NuGet metadata and README links to point to bradygaster/squad (canonical repo) instead of the tamirdresher fork. - Multi-target the test project to match the package's target framework set so CI exercises every framework the package ships against. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- README.md | 2 +- src/Squad.Agents.AI/README.md | 2 +- src/Squad.Agents.AI/Squad.Agents.AI.csproj | 7 ++++--- src/Squad.Agents.AI/SquadAgent.cs | 9 +++++---- .../SquadServiceCollectionExtensions.cs | 20 +++++++++++++++---- .../samples/Squad.Agents.AI.Sample/Program.cs | 2 +- .../Squad.Agents.AI.Tests.csproj | 2 +- 8 files changed, 30 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f542688b..81f25a9e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,7 +114,7 @@ First stable release since v0.9.4 (April 25). Consumes 97 changesets (sdk: 50, c - Added `Squad.Agents.AI`, a `net10.0` Microsoft Agent Framework package that exposes a Squad team as an `AIAgent`. - Public surface: `SquadAgent`, `SquadAgentOptions`, `SquadConnectionFactory`, and `SquadServiceCollectionExtensions`. - Added DI registration, PATH/URI connection-string parsing, GitHub token/provider options, and XML documentation for the public preview surface. -- Documented PR #3 lineage for the v0.1-preview package in `src/Squad.Agents.AI/README.md`. +- Added README documentation for the `Squad.Agents.AI` package in `src/Squad.Agents.AI/README.md`. ## [0.9.0] - 2026-03-23 diff --git a/README.md b/README.md index e1e2af174..b2ab79a3d 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Squad proposes a team — each member named from a persistent thematic cast. You ## .NET package preview -Building a .NET app that should call a Squad team as a Microsoft Agent Framework agent? PR #3 adds `Squad.Agents.AI`, a preview NuGet package under [`src/Squad.Agents.AI`](src/Squad.Agents.AI/README.md). It registers a Squad-backed `AIAgent` in DI and targets early `0.1.0-preview` consumers. +Building a .NET app that should call a Squad team as a Microsoft Agent Framework agent? `Squad.Agents.AI` is a preview NuGet package under [`src/Squad.Agents.AI`](src/Squad.Agents.AI/README.md). It registers a Squad-backed `AIAgent` in DI and targets early `0.1.0-preview` consumers. ## Upgrading diff --git a/src/Squad.Agents.AI/README.md b/src/Squad.Agents.AI/README.md index 6f94a887a..a7905c16d 100644 --- a/src/Squad.Agents.AI/README.md +++ b/src/Squad.Agents.AI/README.md @@ -195,7 +195,7 @@ the `ConfigureCopilotClient` BYOK delegate, and streaming via `RunStreamingAsync |---|---| | [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) | `dotnet --version` should print `10.x.x` | | GitHub Copilot CLI on `PATH` | Install from [github.com/github/copilot-cli](https://github.com/github/copilot-cli); verify with `copilot --version` | -| Squad CLI installed | Follow the install guide in the [Squad repository README](https://github.com/tamirdresher/squad#readme) | +| Squad CLI installed | Follow the install guide in the [Squad repository README](https://github.com/bradygaster/squad#readme) | | Initialized Squad team root | Run `squad init` in a directory; this becomes your team root | | GitHub Copilot authentication | Run `gh auth login` or `copilot auth login` once before running the sample | diff --git a/src/Squad.Agents.AI/Squad.Agents.AI.csproj b/src/Squad.Agents.AI/Squad.Agents.AI.csproj index 50648713d..0aae5bd11 100644 --- a/src/Squad.Agents.AI/Squad.Agents.AI.csproj +++ b/src/Squad.Agents.AI/Squad.Agents.AI.csproj @@ -1,6 +1,6 @@ - net10.0 + net8.0;net9.0;net10.0 enable enable latest @@ -10,9 +10,10 @@ Community SquadAgent for Microsoft Agent Framework. Exposes the Squad multi-agent CLI as an AIAgent that can be composed into any MAF app or workflow. Tamir Dresher squad;agents;ai;copilot;maf;multi-agent - https://github.com/tamirdresher/squad - https://github.com/tamirdresher/squad.git + https://github.com/bradygaster/squad + https://github.com/bradygaster/squad.git git + main MIT README.md true diff --git a/src/Squad.Agents.AI/SquadAgent.cs b/src/Squad.Agents.AI/SquadAgent.cs index b5469acf5..33bde353d 100644 --- a/src/Squad.Agents.AI/SquadAgent.cs +++ b/src/Squad.Agents.AI/SquadAgent.cs @@ -134,7 +134,8 @@ private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILog // Snapshot routing properties before the delegate runs var snapshotCwd = clientOptions.Cwd; var snapshotCliPath = clientOptions.CliPath; - var snapshotCliArgs = clientOptions.CliArgs; + // snapshot is a clone — in-place CliArgs mutation by SDK consumers is also caught + var cliArgsSnapshot = clientOptions.CliArgs?.ToArray(); options.ConfigureCopilotClient(clientOptions); @@ -160,12 +161,12 @@ private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILog restored = true; } - if (!ReferenceEquals(clientOptions.CliArgs, snapshotCliArgs)) + if (!(clientOptions.CliArgs ?? Array.Empty()).SequenceEqual(cliArgsSnapshot ?? Array.Empty())) { logger?.LogWarning( - "ConfigureCopilotClient delegate replaced CliArgs; " + + "ConfigureCopilotClient delegate changed CliArgs; " + "restoring original value to preserve Squad routing."); - clientOptions.CliArgs = snapshotCliArgs; + clientOptions.CliArgs = cliArgsSnapshot; restored = true; } diff --git a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs index 813e87085..73d0de149 100644 --- a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs +++ b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs @@ -40,7 +40,10 @@ public static IServiceCollection AddSquadAgent( this IServiceCollection services, string name, Action? configure = null) - => AddSquadAgentCore(services, name, ServiceLifetime.Scoped, configure); + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return AddSquadAgentCore(services, name, ServiceLifetime.Scoped, configure); + } /// /// Registers and base with the specified lifetime. @@ -68,7 +71,10 @@ public static IServiceCollection AddSquadAgent( string name, ServiceLifetime lifetime, Action? configure = null) - => AddSquadAgentCore(services, name, lifetime, configure); + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return AddSquadAgentCore(services, name, lifetime, configure); + } // ── Keyed DI overloads ─────────────────────────────────────────────────── @@ -109,7 +115,10 @@ public static IServiceCollection AddKeyedSquadAgent( string serviceKey, string name, Action? configure = null) - => AddKeyedSquadAgentCore(services, serviceKey, name, ServiceLifetime.Scoped, configure); + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return AddKeyedSquadAgentCore(services, serviceKey, name, ServiceLifetime.Scoped, configure); + } /// /// Registers as a keyed service with the specified lifetime. @@ -141,7 +150,10 @@ public static IServiceCollection AddKeyedSquadAgent( string name, ServiceLifetime lifetime, Action? configure = null) - => AddKeyedSquadAgentCore(services, serviceKey, name, lifetime, configure); + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return AddKeyedSquadAgentCore(services, serviceKey, name, lifetime, configure); + } // ── Core implementation (non-keyed) ────────────────────────────────────── diff --git a/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Program.cs b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Program.cs index 038c4722f..9b6c3824c 100644 --- a/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Program.cs +++ b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Program.cs @@ -146,7 +146,7 @@ // Simulate a token from a credential store (never hardcode real tokens). // In production replace this with Key Vault, managed identity, etc. - const string simulatedToken = "ghp_EXAMPLE_REPLACE_WITH_REAL_TOKEN"; + const string simulatedToken = "YOUR_GITHUB_PAT_HERE"; var host3 = Host.CreateApplicationBuilder(args); host3.Logging.SetMinimumLevel(LogLevel.Warning); diff --git a/test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj b/test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj index d29de4552..cc129f0c0 100644 --- a/test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj +++ b/test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj @@ -1,6 +1,6 @@ - net10.0 + net8.0;net9.0;net10.0 enable enable latest From c5aab269785fb2a323d17f4b2d9dcab2f8f01e65 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Tue, 9 Jun 2026 09:10:01 +0300 Subject: [PATCH 20/20] refactor(Squad.Agents.AI): adopt DelegatingAIAgent base + MAF ctor pattern (PR #1207 r2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses westey-m's review feedback: - Extend Microsoft.Agents.AI.DelegatingAIAgent — drops ~70 lines of manual Core* overrides; pass-through is provided by the base class. - Adopt MAF constructor pattern: \(string squadFolderPath, SquadAgentOptions? options = null, ILoggerFactory? loggerFactory = null)\. Required settings on the constructor, options optional, ILoggerFactory stays on ctor for DI injection. - Routing-guard, IAsyncDisposable, and security posture preserved. - Add Squad.Agents.AI.slnx solution for easy IDE open. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Squad.Agents.AI.slnx | 11 ++ src/Squad.Agents.AI/SquadAgent.cs | 176 +++++++----------- .../SquadServiceCollectionExtensions.cs | 14 +- .../SquadAgentRoutingTests.cs | 16 +- .../SquadServiceCollectionExtensionsTests.cs | 6 +- 5 files changed, 110 insertions(+), 113 deletions(-) create mode 100644 Squad.Agents.AI.slnx diff --git a/Squad.Agents.AI.slnx b/Squad.Agents.AI.slnx new file mode 100644 index 000000000..907725a98 --- /dev/null +++ b/Squad.Agents.AI.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Squad.Agents.AI/SquadAgent.cs b/src/Squad.Agents.AI/SquadAgent.cs index 33bde353d..b88c1c551 100644 --- a/src/Squad.Agents.AI/SquadAgent.cs +++ b/src/Squad.Agents.AI/SquadAgent.cs @@ -1,6 +1,5 @@ using GitHub.Copilot.SDK; using Microsoft.Agents.AI; -using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -8,6 +7,7 @@ namespace Squad.Agents.AI; /// /// Microsoft Agent Framework agent that delegates to a GitHub Copilot SDK client configured for a Squad team root. +/// Extends — Core* pass-through overrides are provided by the base class. /// /// /// @@ -16,53 +16,94 @@ namespace Squad.Agents.AI; /// /// /// -/// var agent = new SquadAgent(new SquadAgentOptions { SquadFolderPath = @"C:\repo" }); +/// var agent = new SquadAgent(@"C:\repo"); /// var response = await agent.RunAsync("Summarize the team."); /// /// /// -public sealed class SquadAgent : AIAgent, IAsyncDisposable +public sealed class SquadAgent : DelegatingAIAgent, IAsyncDisposable { private readonly CopilotClient _copilotClient; - private readonly AIAgent _inner; private readonly ILogger? _logger; private readonly bool _ownsClient; private readonly SquadAgentOptions _options; + // State-bag to thread pre-base-ctor state through chain constructors. + // DelegatingAIAgent requires the inner AIAgent at base() call time, so we + // build everything via static factory methods before invoking base(). + private readonly record struct SquadAgentState( + AIAgent Inner, + CopilotClient CopilotClient, + ILogger? Logger, + bool OwnsClient, + SquadAgentOptions Options); + /// - /// Initializes a new from fully-resolved options and owns the created Copilot client. + /// Initializes a new with the Squad team root as the primary required parameter. /// - /// Options that describe the Squad team root, CLI process, authentication, logging, and instructions. - /// Optional logger factory used for wrapper logs and SDK trace logging when enabled. - public SquadAgent(SquadAgentOptions options, ILoggerFactory? loggerFactory = null) - : this(CreateCopilotClient(options, loggerFactory), options, loggerFactory, ownsClient: true) + /// Path to the initialized Squad team root. Required. + /// + /// Optional options. When a default is used. + /// is overwritten by + /// so there is exactly one source of truth for the team root. + /// + /// Optional logger factory; inject via DI or supply directly. + public SquadAgent(string squadFolderPath, SquadAgentOptions? options = null, ILoggerFactory? loggerFactory = null) + : this(BuildState(squadFolderPath, options, loggerFactory)) { } /// - /// Initializes a new from the options pattern. + /// Initializes a new from the options pattern (DI-friendly). /// - /// Options wrapper containing the resolved . - /// Optional logger factory used for wrapper logs and SDK trace logging when enabled. + /// + /// Options wrapper. must be set via a configure callback + /// or connection string before the agent is resolved from DI. + /// + /// Optional logger factory. + /// + /// Thrown when is or whitespace. + /// public SquadAgent(IOptions options, ILoggerFactory? loggerFactory = null) - : this(options.Value, loggerFactory) + : this(BuildStateFromOptions(options, loggerFactory)) { } - private SquadAgent(CopilotClient copilotClient, SquadAgentOptions options, ILoggerFactory? loggerFactory, bool ownsClient) + private SquadAgent(SquadAgentState state) + : base(state.Inner) + { + _copilotClient = state.CopilotClient; + _logger = state.Logger; + _ownsClient = state.OwnsClient; + _options = state.Options; + _logger?.LogInformation("SquadAgent initialized with name '{AgentName}', team root '{TeamRoot}'", + state.Options.AgentName, state.Options.SquadFolderPath); + } + + private static SquadAgentState BuildState(string squadFolderPath, SquadAgentOptions? options, ILoggerFactory? lf) { - _copilotClient = copilotClient ?? throw new ArgumentNullException(nameof(copilotClient)); - _logger = loggerFactory?.CreateLogger(); - _ownsClient = ownsClient; - _options = options; + ArgumentException.ThrowIfNullOrWhiteSpace(squadFolderPath); + var resolved = options ?? new SquadAgentOptions(); + resolved.SquadFolderPath = squadFolderPath; // ctor param wins + return BuildStateInternal(resolved, lf); + } - // Create the inner AIAgent via AsAIAgent extension — NO CAST - _inner = _copilotClient.AsAIAgent( - instructions: options.Instructions, - name: options.AgentName ?? "Squad"); + private static SquadAgentState BuildStateFromOptions(IOptions options, ILoggerFactory? lf) + { + ArgumentNullException.ThrowIfNull(options); + var opts = options.Value ?? throw new ArgumentException("Options.Value cannot be null.", nameof(options)); + if (string.IsNullOrWhiteSpace(opts.SquadFolderPath)) + throw new ArgumentException( + "SquadAgentOptions.SquadFolderPath must be set (via configure callback or connection string).", + nameof(options)); + return BuildStateInternal(opts, lf); + } - _logger?.LogInformation("SquadAgent initialized with name '{AgentName}', team root '{TeamRoot}'", - options.AgentName, options.SquadFolderPath); + private static SquadAgentState BuildStateInternal(SquadAgentOptions options, ILoggerFactory? lf) + { + var client = CreateCopilotClient(options, lf); + var inner = client.AsAIAgent(instructions: options.Instructions, name: options.AgentName ?? "Squad"); + return new SquadAgentState(inner, client, lf?.CreateLogger(), true, options); } // ── Extensibility seam ────────────────────────────────────────────── @@ -183,87 +224,14 @@ private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILog /// /// Gets the display name exposed by this agent. + /// Prefers over the inner agent's name. /// - public override string? Name => _options.AgentName ?? _inner.Name; + public override string? Name => _options.AgentName ?? base.Name; /// - /// Gets the agent description exposed by the inner Copilot-backed agent, or a Squad fallback description. + /// Gets the agent description, falling back to a Squad-specific default when the inner agent provides none. /// - public override string? Description => _inner.Description ?? "Squad multi-agent CLI participant"; - - /// - /// Creates a new MAF session by delegating to the inner agent. - /// - /// Token that cancels session creation. - /// A newly-created agent session. - protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) - { - return _inner.CreateSessionAsync(cancellationToken); - } - - /// - /// Runs a non-streaming agent turn by delegating to the inner agent. - /// - /// Messages to send to the agent. - /// Optional session for multi-turn continuity. - /// Optional run options. - /// Token that cancels the run. - /// The complete agent response. - protected override Task RunCoreAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - return _inner.RunAsync(messages, session, options, cancellationToken); - } - - /// - /// Runs a streaming agent turn by delegating to the inner agent. - /// - /// Messages to send to the agent. - /// Optional session for multi-turn continuity. - /// Optional run options. - /// Token that cancels the run. - /// An async stream of response updates. - protected override IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - return _inner.RunStreamingAsync(messages, session, options, cancellationToken); - } - - /// - /// Serializes a session by delegating to the inner agent. - /// - /// Session to serialize. - /// Optional JSON serializer settings. - /// Token that cancels serialization. - /// The serialized session state. - protected override ValueTask SerializeSessionCoreAsync( - AgentSession session, - JsonSerializerOptions? jsonSerializerOptions = null, - CancellationToken cancellationToken = default) - { - return _inner.SerializeSessionAsync(session, jsonSerializerOptions, cancellationToken); - } - - /// - /// Deserializes a session by delegating to the inner agent. - /// - /// Serialized session state. - /// Optional JSON serializer settings. - /// Token that cancels deserialization. - /// The deserialized session. - protected override ValueTask DeserializeSessionCoreAsync( - JsonElement sessionState, - JsonSerializerOptions? jsonSerializerOptions = null, - CancellationToken cancellationToken = default) - { - return _inner.DeserializeSessionAsync(sessionState, jsonSerializerOptions, cancellationToken); - } + public override string? Description => base.Description ?? "Squad multi-agent CLI participant"; /// /// Disposes the owned Copilot client and disposable inner agent resources. @@ -272,14 +240,10 @@ protected override ValueTask DeserializeSessionCoreAsync( public async ValueTask DisposeAsync() { if (_ownsClient) - { await _copilotClient.DisposeAsync().ConfigureAwait(false); - } - if (_inner is IAsyncDisposable innerDisposable) - { + if (InnerAgent is IAsyncDisposable innerDisposable) await innerDisposable.DisposeAsync().ConfigureAwait(false); - } _logger?.LogDebug("SquadAgent disposed"); } diff --git a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs index 73d0de149..045c7b30b 100644 --- a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs +++ b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs @@ -176,7 +176,12 @@ private static IServiceCollection AddSquadAgentCore( sp => { var options = sp.GetRequiredService>().Get(optionsName); - return ActivatorUtilities.CreateInstance(sp, options); + var loggerFactory = sp.GetService(); + var folderPath = options.SquadFolderPath; + if (string.IsNullOrWhiteSpace(folderPath)) + throw new InvalidOperationException( + "SquadAgentOptions.SquadFolderPath must be configured (via configure callback or connection string) before resolving SquadAgent."); + return new SquadAgent(folderPath, options, loggerFactory); }, lifetime)); @@ -214,7 +219,12 @@ private static IServiceCollection AddKeyedSquadAgentCore( (sp, _) => { var options = sp.GetRequiredService>().Get(optionsName); - return ActivatorUtilities.CreateInstance(sp, options); + var loggerFactory = sp.GetService(); + var folderPath = options.SquadFolderPath; + if (string.IsNullOrWhiteSpace(folderPath)) + throw new InvalidOperationException( + "SquadAgentOptions.SquadFolderPath must be configured (via configure callback or connection string) before resolving SquadAgent."); + return new SquadAgent(folderPath, options, loggerFactory); }, lifetime)); diff --git a/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs b/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs index bb445f677..5beff8142 100644 --- a/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs +++ b/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs @@ -77,7 +77,7 @@ public void AddSquadAgent_RoutesThroughCopilotClientOptionsNotAgentName() options.Environment["SQUAD_ROUTE"] = "enabled"; }); - var inner = GetRequiredField(agent, "_inner"); + var inner = GetInnerAgent(agent); var sessionConfig = GetInnerSessionConfig(agent); var clientOptions = GetCopilotClientOptions(agent); var cliArgs = GetRequiredProperty(clientOptions, "CliArgs"); @@ -153,9 +153,21 @@ private static object GetCopilotClientOptions(SquadAgent agent) return GetRequiredField(client, "_options"); } + private static object GetInnerAgent(SquadAgent agent) + { + // InnerAgent is protected on DelegatingAIAgent; search the declaring base type. + var prop = typeof(DelegatingAIAgent).GetProperty( + "InnerAgent", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + Assert.NotNull(prop); + var value = prop.GetValue(agent); + Assert.NotNull(value); + return value!; + } + private static object GetInnerSessionConfig(SquadAgent agent) { - var inner = GetRequiredField(agent, "_inner"); + var inner = GetInnerAgent(agent); return GetRequiredField(inner, "_sessionConfig"); } diff --git a/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs b/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs index 46470778a..0ba8745f3 100644 --- a/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs +++ b/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs @@ -9,7 +9,7 @@ namespace Squad.Agents.AI.Tests; public class SquadServiceCollectionExtensionsTests { [Fact] - public void AddSquadAgent_WithoutConfig_RegistersAIAgent() + public void AddSquadAgent_WithoutConfig_ThrowsOnResolution() { var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); @@ -18,9 +18,9 @@ public void AddSquadAgent_WithoutConfig_RegistersAIAgent() services.AddSquadAgent(); var provider = services.BuildServiceProvider(); - var agent = provider.GetService(); - Assert.NotNull(agent); + // SquadFolderPath not set → InvalidOperationException on resolution (MAF ctor pattern). + Assert.Throws(() => provider.GetRequiredService()); } [Fact]