feat: distill any oversized goal objective, not just plans

The distillation moved from PlanView into setSessionGoal — the shared
choke point for every goal source (composer, fork dialog, plans) — and
into the scheduled-tasks goal creation server-side. Any objective over
5000 chars is distilled into completion criteria by the small model; on a
transient failure a head+tail excerpt keeps the intent (top) and the
acceptance criteria (bottom) with a trim marker, plus a toast. The
working agent always received the full prompt in chat — only the auditor
is bound by the limit.
This commit is contained in:
Bohdan Triapitsyn
2026-07-12 16:34:16 +03:00
parent 19b2a3d0d3
commit 8d956f5f9c
14 changed files with 99 additions and 51 deletions
+12 -25
View File
@@ -30,9 +30,6 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSelectionStore } from '@/sync/selection-store'; import { useSelectionStore } from '@/sync/selection-store';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore'; import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore';
import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT } from '@/lib/sessionGoalMetadata';
import { distillPlanForGoal } from '@/lib/smallModel';
import { toast } from '@/components/ui';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useGitStore } from '@/stores/useGitStore'; import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
@@ -608,27 +605,17 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
// its file); on distillation failure a head+tail excerpt keeps the // its file); on distillation failure a head+tail excerpt keeps the
// intent (top) and acceptance criteria (bottom), sacrificing the // intent (top) and acceptance criteria (bottom), sacrificing the
// implementation middle the agent reads from the file anyway. // implementation middle the agent reads from the file anyway.
let goalObjective: string | null = null; // Oversized objectives (huge plans) are distilled into audit
if (execution.runAsGoal === true) { // criteria inside setSessionGoal — the shared path for every goal
const header = [ // source. Here we only compose header + full content.
`Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`, const goalObjective = execution.runAsGoal === true
'Re-read that file for full details — it is the source of truth.', ? [
].join(' '); `Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`,
const budget = SESSION_GOAL_OBJECTIVE_CHAR_LIMIT - header.length - 2; 'Re-read that file for full details — it is the source of truth.',
if (content.length <= budget) { '',
goalObjective = `${header}\n\n${content}`; content,
} else { ].join('\n')
const distilled = await distillPlanForGoal(content); : null;
if (distilled) {
goalObjective = `${header}\n\n${distilled.slice(0, budget)}`;
} else {
const marker = '\n\n[… plan trimmed for the auditor — the plan file has the full version …]\n\n';
const half = Math.max(0, Math.floor((budget - marker.length) / 2));
goalObjective = `${header}\n\n${content.slice(0, half)}${marker}${content.slice(-half)}`;
toast.error(t('plans.goal.toast.distillFallback'));
}
}
}
useSessionGoalArmStore.getState().setArmed(execution.runAsGoal === true, goalObjective); useSessionGoalArmStore.getState().setArmed(execution.runAsGoal === true, goalObjective);
await sendMessage( await sendMessage(
visiblePrompt, visiblePrompt,
@@ -646,7 +633,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
setIsPlanSendSubmitting(false); setIsPlanSendSubmitting(false);
} }
}, },
[canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession, t] [canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession]
); );
const blockWidgets = React.useMemo(() => { const blockWidgets = React.useMemo(() => {
+1 -1
View File
@@ -1437,7 +1437,7 @@ export const dict = {
'chat.goal.action.save': 'Save goal', 'chat.goal.action.save': 'Save goal',
'chat.goal.action.start': 'Start goal', 'chat.goal.action.start': 'Start goal',
'chat.goal.toast.actionFailed': 'Goal update failed', 'chat.goal.toast.actionFailed': 'Goal update failed',
"plans.goal.toast.distillFallback": "Couldn't distill the plan for the auditor — a trimmed excerpt was used instead", "chat.goal.toast.distillFallback": "Objective exceeded the limit and couldn't be distilled — a trimmed version is used for the auditor",
'chat.goal.row.aria': 'Session goal — open details', 'chat.goal.row.aria': 'Session goal — open details',
'chat.goal.button.createAria': 'Set a session goal', 'chat.goal.button.createAria': 'Set a session goal',
'chat.goal.button.manageAria': 'Manage session goal', 'chat.goal.button.manageAria': 'Manage session goal',
+1 -1
View File
@@ -1415,7 +1415,7 @@ export const dict: Record<I18nKey, string> = {
"chat.goal.action.save": "Guardar objetivo", "chat.goal.action.save": "Guardar objetivo",
"chat.goal.action.start": "Iniciar objetivo", "chat.goal.action.start": "Iniciar objetivo",
"chat.goal.toast.actionFailed": "No se pudo actualizar el objetivo", "chat.goal.toast.actionFailed": "No se pudo actualizar el objetivo",
"plans.goal.toast.distillFallback": "No se pudo destilar el plan para el auditor — se usó un extracto recortado", "chat.goal.toast.distillFallback": "El objetivo superó el límite y no se pudo destilar el auditor usa una versión recortada",
"chat.goal.row.aria": "Objetivo de sesión — abrir detalles", "chat.goal.row.aria": "Objetivo de sesión — abrir detalles",
"chat.goal.button.createAria": "Definir un objetivo de sesión", "chat.goal.button.createAria": "Definir un objetivo de sesión",
"chat.goal.button.manageAria": "Gestionar el objetivo de sesión", "chat.goal.button.manageAria": "Gestionar el objetivo de sesión",
+1 -1
View File
@@ -1258,7 +1258,7 @@ export const dict = {
'chat.goal.action.save': 'Enregistrer l\'objectif', 'chat.goal.action.save': 'Enregistrer l\'objectif',
'chat.goal.action.start': 'Lancer l\'objectif', 'chat.goal.action.start': 'Lancer l\'objectif',
'chat.goal.toast.actionFailed': 'Échec de la mise à jour de l\'objectif', 'chat.goal.toast.actionFailed': 'Échec de la mise à jour de l\'objectif',
"plans.goal.toast.distillFallback": "Impossible de condenser le plan pour l'auditeur — un extrait tronqué a été utilisé", "chat.goal.toast.distillFallback": "L'objectif dépassait la limite et n'a pas pu être condensé — l'auditeur utilise une version tronquée",
'chat.goal.row.aria': 'Objectif de session — ouvrir les détails', 'chat.goal.row.aria': 'Objectif de session — ouvrir les détails',
'chat.goal.button.createAria': 'Définir un objectif de session', 'chat.goal.button.createAria': 'Définir un objectif de session',
'chat.goal.button.manageAria': 'Gérer l\'objectif de session', 'chat.goal.button.manageAria': 'Gérer l\'objectif de session',
+1 -1
View File
@@ -1433,7 +1433,7 @@ export const dict: Record<I18nKey, string> = {
'chat.goal.action.save': 'ゴールを保存', 'chat.goal.action.save': 'ゴールを保存',
'chat.goal.action.start': 'ゴールを開始', 'chat.goal.action.start': 'ゴールを開始',
'chat.goal.toast.actionFailed': 'ゴールの更新に失敗しました', 'chat.goal.toast.actionFailed': 'ゴールの更新に失敗しました',
'plans.goal.toast.distillFallback': '監査用にプランを要約できませんでした — 抜粋版を使用します', 'chat.goal.toast.distillFallback': '目標が上限を超え、要約できませんでした — 監査には抜粋版を使用します',
'chat.goal.row.aria': 'セッションゴール — 詳細を開く', 'chat.goal.row.aria': 'セッションゴール — 詳細を開く',
'chat.goal.button.createAria': 'セッションゴールを設定', 'chat.goal.button.createAria': 'セッションゴールを設定',
'chat.goal.button.manageAria': 'セッションゴールを管理', 'chat.goal.button.manageAria': 'セッションゴールを管理',
+1 -1
View File
@@ -1439,7 +1439,7 @@ export const dict: Record<I18nKey, string> = {
'chat.goal.action.save': '목표 저장', 'chat.goal.action.save': '목표 저장',
'chat.goal.action.start': '목표 시작', 'chat.goal.action.start': '목표 시작',
'chat.goal.toast.actionFailed': '목표 업데이트에 실패했습니다', 'chat.goal.toast.actionFailed': '목표 업데이트에 실패했습니다',
'plans.goal.toast.distillFallback': '감사용으로 계획을 요약하지 못했습니다 — 발췌본을 사용합니다', 'chat.goal.toast.distillFallback': '목표가 제한을 초과했고 요약도 실패했습니다 — 감사에는 발췌본을 사용합니다',
'chat.goal.row.aria': '세션 목표 — 세부 정보 열기', 'chat.goal.row.aria': '세션 목표 — 세부 정보 열기',
'chat.goal.button.createAria': '세션 목표 설정', 'chat.goal.button.createAria': '세션 목표 설정',
'chat.goal.button.manageAria': '세션 목표 관리', 'chat.goal.button.manageAria': '세션 목표 관리',
+1 -1
View File
@@ -2122,7 +2122,7 @@ export const dict: Record<I18nKey, string> = {
'chat.goal.action.save': 'Zapisz cel', 'chat.goal.action.save': 'Zapisz cel',
'chat.goal.action.start': 'Rozpocznij cel', 'chat.goal.action.start': 'Rozpocznij cel',
'chat.goal.toast.actionFailed': 'Nie udało się zaktualizować celu', 'chat.goal.toast.actionFailed': 'Nie udało się zaktualizować celu',
'plans.goal.toast.distillFallback': 'Nie udało się skondensować planu dla audytora — użyto skróconego fragmentu', 'chat.goal.toast.distillFallback': 'Cel przekroczył limit i nie udało się go skondensować audytor używa skróconej wersji',
'chat.goal.row.aria': 'Cel sesji — otwórz szczegóły', 'chat.goal.row.aria': 'Cel sesji — otwórz szczegóły',
'chat.goal.button.createAria': 'Ustaw cel sesji', 'chat.goal.button.createAria': 'Ustaw cel sesji',
'chat.goal.button.manageAria': 'Zarządzaj celem sesji', 'chat.goal.button.manageAria': 'Zarządzaj celem sesji',
+1 -1
View File
@@ -1415,7 +1415,7 @@ export const dict: Record<I18nKey, string> = {
"chat.goal.action.save": "Salvar objetivo", "chat.goal.action.save": "Salvar objetivo",
"chat.goal.action.start": "Iniciar objetivo", "chat.goal.action.start": "Iniciar objetivo",
"chat.goal.toast.actionFailed": "Falha ao atualizar o objetivo", "chat.goal.toast.actionFailed": "Falha ao atualizar o objetivo",
"plans.goal.toast.distillFallback": "Não foi possível destilar o plano para o auditor — um trecho recortado foi usado", "chat.goal.toast.distillFallback": "O objetivo excedeu o limite e não pôde ser destilado — o auditor usa uma versão recortada",
"chat.goal.row.aria": "Objetivo da sessão — abrir detalhes", "chat.goal.row.aria": "Objetivo da sessão — abrir detalhes",
"chat.goal.button.createAria": "Definir um objetivo da sessão", "chat.goal.button.createAria": "Definir um objetivo da sessão",
"chat.goal.button.manageAria": "Gerenciar objetivo da sessão", "chat.goal.button.manageAria": "Gerenciar objetivo da sessão",
+1 -1
View File
@@ -1415,7 +1415,7 @@ export const dict: Record<I18nKey, string> = {
"chat.goal.action.save": "Зберегти ціль", "chat.goal.action.save": "Зберегти ціль",
"chat.goal.action.start": "Розпочати ціль", "chat.goal.action.start": "Розпочати ціль",
"chat.goal.toast.actionFailed": "Не вдалося оновити ціль", "chat.goal.toast.actionFailed": "Не вдалося оновити ціль",
"plans.goal.toast.distillFallback": "Не вдалося стиснути план для аудитора використано скорочений уривок", "chat.goal.toast.distillFallback": "Ціль перевищила ліміт і її не вдалося стиснути аудитор використовує скорочену версію",
"chat.goal.row.aria": "Ціль сесії — відкрити деталі", "chat.goal.row.aria": "Ціль сесії — відкрити деталі",
"chat.goal.button.createAria": "Встановити ціль сесії", "chat.goal.button.createAria": "Встановити ціль сесії",
"chat.goal.button.manageAria": "Керувати ціллю сесії", "chat.goal.button.manageAria": "Керувати ціллю сесії",
+1 -1
View File
@@ -1403,7 +1403,7 @@ export const dict: Record<I18nKey, string> = {
'chat.goal.action.save': '保存目标', 'chat.goal.action.save': '保存目标',
'chat.goal.action.start': '启动目标', 'chat.goal.action.start': '启动目标',
'chat.goal.toast.actionFailed': '目标更新失败', 'chat.goal.toast.actionFailed': '目标更新失败',
'plans.goal.toast.distillFallback': '无法为审核提炼计划 — 已改用节选版本', 'chat.goal.toast.distillFallback': '目标超出限制且无法提炼 — 审核将使用节选版本',
'chat.goal.row.aria': '会话目标 — 打开详情', 'chat.goal.row.aria': '会话目标 — 打开详情',
'chat.goal.button.createAria': '设置会话目标', 'chat.goal.button.createAria': '设置会话目标',
'chat.goal.button.manageAria': '管理会话目标', 'chat.goal.button.manageAria': '管理会话目标',
+1 -1
View File
@@ -1407,7 +1407,7 @@ export const dict: Record<I18nKey, string> = {
'chat.goal.action.save': '儲存目標', 'chat.goal.action.save': '儲存目標',
'chat.goal.action.start': '啟動目標', 'chat.goal.action.start': '啟動目標',
'chat.goal.toast.actionFailed': '目標更新失敗', 'chat.goal.toast.actionFailed': '目標更新失敗',
'plans.goal.toast.distillFallback': '無法為稽核提煉計畫 — 已改用節錄版本', 'chat.goal.toast.distillFallback': '目標超出限制且無法提煉 — 稽核將使用節錄版本',
'chat.goal.row.aria': '工作階段目標 — 開啟詳細資訊', 'chat.goal.row.aria': '工作階段目標 — 開啟詳細資訊',
'chat.goal.button.createAria': '設定工作階段目標', 'chat.goal.button.createAria': '設定工作階段目標',
'chat.goal.button.manageAria': '管理工作階段目標', 'chat.goal.button.manageAria': '管理工作階段目標',
+28 -2
View File
@@ -1,4 +1,7 @@
import { abortCurrentOperation, patchSessionMetadata } from '@/sync/session-actions'; import { abortCurrentOperation, patchSessionMetadata } from '@/sync/session-actions';
import { distillGoalObjective } from '@/lib/smallModel';
import { formatMessage, useI18nStore } from '@/lib/i18n';
import { toast } from '@/components/ui';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { import {
SESSION_GOAL_OBJECTIVE_CHAR_LIMIT, SESSION_GOAL_OBJECTIVE_CHAR_LIMIT,
@@ -74,16 +77,39 @@ export interface SetSessionGoalInput {
* Create a new goal (fresh id resets accounting) or edit the existing one * Create a new goal (fresh id resets accounting) or edit the existing one
* (id and usage counters preserved). * (id and usage counters preserved).
*/ */
// Any goal source can exceed the objective limit (huge plans, pasted specs,
// long composer prompts). The working agent received the full text in chat;
// only the AUDITOR is bound by the limit — so oversized objectives are
// distilled into completion criteria by the small model, and on a transient
// distillation failure a head+tail excerpt keeps the intent (top) and the
// acceptance criteria (bottom), sacrificing the middle.
const TRIM_MARKER = '\n\n[… objective trimmed for the auditor — the full text was delivered in the chat message …]\n\n';
const fitObjective = async (raw: string): Promise<string> => {
if (raw.length <= SESSION_GOAL_OBJECTIVE_CHAR_LIMIT) {
return raw;
}
const distilled = await distillGoalObjective(raw);
if (distilled) {
return distilled.slice(0, SESSION_GOAL_OBJECTIVE_CHAR_LIMIT);
}
const half = Math.max(0, Math.floor((SESSION_GOAL_OBJECTIVE_CHAR_LIMIT - TRIM_MARKER.length) / 2));
const dictionary = useI18nStore.getState().dictionary;
toast.error(formatMessage(dictionary, 'chat.goal.toast.distillFallback'));
return `${raw.slice(0, half)}${TRIM_MARKER}${raw.slice(-half)}`;
};
export async function setSessionGoal( export async function setSessionGoal(
sessionId: string, sessionId: string,
directory: string | undefined, directory: string | undefined,
input: SetSessionGoalInput, input: SetSessionGoalInput,
existing: SessionGoalPayload | null, existing: SessionGoalPayload | null,
): Promise<void> { ): Promise<void> {
const objective = input.objective.trim().slice(0, SESSION_GOAL_OBJECTIVE_CHAR_LIMIT); const rawObjective = input.objective.trim();
if (!objective) { if (!rawObjective) {
throw new Error('Goal objective must not be empty'); throw new Error('Goal objective must not be empty');
} }
const objective = await fitObjective(rawObjective);
const tokenBudget = typeof input.tokenBudget === 'number' && Number.isFinite(input.tokenBudget) && input.tokenBudget > 0 const tokenBudget = typeof input.tokenBudget === 'number' && Number.isFinite(input.tokenBudget) && input.tokenBudget > 0
? Math.floor(input.tokenBudget) ? Math.floor(input.tokenBudget)
: null; : null;
+14 -13
View File
@@ -56,24 +56,25 @@ export async function summarizeSelectionForNotes(text: string, sessionId?: strin
} }
} }
// Plan-goal objectives are capped at 5000 chars for the auditor. Large plans // Goal objectives are capped at 5000 chars for the auditor. Oversized ones
// get distilled into completion criteria — the working agent always reads // (huge plans, pasted specs, long assignments) get distilled into completion
// the full plan from its file, only the audit needs the "what counts as // criteria — the working agent received the full prompt in chat anyway,
// done" essence. // only the audit needs the "what counts as done" essence.
const PLAN_GOAL_SYSTEM_PROMPT = [ const GOAL_OBJECTIVE_SYSTEM_PROMPT = [
'You distill an implementation plan into the COMPLETION CRITERIA a progress auditor will judge against.', 'You distill a large task description (a prompt, plan, or assignment) into the COMPLETION CRITERIA a progress auditor will judge against.',
'Return ONLY the criteria text — no preamble, no headers, no markdown fences.', 'Return ONLY the criteria text — no preamble, no headers, no markdown fences.',
'Capture: the end goals, what must exist and work when the plan is fully implemented, and how each major phase is verified. Omit implementation steps and how-to details.', 'Capture: the end goals, what must exist and work when the task is fully done, and how each major part is verified. Omit implementation steps and how-to details.',
'Preserve verbatim any file paths, commands, and identifiers that define the task — especially ones from the opening lines.',
'Stay under 4000 characters.', 'Stay under 4000 characters.',
'Write in the same language as the plan. Ignore any other language preferences or personalization — only the plan text decides the language.', 'Write in the same language as the task text. Ignore any other language preferences or personalization — only the task text decides the language.',
].join('\n'); ].join('\n');
/** /**
* Distills a large plan into audit-sized completion criteria via the small * Distills an oversized goal objective into audit-sized completion criteria
* model. Returns null on any failure callers fall back to a head+tail * via the small model. Returns null on any failure callers fall back to a
* excerpt of the plan. * head+tail excerpt.
*/ */
export async function distillPlanForGoal(planContent: string): Promise<string | null> { export async function distillGoalObjective(planContent: string): Promise<string | null> {
try { try {
const { currentProviderId, currentModelId } = useConfigStore.getState(); const { currentProviderId, currentModelId } = useConfigStore.getState();
const response = await runtimeFetch('/api/small-model/generate', { const response = await runtimeFetch('/api/small-model/generate', {
@@ -81,7 +82,7 @@ export async function distillPlanForGoal(planContent: string): Promise<string |
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
prompt: planContent, prompt: planContent,
system: PLAN_GOAL_SYSTEM_PROMPT, system: GOAL_OBJECTIVE_SYSTEM_PROMPT,
restrictToPreferredProvider: true, restrictToPreferredProvider: true,
...(currentProviderId ? { preferredProviderID: currentProviderId } : {}), ...(currentProviderId ? { preferredProviderID: currentProviderId } : {}),
...(currentModelId ? { preferredModelID: currentModelId } : {}), ...(currentModelId ? { preferredModelID: currentModelId } : {}),
@@ -447,7 +447,41 @@ export const createScheduledTasksRuntime = (deps) => {
// File-backed objective keyed by session id: metadata stays light, the // File-backed objective keyed by session id: metadata stays light, the
// full expanded prompt lives under the OpenChamber data dir. If the file // full expanded prompt lives under the OpenChamber data dir. If the file
// write fails, fall back to an inline (clamped) objective. // write fails, fall back to an inline (clamped) objective.
const objectiveText = expandSnippets(task.execution.prompt, projectPath); // Oversized prompts are distilled into audit criteria by the small model
// (the working agent gets the full prompt in chat anyway); on distill
// failure a head+tail excerpt keeps intent and acceptance criteria.
let objectiveText = expandSnippets(task.execution.prompt, projectPath);
if (objectiveText.length > 5000) {
let distilled = null;
try {
const { generateSmallModelText } = await import('../small-model/index.js');
const generated = await generateSmallModelText({
restrictToPreferredProvider: true,
prompt: objectiveText,
system: [
'You distill a large task description into the COMPLETION CRITERIA a progress auditor will judge against.',
'Return ONLY the criteria text — no preamble, no headers, no markdown fences.',
'Capture: the end goals, what must exist and work when the task is fully done, and how each major part is verified. Omit implementation steps.',
'Preserve verbatim any file paths, commands, and identifiers that define the task.',
'Stay under 4000 characters.',
'Write in the same language as the task text.',
].join('\n'),
directory: projectPath,
preferredProviderID: task.execution.providerID,
preferredModelID: task.execution.modelID,
});
distilled = typeof generated?.text === 'string' ? generated.text.trim() : null;
} catch (error) {
console.warn('[scheduled-tasks] goal objective distillation failed:', error?.message || error);
}
if (distilled) {
objectiveText = distilled;
} else {
const marker = '\n\n[… objective trimmed for the auditor — the full prompt was delivered in the chat message …]\n\n';
const half = Math.max(0, Math.floor((5000 - marker.length) / 2));
objectiveText = `${objectiveText.slice(0, half)}${marker}${objectiveText.slice(-half)}`;
}
}
let objectiveFile = false; let objectiveFile = false;
try { try {
const { writeObjective } = await import('../session-goal/objectives.js'); const { writeObjective } = await import('../session-goal/objectives.js');