mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(thread_goals): Codex-style thread-level goal (core + RPC + tools + harness + UI) (#4087)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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': 'عدم الموافقة',
|
||||
|
||||
@@ -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': 'অনুমোদন করা হবে না',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 l’objectif',
|
||||
'conversations.threadGoal.clear': 'Effacer l’objectif',
|
||||
'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',
|
||||
|
||||
@@ -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': 'कोई अनुमोदन नहीं',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '승인 없음',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'нет одобрения',
|
||||
|
||||
@@ -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': '无需审批',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user