+49
-5
@@ -10,8 +10,11 @@ const session = await BridgeSwarm.agent.create({
|
|||||||
cwd: 'default',
|
cwd: 'default',
|
||||||
permissionMode: 'ask' // ask | allowlist | always-approve
|
permissionMode: 'ask' // ask | allowlist | always-approve
|
||||||
})
|
})
|
||||||
session.on((ev) => { /* agent_message_chunk | tool_call | permission | end */ })
|
session.on((ev) => { /* agent_message_chunk | tool_call | permission | ask_user | plan_approval | end */ })
|
||||||
await session.prompt('List the workspace and summarize')
|
await session.prompt('List the workspace and summarize', {
|
||||||
|
planMode: false,
|
||||||
|
permissionMode: 'ask',
|
||||||
|
})
|
||||||
await session.cancel()
|
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`
|
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).
|
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
|
## 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
|
## 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).
|
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) |
|
| `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 |
|
| `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` |
|
| `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
|
### Capability events
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ await session.addTool({
|
|||||||
await session.prompt('Create hello.js then call page_time')
|
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);
|
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') {
|
} else if (p.type === 'end') {
|
||||||
turn.finish({ text: p.text, reason: p.reason || 'stop' });
|
turn.finish({ text: p.text, reason: p.reason || 'stop' });
|
||||||
turn = null;
|
turn = null;
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
<p class="bs-brand">BridgeSwarm</p>
|
<p class="bs-brand">BridgeSwarm</p>
|
||||||
<h1 class="bs-title">Agent Studio</h1>
|
<h1 class="bs-title">Agent Studio</h1>
|
||||||
<p class="bs-lede">
|
<p class="bs-lede">
|
||||||
Grok-class coding agent on QVAC. Thinking, tool calls, and permission prompts
|
Grok-class coding agent on QVAC. Thinking, tool calls, permission prompts,
|
||||||
stream in the transcript. Built-in tools can read/write the sandboxed workspace;
|
plan approval, and ask-user questions stream in the transcript. Built-in tools
|
||||||
this page also registers <code>page_time</code>.
|
can read/write the sandboxed workspace; this page also registers <code>page_time</code>.
|
||||||
</p>
|
</p>
|
||||||
<div id="status" class="bs-banner bs-banner--info">Starting…</div>
|
<div id="status" class="bs-banner bs-banner--info">Starting…</div>
|
||||||
<div class="bs-section">
|
<div class="bs-section">
|
||||||
|
|||||||
@@ -272,6 +272,46 @@
|
|||||||
scrollIfStuck(near);
|
scrollIfStuck(near);
|
||||||
return bar;
|
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) {
|
finish: function (info) {
|
||||||
info = info || {};
|
info = info || {};
|
||||||
if (finished) return;
|
if (finished) return;
|
||||||
|
|||||||
+21
-1
@@ -1326,7 +1326,14 @@
|
|||||||
model: r.model,
|
model: r.model,
|
||||||
prompt: function (text, opts) {
|
prompt: function (text, opts) {
|
||||||
opts = 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 () {
|
cancel: function () {
|
||||||
return capabilityCall('agent', 'cancel', { sessionId: sessionId });
|
return capabilityCall('agent', 'cancel', { sessionId: sessionId });
|
||||||
@@ -1346,6 +1353,19 @@
|
|||||||
decision: allow ? 'allow' : 'deny',
|
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) {
|
addTool: function (def) {
|
||||||
remember(def);
|
remember(def);
|
||||||
return capabilityCall('agent', 'registerTools', {
|
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,
|
||||||
|
};
|
||||||
+427
-63
@@ -12,6 +12,10 @@ const compaction = require('./compaction.js');
|
|||||||
const tasks = require('./tasks.js');
|
const tasks = require('./tasks.js');
|
||||||
const customTools = require('./custom-tools.js');
|
const customTools = require('./custom-tools.js');
|
||||||
const toolSet = require('./tool-set.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 path = require('bare-path');
|
||||||
const fs = require('bare-fs');
|
const fs = require('bare-fs');
|
||||||
|
|
||||||
@@ -20,8 +24,14 @@ customTools.setReserved(toolSet.ALWAYS_RESERVED.concat(toolSet.ALWAYS_BUILTIN_RE
|
|||||||
const live = new Map();
|
const live = new Map();
|
||||||
const pendingPerms = new Map();
|
const pendingPerms = new Map();
|
||||||
const pendingCustom = new Map();
|
const pendingCustom = new Map();
|
||||||
|
const pendingAsks = new Map();
|
||||||
|
const pendingPlans = new Map();
|
||||||
const MAX_TURNS = 24;
|
const MAX_TURNS = 24;
|
||||||
|
const SUBAGENT_TURNS = 8;
|
||||||
const CUSTOM_TOOL_TIMEOUT_MS = 60000;
|
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) {
|
function fsRead(cwd, rel) {
|
||||||
return fs.readFileSync(path.join(cwd, rel), 'utf8');
|
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));
|
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) {
|
async function waitPermission(jobId, payload) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
pendingPerms.set(jobId + ':' + payload.toolCallId, resolve);
|
pendingPerms.set(jobId + ':' + payload.toolCallId, resolve);
|
||||||
@@ -75,16 +122,155 @@ function waitCustomResult(jobId, toolCallId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveCustomResult(jobId, toolCallId, payload) {
|
function resolveCustomResult(jobId, toolCallId, payload) {
|
||||||
|
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;
|
const key = jobId + ':' + toolCallId;
|
||||||
const rec = pendingCustom.get(key);
|
if (pendingAsks.has(key)) return resolveKeyed(pendingAsks, key, payload);
|
||||||
const body = payload || {};
|
}
|
||||||
if (rec && typeof rec.resolve === 'function') {
|
const keys = Array.from(pendingAsks.keys());
|
||||||
pendingCustom.delete(key);
|
for (const key of keys) {
|
||||||
rec.resolve(body);
|
if (key === toolCallId || key.endsWith(':' + toolCallId)) return resolveKeyed(pendingAsks, key, payload);
|
||||||
return true;
|
}
|
||||||
|
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) {
|
async function runTurn(ctx) {
|
||||||
@@ -93,20 +279,31 @@ async function runTurn(ctx) {
|
|||||||
const hostWorkspace = session.hostWorkspace !== false;
|
const hostWorkspace = session.hostWorkspace !== false;
|
||||||
const cwd = hostWorkspace ? session.cwd || sandbox.defaultCwd(origin) : session.cwd || 'page';
|
const cwd = hostWorkspace ? session.cwd || sandbox.defaultCwd(origin) : session.cwd || 'page';
|
||||||
const mode = sandbox.permissionMode(payload);
|
const mode = sandbox.permissionMode(payload);
|
||||||
const toolDefs = tools
|
const tracker = planMode.create(session.planMode);
|
||||||
.defs({
|
if (payload && (payload.planMode === true || payload.planMode === 'active' || payload.planMode === 'plan')) {
|
||||||
planMode: ctx.planMode,
|
if (tracker.state === 'inactive') {
|
||||||
webFetch: payload && payload.webFetch,
|
planMode.enterPending(tracker);
|
||||||
hostWorkspace,
|
planMode.activate(tracker);
|
||||||
builtinTools: session.builtinTools,
|
}
|
||||||
})
|
}
|
||||||
.concat(customTools.defs(session.id));
|
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);
|
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({
|
const sys = prompts.assemble({
|
||||||
cwd: hostWorkspace ? cwd : session.workspace || cwd,
|
cwd: hostWorkspace ? cwd : session.workspace || cwd,
|
||||||
hostWorkspace,
|
hostWorkspace,
|
||||||
extra: payload && payload.system,
|
extra: extraSys,
|
||||||
fsRead: hostWorkspace
|
fsRead: hostWorkspace
|
||||||
? (c, r) => {
|
? (c, r) => {
|
||||||
try {
|
try {
|
||||||
@@ -117,28 +314,39 @@ async function runTurn(ctx) {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
refreshSystem(session, sys);
|
||||||
if (!session.history || !session.history.length) {
|
|
||||||
session.history = [{ role: 'system', content: sys }];
|
|
||||||
}
|
|
||||||
if (userText) {
|
if (userText) {
|
||||||
session.history.push({ role: 'user', content: userText });
|
pushHistory(session, { role: 'user', content: userText });
|
||||||
sessions.appendHistory(session.id, { 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 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++) {
|
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, {
|
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,
|
tools: toolDefs,
|
||||||
});
|
});
|
||||||
|
if (session.history.length !== beforeLen) {
|
||||||
|
sessions.replaceHistory(session.id, session.history);
|
||||||
|
}
|
||||||
|
|
||||||
emitUpdate(emit, session.id, jobId, { type: 'turn', turn });
|
emitUpdate(emit, session.id, jobId, { type: 'turn', turn });
|
||||||
|
const history = session.history.concat(sidecarMessages(session, tracker));
|
||||||
const result = await engine.complete(
|
const result = await engine.complete(
|
||||||
{
|
{
|
||||||
history: session.history,
|
history,
|
||||||
tools: toolDefs,
|
tools: toolDefs,
|
||||||
toolDialect: catalog.toolDialectFor(session.model),
|
toolDialect: catalog.toolDialectFor(session.model),
|
||||||
},
|
},
|
||||||
@@ -154,23 +362,49 @@ async function runTurn(ctx) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.text) {
|
if (result.text) {
|
||||||
session.history.push({ role: 'assistant', content: result.text });
|
lastText = result.text;
|
||||||
sessions.appendHistory(session.id, { role: 'assistant', content: result.text });
|
pushHistory(session, { role: 'assistant', content: result.text });
|
||||||
}
|
}
|
||||||
|
|
||||||
const calls = result.toolCalls || [];
|
const calls = result.toolCalls || [];
|
||||||
if (!calls.length) {
|
if (!calls.length) {
|
||||||
emitUpdate(emit, session.id, jobId, { type: 'end', reason: 'stop', text: result.text || '' });
|
const goalActive = goalMod.isActive(session.goal);
|
||||||
sessions.saveSummary(session);
|
if (goalActive && goalNudges < MAX_GOAL_NUDGES) {
|
||||||
return { ok: true, text: result.text || '', turns: turn + 1 };
|
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) {
|
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 name = call.name;
|
||||||
const args = typeof call.arguments === 'string' ? safeJson(call.arguments) : call.arguments || {};
|
const args = typeof call.arguments === 'string' ? safeJson(call.arguments) : call.arguments || {};
|
||||||
const toolCallId = call.id || name + '_' + Date.now();
|
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, {
|
emitUpdate(emit, session.id, jobId, {
|
||||||
type: 'permission',
|
type: 'permission',
|
||||||
toolCallId,
|
toolCallId,
|
||||||
@@ -181,16 +415,27 @@ async function runTurn(ctx) {
|
|||||||
const decision = await waitPermission(jobId, { toolCallId });
|
const decision = await waitPermission(jobId, { toolCallId });
|
||||||
if (decision !== 'allow') {
|
if (decision !== 'allow') {
|
||||||
const denied = 'permission denied for ' + name;
|
const denied = 'permission denied for ' + name;
|
||||||
session.history.push({ role: 'tool', name, content: denied, tool_call_id: toolCallId });
|
pushHistory(session, { 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 });
|
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: denied });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let out;
|
let out;
|
||||||
try {
|
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, {
|
emitUpdate(emit, session.id, jobId, {
|
||||||
type: 'tool_request',
|
type: 'tool_request',
|
||||||
toolCallId,
|
toolCallId,
|
||||||
@@ -207,28 +452,116 @@ async function runTurn(ctx) {
|
|||||||
out = await continueSubagent(ctx, rec, args.message);
|
out = await continueSubagent(ctx, rec, args.message);
|
||||||
} else {
|
} else {
|
||||||
out = await tools.execute(
|
out = await tools.execute(
|
||||||
{ origin, cwd, session, planMode: ctx.planMode, hostWorkspace },
|
{
|
||||||
|
origin,
|
||||||
|
cwd,
|
||||||
|
session,
|
||||||
|
planMode: planMode.isActive(tracker),
|
||||||
|
planTracker: tracker,
|
||||||
|
hostWorkspace,
|
||||||
|
},
|
||||||
name,
|
name,
|
||||||
args
|
args
|
||||||
);
|
);
|
||||||
if (out && out.planMode === true) ctx.planMode = true;
|
if (out && out.type === 'enter_plan_mode') {
|
||||||
if (out && out.planMode === false) ctx.planMode = false;
|
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') {
|
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) {
|
} catch (err) {
|
||||||
out = { error: err.message };
|
out = { error: err.message };
|
||||||
}
|
}
|
||||||
const rendered = typeof out === 'string' ? out : JSON.stringify(out).slice(0, 12000);
|
const rendered = typeof out === 'string' ? out : JSON.stringify(out).slice(0, 12000);
|
||||||
session.history.push({ role: 'tool', name, content: rendered, tool_call_id: toolCallId });
|
pushHistory(session, { 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) });
|
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) {
|
async function runSubagent(parentCtx, args) {
|
||||||
@@ -236,10 +569,9 @@ async function runSubagent(parentCtx, args) {
|
|||||||
const label = args.label || 'subagent';
|
const label = args.label || 'subagent';
|
||||||
const taskId = tasks.create(label);
|
const taskId = tasks.create(label);
|
||||||
const hostWorkspace = parentCtx.session.hostWorkspace !== false;
|
const hostWorkspace = parentCtx.session.hostWorkspace !== false;
|
||||||
|
const allowed = new Set(['read_file', 'grep', 'list_dir', 'memory_search', 'memory_get']);
|
||||||
const toolDefs = hostWorkspace
|
const toolDefs = hostWorkspace
|
||||||
? tools.defs({ planMode: true, webFetch: false, hostWorkspace: true }).filter((t) =>
|
? tools.defs({ webFetch: false, hostWorkspace: true }).filter((t) => allowed.has(t.name))
|
||||||
['read_file', 'grep', 'list_dir', 'memory_search'].includes(t.name)
|
|
||||||
)
|
|
||||||
: [];
|
: [];
|
||||||
await ensureModel(parentCtx.session.model);
|
await ensureModel(parentCtx.session.model);
|
||||||
const history = [
|
const history = [
|
||||||
@@ -252,10 +584,20 @@ async function runSubagent(parentCtx, args) {
|
|||||||
{ role: 'user', content: prompt },
|
{ role: 'user', content: prompt },
|
||||||
];
|
];
|
||||||
try {
|
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 });
|
const result = await engine.complete({ history, tools: toolDefs });
|
||||||
|
if (result.text) history.push({ role: 'assistant', content: result.text });
|
||||||
const calls = result.toolCalls || [];
|
const calls = result.toolCalls || [];
|
||||||
|
if (!calls.length) {
|
||||||
|
summary = result.text || summary;
|
||||||
|
break;
|
||||||
|
}
|
||||||
let extra = '';
|
let extra = '';
|
||||||
for (const call of calls.slice(0, 6)) {
|
for (const call of calls) {
|
||||||
try {
|
try {
|
||||||
const out = await tools.execute(
|
const out = await tools.execute(
|
||||||
{
|
{
|
||||||
@@ -267,17 +609,18 @@ async function runSubagent(parentCtx, args) {
|
|||||||
call.name,
|
call.name,
|
||||||
typeof call.arguments === 'string' ? safeJson(call.arguments) : call.arguments || {}
|
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) {
|
} 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;
|
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);
|
|
||||||
}
|
}
|
||||||
tasks.finish(taskId, summary);
|
tasks.finish(taskId, summary);
|
||||||
return { taskId, label, summary };
|
return { taskId, label, summary };
|
||||||
@@ -313,16 +656,37 @@ function markLive(sessionId) {
|
|||||||
return rec;
|
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) {
|
function cancel(sessionId) {
|
||||||
const rec = live.get(sessionId);
|
const rec = live.get(sessionId);
|
||||||
if (rec) rec.cancelled = true;
|
if (rec) rec.cancelled = true;
|
||||||
engine.cancel().catch(() => {});
|
engine.cancel().catch(() => {});
|
||||||
const keys = Array.from(pendingCustom.keys());
|
flushPending(pendingCustom, { error: 'cancelled' });
|
||||||
for (const key of keys) {
|
flushPending(pendingAsks, { error: 'cancelled' });
|
||||||
const rec2 = pendingCustom.get(key);
|
flushPending(pendingPlans, { decision: 'reject' });
|
||||||
pendingCustom.delete(key);
|
const permKeys = Array.from(pendingPerms.keys());
|
||||||
if (rec2 && typeof rec2.resolve === 'function') rec2.resolve({ error: 'cancelled' });
|
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(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
plan: [],
|
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, 'summary.json'), JSON.stringify(summary, null, 2));
|
||||||
fs.writeFileSync(path.join(dir, 'chat_history.jsonl'), '');
|
fs.writeFileSync(path.join(dir, 'chat_history.jsonl'), '');
|
||||||
@@ -78,7 +80,10 @@ function load(id) {
|
|||||||
|
|
||||||
function saveSummary(summary) {
|
function saveSummary(summary) {
|
||||||
summary.updatedAt = Date.now();
|
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) {
|
function appendHistory(id, msg) {
|
||||||
@@ -95,6 +100,24 @@ function replaceHistory(id, history) {
|
|||||||
fs.writeFileSync(file, body ? body + '\n' : '');
|
fs.writeFileSync(file, body ? body + '\n' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function planFile(id) {
|
||||||
|
return path.join(sessionDir(id), 'plan.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPlan(id) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(planFile(id), 'utf8');
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePlan(id, text) {
|
||||||
|
const file = planFile(id);
|
||||||
|
fs.writeFileSync(file, String(text != null ? text : ''));
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
function list() {
|
function list() {
|
||||||
const root = sessionsRoot();
|
const root = sessionsRoot();
|
||||||
let names = [];
|
let names = [];
|
||||||
@@ -121,6 +144,10 @@ module.exports = {
|
|||||||
appendHistory,
|
appendHistory,
|
||||||
appendUpdate,
|
appendUpdate,
|
||||||
replaceHistory,
|
replaceHistory,
|
||||||
|
sessionDir,
|
||||||
|
planFile,
|
||||||
|
readPlan,
|
||||||
|
writePlan,
|
||||||
list,
|
list,
|
||||||
makeId,
|
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 tasks = new Map();
|
||||||
|
const waiters = [];
|
||||||
|
|
||||||
function makeId() {
|
function makeId() {
|
||||||
return 'task_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
|
return 'task_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
|
||||||
@@ -21,6 +22,12 @@ function create(label) {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flushWaiters() {
|
||||||
|
for (let i = waiters.length - 1; i >= 0; i--) {
|
||||||
|
if (waiters[i]()) waiters.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function finish(id, summary) {
|
function finish(id, summary) {
|
||||||
const t = tasks.get(id);
|
const t = tasks.get(id);
|
||||||
if (t) {
|
if (t) {
|
||||||
@@ -28,6 +35,7 @@ function finish(id, summary) {
|
|||||||
t.summary = summary;
|
t.summary = summary;
|
||||||
t.updatedAt = Date.now();
|
t.updatedAt = Date.now();
|
||||||
}
|
}
|
||||||
|
flushWaiters();
|
||||||
return t || null;
|
return t || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +46,7 @@ function fail(id, err) {
|
|||||||
t.summary = String(err || 'error');
|
t.summary = String(err || 'error');
|
||||||
t.updatedAt = Date.now();
|
t.updatedAt = Date.now();
|
||||||
}
|
}
|
||||||
|
flushWaiters();
|
||||||
return t || null;
|
return t || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +63,24 @@ function list() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitAll() {
|
function waitAll(opts) {
|
||||||
const running = list().filter((t) => t.status === 'running');
|
opts = opts || {};
|
||||||
return { running: running.length, tasks: list() };
|
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) {
|
function kill(id) {
|
||||||
@@ -65,6 +89,7 @@ function kill(id) {
|
|||||||
t.status = 'killed';
|
t.status = 'killed';
|
||||||
t.updatedAt = Date.now();
|
t.updatedAt = Date.now();
|
||||||
}
|
}
|
||||||
|
flushWaiters();
|
||||||
return t || null;
|
return t || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +103,7 @@ function appendMessage(id, text) {
|
|||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
tasks.clear();
|
tasks.clear();
|
||||||
|
waiters.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { create, finish, fail, get, list, waitAll, kill, appendMessage, reset };
|
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',
|
'run_terminal_cmd',
|
||||||
'memory_search',
|
'memory_search',
|
||||||
'memory_get',
|
'memory_get',
|
||||||
|
'memory_write',
|
||||||
];
|
];
|
||||||
|
|
||||||
const HOST_WORKSPACE_SET = new Set(HOST_WORKSPACE_TOOLS);
|
const HOST_WORKSPACE_SET = new Set(HOST_WORKSPACE_TOOLS);
|
||||||
@@ -25,6 +26,8 @@ const ALWAYS_BUILTIN_RESERVED = [
|
|||||||
'enter_plan_mode',
|
'enter_plan_mode',
|
||||||
'exit_plan_mode',
|
'exit_plan_mode',
|
||||||
'ask_user_question',
|
'ask_user_question',
|
||||||
|
'update_goal',
|
||||||
|
'memory_write',
|
||||||
'task',
|
'task',
|
||||||
'send_subagent_message',
|
'send_subagent_message',
|
||||||
'get_task_output',
|
'get_task_output',
|
||||||
@@ -54,7 +57,8 @@ function filterBuiltinSchemas(schemas, opts) {
|
|||||||
list = list.filter((t) => allow.has(t.name));
|
list = list.filter((t) => allow.has(t.name));
|
||||||
}
|
}
|
||||||
if (opts.planMode) {
|
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) {
|
if (opts.webFetch !== true) {
|
||||||
list = list.filter((t) => t.name !== 'web_fetch');
|
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 sandbox = require('./sandbox.js');
|
||||||
const memory = require('./memory.js');
|
const memory = require('./memory.js');
|
||||||
const toolSet = require('./tool-set.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_READ = 400 * 1024;
|
||||||
const MAX_GREP_HITS = 50;
|
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: '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: '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: '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_search', description: 'Search the public web (DuckDuckGo HTML).', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } },
|
||||||
{ type: 'function', name: 'web_fetch', description: 'Fetch a public http(s) URL as text. Off unless enabled.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } },
|
{ type: 'function', name: 'web_fetch', description: 'Fetch 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_search', description: 'Search local agent memory notes.', parameters: { type: 'object', properties: { query: { type: 'string' } } } },
|
||||||
{ type: 'function', name: 'memory_get', description: 'Read a memory note by name.', parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
|
{ type: 'function', name: 'memory_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: '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: 'exit_plan_mode', description: 'Exit plan mode.', parameters: { type: 'object', properties: {} } },
|
{ 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: '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: '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: '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: '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: '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: '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'] } },
|
{ 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 origin = ctx.origin;
|
||||||
const cwd = ctx.cwd;
|
const cwd = ctx.cwd;
|
||||||
args = args || {};
|
args = args || {};
|
||||||
|
const blocked = planMode.gateWrite(ctx.planTracker || ctx.planMode, name, args);
|
||||||
|
if (blocked) return blocked;
|
||||||
if (toolSet.isHostWorkspaceTool(name) && ctx.hostWorkspace === false) {
|
if (toolSet.isHostWorkspaceTool(name) && ctx.hostWorkspace === false) {
|
||||||
throw new Error('host workspace tools are disabled for this session');
|
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);
|
return runShell(cwd, args.command, args.timeout_ms || args.timeoutMs);
|
||||||
}
|
}
|
||||||
case 'todo_write': {
|
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);
|
require('./sessions.js').saveSummary(ctx.session);
|
||||||
return { ok: true, todos: ctx.session.plan };
|
return { ok: true, todos: ctx.session.plan };
|
||||||
}
|
}
|
||||||
@@ -237,20 +245,55 @@ async function execute(ctx, name, args) {
|
|||||||
return memory.search(origin, args.query);
|
return memory.search(origin, args.query);
|
||||||
case 'memory_get':
|
case 'memory_get':
|
||||||
return memory.readNote(origin, args.name);
|
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;
|
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':
|
case 'exit_plan_mode':
|
||||||
ctx.planMode = false;
|
return { type: 'exit_plan_mode' };
|
||||||
return { planMode: false };
|
|
||||||
case 'ask_user_question':
|
case 'ask_user_question':
|
||||||
return { type: 'ask_user', question: args.question, options: args.options || [] };
|
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':
|
case 'send_subagent_message':
|
||||||
return require('./tasks.js').appendMessage(args.task_id || args.taskId, args.message);
|
return require('./tasks.js').appendMessage(args.task_id || args.taskId, args.message);
|
||||||
case 'get_task_output':
|
case 'get_task_output':
|
||||||
return require('./tasks.js').get(args.task_id || args.taskId);
|
return require('./tasks.js').get(args.task_id || args.taskId);
|
||||||
case 'wait_tasks':
|
case 'wait_tasks':
|
||||||
return require('./tasks.js').waitAll();
|
return require('./tasks.js').waitAll({ timeoutMs: args.timeout_ms || args.timeoutMs });
|
||||||
case 'kill_task':
|
case 'kill_task':
|
||||||
return require('./tasks.js').kill(args.task_id || args.taskId);
|
return require('./tasks.js').kill(args.task_id || args.taskId);
|
||||||
case 'search_tool':
|
case 'search_tool':
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ function createAgentPack() {
|
|||||||
model: p.model || 'qwen3.5-4b',
|
model: p.model || 'qwen3.5-4b',
|
||||||
title: p.title || 'New session',
|
title: p.title || 'New session',
|
||||||
sessionId: p.sessionId,
|
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 });
|
customTools.setSession(summary.id, { hostWorkspace });
|
||||||
if (p.tools) {
|
if (p.tools) {
|
||||||
@@ -104,7 +108,7 @@ function createAgentPack() {
|
|||||||
emit,
|
emit,
|
||||||
jobId,
|
jobId,
|
||||||
payload,
|
payload,
|
||||||
planMode: !!payload.planMode,
|
planMode: payload.planMode,
|
||||||
});
|
});
|
||||||
emit('cap-end', { pack: 'agent', jobId, sessionId: session.id, ...result });
|
emit('cap-end', { pack: 'agent', jobId, sessionId: session.id, ...result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -122,6 +126,18 @@ function createAgentPack() {
|
|||||||
loop.resolvePermission(p.jobId, p.toolCallId, p.decision || p.allow ? 'allow' : 'deny');
|
loop.resolvePermission(p.jobId, p.toolCallId, p.decision || p.allow ? 'allow' : 'deny');
|
||||||
ctx.reply({ ok: true });
|
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) {
|
async registerTools(ctx) {
|
||||||
if (refuseDisabled(ctx)) return;
|
if (refuseDisabled(ctx)) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ testPathJail();
|
|||||||
testAgentLoopFakeComplete()
|
testAgentLoopFakeComplete()
|
||||||
.then(() => testCustomToolLoop())
|
.then(() => testCustomToolLoop())
|
||||||
.then(() => testHostWorkspaceOffLoop())
|
.then(() => testHostWorkspaceOffLoop())
|
||||||
|
.then(() => testPlanWriteGateLoop())
|
||||||
|
.then(() => testAskUserLoop())
|
||||||
|
.then(() => testGoalPrematureLoop())
|
||||||
|
.then(() => testStationarityLoop())
|
||||||
.then(() => testBareVersionsCoerce())
|
.then(() => testBareVersionsCoerce())
|
||||||
.then(() => testQvacOffByDefault())
|
.then(() => testQvacOffByDefault())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -316,3 +320,190 @@ async function testHostWorkspaceOffLoop() {
|
|||||||
ok(firstNames.indexOf('grep') === -1, 'host grep not offered');
|
ok(firstNames.indexOf('grep') === -1, 'host grep not offered');
|
||||||
ok(n >= 2, 'custom read_file then final');
|
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();
|
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() {
|
function testRegistryHas() {
|
||||||
const registry = require('../native-host/capabilities/registry.js');
|
const registry = require('../native-host/capabilities/registry.js');
|
||||||
registry.registerPack({ id: 'qvac', commands: { ping() {} } });
|
registry.registerPack({ id: 'qvac', commands: { ping() {} } });
|
||||||
@@ -387,4 +500,11 @@ function testSemverPatch() {
|
|||||||
|
|
||||||
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