mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): summarize a run of tool calls as one line
Adds the grammar behind "Edited wiring.tsx, explored 3 files, ran 5 commands": one clause per category of work, a name when the category holds a single thing and a count otherwise, and the present tense for whichever category is still running. The continuity test is the load-bearing part. Tool grouping was reverted once because it reshuffled the moment a turn settled, so this replays the same turn twice — as the gateway event stream the live view builds bubbles from, and as the rows toChatMessages rehydrates on resume — and asserts both produce the same runs.
This commit is contained in:
parent
9f02bb207d
commit
97d790d8b1
4 changed files with 483 additions and 2 deletions
|
|
@ -65,7 +65,7 @@ function fileEditPath(args: Record<string, unknown>, result: Record<string, unkn
|
|||
)
|
||||
}
|
||||
|
||||
function fileEditBasename(path: string): string {
|
||||
export function fileEditBasename(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/').trim()
|
||||
|
||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
|
|
@ -585,7 +585,7 @@ function summarizeBrowserSnapshot(snapshot: string): string {
|
|||
return labels.length ? `${stats}\nTop controls: ${labels.join(', ')}` : stats
|
||||
}
|
||||
|
||||
function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string {
|
||||
export function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { summarizeToolRun, type ToolCallLike } from './run-summary'
|
||||
|
||||
function tool(toolName: string, args: Record<string, unknown> = {}, result?: unknown): ToolCallLike {
|
||||
return { args, result, toolCallId: `${toolName}-${Math.random()}`, toolName }
|
||||
}
|
||||
|
||||
const edited = (path: string, diff = '') => tool('write_file', { path }, { path, inline_diff: diff })
|
||||
const read = (path: string) => tool('read_file', { path }, { content: '' })
|
||||
const ran = (command: string) => tool('terminal', { command }, { exit_code: 0 })
|
||||
|
||||
describe('summarizeToolRun', () => {
|
||||
it('names a lone edit and counts the rest', () => {
|
||||
expect(
|
||||
summarizeToolRun([edited('src/use-preview-routing.ts'), read('a.ts'), read('b.ts'), read('c.ts')]).text
|
||||
).toBe('Edited use-preview-routing.ts, explored 3 files')
|
||||
})
|
||||
|
||||
it('orders clauses edit, explore, run regardless of call order', () => {
|
||||
expect(
|
||||
summarizeToolRun([ran('ls'), read('a.ts'), edited('src/attachments.tsx'), read('b.ts'), ran('pwd'), ran('id')])
|
||||
.text
|
||||
).toBe('Edited attachments.tsx, explored 2 files, ran 3 commands')
|
||||
})
|
||||
|
||||
it('counts commands rather than naming them once they have run', () => {
|
||||
expect(summarizeToolRun([ran('git status')]).text).toBe('Ran 1 command')
|
||||
expect(summarizeToolRun([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')]).text).toBe(
|
||||
'Explored status.ts, ran 5 commands'
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a multi-file edit', () => {
|
||||
expect(summarizeToolRun([edited('a.tsx'), edited('b.tsx'), read('c.ts')]).text).toBe(
|
||||
'Edited 2 files, explored c.ts'
|
||||
)
|
||||
})
|
||||
|
||||
it('puts the running category in the present tense and leaves the rest past', () => {
|
||||
const live = [edited('a.tsx'), tool('write_file', { path: 'b.tsx' }), ran('x'), ran('y')]
|
||||
|
||||
expect(summarizeToolRun(live).text).toBe('Editing 2 files, ran 2 commands')
|
||||
})
|
||||
|
||||
it('names the command that is still running', () => {
|
||||
expect(summarizeToolRun([tool('terminal', { command: 'npm run typecheck' })]).text).toMatch(/^Running /)
|
||||
})
|
||||
|
||||
it('sums diff stats across the edits in the run', () => {
|
||||
const summary = summarizeToolRun([
|
||||
edited('a.tsx', '--- a\n+++ b\n+one\n+two\n-old'),
|
||||
edited('b.tsx', '+three'),
|
||||
ran('ls')
|
||||
])
|
||||
|
||||
expect(summary).toMatchObject({ added: 3, removed: 1 })
|
||||
})
|
||||
|
||||
it('reports no diff stats for a run that changed nothing', () => {
|
||||
expect(summarizeToolRun([read('a.ts'), ran('ls')])).toMatchObject({ added: 0, removed: 0 })
|
||||
})
|
||||
})
|
||||
168
apps/desktop/src/components/assistant-ui/tool/run-summary.ts
Normal file
168
apps/desktop/src/components/assistant-ui/tool/run-summary.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { summarizeShellCommand } from '@/lib/summarize-command'
|
||||
|
||||
import {
|
||||
countDiffLineStats,
|
||||
fileEditBasename,
|
||||
firstStringField,
|
||||
inlineDiffFromResult,
|
||||
isFileEditTool,
|
||||
parseMaybeObject
|
||||
} from './fallback-model'
|
||||
|
||||
/**
|
||||
* The little a summary needs from a tool call, stated structurally so both
|
||||
* shapes of tool part satisfy it — the stored `ChatMessagePart` and the live
|
||||
* one assistant-ui hands to a renderer.
|
||||
*/
|
||||
export interface ToolCallLike {
|
||||
args?: unknown
|
||||
result?: unknown
|
||||
toolCallId?: string
|
||||
toolName: string
|
||||
}
|
||||
|
||||
export function isToolCallPart<T extends { type: string }>(part: T): part is Extract<T, { type: 'tool-call' }> {
|
||||
return part.type === 'tool-call'
|
||||
}
|
||||
|
||||
type RunCategory = 'delegate' | 'edit' | 'explore' | 'other' | 'run'
|
||||
|
||||
export interface RunSummary {
|
||||
added: number
|
||||
removed: number
|
||||
text: string
|
||||
}
|
||||
|
||||
// Clause order is fixed so the same run always reads the same way, whichever
|
||||
// category happens to be live.
|
||||
const CATEGORY_ORDER: readonly RunCategory[] = ['edit', 'explore', 'run', 'delegate', 'other']
|
||||
|
||||
const CATEGORY_COPY: Record<RunCategory, { noun: [string, string]; past: string; present: string }> = {
|
||||
delegate: { noun: ['task', 'tasks'], past: 'Delegated', present: 'Delegating' },
|
||||
edit: { noun: ['file', 'files'], past: 'Edited', present: 'Editing' },
|
||||
explore: { noun: ['file', 'files'], past: 'Explored', present: 'Exploring' },
|
||||
other: { noun: ['tool', 'tools'], past: 'Used', present: 'Using' },
|
||||
run: { noun: ['command', 'commands'], past: 'Ran', present: 'Running' }
|
||||
}
|
||||
|
||||
const EXPLORE_TOOLS = new Set([
|
||||
'list_files',
|
||||
'read_file',
|
||||
'search_files',
|
||||
'session_search_recall',
|
||||
'vision_analyze',
|
||||
'web_extract',
|
||||
'web_search'
|
||||
])
|
||||
|
||||
function toolCategory(toolName: string): RunCategory {
|
||||
if (isFileEditTool(toolName)) {
|
||||
return 'edit'
|
||||
}
|
||||
|
||||
if (toolName === 'terminal' || toolName === 'execute_code') {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
if (toolName === 'delegate_task') {
|
||||
return 'delegate'
|
||||
}
|
||||
|
||||
if (EXPLORE_TOOLS.has(toolName) || toolName.startsWith('browser_')) {
|
||||
return 'explore'
|
||||
}
|
||||
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function isPending(tool: ToolCallLike): boolean {
|
||||
return tool.result === undefined
|
||||
}
|
||||
|
||||
/** The thing a tool acted on, as the header should name it. */
|
||||
function toolTarget(tool: ToolCallLike): string {
|
||||
const args = parseMaybeObject(tool.args)
|
||||
|
||||
if (toolCategory(tool.toolName) === 'run') {
|
||||
return summarizeShellCommand(firstStringField(args, ['command', 'code']))
|
||||
}
|
||||
|
||||
const path = firstStringField(args, ['path', 'file', 'filepath'])
|
||||
|
||||
return path ? fileEditBasename(path) : firstStringField(args, ['query', 'url'])
|
||||
}
|
||||
|
||||
function diffStats(tools: readonly ToolCallLike[]): { added: number; removed: number } {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!isFileEditTool(tool.toolName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const stats = countDiffLineStats(inlineDiffFromResult(tool.result))
|
||||
|
||||
added += stats.added
|
||||
removed += stats.removed
|
||||
}
|
||||
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
/**
|
||||
* One clause per category. A category holding a single thing says what it was
|
||||
* ("Edited wiring.tsx"); anything else counts ("explored 3 files"). A settled
|
||||
* command is the exception — "ran 5 commands" is the useful reading, and a
|
||||
* command line only earns its space while it's the thing you're waiting on.
|
||||
*/
|
||||
function clause(category: RunCategory, tools: ToolCallLike[], live: boolean): string {
|
||||
const copy = CATEGORY_COPY[category]
|
||||
const verb = live ? copy.present : copy.past
|
||||
const target = tools.length === 1 ? toolTarget(tools[0]) : ''
|
||||
|
||||
if (target && (live || category !== 'run')) {
|
||||
return `${verb} ${target}`
|
||||
}
|
||||
|
||||
return `${verb} ${tools.length} ${copy.noun[tools.length === 1 ? 0 : 1]}`
|
||||
}
|
||||
|
||||
function lowerFirst(text: string): string {
|
||||
return text.charAt(0).toLowerCase() + text.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a run of tool calls into the single grey line that stands in for it
|
||||
* — "Edited wiring.tsx, explored 3 files, ran 5 commands". The category holding
|
||||
* the still-running tool speaks in the present tense so a live run reads as
|
||||
* work in progress rather than work already done.
|
||||
*/
|
||||
export function summarizeToolRun(tools: readonly ToolCallLike[]): RunSummary {
|
||||
const running = tools.find(isPending)
|
||||
const liveCategory = running ? toolCategory(running.toolName) : null
|
||||
|
||||
const byCategory = new Map<RunCategory, ToolCallLike[]>()
|
||||
|
||||
for (const tool of tools) {
|
||||
const category = toolCategory(tool.toolName)
|
||||
const group = byCategory.get(category)
|
||||
|
||||
if (group) {
|
||||
group.push(tool)
|
||||
} else {
|
||||
byCategory.set(category, [tool])
|
||||
}
|
||||
}
|
||||
|
||||
const clauses = CATEGORY_ORDER.flatMap(category => {
|
||||
const group = byCategory.get(category)
|
||||
|
||||
return group ? [clause(category, group, category === liveCategory)] : []
|
||||
})
|
||||
|
||||
return {
|
||||
...diffStats(tools),
|
||||
text: clauses.map((text, index) => (index === 0 ? text : lowerFirst(text))).join(', ')
|
||||
}
|
||||
}
|
||||
250
apps/desktop/src/lib/tool-run-continuity.test.ts
Normal file
250
apps/desktop/src/lib/tool-run-continuity.test.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isToolCallPart, type ToolCallLike } from '@/components/assistant-ui/tool/run-summary'
|
||||
import type { SessionMessage } from '@/types/hermes'
|
||||
|
||||
import type { ChatMessage, ChatMessagePart } from './chat-messages'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
appendReasoningPart,
|
||||
assistantTextPart,
|
||||
mergeFinalAssistantText,
|
||||
toChatMessages,
|
||||
upsertToolPart
|
||||
} from './chat-messages'
|
||||
import { coalesceToolOnlyAssistants, createToolMergeCache } from './chat-runtime'
|
||||
|
||||
/**
|
||||
* A turn described once, replayed two ways: as the gateway event stream the
|
||||
* live view builds bubbles from, and as the persisted rows `toChatMessages`
|
||||
* rehydrates on resume. Grouping is only stable if both produce the same runs.
|
||||
*/
|
||||
type TurnStep =
|
||||
| { kind: 'interim'; text: string }
|
||||
| { kind: 'final'; text: string }
|
||||
| { kind: 'reasoning'; text: string }
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'tool'; id: string; name: string }
|
||||
|
||||
// Mirrors use-message-stream: deltas and tool events accumulate on one pending
|
||||
// bubble; `message.interim` seals it in place and starts a fresh one; and
|
||||
// `message.complete` merges the final text onto whatever bubble is open.
|
||||
function replayLive(steps: TurnStep[]): ChatMessage[] {
|
||||
const messages: ChatMessage[] = []
|
||||
let streamIndex = 0
|
||||
let open: ChatMessage | null = null
|
||||
|
||||
const openBubble = (): ChatMessage => {
|
||||
if (open) {
|
||||
return open
|
||||
}
|
||||
|
||||
streamIndex += 1
|
||||
open = { id: `assistant-stream-${streamIndex}`, role: 'assistant', parts: [], pending: true }
|
||||
messages.push(open)
|
||||
|
||||
return open
|
||||
}
|
||||
|
||||
const seal = (text: string, interim: boolean) => {
|
||||
const bubble = open ?? openBubble()
|
||||
|
||||
bubble.parts = mergeFinalAssistantText(bubble.parts, text)
|
||||
bubble.pending = false
|
||||
bubble.interim = interim
|
||||
open = null
|
||||
}
|
||||
|
||||
for (const step of steps) {
|
||||
switch (step.kind) {
|
||||
case 'interim':
|
||||
seal(step.text, true)
|
||||
|
||||
break
|
||||
|
||||
case 'final':
|
||||
seal(step.text, false)
|
||||
|
||||
break
|
||||
case 'reasoning': {
|
||||
const bubble = openBubble()
|
||||
|
||||
bubble.parts = appendReasoningPart(bubble.parts, step.text)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
const bubble = openBubble()
|
||||
|
||||
bubble.parts = appendAssistantTextPart(bubble.parts, step.text)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool': {
|
||||
const bubble = openBubble()
|
||||
|
||||
bubble.parts = upsertToolPart(bubble.parts, { tool_id: step.id, name: step.name }, 'complete')
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return coalesceToolOnlyAssistants(messages, createToolMergeCache())
|
||||
}
|
||||
|
||||
// The same turn as the gateway persists it: one row per agent iteration. A row
|
||||
// is a single API response, so its reasoning and content always precede its own
|
||||
// tool_calls — anything the agent says after a tool ran belongs to the next row.
|
||||
function replayStored(steps: TurnStep[]): ChatMessage[] {
|
||||
const rows: SessionMessage[] = []
|
||||
let timestamp = 0
|
||||
let row: (SessionMessage & { tool_calls?: unknown[] }) | null = null
|
||||
|
||||
const openRow = (afterTools: boolean) => {
|
||||
if (row && !(afterTools && row.tool_calls)) {
|
||||
return row
|
||||
}
|
||||
|
||||
timestamp += 1
|
||||
row = { role: 'assistant', content: '', timestamp }
|
||||
rows.push(row)
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
for (const step of steps) {
|
||||
switch (step.kind) {
|
||||
case 'interim':
|
||||
|
||||
case 'final':
|
||||
case 'text': {
|
||||
const current = openRow(true)
|
||||
|
||||
current.content = `${current.content ?? ''}${step.text}`
|
||||
|
||||
if (step.kind !== 'text') {
|
||||
row = null
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'reasoning':
|
||||
openRow(true).reasoning = step.text
|
||||
|
||||
break
|
||||
case 'tool': {
|
||||
const current = openRow(false)
|
||||
|
||||
current.tool_calls = [
|
||||
...(current.tool_calls ?? []),
|
||||
{ id: step.id, function: { name: step.name, arguments: '{}' } }
|
||||
]
|
||||
timestamp += 1
|
||||
rows.push({ role: 'tool', tool_call_id: step.id, tool_name: step.name, content: '{}', timestamp })
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return coalesceToolOnlyAssistants(toChatMessages(rows), createToolMergeCache())
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximal spans of back-to-back tool calls — the same rule assistant-ui applies
|
||||
* when it hands `ToolGroupSlot` a range, restated here so these tests check our
|
||||
* two part streams against each other rather than against the renderer.
|
||||
*/
|
||||
function toolRuns(parts: ChatMessagePart[]): ToolCallLike[][] {
|
||||
const runs: ToolCallLike[][] = []
|
||||
let previousWasTool = false
|
||||
|
||||
for (const part of parts) {
|
||||
if (!isToolCallPart(part)) {
|
||||
previousWasTool = false
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (previousWasTool) {
|
||||
runs[runs.length - 1].push(part)
|
||||
} else {
|
||||
runs.push([part])
|
||||
}
|
||||
|
||||
previousWasTool = true
|
||||
}
|
||||
|
||||
return runs
|
||||
}
|
||||
|
||||
function runsAcross(messages: ChatMessage[]): string[][] {
|
||||
return messages
|
||||
.filter(message => message.role === 'assistant')
|
||||
.flatMap(message => toolRuns(message.parts))
|
||||
.map(run => run.map(tool => tool.toolCallId ?? ''))
|
||||
}
|
||||
|
||||
const TURNS: Record<string, TurnStep[]> = {
|
||||
'narration between two tool runs': [
|
||||
{ kind: 'interim', text: 'Let me check the config.' },
|
||||
{ kind: 'tool', id: 'a', name: 'read_file' },
|
||||
{ kind: 'tool', id: 'b', name: 'read_file' },
|
||||
{ kind: 'interim', text: 'Now let me edit it.' },
|
||||
{ kind: 'tool', id: 'c', name: 'write_file' },
|
||||
{ kind: 'final', text: 'Done.' }
|
||||
],
|
||||
'reasoning between two tool runs': [
|
||||
{ kind: 'tool', id: 'a', name: 'terminal' },
|
||||
{ kind: 'reasoning', text: 'That failed, try the other path.' },
|
||||
{ kind: 'tool', id: 'b', name: 'terminal' },
|
||||
{ kind: 'final', text: 'Fixed.' }
|
||||
],
|
||||
'unbroken run of tool calls': [
|
||||
{ kind: 'tool', id: 'a', name: 'read_file' },
|
||||
{ kind: 'tool', id: 'b', name: 'read_file' },
|
||||
{ kind: 'tool', id: 'c', name: 'search_files' },
|
||||
{ kind: 'final', text: 'Here is what I found.' }
|
||||
],
|
||||
'reasoning then tools then final': [
|
||||
{ kind: 'reasoning', text: 'The user wants the lint config.' },
|
||||
{ kind: 'tool', id: 'a', name: 'search_files' },
|
||||
{ kind: 'tool', id: 'b', name: 'read_file' },
|
||||
{ kind: 'final', text: 'It lives in eslint.config.js.' }
|
||||
],
|
||||
'tools with no narration at all': [
|
||||
{ kind: 'tool', id: 'a', name: 'terminal' },
|
||||
{ kind: 'final', text: 'Clean.' }
|
||||
]
|
||||
}
|
||||
|
||||
describe('tool run segmentation survives rehydration', () => {
|
||||
for (const [name, steps] of Object.entries(TURNS)) {
|
||||
it(name, () => {
|
||||
expect(runsAcross(replayLive(steps))).toEqual(runsAcross(replayStored(steps)))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('run identity', () => {
|
||||
const tool = (id: string, name: string): ChatMessagePart =>
|
||||
({ args: {}, toolCallId: id, toolName: name, type: 'tool-call' }) as ChatMessagePart
|
||||
|
||||
it('keeps the first tool call at the head as the run grows', () => {
|
||||
const [run] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file')])
|
||||
const [grown] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file'), tool('c', 'terminal')])
|
||||
|
||||
expect(grown[0].toolCallId).toBe(run[0].toolCallId)
|
||||
expect(grown).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('breaks a run on any non-tool part', () => {
|
||||
const runs = toolRuns([tool('a', 'read_file'), assistantTextPart('Now editing.'), tool('b', 'write_file')])
|
||||
|
||||
expect(runs.map(run => run.map(t => t.toolCallId))).toEqual([['a'], ['b']])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue