diff --git a/CHANGELOG.md b/CHANGELOG.md index b391334a..705b3e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## Unreleased + +### Added + +- **Agents can opt out of the app default layer** with `inheritAppDefaults: false` + in `.agent.md` frontmatter. An app's `default.agent.md` is otherwise + force-merged into every session — both its prompt and its `tools:` — so the + only way to author an agent that did not want those instructions was to + weaken the default for everyone. An opted-out agent is composed from the + framework base plus its own prompt and tools only; the rest of the deployment + is unaffected. + + Omitting the field inherits, so existing agents are unchanged. The flag is + resolved from the agent definition at prompt-composition time rather than + persisted on the session, so redefining an agent takes effect on its next + turn and existing sessions need no migration. It is inert for management + agents, which already bypass app overlays. + + This is a composition choice, not a security boundary — invariants that must + hold for every session still belong in the framework base layer, which cannot + be opted out of. + +### Fixed + +- **`PLUGIN_DIRS` no longer collapses to a single directory.** The variable has + always been documented and parsed as a comma-separated list, and the SDK + worker loads every entry — but the TUI/portal bootstrap resolved it down to + `dirs[0]` and then wrote that single path back over `process.env.PLUGIN_DIRS`. + Any deployment configuring two plugin dirs silently lost all but the first, + which for an overlay holding `default.agent.md` meant the app default layer + vanished with no error. `--plugin` now also accepts a comma-separated list, + and `system.md` is resolved across all plugin dirs (first match wins). + Branding still comes from the first dir only, so single-dir setups are + byte-identical. + ## 0.5.28 — 2026-07-28 ### Portal performance diff --git a/docs/developer/building/plugins.md b/docs/developer/building/plugins.md index e1db04c4..138696b7 100644 --- a/docs/developer/building/plugins.md +++ b/docs/developer/building/plugins.md @@ -154,6 +154,7 @@ Agents are defined as Markdown files with YAML frontmatter. The frontmatter decl | `description` | string | No | Short description shown in agent lists. | | `tools` | string[] | No | Tool names this agent can access. | | `skills` | string[] | No | Skill names to preload into this agent's context from the plugin skill directories. | +| `inheritAppDefaults` | boolean | No | Defaults to `true`. Set `false` to compose this agent without the app's `default.agent.md` layer — neither its prompt nor its `tools:`. | | `system` | boolean | No | If `true`, agent is auto-started by the worker as a background session. | | `id` | string | No | Deterministic slug for system agents (e.g. `"sweeper"`). Used to derive a stable session UUID. | | `title` | string | No | Display name in session lists. Falls back to capitalized `name` + " Agent". | @@ -201,6 +202,32 @@ The agent with `name: default` has unique behavior: - It defines app-wide rules that should apply to your app's sessions. - PilotSwarm management agents do not inherit app `default.agent.md` overlays. +#### Opting a single agent out (`inheritAppDefaults: false`) + +The app default is normally force-merged into every session — both its prompt +and its `tools:`. An individual agent can decline it: + +```yaml +--- +name: reviewer +inheritAppDefaults: false +--- +``` + +That agent is then composed from the framework base plus its own prompt and +tools only. Nothing changes for the other agents in the deployment, so this is +the way to add an agent on a clean slate without weakening an app default that +other agents depend on. + +The flag is resolved from the agent definition on every turn rather than stored +on the session, so editing an agent takes effect on its next turn. Omitting it +inherits, which is the historical behavior. It is inert for management agents, +which already bypass app overlays. + +> Note this is a *composition* choice, not a security boundary: if an app +> default carries rules that must hold for every session without exception, +> those belong in the framework base layer, which cannot be opted out of. + ### System Agents (`system: true`) System agents are background sessions started automatically when the worker launches. They require an `id` field, which is hashed into a deterministic UUID so the session persists across worker restarts. diff --git a/packages/app/tui/src/bootstrap-env.js b/packages/app/tui/src/bootstrap-env.js index 6b5c4b87..3e0bbe88 100644 --- a/packages/app/tui/src/bootstrap-env.js +++ b/packages/app/tui/src/bootstrap-env.js @@ -8,17 +8,17 @@ import { resolveTuiBranding } from "./plugin-config.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const pkgRoot = path.resolve(__dirname, ".."); -function resolvePluginDir(flags) { - if (flags.plugin) return path.resolve(flags.plugin); +export function resolvePluginDirs(flags) { + const split = (value) => String(value).split(",").map((entry) => entry.trim()).filter(Boolean); + if (flags.plugin) return split(flags.plugin).map((entry) => path.resolve(entry)); if (process.env.PLUGIN_DIRS) { - const dirs = process.env.PLUGIN_DIRS.split(",").map((value) => value.trim()).filter(Boolean); - return dirs[0] || null; + return split(process.env.PLUGIN_DIRS).map((entry) => path.resolve(entry)); } const cwdPlugin = path.resolve("plugins"); - if (fs.existsSync(cwdPlugin)) return cwdPlugin; + if (fs.existsSync(cwdPlugin)) return [cwdPlugin]; const bundledPlugin = path.join(pkgRoot, "plugins"); - if (fs.existsSync(bundledPlugin)) return bundledPlugin; - return null; + if (fs.existsSync(bundledPlugin)) return [bundledPlugin]; + return []; } function resolveSystemMessage(flags) { @@ -29,8 +29,8 @@ function resolveSystemMessage(flags) { return flags.system; } - const pluginDir = resolvePluginDir(flags); - if (pluginDir) { + // system.md is resolved across every plugin dir, first match wins. + for (const pluginDir of resolvePluginDirs(flags)) { const systemMd = path.join(pluginDir, "system.md"); if (fs.existsSync(systemMd)) { return fs.readFileSync(systemMd, "utf-8").trim(); @@ -146,12 +146,14 @@ FLAGS process.env.K8S_NAMESPACE = flags.namespace || process.env.K8S_NAMESPACE || "copilot-runtime"; process.env.K8S_POD_LABEL = flags.label || process.env.K8S_POD_LABEL || "app.kubernetes.io/component=worker"; - const pluginDir = resolvePluginDir(flags); - if (pluginDir) { - process.env.PLUGIN_DIRS = pluginDir; + const pluginDirs = resolvePluginDirs(flags); + if (pluginDirs.length > 0) { + process.env.PLUGIN_DIRS = pluginDirs.join(","); } - const branding = resolveTuiBranding(pluginDir); + // Branding is single-sourced from the first plugin dir; later dirs are + // overlays (extra agents/skills) and do not restyle the shell. + const branding = resolveTuiBranding(pluginDirs[0] ?? null); process.env._TUI_TITLE = branding.title; process.env._TUI_SPLASH = branding.splash; diff --git a/packages/app/tui/test/bootstrap-env-plugin-dirs.test.mjs b/packages/app/tui/test/bootstrap-env-plugin-dirs.test.mjs new file mode 100644 index 00000000..eb0180a4 --- /dev/null +++ b/packages/app/tui/test/bootstrap-env-plugin-dirs.test.mjs @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; + +import { resolvePluginDirs } from "../src/bootstrap-env.js"; + +function withEnv(value, fn) { + const prev = process.env.PLUGIN_DIRS; + if (value === undefined) delete process.env.PLUGIN_DIRS; + else process.env.PLUGIN_DIRS = value; + try { + return fn(); + } finally { + if (prev === undefined) delete process.env.PLUGIN_DIRS; + else process.env.PLUGIN_DIRS = prev; + } +} + +test("--plugin with a single dir resolves to one absolute path", () => { + const dirs = withEnv(undefined, () => resolvePluginDirs({ plugin: "./plugin" })); + assert.deepEqual(dirs, [path.resolve("./plugin")]); +}); + +test("--plugin accepts a comma-separated list and preserves order", () => { + const dirs = withEnv(undefined, () => resolvePluginDirs({ plugin: "./plugin,./overlay" })); + assert.deepEqual(dirs, [path.resolve("./plugin"), path.resolve("./overlay")]); +}); + +test("--plugin tolerates whitespace and empty segments", () => { + const dirs = withEnv(undefined, () => resolvePluginDirs({ plugin: " ./plugin , , ./overlay " })); + assert.deepEqual(dirs, [path.resolve("./plugin"), path.resolve("./overlay")]); +}); + +test("PLUGIN_DIRS with multiple entries is no longer truncated to the first", () => { + const dirs = withEnv("./plugin,./overlay", () => resolvePluginDirs({})); + assert.equal(dirs.length, 2, "every configured plugin dir must survive"); + assert.deepEqual(dirs, [path.resolve("./plugin"), path.resolve("./overlay")]); +}); + +test("an explicit --plugin flag still wins over PLUGIN_DIRS", () => { + const dirs = withEnv("./from-env", () => resolvePluginDirs({ plugin: "./from-flag" })); + assert.deepEqual(dirs, [path.resolve("./from-flag")]); +}); + +test("no flag and no env falls back without throwing", () => { + const dirs = withEnv(undefined, () => resolvePluginDirs({})); + assert.ok(Array.isArray(dirs), "always returns an array"); +}); diff --git a/packages/app/web/bin/serve.js b/packages/app/web/bin/serve.js index ce01c194..2f3de941 100755 --- a/packages/app/web/bin/serve.js +++ b/packages/app/web/bin/serve.js @@ -44,7 +44,13 @@ const port = portFlag !== -1 ? parseInt(process.argv[portFlag + 1], 10) : 3001; const pluginFlag = process.argv.indexOf("--plugin"); if (pluginFlag !== -1 && process.argv[pluginFlag + 1]) { - process.env.PLUGIN_DIRS = path.resolve(process.argv[pluginFlag + 1]); + // Accepts a comma-separated list; each entry is resolved independently. + process.env.PLUGIN_DIRS = String(process.argv[pluginFlag + 1]) + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => path.resolve(entry)) + .join(","); } const workersFlag = process.argv.indexOf("--workers"); diff --git a/packages/sdk/src/agent-loader.ts b/packages/sdk/src/agent-loader.ts index 2d3682e6..148802f6 100644 --- a/packages/sdk/src/agent-loader.ts +++ b/packages/sdk/src/agent-loader.ts @@ -91,6 +91,19 @@ export interface AgentConfig { * them. */ inheritDefaultMcpServers?: boolean; + /** + * When `false`, sessions bound to this agent are composed WITHOUT the + * deployment's app default layer — neither the app `default.agent.md` + * prompt nor its `tools:` are inherited. The framework base layer and the + * agent's own prompt/tools still apply. + * + * Defaults to `true`, which is today's behavior: every app agent inherits + * the app default. Use `false` to author an agent on a clean slate without + * removing the app default for everyone else. + * + * Inert for system agents, which already bypass the app default layer. + */ + inheritAppDefaults?: boolean; /** If true, this is a system agent started automatically by workers. */ system?: boolean; /** Deterministic ID slug for system agents (e.g. "sweeper"). Used to derive a fixed session UUID. */ @@ -141,10 +154,10 @@ export interface AgentConfig { * Handles simple `key: value` pairs and YAML list syntax for `tools` and `skills`. */ function parseAgentFrontmatter(content: string): { - meta: { name?: string; description?: string; tools?: string[]; skills?: string[]; mcpServers?: string[]; inheritDefaultMcpServers?: boolean; system?: boolean; id?: string; title?: string; parent?: string; splash?: string; splashMobile?: string; initialPrompt?: string; crawler?: boolean; harvester?: boolean; schemaVersion?: number; version?: string }; + meta: { name?: string; description?: string; tools?: string[]; skills?: string[]; mcpServers?: string[]; inheritDefaultMcpServers?: boolean; inheritAppDefaults?: boolean; system?: boolean; id?: string; title?: string; parent?: string; splash?: string; splashMobile?: string; initialPrompt?: string; crawler?: boolean; harvester?: boolean; schemaVersion?: number; version?: string }; body: string; } { - const meta: { name?: string; description?: string; tools?: string[]; skills?: string[]; mcpServers?: string[]; inheritDefaultMcpServers?: boolean; system?: boolean; id?: string; title?: string; parent?: string; splash?: string; splashMobile?: string; initialPrompt?: string; crawler?: boolean; harvester?: boolean; schemaVersion?: number; version?: string } = {}; + const meta: { name?: string; description?: string; tools?: string[]; skills?: string[]; mcpServers?: string[]; inheritDefaultMcpServers?: boolean; inheritAppDefaults?: boolean; system?: boolean; id?: string; title?: string; parent?: string; splash?: string; splashMobile?: string; initialPrompt?: string; crawler?: boolean; harvester?: boolean; schemaVersion?: number; version?: string } = {}; if (!content.startsWith("---")) { return { meta, body: content }; @@ -262,6 +275,8 @@ function parseAgentFrontmatter(content: string): { meta.mcpServers = []; } else if (key === "inheritDefaultMcpServers") { meta.inheritDefaultMcpServers = value === "true"; + } else if (key === "inheritAppDefaults") { + meta.inheritAppDefaults = value !== "false"; } else if ((key === "splash" || key === "splashMobile" || key === "initialPrompt") && (value === "|" || value === ">")) { // YAML block scalar (| literal, > folded) currentBlockStyle = value; @@ -341,6 +356,7 @@ export function loadAgentFiles(agentsDir: string): AgentConfig[] { skills: meta.skills && meta.skills.length > 0 ? meta.skills : undefined, mcpServers: meta.mcpServers && meta.mcpServers.length > 0 ? meta.mcpServers : undefined, inheritDefaultMcpServers: meta.inheritDefaultMcpServers, + inheritAppDefaults: meta.inheritAppDefaults, system: meta.system, id: meta.id, title: meta.title, diff --git a/packages/sdk/src/session-manager.ts b/packages/sdk/src/session-manager.ts index 561239b5..78fb19ed 100644 --- a/packages/sdk/src/session-manager.ts +++ b/packages/sdk/src/session-manager.ts @@ -88,7 +88,7 @@ export interface WorkerDefaults { /** Backward-compatible alias for older code paths/tests. */ systemMessage?: string; /** Raw prompt lookup for named and system agents bound directly to sessions. */ - agentPromptLookup?: Record; + agentPromptLookup?: Record; /** Descriptor for the PilotSwarm framework base layer (from system default.agent.md). */ frameworkBaseDescriptor?: import("./prompt-layers.js").PromptLayerDescriptor; /** Descriptor for the app default layer (from app default.agent.md or inline config). */ @@ -128,13 +128,30 @@ export interface WorkerDefaults { turnInactivityTimeoutMs?: number; } -function buildEffectivePromptLayers(workerDefaults: WorkerDefaults, config: SerializableSessionConfig): PromptLayerDescriptor[] { +/** + * Whether a session inherits the deployment's app default layer (prompt + tools). + * + * Resolved from the bound agent definition on every turn rather than read from + * the durable session row, so redefining an agent takes effect on the next turn + * and old sessions never carry a stale copy of the decision. + * + * PilotSwarm system agents have always bypassed the app default; an app agent + * opts out explicitly with `inheritAppDefaults: false` in its frontmatter. + * Unbound sessions and agents that say nothing inherit, which is today's behavior. + */ +export function inheritsAppDefaults(workerDefaults: WorkerDefaults, config: SerializableSessionConfig): boolean { const boundAgentName = config.boundAgentName; const layerKind = config.promptLayering?.kind ?? (boundAgentName ? "app-agent" : undefined); - const isPilotSwarmSystemAgent = layerKind === "pilotswarm-system-agent"; + if (layerKind === "pilotswarm-system-agent") return false; + if (!boundAgentName) return true; + return workerDefaults.agentPromptLookup?.[boundAgentName]?.inheritAppDefaults !== false; +} + +function buildEffectivePromptLayers(workerDefaults: WorkerDefaults, config: SerializableSessionConfig): PromptLayerDescriptor[] { + const boundAgentName = config.boundAgentName; const layers: PromptLayerDescriptor[] = []; if (workerDefaults.frameworkBaseDescriptor) layers.push(workerDefaults.frameworkBaseDescriptor); - if (!isPilotSwarmSystemAgent && workerDefaults.appDefaultDescriptor) layers.push(workerDefaults.appDefaultDescriptor); + if (inheritsAppDefaults(workerDefaults, config) && workerDefaults.appDefaultDescriptor) layers.push(workerDefaults.appDefaultDescriptor); if (boundAgentName) { const agentDescriptor = workerDefaults.agentPromptLookup?.[boundAgentName]?.descriptor; if (agentDescriptor) layers.push(agentDescriptor); @@ -936,7 +953,9 @@ export class SessionManager { const epochStart = options?.epochStart === true; const inheritedToolNames = Array.from(new Set([ ...(this.workerDefaults.frameworkBaseToolNames ?? []), - ...(this.workerDefaults.appDefaultToolNames ?? []), + ...(inheritsAppDefaults(this.workerDefaults, serializableConfig) + ? (this.workerDefaults.appDefaultToolNames ?? []) + : []), ...(serializableConfig.toolNames ?? []), ])); const effectiveSerializableConfig: SerializableSessionConfig = inheritedToolNames.length > 0 @@ -1892,22 +1911,19 @@ export class SessionManager { config: SerializableSessionConfig, ): SystemMessageConfig | undefined { const frameworkBase = this.workerDefaults.frameworkBasePrompt ?? this.workerDefaults.systemMessage; - const boundAgentName = config.boundAgentName; - const layerKind = config.promptLayering?.kind ?? (boundAgentName ? "app-agent" : undefined); const knowledgeToolInstructions = this._buildKnowledgeToolInstructionsSection(config.agentIdentity); const lastInstructions = this._buildLastInstructionsSection(sessionId, config); const additionalSections = knowledgeToolInstructions ? { tool_instructions: knowledgeToolInstructions, last_instructions: lastInstructions } : { last_instructions: lastInstructions }; - const isPilotSwarmSystemAgent = layerKind === "pilotswarm-system-agent"; const layerManifest = buildEffectivePromptLayers(this.workerDefaults, config); return composeStructuredSystemMessage({ frameworkBase, - appDefault: isPilotSwarmSystemAgent - ? undefined - : this.workerDefaults.appDefaultPrompt, + appDefault: inheritsAppDefaults(this.workerDefaults, config) + ? this.workerDefaults.appDefaultPrompt + : undefined, additionalSections, layerManifest: layerManifest.length > 0 ? layerManifest : undefined, }); diff --git a/packages/sdk/src/worker.ts b/packages/sdk/src/worker.ts index ef3b6e0f..2a7bf73b 100644 --- a/packages/sdk/src/worker.ts +++ b/packages/sdk/src/worker.ts @@ -153,7 +153,7 @@ export class PilotSwarmWorker { /** Loaded skills by name for agent-declared eager prompt injection. */ private _loadedSkills = new Map(); /** Raw loaded user-creatable agent configs from plugins + direct config. */ - private _rawLoadedAgents: Array<{ name: string; description?: string; prompt: string; tools?: string[] | null; skills?: string[]; mcpServers?: string[]; inheritDefaultMcpServers?: boolean; namespace?: string; crawler?: boolean; harvester?: boolean; promptLayerKind?: "app-agent" | "app-system-agent" | "pilotswarm-system-agent" }> = []; + private _rawLoadedAgents: Array<{ name: string; description?: string; prompt: string; tools?: string[] | null; skills?: string[]; mcpServers?: string[]; inheritDefaultMcpServers?: boolean; inheritAppDefaults?: boolean; namespace?: string; crawler?: boolean; harvester?: boolean; promptLayerKind?: "app-agent" | "app-system-agent" | "pilotswarm-system-agent" }> = []; /** Optional PilotSwarm-bundled user agents, loaded only when session policy opts in. */ private _availableBundledAgents = new Map(); /** Loaded agent configs from plugins + direct config, composed for SDK customAgents. */ @@ -186,7 +186,7 @@ export class PilotSwarmWorker { /** System agents loaded from plugins — started automatically on worker start. */ private _loadedSystemAgents: AgentConfig[] = []; /** Prompt lookup used for direct named/system sessions. */ - private _agentPromptLookup: Record = {}; + private _agentPromptLookup: Record = {}; /** Descriptor for the PilotSwarm framework base layer (from system default.agent.md). */ private _frameworkBaseDescriptor: import("./prompt-layers.js").PromptLayerDescriptor | null = null; /** Descriptor for the app default layer (from app default.agent.md or inline config). */ @@ -903,14 +903,16 @@ export class PilotSwarmWorker { this._resolveAgentMcpServers(); this._loadedAgents = this._rawLoadedAgents.map((agent) => { // Replace the frontmatter's named MCP references with the - // resolved server map (and drop the inherit flag) so the SDK's - // CustomAgentConfig.mcpServers receives real server configs. - const { mcpServers: _refs, inheritDefaultMcpServers: _inherit, ...rest } = agent; + // resolved server map (and drop the inherit flags) so the SDK's + // CustomAgentConfig receives real server configs and no + // PilotSwarm-only layering fields. + const { mcpServers: _refs, inheritDefaultMcpServers: _inherit, inheritAppDefaults, ...rest } = agent; return { ...rest, prompt: composeSystemPrompt({ frameworkBase: this._frameworkBasePrompt, appDefault: this._appDefaultPrompt, + includeAppDefault: inheritAppDefaults !== false, activeAgentPrompt: this._agentPromptLookup[agent.name]?.prompt ?? agent.prompt, }) ?? agent.prompt, ...(this._agentMcpServers[agent.name] ? { mcpServers: this._agentMcpServers[agent.name] } : {}), @@ -1110,6 +1112,7 @@ export class PilotSwarmWorker { prompt: agent.prompt, kind: "app-agent", descriptor, + inheritAppDefaults: agent.inheritAppDefaults, }; this._rawLoadedAgents.push(agent); appAgentKeys.add(key); @@ -1172,6 +1175,7 @@ export class PilotSwarmWorker { prompt: agent.prompt, kind: agent.promptLayerKind, descriptor, + inheritAppDefaults: agent.inheritAppDefaults, }; this._loadedSystemAgents.push(agent); } else { @@ -1180,6 +1184,7 @@ export class PilotSwarmWorker { prompt: agent.prompt, kind: "app-agent", descriptor, + inheritAppDefaults: agent.inheritAppDefaults, }; this._rawLoadedAgents.push(agent); } diff --git a/packages/sdk/test/unit/agent-inherit-app-defaults.test.mjs b/packages/sdk/test/unit/agent-inherit-app-defaults.test.mjs new file mode 100644 index 00000000..8bbb7a48 --- /dev/null +++ b/packages/sdk/test/unit/agent-inherit-app-defaults.test.mjs @@ -0,0 +1,194 @@ +/** + * Per-agent opt-out of the app default layer (pure, no database). + * + * An app deployment's `default.agent.md` is normally force-merged into every + * session — both its prompt (as `guidelines`) and its `tools:`. `inheritAppDefaults: false` + * lets a single agent be authored on a clean slate without changing anything + * for the other agents in the same deployment. + * + * Pins the four layers of the feature: + * 1. agent-loader parses `inheritAppDefaults:` and leaves it undefined when absent. + * 2. The worker carries the flag into the agent prompt lookup and composes + * subagent (CustomAgentConfig) prompts without the app default when it is false. + * 3. The session-side resolver `inheritsAppDefaults()` — which gates the session + * prompt, the app-default tool merge, and the prompt layer manifest — honors it, + * defaults to inheriting, and keeps PilotSwarm system agents opted out. + * 4. The flag is a PilotSwarm-only layering field and never reaches Copilot's + * CustomAgentConfig. + * + * Run: node --test test/unit/agent-inherit-app-defaults.test.mjs + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { loadAgentFiles } from "../../dist/agent-loader.js"; +import { PilotSwarmWorker } from "../../dist/worker.js"; +import { inheritsAppDefaults } from "../../dist/session-manager.js"; +import { buildPromptLayerSections } from "../../dist/prompt-layering.js"; + +function makeTmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function writeAgent(dir, filename, frontmatter, body = "You are a test agent.") { + fs.writeFileSync(path.join(dir, filename), `---\n${frontmatter.trim()}\n---\n\n${body}\n`); +} + +// ─── 1. Frontmatter parsing ───────────────────────────────────── + +test("parses inheritAppDefaults: false", () => { + const dir = makeTmpDir("ps-agent-inherit-"); + writeAgent(dir, "clean.agent.md", ` +version: 1.0.0 +name: clean +inheritAppDefaults: false +`); + const [agent] = loadAgentFiles(dir); + assert.equal(agent.inheritAppDefaults, false); +}); + +test("parses explicit inheritAppDefaults: true", () => { + const dir = makeTmpDir("ps-agent-inherit-"); + writeAgent(dir, "explicit.agent.md", ` +version: 1.0.0 +name: explicit +inheritAppDefaults: true +`); + const [agent] = loadAgentFiles(dir); + assert.equal(agent.inheritAppDefaults, true); +}); + +test("omitting inheritAppDefaults leaves it undefined (inherit by default)", () => { + const dir = makeTmpDir("ps-agent-inherit-"); + writeAgent(dir, "legacy.agent.md", ` +version: 1.0.0 +name: legacy +`); + const [agent] = loadAgentFiles(dir); + assert.equal(agent.inheritAppDefaults, undefined); +}); + +// ─── 2 + 4. Worker-side prompt composition ────────────────────── + +const APP_DEFAULT_MARKER = "APP_DEFAULT_POLICY_MARKER"; +const CLEAN_BODY_MARKER = "CLEAN_AGENT_BODY_MARKER"; + +function buildFixturePlugin() { + const pluginDir = makeTmpDir("ps-inherit-plugin-"); + const agentsDir = path.join(pluginDir, "agents"); + fs.mkdirSync(agentsDir); + writeAgent(agentsDir, "default.agent.md", ` +version: 1.0.0 +name: default +tools: + - app_default_tool +`, APP_DEFAULT_MARKER); + writeAgent(agentsDir, "clean.agent.md", ` +version: 1.0.0 +name: clean +inheritAppDefaults: false +`, CLEAN_BODY_MARKER); + writeAgent(agentsDir, "legacy.agent.md", ` +version: 1.0.0 +name: legacy +`, "LEGACY_AGENT_BODY_MARKER"); + return pluginDir; +} + +function buildWorker(pluginDirs) { + const stateDir = makeTmpDir("ps-inherit-state-"); + return new PilotSwarmWorker({ + sessionStateDir: path.join(stateDir, "session-state"), + pluginDirs: Array.isArray(pluginDirs) ? pluginDirs : [pluginDirs], + workerNodeId: "test-agent-inherit-app-defaults", + }); +} + +test("opted-out agent's composed prompt omits the app default", () => { + const worker = buildWorker(buildFixturePlugin()); + const clean = worker.loadedAgents.find((a) => a.name === "clean"); + assert.ok(clean, "clean agent should load"); + assert.ok( + clean.prompt.includes(CLEAN_BODY_MARKER), + "the agent's own instructions must still be present", + ); + assert.ok( + !clean.prompt.includes(APP_DEFAULT_MARKER), + "the app default must not be layered into an opted-out agent", + ); +}); + +test("agents that say nothing still inherit the app default", () => { + const worker = buildWorker(buildFixturePlugin()); + const legacy = worker.loadedAgents.find((a) => a.name === "legacy"); + assert.ok(legacy, "legacy agent should load"); + assert.ok( + legacy.prompt.includes(APP_DEFAULT_MARKER), + "omitting the flag must preserve today's inheriting behavior", + ); +}); + +test("inheritAppDefaults never reaches Copilot's CustomAgentConfig", () => { + const worker = buildWorker(buildFixturePlugin()); + for (const agent of worker.loadedAgents) { + assert.equal( + Object.hasOwn(agent, "inheritAppDefaults"), + false, + `${agent.name} must not carry the PilotSwarm-only layering flag`, + ); + } +}); + +// ─── 3. Session-side resolution ───────────────────────────────── + +const workerDefaults = { + agentPromptLookup: { + clean: { prompt: "c", kind: "app-agent", inheritAppDefaults: false }, + explicit: { prompt: "e", kind: "app-agent", inheritAppDefaults: true }, + legacy: { prompt: "l", kind: "app-agent" }, + }, +}; + +test("session resolver honors an opted-out bound agent", () => { + assert.equal(inheritsAppDefaults(workerDefaults, { boundAgentName: "clean" }), false); +}); + +test("session resolver inherits for explicit-true and silent agents", () => { + assert.equal(inheritsAppDefaults(workerDefaults, { boundAgentName: "explicit" }), true); + assert.equal(inheritsAppDefaults(workerDefaults, { boundAgentName: "legacy" }), true); +}); + +test("session resolver inherits for unbound and unknown agents", () => { + assert.equal(inheritsAppDefaults(workerDefaults, {}), true); + assert.equal(inheritsAppDefaults(workerDefaults, { boundAgentName: "nonexistent" }), true); +}); + +test("PilotSwarm system agents stay opted out regardless of the flag", () => { + const config = { + boundAgentName: "explicit", + promptLayering: { kind: "pilotswarm-system-agent" }, + }; + assert.equal(inheritsAppDefaults(workerDefaults, config), false); +}); + +test("prompt layering drops the guidelines section when opted out", () => { + const withDefault = buildPromptLayerSections({ + frameworkBase: "FRAMEWORK", + appDefault: APP_DEFAULT_MARKER, + activeAgentPrompt: CLEAN_BODY_MARKER, + }); + assert.ok(withDefault.guidelines, "sanity: app default normally lands in guidelines"); + + const withoutDefault = buildPromptLayerSections({ + frameworkBase: "FRAMEWORK", + appDefault: APP_DEFAULT_MARKER, + activeAgentPrompt: CLEAN_BODY_MARKER, + includeAppDefault: false, + }); + assert.equal(withoutDefault.guidelines, undefined); + assert.ok(withoutDefault.custom_instructions, "framework base is unaffected"); + assert.ok(withoutDefault.last_instructions, "agent prompt is unaffected"); +});