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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user