From c7e5215b508f747a53a97487082c26ed97cd2aff Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sun, 14 Jun 2026 13:56:08 +0530 Subject: [PATCH] =?UTF-8?q?opentui(v6):=20HERMES=5FTUI=5FTOOL=5FOUTPUTS=20?= =?UTF-8?q?flag=20=E2=80=94=20drop=20tool-body=20retention=20(W3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ui-opentui/src/logic/env.ts | 16 +++++ ui-opentui/src/logic/store.ts | 98 ++++++++++++++++++------------ ui-opentui/src/test/tools.test.tsx | 62 +++++++++++++++++++ 3 files changed, 136 insertions(+), 40 deletions(-) diff --git a/ui-opentui/src/logic/env.ts b/ui-opentui/src/logic/env.ts index d831fe6d639..274a91738bd 100644 --- a/ui-opentui/src/logic/env.ts +++ b/ui-opentui/src/logic/env.ts @@ -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. diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index 25d66a464aa..75dd17fcf58 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -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 - // 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 - 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 + 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 + if (!part.argsText) { + try { + part.argsText = JSON.stringify(argsObj, null, 2) + } catch { + /* unstringifiable args — leave unset */ + } } } } diff --git a/ui-opentui/src/test/tools.test.tsx b/ui-opentui/src/test/tools.test.tsx index 565e88c814d..69dc94a9955 100644 --- a/ui-opentui/src/test/tools.test.tsx +++ b/ui-opentui/src/test/tools.test.tsx @@ -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