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:
@@ -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 => {
|
||||
|
||||
@@ -99,6 +99,7 @@ export interface InteractiveModeContext {
|
||||
historyStorage?: HistoryStorage;
|
||||
mcpManager?: MCPManager;
|
||||
lspServers?: LspStartupServerInfo[];
|
||||
titleSystemPrompt?: string;
|
||||
|
||||
// State
|
||||
isInitialized: boolean;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user