diff --git a/src/app/globals.css b/src/app/globals.css
index 0fab70e..42112a3 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -206,6 +206,128 @@
}
@layer utilities {
+ .chat-markdown-content {
+ color: var(--foreground);
+ font-size: 14px;
+ line-height: 1.55;
+ overflow-wrap: break-word;
+ }
+
+ .chat-markdown-content > :first-child {
+ margin-top: 0;
+ }
+
+ .chat-markdown-content > :last-child {
+ margin-bottom: 0;
+ }
+
+ .chat-markdown-content h1,
+ .chat-markdown-content h2,
+ .chat-markdown-content h3,
+ .chat-markdown-content h4,
+ .chat-markdown-content h5,
+ .chat-markdown-content h6 {
+ margin-top: 12px;
+ margin-bottom: 6px;
+ font-weight: 700;
+ line-height: 1.3;
+ }
+
+ .chat-markdown-content h1,
+ .chat-markdown-content h2 {
+ font-size: 1.05em;
+ }
+
+ .chat-markdown-content h3,
+ .chat-markdown-content h4,
+ .chat-markdown-content h5,
+ .chat-markdown-content h6 {
+ font-size: 1em;
+ }
+
+ .chat-markdown-content p,
+ .chat-markdown-content blockquote,
+ .chat-markdown-content ul,
+ .chat-markdown-content ol,
+ .chat-markdown-content table,
+ .chat-markdown-content pre {
+ margin-top: 0;
+ margin-bottom: 10px;
+ }
+
+ .chat-markdown-content a {
+ color: var(--primary);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+
+ .chat-markdown-content strong {
+ font-weight: 700;
+ }
+
+ .chat-markdown-content ul,
+ .chat-markdown-content ol {
+ padding-left: 1.35em;
+ }
+
+ .chat-markdown-content li + li {
+ margin-top: 0.2em;
+ }
+
+ .chat-markdown-content blockquote {
+ border-left: 3px solid var(--border);
+ color: var(--muted-foreground);
+ padding-left: 0.85em;
+ }
+
+ .chat-markdown-content table {
+ border-collapse: collapse;
+ display: block;
+ font-size: 12px;
+ max-width: 100%;
+ overflow-x: auto;
+ width: max-content;
+ }
+
+ .chat-markdown-content th,
+ .chat-markdown-content td {
+ border: 1px solid var(--border);
+ padding: 5px 8px;
+ text-align: left;
+ vertical-align: top;
+ }
+
+ .chat-markdown-content th {
+ background-color: color-mix(in srgb, var(--muted) 60%, transparent);
+ font-weight: 700;
+ }
+
+ .chat-markdown-content code {
+ background-color: color-mix(in srgb, var(--muted) 72%, transparent);
+ border-radius: 4px;
+ font-family: var(--font-mono);
+ font-size: 85%;
+ padding: 0.15em 0.35em;
+ }
+
+ .chat-markdown-content pre {
+ background-color: color-mix(in srgb, var(--muted) 80%, transparent);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ line-height: 1.45;
+ max-width: 100%;
+ overflow-x: auto;
+ padding: 10px;
+ }
+
+ .chat-markdown-content pre code {
+ background: transparent;
+ border-radius: 0;
+ font-size: 100%;
+ padding: 0;
+ white-space: pre;
+ }
+
.original-markdown-preview {
color: var(--foreground);
font-size: 14px;
diff --git a/src/components/chat-message-list.test.ts b/src/components/chat-message-list.test.ts
index 2c0ca80..22f89b7 100644
--- a/src/components/chat-message-list.test.ts
+++ b/src/components/chat-message-list.test.ts
@@ -100,6 +100,109 @@ describe("ChatMessageList", () => {
).toBeNull();
});
+ it("renders assistant markdown with GitHub-flavored tables", () => {
+ render(
+ React.createElement(ChatMessageList, {
+ messages: [
+ {
+ id: "assistant_1",
+ role: "assistant",
+ content:
+ "### Summary\n\n- **Deadline:** Monday\n\n| Item | Status |\n| --- | --- |\n| Draft | Ready |",
+ },
+ ],
+ }),
+ );
+
+ expect(
+ screen.getByRole("heading", { name: "Summary", level: 3 }),
+ ).toBeTruthy();
+ expect(screen.getByRole("listitem").textContent).toContain("Deadline:");
+ expect(screen.getByRole("table")).toBeTruthy();
+ expect(screen.getByRole("columnheader", { name: "Item" })).toBeTruthy();
+ expect(screen.getByRole("cell", { name: "Ready" })).toBeTruthy();
+ });
+
+ it("keeps user markdown-looking text literal", () => {
+ render(
+ React.createElement(ChatMessageList, {
+ messages: [
+ {
+ id: "user_1",
+ role: "user",
+ content: "**Do not render this as bold**",
+ },
+ ],
+ }),
+ );
+
+ expect(screen.getByText("**Do not render this as bold**")).toBeTruthy();
+ expect(screen.queryByText("Do not render this as bold")).toBeNull();
+ });
+
+ it("skips assistant inline HTML while rendering markdown text", () => {
+ render(
+ React.createElement(ChatMessageList, {
+ messages: [
+ {
+ id: "assistant_1",
+ role: "assistant",
+ content: "Visible **text**
",
+ },
+ ],
+ }),
+ );
+
+ expect(screen.getByText("text")).toBeTruthy();
+ expect(screen.queryByAltText("hidden image")).toBeNull();
+ });
+
+ it("does not hide image cards when source links dedupe the same section", () => {
+ render(
+ React.createElement(ChatMessageList, {
+ messages: [
+ {
+ id: "assistant_1",
+ role: "assistant",
+ content: "这里是相关身份证明图片。",
+ citations: [
+ {
+ chunkType: "text",
+ score: 0.9,
+ source: {
+ documentId: "doc_1",
+ sourceFileName: "商务标文件.pdf",
+ sectionPath: "二、法定代表人身份证明",
+ },
+ },
+ {
+ chunkType: "image",
+ score: 0.9,
+ assetUrl: "https://blob.example/images/image-6-id-front.jpg",
+ source: {
+ documentId: "doc_1",
+ sourceFileName: "商务标文件.pdf",
+ sectionPath: "二、法定代表人身份证明",
+ },
+ },
+ ],
+ },
+ ],
+ }),
+ );
+
+ expect(
+ screen.getByRole("img", {
+ name: "商务标文件.pdf · 二、法定代表人身份证明",
+ }),
+ ).toBeTruthy();
+ expect(
+ screen.getAllByRole("button", {
+ name: "Open source 商务标文件.pdf · 二、法定代表人身份证明",
+ }),
+ ).toHaveLength(1);
+ });
+
it("shows thinking progress after existing messages while sending", () => {
render(
React.createElement(ChatMessageList, {
diff --git a/src/components/chat-message-list.tsx b/src/components/chat-message-list.tsx
index 1f81c74..937da4e 100644
--- a/src/components/chat-message-list.tsx
+++ b/src/components/chat-message-list.tsx
@@ -3,6 +3,8 @@
import { type CSSProperties, type ReactElement } from "react";
import { type VirtualItem } from "@tanstack/react-virtual";
import { ImageIcon, MessageCircle } from "lucide-react";
+import ReactMarkdown, { type Components } from "react-markdown";
+import remarkGfm from "remark-gfm";
import { useChatMessageListWorkflow } from "@/components/chat-message-list-workflow";
import { chatPanelModel } from "@/components/chat-panel-model";
@@ -23,6 +25,12 @@ type DisplayImageCitation = DisplayCitation & {
readonly assetUrl: string;
};
+const assistantMarkdownComponents: Components = {
+ p: ({ children }) => (
+
{children}
+ ),
+};
+
export type ChatMessageListProps = {
readonly isDisabled?: boolean;
readonly isSending?: boolean;
@@ -244,12 +252,15 @@ function MessageBubble({
message,
sourceTitlesByDocumentId,
);
- const displayImageCitations = getDisplayImageCitations(displayCitations);
+ const displayImageCitations = getDisplayImageCitations(
+ message,
+ sourceTitlesByDocumentId,
+ );
return (
-
{message.content}
+
{displayImageCitations.length > 0 && (
@@ -309,6 +320,24 @@ function MessageBubble({
);
}
+function AssistantMessageContent({
+ content,
+}: {
+ readonly content: string;
+}): ReactElement {
+ return (
+
+
+ {content}
+
+
+ );
+}
+
function getDisplayCitations(
message: ChatMessageView,
sourceTitlesByDocumentId: Readonly
>,
@@ -336,18 +365,24 @@ function getDisplayCitations(
}
function getDisplayImageCitations(
- citations: readonly DisplayCitation[],
+ message: ChatMessageView,
+ sourceTitlesByDocumentId: Readonly>,
): readonly DisplayImageCitation[] {
const seenAssetUrls = new Set();
const imageCitations: DisplayImageCitation[] = [];
- for (const citation of citations) {
- const assetUrl = getTrimmedCitationField(citation.citation.assetUrl);
- if (!assetUrl || !isImageCitation(citation.citation, assetUrl)) continue;
+ for (const [index, citation] of (message.citations ?? []).entries()) {
+ const assetUrl = getTrimmedCitationField(citation.assetUrl);
+ if (!assetUrl || !isImageCitation(citation, assetUrl)) continue;
if (seenAssetUrls.has(assetUrl)) continue;
seenAssetUrls.add(assetUrl);
- imageCitations.push({ ...citation, assetUrl });
+ imageCitations.push({
+ citation,
+ citationId: chatPanelModel.getCitationId(message.id, index),
+ label: chatPanelModel.getCitationLabel(citation, sourceTitlesByDocumentId),
+ assetUrl,
+ });
}
return imageCitations;
diff --git a/src/components/chunks-panel-state.test.ts b/src/components/chunks-panel-state.test.ts
index a95c98e..bcee0f0 100644
--- a/src/components/chunks-panel-state.test.ts
+++ b/src/components/chunks-panel-state.test.ts
@@ -82,6 +82,63 @@ describe("chunksPanelState", () => {
])
})
+ it("deduplicates repeated chunk ids before ordering and building the section tree", () => {
+ type TestSectionTreeNode = {
+ readonly chunkCount: number
+ readonly chunks: readonly ParsedChunkView[]
+ readonly children: readonly TestSectionTreeNode[]
+ }
+ const buildSectionTree = (
+ chunksPanelState as typeof chunksPanelState & {
+ readonly buildSectionTree?: (
+ chunks: readonly ParsedChunkView[],
+ sourceTitle: string,
+ ) => TestSectionTreeNode
+ }
+ ).buildSectionTree
+ const chunks: ParsedChunkView[] = [
+ {
+ chunkId: "duplicate_chunk",
+ type: "text",
+ content: "First copy.",
+ sectionPath: "manual.pdf/Overview",
+ sourceTitle: "manual.pdf",
+ pageNums: [1],
+ },
+ {
+ chunkId: "other_chunk",
+ type: "text",
+ content: "Other chunk.",
+ sectionPath: "manual.pdf/Overview",
+ sourceTitle: "manual.pdf",
+ pageNums: [2],
+ },
+ {
+ chunkId: "duplicate_chunk",
+ type: "text",
+ content: "Duplicate copy.",
+ sectionPath: "manual.pdf/Overview",
+ sourceTitle: "manual.pdf",
+ pageNums: [3],
+ },
+ ]
+
+ expect(
+ chunksPanelState
+ .getChunksWithFocusedFirst(chunks, null)
+ .map((chunk) => chunk.content),
+ ).toEqual(["First copy.", "Other chunk."])
+
+ const tree = buildSectionTree?.(chunks, "manual.pdf")
+ const overviewSection = tree?.children[0]
+
+ expect(tree?.chunkCount).toBe(2)
+ expect(overviewSection?.chunks.map((chunk) => chunk.content)).toEqual([
+ "First copy.",
+ "Other chunk.",
+ ])
+ })
+
it("formats Knowhere section paths and reference labels for display", () => {
expect(
chunksPanelState.formatChunkSectionPath(
diff --git a/src/components/chunks-panel-state.ts b/src/components/chunks-panel-state.ts
index 83ea12b..16aaa33 100644
--- a/src/components/chunks-panel-state.ts
+++ b/src/components/chunks-panel-state.ts
@@ -55,7 +55,9 @@ function getChunksWithFocusedFirst(
chunks: readonly ParsedChunkView[],
focusedChunkId: string | null,
): readonly ParsedChunkView[] {
- const orderedChunks = getChunksOrderedByPageNumber(chunks)
+ const orderedChunks = getChunksOrderedByPageNumber(
+ dedupeChunksById(chunks),
+ )
if (!focusedChunkId) return orderedChunks
const focusedIndex = orderedChunks.findIndex(
@@ -98,20 +100,23 @@ function buildSectionTree(
chunks: readonly ParsedChunkView[],
sourceTitle: string,
): ChunkSectionTreeNode {
+ const uniqueChunks = dedupeChunksById(chunks)
const root = createMutableSectionTreeNode({
id: "root",
kind: "root",
label: sourceTitle.trim() || "Parsed Chunks",
})
const chunksByParserChunkId = new Map(
- chunks
+ uniqueChunks
.filter((chunk) => chunk.parserChunkId)
.map((chunk) => [chunk.parserChunkId!, chunk]),
)
- const chunksByChunkId = new Map(chunks.map((chunk) => [chunk.chunkId, chunk]))
+ const chunksByChunkId = new Map(
+ uniqueChunks.map((chunk) => [chunk.chunkId, chunk]),
+ )
const sectionSegmentsByChunkId = new Map()
- chunks.forEach((chunk) => {
+ uniqueChunks.forEach((chunk) => {
const sectionSegments = getChunkSectionSegments(chunk, sourceTitle)
if (sectionSegments.length > 0) {
sectionSegmentsByChunkId.set(chunk.chunkId, sectionSegments)
@@ -119,13 +124,13 @@ function buildSectionTree(
})
const embeddedSectionSegmentsByChunkId = getEmbeddedSectionSegmentsByChunkId({
- chunks,
+ chunks: uniqueChunks,
chunksByChunkId,
chunksByParserChunkId,
sectionSegmentsByChunkId,
})
- chunks.forEach((chunk) => {
+ uniqueChunks.forEach((chunk) => {
const sectionSegments =
embeddedSectionSegmentsByChunkId.get(chunk.chunkId) ??
sectionSegmentsByChunkId.get(chunk.chunkId) ??
@@ -136,6 +141,22 @@ function buildSectionTree(
return toReadonlySectionTreeNode(root)
}
+function dedupeChunksById(
+ chunks: readonly ParsedChunkView[],
+): readonly ParsedChunkView[] {
+ const seenChunkIds = new Set()
+ const uniqueChunks: ParsedChunkView[] = []
+
+ chunks.forEach((chunk) => {
+ if (seenChunkIds.has(chunk.chunkId)) return
+
+ seenChunkIds.add(chunk.chunkId)
+ uniqueChunks.push(chunk)
+ })
+
+ return uniqueChunks
+}
+
function createMutableSectionTreeNode(input: {
readonly id: string
readonly kind: ChunkSectionTreeNodeKind
diff --git a/src/components/chunks-panel.test.ts b/src/components/chunks-panel.test.ts
index 3e110ac..c3e87aa 100644
--- a/src/components/chunks-panel.test.ts
+++ b/src/components/chunks-panel.test.ts
@@ -117,6 +117,48 @@ describe("ChunksPanel", () => {
).toBeTruthy();
});
+ it("deduplicates repeated chunks before rendering section tree keys", () => {
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+
+ render(
+ React.createElement(C, {
+ chunks: [
+ {
+ chunkId: "duplicate_chunk",
+ type: "text",
+ content: "Overview text",
+ sectionPath: "manual.pdf/Overview",
+ sourceTitle: "manual.pdf",
+ },
+ {
+ chunkId: "duplicate_chunk",
+ type: "text",
+ content: "Duplicate overview text",
+ sectionPath: "manual.pdf/Overview",
+ sourceTitle: "manual.pdf",
+ },
+ ],
+ selectedSource: "manual.pdf",
+ }),
+ );
+
+ expect(
+ screen.getByRole("treeitem", {
+ name: "Overview section with 1 chunk",
+ }),
+ ).toBeTruthy();
+ expect(
+ screen.getAllByRole("treeitem", { name: "Overview text Text" }),
+ ).toHaveLength(1);
+ expect(
+ consoleError.mock.calls.some((call) =>
+ String(call[0]).includes("Encountered two children with the same key"),
+ ),
+ ).toBe(false);
+ });
+
it("requests the full chunk list before showing the section tree", async () => {
const user = userEvent.setup();
const handleLoadAllChunks = vi.fn();
diff --git a/src/domains/chat/contracts.ts b/src/domains/chat/contracts.ts
index 5205ed0..0bef342 100644
--- a/src/domains/chat/contracts.ts
+++ b/src/domains/chat/contracts.ts
@@ -1,4 +1,8 @@
-import type { RetrievalQueryParams, RetrievalQueryResponse } from "@ontos-ai/knowhere-sdk"
+import type {
+ RetrievalQueryParams,
+ RetrievalQueryResponse,
+ RetrievalSource,
+} from "@ontos-ai/knowhere-sdk"
import type { Source } from "@/infrastructure/db/schema"
import type { ChatCitationView } from "@/domains/chat/types"
@@ -14,19 +18,83 @@ export type ChatHistoryMessage = {
citations?: readonly ChatCitationView[]
}
-export type GenerateRetrievalQuery = (input: {
- question: string
- messages: readonly ChatHistoryMessage[]
- sources: readonly Source[]
- excludedSourceIds: readonly string[]
-}) => Promise
+export type AgenticRetrievalTargetContent =
+ | "all"
+ | "text"
+ | "image"
+ | "table"
+ | "text_image"
+ | "text_table"
+
+export type AgenticRetrievalPlan = {
+ targetContent: AgenticRetrievalTargetContent
+ purpose: string | null
+}
+
+export type AgenticRetrievalQuery = Pick<
+ RetrievalQueryParams,
+ "query" | "topK" | "signalPaths" | "filterMode" | "threshold"
+> & {
+ readonly targetContent?: AgenticRetrievalTargetContent
+ readonly purpose?: string
+}
+
+export type RetrievedChunkReference = {
+ id: string
+ chunkId: string | null
+ kind: "result" | "referencedChunk"
+ resultIndex: number | null
+ chunkType: string
+ score: number | null
+ source: RetrievalSource
+ hasAssetUrl: boolean
+ contentLength: number
+ contentPreview: string
+ contentTruncated: boolean
+}
+
+export type AgenticRetrievalResponse = RetrievalQueryResponse & {
+ chunkReferences: readonly RetrievedChunkReference[]
+ retrievalPlan?: AgenticRetrievalPlan
+}
+
+export type SearchSources = (
+ input: AgenticRetrievalQuery,
+) => Promise
+
+export type ReadRetrievedChunkInput = {
+ id: string
+ offset?: number
+ limit?: number
+}
+
+export type ReadRetrievedChunkResult = {
+ id: string
+ chunkId: string | null
+ found: boolean
+ chunkType: string | null
+ score: number | null
+ source: RetrievalSource | null
+ hasAssetUrl: boolean
+ offset: number
+ limit: number
+ contentLength: number
+ contentSlice: string
+ hasMoreContent: boolean
+ nextOffset: number | null
+}
+
+export type ReadRetrievedChunk = (
+ input: ReadRetrievedChunkInput,
+) => Promise
export type GenerateAnswer = (input: {
question: string
- retrievalQuery: string
messages: readonly ChatHistoryMessage[]
- evidenceText: string
- mediaAssetContext?: string
+ sources: readonly Source[]
+ excludedSourceIds: readonly string[]
+ searchSources: SearchSources
+ readRetrievedChunk: ReadRetrievedChunk
}) => Promise
export type AnswerQuestionInput = {
@@ -35,7 +103,6 @@ export type AnswerQuestionInput = {
sources: readonly Source[]
excludedSourceIds: readonly string[]
retrieval: RetrievalClient
- generateRetrievalQuery: GenerateRetrievalQuery
generateAnswer: GenerateAnswer
loadSourceAssetUrls?: LoadSourceAssetUrls
messages: readonly ChatHistoryMessage[]
diff --git a/src/domains/chat/index.test.ts b/src/domains/chat/index.test.ts
index c4048d7..a60f3c1 100644
--- a/src/domains/chat/index.test.ts
+++ b/src/domains/chat/index.test.ts
@@ -1,32 +1,58 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import type { RetrievalResult } from "@ontos-ai/knowhere-sdk"
import { Effect } from "effect"
-import { generateText } from "ai"
+import { generateText, ToolLoopAgent, type ModelMessage } from "ai"
import {
answerQuestionWithRetrieval,
+ buildAgenticChatSystemPrompt,
buildGroundedPrompt,
buildRetrievalQueryPrompt,
+ generateAgenticGroundedAnswer,
generateContextualRetrievalQuery,
generateGroundedAnswer,
parseChatRequestBody,
} from "."
import type { Source } from "@/infrastructure/db/schema"
+import type {
+ AgenticRetrievalQuery,
+ ReadRetrievedChunkInput,
+} from "./contracts"
-vi.mock("ai", () => ({
+const loggerMock = vi.hoisted(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+}));
+
+vi.mock("ai", async (importOriginal) => ({
+ ...(await importOriginal()),
generateText: vi.fn(),
}));
+vi.mock("@/lib/logger", () => ({
+ logger: {
+ info: loggerMock.info,
+ warn: loggerMock.warn,
+ error: loggerMock.error,
+ },
+}));
+
afterEach(() => {
+ vi.restoreAllMocks();
vi.mocked(generateText).mockReset();
+ loggerMock.info.mockReset();
+ loggerMock.warn.mockReset();
+ loggerMock.error.mockReset();
delete process.env.AI_GATEWAY_API_KEY;
});
describe("answerQuestionWithRetrieval", () => {
it("queries the workspace namespace and excludes unchecked ready documents", async () => {
+ const result = makeRetrievalResult();
const retrieval = {
query: vi.fn().mockResolvedValue({
- results: [makeRetrievalResult()],
+ results: [result],
evidenceText: "Grounding content from evidence tree",
referencedChunks: [],
namespace: "notebook-workspace",
@@ -35,22 +61,22 @@ describe("answerQuestionWithRetrieval", () => {
answerText: null,
}),
};
- const generateAnswer = vi.fn().mockResolvedValue("The answer is grounded.");
- const generateRetrievalQuery = vi
- .fn()
- .mockResolvedValue("What does the document say?");
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({ query: "What does the document say?" });
+ return "The answer is grounded.";
+ });
+ const sources = [
+ makeSource({ knowhereDocumentId: "doc_included" }),
+ makeSource({ id: "source_2", knowhereDocumentId: "doc_excluded" }),
+ ];
const answer = await Effect.runPromise(
answerQuestionWithRetrieval({
question: "What does the document say?",
namespace: "notebook-workspace",
- sources: [
- makeSource({ knowhereDocumentId: "doc_included" }),
- makeSource({ id: "source_2", knowhereDocumentId: "doc_excluded" }),
- ],
+ sources,
excludedSourceIds: ["source_2"],
retrieval,
- generateRetrievalQuery,
generateAnswer,
messages: [],
}),
@@ -61,18 +87,101 @@ describe("answerQuestionWithRetrieval", () => {
query: "What does the document say?",
topK: 8,
useAgentic: true,
+ dataType: 1,
excludeDocumentIds: ["doc_excluded"],
});
expect(generateAnswer).toHaveBeenCalledWith({
question: "What does the document say?",
- retrievalQuery: "What does the document say?",
messages: [],
- evidenceText: "Grounding content from evidence tree",
+ sources,
+ excludedSourceIds: ["source_2"],
+ searchSources: expect.any(Function),
+ readRetrievedChunk: expect.any(Function),
});
expect(answer).toEqual({
answer: "The answer is grounded.",
- citations: [makeRetrievalResult()],
+ citations: [result],
+ });
+ });
+
+ it("logs bounded Knowhere query response chunks", async () => {
+ const result = makeRetrievalResult({
+ chunkType: "image",
+ content: `Identity card front image https://blob.example/id.jpg ${"content ".repeat(
+ 80,
+ )}`,
+ });
+ const retrieval = {
+ query: vi.fn().mockResolvedValue({
+ results: [result],
+ evidenceText: `Evidence https://blob.example/evidence.jpg ${"evidence ".repeat(
+ 80,
+ )}`,
+ referencedChunks: [
+ {
+ chunkId: "chunk_identity_1",
+ documentId: "doc_identity",
+ chunkType: "image",
+ sectionPath: `Assets / images / identity card front ${"summary ".repeat(
+ 80,
+ )}`,
+ filePath: "images/id-front.jpg",
+ jobId: "job_1",
+ assetUrl: "https://blob.example/id.jpg",
+ },
+ ],
+ namespace: "notebook-workspace",
+ query: "冯荣洲 身份证 ID card",
+ routerUsed: "workflow_single_step",
+ answerText: `Matched identity card image ${"answer ".repeat(80)}`,
+ stopReason: "answer_done",
+ failureReason: null,
+ }),
+ };
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({
+ query: "冯荣洲 身份证 ID card",
+ targetContent: "image",
+ });
+ return "Matched identity card image.";
+ });
+
+ await Effect.runPromise(
+ answerQuestionWithRetrieval({
+ question: "请将 冯荣洲 的身份证图片发给我",
+ namespace: "notebook-workspace",
+ sources: [makeSource({ knowhereDocumentId: "doc_identity" })],
+ excludedSourceIds: [],
+ retrieval,
+ generateAnswer,
+ messages: [],
+ }),
+ );
+
+ const meta = getLoggerInfoMeta("chat-agent: knowhere query response");
+ const response = meta.response as KnowhereQueryResponseLogMeta;
+ expect(response).toMatchObject({
+ query: "冯荣洲 身份证 ID card",
+ resultCount: 1,
+ referencedChunkCount: 1,
+ results: [
+ {
+ chunkType: "image",
+ },
+ ],
+ referencedChunks: [
+ {
+ chunkType: "image",
+ },
+ ],
});
+ expect(response.answerText.length).toBeLessThanOrEqual(203);
+ expect(response.evidenceText.length).toBeLessThanOrEqual(203);
+ expect(response.results[0]?.content.length).toBeLessThanOrEqual(103);
+ expect(response.referencedChunks[0]?.summary.length).toBeLessThanOrEqual(
+ 103,
+ );
+ expect(JSON.stringify(meta)).not.toContain("https://blob.example");
});
it("attaches citation descriptions from generated source labels", async () => {
@@ -102,12 +211,10 @@ describe("answerQuestionWithRetrieval", () => {
answerText: null,
}),
};
- const generateAnswer = vi
- .fn()
- .mockResolvedValue(
- "Revenue improved [Source 1: revenue growth]. Margins expanded [Source 2: margin expansion].",
- );
- const generateRetrievalQuery = vi.fn().mockResolvedValue("What improved?");
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({ query: "What improved?" });
+ return "Revenue improved [Source 1: revenue growth]. Margins expanded [Source 2: margin expansion].";
+ });
const answer = await Effect.runPromise(
answerQuestionWithRetrieval({
@@ -116,7 +223,6 @@ describe("answerQuestionWithRetrieval", () => {
sources: [makeSource()],
excludedSourceIds: [],
retrieval,
- generateRetrievalQuery,
generateAnswer,
messages: [],
}),
@@ -147,24 +253,24 @@ describe("answerQuestionWithRetrieval", () => {
answerText: null,
}),
};
- const generateAnswer = vi
- .fn()
- .mockResolvedValue("Tesla invested in xAI [Source 1: xAI investment].");
- const generateRetrievalQuery = vi.fn().mockResolvedValue("Tesla xAI investment");
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({ query: "Tesla xAI investment" });
+ return "Tesla invested in xAI [Source 1: xAI investment].";
+ });
+ const sources = [
+ makeSource({
+ title: "TSLA-Q4-2025-Update.pdf",
+ knowhereDocumentId: "doc_tesla",
+ }),
+ ];
const answer = await Effect.runPromise(
answerQuestionWithRetrieval({
question: "What does the document say about xAI?",
namespace: "notebook-workspace",
- sources: [
- makeSource({
- title: "TSLA-Q4-2025-Update.pdf",
- knowhereDocumentId: "doc_tesla",
- }),
- ],
+ sources,
excludedSourceIds: [],
retrieval,
- generateRetrievalQuery,
generateAnswer,
messages: [],
}),
@@ -172,9 +278,11 @@ describe("answerQuestionWithRetrieval", () => {
expect(generateAnswer).toHaveBeenCalledWith({
question: "What does the document say about xAI?",
- retrievalQuery: "Tesla xAI investment",
messages: [],
- evidenceText: "Tesla invested in xAI.",
+ sources,
+ excludedSourceIds: [],
+ searchSources: expect.any(Function),
+ readRetrievedChunk: expect.any(Function),
});
const expectedResult = {
...result,
@@ -208,12 +316,14 @@ describe("answerQuestionWithRetrieval", () => {
answerText: null,
}),
};
- const generateAnswer = vi
- .fn()
- .mockResolvedValue(
- "Use this launch photo. https://blob.example/images/image-9-Night%20Rocket%20Launch.jpg",
- );
- const generateRetrievalQuery = vi.fn().mockResolvedValue("SpaceX rocket photos");
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({
+ query: "SpaceX rocket photos",
+ targetContent: "image",
+ purpose: "Find visual rocket launch chunks.",
+ });
+ return "Use this launch photo. https://blob.example/images/image-9-Night%20Rocket%20Launch.jpg";
+ });
const loadSourceAssetUrls = vi.fn().mockResolvedValue({
"images/image-9-Night Rocket Launch.jpg":
"https://blob.example/images/image-9-Night%20Rocket%20Launch.jpg",
@@ -232,7 +342,6 @@ describe("answerQuestionWithRetrieval", () => {
],
excludedSourceIds: [],
retrieval,
- generateRetrievalQuery,
generateAnswer,
loadSourceAssetUrls,
messages: [],
@@ -242,13 +351,12 @@ describe("answerQuestionWithRetrieval", () => {
expect(loadSourceAssetUrls).toHaveBeenCalledWith(
expect.objectContaining({ id: "source_spacex" }),
);
- expect(generateAnswer).toHaveBeenCalledWith({
- question: "Show me the SpaceX rocket photos.",
- retrievalQuery: "SpaceX rocket photos",
- messages: [],
- evidenceText: "A SpaceX rocket launches at night.",
- mediaAssetContext:
- "- spacex-s1.pdf / Assets / images / image-9-Night Rocket Launch.jpg: https://blob.example/images/image-9-Night%20Rocket%20Launch.jpg",
+ expect(retrieval.query).toHaveBeenCalledWith({
+ namespace: "notebook-workspace",
+ query: "SpaceX rocket photos",
+ topK: 8,
+ useAgentic: true,
+ dataType: 3,
});
expect(answer.answer).toBe("Use this launch photo.");
expect(answer.citations).toEqual([
@@ -264,7 +372,232 @@ describe("answerQuestionWithRetrieval", () => {
]);
});
- it("returns a deterministic no-results answer without calling the model", async () => {
+ it("turns retrieved evidence image filenames into image citations", async () => {
+ const result = makeRetrievalResult({
+ content: "This section contains identity proof attachments.",
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "二、法定代表人身份证明",
+ },
+ });
+ const retrieval = {
+ query: vi.fn().mockResolvedValue({
+ results: [result],
+ evidenceText:
+ "[image-6-中华人民共和国居民身份证.jpg]\n[image-7-中国居民身份证.jpg]",
+ referencedChunks: [],
+ namespace: "notebook-workspace",
+ query: "公民身份证明 图片",
+ routerUsed: "workflow_single_step",
+ answerText: null,
+ }),
+ };
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({
+ query: "公民身份证明 图片",
+ targetContent: "image",
+ });
+ return "这里是相关身份证明图片。";
+ });
+ const loadSourceAssetUrls = vi.fn().mockResolvedValue({
+ "images/image-6-中华人民共和国居民身份证.jpg":
+ "https://blob.example/images/image-6-id-front.jpg",
+ "images/image-7-中国居民身份证.jpg":
+ "https://blob.example/images/image-7-id-back.jpg",
+ });
+ const sources = [
+ makeSource({
+ id: "source_identity",
+ title: "商务标文件.pdf",
+ knowhereDocumentId: "doc_identity",
+ }),
+ ];
+
+ const answer = await Effect.runPromise(
+ answerQuestionWithRetrieval({
+ question: "请发送几张关于公民身份的图片给我",
+ namespace: "notebook-workspace",
+ sources,
+ excludedSourceIds: [],
+ retrieval,
+ generateAnswer,
+ loadSourceAssetUrls,
+ messages: [],
+ }),
+ );
+
+ expect(generateAnswer).toHaveBeenCalledWith({
+ question: "请发送几张关于公民身份的图片给我",
+ messages: [],
+ sources,
+ excludedSourceIds: [],
+ searchSources: expect.any(Function),
+ readRetrievedChunk: expect.any(Function),
+ });
+ expect(retrieval.query).toHaveBeenCalledWith({
+ namespace: "notebook-workspace",
+ query: "公民身份证明 图片",
+ topK: 8,
+ useAgentic: true,
+ dataType: 3,
+ });
+ expect(answer.citations.map((citation) => citation.assetUrl)).toEqual([
+ "https://blob.example/images/image-6-id-front.jpg",
+ "https://blob.example/images/image-7-id-back.jpg",
+ ]);
+ expect(answer.citations.map((citation) => citation.chunkType)).toEqual([
+ "image",
+ "image",
+ ]);
+ });
+
+ it("sends requested identity-card images without exposing internal media metadata", async () => {
+ const frontAssetUrl = "https://blob.example/images/feng-rongzhou-id-front.jpg";
+ const backAssetUrl = "https://blob.example/images/feng-rongzhou-id-back.jpg";
+ const unrelatedAssetUrl = "https://blob.example/images/company-license.jpg";
+ const textResult = makeRetrievalResult({
+ content: "冯荣洲的法定代表人身份证明页包含居民身份证图片。",
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "二、法定代表人身份证明",
+ },
+ });
+ const duplicateFrontResult = {
+ ...makeRetrievalResult({
+ chunkType: "image",
+ content: "冯荣洲居民身份证正面图片。",
+ assetUrl: frontAssetUrl,
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "images/feng-rongzhou-id-front.jpg",
+ },
+ }),
+ chunkId: "chunk_front_direct",
+ } as RetrievalResult & { readonly chunkId: string };
+ const richerDuplicateFrontResult = {
+ ...makeRetrievalResult({
+ chunkType: "image",
+ content: "冯荣洲居民身份证正面图片,来源于身份证明章节。",
+ assetUrl: frontAssetUrl,
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "二、法定代表人身份证明 / 身份证正面",
+ },
+ }),
+ chunkId: "chunk_front_richer",
+ } as RetrievalResult & { readonly chunkId: string };
+ const backResult = {
+ ...makeRetrievalResult({
+ chunkType: "image",
+ content: "冯荣洲居民身份证反面图片。",
+ assetUrl: backAssetUrl,
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "二、法定代表人身份证明 / 身份证反面",
+ },
+ }),
+ chunkId: "chunk_back",
+ } as RetrievalResult & { readonly chunkId: string };
+ const unrelatedImageResult = {
+ ...makeRetrievalResult({
+ chunkType: "image",
+ content: "公司证照图片。",
+ assetUrl: unrelatedAssetUrl,
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "images/company-license.jpg",
+ },
+ }),
+ chunkId: "chunk_company_license",
+ } as RetrievalResult & { readonly chunkId: string };
+ const retrieval = {
+ query: vi.fn().mockResolvedValue({
+ results: [
+ textResult,
+ duplicateFrontResult,
+ richerDuplicateFrontResult,
+ backResult,
+ unrelatedImageResult,
+ ],
+ evidenceText: "冯荣洲 身份证 图片",
+ referencedChunks: [],
+ namespace: "notebook-workspace",
+ query: "冯荣洲 身份证 图片",
+ routerUsed: "workflow_single_step",
+ answerText: null,
+ }),
+ };
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({
+ query: "冯荣洲 身份证 图片",
+ targetContent: "image",
+ purpose: "查找冯荣洲的身份证图片。",
+ });
+ return [
+ "为您找到冯荣洲的居民身份证图片,相关信息如下:",
+ "- **姓名**:冯荣洲",
+ "- **公民身份号码**:123456789012345678",
+ "- **签发机关**:某公安局",
+ "- **有效期限**:长期",
+ `{"asset_id":"asset_front","assetUrl":"${frontAssetUrl}","chunkId":"chunk_front_direct"}`,
+ ].join("\n");
+ });
+ const sources = [
+ makeSource({
+ id: "source_identity",
+ title: "商务标文件.pdf",
+ knowhereDocumentId: "doc_identity",
+ }),
+ ];
+
+ const answer = await Effect.runPromise(
+ answerQuestionWithRetrieval({
+ question: "请将 冯荣洲 的身份证图片发给我",
+ namespace: "notebook-workspace",
+ sources,
+ excludedSourceIds: [],
+ retrieval,
+ generateAnswer,
+ messages: [],
+ }),
+ );
+
+ expect(retrieval.query).toHaveBeenCalledWith({
+ namespace: "notebook-workspace",
+ query: "冯荣洲 身份证 图片",
+ topK: 8,
+ useAgentic: true,
+ dataType: 3,
+ });
+ expect(answer.answer).toBe("已找到相关身份证图片,见下方图片。");
+ expect(answer.answer).not.toMatch(
+ /asset_id|assetUrl|asset_url|chunkId|chunk_id|https?:\/\//,
+ );
+ expect(answer.answer).not.toMatch(
+ /姓名|公民身份号码|签发机关|有效期限|123456789012345678/,
+ );
+ expect(answer.citations.map((citation) => citation.assetUrl)).toEqual([
+ frontAssetUrl,
+ backAssetUrl,
+ ]);
+ expect(
+ answer.citations.filter(
+ (citation) => citation.assetUrl === frontAssetUrl,
+ ),
+ ).toHaveLength(1);
+ expect(answer.citations[0]?.source).toMatchObject({
+ sourceFileName: "商务标文件.pdf",
+ sectionPath: "二、法定代表人身份证明 / 身份证正面",
+ });
+ });
+
+ it("returns the agent answer without citations when retrieval has no results", async () => {
const retrieval = {
query: vi.fn().mockResolvedValue({
results: [],
@@ -276,8 +609,10 @@ describe("answerQuestionWithRetrieval", () => {
answerText: null,
}),
};
- const generateAnswer = vi.fn();
- const generateRetrievalQuery = vi.fn().mockResolvedValue("Missing fact?");
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({ query: "Missing fact?" });
+ return "I couldn't find that in your sources.";
+ });
const answer = await Effect.runPromise(
answerQuestionWithRetrieval({
@@ -286,20 +621,18 @@ describe("answerQuestionWithRetrieval", () => {
sources: [makeSource()],
excludedSourceIds: [],
retrieval,
- generateRetrievalQuery,
generateAnswer,
messages: [],
}),
);
- expect(generateAnswer).not.toHaveBeenCalled();
expect(answer).toEqual({
answer: "I couldn't find that in your sources.",
citations: [],
});
});
- it("uses an LLM-contextualized query while answering the user's original question", async () => {
+ it("lets the agent issue contextual retrieval queries while answering the original question", async () => {
const retrieval = {
query: vi.fn().mockResolvedValue({
results: [makeRetrievalResult()],
@@ -311,12 +644,12 @@ describe("answerQuestionWithRetrieval", () => {
answerText: null,
}),
};
- const generateRetrievalQuery = vi
- .fn()
- .mockResolvedValue(
- "Tesla Q4 2025 Update energy generation and storage deployments",
- );
- const generateAnswer = vi.fn().mockResolvedValue("Energy storage grew.");
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({
+ query: "Tesla Q4 2025 Update energy generation and storage deployments",
+ });
+ return "Energy storage grew.";
+ });
const messages = [
{
role: "user" as const,
@@ -335,7 +668,6 @@ describe("answerQuestionWithRetrieval", () => {
sources: [makeSource({ title: "TSLA-Q4-2025-Update.pdf" })],
excludedSourceIds: [],
retrieval,
- generateRetrievalQuery,
generateAnswer,
messages,
}),
@@ -346,14 +678,201 @@ describe("answerQuestionWithRetrieval", () => {
query: "Tesla Q4 2025 Update energy generation and storage deployments",
topK: 8,
useAgentic: true,
+ dataType: 1,
});
expect(generateAnswer).toHaveBeenCalledWith({
question: "What about energy storage in this document?",
- retrievalQuery:
- "Tesla Q4 2025 Update energy generation and storage deployments",
messages,
- evidenceText: "Energy storage deployments grew significantly.",
+ sources: [makeSource({ title: "TSLA-Q4-2025-Update.pdf" })],
+ excludedSourceIds: [],
+ searchSources: expect.any(Function),
+ readRetrievedChunk: expect.any(Function),
+ });
+ });
+
+ it("does not append chat history to Knowhere tool queries", async () => {
+ const retrieval = {
+ query: vi.fn().mockResolvedValue({
+ results: [makeRetrievalResult()],
+ evidenceText: "Energy storage deployments grew.",
+ referencedChunks: [],
+ namespace: "notebook-workspace",
+ query: "Tesla energy storage deployments",
+ routerUsed: "workflow_single_step",
+ answerText: null,
+ }),
+ };
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({ query: "Tesla energy storage deployments" });
+ return "Energy storage grew.";
+ });
+ const messages = [
+ {
+ role: "user" as const,
+ content: "do-not-append-this-history-to-query",
+ },
+ {
+ role: "assistant" as const,
+ content: "This older answer should not be concatenated into retrieval.",
+ },
+ ];
+
+ await Effect.runPromise(
+ answerQuestionWithRetrieval({
+ question: "What about it?",
+ namespace: "notebook-workspace",
+ sources: [makeSource()],
+ excludedSourceIds: [],
+ retrieval,
+ generateAnswer,
+ messages,
+ }),
+ );
+
+ const queryInput = retrieval.query.mock.calls[0]?.[0];
+ expect(queryInput).toMatchObject({
+ namespace: "notebook-workspace",
+ query: "Tesla energy storage deployments",
+ topK: 8,
+ useAgentic: true,
+ dataType: 1,
+ });
+ expect(JSON.stringify(queryInput)).not.toContain(
+ "do-not-append-this-history-to-query",
+ );
+ });
+
+ it("lets the agent read untruncated content from returned chunk ids", async () => {
+ const longContent = `${"Earlier context. ".repeat(300)}Critical obligation: retain source receipts.`;
+ const result = {
+ ...makeRetrievalResult({
+ content: longContent,
+ source: {
+ documentId: "doc_contract",
+ sourceFileName: "contract.pdf",
+ sectionPath: "Obligations",
+ },
+ }),
+ chunkId: "chunk_contract_1",
+ } as RetrievalResult & { readonly chunkId: string };
+ const retrieval = {
+ query: vi.fn().mockResolvedValue({
+ results: [result],
+ evidenceText: "Contract obligations were retrieved.",
+ referencedChunks: [],
+ namespace: "notebook-workspace",
+ query: "contract obligations",
+ routerUsed: "workflow_single_step",
+ answerText: null,
+ }),
+ };
+ const generateAnswer = vi.fn(
+ async ({ searchSources, readRetrievedChunk }) => {
+ const response = await searchSources({ query: "contract obligations" });
+ expect(response.chunkReferences[0]).toMatchObject({
+ id: "chunk_contract_1",
+ chunkId: "chunk_contract_1",
+ contentTruncated: true,
+ contentLength: longContent.length,
+ });
+
+ const detail = await readRetrievedChunk({
+ id: "chunk_contract_1",
+ offset: 4_000,
+ limit: 80,
+ });
+
+ expect(detail).toMatchObject({
+ id: "chunk_contract_1",
+ found: true,
+ offset: 4_000,
+ limit: 80,
+ contentLength: longContent.length,
+ });
+ return detail.contentSlice;
+ },
+ );
+
+ const answer = await Effect.runPromise(
+ answerQuestionWithRetrieval({
+ question: "What obligation matters?",
+ namespace: "notebook-workspace",
+ sources: [
+ makeSource({
+ title: "contract.pdf",
+ knowhereDocumentId: "doc_contract",
+ }),
+ ],
+ excludedSourceIds: [],
+ retrieval,
+ generateAnswer,
+ messages: [],
+ }),
+ );
+
+ expect(answer.answer).toBe(longContent.slice(4_000, 4_080).trim());
+ });
+
+ it("uses structured referenced chunks from RetrievalQueryResponse as citations", async () => {
+ const retrieval = {
+ query: vi.fn().mockResolvedValue({
+ results: [],
+ evidenceText: "A launch image was referenced.",
+ referencedChunks: [
+ {
+ chunkId: "chunk_1",
+ documentId: "doc_spacex",
+ chunkType: "image",
+ sectionPath: "Assets / images / launch.jpg",
+ filePath: "images/launch.jpg",
+ jobId: "job_1",
+ assetUrl: "https://blob.example/images/launch.jpg",
+ },
+ ],
+ namespace: "notebook-workspace",
+ query: "SpaceX launch image",
+ routerUsed: "workflow_single_step",
+ answerText: null,
+ }),
+ };
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({
+ query: "SpaceX launch image",
+ targetContent: "image",
+ });
+ return "Here is the launch image.";
});
+
+ const answer = await Effect.runPromise(
+ answerQuestionWithRetrieval({
+ question: "Show me the launch image.",
+ namespace: "notebook-workspace",
+ sources: [
+ makeSource({
+ title: "spacex-s1.pdf",
+ knowhereDocumentId: "doc_spacex",
+ }),
+ ],
+ excludedSourceIds: [],
+ retrieval,
+ generateAnswer,
+ messages: [],
+ }),
+ );
+
+ expect(answer.citations).toEqual([
+ {
+ content: "",
+ chunkType: "image",
+ score: null,
+ assetUrl: "https://blob.example/images/launch.jpg",
+ source: {
+ documentId: "doc_spacex",
+ sourceFileName: "spacex-s1.pdf",
+ sectionPath: "Assets / images / launch.jpg",
+ },
+ },
+ ]);
});
});
@@ -415,6 +934,571 @@ describe("generateGroundedAnswer", () => {
prompt: expect.stringContaining("PR-E wires chat to Knowhere retrieval."),
});
expect(answer).toBe("PR-E wires chat to retrieval.");
+ expect(getLoggerInfoMeta("chat-agent: llm request")).toMatchObject({
+ operation: "generateGroundedAnswer",
+ model: "google/gemini-3-flash",
+ promptType: "text",
+ prompt: expect.stringContaining("PR-E wires chat to Knowhere retrieval."),
+ });
+ expect(getLoggerInfoMeta("chat-agent: llm response")).toMatchObject({
+ operation: "generateGroundedAnswer",
+ model: "google/gemini-3-flash",
+ responseText: "PR-E wires chat to retrieval.",
+ responseTextCharLength: "PR-E wires chat to retrieval.".length,
+ });
+ });
+});
+
+describe("generateAgenticGroundedAnswer", () => {
+ it("builds a Vercel AI SDK tool loop around Knowhere retrieval", async () => {
+ process.env.AI_GATEWAY_API_KEY = "test_gateway_key";
+ let capturedGenerateInput:
+ | Parameters[0]
+ | undefined;
+ const generateSpy = vi
+ .spyOn(ToolLoopAgent.prototype, "generate")
+ .mockImplementation((
+ input: Parameters[0],
+ ): ReturnType => {
+ capturedGenerateInput = input;
+ return Promise.resolve({
+ text: "Here are the requested identity images.",
+ } as Awaited>);
+ });
+ const previewWithinNewLimitMarker = "within-new-preview-limit";
+ const previewAfterNewLimitMarker = "after-new-preview-limit";
+ const fullToolOutputEvidenceMarker = "full-tool-output-evidence-end";
+ const evidenceTreeText = [
+ "Identity image evidence. https://blob.example/images/id-front.jpg",
+ "[Document] document-generated.pdf",
+ "▸ [L1] Assets",
+ " ▸ [L2] images / id-front.jpg",
+ ` ┈ ${"evidence ".repeat(500)}${fullToolOutputEvidenceMarker}`,
+ ].join("\n");
+ const longIdentityPreview = [
+ "Identity card image front side.",
+ "preview ".repeat(170),
+ previewWithinNewLimitMarker,
+ "preview ".repeat(70),
+ previewAfterNewLimitMarker,
+ ].join(" ");
+ const searchSources = vi.fn().mockResolvedValue({
+ results: [
+ makeRetrievalResult({
+ content: longIdentityPreview,
+ chunkType: "image",
+ assetUrl: "https://blob.example/images/id-front.jpg",
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "Assets / images / id-front.jpg",
+ },
+ }),
+ ],
+ evidenceText: evidenceTreeText,
+ referencedChunks: [
+ {
+ chunkId: "chunk_identity_1",
+ documentId: "doc_identity",
+ chunkType: "image",
+ sectionPath: "Assets / images / id-front.jpg",
+ filePath: "images/id-front.jpg",
+ jobId: "job_1",
+ assetUrl: "https://blob.example/images/id-front.jpg",
+ },
+ ],
+ namespace: "notebook-workspace",
+ query: "公民身份证 图片",
+ routerUsed: "workflow_single_step",
+ retrievalPlan: {
+ targetContent: "image",
+ purpose: "Find identity-card image evidence.",
+ },
+ chunkReferences: [
+ {
+ id: "chunk_identity_1",
+ chunkId: "chunk_identity_1",
+ kind: "result",
+ resultIndex: 1,
+ chunkType: "image",
+ score: 0.9,
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "Assets / images / id-front.jpg",
+ },
+ hasAssetUrl: true,
+ contentLength: longIdentityPreview.length,
+ contentPreview: longIdentityPreview,
+ contentTruncated: true,
+ },
+ ],
+ answerText:
+ "The source includes identity card images. https://blob.example/images/id-front.jpg",
+ stopReason: "answer_done",
+ failureReason: null,
+ decisionTrace: [
+ {
+ step: "final",
+ stop: "answer_done",
+ assetUrl: "https://blob.example/images/id-front.jpg",
+ },
+ ],
+ });
+ const readRetrievedChunk = vi.fn().mockResolvedValue({
+ id: "chunk_identity_1",
+ chunkId: "chunk_identity_1",
+ found: true,
+ chunkType: "image",
+ score: 0.9,
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "Assets / images / id-front.jpg",
+ },
+ hasAssetUrl: true,
+ offset: 0,
+ limit: 80,
+ contentLength: 96,
+ contentSlice:
+ "Full identity card text. https://blob.example/images/id-front.jpg",
+ hasMoreContent: false,
+ nextOffset: null,
+ });
+
+ const answer = await generateAgenticGroundedAnswer({
+ question: "请发送几张关于公民身份的图片给我",
+ messages: [],
+ sources: [
+ makeSource({
+ title: "商务标文件.pdf",
+ knowhereDocumentId: "doc_identity",
+ }),
+ ],
+ excludedSourceIds: [],
+ searchSources,
+ readRetrievedChunk,
+ });
+
+ expect(answer).toBe("Here are the requested identity images.");
+ expect(generateSpy).toHaveBeenCalledWith({
+ messages: expect.any(Array),
+ });
+ const agent = getCapturedAgent(generateSpy.mock.contexts[0]);
+ const settings = getCapturedAgentSettings(agent);
+ const generateInput = getCapturedGenerateInput(capturedGenerateInput);
+
+ expect(settings.instructions).toContain("markdown output gives guidance")
+ expect(settings.instructions).toContain("image or text+image search")
+ expect(settings.instructions).toContain("Read IDs")
+ expect(settings.instructions).toContain(
+ "Do not paste raw prior messages into searchSources.query",
+ )
+ expect(generateInput.messages.at(-1)).toEqual({
+ role: "user",
+ content: "请发送几张关于公民身份的图片给我",
+ })
+ expect(
+ settings.prepareStep({
+ stepNumber: 0,
+ messages: [...generateInput.messages],
+ }),
+ ).toMatchObject({
+ toolChoice: { type: "tool", toolName: "searchSources" },
+ activeTools: ["searchSources"],
+ })
+ expect(
+ settings.prepareStep({
+ stepNumber: 1,
+ messages: [...generateInput.messages],
+ }),
+ ).toMatchObject({
+ toolChoice: { type: "tool", toolName: "searchSources" },
+ activeTools: ["searchSources"],
+ })
+ expect(
+ settings.prepareStep({
+ stepNumber: 2,
+ messages: [...generateInput.messages],
+ }),
+ ).toMatchObject({
+ messages: expect.any(Array),
+ })
+
+ const searchSourcesTool = getCapturedAgentTools(agent).searchSources
+ expect(
+ getSearchSourcesTargetContentSchema(searchSourcesTool)._def?.innerType
+ ?._def?.type,
+ ).toBe("enum")
+ expect(
+ searchSourcesTool.inputSchema.safeParse({
+ query: "公民身份证 图片",
+ targetContent: "image",
+ }).success,
+ ).toBe(true)
+ expect(
+ searchSourcesTool.inputSchema.safeParse({
+ query: "公民身份证 图片",
+ targetContent: "video",
+ }).success,
+ ).toBe(false)
+
+ const toolOutput = await searchSourcesTool.execute({
+ query: "公民身份证 图片",
+ targetContent: "image",
+ purpose: "Find identity-card image evidence.",
+ });
+
+ expect(searchSources).toHaveBeenCalledWith({
+ query: "公民身份证 图片",
+ targetContent: "image",
+ purpose: "Find identity-card image evidence.",
+ });
+ expect(toolOutput).toEqual(expect.any(String));
+ expect(toolOutput).toContain("## Retrieval Result");
+ expect(toolOutput).toContain("Query: 公民身份证 图片");
+ expect(toolOutput).toContain("Guidance: Use this evidence");
+ expect(toolOutput).toContain("## Evidence");
+ expect(toolOutput).toContain(
+ "[Document] document-generated.pdf\n▸ [L1] Assets\n ▸ [L2] images / id-front.jpg",
+ );
+ expect(toolOutput).toContain(fullToolOutputEvidenceMarker);
+ expect(toolOutput).not.toContain(
+ "[Document] document-generated.pdf ▸ [L1] Assets",
+ );
+ expect(toolOutput).toContain("## Decision Trace");
+ expect(toolOutput).toContain("- Step 1:");
+ expect(toolOutput).toContain("- step: final");
+ expect(toolOutput).toContain("- stop: answer_done");
+ expect(toolOutput).toContain("### Result 1");
+ expect(toolOutput).toContain("Type: image");
+ expect(toolOutput).toContain(
+ "Source: document-generated.pdf / Assets / images / id-front.jpg",
+ );
+ expect(toolOutput).toContain("Media: image available");
+ expect(toolOutput).toContain("Read ID: chunk_identity_1");
+ expect(toolOutput).toContain("Identity card image front side.");
+ expect(toolOutput).toContain(previewWithinNewLimitMarker);
+ expect(toolOutput).not.toContain(previewAfterNewLimitMarker);
+ expect(toolOutput).not.toContain("https://blob.example");
+ expect(toolOutput).not.toContain("assetUrl");
+ expect(toolOutput).not.toContain("retrievalPlan");
+ expect(toolOutput).not.toContain("decisionTrace");
+ expect(getLoggerInfoMeta("chat-agent: tool output")).toMatchObject({
+ toolName: "searchSources",
+ output: expect.objectContaining({
+ truncated: false,
+ preview: expect.stringContaining(fullToolOutputEvidenceMarker),
+ }),
+ });
+
+ const readRetrievedChunkTool = getCapturedAgentTools(agent).readRetrievedChunk;
+ expect(
+ readRetrievedChunkTool.inputSchema.safeParse({
+ id: "chunk_identity_1",
+ limit: 8_000,
+ }).success,
+ ).toBe(true);
+ expect(
+ readRetrievedChunkTool.inputSchema.safeParse({
+ id: "chunk_identity_1",
+ limit: 8_001,
+ }).success,
+ ).toBe(false);
+
+ const chunkOutput = await readRetrievedChunkTool.execute({
+ id: "chunk_identity_1",
+ offset: 0,
+ limit: 80,
+ });
+
+ expect(readRetrievedChunk).toHaveBeenCalledWith({
+ id: "chunk_identity_1",
+ offset: 0,
+ limit: 80,
+ });
+ expect(chunkOutput).toEqual(expect.any(String));
+ expect(chunkOutput).toContain("## Retrieved Content");
+ expect(chunkOutput).toContain("Status: found");
+ expect(chunkOutput).toContain("Read ID: chunk_identity_1");
+ expect(chunkOutput).toContain(
+ "Source: document-generated.pdf / Assets / images / id-front.jpg",
+ );
+ expect(chunkOutput).toContain(
+ "Full identity card text. [media asset URL hidden]",
+ );
+ expect(chunkOutput).not.toContain("https://blob.example");
+ expect(chunkOutput).not.toContain("chunkId");
+ expect(getLoggerInfoMeta("chat-agent: tool output")).toMatchObject({
+ toolName: "readRetrievedChunk",
+ output: expect.objectContaining({
+ truncated: false,
+ preview: expect.stringContaining("## Retrieved Content\n\nStatus"),
+ }),
+ });
+ });
+
+ it("uses managed context for stored history and loop steps", async () => {
+ process.env.AI_GATEWAY_API_KEY = "test_gateway_key";
+ let capturedGenerateInput:
+ | Parameters[0]
+ | undefined;
+ const generateSpy = vi
+ .spyOn(ToolLoopAgent.prototype, "generate")
+ .mockImplementation((
+ input: Parameters[0],
+ ): ReturnType => {
+ capturedGenerateInput = input;
+ return Promise.resolve({
+ text: "The answer is grounded.",
+ } as Awaited>);
+ });
+ const messages = Array.from({ length: 24 }, (_, index) => ({
+ role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
+ content: `history-message-${index} ${"context ".repeat(80)}`,
+ }));
+
+ await generateAgenticGroundedAnswer({
+ question: "What should I know now?",
+ messages,
+ sources: [makeSource()],
+ excludedSourceIds: [],
+ searchSources: vi.fn(),
+ readRetrievedChunk: vi.fn(),
+ });
+
+ const generateInput = getCapturedGenerateInput(capturedGenerateInput);
+ const serializedMessages = JSON.stringify(generateInput.messages);
+ expect(generateInput.messages[0]).toMatchObject({
+ role: "system",
+ content: expect.stringContaining("Compacted earlier conversation"),
+ });
+ expect(serializedMessages).not.toContain("history-message-0");
+ expect(serializedMessages).toContain("history-message-23");
+
+ const settings = getCapturedAgentSettings(
+ getCapturedAgent(generateSpy.mock.contexts[0]),
+ );
+ const oversizedLoopMessages = Array.from({ length: 25 }, (_, index) => ({
+ role: "user" as const,
+ content: `loop-message-${index}`,
+ }));
+ const preparedStep = settings.prepareStep({
+ stepNumber: 2,
+ messages: oversizedLoopMessages,
+ }) as { readonly messages: readonly ModelMessage[] };
+
+ expect(preparedStep.messages.length).toBeLessThanOrEqual(12);
+ expect(JSON.stringify(preparedStep.messages)).not.toContain("loop-message-0");
+ expect(JSON.stringify(preparedStep.messages)).toContain("loop-message-24");
+ expect(getLoggerInfoMeta("chat-agent: llm request")).toMatchObject({
+ operation: "generateAgenticGroundedAnswer.step",
+ model: "google/gemini-3-flash",
+ promptType: "messages",
+ stepNumber: 2,
+ instructions: expect.stringContaining("Notebook research agent"),
+ messageCount: preparedStep.messages.length,
+ messages: expect.arrayContaining([
+ expect.objectContaining({
+ role: "user",
+ content: "loop-message-24",
+ }),
+ ]),
+ });
+
+ const hugeLoopMessages = [
+ {
+ role: "user" as const,
+ content: `huge-loop-message ${"context ".repeat(9_000)}`,
+ },
+ {
+ role: "assistant" as const,
+ content: "middle-loop-message",
+ },
+ {
+ role: "user" as const,
+ content: "latest-loop-message",
+ },
+ ];
+ const preparedHugeStep = settings.prepareStep({
+ stepNumber: 2,
+ messages: hugeLoopMessages,
+ }) as { readonly messages: readonly ModelMessage[] };
+ const serializedHugeStepMessages = JSON.stringify(
+ preparedHugeStep.messages,
+ );
+
+ expect(getTestModelMessagesCharLength(preparedHugeStep.messages)).toBeLessThanOrEqual(
+ 64_000,
+ );
+ expect(serializedHugeStepMessages).not.toContain("huge-loop-message");
+ expect(serializedHugeStepMessages).toContain("latest-loop-message");
+ });
+
+ it("logs bounded tool calls and complete tool results for each loop step", async () => {
+ process.env.AI_GATEWAY_API_KEY = "test_gateway_key";
+ const fullStepToolOutputMarker = "full-step-tool-output-end";
+ const fullStepToolOutput = `\n${[
+ "## Retrieval Result",
+ "",
+ "Status: useful_evidence_found",
+ "Query: 冯荣洲 身份证 ID card",
+ "Guidance: Use this evidence if it directly answers the user.",
+ "",
+ "## Evidence",
+ `Image evidence https://blob.example/id-front.jpg ${"evidence ".repeat(
+ 600,
+ )}`,
+ "",
+ "## Results",
+ "### Result 1",
+ "Type: image",
+ "Source: 商务标文件.pdf / 二、法定代表人身份证明",
+ "Media: image available",
+ "Read ID: chunk_identity_1",
+ "",
+ "Preview:",
+ `Identity image content ${"result ".repeat(80)}`,
+ fullStepToolOutputMarker,
+ ].join("\n")}\n `;
+ const generateSpy = vi
+ .spyOn(ToolLoopAgent.prototype, "generate")
+ .mockResolvedValue({
+ text: "The answer is grounded.",
+ } as Awaited>);
+
+ await generateAgenticGroundedAnswer({
+ question: "请将 冯荣洲 的身份证图片发给我",
+ messages: [],
+ sources: [makeSource()],
+ excludedSourceIds: [],
+ searchSources: vi.fn(),
+ readRetrievedChunk: vi.fn(),
+ });
+
+ const settings = getCapturedAgentSettings(
+ getCapturedAgent(generateSpy.mock.contexts[0]),
+ );
+ loggerMock.info.mockClear();
+
+ settings.onStepFinish({
+ stepNumber: 1,
+ finishReason: "tool-calls",
+ text: `Inspecting identity image candidates. ${"reason ".repeat(200)}`,
+ toolCalls: [
+ {
+ toolName: "searchSources",
+ toolCallId: "call_1",
+ input: {
+ query: "冯荣洲 身份证 ID card",
+ purpose: `Find the matching identity card image. ${"input ".repeat(
+ 300,
+ )}`,
+ targetContent: "image",
+ },
+ },
+ ],
+ toolResults: [
+ {
+ toolName: "searchSources",
+ toolCallId: "call_1",
+ output: fullStepToolOutput,
+ },
+ ],
+ usage: {
+ inputTokens: 11,
+ outputTokens: 22,
+ totalTokens: 33,
+ },
+ });
+
+ const stepMeta = getLoggerInfoMeta("chat-agent: llm response");
+ const stepLog = stepMeta as unknown as AgentLoopStepLogMeta;
+ expect(stepLog).toMatchObject({
+ operation: "generateAgenticGroundedAnswer.step",
+ model: "google/gemini-3-flash",
+ stepNumber: 1,
+ finishReason: "tool-calls",
+ toolCallCount: 1,
+ toolResultCount: 1,
+ inputTokens: 11,
+ outputTokens: 22,
+ totalTokens: 33,
+ });
+ expect(stepLog.responseText).toContain("Inspecting identity image candidates.");
+ expect(stepLog.toolCalls[0]?.input.truncated).toBe(true);
+ expect(stepLog.toolResults[0]?.output).toMatchObject({
+ kind: "searchSources",
+ output: {
+ truncated: false,
+ },
+ });
+ const searchSourcesOutput = stepLog.toolResults[0]
+ ?.output as SearchSourcesToolOutputLogMeta;
+ expect(searchSourcesOutput.output.preview.startsWith("\n## Retrieval Result"))
+ .toBe(true);
+ expect(searchSourcesOutput.output.preview.endsWith("\n ")).toBe(true);
+ expect(searchSourcesOutput.output.preview).toContain("## Retrieval Result");
+ expect(searchSourcesOutput.output.preview).toContain("\n\n## Evidence");
+ expect(searchSourcesOutput.output.preview).toContain(
+ "Query: 冯荣洲 身份证 ID card",
+ );
+ expect(searchSourcesOutput.output.preview).toContain(
+ "[media asset URL hidden]",
+ );
+ expect(searchSourcesOutput.output.preview).toContain(
+ fullStepToolOutputMarker,
+ );
+ expect(JSON.stringify(stepMeta)).not.toContain("https://blob.example");
+
+ settings.onFinish({
+ steps: [
+ {
+ stepNumber: 1,
+ finishReason: "tool-calls",
+ text: "Read tool result.",
+ toolCalls: [
+ {
+ toolName: "searchSources",
+ toolCallId: "call_1",
+ input: { query: "冯荣洲 身份证 ID card" },
+ },
+ ],
+ toolResults: [
+ {
+ toolName: "searchSources",
+ toolCallId: "call_1",
+ output: "## Evidence\nMatched image evidence.",
+ },
+ ],
+ },
+ ],
+ finishReason: "stop",
+ text: "Here is the matched identity card image.",
+ totalUsage: {
+ inputTokens: 40,
+ outputTokens: 20,
+ totalTokens: 60,
+ },
+ });
+
+ const finishMeta = getLoggerInfoMeta("chat-agent: loop finished");
+ expect(finishMeta).toMatchObject({
+ stepCount: 1,
+ finishReason: "stop",
+ responseText: "Here is the matched identity card image.",
+ toolNames: ["searchSources"],
+ steps: [
+ expect.objectContaining({
+ stepNumber: 1,
+ toolCallCount: 1,
+ toolResultCount: 1,
+ }),
+ ],
+ inputTokens: 40,
+ outputTokens: 20,
+ totalTokens: 60,
+ });
});
});
@@ -443,6 +1527,7 @@ describe("buildGroundedPrompt", () => {
});
expect(prompt).toContain("Answer in a natural, friendly, and direct tone.");
+ expect(prompt).toContain("Use GitHub-flavored Markdown when it improves readability");
expect(prompt).toContain("Start with the answer first.");
expect(prompt).toContain("Avoid meta phrases like \"Based on the sources\"");
expect(prompt).toContain("Keep answers concise by default");
@@ -468,10 +1553,42 @@ describe("buildGroundedPrompt", () => {
expect(prompt).toContain(
"Do not write raw media asset URLs in the answer. They are internal metadata only.",
);
+ expect(prompt).toContain("Never output JSON metadata blocks");
+ expect(prompt).toContain("Never mention asset_id, assetUrl");
+ expect(prompt).toContain("do not transcribe personal details");
expect(prompt).toContain("https://blob.example/images/launch.jpg");
});
});
+describe("buildAgenticChatSystemPrompt", () => {
+ it("instructs the agent how to continue or stop from retrieval responses", () => {
+ const prompt = buildAgenticChatSystemPrompt({
+ messages: [],
+ sources: [makeSource({ title: "商务标文件.pdf" })],
+ excludedSourceIds: [],
+ });
+
+ expect(prompt).toContain("Always call searchSources")
+ expect(prompt).toContain("Make a second searchSources call")
+ expect(prompt).toContain("readRetrievedChunk")
+ expect(prompt).toContain("markdown output gives guidance")
+ expect(prompt).toContain("Read IDs")
+ expect(prompt).toContain("image or text+image search")
+ expect(prompt).toContain("remote index")
+ expect(prompt).toContain("person or section but not an image asset")
+ expect(prompt).toContain("Do not paste raw prior messages")
+ expect(prompt).toContain("身份证")
+ expect(prompt).toContain("For image requests, search visual content directly")
+ expect(prompt).toContain("Never output JSON metadata blocks")
+ expect(prompt).toContain("Use GitHub-flavored Markdown when it improves readability")
+ expect(prompt).toContain("do not transcribe personal details")
+ expect(prompt).not.toContain("targetContent maps")
+ expect(prompt).not.toContain("Read the tool output fields")
+ expect(prompt).not.toContain("intent=overview")
+ expect(prompt).toContain("商务标文件.pdf")
+ });
+});
+
describe("buildRetrievalQueryPrompt", () => {
it("includes source and history context for stateless retrieval", () => {
const prompt = buildRetrievalQueryPrompt({
@@ -581,3 +1698,164 @@ function makeSource(overrides: Partial = {}): Source {
...overrides,
};
}
+
+type CapturedAgentSettings = {
+ readonly instructions: string
+ readonly prepareStep: (input: {
+ readonly stepNumber: number
+ readonly messages: ModelMessage[]
+ }) => unknown
+ readonly onStepFinish: (input: unknown) => void
+ readonly onFinish: (input: unknown) => void
+}
+
+type CapturedAgentTools = {
+ readonly searchSources: {
+ readonly inputSchema: {
+ readonly _def?: {
+ readonly type?: string
+ readonly shape?:
+ | Record
+ | (() => Record)
+ }
+ readonly safeParse: (value: unknown) => { readonly success: boolean }
+ }
+ readonly execute: (input: AgenticRetrievalQuery) => Promise
+ }
+ readonly readRetrievedChunk: {
+ readonly inputSchema: {
+ readonly safeParse: (value: unknown) => { readonly success: boolean }
+ }
+ readonly execute: (input: ReadRetrievedChunkInput) => Promise
+ }
+}
+
+type CapturedZodSchema = {
+ readonly _def?: {
+ readonly type?: string
+ readonly innerType?: CapturedZodSchema
+ }
+}
+
+type AgentLoopStepLogMeta = {
+ readonly operation: string
+ readonly model: string
+ readonly stepNumber: number
+ readonly finishReason: string
+ readonly responseText: string
+ readonly toolCallCount: number
+ readonly toolCalls: readonly {
+ readonly input: AgentLoopLogPreviewMeta
+ }[]
+ readonly toolResultCount: number
+ readonly toolResults: readonly {
+ readonly output: SearchSourcesToolOutputLogMeta | AgentLoopLogPreviewMeta
+ }[]
+ readonly inputTokens: number
+ readonly outputTokens: number
+ readonly totalTokens: number
+}
+
+type SearchSourcesToolOutputLogMeta = {
+ readonly kind: "searchSources"
+ readonly output: AgentLoopLogPreviewMeta
+}
+
+type AgentLoopLogPreviewMeta = {
+ readonly truncated: boolean
+ readonly preview: string
+}
+
+type KnowhereQueryResponseLogMeta = {
+ readonly query: string
+ readonly resultCount: number
+ readonly referencedChunkCount: number
+ readonly answerText: string
+ readonly evidenceText: string
+ readonly results: readonly {
+ readonly chunkType: string
+ readonly content: string
+ }[]
+ readonly referencedChunks: readonly {
+ readonly chunkType: string
+ readonly summary: string
+ }[]
+}
+
+function getCapturedAgent(agent: unknown): ToolLoopAgent {
+ expect(agent).toBeInstanceOf(ToolLoopAgent)
+ return agent as ToolLoopAgent
+}
+
+function getCapturedGenerateInput(
+ input: Parameters[0] | undefined,
+): { readonly messages: ModelMessage[] } {
+ expect(input).toBeDefined()
+ return input as { readonly messages: ModelMessage[] }
+}
+
+function getCapturedAgentSettings(agent: ToolLoopAgent): CapturedAgentSettings {
+ return (agent as unknown as { readonly settings: CapturedAgentSettings })
+ .settings
+}
+
+function getCapturedAgentTools(agent: ToolLoopAgent): CapturedAgentTools {
+ return agent.tools as unknown as CapturedAgentTools
+}
+
+function getTestModelMessagesCharLength(
+ messages: readonly ModelMessage[],
+): number {
+ return messages.reduce(
+ (totalLength, message): number =>
+ totalLength + getTestUnknownTextLength(message.content),
+ 0,
+ )
+}
+
+function getTestUnknownTextLength(value: unknown): number {
+ if (typeof value === "string") return value.length
+ if (Array.isArray(value)) {
+ return value.reduce(
+ (totalLength, nestedValue): number =>
+ totalLength + getTestUnknownTextLength(nestedValue),
+ 0,
+ )
+ }
+ if (!value || typeof value !== "object") return 0
+
+ return Object.values(value as Record).reduce(
+ (totalLength, nestedValue): number =>
+ totalLength + getTestUnknownTextLength(nestedValue),
+ 0,
+ )
+}
+
+function getSearchSourcesTargetContentSchema(
+ tool: CapturedAgentTools["searchSources"],
+): CapturedZodSchema {
+ const shape = tool.inputSchema._def?.shape
+ const fields = typeof shape === "function" ? shape() : shape
+ if (!fields) {
+ throw new Error("searchSources input schema should expose fields.")
+ }
+
+ const targetContentSchema = fields.targetContent
+ if (!targetContentSchema) {
+ throw new Error("searchSources input schema should include targetContent.")
+ }
+
+ return targetContentSchema
+}
+
+function getLoggerInfoMeta(message: string): Record {
+ const calls = loggerMock.info.mock.calls as unknown as readonly (readonly [
+ string,
+ Record | undefined,
+ ])[]
+ const call = calls.findLast(([currentMessage]) => currentMessage === message)
+ expect(call).toBeDefined()
+ const meta = call?.[1]
+ expect(meta).toBeDefined()
+ return meta ?? {}
+}
diff --git a/src/domains/chat/index.ts b/src/domains/chat/index.ts
index e84ec89..b764648 100644
--- a/src/domains/chat/index.ts
+++ b/src/domains/chat/index.ts
@@ -1,35 +1,111 @@
import { Effect } from "effect"
+import type {
+ RetrievalQueryParams,
+ RetrievalQueryResponse,
+ RetrievalResult,
+ RetrievalSource,
+} from "@ontos-ai/knowhere-sdk"
+import { logger } from "@/lib/logger"
import type { ChatCitationView } from "@/domains/chat/types"
import {
toChatCitationViews,
useNotebookSourceTitles,
} from "./citations"
-import type { AnswerQuestionInput, AnswerQuestionResult } from "./contracts"
+import type {
+ AgenticRetrievalQuery,
+ AgenticRetrievalPlan,
+ AgenticRetrievalTargetContent,
+ AgenticRetrievalResponse,
+ AnswerQuestionInput,
+ AnswerQuestionResult,
+ ReadRetrievedChunkInput,
+ ReadRetrievedChunkResult,
+ RetrievedChunkReference,
+} from "./contracts"
import {
excludeDocuments,
normalizeRetrievalQuery,
} from "./retrieval"
import {
enrichRetrievalResultsWithAssetUrls,
- formatRetrievedMediaAssetContext,
+ isImageAssetUrl,
removeRetrievedMediaAssetUrls,
} from "./media-assets"
const DEFAULT_TOP_K = 8
+const MAX_AGENTIC_TOP_K = 12
+const MAX_CITATION_RESULTS = 20
+const DEFAULT_CHUNK_READ_LIMIT = 4_000
+const MAX_CHUNK_READ_LIMIT = 8_000
+const KNOWHERE_RESPONSE_TEXT_LOG_LIMIT = 200
+const KNOWHERE_CHUNK_LOG_LIMIT = 100
const NO_RESULTS_ANSWER = "I couldn't find that in your sources."
+const RAW_URL_PATTERN = /https?:\/\/[^\s)\]}>"']+/g
+const REDACTED_MEDIA_URL = "[media asset URL hidden]"
+const RETRIEVAL_TARGET_CONTENT_DATA_TYPES: Readonly<
+ Record
+> = {
+ all: 1,
+ text: 2,
+ image: 3,
+ table: 4,
+ text_image: 5,
+ text_table: 6,
+} as const
+
+type RetrievalDataType = NonNullable
+
+type StoredRetrievedChunk = {
+ id: string
+ chunkId: string | null
+ kind: RetrievedChunkReference["kind"]
+ resultIndex: number | null
+ content: string
+ chunkType: string
+ score: number | null
+ source: RetrievalSource
+ hasAssetUrl: boolean
+}
+
+type KnowhereQueryResponseLog = {
+ readonly namespace: string
+ readonly query: string
+ readonly routerUsed: string | null | undefined
+ readonly stopReason: string | null | undefined
+ readonly failureReason: string | null | undefined
+ readonly resultCount: number
+ readonly referencedChunkCount: number
+ readonly answerText: string
+ readonly evidenceText: string
+ readonly results: readonly KnowhereResultChunkLog[]
+ readonly referencedChunks: readonly KnowhereReferencedChunkLog[]
+}
+
+type KnowhereResultChunkLog = {
+ readonly chunkType: string
+ readonly content: string
+}
+
+type KnowhereReferencedChunkLog = {
+ readonly chunkType: string
+ readonly summary: string
+}
export type {
AnswerQuestionInput,
AnswerQuestionResult,
ChatHistoryMessage,
GenerateAnswer,
- GenerateRetrievalQuery,
RetrievalClient,
+ SearchSources,
} from "./contracts"
export {
+ buildAgenticChatSystemPrompt,
buildGroundedPrompt,
buildRetrievalQueryPrompt,
+ generateAgenticGroundedAnswer,
+ generateAgenticGroundedAnswerEffect,
generateContextualRetrievalQuery,
generateContextualRetrievalQueryEffect,
generateGroundedAnswer,
@@ -46,51 +122,643 @@ export const answerQuestionWithRetrieval = (
): Effect.Effect =>
Effect.gen(function* () {
const question = input.question.trim()
- const generatedQuery = yield* Effect.tryPromise(() =>
- input.generateRetrievalQuery({
+ const retrievalResponses: RetrievalQueryResponse[] = []
+ const retrievedChunkContext = createRetrievedChunkContext()
+
+ logger.info("chat-agent: answer start", {
+ questionLength: question.length,
+ sourceCount: input.sources.length,
+ excludedSourceCount: input.excludedSourceIds.length,
+ messageCount: input.messages.length,
+ })
+
+ const searchSources = async (
+ queryInput: AgenticRetrievalQuery,
+ ): Promise => {
+ const startedAt = Date.now()
+ const retrievalPlan = toAgenticRetrievalPlan(queryInput)
+ const retrievalQueryParams = buildRetrievalQueryParams({
+ input: queryInput,
+ fallbackQuestion: question,
+ namespace: input.namespace,
+ sources: input.sources,
+ excludedSourceIds: input.excludedSourceIds,
+ })
+ logger.info("chat-agent: searchSources start", {
+ query: retrievalQueryParams.query,
+ topK: retrievalQueryParams.topK,
+ dataType: retrievalQueryParams.dataType ?? null,
+ signalPathCount: retrievalQueryParams.signalPaths?.length ?? 0,
+ filterMode: retrievalQueryParams.filterMode ?? null,
+ threshold: retrievalQueryParams.threshold ?? null,
+ targetContent: retrievalPlan.targetContent,
+ purpose: retrievalPlan.purpose,
+ })
+
+ try {
+ const response = await input.retrieval.query(retrievalQueryParams)
+ retrievalResponses.push(response)
+ const chunkReferences = retrievedChunkContext.registerResponse({
+ response,
+ responseIndex: retrievalResponses.length,
+ })
+ logger.info("chat-agent: searchSources ok", {
+ query: response.query,
+ durationMs: Date.now() - startedAt,
+ resultCount: response.results.length,
+ referencedChunkCount: response.referencedChunks.length,
+ readableChunkCount: chunkReferences.length,
+ truncatedChunkCount: chunkReferences.filter(
+ (reference): boolean => reference.contentTruncated,
+ ).length,
+ stopReason: response.stopReason ?? null,
+ failureReason: response.failureReason ?? null,
+ targetContent: retrievalPlan.targetContent,
+ })
+ logger.info("chat-agent: knowhere query response", {
+ durationMs: Date.now() - startedAt,
+ response: formatKnowhereQueryResponseForLog(response),
+ })
+ return { ...response, chunkReferences, retrievalPlan }
+ } catch (error) {
+ logger.error("chat-agent: searchSources failed", {
+ query: retrievalQueryParams.query,
+ durationMs: Date.now() - startedAt,
+ error: error instanceof Error ? error.message : String(error),
+ targetContent: retrievalPlan.targetContent,
+ })
+ throw error
+ }
+ }
+
+ const readRetrievedChunk = async (
+ readInput: ReadRetrievedChunkInput,
+ ): Promise => {
+ const result = retrievedChunkContext.read(readInput)
+ logger.info("chat-agent: readRetrievedChunk", {
+ id: result.id,
+ found: result.found,
+ offset: result.offset,
+ limit: result.limit,
+ contentLength: result.contentLength,
+ returnedLength: result.contentSlice.length,
+ hasMoreContent: result.hasMoreContent,
+ nextOffset: result.nextOffset,
+ })
+ return result
+ }
+
+ const generatedAnswer = yield* Effect.tryPromise(() =>
+ input.generateAnswer({
question,
messages: input.messages,
sources: input.sources,
excludedSourceIds: input.excludedSourceIds,
+ searchSources,
+ readRetrievedChunk,
}),
)
- const query = normalizeRetrievalQuery(generatedQuery, question)
- const response = yield* Effect.tryPromise(() =>
- input.retrieval.query({
- namespace: input.namespace,
- query,
- topK: DEFAULT_TOP_K,
- useAgentic: true,
- ...excludeDocuments(input.sources, input.excludedSourceIds),
- }),
- )
- const evidenceText = response.evidenceText ?? ""
- if (response.results.length === 0 && !evidenceText) {
+ logger.info("chat-agent: answer generated", {
+ answerLength: generatedAnswer.length,
+ retrievalCallCount: retrievalResponses.length,
+ registeredChunkCount: retrievedChunkContext.size(),
+ })
+
+ const rawResults = collectRetrievalResults(retrievalResponses, input.sources)
+ if (rawResults.length === 0 && generatedAnswer.trim().length === 0) {
return { answer: NO_RESULTS_ANSWER, citations: [] as ChatCitationView[] }
}
const results = yield* Effect.tryPromise(() =>
enrichRetrievalResultsWithAssetUrls({
- results: useNotebookSourceTitles(response.results, input.sources),
+ results: useNotebookSourceTitles(rawResults, input.sources),
sources: input.sources,
loadSourceAssetUrls: input.loadSourceAssetUrls,
+ evidenceText: formatRetrievalEvidenceText(retrievalResponses),
}),
)
- const mediaAssetContext = formatRetrievedMediaAssetContext(results)
- const generateAnswerInput = {
+ const answer = sanitizeGeneratedAnswer({
+ answer: generatedAnswer,
question,
- retrievalQuery: query,
- messages: input.messages,
- evidenceText,
- ...(mediaAssetContext ? { mediaAssetContext } : {}),
- }
- const generatedAnswer = yield* Effect.tryPromise(() =>
- input.generateAnswer(generateAnswerInput),
- )
- const answer = removeRetrievedMediaAssetUrls(generatedAnswer, results)
+ results,
+ })
+ const citationResults = selectCitationResultsForAnswer({
+ question,
+ results,
+ })
+ logger.info("chat-agent: answer complete", {
+ answerLength: answer.length,
+ citationCount: citationResults.length,
+ })
return {
answer,
- citations: toChatCitationViews(results, answer),
+ citations: toChatCitationViews(citationResults, answer),
}
})
+
+type GeneratedAnswerSanitizerInput = {
+ readonly answer: string
+ readonly question: string
+ readonly results: readonly RetrievalResult[]
+}
+
+function sanitizeGeneratedAnswer({
+ answer,
+ question,
+ results,
+}: GeneratedAnswerSanitizerInput): string {
+ const sanitizedAnswer = removeRetrievedMediaAssetUrls(answer, results)
+
+ if (
+ shouldUseConciseImageRequestAnswer({
+ answer: sanitizedAnswer,
+ question,
+ results,
+ })
+ ) {
+ return buildConciseImageRequestAnswer(question)
+ }
+
+ return sanitizedAnswer
+}
+
+function shouldUseConciseImageRequestAnswer({
+ answer,
+ question,
+ results,
+}: GeneratedAnswerSanitizerInput): boolean {
+ return (
+ isShowOrSendImageRequest(question) &&
+ !isExplicitPersonalDetailRequest(question) &&
+ hasImageCitationResult(results) &&
+ shouldSimplifyImageRequestAnswer(answer)
+ )
+}
+
+function selectCitationResultsForAnswer(input: {
+ readonly question: string
+ readonly results: readonly RetrievalResult[]
+}): readonly RetrievalResult[] {
+ if (!isShowOrSendImageRequest(input.question)) return input.results
+
+ const imageResults = input.results.filter(isImageCitationResult)
+ if (imageResults.length === 0) return input.results
+
+ const focusedImageResults = filterFocusedImageCitationResults(
+ input.question,
+ imageResults,
+ )
+ return focusedImageResults.length > 0 ? focusedImageResults : imageResults
+}
+
+function hasImageCitationResult(results: readonly RetrievalResult[]): boolean {
+ return results.some(isImageCitationResult)
+}
+
+function isImageCitationResult(result: RetrievalResult): boolean {
+ const assetUrl = result.assetUrl?.trim()
+ if (!assetUrl) return false
+
+ return result.chunkType.toLowerCase() === "image" || isImageAssetUrl(assetUrl)
+}
+
+function filterFocusedImageCitationResults(
+ question: string,
+ results: readonly RetrievalResult[],
+): readonly RetrievalResult[] {
+ const labelPattern = getFocusedImageCitationLabelPattern(question)
+ if (!labelPattern) return results
+
+ return results.filter((result): boolean =>
+ labelPattern.test(getImageCitationLabel(result)),
+ )
+}
+
+function getFocusedImageCitationLabelPattern(question: string): RegExp | null {
+ if (/身份证|公民身份|居民身份证|\bid card\b|\bidentity card\b/iu.test(question)) {
+ return /身份证|居民身份证|\bid card\b|\bidentity card\b/iu
+ }
+
+ return null
+}
+
+function getImageCitationLabel(result: RetrievalResult): string {
+ return [
+ result.source.sourceFileName,
+ result.source.sectionPath,
+ getAssetPathFromCitationUrl(result.assetUrl),
+ ]
+ .filter((value): value is string => Boolean(value?.trim()))
+ .join(" ")
+}
+
+function getAssetPathFromCitationUrl(assetUrl: string | undefined): string | null {
+ if (!assetUrl) return null
+
+ try {
+ return decodeURIComponent(new URL(assetUrl).pathname)
+ } catch {
+ return assetUrl
+ }
+}
+
+function isShowOrSendImageRequest(question: string): boolean {
+ const normalizedQuestion = question.toLowerCase()
+ const hasImageTerm =
+ /图片|照片|图像|截图|身份证|\bimage\b|\bimages\b|\bphoto\b|\bphotos\b|\bpicture\b|\bpictures\b|\bscreenshot\b|\bid card\b|\bidentity card\b/u.test(
+ normalizedQuestion,
+ )
+ const hasActionTerm =
+ /请将|请把|发送|发给我|发来|给我看|展示|显示|看一下|\bshow\b|\bsend\b|\bdisplay\b|\battach\b|\bgive me\b/u.test(
+ normalizedQuestion,
+ )
+
+ return hasImageTerm && hasActionTerm
+}
+
+function isExplicitPersonalDetailRequest(question: string): boolean {
+ return /号码|身份证号|身份号码|住址|地址|出生|有效期限|签发机关|姓名|是什么|多少|\bid number\b|\bidentity number\b|\baddress\b|\bbirth\b|\bissuer\b|\bvalid/u.test(
+ question.toLowerCase(),
+ )
+}
+
+function containsPersonalDetailField(answer: string): boolean {
+ return /公民身份号码|身份号码|身份证号|身份证号码|住址|地址|出生日期|出生|有效期限|签发机关|性别|民族|姓名|\bid number\b|\bidentity number\b|\baddress\b|\bdate of birth\b|\bbirth date\b|\bissuer\b|\bissuing authority\b|\bvalid until\b|\bvalid through\b/i.test(
+ answer,
+ )
+}
+
+function shouldSimplifyImageRequestAnswer(answer: string): boolean {
+ const trimmedAnswer = answer.trim()
+ return (
+ containsPersonalDetailField(trimmedAnswer) ||
+ containsMarkdownList(trimmedAnswer) ||
+ containsSourceIndexReference(trimmedAnswer) ||
+ trimmedAnswer.length > getConciseImageAnswerLengthLimit(trimmedAnswer)
+ )
+}
+
+function containsMarkdownList(value: string): boolean {
+ return /\n\s*[-*]\s+/u.test(value)
+}
+
+function containsSourceIndexReference(value: string): boolean {
+ return /\bSource\s+\d+\b/iu.test(value)
+}
+
+function getConciseImageAnswerLengthLimit(answer: string): number {
+ return containsCjkText(answer) ? 120 : 220
+}
+
+function buildConciseImageRequestAnswer(question: string): string {
+ if (containsCjkText(question)) {
+ return question.includes("身份证")
+ ? "已找到相关身份证图片,见下方图片。"
+ : "已找到相关图片,见下方图片。"
+ }
+
+ return "I found the relevant image. See the image below."
+}
+
+function containsCjkText(value: string): boolean {
+ return /[\u3400-\u9fff]/u.test(value)
+}
+
+function formatKnowhereQueryResponseForLog(
+ response: RetrievalQueryResponse,
+): KnowhereQueryResponseLog {
+ return {
+ namespace: response.namespace,
+ query: response.query,
+ routerUsed: response.routerUsed,
+ stopReason: response.stopReason,
+ failureReason: response.failureReason,
+ resultCount: response.results.length,
+ referencedChunkCount: response.referencedChunks.length,
+ answerText: truncateLogText(
+ response.answerText ?? "",
+ KNOWHERE_RESPONSE_TEXT_LOG_LIMIT,
+ ),
+ evidenceText: truncateLogText(
+ response.evidenceText ?? "",
+ KNOWHERE_RESPONSE_TEXT_LOG_LIMIT,
+ ),
+ results: response.results.map(formatKnowhereResultChunkForLog),
+ referencedChunks: response.referencedChunks.map(
+ formatKnowhereReferencedChunkForLog,
+ ),
+ }
+}
+
+function formatKnowhereResultChunkForLog(
+ result: RetrievalResult,
+): KnowhereResultChunkLog {
+ return {
+ chunkType: result.chunkType,
+ content: truncateLogText(result.content, KNOWHERE_CHUNK_LOG_LIMIT),
+ }
+}
+
+function formatKnowhereReferencedChunkForLog(
+ chunk: RetrievalQueryResponse["referencedChunks"][number],
+): KnowhereReferencedChunkLog {
+ return {
+ chunkType: chunk.chunkType,
+ summary: truncateLogText(
+ chunk.sectionPath || chunk.filePath || chunk.chunkId,
+ KNOWHERE_CHUNK_LOG_LIMIT,
+ ),
+ }
+}
+
+function truncateLogText(value: string, limit: number): string {
+ const normalized = redactRawUrls(value).replace(/\s+/g, " ").trim()
+ if (normalized.length <= limit) return normalized
+ return `${normalized.slice(0, limit)}...`
+}
+
+function redactRawUrls(value: string): string {
+ return value.replace(RAW_URL_PATTERN, REDACTED_MEDIA_URL)
+}
+
+function buildRetrievalQueryParams(input: {
+ readonly input: AgenticRetrievalQuery
+ readonly fallbackQuestion: string
+ readonly namespace: string
+ readonly sources: AnswerQuestionInput["sources"]
+ readonly excludedSourceIds: readonly string[]
+}): RetrievalQueryParams {
+ const query = normalizeRetrievalQuery(
+ input.input.query,
+ input.fallbackQuestion,
+ )
+ const dataType = normalizeRetrievalDataType(input.input.targetContent)
+ return {
+ namespace: input.namespace,
+ query,
+ topK: normalizeTopK(input.input.topK),
+ useAgentic: true,
+ dataType,
+ ...(input.input.signalPaths && input.input.signalPaths.length > 0
+ ? { signalPaths: input.input.signalPaths }
+ : {}),
+ ...(input.input.filterMode ? { filterMode: input.input.filterMode } : {}),
+ ...(typeof input.input.threshold === "number"
+ ? { threshold: input.input.threshold }
+ : {}),
+ ...excludeDocuments(input.sources, input.excludedSourceIds),
+ }
+}
+
+function toAgenticRetrievalPlan(
+ input: AgenticRetrievalQuery,
+): AgenticRetrievalPlan {
+ return {
+ targetContent: normalizeRetrievalTargetContent(input.targetContent),
+ purpose: normalizeRetrievalPurpose(input.purpose),
+ }
+}
+
+function normalizeRetrievalPurpose(value: string | undefined): string | null {
+ const normalized = value?.replace(/\s+/g, " ").trim()
+ if (!normalized) return null
+ return normalized.slice(0, 240)
+}
+
+function normalizeRetrievalDataType(
+ targetContent: AgenticRetrievalTargetContent | undefined,
+): RetrievalDataType {
+ return RETRIEVAL_TARGET_CONTENT_DATA_TYPES[
+ normalizeRetrievalTargetContent(targetContent)
+ ]
+}
+
+function normalizeRetrievalTargetContent(
+ value: AgenticRetrievalTargetContent | undefined,
+): AgenticRetrievalTargetContent {
+ return value ?? "all"
+}
+
+function createRetrievedChunkContext(): {
+ registerResponse(input: {
+ readonly response: RetrievalQueryResponse
+ readonly responseIndex: number
+ }): readonly RetrievedChunkReference[]
+ read(input: ReadRetrievedChunkInput): ReadRetrievedChunkResult
+ size(): number
+} {
+ const chunksById = new Map()
+
+ function storeChunk(chunk: StoredRetrievedChunk): void {
+ chunksById.set(chunk.id, chunk)
+ if (chunk.chunkId && chunk.chunkId !== chunk.id) {
+ chunksById.set(chunk.chunkId, chunk)
+ }
+ }
+
+ return {
+ registerResponse(input): readonly RetrievedChunkReference[] {
+ const references: RetrievedChunkReference[] = []
+ input.response.results.forEach((result, index): void => {
+ const resultIndex = index + 1
+ const chunkId = getRetrievalResultChunkId(result)
+ const id = chunkId ?? `search_${input.responseIndex}_result_${resultIndex}`
+ const storedChunk: StoredRetrievedChunk = {
+ id,
+ chunkId,
+ kind: "result",
+ resultIndex,
+ content: result.content,
+ chunkType: result.chunkType,
+ score: result.score,
+ source: result.source,
+ hasAssetUrl: Boolean(result.assetUrl),
+ }
+ storeChunk(storedChunk)
+ references.push(toRetrievedChunkReference(storedChunk))
+ })
+
+ input.response.referencedChunks.forEach((chunk, index): void => {
+ const id = chunk.chunkId || `search_${input.responseIndex}_reference_${index + 1}`
+ const existingChunk = chunksById.get(id)
+ if (existingChunk) {
+ references.push(toRetrievedChunkReference(existingChunk))
+ return
+ }
+
+ const storedChunk: StoredRetrievedChunk = {
+ id,
+ chunkId: chunk.chunkId || null,
+ kind: "referencedChunk",
+ resultIndex: null,
+ content: "",
+ chunkType: chunk.chunkType,
+ score: null,
+ source: {
+ documentId: chunk.documentId,
+ sourceFileName: null,
+ sectionPath: chunk.sectionPath,
+ },
+ hasAssetUrl: Boolean(chunk.assetUrl),
+ }
+ storeChunk(storedChunk)
+ references.push(toRetrievedChunkReference(storedChunk))
+ })
+
+ return references
+ },
+ read(input): ReadRetrievedChunkResult {
+ const offset = normalizeChunkReadOffset(input.offset)
+ const limit = normalizeChunkReadLimit(input.limit)
+ const chunk = chunksById.get(input.id)
+ if (!chunk) {
+ return {
+ id: input.id,
+ chunkId: null,
+ found: false,
+ chunkType: null,
+ score: null,
+ source: null,
+ hasAssetUrl: false,
+ offset,
+ limit,
+ contentLength: 0,
+ contentSlice: "",
+ hasMoreContent: false,
+ nextOffset: null,
+ }
+ }
+
+ const boundedOffset = Math.min(offset, chunk.content.length)
+ const endOffset = Math.min(boundedOffset + limit, chunk.content.length)
+ return {
+ id: chunk.id,
+ chunkId: chunk.chunkId,
+ found: true,
+ chunkType: chunk.chunkType,
+ score: chunk.score,
+ source: chunk.source,
+ hasAssetUrl: chunk.hasAssetUrl,
+ offset: boundedOffset,
+ limit,
+ contentLength: chunk.content.length,
+ contentSlice: chunk.content.slice(boundedOffset, endOffset),
+ hasMoreContent: endOffset < chunk.content.length,
+ nextOffset: endOffset < chunk.content.length ? endOffset : null,
+ }
+ },
+ size(): number {
+ return chunksById.size
+ },
+ }
+}
+
+function toRetrievedChunkReference(
+ chunk: StoredRetrievedChunk,
+): RetrievedChunkReference {
+ const contentPreview = chunk.content.slice(0, DEFAULT_CHUNK_READ_LIMIT)
+ return {
+ id: chunk.id,
+ chunkId: chunk.chunkId,
+ kind: chunk.kind,
+ resultIndex: chunk.resultIndex,
+ chunkType: chunk.chunkType,
+ score: chunk.score,
+ source: chunk.source,
+ hasAssetUrl: chunk.hasAssetUrl,
+ contentLength: chunk.content.length,
+ contentPreview,
+ contentTruncated: contentPreview.length < chunk.content.length,
+ }
+}
+
+function getRetrievalResultChunkId(result: RetrievalResult): string | null {
+ const resultWithChunkId = result as RetrievalResult & {
+ readonly chunkId?: string | null
+ }
+ return resultWithChunkId.chunkId?.trim() || null
+}
+
+function normalizeChunkReadOffset(value: number | undefined): number {
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) return 0
+ return Math.max(value, 0)
+}
+
+function normalizeChunkReadLimit(value: number | undefined): number {
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) {
+ return DEFAULT_CHUNK_READ_LIMIT
+ }
+ return Math.min(Math.max(value, 1), MAX_CHUNK_READ_LIMIT)
+}
+
+function normalizeTopK(value: number | undefined): number {
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) {
+ return DEFAULT_TOP_K
+ }
+ return Math.min(Math.max(value, 1), MAX_AGENTIC_TOP_K)
+}
+
+function collectRetrievalResults(
+ responses: readonly RetrievalQueryResponse[],
+ sources: readonly AnswerQuestionInput["sources"][number][],
+): RetrievalResult[] {
+ const results: RetrievalResult[] = []
+ const seenKeys = new Set()
+ const sourceTitlesByDocumentId = new Map(
+ sources.flatMap((source): readonly [string, string][] =>
+ source.knowhereDocumentId ? [[source.knowhereDocumentId, source.title]] : [],
+ ),
+ )
+
+ for (const response of responses) {
+ for (const result of [
+ ...response.results,
+ ...response.referencedChunks.map((chunk): RetrievalResult => ({
+ content: "",
+ chunkType: chunk.chunkType,
+ score: null,
+ ...(chunk.assetUrl ? { assetUrl: chunk.assetUrl } : {}),
+ source: {
+ documentId: chunk.documentId,
+ sourceFileName: sourceTitlesByDocumentId.get(chunk.documentId),
+ sectionPath: chunk.sectionPath,
+ },
+ })),
+ ]) {
+ const key = getRetrievalResultKey(result)
+ if (seenKeys.has(key)) continue
+
+ seenKeys.add(key)
+ results.push(result)
+ if (results.length >= MAX_CITATION_RESULTS) return results
+ }
+ }
+
+ return results
+}
+
+function formatRetrievalEvidenceText(
+ responses: readonly RetrievalQueryResponse[],
+): string | undefined {
+ const evidenceText = responses
+ .map((response): string => response.evidenceText?.trim() ?? "")
+ .filter((value): boolean => value.length > 0)
+ .join("\n")
+
+ return evidenceText || undefined
+}
+
+function getRetrievalResultKey(result: RetrievalResult): string {
+ const source = result.source
+ return [
+ source.documentId ?? "",
+ source.sourceFileName ?? "",
+ source.sectionPath ?? "",
+ result.chunkType,
+ result.assetUrl ?? "",
+ result.content.slice(0, 500),
+ ]
+ .map((part) => `${part.length}:${part}`)
+ .join("|")
+}
diff --git a/src/domains/chat/media-assets.test.ts b/src/domains/chat/media-assets.test.ts
index 95d9398..ceded86 100644
--- a/src/domains/chat/media-assets.test.ts
+++ b/src/domains/chat/media-assets.test.ts
@@ -42,6 +42,131 @@ describe("chat media assets", () => {
)
})
+ it("adds image citation results for asset filenames that only appear in evidence text", async () => {
+ const loadSourceAssetUrls = vi.fn().mockResolvedValue({
+ "images/image-6-中华人民共和国居民身份证.jpg":
+ "https://blob.example/images/image-6-id-front.jpg",
+ "images/image-7-中国居民身份证.jpg":
+ "https://blob.example/images/image-7-id-back.jpg",
+ })
+
+ const results = await enrichRetrievalResultsWithAssetUrls({
+ results: [
+ makeRetrievalResult({
+ content: "The section contains citizen identity proof copies.",
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "商务标文件.pdf",
+ sectionPath: "二、法定代表人身份证明",
+ },
+ }),
+ ],
+ sources: [
+ makeSource({
+ id: "source_identity",
+ title: "商务标文件.pdf",
+ knowhereDocumentId: "doc_identity",
+ }),
+ ],
+ loadSourceAssetUrls,
+ evidenceText:
+ "[image-6-中华人民共和国居民身份证.jpg]\n[image-7-中国居民身份证.jpg]",
+ })
+
+ expect(results).toHaveLength(3)
+ expect(results[0]?.assetUrl).toBeUndefined()
+ expect(results.slice(1).map((result) => result.assetUrl)).toEqual([
+ "https://blob.example/images/image-6-id-front.jpg",
+ "https://blob.example/images/image-7-id-back.jpg",
+ ])
+ expect(results.slice(1).map((result) => result.chunkType)).toEqual([
+ "image",
+ "image",
+ ])
+ expect(results.slice(1).map((result) => result.source.sectionPath)).toEqual([
+ "images/image-6-中华人民共和国居民身份证.jpg",
+ "images/image-7-中国居民身份证.jpg",
+ ])
+ })
+
+ it("deduplicates media citation assets globally by asset URL", async () => {
+ const assetUrl = "https://blob.example/images/id-front.jpg"
+
+ const results = await enrichRetrievalResultsWithAssetUrls({
+ results: [
+ makeRetrievalResult({
+ chunkType: "image",
+ assetUrl,
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "商务标文件.pdf",
+ sectionPath: "images/id-front.jpg",
+ },
+ }),
+ makeRetrievalResult({
+ chunkType: "image",
+ assetUrl,
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "商务标文件.pdf",
+ sectionPath: "二、法定代表人身份证明 / 身份证正面",
+ },
+ }),
+ makeRetrievalResult({
+ chunkType: "image",
+ assetUrl: "https://blob.example/images/id-back.jpg",
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "商务标文件.pdf",
+ sectionPath: "二、法定代表人身份证明 / 身份证反面",
+ },
+ }),
+ ],
+ sources: [],
+ })
+
+ expect(results.map((result) => result.assetUrl)).toEqual([
+ assetUrl,
+ "https://blob.example/images/id-back.jpg",
+ ])
+ expect(results[0]?.source.sectionPath).toBe(
+ "二、法定代表人身份证明 / 身份证正面",
+ )
+ })
+
+ it("deduplicates equivalent media assets served from different URLs", async () => {
+ const results = await enrichRetrievalResultsWithAssetUrls({
+ results: [
+ makeRetrievalResult({
+ chunkType: "image",
+ assetUrl:
+ "https://knowhere-storage.example/results/job_1/images/id-front.jpg?AWSAccessKeyId=test",
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "商务标文件.pdf",
+ sectionPath: "Root",
+ },
+ }),
+ makeRetrievalResult({
+ chunkType: "image",
+ assetUrl:
+ "https://blob.example/workspaces/workspace_1/sources/source_1/parsed-result/images/id-front.jpg",
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "商务标文件.pdf",
+ sectionPath: "images/id-front.jpg",
+ },
+ }),
+ ],
+ sources: [],
+ })
+
+ expect(results.map((result) => result.assetUrl)).toEqual([
+ "https://blob.example/workspaces/workspace_1/sources/source_1/parsed-result/images/id-front.jpg",
+ ])
+ expect(results[0]?.source.sectionPath).toBe("images/id-front.jpg")
+ })
+
it("formats a bounded media asset context for the grounded prompt", () => {
const context = formatRetrievedMediaAssetContext([
makeRetrievalResult({
@@ -86,6 +211,33 @@ describe("chat media assets", () => {
)
expect(answer).not.toContain("https://blob.example")
})
+
+ it("removes internal media JSON blocks from generated answer text", () => {
+ const answer = removeRetrievedMediaAssetUrls(
+ [
+ "这里是相关身份证图片。",
+ "{\"asset_id\":\"asset_front\",\"assetUrl\":\"https://blob.example/images/id-front.jpg\",\"chunk_id\":\"chunk_front\"}",
+ ].join("\n"),
+ [
+ makeRetrievalResult({
+ chunkType: "image",
+ assetUrl: "https://blob.example/images/id-front.jpg",
+ }),
+ ],
+ )
+
+ expect(answer).toBe("这里是相关身份证图片。")
+ expect(answer).not.toMatch(/asset_id|assetUrl|chunk_id|https?:\/\//)
+ })
+
+ it("preserves ordinary JSON answers that do not expose internal metadata", () => {
+ const answer = removeRetrievedMediaAssetUrls(
+ "{\"name\":\"冯荣洲\",\"status\":\"matched\"}",
+ [],
+ )
+
+ expect(answer).toBe("{\"name\":\"冯荣洲\",\"status\":\"matched\"}")
+ })
})
function makeRetrievalResult(
diff --git a/src/domains/chat/media-assets.ts b/src/domains/chat/media-assets.ts
index 00b05f5..e87c655 100644
--- a/src/domains/chat/media-assets.ts
+++ b/src/domains/chat/media-assets.ts
@@ -4,6 +4,13 @@ import type { Source } from "@/infrastructure/db/schema"
const retrievedMediaAssetLimit = 6
const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"] as const
+const internalMetadataKeys = new Set([
+ "asset_id",
+ "assetUrl",
+ "asset_url",
+ "chunkId",
+ "chunk_id",
+])
export type LoadSourceAssetUrls = (
source: Source,
@@ -13,14 +20,18 @@ export type RetrievalResultAssetInput = {
readonly results: readonly RetrievalResult[]
readonly sources: readonly Source[]
readonly loadSourceAssetUrls?: LoadSourceAssetUrls
+ readonly evidenceText?: string
}
export async function enrichRetrievalResultsWithAssetUrls({
results,
sources,
loadSourceAssetUrls,
+ evidenceText,
}: RetrievalResultAssetInput): Promise {
- if (!loadSourceAssetUrls || results.length === 0) return [...results]
+ if (!loadSourceAssetUrls || results.length === 0) {
+ return dedupeMediaCitationResults(results)
+ }
const sourcesByDocumentId = new Map(
sources.flatMap((source): readonly [string, Source][] =>
@@ -32,23 +43,62 @@ export async function enrichRetrievalResultsWithAssetUrls({
Promise>>
>()
- return Promise.all(
- results.map(async (result): Promise => {
- if (getTrimmedString(result.assetUrl)) return result
-
+ const enrichedResults = await Promise.all(
+ results.map(async (result): Promise => {
const documentId = getTrimmedString(result.source.documentId)
const source = documentId ? sourcesByDocumentId.get(documentId) : undefined
- if (!source) return result
+ if (!source) return [result]
const assetUrls = await getCachedSourceAssetUrls(
source,
loadSourceAssetUrls,
assetUrlsBySourceId,
)
- const assetUrl = resolveResultAssetUrl(result, assetUrls)
- return assetUrl ? { ...result, assetUrl } : result
+ return addAssetCitationResults(result, assetUrls, evidenceText)
}),
)
+
+ return dedupeMediaCitationResults(enrichedResults.flat())
+}
+
+export function dedupeMediaCitationResults(
+ results: readonly RetrievalResult[],
+): RetrievalResult[] {
+ const dedupedResults: RetrievalResult[] = []
+ const resultIndexesByAssetKey = new Map()
+
+ for (const result of results) {
+ const assetUrl = getTrimmedString(result.assetUrl)
+ if (!assetUrl || !isRenderableMediaAsset(result, assetUrl)) {
+ dedupedResults.push(result)
+ continue
+ }
+
+ const assetKey = getMediaCitationDedupeKey(result, assetUrl)
+ const existingIndex = resultIndexesByAssetKey.get(assetKey)
+ if (existingIndex === undefined) {
+ resultIndexesByAssetKey.set(assetKey, dedupedResults.length)
+ dedupedResults.push(result)
+ continue
+ }
+
+ const existingResult = dedupedResults[existingIndex]
+ const existingAssetUrl = getTrimmedString(existingResult?.assetUrl)
+ if (
+ existingResult &&
+ existingAssetUrl &&
+ compareMediaCitationResult(
+ result,
+ existingResult,
+ assetUrl,
+ existingAssetUrl,
+ ) > 0
+ ) {
+ dedupedResults[existingIndex] = result
+ }
+ }
+
+ return dedupedResults
}
export function formatRetrievedMediaAssetContext(
@@ -89,15 +139,101 @@ export function removeRetrievedMediaAssetUrls(
.filter((assetUrl): assetUrl is string => assetUrl !== null),
),
)
- if (assetUrls.length === 0) return answer
-
- const sanitizedAnswer = assetUrls
- .flatMap(getAssetUrlTextVariants)
- .reduce(removeAssetUrlFromAnswer, answer)
+ const urlSanitizedAnswer =
+ assetUrls.length > 0
+ ? assetUrls
+ .flatMap(getAssetUrlTextVariants)
+ .reduce(removeAssetUrlFromAnswer, answer)
+ : answer
+ const sanitizedAnswer = removeInternalMetadataJsonBlocks(urlSanitizedAnswer)
return cleanSanitizedAnswer(sanitizedAnswer)
}
+function compareMediaCitationResult(
+ candidate: RetrievalResult,
+ current: RetrievalResult,
+ candidateAssetUrl: string,
+ currentAssetUrl: string,
+): number {
+ return (
+ getMediaCitationResultScore(candidate, candidateAssetUrl) -
+ getMediaCitationResultScore(current, currentAssetUrl)
+ )
+}
+
+function getMediaCitationResultScore(
+ result: RetrievalResult,
+ assetUrl: string,
+): number {
+ const chunkType = result.chunkType.toLowerCase()
+ const isImageAsset = isImageAssetUrl(assetUrl)
+ const isTableAsset = chunkType === "table"
+ const source = result.source
+ let score = 0
+
+ if (chunkType === "image" && isImageAsset) score += 100
+ if (isTableAsset) score += 90
+ if (chunkType === "image" || chunkType === "table") score += 30
+ if (getTrimmedString(source.documentId)) score += 10
+ if (getTrimmedString(source.sourceFileName)) score += 20
+
+ const sectionPath = getTrimmedString(source.sectionPath)
+ if (sectionPath && !isGenericSectionPath(sectionPath)) {
+ score += 30
+ if (!isAssetFilePath(sectionPath)) score += 15
+ score += Math.min(sectionPath.length, 120) / 12
+ }
+ if (isNotebookParsedAssetUrl(assetUrl)) score += 25
+
+ return score
+}
+
+function getMediaCitationDedupeKey(
+ result: RetrievalResult,
+ assetUrl: string,
+): string {
+ const documentKey =
+ getTrimmedString(result.source.documentId) ??
+ getTrimmedString(result.source.sourceFileName) ??
+ "unknown"
+ const assetPath =
+ getCanonicalAssetPathFromSource(result) ?? getCanonicalAssetPathFromUrl(assetUrl)
+
+ return assetPath ? `asset:${documentKey}:${assetPath}` : `url:${assetUrl}`
+}
+
+function getCanonicalAssetPathFromSource(
+ result: RetrievalResult,
+): string | null {
+ const sectionPath = normalizeAssetLookupText(result.source.sectionPath)
+ return sectionPath && isSupportedAssetPath(sectionPath) ? sectionPath : null
+}
+
+function getCanonicalAssetPathFromUrl(assetUrl: string): string | null {
+ const normalizedPath = normalizeAssetLookupText(getUrlPathname(assetUrl))
+ if (!normalizedPath) return null
+
+ const pathMatch = /(?:^|\/)((?:images|tables)\/[^?#]+)$/.exec(normalizedPath)
+ if (pathMatch?.[1]) return pathMatch[1]
+
+ const basename = getNormalizedBasename(normalizedPath)
+ if (!basename) return null
+
+ if (isImageAssetUrl(assetUrl)) return `images/${basename}`
+ return null
+}
+
+function isGenericSectionPath(value: string): boolean {
+ return ["root", "unknown source"].includes(value.trim().toLowerCase())
+}
+
+function isNotebookParsedAssetUrl(assetUrl: string): boolean {
+ return normalizeAssetLookupText(getUrlPathname(assetUrl))?.includes(
+ "/parsed-result/",
+ ) === true
+}
+
async function getCachedSourceAssetUrls(
source: Source,
loadSourceAssetUrls: LoadSourceAssetUrls,
@@ -111,10 +247,109 @@ async function getCachedSourceAssetUrls(
return cached
}
-function resolveResultAssetUrl(
+function addAssetCitationResults(
result: RetrievalResult,
assetUrlsByFilePath: Readonly>,
-): string | null {
+ evidenceText: string | undefined,
+): readonly RetrievalResult[] {
+ const existingAssetUrl = getTrimmedString(result.assetUrl)
+ const resultMatches = resolveAssetReferenceMatches(result, assetUrlsByFilePath)
+ const evidenceMatches = resolveAssetReferenceMatchesFromText(
+ evidenceText,
+ assetUrlsByFilePath,
+ )
+ const seenAssetUrls = new Set()
+ const output: RetrievalResult[] = []
+
+ if (existingAssetUrl) {
+ seenAssetUrls.add(existingAssetUrl)
+ output.push(result)
+ } else if (resultMatches.length > 0) {
+ const [firstMatch, ...remainingMatches] = resultMatches
+ seenAssetUrls.add(firstMatch.assetUrl)
+ output.push(toAssetResult(result, firstMatch))
+ for (const match of remainingMatches) {
+ if (seenAssetUrls.has(match.assetUrl)) continue
+ seenAssetUrls.add(match.assetUrl)
+ output.push(toAssetResult(result, match))
+ }
+ } else {
+ output.push(result)
+ }
+
+ for (const match of evidenceMatches) {
+ if (seenAssetUrls.has(match.assetUrl)) continue
+ seenAssetUrls.add(match.assetUrl)
+ output.push(toAssetResult(result, match))
+ }
+
+ return output
+}
+
+function toAssetResult(
+ result: RetrievalResult,
+ match: AssetReferenceMatch,
+): RetrievalResult {
+ return {
+ ...result,
+ assetUrl: match.assetUrl,
+ chunkType: getAssetChunkType(match, result.chunkType),
+ source: {
+ ...result.source,
+ sectionPath: getAssetSectionPath(result, match.assetPath),
+ },
+ }
+}
+
+function getAssetSectionPath(
+ result: RetrievalResult,
+ assetPath: string,
+): string | null | undefined {
+ const sectionPath = getTrimmedString(result.source.sectionPath)
+ if (!sectionPath) return assetPath
+
+ const normalizedSectionPath = normalizeAssetLookupText(sectionPath)
+ const normalizedAssetPath = normalizeAssetLookupText(assetPath)
+ const assetBasename = getNormalizedBasename(assetPath)
+ if (
+ normalizedAssetPath &&
+ normalizedSectionPath?.includes(normalizedAssetPath)
+ ) {
+ return sectionPath
+ }
+ if (assetBasename && normalizedSectionPath?.includes(assetBasename)) {
+ return sectionPath
+ }
+
+ return assetPath
+}
+
+function getAssetChunkType(
+ match: AssetReferenceMatch,
+ fallback: RetrievalResult["chunkType"],
+): RetrievalResult["chunkType"] {
+ const normalizedAssetPath = normalizeAssetLookupText(match.assetPath)
+ if (
+ normalizedAssetPath?.startsWith("images/") ||
+ isImageAssetUrl(match.assetUrl)
+ ) {
+ return "image"
+ }
+ if (normalizedAssetPath?.startsWith("tables/")) {
+ return "table"
+ }
+ return fallback
+}
+
+function isAssetFilePath(value: string): boolean {
+ const normalizedPath = normalizeAssetLookupText(value)
+ return normalizedPath ? /^(images|tables)\//.test(normalizedPath) : false
+}
+
+function resolveAssetReferenceMatches(
+ result: RetrievalResult,
+ assetUrlsByFilePath: Readonly>,
+): readonly AssetReferenceMatch[] {
const normalizedHaystacks = [
result.source.sectionPath,
result.content,
@@ -122,10 +357,33 @@ function resolveResultAssetUrl(
const normalized = normalizeAssetLookupText(value)
return normalized ? [normalized] : []
})
- if (normalizedHaystacks.length === 0) return null
+ if (normalizedHaystacks.length === 0) return []
+
+ return resolveAssetReferenceMatchesFromHaystacks(
+ normalizedHaystacks,
+ assetUrlsByFilePath,
+ )
+}
+function resolveAssetReferenceMatchesFromText(
+ value: string | null | undefined,
+ assetUrlsByFilePath: Readonly>,
+): readonly AssetReferenceMatch[] {
+ const normalized = normalizeAssetLookupText(value)
+ if (!normalized) return []
+
+ return resolveAssetReferenceMatchesFromHaystacks(
+ [normalized],
+ assetUrlsByFilePath,
+ )
+}
+
+function resolveAssetReferenceMatchesFromHaystacks(
+ normalizedHaystacks: readonly string[],
+ assetUrlsByFilePath: Readonly>,
+): readonly AssetReferenceMatch[] {
const basenameCounts = getNormalizedBasenameCounts(assetUrlsByFilePath)
- const matches = Object.entries(assetUrlsByFilePath)
+ return Object.entries(assetUrlsByFilePath)
.flatMap(([assetPath, assetUrl]): readonly AssetReferenceMatch[] => {
const trimmedUrl = getTrimmedString(assetUrl)
if (!trimmedUrl || !isSupportedAssetPath(assetPath)) return []
@@ -138,8 +396,6 @@ function resolveResultAssetUrl(
return index === null ? [] : [{ assetPath, assetUrl: trimmedUrl, index }]
})
.sort(compareAssetReferenceMatches)
-
- return matches[0]?.assetUrl ?? null
}
type AssetReferenceMatch = {
@@ -273,6 +529,94 @@ function removeAssetUrlFromAnswer(answer: string, assetUrl: string): string {
.replace(new RegExp(escapedAssetUrl, "g"), "")
}
+function removeInternalMetadataJsonBlocks(answer: string): string {
+ let output = ""
+ let index = 0
+
+ while (index < answer.length) {
+ if (answer[index] !== "{") {
+ output += answer[index]
+ index += 1
+ continue
+ }
+
+ const objectEndIndex = findJsonObjectEndIndex(answer, index)
+ if (objectEndIndex === null) {
+ output += answer[index]
+ index += 1
+ continue
+ }
+
+ const objectText = answer.slice(index, objectEndIndex + 1)
+ if (isInternalMetadataJsonObject(objectText)) {
+ index = objectEndIndex + 1
+ continue
+ }
+
+ output += objectText
+ index = objectEndIndex + 1
+ }
+
+ return output
+}
+
+function findJsonObjectEndIndex(value: string, startIndex: number): number | null {
+ let depth = 0
+ let isInsideString = false
+ let isEscaped = false
+
+ for (let index = startIndex; index < value.length; index += 1) {
+ const char = value[index]
+ if (isInsideString) {
+ if (isEscaped) {
+ isEscaped = false
+ continue
+ }
+ if (char === "\\") {
+ isEscaped = true
+ continue
+ }
+ if (char === "\"") {
+ isInsideString = false
+ }
+ continue
+ }
+
+ if (char === "\"") {
+ isInsideString = true
+ continue
+ }
+ if (char === "{") {
+ depth += 1
+ continue
+ }
+ if (char === "}") {
+ depth -= 1
+ if (depth === 0) return index
+ }
+ }
+
+ return null
+}
+
+function isInternalMetadataJsonObject(value: string): boolean {
+ try {
+ return hasInternalMetadataKey(JSON.parse(value))
+ } catch {
+ return false
+ }
+}
+
+function hasInternalMetadataKey(value: unknown): boolean {
+ if (!value || typeof value !== "object") return false
+ if (Array.isArray(value)) return value.some(hasInternalMetadataKey)
+
+ return Object.entries(value).some(
+ ([key, nestedValue]): boolean =>
+ internalMetadataKeys.has(key) || hasInternalMetadataKey(nestedValue),
+ )
+}
+
function cleanSanitizedAnswer(answer: string): string {
const cleanedAnswer = answer
.replace(/[ \t]+([,.;:!?])/g, "$1")
diff --git a/src/domains/chat/prompt.ts b/src/domains/chat/prompt.ts
index e07e7df..eba12ad 100644
--- a/src/domains/chat/prompt.ts
+++ b/src/domains/chat/prompt.ts
@@ -1,15 +1,55 @@
-import { generateText } from "ai"
+import {
+ generateText,
+ pruneMessages,
+ stepCountIs,
+ ToolLoopAgent,
+ tool,
+ type ModelMessage,
+ type PrepareStepFunction,
+} from "ai"
import { Effect } from "effect"
+import type {
+ RetrievalQueryResponse,
+ RetrievalSource,
+} from "@ontos-ai/knowhere-sdk"
+import { z } from "zod"
import { CHAT_MODEL } from "@/lib/ai"
+import { logger } from "@/lib/logger"
import type { Source } from "@/infrastructure/db/schema"
import type { ChatCitationView } from "@/domains/chat/types"
-import type { ChatHistoryMessage } from "./contracts"
+import type {
+ AgenticRetrievalQuery,
+ AgenticRetrievalResponse,
+ ChatHistoryMessage,
+ ReadRetrievedChunk,
+ ReadRetrievedChunkInput,
+ ReadRetrievedChunkResult,
+ RetrievedChunkReference,
+ SearchSources,
+} from "./contracts"
import { normalizeRetrievalQuery } from "./retrieval"
const RECENT_CONTEXT_MESSAGE_LIMIT = 8
const CONTEXT_CONTENT_CHAR_LIMIT = 900
+const COMPACTED_HISTORY_MESSAGE_LIMIT = 12
+const COMPACTED_HISTORY_CONTENT_CHAR_LIMIT = 500
+const STORED_HISTORY_MESSAGE_LIMIT = 20
+const STORED_HISTORY_CHAR_BUDGET = 32_000
+const AGENT_STEP_MESSAGE_LIMIT = 20
+const AGENT_STEP_RECENT_MESSAGE_LIMIT = 12
+const AGENT_STEP_CONTEXT_CHAR_BUDGET = 64_000
const SOURCE_CONTEXT_LIMIT = 12
+const AGENTIC_SEARCH_STEP_LIMIT = 5
+const TOOL_EVIDENCE_CHAR_LIMIT = 12_000
+const TOOL_RESULT_CONTENT_CHAR_LIMIT = 1_500
+const TOOL_CHUNK_READ_LIMIT_DEFAULT = 4_000
+const TOOL_CHUNK_READ_LIMIT_MAX = 8_000
+const AGENT_LOOP_TOOL_INPUT_LOG_LIMIT = 1_200
+const AGENT_LOOP_TOOL_LOG_ENTRY_LIMIT = 4
+const AGENT_REQUIRED_SEARCH_STEP_COUNT = 2
+const RAW_URL_PATTERN = /https?:\/\/[^\s)\]}>"']+/g
+const REDACTED_MEDIA_URL = "[media asset URL hidden]"
type GenerateContextualRetrievalQueryInput = {
question: string
@@ -34,6 +74,80 @@ type BuildGroundedPromptInput = {
mediaAssetContext?: string
}
+type GenerateAgenticGroundedAnswerInput = {
+ question: string
+ messages: readonly ChatHistoryMessage[]
+ sources: readonly Source[]
+ excludedSourceIds: readonly string[]
+ searchSources: SearchSources
+ readRetrievedChunk: ReadRetrievedChunk
+}
+
+type AgenticChatTools = ReturnType
+
+type AgentLoopLogPreview = {
+ readonly charLength: number
+ readonly truncated: boolean
+ readonly preview: string
+}
+
+type AgentLoopToolCallLog = {
+ readonly toolName: string
+ readonly toolCallId: string | null
+ readonly input: AgentLoopLogPreview
+}
+
+type AgentLoopToolOutputLog =
+ | AgentLoopLogPreview
+ | AgentLoopSearchSourcesOutputLog
+ | AgentLoopReadChunkOutputLog
+
+type AgentLoopToolResultLog = {
+ readonly toolName: string
+ readonly toolCallId: string | null
+ readonly output: AgentLoopToolOutputLog
+}
+
+type AgentLoopStepLog = {
+ readonly stepNumber: number
+ readonly finishReason: string | null
+ readonly responseText: string
+ readonly responseTextCharLength: number
+ readonly toolCallCount: number
+ readonly toolCalls: readonly AgentLoopToolCallLog[]
+ readonly toolCallsOmitted: number
+ readonly toolResultCount: number
+ readonly toolResults: readonly AgentLoopToolResultLog[]
+ readonly toolResultsOmitted: number
+}
+
+type AgentLoopSearchSourcesOutputLog = {
+ readonly kind: "searchSources"
+ readonly output: AgentLoopLogPreview
+}
+
+type AgentLoopReadChunkOutputLog = {
+ readonly kind: "readRetrievedChunk"
+ readonly output: AgentLoopLogPreview
+}
+
+type LlmModelMessageLog = {
+ readonly role: string
+ readonly contentCharLength: number
+ readonly content: unknown
+}
+
+type RetrievalResponseWithDecisionData = AgenticRetrievalResponse & {
+ readonly decision_trace?: unknown
+ readonly decisionTree?: unknown
+ readonly decision_tree?: unknown
+}
+
+type GenerateLoggedTextInput = {
+ readonly operation: string
+ readonly prompt: string
+}
+
export const generateContextualRetrievalQueryEffect = (
input: GenerateContextualRetrievalQueryInput,
): Effect.Effect =>
@@ -50,21 +164,22 @@ export const generateContextualRetrievalQueryEffect = (
)
}
+ const prompt = buildRetrievalQueryPrompt({
+ question,
+ messages: input.messages,
+ sources: input.sources,
+ excludedSourceIds: input.excludedSourceIds,
+ })
const response = yield* Effect.tryPromise(() =>
- generateText({
- model: CHAT_MODEL,
- prompt: buildRetrievalQueryPrompt({
- question,
- messages: input.messages,
- sources: input.sources,
- excludedSourceIds: input.excludedSourceIds,
- }),
+ generateLoggedText({
+ operation: "generateContextualRetrievalQuery",
+ prompt,
}),
)
return normalizeRetrievalQuery(response.text, question)
})
-/** Async wrapper matching the GenerateRetrievalQuery signature. */
+/** Async wrapper for the legacy single-query retrieval flow. */
export async function generateContextualRetrievalQuery(
input: GenerateContextualRetrievalQueryInput,
): Promise {
@@ -85,21 +200,85 @@ export const generateGroundedAnswerEffect = (
}
const response = yield* Effect.tryPromise(() =>
- generateText({
- model: CHAT_MODEL,
+ generateLoggedText({
+ operation: "generateGroundedAnswer",
prompt: buildGroundedPrompt(input),
}),
)
return response.text.trim()
})
-/** Async wrapper matching the GenerateAnswer signature. */
+/** Async wrapper for the legacy single-response answer flow. */
export async function generateGroundedAnswer(
input: GenerateGroundedAnswerInput,
): Promise {
return Effect.runPromise(generateGroundedAnswerEffect(input))
}
+export const generateAgenticGroundedAnswerEffect = (
+ input: GenerateAgenticGroundedAnswerInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ if (!process.env.AI_GATEWAY_API_KEY) {
+ return yield* Effect.die(
+ new Error(
+ "AI_GATEWAY_API_KEY environment variable is required. " +
+ "Set it in your .env.local file.",
+ ),
+ )
+ }
+
+ const agent = buildAgenticChatAgent(input)
+ const messages = buildAgenticChatMessages(input)
+ logger.info("chat-agent: llm request", {
+ operation: "generateAgenticGroundedAnswer.initial",
+ model: CHAT_MODEL,
+ promptType: "messages",
+ messageCount: messages.length,
+ messages: formatModelMessagesForLlmLog(messages),
+ })
+ const response = yield* Effect.tryPromise(async () => {
+ const generationResponse = await agent.generate({ messages })
+ logger.info("chat-agent: llm response", {
+ operation: "generateAgenticGroundedAnswer.final",
+ model: CHAT_MODEL,
+ responseTextCharLength: generationResponse.text.length,
+ responseText: redactRawUrls(generationResponse.text),
+ })
+ return generationResponse
+ })
+ return response.text.trim()
+ })
+
+export async function generateAgenticGroundedAnswer(
+ input: GenerateAgenticGroundedAnswerInput,
+): Promise {
+ return Effect.runPromise(generateAgenticGroundedAnswerEffect(input))
+}
+
+async function generateLoggedText(
+ input: GenerateLoggedTextInput,
+): Promise>> {
+ logger.info("chat-agent: llm request", {
+ operation: input.operation,
+ model: CHAT_MODEL,
+ promptType: "text",
+ promptCharLength: input.prompt.length,
+ prompt: redactRawUrls(input.prompt),
+ })
+ const response = await generateText({
+ model: CHAT_MODEL,
+ prompt: input.prompt,
+ })
+ logger.info("chat-agent: llm response", {
+ operation: input.operation,
+ model: CHAT_MODEL,
+ responseTextCharLength: response.text.length,
+ responseText: redactRawUrls(response.text),
+ })
+ return response
+}
+
export function buildRetrievalQueryPrompt(
input: GenerateContextualRetrievalQueryInput,
): string {
@@ -136,11 +315,16 @@ export function buildGroundedPrompt(input: BuildGroundedPromptInput): string {
"Cite document sections (e.g. [文档名 / 章节名]) when they support a claim.",
"When retrieved image or table asset references are relevant to the user's request, cite the matching source label; the UI renders media from citation metadata.",
"Do not write raw media asset URLs in the answer. They are internal metadata only.",
+ "Never output JSON metadata blocks for citations, images, tables, or media.",
+ "Never mention asset_id, assetUrl, raw URLs, chunk ids, request-local ids, or retrieval internals.",
+ "For image requests, answer briefly and let the UI render images from citation metadata.",
+ "For send/show image requests, do not transcribe personal details from the image; do not list identity numbers, addresses, birth dates, or document fields unless the user explicitly asks for those details.",
"Do not invent asset URLs; use only the retrieved media asset references listed below.",
"If the sources are related but incomplete, answer what you can and briefly say what is not covered.",
"Do not invent document-specific facts that are not in the sources.",
"Use the recent conversation only to resolve references like \"this document\"; do not use it as factual evidence.",
"Answer in a natural, friendly, and direct tone.",
+ "Use GitHub-flavored Markdown when it improves readability, such as short lists, tables, or code blocks. Keep simple answers as plain sentences.",
"Start with the answer first. Avoid meta phrases like \"Based on the sources\" or \"Based on the source excerpts\" unless the user asks how you know.",
"Use plain language.",
"Keep answers concise by default: 1-3 short paragraphs unless the user asks for detail.",
@@ -167,6 +351,924 @@ export function buildGroundedPrompt(input: BuildGroundedPromptInput): string {
return promptLines.join("\n")
}
+export function buildAgenticChatSystemPrompt(
+ input: Pick<
+ GenerateAgenticGroundedAnswerInput,
+ "messages" | "sources" | "excludedSourceIds"
+ >,
+): string {
+ const sourceContext = formatSourceContext(input.sources, input.excludedSourceIds)
+
+ return [
+ "Role",
+ "You are a Notebook research agent that answers user questions from their uploaded sources.",
+ "Use retrieved source evidence as the factual source of truth. Do not invent document-specific facts.",
+ "",
+ "Retrieval strategy",
+ "You have two tools: searchSources and readRetrievedChunk.",
+ "Use searchSources for source discovery. Its markdown output gives guidance, evidence, result previews, and Read IDs.",
+ "Use readRetrievedChunk only when a relevant search result preview is too short and the markdown output shows a Read ID.",
+ "Treat tool output like source notes from a remote index: inspect it, reason over it, then decide whether to answer, search again, or read more.",
+ "",
+ "Tool use rules",
+ "1. Always call searchSources before writing a final answer.",
+ "2. Make a second searchSources call before answering to double-check the retrieved data. Reuse the same core query or refine it with entities, document names, section paths, file paths, content types, or failure hints from the first output.",
+ "3. Choose the content target from the user's request: broad questions use broad or text-only search, image requests use image or text+image search, and table requests use table or text+table search.",
+ "4. Do not paste raw prior messages into searchSources.query. The query must be concise and contain only distilled search terms such as document title, person, topic, date, section path, or asset kind.",
+ "5. Use one response to guide the next query: carry forward discovered people, organizations, document names, section paths, file paths, content types, and failure hints.",
+ "6. After the verification search, if the markdown guidance says the evidence is useful and the evidence/results directly support the answer, stop searching and answer.",
+ "7. If results are missing, weak, or do not cover the requested entity/topic/media/table, search again with a broader or more specific query.",
+ "8. Use readRetrievedChunk selectively; do not read every result when the previews already answer the question.",
+ "9. Stop after enough evidence or when further searches are unlikely to help; then clearly say what was not found and what retrieval context was missing.",
+ "",
+ "Media/table handling",
+ "For image requests, search visual content directly or combine text and image evidence. If an initial text result identifies a relevant person or section but not an image asset, query again with that person/section plus the requested image concept, e.g. identity card / 身份证 / 公民身份证明.",
+ "For table requests, search table content directly or combine text and table evidence.",
+ "When retrieved image or table assets are relevant, cite the matching source label; the UI renders media from citation metadata.",
+ "Do not invent asset URLs or describe hidden asset metadata.",
+ "",
+ "Final answer contract",
+ "Conversation context is supplied as managed model messages. Use it only to resolve references like \"this document\" or \"those images\".",
+ "Cite document sections in the answer, e.g. [文档名 / 章节名].",
+ "Use existing [Source N: label] labels only when they are the clearest available citation form.",
+ "Never output JSON metadata blocks for citations, images, tables, or media.",
+ "Never mention asset_id, assetUrl, raw URLs, chunk ids, Read IDs, tool parameters, or retrieval internals.",
+ "For image requests, answer briefly and let the UI render images from citation metadata.",
+ "For send/show image requests, do not transcribe personal details from the image; do not list identity numbers, addresses, birth dates, or document fields unless the user explicitly asks for those details.",
+ "Do not add unrelated personal details for send/show image requests unless the user asks.",
+ "Use GitHub-flavored Markdown when it improves readability, such as short lists, tables, or code blocks. Keep simple answers as plain sentences.",
+ "Start with the answer first. Keep answers concise unless the user asks for detail.",
+ "",
+ "Searchable sources",
+ sourceContext,
+ ].join("\n")
+}
+
+function buildAgenticChatAgent(
+ input: GenerateAgenticGroundedAnswerInput,
+): ToolLoopAgent {
+ const instructions = buildAgenticChatSystemPrompt(input)
+ return new ToolLoopAgent({
+ model: CHAT_MODEL,
+ instructions,
+ tools: buildAgenticChatTools(input),
+ stopWhen: stepCountIs(AGENTIC_SEARCH_STEP_LIMIT),
+ prepareStep: buildAgenticPrepareStep(instructions),
+ onStepFinish: (event) => {
+ logger.info("chat-agent: llm response", {
+ operation: "generateAgenticGroundedAnswer.step",
+ model: CHAT_MODEL,
+ stepNumber: event.stepNumber,
+ finishReason: event.finishReason,
+ responseTextCharLength: event.text.length,
+ responseText: redactRawUrls(event.text),
+ toolCallCount: event.toolCalls.length,
+ toolCalls: formatAgentLoopToolCalls(event.toolCalls),
+ toolCallsOmitted: getOmittedAgentLoopEntryCount(event.toolCalls),
+ toolResultCount: event.toolResults.length,
+ toolResults: formatAgentLoopToolResults(event.toolResults),
+ toolResultsOmitted: getOmittedAgentLoopEntryCount(event.toolResults),
+ inputTokens: event.usage.inputTokens,
+ outputTokens: event.usage.outputTokens,
+ totalTokens: event.usage.totalTokens,
+ })
+ },
+ onFinish: (event) => {
+ logger.info("chat-agent: loop finished", {
+ stepCount: event.steps.length,
+ finishReason: event.finishReason,
+ responseTextCharLength: event.text.length,
+ responseText: redactRawUrls(event.text),
+ steps: event.steps.map(formatAgentLoopStep),
+ toolNames: Array.from(
+ new Set(
+ event.steps.flatMap((step) =>
+ step.toolCalls.map((toolCall) => toolCall.toolName),
+ ),
+ ),
+ ),
+ inputTokens: event.totalUsage.inputTokens,
+ outputTokens: event.totalUsage.outputTokens,
+ totalTokens: event.totalUsage.totalTokens,
+ })
+ },
+ })
+}
+
+function buildAgenticChatTools(
+ input: Pick<
+ GenerateAgenticGroundedAnswerInput,
+ "searchSources" | "readRetrievedChunk"
+ >,
+) {
+ return {
+ searchSources: tool({
+ description:
+ "Search the user's Notebook sources through Knowhere retrieval. " +
+ "It returns markdown source notes with guidance, evidence, previews, " +
+ "and Read IDs for follow-up reads. Use it before answering and call it " +
+ "again with refined text, media, or section-path queries when evidence is missing or weak.",
+ inputSchema: z.object({
+ query: z
+ .string()
+ .min(1)
+ .describe(
+ "A concise, self-contained retrieval query. Do not paste raw chat history or previous messages. Use only distilled terms such as document title, person, topic, date, section path, or asset kind when needed.",
+ ),
+ targetContent: z
+ .enum([
+ "all",
+ "text",
+ "image",
+ "table",
+ "text_image",
+ "text_table",
+ ])
+ .optional()
+ .describe(
+ "The content type to retrieve: all, text, image, table, text_image, or text_table. Omit only when all content types are useful.",
+ ),
+ purpose: z
+ .string()
+ .min(1)
+ .max(240)
+ .optional()
+ .describe(
+ "Short reason this query is needed, such as finding an entity, locating an image asset, or verifying a citation.",
+ ),
+ topK: z
+ .number()
+ .int()
+ .min(1)
+ .max(12)
+ .optional()
+ .describe("Number of chunks to return. Defaults to 8."),
+ signalPaths: z
+ .array(z.string().min(1))
+ .max(8)
+ .optional()
+ .describe(
+ "Optional section/path keywords when a previous result points to a useful section.",
+ ),
+ filterMode: z
+ .enum(["keep", "delete"])
+ .optional()
+ .describe(
+ "How to apply signalPaths. Use keep to focus on matching paths, delete to exclude them.",
+ ),
+ threshold: z
+ .number()
+ .min(0)
+ .max(1)
+ .optional()
+ .describe("Optional minimum retrieval score threshold."),
+ }),
+ execute: async (queryInput: AgenticRetrievalQuery) => {
+ const output = buildRetrievalToolOutput(
+ await input.searchSources(queryInput),
+ )
+ logToolMarkdownOutput("searchSources", output)
+ return output
+ },
+ }),
+ readRetrievedChunk: tool({
+ description:
+ "Read an offset/limit content slice from a Read ID shown in searchSources markdown. " +
+ "Use this when a returned result preview is relevant and you want more data before answering.",
+ inputSchema: z.object({
+ id: z
+ .string()
+ .min(1)
+ .describe(
+ "The Read ID shown in searchSources markdown for a relevant result.",
+ ),
+ offset: z
+ .number()
+ .int()
+ .min(0)
+ .optional()
+ .describe("Character offset to start reading from. Defaults to 0."),
+ limit: z
+ .number()
+ .int()
+ .min(1)
+ .max(TOOL_CHUNK_READ_LIMIT_MAX)
+ .optional()
+ .describe(
+ `Maximum characters to return. Defaults to ${TOOL_CHUNK_READ_LIMIT_DEFAULT}; max ${TOOL_CHUNK_READ_LIMIT_MAX}.`,
+ ),
+ }),
+ execute: async (readInput: ReadRetrievedChunkInput) => {
+ const output = buildRetrievedChunkToolOutput(
+ await input.readRetrievedChunk(readInput),
+ )
+ logToolMarkdownOutput("readRetrievedChunk", output)
+ return output
+ },
+ }),
+ } as const
+}
+
+function buildAgenticPrepareStep(
+ instructions: string,
+): PrepareStepFunction {
+ return ({ stepNumber, messages }) => {
+ const managedMessages = buildAgentStepMessages(messages)
+ if (stepNumber < AGENT_REQUIRED_SEARCH_STEP_COUNT) {
+ const stepInput = {
+ messages: managedMessages,
+ toolChoice: {
+ type: "tool" as const,
+ toolName: "searchSources" as const,
+ },
+ activeTools: ["searchSources" as const],
+ }
+ logAgentStepLlmRequest({
+ stepNumber,
+ instructions,
+ messages: managedMessages,
+ toolChoice: stepInput.toolChoice,
+ activeTools: stepInput.activeTools,
+ })
+ return stepInput
+ }
+
+ logAgentStepLlmRequest({
+ stepNumber,
+ instructions,
+ messages: managedMessages,
+ toolChoice: null,
+ activeTools: null,
+ })
+ return { messages: managedMessages }
+ }
+}
+
+function formatAgentLoopStep(step: unknown, index: number): AgentLoopStepLog {
+ const record = getRecordFromUnknown(step)
+ const toolCalls = getRecordArray(record, "toolCalls")
+ const toolResults = getRecordArray(record, "toolResults")
+ const responseText = getRecordString(record, "text") ?? ""
+ return {
+ stepNumber:
+ getRecordNumber(record, "stepNumber") ??
+ getRecordNumber(record, "stepIndex") ??
+ index + 1,
+ finishReason: getRecordString(record, "finishReason"),
+ responseText: redactRawUrls(responseText),
+ responseTextCharLength: responseText.length,
+ toolCallCount: toolCalls.length,
+ toolCalls: formatAgentLoopToolCalls(toolCalls),
+ toolCallsOmitted: getOmittedAgentLoopEntryCount(toolCalls),
+ toolResultCount: toolResults.length,
+ toolResults: formatAgentLoopToolResults(toolResults),
+ toolResultsOmitted: getOmittedAgentLoopEntryCount(toolResults),
+ }
+}
+
+function formatAgentLoopToolCalls(
+ toolCalls: readonly unknown[],
+): readonly AgentLoopToolCallLog[] {
+ return toolCalls
+ .slice(0, AGENT_LOOP_TOOL_LOG_ENTRY_LIMIT)
+ .map(formatAgentLoopToolCall)
+}
+
+function formatAgentLoopToolCall(toolCall: unknown): AgentLoopToolCallLog {
+ const record = getRecordFromUnknown(toolCall)
+ return {
+ toolName: getRecordString(record, "toolName") ?? "unknown",
+ toolCallId: getRecordString(record, "toolCallId"),
+ input: buildAgentLoopPreview(
+ getFirstRecordValue(record, ["input", "args", "arguments"]),
+ AGENT_LOOP_TOOL_INPUT_LOG_LIMIT,
+ ),
+ }
+}
+
+function formatAgentLoopToolResults(
+ toolResults: readonly unknown[],
+): readonly AgentLoopToolResultLog[] {
+ return toolResults
+ .slice(0, AGENT_LOOP_TOOL_LOG_ENTRY_LIMIT)
+ .map(formatAgentLoopToolResult)
+}
+
+function formatAgentLoopToolResult(toolResult: unknown): AgentLoopToolResultLog {
+ const record = getRecordFromUnknown(toolResult)
+ const toolName = getRecordString(record, "toolName") ?? "unknown"
+ return {
+ toolName,
+ toolCallId: getRecordString(record, "toolCallId"),
+ output: formatAgentLoopToolOutput(
+ toolName,
+ getFirstRecordValue(record, ["output", "result", "content"]),
+ ),
+ }
+}
+
+function formatAgentLoopToolOutput(
+ toolName: string,
+ output: unknown,
+): AgentLoopToolOutputLog {
+ if (toolName === "searchSources") {
+ return {
+ kind: "searchSources",
+ output: buildAgentLoopFullMarkdownPreview(output),
+ }
+ }
+ if (toolName === "readRetrievedChunk") {
+ return {
+ kind: "readRetrievedChunk",
+ output: buildAgentLoopFullMarkdownPreview(output),
+ }
+ }
+ return buildAgentLoopFullPreview(output)
+}
+
+function getOmittedAgentLoopEntryCount(entries: readonly unknown[]): number {
+ return Math.max(0, entries.length - AGENT_LOOP_TOOL_LOG_ENTRY_LIMIT)
+}
+
+function logAgentStepLlmRequest(input: {
+ readonly stepNumber: number
+ readonly instructions: string
+ readonly messages: readonly ModelMessage[]
+ readonly toolChoice: unknown
+ readonly activeTools: readonly string[] | null
+}): void {
+ logger.info("chat-agent: llm request", {
+ operation: "generateAgenticGroundedAnswer.step",
+ model: CHAT_MODEL,
+ promptType: "messages",
+ stepNumber: input.stepNumber,
+ instructionsCharLength: input.instructions.length,
+ instructions: redactRawUrls(input.instructions),
+ messageCount: input.messages.length,
+ messages: formatModelMessagesForLlmLog(input.messages),
+ toolChoice: input.toolChoice,
+ activeTools: input.activeTools,
+ })
+}
+
+function formatModelMessagesForLlmLog(
+ messages: readonly ModelMessage[],
+): readonly LlmModelMessageLog[] {
+ return messages.map(formatModelMessageForLlmLog)
+}
+
+function formatModelMessageForLlmLog(message: ModelMessage): LlmModelMessageLog {
+ return {
+ role: message.role,
+ contentCharLength: getUnknownTextLength(message.content),
+ content: redactRawUrlsFromUnknown(message.content),
+ }
+}
+
+function buildAgentLoopPreview(
+ value: unknown,
+ limit: number,
+): AgentLoopLogPreview {
+ const normalized = redactRawUrls(stringifyAgentLoopLogValue(value))
+ .replace(/\s+/g, " ")
+ .trim()
+ const truncated = normalized.length > limit
+ return {
+ charLength: normalized.length,
+ truncated,
+ preview: truncated ? `${normalized.slice(0, limit)}...` : normalized,
+ }
+}
+
+function buildAgentLoopFullPreview(value: unknown): AgentLoopLogPreview {
+ const normalized = redactRawUrls(stringifyAgentLoopLogValue(value))
+ return {
+ charLength: normalized.length,
+ truncated: false,
+ preview: normalized,
+ }
+}
+
+function buildAgentLoopFullMarkdownPreview(value: unknown): AgentLoopLogPreview {
+ const normalized = redactRawUrls(stringifyAgentLoopLogValue(value))
+ return {
+ charLength: normalized.length,
+ truncated: false,
+ preview: normalized,
+ }
+}
+
+function stringifyAgentLoopLogValue(value: unknown): string {
+ if (typeof value === "string") return value
+ if (value === undefined) return "undefined"
+ if (typeof value === "function") {
+ return `[Function ${value.name || "anonymous"}]`
+ }
+ if (typeof value === "symbol") return value.toString()
+
+ const json = JSON.stringify(value, createAgentLoopLogJsonReplacer())
+ return json ?? String(value)
+}
+
+function createAgentLoopLogJsonReplacer(): (
+ key: string,
+ value: unknown,
+) => unknown {
+ const seenObjects = new WeakSet