Add the harness, envs, per-bot models and chat attachments

- Harness: providers, model catalog synced hourly, automatic free picks,
  cooldowns, tool loop with context trimming, MCP computer tools
- Bots queue runs replies and tasks through the harness; jobs wait
  (attempt refunded) while every model is resting
- Envs: Settings > Environment, scoped general/workspace/computer,
  written to computers and redacted from tool output
- Models: Settings > Models, per-harness provider select defaulting to
  OpenRouter, and a models modal on each bot's chat (the coordinator's
  edits the defaults)
- Chat: thinking row while a reply runs, file attachments (upload,
  paste, drop) that replies read as images, audio and document text
- Fix media uploads failing after a user's first file, and private
  files being served from the public media path

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 03:38:38 +01:00
co-authored by Claude Opus 5.5
parent d49f524fe8
commit 6a2665dae9
223 changed files with 12342 additions and 162 deletions
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import type { HarnessMessage } from "../types";
import fitTranscriptToContext from "./fit-transcript-to-context";
const big = "x".repeat(30000);
const run = (): HarnessMessage[] => [
{ role: "user", content: "Do the thing." },
{ role: "assistant", content: null, tool_calls: [{ id: "1", name: "sandbox_execute_bash", arguments: "{}" }] },
{ role: "tool", tool_call_id: "1", content: big },
{ role: "assistant", content: null, tool_calls: [{ id: "2", name: "sandbox_execute_bash", arguments: "{}" }] },
{ role: "tool", tool_call_id: "2", content: big },
{ role: "assistant", content: null, tool_calls: [{ id: "3", name: "sandbox_execute_bash", arguments: "{}" }] },
{ role: "tool", tool_call_id: "3", content: "recent" },
];
describe("fitTranscriptToContext", () => {
test("leaves a transcript that fits alone", () => {
const transcript = run();
const result = fitTranscriptToContext({ system: "s", transcript, context_length: 200000 });
expect(result.trimmed).toBe(0);
expect(transcript[2]!.content).toBe(big);
});
test("trims the oldest tool output first, keeping the newest", () => {
const transcript = run();
const result = fitTranscriptToContext({ system: "s", transcript, context_length: 20000 });
expect(result.trimmed).toBe(1);
expect(String(transcript[2]!.content)).toStartWith("[Trimmed: 30000 characters");
expect(transcript[4]!.content).toBe(big);
expect(transcript[6]!.content).toBe("recent");
expect(result.estimated_tokens).toBeLessThanOrEqual(result.budget);
});
test("never trims the newest messages, even over budget", () => {
const transcript: HarnessMessage[] = [
{ role: "user", content: "go" },
{ role: "tool", tool_call_id: "1", content: big },
];
const result = fitTranscriptToContext({ system: "s", transcript, context_length: 1000 });
expect(result.trimmed).toBe(0);
expect(transcript[1]!.content).toBe(big);
});
});
@@ -0,0 +1,92 @@
import type { HarnessMessage } from "../types";
type Params = {
system: string;
/**
* The conversation after the system prompt. Trimmed in place, so the
* run keeps the savings.
*/
transcript: HarnessMessage[];
/**
* The model's context window, in tokens.
*/
context_length: number;
/**
* The newest messages, never trimmed: the model needs them to go on.
*/
keep_last?: number;
};
/**
* Share of the window the prompt may fill; the rest is for the answer.
*/
const PromptShare = 0.7;
/**
* Rough characters per token, on the low side so the estimate errs high.
*/
const CharsPerToken = 3;
/**
* What an image or audio part is counted as, in tokens.
*/
const MediaTokens = 1500;
/**
* Keeps a long tool run inside the model's context window: while the
* estimated prompt is over budget, the oldest tool results (then the
* oldest assistant texts) are replaced with a note of what was cut.
* Returns how many messages were trimmed.
*/
export default function fitTranscriptToContext({
system,
transcript,
context_length,
keep_last = 4,
}: Params) {
const budget = Math.floor(context_length * PromptShare);
let total = estimateTokens({ text: system }) + transcript.reduce((sum, m) => sum + messageTokens({ message: m }), 0);
let trimmed = 0;
const trimmable = transcript.length - keep_last;
for (const role of ["tool", "assistant"] as const) {
for (let index = 0; index < trimmable && total > budget; index++) {
const message = transcript[index]!;
if (message.role != role || typeof message.content != "string") continue;
if (message.content.startsWith("[Trimmed:")) continue;
const before = messageTokens({ message });
const note = `[Trimmed: ${message.content.length} characters of earlier ${role == "tool" ? "tool output" : "notes"} to fit the context.]`;
transcript[index] = { ...message, content: note } as HarnessMessage;
total += messageTokens({ message: transcript[index]! }) - before;
trimmed++;
}
}
return { trimmed, estimated_tokens: total, budget };
}
function messageTokens({ message }: { message: HarnessMessage }) {
let tokens = 4;
if (typeof message.content == "string") {
tokens += estimateTokens({ text: message.content });
} else if (Array.isArray(message.content)) {
for (const part of message.content) {
tokens += part.type == "text" ? estimateTokens({ text: part.text }) : MediaTokens;
}
}
if (message.role == "assistant" && message.tool_calls) {
for (const call of message.tool_calls) {
tokens += estimateTokens({ text: call.name + call.arguments });
}
}
return tokens;
}
function estimateTokens({ text }: { text: string }) {
return Math.ceil(text.length / CharsPerToken);
}
+96
View File
@@ -0,0 +1,96 @@
import HarnessError from "../harness-error";
import grabHarnessAdapter from "../providers/grab-harness-adapter";
import resolveHarnessCandidates from "../resolve/resolve-harness-candidates";
import withCandidates from "../resolve/with-candidates";
import type {
HarnessCandidate,
HarnessChatResult,
HarnessMessage,
HarnessToolDefinition,
} from "../types";
import prepareMessagesForModel from "./prepare-messages-for-model";
import stripThinkTags from "./strip-think-tags";
export type GenerateTextParams = {
messages: HarnessMessage[];
tools?: HarnessToolDefinition[];
/**
* Resolved for the text harness when not given.
*/
candidates?: HarnessCandidate[];
/**
* Tried first: the model the run already uses.
*/
prefer_key?: string;
max_tokens?: number;
temperature?: number;
signal?: AbortSignal;
/**
* Media readings shared across calls of one run.
*/
media_cache?: Map<string, string>;
/**
* The bot the turn is for: its own models are used where it has them.
*/
bot_id?: number | string;
};
export type GenerateTextResult = HarnessChatResult & {
candidate: HarnessCandidate;
};
/**
* One model turn on the text harness, falling back across its models.
* Media the model can't take is read by the helper harnesses first.
* Reasoning tags are stripped from the answer.
*/
export default async function generateText({
messages,
tools,
candidates,
prefer_key,
max_tokens,
temperature,
signal = new AbortController().signal,
media_cache = new Map(),
bot_id,
}: GenerateTextParams): Promise<GenerateTextResult> {
const { result, candidate } = await withCandidates({
harness: "text",
candidates:
candidates || (await resolveHarnessCandidates({ harness: "text", bot_id })),
prefer_key,
run: async (candidate) => {
const answer = await grabHarnessAdapter({
kind: candidate.provider.kind,
}).chat({
candidate,
messages: await prepareMessagesForModel({
messages,
model: candidate.model,
cache: media_cache,
signal,
bot_id,
}),
tools,
max_tokens,
temperature,
signal,
});
const text = answer.text ? stripThinkTags({ text: answer.text }) : "";
if (!text && !answer.tool_calls.length) {
throw new HarnessError({
message: `The model returned an empty answer.`,
retryable: true,
candidate_key: candidate.key,
});
}
return { ...answer, text: text || null };
},
});
return { ...result, candidate };
}
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test";
import { MediaReadNote } from "@/src/data/harness-prompts";
import type { HarnessMessage } from "../types";
import prepareMessagesForModel from "./prepare-messages-for-model";
const messages: HarnessMessage[] = [
{ role: "system", content: "You are a bot." },
{
role: "user",
content: [
{ type: "text", text: "What's this?" },
{ type: "image", url: "data:image/png;base64,AAAA", name: "a.png" },
{ type: "audio", data: "AAAA", format: "mp3" },
],
},
];
const signal = new AbortController().signal;
describe("prepareMessagesForModel", () => {
test("keeps media the model can take", async () => {
const fitted = await prepareMessagesForModel({
messages,
model: { input_modalities: ["text", "image", "audio"] },
cache: new Map(),
signal,
read_image: async () => {
throw new Error("should not read");
},
});
expect(fitted).toEqual(messages);
});
test("reads media the model can't take, once, and notes it", async () => {
const cache = new Map<string, string>();
let reads = 0;
const run = () =>
prepareMessagesForModel({
messages,
model: { input_modalities: ["text"] },
cache,
signal,
read_image: async () => {
reads++;
return "A red square.";
},
transcribe: async () => "hello there",
});
const fitted = await run();
await run();
expect(reads).toBe(1);
expect(fitted[0]).toEqual({
role: "system",
content: `You are a bot.\n\n${MediaReadNote}`,
});
expect(fitted[1]).toEqual({
role: "user",
content: [
{ type: "text", text: "What's this?" },
{ type: "text", text: `[Image "a.png": A red square.]` },
{ type: "text", text: "[Audio, transcribed: hello there]" },
],
});
});
test("says when a part can't be read instead of failing", async () => {
const fitted = await prepareMessagesForModel({
messages: [messages[1]!],
model: { input_modalities: ["text", "audio"] },
cache: new Map(),
signal,
read_image: async () => {
throw new Error("No media reading model is set up.");
},
});
expect(fitted[0]).toMatchObject({
content: [
{},
{
type: "text",
text: `[Image "a.png": couldn't be read. No media reading model is set up.]`,
},
{ type: "audio" },
],
});
});
});
@@ -0,0 +1,148 @@
import { MediaReadNote } from "@/src/data/harness-prompts";
import transcribeAudio from "../audio/transcribe-audio";
import HarnessError from "../harness-error";
import readImage from "../media/read-image";
import type {
HarnessContentPart,
HarnessMessage,
HarnessModelInfo,
} from "../types";
type Params = {
messages: HarnessMessage[];
model: Pick<HarnessModelInfo, "input_modalities">;
/**
* Readings by media key, kept across candidates and steps so nothing is
* read twice.
*/
cache: Map<string, string>;
signal: AbortSignal;
/**
* Reads media with this bot's models when it has its own.
*/
bot_id?: number | string;
read_image?: typeof readImage;
transcribe?: typeof transcribeAudio;
};
/**
* Fits messages to a model's inputs: images it can't see are read by the
* media reading harness and audio it can't hear is transcribed, each
* replaced with a `[bracketed]` reading. A part that can't be read says so
* instead of failing the call.
*/
export default async function prepareMessagesForModel({
messages,
model,
cache,
signal,
bot_id,
read_image = readImage,
transcribe = transcribeAudio,
}: Params): Promise<HarnessMessage[]> {
const sees = model.input_modalities.includes("image");
const hears = model.input_modalities.includes("audio");
let replaced = false;
const fitPart = async (
part: HarnessContentPart,
): Promise<HarnessContentPart> => {
if (part.type == "image" && !sees) {
replaced = true;
return {
type: "text",
text: await readOnce({
key: `image:${Bun.hash(part.url)}`,
label: `Image${part.name ? ` "${part.name}"` : ""}`,
cache,
signal,
read: () => read_image({ url: part.url, signal, bot_id }),
}),
};
}
if (part.type == "audio" && !hears) {
replaced = true;
return {
type: "text",
text: await readOnce({
key: `audio:${Bun.hash(part.data)}`,
label: `Audio${part.name ? ` "${part.name}"` : ""}, transcribed`,
cache,
signal,
read: () =>
transcribe({
audio: {
data: Buffer.from(part.data, "base64"),
format: part.format,
},
signal,
bot_id,
}),
}),
};
}
return part;
};
const fitted: HarnessMessage[] = [];
for (const message of messages) {
if (message.role == "user" && typeof message.content != "string") {
const content: HarnessContentPart[] = [];
for (const part of message.content) {
content.push(await fitPart(part));
}
fitted.push({ ...message, content });
} else {
fitted.push(message);
}
}
if (!replaced) return fitted;
const system = fitted.findIndex((message) => message.role == "system");
if (system >= 0) {
const message = fitted[system] as { role: "system"; content: string };
fitted[system] = {
role: "system",
content: `${message.content}\n\n${MediaReadNote}`,
};
}
return fitted;
}
async function readOnce({
key,
label,
cache,
signal,
read,
}: {
key: string;
label: string;
cache: Map<string, string>;
signal: AbortSignal;
read: () => Promise<string>;
}) {
const cached = cache.get(key);
if (cached !== undefined) return cached;
try {
const text = `[${label}: ${await read()}]`;
cache.set(key, text);
return text;
} catch (error: unknown) {
if (signal.aborted || (error instanceof HarnessError && error.code == "aborted")) {
throw error;
}
return `[${label}: couldn't be read. ${error instanceof Error ? error.message : String(error)}]`;
}
}
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, test } from "bun:test";
import { z } from "zod";
import HarnessError from "../harness-error";
import type { HarnessCandidate, HarnessTool } from "../types";
import type { GenerateTextParams, GenerateTextResult } from "./generate-text";
import runTextLoop from "./run-text-loop";
const candidate: HarnessCandidate = {
key: "1:m",
provider: { id: 1, kind: "openrouter", base_url: "https://x.test" },
model: {
slug: "m",
input_modalities: ["text"],
output_modalities: ["text"],
supports_tools: true,
},
source: "auto",
};
const memory: string[] = [];
const remember: HarnessTool<{ note: string }> = {
name: "remember",
description: "Keeps a note.",
schema: z.object({ note: z.string() }),
run: async ({ args }) => {
memory.push(args.note);
return "Saved.";
},
};
/**
* A fake model that plays back scripted turns and records what it saw.
*/
function scripted(turns: Partial<GenerateTextResult>[]) {
const seen: GenerateTextParams[] = [];
const generate = async (params: GenerateTextParams) => {
seen.push(structuredClone({ ...params, signal: undefined }));
const turn = turns[seen.length - 1] || { text: "Done." };
return {
text: null,
tool_calls: [],
candidate,
...turn,
} as GenerateTextResult;
};
return { generate, seen };
}
const resolveCandidates = async () => [candidate];
describe("runTextLoop", () => {
test("runs tools and feeds their results back until the model answers", async () => {
memory.length = 0;
const { generate, seen } = scripted([
{
text: "Noting that.",
tool_calls: [
{ id: "c1", name: "remember", arguments: `{"note":"tea"}` },
],
usage: { prompt_tokens: 10, completion_tokens: 3, cost: 0.5 },
},
{
text: "I'll remember you like tea.",
usage: { prompt_tokens: 20, completion_tokens: 5 },
},
]);
const steps: number[] = [];
const result = await runTextLoop({
system: "You are a bot.",
messages: [{ role: "user", content: "I like tea." }],
tools: [remember],
generate,
resolveCandidates,
onStep: ({ index }) => {
steps.push(index);
},
});
expect(result.text).toBe("I'll remember you like tea.");
expect(result.steps).toBe(2);
expect(steps).toEqual([0, 1]);
expect(memory).toEqual(["tea"]);
expect(result.usage).toEqual({
prompt_tokens: 30,
completion_tokens: 8,
cost: 0.5,
});
expect(seen[0]!.messages[0]).toEqual({
role: "system",
content: "You are a bot.",
});
expect(seen[0]!.tools?.[0]?.name).toBe("remember");
expect(seen[1]!.prefer_key).toBe("1:m");
expect(seen[1]!.messages.slice(-2)).toEqual([
{
role: "assistant",
content: "Noting that.",
tool_calls: [
{ id: "c1", name: "remember", arguments: `{"note":"tea"}` },
],
},
{ role: "tool", tool_call_id: "c1", content: "Saved." },
]);
});
test("asks the model to wrap up, without tools, after the last step", async () => {
const loop_call = {
tool_calls: [{ id: "c", name: "remember", arguments: `{"note":"x"}` }],
};
const { generate, seen } = scripted([
loop_call,
loop_call,
{ text: "Ran out of room; here's where I got to.", ...loop_call },
]);
const result = await runTextLoop({
system: "s",
messages: [{ role: "user", content: "go" }],
tools: [remember],
max_steps: 2,
generate,
resolveCandidates,
});
expect(seen).toHaveLength(3);
expect(seen[2]!.tools).toBe(undefined);
expect(seen[2]!.messages.at(-1)).toMatchObject({ role: "user" });
expect(result.text).toBe("Ran out of room; here's where I got to.");
expect(result.messages.at(-1)).toEqual({
role: "assistant",
content: "Ran out of room; here's where I got to.",
});
});
test("stops when cancelled", async () => {
const controller = new AbortController();
controller.abort();
const error = await runTextLoop({
system: "s",
messages: [{ role: "user", content: "go" }],
signal: controller.signal,
generate: scripted([]).generate,
resolveCandidates,
}).catch((e) => e);
expect(error).toBeInstanceOf(HarnessError);
expect(error.code).toBe("aborted");
});
});
+167
View File
@@ -0,0 +1,167 @@
import { AppData } from "@/src/data/app-data";
import HarnessError from "../harness-error";
import resolveHarnessCandidates from "../resolve/resolve-harness-candidates";
import runToolCall from "../tools/run-tool-call";
import toolDefinition from "../tools/tool-definition";
import type {
HarnessCandidate,
HarnessMessage,
HarnessTool,
HarnessToolCall,
HarnessUsage,
} from "../types";
import fitTranscriptToContext from "./fit-transcript-to-context";
import generateText from "./generate-text";
export type TextLoopStep = {
index: number;
text: string | null;
tool_calls: HarnessToolCall[];
candidate: HarnessCandidate;
};
type Params = {
/**
* The bot's full prompt (`grabBotPrompt`).
*/
system: string;
/**
* The conversation so far, ending with what to do now.
*/
messages: HarnessMessage[];
tools?: HarnessTool[];
max_steps?: number;
signal?: AbortSignal;
/**
* Called after each model turn, before its tools run. Use it for
* progress and checkpoints.
*/
onStep?: (step: TextLoopStep) => Promise<void> | void;
/**
* The bot the run is for: its own models are used where it has them.
*/
bot_id?: number | string;
generate?: typeof generateText;
resolveCandidates?: () => Promise<HarnessCandidate[]>;
};
export type TextLoopResult = {
/**
* The final answer for the user.
*/
text: string;
/**
* Everything after the system prompt, including tool turns.
*/
messages: HarnessMessage[];
steps: number;
candidate: HarnessCandidate;
usage: HarnessUsage;
};
const WrapUpPrompt = `You've used every step this run allows. Don't call any more tools. Report what you did, what is left, and anything you need from the user.`;
/**
* The text harness at work: the model answers or calls tools, tool results
* go back to it, and so on until it answers without calling a tool. Tools
* run one at a time, in order. Stays on one model unless it fails. After
* `max_steps` turns the model is asked to wrap up without tools.
*/
export default async function runTextLoop({
system,
messages,
tools = [],
max_steps = AppData.HarnessMaxToolSteps,
signal = new AbortController().signal,
onStep,
bot_id,
generate = generateText,
resolveCandidates = () => resolveHarnessCandidates({ harness: "text", bot_id }),
}: Params): Promise<TextLoopResult> {
const candidates = await resolveCandidates();
const definitions = tools.map((tool) => toolDefinition({ tool }));
const media_cache = new Map<string, string>();
const transcript: HarnessMessage[] = [...messages];
const usage: Required<HarnessUsage> = {
prompt_tokens: 0,
completion_tokens: 0,
cost: 0,
};
let prefer_key: string | undefined;
for (let index = 0; index <= max_steps; index++) {
if (signal.aborted) {
throw new HarnessError({ message: `Cancelled.`, code: "aborted" });
}
const wrap_up = index == max_steps;
if (wrap_up) {
transcript.push({ role: "user", content: WrapUpPrompt });
}
/**
* Sized for the model the run is on (or will start on). A fallback
* with a smaller window gets a 400 and the next model is tried.
*/
const model = (candidates.find((c) => c.key == prefer_key) || candidates[0])?.model;
fitTranscriptToContext({
system,
transcript,
context_length: model?.context_length || AppData.HarnessAutoMinContext,
});
const answer = await generate({
messages: [{ role: "system", content: system }, ...transcript],
tools: wrap_up || !definitions.length ? undefined : definitions,
candidates,
prefer_key,
signal,
media_cache,
bot_id,
});
prefer_key = answer.candidate.key;
usage.prompt_tokens += answer.usage?.prompt_tokens || 0;
usage.completion_tokens += answer.usage?.completion_tokens || 0;
usage.cost += answer.usage?.cost || 0;
const tool_calls = wrap_up ? [] : answer.tool_calls;
transcript.push({
role: "assistant",
content: answer.text,
...(tool_calls.length ? { tool_calls } : {}),
});
await onStep?.({
index,
text: answer.text,
tool_calls,
candidate: answer.candidate,
});
if (!tool_calls.length) {
return {
text:
answer.text ||
(wrap_up ? `I ran out of steps before I could finish.` : ""),
messages: transcript,
steps: index + 1,
candidate: answer.candidate,
usage,
};
}
for (const call of tool_calls) {
transcript.push({
role: "tool",
tool_call_id: call.id,
content: await runToolCall({ call, tools, signal }),
});
}
}
throw new Error(`Unreachable: the text loop ended without an answer.`);
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import stripThinkTags from "./strip-think-tags";
describe("stripThinkTags", () => {
test("removes reasoning blocks", () => {
expect(
stripThinkTags({ text: "<think>plan\nmore</think>\n\nDone." }),
).toBe("Done.");
expect(
stripThinkTags({ text: "A <THINK>x</THINK>B <think>y</think>C" }),
).toBe("A B C");
});
test("drops an unclosed block at the start", () => {
expect(stripThinkTags({ text: "<think>never ends" })).toBe("");
});
test("leaves plain text alone", () => {
expect(stripThinkTags({ text: " Hello " })).toBe("Hello");
});
});
+14
View File
@@ -0,0 +1,14 @@
type Params = {
text: string;
};
/**
* Removes the `<think>...</think>` reasoning some models write into their
* answer, including an unclosed one at the start.
*/
export default function stripThinkTags({ text }: Params) {
return text
.replace(/<think>[\s\S]*?<\/think>/gi, "")
.replace(/^\s*<think>[\s\S]*$/i, "")
.trim();
}