mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): tool output uncapped by default (env restores a cap)
User feedback: "for all tools, i'd want all their output viewing enabled to be infinite by default." Flip envOutputLines (HERMES_TUI_TOOL_OUTPUT_LINES): unset -> Infinity (was 200); a positive integer RESTORES a cap (e.g. =200); 0 stays Infinity for back-compat with the old opt-in-unlimited value; garbage -> Infinity (unrecognized = no cap asked for). The semantic is now "cap only when the user asked for one". The store's raw-result preference follows the same rule: envOutputLinesSet becomes envOutputUnlimited — whenever the cap is unlimited (the default now) and a gateway tail-capped result_text (omittedNote) arrives with the always-full raw result on the wire, the raw result wins, since an uncapped view of a tail would silently miss the head. With an explicit finite cap the gateway tail + honest omitted note are kept. Memory safety is unchanged: tool bodies mount only while EXPANDED (rows default collapsed and free their Yoga nodes on collapse/unmount), and the rolling HERMES_TUI_MAX_MESSAGES cap bounds the transcript's high-water mark. Tests: env.test.ts expectations flipped (unset/garbage -> Infinity, 0 documented as back-compat); tools.test.tsx "flag unset caps at 200" becomes "unset renders all 250 lines", plus an explicit =50 cap (+note) test and =200 restored-cap test; the store preference matrix covers unset/0 (raw wins), =50 (tail+note kept), and no-raw (tail+note, no crash). Verified live: seq 1 220 expanded renders rows 201-220 with no "+N more lines" note.
This commit is contained in:
parent
0bb58b65ec
commit
38eb9bb19a
6 changed files with 116 additions and 63 deletions
|
|
@ -15,28 +15,29 @@ export function envFlag(value: string | undefined, fallback: boolean): boolean {
|
|||
return fallback
|
||||
}
|
||||
|
||||
/** Default cap on output lines shown by an EXPANDED tool body. */
|
||||
export const TOOL_OUTPUT_LINES_DEFAULT = 200
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Unset/garbage → 200 (the long-standing default); a positive integer → that
|
||||
* cap; `0` → Infinity (UNLIMITED — show the entire output).
|
||||
* UNSET → Infinity (UNLIMITED — expanded tool output is uncapped by default;
|
||||
* setting the var is how you RESTORE a cap, e.g. `=200`). A positive integer
|
||||
* → that cap. `0` → Infinity too (back-compat: it was the old opt-in
|
||||
* "unlimited" value). Garbage → Infinity (unrecognized ≙ no cap asked for —
|
||||
* the semantic is "cap only when the user asked for one").
|
||||
*/
|
||||
export function envOutputLines(value: string | undefined): number {
|
||||
const v = value?.trim() ?? ''
|
||||
if (!/^\d+$/.test(v)) return TOOL_OUTPUT_LINES_DEFAULT
|
||||
if (!/^\d+$/.test(v)) return Number.POSITIVE_INFINITY
|
||||
const n = Number.parseInt(v, 10)
|
||||
return n === 0 ? Number.POSITIVE_INFINITY : n
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `HERMES_TUI_TOOL_OUTPUT_LINES` was EXPLICITLY set (any non-empty
|
||||
* value, even an unparseable one). When it is, the store prefers the
|
||||
* always-full raw `result` over a gateway tail-capped `result_text` — see
|
||||
* store.ts tool.complete.
|
||||
* Whether NO line cap applies (unset / `0` / unparseable). When unlimited,
|
||||
* the store prefers the always-full raw `result` over a gateway tail-capped
|
||||
* `result_text` — an "unlimited" view of a tail would still be missing its
|
||||
* head — see store.ts tool.complete. With an explicit finite cap the gateway
|
||||
* tail (+ honest omitted note) is kept: the user asked for a bounded view.
|
||||
*/
|
||||
export function envOutputLinesSet(value: string | undefined): boolean {
|
||||
return (value?.trim() ?? '') !== ''
|
||||
export function envOutputUnlimited(value: string | undefined): boolean {
|
||||
return envOutputLines(value) === Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
} from '../boundary/schema/SessionInfo.ts'
|
||||
import type { DetailsMode } from './details.ts'
|
||||
import { diffStats, type DiffStats } from './diff.ts'
|
||||
import { envOutputLinesSet } from './env.ts'
|
||||
import { envOutputUnlimited } from './env.ts'
|
||||
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
|
||||
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
|
||||
|
||||
|
|
@ -744,17 +744,18 @@ export function createSessionStore() {
|
|||
let { body: rawBody, omittedNote } = stripOmittedNote(
|
||||
readStr(event.payload, 'result_text') ?? stringifyResult(event.payload['result']) ?? summary ?? ''
|
||||
)
|
||||
// HERMES_TUI_TOOL_OUTPUT_LINES set → the user explicitly controls how much
|
||||
// output the expanded view shows, but a gateway-capped `result_text`
|
||||
// (omittedNote) is only a TAIL — substituting the always-full raw `result`
|
||||
// is the only way flag=0 (unlimited) 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; "+N more lines"
|
||||
// (view-side) stays honest for caps below the full length. File-edit JSON
|
||||
// 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 && envOutputLinesSet(process.env.HERMES_TUI_TOOL_OUTPUT_LINES)) {
|
||||
if (omittedNote && envOutputUnlimited(process.env.HERMES_TUI_TOOL_OUTPUT_LINES)) {
|
||||
const full = stringifyResult(event.payload['result'])
|
||||
if (full !== undefined) {
|
||||
rawBody = full
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { envFlag, envOutputLines, envOutputLinesSet, TOOL_OUTPUT_LINES_DEFAULT } from '../logic/env.ts'
|
||||
import { envFlag, envOutputLines, envOutputUnlimited } from '../logic/env.ts'
|
||||
|
||||
describe('envFlag', () => {
|
||||
test('recognizes truthy values regardless of case/whitespace', () => {
|
||||
|
|
@ -31,37 +31,38 @@ describe('envFlag', () => {
|
|||
})
|
||||
|
||||
describe('envOutputLines (HERMES_TUI_TOOL_OUTPUT_LINES)', () => {
|
||||
test('unset → the 200-line default (today’s behavior)', () => {
|
||||
expect(TOOL_OUTPUT_LINES_DEFAULT).toBe(200)
|
||||
expect(envOutputLines(undefined)).toBe(200)
|
||||
expect(envOutputLines('')).toBe(200)
|
||||
expect(envOutputLines(' ')).toBe(200)
|
||||
test('unset → Infinity (UNLIMITED by default — the env var RESTORES a cap)', () => {
|
||||
expect(envOutputLines(undefined)).toBe(Number.POSITIVE_INFINITY)
|
||||
expect(envOutputLines('')).toBe(Number.POSITIVE_INFINITY)
|
||||
expect(envOutputLines(' ')).toBe(Number.POSITIVE_INFINITY)
|
||||
})
|
||||
|
||||
test('a positive integer → that cap (whitespace-tolerant)', () => {
|
||||
expect(envOutputLines('50')).toBe(50)
|
||||
expect(envOutputLines(' 50 ')).toBe(50)
|
||||
expect(envOutputLines('1')).toBe(1)
|
||||
expect(envOutputLines('200')).toBe(200)
|
||||
expect(envOutputLines('1000')).toBe(1000)
|
||||
})
|
||||
|
||||
test('"0" → Infinity (UNLIMITED — show the entire output)', () => {
|
||||
test('"0" → Infinity too (back-compat with the old opt-in "unlimited" value)', () => {
|
||||
expect(envOutputLines('0')).toBe(Number.POSITIVE_INFINITY)
|
||||
})
|
||||
|
||||
test('garbage → the 200-line default', () => {
|
||||
expect(envOutputLines('unlimited')).toBe(200)
|
||||
expect(envOutputLines('-5')).toBe(200)
|
||||
expect(envOutputLines('1.5')).toBe(200)
|
||||
expect(envOutputLines('50 lines')).toBe(200)
|
||||
test('garbage → Infinity (unrecognized ≙ no cap asked for)', () => {
|
||||
expect(envOutputLines('unlimited')).toBe(Number.POSITIVE_INFINITY)
|
||||
expect(envOutputLines('-5')).toBe(Number.POSITIVE_INFINITY)
|
||||
expect(envOutputLines('1.5')).toBe(Number.POSITIVE_INFINITY)
|
||||
expect(envOutputLines('50 lines')).toBe(Number.POSITIVE_INFINITY)
|
||||
})
|
||||
|
||||
test('envOutputLinesSet: set means any non-empty value, even garbage', () => {
|
||||
expect(envOutputLinesSet(undefined)).toBe(false)
|
||||
expect(envOutputLinesSet('')).toBe(false)
|
||||
expect(envOutputLinesSet(' ')).toBe(false)
|
||||
expect(envOutputLinesSet('0')).toBe(true)
|
||||
expect(envOutputLinesSet('50')).toBe(true)
|
||||
expect(envOutputLinesSet('garbage')).toBe(true)
|
||||
test('envOutputUnlimited: true unless an explicit finite cap was asked for', () => {
|
||||
expect(envOutputUnlimited(undefined)).toBe(true)
|
||||
expect(envOutputUnlimited('')).toBe(true)
|
||||
expect(envOutputUnlimited(' ')).toBe(true)
|
||||
expect(envOutputUnlimited('0')).toBe(true)
|
||||
expect(envOutputUnlimited('garbage')).toBe(true)
|
||||
expect(envOutputUnlimited('50')).toBe(false)
|
||||
expect(envOutputUnlimited('200')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
* acceptance gate asserts NO raw JSON syntax (`{"` / `":`) ever reaches the
|
||||
* frame for tool parts, collapsed or expanded — delegate_task carries the
|
||||
* Ink-parity "(/agents to monitor)" hint, and the bash renderer shows the
|
||||
* command verbatim collapsed + the full (EXPANDED_MAX-capped) output expanded.
|
||||
* command verbatim collapsed + the FULL output expanded (uncapped by default;
|
||||
* HERMES_TUI_TOOL_OUTPUT_LINES restores a cap).
|
||||
* Expansion goes through the REAL mouse path: mockMouse clicks the header row
|
||||
* (found by scanning the frame). The long-output cap is asserted at the Body
|
||||
* level (a tall frame would otherwise hide the trailing note).
|
||||
|
|
@ -180,7 +181,10 @@ describe('bash tool renderer — command + full output (Epic 2.4)', () => {
|
|||
}
|
||||
})
|
||||
|
||||
test('long output is capped to EXPANDED_MAX with an honest "+N more lines" note', async () => {
|
||||
test('long output with an explicit =200 cap restored gets the honest "+N more lines" note', async () => {
|
||||
// Output is UNCAPPED by default now — restore the old 200-line cap explicitly.
|
||||
const prev = process.env.HERMES_TUI_TOOL_OUTPUT_LINES
|
||||
process.env.HERMES_TUI_TOOL_OUTPUT_LINES = '200'
|
||||
const lines = Array.from({ length: 250 }, (_, i) => `line-${String(i + 1).padStart(3, '0')}`)
|
||||
const part: ToolPartState = {
|
||||
type: 'tool',
|
||||
|
|
@ -203,11 +207,13 @@ describe('bash tool renderer — command + full output (Epic 2.4)', () => {
|
|||
const frame = await probe.waitForFrame(f => f.includes('+50 more lines'))
|
||||
expect(frame).toContain('$ for i in range(250): print(i)')
|
||||
expect(frame).toContain('line-001') // the cap keeps the HEAD of the output
|
||||
expect(frame).toContain('line-200') // …up to EXPANDED_MAX
|
||||
expect(frame).toContain('line-200') // …up to the restored cap
|
||||
expect(frame).not.toContain('line-201') // the rest is honestly elided
|
||||
expect(frame).toContain('… +50 more lines')
|
||||
} finally {
|
||||
probe.destroy()
|
||||
if (prev === undefined) delete process.env.HERMES_TUI_TOOL_OUTPUT_LINES
|
||||
else process.env.HERMES_TUI_TOOL_OUTPUT_LINES = prev
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -620,7 +626,7 @@ describe('HERMES_TUI_TOOL_OUTPUT_LINES — expanded-output line cap (TUI-only en
|
|||
})
|
||||
})
|
||||
|
||||
test('flag unset: the same 250-line output still caps at 200 + the honest note (default unchanged)', async () => {
|
||||
test('flag unset: the same 250-line output renders ALL lines (UNLIMITED is the default now)', async () => {
|
||||
await withFlag(undefined, async () => {
|
||||
const lines = Array.from({ length: 250 }, (_, i) => `row-${String(i + 1).padStart(3, '0')}`)
|
||||
const part: ToolPartState = {
|
||||
|
|
@ -640,17 +646,48 @@ describe('HERMES_TUI_TOOL_OUTPUT_LINES — expanded-output line cap (TUI-only en
|
|||
{ width: 80, height: 260 }
|
||||
)
|
||||
try {
|
||||
const frame = await probe.waitForFrame(f => f.includes('+50 more lines'))
|
||||
expect(frame).toContain('row-200')
|
||||
expect(frame).not.toContain('row-201')
|
||||
expect(frame).toContain('… +50 more lines')
|
||||
const frame = await probe.waitForFrame(f => f.includes('row-250'))
|
||||
expect(frame).toContain('row-001')
|
||||
expect(frame).toContain('row-201') // beyond the old 200-line default…
|
||||
expect(frame).toContain('row-250') // …down to the very last line
|
||||
expect(frame).not.toContain('more lines') // and no truncation note
|
||||
} finally {
|
||||
probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('store: flag set + gateway tail-cap (omittedNote) + full raw result → body derived from the raw result', async () => {
|
||||
test('flag=50: an explicit cap is RESTORED — 50 lines + the honest "+200 more lines" note', async () => {
|
||||
await withFlag('50', async () => {
|
||||
const lines = Array.from({ length: 250 }, (_, i) => `row-${String(i + 1).padStart(3, '0')}`)
|
||||
const part: ToolPartState = {
|
||||
type: 'tool',
|
||||
id: 'u3',
|
||||
name: 'terminal',
|
||||
state: 'complete',
|
||||
args: { command: 'seq 1 250' },
|
||||
resultText: lines.join('\n')
|
||||
}
|
||||
const probe = await renderProbe(
|
||||
() => (
|
||||
<ThemeProvider>
|
||||
<BashToolBody part={part} width={70} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width: 80, height: 260 }
|
||||
)
|
||||
try {
|
||||
const frame = await probe.waitForFrame(f => f.includes('+200 more lines'))
|
||||
expect(frame).toContain('row-050')
|
||||
expect(frame).not.toContain('row-051')
|
||||
expect(frame).toContain('… +200 more lines')
|
||||
} finally {
|
||||
probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('store: unlimited cap + gateway tail-cap (omittedNote) + full raw result → body derived from the raw result', async () => {
|
||||
const full = Array.from({ length: 250 }, (_, i) => `full-${i + 1}`).join('\n')
|
||||
const cappedTail = ['full-241', 'full-242', 'full-243'].join('\n') // what the gateway kept
|
||||
const payload = {
|
||||
|
|
@ -675,8 +712,19 @@ describe('HERMES_TUI_TOOL_OUTPUT_LINES — expanded-output line cap (TUI-only en
|
|||
expect(part?.omittedNote).toBeUndefined() // the note no longer applies
|
||||
})
|
||||
|
||||
// flag UNSET → today's behavior: the gateway tail + the tidy omitted note
|
||||
// flag UNSET → unlimited is the DEFAULT now: the full raw result wins too
|
||||
await withFlag(undefined, () => {
|
||||
const store = createSessionStore()
|
||||
seedTool(store, { tool_id: 'p1', name: 'terminal' }, payload)
|
||||
const part = partOf(store)
|
||||
expect(part?.resultText).toContain('full-1\n')
|
||||
expect(part?.resultText).toContain('full-250')
|
||||
expect(part?.omittedNote).toBeUndefined()
|
||||
})
|
||||
|
||||
// an explicit FINITE cap (=50) → the user asked for a bounded view: keep
|
||||
// the gateway tail + the honest omitted note (the view caps further)
|
||||
await withFlag('50', () => {
|
||||
const store = createSessionStore()
|
||||
seedTool(store, { tool_id: 'p1', name: 'terminal' }, payload)
|
||||
const part = partOf(store)
|
||||
|
|
@ -685,7 +733,7 @@ describe('HERMES_TUI_TOOL_OUTPUT_LINES — expanded-output line cap (TUI-only en
|
|||
expect(part?.omittedNote).toBe('240 lines / 2000 chars')
|
||||
})
|
||||
|
||||
// flag set but NO raw result on the wire → keep the tail + note (no crash)
|
||||
// unlimited but NO raw result on the wire → keep the tail + note (no crash)
|
||||
await withFlag('0', () => {
|
||||
const store = createSessionStore()
|
||||
const withoutRaw: Record<string, unknown> = { ...payload }
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
* BashTool — renderer for the shell-ish tools `terminal`, `execute_code` and
|
||||
* `process` (Epic 2.4). Collapsed: the COMMAND BEING INVOKED, verbatim, on one
|
||||
* line (the shell truncates to width; expanding reveals the rest). Expanded:
|
||||
* the full command (`$ `-prefixed, multi-line safe) and the FULL output, kept
|
||||
* to the expanded-lines cap (HERMES_TUI_TOOL_OUTPUT_LINES; default 200, 0 →
|
||||
* unlimited) with the honest omitted / "+N more lines" notes from
|
||||
* the full command (`$ `-prefixed, multi-line safe) and the FULL output —
|
||||
* uncapped by default (HERMES_TUI_TOOL_OUTPUT_LINES restores a cap, e.g.
|
||||
* `=200`) with the honest omitted / "+N more lines" notes from
|
||||
* `logic/toolOutput.ts` (via the shared ToolOutputBlock).
|
||||
*
|
||||
* Arg keys verified against the Python tool schemas:
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
* `(N fields)` / `(N items)` (opencode's primitive-only `input()` idea)
|
||||
* A single field whose value already equals the header's primary-arg preview is
|
||||
* hidden (it adds nothing over the header). The output body keeps the store's
|
||||
* envelope-stripped text, capped to the expanded-lines cap (the
|
||||
* HERMES_TUI_TOOL_OUTPUT_LINES env var; default 200, 0 → unlimited) with the
|
||||
* honest omitted / "+N more lines" notes. `ToolOutputBlock` is shared with the
|
||||
* envelope-stripped text, UNCAPPED by default (the HERMES_TUI_TOOL_OUTPUT_LINES
|
||||
* env var restores a cap, e.g. `=200`) with the honest omitted / "+N more
|
||||
* lines" notes when a cap applies. `ToolOutputBlock` is shared with the
|
||||
* per-tool renderers (bash, …). Fully themed; labels/notes are chrome
|
||||
* (selectable=false).
|
||||
*/
|
||||
|
|
@ -24,11 +24,13 @@ import type { ToolBodyProps, ToolRenderer } from './registry.tsx'
|
|||
|
||||
/**
|
||||
* Max output lines shown when expanded — `HERMES_TUI_TOOL_OUTPUT_LINES` (a
|
||||
* TUI-only env var, not a config.yaml knob): unset/garbage → 200 (a sane cap
|
||||
* to avoid huge renders), positive int → that cap, `0` → Infinity (unlimited).
|
||||
* Memory note: this is safe to lift — tool rows mount collapsed (no body), and
|
||||
* the rolling HERMES_TUI_MAX_MESSAGES cap bounds the transcript's Yoga-node
|
||||
* high-water mark; an expanded body's nodes free on collapse/unmount.
|
||||
* TUI-only env var, not a config.yaml knob): unset/garbage/`0` → Infinity
|
||||
* (UNLIMITED, the default — all tool output is viewable), positive int →
|
||||
* restore that cap.
|
||||
* Memory note: unlimited-by-default is safe — tool rows mount collapsed (no
|
||||
* body), bodies only exist while EXPANDED, and the rolling
|
||||
* HERMES_TUI_MAX_MESSAGES cap bounds the transcript's Yoga-node high-water
|
||||
* mark; an expanded body's nodes free on collapse/unmount.
|
||||
*/
|
||||
export function expandedMaxLines(): number {
|
||||
return envOutputLines(process.env.HERMES_TUI_TOOL_OUTPUT_LINES)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue