mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-13 14:02:16 +00:00
213 lines
9.4 KiB
TypeScript
213 lines
9.4 KiB
TypeScript
/**
|
|
* DefaultTool — the fallback renderer for every unmapped tool, incl. MCP tools
|
|
* (Epic 2.2: kill the raw-JSON args path). Expanded args render as LABELED
|
|
* FIELDS (key → value rows), never a JSON dump:
|
|
* - strings verbatim, flattened to one line + truncated to the frame width
|
|
* - numbers/booleans via String()
|
|
* - arrays of primitives joined ("a, b, c"); anything nested summarized as
|
|
* `(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, 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).
|
|
*/
|
|
import { createMemo, For, Show } from 'solid-js'
|
|
|
|
import { envOutputLines } from '../../logic/env.ts'
|
|
import type { ToolPartState } from '../../logic/store.ts'
|
|
import { collapseToolOutput, truncate } from '../../logic/toolOutput.ts'
|
|
import { useTheme } from '../theme.tsx'
|
|
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/`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)
|
|
}
|
|
/** Max labeled arg fields shown when expanded. */
|
|
const FIELDS_MAX = 16
|
|
|
|
/**
|
|
* The tool's structured args. SECURITY: parse `part.argsText` FIRST — when it
|
|
* came from the gateway's verbose `args_text` (tool.start) it is REDACTED
|
|
* (server.py `_tool_args_text` masks secrets), while the raw `args` dict on
|
|
* `tool.complete` is sent unredacted. The store never overwrites a tool.start
|
|
* argsText — `tool.complete` only back-fills it by stringifying raw `args`
|
|
* when absent (store.ts) — so this precedence yields redacted values whenever
|
|
* the gateway sent them, and the same raw args as before otherwise. Raw
|
|
* `part.args` is the fallback when argsText is absent or unparseable.
|
|
*/
|
|
export function structuredArgs(part: ToolPartState): Record<string, unknown> | undefined {
|
|
if (part.argsText) {
|
|
try {
|
|
const o: unknown = JSON.parse(part.argsText)
|
|
if (o && typeof o === 'object' && !Array.isArray(o)) return o as Record<string, unknown>
|
|
} catch {
|
|
/* unparseable argsText (e.g. capped mid-JSON) — fall back to raw args */
|
|
}
|
|
}
|
|
return part.args
|
|
}
|
|
|
|
/**
|
|
* The tool's structured RESULT object — `part.result` (the raw dict shipped on
|
|
* tool.complete, captured by the store) first; falls back to parsing
|
|
* `part.resultText` when it still looks like intact JSON (covers resumed
|
|
* sessions, which hydrate result_text only). NOTE the fallback is best-effort:
|
|
* the store's normalizeOutput un-escapes literal `\n` inside JSON string
|
|
* values, so multi-line payloads (read_file content, skill_view content) only
|
|
* survive via `part.result`.
|
|
*/
|
|
export function structuredResult(part: ToolPartState): Record<string, unknown> | undefined {
|
|
if (part.result) return part.result
|
|
const s = (part.resultText ?? '').trim()
|
|
if (!s.startsWith('{')) return undefined
|
|
try {
|
|
const o: unknown = JSON.parse(s)
|
|
if (o && typeof o === 'object' && !Array.isArray(o)) return o as Record<string, unknown>
|
|
} catch {
|
|
/* capped/mangled JSON — no structured result */
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function isPrimitive(v: unknown): v is string | number | boolean {
|
|
return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
|
|
}
|
|
|
|
/** One-line display value for an arg: primitives verbatim, nesting summarized. */
|
|
function fieldValue(v: unknown): string {
|
|
if (typeof v === 'string') return v.replace(/\s+/g, ' ').trim()
|
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
|
if (v === null || v === undefined) return '∅'
|
|
if (Array.isArray(v)) {
|
|
if (v.length > 0 && v.every(isPrimitive)) return v.map(String).join(', ')
|
|
return `(${v.length} item${v.length === 1 ? '' : 's'})`
|
|
}
|
|
const n = Object.keys(v).length
|
|
return `(${n} field${n === 1 ? '' : 's'})`
|
|
}
|
|
|
|
/**
|
|
* Labeled key→value rows for the expanded args (NEVER raw JSON). `omit` is the
|
|
* per-tool NOISE-FIELD suppression hook (items 1 + 2): a renderer passes the
|
|
* arg keys its expanded view shouldn't repeat (read_file's limit/offset,
|
|
* search_files' context/output_mode, keys already shown as the subtitle).
|
|
*/
|
|
export function argFields(part: ToolPartState, omit?: readonly string[]): Array<[string, string]> {
|
|
const obj = structuredArgs(part)
|
|
if (!obj) return []
|
|
const entries = Object.entries(obj)
|
|
.filter(([k]) => !omit?.includes(k))
|
|
.map(([k, v]): [string, string] => [k, fieldValue(v)])
|
|
// A single field whose value is already the header's primary-arg preview adds
|
|
// nothing over the header (e.g. terminal's `command`) — hide it (kept from
|
|
// the pre-registry render's redundancy rule).
|
|
const only = entries.length === 1 ? entries[0] : undefined
|
|
if (only && only[1] === (part.argsPreview ?? '').trim()) return []
|
|
return entries
|
|
}
|
|
|
|
/** The settled output body, trailing-whitespace-trimmed, split to lines. */
|
|
export function resultLines(part: ToolPartState): string[] {
|
|
const r = (part.resultText ?? '').replace(/\s+$/, '')
|
|
return r ? r.split('\n') : []
|
|
}
|
|
|
|
/** Collapsed subtitle: primary-arg preview, else summary, else the first output line. */
|
|
export function defaultSubtitle(part: ToolPartState): string {
|
|
return part.argsPreview || part.summary || resultLines(part)[0] || ''
|
|
}
|
|
|
|
/**
|
|
* The output section of an expanded tool body — shared by the default and the
|
|
* per-tool renderers. Caps to expandedMaxLines() and renders the honest
|
|
* truncation notes (`omittedNote` from the gateway cap; "+N more lines" from ours).
|
|
*/
|
|
export function ToolOutputBlock(props: { part: ToolPartState; width: number; label?: boolean }) {
|
|
const theme = useTheme()
|
|
const result = () => (props.part.resultText ?? '').replace(/\s+$/, '')
|
|
const body = createMemo(() => collapseToolOutput(result(), expandedMaxLines(), props.width))
|
|
return (
|
|
<Show when={result()}>
|
|
{/* section label — chrome, not content */}
|
|
<Show when={props.label}>
|
|
<text selectable={false}>
|
|
<span style={{ fg: theme().color.label }}>output</span>
|
|
</text>
|
|
</Show>
|
|
{/* output body lines are the copyable content → themed selection bar */}
|
|
<For each={body().lines}>
|
|
{line => (
|
|
<text selectionBg={theme().color.selectionBg}>
|
|
<span style={{ fg: theme().color.muted }}>{line}</span>
|
|
</text>
|
|
)}
|
|
</For>
|
|
{/* truncation annotations — chrome (not part of the real output body) */}
|
|
<Show when={props.part.omittedNote}>
|
|
<text selectable={false}>
|
|
<span style={{ fg: theme().color.muted }}>{`… omitted ${props.part.omittedNote}`}</span>
|
|
</text>
|
|
</Show>
|
|
<Show when={body().hiddenLines > 0 && !props.part.omittedNote}>
|
|
<text selectable={false}>
|
|
<span style={{ fg: theme().color.accent }}>{`… +${body().hiddenLines} more lines`}</span>
|
|
</text>
|
|
</Show>
|
|
</Show>
|
|
)
|
|
}
|
|
|
|
/** Expanded body: labeled arg fields, then the (capped) output block.
|
|
* `omitFields` = the per-tool noise-field suppression list (see argFields). */
|
|
export function DefaultToolBody(props: ToolBodyProps & { omitFields?: readonly string[] }) {
|
|
const theme = useTheme()
|
|
const fields = createMemo(() => argFields(props.part, props.omitFields))
|
|
return (
|
|
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
|
|
<Show when={fields().length > 0}>
|
|
<For each={fields().slice(0, FIELDS_MAX)}>
|
|
{([key, value]) => (
|
|
<text selectionBg={theme().color.selectionBg}>
|
|
<span style={{ fg: theme().color.label }}>{key}</span>
|
|
<span style={{ fg: theme().color.muted }}>
|
|
{` ${truncate(value, Math.max(1, props.width - key.length - 2))}`}
|
|
</span>
|
|
</text>
|
|
)}
|
|
</For>
|
|
<Show when={fields().length > FIELDS_MAX}>
|
|
{/* overflow annotation — chrome, not content */}
|
|
<text selectable={false}>
|
|
<span style={{ fg: theme().color.accent }}>{`… +${fields().length - FIELDS_MAX} more`}</span>
|
|
</text>
|
|
</Show>
|
|
</Show>
|
|
<ToolOutputBlock part={props.part} width={props.width} label={fields().length > 0} />
|
|
</box>
|
|
)
|
|
}
|
|
|
|
export const defaultRenderer: ToolRenderer = {
|
|
Body: DefaultToolBody,
|
|
// Expandable when there's a body beyond the header: multi-line output, labeled
|
|
// arg fields, or a single output line the subtitle doesn't already show
|
|
// (argsPreview/summary win the subtitle, hiding that line when collapsed).
|
|
expandable: part =>
|
|
resultLines(part).length > 1 ||
|
|
argFields(part).length > 0 ||
|
|
(resultLines(part).length === 1 && Boolean(part.argsPreview || part.summary)),
|
|
subtitle: defaultSubtitle
|
|
}
|