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