Skip to content

Apply agentOverrides to user/project custom agents (frontmatter wins per field)#219

Open
jjuraszek wants to merge 1 commit into
nicobailon:mainfrom
jjuraszek:apply-agent-overrides-to-custom-agents
Open

Apply agentOverrides to user/project custom agents (frontmatter wins per field)#219
jjuraszek wants to merge 1 commit into
nicobailon:mainfrom
jjuraszek:apply-agent-overrides-to-custom-agents

Conversation

@jjuraszek
Copy link
Copy Markdown

Apply agentOverrides to user/project custom agents (frontmatter wins per field)

Fixes #218.

What this changes

subagents.agentOverrides.<name> now resolves against any agent with a matching name — user-scope, project-scope, or builtin. Previously it resolved only against builtins; matches against custom agents were silently dropped.

One safety rule for custom agents: frontmatter wins per-field. The override fills only fields the agent's frontmatter left unset. Builtins continue to behave exactly as before because they ship with no frontmatter values, so per-field fill-in collapses to the existing overwrite behavior.

Why

Detailed motivation in the linked issue. The short version: teams ship a custom persona in .pi/agents/<name>.md so the prompt is shared and version-controlled, while individual developers run different Pi distributions (direct Anthropic, Bedrock, ...) whose model identifiers differ. There's no clean way today to keep the persona body shared and let each harness pick its own model — agentOverrides looks like it should do exactly that, but doesn't reach custom agents.

Implementation

Single source file: src/agents/agents.ts. New helpers, sibling to the existing applyBuiltinOverride / applyBuiltinOverrides:

function applyCustomAgentOverride(agent, override, meta): AgentConfig {
    // For each field: if override sets it AND agent's frontmatter didn't, fill it in.
    // If frontmatter set it, keep frontmatter.
    // If override matched but didn't fill anything, return the original agent unchanged
    // (so the `override` annotation stays absent, matching existing test semantics).
}

function applyCustomAgentOverrides(agents, userSettings, projectSettings, ...): AgentConfig[] {
    // Per-agent: project override (if project settings.json exists) wins over user override.
    // Same precedence as builtin path.
}

Wired into both discovery entry points:

-const userAgents = [...userAgentsOld, ...userAgentsNew];
-const projectAgents = scope === "user" ? [] : projectAgentDirs.flatMap(...);
+const userAgents = applyCustomAgentOverrides([...userAgentsOld, ...userAgentsNew], ...);
+const projectAgents = applyCustomAgentOverrides(
+    scope === "user" ? [] : projectAgentDirs.flatMap(...),
+    ...,
+);

disableBuiltins deliberately stays out of the custom path — bulk-disabling builtins shouldn't bulk-disable a user's hand-written agents.

The helper name applyBuiltinOverrides is now slightly misleading since a sibling function does the same for non-builtins. A rename (applyAgentOverridesBuiltin / applyAgentOverridesCustom, or merging the two) is a clean follow-up; kept out of this PR to keep the diff focused on behavior.

Behavior summary

Agent shape agentOverrides[name] entry Before After
Builtin, no override bundled defaults unchanged
Builtin, override matches sets model override applied unchanged
Custom agent, no override entry as-loaded unchanged
Custom agent, override matches, frontmatter has the field sets model override silently dropped unchanged — frontmatter still wins
Custom agent, override matches, frontmatter left field unset sets model override silently dropped, falls through to defaultModel override now applied
Custom agent + disableBuiltins: true custom agent unaffected unchanged

Only the bold row is new behavior.

Tests

6 new unit tests in test/unit/agent-overrides.test.ts:

  • fills in unset fields on a custom project agent from project agentOverrides
  • fills in unset fields on a custom user agent from user agentOverrides
  • applies user agentOverrides to a custom project agent when project settings have no entry
  • prefers project agentOverrides over user agentOverrides on a custom project agent
  • leaves a custom agent untouched when no agentOverrides entry matches its name
  • disableBuiltins does not disable custom agents

The pre-existing test asserting "frontmatter wins when a project agent shadows a builtin" stays green; renamed to frontmatter wins per-field over agentOverrides for a shadowing project agent to spell out the rule that's now generalized.

All 19 tests in agent-overrides.test.ts pass. Pre-existing failures in unrelated test files (fork-context, nested-control, render-helpers, widget-nested-render) are identical before and after this PR — verified with git stash; git checkout main; node --test ... and back.

Test isolation caveat

agent-overrides.test.ts resets HOME per-test but doesn't unset PI_CODING_AGENT_DIR. If a developer has that env var pointing to a real ~/.pi/agent.*/settings.json with agentOverrides entries (typical when running tests from inside a Pi session), several existing tests fail because the suite reads real config instead of the temp fixture. Workaround:

env -u PI_CODING_AGENT_DIR node --test test/unit/agent-overrides.test.ts

All 19 pass under that invocation. Worth tightening beforeEach to also clear PI_CODING_AGENT_DIR, but I left it out of this PR — it's an orthogonal cleanup and would muddy the diff.

CHANGELOG

Entry added under [Unreleased] / Added:

Apply subagents.agentOverrides[name] to user-scope and project-scope custom agents in addition to builtins. Frontmatter wins per-field — overrides only fill fields the agent's frontmatter left unset, so existing custom agents that pin their own model/thinking/etc. are unaffected. Lets shared persona files (.pi/agents/<name>.md) stay version-controlled while per-harness settings.json supplies the local model. disableBuiltins continues to apply only to builtins.

Happy to move it under Changed or Fixed if you prefer — the behavior change is small enough that all three are defensible.

Out of scope

  • Renaming applyBuiltinOverridesapplyAgentOverridesBuiltin (mechanical follow-up).
  • Warning the user when an agentOverrides[name] entry matches zero agents anywhere.
  • Tightening beforeEach in agent-overrides.test.ts to clear PI_CODING_AGENT_DIR.

Each is small and unrelated; happy to send separate PRs.

Verification

Reproduced locally against a real project-scope custom agent (gridstrong/.pi/agents/implementer.md, no model: in frontmatter) with a user-scope agentOverrides.implementer.model = "anthropic/claude-sonnet-4-6".

Before: discoverAgents(...) returns { source: "project", model: undefined, override: undefined }, subagent dispatch runs on claude-opus-4-7 (the default).

After: discoverAgents(...) returns { source: "project", model: "anthropic/claude-sonnet-4-6", override: { scope: "user", ... } }, subagent dispatch runs on claude-sonnet-4-6. Builtin overrides for worker/reviewer continue to apply identically.

Previously, settings.json overrides only resolved against builtin agents.
Matches against custom user-scope or project-scope agents were silently
dropped, even though the setting key (agentOverrides, not
builtinAgentOverrides) and the docs prose imply broader scope.

This change applies overrides to custom agents too, with one safety rule:
frontmatter wins per-field. The override only fills fields the agent's
frontmatter left unset. So existing custom agents that pin their own
model / thinking / etc. keep their pin; agents that left a field unset
now inherit the override that previously sat idle.

Use case: a shared persona file (.pi/agents/<name>.md) under version
control with no model: in frontmatter, paired with a per-harness
~/.pi/agent.<flavor>/settings.json that supplies the local model
identifier. Lets one repo serve developers running different Pi
distributions (e.g. direct Anthropic vs Bedrock) without committing a
provider choice into the repo.

Behavior preserved:
- Builtin overrides: unchanged. Builtins ship with no frontmatter, so
  fill-in semantics collapse to the existing overwrite behavior.
- Custom agent with frontmatter model: unchanged (test renamed to make
  the per-field rule explicit).
- agentOverrides entry matching no agent: unchanged (still a no-op).
- disableBuiltins: still touches only builtins.

Tests: 6 added covering project-fill, user-fill, project-precedence
over user, user-applies-to-project-agent, no-match-no-op, and
disableBuiltins-does-not-touch-custom. All 19 tests in
agent-overrides.test.ts pass. Pre-existing failures in unrelated files
(fork-context, nested-control, render-helpers, widget-nested-render)
are identical before and after this change.

Note: agent-overrides.test.ts does not isolate PI_CODING_AGENT_DIR
in beforeEach, so the suite reads the developer's real config when
that env var is set. Workaround: env -u PI_CODING_AGENT_DIR before
running the tests locally. Worth fixing separately.
@nicobailon nicobailon added bug Something isn't working ready-for-human Needs human implementation or product/design judgment priority: high Important user-facing bug or high-value maintenance item labels May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working priority: high Important user-facing bug or high-value maintenance item ready-for-human Needs human implementation or product/design judgment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

agentOverrides silently ignored for custom user/project agents

2 participants