Actually vendor harness

This commit is contained in:
2026-09-11 13:41:22 -04:00
parent 1acd371668
commit 04e013b090
52 changed files with 8550 additions and 1 deletions
+273
View File
@@ -0,0 +1,273 @@
/**
* Context compaction: heuristic fallback + optional one-shot LLM summary.
*/
const truncate = require('./truncate.js');
const CHAR_PER_TOKEN = 3;
const THRESHOLD = 0.68;
const MIN_SUMMARY = 80;
const COMPACT_PROMPT =
'Summarize this coding-agent conversation. Use exactly these sections:\n' +
'1. Goal\n' +
'2. Done\n' +
'3. Files / decisions\n' +
'4. Open work\n' +
'5. Next action\n' +
'Be specific (paths, names, errors). Do not say the conversation was compacted.';
function contentChars(content) {
if (content == null) return 0;
if (typeof content === 'string') return content.length;
if (Array.isArray(content)) {
let n = 0;
for (let i = 0; i < content.length; i++) {
const p = content[i];
if (p == null) continue;
if (typeof p === 'string') n += p.length;
else if (p.text) n += String(p.text).length;
else if (p.type === 'image_url' || p.image_url) n += 1600;
else n += JSON.stringify(p).length;
}
return n;
}
return JSON.stringify(content).length;
}
function messageChars(m) {
if (!m) return 0;
let n = contentChars(m.content);
if (m.tool_calls) n += JSON.stringify(m.tool_calls).length;
if (m.name) n += String(m.name).length;
return n + 8;
}
function estimateTokens(messages, tools) {
let n = 0;
for (const m of messages || []) n += messageChars(m);
n += JSON.stringify(tools || []).length;
return Math.ceil(n / CHAR_PER_TOKEN);
}
function historyBudget(ctxSize, tools, attempt) {
const cap = ctxSize > 0 ? Number(ctxSize) : 8192;
const toolTok = Math.ceil(JSON.stringify(tools || []).length / CHAR_PER_TOKEN);
const reserve = Math.max(384, Math.floor(cap * (0.18 + (Number(attempt) || 0) * 0.08)));
return Math.max(240, Math.floor(cap * 0.72) - toolTok - reserve);
}
function shouldCompact(messages, tools, ctxSize) {
const cap = ctxSize > 0 ? ctxSize : 8192;
return estimateTokens(messages, tools) > Math.floor(cap * THRESHOLD);
}
function isOverflowError(err) {
const s = String((err && err.message) || err || '');
return /context window|context.?length|too many tokens|maximum context|prompt too (?:long|large)|prompt exceeds|ContextOverflow|reduce the prompt size/i.test(
s
);
}
function usage(messages, tools, ctxSize) {
return snapshot(estimateTokens(messages, tools), ctxSize);
}
function snapshot(used, ctxSize) {
const limit = ctxSize > 0 ? Number(ctxSize) : 8192;
const n = Math.max(0, Math.round(Number(used) || 0));
const pct = limit ? Math.min(100, Math.round((n / limit) * 1000) / 10) : 0;
return {
used: n,
limit,
pct,
threshold: Math.round(THRESHOLD * 100),
over: n > Math.floor(limit * THRESHOLD),
};
}
function isDegenerate(summary) {
const s = String(summary || '').trim();
if (s.length < MIN_SUMMARY) return true;
if (/conversation compacted/i.test(s)) return true;
if (/^\s*\[earlier conversation compacted\]\s*$/i.test(s)) return true;
return false;
}
function isRealUser(m) {
if (!m || m.role !== 'user') return false;
const c = String(m.content || '');
if (c.indexOf('<system-reminder>') >= 0) return false;
if (c.indexOf('[conversation summary]') >= 0) return false;
if (c.indexOf('[memory]') === 0) return false;
if (c.indexOf('[git status]') === 0) return false;
return true;
}
function rebuildHistory(messages, summary) {
const list = Array.isArray(messages) ? messages : [];
const sys =
list[0] && list[0].role === 'system'
? { role: 'system', content: list[0].content }
: { role: 'system', content: '' };
let lastUser = null;
for (let i = list.length - 1; i >= 0; i--) {
if (isRealUser(list[i])) {
lastUser = { role: 'user', content: list[i].content };
break;
}
}
const tail = [];
for (let i = list.length - 1; i >= 1 && tail.length < 6; i--) {
const m = list[i];
if (!m || m.role === 'system') continue;
if (lastUser && m.role === 'user' && m.content === lastUser.content) continue;
tail.unshift({ role: m.role, content: m.content, name: m.name, tool_call_id: m.tool_call_id });
}
const out = [sys, { role: 'user', content: '[conversation summary]\n' + String(summary || '').trim() }];
if (lastUser) out.push(lastUser);
return out.concat(tail);
}
function truncateMsg(m, maxChars) {
if (!m) return m;
const copy = Object.assign({}, m);
const c = copy.content;
if (typeof c === 'string' && c.length > maxChars) {
copy.content = truncate.truncateWithMarker(c, maxChars);
} else if (Array.isArray(c)) {
copy.content = c.map((part) => {
if (!part || typeof part !== 'object') return part;
if (typeof part.text === 'string' && part.text.length > maxChars) {
return Object.assign({}, part, { text: truncate.truncateWithMarker(part.text, maxChars) });
}
return part;
});
}
return copy;
}
function lastRealUserIndex(list) {
for (let i = list.length - 1; i >= 0; i--) {
if (isRealUser(list[i])) return i;
}
return -1;
}
function heuristicCompact(messages, opts) {
opts = opts || {};
const budget = opts.budgetTokens || 6000;
const aggressive = !!opts.aggressive;
let keep = messages.slice();
while (estimateTokens(keep, opts.tools) > budget && keep.length > 4) {
let idx = keep.findIndex((m, i) => i > 0 && m.role === 'tool');
if (idx < 0) idx = keep.findIndex((m, i) => i > 1 && m.role === 'assistant');
if (idx < 0) break;
keep.splice(idx, 1);
}
const maxMsg = aggressive ? 1200 : 3200;
if (estimateTokens(keep, opts.tools) > budget) {
const lastUser = lastRealUserIndex(keep);
keep = keep.map((m, i) => (i === 0 || i === lastUser ? m : truncateMsg(m, maxMsg)));
}
if (estimateTokens(keep, opts.tools) > budget && keep.length > 3) {
const head = keep[0];
const lastUserIdx = lastRealUserIndex(keep);
const lastUser = lastUserIdx >= 0 ? keep[lastUserIdx] : null;
const tail = [];
for (let i = keep.length - 1; i >= 1 && tail.length < (aggressive ? 2 : 4); i--) {
if (i === lastUserIdx) continue;
tail.unshift(keep[i]);
}
keep = [head, { role: 'user', content: '[conversation summary]\nEarlier turns were compacted to fit the model window.' }];
if (lastUser) keep.push(lastUser);
keep = keep.concat(tail);
keep = keep.map((m, i) => (i === 0 ? m : truncateMsg(m, aggressive ? 700 : 1800)));
}
while (estimateTokens(keep, opts.tools) > budget && keep.length > 3) {
const dropAt = keep.findIndex((m, i) => i > 1 && !isRealUser(m));
if (dropAt < 0) break;
keep.splice(dropAt, 1);
}
return keep;
}
function compact(messages, opts) {
return heuristicCompact(messages, opts);
}
function transcript(messages) {
return (messages || [])
.map((m) => {
const role = m && m.role ? m.role : 'unknown';
const name = m && m.name ? ' ' + m.name : '';
return role + name + ':\n' + String(m && m.content != null ? m.content : '');
})
.join('\n\n');
}
function autoContinue(messages) {
const list = messages || [];
const last = list[list.length - 1];
if (!last) return null;
if (last.role === 'tool' || last.role === 'assistant') {
return {
role: 'user',
content:
'<system-reminder>\nContinue the work from the summary. Do not wait for a new user request.\n</system-reminder>',
};
}
return null;
}
function compactReminder() {
return (
'<system-reminder>\n' +
'Context was compacted. Trust the summary and the last user request. Re-read files before further edits. Follow AGENTS.md if present.\n' +
'</system-reminder>'
);
}
async function compactWithLlm(messages, opts) {
opts = opts || {};
const fallback = () => heuristicCompact(messages, opts);
const complete = opts.complete;
if (typeof complete !== 'function') return fallback();
const cap =
opts.maxTranscriptChars ||
Math.max(1500, Math.min(24000, Math.floor((opts.budgetTokens || 4000) * CHAR_PER_TOKEN * 0.45)));
const body = truncate.truncateWithMarker(transcript(messages), cap);
try {
const result = await complete({
history: [
{ role: 'system', content: 'Reply with the five summary sections only. No tools.' },
{ role: 'user', content: COMPACT_PROMPT + '\n\n---\n\n' + body },
],
tools: [],
});
const summary = result && result.text;
if (isDegenerate(summary)) return fallback();
return rebuildHistory(messages, summary);
} catch (_) {
return fallback();
}
}
module.exports = {
CHAR_PER_TOKEN,
THRESHOLD,
MIN_SUMMARY,
COMPACT_PROMPT,
estimateTokens,
historyBudget,
shouldCompact,
isOverflowError,
usage,
snapshot,
isDegenerate,
rebuildHistory,
heuristicCompact,
compact,
autoContinue,
compactReminder,
compactWithLlm,
};
+141
View File
@@ -0,0 +1,141 @@
/**
* Page-registered custom tools (schemas only; handlers stay in the page).
* No Bare imports — unit-testable on Node.
*/
const toolSet = require('./tool-set.js');
const NAME_RE = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
const MAX_TOOLS = 32;
const MAX_DESC = 2000;
const bySession = new Map();
const sessionOpts = new Map();
const handlers = new Map();
function setReserved(names) {
reserved = new Set(names);
}
let reserved = new Set(toolSet.ALWAYS_RESERVED.concat(toolSet.ALWAYS_BUILTIN_RESERVED));
function setSession(sessionId, opts) {
if (!sessionId) return;
sessionOpts.set(sessionId, {
hostWorkspace: toolSet.parseHostWorkspace(opts),
});
}
function isReserved(name, sessionId) {
if (reserved.has(name)) return true;
if (!toolSet.isHostWorkspaceTool(name)) return false;
const flags = sessionId ? sessionOpts.get(sessionId) : null;
if (flags && flags.hostWorkspace === false) return false;
return true;
}
function normalizeSchema(raw, sessionId) {
if (!raw || typeof raw !== 'object') throw new Error('tool schema required');
let name = raw.name;
let description = raw.description;
let parameters = raw.parameters;
if (raw.type === 'function' && raw.function) {
name = raw.function.name;
description = raw.function.description;
parameters = raw.function.parameters;
}
if (!NAME_RE.test(String(name || ''))) throw new Error('invalid tool name');
if (isReserved(name, sessionId)) throw new Error('tool name is reserved: ' + name);
const desc = String(description || '').slice(0, MAX_DESC);
let params = parameters && typeof parameters === 'object' ? parameters : { type: 'object', properties: {} };
if (params.type && params.type !== 'object') {
throw new Error('tool parameters must be a JSON object schema');
}
return {
type: 'function',
name,
description: desc || name,
parameters: {
type: 'object',
properties: params.properties && typeof params.properties === 'object' ? params.properties : {},
required: Array.isArray(params.required) ? params.required.map(String) : undefined,
},
};
}
function list(sessionId) {
const m = bySession.get(sessionId);
return m ? Array.from(m.values()) : [];
}
function register(sessionId, tools) {
if (!sessionId) throw new Error('sessionId required');
const arr = Array.isArray(tools) ? tools : [tools];
let map = bySession.get(sessionId);
if (!map) {
map = new Map();
bySession.set(sessionId, map);
}
const out = [];
for (const t of arr) {
const schema = normalizeSchema(t, sessionId);
if (map.size >= MAX_TOOLS && !map.has(schema.name)) {
throw new Error('too many custom tools (max ' + MAX_TOOLS + ')');
}
map.set(schema.name, schema);
if (typeof t.execute === 'function') {
let h = handlers.get(sessionId);
if (!h) {
h = new Map();
handlers.set(sessionId, h);
}
h.set(schema.name, t.execute);
}
out.push(schema);
}
return list(sessionId);
}
function unregister(sessionId, name) {
const map = bySession.get(sessionId);
if (map && name) map.delete(name);
const h = handlers.get(sessionId);
if (h && name) h.delete(name);
return list(sessionId);
}
function clear(sessionId) {
bySession.delete(sessionId);
sessionOpts.delete(sessionId);
handlers.delete(sessionId);
}
function has(sessionId, name) {
const map = bySession.get(sessionId);
return !!(map && map.has(name));
}
function getHandler(sessionId, name) {
const h = handlers.get(sessionId);
return h && h.get(name);
}
function defs(sessionId) {
return list(sessionId);
}
module.exports = {
NAME_RE,
MAX_TOOLS,
setReserved,
setSession,
isReserved,
normalizeSchema,
register,
unregister,
list,
clear,
has,
getHandler,
defs,
};
+50
View File
@@ -0,0 +1,50 @@
/**
* Cached `git status -sb` for the system prompt. Spawn is optional so Node tests
* can inject a runner.
*/
const TTL_MS = 30000;
const cache = { cwd: '', at: 0, text: '' };
function formatStatus(stdout, stderr) {
const body = String(stdout || '').trim() || String(stderr || '').trim();
if (!body) return '';
if (/not a git repository/i.test(body)) return '';
const lines = body.split('\n').slice(0, 40);
return '[git status]\n' + lines.join('\n');
}
async function gitStatusSb(cwd, opts) {
opts = opts || {};
if (!cwd || opts.hostWorkspace === false) return '';
if (cache.cwd === cwd && Date.now() - cache.at < (opts.ttlMs || TTL_MS)) return cache.text;
const run = opts.run;
if (typeof run !== 'function') {
cache.cwd = cwd;
cache.at = Date.now();
cache.text = '';
return '';
}
try {
const out = await run(cwd, 'git status -sb', opts.timeoutMs || 8000);
const text = formatStatus(out && out.stdout, out && out.stderr);
cache.cwd = cwd;
cache.at = Date.now();
cache.text = text;
return text;
} catch (_) {
cache.cwd = cwd;
cache.at = Date.now();
cache.text = '';
return '';
}
}
function resetCache() {
cache.cwd = '';
cache.at = 0;
cache.text = '';
}
module.exports = { TTL_MS, formatStatus, gitStatusSb, resetCache };
+110
View File
@@ -0,0 +1,110 @@
/**
* Session goal tracker + verifier prompt. No Bare imports.
*
* Done is update_goal({ completed: true }) passing a single-call verifier,
* not the model merely stopping tool use.
*/
const STATUSES = ['idle', 'planning', 'executing', 'verifying', 'complete', 'blocked'];
const MAX_VERIFIER_RUNS = 5;
function create(objective, opts) {
opts = opts || {};
const text = String(objective || '').trim();
return {
objective: text,
criteria: Array.isArray(opts.criteria) ? opts.criteria.map(String) : [],
status: text ? 'executing' : 'idle',
gaps: [],
notes: '',
verifierRuns: 0,
verify: opts.verify !== false,
blockedReason: '',
};
}
function isActive(g) {
return !!(g && (g.status === 'planning' || g.status === 'executing' || g.status === 'verifying'));
}
function plannerAddendum(g) {
if (!g || !g.objective) return '';
const criteria = (g.criteria || []).length
? '\nAcceptance criteria:\n' + g.criteria.map((c, i) => (i + 1) + '. ' + c).join('\n')
: '';
return (
'Active goal: ' +
g.objective +
criteria +
'\nComplete all todos, then call update_goal({ completed: true }).' +
'\nDo not stop with open todos. If blocked, call update_goal({ blocked_reason: "..." }).'
);
}
function continuation(g) {
const gaps = g && g.gaps && g.gaps.length ? '\nGaps: ' + g.gaps.join('; ') : '';
return (
'Goal NOT complete — continue. Objective: ' +
((g && g.objective) || '') +
gaps +
'\nDo not stop until you call update_goal({ completed: true }) or update_goal({ blocked_reason: "..." }).'
);
}
function verifierPrompt(g, evidence) {
const prior = (g && g.gaps && g.gaps.length ? g.gaps.join('\n- ') : '(none)');
return (
'You are an adversarial verifier. You are NOT the agent that produced the work. ' +
'Your job is to refute that the objective has been met. Default to achieved: false if uncertain.\n\n' +
'OBJECTIVE:\n' +
((g && g.objective) || '') +
'\n\nEVIDENCE:\n' +
String(evidence || '(none)') +
'\n\nPRIOR_GAPS:\n- ' +
prior +
'\n\nReply with JSON only: {"achieved": true|false, "gaps": ["..."]}'
);
}
function parseVerifier(text) {
const raw = String(text || '');
const m = raw.match(/\{[\s\S]*\}/);
if (!m) {
const yes = /not refuted|achieved["']?\s*:\s*true/i.test(raw);
const no = /refuted|not achieved|achieved["']?\s*:\s*false/i.test(raw);
return { achieved: yes && !no, gaps: no ? ['verifier response was not JSON'] : [] };
}
try {
const j = JSON.parse(m[0]);
const gaps = Array.isArray(j.gaps) ? j.gaps.map(String) : [];
return { achieved: !!j.achieved, gaps };
} catch (_) {
return { achieved: false, gaps: ['verifier response was not JSON'] };
}
}
function snapshot(g) {
if (!g) return null;
return {
objective: g.objective,
criteria: g.criteria || [],
status: g.status,
gaps: g.gaps || [],
notes: g.notes || '',
verifierRuns: g.verifierRuns || 0,
verify: g.verify !== false,
blockedReason: g.blockedReason || '',
};
}
module.exports = {
STATUSES,
MAX_VERIFIER_RUNS,
create,
isActive,
plannerAddendum,
continuation,
verifierPrompt,
parseVerifier,
snapshot,
};
+59
View File
@@ -0,0 +1,59 @@
/**
* Grep helpers: glob match + output modes. No Bare imports.
*/
function globToRegExp(glob) {
const g = String(glob || '').replace(/\\/g, '/');
if (!g) return null;
let out = '^';
for (let i = 0; i < g.length; i++) {
const c = g[i];
if (c === '*' && g[i + 1] === '*') {
out += '.*';
i += 1;
if (g[i + 1] === '/') i += 1;
} else if (c === '*') out += '[^/]*';
else if (c === '?') out += '[^/]';
else if ('\\.()+^$[]{}|'.indexOf(c) >= 0) out += '\\' + c;
else out += c;
}
out += '$';
return new RegExp(out, 'i');
}
function matchGlob(relPath, glob) {
if (!glob) return true;
const rel = String(relPath || '').replace(/\\/g, '/');
const re = globToRegExp(glob);
if (!re) return true;
if (re.test(rel)) return true;
const base = rel.split('/').pop();
return re.test(base);
}
function formatHits(hits, mode, truncated) {
const list = hits || [];
const m = String(mode || 'content').toLowerCase();
if (m === 'count') {
const by = {};
for (const h of list) {
const p = h.path || '';
by[p] = (by[p] || 0) + 1;
}
return { mode: 'count', files: by, truncated: !!truncated };
}
if (m === 'files_with_matches' || m === 'files') {
const seen = [];
const set = new Set();
for (const h of list) {
if (!set.has(h.path)) {
set.add(h.path);
seen.push(h.path);
}
}
return { mode: 'files_with_matches', files: seen, truncated: !!truncated };
}
return { mode: 'content', hits: list, truncated: !!truncated };
}
module.exports = { globToRegExp, matchGlob, formatHits };
+1076
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
/**
* MCP JSON-RPC response parsing. No Bare imports / no network.
*/
function extractJson(text) {
const raw = String(text || '').trim();
if (!raw) throw new Error('empty MCP response');
if (raw[0] === '{' || raw[0] === '[') {
return JSON.parse(raw);
}
const lines = raw.split('\n');
for (const line of lines) {
const t = line.trim();
if (t.indexOf('data:') === 0) {
const payload = t.slice(5).trim();
if (payload && payload !== '[DONE]') return JSON.parse(payload);
}
}
const m = raw.match(/\{[\s\S]*\}/);
if (!m) throw new Error('MCP response was not JSON');
return JSON.parse(m[0]);
}
function unwrapResult(body) {
if (body && body.error) {
const msg = body.error.message || JSON.stringify(body.error);
throw new Error(String(msg));
}
if (body && Object.prototype.hasOwnProperty.call(body, 'result')) return body.result;
return body;
}
function normalizeTools(result) {
const list = result && result.tools ? result.tools : Array.isArray(result) ? result : [];
return list
.map((t) => {
if (!t) return null;
if (typeof t === 'string') return { name: t, description: '' };
const name = t.name || (t.function && t.function.name);
if (!name) return null;
return {
name: String(name),
description: String(t.description || (t.function && t.function.description) || ''),
inputSchema: t.inputSchema || t.parameters || (t.function && t.function.parameters) || undefined,
};
})
.filter(Boolean);
}
module.exports = { extractJson, unwrapResult, normalizeTools };
+153
View File
@@ -0,0 +1,153 @@
/**
* MCP tool registry: HTTP JSON-RPC (initialize + tools/list + tools/call).
* Stdio is trusted-origin only and is not spawned in this wave.
*/
const rpc = require('./mcp-rpc.js');
const truncate = require('./truncate.js');
const servers = new Map();
let rpcId = 1;
function nextId() {
rpcId += 1;
return rpcId;
}
function register(spec, opts) {
if (!spec || !spec.id) throw new Error('mcp id required');
const transport = spec.transport || 'http';
if (transport === 'stdio') {
const approved = opts && (opts.alwaysApprove || opts._alwaysApprove);
if (!approved) throw new Error('stdio MCP requires a trusted origin (always-approve)');
throw new Error('stdio MCP is opt-in and not connected until explicitly implemented');
}
if (transport === 'http' && spec.url) {
require('../lib/net.js').assertPublicHttpUrl(spec.url);
}
const rec = {
id: spec.id,
name: spec.name || spec.id,
transport,
url: spec.url || null,
command: spec.command || null,
tools: Array.isArray(spec.tools) ? spec.tools : [],
handshakeError: null,
handshakeReported: false,
ready: null,
};
servers.set(spec.id, rec);
if (transport === 'http' && spec.url) {
rec.ready = handshake(rec).catch((err) => {
rec.handshakeError = err && err.message ? err.message : String(err);
});
return rec.ready.then(() => list());
}
return Promise.resolve(list());
}
async function handshake(rec) {
await jsonRpc(rec.url, 'initialize', {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
clientInfo: { name: 'agent-harness', version: '0.1.0' },
});
try {
await jsonRpc(rec.url, 'notifications/initialized', {});
} catch (_) {}
const listed = await jsonRpc(rec.url, 'tools/list', {});
rec.tools = rpc.normalizeTools(listed);
rec.handshakeError = null;
return rec.tools;
}
async function jsonRpc(url, method, params) {
const net = require('../lib/net.js');
net.assertPublicHttpUrl(url);
const payload = { jsonrpc: '2.0', id: nextId(), method, params: params || {} };
const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
},
body: JSON.stringify(payload),
});
const text = await res.text();
const body = rpc.extractJson(text);
return rpc.unwrapResult(body);
}
function unregister(id) {
servers.delete(id);
return list();
}
function list() {
return Array.from(servers.values()).map((s) => ({
id: s.id,
name: s.name,
transport: s.transport,
toolCount: (s.tools || []).length,
tools: (s.tools || []).map((t) => t.name || t),
handshakeError: s.handshakeError || null,
}));
}
function search(query) {
const q = String(query || '').toLowerCase();
const hits = [];
for (const s of servers.values()) {
for (const t of s.tools || []) {
const name = typeof t === 'string' ? t : t.name;
const desc = typeof t === 'object' ? t.description || '' : '';
const wire = s.id + '__' + name;
if (!q || wire.toLowerCase().includes(q) || desc.toLowerCase().includes(q)) {
hits.push({ name: wire, server: s.id, description: desc });
}
}
}
return hits;
}
async function call(wireName, args) {
const idx = String(wireName || '').indexOf('__');
if (idx < 0) throw new Error('expected server__tool name');
const serverId = wireName.slice(0, idx);
const tool = wireName.slice(idx + 2);
const s = servers.get(serverId);
if (!s) throw new Error('unknown MCP server: ' + serverId);
if (s.ready) {
try {
await s.ready;
} catch (_) {}
}
if (s.handshakeError) throw new Error('MCP handshake failed: ' + s.handshakeError);
if (s.transport === 'http' && s.url) {
const result = await jsonRpc(s.url, 'tools/call', { name: tool, arguments: args || {} });
return truncate.truncateWithMarker(typeof result === 'string' ? result : JSON.stringify(result), 12000);
}
throw new Error('MCP transport not connected: ' + s.transport);
}
function handshakeReminders() {
const out = [];
for (const s of servers.values()) {
if (s.handshakeError && !s.handshakeReported) {
s.handshakeReported = true;
out.push('MCP ' + s.id + ' handshake failed: ' + s.handshakeError);
}
}
return out;
}
function count() {
return servers.size;
}
function reset() {
servers.clear();
rpcId = 1;
}
module.exports = { register, unregister, list, search, call, count, handshakeReminders, reset };
+22
View File
@@ -0,0 +1,22 @@
/**
* Format memory hits for prompt injection. No Bare imports.
*/
function formatInject(hits, max) {
const list = (hits || []).slice(0, max || 5);
if (!list.length) return '';
return (
'[memory]\n' +
list
.map((h) => {
const snip = String(h.snippet || '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 220);
return '- ' + (h.name || 'note') + (snip ? ': ' + snip : '');
})
.join('\n')
);
}
module.exports = { formatInject };
+63
View File
@@ -0,0 +1,63 @@
/**
* Simple file-backed memory under the agent workspace.
*/
const path = require('path');
const fs = require('fs');
const { ensureDir } = require('../lib/paths.js');
const { formatInject } = require('./memory-format.js');
const sandbox = require('./sandbox.js');
function memoryDir(origin) {
return ensureDir(path.join(sandbox.defaultCwd(origin), '.agent-harness', 'memory'));
}
function writeNote(origin, name, text) {
const dir = memoryDir(origin);
const file = path.join(dir, sandbox.sanitizeId(name || 'note') + '.md');
fs.writeFileSync(file, String(text || ''));
return file;
}
function listNotes(origin) {
const dir = memoryDir(origin);
let names = [];
try {
names = fs.readdirSync(dir);
} catch (_) {
return [];
}
return names.filter((n) => n.endsWith('.md'));
}
function readNote(origin, name) {
const file = path.join(memoryDir(origin), sandbox.sanitizeId(name.replace(/\.md$/, '')) + '.md');
return fs.readFileSync(file, 'utf8');
}
function search(origin, query) {
const q = String(query || '').toLowerCase();
const hits = [];
for (const n of listNotes(origin)) {
let text = '';
try {
text = readNote(origin, n);
} catch (_) {
continue;
}
if (!q || n.toLowerCase().includes(q) || text.toLowerCase().includes(q)) {
hits.push({ name: n, snippet: text.slice(0, 400) });
}
}
return hits.slice(0, 20);
}
function injectBlock(origin, query) {
try {
return formatInject(search(origin, query), 5);
} catch (_) {
return '';
}
}
module.exports = { writeNote, listNotes, readNote, search, formatInject, injectBlock };
+59
View File
@@ -0,0 +1,59 @@
/**
* Persistent allow/deny patterns. No Bare imports.
*
* A rule is { tool, pattern, decision: 'allow'|'deny' }.
* Shell patterns match the leading tokens of the command (e.g. "git status").
* Path patterns match args.path / args.file, prefix or exact.
*/
function patternFromArgs(tool, args) {
args = args || {};
if (tool === 'run_terminal_cmd') {
return String(args.command || '')
.trim()
.split(/\s+/)
.slice(0, 2)
.join(' ');
}
if (args.path) return String(args.path);
if (args.file) return String(args.file);
if (args.url) return String(args.url);
if (args.name) return String(args.name);
return '*';
}
function globish(value, pattern) {
const v = String(value || '');
const p = String(pattern || '');
if (!p || p === '*') return true;
if (v === p) return true;
if (v.indexOf(p) === 0) return true;
if (p.endsWith('*') && v.indexOf(p.slice(0, -1)) === 0) return true;
return false;
}
function matchRule(rule, tool, args) {
if (!rule || rule.tool !== tool) return false;
const pat = patternFromArgs(tool, args);
return globish(pat, rule.pattern);
}
function resolve(rules, tool, args) {
const list = Array.isArray(rules) ? rules : [];
for (let i = list.length - 1; i >= 0; i--) {
if (matchRule(list[i], tool, args)) return list[i].decision === 'deny' ? 'deny' : 'allow';
}
return null;
}
function addRule(rules, tool, args, decision) {
const next = Array.isArray(rules) ? rules.slice() : [];
const pattern = patternFromArgs(tool, args);
const rec = { tool: String(tool), pattern: pattern || '*', decision: decision === 'deny' ? 'deny' : 'allow' };
const idx = next.findIndex((r) => r.tool === rec.tool && r.pattern === rec.pattern);
if (idx >= 0) next[idx] = rec;
else next.push(rec);
return next;
}
module.exports = { patternFromArgs, globish, matchRule, resolve, addRule };
+37
View File
@@ -0,0 +1,37 @@
/**
* Persist allow/deny patterns next to agent sessions.
*/
const path = require('path');
const fs = require('fs');
const { ensureAgentRoot, ensureDir } = require('../lib/paths.js');
const rules = require('./perm-rules.js');
function rulesFile() {
return path.join(ensureDir(ensureAgentRoot()), 'permission-rules.json');
}
function load() {
try {
const raw = JSON.parse(fs.readFileSync(rulesFile(), 'utf8'));
return Array.isArray(raw) ? raw : [];
} catch (_) {
return [];
}
}
function save(list) {
const next = Array.isArray(list) ? list : [];
fs.writeFileSync(rulesFile(), JSON.stringify(next, null, 2));
return next;
}
function resolve(tool, args) {
return rules.resolve(load(), tool, args);
}
function remember(tool, args, decision) {
return save(rules.addRule(load(), tool, args, decision));
}
module.exports = { rulesFile, load, save, resolve, remember };
+176
View File
@@ -0,0 +1,176 @@
/**
* Plan-mode state machine (Grok-shaped). No Bare imports — unit-testable on Node.
*
* States: inactive | pending | active | exitPending
* While active, write/shell tools are blocked except the session plan file.
*/
const STATES = ['inactive', 'pending', 'active', 'exitPending'];
const WRITE_TOOLS = ['write_file', 'search_replace', 'run_terminal_cmd'];
const PLAN_FILE_NAMES = ['plan.md'];
function create(snapshot) {
const s = snapshot && typeof snapshot === 'object' ? snapshot : {};
let state = String(s.state || 'inactive');
if (STATES.indexOf(state) < 0) state = 'inactive';
if (state === 'pending') state = 'inactive';
if (state === 'exitPending') state = s.awaitingApproval ? 'active' : 'inactive';
return {
state,
reminderCount: Number(s.reminderCount) || 0,
planPath: String(s.planPath || 'plan.md'),
awaitingApproval: !!s.awaitingApproval,
wasActive: !!s.wasActive,
pendingExitReminder: !!s.pendingExitReminder,
};
}
function isActive(pm) {
if (!pm) return false;
if (pm === true) return true;
return pm.state === 'active' || pm.state === 'exitPending';
}
function enterPending(pm) {
if (!pm) return false;
if (pm.state === 'inactive') {
pm.state = 'pending';
pm.pendingExitReminder = false;
return true;
}
if (pm.state === 'exitPending') {
pm.state = 'active';
pm.pendingExitReminder = false;
return true;
}
return false;
}
function activate(pm) {
if (!pm) return false;
if (pm.state !== 'pending' && pm.state !== 'inactive') return false;
pm.state = 'active';
pm.wasActive = true;
pm.reminderCount = 0;
pm.awaitingApproval = false;
pm.pendingExitReminder = false;
return true;
}
function requestExit(pm) {
if (!pm || pm.state !== 'active') return false;
pm.state = 'exitPending';
pm.awaitingApproval = true;
return true;
}
function approveExit(pm) {
if (!pm) return false;
if (pm.state !== 'active' && pm.state !== 'exitPending') return false;
pm.state = 'inactive';
pm.awaitingApproval = false;
pm.reminderCount = 0;
pm.pendingExitReminder = false;
return true;
}
function rejectExit(pm) {
if (!pm) return false;
pm.state = 'active';
pm.awaitingApproval = false;
return true;
}
function reminder(pm, opts) {
opts = opts || {};
const path = (pm && pm.planPath) || 'plan.md';
const has = !!opts.hasContent;
const full = has
? 'Plan mode is active. Do not make any edits or writes to the system.\n\n' +
'A plan file exists at ' +
path +
'. You can read it and make edits using write_file or search_replace.\n' +
'This is the only file you are allowed to edit.\n\n' +
'Your turn should only end with either ask_user_question to clarify requirements or exit_plan_mode to present your plan to the user.'
: 'Plan mode is active. Do not make any edits or writes to the system.\n\n' +
'No plan written yet. Write your plan to ' +
path +
' using write_file.\n' +
'This is the only file you are allowed to edit.\n\n' +
'Your turn should only end with either ask_user_question to clarify requirements or exit_plan_mode to present your plan to the user.';
const sparse = 'Plan mode is still active. Do not make any edits or writes to the system except for the plan file.';
const useFull = !pm || pm.reminderCount % 2 === 0;
if (pm) pm.reminderCount += 1;
return useFull ? full : sparse;
}
function exitReminder() {
return 'You have exited plan mode. You can now make edits, run tools, and take actions. Implement the approved plan.';
}
function isWriteTool(name) {
return WRITE_TOOLS.indexOf(String(name || '')) >= 0;
}
function basename(p) {
const s = String(p || '').replace(/\\/g, '/');
const i = s.lastIndexOf('/');
return i >= 0 ? s.slice(i + 1) : s;
}
function isPlanFilePath(filePath, planPath) {
const want = basename(planPath || 'plan.md').toLowerCase();
const got = basename(filePath).toLowerCase();
if (!got) return false;
if (got === want) return true;
return PLAN_FILE_NAMES.indexOf(got) >= 0;
}
function gateWrite(pm, name, args) {
if (!isActive(pm)) return null;
if (!isWriteTool(name)) return null;
if (name === 'run_terminal_cmd') {
return (
'Plan mode is active. Shell is blocked. Write only ' +
((pm && pm.planPath) || 'plan.md') +
', or call exit_plan_mode.'
);
}
const file = args && (args.path || args.file);
if (isPlanFilePath(file, pm && pm.planPath)) return null;
return (
'Rejected: file edits are not allowed in plan mode - the only editable file is the plan file (' +
((pm && pm.planPath) || 'plan.md') +
').'
);
}
function snapshot(pm) {
if (!pm) return { state: 'inactive', reminderCount: 0, planPath: 'plan.md', awaitingApproval: false };
return {
state: pm.state,
reminderCount: pm.reminderCount,
planPath: pm.planPath || 'plan.md',
awaitingApproval: !!pm.awaitingApproval,
wasActive: !!pm.wasActive,
pendingExitReminder: !!pm.pendingExitReminder,
};
}
module.exports = {
STATES,
WRITE_TOOLS,
create,
isActive,
enterPending,
activate,
requestExit,
approveExit,
rejectExit,
reminder,
exitReminder,
isWriteTool,
isPlanFilePath,
gateWrite,
snapshot,
};
+68
View File
@@ -0,0 +1,68 @@
/** Permission + shell policy with no Bare imports (unit-testable on Node). */
const WRITE_TOOLS = new Set(['search_replace', 'write_file', 'run_terminal_cmd', 'use_tool']);
const ASK_TOOLS = new Set(['run_terminal_cmd', 'web_fetch', 'web_search', 'use_tool']);
const SHELL_ALLOW = new Set([
'git', 'rg', 'grep', 'ls', 'cat', 'head', 'tail', 'pwd', 'echo', 'node', 'npm', 'npx',
'python3', 'python', 'cargo', 'go', 'make', 'bare', 'wc', 'sort', 'uniq', 'find', 'sed', 'awk',
]);
const SHELL_UNSAFE = /[;|`$()<>\n]|&&|\|\|/;
const SHELL_REMEMBER_PREFIXES = ['git status', 'git diff'];
function needsPermission(toolName, mode) {
if (mode === 'always-approve') return false;
if (mode === 'allowlist') return ASK_TOOLS.has(toolName);
return WRITE_TOOLS.has(toolName) || ASK_TOOLS.has(toolName);
}
function shellName(command) {
const c = String(command || '').trim();
const first = c.split(/\s+/)[0] || '';
return first.replace(/^["']|["']$/g, '').split(/[/\\]/).pop();
}
function shellAllowlisted(command) {
return SHELL_ALLOW.has(shellName(command));
}
function shellSafe(command) {
const c = String(command || '');
if (!c.trim()) return false;
if (SHELL_UNSAFE.test(c)) return false;
return shellAllowlisted(c);
}
function shellPrefix(command, n) {
return String(command || '')
.trim()
.split(/\s+/)
.slice(0, n || 2)
.join(' ');
}
function matchesCommandPrefix(command, pattern) {
const c = String(command || '').trim();
const p = String(pattern || '').trim();
if (!p) return false;
if (c === p) return true;
if (c.indexOf(p + ' ') === 0) return true;
return false;
}
function isRememberableShell(command) {
return SHELL_REMEMBER_PREFIXES.some((p) => matchesCommandPrefix(command, p));
}
module.exports = {
WRITE_TOOLS,
ASK_TOOLS,
SHELL_ALLOW,
SHELL_REMEMBER_PREFIXES,
needsPermission,
shellName,
shellAllowlisted,
shellSafe,
shellPrefix,
matchesCommandPrefix,
isRememberableShell,
};
+41
View File
@@ -0,0 +1,41 @@
const DEFAULT_SYSTEM = `You are a local coding agent running on-device via QVAC.
You operate inside a sandboxed workspace. Prefer small, reversible edits.
Use tools to inspect the workspace before changing files.
write_file creates or overwrites files; search_replace is for small in-place edits; run_terminal_cmd is for tests/build.
Never generate images or video. Never exfiltrate secrets.
When you are done, give a concise summary of what you did.
Workspace conventions:
- Read AGENTS.md if present and follow it.
- Do not escape the granted workspace roots.
- For shell, keep commands scoped to the workspace cwd.`;
const PAGE_SYSTEM = `You are a local coding agent running on-device via QVAC.
The host filesystem and host shell are disabled for this session.
You work only through tools the embedder registered. Call those tools to inspect and change state.
Do not assume a host workspace, host paths, or run_terminal_cmd on this machine.
Never generate images or video. Never exfiltrate secrets.
When you are done, give a concise summary of what you did.`;
function loadWorkspaceRules(fsRead, cwd) {
const names = ['AGENTS.md', '.agent-harness/AGENTS.md'];
const chunks = [];
for (const n of names) {
try {
const text = fsRead(cwd, n);
if (text && text.trim()) chunks.push('## ' + n + '\n' + text.trim());
} catch (_) {}
}
return chunks.join('\n\n');
}
function assemble({ cwd, extra, fsRead, hostWorkspace }) {
const parts = [hostWorkspace === false ? PAGE_SYSTEM : DEFAULT_SYSTEM];
if (cwd) parts.push(hostWorkspace === false ? 'Workspace: ' + cwd : 'Current workspace: ' + cwd);
const rules = hostWorkspace === false ? '' : fsRead ? loadWorkspaceRules(fsRead, cwd) : '';
if (rules) parts.push(rules);
if (extra) parts.push(String(extra));
return parts.join('\n\n');
}
module.exports = { DEFAULT_SYSTEM, PAGE_SYSTEM, assemble, loadWorkspaceRules };
+82
View File
@@ -0,0 +1,82 @@
/**
* Agent path jail + permission policy.
*/
const path = require('path');
const {
getAgentOriginRoot,
ensureDir,
resolveUnderAnyRoot,
isPathInside,
sanitizeId,
} = require('../lib/paths.js');
const policy = require('./policy.js');
const grantedRoots = new Set();
const alwaysApproveOrigins = new Set();
function setGrants(opts) {
opts = opts || {};
grantedRoots.clear();
for (const r of opts.roots || []) {
if (r) grantedRoots.add(path.resolve(String(r)));
}
alwaysApproveOrigins.clear();
for (const o of opts.alwaysApproveOrigins || []) {
if (o) alwaysApproveOrigins.add(String(o));
}
}
function listGrantedRoots() {
return Array.from(grantedRoots);
}
function defaultCwd(origin) {
return ensureDir(getAgentOriginRoot(origin));
}
function workspaceRoots(origin) {
const roots = [defaultCwd(origin)];
for (const r of grantedRoots) roots.push(r);
return roots;
}
function resolvePath(origin, userPath, cwd) {
const roots = workspaceRoots(origin);
const base = cwd && isAllowed(origin, cwd) ? cwd : roots[0];
if (!userPath || userPath === '.' || userPath === '') return path.resolve(base);
return resolveUnderAnyRoot(roots, userPath, base);
}
function isAllowed(origin, absPath) {
const roots = workspaceRoots(origin);
const resolved = path.resolve(absPath);
return roots.some((r) => isPathInside(r, resolved));
}
function permissionMode(payload) {
if (payload && payload._alwaysApprove) return 'always-approve';
if (payload && payload.permissionMode) return payload.permissionMode;
if (payload && payload._origin && alwaysApproveOrigins.has(payload._origin)) return 'always-approve';
return 'ask';
}
function needsPermission(toolName, mode) {
return policy.needsPermission(toolName, mode);
}
module.exports = {
setGrants,
listGrantedRoots,
defaultCwd,
workspaceRoots,
resolvePath,
isAllowed,
permissionMode,
needsPermission,
shellAllowlisted: policy.shellAllowlisted,
shellSafe: policy.shellSafe,
shellName: policy.shellName,
sanitizeId,
};
+65
View File
@@ -0,0 +1,65 @@
/**
* Unique search/replace (Grok-shaped). No Bare imports.
*/
function countOccurrences(hay, needle) {
if (!needle) return 0;
const h = String(hay || '');
const n = String(needle);
let count = 0;
let i = 0;
while (i < h.length) {
const at = h.indexOf(n, i);
if (at < 0) break;
count += 1;
i = at + Math.max(1, n.length);
}
return count;
}
function applySearchReplace(cur, oldString, newString, replaceAll) {
const old = oldString == null ? '' : String(oldString);
const neu = newString == null ? '' : String(newString);
const text = cur == null ? '' : String(cur);
if (!old) {
if (text.trim()) {
throw new Error(
'old_string is empty but the file is not empty; refuse overwrite. Use write_file to replace the whole file, or pass a unique old_string.'
);
}
return { text: neu, created: true, replacements: 1 };
}
const n = countOccurrences(text, old);
if (n === 0) throw new Error('old_string not found');
if (n > 1 && !replaceAll) {
throw new Error(
'old_string matched ' + n + ' times; add surrounding lines to make it unique, or set replace_all to true.'
);
}
const next = replaceAll ? text.split(old).join(neu) : text.replace(old, neu);
return { text: next, created: false, replacements: replaceAll ? n : 1 };
}
function contextSnippet(text, needle, radius) {
const lines = String(text || '').split('\n');
const r = radius == null ? 3 : radius;
const want = String(needle || '');
let idx = -1;
if (want) {
for (let i = 0; i < lines.length; i++) {
if (lines[i].indexOf(want) >= 0) {
idx = i;
break;
}
}
}
if (idx < 0) idx = 0;
const start = Math.max(0, idx - r);
const end = Math.min(lines.length, idx + r + 1);
return lines
.slice(start, end)
.map((l, i) => String(start + i + 1).padStart(6) + '| ' + l)
.join('\n');
}
module.exports = { countOccurrences, applySearchReplace, contextSnippet };
+199
View File
@@ -0,0 +1,199 @@
/**
* JSONL session store under $AGENT_HARNESS_HOME/agent/sessions/.
*/
const path = require('path');
const fs = require('fs');
const { ensureAgentRoot, ensureDir, sanitizeId } = require('../lib/paths.js');
function sessionsRoot() {
return ensureDir(path.join(ensureAgentRoot(), 'sessions'));
}
function sessionDir(id) {
return ensureDir(path.join(sessionsRoot(), sanitizeId(id)));
}
function readJsonl(file) {
try {
const raw = fs.readFileSync(file, 'utf8');
return raw
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
try {
return JSON.parse(l);
} catch (_) {
return null;
}
})
.filter(Boolean);
} catch (_) {
return [];
}
}
function appendJsonl(file, obj) {
fs.appendFileSync(file, JSON.stringify(obj) + '\n');
}
function makeId() {
return 'sess_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10);
}
function create(meta) {
const id = meta.sessionId || makeId();
const dir = sessionDir(id);
const summary = {
id,
origin: meta.origin || '',
cwd: meta.cwd || '',
model: meta.model || '',
title: meta.title || 'New session',
hostWorkspace: meta.hostWorkspace !== false,
workspace: meta.workspace || meta.cwd || '',
builtinTools: meta.builtinTools,
createdAt: Date.now(),
updatedAt: Date.now(),
plan: [],
planMode: meta.planMode || { state: 'inactive', planPath: 'plan.md', reminderCount: 0 },
goal: meta.goal || null,
};
fs.writeFileSync(path.join(dir, 'summary.json'), JSON.stringify(summary, null, 2));
fs.writeFileSync(path.join(dir, 'chat_history.jsonl'), '');
return summary;
}
function load(id, opts) {
const dir = sessionDir(id);
let summary;
try {
summary = JSON.parse(fs.readFileSync(path.join(dir, 'summary.json'), 'utf8'));
} catch (_) {
throw new Error('session not found: ' + id);
}
summary.history = readJsonl(path.join(dir, 'chat_history.jsonl'));
summary.updates = opts && opts.updates ? readJsonl(path.join(dir, 'updates.jsonl')) : [];
return summary;
}
function saveSummary(summary) {
summary.updatedAt = Date.now();
const copy = Object.assign({}, summary);
delete copy.history;
delete copy.updates;
fs.writeFileSync(path.join(sessionDir(summary.id), 'summary.json'), JSON.stringify(copy, null, 2));
}
function appendHistory(id, msg) {
appendJsonl(path.join(sessionDir(id), 'chat_history.jsonl'), Object.assign({ ts: Date.now() }, msg));
}
function appendUpdate(id, update) {
appendJsonl(path.join(sessionDir(id), 'updates.jsonl'), Object.assign({ ts: Date.now() }, update));
}
function replaceHistory(id, history) {
const file = path.join(sessionDir(id), 'chat_history.jsonl');
const body = (history || []).map((m) => JSON.stringify(m)).join('\n');
fs.writeFileSync(file, body ? body + '\n' : '');
}
function planFile(id) {
return path.join(sessionDir(id), 'plan.md');
}
function readPlan(id) {
try {
return fs.readFileSync(planFile(id), 'utf8');
} catch (_) {
return '';
}
}
function writePlan(id, text) {
const file = planFile(id);
fs.writeFileSync(file, String(text != null ? text : ''));
return file;
}
function rmDeep(dir) {
let ents = [];
try {
ents = fs.readdirSync(dir, { withFileTypes: true });
} catch (_) {
return;
}
for (const ent of ents) {
const child = path.join(dir, ent.name);
const isDir = typeof ent.isDirectory === 'function' ? ent.isDirectory() : false;
if (isDir) rmDeep(child);
else {
try {
fs.unlinkSync(child);
} catch (_) {}
}
}
try {
fs.rmdirSync(dir);
} catch (_) {}
}
function remove(id) {
if (!id) return false;
const dir = path.join(sessionsRoot(), sanitizeId(id));
try {
if (typeof fs.rmSync === 'function') fs.rmSync(dir, { recursive: true, force: true });
else rmDeep(dir);
return true;
} catch (_) {
return false;
}
}
function exists(id) {
if (!id) return false;
try {
fs.statSync(path.join(sessionsRoot(), sanitizeId(id), 'summary.json'));
return true;
} catch (_) {
return false;
}
}
function list() {
const root = sessionsRoot();
let names = [];
try {
names = fs.readdirSync(root);
} catch (_) {
return [];
}
const out = [];
for (const name of names) {
try {
const summary = JSON.parse(fs.readFileSync(path.join(root, name, 'summary.json'), 'utf8'));
out.push(summary);
} catch (_) {}
}
out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
return out;
}
module.exports = {
create,
load,
saveSummary,
appendHistory,
appendUpdate,
replaceHistory,
sessionDir,
planFile,
readPlan,
writePlan,
list,
remove,
exists,
makeId,
};
+61
View File
@@ -0,0 +1,61 @@
/**
* Identical tool-call stationarity. No Bare imports.
* Same name+args 4 times → nudge; 8 times → stuck.
*/
const NUDGE_AFTER = 4;
const STOP_AFTER = 8;
function fingerprint(name, args) {
let a = args;
try {
a = JSON.stringify(args || {});
} catch (_) {
a = String(args);
}
return String(name || '') + ':' + a;
}
function create() {
return { last: null, count: 0, nudged: false };
}
function observe(st, name, args) {
if (!st) return 0;
const fp = fingerprint(name, args);
if (st.last === fp) st.count += 1;
else {
st.last = fp;
st.count = 1;
st.nudged = false;
}
return st.count;
}
function shouldNudge(st) {
return !!(st && st.count >= NUDGE_AFTER && st.count < STOP_AFTER && !st.nudged);
}
function shouldStop(st) {
return !!(st && st.count >= STOP_AFTER);
}
function nudgeText() {
return 'You are repeating the same tool call. Try a different approach, a different path, or finish.';
}
function markNudged(st) {
if (st) st.nudged = true;
}
module.exports = {
NUDGE_AFTER,
STOP_AFTER,
fingerprint,
create,
observe,
shouldNudge,
shouldStop,
nudgeText,
markNudged,
};
+148
View File
@@ -0,0 +1,148 @@
/**
* In-process subagent task registry (same loaded model; no second loadModel).
*/
const tasks = new Map();
const waiters = [];
const MAX_CONCURRENT = 2;
function makeId() {
return 'task_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
}
function create(label, extra) {
extra = extra || {};
if (runningCount() >= MAX_CONCURRENT) {
throw new Error('too many concurrent subagents (max ' + MAX_CONCURRENT + ')');
}
const id = makeId();
const kind = extra.subagentType === 'general' || extra.subagent_type === 'general' ? 'general' : 'explore';
tasks.set(id, {
id,
label: label || 'subagent',
status: 'running',
summary: null,
messages: [],
history: Array.isArray(extra.history) ? extra.history : [],
subagentType: kind,
createdAt: Date.now(),
});
return id;
}
function runningCount() {
let n = 0;
for (const t of tasks.values()) {
if (t.status === 'running') n += 1;
}
return n;
}
function setHistory(id, history) {
const t = tasks.get(id);
if (t) {
t.history = history || [];
t.updatedAt = Date.now();
}
return t || null;
}
function flushWaiters() {
for (let i = waiters.length - 1; i >= 0; i--) {
if (waiters[i]()) waiters.splice(i, 1);
}
}
function finish(id, summary) {
const t = tasks.get(id);
if (t) {
t.status = 'done';
t.summary = summary;
t.updatedAt = Date.now();
}
flushWaiters();
return t || null;
}
function fail(id, err) {
const t = tasks.get(id);
if (t) {
t.status = 'error';
t.summary = String(err || 'error');
t.updatedAt = Date.now();
}
flushWaiters();
return t || null;
}
function get(id) {
return tasks.get(id) || null;
}
function list() {
return Array.from(tasks.values()).map((t) => ({
id: t.id,
label: t.label,
status: t.status,
summary: t.summary,
subagentType: t.subagentType || 'explore',
}));
}
function waitAll(opts) {
opts = opts || {};
const timeoutMs = opts.timeoutMs > 0 ? opts.timeoutMs : 120000;
const running = () => list().filter((t) => t.status === 'running');
if (!running().length) return Promise.resolve({ running: 0, tasks: list() });
return new Promise((resolve) => {
const timer = setTimeout(() => {
resolve({ running: running().length, tasks: list(), timedOut: true });
}, timeoutMs);
waiters.push(() => {
if (!running().length) {
clearTimeout(timer);
resolve({ running: 0, tasks: list() });
return true;
}
return false;
});
});
}
function kill(id) {
const t = tasks.get(id);
if (t && t.status === 'running') {
t.status = 'killed';
t.updatedAt = Date.now();
}
flushWaiters();
return t || null;
}
function appendMessage(id, text) {
const t = tasks.get(id);
if (!t) throw new Error('unknown task: ' + id);
t.messages.push(String(text || ''));
t.updatedAt = Date.now();
return t;
}
function reset() {
tasks.clear();
waiters.length = 0;
}
module.exports = {
MAX_CONCURRENT,
create,
finish,
fail,
get,
list,
waitAll,
kill,
appendMessage,
setHistory,
runningCount,
reset,
};
+57
View File
@@ -0,0 +1,57 @@
/**
* Session todo list: merge-by-id, grok-shaped statuses. No Bare imports.
*/
const STATUSES = ['pending', 'in_progress', 'completed', 'cancelled'];
function normalizeStatus(s) {
const v = String(s || 'pending')
.toLowerCase()
.replace(/[\s-]+/g, '_');
if (v === 'done' || v === 'complete') return 'completed';
if (v === 'progress' || v === 'doing' || v === 'inprogress') return 'in_progress';
if (v === 'cancel' || v === 'canceled') return 'cancelled';
if (STATUSES.indexOf(v) >= 0) return v;
return 'pending';
}
function normalizeItem(t, i) {
t = t || {};
return {
id: String(t.id || 'todo_' + (i + 1)),
content: String(t.content || t.text || t.title || ''),
status: normalizeStatus(t.status),
};
}
function merge(existing, incoming, mode) {
const next = Array.isArray(incoming) ? incoming.map(normalizeItem) : [];
if (mode === 'replace' || !existing || !existing.length) return next;
const byId = new Map();
for (const t of existing) {
const n = normalizeItem(t, 0);
byId.set(n.id, n);
}
for (const t of next) {
const prev = byId.get(t.id) || {};
const merged = Object.assign({}, prev, t);
if (!t.content && prev.content) merged.content = prev.content;
byId.set(t.id, merged);
}
return Array.from(byId.values());
}
function hasOpen(list) {
return (list || []).some((t) => t.status === 'pending' || t.status === 'in_progress');
}
function formatBlock(list) {
const todos = list || [];
if (!todos.length) return '[todos]\n(none)';
return (
'[todos]\n' +
todos.map((t) => '- [' + t.status + '] ' + t.id + ': ' + t.content).join('\n')
);
}
module.exports = { STATUSES, normalizeStatus, normalizeItem, merge, hasOpen, formatBlock };
+65
View File
@@ -0,0 +1,65 @@
/**
* Split a tool-call list into sequential vs parallel groups.
* Same-path write_file / search_replace share a lock key.
*/
const SEQUENTIAL = new Set([
'ask_user_question',
'exit_plan_mode',
'update_goal',
'enter_plan_mode',
'task',
'send_subagent_message',
'wait_tasks',
'kill_task',
]);
function isSequential(name) {
return SEQUENTIAL.has(String(name || ''));
}
function pathLockKey(name, args) {
args = args || {};
if (name === 'write_file' || name === 'search_replace') {
return String(args.path || args.file || '')
.replace(/\\/g, '/')
.replace(/\/+/g, '/');
}
return '';
}
function groups(calls) {
const out = [];
let current = null;
for (const call of calls || []) {
const seq = isSequential(call && call.name);
if (seq) {
if (current) {
out.push(current);
current = null;
}
out.push({ sequential: true, calls: [call] });
} else {
if (!current) current = { sequential: false, calls: [] };
current.calls.push(call);
}
}
if (current) out.push(current);
return out;
}
function withPathLock(locks, key, fn) {
if (!key) return Promise.resolve().then(fn);
const prev = locks.get(key) || Promise.resolve();
const curr = prev.then(fn, fn);
locks.set(
key,
curr.then(
() => {},
() => {}
)
);
return curr;
}
module.exports = { SEQUENTIAL, isSequential, pathLockKey, groups, withPathLock };
+80
View File
@@ -0,0 +1,80 @@
/**
* Which built-in agent tools touch the host workspace jail.
* No Bare imports — unit-testable on Node.
*/
const HOST_WORKSPACE_TOOLS = [
'read_file',
'write_file',
'search_replace',
'grep',
'list_dir',
'run_terminal_cmd',
'memory_search',
'memory_get',
'memory_write',
];
const HOST_WORKSPACE_SET = new Set(HOST_WORKSPACE_TOOLS);
const ALWAYS_RESERVED = ['image_gen', 'image_edit', 'image_to_video', 'deploy_app'];
const ALWAYS_BUILTIN_RESERVED = [
'todo_write',
'web_search',
'web_fetch',
'enter_plan_mode',
'exit_plan_mode',
'ask_user_question',
'update_goal',
'memory_write',
'task',
'send_subagent_message',
'get_task_output',
'wait_tasks',
'kill_task',
'search_tool',
'use_tool',
];
function parseHostWorkspace(opts) {
opts = opts || {};
if (opts.hostWorkspace === false || opts.hostTools === false) return false;
return true;
}
function filterBuiltinSchemas(schemas, opts) {
opts = opts || {};
const hostWorkspace = parseHostWorkspace(opts);
let list = Array.isArray(schemas) ? schemas.slice() : [];
if (!hostWorkspace) {
list = list.filter((t) => !HOST_WORKSPACE_SET.has(t.name));
}
if (opts.builtinTools === false) {
list = [];
} else if (Array.isArray(opts.builtinTools)) {
const allow = new Set(opts.builtinTools.map(String));
list = list.filter((t) => allow.has(t.name));
}
if (opts.planMode) {
// Keep write_file / search_replace so the model can edit plan.md; gate other writes at execute.
list = list.filter((t) => t.name !== 'run_terminal_cmd');
}
if (opts.webFetch !== true) {
list = list.filter((t) => t.name !== 'web_fetch');
}
return list;
}
function isHostWorkspaceTool(name) {
return HOST_WORKSPACE_SET.has(name);
}
module.exports = {
HOST_WORKSPACE_TOOLS,
ALWAYS_RESERVED,
ALWAYS_BUILTIN_RESERVED,
parseHostWorkspace,
filterBuiltinSchemas,
isHostWorkspaceTool,
};
+395
View File
@@ -0,0 +1,395 @@
/**
* Grok-class tools, sandboxed to granted workspace roots.
* Excludes image/video generation.
*/
const path = require('path');
const fs = require('fs');
const sandbox = require('./sandbox.js');
const memory = require('./memory.js');
const toolSet = require('./tool-set.js');
const planMode = require('./plan-mode.js');
const todos = require('./todos.js');
const goalMod = require('./goal.js');
const sr = require('./search-replace.js');
const grepUtil = require('./grep-util.js');
const truncate = require('./truncate.js');
const MAX_READ = 400 * 1024;
const MAX_GREP_HITS = 50;
function readFileSafe(abs, offset, limit) {
const st = fs.statSync(abs);
if (st.isDirectory()) throw new Error('is a directory');
let buf = fs.readFileSync(abs);
if (buf.length > MAX_READ) buf = buf.subarray(0, MAX_READ);
let text = buf.toString('utf8');
const lines = text.split('\n');
const start = Math.max(0, (offset || 1) - 1);
const end = limit ? start + limit : lines.length;
const slice = lines.slice(start, end);
const numbered = slice.map((l, i) => String(start + i + 1).padStart(6) + '| ' + l);
return numbered.join('\n');
}
function listDirSafe(abs, recursive) {
const out = [];
function walk(dir, depth) {
let ents = [];
try {
ents = fs.readdirSync(dir, { withFileTypes: true });
} catch (_) {
return;
}
for (const ent of ents) {
if (out.length >= 500) return;
const child = path.join(dir, ent.name);
out.push({
path: child,
name: ent.name,
isDirectory: typeof ent.isDirectory === 'function' ? ent.isDirectory() : false,
isFile: typeof ent.isFile === 'function' ? ent.isFile() : false,
});
if (recursive && depth < 8 && ent.isDirectory && ent.isDirectory()) walk(child, depth + 1);
}
}
walk(abs, 0);
return out;
}
function grepWalk(abs, re, hits, glob, relBase) {
let ents = [];
try {
ents = fs.readdirSync(abs, { withFileTypes: true });
} catch (_) {
return;
}
for (const ent of ents) {
if (hits.length >= MAX_GREP_HITS) return;
if (ent.name === 'node_modules' || ent.name === '.git') continue;
const child = path.join(abs, ent.name);
const rel = relBase ? relBase + '/' + ent.name : ent.name;
try {
if (ent.isDirectory && ent.isDirectory()) {
grepWalk(child, re, hits, glob, rel);
} else if (ent.isFile && ent.isFile()) {
if (glob && !grepUtil.matchGlob(rel, glob)) continue;
const st = fs.statSync(child);
if (st.size > MAX_READ) continue;
const text = fs.readFileSync(child, 'utf8');
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
if (re.test(lines[i])) {
hits.push({ path: child, line: i + 1, text: lines[i].slice(0, 240) });
if (hits.length >= MAX_GREP_HITS) return;
}
}
}
} catch (_) {}
}
}
async function rgGrep(root, pattern, glob, timeoutMs) {
let spawn;
try {
spawn = require('child_process').spawn;
} catch (_) {
return null;
}
const args = ['-n', '-i', '--no-heading', '--color', 'never', '-m', String(MAX_GREP_HITS)];
if (glob) args.push('--glob', String(glob));
args.push('--', String(pattern), root);
return new Promise((resolve) => {
let proc;
try {
proc = spawn('rg', args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
} catch (_) {
resolve(null);
return;
}
let stdout = '';
let stderr = '';
if (proc.stdout) {
proc.stdout.on('data', (d) => {
stdout += d.toString();
});
}
if (proc.stderr) {
proc.stderr.on('data', (d) => {
stderr += d.toString();
});
}
const t = setTimeout(() => {
try {
proc.kill();
} catch (_) {}
resolve(null);
}, timeoutMs || 15000);
proc.on('exit', (code) => {
clearTimeout(t);
if (code !== 0 && code !== 1) {
resolve(null);
return;
}
const hits = [];
const lines = stdout.split('\n');
for (const line of lines) {
if (!line.trim() || hits.length >= MAX_GREP_HITS) break;
const m = line.match(/^(.*?):(\d+):(.*)$/);
if (!m) continue;
hits.push({ path: m[1], line: Number(m[2]), text: m[3].slice(0, 240) });
}
resolve({ hits, truncated: hits.length >= MAX_GREP_HITS, via: 'rg' });
});
proc.on('error', () => {
clearTimeout(t);
resolve(null);
});
});
}
async function runShell(cwd, command, timeoutMs) {
let spawn;
try {
spawn = require('child_process').spawn;
} catch (_) {
throw new Error('child_process not available');
}
const isWin = process.platform === 'win32';
const cmd = isWin ? 'cmd.exe' : '/bin/sh';
const args = isWin ? ['/c', command] : ['-c', command];
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
if (proc.stdout) proc.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 200000) stdout = stdout.slice(-200000); });
if (proc.stderr) proc.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 80000) stderr = stderr.slice(-80000); });
const t = setTimeout(() => {
try { proc.kill(); } catch (_) {}
reject(new Error('command timed out'));
}, timeoutMs || 30000);
proc.on('exit', (code) => {
clearTimeout(t);
resolve({ exitCode: code, stdout, stderr });
});
proc.on('error', (err) => {
clearTimeout(t);
reject(err);
});
});
}
const SCHEMAS = [
{ type: 'function', name: 'read_file', description: 'Read a text file with line numbers.', parameters: { type: 'object', properties: { path: { type: 'string' }, offset: { type: 'number' }, limit: { type: 'number' } }, required: ['path'] } },
{ type: 'function', name: 'search_replace', description: 'Replace an exact string in a file. old_string must match once unless replace_all is true. Empty old_string creates a new file only if it does not already have content.', parameters: { type: 'object', properties: { path: { type: 'string' }, old_string: { type: 'string' }, new_string: { type: 'string' }, replace_all: { type: 'boolean' } }, required: ['path', 'new_string'] } },
{ type: 'function', name: 'write_file', description: 'Create or overwrite a text file in the workspace. Use search_replace for small edits.', parameters: { type: 'object', properties: { path: { type: 'string' }, contents: { type: 'string' } }, required: ['path', 'contents'] } },
{ type: 'function', name: 'grep', description: 'Search workspace files. Prefer ripgrep when available. output_mode: content | files_with_matches | count.', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' }, glob: { type: 'string' }, output_mode: { type: 'string' } }, required: ['pattern'] } },
{ type: 'function', name: 'list_dir', description: 'List a directory.', parameters: { type: 'object', properties: { path: { type: 'string' }, recursive: { type: 'boolean' } } } },
{ type: 'function', name: 'run_terminal_cmd', description: 'Run a shell command in the workspace cwd.', parameters: { type: 'object', properties: { command: { type: 'string' }, timeout_ms: { type: 'number' } }, required: ['command'] } },
{ type: 'function', name: 'todo_write', description: 'Merge or replace session todos. Status: pending | in_progress | completed | cancelled.', parameters: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'cancelled'] } } } }, merge: { type: 'boolean', description: 'If true (default), merge by id. If false, replace the list.' } }, required: ['todos'] } },
{ type: 'function', name: 'web_search', description: 'Search the public web (DuckDuckGo HTML).', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } },
{ type: 'function', name: 'web_fetch', description: 'Fetch a public http(s) URL as text. Off unless enabled.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } },
{ type: 'function', name: 'memory_search', description: 'Search local agent memory notes.', parameters: { type: 'object', properties: { query: { type: 'string' } } } },
{ type: 'function', name: 'memory_get', description: 'Read a memory note by name.', parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
{ type: 'function', name: 'memory_write', description: 'Write a local agent memory note.', parameters: { type: 'object', properties: { name: { type: 'string' }, text: { type: 'string' } }, required: ['name', 'text'] } },
{ type: 'function', name: 'enter_plan_mode', description: 'Switch to plan mode. Only plan.md is writable until the plan is approved.', parameters: { type: 'object', properties: {} } },
{ type: 'function', name: 'exit_plan_mode', description: 'Present the plan for user approval and exit plan mode if approved.', parameters: { type: 'object', properties: {} } },
{ type: 'function', name: 'update_goal', description: 'Update the active goal. Call with completed true when the objective is met, or blocked_reason if stuck.', parameters: { type: 'object', properties: { notes: { type: 'string' }, completed: { type: 'boolean' }, blocked_reason: { type: 'string' } } } },
{ type: 'function', name: 'ask_user_question', description: 'Ask the user a structured question.', parameters: { type: 'object', properties: { question: { type: 'string' }, options: { type: 'array', items: { type: 'string' } } }, required: ['question'] } },
{ type: 'function', name: 'task', description: 'Spawn a subagent with a focused prompt (same model). subagent_type: explore (read-only) or general (can write). Max 2 concurrent.', parameters: { type: 'object', properties: { prompt: { type: 'string' }, label: { type: 'string' }, subagent_type: { type: 'string' } }, required: ['prompt'] } },
{ type: 'function', name: 'send_subagent_message', description: 'Send a follow-up message to a subagent task.', parameters: { type: 'object', properties: { task_id: { type: 'string' }, message: { type: 'string' } }, required: ['task_id', 'message'] } },
{ type: 'function', name: 'get_task_output', description: 'Get status/output of a subagent task.', parameters: { type: 'object', properties: { task_id: { type: 'string' } }, required: ['task_id'] } },
{ type: 'function', name: 'wait_tasks', description: 'Wait until subagent tasks finish (or timeout).', parameters: { type: 'object', properties: { timeout_ms: { type: 'number' } } } },
{ type: 'function', name: 'kill_task', description: 'Mark a running subagent task as killed.', parameters: { type: 'object', properties: { task_id: { type: 'string' } }, required: ['task_id'] } },
{ type: 'function', name: 'search_tool', description: 'Search registered MCP tools.', parameters: { type: 'object', properties: { query: { type: 'string' } } } },
{ type: 'function', name: 'use_tool', description: 'Invoke an MCP tool by server__name.', parameters: { type: 'object', properties: { name: { type: 'string' }, arguments: { type: 'object' } }, required: ['name'] } },
];
function defs(opts) {
return toolSet.filterBuiltinSchemas(SCHEMAS, opts);
}
async function webSearch(query) {
const net = require('../lib/net.js');
const url = 'https://html.duckduckgo.com/html/?q=' + encodeURIComponent(query);
net.assertPublicHttpUrl(url);
const res = await fetch(url, { headers: { 'user-agent': 'agent-harness/0.1' } });
const text = await res.text();
const hits = [];
const re = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
let m;
while ((m = re.exec(text)) && hits.length < 8) {
hits.push({ url: m[1], title: m[2].replace(/<[^>]+>/g, '').trim() });
}
return hits;
}
async function webFetch(url) {
const net = require('../lib/net.js');
net.assertPublicHttpUrl(url);
const res = await fetch(url, { headers: { 'user-agent': 'agent-harness/0.1' } });
let text = await res.text();
text = truncate.truncateWithMarker(text, 80000);
return { status: res.status, url: String(res.url || url), text };
}
async function execute(ctx, name, args) {
const origin = ctx.origin;
const cwd = ctx.cwd;
args = args || {};
const blocked = planMode.gateWrite(ctx.planTracker || ctx.planMode, name, args);
if (blocked) return blocked;
if (toolSet.isHostWorkspaceTool(name) && ctx.hostWorkspace === false) {
throw new Error('host workspace tools are disabled for this session');
}
switch (name) {
case 'read_file': {
const abs = sandbox.resolvePath(origin, args.path, cwd);
return readFileSafe(abs, args.offset, args.limit);
}
case 'search_replace': {
const abs = sandbox.resolvePath(origin, args.path, cwd);
let cur = '';
let existed = true;
try {
cur = fs.readFileSync(abs, 'utf8');
} catch (_) {
cur = '';
existed = false;
}
const old = args.old_string || args.oldString || '';
const neu = args.new_string != null ? args.new_string : args.newString;
if (neu == null) throw new Error('new_string required');
const applied = sr.applySearchReplace(cur, old, neu, !!(args.replace_all || args.replaceAll));
ensureParent(abs);
fs.writeFileSync(abs, applied.text);
const snippet = sr.contextSnippet(applied.text, neu, 3);
return {
path: abs,
created: applied.created || !existed,
replacements: applied.replacements,
context: snippet,
};
}
case 'write_file': {
const abs = sandbox.resolvePath(origin, args.path, cwd);
const contents = args.contents != null ? String(args.contents) : args.content != null ? String(args.content) : '';
ensureParent(abs);
fs.writeFileSync(abs, contents);
return 'wrote ' + abs + ' (' + contents.length + ' bytes)';
}
case 'grep': {
const root = args.path ? sandbox.resolvePath(origin, args.path, cwd) : cwd;
const glob = args.glob || args.include;
const mode = args.output_mode || args.outputMode || 'content';
const viaRg = await rgGrep(root, args.pattern, glob);
let hits;
let truncated = false;
let via = 'js';
if (viaRg && viaRg.hits) {
hits = viaRg.hits;
truncated = !!viaRg.truncated;
via = 'rg';
} else {
const re = new RegExp(args.pattern, 'i');
hits = [];
grepWalk(root, re, hits, glob, '');
truncated = hits.length >= MAX_GREP_HITS;
}
const formatted = grepUtil.formatHits(hits, mode, truncated);
formatted.via = via;
return formatted;
}
case 'list_dir': {
const abs = sandbox.resolvePath(origin, args.path || '.', cwd);
return listDirSafe(abs, !!args.recursive);
}
case 'run_terminal_cmd': {
if (!sandbox.isAllowed(origin, cwd)) throw new Error('cwd not allowlisted');
if (!sandbox.shellSafe(args.command)) {
throw new Error('command not allowlisted (or contains shell metacharacters)');
}
return runShell(cwd, args.command, args.timeout_ms || args.timeoutMs);
}
case 'todo_write': {
const mode = args.merge === false || args.replace === true ? 'replace' : 'merge';
ctx.session.plan = todos.merge(ctx.session.plan, args.todos || [], mode);
require('./sessions.js').saveSummary(ctx.session);
return { ok: true, todos: ctx.session.plan };
}
case 'web_search':
return webSearch(args.query);
case 'web_fetch':
return webFetch(args.url);
case 'memory_search':
return memory.search(origin, args.query);
case 'memory_get':
return memory.readNote(origin, args.name);
case 'memory_write': {
const file = memory.writeNote(origin, args.name, args.text != null ? args.text : args.content);
return { ok: true, file };
}
case 'enter_plan_mode': {
const tracker = ctx.planTracker || planMode.create(ctx.session && ctx.session.planMode);
planMode.activate(tracker);
ctx.planTracker = tracker;
ctx.planMode = true;
if (ctx.session) {
ctx.session.planMode = planMode.snapshot(tracker);
require('./sessions.js').saveSummary(ctx.session);
}
return { type: 'enter_plan_mode', planMode: planMode.snapshot(tracker) };
}
case 'exit_plan_mode':
return { type: 'exit_plan_mode' };
case 'ask_user_question':
return { type: 'ask_user', question: args.question, options: args.options || [] };
case 'update_goal': {
const g = (ctx.session && ctx.session.goal) || goalMod.create('');
if (args.notes) g.notes = String(args.notes);
if (ctx.session) ctx.session.goal = g;
if (args.blocked_reason) {
g.status = 'blocked';
g.blockedReason = String(args.blocked_reason);
if (ctx.session) require('./sessions.js').saveSummary(ctx.session);
return { type: 'goal_blocked', goal: goalMod.snapshot(g), blocked_reason: g.blockedReason };
}
if (args.completed) {
if (todos.hasOpen(ctx.session && ctx.session.plan)) {
return {
error: 'Goal not complete: todos are still pending or in_progress. Finish or cancel them before update_goal({ completed: true }).',
todos: ctx.session && ctx.session.plan,
};
}
g.status = 'verifying';
if (ctx.session) require('./sessions.js').saveSummary(ctx.session);
return { type: 'goal_completed', goal: goalMod.snapshot(g), verify: g.verify !== false };
}
if (ctx.session) require('./sessions.js').saveSummary(ctx.session);
return { ok: true, goal: goalMod.snapshot(g) };
}
case 'send_subagent_message':
return require('./tasks.js').appendMessage(args.task_id || args.taskId, args.message);
case 'get_task_output':
return require('./tasks.js').get(args.task_id || args.taskId);
case 'wait_tasks':
return require('./tasks.js').waitAll({ timeoutMs: args.timeout_ms || args.timeoutMs });
case 'kill_task':
return require('./tasks.js').kill(args.task_id || args.taskId);
case 'search_tool':
return require('./mcp.js').search(args.query);
case 'use_tool':
return require('./mcp.js').call(args.name, args.arguments || args.args || {});
default:
throw new Error('unknown tool: ' + name);
}
}
function ensureParent(abs) {
const dir = path.dirname(abs);
try {
fs.mkdirSync(dir, { recursive: true });
} catch (_) {}
}
module.exports = { defs, execute, SCHEMAS, HOST_WORKSPACE_TOOLS: toolSet.HOST_WORKSPACE_TOOLS, runShell };
+31
View File
@@ -0,0 +1,31 @@
/**
* Head+tail truncation with a recovery marker. No Bare imports.
*/
const CHAR_PER_TOKEN = 4;
function estimateTokens(s) {
return Math.ceil(String(s || '').length / CHAR_PER_TOKEN);
}
function truncateWithMarker(text, maxChars) {
const s = text == null ? '' : typeof text === 'string' ? text : JSON.stringify(text);
const max = maxChars > 0 ? maxChars : 12000;
if (s.length <= max) return s;
const keep = Math.max(80, Math.floor((max - 80) / 2));
const omitted = s.length - keep * 2;
return (
s.slice(0, keep) +
'\n\n[truncated ' +
omitted +
' chars; use offset/limit or a narrower path to read the middle]\n\n' +
s.slice(-keep)
);
}
function renderToolResult(out, maxChars) {
const raw = typeof out === 'string' ? out : JSON.stringify(out);
return truncateWithMarker(raw, maxChars != null ? maxChars : 12000);
}
module.exports = { CHAR_PER_TOKEN, estimateTokens, truncateWithMarker, renderToolResult };