From 441085b623da24944f8dbee887877f1af71a5232 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Mon, 10 Aug 2026 01:19:11 +0800 Subject: [PATCH] fix ai tool prompt alignment --- .../src/ai/__tests__/system-prompt.test.ts | 24 +- packages/core/src/ai/agents/reading-agent.ts | 149 ++++++++++-- packages/core/src/ai/system-prompt.ts | 216 +++++++++++++----- packages/core/src/ai/tools/tool-types.ts | 1 + 4 files changed, 322 insertions(+), 68 deletions(-) diff --git a/packages/core/src/ai/__tests__/system-prompt.test.ts b/packages/core/src/ai/__tests__/system-prompt.test.ts index 50ba4e465..4030add93 100644 --- a/packages/core/src/ai/__tests__/system-prompt.test.ts +++ b/packages/core/src/ai/__tests__/system-prompt.test.ts @@ -107,7 +107,29 @@ describe("buildSystemPrompt citations", () => { expect(prompt).toContain("- addCitation"); expect(prompt).not.toContain("- getReadingProgress"); expect(prompt).not.toContain("Get overall reading progress"); - expect(prompt).not.toContain("- ragSearch"); + expect(prompt).not.toContain("ragSearch"); + expect(prompt).not.toContain("ragContext"); + expect(prompt).not.toContain("fallbackSearch"); expect(prompt).not.toContain("Semantic/keyword search across book content"); }); + + it("keeps workflow instructions aligned with library-only tools", () => { + const prompt = buildSystemPrompt({ + book: makeBook(), + semanticContext: null, + enabledSkills: [], + isVectorized: true, + userLanguage: "en", + questionCategory: "library_request", + allowedToolNames: ["listBooks", "getReadingStats"], + }); + + expect(prompt).toContain("- listBooks"); + expect(prompt).toContain("- getReadingStats"); + expect(prompt).toContain("This turn does not expose book-content retrieval tools"); + expect(prompt).not.toContain("ragSearch"); + expect(prompt).not.toContain("fallbackSearch"); + expect(prompt).not.toContain("addCitation"); + expect(prompt).not.toContain("mindmap"); + }); }); diff --git a/packages/core/src/ai/agents/reading-agent.ts b/packages/core/src/ai/agents/reading-agent.ts index 430487e7d..f7d381ae6 100644 --- a/packages/core/src/ai/agents/reading-agent.ts +++ b/packages/core/src/ai/agents/reading-agent.ts @@ -29,6 +29,32 @@ const CHAPTER_TASK_RECURSION_LIMIT = 24; const DEFAULT_TOOL_TIMEOUT_MS = 45_000; const TOOL_EXECUTION_LIMIT = 12; const REPEATED_TOOL_CALL_LIMIT = 2; +const TOOL_TIMEOUT_MS_BY_NAME: Record = { + getSelection: 5_000, + getCurrentChapter: 5_000, + getReadingProgress: 5_000, + getSurroundingContext: 8_000, + getRecentHighlights: 8_000, + getAnnotations: 8_000, + addCitation: 20_000, + ragSearch: 30_000, + ragToc: 20_000, + ragContext: 30_000, + summarize: 35_000, + extractEntities: 35_000, + analyzeArguments: 35_000, + findQuotes: 35_000, + compareSections: 35_000, + fallbackSearch: 60_000, + fallbackToc: 45_000, + fallbackChapterContext: 60_000, + classifyBooks: 60_000, + tagBooks: 30_000, + manageBookTags: 30_000, + updateBookMetadata: 30_000, + manageBookGroups: 30_000, + mindmap: 10_000, +}; const CHAPTER_LOOKUP_STOP_TOOL_NAMES = new Set([ "resolveChapterReference", @@ -553,13 +579,36 @@ function buildZodSchema( switch (param.type) { case "number": - fieldSchema = z.number().describe(param.description); + fieldSchema = z + .preprocess((value) => { + if (typeof value !== "string" || !value.trim()) return value; + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : value; + }, z.number()) + .describe(param.description); break; case "boolean": - fieldSchema = z.boolean().describe(param.description); + fieldSchema = z + .preprocess((value) => { + if (typeof value !== "string") return value; + const normalized = value.trim().toLowerCase(); + if (normalized === "true") return true; + if (normalized === "false") return false; + return value; + }, z.boolean()) + .describe(param.description); break; default: - fieldSchema = z.string().describe(param.description); + fieldSchema = /json/i.test(param.description) + ? z + .preprocess((value) => { + if (Array.isArray(value) || (value && typeof value === "object")) { + return JSON.stringify(value); + } + return value; + }, z.string()) + .describe(param.description) + : z.string().describe(param.description); break; } @@ -592,16 +641,68 @@ function withToolTimeout(promise: Promise, timeoutMs: number, toolName: st }); } +function getToolTimeoutMs(tool: ToolDefinition, defaultTimeoutMs: number): number { + return tool.timeoutMs ?? TOOL_TIMEOUT_MS_BY_NAME[tool.name] ?? defaultTimeoutMs; +} + +function compactToolLogValue(value: unknown, maxLength = 240): unknown { + if (typeof value === "string") { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength)}...(${value.length} chars)`; + } + if (Array.isArray(value)) { + return value.slice(0, 8).map((item) => compactToolLogValue(item, 120)); + } + if (!value || typeof value !== "object") return value; + + const result: Record = {}; + for (const [key, childValue] of Object.entries(value as Record).slice(0, 12)) { + result[key] = compactToolLogValue(childValue, 120); + } + return result; +} + async function executeTool( tool: ToolDefinition, args: Record, timeoutMs: number, ): Promise { + const startedAt = Date.now(); + console.log( + "[ReadingAgent][tool-start]", + JSON.stringify({ + name: tool.name, + timeoutMs, + args: compactToolLogValue(args), + }), + ); try { - return await withToolTimeout(Promise.resolve(tool.execute(args)), timeoutMs, tool.name); + const result = await withToolTimeout(Promise.resolve(tool.execute(args)), timeoutMs, tool.name); + console.log( + "[ReadingAgent][tool-end]", + JSON.stringify({ + name: tool.name, + durationMs: Date.now() - startedAt, + ok: true, + result: compactToolLogValue(result), + }), + ); + return result; } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const payload = { + name: tool.name, + durationMs: Date.now() - startedAt, + ok: false, + error: message, + }; + if (/timed out/i.test(message)) { + console.warn("[ReadingAgent][tool-timeout]", JSON.stringify(payload)); + } else { + console.warn("[ReadingAgent][tool-error]", JSON.stringify(payload)); + } return { - error: error instanceof Error ? error.message : String(error), + error: message, }; } } @@ -947,7 +1048,7 @@ export async function* streamReadingAgent( return JSON.stringify(cachedResult); } - const result = await executeTool(tool, toolInput, toolTimeoutMs); + const result = await executeTool(tool, toolInput, getToolTimeoutMs(tool, toolTimeoutMs)); if (exactCacheKey) { toolResultCache.set(exactCacheKey, result); } @@ -991,8 +1092,8 @@ export async function* streamReadingAgent( ); // Track tool calls already emitted (from streaming chunks or on_chat_model_end) - // so we can deduplicate against on_tool_start events. - let pendingEarlyToolCalls = 0; + // so we can deduplicate against on_tool_start events without hiding unrelated calls. + const pendingEarlyToolStartNames: string[] = []; // Accumulate tool_call_chunks from streaming to emit tool_call as early as possible. // Key: chunk index, Value: { name accumulated so far, args accumulated so far } @@ -1100,7 +1201,7 @@ export async function* streamReadingAgent( // Emit as soon as we have a tool name (don't wait for full args) if (entry.name && !entry.emitted) { entry.emitted = true; - pendingEarlyToolCalls++; + pendingEarlyToolStartNames.push(entry.name); yield { type: "tool_call" as const, name: entry.name, @@ -1132,25 +1233,38 @@ export async function* streamReadingAgent( if (output) { if (Array.isArray(toolCalls)) { + const alreadyEmittedNames = [...pendingEarlyToolStartNames]; for (const tc of toolCalls) { + const toolName = + typeof tc?.name === "string" + ? tc.name + : typeof tc?.function?.name === "string" + ? tc.function.name + : ""; + if (!toolName) continue; + // Check if already emitted from streaming chunks - if (pendingEarlyToolCalls > 0) { - // Already emitted — skip but don't decrement yet (that's for on_tool_start) + const alreadyEmittedIndex = alreadyEmittedNames.findIndex( + (name) => name === toolName, + ); + if (alreadyEmittedIndex >= 0) { + alreadyEmittedNames.splice(alreadyEmittedIndex, 1); continue; } let args: Record; + const rawArgs = tc.args ?? tc.function?.arguments; try { - args = (typeof tc.args === "string" ? JSON.parse(tc.args) : tc.args) as Record< + args = (typeof rawArgs === "string" ? JSON.parse(rawArgs) : rawArgs) as Record< string, unknown >; } catch { args = {}; } - pendingEarlyToolCalls++; + pendingEarlyToolStartNames.push(toolName); yield { type: "tool_call" as const, - name: tc.name, + name: toolName, args, }; } @@ -1161,8 +1275,11 @@ export async function* streamReadingAgent( // Tool call started — skip if already emitted earlier if (event.event === "on_tool_start") { pendingToolCallNames.push(event.name); - if (pendingEarlyToolCalls > 0) { - pendingEarlyToolCalls--; + const pendingEarlyIndex = pendingEarlyToolStartNames.findIndex( + (name) => name === event.name, + ); + if (pendingEarlyIndex >= 0) { + pendingEarlyToolStartNames.splice(pendingEarlyIndex, 1); } else { // Fallback: emit if not already emitted (e.g. non-OpenAI model) yield { diff --git a/packages/core/src/ai/system-prompt.ts b/packages/core/src/ai/system-prompt.ts index fe89fccb8..4be515bdd 100644 --- a/packages/core/src/ai/system-prompt.ts +++ b/packages/core/src/ai/system-prompt.ts @@ -49,13 +49,14 @@ export function buildSystemPrompt(ctx: PromptContext): string { !!(ctx.book?.id || ctx.bookId), ctx.allowedToolNames, ), - buildWorkflowSection(ctx.isVectorized, !!(ctx.book?.id || ctx.bookId)), + buildWorkflowSection(ctx.isVectorized, !!(ctx.book?.id || ctx.bookId), ctx.allowedToolNames), buildConstraintsSection( ctx.userLanguage, ctx.isVectorized, ctx.spoilerFree, ctx.book, ctx.semanticContext, + ctx.allowedToolNames, ), ]; @@ -291,9 +292,12 @@ function buildToolsSection( "- **getAnnotations**: Get user's highlights and notes (params: type)", ); if (isVectorized && canUse("addCitation")) { + const citationSourceHint = canUse("ragSearch") + ? "ragSearch/tool results" + : "available tool results"; pushTool( "addCitation", - "- **addCitation**: CRITICAL - Register a citation with CFI for precise navigation. You MUST extract the 'cfi' field from ragSearch/tool results and pass it here. The citationIndex param determines which [N] marker it maps to (params: citationIndex [REQUIRED - the number N for [N]], chapterTitle, chapterIndex, cfi [REQUIRED from tool results], quotedText, reasoning)", + `- **addCitation**: CRITICAL - Register a citation with CFI for precise navigation. You MUST extract the 'cfi' field from ${citationSourceHint} and pass it here. The citationIndex param determines which [N] marker it maps to (params: citationIndex [REQUIRED - the number N for [N]], chapterTitle, chapterIndex, cfi [REQUIRED from tool results], quotedText, reasoning)`, ); } else if (canUse("addCitation")) { pushTool( @@ -317,7 +321,35 @@ function buildToolsSection( return `## Available Tools\n\n${tools.join("\n")}`; } -function buildWorkflowSection(isVectorized: boolean, hasBookContext: boolean): string { +function buildWorkflowSection( + isVectorized: boolean, + hasBookContext: boolean, + allowedToolNames?: string[], +): string { + const allowed = allowedToolNames ? new Set(allowedToolNames) : null; + const canUse = (name: string) => !allowed || allowed.has(name); + const anyCanUse = (names: string[]) => names.some(canUse); + const analysisTools = [ + "summarize", + "extractEntities", + "analyzeArguments", + "findQuotes", + "compareSections", + ].filter(canUse); + const contentToolNames = [ + "getSelection", + "getCurrentChapter", + "getReadingProgress", + "getSurroundingContext", + "resolveChapterReference", + "ragSearch", + "ragToc", + "ragContext", + "fallbackSearch", + "fallbackToc", + "fallbackChapterContext", + ...analysisTools, + ]; const steps: string[] = [ "## Core Workflow", "", @@ -332,38 +364,80 @@ function buildWorkflowSection(isVectorized: boolean, hasBookContext: boolean): s return steps.join("\n"); } - if (isVectorized) { + if (!anyCanUse(contentToolNames)) { steps.push( - " - **resolveChapterReference**: first step for user-mentioned chapter numbers/titles; do not convert human chapter numbers to chapterIndex yourself", + "This turn does not expose book-content retrieval tools. Use only the tools listed in Turn-Available Tools; do not call or plan with retrieval/citation tools that are not listed.", ); + return steps.join("\n"); + } + + if (isVectorized) { + if (canUse("resolveChapterReference")) { + steps.push( + " - **resolveChapterReference**: first step for user-mentioned chapter numbers/titles; do not convert human chapter numbers to chapterIndex yourself", + ); + } + if (canUse("ragSearch")) { + steps.push( + " - **ragSearch**: primary path for indexed book-content questions by topic/keyword", + ); + } + if (canUse("ragToc")) steps.push(" - **ragToc**: for compact/paginated structure browsing"); + if (analysisTools.length > 0) { + steps.push(` - **${analysisTools.join("/")}**: for indexed content analysis`); + } + } else { + if (canUse("resolveChapterReference")) { + steps.push( + " - **resolveChapterReference**: first step for user-mentioned chapter numbers/titles; do not convert human chapter numbers to chapterIndex yourself", + ); + } + if (canUse("fallbackSearch")) { + steps.push( + " - **fallbackSearch**: for keyword exploration when the book is not vectorized", + ); + } + if (canUse("fallbackToc")) { + steps.push(" - **fallbackToc**: for compact/paginated structure browsing without an index"); + } + if (canUse("fallbackChapterContext")) { + steps.push( + " - **fallbackChapterContext**: for reading a specific chapter without an index", + ); + } + } + + if (canUse("getSurroundingContext")) { + steps.push(" - **getSurroundingContext**: for current page content"); + } + + if (canUse("addCitation")) { + steps.push("3. **Register citations before answering** — If your answer uses book content:"); + steps.push(" - Call **addCitation** before writing the final response body"); steps.push( - " - **ragSearch**: primary path for indexed book-content questions by topic/keyword", + " - Wait for addCitation to return successfully before using the matching [N] marker", ); - steps.push(" - **ragToc**: for compact/paginated structure browsing"); + steps.push(" - This rule applies to BOTH indexed books and non-indexed fallback content"); steps.push( - " - **summarize/extractEntities/analyzeArguments/findQuotes**: for indexed content analysis", + "4. **Synthesize and answer** — Only after citation registration, write your answer", ); } else { + steps.push("3. **Use plain source references** — If your answer uses book content:"); steps.push( - " - **resolveChapterReference**: first step for user-mentioned chapter numbers/titles; do not convert human chapter numbers to chapterIndex yourself", + " - The citation tool is not available this turn; do not use clickable [N] citation markers. Cite plainly with chapter/title/excerpt information from available results.", ); - steps.push(" - **fallbackSearch**: for keyword exploration when the book is not vectorized"); - steps.push(" - **fallbackToc**: for compact/paginated structure browsing without an index"); - steps.push(" - **fallbackChapterContext**: for reading a specific chapter without an index"); + steps.push("4. **Synthesize and answer** — Write your answer using only retrieved content"); } - - steps.push(" - **getSurroundingContext**: for current page content"); - - steps.push("3. **Register citations before answering** — If your answer uses book content:"); - steps.push(" - Call **addCitation** before writing the final response body"); - steps.push( - " - Wait for addCitation to return successfully before using the matching [N] marker", - ); - steps.push(" - This rule applies to BOTH indexed books and non-indexed fallback content"); - steps.push("4. **Synthesize and answer** — Only after citation registration, write your answer"); steps.push(""); - if (isVectorized) { + if (isVectorized && canUse("addCitation")) { + const sourceTools = [ + "ragSearch", + "ragContext", + ...analysisTools, + "getSurroundingContext", + "getCurrentChapter", + ].filter(canUse); steps.push("## CRITICAL: Citation Requirements"); steps.push(""); steps.push("**You MUST cite all factual claims about the book's content.**"); @@ -371,7 +445,7 @@ function buildWorkflowSection(isVectorized: boolean, hasBookContext: boolean): s steps.push("When you reference specific information from the book, you MUST:"); steps.push(""); steps.push("1. **Call addCitation tool** for each source location:"); - steps.push(" - Use chapterTitle, chapterIndex, cfi from ragSearch/tool results"); + steps.push(" - Use chapterTitle, chapterIndex, cfi from available tool results"); steps.push(" - Provide a short quotedText excerpt (max 200 chars)"); steps.push(" - Each citation registers a verifiable source"); steps.push(""); @@ -385,16 +459,18 @@ function buildWorkflowSection(isVectorized: boolean, hasBookContext: boolean): s steps.push(" - Specific facts, data, or statistics from the book"); steps.push(" - Author's arguments, claims, or opinions"); steps.push(" - Plot events, character descriptions, or story details"); - steps.push(" - Any content retrieved via ragSearch, summarize, or content tools"); + steps.push(" - Any content retrieved via available content tools"); steps.push(" - General knowledge not from this book does not need citation"); steps.push( " - Your own analysis does not need citation, but cite the content you're analyzing", ); steps.push(""); steps.push("4. **Citation workflow with CFI:**"); - steps.push( - " - Step 1: Use ragSearch/ragContext or indexed analysis tools to retrieve content", - ); + if (sourceTools.length > 0) { + steps.push(` - Step 1: Use ${sourceTools.join("/")} to retrieve content`); + } else { + steps.push(" - Step 1: Use the available content tool results"); + } steps.push(" - Step 2: Extract chapterTitle, chapterIndex, and **CFI** from tool results"); steps.push( " - Step 3: Call addCitation with the extracted CFI and set citationIndex to the number you will use in [N]", @@ -407,14 +483,14 @@ function buildWorkflowSection(isVectorized: boolean, hasBookContext: boolean): s " - Step 5: Write your final response using [1], [2] to reference citations — each must match the citationIndex you set", ); steps.push( - " - **Example**: ragSearch returns {cfi: 'epubcfi(/6/52!/4...)', ...} → pass this exact CFI to addCitation", + " - **Example**: a tool result returns {cfi: 'epubcfi(/6/52!/4...)', ...} -> pass this exact CFI to addCitation", ); steps.push(""); steps.push( "**This is MANDATORY for academic integrity and user trust. Never skip citations for book content.**", ); steps.push(""); - } else { + } else if (!isVectorized && canUse("addCitation")) { steps.push("## CRITICAL: Fallback Source Requirements"); steps.push(""); steps.push( @@ -441,32 +517,51 @@ function buildWorkflowSection(isVectorized: boolean, hasBookContext: boolean): s } steps.push("### Tool-Calling Discipline (CRITICAL)"); - steps.push( - '- **NEVER call the same tool repeatedly with similar/identical arguments.** If ragSearch("人物") returned results, DO NOT call ragSearch("人物介绍"), ragSearch("人物关系") etc. Use the results you already have.', - ); + const primarySearchTool = canUse("ragSearch") + ? "ragSearch" + : canUse("fallbackSearch") + ? "fallbackSearch" + : undefined; + if (primarySearchTool) { + steps.push( + `- **NEVER call the same tool repeatedly with similar/identical arguments.** If ${primarySearchTool}("人物") returned results, DO NOT call ${primarySearchTool}("人物介绍"), ${primarySearchTool}("人物关系") etc. Use the results you already have.`, + ); + } else { + steps.push( + "- **NEVER call the same available tool repeatedly with similar/identical arguments.** Use the results you already have.", + ); + } steps.push( '- **When a tool returns `content` + `instruction` fields**: the `content` IS your data. Read it, follow the `instruction` to analyze it, then write your answer. Do NOT call more tools to "find more".', ); steps.push( - '- **Each tool call must have a distinct purpose.** Good: ragToc → summarize(chapter 1) → summarize(chapter 2). Bad: ragSearch("主题") → ragSearch("主要主题") → ragSearch("书的主题").', + "- **Each tool call must have a distinct purpose.** Do not use multiple similar queries to fish for the same answer.", ); steps.push( - "- If a content retrieval/analysis tool returns enough information to answer, do NOT call more retrieval tools. If the answer uses that book content, call addCitation first, then answer.", + canUse("addCitation") + ? "- If a content retrieval/analysis tool returns enough information to answer, do NOT call more retrieval tools. If the answer uses that book content, call addCitation first, then answer." + : "- If a content retrieval/analysis tool returns enough information to answer, do NOT call more retrieval tools. If citations are unavailable, answer with plain chapter/source references.", ); steps.push( "- If a tool returns no results or an error, tell the user honestly. Do NOT retry with rephrased queries.", ); - steps.push( - isVectorized - ? "- For indexed books, prefer ragSearch/ragContext for broad content questions. Use current selection/page/chapter context first only when the user explicitly asks about what they are reading right now, then fall back to indexed retrieval if needed." - : "- For non-indexed books, prefer fallbackSearch/fallbackChapterContext for broad content questions. Use current selection/page/chapter context first only when the user explicitly asks about what they are reading right now, then fall back to original-file retrieval if needed.", - ); - steps.push( - "- For a specific chapter request, call resolveChapterReference first. If matched=false, present the candidates or ask for clarification instead of guessing chapterIndex.", - ); - steps.push( - "- For chapter lookup failures, chapter search gets at most three chances in one turn. The first uses the user's original wording, the second may use one simplified query, and the third is the last chance. After that, STOP and tell the user: 未能可靠定位章节,请补充更准确的章节名", - ); + if (isVectorized && (canUse("ragSearch") || canUse("ragContext"))) { + steps.push( + "- For indexed books, prefer available indexed retrieval tools for broad content questions. Use current selection/page/chapter context first only when the user explicitly asks about what they are reading right now, then fall back to indexed retrieval if needed.", + ); + } else if (!isVectorized && (canUse("fallbackSearch") || canUse("fallbackChapterContext"))) { + steps.push( + "- For non-indexed books, prefer available fallback content tools for broad content questions. Use current selection/page/chapter context first only when the user explicitly asks about what they are reading right now, then fall back to original-file retrieval if needed.", + ); + } + if (canUse("resolveChapterReference")) { + steps.push( + "- For a specific chapter request, call resolveChapterReference first. If matched=false, present the candidates or ask for clarification instead of guessing chapterIndex.", + ); + steps.push( + "- For chapter lookup failures, chapter search gets at most three chances in one turn. The first uses the user's original wording, the second may use one simplified query, and the third is the last chance. After that, STOP and tell the user: 未能可靠定位章节,请补充更准确的章节名", + ); + } steps.push( '- For multi-step tasks (e.g. "summarize each chapter"), you MAY call tools many times — but each call must target a DIFFERENT chapter/scope. Never repeat the same query.', ); @@ -485,10 +580,15 @@ function buildConstraintsSection( spoilerFree?: boolean, book?: Book | null, semanticContext?: SemanticContext | null, + allowedToolNames?: string[], ): string { - const citationGuideline = isVectorized - ? "- When citing indexed book content, use [1], [2] format with registered citations via addCitation tool" - : "- When citing non-indexed fallback content, use [1], [2] only after addCitation succeeds with a returned fallback cfi; otherwise use plain chapter names/indices and quoted excerpts"; + const allowed = allowedToolNames ? new Set(allowedToolNames) : null; + const canUse = (name: string) => !allowed || allowed.has(name); + const citationGuideline = canUse("addCitation") + ? isVectorized + ? "- When citing indexed book content, use [1], [2] format with registered citations via addCitation tool" + : "- When citing non-indexed fallback content, use [1], [2] only after addCitation succeeds with a returned fallback cfi; otherwise use plain chapter names/indices and quoted excerpts" + : "- When citing book content, do not use clickable [N] markers unless the citation tool is available; use plain chapter names/indices and quoted excerpts instead"; const lines = [ "## Response Guidelines", `- **IMPORTANT: You MUST respond in ${language || "English"}. This is non-negotiable regardless of the book's language.**`, @@ -512,7 +612,9 @@ function buildConstraintsSection( " B -->|No| D[Action 2]", "```", "", - "Note: Do NOT use Mermaid for mindmaps - use the dedicated `mindmap` tool instead.", + canUse("mindmap") + ? "Note: Do NOT use Mermaid for mindmaps - use the dedicated `mindmap` tool instead." + : "Note: Use Mermaid diagrams only when they help the answer; do not reference unavailable tools.", ]; if (spoilerFree && book) { @@ -531,8 +633,20 @@ function buildConstraintsSection( lines.push( "1. **NEVER reveal** plot events, character fates, twists, deaths, relationships, or any narrative developments that occur after the reader's current position.", ); + const spoilerSensitiveTools = [ + "ragSearch", + "ragContext", + "summarize", + "extractEntities", + "findQuotes", + "compareSections", + "fallbackSearch", + "fallbackChapterContext", + ].filter(canUse); lines.push( - "2. **NEVER use tools** (ragSearch, ragContext, summarize, extractEntities, findQuotes, compareSections) to retrieve or analyze content from chapters beyond the current reading position. If a tool call would target a later chapter, DO NOT make that call.", + spoilerSensitiveTools.length > 0 + ? `2. **NEVER use tools** (${spoilerSensitiveTools.join(", ")}) to retrieve or analyze content from chapters beyond the current reading position. If a tool call would target a later chapter, DO NOT make that call.` + : "2. **NEVER retrieve or analyze** content from chapters beyond the current reading position.", ); lines.push( '3. **If the user explicitly asks about later content** (e.g., "What happens in Chapter 5?", "How does the book end?", "Does X character die?"), **politely decline**: explain that you want to protect their reading experience, and suggest they keep reading.', diff --git a/packages/core/src/ai/tools/tool-types.ts b/packages/core/src/ai/tools/tool-types.ts index 0705c99b7..1212edadb 100644 --- a/packages/core/src/ai/tools/tool-types.ts +++ b/packages/core/src/ai/tools/tool-types.ts @@ -7,6 +7,7 @@ export interface ToolDefinition { name: string; description: string; parameters: Record; + timeoutMs?: number; execute: (args: Record) => Promise; }