diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f2a7518e4..b3db2c89b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added the active model identifier (`provider/id`) to the system prompt's `` block so the agent knows which model it is running as. Gated by the new `includeModelInPrompt` setting (default on); the base prompt is rebuilt on a mid-session model switch so the surfaced identifier stays current. - Added `OLLAMA_HOST` support for implicit local Ollama discovery when `OLLAMA_BASE_URL` is unset, so OMP picks up the same host setting used by Ollama. - Added `OLLAMA_CONTEXT_LENGTH` as a positive-integer context-window override for implicit local Ollama discovery, so users can correct OMP context budgeting without writing per-model overrides. diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 277d70eba..d67232835 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -722,6 +722,16 @@ export const SETTINGS_SCHEMA = { }, }, + includeModelInPrompt: { + type: "boolean", + default: true, + ui: { + tab: "model", + label: "Include Model In Prompt", + description: "Surface the active model identifier in the system prompt so the agent knows which model it is", + }, + }, + // Sampling temperature: { type: "number", diff --git a/packages/coding-agent/src/prompts/system/project-prompt.md b/packages/coding-agent/src/prompts/system/project-prompt.md index 6ec027800..d2bd13d43 100644 --- a/packages/coding-agent/src/prompts/system/project-prompt.md +++ b/packages/coding-agent/src/prompts/system/project-prompt.md @@ -3,6 +3,7 @@ PROJECT {{#list environment prefix="- " join="\n"}}{{label}}: {{value}}{{/list}} +{{#if model}}- Model: {{model}}{{/if}} {{#if contextFiles.length}} diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 7baea380c..d5f807702 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1773,6 +1773,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} secretsEnabled, workspaceTree: workspaceTreePromise, memoryRootEnabled: memoryBackend.id === "local", + model: settings.get("includeModelInPrompt") ? getActiveModelString() : undefined, }); if (options.systemPrompt === undefined) { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 84f78cdce..24039d32b 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -960,6 +960,13 @@ export class AgentSession { * the dominant cause of prompt-cache invalidation in long sessions. */ #lastAppliedToolSignature: string | undefined; + /** + * Model identifier (`provider/id`) currently rendered into `#baseSystemPrompt`. + * The prompt surfaces the active model to the agent, so a model switch must + * trigger a rebuild. Compared against the live model after every model change + * to decide whether the cached prompt is stale. + */ + #promptModelKey: string | undefined; #mcpDiscoveryEnabled = false; #discoverableMCPTools = new Map(); #selectedMCPToolNames = new Set(); @@ -1173,6 +1180,7 @@ export class AgentSession { this.#getMcpServerInstructions = config.getMcpServerInstructions; this.#reloadSshTool = config.reloadSshTool; this.#baseSystemPrompt = this.agent.state.systemPrompt; + this.#promptModelKey = this.#currentPromptModelKey(); this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false; this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry()); this.#selectedMCPToolNames = new Set(config.initialSelectedMCPToolNames ?? []); @@ -3264,9 +3272,21 @@ export class AgentSession { return resolveEditMode(this.#getEditModeSession()); } - async #syncEditToolModeAfterModelChange(previousEditMode: EditMode): Promise { + /** + * Model key (`provider/id`) currently surfaced in the system prompt, or + * undefined when the model is unset or `includeModelInPrompt` is disabled. + */ + #currentPromptModelKey(): string | undefined { + if (!this.settings.get("includeModelInPrompt")) return undefined; + return this.model ? formatModelString(this.model) : undefined; + } + + async #syncAfterModelChange(previousEditMode: EditMode): Promise { const currentEditMode = this.#resolveActiveEditMode(); - if (previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit")) { + const editModeChanged = previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit"); + // The system prompt may surface the active model; a switch makes the cached prompt stale. + const modelChanged = this.#currentPromptModelKey() !== this.#promptModelKey; + if (editModeChanged || modelChanged) { await this.refreshBaseSystemPrompt(); } } @@ -3551,6 +3571,7 @@ export class AgentSession { this.#baseSystemPrompt = built.systemPrompt; this.agent.setSystemPrompt(this.#baseSystemPrompt); this.#lastAppliedToolSignature = signature; + this.#promptModelKey = this.#currentPromptModelKey(); } } if (options?.persistMCPSelection !== false) { @@ -3633,6 +3654,7 @@ export class AgentSession { const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry); this.#baseSystemPrompt = built.systemPrompt; this.agent.setSystemPrompt(this.#baseSystemPrompt); + this.#promptModelKey = this.#currentPromptModelKey(); // Refresh the cached signature so a subsequent `#applyActiveToolsByName` with // the same tool set does not re-rebuild on top of the explicit refresh we // just performed (and conversely, a different set forces a fresh rebuild). @@ -3692,7 +3714,7 @@ export class AgentSession { * closure-captured ones cannot change at runtime regardless of skip behavior. * For everything else, callers must explicitly call `refreshBaseSystemPrompt()` * after side-effecting changes; see e.g. the memory hooks and - * `#syncEditToolModeAfterModelChange`. + * `#syncAfterModelChange`. * * The current calendar date IS covered (appended as a segment) because * `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`). @@ -5284,7 +5306,7 @@ export class AgentSession { // Re-apply thinking for the newly selected model. Prefer the model's // configured defaultLevel; otherwise preserve the current level (or auto). this.#reapplyThinkingLevel(model.thinking?.defaultLevel); - await this.#syncEditToolModeAfterModelChange(previousEditMode); + await this.#syncAfterModelChange(previousEditMode); } /** @@ -5318,7 +5340,7 @@ export class AgentSession { } else { this.#reapplyThinkingLevel(model.thinking?.defaultLevel); } - await this.#syncEditToolModeAfterModelChange(previousEditMode); + await this.#syncAfterModelChange(previousEditMode); } /** @@ -5462,7 +5484,7 @@ export class AgentSession { // Apply the scoped model's configured thinking level, preserving auto. this.setThinkingLevel(this.#autoThinking ? AUTO_THINKING : next.thinkingLevel); - await this.#syncEditToolModeAfterModelChange(previousEditMode); + await this.#syncAfterModelChange(previousEditMode); return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; } @@ -5491,7 +5513,7 @@ export class AgentSession { this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`); // Re-apply the current thinking level (or auto) for the newly selected model this.#reapplyThinkingLevel(); - await this.#syncEditToolModeAfterModelChange(previousEditMode); + await this.#syncAfterModelChange(previousEditMode); return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false }; } diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index a8b525b06..c0d935a51 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -363,6 +363,8 @@ export interface BuildSystemPromptOptions { workspaceTree?: WorkspaceTree | Promise; /** Whether the local memory://root summary is active. */ memoryRootEnabled?: boolean; + /** Active model identifier (e.g. "anthropic/claude-opus-4") surfaced to the agent. */ + model?: string; } /** Result of building provider-facing system prompt messages. */ @@ -396,6 +398,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): secretsEnabled = false, workspaceTree: providedWorkspaceTree, memoryRootEnabled = false, + model, } = options; const resolvedCwd = cwd ?? getProjectDir(); @@ -566,6 +569,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): date, dateTime, cwd: promptCwd, + model: model ?? "", intentTracing: !!intentField, intentField: intentField ?? "", mcpDiscoveryMode, diff --git a/packages/coding-agent/test/system-prompt-model.test.ts b/packages/coding-agent/test/system-prompt-model.test.ts new file mode 100644 index 000000000..92bc6c907 --- /dev/null +++ b/packages/coding-agent/test/system-prompt-model.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Agent } from "@oh-my-pi/pi-agent-core"; +import type { Model } from "@oh-my-pi/pi-ai"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { buildSystemPrompt } from "@oh-my-pi/pi-coding-agent/system-prompt"; +import { cleanupTempHome } from "./helpers/temp-home-cleanup"; + +const EMPTY_TREE = { + rootPath: "", + rendered: "", + truncated: false, + totalLines: 0, + agentsMdFiles: [], +}; + +describe("system prompt model identifier", () => { + let tempDir = ""; + let tempHomeDir = ""; + let originalHome: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-prompt-model-")); + tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-prompt-model-home-")); + originalHome = process.env.HOME; + process.env.HOME = tempHomeDir; + }); + + afterEach(cleanupTempHome(() => ({ tempDir, tempHomeDir, originalHome }))); + + it("renders the model identifier into the workstation block when provided", async () => { + const { systemPrompt } = await buildSystemPrompt({ + cwd: tempDir, + contextFiles: [], + skills: [], + rules: [], + toolNames: [], + workspaceTree: { ...EMPTY_TREE, rootPath: tempDir }, + model: "anthropic/claude-opus-4", + }); + + expect(systemPrompt.join("\n\n")).toContain("Model: anthropic/claude-opus-4"); + }); + + it("omits the model line when no model is provided", async () => { + const { systemPrompt } = await buildSystemPrompt({ + cwd: tempDir, + contextFiles: [], + skills: [], + rules: [], + toolNames: [], + workspaceTree: { ...EMPTY_TREE, rootPath: tempDir }, + }); + + expect(systemPrompt.join("\n\n")).not.toContain("Model:"); + }); +}); + +describe("AgentSession model-change prompt refresh", () => { + let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; + let tempDir: string; + let session: AgentSession | undefined; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-prompt-model-session-")); + authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); + modelRegistry = new ModelRegistry(authStorage); + }); + + afterEach(async () => { + if (session) { + await session.dispose(); + session = undefined; + } + authStorage.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function pickTwoModels(): [Model, Model] { + const all = modelRegistry.getAll(); + const first = all[0]; + const second = all.find(m => m.provider !== first.provider || m.id !== first.id); + if (!first || !second) throw new Error("Expected at least two distinct models in the registry"); + return [first, second]; + } + + function newSession( + model: Model, + settings: Settings, + rebuild: () => Promise<{ systemPrompt: string[] }>, + ): AgentSession { + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model, systemPrompt: ["initial"], tools: [], messages: [] }, + }); + const created = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + toolRegistry: new Map(), + rebuildSystemPrompt: async () => rebuild(), + }); + return created; + } + + it("rebuilds the prompt with the new model when includeModelInPrompt is enabled", async () => { + const [modelA, modelB] = pickTwoModels(); + authStorage.setRuntimeApiKey(modelA.provider, "key-a"); + authStorage.setRuntimeApiKey(modelB.provider, "key-b"); + + let rebuildCount = 0; + session = newSession(modelA, Settings.isolated({ "compaction.enabled": false }), async () => { + rebuildCount++; + const active = session?.model; + return { systemPrompt: [`model:${active ? `${active.provider}/${active.id}` : ""}`] }; + }); + + await session.setModel(modelB); + expect(rebuildCount).toBe(1); + expect(session.agent.state.systemPrompt).toEqual([`model:${modelB.provider}/${modelB.id}`]); + + // Re-selecting the same model leaves the rendered model unchanged → no rebuild. + await session.setModel(modelB); + expect(rebuildCount).toBe(1); + }); + + it("does not rebuild on model change when includeModelInPrompt is disabled", async () => { + const [modelA, modelB] = pickTwoModels(); + authStorage.setRuntimeApiKey(modelA.provider, "key-a"); + authStorage.setRuntimeApiKey(modelB.provider, "key-b"); + + let rebuildCount = 0; + session = newSession( + modelA, + Settings.isolated({ "compaction.enabled": false, includeModelInPrompt: false }), + async () => { + rebuildCount++; + return { systemPrompt: ["unchanged"] }; + }, + ); + + await session.setModel(modelB); + expect(rebuildCount).toBe(0); + expect(session.agent.state.systemPrompt).toEqual(["initial"]); + }); +});