feat: distill oversized plan goals into audit criteria

Plan-goal objectives are capped at 5000 chars for the auditor while the
working agent reads the full plan from its file. Plans over the limit are
now distilled by the small model into completion criteria (end goals +
per-phase verification, no implementation steps), prefixed with a header
pointing back at the plan file so every continuation re-anchors on the
live source of truth. If distillation fails (transient small-model
hiccup), a head+tail excerpt keeps the plan's intent (top) and acceptance
criteria (bottom) with a trim marker between — sacrificing the
implementation middle the agent reads from the file anyway — and a toast
tells the user the objective is degraded.
This commit is contained in:
Bohdan Triapitsyn
2026-07-12 16:28:48 +03:00
parent b09614fd68
commit 19b2a3d0d3
12 changed files with 82 additions and 10 deletions
+32 -10
View File
@@ -30,6 +30,9 @@ 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';
@@ -598,15 +601,34 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
// "Run as goal" rides the same arm mechanism as the composer target // "Run as goal" rides the same arm mechanism as the composer target
// button; set explicitly either way so a stray armed flag cannot // button; set explicitly either way so a stray armed flag cannot
// leak into a non-goal plan send. The objective override carries the // leak into a non-goal plan send. The objective override carries the
// actual plan content — "Implement this plan: X" alone would give // plan substance — "Implement this plan: X" alone would give the
// the progress audit nothing to judge against. // progress audit nothing to judge against. Plans that exceed the
const goalObjective = execution.runAsGoal === true // objective limit are distilled into completion criteria by the
? [ // small model (the working agent always reads the full plan from
`Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`, // its file); on distillation failure a head+tail excerpt keeps the
'', // intent (top) and acceptance criteria (bottom), sacrificing the
content, // implementation middle the agent reads from the file anyway.
].join('\n') let goalObjective: string | null = 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'));
}
}
}
useSessionGoalArmStore.getState().setArmed(execution.runAsGoal === true, goalObjective); useSessionGoalArmStore.getState().setArmed(execution.runAsGoal === true, goalObjective);
await sendMessage( await sendMessage(
visiblePrompt, visiblePrompt,
@@ -624,7 +646,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
setIsPlanSendSubmitting(false); setIsPlanSendSubmitting(false);
} }
}, },
[canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession] [canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession, t]
); );
const blockWidgets = React.useMemo(() => { const blockWidgets = React.useMemo(() => {
+1
View File
@@ -1437,6 +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.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
View File
@@ -1415,6 +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.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
View File
@@ -1258,6 +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.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
View File
@@ -1433,6 +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.row.aria': 'セッションゴール — 詳細を開く', 'chat.goal.row.aria': 'セッションゴール — 詳細を開く',
'chat.goal.button.createAria': 'セッションゴールを設定', 'chat.goal.button.createAria': 'セッションゴールを設定',
'chat.goal.button.manageAria': 'セッションゴールを管理', 'chat.goal.button.manageAria': 'セッションゴールを管理',
+1
View File
@@ -1439,6 +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.row.aria': '세션 목표 — 세부 정보 열기', 'chat.goal.row.aria': '세션 목표 — 세부 정보 열기',
'chat.goal.button.createAria': '세션 목표 설정', 'chat.goal.button.createAria': '세션 목표 설정',
'chat.goal.button.manageAria': '세션 목표 관리', 'chat.goal.button.manageAria': '세션 목표 관리',
+1
View File
@@ -2122,6 +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.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',
@@ -1415,6 +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.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
View File
@@ -1415,6 +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.row.aria": "Ціль сесії — відкрити деталі", "chat.goal.row.aria": "Ціль сесії — відкрити деталі",
"chat.goal.button.createAria": "Встановити ціль сесії", "chat.goal.button.createAria": "Встановити ціль сесії",
"chat.goal.button.manageAria": "Керувати ціллю сесії", "chat.goal.button.manageAria": "Керувати ціллю сесії",
@@ -1403,6 +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.row.aria': '会话目标 — 打开详情', 'chat.goal.row.aria': '会话目标 — 打开详情',
'chat.goal.button.createAria': '设置会话目标', 'chat.goal.button.createAria': '设置会话目标',
'chat.goal.button.manageAria': '管理会话目标', 'chat.goal.button.manageAria': '管理会话目标',
@@ -1407,6 +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.row.aria': '工作階段目標 — 開啟詳細資訊', 'chat.goal.row.aria': '工作階段目標 — 開啟詳細資訊',
'chat.goal.button.createAria': '設定工作階段目標', 'chat.goal.button.createAria': '設定工作階段目標',
'chat.goal.button.manageAria': '管理工作階段目標', 'chat.goal.button.manageAria': '管理工作階段目標',
+40
View File
@@ -55,3 +55,43 @@ export async function summarizeSelectionForNotes(text: string, sessionId?: strin
return trimmed; return trimmed;
} }
} }
// 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.',
'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.',
'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.',
].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.
*/
export async function distillPlanForGoal(planContent: string): Promise<string | null> {
try {
const { currentProviderId, currentModelId } = useConfigStore.getState();
const response = await runtimeFetch('/api/small-model/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: planContent,
system: PLAN_GOAL_SYSTEM_PROMPT,
restrictToPreferredProvider: true,
...(currentProviderId ? { preferredProviderID: currentProviderId } : {}),
...(currentModelId ? { preferredModelID: currentModelId } : {}),
}),
});
if (!response.ok) return null;
const payload = await response.json().catch(() => null) as { text?: unknown } | null;
const distilled = typeof payload?.text === 'string' ? payload.text.trim() : '';
return distilled || null;
} catch {
return null;
}
}