diff --git a/src/Squad.Agents.AI/README.md b/src/Squad.Agents.AI/README.md index 78a3d89d0..a6dff3e0c 100644 --- a/src/Squad.Agents.AI/README.md +++ b/src/Squad.Agents.AI/README.md @@ -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. @@ -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. | diff --git a/src/Squad.Agents.AI/Squad.Agents.AI.csproj b/src/Squad.Agents.AI/Squad.Agents.AI.csproj index 40fa8553b..e2187fc5b 100644 --- a/src/Squad.Agents.AI/Squad.Agents.AI.csproj +++ b/src/Squad.Agents.AI/Squad.Agents.AI.csproj @@ -17,7 +17,7 @@ MIT README.md true - 0.4.0 + 0.5.0 diff --git a/src/Squad.Agents.AI/SquadAgent.cs b/src/Squad.Agents.AI/SquadAgent.cs index c54528063..afdca9653 100644 --- a/src/Squad.Agents.AI/SquadAgent.cs +++ b/src/Squad.Agents.AI/SquadAgent.cs @@ -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 }; @@ -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 diff --git a/src/Squad.Agents.AI/SquadAgentOptions.cs b/src/Squad.Agents.AI/SquadAgentOptions.cs index 295c76077..a696c1174 100644 --- a/src/Squad.Agents.AI/SquadAgentOptions.cs +++ b/src/Squad.Agents.AI/SquadAgentOptions.cs @@ -82,6 +82,36 @@ public sealed class SquadAgentOptions /// public string AgentName { get; set; } = "Squad"; + /// + /// Gets or sets the Squad coordinator agent definition file (under .github/agents/) to + /// load as the wrapped session's agent. Defaults to "squad", which selects + /// .github/agents/squad.agent.md — the Squad coordinator system prompt that drives + /// eager execution, parallel fan-out, and dispatch through the task tool. + /// + /// + /// + /// The whole reason exists is to wrap a Squad coordinator team, so + /// the SDK sets 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 copilot --agent squad (CLI) and SquadAgent.RunAsync (SDK) that this + /// default eliminates. + /// + /// + /// When the named file does not exist at + /// {team-root}/.github/agents/{AgentFileName}.agent.md (e.g. the team root is not + /// Squad-initialized yet), the SDK silently leaves + /// unset so the session can still start with the SDK's default agent. + /// + /// + /// Set to (or whitespace) to disable the auto-selection entirely. Set + /// to a different name (e.g. "data") to load a custom agent file. To override the + /// final value, set sessionConfig.Agent from inside — + /// that callback runs after this default is applied. + /// + /// + public string? AgentFileName { get; set; } = "squad"; + /// /// Gets or sets optional system instructions passed to the inner Copilot-backed agent. /// diff --git a/test/Squad.Agents.AI.Tests/SquadAgentDefaultAgentTests.cs b/test/Squad.Agents.AI.Tests/SquadAgentDefaultAgentTests.cs new file mode 100644 index 000000000..92469a7ed --- /dev/null +++ b/test/Squad.Agents.AI.Tests/SquadAgentDefaultAgentTests.cs @@ -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; + +/// +/// Tests for the 0.5.0 behaviour where SquadAgent auto-sets +/// (the SDK's first-class equivalent of +/// the Copilot CLI's --agent flag) to , +/// so the wrapped session picks up .github/agents/squad.agent.md by default — +/// matching copilot --agent squad from the terminal. +/// +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 configure) + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().Build()); + services.AddLogging(); + services.AddSquadAgent(opts => + { + opts.CliPath = @"C:\fake-copilot\copilot.exe"; + configure(opts); + }); + return services.BuildServiceProvider().GetRequiredService(); + } + + /// + /// Reach into the SquadAgent → DelegatingAIAgent.InnerAgent → ChatClientAgent.SessionConfig + /// to assert against the SessionConfig the SDK was constructed with. + /// + 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!; + } +}