feat(thread_goals): Codex-style thread-level goal (core + RPC + tools + harness + UI) (#4087)

This commit is contained in:
Steven Enamakel
2026-06-25 01:01:21 -07:00
committed by GitHub
parent 364496da70
commit 13938f5973
48 changed files with 3783 additions and 165 deletions
+6
View File
@@ -102,6 +102,12 @@ coding_provider = "e2e:e2e-mock-model"
[update]
enabled = false
[context]
# Deterministic e2e specs script the mock-LLM call sequence exactly; the
# default-on first-turn "super context" scout adds an extra agentic call that
# would perturb those sequences. No spec exercises super context, so disable it.
super_context_enabled = false
[[cloud_providers]]
id = "p_e2e_mock"
slug = "e2e"
+152 -135
View File
@@ -39,6 +39,14 @@ export interface ChatComposerProps {
* attach button, hidden file input, and preview strip are not rendered.
*/
attachmentsEnabled: boolean;
/**
* Optional nodes stacked above the input box but *outside* its focus
* highlight — e.g. the queued-follow-ups strip and the thread-goal editor.
* They render within the overall composer component (so they move with it)
* yet are not wrapped by the blue focus-within ring/border of the input box.
* Entries that render `null` contribute nothing.
*/
headerSlots?: React.ReactNode[];
}
/**
@@ -66,6 +74,7 @@ export default function ChatComposer({
maxAttachments,
allowedMimeTypes,
attachmentsEnabled,
headerSlots = [],
}: ChatComposerProps) {
const { t } = useT();
@@ -93,155 +102,163 @@ export default function ChatComposer({
}, [inputValue, textInputRef]);
return (
<div className="relative flex flex-col rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 transition-all focus-within:border-primary-500/50 focus-within:ring-1 focus-within:ring-primary-500/50">
{/* Hidden file input for attachment (gated — see attachmentsEnabled). */}
{attachmentsEnabled && (
<input
ref={fileInputRef}
type="file"
// No `accept` filter: Chromium 146 / CEF on macOS greys out valid files
// at the native open panel regardless of the filter shape (MIME, mixed,
// or extension-only). Selection is gated in `onAttachFiles` →
// `validateAndReadFile` instead (rejects unsupported types + images on
// non-vision models). `allowedMimeTypes` is kept for that JS validation.
accept={allowedMimeTypes.length ? allowedMimeTypes.join(',') : undefined}
multiple
className="hidden"
onChange={e => {
void onAttachFiles(e.target.files);
e.target.value = '';
}}
/>
)}
<div className="relative flex flex-col gap-1">
{/* Header stack (e.g. queued follow-ups, thread-goal editor): rendered
above the input box and OUTSIDE its blue focus highlight, but still
within the overall composer component so they move as one unit. */}
{headerSlots}
{/* Attachment preview strip */}
{attachmentsEnabled && attachments.length > 0 && (
<div className="px-3 pt-2.5">
<AttachmentPreview
attachments={attachments}
onRemove={onRemoveAttachment}
disabled={composerInteractionBlocked || isSending}
/>
</div>
)}
{/* Single row: [+] textarea [mic] [send] */}
<div className="flex items-center gap-2 p-3">
{/* Attach button */}
{/* The input box — only this carries the focus-within highlight. */}
<div className="relative flex flex-col rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 transition-all focus-within:border-primary-500/50 focus-within:ring-1 focus-within:ring-primary-500/50">
{/* Hidden file input for attachment (gated — see attachmentsEnabled). */}
{attachmentsEnabled && (
<input
ref={fileInputRef}
type="file"
// No `accept` filter: Chromium 146 / CEF on macOS greys out valid files
// at the native open panel regardless of the filter shape (MIME, mixed,
// or extension-only). Selection is gated in `onAttachFiles` →
// `validateAndReadFile` instead (rejects unsupported types + images on
// non-vision models). `allowedMimeTypes` is kept for that JS validation.
accept={allowedMimeTypes.length ? allowedMimeTypes.join(',') : undefined}
multiple
className="hidden"
onChange={e => {
void onAttachFiles(e.target.files);
e.target.value = '';
}}
/>
)}
{/* Attachment preview strip */}
{attachmentsEnabled && attachments.length > 0 && (
<div className="px-3 pt-2.5">
<AttachmentPreview
attachments={attachments}
onRemove={onRemoveAttachment}
disabled={composerInteractionBlocked || isSending}
/>
</div>
)}
{/* Single row: [+] textarea [mic] [send] */}
<div className="flex items-center gap-2 p-3">
{/* Attach button */}
{attachmentsEnabled && (
<button
type="button"
data-analytics-id="chat-composer-attach-file"
aria-label={t('composer.attachFile')}
title={t('composer.attachFile')}
onClick={() => fileInputRef.current?.click()}
disabled={
composerInteractionBlocked || isSending || attachments.length >= maxAttachments
}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M12 5v14m-7-7h14"
/>
</svg>
</button>
)}
{/* Textarea with ghost completion */}
<div className="relative flex-1 align-middle flex min-w-0">
<div
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words py-0.5 text-sm leading-5 font-sans">
<span className="invisible">{inputValue}</span>
<span className="text-stone-500 dark:text-neutral-400/50">
{inlineCompletionSuffix}
</span>
</div>
<textarea
ref={textInputRef}
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onCompositionStart={() => {
isComposingTextRef.current = true;
}}
onCompositionEnd={() => {
isComposingTextRef.current = false;
}}
onKeyDown={handleInputKeyDown}
placeholder={allowParallelSend ? t('chat.followupHint') : t('chat.typeMessage')}
rows={1}
disabled={textareaDisabled}
className="relative z-10 w-full resize-none border-0 bg-transparent py-0.5 px-0.5 text-sm leading-5 whitespace-pre-wrap break-words font-sans text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 overflow-hidden disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
{/* Voice mode */}
<button
type="button"
data-analytics-id="chat-composer-attach-file"
aria-label={t('composer.attachFile')}
title={t('composer.attachFile')}
onClick={() => fileInputRef.current?.click()}
disabled={
composerInteractionBlocked || isSending || attachments.length >= maxAttachments
}
data-analytics-id="chat-composer-voice-mode"
aria-label={t('composer.voiceMode')}
title={t('composer.voiceMode')}
onClick={onSwitchToMicCloud}
disabled={composerInteractionBlocked || isSending}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M12 5v14m-7-7h14"
strokeWidth={2}
d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 10v2a7 7 0 01-14 0v-2M12 19v4m-4 0h8"
/>
</svg>
</button>
)}
{/* Textarea with ghost completion */}
<div className="relative flex-1 align-middle flex min-w-0">
<div
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words py-0.5 text-sm leading-5 font-sans">
<span className="invisible">{inputValue}</span>
<span className="text-stone-500 dark:text-neutral-400/50">
{inlineCompletionSuffix}
</span>
</div>
<textarea
ref={textInputRef}
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onCompositionStart={() => {
isComposingTextRef.current = true;
{/* Send button */}
<button
type="button"
data-analytics-id="chat-composer-send"
data-testid="send-message-button"
aria-label={t('chat.send')}
title={t('chat.send')}
onClick={() => {
void onSend();
}}
onCompositionEnd={() => {
isComposingTextRef.current = false;
}}
onKeyDown={handleInputKeyDown}
placeholder={allowParallelSend ? t('chat.followupHint') : t('chat.typeMessage')}
rows={1}
disabled={textareaDisabled}
className="relative z-10 w-full resize-none border-0 bg-transparent py-0.5 px-0.5 text-sm leading-5 whitespace-pre-wrap break-words font-sans text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 overflow-hidden disabled:opacity-50 disabled:cursor-not-allowed"
/>
disabled={!hasContent || composerLocked}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
{showSendingSpinner ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
)}
</button>
</div>
{/* Voice mode */}
<button
type="button"
data-analytics-id="chat-composer-voice-mode"
aria-label={t('composer.voiceMode')}
title={t('composer.voiceMode')}
onClick={onSwitchToMicCloud}
disabled={composerInteractionBlocked || isSending}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 10v2a7 7 0 01-14 0v-2M12 19v4m-4 0h8"
/>
</svg>
</button>
{/* Send button */}
<button
type="button"
data-analytics-id="chat-composer-send"
data-testid="send-message-button"
aria-label={t('chat.send')}
title={t('chat.send')}
onClick={() => {
void onSend();
}}
disabled={!hasContent || composerLocked}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
{showSendingSpinner ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
)}
</button>
</div>
</div>
);
+1 -1
View File
@@ -22,7 +22,7 @@ export default function QueuedFollowups({ items, onClear }: QueuedFollowupsProps
return (
<div
data-testid="queued-followups"
className="mb-2 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-900/60 px-3 py-2">
className="mb-2 rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
<div className="flex items-center justify-between gap-2 mb-1.5">
<span className="text-xs font-medium text-stone-500 dark:text-neutral-400">
{t('chat.queuedFollowups.label')} · {items.length}
+15
View File
@@ -2984,6 +2984,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'نقل لليمين',
'conversations.taskKanban.title': 'المهام',
'conversations.threadTodo.title': 'الخطة',
'conversations.threadGoal.label': 'الهدف',
'conversations.threadGoal.setCta': 'تحديد هدف',
'conversations.threadGoal.placeholder': 'ما الذي يجب أن تحققه هذه المحادثة؟',
'conversations.threadGoal.save': 'حفظ',
'conversations.threadGoal.cancel': 'إلغاء',
'conversations.threadGoal.edit': 'تعديل الهدف',
'conversations.threadGoal.clear': 'مسح الهدف',
'conversations.threadGoal.complete': 'وضع علامة كمكتمل',
'conversations.threadGoal.pause': 'إيقاف مؤقت',
'conversations.threadGoal.resume': 'استئناف',
'conversations.threadGoal.tokensSuffix': 'رمز',
'conversations.threadGoal.status.active': 'نشط',
'conversations.threadGoal.status.paused': 'متوقف مؤقتًا',
'conversations.threadGoal.status.budget_limited': 'تم بلوغ الحد',
'conversations.threadGoal.status.complete': 'مكتمل',
'conversations.taskKanban.approval.default': 'التقصير',
'conversations.taskKanban.approval.notRequired': 'غير مطلوب',
'conversations.taskKanban.approval.notRequiredBadge': 'عدم الموافقة',
+15
View File
@@ -3050,6 +3050,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'ডানে সরান',
'conversations.taskKanban.title': 'টাস্ক',
'conversations.threadTodo.title': 'পরিকল্পনা',
'conversations.threadGoal.label': 'লক্ষ্য',
'conversations.threadGoal.setCta': 'লক্ষ্য নির্ধারণ',
'conversations.threadGoal.placeholder': 'এই থ্রেডে কী অর্জন করতে হবে?',
'conversations.threadGoal.save': 'সংরক্ষণ',
'conversations.threadGoal.cancel': 'বাতিল',
'conversations.threadGoal.edit': 'লক্ষ্য সম্পাদনা',
'conversations.threadGoal.clear': 'লক্ষ্য মুছুন',
'conversations.threadGoal.complete': 'সম্পন্ন হিসেবে চিহ্নিত করুন',
'conversations.threadGoal.pause': 'বিরতি',
'conversations.threadGoal.resume': 'পুনরায় শুরু',
'conversations.threadGoal.tokensSuffix': 'টোকেন',
'conversations.threadGoal.status.active': 'সক্রিয়',
'conversations.threadGoal.status.paused': 'বিরতিতে',
'conversations.threadGoal.status.budget_limited': 'বাজেট শেষ',
'conversations.threadGoal.status.complete': 'সম্পন্ন',
'conversations.taskKanban.approval.default': 'ডিফল্ট',
'conversations.taskKanban.approval.notRequired': 'প্রয়োজন নেই',
'conversations.taskKanban.approval.notRequiredBadge': 'অনুমোদন করা হবে না',
+15
View File
@@ -3121,6 +3121,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Bewege dich nach rechts',
'conversations.taskKanban.title': 'Aufgaben',
'conversations.threadTodo.title': 'Plan',
'conversations.threadGoal.label': 'Ziel',
'conversations.threadGoal.setCta': 'Ziel festlegen',
'conversations.threadGoal.placeholder': 'Was soll dieser Thread erreichen?',
'conversations.threadGoal.save': 'Speichern',
'conversations.threadGoal.cancel': 'Abbrechen',
'conversations.threadGoal.edit': 'Ziel bearbeiten',
'conversations.threadGoal.clear': 'Ziel löschen',
'conversations.threadGoal.complete': 'Als erledigt markieren',
'conversations.threadGoal.pause': 'Pausieren',
'conversations.threadGoal.resume': 'Fortsetzen',
'conversations.threadGoal.tokensSuffix': 'Tokens',
'conversations.threadGoal.status.active': 'Aktiv',
'conversations.threadGoal.status.paused': 'Pausiert',
'conversations.threadGoal.status.budget_limited': 'Budget erreicht',
'conversations.threadGoal.status.complete': 'Abgeschlossen',
'conversations.taskKanban.approval.default': 'Standard',
'conversations.taskKanban.approval.notRequired': 'Nicht erforderlich',
'conversations.taskKanban.approval.notRequiredBadge': 'keine Genehmigung',
+16
View File
@@ -3585,6 +3585,22 @@ const en: TranslationMap = {
'conversations.taskKanban.moveRight': 'Move right',
'conversations.taskKanban.title': 'Tasks',
'conversations.threadTodo.title': 'Plan',
// Thread-level goal chip (Codex-style per-thread completion contract).
'conversations.threadGoal.label': 'Goal',
'conversations.threadGoal.setCta': 'Set goal',
'conversations.threadGoal.placeholder': 'What should this thread accomplish?',
'conversations.threadGoal.save': 'Save',
'conversations.threadGoal.cancel': 'Cancel',
'conversations.threadGoal.edit': 'Edit goal',
'conversations.threadGoal.clear': 'Clear goal',
'conversations.threadGoal.complete': 'Mark complete',
'conversations.threadGoal.pause': 'Pause',
'conversations.threadGoal.resume': 'Resume',
'conversations.threadGoal.tokensSuffix': 'tokens',
'conversations.threadGoal.status.active': 'Active',
'conversations.threadGoal.status.paused': 'Paused',
'conversations.threadGoal.status.budget_limited': 'Budget reached',
'conversations.threadGoal.status.complete': 'Complete',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
+15
View File
@@ -3099,6 +3099,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Mover a la derecha',
'conversations.taskKanban.title': 'Tareas',
'conversations.threadTodo.title': 'Plan',
'conversations.threadGoal.label': 'Objetivo',
'conversations.threadGoal.setCta': 'Definir objetivo',
'conversations.threadGoal.placeholder': '¿Qué debe lograr este hilo?',
'conversations.threadGoal.save': 'Guardar',
'conversations.threadGoal.cancel': 'Cancelar',
'conversations.threadGoal.edit': 'Editar objetivo',
'conversations.threadGoal.clear': 'Borrar objetivo',
'conversations.threadGoal.complete': 'Marcar como completado',
'conversations.threadGoal.pause': 'Pausar',
'conversations.threadGoal.resume': 'Reanudar',
'conversations.threadGoal.tokensSuffix': 'tokens',
'conversations.threadGoal.status.active': 'Activo',
'conversations.threadGoal.status.paused': 'En pausa',
'conversations.threadGoal.status.budget_limited': 'Presupuesto agotado',
'conversations.threadGoal.status.complete': 'Completado',
'conversations.taskKanban.approval.default': 'Por defecto',
'conversations.taskKanban.approval.notRequired': 'No requerido',
'conversations.taskKanban.approval.notRequiredBadge': 'sin aprobación',
+15
View File
@@ -3114,6 +3114,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Déplacer à droite',
'conversations.taskKanban.title': 'Tâches',
'conversations.threadTodo.title': 'Plan',
'conversations.threadGoal.label': 'Objectif',
'conversations.threadGoal.setCta': 'Définir un objectif',
'conversations.threadGoal.placeholder': 'Que doit accomplir ce fil ?',
'conversations.threadGoal.save': 'Enregistrer',
'conversations.threadGoal.cancel': 'Annuler',
'conversations.threadGoal.edit': 'Modifier lobjectif',
'conversations.threadGoal.clear': 'Effacer lobjectif',
'conversations.threadGoal.complete': 'Marquer comme terminé',
'conversations.threadGoal.pause': 'Suspendre',
'conversations.threadGoal.resume': 'Reprendre',
'conversations.threadGoal.tokensSuffix': 'jetons',
'conversations.threadGoal.status.active': 'Actif',
'conversations.threadGoal.status.paused': 'En pause',
'conversations.threadGoal.status.budget_limited': 'Budget atteint',
'conversations.threadGoal.status.complete': 'Terminé',
'conversations.taskKanban.approval.default': 'Par défaut',
'conversations.taskKanban.approval.notRequired': 'Non requis',
'conversations.taskKanban.approval.notRequiredBadge': 'aucune approbation',
+15
View File
@@ -3050,6 +3050,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'दाएं ले जाएं',
'conversations.taskKanban.title': 'टास्क',
'conversations.threadTodo.title': 'योजना',
'conversations.threadGoal.label': 'लक्ष्य',
'conversations.threadGoal.setCta': 'लक्ष्य सेट करें',
'conversations.threadGoal.placeholder': 'इस थ्रेड को क्या हासिल करना चाहिए?',
'conversations.threadGoal.save': 'सहेजें',
'conversations.threadGoal.cancel': 'रद्द करें',
'conversations.threadGoal.edit': 'लक्ष्य संपादित करें',
'conversations.threadGoal.clear': 'लक्ष्य हटाएँ',
'conversations.threadGoal.complete': 'पूर्ण के रूप में चिह्नित करें',
'conversations.threadGoal.pause': 'रोकें',
'conversations.threadGoal.resume': 'फिर शुरू करें',
'conversations.threadGoal.tokensSuffix': 'टोकन',
'conversations.threadGoal.status.active': 'सक्रिय',
'conversations.threadGoal.status.paused': 'रुका हुआ',
'conversations.threadGoal.status.budget_limited': 'बजट समाप्त',
'conversations.threadGoal.status.complete': 'पूर्ण',
'conversations.taskKanban.approval.default': 'डिफ़ॉल्ट',
'conversations.taskKanban.approval.notRequired': 'आवश्यकता नहीं',
'conversations.taskKanban.approval.notRequiredBadge': 'कोई अनुमोदन नहीं',
+15
View File
@@ -3053,6 +3053,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Pindah ke kanan',
'conversations.taskKanban.title': 'Tugas',
'conversations.threadTodo.title': 'Rencana',
'conversations.threadGoal.label': 'Tujuan',
'conversations.threadGoal.setCta': 'Tetapkan tujuan',
'conversations.threadGoal.placeholder': 'Apa yang harus dicapai utas ini?',
'conversations.threadGoal.save': 'Simpan',
'conversations.threadGoal.cancel': 'Batal',
'conversations.threadGoal.edit': 'Edit tujuan',
'conversations.threadGoal.clear': 'Hapus tujuan',
'conversations.threadGoal.complete': 'Tandai selesai',
'conversations.threadGoal.pause': 'Jeda',
'conversations.threadGoal.resume': 'Lanjutkan',
'conversations.threadGoal.tokensSuffix': 'token',
'conversations.threadGoal.status.active': 'Aktif',
'conversations.threadGoal.status.paused': 'Dijeda',
'conversations.threadGoal.status.budget_limited': 'Anggaran tercapai',
'conversations.threadGoal.status.complete': 'Selesai',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Tidak diperlukan',
'conversations.taskKanban.approval.notRequiredBadge': 'tidak ada persetujuan',
+15
View File
@@ -3093,6 +3093,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Sposta a destra',
'conversations.taskKanban.title': 'Attività',
'conversations.threadTodo.title': 'Piano',
'conversations.threadGoal.label': 'Obiettivo',
'conversations.threadGoal.setCta': 'Imposta obiettivo',
'conversations.threadGoal.placeholder': 'Cosa deve realizzare questa conversazione?',
'conversations.threadGoal.save': 'Salva',
'conversations.threadGoal.cancel': 'Annulla',
'conversations.threadGoal.edit': 'Modifica obiettivo',
'conversations.threadGoal.clear': 'Cancella obiettivo',
'conversations.threadGoal.complete': 'Segna come completato',
'conversations.threadGoal.pause': 'Sospendi',
'conversations.threadGoal.resume': 'Riprendi',
'conversations.threadGoal.tokensSuffix': 'token',
'conversations.threadGoal.status.active': 'Attivo',
'conversations.threadGoal.status.paused': 'In pausa',
'conversations.threadGoal.status.budget_limited': 'Budget raggiunto',
'conversations.threadGoal.status.complete': 'Completato',
'conversations.taskKanban.approval.default': 'Predefinito',
'conversations.taskKanban.approval.notRequired': 'Non richiesto',
'conversations.taskKanban.approval.notRequiredBadge': 'nessuna approvazione',
+15
View File
@@ -3023,6 +3023,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': '오른쪽으로 이동',
'conversations.taskKanban.title': '작업',
'conversations.threadTodo.title': '계획',
'conversations.threadGoal.label': '목표',
'conversations.threadGoal.setCta': '목표 설정',
'conversations.threadGoal.placeholder': '이 스레드가 무엇을 달성해야 하나요?',
'conversations.threadGoal.save': '저장',
'conversations.threadGoal.cancel': '취소',
'conversations.threadGoal.edit': '목표 편집',
'conversations.threadGoal.clear': '목표 지우기',
'conversations.threadGoal.complete': '완료로 표시',
'conversations.threadGoal.pause': '일시 중지',
'conversations.threadGoal.resume': '재개',
'conversations.threadGoal.tokensSuffix': '토큰',
'conversations.threadGoal.status.active': '활성',
'conversations.threadGoal.status.paused': '일시 중지됨',
'conversations.threadGoal.status.budget_limited': '예산 도달',
'conversations.threadGoal.status.complete': '완료',
'conversations.taskKanban.approval.default': '기본값',
'conversations.taskKanban.approval.notRequired': '필요 없음',
'conversations.taskKanban.approval.notRequiredBadge': '승인 없음',
+15
View File
@@ -3080,6 +3080,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Przesuń w prawo',
'conversations.taskKanban.title': 'Zadania',
'conversations.threadTodo.title': 'Plan',
'conversations.threadGoal.label': 'Cel',
'conversations.threadGoal.setCta': 'Ustaw cel',
'conversations.threadGoal.placeholder': 'Co ma osiągnąć ten wątek?',
'conversations.threadGoal.save': 'Zapisz',
'conversations.threadGoal.cancel': 'Anuluj',
'conversations.threadGoal.edit': 'Edytuj cel',
'conversations.threadGoal.clear': 'Wyczyść cel',
'conversations.threadGoal.complete': 'Oznacz jako ukończone',
'conversations.threadGoal.pause': 'Wstrzymaj',
'conversations.threadGoal.resume': 'Wznów',
'conversations.threadGoal.tokensSuffix': 'tokeny',
'conversations.threadGoal.status.active': 'Aktywny',
'conversations.threadGoal.status.paused': 'Wstrzymany',
'conversations.threadGoal.status.budget_limited': 'Budżet wyczerpany',
'conversations.threadGoal.status.complete': 'Ukończony',
'conversations.taskKanban.approval.default': 'Domyślne',
'conversations.taskKanban.approval.notRequired': 'Niewymagane',
'conversations.taskKanban.approval.notRequiredBadge': 'bez zatwierdzenia',
+15
View File
@@ -3098,6 +3098,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Mover para direita',
'conversations.taskKanban.title': 'Tarefas',
'conversations.threadTodo.title': 'Plano',
'conversations.threadGoal.label': 'Objetivo',
'conversations.threadGoal.setCta': 'Definir objetivo',
'conversations.threadGoal.placeholder': 'O que esta conversa deve realizar?',
'conversations.threadGoal.save': 'Salvar',
'conversations.threadGoal.cancel': 'Cancelar',
'conversations.threadGoal.edit': 'Editar objetivo',
'conversations.threadGoal.clear': 'Limpar objetivo',
'conversations.threadGoal.complete': 'Marcar como concluído',
'conversations.threadGoal.pause': 'Pausar',
'conversations.threadGoal.resume': 'Retomar',
'conversations.threadGoal.tokensSuffix': 'tokens',
'conversations.threadGoal.status.active': 'Ativo',
'conversations.threadGoal.status.paused': 'Pausado',
'conversations.threadGoal.status.budget_limited': 'Orçamento atingido',
'conversations.threadGoal.status.complete': 'Concluído',
'conversations.taskKanban.approval.default': 'Padrão',
'conversations.taskKanban.approval.notRequired': 'Não obrigatório',
'conversations.taskKanban.approval.notRequiredBadge': 'sem aprovação',
+15
View File
@@ -3072,6 +3072,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Переместить вправо',
'conversations.taskKanban.title': 'Задачи',
'conversations.threadTodo.title': 'План',
'conversations.threadGoal.label': 'Цель',
'conversations.threadGoal.setCta': 'Задать цель',
'conversations.threadGoal.placeholder': 'Чего должен достичь этот тред?',
'conversations.threadGoal.save': 'Сохранить',
'conversations.threadGoal.cancel': 'Отмена',
'conversations.threadGoal.edit': 'Изменить цель',
'conversations.threadGoal.clear': 'Очистить цель',
'conversations.threadGoal.complete': 'Отметить выполненным',
'conversations.threadGoal.pause': 'Пауза',
'conversations.threadGoal.resume': 'Возобновить',
'conversations.threadGoal.tokensSuffix': 'токены',
'conversations.threadGoal.status.active': 'Активна',
'conversations.threadGoal.status.paused': 'На паузе',
'conversations.threadGoal.status.budget_limited': 'Бюджет исчерпан',
'conversations.threadGoal.status.complete': 'Завершена',
'conversations.taskKanban.approval.default': 'По умолчанию',
'conversations.taskKanban.approval.notRequired': 'Не требуется',
'conversations.taskKanban.approval.notRequiredBadge': 'нет одобрения',
+15
View File
@@ -2902,6 +2902,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': '向右移动',
'conversations.taskKanban.title': '任务',
'conversations.threadTodo.title': '计划',
'conversations.threadGoal.label': '目标',
'conversations.threadGoal.setCta': '设定目标',
'conversations.threadGoal.placeholder': '这个对话需要完成什么?',
'conversations.threadGoal.save': '保存',
'conversations.threadGoal.cancel': '取消',
'conversations.threadGoal.edit': '编辑目标',
'conversations.threadGoal.clear': '清除目标',
'conversations.threadGoal.complete': '标记为完成',
'conversations.threadGoal.pause': '暂停',
'conversations.threadGoal.resume': '继续',
'conversations.threadGoal.tokensSuffix': '令牌',
'conversations.threadGoal.status.active': '进行中',
'conversations.threadGoal.status.paused': '已暂停',
'conversations.threadGoal.status.budget_limited': '预算已用尽',
'conversations.threadGoal.status.complete': '已完成',
'conversations.taskKanban.approval.default': '默认',
'conversations.taskKanban.approval.notRequired': '无需审批',
'conversations.taskKanban.approval.notRequiredBadge': '无需审批',
+33 -13
View File
@@ -100,6 +100,11 @@ import {
} from './conversations/components/BackgroundProcessesPanel';
import { CitationChips, type MessageCitation } from './conversations/components/CitationChips';
import { SubagentDrawer } from './conversations/components/SubagentDrawer';
import {
ThreadGoalEditorPanel,
ThreadGoalFooterTrigger,
useThreadGoal,
} from './conversations/components/ThreadGoalChip';
import { ThreadTodoStrip } from './conversations/components/ThreadTodoStrip';
import { ToolTimelineBlock } from './conversations/components/ToolTimelineBlock';
import {
@@ -239,6 +244,10 @@ const Conversations = ({
: false;
const firstActiveThreadId = Object.keys(activeThreadIds)[0] ?? null;
// Thread-goal controller shared by the footer trigger (under the composer)
// and the editor panel (above the composer).
const threadGoal = useThreadGoal(selectedThreadId ?? null);
const [inputValue, setInputValue] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -894,12 +903,6 @@ const Conversations = ({
// may proceed concurrently.
if (selectedThreadId && pendingSendsRef.current.has(selectedThreadId)) return;
// If the user just flipped the Super Context toggle, make sure that config
// write has landed before the core builds this thread's session (which
// reads `context.super_context_enabled`). Resolves instantly when nothing
// is pending.
await whenSuperContextWriteSettled();
const normalized = text ?? inputValue;
const trimmedInput = normalized.trim();
@@ -944,6 +947,12 @@ const Conversations = ({
if (!sendingThreadId) return;
pendingSendsRef.current.add(sendingThreadId);
addPendingSendingThread(sendingThreadId);
// If the user just flipped the Super Context toggle, make sure that config
// write has landed before the core builds this thread's session (which
// reads `context.super_context_enabled`). Done AFTER the duplicate-send
// guard above is set so this await can't open a check→add race for rapid
// repeat clicks. Resolves instantly when nothing is pending.
await whenSuperContextWriteSettled();
const pendingAttachments = attachments.slice();
const modelOverride =
agentProfiles.find(p => p.id === selectedAgentProfileId)?.modelOverride ?? CHAT_MODEL_HINT;
@@ -2659,12 +2668,6 @@ const Conversations = ({
</div>
) : inputMode === 'text' ? (
<>
{selectedThreadId && (queuedFollowupsByThread[selectedThreadId]?.length ?? 0) > 0 && (
<QueuedFollowups
items={queuedFollowupsByThread[selectedThreadId] ?? []}
onClear={() => void handleClearQueuedFollowups()}
/>
)}
<ChatComposer
inputValue={inputValue}
setInputValue={setInputValue}
@@ -2688,6 +2691,19 @@ const Conversations = ({
// validateAndReadFile, which honors modelSupportsVision.
allowedMimeTypes={[]}
attachmentsEnabled={CHAT_ATTACHMENTS_ENABLED}
// Header stack above the input box (outside its blue focus ring):
// queued follow-ups + the thread-goal editor (opened via the
// footer "Set goal" trigger). Entries that render null are no-ops.
headerSlots={[
selectedThreadId && (queuedFollowupsByThread[selectedThreadId]?.length ?? 0) > 0 ? (
<QueuedFollowups
key="queued-followups"
items={queuedFollowupsByThread[selectedThreadId] ?? []}
onClear={() => void handleClearQueuedFollowups()}
/>
) : null,
<ThreadGoalEditorPanel key="thread-goal" ctl={threadGoal} />,
]}
/>
</>
) : (
@@ -2762,7 +2778,11 @@ const Conversations = ({
<div
className="mt-2 flex items-center justify-between gap-2"
data-walkthrough="chat-agent-panel">
<ComposerTokenStats model={resolvedModel} />
<div className="flex min-w-0 items-center gap-2">
<ComposerTokenStats model={resolvedModel} />
{/* Set/show the thread goal; click opens the editor above the composer. */}
<ThreadGoalFooterTrigger ctl={threadGoal} />
</div>
{!isSidebar && (
<div className="flex flex-shrink-0 items-center gap-2">
<div
@@ -0,0 +1,97 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { ThreadGoal } from '../../../services/api/threadGoalApi';
import { ThreadGoalEditorPanel, ThreadGoalFooterTrigger, useThreadGoal } from './ThreadGoalChip';
// Echo i18n keys so assertions are on stable key strings.
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
function goal(partial: Partial<ThreadGoal> = {}): ThreadGoal {
return {
threadId: 't1',
goalId: 'g1',
objective: 'Ship the release',
status: 'active',
tokenBudget: 1000,
tokensUsed: 250,
timeUsedSeconds: 0,
createdAtMs: 0,
updatedAtMs: 0,
continuationSuppressed: false,
...partial,
};
}
function stubApi(initial: ThreadGoal | null) {
return {
get: vi.fn().mockResolvedValue(initial),
set: vi.fn().mockResolvedValue(goal()),
complete: vi.fn().mockResolvedValue(goal({ status: 'complete' })),
pause: vi.fn().mockResolvedValue(goal({ status: 'paused' })),
resume: vi.fn().mockResolvedValue(goal()),
clear: vi.fn().mockResolvedValue(true),
} as unknown as typeof import('../../../services/api/threadGoalApi').threadGoalApi;
}
/** Harness wiring the shared controller to both pieces, as Conversations does. */
function Harness({ api }: { api: ReturnType<typeof stubApi> }) {
const ctl = useThreadGoal('t1', api);
return (
<div>
<ThreadGoalEditorPanel ctl={ctl} />
<ThreadGoalFooterTrigger ctl={ctl} />
</div>
);
}
describe('ThreadGoal footer trigger + editor panel', () => {
it('shows a footer "Set goal" trigger and no editor when empty', async () => {
const api = stubApi(null);
render(<Harness api={api} />);
await waitFor(() => expect(api.get).toHaveBeenCalledWith('t1'));
expect(await screen.findByText('conversations.threadGoal.setCta')).toBeTruthy();
// Editor stays closed until clicked.
expect(screen.queryByPlaceholderText('conversations.threadGoal.placeholder')).toBeNull();
});
it('clicking the trigger opens the editor input above; saving calls set', async () => {
const api = stubApi(null);
render(<Harness api={api} />);
fireEvent.click(await screen.findByText('conversations.threadGoal.setCta'));
const input = screen.getByPlaceholderText('conversations.threadGoal.placeholder');
fireEvent.change(input, { target: { value: 'New objective' } });
fireEvent.click(screen.getByText('conversations.threadGoal.save'));
await waitFor(() => expect(api.set).toHaveBeenCalledWith('t1', 'New objective'));
});
it('shows the goal status + objective in the footer trigger when set', async () => {
const api = stubApi(goal());
render(<Harness api={api} />);
expect(await screen.findByText('Ship the release')).toBeTruthy();
expect(screen.getByText('conversations.threadGoal.status.active')).toBeTruthy();
// Editor (with the token budget) is closed until the trigger is clicked.
expect(screen.queryByText(/250 \/ 1,000/)).toBeNull();
});
it('opening an existing goal seeds the input and exposes lifecycle actions', async () => {
const api = stubApi(goal());
render(<Harness api={api} />);
fireEvent.click(await screen.findByTitle('Ship the release'));
const input = screen.getByPlaceholderText<HTMLInputElement>(
'conversations.threadGoal.placeholder'
);
expect(input.value).toBe('Ship the release');
expect(screen.getByText(/250 \/ 1,000/)).toBeTruthy();
fireEvent.click(screen.getByLabelText('conversations.threadGoal.complete'));
await waitFor(() => expect(api.complete).toHaveBeenCalledWith('t1'));
});
it('shows Resume (not Pause) in the editor for a paused goal', async () => {
const api = stubApi(goal({ status: 'paused' }));
render(<Harness api={api} />);
fireEvent.click(await screen.findByTitle('Ship the release'));
expect(screen.getByLabelText('conversations.threadGoal.resume')).toBeTruthy();
expect(screen.queryByLabelText('conversations.threadGoal.pause')).toBeNull();
});
});
@@ -0,0 +1,369 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
type ThreadGoal,
threadGoalApi,
type ThreadGoalStatus,
} from '../../../services/api/threadGoalApi';
/**
* Per-thread goal UI — a Codex-style completion contract the agent pursues
* across turns. Split into two pieces sharing one {@link useThreadGoal}
* controller so the trigger can live in the composer footer while the editor
* opens above the composer:
*
* - {@link ThreadGoalFooterTrigger} — a compact affordance in the footer under
* the composer ("Set goal" when empty; status + objective when set). Click to
* open the editor.
* - {@link ThreadGoalEditorPanel} — the input field + status/budget + actions,
* rendered above the composer, shown only while expanded.
*
* Distinct from the global long-term goals list (Intelligence tab) and the
* thread task board (todo strip). Liveness: fetch on thread change + light poll
* so agent/continuation-driven changes surface without a manual refresh.
*/
const POLL_INTERVAL_MS = 10_000;
/** Shared controller returned by {@link useThreadGoal}. */
export interface ThreadGoalController {
threadId: string | null;
goal: ThreadGoal | null;
/** Whether the editor panel (above the composer) is open. */
expanded: boolean;
draft: string;
busy: boolean;
setDraft: (value: string) => void;
/** Open the editor, seeding the draft from the current objective. */
open: () => void;
close: () => void;
/** Toggle the editor open/closed (open seeds the draft). */
toggle: () => void;
/** Persist the draft as the objective (no-op on empty), then collapse. */
save: () => void;
complete: () => void;
pause: () => void;
resume: () => void;
clear: () => void;
}
/** Tailwind classes per status, using the app's ocean/sage/amber/coral palette. */
function statusClasses(status: ThreadGoalStatus): string {
switch (status) {
case 'active':
return 'bg-primary-50 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200';
case 'paused':
return 'bg-stone-100 text-stone-600 dark:bg-neutral-800 dark:text-neutral-300';
case 'budget_limited':
return 'bg-amber-50 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200';
case 'complete':
return 'bg-sage-50 text-sage-700 dark:bg-sage-900/40 dark:text-sage-200';
default:
return 'bg-stone-100 text-stone-600';
}
}
/**
* Goal state + actions for `threadId`. Call once in the parent and hand the
* result to both the footer trigger and the editor panel so they stay in sync.
*/
export function useThreadGoal(
threadId: string | null,
api: typeof threadGoalApi = threadGoalApi
): ThreadGoalController {
const [goal, setGoal] = useState<ThreadGoal | null>(null);
const [expanded, setExpanded] = useState(false);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
// Guard against thread-switch races resolving onto the wrong thread.
const activeThread = useRef(threadId);
const refresh = useCallback(async () => {
if (!threadId) {
setGoal(null);
return;
}
try {
const g = await api.get(threadId);
if (activeThread.current === threadId) setGoal(g);
} catch {
/* best-effort; keep last known goal */
}
}, [api, threadId]);
// Reset the editor + cached goal when the thread changes. Done during render
// (React's sanctioned "reset state on prop change" pattern) rather than in an
// effect, so it's synchronous and lint-clean.
if (activeThread.current !== threadId) {
activeThread.current = threadId;
setExpanded(false);
setGoal(null);
}
// Fetch on mount/thread-change and poll lightly. `refresh` is async, so its
// setState lands in a later microtask (not a synchronous effect write).
useEffect(() => {
if (!threadId) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch; setState lands post-await
void refresh();
const id = window.setInterval(() => void refresh(), POLL_INTERVAL_MS);
return () => window.clearInterval(id);
}, [threadId, refresh]);
const runAction = useCallback(
async (fn: () => Promise<ThreadGoal | null | boolean>, collapse: boolean) => {
setBusy(true);
try {
await fn();
await refresh();
if (collapse) setExpanded(false);
} finally {
setBusy(false);
}
},
[refresh]
);
const open = useCallback(() => {
setDraft(goal?.objective ?? '');
setExpanded(true);
}, [goal]);
const close = useCallback(() => setExpanded(false), []);
const toggle = useCallback(() => {
setExpanded(prev => {
if (!prev) setDraft(goal?.objective ?? '');
return !prev;
});
}, [goal]);
const save = useCallback(() => {
if (!threadId) return;
const objective = draft.trim();
if (!objective) return;
void runAction(() => api.set(threadId, objective), true);
}, [api, draft, runAction, threadId]);
const complete = useCallback(() => {
if (threadId) void runAction(() => api.complete(threadId), true);
}, [api, runAction, threadId]);
const pause = useCallback(() => {
if (threadId) void runAction(() => api.pause(threadId), false);
}, [api, runAction, threadId]);
const resume = useCallback(() => {
if (threadId) void runAction(() => api.resume(threadId), false);
}, [api, runAction, threadId]);
const clear = useCallback(() => {
if (threadId) void runAction(() => api.clear(threadId), true);
}, [api, runAction, threadId]);
return {
threadId,
goal,
expanded,
draft,
busy,
setDraft,
open,
close,
toggle,
save,
complete,
pause,
resume,
clear,
};
}
/** Compact trigger for the composer footer: "Set goal" or the current goal. */
export function ThreadGoalFooterTrigger({
ctl,
}: {
ctl: ThreadGoalController;
}): React.ReactElement | null {
const { t } = useT();
if (!ctl.threadId) return null;
if (!ctl.goal) {
return (
<button
type="button"
onClick={ctl.toggle}
aria-expanded={ctl.expanded}
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-stone-500 hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200">
<span aria-hidden></span>
{t('conversations.threadGoal.setCta')}
</button>
);
}
return (
<button
type="button"
onClick={ctl.toggle}
aria-expanded={ctl.expanded}
title={ctl.goal.objective}
className="inline-flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-0.5 text-xs hover:bg-stone-100 dark:hover:bg-neutral-800">
<span aria-hidden className="shrink-0 text-stone-400">
</span>
<span
className={`shrink-0 rounded px-1 py-0.5 text-[10px] font-medium uppercase tracking-wide ${statusClasses(ctl.goal.status)}`}>
{t(`conversations.threadGoal.status.${ctl.goal.status}`)}
</span>
<MarqueeText
text={ctl.goal.objective}
className="max-w-[18rem] text-stone-600 dark:text-neutral-300"
/>
</button>
);
}
/** Expanded editor above the composer: input + budget + lifecycle actions. */
export function ThreadGoalEditorPanel({
ctl,
}: {
ctl: ThreadGoalController;
}): React.ReactElement | null {
const { t } = useT();
if (!ctl.threadId || !ctl.expanded) return null;
const goal = ctl.goal;
const budgetText =
goal && typeof goal.tokenBudget === 'number' && goal.tokenBudget > 0
? `${goal.tokensUsed.toLocaleString()} / ${goal.tokenBudget.toLocaleString()} ${t('conversations.threadGoal.tokensSuffix')}`
: null;
return (
<div className="flex flex-col gap-1.5 rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 dark:border-neutral-800 dark:bg-neutral-800">
{/* Controls row — lifecycle (left) + budget and Cancel/Save (right) —
sits above the input so the input gets the full width below. */}
<div className="flex items-center gap-1">
{goal && goal.status === 'active' && (
<PanelButton
label={t('conversations.threadGoal.pause')}
disabled={ctl.busy}
onClick={ctl.pause}
/>
)}
{goal && goal.status === 'paused' && (
<PanelButton
label={t('conversations.threadGoal.resume')}
disabled={ctl.busy}
onClick={ctl.resume}
/>
)}
{goal && goal.status !== 'complete' && (
<PanelButton
label={t('conversations.threadGoal.complete')}
disabled={ctl.busy}
onClick={ctl.complete}
/>
)}
{goal && (
<PanelButton
label={t('conversations.threadGoal.clear')}
disabled={ctl.busy}
onClick={ctl.clear}
/>
)}
<div className="ml-auto flex items-center gap-1">
{budgetText && (
<span className="shrink-0 text-[11px] tabular-nums text-stone-400 dark:text-neutral-500">
{budgetText}
</span>
)}
<button
type="button"
onClick={ctl.close}
className="shrink-0 rounded px-2 py-0.5 text-xs text-stone-500 hover:bg-stone-100 dark:text-neutral-400 dark:hover:bg-neutral-800">
{t('conversations.threadGoal.cancel')}
</button>
<button
type="button"
onClick={ctl.save}
disabled={!ctl.draft.trim() || ctl.busy}
className="shrink-0 rounded px-2 py-0.5 text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:opacity-40 dark:text-primary-300 dark:hover:bg-primary-900/40">
{t('conversations.threadGoal.save')}
</button>
</div>
</div>
{/* Full-width objective input below the controls. */}
<input
autoFocus
value={ctl.draft}
onChange={e => ctl.setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') ctl.save();
if (e.key === 'Escape') ctl.close();
}}
placeholder={t('conversations.threadGoal.placeholder')}
aria-label={t('conversations.threadGoal.placeholder')}
className="w-full border-0 bg-transparent text-sm text-stone-800 outline-none focus:outline-none focus:ring-0 placeholder:text-stone-400 dark:text-neutral-100"
/>
</div>
);
}
/**
* Single-line label that gently marquees (ping-pong scroll) only when the text
* overflows its max width; otherwise it truncates. The scroll distance is
* measured and fed to the `goal-marquee` keyframe via a CSS variable.
*/
function MarqueeText({
text,
className = '',
}: {
text: string;
className?: string;
}): React.ReactElement {
const outerRef = useRef<HTMLSpanElement>(null);
const innerRef = useRef<HTMLSpanElement>(null);
const [shift, setShift] = useState(0);
useEffect(() => {
const outer = outerRef.current;
const inner = innerRef.current;
if (!outer || !inner) return;
const overflow = inner.scrollWidth - outer.clientWidth;
setShift(overflow > 4 ? overflow + 8 : 0);
}, [text]);
return (
<span ref={outerRef} className={`relative block min-w-0 overflow-hidden ${className}`}>
<span
ref={innerRef}
className={`block whitespace-nowrap ${shift > 0 ? 'animate-goal-marquee' : 'truncate'}`}
style={
shift > 0 ? ({ '--goal-marquee-shift': `-${shift}px` } as React.CSSProperties) : undefined
}>
{text}
</span>
</span>
);
}
function PanelButton({
label,
onClick,
disabled,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
}): React.ReactElement {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={onClick}
disabled={disabled}
className="rounded px-1.5 py-0.5 text-[11px] text-stone-500 hover:bg-stone-100 disabled:opacity-40 dark:text-neutral-400 dark:hover:bg-neutral-800">
{label}
</button>
);
}
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockCallCoreRpc = vi.fn();
vi.mock('../../coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
}));
const { threadGoalApi } = await import('../threadGoalApi');
const goal = {
threadId: 't1',
goalId: 'g-uuid',
objective: 'ship it',
status: 'active' as const,
tokenBudget: 1000,
tokensUsed: 100,
timeUsedSeconds: 5,
createdAtMs: 1,
updatedAtMs: 2,
continuationSuppressed: false,
};
describe('threadGoalApi', () => {
beforeEach(() => mockCallCoreRpc.mockReset());
it('get returns the bare goal envelope', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ goal });
const out = await threadGoalApi.get('t1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.thread_goals_get',
params: { thread_id: 't1' },
});
expect(out?.objective).toBe('ship it');
});
it('get returns null when the thread has no goal', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ goal: null });
expect(await threadGoalApi.get('t1')).toBeNull();
});
it('set unwraps the { result, logs } envelope and forwards the budget', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: { goal }, logs: ['set'] });
const out = await threadGoalApi.set('t1', 'ship it', 1000);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.thread_goals_set',
params: { thread_id: 't1', objective: 'ship it', token_budget: 1000 },
});
expect(out?.goalId).toBe('g-uuid');
});
it('set omits token_budget when not provided', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: { goal }, logs: [] });
await threadGoalApi.set('t1', 'ship it');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.thread_goals_set',
params: { thread_id: 't1', objective: 'ship it' },
});
});
it('complete / pause / resume hit the right methods', async () => {
mockCallCoreRpc.mockResolvedValue({ result: { goal }, logs: [] });
await threadGoalApi.complete('t1');
await threadGoalApi.pause('t1');
await threadGoalApi.resume('t1');
const methods = mockCallCoreRpc.mock.calls.map(c => (c[0] as { method: string }).method);
expect(methods).toEqual([
'openhuman.thread_goals_complete',
'openhuman.thread_goals_pause',
'openhuman.thread_goals_resume',
]);
});
it('clear returns the removed flag', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: { removed: true }, logs: ['cleared'] });
expect(await threadGoalApi.clear('t1')).toBe(true);
mockCallCoreRpc.mockResolvedValueOnce({ removed: false });
expect(await threadGoalApi.clear('t1')).toBe(false);
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Frontend client for the thread-level goal surface (`openhuman.thread_goals_*`).
*
* A thread goal is a single, thread-scoped "completion contract" (Codex-style)
* the agent pursues across turns — distinct from the global long-term goals list
* ({@link ./goalsApi}) and the per-thread task board. The Rust handlers persist
* one goal per thread under `<workspace>/thread_goals/<hex(thread_id)>.json`.
*
* Wire shapes: `get` returns the bare `{ goal }` envelope; the mutation methods
* wrap their value in `{ result, logs }` when logs are present. {@link unwrap}
* normalises both so callers always get the typed payload.
*/
import debug from 'debug';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('openhuman:threadGoalApi');
/** Lifecycle state of a thread goal (mirrors the Rust `ThreadGoalStatus`). */
export type ThreadGoalStatus = 'active' | 'paused' | 'budget_limited' | 'complete';
/** A single thread-scoped goal (mirrors the Rust `ThreadGoal`, camelCase wire). */
export interface ThreadGoal {
threadId: string;
goalId: string;
objective: string;
status: ThreadGoalStatus;
tokenBudget?: number | null;
tokensUsed: number;
timeUsedSeconds: number;
createdAtMs: number;
updatedAtMs: number;
continuationSuppressed: boolean;
}
/** Unwrap the optional `{ result, logs }` RpcOutcome envelope. */
function unwrap<T>(res: unknown): T {
if (res && typeof res === 'object' && 'result' in (res as Record<string, unknown>)) {
return (res as { result: T }).result;
}
return res as T;
}
/** Pull a `ThreadGoal | null` out of a `{ goal }` envelope. */
function extractGoal(res: unknown): ThreadGoal | null {
const value = unwrap<{ goal?: ThreadGoal | null }>(res);
return value && typeof value === 'object' && value.goal ? value.goal : null;
}
export const threadGoalApi = {
/** Get the thread's goal, or `null` when it has none. */
get: async (threadId: string): Promise<ThreadGoal | null> => {
log('get thread=%s', threadId);
const res = await callCoreRpc<unknown>({
method: 'openhuman.thread_goals_get',
params: { thread_id: threadId },
});
return extractGoal(res);
},
/** Create or replace the thread's goal. */
set: async (
threadId: string,
objective: string,
tokenBudget?: number
): Promise<ThreadGoal | null> => {
log('set thread=%s budget=%o', threadId, tokenBudget);
const params: Record<string, unknown> = { thread_id: threadId, objective };
if (typeof tokenBudget === 'number') params.token_budget = tokenBudget;
const res = await callCoreRpc<unknown>({ method: 'openhuman.thread_goals_set', params });
return extractGoal(res);
},
/** Mark the thread's goal complete. */
complete: async (threadId: string): Promise<ThreadGoal | null> => {
log('complete thread=%s', threadId);
const res = await callCoreRpc<unknown>({
method: 'openhuman.thread_goals_complete',
params: { thread_id: threadId },
});
return extractGoal(res);
},
/** Pause an active goal. */
pause: async (threadId: string): Promise<ThreadGoal | null> => {
log('pause thread=%s', threadId);
const res = await callCoreRpc<unknown>({
method: 'openhuman.thread_goals_pause',
params: { thread_id: threadId },
});
return extractGoal(res);
},
/** Resume a paused goal. */
resume: async (threadId: string): Promise<ThreadGoal | null> => {
log('resume thread=%s', threadId);
const res = await callCoreRpc<unknown>({
method: 'openhuman.thread_goals_resume',
params: { thread_id: threadId },
});
return extractGoal(res);
},
/** Clear (delete) the thread's goal. Returns whether one existed. */
clear: async (threadId: string): Promise<boolean> => {
log('clear thread=%s', threadId);
const res = await callCoreRpc<unknown>({
method: 'openhuman.thread_goals_clear',
params: { thread_id: threadId },
});
const value = unwrap<{ removed?: boolean }>(res);
return !!(value && typeof value === 'object' && value.removed);
},
};
+8
View File
@@ -239,6 +239,10 @@ module.exports = {
'float': 'float 3s ease-in-out infinite',
'ticker': 'ticker 30s linear infinite',
'wiggle': 'wiggle 0.5s ease-in-out',
// Gentle ping-pong scroll for overflowing single-line labels (e.g. a
// long thread-goal objective). Distance is supplied per-element via the
// `--goal-marquee-shift` CSS var; `alternate` returns it to the start.
'goal-marquee': 'goalMarquee 6s ease-in-out infinite alternate',
},
keyframes: {
@@ -283,6 +287,10 @@ module.exports = {
'25%': { transform: 'rotate(-9deg)' },
'75%': { transform: 'rotate(9deg)' },
},
goalMarquee: {
'0%, 18%': { transform: 'translateX(0)' },
'82%, 100%': { transform: 'translateX(var(--goal-marquee-shift, 0px))' },
},
},
// Backdrop blur for glass morphism
+6
View File
@@ -219,6 +219,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::memory::all_memory_registered_controllers());
// Long-term goals list (editable list + turn-based enrichment agent)
controllers.extend(crate::openhuman::memory_goals::all_memory_goals_registered_controllers());
// Thread-level goal (Codex-style per-thread completion contract)
controllers.extend(crate::openhuman::thread_goals::all_thread_goals_registered_controllers());
// Memory tree ingestion layer (#707 — canonicalised chunks with provenance)
controllers.extend(crate::openhuman::memory_tree::all_memory_tree_registered_controllers());
// Memory tree retrieval layer (#710 — LLM-callable read tools over the tree)
@@ -403,6 +405,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::tool_registry::all_tool_registry_controller_schemas());
schemas.extend(crate::openhuman::memory::all_memory_controller_schemas());
schemas.extend(crate::openhuman::memory_goals::all_memory_goals_controller_schemas());
schemas.extend(crate::openhuman::thread_goals::all_thread_goals_controller_schemas());
schemas.extend(crate::openhuman::memory_tree::all_memory_tree_controller_schemas());
schemas.extend(crate::openhuman::memory_tree::all_retrieval_controller_schemas());
schemas.extend(
@@ -535,6 +538,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"memory_goals" => Some(
"The agent's long-term goals list for working with the user — editable items plus turn-based enrichment.",
),
"thread_goals" => Some(
"The thread-level goal — a Codex-style per-thread completion contract with lifecycle, token budget, and idle continuation.",
),
"memory_tree" => Some(
"Canonical chunk ingestion, provenance capture, and chunk retrieval for source-grounded memory.",
),
+15
View File
@@ -1012,6 +1012,17 @@ pub enum DomainEvent {
reason: String,
},
// ── Thread goals ──────────────────────────────────────────────────
/// A thread's goal was created, replaced, or transitioned state
/// (active/paused/budget_limited/complete). Drives the desktop goal chip.
ThreadGoalUpdated {
thread_id: String,
goal_id: String,
status: String,
},
/// A thread's goal was cleared (deleted).
ThreadGoalCleared { thread_id: String },
// ── Backend Meet Bot ──────────────────────────────────────────────
/// Backend gmeet bot successfully joined the meeting.
BackendMeetJoined {
@@ -1242,6 +1253,8 @@ impl DomainEvent {
Self::TaskPlanAwaitingApproval { .. } | Self::TaskRunReclaimed { .. } => "agent",
Self::ThreadGoalUpdated { .. } | Self::ThreadGoalCleared { .. } => "agent",
Self::SubconsciousTriggerProcessed { .. } => "subconscious",
Self::Voice(_) => "voice",
@@ -1392,6 +1405,8 @@ impl DomainEvent {
Self::TaskSourceFetchFailed { .. } => "TaskSourceFetchFailed",
Self::TaskPlanAwaitingApproval { .. } => "TaskPlanAwaitingApproval",
Self::TaskRunReclaimed { .. } => "TaskRunReclaimed",
Self::ThreadGoalUpdated { .. } => "ThreadGoalUpdated",
Self::ThreadGoalCleared { .. } => "ThreadGoalCleared",
Self::BackendMeetJoined { .. } => "BackendMeetJoined",
Self::BackendMeetLeft { .. } => "BackendMeetLeft",
Self::BackendMeetReply { .. } => "BackendMeetReply",
+21
View File
@@ -378,6 +378,27 @@ pub(super) const CAPABILITIES: &[Capability] = &[
// the device during a reflect pass (CRUD/storage stays local).
privacy: DERIVED_TO_BACKEND,
},
Capability {
id: "conversation.thread_goal",
name: "Thread Goal",
domain: "conversation",
category: CapabilityCategory::Conversation,
description: "A single, thread-scoped goal (Codex-style \"completion contract\") the \
assistant keeps pursuing across turns, interrupts, resumes, and budget boundaries — \
distinct from the long-term goals list and the per-thread task board. Stored locally \
(one goal per thread), with a lifecycle (active/paused/budget_limited/complete) and an \
optional token budget. The active goal is injected into context each turn; the context \
scout proposes a goal on a fresh thread (only if none is set) and the orchestrator can \
set/refine it. When enabled, idle threads can autonomously continue toward the goal.",
how_to: "Set/edit via the goal chip above the composer in Conversations, or the \
thread_goals.* RPC (get/set/complete/pause/resume/clear); the assistant manages it via \
the goal_set / goal_get / goal_complete tools. Autonomous continuation is opt-in via \
heartbeat.goal_continuation_enabled.",
status: CapabilityStatus::Beta,
// Goal CRUD/storage is local; autonomous continuation (opt-in) runs a
// cloud agentic model, so objective/context can leave the device then.
privacy: DERIVED_TO_BACKEND,
},
Capability {
id: "intelligence.memory_tree_retrieval",
name: "Memory Tree Retrieval (chat)",
@@ -270,6 +270,12 @@ impl Agent {
"model_council_readonly",
);
agent.set_agent_definition_name(format!("model_council_{safe_name}"));
// Council jurors are non-interactive, single-shot read-only model calls
// built from the orchestrator definition. The first-turn super-context
// pass (default-on) is an interactive convenience for the user-facing
// chat orchestrator — running it per juror would add an unexpected
// `context_scout` LLM call to each jury seat. Suppress it here.
agent.context.set_super_context_enabled(false);
if let Some(model) = model_override
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty())
@@ -368,6 +368,53 @@ impl Agent {
}
}
// ── Thread goal (Codex-style per-thread completion contract) ─────────
// Load this thread's durable goal once per turn and prepend a compact
// [active_goal] block so the objective + live status/budget steer the
// turn. Rides the per-turn context (NOT the cached system-prompt prefix)
// so edits take effect immediately. `active_goal` is reused below to arm
// the budget stop hook around the engine call.
// Capture the workspace path for the budget stop hook built after the
// `turn_body` coroutine (which borrows `&mut self`) is constructed.
let goal_workspace_dir = self.workspace_dir.clone();
let active_goal = {
let loaded = crate::openhuman::thread_goals::runtime::load_for_current_thread(
&self.workspace_dir,
)
.await;
// Thread-resume semantics: the user re-engaging a thread reactivates a
// paused goal (Codex's ThreadResumed). Best-effort; on failure keep
// the loaded (paused) goal so we still surface it.
match loaded {
Some(goal)
if matches!(
goal.status,
crate::openhuman::thread_goals::ThreadGoalStatus::Paused
) =>
{
crate::openhuman::thread_goals::runtime::resume_for_current_thread(
&self.workspace_dir,
)
.await
.unwrap_or(Some(goal))
}
other => other,
}
};
if let Some(ref goal) = active_goal {
if let Some(block) =
crate::openhuman::thread_goals::runtime::active_goal_context_block(goal)
{
log::info!(
"[thread_goals] injecting active_goal block status={} budget={:?} ({} chars)",
goal.status.as_str(),
goal.token_budget,
block.chars().count()
);
context.push_str(&block);
}
}
let enriched = if context.is_empty() {
log::info!("[agent] no memory context found — using raw user message");
self.last_memory_context = None;
@@ -739,6 +786,17 @@ impl Agent {
self.context.record_tool_calls(records.len());
// Account this turn's tokens (prompt + completion) and elapsed time
// against the thread's active goal, flipping it to budget_limited
// when the cap is crossed. Best-effort — never fails the turn.
crate::openhuman::thread_goals::runtime::account_turn_against_goal(
&self.workspace_dir,
cumulative_input,
cumulative_output,
turn_started.elapsed().as_secs(),
)
.await;
// For a clean final response the observer already pushed the
// assistant message + persisted. For a max-iteration checkpoint or
// circuit-breaker halt the engine returned the text without pushing
@@ -799,7 +857,31 @@ impl Agent {
// that any `spawn_subagent` tool call fired during the loop can
// read the parent's provider, tools, model, and workspace via
// the PARENT_CONTEXT task-local.
let result = harness::with_parent_context(parent_context, turn_body).await;
// Arm the thread-goal budget stop hook for this turn when an active,
// budgeted goal exists — it hard-stops the loop the moment running usage
// would exceed the cap (so an autonomous run can't blow past it between
// accounting points). Merge with any ambient stop hooks rather than
// clobbering them. No budgeted active goal → no extra hook, no wrap.
let mut turn_stop_hooks = crate::openhuman::agent::stop_hooks::current_stop_hooks();
if let Some(ref goal) = active_goal {
if let Some(hook) =
crate::openhuman::thread_goals::runtime::GoalBudgetStopHook::for_goal(
&goal_workspace_dir,
goal,
)
{
turn_stop_hooks.push(std::sync::Arc::new(hook));
}
}
let result = if turn_stop_hooks.is_empty() {
harness::with_parent_context(parent_context, turn_body).await
} else {
harness::with_parent_context(
parent_context,
crate::openhuman::agent::stop_hooks::with_stop_hooks(turn_stop_hooks, turn_body),
)
.await
};
// Session transcript persistence lives INSIDE the turn body —
// one write per provider response, fired right after the
+3
View File
@@ -75,6 +75,9 @@ pub enum TrustedAutomationSource {
/// from an external sync source (Gmail / Slack / Notion / etc.).
/// Treated as untrusted: external-effect tool surface blocked.
SubconsciousTainted,
/// Autonomous continuation of a thread goal: the heartbeat injected a turn
/// to keep working an idle `active` goal the user explicitly created.
GoalContinuation,
}
tokio::task_local! {
@@ -18,6 +18,7 @@ use crate::openhuman::agent::harness::subagent_runner::{
run_subagent, SubagentRunOptions, SubagentRunStatus,
};
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::inference::provider::thread_context::current_thread_id;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
@@ -222,6 +223,57 @@ pub async fn run_context_scout(question: &str, focus: Option<&str>) -> anyhow::R
})
.await;
}
// Bootstrap this thread's goal from the scout's proposal — but
// ONLY when the thread has none yet. The orchestrator stays
// authoritative (it sets/replaces via `goal_set`); the
// context-gathering path just seeds a goal on the first scout of
// a fresh chat so the harness has something to steer toward.
// Runs for both entry points (LLM-invoked tool + harness
// super-context first turn). Best-effort — never fails the call.
if let (Some(parent), Some(thread_id)) = (current_parent(), current_thread_id()) {
if let Some(objective) =
AgentPrepareContextTool::parse_proposed_goal(&outcome.output)
{
match crate::openhuman::thread_goals::store::set_if_absent(
&parent.workspace_dir,
&thread_id,
&objective,
None,
)
.await
{
Ok(Some(goal)) => {
tracing::info!(
target: "agent_prepare_context",
thread_id = %thread_id,
goal_id = %goal.goal_id,
"[agent_prepare_context] bootstrapped thread goal from scout proposal"
);
publish_global(DomainEvent::ThreadGoalUpdated {
thread_id: goal.thread_id.clone(),
goal_id: goal.goal_id.clone(),
status: goal.status.as_str().to_string(),
});
}
Ok(None) => {
tracing::debug!(
target: "agent_prepare_context",
thread_id = %thread_id,
"[agent_prepare_context] thread already has a goal — scout proposal not applied"
);
}
Err(e) => {
tracing::debug!(
target: "agent_prepare_context",
error = %e,
"[agent_prepare_context] failed to persist scout-proposed goal"
);
}
}
}
}
Ok(ToolResult::success(outcome.output))
}
// The scout has no `ask_user_clarification` tool, so this
@@ -388,6 +440,25 @@ impl AgentPrepareContextTool {
);
prompt
}
/// Extract the scout's `proposed_goal:` line from a `[context_bundle]`, if
/// present and meaningful. Returns `None` for a missing line or an explicit
/// `none`. The prefix is matched case-insensitively; its byte length is
/// fixed (no multibyte), so slicing past it is safe.
fn parse_proposed_goal(bundle: &str) -> Option<String> {
const PREFIX: &str = "proposed_goal:";
// Boundary-safe prefix match: `get(..len)` returns None rather than
// panicking when the line begins with a multibyte char before byte 14.
let line = bundle.lines().map(str::trim).find(|l| {
l.get(..PREFIX.len())
.is_some_and(|p| p.eq_ignore_ascii_case(PREFIX))
})?;
let value = line[PREFIX.len()..].trim();
if value.is_empty() || value.eq_ignore_ascii_case("none") {
return None;
}
Some(value.to_string())
}
}
#[async_trait]
@@ -485,6 +556,40 @@ mod tests {
assert!(!prompt.contains("[Focus]"));
}
#[test]
fn parse_proposed_goal_extracts_objective_or_none() {
let bundle = "[context_bundle]\nhas_enough_context: true\n\
proposed_goal: Ship the desktop release to all platforms\n\
summary: ...\n[/context_bundle]";
assert_eq!(
AgentPrepareContextTool::parse_proposed_goal(bundle).as_deref(),
Some("Ship the desktop release to all platforms")
);
// Explicit `none` → no goal.
let none_bundle = "[context_bundle]\nproposed_goal: none\nsummary: x\n[/context_bundle]";
assert!(AgentPrepareContextTool::parse_proposed_goal(none_bundle).is_none());
// Missing line → no goal.
let no_line = "[context_bundle]\nhas_enough_context: true\n[/context_bundle]";
assert!(AgentPrepareContextTool::parse_proposed_goal(no_line).is_none());
// Case-insensitive prefix.
let cased = "Proposed_Goal: Land the migration ";
assert_eq!(
AgentPrepareContextTool::parse_proposed_goal(cased).as_deref(),
Some("Land the migration")
);
// Lines starting with a multibyte char must not panic the byte-prefix
// match (regression for the `l[..14]` non-boundary slice).
let multibyte = "[context_bundle]\n日本語の要約 summary line\nproposed_goal: 目標を達成する\n[/context_bundle]";
assert_eq!(
AgentPrepareContextTool::parse_proposed_goal(multibyte).as_deref(),
Some("目標を達成する")
);
}
#[tokio::test]
async fn missing_question_returns_error() {
let tool = AgentPrepareContextTool::new();
@@ -41,6 +41,10 @@ outside it. No preamble, no closing prose. Use exactly this shape:
```text
[context_bundle]
has_enough_context: true|false
proposed_goal: <ONE single line — the durable objective this thread should
pursue (what "done" looks like), or `none` for a trivial/one-shot request that
needs no goal. Keep it on this one line; the harness only reads the text on the
same line as `proposed_goal:`.>
summary: <≤ ~700 tokens of distilled, source-attributed context. Lead with what
matters. Attribute facts: (memory), (transcript: <thread>), (profile),
(web: <url>), (integrations).>
@@ -62,6 +66,11 @@ Rules for the bundle:
- `has_enough_context` is `true` when the orchestrator could act now without
more gathering; `false` when key facts are still missing (say which in the
summary).
- `proposed_goal` is the durable objective for the *thread* (not a list of
steps). The harness records it as the thread's goal **only if none is set yet**
— the orchestrator stays authoritative and may refine it later — so make it a
clean, outcome-shaped sentence. Use `none` for chit-chat or a trivial one-shot
that doesn't warrant a tracked goal.
- Every `recommended_tool_calls[].tool` MUST be an **exact name** from the
"Orchestrator tools" list injected below — these are the tools the
*orchestrator* can call, not the tools you used. Order them in the sequence
@@ -223,6 +223,18 @@ named = [
# downstream agents — the orchestrator coordinates, it doesn't
# touch files itself.
"todowrite",
# Thread-level goal (Codex-style per-thread completion contract). `goal_set`
# records the durable objective for THIS thread when a non-trivial request
# lands (the orchestrator is authoritative — it always creates or replaces);
# `goal_get` reads it back (status + token budget); `goal_complete` closes it
# out only when evidence confirms success. Distinct from `todowrite` (this
# thread's task board) and the long-term `goals_*` list (cross-thread). The
# harness injects the active goal into context each turn and can auto-continue
# it when the thread goes idle, so keeping the objective current here is what
# keeps that loop on-target.
"goal_set",
"goal_get",
"goal_complete",
# `update_task` moves/updates a specific task card by id on a target board
# (defaults to the proactive `task-sources` board) — so the orchestrator can
# advance the task it's working: → in_progress when it starts, → done with
+41 -14
View File
@@ -298,20 +298,6 @@ impl ApprovalGate {
action_summary: &str,
args_redacted: serde_json::Value,
) -> (GateOutcome, Option<String>) {
// "Always allow" allowlist shortcut — the user's persisted
// `autonomy.auto_approve` set. Read from the live policy first so a
// grant made earlier in this session (which writes config + reloads the
// live policy) takes effect on the very next tool call; fall back to the
// gate's boot-time config when no live policy is installed (e.g. a CLI
// invocation that never started a session runtime, or a unit test).
if self.tool_is_auto_approved(tool_name) {
tracing::debug!(
tool = tool_name,
"[approval::gate] auto_approve allowlist hit, skipping prompt"
);
return (GateOutcome::Allow, None);
}
// Origin tells us who scheduled this turn. Entry points (web channel,
// channel runtime, subconscious, cron, CLI) scope a typed
// `AgentTurnOrigin` around `run_turn`. Unlabelled callers map to
@@ -319,6 +305,32 @@ impl ApprovalGate {
// external_effect tool from an unlabelled call site.
let origin = turn_origin::current().unwrap_or(AgentTurnOrigin::Unknown);
// An autonomous goal continuation runs with no user present, so an
// irreversible external action must never be auto-allowed — not even via
// the `autonomy.auto_approve` allowlist. Skip the shortcut for that
// origin and fall through to the parking flow below.
let is_goal_continuation = matches!(
&origin,
AgentTurnOrigin::TrustedAutomation {
source: TrustedAutomationSource::GoalContinuation,
..
}
);
// "Always allow" allowlist shortcut — the user's persisted
// `autonomy.auto_approve` set. Read from the live policy first so a
// grant made earlier in this session (which writes config + reloads the
// live policy) takes effect on the very next tool call; fall back to the
// gate's boot-time config when no live policy is installed (e.g. a CLI
// invocation that never started a session runtime, or a unit test).
if !is_goal_continuation && self.tool_is_auto_approved(tool_name) {
tracing::debug!(
tool = tool_name,
"[approval::gate] auto_approve allowlist hit, skipping prompt"
);
return (GateOutcome::Allow, None);
}
// Chat context (thread/client id) for routing the yes/no reply — set by
// the web channel around the agent run; absent for non-chat callers.
let chat_ctx = APPROVAL_CHAT_CONTEXT.try_with(|c| c.clone()).ok();
@@ -407,6 +419,21 @@ impl ApprovalGate {
None,
);
}
AgentTurnOrigin::TrustedAutomation {
source: TrustedAutomationSource::GoalContinuation,
job_id,
} => {
tracing::debug!(
tool = tool_name,
job_id = %job_id,
"[approval::gate] autonomous goal continuation — external_effect tool parks \
(no present user to authorize); TTL-denies without a routable surface"
);
// Fall through to the parking flow: an autonomous continuation
// runs with no user present, so we must NOT auto-allow an
// irreversible external action. Read/compute tools (not gated
// here) still make progress on the goal.
}
AgentTurnOrigin::Cli => {
tracing::debug!(
tool = tool_name,
@@ -126,12 +126,27 @@ pub struct HeartbeatConfig {
/// the always-on loop's spend ceiling.
#[serde(default = "default_max_promotions_per_hour")]
pub max_promotions_per_hour: u32,
/// Enable Codex-style autonomous continuation of thread goals: when a thread
/// with an `active` goal goes idle (no in-flight turn, no recent activity),
/// the heartbeat injects one continuation turn to keep working it. Opt-in
/// (default false) because it spends model/integration budget without a user
/// present — matching the project's stance on autonomous features.
#[serde(default)]
pub goal_continuation_enabled: bool,
/// Minutes a thread goal must be idle before a continuation turn may fire.
/// Clamped to at least 1.
#[serde(default = "default_goal_idle_minutes")]
pub goal_idle_minutes: u32,
}
fn default_max_promotions_per_hour() -> u32 {
30
}
fn default_goal_idle_minutes() -> u32 {
10
}
fn default_context_budget() -> u32 {
40_000
}
@@ -179,6 +194,8 @@ impl Default for HeartbeatConfig {
subconscious_mode: SubconsciousMode::Off,
triggers_enabled: false,
max_promotions_per_hour: default_max_promotions_per_hour(),
goal_continuation_enabled: false,
goal_idle_minutes: default_goal_idle_minutes(),
}
}
}
+8
View File
@@ -205,6 +205,14 @@ impl ContextManager {
self.super_context_enabled
}
/// Force-disable the first-turn super-context pass for this session,
/// regardless of the config default. Used by non-interactive orchestrator
/// builds (e.g. read-only model-council jurors) where a scout pass would add
/// an unexpected LLM call and perturb deterministic call sequences.
pub fn set_super_context_enabled(&mut self, enabled: bool) {
self.super_context_enabled = enabled;
}
// ─── Budget tracking ──────────────────────────────────────────
/// Feed the latest provider [`UsageInfo`] into the guard + the
+1
View File
@@ -113,6 +113,7 @@ pub mod team;
#[cfg(feature = "e2e-test-support")]
pub mod test_support;
pub mod text_input;
pub mod thread_goals;
pub mod threads;
pub mod tinyplace;
pub mod tls;
@@ -87,6 +87,19 @@ impl HeartbeatEngine {
self.run_event_planner_tick_for_config(&config).await;
// Codex-style autonomous continuation of idle thread goals (opt-in
// via `heartbeat.goal_continuation_enabled`). Spawned detached, NOT
// awaited: a continuation runs a model-backed turn that can be slow
// or park on an approval, and must never stall the rest of the
// heartbeat tick. Its own process-wide Semaphore(1) prevents
// overlapping continuations across ticks.
{
let cfg = config.clone();
tokio::spawn(async move {
crate::openhuman::thread_goals::continuation::run_continuation_tick(&cfg).await;
});
}
if current.inference_enabled {
// Get the shared global engine (same instance as RPC handlers)
let lock = match get_or_init_engine().await {
+270
View File
@@ -0,0 +1,270 @@
//! Heartbeat-driven autonomous continuation of idle thread goals (Codex's
//! `MaybeContinueIfIdle`).
//!
//! When a thread carries an **active** goal and goes idle — no in-flight turn
//! and no activity for `goal_idle_minutes` — the heartbeat injects **one**
//! continuation turn that keeps working the objective. The guards mirror Codex:
//!
//! - **Opt-in.** Off unless `heartbeat.goal_continuation_enabled` is set — an
//! autonomous turn spends budget with no user present, so it defaults off
//! (matching the project's stance on background automation).
//! - **One-shot per idle period.** After a continuation fires, the goal is
//! marked `continuation_suppressed`; a later user-initiated turn clears it
//! (see [`super::runtime::account_turn_against_goal`]), so the next idle
//! period can fire again. Prevents a tight self-driving loop.
//! - **Serialised.** A process-wide `Semaphore(1)` ensures at most one
//! continuation runs at a time.
//! - **No in-flight turn.** Skipped while the thread has a live turn.
//! - **Per-tick cap.** Bounds how many goals continue in a single tick.
//!
//! The continuation turn runs the orchestrator on the thread (resuming its
//! transcript) under a `TrustedAutomation { GoalContinuation }` origin, so the
//! approval gate parks irreversible external actions (no present user to
//! authorize) while read/compute work proceeds.
use std::path::Path;
use std::sync::OnceLock;
use tokio::sync::Semaphore;
use super::store;
use super::types::{ThreadGoal, ThreadGoalStatus};
use crate::openhuman::agent::turn_origin::{with_origin, AgentTurnOrigin, TrustedAutomationSource};
use crate::openhuman::agent::Agent;
use crate::openhuman::config::Config;
use crate::openhuman::inference::provider::thread_context::with_thread_id;
use crate::openhuman::threads::turn_state::{TurnLifecycle, TurnStateStore};
/// Serialise continuation dispatches so at most one autonomous goal turn runs at
/// a time (Codex's `Semaphore(1)` guard).
fn continuation_gate() -> &'static Semaphore {
static GATE: OnceLock<Semaphore> = OnceLock::new();
GATE.get_or_init(|| Semaphore::new(1))
}
/// Max continuation turns dispatched per heartbeat tick (bounds spend).
const MAX_CONTINUATIONS_PER_TICK: usize = 2;
/// Current unix time in milliseconds.
fn now_ms() -> u64 {
chrono::Utc::now().timestamp_millis().max(0) as u64
}
/// One heartbeat pass: find idle active goals and continue them. No-op unless
/// `heartbeat.goal_continuation_enabled`.
pub async fn run_continuation_tick(config: &Config) {
if !config.heartbeat.goal_continuation_enabled {
return;
}
let workspace_dir = config.workspace_dir.clone();
let idle_ms = u64::from(config.heartbeat.goal_idle_minutes.max(1)) * 60_000;
let now = now_ms();
let goals = match store::list_all(&workspace_dir).await {
Ok(g) => g,
Err(e) => {
tracing::warn!("[thread_goals] continuation tick: list_all failed: {e}");
return;
}
};
let mut candidates: Vec<ThreadGoal> = goals
.into_iter()
.filter(|g| g.status == ThreadGoalStatus::Active)
.filter(|g| !g.continuation_suppressed)
.filter(|g| now.saturating_sub(g.updated_at_ms) >= idle_ms)
.collect();
// Oldest-idle first so the most-neglected goal advances under the per-tick cap.
candidates.sort_by_key(|g| g.updated_at_ms);
candidates.truncate(MAX_CONTINUATIONS_PER_TICK);
if candidates.is_empty() {
tracing::debug!("[thread_goals] continuation tick: no idle active goals");
return;
}
tracing::info!(
"[thread_goals] continuation tick: {} idle candidate(s) (idle_min={})",
candidates.len(),
config.heartbeat.goal_idle_minutes.max(1)
);
for goal in candidates {
if has_in_flight_turn(&workspace_dir, &goal.thread_id) {
tracing::debug!(
thread_id = %goal.thread_id,
"[thread_goals] continuation skip: turn in flight"
);
continue;
}
// One continuation at a time. If the gate is busy, another continuation
// is mid-flight — defer the rest to the next tick.
let permit = match continuation_gate().try_acquire() {
Ok(p) => p,
Err(_) => {
tracing::debug!("[thread_goals] continuation gate busy; deferring to next tick");
return;
}
};
let ran = dispatch_continuation(config, &goal).await;
// One-shot: suppress further continuations on this goal until a
// user-initiated turn clears the flag. Only when the turn actually ran,
// and only if THIS goal is still current (compare-and-set on goal_id) —
// a goal completed or replaced during the turn must not be suppressed.
if ran {
if let Err(e) = store::set_continuation_suppressed_if(
&workspace_dir,
&goal.thread_id,
&goal.goal_id,
true,
)
.await
{
tracing::debug!(
thread_id = %goal.thread_id,
error = %e,
"[thread_goals] failed to set continuation_suppressed"
);
}
}
drop(permit);
}
}
/// Whether `thread_id` currently has a live (started/streaming) turn.
fn has_in_flight_turn(workspace_dir: &Path, thread_id: &str) -> bool {
match TurnStateStore::new(workspace_dir.to_path_buf()).get(thread_id) {
Ok(Some(ts)) => matches!(
ts.lifecycle,
TurnLifecycle::Started | TurnLifecycle::Streaming
),
_ => false,
}
}
/// The continuation message handed to the orchestrator.
fn continuation_prompt(objective: &str) -> String {
format!(
"[goal continuation] You are resuming autonomous work toward this thread's goal — \
no user is currently present.\n\nGoal: {objective}\n\n\
Assess progress against concrete evidence, then take the next useful step. \
If the goal is already satisfied, call `goal_complete`. If you are blocked or \
need the user (e.g. an irreversible external action, missing input), stop and \
summarise the blocker and the next step rather than guessing — external actions \
are not auto-approved while you run unattended."
)
}
/// Build and run a single continuation turn for `goal`. Best-effort: failures
/// are logged, never propagated (the heartbeat must keep ticking).
///
/// Returns `true` when a turn was actually attempted (agent built + run_single
/// invoked), `false` when the agent couldn't even be built — the caller only
/// suppresses further continuations when a turn actually ran.
async fn dispatch_continuation(config: &Config, goal: &ThreadGoal) -> bool {
let thread_id = goal.thread_id.clone();
tracing::info!(
thread_id = %thread_id,
goal_id = %goal.goal_id,
"[thread_goals] dispatching continuation turn"
);
let mut agent = match Agent::from_config_for_agent(config, "orchestrator") {
Ok(a) => a,
Err(e) => {
tracing::warn!(
thread_id = %thread_id,
error = %e,
"[thread_goals] continuation: failed to build orchestrator agent"
);
return false;
}
};
// Tag events so subscribers can correlate goal-continuation turns and filter
// them from user-driven flows.
agent.set_event_context(format!("goal:{thread_id}"), "goal_continuation");
let prompt = continuation_prompt(&goal.objective);
let origin = AgentTurnOrigin::TrustedAutomation {
job_id: format!("goal:{thread_id}"),
source: TrustedAutomationSource::GoalContinuation,
};
// Scope the ambient thread id (so the goal tools + per-turn injection target
// this thread) and the trusted-automation origin (so the approval gate parks
// unattended external actions). `run_single` resumes the thread transcript.
let result = with_thread_id(
thread_id.clone(),
with_origin(origin, agent.run_single(&prompt)),
)
.await;
match result {
Ok(text) => tracing::info!(
thread_id = %thread_id,
response_chars = text.chars().count(),
"[thread_goals] continuation turn complete"
),
Err(e) => tracing::warn!(
thread_id = %thread_id,
error = %e,
"[thread_goals] continuation turn failed"
),
}
// A turn was attempted (built + run) regardless of Ok/Err — suppress so we
// don't re-fire it every tick until the user re-engages.
true
}
#[cfg(test)]
mod tests {
use super::*;
fn config_with(enabled: bool, idle_minutes: u32, workspace: &Path) -> Config {
let mut config = Config::default();
config.workspace_dir = workspace.to_path_buf();
config.heartbeat.goal_continuation_enabled = enabled;
config.heartbeat.goal_idle_minutes = idle_minutes;
config
}
#[tokio::test]
async fn tick_is_noop_when_disabled() {
let tmp = tempfile::tempdir().unwrap();
// Active, long-idle goal present — but the feature is off.
store::set(tmp.path(), "t", "obj", None).await.unwrap();
let config = config_with(false, 1, tmp.path());
// Must not panic / must return promptly; nothing to assert beyond no-op.
run_continuation_tick(&config).await;
// Goal untouched (not suppressed) since the feature is disabled.
let g = store::get(tmp.path(), "t").await.unwrap().unwrap();
assert!(!g.continuation_suppressed);
}
#[tokio::test]
async fn candidate_filter_respects_status_idle_and_suppression() {
// This exercises the selection predicate without dispatching a turn by
// keeping the feature enabled but pointing at a fresh (non-idle) goal.
let tmp = tempfile::tempdir().unwrap();
// Fresh goal: updated_at is "now", so with a 60-min idle window it is
// NOT a candidate and the tick stays a no-op (no agent build attempted).
store::set(tmp.path(), "fresh", "obj", None).await.unwrap();
let config = config_with(true, 60, tmp.path());
run_continuation_tick(&config).await;
let g = store::get(tmp.path(), "fresh").await.unwrap().unwrap();
assert!(
!g.continuation_suppressed,
"fresh goal must not be continued/suppressed"
);
}
#[test]
fn continuation_prompt_names_objective_and_guards() {
let p = continuation_prompt("ship the release");
assert!(p.contains("ship the release"));
assert!(p.contains("goal_complete"));
assert!(p.contains("not auto-approved"));
}
}
+33
View File
@@ -0,0 +1,33 @@
//! `thread_goals` — the agent's single, thread-scoped goal.
//!
//! A **thread goal** is a durable "completion contract" the agent keeps
//! pursuing across turns, interrupts, resumes, and budget boundaries — modelled
//! on OpenAI Codex's `/goal`. It is deliberately distinct from the two existing
//! "goals" concepts:
//!
//! - [`memory_goals`](crate::openhuman::memory_goals) — a *global*, long-term
//! list of the user's durable goals (`MEMORY_GOALS.md`).
//! - the per-thread kanban [task board](crate::openhuman::agent::task_board) —
//! a list of work cards.
//!
//! There is **exactly one** thread goal per thread, with a small lifecycle
//! (active / paused / budget_limited / complete), an optional token budget, and
//! support for autonomous idle continuation.
//!
//! Persistence is per-thread file-JSON under
//! `<workspace>/thread_goals/<hex(thread_id)>.json` (see [`store`]). Two writers
//! may set it when a chat begins: the orchestrator (authoritative, via
//! `goal_set`) and the context-gathering path (proposes only if absent, via
//! [`store::set_if_absent`]).
pub mod continuation;
pub mod ops;
pub mod runtime;
mod schemas;
pub mod store;
pub mod tools;
pub mod types;
pub use schemas::{all_thread_goals_controller_schemas, all_thread_goals_registered_controllers};
pub use tools::{GoalCompleteTool, GoalGetTool, GoalSetTool};
pub use types::{ThreadGoal, ThreadGoalStatus};
+165
View File
@@ -0,0 +1,165 @@
//! Business logic for the thread-goal domain — thin handlers over
//! [`super::store`], each returning an [`RpcOutcome`] so the RPC layer and CLI
//! share a uniform shape with logs. Mutating ops emit `thread/goal/updated` (or
//! `thread/goal/cleared`) domain events so the desktop UI can live-update.
use std::path::Path;
use serde::Serialize;
use super::store;
use super::types::ThreadGoal;
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::rpc::RpcOutcome;
/// Envelope returned by reads/clears where the goal may be absent.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GoalEnvelope {
/// The current goal, or `null` when the thread has none.
pub goal: Option<ThreadGoal>,
}
/// Result of a clear operation.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClearResult {
/// Whether a goal existed and was removed.
pub removed: bool,
}
/// Publish a `thread/goal/updated` event for live UI sync (best-effort).
fn emit_updated(goal: &ThreadGoal) {
publish_global(DomainEvent::ThreadGoalUpdated {
thread_id: goal.thread_id.clone(),
goal_id: goal.goal_id.clone(),
status: goal.status.as_str().to_string(),
});
}
/// Get the goal for a thread (or `null`).
pub async fn get(
workspace_dir: &Path,
thread_id: &str,
) -> Result<RpcOutcome<GoalEnvelope>, String> {
log::debug!("[thread_goals] rpc=get thread_id={thread_id}");
let goal = store::get(workspace_dir, thread_id).await?;
Ok(RpcOutcome::new(GoalEnvelope { goal }, vec![]))
}
/// Create or replace the goal for a thread.
pub async fn set(
workspace_dir: &Path,
thread_id: &str,
objective: &str,
token_budget: Option<u64>,
) -> Result<RpcOutcome<GoalEnvelope>, String> {
log::debug!("[thread_goals] rpc=set thread_id={thread_id}");
let goal = store::set(workspace_dir, thread_id, objective, token_budget).await?;
emit_updated(&goal);
Ok(RpcOutcome::single_log(
GoalEnvelope {
goal: Some(goal.clone()),
},
format!(
"set thread goal {} ({})",
goal.goal_id,
goal.status.as_str()
),
))
}
/// Mark the goal complete.
pub async fn complete(
workspace_dir: &Path,
thread_id: &str,
) -> Result<RpcOutcome<GoalEnvelope>, String> {
log::debug!("[thread_goals] rpc=complete thread_id={thread_id}");
let goal = store::complete(workspace_dir, thread_id).await?;
emit_updated(&goal);
Ok(RpcOutcome::single_log(
GoalEnvelope {
goal: Some(goal.clone()),
},
format!("completed thread goal {}", goal.goal_id),
))
}
/// Pause an active goal.
pub async fn pause(
workspace_dir: &Path,
thread_id: &str,
) -> Result<RpcOutcome<GoalEnvelope>, String> {
log::debug!("[thread_goals] rpc=pause thread_id={thread_id}");
let goal = store::pause(workspace_dir, thread_id).await?;
emit_updated(&goal);
Ok(RpcOutcome::single_log(
GoalEnvelope {
goal: Some(goal.clone()),
},
format!("paused thread goal {}", goal.goal_id),
))
}
/// Resume a paused goal.
pub async fn resume(
workspace_dir: &Path,
thread_id: &str,
) -> Result<RpcOutcome<GoalEnvelope>, String> {
log::debug!("[thread_goals] rpc=resume thread_id={thread_id}");
let goal = store::resume(workspace_dir, thread_id).await?;
emit_updated(&goal);
Ok(RpcOutcome::single_log(
GoalEnvelope {
goal: Some(goal.clone()),
},
format!("resumed thread goal {}", goal.goal_id),
))
}
/// Clear (delete) the goal for a thread.
pub async fn clear(
workspace_dir: &Path,
thread_id: &str,
) -> Result<RpcOutcome<ClearResult>, String> {
log::debug!("[thread_goals] rpc=clear thread_id={thread_id}");
let removed = store::clear(workspace_dir, thread_id).await?;
if removed {
publish_global(DomainEvent::ThreadGoalCleared {
thread_id: thread_id.to_string(),
});
}
Ok(RpcOutcome::single_log(
ClearResult { removed },
format!("cleared thread goal (removed={removed})"),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn set_get_complete_clear_flow() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
// Empty to start.
let got = get(dir, "t").await.unwrap();
assert!(got.value.goal.is_none());
// Set.
let set_out = set(dir, "t", "ship it", Some(1000)).await.unwrap();
let goal = set_out.value.goal.unwrap();
assert_eq!(goal.objective, "ship it");
// Complete.
let done = complete(dir, "t").await.unwrap();
assert_eq!(done.value.goal.unwrap().status.as_str(), "complete");
// Clear.
let cleared = clear(dir, "t").await.unwrap();
assert!(cleared.value.removed);
assert!(get(dir, "t").await.unwrap().value.goal.is_none());
}
}
+424
View File
@@ -0,0 +1,424 @@
//! Harness-level runtime for the thread goal: per-turn context injection,
//! token-budget accounting, and the mid-turn budget stop hook.
//!
//! These are the pieces that make a stored goal actually steer the agent
//! (Codex parity):
//!
//! - [`active_goal_context_block`] renders a compact `[active_goal]` block that
//! the turn loop prepends to the user message **fresh each turn** (never the
//! cached system-prompt prefix), so the objective stays visible and the model
//! sees live budget/status.
//! - [`account_turn_against_goal`] folds a completed turn's token + time usage
//! into the active goal, flipping it to `budget_limited` when the cap is
//! crossed.
//! - [`GoalBudgetStopHook`] hard-stops an in-flight turn the moment an *active*
//! goal's running usage would exceed its budget, so an autonomous run can't
//! blow past the ceiling between accounting points.
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use super::store;
use super::types::{ThreadGoal, ThreadGoalStatus};
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::agent::stop_hooks::{StopDecision, StopHook, TurnState};
use crate::openhuman::inference::provider::thread_context::current_thread_id;
/// Load the goal for the ambient chat thread, if any. Returns `None` outside a
/// thread scope (CLI / background paths) or when the thread has no goal.
pub async fn load_for_current_thread(workspace_dir: &Path) -> Option<ThreadGoal> {
let thread_id = current_thread_id()?;
match store::get(workspace_dir, &thread_id).await {
Ok(goal) => goal,
Err(e) => {
tracing::debug!(thread_id = %thread_id, error = %e, "[thread_goals] load_for_current_thread failed");
None
}
}
}
/// Reactivate a paused goal for the ambient thread (thread-resume semantics).
/// Returns the updated goal, or `None` outside a thread scope / when absent.
/// Best-effort: a failure is logged and surfaced as `Ok(None)`-style `None`.
pub async fn resume_for_current_thread(workspace_dir: &Path) -> Option<Option<ThreadGoal>> {
let thread_id = current_thread_id()?;
match store::resume(workspace_dir, &thread_id).await {
Ok(goal) => {
if goal.status.is_active() {
publish_global(DomainEvent::ThreadGoalUpdated {
thread_id: goal.thread_id.clone(),
goal_id: goal.goal_id.clone(),
status: goal.status.as_str().to_string(),
});
}
Some(Some(goal))
}
Err(e) => {
tracing::debug!(thread_id = %thread_id, error = %e, "[thread_goals] resume_for_current_thread failed");
None
}
}
}
/// Pause the active goal for the ambient thread (interrupt/abort semantics).
/// Best-effort; safe to call when there is no goal or no thread scope.
pub async fn pause_for_current_thread(workspace_dir: &Path) {
let Some(thread_id) = current_thread_id() else {
return;
};
match store::pause(workspace_dir, &thread_id).await {
Ok(goal) => {
if matches!(goal.status, ThreadGoalStatus::Paused) {
publish_global(DomainEvent::ThreadGoalUpdated {
thread_id: goal.thread_id.clone(),
goal_id: goal.goal_id.clone(),
status: goal.status.as_str().to_string(),
});
}
}
Err(e) => {
tracing::debug!(thread_id = %thread_id, error = %e, "[thread_goals] pause_for_current_thread failed");
}
}
}
/// Render the per-turn `[active_goal]` context block for `goal`, or `None` when
/// the goal is in a state that needs no steering text.
///
/// The block is intentionally tiny and source-attributed so it reads as harness
/// state, not user instruction.
pub fn active_goal_context_block(goal: &ThreadGoal) -> Option<String> {
let directive = match goal.status {
ThreadGoalStatus::Active => {
"Keep working toward this goal. When evidence confirms it's done, call \
goal_complete; if the objective has changed, update it with goal_set."
}
ThreadGoalStatus::BudgetLimited => {
"This goal has reached its token budget. Stop substantive work: summarise \
progress and blockers, and name the next useful step. Do not continue \
until the user raises the budget or clears the goal."
}
// A paused goal isn't being worked right now; a completed goal needs no
// steering. Surfacing them would only add noise to the turn.
ThreadGoalStatus::Paused | ThreadGoalStatus::Complete => return None,
};
let budget = match (goal.token_budget, goal.budget_remaining()) {
(Some(b), Some(rem)) => format!(
"\nbudget: {} used / {b} ({rem} remaining)",
goal.tokens_used
),
_ => String::new(),
};
Some(format!(
"[active_goal]\nstatus: {}\nobjective: {}{}\n{}\n[/active_goal]\n\n",
goal.status.as_str(),
goal.objective,
budget,
directive
))
}
/// The per-turn token total used for budget accounting (prompt + completion).
fn turn_tokens(input: u64, output: u64) -> u64 {
input.saturating_add(output)
}
/// Whether the current turn is an autonomous goal-continuation (vs. a
/// user-initiated turn). Used so a continuation doesn't clear its own one-shot
/// suppression flag.
fn is_goal_continuation_turn() -> bool {
matches!(
crate::openhuman::agent::turn_origin::current(),
Some(
crate::openhuman::agent::turn_origin::AgentTurnOrigin::TrustedAutomation {
source:
crate::openhuman::agent::turn_origin::TrustedAutomationSource::GoalContinuation,
..
}
)
)
}
/// Account a finished turn's usage against the ambient thread's goal.
///
/// Only **active** goals are charged (a paused/complete/budget-limited goal
/// doesn't accrue usage from incidental chat). Best-effort: a failure is logged
/// and swallowed so accounting never fails a user turn. Emits
/// `ThreadGoalUpdated` when the status changes (e.g. → `budget_limited`) so the
/// UI chip refreshes.
pub async fn account_turn_against_goal(workspace_dir: &Path, input: u64, output: u64, secs: u64) {
let Some(thread_id) = current_thread_id() else {
return;
};
let goal = match store::get(workspace_dir, &thread_id).await {
Ok(Some(g)) => g,
Ok(None) => return,
Err(e) => {
tracing::debug!(thread_id = %thread_id, error = %e, "[thread_goals] account get failed");
return;
}
};
if !goal.status.is_active() {
return;
}
// Reset the one-shot continuation suppression on user-initiated activity: a
// real turn in this thread means the user re-engaged, so a future idle
// period may auto-continue again. The continuation turn itself runs under a
// GoalContinuation origin and must NOT clear its own suppression.
if goal.continuation_suppressed && !is_goal_continuation_turn() {
if let Err(e) = store::set_continuation_suppressed(workspace_dir, &thread_id, false).await {
tracing::debug!(
thread_id = %thread_id,
error = %e,
"[thread_goals] failed to clear continuation suppression"
);
}
}
let delta = turn_tokens(input, output);
if delta == 0 && secs == 0 {
return;
}
let prev_status = goal.status;
match store::account_usage(workspace_dir, &thread_id, &goal.goal_id, delta, secs).await {
Ok(Some(updated)) => {
tracing::debug!(
thread_id = %thread_id,
goal_id = %updated.goal_id,
tokens_used = updated.tokens_used,
status = updated.status.as_str(),
"[thread_goals] accounted turn usage (+{delta} tok, +{secs}s)"
);
if updated.status != prev_status {
publish_global(DomainEvent::ThreadGoalUpdated {
thread_id: updated.thread_id.clone(),
goal_id: updated.goal_id.clone(),
status: updated.status.as_str().to_string(),
});
}
}
Ok(None) => {}
Err(e) => {
tracing::debug!(thread_id = %thread_id, error = %e, "[thread_goals] account_usage failed");
}
}
}
/// Mid-turn stop hook that halts an in-flight turn once an **active** goal's
/// running usage (already-accounted tokens from prior turns + this turn's
/// tokens so far) would meet or exceed its budget.
///
/// It only fires for goals that are still `Active` with a configured budget —
/// once a goal is `budget_limited`/`paused`/`complete` the user can still chat
/// freely (the injected context steers the model to summarise), so we never
/// hard-stop a user-present turn that isn't actively burning a live budget.
#[derive(Debug, Clone)]
pub struct GoalBudgetStopHook {
workspace_dir: PathBuf,
thread_id: String,
/// The goal version this hook was armed for. Stops enforcing if the goal is
/// replaced mid-turn (a new objective mints a new id).
goal_id: String,
budget: u64,
}
impl GoalBudgetStopHook {
/// Build a hook for `goal` if it's active and has a budget; `None` otherwise.
pub fn for_goal(workspace_dir: &Path, goal: &ThreadGoal) -> Option<Self> {
if !goal.status.is_active() {
return None;
}
let budget = goal.token_budget?;
Some(Self {
workspace_dir: workspace_dir.to_path_buf(),
thread_id: goal.thread_id.clone(),
goal_id: goal.goal_id.clone(),
budget,
})
}
}
#[async_trait]
impl StopHook for GoalBudgetStopHook {
fn name(&self) -> &str {
"thread_goal_budget"
}
async fn check(&self, ctx: &TurnState<'_>) -> StopDecision {
// Read the goal's already-accounted usage (prior turns). If it's gone,
// replaced, or no longer active, stop enforcing.
let goal = match store::get(&self.workspace_dir, &self.thread_id).await {
Ok(Some(g)) => g,
_ => return StopDecision::Continue,
};
if goal.goal_id != self.goal_id || !goal.status.is_active() {
return StopDecision::Continue;
}
let projected = goal
.tokens_used
.saturating_add(turn_tokens(ctx.cost.input_tokens, ctx.cost.output_tokens));
if projected >= self.budget {
StopDecision::Stop {
reason: format!(
"thread goal budget reached: {projected} tokens >= {} budget — stopping to summarise progress",
self.budget
),
}
} else {
StopDecision::Continue
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::agent::cost::TurnCost;
use crate::openhuman::inference::provider::UsageInfo;
fn cost_with_tokens(input: u64, output: u64) -> TurnCost {
let mut tc = TurnCost::new();
tc.add_call(
"agentic-v1",
&UsageInfo {
input_tokens: input,
output_tokens: output,
..Default::default()
},
);
tc
}
#[test]
fn active_block_includes_objective_and_budget() {
let goal = ThreadGoal {
thread_id: "t".into(),
goal_id: "g".into(),
objective: "ship the feature".into(),
status: ThreadGoalStatus::Active,
token_budget: Some(1000),
tokens_used: 250,
time_used_seconds: 0,
created_at_ms: 0,
updated_at_ms: 0,
continuation_suppressed: false,
};
let block = active_goal_context_block(&goal).unwrap();
assert!(block.contains("[active_goal]"));
assert!(block.contains("ship the feature"));
assert!(block.contains("250 used / 1000"));
assert!(block.contains("goal_complete"));
}
#[test]
fn budget_limited_block_steers_to_summarise() {
let goal = ThreadGoal {
thread_id: "t".into(),
goal_id: "g".into(),
objective: "obj".into(),
status: ThreadGoalStatus::BudgetLimited,
token_budget: Some(100),
tokens_used: 100,
time_used_seconds: 0,
created_at_ms: 0,
updated_at_ms: 0,
continuation_suppressed: false,
};
let block = active_goal_context_block(&goal).unwrap();
assert!(block.contains("reached its token budget"));
}
#[test]
fn paused_and_complete_render_no_block() {
let mut goal = ThreadGoal {
thread_id: "t".into(),
goal_id: "g".into(),
objective: "obj".into(),
status: ThreadGoalStatus::Paused,
token_budget: None,
tokens_used: 0,
time_used_seconds: 0,
created_at_ms: 0,
updated_at_ms: 0,
continuation_suppressed: false,
};
assert!(active_goal_context_block(&goal).is_none());
goal.status = ThreadGoalStatus::Complete;
assert!(active_goal_context_block(&goal).is_none());
}
#[tokio::test]
async fn account_turn_charges_active_goal_and_trips_budget() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
crate::openhuman::inference::provider::thread_context::with_thread_id("t-acct", async {
store::set(&dir, "t-acct", "obj", Some(100)).await.unwrap();
account_turn_against_goal(&dir, 80, 40, 3).await; // 120 >= 100
let g = store::get(&dir, "t-acct").await.unwrap().unwrap();
assert_eq!(g.tokens_used, 120);
assert_eq!(g.status, ThreadGoalStatus::BudgetLimited);
})
.await;
}
#[tokio::test]
async fn account_turn_skips_non_active_goal() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
crate::openhuman::inference::provider::thread_context::with_thread_id("t-paused", async {
store::set(&dir, "t-paused", "obj", Some(1000))
.await
.unwrap();
store::pause(&dir, "t-paused").await.unwrap();
account_turn_against_goal(&dir, 500, 500, 1).await;
let g = store::get(&dir, "t-paused").await.unwrap().unwrap();
assert_eq!(g.tokens_used, 0, "paused goal must not accrue usage");
})
.await;
}
#[tokio::test]
async fn budget_stop_hook_fires_on_crossing() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let goal = store::set(&dir, "t-hook", "obj", Some(1000)).await.unwrap();
// 600 already used in a prior turn.
store::account_usage(&dir, "t-hook", &goal.goal_id, 600, 0)
.await
.unwrap();
let goal = store::get(&dir, "t-hook").await.unwrap().unwrap();
let hook = GoalBudgetStopHook::for_goal(&dir, &goal).expect("budgeted active goal");
// This turn so far: 300 in + 200 out = 500. 600 + 500 = 1100 >= 1000.
let cost = cost_with_tokens(300, 200);
let ctx = TurnState {
iteration: 2,
max_iterations: 10,
cost: &cost,
model: "agentic-v1",
};
assert!(matches!(hook.check(&ctx).await, StopDecision::Stop { .. }));
// Under the cap continues.
let small = cost_with_tokens(100, 100); // 600 + 200 = 800 < 1000
let ctx2 = TurnState {
iteration: 1,
max_iterations: 10,
cost: &small,
model: "agentic-v1",
};
assert!(matches!(hook.check(&ctx2).await, StopDecision::Continue));
}
#[tokio::test]
async fn no_hook_without_budget_or_when_inactive() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let no_budget = store::set(&dir, "a", "obj", None).await.unwrap();
assert!(GoalBudgetStopHook::for_goal(&dir, &no_budget).is_none());
store::set(&dir, "b", "obj", Some(100)).await.unwrap();
store::pause(&dir, "b").await.unwrap();
let paused = store::get(&dir, "b").await.unwrap().unwrap();
assert!(GoalBudgetStopHook::for_goal(&dir, &paused).is_none());
}
}
+248
View File
@@ -0,0 +1,248 @@
//! Controller schemas + JSON-RPC handlers for the `thread_goals` namespace.
//!
//! Methods are exposed as `openhuman.thread_goals_<function>`:
//! `get`, `set`, `complete`, `pause`, `resume`, `clear`. Handlers load the
//! active config (for `workspace_dir`), delegate to [`super::ops`], and
//! serialise the [`RpcOutcome`] into the CLI-compatible JSON shape.
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use super::ops;
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;
use crate::rpc::RpcOutcome;
/// All `thread_goals` controller schemas (advertised to CLI + RPC consumers).
pub fn all_thread_goals_controller_schemas() -> Vec<ControllerSchema> {
FUNCTIONS.iter().map(|f| schemas(f)).collect()
}
/// Registered `thread_goals` controllers (schema + handler pairs).
pub fn all_thread_goals_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schemas("get"),
handler: handle_get,
},
RegisteredController {
schema: schemas("set"),
handler: handle_set,
},
RegisteredController {
schema: schemas("complete"),
handler: handle_complete,
},
RegisteredController {
schema: schemas("pause"),
handler: handle_pause,
},
RegisteredController {
schema: schemas("resume"),
handler: handle_resume,
},
RegisteredController {
schema: schemas("clear"),
handler: handle_clear,
},
]
}
const FUNCTIONS: &[&str] = &["get", "set", "complete", "pause", "resume", "clear"];
fn thread_id_input() -> FieldSchema {
FieldSchema {
name: "thread_id",
ty: TypeSchema::String,
comment: "The conversation thread the goal belongs to.",
required: true,
}
}
fn goal_output() -> FieldSchema {
FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "{ goal } — the current thread goal (or null when absent).",
required: true,
}
}
/// Schema definitions for every `thread_goals` function.
fn schemas(function: &str) -> ControllerSchema {
match function {
"get" => ControllerSchema {
namespace: "thread_goals",
function: "get",
description: "Get the thread-level goal for a thread (or null).",
inputs: vec![thread_id_input()],
outputs: vec![goal_output()],
},
"set" => ControllerSchema {
namespace: "thread_goals",
function: "set",
description: "Create or replace the thread-level goal. Changing the objective \
mints a new goal id and resets usage counters.",
inputs: vec![
thread_id_input(),
FieldSchema {
name: "objective",
ty: TypeSchema::String,
comment: "The durable objective the agent should keep pursuing.",
required: true,
},
FieldSchema {
name: "token_budget",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment:
"Optional token ceiling; exceeding it flips the goal to budget_limited.",
required: false,
},
],
outputs: vec![goal_output()],
},
"complete" => ControllerSchema {
namespace: "thread_goals",
function: "complete",
description: "Mark the thread goal complete.",
inputs: vec![thread_id_input()],
outputs: vec![goal_output()],
},
"pause" => ControllerSchema {
namespace: "thread_goals",
function: "pause",
description: "Pause an active thread goal.",
inputs: vec![thread_id_input()],
outputs: vec![goal_output()],
},
"resume" => ControllerSchema {
namespace: "thread_goals",
function: "resume",
description: "Resume a paused thread goal.",
inputs: vec![thread_id_input()],
outputs: vec![goal_output()],
},
"clear" => ControllerSchema {
namespace: "thread_goals",
function: "clear",
description: "Clear (delete) the thread goal.",
inputs: vec![thread_id_input()],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "{ removed } — whether a goal existed and was removed.",
required: true,
}],
},
other => panic!("unknown thread_goals function: {other}"),
}
}
// ── Handlers ─────────────────────────────────────────────────────────────
fn handle_get(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<ThreadIdParams>(Value::Object(params))?;
to_json(ops::get(&config.workspace_dir, &req.thread_id).await?)
})
}
fn handle_set(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<SetParams>(Value::Object(params))?;
to_json(
ops::set(
&config.workspace_dir,
&req.thread_id,
&req.objective,
req.token_budget,
)
.await?,
)
})
}
fn handle_complete(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<ThreadIdParams>(Value::Object(params))?;
to_json(ops::complete(&config.workspace_dir, &req.thread_id).await?)
})
}
fn handle_pause(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<ThreadIdParams>(Value::Object(params))?;
to_json(ops::pause(&config.workspace_dir, &req.thread_id).await?)
})
}
fn handle_resume(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<ThreadIdParams>(Value::Object(params))?;
to_json(ops::resume(&config.workspace_dir, &req.thread_id).await?)
})
}
fn handle_clear(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<ThreadIdParams>(Value::Object(params))?;
to_json(ops::clear(&config.workspace_dir, &req.thread_id).await?)
})
}
// ── Param structs + helpers ──────────────────────────────────────────────
#[derive(serde::Deserialize)]
struct ThreadIdParams {
thread_id: String,
}
#[derive(serde::Deserialize)]
struct SetParams {
thread_id: String,
objective: String,
#[serde(default)]
token_budget: Option<u64>,
}
fn parse_value<T: DeserializeOwned>(v: Value) -> Result<T, String> {
serde_json::from_value(v).map_err(|e| format!("invalid params: {e}"))
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registers_all_controllers() {
let controllers = all_thread_goals_registered_controllers();
assert_eq!(controllers.len(), FUNCTIONS.len());
let methods: Vec<String> = controllers
.iter()
.map(|c| format!("{}.{}", c.schema.namespace, c.schema.function))
.collect();
for f in FUNCTIONS {
let expected = format!("thread_goals.{f}");
assert!(methods.contains(&expected), "missing {expected}");
}
}
#[test]
fn schemas_and_controllers_stay_in_sync() {
assert_eq!(
all_thread_goals_controller_schemas().len(),
all_thread_goals_registered_controllers().len()
);
}
}
+617
View File
@@ -0,0 +1,617 @@
//! Persistence for the thread-level goal.
//!
//! Each thread's goal lives in a single JSON file at
//! `<workspace>/thread_goals/<hex(thread_id)>.json` — the same per-thread
//! file-JSON pattern as the kanban task board
//! ([`crate::openhuman::agent::task_board`]). There is at most one goal per
//! thread.
//!
//! All mutations go through a load → mutate → atomic-persist sequence,
//! serialised by a process-wide mutex so concurrent writers (agent tools, RPC,
//! the heartbeat continuation runtime, and post-turn accounting) can't clobber
//! each other.
use std::fs;
use std::io::{Read as _, Write as _};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use chrono::Utc;
use tokio::sync::Mutex;
use super::types::{ThreadGoal, ThreadGoalStatus};
const THREAD_GOALS_DIR: &str = "thread_goals";
const THREAD_GOALS_EXTENSION: &str = "json";
/// Serialises load→mutate→save sequences across all callers.
fn goal_mutation_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
/// Current unix time in milliseconds.
fn now_ms() -> u64 {
Utc::now().timestamp_millis().max(0) as u64
}
fn validate_thread_id(thread_id: &str) -> Result<String, String> {
let trimmed = thread_id.trim();
if trimmed.is_empty() {
return Err("invalid thread goal thread_id: empty or whitespace".to_string());
}
Ok(trimmed.to_string())
}
/// On-disk store rooted at the user's `workspace_dir`.
#[derive(Debug, Clone)]
pub struct ThreadGoalStore {
workspace_dir: PathBuf,
}
impl ThreadGoalStore {
pub fn new(workspace_dir: PathBuf) -> Self {
Self { workspace_dir }
}
fn ensure_dir(&self) -> Result<PathBuf, String> {
let dir = self.workspace_dir.join(THREAD_GOALS_DIR);
fs::create_dir_all(&dir)
.map_err(|e| format!("create thread goals dir {}: {e}", dir.display()))?;
Ok(dir)
}
fn goal_path(&self, thread_id: &str) -> Result<PathBuf, String> {
let thread_id = validate_thread_id(thread_id)?;
Ok(self.workspace_dir.join(THREAD_GOALS_DIR).join(format!(
"{}.{}",
hex::encode(thread_id.as_bytes()),
THREAD_GOALS_EXTENSION
)))
}
/// Load the goal for `thread_id`, or `None` if the thread has no goal yet.
pub fn get(&self, thread_id: &str) -> Result<Option<ThreadGoal>, String> {
let thread_id = validate_thread_id(thread_id)?;
tracing::debug!(thread_id = %thread_id, "[thread_goals] get entry");
let path = self.goal_path(&thread_id)?;
if !path.exists() {
tracing::debug!(thread_id = %thread_id, "[thread_goals] get not_found");
return Ok(None);
}
let mut buf = String::new();
fs::File::open(&path)
.map_err(|e| format!("open thread goal {}: {e}", path.display()))?
.read_to_string(&mut buf)
.map_err(|e| format!("read thread goal {}: {e}", path.display()))?;
let goal = serde_json::from_str::<ThreadGoal>(&buf).map_err(|e| {
format!(
"parse thread goal {} for thread '{}': {e}",
path.display(),
thread_id
)
})?;
tracing::debug!(
thread_id = %thread_id,
goal_id = %goal.goal_id,
status = goal.status.as_str(),
"[thread_goals] get ok"
);
Ok(Some(goal))
}
/// Persist `goal` atomically (temp file + fsync + rename), mirroring the
/// task board's durable write path.
fn put(&self, goal: &ThreadGoal) -> Result<(), String> {
let thread_id = validate_thread_id(&goal.thread_id)?;
let dir = self.ensure_dir()?;
let path = self.goal_path(&thread_id)?;
let mut tmp = tempfile::NamedTempFile::new_in(&dir)
.map_err(|e| format!("create thread goal tempfile in {}: {e}", dir.display()))?;
let bytes =
serde_json::to_vec_pretty(goal).map_err(|e| format!("serialize thread goal: {e}"))?;
tmp.write_all(&bytes)
.map_err(|e| format!("write thread goal tempfile: {e}"))?;
tmp.as_file()
.sync_all()
.map_err(|e| format!("fsync thread goal tempfile: {e}"))?;
tmp.persist(&path)
.map_err(|e| format!("persist thread goal {}: {e}", path.display()))?;
tracing::debug!(
thread_id = %thread_id,
goal_id = %goal.goal_id,
status = goal.status.as_str(),
tokens_used = goal.tokens_used,
"[thread_goals] put ok"
);
Ok(())
}
/// Delete the goal for `thread_id`. Returns whether a file was removed.
fn delete_file(&self, thread_id: &str) -> Result<bool, String> {
let path = self.goal_path(thread_id)?;
if !path.exists() {
return Ok(false);
}
fs::remove_file(&path)
.map_err(|e| format!("delete thread goal {}: {e}", path.display()))?;
Ok(true)
}
}
// ── Async mutation API (mutex-guarded load→mutate→save) ──────────────────────
/// Create or replace the goal for `thread_id`.
///
/// Codex semantics: if `objective` **differs** from the current one, a fresh
/// `goal_id` is minted and the usage counters reset (status → `Active`). If the
/// objective is **unchanged**, counters and `goal_id` are preserved and only
/// the budget / `updated_at` are refreshed (status returns to `Active` so a
/// completed/paused goal can be explicitly re-opened by re-setting it).
pub async fn set(
workspace_dir: &Path,
thread_id: &str,
objective: &str,
token_budget: Option<u64>,
) -> Result<ThreadGoal, String> {
let objective = objective.trim();
if objective.is_empty() {
return Err("thread goal objective must not be empty".to_string());
}
let thread_id = validate_thread_id(thread_id)?;
let _guard = goal_mutation_lock().lock().await;
let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
let goal = compute_and_put_set(&store, &thread_id, objective, token_budget)?;
tracing::info!(
thread_id = %thread_id,
goal_id = %goal.goal_id,
"[thread_goals] set objective ({} chars), budget={:?}",
goal.objective.chars().count(),
goal.token_budget
);
Ok(goal)
}
/// Build + persist the goal for a `set`. The caller MUST hold
/// [`goal_mutation_lock`] so the read-modify-write is atomic.
fn compute_and_put_set(
store: &ThreadGoalStore,
thread_id: &str,
objective: &str,
token_budget: Option<u64>,
) -> Result<ThreadGoal, String> {
let now = now_ms();
let goal = match store.get(thread_id)? {
Some(mut existing) if existing.objective == objective => {
// Same objective → preserve counters/goal_id; refresh budget.
existing.token_budget = token_budget;
existing.continuation_suppressed = false;
existing.updated_at_ms = now;
// Re-open to Active, but don't un-limit a goal that is still over its
// (possibly updated) budget — counters are only reset by a *changed*
// objective, so a same-objective re-set must stay budget_limited.
existing.status = if existing.over_budget() {
ThreadGoalStatus::BudgetLimited
} else {
ThreadGoalStatus::Active
};
existing
}
existing => {
// New / changed objective → fresh goal_id, reset counters.
let created_at_ms = existing.as_ref().map(|g| g.created_at_ms).unwrap_or(now);
ThreadGoal {
thread_id: thread_id.to_string(),
goal_id: uuid::Uuid::new_v4().to_string(),
objective: objective.to_string(),
status: ThreadGoalStatus::Active,
token_budget,
tokens_used: 0,
time_used_seconds: 0,
created_at_ms,
updated_at_ms: now,
continuation_suppressed: false,
}
}
};
store.put(&goal)?;
Ok(goal)
}
/// Set the goal **only if the thread has none yet**. Returns `Some(goal)` when a
/// new goal was created, or `None` when a goal already existed (left untouched).
///
/// This backs the "scout proposes if empty, orchestrator authoritative"
/// precedence: the context-gathering path may bootstrap a goal on the first
/// turn but must never clobber a user/orchestrator-refined one. The check and
/// the write run under a single lock acquisition so a concurrent `set` /
/// `set_if_absent` can't slip into the gap and get clobbered.
pub async fn set_if_absent(
workspace_dir: &Path,
thread_id: &str,
objective: &str,
token_budget: Option<u64>,
) -> Result<Option<ThreadGoal>, String> {
let objective = objective.trim();
if objective.is_empty() {
return Err("thread goal objective must not be empty".to_string());
}
let thread_id = validate_thread_id(thread_id)?;
let _guard = goal_mutation_lock().lock().await;
let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
if store.get(&thread_id)?.is_some() {
tracing::debug!(thread_id = %thread_id, "[thread_goals] set_if_absent skipped (exists)");
return Ok(None);
}
let goal = compute_and_put_set(&store, &thread_id, objective, token_budget)?;
Ok(Some(goal))
}
/// Load the goal for `thread_id` (read-only), or `None`.
pub async fn get(workspace_dir: &Path, thread_id: &str) -> Result<Option<ThreadGoal>, String> {
let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
store.get(thread_id)
}
/// Load every persisted thread goal (read-only). Skips files that fail to parse
/// (logged) so one corrupt entry can't hide the rest. Used by the heartbeat
/// continuation runtime to find idle candidates.
pub async fn list_all(workspace_dir: &Path) -> Result<Vec<ThreadGoal>, String> {
let dir = workspace_dir.join(THREAD_GOALS_DIR);
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(format!("read thread goals dir {}: {e}", dir.display())),
};
let mut goals = Vec::new();
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| format!("iterate thread goals dir: {e}"))?
{
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some(THREAD_GOALS_EXTENSION) {
continue;
}
match tokio::fs::read_to_string(&path).await {
Ok(body) => match serde_json::from_str::<ThreadGoal>(&body) {
Ok(goal) => goals.push(goal),
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "[thread_goals] list_all skip parse error");
}
},
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "[thread_goals] list_all skip read error");
}
}
}
Ok(goals)
}
/// Delete the goal for `thread_id`. Returns whether one existed.
pub async fn clear(workspace_dir: &Path, thread_id: &str) -> Result<bool, String> {
let _guard = goal_mutation_lock().lock().await;
let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
let removed = store.delete_file(thread_id)?;
tracing::info!(thread_id = %thread_id, removed, "[thread_goals] clear");
Ok(removed)
}
/// Generic guarded mutator: load, apply `f`, persist. Returns the updated goal,
/// or an error if the thread has no goal.
async fn mutate<F>(workspace_dir: &Path, thread_id: &str, f: F) -> Result<ThreadGoal, String>
where
F: FnOnce(&mut ThreadGoal),
{
let thread_id = validate_thread_id(thread_id)?;
let _guard = goal_mutation_lock().lock().await;
let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
let mut goal = store
.get(&thread_id)?
.ok_or_else(|| format!("no thread goal for thread '{thread_id}'"))?;
f(&mut goal);
goal.updated_at_ms = now_ms();
store.put(&goal)?;
Ok(goal)
}
/// Mark the goal `Complete` (model-driven success).
pub async fn complete(workspace_dir: &Path, thread_id: &str) -> Result<ThreadGoal, String> {
let goal = mutate(workspace_dir, thread_id, |g| {
g.status = ThreadGoalStatus::Complete;
g.continuation_suppressed = true;
})
.await?;
tracing::info!(thread_id = %thread_id, goal_id = %goal.goal_id, "[thread_goals] complete");
Ok(goal)
}
/// Pause an `Active` goal (system-driven, on interrupt/abort). A no-op for goals
/// that aren't currently active.
pub async fn pause(workspace_dir: &Path, thread_id: &str) -> Result<ThreadGoal, String> {
let goal = mutate(workspace_dir, thread_id, |g| {
if g.status.is_active() {
g.status = ThreadGoalStatus::Paused;
}
})
.await?;
tracing::info!(thread_id = %thread_id, status = goal.status.as_str(), "[thread_goals] pause");
Ok(goal)
}
/// Resume a `Paused` goal (system-driven, on thread resume). A no-op for goals
/// that aren't paused (a completed/budget-limited goal is not reactivated).
pub async fn resume(workspace_dir: &Path, thread_id: &str) -> Result<ThreadGoal, String> {
let goal = mutate(workspace_dir, thread_id, |g| {
if matches!(g.status, ThreadGoalStatus::Paused) {
g.status = ThreadGoalStatus::Active;
g.continuation_suppressed = false;
}
})
.await?;
tracing::info!(thread_id = %thread_id, status = goal.status.as_str(), "[thread_goals] resume");
Ok(goal)
}
/// Set or clear the `continuation_suppressed` flag (idle-continuation guard).
pub async fn set_continuation_suppressed(
workspace_dir: &Path,
thread_id: &str,
suppressed: bool,
) -> Result<ThreadGoal, String> {
mutate(workspace_dir, thread_id, |g| {
g.continuation_suppressed = suppressed;
})
.await
}
/// Set `continuation_suppressed` only when the thread's current goal still
/// matches `expected_goal_id` (compare-and-set). Returns the goal as it stands
/// after the (possibly skipped) write, or `None` when the thread has no goal.
///
/// Used by the continuation runtime so a goal that was completed or replaced
/// during the autonomous turn is never suppressed by the post-dispatch write.
pub async fn set_continuation_suppressed_if(
workspace_dir: &Path,
thread_id: &str,
expected_goal_id: &str,
suppressed: bool,
) -> Result<Option<ThreadGoal>, String> {
let thread_id = validate_thread_id(thread_id)?;
let _guard = goal_mutation_lock().lock().await;
let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
let Some(mut goal) = store.get(&thread_id)? else {
return Ok(None);
};
// Skip when the goal was replaced (goal_id mismatch), is no longer active
// (completed/paused/budget_limited during the turn — must not be re-touched),
// or is already in the requested state.
if goal.goal_id != expected_goal_id
|| !goal.status.is_active()
|| goal.continuation_suppressed == suppressed
{
return Ok(Some(goal));
}
goal.continuation_suppressed = suppressed;
goal.updated_at_ms = now_ms();
store.put(&goal)?;
Ok(Some(goal))
}
/// Account token + time usage against the goal, applying the budget constraint.
///
/// **Stale-write guard (Codex parity):** the delta is **silently ignored** when
/// `expected_goal_id` doesn't match the current goal — an in-flight accounting
/// call from a now-replaced goal must not corrupt the new one. Returns the goal
/// as it stands after the (possibly skipped) update, or `None` if there is no
/// goal for the thread.
pub async fn account_usage(
workspace_dir: &Path,
thread_id: &str,
expected_goal_id: &str,
token_delta: u64,
secs_delta: u64,
) -> Result<Option<ThreadGoal>, String> {
let thread_id = validate_thread_id(thread_id)?;
let _guard = goal_mutation_lock().lock().await;
let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
let Some(mut goal) = store.get(&thread_id)? else {
return Ok(None);
};
if goal.goal_id != expected_goal_id {
tracing::debug!(
thread_id = %thread_id,
expected = %expected_goal_id,
actual = %goal.goal_id,
"[thread_goals] account_usage ignored stale goal_id"
);
return Ok(Some(goal));
}
if token_delta == 0 && secs_delta == 0 {
return Ok(Some(goal));
}
goal.tokens_used = goal.tokens_used.saturating_add(token_delta);
goal.time_used_seconds = goal.time_used_seconds.saturating_add(secs_delta);
// Apply the budget cap: an active goal that crosses its budget becomes
// budget-limited. Completed/paused goals keep their status.
if goal.status.is_active() && goal.over_budget() {
goal.status = ThreadGoalStatus::BudgetLimited;
tracing::info!(
thread_id = %thread_id,
goal_id = %goal.goal_id,
tokens_used = goal.tokens_used,
budget = ?goal.token_budget,
"[thread_goals] budget reached → budget_limited"
);
}
goal.updated_at_ms = now_ms();
store.put(&goal)?;
Ok(Some(goal))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn set_get_clear_round_trip() {
let tmp = tempdir().unwrap();
let dir = tmp.path();
assert!(get(dir, "t1").await.unwrap().is_none());
let g = set(dir, "t1", "ship the feature", None).await.unwrap();
assert_eq!(g.objective, "ship the feature");
assert_eq!(g.status, ThreadGoalStatus::Active);
assert_eq!(g.tokens_used, 0);
let loaded = get(dir, "t1").await.unwrap().unwrap();
assert_eq!(loaded.goal_id, g.goal_id);
assert!(clear(dir, "t1").await.unwrap());
assert!(get(dir, "t1").await.unwrap().is_none());
assert!(!clear(dir, "t1").await.unwrap());
}
#[tokio::test]
async fn set_same_objective_preserves_goal_id_and_counters() {
let tmp = tempdir().unwrap();
let dir = tmp.path();
let g1 = set(dir, "t", "objective A", Some(100)).await.unwrap();
account_usage(dir, "t", &g1.goal_id, 30, 5)
.await
.unwrap()
.unwrap();
let g2 = set(dir, "t", "objective A", Some(200)).await.unwrap();
assert_eq!(g1.goal_id, g2.goal_id, "same objective keeps goal_id");
assert_eq!(g2.tokens_used, 30, "counters preserved");
assert_eq!(g2.token_budget, Some(200), "budget refreshed");
}
#[tokio::test]
async fn set_same_objective_stays_budget_limited_when_over_budget() {
let tmp = tempdir().unwrap();
let dir = tmp.path();
let g = set(dir, "t", "obj", Some(100)).await.unwrap();
// Burn past the budget → BudgetLimited.
let limited = account_usage(dir, "t", &g.goal_id, 120, 0)
.await
.unwrap()
.unwrap();
assert_eq!(limited.status, ThreadGoalStatus::BudgetLimited);
// Re-setting the SAME objective preserves counters, so it must NOT
// silently re-activate while still over budget.
let resed = set(dir, "t", "obj", Some(100)).await.unwrap();
assert_eq!(resed.tokens_used, 120, "same-objective preserves counters");
assert_eq!(
resed.status,
ThreadGoalStatus::BudgetLimited,
"still over budget → must stay budget_limited, not active"
);
// Raising the budget above usage re-opens it.
let raised = set(dir, "t", "obj", Some(1000)).await.unwrap();
assert_eq!(raised.status, ThreadGoalStatus::Active);
}
#[tokio::test]
async fn set_changed_objective_mints_new_goal_id_and_resets() {
let tmp = tempdir().unwrap();
let dir = tmp.path();
let g1 = set(dir, "t", "objective A", None).await.unwrap();
account_usage(dir, "t", &g1.goal_id, 30, 5)
.await
.unwrap()
.unwrap();
let g2 = set(dir, "t", "objective B", None).await.unwrap();
assert_ne!(g1.goal_id, g2.goal_id);
assert_eq!(g2.tokens_used, 0, "counters reset on new objective");
assert_eq!(g2.created_at_ms, g1.created_at_ms, "created_at preserved");
}
#[tokio::test]
async fn set_if_absent_only_bootstraps_when_empty() {
let tmp = tempdir().unwrap();
let dir = tmp.path();
let created = set_if_absent(dir, "t", "scout goal", None).await.unwrap();
assert!(created.is_some());
// Second call must not clobber.
let again = set_if_absent(dir, "t", "different goal", None)
.await
.unwrap();
assert!(again.is_none());
assert_eq!(
get(dir, "t").await.unwrap().unwrap().objective,
"scout goal"
);
}
#[tokio::test]
async fn account_usage_ignores_stale_goal_id() {
let tmp = tempdir().unwrap();
let dir = tmp.path();
let g = set(dir, "t", "obj", None).await.unwrap();
// Stale id → ignored, counters unchanged.
let after = account_usage(dir, "t", "not-the-goal-id", 50, 1)
.await
.unwrap()
.unwrap();
assert_eq!(after.tokens_used, 0);
// Correct id → applied.
let after = account_usage(dir, "t", &g.goal_id, 50, 1)
.await
.unwrap()
.unwrap();
assert_eq!(after.tokens_used, 50);
}
#[tokio::test]
async fn account_usage_trips_budget_limited() {
let tmp = tempdir().unwrap();
let dir = tmp.path();
let g = set(dir, "t", "obj", Some(100)).await.unwrap();
let after = account_usage(dir, "t", &g.goal_id, 120, 2)
.await
.unwrap()
.unwrap();
assert_eq!(after.status, ThreadGoalStatus::BudgetLimited);
assert_eq!(after.budget_remaining(), Some(0));
}
#[tokio::test]
async fn pause_resume_complete_transitions() {
let tmp = tempdir().unwrap();
let dir = tmp.path();
set(dir, "t", "obj", None).await.unwrap();
assert_eq!(
pause(dir, "t").await.unwrap().status,
ThreadGoalStatus::Paused
);
assert_eq!(
resume(dir, "t").await.unwrap().status,
ThreadGoalStatus::Active
);
let done = complete(dir, "t").await.unwrap();
assert_eq!(done.status, ThreadGoalStatus::Complete);
// Resume does not reactivate a completed goal.
assert_eq!(
resume(dir, "t").await.unwrap().status,
ThreadGoalStatus::Complete
);
}
#[tokio::test]
async fn mutators_error_without_a_goal() {
let tmp = tempdir().unwrap();
let dir = tmp.path();
assert!(complete(dir, "missing").await.is_err());
assert!(pause(dir, "missing").await.is_err());
}
#[tokio::test]
async fn empty_objective_and_blank_thread_id_rejected() {
let tmp = tempdir().unwrap();
let dir = tmp.path();
assert!(set(dir, "t", " ", None).await.is_err());
assert!(set(dir, " ", "obj", None).await.is_err());
}
}
+275
View File
@@ -0,0 +1,275 @@
//! Agent-facing tools for the thread-level goal.
//!
//! These let the orchestrator (and any agent that allowlists them) read and
//! drive the current thread's goal. Ownership is **asymmetric** (Codex parity):
//! the model may create/replace the goal (`goal_set`), read it (`goal_get`), and
//! mark it complete (`goal_complete`). Pause / resume / budget-limit are
//! system-driven and have no model tool.
//!
//! The target thread is resolved from the ambient
//! [`current_thread_id`](crate::openhuman::inference::provider::thread_context::current_thread_id)
//! task-local set by the chat channel — tools never take a `thread_id` arg, so
//! the model can't address another thread's goal. Each tool is sandboxed to a
//! single `workspace_dir` captured at construction.
use std::path::PathBuf;
use async_trait::async_trait;
use serde_json::json;
use super::store;
use super::types::ThreadGoal;
use crate::openhuman::inference::provider::thread_context::current_thread_id;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
/// Render a goal as a compact, model-readable block.
fn render_goal(goal: &ThreadGoal) -> String {
let budget = match (goal.token_budget, goal.budget_remaining()) {
(Some(b), Some(rem)) => format!("{} used / {b} budget ({rem} left)", goal.tokens_used),
_ => format!("{} used / no budget", goal.tokens_used),
};
format!(
"[thread_goal]\nstatus: {}\nobjective: {}\ntokens: {budget}\n[/thread_goal]",
goal.status.as_str(),
goal.objective
)
}
/// Resolve the ambient thread id or return a uniform tool error.
fn require_thread_id() -> Result<String, ToolResult> {
current_thread_id().ok_or_else(|| {
ToolResult::error(
"thread goal tools require an active chat thread (no ambient thread_id in this context)",
)
})
}
/// `goal_get` — read the current thread goal.
pub struct GoalGetTool {
workspace_dir: PathBuf,
}
impl GoalGetTool {
pub fn new(workspace_dir: PathBuf) -> Self {
Self { workspace_dir }
}
}
#[async_trait]
impl Tool for GoalGetTool {
fn name(&self) -> &str {
"goal_get"
}
fn description(&self) -> &str {
"Read this thread's goal — the durable objective you're pursuing across \
turns — with its status (active/paused/budget_limited/complete) and token \
usage. Returns 'no goal set' when the thread has none."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({ "type": "object", "properties": {} })
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::ReadOnly
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
let thread_id = match require_thread_id() {
Ok(id) => id,
Err(e) => return Ok(e),
};
log::debug!("[thread_goals] tool=goal_get thread_id={thread_id}");
match store::get(&self.workspace_dir, &thread_id).await {
Ok(Some(goal)) => Ok(ToolResult::success(render_goal(&goal))),
Ok(None) => Ok(ToolResult::success("no goal set for this thread")),
Err(e) => Ok(ToolResult::error(e)),
}
}
}
/// `goal_set` — create or replace this thread's goal.
pub struct GoalSetTool {
workspace_dir: PathBuf,
}
impl GoalSetTool {
pub fn new(workspace_dir: PathBuf) -> Self {
Self { workspace_dir }
}
}
#[async_trait]
impl Tool for GoalSetTool {
fn name(&self) -> &str {
"goal_set"
}
fn description(&self) -> &str {
"Set (or replace) this thread's goal — the durable objective you should \
keep pursuing across turns until it's complete. Use at the start of a \
non-trivial request, or to refine the objective as it sharpens. Changing \
the objective resets usage counters. Optionally set a token_budget; when \
reached, the goal pauses with a progress summary."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"required": ["objective"],
"properties": {
"objective": {
"type": "string",
"description": "The durable objective — what 'done' looks like for this thread."
},
"token_budget": {
"type": "integer",
"minimum": 1,
"description": "Optional token ceiling for the goal. Omit for no limit."
}
}
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Write
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let thread_id = match require_thread_id() {
Ok(id) => id,
Err(e) => return Ok(e),
};
let Some(objective) = args.get("objective").and_then(|v| v.as_str()) else {
return Ok(ToolResult::error("Missing 'objective' parameter"));
};
let token_budget = args.get("token_budget").and_then(|v| v.as_u64());
log::debug!("[thread_goals] tool=goal_set thread_id={thread_id} budget={token_budget:?}");
match store::set(&self.workspace_dir, &thread_id, objective, token_budget).await {
Ok(goal) => {
// Emit the live-update event so the UI chip refreshes immediately.
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::ThreadGoalUpdated {
thread_id: goal.thread_id.clone(),
goal_id: goal.goal_id.clone(),
status: goal.status.as_str().to_string(),
},
);
Ok(ToolResult::success(format!(
"Goal set.\n{}",
render_goal(&goal)
)))
}
Err(e) => Ok(ToolResult::error(e)),
}
}
}
/// `goal_complete` — mark this thread's goal complete (evidence-backed success).
pub struct GoalCompleteTool {
workspace_dir: PathBuf,
}
impl GoalCompleteTool {
pub fn new(workspace_dir: PathBuf) -> Self {
Self { workspace_dir }
}
}
#[async_trait]
impl Tool for GoalCompleteTool {
fn name(&self) -> &str {
"goal_complete"
}
fn description(&self) -> &str {
"Mark this thread's goal complete. Only call this when concrete evidence \
confirms the objective is satisfied — completing stops autonomous \
continuation."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({ "type": "object", "properties": {} })
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Write
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
let thread_id = match require_thread_id() {
Ok(id) => id,
Err(e) => return Ok(e),
};
log::debug!("[thread_goals] tool=goal_complete thread_id={thread_id}");
match store::complete(&self.workspace_dir, &thread_id).await {
Ok(goal) => {
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::ThreadGoalUpdated {
thread_id: goal.thread_id.clone(),
goal_id: goal.goal_id.clone(),
status: goal.status.as_str().to_string(),
},
);
Ok(ToolResult::success(format!(
"Goal marked complete.\n{}",
render_goal(&goal)
)))
}
Err(e) => Ok(ToolResult::error(e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::inference::provider::thread_context::with_thread_id;
#[tokio::test]
async fn set_get_complete_via_tools_in_thread_scope() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
with_thread_id("thread-tools", async {
let set = GoalSetTool::new(dir.clone());
let res = set
.execute(json!({ "objective": "land the PR", "token_budget": 5000 }))
.await
.unwrap();
assert!(!res.is_error, "{}", res.text());
assert!(res.text().contains("land the PR"));
let get = GoalGetTool::new(dir.clone());
let res = get.execute(json!({})).await.unwrap();
assert!(res.text().contains("status: active"));
let done = GoalCompleteTool::new(dir.clone());
let res = done.execute(json!({})).await.unwrap();
assert!(res.text().contains("status: complete"));
})
.await;
}
#[tokio::test]
async fn tools_error_without_thread_scope() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let set = GoalSetTool::new(dir.clone());
let res = set.execute(json!({ "objective": "x" })).await.unwrap();
assert!(res.is_error);
assert!(res.text().contains("active chat thread"));
}
#[tokio::test]
async fn get_reports_absent_goal() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
with_thread_id("empty-thread", async {
let get = GoalGetTool::new(dir.clone());
let res = get.execute(json!({})).await.unwrap();
assert!(res.text().contains("no goal set"));
})
.await;
}
}
+151
View File
@@ -0,0 +1,151 @@
//! Domain types for the thread-level goal.
//!
//! A **thread goal** is a single, thread-scoped "completion contract" — a
//! durable objective the agent keeps pursuing across turns, interrupts,
//! resumes, and budget boundaries. It is distinct from the global
//! [`memory_goals`](crate::openhuman::memory_goals) list (long-term, workspace
//! wide) and from the per-thread kanban task board: there is **exactly one**
//! goal per thread, with a small lifecycle and optional token budget.
//!
//! The shape mirrors OpenAI Codex's `thread_goals` row, adapted to OpenHuman's
//! per-thread file-JSON persistence (see [`super::store`]).
use serde::{Deserialize, Serialize};
/// Lifecycle state of a thread goal.
///
/// Ownership is **asymmetric** (Codex parity): the model may create/replace a
/// goal and mark it `Complete`; `Paused` / `BudgetLimited` are system-driven
/// (interrupt/abort and accounting respectively), and clearing deletes the row
/// entirely rather than being a status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThreadGoalStatus {
/// The agent may make progress and (when idle) auto-continue.
Active,
/// Work is suspended (user interrupt/abort); the objective persists and is
/// reactivated on thread resume.
Paused,
/// The token budget has been reached; substantive work halts until the user
/// raises the budget or clears the goal.
BudgetLimited,
/// Evidence confirms the objective is satisfied.
Complete,
}
impl ThreadGoalStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Paused => "paused",
Self::BudgetLimited => "budget_limited",
Self::Complete => "complete",
}
}
/// Whether the goal is in a state where the agent should keep working it
/// (and idle auto-continuation may fire).
pub fn is_active(&self) -> bool {
matches!(self, Self::Active)
}
/// Whether the goal is in a terminal state for continuation purposes —
/// `Complete` or `BudgetLimited` never auto-continue.
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Complete | Self::BudgetLimited)
}
}
/// A single thread-scoped goal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThreadGoal {
/// The thread this goal belongs to (one goal per thread).
pub thread_id: String,
/// Version identifier, re-minted on **every objective replacement**. Stale
/// accounting writes that pass a non-matching `expected_goal_id` are
/// silently ignored — see [`super::store::account_usage`].
pub goal_id: String,
/// The durable objective, one or more sentences.
pub objective: String,
/// Lifecycle state.
pub status: ThreadGoalStatus,
/// Optional token ceiling. When set and `tokens_used >= token_budget`, the
/// goal transitions to [`ThreadGoalStatus::BudgetLimited`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_budget: Option<u64>,
/// Cumulative tokens accounted against this goal.
#[serde(default)]
pub tokens_used: u64,
/// Cumulative wall-clock seconds accounted against this goal.
#[serde(default)]
pub time_used_seconds: u64,
/// Creation time (unix epoch milliseconds).
pub created_at_ms: u64,
/// Last-mutation time (unix epoch milliseconds).
pub updated_at_ms: u64,
/// Set when an idle auto-continuation turn produced **zero tool calls**, to
/// stop a continuation loop. Cleared on any user action, tool execution, or
/// external mutation (e.g. `goal_set`).
#[serde(default)]
pub continuation_suppressed: bool,
}
impl ThreadGoal {
/// Tokens remaining before the budget cap, if a budget is set.
pub fn budget_remaining(&self) -> Option<u64> {
self.token_budget
.map(|b| b.saturating_sub(self.tokens_used))
}
/// Whether accounting has reached or exceeded the configured budget.
pub fn over_budget(&self) -> bool {
matches!(self.token_budget, Some(b) if self.tokens_used >= b)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_strings_match_serialized() {
assert_eq!(ThreadGoalStatus::Active.as_str(), "active");
assert_eq!(ThreadGoalStatus::Paused.as_str(), "paused");
assert_eq!(ThreadGoalStatus::BudgetLimited.as_str(), "budget_limited");
assert_eq!(ThreadGoalStatus::Complete.as_str(), "complete");
}
#[test]
fn active_and_terminal_predicates() {
assert!(ThreadGoalStatus::Active.is_active());
assert!(!ThreadGoalStatus::Paused.is_active());
assert!(ThreadGoalStatus::Complete.is_terminal());
assert!(ThreadGoalStatus::BudgetLimited.is_terminal());
assert!(!ThreadGoalStatus::Active.is_terminal());
}
#[test]
fn budget_helpers() {
let mut g = ThreadGoal {
thread_id: "t".into(),
goal_id: "g".into(),
objective: "do it".into(),
status: ThreadGoalStatus::Active,
token_budget: Some(100),
tokens_used: 40,
time_used_seconds: 0,
created_at_ms: 0,
updated_at_ms: 0,
continuation_suppressed: false,
};
assert_eq!(g.budget_remaining(), Some(60));
assert!(!g.over_budget());
g.tokens_used = 120;
assert_eq!(g.budget_remaining(), Some(0));
assert!(g.over_budget());
g.token_budget = None;
assert_eq!(g.budget_remaining(), None);
assert!(!g.over_budget());
}
}
+18
View File
@@ -576,6 +576,24 @@ pub fn all_tools_with_runtime(
));
}
// Thread-level goal tools (Codex-style per-thread completion contract).
// Visible only to agents that allowlist them (orchestrator). The target
// thread is resolved from the ambient `thread_id`, so no thread arg is
// taken. `goal_get`/`goal_set`/`goal_complete` — pause/resume/budget are
// system-driven and have no model tool.
{
let goal_dir = root_config.workspace_dir.clone();
tools.push(Box::new(crate::openhuman::thread_goals::GoalGetTool::new(
goal_dir.clone(),
)));
tools.push(Box::new(crate::openhuman::thread_goals::GoalSetTool::new(
goal_dir.clone(),
)));
tools.push(Box::new(
crate::openhuman::thread_goals::GoalCompleteTool::new(goal_dir),
));
}
if browser_config.enabled {
// Unified web-access allowlist (merge fetch + browser firewalls): the
// browser tool shares the single `http_request.allowed_domains` host
+14 -1
View File
@@ -294,6 +294,12 @@ chat_onboarding_completed = true
[secrets]
encrypt = false
[context]
# These harness tests script the mock-LLM call sequence exactly; the default-on
# first-turn "super context" pass (#4085) would spawn a context_scout and consume
# a scripted response, desyncing the orchestrator turns. Disable it here.
super_context_enabled = false
"#
);
fn write_config_file(config_dir: &Path, cfg: &str) {
@@ -2387,7 +2393,14 @@ mod streaming_support {
.event_context("stream-accum-session", "stream-accum-channel")
.agent_definition_name("round17/orchestrator")
.config(config)
.context_config(ContextConfig::default())
// These are deterministic scripted-mock orchestrator turns. The
// default-on first-turn "super context" pass (#4085) would spawn a
// context_scout and add an extra model call the scripts don't expect,
// breaking every orchestrator test here. Disable it for the harness.
.context_config(ContextConfig {
super_context_enabled: false,
..ContextConfig::default()
})
.auto_save(true)
.explicit_preferences_enabled(false)
.unified_compaction_enabled(false)
+157
View File
@@ -2519,6 +2519,163 @@ async fn json_rpc_todos_crud_on_personal_board() {
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_thread_goal_lifecycle() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_url_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let _api_url_guard = EnvVarGuard::unset("OPENHUMAN_API_URL");
let (api_addr, api_join) = serve_on_ephemeral(mock_upstream_router()).await;
let api_origin = format!("http://{api_addr}");
write_min_config(openhuman_home.as_path(), &api_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{rpc_addr}");
// `thread_goals_*` handlers never require the thread to exist (mirrors
// `todos_*`), so a synthetic thread id is fine.
let thread = "thread-goal-e2e";
// Pull the `{ goal }` payload out of either response shape: a bare
// `{ goal }` (get) or the `{ result: { goal }, logs }` CLI envelope
// (mutations).
fn goal_of<'a>(result: &'a Value) -> Option<&'a Value> {
let envelope = result.get("result").unwrap_or(result);
envelope.get("goal").filter(|g| !g.is_null())
}
// 1. No goal yet.
let got = post_json_rpc(
&rpc_base,
9201,
"openhuman.thread_goals_get",
json!({ "thread_id": thread }),
)
.await;
assert!(
goal_of(assert_no_jsonrpc_error(&got, "thread_goals_get")).is_none(),
"no goal initially"
);
// 2. Set with a token budget.
let set = post_json_rpc(
&rpc_base,
9202,
"openhuman.thread_goals_set",
json!({ "thread_id": thread, "objective": "Ship the release", "token_budget": 5000 }),
)
.await;
let goal = goal_of(assert_no_jsonrpc_error(&set, "thread_goals_set")).expect("goal after set");
let goal_id = goal
.get("goalId")
.and_then(Value::as_str)
.expect("goalId")
.to_string();
assert_eq!(
goal.get("objective").and_then(Value::as_str),
Some("Ship the release")
);
assert_eq!(goal.get("status").and_then(Value::as_str), Some("active"));
assert_eq!(goal.get("tokenBudget").and_then(Value::as_u64), Some(5000));
// 3. Get reflects the persisted goal (same goal id).
let got2 = post_json_rpc(
&rpc_base,
9203,
"openhuman.thread_goals_get",
json!({ "thread_id": thread }),
)
.await;
assert_eq!(
goal_of(assert_no_jsonrpc_error(&got2, "thread_goals_get#2"))
.and_then(|g| g.get("goalId"))
.and_then(Value::as_str),
Some(goal_id.as_str())
);
// 4. Pause then resume (system-driven lifecycle).
let paused = post_json_rpc(
&rpc_base,
9204,
"openhuman.thread_goals_pause",
json!({ "thread_id": thread }),
)
.await;
assert_eq!(
goal_of(assert_no_jsonrpc_error(&paused, "pause"))
.and_then(|g| g.get("status"))
.and_then(Value::as_str),
Some("paused")
);
let resumed = post_json_rpc(
&rpc_base,
9205,
"openhuman.thread_goals_resume",
json!({ "thread_id": thread }),
)
.await;
assert_eq!(
goal_of(assert_no_jsonrpc_error(&resumed, "resume"))
.and_then(|g| g.get("status"))
.and_then(Value::as_str),
Some("active")
);
// 5. Complete.
let done = post_json_rpc(
&rpc_base,
9206,
"openhuman.thread_goals_complete",
json!({ "thread_id": thread }),
)
.await;
assert_eq!(
goal_of(assert_no_jsonrpc_error(&done, "complete"))
.and_then(|g| g.get("status"))
.and_then(Value::as_str),
Some("complete")
);
// 6. Clear → removed; subsequent get is null.
let cleared = post_json_rpc(
&rpc_base,
9207,
"openhuman.thread_goals_clear",
json!({ "thread_id": thread }),
)
.await;
let cleared_result = assert_no_jsonrpc_error(&cleared, "clear");
assert_eq!(
cleared_result
.get("result")
.and_then(|r| r.get("removed"))
.and_then(Value::as_bool),
Some(true),
"clear reports removal"
);
let got3 = post_json_rpc(
&rpc_base,
9208,
"openhuman.thread_goals_get",
json!({ "thread_id": thread }),
)
.await;
assert!(
goal_of(assert_no_jsonrpc_error(&got3, "thread_goals_get#3")).is_none(),
"goal gone after clear"
);
api_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_thread_title_create_and_update() {
let _env_lock = json_rpc_e2e_env_lock();