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
+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) });