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:
committed by
GitHub
parent
82c039117a
commit
bb45164ae8
@@ -0,0 +1,124 @@
|
||||
import { abortCurrentOperation, patchSessionMetadata } from '@/sync/session-actions';
|
||||
import {
|
||||
SESSION_GOAL_OBJECTIVE_CHAR_LIMIT,
|
||||
type SessionGoalPayload,
|
||||
type SessionGoalStatus,
|
||||
} from '@/lib/sessionGoalMetadata';
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const createGoalId = (): string =>
|
||||
`${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const writeGoal = (
|
||||
sessionId: string,
|
||||
directory: string | undefined,
|
||||
update: (currentGoal: Record<string, unknown> | null) => Record<string, unknown> | null,
|
||||
) =>
|
||||
patchSessionMetadata(sessionId, directory, (metadata) => {
|
||||
const namespace = isRecord(metadata.openchamber) ? metadata.openchamber : {};
|
||||
const currentGoal = isRecord(namespace.goal) ? namespace.goal : null;
|
||||
const nextGoal = update(currentGoal);
|
||||
const nextNamespace = { ...namespace };
|
||||
if (nextGoal) {
|
||||
nextNamespace.goal = nextGoal;
|
||||
} else {
|
||||
delete nextNamespace.goal;
|
||||
}
|
||||
return { ...metadata, openchamber: nextNamespace };
|
||||
});
|
||||
|
||||
export interface SetSessionGoalInput {
|
||||
objective: string;
|
||||
tokenBudget: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new goal (fresh id resets accounting) or edit the existing one
|
||||
* (id and usage counters preserved).
|
||||
*/
|
||||
export async function setSessionGoal(
|
||||
sessionId: string,
|
||||
directory: string | undefined,
|
||||
input: SetSessionGoalInput,
|
||||
existing: SessionGoalPayload | null,
|
||||
): Promise<void> {
|
||||
const objective = input.objective.trim().slice(0, SESSION_GOAL_OBJECTIVE_CHAR_LIMIT);
|
||||
if (!objective) {
|
||||
throw new Error('Goal objective must not be empty');
|
||||
}
|
||||
const tokenBudget = typeof input.tokenBudget === 'number' && Number.isFinite(input.tokenBudget) && input.tokenBudget > 0
|
||||
? Math.floor(input.tokenBudget)
|
||||
: null;
|
||||
const now = Date.now();
|
||||
await writeGoal(sessionId, directory, (currentGoal) => {
|
||||
if (existing && currentGoal && currentGoal.id === existing.id && existing.status !== 'complete') {
|
||||
// Edit in place: keep accounting, reactivate, clear stale audit state.
|
||||
return {
|
||||
...currentGoal,
|
||||
objective,
|
||||
tokenBudget,
|
||||
status: 'active',
|
||||
statusReason: 'resumed',
|
||||
blockedStreak: 0,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: createGoalId(),
|
||||
objective,
|
||||
status: 'active',
|
||||
tokenBudget,
|
||||
tokensUsed: 0,
|
||||
turnsUsed: 0,
|
||||
blockedStreak: 0,
|
||||
note: '',
|
||||
statusReason: '',
|
||||
lastAccountedMessageID: '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function setSessionGoalStatus(
|
||||
sessionId: string,
|
||||
directory: string | undefined,
|
||||
status: Extract<SessionGoalStatus, 'active' | 'paused' | 'complete'>,
|
||||
): Promise<void> {
|
||||
// Pausing a goal also stops the agent's current turn — same mental model
|
||||
// as the stop button, expressed through goal control. A no-op when the
|
||||
// session is already idle.
|
||||
if (status === 'paused') {
|
||||
void abortCurrentOperation(sessionId);
|
||||
}
|
||||
await writeGoal(sessionId, directory, (currentGoal) => {
|
||||
if (!currentGoal) return null;
|
||||
return {
|
||||
...currentGoal,
|
||||
status,
|
||||
// 'resumed' is the server's kickoff signal for an already-idle session.
|
||||
statusReason: status === 'active' ? 'resumed' : (status === 'complete' ? 'marked by user' : ''),
|
||||
blockedStreak: 0,
|
||||
// An explicit resume grants a fresh auto-continuation allowance —
|
||||
// otherwise a goal blocked on the turn cap would re-block on the very
|
||||
// next tick and Resume would be a dead end.
|
||||
...(status === 'active' ? { turnsUsed: 0 } : {}),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearSessionGoal(sessionId: string, directory: string | undefined): Promise<void> {
|
||||
let wasActive = false;
|
||||
await writeGoal(sessionId, directory, (currentGoal) => {
|
||||
wasActive = currentGoal?.status === 'active';
|
||||
return null;
|
||||
});
|
||||
// Removing a running goal is a "stop" too — abort the current turn like
|
||||
// pause does. A no-op when the session is idle.
|
||||
if (wasActive) {
|
||||
void abortCurrentOperation(sessionId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user