Plan mode (/plan) with approve/edit/reject/start-as-goal routes, goal steps with per-step events, queued messages during a goal

This commit is contained in:
Niko1221 committed 2026-10-16 18:42:14 +02:00
1 parent 2e1fcd15d9
commit eecb29593c
6 files changed
+729 -20

No files matched your search

+286 -18
View File
@@ -23,6 +23,7 @@ import { StatusReporter } from './status.mjs';
import { buildProfile, routeFor, MODE_PRESETS, modeOf, effortOf } from './profile.mjs';
import { TurnProjector, parseArguments as parseArgs } from './events.mjs';
import { ChildProjector } from './children.mjs';
import { PlanStore, planPrompt, parsePlan, parsePoints, stepObjective, stepLine, firstSentence, planAsPrompt, editedSteps, planView } from './plan.mjs';
/**
* A failure the person can read. `message` is a plain sentence with the next step; `details` keeps the technical
@@ -55,7 +56,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
* @param {string} [options.nodePath] the Node runtime that runs the Harness
* @param {(line:string)=>void} [options.log]
*/
export function createAgentBridge({ dataDir, componentsDir = null, bundledDir = null, gate = true, localInstances, connections, tools = () => undefined, skillsDir = () => null, onGoal = () => {}, env = process.env, nodePath = process.execPath, log = () => {}, startTimeoutMs = 120_000 }) {
export function createAgentBridge({ dataDir, componentsDir = null, bundledDir = null, gate = true, localInstances, connections, tools = () => undefined, skillsDir = () => null, onGoal = () => {}, onPlan = () => {}, onQueue = () => {}, env = process.env, nodePath = process.execPath, log = () => {}, startTimeoutMs = 120_000 }) {
const root = join(dataDir, 'agent');
const home = join(root, 'home');
const sessionsFile = join(root, 'sessions.json');
@@ -78,6 +79,10 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
let lastError = null;
let starting = null;
const chats = new Map(); // chatId -> { turn, route, routeGeneration }
const plans = new PlanStore(join(root, 'plans.json'));
/** chatId -> messages the person typed while a goal ran: [{ id, text, at, deliverAt }]; delivered between steps or when the goal ends */
const queues = new Map();
const queueView = chatId => (queues.get(chatId) ?? []).map(({ id, text, at, deliverAt }) => ({ id, text, at, deliverAt }));
const pending = new Map(); // `${sessionId}:${callId}` -> { eventId, sessionId, callId, at }
const idleWaiters = new Set();
@@ -241,10 +246,16 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
try { return await runTurnInner(c, options); } finally { c.starting = false; }
}
async function runTurnInner(c, { chatId, message, workspacePath, choice = null, mode = null, history = [], onFrame, requestId = `req-${randomUUID()}`, signal, goal = null, thinking = null }) {
let send = onFrame;
signal?.addEventListener('abort', () => { send = () => {}; }, { once: true });
const emit = frame => { try { send(frame); } catch { send = () => {}; } };
async function runTurnInner(c, { chatId, message, workspacePath, choice = null, mode = null, history = [], onFrame, requestId = `req-${randomUUID()}`, signal, goal = null, thinking = null, planTask = null, planRun = null }) {
let send = onFrame; let attached = true;
signal?.addEventListener('abort', () => { send = () => {}; attached = false; }, { once: true });
// What the assistant itself wrote in this run (not our own step lines): a plan turn reads its plan from it, a step reads its summary.
const written = { all: '', step: '' };
const emit = frame => {
if (frame.type === 'delta' && !frame.own) { written.all += frame.text; written.step += frame.text; }
if (frame.own) { const { own, ...rest } = frame; frame = rest; }
try { send(frame); } catch { send = () => {}; attached = false; }
};
// What the assistant is doing right now, one small frame (status.mjs). Cleared before the final frame of the turn.
const reporter = new StatusReporter({ emit });
@@ -272,7 +283,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
// The chat's permission mode is read once, here, for this whole turn. A change the user makes while the
// turn runs applies to the next one, so a running turn never sees its permissions move under it.
const appliedMode = modeOf(mode);
const appliedMode = planTask ? 'plan' : modeOf(mode);
await applyMode(runtime, sessionId, appliedMode);
const projector = new TurnProjector({ policy: MODE_PRESETS[appliedMode].approval });
@@ -283,6 +294,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
abortFromRuntime: reason => finishTurn(reason), finish: failure => finishTurn(failure),
// Helper agents (subagents) the Harness started for this chat, and the state of the run around them.
children: new Map(), subagentCalls: new Map(), parentOpen: false, stopping: false, settleTimer: null, mode: appliedMode,
plan: null, advancing: false, goalRun: false,
};
c.turn = turn;
function finishTurn(failure) {
@@ -293,14 +305,23 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
for (const child of turn.children.values()) child.follow?.close();
if (turn.stopping && !failure) projector.ended = { kind: 'aborted', reason: { kind: 'user' } };
reporter.clear();
const extra = [];
try { extra.push(...settlePlan(failure)); } catch { /* a plan frame must never break the end of a turn */ }
for (const f of extra) emit(f);
const frames = failure ? [{ type: 'error', message: plainAgentFailure(failure), details: failure, code: 'AGENT_STOPPED' }] : projector.finish({ model: route.name, contextMax: route.contextWindow });
for (const f of frames) emit(f);
for (const f of frames.slice(0, -1)) emit(f);
// Messages typed while the goal ran and not delivered between steps go back to the page now, to be sent as ordinary messages.
if (turn.goalRun && attached) {
const left = queueView(chatId);
if (left.length) { queues.delete(chatId); onQueue(chatId, []); emit({ type: 'queued-release', messages: left.map(({ id, text }) => ({ id, text })) }); }
}
for (const f of frames.slice(-1)) emit(f);
c.turn = null;
turn.ended.resolve({ reason: projector.ended, failure: failure ?? null });
notifyIdle();
}
emit({ type: 'start', model: route.name, contextMax: route.contextWindow, demo: false, remoteModel: route.kind === 'api', agent: true, mode: appliedMode, sessionId, requestId, skills: [] });
emit({ type: 'start', model: route.name, contextMax: route.contextWindow, demo: false, remoteModel: route.kind === 'api', agent: true, mode: appliedMode, sessionId, requestId, skills: [], ...(planTask ? { planning: true } : {}) });
turn.follow = runtime.gw.openStream('session/follow', { request: { address: { kind: 'session', sessionId }, assistantStream: true } }, {
onItem: item => {
@@ -418,6 +439,95 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
}
}
// -- plans and goal steps ----------------------------------------------------------------------------------
const planFrame = (event, plan, extra = {}) => ({ type: 'plan', event, plan: planView(plan), ...extra });
const publishPlan = (event, plan) => { onPlan(chatId, event, planView(plan)); return planFrame(event, plan); };
const stepFrame = (plan, index) => {
const st = plan.steps[index];
return { type: 'plan-step', planId: plan.id, stepId: st.id, index: index + 1, total: plan.steps.length, state: st.state, text: st.text, summary: st.summary ?? null, line: stepLine(index, plan.steps.length, st.text, st.state === 'running' ? 'started' : st.state) };
};
const currentStep = plan => plan.steps.findIndex(x => x.state !== 'done' && x.state !== 'skipped');
/** What the end of the turn means for the plan: a plan turn makes one; a step run records where it stopped. */
function settlePlan(failure) {
if (planTask) {
if (failure || turn.stopping) return [];
const parsed = parsePlan(written.all, planTask);
if (!parsed) return [];
const plan = plans.create(chatId, { title: parsed.title, steps: parsed.steps, request: planTask });
return [publishPlan('proposed', plan)];
}
const run = turn.plan;
if (!run) return [];
const plan = plans.get(chatId);
if (!plan || plan.id !== run.id) return [];
if (plan.state !== 'running') return [];
const out = [];
const at = currentStep(plan);
plans.update(chatId, p => {
if (at < 0) { p.state = 'done'; return; }
// The step that was running goes back to waiting, so Resume runs it again.
p.steps[at].state = 'pending';
p.state = 'paused';
});
const updated = plans.get(chatId);
if (at >= 0) out.push(stepFrame(updated, at));
out.push(publishPlan(updated.state === 'done' ? 'done' : 'paused', updated));
return out;
}
/** The goal the Harness runs for the plan's next waiting step, and the frames that say so. */
async function startStep(plan, index) {
plans.update(chatId, p => { p.state = 'running'; p.goalStarted = true; p.steps[index].state = 'running'; });
const now = plans.get(chatId);
await runtime.gw.rpc('goals/create', { agentId: sessionId, request: { objective: stepObjective(now, index) } });
written.step = '';
emit(stepFrame(now, index));
emit(publishPlan('step', now));
const st = now.steps[index];
reporter.set({ phase: 'goal', code: 'goal-step', text: stepLine(index, now.steps.length, st.text, 'started'), key: 'Step {0} of {1}: {2}', values: [index + 1, now.steps.length, st.text], args: { step: index + 1, steps: now.steps.length } });
}
/** One goal step finished: say so, hand over queued messages, and begin the next step (or end the plan). */
async function advancePlan() {
const run = turn.plan;
const plan = plans.get(chatId);
if (!run || !plan || plan.id !== run.id) return false;
const at = currentStep(plan);
if (at < 0) return false;
const summary = firstSentence(written.step.split(/\n\s*\n/).filter(Boolean).at(-1) ?? written.step);
plans.update(chatId, p => { p.steps[at].state = 'done'; p.steps[at].summary = summary; });
let now = plans.get(chatId);
emit(stepFrame(now, at));
emit({ type: 'delta', own: true, text: `\n\n${stepLine(at, now.steps.length, now.steps[at].text, 'done')}\n` });
const next = currentStep(now);
emit(publishPlan(next < 0 ? 'done' : 'step', next < 0 ? (plans.update(chatId, p => { p.state = 'done'; }), plans.get(chatId)) : now));
await deliverQueued(at + 1);
if (turn.stopping || turn.finished) return true;
if (next < 0) return false;
await startStep(plans.get(chatId), next);
void announceGoal();
return true;
}
/** Hand the person's queued messages to the assistant, one at a time, each answered before the next. */
async function deliverQueued(afterStep) {
const list = queues.get(chatId) ?? [];
while (list.length && !turn.stopping && !turn.finished) {
const item = list.shift();
if (!list.length) queues.delete(chatId);
onQueue(chatId, queueView(chatId));
emit({ type: 'queued-message', state: 'delivered', message: { id: item.id, text: item.text, at: item.at, deliverAt: item.deliverAt }, afterStep, queue: queueView(chatId) });
try { await runtime.gw.rpc('session/prompt', { request: { requestId: `queued-${randomUUID()}`, sessionId, mode: 'queue', content: [{ type: 'text', text: item.text }] } }); }
catch { emit({ type: 'queued-release', messages: [{ id: item.id, text: item.text }] }); continue; }
// Wait for the answer: the parent opens a turn for it and closes it again.
const t0 = Date.now();
while (!turn.parentOpen && Date.now() - t0 < 4000 && !turn.finished) await new Promise(r => setTimeout(r, 50));
while (turn.parentOpen && !turn.finished && !turn.stopping) await new Promise(r => setTimeout(r, 100));
}
}
/**
* The run is over when the parent is idle and every helper has finished and reported back. A helper's report
* wakes the parent a moment after the helper ends, so a helper that ended but has not reported yet keeps the run
@@ -427,7 +537,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
clearTimeout(turn.settleTimer);
if (turn.finished) return;
turn.settleTimer = after(turn.stopping ? 1200 : turn.children.size ? 600 : 0, async () => {
if (turn.finished || turn.parentOpen) return;
if (turn.finished || turn.parentOpen || turn.advancing) return;
const open = [...turn.children.values()];
if (open.some(c => !c.done)) return;
if (!turn.stopping) {
@@ -436,6 +546,15 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
const current = await goalGet();
if (turn.finished || turn.parentOpen) return;
if (current && current.phase === 'active' && current.activation === 'armed') { turn.settleTimer = after(700, armSettle); return; }
// A plan step's goal is complete: record it, deliver queued messages, start the next step.
if (turn.plan && current && current.phase === 'complete' && !turn.advancing) {
turn.advancing = true;
let more = false;
try { more = await advancePlan(); } catch (e) { emit({ type: 'delta', own: true, text: `\n\nThe plan stopped: ${plainGoalError(e)}\n` }); }
turn.advancing = false;
if (more && !turn.finished && !turn.stopping) { turn.settleTimer = after(700, armSettle); return; }
}
if (turn.goalRun && current) { const line = goalEnding(current); if (line) emit({ type: 'delta', text: line }); }
if (turn.goalRun) await announceGoal(true);
}
@@ -447,12 +566,13 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
async function announceGoal(final = false) {
const view = await goalGet();
if (turn.finished && !final) return;
const goal = goalObject(view);
const goal = goalObject(view, chatId);
emit({ type: 'goal', goal });
onGoal(chatId, goal);
if (!final && goal && goal.phase === 'active' && turn.goalRound) reporter.set({ phase: 'goal', code: 'goal-step', text: `Working on the goal, step ${turn.goalRound}`, key: 'Working on the goal, step {0}', values: [turn.goalRound], args: { round: turn.goalRound, max: goal.maxRounds } });
}
turn.announceGoal = announceGoal;
turn.announceQueue = frame => emit(frame);
const snapshotTimer = {};
await Promise.race([turn.snapshot.promise, new Promise((_, rej) => { snapshotTimer.t = after(20_000, () => rej(new Error('the session stream did not open'))); })])
@@ -464,8 +584,22 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
if (goal) {
// A goal run has no prompt of its own: the Harness's goal driver starts the rounds. Everything from now on is ours.
turn.live = true; turn.goalRun = true;
try { await startGoal(runtime, sessionId, goal); }
catch (e) { finishTurn(plainGoalError(e)); return { error: true }; }
try {
if (planRun) {
const plan = plans.get(chatId);
if (!plan || plan.id !== planRun.planId) throw new AgentUnavailable('That plan is no longer there. Ask for a new one with /plan.', 'plan-gone');
turn.plan = { id: plan.id };
const at = currentStep(plan);
if (at < 0) throw new AgentUnavailable('Every step of this plan is already done.', 'plan-done');
if (goal.action === 'resume' && plan.goalStarted) {
plans.update(chatId, p => { p.state = 'running'; p.steps[at].state = 'running'; });
await startGoal(runtime, sessionId, goal);
emit(publishPlan('step', plans.get(chatId)));
} else await startStep(plan, at);
} else {
await startGoal(runtime, sessionId, goal);
}
} catch (e) { finishTurn(e instanceof AgentUnavailable ? e.message : plainGoalError(e)); return { error: true }; }
reporter.set({ phase: 'waiting', code: 'reading-prompt', text: 'Reading your message…' });
void announceGoal();
} else {
@@ -519,17 +653,22 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
}
/** The goal as the page and the goal bar see it (notes-split-agent3.md), or null. */
function goalObject(view) {
function goalObject(view, chatId = null) {
if (!view) return null;
const phase = ['active', 'paused', 'complete', 'blocked'].includes(view.phase) ? view.phase : 'blocked';
return { objective: String(view.objective ?? ''), phase, round: view.roundsStarted ?? 0, maxRounds: view.maxGoalRounds ?? null, ...(phase === 'blocked' ? { blocked: plainBlocker(view.blockedReason) } : {}) };
// A goal that is running a plan (or the points of a goal) shows the plan's title and which step it is on.
const plan = chatId ? plans.get(chatId) : null;
const running = plan && plan.goalStarted && ['running', 'paused'].includes(plan.state) ? plan : null;
const at = running ? running.steps.findIndex(x => x.state !== 'done' && x.state !== 'skipped') : -1;
const step = running && at >= 0 ? { index: at + 1, total: running.steps.length, text: running.steps[at].text } : null;
return { objective: step ? running.title : String(view.objective ?? ''), ...(step ? { step } : {}), phase, round: view.roundsStarted ?? 0, maxRounds: view.maxGoalRounds ?? null, ...(phase === 'blocked' ? { blocked: plainBlocker(view.blockedReason) } : {}) };
}
/** The goal of a chat now, from the Harness (null: none, or the agent is not running). */
async function goalState(chatId) {
const known = sessionMap[chatId]?.sessionId;
if (!known || !rt?.hp.running) return null;
return goalObject(await rt.gw.rpc('goals/get', { agentId: known }).catch(() => undefined));
return goalObject(await rt.gw.rpc('goals/get', { agentId: known }).catch(() => undefined), chatId);
}
/** Pause, edit or remove the goal from the goal bar. Instant; resume runs rounds and goes through the chat stream. */
@@ -548,7 +687,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
await rt.gw.rpc('goals/edit', { agentId: known, ref, request: { objective } });
} else throw new AgentUnavailable('That goal action is not known.', 'goal-action');
} catch (e) { throw e instanceof AgentUnavailable ? e : new AgentUnavailable(plainGoalError(e), 'goal-failed', e.message); }
const goal = action === 'clear' ? null : goalObject(await rt.gw.rpc('goals/get', { agentId: known }).catch(() => undefined));
if (action === 'clear') { const plan = plans.get(chatId); if (plan && ['running', 'paused'].includes(plan.state)) { plans.update(chatId, p => { p.state = 'stopped'; for (const st of p.steps) if (st.state === 'running') st.state = 'pending'; }); onPlan(chatId, 'stopped', planView(plans.get(chatId))); } }
const goal = action === 'clear' ? null : goalObject(await rt.gw.rpc('goals/get', { agentId: known }).catch(() => undefined), chatId);
onGoal(chatId, goal);
return { goal };
}
@@ -605,11 +745,139 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
}
} catch (e) { return say(plainGoalError(e)); }
}
if (verb === 'resume') return runTurn({ ...options, goal: { action: 'resume' } });
if (verb === 'resume') {
const plan = plans.get(chatId);
const goalPlan = plan && plan.goalStarted && plan.state === 'paused' ? { planId: plan.id } : null;
return runTurn({ ...options, goal: { action: 'resume' }, ...(goalPlan ? { planRun: goalPlan } : {}) });
}
// A goal whose own words hold numbered points ("1. ... 2. ... 3. ...") runs point by point, each one reported.
const points = parsePoints(text);
if (points) {
const plan = plans.create(chatId, { title: points.title, steps: points.steps, request: text, source: 'goal', state: 'approved' });
onPlan(chatId, 'approved', planView(plan));
return runTurn({ ...options, goal: { action: 'create', objective: text }, planRun: { planId: plan.id } });
}
// Any other text is a new goal. One unfinished goal at a time (the Harness's rule): say so in plain words.
return runTurn({ ...options, goal: { action: 'create', objective: text } });
}
// -- /plan -----------------------------------------------------------------------------------------------
/** A short answer in the chat (no model): the same frames a turn sends. */
function sayOnce(options, reply) {
const { onFrame } = options;
onFrame({ type: 'queued', requestId: `plan-${randomUUID()}` });
onFrame({ type: 'start', model: '', contextMax: 0, demo: false, remoteModel: false, agent: true, mode: modeOf(options.mode), skills: [] });
onFrame({ type: 'delta', text: reply });
onFrame({ type: 'done', finishReason: 'stop', tokensIn: null, tokensOut: null, usageSource: 'unavailable', durationMs: 0 });
return { plan: true };
}
/**
* "/plan <what you want>": the assistant looks around (the chat is put in Plan mode for this turn, so nothing can change), writes the
* plan, and the host sends it as a `plan` frame. Nothing runs until the person approves it.
*/
async function planCommand(options) {
const task = String(options.line ?? '').trim();
if (!task) {
const open = plans.get(options.chatId);
return sayOnce(options, open && ['proposed', 'approved'].includes(open.state)
? 'A plan is waiting for you above. Approve it, edit it or reject it, or type /plan followed by something else to make a new one.'
: 'Type /plan followed by what you want done, for example: /plan build a small pagoda page. I will look around and write a plan; nothing changes until you approve it.');
}
return runTurn({ ...options, message: planPrompt(task), planTask: task, mode: 'plan' });
}
const planGone = () => new AgentUnavailable('There is no plan waiting in this chat. Ask for one with /plan followed by what you want done.', 'plan-gone');
function planOf(chatId, planId) {
const plan = plans.get(chatId);
if (!plan || (planId && plan.id !== planId)) throw planGone();
return plan;
}
const mustBeOpen = plan => { if (plan.state !== 'proposed' && plan.state !== 'approved') throw new AgentUnavailable('This plan has already been used. Ask for a new one with /plan.', 'plan-used'); };
/** The plan of a chat, or null. */
const planState = chatId => planView(plans.get(chatId));
/** Approve, edit or reject the plan; instant. Approve also returns the message that carries the plan out as an ordinary turn. */
function planControl({ chatId, planId, action, steps, title }) {
const plan = planOf(chatId, planId);
if (action === 'edit') {
mustBeOpen(plan);
const next = editedSteps(steps, plan.steps);
const t = String(title ?? '').replace(/\s+/g, ' ').trim().slice(0, 80);
plans.update(chatId, p => { p.steps = next; if (t) p.title = t; p.state = 'proposed'; });
onPlan(chatId, 'edited', planView(plans.get(chatId)));
return { plan: planView(plans.get(chatId)) };
}
if (action === 'approve') {
mustBeOpen(plan);
plans.update(chatId, p => { p.state = 'approved'; });
const approved = plans.get(chatId);
onPlan(chatId, 'approved', planView(approved));
return { plan: planView(approved), prompt: planAsPrompt(approved) };
}
if (action === 'reject') {
mustBeOpen(plan);
plans.update(chatId, p => { p.state = 'rejected'; });
onPlan(chatId, 'rejected', planView(plans.get(chatId)));
return { plan: planView(plans.get(chatId)) };
}
throw new AgentUnavailable('That plan action is not known.', 'plan-action');
}
/** "Start as goal": each step of the plan becomes a goal step, run one after another and reported. Streams like a chat turn. */
async function planGoal(options) {
const { chatId, planId } = options;
let plan;
try { plan = planOf(chatId, planId); } catch (e) { return sayOnce(options, e.message); }
if (modeOf(options.mode) === 'plan') return sayOnce(options, 'Plan mode only looks, so it cannot carry out a plan. Switch this chat to Manual or Bypass, then start it again.');
if (plan.state === 'paused' && plan.goalStarted) return goalCommand({ ...options, line: 'resume' });
try { mustBeOpen(plan); } catch (e) { return sayOnce(options, e.message); }
if (tools()?.off?.has('goals')) return sayOnce(options, 'Goals are turned off. You can turn them back on in Settings, Tools.');
plans.update(chatId, p => { p.state = 'approved'; });
onPlan(chatId, 'approved', planView(plans.get(chatId)));
return runTurn({ ...options, goal: { action: 'create', objective: plan.title }, planRun: { planId: plan.id } });
}
// -- messages typed while a goal runs ----------------------------------------------------------------------
/** Queue a message for the running goal. It is delivered between steps, or handed back to the page when the goal ends or pauses. */
function queueMessage({ chatId, text }) {
const turn = chats.get(chatId)?.turn;
if (!turn || !turn.goalRun || turn.stopping || turn.finished) throw new AgentUnavailable('No goal is running in this chat, so there is nothing to queue behind. Send the message as usual.', 'queue-idle');
const clean = String(text ?? '').trim().slice(0, 8000);
if (!clean) throw new AgentUnavailable('Type a few words to queue.', 'queue-empty');
const list = queues.get(chatId) ?? [];
if (list.length >= 20) throw new AgentUnavailable('Twenty messages are already waiting. Wait for some to be delivered.', 'queue-full');
const item = { id: randomUUID(), text: clean, at: new Date().toISOString(), deliverAt: turn.plan ? 'step' : 'end' };
list.push(item); queues.set(chatId, list);
const message = { id: item.id, text: item.text, at: item.at, deliverAt: item.deliverAt };
onQueue(chatId, queueView(chatId));
turn.announceQueue?.({ type: 'queued-message', state: 'queued', message, queue: queueView(chatId) });
return { message, queue: queueView(chatId) };
}
/** Take a queued message back (the x on it). A message already being delivered cannot be taken back. */
function cancelQueued({ chatId, id }) {
const list = queues.get(chatId) ?? [];
const at = list.findIndex(m => m.id === id);
if (at < 0) throw new AgentUnavailable('That message was already sent or removed.', 'queue-gone');
const [item] = list.splice(at, 1);
if (!list.length) queues.delete(chatId);
onQueue(chatId, queueView(chatId));
chats.get(chatId)?.turn?.announceQueue?.({ type: 'queued-message', state: 'cancelled', message: { id: item.id, text: item.text, at: item.at, deliverAt: item.deliverAt }, queue: queueView(chatId) });
return { queue: queueView(chatId) };
}
/** Messages waiting for a chat, and (after a goal ended while the page was away) the ones it should now send. */
function takeQueue(chatId, { release = false } = {}) {
const turn = chats.get(chatId)?.turn;
const list = queueView(chatId);
if (release && !turn && list.length) { queues.delete(chatId); onQueue(chatId, []); return { queue: [], released: list.map(({ id, text }) => ({ id, text })) }; }
return { queue: list, released: [] };
}
/**
* The person changed the chat's mode while a turn (a goal and its helpers included) is running. The new preset
* is put on the session at once, the running turn's own bookkeeping follows, and questions already waiting are
@@ -708,7 +976,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
/** Let go of a chat's Harness session mapping (a task starts every run in a session of its own). */
function forget(chatId) { if (chats.get(chatId)?.turn) return; delete sessionMap[chatId]; chats.delete(chatId); persist(); }
return { goalState, goalControl, harnessDir, status, runTurn, goalCommand, forget, approve, setMode, stop, pendingFor, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
return { goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
}
/** A goal that stopped by itself, in words. */
+189
View File
@@ -0,0 +1,189 @@
/**
* Plans (the /plan command) and goal points, as the host sees them. Pure helpers plus a small store; the bridge drives them.
*
* A plan is a short numbered list the assistant wrote after looking around (the chat was in Plan mode: nothing could change). The
* person approves it, edits it, rejects it, or starts it as a goal whose steps are the plan's steps. A /goal whose text already has
* numbered points ("1. ... 2. ... 3. ...") is run the same way, so a point is a step and each one is reported.
*
* Plan shape (also what the page renders, notes-split-agent6.md):
* { id, chatId, title, request, source: 'plan'|'goal', state, steps: [{ id, text, detail, state, summary }], createdAt, updatedAt, goalStarted }
* plan.state: proposed | approved | rejected | running | paused | done | stopped
* step.state: pending | running | done | failed | skipped
*/
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
export const MAX_STEPS = 12;
const MAX_TEXT = 140;
const MAX_DETAIL = 220;
const clean = s => String(s ?? '').replace(/\*\*|__|`/g, '').replace(/\s+/g, ' ').trim();
const clip = (s, n) => (s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s);
/** "Write the file — one file, inline styles" into { text, detail }. */
function splitStep(raw) {
const s = clean(raw).replace(/^(?:step\s*\d+\s*[:.\-]\s*)/i, '');
const at = s.search(/\s[—–]\s|\s-\s|:\s/);
const head = at > 8 ? s.slice(0, at) : s;
const rest = at > 8 ? s.slice(at).replace(/^\s*[—–:\-]\s*/, '') : '';
return { text: clip(head.replace(/[.;:]+$/, ''), MAX_TEXT), detail: clip(rest, MAX_DETAIL) };
}
/** The instruction a plan turn gets in front of the person's request. */
export function planPrompt(task) {
return [
'Make a plan for the request below. Do not change anything: you may only look (read files, list folders, search).',
'When you have looked enough, end your reply with the plan as a numbered list: one short line per step, in the order they should be done.',
'You may add the reason after a dash. Use 3 to 8 steps. Write nothing after the list.',
'',
`Request: ${task}`,
].join('\n');
}
/**
* The plan in a reply: its last run of numbered lines (at least two). Lines that follow a step and are indented or bulleted are that
* step's detail. Returns null when the reply holds no list, so the page shows the reply as it is and never a made-up plan.
*/
export function parsePlan(text, request = '') {
const lines = String(text ?? '').replace(/\r/g, '').split('\n');
const item = /^\s{0,3}(?:step\s+)?(\d{1,2})[.):]\s+(\S.*)$/i;
const runs = [];
let run = null;
lines.forEach((line, i) => {
const m = item.exec(line);
if (m) {
const n = Number(m[1]);
if (run && n === run.items.length + 1) { run.items.push({ raw: m[2], extra: [] }); run.end = i; return; }
if (n === 1) { run = { start: i, end: i, items: [{ raw: m[2], extra: [] }] }; runs.push(run); return; }
run = null; return;
}
if (run && /^\s+\S/.test(line)) { run.items.at(-1).extra.push(clean(line.replace(/^\s*[-*•]\s*/, ''))); run.end = i; return; }
if (run && /^\s*[-*•]\s+\S/.test(line)) { run.items.at(-1).extra.push(clean(line.replace(/^\s*[-*•]\s*/, ''))); run.end = i; return; }
if (run && line.trim() === '') return;
run = null;
});
const best = runs.filter(r => r.items.length >= 2).at(-1);
if (!best) return null;
const steps = best.items.slice(0, MAX_STEPS).map((it, k) => {
const { text: t, detail } = splitStep(it.raw);
const extra = clip(it.extra.join(' '), MAX_DETAIL);
return { id: `s${k + 1}`, text: t, detail: detail || extra, state: 'pending', summary: null };
}).filter(s => s.text);
if (steps.length < 2) return null;
let title = '';
for (let i = best.start - 1; i >= 0 && i >= best.start - 6; i--) {
const h = /^\s*#{1,6}\s+(.+?)\s*$/.exec(lines[i]);
if (h) { title = clean(h[1]); break; }
}
return { title: clip(title || clean(request) || 'Plan', 80), steps };
}
/** Numbered points inside a goal's own words ("1. a 2. b" on lines or in one line); at least two, counting up from 1. */
export function parsePoints(objective) {
const text = String(objective ?? '').replace(/\r/g, '');
const found = [];
const re = /(?:^|\s)(\d{1,2})[.)]\s+/g;
let m; const marks = [];
while ((m = re.exec(text))) marks.push({ n: Number(m[1]), at: m.index, after: m.index + m[0].length });
let want = 1; let start = -1;
const seq = [];
for (const k of marks) {
if (k.n === want) { if (want === 1) start = seq.length; seq.push(k); want++; }
else if (k.n === 1) { seq.length = 0; seq.push(k); want = 2; }
}
if (seq.length < 2) return null;
seq.forEach((k, i) => found.push(clean(text.slice(k.after, i + 1 < seq.length ? seq[i + 1].at : undefined))));
const aim = clean(text.slice(0, seq[0].at)).replace(/[:\-–—]+$/, '').trim();
const steps = found.slice(0, MAX_STEPS).map((t, k) => {
const { text: s, detail } = splitStep(t);
return { id: `s${k + 1}`, text: s, detail, state: 'pending', summary: null };
}).filter(s => s.text);
if (steps.length < 2) return null;
return { title: clip(aim || steps[0].text, 80), steps };
}
/** The goal the Harness runs for step `index` (0-based): one step, with the aim and the steps around it named. */
export function stepObjective(plan, index) {
const n = plan.steps.length; const s = plan.steps[index];
const done = plan.steps.slice(0, index).map((x, i) => `${i + 1}. ${x.text}`).join('; ');
return [
`Overall aim: ${plan.title}.`,
`You are on step ${index + 1} of ${n}: ${s.text}${s.detail ? ` (${s.detail})` : ''}.`,
done ? `Already done: ${done}.` : '',
'Do only this step, check that it worked, and then finish the goal.',
index + 1 < n ? 'The next steps are handled separately: do not start them.' : '',
].filter(Boolean).join(' ');
}
const WORD = { started: 'started', done: 'done', failed: "didn't finish", stopped: 'stopped', skipped: 'skipped' };
/** One short friendly line for a step: "Step 2 of 4: Write the roof — done". */
export function stepLine(index, total, text, state) {
return `Step ${index + 1} of ${total}: ${clip(clean(text), 90)} — ${WORD[state] ?? state}`;
}
/** The short summary of a step from the words it closed with: the first sentence, plain. */
export function firstSentence(text, max = 140) {
const s = clean(String(text ?? '').replace(/^#+\s.*$/gm, '').replace(/^[-*]\s+/gm, ''));
if (!s) return null;
const m = /^(.{12,}?[.!?])(?:\s|$)/.exec(s);
return clip(m ? m[1] : s, max);
}
/** The words a person sees for the plan, as one message the model can carry out (the plain Approve path). */
export function planAsPrompt(plan) {
return ['Carry out this plan, step by step, and tell me briefly when each step is done.', '', plan.title ? `Plan: ${plan.title}` : '', ...plan.steps.map((s, i) => `${i + 1}. ${s.text}${s.detail ? ` (${s.detail})` : ''}`)].filter((l, i) => l !== '' || i === 1).join('\n');
}
/** A step list from the person's edit: texts only, validated and capped. Throws a plain sentence for an unusable list. */
export function editedSteps(input, previous = []) {
if (!Array.isArray(input)) throw Object.assign(new Error('A plan needs a list of steps.'), { code: 'plan-steps' });
const steps = input.map((x, k) => {
const text = clip(clean(typeof x === 'string' ? x : x?.text), MAX_TEXT);
const detail = clip(clean(typeof x === 'string' ? '' : x?.detail), MAX_DETAIL);
const kept = previous.find(p => p.id === x?.id);
return text ? { id: kept?.id ?? `s${k + 1}`, text, detail, state: 'pending', summary: null } : null;
}).filter(Boolean).slice(0, MAX_STEPS);
if (!steps.length) throw Object.assign(new Error('A plan needs at least one step.'), { code: 'plan-steps' });
// ids must be unique and in order of the list
steps.forEach((s, i) => { s.id = `s${i + 1}`; });
return steps;
}
/** The latest plan of each chat, kept in memory and in one small file so a restart does not lose a proposal. */
export class PlanStore {
constructor(file = null) {
this.file = file; this.plans = new Map();
if (file && existsSync(file)) {
try { for (const p of JSON.parse(readFileSync(file, 'utf8'))) if (p?.chatId) this.plans.set(p.chatId, p); } catch { /* a damaged file starts empty */ }
}
}
#save() {
if (!this.file) return;
try {
mkdirSync(dirname(this.file), { recursive: true });
const tmp = this.file + '.tmp';
writeFileSync(tmp, JSON.stringify([...this.plans.values()]));
renameSync(tmp, this.file);
} catch { /* the plan still lives in memory */ }
}
get(chatId) { return this.plans.get(chatId) ?? null; }
create(chatId, { title, steps, request = '', source = 'plan', state = 'proposed' }) {
const now = new Date().toISOString();
const plan = { id: randomUUID(), chatId, title, request, source, state, steps, createdAt: now, updatedAt: now, goalStarted: false };
this.plans.set(chatId, plan); this.#save();
return plan;
}
update(chatId, fn) {
const plan = this.plans.get(chatId);
if (!plan) return null;
fn(plan); plan.updatedAt = new Date().toISOString();
this.#save();
return plan;
}
clear(chatId) { this.plans.delete(chatId); this.#save(); }
}
/** What the page gets: a copy with no host-only fields. */
export const planView = plan => (plan ? JSON.parse(JSON.stringify(plan)) : null);
+28 -2
View File
@@ -116,6 +116,8 @@ const agent = createAgentBridge({
tools: () => agentTools.profileView(),
skillsDir: () => AGENT_SKILLS,
onGoal: (chatId, goal) => hub.send('goal', { chatId, goal }),
onPlan: (chatId, event, plan) => hub.send('plan', { chatId, event, plan }),
onQueue: (chatId, queue) => hub.send('queue', { chatId, queue }),
dataDir: store.DATA_DIR,
componentsDir: componentsDirectory(),
// The installer ships the Harness next to the host (<install>/host/server -> <install>/harness).
@@ -521,8 +523,11 @@ async function runAgentTurn({ res, controller, chatId, body, message, workspaceI
try {
// "/goal ..." is the Harness's goal command (set, show, pause, resume, clear); everything else is a message.
const goalLine = /^\/goal(?:\s+([\s\S]*))?$/i.exec(message.trim());
const planLine = /^\/plan(?:\s+([\s\S]*))?$/i.exec(message.trim());
const turnOptions = { chatId, message, workspacePath: cwd, choice, mode: typeof body.mode === 'string' ? body.mode : null, thinking: typeof body.thinking === 'string' ? body.thinking : null, history: Array.isArray(body.history) ? body.history : [], onFrame: write, signal: controller.signal };
const result = goalLine ? await agent.goalCommand({ ...turnOptions, line: goalLine[1] ?? '' }) : await agent.runTurn(turnOptions);
const result = typeof body.planId === 'string' && body.message === '/plan start' ? await agent.planGoal({ ...turnOptions, planId: body.planId })
: planLine ? await agent.planCommand({ ...turnOptions, line: planLine[1] ?? '' })
: goalLine ? await agent.goalCommand({ ...turnOptions, line: goalLine[1] ?? '' }) : await agent.runTurn(turnOptions);
if (result?.detached) void result.ended.then(r => settle(r?.tokensOut ?? null), () => settle(null));
else settle(result?.tokensOut ?? null, result?.error ? 'The agent turn failed.' : undefined);
} catch (err) {
@@ -1607,6 +1612,26 @@ async function api(req, res, path, method, url) {
const body = await readBody(req);
try { return json(res, 200, await agent.goalControl({ chatId: String(body.chatId ?? body.sessionId ?? ''), action: goalAct[1], text: body.text })); } catch (e) { return error(res, 400, e.message); }
}
// Plans (notes-split-agent6.md): the plan of a chat, and the instant actions. "Start as goal" streams, so it is /api/plan/start-goal below.
if (path === '/api/plan' && method === 'GET') return json(res, 200, { plan: agent.planState(url.searchParams.get('sessionId') ?? url.searchParams.get('chatId') ?? '') });
const planAct = /^\/api\/plan\/(approve|edit|reject)$/.exec(path);
if (planAct && method === 'POST') {
const body = await readBody(req);
try { return json(res, 200, agent.planControl({ chatId: String(body.sessionId ?? body.chatId ?? ''), planId: typeof body.planId === 'string' ? body.planId : null, action: planAct[1], steps: body.steps, title: body.title })); }
catch (e) { return error(res, e.code === 'plan-gone' || e.code === 'plan-used' ? 409 : 400, e.message); }
}
// Messages typed while a goal runs: queued (shown as queued), taken back, or listed.
if (path === '/api/chat/queue' && method === 'GET') return json(res, 200, agent.takeQueue(url.searchParams.get('sessionId') ?? '', { release: url.searchParams.get('release') === '1' }));
if (path === '/api/chat/queue' && method === 'POST') {
const body = await readBody(req);
try { return json(res, 200, agent.queueMessage({ chatId: String(body.sessionId ?? ''), text: body.message })); }
catch (e) { return error(res, e.code === 'queue-idle' ? 409 : 400, e.message); }
}
if (path === '/api/chat/queue/cancel' && method === 'POST') {
const body = await readBody(req);
try { return json(res, 200, agent.cancelQueued({ chatId: String(body.sessionId ?? ''), id: String(body.id ?? '') })); }
catch (e) { return error(res, e.code === 'queue-gone' ? 409 : 400, e.message); }
}
if (path === '/api/agent/tools' && method === 'GET') return json(res, 200, agentTools.view());
if (path === '/api/agent/tools' && method === 'PUT') {
try { return json(res, 200, agentTools.update(await readBody(req))); } catch (e) { return error(res, 400, e.message); }
@@ -1650,8 +1675,9 @@ async function api(req, res, path, method, url) {
}
// --- chat ---------------------------------------------------------------
if (path === '/api/chat/stream' && method === 'POST') {
if ((path === '/api/chat/stream' || path === '/api/plan/start-goal') && method === 'POST') {
const body = await readBody(req,12*1024*1024);
if (path === '/api/plan/start-goal') { body.message = '/plan start'; if (typeof body.planId !== 'string') return error(res, 400, 'planId is required'); }
let images;try{images=validateChatImages(body.images);}catch(e){return error(res,400,e.message);}
if(images.length){if(body.remoteModel)return error(res,409,'Choose a local image-reading model before sending attached pictures.');let selected;try{selected=native.resolveInstance(body.nativeModelId??native.state().defaultId);}catch(e){return error(res,409,e.message);}if(!selected.vision)return error(res,409,'Enable Read pictures for this model before sending images.');}
// The goal bar's Resume has no words of its own: it is the same turn as "/goal resume".
+134
View File
@@ -0,0 +1,134 @@
// /plan, plan goals, goal points and queued messages through the real Harness with a scripted model.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startScriptedWorker } from './helpers/scripted-worker.mjs';
import { createAgentBridge } from '../server/agent/agent-bridge.mjs';
import { resolveHarness } from '../server/agent/harness-runtime.mjs';
const HARNESS = process.env.STRATA_HARNESS_DIR;
const skip = !HARNESS || !resolveHarness({ env: process.env }).ok ? 'STRATA_HARNESS_DIR is not set to a built Harness' : false;
const wait = ms => new Promise(r => setTimeout(r, ms));
const words = frames => frames.filter(f => f.type === 'delta').map(f => f.text).join('');
async function rig(t) {
const worker = await startScriptedWorker({});
const dir = mkdtempSync(join(tmpdir(), 'strata-planrun-'));
const ws = join(dir, 'ws'); mkdirSync(ws, { recursive: true });
const events = { plan: [], queue: [], goal: [] };
const local = [{ id: 'inst-1', label: 'Scripted', baseUrl: worker.url, modelId: 'scripted-model', context: 32768, slots: 2 }];
const bridge = createAgentBridge({ dataDir: dir, localInstances: () => local, connections: () => [], env: process.env, startTimeoutMs: 90_000,
onPlan: (chatId, event, plan) => events.plan.push({ chatId, event, plan }), onQueue: (chatId, queue) => events.queue.push({ chatId, queue }), onGoal: (chatId, goal) => events.goal.push({ chatId, goal }) });
t.after(async () => { await bridge.close(); await worker.close(); try { rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); } catch { /* best effort */ } });
const base = (chatId, onFrame = () => {}) => ({ chatId, workspacePath: ws, mode: 'bypass', onFrame });
return { worker, bridge, ws, events, base };
}
test('/plan: the assistant looks (Plan mode), the host sends the plan, and the person can edit, approve or reject it', { skip }, async (t) => {
const r = await rig(t);
const out = [];
await r.bridge.planCommand({ ...r.base('p1', f => out.push(f)), mode: 'manual', line: 'SCRIPT:planreply' });
const start = out.find(f => f.type === 'start');
assert.equal(start.mode, 'plan', 'a plan turn is read-only whatever mode the chat is in'); assert.equal(start.planning, true);
const plan = out.find(f => f.type === 'plan');
assert.equal(plan.event, 'proposed'); assert.equal(plan.plan.state, 'proposed'); assert.equal(plan.plan.steps.length, 3);
assert.ok(out.findIndex(f => f.type === 'plan') < out.findIndex(f => f.type === 'done'), 'the plan frame comes before the end of the turn');
assert.equal(r.events.plan.at(-1).event, 'proposed');
assert.equal(r.bridge.planState('p1').id, plan.plan.id);
const edited = r.bridge.planControl({ chatId: 'p1', planId: plan.plan.id, action: 'edit', steps: ['Only step SCRIPT:goalstep', { text: 'Second SCRIPT:goalstep', detail: 'why' }], title: 'My plan' });
assert.equal(edited.plan.steps.length, 2); assert.equal(edited.plan.state, 'proposed'); assert.equal(edited.plan.title, 'My plan');
const approved = r.bridge.planControl({ chatId: 'p1', planId: plan.plan.id, action: 'approve' });
assert.equal(approved.plan.state, 'approved'); assert.match(approved.prompt, /1\. Only step/);
assert.throws(() => r.bridge.planControl({ chatId: 'p1', planId: 'other', action: 'approve' }), /no plan waiting/);
const rej = r.bridge.planControl({ chatId: 'p1', planId: plan.plan.id, action: 'reject' });
assert.equal(rej.plan.state, 'rejected');
assert.throws(() => r.bridge.planControl({ chatId: 'p1', planId: plan.plan.id, action: 'edit', steps: ['x'] }), /already been used/);
assert.deepEqual(r.events.plan.map(e => e.event), ['proposed', 'edited', 'approved', 'rejected']);
});
test('Start as goal: each plan step is a goal step, reported one by one, queued messages are delivered between steps, and the plan ends done', { skip }, async (t) => {
const r = await rig(t);
await r.bridge.planCommand({ ...r.base('p2'), line: 'SCRIPT:planreply' });
const plan = r.bridge.planState('p2');
assert.equal(plan.steps.length, 3);
const out = [];
let queued = null;
await r.bridge.planGoal({ ...r.base('p2', f => {
out.push(f);
if (f.type === 'plan-step' && f.index === 1 && f.state === 'running' && !queued) { try { queued = r.bridge.queueMessage({ chatId: 'p2', text: 'Also add a moon SCRIPT:echo noted' }); } catch (e) { out.push({ type: 'test-error', message: e.message }); } }
}), planId: plan.id });
assert.deepEqual(out.filter(f => f.type === 'test-error'), []);
const stepFrames = out.filter(f => f.type === 'plan-step').map(f => `${f.index}:${f.state}`);
assert.deepEqual(stepFrames, ['1:running', '1:done', '2:running', '2:done', '3:running', '3:done']);
const lines = words(out);
assert.match(lines, /Step 1 of 3: First thing SCRIPT:goalstep — done/); assert.match(lines, /Step 3 of 3: Third thing SCRIPT:goalstep — done/);
assert.ok(out.find(f => f.type === 'plan-step' && f.state === 'done').summary, 'a done step carries a short summary from its closing words');
const goals = out.filter(f => f.type === 'goal' && f.goal);
assert.ok(goals.some(f => f.goal.step?.index === 2 && f.goal.step.total === 3 && f.goal.objective === plan.title), 'the goal bar knows the step and shows the plan title');
assert.equal(r.bridge.planState('p2').state, 'done'); assert.ok(r.bridge.planState('p2').steps.every(s => s.state === 'done'));
assert.equal(out.filter(f => f.type === 'plan').at(-1).event, 'done');
assert.equal(queued.message.deliverAt, 'step');
assert.deepEqual(out.filter(f => f.type === 'queued-message').map(f => f.state), ['queued', 'delivered']);
assert.equal(out.find(f => f.type === 'queued-message' && f.state === 'delivered').afterStep, 1);
assert.equal(out.filter(f => f.type === 'queued-release').length, 0);
assert.match(words(out), /noted/, 'the assistant answered the delivered message');
assert.equal(out.at(-1).type, 'done');
assert.deepEqual(r.bridge.takeQueue('p2'), { queue: [], released: [] });
});
test('a goal with numbered points runs point by point and reports each one', { skip }, async (t) => {
const r = await rig(t);
const out = [];
await r.bridge.goalCommand({ ...r.base('p3', f => out.push(f)), line: 'Make the page: 1. draw the base SCRIPT:goalstep 2. add the roof SCRIPT:goalstep' });
const plan = r.bridge.planState('p3');
assert.equal(plan.source, 'goal'); assert.equal(plan.title, 'Make the page'); assert.equal(plan.state, 'done');
assert.match(words(out), /Step 1 of 2: draw the base SCRIPT:goalstep — done/); assert.match(words(out), /Step 2 of 2: add the roof SCRIPT:goalstep — done/);
});
test('a message typed while a goal without points runs is queued, can be taken back, and is handed back when the goal is stopped', { skip }, async (t) => {
const r = await rig(t);
assert.throws(() => r.bridge.queueMessage({ chatId: 'q1', text: 'hello' }), /No goal is running/);
const out = [];
const run = r.bridge.runTurn({ ...r.base('q1', f => out.push(f)), goal: { action: 'create', objective: 'keep going', maxRounds: 50 } });
await wait(3000);
const a = r.bridge.queueMessage({ chatId: 'q1', text: 'first thing' });
const b = r.bridge.queueMessage({ chatId: 'q1', text: 'second thing' });
assert.equal(a.message.deliverAt, 'end'); assert.deepEqual(b.queue.map(m => m.text), ['first thing', 'second thing']);
const back = r.bridge.cancelQueued({ chatId: 'q1', id: b.message.id });
assert.deepEqual(back.queue.map(m => m.text), ['first thing']);
assert.throws(() => r.bridge.cancelQueued({ chatId: 'q1', id: b.message.id }), /already sent or removed/);
await r.bridge.stop('q1');
await run;
const rel = out.find(f => f.type === 'queued-release');
assert.deepEqual(rel.messages.map(m => m.text), ['first thing']);
assert.ok(out.findIndex(f => f.type === 'queued-release') < out.findIndex(f => f.type === 'done'));
assert.deepEqual(out.filter(f => f.type === 'queued-message').map(f => f.state), ['queued', 'queued', 'cancelled']);
assert.deepEqual(r.bridge.takeQueue('q1'), { queue: [], released: [] });
});
test('Stop in the middle of a plan pauses it with the finished steps kept; Resume goes on from the unfinished step', { skip }, async (t) => {
const r = await rig(t);
await r.bridge.planCommand({ ...r.base('p4'), line: 'SCRIPT:planreply' });
const plan = r.bridge.planState('p4');
r.bridge.planControl({ chatId: 'p4', planId: plan.id, action: 'edit', steps: ['Quick one SCRIPT:goalstep', 'Slow one SCRIPT:slow 400', 'Last one SCRIPT:goalstep'] });
const out = [];
const run = r.bridge.planGoal({ ...r.base('p4', f => out.push(f)), planId: plan.id });
for (let i = 0; i < 100 && !out.some(f => f.type === 'plan-step' && f.index === 2 && f.state === 'running'); i++) await wait(100);
await wait(1500);
assert.equal((await r.bridge.stop('p4')).stopped, true);
await run;
const paused = r.bridge.planState('p4');
assert.equal(paused.state, 'paused'); assert.deepEqual(paused.steps.map(s => s.state), ['done', 'pending', 'pending']);
assert.equal(out.filter(f => f.type === 'plan').at(-1).event, 'paused');
const goal = await r.bridge.goalState('p4');
assert.equal(goal.phase, 'paused'); assert.equal(goal.step.index, 2);
const again = [];
const second = r.bridge.goalCommand({ ...r.base('p4', f => again.push(f)), line: 'resume' });
for (let i = 0; i < 100 && !again.some(f => f.type === 'plan' && f.event === 'step'); i++) await wait(100);
assert.equal(r.bridge.planState('p4').state, 'running'); assert.equal(r.bridge.planState('p4').steps[1].state, 'running');
await wait(1000);
await r.bridge.stop('p4'); await second;
assert.equal(r.bridge.planState('p4').state, 'paused');
});
+80
View File
@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parsePlan, parsePoints, stepObjective, stepLine, firstSentence, editedSteps, planAsPrompt, PlanStore, planPrompt } from '../server/agent/plan.mjs';
test('a plan is the last numbered list of the reply, with reasons after a dash and indented detail kept', () => {
const p = parsePlan([
'I looked at the folder. It is empty.',
'',
'## Pagoda page',
'1. Look at what is already in the folder',
'2. **Write pagoda.html** with the night sky and the glow — one file, inline styles',
' - keep it under 10 KB',
'3. Open it and check it renders.',
'',
'Tell me if you want changes.',
].join('\n'), 'build a pagoda');
assert.equal(p.title, 'Pagoda page');
assert.deepEqual(p.steps.map(s => s.text), ['Look at what is already in the folder', 'Write pagoda.html with the night sky and the glow', 'Open it and check it renders']);
assert.equal(p.steps[1].detail, 'one file, inline styles');
assert.equal(p.steps[2].state, 'pending');
assert.deepEqual(p.steps.map(s => s.id), ['s1', 's2', 's3']);
});
test('a reply with no list gives no plan, so the page shows the words as they are', () => {
assert.equal(parsePlan('I would just write the file.', 'x'), null);
assert.equal(parsePlan('1. only one item', 'x'), null);
assert.equal(parsePlan('3. a\n4. b', 'x'), null, 'a list must start at 1');
});
test('the last list wins, and a title falls back to the request', () => {
const p = parsePlan('1. old a\n2. old b\n\nBetter:\n1. new a\n2. new b\n3. new c', 'Make a thing');
assert.deepEqual(p.steps.map(s => s.text), ['new a', 'new b', 'new c']);
assert.equal(p.title, 'Make a thing');
});
test('goal points are found in lines or in one sentence, and one point is not a list', () => {
const a = parsePoints('Build the page: 1. draw the base 2. add the roof 3. add the glow');
assert.equal(a.title, 'Build the page');
assert.deepEqual(a.steps.map(s => s.text), ['draw the base', 'add the roof', 'add the glow']);
const b = parsePoints('Make it work\n1) first\n2) second');
assert.equal(b.steps.length, 2);
assert.equal(parsePoints('Fix the 3. test and see'), null);
assert.equal(parsePoints('1. only'), null);
assert.equal(parsePoints('make every test pass'), null);
});
test('a step goal names its step, what is done and what is not its job', () => {
const plan = { title: 'Pagoda', steps: [{ text: 'Look', detail: '' }, { text: 'Write it', detail: 'one file' }, { text: 'Check', detail: '' }] };
const o = stepObjective(plan, 1);
assert.match(o, /step 2 of 3: Write it \(one file\)/); assert.match(o, /Already done: 1\. Look/); assert.match(o, /do not start them/);
assert.ok(!/do not start/.test(stepObjective(plan, 2)));
assert.equal(stepLine(1, 4, 'Build the roof', 'done'), 'Step 2 of 4: Build the roof — done');
assert.equal(stepLine(0, 4, 'x', 'failed'), "Step 1 of 4: x — didn't finish");
assert.equal(firstSentence('## Done\n\nThe roof is built and tiled. Then I checked it.'), 'The roof is built and tiled.');
assert.equal(firstSentence(' '), null);
});
test('an edit keeps order, drops empty lines and refuses an empty plan', () => {
const steps = editedSteps([' one ', '', { text: 'two', detail: 'why' }, { text: ' ' }]);
assert.deepEqual(steps.map(s => [s.id, s.text, s.detail]), [['s1', 'one', ''], ['s2', 'two', 'why']]);
assert.throws(() => editedSteps([]), /at least one step/); assert.throws(() => editedSteps('x'), /list of steps/);
assert.match(planAsPrompt({ title: 'T', steps }), /1\. one\n2\. two \(why\)/);
assert.match(planPrompt('do x'), /Request: do x/);
});
test('plans survive a restart', () => {
const dir = mkdtempSync(join(tmpdir(), 'strata-plans-'));
try {
const file = join(dir, 'plans.json');
const a = new PlanStore(file);
const plan = a.create('chat-1', { title: 'T', steps: editedSteps(['a', 'b']), request: 'r' });
a.update('chat-1', p => { p.state = 'approved'; });
const b = new PlanStore(file);
assert.equal(b.get('chat-1').id, plan.id); assert.equal(b.get('chat-1').state, 'approved');
b.clear('chat-1'); assert.equal(new PlanStore(file).get('chat-1'), null);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
@@ -23,6 +23,8 @@
* SCRIPT:read <file> the `read` tool on <file>
* SCRIPT:spawn <n> <script...> n `subagent` calls in one reply; each child's prompt is SCRIPT:<script...>. When a child's
* completion notice wakes the parent, it answers with a short text quoting the notice.
* SCRIPT:goalstep one goal round: get_goal, then update_goal complete, then a short closing text
* SCRIPT:planreply a reply ending with a numbered plan of three steps (each step's text carries SCRIPT:goalstep)
* SCRIPT:call <tool> <json> one call to any tool with the given JSON arguments
*
* A request whose last message is a tool result is answered with a text that quotes the result.
@@ -106,6 +108,16 @@ export async function startScriptedWorker({ port = 0, chunkDelayMs = 40, model =
`, sandbox_permissions: 'workspace-write', justification: 'The user asked for this file to be written.' }
: { command: shellCommand(file, value), description: `Append one line to ${file}`, sandbox_permissions: 'workspace-write', justification: 'The user asked for this file to be written.' });
}
if (directive?.[1] === 'goalstep') {
// A goal round: read the goal, mark it complete, then say so.
if (!toolMessages.length) return toolCall('get_goal', {});
if (toolMessages.length === 1 && last.role === 'tool') {
const t = text(last); const id = /"id"\s*:\s*"([^"]+)"/.exec(t)?.[1]; const revision = Number(/"revision"\s*:\s*(\d+)/.exec(t)?.[1] ?? 1);
if (id) return toolCall('update_goal', { goal_id: id, revision, action: 'complete' });
}
return say(['Step finished. The result is checked.']);
}
if (directive?.[1] === 'planreply') return say(['I looked around the folder first.\n\n## The pagoda plan\n\n', '1. First thing SCRIPT:goalstep\n', '2. Second thing SCRIPT:goalstep\n', '3. Third thing SCRIPT:goalstep\n']);
if (last.role === 'tool') {
const result = text(last).replace(/\s+/g, ' ').slice(0, 120);
return say(['Tool finished. ', `Result: ${result}`]);