her
This commit is contained in:
parent
133b557512
commit
ed629ff60e
7 changed files with 948 additions and 525 deletions
|
|
@ -18,26 +18,35 @@ CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
|
|||
BRAILLE_SPINNER_RE = re.compile(r"[\u2800-\u28ff]")
|
||||
SPINNER_ONLY_RE = re.compile(r"^[\s|/\\\-]+$")
|
||||
BOX_DRAWING_ONLY_RE = re.compile(r"^[\s\u2500-\u257f]+$")
|
||||
THINKING_LINE_RE = re.compile(r"^(?:agent|nanobot)\s+is\s+thinking\b", re.IGNORECASE)
|
||||
THINKING_LINE_RE = re.compile(
|
||||
r"\b(?:agent|nanobot|napbot)\b(?:\s+is)?\s+thinking\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
USER_ECHO_LINE_RE = re.compile(r"^(?:you|user)\s*:", re.IGNORECASE)
|
||||
TOOL_STREAM_LINE_RE = re.compile(
|
||||
r"^(?:tool(?:\s+call|\s+output)?|calling\s+tool|running\s+tool|executing\s+tool)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
LEADING_NON_WORD_RE = re.compile(r"^[^\w]+")
|
||||
WHITESPACE_RE = re.compile(r"\s+")
|
||||
AGENT_OUTPUT_PREFIX_RE = re.compile(
|
||||
r"^(?:nanobot|napbot)\b\s*[:>\-]?\s*", re.IGNORECASE
|
||||
)
|
||||
EMOJI_RE = re.compile(
|
||||
"[" # Common emoji and pictograph blocks.
|
||||
"\U0001F1E6-\U0001F1FF"
|
||||
"\U0001F300-\U0001F5FF"
|
||||
"\U0001F600-\U0001F64F"
|
||||
"\U0001F680-\U0001F6FF"
|
||||
"\U0001F700-\U0001F77F"
|
||||
"\U0001F780-\U0001F7FF"
|
||||
"\U0001F800-\U0001F8FF"
|
||||
"\U0001F900-\U0001F9FF"
|
||||
"\U0001FA00-\U0001FAFF"
|
||||
"\u2600-\u26FF"
|
||||
"\u2700-\u27BF"
|
||||
"\uFE0F"
|
||||
"\u200D"
|
||||
"\U0001f1e6-\U0001f1ff"
|
||||
"\U0001f300-\U0001f5ff"
|
||||
"\U0001f600-\U0001f64f"
|
||||
"\U0001f680-\U0001f6ff"
|
||||
"\U0001f700-\U0001f77f"
|
||||
"\U0001f780-\U0001f7ff"
|
||||
"\U0001f800-\U0001f8ff"
|
||||
"\U0001f900-\U0001f9ff"
|
||||
"\U0001fa00-\U0001faff"
|
||||
"\u2600-\u26ff"
|
||||
"\u2700-\u27bf"
|
||||
"\ufe0f"
|
||||
"\u200d"
|
||||
"]"
|
||||
)
|
||||
|
||||
|
|
@ -70,7 +79,10 @@ def _resolve_nanobot_command_and_workdir() -> tuple[str, Path]:
|
|||
if nanobot_venv_python.exists():
|
||||
if not workdir_override:
|
||||
default_workdir = nanobot_dir
|
||||
return f"{nanobot_venv_python} -m nanobot agent --no-markdown", default_workdir
|
||||
return (
|
||||
f"{nanobot_venv_python} -m nanobot agent --no-markdown",
|
||||
default_workdir,
|
||||
)
|
||||
|
||||
return "nanobot agent --no-markdown", default_workdir
|
||||
|
||||
|
|
@ -80,7 +92,11 @@ def _infer_venv_root(command_parts: list[str], workdir: Path) -> Path | None:
|
|||
return None
|
||||
|
||||
binary = Path(command_parts[0]).expanduser()
|
||||
if binary.is_absolute() and binary.name.startswith("python") and binary.parent.name == "bin":
|
||||
if (
|
||||
binary.is_absolute()
|
||||
and binary.name.startswith("python")
|
||||
and binary.parent.name == "bin"
|
||||
):
|
||||
return binary.parent.parent
|
||||
|
||||
for candidate in (workdir / ".venv", workdir / "venv"):
|
||||
|
|
@ -89,7 +105,9 @@ def _infer_venv_root(command_parts: list[str], workdir: Path) -> Path | None:
|
|||
return None
|
||||
|
||||
|
||||
def _build_process_env(command_parts: list[str], workdir: Path) -> tuple[dict[str, str], Path | None]:
|
||||
def _build_process_env(
|
||||
command_parts: list[str], workdir: Path
|
||||
) -> tuple[dict[str, str], Path | None]:
|
||||
env = os.environ.copy()
|
||||
env.pop("PYTHONHOME", None)
|
||||
|
||||
|
|
@ -115,14 +133,18 @@ class NanobotTUIProcess:
|
|||
self._master_fd: int | None = None
|
||||
self._read_task: asyncio.Task[None] | None = None
|
||||
self._pending_output = ""
|
||||
self._suppress_noisy_ui = os.getenv("NANOBOT_SUPPRESS_NOISY_UI", "1").strip() not in {
|
||||
self._suppress_noisy_ui = os.getenv(
|
||||
"NANOBOT_SUPPRESS_NOISY_UI", "1"
|
||||
).strip() not in {
|
||||
"0",
|
||||
"false",
|
||||
"False",
|
||||
"no",
|
||||
"off",
|
||||
}
|
||||
self._dedup_window_s = max(0.2, float(os.getenv("NANOBOT_OUTPUT_DEDUP_WINDOW_S", "1.5")))
|
||||
self._dedup_window_s = max(
|
||||
0.2, float(os.getenv("NANOBOT_OUTPUT_DEDUP_WINDOW_S", "1.5"))
|
||||
)
|
||||
self._recent_lines: deque[tuple[str, float]] = deque()
|
||||
self._last_tts_line = ""
|
||||
|
||||
|
|
@ -132,14 +154,19 @@ class NanobotTUIProcess:
|
|||
|
||||
async def start(self) -> None:
|
||||
if self.running:
|
||||
await self._bus.publish(WisperEvent(role="system", text="Nanobot TUI is already running."))
|
||||
await self._bus.publish(
|
||||
WisperEvent(role="system", text="Nanobot TUI is already running.")
|
||||
)
|
||||
return
|
||||
|
||||
command_parts = [
|
||||
os.path.expandvars(os.path.expanduser(part)) for part in shlex.split(self._command)
|
||||
os.path.expandvars(os.path.expanduser(part))
|
||||
for part in shlex.split(self._command)
|
||||
]
|
||||
if not command_parts:
|
||||
await self._bus.publish(WisperEvent(role="system", text="NANOBOT_COMMAND is empty."))
|
||||
await self._bus.publish(
|
||||
WisperEvent(role="system", text="NANOBOT_COMMAND is empty.")
|
||||
)
|
||||
return
|
||||
|
||||
if not self._workdir.exists():
|
||||
|
|
@ -152,7 +179,9 @@ class NanobotTUIProcess:
|
|||
return
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
child_env, child_venv_root = _build_process_env(command_parts=command_parts, workdir=self._workdir)
|
||||
child_env, child_venv_root = _build_process_env(
|
||||
command_parts=command_parts, workdir=self._workdir
|
||||
)
|
||||
try:
|
||||
self._process = subprocess.Popen(
|
||||
command_parts,
|
||||
|
|
@ -188,7 +217,9 @@ class NanobotTUIProcess:
|
|||
os.close(slave_fd)
|
||||
os.set_blocking(master_fd, False)
|
||||
self._master_fd = master_fd
|
||||
self._read_task = asyncio.create_task(self._read_output(), name="nanobot-tui-reader")
|
||||
self._read_task = asyncio.create_task(
|
||||
self._read_output(), name="nanobot-tui-reader"
|
||||
)
|
||||
await self._bus.publish(
|
||||
WisperEvent(
|
||||
role="system",
|
||||
|
|
@ -206,14 +237,18 @@ class NanobotTUIProcess:
|
|||
async def send(self, text: str) -> None:
|
||||
if not self.running or self._master_fd is None:
|
||||
await self._bus.publish(
|
||||
WisperEvent(role="system", text="Nanobot TUI is not running. Click spawn first.")
|
||||
WisperEvent(
|
||||
role="system", text="Nanobot TUI is not running. Click spawn first."
|
||||
)
|
||||
)
|
||||
return
|
||||
message = text.rstrip("\n") + "\n"
|
||||
try:
|
||||
os.write(self._master_fd, message.encode())
|
||||
except OSError as exc:
|
||||
await self._bus.publish(WisperEvent(role="system", text=f"Failed to write to TUI: {exc}"))
|
||||
await self._bus.publish(
|
||||
WisperEvent(role="system", text=f"Failed to write to TUI: {exc}")
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._read_task:
|
||||
|
|
@ -251,29 +286,40 @@ class NanobotTUIProcess:
|
|||
if self._master_fd is None:
|
||||
return
|
||||
while self.running:
|
||||
if not await self._wait_for_fd_readable():
|
||||
break
|
||||
try:
|
||||
chunk = os.read(self._master_fd, 4096)
|
||||
except BlockingIOError:
|
||||
await asyncio.sleep(0.05)
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
|
||||
if not chunk:
|
||||
await asyncio.sleep(0.05)
|
||||
if not self.running:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
continue
|
||||
|
||||
text = _clean_output(chunk.decode(errors="ignore"))
|
||||
if not text.strip():
|
||||
continue
|
||||
|
||||
displayable, tts_publishable = self._consume_output_chunk(text)
|
||||
displayable, tts_publishable, saw_thinking = self._consume_output_chunk(
|
||||
text
|
||||
)
|
||||
if saw_thinking:
|
||||
await self._bus.publish(
|
||||
WisperEvent(role="agent-state", text="thinking")
|
||||
)
|
||||
if displayable:
|
||||
await self._bus.publish(WisperEvent(role="nanobot", text=displayable))
|
||||
if tts_publishable:
|
||||
await self._bus.publish(WisperEvent(role="nanobot-tts", text=tts_publishable))
|
||||
await self._bus.publish(
|
||||
WisperEvent(role="nanobot-tts", text=tts_publishable)
|
||||
)
|
||||
|
||||
trailing_display, trailing_tts = self._consume_output_chunk("\n")
|
||||
trailing_display, trailing_tts, _ = self._consume_output_chunk("\n")
|
||||
if trailing_display:
|
||||
await self._bus.publish(WisperEvent(role="nanobot", text=trailing_display))
|
||||
if trailing_tts:
|
||||
|
|
@ -282,10 +328,13 @@ class NanobotTUIProcess:
|
|||
if self._process is not None:
|
||||
exit_code = self._process.poll()
|
||||
await self._bus.publish(
|
||||
WisperEvent(role="system", text=f"Nanobot TUI exited (code={exit_code}).")
|
||||
WisperEvent(
|
||||
role="system", text=f"Nanobot TUI exited (code={exit_code})."
|
||||
)
|
||||
)
|
||||
|
||||
def _consume_output_chunk(self, text: str) -> tuple[str, str]:
|
||||
def _consume_output_chunk(self, text: str) -> tuple[str, str, bool]:
|
||||
"""Return (displayable, tts_publishable, saw_thinking)."""
|
||||
self._pending_output += text
|
||||
|
||||
lines = self._pending_output.split("\n")
|
||||
|
|
@ -297,11 +346,16 @@ class NanobotTUIProcess:
|
|||
|
||||
kept_lines: list[str] = []
|
||||
tts_lines: list[str] = []
|
||||
saw_thinking = False
|
||||
for line in lines:
|
||||
normalized = self._normalize_line(line)
|
||||
if not normalized:
|
||||
continue
|
||||
if self._suppress_noisy_ui and self._is_noisy_ui_line(normalized):
|
||||
# Detect thinking lines even though they are filtered from display.
|
||||
candidate = LEADING_NON_WORD_RE.sub("", normalized)
|
||||
if THINKING_LINE_RE.search(candidate):
|
||||
saw_thinking = True
|
||||
continue
|
||||
if normalized != self._last_tts_line:
|
||||
tts_lines.append(normalized)
|
||||
|
|
@ -310,11 +364,16 @@ class NanobotTUIProcess:
|
|||
continue
|
||||
kept_lines.append(normalized)
|
||||
|
||||
return "\n".join(kept_lines).strip(), "\n".join(tts_lines).strip()
|
||||
return "\n".join(kept_lines).strip(), "\n".join(tts_lines).strip(), saw_thinking
|
||||
|
||||
def _normalize_line(self, line: str) -> str:
|
||||
without_emoji = EMOJI_RE.sub(" ", line)
|
||||
return re.sub(r"\s+", " ", without_emoji).strip()
|
||||
normalized = WHITESPACE_RE.sub(" ", without_emoji).strip()
|
||||
# Strip leading "nanobot:" prefix that the TUI echoes in its own output,
|
||||
# since the frontend already labels lines with the role name and TTS
|
||||
# should not read the agent's own name aloud.
|
||||
normalized = AGENT_OUTPUT_PREFIX_RE.sub("", normalized)
|
||||
return normalized
|
||||
|
||||
def _is_noisy_ui_line(self, line: str) -> bool:
|
||||
if SPINNER_ONLY_RE.fullmatch(line):
|
||||
|
|
@ -322,18 +381,47 @@ class NanobotTUIProcess:
|
|||
if BOX_DRAWING_ONLY_RE.fullmatch(line):
|
||||
return True
|
||||
|
||||
candidate = re.sub(r"^[^\w]+", "", line)
|
||||
if THINKING_LINE_RE.match(candidate):
|
||||
candidate = LEADING_NON_WORD_RE.sub("", line)
|
||||
if THINKING_LINE_RE.search(candidate):
|
||||
return True
|
||||
if TOOL_STREAM_LINE_RE.match(candidate):
|
||||
return True
|
||||
if USER_ECHO_LINE_RE.match(candidate):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _wait_for_fd_readable(self) -> bool:
|
||||
if self._master_fd is None:
|
||||
return False
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
ready: asyncio.Future[None] = loop.create_future()
|
||||
|
||||
def _mark_ready() -> None:
|
||||
if not ready.done():
|
||||
ready.set_result(None)
|
||||
|
||||
try:
|
||||
loop.add_reader(self._master_fd, _mark_ready)
|
||||
except (AttributeError, NotImplementedError, OSError, ValueError):
|
||||
await asyncio.sleep(0.01)
|
||||
return True
|
||||
|
||||
try:
|
||||
await ready
|
||||
return True
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
loop.remove_reader(self._master_fd)
|
||||
|
||||
def _is_recent_duplicate(self, line: str) -> bool:
|
||||
now = time.monotonic()
|
||||
normalized = line.lower()
|
||||
|
||||
while self._recent_lines and (now - self._recent_lines[0][1]) > self._dedup_window_s:
|
||||
while (
|
||||
self._recent_lines
|
||||
and (now - self._recent_lines[0][1]) > self._dedup_window_s
|
||||
):
|
||||
self._recent_lines.popleft()
|
||||
|
||||
for previous, _timestamp in self._recent_lines:
|
||||
|
|
@ -359,11 +447,15 @@ class SuperTonicGateway:
|
|||
async def spawn_tui(self) -> None:
|
||||
async with self._lock:
|
||||
if self._tui and self._tui.running:
|
||||
await self.bus.publish(WisperEvent(role="system", text="Nanobot TUI is already running."))
|
||||
await self.bus.publish(
|
||||
WisperEvent(role="system", text="Nanobot TUI is already running.")
|
||||
)
|
||||
return
|
||||
|
||||
command, workdir = _resolve_nanobot_command_and_workdir()
|
||||
self._tui = NanobotTUIProcess(bus=self.bus, command=command, workdir=workdir)
|
||||
self._tui = NanobotTUIProcess(
|
||||
bus=self.bus, command=command, workdir=workdir
|
||||
)
|
||||
await self._tui.start()
|
||||
|
||||
async def send_user_message(self, text: str) -> None:
|
||||
|
|
@ -374,7 +466,10 @@ class SuperTonicGateway:
|
|||
async with self._lock:
|
||||
if not self._tui:
|
||||
await self.bus.publish(
|
||||
WisperEvent(role="system", text="Nanobot TUI is not running. Click spawn first.")
|
||||
WisperEvent(
|
||||
role="system",
|
||||
text="Nanobot TUI is not running. Click spawn first.",
|
||||
)
|
||||
)
|
||||
return
|
||||
await self._tui.send(message)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue