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
+9
View File
@@ -18,6 +18,7 @@ Hit a global shortcut, type a question, get a streamed answer without leaving wh
- **Real markdown.** Tables, bulleted and numbered lists, headings, blockquotes, bold, italics and links all render natively, and the default prompt asks for the shape that fits the answer rather than flattening everything into prose.
- **Maths, real HTML, and diagrams.** LaTeX renders, so an equation arrives as an equation instead of a wall of backslashes. A small amount of HTML draws as well, for the status chip or tiny grid a sentence is worse at, and a diagram drawn with box characters keeps its alignment instead of being reflowed. See [Rendering](#rendering).
- **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching. A turn that uses up its tool budget still ends in an answer, written from what it gathered, rather than a notice that it stopped.
- **Sources you can check.** A search result and a fetched page each get a number, and those numbers are the ones Lam cites, so `[1]` in the answer and `[1]` under it are the same page. The list sits under the answer it belongs to, each number opens the page it names, and a number Lam was never shown stays as plain text rather than becoming a link to somewhere else.
- **Tool calls you can watch and stop.** What Lam is doing right now stays on screen, including when it asks the same question twice, the round limit is a preference, Force Stop is always one keystroke away, and a call that repeats itself with identical arguments ends the turn instead of running forever.
- **Models, from a list.** Browse every model your host can reach, pick one and start using it immediately, and on a server you run, pull new ones or delete old ones. Tag names are picked, never typed.
- **Optional auto-deletion.** Set a retention window and Lam clears out the conversations you have stopped using the next time you open a command, taking their screenshots with them and collecting any capture you never sent. Off by default, and nothing at all is read or written while it is off. The setup screen also has both tidy-ups as explicit actions, confirmed first, for when you would rather press the button yourself.
@@ -295,6 +296,14 @@ The rest of the pass is defensive, and stays away from guessing what the model m
The whole pass is plain TypeScript with no Vicinae imports, so it unit tests without a runtime, and copying a conversation to the clipboard uses the source text rather than the rendering.
### Sources
Footnotes deserve a note of their own, because the window cannot draw them. Vicinae parses markdown with `CMARK_OPT_DEFAULT` and attaches only the table and strikethrough extensions, so a `[^1]` arrives as literal text. The citation pass therefore does the whole job: it links the marker, drops a footnote definition that would only repeat an entry the tools already registered, and builds the list from those entries, which is also what stops the list from being something a model can quietly invent.
Numbers are handed out where the tools run, once per turn, and printed in the tool result the model reads. So `[3]` means one page for the model and for the reader, three searches in one turn do not each start again at `1`, and a page found by a search and read again later keeps the number it already had. A marker stays as written unless every number in it is a source with a destination this window should open: half a link reads as a citation that works, and a `javascript:` address out of a search result is a real vulnerability rather than a rendering quirk.
The list shows the sources the answer cited, falling back to everything the turn consulted when it cited nothing, since that is the one case where the list is the only evidence of what was read. It waits for a citation before appearing at all, so it never arrives in full and then shrinks.
## Architecture
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,249 @@
# Source citations in the transcript
Status: draft for review
Date: 2026-10-07
## Problem
A turn that searches the web leaves no verifiable provenance behind.
- `web_search` numbers its own results from 1 on every call. Three searches in
one turn produce three separate runs of `1.`, `2.`, `3.`, so a number in the
answer cannot identify a source across the turn.
- `web_fetch` prints no number at all, so a page the model actually read cannot
be cited by number.
- Nothing tells the model to cite, so it either pastes bare URLs into prose
(which the window draws as dead text if they land in a code span) or says
"according to the search results" with nothing to click.
- Vicinae cannot render markdown footnotes. `markdown-model.cpp` calls
`cmark_parser_new(CMARK_OPT_DEFAULT)` and attaches only the `table` and
`strikethrough` extensions (`src/server/src/ui/quick/markdown-model.cpp:330-340`),
so `[^1]` reaches the pane as literal text. Any footnote behaviour has to be
produced on our side of the boundary.
## Goals
1. Every source a turn consults gets one number, stable for that turn and the
same number for the model and for the reader.
2. The model is told to cite with that number, and the number it writes is the
number the reader sees.
3. The transcript turns a citation marker into a link to that source, and lists
the sources underneath the answer it belongs to.
4. Nothing is invented. A marker that does not match a known source is left
exactly as written.
5. A reopened session shows the same sources, because they are stored with the
turn rather than derived at render time from a tool result string.
## Non-goals
- Native markdown footnotes. The renderer has no such mode, and this spec does
not wait for one.
- Renumbering the model's citations to be contiguous.
- Favicons, snippets, timestamps or reading time in the list.
- Sources for any tool other than the web tools. Nothing else returns sources.
- Detecting and deleting a sources list the model wrote by hand. The prompt
forbids it; a heuristic that strips prose is a worse failure than a duplicate.
## Assumptions
The clarifying questions were delegated for this cycle, so the decisions that
would have been asked are recorded here instead.
- **A1. The list shows what the answer cites, falling back to everything the
turn gathered.** An uncited entry is noise in a narrow pane, and a long
research turn can gather twenty. Listing only cited entries also keeps a
twenty five result turn from ending in a twenty five line list. When the
answer cites nothing while sources exist, the list shows all of them, because
then it is the only evidence of what was consulted. Rejected alternatives are
in "Approaches considered".
- **A2. Numbers are never renumbered.** Contiguous numbering from 1 in citation
order is the academic convention, but it would make the transcript disagree
with the model's own reasoning text and with the tool output a curious reader
can inspect. Keeping the model's numbers means one number means one thing
everywhere in the turn.
- **A3. The list is answer content, not a tool log.** It is shown whether or not
the Show Tool Calls preference is on. Hiding tool calls is about the activity
line; a citation list is part of the answer's credibility.
- **A4. The copied conversation includes the list**, in the same form, so
provenance survives a paste into a document.
- **A5. The marker is `[1]`, not a superscript.** A superscript inside a link is
a smaller target and, at this pane width, harder to read than the bracket form
that is already the convention in most model output.
## Design
### Module layout
One new module, `src/lib/citations.ts`, with no Vicinae imports so it unit tests
without a runtime. It exports:
- `interface Source { index: number; title: string; url: string }`, the shape
stored on a record and rendered in the list.
- `createSourceList()`, returning an object with `add(title, url)`, which gives
back the number to print plus the `Source` when that URL was new and nothing
when it was already known.
- `collectSources(calls)`, flattening a turn's records into one list ordered by
number, which is what the transcript renders from.
- `linkCitations(markdown, sources)`, returning the rewritten text and the set of
numbers the answer actually cited, since the list needs that set (A1).
- `renderSources(sources)`, the list block itself.
### Numbering
One source list per turn, created in `runAgent` and threaded through
`ToolContext`. It assigns the next free integer the first time it sees a URL,
and reuses the existing number afterwards, so a page found by a search and read
again with `web_fetch` keeps one number. A turn is the whole scope: numbers
restart at 1 on the next question, which is what makes them safe to renumber
never (A2).
Deduplication is an exact match on the trimmed URL. Normalising further (case,
trailing slash, tracking parameters) risks merging two genuinely different
pages, and the cost of a miss is only a duplicate entry.
### Tool output
Both web tools print the number so the model can cite it.
```
[1] Result title
https://example.com/page
Snippet text …
```
A fetched page keeps its labelled header, because the body after it can run for
thousands of characters, and gains the same numbered first line:
```
[2] Page title
URL: https://example.com/page
<body>
Links on page:
- https://example.com/other
```
`executeTool` returns the new entries with the result (`ToolResult.sources`), and
`runAgent` puts them on the `ToolCallRecord` it already creates. Nothing else
about the tool protocol changes, and a caller that supplies no list (a unit
test, say) gets a throwaway one so the output format is identical either way.
### Storage
`ToolCallRecord` gains an optional `sources?: Source[]` holding only the entries
that call added, so a turn's full list is the flattening of its records. The
zod schema gains the same optional field: zod strips unknown keys by default, so
without it the field would be silently lost on the first reload and a reopened
conversation would lose every link.
A turn stored before this change has no field, so its markers stay literal and
it shows no list. That is the honest outcome: those turns never had numbered
sources.
### Rendering
`renderModelOutput` takes an optional `{ sources }` and runs a citation pass
first, before the maths and HTML passes, so a marker that the maths pass or the
HTML allowlist then walks over is already a plain markdown link.
The pass follows the rules the rest of the pipeline already follows.
- **Prose only.** Fenced segments and code spans are skipped, using the same
machinery the maths pass uses. A `[1]` inside a snippet is text.
- **`[n]`, `[^n]` and comma lists** (`[1, 3]`) are recognised.
- **All or nothing.** A marker is linked only when every index it names is a
known source. `[1, 9]` where 9 was never seen stays exactly as written, rather
than becoming half a link.
- **`http` and `https` only.** A destination is linked only when its scheme is
`http` or `https` and it contains no whitespace, no angle bracket, no
backslash and no control character, since any of those would break the link
form or smuggle a second one. Anything else is left as literal text. This is
the same reasoning as `SAFE_URL` in `html.ts`: a search result is untrusted
input, and a clickable `javascript:` link would be a real vulnerability.
- **Markdown structure wins.** A marker followed by `(` or `[` is part of
something the model wrote, such as a link, so it is left alone.
- **Duplicate definitions are dropped.** A `[^n]:` definition line for a known
index is removed, because the authoritative entry is in our list and the
model's copy would be a second line saying the same thing. An unknown index
keeps its line, since there is nothing to replace it with.
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
```
The marker is the link, matching the inline marker character for character, so
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
The system prompt gains a paragraph next to the existing link rule, because both
are about the same thing: what a pointer in this window does. It asks for a
number the tool actually printed, one number at a time, no footnote definitions,
and no sources list of its own.
## Error handling
| Situation | Behaviour |
| ---------------------------------------- | ------------------------------------------------------ |
| No sources gathered | The pass is the identity function and no list is added |
| Tool failed, or returned no results | Registers nothing |
| Same URL found twice | One number, reused |
| Model cited a number that does not exist | Left as literal text |
| Model wrote `[1](https://…)` | Left alone, the `(` marks it as structure |
| Answer cites nothing while sources exist | Every source is listed (A1) |
| Session stored before this change | No links, no list |
| Unsafe or malformed destination | Marker and entry stay text, nothing is clickable |
## Testing
- `citations.test.ts`, new: numbering, deduplication, order, and reuse across
calls; linkification of `[1]`, `[^1]` and `[1, 3]`; a marker left alone for an
unknown index, an unsafe scheme, a fenced block, a code span, and a following
`(`; definition line removal; the cited set; list rendering; record
flattening.
- `tools.test.ts`: numbering continues across two calls in one turn; `web_fetch`
registers its page; the same URL keeps its number; the format with no list
supplied.
- `render.test.ts`: a citation marker survives the maths and HTML passes intact.
- `markdown.test.ts`: an answer with sources renders markers and a list; an
answer without sources is byte for byte what it was before; the list is shown
with Show Tool Calls off; the plain text export carries the list.
- Existing tool format assertions are updated for the new first line.
## Approaches considered
- **Parse the stored tool result strings at render time.** Rejected. It needs no
schema change and would work on old sessions, but it means re-deriving
numbering from formatted prose, and it breaks the moment the tool output
format changes for any other reason.
- **Use native markdown footnotes.** Rejected, and now known to be impossible:
see the Problem section.
- **Always list every source gathered.** Rejected as the default because a long
research turn ends in a wall of uncited links, though it is the fallback when
the answer cites nothing (A1).
- **Renumber citations to be contiguous.** Rejected in A2.
- **Link only the first citation of each source.** Rejected: every marker should
be clickable, since the reader may arrive at any one of them.
- **Superscript markers.** Rejected in A5.
## Risks
- Linkification cannot know which sentence a number supports. It makes the
number clickable and lists it; the accuracy of the mapping is the model's job.
This is why the prompt is specific about citing the number the tool printed
rather than a number chosen by the model.
- A model that ignores the instruction and writes its own sources list produces
two lists. The alternative, guessing at a section to delete, risks deleting an
answer.
- A turn with many results produces a long list. It is capped only by the tool
round budget, and nothing is silently truncated.
+167 -127
View File
@@ -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", () => {
+235
View File
@@ -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");
});
});
+72 -9
View File
@@ -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");
});
});
+67 -34
View File
@@ -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
View File
@@ -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");
+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);
+229
View File
@@ -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");
}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}
+11
View File
@@ -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
View File
@@ -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] : [] };
}
+27
View File
@@ -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.
*