opentui(v6): HERMES_TUI_TOOL_OUTPUTS flag — drop tool-body retention (W3)

The biggest real memory lever: OpenTUI retained full resultText + the raw
result dict + the args dict per tool call, while Ink discards tool bodies
(keeps only a short context line). That retention asymmetry is the bulk of the
Ink-vs-OpenTUI memory gap.

New `HERMES_TUI_TOOL_OUTPUTS` flag (toolOutputsEnabled() in env.ts, default
ON — the rich tool cards are OpenTUI's differentiator). When OFF, the
tool.complete reducer (store.ts) neither BUILDS nor STORES the body: skip the
whole result_text/result stringify+envelope-strip work and suppress
part.resultText / part.result / part.args / part.argsText / part.lineCount /
part.omittedNote. KEPT either way: name, state, duration, error, summary,
argsPreview (the redaction-safe one-liner from tool.start context = Ink's
context line), and the file-edit diff (diffUnified/diffStats — a diff is a
high-value surface, not generic "output"). Render is automatic: with no
resultText/result, defaultRenderer.expandable() is false → header-only row =
Ink parity, no extra view gating needed.

This powers the bench's fair Ink-vs-OpenTUI comparison (D8 — launch OpenTUI
with outputs off so both engines are body-less = pure engine overhead) and the
low-mem mode.

Tests: store retains rich outputs by default; OFF drops the bodies but keeps
name/duration/error/argsPreview/diff. npm run check OK (770 tests).
This commit is contained in:
alt-glitch 2026-06-14 13:56:08 +05:30
parent 25686feebf
commit c7e5215b50
3 changed files with 136 additions and 40 deletions

View file

@ -30,6 +30,22 @@ export function diagnosticsEnabled(): boolean {
return envFlag(process.env.HERMES_TUI_DIAGNOSTICS, false)
}
/**
* Whether rich tool-call OUTPUTS are kept `HERMES_TUI_TOOL_OUTPUTS` (default
* ON). OpenTUI's rich tool cards (full result body + raw result/args dicts) are
* its differentiator vs Ink, so they stay on for real users. Setting `=off`
* drops both the RENDER and the STORE of those bodies (exact Ink parity: Ink
* keeps only a short context line and discards the result/args dicts), which is
* the biggest memory lever used by the bench (D8: a fair Ink-vs-OpenTUI
* engine-overhead comparison) and the low-mem mode. The redaction-safe
* `argsPreview` one-liner, name/duration/error, and file-edit diffs are KEPT
* either way (a diff is a high-value surface, not generic "output"). Read per
* call so a wrapper that mutates env before launch sees the live value.
*/
export function toolOutputsEnabled(): boolean {
return envFlag(process.env.HERMES_TUI_TOOL_OUTPUTS, true)
}
/**
* Parse `HERMES_TUI_TOOL_OUTPUT_LINES` (a TUI-only env var deliberately NOT
* a config.yaml knob): how many output lines an expanded tool body shows.

View file

@ -24,7 +24,7 @@ import {
import type { DetailsMode } from './details.ts'
import { diffStats, type DiffStats } from './diff.ts'
import type { SessionTabId } from './sessionPicker.ts'
import { envFlag, envOutputUnlimited } from './env.ts'
import { envFlag, envOutputUnlimited, toolOutputsEnabled } from './env.ts'
import { registerNotifier } from './notify.ts'
import {
isChromeNotice,
@ -997,40 +997,54 @@ export function createSessionStore(options?: SessionStoreOptions) {
// convention (the only failure signal the live gateway actually ships).
const error = readStr(event.payload, 'error')
const summary = readStr(event.payload, 'summary')
// Tool-output retention flag (W3, glitch 2026-06-14): when OFF, the rich
// result body + raw result/args dicts are neither built nor stored (Ink
// parity — Ink keeps only a context line). KEEP either way: name, state,
// duration, error, summary, argsPreview, and the file-edit diff (a diff
// is a high-value surface, not generic "output"). This is the biggest
// memory lever (the OpenTUI-vs-Ink retention asymmetry).
const keepOutputs = toolOutputsEnabled()
// `result_text` is verbose-gated, but the raw `result` is ALWAYS sent —
// when the verbose text is absent, derive the display body from `result`
// (so e.g. bash output still renders in non-verbose sessions). Then peel
// the gateway's "[showing verbose tail; omitted …]" label (item 2) before
// envelope-stripping, so the body is clean and the note renders tidily.
let { body: rawBody, omittedNote } = stripOmittedNote(
readStr(event.payload, 'result_text') ?? stringifyResult(event.payload['result']) ?? summary ?? ''
)
// The view cap is UNLIMITED (HERMES_TUI_TOOL_OUTPUT_LINES unset/0 — the
// default), but a gateway-capped `result_text` (omittedNote) is only a
// TAIL — substituting the always-full raw `result` is the only way the
// uncapped view can actually show everything. Same display pipeline
// (envelope strip) — and the same raw-result redaction tradeoff — as the
// existing non-verbose stringifyResult fallback above. The omitted note
// no longer applies to the full body. With an explicit FINITE cap the
// gateway tail + note are kept (the user asked for a bounded view; the
// view-side "+N more lines" stays honest below that). File-edit JSON
// results stay parseable, so fileTool's diffOutputPlan still suppresses
// the diff echo. The redacted-argsText precedence is untouched.
if (omittedNote && envOutputUnlimited(process.env.HERMES_TUI_TOOL_OUTPUT_LINES)) {
const full = stringifyResult(event.payload['result'])
if (full !== undefined) {
rawBody = full
omittedNote = undefined
// Skip the whole body-build when outputs are OFF (nothing consumes it).
let resultText = ''
let lineCount = 0
let omittedNote: string | undefined
if (keepOutputs) {
let rawBody: string
;({ body: rawBody, omittedNote } = stripOmittedNote(
readStr(event.payload, 'result_text') ?? stringifyResult(event.payload['result']) ?? summary ?? ''
))
// The view cap is UNLIMITED (HERMES_TUI_TOOL_OUTPUT_LINES unset/0 — the
// default), but a gateway-capped `result_text` (omittedNote) is only a
// TAIL — substituting the always-full raw `result` is the only way the
// uncapped view can actually show everything. Same display pipeline
// (envelope strip) — and the same raw-result redaction tradeoff — as the
// existing non-verbose stringifyResult fallback above. The omitted note
// no longer applies to the full body. With an explicit FINITE cap the
// gateway tail + note are kept (the user asked for a bounded view; the
// view-side "+N more lines" stays honest below that). File-edit JSON
// results stay parseable, so fileTool's diffOutputPlan still suppresses
// the diff echo. The redacted-argsText precedence is untouched.
if (omittedNote && envOutputUnlimited(process.env.HERMES_TUI_TOOL_OUTPUT_LINES)) {
const full = stringifyResult(event.payload['result'])
if (full !== undefined) {
rawBody = full
omittedNote = undefined
}
}
resultText = stripToolEnvelope(rawBody)
lineCount = resultText ? resultText.replace(/\s+$/, '').split('\n').length : 0
}
const resultText = stripToolEnvelope(rawBody)
const lineCount = resultText ? resultText.replace(/\s+$/, '').split('\n').length : 0
// `args` (full dict) is always sent; stringify as the expanded-view args
// when verbose `args_text` wasn't captured on start. `duration_s` → header.
const argsObj = event.payload['args']
const duration = readOptNum(event.payload, 'duration_s')
// FULL raw unified diff (file-edit tools; gateway caps at 512KB). Stats
// are computed once here, not per render.
// are computed once here, not per render. Kept even when outputs are OFF.
const diffUnified = readStr(event.payload, 'diff_unified')
setState(
produce(draft => {
@ -1041,31 +1055,35 @@ export function createSessionStore(options?: SessionStoreOptions) {
;(ensureAssistant(draft).parts ??= []).push(part)
}
part.state = 'complete'
part.lineCount = lineCount
if (name) part.name = name
if (resultText) part.resultText = resultText
if (summary) part.summary = summary
if (error) part.error = error
if (duration !== undefined) part.duration = duration
if (omittedNote) part.omittedNote = omittedNote
if (diffUnified) {
part.diffUnified = diffUnified
part.diffStats = diffStats(diffUnified)
}
// structured dict results feed the per-tool renderers (read_file
// content, search matches, clarify Q&A, skill_view description).
const resultObj = event.payload['result']
if (resultObj && typeof resultObj === 'object' && !Array.isArray(resultObj))
part.result = resultObj as Record<string, unknown>
// argsPreview (from tool.start `context`) is intentionally NOT overwritten.
if (argsObj && typeof argsObj === 'object') {
// structured args feed the per-tool renderers (labeled fields, bash command).
if (!Array.isArray(argsObj)) part.args = argsObj as Record<string, unknown>
if (!part.argsText) {
try {
part.argsText = JSON.stringify(argsObj, null, 2)
} catch {
/* unstringifiable args — leave unset */
// Tool-output bodies (W3): only retained when outputs are ON. With no
// resultText/result, defaultRenderer.expandable() is false → header-
// only row (Ink parity). argsPreview (from tool.start) is untouched.
if (keepOutputs) {
part.lineCount = lineCount
if (resultText) part.resultText = resultText
if (omittedNote) part.omittedNote = omittedNote
// structured dict results feed the per-tool renderers (read_file
// content, search matches, clarify Q&A, skill_view description).
const resultObj = event.payload['result']
if (resultObj && typeof resultObj === 'object' && !Array.isArray(resultObj))
part.result = resultObj as Record<string, unknown>
if (argsObj && typeof argsObj === 'object') {
// structured args feed the per-tool renderers (labeled fields, bash command).
if (!Array.isArray(argsObj)) part.args = argsObj as Record<string, unknown>
if (!part.argsText) {
try {
part.argsText = JSON.stringify(argsObj, null, 2)
} catch {
/* unstringifiable args — leave unset */
}
}
}
}

View file

@ -381,6 +381,68 @@ describe('file tool renderer — relative path + diff stats (Epic 2.3)', () => {
})
})
describe('store: HERMES_TUI_TOOL_OUTPUTS flag (W3 — retention asymmetry lever)', () => {
const DIFF = ['--- a/x.py', '+++ b/x.py', '@@ -1,1 +1,1 @@', '-old', '+new'].join('\n')
const findTool = (store: Store, id: string) => {
const last = store.state.messages[store.state.messages.length - 1]
return last?.parts?.find((p): p is ToolPartState => p.type === 'tool' && p.id === id)
}
const complete = {
tool_id: 't1',
name: 'terminal',
args: { command: 'ls -la' },
result_text: 'file-a\nfile-b\nfile-c',
duration_s: 0.4,
diff_unified: DIFF
}
test('default (ON): rich outputs are retained', () => {
const prev = process.env.HERMES_TUI_TOOL_OUTPUTS
delete process.env.HERMES_TUI_TOOL_OUTPUTS
try {
const store = createSessionStore()
seedTool(store, { tool_id: 't1', name: 'terminal', context: 'ls -la' }, complete)
const part = findTool(store, 't1')
expect(part?.resultText).toBe('file-a\nfile-b\nfile-c')
expect(part?.lineCount).toBe(3)
expect(part?.args).toEqual({ command: 'ls -la' })
expect(part?.argsText).toContain('command')
} finally {
if (prev === undefined) delete process.env.HERMES_TUI_TOOL_OUTPUTS
else process.env.HERMES_TUI_TOOL_OUTPUTS = prev
}
})
test('OFF: result body + raw result/args are dropped; name/duration/error/diff are kept', () => {
const prev = process.env.HERMES_TUI_TOOL_OUTPUTS
process.env.HERMES_TUI_TOOL_OUTPUTS = 'off'
try {
const store = createSessionStore()
seedTool(store, { tool_id: 't1', name: 'terminal', context: 'ls -la' }, { ...complete, error: 'boom' })
const part = findTool(store, 't1')
// suppressed (the memory lever)
expect(part?.resultText).toBeUndefined()
expect(part?.result).toBeUndefined()
expect(part?.args).toBeUndefined()
expect(part?.argsText).toBeUndefined()
expect(part?.lineCount).toBeUndefined()
expect(part?.omittedNote).toBeUndefined()
// kept either way
expect(part?.name).toBe('terminal')
expect(part?.state).toBe('complete')
expect(part?.duration).toBe(0.4)
expect(part?.error).toBe('boom')
expect(part?.argsPreview).toBe('ls -la') // from tool.start context — the Ink-parity one-liner
// the file-edit diff is a high-value surface, not generic output — KEPT
expect(part?.diffUnified).toBe(DIFF)
expect(part?.diffStats).toEqual({ added: 1, removed: 1 })
} finally {
if (prev === undefined) delete process.env.HERMES_TUI_TOOL_OUTPUTS
else process.env.HERMES_TUI_TOOL_OUTPUTS = prev
}
})
})
describe('file tool — output suppression under a rendered diff (no raw JSON, ever)', () => {
// A file-edit result is a JSON record whose payload IS the diff. In a verbose
// session the gateway REDACTS + CAPS result_text, so it can arrive truncated