feat: snip a region into chat, and make links clickable (#14)

This commit was merged in pull request #14.
This commit is contained in:
2026-10-05 08:38:50 +00:00
parent f5d1ced4d6
commit 576c5946f7
44 changed files with 3890 additions and 120 deletions
+73 -6
View File
@@ -59,10 +59,36 @@ function normalizeToolCall(call: ToolCall): { name: string; arguments: Record<st
return { name, arguments: args as Record<string, unknown> };
}
/**
* The last word, when the tool budget runs out.
*
* The loop used to end by throwing away every preamble the model had written and
* returning a notice about itself, so a long research question produced no answer
* at all. One closing round, with the tools taken away, turns that into an answer
* built from whatever was already gathered, with the gaps named. The instruction
* asks for the gaps because an answer that knows what it is missing is worth more
* than a confident half of one.
*/
const CLOSING_INSTRUCTION =
"You have reached the limit on tool calls for this turn, so no more tools are available. Answer now using what you have already gathered. If something is missing, say plainly which parts you could not confirm.";
/** Only reachable if the closing round comes back empty too. */
function exhaustedNotice(rounds: number): string {
return `_Ran out of tool calls after ${rounds} rounds and could not write an answer. Try asking something narrower._`;
}
export async function runAgent(options: AgentOptions): Promise<AgentResult> {
const conversation: ApiMessage[] = [...options.messages];
const toolCalls: ToolCallRecord[] = [];
let thinking = "";
/**
* The most recent preamble the model wrote.
*
* Not normally shown, because a preamble is what it says before finding out
* anything. Kept anyway, so that a turn which runs out of budget still has
* something to fall back on if the closing round produces nothing.
*/
let lastPreamble = "";
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
let content = "";
@@ -91,20 +117,25 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
thinking += streamedThinking;
// No tool calls means this was the model's answer. Earlier iterations only
// produced a preamble, which is intentionally discarded rather than shown
// as a half-answer.
// No tool calls means this was the model's answer, rather than something it
// said on the way to finding out.
if (!pending || pending.length === 0) {
return { content, thinking, toolCalls };
}
if (content.trim()) lastPreamble = content;
conversation.push({ role: "assistant", content, tool_calls: pending });
for (const rawCall of pending) {
const { name, arguments: args } = normalizeToolCall(rawCall);
if (!name) {
conversation.push({ role: "tool", content: "Malformed tool call: missing function name.", tool_name: "unknown" });
conversation.push({
role: "tool",
content: "Malformed tool call: missing function name.",
tool_name: "unknown",
});
toolCalls.push({ name: "unknown", arguments: {}, result: "Malformed tool call.", isError: true });
continue;
}
@@ -114,11 +145,17 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
const result = await executeTool(name, args, {
host: options.toolContext.host,
apiKey: options.toolContext.apiKey,
place: options.toolContext.place,
now: options.toolContext.now,
signal: options.signal,
});
const record: ToolCallRecord = { name, arguments: args, result: result.content, isError: result.isError };
const record: ToolCallRecord = {
name,
arguments: args,
result: result.content,
isError: result.isError,
};
toolCalls.push(record);
options.events?.onToolCall?.(record);
@@ -126,8 +163,38 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
}
}
// Budget spent, so the turn gets one closing round with the tools taken away.
// Ending on an answer drawn from what was gathered beats ending on a notice
// about the loop itself, and the model is the only one that can say what it
// did and did not manage to confirm.
options.events?.onStatus?.("Writing the answer");
conversation.push({ role: "user", content: CLOSING_INSTRUCTION });
let closing = "";
for await (const chunk of streamChat({
host: options.host,
apiKey: options.apiKey,
model: options.model,
messages: conversation,
// Deliberately no tools: the answer has to be written from what is already here.
tools: undefined,
think: options.think,
signal: options.signal,
})) {
if (chunk.thinking) {
thinking += chunk.thinking;
options.events?.onThinkingDelta?.(chunk.thinking);
}
if (chunk.content) {
closing += chunk.content;
options.events?.onContentDelta?.(chunk.content);
}
}
return {
content: `_Stopped after ${MAX_TOOL_ITERATIONS} rounds of tool calls without a final answer._`,
content: closing.trim() || lastPreamble.trim() || exhaustedNotice(MAX_TOOL_ITERATIONS),
thinking,
toolCalls,
};