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
2 changes: 2 additions & 0 deletions apps/desktop/electron/main/ipc/register.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { join } from "node:path";
import { dialog, type BrowserWindow, type IpcMain, type IpcMainInvokeEvent } from "electron";
import { err, ErrorCodes, IPC, ok, type Result } from "@pi-desktop/shared";
import { builtinSubagentOverridesDir } from "@pi-desktop/agent-runtime";
import type { AgentHostBridge } from "../agent-host-bridge";
import type { AgentSidecar } from "../agent-sidecar";
import type { HostProcess } from "../host-process";
Expand Down Expand Up @@ -441,6 +442,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) {
optionalWorkspaceRoot,
activeUserSubagentDocuments,
disabledBuiltinSubagents,
builtinOverridesDir: builtinSubagentOverridesDir(dataDir),
stripWinLongPrefix,
sendToRenderer,
logger,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/electron/main/ipc/skills-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type SkillsIpcDependencies = {
activeUserSubagentDocuments: (projectPath: string | undefined) => Promise<UserSubagentDocument[]>;
/** Handles whose shipped definition the user turned off (builtin activation). */
disabledBuiltinSubagents: () => Promise<string[]>;
/** App-owned directory of builtin override documents (ADR 0319). */
builtinOverridesDir: string;
stripWinLongPrefix: (path: string) => string;
sendToRenderer: (channel: string, payload?: unknown) => void;
searchSkillMarket: (query: string, sources: { id: string; name: string; url: string }[]) => Promise<SkillMarketSearchResult>;
Expand All @@ -36,6 +38,7 @@ export function registerSkillsIpc({
optionalWorkspaceRoot,
activeUserSubagentDocuments,
disabledBuiltinSubagents,
builtinOverridesDir,
stripWinLongPrefix,
sendToRenderer,
searchSkillMarket,
Expand Down Expand Up @@ -322,6 +325,7 @@ export function registerSkillsIpc({
{
userDocuments: await activeUserSubagentDocuments(projectPath),
disabledBuiltins: disabled,
builtinOverridesDir,
},
);
const off = new Set(disabled);
Expand Down
46 changes: 25 additions & 21 deletions apps/desktop/electron/main/runtime/session-launch.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,6 @@
import { join } from "node:path";
import {
ErrorCodes as SharedErrorCodes,
isActiveInProject,
isCommandShellCatalog,
imageGenerationBindings,
isImageGenerationModel,
normalizeMode,
trustedExtensionAgentKeyFromProviderId,
type CommandShellCatalog,
type McpServerRecord,
type ModelBinding,
type Mode,
type Risk,
type SessionThinkingLevel,
type UserSkillRecord,
type UserSubagentRecord,
} from "@pi-desktop/shared";
import {
builtinSubagentOverridesDir,
capabilitiesFromModelConfig,
clampThinkingLevel,
loadCustomSystemPrompt,
Expand All @@ -25,21 +9,38 @@ import {
modelConfigWithBinding,
optionalProviderHeaders,
resolveSubagentProviders,
visionFromModelConfig,
type UserSubagentDocument,
visionFromModelConfig,
} from "@pi-desktop/agent-runtime";
import {
type CommandShellCatalog,
imageGenerationBindings,
isActiveInProject,
isCommandShellCatalog,
isImageGenerationModel,
type McpServerRecord,
type Mode,
type ModelBinding,
normalizeMode,
type Risk,
type SessionThinkingLevel,
ErrorCodes as SharedErrorCodes,
trustedExtensionAgentKeyFromProviderId,
type UserSkillRecord,
type UserSubagentRecord,
} from "@pi-desktop/shared";
import { builtinSkills } from "../builtin-skills";
import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth";
import type { Logger } from "../logger";
import {
catalogModelConfigFor,
type ModelsDevCatalog,
} from "../models-dev-catalog";
import type { Logger } from "../logger";
import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth";
import type { PluginRuntime } from "../plugin-runtime";
import type { LoadedSkillDocument } from "../skill-document";
import type { UserMcpRuntime } from "../user-mcp";
import type { RuntimeState } from "./context";
import type { RuntimeProvider } from "./provider-catalog";
import type { LoadedSkillDocument } from "../skill-document";

const ErrorCodes = {
...SharedErrorCodes,
Expand Down Expand Up @@ -472,6 +473,9 @@ export function createSessionLaunchRuntime({
userDocuments: await activeUserSubagentDocuments(projectPath),
// A switched-off builtin is dropped from what this prompt may delegate to.
disabledBuiltins: await disabledBuiltinSubagents(),
// Builtin overrides (ADR 0319) are re-read with the other sources, so an
// edit reaches every session — this one included — on its next prompt.
builtinOverridesDir: builtinSubagentOverridesDir(dataDir),
});
const subagentBindings = await resolveSubagentProviders({
definitions: subagentCatalog.definitions,
Expand Down
62 changes: 62 additions & 0 deletions apps/desktop/test/subagent-builtin-overrides.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";

const read = (relativePath) => readFile(new URL(relativePath, import.meta.url), "utf8");

const [loader, sessionLaunch, skillsIpc, register] = await Promise.all([
read("../../../packages/agent-runtime/src/subagent-definitions.ts"),
read("../electron/main/runtime/session-launch.ts"),
read("../electron/main/ipc/skills-ipc.ts"),
read("../electron/main/ipc/register.ts"),
]);

test("the loader owns the override directory constant and option (ADR 0319)", () => {
// One constant, so Electron main and the loader cannot drift apart on the
// directory name.
assert.match(
loader,
/export function builtinSubagentOverridesDir\(dataDir: string\): string \{\s*return join\(dataDir, "subagent-overrides"\);/,
);
assert.match(loader, /builtinOverridesDir\?: string;/);
// Overrides parse as builtin source, so the Settings switch (ADR 0270)
// keeps governing the handle, and only a builtin name is retunable.
assert.match(loader, /loadDirDocuments\(dir, "builtin"\)/);
assert.match(loader, /override matches no builtin/);
assert.match(loader, /new delegates belong in ~\/\.agents\/subagents/);
// The merge feeds overrides ahead of the shipped constants but behind the
// user's registry documents.
assert.match(
loader,
/\.\.\.disk\.definitions,\s*\.\.\.user\.definitions,\s*\.\.\.overrides\.definitions,\s*\.\.\.builtin\.definitions,/,
);
});

test("session launch reads the override directory on every prompt", () => {
assert.match(sessionLaunch, /builtinSubagentOverridesDir,/);
assert.match(
sessionLaunch,
/builtinOverridesDir: builtinSubagentOverridesDir\(dataDir\),/,
);
});

test("the settings catalog shows retuned builtins from the same directory", () => {
assert.match(skillsIpc, /builtinOverridesDir: string;/);
assert.match(skillsIpc, / builtinOverridesDir,\n stripWinLongPrefix,/);
const start = skillsIpc.indexOf("IPC.invoke.subagentCatalog");
const end = skillsIpc.indexOf("IPC.invoke.subagentCreate", start);
const handler = skillsIpc.slice(start, end);
assert.ok(start >= 0 && end > start, "subagent catalog handler should exist");
assert.match(handler, /disabledBuiltins: disabled,\s*builtinOverridesDir,/);
});

test("IPC registration wires the data dir into the skills channels", () => {
assert.match(
register,
/import \{ builtinSubagentOverridesDir \} from "@pi-desktop\/agent-runtime";/,
);
assert.match(
register,
/builtinOverridesDir: builtinSubagentOverridesDir\(dataDir\),/,
);
});
7 changes: 5 additions & 2 deletions docs/adr/0062-bounded-subagents-behind-a-task-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
- Status: Accepted for implementation (definition roots amended by ADR 0112;
timeout policy amended by ADR 0119; delegation presentation amended by D265;
opt-in parent-tool inherit amended by ADR 0246; resumable delegations amended
by ADR 0279; report spillover amended by RFC #1196)
by ADR 0279; report spillover amended by RFC #1196; builtin retune source
amended by ADR 0319)
- Date: 2026-08-06
- Deciders: PI-Desktop core
- Related: D201, ADR 0041 (persistence outbox), ADR 0048 (lazy per-turn tool
Expand Down Expand Up @@ -42,7 +43,9 @@ delegate's system prompt, mirroring prompt templates (D123):

PI-Desktop ships three builtins inline in `agent-runtime` (`explorer`,
`code-reviewer`, `test-runner`). User documents under `~/.agents/subagents`
are combined with the builtins by name. There is no project-level subagent
are combined with the builtins by name. An app-owned override directory
(`<data>/subagent-overrides`, ADR 0319) retunes a shipped builtin by name
without adding a delegate or a second Settings row. There is no project-level subagent
capability source; the global user catalog is the only user-managed layer.
The catalog is re-read
on every session launch, capped at `MAX_SUBAGENT_DEFINITIONS` (16), and a
Expand Down
83 changes: 83 additions & 0 deletions docs/adr/0319-builtin-subagent-override-documents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# ADR 0319: Builtin Subagents Can Be Retuned By Override Documents

- Status: Implemented candidate
- Date: 2026-10-04
- Related: D202, ADR 0062, ADR 0063, ADR 0270

## Context

Settings > Agent > Subagents lists the five shipped builtins
(`explorer`, `code-reviewer`, `test-runner`, `fixer`, `ui-designer`) next to the
user's own `~/.agents/subagents/*.md` documents. ADR 0270 gave every builtin an
enablement switch, but the switch is binary: a user who wanted the `fixer`
delegate with a stricter write policy, or `test-runner` pinned to a cheaper
model, had no supported way to get it.

The obvious workaround — copying the builtin's Markdown into
`~/.agents/subagents` and editing the copy — did not work either: user
documents outrank builtins by the merge order in `loadSubagentDefinitions`, but
the copy keeps the builtin's `name` only if the user reproduces it exactly,
and Settings then renders two rows that both claim the same handle, with the
enablement switches now ambiguous (the user document's switch governs the
user row, ADR 0063 §2, while the builtin row keeps its own).

We needed a first-class place where a shipped delegate can be retuned without
duplicating its row or fighting the activation model.

## Decision

1. **Overrides live in an app-owned directory inside the installation data
dir.** `builtinSubagentOverridesDir(dataDir)` returns
`<data>/subagent-overrides`; the directory does not exist by default.
Documents are Markdown with the same frontmatter contract as user
documents. The directory is read on every session launch and on every
`subagent/catalog` request, alongside the other definition sources, so an
edit reaches every session — open ones included — on its next prompt.
2. **An override retunes a builtin by name; it never adds a delegate.**
`loadBuiltinOverrides` keeps a document only when its `name` matches a
shipped builtin. A name no builtin uses is a load diagnostic (visible in
the catalog's diagnostics surface), not a new delegate: new delegates
belong in `~/.agents/subagents`.
3. **Overrides parse as `builtin` source.** They reach the merge as
`source: "builtin"`, so:
- the ADR 0270 Settings switch keeps governing the handle — switching a
builtin off also switches off its retuned definition, and the Settings
row shows the retuned document, not a second row;
- user documents still outrank overrides: a user document with the same
name wins the handle over both the override and the shipped definition.
4. **Merge order is user documents, overrides, builtins.** The existing
`mergeSubagentDefinitions` first-wins rule applies unchanged; overrides sit
between the user registry and the shipped constants.
5. **Electron main wires the directory, the loader stays directory-agnostic.**
`createSessionLaunchRuntime` and the `subagent/catalog` IPC both pass
`builtinOverridesDir: builtinSubagentOverridesDir(dataDir)`; the loader
accepts it as an option and never computes it itself. The override
directory therefore travels with the app data dir, including test
harnesses that point `dataDir` at a temp directory.

## Consequences

- Retuning a shipped delegate is now an edit-and-save operation in an
app-owned directory; no settings UI is required to ship the capability.
- The five shipped definitions remain the only handles a session may offer
unless the user adds documents; the override directory cannot grow the
catalog.
- A stale override (name no longer shipped) surfaces as a diagnostic rather
than disappearing silently; the user sees why the retune stopped applying.
- Disabled-builtin state (ADR 0270) is untouched: it still keys the handle,
and it governs the retuned definition exactly as it governs the shipped
one.

## Alternatives considered

- **Copy the builtin into `~/.agents/subagents` and edit it.** Rejected: two
rows claim one handle, activation splits across both switches, and deleting
the document silently reverts to the shipped definition with no signal.
- **Settings UI for editing builtins in place.** Rejected for now: it needs
an editor surface, a draft model, and conflict handling with the shipped
constants — cost that a directory of Markdown documents does not carry. The
directory is also scriptable and diffable; a UI can later write the same
documents.
- **A per-builtin settings blob in host-core state.** Rejected: it splits the
definition format across two stores and makes the catalog's
document-scan diagnostics inapplicable.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,4 @@ Each ADR includes:
| image-generation-capability | [Image generation as a configured Agent capability](image-generation-capability.md) | Accepted |
| retained-browser-pages-per-tab | [Retain a host-owned browser page per resource tab](retained-browser-pages-per-tab.md) | Accepted |
| 0318 | [Publish native Linux arm64 artifacts](0318-linux-arm64-release-lane.md) | Accepted (D638) |
| 0319 | [Builtin subagents can be retuned by override documents](0319-builtin-subagent-override-documents.md) | Implemented candidate |
16 changes: 11 additions & 5 deletions docs/spec/03-runtime/02-agent-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -811,13 +811,19 @@ transient and never restored into durable UI messages or transcript records.
The session Agent can hand separable pieces of work to delegates that run in
their own context, in the background, and report back on demand.

**Catalog.** Definitions are Markdown documents from two sources: the five
**Catalog.** Definitions are Markdown documents from three sources: the five
builtins shipped inline in `agent-runtime` (`explorer`, `code-reviewer`,
`test-runner`, `fixer`, `ui-designer`) and the global user documents under
`~/.agents/subagents/*.md`. There is no project-level subagent directory and
`.pi/agents` is not scanned for capabilities. User documents are filtered by
`test-runner`, `fixer`, `ui-designer`), the global user documents under
`~/.agents/subagents/*.md`, and the app-owned builtin override documents under
`<data>/subagent-overrides/*.md` (ADR 0319) — the last of which retunes a
shipped builtin by name and can neither add a delegate nor outrank a user
document; a name no builtin uses is a diagnostic. There is no project-level
subagent directory and `.pi/agents` is not scanned for capabilities. User
documents are filtered by
the app-local enabled state before they reach the loader, and the shipped
builtins are filtered by that same app-local state inside it (ADR 0270).
builtins are filtered by that same app-local state inside it (ADR 0270) —
an override document parses as builtin source, so the ADR 0270 switch governs
the retuned definition and Settings renders one row, not two.
Electron main loads
`subagentProviders` in the sidecar params, so editing a definition takes effect
on the next prompt. The catalog is capped at `MAX_SUBAGENT_DEFINITIONS` (16);
Expand Down
5 changes: 4 additions & 1 deletion docs/spec/04-ux/06-settings-ia.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,10 @@ system while preserving their different data ownership:
user document of the same name shadows that builtin in the Task catalog, so
the Built-in row is omitted while the user row remains. A disabled user
document of the same name leaves the builtin in the catalog (and on the
Built-in list) because Task uses the shipped definition again. Built-in rows
Built-in list) because Task uses the shipped definition again. An override document under `<data>/subagent-overrides`
(ADR 0319) retunes its builtin inside that same Built-in row — the row shows
the retuned definition and the switch governs it; it never adds a second row.
Built-in rows
carry a source badge, **Copy as mine** (opens the create sheet pre-filled from
that definition, with the matching template chip selected), and the same
enablement switch a user row has (D202 activation, ADR 0270): turning one off
Expand Down
34 changes: 34 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -7458,6 +7458,40 @@ must keep splitting are covered by `markdown-blocks.test.mjs`.
- **Milestone**: M6+
- **Status**: Automated by `test:e2e:subagents` (host-core create/read/on-disk/active/loader inherit round-trip) and `test:e2e:subagent-models` (real sidecar/local transport Task spawn, inherited Skill/plugin catalog minus the deny list, and builtin explorer isolation). Unit coverage remains in `packages/shared`, `packages/agent-runtime`, and host-core `user_subagents`; the UI inherit-checkbox journey remains Draft. Required suites: `test:e2e`, `test:e2e:subagents`, `test:e2e:subagent-models`.

#### E2E-1175: Builtin override documents retune shipped delegates without adding rows

- **Preconditions**: A project-bound Agent session; the app data dir contains
`subagent-overrides/test-runner.md` whose frontmatter re-pins the model and
whose body replaces the prompt; a second override document named
`no-such-builtin.md`; Settings > Agent > Subagents reachable.
- **Steps**:
1. Start a prompt. Verify the `Task` catalog offers `test-runner` with the
override's model pin (visible in the delegation's details), while the
other four builtins keep their shipped definitions.
2. Open Settings > Agent > Subagents. Verify `test-runner` renders one row
showing the retuned document, with the ADR 0270 switch still governing it;
no sixth row appears for the override.
3. Switch `test-runner` off in Settings, run another prompt, and verify the
delegate is absent from the catalog.
4. Check the catalog diagnostics surface and verify the
`no-such-builtin.md` override appears as a diagnostic that matches no
builtin, not as a delegate.
5. Edit `test-runner.md` while a session is open, run one more prompt, and
verify the next delegation uses the edited definition (re-read per launch).
- **Expected**: Override documents retune the named builtin only; the override
never adds a delegate or a Settings row, stays under the ADR 0270 switch, and
loses its handle to a same-named user document; an unknown name is a
diagnostic; edits apply on the next prompt.
- **Specs linked**: `03-runtime/02-agent-runtime.md` §5f, ADR 0319, ADR 0270
- **Acceptance**: E (tools & permissions), Quality
- **Milestone**: M6+
- **Status**: Unit coverage in
`packages/agent-runtime/src/subagent-definitions.test.ts` (override merge,
unknown-name diagnostic, user-document precedence, disabled-builtin
interplay) and `apps/desktop/test/subagent-builtin-overrides.test.mjs`
(session-launch wiring reads `<data>/subagent-overrides` per launch).
Desktop journey remains Draft.

#### E2E-145: Tool results read as structured blocks, never JSON

- **Preconditions**: A project-bound Agent session with permissions allowed for
Expand Down
Loading