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 } : {}),