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 { useConfigStore } from '@/stores/useConfigStore';
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 { useGitStore } from '@/stores/useGitStore';
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
// intent (top) and acceptance criteria (bottom), sacrificing the
// implementation middle the agent reads from the file anyway.
let goalObjective: string | null = null;
if (execution.runAsGoal === true) {
const header = [
`Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`,
'Re-read that file for full details — it is the source of truth.',
].join(' ');
const budget = SESSION_GOAL_OBJECTIVE_CHAR_LIMIT - header.length - 2;
if (content.length <= budget) {
goalObjective = `${header}\n\n${content}`;
} else {
const distilled = await distillPlanForGoal(content);
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'));
}
}
}
// Oversized objectives (huge plans) are distilled into audit
// criteria inside setSessionGoal — the shared path for every goal
// source. Here we only compose header + full content.
const goalObjective = execution.runAsGoal === true
? [
`Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`,
'Re-read that file for full details — it is the source of truth.',
'',
content,
].join('\n')
: null;
useSessionGoalArmStore.getState().setArmed(execution.runAsGoal === true, goalObjective);
await sendMessage(
visiblePrompt,
@@ -646,7 +633,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
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(() => {
+1 -1
View File
@@ -1437,7 +1437,7 @@ export const dict = {
'chat.goal.action.save': 'Save goal',
'chat.goal.action.start': 'Start goal',
'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.button.createAria': 'Set a 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.start": "Iniciar 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.button.createAria": "Definir un 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.start': 'Lancer 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.button.createAria': 'Définir un 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.start': 'ゴールを開始',
'chat.goal.toast.actionFailed': 'ゴールの更新に失敗しました',
'plans.goal.toast.distillFallback': '監査用にプランを要約できませんでした — 抜粋版を使用します',
'chat.goal.toast.distillFallback': '目標が上限を超え、要約できませんでした — 監査には抜粋版を使用します',
'chat.goal.row.aria': 'セッションゴール — 詳細を開く',
'chat.goal.button.createAria': 'セッションゴールを設定',
'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.start': '목표 시작',
'chat.goal.toast.actionFailed': '목표 업데이트에 실패했습니다',
'plans.goal.toast.distillFallback': '감사용으로 계획을 요약하지 못했습니다 — 발췌본을 사용합니다',
'chat.goal.toast.distillFallback': '목표가 제한을 초과했고 요약도 실패했습니다 — 감사에는 발췌본을 사용합니다',
'chat.goal.row.aria': '세션 목표 — 세부 정보 열기',
'chat.goal.button.createAria': '세션 목표 설정',
'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.start': 'Rozpocznij cel',
'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.button.createAria': 'Ustaw cel 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.start": "Iniciar 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.button.createAria": "Definir um 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.start": "Розпочати ціль",
"chat.goal.toast.actionFailed": "Не вдалося оновити ціль",
"plans.goal.toast.distillFallback": "Не вдалося стиснути план для аудитора використано скорочений уривок",
"chat.goal.toast.distillFallback": "Ціль перевищила ліміт і її не вдалося стиснути аудитор використовує скорочену версію",
"chat.goal.row.aria": "Ціль сесії — відкрити деталі",
"chat.goal.button.createAria": "Встановити ціль сесії",
"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.start': '启动目标',
'chat.goal.toast.actionFailed': '目标更新失败',
'plans.goal.toast.distillFallback': '无法为审核提炼计划 — 已改用节选版本',
'chat.goal.toast.distillFallback': '目标超出限制且无法提炼 — 审核将使用节选版本',
'chat.goal.row.aria': '会话目标 — 打开详情',
'chat.goal.button.createAria': '设置会话目标',
'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.start': '啟動目標',
'chat.goal.toast.actionFailed': '目標更新失敗',
'plans.goal.toast.distillFallback': '無法為稽核提煉計畫 — 已改用節錄版本',
'chat.goal.toast.distillFallback': '目標超出限制且無法提煉 — 稽核將使用節錄版本',
'chat.goal.row.aria': '工作階段目標 — 開啟詳細資訊',
'chat.goal.button.createAria': '設定工作階段目標',
'chat.goal.button.manageAria': '管理工作階段目標',
+28 -2
View File
@@ -1,4 +1,7 @@
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 {
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
* (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(
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) {
const rawObjective = input.objective.trim();
if (!rawObjective) {
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
? Math.floor(input.tokenBudget)
: 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
// get distilled into completion criteria — the working agent always reads
// the full plan from its file, only the audit needs the "what counts as
// done" essence.
const PLAN_GOAL_SYSTEM_PROMPT = [
'You distill an implementation plan into the COMPLETION CRITERIA a progress auditor will judge against.',
// Goal objectives are capped at 5000 chars for the auditor. Oversized ones
// (huge plans, pasted specs, long assignments) get distilled into completion
// criteria — the working agent received the full prompt in chat anyway,
// only the audit needs the "what counts as done" essence.
const GOAL_OBJECTIVE_SYSTEM_PROMPT = [
'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.',
'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.',
'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');
/**
* Distills a large plan into audit-sized completion criteria via the small
* model. Returns null on any failure callers fall back to a head+tail
* excerpt of the plan.
* Distills an oversized goal objective into audit-sized completion criteria
* via the small model. Returns null on any failure callers fall back to a
* head+tail excerpt.
*/
export async function distillPlanForGoal(planContent: string): Promise<string | null> {
export async function distillGoalObjective(planContent: string): Promise<string | null> {
try {
const { currentProviderId, currentModelId } = useConfigStore.getState();
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' },
body: JSON.stringify({
prompt: planContent,
system: PLAN_GOAL_SYSTEM_PROMPT,
system: GOAL_OBJECTIVE_SYSTEM_PROMPT,
restrictToPreferredProvider: true,
...(currentProviderId ? { preferredProviderID: currentProviderId } : {}),
...(currentModelId ? { preferredModelID: currentModelId } : {}),
@@ -447,7 +447,41 @@ export const createScheduledTasksRuntime = (deps) => {
// File-backed objective keyed by session id: metadata stays light, the
// full expanded prompt lives under the OpenChamber data dir. If the file
// 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;
try {
const { writeObjective } = await import('../session-goal/objectives.js');