import { Agent, type AgentMessage, type AgentOptions, type AgentTool, } from "@earendil-works/pi-agent-core"; import type { AgentSession } from "@earendil-works/pi-coding-agent"; const TITLE_TIMEOUT_MS = 90_000; const MAX_TITLE_LENGTH = 80; const TITLE_PROMPT = `Create a concise title for this session based on the conversation above. Requirements: - Match the primary language used by the user. - Describe the user's concrete goal or the outcome, not the act of chatting. - Use 4-12 words for space-separated languages, or 8-24 characters for CJK text when practical. - Do not call any tools. - Return only the title as plain text, with no quotes, label, markdown, or explanation.`; export interface GeneratedSessionTitle { title: string; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number; }; } function createShadowTools(tools: AgentTool[]): AgentTool[] { return tools.map((tool) => ({ ...tool, execute: async () => { throw new Error("Tools cannot be executed while generating a session title"); }, })); } /** * Build a temporary Agent configuration whose provider-facing prefix matches * the source Agent. Tool implementations are replaced without changing their * names, descriptions, or schemas, so a naming run cannot mutate the project. */ export function buildSessionTitleAgentOptions(source: Agent): AgentOptions { const state = source.state; return { initialState: { systemPrompt: state.systemPrompt, model: state.model, thinkingLevel: state.thinkingLevel, tools: createShadowTools(state.tools), messages: state.messages, }, convertToLlm: source.convertToLlm, transformContext: source.transformContext, streamFn: source.streamFunction, getApiKey: source.getApiKey, onPayload: source.onPayload, onResponse: source.onResponse, steeringMode: source.steeringMode, followUpMode: source.followUpMode, sessionId: source.sessionId, thinkingBudgets: source.thinkingBudgets, transport: source.transport, maxRetryDelayMs: source.maxRetryDelayMs, toolExecution: source.toolExecution, }; } /** * A running source session usually ends in the user message currently being * answered. Fold the title request into a copy of that message so the title * request does not send two consecutive user messages to the provider. */ export function appendTitleRequestToTrailingUser(messages: AgentMessage[]): AgentMessage[] { const lastMessage = messages.at(-1); if (!lastMessage || lastMessage.role !== "user") return messages; const content = typeof lastMessage.content === "string" ? `${lastMessage.content}\n\n${TITLE_PROMPT}` : [...lastMessage.content, { type: "text" as const, text: TITLE_PROMPT }]; return [ ...messages.slice(0, -1), { ...lastMessage, content }, ]; } function stripWrappingQuotes(value: string): string { const pairs: Array<[string, string]> = [ ['"', '"'], ["'", "'"], ["`", "`"], ["\u201c", "\u201d"], ["\u300c", "\u300d"], ["\u300e", "\u300f"], ]; for (const [start, end] of pairs) { if (value.startsWith(start) && value.endsWith(end) && value.length > start.length + end.length) { return value.slice(start.length, -end.length).trim(); } } return value; } export function parseGeneratedSessionTitle(raw: string): string { let value = raw.trim(); const fenced = value.match(/^```(?:json|text)?\s*([\s\S]*?)\s*```$/i); if (fenced) value = fenced[1].trim(); if (value.startsWith("{")) { try { const parsed = JSON.parse(value) as { title?: unknown }; if (typeof parsed.title === "string") value = parsed.title.trim(); } catch { // Fall back to plain-text cleanup below. } } value = value.split(/\r?\n/, 1)[0] ?? ""; value = value.replace(/^(?:session\s+title|title|标题)\s*[::-]\s*/i, ""); value = stripWrappingQuotes(value).replace(/\s+/g, " ").trim(); value = value.replace(/[。.!]+$/u, "").trim(); if (!/[\p{L}\p{N}]/u.test(value)) { throw new Error("The model did not return a usable session title"); } const characters = Array.from(value); if (characters.length > MAX_TITLE_LENGTH) { value = characters.slice(0, MAX_TITLE_LENGTH).join("").trim(); } return value; } function getAssistantResult(agent: Agent, historyLength: number): GeneratedSessionTitle { const generatedMessages = agent.state.messages.slice(historyLength); for (let i = generatedMessages.length - 1; i >= 0; i--) { const message = generatedMessages[i]; if (message.role !== "assistant") continue; if (message.stopReason === "error") { throw new Error(message.errorMessage || "The title model request failed"); } const text = message.content .filter((block) => block.type === "text") .map((block) => block.text) .join("\n") .trim(); if (!text) continue; return { title: parseGeneratedSessionTitle(text), ...(message.usage ? { usage: { input: message.usage.input, output: message.usage.output, cacheRead: message.usage.cacheRead, cacheWrite: message.usage.cacheWrite, total: message.usage.totalTokens, }, } : {}), }; } throw new Error("The model did not return a session title"); } export function sanitizeTitleMessages(messages: AgentMessage[]): AgentMessage[] { const sanitized: AgentMessage[] = []; let expectedToolResultIds: Set | undefined; for (let index = 0; index < messages.length; index++) { const message = messages[index]; if (message.role === "assistant") { const followingToolResultIds = new Set(); for (let resultIndex = index + 1; resultIndex < messages.length; resultIndex++) { const resultMessage = messages[resultIndex]; if (resultMessage.role !== "toolResult") break; followingToolResultIds.add(resultMessage.toolCallId); } expectedToolResultIds = new Set(); const content = message.content.filter((block) => { if (block.type !== "toolCall") return true; if (!followingToolResultIds.has(block.id)) return false; expectedToolResultIds!.add(block.id); return true; }); if (content.length > 0) { sanitized.push({ ...message, content }); } continue; } if (message.role === "toolResult") { if (expectedToolResultIds?.delete(message.toolCallId)) { sanitized.push(message); } continue; } expectedToolResultIds = undefined; sanitized.push(message); } return sanitized; } export async function generateSessionTitle(source: AgentSession): Promise { const sourceAgent = source.agent; await sourceAgent.waitForIdle(); const sanitizedMessages = sanitizeTitleMessages(sourceAgent.state.messages); const historyLength = sanitizedMessages.length; if (!sanitizedMessages.some( (message) => message.role === "user" || message.role === "compactionSummary", )) { throw new Error("The session has no user messages to name"); } const options = buildSessionTitleAgentOptions(sourceAgent); options.initialState!.messages = sanitizedMessages; const continuesFromTrailingUser = sanitizedMessages.at(-1)?.role === "user"; if (continuesFromTrailingUser) { options.initialState!.messages = appendTitleRequestToTrailingUser(sanitizedMessages); } const temporaryAgent = new Agent(options); const runPromise = continuesFromTrailingUser ? temporaryAgent.continue() : temporaryAgent.prompt(TITLE_PROMPT); let timeout: ReturnType | undefined; try { await Promise.race([ runPromise, new Promise((_, reject) => { timeout = setTimeout(() => { temporaryAgent.abort(); reject(new Error("Session title generation timed out")); }, TITLE_TIMEOUT_MS); }), ]); } catch (error) { temporaryAgent.abort(); await runPromise.catch(() => {}); throw error; } finally { if (timeout) clearTimeout(timeout); } return getAssistantResult(temporaryAgent, historyLength); }