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
13 changes: 13 additions & 0 deletions src/Squad.Agents.AI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,18 @@ squad://localhost?teamRoot=C%3A%5Cteam&cliPath=C%3A%5Ctools%5Ccopilot.exe&cliArg

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.

## Coordinator agent selection

`SquadAgent` exists to wrap a Squad coordinator team, so the SDK sets `SessionConfig.Agent` to `"squad"` by default. That's the SDK-native equivalent of running `copilot --agent squad` from a terminal — it tells the wrapped Copilot session to load `.github/agents/squad.agent.md` as the agent definition, which is what teaches the coordinator to eager-execute, fan out, and dispatch through the `task` tool. Without it, the wrapped session uses its built-in generic agent and the coordinator role-plays responses inline instead of spawning real subagents — producing SDK behavior that does NOT match running `copilot --agent squad` interactively against the same team root.

| Scenario | What the SDK does |
|---|---|
| Default — `squad.agent.md` exists | Sets `sessionConfig.Agent = "squad"` |
| `squad.agent.md` not found at `.github/agents/` | Leaves `sessionConfig.Agent` unset (graceful degradation for not-yet-initialized teams) |
| Consumer sets `AgentFileName = "data"` (and file exists) | Sets `sessionConfig.Agent = "data"` |
| Consumer sets `AgentFileName = null` | Leaves `sessionConfig.Agent` unset |
| Consumer overrides `sessionConfig.Agent` inside `ConfigureSession` | The `ConfigureSession` callback runs after the default is applied, so it always wins |

## Subagent observability — first-class OpenTelemetry

`Squad.Agents.AI` emits one OpenTelemetry `Activity` per subagent dispatch out of the box. Hosts that subscribe to the activity source see one `squad.subagent {Name}` span per spawn, with the subagent name as a tag and timeline annotations (`squad.subagent.start`, `squad.subagent.message`, `squad.subagent.completed`, `squad.subagent.failed`) marking every state transition.
Expand Down Expand Up @@ -196,6 +208,7 @@ builder.Services.AddSquadAgent(o =>
| `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`. |
| `AgentFileName` | Name of the agent definition under `.github/agents/{name}.agent.md` to load via `SessionConfig.Agent` (the SDK equivalent of the Copilot CLI's `--agent` flag). Defaults to `"squad"`. Set to `null` to skip auto-selection; the file-existence check makes this safe to leave on for not-yet-initialized teams. Override `sessionConfig.Agent` from inside `ConfigureSession` for full control. |
| `Instructions` | Optional system instructions passed to the inner Copilot agent. |
| `EmitSubagentActivities` | Whether the SDK opens an OpenTelemetry `Activity` per subagent dispatch and annotates lifecycle events. Defaults to `true`. Set to `false` to handle telemetry from your own `OnSubagentTrace` callback. |
| `OnSubagentTrace` | Optional callback invoked for every typed `SquadAgentTraceEvent`. Independent of `EmitSubagentActivities` — both can be on simultaneously. |
Expand Down
2 changes: 1 addition & 1 deletion src/Squad.Agents.AI/Squad.Agents.AI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Version>0.4.0</Version>
<Version>0.5.0</Version>
</PropertyGroup>
<ItemGroup>
<Compile Remove="samples/**/*.cs" />
Expand Down
31 changes: 31 additions & 0 deletions src/Squad.Agents.AI/SquadAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,36 @@ private static SquadAgentState BuildStateInternal(SquadAgentOptions options, ILo
ConfigDirectory = squadConfigDir,
EnableConfigDiscovery = true,
};

// ── Default coordinator agent selection ────────────────────────────
// SquadAgent wraps a Squad coordinator team. The SDK's SessionConfig.Agent
// property is the first-class equivalent of the Copilot CLI's --agent flag —
// it selects which discovered agent definition (e.g. .github/agents/squad.agent.md)
// to load as the session's system prompt. Without it the CLI uses its built-in
// generic agent and the coordinator role-plays responses inline instead of
// dispatching real subagents — exactly the inconsistency between
// `copilot --agent squad` (CLI) and SquadAgent.RunAsync (SDK) that this
// default eliminates.
//
// We set Agent = AgentFileName (default "squad") unless:
// 1. The host explicitly configured a different Agent via ConfigureSession
// (handled implicitly — ConfigureSession runs after us and wins)
// 2. AgentFileName is null or whitespace (explicit opt-out)
// 3. The agent file does not exist at the conventional path on disk
// (graceful degradation for folders that are not yet Squad-initialized,
// where the CLI would error on `--agent squad`)
// ───────────────────────────────────────────────────────────────────
if (!string.IsNullOrWhiteSpace(options.AgentFileName) && !string.IsNullOrWhiteSpace(teamRoot))
{
var agentFilePath = Path.Combine(teamRoot, ".github", "agents", $"{options.AgentFileName}.agent.md");
if (File.Exists(agentFilePath))
{
sessionConfig.Agent = options.AgentFileName;
}
// If the file is missing we leave SessionConfig.Agent unset; the SDK will
// start with its default agent, which is what 0.4.x did anyway.
}

if (!string.IsNullOrEmpty(options.Instructions))
{
sessionConfig.SystemMessage = new SystemMessageConfig { Content = options.Instructions };
Expand Down Expand Up @@ -242,6 +272,7 @@ private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILog
{
combinedCliArgs.Add("--allow-all");
}

combinedCliArgs.AddRange(options.CliArgs);

// Only override the SDK's default child-process connection when the consumer
Expand Down
30 changes: 30 additions & 0 deletions src/Squad.Agents.AI/SquadAgentOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,36 @@
/// </summary>
public string AgentName { get; set; } = "Squad";

/// <summary>
/// Gets or sets the Squad coordinator agent definition file (under <c>.github/agents/</c>) to
/// load as the wrapped session's agent. Defaults to <c>"squad"</c>, which selects
/// <c>.github/agents/squad.agent.md</c> — the Squad coordinator system prompt that drives
/// eager execution, parallel fan-out, and dispatch through the <c>task</c> tool.
/// </summary>
/// <remarks>
/// <para>
/// The whole reason <see cref="SquadAgent"/> exists is to wrap a Squad coordinator team, so
/// the SDK sets <see cref="GitHub.Copilot.SessionConfigBase.Agent"/> by default. Without it,
/// the wrapped session uses its built-in generic agent and the coordinator role-plays
/// responses inline instead of dispatching real subagents — exactly the inconsistency
/// between <c>copilot --agent squad</c> (CLI) and <c>SquadAgent.RunAsync</c> (SDK) that this
/// default eliminates.
/// </para>
/// <para>
/// When the named file does not exist at
/// <c>{team-root}/.github/agents/{AgentFileName}.agent.md</c> (e.g. the team root is not
/// Squad-initialized yet), the SDK silently leaves <see cref="GitHub.Copilot.SessionConfigBase.Agent"/>
/// unset so the session can still start with the SDK's default agent.
/// </para>
/// <para>
/// Set to <see langword="null"/> (or whitespace) to disable the auto-selection entirely. Set
/// to a different name (e.g. <c>"data"</c>) to load a custom agent file. To override the
/// final value, set <c>sessionConfig.Agent</c> from inside <see cref="ConfigureSession"/> —
/// that callback runs after this default is applied.
/// </para>
/// </remarks>
public string? AgentFileName { get; set; } = "squad";

/// <summary>
/// Gets or sets optional system instructions passed to the inner Copilot-backed agent.
/// </summary>
Expand Down Expand Up @@ -115,7 +145,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 148 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 148 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 148 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 Expand Up @@ -173,7 +203,7 @@
/// {
/// if (trace.Kind == SquadAgentTraceEventKind.SubagentStarted)
/// Console.WriteLine($"[spawn] {trace.SubagentName} (id={trace.SdkAgentId})");
/// else if (trace.Kind == SquadAgentTraceEventKind.AssistantMessage && trace.SdkAgentId is not null)

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

View workflow job for this annotation

GitHub Actions / .NET ubuntu-latest

XML comment has badly formed XML -- 'Whitespace is not allowed at this location.'

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

View workflow job for this annotation

GitHub Actions / .NET ubuntu-latest

XML comment has badly formed XML -- 'The character(s) '&' cannot be used at this location.'

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

View workflow job for this annotation

GitHub Actions / .NET ubuntu-latest

XML comment has badly formed XML -- 'Whitespace is not allowed at this location.'

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

View workflow job for this annotation

GitHub Actions / .NET ubuntu-latest

XML comment has badly formed XML -- 'The character(s) '&' cannot be used at this location.'

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

View workflow job for this annotation

GitHub Actions / .NET windows-latest

XML comment has badly formed XML -- 'Whitespace is not allowed at this location.'

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

View workflow job for this annotation

GitHub Actions / .NET windows-latest

XML comment has badly formed XML -- 'Whitespace is not allowed at this location.'

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

View workflow job for this annotation

GitHub Actions / .NET windows-latest

XML comment has badly formed XML -- 'The character(s) '&' cannot be used at this location.'

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

View workflow job for this annotation

GitHub Actions / .NET windows-latest

XML comment has badly formed XML -- 'The character(s) '&' cannot be used at this location.'

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

View workflow job for this annotation

GitHub Actions / .NET windows-latest

XML comment has badly formed XML -- 'Whitespace is not allowed at this location.'

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

View workflow job for this annotation

GitHub Actions / .NET windows-latest

XML comment has badly formed XML -- 'The character(s) '&' cannot be used at this location.'
/// Console.WriteLine($"[{trace.SdkAgentId}] {trace.Content}");
/// };
/// </code>
Expand Down
194 changes: 194 additions & 0 deletions test/Squad.Agents.AI.Tests/SquadAgentDefaultAgentTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
using System.Reflection;
using GitHub.Copilot;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace Squad.Agents.AI.Tests;

/// <summary>
/// Tests for the 0.5.0 behaviour where SquadAgent auto-sets
/// <see cref="SessionConfigBase.Agent"/> (the SDK's first-class equivalent of
/// the Copilot CLI's <c>--agent</c> flag) to <see cref="SquadAgentOptions.AgentFileName"/>,
/// so the wrapped session picks up <c>.github/agents/squad.agent.md</c> by default —
/// matching <c>copilot --agent squad</c> from the terminal.
/// </summary>
public class SquadAgentDefaultAgentTests : IDisposable
{
private readonly string _tempRoot;

public SquadAgentDefaultAgentTests()
{
// Each test gets its own throwaway team root so file-existence checks
// are deterministic and we can scaffold or omit the squad.agent.md file
// per scenario without contaminating siblings.
_tempRoot = Path.Combine(Path.GetTempPath(), "squad-agents-ai-tests", Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(_tempRoot);
}

public void Dispose()
{
try { Directory.Delete(_tempRoot, recursive: true); }
catch { /* best effort */ }
}

private string ScaffoldAgentFile(string agentName)
{
var dir = Path.Combine(_tempRoot, ".github", "agents");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, $"{agentName}.agent.md");
File.WriteAllText(path, $"# {agentName}\n\nstub agent file for tests");
return path;
}

[Fact]
public void Options_AgentFileName_DefaultsToSquad()
{
var options = new SquadAgentOptions();
Assert.Equal("squad", options.AgentFileName);
}

[Fact]
public void AddSquadAgent_AutoSetsSessionConfigAgentToSquad_WhenSquadAgentMdExists()
{
ScaffoldAgentFile("squad");

var agent = CreateAgent(opts =>
{
opts.SquadFolderPath = _tempRoot;
opts.Cwd = _tempRoot;
});

var sessionConfig = GetInnerSessionConfig(agent);
Assert.Equal("squad", sessionConfig.Agent);
}

[Fact]
public void AddSquadAgent_LeavesAgentUnset_WhenAgentFileMissing()
{
// No .github/agents/squad.agent.md scaffolded — the SDK should NOT
// set sessionConfig.Agent because the underlying session would error
// when asked to load a non-existent agent.
var agent = CreateAgent(opts =>
{
opts.SquadFolderPath = _tempRoot;
opts.Cwd = _tempRoot;
});

var sessionConfig = GetInnerSessionConfig(agent);
Assert.Null(sessionConfig.Agent);
}

[Fact]
public void AddSquadAgent_RespectsCustomAgentFileName_WhenFileExists()
{
ScaffoldAgentFile("data");

var agent = CreateAgent(opts =>
{
opts.SquadFolderPath = _tempRoot;
opts.Cwd = _tempRoot;
opts.AgentFileName = "data";
});

var sessionConfig = GetInnerSessionConfig(agent);
Assert.Equal("data", sessionConfig.Agent);
}

[Fact]
public void AddSquadAgent_LeavesAgentUnset_WhenAgentFileNameIsNull()
{
// Opt-out: setting AgentFileName=null disables the auto-set even if
// squad.agent.md exists. Used when the consumer wants to drive the
// session with a custom SystemMessage instead of an agent file.
ScaffoldAgentFile("squad");

var agent = CreateAgent(opts =>
{
opts.SquadFolderPath = _tempRoot;
opts.Cwd = _tempRoot;
opts.AgentFileName = null;
});

var sessionConfig = GetInnerSessionConfig(agent);
Assert.Null(sessionConfig.Agent);
}

[Fact]
public void AddSquadAgent_LeavesAgentUnset_WhenAgentFileNameIsWhitespace()
{
ScaffoldAgentFile("squad");

var agent = CreateAgent(opts =>
{
opts.SquadFolderPath = _tempRoot;
opts.Cwd = _tempRoot;
opts.AgentFileName = " ";
});

var sessionConfig = GetInnerSessionConfig(agent);
Assert.Null(sessionConfig.Agent);
}

[Fact]
public void AddSquadAgent_ConfigureSession_CanOverrideAutoSetAgent()
{
// ConfigureSession runs AFTER the default is applied, so consumers retain
// full control — they can replace the auto-set value with anything else
// (or null it out) from inside their own callback.
ScaffoldAgentFile("squad");
ScaffoldAgentFile("custom");

var agent = CreateAgent(opts =>
{
opts.SquadFolderPath = _tempRoot;
opts.Cwd = _tempRoot;
opts.ConfigureSession = sessionConfig =>
{
// Confirm the default was applied before this callback ran.
Assert.Equal("squad", sessionConfig.Agent);
sessionConfig.Agent = "custom";
};
});

var actualSessionConfig = GetInnerSessionConfig(agent);
Assert.Equal("custom", actualSessionConfig.Agent);
}

// ── helpers ─────────────────────────────────────────────────────────────

private static SquadAgent CreateAgent(Action<SquadAgentOptions> configure)
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddLogging();
services.AddSquadAgent(opts =>
{
opts.CliPath = @"C:\fake-copilot\copilot.exe";
configure(opts);
});
return services.BuildServiceProvider().GetRequiredService<SquadAgent>();
}

/// <summary>
/// Reach into the SquadAgent → DelegatingAIAgent.InnerAgent → ChatClientAgent.SessionConfig
/// to assert against the SessionConfig the SDK was constructed with.
/// </summary>
private static SessionConfig GetInnerSessionConfig(SquadAgent agent)
{
var innerProp = typeof(Microsoft.Agents.AI.DelegatingAIAgent).GetProperty(
"InnerAgent",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
Assert.NotNull(innerProp);
var inner = innerProp!.GetValue(agent);
Assert.NotNull(inner);

var configField = inner!.GetType().GetField(
"_sessionConfig",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(configField);
var config = configField!.GetValue(inner);
Assert.NotNull(config);
return (SessionConfig)config!;
}
}
Expand Down
Loading