mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
fix(ui): share compact tool previews across clients
Move terminal/execute_code/read_file preview compaction into agent.display so CLI, gateway, and Ink TUI all inherit the same labels that desktop introduced in #52321. The shared preview keeps raw args intact while trimming display-only shell plumbing (`cd`, pipe tails, banner/status echoes) and read_file line ranges. Desktop now prefers backend `context` for live rows and keeps its TypeScript fallback only for hydrated history.
This commit is contained in:
parent
3af22c0ed5
commit
f3d6d9bbd3
4 changed files with 290 additions and 26 deletions
183
agent/display.py
183
agent/display.py
|
|
@ -6,6 +6,7 @@ Used by AIAgent._execute_tool_calls for CLI feedback.
|
|||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
|
@ -177,6 +178,167 @@ def _truncate_preview(text: str, max_len: int | None) -> str:
|
|||
return text
|
||||
|
||||
|
||||
_SHELL_SILENT_HEADS = {"cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":"}
|
||||
_SHELL_PIPE_TAIL_HEADS = {"head", "tail", "wc", "sort", "uniq"}
|
||||
|
||||
|
||||
def _shell_basename(head: str) -> str:
|
||||
return head.rsplit("/", 1)[-1] if head else ""
|
||||
|
||||
|
||||
def _split_shell_words(segment: str) -> list[str]:
|
||||
words: list[str] = []
|
||||
buf: list[str] = []
|
||||
quote: str | None = None
|
||||
|
||||
for i, ch in enumerate(segment):
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if ch == quote and (i == 0 or segment[i - 1] != "\\"):
|
||||
quote = None
|
||||
continue
|
||||
|
||||
if ch in {"'", '"'}:
|
||||
quote = ch
|
||||
buf.append(ch)
|
||||
continue
|
||||
|
||||
if ch.isspace():
|
||||
if buf:
|
||||
words.append("".join(buf))
|
||||
buf = []
|
||||
continue
|
||||
|
||||
buf.append(ch)
|
||||
|
||||
if buf:
|
||||
words.append("".join(buf))
|
||||
|
||||
return words
|
||||
|
||||
|
||||
def _strip_shell_pipe_tail(segment: str) -> str:
|
||||
words = _split_shell_words(segment)
|
||||
out: list[str] = []
|
||||
|
||||
for i, word in enumerate(words):
|
||||
if word == "|" and _shell_basename(words[i + 1] if i + 1 < len(words) else "") in _SHELL_PIPE_TAIL_HEADS:
|
||||
break
|
||||
out.append(word)
|
||||
|
||||
return " ".join(out).strip()
|
||||
|
||||
|
||||
def _split_shell_compound(command: str) -> list[str]:
|
||||
segments: list[str] = []
|
||||
buf: list[str] = []
|
||||
quote: str | None = None
|
||||
i = 0
|
||||
|
||||
while i < len(command):
|
||||
ch = command[i]
|
||||
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if ch == quote and (i == 0 or command[i - 1] != "\\"):
|
||||
quote = None
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch in {"'", '"'}:
|
||||
quote = ch
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
op_len = 2 if command.startswith("&&", i) or command.startswith("||", i) else 1 if ch in {";", "\n"} else 0
|
||||
if op_len:
|
||||
segment = _strip_shell_pipe_tail("".join(buf).strip())
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
buf = []
|
||||
i += op_len
|
||||
continue
|
||||
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
|
||||
segment = _strip_shell_pipe_tail("".join(buf).strip())
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def _shell_head_word(segment: str) -> str:
|
||||
words = _split_shell_words(segment)
|
||||
index = 0
|
||||
while index < len(words) and re.match(r"^[A-Za-z_]\w*=", words[index]):
|
||||
index += 1
|
||||
return _shell_basename(words[index] if index < len(words) else "")
|
||||
|
||||
|
||||
def _clean_shell_segment(segment: str) -> str:
|
||||
words = _split_shell_words(segment)
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(words):
|
||||
word = words[i]
|
||||
if re.match(r"^\d*(?:>>?|<)$", word):
|
||||
i += 2
|
||||
continue
|
||||
if re.match(r"^\d*(?:>&|<&)\d+$", word) or re.match(r"^\d*>&\d+$", word):
|
||||
i += 1
|
||||
continue
|
||||
out.append(word)
|
||||
i += 1
|
||||
return " ".join(out).strip()
|
||||
|
||||
|
||||
def _is_shell_boundary_echo(segment: str) -> bool:
|
||||
words = _split_shell_words(segment)
|
||||
if _shell_basename(words[0] if words else "") != "echo":
|
||||
return False
|
||||
rest = " ".join(words[1:])
|
||||
return bool(re.search(r"-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS", rest))
|
||||
|
||||
|
||||
def summarize_shell_command(command: str) -> str:
|
||||
"""Compact shell wrapper/plumbing for display while preserving raw command elsewhere."""
|
||||
original = _oneline(command)
|
||||
if not original:
|
||||
return ""
|
||||
|
||||
segments = _split_shell_compound(original)
|
||||
if len(segments) <= 1:
|
||||
return _clean_shell_segment(segments[0] if segments else original) or original
|
||||
|
||||
core: list[str] = []
|
||||
for segment in segments:
|
||||
cleaned = _clean_shell_segment(segment)
|
||||
head = _shell_head_word(cleaned)
|
||||
if cleaned and head not in _SHELL_SILENT_HEADS and not _is_shell_boundary_echo(cleaned):
|
||||
core.append(cleaned)
|
||||
|
||||
if not core:
|
||||
return original
|
||||
if len(core) == 1:
|
||||
return core[0]
|
||||
|
||||
count = len(core) - 1
|
||||
return f"{core[0]} + {count} {'command' if count == 1 else 'commands'}"
|
||||
|
||||
|
||||
def _read_file_line_label(args: dict) -> str:
|
||||
offset = args.get("offset")
|
||||
limit = args.get("limit")
|
||||
if not isinstance(offset, int) or offset <= 0:
|
||||
return ""
|
||||
if not isinstance(limit, int) or limit <= 1:
|
||||
return f"L{offset}"
|
||||
return f"L{offset}-{offset + limit - 1}"
|
||||
|
||||
|
||||
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
|
||||
if not isinstance(tasks, list):
|
||||
return 0, []
|
||||
|
|
@ -253,6 +415,23 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
|||
else:
|
||||
return f"planning {len(todos_arg)} task(s)"
|
||||
|
||||
if tool_name in {"terminal", "execute_code"}:
|
||||
key = "code" if tool_name == "execute_code" else "command"
|
||||
command = args.get(key)
|
||||
if command is None:
|
||||
return None
|
||||
preview = summarize_shell_command(str(command))
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
if tool_name == "read_file":
|
||||
path = args.get("path") or args.get("file") or args.get("filepath")
|
||||
if path is None:
|
||||
return None
|
||||
label = Path(str(path).replace("\\", "/")).name or str(path)
|
||||
line_label = _read_file_line_label(args)
|
||||
preview = f"{label} {line_label}".strip()
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
if tool_name == "session_search":
|
||||
query = _oneline(args.get("query", ""))
|
||||
return f"recall: \"{query[:25]}{'...' if len(query) > 25 else ''}\""
|
||||
|
|
@ -943,7 +1122,7 @@ def get_cute_tool_message(
|
|||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
if tool_name == "terminal":
|
||||
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
|
||||
return _wrap(f"┊ 💻 $ {_trunc(build_tool_preview(tool_name, args) or args.get('command', ''), 42)} {dur}")
|
||||
if tool_name == "process":
|
||||
action = args.get("action", "?")
|
||||
sid = args.get("session_id", "")[:12]
|
||||
|
|
@ -951,7 +1130,7 @@ def get_cute_tool_message(
|
|||
"wait": f"wait {sid}", "kill": f"kill {sid}", "write": f"write {sid}", "submit": f"submit {sid}"}
|
||||
return _wrap(f"┊ ⚙️ proc {labels.get(action, f'{action} {sid}')} {dur}")
|
||||
if tool_name == "read_file":
|
||||
return _wrap(f"┊ 📖 read {_path(args.get('path', ''))} {dur}")
|
||||
return _wrap(f"┊ 📖 read {_trunc(build_tool_preview(tool_name, args) or args.get('path', ''), 42)} {dur}")
|
||||
if tool_name == "write_file":
|
||||
return _wrap(f"┊ ✍️ write {_path(args.get('path', ''))} {dur}")
|
||||
if tool_name == "patch":
|
||||
|
|
|
|||
|
|
@ -136,8 +136,8 @@ describe('buildToolView title actions', () => {
|
|||
''
|
||||
)
|
||||
|
||||
expect(read.title).toBe('Reading file')
|
||||
expect(read.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' file' })
|
||||
expect(read.title).toBe('Reading demo.txt')
|
||||
expect(read.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' demo.txt' })
|
||||
expect(web.title).toBe('Reading example.com/docs')
|
||||
expect(web.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' example.com/docs' })
|
||||
expect(terminal.title).toBe('Running npm test -- --runInBand')
|
||||
|
|
@ -191,6 +191,20 @@ describe('buildToolView title actions', () => {
|
|||
expect(view.title).toBe('Read package.json L1-5')
|
||||
})
|
||||
|
||||
it('uses inherited backend context for live read_file rows', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { context: 'package.json L1-5', path: './package.json' },
|
||||
result: undefined,
|
||||
toolName: 'read_file'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('Reading package.json L1-5')
|
||||
expect(view.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' package.json L1-5' })
|
||||
})
|
||||
|
||||
it('uses returned line numbers for negative-offset read_file rows', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
|
|
@ -239,6 +253,24 @@ describe('buildToolView title actions', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('uses inherited backend context for live terminal rows', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: {
|
||||
command: 'cd /Users/brooklyn/www/bb-rainbows && pnpm run lint 2>&1 | tail -20',
|
||||
context: 'pnpm run lint'
|
||||
},
|
||||
result: undefined,
|
||||
toolName: 'terminal'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('Running pnpm run lint')
|
||||
expect(view.subtitle).toBe('')
|
||||
expect(view.titleAction).toEqual({ prefix: '', text: 'Running', suffix: ' pnpm run lint' })
|
||||
})
|
||||
|
||||
it('uses the runtime locale for title text and action placement', () => {
|
||||
setRuntimeI18nLocale('ja')
|
||||
|
||||
|
|
@ -249,8 +281,8 @@ describe('buildToolView title actions', () => {
|
|||
''
|
||||
)
|
||||
|
||||
expect(read.title).toBe('ファイルを読み取り中')
|
||||
expect(read.titleAction).toEqual({ prefix: 'ファイルを', text: '読み取り中', suffix: '' })
|
||||
expect(read.title).toBe('demo.txt を読み取り中')
|
||||
expect(read.titleAction).toEqual({ prefix: 'demo.txt を', text: '読み取り中', suffix: '' })
|
||||
expect(web.title).toBe('example.com/docs を読み取り中')
|
||||
expect(web.titleAction).toEqual({ prefix: 'example.com/docs を', text: '読み取り中', suffix: '' })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -165,6 +165,24 @@ function readFileLineLabel(args: Record<string, unknown>, result: Record<string,
|
|||
return start === end ? `L${start}` : `L${start}-${end}`
|
||||
}
|
||||
|
||||
function readFileDisplayTarget(args: Record<string, unknown>, result: Record<string, unknown>): string {
|
||||
const inherited = firstStringField(args, ['context', 'preview'])
|
||||
|
||||
if (inherited) {
|
||||
return inherited
|
||||
}
|
||||
|
||||
const path = firstStringField(args, ['path', 'file', 'filepath'])
|
||||
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const lineLabel = readFileLineLabel(args, result)
|
||||
|
||||
return [fileEditBasename(path), lineLabel].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const TOOL_META: Record<ToolTitleKey, ToolMetaSpec> = {
|
||||
browser_click: {
|
||||
icon: 'globe',
|
||||
|
|
@ -1220,9 +1238,9 @@ function toolSubtitle(
|
|||
}
|
||||
}
|
||||
|
||||
const command = firstStringField(argsRecord, ['command', 'code']) || contextValue(argsRecord)
|
||||
const command = firstStringField(argsRecord, ['context', 'preview', 'command', 'code']) || contextValue(argsRecord)
|
||||
|
||||
return command ? compactPreview(summarizeShellCommand(command), 120) : 'Executed command'
|
||||
return command ? '' : 'Executed command'
|
||||
}
|
||||
|
||||
if (toolName === 'read_file' || isFileEditTool(toolName)) {
|
||||
|
|
@ -1543,8 +1561,18 @@ function dynamicTitle(
|
|||
: fallback
|
||||
}
|
||||
|
||||
if (part.toolName === 'read_file') {
|
||||
const target = readFileDisplayTarget(args, result)
|
||||
const action = verb(translateNow('assistant.tool.actions.reading'), translateNow('assistant.tool.actions.read'))
|
||||
|
||||
return target
|
||||
? titledAction(action, translateNow('assistant.tool.titleTemplates.actionTarget', action, target))
|
||||
: fallback
|
||||
}
|
||||
|
||||
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
|
||||
const command = firstStringField(args, ['command', 'code']) || contextValue(args)
|
||||
const command =
|
||||
firstStringField(args, ['context', 'preview']) || firstStringField(args, ['command', 'code']) || contextValue(args)
|
||||
|
||||
if (command) {
|
||||
const action =
|
||||
|
|
@ -1563,18 +1591,6 @@ function dynamicTitle(
|
|||
}
|
||||
}
|
||||
|
||||
if (part.toolName === 'read_file' && part.result !== undefined) {
|
||||
const path = firstStringField(args, ['path', 'file', 'filepath'])
|
||||
|
||||
if (path) {
|
||||
const lineLabel = readFileLineLabel(args, result)
|
||||
const target = [fileEditBasename(path), lineLabel].filter(Boolean).join(' ')
|
||||
const action = verb(translateNow('assistant.tool.actions.reading'), translateNow('assistant.tool.actions.read'))
|
||||
|
||||
return { title: translateNow('assistant.tool.titleTemplates.actionTarget', action, target) }
|
||||
}
|
||||
}
|
||||
|
||||
if (isFileEditTool(part.toolName)) {
|
||||
const path = fileEditPath(args, result)
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,38 @@ class TestBuildToolPreview:
|
|||
assert result is not None
|
||||
assert "ls -la" in result
|
||||
|
||||
def test_terminal_preview_compacts_shell_plumbing(self):
|
||||
result = build_tool_preview(
|
||||
"terminal",
|
||||
{
|
||||
"command": (
|
||||
'cd /Users/brooklyn/www/bb-rainbows && pnpm run lint 2>&1 '
|
||||
'| tail -20; echo "lint_exit=${PIPESTATUS[0]}"'
|
||||
)
|
||||
},
|
||||
)
|
||||
assert result == "pnpm run lint"
|
||||
|
||||
def test_terminal_preview_compacts_multi_command_probe(self):
|
||||
result = build_tool_preview(
|
||||
"terminal",
|
||||
{
|
||||
"command": (
|
||||
'which node pnpm corepack; node -v; echo "---"; '
|
||||
'corepack --version 2>&1; echo "---pnpm via corepack---"; '
|
||||
'pnpm --version 2>&1 | tail -5'
|
||||
)
|
||||
},
|
||||
)
|
||||
assert result == "which node pnpm corepack + 3 commands"
|
||||
|
||||
def test_execute_code_preview_uses_same_shell_summary(self):
|
||||
result = build_tool_preview(
|
||||
"execute_code",
|
||||
{"code": 'cd /tmp/demo && python -m pytest -q 2>&1 | tail -5; echo "exit=$?"'},
|
||||
)
|
||||
assert result == "python -m pytest -q"
|
||||
|
||||
def test_web_search_preview(self):
|
||||
result = build_tool_preview("web_search", {"query": "hello world"})
|
||||
assert result is not None
|
||||
|
|
@ -48,7 +80,11 @@ class TestBuildToolPreview:
|
|||
def test_read_file_preview(self):
|
||||
result = build_tool_preview("read_file", {"path": "/tmp/test.py", "offset": 1})
|
||||
assert result is not None
|
||||
assert "/tmp/test.py" in result
|
||||
assert result == "test.py L1"
|
||||
|
||||
def test_read_file_preview_includes_requested_line_range(self):
|
||||
result = build_tool_preview("read_file", {"path": "./package.json", "offset": 1, "limit": 5})
|
||||
assert result == "package.json L1-5"
|
||||
|
||||
def test_unknown_tool_with_fallback_key(self):
|
||||
"""Unknown tool but with a recognized fallback key should still preview."""
|
||||
|
|
@ -145,7 +181,8 @@ class TestCuteToolMessagePreviewLength:
|
|||
|
||||
line = get_cute_tool_message("terminal", {"command": command}, 0.1)
|
||||
|
||||
assert command in line
|
||||
assert "curl -s http://localhost:9222/json/list | jq -r '.[] | select(.type==\"page\")'" in line
|
||||
assert "head -5" not in line
|
||||
assert "..." not in line
|
||||
|
||||
def test_terminal_preview_uses_positive_configured_limit(self):
|
||||
|
|
@ -154,8 +191,8 @@ class TestCuteToolMessagePreviewLength:
|
|||
|
||||
line = get_cute_tool_message("terminal", {"command": command}, 0.1)
|
||||
|
||||
assert command[:77] in line
|
||||
assert "..." in line
|
||||
assert "curl -s http://localhost:9222/json/list | jq -r '.[] | select(.type==\"page\")'" in line
|
||||
assert "..." not in line
|
||||
assert "head -5" not in line
|
||||
|
||||
def test_search_files_preview_uses_positive_configured_limit_not_default(self):
|
||||
|
|
@ -173,7 +210,7 @@ class TestCuteToolMessagePreviewLength:
|
|||
|
||||
line = get_cute_tool_message("read_file", {"path": path}, 0.1)
|
||||
|
||||
assert path in line
|
||||
assert "test-output.txt" in line
|
||||
assert "..." not in line
|
||||
|
||||
def test_write_file_lint_error_result_is_not_marked_failed(self):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue