feat: source citations with footnotes in the transcript (#21)
This commit was merged in pull request #21.
This commit is contained in:
+167
-127
@@ -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" }]));
|
||||
|
||||
@@ -261,137 +301,137 @@ describe("runAgent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Queues `count` rounds that all ask for the identical call. */
|
||||
function queueRepeats(count: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
|
||||
}
|
||||
/** Queues `count` rounds that all ask for the identical call. */
|
||||
function queueRepeats(count: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
|
||||
}
|
||||
}
|
||||
|
||||
it("stops when the same call comes round a third time instead of running it again", async () => {
|
||||
// The rut this guards against: the model decides the first search did not
|
||||
// answer the question and asks the identical question again, and again.
|
||||
// Two executions is a retry; a third is not going to learn anything new.
|
||||
queueRepeats(3);
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("It is 03:04 UTC.")));
|
||||
it("stops when the same call comes round a third time instead of running it again", async () => {
|
||||
// The rut this guards against: the model decides the first search did not
|
||||
// answer the question and asks the identical question again, and again.
|
||||
// Two executions is a retry; a third is not going to learn anything new.
|
||||
queueRepeats(3);
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("It is 03:04 UTC.")));
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
// Two executions, plus the refusal that ended the turn.
|
||||
expect(result.toolCalls).toHaveLength(3);
|
||||
expect(result.toolCalls.filter((call) => call.repeated)).toHaveLength(1);
|
||||
expect(result.content).toBe("It is 03:04 UTC.");
|
||||
// Two tool rounds, the refusal round, and the closing round.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
// Two executions, plus the refusal that ended the turn.
|
||||
expect(result.toolCalls).toHaveLength(3);
|
||||
expect(result.toolCalls.filter((call) => call.repeated)).toHaveLength(1);
|
||||
expect(result.content).toBe("It is 03:04 UTC.");
|
||||
// Two tool rounds, the refusal round, and the closing round.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("does not count a different call with the same tool as a repeat", async () => {
|
||||
// Searching for three different things is a model doing its job, not a loop.
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "CET" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "JST" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("Three zones.")));
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.toolCalls).toHaveLength(3);
|
||||
expect(result.toolCalls.every((call) => !call.repeated)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats reordered arguments as the same call", async () => {
|
||||
// Models are not consistent about key order, and a guard that only catches
|
||||
// byte-identical calls would miss the ordinary shape of a repeat.
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC", unit: "h" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { unit: "h", tz: "UTC" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC", unit: "h" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.toolCalls.filter((call) => call.repeated)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("tells the model the call was repeating rather than that a budget ran out", async () => {
|
||||
queueRepeats(3);
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("ok")));
|
||||
|
||||
await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
const last = JSON.parse(fetchMock.mock.calls[3][1].body as string) as { messages: ApiMessage[] };
|
||||
const nudge = last.messages[last.messages.length - 1];
|
||||
|
||||
expect(nudge.role).toBe("user");
|
||||
expect(nudge.content).toContain("identical arguments 3 times");
|
||||
expect(nudge.content).not.toContain("reached the limit");
|
||||
});
|
||||
|
||||
it("answers from what it gathered when a repeat ends the turn", async () => {
|
||||
queueRepeats(3);
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("I could not get past the time check.")));
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.content).toBe("I could not get past the time check.");
|
||||
expect(result.content).not.toContain("kept repeating");
|
||||
});
|
||||
|
||||
it("says the turn was cut short when a repeat leaves the closing round speechless", async () => {
|
||||
queueRepeats(3);
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
ndjsonResponse([{ message: { role: "assistant", content: "" }, done: true }]),
|
||||
);
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.content).toContain("kept repeating the same call");
|
||||
});
|
||||
|
||||
it("honours a caller's round cap in place of the default", async () => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
|
||||
}
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||
|
||||
const result = await runAgent({ ...baseOptions([{ role: "user", content: "hi" }]), maxToolRounds: 4 });
|
||||
|
||||
expect(result.toolCalls).toHaveLength(4);
|
||||
expect(result.content).toBe("done");
|
||||
});
|
||||
|
||||
it("runs past the default cap when the caller asks for no cap at all", async () => {
|
||||
// The preference can be switched to Unlimited, and the only difference that
|
||||
// should make is that the cap stops arriving.
|
||||
const rounds = DEFAULT_MAX_TOOL_ROUNDS + 3;
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
|
||||
}
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||
|
||||
const result = await runAgent({ ...baseOptions([{ role: "user", content: "hi" }]), maxToolRounds: null });
|
||||
|
||||
expect(result.toolCalls).toHaveLength(rounds);
|
||||
expect(result.content).toBe("done");
|
||||
});
|
||||
|
||||
it("reports each tool start with how many times that call has come round", async () => {
|
||||
// This is what the transcript shows while a turn is in flight, so a repeat
|
||||
// is visible as it happens and not only in the record afterwards.
|
||||
const started: { name: string; label: string; count: number }[] = [];
|
||||
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||
|
||||
await runAgent({
|
||||
...baseOptions([{ role: "user", content: "hi" }]),
|
||||
events: { onToolStart: (name, label, count) => started.push({ name, label, count }) },
|
||||
});
|
||||
|
||||
it("does not count a different call with the same tool as a repeat", async () => {
|
||||
// Searching for three different things is a model doing its job, not a loop.
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "CET" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "JST" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("Three zones.")));
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.toolCalls).toHaveLength(3);
|
||||
expect(result.toolCalls.every((call) => !call.repeated)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats reordered arguments as the same call", async () => {
|
||||
// Models are not consistent about key order, and a guard that only catches
|
||||
// byte-identical calls would miss the ordinary shape of a repeat.
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC", unit: "h" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { unit: "h", tz: "UTC" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC", unit: "h" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.toolCalls.filter((call) => call.repeated)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("tells the model the call was repeating rather than that a budget ran out", async () => {
|
||||
queueRepeats(3);
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("ok")));
|
||||
|
||||
await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
const last = JSON.parse(fetchMock.mock.calls[3][1].body as string) as { messages: ApiMessage[] };
|
||||
const nudge = last.messages[last.messages.length - 1];
|
||||
|
||||
expect(nudge.role).toBe("user");
|
||||
expect(nudge.content).toContain("identical arguments 3 times");
|
||||
expect(nudge.content).not.toContain("reached the limit");
|
||||
});
|
||||
|
||||
it("answers from what it gathered when a repeat ends the turn", async () => {
|
||||
queueRepeats(3);
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("I could not get past the time check.")));
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.content).toBe("I could not get past the time check.");
|
||||
expect(result.content).not.toContain("kept repeating");
|
||||
});
|
||||
|
||||
it("says the turn was cut short when a repeat leaves the closing round speechless", async () => {
|
||||
queueRepeats(3);
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
ndjsonResponse([{ message: { role: "assistant", content: "" }, done: true }]),
|
||||
);
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.content).toContain("kept repeating the same call");
|
||||
});
|
||||
|
||||
it("honours a caller's round cap in place of the default", async () => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
|
||||
}
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||
|
||||
const result = await runAgent({ ...baseOptions([{ role: "user", content: "hi" }]), maxToolRounds: 4 });
|
||||
|
||||
expect(result.toolCalls).toHaveLength(4);
|
||||
expect(result.content).toBe("done");
|
||||
});
|
||||
|
||||
it("runs past the default cap when the caller asks for no cap at all", async () => {
|
||||
// The preference can be switched to Unlimited, and the only difference that
|
||||
// should make is that the cap stops arriving.
|
||||
const rounds = DEFAULT_MAX_TOOL_ROUNDS + 3;
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
|
||||
}
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||
|
||||
const result = await runAgent({ ...baseOptions([{ role: "user", content: "hi" }]), maxToolRounds: null });
|
||||
|
||||
expect(result.toolCalls).toHaveLength(rounds);
|
||||
expect(result.content).toBe("done");
|
||||
});
|
||||
|
||||
it("reports each tool start with how many times that call has come round", async () => {
|
||||
// This is what the transcript shows while a turn is in flight, so a repeat
|
||||
// is visible as it happens and not only in the record afterwards.
|
||||
const started: { name: string; label: string; count: number }[] = [];
|
||||
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||
|
||||
await runAgent({
|
||||
...baseOptions([{ role: "user", content: "hi" }]),
|
||||
events: { onToolStart: (name, label, count) => started.push({ name, label, count }) },
|
||||
});
|
||||
|
||||
expect(started).toEqual([
|
||||
{ name: "get_datetime", label: "Checking the time", count: 1 },
|
||||
{ name: "get_datetime", label: "Checking the time", count: 2 },
|
||||
]);
|
||||
});
|
||||
expect(started).toEqual([
|
||||
{ name: "get_datetime", label: "Checking the time", count: 1 },
|
||||
{ name: "get_datetime", label: "Checking the time", count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("module wiring", () => {
|
||||
it("keeps the request, tool, and agent layers free of Vicinae imports", () => {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
citedIndices,
|
||||
collectSources,
|
||||
createSourceList,
|
||||
linkCitations,
|
||||
renderSources,
|
||||
} from "../lib/citations";
|
||||
import type { ToolCallRecord } from "../lib/types";
|
||||
|
||||
/** One record, with only the fields the source helpers read. */
|
||||
function call(sources?: ToolCallRecord["sources"]): ToolCallRecord {
|
||||
return { name: "web_search", arguments: {}, result: "", isError: false, sources };
|
||||
}
|
||||
|
||||
describe("createSourceList", () => {
|
||||
it("numbers sources from one in the order they were found", () => {
|
||||
const list = createSourceList();
|
||||
expect(list.add("First", "https://first.example").index).toBe(1);
|
||||
expect(list.add("Second", "https://second.example").index).toBe(2);
|
||||
});
|
||||
|
||||
it("gives a URL the number it already has, so a re-fetch cites the same page", () => {
|
||||
const list = createSourceList();
|
||||
list.add("First", "https://first.example");
|
||||
const again = list.add("First again", "https://first.example");
|
||||
|
||||
expect(again.index).toBe(1);
|
||||
// Nothing new was added, which is how the caller knows not to store it twice.
|
||||
expect(again.source).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the entry only the first time, so a turn stores it once", () => {
|
||||
const list = createSourceList();
|
||||
expect(list.add("First", "https://first.example").source).toEqual({
|
||||
index: 1,
|
||||
title: "First",
|
||||
url: "https://first.example",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores surrounding whitespace when matching a URL", () => {
|
||||
const list = createSourceList();
|
||||
list.add("First", "https://first.example");
|
||||
expect(list.add("First", " https://first.example ").index).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to the URL as the title when there is none", () => {
|
||||
const list = createSourceList();
|
||||
expect(list.add(" ", "https://first.example").source?.title).toBe("https://first.example");
|
||||
});
|
||||
|
||||
it("gives a result with no usable URL no number at all", () => {
|
||||
// Zero rather than the next number: there is nothing to link to, so the tool
|
||||
// prints no marker and the model cannot cite a page that has no address.
|
||||
const list = createSourceList();
|
||||
expect(list.add("Title", " ").index).toBe(0);
|
||||
expect(list.add("Next", "https://next.example").index).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectSources", () => {
|
||||
it("flattens a turn's records into one list in number order", () => {
|
||||
const collected = collectSources([
|
||||
call([{ index: 1, title: "First", url: "https://first.example" }]),
|
||||
call([{ index: 2, title: "Second", url: "https://second.example" }]),
|
||||
]);
|
||||
|
||||
expect(collected.map((source) => source.index)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("keeps the first record's version of a number", () => {
|
||||
// A number is assigned once, so two records carrying it describe the same
|
||||
// page, and the first one seen is the one that named it.
|
||||
const collected = collectSources([
|
||||
call([{ index: 1, title: "First", url: "https://first.example" }]),
|
||||
call([{ index: 1, title: "Duplicate", url: "https://first.example" }]),
|
||||
]);
|
||||
|
||||
expect(collected).toEqual([{ index: 1, title: "First", url: "https://first.example" }]);
|
||||
});
|
||||
|
||||
it("sorts by number even when the records arrive out of order", () => {
|
||||
const collected = collectSources([
|
||||
call([{ index: 3, title: "Third", url: "https://third.example" }]),
|
||||
call([{ index: 1, title: "First", url: "https://first.example" }]),
|
||||
]);
|
||||
|
||||
expect(collected.map((source) => source.index)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("is empty for a turn that consulted nothing", () => {
|
||||
expect(collectSources([])).toEqual([]);
|
||||
expect(collectSources([call()])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("citedIndices", () => {
|
||||
const sources = [
|
||||
{ index: 1, title: "First", url: "https://first.example" },
|
||||
{ index: 2, title: "Second", url: "https://second.example" },
|
||||
];
|
||||
|
||||
it("finds the numbers an answer cites, in either marker shape", () => {
|
||||
expect([...citedIndices("See [1] and [^2].", sources)].sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("ignores a number that is not a source", () => {
|
||||
expect([...citedIndices("See [9].", sources)]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("linkCitations", () => {
|
||||
const sources = [
|
||||
{ index: 1, title: "First", url: "https://first.example" },
|
||||
{ index: 3, title: "Third", url: "https://third.example" },
|
||||
];
|
||||
|
||||
it("turns a marker into a link whose label is the marker", () => {
|
||||
const result = linkCitations("Rain is likely [1].", sources);
|
||||
|
||||
expect(result.text).toBe("Rain is likely [\\[1\\]](https://first.example).");
|
||||
expect([...result.cited]).toEqual([1]);
|
||||
});
|
||||
|
||||
it("reads the footnote shape as the same thing", () => {
|
||||
expect(linkCitations("Rain [^1].", sources).text).toBe("Rain [\\[1\\]](https://first.example).");
|
||||
});
|
||||
|
||||
it("links a list of markers and keeps the comma", () => {
|
||||
const result = linkCitations("Both [1, 3] agree.", sources);
|
||||
|
||||
expect(result.text).toBe(
|
||||
"Both [\\[1\\]](https://first.example), [\\[3\\]](https://third.example) agree.",
|
||||
);
|
||||
expect([...result.cited].sort()).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("leaves a marker alone when any number in it is not a source", () => {
|
||||
// Half a link reads as a citation that works, which is worse than the marker
|
||||
// the model wrote: the reader cannot tell which half is missing.
|
||||
const result = linkCitations("See [1, 9].", sources);
|
||||
|
||||
expect(result.text).toBe("See [1, 9].");
|
||||
expect([...result.cited]).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves a marker alone when the destination is not one this window should open", () => {
|
||||
const unsafe = [{ index: 1, title: "Nasty", url: "javascript:alert(1)" }];
|
||||
const result = linkCitations("See [1].", unsafe);
|
||||
|
||||
expect(result.text).toBe("See [1].");
|
||||
expect([...result.cited]).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves a destination containing whitespace as text", () => {
|
||||
const odd = [{ index: 1, title: "Odd", url: "https://first.example/a b" }];
|
||||
expect(linkCitations("See [1].", odd).text).toBe("See [1].");
|
||||
});
|
||||
|
||||
it("does not touch a link the model wrote", () => {
|
||||
const result = linkCitations("See [1](https://elsewhere.example).", sources);
|
||||
expect(result.text).toBe("See [1](https://elsewhere.example).");
|
||||
});
|
||||
|
||||
it("does not touch a reference definition line", () => {
|
||||
expect(linkCitations("[1]: https://elsewhere.example", sources).text).toBe(
|
||||
"[1]: https://elsewhere.example",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a footnote definition for a source it already lists", () => {
|
||||
const answer = "Rain [^1].\n\n[^1]: First, https://first.example";
|
||||
expect(linkCitations(answer, sources).text).toBe("Rain [\\[1\\]](https://first.example).\n");
|
||||
});
|
||||
|
||||
it("keeps a markdown reference definition, which backs a link the model wrote", () => {
|
||||
// `[1]:` with no caret is reference syntax, not a footnote. Only the footnote
|
||||
// shape is unambiguously a duplicate of an entry we render ourselves.
|
||||
const answer = "Rain [^1].\n\n[1]: https://elsewhere.example";
|
||||
expect(linkCitations(answer, sources).text).toBe(
|
||||
"Rain [\\[1\\]](https://first.example).\n\n[1]: https://elsewhere.example",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a definition for a number it knows nothing about", () => {
|
||||
const answer = "Rain [^1].\n\n[^7]: Something else";
|
||||
expect(linkCitations(answer, sources).text).toBe(
|
||||
"Rain [\\[1\\]](https://first.example).\n\n[^7]: Something else",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a marker inside a code span as the text it is", () => {
|
||||
expect(linkCitations("Type `curl [1]` now.", sources).text).toBe("Type `curl [1]` now.");
|
||||
});
|
||||
|
||||
it("changes nothing when there are no sources", () => {
|
||||
expect(linkCitations("See [1].", []).text).toBe("See [1].");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderSources", () => {
|
||||
const sources = [
|
||||
{ index: 1, title: "First", url: "https://first.example" },
|
||||
{ index: 3, title: "Third", url: "https://third.example" },
|
||||
];
|
||||
|
||||
it("lists the sources the answer cited, numbered as the model numbered them", () => {
|
||||
const list = renderSources(sources, new Set([3]), false);
|
||||
|
||||
expect(list).toBe("_Sources_\n\n- [\\[3\\]](https://third.example) Third");
|
||||
});
|
||||
|
||||
it("lists everything when the answer cited nothing, once the turn has ended", () => {
|
||||
// Nothing to filter on, so the only thing left to show is what was consulted.
|
||||
expect(renderSources(sources, new Set(), false)).toBe(
|
||||
"_Sources_\n\n- [\\[1\\]](https://first.example) First\n- [\\[3\\]](https://third.example) Third",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays away while an answer is still arriving", () => {
|
||||
// Otherwise the list appears in full at the first token and then shrinks as
|
||||
// citations arrive, which reads as a glitch rather than as settling.
|
||||
expect(renderSources(sources, new Set(), true)).toBe("");
|
||||
});
|
||||
|
||||
it("lists nothing when there was nothing to list", () => {
|
||||
expect(renderSources([], new Set(), false)).toBe("");
|
||||
});
|
||||
|
||||
it("keeps an entry whose destination cannot be linked", () => {
|
||||
const unsafe = [{ index: 1, title: "Nasty", url: "javascript:alert(1)" }];
|
||||
expect(renderSources(unsafe, new Set(), false)).toBe("_Sources_\n\n- [1] Nasty");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
STREAMING_CURSOR,
|
||||
buildThreadMarkdown,
|
||||
quote,
|
||||
sessionToPlainText,
|
||||
} from "../lib/markdown";
|
||||
import { STREAMING_CURSOR, buildThreadMarkdown, quote, sessionToPlainText } from "../lib/markdown";
|
||||
import type { ChatTurn, ThreadMessage } from "../lib/types";
|
||||
|
||||
function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">): ThreadMessage {
|
||||
@@ -235,9 +230,7 @@ describe("buildThreadMarkdown", () => {
|
||||
});
|
||||
|
||||
it("links a lone URL the model put in inline code", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ role: "assistant", text: "`https://example.com`" }),
|
||||
]);
|
||||
const markdown = buildThreadMarkdown([message({ role: "assistant", text: "`https://example.com`" })]);
|
||||
|
||||
expect(markdown).not.toContain("```");
|
||||
expect(markdown).toContain("[https://example.com](https://example.com)");
|
||||
@@ -655,3 +648,73 @@ describe("attachments in the transcript", () => {
|
||||
expect(text).toContain("_(screenshot attached)_");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sources", () => {
|
||||
const sources = [
|
||||
{ index: 1, title: "First", url: "https://first.example" },
|
||||
{ index: 2, title: "Second", url: "https://second.example" },
|
||||
];
|
||||
|
||||
/** One search, and the answer that may or may not point at it. */
|
||||
function answered(text: string, streaming = false): string {
|
||||
return buildThreadMarkdown(
|
||||
[
|
||||
message({
|
||||
id: "t1:assistant",
|
||||
role: "assistant",
|
||||
text,
|
||||
isStreaming: streaming,
|
||||
toolCalls: [{ name: "web_search", arguments: {}, result: "", isError: false, sources }],
|
||||
}),
|
||||
message({ id: "t1:user", role: "user", text: "When is the rain?" }),
|
||||
],
|
||||
{ showToolCalls: false, showReasoning: false },
|
||||
);
|
||||
}
|
||||
|
||||
it("links the markers and lists the sources the answer leaned on", () => {
|
||||
const text = answered("Rain is likely [1].");
|
||||
|
||||
expect(text).toContain("Rain is likely [\\[1\\]](https://first.example).");
|
||||
expect(text).toContain("_Sources_");
|
||||
expect(text).toContain("- [\\[1\\]](https://first.example) First");
|
||||
// The answer did not lean on the second one.
|
||||
expect(text).not.toContain("Second");
|
||||
});
|
||||
|
||||
it("lists everything the turn consulted when the answer cited nothing", () => {
|
||||
const text = answered("Rain is likely.");
|
||||
|
||||
expect(text).toContain("- [\\[1\\]](https://first.example) First");
|
||||
expect(text).toContain("- [\\[2\\]](https://second.example) Second");
|
||||
});
|
||||
|
||||
it("waits for a citation before drawing anything", () => {
|
||||
// Otherwise the list arrives in full with the first token and then shrinks as
|
||||
// citations appear, which reads as a glitch rather than as settling.
|
||||
expect(answered("Rain is", true)).not.toContain("_Sources_");
|
||||
});
|
||||
|
||||
it("adds nothing to a turn that consulted nothing", () => {
|
||||
expect(buildThreadMarkdown([message({ role: "assistant", text: "Plain." })])).toBe("Plain.");
|
||||
});
|
||||
|
||||
it("carries the list into the copied conversation", () => {
|
||||
const copied = sessionToPlainText([
|
||||
{
|
||||
id: "t1",
|
||||
prompt: "When is the rain?",
|
||||
response: "Rain [1].",
|
||||
thinking: "",
|
||||
model: "m",
|
||||
createdAt: 0,
|
||||
toolCalls: [{ name: "web_search", arguments: {}, result: "", isError: false, sources }],
|
||||
attachments: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(copied).toContain("**Lam**\n\nRain [1].");
|
||||
expect(copied).toContain("_Sources_");
|
||||
expect(copied).toContain("- [\\[1\\]](https://first.example) First");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,93 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderInlineContent, renderModelOutput } from "../lib/render";
|
||||
import type { Source } from "../lib/types";
|
||||
|
||||
/**
|
||||
* The drawn text, for the many cases here that are about what the pane shows
|
||||
* rather than about which citations were linked.
|
||||
*/
|
||||
function rendered(markdown: string, sources?: Source[]): string {
|
||||
return renderModelOutput(markdown, sources ? { sources } : {}).text;
|
||||
}
|
||||
|
||||
describe("renderModelOutput", () => {
|
||||
it("renders inline maths in a sentence", () => {
|
||||
expect(renderModelOutput("The answer is $x^2 + 1$, obviously.")).toBe("The answer is x² + 1, obviously.");
|
||||
expect(renderModelOutput("So \\(x^2\\) holds.")).toBe("So x² holds.");
|
||||
expect(rendered("The answer is $x^2 + 1$, obviously.")).toBe("The answer is x² + 1, obviously.");
|
||||
expect(rendered("So \\(x^2\\) holds.")).toBe("So x² holds.");
|
||||
});
|
||||
|
||||
it("leaves money and shell variables alone", () => {
|
||||
expect(renderModelOutput("Costs $5 and $10 today.")).toBe("Costs $5 and $10 today.");
|
||||
expect(renderModelOutput("cd $HOME && echo $PATH")).toBe("cd $HOME && echo $PATH");
|
||||
expect(rendered("Costs $5 and $10 today.")).toBe("Costs $5 and $10 today.");
|
||||
expect(rendered("cd $HOME && echo $PATH")).toBe("cd $HOME && echo $PATH");
|
||||
});
|
||||
|
||||
it("puts a display formula on a paragraph of its own", () => {
|
||||
expect(renderModelOutput("$$\\frac{a}{b} = c$$")).toBe("\na/b = c\n");
|
||||
expect(renderModelOutput("$$\nx = 1\n$$")).toBe("\nx = 1\n");
|
||||
expect(renderModelOutput("\\[\\sum_{i=1}^{n} i\\]")).toBe("\n∑ᵢ₌₁ⁿ i\n");
|
||||
expect(rendered("$$\\frac{a}{b} = c$$")).toBe("\na/b = c\n");
|
||||
expect(rendered("$$\nx = 1\n$$")).toBe("\nx = 1\n");
|
||||
expect(rendered("\\[\\sum_{i=1}^{n} i\\]")).toBe("\n∑ᵢ₌₁ⁿ i\n");
|
||||
});
|
||||
|
||||
it("does not let a stray marker swallow the rest of the answer", () => {
|
||||
// An opening `$$` with no closer only counts when what follows reads as a
|
||||
// formula, so a price is not a formula and the paragraphs after it survive.
|
||||
expect(renderModelOutput("The price is\n$$5 per unit.\n\nThat is cheap.")).toBe(
|
||||
expect(rendered("The price is\n$$5 per unit.\n\nThat is cheap.")).toBe(
|
||||
"The price is\n$$5 per unit.\n\nThat is cheap.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a maths fence as a formula rather than as code", () => {
|
||||
expect(renderModelOutput("```math\n\\sum_{i=1}^{n} i\n```")).toBe("\n∑ᵢ₌₁ⁿ i\n");
|
||||
expect(renderModelOutput("```latex\nx^2\n```")).toBe("\nx²\n");
|
||||
expect(rendered("```math\n\\sum_{i=1}^{n} i\n```")).toBe("\n∑ᵢ₌₁ⁿ i\n");
|
||||
expect(rendered("```latex\nx^2\n```")).toBe("\nx²\n");
|
||||
});
|
||||
|
||||
it("leaves maths inside a fence alone", () => {
|
||||
const markdown = "```ts\nconst x = $a$;\n```";
|
||||
expect(renderModelOutput(markdown)).toBe(markdown);
|
||||
expect(rendered(markdown)).toBe(markdown);
|
||||
});
|
||||
|
||||
it("leaves a dollar inside a code span alone", () => {
|
||||
expect(renderModelOutput("`$x^2$` stays.")).toBe("`$x^2$` stays.");
|
||||
expect(rendered("`$x^2$` stays.")).toBe("`$x^2$` stays.");
|
||||
});
|
||||
|
||||
it("renames a fence language the renderer would print but not colour", () => {
|
||||
expect(renderModelOutput("```console\nls\n```")).toBe("```bash\nls\n```");
|
||||
expect(renderModelOutput("```terminal\nls\n```")).toBe("```bash\nls\n```");
|
||||
expect(rendered("```console\nls\n```")).toBe("```bash\nls\n```");
|
||||
expect(rendered("```terminal\nls\n```")).toBe("```bash\nls\n```");
|
||||
});
|
||||
|
||||
it("drops a fence label that carries no information", () => {
|
||||
expect(renderModelOutput("```text\nplain\n```")).toBe("```\nplain\n```");
|
||||
expect(rendered("```text\nplain\n```")).toBe("```\nplain\n```");
|
||||
});
|
||||
|
||||
it("leaves a label that does resolve exactly as written", () => {
|
||||
const markdown = "```python\nprint(1)\n```";
|
||||
expect(renderModelOutput(markdown)).toBe(markdown);
|
||||
expect(rendered(markdown)).toBe(markdown);
|
||||
});
|
||||
|
||||
it("fences a diagram so its alignment survives", () => {
|
||||
expect(renderModelOutput("src/\n├── a.ts\n└── b.ts")).toBe("```\nsrc/\n├── a.ts\n└── b.ts\n```");
|
||||
expect(rendered("src/\n├── a.ts\n└── b.ts")).toBe("```\nsrc/\n├── a.ts\n└── b.ts\n```");
|
||||
});
|
||||
|
||||
it("leaves a real table to the table renderer", () => {
|
||||
const table = "| a | b |\n| --- | --- |\n| 1 | 2 |";
|
||||
expect(renderModelOutput(table)).toBe(table);
|
||||
expect(rendered(table)).toBe(table);
|
||||
});
|
||||
|
||||
it("leaves a diagram inside a fence alone", () => {
|
||||
const markdown = "```text\n├── a.ts\n└── b.ts\n```";
|
||||
expect(renderModelOutput(markdown)).toBe("```\n├── a.ts\n└── b.ts\n```");
|
||||
expect(rendered(markdown)).toBe("```\n├── a.ts\n└── b.ts\n```");
|
||||
});
|
||||
|
||||
it("filters HTML in the flow of an answer", () => {
|
||||
expect(renderModelOutput("<script>alert(1)</script>still here")).toBe("still here");
|
||||
expect(renderModelOutput('<span style="color:#ff0000;position:fixed">x</span>')).toBe(
|
||||
expect(rendered("<script>alert(1)</script>still here")).toBe("still here");
|
||||
expect(rendered('<span style="color:#ff0000;position:fixed">x</span>')).toBe(
|
||||
'<span style="color:#ff0000">x</span>',
|
||||
);
|
||||
expect(renderModelOutput('Read <a href="https://example.com">the docs</a>.')).toBe(
|
||||
expect(rendered('Read <a href="https://example.com">the docs</a>.')).toBe(
|
||||
"Read [the docs](https://example.com).",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the markup inside a fence as the code it is", () => {
|
||||
const markdown = "```html\n<b>x</b>\n```";
|
||||
expect(renderModelOutput(markdown)).toBe(markdown);
|
||||
expect(rendered(markdown)).toBe(markdown);
|
||||
});
|
||||
|
||||
it("hands a block of HTML to the engine as a paragraph", () => {
|
||||
// A line that starts with a block tag is an HTML *block*, and Vicinae's block
|
||||
// path keeps only the text of one. Wrapped in a span it is a paragraph, which
|
||||
// is the path that keeps raw HTML.
|
||||
expect(renderModelOutput("<table><tr><td>a</td><td>b</td></tr></table>")).toBe(
|
||||
expect(rendered("<table><tr><td>a</td><td>b</td></tr></table>")).toBe(
|
||||
"<span><table><tr><td>a</td><td>b</td></tr></table></span>",
|
||||
);
|
||||
});
|
||||
@@ -95,35 +104,31 @@ describe("renderModelOutput", () => {
|
||||
it("joins a block of HTML onto one line", () => {
|
||||
// `class` is dropped, because there is no stylesheet for it to name, and the
|
||||
// style attribute is the only thing the renderer reads.
|
||||
expect(renderModelOutput('<div class="card">\n<b>bold</b>\n</div>')).toBe(
|
||||
"<span><div> <b>bold</b> </div></span>",
|
||||
);
|
||||
expect(rendered('<div class="card">\n<b>bold</b>\n</div>')).toBe("<span><div> <b>bold</b> </div></span>");
|
||||
});
|
||||
|
||||
it("leaves markup that does not balance where the text it carries still shows", () => {
|
||||
expect(renderModelOutput("<div>hello")).toBe("<div>hello");
|
||||
expect(rendered("<div>hello")).toBe("<div>hello");
|
||||
});
|
||||
|
||||
it("leaves a block holding an image alone, because that path loads it", () => {
|
||||
expect(renderModelOutput('<div><img src="https://x/y.png"></div>')).toBe(
|
||||
'<div><img src="https://x/y.png"></div>',
|
||||
);
|
||||
expect(rendered('<div><img src="https://x/y.png"></div>')).toBe('<div><img src="https://x/y.png"></div>');
|
||||
});
|
||||
|
||||
it("leaves a lone inline tag alone, because it is not a block", () => {
|
||||
const markdown = "Try <b>bold</b> for that.";
|
||||
expect(renderModelOutput(markdown)).toBe(markdown);
|
||||
expect(rendered(markdown)).toBe(markdown);
|
||||
});
|
||||
|
||||
it("keeps an answer that has nothing to render exactly as written", () => {
|
||||
const plain = "Just a sentence, with a [link](https://example.com) and `code`.";
|
||||
expect(renderModelOutput(plain)).toBe(plain);
|
||||
expect(rendered(plain)).toBe(plain);
|
||||
});
|
||||
|
||||
it("repairs fences in model output but not in what a person typed", () => {
|
||||
// A lone command is a fence when a model writes it, and a question about it
|
||||
// is left as the person typed it.
|
||||
expect(renderModelOutput("`pwd`")).toBe("```bash\npwd\n```");
|
||||
expect(rendered("`pwd`")).toBe("```bash\npwd\n```");
|
||||
expect(renderInlineContent("`pwd`")).toBe("`pwd`");
|
||||
});
|
||||
|
||||
@@ -133,3 +138,31 @@ describe("renderModelOutput", () => {
|
||||
expect(renderInlineContent("```ts\nconst x = $a$;\n```")).toBe("```ts\nconst x = $a$;\n```");
|
||||
});
|
||||
});
|
||||
|
||||
describe("citations in the pipeline", () => {
|
||||
const sources = [{ index: 1, title: "First", url: "https://first.example" }];
|
||||
|
||||
it("reports which citations it linked", () => {
|
||||
const result = renderModelOutput("Rain [1].", { sources });
|
||||
|
||||
expect(result.text).toBe("Rain [\\[1\\]](https://first.example).");
|
||||
expect([...result.cited]).toEqual([1]);
|
||||
});
|
||||
|
||||
it("links a marker that has to survive the maths and markup passes", () => {
|
||||
expect(rendered("Let $x^2$ be [1], and nothing else.", sources)).toContain(
|
||||
"x² be [\\[1\\]](https://first.example)",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a marker inside a fence for the reader to copy", () => {
|
||||
const text = rendered("```bash\ncurl [1]\n```\n\nSee [1].", sources);
|
||||
|
||||
expect(text).toContain("curl [1]");
|
||||
expect(text).toContain("[\\[1\\]](https://first.example).");
|
||||
});
|
||||
|
||||
it("changes nothing when no sources are given", () => {
|
||||
expect(rendered("Rain [1].")).toBe("Rain [1].");
|
||||
});
|
||||
});
|
||||
|
||||
+120
-21
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSourceList } from "../lib/citations";
|
||||
import { TOOL_DEFINITIONS, WEB_TOOL_NAMES, executeTool, toolsFor } from "../lib/tools";
|
||||
|
||||
/**
|
||||
@@ -86,7 +87,7 @@ describe("web_search", () => {
|
||||
expect(requestBody()).toEqual({ query: "vicinae launcher", max_results: 3 });
|
||||
});
|
||||
|
||||
it("renders results as numbered title, url, and snippet", async () => {
|
||||
it("renders each result under the number the model will cite", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
results: [
|
||||
@@ -99,9 +100,9 @@ describe("web_search", () => {
|
||||
const result = await executeTool("web_search", { query: "x" }, context);
|
||||
|
||||
expect(result.content).toContain('Web results for "x"');
|
||||
expect(result.content).toContain("1. First");
|
||||
expect(result.content).toContain("[1] First");
|
||||
expect(result.content).toContain("https://first.example");
|
||||
expect(result.content).toContain("2. Second");
|
||||
expect(result.content).toContain("[2] Second");
|
||||
});
|
||||
|
||||
it("omits max_results when the model does not ask for it", async () => {
|
||||
@@ -177,15 +178,13 @@ describe("web_fetch", () => {
|
||||
expect(request().url).toBe("https://ollama.com/api/web_fetch");
|
||||
expect(requestBody()).toEqual({ url: "https://example.com" });
|
||||
|
||||
expect(result.content).toContain("Title: Example");
|
||||
expect(result.content).toContain("[1] Example");
|
||||
expect(result.content).toContain("Body text.");
|
||||
expect(result.content).toContain("https://a.example");
|
||||
});
|
||||
|
||||
it("truncates an enormous page and says so", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ title: "Huge", content: "y".repeat(20_000), links: [] }),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ title: "Huge", content: "y".repeat(20_000), links: [] }));
|
||||
|
||||
const result = await executeTool("web_fetch", { url: "https://huge.example" }, context);
|
||||
|
||||
@@ -198,7 +197,7 @@ describe("web_fetch", () => {
|
||||
|
||||
const result = await executeTool("web_fetch", { url: "https://untitled.example" }, context);
|
||||
|
||||
expect(result.content).toContain("Title: https://untitled.example");
|
||||
expect(result.content).toContain("[1] https://untitled.example");
|
||||
});
|
||||
|
||||
it("rejects a call with no url without touching the network", async () => {
|
||||
@@ -210,12 +209,104 @@ describe("web_fetch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sources", () => {
|
||||
it("keeps numbering across the calls in one turn", async () => {
|
||||
// Two searches in a turn must not each start at 1, or a marker in the answer
|
||||
// would point at two different pages at once.
|
||||
const list = createSourceList();
|
||||
const turn = { ...context, sources: list };
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{ title: "First", url: "https://first.example", content: "First body" },
|
||||
{ title: "Second", url: "https://second.example", content: "Second body" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ results: [{ title: "Third", url: "https://third.example", content: "Third" }] }),
|
||||
);
|
||||
|
||||
const first = await executeTool("web_search", { query: "one" }, turn);
|
||||
const second = await executeTool("web_search", { query: "two" }, turn);
|
||||
|
||||
expect(first.content).toContain("[1] First");
|
||||
expect(second.content).toContain("[3] Third");
|
||||
expect(second.sources?.map((source) => source.index)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("returns only what the call itself added", async () => {
|
||||
// The turn's whole list is the flattening of its records, so a record that
|
||||
// repeated the running total would store the same page once per later call.
|
||||
const list = createSourceList();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{ title: "First", url: "https://first.example", content: "First body" },
|
||||
{ title: "Second", url: "https://second.example", content: "Second body" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executeTool("web_search", { query: "x" }, { ...context, sources: list });
|
||||
|
||||
expect(result.sources?.map((source) => source.index)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("gives a page the number it already has, however the turn reached it", async () => {
|
||||
const list = createSourceList();
|
||||
const turn = { ...context, sources: list };
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ results: [{ title: "First", url: "https://first.example", content: "B" }] }),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ title: "First", content: "body", links: [] }));
|
||||
|
||||
await executeTool("web_search", { query: "x" }, turn);
|
||||
const page = await executeTool("web_fetch", { url: "https://first.example" }, turn);
|
||||
|
||||
expect(page.content).toContain("[1] First");
|
||||
// Nothing new, so nothing for the turn to store a second time.
|
||||
expect(page.sources).toEqual([]);
|
||||
});
|
||||
|
||||
it("numbers from one when the caller keeps no list of its own", async () => {
|
||||
// The format must not depend on whether anyone is collecting: a test or a
|
||||
// future caller with no interest in citations sees the same numbering.
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ results: [{ title: "First", url: "https://first.example", content: "B" }] }),
|
||||
);
|
||||
|
||||
const result = await executeTool("web_search", { query: "x" }, context);
|
||||
|
||||
expect(result.content).toContain("[1] First");
|
||||
});
|
||||
|
||||
it("prints no number for a result with no address to link to", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ results: [{ title: "Nowhere", url: " ", content: "Body" }] }),
|
||||
);
|
||||
|
||||
const result = await executeTool("web_search", { query: "x" }, context);
|
||||
|
||||
expect(result.content).toContain("Nowhere");
|
||||
expect(result.content).not.toContain("[0]");
|
||||
expect(result.sources).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local tools", () => {
|
||||
it("uses the injected clock instead of the wall clock", async () => {
|
||||
const result = await executeTool("get_datetime", {}, {
|
||||
...context,
|
||||
now: new Date("2026-01-02T03:04:05Z"),
|
||||
});
|
||||
const result = await executeTool(
|
||||
"get_datetime",
|
||||
{},
|
||||
{
|
||||
...context,
|
||||
now: new Date("2026-01-02T03:04:05Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toContain("2026-01-02T03:04:05.000Z");
|
||||
@@ -245,10 +336,14 @@ describe("local tools", () => {
|
||||
});
|
||||
|
||||
it("puts the configured place first and says to prefer it", async () => {
|
||||
const result = await executeTool("get_location", {}, {
|
||||
...context,
|
||||
place: "Cebu City, Philippines",
|
||||
});
|
||||
const result = await executeTool(
|
||||
"get_location",
|
||||
{},
|
||||
{
|
||||
...context,
|
||||
place: "Cebu City, Philippines",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.content).toContain("Place: Cebu City, Philippines");
|
||||
expect(result.content).toContain("prefer it");
|
||||
@@ -294,11 +389,15 @@ describe("get_location with IP geolocation", () => {
|
||||
// would be a privacy cost for nothing.
|
||||
const ipPlace = vi.fn(async () => resolved);
|
||||
|
||||
const result = await executeTool("get_location", {}, {
|
||||
...context,
|
||||
place: "Cebu City, Philippines",
|
||||
ipPlace,
|
||||
});
|
||||
const result = await executeTool(
|
||||
"get_location",
|
||||
{},
|
||||
{
|
||||
...context,
|
||||
place: "Cebu City, Philippines",
|
||||
ipPlace,
|
||||
},
|
||||
);
|
||||
|
||||
expect(ipPlace).not.toHaveBeenCalled();
|
||||
expect(result.content).toContain("Place: Cebu City, Philippines");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { mapOutsideCodeSpans, markdownLink } from "./code";
|
||||
import type { Source, ToolCallRecord } from "./types";
|
||||
|
||||
/**
|
||||
* Numbered sources for one turn, and the citation markers that point at them.
|
||||
*
|
||||
* Vicinae has no footnote support to lean on: it parses markdown with
|
||||
* `CMARK_OPT_DEFAULT` and attaches only the table and strikethrough extensions
|
||||
* (`src/server/src/ui/quick/markdown-model.cpp` upstream), so a `[^1]` written by
|
||||
* a model arrives in the pane as literal text. Everything a footnote would do
|
||||
* therefore has to happen here, on the way to the renderer.
|
||||
*
|
||||
* Imports no Vicinae module, so it unit tests with no runtime.
|
||||
*/
|
||||
|
||||
/** What a turn uses to hand out numbers, one per page it consults. */
|
||||
export interface SourceList {
|
||||
/**
|
||||
* The number for a URL, registering it the first time it is seen.
|
||||
*
|
||||
* `index` is 0 for a result with no usable address, which is the caller's
|
||||
* signal to print no marker at all. `source` comes back only when this call
|
||||
* registered something new, so a caller records each page exactly once.
|
||||
*/
|
||||
add(title: string, url: string): { index: number; source?: Source };
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh list, numbering from one.
|
||||
*
|
||||
* Scoped to a turn rather than a conversation: a new question starts again at
|
||||
* one, which is what lets a marker mean one thing for the whole answer beside
|
||||
* it, and lets the transcript list sources per answer rather than per session.
|
||||
*/
|
||||
export function createSourceList(): SourceList {
|
||||
const byUrl = new Map<string, Source>();
|
||||
let next = 1;
|
||||
|
||||
return {
|
||||
add(title, url) {
|
||||
const address = url.trim();
|
||||
if (address.length === 0) return { index: 0 };
|
||||
|
||||
const known = byUrl.get(address);
|
||||
if (known) return { index: known.index };
|
||||
|
||||
const source: Source = { index: next, title: title.trim() || address, url: address };
|
||||
next += 1;
|
||||
byUrl.set(address, source);
|
||||
|
||||
return { index: source.index, source };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every source a turn consulted, in number order.
|
||||
*
|
||||
* A record holds only what its own call added, so the turn's list is the
|
||||
* flattening of its records. That keeps the running total from being stored once
|
||||
* per later call, and it means the list can be rebuilt from a stored turn without
|
||||
* re-reading a tool result string and hoping its format held.
|
||||
*/
|
||||
export function collectSources(calls: ToolCallRecord[]): Source[] {
|
||||
const byIndex = new Map<number, Source>();
|
||||
|
||||
for (const call of calls) {
|
||||
for (const source of call.sources ?? []) {
|
||||
// First wins. A number is assigned once, so a second record carrying it is
|
||||
// describing the same page, and the first one is what named it.
|
||||
if (!byIndex.has(source.index)) byIndex.set(source.index, source);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byIndex.values()].sort((left, right) => left.index - right.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* The numbers an answer cites that are actually sources.
|
||||
*
|
||||
* A plain scan, without the fence awareness the linking pass has, because its one
|
||||
* caller is the clipboard export: what a copied document needs is the pages the
|
||||
* answer leaned on, and a `[1]` inside a snippet there costs a reader nothing.
|
||||
*/
|
||||
export function citedIndices(answer: string, sources: Source[]): Set<number> {
|
||||
const known = new Set(sources.map((source) => source.index));
|
||||
const cited = new Set<number>();
|
||||
|
||||
for (const match of answer.matchAll(/\[\^?(\d+(?:\s*,\s*\d+)*)\]/g)) {
|
||||
for (const part of match[1].split(",")) {
|
||||
const index = Number(part.trim());
|
||||
if (known.has(index)) cited.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
return cited;
|
||||
}
|
||||
|
||||
/** What linking did to one run of prose. */
|
||||
export interface CitationResult {
|
||||
text: string;
|
||||
/** The numbers that became links, which is what the list filters on. */
|
||||
cited: Set<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A citation marker the model wrote, in the two shapes it reaches for.
|
||||
*
|
||||
* The trailing guards are what keep this off markdown the model wrote on purpose:
|
||||
* `](` starts a link, and `]:` is a reference definition. The digit list allows
|
||||
* `[1, 3]`, which is how a sentence resting on two sources is usually written.
|
||||
*/
|
||||
const CITATION_MARKER = /\[\^?(\d+(?:\s*,\s*\d+)*)\](?![(:])/g;
|
||||
|
||||
/**
|
||||
* A footnote definition line, which is `[^1]: title, url` on a line of its own.
|
||||
*
|
||||
* The caret is required rather than optional, and the two shapes mean different
|
||||
* things. `[1]: something` is markdown's own reference definition syntax, which
|
||||
* backs a link the model wrote, so dropping one because it happens to carry a
|
||||
* source number would break that link. `[^1]:` is a footnote definition, which is
|
||||
* a duplicate of an entry we render ourselves.
|
||||
*/
|
||||
const FOOTNOTE_DEFINITION = /^\s*\[\^(\d+)\]:\s*\S/;
|
||||
|
||||
/**
|
||||
* A destination this window can safely be asked to open, or nothing.
|
||||
*
|
||||
* A search result is untrusted input, so the scheme is checked here for the same
|
||||
* reason `SAFE_URL` in `html.ts` checks it: a clickable `javascript:` link would
|
||||
* be a real vulnerability rather than a rendering quirk. The rest of the test is
|
||||
* characters that would break the link or smuggle a second one out of it.
|
||||
*/
|
||||
function linkableUrl(url: string): string | undefined {
|
||||
const address = url.trim();
|
||||
if (!/^https?:\/\//i.test(address)) return undefined;
|
||||
if (/[\s<>\\]/.test(address)) return undefined;
|
||||
|
||||
// Control characters by code point. A character class would say the same thing
|
||||
// and trip `no-control-regex`, and a NUL or a DEL in a destination is never
|
||||
// anything but damage, so it is refused either way.
|
||||
for (const character of address) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
if (code < 0x20 || code === 0x7f) return undefined;
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a definition line for a source the turn already lists.
|
||||
*
|
||||
* The entry is drawn from what the tool returned, so the model's copy of the same
|
||||
* thing is a duplicate line rather than information. One for a number we know
|
||||
* nothing about is left alone, because then it is the only place that text exists.
|
||||
*/
|
||||
function stripDefinitions(text: string, known: Set<number>): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((line) => {
|
||||
const definition = FOOTNOTE_DEFINITION.exec(line);
|
||||
return !definition || !known.has(Number(definition[1]));
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* `[1]`, `[^1]` and `[1, 3]` as links, plus the numbers that became links.
|
||||
*
|
||||
* One run of prose only: fences are the caller's business, because the caller
|
||||
* already walks the document in segments for the maths pass and there is no
|
||||
* reason for two ideas of where code starts.
|
||||
*/
|
||||
export function linkCitations(text: string, sources: Source[]): CitationResult {
|
||||
const cited = new Set<number>();
|
||||
if (sources.length === 0) return { text, cited };
|
||||
|
||||
const byIndex = new Map(sources.map((source) => [source.index, source]));
|
||||
const prose = stripDefinitions(text, new Set(byIndex.keys()));
|
||||
|
||||
const linked = mapOutsideCodeSpans(prose, (chunk) =>
|
||||
chunk.replace(CITATION_MARKER, (whole: string, digits: string) => {
|
||||
const wanted = digits.split(",").map((part) => Number(part.trim()));
|
||||
const links: string[] = [];
|
||||
|
||||
for (const index of wanted) {
|
||||
const source = byIndex.get(index);
|
||||
const href = source ? linkableUrl(source.url) : undefined;
|
||||
// All or nothing, and nothing at all if a destination is unusable: a
|
||||
// marker that is partly a link reads as a citation that works.
|
||||
if (!href) return whole;
|
||||
|
||||
links.push(markdownLink(`[${index}]`, href));
|
||||
}
|
||||
|
||||
for (const index of wanted) cited.add(index);
|
||||
return links.join(", ");
|
||||
}),
|
||||
);
|
||||
|
||||
return { text: linked, cited };
|
||||
}
|
||||
|
||||
/**
|
||||
* The sources under an answer, or nothing when there is nothing to say.
|
||||
*
|
||||
* The marker is the link, matching the inline marker character for character, so
|
||||
* one token means one thing in both places. Numbers are the model's rather than
|
||||
* the reader's: `[3]` is the third source the turn found whether or not the answer
|
||||
* cited the two before it, which keeps the transcript agreeing with the tool
|
||||
* output and with anything the model said about its own numbering.
|
||||
*/
|
||||
export function renderSources(sources: Source[], cited: Set<number>, streaming: boolean): string {
|
||||
// While an answer is arriving the cited set is still filling up, so the list
|
||||
// waits rather than appearing in full and then dropping most of itself. An
|
||||
// answer that cites nothing at all is the one case left to fall back on
|
||||
// everything consulted, because there is then nothing to filter by.
|
||||
const shown =
|
||||
cited.size > 0 ? sources.filter((source) => cited.has(source.index)) : streaming ? [] : sources;
|
||||
if (shown.length === 0) return "";
|
||||
|
||||
const entries = shown.map((source) => {
|
||||
const href = linkableUrl(source.url);
|
||||
const marker = href ? markdownLink(`[${source.index}]`, href) : `[${source.index}]`;
|
||||
return `- ${marker} ${source.title}`;
|
||||
});
|
||||
|
||||
return ["_Sources_", "", ...entries].join("\n");
|
||||
}
|
||||
@@ -96,6 +96,8 @@ Fence every command and every snippet, and always name the language: bash for sh
|
||||
|
||||
A web address is a link, never code. A markdown link, written as [label](https://example.com), is clickable in this window, while a URL inside backticks or a fenced block is dead text the user cannot open. So never fence a URL, never put one in inline code, and never answer "here are the links" with a snippet. Label a link with what it is ("OpenClickly on GitHub"), and when you give several, put each on its own bullet with its own link.
|
||||
|
||||
When a tool result gives you a source, cite it: put its number in square brackets right after the sentence it supports, like a claim resting on a search result [1]. Cite the page that carries the point rather than the search that found it, cite one number at a time when a sentence rests on two of them (write [1][2], not [1,2]), and never cite a number you were not shown. The window turns that number into a link and lists the sources under your answer, so write no footnote definitions and no sources or references list of your own.
|
||||
|
||||
A fenced block is never a whole answer on its own. Say in one plain sentence what the code does or when to reach for it, so the reply reads like something a person wrote rather than a bare snippet.
|
||||
|
||||
Call a tool when it makes the answer more accurate. Anything time-sensitive or past your cutoff should be checked rather than guessed, and never invent tool output. If a tool fails, say so and answer with what you know.
|
||||
|
||||
+33
-6
@@ -1,5 +1,6 @@
|
||||
import { imageSource } from "./attachments";
|
||||
import { renderInlineContent, renderModelOutput } from "./render";
|
||||
import { citedIndices, collectSources, renderSources } from "./citations";
|
||||
import { renderInlineContent, renderModelOutput, type RenderedMarkdown } from "./render";
|
||||
import { describeActivity } from "./thread";
|
||||
import type { Attachment, ChatTurn, ThreadMessage, ToolCallRecord } from "./types";
|
||||
|
||||
@@ -150,11 +151,20 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
|
||||
|
||||
/** The answer, or whatever stands in for it while there is none. */
|
||||
const answered = message.text.trim();
|
||||
/**
|
||||
* The turn's sources, read back off its records rather than out of a tool result
|
||||
* string, so a reopened conversation draws the same list with the same numbers.
|
||||
*/
|
||||
const sources = collectSources(message.toolCalls);
|
||||
const drawn: RenderedMarkdown = answered
|
||||
? // One pass, in one place: fences get their languages and links recovered, then
|
||||
// TeX becomes something drawable, a citation marker becomes a link to the page
|
||||
// it names, and the HTML around all of it is filtered to the subset the
|
||||
// renderer acts on. See `render.ts` for the order and why.
|
||||
renderModelOutput(answered, { sources })
|
||||
: { text: "", cited: new Set<number>() };
|
||||
const body = answered
|
||||
? // One pass, in one place: fences get their languages and links recovered,
|
||||
// then TeX becomes something drawable and the HTML around it is filtered to
|
||||
// the subset the renderer acts on. See `render.ts` for the order and why.
|
||||
renderModelOutput(answered)
|
||||
? drawn.text
|
||||
: !message.isStreaming
|
||||
? "_No response._"
|
||||
: activityLine
|
||||
@@ -178,6 +188,14 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
|
||||
// something went wrong and should not be skimmed past.
|
||||
if (message.error) sections.push("", `**Error:** ${message.error}`);
|
||||
|
||||
/**
|
||||
* Sources belong to the answer rather than to the tool list, so they are drawn
|
||||
* whether or not the user asked to see tool calls. Hiding the activity line is
|
||||
* about chrome; a citation list is the answer's own provenance.
|
||||
*/
|
||||
const list = renderSources(sources, drawn.cited, message.isStreaming);
|
||||
if (list) sections.push("", list);
|
||||
|
||||
/**
|
||||
* Tool calls are a display preference. When they are on, every tool is named
|
||||
* once. When they are off, nothing is listed; a failure or a refused repeat
|
||||
@@ -276,7 +294,16 @@ export function sessionToPlainText(turns: ChatTurn[]): string {
|
||||
const attached = turn.attachments.length > 0 ? "\n\n_(screenshot attached)_" : "";
|
||||
const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_");
|
||||
const failure = turn.error ? `\n\n**[error]** ${turn.error}` : "";
|
||||
return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}`;
|
||||
/**
|
||||
* The same list the transcript draws, worked out from a plain scan rather than
|
||||
* from the panel's own set. A copied conversation is markdown on its way
|
||||
* somewhere else, where a `[1]` inside a snippet counting as a citation costs a
|
||||
* reader nothing.
|
||||
*/
|
||||
const sources = collectSources(turn.toolCalls);
|
||||
const list = renderSources(sources, citedIndices(turn.response, sources), false);
|
||||
const cited = list ? `\n\n${list}` : "";
|
||||
return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}${cited}`;
|
||||
})
|
||||
.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
+50
-2
@@ -1,6 +1,8 @@
|
||||
import { linkCitations } from "./citations";
|
||||
import { CLOSING_FENCE, OPENING_FENCE, mapOutsideCodeSpans, readCodeSpan, repairCodeBlocks } from "./code";
|
||||
import { sanitizeInlineHtml } from "./html";
|
||||
import { looksLikeMath, renderLatex } from "./latex";
|
||||
import type { Source } from "./types";
|
||||
|
||||
/**
|
||||
* The rendering pass for model output.
|
||||
@@ -490,6 +492,30 @@ export function renderInlineContent(markdown: string): string {
|
||||
return flatten(htmlPass(mathPass(splitSegments(markdown))));
|
||||
}
|
||||
|
||||
/** What the pane will draw, and what the answer cited so the list can filter. */
|
||||
export interface RenderedMarkdown {
|
||||
text: string;
|
||||
/**
|
||||
* The numbers this answer cited that became links.
|
||||
*
|
||||
* Reported rather than worked out again by the caller: the pass already had to
|
||||
* decide which markers were real and which were inside code, and a second reader
|
||||
* of the same text could only disagree with it.
|
||||
*/
|
||||
cited: Set<number>;
|
||||
}
|
||||
|
||||
/** Optional input to the pass. */
|
||||
export interface RenderOptions {
|
||||
/**
|
||||
* The turn's sources, so a marker can become a link to the page it names.
|
||||
*
|
||||
* Absent for a question, and absent for an answer in a turn that consulted
|
||||
* nothing, in which case the citation pass is the identity function.
|
||||
*/
|
||||
sources?: Source[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The full rendering pass, from what the model wrote to what the pane draws.
|
||||
*
|
||||
@@ -497,6 +523,28 @@ export function renderInlineContent(markdown: string): string {
|
||||
* markdown around each message (a blockquote for a question, a rule between
|
||||
* exchanges) and none of that should be read as model output.
|
||||
*/
|
||||
export function renderModelOutput(markdown: string): string {
|
||||
return renderInlineContent(repairCodeBlocks(markdown));
|
||||
export function renderModelOutput(markdown: string, options: RenderOptions = {}): RenderedMarkdown {
|
||||
const sources = options.sources ?? [];
|
||||
const cited = new Set<number>();
|
||||
|
||||
/**
|
||||
* Maths first, then citations, then markup.
|
||||
*
|
||||
* The order is forced by one collision. A link label has to escape its brackets
|
||||
* or the parser ends the link text early, and `[\[1\]]` is exactly the shape of
|
||||
* LaTeX's own `\[...\]` display delimiter, which the maths pass would eat and
|
||||
* hand back as a bare `1`. So the model's maths is converted before any marker
|
||||
* becomes a link: the citation pass writes final form markdown, which is not
|
||||
* input for anything but the markup pass, and that one only reads angle brackets.
|
||||
*/
|
||||
const segments = mathPass(splitSegments(repairCodeBlocks(markdown))).map((segment) => {
|
||||
if (segment.fence || sources.length === 0) return segment;
|
||||
|
||||
const result = linkCitations(segment.lines.join("\n"), sources);
|
||||
for (const index of result.cited) cited.add(index);
|
||||
|
||||
return { fence: false, language: "", lines: result.text.split("\n") };
|
||||
});
|
||||
|
||||
return { text: flatten(htmlPass(segments)), cited };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
+47
-6
@@ -1,7 +1,8 @@
|
||||
import { createSourceList, type SourceList } from "./citations";
|
||||
import { formatNow, getLocationContext, weekdayName } from "./location";
|
||||
import { webFetch, webSearch } from "./ollama";
|
||||
import type { GeoLookup } from "./geo";
|
||||
import type { ToolDefinition } from "./types";
|
||||
import type { Source, ToolDefinition } from "./types";
|
||||
import { errorMessage } from "./utils";
|
||||
|
||||
/**
|
||||
@@ -35,11 +36,34 @@ export interface ToolContext {
|
||||
* for a location on its own.
|
||||
*/
|
||||
ipPlace?: (options: { signal?: AbortSignal }) => Promise<GeoLookup>;
|
||||
/**
|
||||
* The turn's source list, when the caller keeps one.
|
||||
*
|
||||
* Optional, so a caller with no interest in citations (a unit test, or any
|
||||
* future tool that returns no sources) still gets the same numbered output: a
|
||||
* list is made on the spot when none is supplied.
|
||||
*/
|
||||
sources?: SourceList;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a tool returned.
|
||||
*
|
||||
* `sources` is separate from `content` on purpose. The content is prose written
|
||||
* for a model to read, while the sources are data the transcript renders, so a
|
||||
* change to one format cannot quietly break the other.
|
||||
*/
|
||||
export interface ToolResult {
|
||||
content: string;
|
||||
isError: boolean;
|
||||
/**
|
||||
* The sources this call added, for the transcript to list under the answer.
|
||||
*
|
||||
* Only what this call found, never the turn's running total: that total is the
|
||||
* flattening of the turn's records, so storing it here would write the same page
|
||||
* once per later call.
|
||||
*/
|
||||
sources?: Source[];
|
||||
}
|
||||
|
||||
/** Search snippets are truncated so a chatty result set cannot swallow the model's context window. */
|
||||
@@ -272,8 +296,11 @@ async function runWebSearch(args: Record<string, unknown>, context: ToolContext)
|
||||
return { content: `No web results for "${query}".`, isError: false };
|
||||
}
|
||||
|
||||
const list = context.sources ?? createSourceList();
|
||||
const added: Source[] = [];
|
||||
|
||||
const rendered = results
|
||||
.map((result, index) => {
|
||||
.map((result) => {
|
||||
const collapsed = result.content.replace(/\s+/g, " ").trim();
|
||||
// Mark the cut rather than silently dropping the tail: the model should
|
||||
// know a snippet was trimmed, and this matches what web_fetch does.
|
||||
@@ -281,11 +308,19 @@ async function runWebSearch(args: Record<string, unknown>, context: ToolContext)
|
||||
collapsed.length > MAX_SNIPPET_CHARS
|
||||
? `${collapsed.slice(0, MAX_SNIPPET_CHARS)} …[truncated]`
|
||||
: collapsed;
|
||||
return `${index + 1}. ${result.title}\n ${result.url}\n ${snippet}`;
|
||||
|
||||
const { index, source } = list.add(result.title, result.url);
|
||||
if (source) added.push(source);
|
||||
|
||||
// The number is what the model cites, so it is printed rather than left to be
|
||||
// inferred from position: three searches in one turn would otherwise each
|
||||
// start again at 1, and a marker in the answer would point at three pages.
|
||||
const marker = index > 0 ? `[${index}] ` : "";
|
||||
return `${marker}${result.title}\n ${result.url}\n ${snippet}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return { content: `Web results for "${query}":\n\n${rendered}`, isError: false };
|
||||
return { content: `Web results for "${query}":\n\n${rendered}`, isError: false, sources: added };
|
||||
}
|
||||
|
||||
async function runWebFetch(args: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
|
||||
@@ -305,10 +340,16 @@ async function runWebFetch(args: Record<string, unknown>, context: ToolContext):
|
||||
const truncated = page.content.length > MAX_FETCH_CHARS ? "\n\n[content truncated]" : "";
|
||||
const links = page.links.slice(0, 20);
|
||||
|
||||
const sections = [`Title: ${page.title}`, `URL: ${url}`, "", content + truncated];
|
||||
const { index, source } = (context.sources ?? createSourceList()).add(page.title, url);
|
||||
const heading = index > 0 ? `[${index}] ${page.title}` : page.title;
|
||||
|
||||
// The address keeps its label here, unlike in a search result: the body after it
|
||||
// can run for thousands of characters, and a reader scrolling that wants to be
|
||||
// able to find the page's own URL again.
|
||||
const sections = [heading, `URL: ${url}`, "", content + truncated];
|
||||
if (links.length > 0) {
|
||||
sections.push("", `Links on page:\n${links.map((link) => `- ${link}`).join("\n")}`);
|
||||
}
|
||||
|
||||
return { content: sections.join("\n"), isError: false };
|
||||
return { content: sections.join("\n"), isError: false, sources: source ? [source] : [] };
|
||||
}
|
||||
|
||||
@@ -84,12 +84,39 @@ export interface PendingAttachment extends Attachment {
|
||||
/** Base64 of the image, with no `data:` URL prefix. Ollama wants it bare. */
|
||||
base64: string;
|
||||
}
|
||||
/**
|
||||
* One numbered source, as a web tool found it.
|
||||
*
|
||||
* The number is the whole point: it is assigned once per turn, printed to the
|
||||
* model in the tool result, and rendered as the marker the reader clicks. The
|
||||
* model and the reader therefore agree on what `[3]` means without either of them
|
||||
* being asked to remember a URL.
|
||||
*/
|
||||
export interface Source {
|
||||
/** The number the model cites. One-based, and never reused for a different page. */
|
||||
index: number;
|
||||
/** Page title, or the URL when the page had none. */
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** What a tool actually returned during a turn, kept for display and debugging. */
|
||||
export interface ToolCallRecord {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result: string;
|
||||
isError: boolean;
|
||||
/**
|
||||
* The sources this call added to the turn, if it consulted any.
|
||||
*
|
||||
* Only what this call found, never the turn's running total: the whole list is
|
||||
* the flattening of a turn's records, so storing the total here would write
|
||||
* the same page once per later call.
|
||||
*
|
||||
* Absent on turns stored before sources were numbered, which is honest rather
|
||||
* than empty: those answers never cited a number, so there is nothing to draw.
|
||||
*/
|
||||
sources?: Source[];
|
||||
/**
|
||||
* Set when the call was refused because the turn had already made it twice.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user