diff --git a/README.md b/README.md index 12b41ddf..ea34034c 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,44 @@ cp config.json.example config.json --- +### Model & Thinking Overrides via Settings + +Override the default model or thinking level for any bundled or custom agent via `settings.json` — no need to edit agent `.md` files or pass `model:` on every spawn. + +**Resolution order** (highest → lowest): +1. Explicit `model` or `thinking` tool parameter +2. `settings.json` → `subagents.agentOverrides..model`/`.thinking` +3. Agent definition frontmatter (`model:`, `thinking:`) + +Supported settings locations (project overrides global): + +| Location | Scope | +|----------|-------| +| `~/.pi/agent/settings.json` | Global (all projects) | +| `.pi/settings.json` | Project (current directory) | + +Example: + +```json +{ + "subagents": { + "agentOverrides": { + "scout": { + "model": "llm-gateway/gpt-5.4" + }, + "planner": { + "model": "anthropic/claude-opus-4-6", + "thinking": "high" + } + } + } +} +``` + +Changes take effect immediately on the next spawn — no `/reload` needed. + +--- + ## Spawning Subagents ```typescript diff --git a/package-lock.json b/package-lock.json index 444020c1..6ece943c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-interactive-subagents", - "version": "1.6.0", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-interactive-subagents", - "version": "1.6.0", + "version": "3.6.0", "license": "MIT", "devDependencies": { "@mariozechner/pi-coding-agent": "^0.65.0", diff --git a/pi-extension/subagents/index.ts b/pi-extension/subagents/index.ts index 1320d4e4..f58b3761 100644 --- a/pi-extension/subagents/index.ts +++ b/pi-extension/subagents/index.ts @@ -143,6 +143,17 @@ interface AgentDefaults { disableModelInvocation?: boolean; } +interface AgentOverrideConfig { + model?: string; + thinking?: string; +} + +interface AgentSettings { + subagents?: { + agentOverrides?: Record; + }; +} + type AgentSource = "package" | "global" | "project"; interface AgentDefinition extends AgentDefaults { @@ -368,6 +379,42 @@ function loadAgentDefaults(agentName: string): AgentDefaults | null { return null; } +/** + * Read global and project-local settings.json to resolve agent overrides. + * Project settings override global settings per-agent (shallow merge per agent). + */ +function loadAgentSettings(): AgentSettings { + const globalPath = join(getAgentConfigDir(), "settings.json"); + const projectPath = join(process.cwd(), ".pi", "settings.json"); + + const readSettings = (path: string): AgentSettings => { + if (!existsSync(path)) return {}; + try { + const raw = readFileSync(path, "utf8"); + return JSON.parse(raw) as AgentSettings; + } catch { + return {}; + } + }; + + const globalSettings = readSettings(globalPath); + const projectSettings = readSettings(projectPath); + + if (!globalSettings.subagents?.agentOverrides && !projectSettings.subagents?.agentOverrides) { + return {}; + } + + const mergedOverrides: Record = { + ...globalSettings.subagents?.agentOverrides, + }; + + for (const [name, override] of Object.entries(projectSettings.subagents?.agentOverrides ?? {})) { + mergedOverrides[name] = { ...mergedOverrides[name], ...override }; + } + + return { subagents: { agentOverrides: mergedOverrides } }; +} + function formatElapsed(seconds: number): string { if (seconds < 60) return `${seconds}s`; const m = Math.floor(seconds / 60); @@ -842,6 +889,7 @@ export const __test__ = { getShellReadyDelayMs, renderSubagentWidgetLines, loadAgentDefaults, + loadAgentSettings, discoverAgentDefinitions, resolveEffectiveSessionMode, resolveLaunchBehavior, @@ -881,10 +929,12 @@ async function launchSubagent( const id = Math.random().toString(16).slice(2, 10); const agentDefs = params.agent ? loadAgentDefaults(params.agent) : null; - const effectiveModel = params.model ?? agentDefs?.model; + const settings = loadAgentSettings(); + const configOverride = params.agent ? settings.subagents?.agentOverrides?.[params.agent] : undefined; + const effectiveModel = params.model ?? configOverride?.model ?? agentDefs?.model; const effectiveTools = params.tools ?? agentDefs?.tools; const effectiveSkills = params.skills ?? agentDefs?.skills; - const effectiveThinking = agentDefs?.thinking; + const effectiveThinking = configOverride?.thinking ?? agentDefs?.thinking; const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs); const sessionFile = ctx.sessionManager.getSessionFile(); diff --git a/test/test.ts b/test/test.ts index 2da11959..4b9a4905 100644 --- a/test/test.ts +++ b/test/test.ts @@ -128,6 +128,11 @@ function writeAgentFile( writeFileSync(join(agentsDir, `${name}.md`), `---\n${frontmatter}\n---\n\n${body}\n`); } +function writeSettingsFile(settingsPath: string, data: object) { + mkdirSync(settingsPath, { recursive: true }); + writeFileSync(join(settingsPath, "settings.json"), JSON.stringify(data, null, 2)); +} + async function withIsolatedAgentEnv( fn: (paths: { projectDir: string; @@ -955,6 +960,83 @@ describe("subagent discovery", () => { }); }); + it("loadAgentSettings returns empty when no settings files exist", async () => { + await withIsolatedAgentEnv(async () => { + const settings = testApi.loadAgentSettings(); + assert.deepEqual(settings, {}); + }); + }); + + it("loadAgentSettings reads global settings.json", async () => { + await withIsolatedAgentEnv(async ({ globalDir }) => { + writeSettingsFile(globalDir, { + subagents: { + agentOverrides: { + scout: { model: "llm-gateway/gpt-5.4" }, + }, + }, + }); + + const settings = testApi.loadAgentSettings(); + assert.equal(settings.subagents?.agentOverrides?.scout?.model, "llm-gateway/gpt-5.4"); + }); + }); + + it("loadAgentSettings project overrides global per-agent", async () => { + await withIsolatedAgentEnv(async ({ projectDir, globalDir }) => { + writeSettingsFile(globalDir, { + subagents: { + agentOverrides: { + scout: { model: "llm-gateway/gpt-5.4" }, + worker: { model: "llm-gateway/Kimi-K2.6" }, + }, + }, + }); + writeSettingsFile(join(projectDir, ".pi"), { + subagents: { + agentOverrides: { + scout: { model: "anthropic/claude-sonnet-4-6" }, + }, + }, + }); + + const settings = testApi.loadAgentSettings(); + assert.equal(settings.subagents?.agentOverrides?.scout?.model, "anthropic/claude-sonnet-4-6"); + assert.equal(settings.subagents?.agentOverrides?.worker?.model, "llm-gateway/Kimi-K2.6"); + }); + }); + + it("loadAgentSettings merges model and thinking per-agent from global and project", async () => { + await withIsolatedAgentEnv(async ({ projectDir, globalDir }) => { + writeSettingsFile(globalDir, { + subagents: { + agentOverrides: { + scout: { model: "llm-gateway/gpt-5.4" }, + }, + }, + }); + writeSettingsFile(join(projectDir, ".pi"), { + subagents: { + agentOverrides: { + scout: { thinking: "high" }, + }, + }, + }); + + const settings = testApi.loadAgentSettings(); + assert.equal(settings.subagents?.agentOverrides?.scout?.model, "llm-gateway/gpt-5.4"); + assert.equal(settings.subagents?.agentOverrides?.scout?.thinking, "high"); + }); + }); + + it("loadAgentSettings ignores malformed settings.json", async () => { + await withIsolatedAgentEnv(async ({ globalDir }) => { + writeFileSync(join(globalDir, "settings.json"), "not json"); + const settings = testApi.loadAgentSettings(); + assert.deepEqual(settings, {}); + }); + }); + it("resolves session mode with fork override precedence", () => { assert.equal(testApi.resolveEffectiveSessionMode({ name: "A", task: "T" }, null), "standalone"); assert.equal(