diff --git a/docs/chat-chain-changes/2026-11-09-clarify-callback-compat.md b/docs/chat-chain-changes/2026-11-09-clarify-callback-compat.md new file mode 100644 index 0000000..6e05560 --- /dev/null +++ b/docs/chat-chain-changes/2026-11-09-clarify-callback-compat.md @@ -0,0 +1,10 @@ +--- +date: 2026-11-09 +pr: pending +feature: Hermes batch clarify compatibility +impact: Structured questions are displayed and answered in order, with multiple-choice, multi-select and custom responses. Legacy single-question callbacks remain supported. +--- + +The bridge launches a local adapter around the read-only vendored bridge. It +maps Hermes batch questions to the existing per-question events and returns the +structured answers expected by newer Hermes releases. diff --git a/packages/client/src/components/hermes/chat/MessageList.vue b/packages/client/src/components/hermes/chat/MessageList.vue index e470972..68c217e 100644 --- a/packages/client/src/components/hermes/chat/MessageList.vue +++ b/packages/client/src/components/hermes/chat/MessageList.vue @@ -189,6 +189,11 @@ const queuedMessages = computed(() => { const visibleApproval = computed(() => chatStore.activePendingApproval); const visibleClarify = computed(() => chatStore.activePendingClarify); const clarifyResponse = ref(""); +const clarifySelections = ref([]); +watch(() => visibleClarify.value?.clarifyId, () => { + clarifyResponse.value = ""; + clarifySelections.value = []; +}); const hasFloatingPrompt = computed(() => !!visibleApproval.value || !!visibleClarify.value); const virtualListPadding = computed(() => { if (queuedMessages.value.length > 0 && hasFloatingPrompt.value) return "20px 20px 380px"; @@ -242,6 +247,13 @@ function handleClarify(response?: string) { clarifyResponse.value = ""; } +function handleClarifyMulti() { + const responses = [...clarifySelections.value]; + const custom = clarifyResponse.value.trim(); + if (custom) responses.push(custom); + handleClarify(responses.length ? JSON.stringify(responses) : ''); +} + function removeQueuedMessage(messageId: string) { const sid = chatStore.activeSessionId; if (!sid) return; @@ -829,7 +841,10 @@ defineExpose({
{{ t("chat.clarifyTitle") }}
{{ visibleClarify.question }}
-
+ + {{ choice }} + +
{{ choice }} - - {{ t("chat.clarifyDismiss") }} -
-
+
- + {{ t("chat.clarifySubmit") }} + + {{ t("chat.clarifyDismiss") }} +
diff --git a/packages/client/src/stores/hermes/chat.ts b/packages/client/src/stores/hermes/chat.ts index d23765a..0852888 100644 --- a/packages/client/src/stores/hermes/chat.ts +++ b/packages/client/src/stores/hermes/chat.ts @@ -88,6 +88,7 @@ export interface PendingClarify { clarifyId: string question: string choices: string[] | null + multiSelect: boolean timeoutMs: number requestedAt: number } @@ -2113,6 +2114,7 @@ export const useChatStore = defineStore('chat', () => { clarifyId, question: String((evt as any).question || ''), choices: Array.isArray((evt as any).choices) ? (evt as any).choices : null, + multiSelect: !!(evt as any).multi_select, timeoutMs: Number((evt as any).timeout_ms) || 300000, requestedAt: Date.now(), }) diff --git a/packages/server/src/services/hermes/agent-bridge/compat/clarify_compat.py b/packages/server/src/services/hermes/agent-bridge/compat/clarify_compat.py new file mode 100644 index 0000000..6fca9da --- /dev/null +++ b/packages/server/src/services/hermes/agent-bridge/compat/clarify_compat.py @@ -0,0 +1,41 @@ +"""Adapt Hermes batch clarify callbacks to the bridge's per-question UI.""" +from __future__ import annotations + +import queue +import uuid + + +def make_clarify_callback(pool, session_id, legacy_callback): + def callback(questions, choices=None): + # Older Hermes versions call callback(question, choices) and expect text. + if not isinstance(questions, list): + return legacy_callback(questions, choices) + + answers = {} + for index, item in enumerate(questions): + if not isinstance(item, dict): + raise ValueError('invalid clarify question') + question_id = str(item.get('qid') or f'q{index}') + clarify_id = uuid.uuid4().hex + response_queue = queue.Queue(maxsize=1) + with pool._lock: + pool._clarify_requests[clarify_id] = response_queue + try: + pool._append_event(session_id, { + 'event': 'clarify.requested', + 'clarify_id': clarify_id, + 'question': str(item.get('question') or ''), + 'choices': item.get('choices') or None, + 'multi_select': bool(item.get('multi_select')), + 'timeout_ms': 300_000, + }) + try: + response = response_queue.get(timeout=300) + except queue.Empty: + return {'answers': answers, 'outcome': 'timed_out'} + answers[question_id] = response or None + finally: + with pool._lock: + pool._clarify_requests.pop(clarify_id, None) + return {'answers': answers, 'outcome': 'submitted'} + return callback diff --git a/packages/server/src/services/hermes/agent-bridge/compat/hermes_bridge.py b/packages/server/src/services/hermes/agent-bridge/compat/hermes_bridge.py new file mode 100644 index 0000000..a362a2b --- /dev/null +++ b/packages/server/src/services/hermes/agent-bridge/compat/hermes_bridge.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Launch the read-only vendored bridge with local compatibility adapters.""" +from pathlib import Path +import sys + +compat_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(compat_dir)) +sys.path.insert(0, str(compat_dir.parent / 'python')) + +import hermes_bridge as bridge +from clarify_compat import make_clarify_callback + +original_callback = bridge.AgentPool._clarify_callback + + +def clarify_callback(self, session_id): + return make_clarify_callback(self, session_id, original_callback(self, session_id)) + + +bridge.AgentPool._clarify_callback = clarify_callback +# Workers must launch this same compatibility entrypoint, not the vendored one. +bridge._transport.__file__ = __file__ + +if __name__ == '__main__': + raise SystemExit(bridge.main()) diff --git a/packages/server/src/services/hermes/agent-bridge/manager.ts b/packages/server/src/services/hermes/agent-bridge/manager.ts index bd63ec4..78c9dc6 100644 --- a/packages/server/src/services/hermes/agent-bridge/manager.ts +++ b/packages/server/src/services/hermes/agent-bridge/manager.ts @@ -332,11 +332,11 @@ export function buildAgentBridgeLaunchArgs(command: BridgeCommand, script: strin function bridgeScriptPath(): string { const candidates = [ - // Built server: dist/server/index.js -> dist/server/agent-bridge/python/hermes_bridge.py - resolve(__dirname, 'agent-bridge', 'python', 'hermes_bridge.py'), + // Built server: dist/server/index.js -> dist/server/agent-bridge/compat/hermes_bridge.py + resolve(__dirname, 'agent-bridge', 'compat', 'hermes_bridge.py'), // ts-node/dev source tree. - resolve(__dirname, 'services/hermes/agent-bridge/python/hermes_bridge.py'), - resolve(process.cwd(), 'packages/server/src/services/hermes/agent-bridge/python/hermes_bridge.py'), + resolve(__dirname, 'services/hermes/agent-bridge/compat/hermes_bridge.py'), + resolve(process.cwd(), 'packages/server/src/services/hermes/agent-bridge/compat/hermes_bridge.py'), ] const found = candidates.find(candidate => existsSync(candidate)) if (!found) { diff --git a/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts index cda333a..501e942 100644 --- a/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts +++ b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts @@ -1202,6 +1202,7 @@ async function applyBridgeChunkAsync( clarify_id: ev.clarify_id, question: ev.question, choices: Array.isArray(ev.choices) ? ev.choices : null, + multi_select: Boolean(ev.multi_select), timeout_ms: ev.timeout_ms, } replaceState(sessionMap, sessionId, 'clarify.requested', payload) diff --git a/scripts/build-server.mjs b/scripts/build-server.mjs index c5a8309..9b0eb44 100644 --- a/scripts/build-server.mjs +++ b/scripts/build-server.mjs @@ -37,6 +37,11 @@ for (const fileName of readdirSync(bridgeSrcDir)) { } } chmodSync(resolve(bridgeOutDir, 'hermes_bridge.py'), 0o755) +cpSync( + resolve(rootDir, 'packages/server/src/services/hermes/agent-bridge/compat'), + resolve(serverOutDir, 'agent-bridge', 'compat'), + { recursive: true, filter: source => !source.includes('__pycache__') }, +) const serverAssetsSrcDir = resolve(rootDir, 'packages/server/src/assets') if (existsSync(serverAssetsSrcDir)) { diff --git a/tests/e2e/chat-clarify.spec.ts b/tests/e2e/chat-clarify.spec.ts new file mode 100644 index 0000000..552f310 --- /dev/null +++ b/tests/e2e/chat-clarify.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test' +import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures' + +test('answers sequential clarify questions with custom and multi-select responses', async ({ page }) => { + await authenticate(page, TEST_ACCESS_KEY, 'research') + await mockHermesApi(page) + await mockChatSocket(page) + await page.goto('/#/hermes/chat') + await page.locator('textarea.input-textarea').fill('Ask me questions') + await page.getByRole('button', { name: 'Send', exact: true }).click() + const sessionId = await page.waitForFunction(() => (window as any).__PW_CHAT_SOCKET__?.emitted.find((item: any) => item.event === 'run')?.payload.session_id).then(handle => handle.jsonValue()) + await page.evaluate((sid) => { + const socket = (window as any).__PW_CHAT_SOCKET__.latest + socket.__trigger('clarify.requested', { event: 'clarify.requested', session_id: sid, clarify_id: 'q1', question: 'Which day?', choices: ['Today', 'Yesterday'], multi_select: false }) + }, sessionId) + await expect(page.getByText('Which day?')).toBeVisible() + await page.getByPlaceholder('Type your answer...').fill('Another day') + await page.getByRole('button', { name: 'Reply', exact: true }).click() + await expect.poll(() => page.evaluate(() => (window as any).__PW_CHAT_SOCKET__.emitted.filter((e: any) => e.event === 'clarify.respond'))).toContainEqual({ + event: 'clarify.respond', payload: { session_id: sessionId, clarify_id: 'q1', response: 'Another day' }, + }) + await page.evaluate((sid) => { + const socket = (window as any).__PW_CHAT_SOCKET__.latest + socket.__trigger('clarify.requested', { event: 'clarify.requested', session_id: sid, clarify_id: 'q2', question: 'Which topics?', choices: ['News', 'Science'], multi_select: true }) + socket.__trigger('clarify.resolved', { event: 'clarify.resolved', session_id: sid, clarify_id: 'q1', resolved: true }) + }, sessionId) + await expect(page.getByText('Which topics?')).toBeVisible() + await page.getByText('News', { exact: true }).click() + await page.getByText('Science', { exact: true }).click() + await page.getByPlaceholder('Type your answer...').fill('Other') + await page.getByRole('button', { name: 'Reply', exact: true }).click() + await expect.poll(() => page.evaluate(() => (window as any).__PW_CHAT_SOCKET__.emitted.filter((e: any) => e.event === 'clarify.respond'))).toContainEqual({ + event: 'clarify.respond', payload: { session_id: sessionId, clarify_id: 'q2', response: '["News","Science","Other"]' }, + }) +}) diff --git a/tests/server/agent-bridge-clarify-compat.test.ts b/tests/server/agent-bridge-clarify-compat.test.ts new file mode 100644 index 0000000..9badf9a --- /dev/null +++ b/tests/server/agent-bridge-clarify-compat.test.ts @@ -0,0 +1,89 @@ +import { execFileSync } from 'node:child_process' +import { describe, it } from 'vitest' + +describe('Hermes clarify bridge compatibility', () => { + it('supports legacy callbacks, batches, multi-select, timeouts, and worker entrypoints', () => { + const script = String.raw` +import importlib.util, json, queue, sys, threading +from pathlib import Path + +root = Path('packages/server/src/services/hermes/agent-bridge/compat').resolve() +sys.path.insert(0, str(root)) +from clarify_compat import make_clarify_callback + +class Pool: + def __init__(self, replies): + self._lock = threading.RLock() + self._clarify_requests = {} + self.events = [] + self.replies = iter(replies) + def _append_event(self, session, event): + self.events.append((session, event)) + self._clarify_requests[event['clarify_id']].put_nowait(next(self.replies)) + +pool = Pool(['first', json.dumps(['A', 'Other']), '']) +callback = make_clarify_callback(pool, 'session-1', lambda q, c: f'legacy:{q}:{c[0]}') +assert callback('old question', ['old choice']) == 'legacy:old question:old choice' +result = callback([ + {'qid': 'q0', 'question': 'First?', 'choices': ['first', 'second']}, + {'qid': 'q1', 'question': 'Pick several', 'choices': ['A', 'B'], 'multi_select': True}, + {'qid': 'q2', 'question': 'Optional question'}, +]) +assert result == {'answers': {'q0': 'first', 'q1': '["A", "Other"]', 'q2': None}, 'outcome': 'submitted'} +assert [event['question'] for _, event in pool.events] == ['First?', 'Pick several', 'Optional question'] +assert [event['multi_select'] for _, event in pool.events] == [False, True, False] +assert not pool._clarify_requests + +class TimeoutQueue: + def __init__(self, maxsize): pass + def get(self, timeout): raise queue.Empty() +pool = Pool([]) +pool._append_event = lambda session, event: None +import clarify_compat +clarify_compat.queue.Queue = TimeoutQueue +assert make_clarify_callback(pool, 's', lambda *a: None)([{'qid': 'q0', 'question': 'timeout'}]) == {'answers': {}, 'outcome': 'timed_out'} +assert not pool._clarify_requests + +spec = importlib.util.spec_from_file_location('compat_entry', root / 'hermes_bridge.py') +entry = importlib.util.module_from_spec(spec) +spec.loader.exec_module(entry) +assert Path(entry.bridge._transport.__file__).resolve() == root / 'hermes_bridge.py' +assert entry.bridge.AgentPool._clarify_callback is entry.clarify_callback +` + execFileSync('python3', ['-c', script], { cwd: process.cwd(), encoding: 'utf8', stdio: 'pipe' }) + }) + + it('starts a broker and profile worker through the compatibility entrypoint', () => { + const script = String.raw` +import json, os, socket, subprocess, sys, tempfile, time +from pathlib import Path +entry = Path('packages/server/src/services/hermes/agent-bridge/compat/hermes_bridge.py').resolve() +with tempfile.TemporaryDirectory(prefix='clarify-bridge-test-') as temp: + root = Path(temp) + (root / 'run_agent.py').write_text('') + endpoint = 'ipc://' + str(root / 'broker.sock') + env = dict(os.environ, HERMES_HOME=str(root / 'home'), HERMES_AGENT_ROOT=str(root), PYTHONDONTWRITEBYTECODE='1') + proc = subprocess.Popen([sys.executable, str(entry), '--endpoint', endpoint, '--agent-root', str(root), '--hermes-home', str(root / 'home')], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + deadline = time.monotonic() + 15 + while not (root / 'broker.sock').exists(): + if proc.poll() is not None or time.monotonic() > deadline: + raise RuntimeError('compatibility broker failed to start') + time.sleep(0.05) + with socket.socket(socket.AF_UNIX) as client: + client.settimeout(20) + client.connect(str(root / 'broker.sock')) + client.sendall(b'{"action":"worker_ping","profile":"default"}\n') + with client.makefile('rb') as response: + result = json.loads(response.readline()) + assert result.get('ok') and result.get('pong') and result.get('worker_profile') == 'default', result + finally: + proc.terminate() + try: proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) +` + execFileSync('python3', ['-c', script], { cwd: process.cwd(), encoding: 'utf8', stdio: 'pipe', timeout: 30_000 }) + }) +})