mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-12 13:52:15 +00:00
opentui(v5/item1): resumed tools render like live (collapsible + output)
Resumed tool calls were flat `⚡name arg` rows — no output, not collapsible — because the resume snapshot (_history_to_messages) dropped each tool's result. Now the native engine passes `with_tool_output: true` on session.resume and the gateway folds the tool's redacted+capped result + args into its row, so resumed turns show `▶ name arg (N lines)` collapsible blocks identical to a live turn. The flag is OPT-IN: Ink doesn't pass it, so _history_to_messages stays byte-for- byte unchanged for the Ink path (its expanded verbose-trail render OOM'd on big output, #34095; the native engine renders tools collapsed, so the capped tail is safe there). resume.ts maps context→argsPreview, result_text→resultText (label peeled + envelope stripped), args→argsText — same shape as the live tool part. py_compile OK. resume tests updated + 1 added (79 pass).
This commit is contained in:
parent
aec752faa3
commit
ee211d087b
4 changed files with 68 additions and 11 deletions
|
|
@ -2931,7 +2931,13 @@ def _coerce_message_text(content: Any) -> str:
|
|||
return str(content)
|
||||
|
||||
|
||||
def _history_to_messages(history: list[dict]) -> list[dict]:
|
||||
def _history_to_messages(history: list[dict], include_tool_output: bool = False) -> list[dict]:
|
||||
# ``include_tool_output`` (opt-in; only the native/opentui engine passes it via
|
||||
# session.resume) folds each tool's redacted+capped result + args into its row so
|
||||
# a resumed transcript renders collapsible tool blocks identical to a live turn.
|
||||
# OFF by default so the Ink path is byte-for-byte unchanged (its render tree showed
|
||||
# the verbose trail expanded and OOM'd on big output — #34095; the native engine
|
||||
# renders tools collapsed, so shipping the same capped tail is safe there).
|
||||
messages = []
|
||||
tool_call_args = {}
|
||||
|
||||
|
|
@ -2959,9 +2965,13 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
|
|||
tc_info = tool_call_args.get(tc_id) if tc_id else None
|
||||
name = (tc_info[0] if tc_info else None) or m.get("tool_name") or "tool"
|
||||
args = (tc_info[1] if tc_info else None) or {}
|
||||
messages.append(
|
||||
{"role": "tool", "name": name, "context": _tool_ctx(name, args)}
|
||||
)
|
||||
tool_msg = {"role": "tool", "name": name, "context": _tool_ctx(name, args)}
|
||||
if include_tool_output:
|
||||
if args:
|
||||
tool_msg["args"] = args
|
||||
if content_text.strip():
|
||||
tool_msg["result_text"] = _redact_tui_verbose_text(content_text)
|
||||
messages.append(tool_msg)
|
||||
continue
|
||||
if not content_text.strip():
|
||||
continue
|
||||
|
|
@ -3338,7 +3348,9 @@ def _(rid, params: dict) -> dict:
|
|||
display_history_prefix = display_history[
|
||||
: max(0, len(display_history) - len(history))
|
||||
]
|
||||
messages = _history_to_messages(display_history)
|
||||
messages = _history_to_messages(
|
||||
display_history, include_tool_output=bool(params.get("with_tool_output"))
|
||||
)
|
||||
tokens = _set_session_context(target)
|
||||
try:
|
||||
# Pass the profile's db so the agent persists turns to the right
|
||||
|
|
|
|||
|
|
@ -66,7 +66,10 @@ const resumeInto = (gateway: GatewayServiceShape, store: SessionStore, sid: stri
|
|||
const t0 = Date.now()
|
||||
const resumed = yield* gateway.request<{ messages?: unknown; info?: Record<string, unknown> }>('session.resume', {
|
||||
cols,
|
||||
session_id: sid
|
||||
session_id: sid,
|
||||
// native engine renders tools collapsed → safe to fold each tool's capped
|
||||
// result into the resume snapshot so resumed turns render like live (item 1).
|
||||
with_tool_output: true
|
||||
})
|
||||
const t1 = Date.now()
|
||||
const snapshot = mapResumeHistory(resumed?.messages)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
* a live one. Resumed assistant text is given a single text part so it renders
|
||||
* through the native markdown path. IDs are `r*` (distinct from live `p*`).
|
||||
*/
|
||||
import type { Message, Part, SessionItem } from './store.ts'
|
||||
import type { Message, Part, SessionItem, ToolPartState } from './store.ts'
|
||||
import { stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
|
||||
|
||||
function readStr(value: unknown, key: string): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
|
|
@ -55,8 +56,28 @@ export function mapResumeHistory(history: unknown): Message[] {
|
|||
if (role === 'tool') {
|
||||
const name = readStr(raw, 'name') ?? 'tool'
|
||||
const context = readStr(raw, 'context')
|
||||
const tool: Part = { type: 'tool', id: id(), name, state: 'complete' }
|
||||
if (context) tool.summary = context
|
||||
const tool: ToolPartState = { type: 'tool', id: id(), name, state: 'complete' }
|
||||
// Match the live tool part exactly (item 1): primary-arg preview in the
|
||||
// header, plus the (capped) output so resumed tools are collapsible too.
|
||||
if (context) tool.argsPreview = context
|
||||
const rawResult = readStr(raw, 'result_text')
|
||||
if (rawResult) {
|
||||
const { body, omittedNote } = stripOmittedNote(rawResult)
|
||||
const resultText = stripToolEnvelope(body)
|
||||
if (resultText) {
|
||||
tool.resultText = resultText
|
||||
tool.lineCount = resultText.replace(/\s+$/, '').split('\n').length
|
||||
}
|
||||
if (omittedNote) tool.omittedNote = omittedNote
|
||||
}
|
||||
const args = (raw as { args?: unknown }).args
|
||||
if (args && typeof args === 'object') {
|
||||
try {
|
||||
tool.argsText = JSON.stringify(args, null, 2)
|
||||
} catch {
|
||||
/* unstringifiable — leave unset */
|
||||
}
|
||||
}
|
||||
if (!currentAssistant) {
|
||||
currentAssistant = { role: 'assistant', text: '', parts: [] }
|
||||
out.push(currentAssistant)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ describe('mapResumeHistory (Phase 4b)', () => {
|
|||
expect(a1.parts?.map(p => p.type)).toEqual(['text', 'tool']) // text + folded tool, inline
|
||||
const tool = a1.parts![1]!
|
||||
if (tool.type === 'tool') {
|
||||
expect(tool).toMatchObject({ name: 'terminal', state: 'complete', summary: 'ls -la' })
|
||||
// context → argsPreview (same field as a live tool part, so it renders identically)
|
||||
expect(tool).toMatchObject({ name: 'terminal', state: 'complete', argsPreview: 'ls -la' })
|
||||
} else {
|
||||
throw new Error('expected a folded tool part')
|
||||
}
|
||||
|
|
@ -33,7 +34,27 @@ describe('mapResumeHistory (Phase 4b)', () => {
|
|||
const msgs = mapResumeHistory([{ role: 'tool', name: 'read_file', context: 'foo.ts' }])
|
||||
expect(msgs).toHaveLength(1)
|
||||
expect(msgs[0]!.role).toBe('assistant')
|
||||
expect(msgs[0]!.parts?.[0]).toMatchObject({ type: 'tool', name: 'read_file', summary: 'foo.ts' })
|
||||
expect(msgs[0]!.parts?.[0]).toMatchObject({ type: 'tool', name: 'read_file', argsPreview: 'foo.ts' })
|
||||
})
|
||||
|
||||
test('folds result_text + args so resumed tools render collapsible like live (item 1)', () => {
|
||||
const msgs = mapResumeHistory([
|
||||
{ role: 'assistant', text: 'Running.' },
|
||||
{
|
||||
role: 'tool',
|
||||
name: 'terminal',
|
||||
context: 'ls /usr/bin',
|
||||
args: { command: 'ls /usr/bin' },
|
||||
result_text: '[showing verbose tail; omitted 90 chars]\n{"output":"a\\nb\\nc","exit_code":0}'
|
||||
}
|
||||
])
|
||||
const tool = msgs[0]!.parts![1]!
|
||||
if (tool.type !== 'tool') throw new Error('expected a folded tool part')
|
||||
expect(tool.argsPreview).toBe('ls /usr/bin')
|
||||
expect(tool.resultText).toBe('a\nb\nc') // label peeled + envelope stripped → collapsible
|
||||
expect(tool.lineCount).toBe(3)
|
||||
expect(tool.omittedNote).toBe('90 chars')
|
||||
expect(tool.argsText).toContain('"command"')
|
||||
})
|
||||
|
||||
test('ignores non-arrays and unknown roles', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue