Actually vendor harness
This commit is contained in:
+273
@@ -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
@@ -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
@@ -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 };
|
||||
Vendored
+110
@@ -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
@@ -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 };
|
||||
Vendored
+1076
File diff suppressed because it is too large
Load Diff
+50
@@ -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 };
|
||||
Vendored
+153
@@ -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
@@ -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 };
|
||||
Vendored
+63
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
Vendored
+68
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
Vendored
+148
@@ -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,
|
||||
};
|
||||
Vendored
+57
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
Vendored
+395
@@ -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
@@ -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 };
|
||||
Reference in New Issue
Block a user