diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index bf1541a6a2..b57a563867 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -10,6 +10,8 @@ from typing import Any, Callable, Dict, List, Optional import acp from acp.schema import ToolCallLocation, ToolCallProgress, ToolCallStart, ToolKind +from agent.display import build_tool_preview + logger = logging.getLogger(__name__) # Hermes tool name -> ACP ToolKind (anything unlisted is "other"). @@ -84,11 +86,6 @@ def _first(data: Args, *keys: str, default: Any = "") -> Any: return next((data[k] for k in keys if data.get(k)), default) -def _clip(text: str, limit: int) -> str: - """Hard-truncate to ``limit`` chars with a trailing ellipsis.""" - return text if len(text) <= limit else text[: limit - 3] + "..." - - def _fmt(value: Any, template: str, fallback: str) -> str: """``template.format(value)`` when value is truthy, else ``fallback``.""" return template.format(value) if value else fallback @@ -193,68 +190,12 @@ def _tool_result_failed(result: Optional[str], tool_name: str | None = None) -> # --- tool-call titles ------------------------------------------------------- -def _title_web_extract(args: Args) -> str: - urls = args.get("urls", []) - if not urls: - return "web extract" - first = urls[0] - if isinstance(first, dict): - first = first.get("url") or first.get("href") or "?" - elif not isinstance(first, str): - first = "?" - return f"extract: {first}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "") - - -def _title_delegate(args: Args) -> str: - if isinstance(tasks := args.get("tasks"), list) and tasks: - return f"delegate batch ({len(tasks)} tasks)" - return f"delegate: {_clip(goal, 60)}" if (goal := args.get("goal", "")) else "delegate task" - - -def _title_execute_code(args: Args) -> str: - first_line = next((line.strip() for line in _arg(args, "code").splitlines() if line.strip()), "") - return _fmt(_clip(first_line, 70), "python: {}", "python code") - - -def _title_skill_manage(args: Args) -> str: - name, file_path = _arg(args, "name", default="?"), _arg(args, "file_path") - target = _clip(f"{name}/{file_path}" if file_path else name, 64) - return f"skill {_arg(args, 'action', default='manage')}: {target}" - - -_TITLE_BUILDERS: Dict[str, Callable[[Args], str]] = { - "terminal": lambda a: f"terminal: {_clip(a.get('command', ''), 80)}", - "read_file": lambda a: f"read: {a.get('path', '?')}", - "write_file": lambda a: f"write: {a.get('path', '?')}", - "patch": lambda a: f"patch ({a.get('mode', 'replace')}): {a.get('path', '?')}", - "search_files": lambda a: f"search: {a.get('pattern', '?')}", - "web_search": lambda a: f"web search: {a.get('query', '?')}", - "web_extract": _title_web_extract, - "process": lambda a: _fmt(_arg(a, "session_id"), f"process {_arg(a, 'action', default='manage')}: {{}}", - f"process {_arg(a, 'action', default='manage')}"), - "delegate_task": _title_delegate, - "session_search": lambda a: _fmt(_arg(a, "query"), "session search: {}", "recent sessions"), - "memory": lambda a: f"memory {_arg(a, 'action', default='manage')}: {_arg(a, 'target', default='memory')}", - "execute_code": _title_execute_code, - "todo": lambda a: f"todo ({_plural(len(a['todos']), 'item')})" if isinstance(a.get("todos"), list) else "todo", - "skill_view": lambda a: f"skill view ({_arg(a, 'name', default='?')}{_fmt(_arg(a, 'file_path'), '/{}', '')})", - "skills_list": lambda a: _fmt(_arg(a, "category"), "skills list ({})", "skills list"), - "skill_manage": _title_skill_manage, - "browser_navigate": lambda a: f"navigate: {a.get('url', '?')}", - "browser_snapshot": lambda a: "browser snapshot", - "browser_vision": lambda a: f"browser vision: {str(a.get('question', '?'))[:50]}", - "browser_get_images": lambda a: "browser images", - "vision_analyze": lambda a: f"analyze image: {str(a.get('question', '?'))[:50]}", - "image_generate": lambda a: _fmt(_arg(a, "prompt", "description")[:50], "generate image: {}", "generate image"), - "cronjob": lambda a: _fmt(_arg(a, "job_id", "id"), f"cron {_arg(a, 'action', default='manage')}: {{}}", - f"cron {_arg(a, 'action', default='manage')}"), -} - - def build_tool_title(tool_name: str, args: Args) -> str: - """Build a human-readable title for a tool call (defaults to the tool name).""" - builder = _TITLE_BUILDERS.get(tool_name) - return builder(args) if builder is not None else tool_name + """``: `` using the same per-tool preview (and argument redaction) as + every other Hermes surface, so ACP clients never show a different summary than the CLI/TUI; + bare tool name when the arguments yield no preview.""" + preview = build_tool_preview(tool_name, args, max_len=80) + return f"{tool_name}: {preview}" if preview else tool_name # --- completion formatters; all share the signature (tool_name, result, args) -- diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 9a1432a45f..658f156bac 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -21,6 +21,7 @@ from agent.message_sanitization import ( ) from agent.prompt_builder import STEER_DISPLAY_KIND, steer_user_row from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message +from agent.think_scrubber import THINK_TAG_NAMES from agent.trajectory import convert_scratchpad_to_think from agent.credential_pool import ( STATUS_EXHAUSTED, credential_pool_matches_provider, resolve_runtime_pool_key @@ -33,10 +34,9 @@ logger = logging.getLogger(__name__) # Cap same-entry OAuth refreshes on a persistent auth failure, else a single-entry pool re-mints forever. _MAX_AUTH_REFRESH_ATTEMPTS = 2 -_REASONING_TAG_NAMES = ("think", "thinking", "reasoning", "REASONING_SCRATCHPAD", "thought") _TOOL_CALL_TAG_NAMES = ("tool_call", "tool_calls", "tool_result", "function_call", "function_calls") _REASONING_BLOCK_PATTERNS = tuple( - re.compile(rf"<{name}>.*?", re.DOTALL | re.IGNORECASE) for name in _REASONING_TAG_NAMES + re.compile(rf"<{name}>.*?", re.DOTALL | re.IGNORECASE) for name in THINK_TAG_NAMES ) _TOOL_CALL_BLOCK_PATTERNS = tuple( re.compile(rf"<{name}\b[^>]*>.*?", re.DOTALL | re.IGNORECASE) @@ -50,10 +50,10 @@ _NAMED_FUNCTION_BLOCK_PATTERN = re.compile( r'(?:(?:(?!).)*)', re.DOTALL | re.IGNORECASE, ) _UNTERMINATED_REASONING_BLOCK_PATTERN = re.compile( - rf'(?:^|\n)[ \t]*<(?:{"|".join(_REASONING_TAG_NAMES)})\b[^>]*>.*$', re.DOTALL | re.IGNORECASE + rf'(?:^|\n)[ \t]*<(?:{"|".join(THINK_TAG_NAMES)})\b[^>]*>.*$', re.DOTALL | re.IGNORECASE ) _ORPHAN_REASONING_TAG_PATTERN = re.compile( - rf'\s*', re.IGNORECASE + rf'\s*', re.IGNORECASE ) _STRAY_TOOL_CALL_CLOSER_PATTERN = re.compile( rf'\s*', re.IGNORECASE @@ -1207,7 +1207,7 @@ _TRANSIENT_TRANSPORT_ERRORS = frozenset({ }) _INLINE_REASONING_PATTERNS = tuple( re.compile(rf"<{tag}>(.*?)", re.DOTALL | re.IGNORECASE) - for tag in ("think", "thinking", "thought", "reasoning", "REASONING_SCRATCHPAD") + for tag in THINK_TAG_NAMES ) diff --git a/agent/think_scrubber.py b/agent/think_scrubber.py index 5ded655d3e..3ee45f0ca8 100644 --- a/agent/think_scrubber.py +++ b/agent/think_scrubber.py @@ -13,7 +13,15 @@ from __future__ import annotations import re from typing import Tuple -__all__ = ["StreamingThinkScrubber"] +__all__ = ["StreamingThinkScrubber", "THINK_TAG_NAMES", "THINK_OPEN_TAGS", "THINK_CLOSE_TAGS"] + +# The one list of model reasoning tag names. Every surface that hides reasoning (this scrubber, +# the CLI stream filter, the gateway stream filter, the final-response regex stripper) binds to +# these; a tag added here is covered everywhere. Consumers match case-insensitively, so the +# literal tags are lowercase. +THINK_TAG_NAMES: Tuple[str, ...] = ("think", "thinking", "reasoning", "thought", "REASONING_SCRATCHPAD") +THINK_OPEN_TAGS: Tuple[str, ...] = tuple(f"<{name.lower()}>" for name in THINK_TAG_NAMES) +THINK_CLOSE_TAGS: Tuple[str, ...] = tuple(f"" for name in THINK_TAG_NAMES) class StreamingThinkScrubber: @@ -24,11 +32,9 @@ class StreamingThinkScrubber: was emitted yet — decides whether an open tag at buffer position 0 sits at a block boundary). """ - _OPEN_TAG_NAMES: Tuple[str, ...] = ("think", "thinking", "reasoning", "thought", "REASONING_SCRATCHPAD") - - # Lowercased literal tags so the hot path does string ops, not regex per feed(). - _OPEN_TAGS: Tuple[str, ...] = tuple(f"<{name.lower()}>" for name in _OPEN_TAG_NAMES) - _CLOSE_TAGS: Tuple[str, ...] = tuple(f"" for name in _OPEN_TAG_NAMES) + # Literal tags so the hot path does string ops, not regex per feed(). + _OPEN_TAGS: Tuple[str, ...] = THINK_OPEN_TAGS + _CLOSE_TAGS: Tuple[str, ...] = THINK_CLOSE_TAGS _ALL_TAGS: Tuple[str, ...] = _OPEN_TAGS + _CLOSE_TAGS _MAX_TAG_LEN: int = max(len(tag) for tag in _ALL_TAGS) # Orphan close tag plus trailing whitespace (matches _strip_think_blocks case 3). diff --git a/gateway/stream_consumer_think.py b/gateway/stream_consumer_think.py index 21d9277314..e458312a45 100644 --- a/gateway/stream_consumer_think.py +++ b/gateway/stream_consumer_think.py @@ -9,6 +9,7 @@ from __future__ import annotations import logging +from agent.think_scrubber import THINK_CLOSE_TAGS, THINK_OPEN_TAGS from agent.think_scrubber import StreamingThinkScrubber as _Scrubber logger = logging.getLogger("gateway.stream_consumer") @@ -17,21 +18,13 @@ logger = logging.getLogger("gateway.stream_consumer") class StreamThinkFilterMixin: """Progressive -tag suppression over streamed deltas.""" - # Must stay in sync with cli.py _OPEN_TAGS/_CLOSE_TAGS and - # run_agent.py _strip_think_blocks() tag variants. - _OPEN_THINK_TAGS = ( - "", "", "", - "", "", "", - ) - _CLOSE_THINK_TAGS = ( - "", "", "", - "", "", "", - ) + _OPEN_THINK_TAGS = THINK_OPEN_TAGS + _CLOSE_THINK_TAGS = THINK_CLOSE_TAGS def _at_block_boundary(self, buf: str, idx: int) -> bool: """Tag at ``idx`` starts a block: start of text, or newline + optional whitespace. - Prose that merely *mentions* a tag must not trigger (mirrors cli.py). + Prose that merely *mentions* a tag must not trigger. """ acc_boundary = not self._accumulated or self._accumulated.endswith("\n") if idx == 0: diff --git a/hermes_cli/cli_stream_mixin.py b/hermes_cli/cli_stream_mixin.py index ec99e7beea..8a49ac8ba8 100644 --- a/hermes_cli/cli_stream_mixin.py +++ b/hermes_cli/cli_stream_mixin.py @@ -17,11 +17,12 @@ from contextlib import contextmanager from pathlib import Path from rich.markup import escape as _escape +from agent.think_scrubber import THINK_CLOSE_TAGS, THINK_OPEN_TAGS + # Model-generated reasoning tags: suppressed during streaming (they'd display as raw XML; # the agent strips them from final_response too) unless show_reasoning routes them to the box. -_OPEN_TAGS = ( - "", "", "", "", "", "") -_CLOSE_TAGS = tuple("", "hidden", f"", "visible"): + c._filter_and_accumulate(chunk) + c._flush_think_buffer() + assert c._accumulated == "visible" + def test_prose_mention_not_stripped(self): """ mentioned mid-line in prose should NOT trigger filtering.""" c = _make_consumer()