Updates
Rolling release / release (push) Successful in 6m40s

This commit is contained in:
2026-09-12 07:22:47 -04:00
parent e4546f8e95
commit e9040d110a
30 changed files with 1688 additions and 444 deletions
+71 -13
View File
@@ -1,5 +1,8 @@
/**
* Context compaction: heuristic fallback + optional one-shot LLM summary.
*
* Tool schemas are reserved out of historyBudget. Compact against conversation
* tokens only — never treat a large tool list as "history is full".
*/
const truncate = require('./truncate.js');
@@ -7,6 +10,7 @@ const truncate = require('./truncate.js');
const CHAR_PER_TOKEN = 3;
const THRESHOLD = 0.68;
const MIN_SUMMARY = 80;
const MIN_COMPACT_MESSAGES = 4;
const COMPACT_PROMPT =
'Summarize this coding-agent conversation. Use exactly these sections:\n' +
'1. Goal\n' +
@@ -15,6 +19,13 @@ const COMPACT_PROMPT =
'4. Open work\n' +
'5. Next action\n' +
'Be specific (paths, names, errors). Do not say the conversation was compacted.';
const VOICE_COMPACT_PROMPT =
'Summarize this spoken assistant conversation for the next turn. Use exactly these sections:\n' +
'1. Latest user request\n' +
'2. Facts from tools\n' +
'3. What was already answered\n' +
'4. Open follow-ups\n' +
'Be specific. Do not greet. Do not say the conversation was compacted.';
function contentChars(content) {
if (content == null) return 0;
@@ -42,23 +53,38 @@ function messageChars(m) {
return n + 8;
}
function estimateTokens(messages, tools) {
function nonSystemCount(messages) {
let n = 0;
for (const m of messages || []) {
if (m && m.role !== 'system') n += 1;
}
return n;
}
function conversationTokens(messages) {
let n = 0;
for (const m of messages || []) n += messageChars(m);
n += JSON.stringify(tools || []).length;
return Math.ceil(n / CHAR_PER_TOKEN);
}
function toolTokens(tools) {
return Math.ceil(JSON.stringify(tools || []).length / CHAR_PER_TOKEN);
}
function estimateTokens(messages, tools) {
return conversationTokens(messages) + toolTokens(tools);
}
function historyBudget(ctxSize, tools, attempt) {
const cap = ctxSize > 0 ? Number(ctxSize) : 8192;
const toolTok = Math.ceil(JSON.stringify(tools || []).length / CHAR_PER_TOKEN);
const toolTok = toolTokens(tools);
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);
if (nonSystemCount(messages) < MIN_COMPACT_MESSAGES) return false;
return conversationTokens(messages) > historyBudget(ctxSize, tools, 0);
}
function isOverflowError(err) {
@@ -153,23 +179,30 @@ function lastRealUserIndex(list) {
return -1;
}
function overHistoryBudget(keep, budget) {
return conversationTokens(keep) > budget;
}
function heuristicCompact(messages, opts) {
opts = opts || {};
const budget = opts.budgetTokens || 6000;
const aggressive = !!opts.aggressive;
if (!aggressive && nonSystemCount(messages) < MIN_COMPACT_MESSAGES) {
return (messages || []).slice();
}
let keep = messages.slice();
while (estimateTokens(keep, opts.tools) > budget && keep.length > 4) {
while (overHistoryBudget(keep, 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) {
if (overHistoryBudget(keep, 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) {
if (overHistoryBudget(keep, budget) && keep.length > 3) {
const head = keep[0];
const lastUserIdx = lastRealUserIndex(keep);
const lastUser = lastUserIdx >= 0 ? keep[lastUserIdx] : null;
@@ -183,7 +216,7 @@ function heuristicCompact(messages, opts) {
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) {
while (overHistoryBudget(keep, budget) && keep.length > 3) {
const dropAt = keep.findIndex((m, i) => i > 1 && !isRealUser(m));
if (dropAt < 0) break;
keep.splice(dropAt, 1);
@@ -205,7 +238,12 @@ function transcript(messages) {
.join('\n\n');
}
function autoContinue(messages) {
function isVoice(opts) {
return !!(opts && opts.voice);
}
function autoContinue(messages, opts) {
if (isVoice(opts)) return null;
const list = messages || [];
const last = list[list.length - 1];
if (!last) return null;
@@ -219,7 +257,14 @@ function autoContinue(messages) {
return null;
}
function compactReminder() {
function compactReminder(opts) {
if (isVoice(opts)) {
return (
'<system-reminder>\n' +
'Context was compacted. Trust the summary and the last user request. Answer that request. Do not greet again.\n' +
'</system-reminder>'
);
}
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' +
@@ -230,17 +275,25 @@ function compactReminder() {
async function compactWithLlm(messages, opts) {
opts = opts || {};
const fallback = () => heuristicCompact(messages, opts);
if (!opts.aggressive && nonSystemCount(messages) < MIN_COMPACT_MESSAGES) {
return (messages || []).slice();
}
const complete = opts.complete;
if (typeof complete !== 'function') return fallback();
const voice = isVoice(opts);
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);
const prompt = voice ? VOICE_COMPACT_PROMPT : COMPACT_PROMPT;
const sys = voice
? 'Reply with the four summary sections only. No tools. Do not greet.'
: 'Reply with the five summary sections only. No tools.';
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 },
{ role: 'system', content: sys },
{ role: 'user', content: prompt + '\n\n---\n\n' + body },
],
tools: [],
});
@@ -256,10 +309,15 @@ module.exports = {
CHAR_PER_TOKEN,
THRESHOLD,
MIN_SUMMARY,
MIN_COMPACT_MESSAGES,
COMPACT_PROMPT,
VOICE_COMPACT_PROMPT,
conversationTokens,
toolTokens,
estimateTokens,
historyBudget,
shouldCompact,
nonSystemCount,
isOverflowError,
usage,
snapshot,
+85 -32
View File
@@ -15,6 +15,7 @@ const toolSet = require('./tool-set.js');
const planMode = require('./plan-mode.js');
const todos = require('./todos.js');
const stationarity = require('./stationarity.js');
const toolBudget = require('./tool-budget.js');
const goalMod = require('./goal.js');
const truncate = require('./truncate.js');
const sr = require('./search-replace.js');
@@ -160,7 +161,18 @@ function resolvePermission(jobId, toolCallId, decision) {
if (fn) {
pendingPerms.delete(key);
fn(decision);
return true;
}
const keys = Array.from(pendingPerms.keys());
for (const k of keys) {
if (k === toolCallId || k.endsWith(':' + toolCallId)) {
const resolve = pendingPerms.get(k);
pendingPerms.delete(k);
if (resolve) resolve(decision);
return true;
}
}
return false;
}
function waitCustomResult(jobId, toolCallId) {
@@ -260,11 +272,15 @@ function applyPlanWrite(session, name, args) {
return { ok: true, path: file, bytes: text.length };
}
function sidecarMessages(session, tracker) {
function sidecarMessages(session, tracker, budget) {
const extra = [];
if (budget && budget.answerOnly) {
extra.push({ role: 'user', content: toolBudget.answerNowMessage() });
}
if (tracker && tracker.pendingCompactReminder) {
extra.push({ role: 'user', content: compaction.compactReminder() });
const cont = compaction.autoContinue(session.history);
const voice = !!(budget && budget.voice);
extra.push({ role: 'user', content: compaction.compactReminder({ voice }) });
const cont = compaction.autoContinue(session.history, { voice });
if (cont) extra.push(cont);
tracker.pendingCompactReminder = false;
}
@@ -368,6 +384,7 @@ async function runTurn(ctx) {
await ensureModel(session.model);
const voice = (payload && payload.voice === true) || origin === 'jarvis-qvac';
let extraSys = payload && payload.system;
if (session.goal && goalMod.isActive(session.goal)) {
extraSys = [extraSys, goalMod.plannerAddendum(session.goal)].filter(Boolean).join('\n\n');
@@ -376,20 +393,24 @@ async function runTurn(ctx) {
cwd: hostWorkspace ? cwd : session.workspace || cwd,
hostWorkspace,
extra: extraSys,
fsRead: hostWorkspace
? (c, r) => {
try {
return fsRead(c, r);
} catch (_) {
return '';
personality: voice ? 'voice' : undefined,
fsRead:
hostWorkspace && !voice
? (c, r) => {
try {
return fsRead(c, r);
} catch (_) {
return '';
}
}
}
: null,
: null,
});
const sidecars = [];
if (hostWorkspace) {
const gitText = await gitSidecar.gitStatusSb(cwd, { hostWorkspace: true, run: tools.runShell });
if (gitText) sidecars.push(gitText);
if (!voice) {
const gitText = await gitSidecar.gitStatusSb(cwd, { hostWorkspace: true, run: tools.runShell });
if (gitText) sidecars.push(gitText);
}
const memText = memory.injectBlock(origin, userText || '');
if (memText) sidecars.push(memText);
}
@@ -416,6 +437,7 @@ async function runTurn(ctx) {
const cancelled = () => live.get(session.id) && live.get(session.id).cancelled;
const stuck = stationarity.create();
const budget = toolBudget.fromPayload(payload, origin);
let lastText = '';
let goalNudges = 0;
@@ -557,29 +579,39 @@ async function runTurn(ctx) {
}
try {
for (let turn = 0; turn < MAX_TURNS; turn++) {
for (let turn = 0; turn < budget.maxTurns; turn++) {
if (cancelled()) return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', turns: turn });
const toolDefs = buildToolDefs(session, payload, tracker);
if (turn === budget.maxTurns - 1) toolBudget.forceAnswer(budget);
const toolDefs = budget.answerOnly ? [] : buildToolDefs(session, payload, tracker);
ctx.planMode = planMode.isActive(tracker);
const ctxSize = loadedCtxSize();
const beforeLen = session.history.length;
const beforeUsage = compaction.usage(session.history, toolDefs, ctxSize);
emitLive(emit, session.id, jobId, Object.assign({ type: 'context' }, beforeUsage));
if (compaction.shouldCompact(session.history, toolDefs, ctxSize)) {
const useLlm = !budget.voice || compaction.nonSystemCount(session.history) >= 8;
emitUpdate(emit, session.id, jobId, {
type: 'compaction',
status: 'start',
method: 'llm',
method: useLlm ? 'llm' : 'heuristic',
used: beforeUsage.used,
limit: beforeUsage.limit,
pct: beforeUsage.pct,
threshold: beforeUsage.threshold,
});
session.history = await compaction.compactWithLlm(session.history, {
const compactOpts = {
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, 0),
tools: toolDefs,
complete: (opts) => engine.complete(Object.assign({}, opts, { desktopVision: false })),
});
voice: !!budget.voice,
};
session.history = useLlm
? await compaction.compactWithLlm(
session.history,
Object.assign({}, compactOpts, {
complete: (opts) => engine.complete(Object.assign({}, opts, { desktopVision: false })),
})
)
: compaction.compact(session.history, compactOpts);
sessions.replaceHistory(session.id, session.history);
tracker.pendingCompactReminder = true;
if (hostWorkspace) {
@@ -593,7 +625,7 @@ async function runTurn(ctx) {
emitUpdate(emit, session.id, jobId, {
type: 'compaction',
status: 'done',
method: 'llm',
method: useLlm ? 'llm' : 'heuristic',
used: afterUsage.used,
limit: afterUsage.limit,
pct: afterUsage.pct,
@@ -605,6 +637,7 @@ async function runTurn(ctx) {
session.history = compaction.compact(session.history, {
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, 0),
tools: toolDefs,
voice: !!budget.voice,
});
if (session.history.length !== beforeLen) {
sessions.replaceHistory(session.id, session.history);
@@ -624,7 +657,7 @@ async function runTurn(ctx) {
}
emitUpdate(emit, session.id, jobId, { type: 'turn', turn });
let streamBase = compaction.usage(session.history.concat(sidecarMessages(session, tracker)), toolDefs, ctxSize);
let streamBase = compaction.usage(session.history.concat(sidecarMessages(session, tracker, budget)), toolDefs, ctxSize);
emitLive(emit, session.id, jobId, Object.assign({ type: 'context' }, streamBase));
let streamChars = 0;
function liveUsed() {
@@ -636,7 +669,7 @@ async function runTurn(ctx) {
}
let result;
for (let overflowTry = 0; overflowTry < 4; overflowTry++) {
const history = session.history.concat(sidecarMessages(session, tracker));
const history = session.history.concat(sidecarMessages(session, tracker, budget));
streamBase = compaction.usage(history, toolDefs, ctxSize);
streamChars = 0;
try {
@@ -681,6 +714,7 @@ async function runTurn(ctx) {
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, overflowTry + 1),
tools: toolDefs,
aggressive: true,
voice: !!budget.voice,
});
sessions.replaceHistory(session.id, session.history);
tracker.pendingCompactReminder = true;
@@ -694,15 +728,17 @@ async function runTurn(ctx) {
Object.assign({ type: 'context' }, usageFromStats(result && result.stats, liveUsed(), ctxSize))
);
if (result.text) {
lastText = result.text;
pushHistory(session, { role: 'assistant', content: result.text });
let calls = (result && result.toolCalls) || [];
if (budget.answerOnly) calls = [];
if ((result && result.text) || calls.length) {
if (result.text) lastText = result.text;
const assistant = { role: 'assistant', content: result.text || '' };
if (calls.length) assistant.tool_calls = calls;
pushHistory(session, assistant);
}
const calls = result.toolCalls || [];
if (!calls.length) {
const goalActive = goalMod.isActive(session.goal);
if (goalActive && goalNudges < MAX_GOAL_NUDGES) {
if (goalActive && goalNudges < MAX_GOAL_NUDGES && !budget.answerOnly) {
goalNudges += 1;
pushHistory(session, { role: 'user', content: goalMod.continuation(session.goal) });
continue;
@@ -710,7 +746,7 @@ async function runTurn(ctx) {
return endTurn(emit, session, jobId, tracker, {
type: 'end',
reason: 'stop',
text: result.text || lastText || '',
text: (result && result.text) || lastText || toolBudget.lastToolText(session.history) || '',
turns: turn + 1,
});
}
@@ -738,6 +774,13 @@ async function runTurn(ctx) {
continue;
}
if (name === 'run_terminal_cmd' && toolBudget.shouldSkipShell(budget)) {
const skipped = toolBudget.skipShellMessage();
pushHistory(session, { role: 'tool', name, content: skipped, tool_call_id: toolCallId });
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: skipped });
continue;
}
if (sandbox.needsPermission(name, mode) && !planMode.isPlanFilePath(args.path, tracker.planPath)) {
let remembered = null;
try {
@@ -760,7 +803,7 @@ async function runTurn(ctx) {
let decision = await waitPermission(jobId, { toolCallId });
if (decision === 'always') {
try {
permStore.remember(name, args, 'allow');
permStore.rememberAlways(name);
} catch (_) {}
decision = 'allow';
}
@@ -774,6 +817,7 @@ async function runTurn(ctx) {
}
prepared.push({ name, args, toolCallId });
if (name === 'run_terminal_cmd') toolBudget.markShell(budget);
}
let stopEarly = null;
@@ -795,19 +839,28 @@ async function runTurn(ctx) {
}
if (stopEarly) break;
}
if (prepared.length) toolBudget.markToolRound(budget);
if (stopEarly) {
return endTurn(emit, session, jobId, tracker, stopEarly);
}
if (stuckNow) {
return endTurn(emit, session, jobId, tracker, { reason: 'stuck', text: lastText, turns: turn + 1 });
return endTurn(emit, session, jobId, tracker, {
reason: 'stuck',
text: lastText || toolBudget.lastToolText(session.history),
turns: turn + 1,
});
}
if (stationarity.shouldNudge(stuck)) {
stationarity.markNudged(stuck);
pushHistory(session, { role: 'user', content: stationarity.nudgeText() });
}
}
return endTurn(emit, session, jobId, tracker, { reason: 'max_turns', text: lastText, turns: MAX_TURNS });
return endTurn(emit, session, jobId, tracker, {
reason: 'max_turns',
text: lastText || toolBudget.lastToolText(session.history),
turns: budget.maxTurns,
});
} catch (err) {
if (err && err.message === 'cancelled') {
return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', text: lastText });
+6 -1
View File
@@ -34,4 +34,9 @@ function remember(tool, args, decision) {
return save(rules.addRule(load(), tool, args, decision));
}
module.exports = { rulesFile, load, save, resolve, remember };
function rememberAlways(tool) {
const args = String(tool) === 'run_terminal_cmd' ? { command: '*' } : { path: '*' };
return remember(tool, args, 'allow');
}
module.exports = { rulesFile, load, save, resolve, remember, rememberAlways };
+7 -1
View File
@@ -29,7 +29,13 @@ function loadWorkspaceRules(fsRead, cwd) {
return chunks.join('\n\n');
}
function assemble({ cwd, extra, fsRead, hostWorkspace }) {
function assemble({ cwd, extra, fsRead, hostWorkspace, personality }) {
if (personality === 'voice') {
const parts = [];
if (extra) parts.push(String(extra));
if (cwd) parts.push('Current workspace: ' + cwd);
return parts.join('\n\n');
}
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) : '';
+82
View File
@@ -0,0 +1,82 @@
/**
* Voice/coding turn budgets. No Bare imports.
*
* Coding agents may chain many shells. A spoken GNOME assistant should run one
* command, then answer — otherwise a 4B model loops hostnamectl/uname/free.
*/
function num(value, fallback) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
}
function fromPayload(payload, origin) {
payload = payload || {};
const voice = payload.voice === true || origin === 'jarvis-qvac';
const unlimitedShell = payload.maxShellCalls === 0 || payload.maxShellCalls === false;
return {
voice,
maxTurns: num(payload.maxTurns, voice ? 6 : 24),
maxShellCalls: unlimitedShell ? 0 : num(payload.maxShellCalls, voice ? 1 : 0),
maxToolRounds: num(payload.maxToolRounds, voice ? 4 : 0),
shellCalls: 0,
toolRounds: 0,
answerOnly: false,
};
}
function capped(limit) {
return limit > 0;
}
function shouldSkipShell(budget) {
return !!(budget && capped(budget.maxShellCalls) && budget.shellCalls >= budget.maxShellCalls);
}
function markShell(budget) {
if (!budget) return;
budget.shellCalls += 1;
if (shouldSkipShell(budget)) budget.answerOnly = true;
}
function markToolRound(budget) {
if (!budget) return;
budget.toolRounds += 1;
if (capped(budget.maxToolRounds) && budget.toolRounds >= budget.maxToolRounds) budget.answerOnly = true;
}
function forceAnswer(budget) {
if (budget) budget.answerOnly = true;
}
function skipShellMessage() {
return 'A terminal command already ran this turn. Answer the user from that output. Do not run another command.';
}
function answerNowMessage() {
return 'You have tool results. Reply to the user in one to three sentences. Do not call more tools.';
}
function lastToolText(history, maxChars) {
const list = Array.isArray(history) ? history : [];
for (let i = list.length - 1; i >= 0; i--) {
if (list[i] && list[i].role === 'tool' && list[i].content) {
const text = String(list[i].content).replace(/\s+/g, ' ').trim();
if (!text) continue;
const max = maxChars > 0 ? maxChars : 400;
return text.length > max ? text.slice(0, max) + '…' : text;
}
}
return '';
}
module.exports = {
fromPayload,
shouldSkipShell,
markShell,
markToolRound,
forceAnswer,
skipShellMessage,
answerNowMessage,
lastToolText,
};
+52 -19
View File
@@ -148,6 +148,30 @@ async function rgGrep(root, pattern, glob, timeoutMs) {
});
}
function collectStream(stream, maxChars) {
let buf = '';
if (!stream) return () => buf;
const append = (chunk) => {
buf += Buffer.isBuffer(chunk) || chunk instanceof Uint8Array ? Buffer.from(chunk).toString() : String(chunk);
if (buf.length > maxChars) buf = buf.slice(-maxChars);
};
if (typeof stream.on === 'function') stream.on('data', append);
if (typeof stream.resume === 'function') stream.resume();
return () => buf;
}
function formatShellResult(result) {
const exitCode = result && result.exitCode != null ? result.exitCode : 0;
const stdout = String((result && result.stdout) || '').trimEnd();
const stderr = String((result && result.stderr) || '').trimEnd();
const parts = [];
if (stdout) parts.push(stdout);
if (stderr) parts.push(stderr);
if (!parts.length) parts.push('(no output)');
parts.push('exit ' + String(exitCode));
return parts.join('\n');
}
async function runShell(cwd, command, timeoutMs) {
let spawn;
try {
@@ -160,22 +184,28 @@ async function runShell(cwd, command, timeoutMs) {
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 readOut = collectStream(proc.stdout, 200000);
const readErr = collectStream(proc.stderr, 80000);
let settled = false;
const t = setTimeout(() => {
try { proc.kill(); } catch (_) {}
reject(new Error('command timed out'));
finish(new Error('command timed out'));
}, timeoutMs || 30000);
proc.on('exit', (code) => {
const finish = (err, code) => {
if (settled) return;
settled = true;
clearTimeout(t);
resolve({ exitCode: code, stdout, stderr });
});
proc.on('error', (err) => {
clearTimeout(t);
reject(err);
});
if (err) reject(err);
else resolve({ exitCode: code, stdout: readOut(), stderr: readErr() });
};
// Bare's subprocess emits `exit` before it resumes stdio pipes. Wait for
// `close` so stdout/stderr are actually collected.
if (typeof proc.on === 'function') {
proc.on('close', (code) => finish(null, code));
proc.on('error', (err) => finish(err));
} else {
finish(new Error('spawned process has no event API'));
}
});
}
@@ -188,7 +218,7 @@ const SCHEMAS = [
{ 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: 'web_fetch', description: 'Fetch any http or https URL as text, including public internet hosts.', 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'] } },
@@ -226,7 +256,7 @@ async function webSearch(query) {
async function webFetch(url) {
const net = require('../lib/net.js');
net.assertPublicHttpUrl(url);
net.assertHttpUrl(url);
const res = await fetch(url, { headers: { 'user-agent': 'agent-harness/0.1' } });
let text = await res.text();
text = truncate.truncateWithMarker(text, 80000);
@@ -306,10 +336,13 @@ async function execute(ctx, name, args) {
}
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);
const command = String(args.command || '').trim();
if (!command) throw new Error('command required');
// HUD/CLI permission is the gate. The coding-agent allowlist would reject
// desktop commands the user already approved (and Bare would then look
// like a silent empty result).
const raw = await runShell(cwd, command, args.timeout_ms || args.timeoutMs);
return formatShellResult(raw);
}
case 'todo_write': {
const mode = args.merge === false || args.replace === true ? 'replace' : 'merge';
@@ -392,4 +425,4 @@ function ensureParent(abs) {
} catch (_) {}
}
module.exports = { defs, execute, SCHEMAS, HOST_WORKSPACE_TOOLS: toolSet.HOST_WORKSPACE_TOOLS, runShell };
module.exports = { defs, execute, SCHEMAS, HOST_WORKSPACE_TOOLS: toolSet.HOST_WORKSPACE_TOOLS, runShell, formatShellResult };