Updates to Agent QVAC
CI / Build & Test (push) Successful in 28m18s

This commit is contained in:
2026-09-03 16:06:16 -04:00
parent 4fffdce61c
commit 9a5cbbe1ff
19 changed files with 1492 additions and 172 deletions
+49 -5
View File
@@ -10,8 +10,11 @@ const session = await BridgeSwarm.agent.create({
cwd: 'default',
permissionMode: 'ask' // ask | allowlist | always-approve
})
session.on((ev) => { /* agent_message_chunk | tool_call | permission | end */ })
await session.prompt('List the workspace and summarize')
session.on((ev) => { /* agent_message_chunk | tool_call | permission | ask_user | plan_approval | end */ })
await session.prompt('List the workspace and summarize', {
planMode: false,
permissionMode: 'ask',
})
await session.cancel()
```
@@ -21,7 +24,7 @@ Always-approve is only honored for origins listed under **Settings → Agent alw
Must-have: `read_file`, `write_file`, `search_replace`, `grep`, `list_dir`, `run_terminal_cmd`, `todo_write`
Also: `task` / `send_subagent_message` / `get_task_output` / `wait_tasks` / `kill_task` (subagents share the loaded model), `web_search`, `web_fetch` (opt-in), `memory_search` / `memory_get`, `enter_plan_mode` / `exit_plan_mode`, `ask_user_question`, `search_tool` / `use_tool` (MCP HTTP only; stdio requires a trusted origin and is not spawned by default)
Also: `task` / `send_subagent_message` / `get_task_output` / `wait_tasks` / `kill_task` (subagents share the loaded model; `wait_tasks` actually waits), `web_search`, `web_fetch` (opt-in), `memory_search` / `memory_get` / `memory_write`, `enter_plan_mode` / `exit_plan_mode`, `ask_user_question`, `update_goal`, `search_tool` / `use_tool` (MCP HTTP only; stdio requires a trusted origin and is not spawned by default)
Shell is cwd-jailed via `bare-subprocess` (host-internal, not a page pack). MCP HTTP tools must be public URLs (`net` policy).
@@ -97,10 +100,51 @@ Default cwd: `$BRIDGE_SWARM_STORAGE/agent/<origin-hash>/`. Extra absolute roots:
## Sessions
JSONL under `$BRIDGE_SWARM_STORAGE/agent/sessions/`. History is compacted near 80% of the model context.
JSONL under `$BRIDGE_SWARM_STORAGE/agent/sessions/`. History is compacted near 80% of the model context; compacted history is rewritten so a reload does not restore the uncompacted log. `plan.md`, plan-mode snapshot, and goal status live on the session summary.
## ACP-shaped events
Host emits `cap-chunk` with `pack: 'agent'` and `type` in `agent_message_chunk`, `agent_thought_chunk`, `tool_call`, `tool_result`, `permission`, `ask_user`, `end`.
Host emits `cap-chunk` with `pack: 'agent'` and `type` in `agent_message_chunk`, `agent_thought_chunk`, `tool_call`, `tool_result`, `permission`, `ask_user`, `plan_approval`, `plan_update`, `goal_update`, `end`.
`end.reason` is one of `stop` | `max_turns` | `stuck` | `cancelled` | `goal_complete` | `goal_blocked`.
## Plan mode
`enter_plan_mode` / `session.prompt(text, { planMode: true })` switches the session to plan mode. While active:
- `run_terminal_cmd` is not offered; `write_file` / `search_replace` may only edit the session `plan.md` (host session dir, streamed as `plan_update`).
- Other writes are rejected at execute time, including page-registered `write_file` / `search_replace` / `run_terminal_cmd`.
- The model is reminded to end the turn with `ask_user_question` or `exit_plan_mode`.
`exit_plan_mode` emits `plan_approval` and **blocks** until the page calls `session.planDecision('approve' | 'reject')`. Approve injects “plan approved, implement it”; reject stays in plan mode.
```js
session.on((ev) => {
if (ev.type === 'plan_approval') {
session.planDecision('approve')
}
})
await session.prompt('Plan a README then implement it', { planMode: true })
```
## Ask the user
`ask_user_question` emits `ask_user` and **waits**. Reply with `session.answer(toolCallId, choice)`.
```js
session.on((ev) => {
if (ev.type === 'ask_user') session.answer(ev.toolCallId, ev.options[0])
})
```
## Goals
Done is **not** the model stopping tool calls. Start a goal with `create({ goal: '…' })` or `prompt(text, { goal: '…' })`. The agent must `update_goal({ completed: true })` after todos are finished. Open todos reject that call (premature stop). A single verifier `complete` then either ends with `reason: 'goal_complete'` or injects gaps and continues. `update_goal({ blocked_reason })` ends with `goal_blocked`. Pass `{ verify: false }` to skip the verifier.
If a goal is active and the model stops with open work, the loop injects a continuation instead of `end stop`.
## Prompt options
`session.prompt(text, { planMode, permissionMode, system, webFetch, goal, verify })` forwards those fields to the host.
Models, devices, enable toggle, and `BridgeSwarm.qvac` chat (page-owned tools, no workspace shell) are documented in [QVAC.md](QVAC.md).
+1 -1
View File
@@ -173,7 +173,7 @@ Full details and examples: [DATA-API.md](DATA-API.md).
| `sqlite.open` / `close` / `list` / `exec` / `query` / `get` / `run` | db under `sqlite/` | see [CAPABILITIES.md](CAPABILITIES.md) |
| `net.fetch` | `{ url, method?, as? }` | public http(s) only |
| `qvac.detect` / `status` / `resources` / `catalog` / `load` / `complete` / `chat` / `embed` / … | see [QVAC.md](QVAC.md) | origin-gated; off until Settings → Enable QVAC; streams `cap-chunk` |
| `agent.create` / `prompt` / `cancel` / `permission` / `mcpRegister` | see [AGENT.md](AGENT.md) | origin-gated; host workspace tools unless `hostWorkspace: false` |
| `agent.create` / `prompt` / `cancel` / `permission` / `planDecision` / `answer` / `mcpRegister` | see [AGENT.md](AGENT.md) | origin-gated; host workspace tools unless `hostWorkspace: false` |
### Capability events
+1 -1
View File
@@ -15,4 +15,4 @@ await session.addTool({
await session.prompt('Create hello.js then call page_time')
```
This demo registers `page_time` on every new session. Built-in workspace tools stay on (`hostWorkspace` defaults to `true`). For a page- or container-owned workspace, pass `hostWorkspace: false` and register your own `read_file` / `run_terminal_cmd` handlers — see [AGENT.md](../../docs/AGENT.md).
This demo registers `page_time` on every new session. Permission prompts, plan Approve/Reject, and in-thread `ask_user_question` choices appear in the transcript. Built-in workspace tools stay on (`hostWorkspace` defaults to `true`). For a page- or container-owned workspace, pass `hostWorkspace: false` and register your own `read_file` / `run_terminal_cmd` handlers — see [AGENT.md](../../docs/AGENT.md). Pass `{ planMode: true }` or `{ goal: '…' }` on `prompt` for plan/act and goal completion.
+21
View File
@@ -68,6 +68,27 @@
session.permit(p.jobId, p.toolCallId, false);
},
});
} else if (p.type === 'ask_user') {
turn.askChoice({
question: p.question || 'The agent has a question.',
options: p.options && p.options.length ? p.options : ['Continue'],
onPick: function (choice) {
session.answer(p.toolCallId, choice);
},
});
} else if (p.type === 'plan_approval') {
turn.askChoice({
question: 'Approve this plan and start implementing?',
options: ['Approve', 'Reject'],
onPick: function (choice) {
session.planDecision(choice === 'Approve' ? 'approve' : 'reject');
},
});
if (p.plan) transcript.addSys('Plan ready for approval', 'ok');
} else if (p.type === 'plan_update') {
transcript.addSys('plan.md updated', 'ok');
} else if (p.type === 'goal_update' && p.goal) {
transcript.addSys('Goal ' + (p.goal.status || '') + (p.goal.objective ? ': ' + String(p.goal.objective).slice(0, 72) : ''), 'turn');
} else if (p.type === 'end') {
turn.finish({ text: p.text, reason: p.reason || 'stop' });
turn = null;
+3 -3
View File
@@ -18,9 +18,9 @@
<p class="bs-brand">BridgeSwarm</p>
<h1 class="bs-title">Agent Studio</h1>
<p class="bs-lede">
Grok-class coding agent on QVAC. Thinking, tool calls, and permission prompts
stream in the transcript. Built-in tools can read/write the sandboxed workspace;
this page also registers <code>page_time</code>.
Grok-class coding agent on QVAC. Thinking, tool calls, permission prompts,
plan approval, and ask-user questions stream in the transcript. Built-in tools
can read/write the sandboxed workspace; this page also registers <code>page_time</code>.
</p>
<div id="status" class="bs-banner bs-banner--info">Starting…</div>
<div class="bs-section">
+40
View File
@@ -272,6 +272,46 @@
scrollIfStuck(near);
return bar;
},
askChoice: function (choiceOpts) {
choiceOpts = choiceOpts || {};
var near = stick();
var bar = document.createElement('div');
bar.className = 'ai-perm';
var label = document.createElement('div');
label.className = 'ai-perm-label';
label.textContent = choiceOpts.question || 'Choose:';
bar.appendChild(label);
var actions = document.createElement('div');
actions.className = 'ai-perm-actions';
var opts = Array.isArray(choiceOpts.options) && choiceOpts.options.length
? choiceOpts.options
: ['OK'];
var buttons = [];
function finishPick(value, ok) {
for (var i = 0; i < buttons.length; i++) buttons[i].disabled = true;
bar.classList.add(ok === false ? 'ai-perm--deny' : 'ai-perm--allow');
if (choiceOpts.onPick) choiceOpts.onPick(value);
}
for (var i = 0; i < opts.length; i++) {
(function (opt, idx) {
var btn = document.createElement('button');
var labelText = typeof opt === 'string' ? opt : (opt.label || opt.id || String(opt));
var value = typeof opt === 'string' ? opt : (opt.value != null ? opt.value : opt.id || labelText);
btn.className = idx === 0 ? 'primary' : '';
btn.textContent = labelText;
btn.onclick = function () {
var deny = /reject|deny|no/i.test(String(labelText));
finishPick(value, !deny);
};
buttons.push(btn);
actions.appendChild(btn);
})(opts[i], i);
}
bar.appendChild(actions);
wrap.appendChild(bar);
scrollIfStuck(near);
return bar;
},
finish: function (info) {
info = info || {};
if (finished) return;
+21 -1
View File
@@ -1326,7 +1326,14 @@
model: r.model,
prompt: function (text, opts) {
opts = opts || {};
return capabilityCall('agent', 'prompt', { sessionId: sessionId, text: text }, Object.assign({ timeoutMs: 0 }, opts));
const payload = { sessionId: sessionId, text: text };
if (opts.planMode != null) payload.planMode = opts.planMode;
if (opts.permissionMode) payload.permissionMode = opts.permissionMode;
if (opts.system) payload.system = opts.system;
if (opts.webFetch != null) payload.webFetch = opts.webFetch;
if (opts.goal != null) payload.goal = opts.goal;
if (opts.verify != null) payload.verify = opts.verify;
return capabilityCall('agent', 'prompt', payload, Object.assign({ timeoutMs: 0 }, opts));
},
cancel: function () {
return capabilityCall('agent', 'cancel', { sessionId: sessionId });
@@ -1346,6 +1353,19 @@
decision: allow ? 'allow' : 'deny',
});
},
planDecision: function (decision) {
return capabilityCall('agent', 'planDecision', {
sessionId: sessionId,
decision: decision === 'approve' || decision === true ? 'approve' : 'reject',
});
},
answer: function (toolCallId, choice) {
return capabilityCall('agent', 'answer', {
sessionId: sessionId,
toolCallId: toolCallId,
choice: choice,
});
},
addTool: function (def) {
remember(def);
return capabilityCall('agent', 'registerTools', {
+110
View File
@@ -0,0 +1,110 @@
/**
* Session goal tracker + verifier prompt. No Bare imports.
*
* Done is update_goal({ completed: true }) passing a single-call verifier,
* not the model merely stopping tool use.
*/
const STATUSES = ['idle', 'planning', 'executing', 'verifying', 'complete', 'blocked'];
const MAX_VERIFIER_RUNS = 5;
function create(objective, opts) {
opts = opts || {};
const text = String(objective || '').trim();
return {
objective: text,
criteria: Array.isArray(opts.criteria) ? opts.criteria.map(String) : [],
status: text ? 'executing' : 'idle',
gaps: [],
notes: '',
verifierRuns: 0,
verify: opts.verify !== false,
blockedReason: '',
};
}
function isActive(g) {
return !!(g && (g.status === 'planning' || g.status === 'executing' || g.status === 'verifying'));
}
function plannerAddendum(g) {
if (!g || !g.objective) return '';
const criteria = (g.criteria || []).length
? '\nAcceptance criteria:\n' + g.criteria.map((c, i) => (i + 1) + '. ' + c).join('\n')
: '';
return (
'Active goal: ' +
g.objective +
criteria +
'\nComplete all todos, then call update_goal({ completed: true }).' +
'\nDo not stop with open todos. If blocked, call update_goal({ blocked_reason: "..." }).'
);
}
function continuation(g) {
const gaps = g && g.gaps && g.gaps.length ? '\nGaps: ' + g.gaps.join('; ') : '';
return (
'Goal NOT complete — continue. Objective: ' +
((g && g.objective) || '') +
gaps +
'\nDo not stop until you call update_goal({ completed: true }) or update_goal({ blocked_reason: "..." }).'
);
}
function verifierPrompt(g, evidence) {
const prior = (g && g.gaps && g.gaps.length ? g.gaps.join('\n- ') : '(none)');
return (
'You are an adversarial verifier. You are NOT the agent that produced the work. ' +
'Your job is to refute that the objective has been met. Default to achieved: false if uncertain.\n\n' +
'OBJECTIVE:\n' +
((g && g.objective) || '') +
'\n\nEVIDENCE:\n' +
String(evidence || '(none)') +
'\n\nPRIOR_GAPS:\n- ' +
prior +
'\n\nReply with JSON only: {"achieved": true|false, "gaps": ["..."]}'
);
}
function parseVerifier(text) {
const raw = String(text || '');
const m = raw.match(/\{[\s\S]*\}/);
if (!m) {
const yes = /not refuted|achieved["']?\s*:\s*true/i.test(raw);
const no = /refuted|not achieved|achieved["']?\s*:\s*false/i.test(raw);
return { achieved: yes && !no, gaps: no ? ['verifier response was not JSON'] : [] };
}
try {
const j = JSON.parse(m[0]);
const gaps = Array.isArray(j.gaps) ? j.gaps.map(String) : [];
return { achieved: !!j.achieved, gaps };
} catch (_) {
return { achieved: false, gaps: ['verifier response was not JSON'] };
}
}
function snapshot(g) {
if (!g) return null;
return {
objective: g.objective,
criteria: g.criteria || [],
status: g.status,
gaps: g.gaps || [],
notes: g.notes || '',
verifierRuns: g.verifierRuns || 0,
verify: g.verify !== false,
blockedReason: g.blockedReason || '',
};
}
module.exports = {
STATUSES,
MAX_VERIFIER_RUNS,
create,
isActive,
plannerAddendum,
continuation,
verifierPrompt,
parseVerifier,
snapshot,
};
+428 -64
View File
@@ -12,6 +12,10 @@ const compaction = require('./compaction.js');
const tasks = require('./tasks.js');
const customTools = require('./custom-tools.js');
const toolSet = require('./tool-set.js');
const planMode = require('./plan-mode.js');
const todos = require('./todos.js');
const stationarity = require('./stationarity.js');
const goalMod = require('./goal.js');
const path = require('bare-path');
const fs = require('bare-fs');
@@ -20,8 +24,14 @@ customTools.setReserved(toolSet.ALWAYS_RESERVED.concat(toolSet.ALWAYS_BUILTIN_RE
const live = new Map();
const pendingPerms = new Map();
const pendingCustom = new Map();
const pendingAsks = new Map();
const pendingPlans = new Map();
const MAX_TURNS = 24;
const SUBAGENT_TURNS = 8;
const CUSTOM_TOOL_TIMEOUT_MS = 60000;
const ASK_TIMEOUT_MS = 10 * 60 * 1000;
const PLAN_TIMEOUT_MS = 10 * 60 * 1000;
const MAX_GOAL_NUDGES = 8;
function fsRead(cwd, rel) {
return fs.readFileSync(path.join(cwd, rel), 'utf8');
@@ -38,6 +48,43 @@ function emitUpdate(emit, sessionId, jobId, update) {
emit('cap-chunk', Object.assign({ pack: 'agent', sessionId, jobId, kind: 'session_update' }, update));
}
function persistSession(session, tracker) {
if (tracker) session.planMode = planMode.snapshot(tracker);
if (session.goal) session.goal = goalMod.snapshot(session.goal);
sessions.saveSummary(session);
}
async function waitKeyed(map, key, timeoutMs, timeoutValue) {
const existing = map.get(key);
if (existing && existing.ready) {
map.delete(key);
return existing.ready;
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
map.delete(key);
resolve(timeoutValue);
}, timeoutMs);
map.set(key, {
resolve(payload) {
clearTimeout(timer);
resolve(payload);
},
});
});
}
function resolveKeyed(map, key, payload) {
const rec = map.get(key);
if (rec && typeof rec.resolve === 'function') {
map.delete(key);
rec.resolve(payload);
return true;
}
map.set(key, { ready: payload });
return true;
}
async function waitPermission(jobId, payload) {
return new Promise((resolve) => {
pendingPerms.set(jobId + ':' + payload.toolCallId, resolve);
@@ -75,16 +122,155 @@ function waitCustomResult(jobId, toolCallId) {
}
function resolveCustomResult(jobId, toolCallId, payload) {
const key = jobId + ':' + toolCallId;
const rec = pendingCustom.get(key);
const body = payload || {};
if (rec && typeof rec.resolve === 'function') {
pendingCustom.delete(key);
rec.resolve(body);
return true;
return resolveKeyed(pendingCustom, jobId + ':' + toolCallId, payload || {});
}
function waitAsk(jobId, toolCallId) {
return waitKeyed(pendingAsks, jobId + ':' + toolCallId, ASK_TIMEOUT_MS, { error: 'ask_user timed out' });
}
function resolveAsk(jobId, toolCallId, choice) {
const payload = { choice };
if (jobId) {
const key = jobId + ':' + toolCallId;
if (pendingAsks.has(key)) return resolveKeyed(pendingAsks, key, payload);
}
const keys = Array.from(pendingAsks.keys());
for (const key of keys) {
if (key === toolCallId || key.endsWith(':' + toolCallId)) return resolveKeyed(pendingAsks, key, payload);
}
return resolveKeyed(pendingAsks, (jobId || '') + ':' + toolCallId, payload);
}
function waitPlanDecision(sessionId) {
return waitKeyed(pendingPlans, sessionId, PLAN_TIMEOUT_MS, { decision: 'timeout' });
}
function resolvePlanDecision(sessionId, decision) {
return resolveKeyed(pendingPlans, sessionId, { decision: decision === 'approve' ? 'approve' : 'reject' });
}
function buildToolDefs(session, payload, tracker) {
const hostWorkspace = session.hostWorkspace !== false;
return tools
.defs({
planMode: planMode.isActive(tracker),
webFetch: payload && payload.webFetch,
hostWorkspace,
builtinTools: session.builtinTools,
})
.concat(customTools.defs(session.id));
}
function refreshSystem(session, sys) {
if (!session.history) session.history = [];
if (session.history.length && session.history[0].role === 'system') {
session.history[0] = { role: 'system', content: sys };
} else {
session.history.unshift({ role: 'system', content: sys });
}
}
function pushHistory(session, msg) {
session.history.push(msg);
if (msg.role !== 'system') sessions.appendHistory(session.id, msg);
}
function applyPlanWrite(session, name, args) {
let text = sessions.readPlan(session.id) || '';
if (name === 'write_file') {
text = args.contents != null ? String(args.contents) : args.content != null ? String(args.content) : '';
} else {
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');
if (!old) text = String(neu);
else {
if (!text.includes(old)) throw new Error('old_string not found');
text = text.replace(old, neu);
}
}
const file = sessions.writePlan(session.id, text);
if (session.hostWorkspace !== false && session.cwd) {
try {
const abs = path.join(session.cwd, 'plan.md');
fs.writeFileSync(abs, text);
} catch (_) {}
}
return { ok: true, path: file, bytes: text.length };
}
function sidecarMessages(session, tracker) {
const extra = [];
if (planMode.isActive(tracker)) {
extra.push({
role: 'user',
content:
'<system-reminder>\n' +
planMode.reminder(tracker, { hasContent: !!(sessions.readPlan(session.id) || '').trim() }) +
'\n</system-reminder>',
});
} else if (tracker && tracker.pendingExitReminder) {
extra.push({ role: 'user', content: '<system-reminder>\n' + planMode.exitReminder() + '\n</system-reminder>' });
tracker.pendingExitReminder = false;
}
if (session.plan && session.plan.length) {
extra.push({ role: 'user', content: todos.formatBlock(session.plan) });
}
return extra;
}
function endTurn(emit, session, jobId, tracker, extra) {
persistSession(session, tracker);
const payload = Object.assign({ type: 'end', reason: 'stop' }, extra || {});
emitUpdate(emit, session.id, jobId, payload);
return {
ok: payload.reason !== 'cancelled',
text: payload.text || '',
turns: payload.turns,
reason: payload.reason,
};
}
async function verifyGoal(session, tracker, lastText) {
const g = session.goal;
if (!g || g.verify === false) {
g.status = 'complete';
return { achieved: true, gaps: [] };
}
if ((g.verifierRuns || 0) >= goalMod.MAX_VERIFIER_RUNS) {
return { achieved: false, gaps: ['verification budget exhausted'] };
}
g.status = 'verifying';
g.verifierRuns = (g.verifierRuns || 0) + 1;
const evidence = [
lastText || '',
todos.formatBlock(session.plan),
(sessions.readPlan(session.id) || '').slice(0, 8000),
g.notes || '',
]
.filter(Boolean)
.join('\n\n');
try {
const result = await engine.complete({
history: [
{ role: 'system', content: 'Reply with JSON only.' },
{ role: 'user', content: goalMod.verifierPrompt(g, evidence) },
],
tools: [],
});
const verdict = goalMod.parseVerifier(result && result.text);
g.gaps = verdict.gaps || [];
if (verdict.achieved) g.status = 'complete';
else g.status = 'executing';
persistSession(session, tracker);
return verdict;
} catch (err) {
g.status = 'executing';
g.gaps = ['verifier failed: ' + err.message];
persistSession(session, tracker);
return { achieved: false, gaps: g.gaps };
}
pendingCustom.set(key, { ready: body });
return true;
}
async function runTurn(ctx) {
@@ -93,20 +279,31 @@ async function runTurn(ctx) {
const hostWorkspace = session.hostWorkspace !== false;
const cwd = hostWorkspace ? session.cwd || sandbox.defaultCwd(origin) : session.cwd || 'page';
const mode = sandbox.permissionMode(payload);
const toolDefs = tools
.defs({
planMode: ctx.planMode,
webFetch: payload && payload.webFetch,
hostWorkspace,
builtinTools: session.builtinTools,
})
.concat(customTools.defs(session.id));
const tracker = planMode.create(session.planMode);
if (payload && (payload.planMode === true || payload.planMode === 'active' || payload.planMode === 'plan')) {
if (tracker.state === 'inactive') {
planMode.enterPending(tracker);
planMode.activate(tracker);
}
}
ctx.planTracker = tracker;
ctx.planMode = planMode.isActive(tracker);
if (payload && payload.goal) {
session.goal = goalMod.create(payload.goal, { verify: payload.verify !== false });
}
if (payload && payload.verify === false && session.goal) session.goal.verify = false;
await ensureModel(session.model);
let extraSys = payload && payload.system;
if (session.goal && goalMod.isActive(session.goal)) {
extraSys = [extraSys, goalMod.plannerAddendum(session.goal)].filter(Boolean).join('\n\n');
}
const sys = prompts.assemble({
cwd: hostWorkspace ? cwd : session.workspace || cwd,
hostWorkspace,
extra: payload && payload.system,
extra: extraSys,
fsRead: hostWorkspace
? (c, r) => {
try {
@@ -117,28 +314,39 @@ async function runTurn(ctx) {
}
: null,
});
if (!session.history || !session.history.length) {
session.history = [{ role: 'system', content: sys }];
}
refreshSystem(session, sys);
if (userText) {
session.history.push({ role: 'user', content: userText });
sessions.appendHistory(session.id, { role: 'user', content: userText });
pushHistory(session, { role: 'user', content: userText });
}
persistSession(session, tracker);
if (session.goal) {
emitUpdate(emit, session.id, jobId, { type: 'goal_update', goal: goalMod.snapshot(session.goal) });
}
const cancelled = () => live.get(session.id) && live.get(session.id).cancelled;
const stuck = stationarity.create();
let lastText = '';
let goalNudges = 0;
try {
for (let turn = 0; turn < MAX_TURNS; turn++) {
if (cancelled()) throw new Error('cancelled');
if (cancelled()) return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', turns: turn });
const toolDefs = buildToolDefs(session, payload, tracker);
ctx.planMode = planMode.isActive(tracker);
const beforeLen = session.history.length;
session.history = compaction.compact(session.history, {
budgetTokens: Math.floor(((engine.getLoaded().ctxSize || 8192) * 0.8)),
budgetTokens: Math.floor((engine.getLoaded().ctxSize || 8192) * 0.8),
tools: toolDefs,
});
if (session.history.length !== beforeLen) {
sessions.replaceHistory(session.id, session.history);
}
emitUpdate(emit, session.id, jobId, { type: 'turn', turn });
const history = session.history.concat(sidecarMessages(session, tracker));
const result = await engine.complete(
{
history: session.history,
history,
tools: toolDefs,
toolDialect: catalog.toolDialectFor(session.model),
},
@@ -154,23 +362,49 @@ async function runTurn(ctx) {
);
if (result.text) {
session.history.push({ role: 'assistant', content: result.text });
sessions.appendHistory(session.id, { role: 'assistant', content: result.text });
lastText = result.text;
pushHistory(session, { role: 'assistant', content: result.text });
}
const calls = result.toolCalls || [];
if (!calls.length) {
emitUpdate(emit, session.id, jobId, { type: 'end', reason: 'stop', text: result.text || '' });
sessions.saveSummary(session);
return { ok: true, text: result.text || '', turns: turn + 1 };
const goalActive = goalMod.isActive(session.goal);
if (goalActive && goalNudges < MAX_GOAL_NUDGES) {
goalNudges += 1;
pushHistory(session, { role: 'user', content: goalMod.continuation(session.goal) });
continue;
}
return endTurn(emit, session, jobId, tracker, {
type: 'end',
reason: 'stop',
text: result.text || lastText || '',
turns: turn + 1,
});
}
let stuckNow = false;
for (const call of calls) {
if (cancelled()) throw new Error('cancelled');
if (cancelled()) return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', turns: turn + 1 });
const name = call.name;
const args = typeof call.arguments === 'string' ? safeJson(call.arguments) : call.arguments || {};
const toolCallId = call.id || name + '_' + Date.now();
if (sandbox.needsPermission(name, mode)) {
stationarity.observe(stuck, name, args);
if (stationarity.shouldStop(stuck)) {
stuckNow = true;
const msg = 'Repeating the same tool call; stopping.';
pushHistory(session, { role: 'tool', name, content: msg, tool_call_id: toolCallId });
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: msg });
break;
}
const gateErr = planMode.gateWrite(tracker, name, args);
if (gateErr) {
pushHistory(session, { role: 'tool', name, content: gateErr, tool_call_id: toolCallId });
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: gateErr });
continue;
}
if (sandbox.needsPermission(name, mode) && !planMode.isPlanFilePath(args.path, tracker.planPath)) {
emitUpdate(emit, session.id, jobId, {
type: 'permission',
toolCallId,
@@ -181,16 +415,27 @@ async function runTurn(ctx) {
const decision = await waitPermission(jobId, { toolCallId });
if (decision !== 'allow') {
const denied = 'permission denied for ' + name;
session.history.push({ role: 'tool', name, content: denied, tool_call_id: toolCallId });
sessions.appendHistory(session.id, { role: 'tool', name, content: denied });
pushHistory(session, { role: 'tool', name, content: denied, tool_call_id: toolCallId });
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: denied });
continue;
}
}
}
let out;
try {
if (customTools.has(session.id, name)) {
if (
planMode.isActive(tracker) &&
(name === 'write_file' || name === 'search_replace') &&
planMode.isPlanFilePath(args.path || args.file, tracker.planPath)
) {
out = applyPlanWrite(session, name, args);
emitUpdate(emit, session.id, jobId, {
type: 'plan_update',
path: tracker.planPath,
text: sessions.readPlan(session.id),
});
} else if (customTools.has(session.id, name)) {
emitUpdate(emit, session.id, jobId, {
type: 'tool_request',
toolCallId,
@@ -207,28 +452,116 @@ async function runTurn(ctx) {
out = await continueSubagent(ctx, rec, args.message);
} else {
out = await tools.execute(
{ origin, cwd, session, planMode: ctx.planMode, hostWorkspace },
{
origin,
cwd,
session,
planMode: planMode.isActive(tracker),
planTracker: tracker,
hostWorkspace,
},
name,
args
);
if (out && out.planMode === true) ctx.planMode = true;
if (out && out.planMode === false) ctx.planMode = false;
if (out && out.type === 'enter_plan_mode') {
persistSession(session, tracker);
}
if (out && out.type === 'exit_plan_mode') {
planMode.requestExit(tracker);
persistSession(session, tracker);
emitUpdate(emit, session.id, jobId, {
type: 'plan_approval',
toolCallId,
plan: sessions.readPlan(session.id),
});
const ans = await waitPlanDecision(session.id);
if (cancelled()) return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', turns: turn + 1 });
if (ans && ans.decision === 'approve') {
planMode.approveExit(tracker);
tracker.pendingExitReminder = true;
persistSession(session, tracker);
out = { planMode: false, approved: true, message: planMode.exitReminder() };
} else {
planMode.rejectExit(tracker);
persistSession(session, tracker);
out = {
planMode: true,
approved: false,
message: 'Plan rejected. Stay in plan mode and revise plan.md.',
};
}
}
if (out && out.type === 'ask_user') {
emitUpdate(emit, session.id, jobId, { type: 'ask_user', question: out.question, options: out.options });
emitUpdate(emit, session.id, jobId, {
type: 'ask_user',
toolCallId,
question: out.question,
options: out.options,
});
const ans = await waitAsk(jobId, toolCallId);
if (cancelled()) return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', turns: turn + 1 });
if (ans && ans.error) out = { error: ans.error };
else out = { type: 'ask_user', question: out.question, choice: ans && ans.choice };
}
if (out && out.type === 'goal_blocked') {
session.goal.status = 'blocked';
emitUpdate(emit, session.id, jobId, { type: 'goal_update', goal: goalMod.snapshot(session.goal) });
const rendered = JSON.stringify(out).slice(0, 12000);
pushHistory(session, { role: 'tool', name, content: rendered, tool_call_id: toolCallId });
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: rendered.slice(0, 4000) });
return endTurn(emit, session, jobId, tracker, {
reason: 'goal_blocked',
text: out.blocked_reason || lastText,
turns: turn + 1,
});
}
if (out && out.type === 'goal_completed') {
emitUpdate(emit, session.id, jobId, { type: 'goal_update', goal: goalMod.snapshot(session.goal) });
const skipVerify = payload && payload.verify === false;
const verdict = skipVerify ? { achieved: true, gaps: [] } : await verifyGoal(session, tracker, lastText);
if (verdict.achieved) {
const rendered = JSON.stringify({ ok: true, achieved: true }).slice(0, 12000);
pushHistory(session, { role: 'tool', name, content: rendered, tool_call_id: toolCallId });
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: rendered });
emitUpdate(emit, session.id, jobId, { type: 'goal_update', goal: goalMod.snapshot(session.goal) });
return endTurn(emit, session, jobId, tracker, {
reason: 'goal_complete',
text: lastText,
turns: turn + 1,
});
}
out = {
ok: false,
achieved: false,
gaps: verdict.gaps,
message: 'Verifier rejected completion. Fix the gaps and continue.',
};
emitUpdate(emit, session.id, jobId, { type: 'goal_update', goal: goalMod.snapshot(session.goal) });
}
}
} catch (err) {
out = { error: err.message };
}
const rendered = typeof out === 'string' ? out : JSON.stringify(out).slice(0, 12000);
session.history.push({ role: 'tool', name, content: rendered, tool_call_id: toolCallId });
sessions.appendHistory(session.id, { role: 'tool', name, content: rendered });
pushHistory(session, { role: 'tool', name, content: rendered, tool_call_id: toolCallId });
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: rendered.slice(0, 4000) });
}
if (stuckNow) {
return endTurn(emit, session, jobId, tracker, { reason: 'stuck', text: lastText, 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 });
} catch (err) {
if (err && err.message === 'cancelled') {
return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', text: lastText });
}
throw err;
}
emitUpdate(emit, session.id, jobId, { type: 'end', reason: 'max_turns' });
sessions.saveSummary(session);
return { ok: true, text: '', turns: MAX_TURNS, reason: 'max_turns' };
}
async function runSubagent(parentCtx, args) {
@@ -236,10 +569,9 @@ async function runSubagent(parentCtx, args) {
const label = args.label || 'subagent';
const taskId = tasks.create(label);
const hostWorkspace = parentCtx.session.hostWorkspace !== false;
const allowed = new Set(['read_file', 'grep', 'list_dir', 'memory_search', 'memory_get']);
const toolDefs = hostWorkspace
? tools.defs({ planMode: true, webFetch: false, hostWorkspace: true }).filter((t) =>
['read_file', 'grep', 'list_dir', 'memory_search'].includes(t.name)
)
? tools.defs({ webFetch: false, hostWorkspace: true }).filter((t) => allowed.has(t.name))
: [];
await ensureModel(parentCtx.session.model);
const history = [
@@ -252,10 +584,20 @@ async function runSubagent(parentCtx, args) {
{ role: 'user', content: prompt },
];
try {
let summary = '';
for (let turn = 0; turn < SUBAGENT_TURNS; turn++) {
if (parentCtx.session && live.get(parentCtx.session.id) && live.get(parentCtx.session.id).cancelled) {
throw new Error('cancelled');
}
const result = await engine.complete({ history, tools: toolDefs });
if (result.text) history.push({ role: 'assistant', content: result.text });
const calls = result.toolCalls || [];
if (!calls.length) {
summary = result.text || summary;
break;
}
let extra = '';
for (const call of calls.slice(0, 6)) {
for (const call of calls) {
try {
const out = await tools.execute(
{
@@ -267,17 +609,18 @@ async function runSubagent(parentCtx, args) {
call.name,
typeof call.arguments === 'string' ? safeJson(call.arguments) : call.arguments || {}
);
extra += '\n[' + call.name + '] ' + (typeof out === 'string' ? out : JSON.stringify(out)).slice(0, 2000);
extra = typeof out === 'string' ? out : JSON.stringify(out);
} catch (err) {
extra += '\n[' + call.name + ' error] ' + err.message;
extra = 'error: ' + err.message;
}
history.push({
role: 'tool',
name: call.name,
content: String(extra).slice(0, 8000),
tool_call_id: call.id,
});
}
let summary = result.text || extra;
if (extra) {
history.push({ role: 'assistant', content: result.text || '' });
history.push({ role: 'user', content: 'Tool results:\n' + extra + '\nSummarize for the parent agent.' });
const second = await engine.complete({ history });
summary = second.text || result.text || extra.slice(0, 4000);
summary = result.text || extra;
}
tasks.finish(taskId, summary);
return { taskId, label, summary };
@@ -313,16 +656,37 @@ function markLive(sessionId) {
return rec;
}
function flushPending(map, payload) {
const keys = Array.from(map.keys());
for (const key of keys) {
const rec = map.get(key);
map.delete(key);
if (rec && typeof rec.resolve === 'function') rec.resolve(payload);
}
}
function cancel(sessionId) {
const rec = live.get(sessionId);
if (rec) rec.cancelled = true;
engine.cancel().catch(() => {});
const keys = Array.from(pendingCustom.keys());
for (const key of keys) {
const rec2 = pendingCustom.get(key);
pendingCustom.delete(key);
if (rec2 && typeof rec2.resolve === 'function') rec2.resolve({ error: 'cancelled' });
flushPending(pendingCustom, { error: 'cancelled' });
flushPending(pendingAsks, { error: 'cancelled' });
flushPending(pendingPlans, { decision: 'reject' });
const permKeys = Array.from(pendingPerms.keys());
for (const key of permKeys) {
const fn = pendingPerms.get(key);
pendingPerms.delete(key);
if (typeof fn === 'function') fn('deny');
}
}
module.exports = { runTurn, resolvePermission, resolveCustomResult, markLive, cancel };
module.exports = {
runTurn,
resolvePermission,
resolveCustomResult,
resolveAsk,
resolvePlanDecision,
markLive,
cancel,
MAX_TURNS,
};
+176
View File
@@ -0,0 +1,176 @@
/**
* Plan-mode state machine (Grok-shaped). No Bare imports unit-testable on Node.
*
* States: inactive | pending | active | exitPending
* While active, write/shell tools are blocked except the session plan file.
*/
const STATES = ['inactive', 'pending', 'active', 'exitPending'];
const WRITE_TOOLS = ['write_file', 'search_replace', 'run_terminal_cmd'];
const PLAN_FILE_NAMES = ['plan.md'];
function create(snapshot) {
const s = snapshot && typeof snapshot === 'object' ? snapshot : {};
let state = String(s.state || 'inactive');
if (STATES.indexOf(state) < 0) state = 'inactive';
if (state === 'pending') state = 'inactive';
if (state === 'exitPending') state = s.awaitingApproval ? 'active' : 'inactive';
return {
state,
reminderCount: Number(s.reminderCount) || 0,
planPath: String(s.planPath || 'plan.md'),
awaitingApproval: !!s.awaitingApproval,
wasActive: !!s.wasActive,
pendingExitReminder: !!s.pendingExitReminder,
};
}
function isActive(pm) {
if (!pm) return false;
if (pm === true) return true;
return pm.state === 'active' || pm.state === 'exitPending';
}
function enterPending(pm) {
if (!pm) return false;
if (pm.state === 'inactive') {
pm.state = 'pending';
pm.pendingExitReminder = false;
return true;
}
if (pm.state === 'exitPending') {
pm.state = 'active';
pm.pendingExitReminder = false;
return true;
}
return false;
}
function activate(pm) {
if (!pm) return false;
if (pm.state !== 'pending' && pm.state !== 'inactive') return false;
pm.state = 'active';
pm.wasActive = true;
pm.reminderCount = 0;
pm.awaitingApproval = false;
pm.pendingExitReminder = false;
return true;
}
function requestExit(pm) {
if (!pm || pm.state !== 'active') return false;
pm.state = 'exitPending';
pm.awaitingApproval = true;
return true;
}
function approveExit(pm) {
if (!pm) return false;
if (pm.state !== 'active' && pm.state !== 'exitPending') return false;
pm.state = 'inactive';
pm.awaitingApproval = false;
pm.reminderCount = 0;
pm.pendingExitReminder = false;
return true;
}
function rejectExit(pm) {
if (!pm) return false;
pm.state = 'active';
pm.awaitingApproval = false;
return true;
}
function reminder(pm, opts) {
opts = opts || {};
const path = (pm && pm.planPath) || 'plan.md';
const has = !!opts.hasContent;
const full = has
? 'Plan mode is active. Do not make any edits or writes to the system.\n\n' +
'A plan file exists at ' +
path +
'. You can read it and make edits using write_file or search_replace.\n' +
'This is the only file you are allowed to edit.\n\n' +
'Your turn should only end with either ask_user_question to clarify requirements or exit_plan_mode to present your plan to the user.'
: 'Plan mode is active. Do not make any edits or writes to the system.\n\n' +
'No plan written yet. Write your plan to ' +
path +
' using write_file.\n' +
'This is the only file you are allowed to edit.\n\n' +
'Your turn should only end with either ask_user_question to clarify requirements or exit_plan_mode to present your plan to the user.';
const sparse = 'Plan mode is still active. Do not make any edits or writes to the system except for the plan file.';
const useFull = !pm || pm.reminderCount % 2 === 0;
if (pm) pm.reminderCount += 1;
return useFull ? full : sparse;
}
function exitReminder() {
return 'You have exited plan mode. You can now make edits, run tools, and take actions. Implement the approved plan.';
}
function isWriteTool(name) {
return WRITE_TOOLS.indexOf(String(name || '')) >= 0;
}
function basename(p) {
const s = String(p || '').replace(/\\/g, '/');
const i = s.lastIndexOf('/');
return i >= 0 ? s.slice(i + 1) : s;
}
function isPlanFilePath(filePath, planPath) {
const want = basename(planPath || 'plan.md').toLowerCase();
const got = basename(filePath).toLowerCase();
if (!got) return false;
if (got === want) return true;
return PLAN_FILE_NAMES.indexOf(got) >= 0;
}
function gateWrite(pm, name, args) {
if (!isActive(pm)) return null;
if (!isWriteTool(name)) return null;
if (name === 'run_terminal_cmd') {
return (
'Plan mode is active. Shell is blocked. Write only ' +
((pm && pm.planPath) || 'plan.md') +
', or call exit_plan_mode.'
);
}
const file = args && (args.path || args.file);
if (isPlanFilePath(file, pm && pm.planPath)) return null;
return (
'Rejected: file edits are not allowed in plan mode - the only editable file is the plan file (' +
((pm && pm.planPath) || 'plan.md') +
').'
);
}
function snapshot(pm) {
if (!pm) return { state: 'inactive', reminderCount: 0, planPath: 'plan.md', awaitingApproval: false };
return {
state: pm.state,
reminderCount: pm.reminderCount,
planPath: pm.planPath || 'plan.md',
awaitingApproval: !!pm.awaitingApproval,
wasActive: !!pm.wasActive,
pendingExitReminder: !!pm.pendingExitReminder,
};
}
module.exports = {
STATES,
WRITE_TOOLS,
create,
isActive,
enterPending,
activate,
requestExit,
approveExit,
rejectExit,
reminder,
exitReminder,
isWriteTool,
isPlanFilePath,
gateWrite,
snapshot,
};
+28 -1
View File
@@ -57,6 +57,8 @@ function create(meta) {
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'), '');
@@ -78,7 +80,10 @@ function load(id) {
function saveSummary(summary) {
summary.updatedAt = Date.now();
fs.writeFileSync(path.join(sessionDir(summary.id), 'summary.json'), JSON.stringify(summary, null, 2));
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) {
@@ -95,6 +100,24 @@ function replaceHistory(id, history) {
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 list() {
const root = sessionsRoot();
let names = [];
@@ -121,6 +144,10 @@ module.exports = {
appendHistory,
appendUpdate,
replaceHistory,
sessionDir,
planFile,
readPlan,
writePlan,
list,
makeId,
};
+61
View File
@@ -0,0 +1,61 @@
/**
* Identical tool-call stationarity. No Bare imports.
* Same name+args 4 times nudge; 8 times stuck.
*/
const NUDGE_AFTER = 4;
const STOP_AFTER = 8;
function fingerprint(name, args) {
let a = args;
try {
a = JSON.stringify(args || {});
} catch (_) {
a = String(args);
}
return String(name || '') + ':' + a;
}
function create() {
return { last: null, count: 0, nudged: false };
}
function observe(st, name, args) {
if (!st) return 0;
const fp = fingerprint(name, args);
if (st.last === fp) st.count += 1;
else {
st.last = fp;
st.count = 1;
st.nudged = false;
}
return st.count;
}
function shouldNudge(st) {
return !!(st && st.count >= NUDGE_AFTER && st.count < STOP_AFTER && !st.nudged);
}
function shouldStop(st) {
return !!(st && st.count >= STOP_AFTER);
}
function nudgeText() {
return 'You are repeating the same tool call. Try a different approach, a different path, or finish.';
}
function markNudged(st) {
if (st) st.nudged = true;
}
module.exports = {
NUDGE_AFTER,
STOP_AFTER,
fingerprint,
create,
observe,
shouldNudge,
shouldStop,
nudgeText,
markNudged,
};
+29 -3
View File
@@ -3,6 +3,7 @@
*/
const tasks = new Map();
const waiters = [];
function makeId() {
return 'task_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
@@ -21,6 +22,12 @@ function create(label) {
return id;
}
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) {
@@ -28,6 +35,7 @@ function finish(id, summary) {
t.summary = summary;
t.updatedAt = Date.now();
}
flushWaiters();
return t || null;
}
@@ -38,6 +46,7 @@ function fail(id, err) {
t.summary = String(err || 'error');
t.updatedAt = Date.now();
}
flushWaiters();
return t || null;
}
@@ -54,9 +63,24 @@ function list() {
}));
}
function waitAll() {
const running = list().filter((t) => t.status === 'running');
return { running: running.length, tasks: list() };
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) {
@@ -65,6 +89,7 @@ function kill(id) {
t.status = 'killed';
t.updatedAt = Date.now();
}
flushWaiters();
return t || null;
}
@@ -78,6 +103,7 @@ function appendMessage(id, text) {
function reset() {
tasks.clear();
waiters.length = 0;
}
module.exports = { create, finish, fail, get, list, waitAll, kill, appendMessage, reset };
+57
View File
@@ -0,0 +1,57 @@
/**
* Session todo list: merge-by-id, grok-shaped statuses. No Bare imports.
*/
const STATUSES = ['pending', 'in_progress', 'completed', 'cancelled'];
function normalizeStatus(s) {
const v = String(s || 'pending')
.toLowerCase()
.replace(/[\s-]+/g, '_');
if (v === 'done' || v === 'complete') return 'completed';
if (v === 'progress' || v === 'doing' || v === 'inprogress') return 'in_progress';
if (v === 'cancel' || v === 'canceled') return 'cancelled';
if (STATUSES.indexOf(v) >= 0) return v;
return 'pending';
}
function normalizeItem(t, i) {
t = t || {};
return {
id: String(t.id || 'todo_' + (i + 1)),
content: String(t.content || t.text || t.title || ''),
status: normalizeStatus(t.status),
};
}
function merge(existing, incoming, mode) {
const next = Array.isArray(incoming) ? incoming.map(normalizeItem) : [];
if (mode === 'replace' || !existing || !existing.length) return next;
const byId = new Map();
for (const t of existing) {
const n = normalizeItem(t, 0);
byId.set(n.id, n);
}
for (const t of next) {
const prev = byId.get(t.id) || {};
const merged = Object.assign({}, prev, t);
if (!t.content && prev.content) merged.content = prev.content;
byId.set(t.id, merged);
}
return Array.from(byId.values());
}
function hasOpen(list) {
return (list || []).some((t) => t.status === 'pending' || t.status === 'in_progress');
}
function formatBlock(list) {
const todos = list || [];
if (!todos.length) return '[todos]\n(none)';
return (
'[todos]\n' +
todos.map((t) => '- [' + t.status + '] ' + t.id + ': ' + t.content).join('\n')
);
}
module.exports = { STATUSES, normalizeStatus, normalizeItem, merge, hasOpen, formatBlock };
+5 -1
View File
@@ -12,6 +12,7 @@ const HOST_WORKSPACE_TOOLS = [
'run_terminal_cmd',
'memory_search',
'memory_get',
'memory_write',
];
const HOST_WORKSPACE_SET = new Set(HOST_WORKSPACE_TOOLS);
@@ -25,6 +26,8 @@ const ALWAYS_BUILTIN_RESERVED = [
'enter_plan_mode',
'exit_plan_mode',
'ask_user_question',
'update_goal',
'memory_write',
'task',
'send_subagent_message',
'get_task_output',
@@ -54,7 +57,8 @@ function filterBuiltinSchemas(schemas, opts) {
list = list.filter((t) => allow.has(t.name));
}
if (opts.planMode) {
list = list.filter((t) => t.name !== 'search_replace' && t.name !== 'write_file' && t.name !== 'run_terminal_cmd');
// 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');
+53 -10
View File
@@ -8,6 +8,9 @@ const fs = require('bare-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 MAX_READ = 400 * 1024;
const MAX_GREP_HITS = 50;
@@ -119,18 +122,20 @@ const SCHEMAS = [
{ type: 'function', name: 'grep', description: 'Search workspace files with a JS regex.', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { 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: 'Replace the session todo list.', parameters: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, content: { type: 'string' }, status: { type: 'string' } } } } }, required: ['todos'] } },
{ 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: 'enter_plan_mode', description: 'Switch to plan mode (no writes).', parameters: { type: 'object', properties: {} } },
{ type: 'function', name: 'exit_plan_mode', description: 'Exit plan mode.', parameters: { type: 'object', properties: {} } },
{ 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).', parameters: { type: 'object', properties: { prompt: { type: 'string' }, label: { 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: 'List running and completed subagent tasks.', parameters: { type: 'object', properties: {} } },
{ 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'] } },
@@ -170,6 +175,8 @@ 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');
}
@@ -225,7 +232,8 @@ async function execute(ctx, name, args) {
return runShell(cwd, args.command, args.timeout_ms || args.timeoutMs);
}
case 'todo_write': {
ctx.session.plan = args.todos || [];
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 };
}
@@ -237,20 +245,55 @@ async function execute(ctx, name, args) {
return memory.search(origin, args.query);
case 'memory_get':
return memory.readNote(origin, args.name);
case 'enter_plan_mode':
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;
return { 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':
ctx.planMode = false;
return { planMode: false };
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();
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':
+17 -1
View File
@@ -50,6 +50,10 @@ function createAgentPack() {
model: p.model || 'qwen3.5-4b',
title: p.title || 'New session',
sessionId: p.sessionId,
goal: p.goal ? require('../agent/goal.js').create(p.goal, { verify: p.verify !== false }) : null,
planMode: p.planMode
? { state: p.planMode === true || p.planMode === 'active' ? 'active' : 'inactive', planPath: 'plan.md' }
: undefined,
});
customTools.setSession(summary.id, { hostWorkspace });
if (p.tools) {
@@ -104,7 +108,7 @@ function createAgentPack() {
emit,
jobId,
payload,
planMode: !!payload.planMode,
planMode: payload.planMode,
});
emit('cap-end', { pack: 'agent', jobId, sessionId: session.id, ...result });
} catch (err) {
@@ -122,6 +126,18 @@ function createAgentPack() {
loop.resolvePermission(p.jobId, p.toolCallId, p.decision || p.allow ? 'allow' : 'deny');
ctx.reply({ ok: true });
},
async planDecision(ctx) {
if (refuseDisabled(ctx)) return;
const p = ctx.payload || {};
loop.resolvePlanDecision(p.sessionId, p.decision === 'approve' || p.approve === true ? 'approve' : 'reject');
ctx.reply({ ok: true });
},
async answer(ctx) {
if (refuseDisabled(ctx)) return;
const p = ctx.payload || {};
loop.resolveAsk(p.jobId, p.toolCallId, p.choice != null ? p.choice : p.answer);
ctx.reply({ ok: true });
},
async registerTools(ctx) {
if (refuseDisabled(ctx)) return;
try {
+191
View File
@@ -132,6 +132,10 @@ testPathJail();
testAgentLoopFakeComplete()
.then(() => testCustomToolLoop())
.then(() => testHostWorkspaceOffLoop())
.then(() => testPlanWriteGateLoop())
.then(() => testAskUserLoop())
.then(() => testGoalPrematureLoop())
.then(() => testStationarityLoop())
.then(() => testBareVersionsCoerce())
.then(() => testQvacOffByDefault())
.then(() => {
@@ -316,3 +320,190 @@ async function testHostWorkspaceOffLoop() {
ok(firstNames.indexOf('grep') === -1, 'host grep not offered');
ok(n >= 2, 'custom read_file then final');
}
function mockEngine(engine) {
const origComplete = engine.complete;
const origLoad = engine.load;
const origGet = engine.getLoaded;
engine.getLoaded = () => ({
modelId: 'fake',
friendlyId: 'qwen3.5-4b',
constant: 'QWEN3_5_4B_MULTIMODAL_Q4_K_M',
ctxSize: 8192,
});
engine.load = async () => engine.getLoaded();
return () => {
engine.complete = origComplete;
engine.load = origLoad;
engine.getLoaded = origGet;
};
}
async function testPlanWriteGateLoop() {
const tmp = tmpDir('bs-plan-');
process.env.BRIDGE_SWARM_STORAGE = tmp;
const engine = require('./qvac/engine.js');
const loop = require('./agent/loop.js');
const sessions = require('./agent/sessions.js');
const restore = mockEngine(engine);
let n = 0;
const results = [];
engine.complete = async () => {
n += 1;
if (n === 1) {
return {
text: '',
toolCalls: [
{ id: 'e', name: 'enter_plan_mode', arguments: {} },
{ id: 'w', name: 'write_file', arguments: { path: 'hello.js', contents: 'nope' } },
],
};
}
return { text: 'planned', toolCalls: [] };
};
const cwd = path.join(tmp, 'ws');
fs.mkdirSync(cwd, { recursive: true });
const session = sessions.create({
origin: 'http://127.0.0.1:4173',
cwd,
model: 'qwen3.5-4b',
title: 'plan',
});
const result = await loop.runTurn({
session,
userText: 'plan then write',
jobId: 'job_plan',
payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' },
emit(_t, p) {
if (p && p.type === 'tool_result') results.push(String(p.result || ''));
},
});
restore();
ok(result.ok, 'plan gate loop ok');
ok(results.some((r) => /Rejected|plan mode/i.test(r)), 'write to hello.js rejected');
ok(!fs.existsSync(path.join(cwd, 'hello.js')), 'hello.js not written');
}
async function testAskUserLoop() {
const tmp = tmpDir('bs-ask-');
process.env.BRIDGE_SWARM_STORAGE = tmp;
const engine = require('./qvac/engine.js');
const loop = require('./agent/loop.js');
const sessions = require('./agent/sessions.js');
const restore = mockEngine(engine);
let n = 0;
let choiceSeen = false;
engine.complete = async (opts) => {
n += 1;
if (n === 1) {
return {
text: '',
toolCalls: [{ id: 'a1', name: 'ask_user_question', arguments: { question: 'Pick', options: ['A', 'B'] } }],
};
}
const last = (opts.history || []).filter((m) => m.role === 'tool').pop();
choiceSeen = !!(last && /A/.test(String(last.content)));
return { text: 'chose A', toolCalls: [] };
};
const session = sessions.create({
origin: 'http://127.0.0.1:4173',
cwd: path.join(tmp, 'ws'),
model: 'qwen3.5-4b',
title: 'ask',
});
fs.mkdirSync(session.cwd, { recursive: true });
const result = await loop.runTurn({
session,
userText: 'ask me',
jobId: 'job_ask',
payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' },
emit(_t, p) {
if (p && p.type === 'ask_user') loop.resolveAsk('job_ask', p.toolCallId, 'A');
},
});
restore();
ok(result.ok, 'ask loop ok');
ok(n >= 2, 'waited then continued');
ok(choiceSeen, 'choice reached history');
}
async function testGoalPrematureLoop() {
const tmp = tmpDir('bs-goal-');
process.env.BRIDGE_SWARM_STORAGE = tmp;
const engine = require('./qvac/engine.js');
const loop = require('./agent/loop.js');
const sessions = require('./agent/sessions.js');
const restore = mockEngine(engine);
let n = 0;
let premature = false;
engine.complete = async (opts) => {
n += 1;
if (!opts.tools || !opts.tools.length) {
return { text: '{"achieved": true, "gaps": []}', toolCalls: [] };
}
if (n === 1) {
return { text: '', toolCalls: [{ id: 'g1', name: 'update_goal', arguments: { completed: true } }] };
}
const last = (opts.history || []).filter((m) => m.role === 'tool').pop();
premature = !!(last && /todos|not complete/i.test(String(last.content)));
return { text: 'still working', toolCalls: [] };
};
const cwd = path.join(tmp, 'ws');
fs.mkdirSync(cwd, { recursive: true });
const session = sessions.create({
origin: 'http://127.0.0.1:4173',
cwd,
model: 'qwen3.5-4b',
title: 'goal',
});
session.plan = [{ id: '1', content: 'write file', status: 'pending' }];
const result = await loop.runTurn({
session,
userText: 'finish the goal',
jobId: 'job_goal',
payload: {
_origin: 'http://127.0.0.1:4173',
permissionMode: 'always-approve',
goal: 'Ship hello.js',
verify: false,
},
emit() {},
});
restore();
ok(result.ok, 'goal loop ok');
ok(premature, 'update_goal rejected with open todos');
ok(n >= 2, 'continued after premature stop');
}
async function testStationarityLoop() {
const tmp = tmpDir('bs-stuck-');
process.env.BRIDGE_SWARM_STORAGE = tmp;
const engine = require('./qvac/engine.js');
const loop = require('./agent/loop.js');
const sessions = require('./agent/sessions.js');
const restore = mockEngine(engine);
engine.complete = async () => {
const calls = [];
for (let i = 0; i < 8; i++) {
calls.push({ id: 'c' + i, name: 'list_dir', arguments: { path: '.' } });
}
return { text: '', toolCalls: calls };
};
const cwd = path.join(tmp, 'ws');
fs.mkdirSync(cwd, { recursive: true });
const session = sessions.create({
origin: 'http://127.0.0.1:4173',
cwd,
model: 'qwen3.5-4b',
title: 'stuck',
});
const result = await loop.runTurn({
session,
userText: 'list forever',
jobId: 'job_stuck',
payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' },
emit() {},
});
restore();
ok(result.reason === 'stuck', 'stuck end reason');
}
+120
View File
@@ -359,6 +359,119 @@ function testListenErrors() {
testListenErrors();
function testPlanMode() {
const plan = require('../native-host/agent/plan-mode.js');
const pm = plan.create();
assert.strictEqual(pm.state, 'inactive');
assert.strictEqual(plan.isActive(pm), false);
plan.enterPending(pm);
assert.strictEqual(pm.state, 'pending');
plan.activate(pm);
assert.strictEqual(pm.state, 'active');
assert.strictEqual(plan.isActive(pm), true);
const full = plan.reminder(pm, { hasContent: false });
assert.ok(/plan\.md/.test(full));
assert.ok(/ask_user_question/.test(full) || /exit_plan_mode/.test(full));
const sparse = plan.reminder(pm, { hasContent: true });
assert.ok(/still active/.test(sparse));
assert.ok(plan.gateWrite(pm, 'write_file', { path: 'hello.js' }));
assert.strictEqual(plan.gateWrite(pm, 'write_file', { path: 'plan.md' }), null);
assert.ok(plan.gateWrite(pm, 'run_terminal_cmd', { command: 'ls' }));
assert.strictEqual(plan.gateWrite(pm, 'read_file', { path: 'hello.js' }), null);
plan.requestExit(pm);
assert.strictEqual(pm.state, 'exitPending');
plan.rejectExit(pm);
assert.strictEqual(pm.state, 'active');
plan.requestExit(pm);
plan.approveExit(pm);
assert.strictEqual(pm.state, 'inactive');
assert.strictEqual(plan.gateWrite(pm, 'write_file', { path: 'hello.js' }), null);
const restored = plan.create({ state: 'exitPending', awaitingApproval: true });
assert.strictEqual(restored.state, 'active');
}
testPlanMode();
function testTodosMerge() {
const todos = require('../native-host/agent/todos.js');
const a = todos.merge([], [{ id: '1', content: 'A', status: 'pending' }]);
const b = todos.merge(a, [{ id: '1', status: 'in_progress' }, { id: '2', content: 'B', status: 'done' }]);
assert.strictEqual(b.length, 2);
assert.strictEqual(b[0].status, 'in_progress');
assert.strictEqual(b[0].content, 'A');
assert.strictEqual(b[1].status, 'completed');
assert.strictEqual(todos.hasOpen(b), true);
const c = todos.merge(b, [{ id: '1', status: 'completed' }, { id: '2', status: 'cancelled' }]);
assert.strictEqual(todos.hasOpen(c), false);
const replaced = todos.merge(c, [{ id: 'x', content: 'only', status: 'pending' }], 'replace');
assert.strictEqual(replaced.length, 1);
assert.ok(/\[todos\]/.test(todos.formatBlock(replaced)));
}
testTodosMerge();
function testStationarity() {
const st = require('../native-host/agent/stationarity.js');
const s = st.create();
for (let i = 0; i < 4; i++) st.observe(s, 'read_file', { path: 'a' });
assert.strictEqual(st.shouldNudge(s), true);
assert.strictEqual(st.shouldStop(s), false);
st.markNudged(s);
assert.strictEqual(st.shouldNudge(s), false);
for (let i = 0; i < 4; i++) st.observe(s, 'read_file', { path: 'a' });
assert.strictEqual(st.shouldStop(s), true);
st.observe(s, 'read_file', { path: 'b' });
assert.strictEqual(st.shouldStop(s), false);
}
testStationarity();
function testGoalPremature() {
const goal = require('../native-host/agent/goal.js');
const todos = require('../native-host/agent/todos.js');
const g = goal.create('Ship hello.js');
assert.strictEqual(goal.isActive(g), true);
assert.ok(/update_goal/.test(goal.plannerAddendum(g)));
const open = todos.merge([], [{ id: '1', content: 'write', status: 'pending' }]);
assert.strictEqual(todos.hasOpen(open), true);
const v = goal.parseVerifier('{"achieved": false, "gaps": ["missing test"]}');
assert.strictEqual(v.achieved, false);
assert.strictEqual(v.gaps[0], 'missing test');
const ok = goal.parseVerifier('{"achieved": true, "gaps": []}');
assert.strictEqual(ok.achieved, true);
}
testGoalPremature();
function testPlanSchemaKeepsWrites() {
const toolSet = require('../native-host/agent/tool-set.js');
const schemas = [
{ name: 'write_file' },
{ name: 'search_replace' },
{ name: 'run_terminal_cmd' },
{ name: 'read_file' },
];
const on = toolSet.filterBuiltinSchemas(schemas, { planMode: true, hostWorkspace: true });
assert.strictEqual(on.some((t) => t.name === 'write_file'), true);
assert.strictEqual(on.some((t) => t.name === 'search_replace'), true);
assert.strictEqual(on.some((t) => t.name === 'run_terminal_cmd'), false);
assert.strictEqual(on.some((t) => t.name === 'read_file'), true);
}
testPlanSchemaKeepsWrites();
function testTaskWait() {
const tasks = require('../native-host/agent/tasks.js');
tasks.reset();
const id = tasks.create('t');
const p = tasks.waitAll({ timeoutMs: 2000 });
tasks.finish(id, 'done');
return p.then((r) => {
assert.strictEqual(r.running, 0);
assert.strictEqual(r.tasks[0].status, 'done');
});
}
function testRegistryHas() {
const registry = require('../native-host/capabilities/registry.js');
registry.registerPack({ id: 'qvac', commands: { ping() {} } });
@@ -387,4 +500,11 @@ function testSemverPatch() {
testSemverPatch();
testTaskWait()
.then(() => {
console.log('ok — host unit checks passed');
})
.catch((err) => {
console.error(err && err.stack ? err.stack : err);
process.exit(1);
});