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