feat(coding-agent): add configurable title system prompt for sessions

- add discovery of `TITLE_SYSTEM.md` and pass it through interactive startup context
- route custom title prompts to online and local tiny title generators via protocol
- update session-title docs and changelog with override behavior
- add tests for prompt discovery, forwarding, and fallback to bundled title prompts
This commit is contained in:
Adryel Dearo
2026-06-09 14:13:20 -03:00
committed by can1357
parent 96defff9a5
commit ecbc2a3c15
15 changed files with 232 additions and 19 deletions
+14
View File
@@ -243,6 +243,20 @@ Native provider (`id: native`) reads native config from:
- `Settings.init()` loads global `config.yml` + discovered project settings capability items.
- Only capability items with `level === "project"` are merged into project layer.
### Session title prompt override
Create `TITLE_SYSTEM.md` in the same config locations as `SYSTEM.md` / `APPEND_SYSTEM.md`:
```text
# ~/.omp/agent/TITLE_SYSTEM.md
Generate a session name using lowercase `<type>:<primary-objective>`.
```
- Missing `TITLE_SYSTEM.md` keeps the bundled title prompts.
- Discovery uses the same project-then-user config directory pattern as `SYSTEM.md`: project `.omp/TITLE_SYSTEM.md` first, then user `~/.omp/agent/TITLE_SYSTEM.md` and the other supported config bases.
- The override replaces only the automatic session-title generation system prompt; normal `SYSTEM.md` / `APPEND_SYSTEM.md` prompt customization is unaffected.
- The online path still forces the `set_title` tool call. The local tiny-title path keeps the `<title>...</title>` prefill/stop wrapper and uses this file as its system turn.
## Skills subsystem
- `extensibility/skills.ts` loads via `loadCapability(skillCapability.id, { cwd })`.
+13
View File
@@ -124,6 +124,18 @@ The dynamic project/environment footer that remains after `SYSTEM.md` is only bl
There is currently no supported CLI mode for "replace the stable default instructions but keep the generated skills/rules/tool guidance." If you need automatic skills loading, keep the default block and add your customization via `APPEND_SYSTEM.md`. If you fully replace with `SYSTEM.md`, you must hard-code any skill names/instructions you want the model to know about, and those will not track discovery automatically.
### "Customize automatic session titles"
`SYSTEM.md` and `APPEND_SYSTEM.md` do not affect the model call that names a new session. Create the title-specific prompt file instead:
```text
# ~/.omp/agent/TITLE_SYSTEM.md
Generate a session name using lowercase `<type>:<primary-objective>`.
If the message carries no concrete task, output exactly `none`.
```
`TITLE_SYSTEM.md` is discovered with the same project-then-user config-directory pattern as `SYSTEM.md` / `APPEND_SYSTEM.md`. When absent, OMP uses the bundled `title-system.md` / `tiny-title-system.md` prompts. When present, the online title path still forces the `set_title` tool call, and the local tiny-model path keeps the `<title>...</title>` wrapper while using this file as the system turn.
### "Replace everything, including project context" — SDK-only
The normal CLI file/flag path intentionally preserves `defaultPrompt.slice(1)`. Code using `CreateAgentSessionOptions.systemPrompt` directly can return a full replacement array and omit the project footer, but that is not what `.omp/SYSTEM.md`, `~/.omp/agent/SYSTEM.md`, or `--system-prompt` do.
@@ -163,6 +175,7 @@ Net effect for CLI users: put `SYSTEM.md` / `APPEND_SYSTEM.md` directly under `<
| Add an instruction on top of the full default prompt | `APPEND_SYSTEM.md` or `--append-system-prompt` |
| Replace the stable default instructions but keep project/environment context | `SYSTEM.md` or `--system-prompt` |
| Preserve generated skills/rules/tool guidance while customizing | `APPEND_SYSTEM.md`; `SYSTEM.md` replaces that generated block |
| Customize automatic session titles | `TITLE_SYSTEM.md`; chat-turn `SYSTEM.md` / `APPEND_SYSTEM.md` do not affect title generation |
| Use `{{cwd}}` / `{{date}}` / other internals in my file | Not supported. Files are inserted verbatim. |
| Inherit specific sections from `system-prompt.md` | Not supported; use append, or copy what you need into `SYSTEM.md`. |
| Override at a per-repo level | Project `.omp/SYSTEM.md` under the cwd you launch `omp` from |
+4
View File
@@ -133,6 +133,10 @@
- Fixed Windows stdio MCP servers launched through PATH shims such as `codegraph.cmd` so bare commands like `codegraph` resolve via `PATHEXT` before spawn ([#2174](https://github.com/can1357/oh-my-pi/issues/2174)).
- Fixed compiled-binary extensions failing to load `@oh-my-pi/pi-*` packages when `bun --compile` quietly dropped one of the extra entrypoints (observed on macOS arm64 release builds): the legacy-pi compat shim's package-root override branch returned the bunfs path without checking the target was present, so the rewrite emitted a `file://` URL to a missing module and the #1216 fallback (scoped to the throwing `getResolvedSpecifier` path) never ran. Override targets are now validated against the on-disk filesystem at module init, missing entries are dropped, and resolution falls through to canonical lookup so Bun resolves the import from the extension's own `node_modules` ([#2168](https://github.com/can1357/oh-my-pi/issues/2168)).
### Added
- Added `TITLE_SYSTEM.md` discovery so users can override the automatic session-title generation prompt for online and local tiny title models without patching installed prompt files.
## [15.10.8] - 2026-06-09
### Added
+21 -3
View File
@@ -370,6 +370,7 @@ async function runInteractiveMode(
eventBus?: EventBus,
initialMessage?: string,
initialImages?: ImageContent[],
titleSystemPrompt?: string,
): Promise<void> {
const mode = new InteractiveMode(
session,
@@ -379,6 +380,7 @@ async function runInteractiveMode(
lspServers,
mcpManager,
eventBus,
titleSystemPrompt,
);
// Cold-launch gate: the full setup wizard (every scene + the overlay and
@@ -718,13 +720,26 @@ function discoverAppendSystemPromptFile(): string | undefined {
return undefined;
}
/** Discover TITLE_SYSTEM.md file for automatic session-title prompt overrides */
export function discoverTitleSystemPromptFile(cwd?: string): string | undefined {
const projectPath = findConfigFile("TITLE_SYSTEM.md", { user: false, cwd });
if (projectPath) {
return projectPath;
}
const globalPath = findConfigFile("TITLE_SYSTEM.md", { user: true, cwd });
if (globalPath) {
return globalPath;
}
return undefined;
}
async function buildSessionOptions(
parsed: Args,
scopedModels: ScopedModel[],
sessionManager: SessionManager | undefined,
modelRegistry: ModelRegistry,
activeSettings: Settings,
): Promise<{ options: CreateAgentSessionOptions }> {
): Promise<{ options: CreateAgentSessionOptions; titleSystemPrompt?: string }> {
const options: CreateAgentSessionOptions = {
cwd: parsed.cwd ?? getProjectDir(),
autoApprove: parsed.autoApprove ?? false,
@@ -735,6 +750,8 @@ async function buildSessionOptions(
const resolvedSystemPrompt = await resolvePromptInput(systemPromptSource, "system prompt");
const appendPromptSource = parsed.appendSystemPrompt ?? discoverAppendSystemPromptFile();
const resolvedAppendPrompt = await resolvePromptInput(appendPromptSource, "append system prompt");
const titleSystemPromptSource = discoverTitleSystemPromptFile();
const titleSystemPrompt = await resolvePromptInput(titleSystemPromptSource, "title system prompt");
if (sessionManager) {
options.sessionManager = sessionManager;
@@ -880,7 +897,7 @@ async function buildSessionOptions(
options.additionalExtensionPaths = [];
}
return { options };
return { options, titleSystemPrompt };
}
interface RunRootCommandDependencies {
@@ -1133,7 +1150,7 @@ export async function runRootCommand(
clearPluginRootsCache: clearPluginRootsAndCaches,
});
const { options: sessionOptions } = await logger.time(
const { options: sessionOptions, titleSystemPrompt } = await logger.time(
"buildSessionOptions",
buildSessionOptions,
parsedArgs,
@@ -1338,6 +1355,7 @@ export async function runRootCommand(
eventBus,
initialMessage,
initialImages,
titleSystemPrompt,
);
} else {
// Branch-only single-shot runner: keep print-mode code out of normal interactive startup.
@@ -467,6 +467,7 @@ export class InputController {
this.ctx.session.sessionId,
this.ctx.session.model,
provider => this.ctx.session.agent.metadataForProvider(provider),
this.ctx.titleSystemPrompt,
)
.then(async title => {
// Re-check: a concurrent attempt for an earlier message may have
@@ -260,6 +260,7 @@ export class InteractiveMode implements InteractiveModeContext {
keybindings: KeybindingsManager;
agent: Agent;
historyStorage?: HistoryStorage;
titleSystemPrompt?: string;
ui: TUI;
chatContainer: TranscriptContainer;
@@ -382,6 +383,7 @@ export class InteractiveMode implements InteractiveModeContext {
lspServers: LspStartupServerInfo[] | undefined = undefined,
mcpManager?: import("../mcp").MCPManager,
eventBus?: EventBus,
titleSystemPrompt?: string,
) {
this.session = session;
this.sessionManager = session.sessionManager;
@@ -394,6 +396,7 @@ export class InteractiveMode implements InteractiveModeContext {
this.lspServers = lspServers;
this.mcpManager = mcpManager;
this.#eventBus = eventBus;
this.titleSystemPrompt = titleSystemPrompt;
if (eventBus) {
this.#eventBusUnsubscribers.push(
eventBus.on(LSP_STARTUP_EVENT_CHANNEL, data => {
+1
View File
@@ -99,6 +99,7 @@ export interface InteractiveModeContext {
historyStorage?: HistoryStorage;
mcpManager?: MCPManager;
lspServers?: LspStartupServerInfo[];
titleSystemPrompt?: string;
// State
isInitialized: boolean;
+28 -5
View File
@@ -39,6 +39,11 @@ export interface TinyTitleDownloadOptions {
onProgress?: (event: TinyTitleProgressEvent) => void;
}
export interface TinyTitleGenerateOptions {
signal?: AbortSignal;
systemPrompt?: string;
}
// Cold-starting the worker subprocess from a compiled binary (decompress + module
// graph load) is slow on contended CI runners — the macos-15-intel release smoke
// blew past 5s while arm64/linux/win passed. The probe only needs to prove the
@@ -46,6 +51,14 @@ export interface TinyTitleDownloadOptions {
// generous bound removes the flake without weakening the check.
const SMOKE_TEST_TIMEOUT_MS = 30_000;
function normalizeTinyTitleGenerateOptions(
options: AbortSignal | TinyTitleGenerateOptions | undefined,
): TinyTitleGenerateOptions {
if (!options) return {};
if ("aborted" in options && "addEventListener" in options) return { signal: options };
return options;
}
/**
* Hidden subcommand on the main CLI that boots the tiny-model worker in the
* spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
@@ -295,9 +308,16 @@ export class TinyTitleClient {
return () => this.#progressListeners.delete(listener);
}
async generate(modelKey: string, message: string, signal?: AbortSignal): Promise<string | null> {
async generate(modelKey: string, message: string, signal?: AbortSignal): Promise<string | null>;
async generate(modelKey: string, message: string, options?: TinyTitleGenerateOptions): Promise<string | null>;
async generate(
modelKey: string,
message: string,
optionsOrSignal?: AbortSignal | TinyTitleGenerateOptions,
): Promise<string | null> {
const options = normalizeTinyTitleGenerateOptions(optionsOrSignal);
if (!isTinyTitleLocalModelKey(modelKey)) return null;
if (signal?.aborted) return null;
if (options.signal?.aborted) return null;
try {
const worker = this.#ensureWorker();
@@ -310,12 +330,15 @@ export class TinyTitleClient {
this.#pending.delete(id);
pending.resolve(null);
};
signal?.addEventListener("abort", abort, { once: true });
options.signal?.addEventListener("abort", abort, { once: true });
try {
worker.send({ type: "generate", id, modelKey, message });
const request: TinyTitleWorkerInbound = options.systemPrompt
? { type: "generate", id, modelKey, message, systemPrompt: options.systemPrompt }
: { type: "generate", id, modelKey, message };
worker.send(request);
return await promise;
} finally {
signal?.removeEventListener("abort", abort);
options.signal?.removeEventListener("abort", abort);
this.#pending.delete(id);
}
} catch (error) {
@@ -29,7 +29,7 @@ export interface TinyTitleProgressEvent {
export type TinyTitleWorkerInbound =
| { type: "ping"; id: string }
| { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string }
| { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string; systemPrompt?: string }
| { type: "complete"; id: string; modelKey: TinyLocalModelKey; prompt: string; maxTokens?: number }
| { type: "download"; id: string; modelKey: TinyLocalModelKey };
+6 -4
View File
@@ -436,9 +436,10 @@ async function loadPipeline(
return loaded;
}
function buildPrompt(generator: TextGenerationPipeline, message: string): string {
function buildPrompt(generator: TextGenerationPipeline, message: string, systemPrompt?: string): string {
const selectedSystemPrompt = systemPrompt?.trim() || TINY_TITLE_SYSTEM_PROMPT;
const chat = [
{ role: "system", content: TINY_TITLE_SYSTEM_PROMPT },
{ role: "system", content: selectedSystemPrompt },
{ role: "user", content: formatTitleUserMessage(message) },
];
const chatTemplateOptions = {
@@ -464,9 +465,10 @@ async function generateTitle(
requestId: string,
modelKey: TinyTitleLocalModelKey,
message: string,
systemPrompt?: string,
): Promise<string | null> {
const generator = await loadPipeline(modelKey, transport, requestId);
const promptText = buildPrompt(generator, message);
const promptText = buildPrompt(generator, message, systemPrompt);
const transformers = await loadTransformers(transport, requestId, modelKey);
const output = (await generator(promptText, {
max_new_tokens: TITLE_MAX_NEW_TOKENS,
@@ -548,7 +550,7 @@ async function handleQueuedRequest(
transport.send({ type: "completion", id: request.id, text });
return;
}
const title = await generateTitle(transport, request.id, request.modelKey, request.message);
const title = await generateTitle(transport, request.id, request.modelKey, request.message, request.systemPrompt);
transport.send({ type: "title", id: request.id, title });
} catch (error) {
transport.send({ type: "error", id: request.id, error: errorText(error) });
@@ -33,7 +33,7 @@ const setTitleTool: Tool = {
title: {
type: "string",
description:
'A concise, sentence-case 3-7 word title for the session (capitalize only the first word and proper nouns), or exactly "none" when the message carries no concrete task yet (greeting, small talk, vague).',
'The generated session title, or exactly "none" when the message carries no concrete task yet.',
},
},
required: ["title"],
@@ -137,6 +137,7 @@ export async function raceFirstNonNull<T>(
* to produce request metadata (e.g. user_id for session attribution). Using a
* resolver instead of a pre-evaluated value ensures the metadata's account_uuid
* reflects the credential actually selected for this request.
* @param customSystemPrompt Optional title-specific system prompt override
*/
export async function generateSessionTitle(
firstMessage: string,
@@ -145,6 +146,7 @@ export async function generateSessionTitle(
sessionId?: string,
currentModel?: Model<Api>,
metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
customSystemPrompt?: string,
): Promise<string | null> {
// Defer titling for greetings / acknowledgements / empty input. The default
// tiny title model can't reliably decline trivial input, so this happens
@@ -155,13 +157,26 @@ export async function generateSessionTitle(
return null;
}
const titleSystemPrompt = customSystemPrompt?.trim() || undefined;
const tinyModel = settings.get("providers.tinyModel");
if (tinyModel === ONLINE_TINY_TITLE_MODEL_KEY) {
return generateTitleOnline(firstMessage, registry, settings, sessionId, currentModel, metadataResolver);
return generateTitleOnline(
firstMessage,
registry,
settings,
sessionId,
currentModel,
metadataResolver,
undefined,
titleSystemPrompt,
);
}
const onlineAbortController = new AbortController();
const localTitle = tinyTitleClient.generate(tinyModel, firstMessage).then(
const localTitlePromise = titleSystemPrompt
? tinyTitleClient.generate(tinyModel, firstMessage, { systemPrompt: titleSystemPrompt })
: tinyTitleClient.generate(tinyModel, firstMessage);
const localTitle = localTitlePromise.then(
title => title || null,
err => {
logger.warn("title-generator: local model error", {
@@ -181,6 +196,7 @@ export async function generateSessionTitle(
currentModel,
metadataResolver,
onlineAbortController.signal,
titleSystemPrompt,
);
return raceFirstNonNull(localTitle, startOnline, TITLE_LOCAL_FALLBACK_DELAY_MS, () => {
@@ -196,6 +212,7 @@ export async function generateTitleOnline(
currentModel?: Model<Api>,
metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
signal?: AbortSignal,
customSystemPrompt?: string,
): Promise<string | null> {
const model = getTitleModel(registry, settings, currentModel);
if (!model) {
@@ -203,6 +220,8 @@ export async function generateTitleOnline(
return null;
}
const titleSystemPrompt = customSystemPrompt?.trim() || undefined;
const systemPrompt = titleSystemPrompt ?? TITLE_SYSTEM_PROMPT;
const userMessage = formatTitleUserMessage(firstMessage);
const modelName = `${model.provider}/${model.id}`;
const modelContext = {
@@ -234,7 +253,7 @@ export async function generateTitleOnline(
const response = await completeSimple(
model,
{
systemPrompt: [TITLE_SYSTEM_PROMPT],
systemPrompt: [systemPrompt],
messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
tools: [setTitleTool],
},
@@ -33,6 +33,35 @@ class FakeTinyWorker {
}
}
describe("tiny title client prompt options", () => {
it("forwards a custom system prompt on local title requests", async () => {
let sent: TinyTitleWorkerInbound | undefined;
const worker = new FakeTinyWorker((message, worker) => {
sent = message;
if (message.type === "generate") {
worker.emit({ type: "title", id: message.id, title: "custom title" });
}
});
const client = new TinyTitleClient(() => worker);
try {
const title = await client.generate("lfm2-350m", "Investigate routing", {
systemPrompt: "Custom title prompt",
});
expect(title).toBe("custom title");
expect(sent).toMatchObject({
type: "generate",
modelKey: "lfm2-350m",
message: "Investigate routing",
systemPrompt: "Custom title prompt",
});
} finally {
await client.terminate();
}
});
});
describe("issue #1940 — local model failures release the worker process", () => {
it("recycles the tiny-model worker after model execution returns an error", async () => {
const first = new FakeTinyWorker((message, worker) => {
@@ -1,7 +1,16 @@
import { describe, expect, it, vi } from "bun:test";
import { submitInteractiveInput } from "@oh-my-pi/pi-coding-agent/main";
import { afterEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { discoverTitleSystemPromptFile, submitInteractiveInput } from "@oh-my-pi/pi-coding-agent/main";
import type { SubmittedUserInput } from "@oh-my-pi/pi-coding-agent/modes/types";
const cleanupDirs: string[] = [];
afterEach(async () => {
await Promise.all(cleanupDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })));
});
function createInput(overrides: Partial<SubmittedUserInput> = {}): SubmittedUserInput {
return {
text: "hello",
@@ -12,6 +21,19 @@ function createInput(overrides: Partial<SubmittedUserInput> = {}): SubmittedUser
};
}
describe("discoverTitleSystemPromptFile", () => {
it("discovers TITLE_SYSTEM.md from the project omp config directory", async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-title-system-"));
cleanupDirs.push(projectDir);
const configDir = path.join(projectDir, ".omp");
await fs.mkdir(configDir, { recursive: true });
const promptPath = path.join(configDir, "TITLE_SYSTEM.md");
await fs.writeFile(promptPath, "custom title prompt");
expect(discoverTitleSystemPromptFile(projectDir)).toBe(promptPath);
});
});
describe("submitInteractiveInput", () => {
it("routes already-started synthetic continue submissions to a hidden developer prompt", async () => {
const mode = {
@@ -243,6 +243,27 @@ describe("tiny title generator routing", () => {
expect(online).not.toHaveBeenCalled();
});
it("passes the resolved TITLE_SYSTEM.md prompt to the local client", async () => {
const model = getModelOrThrow("claude-sonnet-4-5");
const customPrompt = "Generate lowercase colon-delimited session names.";
const local = vi.spyOn(tinyTitleClient, "generate").mockResolvedValue("Local Title");
const online = mockOnlineTitle("Online Title");
const title = await generateSessionTitle(
"Investigate routing",
createRegistry(model),
createSettings(model, "lfm2-350m"),
undefined,
undefined,
undefined,
customPrompt,
);
expect(title).toBe("Local Title");
expect(local).toHaveBeenCalledWith("lfm2-350m", "Investigate routing", { systemPrompt: customPrompt });
expect(online).not.toHaveBeenCalled();
});
it("starts online fallback immediately when local returns null", async () => {
const model = getModelOrThrow("claude-sonnet-4-5");
vi.spyOn(tinyTitleClient, "generate").mockResolvedValue(null);
@@ -71,6 +71,49 @@ describe("title generator", () => {
});
});
it("uses the bundled default prompt when no title prompt file is resolved", async () => {
const model = getModelOrThrow("claude-sonnet-4-5");
const completeSimpleMock = vi.spyOn(ai, "completeSimple").mockResolvedValue({
stopReason: "stop",
content: [{ type: "toolCall", id: "call-title", name: "set_title", arguments: { title: "Default Prompt" } }],
} as never);
await generateSessionTitle("Investigate the resolver", createRegistry(model), createSettings(model));
const request = completeSimpleMock.mock.calls[0]?.[1] as { systemPrompt?: string[] } | undefined;
expect(request?.systemPrompt).toHaveLength(1);
expect(request?.systemPrompt?.[0]).toContain("Generate a concise, sentence-case title");
});
it("uses the resolved TITLE_SYSTEM.md prompt for online title generation", async () => {
const model = getModelOrThrow("claude-sonnet-4-5");
const customPrompt = "Generate lowercase colon-delimited session names.";
const completeSimpleMock = vi.spyOn(ai, "completeSimple").mockResolvedValue({
stopReason: "stop",
content: [{ type: "toolCall", id: "call-title", name: "set_title", arguments: { title: "fix:resolver" } }],
} as never);
await generateSessionTitle(
"Investigate the resolver",
createRegistry(model),
createSettings(model),
undefined,
undefined,
undefined,
customPrompt,
);
const request = completeSimpleMock.mock.calls[0]?.[1] as
| { systemPrompt?: string[]; tools?: Array<{ name?: string }> }
| undefined;
const options = completeSimpleMock.mock.calls[0]?.[2] as
| { toolChoice?: { type?: string; name?: string } }
| undefined;
expect(request?.systemPrompt).toEqual([customPrompt]);
expect(request?.tools?.[0]?.name).toBe("set_title");
expect(options?.toolChoice).toEqual({ type: "tool", name: "set_title" });
});
it("falls back to text content when no set_title tool call is returned", async () => {
const model = getModelOrThrow("claude-sonnet-4-5");
vi.spyOn(ai, "completeSimple").mockResolvedValue({