mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-18 09:51:59 +00:00
A heavy --tui session (browser snapshots, large tool outputs) silently OOM-killed the Node parent within minutes — closing the gateway child's stdin, which the user saw only as a bare "gateway exited" / stdin EOF. CLI was immune. Root cause: each completed tool's verbose trail line embedded up to 16KB of result_text, persisted in transcript Msg.tools[] for the whole session and rendered EXPANDED by default, so an Ink render-node tree was built for every one of up to 800 messages at once. That tree blew past Node's heap at a few hundred MB — far below the 2.5GB memory-monitor exit threshold, so the death was never even attributed. - text.ts: persisted verbose tool-trail blocks now cap to a small preview (VERBOSE_TRAIL_MAX_CHARS=800/12 lines), not the 16KB live-render budget. Retained trail strings drop ~17x (12.2MB -> 0.7MB at 800 msgs); the live streaming tail still uses the larger LIVE_RENDER budget. - tui_gateway/server.py: lower the gateway-side verbose text cap to match (1KB/16 lines) so we stop shipping output the TUI no longer renders. - memoryMonitor.ts: derive critical/high thresholds from the real V8 heap ceiling (~88%/70%) instead of the hardcoded 2.5GB that killed the process at 31% of an 8GB ceiling; add a one-shot onWarn early-warning on fast sub-threshold heap growth so the next such death is diagnosable, not silent. - entry.tsx: wire onWarn to a crash-log breadcrumb + stderr line. Full tool output is unchanged in the agent context and SQLite session — this is display/transport only, no behavior or context change. Fixes #34095. Related #27282. Tests: ui-tui text + new memoryMonitor suites (33 pass), python verbose-cap guard (5 pass); full ui-tui suite shows no new failures vs pristine main. E2E repro confirms the retention drop.
256 lines
8.4 KiB
TypeScript
256 lines
8.4 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
boundedLiveRenderText,
|
|
buildToolTrailLine,
|
|
buildVerboseToolTrailLine,
|
|
edgePreview,
|
|
estimateRows,
|
|
estimateTokensRough,
|
|
fmtK,
|
|
hasAnsi,
|
|
isToolTrailResultLine,
|
|
lastCotTrailIndex,
|
|
parseToolTrailResultLine,
|
|
pasteTokenLabel,
|
|
sameToolTrailGroup,
|
|
sanitizeAnsiForRender,
|
|
splitToolDuration,
|
|
stripAnsi,
|
|
thinkingPreview
|
|
} from '../lib/text.js'
|
|
|
|
describe('isToolTrailResultLine', () => {
|
|
it('detects completion markers', () => {
|
|
expect(isToolTrailResultLine('foo ✓')).toBe(true)
|
|
expect(isToolTrailResultLine('foo ✗')).toBe(true)
|
|
expect(isToolTrailResultLine('drafting x…')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('buildToolTrailLine', () => {
|
|
it('puts completion duration inline before the result marker', () => {
|
|
const line = buildToolTrailLine('read_file', 'x', false, '', 0.94)
|
|
|
|
expect(line).toBe('Read File("x") (0.9s) ✓')
|
|
expect(parseToolTrailResultLine(line)).toEqual({ call: 'Read File("x") (0.9s)', detail: '', mark: '✓' })
|
|
expect(splitToolDuration('Read File("x") (0.9s)')).toEqual({ label: 'Read File("x")', duration: ' (0.9s)' })
|
|
})
|
|
})
|
|
|
|
describe('buildVerboseToolTrailLine', () => {
|
|
it('preserves multiline args and result details', () => {
|
|
const line = buildVerboseToolTrailLine(
|
|
'terminal',
|
|
'npm test',
|
|
false,
|
|
1.25,
|
|
'{\n "cmd": "npm test"\n}',
|
|
'first line\nsecond :: line'
|
|
)
|
|
|
|
expect(line).toContain('Args:\n{')
|
|
expect(line).toContain('Result:\nfirst line\nsecond :: line')
|
|
expect(parseToolTrailResultLine(line)).toEqual({
|
|
call: 'Terminal("npm test") (1.3s)',
|
|
detail: 'Args:\n{\n "cmd": "npm test"\n}\nResult:\nfirst line\nsecond :: line',
|
|
mark: '✓'
|
|
})
|
|
})
|
|
|
|
it('labels verbose failures as errors', () => {
|
|
const line = buildVerboseToolTrailLine('terminal', 'npm test', true, 0.5, undefined, 'command failed')
|
|
|
|
expect(line).toContain('Error:\ncommand failed')
|
|
expect(line).not.toContain('Result:\ncommand failed')
|
|
expect(parseToolTrailResultLine(line)).toEqual({
|
|
call: 'Terminal("npm test") (0.5s)',
|
|
detail: 'Error:\ncommand failed',
|
|
mark: '✗'
|
|
})
|
|
})
|
|
|
|
it('caps a large result to a small persisted preview (#34095)', () => {
|
|
// A 40KB browser-snapshot-sized result must NOT be embedded whole — the
|
|
// persisted, expanded-by-default trail block is what blew up the Ink
|
|
// render tree and silently OOM-killed the TUI. The block stays small.
|
|
const huge = 'A'.repeat(40_000)
|
|
const line = buildVerboseToolTrailLine('browser_snapshot', 'https://x.example', false, 2, undefined, huge)
|
|
|
|
expect(line).toContain('Result:\n')
|
|
// Far below the old 16KB live-render budget; the whole line (call + label +
|
|
// omitted marker + preview) must stay on the order of ~1KB, not ~40KB.
|
|
expect(line.length).toBeLessThan(2_000)
|
|
expect(line).toContain('omitted')
|
|
expect(line.endsWith(' ✓')).toBe(true)
|
|
})
|
|
|
|
it('does not truncate a result that already fits the preview budget', () => {
|
|
const small = 'ok: 3 files changed'
|
|
const line = buildVerboseToolTrailLine('patch', 'index.html', false, 0.1, undefined, small)
|
|
|
|
expect(line).toContain(`Result:\n${small}`)
|
|
expect(line).not.toContain('omitted')
|
|
})
|
|
})
|
|
|
|
describe('lastCotTrailIndex', () => {
|
|
it('finds last non-result line', () => {
|
|
expect(lastCotTrailIndex(['a ✓', 'thinking…'])).toBe(1)
|
|
expect(lastCotTrailIndex(['only result ✓'])).toBe(-1)
|
|
})
|
|
})
|
|
|
|
describe('sameToolTrailGroup', () => {
|
|
it('matches bare check lines', () => {
|
|
expect(sameToolTrailGroup('searching', 'searching ✓')).toBe(true)
|
|
expect(sameToolTrailGroup('searching', 'searching ✗')).toBe(true)
|
|
})
|
|
|
|
it('matches contextual lines', () => {
|
|
expect(sameToolTrailGroup('searching', 'searching: * ✓')).toBe(true)
|
|
expect(sameToolTrailGroup('searching', 'searching: foo ✓')).toBe(true)
|
|
})
|
|
|
|
it('rejects other tools', () => {
|
|
expect(sameToolTrailGroup('searching', 'reading ✓')).toBe(false)
|
|
expect(sameToolTrailGroup('searching', 'searching extra ✓')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('fmtK', () => {
|
|
it('keeps small numbers plain', () => {
|
|
expect(fmtK(999)).toBe('999')
|
|
})
|
|
|
|
it('formats thousands as lowercase k', () => {
|
|
expect(fmtK(1000)).toBe('1k')
|
|
expect(fmtK(1500)).toBe('1.5k')
|
|
})
|
|
|
|
it('formats millions and billions with lowercase suffixes', () => {
|
|
expect(fmtK(1_000_000)).toBe('1m')
|
|
expect(fmtK(1_000_000_000)).toBe('1b')
|
|
})
|
|
})
|
|
|
|
describe('estimateTokensRough', () => {
|
|
it('uses 4 chars per token rounding up', () => {
|
|
expect(estimateTokensRough('')).toBe(0)
|
|
expect(estimateTokensRough('a')).toBe(1)
|
|
expect(estimateTokensRough('abcd')).toBe(1)
|
|
expect(estimateTokensRough('abcde')).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('ANSI sanitizers', () => {
|
|
const ESC = String.fromCharCode(27)
|
|
const BEL = String.fromCharCode(7)
|
|
|
|
it('strips CSI/OSC/control bytes from plain previews', () => {
|
|
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}C${ESC}[?25lD`
|
|
|
|
expect(stripAnsi(sample)).toBe('ABCD')
|
|
})
|
|
|
|
it('strips incomplete CSI prefixes and carriage returns', () => {
|
|
const sample = `A${ESC}[31mB${ESC}[12;${ESC}[CD\rE`
|
|
|
|
expect(stripAnsi(sample)).toBe('ABDE')
|
|
})
|
|
|
|
it('keeps SGR color spans but removes cursor controls for Ansi rendering', () => {
|
|
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}${ESC}[?25lC`
|
|
|
|
expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mC`)
|
|
})
|
|
|
|
it('keeps valid SGR while removing dangling CSI and carriage returns', () => {
|
|
const sample = `A${ESC}[31mB${ESC}[12;${ESC}[39mC\rD`
|
|
|
|
expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mCD`)
|
|
})
|
|
|
|
it('strips multi-byte non-CSI ESC sequences without leaving trailing bytes', () => {
|
|
const sample = `A${ESC}(0B${ESC}%GC${ESC})0D`
|
|
|
|
expect(stripAnsi(sample)).toBe('ABCD')
|
|
expect(sanitizeAnsiForRender(sample)).toBe('ABCD')
|
|
})
|
|
|
|
it('detects non-CSI escape prefixes too', () => {
|
|
expect(hasAnsi(`ok${ESC}Ppayload${ESC}\\`)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('thinkingPreview', () => {
|
|
it('adds paragraph breaks before markdown thinking headings', () => {
|
|
const raw =
|
|
'**Considering user instructions**\nI need to answer.**Planning tool execution**\nI can run tools.**Determining weather search parameters**\nUse SF.'
|
|
|
|
expect(thinkingPreview(raw, 'full')).toBe(
|
|
'**Considering user instructions**\nI need to answer.\n\n**Planning tool execution**\nI can run tools.\n\n**Determining weather search parameters**\nUse SF.'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('boundedLiveRenderText', () => {
|
|
it('preserves short live text verbatim', () => {
|
|
expect(boundedLiveRenderText('one\ntwo', { maxChars: 100, maxLines: 10 })).toBe('one\ntwo')
|
|
})
|
|
|
|
it('keeps the live tail by character budget', () => {
|
|
const out = boundedLiveRenderText('abcdefghij', { maxChars: 4, maxLines: 10 })
|
|
|
|
expect(out).toContain('ghij')
|
|
expect(out).toContain('omitted')
|
|
expect(out).not.toContain('abcdef')
|
|
})
|
|
|
|
it('keeps the live tail by line budget', () => {
|
|
const out = boundedLiveRenderText(['a', 'b', 'c', 'd'].join('\n'), { maxChars: 100, maxLines: 2 })
|
|
|
|
expect(out).toContain('c\nd')
|
|
expect(out).toContain('omitted 2 lines')
|
|
expect(out).not.toContain('a\nb')
|
|
})
|
|
})
|
|
|
|
describe('edgePreview', () => {
|
|
it('keeps both ends for long text', () => {
|
|
expect(edgePreview('Vampire Bondage ropes slipped from her neck, still stained with blood', 8, 18)).toBe(
|
|
'Vampire.. stained with blood'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('pasteTokenLabel', () => {
|
|
it('builds readable long-paste labels with counts', () => {
|
|
const label = pasteTokenLabel('Vampire Bondage ropes slipped from her neck, still stained with blood', 250)
|
|
expect(label.startsWith('[[ ')).toBe(true)
|
|
expect(label).toContain('[250 lines]')
|
|
expect(label.endsWith(' ]]')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('estimateRows', () => {
|
|
it('handles tilde code fences', () => {
|
|
const md = ['~~~markdown', '# heading', '~~~'].join('\n')
|
|
|
|
expect(estimateRows(md, 40)).toBeGreaterThanOrEqual(2)
|
|
})
|
|
|
|
it('handles checklist bullets as list rows', () => {
|
|
const md = ['- [x] done', '- [ ] todo'].join('\n')
|
|
|
|
expect(estimateRows(md, 40)).toBe(2)
|
|
})
|
|
|
|
it('keeps intraword underscores when sizing snake_case identifiers', () => {
|
|
const w = 80
|
|
const snake = 'look at test_case_with_underscores now'
|
|
const plain = 'look at test case with underscores now'
|
|
|
|
expect(estimateRows(snake, w)).toBe(estimateRows(plain, w))
|
|
})
|
|
})
|