fix(chat): support Hermes clarify batches
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

This commit is contained in:
2026-11-09 09:14:42 +08:00
parent 96aac671b3
commit f935385356
10 changed files with 233 additions and 10 deletions
@@ -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.
@@ -189,6 +189,11 @@ const queuedMessages = computed(() => {
const visibleApproval = computed(() => chatStore.activePendingApproval);
const visibleClarify = computed(() => chatStore.activePendingClarify);
const clarifyResponse = ref("");
const clarifySelections = ref<string[]>([]);
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({
</div>
<div class="approval-float-title">{{ t("chat.clarifyTitle") }}</div>
<div class="approval-float-desc">{{ visibleClarify.question }}</div>
<div v-if="visibleClarify.choices && visibleClarify.choices.length" class="approval-float-actions">
<a-checkbox-group v-if="visibleClarify.multiSelect && visibleClarify.choices?.length" v-model="clarifySelections" class="approval-float-actions">
<a-checkbox v-for="choice in visibleClarify.choices" :key="choice" :value="choice">{{ choice }}</a-checkbox>
</a-checkbox-group>
<div v-else-if="visibleClarify.choices && visibleClarify.choices.length" class="approval-float-actions">
<a-button
v-for="choice in visibleClarify.choices"
:key="choice"
@@ -839,19 +854,19 @@ defineExpose({
>
{{ choice }}
</a-button>
<a-button size="small" status="danger" type="secondary" @click="handleClarify('')">
{{ t("chat.clarifyDismiss") }}
</a-button>
</div>
<div v-else class="clarify-float-input-row">
<div class="clarify-float-input-row">
<a-input
v-model="clarifyResponse"
size="small"
:placeholder="t('chat.clarifyPlaceholder')"
/>
<a-button size="small" type="primary" @click="handleClarify()">
<a-button size="small" type="primary" @click="visibleClarify.multiSelect ? handleClarifyMulti() : handleClarify()">
{{ t("chat.clarifySubmit") }}
</a-button>
<a-button size="small" status="danger" type="secondary" @click="handleClarify('')">
{{ t("chat.clarifyDismiss") }}
</a-button>
</div>
</div>
</Transition>
@@ -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(),
})
@@ -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
@@ -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())
@@ -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) {
@@ -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)
+5
View File
@@ -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)) {
+35
View File
@@ -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"]' },
})
})
@@ -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 })
})
})