diff --git a/docs/AGENT.md b/docs/AGENT.md index 5d9b04b..34cfe9b 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -10,8 +10,11 @@ const session = await BridgeSwarm.agent.create({ cwd: 'default', permissionMode: 'ask' // ask | allowlist | always-approve }) -session.on((ev) => { /* agent_message_chunk | tool_call | permission | end */ }) -await session.prompt('List the workspace and summarize') +session.on((ev) => { /* agent_message_chunk | tool_call | permission | ask_user | plan_approval | end */ }) +await session.prompt('List the workspace and summarize', { + planMode: false, + permissionMode: 'ask', +}) await session.cancel() ``` @@ -21,7 +24,7 @@ Always-approve is only honored for origins listed under **Settings → Agent alw Must-have: `read_file`, `write_file`, `search_replace`, `grep`, `list_dir`, `run_terminal_cmd`, `todo_write` -Also: `task` / `send_subagent_message` / `get_task_output` / `wait_tasks` / `kill_task` (subagents share the loaded model), `web_search`, `web_fetch` (opt-in), `memory_search` / `memory_get`, `enter_plan_mode` / `exit_plan_mode`, `ask_user_question`, `search_tool` / `use_tool` (MCP HTTP only; stdio requires a trusted origin and is not spawned by default) +Also: `task` / `send_subagent_message` / `get_task_output` / `wait_tasks` / `kill_task` (subagents share the loaded model; `wait_tasks` actually waits), `web_search`, `web_fetch` (opt-in), `memory_search` / `memory_get` / `memory_write`, `enter_plan_mode` / `exit_plan_mode`, `ask_user_question`, `update_goal`, `search_tool` / `use_tool` (MCP HTTP only; stdio requires a trusted origin and is not spawned by default) Shell is cwd-jailed via `bare-subprocess` (host-internal, not a page pack). MCP HTTP tools must be public URLs (`net` policy). @@ -97,10 +100,51 @@ Default cwd: `$BRIDGE_SWARM_STORAGE/agent//`. Extra absolute roots: ## Sessions -JSONL under `$BRIDGE_SWARM_STORAGE/agent/sessions/`. History is compacted near 80% of the model context. +JSONL under `$BRIDGE_SWARM_STORAGE/agent/sessions/`. History is compacted near 80% of the model context; compacted history is rewritten so a reload does not restore the uncompacted log. `plan.md`, plan-mode snapshot, and goal status live on the session summary. ## ACP-shaped events -Host emits `cap-chunk` with `pack: 'agent'` and `type` in `agent_message_chunk`, `agent_thought_chunk`, `tool_call`, `tool_result`, `permission`, `ask_user`, `end`. +Host emits `cap-chunk` with `pack: 'agent'` and `type` in `agent_message_chunk`, `agent_thought_chunk`, `tool_call`, `tool_result`, `permission`, `ask_user`, `plan_approval`, `plan_update`, `goal_update`, `end`. + +`end.reason` is one of `stop` | `max_turns` | `stuck` | `cancelled` | `goal_complete` | `goal_blocked`. + +## Plan mode + +`enter_plan_mode` / `session.prompt(text, { planMode: true })` switches the session to plan mode. While active: + +- `run_terminal_cmd` is not offered; `write_file` / `search_replace` may only edit the session `plan.md` (host session dir, streamed as `plan_update`). +- Other writes are rejected at execute time, including page-registered `write_file` / `search_replace` / `run_terminal_cmd`. +- The model is reminded to end the turn with `ask_user_question` or `exit_plan_mode`. + +`exit_plan_mode` emits `plan_approval` and **blocks** until the page calls `session.planDecision('approve' | 'reject')`. Approve injects “plan approved, implement it”; reject stays in plan mode. + +```js +session.on((ev) => { + if (ev.type === 'plan_approval') { + session.planDecision('approve') + } +}) +await session.prompt('Plan a README then implement it', { planMode: true }) +``` + +## Ask the user + +`ask_user_question` emits `ask_user` and **waits**. Reply with `session.answer(toolCallId, choice)`. + +```js +session.on((ev) => { + if (ev.type === 'ask_user') session.answer(ev.toolCallId, ev.options[0]) +}) +``` + +## Goals + +Done is **not** the model stopping tool calls. Start a goal with `create({ goal: '…' })` or `prompt(text, { goal: '…' })`. The agent must `update_goal({ completed: true })` after todos are finished. Open todos reject that call (premature stop). A single verifier `complete` then either ends with `reason: 'goal_complete'` or injects gaps and continues. `update_goal({ blocked_reason })` ends with `goal_blocked`. Pass `{ verify: false }` to skip the verifier. + +If a goal is active and the model stops with open work, the loop injects a continuation instead of `end stop`. + +## Prompt options + +`session.prompt(text, { planMode, permissionMode, system, webFetch, goal, verify })` forwards those fields to the host. Models, devices, enable toggle, and `BridgeSwarm.qvac` chat (page-owned tools, no workspace shell) are documented in [QVAC.md](QVAC.md). diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index 41133fc..2d3d266 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -173,7 +173,7 @@ Full details and examples: [DATA-API.md](DATA-API.md). | `sqlite.open` / `close` / `list` / `exec` / `query` / `get` / `run` | db under `sqlite/` | see [CAPABILITIES.md](CAPABILITIES.md) | | `net.fetch` | `{ url, method?, as? }` | public http(s) only | | `qvac.detect` / `status` / `resources` / `catalog` / `load` / `complete` / `chat` / `embed` / … | see [QVAC.md](QVAC.md) | origin-gated; off until Settings → Enable QVAC; streams `cap-chunk` | -| `agent.create` / `prompt` / `cancel` / `permission` / `mcpRegister` | see [AGENT.md](AGENT.md) | origin-gated; host workspace tools unless `hostWorkspace: false` | +| `agent.create` / `prompt` / `cancel` / `permission` / `planDecision` / `answer` / `mcpRegister` | see [AGENT.md](AGENT.md) | origin-gated; host workspace tools unless `hostWorkspace: false` | ### Capability events diff --git a/examples/agent-studio/README.md b/examples/agent-studio/README.md index 82ef125..c2107d1 100644 --- a/examples/agent-studio/README.md +++ b/examples/agent-studio/README.md @@ -15,4 +15,4 @@ await session.addTool({ await session.prompt('Create hello.js then call page_time') ``` -This demo registers `page_time` on every new session. Built-in workspace tools stay on (`hostWorkspace` defaults to `true`). For a page- or container-owned workspace, pass `hostWorkspace: false` and register your own `read_file` / `run_terminal_cmd` handlers — see [AGENT.md](../../docs/AGENT.md). +This demo registers `page_time` on every new session. Permission prompts, plan Approve/Reject, and in-thread `ask_user_question` choices appear in the transcript. Built-in workspace tools stay on (`hostWorkspace` defaults to `true`). For a page- or container-owned workspace, pass `hostWorkspace: false` and register your own `read_file` / `run_terminal_cmd` handlers — see [AGENT.md](../../docs/AGENT.md). Pass `{ planMode: true }` or `{ goal: '…' }` on `prompt` for plan/act and goal completion. diff --git a/examples/agent-studio/app.js b/examples/agent-studio/app.js index 3ca7a5f..bdd96f3 100644 --- a/examples/agent-studio/app.js +++ b/examples/agent-studio/app.js @@ -68,6 +68,27 @@ session.permit(p.jobId, p.toolCallId, false); }, }); + } else if (p.type === 'ask_user') { + turn.askChoice({ + question: p.question || 'The agent has a question.', + options: p.options && p.options.length ? p.options : ['Continue'], + onPick: function (choice) { + session.answer(p.toolCallId, choice); + }, + }); + } else if (p.type === 'plan_approval') { + turn.askChoice({ + question: 'Approve this plan and start implementing?', + options: ['Approve', 'Reject'], + onPick: function (choice) { + session.planDecision(choice === 'Approve' ? 'approve' : 'reject'); + }, + }); + if (p.plan) transcript.addSys('Plan ready for approval', 'ok'); + } else if (p.type === 'plan_update') { + transcript.addSys('plan.md updated', 'ok'); + } else if (p.type === 'goal_update' && p.goal) { + transcript.addSys('Goal ' + (p.goal.status || '') + (p.goal.objective ? ': ' + String(p.goal.objective).slice(0, 72) : ''), 'turn'); } else if (p.type === 'end') { turn.finish({ text: p.text, reason: p.reason || 'stop' }); turn = null; diff --git a/examples/agent-studio/index.html b/examples/agent-studio/index.html index e309247..655dce8 100644 --- a/examples/agent-studio/index.html +++ b/examples/agent-studio/index.html @@ -18,9 +18,9 @@

BridgeSwarm

Agent Studio

- Grok-class coding agent on QVAC. Thinking, tool calls, and permission prompts - stream in the transcript. Built-in tools can read/write the sandboxed workspace; - this page also registers page_time. + Grok-class coding agent on QVAC. Thinking, tool calls, permission prompts, + plan approval, and ask-user questions stream in the transcript. Built-in tools + can read/write the sandboxed workspace; this page also registers page_time.

Starting…
diff --git a/examples/shared/ai-transcript.js b/examples/shared/ai-transcript.js index 234f716..09cb962 100644 --- a/examples/shared/ai-transcript.js +++ b/examples/shared/ai-transcript.js @@ -272,6 +272,46 @@ scrollIfStuck(near); return bar; }, + askChoice: function (choiceOpts) { + choiceOpts = choiceOpts || {}; + var near = stick(); + var bar = document.createElement('div'); + bar.className = 'ai-perm'; + var label = document.createElement('div'); + label.className = 'ai-perm-label'; + label.textContent = choiceOpts.question || 'Choose:'; + bar.appendChild(label); + var actions = document.createElement('div'); + actions.className = 'ai-perm-actions'; + var opts = Array.isArray(choiceOpts.options) && choiceOpts.options.length + ? choiceOpts.options + : ['OK']; + var buttons = []; + function finishPick(value, ok) { + for (var i = 0; i < buttons.length; i++) buttons[i].disabled = true; + bar.classList.add(ok === false ? 'ai-perm--deny' : 'ai-perm--allow'); + if (choiceOpts.onPick) choiceOpts.onPick(value); + } + for (var i = 0; i < opts.length; i++) { + (function (opt, idx) { + var btn = document.createElement('button'); + var labelText = typeof opt === 'string' ? opt : (opt.label || opt.id || String(opt)); + var value = typeof opt === 'string' ? opt : (opt.value != null ? opt.value : opt.id || labelText); + btn.className = idx === 0 ? 'primary' : ''; + btn.textContent = labelText; + btn.onclick = function () { + var deny = /reject|deny|no/i.test(String(labelText)); + finishPick(value, !deny); + }; + buttons.push(btn); + actions.appendChild(btn); + })(opts[i], i); + } + bar.appendChild(actions); + wrap.appendChild(bar); + scrollIfStuck(near); + return bar; + }, finish: function (info) { info = info || {}; if (finished) return; diff --git a/extension/api.js b/extension/api.js index c530786..3c2f640 100644 --- a/extension/api.js +++ b/extension/api.js @@ -1326,7 +1326,14 @@ model: r.model, prompt: function (text, opts) { opts = opts || {}; - return capabilityCall('agent', 'prompt', { sessionId: sessionId, text: text }, Object.assign({ timeoutMs: 0 }, opts)); + const payload = { sessionId: sessionId, text: text }; + if (opts.planMode != null) payload.planMode = opts.planMode; + if (opts.permissionMode) payload.permissionMode = opts.permissionMode; + if (opts.system) payload.system = opts.system; + if (opts.webFetch != null) payload.webFetch = opts.webFetch; + if (opts.goal != null) payload.goal = opts.goal; + if (opts.verify != null) payload.verify = opts.verify; + return capabilityCall('agent', 'prompt', payload, Object.assign({ timeoutMs: 0 }, opts)); }, cancel: function () { return capabilityCall('agent', 'cancel', { sessionId: sessionId }); @@ -1346,6 +1353,19 @@ decision: allow ? 'allow' : 'deny', }); }, + planDecision: function (decision) { + return capabilityCall('agent', 'planDecision', { + sessionId: sessionId, + decision: decision === 'approve' || decision === true ? 'approve' : 'reject', + }); + }, + answer: function (toolCallId, choice) { + return capabilityCall('agent', 'answer', { + sessionId: sessionId, + toolCallId: toolCallId, + choice: choice, + }); + }, addTool: function (def) { remember(def); return capabilityCall('agent', 'registerTools', { diff --git a/native-host/agent/goal.js b/native-host/agent/goal.js new file mode 100644 index 0000000..ffe6b35 --- /dev/null +++ b/native-host/agent/goal.js @@ -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, +}; diff --git a/native-host/agent/loop.js b/native-host/agent/loop.js index 8d0678a..f83124c 100644 --- a/native-host/agent/loop.js +++ b/native-host/agent/loop.js @@ -12,6 +12,10 @@ const compaction = require('./compaction.js'); const tasks = require('./tasks.js'); const customTools = require('./custom-tools.js'); const toolSet = require('./tool-set.js'); +const planMode = require('./plan-mode.js'); +const todos = require('./todos.js'); +const stationarity = require('./stationarity.js'); +const goalMod = require('./goal.js'); const path = require('bare-path'); const fs = require('bare-fs'); @@ -20,8 +24,14 @@ customTools.setReserved(toolSet.ALWAYS_RESERVED.concat(toolSet.ALWAYS_BUILTIN_RE const live = new Map(); const pendingPerms = new Map(); const pendingCustom = new Map(); +const pendingAsks = new Map(); +const pendingPlans = new Map(); const MAX_TURNS = 24; +const SUBAGENT_TURNS = 8; const CUSTOM_TOOL_TIMEOUT_MS = 60000; +const ASK_TIMEOUT_MS = 10 * 60 * 1000; +const PLAN_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_GOAL_NUDGES = 8; function fsRead(cwd, rel) { return fs.readFileSync(path.join(cwd, rel), 'utf8'); @@ -38,6 +48,43 @@ function emitUpdate(emit, sessionId, jobId, update) { emit('cap-chunk', Object.assign({ pack: 'agent', sessionId, jobId, kind: 'session_update' }, update)); } +function persistSession(session, tracker) { + if (tracker) session.planMode = planMode.snapshot(tracker); + if (session.goal) session.goal = goalMod.snapshot(session.goal); + sessions.saveSummary(session); +} + +async function waitKeyed(map, key, timeoutMs, timeoutValue) { + const existing = map.get(key); + if (existing && existing.ready) { + map.delete(key); + return existing.ready; + } + return new Promise((resolve) => { + const timer = setTimeout(() => { + map.delete(key); + resolve(timeoutValue); + }, timeoutMs); + map.set(key, { + resolve(payload) { + clearTimeout(timer); + resolve(payload); + }, + }); + }); +} + +function resolveKeyed(map, key, payload) { + const rec = map.get(key); + if (rec && typeof rec.resolve === 'function') { + map.delete(key); + rec.resolve(payload); + return true; + } + map.set(key, { ready: payload }); + return true; +} + async function waitPermission(jobId, payload) { return new Promise((resolve) => { pendingPerms.set(jobId + ':' + payload.toolCallId, resolve); @@ -75,16 +122,155 @@ function waitCustomResult(jobId, toolCallId) { } function resolveCustomResult(jobId, toolCallId, payload) { - const key = jobId + ':' + toolCallId; - const rec = pendingCustom.get(key); - const body = payload || {}; - if (rec && typeof rec.resolve === 'function') { - pendingCustom.delete(key); - rec.resolve(body); - return true; + return resolveKeyed(pendingCustom, jobId + ':' + toolCallId, payload || {}); +} + +function waitAsk(jobId, toolCallId) { + return waitKeyed(pendingAsks, jobId + ':' + toolCallId, ASK_TIMEOUT_MS, { error: 'ask_user timed out' }); +} + +function resolveAsk(jobId, toolCallId, choice) { + const payload = { choice }; + if (jobId) { + const key = jobId + ':' + toolCallId; + if (pendingAsks.has(key)) return resolveKeyed(pendingAsks, key, payload); + } + const keys = Array.from(pendingAsks.keys()); + for (const key of keys) { + if (key === toolCallId || key.endsWith(':' + toolCallId)) return resolveKeyed(pendingAsks, key, payload); + } + return resolveKeyed(pendingAsks, (jobId || '') + ':' + toolCallId, payload); +} + +function waitPlanDecision(sessionId) { + return waitKeyed(pendingPlans, sessionId, PLAN_TIMEOUT_MS, { decision: 'timeout' }); +} + +function resolvePlanDecision(sessionId, decision) { + return resolveKeyed(pendingPlans, sessionId, { decision: decision === 'approve' ? 'approve' : 'reject' }); +} + +function buildToolDefs(session, payload, tracker) { + const hostWorkspace = session.hostWorkspace !== false; + return tools + .defs({ + planMode: planMode.isActive(tracker), + webFetch: payload && payload.webFetch, + hostWorkspace, + builtinTools: session.builtinTools, + }) + .concat(customTools.defs(session.id)); +} + +function refreshSystem(session, sys) { + if (!session.history) session.history = []; + if (session.history.length && session.history[0].role === 'system') { + session.history[0] = { role: 'system', content: sys }; + } else { + session.history.unshift({ role: 'system', content: sys }); + } +} + +function pushHistory(session, msg) { + session.history.push(msg); + if (msg.role !== 'system') sessions.appendHistory(session.id, msg); +} + +function applyPlanWrite(session, name, args) { + let text = sessions.readPlan(session.id) || ''; + if (name === 'write_file') { + text = args.contents != null ? String(args.contents) : args.content != null ? String(args.content) : ''; + } else { + const old = args.old_string || args.oldString || ''; + const neu = args.new_string != null ? args.new_string : args.newString; + if (neu == null) throw new Error('new_string required'); + if (!old) text = String(neu); + else { + if (!text.includes(old)) throw new Error('old_string not found'); + text = text.replace(old, neu); + } + } + const file = sessions.writePlan(session.id, text); + if (session.hostWorkspace !== false && session.cwd) { + try { + const abs = path.join(session.cwd, 'plan.md'); + fs.writeFileSync(abs, text); + } catch (_) {} + } + return { ok: true, path: file, bytes: text.length }; +} + +function sidecarMessages(session, tracker) { + const extra = []; + if (planMode.isActive(tracker)) { + extra.push({ + role: 'user', + content: + '\n' + + planMode.reminder(tracker, { hasContent: !!(sessions.readPlan(session.id) || '').trim() }) + + '\n', + }); + } else if (tracker && tracker.pendingExitReminder) { + extra.push({ role: 'user', content: '\n' + planMode.exitReminder() + '\n' }); + tracker.pendingExitReminder = false; + } + if (session.plan && session.plan.length) { + extra.push({ role: 'user', content: todos.formatBlock(session.plan) }); + } + return extra; +} + +function endTurn(emit, session, jobId, tracker, extra) { + persistSession(session, tracker); + const payload = Object.assign({ type: 'end', reason: 'stop' }, extra || {}); + emitUpdate(emit, session.id, jobId, payload); + return { + ok: payload.reason !== 'cancelled', + text: payload.text || '', + turns: payload.turns, + reason: payload.reason, + }; +} + +async function verifyGoal(session, tracker, lastText) { + const g = session.goal; + if (!g || g.verify === false) { + g.status = 'complete'; + return { achieved: true, gaps: [] }; + } + if ((g.verifierRuns || 0) >= goalMod.MAX_VERIFIER_RUNS) { + return { achieved: false, gaps: ['verification budget exhausted'] }; + } + g.status = 'verifying'; + g.verifierRuns = (g.verifierRuns || 0) + 1; + const evidence = [ + lastText || '', + todos.formatBlock(session.plan), + (sessions.readPlan(session.id) || '').slice(0, 8000), + g.notes || '', + ] + .filter(Boolean) + .join('\n\n'); + try { + const result = await engine.complete({ + history: [ + { role: 'system', content: 'Reply with JSON only.' }, + { role: 'user', content: goalMod.verifierPrompt(g, evidence) }, + ], + tools: [], + }); + const verdict = goalMod.parseVerifier(result && result.text); + g.gaps = verdict.gaps || []; + if (verdict.achieved) g.status = 'complete'; + else g.status = 'executing'; + persistSession(session, tracker); + return verdict; + } catch (err) { + g.status = 'executing'; + g.gaps = ['verifier failed: ' + err.message]; + persistSession(session, tracker); + return { achieved: false, gaps: g.gaps }; } - pendingCustom.set(key, { ready: body }); - return true; } async function runTurn(ctx) { @@ -93,20 +279,31 @@ async function runTurn(ctx) { const hostWorkspace = session.hostWorkspace !== false; const cwd = hostWorkspace ? session.cwd || sandbox.defaultCwd(origin) : session.cwd || 'page'; const mode = sandbox.permissionMode(payload); - const toolDefs = tools - .defs({ - planMode: ctx.planMode, - webFetch: payload && payload.webFetch, - hostWorkspace, - builtinTools: session.builtinTools, - }) - .concat(customTools.defs(session.id)); + const tracker = planMode.create(session.planMode); + if (payload && (payload.planMode === true || payload.planMode === 'active' || payload.planMode === 'plan')) { + if (tracker.state === 'inactive') { + planMode.enterPending(tracker); + planMode.activate(tracker); + } + } + ctx.planTracker = tracker; + ctx.planMode = planMode.isActive(tracker); + + if (payload && payload.goal) { + session.goal = goalMod.create(payload.goal, { verify: payload.verify !== false }); + } + if (payload && payload.verify === false && session.goal) session.goal.verify = false; + await ensureModel(session.model); + let extraSys = payload && payload.system; + if (session.goal && goalMod.isActive(session.goal)) { + extraSys = [extraSys, goalMod.plannerAddendum(session.goal)].filter(Boolean).join('\n\n'); + } const sys = prompts.assemble({ cwd: hostWorkspace ? cwd : session.workspace || cwd, hostWorkspace, - extra: payload && payload.system, + extra: extraSys, fsRead: hostWorkspace ? (c, r) => { try { @@ -117,118 +314,254 @@ async function runTurn(ctx) { } : null, }); - - if (!session.history || !session.history.length) { - session.history = [{ role: 'system', content: sys }]; - } + refreshSystem(session, sys); if (userText) { - session.history.push({ role: 'user', content: userText }); - sessions.appendHistory(session.id, { role: 'user', content: userText }); + pushHistory(session, { role: 'user', content: userText }); + } + persistSession(session, tracker); + if (session.goal) { + emitUpdate(emit, session.id, jobId, { type: 'goal_update', goal: goalMod.snapshot(session.goal) }); } const cancelled = () => live.get(session.id) && live.get(session.id).cancelled; + const stuck = stationarity.create(); + let lastText = ''; + let goalNudges = 0; - for (let turn = 0; turn < MAX_TURNS; turn++) { - if (cancelled()) throw new Error('cancelled'); - session.history = compaction.compact(session.history, { - budgetTokens: Math.floor(((engine.getLoaded().ctxSize || 8192) * 0.8)), - tools: toolDefs, - }); - - emitUpdate(emit, session.id, jobId, { type: 'turn', turn }); - const result = await engine.complete( - { - history: session.history, + try { + for (let turn = 0; turn < MAX_TURNS; turn++) { + if (cancelled()) return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', turns: turn }); + const toolDefs = buildToolDefs(session, payload, tracker); + ctx.planMode = planMode.isActive(tracker); + const beforeLen = session.history.length; + session.history = compaction.compact(session.history, { + budgetTokens: Math.floor((engine.getLoaded().ctxSize || 8192) * 0.8), tools: toolDefs, - toolDialect: catalog.toolDialectFor(session.model), - }, - (ev) => { - if (ev.type === 'contentDelta') { - emitUpdate(emit, session.id, jobId, { type: 'agent_message_chunk', text: ev.delta }); - } else if (ev.type === 'thinkingDelta') { - emitUpdate(emit, session.id, jobId, { type: 'agent_thought_chunk', text: ev.delta }); - } else if (ev.type === 'toolCall') { - emitUpdate(emit, session.id, jobId, { type: 'tool_call', call: ev.call }); - } + }); + if (session.history.length !== beforeLen) { + sessions.replaceHistory(session.id, session.history); } - ); - if (result.text) { - session.history.push({ role: 'assistant', content: result.text }); - sessions.appendHistory(session.id, { role: 'assistant', content: result.text }); - } - - const calls = result.toolCalls || []; - if (!calls.length) { - emitUpdate(emit, session.id, jobId, { type: 'end', reason: 'stop', text: result.text || '' }); - sessions.saveSummary(session); - return { ok: true, text: result.text || '', turns: turn + 1 }; - } - - for (const call of calls) { - if (cancelled()) throw new Error('cancelled'); - const name = call.name; - const args = typeof call.arguments === 'string' ? safeJson(call.arguments) : call.arguments || {}; - const toolCallId = call.id || name + '_' + Date.now(); - if (sandbox.needsPermission(name, mode)) { - emitUpdate(emit, session.id, jobId, { - type: 'permission', - toolCallId, - tool: name, - args, - }); - if (mode !== 'always-approve') { - const decision = await waitPermission(jobId, { toolCallId }); - if (decision !== 'allow') { - const denied = 'permission denied for ' + name; - session.history.push({ role: 'tool', name, content: denied, tool_call_id: toolCallId }); - sessions.appendHistory(session.id, { role: 'tool', name, content: denied }); - emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: denied }); - continue; + emitUpdate(emit, session.id, jobId, { type: 'turn', turn }); + const history = session.history.concat(sidecarMessages(session, tracker)); + const result = await engine.complete( + { + history, + tools: toolDefs, + toolDialect: catalog.toolDialectFor(session.model), + }, + (ev) => { + if (ev.type === 'contentDelta') { + emitUpdate(emit, session.id, jobId, { type: 'agent_message_chunk', text: ev.delta }); + } else if (ev.type === 'thinkingDelta') { + emitUpdate(emit, session.id, jobId, { type: 'agent_thought_chunk', text: ev.delta }); + } else if (ev.type === 'toolCall') { + emitUpdate(emit, session.id, jobId, { type: 'tool_call', call: ev.call }); } } + ); + + if (result.text) { + lastText = result.text; + pushHistory(session, { role: 'assistant', content: result.text }); } - let out; - try { - if (customTools.has(session.id, name)) { + + const calls = result.toolCalls || []; + if (!calls.length) { + const goalActive = goalMod.isActive(session.goal); + if (goalActive && goalNudges < MAX_GOAL_NUDGES) { + goalNudges += 1; + pushHistory(session, { role: 'user', content: goalMod.continuation(session.goal) }); + continue; + } + return endTurn(emit, session, jobId, tracker, { + type: 'end', + reason: 'stop', + text: result.text || lastText || '', + turns: turn + 1, + }); + } + + let stuckNow = false; + for (const call of calls) { + if (cancelled()) return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', turns: turn + 1 }); + const name = call.name; + const args = typeof call.arguments === 'string' ? safeJson(call.arguments) : call.arguments || {}; + const toolCallId = call.id || name + '_' + Date.now(); + stationarity.observe(stuck, name, args); + if (stationarity.shouldStop(stuck)) { + stuckNow = true; + const msg = 'Repeating the same tool call; stopping.'; + pushHistory(session, { role: 'tool', name, content: msg, tool_call_id: toolCallId }); + emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: msg }); + break; + } + + const gateErr = planMode.gateWrite(tracker, name, args); + if (gateErr) { + pushHistory(session, { role: 'tool', name, content: gateErr, tool_call_id: toolCallId }); + emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: gateErr }); + continue; + } + + if (sandbox.needsPermission(name, mode) && !planMode.isPlanFilePath(args.path, tracker.planPath)) { emitUpdate(emit, session.id, jobId, { - type: 'tool_request', + type: 'permission', toolCallId, - name, + tool: name, args, }); - const answered = await waitCustomResult(jobId, toolCallId); - if (answered && answered.error) throw new Error(answered.error); - out = answered && answered.result != null ? answered.result : answered; - } else if (name === 'task') { - out = await runSubagent(ctx, args); - } else if (name === 'send_subagent_message') { - const rec = tasks.appendMessage(args.task_id || args.taskId, args.message); - out = await continueSubagent(ctx, rec, args.message); - } else { - out = await tools.execute( - { origin, cwd, session, planMode: ctx.planMode, hostWorkspace }, - name, - args - ); - if (out && out.planMode === true) ctx.planMode = true; - if (out && out.planMode === false) ctx.planMode = false; - if (out && out.type === 'ask_user') { - emitUpdate(emit, session.id, jobId, { type: 'ask_user', question: out.question, options: out.options }); + if (mode !== 'always-approve') { + const decision = await waitPermission(jobId, { toolCallId }); + if (decision !== 'allow') { + const denied = 'permission denied for ' + name; + pushHistory(session, { role: 'tool', name, content: denied, tool_call_id: toolCallId }); + emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: denied }); + continue; + } } } - } catch (err) { - out = { error: err.message }; + + let out; + try { + if ( + planMode.isActive(tracker) && + (name === 'write_file' || name === 'search_replace') && + planMode.isPlanFilePath(args.path || args.file, tracker.planPath) + ) { + out = applyPlanWrite(session, name, args); + emitUpdate(emit, session.id, jobId, { + type: 'plan_update', + path: tracker.planPath, + text: sessions.readPlan(session.id), + }); + } else if (customTools.has(session.id, name)) { + emitUpdate(emit, session.id, jobId, { + type: 'tool_request', + toolCallId, + name, + args, + }); + const answered = await waitCustomResult(jobId, toolCallId); + if (answered && answered.error) throw new Error(answered.error); + out = answered && answered.result != null ? answered.result : answered; + } else if (name === 'task') { + out = await runSubagent(ctx, args); + } else if (name === 'send_subagent_message') { + const rec = tasks.appendMessage(args.task_id || args.taskId, args.message); + out = await continueSubagent(ctx, rec, args.message); + } else { + out = await tools.execute( + { + origin, + cwd, + session, + planMode: planMode.isActive(tracker), + planTracker: tracker, + hostWorkspace, + }, + name, + args + ); + if (out && out.type === 'enter_plan_mode') { + persistSession(session, tracker); + } + if (out && out.type === 'exit_plan_mode') { + planMode.requestExit(tracker); + persistSession(session, tracker); + emitUpdate(emit, session.id, jobId, { + type: 'plan_approval', + toolCallId, + plan: sessions.readPlan(session.id), + }); + const ans = await waitPlanDecision(session.id); + if (cancelled()) return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', turns: turn + 1 }); + if (ans && ans.decision === 'approve') { + planMode.approveExit(tracker); + tracker.pendingExitReminder = true; + persistSession(session, tracker); + out = { planMode: false, approved: true, message: planMode.exitReminder() }; + } else { + planMode.rejectExit(tracker); + persistSession(session, tracker); + out = { + planMode: true, + approved: false, + message: 'Plan rejected. Stay in plan mode and revise plan.md.', + }; + } + } + if (out && out.type === 'ask_user') { + emitUpdate(emit, session.id, jobId, { + type: 'ask_user', + toolCallId, + question: out.question, + options: out.options, + }); + const ans = await waitAsk(jobId, toolCallId); + if (cancelled()) return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', turns: turn + 1 }); + if (ans && ans.error) out = { error: ans.error }; + else out = { type: 'ask_user', question: out.question, choice: ans && ans.choice }; + } + if (out && out.type === 'goal_blocked') { + session.goal.status = 'blocked'; + emitUpdate(emit, session.id, jobId, { type: 'goal_update', goal: goalMod.snapshot(session.goal) }); + const rendered = JSON.stringify(out).slice(0, 12000); + pushHistory(session, { role: 'tool', name, content: rendered, tool_call_id: toolCallId }); + emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: rendered.slice(0, 4000) }); + return endTurn(emit, session, jobId, tracker, { + reason: 'goal_blocked', + text: out.blocked_reason || lastText, + turns: turn + 1, + }); + } + if (out && out.type === 'goal_completed') { + emitUpdate(emit, session.id, jobId, { type: 'goal_update', goal: goalMod.snapshot(session.goal) }); + const skipVerify = payload && payload.verify === false; + const verdict = skipVerify ? { achieved: true, gaps: [] } : await verifyGoal(session, tracker, lastText); + if (verdict.achieved) { + const rendered = JSON.stringify({ ok: true, achieved: true }).slice(0, 12000); + pushHistory(session, { role: 'tool', name, content: rendered, tool_call_id: toolCallId }); + emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: rendered }); + emitUpdate(emit, session.id, jobId, { type: 'goal_update', goal: goalMod.snapshot(session.goal) }); + return endTurn(emit, session, jobId, tracker, { + reason: 'goal_complete', + text: lastText, + turns: turn + 1, + }); + } + out = { + ok: false, + achieved: false, + gaps: verdict.gaps, + message: 'Verifier rejected completion. Fix the gaps and continue.', + }; + emitUpdate(emit, session.id, jobId, { type: 'goal_update', goal: goalMod.snapshot(session.goal) }); + } + } + } catch (err) { + out = { error: err.message }; + } + const rendered = typeof out === 'string' ? out : JSON.stringify(out).slice(0, 12000); + pushHistory(session, { role: 'tool', name, content: rendered, tool_call_id: toolCallId }); + emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: rendered.slice(0, 4000) }); + } + + if (stuckNow) { + return endTurn(emit, session, jobId, tracker, { reason: 'stuck', text: lastText, turns: turn + 1 }); + } + if (stationarity.shouldNudge(stuck)) { + stationarity.markNudged(stuck); + pushHistory(session, { role: 'user', content: stationarity.nudgeText() }); } - const rendered = typeof out === 'string' ? out : JSON.stringify(out).slice(0, 12000); - session.history.push({ role: 'tool', name, content: rendered, tool_call_id: toolCallId }); - sessions.appendHistory(session.id, { role: 'tool', name, content: rendered }); - emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: rendered.slice(0, 4000) }); } + return endTurn(emit, session, jobId, tracker, { reason: 'max_turns', text: lastText, turns: MAX_TURNS }); + } catch (err) { + if (err && err.message === 'cancelled') { + return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', text: lastText }); + } + throw err; } - emitUpdate(emit, session.id, jobId, { type: 'end', reason: 'max_turns' }); - sessions.saveSummary(session); - return { ok: true, text: '', turns: MAX_TURNS, reason: 'max_turns' }; } async function runSubagent(parentCtx, args) { @@ -236,10 +569,9 @@ async function runSubagent(parentCtx, args) { const label = args.label || 'subagent'; const taskId = tasks.create(label); const hostWorkspace = parentCtx.session.hostWorkspace !== false; + const allowed = new Set(['read_file', 'grep', 'list_dir', 'memory_search', 'memory_get']); const toolDefs = hostWorkspace - ? tools.defs({ planMode: true, webFetch: false, hostWorkspace: true }).filter((t) => - ['read_file', 'grep', 'list_dir', 'memory_search'].includes(t.name) - ) + ? tools.defs({ webFetch: false, hostWorkspace: true }).filter((t) => allowed.has(t.name)) : []; await ensureModel(parentCtx.session.model); const history = [ @@ -252,32 +584,43 @@ async function runSubagent(parentCtx, args) { { role: 'user', content: prompt }, ]; try { - const result = await engine.complete({ history, tools: toolDefs }); - const calls = result.toolCalls || []; - let extra = ''; - for (const call of calls.slice(0, 6)) { - try { - const out = await tools.execute( - { - origin: parentCtx.session.origin, - cwd: parentCtx.session.cwd, - session: parentCtx.session, - hostWorkspace: parentCtx.session.hostWorkspace !== false, - }, - call.name, - typeof call.arguments === 'string' ? safeJson(call.arguments) : call.arguments || {} - ); - extra += '\n[' + call.name + '] ' + (typeof out === 'string' ? out : JSON.stringify(out)).slice(0, 2000); - } catch (err) { - extra += '\n[' + call.name + ' error] ' + err.message; + let summary = ''; + for (let turn = 0; turn < SUBAGENT_TURNS; turn++) { + if (parentCtx.session && live.get(parentCtx.session.id) && live.get(parentCtx.session.id).cancelled) { + throw new Error('cancelled'); } - } - let summary = result.text || extra; - if (extra) { - history.push({ role: 'assistant', content: result.text || '' }); - history.push({ role: 'user', content: 'Tool results:\n' + extra + '\nSummarize for the parent agent.' }); - const second = await engine.complete({ history }); - summary = second.text || result.text || extra.slice(0, 4000); + const result = await engine.complete({ history, tools: toolDefs }); + if (result.text) history.push({ role: 'assistant', content: result.text }); + const calls = result.toolCalls || []; + if (!calls.length) { + summary = result.text || summary; + break; + } + let extra = ''; + for (const call of calls) { + try { + const out = await tools.execute( + { + origin: parentCtx.session.origin, + cwd: parentCtx.session.cwd, + session: parentCtx.session, + hostWorkspace: parentCtx.session.hostWorkspace !== false, + }, + call.name, + typeof call.arguments === 'string' ? safeJson(call.arguments) : call.arguments || {} + ); + extra = typeof out === 'string' ? out : JSON.stringify(out); + } catch (err) { + extra = 'error: ' + err.message; + } + history.push({ + role: 'tool', + name: call.name, + content: String(extra).slice(0, 8000), + tool_call_id: call.id, + }); + } + summary = result.text || extra; } tasks.finish(taskId, summary); return { taskId, label, summary }; @@ -313,16 +656,37 @@ function markLive(sessionId) { return rec; } +function flushPending(map, payload) { + const keys = Array.from(map.keys()); + for (const key of keys) { + const rec = map.get(key); + map.delete(key); + if (rec && typeof rec.resolve === 'function') rec.resolve(payload); + } +} + function cancel(sessionId) { const rec = live.get(sessionId); if (rec) rec.cancelled = true; engine.cancel().catch(() => {}); - const keys = Array.from(pendingCustom.keys()); - for (const key of keys) { - const rec2 = pendingCustom.get(key); - pendingCustom.delete(key); - if (rec2 && typeof rec2.resolve === 'function') rec2.resolve({ error: 'cancelled' }); + flushPending(pendingCustom, { error: 'cancelled' }); + flushPending(pendingAsks, { error: 'cancelled' }); + flushPending(pendingPlans, { decision: 'reject' }); + const permKeys = Array.from(pendingPerms.keys()); + for (const key of permKeys) { + const fn = pendingPerms.get(key); + pendingPerms.delete(key); + if (typeof fn === 'function') fn('deny'); } } -module.exports = { runTurn, resolvePermission, resolveCustomResult, markLive, cancel }; +module.exports = { + runTurn, + resolvePermission, + resolveCustomResult, + resolveAsk, + resolvePlanDecision, + markLive, + cancel, + MAX_TURNS, +}; diff --git a/native-host/agent/plan-mode.js b/native-host/agent/plan-mode.js new file mode 100644 index 0000000..8e27564 --- /dev/null +++ b/native-host/agent/plan-mode.js @@ -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, +}; diff --git a/native-host/agent/sessions.js b/native-host/agent/sessions.js index f195546..0e08d1d 100644 --- a/native-host/agent/sessions.js +++ b/native-host/agent/sessions.js @@ -57,6 +57,8 @@ function create(meta) { createdAt: Date.now(), updatedAt: Date.now(), plan: [], + planMode: meta.planMode || { state: 'inactive', planPath: 'plan.md', reminderCount: 0 }, + goal: meta.goal || null, }; fs.writeFileSync(path.join(dir, 'summary.json'), JSON.stringify(summary, null, 2)); fs.writeFileSync(path.join(dir, 'chat_history.jsonl'), ''); @@ -78,7 +80,10 @@ function load(id) { function saveSummary(summary) { summary.updatedAt = Date.now(); - fs.writeFileSync(path.join(sessionDir(summary.id), 'summary.json'), JSON.stringify(summary, null, 2)); + const copy = Object.assign({}, summary); + delete copy.history; + delete copy.updates; + fs.writeFileSync(path.join(sessionDir(summary.id), 'summary.json'), JSON.stringify(copy, null, 2)); } function appendHistory(id, msg) { @@ -95,6 +100,24 @@ function replaceHistory(id, history) { fs.writeFileSync(file, body ? body + '\n' : ''); } +function planFile(id) { + return path.join(sessionDir(id), 'plan.md'); +} + +function readPlan(id) { + try { + return fs.readFileSync(planFile(id), 'utf8'); + } catch (_) { + return ''; + } +} + +function writePlan(id, text) { + const file = planFile(id); + fs.writeFileSync(file, String(text != null ? text : '')); + return file; +} + function list() { const root = sessionsRoot(); let names = []; @@ -121,6 +144,10 @@ module.exports = { appendHistory, appendUpdate, replaceHistory, + sessionDir, + planFile, + readPlan, + writePlan, list, makeId, }; diff --git a/native-host/agent/stationarity.js b/native-host/agent/stationarity.js new file mode 100644 index 0000000..1578149 --- /dev/null +++ b/native-host/agent/stationarity.js @@ -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, +}; diff --git a/native-host/agent/tasks.js b/native-host/agent/tasks.js index 00ee0b4..1db56b2 100644 --- a/native-host/agent/tasks.js +++ b/native-host/agent/tasks.js @@ -3,6 +3,7 @@ */ const tasks = new Map(); +const waiters = []; function makeId() { return 'task_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); @@ -21,6 +22,12 @@ function create(label) { return id; } +function flushWaiters() { + for (let i = waiters.length - 1; i >= 0; i--) { + if (waiters[i]()) waiters.splice(i, 1); + } +} + function finish(id, summary) { const t = tasks.get(id); if (t) { @@ -28,6 +35,7 @@ function finish(id, summary) { t.summary = summary; t.updatedAt = Date.now(); } + flushWaiters(); return t || null; } @@ -38,6 +46,7 @@ function fail(id, err) { t.summary = String(err || 'error'); t.updatedAt = Date.now(); } + flushWaiters(); return t || null; } @@ -54,9 +63,24 @@ function list() { })); } -function waitAll() { - const running = list().filter((t) => t.status === 'running'); - return { running: running.length, tasks: list() }; +function waitAll(opts) { + opts = opts || {}; + const timeoutMs = opts.timeoutMs > 0 ? opts.timeoutMs : 120000; + const running = () => list().filter((t) => t.status === 'running'); + if (!running().length) return Promise.resolve({ running: 0, tasks: list() }); + return new Promise((resolve) => { + const timer = setTimeout(() => { + resolve({ running: running().length, tasks: list(), timedOut: true }); + }, timeoutMs); + waiters.push(() => { + if (!running().length) { + clearTimeout(timer); + resolve({ running: 0, tasks: list() }); + return true; + } + return false; + }); + }); } function kill(id) { @@ -65,6 +89,7 @@ function kill(id) { t.status = 'killed'; t.updatedAt = Date.now(); } + flushWaiters(); return t || null; } @@ -78,6 +103,7 @@ function appendMessage(id, text) { function reset() { tasks.clear(); + waiters.length = 0; } module.exports = { create, finish, fail, get, list, waitAll, kill, appendMessage, reset }; diff --git a/native-host/agent/todos.js b/native-host/agent/todos.js new file mode 100644 index 0000000..c1f790e --- /dev/null +++ b/native-host/agent/todos.js @@ -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 }; diff --git a/native-host/agent/tool-set.js b/native-host/agent/tool-set.js index 9d5168b..54d0c15 100644 --- a/native-host/agent/tool-set.js +++ b/native-host/agent/tool-set.js @@ -12,6 +12,7 @@ const HOST_WORKSPACE_TOOLS = [ 'run_terminal_cmd', 'memory_search', 'memory_get', + 'memory_write', ]; const HOST_WORKSPACE_SET = new Set(HOST_WORKSPACE_TOOLS); @@ -25,6 +26,8 @@ const ALWAYS_BUILTIN_RESERVED = [ 'enter_plan_mode', 'exit_plan_mode', 'ask_user_question', + 'update_goal', + 'memory_write', 'task', 'send_subagent_message', 'get_task_output', @@ -54,7 +57,8 @@ function filterBuiltinSchemas(schemas, opts) { list = list.filter((t) => allow.has(t.name)); } if (opts.planMode) { - list = list.filter((t) => t.name !== 'search_replace' && t.name !== 'write_file' && t.name !== 'run_terminal_cmd'); + // Keep write_file / search_replace so the model can edit plan.md; gate other writes at execute. + list = list.filter((t) => t.name !== 'run_terminal_cmd'); } if (opts.webFetch !== true) { list = list.filter((t) => t.name !== 'web_fetch'); diff --git a/native-host/agent/tools.js b/native-host/agent/tools.js index efaac37..8f8aa15 100644 --- a/native-host/agent/tools.js +++ b/native-host/agent/tools.js @@ -8,6 +8,9 @@ const fs = require('bare-fs'); const sandbox = require('./sandbox.js'); const memory = require('./memory.js'); const toolSet = require('./tool-set.js'); +const planMode = require('./plan-mode.js'); +const todos = require('./todos.js'); +const goalMod = require('./goal.js'); const MAX_READ = 400 * 1024; const MAX_GREP_HITS = 50; @@ -119,18 +122,20 @@ const SCHEMAS = [ { type: 'function', name: 'grep', description: 'Search workspace files with a JS regex.', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] } }, { type: 'function', name: 'list_dir', description: 'List a directory.', parameters: { type: 'object', properties: { path: { type: 'string' }, recursive: { type: 'boolean' } } } }, { type: 'function', name: 'run_terminal_cmd', description: 'Run a shell command in the workspace cwd.', parameters: { type: 'object', properties: { command: { type: 'string' }, timeout_ms: { type: 'number' } }, required: ['command'] } }, - { type: 'function', name: 'todo_write', description: 'Replace the session todo list.', parameters: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, content: { type: 'string' }, status: { type: 'string' } } } } }, required: ['todos'] } }, + { type: 'function', name: 'todo_write', description: 'Merge or replace session todos. Status: pending | in_progress | completed | cancelled.', parameters: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'cancelled'] } } } }, merge: { type: 'boolean', description: 'If true (default), merge by id. If false, replace the list.' } }, required: ['todos'] } }, { type: 'function', name: 'web_search', description: 'Search the public web (DuckDuckGo HTML).', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } }, { type: 'function', name: 'web_fetch', description: 'Fetch a public http(s) URL as text. Off unless enabled.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } }, { type: 'function', name: 'memory_search', description: 'Search local agent memory notes.', parameters: { type: 'object', properties: { query: { type: 'string' } } } }, { type: 'function', name: 'memory_get', description: 'Read a memory note by name.', parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } }, - { type: 'function', name: 'enter_plan_mode', description: 'Switch to plan mode (no writes).', parameters: { type: 'object', properties: {} } }, - { type: 'function', name: 'exit_plan_mode', description: 'Exit plan mode.', parameters: { type: 'object', properties: {} } }, + { type: 'function', name: 'memory_write', description: 'Write a local agent memory note.', parameters: { type: 'object', properties: { name: { type: 'string' }, text: { type: 'string' } }, required: ['name', 'text'] } }, + { type: 'function', name: 'enter_plan_mode', description: 'Switch to plan mode. Only plan.md is writable until the plan is approved.', parameters: { type: 'object', properties: {} } }, + { type: 'function', name: 'exit_plan_mode', description: 'Present the plan for user approval and exit plan mode if approved.', parameters: { type: 'object', properties: {} } }, + { type: 'function', name: 'update_goal', description: 'Update the active goal. Call with completed true when the objective is met, or blocked_reason if stuck.', parameters: { type: 'object', properties: { notes: { type: 'string' }, completed: { type: 'boolean' }, blocked_reason: { type: 'string' } } } }, { type: 'function', name: 'ask_user_question', description: 'Ask the user a structured question.', parameters: { type: 'object', properties: { question: { type: 'string' }, options: { type: 'array', items: { type: 'string' } } }, required: ['question'] } }, { type: 'function', name: 'task', description: 'Spawn a subagent with a focused prompt (same model).', parameters: { type: 'object', properties: { prompt: { type: 'string' }, label: { type: 'string' } }, required: ['prompt'] } }, { type: 'function', name: 'send_subagent_message', description: 'Send a follow-up message to a subagent task.', parameters: { type: 'object', properties: { task_id: { type: 'string' }, message: { type: 'string' } }, required: ['task_id', 'message'] } }, { type: 'function', name: 'get_task_output', description: 'Get status/output of a subagent task.', parameters: { type: 'object', properties: { task_id: { type: 'string' } }, required: ['task_id'] } }, - { type: 'function', name: 'wait_tasks', description: 'List running and completed subagent tasks.', parameters: { type: 'object', properties: {} } }, + { type: 'function', name: 'wait_tasks', description: 'Wait until subagent tasks finish (or timeout).', parameters: { type: 'object', properties: { timeout_ms: { type: 'number' } } } }, { type: 'function', name: 'kill_task', description: 'Mark a running subagent task as killed.', parameters: { type: 'object', properties: { task_id: { type: 'string' } }, required: ['task_id'] } }, { type: 'function', name: 'search_tool', description: 'Search registered MCP tools.', parameters: { type: 'object', properties: { query: { type: 'string' } } } }, { type: 'function', name: 'use_tool', description: 'Invoke an MCP tool by server__name.', parameters: { type: 'object', properties: { name: { type: 'string' }, arguments: { type: 'object' } }, required: ['name'] } }, @@ -170,6 +175,8 @@ async function execute(ctx, name, args) { const origin = ctx.origin; const cwd = ctx.cwd; args = args || {}; + const blocked = planMode.gateWrite(ctx.planTracker || ctx.planMode, name, args); + if (blocked) return blocked; if (toolSet.isHostWorkspaceTool(name) && ctx.hostWorkspace === false) { throw new Error('host workspace tools are disabled for this session'); } @@ -225,7 +232,8 @@ async function execute(ctx, name, args) { return runShell(cwd, args.command, args.timeout_ms || args.timeoutMs); } case 'todo_write': { - ctx.session.plan = args.todos || []; + const mode = args.merge === false || args.replace === true ? 'replace' : 'merge'; + ctx.session.plan = todos.merge(ctx.session.plan, args.todos || [], mode); require('./sessions.js').saveSummary(ctx.session); return { ok: true, todos: ctx.session.plan }; } @@ -237,20 +245,55 @@ async function execute(ctx, name, args) { return memory.search(origin, args.query); case 'memory_get': return memory.readNote(origin, args.name); - case 'enter_plan_mode': + case 'memory_write': { + const file = memory.writeNote(origin, args.name, args.text != null ? args.text : args.content); + return { ok: true, file }; + } + case 'enter_plan_mode': { + const tracker = ctx.planTracker || planMode.create(ctx.session && ctx.session.planMode); + planMode.activate(tracker); + ctx.planTracker = tracker; ctx.planMode = true; - return { planMode: true }; + if (ctx.session) { + ctx.session.planMode = planMode.snapshot(tracker); + require('./sessions.js').saveSummary(ctx.session); + } + return { type: 'enter_plan_mode', planMode: planMode.snapshot(tracker) }; + } case 'exit_plan_mode': - ctx.planMode = false; - return { planMode: false }; + return { type: 'exit_plan_mode' }; case 'ask_user_question': return { type: 'ask_user', question: args.question, options: args.options || [] }; + case 'update_goal': { + const g = (ctx.session && ctx.session.goal) || goalMod.create(''); + if (args.notes) g.notes = String(args.notes); + if (ctx.session) ctx.session.goal = g; + if (args.blocked_reason) { + g.status = 'blocked'; + g.blockedReason = String(args.blocked_reason); + if (ctx.session) require('./sessions.js').saveSummary(ctx.session); + return { type: 'goal_blocked', goal: goalMod.snapshot(g), blocked_reason: g.blockedReason }; + } + if (args.completed) { + if (todos.hasOpen(ctx.session && ctx.session.plan)) { + return { + error: 'Goal not complete: todos are still pending or in_progress. Finish or cancel them before update_goal({ completed: true }).', + todos: ctx.session && ctx.session.plan, + }; + } + g.status = 'verifying'; + if (ctx.session) require('./sessions.js').saveSummary(ctx.session); + return { type: 'goal_completed', goal: goalMod.snapshot(g), verify: g.verify !== false }; + } + if (ctx.session) require('./sessions.js').saveSummary(ctx.session); + return { ok: true, goal: goalMod.snapshot(g) }; + } case 'send_subagent_message': return require('./tasks.js').appendMessage(args.task_id || args.taskId, args.message); case 'get_task_output': return require('./tasks.js').get(args.task_id || args.taskId); case 'wait_tasks': - return require('./tasks.js').waitAll(); + return require('./tasks.js').waitAll({ timeoutMs: args.timeout_ms || args.timeoutMs }); case 'kill_task': return require('./tasks.js').kill(args.task_id || args.taskId); case 'search_tool': diff --git a/native-host/capabilities/agent.js b/native-host/capabilities/agent.js index d29f67d..ff654c0 100644 --- a/native-host/capabilities/agent.js +++ b/native-host/capabilities/agent.js @@ -50,6 +50,10 @@ function createAgentPack() { model: p.model || 'qwen3.5-4b', title: p.title || 'New session', sessionId: p.sessionId, + goal: p.goal ? require('../agent/goal.js').create(p.goal, { verify: p.verify !== false }) : null, + planMode: p.planMode + ? { state: p.planMode === true || p.planMode === 'active' ? 'active' : 'inactive', planPath: 'plan.md' } + : undefined, }); customTools.setSession(summary.id, { hostWorkspace }); if (p.tools) { @@ -104,7 +108,7 @@ function createAgentPack() { emit, jobId, payload, - planMode: !!payload.planMode, + planMode: payload.planMode, }); emit('cap-end', { pack: 'agent', jobId, sessionId: session.id, ...result }); } catch (err) { @@ -122,6 +126,18 @@ function createAgentPack() { loop.resolvePermission(p.jobId, p.toolCallId, p.decision || p.allow ? 'allow' : 'deny'); ctx.reply({ ok: true }); }, + async planDecision(ctx) { + if (refuseDisabled(ctx)) return; + const p = ctx.payload || {}; + loop.resolvePlanDecision(p.sessionId, p.decision === 'approve' || p.approve === true ? 'approve' : 'reject'); + ctx.reply({ ok: true }); + }, + async answer(ctx) { + if (refuseDisabled(ctx)) return; + const p = ctx.payload || {}; + loop.resolveAsk(p.jobId, p.toolCallId, p.choice != null ? p.choice : p.answer); + ctx.reply({ ok: true }); + }, async registerTools(ctx) { if (refuseDisabled(ctx)) return; try { diff --git a/native-host/test-bare.js b/native-host/test-bare.js index 10fb084..64833c8 100644 --- a/native-host/test-bare.js +++ b/native-host/test-bare.js @@ -132,6 +132,10 @@ testPathJail(); testAgentLoopFakeComplete() .then(() => testCustomToolLoop()) .then(() => testHostWorkspaceOffLoop()) + .then(() => testPlanWriteGateLoop()) + .then(() => testAskUserLoop()) + .then(() => testGoalPrematureLoop()) + .then(() => testStationarityLoop()) .then(() => testBareVersionsCoerce()) .then(() => testQvacOffByDefault()) .then(() => { @@ -316,3 +320,190 @@ async function testHostWorkspaceOffLoop() { ok(firstNames.indexOf('grep') === -1, 'host grep not offered'); ok(n >= 2, 'custom read_file then final'); } + +function mockEngine(engine) { + const origComplete = engine.complete; + const origLoad = engine.load; + const origGet = engine.getLoaded; + engine.getLoaded = () => ({ + modelId: 'fake', + friendlyId: 'qwen3.5-4b', + constant: 'QWEN3_5_4B_MULTIMODAL_Q4_K_M', + ctxSize: 8192, + }); + engine.load = async () => engine.getLoaded(); + return () => { + engine.complete = origComplete; + engine.load = origLoad; + engine.getLoaded = origGet; + }; +} + +async function testPlanWriteGateLoop() { + const tmp = tmpDir('bs-plan-'); + process.env.BRIDGE_SWARM_STORAGE = tmp; + const engine = require('./qvac/engine.js'); + const loop = require('./agent/loop.js'); + const sessions = require('./agent/sessions.js'); + const restore = mockEngine(engine); + let n = 0; + const results = []; + engine.complete = async () => { + n += 1; + if (n === 1) { + return { + text: '', + toolCalls: [ + { id: 'e', name: 'enter_plan_mode', arguments: {} }, + { id: 'w', name: 'write_file', arguments: { path: 'hello.js', contents: 'nope' } }, + ], + }; + } + return { text: 'planned', toolCalls: [] }; + }; + const cwd = path.join(tmp, 'ws'); + fs.mkdirSync(cwd, { recursive: true }); + const session = sessions.create({ + origin: 'http://127.0.0.1:4173', + cwd, + model: 'qwen3.5-4b', + title: 'plan', + }); + const result = await loop.runTurn({ + session, + userText: 'plan then write', + jobId: 'job_plan', + payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' }, + emit(_t, p) { + if (p && p.type === 'tool_result') results.push(String(p.result || '')); + }, + }); + restore(); + ok(result.ok, 'plan gate loop ok'); + ok(results.some((r) => /Rejected|plan mode/i.test(r)), 'write to hello.js rejected'); + ok(!fs.existsSync(path.join(cwd, 'hello.js')), 'hello.js not written'); +} + +async function testAskUserLoop() { + const tmp = tmpDir('bs-ask-'); + process.env.BRIDGE_SWARM_STORAGE = tmp; + const engine = require('./qvac/engine.js'); + const loop = require('./agent/loop.js'); + const sessions = require('./agent/sessions.js'); + const restore = mockEngine(engine); + let n = 0; + let choiceSeen = false; + engine.complete = async (opts) => { + n += 1; + if (n === 1) { + return { + text: '', + toolCalls: [{ id: 'a1', name: 'ask_user_question', arguments: { question: 'Pick', options: ['A', 'B'] } }], + }; + } + const last = (opts.history || []).filter((m) => m.role === 'tool').pop(); + choiceSeen = !!(last && /A/.test(String(last.content))); + return { text: 'chose A', toolCalls: [] }; + }; + const session = sessions.create({ + origin: 'http://127.0.0.1:4173', + cwd: path.join(tmp, 'ws'), + model: 'qwen3.5-4b', + title: 'ask', + }); + fs.mkdirSync(session.cwd, { recursive: true }); + const result = await loop.runTurn({ + session, + userText: 'ask me', + jobId: 'job_ask', + payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' }, + emit(_t, p) { + if (p && p.type === 'ask_user') loop.resolveAsk('job_ask', p.toolCallId, 'A'); + }, + }); + restore(); + ok(result.ok, 'ask loop ok'); + ok(n >= 2, 'waited then continued'); + ok(choiceSeen, 'choice reached history'); +} + +async function testGoalPrematureLoop() { + const tmp = tmpDir('bs-goal-'); + process.env.BRIDGE_SWARM_STORAGE = tmp; + const engine = require('./qvac/engine.js'); + const loop = require('./agent/loop.js'); + const sessions = require('./agent/sessions.js'); + const restore = mockEngine(engine); + let n = 0; + let premature = false; + engine.complete = async (opts) => { + n += 1; + if (!opts.tools || !opts.tools.length) { + return { text: '{"achieved": true, "gaps": []}', toolCalls: [] }; + } + if (n === 1) { + return { text: '', toolCalls: [{ id: 'g1', name: 'update_goal', arguments: { completed: true } }] }; + } + const last = (opts.history || []).filter((m) => m.role === 'tool').pop(); + premature = !!(last && /todos|not complete/i.test(String(last.content))); + return { text: 'still working', toolCalls: [] }; + }; + const cwd = path.join(tmp, 'ws'); + fs.mkdirSync(cwd, { recursive: true }); + const session = sessions.create({ + origin: 'http://127.0.0.1:4173', + cwd, + model: 'qwen3.5-4b', + title: 'goal', + }); + session.plan = [{ id: '1', content: 'write file', status: 'pending' }]; + const result = await loop.runTurn({ + session, + userText: 'finish the goal', + jobId: 'job_goal', + payload: { + _origin: 'http://127.0.0.1:4173', + permissionMode: 'always-approve', + goal: 'Ship hello.js', + verify: false, + }, + emit() {}, + }); + restore(); + ok(result.ok, 'goal loop ok'); + ok(premature, 'update_goal rejected with open todos'); + ok(n >= 2, 'continued after premature stop'); +} + +async function testStationarityLoop() { + const tmp = tmpDir('bs-stuck-'); + process.env.BRIDGE_SWARM_STORAGE = tmp; + const engine = require('./qvac/engine.js'); + const loop = require('./agent/loop.js'); + const sessions = require('./agent/sessions.js'); + const restore = mockEngine(engine); + engine.complete = async () => { + const calls = []; + for (let i = 0; i < 8; i++) { + calls.push({ id: 'c' + i, name: 'list_dir', arguments: { path: '.' } }); + } + return { text: '', toolCalls: calls }; + }; + const cwd = path.join(tmp, 'ws'); + fs.mkdirSync(cwd, { recursive: true }); + const session = sessions.create({ + origin: 'http://127.0.0.1:4173', + cwd, + model: 'qwen3.5-4b', + title: 'stuck', + }); + const result = await loop.runTurn({ + session, + userText: 'list forever', + jobId: 'job_stuck', + payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' }, + emit() {}, + }); + restore(); + ok(result.reason === 'stuck', 'stuck end reason'); +} diff --git a/scripts/test-host-units.js b/scripts/test-host-units.js index 0497fff..719fd9f 100644 --- a/scripts/test-host-units.js +++ b/scripts/test-host-units.js @@ -359,6 +359,119 @@ function testListenErrors() { testListenErrors(); +function testPlanMode() { + const plan = require('../native-host/agent/plan-mode.js'); + const pm = plan.create(); + assert.strictEqual(pm.state, 'inactive'); + assert.strictEqual(plan.isActive(pm), false); + plan.enterPending(pm); + assert.strictEqual(pm.state, 'pending'); + plan.activate(pm); + assert.strictEqual(pm.state, 'active'); + assert.strictEqual(plan.isActive(pm), true); + const full = plan.reminder(pm, { hasContent: false }); + assert.ok(/plan\.md/.test(full)); + assert.ok(/ask_user_question/.test(full) || /exit_plan_mode/.test(full)); + const sparse = plan.reminder(pm, { hasContent: true }); + assert.ok(/still active/.test(sparse)); + assert.ok(plan.gateWrite(pm, 'write_file', { path: 'hello.js' })); + assert.strictEqual(plan.gateWrite(pm, 'write_file', { path: 'plan.md' }), null); + assert.ok(plan.gateWrite(pm, 'run_terminal_cmd', { command: 'ls' })); + assert.strictEqual(plan.gateWrite(pm, 'read_file', { path: 'hello.js' }), null); + plan.requestExit(pm); + assert.strictEqual(pm.state, 'exitPending'); + plan.rejectExit(pm); + assert.strictEqual(pm.state, 'active'); + plan.requestExit(pm); + plan.approveExit(pm); + assert.strictEqual(pm.state, 'inactive'); + assert.strictEqual(plan.gateWrite(pm, 'write_file', { path: 'hello.js' }), null); + const restored = plan.create({ state: 'exitPending', awaitingApproval: true }); + assert.strictEqual(restored.state, 'active'); +} + +testPlanMode(); + +function testTodosMerge() { + const todos = require('../native-host/agent/todos.js'); + const a = todos.merge([], [{ id: '1', content: 'A', status: 'pending' }]); + const b = todos.merge(a, [{ id: '1', status: 'in_progress' }, { id: '2', content: 'B', status: 'done' }]); + assert.strictEqual(b.length, 2); + assert.strictEqual(b[0].status, 'in_progress'); + assert.strictEqual(b[0].content, 'A'); + assert.strictEqual(b[1].status, 'completed'); + assert.strictEqual(todos.hasOpen(b), true); + const c = todos.merge(b, [{ id: '1', status: 'completed' }, { id: '2', status: 'cancelled' }]); + assert.strictEqual(todos.hasOpen(c), false); + const replaced = todos.merge(c, [{ id: 'x', content: 'only', status: 'pending' }], 'replace'); + assert.strictEqual(replaced.length, 1); + assert.ok(/\[todos\]/.test(todos.formatBlock(replaced))); +} + +testTodosMerge(); + +function testStationarity() { + const st = require('../native-host/agent/stationarity.js'); + const s = st.create(); + for (let i = 0; i < 4; i++) st.observe(s, 'read_file', { path: 'a' }); + assert.strictEqual(st.shouldNudge(s), true); + assert.strictEqual(st.shouldStop(s), false); + st.markNudged(s); + assert.strictEqual(st.shouldNudge(s), false); + for (let i = 0; i < 4; i++) st.observe(s, 'read_file', { path: 'a' }); + assert.strictEqual(st.shouldStop(s), true); + st.observe(s, 'read_file', { path: 'b' }); + assert.strictEqual(st.shouldStop(s), false); +} + +testStationarity(); + +function testGoalPremature() { + const goal = require('../native-host/agent/goal.js'); + const todos = require('../native-host/agent/todos.js'); + const g = goal.create('Ship hello.js'); + assert.strictEqual(goal.isActive(g), true); + assert.ok(/update_goal/.test(goal.plannerAddendum(g))); + const open = todos.merge([], [{ id: '1', content: 'write', status: 'pending' }]); + assert.strictEqual(todos.hasOpen(open), true); + const v = goal.parseVerifier('{"achieved": false, "gaps": ["missing test"]}'); + assert.strictEqual(v.achieved, false); + assert.strictEqual(v.gaps[0], 'missing test'); + const ok = goal.parseVerifier('{"achieved": true, "gaps": []}'); + assert.strictEqual(ok.achieved, true); +} + +testGoalPremature(); + +function testPlanSchemaKeepsWrites() { + const toolSet = require('../native-host/agent/tool-set.js'); + const schemas = [ + { name: 'write_file' }, + { name: 'search_replace' }, + { name: 'run_terminal_cmd' }, + { name: 'read_file' }, + ]; + const on = toolSet.filterBuiltinSchemas(schemas, { planMode: true, hostWorkspace: true }); + assert.strictEqual(on.some((t) => t.name === 'write_file'), true); + assert.strictEqual(on.some((t) => t.name === 'search_replace'), true); + assert.strictEqual(on.some((t) => t.name === 'run_terminal_cmd'), false); + assert.strictEqual(on.some((t) => t.name === 'read_file'), true); +} + +testPlanSchemaKeepsWrites(); + +function testTaskWait() { + const tasks = require('../native-host/agent/tasks.js'); + tasks.reset(); + const id = tasks.create('t'); + const p = tasks.waitAll({ timeoutMs: 2000 }); + tasks.finish(id, 'done'); + return p.then((r) => { + assert.strictEqual(r.running, 0); + assert.strictEqual(r.tasks[0].status, 'done'); + }); +} + function testRegistryHas() { const registry = require('../native-host/capabilities/registry.js'); registry.registerPack({ id: 'qvac', commands: { ping() {} } }); @@ -387,4 +500,11 @@ function testSemverPatch() { testSemverPatch(); -console.log('ok — host unit checks passed'); +testTaskWait() + .then(() => { + console.log('ok — host unit checks passed'); + }) + .catch((err) => { + console.error(err && err.stack ? err.stack : err); + process.exit(1); + });