feat: source citations with footnotes in the transcript #21

Merged
adrianbonpin merged 10 commits from feat/source-citations into prod 2026-10-07 08:12:52 +00:00
4 changed files with 197 additions and 131 deletions
Showing only changes of commit 1a8e767fbe - Show all commits
@@ -174,13 +174,15 @@ The answer then gains a list, after the body and before the `_Tools:_` line:
``` ```
_Sources_ _Sources_
- [[1]](https://example.com/page) Result title - [\[1\]](https://example.com/page) Result title
- [[2]](https://example.com/page-two) Page title - [\[2\]](https://example.com/page-two) Page title
``` ```
The marker is the link, matching the inline marker character for character, so The marker is the link, matching the inline marker character for character, so
the same token means the same thing in both places. An entry whose URL cannot be the same token means the same thing in both places. The brackets are
linked keeps its title and shows no marker link. escaped because `markdownLink` escapes them: an unescaped `]` would end the link
text early, and the renderer draws the escaped form as a plain `[1]`. An entry
whose URL cannot be linked keeps its title and shows no marker link.
### Prompt ### Prompt
+42 -2
View File
@@ -21,7 +21,18 @@ function ndjsonResponse(events: unknown[], status = 200): Response {
} }
function toolCall(name: string, args: Record<string, unknown> = {}): Record<string, unknown> { function toolCall(name: string, args: Record<string, unknown> = {}): Record<string, unknown> {
return { message: { role: "assistant", content: "", tool_calls: [{ function: { name, arguments: args } }] }, done: true }; return {
message: { role: "assistant", content: "", tool_calls: [{ function: { name, arguments: args } }] },
done: true,
};
}
/** The shape the two Ollama web endpoints answer with. */
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} }
function answer(text: string): Record<string, unknown>[] { function answer(text: string): Record<string, unknown>[] {
@@ -136,6 +147,33 @@ describe("runAgent", () => {
expect(toolMessage?.content).toContain("2026"); expect(toolMessage?.content).toContain("2026");
}); });
it("numbers a turn's sources once, and keeps each call's own on its record", async () => {
// Two searches in one turn. The second has to continue the first's numbering,
// or one marker in the answer could mean two different pages; and each record
// has to hold only what its own call found, or the list would repeat a page.
fetchMock
.mockResolvedValueOnce(ndjsonResponse([toolCall("web_search", { query: "one" })]))
.mockResolvedValueOnce(
jsonResponse({ results: [{ title: "First", url: "https://first.example", content: "A" }] }),
)
.mockResolvedValueOnce(ndjsonResponse([toolCall("web_search", { query: "two" })]))
.mockResolvedValueOnce(
jsonResponse({
results: [{ title: "Second", url: "https://second.example", content: "B" }],
}),
)
.mockResolvedValueOnce(ndjsonResponse(answer("Both [1][2] agree.")));
const result = await runAgent({
...baseOptions([{ role: "user", content: "hi" }]),
tools: toolsFor({ webEnabled: true }),
});
expect(result.toolCalls.map((call) => call.sources?.map((source) => source.index))).toEqual([[1], [2]]);
expect(result.toolCalls[0].result).toContain("[1] First");
expect(result.toolCalls[1].result).toContain("[2] Second");
});
it("reports an unknown tool as an error without aborting the turn", async () => { it("reports an unknown tool as an error without aborting the turn", async () => {
fetchMock fetchMock
.mockResolvedValueOnce(ndjsonResponse([toolCall("no_such_tool")])) .mockResolvedValueOnce(ndjsonResponse([toolCall("no_such_tool")]))
@@ -229,7 +267,9 @@ describe("runAgent", () => {
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS; i++) { for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([preamble(i)])); fetchMock.mockResolvedValueOnce(ndjsonResponse([preamble(i)]));
} }
fetchMock.mockResolvedValueOnce(ndjsonResponse([{ message: { role: "assistant", content: "" }, done: true }])); fetchMock.mockResolvedValueOnce(
ndjsonResponse([{ message: { role: "assistant", content: "" }, done: true }]),
);
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }])); const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
+13
View File
@@ -1,3 +1,4 @@
import { createSourceList } from "./citations";
import { DEFAULT_MAX_TOOL_ROUNDS, MAX_REPEATED_TOOL_CALLS } from "./constants"; import { DEFAULT_MAX_TOOL_ROUNDS, MAX_REPEATED_TOOL_CALLS } from "./constants";
import { streamChat } from "./ollama"; import { streamChat } from "./ollama";
import { executeTool, toolLabel, type ToolContext } from "./tools"; import { executeTool, toolLabel, type ToolContext } from "./tools";
@@ -137,6 +138,14 @@ function exhaustedNotice(reason: StopReason): string {
export async function runAgent(options: AgentOptions): Promise<AgentResult> { export async function runAgent(options: AgentOptions): Promise<AgentResult> {
const conversation: ApiMessage[] = [...options.messages]; const conversation: ApiMessage[] = [...options.messages];
const toolCalls: ToolCallRecord[] = []; const toolCalls: ToolCallRecord[] = [];
/**
* The turn's sources, so one number means one page for the whole turn.
*
* Created here rather than passed in: it is scoped to a single run of this loop,
* and every call the loop makes shares it, which is what keeps three searches in
* one answer from each starting again at 1.
*/
const sources = createSourceList();
/** `Infinity` needs no special case: the loop simply never reaches its own end. */ /** `Infinity` needs no special case: the loop simply never reaches its own end. */
const rounds = const rounds =
options.maxToolRounds === null ? Infinity : (options.maxToolRounds ?? DEFAULT_MAX_TOOL_ROUNDS); options.maxToolRounds === null ? Infinity : (options.maxToolRounds ?? DEFAULT_MAX_TOOL_ROUNDS);
@@ -239,6 +248,7 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
place: options.toolContext.place, place: options.toolContext.place,
now: options.toolContext.now, now: options.toolContext.now,
signal: options.signal, signal: options.signal,
sources,
}); });
const record: ToolCallRecord = { const record: ToolCallRecord = {
@@ -246,6 +256,9 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
arguments: args, arguments: args,
result: result.content, result: result.content,
isError: result.isError, isError: result.isError,
// Only what this call added. The turn's whole list is the flattening of
// its records, so the running total is deliberately not repeated here.
sources: result.sources,
}; };
toolCalls.push(record); toolCalls.push(record);
options.events?.onToolCall?.(record); options.events?.onToolCall?.(record);
+11
View File
@@ -7,12 +7,23 @@ import { z } from "zod";
* older or hand-edited payload gets rejected instead of crashing a render. * older or hand-edited payload gets rejected instead of crashing a render.
*/ */
export const SourceSchema = z.object({
index: z.number(),
title: z.string(),
url: z.string(),
});
export const ToolCallRecordSchema = z.object({ export const ToolCallRecordSchema = z.object({
name: z.string(), name: z.string(),
arguments: z.record(z.string(), z.unknown()), arguments: z.record(z.string(), z.unknown()),
result: z.string(), result: z.string(),
isError: z.boolean(), isError: z.boolean(),
repeated: z.boolean().optional(), repeated: z.boolean().optional(),
// Absent on turns stored before sources were numbered, which is honest: those
// answers never cited a number, so there is nothing to draw for them. zod drops
// unknown keys, so without this line the field would be lost on the first
// reload and a reopened conversation would lose every link.
sources: z.array(SourceSchema).optional(),
}); });
export const AttachmentSchema = z.object({ export const AttachmentSchema = z.object({