fix: stabilize terminal tool timeouts

This commit is contained in:
JOJO 2025-12-15 15:15:03 +08:00
parent 28383722cc
commit 8fe06753bb
7 changed files with 328 additions and 101 deletions

View File

@ -1303,7 +1303,7 @@ class MainTerminal:
"type": "function",
"function": {
"name": "terminal_input",
"description": "向活动终端发送命令或输入。禁止启动会占用终端界面的程序python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_reset 恢复。",
"description": "向活动终端发送命令或输入。禁止启动会占用终端界面的程序python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_reset 恢复。默认等待输出120秒最长300秒超时会尝试中断并返回已捕获输出。",
"parameters": {
"type": "object",
"properties": {
@ -1321,7 +1321,7 @@ class MainTerminal:
},
"timeout": {
"type": "number",
"description": "等待输出的最长秒数,默认使用配置项 TERMINAL_OUTPUT_WAIT"
"description": "等待输出的最长秒数,默认120最大300"
}
},
"required": ["command"]
@ -1446,11 +1446,15 @@ class MainTerminal:
"type": "function",
"function": {
"name": "run_python",
"description": "执行一次性 Python 脚本,可用于处理二进制或非 UTF-8 文件(如 Excel、Word、PDF、图片或进行数据分析与验证。请在脚本内显式读取文件并输出结果,避免长时间阻塞",
"description": "执行一次性 Python 脚本,可用于处理二进制或非 UTF-8 文件(如 Excel、Word、PDF、图片或进行数据分析与验证。默认超时20秒最长60秒超时会尝试中断并返回已捕获输出",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "Python代码"}
"code": {"type": "string", "description": "Python代码"},
"timeout": {
"type": "number",
"description": "超时时长默认20最大60"
}
},
"required": ["code"]
}
@ -1460,11 +1464,15 @@ class MainTerminal:
"type": "function",
"function": {
"name": "run_command",
"description": "执行一次性终端命令适合查看文件信息file/ls/stat/iconv 等)、转换编码或调用 CLI 工具。禁止启动交互式程序;对已聚焦文件仅允许使用 grep -n 等定位命令。输出超过10000字符将被拒绝,可先限制返回体量",
"description": "执行一次性终端命令适合查看文件信息file/ls/stat/iconv 等)、转换编码或调用 CLI 工具。禁止启动交互式程序;对已聚焦文件仅允许使用 grep -n 等定位命令。默认超时10秒最长30秒超时会尝试中断并返回已捕获输出输出超过10000字符将被截断或拒绝。",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "终端命令"}
"command": {"type": "string", "description": "终端命令"},
"timeout": {
"type": "number",
"description": "超时时长默认10最大30"
}
},
"required": ["command"]
}
@ -2079,10 +2087,16 @@ class MainTerminal:
}
elif tool_name == "run_python":
result = await self.terminal_ops.run_python_code(arguments["code"])
result = await self.terminal_ops.run_python_code(
arguments["code"],
timeout=arguments.get("timeout")
)
elif tool_name == "run_command":
result = await self.terminal_ops.run_command(arguments["command"])
result = await self.terminal_ops.run_command(
arguments["command"],
timeout=arguments.get("timeout")
)
# 字符数检查
if result.get("success") and "output" in result:

View File

@ -5,8 +5,9 @@ import subprocess
import os
import sys
import time
import signal
from pathlib import Path
from typing import Optional, Callable, Dict, List
from typing import Optional, Callable, Dict, List, Tuple
from datetime import datetime
import threading
import queue
@ -507,16 +508,23 @@ class PersistentTerminal:
def _process_output(self, output: str):
"""处理输出行"""
# 添加到缓冲区
self.output_buffer.append(output)
self.total_output_size += len(output)
now = time.time()
self.last_output_time = now
noisy_markers = (
"bash: cannot set terminal process group",
"bash: no job control in this shell",
)
for line in output.splitlines(keepends=True):
if any(noise in line for noise in noisy_markers):
continue
self.output_buffer.append(line)
self.total_output_size += len(line)
now = time.time()
self.last_output_time = now
# 记录输出事件
self._output_event_counter += 1
self.output_history.append((self._output_event_counter, now, output))
self._append_io_event('output', output, timestamp=now)
# 记录输出事件
self._output_event_counter += 1
self.output_history.append((self._output_event_counter, now, line))
self._append_io_event('output', line, timestamp=now)
# 控制输出历史长度
if len(self.output_history) > 2000:
@ -622,11 +630,18 @@ class PersistentTerminal:
if not self.is_running or not self.process:
return {
"success": False,
"error": "终端未运行",
"error": "终端未运行,请先打开终端会话。",
"session": self.session_name
}
try:
# 清空残留输出,防止上一条命令的输出干扰
try:
while True:
self.output_queue.get_nowait()
except queue.Empty:
pass
marker = self._capture_history_marker()
if timeout is None:
timeout = TERMINAL_OUTPUT_WAIT
@ -674,12 +689,19 @@ class PersistentTerminal:
else:
command_bytes = to_send.encode('utf-8', errors='replace')
self.process.stdin.write(command_bytes)
self.process.stdin.flush()
try:
self.process.stdin.write(command_bytes)
self.process.stdin.flush()
except Exception:
return {
"success": False,
"error": "终端已不可用或输入失败,请重新打开终端会话。",
"session": self.session_name
}
# 如果需要等待输出
if wait_for_output:
output = self._wait_for_output(timeout=timeout)
output, timed_out = self._wait_for_output(timeout=timeout)
recent_output = self._get_output_since_marker(marker)
if recent_output:
output = recent_output
@ -690,7 +712,9 @@ class PersistentTerminal:
output_clean = output.strip()
has_output = bool(output_clean)
status = "completed"
if not has_output:
if timed_out:
status = "timeout"
elif not has_output:
if self.echo_loop_detected:
status = "echo_loop"
elif self.is_interactive:
@ -701,23 +725,27 @@ class PersistentTerminal:
if self.echo_loop_detected:
status = "output_with_echo"
message_map = {
"completed": "命令执行完成,已捕获终端输出",
"no_output": "未捕获任何输出,命令可能未产生可见结果或终端已卡死需要重制",
"awaiting_input": "命令已发送,终端正在等待进一步输入或进程仍在运行",
"completed": "命令执行完成",
"no_output": "未捕获输出,命令可能未产生结果",
"awaiting_input": "命令已发送,终端等待进一步输入或仍在运行",
"echo_loop": "检测到终端正在回显输入,命令可能未成功执行",
"output_with_echo": "命令产生输出,但终端疑似重复回显,请检查是否卡住"
"output_with_echo": "命令产生输出,但终端疑似重复回显",
"timeout": f"命令超时({int(timeout)}秒)"
}
message = message_map.get(status, "命令执行完成")
if output_truncated:
message += f"(输出已截断,保留末尾{TERMINAL_INPUT_MAX_CHARS}字符)"
elapsed_ms = int((time.time() - start_time) * 1000)
return {
"success": True,
"success": status in {"completed", "output_with_echo"},
"session": self.session_name,
"command": command_text,
"output": output,
"message": message,
"status": status,
"truncated": output_truncated
"truncated": output_truncated,
"elapsed_ms": elapsed_ms,
"timeout": timeout
}
else:
return {
@ -727,7 +755,9 @@ class PersistentTerminal:
"output": "",
"message": "命令已发送至终端,后续输出将实时流式返回",
"status": "pending",
"truncated": False
"truncated": False,
"elapsed_ms": int((time.time() - start_time) * 1000),
"timeout": timeout
}
except Exception as e:
@ -739,11 +769,15 @@ class PersistentTerminal:
"session": self.session_name
}
def _wait_for_output(self, timeout: float = 5) -> str:
"""等待并收集输出"""
def _wait_for_output(self, timeout: float = 5) -> Tuple[str, bool]:
"""等待并收集输出,返回 (output, timed_out)。
策略若尚未收到任何输出则一直等到超时一旦收到输出若后续空闲>0.3s则视为命令结束
"""
collected_output = []
start_time = time.time()
last_output_time = time.time()
output_seen = False
if timeout is None or timeout <= 0:
timeout = 0
@ -754,29 +788,33 @@ class PersistentTerminal:
output = self.output_queue.get_nowait()
collected_output.append(output)
except queue.Empty:
return ''.join(collected_output)
return ''.join(collected_output), False
idle_threshold = 1.5
while time.time() - start_time < timeout:
try:
remaining = max(0.05, min(0.5, timeout - (time.time() - start_time)))
output = self.output_queue.get(timeout=remaining)
collected_output.append(output)
last_output_time = time.time()
output_seen = True
# 快速收集剩余输出,直到短暂空闲
while True:
try:
output = self.output_queue.get(timeout=0.1)
collected_output.append(output)
last_output_time = time.time()
output_seen = True
except queue.Empty:
break
except queue.Empty:
if collected_output and time.time() - last_output_time > 0.3:
if output_seen and (time.time() - last_output_time > idle_threshold):
break
if timeout == 0:
break
return ''.join(collected_output)
timed_out = (time.time() - start_time) >= timeout and timeout > 0
return ''.join(collected_output), timed_out
def get_output(self, last_n_lines: int = 50) -> str:
"""

View File

@ -491,7 +491,32 @@ class TerminalManager:
# 发送命令
terminal = self.terminals[target_session]
result = terminal.send_command(command, wait_for_output, timeout=timeout)
if timeout is None or timeout <= 0:
timeout = 120
timeout = min(timeout, 300)
wrapped_command = command
wait_timeout = timeout
base_timeout = timeout
if wait_for_output and timeout > 0:
wrapped_command = f"timeout -k 2 {int(timeout)}s {command}"
wait_timeout = timeout + 3 # 留出终止与收尾输出
result = terminal.send_command(wrapped_command, wait_for_output, timeout=wait_timeout)
result["timeout"] = base_timeout
# 若超时阈值已到且状态仍是 completed则视为超时避免误判
elapsed_ms = result.get("elapsed_ms")
if (
wait_for_output
and base_timeout
and elapsed_ms is not None
and elapsed_ms >= int(base_timeout * 1000)
and result.get("status") == "completed"
):
result["status"] = "timeout"
result["success"] = False
result["message"] = result.get("message") or f"命令超时({int(base_timeout)}秒)"
return result

View File

@ -5,6 +5,8 @@ import sys
import asyncio
import subprocess
import shutil
import time
import signal
from pathlib import Path
from typing import Dict, Optional, Tuple, TYPE_CHECKING
try:
@ -129,6 +131,13 @@ class TerminalOperator:
return True, ""
@staticmethod
def _clamp_timeout(requested: Optional[int], default: int, max_limit: int) -> int:
"""对timeout进行默认化与上限夹紧。"""
if not requested or requested <= 0:
return default
return min(int(requested), max_limit)
async def run_command(
self,
command: str,
@ -146,7 +155,7 @@ class TerminalOperator:
Returns:
执行结果字典
"""
# 每次执行前重置工具容器,防止上一条命令的输出/状态干扰
# 每次执行前重置工具容器(保持隔离),但下面改用一次性子进程执行,仍保留重置以兼容后续逻辑
self._reset_toolbox()
# 替换命令中的python3为实际可用的命令
if "python3" in command and self.python_cmd != "python3":
@ -176,17 +185,15 @@ class TerminalOperator:
"return_code": -1
}
timeout = timeout or TERMINAL_COMMAND_TIMEOUT
# 默认10s上限30s
timeout = self._clamp_timeout(timeout, default=10, max_limit=TERMINAL_COMMAND_TIMEOUT)
print(f"{OUTPUT_FORMATS['terminal']} 执行命令: {command}")
print(f"{OUTPUT_FORMATS['info']} 工作目录: {work_path}")
try:
toolbox_raw = await self._get_toolbox().run(command, work_path, timeout=timeout)
result_payload = self._format_toolbox_output(toolbox_raw)
except Exception as exc:
print(f"{OUTPUT_FORMATS['warning']} 工具容器执行失败,回退到本地子进程: {exc}")
result_payload = await self._run_command_subprocess(command, work_path, timeout)
start_ts = time.time()
# 改为一次性子进程执行,确保等待到超时或命令结束
result_payload = await self._run_command_subprocess(command, work_path, timeout)
# 字符数检查
if result_payload.get("success") and "output" in result_payload:
@ -200,6 +207,9 @@ class TerminalOperator:
"command": command
}
result_payload.setdefault("status", "completed" if result_payload.get("success") else "error")
result_payload["timeout"] = timeout
result_payload["elapsed_ms"] = int((time.time() - start_ts) * 1000)
return result_payload
def _resolve_work_path(self, working_dir: Optional[str]) -> Path:
@ -212,6 +222,17 @@ class TerminalOperator:
def _format_toolbox_output(self, payload: Dict) -> Dict:
success = bool(payload.get("success"))
output_text = payload.get("output", "") or ""
# 去掉常见的交互式shell警告
noisy_lines = (
"bash: cannot set terminal process group",
"bash: no job control in this shell",
)
filtered = []
for line in output_text.splitlines():
if any(noise in line for noise in noisy_lines):
continue
filtered.append(line)
output_text = "\n".join(filtered)
result = {
"success": success,
"output": output_text,
@ -228,44 +249,124 @@ class TerminalOperator:
return result
async def _run_command_subprocess(self, command: str, work_path: Path, timeout: int) -> Dict:
start_ts = time.time()
try:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(work_path),
shell=True
)
process = None
exec_cmd = None
use_shell = True
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout
# 如果存在容器会话且模式为docker则在容器内执行
if self.container_session and getattr(self.container_session, "mode", None) == "docker":
container_name = getattr(self.container_session, "container_name", None)
mount_path = getattr(self.container_session, "mount_path", "/workspace") or "/workspace"
docker_bin = shutil.which("docker") or "docker"
try:
relative = work_path.relative_to(self.project_path).as_posix()
except ValueError:
relative = ""
container_workdir = mount_path.rstrip("/")
if relative:
container_workdir = f"{container_workdir}/{relative}"
exec_cmd = [
docker_bin,
"exec",
"-w",
container_workdir,
container_name,
"/bin/bash",
"-lc",
command,
]
use_shell = False
# 统一环境,确保 Python 输出无缓冲
env = os.environ.copy()
env.setdefault("PYTHONUNBUFFERED", "1")
if use_shell:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(work_path),
shell=True,
env=env,
start_new_session=True,
)
else:
process = await asyncio.create_subprocess_exec(
*exec_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
start_new_session=True,
)
stdout_buf: list[bytes] = []
stderr_buf: list[bytes] = []
async def _read_stream(stream, collector):
try:
async for chunk in stream:
if chunk:
collector.append(chunk)
except asyncio.CancelledError:
pass
except Exception:
pass
stdout_task = asyncio.create_task(_read_stream(process.stdout, stdout_buf))
stderr_task = asyncio.create_task(_read_stream(process.stderr, stderr_buf))
timed_out = False
try:
await asyncio.wait_for(process.wait(), timeout=timeout)
except asyncio.TimeoutError:
process.kill()
await process.wait()
return {
"success": False,
"error": f"命令执行超时 ({timeout}秒)",
"output": "",
"return_code": -1
}
timed_out = True
try:
os.killpg(process.pid, signal.SIGINT)
except Exception:
pass
try:
await asyncio.wait_for(process.wait(), timeout=2)
except asyncio.TimeoutError:
try:
os.killpg(process.pid, signal.SIGKILL)
except Exception:
process.kill()
await process.wait()
# 确保读取协程结束
await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
# 兜底再读一次,防止剩余缓冲未被读取
try:
remaining_out = await process.stdout.read()
if remaining_out:
stdout_buf.append(remaining_out)
except Exception:
pass
try:
remaining_err = await process.stderr.read()
if remaining_err:
stderr_buf.append(remaining_err)
except Exception:
pass
stdout = b"".join(stdout_buf)
stderr = b"".join(stderr_buf)
stdout_text = stdout.decode('utf-8', errors='replace') if stdout else ""
stderr_text = stderr.decode('utf-8', errors='replace') if stderr else ""
success = process.returncode == 0
if success:
print(f"{OUTPUT_FORMATS['success']} 命令执行成功")
else:
print(f"{OUTPUT_FORMATS['error']} 命令执行失败 (返回码: {process.returncode})")
success = (process.returncode == 0) and not timed_out
status = "completed" if success else ("timeout" if timed_out else "error")
output_parts = []
if stdout_text:
output_parts.append(stdout_text)
if stderr_text:
output_parts.append(f"[stderr]\n{stderr_text}")
output_parts.append(stderr_text)
combined_output = "\n".join(output_parts)
truncated = False
@ -275,20 +376,30 @@ class TerminalOperator:
response = {
"success": success,
"status": status,
"command": command,
"output": combined_output,
"return_code": process.returncode,
"truncated": truncated
"truncated": truncated,
"timeout": timeout,
"elapsed_ms": int((time.time() - start_ts) * 1000)
}
if not success and timed_out:
response["message"] = f"命令执行超时 ({timeout}秒)"
elif not success and process.returncode is not None:
response["message"] = f"命令执行失败 (返回码: {process.returncode})"
if stderr_text:
response["stderr"] = stderr_text
return response
except Exception as exc:
return {
"success": False,
"status": "error",
"error": f"执行失败: {str(exc)}",
"output": "",
"return_code": -1
"return_code": -1,
"timeout": timeout,
"elapsed_ms": int((time.time() - start_ts) * 1000)
}
async def run_python_code(
@ -306,7 +417,7 @@ class TerminalOperator:
Returns:
执行结果字典
"""
timeout = timeout or CODE_EXECUTION_TIMEOUT
timeout = self._clamp_timeout(timeout, default=20, max_limit=CODE_EXECUTION_TIMEOUT)
# 强制重置工具容器,避免上一段代码仍在运行时输出混入
self._reset_toolbox()
@ -328,7 +439,7 @@ class TerminalOperator:
# 使用检测到的Python命令执行文件相对路径可兼容容器挂载路径
result = await self.run_command(
f'{self.python_cmd} "{relative_temp}"',
f'{self.python_cmd} -u "{relative_temp}"',
timeout=timeout
)

View File

@ -118,6 +118,8 @@ const appOptions = {
activeTools: new Map(),
toolActionIndex: new Map(),
toolStacks: new Map(),
// 当前任务是否仍在进行中(用于保持输入区的“停止”状态)
taskInProgress: false,
// ==========================================
// 对话管理相关状态
@ -302,7 +304,7 @@ const appOptions = {
},
composerBusy() {
const monitorLock = this.monitorIsLocked && this.chatDisplayMode === 'monitor';
return this.streamingUi || monitorLock || this.stopRequested;
return this.streamingUi || this.taskInProgress || monitorLock || this.stopRequested;
}
},
@ -1038,6 +1040,7 @@ const appOptions = {
this.streamingMessage = false;
this.currentMessageIndex = -1;
this.stopRequested = false;
this.taskInProgress = false;
this.dropToolEvents = false;
// 清理工具状态
@ -1957,6 +1960,8 @@ const appOptions = {
return;
}
// 标记任务进行中,直到任务完成或用户手动停止
this.taskInProgress = true;
this.chatAddUserMessage(message);
this.socket.emit('send_message', { message: message, conversation_id: this.currentConversationId });
if (typeof this.monitorShowPendingReply === 'function') {
@ -1995,6 +2000,7 @@ const appOptions = {
// 立即清理前端状态,避免出现“不可输入也不可停止”的卡死状态
this.clearPendingTools('user_stop');
this.streamingMessage = false;
this.taskInProgress = false;
this.forceUnlockMonitor('user_stop');
},
@ -2035,6 +2041,7 @@ const appOptions = {
this.toolActionIndex.clear();
this.toolStacks.clear();
this.stopRequested = false;
this.taskInProgress = false;
},
async clearChat() {

View File

@ -690,6 +690,7 @@ export async function initializeLegacySocket(ctx: any) {
resetStreamingBuffer();
ctx.monitorResetSpeech();
ctx.cleanupStaleToolActions();
ctx.taskInProgress = true;
ctx.chatStartAssistantMessage();
streamingState.activeMessageIndex =
typeof ctx.currentMessageIndex === 'number' ? ctx.currentMessageIndex : null;
@ -1114,12 +1115,14 @@ export async function initializeLegacySocket(ctx: any) {
// 任务停止
ctx.socket.on('task_stopped', (data) => {
socketLog('任务已停止:', data.message);
ctx.taskInProgress = false;
ctx.scheduleResetAfterTask('socket:task_stopped', { preserveMonitorWindows: true });
});
// 任务完成重点更新Token统计
ctx.socket.on('task_complete', (data) => {
socketLog('任务完成', data);
ctx.taskInProgress = false;
ctx.scheduleResetAfterTask('socket:task_complete', { preserveMonitorWindows: true });
resetPendingToolEvents();
@ -1167,6 +1170,7 @@ export async function initializeLegacySocket(ctx: any) {
// 仅标记当前流结束,避免状态错乱
ctx.streamingMessage = false;
ctx.stopRequested = false;
ctx.taskInProgress = false;
});
// 命令结果

View File

@ -301,20 +301,45 @@ def _format_terminal_session(result_data: Dict[str, Any]) -> str:
return result_data.get("message") or f"{tag} 操作已完成。"
def _plain_command_output(result_data: Dict[str, Any]) -> str:
"""生成纯文本输出,按需要加状态前缀。"""
output = result_data.get("output") or ""
status = (result_data.get("status") or "").lower()
timeout = result_data.get("timeout")
return_code = result_data.get("return_code")
truncated = result_data.get("truncated")
prefixes = []
if status in {"timeout"} and timeout:
prefixes.append(f"[timeout after {int(timeout)}s]")
elif status in {"timeout"}:
prefixes.append("[timeout]")
elif status in {"killed"}:
prefixes.append("[killed]")
elif status in {"awaiting_input"}:
prefixes.append("[awaiting_input]")
elif status in {"no_output"} and not output:
prefixes.append("[no_output]")
elif status in {"error"} and return_code is not None:
prefixes.append(f"[error rc={return_code}]")
elif status in {"error"}:
prefixes.append("[error]")
if truncated:
prefixes.append("[truncated]")
prefix_text = "".join(prefixes)
if prefix_text and output:
return f"{prefix_text}\n{output}"
if prefix_text:
return prefix_text
if not output:
return "[no_output]"
return output
def _format_terminal_input(result_data: Dict[str, Any]) -> str:
if not result_data.get("success"):
return _format_failure("terminal_input", result_data)
session = result_data.get("session") or result_data.get("session_name") or "default"
command = result_data.get("command") or "(命令缺失)"
status = result_data.get("status") or "completed"
message = result_data.get("message") or ""
lines = [
f"terminal_input: 在 {session} 执行 `{command}`,状态 {status}",
]
if message:
lines.append(message)
lines.append(_summarize_output_block(result_data.get("output"), result_data.get("truncated")))
return "\n".join(lines)
return _plain_command_output(result_data)
def _format_sleep(result_data: Dict[str, Any]) -> str:
@ -332,16 +357,19 @@ def _format_sleep(result_data: Dict[str, Any]) -> str:
def _format_run_command(result_data: Dict[str, Any]) -> str:
return _format_command_result("run_command", result_data)
text = _plain_command_output(result_data)
if (result_data.get("status") or "").lower() == "timeout":
suggestion = "建议在持久终端中直接运行该命令terminal_session + terminal_input或缩短命令执行时间。"
text = f"{text}\n{suggestion}" if text else suggestion
return text
def _format_run_python(result_data: Dict[str, Any]) -> str:
base = _format_command_result("run_python", result_data)
code = result_data.get("code")
if not isinstance(code, str):
return base
header = f"run_python: 执行临时代码({len(code)} 字符)"
return "\n".join([header, base])
text = _plain_command_output(result_data)
if (result_data.get("status") or "").lower() == "timeout":
suggestion = "建议将代码保存为脚本后在持久终端中执行terminal_session + terminal_input或拆分/优化代码以缩短运行时间。"
text = f"{text}\n{suggestion}" if text else suggestion
return text
def _format_todo_create(result_data: Dict[str, Any]) -> str: