feat: session goals - server-driven goal loop with independent small-model audit (#2148)

Arm the target button in the composer and the next prompt becomes a goal:
the server keeps the session working toward it (idle tick -> small-model
audit -> continuation) until the objective is verifiably complete, blocked,
or out of budget — even with the UI closed.

Server (packages/web/server/lib/session-goal):
- event-driven loop on the global SSE hub; goal state lives in
  session.metadata.openchamber.goal (merge-safe patches, stale-write guard
  by goal id), so it survives restarts and syncs to every client for free
- the small-model audit (objective + last assistant turn only, language
  pinned to the objective) is the sole termination authority; blocked needs
  3 consecutive verdicts, audit outages tolerate one unaudited continuation
  then stop the goal as resumable-blocked
- hard stops: optional token budget, auto-continuation cap (Resume grants a
  fresh allowance), turn errors; user abort pauses the goal instead of
  blocking it, and resuming over an aborted tail nudges immediately
- token accounting as a snapshot of the latest turn (input + cache.read +
  output), goal-relative via a creation baseline and segmented across
  compactions; a compaction summary skips the audit and continues
- continuations reuse the session's own provider/model/agent/variant

UI:
- three-mode target button (arm / disarm / manage dialog), informational
  goal strip with inline pause/resume and an Evaluating indicator, sidebar
  state glyph, objective length counter (2000-char server clamp),
  read-only completed goals
- goal entry points: composer (sessions and drafts), start-new-session-
  from-answer dialog, plan implement dialog (plan content becomes the
  objective), scheduled tasks (Run as goal + budget)
- Settings -> Chat -> Goal: feature toggle + default token budget with
  three-layer parity (web server, client persistence, VS Code bridge);
  VS Code renders goal state but hides the entry points (the loop runs in
  the web server only)

Notifications: per-turn "ready" notifications are suppressed while a goal
is active; settling sends one final notification (desktop, web-push, APNs
generic titles with the session name as body) honoring the completion
toggle. Error/question/permission notifications are untouched.

Docs: user guide (session-goals) in all 9 locales + sidebar entry,
scheduled-tasks cross-reference, server module DOCUMENTATION.md.
This commit is contained in:
Bohdan Triapitsyn
2026-07-12 01:23:22 +03:00
committed by GitHub
parent 82c039117a
commit bb45164ae8
73 changed files with 3330 additions and 29 deletions
@@ -406,6 +406,21 @@ export const createScheduledTasksRuntime = (deps) => {
return projectRunning < maxProjectConcurrency;
};
// Same instruction the composer attaches on an armed goal send: the agent
// must know goal mode is on from turn one, and each turn has to end with a
// factual report for the independent audit.
const buildGoalIntroText = (tokenBudget) => {
const budgetLine = tokenBudget
? ` A token budget of ${tokenBudget} tokens applies to this goal.`
: '';
return '<system-reminder>\n'
+ 'Goal mode is active for this session. The user message above defines the goal objective. '
+ 'Work toward it across turns; whenever you stop before the objective is verifiably complete, the system will automatically prompt you to continue. '
+ 'Progress is evaluated independently after each turn, so end every turn with a clear, factual statement of what is done, what was verified, and what remains.'
+ budgetLine
+ '\n</system-reminder>';
};
const buildPromptAsyncPayload = (task, projectPath) => ({
model: {
providerID: task.execution.providerID,
@@ -418,9 +433,47 @@ export const createScheduledTasksRuntime = (deps) => {
type: 'text',
text: expandSnippets(task.execution.prompt, projectPath),
},
...(task.execution.goalEnabled
? [{ type: 'text', text: buildGoalIntroText(task.execution.goalTokenBudget), synthetic: true }]
: []),
],
});
// Scheduled goal runs: stamp the goal onto the fresh session's metadata
// before the prompt goes out; the session-goal runtime picks the loop up
// from session events like any other goal.
const createTaskGoal = async ({ baseUrl, authHeaders, sessionID, projectPath, task }) => {
const now = Date.now();
const goal = {
id: `${now.toString(36)}${Math.random().toString(36).slice(2, 8)}`,
objective: expandSnippets(task.execution.prompt, projectPath).slice(0, 2000),
status: 'active',
tokenBudget: task.execution.goalTokenBudget || null,
tokensUsed: 0,
turnsUsed: 0,
blockedStreak: 0,
note: '',
statusReason: '',
lastAccountedMessageID: '',
createdAt: now,
updatedAt: now,
};
const url = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}`);
url.searchParams.set('directory', projectPath);
const response = await fetch(url.toString(), {
method: 'PATCH',
headers: {
...authHeaders,
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({ metadata: { openchamber: { goal } } }),
});
if (!response.ok) {
throw new Error(`goal metadata patch failed (${response.status})`);
}
};
const runPromptAsync = async ({ baseUrl, authHeaders, sessionID, projectPath, task }) => {
const promptUrl = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}/prompt_async`);
promptUrl.searchParams.set('directory', projectPath);
@@ -511,6 +564,10 @@ export const createScheduledTasksRuntime = (deps) => {
} catch {
}
if (task.execution.goalEnabled) {
await createTaskGoal({ baseUrl, authHeaders, sessionID, projectPath, task });
}
const executedAsCommand = await runScheduledCommandIfApplicable({
client,
projectPath,