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
+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],
},