Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,9 @@ docs/tests/screenshots/
bin/
obj/
artifacts/

# Visual Studio IDE artifacts
.vs/
*.user
*.userprefs

13 changes: 11 additions & 2 deletions src/Squad.Agents.AI/Squad.Agents.AI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,22 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Version>0.1.0-preview</Version>
<Version>0.2.0</Version>
</PropertyGroup>
<ItemGroup>
<Compile Remove="samples/**/*.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.7.0-preview.260526.1" />
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.10.0-rc1" />
<!--
Direct reference to GitHub.Copilot.SDK so its build/ targets fire during our build.
Without this, the SDK's MSBuild download/copy targets only flow to projects with a
DIRECT PackageReference — transitive consumers (us → MAF → SDK) get only the DLL,
not the binary download. With the direct ref, the SDK downloads copilot.exe into
bin/.../runtimes/{rid}/native/ and ContentWithTargetPath propagates it to consumers.
Once microsoft/agent-framework#6457 merges, this will no longer be required.
-->
<PackageReference Include="GitHub.Copilot.SDK" Version="1.0.0" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.6.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" />
Expand Down
130 changes: 93 additions & 37 deletions src/Squad.Agents.AI/SquadAgent.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using GitHub.Copilot.SDK;
using GitHub.Copilot;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -103,14 +103,33 @@ private static SquadAgentState BuildStateInternal(SquadAgentOptions options, ILo
{
var client = CreateCopilotClient(options, lf);

// Build a SessionConfig with a sensible default permission handler so the
// resulting AIAgent can call CreateSession without throwing. Callers can override
// via SquadAgentOptions.ConfigureSession (e.g., to inject a stricter handler or
// tweak instructions / available tools / model on the inner session).
// Build the SessionConfig. Two non-obvious settings are critical here for the
// SquadAgent to actually work end-to-end against a `.squad/`-initialised team:
//
// 1. ConfigDir + EnableConfigDiscovery = true
// Points the Copilot CLI at the .squad/ folder so it auto-discovers the
// team's agents, skills, instructions, and MCP servers at session start.
// Without these, the agent has to read .squad/team.md (and per-agent
// charters) via runtime file tools — every read becomes a permission
// request, the agent eventually gives up and reports "permission errors".
//
// 2. OnPermissionRequest = PermissionHandler.ApproveAll
// Required by the SDK (CreateSessionAsync throws without it). This is the
// SDK-protocol layer; the CLI also has its own per-tool gate which we
// open with --allow-all in CreateCopilotClient.
//
// Callers can override any of this via SquadAgentOptions.ConfigureSession.
var teamRoot = options.Cwd ?? options.SquadFolderPath;
var squadConfigDir = !string.IsNullOrEmpty(teamRoot)
? Path.Combine(teamRoot, ".squad")
: null;

var sessionConfig = new SessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll,
WorkingDirectory = options.Cwd ?? options.SquadFolderPath,
WorkingDirectory = teamRoot,
ConfigDirectory = squadConfigDir,
EnableConfigDiscovery = true,
};
if (!string.IsNullOrEmpty(options.Instructions))
{
Expand Down Expand Up @@ -155,17 +174,64 @@ private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILog
resolvedToken = options.GitHubToken;
}

// ── CLI path resolution ────────────────────────────────────────────
// When this package is consumed via NuGet, GitHub.Copilot.SDK's build
// targets download the copilot CLI binary and copy it to bin/.../runtimes/
// {rid}/native/copilot.exe. The SDK runtime looks there by default, so
// most consumers don't need to set anything.
//
// (Squad.Agents.AI keeps a *direct* PackageReference to GitHub.Copilot.SDK
// exactly to force those build targets to fire — without it the SDK is only
// a transitive dependency of Microsoft.Agents.AI.GitHub.Copilot and its
// build/ targets don't propagate. Once microsoft/agent-framework#6457
// merges, this dance will no longer be required.)
//
// SquadAgentOptions.CliPath / CliArgs remain explicit overrides for advanced
// scenarios: custom CLI builds, sandboxed runners, air-gapped environments.
// SDK 1.0.0 expresses both via RuntimeConnection.ForStdio(path, args), so we
// build a single Connection when either is supplied.
// ───────────────────────────────────────────────────────────────────
var clientOptions = new CopilotClientOptions
{
CliPath = options.CliPath,
Cwd = options.Cwd ?? options.SquadFolderPath,
GitHubToken = resolvedToken
WorkingDirectory = options.Cwd ?? options.SquadFolderPath,
GitHubToken = resolvedToken,
};

// Preserve CLI args parsed from connection strings or supplied by user code for the SDK's CLI invocation.
if (options.CliArgs.Count > 0)
// ── CLI permission flags ───────────────────────────────────────────
// The Copilot CLI enforces THREE independent permission gates:
// 1. Tools (--allow-all-tools) — which tool kinds may run
// 2. Paths (--allow-all-paths) — which filesystem paths may be read/written
// 3. URLs (--allow-all-urls) — which URLs may be fetched
//
// The SDK-level `OnPermissionRequest` handler only covers the SDK protocol;
// each CLI gate is a separate verification step. We pass `--allow-all` by
// default so the SquadAgent can actually drive tool calls end-to-end against
// the entire Squad workspace. Hosts that want stricter behavior can replace
// this via SquadAgentOptions.CliArgs / the ConfigureCopilotClient delegate.
// ───────────────────────────────────────────────────────────────────
// Skip our `--allow-all` default if the host already opted in via any of the
// CLI's permission-opening flags (or the omnibus `--yolo` alias). Comparison is
// case-insensitive because `copilot --help` documents the flags in lowercase but
// CLI argument parsers on Windows are commonly forgiving of case differences.
var combinedCliArgs = new List<string>();
bool hostAlreadyOpenedPermissions = options.CliArgs.Any(a =>
string.Equals(a, "--allow-all", StringComparison.OrdinalIgnoreCase) ||
string.Equals(a, "--allow-all-tools", StringComparison.OrdinalIgnoreCase) ||
string.Equals(a, "--allow-all-paths", StringComparison.OrdinalIgnoreCase) ||
string.Equals(a, "--allow-all-urls", StringComparison.OrdinalIgnoreCase) ||
string.Equals(a, "--yolo", StringComparison.OrdinalIgnoreCase));
if (!hostAlreadyOpenedPermissions)
{
combinedCliArgs.Add("--allow-all");
}
combinedCliArgs.AddRange(options.CliArgs);

// Only override the SDK's default child-process connection when the consumer
// supplied a custom CLI path or any extra CLI args. Otherwise let the SDK
// resolve its own bundled binary (downloaded via the build/ targets).
if (!string.IsNullOrEmpty(options.CliPath) || combinedCliArgs.Count > 0)
{
clientOptions.CliArgs = options.CliArgs.ToArray();
clientOptions.Connection = RuntimeConnection.ForStdio(options.CliPath, combinedCliArgs);
}

// Copy environment variables
Expand All @@ -192,52 +258,42 @@ private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILog
// 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.)
//
// SDK 1.0.0 collapsed CliPath/CliArgs into Connection (RuntimeConnection),
// so the routing gate now snapshots WorkingDirectory and Connection.
if (options.ConfigureCopilotClient is not null)
{
// Snapshot routing properties before the delegate runs
var snapshotCwd = clientOptions.Cwd;
var snapshotCliPath = clientOptions.CliPath;
// snapshot is a clone — in-place CliArgs mutation by SDK consumers is also caught
var cliArgsSnapshot = clientOptions.CliArgs?.ToArray();
var snapshotWorkingDirectory = clientOptions.WorkingDirectory;
var snapshotConnection = clientOptions.Connection;

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))
if (!string.Equals(clientOptions.WorkingDirectory, snapshotWorkingDirectory, StringComparison.Ordinal))
{
logger?.LogWarning(
"ConfigureCopilotClient delegate changed CliPath from '{Original}' to '{Changed}'; " +
"ConfigureCopilotClient delegate changed WorkingDirectory from '{Original}' to '{Changed}'; " +
"restoring original value to preserve Squad routing.",
snapshotCliPath, clientOptions.CliPath);
clientOptions.CliPath = snapshotCliPath;
snapshotWorkingDirectory, clientOptions.WorkingDirectory);
clientOptions.WorkingDirectory = snapshotWorkingDirectory;
restored = true;
}

if (!(clientOptions.CliArgs ?? Array.Empty<string>()).SequenceEqual(cliArgsSnapshot ?? Array.Empty<string>()))
if (!ReferenceEquals(clientOptions.Connection, snapshotConnection))
{
logger?.LogWarning(
"ConfigureCopilotClient delegate changed CliArgs; " +
"restoring original value to preserve Squad routing.");
clientOptions.CliArgs = cliArgsSnapshot;
"ConfigureCopilotClient delegate replaced Connection; " +
"restoring original value to preserve Squad routing. " +
"Configure CLI path / args via SquadAgentOptions.CliPath / CliArgs instead.");
clientOptions.Connection = snapshotConnection;
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.");
"To set the team root, CLI path, or CLI args, configure them on SquadAgentOptions instead.");
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Squad.Agents.AI/SquadAgentOptions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using System.Text.Json.Serialization;
using GitHub.Copilot.SDK;
using GitHub.Copilot;

namespace Squad.Agents.AI;

Expand Down Expand Up @@ -115,7 +115,7 @@
/// Gets or sets a delegate that customizes the <see cref="SessionConfig"/> used to
/// construct the inner <see cref="Microsoft.Agents.AI.AIAgent"/>. The delegate runs
/// after Squad has applied its defaults (including an <c>ApproveAll</c>
/// <see cref="SessionConfig.OnPermissionRequest"/> handler and the

Check warning on line 118 in src/Squad.Agents.AI/SquadAgentOptions.cs

View workflow job for this annotation

GitHub Actions / .NET ubuntu-latest

XML comment has cref attribute 'OnPermissionRequest' that could not be resolved

Check warning on line 118 in src/Squad.Agents.AI/SquadAgentOptions.cs

View workflow job for this annotation

GitHub Actions / .NET ubuntu-latest

XML comment has cref attribute 'OnPermissionRequest' that could not be resolved

Check warning on line 118 in src/Squad.Agents.AI/SquadAgentOptions.cs

View workflow job for this annotation

GitHub Actions / .NET ubuntu-latest

XML comment has cref attribute 'OnPermissionRequest' that could not be resolved

Check warning on line 118 in src/Squad.Agents.AI/SquadAgentOptions.cs

View workflow job for this annotation

GitHub Actions / .NET ubuntu-latest

XML comment has cref attribute 'OnPermissionRequest' that could not be resolved

Check warning on line 118 in src/Squad.Agents.AI/SquadAgentOptions.cs

View workflow job for this annotation

GitHub Actions / .NET ubuntu-latest

XML comment has cref attribute 'OnPermissionRequest' that could not be resolved

Check warning on line 118 in src/Squad.Agents.AI/SquadAgentOptions.cs

View workflow job for this annotation

GitHub Actions / .NET ubuntu-latest

XML comment has cref attribute 'OnPermissionRequest' that could not be resolved

Check warning on line 118 in src/Squad.Agents.AI/SquadAgentOptions.cs

View workflow job for this annotation

GitHub Actions / .NET windows-latest

XML comment has cref attribute 'OnPermissionRequest' that could not be resolved

Check warning on line 118 in src/Squad.Agents.AI/SquadAgentOptions.cs

View workflow job for this annotation

GitHub Actions / .NET windows-latest

XML comment has cref attribute 'OnPermissionRequest' that could not be resolved

Check warning on line 118 in src/Squad.Agents.AI/SquadAgentOptions.cs

View workflow job for this annotation

GitHub Actions / .NET windows-latest

XML comment has cref attribute 'OnPermissionRequest' that could not be resolved

Check warning on line 118 in src/Squad.Agents.AI/SquadAgentOptions.cs

View workflow job for this annotation

GitHub Actions / .NET windows-latest

XML comment has cref attribute 'OnPermissionRequest' that could not be resolved

Check warning on line 118 in src/Squad.Agents.AI/SquadAgentOptions.cs

View workflow job for this annotation

GitHub Actions / .NET windows-latest

XML comment has cref attribute 'OnPermissionRequest' that could not be resolved

Check warning on line 118 in src/Squad.Agents.AI/SquadAgentOptions.cs

View workflow job for this annotation

GitHub Actions / .NET windows-latest

XML comment has cref attribute 'OnPermissionRequest' that could not be resolved
/// <see cref="Instructions"/> as the appended system message), so it can override or
/// extend any session-scoped setting such as the permission handler, tool list,
/// model name, or hooks.
Expand Down
22 changes: 14 additions & 8 deletions src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
// 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})");
Comment thread
tamirdresher marked this conversation as resolved.
Console.WriteLine();

Expand Down Expand Up @@ -278,7 +277,10 @@ static void PrintDone(string flow)
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[ERROR] {ex.GetType().Name}: {ex.Message}");
Console.WriteLine($"[ERROR] {ex.GetType().FullName}: {ex.Message}");
if (ex.InnerException is not null)
Console.WriteLine($" inner: {ex.InnerException.GetType().FullName}: {ex.InnerException.Message}");
Console.WriteLine(ex.StackTrace);
Console.ResetColor();
return null;
}
Expand Down Expand Up @@ -307,12 +309,12 @@ static async Task<bool> RunStreamingWithErrorHandlingAsync(Func<Task> action)

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;
// Only treat *startup* failures (CLI binary missing) as the friendly CLI-not-found case.
// Everything else — auth, RPC, agent runtime errors — should bubble up as a real error
// so the user can actually see what went wrong instead of a misleading "install copilot" banner.
return ex is System.ComponentModel.Win32Exception w32 &&
(w32.NativeErrorCode == 2 /* ERROR_FILE_NOT_FOUND */ ||
w32.NativeErrorCode == 3 /* ERROR_PATH_NOT_FOUND */);
}

static void PrintCliError()
Expand All @@ -335,3 +337,7 @@ static async ValueTask DisposeIfNeeded(SquadAgent agent)
if (agent is IAsyncDisposable d)
await d.DisposeAsync();
}




22 changes: 18 additions & 4 deletions test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public void AddSquadAgent_UsesExplicitCwdForCopilotClientRouting()

var clientOptions = GetCopilotClientOptions(agent);

Assert.Equal(@"C:\isolated-working-directory", GetRequiredProperty<string>(clientOptions, "Cwd"));
Assert.Equal(@"C:\isolated-working-directory", GetRequiredProperty<string>(clientOptions, "WorkingDirectory"));
}

[Fact]
Expand All @@ -62,7 +62,7 @@ public void AddSquadAgent_DefaultsCopilotClientCwdToSquadFolderPath()

var clientOptions = GetCopilotClientOptions(agent);

Assert.Equal(@"C:\squad-team-root", GetRequiredProperty<string>(clientOptions, "Cwd"));
Assert.Equal(@"C:\squad-team-root", GetRequiredProperty<string>(clientOptions, "WorkingDirectory"));
}

[Fact]
Expand All @@ -80,7 +80,7 @@ public void AddSquadAgent_RoutesThroughCopilotClientOptionsNotAgentName()
var inner = GetInnerAgent(agent);
var sessionConfig = GetInnerSessionConfig(agent);
var clientOptions = GetCopilotClientOptions(agent);
var cliArgs = GetRequiredProperty<string[]>(clientOptions, "CliArgs");
var cliArgs = GetConnectionArgs(clientOptions);
var environment = GetRequiredProperty<IReadOnlyDictionary<string, string>>(clientOptions, "Environment");

Assert.Equal("Custom Persona", GetRequiredProperty<string>(inner, "Name"));
Expand Down Expand Up @@ -112,7 +112,7 @@ public void AddSquadAgent_CopiesConnectionStringCliArgsToCopilotClientOptions()
var provider = services.BuildServiceProvider();
var agent = provider.GetRequiredService<SquadAgent>();
var clientOptions = GetCopilotClientOptions(agent);
var cliArgs = GetRequiredProperty<string[]>(clientOptions, "CliArgs");
var cliArgs = GetConnectionArgs(clientOptions);

Assert.Equal(new[] { "--yolo", "--model", "gpt-5" }, cliArgs);
}
Expand Down Expand Up @@ -153,6 +153,20 @@ private static object GetCopilotClientOptions(SquadAgent agent)
return GetRequiredField<object>(client, "_options");
}

/// <summary>
/// In SDK 1.0.0 the CLI args live inside CopilotClientOptions.Connection (a
/// RuntimeConnection — concrete type ChildProcessRuntimeConnection — exposing
/// .Path and .Args). Older tests asserted against a flat clientOptions.CliArgs
/// property; this helper hides the unwrap so each test stays readable.
/// </summary>
private static IList<string> GetConnectionArgs(object clientOptions)
{
var connection = GetRequiredProperty<object>(clientOptions, "Connection");
var args = GetProperty<IList<string>>(connection, "Args");
Assert.NotNull(args);
return args!;
}

private static object GetInnerAgent(SquadAgent agent)
{
// InnerAgent is protected on DelegatingAIAgent; search the declaring base type.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using GitHub.Copilot;
using GitHub.Copilot.SDK;
using Xunit;

namespace Squad.Agents.AI.Tests;
Expand Down Expand Up @@ -53,7 +52,7 @@ public void ConfigureSession_CanReplacePermissionHandler()
var options = new SquadAgentOptions();
// Build a no-op handler so we just verify the property can be reassigned.
// The exact PermissionRequestResult shape isn't relevant to this test.
PermissionRequestHandler customHandler = PermissionHandler.ApproveAll;
var customHandler = PermissionHandler.ApproveAll;

options.ConfigureSession = config => config.OnPermissionRequest = customHandler;

Expand Down
Loading
Loading