From 0075b6a922b1dd6cc88598f16a00b7c87effac19 Mon Sep 17 00:00:00 2001 From: Bertan Ari Date: Mon, 27 Jul 2026 15:01:56 -0700 Subject: [PATCH 1/2] Add per-agent opt-out of the app default layer An app's `default.agent.md` is force-merged into every session -- both its prompt and its `tools:`. The only escape hatch was the internal `pilotswarm-system-agent` prompt-layer kind, which app agents cannot reach, so authoring one agent that did not want the app-wide instructions meant weakening the default for every other agent in the deployment. Agents can now declare `inheritAppDefaults: false`. Such an agent is composed from the framework base plus its own prompt and tools only, and it is dropped from the prompt layer manifest. Omitting the field inherits, so every existing agent is unchanged. The flag is resolved from the bound agent definition at prompt-composition time rather than persisted on the session row, matching how agent redefinition is expected to take effect on the next turn -- so no migration is needed and no session can carry a stale copy of the decision. A single `inheritsAppDefaults()` resolver gates all four consumption points (session prompt, subagent prompt, app-default tool merge, layer manifest) so they cannot drift apart. 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. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 22 ++ docs/developer/building/plugins.md | 27 +++ packages/sdk/src/agent-loader.ts | 20 +- packages/sdk/src/session-manager.ts | 38 +++- packages/sdk/src/worker.ts | 15 +- .../unit/agent-inherit-app-defaults.test.mjs | 194 ++++++++++++++++++ 6 files changed, 298 insertions(+), 18 deletions(-) create mode 100644 packages/sdk/test/unit/agent-inherit-app-defaults.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b391334a..e4b27282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # 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. + ## 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/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"); +}); From 59ac77de32c314634de0672bb4b80e31c4bc9922 Mon Sep 17 00:00:00 2001 From: Bertan Ari Date: Tue, 28 Jul 2026 08:31:40 -0700 Subject: [PATCH 2/2] Stop PLUGIN_DIRS collapsing to a single plugin directory PLUGIN_DIRS is documented and parsed as a comma-separated list, and the SDK worker loads every entry (worker.ts Tier 4 iterates config.pluginDirs). The TUI/portal bootstrap did not: resolvePluginDir() returned dirs[0] and then overwrote process.env.PLUGIN_DIRS with that single path, so every dir after the first was discarded before the worker ever saw it. The failure is silent and the blast radius is bad: an overlay dir holding default.agent.md would simply not load, dropping the app default prompt and its force-merged tools with no warning. resolvePluginDirs() now returns the full list, --plugin accepts a comma-separated value, and system.md is resolved across all dirs (first match wins). Branding still reads only the first dir, so single-dir configurations resolve to exactly the same values as before. The same truncation in web/bin/serve.js --plugin handling is fixed too. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 13 +++++ packages/app/tui/src/bootstrap-env.js | 28 ++++++----- .../test/bootstrap-env-plugin-dirs.test.mjs | 48 +++++++++++++++++++ packages/app/web/bin/serve.js | 8 +++- 4 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 packages/app/tui/test/bootstrap-env-plugin-dirs.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b27282..705b3e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,19 @@ 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/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");