bd307f3a11
* feat(gateway): graph gateway protocol * refactor(cli): wire gateway in cli/tui * refactor(gateway): centralize runtime gateway init * chore(gateway): restrict RunRequest message type * feat(gateway): add langgraph server gateway * chore(cli): tighten serve runtime state typing * refactor(cli): route async task state reads through graph gateway * refactor(gateway): support graph targets in server gateway * refactor(cli): route session commands through graph gateway * refactor(cli): fold thread store under graph gateway * refactor(gateway): route graph state access through gateway * refactor(channels): wire graph gateway * refactor(memory): preserve graph threads for cloning * feat(gateway): add thread cloning * fix(tui): pass effective workspace for thread creation * chore(memory): add workspare dir to memory worker metadata * fix(sessions): filter preloaded UUID registy entries by the current scope * test(fakes): use https * refactor(consumer): consolidate imports * fix(stream): optional summarization event * fix(gateway): resolve abbreviated thread IDs by search * fix(gateway): page server thread listings * fix(gateway): emit pending interrupt events * style: fmt * feat(gateway): persist workspace_dir & model in thread metadata * fix(gateway): page server thread prefix resolution * fix(gateway): expose server thread list metadata * refactor: add back type def * refactor: tighten types * revert: add back worker thread deletion The worker thread forking changes are out of scope for now, so to maintain parity with the existing behavior we'll leave this intact. * fix(gateway): apply compaction to server thread history * refactor(stream): restore direct summary replay suppression * fix(gateway): preserve compaction state and server stream output * fix(gateway): close local stream generator on cancellation --------- Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
1599 lines
67 KiB
Python
1599 lines
67 KiB
Python
"""Interactive CLI mode and single-shot execution."""
|
||
|
||
import asyncio
|
||
import logging
|
||
import queue
|
||
import random
|
||
import sys
|
||
import time
|
||
from collections.abc import Callable
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
import typer # type: ignore[import-untyped]
|
||
from prompt_toolkit import PromptSession # type: ignore[import-untyped]
|
||
from prompt_toolkit.auto_suggest import (
|
||
AutoSuggestFromHistory, # type: ignore[import-untyped]
|
||
)
|
||
from prompt_toolkit.completion import ( # type: ignore[import-untyped]
|
||
Completer,
|
||
Completion,
|
||
)
|
||
from prompt_toolkit.formatted_text import HTML # type: ignore[import-untyped]
|
||
from prompt_toolkit.history import FileHistory # type: ignore[import-untyped]
|
||
from prompt_toolkit.key_binding import KeyBindings # type: ignore[import-untyped]
|
||
from prompt_toolkit.patch_stdout import patch_stdout # type: ignore[import-untyped]
|
||
from prompt_toolkit.shortcuts import CompleteStyle # type: ignore[import-untyped]
|
||
from prompt_toolkit.styles import Style as PtStyle # type: ignore[import-untyped]
|
||
from rich.markdown import Markdown
|
||
from rich.markup import escape
|
||
from rich.panel import Panel
|
||
from rich.text import Text
|
||
|
||
import EvoScientist.cli.channel as _ch_mod
|
||
|
||
from ..commands.base import Command, CommandContext
|
||
from ..commands.manager import manager as cmd_manager
|
||
from ..gateway import (
|
||
GraphGateway,
|
||
GraphTarget,
|
||
RuntimeGateways,
|
||
create_runtime_gateways,
|
||
)
|
||
from ..sessions import get_checkpointer, short_thread_id
|
||
from ..stream.console import console
|
||
from ..stream.display import _fix_markdown_heading_spacing
|
||
from . import async_notifier
|
||
from ._agent_loader import BackgroundAgentLoader, MCPProgressTracker
|
||
from ._constants import (
|
||
DANGEROUS_BANNER_LABEL,
|
||
DANGEROUS_BANNER_MESSAGE,
|
||
LOGO_GRADIENT,
|
||
LOGO_LINES,
|
||
WELCOME_SLOGANS,
|
||
build_metadata,
|
||
)
|
||
from .agent import _create_session_workspace, _load_agent, _shorten_path
|
||
from .channel import (
|
||
ChannelMessage,
|
||
_auto_start_channel,
|
||
_channels_is_running,
|
||
_message_queue,
|
||
_set_channel_response,
|
||
dispatch_channel_slash_command,
|
||
)
|
||
from .file_mentions import complete_file_mention, resolve_file_mentions
|
||
from .rich_command_ui import RichCLICommandUI
|
||
from .status_bar import (
|
||
SPINNER_FRAMES,
|
||
STATUS_BAD,
|
||
STATUS_BAR_BG,
|
||
STATUS_CRITICAL,
|
||
STATUS_DIM,
|
||
STATUS_GOOD,
|
||
STATUS_STRONG,
|
||
STATUS_TEXT,
|
||
STATUS_WARN,
|
||
apply_assistant_text_to_snapshot,
|
||
apply_user_text_to_snapshot,
|
||
build_session_status_snapshot,
|
||
build_status_fragments,
|
||
build_status_text,
|
||
make_empty_status_snapshot,
|
||
make_usage_status_snapshot,
|
||
)
|
||
from .tui_interactive import run_textual_interactive
|
||
from .tui_runtime import resolve_ui_backend, run_streaming
|
||
|
||
_MEMORY_WORKER_SHUTDOWN_WAIT_SECONDS = 90.0
|
||
_MEMORY_WORKER_SHUTDOWN_POLL_SECONDS = 0.5
|
||
_MEMORY_WORKER_OUTPUT_GRACE_SECONDS = 3.0
|
||
|
||
_channel_logger = logging.getLogger(__name__)
|
||
|
||
# Keeps references to fire-and-forget coroutines so they aren't GC'd mid-flight.
|
||
_background_tasks: set[asyncio.Task] = set()
|
||
|
||
if TYPE_CHECKING:
|
||
from langgraph.graph.state import CompiledStateGraph
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class _StartupSession:
|
||
"""Resolved interactive startup session with a concrete active thread."""
|
||
|
||
thread_id: str
|
||
workspace_dir: str | None
|
||
resumed: bool
|
||
|
||
|
||
# =============================================================================
|
||
# Banner
|
||
# =============================================================================
|
||
|
||
|
||
def print_banner(
|
||
thread_id: str,
|
||
workspace_dir: str | None = None,
|
||
memory_dir: str | None = None,
|
||
mode: str | None = None,
|
||
model: str | None = None,
|
||
provider: str | None = None,
|
||
ui_backend: str | None = None,
|
||
):
|
||
"""Print welcome banner with ASCII art logo, info line, and hint."""
|
||
for line, color in zip(LOGO_LINES, LOGO_GRADIENT, strict=False):
|
||
console.print(Text(line, style=f"{color} bold"))
|
||
info = Text()
|
||
info.append(" ", style="dim")
|
||
parts: list[tuple[str, str]] = []
|
||
if model:
|
||
parts.append(("Model: ", model))
|
||
if provider:
|
||
parts.append(("Provider: ", provider))
|
||
if mode:
|
||
parts.append(("Mode: ", mode))
|
||
if ui_backend:
|
||
parts.append(("UI: ", ui_backend))
|
||
for i, (label, value) in enumerate(parts):
|
||
if i > 0:
|
||
info.append(" ", style="dim")
|
||
info.append(label, style="dim")
|
||
info.append(value, style="magenta")
|
||
# Directory line
|
||
import os
|
||
|
||
effective_dir = workspace_dir or os.getcwd()
|
||
home = os.path.expanduser("~")
|
||
dir_display = (
|
||
effective_dir.replace(home, "~", 1)
|
||
if effective_dir.startswith(home)
|
||
else effective_dir
|
||
)
|
||
info.append("\n ", style="dim")
|
||
info.append("Directory: ", style="dim")
|
||
info.append(dir_display, style="magenta")
|
||
_nl_key = "Option+Enter" if sys.platform == "darwin" else "Ctrl+J"
|
||
info.append("\n Enter ", style="#ffe082")
|
||
info.append("send", style="#ffe082 bold")
|
||
info.append(f" \u2022 {_nl_key} ", style="#ffe082")
|
||
info.append("newline", style="#ffe082 bold")
|
||
info.append(" \u2022 Type ", style="#ffe082")
|
||
info.append("/", style="#ffe082 bold")
|
||
info.append(" for commands", style="#ffe082")
|
||
info.append(" \u2022 ", style="#ffe082")
|
||
info.append("@ files", style="#ffe082 bold")
|
||
info.append(" \u2022 Ctrl+C ", style="#ffe082")
|
||
info.append("interrupt", style="#ffe082 bold")
|
||
console.print(info)
|
||
print_dangerous_warning()
|
||
|
||
|
||
def print_dangerous_warning() -> None:
|
||
"""Print an unmissable warning when dangerous (real-filesystem) mode is on."""
|
||
try:
|
||
from ..config import get_effective_config
|
||
|
||
if not get_effective_config().dangerous_mode:
|
||
return
|
||
except Exception:
|
||
return
|
||
warn = Text()
|
||
warn.append(f"\n \u26a0 {DANGEROUS_BANNER_LABEL}", style="bold white on red")
|
||
warn.append(f" {DANGEROUS_BANNER_MESSAGE}", style="bold red")
|
||
console.print(warn)
|
||
|
||
|
||
# =============================================================================
|
||
# Slash-command completer
|
||
# =============================================================================
|
||
|
||
_COMPLETION_STYLE = PtStyle.from_dict(
|
||
{
|
||
"completion-menu": "bg:default noreverse nounderline noitalic",
|
||
"completion-menu.completion": "bg:default #888888 noreverse",
|
||
"completion-menu.completion.current": "bg:default default bold noreverse",
|
||
"completion-menu.meta.completion": "bg:default #888888 noreverse",
|
||
"completion-menu.meta.completion.current": "bg:default default bold noreverse",
|
||
"scrollbar.background": "bg:default",
|
||
"scrollbar.button": "bg:default",
|
||
"status-bar": f"bg:{STATUS_BAR_BG} {STATUS_TEXT}",
|
||
"status-bar-strong": f"bg:{STATUS_BAR_BG} {STATUS_STRONG} bold",
|
||
"status-bar-dim": f"bg:{STATUS_BAR_BG} {STATUS_DIM}",
|
||
"status-bar-good": f"bg:{STATUS_BAR_BG} {STATUS_GOOD} bold",
|
||
"status-bar-warn": f"bg:{STATUS_BAR_BG} {STATUS_WARN} bold",
|
||
"status-bar-bad": f"bg:{STATUS_BAR_BG} {STATUS_BAD} bold",
|
||
"status-bar-critical": f"bg:{STATUS_BAR_BG} {STATUS_CRITICAL} bold",
|
||
}
|
||
)
|
||
|
||
|
||
class SlashCommandCompleter(Completer):
|
||
"""Autocomplete for slash commands and ``@file`` mentions.
|
||
|
||
``workspace_getter`` is invoked on every keystroke so ``@file``
|
||
suggestions automatically follow ``/new`` / ``/resume`` workspace
|
||
changes without having to poke the completer from the callbacks.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
workspace_getter: Callable[[], str | None] | None = None,
|
||
) -> None:
|
||
"""Initialise the completer.
|
||
|
||
Args:
|
||
workspace_getter: Callable returning the current workspace
|
||
directory for ``@file`` completions. Called on every
|
||
keystroke so suggestions stay in sync after ``/new``.
|
||
"""
|
||
self._workspace_getter = workspace_getter or (lambda: None)
|
||
|
||
def get_completions(self, document, complete_event):
|
||
"""Yield prompt_toolkit completions for slash commands and ``@file``."""
|
||
text = document.text_before_cursor
|
||
workspace_dir = self._workspace_getter()
|
||
|
||
# @file mention completion
|
||
if "@" in text:
|
||
candidates = complete_file_mention(text, workspace_dir)
|
||
if candidates:
|
||
# Replace from the last '@' token
|
||
import re as _re
|
||
|
||
m = _re.search(r"@[^\s]*$", text)
|
||
start = -len(m.group(0)) if m else 0
|
||
for path, type_hint in candidates:
|
||
yield Completion(path, start_position=start, display_meta=type_hint)
|
||
return
|
||
|
||
# Slash command completion
|
||
if not text.startswith("/"):
|
||
return
|
||
|
||
from ..commands._completion_engine import compute_completions
|
||
|
||
result = compute_completions(
|
||
document.text_before_cursor, len(document.text_before_cursor)
|
||
)
|
||
if result.kind == "empty" or not result.candidates:
|
||
return
|
||
|
||
# Sort alphabetically by completion text for stable, predictable
|
||
# ordering in the popup. The engine returns candidates in
|
||
# manager-registration order, which is not stable across changes.
|
||
for c in sorted(result.candidates, key=lambda c: c.text):
|
||
start_pos = c.replace_start - len(document.text_before_cursor)
|
||
yield Completion(
|
||
c.text, start_position=start_pos, display_meta=c.description
|
||
)
|
||
|
||
|
||
async def _resolve_startup_session(
|
||
requested_thread_id: str | None,
|
||
*,
|
||
workspace_dir: str | None,
|
||
graph_gateway: GraphGateway,
|
||
config: Any,
|
||
) -> _StartupSession:
|
||
"""Resolve/create the initial CLI session before shared REPL state exists."""
|
||
if not requested_thread_id:
|
||
return _StartupSession(
|
||
thread_id=await graph_gateway.create_thread(
|
||
GraphTarget(workspace_dir=workspace_dir)
|
||
),
|
||
workspace_dir=workspace_dir,
|
||
resumed=False,
|
||
)
|
||
|
||
resolution = await graph_gateway.resolve_thread(requested_thread_id)
|
||
if resolution.thread_id is None:
|
||
if resolution.matches:
|
||
console.print(
|
||
f"[yellow]Ambiguous thread ID '{escape(requested_thread_id)}'. "
|
||
"Matches:[/yellow]"
|
||
)
|
||
for match in resolution.matches:
|
||
console.print(f" [cyan]{match}[/cyan]")
|
||
else:
|
||
console.print(
|
||
f"[red]Thread '{escape(requested_thread_id)}' not found.[/red]"
|
||
)
|
||
return _StartupSession(
|
||
thread_id=await graph_gateway.create_thread(
|
||
GraphTarget(workspace_dir=workspace_dir)
|
||
),
|
||
workspace_dir=workspace_dir,
|
||
resumed=False,
|
||
)
|
||
|
||
resolved_thread_id = resolution.thread_id
|
||
metadata = await graph_gateway.get_thread_metadata(resolved_thread_id)
|
||
resolved_workspace = (metadata or {}).get("workspace_dir") or workspace_dir
|
||
if resolved_workspace:
|
||
from ..langgraph_dev.manager import WorkspaceMismatchError
|
||
from .commands import _sync_background_agent_server_workspace
|
||
|
||
try:
|
||
await _sync_background_agent_server_workspace(
|
||
config,
|
||
workspace_dir=resolved_workspace,
|
||
)
|
||
except WorkspaceMismatchError as exc:
|
||
console.print(f"[red]{exc}[/red]")
|
||
raise typer.Exit(1) from exc
|
||
|
||
return _StartupSession(
|
||
thread_id=resolved_thread_id,
|
||
workspace_dir=resolved_workspace,
|
||
resumed=True,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# Interactive & single-shot modes
|
||
# =============================================================================
|
||
|
||
|
||
def cmd_interactive(
|
||
show_thinking: bool = True,
|
||
channel_send_thinking: bool = True,
|
||
workspace_dir: str | None = None,
|
||
workspace_fixed: bool = False,
|
||
mode: str | None = None,
|
||
model: str | None = None,
|
||
provider: str | None = None,
|
||
run_name: str | None = None,
|
||
thread_id: str | None = None,
|
||
ui_backend: str = "cli",
|
||
config=None,
|
||
) -> None:
|
||
"""Interactive conversation mode with streaming output.
|
||
|
||
The persistent ``AsyncSqliteSaver`` checkpointer is opened here and
|
||
shared for the entire interactive session lifetime.
|
||
|
||
Args:
|
||
show_thinking: Whether to display thinking panels
|
||
channel_send_thinking: Whether channels should receive thinking messages
|
||
workspace_dir: Per-session workspace directory path
|
||
workspace_fixed: If True, /new keeps the same workspace directory
|
||
mode: Workspace mode ('daemon' or 'run'), displayed in banner
|
||
model: Model name to display in banner
|
||
provider: LLM provider name to display in banner
|
||
run_name: Optional run name for /new session deduplication
|
||
thread_id: Optional thread ID to resume a previous session
|
||
ui_backend: UI backend ('cli' or 'tui')
|
||
"""
|
||
import nest_asyncio
|
||
|
||
nest_asyncio.apply()
|
||
|
||
resolved_ui_backend = resolve_ui_backend(ui_backend, warn_fallback=True)
|
||
if resolved_ui_backend == "tui":
|
||
from functools import partial
|
||
|
||
load_agent = partial(_load_agent, config=config)
|
||
run_textual_interactive(
|
||
show_thinking=show_thinking,
|
||
channel_send_thinking=channel_send_thinking,
|
||
workspace_dir=workspace_dir,
|
||
workspace_fixed=workspace_fixed,
|
||
mode=mode,
|
||
model=model,
|
||
provider=provider,
|
||
run_name=run_name,
|
||
thread_id=thread_id,
|
||
load_agent=load_agent,
|
||
create_session_workspace=_create_session_workspace,
|
||
config=config,
|
||
)
|
||
return
|
||
|
||
from .. import paths
|
||
|
||
memory_dir = str(paths.MEMORIES_DIR)
|
||
|
||
paths.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
history_file = str(paths.DATA_DIR / "history")
|
||
|
||
# Key bindings: Enter submits, Alt+Enter (Option+Enter) inserts newline
|
||
_kb = KeyBindings()
|
||
|
||
@_kb.add("escape", "enter") # Alt+Enter / Option+Enter on macOS
|
||
def _insert_newline(event):
|
||
event.current_buffer.insert_text("\n")
|
||
|
||
@_kb.add("enter")
|
||
def _submit(event):
|
||
event.current_buffer.validate_and_handle()
|
||
|
||
session = PromptSession(
|
||
history=FileHistory(history_file),
|
||
auto_suggest=AutoSuggestFromHistory(),
|
||
completer=SlashCommandCompleter(
|
||
workspace_getter=lambda: state["workspace_dir"],
|
||
),
|
||
complete_style=CompleteStyle.COLUMN,
|
||
complete_while_typing=True,
|
||
style=_COMPLETION_STYLE,
|
||
multiline=True,
|
||
key_bindings=_kb,
|
||
)
|
||
|
||
def _print_separator():
|
||
"""Print a horizontal separator line spanning the terminal width."""
|
||
width = console.size.width
|
||
console.print(Text("\u2500" * width, style="dim"))
|
||
|
||
from ..commands.base import ChannelRuntime
|
||
|
||
channel_runtime = ChannelRuntime()
|
||
|
||
progress_tracker = MCPProgressTracker()
|
||
|
||
def _on_mcp_progress(event: str, server: str, detail: str) -> None:
|
||
"""Record progress + print the inline ✓/✗ line.
|
||
|
||
Runs on the MCP worker thread; ``console.print`` while the main
|
||
loop is inside ``patch_stdout`` lands above the prompt safely.
|
||
"""
|
||
new_state = progress_tracker.record(event, server, detail)
|
||
if new_state == "ok":
|
||
console.print(
|
||
f"[green]\u2713[/green] [dim]MCP[/dim] [bold]{server}[/bold] "
|
||
f"[dim]({detail} tools)[/dim]"
|
||
)
|
||
elif new_state == "error":
|
||
console.print(
|
||
f"[red]\u2717[/red] [dim]MCP[/dim] [bold]{server}[/bold] "
|
||
f"[red]failed:[/red] {escape(detail)}"
|
||
)
|
||
|
||
agent_loader = BackgroundAgentLoader(
|
||
_load_agent,
|
||
on_progress=_on_mcp_progress,
|
||
)
|
||
|
||
runtime_gateways = create_runtime_gateways()
|
||
graph_gateway = runtime_gateways.graph_gateway
|
||
requested_thread_id = thread_id
|
||
|
||
# Mutable state for async loop
|
||
state: dict[str, Any] = {
|
||
"workspace_dir": workspace_dir,
|
||
"running": True,
|
||
"resumed": False,
|
||
"ui_backend": resolved_ui_backend,
|
||
"status_started_at": datetime.now(),
|
||
"status_base_snapshot": make_empty_status_snapshot(model),
|
||
"status_snapshot": make_empty_status_snapshot(model),
|
||
"status_streaming_text": "",
|
||
"status_last_input_tokens": None,
|
||
}
|
||
|
||
def _on_status_after_compact(input_tokens: int) -> None:
|
||
"""Mirror inline /compact post-update: refresh both fields so the
|
||
next status render reflects the reduced context immediately.
|
||
``_refresh_status_snapshot`` is invoked by the dispatch block once
|
||
the command finishes (since it's async)."""
|
||
state["status_last_input_tokens"] = input_tokens
|
||
state["status_base_snapshot"] = make_usage_status_snapshot(
|
||
input_tokens,
|
||
model_name=model,
|
||
)
|
||
|
||
# ``rich_ui`` is constructed inside ``_async_main_loop`` so the
|
||
# lifecycle callbacks can close over ``checkpointer`` from
|
||
# ``get_checkpointer()``.
|
||
|
||
def _start_agent_load(checkpointer) -> None:
|
||
progress_tracker.prime()
|
||
agent_loader.start(
|
||
workspace_dir=state["workspace_dir"],
|
||
checkpointer=checkpointer,
|
||
config=config,
|
||
)
|
||
|
||
async def _await_agent_ready() -> "CompiledStateGraph":
|
||
"""Await the agent load and apply CLI-side post-load side effects.
|
||
|
||
Raises when called before ``_start_agent_load``: reloading here
|
||
would drop the SQLite checkpointer and silently lose persistence.
|
||
"""
|
||
try:
|
||
agent = await agent_loader.await_ready()
|
||
except RuntimeError as exc:
|
||
if "before start()" in str(exc):
|
||
raise RuntimeError(
|
||
"_await_agent_ready called before _start_agent_load — "
|
||
"the checkpointer reference is not available here."
|
||
) from exc
|
||
raise
|
||
await _refresh_status_snapshot(reset_streaming_text=True)
|
||
if _channels_is_running():
|
||
channel_runtime.bind(agent, state["thread_id"])
|
||
return agent
|
||
|
||
def _rebuild_status_snapshot() -> None:
|
||
"""Compose the visible snapshot from thread state + live output."""
|
||
state["status_snapshot"] = apply_assistant_text_to_snapshot(
|
||
state["status_base_snapshot"],
|
||
state["status_streaming_text"],
|
||
)
|
||
|
||
def _set_status_streaming_text(text: str | None) -> None:
|
||
"""Update the in-flight assistant overlay used by the status bar."""
|
||
new_text = text or ""
|
||
if new_text == state["status_streaming_text"]:
|
||
return
|
||
state["status_streaming_text"] = new_text
|
||
_rebuild_status_snapshot()
|
||
|
||
async def _refresh_status_snapshot(
|
||
pending_user_text: str | None = None,
|
||
*,
|
||
reset_streaming_text: bool = True,
|
||
) -> None:
|
||
"""Recompute the persistent status-bar snapshot for the active thread."""
|
||
pending = (pending_user_text or "").strip()
|
||
if pending:
|
||
if state["status_last_input_tokens"] is not None:
|
||
state["status_base_snapshot"] = apply_user_text_to_snapshot(
|
||
make_usage_status_snapshot(
|
||
state["status_last_input_tokens"],
|
||
model_name=model,
|
||
),
|
||
pending,
|
||
)
|
||
else:
|
||
state["status_base_snapshot"] = await build_session_status_snapshot(
|
||
state["thread_id"],
|
||
model_name=model,
|
||
pending_user_text=pending,
|
||
graph_gateway=graph_gateway,
|
||
)
|
||
elif state["status_last_input_tokens"] is not None:
|
||
state["status_base_snapshot"] = make_usage_status_snapshot(
|
||
state["status_last_input_tokens"],
|
||
model_name=model,
|
||
)
|
||
else:
|
||
state["status_base_snapshot"] = await build_session_status_snapshot(
|
||
state["thread_id"],
|
||
model_name=model,
|
||
graph_gateway=graph_gateway,
|
||
)
|
||
if reset_streaming_text:
|
||
state["status_streaming_text"] = ""
|
||
_rebuild_status_snapshot()
|
||
|
||
def _bottom_toolbar():
|
||
"""Render the persistent bottom status bar for prompt_toolkit."""
|
||
try:
|
||
from prompt_toolkit.application import get_app
|
||
|
||
width = get_app().output.get_size().columns
|
||
except Exception:
|
||
width = console.size.width
|
||
fragments = build_status_fragments(
|
||
state["status_snapshot"],
|
||
state["status_started_at"],
|
||
width,
|
||
)
|
||
if agent_loader.is_pending:
|
||
# Per-server ✓/✗ lines are printed above the prompt by
|
||
# `_on_mcp_progress`; this just shows the animated summary.
|
||
done, total = progress_tracker.totals()
|
||
frame = SPINNER_FRAMES[
|
||
int(datetime.now().timestamp() * 10) % len(SPINNER_FRAMES)
|
||
]
|
||
label = (
|
||
f"{frame} Loading MCP tools {done}/{total} "
|
||
if total and width >= 60
|
||
else f"{frame} Loading MCP tools "
|
||
)
|
||
fragments = [
|
||
("class:status-bar-warn", label),
|
||
("class:status-bar-dim", "│ "),
|
||
*fragments,
|
||
]
|
||
return fragments
|
||
|
||
def _stream_status_footer():
|
||
"""Render the live Rich footer used during streaming output."""
|
||
return build_status_text(
|
||
state["status_snapshot"],
|
||
state["status_started_at"],
|
||
console.size.width,
|
||
)
|
||
|
||
async def _handle_stream_status_event(event_type: str, stream_state) -> None:
|
||
"""Keep the CLI status bar aligned with live stream progress."""
|
||
if event_type == "usage_stats":
|
||
last_input_tokens = getattr(stream_state, "last_input_tokens", 0)
|
||
if last_input_tokens > 0:
|
||
state["status_last_input_tokens"] = last_input_tokens
|
||
state["status_base_snapshot"] = make_usage_status_snapshot(
|
||
last_input_tokens,
|
||
model_name=model,
|
||
)
|
||
_rebuild_status_snapshot()
|
||
elif event_type == "text":
|
||
_set_status_streaming_text(stream_state.response_text)
|
||
elif event_type in ("done", "error"):
|
||
_set_status_streaming_text("")
|
||
|
||
async def _render_history(thread_id: str):
|
||
"""Display conversation history for a resumed session."""
|
||
messages = await graph_gateway.get_thread_messages(thread_id)
|
||
if not messages:
|
||
return
|
||
|
||
HISTORY_WINDOW = 50
|
||
|
||
# Only human and ai messages; skip tool/system
|
||
display = [m for m in messages if getattr(m, "type", None) in ("human", "ai")]
|
||
|
||
if len(display) > HISTORY_WINDOW:
|
||
skipped = len(display) - HISTORY_WINDOW
|
||
display = display[-HISTORY_WINDOW:]
|
||
console.print(f"[dim]── ... {skipped} earlier messages ──[/dim]")
|
||
else:
|
||
console.print("[dim]── Conversation history ──[/dim]")
|
||
|
||
for msg in display:
|
||
msg_type = getattr(msg, "type", None)
|
||
content = getattr(msg, "content", "") or ""
|
||
|
||
if msg_type == "human":
|
||
# Extract text from multimodal list
|
||
if isinstance(content, list):
|
||
parts = [
|
||
b.get("text", "")
|
||
for b in content
|
||
if isinstance(b, dict) and b.get("type") == "text"
|
||
]
|
||
content = " ".join(parts) if parts else ""
|
||
content = content.strip()
|
||
if content:
|
||
console.print(
|
||
Text.assemble(("\u276f ", "bold blue"), (content, ""))
|
||
)
|
||
|
||
elif msg_type == "ai":
|
||
thinking_text = ""
|
||
text_content = ""
|
||
|
||
if isinstance(content, list):
|
||
for block in content:
|
||
if not isinstance(block, dict):
|
||
continue
|
||
if block.get("type") == "thinking":
|
||
thinking_text += block.get("thinking", "")
|
||
elif block.get("type") == "text":
|
||
text_content += block.get("text", "")
|
||
else:
|
||
text_content = content or ""
|
||
|
||
text_content = text_content.strip()
|
||
|
||
# Thinking panel (only when show_thinking is enabled)
|
||
if thinking_text.strip() and show_thinking:
|
||
console.print(
|
||
Panel(
|
||
thinking_text.strip(),
|
||
title="[bold blue]\U0001f4ad Thinking[/bold blue]",
|
||
border_style="blue",
|
||
expand=False,
|
||
)
|
||
)
|
||
|
||
# AI response — full Markdown rendering
|
||
if text_content:
|
||
console.print(Markdown(_fix_markdown_heading_spacing(text_content)))
|
||
|
||
# Skip tool messages — verbose and not useful in replay
|
||
|
||
console.print("[dim]── End of history ──[/dim]")
|
||
console.print()
|
||
|
||
async def _async_main_loop():
|
||
"""Async main loop with prompt_async and channel queue checking."""
|
||
nonlocal model
|
||
async with get_checkpointer() as checkpointer:
|
||
startup = await _resolve_startup_session(
|
||
requested_thread_id,
|
||
workspace_dir=state["workspace_dir"],
|
||
graph_gateway=graph_gateway,
|
||
config=config,
|
||
)
|
||
state["thread_id"] = startup.thread_id
|
||
state["workspace_dir"] = startup.workspace_dir
|
||
state["resumed"] = startup.resumed
|
||
if startup.resumed:
|
||
state["status_started_at"] = datetime.now()
|
||
state["status_last_input_tokens"] = None
|
||
# Lifecycle callbacks (new / resume) need ``checkpointer``
|
||
# in scope — define the ``rich_ui`` adapter here rather than
|
||
# at the outer function level.
|
||
|
||
async def _on_start_new_session() -> None:
|
||
"""NewCommand callback — rotate workspace (if not fixed),
|
||
issue a new thread id, reset session-scoped status fields,
|
||
and kick off background agent reload. The dispatch block
|
||
refreshes the status bar post-execute (symmetric with
|
||
/compact)."""
|
||
_ch_mod.forget_channel_origin(state.get("thread_id"))
|
||
if not workspace_fixed:
|
||
state["workspace_dir"] = _create_session_workspace(run_name)
|
||
state["thread_id"] = await graph_gateway.create_thread(
|
||
GraphTarget(workspace_dir=state["workspace_dir"])
|
||
)
|
||
state["resumed"] = False
|
||
state["status_started_at"] = datetime.now()
|
||
state["status_last_input_tokens"] = None
|
||
_start_agent_load(checkpointer)
|
||
console.print(
|
||
f"[green]New session:[/green] [yellow]{state['thread_id']}[/yellow]"
|
||
)
|
||
if state["workspace_dir"]:
|
||
console.print(
|
||
f"[dim]Workspace:[/dim] [cyan]"
|
||
f"{_shorten_path(state['workspace_dir'])}[/cyan]\n"
|
||
)
|
||
|
||
async def _on_handle_session_resume(
|
||
thread_id: str, workspace_dir: str | None
|
||
) -> None:
|
||
"""ResumeCommand callback — after the command resolves
|
||
the thread id + restores workspace from metadata, this
|
||
callback mutates REPL state, reloads the agent, and
|
||
renders conversation history."""
|
||
if workspace_dir:
|
||
# Sync the langgraph dev subprocess to the resumed
|
||
# workspace so background workers and deployed sub-agents
|
||
# don't operate on the previous workspace's files. The
|
||
# manager auto-detects the change and restarts when needed.
|
||
# Restart can take 10-15s — show a spinner so the user
|
||
# doesn't think the CLI is frozen, and run the sync call
|
||
# in a worker thread so the asyncio event loop keeps
|
||
# serving channel polls / MCP heartbeats during the wait.
|
||
#
|
||
# State mutation happens AFTER this sync succeeds so a
|
||
# WorkspaceMismatchError leaves the session's existing
|
||
# workspace_dir / thread_id untouched.
|
||
from ..langgraph_dev.manager import WorkspaceMismatchError
|
||
from .commands import _sync_background_agent_server_workspace
|
||
|
||
try:
|
||
await _sync_background_agent_server_workspace(
|
||
config,
|
||
workspace_dir=workspace_dir,
|
||
)
|
||
except WorkspaceMismatchError as exc:
|
||
# Another EvoSci process owns the langgraph dev
|
||
# server for a different workspace. Abort the
|
||
# resume without mutating session state. Raise so
|
||
# command UIs, including channel UI, report failure
|
||
# instead of continuing with success/history output.
|
||
raise RuntimeError(str(exc)) from exc
|
||
state["workspace_dir"] = workspace_dir
|
||
if thread_id != state.get("thread_id"):
|
||
# Only drop the origin on a real thread change — resuming
|
||
# the already-active thread must keep its live origin so a
|
||
# later async-notifier turn still forwards to the channel.
|
||
_ch_mod.forget_channel_origin(state.get("thread_id"))
|
||
state["thread_id"] = thread_id
|
||
state["resumed"] = True
|
||
state["status_started_at"] = datetime.now()
|
||
state["status_last_input_tokens"] = None
|
||
_start_agent_load(checkpointer)
|
||
await _refresh_status_snapshot(reset_streaming_text=True)
|
||
console.print(
|
||
f"[green]Resumed session:[/green] [yellow]{thread_id}[/yellow]"
|
||
)
|
||
if state["workspace_dir"]:
|
||
console.print(
|
||
f"[dim]Workspace:[/dim] [cyan]"
|
||
f"{_shorten_path(state['workspace_dir'])}[/cyan]"
|
||
)
|
||
console.print()
|
||
await _render_history(thread_id)
|
||
|
||
# Rich CLI collapses ``request_quit`` / ``force_quit`` into the
|
||
# same "break the prompt loop" effect — there's no equivalent
|
||
# of the TUI's double-press-to-confirm quit distinction. A
|
||
# shared ``_stop`` helper makes the intentional symmetry
|
||
# explicit instead of silently duplicating a lambda.
|
||
def _stop() -> None:
|
||
state["running"] = False
|
||
|
||
rich_ui = RichCLICommandUI(
|
||
console,
|
||
on_request_quit=_stop,
|
||
on_force_quit=_stop,
|
||
on_clear_chat=lambda: console.clear(),
|
||
on_status_after_compact=_on_status_after_compact,
|
||
on_start_new_session=_on_start_new_session,
|
||
on_handle_session_resume=_on_handle_session_resume,
|
||
)
|
||
|
||
# Kick off agent construction (MCP tool enumeration is the
|
||
# slow part) in the background so the banner and prompt can
|
||
# appear immediately. The status bar shows a spinner while
|
||
# this is in flight; submitting a message awaits the result.
|
||
_start_agent_load(checkpointer)
|
||
await _refresh_status_snapshot(reset_streaming_text=True)
|
||
|
||
# Print banner
|
||
if state["resumed"]:
|
||
print_banner(
|
||
state["thread_id"],
|
||
state["workspace_dir"],
|
||
memory_dir,
|
||
mode,
|
||
model,
|
||
provider,
|
||
state["ui_backend"],
|
||
)
|
||
console.print(
|
||
f"[green]Resumed session [yellow]{state['thread_id']}[/yellow][/green]\n"
|
||
)
|
||
await _render_history(state["thread_id"])
|
||
else:
|
||
print_banner(
|
||
state["thread_id"],
|
||
state["workspace_dir"],
|
||
memory_dir,
|
||
mode,
|
||
model,
|
||
provider,
|
||
state["ui_backend"],
|
||
)
|
||
|
||
# ---- Channel queue processing (bus → main thread) ----
|
||
|
||
async def _process_channel_message(msg: ChannelMessage) -> None:
|
||
"""Process a single channel message with real-time streaming.
|
||
|
||
Clears the waiting prompt line and reprints the message as if
|
||
the user typed it after ❯, then streams the agent response
|
||
with Rich Live display.
|
||
|
||
Display:
|
||
❯ message content
|
||
[channel: Received from sender]
|
||
─────────────────
|
||
(real-time streaming output)
|
||
[channel: Replied to sender]
|
||
─────────────────
|
||
"""
|
||
if not _ch_mod._claim_or_complete_channel_request(msg):
|
||
return
|
||
|
||
_ch_mod.remember_channel_origin(state["thread_id"], msg)
|
||
|
||
try:
|
||
# Clear the waiting ❯ prompt line
|
||
sys.stdout.write("\r\033[2K")
|
||
sys.stdout.flush()
|
||
|
||
# Reprint as if user typed it after ❯
|
||
prompt_line = Text()
|
||
prompt_line.append("\u276f ", style="bold blue")
|
||
prompt_line.append(msg.content)
|
||
console.print(prompt_line)
|
||
rx = Text()
|
||
rx.append(f"[{msg.channel_type}: Received from ", style="dim")
|
||
rx.append(msg.sender, style="cyan")
|
||
rx.append("]", style="dim")
|
||
console.print(rx)
|
||
_print_separator()
|
||
|
||
def _send_to_channel(coro, label: str, timeout: int = 15) -> None:
|
||
"""Schedule an async channel send on the bus loop."""
|
||
loop = _ch_mod._bus_loop
|
||
if not loop:
|
||
return
|
||
try:
|
||
asyncio.run_coroutine_threadsafe(coro, loop).result(
|
||
timeout=timeout
|
||
)
|
||
except Exception as e:
|
||
_channel_logger.debug(f"{label} send failed: {e}")
|
||
|
||
def _send_thinking_to_channel(thinking: str) -> None:
|
||
ch = msg.channel_ref
|
||
if ch and ch.send_thinking:
|
||
_send_to_channel(
|
||
ch.send_thinking_message(
|
||
sender=msg.chat_id,
|
||
thinking=thinking,
|
||
metadata=msg.metadata,
|
||
),
|
||
"Thinking",
|
||
)
|
||
|
||
def _send_todo_to_channel(items: list[dict]) -> None:
|
||
from ..channels.consumer import _format_todo_list
|
||
|
||
if msg.channel_ref:
|
||
_send_to_channel(
|
||
msg.channel_ref.send_todo_message(
|
||
sender=msg.chat_id,
|
||
content=_format_todo_list(items),
|
||
metadata=msg.metadata,
|
||
),
|
||
"Todo",
|
||
)
|
||
|
||
def _send_media_to_channel(file_path: str) -> None:
|
||
if msg.channel_ref:
|
||
_send_to_channel(
|
||
msg.channel_ref.send_media(
|
||
recipient=msg.chat_id,
|
||
file_path=file_path,
|
||
metadata=msg.metadata,
|
||
),
|
||
"Media",
|
||
timeout=30,
|
||
)
|
||
|
||
def _channel_hitl_prompt(
|
||
action_requests: list,
|
||
) -> list[dict] | None:
|
||
"""Send HITL approval prompt to channel user and wait for reply."""
|
||
return _ch_mod.channel_hitl_prompt(action_requests, msg)
|
||
|
||
def _channel_ask_user(ask_user_data: dict) -> dict:
|
||
"""Send ask_user questions to channel user and wait for reply."""
|
||
return _ch_mod.channel_ask_user_prompt(ask_user_data, msg)
|
||
|
||
# ---- Slash command dispatch (cmd_manager, not the agent) ----
|
||
# Mirrors the TUI's behavior so ``/evoskills``, ``/mcp list``
|
||
# etc. sent via iMessage actually execute instead of being
|
||
# fed to the LLM as a plain prompt.
|
||
async def _on_channel_cmd_completed(
|
||
ctx: CommandContext, original_agent: Any, cmd: Command
|
||
) -> None:
|
||
"""Mirror the REPL adoption block at
|
||
``interactive.py:1005-1030`` so ``/model`` and similar
|
||
state-mutating commands invoked via a channel actually
|
||
rebind the running session and keep the status bar
|
||
in sync."""
|
||
nonlocal model
|
||
agent_swapped = (
|
||
ctx.agent is not None and ctx.agent is not original_agent
|
||
)
|
||
if agent_swapped:
|
||
from ..EvoScientist import _ensure_config
|
||
|
||
agent_loader.adopt(ctx.agent)
|
||
cfg = _ensure_config()
|
||
model = cfg.model
|
||
state["status_base_snapshot"] = make_empty_status_snapshot(
|
||
model
|
||
)
|
||
|
||
# Rebind the runtime whenever the agent OR
|
||
# thread_id may have moved — ``/new`` and
|
||
# ``/resume`` rotate ``state["thread_id"]``
|
||
# without swapping the agent, and the bus
|
||
# expects both to stay in sync (matches the
|
||
# serve-mode hook contract).
|
||
if _channels_is_running():
|
||
runtime_agent = (
|
||
ctx.agent
|
||
if ctx.agent is not None
|
||
else agent_loader.agent
|
||
)
|
||
if runtime_agent is not None:
|
||
channel_runtime.bind(runtime_agent, state["thread_id"])
|
||
|
||
# ``/new`` rotates ``state["thread_id"]`` / workspace,
|
||
# ``/compact`` reduces token usage — both need the
|
||
# status snapshot re-rendered even when the agent
|
||
# didn't swap. ``/resume`` refreshes inline in its
|
||
# own async callback.
|
||
if agent_swapped or cmd.name in (
|
||
"/compact",
|
||
"/new",
|
||
):
|
||
await _refresh_status_snapshot(reset_streaming_text=True)
|
||
|
||
_slash_handled = await dispatch_channel_slash_command(
|
||
msg,
|
||
agent=agent_loader.agent,
|
||
thread_id=state["thread_id"],
|
||
workspace_dir=state["workspace_dir"],
|
||
checkpointer=checkpointer,
|
||
append_system=lambda t, s="dim": console.print(t, style=s),
|
||
start_new_session_cb=_on_start_new_session,
|
||
handle_session_resume_cb=_on_handle_session_resume,
|
||
await_agent_ready=_await_agent_ready,
|
||
on_cmd_completed=_on_channel_cmd_completed,
|
||
channel_runtime=channel_runtime,
|
||
graph_gateway=runtime_gateways.graph_gateway,
|
||
)
|
||
if _slash_handled:
|
||
# A channel-issued /new or /resume rotates the thread
|
||
# inside the dispatch above; re-bind the now-current
|
||
# thread to this channel so async-notifier turns on it
|
||
# still forward back here.
|
||
_ch_mod.remember_channel_origin(state["thread_id"], msg)
|
||
_print_separator()
|
||
sys.stdout.write("\033[34;1m❯\033[0m ")
|
||
sys.stdout.flush()
|
||
return
|
||
|
||
try:
|
||
ready_agent = await _await_agent_ready()
|
||
meta = build_metadata(state["workspace_dir"], model)
|
||
await _refresh_status_snapshot(
|
||
msg.content, reset_streaming_text=True
|
||
)
|
||
response = run_streaming(
|
||
ui_backend=state["ui_backend"],
|
||
agent=ready_agent,
|
||
message=msg.content,
|
||
thread_id=state["thread_id"],
|
||
show_thinking=show_thinking,
|
||
interactive=True,
|
||
metadata=meta,
|
||
on_thinking=_send_thinking_to_channel,
|
||
on_todo=_send_todo_to_channel,
|
||
on_file_write=_send_media_to_channel,
|
||
hitl_prompt_fn=_channel_hitl_prompt,
|
||
ask_user_prompt_fn=_channel_ask_user,
|
||
on_stream_event=_handle_stream_status_event,
|
||
status_footer_builder=_stream_status_footer,
|
||
cancel_scope=_ch_mod._channel_message_cancel_scope(msg),
|
||
gateway=runtime_gateways.graph_gateway,
|
||
)
|
||
except Exception as e:
|
||
response = f"Error: {e}"
|
||
console.print(f"[red]Channel error: {e}[/red]")
|
||
|
||
_set_channel_response(msg.msg_id, response)
|
||
await _refresh_status_snapshot(reset_streaming_text=True)
|
||
|
||
tx = Text()
|
||
tx.append(f"[{msg.channel_type}: Replied to ", style="dim")
|
||
tx.append(msg.sender, style="cyan")
|
||
tx.append("]", style="dim")
|
||
console.print(tx)
|
||
_print_separator()
|
||
|
||
# Redraw the ❯ prompt on a new line after separator
|
||
sys.stdout.write("\033[34;1m\u276f\033[0m ")
|
||
sys.stdout.flush()
|
||
finally:
|
||
_ch_mod._complete_channel_request(msg.msg_id)
|
||
|
||
async def _inject_notification_message(
|
||
text: str,
|
||
notifs: list,
|
||
*,
|
||
target_thread_id: str | None,
|
||
) -> None:
|
||
"""Inject a batched async-task notification as a synthetic user message.
|
||
|
||
Renders one compact tool-result-style line per task (matching the
|
||
TaskList spinner aesthetic) for the human operator. The LLM
|
||
receives the full structured ``text`` from ``format_batch_message``
|
||
unchanged — only the screen visual is simplified.
|
||
"""
|
||
from EvoScientist.cli.async_notifier import format_notification_lines
|
||
|
||
for line_text, line_style in format_notification_lines(notifs):
|
||
console.print(line_text, style=line_style, markup=False)
|
||
meta = build_metadata(state["workspace_dir"], model)
|
||
await _refresh_status_snapshot(text, reset_streaming_text=True)
|
||
ready_agent = await _await_agent_ready()
|
||
response = run_streaming(
|
||
ui_backend=state["ui_backend"],
|
||
agent=ready_agent,
|
||
message=text,
|
||
# Falls back to live state["thread_id"] if no override is
|
||
# passed (legacy / direct-call paths). Dedup reader has no
|
||
# fallback and returns {} for a falsey id; the asymmetry
|
||
# is intentional — we'd rather inject into the live thread
|
||
# than drop the notification entirely.
|
||
thread_id=target_thread_id or state["thread_id"],
|
||
show_thinking=show_thinking,
|
||
interactive=True,
|
||
metadata=meta,
|
||
on_stream_event=_handle_stream_status_event,
|
||
status_footer_builder=_stream_status_footer,
|
||
gateway=runtime_gateways.graph_gateway,
|
||
)
|
||
_notif_tid = target_thread_id or state["thread_id"]
|
||
if _ch_mod.publish_to_channel_origin(_notif_tid, response):
|
||
# Forwarded to a channel — print the same closing
|
||
# "Replied to" line a normal channel turn shows, so the
|
||
# forwarded block reads as terminated on screen.
|
||
_origin = _ch_mod.get_channel_origin(_notif_tid)
|
||
if _origin is not None:
|
||
tx = Text()
|
||
tx.append(f"[{_origin.channel_type}: Replied to ", style="dim")
|
||
tx.append(_origin.sender or _origin.chat_id, style="cyan")
|
||
tx.append("]", style="dim")
|
||
console.print(tx)
|
||
await _refresh_status_snapshot(reset_streaming_text=True)
|
||
console.print()
|
||
_print_separator()
|
||
sys.stdout.write("\033[34;1m❯\033[0m ")
|
||
sys.stdout.flush()
|
||
|
||
async def _empty_async_tasks() -> async_notifier.AsyncTasksState:
|
||
return {}
|
||
|
||
async def _read_current_async_tasks(
|
||
target_thread_id: str,
|
||
) -> async_notifier.AsyncTasksState:
|
||
"""Snapshot async_tasks from the active agent state for dedup.
|
||
|
||
Uses ``agent_loader.agent`` (the currently loaded agent) and
|
||
``target_thread_id`` (the thread id captured at the start of
|
||
``consume_notifications`` — frozen so a mid-consume ``/new``
|
||
cannot make us read the wrong thread's state).
|
||
"""
|
||
agent = agent_loader.agent
|
||
if agent is None:
|
||
return {}
|
||
try:
|
||
return await async_notifier.read_async_tasks_from_gateway(
|
||
runtime_gateways.graph_gateway,
|
||
GraphTarget(
|
||
local_graph=agent,
|
||
workspace_dir=state["workspace_dir"],
|
||
),
|
||
target_thread_id,
|
||
)
|
||
except Exception:
|
||
return {}
|
||
|
||
async def _check_channel_queue() -> None:
|
||
"""Poll the channel + notification queues and dispatch."""
|
||
while True:
|
||
try:
|
||
msg = _message_queue.get_nowait()
|
||
except queue.Empty:
|
||
msg = None
|
||
if msg is not None:
|
||
await _process_channel_message(msg)
|
||
continue # check queues again immediately
|
||
|
||
# Notification path (only when no channel message was pending).
|
||
# Wrap in try/except so an exception in dedup/inject can't
|
||
# kill the poller task — channel + notification dispatch
|
||
# would silently die otherwise (Fix #4).
|
||
current_tid = state.get("thread_id")
|
||
if async_notifier.has_pending_notifications(current_tid):
|
||
read_async_tasks_state = (
|
||
(lambda _tid=current_tid: _read_current_async_tasks(_tid))
|
||
if current_tid
|
||
else _empty_async_tasks
|
||
)
|
||
try:
|
||
await async_notifier.consume_notifications(
|
||
run_message=lambda text, notifs, _tid=current_tid: (
|
||
_inject_notification_message(
|
||
text, notifs, target_thread_id=_tid
|
||
)
|
||
),
|
||
read_async_tasks_state=read_async_tasks_state,
|
||
current_thread_id=current_tid,
|
||
)
|
||
except Exception:
|
||
_channel_logger.warning(
|
||
"async-notifier consume failed", exc_info=True
|
||
)
|
||
continue
|
||
|
||
await asyncio.sleep(0.1)
|
||
|
||
queue_task = asyncio.create_task(_check_channel_queue())
|
||
|
||
# Startup hint
|
||
console.print(
|
||
Text(
|
||
" EvoScientist is your research buddy.\n"
|
||
" Tell it about your taste before cooking some meal!",
|
||
style="yellow",
|
||
)
|
||
)
|
||
|
||
# Auto-start channel if enabled in config. Needs the agent
|
||
# bound before the bus starts polling, so schedule it as a
|
||
# background coroutine that waits for the loader first.
|
||
from ..config import load_config
|
||
|
||
_channel_cfg = load_config()
|
||
if (
|
||
_channel_cfg
|
||
and _channel_cfg.channel_enabled
|
||
and not _channels_is_running()
|
||
):
|
||
|
||
async def _deferred_auto_start_channel(cfg):
|
||
try:
|
||
agent = await _await_agent_ready()
|
||
except Exception as e:
|
||
console.print(
|
||
f"[red]Channel auto-start skipped: agent load failed:[/red] "
|
||
f"{escape(str(e))}"
|
||
)
|
||
return
|
||
if not _channels_is_running():
|
||
_auto_start_channel(
|
||
agent,
|
||
state["thread_id"],
|
||
cfg,
|
||
send_thinking=channel_send_thinking,
|
||
runtime=channel_runtime,
|
||
)
|
||
|
||
_auto_start_task = asyncio.create_task(
|
||
_deferred_auto_start_channel(_channel_cfg)
|
||
)
|
||
_background_tasks.add(_auto_start_task)
|
||
_auto_start_task.add_done_callback(_background_tasks.discard)
|
||
|
||
# Update check — non-blocking, runs in background thread
|
||
import concurrent.futures
|
||
|
||
_update_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||
|
||
def _show_update_hint() -> None:
|
||
try:
|
||
from ..update_check import _installed_version, is_update_available
|
||
|
||
available, latest = is_update_available()
|
||
if available:
|
||
current = _installed_version()
|
||
console.print(
|
||
Text(
|
||
f" Update available: v{latest} (current: v{current}).\n"
|
||
" Run: uv tool upgrade EvoScientist",
|
||
style="yellow",
|
||
)
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
_update_executor.submit(_show_update_hint)
|
||
|
||
# Slogan — after channels, right before user input
|
||
console.print(
|
||
Text(f" {random.choice(WELCOME_SLOGANS)}", style="dim italic")
|
||
)
|
||
console.print()
|
||
|
||
try:
|
||
_print_separator()
|
||
while state["running"]:
|
||
try:
|
||
# ``patch_stdout`` routes stray ``print`` /
|
||
# ``console.print`` calls — including the MCP
|
||
# progress callback firing from a worker thread —
|
||
# above the live prompt instead of over it.
|
||
with patch_stdout(raw=True):
|
||
user_input = await session.prompt_async(
|
||
HTML("<ansiblue><b>\u276f</b></ansiblue> "),
|
||
bottom_toolbar=_bottom_toolbar,
|
||
refresh_interval=1.0,
|
||
)
|
||
user_input = user_input.strip()
|
||
|
||
if not user_input:
|
||
# Erase the empty prompt line so it looks like nothing happened
|
||
sys.stdout.write("\033[A\033[2K\r")
|
||
sys.stdout.flush()
|
||
continue
|
||
|
||
_print_separator()
|
||
|
||
# ==== Shared CommandManager dispatch ====
|
||
# Every registered slash command routes through the
|
||
# manager. Unresolved input (non-slash, typo) falls
|
||
# through to the agent message path below.
|
||
_parsed = cmd_manager.resolve(user_input)
|
||
if _parsed is not None:
|
||
_cmd, _cmd_args = _parsed
|
||
_agent_for_ctx: Any = agent_loader.agent
|
||
if _cmd.needs_agent(_cmd_args):
|
||
_agent_for_ctx = await _await_agent_ready()
|
||
ctx = CommandContext(
|
||
agent=_agent_for_ctx,
|
||
thread_id=state["thread_id"],
|
||
ui=rich_ui,
|
||
workspace_dir=state["workspace_dir"],
|
||
checkpointer=checkpointer,
|
||
config=config,
|
||
input_tokens_hint=state.get("status_last_input_tokens"),
|
||
channel_runtime=channel_runtime,
|
||
graph_gateway=runtime_gateways.graph_gateway,
|
||
)
|
||
await cmd_manager.execute(user_input, ctx)
|
||
|
||
# ExitCommand signals quit via ``force_quit`` →
|
||
# callback flips ``state["running"]`` to False.
|
||
if not state["running"]:
|
||
break
|
||
|
||
# Agent swap (e.g. /model successfully built a
|
||
# new agent): adopt into loader + reset status
|
||
# snapshot + sync channel runtime.
|
||
agent_swapped = (
|
||
ctx.agent is not None
|
||
and ctx.agent is not _agent_for_ctx
|
||
)
|
||
if agent_swapped:
|
||
from ..EvoScientist import _ensure_config
|
||
|
||
agent_loader.adopt(ctx.agent)
|
||
cfg = _ensure_config()
|
||
model = cfg.model
|
||
state["status_base_snapshot"] = (
|
||
make_empty_status_snapshot(model)
|
||
)
|
||
|
||
# Rebind the runtime whenever the agent OR
|
||
# thread_id may have moved — ``/new`` /
|
||
# ``/resume`` rotate ``state["thread_id"]``
|
||
# without swapping the agent, and the bus
|
||
# expects both to stay in sync.
|
||
if _channels_is_running():
|
||
runtime_agent = (
|
||
ctx.agent
|
||
if ctx.agent is not None
|
||
else agent_loader.agent
|
||
)
|
||
if runtime_agent is not None:
|
||
channel_runtime.bind(
|
||
runtime_agent, state["thread_id"]
|
||
)
|
||
|
||
# Commands that mutate status fields need an
|
||
# async refresh here (/compact + /new use sync
|
||
# callbacks; /model swaps the agent). /resume
|
||
# awaits its own refresh inline inside the
|
||
# async callback.
|
||
if agent_swapped or _cmd.name in ("/compact", "/new"):
|
||
await _refresh_status_snapshot(
|
||
reset_streaming_text=True,
|
||
)
|
||
continue
|
||
|
||
# Unknown slash command (typo) — short-circuit so
|
||
# it doesn't get forwarded to the agent, which
|
||
# would waste tokens interpreting the nonsense.
|
||
if user_input.lstrip().startswith("/"):
|
||
bad_cmd = user_input.split(None, 1)[0]
|
||
console.print(
|
||
f"[red]Unknown command:[/red] {escape(bad_cmd)}"
|
||
)
|
||
console.print(
|
||
"[dim]Type /help to see available commands.[/dim]"
|
||
)
|
||
console.print()
|
||
continue
|
||
|
||
# Resolve @file mentions — inject file contents inline
|
||
_, message_to_send, file_warnings = resolve_file_mentions(
|
||
user_input, state["workspace_dir"]
|
||
)
|
||
|
||
# Stream agent response with metadata for persistence
|
||
# Warnings printed here so they appear just before the
|
||
# model response, not before the user input echo.
|
||
for w in file_warnings:
|
||
console.print(f"[yellow]⚠ {escape(w)}[/yellow]")
|
||
console.print()
|
||
ready_agent = await _await_agent_ready()
|
||
meta = build_metadata(state["workspace_dir"], model)
|
||
await _refresh_status_snapshot(
|
||
message_to_send, reset_streaming_text=True
|
||
)
|
||
run_streaming(
|
||
ui_backend=state["ui_backend"],
|
||
agent=ready_agent,
|
||
message=message_to_send,
|
||
thread_id=state["thread_id"],
|
||
show_thinking=show_thinking,
|
||
interactive=True,
|
||
metadata=meta,
|
||
on_stream_event=_handle_stream_status_event,
|
||
status_footer_builder=_stream_status_footer,
|
||
gateway=runtime_gateways.graph_gateway,
|
||
)
|
||
await _refresh_status_snapshot(reset_streaming_text=True)
|
||
console.print()
|
||
_print_separator()
|
||
|
||
except KeyboardInterrupt:
|
||
console.print()
|
||
state["running"] = False
|
||
break
|
||
except EOFError:
|
||
# Handle Ctrl+D
|
||
console.print()
|
||
state["running"] = False
|
||
break
|
||
except Exception as e:
|
||
error_msg = str(e)
|
||
if (
|
||
"authentication" in error_msg.lower()
|
||
or "api_key" in error_msg.lower()
|
||
):
|
||
console.print("[red]Error: API key not configured.[/red]")
|
||
console.print(
|
||
"[dim]Run [bold]EvoSci onboard[/bold] to set up your API key.[/dim]"
|
||
)
|
||
state["running"] = False
|
||
break
|
||
else:
|
||
console.print(f"[red]Error: {escape(str(e))}[/red]")
|
||
finally:
|
||
queue_task.cancel()
|
||
try:
|
||
await queue_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
# Best-effort: guard so a DB lookup failure here can't
|
||
# shadow the original exception exiting _async_main_loop.
|
||
current_tid = state.get("thread_id")
|
||
if current_tid:
|
||
try:
|
||
if await graph_gateway.thread_exists(current_tid):
|
||
state["resume_hint_thread_id"] = current_tid
|
||
except Exception:
|
||
_channel_logger.debug(
|
||
"resume-hint thread_exists lookup failed",
|
||
exc_info=True,
|
||
)
|
||
|
||
# Run the async main loop
|
||
from .resume_hint import print_resume_hint
|
||
|
||
try:
|
||
asyncio.run(_async_main_loop())
|
||
except KeyboardInterrupt:
|
||
console.print()
|
||
finally:
|
||
try:
|
||
print_resume_hint(state.get("resume_hint_thread_id"), console=console)
|
||
except Exception:
|
||
_channel_logger.debug("print_resume_hint failed", exc_info=True)
|
||
|
||
|
||
def cmd_run(
|
||
agent: "CompiledStateGraph",
|
||
prompt: str,
|
||
thread_id: str,
|
||
show_thinking: bool = True,
|
||
workspace_dir: str | None = None,
|
||
model: str | None = None,
|
||
ui_backend: str = "cli",
|
||
*,
|
||
runtime_gateways: RuntimeGateways,
|
||
) -> None:
|
||
"""Single-shot execution with streaming display.
|
||
|
||
Args:
|
||
agent: Compiled agent graph
|
||
prompt: User prompt
|
||
thread_id: Thread ID for conversation persistence.
|
||
show_thinking: Whether to display thinking panels
|
||
workspace_dir: Per-session workspace directory path
|
||
model: Model name for checkpoint metadata
|
||
ui_backend: UI backend ('cli' or 'tui')
|
||
"""
|
||
width = console.size.width
|
||
sep = Text("\u2500" * width, style="dim")
|
||
console.print(sep)
|
||
console.print(Text(f"> {prompt}"))
|
||
console.print(sep)
|
||
console.print(f"[dim]Thread: {short_thread_id(thread_id)}[/dim]")
|
||
if workspace_dir:
|
||
console.print(f"[dim]Workspace: {_shorten_path(workspace_dir)}[/dim]")
|
||
console.print()
|
||
|
||
meta = build_metadata(workspace_dir, model)
|
||
try:
|
||
run_streaming(
|
||
ui_backend=resolve_ui_backend(ui_backend, warn_fallback=True),
|
||
agent=agent,
|
||
message=prompt,
|
||
thread_id=thread_id,
|
||
show_thinking=show_thinking,
|
||
interactive=False,
|
||
metadata=meta,
|
||
gateway=runtime_gateways.graph_gateway,
|
||
)
|
||
_wait_for_memory_workers_before_exit()
|
||
except Exception as e:
|
||
error_msg = str(e)
|
||
if "authentication" in error_msg.lower() or "api_key" in error_msg.lower():
|
||
console.print("[red]Error: API key not configured.[/red]")
|
||
console.print(
|
||
"[dim]Run [bold]EvoSci onboard[/bold] to set up your API key.[/dim]"
|
||
)
|
||
raise typer.Exit(1) from e
|
||
else:
|
||
console.print(f"[red]Error: {e}[/red]")
|
||
raise
|
||
|
||
|
||
def _wait_for_memory_workers_before_exit(
|
||
*,
|
||
timeout_seconds: float = _MEMORY_WORKER_SHUTDOWN_WAIT_SECONDS,
|
||
) -> None:
|
||
"""Let one-shot CLI runs persist post-run memory before atexit cleanup."""
|
||
try:
|
||
from ..memory.worker_activity import memory_worker_observed_outputs
|
||
except Exception:
|
||
return
|
||
|
||
deadline = time.monotonic() + timeout_seconds
|
||
announced = False
|
||
saved_announced = False
|
||
announced_saved_counts: tuple[int, int] | None = None
|
||
output_seen_at: float | None = None
|
||
observed_status = None
|
||
while True:
|
||
now = time.monotonic()
|
||
try:
|
||
observed = memory_worker_observed_outputs()
|
||
except Exception:
|
||
return
|
||
|
||
if not observed.is_running:
|
||
saved_counts = (observed.observations_recorded, observed.profile_updates)
|
||
if saved_counts != (0, 0) and saved_counts != announced_saved_counts:
|
||
saved = []
|
||
if observed.observations_recorded:
|
||
saved.append(f"{observed.observations_recorded} observation(s)")
|
||
if observed.profile_updates:
|
||
saved.append(f"{observed.profile_updates} profile update(s)")
|
||
if saved:
|
||
console.print(f"[dim]EvoMemory saved {', '.join(saved)}.[/dim]")
|
||
return
|
||
|
||
if observed.observations_recorded or observed.profile_updates:
|
||
if output_seen_at is None:
|
||
output_seen_at = now
|
||
observed_status = observed
|
||
if (
|
||
now - output_seen_at >= _MEMORY_WORKER_OUTPUT_GRACE_SECONDS
|
||
and not saved_announced
|
||
):
|
||
saved = []
|
||
if observed_status and observed_status.observations_recorded:
|
||
saved.append(
|
||
f"{observed_status.observations_recorded} observation(s)"
|
||
)
|
||
if observed_status and observed_status.profile_updates:
|
||
saved.append(f"{observed_status.profile_updates} profile update(s)")
|
||
if saved:
|
||
console.print(f"[dim]EvoMemory saved {', '.join(saved)}.[/dim]")
|
||
saved_announced = True
|
||
announced_saved_counts = (
|
||
observed_status.observations_recorded,
|
||
observed_status.profile_updates,
|
||
)
|
||
|
||
if now >= deadline:
|
||
console.print(
|
||
"[dim]EvoMemory worker is still running; shutting down.[/dim]"
|
||
)
|
||
return
|
||
|
||
if not announced:
|
||
console.print("[dim]Waiting for EvoMemory worker...[/dim]")
|
||
announced = True
|
||
time.sleep(_MEMORY_WORKER_SHUTDOWN_POLL_SECONDS)
|