Skip to content
Open
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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
27 changes: 27 additions & 0 deletions docs/developer/building/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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". |
Expand Down Expand Up @@ -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.
Expand Down
28 changes: 15 additions & 13 deletions packages/app/tui/src/bootstrap-env.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
Expand Down Expand Up @@ -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;

Expand Down
48 changes: 48 additions & 0 deletions packages/app/tui/test/bootstrap-env-plugin-dirs.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
8 changes: 7 additions & 1 deletion packages/app/web/bin/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
20 changes: 18 additions & 2 deletions packages/sdk/src/agent-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 27 additions & 11 deletions packages/sdk/src/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { prompt: string; kind: "app-agent" | "app-system-agent" | "pilotswarm-system-agent"; descriptor?: import("./prompt-layers.js").PromptLayerDescriptor }>;
agentPromptLookup?: Record<string, { prompt: string; kind: "app-agent" | "app-system-agent" | "pilotswarm-system-agent"; descriptor?: import("./prompt-layers.js").PromptLayerDescriptor; inheritAppDefaults?: boolean }>;
/** 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). */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
});
Expand Down
Loading