feat(coding-agent): surfaced active model in system prompt

- Rendered the active model identifier into the project prompt.
- Rebuilt the cached base prompt on model switch to avoid staleness.
This commit is contained in:
can1357
2026-06-05 23:02:41 +02:00
parent 78700a2c77
commit 9114607ce6
7 changed files with 200 additions and 7 deletions
+1
View File
@@ -4,6 +4,7 @@
### Added
- Added the active model identifier (`provider/id`) to the system prompt's `<workstation>` 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.
@@ -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",
@@ -3,6 +3,7 @@ PROJECT
<workstation>
{{#list environment prefix="- " join="\n"}}{{label}}: {{value}}{{/list}}
{{#if model}}- Model: {{model}}{{/if}}
</workstation>
{{#if contextFiles.length}}
+1
View File
@@ -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) {
@@ -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<string, DiscoverableTool>();
#selectedMCPToolNames = new Set<string>();
@@ -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<void> {
/**
* 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<void> {
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 };
}
@@ -363,6 +363,8 @@ export interface BuildSystemPromptOptions {
workspaceTree?: WorkspaceTree | Promise<WorkspaceTree>;
/** 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,
@@ -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"]);
});
});