@juliangruber/harness 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/llm.js ADDED
@@ -0,0 +1,52 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ // Speaks the OpenAI compatible /v1/chat/completions API
3
+ // (Ollama, llama.cpp, vLLM, LM Studio, OpenRouter, ...)
4
+ export function createClient({ baseUrl, model, apiKey, retries = 2, onRetry }) {
5
+ return {
6
+ async chat(messages, tools) {
7
+ for (let attempt = 0;; attempt++) {
8
+ const res = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
9
+ method: 'POST',
10
+ headers: {
11
+ 'content-type': 'application/json',
12
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {})
13
+ },
14
+ body: JSON.stringify({
15
+ model,
16
+ messages,
17
+ tools: tools.length ? tools : undefined
18
+ })
19
+ });
20
+ if (res.ok) {
21
+ const body = await res.json();
22
+ return normalize(body.choices[0].message);
23
+ }
24
+ const error = new Error(`LLM request failed: ${res.status} ${res.statusText} ${await res.text()}`);
25
+ // Server errors are often the server failing to parse sampled model
26
+ // output, like a malformed tool call, so a retry usually works
27
+ if (res.status < 500 || attempt >= retries)
28
+ throw error;
29
+ onRetry?.(error);
30
+ }
31
+ }
32
+ };
33
+ }
34
+ // Servers differ slightly: some omit tool call ids or send arguments as
35
+ // objects. Also drops extra fields (like `reasoning`) so they are not sent back.
36
+ function normalize(message) {
37
+ const toolCalls = (message.tool_calls ?? []).map((call) => ({
38
+ id: call.id || `call_${randomUUID()}`,
39
+ type: 'function',
40
+ function: {
41
+ name: call.function.name,
42
+ arguments: typeof call.function.arguments === 'string'
43
+ ? call.function.arguments
44
+ : JSON.stringify(call.function.arguments ?? {})
45
+ }
46
+ }));
47
+ return {
48
+ role: 'assistant',
49
+ content: message.content ?? null,
50
+ tool_calls: toolCalls.length ? toolCalls : undefined
51
+ };
52
+ }
@@ -0,0 +1,67 @@
1
+ import { lexer } from 'marked';
2
+ import { stripVTControlCharacters, styleText } from 'node:util';
3
+ // Renders markdown for the terminal: marked parses, util.styleText styles
4
+ export function renderMarkdown(markdown, { color, stream, highlight } = {}) {
5
+ const style = (format, text) => color === false ? text : styleText(format, text, { validateStream: color === undefined, stream });
6
+ const inline = (tokens = []) => tokens.map(token => {
7
+ switch (token.type) {
8
+ case 'strong': return style('bold', inline(token.tokens));
9
+ case 'em': return style('italic', inline(token.tokens));
10
+ case 'del': return style('strikethrough', inline(token.tokens));
11
+ case 'codespan': return style('cyan', token.text);
12
+ case 'link': {
13
+ const text = inline(token.tokens);
14
+ return text === token.href
15
+ ? style('underline', text)
16
+ : `${style('underline', text)} ${style('dim', `(${token.href})`)}`;
17
+ }
18
+ case 'image': return style('dim', `[image: ${token.text}]`);
19
+ case 'br': return '\n';
20
+ case 'text': return token.tokens ? inline(token.tokens) : token.text;
21
+ default: return 'text' in token ? token.text : token.raw;
22
+ }
23
+ }).join('');
24
+ const block = (tokens = [], separator = '\n\n') => tokens.map(render).filter(Boolean).join(separator);
25
+ const highlighted = (text) => {
26
+ const plain = stripVTControlCharacters(text);
27
+ return highlight?.test(plain) ? style(['bold', 'cyan'], plain) : undefined;
28
+ };
29
+ const render = (token) => {
30
+ switch (token.type) {
31
+ case 'heading': {
32
+ const text = inline(token.tokens);
33
+ return highlighted(text) ?? style(token.depth === 1 ? ['bold', 'underline'] : 'bold', text);
34
+ }
35
+ case 'paragraph': {
36
+ const text = inline(token.tokens);
37
+ return highlighted(text) ?? text;
38
+ }
39
+ case 'text': return token.tokens ? inline(token.tokens) : token.text;
40
+ case 'code': return token.text.split('\n').map((line) => ` ${style('yellow', line)}`).join('\n');
41
+ case 'blockquote': return block(token.tokens).split('\n').map(line => `${style('dim', '│')} ${line}`).join('\n');
42
+ case 'list': return renderList(token);
43
+ case 'table': return renderTable(token);
44
+ case 'hr': return style('dim', '─'.repeat(40));
45
+ case 'space':
46
+ case 'def': return '';
47
+ default: return 'text' in token ? token.text : token.raw;
48
+ }
49
+ };
50
+ const renderList = (list) => list.items.map((item, i) => {
51
+ const bullet = list.ordered ? `${Number(list.start || 1) + i}.` : '-';
52
+ const checkbox = item.task ? `[${item.checked ? 'x' : ' '}] ` : '';
53
+ const body = block(item.tokens.filter(t => t.type !== 'checkbox'), list.loose ? '\n\n' : '\n');
54
+ return `${bullet} ${checkbox}${indent(body, bullet.length + 1).slice(bullet.length + 1)}`;
55
+ }).join(list.loose ? '\n\n' : '\n');
56
+ const renderTable = (table) => {
57
+ const header = table.header.map(cell => style('bold', inline(cell.tokens)));
58
+ const rows = table.rows.map(row => row.map(cell => inline(cell.tokens)));
59
+ const widths = header.map((_, i) => Math.max(...[header, ...rows].map(row => width(row[i]))));
60
+ const line = (row) => row.map((cell, i) => cell + ' '.repeat(widths[i] - width(cell))).join(style('dim', ' │ ')).trimEnd();
61
+ const divider = style('dim', widths.map(w => '─'.repeat(w)).join('─┼─'));
62
+ return [line(header), divider, ...rows.map(line)].join('\n');
63
+ };
64
+ return block(lexer(markdown));
65
+ }
66
+ const width = (text = '') => stripVTControlCharacters(text).length;
67
+ const indent = (text, n) => text.split('\n').map(line => line && ' '.repeat(n) + line).join('\n');
@@ -0,0 +1,34 @@
1
+ // Remote MCP servers the harness uses. Each one is reviewed for fairness: the
2
+ // operator's income doesn't depend on ads or human visits to the content.
3
+ // Reviewed and left out:
4
+ // - Manifold (api.manifold.markets/v0/mcp): decided against it
5
+ // - Exa (mcp.exa.ai): web search, returns content from ad funded websites
6
+ // - GitMCP's fetch_generic_url_content: reads any URL, like the unavailable "fetch web page" tool
7
+ export const researchServers = [
8
+ {
9
+ name: 'wolfram',
10
+ url: 'https://agenttools.wolfram.com/mcp',
11
+ fairness: 'Wolfram|Alpha is free and funded by Pro subscriptions, apps, paid APIs and enterprise versions, not ads. The cloud MCP service is free for limited personal use, so it is only contacted for maths questions. Code execution (WolframLanguageEvaluator) is left out, since it costs Wolfram compute.',
12
+ tools: ['WolframAlpha']
13
+ }
14
+ ];
15
+ export const docsServers = [
16
+ {
17
+ name: 'deepwiki',
18
+ url: 'https://mcp.deepwiki.com/mcp',
19
+ fairness: 'Run by Cognition (Devin) as a free service for agents. Documentation generated from public GitHub repositories, no ads.',
20
+ tools: ['read_wiki_structure', 'read_wiki_contents', 'ask_question']
21
+ },
22
+ {
23
+ name: 'context7',
24
+ url: 'https://mcp.context7.com/mcp',
25
+ fairness: 'Run by Upstash, funded by paid plans. Library documentation made for agents, no ads.',
26
+ tools: ['resolve-library-id', 'query-docs']
27
+ },
28
+ {
29
+ name: 'microsoft',
30
+ url: 'https://learn.microsoft.com/api/mcp',
31
+ fairness: 'Microsoft\'s own product documentation, offered to agents by Microsoft. Documentation supports its products, not ads.',
32
+ tools: ['microsoft_docs_search', 'microsoft_code_sample_search', 'microsoft_docs_fetch']
33
+ }
34
+ ];
package/dist/mcp.js ADDED
@@ -0,0 +1,197 @@
1
+ // A small Model Context Protocol client for remote servers, over the
2
+ // Streamable HTTP transport: JSON-RPC over POST, answered with JSON or an
3
+ // event stream. https://modelcontextprotocol.io/specification/2025-06-18
4
+ import { request } from './http.js';
5
+ const PROTOCOL_VERSION = '2025-06-18';
6
+ const HINT = 'Try another tool instead.';
7
+ const MAX_OUTPUT = 30_000;
8
+ const MAX_TOOL_PAGES = 10;
9
+ export async function connectMcp(url, { timeout = 60_000 } = {}) {
10
+ let sessionId;
11
+ let protocolVersion;
12
+ let nextId = 1;
13
+ const post = async (message) => {
14
+ const headers = {
15
+ 'content-type': 'application/json',
16
+ accept: 'application/json, text/event-stream'
17
+ };
18
+ if (sessionId)
19
+ headers['mcp-session-id'] = sessionId;
20
+ if (protocolVersion)
21
+ headers['mcp-protocol-version'] = protocolVersion;
22
+ const res = await request(url, { method: 'POST', body: JSON.stringify(message), headers, timeout, hint: HINT });
23
+ sessionId = res.headers.get('mcp-session-id') ?? sessionId;
24
+ return res;
25
+ };
26
+ const call = async (method, params = {}) => {
27
+ const id = nextId++;
28
+ const res = await post({ jsonrpc: '2.0', id, method, params });
29
+ const message = await readResponse(res, id);
30
+ if (message.error)
31
+ throw new Error(`MCP ${method} failed: ${message.error.message}`);
32
+ return message.result;
33
+ };
34
+ const notify = async (method) => {
35
+ const res = await post({ jsonrpc: '2.0', method });
36
+ await res.body?.cancel();
37
+ };
38
+ const initialized = await call('initialize', {
39
+ protocolVersion: PROTOCOL_VERSION,
40
+ capabilities: {},
41
+ clientInfo: { name: 'harness', version: '0.0.0' }
42
+ });
43
+ protocolVersion = initialized.protocolVersion ?? PROTOCOL_VERSION;
44
+ await notify('notifications/initialized');
45
+ return {
46
+ serverInfo: initialized.serverInfo ?? { name: new URL(url).hostname },
47
+ async listTools() {
48
+ const tools = [];
49
+ let cursor;
50
+ for (let page = 0; page < MAX_TOOL_PAGES; page++) {
51
+ const result = await call('tools/list', cursor ? { cursor } : {});
52
+ tools.push(...(result.tools ?? []));
53
+ cursor = result.nextCursor;
54
+ if (!cursor)
55
+ break;
56
+ }
57
+ return tools;
58
+ },
59
+ async callTool(name, args = {}) {
60
+ const result = await call('tools/call', { name, arguments: args });
61
+ const text = formatContent(result);
62
+ if (result.isError)
63
+ throw new Error(text || `MCP tool ${name} failed`);
64
+ return text;
65
+ },
66
+ async close() {
67
+ if (!sessionId)
68
+ return;
69
+ try {
70
+ const headers = { 'mcp-session-id': sessionId, 'mcp-protocol-version': protocolVersion ?? PROTOCOL_VERSION };
71
+ const res = await request(url, { method: 'DELETE', headers, allowErrors: true, hint: HINT });
72
+ await res.body?.cancel();
73
+ }
74
+ catch { }
75
+ }
76
+ };
77
+ }
78
+ // Servers answer a request with JSON, or with an event stream that can carry
79
+ // other messages before the response
80
+ async function readResponse(res, id) {
81
+ if ((res.headers.get('content-type') ?? '').includes('text/event-stream')) {
82
+ for await (const message of eventStream(res)) {
83
+ if (message.id === id && ('result' in message || 'error' in message))
84
+ return message;
85
+ }
86
+ throw new Error('MCP server closed the stream without a response');
87
+ }
88
+ const body = await res.json();
89
+ const message = (Array.isArray(body) ? body : [body]).find((m) => m.id === id);
90
+ if (!message)
91
+ throw new Error('MCP server sent no response');
92
+ return message;
93
+ }
94
+ async function* eventStream(res) {
95
+ if (!res.body)
96
+ return;
97
+ const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
98
+ let buffer = '';
99
+ const parse = (event) => {
100
+ const data = event.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).replace(/^ /, '')).join('\n');
101
+ if (!data)
102
+ return;
103
+ try {
104
+ return JSON.parse(data);
105
+ }
106
+ catch { }
107
+ };
108
+ try {
109
+ while (true) {
110
+ const { value, done } = await reader.read();
111
+ if (done)
112
+ break;
113
+ buffer += value;
114
+ let match;
115
+ while ((match = buffer.match(/\r?\n\r?\n/))) {
116
+ const message = parse(buffer.slice(0, match.index));
117
+ buffer = buffer.slice(match.index + match[0].length);
118
+ if (message)
119
+ yield message;
120
+ }
121
+ }
122
+ const last = parse(buffer);
123
+ if (last)
124
+ yield last;
125
+ }
126
+ finally {
127
+ reader.cancel().catch(() => { });
128
+ }
129
+ }
130
+ function formatContent(result) {
131
+ const parts = (result.content ?? []).map((item) => {
132
+ switch (item.type) {
133
+ case 'text': return item.text;
134
+ case 'resource_link': return `${item.name ? `${item.name}: ` : ''}${item.uri}`;
135
+ case 'resource': return item.resource?.text ?? item.resource?.uri ?? '';
136
+ default: return `[${item.type}${item.mimeType ? ` ${item.mimeType}` : ''}]`;
137
+ }
138
+ });
139
+ if (!parts.length && result.structuredContent)
140
+ parts.push(JSON.stringify(result.structuredContent, null, 2));
141
+ const text = parts.join('\n\n');
142
+ return text.length > MAX_OUTPUT ? `${text.slice(0, MAX_OUTPUT)}\n[truncated ${text.length - MAX_OUTPUT} chars]` : text;
143
+ }
144
+ // Harness tools for an MCP server's tools, like deepwiki_ask_question. Names
145
+ // that already start with the prefix, like microsoft_docs_search, stay as is.
146
+ export function mcpTools(client, prefix, tools, rename = {}) {
147
+ return tools.map(tool => ({
148
+ name: (rename[tool.name] ?? (tool.name.startsWith(`${prefix}_`) ? tool.name : `${prefix}_${tool.name}`)).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64),
149
+ description: tool.description ?? '',
150
+ parameters: tool.inputSchema ?? { type: 'object' },
151
+ run: (args) => client.callTool(tool.name, args)
152
+ }));
153
+ }
154
+ // An MCP tool that only contacts its server when the model calls it
155
+ export function createLazyMcpTool({ server, tool, name, description, parameters, connect = connectMcp }) {
156
+ if (!server.tools.includes(tool))
157
+ throw new Error(`${tool} isn't a reviewed tool of MCP server ${server.name}`);
158
+ let client;
159
+ return {
160
+ name,
161
+ description,
162
+ parameters,
163
+ async run(args) {
164
+ // A failed connection is tried again on the next call
165
+ client ??= connect(server.url).catch(err => {
166
+ client = undefined;
167
+ throw err;
168
+ });
169
+ return (await client).callTool(tool, args);
170
+ }
171
+ };
172
+ }
173
+ // Connects to servers on first use and keeps the connection. Only reviewed
174
+ // tools are exposed. Failed servers are tried again on the next load.
175
+ export function createMcpToolLoader(servers, { connect = connectMcp, onError } = {}) {
176
+ const loaded = new Map();
177
+ const load = async (server) => {
178
+ const client = await connect(server.url);
179
+ const tools = (await client.listTools()).filter(tool => server.tools.includes(tool.name));
180
+ return mcpTools(client, server.name, tools, server.rename);
181
+ };
182
+ return async () => {
183
+ const results = await Promise.all(servers.map(server => {
184
+ let tools = loaded.get(server.url);
185
+ if (!tools) {
186
+ tools = load(server);
187
+ loaded.set(server.url, tools);
188
+ }
189
+ return tools.catch(err => {
190
+ loaded.delete(server.url);
191
+ onError?.(server, err instanceof Error ? err : new Error(String(err)));
192
+ return [];
193
+ });
194
+ }));
195
+ return results.flat();
196
+ };
197
+ }
@@ -0,0 +1,98 @@
1
+ // Per host rate limiting that follows what APIs tell us: Retry-After on 429
2
+ // and 503, budget headers (x-ratelimit-*) and rate headers (x-rate-limit-*)
3
+ import { setTimeout } from 'node:timers/promises';
4
+ const DEFAULT_COOLDOWN = 60_000;
5
+ // Warn when this share of a budget is left
6
+ const LOW_REMAINING = 0.1;
7
+ export function createRateLimiter({ intervals = {}, now = Date.now, sleep = setTimeout } = {}) {
8
+ const hosts = new Map();
9
+ const state = (host) => {
10
+ let hostState = hosts.get(host);
11
+ if (!hostState) {
12
+ hostState = { interval: intervals[host] ?? 0, nextRequest: 0, blockedUntil: 0, warned: false };
13
+ hosts.set(host, hostState);
14
+ }
15
+ return hostState;
16
+ };
17
+ const limiter = {
18
+ async schedule(host, minInterval = 0) {
19
+ const hostState = state(host);
20
+ hostState.interval = Math.max(hostState.interval, minInterval);
21
+ const blocked = hostState.blockedUntil - now();
22
+ if (blocked > 0)
23
+ throw new Error(`${host} is rate limiting, skipping it for ${formatDuration(blocked)}`);
24
+ const wait = hostState.nextRequest - now();
25
+ hostState.nextRequest = Math.max(now(), hostState.nextRequest) + hostState.interval;
26
+ if (wait > 0)
27
+ await sleep(wait);
28
+ },
29
+ failed(host) {
30
+ const hostState = state(host);
31
+ hostState.blockedUntil = Math.max(hostState.blockedUntil, now() + DEFAULT_COOLDOWN);
32
+ },
33
+ update(host, res) {
34
+ const hostState = state(host);
35
+ const header = (name) => res.headers.get(name);
36
+ const block = (until) => { hostState.blockedUntil = Math.max(hostState.blockedUntil, until); };
37
+ // Overloaded: too many requests, or the server (or a gateway in front of it) gave up
38
+ if ([429, 502, 503, 504].includes(res.status)) {
39
+ block(now() + (parseRetryAfter(header('retry-after'), now()) ?? DEFAULT_COOLDOWN));
40
+ }
41
+ // Budgets, like OpenAlex's daily credits
42
+ const limit = Number(header('x-ratelimit-limit'));
43
+ const remaining = header('x-ratelimit-remaining') === null ? NaN : Number(header('x-ratelimit-remaining'));
44
+ const reset = parseReset(header('x-ratelimit-reset'), now());
45
+ if (remaining <= 0 && reset !== undefined)
46
+ block(reset);
47
+ if (limit > 0 && remaining <= limit * LOW_REMAINING) {
48
+ if (!hostState.warned) {
49
+ hostState.warned = true;
50
+ limiter.onWarning?.(`${host}: rate limit almost used up, ${remaining} of ${limit} left${reset === undefined ? '' : `, resets in ${formatDuration(reset - now())}`}`);
51
+ }
52
+ }
53
+ else if (remaining > limit * LOW_REMAINING) {
54
+ hostState.warned = false;
55
+ }
56
+ // Rates, like Crossref's "1 request per 1s"
57
+ const rateLimit = Number(header('x-rate-limit-limit'));
58
+ const interval = parseDuration(header('x-rate-limit-interval'));
59
+ if (rateLimit > 0 && interval !== undefined) {
60
+ hostState.interval = Math.max(intervals[host] ?? 0, interval / rateLimit);
61
+ }
62
+ }
63
+ };
64
+ return limiter;
65
+ }
66
+ // Retry-After is either seconds or an HTTP date
67
+ function parseRetryAfter(value, now) {
68
+ if (value === null)
69
+ return;
70
+ if (/^\d+$/.test(value.trim()))
71
+ return Number(value) * 1000;
72
+ const date = Date.parse(value);
73
+ return Number.isNaN(date) ? undefined : Math.max(0, date - now);
74
+ }
75
+ // Resets are either seconds from now or a unix timestamp
76
+ function parseReset(value, now) {
77
+ if (value === null)
78
+ return;
79
+ const seconds = Number(value);
80
+ if (!Number.isFinite(seconds))
81
+ return;
82
+ return seconds > 1e9 ? seconds * 1000 : now + seconds * 1000;
83
+ }
84
+ function parseDuration(value) {
85
+ const match = value?.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/);
86
+ if (!match)
87
+ return;
88
+ const units = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 };
89
+ return Number(match[1]) * units[match[2] ?? 's'];
90
+ }
91
+ function formatDuration(ms) {
92
+ const seconds = Math.ceil(ms / 1000);
93
+ if (seconds < 600)
94
+ return `${seconds}s`;
95
+ if (seconds < 7200)
96
+ return `${Math.ceil(seconds / 60)}m`;
97
+ return `${Math.ceil(seconds / 3600)}h`;
98
+ }
@@ -0,0 +1,33 @@
1
+ import { connectMcp, createLazyMcpTool } from './mcp.js';
2
+ import { researchServers } from './mcp-servers.js';
3
+ import { sourceTools } from './sources.js';
4
+ import { createSubagentTool } from './subagent.js';
5
+ export const researchPrompt = (date = new Date()) => `You are a research agent. Today's date is ${date.toLocaleDateString('en-CA')}.
6
+ Answer the question using the tools: Wikipedia, Wikidata and scholarly paper databases. Search first, then read the most relevant results. Don't rely on memory for facts you can look up. Paper tools only return abstracts, not full papers, so say when a claim is based on an abstract. Sources can be unavailable or rate limited: if a tool fails, move on to another source instead of retrying it.
7
+ Only use wolfram_alpha when the question is about mathematics, like calculations, equations or statistics. Never use it for other facts.
8
+ Reply with a concise answer, followed by a "Sources" list with the URL of every source you used. If the sources don't answer the question, say so.
9
+ If a regular web search would help the user learn more, for example about news, prices, opinions or anything your sources don't cover, end with a "Suggested web searches:" list of search queries. Leave it out if you have none.`;
10
+ export function createResearchTool({ connect = connectMcp, ...options }) {
11
+ const wolfram = researchServers.find(server => server.name === 'wolfram');
12
+ // Wolfram is only contacted when the model calls this tool, for maths
13
+ const wolframAlpha = createLazyMcpTool({
14
+ server: wolfram,
15
+ tool: 'WolframAlpha',
16
+ name: 'wolfram_alpha',
17
+ description: 'Compute answers to mathematics questions with Wolfram|Alpha: calculations, equations, statistics. Only use it for maths.',
18
+ parameters: {
19
+ type: 'object',
20
+ properties: { query: { type: 'string', description: 'Wolfram Alpha query, like "integrate x^2 sin(x)"' } },
21
+ required: ['query']
22
+ },
23
+ connect
24
+ });
25
+ return createSubagentTool({
26
+ name: 'research',
27
+ description: 'Ask a research agent that searches Wikipedia, Wikidata and scholarly papers, and computes maths with Wolfram|Alpha. Returns a concise answer with source URLs, and sometimes suggested web searches. Use it for facts you are unsure about or that may have changed. Include the source URLs and suggested web searches in your answer.',
28
+ parameter: { name: 'question', description: 'A self-contained question, including all needed context' },
29
+ prompt: () => researchPrompt(),
30
+ tools: [...sourceTools, wolframAlpha],
31
+ maxTurns: 15
32
+ }, options);
33
+ }