@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julian Gruber <mail@juliangruber.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # harness
2
+
3
+ A general purpose agent harness: it answers questions, does research and writes code. Talks to any OpenAI compatible `/v1/chat/completions` API (Ollama, llama.cpp, vLLM, LM Studio, OpenRouter, ...) and gives the model the tools OpenCode and Pi converged on: `read`, `write`, `edit`, `glob`, `grep` and `bash`.
4
+
5
+ Its research is fair: it only uses sources that don't rely on ads, like Wikipedia, Wikidata and open scholarly databases, so agent traffic doesn't take income away from sites that need human visitors.
6
+
7
+ The `bash` tool runs whatever the model asks for, with your permissions. Run it in a container (see [juliangruber/agent](https://github.com/juliangruber/agent)) if that worries you.
8
+
9
+ ## Install
10
+
11
+ Requires Node 22.17+ or 24.1+.
12
+
13
+ ```console
14
+ $ npx @juliangruber/harness "what files are in this directory?"
15
+ ```
16
+
17
+ Or install it globally, as the `harness` command:
18
+
19
+ ```console
20
+ $ npm install -g @juliangruber/harness
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```console
26
+ $ npx @juliangruber/harness "what files are in this directory?"
27
+ $ npx @juliangruber/harness # interactive session
28
+ $ npx @juliangruber/harness --debug "hi" # also log system prompt, tools and all messages to stderr
29
+ $ npx @juliangruber/harness --trust "hi" # use AGENTS.md / CLAUDE.md without asking
30
+ $ npx @juliangruber/harness --unsafe "hi" # no bash checks, no working directory limit, for containers
31
+ ```
32
+
33
+ If the working directory contains `AGENTS.md`, `AGENT.md` or `CLAUDE.md` (first match wins), the harness shows a preview and asks whether to use it, every time. Without a terminal to ask in, the file is ignored with a warning unless `--trust` is passed.
34
+
35
+ Answers go to stdout, rendered from markdown. Tool calls are logged to stderr.
36
+
37
+ Before a `bash` command runs, a separate model call checks two things at once: whether the other tools could do the same, and whether the command is safe to run. If the other tools could do it, the command is refused and the model is told which tools to use. Unsafe commands are refused too: ones that could delete files outside the working directory, change the system, touch secrets, send data out, push or publish, or keep running in the background. If the check can't decide, the command is refused. Allowed commands print the reason as a yellow warning, since it often points at a tool worth adding. The check is a model's judgment, not a security boundary.
38
+
39
+ The file tools (`read`, `write`, `edit`, `ls`, `glob`, `grep`) only access files inside the working directory, and follow symlinks to check where they really point. When running in a container, `--unsafe` skips the bash check and lifts this limit.
40
+
41
+ Tools the model asks for but doesn't have are printed in red after the answer, as a TODO list of tools to add. A made up name that resembles an existing tool, like `search_paper_query` for `search_papers`, is a naming problem instead: the model gets a "Did you mean" hint and a yellow warning is printed. Tools that are missing on purpose, fetching arbitrary web pages and searching the web, are listed in the system prompt so the model doesn't ask for them.
42
+
43
+ | Env var | Default |
44
+ | ---------------- | --------------------------- |
45
+ | `AGENT_MODEL` | `qwen3.8` |
46
+ | `AGENT_BASE_URL` | `http://localhost:11434/v1` |
47
+ | `AGENT_API_KEY` | none |
48
+ | `AGENT_RESEARCH_MODEL` | `AGENT_MODEL`, also used by the docs agent |
49
+ | `AGENT_CONTACT` | none, sent in the User-Agent to research sources and MCP servers if set |
50
+
51
+ ## Tools
52
+
53
+ ### Main agent
54
+
55
+ | Tool | What it does |
56
+ | ---- | ------------ |
57
+ | `read` | Read a text file, optionally a range of lines |
58
+ | `write` | Write a file, creating parent directories |
59
+ | `edit` | Replace an exact string in a file |
60
+ | `ls` | List a directory, like `ls -la`: hidden files included, directories end with `/`, files show their size |
61
+ | `glob` | Find files by glob pattern, or list a directory with `*`. Includes hidden files, directories end with `/`, files show their size. Doesn't look inside `node_modules` and `.git` |
62
+ | `grep` | Search file contents with a regular expression |
63
+ | `bash` | Run a shell command, if checks find the other tools can't do the same and the command is safe |
64
+ | `research` | Ask the [research agent](#research) a question |
65
+ | `docs` | Ask the [docs agent](#docs) about software libraries and APIs |
66
+
67
+ ### Research agent
68
+
69
+ | Tool | What it does |
70
+ | ---- | ------------ |
71
+ | `wikipedia_search` | Search Wikipedia articles |
72
+ | `wikipedia_article` | Read the text of a Wikipedia article |
73
+ | `wikidata_search` | Search Wikidata entities |
74
+ | `wikidata_entity` | Facts Wikidata has about an entity |
75
+ | `search_papers` | Search all paper sources below at once, merging duplicates |
76
+ | `search_openalex` | Search scholarly works across all fields |
77
+ | `search_semantic_scholar` | Search papers, strong in computer science and biomedicine |
78
+ | `search_crossref` | Search publication metadata |
79
+ | `search_europe_pmc` | Search biomedical and life science literature |
80
+ | `search_arxiv` | Search preprints, often rate limited |
81
+ | `wolfram_alpha` | Compute answers to maths questions with Wolfram Alpha, through Wolfram's MCP server. Only used for maths |
82
+
83
+ Paper tools return abstracts, not full papers.
84
+
85
+ ### Docs agent
86
+
87
+ Tools come from remote MCP servers and are loaded when the agent first runs.
88
+
89
+ | Tool | What it does |
90
+ | ---- | ------------ |
91
+ | `deepwiki_read_wiki_structure` | List the documentation topics DeepWiki has for a GitHub repository |
92
+ | `deepwiki_read_wiki_contents` | Read DeepWiki's documentation for a GitHub repository |
93
+ | `deepwiki_ask_question` | Ask DeepWiki a question about a GitHub repository |
94
+ | `context7_resolve-library-id` | Find a library's Context7 id |
95
+ | `context7_query-docs` | Get current documentation and code examples for a library |
96
+ | `microsoft_docs_search` | Search Microsoft Learn documentation |
97
+ | `microsoft_code_sample_search` | Search Microsoft Learn code samples |
98
+ | `microsoft_docs_fetch` | Read a Microsoft Learn page |
99
+
100
+ ## Research
101
+
102
+ The `research` tool hands a question to a separate agent, so search results don't fill up the main conversation. It only uses sources whose income doesn't depend on human visitors, through their official keyless APIs. Wolfram|Alpha, funded by subscriptions and paid APIs rather than ads, is reached through Wolfram's MCP server, which is free for limited personal use. Only its `WolframAlpha` tool is used, only for maths questions, and Wolfram's server is only contacted when the research agent actually calls it. Running Wolfram Language code on Wolfram's servers is left out.
103
+
104
+ The harness doesn't search the web itself. When a regular web search would help, the answer ends with suggested web searches for you to run, and your visit goes to the sites that need it. Sources and suggested searches from research are always added to the final answer, even if the model leaves them out, and they're highlighted.
105
+
106
+ Requests are rate limited per host: `Retry-After` on 429/503 pauses a host (60s if missing), so do timeouts and network errors (60s), a used up `x-ratelimit-remaining` budget pauses it until `x-ratelimit-reset`, and `x-rate-limit-limit`/`-interval` space requests out. arXiv and Semantic Scholar get fixed spacing. Paused sources fail right away, so the model moves on. Without a key, OpenAlex allows roughly 100 searches a day; a warning is printed when it runs low.
107
+
108
+ Ollama's `/v1` endpoint can't set the context size per request and the default is small. Raise it on the server, e.g. `OLLAMA_CONTEXT_LENGTH=32768 ollama serve`.
109
+
110
+ ## Docs
111
+
112
+ The `docs` tool hands a question about software to a separate agent, which reads documentation from remote [MCP](https://modelcontextprotocol.io) servers that need no account: DeepWiki, Context7 and Microsoft Learn. Each server is reviewed for fairness first: its operator offers documentation to agents and doesn't earn from ads on it. The list of MCP servers for the docs and research agents, with the reasoning for each and the servers left out, is in `src/mcp-servers.ts`. Only reviewed tools are exposed, tools a server adds later stay hidden until reviewed.
113
+
114
+ Servers are connected when the docs agent first runs, not at startup. A server that can't be reached prints a warning and is left out.
115
+
116
+ ## Development
117
+
118
+ Running from source needs Node 22.18+ or 24.1+, which run TypeScript directly.
119
+
120
+ ```console
121
+ $ npm install
122
+ $ node src/cli.ts "hi" # run from source, no build needed
123
+ $ npm test
124
+ $ npm run typecheck
125
+ ```
126
+
127
+ Tests use a fake server and mocked `fetch`, no model or network needed. CI runs typecheck and tests on every push to `main` and on pull requests (`.github/workflows/ci.yml`).
package/dist/agent.js ADDED
@@ -0,0 +1,73 @@
1
+ import { UNAVAILABLE_TOOLS } from './tools.js';
2
+ export function systemPrompt(cwd = process.cwd(), instructions, { bashReview = true, restrictFiles = true } = {}) {
3
+ const files = restrictFiles ? ' File tools only access files inside it.' : '';
4
+ const bash = bashReview
5
+ ? 'Prefer the dedicated tools over bash: every bash call makes an extra round trip, in which a separate check reviews whether the other tools could do the same and whether the command is safe to run. Commands failing the check are refused.'
6
+ : 'Prefer the dedicated tools over bash.';
7
+ const prompt = `You are a helpful assistant running on the user's computer. The working directory is ${cwd}.${files} Today's date is ${new Date().toLocaleDateString('en-CA')}.
8
+ Answer any question or request. Not every request is about code: answer general questions directly, without commenting on what kind of session this is. When it helps, use the tools to read and write files or run commands, and keep going until the request is done. ${bash}
9
+ If none of your tools fit, use tool_search to find more. These tools are known to be unavailable, don't search for them: ${UNAVAILABLE_TOOLS.join(', ')}.
10
+ If you'd recommend follow-up web searches to the user, end your answer with a "Suggested web searches:" list of search queries.`;
11
+ return instructions
12
+ ? `${prompt}\n\nInstructions from ${instructions.path}:\n${instructions.content}`
13
+ : prompt;
14
+ }
15
+ // Runs the model until it replies without tool calls. Appends every message
16
+ // to `messages`, so calling it again continues the conversation.
17
+ export async function runAgent(messages, { client, tools, maxTurns = 50, onToolCall, onToolResult, onMissingTool }) {
18
+ const schemas = tools.map(({ name, description, parameters }) => ({
19
+ type: 'function',
20
+ function: { name, description, parameters }
21
+ }));
22
+ for (let turn = 0; turn < maxTurns; turn++) {
23
+ const reply = await client.chat(messages, schemas);
24
+ messages.push(reply);
25
+ if (!reply.tool_calls)
26
+ return reply.content ?? '';
27
+ for (const call of reply.tool_calls) {
28
+ onToolCall?.(call);
29
+ const tool = tools.find(t => t.name === call.function.name);
30
+ let content;
31
+ if (tool) {
32
+ content = await runTool(tool, call);
33
+ }
34
+ else {
35
+ const suggestions = similarTools(call.function.name, tools);
36
+ onMissingTool?.(call, suggestions);
37
+ content = suggestions.length
38
+ ? `Error: unknown tool ${call.function.name}. Did you mean: ${suggestions.join(', ')}? Otherwise use tool_search to find more tools.`
39
+ : `Error: unknown tool ${call.function.name}. Use tool_search to find more tools.`;
40
+ }
41
+ onToolResult?.(call, content);
42
+ messages.push({ role: 'tool', tool_call_id: call.id, content });
43
+ }
44
+ }
45
+ throw new Error(`Stopped after ${maxTurns} turns`);
46
+ }
47
+ // Words many tool names share, which say nothing about what a tool is for
48
+ const GENERIC_WORDS = new Set(['search', 'find', 'get', 'read', 'list', 'fetch', 'query', 'tool', 'run', 'call']);
49
+ const meaningfulWords = (name) => new Set(name
50
+ .toLowerCase()
51
+ .split(/[^a-z0-9]+/)
52
+ .map(word => word.replace(/s$/, ''))
53
+ .filter(word => word.length > 1 && !GENERIC_WORDS.has(word)));
54
+ // Existing tools sharing the most meaningful words with a made up tool name,
55
+ // like search_papers for search_paper_query
56
+ export function similarTools(name, tools) {
57
+ const wanted = meaningfulWords(name);
58
+ const scored = tools
59
+ .filter(tool => tool.name !== 'tool_search')
60
+ .map(tool => ({ name: tool.name, score: [...meaningfulWords(tool.name)].filter(word => wanted.has(word)).length }))
61
+ .filter(tool => tool.score > 0);
62
+ const best = Math.max(0, ...scored.map(tool => tool.score));
63
+ return scored.filter(tool => tool.score === best).slice(0, 3).map(tool => tool.name);
64
+ }
65
+ // Errors go back to the model as text so it can recover
66
+ async function runTool(tool, call) {
67
+ try {
68
+ return await tool.run(JSON.parse(call.function.arguments || '{}'));
69
+ }
70
+ catch (err) {
71
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
72
+ }
73
+ }
@@ -0,0 +1,58 @@
1
+ export const bashReviewPrompt = (tools, cwd) => `You check a shell command that an agent wants to run with its bash tool, on the user's computer and without asking the user. The working directory is ${cwd}. Besides bash, the agent has these tools:
2
+ ${tools.map(tool => `- ${tool.name}: ${tool.description}`).join('\n')}
3
+
4
+ Answer two questions.
5
+ 1. Can the purpose of the command be achieved with these tools instead of bash? Listing, finding, reading and searching files can be done with them. Running programs, tests, builds, git or package managers can't.
6
+ 2. Is the command safe to run? It is unsafe if it could:
7
+ - delete or overwrite files outside the working directory, or delete many files inside it
8
+ - change system settings, install software system wide, or use sudo
9
+ - read or send secrets, like SSH keys, tokens, passwords or environment variables
10
+ - send data to the internet, other than downloading the project's dependencies
11
+ - push, publish or deploy anything, or rewrite git history
12
+ - start processes that keep running in the background
13
+ Other commands, like running tests, builds, linters or read-only git commands, are safe.
14
+
15
+ Reply with JSON only: {"use_tools": true or false, "tools": ["names of the tools to use, if use_tools is true"], "reason": "why the tools can or can't do it", "safe": true or false, "safety": "why the command is safe or unsafe"}`;
16
+ const text = (value, fallback) => typeof value === 'string' && value.trim() ? value.trim() : fallback;
17
+ export async function reviewBashCommand(command, { client, tools, cwd = process.cwd() }) {
18
+ let answer;
19
+ try {
20
+ const reply = await client.chat([
21
+ { role: 'system', content: bashReviewPrompt(tools, cwd) },
22
+ { role: 'user', content: `Command:\n${command}` }
23
+ ], []);
24
+ answer = JSON.parse(reply.content?.match(/\{[\s\S]*\}/)?.[0] ?? '');
25
+ }
26
+ catch { }
27
+ const names = new Set(tools.map(tool => tool.name));
28
+ const useTools = answer?.use_tools === true;
29
+ // Only an explicit yes counts as safe, so a failed check refuses the command
30
+ const safe = answer?.safe === true;
31
+ return {
32
+ useTools,
33
+ tools: useTools && Array.isArray(answer.tools)
34
+ ? answer.tools.filter((name) => typeof name === 'string' && names.has(name))
35
+ : [],
36
+ reason: text(answer?.reason, answer ? 'no reason given' : 'the check couldn\'t decide'),
37
+ safe,
38
+ safety: text(answer?.safety, safe ? 'no reason given' : 'the check couldn\'t verify the command is safe')
39
+ };
40
+ }
41
+ export function withBashReview(bash, { onReview, ...options }) {
42
+ return {
43
+ ...bash,
44
+ async run(args) {
45
+ const review = await reviewBashCommand(args.command, options);
46
+ onReview?.(args.command, review);
47
+ // Unsafe comes first, so an unsafe command never points the model at another tool
48
+ if (!review.safe) {
49
+ throw new Error(`bash refused as unsafe: ${review.safety.replace(/\.$/, '')}. Don't retry it. If it's needed, ask the user to run it themselves.`);
50
+ }
51
+ if (review.useTools) {
52
+ const instead = review.tools.length ? review.tools.join(', ') : 'the other tools';
53
+ throw new Error(`bash refused: ${review.reason.replace(/\.$/, '')}. Use ${instead} instead.`);
54
+ }
55
+ return bash.run(args);
56
+ }
57
+ };
58
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { cwd, env, exit, stderr, stdin, stdout } from 'node:process';
4
+ import { parseArgs, styleText } from 'node:util';
5
+ import { runAgent, systemPrompt } from './agent.js';
6
+ import { withBashReview } from './bashreview.js';
7
+ import { answerSeparator, withDebug } from './debug.js';
8
+ import { createDocsTool } from './docs.js';
9
+ import { resolveInstructions } from './instructions.js';
10
+ import { createClient } from './llm.js';
11
+ import { renderMarkdown } from './markdown.js';
12
+ import { rateLimiter } from './http.js';
13
+ import { createResearchTool } from './research.js';
14
+ import { appendSearches, appendSources, extractSearches, extractUrls, SECTION_HEADING } from './subagent.js';
15
+ import { bash, createToolSearch, restrictFileTools, tools } from './tools.js';
16
+ const { values, positionals } = parseArgs({
17
+ options: {
18
+ debug: { type: 'boolean', short: 'd' },
19
+ trust: { type: 'boolean' },
20
+ // For running in a container: bash commands aren't checked
21
+ unsafe: { type: 'boolean' }
22
+ },
23
+ allowPositionals: true
24
+ });
25
+ const warn = (text) => stderr.write(`${styleText('yellow', text, { stream: stderr })}\n`);
26
+ if (values.unsafe)
27
+ warn('WARNING: --unsafe: bash commands run without checks, and file tools can access files outside the working directory. Only use this in a container.');
28
+ else
29
+ restrictFileTools(cwd());
30
+ rateLimiter.onWarning = warn;
31
+ async function askUser(question) {
32
+ const rl = createInterface({ input: stdin, output: stderr });
33
+ rl.on('SIGINT', () => exit(130));
34
+ try {
35
+ return await rl.question(question);
36
+ }
37
+ finally {
38
+ rl.close();
39
+ }
40
+ }
41
+ const instructions = await resolveInstructions(cwd(), {
42
+ trust: values.trust ?? false,
43
+ ask: stdin.isTTY ? askUser : undefined,
44
+ warn
45
+ });
46
+ const baseUrl = env.AGENT_BASE_URL ?? 'http://localhost:11434/v1';
47
+ const model = env.AGENT_MODEL ?? 'qwen3.8';
48
+ const onRetry = (err) => warn(`${err.message}, retrying`);
49
+ const llm = createClient({ baseUrl, model, apiKey: env.AGENT_API_KEY, onRetry });
50
+ const researchLlm = createClient({ baseUrl, model: env.AGENT_RESEARCH_MODEL ?? model, apiKey: env.AGENT_API_KEY, onRetry });
51
+ // Tools the model asked for, collected per question and printed after the answer
52
+ const toolRequests = new Set();
53
+ const toolSearch = createToolSearch(description => toolRequests.add(description));
54
+ const logToolCall = (prefix) => (call) => {
55
+ // Debug mode already logs tool calls
56
+ if (!values.debug)
57
+ stderr.write(`[${prefix}${call.function.name}] ${call.function.arguments}\n`);
58
+ };
59
+ // Models sometimes call tools by made up names. Close to an existing tool's
60
+ // name it's a naming problem, otherwise it's a request for a new tool.
61
+ const onMissingTool = (call, suggestions) => {
62
+ if (suggestions.length)
63
+ warn(`WARNING: unknown tool ${call.function.name}, probably meant ${suggestions.join(' or ')}`);
64
+ else
65
+ toolRequests.add(`${call.function.name} ${call.function.arguments}`);
66
+ };
67
+ const subagentOptions = (label) => ({
68
+ client: values.debug ? withDebug(researchLlm, { label }) : researchLlm,
69
+ extraTools: [toolSearch],
70
+ onToolCall: logToolCall(`${label} > `),
71
+ onMissingTool
72
+ });
73
+ const onMcpError = (server, err) => warn(`MCP server ${server.name} unavailable: ${err.message}`);
74
+ const research = createResearchTool(subagentOptions('research'));
75
+ const docs = createDocsTool({ ...subagentOptions('docs'), onError: onMcpError });
76
+ // Their answers carry sources and suggested web searches
77
+ const subagents = new Set([research.name, docs.name]);
78
+ // bash only runs when the other tools can't do the same and the command is
79
+ // safe, unless --unsafe skips the check
80
+ const checkedBash = values.unsafe
81
+ ? bash
82
+ : withBashReview(bash, {
83
+ client: values.debug ? withDebug(llm, { label: 'bash review' }) : llm,
84
+ tools: [...tools.filter(tool => tool !== bash), research, docs],
85
+ onReview: (_, review) => warn(!review.safe
86
+ ? `WARNING: bash refused as unsafe: ${review.safety}`
87
+ : review.useTools
88
+ ? `WARNING: bash refused, use ${review.tools.join(' or ') || 'the other tools'}: ${review.reason}`
89
+ : `WARNING: bash used: ${review.reason}`)
90
+ });
91
+ const mainTools = [...tools.map(tool => tool === bash ? checkedBash : tool), research, docs, toolSearch];
92
+ const client = values.debug ? withDebug(llm) : llm;
93
+ const messages = [{ role: 'system', content: systemPrompt(cwd(), instructions, { bashReview: !values.unsafe, restrictFiles: !values.unsafe }) }];
94
+ async function ask(prompt) {
95
+ messages.push({ role: 'user', content: prompt });
96
+ toolRequests.clear();
97
+ const researchUrls = [];
98
+ const researchSearches = [];
99
+ let answer = await runAgent(messages, {
100
+ client,
101
+ tools: mainTools,
102
+ onToolCall: logToolCall(''),
103
+ onToolResult: (call, result) => {
104
+ if (!subagents.has(call.function.name))
105
+ return;
106
+ researchUrls.push(...extractUrls(result));
107
+ researchSearches.push(...extractSearches(result));
108
+ },
109
+ onMissingTool
110
+ });
111
+ answer = appendSources(answer, [...new Set(researchUrls)]);
112
+ answer = appendSearches(answer, [...new Set(researchSearches)]);
113
+ // Keep the conversation in sync with what the user sees
114
+ const last = messages.at(-1);
115
+ if (last?.role === 'assistant')
116
+ last.content = answer;
117
+ if (values.debug)
118
+ stderr.write(answerSeparator());
119
+ stdout.write(`${renderMarkdown(answer, { highlight: SECTION_HEADING })}\n`);
120
+ for (const request of toolRequests) {
121
+ stderr.write(`${styleText('red', `TODO: add tool: ${request}`, { stream: stderr })}\n`);
122
+ }
123
+ }
124
+ const prompt = positionals.join(' ');
125
+ if (prompt) {
126
+ await ask(prompt);
127
+ }
128
+ else {
129
+ // Write the prompt manually: rl.prompt() throws once stdin has ended
130
+ const rl = createInterface({ input: stdin });
131
+ stdout.write('> ');
132
+ for await (const line of rl) {
133
+ if (line.trim()) {
134
+ try {
135
+ await ask(line);
136
+ }
137
+ catch (err) {
138
+ stderr.write(`${err instanceof Error ? err.message : err}\n`);
139
+ }
140
+ }
141
+ stdout.write('> ');
142
+ }
143
+ }
package/dist/debug.js ADDED
@@ -0,0 +1,83 @@
1
+ import { stderr } from 'node:process';
2
+ import { styleText } from 'node:util';
3
+ import { renderMarkdown } from './markdown.js';
4
+ const style = (format, text) => styleText(format, text, { stream: stderr });
5
+ const indent = (text, spaces) => text.split('\n').map(line => line && ' '.repeat(spaces) + line).join('\n');
6
+ // Printed between the debug log and the rendered answer
7
+ export const answerSeparator = () => `${style(['bold', 'magenta'], '=== answer ===')}\n`;
8
+ // Tool arguments as `key: value` lines, multi-line values indented below
9
+ function formatArguments(args) {
10
+ let parsed;
11
+ try {
12
+ parsed = JSON.parse(args || '{}');
13
+ }
14
+ catch {
15
+ return indent(args, 2);
16
+ }
17
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
18
+ return indent(JSON.stringify(parsed, null, 2), 2);
19
+ return Object.entries(parsed).map(([key, value]) => {
20
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
21
+ return text.includes('\n')
22
+ ? ` ${style('dim', `${key}:`)}\n${indent(text, 4)}`
23
+ : ` ${style('dim', `${key}:`)} ${text}`;
24
+ }).join('\n');
25
+ }
26
+ function formatResult(content) {
27
+ if (/^\s*[[{]/.test(content)) {
28
+ try {
29
+ return JSON.stringify(JSON.parse(content), null, 2);
30
+ }
31
+ catch { }
32
+ }
33
+ return content;
34
+ }
35
+ // Wraps a client to log everything exchanged with the model, including
36
+ // implicit context like the system prompt, tool definitions and tool results
37
+ export function withDebug(client, { label, log = text => { stderr.write(text); } } = {}) {
38
+ const seen = new WeakSet();
39
+ const toolNames = new Map();
40
+ let toolsLogged = false;
41
+ const header = (text) => style(['bold', 'magenta'], `--- ${label ? `${label}: ` : ''}${text} ---`);
42
+ const formatMessage = (message) => {
43
+ const lines = [];
44
+ if (message.role === 'tool') {
45
+ const name = toolNames.get(message.tool_call_id);
46
+ lines.push(header(`tool result: ${name ? `${name} ` : ''}${message.tool_call_id}`), formatResult(message.content));
47
+ }
48
+ else {
49
+ lines.push(header(message.role));
50
+ if (message.content)
51
+ lines.push(renderMarkdown(message.content, { stream: stderr }));
52
+ if (message.role === 'assistant') {
53
+ for (const call of message.tool_calls ?? []) {
54
+ toolNames.set(call.id, call.function.name);
55
+ lines.push(`${style(['bold', 'cyan'], call.function.name)} ${style('dim', call.id)}`);
56
+ const args = formatArguments(call.function.arguments);
57
+ if (args)
58
+ lines.push(args);
59
+ }
60
+ }
61
+ }
62
+ return `${lines.join('\n')}\n`;
63
+ };
64
+ const formatTools = (tools) => `${[header('tools'), ...tools.map(({ function: f }) => `${style('bold', f.name)}: ${f.description}`)].join('\n')}\n`;
65
+ return {
66
+ async chat(messages, tools) {
67
+ if (!toolsLogged && tools.length) {
68
+ log(formatTools(tools));
69
+ toolsLogged = true;
70
+ }
71
+ for (const message of messages) {
72
+ if (seen.has(message))
73
+ continue;
74
+ seen.add(message);
75
+ log(formatMessage(message));
76
+ }
77
+ const reply = await client.chat(messages, tools);
78
+ seen.add(reply);
79
+ log(formatMessage(reply));
80
+ return reply;
81
+ }
82
+ };
83
+ }
package/dist/docs.js ADDED
@@ -0,0 +1,24 @@
1
+ // The docs agent answers questions about software from documentation MCP
2
+ // servers, see mcp-servers.ts for the list and why each one is fair.
3
+ import { createMcpToolLoader } from './mcp.js';
4
+ import { docsServers } from './mcp-servers.js';
5
+ import { createSubagentTool } from './subagent.js';
6
+ export const docsPrompt = (date = new Date()) => `You are a documentation agent. Today's date is ${date.toLocaleDateString('en-CA')}.
7
+ Answer questions about software libraries, frameworks, GitHub repositories and Microsoft products using the tools: DeepWiki (documentation of public GitHub repositories), Context7 (up to date library documentation) and Microsoft Learn. APIs change, so look them up instead of relying on memory. If a tool fails, move on to another tool instead of retrying it.
8
+ Reply with a concise answer, with code examples where useful, followed by a "Sources" list with the URLs of the documentation you used. If the documentation doesn't answer the question, say so.`;
9
+ export function createDocsTool({ servers = docsServers, connect, onError, ...options }) {
10
+ const loadTools = createMcpToolLoader(servers, { connect, onError });
11
+ return createSubagentTool({
12
+ name: 'docs',
13
+ description: 'Ask a documentation agent about software libraries, frameworks, GitHub repositories and Microsoft products. It reads current documentation, so use it for APIs and usage you are unsure about. Returns an answer with source URLs, include them in your answer.',
14
+ parameter: { name: 'question', description: 'A self-contained question, including library names and versions' },
15
+ prompt: () => docsPrompt(),
16
+ tools: async () => {
17
+ const tools = await loadTools();
18
+ if (!tools.length)
19
+ throw new Error('No documentation server could be reached. Answer without docs, and say so.');
20
+ return tools;
21
+ },
22
+ maxTurns: 15
23
+ }, options);
24
+ }
package/dist/html.js ADDED
@@ -0,0 +1,12 @@
1
+ // Small, dependency free helpers for text that contains HTML
2
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
3
+ export const decodeEntities = (text) => text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (match, entity) => entity[0] !== '#'
4
+ ? ENTITIES[entity.toLowerCase()] ?? match
5
+ : String.fromCodePoint(entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)));
6
+ // Strips tags, decodes entities and collapses whitespace into one line. Block
7
+ // tags become spaces so paragraphs don't run together, inline tags disappear.
8
+ export const cleanText = (text) => decodeEntities(text
9
+ .replace(/<\/?(?:[a-z]+:)?(?:p|div|br|li|title|sec|h\d)\b[^>]*>/gi, ' ')
10
+ .replace(/<[^>]+>/g, ''))
11
+ .replace(/\s+/g, ' ')
12
+ .trim();
package/dist/http.js ADDED
@@ -0,0 +1,44 @@
1
+ // HTTP requests to research sources and MCP servers, rate limited per host
2
+ import { createRateLimiter } from './ratelimit.js';
3
+ // Optional contact info, recommended by Wikimedia, OpenAlex and Crossref
4
+ export const CONTACT = process.env.AGENT_CONTACT;
5
+ export const USER_AGENT = `harness/0.0.0${CONTACT ? ` (${CONTACT})` : ''}`;
6
+ // Shared by all requests. Set onWarning to hear about budgets running low.
7
+ export const rateLimiter = createRateLimiter({
8
+ intervals: {
9
+ // arXiv asks for at most one request every 3 seconds
10
+ 'export.arxiv.org': 3000,
11
+ // Keyless Semantic Scholar requests share one pool with everyone
12
+ 'api.semanticscholar.org': 1000
13
+ }
14
+ });
15
+ // Bot protection like Cloudflare's answers with a challenge page instead of content
16
+ export const isBotChallenge = (res) => res.headers.get('cf-mitigated') === 'challenge';
17
+ export async function request(url, { method = 'GET', body, headers = {}, timeout = 30_000, minInterval, hint = 'Try another source instead.', allowErrors = false } = {}) {
18
+ const { hostname } = new URL(url);
19
+ try {
20
+ await rateLimiter.schedule(hostname, minInterval);
21
+ let res;
22
+ try {
23
+ res = await fetch(url, { method, body, headers: { 'user-agent': USER_AGENT, ...headers }, signal: AbortSignal.timeout(timeout) });
24
+ }
25
+ catch (err) {
26
+ rateLimiter.failed(hostname);
27
+ throw err;
28
+ }
29
+ rateLimiter.update(hostname, res);
30
+ if (isBotChallenge(res)) {
31
+ // The site doesn't want automated visits. Respect that, don't work around it.
32
+ rateLimiter.failed(hostname);
33
+ throw new Error(`${hostname} blocks automated clients with a bot challenge`);
34
+ }
35
+ if (!res.ok && !allowErrors)
36
+ throw new Error(`${hostname} responded ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`);
37
+ return res;
38
+ }
39
+ catch (err) {
40
+ const message = err instanceof Error ? err.message : String(err);
41
+ throw new Error(`${message.includes(hostname) ? message : `${hostname} unavailable: ${message}`}. ${hint}`);
42
+ }
43
+ }
44
+ export const getJson = async (url) => (await request(url)).json();
@@ -0,0 +1,31 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ // In order of preference, matched case insensitively
4
+ const FILENAMES = ['agents.md', 'agent.md', 'claude.md'];
5
+ const PREVIEW_LINES = 10;
6
+ export async function findInstructions(cwd) {
7
+ const entries = await readdir(cwd, { withFileTypes: true });
8
+ for (const filename of FILENAMES) {
9
+ const entry = entries.find(e => e.isFile() && e.name.toLowerCase() === filename);
10
+ if (entry) {
11
+ const path = join(cwd, entry.name);
12
+ return { path, content: await readFile(path, 'utf8') };
13
+ }
14
+ }
15
+ }
16
+ // Instructions are only used if the user trusts them, every time
17
+ export async function resolveInstructions(cwd, { trust, ask, warn }) {
18
+ const instructions = await findInstructions(cwd);
19
+ if (!instructions || trust)
20
+ return instructions;
21
+ if (!ask) {
22
+ warn(`Ignoring ${instructions.path}: can't ask whether to trust it. Pass --trust to use it.`);
23
+ return;
24
+ }
25
+ const lines = instructions.content.split('\n');
26
+ const preview = lines.slice(0, PREVIEW_LINES).map(line => ` ${line}`).join('\n');
27
+ const more = lines.length > PREVIEW_LINES ? `\n [${lines.length - PREVIEW_LINES} more lines]` : '';
28
+ const answer = await ask(`Found ${instructions.path} for review:\n${preview}${more}\nUse these instructions? [y/N] `);
29
+ if (/^y(es)?$/i.test(answer.trim()))
30
+ return instructions;
31
+ }