Files
OpenOPC/opc/layer3_agent/adapters/opencode_adapter.py
T
2026-07-01 17:56:31 +08:00

830 lines
32 KiB
Python

"""OpenCode CLI adapter."""
from __future__ import annotations
import asyncio
import json
import os
import shutil
import subprocess
from pathlib import Path
from typing import Any
from loguru import logger
from opc.core.models import AgentStatus, ApprovalDecision, Task, TaskResult, TaskStatus
from opc.layer3_agent.adapters.base import (
ExternalAgentAdapter,
ExternalAgentStdinPolicy,
ExternalApprovalRequest,
)
class OpenCodeAdapter(ExternalAgentAdapter):
"""Invokes the OpenCode CLI via ``opencode run``."""
agent_type = "opencode"
default_command = "opencode"
_permission_handler_support_cache: dict[str, bool] = {}
def __init__(self, config=None) -> None:
super().__init__(config=config)
self._process: asyncio.subprocess.Process | None = None
def resolve_binary(self) -> str | None:
if not self.config.enabled:
return None
for candidate in self._candidate_commands():
resolved = self._resolve_command_candidate(candidate)
if resolved:
return resolved
return None
def _runtime_command(self) -> str:
return self.resolve_binary() or self.configured_command()
def _candidate_commands(self) -> list[str]:
configured = str(self.configured_command() or "").strip()
candidates: list[str] = []
if configured:
candidates.append(configured)
env_binary = str(os.environ.get("OPENCODE_BIN") or "").strip()
if env_binary:
candidates.append(env_binary)
candidates.extend([
str(Path.home() / ".opencode" / "bin" / "opencode"),
str(Path.home() / ".local" / "bin" / "opencode"),
"opencode",
])
return list(dict.fromkeys(candidates))
@staticmethod
def _resolve_command_candidate(candidate: str) -> str | None:
raw = str(candidate or "").strip()
if not raw:
return None
expanded = Path(raw).expanduser()
if expanded.is_absolute() or os.sep in raw:
return str(expanded) if expanded.is_file() and os.access(expanded, os.X_OK) else None
return shutil.which(raw)
async def is_available(self) -> bool:
return self.resolve_binary() is not None
async def get_status(self) -> AgentStatus:
if self._process and self._process.returncode is None:
return AgentStatus.RUNNING
return AgentStatus.IDLE
def supports_interactive(self) -> bool:
return True
def supports_session_resume(self) -> bool:
return True
def can_resume_without_session_id(self) -> bool:
return True
def agent_isolation_home_slug(self) -> str:
# OpenCode discovers project/user config, agents, commands, plugins,
# and skills from OPENCODE_CONFIG_DIR. Point spawned OpenCode at an
# isolated config dir so the opc-collab skill is discoverable without
# mutating the user's normal OpenCode config directory.
return "opencode"
def agent_home_env_vars(self, home: str) -> dict[str, str]:
return {"OPENCODE_CONFIG_DIR": home}
def post_install_agent_home(self, home: str) -> None:
target_home = Path(home)
target_home.mkdir(parents=True, exist_ok=True)
source_home = self._user_config_dir(target_home)
if source_home is None:
return
for file_name in ("opencode.json", "opencode.jsonc"):
self._mirror_user_config_path(source_home / file_name, target_home / file_name)
for dir_name in ("agent", "agents", "command", "commands", "plugin", "plugins"):
self._mirror_user_config_path(source_home / dir_name, target_home / dir_name)
@staticmethod
def _user_config_dir(target_home: Path) -> Path | None:
candidates: list[Path] = []
raw_env = str(os.environ.get("OPENCODE_CONFIG_DIR") or "").strip()
if raw_env:
candidates.append(Path(raw_env).expanduser())
xdg_config_home = str(os.environ.get("XDG_CONFIG_HOME") or "").strip()
if xdg_config_home:
candidates.append(Path(xdg_config_home).expanduser() / "opencode")
candidates.append(Path.home() / ".config" / "opencode")
target_resolved = target_home.expanduser().resolve()
for candidate in candidates:
try:
resolved = candidate.expanduser().resolve()
except OSError:
continue
if resolved == target_resolved:
continue
if resolved.exists():
return resolved
return None
@staticmethod
def _mirror_user_config_path(source: Path, target: Path) -> None:
if not source.exists():
return
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists() or target.is_symlink():
return
try:
target.symlink_to(source, target_is_directory=source.is_dir())
except (OSError, NotImplementedError):
try:
if source.is_dir():
import shutil as _shutil
_shutil.copytree(source, target, dirs_exist_ok=True)
else:
target.write_bytes(source.read_bytes())
except OSError as exc:
logger.warning(
"Unable to mirror OpenCode config path {} into isolated home: {}",
source,
exc,
)
def build_process_env(self, extra_env: dict[str, str] | None = None) -> dict[str, str] | None:
env = super().build_process_env(extra_env)
config_dir = str((env or os.environ).get("OPENCODE_CONFIG_DIR") or "").strip()
if config_dir:
try:
Path(config_dir).expanduser().mkdir(parents=True, exist_ok=True)
except OSError as exc:
logger.warning("Unable to create OPENCODE_CONFIG_DIR {}: {}", config_dir, exc)
if str(self.config.approval_mode or "auto").strip().lower() != "full-auto":
return env
merged = dict(os.environ if env is None else env)
inline_config: dict[str, object] = {}
raw_inline = str(merged.get("OPENCODE_CONFIG_CONTENT") or "").strip()
if raw_inline:
try:
parsed = json.loads(raw_inline)
if isinstance(parsed, dict):
inline_config = dict(parsed)
except Exception:
inline_config = {}
inline_config["permission"] = "allow"
merged["OPENCODE_CONFIG_CONTENT"] = json.dumps(
inline_config,
ensure_ascii=False,
separators=(",", ":"),
)
return merged
def stdin_policy_for_process(
self,
cmd: list[str],
metadata: dict[str, Any] | None = None,
) -> ExternalAgentStdinPolicy:
_ = metadata
has_permission_handler = any(
arg == "--permission-handler" or arg.startswith("--permission-handler=")
for arg in cmd
)
return "pipe_open" if has_permission_handler else "devnull"
def supports_approval_prompt_handling(
self,
cmd: list[str],
metadata: dict[str, Any] | None = None,
) -> bool:
mode = str(
(metadata or {}).get("approval_mode")
or self.config.approval_mode
or "auto"
).strip().lower()
if mode == "full-auto" or "--dangerously-skip-permissions" in cmd:
return False
return self.stdin_policy_for_process(cmd, metadata) == "pipe_open"
def build_invocation(
self,
task: Task,
workspace_path: str | None = None,
) -> tuple[list[str], dict[str, object]]:
_ = workspace_path
prompt = self.build_task_prompt(task)
command = self._runtime_command()
cmd = [
command,
"run",
"--format",
"default",
*self._build_approval_args(),
*self._build_thinking_args(),
*self._build_session_args(),
*self._build_model_args(),
*list(self.config.extra_args),
prompt,
]
metadata = self.build_invocation_metadata(cmd)
metadata["binary"] = command
return cmd, metadata
def build_interactive_invocation(
self,
task: Task,
workspace_path: str | None = None,
) -> tuple[list[str], dict[str, object]]:
_ = workspace_path
prompt = self.build_task_prompt(task)
command = self._runtime_command()
cmd = [
command,
"run",
"--format",
"json",
*self._build_approval_args(),
*self._build_permission_handler_args(),
*self._build_thinking_args(),
*self._build_session_args(),
*self._build_model_args(),
*list(self.config.extra_args),
prompt,
]
metadata = self.build_invocation_metadata(cmd)
metadata["binary"] = command
return cmd, metadata
def normalize_result_output(self, output: str) -> str:
last_result = ""
last_assistant = ""
tool_summaries: list[str] = []
saw_json_event = False
for line in output.splitlines():
event = self._parse_json_line(line)
if not isinstance(event, dict):
continue
saw_json_event = True
event_type = str(event.get("type") or event.get("event") or "").strip()
if event_type in {"result", "session.result", "run.completed"}:
text = self._event_text(event)
if text:
last_result = text
elif (
"tool" in event_type
or "command" in event_type
or event_type.startswith("item.")
or self._event_part_type(event) == "tool"
):
summary = self._tool_summary(event)
if summary:
tool_summaries.append(summary)
elif (
self._event_role(event) == "assistant"
or event_type in {"assistant", "assistant_message", "message", "text"}
):
text = self._event_text(event)
if text:
last_assistant = text
if last_result or last_assistant:
return last_result or last_assistant
if saw_json_event:
return self._tool_only_result_fallback(tool_summaries)
return output
def format_progress_update(self, text: str, stream_name: str) -> str | None:
if stream_name != "stdout":
stripped = str(text or "").strip()
return f"[External:{self.agent_type}:stderr] {stripped[:500]}" if stripped else None
event = self._parse_json_line(text)
if not isinstance(event, dict):
return super().format_progress_update(text, stream_name)
event_type = str(event.get("type") or event.get("event") or "").strip()
part = event.get("part") if isinstance(event.get("part"), dict) else {}
part_type = str(part.get("type") or "").strip()
if event_type == "approval_request":
return None
if event_type in {"session", "session.started", "init", "step_start", "step-start"}:
session_id = self._session_id_from_event(event)
return (
f"[External:{self.agent_type}:init] session={session_id[:8]}"
if session_id
else None
)
if (
"tool" in event_type
or "command" in event_type
or event_type.startswith("item.")
or part_type == "tool"
):
summary = self._tool_summary(event)
return f"[External:{self.agent_type}:tool] {summary}" if summary else None
if event_type in {"thinking", "reasoning"} or part_type in {"thinking", "reasoning"}:
message = self._event_text(event)
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
if event_type in {"result", "session.result", "run.completed"}:
result = self._event_text(event)
return f"[External:{self.agent_type}:thinking] {result[:2400]}" if result else None
if (
self._event_role(event) == "assistant"
or event_type in {"assistant", "assistant_message", "message", "text"}
):
message = self._event_text(event)
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
return None
def detect_runtime_failure(self, text: str, stream_name: str) -> str | None:
_ = stream_name
lowered = str(text or "").lower()
if "permission-handler" in lowered and ("unknown" in lowered or "invalid" in lowered):
return "OpenCode rejected the configured stdio permission handler flag."
return None
def parse_approval_request(
self,
text: str,
stream_name: str,
) -> ExternalApprovalRequest | None:
event = self._parse_json_line(text)
if not isinstance(event, dict) or str(event.get("type") or "").strip() != "approval_request":
return super().parse_approval_request(text, stream_name)
permission = event.get("permission")
if not isinstance(permission, dict):
return super().parse_approval_request(text, stream_name)
permission_name = str(permission.get("permission") or "").strip()
patterns = [str(item).strip() for item in permission.get("patterns") or [] if str(item).strip()]
metadata = permission.get("metadata") if isinstance(permission.get("metadata"), dict) else {}
common_metadata = {
"stream": stream_name,
"provider_event_type": "approval_request",
"approval_id": str(permission.get("id") or "").strip(),
"permission_name": permission_name,
"permission_patterns": patterns,
"permission_always": list(permission.get("always") or []),
"provider_metadata": metadata,
"raw_event": event,
}
normalized = self._normalize_name(permission_name)
if normalized == "bash":
command = self.normalize_shell_command(
metadata.get("command") or metadata.get("cmd") or (patterns[0] if patterns else "")
)
arguments: dict[str, object] = {"command": command} if command else {}
working_directory = (
metadata.get("workdir") or metadata.get("cwd") or metadata.get("working_directory")
)
if working_directory:
arguments["working_directory"] = str(working_directory)
prompt_text = f"Allow OpenCode to run `{command}`?" if command else "Allow OpenCode to run a shell command?"
return ExternalApprovalRequest(
approval_scope="tool",
action_name="shell_exec",
prompt_text=prompt_text,
arguments=arguments,
metadata=common_metadata,
raw_text=text,
)
path_value = (
metadata.get("filepath")
or metadata.get("filePath")
or metadata.get("path")
or (patterns[0] if patterns else "")
)
if normalized == "edit":
arguments = {"path": str(path_value)} if path_value else {}
prompt_text = (
f"Allow OpenCode to edit `{path_value}`?" if path_value else "Allow OpenCode to edit files?"
)
return ExternalApprovalRequest(
approval_scope="tool",
action_name="file_edit",
prompt_text=prompt_text,
arguments=arguments,
metadata=common_metadata,
raw_text=text,
)
if normalized == "read":
arguments = {"path": str(path_value)} if path_value else {}
prompt_text = (
f"Allow OpenCode to read `{path_value}`?" if path_value else "Allow OpenCode to read files?"
)
return ExternalApprovalRequest(
approval_scope="tool",
action_name="file_read",
prompt_text=prompt_text,
arguments=arguments,
metadata=common_metadata,
raw_text=text,
)
if normalized == "list":
arguments = {"path": str(path_value)} if path_value else {}
prompt_text = (
f"Allow OpenCode to list `{path_value}`?" if path_value else "Allow OpenCode to list files?"
)
return ExternalApprovalRequest(
approval_scope="tool",
action_name="file_list",
prompt_text=prompt_text,
arguments=arguments,
metadata=common_metadata,
raw_text=text,
)
if normalized == "glob":
arguments: dict[str, object] = {}
if path_value:
arguments["path"] = str(path_value)
pattern = str(metadata.get("pattern") or (patterns[0] if patterns else "")).strip()
if pattern:
arguments["query"] = pattern
prompt_text = f"Allow OpenCode to glob `{pattern}`?" if pattern else "Allow OpenCode to glob files?"
return ExternalApprovalRequest(
approval_scope="tool",
action_name="file_glob",
prompt_text=prompt_text,
arguments=arguments,
metadata=common_metadata,
raw_text=text,
)
if normalized == "grep":
arguments = {}
if path_value:
arguments["path"] = str(path_value)
query = str(metadata.get("pattern") or (patterns[0] if patterns else "")).strip()
if query:
arguments["query"] = query
prompt_text = f"Allow OpenCode to grep `{query}`?" if query else "Allow OpenCode to grep files?"
return ExternalApprovalRequest(
approval_scope="tool",
action_name="file_search",
prompt_text=prompt_text,
arguments=arguments,
metadata=common_metadata,
raw_text=text,
)
if normalized == "externaldirectory":
arguments = {"path": str(patterns[0])} if patterns else {}
prompt_text = (
f"Allow OpenCode to access `{patterns[0]}` outside the workspace?"
if patterns
else "Allow OpenCode to access directories outside the workspace?"
)
return ExternalApprovalRequest(
approval_scope="external_agent",
action_name=f"{self.agent_type}:external_directory",
prompt_text=prompt_text,
arguments=arguments,
metadata=common_metadata,
raw_text=text,
)
prompt_text = permission_name or "permission request"
if patterns:
prompt_text = f"{prompt_text}: {', '.join(patterns[:3])}"
return ExternalApprovalRequest(
approval_scope="external_agent",
action_name=f"{self.agent_type}:{permission_name or 'permission'}",
prompt_text=prompt_text,
arguments={"target": patterns[0]} if patterns else {},
metadata=common_metadata,
raw_text=text,
)
def format_approval_response(
self,
request: ExternalApprovalRequest,
approved: bool,
decision: ApprovalDecision,
) -> str:
approval_id = str(request.metadata.get("approval_id") or "").strip()
if not approval_id:
return super().format_approval_response(request, approved, decision)
human_reply = str((decision.metadata or {}).get("human_reply") or "").strip().lower()
reply = "reject"
if approved:
reply = "always" if human_reply in {"always_project", "always_global"} else "once"
payload = {
"type": "approval_response",
"permission_id": approval_id,
"reply": reply,
}
return json.dumps(payload, ensure_ascii=False) + "\n"
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
if not await self.is_available():
return TaskResult(status=TaskStatus.FAILED, content="OpenCode CLI not found")
cmd, metadata = self.build_invocation(task, workspace_path=workspace_path)
logger.info(f"OpenCode executing: {task.title}")
try:
stdin_policy = self.stdin_policy_for_process(cmd, metadata)
self._record_stdin_policy_metadata(metadata, stdin_policy)
self._process = await asyncio.create_subprocess_exec(
*cmd,
stdin=self._stdin_target_for_policy(stdin_policy),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace_path,
**self._subprocess_group_kwargs(),
)
stdout, stderr = await asyncio.wait_for(self._process.communicate(), timeout=600)
output = stdout.decode("utf-8", errors="replace")
errors = stderr.decode("utf-8", errors="replace")
if self._process.returncode == 0:
return TaskResult(
status=TaskStatus.DONE,
content=output,
artifacts={**metadata, "stderr": errors} if errors else metadata,
)
return TaskResult(
status=TaskStatus.FAILED,
content=f"OpenCode exited with code {self._process.returncode}\n{errors}\n{output}",
artifacts=metadata,
)
except asyncio.TimeoutError:
if self._process:
self._process.kill()
return TaskResult(
status=TaskStatus.FAILED,
content="OpenCode timed out after 600s",
artifacts=metadata,
)
except Exception as e:
return TaskResult(
status=TaskStatus.FAILED,
content=f"OpenCode error: {e}",
artifacts=metadata,
)
finally:
self._process = None
async def cancel(self, task_id: str) -> bool:
if self._process and self._process.returncode is None:
self._process.kill()
return True
return False
def _build_model_args(self) -> list[str]:
if not self.config.model:
return []
extra_args = list(self.config.extra_args)
if self.config.model_flag and any(
arg == self.config.model_flag or arg.startswith(f"{self.config.model_flag}=")
for arg in extra_args
):
return []
flag = self.config.model_flag or "--model"
return [flag, self.config.model]
def _build_approval_args(self) -> list[str]:
extra_args = list(self.config.extra_args)
if any(arg == "--dangerously-skip-permissions" for arg in extra_args):
return []
mode = str(self.config.approval_mode or "auto").strip().lower()
if mode == "full-auto":
return ["--dangerously-skip-permissions"]
return []
def _build_permission_handler_args(self) -> list[str]:
extra_args = list(self.config.extra_args)
if any(
arg == "--permission-handler" or arg.startswith("--permission-handler=")
for arg in extra_args
):
return []
if self._supports_stdio_permission_handler():
return ["--permission-handler", "stdio-json"]
return []
def _supports_stdio_permission_handler(self) -> bool:
command = self.resolve_binary() or shutil.which(self.configured_command()) or self.configured_command()
if not command:
return False
cached = self._permission_handler_support_cache.get(command)
if cached is not None:
return cached
try:
proc = subprocess.run(
[command, "run", "--help"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=2,
check=False,
)
help_text = f"{proc.stdout}\n{proc.stderr}"
supported = "--permission-handler" in help_text
except (OSError, subprocess.SubprocessError):
supported = False
self._permission_handler_support_cache[command] = supported
return supported
def _build_thinking_args(self) -> list[str]:
extra_args = list(self.config.extra_args)
if any(arg == "--thinking" for arg in extra_args):
return []
if bool(getattr(self.config, "show_thinking", False)):
return ["--thinking"]
return []
def _build_session_args(self) -> list[str]:
extra_args = list(self.config.extra_args)
if any(arg in {"--continue", "--session"} or arg.startswith("--session=") for arg in extra_args):
return []
mode = str(self.config.session_mode or "auto").strip().lower()
if mode == "resume":
session_id = str(self.config.session_id or "").strip()
if session_id:
return ["--session", session_id]
return ["--continue"]
if mode == "new" and self.config.new_session_flag:
return [self.config.new_session_flag]
return []
@classmethod
def _session_id_from_event(cls, event: dict[str, Any]) -> str:
for key in ("sessionID", "sessionId", "session_id", "id"):
token = str(event.get(key) or "").strip()
if token:
return token
for key in ("session", "message", "data", "result"):
nested = event.get(key)
if isinstance(nested, dict):
token = cls._session_id_from_event(nested)
if token:
return token
return ""
@staticmethod
def _event_role(event: dict[str, Any]) -> str:
role = str(event.get("role") or "").strip().lower()
if role:
return role
message = event.get("message")
if isinstance(message, dict):
return str(message.get("role") or "").strip().lower()
return ""
@staticmethod
def _event_part_type(event: dict[str, Any]) -> str:
part = event.get("part")
if isinstance(part, dict):
return str(part.get("type") or "").strip().lower()
return ""
@classmethod
def _event_text(cls, event: Any) -> str:
if isinstance(event, str):
return event.strip()
if isinstance(event, list):
parts = [cls._event_text(item) for item in event]
return "\n".join(part for part in parts if part).strip()
if not isinstance(event, dict):
return ""
for key in ("result", "message", "part", "text", "content", "summary", "output"):
value = event.get(key)
if key in {"message", "part"} and isinstance(value, dict):
nested = cls._event_text(value)
if nested:
return nested
elif isinstance(value, (str, list, dict)):
nested = cls._event_text(value)
if nested:
return nested
return ""
@classmethod
def _tool_summary(cls, event: dict[str, Any]) -> str:
item = event.get("item") if isinstance(event.get("item"), dict) else event
part = item.get("part") if isinstance(item.get("part"), dict) else {}
if part:
tool_name = str(part.get("tool") or part.get("name") or "tool").strip()
normalized_tool = cls._normalize_name(tool_name)
state = part.get("state") if isinstance(part.get("state"), dict) else {}
tool_input = state.get("input") if isinstance(state.get("input"), dict) else {}
command = cls.normalize_shell_command(
tool_input.get("command") or tool_input.get("cmd") or tool_input.get("argv")
)
title = str(
state.get("title")
or tool_input.get("description")
or state.get("description")
or ""
).strip()
metadata = state.get("metadata") if isinstance(state.get("metadata"), dict) else {}
raw_output = str(state.get("output") or metadata.get("output") or "").strip()
status = str(state.get("status") or "").strip()
lines: list[str] = []
if command:
lines.append(f"$ {command[:240]}")
else:
target = str(
tool_input.get("path")
or tool_input.get("file")
or tool_input.get("target")
or tool_input.get("pattern")
or tool_input.get("query")
or tool_input.get("searchTerm")
or ""
).strip()
label = "web search" if normalized_tool in {"websearch", "web_search"} else tool_name
lines.append(f"{label}: {target}".strip(": "))
if title and title not in lines[0]:
lines.append(title[:240])
output = cls._summarize_tool_output(tool_name, raw_output)
if output:
lines.append(output)
elif status:
lines.append(f"status={status}")
return "\n".join(line for line in lines if line).strip()
source = item.get("tool_call") if isinstance(item.get("tool_call"), dict) else item
name = str(
source.get("name")
or source.get("tool_name")
or source.get("toolName")
or source.get("type")
or "tool"
).strip()
command = cls.normalize_shell_command(source.get("command"))
if command:
return f"$ {command[:240]}"
args = source.get("input") or source.get("arguments") or source.get("params") or {}
if isinstance(args, dict):
command = cls.normalize_shell_command(
args.get("command") or args.get("cmd") or args.get("argv")
)
if command:
return f"$ {command[:240]}"
path = str(args.get("path") or args.get("file_path") or args.get("target") or "").strip()
if path:
return f"{name} {path}"
output = str(source.get("aggregated_output") or source.get("output") or "").strip()
if output and name != "tool":
return f"{name}: {output[:200]}"
return name
@classmethod
def _tool_only_result_fallback(cls, tool_summaries: list[str]) -> str:
lines = [
"OpenCode completed but did not emit a final assistant message.",
"The raw JSON stream was parsed and suppressed from the user-facing reply.",
]
cleaned = [cls._compact_multiline(item, limit=500) for item in tool_summaries if item]
if cleaned:
lines.append("")
lines.append("Tool activity:")
lines.extend(f"- {item}" for item in cleaned[-6:])
return "\n".join(lines).strip()
@classmethod
def _summarize_tool_output(cls, tool_name: str, output: str) -> str:
normalized_tool = cls._normalize_name(tool_name)
if not output:
return ""
if normalized_tool in {"bash", "shell", "command", "exec"}:
return output[:1200]
parsed = cls._parse_json_line(output)
if isinstance(parsed, dict):
results = parsed.get("results")
if isinstance(results, list):
titles: list[str] = []
for item in results[:5]:
if not isinstance(item, dict):
continue
title = str(item.get("title") or item.get("url") or "").strip()
if title:
titles.append(title)
if titles:
return "results: " + "; ".join(titles)
if "url" in parsed or "title" in parsed:
return str(parsed.get("title") or parsed.get("url") or "").strip()[:500]
return cls._compact_multiline(output, limit=500)
@staticmethod
def _compact_multiline(text: str, *, limit: int) -> str:
compact = " ".join(str(text or "").split())
if len(compact) <= limit:
return compact
return compact[: max(0, limit - 1)].rstrip() + "…"