feat: source citations with footnotes in the transcript #21
@@ -174,13 +174,15 @@ The answer then gains a list, after the body and before the `_Tools:_` line:
|
||||
```
|
||||
_Sources_
|
||||
|
||||
- [[1]](https://example.com/page) Result title
|
||||
- [[2]](https://example.com/page-two) Page title
|
||||
- [\[1\]](https://example.com/page) Result title
|
||||
- [\[2\]](https://example.com/page-two) Page title
|
||||
```
|
||||
|
||||
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
|
||||
linked keeps its title and shows no marker link.
|
||||
the same token means the same thing in both places. The brackets are
|
||||
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
|
||||
|
||||
|
||||
@@ -21,7 +21,18 @@ function ndjsonResponse(events: unknown[], status = 200): Response {
|
||||
}
|
||||
|
||||
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>[] {
|
||||
@@ -136,6 +147,33 @@ describe("runAgent", () => {
|
||||
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 () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(ndjsonResponse([toolCall("no_such_tool")]))
|
||||
@@ -229,7 +267,9 @@ describe("runAgent", () => {
|
||||
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS; 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" }]));
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSourceList } from "./citations";
|
||||
import { DEFAULT_MAX_TOOL_ROUNDS, MAX_REPEATED_TOOL_CALLS } from "./constants";
|
||||
import { streamChat } from "./ollama";
|
||||
import { executeTool, toolLabel, type ToolContext } from "./tools";
|
||||
@@ -137,6 +138,14 @@ function exhaustedNotice(reason: StopReason): string {
|
||||
export async function runAgent(options: AgentOptions): Promise<AgentResult> {
|
||||
const conversation: ApiMessage[] = [...options.messages];
|
||||
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. */
|
||||
const 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,
|
||||
now: options.toolContext.now,
|
||||
signal: options.signal,
|
||||
sources,
|
||||
});
|
||||
|
||||
const record: ToolCallRecord = {
|
||||
@@ -246,6 +256,9 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
|
||||
arguments: args,
|
||||
result: result.content,
|
||||
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);
|
||||
options.events?.onToolCall?.(record);
|
||||
|
||||
@@ -7,12 +7,23 @@ import { z } from "zod";
|
||||
* 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({
|
||||
name: z.string(),
|
||||
arguments: z.record(z.string(), z.unknown()),
|
||||
result: z.string(),
|
||||
isError: z.boolean(),
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user