thinkpool-pair 0.7.245 → 0.7.247
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge.mjs +99 -60
- package/event-id.mjs +231 -0
- package/flow-models.mjs +26 -2
- package/flow-task-graph.mjs +3 -3
- package/hermes-acp-bootstrap.py +196 -0
- package/hermes-policy.mjs +85 -0
- package/hermes-probe.mjs +32 -2
- package/hermes-session.mjs +62 -13
- package/package.json +6 -1
- package/review-check.mjs +155 -0
- package/runtime-registry.mjs +1 -1
- package/sdk-admission.mjs +9 -0
- package/session-store.mjs +84 -13
package/event-id.mjs
CHANGED
|
@@ -210,6 +210,237 @@ export function inlineImageBlocks(evt) {
|
|
|
210
210
|
return out
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
// One ordered emission boundary for structured-session events while agent screenshots
|
|
214
|
+
// are lifted out of inline base64. This stays import-safe: bridge.mjs owns room/auth
|
|
215
|
+
// state; these definitions own deadlines, bounded reservations, ordering, and teardown.
|
|
216
|
+
export const SCREENSHOT_OMITTED = '[screenshot omitted — upload failed]'
|
|
217
|
+
export const DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS = 15_000
|
|
218
|
+
export const DEFAULT_IMAGE_QUEUE_MAX_PENDING = 4
|
|
219
|
+
export const DEFAULT_IMAGE_QUEUE_MAX_BYTES = 16_000_000
|
|
220
|
+
|
|
221
|
+
const sanePositiveInt = (value, fallback) => {
|
|
222
|
+
const n = Number(value)
|
|
223
|
+
return Number.isSafeInteger(n) && n > 0 ? n : fallback
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function imageQueueConfig(env = process.env) {
|
|
227
|
+
return {
|
|
228
|
+
uploadTimeoutMs: sanePositiveInt(env.TP_CODE_IMAGE_UPLOAD_TIMEOUT_MS, DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS),
|
|
229
|
+
maxPending: sanePositiveInt(env.TP_CODE_IMAGE_QUEUE_MAX_PENDING, DEFAULT_IMAGE_QUEUE_MAX_PENDING),
|
|
230
|
+
maxBytes: sanePositiveInt(env.TP_CODE_IMAGE_QUEUE_MAX_BYTES, DEFAULT_IMAGE_QUEUE_MAX_BYTES),
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Deadline covers fetch AND response parsing. Promise.race is deliberate: AbortSignal is
|
|
235
|
+
// still passed to real fetch, but an injected/broken fetch that ignores it cannot pin the
|
|
236
|
+
// ordered queue forever. activeUploads lets session teardown cancel the deadline now.
|
|
237
|
+
export async function uploadCodeImage({
|
|
238
|
+
webBase,
|
|
239
|
+
room,
|
|
240
|
+
authToken,
|
|
241
|
+
term,
|
|
242
|
+
cid,
|
|
243
|
+
image,
|
|
244
|
+
fetchImpl = globalThis.fetch,
|
|
245
|
+
timeoutMs = DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS,
|
|
246
|
+
activeUploads = new Set(),
|
|
247
|
+
}) {
|
|
248
|
+
const controller = new AbortController()
|
|
249
|
+
let timer = null
|
|
250
|
+
let rejectCancel = null
|
|
251
|
+
let settled = false
|
|
252
|
+
|
|
253
|
+
const cancelled = new Promise((_, reject) => { rejectCancel = reject })
|
|
254
|
+
const handle = {
|
|
255
|
+
cancel(reason = new Error('code-image upload cancelled')) {
|
|
256
|
+
if (settled) return
|
|
257
|
+
if (timer) { clearTimeout(timer); timer = null }
|
|
258
|
+
try { controller.abort(reason) } catch { /* AbortController is best-effort */ }
|
|
259
|
+
rejectCancel(reason instanceof Error ? reason : new Error(String(reason)))
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
activeUploads.add(handle)
|
|
263
|
+
timer = setTimeout(() => handle.cancel(new Error(`code-image upload timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
264
|
+
timer.unref?.()
|
|
265
|
+
|
|
266
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
267
|
+
if (authToken) headers.Authorization = `Bearer ${authToken}`
|
|
268
|
+
const request = (async () => {
|
|
269
|
+
const res = await fetchImpl(`${webBase}/api/code-image`, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers,
|
|
272
|
+
signal: controller.signal,
|
|
273
|
+
body: JSON.stringify({ code: room, term, cid, idx: image.idx, mediaType: image.mediaType, b64: image.b64 }),
|
|
274
|
+
})
|
|
275
|
+
if (!res.ok) throw new Error(`code-image ${res.status}`)
|
|
276
|
+
const { path } = await res.json()
|
|
277
|
+
if (!path) throw new Error('code-image: no path')
|
|
278
|
+
return path
|
|
279
|
+
})()
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
return await Promise.race([request, cancelled])
|
|
283
|
+
} finally {
|
|
284
|
+
settled = true
|
|
285
|
+
if (timer) clearTimeout(timer)
|
|
286
|
+
activeUploads.delete(handle)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const encodedImageBytes = (image) => Buffer.byteLength(String(image?.b64 || ''), 'ascii')
|
|
291
|
+
|
|
292
|
+
function stripRawImages(evt, images, placeholder) {
|
|
293
|
+
for (const image of images) {
|
|
294
|
+
const block = Array.isArray(evt?.content) ? evt.content[image.idx] : null
|
|
295
|
+
if (block?.source?.type === 'base64' || image.b64) {
|
|
296
|
+
evt.content[image.idx] = { type: 'text', text: placeholder }
|
|
297
|
+
}
|
|
298
|
+
image.b64 = ''
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export class ImageEventQueue {
|
|
303
|
+
constructor({
|
|
304
|
+
upload,
|
|
305
|
+
maxPending = DEFAULT_IMAGE_QUEUE_MAX_PENDING,
|
|
306
|
+
maxBytes = DEFAULT_IMAGE_QUEUE_MAX_BYTES,
|
|
307
|
+
placeholder = SCREENSHOT_OMITTED,
|
|
308
|
+
onUploadError = () => {},
|
|
309
|
+
onOverload = () => {},
|
|
310
|
+
}) {
|
|
311
|
+
if (typeof upload !== 'function') throw new TypeError('ImageEventQueue requires upload')
|
|
312
|
+
this.upload = upload
|
|
313
|
+
this.maxPending = sanePositiveInt(maxPending, DEFAULT_IMAGE_QUEUE_MAX_PENDING)
|
|
314
|
+
this.maxBytes = sanePositiveInt(maxBytes, DEFAULT_IMAGE_QUEUE_MAX_BYTES)
|
|
315
|
+
this.placeholder = placeholder
|
|
316
|
+
this.onUploadError = onUploadError
|
|
317
|
+
this.onOverload = onOverload
|
|
318
|
+
this.pendingCount = 0
|
|
319
|
+
this.pendingBytes = 0
|
|
320
|
+
this.peakPendingCount = 0
|
|
321
|
+
this.peakPendingBytes = 0
|
|
322
|
+
this.activeUploads = new Set()
|
|
323
|
+
this.tasks = new Set()
|
|
324
|
+
this.tail = Promise.resolve()
|
|
325
|
+
this.closed = false
|
|
326
|
+
this.overloadNotified = false
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
stats() {
|
|
330
|
+
return {
|
|
331
|
+
pendingCount: this.pendingCount,
|
|
332
|
+
pendingBytes: this.pendingBytes,
|
|
333
|
+
peakPendingCount: this.peakPendingCount,
|
|
334
|
+
peakPendingBytes: this.peakPendingBytes,
|
|
335
|
+
activeUploads: this.activeUploads.size,
|
|
336
|
+
queuedEvents: this.tasks.size,
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
enqueue(evt, images, emitTail) {
|
|
341
|
+
if (this.closed || typeof emitTail !== 'function') return false
|
|
342
|
+
const imgs = Array.isArray(images) ? images : []
|
|
343
|
+
const count = imgs.length
|
|
344
|
+
const bytes = imgs.reduce((sum, image) => sum + encodedImageBytes(image), 0)
|
|
345
|
+
const accepted = count > 0 &&
|
|
346
|
+
this.pendingCount + count <= this.maxPending &&
|
|
347
|
+
this.pendingBytes + bytes <= this.maxBytes
|
|
348
|
+
|
|
349
|
+
if (count && accepted) {
|
|
350
|
+
for (const image of imgs) {
|
|
351
|
+
image._queueBytes = encodedImageBytes(image)
|
|
352
|
+
image._queueReserved = true
|
|
353
|
+
}
|
|
354
|
+
this.pendingCount += count
|
|
355
|
+
this.pendingBytes += bytes
|
|
356
|
+
this.peakPendingCount = Math.max(this.peakPendingCount, this.pendingCount)
|
|
357
|
+
this.peakPendingBytes = Math.max(this.peakPendingBytes, this.pendingBytes)
|
|
358
|
+
} else if (count) {
|
|
359
|
+
// Critical memory boundary: discard raw base64 BEFORE this event can join a
|
|
360
|
+
// chain blocked behind an earlier upload.
|
|
361
|
+
stripRawImages(evt, imgs, this.placeholder)
|
|
362
|
+
if (!this.overloadNotified) {
|
|
363
|
+
this.overloadNotified = true
|
|
364
|
+
this.onOverload({ count, bytes, maxPending: this.maxPending, maxBytes: this.maxBytes })
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Preserve the old synchronous fast path when no image upload is ahead of us.
|
|
369
|
+
if (!accepted && this.tasks.size === 0) {
|
|
370
|
+
emitTail(evt)
|
|
371
|
+
return true
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const task = { evt, images: imgs, emitTail, accepted, emitted: false, done: false }
|
|
375
|
+
this.tasks.add(task)
|
|
376
|
+
this.tail = this.tail.catch(() => {}).then(() => this._run(task))
|
|
377
|
+
return true
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async _run(task) {
|
|
381
|
+
if (task.done || this.closed) return
|
|
382
|
+
try {
|
|
383
|
+
if (task.accepted) {
|
|
384
|
+
for (const image of task.images) {
|
|
385
|
+
if (!image._queueReserved || this.closed) continue
|
|
386
|
+
try {
|
|
387
|
+
const path = await this.upload(task.evt, image, this.activeUploads)
|
|
388
|
+
task.evt.content[image.idx] = { type: 'image', path, mediaType: image.mediaType }
|
|
389
|
+
} catch (error) {
|
|
390
|
+
task.evt.content[image.idx] = { type: 'text', text: this.placeholder }
|
|
391
|
+
if (!this.closed) this.onUploadError(error)
|
|
392
|
+
} finally {
|
|
393
|
+
image.b64 = ''
|
|
394
|
+
this._release(image)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (!this.closed && !task.emitted) {
|
|
399
|
+
task.emitTail(task.evt)
|
|
400
|
+
task.emitted = true
|
|
401
|
+
}
|
|
402
|
+
} finally {
|
|
403
|
+
task.done = true
|
|
404
|
+
this.tasks.delete(task)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
_release(image) {
|
|
409
|
+
if (!image?._queueReserved) return
|
|
410
|
+
image._queueReserved = false
|
|
411
|
+
this.pendingCount = Math.max(0, this.pendingCount - 1)
|
|
412
|
+
this.pendingBytes = Math.max(0, this.pendingBytes - (image._queueBytes || 0))
|
|
413
|
+
image._queueBytes = 0
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async idle() {
|
|
417
|
+
await this.tail.catch(() => {})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Session close/provider switch/shutdown is synchronous. Cancel active deadlines,
|
|
421
|
+
// replace any still-raw image, and flush queued tails in insertion order before the
|
|
422
|
+
// owner persists/deletes the entry. Later promise continuations see done/closed.
|
|
423
|
+
close({ flush = true } = {}) {
|
|
424
|
+
if (this.closed) return
|
|
425
|
+
this.closed = true
|
|
426
|
+
const reason = new Error('code-image queue closed')
|
|
427
|
+
for (const upload of this.activeUploads) upload.cancel(reason)
|
|
428
|
+
this.activeUploads.clear()
|
|
429
|
+
for (const task of this.tasks) {
|
|
430
|
+
stripRawImages(task.evt, task.images, this.placeholder)
|
|
431
|
+
for (const image of task.images) this._release(image)
|
|
432
|
+
if (flush && !task.emitted) {
|
|
433
|
+
try { task.emitTail(task.evt) } catch { /* teardown must continue */ }
|
|
434
|
+
task.emitted = true
|
|
435
|
+
}
|
|
436
|
+
task.done = true
|
|
437
|
+
}
|
|
438
|
+
this.tasks.clear()
|
|
439
|
+
this.pendingCount = 0
|
|
440
|
+
this.pendingBytes = 0
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
213
444
|
// Pure aggregation for the /usage report — replay a session's retained log events and
|
|
214
445
|
// fold every completed turn (kind:'result' with usage) into per-model + total token/cost
|
|
215
446
|
// sums. No side effects, import-safe (bridge/test-usage-summary.mjs exercises it), so the
|
package/flow-models.mjs
CHANGED
|
@@ -11,10 +11,11 @@ const CLAUDE_TIERS = {
|
|
|
11
11
|
|
|
12
12
|
const CODEX_SCAFFOLD = ['gpt-5.6-luna', 'gpt-5.4-mini', 'gpt-5.3-codex-spark']
|
|
13
13
|
const CODEX_BALANCED = ['gpt-5.6-terra', 'gpt-5.4']
|
|
14
|
+
const HERMES_REVIEW = ['nous:anthropic/claude-sonnet-4.6', 'nous:anthropic/claude-sonnet-4.5', 'nous:anthropic/claude-sonnet-4']
|
|
14
15
|
|
|
15
16
|
export function normalizeFlowRuntime(runtime, fallback = null) {
|
|
16
|
-
if (runtime === 'claude' || runtime === 'codex') return runtime
|
|
17
|
-
return fallback === 'claude' || fallback === 'codex' ? fallback : null
|
|
17
|
+
if (runtime === 'claude' || runtime === 'codex' || runtime === 'hermes') return runtime
|
|
18
|
+
return fallback === 'claude' || fallback === 'codex' || fallback === 'hermes' ? fallback : null
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export function modelCatalogValues(catalog = []) {
|
|
@@ -32,6 +33,12 @@ function firstVisible(candidates, catalog) {
|
|
|
32
33
|
return undefined
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
function firstVisibleMatching(catalog, patterns) {
|
|
37
|
+
const visible = modelCatalogValues(catalog)
|
|
38
|
+
for (const pattern of patterns) for (const model of visible) if (pattern.test(model)) return model
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
export function flowLaneModelFor({ sliceType, runtime = 'claude', catalog = [], env = process.env } = {}) {
|
|
36
43
|
if (normalizeFlowRuntime(runtime, 'claude') === 'codex') {
|
|
37
44
|
const override = env.TP_FLOW_CODEX_LANE_MODEL
|
|
@@ -39,6 +46,16 @@ export function flowLaneModelFor({ sliceType, runtime = 'claude', catalog = [],
|
|
|
39
46
|
if (override) return modelCatalogValues(catalog).has(override) ? override : undefined
|
|
40
47
|
return firstVisible(sliceType === 'scaffold' ? CODEX_SCAFFOLD : CODEX_BALANCED, catalog)
|
|
41
48
|
}
|
|
49
|
+
if (normalizeFlowRuntime(runtime, 'claude') === 'hermes') {
|
|
50
|
+
const override = env.TP_FLOW_HERMES_LANE_MODEL
|
|
51
|
+
const visible = modelCatalogValues(catalog)
|
|
52
|
+
if (override === 'inherit') return undefined
|
|
53
|
+
if (override) return visible.has(override) ? override : undefined
|
|
54
|
+
if (sliceType === 'review') return firstVisible(HERMES_REVIEW, catalog)
|
|
55
|
+
|| firstVisibleMatching(catalog, [/terra/i, /sonnet/i, /balanced/i])
|
|
56
|
+
if (sliceType === 'scaffold') return firstVisibleMatching(catalog, [/(?:luna|mini)/i])
|
|
57
|
+
return firstVisibleMatching(catalog, [/terra/i, /(?:luna|mini)/i])
|
|
58
|
+
}
|
|
42
59
|
const override = env.TP_FLOW_CLAUDE_LANE_MODEL || env.TP_FLOW_LANE_MODEL
|
|
43
60
|
if (override === 'inherit') return undefined
|
|
44
61
|
if (override) return override
|
|
@@ -53,6 +70,13 @@ export function flowConductorModelFor({ runtime = 'claude', originModel, catalog
|
|
|
53
70
|
if (override) return visible.has(override) ? override : undefined
|
|
54
71
|
return originModel && visible.has(originModel) ? originModel : undefined
|
|
55
72
|
}
|
|
73
|
+
if (normalizeFlowRuntime(runtime, 'claude') === 'hermes') {
|
|
74
|
+
const override = env.TP_FLOW_HERMES_CONDUCTOR_MODEL
|
|
75
|
+
const visible = modelCatalogValues(catalog)
|
|
76
|
+
if (override === 'inherit') return undefined
|
|
77
|
+
if (override) return visible.has(override) ? override : undefined
|
|
78
|
+
return originModel && visible.has(originModel) ? originModel : undefined
|
|
79
|
+
}
|
|
56
80
|
const override = env.TP_FLOW_CLAUDE_CONDUCTOR_MODEL || env.TP_FLOW_CONDUCTOR_MODEL
|
|
57
81
|
if (override === 'inherit') return undefined
|
|
58
82
|
// Claude non-regression: the conductor historically inherited the host default,
|
package/flow-task-graph.mjs
CHANGED
|
@@ -176,10 +176,10 @@ export function normalizePlanOutput (raw) {
|
|
|
176
176
|
// Codex review protocol deliberately maps one reviewer lane to one builder target.
|
|
177
177
|
// Claude's legacy Flow plans may review several deps and remain unchanged.
|
|
178
178
|
export function validatePlanForRuntime (plan, runtime = 'claude') {
|
|
179
|
-
if (runtime !== 'codex') return plan
|
|
179
|
+
if (runtime !== 'codex' && runtime !== 'hermes') return plan
|
|
180
180
|
for (const task of plan?.tasks || []) {
|
|
181
181
|
if (task.sliceType === SLICE_TYPE.review && task.deps.length !== 1) {
|
|
182
|
-
throw new Error(
|
|
182
|
+
throw new Error(`${runtime === 'hermes' ? 'Hermes' : 'Codex'} review task "${task.key}" must depend on exactly one builder task`)
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
return plan
|
|
@@ -187,7 +187,7 @@ export function validatePlanForRuntime (plan, runtime = 'claude') {
|
|
|
187
187
|
|
|
188
188
|
export function validReviewTargetShape (task, runtime = 'claude') {
|
|
189
189
|
if (!task || task.slice_type !== SLICE_TYPE.review) return true
|
|
190
|
-
return runtime !== 'codex' || (Array.isArray(task.deps) && task.deps.length === 1)
|
|
190
|
+
return (runtime !== 'codex' && runtime !== 'hermes') || (Array.isArray(task.deps) && task.deps.length === 1)
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
export function legacyBuilderCompletionAllowed ({ runtime, flowRole, eventKind, eventSubtype, interrupted = false } = {}) {
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Process-local ThinkPool Hermes ACP tool policy.
|
|
3
|
+
|
|
4
|
+
Never import this through the user profile. The bridge starts this file with
|
|
5
|
+
Hermes' installed venv interpreter and passes a validated role policy in env.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
POLICY_ENV = "THINKPOOL_HERMES_ACP_POLICY"
|
|
12
|
+
|
|
13
|
+
CODING_TOOLS = frozenset({
|
|
14
|
+
"web_search", "web_extract", "terminal", "process", "read_file", "write_file",
|
|
15
|
+
"patch", "search_files", "vision_analyze", "skills_list", "skill_view",
|
|
16
|
+
"skill_manage", "browser_navigate", "browser_snapshot", "browser_click",
|
|
17
|
+
"browser_type", "browser_scroll", "browser_back", "browser_press",
|
|
18
|
+
"browser_get_images", "browser_vision", "browser_console", "browser_cdp",
|
|
19
|
+
"browser_dialog", "todo", "memory", "execute_code",
|
|
20
|
+
})
|
|
21
|
+
READ_ONLY_TOOLS = frozenset({"read_file", "search_files"})
|
|
22
|
+
ESSENTIAL_CODING_TOOLS = frozenset({
|
|
23
|
+
"terminal", "process", "read_file", "write_file", "patch", "search_files",
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
def die(message):
|
|
27
|
+
print("ThinkPool Hermes ACP policy error: " + message, file=sys.stderr)
|
|
28
|
+
raise SystemExit(78)
|
|
29
|
+
|
|
30
|
+
def policy():
|
|
31
|
+
raw = os.environ.get(POLICY_ENV)
|
|
32
|
+
try:
|
|
33
|
+
value = json.loads(raw)
|
|
34
|
+
except Exception:
|
|
35
|
+
die("missing or malformed policy")
|
|
36
|
+
if not isinstance(value, dict) or value.get("version") != 1:
|
|
37
|
+
die("unsupported policy")
|
|
38
|
+
role = value.get("role")
|
|
39
|
+
builtin = value.get("builtinTools")
|
|
40
|
+
required_builtin = value.get("requiredBuiltinTools")
|
|
41
|
+
tools = value.get("mcpTools")
|
|
42
|
+
if role not in {"ordinary", "builder", "conductor", "reviewer", "manual-review"}:
|
|
43
|
+
die("unknown role")
|
|
44
|
+
if value.get("mcpServer") != "thinkpool" or not isinstance(builtin, list) or not isinstance(required_builtin, list) or not isinstance(tools, list):
|
|
45
|
+
die("invalid tool policy")
|
|
46
|
+
if not all(isinstance(x, str) and x and x.replace("_", "").isalnum() for x in builtin + required_builtin + tools):
|
|
47
|
+
die("invalid tool name")
|
|
48
|
+
forbidden = {"delegate_task", "session_search"}
|
|
49
|
+
if forbidden.intersection(builtin) or forbidden.intersection(required_builtin) or forbidden.intersection(tools):
|
|
50
|
+
die("delegation and session search are forbidden")
|
|
51
|
+
required_mcp = {
|
|
52
|
+
"ordinary": {"read_terminal"}, "builder": {"mark_flow_done"},
|
|
53
|
+
"conductor": {"submit_flow_plan"},
|
|
54
|
+
"reviewer": {"submit_flow_review", "run_review_check", "read_review_file"},
|
|
55
|
+
"manual-review": {"run_review_check", "read_review_file"},
|
|
56
|
+
}
|
|
57
|
+
if not required_mcp[role].issubset(tools):
|
|
58
|
+
die("incomplete role MCP policy")
|
|
59
|
+
restricted = role in {"conductor", "reviewer", "manual-review"}
|
|
60
|
+
allowed_builtin = READ_ONLY_TOOLS if restricted else CODING_TOOLS
|
|
61
|
+
required = READ_ONLY_TOOLS if restricted else ESSENTIAL_CODING_TOOLS
|
|
62
|
+
if set(builtin) != allowed_builtin:
|
|
63
|
+
die("restricted role requires exact read-only builtins" if restricted else "coding role requires the full approved builtin allowlist")
|
|
64
|
+
if set(required_builtin) != required:
|
|
65
|
+
die("invalid required builtin policy")
|
|
66
|
+
return role, tuple(dict.fromkeys(builtin)), tuple(dict.fromkeys(required_builtin)), tuple(dict.fromkeys(tools))
|
|
67
|
+
|
|
68
|
+
ROLE, BUILTIN, REQUIRED_BUILTIN, MCP_TOOLS = policy()
|
|
69
|
+
ALLOWED = frozenset(BUILTIN) | frozenset("mcp__thinkpool__" + x for x in MCP_TOOLS) | frozenset("mcp_thinkpool_" + x for x in MCP_TOOLS)
|
|
70
|
+
|
|
71
|
+
def name_of(schema):
|
|
72
|
+
if not isinstance(schema, dict): return ""
|
|
73
|
+
fn = schema.get("function")
|
|
74
|
+
return fn.get("name", "") if isinstance(fn, dict) else schema.get("name", "")
|
|
75
|
+
|
|
76
|
+
def filter_schemas(items):
|
|
77
|
+
return [item for item in (items or []) if name_of(item) in ALLOWED]
|
|
78
|
+
|
|
79
|
+
def assert_exact_inventory(agent):
|
|
80
|
+
"""Reject an ACP lifecycle that lost the bridge-owned MCP surface.
|
|
81
|
+
|
|
82
|
+
Hermes treats registration errors as non-fatal. That is acceptable for a
|
|
83
|
+
standalone CLI, but never for a ThinkPool role: the bridge must not let a
|
|
84
|
+
reconstructed agent accept a prompt with a partial policy.
|
|
85
|
+
"""
|
|
86
|
+
tools = list(getattr(agent, "tools", []) or [])
|
|
87
|
+
names = {name_of(item) for item in tools}
|
|
88
|
+
# Allowed is deliberately broader than required for ordinary/builder:
|
|
89
|
+
# browser/provider/vision integrations are availability-gated upstream.
|
|
90
|
+
missing_builtin = set(REQUIRED_BUILTIN) - names
|
|
91
|
+
missing_mcp = [name for name in MCP_TOOLS if not ({"mcp__thinkpool__" + name, "mcp_thinkpool_" + name} & names)]
|
|
92
|
+
extras = names - ALLOWED
|
|
93
|
+
if missing_builtin or missing_mcp or extras:
|
|
94
|
+
details = []
|
|
95
|
+
if missing_builtin: details.append("missing builtins " + ", ".join(sorted(missing_builtin)))
|
|
96
|
+
if missing_mcp: details.append("missing MCP " + ", ".join(sorted(missing_mcp)))
|
|
97
|
+
if extras: details.append("forbidden extras " + ", ".join(sorted(extras)))
|
|
98
|
+
raise RuntimeError("ThinkPool exact inventory is incomplete: " + "; ".join(details))
|
|
99
|
+
agent.tools = tools
|
|
100
|
+
agent.valid_tool_names = names
|
|
101
|
+
|
|
102
|
+
# Patch before importing ACP server. Each new/resumed/reset ACP process reloads
|
|
103
|
+
# this exact policy; no mutable shell alias or profile config participates.
|
|
104
|
+
import toolsets
|
|
105
|
+
toolsets.TOOLSETS["hermes-acp"] = {
|
|
106
|
+
"description": "ThinkPool process-local ACP policy",
|
|
107
|
+
"tools": list(BUILTIN), "includes": []
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# entry.main() normally discovers profile-configured MCP servers before it
|
|
111
|
+
# creates the ACP server. ThinkPool ACP never inherits those profile servers;
|
|
112
|
+
# the only permitted registration is the bridge's per-session `thinkpool` one.
|
|
113
|
+
import tools.mcp_tool
|
|
114
|
+
tools.mcp_tool.discover_mcp_tools = lambda *args, **kwargs: []
|
|
115
|
+
|
|
116
|
+
import model_tools
|
|
117
|
+
_get_definitions = model_tools.get_tool_definitions
|
|
118
|
+
def constrained_definitions(*args, **kwargs):
|
|
119
|
+
return filter_schemas(_get_definitions(*args, **kwargs))
|
|
120
|
+
model_tools.get_tool_definitions = constrained_definitions
|
|
121
|
+
|
|
122
|
+
import agent.memory_manager
|
|
123
|
+
_inject_memory = agent.memory_manager.inject_memory_provider_tools
|
|
124
|
+
def constrained_memory(agent):
|
|
125
|
+
result = _inject_memory(agent)
|
|
126
|
+
if hasattr(agent, "tools"):
|
|
127
|
+
agent.tools = filter_schemas(agent.tools)
|
|
128
|
+
agent.valid_tool_names = {name_of(x) for x in agent.tools}
|
|
129
|
+
return result
|
|
130
|
+
agent.memory_manager.inject_memory_provider_tools = constrained_memory
|
|
131
|
+
|
|
132
|
+
import acp_adapter.session
|
|
133
|
+
_expand = acp_adapter.session._expand_acp_enabled_toolsets
|
|
134
|
+
def constrained_expand(toolsets_arg=None, mcp_server_names=None):
|
|
135
|
+
requested = list(toolsets_arg or ["hermes-acp"])
|
|
136
|
+
if any(name not in {"hermes-acp", "mcp-thinkpool"} for name in requested):
|
|
137
|
+
raise RuntimeError("ThinkPool ACP only permits hermes-acp and mcp-thinkpool toolsets")
|
|
138
|
+
names = list(mcp_server_names or [])
|
|
139
|
+
if any(name != "thinkpool" for name in names):
|
|
140
|
+
raise RuntimeError("ThinkPool ACP only permits dynamic MCP server thinkpool")
|
|
141
|
+
# Hermes 0.18.2 calls this from /tools with an already-expanded
|
|
142
|
+
# ["hermes-acp", "mcp-thinkpool"] list and no mcp_server_names. Preserve
|
|
143
|
+
# that exact legal expansion; otherwise /tools silently omits ThinkPool.
|
|
144
|
+
return ["hermes-acp"] + (["mcp-thinkpool"] if names or "mcp-thinkpool" in requested else [])
|
|
145
|
+
acp_adapter.session._expand_acp_enabled_toolsets = constrained_expand
|
|
146
|
+
|
|
147
|
+
import acp_adapter.server
|
|
148
|
+
_register = acp_adapter.server.HermesACPAgent._register_session_mcp_servers
|
|
149
|
+
async def constrained_register(self, state, mcp_servers):
|
|
150
|
+
if any(getattr(server, "name", None) != "thinkpool" for server in (mcp_servers or [])):
|
|
151
|
+
raise RuntimeError("ThinkPool ACP only permits dynamic MCP server thinkpool")
|
|
152
|
+
if mcp_servers:
|
|
153
|
+
# SessionState is process-local. Keep only the validated descriptors so
|
|
154
|
+
# a subsequent set_model reconstruction can re-register the same MCP.
|
|
155
|
+
state._thinkpool_mcp_servers = tuple(mcp_servers)
|
|
156
|
+
await _register(self, state, mcp_servers)
|
|
157
|
+
assert_exact_inventory(state.agent)
|
|
158
|
+
acp_adapter.server.HermesACPAgent._register_session_mcp_servers = constrained_register
|
|
159
|
+
|
|
160
|
+
# Hermes 0.18.2's session/set_model creates a fresh state.agent. Upstream
|
|
161
|
+
# does not re-run ACP MCP registration, so the new agent can expose only its
|
|
162
|
+
# built-ins while the request still returns success. Keep the old state until
|
|
163
|
+
# the replacement has re-registered and passed the same exact policy check.
|
|
164
|
+
_set_model = acp_adapter.server.HermesACPAgent.set_session_model
|
|
165
|
+
async def constrained_set_model(self, model_id, session_id, **kwargs):
|
|
166
|
+
state = self.session_manager.get_session(session_id)
|
|
167
|
+
if state is None:
|
|
168
|
+
return await _set_model(self, model_id, session_id, **kwargs)
|
|
169
|
+
old_agent, old_model = state.agent, getattr(state, "model", None)
|
|
170
|
+
try:
|
|
171
|
+
result = await _set_model(self, model_id, session_id, **kwargs)
|
|
172
|
+
if result is None:
|
|
173
|
+
raise RuntimeError("Hermes did not acknowledge model switch")
|
|
174
|
+
servers = getattr(state, "_thinkpool_mcp_servers", ())
|
|
175
|
+
if MCP_TOOLS and not servers:
|
|
176
|
+
raise RuntimeError("ThinkPool MCP registration is unavailable after model switch")
|
|
177
|
+
await constrained_register(self, state, list(servers))
|
|
178
|
+
assert_exact_inventory(state.agent)
|
|
179
|
+
self.session_manager.save_session(session_id)
|
|
180
|
+
return result
|
|
181
|
+
except Exception:
|
|
182
|
+
# Fail closed and restore the prior usable agent/model. A later bridge
|
|
183
|
+
# /tools probe is the external acknowledgement before UI persistence.
|
|
184
|
+
state.agent, state.model = old_agent, old_model
|
|
185
|
+
try:
|
|
186
|
+
servers = getattr(state, "_thinkpool_mcp_servers", ())
|
|
187
|
+
if servers:
|
|
188
|
+
await constrained_register(self, state, list(servers))
|
|
189
|
+
self.session_manager.save_session(session_id)
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
raise
|
|
193
|
+
acp_adapter.server.HermesACPAgent.set_session_model = constrained_set_model
|
|
194
|
+
|
|
195
|
+
from acp_adapter.entry import main
|
|
196
|
+
main()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Bridge-owned Hermes ACP schema policy. This is intentionally a small, pure
|
|
2
|
+
// contract: Python receives only this JSON and fails closed for anything else.
|
|
3
|
+
export const HERMES_POLICY_VERSION = 1
|
|
4
|
+
|
|
5
|
+
export const CODING_TOOLS = Object.freeze([
|
|
6
|
+
'web_search', 'web_extract', 'terminal', 'process', 'read_file', 'write_file',
|
|
7
|
+
'patch', 'search_files', 'vision_analyze', 'skills_list', 'skill_view',
|
|
8
|
+
'skill_manage', 'browser_navigate', 'browser_snapshot', 'browser_click',
|
|
9
|
+
'browser_type', 'browser_scroll', 'browser_back', 'browser_press',
|
|
10
|
+
'browser_get_images', 'browser_vision', 'browser_console', 'browser_cdp',
|
|
11
|
+
'browser_dialog', 'todo', 'memory', 'execute_code',
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
const READ_ONLY_TOOLS = Object.freeze(['read_file', 'search_files'])
|
|
15
|
+
// Hermes providers advertise capabilities conditionally (for example browser
|
|
16
|
+
// CDP/dialog support depends on the installed browser integration). These are
|
|
17
|
+
// the only builtins an ordinary or builder lane must have to do useful coding
|
|
18
|
+
// work; the complete coding allowlist above remains permitted when available.
|
|
19
|
+
export const ESSENTIAL_CODING_TOOLS = Object.freeze([
|
|
20
|
+
'terminal', 'process', 'read_file', 'write_file', 'patch', 'search_files',
|
|
21
|
+
])
|
|
22
|
+
const ROLE_REQUIRED = Object.freeze({
|
|
23
|
+
ordinary: ['read_terminal', 'spawn_terminal', 'close_terminal'],
|
|
24
|
+
conductor: ['submit_flow_plan'],
|
|
25
|
+
builder: ['mark_flow_done'],
|
|
26
|
+
reviewer: ['submit_flow_review', 'run_review_check', 'read_review_file'],
|
|
27
|
+
'manual-review': ['run_review_check', 'read_review_file'],
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export function hermesRoleFor({ flowRole, sliceType } = {}) {
|
|
31
|
+
if (flowRole === 'conductor') return 'conductor'
|
|
32
|
+
if (flowRole === 'reviewer') return 'reviewer'
|
|
33
|
+
if (sliceType === 'review') return 'manual-review'
|
|
34
|
+
if (flowRole === 'builder') return 'builder'
|
|
35
|
+
return 'ordinary'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function hermesPolicyForRole(role, { mcpTools } = {}) {
|
|
39
|
+
if (!Object.hasOwn(ROLE_REQUIRED, role)) throw new Error(`Unknown Hermes role policy: ${role}`)
|
|
40
|
+
const supplied = Array.isArray(mcpTools) ? mcpTools.map(String).filter(Boolean) : ROLE_REQUIRED[role]
|
|
41
|
+
const required = ROLE_REQUIRED[role]
|
|
42
|
+
// Ordinary worker leaves deliberately lack spawn/close. Main-lane proof is
|
|
43
|
+
// enforced by requiredMcpTools at dispatch; keep this schema usable for a
|
|
44
|
+
// non-delegating ordinary child without widening it.
|
|
45
|
+
const minimum = role === 'ordinary' ? ['read_terminal'] : required
|
|
46
|
+
for (const tool of minimum) if (!supplied.includes(tool)) throw new Error(`Hermes ${role} policy is missing required ThinkPool tool ${tool}`)
|
|
47
|
+
const restricted = role === 'conductor' || role === 'reviewer' || role === 'manual-review'
|
|
48
|
+
const builtinTools = restricted ? READ_ONLY_TOOLS : CODING_TOOLS
|
|
49
|
+
const requiredBuiltinTools = restricted ? READ_ONLY_TOOLS : ESSENTIAL_CODING_TOOLS
|
|
50
|
+
return Object.freeze({
|
|
51
|
+
version: HERMES_POLICY_VERSION,
|
|
52
|
+
role,
|
|
53
|
+
// `builtinTools` is the role's complete approved surface, not a promise
|
|
54
|
+
// that an availability-gated upstream provider implements every tool.
|
|
55
|
+
builtinTools,
|
|
56
|
+
requiredBuiltinTools,
|
|
57
|
+
mcpServer: 'thinkpool',
|
|
58
|
+
mcpTools: [...new Set(supplied)].sort(),
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function hermesRequiredMcpTools(role, { canSpawnWorkers = false } = {}) {
|
|
63
|
+
if (role === 'ordinary') return ['read_terminal', ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
|
|
64
|
+
return [...ROLE_REQUIRED[role]]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Keep the bridge's local /tools proof on the same canonical inventory as the
|
|
68
|
+
// process-local Python bootstrap. `allBuiltinTools` lets that proof reject a
|
|
69
|
+
// known built-in which is not part of this role, rather than merely looking
|
|
70
|
+
// for a couple of required MCP names.
|
|
71
|
+
export function hermesExactInventory(role, options = {}) {
|
|
72
|
+
const policy = hermesPolicyForRole(role, options)
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
builtinTools: [...policy.builtinTools].sort(),
|
|
75
|
+
requiredBuiltinTools: [...policy.requiredBuiltinTools].sort(),
|
|
76
|
+
mcpTools: [...policy.mcpTools].sort(),
|
|
77
|
+
// The two excluded upstream capabilities must be checked as forbidden too;
|
|
78
|
+
// they are intentionally absent from CODING_TOOLS, not unknown to policy.
|
|
79
|
+
allBuiltinTools: [...CODING_TOOLS, 'delegate_task', 'session_search'].sort(),
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function hermesPolicyEnv(role, options = {}) {
|
|
84
|
+
return JSON.stringify(hermesPolicyForRole(role, options))
|
|
85
|
+
}
|
package/hermes-probe.mjs
CHANGED
|
@@ -1,8 +1,36 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
2
4
|
|
|
3
5
|
const clean = (value) => String(value || '').replace(/[\r\n]+/g, ' ').trim()
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
const INSTALL = /Install directory:\s*(.+?)(?:\r?\n|$)/i
|
|
8
|
+
const PROFILE = /Config:\s*(.+?)(?:\r?\n|$)/i
|
|
9
|
+
|
|
10
|
+
// Resolve the installed venv and isolated profile once, then launch ACP through
|
|
11
|
+
// bridge-owned code. `thinkpool` is only queried for inventory; it is never the
|
|
12
|
+
// executable that serves an ACP lane.
|
|
13
|
+
export function resolveHermesAcpRuntime({ command = 'thinkpool', execFile = execFileSync, exists = fs.existsSync, bootstrap = new URL('./hermes-acp-bootstrap.py', import.meta.url) } = {}) {
|
|
14
|
+
try {
|
|
15
|
+
const versionOutput = execFile(command, ['--version'], { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
16
|
+
const install = clean(String(versionOutput).match(INSTALL)?.[1])
|
|
17
|
+
if (!install || !path.isAbsolute(install)) throw new Error('Hermes did not report an absolute install directory')
|
|
18
|
+
const python = path.join(install, 'venv', 'bin', 'python')
|
|
19
|
+
const bootstrapPath = bootstrap instanceof URL ? bootstrap.pathname : String(bootstrap)
|
|
20
|
+
if (!exists(python) || !exists(bootstrapPath)) throw new Error('Hermes venv Python or ThinkPool ACP bootstrap is missing')
|
|
21
|
+
// `thinkpool` is the dedicated profile wrapper on supported installs. Its
|
|
22
|
+
// config output is evidence, not the ACP launch path.
|
|
23
|
+
const configOutput = execFile(command, ['config', 'show'], { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
24
|
+
const config = clean(String(configOutput).match(PROFILE)?.[1])
|
|
25
|
+
const profile = config ? path.dirname(config) : ''
|
|
26
|
+
if (!profile || !path.isAbsolute(profile) || path.basename(profile) !== 'thinkpool') throw new Error('Hermes did not report the isolated thinkpool profile')
|
|
27
|
+
return { python, bootstrap: bootstrapPath, profile, install }
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return { error: clean(error?.stderr || error?.message || error) || 'could not resolve Hermes ACP runtime' }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function probeHermesRuntime({ command = 'thinkpool', prefixArgs = [], execFile = execFileSync, env = process.env, strictBootstrap = false, exists = fs.existsSync } = {}) {
|
|
6
34
|
try {
|
|
7
35
|
const versionOutput = execFile(command, [...prefixArgs, '--version'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
8
36
|
const version = clean(versionOutput).match(/Hermes Agent v([^\s]+)/i)?.[1] || null
|
|
@@ -16,7 +44,9 @@ export function probeHermesRuntime({ command = 'thinkpool', prefixArgs = [], exe
|
|
|
16
44
|
&& /ThinkPool blocks hidden Hermes delegation/i.test(guardOutput)
|
|
17
45
|
&& /pre_tool_call[\s\S]*delegate_task[\s\S]*(?:✓ allowed|allowed)/i.test(hooksOutput)
|
|
18
46
|
if (!doctorHealthy || !delegationBlocked) return { available: false, version, reason: 'delegate_task guard is not healthy, unchanged, allowlisted, and structurally blocking in the dedicated Hermes profile' }
|
|
19
|
-
|
|
47
|
+
const runtime = strictBootstrap ? resolveHermesAcpRuntime({ command, execFile, exists }) : null
|
|
48
|
+
if (runtime?.error) return { available: false, version, reason: `Hermes ACP bootstrap unavailable: ${runtime.error}` }
|
|
49
|
+
return { available: true, version, acpProtocol: 1, delegationBlocked: true, ...(runtime || {}) }
|
|
20
50
|
} catch (error) {
|
|
21
51
|
return { available: false, version: null, reason: clean(error?.stderr || error?.message || error) }
|
|
22
52
|
}
|