+71
-13
@@ -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,
|
||||
|
||||
Vendored
+85
-32
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
Vendored
+52
-19
@@ -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 };
|
||||
|
||||
Vendored
+4
@@ -40,6 +40,10 @@ function wrapSession(summary, opts) {
|
||||
permissionMode: opts.permissionMode || 'ask',
|
||||
webFetch: opts.webFetch === true,
|
||||
system: opts.system,
|
||||
maxTurns: opts.maxTurns,
|
||||
maxShellCalls: opts.maxShellCalls,
|
||||
maxToolRounds: opts.maxToolRounds,
|
||||
voice: opts.voice,
|
||||
},
|
||||
payload || {}
|
||||
);
|
||||
|
||||
Vendored
+7
-2
@@ -32,7 +32,7 @@ function isBlockedHostname(hostname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function assertPublicHttpUrl(raw) {
|
||||
function assertHttpUrl(raw) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(raw));
|
||||
@@ -42,10 +42,15 @@ function assertPublicHttpUrl(raw) {
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw new Error('only http(s) URLs are allowed');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function assertPublicHttpUrl(raw) {
|
||||
const url = assertHttpUrl(raw);
|
||||
if (isBlockedHostname(url.hostname)) {
|
||||
throw new Error('private, loopback, and metadata hosts are blocked');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
module.exports = { isBlockedHostname, assertPublicHttpUrl };
|
||||
module.exports = { isBlockedHostname, assertHttpUrl, assertPublicHttpUrl };
|
||||
|
||||
Vendored
+74
-1
@@ -17,6 +17,7 @@ const stationarity = require('../agent/stationarity.js');
|
||||
const truncate = require('../agent/truncate.js');
|
||||
const permRules = require('../agent/perm-rules.js');
|
||||
const policy = require('../agent/policy.js');
|
||||
const toolBudget = require('../agent/tool-budget.js');
|
||||
const paths = require('../lib/paths.js');
|
||||
const device = require('../lib/device.js');
|
||||
|
||||
@@ -35,6 +36,14 @@ function testCatalog() {
|
||||
assert.ok(listed && listed.label.indexOf('~3.5 GB') >= 0);
|
||||
}
|
||||
|
||||
function hugeToolDefs() {
|
||||
return Array.from({ length: 24 }, (_, i) => ({
|
||||
name: 'tool_' + i,
|
||||
description: 'd'.repeat(400),
|
||||
parameters: { type: 'object', properties: { q: { type: 'string' } } },
|
||||
}));
|
||||
}
|
||||
|
||||
function testCompaction() {
|
||||
const sys = { role: 'system', content: 'sys' };
|
||||
const user = { role: 'user', content: 'hello' };
|
||||
@@ -48,6 +57,39 @@ function testCompaction() {
|
||||
assert.ok(out.length < hist.length);
|
||||
assert.strictEqual(out[0].role, 'system');
|
||||
assert.ok(compaction.isOverflowError(new Error('prompt too long for context window')));
|
||||
|
||||
const tools = hugeToolDefs();
|
||||
const short = [
|
||||
{ role: 'system', content: 'You are Jarvis' },
|
||||
{ role: 'assistant', content: 'Hello! How can I help you today?' },
|
||||
{ role: 'user', content: 'Hi, please tell me about my computer.' },
|
||||
];
|
||||
assert.ok(compaction.toolTokens(tools) > 2000);
|
||||
assert.equal(compaction.shouldCompact(short, tools, 8192), false);
|
||||
const kept = compaction.compact(short, {
|
||||
budgetTokens: compaction.historyBudget(8192, tools, 0),
|
||||
tools,
|
||||
});
|
||||
assert.strictEqual(kept.length, short.length);
|
||||
assert.strictEqual(kept[2].content, short[2].content);
|
||||
assert.ok(JSON.stringify(kept).indexOf('Earlier turns were compacted') < 0);
|
||||
|
||||
const four = short.concat([
|
||||
{ role: 'assistant', content: 'Let me look.' },
|
||||
{ role: 'user', content: 'Go ahead.' },
|
||||
]);
|
||||
assert.ok(compaction.nonSystemCount(four) >= compaction.MIN_COMPACT_MESSAGES);
|
||||
const stillKept = compaction.heuristicCompact(four, {
|
||||
budgetTokens: compaction.historyBudget(8192, tools, 0),
|
||||
tools,
|
||||
});
|
||||
assert.ok(stillKept.some((m) => String(m.content).indexOf('tell me about my computer') >= 0));
|
||||
assert.ok(JSON.stringify(stillKept).indexOf('Earlier turns were compacted') < 0);
|
||||
|
||||
assert.strictEqual(compaction.autoContinue([{ role: 'assistant', content: 'hi' }], { voice: true }), null);
|
||||
assert.ok(compaction.autoContinue([{ role: 'assistant', content: 'hi' }]));
|
||||
assert.ok(compaction.compactReminder({ voice: true }).indexOf('Do not greet') >= 0);
|
||||
assert.ok(compaction.VOICE_COMPACT_PROMPT.indexOf('Do not greet') >= 0);
|
||||
}
|
||||
|
||||
function testSearchReplace() {
|
||||
@@ -66,7 +108,17 @@ function testToolSet() {
|
||||
];
|
||||
const page = toolSet.filterBuiltinSchemas(all, { hostWorkspace: false, builtinTools: ['todo_write'] });
|
||||
assert.ok(!page.find((t) => t.name === 'read_file'));
|
||||
assert.ok(page.find((t) => t.name === 'todo_write'));
|
||||
const withFetch = toolSet.filterBuiltinSchemas(all, {
|
||||
hostWorkspace: true,
|
||||
builtinTools: ['web_fetch'],
|
||||
webFetch: true,
|
||||
});
|
||||
assert.ok(withFetch.find((t) => t.name === 'web_fetch'));
|
||||
const noFetch = toolSet.filterBuiltinSchemas(all, {
|
||||
hostWorkspace: true,
|
||||
builtinTools: ['web_fetch'],
|
||||
});
|
||||
assert.ok(!noFetch.find((t) => t.name === 'web_fetch'));
|
||||
}
|
||||
|
||||
function testPrompts() {
|
||||
@@ -74,6 +126,18 @@ function testPrompts() {
|
||||
assert.ok(prompts.DEFAULT_SYSTEM.indexOf('BridgeSwarm') < 0);
|
||||
const page = prompts.assemble({ cwd: 'container', hostWorkspace: false });
|
||||
assert.ok(page.indexOf('host filesystem') >= 0);
|
||||
const voice = prompts.assemble({
|
||||
personality: 'voice',
|
||||
extra: 'You are Jarvis, a local Ubuntu GNOME voice assistant.',
|
||||
cwd: '/tmp/jarvis',
|
||||
hostWorkspace: true,
|
||||
fsRead: () => '# AGENTS.md\nFollow coding-agent rules.',
|
||||
});
|
||||
assert.ok(voice.indexOf('You are Jarvis') >= 0);
|
||||
assert.ok(voice.indexOf(prompts.DEFAULT_SYSTEM) < 0);
|
||||
assert.ok(voice.indexOf('coding agent') < 0);
|
||||
assert.ok(voice.indexOf('AGENTS.md') < 0);
|
||||
assert.ok(voice.indexOf('Current workspace:') >= 0);
|
||||
}
|
||||
|
||||
function testNet() {
|
||||
@@ -81,6 +145,9 @@ function testNet() {
|
||||
assert.throws(() => net.assertPublicHttpUrl('http://192.168.1.1/x'));
|
||||
const u = net.assertPublicHttpUrl('https://example.com/a');
|
||||
assert.strictEqual(u.hostname, 'example.com');
|
||||
assert.strictEqual(net.assertHttpUrl('https://ifconfig.me/ip').hostname, 'ifconfig.me');
|
||||
assert.strictEqual(net.assertHttpUrl('http://192.168.1.1/status').hostname, '192.168.1.1');
|
||||
assert.throws(() => net.assertHttpUrl('file:///etc/passwd'));
|
||||
}
|
||||
|
||||
function testCustomTools() {
|
||||
@@ -124,6 +191,12 @@ function testTruncateAndPerm() {
|
||||
assert.strictEqual(pat, 'git status');
|
||||
assert.ok(policy.shellSafe('git status'));
|
||||
assert.ok(!policy.shellSafe('rm -rf /'));
|
||||
assert.ok(permRules.globish('uname -a', '*'));
|
||||
assert.strictEqual(permRules.patternFromArgs('run_terminal_cmd', { command: '*' }), '*');
|
||||
const voice = toolBudget.fromPayload({}, 'jarvis-qvac');
|
||||
toolBudget.markShell(voice);
|
||||
assert.strictEqual(voice.answerOnly, true);
|
||||
assert.ok(toolBudget.shouldSkipShell(voice));
|
||||
}
|
||||
|
||||
function testPaths() {
|
||||
|
||||
Reference in New Issue
Block a user