feat: source citations with footnotes in the transcript (#21)

This commit was merged in pull request #21.
This commit is contained in:
2026-10-07 08:12:48 +00:00
parent 72a63e69e6
commit fcc6d26c9d
16 changed files with 2716 additions and 205 deletions
+13
View File
@@ -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);