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
+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.`);
}