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:
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'セッションゴールを管理',
|
||||
|
||||
@@ -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': '세션 목표 관리',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Керувати ціллю сесії",
|
||||
|
||||
@@ -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': '管理会话目标',
|
||||
|
||||
@@ -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': '管理工作階段目標',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user