+49
-5
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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', {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
+508
-144
@@ -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,118 +314,254 @@ 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;
|
||||
|
||||
for (let turn = 0; turn < MAX_TURNS; turn++) {
|
||||
if (cancelled()) throw new Error('cancelled');
|
||||
session.history = compaction.compact(session.history, {
|
||||
budgetTokens: Math.floor(((engine.getLoaded().ctxSize || 8192) * 0.8)),
|
||||
tools: toolDefs,
|
||||
});
|
||||
|
||||
emitUpdate(emit, session.id, jobId, { type: 'turn', turn });
|
||||
const result = await engine.complete(
|
||||
{
|
||||
history: session.history,
|
||||
try {
|
||||
for (let turn = 0; turn < MAX_TURNS; turn++) {
|
||||
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),
|
||||
tools: toolDefs,
|
||||
toolDialect: catalog.toolDialectFor(session.model),
|
||||
},
|
||||
(ev) => {
|
||||
if (ev.type === 'contentDelta') {
|
||||
emitUpdate(emit, session.id, jobId, { type: 'agent_message_chunk', text: ev.delta });
|
||||
} else if (ev.type === 'thinkingDelta') {
|
||||
emitUpdate(emit, session.id, jobId, { type: 'agent_thought_chunk', text: ev.delta });
|
||||
} else if (ev.type === 'toolCall') {
|
||||
emitUpdate(emit, session.id, jobId, { type: 'tool_call', call: ev.call });
|
||||
}
|
||||
});
|
||||
if (session.history.length !== beforeLen) {
|
||||
sessions.replaceHistory(session.id, session.history);
|
||||
}
|
||||
);
|
||||
|
||||
if (result.text) {
|
||||
session.history.push({ role: 'assistant', content: result.text });
|
||||
sessions.appendHistory(session.id, { 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 };
|
||||
}
|
||||
|
||||
for (const call of calls) {
|
||||
if (cancelled()) throw new Error('cancelled');
|
||||
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)) {
|
||||
emitUpdate(emit, session.id, jobId, {
|
||||
type: 'permission',
|
||||
toolCallId,
|
||||
tool: name,
|
||||
args,
|
||||
});
|
||||
if (mode !== 'always-approve') {
|
||||
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 });
|
||||
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: denied });
|
||||
continue;
|
||||
emitUpdate(emit, session.id, jobId, { type: 'turn', turn });
|
||||
const history = session.history.concat(sidecarMessages(session, tracker));
|
||||
const result = await engine.complete(
|
||||
{
|
||||
history,
|
||||
tools: toolDefs,
|
||||
toolDialect: catalog.toolDialectFor(session.model),
|
||||
},
|
||||
(ev) => {
|
||||
if (ev.type === 'contentDelta') {
|
||||
emitUpdate(emit, session.id, jobId, { type: 'agent_message_chunk', text: ev.delta });
|
||||
} else if (ev.type === 'thinkingDelta') {
|
||||
emitUpdate(emit, session.id, jobId, { type: 'agent_thought_chunk', text: ev.delta });
|
||||
} else if (ev.type === 'toolCall') {
|
||||
emitUpdate(emit, session.id, jobId, { type: 'tool_call', call: ev.call });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (result.text) {
|
||||
lastText = result.text;
|
||||
pushHistory(session, { role: 'assistant', content: result.text });
|
||||
}
|
||||
let out;
|
||||
try {
|
||||
if (customTools.has(session.id, name)) {
|
||||
|
||||
const calls = result.toolCalls || [];
|
||||
if (!calls.length) {
|
||||
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()) 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();
|
||||
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: 'tool_request',
|
||||
type: 'permission',
|
||||
toolCallId,
|
||||
name,
|
||||
tool: name,
|
||||
args,
|
||||
});
|
||||
const answered = await waitCustomResult(jobId, toolCallId);
|
||||
if (answered && answered.error) throw new Error(answered.error);
|
||||
out = answered && answered.result != null ? answered.result : answered;
|
||||
} else if (name === 'task') {
|
||||
out = await runSubagent(ctx, args);
|
||||
} else if (name === 'send_subagent_message') {
|
||||
const rec = tasks.appendMessage(args.task_id || args.taskId, args.message);
|
||||
out = await continueSubagent(ctx, rec, args.message);
|
||||
} else {
|
||||
out = await tools.execute(
|
||||
{ origin, cwd, session, planMode: ctx.planMode, hostWorkspace },
|
||||
name,
|
||||
args
|
||||
);
|
||||
if (out && out.planMode === true) ctx.planMode = true;
|
||||
if (out && out.planMode === false) ctx.planMode = false;
|
||||
if (out && out.type === 'ask_user') {
|
||||
emitUpdate(emit, session.id, jobId, { type: 'ask_user', question: out.question, options: out.options });
|
||||
if (mode !== 'always-approve') {
|
||||
const decision = await waitPermission(jobId, { toolCallId });
|
||||
if (decision !== 'allow') {
|
||||
const denied = 'permission denied for ' + name;
|
||||
pushHistory(session, { role: 'tool', name, content: denied, tool_call_id: toolCallId });
|
||||
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: denied });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
out = { error: err.message };
|
||||
|
||||
let out;
|
||||
try {
|
||||
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,
|
||||
name,
|
||||
args,
|
||||
});
|
||||
const answered = await waitCustomResult(jobId, toolCallId);
|
||||
if (answered && answered.error) throw new Error(answered.error);
|
||||
out = answered && answered.result != null ? answered.result : answered;
|
||||
} else if (name === 'task') {
|
||||
out = await runSubagent(ctx, args);
|
||||
} else if (name === 'send_subagent_message') {
|
||||
const rec = tasks.appendMessage(args.task_id || args.taskId, args.message);
|
||||
out = await continueSubagent(ctx, rec, args.message);
|
||||
} else {
|
||||
out = await tools.execute(
|
||||
{
|
||||
origin,
|
||||
cwd,
|
||||
session,
|
||||
planMode: planMode.isActive(tracker),
|
||||
planTracker: tracker,
|
||||
hostWorkspace,
|
||||
},
|
||||
name,
|
||||
args
|
||||
);
|
||||
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',
|
||||
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);
|
||||
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() });
|
||||
}
|
||||
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 });
|
||||
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: rendered.slice(0, 4000) });
|
||||
}
|
||||
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,32 +584,43 @@ async function runSubagent(parentCtx, args) {
|
||||
{ role: 'user', content: prompt },
|
||||
];
|
||||
try {
|
||||
const result = await engine.complete({ history, tools: toolDefs });
|
||||
const calls = result.toolCalls || [];
|
||||
let extra = '';
|
||||
for (const call of calls.slice(0, 6)) {
|
||||
try {
|
||||
const out = await tools.execute(
|
||||
{
|
||||
origin: parentCtx.session.origin,
|
||||
cwd: parentCtx.session.cwd,
|
||||
session: parentCtx.session,
|
||||
hostWorkspace: parentCtx.session.hostWorkspace !== false,
|
||||
},
|
||||
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);
|
||||
} catch (err) {
|
||||
extra += '\n[' + call.name + ' error] ' + err.message;
|
||||
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');
|
||||
}
|
||||
}
|
||||
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);
|
||||
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) {
|
||||
try {
|
||||
const out = await tools.execute(
|
||||
{
|
||||
origin: parentCtx.session.origin,
|
||||
cwd: parentCtx.session.cwd,
|
||||
session: parentCtx.session,
|
||||
hostWorkspace: parentCtx.session.hostWorkspace !== false,
|
||||
},
|
||||
call.name,
|
||||
typeof call.arguments === 'string' ? safeJson(call.arguments) : call.arguments || {}
|
||||
);
|
||||
extra = typeof out === 'string' ? out : JSON.stringify(out);
|
||||
} catch (err) {
|
||||
extra = 'error: ' + err.message;
|
||||
}
|
||||
history.push({
|
||||
role: 'tool',
|
||||
name: call.name,
|
||||
content: String(extra).slice(0, 8000),
|
||||
tool_call_id: call.id,
|
||||
});
|
||||
}
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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':
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
+121
-1
@@ -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();
|
||||
|
||||
console.log('ok — host unit checks passed');
|
||||
testTaskWait()
|
||||
.then(() => {
|
||||
console.log('ok — host unit checks passed');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err && err.stack ? err.stack : err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user