mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(super-context): harness-driven first-turn context collection (default on) (#4085)
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import debugFactory from 'debug';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { isTauri } from '../../utils/tauriCommands/common';
|
||||
import {
|
||||
openhumanGetSuperContextEnabled,
|
||||
openhumanSetSuperContextEnabled,
|
||||
} from '../../utils/tauriCommands/config';
|
||||
import SettingsSwitch from '../settings/controls/SettingsSwitch';
|
||||
import { trackSuperContextWrite } from './superContextWrite';
|
||||
|
||||
const log = debugFactory('chat:super-context-toggle');
|
||||
|
||||
/**
|
||||
* "Super context" toggle, rendered directly below the chat composer.
|
||||
*
|
||||
* Flips the persistent `context.super_context_enabled` core config flag. When
|
||||
* on, the harness runs a read-only context-collection pass on the **first turn
|
||||
* of a new thread** — before the orchestrator LLM runs — and folds the result
|
||||
* into the user message. This is harness-driven (deterministic), unlike the
|
||||
* `agent_prepare_context` tool the model may call on its own.
|
||||
*
|
||||
* Because the flag is read at thread construction, toggling it only affects
|
||||
* threads started afterwards — surfaced to the user via the helper hint.
|
||||
*/
|
||||
const SuperContextToggle = () => {
|
||||
const { t } = useT();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
// Until the first read resolves we don't know the real value; keep the
|
||||
// switch disabled so a stray click can't write a stale default back. Outside
|
||||
// Tauri (Storybook/web preview) there's no core to read, so treat the control
|
||||
// as loaded immediately in its default-off state without hitting the RPC.
|
||||
const [loaded, setLoaded] = useState(() => !isTauri());
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await openhumanGetSuperContextEnabled();
|
||||
if (!cancelled) {
|
||||
setEnabled(Boolean(res.result));
|
||||
log('loaded super_context_enabled=%o', res.result);
|
||||
}
|
||||
} catch (err) {
|
||||
// Best-effort: a read failure leaves the toggle in its default-off
|
||||
// state. The user can still flip it; the write path surfaces errors.
|
||||
log('failed to load super_context_enabled: %o', err);
|
||||
} finally {
|
||||
if (!cancelled) setLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (busy) return;
|
||||
const previous = enabled;
|
||||
// Optimistic: reflect the choice immediately, roll back on failure.
|
||||
setEnabled(next);
|
||||
setBusy(true);
|
||||
log('set super_context_enabled -> %o', next);
|
||||
const write = openhumanSetSuperContextEnabled(next);
|
||||
// Register the write so a flip-then-immediately-Send awaits it before the
|
||||
// new thread's session reads the persisted flag (avoids a stale first turn).
|
||||
trackSuperContextWrite(write);
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await write;
|
||||
setEnabled(Boolean(res.result));
|
||||
} catch (err) {
|
||||
log('failed to persist super_context_enabled, rolling back: %o', err);
|
||||
setEnabled(previous);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[busy, enabled]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-7 flex-shrink-0 items-center gap-1.5 text-xs text-stone-500 dark:text-neutral-400">
|
||||
<SettingsSwitch
|
||||
id="super-context-toggle"
|
||||
checked={enabled}
|
||||
onCheckedChange={handleChange}
|
||||
disabled={!loaded || busy}
|
||||
aria-label={t('chat.superContext.label')}
|
||||
data-testid="super-context-toggle"
|
||||
/>
|
||||
<span className="font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('chat.superContext.label')}
|
||||
</span>
|
||||
{/* Self-contained wrapping tooltip (the shared <Tooltip> is single-line
|
||||
nowrap and can't fit this paragraph). Anchored bottom-full + right-0
|
||||
so it grows up-and-left into the app interior — the toggle only shows
|
||||
on a fresh thread, where that space is empty, so it never clips. */}
|
||||
<span className="group relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
aria-describedby="super-context-tooltip"
|
||||
aria-label={t('chat.superContext.label')}
|
||||
data-testid="super-context-info"
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full text-stone-400 transition-colors hover:text-stone-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:text-neutral-500 dark:hover:text-neutral-300">
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M12 16v-4m0-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
id="super-context-tooltip"
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-full right-0 z-[9999] mb-2 w-72 rounded-lg bg-stone-800 px-3 py-2 text-xs font-normal leading-snug text-white opacity-0 shadow-lg transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100 dark:bg-neutral-700">
|
||||
{t('chat.superContext.hint')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuperContextToggle;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SuperContextToggle from '../SuperContextToggle';
|
||||
|
||||
// `vi.mock` factories are hoisted above the static imports, so any module-level
|
||||
// mock fns they reference must be created with `vi.hoisted` (hoisted alongside
|
||||
// them) — a plain `const` would be in the TDZ when the factory runs and could
|
||||
// throw on import. See CodeRabbit/Codex review on PR #4085.
|
||||
const { isTauriMock, getMock, setMock } = vi.hoisted(() => ({
|
||||
isTauriMock: vi.fn(() => true),
|
||||
getMock: vi.fn(),
|
||||
setMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
vi.mock('../../../utils/tauriCommands/common', () => ({ isTauri: () => isTauriMock() }));
|
||||
vi.mock('../../../utils/tauriCommands/config', () => ({
|
||||
openhumanGetSuperContextEnabled: () => getMock(),
|
||||
openhumanSetSuperContextEnabled: (value: boolean) => setMock(value),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
isTauriMock.mockReturnValue(true);
|
||||
getMock.mockReset();
|
||||
setMock.mockReset();
|
||||
});
|
||||
|
||||
describe('<SuperContextToggle />', () => {
|
||||
it('loads the persisted flag and reflects it as the switch state', async () => {
|
||||
getMock.mockResolvedValue({ result: true, logs: [] });
|
||||
|
||||
render(<SuperContextToggle />);
|
||||
|
||||
const sw = screen.getByTestId('super-context-toggle');
|
||||
await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'true'));
|
||||
expect(getMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('persists the new value and optimistically updates on toggle', async () => {
|
||||
getMock.mockResolvedValue({ result: false, logs: [] });
|
||||
setMock.mockResolvedValue({ result: true, logs: [] });
|
||||
|
||||
render(<SuperContextToggle />);
|
||||
const sw = screen.getByTestId('super-context-toggle');
|
||||
// Wait for the initial read to enable the switch.
|
||||
await waitFor(() => expect(sw).not.toBeDisabled());
|
||||
|
||||
fireEvent.click(sw);
|
||||
|
||||
// Optimistic flip is immediate; persistence is called with the new value.
|
||||
await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'true'));
|
||||
expect(setMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('rolls back the optimistic flip when persistence fails', async () => {
|
||||
getMock.mockResolvedValue({ result: false, logs: [] });
|
||||
setMock.mockRejectedValue(new Error('rpc down'));
|
||||
|
||||
render(<SuperContextToggle />);
|
||||
const sw = screen.getByTestId('super-context-toggle');
|
||||
await waitFor(() => expect(sw).not.toBeDisabled());
|
||||
|
||||
fireEvent.click(sw);
|
||||
|
||||
// Ends back at false after the rejected write.
|
||||
await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'false'));
|
||||
expect(setMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('does not call the core RPC when running outside Tauri', () => {
|
||||
isTauriMock.mockReturnValue(false);
|
||||
|
||||
render(<SuperContextToggle />);
|
||||
|
||||
expect(getMock).not.toHaveBeenCalled();
|
||||
const sw = screen.getByTestId('super-context-toggle');
|
||||
// Treated as loaded (enabled) immediately, default-off.
|
||||
expect(sw).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { trackSuperContextWrite, whenSuperContextWriteSettled } from '../superContextWrite';
|
||||
|
||||
describe('superContextWrite gate', () => {
|
||||
it('resolves immediately when nothing is pending', async () => {
|
||||
// Completing the await without throwing is the assertion.
|
||||
await whenSuperContextWriteSettled();
|
||||
});
|
||||
|
||||
it('waits for a tracked write to settle before resolving', async () => {
|
||||
let settled = false;
|
||||
const write = new Promise(resolve =>
|
||||
setTimeout(() => {
|
||||
settled = true;
|
||||
resolve('ok');
|
||||
}, 5)
|
||||
);
|
||||
trackSuperContextWrite(write);
|
||||
|
||||
await whenSuperContextWriteSettled();
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it('still settles when a tracked write rejects', async () => {
|
||||
trackSuperContextWrite(Promise.reject(new Error('rpc down')));
|
||||
// Must not throw — the send path only needs the write to have settled.
|
||||
await whenSuperContextWriteSettled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// Tracks the most recent in-flight "super context" config write so the chat
|
||||
// send path can await it before a new thread's session is built.
|
||||
//
|
||||
// The flag is read by the core when the chat task constructs its session, so a
|
||||
// flip-then-immediately-Send could otherwise race: the fire-and-forget write
|
||||
// from the composer toggle may still be in flight, and the first turn would run
|
||||
// with the *previous* persisted value while the UI already shows the new one.
|
||||
// `SuperContextToggle` registers its write here; `handleSendMessage` awaits
|
||||
// `whenSuperContextWriteSettled()` before sending.
|
||||
|
||||
let pending: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/** Register an in-flight super-context write. Failures are swallowed — the
|
||||
* send path only needs the write to have *settled*, not to have succeeded. */
|
||||
export function trackSuperContextWrite(write: Promise<unknown>): void {
|
||||
// Chain so concurrent flips all settle before the gate resolves.
|
||||
const prior = pending;
|
||||
pending = Promise.allSettled([prior, write]);
|
||||
}
|
||||
|
||||
/** Resolves once every registered super-context write has settled. Cheap to
|
||||
* await when nothing is pending (resolved promise). */
|
||||
export function whenSuperContextWriteSettled(): Promise<unknown> {
|
||||
return pending;
|
||||
}
|
||||
@@ -2142,6 +2142,9 @@ const messages: TranslationMap = {
|
||||
'chat.left': 'متبقٍ',
|
||||
'chat.setup': 'إعداد',
|
||||
'chat.switchToText': 'التبديل إلى النص',
|
||||
'chat.superContext.label': 'سياق فائق',
|
||||
'chat.superContext.hint':
|
||||
'يتيح «سياق فائق» لـ OpenHuman جمع وتحضير السياق من جميع البيانات التي يمكنه الوصول إليها لتقديم إجابة وثيقة الصلة للغاية. «سياق فائق» في مرحلة تجريبية مبكرة.',
|
||||
'chat.transcribing': 'جارٍ النسخ...',
|
||||
'chat.stopAndSend': 'إيقاف وإرسال',
|
||||
'chat.startTalking': 'ابدأ الحديث',
|
||||
|
||||
@@ -2193,6 +2193,9 @@ const messages: TranslationMap = {
|
||||
'chat.left': 'বাকি',
|
||||
'chat.setup': 'সেটআপ করুন',
|
||||
'chat.switchToText': 'টেক্সটে পরিবর্তন করুন',
|
||||
'chat.superContext.label': 'সুপার কনটেক্সট',
|
||||
'chat.superContext.hint':
|
||||
'সুপার কনটেক্সট OpenHuman-কে তার অ্যাক্সেসযোগ্য সমস্ত ডেটা থেকে প্রসঙ্গ সংগ্রহ ও প্রস্তুত করতে দেয় যাতে এটি অত্যন্ত প্রাসঙ্গিক উত্তর দিতে পারে। সুপার কনটেক্সট প্রাথমিক বিটা পর্যায়ে রয়েছে।',
|
||||
'chat.transcribing': 'ট্রান্সক্রাইব হচ্ছে...',
|
||||
'chat.stopAndSend': 'থামুন ও পাঠান',
|
||||
'chat.startTalking': 'কথা বলা শুরু করুন',
|
||||
|
||||
@@ -2244,6 +2244,9 @@ const messages: TranslationMap = {
|
||||
'chat.left': 'links',
|
||||
'chat.setup': 'Einrichten',
|
||||
'chat.switchToText': 'Wechsle zu Text',
|
||||
'chat.superContext.label': 'Super-Kontext',
|
||||
'chat.superContext.hint':
|
||||
'Super-Kontext ermöglicht OpenHuman, Kontext aus allen verfügbaren Daten zu sammeln und aufzubereiten, um eine hochrelevante Antwort zu liefern. Super-Kontext befindet sich in einer frühen Beta-Phase.',
|
||||
'chat.transcribing': 'Transkribieren...',
|
||||
'chat.stopAndSend': 'Stoppen und senden',
|
||||
'chat.startTalking': 'Sprich los',
|
||||
|
||||
@@ -2590,6 +2590,9 @@ const en: TranslationMap = {
|
||||
'chat.left': 'left',
|
||||
'chat.setup': 'Set up',
|
||||
'chat.switchToText': 'Switch to text',
|
||||
'chat.superContext.label': 'Super Context',
|
||||
'chat.superContext.hint':
|
||||
'Super Context allows OpenHuman to gather and prepare context from all the data it has access to so that it can deliver a highly relevant answer. Super Context is in early beta.',
|
||||
'chat.transcribing': 'Transcribing...',
|
||||
'chat.stopAndSend': 'Stop and send',
|
||||
'chat.startTalking': 'Start talking',
|
||||
|
||||
@@ -2232,6 +2232,9 @@ const messages: TranslationMap = {
|
||||
'chat.left': 'restante',
|
||||
'chat.setup': 'Configurar',
|
||||
'chat.switchToText': 'Cambiar a texto',
|
||||
'chat.superContext.label': 'Súper contexto',
|
||||
'chat.superContext.hint':
|
||||
'Súper contexto permite a OpenHuman reunir y preparar contexto a partir de todos los datos a los que tiene acceso para ofrecer una respuesta muy relevante. Súper contexto está en fase beta inicial.',
|
||||
'chat.transcribing': 'Transcribiendo...',
|
||||
'chat.stopAndSend': 'Detener y enviar',
|
||||
'chat.startTalking': 'Empieza a hablar',
|
||||
|
||||
@@ -2243,6 +2243,9 @@ const messages: TranslationMap = {
|
||||
'chat.left': 'restant',
|
||||
'chat.setup': 'Configurer',
|
||||
'chat.switchToText': 'Passer au texte',
|
||||
'chat.superContext.label': 'Super contexte',
|
||||
'chat.superContext.hint':
|
||||
'Super contexte permet à OpenHuman de rassembler et de préparer le contexte à partir de toutes les données auxquelles il a accès afin de fournir une réponse très pertinente. Super contexte est en bêta précoce.',
|
||||
'chat.transcribing': 'Transcription…',
|
||||
'chat.stopAndSend': 'Arrêter et envoyer',
|
||||
'chat.startTalking': 'Commence à parler',
|
||||
|
||||
@@ -2189,6 +2189,9 @@ const messages: TranslationMap = {
|
||||
'chat.left': 'बचा हुआ',
|
||||
'chat.setup': 'सेट अप करें',
|
||||
'chat.switchToText': 'टेक्स्ट पर स्विच करें',
|
||||
'chat.superContext.label': 'सुपर कॉन्टेक्स्ट',
|
||||
'chat.superContext.hint':
|
||||
'सुपर कॉन्टेक्स्ट OpenHuman को उन सभी डेटा से संदर्भ इकट्ठा और तैयार करने देता है जिन तक उसकी पहुँच है, ताकि वह अत्यधिक प्रासंगिक उत्तर दे सके। सुपर कॉन्टेक्स्ट प्रारंभिक बीटा में है।',
|
||||
'chat.transcribing': 'ट्रांसक्राइब हो रहा है...',
|
||||
'chat.stopAndSend': 'रोकें और भेजें',
|
||||
'chat.startTalking': 'बोलना शुरू करें',
|
||||
|
||||
@@ -2192,6 +2192,9 @@ const messages: TranslationMap = {
|
||||
'chat.left': 'tersisa',
|
||||
'chat.setup': 'Atur',
|
||||
'chat.switchToText': 'Beralih ke teks',
|
||||
'chat.superContext.label': 'Super konteks',
|
||||
'chat.superContext.hint':
|
||||
'Super konteks memungkinkan OpenHuman mengumpulkan dan menyiapkan konteks dari semua data yang dapat diaksesnya agar dapat memberikan jawaban yang sangat relevan. Super konteks masih dalam tahap beta awal.',
|
||||
'chat.transcribing': 'Mentranskripsi...',
|
||||
'chat.stopAndSend': 'Berhenti dan kirim',
|
||||
'chat.startTalking': 'Mulai bicara',
|
||||
|
||||
@@ -2224,6 +2224,9 @@ const messages: TranslationMap = {
|
||||
'chat.left': 'rimasti',
|
||||
'chat.setup': 'Configura',
|
||||
'chat.switchToText': 'Passa al testo',
|
||||
'chat.superContext.label': 'Super contesto',
|
||||
'chat.superContext.hint':
|
||||
'Super contesto consente a OpenHuman di raccogliere e preparare il contesto da tutti i dati a cui ha accesso per fornire una risposta altamente pertinente. Super contesto è in beta iniziale.',
|
||||
'chat.transcribing': 'Trascrizione...',
|
||||
'chat.stopAndSend': 'Ferma e invia',
|
||||
'chat.startTalking': 'Inizia a parlare',
|
||||
|
||||
@@ -2169,6 +2169,9 @@ const messages: TranslationMap = {
|
||||
'chat.left': '남음',
|
||||
'chat.setup': '설정',
|
||||
'chat.switchToText': '텍스트로 전환',
|
||||
'chat.superContext.label': '슈퍼 컨텍스트',
|
||||
'chat.superContext.hint':
|
||||
'슈퍼 컨텍스트를 사용하면 OpenHuman이 접근할 수 있는 모든 데이터에서 컨텍스트를 수집하고 준비하여 매우 관련성 높은 답변을 제공할 수 있습니다. 슈퍼 컨텍스트는 초기 베타 단계입니다.',
|
||||
'chat.transcribing': '전사 중...',
|
||||
'chat.stopAndSend': '중지하고 보내기',
|
||||
'chat.startTalking': '말하기 시작',
|
||||
|
||||
@@ -2212,6 +2212,9 @@ const messages: TranslationMap = {
|
||||
'chat.left': 'pozostało',
|
||||
'chat.setup': 'Skonfiguruj',
|
||||
'chat.switchToText': 'Przełącz na tekst',
|
||||
'chat.superContext.label': 'Superkontekst',
|
||||
'chat.superContext.hint':
|
||||
'Superkontekst pozwala OpenHuman zebrać i przygotować kontekst ze wszystkich dostępnych danych, aby udzielić wysoce trafnej odpowiedzi. Superkontekst jest we wczesnej wersji beta.',
|
||||
'chat.transcribing': 'Transkrypcja...',
|
||||
'chat.stopAndSend': 'Zatrzymaj i wyślij',
|
||||
'chat.startTalking': 'Zacznij mówić',
|
||||
|
||||
@@ -2233,6 +2233,9 @@ const messages: TranslationMap = {
|
||||
'chat.left': 'restante',
|
||||
'chat.setup': 'Configurar',
|
||||
'chat.switchToText': 'Mudar para texto',
|
||||
'chat.superContext.label': 'Supercontexto',
|
||||
'chat.superContext.hint':
|
||||
'O Supercontexto permite que o OpenHuman reúna e prepare contexto a partir de todos os dados a que tem acesso para oferecer uma resposta altamente relevante. O Supercontexto está em beta inicial.',
|
||||
'chat.transcribing': 'Transcrevendo...',
|
||||
'chat.stopAndSend': 'Parar e enviar',
|
||||
'chat.startTalking': 'Comece a falar',
|
||||
|
||||
@@ -2208,6 +2208,9 @@ const messages: TranslationMap = {
|
||||
'chat.left': 'осталось',
|
||||
'chat.setup': 'Настроить',
|
||||
'chat.switchToText': 'Переключиться на текст',
|
||||
'chat.superContext.label': 'Супер-контекст',
|
||||
'chat.superContext.hint':
|
||||
'Супер-контекст позволяет OpenHuman собирать и подготавливать контекст из всех доступных ему данных, чтобы давать максимально релевантный ответ. Супер-контекст находится на стадии раннего бета-тестирования.',
|
||||
'chat.transcribing': 'Транскрипция...',
|
||||
'chat.stopAndSend': 'Остановить и отправить',
|
||||
'chat.startTalking': 'Начни говорить',
|
||||
|
||||
@@ -2077,6 +2077,9 @@ const messages: TranslationMap = {
|
||||
'chat.left': '剩余',
|
||||
'chat.setup': '设置',
|
||||
'chat.switchToText': '切换到文本',
|
||||
'chat.superContext.label': '超级上下文',
|
||||
'chat.superContext.hint':
|
||||
'超级上下文让 OpenHuman 能够从其可访问的所有数据中收集并准备上下文,从而给出高度相关的回答。超级上下文目前处于早期测试阶段。',
|
||||
'chat.transcribing': '转录中...',
|
||||
'chat.stopAndSend': '停止并发送',
|
||||
'chat.startTalking': '开始说话',
|
||||
|
||||
@@ -13,6 +13,8 @@ import ChatNewWindowHero from '../components/chat/ChatNewWindowHero';
|
||||
import ComposerTokenStats from '../components/chat/ComposerTokenStats';
|
||||
import IntegrationConnectCard from '../components/chat/IntegrationConnectCard';
|
||||
import QueuedFollowups from '../components/chat/QueuedFollowups';
|
||||
import SuperContextToggle from '../components/chat/SuperContextToggle';
|
||||
import { whenSuperContextWriteSettled } from '../components/chat/superContextWrite';
|
||||
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
|
||||
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
|
||||
import { settingsNavState } from '../components/settings/modal/settingsOverlay';
|
||||
@@ -892,6 +894,12 @@ 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();
|
||||
|
||||
@@ -2788,6 +2796,12 @@ const Conversations = ({
|
||||
{t('chat.agentProfile.reasoning')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Super context is read at thread construction, so it only
|
||||
affects NEW threads. Hide the toggle once the thread has ANY
|
||||
activity — use the raw `messages` (not `hasVisibleMessages`,
|
||||
which ignores hidden transcript entries) so an already-started
|
||||
thread never looks "fresh" here. */}
|
||||
{messages.length === 0 && <SuperContextToggle />}
|
||||
{selectedThreadId && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -10,6 +10,8 @@ export const CORE_RPC_METHODS = {
|
||||
configGetMemorySyncSettings: 'openhuman.config_get_memory_sync_settings',
|
||||
configGetSandboxSettings: 'openhuman.config_get_sandbox_settings',
|
||||
configGetSearchSettings: 'openhuman.config_get_search_settings',
|
||||
configGetSuperContextEnabled: 'openhuman.config_get_super_context_enabled',
|
||||
configSetSuperContextEnabled: 'openhuman.config_set_super_context_enabled',
|
||||
configUpdateSearchSettings: 'openhuman.config_update_search_settings',
|
||||
configSetBrowserAllowAll: 'openhuman.config_set_browser_allow_all',
|
||||
configUpdateAgentPaths: 'openhuman.config_update_agent_paths',
|
||||
|
||||
@@ -564,6 +564,40 @@ export async function openhumanUpdateAutonomySettings(
|
||||
});
|
||||
}
|
||||
|
||||
// ── "Super context" toggle ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reads the "super context" flag (`context.super_context_enabled`). When on,
|
||||
* the harness runs a read-only context-collection pass on the first turn of a
|
||||
* new thread — before the orchestrator LLM runs — and folds the result into the
|
||||
* user message. Surfaced as the toggle below the chat composer.
|
||||
*/
|
||||
export async function openhumanGetSuperContextEnabled(): Promise<CommandResponse<boolean>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<boolean>>({
|
||||
method: CORE_RPC_METHODS.configGetSuperContextEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables "super context". Takes effect for threads started after
|
||||
* the change (the value is baked into the frozen turn-1 prefix), so toggling it
|
||||
* mid-conversation only affects the next new thread.
|
||||
*/
|
||||
export async function openhumanSetSuperContextEnabled(
|
||||
value: boolean
|
||||
): Promise<CommandResponse<boolean>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<boolean>>({
|
||||
method: CORE_RPC_METHODS.configSetSuperContextEnabled,
|
||||
params: { value },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sandbox execution backend settings ───────────────────────────────────────
|
||||
|
||||
export type SandboxBackendId = 'auto' | 'docker' | 'landlock' | 'firejail' | 'bubblewrap' | 'none';
|
||||
|
||||
@@ -24,6 +24,41 @@ use anyhow::Result;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Decide whether the harness-driven "super context" collection pass should
|
||||
/// run this turn.
|
||||
///
|
||||
/// It runs only on the first turn of a **genuinely new** thread driven by the
|
||||
/// **user-facing orchestrator**:
|
||||
/// - `is_orchestrator` — the turn belongs to the `orchestrator` agent (the
|
||||
/// interactive chat path surfaced by the composer toggle). `Agent::turn` is
|
||||
/// shared with `run_single()` background/automated flows (goals enrichment,
|
||||
/// cron/task agents, specialist sub-agents); without this gate those first
|
||||
/// turns would spawn `context_scout` and prepend a prepared-context block,
|
||||
/// adding unexpected LLM/tool work and changing automated outputs; AND
|
||||
/// - `first_turn` — the agent's `history` is empty at turn start; AND
|
||||
/// - `!has_prior_conversation` — the seeded `cached_transcript_messages`
|
||||
/// prefix contains no prior **assistant** reply. A thread resumed cold
|
||||
/// (web-chat task rebuilt for an existing conversation, or a transcript
|
||||
/// loaded from disk) also has an empty `history`, so the seeded prefix is
|
||||
/// what distinguishes a *new* thread from a *resumed* one. We key on a prior
|
||||
/// assistant message rather than "any cached prefix" because an
|
||||
/// attachment-first new thread can seed a single just-persisted *user* row
|
||||
/// (the expanded `[IMAGE:…]`/`[FILE:…]` send payload doesn't exact-match the
|
||||
/// persisted `content`, so `seed_resume_from_messages` can't drop it) — that
|
||||
/// is still a brand-new conversation and should get super context; AND
|
||||
/// - `enabled` — the `context.super_context_enabled` config flag is on.
|
||||
///
|
||||
/// Pulled out as a pure function so the gate (in particular the resume and
|
||||
/// orchestrator guards) is unit-testable without a full agent turn harness.
|
||||
fn should_run_super_context(
|
||||
is_orchestrator: bool,
|
||||
first_turn: bool,
|
||||
has_prior_conversation: bool,
|
||||
enabled: bool,
|
||||
) -> bool {
|
||||
is_orchestrator && first_turn && !has_prior_conversation && enabled
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
/// Executes a single interaction "turn" with the agent.
|
||||
///
|
||||
@@ -47,6 +82,10 @@ impl Agent {
|
||||
/// extraction asynchronously.
|
||||
pub async fn turn(&mut self, user_message: &str) -> Result<String> {
|
||||
let turn_started = std::time::Instant::now();
|
||||
// Capture before any system-prompt push mutates `history`: this is the
|
||||
// signal that gates first-turn-only work (system prompt build, and the
|
||||
// "super context" harness-driven context-collection pass below).
|
||||
let first_turn = self.history.is_empty();
|
||||
self.emit_progress(AgentProgress::TurnStarted).await;
|
||||
log::info!("[agent] turn started — awaiting user message processing");
|
||||
log::info!(
|
||||
@@ -428,6 +467,89 @@ impl Agent {
|
||||
.inject_triggered_memory_agent_context(user_message, enriched, &parent_context)
|
||||
.await;
|
||||
|
||||
// ── "Super context": harness-driven first-turn context collection ──
|
||||
// When enabled (config `context.super_context_enabled`, surfaced as the
|
||||
// composer toggle), run the read-only `context_scout` BEFORE the
|
||||
// orchestrator LLM gets the turn, and fold its bounded
|
||||
// `[context_bundle]` into the user message. This is the harness driving
|
||||
// the collection deterministically — unlike the `agent_prepare_context`
|
||||
// tool, which the model chooses to call. The tool stays exposed so the
|
||||
// model can still scout again mid-turn.
|
||||
//
|
||||
// Gate on the **first turn of a genuinely new thread**: `first_turn`
|
||||
// (empty `history`) is necessary but NOT sufficient, because a thread
|
||||
// resumed cold (e.g. a web-chat task rebuilt for an existing
|
||||
// conversation after an app restart) seeds prior messages into
|
||||
// `cached_transcript_messages` via `seed_resume_from_messages` /
|
||||
// `try_load_session_transcript` WITHOUT populating `history`. Without
|
||||
// the `cached_transcript_messages.is_none()` guard, super context would
|
||||
// re-fire on every cold-started existing conversation, surprising the
|
||||
// user with extra scout/tool calls and a stray prepared-context block.
|
||||
//
|
||||
// Runs inside the parent-context scope because `run_context_scout`
|
||||
// reads the parent's visible tool catalogue and runs the scout against
|
||||
// the parent's provider via the PARENT_CONTEXT task-local. Best-effort:
|
||||
// any failure (scout error, no bundle) leaves the turn to proceed with
|
||||
// the un-augmented message rather than blocking the user.
|
||||
// A genuinely new thread has no prior assistant reply in its seeded
|
||||
// transcript prefix; a cold-resumed thread does. (An attachment-first
|
||||
// new thread may seed a lone user row — see `should_run_super_context`.)
|
||||
let has_prior_conversation = self
|
||||
.cached_transcript_messages
|
||||
.as_ref()
|
||||
.is_some_and(|msgs| msgs.iter().any(|m| m.role == "assistant"));
|
||||
let enriched = if should_run_super_context(
|
||||
self.agent_definition_id == "orchestrator",
|
||||
first_turn,
|
||||
has_prior_conversation,
|
||||
self.context.super_context_enabled(),
|
||||
) {
|
||||
log::info!(
|
||||
"[agent_loop] super_context enabled — running harness-driven context collection (new thread, first turn)"
|
||||
);
|
||||
let scout = harness::with_parent_context(parent_context.clone(), {
|
||||
let user_message = user_message.to_string();
|
||||
async move {
|
||||
crate::openhuman::agent_orchestration::tools::run_context_scout(
|
||||
&user_message,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match scout {
|
||||
Ok(result) if !result.is_error => {
|
||||
let bundle = result.output();
|
||||
log::info!(
|
||||
"[agent_loop] super_context bundle collected bundle_chars={}",
|
||||
bundle.chars().count()
|
||||
);
|
||||
format!(
|
||||
"## Prepared context (super context)\n\nThe following context was \
|
||||
collected up-front by a read-only context scout before this turn. \
|
||||
Use it to ground your response; you may still gather more if needed.\n\n\
|
||||
{bundle}\n\n---\n\n{enriched}"
|
||||
)
|
||||
}
|
||||
Ok(result) => {
|
||||
log::warn!(
|
||||
"[agent_loop] super_context scout returned an error — proceeding without bundle: {}",
|
||||
result.output()
|
||||
);
|
||||
enriched
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"[agent_loop] super_context collection failed — proceeding without bundle: {err}"
|
||||
);
|
||||
enriched
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enriched
|
||||
};
|
||||
|
||||
// #3602: stamp every turn's user message with the live local time
|
||||
// so time-relative phrasing (greetings, "today"/"tonight") is
|
||||
// grounded on the real clock. Rides the user message — not the
|
||||
@@ -869,3 +991,52 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod super_context_gate_tests {
|
||||
use super::should_run_super_context;
|
||||
|
||||
#[test]
|
||||
fn runs_only_on_first_turn_of_a_new_orchestrator_thread_when_enabled() {
|
||||
// Orchestrator, new thread, first turn, flag on → run.
|
||||
assert!(should_run_super_context(true, true, false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_flag_disabled() {
|
||||
assert!(!should_run_super_context(true, true, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_on_later_turns() {
|
||||
// history non-empty → not the first turn.
|
||||
assert!(!should_run_super_context(true, false, false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_on_cold_resumed_thread_even_on_first_turn() {
|
||||
// Regression: a thread resumed cold has an empty `history` (so
|
||||
// `first_turn` is true) but a seeded prefix that includes a prior
|
||||
// assistant reply. Super context must NOT re-fire on these existing
|
||||
// conversations.
|
||||
assert!(!should_run_super_context(true, true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_for_attachment_first_new_thread_with_lone_seeded_user_row() {
|
||||
// Regression: an attachment-first new thread can seed a single just-
|
||||
// persisted *user* row (no assistant reply), so `has_prior_conversation`
|
||||
// is false. That is still a brand-new conversation — super context
|
||||
// should run.
|
||||
assert!(should_run_super_context(true, true, false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_for_non_orchestrator_agents() {
|
||||
// Regression: `Agent::turn` is shared with background/automated
|
||||
// `run_single()` flows (goals enrichment, cron/task agents,
|
||||
// specialist sub-agents). Even on a fresh first turn with the flag on,
|
||||
// super context must only run for the user-facing orchestrator.
|
||||
assert!(!should_run_super_context(false, true, false, true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ mod worker_thread;
|
||||
|
||||
pub(crate) use dispatch::dispatch_subagent;
|
||||
|
||||
pub use agent_prepare_context::AgentPrepareContextTool;
|
||||
pub use agent_prepare_context::{run_context_scout, AgentPrepareContextTool};
|
||||
pub use archetype_delegation::ArchetypeDelegationTool;
|
||||
pub use close_subagent::CloseSubagentTool;
|
||||
pub use continue_subagent::ContinueSubagentTool;
|
||||
|
||||
@@ -26,6 +26,279 @@ use std::fmt::Write as _;
|
||||
/// The sub-agent archetype this tool drives.
|
||||
const SCOUT_AGENT_ID: &str = "context_scout";
|
||||
|
||||
/// Whether `output` is exactly one well-formed `[context_bundle] …
|
||||
/// [/context_bundle]` envelope and *nothing else*: the trimmed output must
|
||||
/// start with the open tag, end with the close tag, and contain exactly one of
|
||||
/// each.
|
||||
///
|
||||
/// The harness prepends every non-error `run_context_scout` result to turn 1
|
||||
/// as "Prepared context", so a prompt drift / model regression in
|
||||
/// `context_scout` that emits free-form prose (e.g. `Sure, here's what I
|
||||
/// found:\n[context_bundle]…[/context_bundle]`), or a malformed/duplicated
|
||||
/// envelope, would otherwise silently inject arbitrary text. We reject those so
|
||||
/// the caller falls back to the un-augmented message instead. The scout's own
|
||||
/// contract is "emit the single envelope and nothing outside it".
|
||||
fn is_well_formed_context_bundle(output: &str) -> bool {
|
||||
const OPEN: &str = "[context_bundle]";
|
||||
const CLOSE: &str = "[/context_bundle]";
|
||||
let trimmed = output.trim();
|
||||
// Exactly one open + one close tag, with no text outside the envelope.
|
||||
trimmed.starts_with(OPEN)
|
||||
&& trimmed.ends_with(CLOSE)
|
||||
&& trimmed.matches(OPEN).count() == 1
|
||||
&& trimmed.matches(CLOSE).count() == 1
|
||||
// Guard the degenerate case where OPEN/CLOSE overlap on too-short input.
|
||||
&& trimmed.len() >= OPEN.len() + CLOSE.len()
|
||||
}
|
||||
|
||||
/// Run the `context_scout` sub-agent inline (blocking) for `question` and
|
||||
/// return its bounded `[context_bundle]` envelope as a [`ToolResult`].
|
||||
///
|
||||
/// This is the shared engine behind two callers:
|
||||
///
|
||||
/// 1. The [`AgentPrepareContextTool`] — invoked *autonomously by the LLM*
|
||||
/// when it decides to scout context mid-turn.
|
||||
/// 2. The agent harness itself — when "super context" is enabled it calls
|
||||
/// this directly on the first turn of a new thread (see
|
||||
/// [`crate::openhuman::config::ContextConfig::super_context_enabled`]),
|
||||
/// so the collection happens regardless of the model's decision.
|
||||
///
|
||||
/// Must be called from within an active agent turn (i.e. with the
|
||||
/// [`crate::openhuman::agent::harness::fork_context::PARENT_CONTEXT`]
|
||||
/// task-local installed) — it reads the parent's visible tool catalogue
|
||||
/// and runs the scout against the parent's provider. Outside a turn the
|
||||
/// `run_subagent` call surfaces a no-parent error as a [`ToolResult::error`].
|
||||
pub async fn run_context_scout(question: &str, focus: Option<&str>) -> anyhow::Result<ToolResult> {
|
||||
let question = question.trim().to_string();
|
||||
let focus = focus.map(|s| s.to_string());
|
||||
|
||||
tracing::info!(
|
||||
target: "agent_prepare_context",
|
||||
question_chars = question.chars().count(),
|
||||
has_focus = focus.as_deref().map(|f| !f.trim().is_empty()).unwrap_or(false),
|
||||
"[agent_prepare_context] invoked"
|
||||
);
|
||||
|
||||
if question.is_empty() {
|
||||
return Ok(ToolResult::error(
|
||||
"agent_prepare_context: `question` is required",
|
||||
));
|
||||
}
|
||||
|
||||
let registry = match AgentDefinitionRegistry::global() {
|
||||
Some(reg) => reg,
|
||||
None => {
|
||||
return Ok(ToolResult::error(
|
||||
"agent_prepare_context: AgentDefinitionRegistry has not been initialised.",
|
||||
));
|
||||
}
|
||||
};
|
||||
let definition = match registry.get(SCOUT_AGENT_ID) {
|
||||
Some(def) => def,
|
||||
None => {
|
||||
return Ok(ToolResult::error(format!(
|
||||
"agent_prepare_context: built-in agent `{SCOUT_AGENT_ID}` is not registered.",
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let tool_catalog = AgentPrepareContextTool::render_parent_tool_catalog();
|
||||
let catalog_tool_count = tool_catalog.lines().filter(|l| !l.is_empty()).count();
|
||||
let scout_prompt =
|
||||
AgentPrepareContextTool::build_scout_prompt(&question, focus.as_deref(), &tool_catalog);
|
||||
|
||||
tracing::debug!(
|
||||
target: "agent_prepare_context",
|
||||
catalog_tool_count,
|
||||
scout_prompt_chars = scout_prompt.chars().count(),
|
||||
"[agent_prepare_context] spawning context_scout (blocking)"
|
||||
);
|
||||
|
||||
let task_id = format!("ctx-{}", uuid::Uuid::new_v4());
|
||||
let parent_session = current_parent()
|
||||
.map(|p| p.session_id.clone())
|
||||
.unwrap_or_else(|| "standalone".into());
|
||||
let progress_sink = current_parent().and_then(|p| p.on_progress.clone());
|
||||
|
||||
// Surface the scout as a live subagent row in the parent thread. The
|
||||
// child's own iterations/tool-calls already stream to this sink from
|
||||
// inside run_subagent; we bookend them with spawned/completed so the
|
||||
// UI opens and closes the card. Best-effort — a closed sink is fine.
|
||||
publish_global(DomainEvent::SubagentSpawned {
|
||||
parent_session: parent_session.clone(),
|
||||
agent_id: definition.id.clone(),
|
||||
mode: "typed".to_string(),
|
||||
task_id: task_id.clone(),
|
||||
prompt_chars: scout_prompt.chars().count(),
|
||||
});
|
||||
if let Some(ref tx) = progress_sink {
|
||||
let _ = tx
|
||||
.send(AgentProgress::SubagentSpawned {
|
||||
agent_id: definition.id.clone(),
|
||||
task_id: task_id.clone(),
|
||||
mode: "typed".to_string(),
|
||||
dedicated_thread: false,
|
||||
prompt_chars: scout_prompt.chars().count(),
|
||||
worker_thread_id: None,
|
||||
display_name: Some(definition.display_name().to_string()),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let options = SubagentRunOptions {
|
||||
task_id: Some(task_id.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match run_subagent(definition, &scout_prompt, options).await {
|
||||
Ok(outcome) => match &outcome.status {
|
||||
SubagentRunStatus::Completed => {
|
||||
// Guard the contract: the scout MUST return exactly one
|
||||
// `[context_bundle] … [/context_bundle]` envelope. Reject
|
||||
// free-form / malformed output so the harness (which prepends
|
||||
// any non-error result to turn 1 as "Prepared context") cannot
|
||||
// inject arbitrary prose on a scout prompt drift.
|
||||
if !is_well_formed_context_bundle(&outcome.output) {
|
||||
tracing::warn!(
|
||||
target: "agent_prepare_context",
|
||||
task_id = %outcome.task_id,
|
||||
output_chars = outcome.output.chars().count(),
|
||||
"[agent_prepare_context] scout returned a malformed/absent context_bundle — rejecting"
|
||||
);
|
||||
publish_global(DomainEvent::SubagentCompleted {
|
||||
parent_session: parent_session.clone(),
|
||||
task_id: outcome.task_id.clone(),
|
||||
agent_id: outcome.agent_id.clone(),
|
||||
elapsed_ms: outcome.elapsed.as_millis() as u64,
|
||||
output_chars: 0,
|
||||
iterations: outcome.iterations,
|
||||
});
|
||||
if let Some(ref tx) = progress_sink {
|
||||
let _ = tx
|
||||
.send(AgentProgress::SubagentCompleted {
|
||||
agent_id: outcome.agent_id.clone(),
|
||||
task_id: outcome.task_id.clone(),
|
||||
elapsed_ms: outcome.elapsed.as_millis() as u64,
|
||||
iterations: outcome.iterations as u32,
|
||||
output_chars: 0,
|
||||
worktree_path: None,
|
||||
changed_files: Vec::new(),
|
||||
dirty_status: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
return Ok(ToolResult::error(
|
||||
"agent_prepare_context: context_scout did not return a well-formed \
|
||||
[context_bundle] envelope",
|
||||
));
|
||||
}
|
||||
tracing::info!(
|
||||
target: "agent_prepare_context",
|
||||
task_id = %outcome.task_id,
|
||||
elapsed_ms = outcome.elapsed.as_millis() as u64,
|
||||
iterations = outcome.iterations,
|
||||
output_chars = outcome.output.chars().count(),
|
||||
"[agent_prepare_context] context bundle ready"
|
||||
);
|
||||
publish_global(DomainEvent::SubagentCompleted {
|
||||
parent_session: parent_session.clone(),
|
||||
task_id: outcome.task_id.clone(),
|
||||
agent_id: outcome.agent_id.clone(),
|
||||
elapsed_ms: outcome.elapsed.as_millis() as u64,
|
||||
output_chars: outcome.output.chars().count(),
|
||||
iterations: outcome.iterations,
|
||||
});
|
||||
if let Some(ref tx) = progress_sink {
|
||||
let _ = tx
|
||||
.send(AgentProgress::SubagentCompleted {
|
||||
agent_id: outcome.agent_id.clone(),
|
||||
task_id: outcome.task_id.clone(),
|
||||
elapsed_ms: outcome.elapsed.as_millis() as u64,
|
||||
iterations: outcome.iterations as u32,
|
||||
output_chars: outcome.output.chars().count(),
|
||||
worktree_path: None,
|
||||
changed_files: Vec::new(),
|
||||
dirty_status: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(ToolResult::success(outcome.output))
|
||||
}
|
||||
// The scout has no `ask_user_clarification` tool, so this
|
||||
// branch should not fire — handle defensively rather than
|
||||
// leaking a confusing checkpoint envelope to the parent.
|
||||
SubagentRunStatus::AwaitingUser { question, .. } => {
|
||||
tracing::warn!(
|
||||
target: "agent_prepare_context",
|
||||
task_id = %outcome.task_id,
|
||||
"[agent_prepare_context] scout unexpectedly awaited user input"
|
||||
);
|
||||
// Close the domain-event lifecycle too — a SubagentSpawned
|
||||
// was already published, so emit Completed to avoid a
|
||||
// dangling spawned state for event-bus consumers.
|
||||
publish_global(DomainEvent::SubagentCompleted {
|
||||
parent_session: parent_session.clone(),
|
||||
task_id: outcome.task_id.clone(),
|
||||
agent_id: outcome.agent_id.clone(),
|
||||
elapsed_ms: outcome.elapsed.as_millis() as u64,
|
||||
output_chars: 0,
|
||||
iterations: outcome.iterations,
|
||||
});
|
||||
if let Some(ref tx) = progress_sink {
|
||||
let _ = tx
|
||||
.send(AgentProgress::SubagentCompleted {
|
||||
agent_id: outcome.agent_id.clone(),
|
||||
task_id: outcome.task_id.clone(),
|
||||
elapsed_ms: outcome.elapsed.as_millis() as u64,
|
||||
iterations: outcome.iterations as u32,
|
||||
output_chars: 0,
|
||||
worktree_path: None,
|
||||
changed_files: Vec::new(),
|
||||
dirty_status: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(ToolResult::success(format!(
|
||||
"[context_bundle]\nhas_enough_context: false\n\
|
||||
summary: The context scout could not complete without clarification: {question}\n\
|
||||
recommended_tool_calls:\n[/context_bundle]"
|
||||
)))
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
let error_kind = message
|
||||
.split(':')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or("unknown");
|
||||
tracing::error!(
|
||||
target: "agent_prepare_context",
|
||||
error_kind = %error_kind,
|
||||
"[agent_prepare_context] context_scout run failed"
|
||||
);
|
||||
publish_global(DomainEvent::SubagentFailed {
|
||||
parent_session: parent_session.clone(),
|
||||
task_id: task_id.clone(),
|
||||
agent_id: definition.id.clone(),
|
||||
error: message.clone(),
|
||||
});
|
||||
if let Some(ref tx) = progress_sink {
|
||||
let _ = tx
|
||||
.send(AgentProgress::SubagentFailed {
|
||||
agent_id: definition.id.clone(),
|
||||
task_id: task_id.clone(),
|
||||
error: message.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(ToolResult::error(format!(
|
||||
"agent_prepare_context failed: {message}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the `context_scout` sub-agent to collect context and propose a plan.
|
||||
pub struct AgentPrepareContextTool;
|
||||
|
||||
@@ -160,202 +433,9 @@ impl Tool for AgentPrepareContextTool {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let question = args
|
||||
.get("question")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let focus = args
|
||||
.get("focus")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
tracing::info!(
|
||||
target: "agent_prepare_context",
|
||||
question_chars = question.chars().count(),
|
||||
has_focus = focus.as_deref().map(|f| !f.trim().is_empty()).unwrap_or(false),
|
||||
"[agent_prepare_context] invoked"
|
||||
);
|
||||
|
||||
if question.is_empty() {
|
||||
return Ok(ToolResult::error(
|
||||
"agent_prepare_context: `question` is required",
|
||||
));
|
||||
}
|
||||
|
||||
let registry = match AgentDefinitionRegistry::global() {
|
||||
Some(reg) => reg,
|
||||
None => {
|
||||
return Ok(ToolResult::error(
|
||||
"agent_prepare_context: AgentDefinitionRegistry has not been initialised.",
|
||||
));
|
||||
}
|
||||
};
|
||||
let definition = match registry.get(SCOUT_AGENT_ID) {
|
||||
Some(def) => def,
|
||||
None => {
|
||||
return Ok(ToolResult::error(format!(
|
||||
"agent_prepare_context: built-in agent `{SCOUT_AGENT_ID}` is not registered.",
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let tool_catalog = Self::render_parent_tool_catalog();
|
||||
let catalog_tool_count = tool_catalog.lines().filter(|l| !l.is_empty()).count();
|
||||
let scout_prompt = Self::build_scout_prompt(&question, focus.as_deref(), &tool_catalog);
|
||||
|
||||
tracing::debug!(
|
||||
target: "agent_prepare_context",
|
||||
catalog_tool_count,
|
||||
scout_prompt_chars = scout_prompt.chars().count(),
|
||||
"[agent_prepare_context] spawning context_scout (blocking)"
|
||||
);
|
||||
|
||||
let task_id = format!("ctx-{}", uuid::Uuid::new_v4());
|
||||
let parent_session = current_parent()
|
||||
.map(|p| p.session_id.clone())
|
||||
.unwrap_or_else(|| "standalone".into());
|
||||
let progress_sink = current_parent().and_then(|p| p.on_progress.clone());
|
||||
|
||||
// Surface the scout as a live subagent row in the parent thread. The
|
||||
// child's own iterations/tool-calls already stream to this sink from
|
||||
// inside run_subagent; we bookend them with spawned/completed so the
|
||||
// UI opens and closes the card. Best-effort — a closed sink is fine.
|
||||
publish_global(DomainEvent::SubagentSpawned {
|
||||
parent_session: parent_session.clone(),
|
||||
agent_id: definition.id.clone(),
|
||||
mode: "typed".to_string(),
|
||||
task_id: task_id.clone(),
|
||||
prompt_chars: scout_prompt.chars().count(),
|
||||
});
|
||||
if let Some(ref tx) = progress_sink {
|
||||
let _ = tx
|
||||
.send(AgentProgress::SubagentSpawned {
|
||||
agent_id: definition.id.clone(),
|
||||
task_id: task_id.clone(),
|
||||
mode: "typed".to_string(),
|
||||
dedicated_thread: false,
|
||||
prompt_chars: scout_prompt.chars().count(),
|
||||
worker_thread_id: None,
|
||||
display_name: Some(definition.display_name().to_string()),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let options = SubagentRunOptions {
|
||||
task_id: Some(task_id.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match run_subagent(definition, &scout_prompt, options).await {
|
||||
Ok(outcome) => match &outcome.status {
|
||||
SubagentRunStatus::Completed => {
|
||||
tracing::info!(
|
||||
target: "agent_prepare_context",
|
||||
task_id = %outcome.task_id,
|
||||
elapsed_ms = outcome.elapsed.as_millis() as u64,
|
||||
iterations = outcome.iterations,
|
||||
output_chars = outcome.output.chars().count(),
|
||||
"[agent_prepare_context] context bundle ready"
|
||||
);
|
||||
publish_global(DomainEvent::SubagentCompleted {
|
||||
parent_session: parent_session.clone(),
|
||||
task_id: outcome.task_id.clone(),
|
||||
agent_id: outcome.agent_id.clone(),
|
||||
elapsed_ms: outcome.elapsed.as_millis() as u64,
|
||||
output_chars: outcome.output.chars().count(),
|
||||
iterations: outcome.iterations,
|
||||
});
|
||||
if let Some(ref tx) = progress_sink {
|
||||
let _ = tx
|
||||
.send(AgentProgress::SubagentCompleted {
|
||||
agent_id: outcome.agent_id.clone(),
|
||||
task_id: outcome.task_id.clone(),
|
||||
elapsed_ms: outcome.elapsed.as_millis() as u64,
|
||||
iterations: outcome.iterations as u32,
|
||||
output_chars: outcome.output.chars().count(),
|
||||
worktree_path: None,
|
||||
changed_files: Vec::new(),
|
||||
dirty_status: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(ToolResult::success(outcome.output))
|
||||
}
|
||||
// The scout has no `ask_user_clarification` tool, so this
|
||||
// branch should not fire — handle defensively rather than
|
||||
// leaking a confusing checkpoint envelope to the parent.
|
||||
SubagentRunStatus::AwaitingUser { question, .. } => {
|
||||
tracing::warn!(
|
||||
target: "agent_prepare_context",
|
||||
task_id = %outcome.task_id,
|
||||
"[agent_prepare_context] scout unexpectedly awaited user input"
|
||||
);
|
||||
// Close the domain-event lifecycle too — a SubagentSpawned
|
||||
// was already published, so emit Completed to avoid a
|
||||
// dangling spawned state for event-bus consumers.
|
||||
publish_global(DomainEvent::SubagentCompleted {
|
||||
parent_session: parent_session.clone(),
|
||||
task_id: outcome.task_id.clone(),
|
||||
agent_id: outcome.agent_id.clone(),
|
||||
elapsed_ms: outcome.elapsed.as_millis() as u64,
|
||||
output_chars: 0,
|
||||
iterations: outcome.iterations,
|
||||
});
|
||||
if let Some(ref tx) = progress_sink {
|
||||
let _ = tx
|
||||
.send(AgentProgress::SubagentCompleted {
|
||||
agent_id: outcome.agent_id.clone(),
|
||||
task_id: outcome.task_id.clone(),
|
||||
elapsed_ms: outcome.elapsed.as_millis() as u64,
|
||||
iterations: outcome.iterations as u32,
|
||||
output_chars: 0,
|
||||
worktree_path: None,
|
||||
changed_files: Vec::new(),
|
||||
dirty_status: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(ToolResult::success(format!(
|
||||
"[context_bundle]\nhas_enough_context: false\n\
|
||||
summary: The context scout could not complete without clarification: {question}\n\
|
||||
recommended_tool_calls:\n[/context_bundle]"
|
||||
)))
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
let error_kind = message
|
||||
.split(':')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or("unknown");
|
||||
tracing::error!(
|
||||
target: "agent_prepare_context",
|
||||
error_kind = %error_kind,
|
||||
"[agent_prepare_context] context_scout run failed"
|
||||
);
|
||||
publish_global(DomainEvent::SubagentFailed {
|
||||
parent_session: parent_session.clone(),
|
||||
task_id: task_id.clone(),
|
||||
agent_id: definition.id.clone(),
|
||||
error: message.clone(),
|
||||
});
|
||||
if let Some(ref tx) = progress_sink {
|
||||
let _ = tx
|
||||
.send(AgentProgress::SubagentFailed {
|
||||
agent_id: definition.id.clone(),
|
||||
task_id: task_id.clone(),
|
||||
error: message.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(ToolResult::error(format!(
|
||||
"agent_prepare_context failed: {message}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
let question = args.get("question").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let focus = args.get("focus").and_then(|v| v.as_str());
|
||||
run_context_scout(question, focus).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,4 +491,54 @@ mod tests {
|
||||
assert!(result.is_error);
|
||||
assert!(result.output().contains("question"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_a_single_well_formed_bundle() {
|
||||
let out = "[context_bundle]\nhas_enough_context: true\nsummary: ok\n[/context_bundle]";
|
||||
assert!(is_well_formed_context_bundle(out));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_free_form_prose_without_a_bundle() {
|
||||
assert!(!is_well_formed_context_bundle(
|
||||
"Sure! Here's what I found about your request..."
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unterminated_or_reversed_envelope() {
|
||||
assert!(!is_well_formed_context_bundle(
|
||||
"[context_bundle]\nsummary: ..."
|
||||
));
|
||||
assert!(!is_well_formed_context_bundle(
|
||||
"[/context_bundle] stray [context_bundle]"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicated_envelope() {
|
||||
assert!(!is_well_formed_context_bundle(
|
||||
"[context_bundle]a[/context_bundle][context_bundle]b[/context_bundle]"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_prose_around_the_envelope() {
|
||||
// Leading prose before the bundle.
|
||||
assert!(!is_well_formed_context_bundle(
|
||||
"Sure, here's what I found:\n[context_bundle]\nsummary: x\n[/context_bundle]"
|
||||
));
|
||||
// Trailing prose after the bundle.
|
||||
assert!(!is_well_formed_context_bundle(
|
||||
"[context_bundle]\nsummary: x\n[/context_bundle]\nHope that helps!"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_envelope_with_surrounding_whitespace() {
|
||||
// Leading/trailing whitespace is trimmed, not treated as prose.
|
||||
assert!(is_well_formed_context_bundle(
|
||||
"\n [context_bundle]\nsummary: x\n[/context_bundle]\n "
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +55,11 @@ pub use sandbox::{
|
||||
pub use ui::{
|
||||
apply_analytics_settings, apply_browser_settings, apply_meet_settings,
|
||||
apply_screen_intelligence_settings, apply_search_settings, get_dictation_settings,
|
||||
get_onboarding_completed, get_search_settings, get_voice_server_settings,
|
||||
load_and_apply_analytics_settings, load_and_apply_browser_settings,
|
||||
get_onboarding_completed, get_search_settings, get_super_context_enabled,
|
||||
get_voice_server_settings, load_and_apply_analytics_settings, load_and_apply_browser_settings,
|
||||
load_and_apply_dictation_settings, load_and_apply_meet_settings,
|
||||
load_and_apply_screen_intelligence_settings, load_and_apply_search_settings,
|
||||
load_and_apply_voice_server_settings, set_onboarding_completed,
|
||||
load_and_apply_voice_server_settings, set_onboarding_completed, set_super_context_enabled,
|
||||
workspace_onboarding_flag_exists, workspace_onboarding_flag_resolve,
|
||||
workspace_onboarding_flag_set, AnalyticsSettingsPatch, BrowserSettingsPatch,
|
||||
DictationSettingsPatch, MeetSettingsPatch, ScreenIntelligenceSettingsPatch,
|
||||
|
||||
@@ -505,6 +505,35 @@ pub async fn set_onboarding_completed(value: bool) -> Result<RpcOutcome<bool>, S
|
||||
))
|
||||
}
|
||||
|
||||
/// Reads the "super context" toggle (`context.super_context_enabled`).
|
||||
///
|
||||
/// When on, the agent harness runs a mandatory read-only context-collection
|
||||
/// pass on the first turn of a new thread before the orchestrator LLM runs.
|
||||
/// Surfaced as the toggle below the chat composer.
|
||||
pub async fn get_super_context_enabled() -> Result<RpcOutcome<bool>, String> {
|
||||
let config = load_config_with_timeout().await?;
|
||||
Ok(RpcOutcome::single_log(
|
||||
config.context.super_context_enabled,
|
||||
"super_context_enabled read from config",
|
||||
))
|
||||
}
|
||||
|
||||
/// Updates and persists the "super context" toggle.
|
||||
///
|
||||
/// Read at thread/session construction, so the new value only takes effect
|
||||
/// for threads started after the change (matches the frozen turn-1 prefix
|
||||
/// contract).
|
||||
pub async fn set_super_context_enabled(value: bool) -> Result<RpcOutcome<bool>, String> {
|
||||
tracing::debug!(value, "[super_context] set_super_context_enabled called");
|
||||
let mut config = load_config_with_timeout().await?;
|
||||
config.context.super_context_enabled = value;
|
||||
config.save().await.map_err(|e| e.to_string())?;
|
||||
Ok(RpcOutcome::single_log(
|
||||
config.context.super_context_enabled,
|
||||
"super_context_enabled saved to config",
|
||||
))
|
||||
}
|
||||
|
||||
/// Returns the current dictation settings as a JSON object.
|
||||
pub async fn get_dictation_settings() -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let config = load_config_with_timeout().await?;
|
||||
|
||||
@@ -118,6 +118,25 @@ pub struct ContextConfig {
|
||||
/// See `compaction-plan.md`.
|
||||
#[serde(default = "default_true")]
|
||||
pub compaction_enabled: bool,
|
||||
|
||||
/// "Super context" mode. When `true`, the agent harness runs a
|
||||
/// mandatory read-only context-collection pass (the `context_scout`
|
||||
/// sub-agent, the same one behind the `agent_prepare_context` tool)
|
||||
/// on the **first turn** of a new thread, *before* the orchestrator
|
||||
/// LLM runs, and folds the resulting `[context_bundle]` into the user
|
||||
/// message. Unlike the `agent_prepare_context` tool — which the LLM
|
||||
/// chooses to call — this pass is driven by the harness regardless of
|
||||
/// the model's decision. The `agent_prepare_context` tool stays
|
||||
/// exposed so the LLM can still scout again mid-turn.
|
||||
///
|
||||
/// Read once at session/thread construction, so toggling it only
|
||||
/// affects threads started afterwards (the value is baked into the
|
||||
/// frozen turn-1 context). Default: `true`. Env override:
|
||||
/// `OPENHUMAN_SUPER_CONTEXT` (set to `0` to opt out). Surfaced in the
|
||||
/// UI as the "super context" toggle next to the chat composer's
|
||||
/// Quick/Reasoning mode switch, shown only on a fresh thread.
|
||||
#[serde(default = "default_true")]
|
||||
pub super_context_enabled: bool,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
@@ -162,6 +181,7 @@ impl Default for ContextConfig {
|
||||
summarizer_model: None,
|
||||
prefer_markdown_tool_output: default_true(),
|
||||
compaction_enabled: default_true(),
|
||||
super_context_enabled: default_true(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,6 +845,19 @@ impl Config {
|
||||
self.context.summarizer_model = Some(model.to_string());
|
||||
}
|
||||
}
|
||||
// "Super context" — harness-driven first-turn context collection.
|
||||
// On by default; `OPENHUMAN_SUPER_CONTEXT=0` opts out. Accepts
|
||||
// the canonical short name and the namespaced form.
|
||||
if let Some(flag) = env
|
||||
.get("OPENHUMAN_SUPER_CONTEXT")
|
||||
.or_else(|| env.get("OPENHUMAN_CONTEXT_SUPER_CONTEXT_ENABLED"))
|
||||
{
|
||||
match flag.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => self.context.super_context_enabled = true,
|
||||
"0" | "false" | "no" | "off" => self.context.super_context_enabled = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let context_default = crate::openhuman::context::DEFAULT_TOOL_RESULT_BUDGET_BYTES;
|
||||
let context_env_set = env.contains("OPENHUMAN_CONTEXT_TOOL_RESULT_BUDGET_BYTES");
|
||||
|
||||
@@ -1059,6 +1059,31 @@ fn env_overlay_compaction_default_on_and_kill_switch() {
|
||||
assert!(cfg.context.compaction_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_overlay_super_context_default_on_and_toggle() {
|
||||
// Default is on.
|
||||
assert!(Config::default().context.super_context_enabled);
|
||||
|
||||
// `OPENHUMAN_SUPER_CONTEXT=0` opts out.
|
||||
let mut cfg = Config::default();
|
||||
cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_SUPER_CONTEXT", "0"));
|
||||
assert!(!cfg.context.super_context_enabled);
|
||||
|
||||
// The namespaced alias works and `on` re-enables it.
|
||||
let mut cfg = Config::default();
|
||||
cfg.context.super_context_enabled = false;
|
||||
cfg.apply_env_overlay_with(
|
||||
&HashMapEnv::new().with("OPENHUMAN_CONTEXT_SUPER_CONTEXT_ENABLED", "on"),
|
||||
);
|
||||
assert!(cfg.context.super_context_enabled);
|
||||
|
||||
// Garbage is ignored (leaves the prior value untouched).
|
||||
let mut cfg = Config::default();
|
||||
cfg.context.super_context_enabled = false;
|
||||
cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_SUPER_CONTEXT", "maybe"));
|
||||
assert!(!cfg.context.super_context_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_overlay_context_tool_result_budget_legacy_migration_when_env_absent() {
|
||||
// Env absent, context at default, agent customised → agent value copies forward.
|
||||
|
||||
@@ -12,8 +12,8 @@ use super::helpers::{
|
||||
MeetSettingsUpdate, MemorySettingsUpdate, MemorySyncSettingsUpdate, ModelSettingsUpdate,
|
||||
OnboardingCompletedSetParams, RuntimeSettingsUpdate, SandboxSettingsUpdate,
|
||||
ScreenIntelligenceSettingsUpdate, SearchSettingsUpdate, SetBrowserAllowAllParams,
|
||||
VoiceServerSettingsUpdate, WorkspaceOnboardingFlagParams, WorkspaceOnboardingFlagSetParams,
|
||||
DEFAULT_ONBOARDING_FLAG_NAME,
|
||||
SuperContextSetParams, VoiceServerSettingsUpdate, WorkspaceOnboardingFlagParams,
|
||||
WorkspaceOnboardingFlagSetParams, DEFAULT_ONBOARDING_FLAG_NAME,
|
||||
};
|
||||
use super::schema_defs::schemas;
|
||||
|
||||
@@ -44,6 +44,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("update_agent_paths"),
|
||||
schemas("get_onboarding_completed"),
|
||||
schemas("set_onboarding_completed"),
|
||||
schemas("get_super_context_enabled"),
|
||||
schemas("set_super_context_enabled"),
|
||||
schemas("get_dictation_settings"),
|
||||
schemas("update_dictation_settings"),
|
||||
schemas("get_voice_server_settings"),
|
||||
@@ -167,6 +169,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("set_onboarding_completed"),
|
||||
handler: handle_set_onboarding_completed,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("get_super_context_enabled"),
|
||||
handler: handle_get_super_context_enabled,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("set_super_context_enabled"),
|
||||
handler: handle_set_super_context_enabled,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("get_dictation_settings"),
|
||||
handler: handle_get_dictation_settings,
|
||||
@@ -832,6 +842,17 @@ fn handle_set_onboarding_completed(params: Map<String, Value>) -> ControllerFutu
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_get_super_context_enabled(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async { to_json(config_rpc::get_super_context_enabled().await?) })
|
||||
}
|
||||
|
||||
fn handle_set_super_context_enabled(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<SuperContextSetParams>(params)?;
|
||||
to_json(config_rpc::set_super_context_enabled(payload.value).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_update_composio_trigger_settings(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
log::debug!("[config][rpc] update_composio_trigger_settings enter");
|
||||
|
||||
@@ -179,6 +179,11 @@ pub(super) struct OnboardingCompletedSetParams {
|
||||
pub(super) value: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct SuperContextSetParams {
|
||||
pub(super) value: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct DictationSettingsUpdate {
|
||||
pub(super) enabled: Option<bool>,
|
||||
|
||||
@@ -772,6 +772,38 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"get_super_context_enabled" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "get_super_context_enabled",
|
||||
description: "Read whether \"super context\" is enabled (harness runs a \
|
||||
read-only context-collection pass on the first turn of a new \
|
||||
thread before the orchestrator LLM runs).",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "enabled",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when super context is enabled.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"set_super_context_enabled" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "set_super_context_enabled",
|
||||
description: "Enable or disable \"super context\". Takes effect for threads \
|
||||
started after the change.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "value",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True to enable super context, false to disable.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "enabled",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Updated super-context enabled state.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"update_composio_trigger_settings" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "update_composio_trigger_settings",
|
||||
|
||||
@@ -182,6 +182,23 @@ fn autonomy_settings_rpc_is_registered() {
|
||||
assert!(funcs.contains(&"update_autonomy_settings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn super_context_rpc_is_registered() {
|
||||
let funcs: Vec<&str> = all_controller_schemas()
|
||||
.iter()
|
||||
.map(|s| s.function)
|
||||
.collect();
|
||||
assert!(funcs.contains(&"get_super_context_enabled"));
|
||||
assert!(funcs.contains(&"set_super_context_enabled"));
|
||||
// Handler registry must stay in lockstep with the schema list.
|
||||
let handlers: Vec<&str> = all_registered_controllers()
|
||||
.iter()
|
||||
.map(|h| h.schema.function)
|
||||
.collect();
|
||||
assert!(handlers.contains(&"get_super_context_enabled"));
|
||||
assert!(handlers.contains(&"set_super_context_enabled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_sync_settings_rpc_is_registered() {
|
||||
let funcs: Vec<&str> = all_controller_schemas()
|
||||
|
||||
@@ -125,6 +125,11 @@ pub struct ContextManager {
|
||||
/// kill-switch lives here so every caller reads one source of truth.
|
||||
/// See [`ContextConfig::compaction_enabled`].
|
||||
compaction_enabled: bool,
|
||||
/// When `true`, the harness runs a mandatory first-turn context
|
||||
/// collection pass before the orchestrator LLM runs. Read once at
|
||||
/// session construction so it only affects newly started threads.
|
||||
/// See [`ContextConfig::super_context_enabled`].
|
||||
super_context_enabled: bool,
|
||||
}
|
||||
|
||||
impl ContextManager {
|
||||
@@ -167,6 +172,7 @@ impl ContextManager {
|
||||
tool_result_budget_bytes: config.tool_result_budget_bytes,
|
||||
prefer_markdown_tool_output: config.prefer_markdown_tool_output,
|
||||
compaction_enabled: config.compaction_enabled,
|
||||
super_context_enabled: config.super_context_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +197,14 @@ impl ContextManager {
|
||||
self.compaction_enabled
|
||||
}
|
||||
|
||||
/// Whether "super context" is enabled — i.e. whether the harness
|
||||
/// should run a mandatory read-only context-collection pass on the
|
||||
/// first turn of a new thread before the orchestrator LLM runs.
|
||||
/// Read by `Agent::turn`. See [`ContextConfig::super_context_enabled`].
|
||||
pub fn super_context_enabled(&self) -> bool {
|
||||
self.super_context_enabled
|
||||
}
|
||||
|
||||
// ─── Budget tracking ──────────────────────────────────────────
|
||||
|
||||
/// Feed the latest provider [`UsageInfo`] into the guard + the
|
||||
|
||||
@@ -390,6 +390,29 @@ fn new_exposes_tool_budget_and_markdown_preference_from_config() {
|
||||
assert!(manager.prefer_markdown_tool_output());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn super_context_enabled_reflects_config() {
|
||||
// Default config: on.
|
||||
let on = ContextManager::new(
|
||||
&ContextConfig::default(),
|
||||
MockSummarizer::ok(),
|
||||
"m".into(),
|
||||
SystemPromptBuilder::with_defaults(),
|
||||
);
|
||||
assert!(on.super_context_enabled());
|
||||
|
||||
// Explicitly disabled in config → getter reports off.
|
||||
let mut config = ContextConfig::default();
|
||||
config.super_context_enabled = false;
|
||||
let off = ContextManager::new(
|
||||
&config,
|
||||
MockSummarizer::ok(),
|
||||
"m".into(),
|
||||
SystemPromptBuilder::with_defaults(),
|
||||
);
|
||||
assert!(!off.super_context_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_memory_lifecycle_changes_should_extract_state() {
|
||||
let summarizer = MockSummarizer::ok();
|
||||
|
||||
@@ -2805,11 +2805,13 @@ async fn worker_a_controller_schemas_are_fully_exposed() {
|
||||
"openhuman.config_get_runtime_flags",
|
||||
"openhuman.config_get_sandbox_settings",
|
||||
"openhuman.config_get_search_settings",
|
||||
"openhuman.config_get_super_context_enabled",
|
||||
"openhuman.config_get_voice_server_settings",
|
||||
"openhuman.config_reset_local_data",
|
||||
"openhuman.config_resolve_api_url",
|
||||
"openhuman.config_set_browser_allow_all",
|
||||
"openhuman.config_set_onboarding_completed",
|
||||
"openhuman.config_set_super_context_enabled",
|
||||
"openhuman.config_update_activity_level_settings",
|
||||
"openhuman.config_update_agent_paths",
|
||||
"openhuman.config_update_agent_settings",
|
||||
|
||||
Reference in New Issue
Block a user