mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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:
parent
25686feebf
commit
c7e5215b50
3 changed files with 136 additions and 40 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue