Files
openchamber/packages/web/server/lib/session-goal/DOCUMENTATION.md
T
Bohdan Triapitsyn bb45164ae8 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.
2026-07-12 01:23:22 +03:00

150 lines
8.7 KiB
Markdown

# Session Goal
Server-side control loop that keeps a session working toward a user-defined
objective stored under `metadata.openchamber.goal`, with the small model as
an independent progress auditor. Built on OpenChamber's backend-driven
architecture (session-assist is the structural template): the loop lives in
the web server and survives UI disconnects.
## Goal payload (`metadata.openchamber.goal`)
```
{
id, // opaque per-logical-goal id; stale-write guard
objective, // user text, <= 2000 chars
status, // active | paused | blocked | budgetLimited | complete
tokenBudget, // optional positive int
tokensUsed, // tokensCommitted + current segment (snapshot - baseline)
tokensBaseline, // segment start snapshot (pre-goal turn; 0 after compaction)
tokensCommitted, // closed segments' total (one segment per compaction)
turnsUsed, // auto-continuations sent (capped at MAX_AUTO_TURNS)
blockedStreak, // consecutive blocked audit verdicts
auditFailStreak, // consecutive failed/unavailable audit calls
note, // latest audit progress note, <= 280 chars
statusReason, // why settled; 'resumed' is a kickoff signal from UI
lastAccountedMessageID, // incremental accounting cursor
createdAt, updatedAt
}
```
The UI writes goals (create/edit/pause/resume/clear) by patching this
metadata; the runtime never creates a goal on its own. Goal creation happens
at send time via the arm store (`useSessionGoalArmStore`): the composer
target button arms "the next prompt is the objective", and the run-as-goal
flows (fork-from-answer dialog, plan implement dialog) arm the same way —
the plan flow additionally supplies an objective OVERRIDE carrying the plan
content, since "Implement this plan: X" alone gives the audit nothing to
judge against. The armed send also attaches a synthetic system-reminder
part telling the agent goal mode is active and that each turn should end
with a factual done/verified/remaining statement for the independent audit.
Freshness/stale-write protection is by `id`: every runtime write re-reads the
session and drops the write when the stored goal id no longer matches.
## Flow
1. `createSessionGoalRuntime` subscribes to the global SSE hub (same pattern
as session-assist — it needs the envelope's `directory`).
2. `session.status: idle` arms a 15s per-session timer; `busy`/`retry` clears
it. A `session.updated` carrying a fresh active goal (`turnsUsed === 0` or
`statusReason === 'resumed'`) arms a kickoff timer — 3s for fresh goals,
~250ms for an explicit Resume so the nudge feels immediate — since setting
a goal on an idle session emits no status transition.
3. On fire (`tick`), gated by the `sessionGoalEnabled` setting:
- fetch session (skip sub-agent sessions), require an `active` goal;
- quiescence check via the message tail (trailing user message or
unfinished assistant reply → bail; the next idle transition re-arms);
- token accounting as a SNAPSHOT of the latest completed assistant turn:
`input + cache.read + output`. Earlier turns' inputs and outputs fold
into the next turn's cache, so the latest snapshot already carries the
whole run's paid tokens — no summing across messages. Goal-relative via
`tokensBaseline` (the same snapshot of the newest pre-goal turn,
captured on the first tick). Compaction (an assistant message with
`summary: true`) breaks the snapshot chain, so accounting is segmented:
the summary message closes the segment into `tokensCommitted` (the
summary turn read the whole context, so its snapshot prices the
compaction itself) and the next segment starts with a zero baseline.
`tokensUsed = tokensCommitted + current segment`, kept monotonic so
unflagged context shrinks never move the budget backwards;
- a user abort pauses the goal instead of blocking it: the event path in
`processPayload` pauses immediately on the MessageAbortedError message
(before any tick could send a continuation over the user's explicit
stop), with a tick-side safety net. Messages sent while paused leave
the goal alone; Resume re-arms the loop, and resuming over an aborted
tail skips the audit and goes straight to a continuation nudge;
- terminal checks, cheapest first: assistant turn error → `blocked`;
`tokensUsed >= tokenBudget``budgetLimited`;
`turnsUsed >= MAX_AUTO_TURNS` (20) → `blocked`;
- if the latest message is a compaction summary, skip the audit and
continue unconditionally — running into the context window mid-work is
by definition "in progress, not finished" (the summary is a retelling,
not evidence, and must not be judged);
- otherwise, small-model audit of the objective + the last assistant turn
only — no conversation history and no continuation prompts
(`restrictToPreferredProvider`, session's own provider/model preferred):
JSON `{verdict: continue|complete|blocked, note}`. The audit is the SOLE
termination authority besides the hard stops above — the working agent
has no channel to settle its own goal. `complete` settles; `blocked`
increments `blockedStreak` and settles only after 3 consecutive blocked
verdicts, so a one-off snag cannot end the goal. Audit failure/absence
tolerates ONE consecutive unaudited continuation (`auditFailStreak`); a
second consecutive failure settles the goal as `blocked` ("progress
audit unavailable") — resumable, and settling resets the streak so
Resume gets fresh tolerance. A dead small model can never drive the
loop blind to the turn cap;
- continue: persist accounting + `turnsUsed` first (a crash after the
write just waits for the next idle tick; the reverse could double-send),
re-check the tail, then `POST /session/:id/prompt_async` with the
continuation prompt using the last assistant message's
provider/model/agent — the goal spends the session's own subscription.
4. Settling (`complete`/`blocked`/`budgetLimited`) fires the injected
`emitGoalNotification` so the user hears about it even with the UI closed:
desktop + UI broadcast + the standard push fanout (web-push with full
text; APNs with a generic per-type title and the session name as body).
It obeys the notify-on-completion setting. Conversely, while a goal is
ACTIVE the notifications runtime suppresses per-turn "ready"
notifications on every channel — they would only echo the loop's own
continuations; error/question/permission notifications are untouched.
Pausing a goal from the UI also aborts the running turn (and vice versa —
an abort pauses the goal), so "stop" means stop on both axes.
## Continuation prompt
Built inline in `runtime.js`: the objective as untrusted user data in an
XML-escaped `<objective>` block, budget numbers, keep-the-full-objective and
work-from-evidence rules, a completion-audit instruction, and the requirement
to end every turn with a factual done/verified/remaining report — the audit
sees only that final turn, so the report is its evidence.
## UI consumers (packages/ui)
- `lib/sessionGoalMetadata.ts` — payload parsing/types.
- `lib/sessionGoalActions.ts` — create/edit/pause/resume/clear via
`patchSessionMetadata`; `lib/sessionGoalPresentation.ts` — status
colors/labels shared across surfaces.
- `stores/useSessionGoalArmStore.ts` — the "next prompt starts a goal" flag,
consumed by `sendMessage` in `sync/session-ui-store.ts` (works for drafts).
- `hooks/useSessionGoal.ts` — live goal state.
- `components/chat/SessionGoalButton.tsx` — composer target button
(arm / status color / cancel confirm); `SessionGoalRow.tsx` — goal strip
above the composer; `SessionGoalDialog.tsx` — manage dialog
(edit/pause/resume/complete/clear).
- Sidebar glyph next to the date in `SessionNodeItem`.
## Scheduled goals
Scheduled tasks can run as goals: `execution.goalEnabled` (+ optional
`execution.goalTokenBudget`) on a task makes the scheduled-tasks runtime
stamp `metadata.openchamber.goal` onto the fresh session (objective = the
expanded task prompt) and attach the goal-mode intro part to the prompt.
The loop here picks it up from session events like any other goal.
## Limitations
- Web-server feature: VS Code (extension-only) renders goal state via
`session.updated` but does not run the loop.
- A goal on a session with no assistant reply yet starts after the first
user exchange completes (no provider/model to continue with before that).
- `tokensUsed` only counts completed assistant messages seen within the
40-message fetch window per tick; extremely long busy stretches between
idles undercount (acceptable: budget is a guardrail, not billing).