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:
@@ -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}}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user