3c5cc831c0
* feat: configurable bind host for WebUI and langgraph dev (refs #400) WebUI mode was only reachable from the machine running it: the front-end got no bind interface, and `start_langgraph_dev(...)` was called without a host, so both servers stayed on loopback with no way to widen them. Adds two config fields with deliberately different defaults: webui_host = 0.0.0.0 front-end serves the app shell, no secrets langgraph_dev_host = 127.0.0.1 unauthenticated API, agent can run shell The design hinges on separating bind address from client address. Only bind() uses the configured interface; every consumer that *connects* (health probes, occupancy checks, async sub-agent self-dispatch) goes through the new `_probe_host`, which maps a wildcard bind back to loopback and honors a pinned interface verbatim. `_can_bind_port` is the one exception and binds the literal host, since it must replicate the bind the server itself will attempt. - manager.py: `_probe_host`, `_is_loopback_host`, `_format_hostport`; host kwarg threaded through the probes and `start_langgraph_dev`, which now emits `--host` and propagates EVOSCIENTIST_LANGGRAPH_DEV_HOST to the subprocess - sdk.py: `langgraph_dev_url` tracks host as well as port; EvoScientist.py reuses it instead of an inline f-string - server.py: `--host` flag mirroring `--port`, plus a red PUBLIC BIND banner whenever the bind is not provably loopback - webui.py: forwards both hosts; the front-end is widened via HOSTNAME because @evoscientist/webui ships no --host flag — its bin launcher does `HOSTNAME: process.env.HOSTNAME || "127.0.0.1"`. The warning is gated on the backend host only, so the shipped front-end default doesn't print a banner on every launch Verified end to end against a live server: requesting 0.0.0.0 yields a socket listening on 0.0.0.0 with the health probe correctly resolved to 127.0.0.1, while the default still binds 127.0.0.1 only. Note: webui_host defaulting to 0.0.0.0 is a behavior change — upgrading users will find the front-end reachable from the LAN. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: default both bind hosts to 0.0.0.0, add --host and wizard host rendering (closes #400) Completes the remaining items from #400. - `langgraph_dev_host` now defaults to 0.0.0.0, matching `webui_host`. Remote WebUI use needs both anyway (the UI reaches the backend from the browser, not server-side), so a loopback backend default just meant every remote user hit a silently failing UI. `_DEFAULT_HOST` and sdk's `DEFAULT_LANGGRAPH_DEV_HOST` follow, so there is one story about where these servers listen. SECURITY: this exposes an unauthenticated API whose agent can run shell commands. The red PUBLIC BIND banner consequently fires on every launch while exposed — kept deliberately, since the exposure is real and the escape hatch (`--host 127.0.0.1` / `config set langgraph_dev_host`) is only discoverable if we say so. READMEs now lead with the warning and document the SSH-tunnel alternative. - `EvoSci --host <ip>` on the WebUI launch path, driving both servers. In WebUI mode they are two halves of one surface; moving only one leaves the UI loading but unable to reach the agent. Blank values are dropped rather than written as an empty override that would beat the config file. - Onboarding wizard no longer prints hard-coded `http://127.0.0.1:{port}` / `http://localhost:{port}` (steps.py:160, :223) — both render the configured bind through `_base_url` / `_format_hostport`, so a pinned interface is reported honestly and a wildcard still shows loopback. Verified against a live server: with no host argument at all, resolution through EvoScientistConfig yields a socket listening on 0.0.0.0, a client URL of http://127.0.0.1, and the warning gate returning True. Still open and tracked separately: the front-end takes its backend URL from browser input: `@evoscientist/webui` reads only HOSTNAME, PORT and EVOSCIENTIST_LANGGRAPH_DEV_PORT, so advertising a backend URL needs a change in that repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ci: bump setup-uv v6 -> v9.0.0 to drop the deprecated node20 runtime GitHub now warns that setup-uv@v6 targets Node.js 20 and is being forced onto Node.js 24. v7.0.0 is the release that made that switch, so anything >= v7 clears the warning; v9.0.0 is current. Pinned to the full tag deliberately: setup-uv stopped publishing major and minor tags in v8.0.0 as supply-chain hardening, so `@v9` and `@v8` return 404 and would fail the job outright. Releases are immutable from v8 on, so the full tag is as tamper-proof as a SHA. Comment left in lint.yml because "simplifying" this back to `@v9` is an easy and CI-breaking mistake. actions/checkout@v5 is already node24 and needs no change. Note: v9.0.0 flips the `prune-cache` default to false (upstream did this to ease load on PyPI infrastructure). None of these workflows set it, so they follow the new default and Actions cache usage may grow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(cli): correct --host help text and warn on public bind in non-WebUI modes The --host help claimed "WebUI mode only", which is wrong in a way that matters for security. `--host` writes `langgraph_dev_host` unconditionally, and `_ensure_async_subagent_server` auto-starts that backend for tui / cli / serve as well — the langgraph dev server is shared across UI modes. So the flag narrows or widens the agent API in every mode, and only `webui_host` is actually WebUI-specific. Reported against cli/commands.py. The documentation error hid a real gap: the PUBLIC BIND banner lived only in deploy/server.py and deploy/webui.py, so a plain `EvoSci` session bound 0.0.0.0 with no runtime signal whatsoever — and `--help` is opt-in, so fixing the text alone would not surface it. Added the same banner to the shared CLI path, gated on `is_async_subagents_available()`: ensure_langgraph_dev fails soft (async degrades to in-process delegation), and warning about a bind that never happened would be worse than staying quiet. READMEs (EN + zh-CN) get the same correction — the warning block sat inside the Desktop WebUI section and read as WebUI-scoped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(deploy): strip the config-derived bind host, not just the CLI one `deploy()` only stripped the `--host` branch. When the flag was omitted, `getattr(config, "langgraph_dev_host", ...)` flowed unstripped into `_is_port_occupied`, `is_langgraph_dev_running`, `start_langgraph_dev` and the banner. `run_webui` already strips unconditionally; this aligns the two. Reachable because `deploy()` reads through `getattr` and is routinely handed duck-typed config objects (tests, embedders) that never run `EvoScientistConfig.__post_init__`, which is what normally normalizes these fields. Worst case was not just a bad bind: `_is_loopback_host(" 127.0.0.1 ")` is False, so a padded loopback value would print a false PUBLIC BIND warning while binding a string socket.bind() rejects outright — a security banner saying the opposite of the truth. Three regression tests added, each verified to fail against the old code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style: apply ruff format to the bind-host changes The Lint workflow runs both `ruff check` and `ruff format --check`; I had only been running the former locally, so five files landed unformatted and failed CI. Whitespace and line-wrapping only — no semantic change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(security): keep the langgraph dev backend on loopback by default The backend is an unauthenticated API whose agent can run shell commands, and it is auto-started in every UI mode (tui/cli/webui/serve/deploy) — so a 0.0.0.0 default put it on the network for users who never asked. Restore 127.0.0.1 as the default and make 0.0.0.0 an explicit opt-in. webui_host keeps its 0.0.0.0 default: the front-end serves the app shell only and holds no credentials. run_webui already prints a remote-backend hint when the front-end is exposed and the backend is not. Help text and both READMEs are reframed around widening rather than narrowing; the escape-hatch tests are inverted to assert the public-bind opt-in survives into argv. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2580 lines
93 KiB
Python
2580 lines
93 KiB
Python
"""Typer command registrations — onboard, config, mcp, main callback."""
|
|
|
|
import logging
|
|
import os
|
|
import queue
|
|
import re
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from importlib.metadata import version as _pkg_version
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Annotated, Any, cast
|
|
|
|
import click
|
|
import typer
|
|
from rich.markup import escape
|
|
from rich.table import Table
|
|
|
|
from ..commands.base import ChannelRuntime, Command, CommandContext
|
|
from ..gateway import (
|
|
GraphGateway,
|
|
GraphTarget,
|
|
RunRequest,
|
|
RuntimeGateways,
|
|
create_runtime_gateways,
|
|
)
|
|
from ..llm.context_window import DEFAULT_CONTEXT_WINDOW_FALLBACK, resolve_context_window
|
|
from ..paths import ensure_dirs, set_active_workspace, set_workspace_root
|
|
from ..runtime import AsyncRuntime
|
|
from ..stream.console import console
|
|
from . import async_notifier
|
|
from ._app import app, channel_app, config_app, configure_app, mcp_app, sessions_app
|
|
from ._constants import build_metadata
|
|
from .agent import (
|
|
_create_session_workspace,
|
|
_deduplicate_run_name,
|
|
_load_agent,
|
|
_shorten_path,
|
|
)
|
|
from .channel import (
|
|
ChannelMessage,
|
|
_channel_message_cancel_scope,
|
|
_channels_stop,
|
|
_claim_or_complete_channel_request,
|
|
_complete_channel_request,
|
|
_message_queue,
|
|
_set_channel_response,
|
|
_start_channels_bus_mode,
|
|
channel_ask_user_prompt,
|
|
channel_hitl_prompt,
|
|
dispatch_channel_slash_command,
|
|
forget_channel_origin,
|
|
get_channel_origin,
|
|
publish_to_channel_origin,
|
|
remember_channel_origin,
|
|
)
|
|
from .channel_sends import PendingChannelSends
|
|
from .mcp_ui import (
|
|
_mcp_add_server_from_kwargs,
|
|
_mcp_edit_server_fields,
|
|
_mcp_list_servers,
|
|
_mcp_remove_server,
|
|
_show_mcp_config,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from langgraph.graph.state import CompiledStateGraph
|
|
|
|
from ..config import EvoScientistConfig
|
|
|
|
|
|
_ASYNC_RUNTIME_META_KEY = "evoscientist.async_runtime"
|
|
|
|
|
|
def _close_cli_async_runtime(runtime: AsyncRuntime) -> None:
|
|
"""Close the owned runtime or surface a controlled CLI shutdown failure."""
|
|
try:
|
|
runtime.close()
|
|
except TimeoutError as exc:
|
|
click.echo(
|
|
f"Error: Async runtime shutdown did not complete: {exc}",
|
|
err=True,
|
|
)
|
|
raise click.exceptions.Exit(1) from None
|
|
|
|
|
|
def _get_cli_async_runtime(ctx: typer.Context) -> AsyncRuntime:
|
|
"""Return the application-scoped runtime owned by this CLI invocation."""
|
|
root = ctx.find_root()
|
|
runtime = root.meta.get(_ASYNC_RUNTIME_META_KEY)
|
|
if runtime is None:
|
|
runtime = AsyncRuntime()
|
|
root.meta[_ASYNC_RUNTIME_META_KEY] = runtime
|
|
root.call_on_close(lambda: _close_cli_async_runtime(runtime))
|
|
if not isinstance(runtime, AsyncRuntime): # pragma: no cover - defensive
|
|
raise RuntimeError("CLI async runtime context is invalid")
|
|
return runtime
|
|
|
|
|
|
# =============================================================================
|
|
# Onboard command
|
|
# =============================================================================
|
|
|
|
|
|
@app.command()
|
|
def onboard(
|
|
ctx: typer.Context,
|
|
skip_validation: bool = typer.Option(
|
|
False, "--skip-validation", help="Skip API key validation during setup"
|
|
),
|
|
# ---- Pre-fill answers (any subset; remaining prompts stay interactive)
|
|
provider: str | None = typer.Option(
|
|
None, "--provider", help="Pre-set LLM provider (e.g. anthropic, openai)"
|
|
),
|
|
model: str | None = typer.Option(None, "--model", help="Pre-set model name"),
|
|
api_key: str | None = typer.Option(
|
|
None, "--api-key", help="Pre-set API key for the chosen --provider"
|
|
),
|
|
tavily_key: str | None = typer.Option(
|
|
None, "--tavily-key", help="Pre-set Tavily API key"
|
|
),
|
|
workspace_mode: str | None = typer.Option(
|
|
None,
|
|
"--workspace-mode",
|
|
help="Pre-set workspace mode (daemon | run)",
|
|
),
|
|
show_thinking: bool | None = typer.Option(
|
|
None,
|
|
"--show-thinking/--no-show-thinking",
|
|
help="Pre-set thinking-panel visibility",
|
|
),
|
|
ui: str | None = typer.Option(
|
|
None, "--ui", help="Pre-set UI backend (tui | cli | webui)"
|
|
),
|
|
port: int | None = typer.Option(
|
|
None, "--port", help="Pre-set langgraph dev server port"
|
|
),
|
|
# ---- Skip flags
|
|
skip_skills: bool = typer.Option(
|
|
False, "--skip-skills", help="Skip skills install"
|
|
),
|
|
skip_mcp: bool = typer.Option(False, "--skip-mcp", help="Skip MCP server setup"),
|
|
skip_latex: bool = typer.Option(False, "--skip-latex", help="Skip LaTeX setup"),
|
|
skip_channels: bool = typer.Option(
|
|
False, "--skip-channels", help="Skip channels setup"
|
|
),
|
|
non_interactive: bool = typer.Option(
|
|
False,
|
|
"--non-interactive",
|
|
help="Run without prompts — every required answer must come from a flag",
|
|
),
|
|
):
|
|
"""Interactive setup wizard for EvoScientist.
|
|
|
|
Guides you through configuring API keys, model selection,
|
|
workspace settings, and agent parameters.
|
|
|
|
Any answer can be pre-set via a flag (``--provider anthropic
|
|
--model claude-sonnet-4-6 ...``); prompts for unset answers stay
|
|
interactive unless ``--non-interactive`` is passed, in which case any
|
|
missing required answer aborts the wizard.
|
|
"""
|
|
from ..config.onboard.constants import (
|
|
VALID_PROVIDERS,
|
|
VALID_UI_BACKENDS,
|
|
VALID_WORKSPACE_MODES,
|
|
)
|
|
from ..config.onboard.prompter import NonInteractivePrompter
|
|
|
|
# Validate constrained string flags up-front so a typo doesn't silently
|
|
# poison the saved config. Allowed-value sets live in
|
|
# ``EvoScientist/config/onboard/constants.py``; a drift test in
|
|
# ``tests/test_onboard.py`` keeps them aligned with the interactive
|
|
# ``Choice(value=...)`` lists in ``steps.py``.
|
|
if ui is not None and ui not in VALID_UI_BACKENDS:
|
|
raise typer.BadParameter(
|
|
f"--ui must be one of {sorted(VALID_UI_BACKENDS)}", param_hint="--ui"
|
|
)
|
|
if workspace_mode is not None and workspace_mode not in VALID_WORKSPACE_MODES:
|
|
raise typer.BadParameter(
|
|
f"--workspace-mode must be one of {sorted(VALID_WORKSPACE_MODES)}",
|
|
param_hint="--workspace-mode",
|
|
)
|
|
if provider is not None and provider not in VALID_PROVIDERS:
|
|
raise typer.BadParameter(
|
|
f"--provider must be one of {sorted(VALID_PROVIDERS)}",
|
|
param_hint="--provider",
|
|
)
|
|
# Match the interactive prompt's range (1024 < port < 65536). Without
|
|
# this check, --port 80 or --port 99999 would land in config and break
|
|
# the langgraph dev server on startup.
|
|
if port is not None and not (1024 < port < 65536):
|
|
raise typer.BadParameter(
|
|
"--port must be in the user-port range (1025 — 65535)",
|
|
param_hint="--port",
|
|
)
|
|
|
|
# Collect flag-supplied answers keyed by the prompt_id wizard steps use.
|
|
answers: dict = {}
|
|
if ui is not None:
|
|
answers["ui"] = ui
|
|
if port is not None:
|
|
answers["port"] = str(port)
|
|
if provider is not None:
|
|
answers["provider"] = provider
|
|
if model is not None:
|
|
answers["model"] = model
|
|
if api_key is not None:
|
|
answers["api_key"] = api_key
|
|
if tavily_key is not None:
|
|
answers["tavily_key"] = tavily_key
|
|
if workspace_mode is not None:
|
|
answers["workspace_mode"] = workspace_mode
|
|
if show_thinking is not None:
|
|
answers["show_thinking"] = show_thinking
|
|
|
|
skip_set = {
|
|
section
|
|
for section, flag in (
|
|
("skills", skip_skills),
|
|
("mcp", skip_mcp),
|
|
("latex", skip_latex),
|
|
("channels", skip_channels),
|
|
)
|
|
if flag
|
|
}
|
|
|
|
prompter = None
|
|
if answers or skip_set or non_interactive:
|
|
prompter = NonInteractivePrompter(
|
|
answers=answers,
|
|
skip_set=skip_set,
|
|
strict=non_interactive,
|
|
)
|
|
|
|
_run_onboard_cli(
|
|
skip_validation=skip_validation,
|
|
prompter=prompter,
|
|
runtime=_get_cli_async_runtime(ctx),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# `EvoSci configure <section>` — re-run one onboarding section
|
|
# =============================================================================
|
|
|
|
|
|
_CONFIGURE_SECTIONS = {
|
|
"ui": "UI backend",
|
|
"port": "LangGraph server port",
|
|
"provider": "LLM provider + auth + API key",
|
|
"model": "Model + reasoning effort",
|
|
"tavily": "Tavily search key",
|
|
"workspace": "Workspace mode",
|
|
"thinking": "Thinking panel",
|
|
"skills": "Skills",
|
|
"mcp": "MCP servers",
|
|
"latex": "LaTeX (TinyTeX)",
|
|
"channels": "Channels",
|
|
}
|
|
|
|
|
|
def _run_onboard_cli(**kwargs: Any) -> None:
|
|
"""Invoke the wizard, presenting non-interactive errors as a clean
|
|
message + exit code 1 instead of a raw Python traceback.
|
|
|
|
The wizard raises ``RuntimeError`` for *expected* non-interactive
|
|
failures: rejected ``--api-key`` / ``--tavily-key`` presets, missing
|
|
required flags under ``--non-interactive``, or a missing base URL.
|
|
Those are user-input problems, not bugs — surface them like any other
|
|
CLI validation error rather than dumping a stack trace.
|
|
"""
|
|
from ..config import run_onboard
|
|
|
|
try:
|
|
run_onboard(**kwargs)
|
|
except RuntimeError as exc:
|
|
console.print(f"[red]✗ {escape(str(exc))}[/red]")
|
|
raise typer.Exit(code=1) from exc
|
|
|
|
|
|
def _configure_section(
|
|
section: str,
|
|
skip_validation: bool = False,
|
|
*,
|
|
runtime: AsyncRuntime | None = None,
|
|
) -> None:
|
|
"""Run a single onboarding section, reusing the wizard's step logic."""
|
|
kwargs: dict[str, Any] = {
|
|
"skip_validation": skip_validation,
|
|
"only_sections": {section},
|
|
}
|
|
if runtime is not None:
|
|
kwargs["runtime"] = runtime
|
|
_run_onboard_cli(
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
@configure_app.command("ui")
|
|
def configure_ui():
|
|
"""Re-run UI backend (TUI / CLI) selection."""
|
|
_configure_section("ui")
|
|
|
|
|
|
@configure_app.command("port")
|
|
def configure_port():
|
|
"""Re-run langgraph dev server port selection."""
|
|
_configure_section("port")
|
|
|
|
|
|
@configure_app.command("provider")
|
|
def configure_provider(
|
|
skip_validation: bool = typer.Option(False, "--skip-validation"),
|
|
):
|
|
"""Re-run LLM provider, auth mode, and API key prompts.
|
|
|
|
Model selection is automatically re-run after provider — the model list
|
|
depends on the provider, and silently leaving e.g. ``model="claude-...""``
|
|
when the provider was switched to ``openai`` would break the first
|
|
request. Press Enter on the model picker to keep the current default.
|
|
"""
|
|
_run_onboard_cli(
|
|
skip_validation=skip_validation,
|
|
only_sections={"provider", "model"},
|
|
)
|
|
|
|
|
|
@configure_app.command("model")
|
|
def configure_model():
|
|
"""Re-run model selection (and reasoning effort for OpenRouter)."""
|
|
_configure_section("model")
|
|
|
|
|
|
@configure_app.command("tavily")
|
|
def configure_tavily(
|
|
skip_validation: bool = typer.Option(False, "--skip-validation"),
|
|
):
|
|
"""Re-run Tavily search-key prompt."""
|
|
_configure_section("tavily", skip_validation=skip_validation)
|
|
|
|
|
|
@configure_app.command("workspace")
|
|
def configure_workspace():
|
|
"""Re-run workspace mode (daemon/run) selection."""
|
|
_configure_section("workspace")
|
|
|
|
|
|
@configure_app.command("thinking")
|
|
def configure_thinking():
|
|
"""Re-run thinking-panel visibility selection."""
|
|
_configure_section("thinking")
|
|
|
|
|
|
@configure_app.command("skills")
|
|
def configure_skills():
|
|
"""Re-run skills install/sync."""
|
|
_configure_section("skills")
|
|
|
|
|
|
@configure_app.command("mcp")
|
|
def configure_mcp():
|
|
"""Re-run MCP server selection."""
|
|
_configure_section("mcp")
|
|
|
|
|
|
@configure_app.command("latex")
|
|
def configure_latex():
|
|
"""Re-run LaTeX (TinyTeX) setup."""
|
|
_configure_section("latex")
|
|
|
|
|
|
@configure_app.command("channels")
|
|
def configure_channels(ctx: typer.Context):
|
|
"""Re-run channels selection and per-channel configuration."""
|
|
_configure_section("channels", runtime=_get_cli_async_runtime(ctx))
|
|
|
|
|
|
# =============================================================================
|
|
# Channel setup command
|
|
# =============================================================================
|
|
|
|
|
|
@channel_app.command("setup")
|
|
def channel_setup(ctx: typer.Context):
|
|
"""Interactive channel configuration wizard.
|
|
|
|
Guides you through selecting and configuring messaging channels
|
|
(Telegram, Discord, or iMessage).
|
|
"""
|
|
from ..config import load_config, save_config
|
|
from ..config.onboard.channels import _step_channels
|
|
|
|
config = load_config()
|
|
updates = _step_channels(config, runtime=_get_cli_async_runtime(ctx))
|
|
if updates:
|
|
for key, value in updates.items():
|
|
setattr(config, key, value)
|
|
save_config(config)
|
|
console.print("[green]Channel configuration saved.[/green]")
|
|
else:
|
|
console.print("[dim]No changes made.[/dim]")
|
|
|
|
|
|
# =============================================================================
|
|
# Compact helper
|
|
# =============================================================================
|
|
|
|
_COMPACT_CONTEXT_WINDOW_FALLBACK = DEFAULT_CONTEXT_WINDOW_FALLBACK
|
|
_MANUAL_COMPACT_MIN_FRACTION = 0.40
|
|
_MANUAL_COMPACT_MIN_PERCENT = int(_MANUAL_COMPACT_MIN_FRACTION * 100)
|
|
|
|
|
|
class CompactResult:
|
|
"""Structured result from compact_conversation.
|
|
|
|
Attributes:
|
|
status: "noop" (nothing to compact), "ok" (compacted), or "error".
|
|
message: Short human-readable message (used as fallback / TUI text).
|
|
messages_compacted: Number of messages summarized (0 for noop/error).
|
|
messages_kept: Number of messages unchanged.
|
|
tokens_before: Total tokens before compaction.
|
|
tokens_after: Total tokens after compaction.
|
|
tokens_summarized: Tokens in the summarized portion (before).
|
|
tokens_summary: Tokens in the summary message (after).
|
|
pct_decrease: Percentage decrease.
|
|
context_window: Model context window used for thresholding.
|
|
context_percent: Effective context utilization percent.
|
|
summary_text: Human-readable compact summary content for UI display.
|
|
"""
|
|
|
|
__slots__ = (
|
|
"context_percent",
|
|
"context_window",
|
|
"message",
|
|
"messages_compacted",
|
|
"messages_kept",
|
|
"pct_decrease",
|
|
"status",
|
|
"summary_text",
|
|
"tokens_after",
|
|
"tokens_before",
|
|
"tokens_summarized",
|
|
"tokens_summary",
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
status: str,
|
|
message: str,
|
|
*,
|
|
messages_compacted: int = 0,
|
|
messages_kept: int = 0,
|
|
tokens_before: int = 0,
|
|
tokens_after: int = 0,
|
|
tokens_summarized: int = 0,
|
|
tokens_summary: int = 0,
|
|
pct_decrease: int = 0,
|
|
context_window: int = 0,
|
|
context_percent: int = 0,
|
|
summary_text: str = "",
|
|
):
|
|
self.status = status
|
|
self.message = message
|
|
self.messages_compacted = messages_compacted
|
|
self.messages_kept = messages_kept
|
|
self.tokens_before = tokens_before
|
|
self.tokens_after = tokens_after
|
|
self.tokens_summarized = tokens_summarized
|
|
self.tokens_summary = tokens_summary
|
|
self.pct_decrease = pct_decrease
|
|
self.context_window = context_window
|
|
self.context_percent = context_percent
|
|
self.summary_text = summary_text
|
|
|
|
def __str__(self) -> str:
|
|
return self.message
|
|
|
|
|
|
class CompactSummaryRenderable:
|
|
"""Rich renderable payload for the manual compact summary content."""
|
|
|
|
__slots__ = ("summary_text",)
|
|
|
|
def __init__(self, summary_text: str):
|
|
self.summary_text = (summary_text or "").strip()
|
|
|
|
def __rich_console__(self, console, options):
|
|
yield render_compact_summary_panel(self.summary_text)
|
|
|
|
|
|
def _ensure_async_subagent_server(config: Any, *, workspace_dir: str) -> None:
|
|
"""Start the langgraph dev subprocess for background agent work.
|
|
|
|
Shared by both the interactive entry and the serve entry so the
|
|
user-visible status message and workspace-mismatch handling stay in one
|
|
place.
|
|
|
|
Raises ``typer.Exit(1)`` (after surfacing a red error) when an
|
|
externally-managed langgraph dev is already running for a different
|
|
workspace — e.g., ``EvoSci deploy --workdir /A`` is up and the user
|
|
is starting ``EvoSci`` / ``EvoSci serve`` in /B. Continuing in that
|
|
state would route async sub-agent calls to a process pinned to /A
|
|
while the main agent runs in /B.
|
|
"""
|
|
from ..langgraph_dev.manager import (
|
|
_DEFAULT_HOST,
|
|
WorkspaceMismatchError,
|
|
_is_loopback_host,
|
|
ensure_langgraph_dev,
|
|
is_async_subagents_available,
|
|
)
|
|
|
|
try:
|
|
with console.status(
|
|
"[dim]Starting background agent server (langgraph dev)...[/dim]",
|
|
spinner="dots",
|
|
):
|
|
ensure_langgraph_dev(config, workspace_dir=workspace_dir)
|
|
_reconcile_autoskill_schedule(config, workspace_dir=workspace_dir)
|
|
except WorkspaceMismatchError as exc:
|
|
console.print(f"[red]{exc}[/red]")
|
|
raise typer.Exit(1) from exc
|
|
|
|
# This backend is shared by every UI mode, not just `deploy` / WebUI — so
|
|
# the exposure warning belongs here too, or a plain `EvoSci` session would
|
|
# put an unauthenticated, shell-capable API on the network with no signal
|
|
# at all. Gated on the server actually being up: ensure_langgraph_dev
|
|
# fails soft (async falls back to in-process), and warning about a bind
|
|
# that never happened would be worse than saying nothing.
|
|
bind_host = str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or "").strip()
|
|
if (
|
|
bind_host
|
|
and not _is_loopback_host(bind_host)
|
|
and is_async_subagents_available()
|
|
):
|
|
console.print(
|
|
"[bold white on red] ⚠ PUBLIC BIND [/bold white on red] "
|
|
f"[bold red]Agent server listening on {bind_host} — no auth, and "
|
|
f"the agent can run shell. Use --host 127.0.0.1 on untrusted "
|
|
f"networks.[/bold red]"
|
|
)
|
|
|
|
|
|
def _reconcile_autoskill_schedule(config: Any, *, workspace_dir: str) -> None:
|
|
"""Best-effort reconciliation for EvoMemory's hidden AutoSkills cron."""
|
|
try:
|
|
from ..memory.autoskills.schedule import reconcile_autoskill_schedule
|
|
|
|
reconcile_autoskill_schedule(config, workspace_dir=workspace_dir)
|
|
except Exception:
|
|
logging.getLogger(__name__).warning(
|
|
"Failed to reconcile EvoMemory AutoSkills schedule", exc_info=True
|
|
)
|
|
|
|
|
|
def _pending_skill_proposals_message(
|
|
workspace_dir: str | Path | None = None,
|
|
) -> str | None:
|
|
"""Return a concise review reminder when autoskill proposals are waiting."""
|
|
try:
|
|
from .. import paths
|
|
from ..memory.autoskills.proposals import pending_skill_proposal_count
|
|
|
|
count = pending_skill_proposal_count(
|
|
paths.MEMORIES_DIR,
|
|
workspace_dir=workspace_dir or paths.WORKSPACE_ROOT,
|
|
)
|
|
except Exception:
|
|
return None
|
|
if not count:
|
|
return None
|
|
return (
|
|
f"EvoMemory has {count} autoskill proposal(s) ready for review. "
|
|
"Run /autoskills review."
|
|
)
|
|
|
|
|
|
async def _sync_background_agent_server_workspace(
|
|
config: Any,
|
|
*,
|
|
workspace_dir: str,
|
|
status_message: str = (
|
|
"[dim]Syncing background agent server to resumed workspace...[/dim]"
|
|
),
|
|
) -> None:
|
|
"""Sync langgraph dev to a resumed workspace for background agent work.
|
|
|
|
``ensure_langgraph_dev`` is intentionally always called: EvoMemory
|
|
background workers require the server even when async subagents are disabled.
|
|
WorkspaceMismatchError is left for callers to handle according to their UI
|
|
flow.
|
|
"""
|
|
import asyncio
|
|
|
|
from ..langgraph_dev.manager import ensure_langgraph_dev
|
|
|
|
with console.status(status_message, spinner="dots"):
|
|
await asyncio.to_thread(
|
|
ensure_langgraph_dev,
|
|
config,
|
|
workspace_dir=workspace_dir,
|
|
)
|
|
await asyncio.to_thread(
|
|
_reconcile_autoskill_schedule,
|
|
config,
|
|
workspace_dir=workspace_dir,
|
|
)
|
|
|
|
|
|
def _resolve_context_window(
|
|
model: Any, fallback: int = _COMPACT_CONTEXT_WINDOW_FALLBACK
|
|
) -> int:
|
|
"""Resolve a model context window with a stable fallback."""
|
|
return resolve_context_window(model, fallback=fallback)
|
|
|
|
|
|
def _percent_used(tokens: int, context_window: int) -> int:
|
|
"""Return a clamped utilization percent."""
|
|
if context_window <= 0:
|
|
return 0
|
|
return max(0, min(100, round((tokens / context_window) * 100)))
|
|
|
|
|
|
def render_compact_result(result: CompactResult): # -> rich.text.Text
|
|
"""Render a CompactResult as styled Rich Text.
|
|
|
|
Uses the same visual language as the token usage display:
|
|
cyan for numbers, green for savings, dim for labels.
|
|
"""
|
|
from rich.text import Text
|
|
|
|
output = Text()
|
|
|
|
if result.status == "noop":
|
|
output.append("○ ", style="dim")
|
|
output.append("Manual compact not needed", style="dim")
|
|
if result.tokens_before > 0:
|
|
output.append(" [", style="dim")
|
|
output.append(f"{result.tokens_before:,}", style="cyan")
|
|
if result.context_window > 0:
|
|
output.append(" / ", style="dim")
|
|
output.append(f"{result.context_window:,}", style="cyan")
|
|
output.append(" tokens", style="dim")
|
|
output.append(" │ ", style="dim")
|
|
output.append(f"{result.context_percent}%", style="cyan")
|
|
output.append(" of window", style="dim")
|
|
else:
|
|
output.append(" tokens", style="dim")
|
|
output.append("]", style="dim")
|
|
if result.message:
|
|
output.append("\n ", style="")
|
|
output.append(result.message, style="dim")
|
|
return output
|
|
|
|
if result.status == "error":
|
|
output.append("✗ ", style="red")
|
|
output.append(result.message, style="red")
|
|
return output
|
|
|
|
# status == "ok"
|
|
output.append("✓ ", style="green")
|
|
output.append("Compacted ", style="dim")
|
|
output.append(f"{result.messages_compacted}", style="bold")
|
|
output.append(" messages", style="dim")
|
|
output.append(" [", style="dim")
|
|
output.append(f"{result.tokens_before:,}", style="cyan")
|
|
output.append(" → ", style="dim")
|
|
output.append(f"{result.tokens_after:,}", style="green")
|
|
output.append(" tokens", style="dim")
|
|
output.append(f" ↓{result.pct_decrease}%", style="green bold")
|
|
output.append("]", style="dim")
|
|
|
|
# Second line: detail breakdown
|
|
output.append("\n ", style="")
|
|
output.append("Summarized: ", style="dim")
|
|
output.append(f"{result.tokens_summarized:,}", style="cyan")
|
|
output.append(" → ", style="dim")
|
|
output.append(f"{result.tokens_summary:,}", style="green")
|
|
output.append(" │ ", style="dim")
|
|
output.append("Kept: ", style="dim")
|
|
output.append(f"{result.messages_kept}", style="cyan")
|
|
output.append(" messages unchanged", style="dim")
|
|
if result.context_window > 0:
|
|
output.append(" │ ", style="dim")
|
|
output.append("Window: ", style="dim")
|
|
output.append(f"{result.context_percent}%", style="cyan")
|
|
output.append(" used", style="dim")
|
|
|
|
return output
|
|
|
|
|
|
def render_compact_summary_panel(summary_text: str):
|
|
"""Render the compacted summary content as a Rich panel."""
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
|
|
content = (summary_text or "").strip()
|
|
body = Text(content or "(empty summary)", style="dim italic")
|
|
return Panel(
|
|
body,
|
|
title="Context Compacted",
|
|
border_style="#f59e0b",
|
|
padding=(0, 1),
|
|
)
|
|
|
|
|
|
def build_compact_summary_renderable(
|
|
result: CompactResult,
|
|
) -> CompactSummaryRenderable | None:
|
|
"""Build the UI summary payload for a successful compact operation."""
|
|
if result.status != "ok" or not result.summary_text.strip():
|
|
return None
|
|
return CompactSummaryRenderable(result.summary_text)
|
|
|
|
|
|
async def compact_conversation(
|
|
graph_gateway: GraphGateway,
|
|
thread_id: str,
|
|
target: GraphTarget,
|
|
*,
|
|
input_tokens_hint: int | None = None,
|
|
) -> CompactResult:
|
|
"""Compact the conversation by summarizing old messages.
|
|
|
|
Reads the graph's checkpointed state, creates a temporary
|
|
``SummarizationMiddleware``, generates a summary, and writes
|
|
the compacted state back through ``GraphGateway``.
|
|
|
|
``input_tokens_hint`` is the real LLM input token count from the last
|
|
``usage_metadata`` (includes system prompt + tool schemas). When
|
|
provided it is used for the display values in ``CompactResult`` so the
|
|
panel stays in sync with the status bar; the internal compact logic
|
|
(cutoff determination) still uses message-level token counts.
|
|
|
|
Returns a structured ``CompactResult``.
|
|
"""
|
|
from langchain_core.messages.utils import count_tokens_approximately
|
|
from langchain_core.runnables import RunnableConfig
|
|
|
|
config: RunnableConfig = {"configurable": {"thread_id": thread_id}}
|
|
|
|
try:
|
|
state_values = await graph_gateway.get_state_values(target, thread_id)
|
|
except Exception as exc:
|
|
return CompactResult("error", f"Failed to read state: {exc}")
|
|
|
|
messages = state_values.get("messages", [])
|
|
if not messages:
|
|
return CompactResult(
|
|
"noop", "Nothing to compact — no messages in conversation."
|
|
)
|
|
|
|
from deepagents.middleware.summarization import (
|
|
SummarizationEvent,
|
|
SummarizationMiddleware,
|
|
compute_summarization_defaults,
|
|
)
|
|
|
|
from ..EvoScientist import _ensure_chat_model, _get_default_backend
|
|
|
|
try:
|
|
model = _ensure_chat_model()
|
|
except Exception as exc:
|
|
return CompactResult(
|
|
"error", f"Compaction requires a working model configuration: {exc}"
|
|
)
|
|
|
|
backend = _get_default_backend()
|
|
context_window = _resolve_context_window(model)
|
|
|
|
defaults = compute_summarization_defaults(model)
|
|
middleware = SummarizationMiddleware(
|
|
model=model,
|
|
backend=backend,
|
|
keep=defaults["keep"],
|
|
trim_tokens_to_summarize=None,
|
|
)
|
|
|
|
# Rebuild effective message list accounting for prior compaction
|
|
event = state_values.get("_summarization_event")
|
|
effective = middleware._apply_event_to_messages(messages, event)
|
|
effective_tokens = count_tokens_approximately(effective)
|
|
|
|
# For display and threshold we prefer the real LLM input token count
|
|
# (includes system prompt + tool schemas) so the panel stays in sync with
|
|
# the status bar. The internal compact logic (cutoff, partition, savings)
|
|
# still uses effective_tokens (message-level) because compact only reduces
|
|
# messages, not the constant system/tool overhead.
|
|
display_tokens = (
|
|
input_tokens_hint
|
|
if input_tokens_hint is not None and input_tokens_hint > 0
|
|
else effective_tokens
|
|
)
|
|
display_percent = _percent_used(display_tokens, context_window)
|
|
|
|
if display_percent < _MANUAL_COMPACT_MIN_PERCENT:
|
|
return CompactResult(
|
|
"noop",
|
|
"Conversation is below the manual compact threshold "
|
|
f"({display_percent}% < {_MANUAL_COMPACT_MIN_PERCENT}%).",
|
|
tokens_before=display_tokens,
|
|
context_window=context_window,
|
|
context_percent=display_percent,
|
|
)
|
|
|
|
cutoff = middleware._determine_cutoff_index(effective)
|
|
if cutoff == 0:
|
|
return CompactResult(
|
|
"noop",
|
|
f"Conversation (~{display_tokens:,} tokens) is within the retention budget.",
|
|
tokens_before=display_tokens,
|
|
context_window=context_window,
|
|
context_percent=display_percent,
|
|
)
|
|
|
|
to_summarize, to_keep = middleware._partition_messages(effective, cutoff)
|
|
|
|
tokens_summarized = count_tokens_approximately(to_summarize)
|
|
tokens_kept = count_tokens_approximately(to_keep)
|
|
tokens_before = tokens_summarized + tokens_kept
|
|
|
|
# Skip if savings would be negligible — compacting ≤2 messages with
|
|
# <2% of total tokens prevents the infinite 1-message-at-a-time loop
|
|
# that occurs when the conversation sits just above the keep budget.
|
|
_MIN_COMPACT_MESSAGES = 3
|
|
_MIN_COMPACT_TOKEN_FRACTION = 0.02
|
|
if (
|
|
len(to_summarize) < _MIN_COMPACT_MESSAGES
|
|
and tokens_summarized < tokens_before * _MIN_COMPACT_TOKEN_FRACTION
|
|
):
|
|
return CompactResult(
|
|
"noop",
|
|
f"Nothing to compact — only {len(to_summarize)} message(s) "
|
|
f"({tokens_summarized:,} tokens) would be summarized, "
|
|
f"not worth the overhead.",
|
|
tokens_before=display_tokens,
|
|
context_window=context_window,
|
|
context_percent=display_percent,
|
|
)
|
|
|
|
# Generate summary (LLM call)
|
|
summary = await middleware._acreate_summary(to_summarize)
|
|
|
|
# Inject thread_id into LangGraph contextvar so _get_thread_id() finds it
|
|
# (compact runs outside a runnable context, so get_config() would fail
|
|
# and the middleware would generate a random "session_xxx" filename instead
|
|
# of reusing the real thread_id).
|
|
from langgraph.config import var_child_runnable_config
|
|
|
|
_token = var_child_runnable_config.set(config)
|
|
|
|
# Offload old messages to backend
|
|
file_path: str | None = None
|
|
try:
|
|
file_path = await middleware._aoffload_to_backend(backend, to_summarize)
|
|
except Exception:
|
|
pass # non-fatal — proceed without offloaded history
|
|
finally:
|
|
var_child_runnable_config.reset(_token)
|
|
|
|
from langchain_core.messages import HumanMessage
|
|
|
|
summary_msg = cast(
|
|
HumanMessage,
|
|
middleware._build_new_messages_with_path(summary, file_path)[0],
|
|
)
|
|
|
|
# Compute token savings (message-level, used for pct calculation)
|
|
tokens_summary = count_tokens_approximately([summary_msg])
|
|
tokens_after = tokens_summary + tokens_kept
|
|
pct = (
|
|
round((tokens_before - tokens_after) / tokens_before * 100)
|
|
if tokens_before > 0
|
|
else 0
|
|
)
|
|
|
|
# Adjust display totals: preserve real overhead (system + tools) by
|
|
# offsetting from input_tokens_hint rather than using bare message counts.
|
|
msg_reduction = tokens_before - tokens_after # how many message tokens saved
|
|
display_before = display_tokens
|
|
display_after = max(0, display_tokens - msg_reduction)
|
|
display_after_percent = _percent_used(display_after, context_window)
|
|
|
|
# Append savings note to summary message for model awareness
|
|
savings_note = (
|
|
f"\n\n{len(to_summarize)} messages were compacted "
|
|
f"({tokens_summarized:,} → {tokens_summary:,} tokens). "
|
|
f"Total context: {display_before:,} → {display_after:,} tokens "
|
|
f"({pct}% decrease), "
|
|
f"{len(to_keep)} messages unchanged."
|
|
)
|
|
summary_msg.content += savings_note
|
|
|
|
state_cutoff = middleware._compute_state_cutoff(event, cutoff)
|
|
|
|
new_event: SummarizationEvent = {
|
|
"cutoff_index": state_cutoff,
|
|
"summary_message": summary_msg,
|
|
"file_path": file_path,
|
|
}
|
|
|
|
await graph_gateway.update_state_values(
|
|
target,
|
|
thread_id,
|
|
{"_summarization_event": new_event},
|
|
)
|
|
|
|
return CompactResult(
|
|
"ok",
|
|
f"Compacted {len(to_summarize)} messages "
|
|
f"({display_before:,} → {display_after:,} tokens, {pct}% decrease)",
|
|
messages_compacted=len(to_summarize),
|
|
messages_kept=len(to_keep),
|
|
tokens_before=display_before,
|
|
tokens_after=display_after,
|
|
tokens_summarized=tokens_summarized,
|
|
tokens_summary=tokens_summary,
|
|
pct_decrease=pct,
|
|
context_window=context_window,
|
|
context_percent=display_after_percent,
|
|
summary_text=summary,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Serve helpers
|
|
# =============================================================================
|
|
|
|
_serve_logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ServeRuntimeState:
|
|
"""Mutable serve-mode runtime shared by the poll loop and slash callbacks."""
|
|
|
|
agent: "CompiledStateGraph"
|
|
thread_id: str
|
|
workspace_dir: str | None
|
|
config: "EvoScientistConfig | None"
|
|
runtime_gateways: RuntimeGateways
|
|
async_runtime: AsyncRuntime
|
|
resume_warning_thread_id: str | None = None
|
|
|
|
def set_agent(
|
|
self,
|
|
agent: "CompiledStateGraph",
|
|
channel_runtime: ChannelRuntime | None,
|
|
) -> None:
|
|
self.agent = agent
|
|
if channel_runtime is not None:
|
|
channel_runtime.agent = agent
|
|
|
|
def set_thread_id(
|
|
self,
|
|
thread_id: str,
|
|
channel_runtime: ChannelRuntime | None,
|
|
*,
|
|
forget_previous_origin: bool = True,
|
|
) -> None:
|
|
old_thread_id = self.thread_id
|
|
if forget_previous_origin:
|
|
forget_channel_origin(old_thread_id)
|
|
self.thread_id = thread_id
|
|
if channel_runtime is not None:
|
|
channel_runtime.thread_id = thread_id
|
|
|
|
|
|
def _make_serve_start_new_session_cb(
|
|
runtime_state: ServeRuntimeState,
|
|
channel_runtime: ChannelRuntime | None = None,
|
|
):
|
|
"""Build the ``start_new_session_cb`` used by serve mode.
|
|
|
|
``/new`` delegates session rotation entirely to this callback: it
|
|
does not mutate ``ctx.thread_id`` itself, it just calls
|
|
``ctx.ui.start_new_session()`` and expects the surface to issue a
|
|
fresh thread id. Without a wired callback the channel user gets
|
|
``ChannelCommandUI``'s fallback "restart the channel link" message
|
|
and nothing actually rotates. This helper generates a new thread
|
|
id, updates the shared runtime state, and syncs the channel runtime so
|
|
subsequent messages land on the new thread.
|
|
"""
|
|
|
|
async def _cb() -> None:
|
|
new_tid = await runtime_state.runtime_gateways.graph_gateway.create_thread(
|
|
GraphTarget(workspace_dir=runtime_state.workspace_dir)
|
|
)
|
|
runtime_state.set_thread_id(new_tid, channel_runtime)
|
|
console.print(f"[dim][serve] New thread: {new_tid}[/dim]")
|
|
|
|
return _cb
|
|
|
|
|
|
def _serve_resume_config(
|
|
runtime_state: ServeRuntimeState,
|
|
config: "EvoScientistConfig | None",
|
|
) -> "EvoScientistConfig | None":
|
|
"""Return the effective config to use for serve-mode resume sync."""
|
|
return config if config is not None else runtime_state.config
|
|
|
|
|
|
async def _apply_serve_resume_state(
|
|
runtime_state: ServeRuntimeState,
|
|
channel_runtime: ChannelRuntime | None,
|
|
*,
|
|
thread_id: str,
|
|
workspace_dir: str | None,
|
|
config: "EvoScientistConfig | None" = None,
|
|
) -> None:
|
|
"""Adopt a resumed thread/workspace into serve-mode runtime state.
|
|
|
|
Workspace-bound resources are rebuilt and synced before mutating the shared
|
|
state. The agent is loaded before syncing the external server so a load
|
|
failure cannot move the server away from the currently active session.
|
|
"""
|
|
import asyncio
|
|
|
|
old_workspace = runtime_state.workspace_dir
|
|
new_workspace = (
|
|
workspace_dir if workspace_dir and workspace_dir != old_workspace else None
|
|
)
|
|
workspace_update: tuple[str, CompiledStateGraph] | None = None
|
|
|
|
if new_workspace is not None:
|
|
effective_config = _serve_resume_config(runtime_state, config)
|
|
if effective_config is None:
|
|
raise RuntimeError(
|
|
"Cannot resume into a different workspace in serve mode without "
|
|
"the effective configuration."
|
|
)
|
|
try:
|
|
new_agent = await asyncio.to_thread(
|
|
_load_agent,
|
|
workspace_dir=new_workspace,
|
|
config=effective_config,
|
|
runtime=runtime_state.async_runtime,
|
|
)
|
|
await _sync_background_agent_server_workspace(
|
|
effective_config,
|
|
workspace_dir=new_workspace,
|
|
)
|
|
workspace_update = (new_workspace, new_agent)
|
|
except Exception:
|
|
if old_workspace:
|
|
set_active_workspace(old_workspace)
|
|
raise
|
|
|
|
old_thread_id = runtime_state.thread_id
|
|
thread_changed = thread_id != old_thread_id
|
|
if thread_changed:
|
|
runtime_state.set_thread_id(thread_id, channel_runtime)
|
|
|
|
if workspace_update is not None:
|
|
updated_workspace, updated_agent = workspace_update
|
|
runtime_state.workspace_dir = updated_workspace
|
|
runtime_state.set_agent(updated_agent, channel_runtime)
|
|
|
|
|
|
def _make_serve_handle_session_resume_cb(
|
|
runtime_state: ServeRuntimeState,
|
|
channel_runtime: ChannelRuntime | None = None,
|
|
*,
|
|
config: "EvoScientistConfig | None" = None,
|
|
):
|
|
"""Build the ChannelCommandUI resume callback for serve mode."""
|
|
|
|
async def _cb(thread_id: str, workspace_dir: str | None = None) -> None:
|
|
old_thread_id = runtime_state.thread_id
|
|
await _apply_serve_resume_state(
|
|
runtime_state,
|
|
channel_runtime,
|
|
thread_id=thread_id,
|
|
workspace_dir=workspace_dir,
|
|
config=config,
|
|
)
|
|
if thread_id != old_thread_id:
|
|
runtime_state.resume_warning_thread_id = thread_id
|
|
|
|
return _cb
|
|
|
|
|
|
def _make_serve_cmd_completed_hook(
|
|
runtime_state: ServeRuntimeState,
|
|
channel_runtime: ChannelRuntime | None = None,
|
|
*,
|
|
config: "EvoScientistConfig | None" = None,
|
|
):
|
|
"""Build the ``on_cmd_completed`` hook used by serve mode.
|
|
|
|
Adopts ``/model`` agent swaps and ``/resume`` thread/workspace
|
|
swaps back into ``runtime_state`` so the outer poll loop picks up
|
|
the new handles on subsequent messages. Also keeps
|
|
``channel_runtime`` in sync so the bus sees the new values.
|
|
|
|
For ``/resume`` specifically, surface a user-visible warning via
|
|
``ctx.ui``: serve uses ``InMemorySaver`` (not the SQLite
|
|
checkpointer the interactive CLI uses), so historical state for
|
|
any persisted thread is not available — the resumed thread will
|
|
start fresh. Without this the ``/resume`` command appears to
|
|
succeed silently from the channel user's POV.
|
|
|
|
Extracted from ``_serve_process_message`` so it can be unit tested
|
|
without spinning up the whole serve loop.
|
|
"""
|
|
|
|
async def _hook(
|
|
ctx: CommandContext,
|
|
original_agent: "CompiledStateGraph",
|
|
cmd: Command,
|
|
) -> None:
|
|
if ctx.agent is not None and ctx.agent is not original_agent:
|
|
runtime_state.set_agent(ctx.agent, channel_runtime)
|
|
|
|
old_thread_id = runtime_state.thread_id
|
|
resume_warning_thread_id = runtime_state.resume_warning_thread_id
|
|
runtime_state.resume_warning_thread_id = None
|
|
|
|
# ``/resume`` mutates ``ctx.thread_id`` directly (its UI callback
|
|
# is a no-op in serve mode since there's no REPL to reset). Pick
|
|
# up the new id here so subsequent messages run on the resumed
|
|
# thread instead of the one captured at serve startup. A bare
|
|
# ``/resume`` with no argument just prints usage and leaves
|
|
# ``ctx.thread_id`` unchanged — ``thread_changed`` gates both
|
|
# the adoption and the user-facing warning so neither fires in
|
|
# that case.
|
|
new_tid = ctx.thread_id
|
|
if cmd.name == "/resume":
|
|
await _apply_serve_resume_state(
|
|
runtime_state,
|
|
channel_runtime,
|
|
thread_id=new_tid,
|
|
workspace_dir=ctx.workspace_dir,
|
|
config=config,
|
|
)
|
|
else:
|
|
thread_changed = new_tid != old_thread_id
|
|
if thread_changed:
|
|
runtime_state.set_thread_id(new_tid, channel_runtime)
|
|
|
|
thread_changed = new_tid != old_thread_id
|
|
|
|
# Surface the in-memory-state limitation to the channel user
|
|
# for ``/resume`` so the missing history isn't silent. Flush
|
|
# is required because ``cmd_manager.execute`` already flushed
|
|
# the command's own output before calling this hook.
|
|
if cmd.name == "/resume" and (
|
|
thread_changed or resume_warning_thread_id == new_tid
|
|
):
|
|
try:
|
|
ctx.ui.append_system(
|
|
"Note: serve mode uses in-memory state — "
|
|
f"thread {new_tid[:8]} starts without prior history.",
|
|
style="yellow",
|
|
)
|
|
await ctx.ui.flush()
|
|
except Exception: # pragma: no cover — defensive
|
|
pass
|
|
|
|
return _hook
|
|
|
|
|
|
def _serve_process_message(
|
|
msg: ChannelMessage,
|
|
*,
|
|
runtime_state: ServeRuntimeState,
|
|
model: str | None,
|
|
workspace_dir: str,
|
|
show_thinking: bool,
|
|
on_cmd_completed: Callable[..., Awaitable[None]] | None = None,
|
|
handle_session_resume_cb: Callable[..., Awaitable[None]] | None = None,
|
|
start_new_session_cb: Callable[[], Awaitable[None]] | None = None,
|
|
channel_runtime: ChannelRuntime | None = None,
|
|
) -> None:
|
|
"""Process a single channel message in headless serve mode.
|
|
|
|
Headless equivalent of interactive.py's ``_process_channel_message``.
|
|
No CLI prompt manipulation — just log lines for monitoring.
|
|
|
|
``runtime_state`` is shared with the outer ``serve()`` loop.
|
|
``on_cmd_completed`` (the agent-swap / session-adoption hook) and
|
|
``start_new_session_cb`` (thread rotation for ``/new``) are
|
|
constructed once in ``serve()`` — if omitted, they're rebuilt per
|
|
message (backward compat for existing tests). ``/resume`` lands
|
|
via the ``on_cmd_completed`` hook because the command mutates
|
|
``ctx.thread_id`` / ``ctx.workspace_dir`` directly.
|
|
"""
|
|
from .channel import _bus_loop
|
|
from .tui_runtime import run_streaming
|
|
|
|
runtime_gateways = runtime_state.runtime_gateways
|
|
|
|
if not _claim_or_complete_channel_request(msg):
|
|
return
|
|
|
|
remember_channel_origin(runtime_state.thread_id, msg)
|
|
|
|
runtime_workspace = runtime_state.workspace_dir or workspace_dir
|
|
|
|
console.print(
|
|
f"[dim][{msg.channel_type}] {msg.sender}: {escape(msg.content[:80])}[/dim]"
|
|
)
|
|
|
|
# -- channel callback helpers (same pattern as interactive.py) --
|
|
|
|
pending_channel_sends = PendingChannelSends(_bus_loop, _serve_logger)
|
|
|
|
def _send_to_channel(coro, label: str, timeout: int = 15) -> None:
|
|
pending_channel_sends.submit(coro, label, timeout)
|
|
|
|
def _send_thinking(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(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(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 _hitl_prompt(action_requests: list) -> list[dict] | None:
|
|
return channel_hitl_prompt(action_requests, msg)
|
|
|
|
def _ask_user_prompt(ask_user_data: dict) -> dict:
|
|
return channel_ask_user_prompt(ask_user_data, msg)
|
|
|
|
# ---- Slash command dispatch (cmd_manager, not the agent) ----
|
|
# Headless equivalent of the Rich CLI / TUI slash branch so channel
|
|
# commands like ``/evoskills`` actually execute in serve mode instead
|
|
# of being fed to the LLM as a plain prompt. ``await_agent_ready`` is
|
|
# None because the agent is always loaded before the serve loop polls.
|
|
# Slash commands run on the application-owned runtime. The main thread
|
|
# remains the signal owner while command coroutines share one stable loop.
|
|
try:
|
|
_slash_handled = False
|
|
_slash_error: Exception | None = None
|
|
try:
|
|
async_runtime = runtime_state.async_runtime
|
|
_slash_handled = async_runtime.run_sync(
|
|
lambda: dispatch_channel_slash_command(
|
|
msg,
|
|
agent=runtime_state.agent,
|
|
thread_id=runtime_state.thread_id,
|
|
workspace_dir=runtime_workspace,
|
|
checkpointer=None,
|
|
append_system=lambda t, s="dim": console.print(t, style=s),
|
|
start_new_session_cb=start_new_session_cb
|
|
or _make_serve_start_new_session_cb(
|
|
runtime_state,
|
|
channel_runtime,
|
|
),
|
|
handle_session_resume_cb=handle_session_resume_cb
|
|
or _make_serve_handle_session_resume_cb(
|
|
runtime_state,
|
|
channel_runtime,
|
|
),
|
|
on_cmd_completed=on_cmd_completed
|
|
or _make_serve_cmd_completed_hook(
|
|
runtime_state,
|
|
channel_runtime,
|
|
config=runtime_state.config,
|
|
),
|
|
channel_runtime=channel_runtime,
|
|
graph_gateway=runtime_gateways.graph_gateway,
|
|
async_runtime=async_runtime,
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
_slash_error = exc
|
|
_serve_logger.exception("Slash dispatch failed for %s", msg.channel_type)
|
|
|
|
if _slash_error is not None:
|
|
_set_channel_response(msg.msg_id, f"Command error: {_slash_error}")
|
|
console.print(
|
|
f"[red]Slash command error: {escape(str(_slash_error))}[/red]"
|
|
)
|
|
return
|
|
|
|
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.
|
|
remember_channel_origin(runtime_state.thread_id, msg)
|
|
console.print(f"[dim][{msg.channel_type}] Replied to {msg.sender}[/dim]")
|
|
return
|
|
|
|
meta = build_metadata(runtime_workspace, model)
|
|
try:
|
|
response = run_streaming(
|
|
ui_backend="cli",
|
|
agent=runtime_state.agent,
|
|
message=msg.content,
|
|
thread_id=runtime_state.thread_id,
|
|
show_thinking=show_thinking,
|
|
interactive=True,
|
|
metadata=meta,
|
|
on_thinking=_send_thinking,
|
|
on_todo=_send_todo,
|
|
on_file_write=_send_media,
|
|
hitl_prompt_fn=_hitl_prompt,
|
|
ask_user_prompt_fn=_ask_user_prompt,
|
|
cancel_scope=_channel_message_cancel_scope(msg),
|
|
gateway=runtime_gateways.graph_gateway,
|
|
runtime=runtime_state.async_runtime,
|
|
)
|
|
except Exception as e:
|
|
response = f"Error: {e}"
|
|
console.print(f"[red]Serve error: {e}[/red]")
|
|
|
|
pending_channel_sends.settle()
|
|
_set_channel_response(msg.msg_id, response)
|
|
console.print(f"[dim][{msg.channel_type}] Replied to {msg.sender}[/dim]")
|
|
finally:
|
|
_complete_channel_request(msg.msg_id)
|
|
|
|
|
|
# =============================================================================
|
|
# Serve command (headless mode)
|
|
# =============================================================================
|
|
|
|
|
|
def _serve_drain_notifications(
|
|
*,
|
|
runtime_state: ServeRuntimeState,
|
|
model: str | None,
|
|
workspace_dir: str,
|
|
show_thinking: bool,
|
|
) -> None:
|
|
"""Drain the async-task notification queue in headless serve mode.
|
|
|
|
Mirrors the Rich CLI's ``_check_channel_queue`` notification path.
|
|
Uses a dedicated event loop (same pattern as serve mode's slash dispatch).
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
from .tui_runtime import run_streaming
|
|
|
|
def _run_notification_message(text: str, notifs: list) -> None:
|
|
"""Synchronous wrapper: run the agent on the synthetic notification text."""
|
|
# Render the per-task visual frame (matches CLI/TUI aesthetic).
|
|
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)
|
|
# Use the current workspace from runtime_state (updated by /resume's
|
|
# session-rebind callback), falling back to the startup value.
|
|
runtime_workspace = runtime_state.workspace_dir or workspace_dir
|
|
meta = build_metadata(runtime_workspace, model)
|
|
tid = runtime_state.thread_id
|
|
try:
|
|
response = run_streaming(
|
|
ui_backend="cli",
|
|
agent=runtime_state.agent,
|
|
message=text,
|
|
thread_id=tid,
|
|
show_thinking=show_thinking,
|
|
interactive=True,
|
|
metadata=meta,
|
|
gateway=runtime_state.runtime_gateways.graph_gateway,
|
|
runtime=runtime_state.async_runtime,
|
|
)
|
|
except Exception as exc:
|
|
_serve_logger.warning("Notification agent turn failed: %s", exc)
|
|
return
|
|
if publish_to_channel_origin(tid, response or ""):
|
|
# Mirror a normal channel turn's closing "Replied to" line so the
|
|
# forwarded notification reads as terminated in the serve log.
|
|
origin = get_channel_origin(tid)
|
|
if origin is not None:
|
|
console.print(
|
|
f"[dim][{origin.channel_type}] Replied to "
|
|
f"{origin.sender or origin.chat_id}[/dim]"
|
|
)
|
|
|
|
async def _run_notification_message_async(text: str, notifs: list) -> None:
|
|
await _aio.to_thread(_run_notification_message, text, notifs)
|
|
|
|
async def _read_async_tasks() -> async_notifier.AsyncTasksState:
|
|
thread_id = runtime_state.thread_id
|
|
if not thread_id:
|
|
return {}
|
|
return await async_notifier.read_async_tasks_from_gateway(
|
|
runtime_state.runtime_gateways.graph_gateway,
|
|
GraphTarget(
|
|
local_graph=runtime_state.agent,
|
|
workspace_dir=runtime_state.workspace_dir,
|
|
),
|
|
thread_id,
|
|
)
|
|
|
|
async def _consume() -> None:
|
|
await async_notifier.consume_notifications(
|
|
run_message=_run_notification_message_async,
|
|
read_async_tasks_state=_read_async_tasks,
|
|
current_thread_id=runtime_state.thread_id,
|
|
)
|
|
|
|
try:
|
|
runtime_state.async_runtime.run_sync(_consume)
|
|
except Exception as exc:
|
|
_serve_logger.warning("Notification drain failed: %s", exc)
|
|
|
|
|
|
@app.command()
|
|
def serve(
|
|
ctx: typer.Context,
|
|
no_thinking: bool = typer.Option(
|
|
False, "--no-thinking", help="Disable thinking relay to channels"
|
|
),
|
|
workdir: str | None = typer.Option(
|
|
None, "--workdir", help="Override workspace directory"
|
|
),
|
|
auto_approve: bool = typer.Option(
|
|
False,
|
|
"--auto-approve",
|
|
help="Skip tool approval prompts for HITL actions",
|
|
),
|
|
auto_mode: bool = typer.Option(
|
|
False,
|
|
"--auto-mode",
|
|
help="Run unattended: skip ask_user and tool approval prompts",
|
|
),
|
|
ask_user: bool = typer.Option(
|
|
False,
|
|
"--ask-user",
|
|
help="Enable agent to ask clarifying questions about your research preferences",
|
|
),
|
|
dangerous: bool = typer.Option(
|
|
False,
|
|
"--dangerous",
|
|
help="DANGEROUS: real-filesystem access (no workspace confinement); implies --auto-approve",
|
|
),
|
|
debug: bool = typer.Option(
|
|
False,
|
|
"--debug",
|
|
help="Enable debug logging and channel trace output in serve mode",
|
|
),
|
|
):
|
|
"""Run EvoScientist in headless mode -- channels only, no interactive prompt.
|
|
|
|
Starts all configured channels and processes messages via the agent.
|
|
Press Ctrl+C to shut down.
|
|
"""
|
|
from ..config import apply_config_to_env, get_effective_config
|
|
|
|
cli_overrides = {}
|
|
if auto_approve:
|
|
cli_overrides["auto_approve"] = True
|
|
if auto_mode:
|
|
cli_overrides["auto_mode"] = True
|
|
cli_overrides["auto_approve"] = True
|
|
cli_overrides["enable_ask_user"] = False
|
|
elif ask_user:
|
|
cli_overrides["enable_ask_user"] = True
|
|
if dangerous:
|
|
cli_overrides["dangerous_mode"] = True
|
|
if debug:
|
|
cli_overrides["log_level"] = "DEBUG"
|
|
cli_overrides["channel_debug_tracing"] = True
|
|
config = get_effective_config(cli_overrides)
|
|
async_runtime = _get_cli_async_runtime(ctx)
|
|
if debug:
|
|
os.environ["EVOSCIENTIST_LOG_LEVEL"] = "DEBUG"
|
|
os.environ["EVOSCIENTIST_CHANNEL_DEBUG_TRACING"] = "true"
|
|
apply_config_to_env(config)
|
|
if debug:
|
|
_configure_logging()
|
|
|
|
# Auto-start ccproxy if any provider uses OAuth mode
|
|
_ccproxy_proc_serve = None
|
|
if config.anthropic_auth_mode == "oauth" or config.openai_auth_mode == "oauth":
|
|
try:
|
|
from ..ccproxy_manager import maybe_start_ccproxy, stop_ccproxy
|
|
|
|
_ccproxy_proc_serve = maybe_start_ccproxy(config)
|
|
if _ccproxy_proc_serve:
|
|
import atexit
|
|
|
|
atexit.register(stop_ccproxy, _ccproxy_proc_serve)
|
|
except RuntimeError as exc:
|
|
console.print(f"[red]{exc}[/red]")
|
|
raise typer.Exit(1) from exc
|
|
|
|
if not config.channel_enabled:
|
|
console.print("[red]No channels configured.[/red]")
|
|
console.print("[dim]Run [bold]evosci channel setup[/bold] first.[/dim]")
|
|
raise typer.Exit(1)
|
|
|
|
effective_channel_thinking = config.channel_send_thinking and (not no_thinking)
|
|
if workdir:
|
|
ws = os.path.abspath(os.path.expanduser(workdir))
|
|
elif config.default_workdir:
|
|
ws = os.path.abspath(os.path.expanduser(config.default_workdir))
|
|
else:
|
|
ws = os.getcwd()
|
|
os.makedirs(ws, exist_ok=True)
|
|
set_workspace_root(ws)
|
|
ensure_dirs()
|
|
|
|
# Auto-start langgraph dev (after workspace resolution, so deployed
|
|
# async sub-agents inherit the CLI's workspace via EVOSCIENTIST_WORKSPACE_DIR).
|
|
_ensure_async_subagent_server(config, workspace_dir=ws)
|
|
|
|
if config.dangerous_mode:
|
|
from ._constants import DANGEROUS_BANNER_LABEL, DANGEROUS_BANNER_MESSAGE
|
|
|
|
console.print(
|
|
f"[bold white on red] ⚠ {DANGEROUS_BANNER_LABEL} [/bold white on red] "
|
|
f"[bold red]{DANGEROUS_BANNER_MESSAGE}[/bold red]"
|
|
)
|
|
console.print("[dim]Loading agent...[/dim]")
|
|
agent = _load_agent(workspace_dir=ws, config=config, runtime=async_runtime)
|
|
|
|
runtime_gateways = create_runtime_gateways()
|
|
tid = async_runtime.run_sync(
|
|
lambda: runtime_gateways.graph_gateway.create_thread(
|
|
GraphTarget(workspace_dir=ws)
|
|
)
|
|
)
|
|
|
|
# Mutable runtime shared with _serve_process_message so channel slash
|
|
# commands can update the active agent/thread/workspace for subsequent
|
|
# messages.
|
|
runtime_state = ServeRuntimeState(
|
|
agent=agent,
|
|
thread_id=tid,
|
|
workspace_dir=ws,
|
|
config=config,
|
|
runtime_gateways=runtime_gateways,
|
|
async_runtime=async_runtime,
|
|
)
|
|
|
|
channel_runtime = ChannelRuntime(agent=agent, thread_id=tid)
|
|
|
|
# Build the slash-dispatch callbacks once; the poll loop reuses
|
|
# them for every inbound message. Without this hoist each message
|
|
# would allocate a fresh closure pair.
|
|
_serve_on_cmd_completed = _make_serve_cmd_completed_hook(
|
|
runtime_state, channel_runtime, config=config
|
|
)
|
|
_serve_handle_session_resume_cb = _make_serve_handle_session_resume_cb(
|
|
runtime_state, channel_runtime, config=config
|
|
)
|
|
_serve_start_new_session_cb = _make_serve_start_new_session_cb(
|
|
runtime_state, channel_runtime
|
|
)
|
|
|
|
_start_channels_bus_mode(
|
|
config,
|
|
agent,
|
|
tid,
|
|
send_thinking=effective_channel_thinking,
|
|
)
|
|
console.print("[green]Serve mode started (bus mode).[/green]")
|
|
|
|
console.print(f"[dim]Thread: {tid}[/dim]")
|
|
console.print(f"[dim]Workspace: {_shorten_path(ws)}[/dim]")
|
|
console.print("[dim]Press Ctrl+C to stop.[/dim]\n")
|
|
|
|
# Explicit SIGINT/SIGTERM handlers. Python's default SIGINT raises
|
|
# KeyboardInterrupt in the main thread, which ought to unblock
|
|
# ``_message_queue.get(timeout=...)`` and land in the ``except``
|
|
# below — but edge cases (e.g. an asyncio ``set_wakeup_fd`` left
|
|
# dangling by a nested ``asyncio.run``) can silently swallow the
|
|
# signal. Setting a ``threading.Event`` in addition gives us a
|
|
# second gate that the poll loop always observes.
|
|
import signal
|
|
import threading
|
|
|
|
shutdown_event = threading.Event()
|
|
no_active_cancel_scope = object()
|
|
active_cancel_scope: str | object | None = no_active_cancel_scope
|
|
|
|
def _handle_shutdown(signum: int, _frame: Any) -> None:
|
|
shutdown_event.set()
|
|
# Cancelling the owned asyncio task is not enough when it is awaiting a
|
|
# blocking execute call: the executor thread and its isolated process
|
|
# group keep running until the matching stream event is set. Request
|
|
# scope cancellation before KeyboardInterrupt unwinds message cleanup
|
|
# (which discards that scope). SIGTERM also needs this to unblock the
|
|
# synchronous serve call so the poll loop can observe shutdown_event.
|
|
scope = active_cancel_scope
|
|
if scope is not no_active_cancel_scope:
|
|
from ..stream.display import request_stream_cancel
|
|
|
|
request_stream_cancel(cast(str | None, scope))
|
|
# Fall back to Python's default SIGINT behavior (raises
|
|
# KeyboardInterrupt) so blocking I/O inside ``run_streaming``
|
|
# is still interrupted. For SIGTERM there's no default that
|
|
# raises, so the event check below is the only gate.
|
|
if signum == signal.SIGINT:
|
|
signal.default_int_handler(signum, _frame)
|
|
|
|
_orig_sigint = signal.signal(signal.SIGINT, _handle_shutdown)
|
|
_orig_sigterm = signal.signal(signal.SIGTERM, _handle_shutdown)
|
|
|
|
try:
|
|
while not shutdown_event.is_set():
|
|
try:
|
|
msg = _message_queue.get(timeout=0.5)
|
|
except queue.Empty:
|
|
msg = None
|
|
if shutdown_event.is_set():
|
|
break
|
|
if msg is not None:
|
|
active_cancel_scope = _channel_message_cancel_scope(msg)
|
|
try:
|
|
_serve_process_message(
|
|
msg,
|
|
runtime_state=runtime_state,
|
|
model=config.model,
|
|
workspace_dir=ws,
|
|
show_thinking=effective_channel_thinking,
|
|
on_cmd_completed=_serve_on_cmd_completed,
|
|
handle_session_resume_cb=_serve_handle_session_resume_cb,
|
|
start_new_session_cb=_serve_start_new_session_cb,
|
|
channel_runtime=channel_runtime,
|
|
)
|
|
except KeyboardInterrupt:
|
|
shutdown_event.set()
|
|
break
|
|
finally:
|
|
active_cancel_scope = no_active_cancel_scope
|
|
|
|
# Poll notification queue when idle (no channel message was pending).
|
|
if async_notifier.has_pending_notifications(runtime_state.thread_id):
|
|
# Notification turns use the default stream cancellation scope.
|
|
active_cancel_scope = None
|
|
try:
|
|
_serve_drain_notifications(
|
|
runtime_state=runtime_state,
|
|
model=config.model,
|
|
workspace_dir=ws,
|
|
show_thinking=effective_channel_thinking,
|
|
)
|
|
finally:
|
|
active_cancel_scope = no_active_cancel_scope
|
|
except KeyboardInterrupt:
|
|
shutdown_event.set()
|
|
finally:
|
|
signal.signal(signal.SIGINT, _orig_sigint)
|
|
signal.signal(signal.SIGTERM, _orig_sigterm)
|
|
console.print("\n[dim]Shutting down...[/dim]")
|
|
_channels_stop(runtime=channel_runtime)
|
|
console.print("[dim]Stopped.[/dim]")
|
|
|
|
|
|
# =============================================================================
|
|
# Config commands
|
|
# =============================================================================
|
|
|
|
|
|
@config_app.callback(invoke_without_command=True)
|
|
def config_callback(ctx: typer.Context):
|
|
"""Configuration management commands"""
|
|
if ctx.invoked_subcommand is None:
|
|
config_list()
|
|
|
|
|
|
@config_app.command("list")
|
|
def config_list():
|
|
"""List all configuration values"""
|
|
from ..config import get_config_path, list_config
|
|
|
|
config_data = list_config()
|
|
|
|
table = Table(title="EvoScientist Configuration", show_header=True)
|
|
table.add_column("Setting", style="cyan")
|
|
table.add_column("Value")
|
|
|
|
# Mask API keys
|
|
def format_value(key: str, value: Any) -> str:
|
|
if "api_key" in key and value:
|
|
return "***" + str(value)[-4:] if len(str(value)) > 4 else "***"
|
|
if value == "":
|
|
return "[dim](not set)[/dim]"
|
|
return str(value)
|
|
|
|
for key, value in config_data.items():
|
|
table.add_row(key, format_value(key, value))
|
|
|
|
console.print(table)
|
|
console.print(f"\n[dim]Config file: {get_config_path()}[/dim]")
|
|
|
|
|
|
@config_app.command("get")
|
|
def config_get(key: str = typer.Argument(..., help="Configuration key to get")):
|
|
"""Get a single configuration value"""
|
|
from ..config import get_config_value
|
|
|
|
value = get_config_value(key)
|
|
if value is None:
|
|
console.print(f"[red]Unknown key: {key}[/red]")
|
|
raise typer.Exit(1)
|
|
|
|
# Mask API keys
|
|
if "api_key" in key and value:
|
|
display_value = "***" + str(value)[-4:] if len(str(value)) > 4 else "***"
|
|
elif value == "":
|
|
display_value = "(not set)"
|
|
else:
|
|
display_value = str(value)
|
|
|
|
console.print(f"[cyan]{key}[/cyan]: {display_value}")
|
|
|
|
|
|
@config_app.command("set")
|
|
def config_set(
|
|
key: str = typer.Argument(..., help="Configuration key to set"),
|
|
value: str = typer.Argument(..., help="New value"),
|
|
):
|
|
"""Set a single configuration value"""
|
|
from ..config import set_config_value
|
|
|
|
if set_config_value(key, value):
|
|
console.print(f"[green]Set {escape(key)}[/green]")
|
|
else:
|
|
console.print(f"[red]Could not set {escape(key)}: invalid key or value[/red]")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@config_app.command("reset")
|
|
def config_reset(
|
|
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
):
|
|
"""Reset configuration to defaults"""
|
|
from ..config import get_config_path, reset_config
|
|
|
|
config_path = get_config_path()
|
|
|
|
if not config_path.exists():
|
|
console.print("[yellow]No config file to reset.[/yellow]")
|
|
return
|
|
|
|
if not yes:
|
|
confirm = typer.confirm("Reset configuration to defaults?")
|
|
if not confirm:
|
|
console.print("[dim]Cancelled.[/dim]")
|
|
return
|
|
|
|
reset_config()
|
|
console.print("[green]Configuration reset to defaults.[/green]")
|
|
|
|
|
|
@config_app.command("path")
|
|
def config_path():
|
|
"""Show the configuration file path"""
|
|
from ..config import get_config_path
|
|
|
|
path = get_config_path()
|
|
exists = path.exists()
|
|
status = "[green]exists[/green]" if exists else "[dim]not created yet[/dim]"
|
|
console.print(f"{path} ({status})")
|
|
|
|
|
|
# =============================================================================
|
|
# MCP commands
|
|
# =============================================================================
|
|
|
|
|
|
@mcp_app.callback(invoke_without_command=True)
|
|
def mcp_callback(ctx: typer.Context):
|
|
"""MCP server management commands"""
|
|
if ctx.invoked_subcommand is None:
|
|
mcp_list()
|
|
|
|
|
|
@mcp_app.command("list")
|
|
def mcp_list():
|
|
"""List configured MCP servers"""
|
|
_mcp_list_servers()
|
|
|
|
|
|
@mcp_app.command("config")
|
|
def mcp_config(
|
|
name: str | None = typer.Argument(None, help="Server name (omit to show all)"),
|
|
):
|
|
"""Show detailed configuration for MCP servers
|
|
|
|
\b
|
|
Examples:
|
|
evosci mcp config # Show all servers in detail
|
|
evosci mcp config filesystem # Show one server
|
|
"""
|
|
status = _show_mcp_config(name or "", show_blank_line=False)
|
|
if status == "empty":
|
|
console.print(
|
|
"[dim]Add one with:[/dim] EvoSci mcp add <name> <transport> <command-or-url> [args...]"
|
|
)
|
|
return
|
|
if status == "missing":
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@mcp_app.command("add")
|
|
def mcp_add(
|
|
name: Annotated[str, typer.Argument(help="Server name")],
|
|
target: Annotated[str, typer.Argument(help="Command (stdio) or URL (http/sse)")],
|
|
args: Annotated[
|
|
list[str] | None, typer.Argument(help="Extra args for stdio command")
|
|
] = None,
|
|
transport: Annotated[
|
|
str | None,
|
|
typer.Option("--transport", "-T", help="Transport type (default: auto-detect)"),
|
|
] = None,
|
|
tools: Annotated[
|
|
str | None,
|
|
typer.Option(
|
|
"--tools",
|
|
"-t",
|
|
help="Comma-separated tool allowlist (supports wildcards: *_exa, read_*)",
|
|
),
|
|
] = None,
|
|
expose_to: Annotated[
|
|
str | None,
|
|
typer.Option("--expose-to", "-e", help="Comma-separated target agents"),
|
|
] = None,
|
|
header: Annotated[
|
|
list[str] | None,
|
|
typer.Option("--header", "-H", help="HTTP header as Key:Value (repeatable)"),
|
|
] = None,
|
|
env: Annotated[
|
|
list[str] | None,
|
|
typer.Option("--env", help="Env var as KEY=VALUE for stdio (repeatable)"),
|
|
] = None,
|
|
env_ref: Annotated[
|
|
list[str] | None,
|
|
typer.Option(
|
|
"--env-ref", help="Env var name as ${NAME} runtime ref (repeatable)"
|
|
),
|
|
] = None,
|
|
):
|
|
"""Add an MCP server to user config
|
|
|
|
\b
|
|
Transport is auto-detected: URLs default to http, commands default to stdio.
|
|
|
|
\b
|
|
Examples:
|
|
evosci mcp add sequential-thinking npx -- -y @modelcontextprotocol/server-sequential-thinking
|
|
evosci mcp add docs-langchain https://docs.langchain.com/mcp
|
|
evosci mcp add my-sse https://example.com/sse --transport sse -e research-agent
|
|
evosci mcp add brave-search npx --env-ref BRAVE_API_KEY -- -y @modelcontextprotocol/server-brave-search
|
|
"""
|
|
from ..mcp import build_mcp_add_kwargs
|
|
|
|
# Merge env and env_ref into a single dict
|
|
env_dict: dict[str, str] = {}
|
|
for e in env or []:
|
|
if "=" in e:
|
|
k, v = e.split("=", 1)
|
|
env_dict[k.strip()] = v.strip()
|
|
for ref in env_ref or []:
|
|
env_dict[ref] = "${" + ref + "}"
|
|
|
|
kwargs = build_mcp_add_kwargs(
|
|
name=name,
|
|
target=target,
|
|
extra_args=list(args) if args else None,
|
|
transport=transport,
|
|
tools=[t.strip() for t in tools.split(",") if t.strip()] if tools else None,
|
|
expose_to=[a.strip() for a in expose_to.split(",") if a.strip()]
|
|
if expose_to
|
|
else None,
|
|
headers={
|
|
k.strip(): v.strip()
|
|
for h in (header or [])
|
|
for k, v in [h.split(":", 1)]
|
|
if ":" in h
|
|
}
|
|
or None,
|
|
env=env_dict or None,
|
|
)
|
|
|
|
if not _mcp_add_server_from_kwargs(kwargs, show_reload_hint=False):
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@mcp_app.command("edit")
|
|
def mcp_edit(
|
|
name: Annotated[str, typer.Argument(help="Server name to edit")],
|
|
transport: Annotated[
|
|
str | None, typer.Option("--transport", help="New transport type")
|
|
] = None,
|
|
command: Annotated[
|
|
str | None, typer.Option("--command", help="New command (stdio)")
|
|
] = None,
|
|
url: Annotated[
|
|
str | None, typer.Option("--url", help="New URL (http/sse/websocket)")
|
|
] = None,
|
|
tools: Annotated[
|
|
str | None,
|
|
typer.Option(
|
|
"--tools",
|
|
"-t",
|
|
help="Comma-separated tool allowlist, supports wildcards ('none' to clear)",
|
|
),
|
|
] = None,
|
|
expose_to: Annotated[
|
|
str | None,
|
|
typer.Option(
|
|
"--expose-to",
|
|
"-e",
|
|
help="Comma-separated target agents ('none' to clear)",
|
|
),
|
|
] = None,
|
|
header: Annotated[
|
|
list[str] | None,
|
|
typer.Option("--header", "-H", help="HTTP header as Key:Value (repeatable)"),
|
|
] = None,
|
|
env: Annotated[
|
|
list[str] | None,
|
|
typer.Option("--env", help="Env var as KEY=VALUE for stdio (repeatable)"),
|
|
] = None,
|
|
):
|
|
"""Edit an existing MCP server in user config
|
|
|
|
\b
|
|
Examples:
|
|
evosci mcp edit filesystem --expose-to main,code-agent
|
|
evosci mcp edit filesystem -t read_file,write_file
|
|
evosci mcp edit my-api --url http://new-host:9090/mcp
|
|
evosci mcp edit my-api --tools none
|
|
"""
|
|
from ..mcp import build_mcp_edit_fields
|
|
|
|
fields = build_mcp_edit_fields(
|
|
transport=transport,
|
|
command=command,
|
|
url=url,
|
|
tools=tools,
|
|
expose_to=expose_to,
|
|
headers=header,
|
|
env=env,
|
|
)
|
|
|
|
if not _mcp_edit_server_fields(name, fields, show_reload_hint=False):
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@mcp_app.command("remove")
|
|
def mcp_remove(
|
|
name: str = typer.Argument(..., help="Server name to remove"),
|
|
):
|
|
"""Remove an MCP server from user config"""
|
|
if not _mcp_remove_server(name, show_reload_hint=False):
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@mcp_app.command("install")
|
|
def mcp_install(
|
|
source: Annotated[
|
|
str | None, typer.Argument(help="Server name or tag filter")
|
|
] = None,
|
|
):
|
|
"""Browse and install MCP servers from the registry and marketplace
|
|
|
|
\b
|
|
Examples:
|
|
evosci mcp install # Interactive browser
|
|
evosci mcp install search # Filter by 'search' tag
|
|
evosci mcp install sequential-thinking # Install by name
|
|
"""
|
|
from .mcp_install_cmd import _cmd_install_mcp
|
|
|
|
_cmd_install_mcp(source or "")
|
|
|
|
|
|
# =============================================================================
|
|
# Sessions commands — read-only diagnostics for ~/.evoscientist/sessions.db
|
|
# =============================================================================
|
|
|
|
|
|
def _format_bytes(n: int) -> str:
|
|
"""Render a byte count as a human-readable string (KB / MB / GB)."""
|
|
if n < 1024:
|
|
return f"{n} B"
|
|
units = ["KB", "MB", "GB", "TB"]
|
|
size = float(n) / 1024.0
|
|
for unit in units:
|
|
if size < 1024.0:
|
|
return f"{size:.1f} {unit}"
|
|
size /= 1024.0
|
|
return f"{size:.1f} PB"
|
|
|
|
|
|
@sessions_app.callback(invoke_without_command=True)
|
|
def sessions_callback(ctx: typer.Context):
|
|
"""Inspect and manage the sessions DB.
|
|
|
|
Running ``EvoSci sessions`` with no subcommand defaults to ``stats``
|
|
so the bare command is informative rather than silent.
|
|
"""
|
|
if ctx.invoked_subcommand is None:
|
|
sessions_stats(ctx)
|
|
|
|
|
|
@sessions_app.command("stats")
|
|
def sessions_stats(ctx: typer.Context):
|
|
"""Show DB size, thread count, total checkpoints, top heaviest threads."""
|
|
from ..sessions import db_stats
|
|
|
|
runtime = _get_cli_async_runtime(ctx)
|
|
stats = runtime.run_sync(db_stats)
|
|
|
|
table = Table(title="EvoScientist sessions DB", show_header=True)
|
|
table.add_column("Metric", style="cyan")
|
|
table.add_column("Value")
|
|
table.add_row("Path", stats["db_path"])
|
|
table.add_row("Size", _format_bytes(int(stats["size_bytes"])))
|
|
table.add_row("Threads", str(stats["thread_count"]))
|
|
table.add_row("Checkpoints", str(stats["checkpoint_count"]))
|
|
table.add_row("Writes", str(stats["write_count"]))
|
|
console.print(table)
|
|
|
|
if stats["top_threads"]:
|
|
top = Table(title="Heaviest threads (checkpoints per thread)")
|
|
top.add_column("thread_id", style="yellow")
|
|
top.add_column("checkpoints", justify="right")
|
|
for row in stats["top_threads"]:
|
|
top.add_row(str(row["thread_id"]), str(row["count"]))
|
|
console.print(top)
|
|
|
|
|
|
# =============================================================================
|
|
# Main callback (default behavior)
|
|
# =============================================================================
|
|
|
|
|
|
def _version_callback(value: bool):
|
|
if value:
|
|
typer.echo(f"EvoScientist {_pkg_version('EvoScientist')}")
|
|
raise typer.Exit()
|
|
|
|
|
|
def _is_fresh_interactive_session(prompt: str | None, thread_id: str | None) -> bool:
|
|
"""True for a brand-new interactive session — no one-shot ``-p`` prompt and
|
|
no ``--resume`` / ``--thread-id`` to continue.
|
|
|
|
This is the only case where a WebUI-configured ``EvoSci`` opens the browser
|
|
app: a one-shot or a resume has a concrete conversation to render in the
|
|
terminal, so it falls back to the Rich CLI instead.
|
|
"""
|
|
return not prompt and not thread_id
|
|
|
|
|
|
def _resolve_stream_json_auto_mode(auto_mode: bool | None, output_format: str) -> bool:
|
|
"""Resolve the effective ``--auto-mode`` for a single-shot run.
|
|
|
|
stream-json is headless, so auto-mode defaults on there when the caller did
|
|
not pass the flag (``None``); an explicit ``--auto-mode`` / ``--no-auto-mode``
|
|
always wins, and non-stream-json runs keep the historical off-by-default.
|
|
"""
|
|
if auto_mode is not None:
|
|
return auto_mode
|
|
return output_format == "stream-json"
|
|
|
|
|
|
@app.callback(invoke_without_command=True)
|
|
def _main_callback(
|
|
ctx: typer.Context,
|
|
version: bool | None = typer.Option(
|
|
None,
|
|
"-V",
|
|
"--version",
|
|
callback=_version_callback,
|
|
is_eager=True,
|
|
help="Show version and exit.",
|
|
),
|
|
mode: str | None = typer.Option(
|
|
None,
|
|
"-m",
|
|
"--mode",
|
|
help="Workspace mode: 'daemon' (persistent, default) or 'run' (isolated per-session)",
|
|
),
|
|
name: str | None = typer.Option(
|
|
None,
|
|
"-n",
|
|
"--name",
|
|
help="Name for this run (used as directory name instead of timestamp; requires --mode run)",
|
|
),
|
|
prompt: str | None = typer.Option(
|
|
None, "-p", "--prompt", help="Query to execute (single-shot mode)"
|
|
),
|
|
thread_id: str | None = typer.Option(
|
|
None,
|
|
"--resume",
|
|
"--thread-id",
|
|
help="Thread ID (or prefix) to resume a previous session.",
|
|
),
|
|
workdir: str | None = typer.Option(
|
|
None, "--workdir", help="Override workspace directory for this session"
|
|
),
|
|
use_cwd: bool = typer.Option(
|
|
False, "--use-cwd", help="Use current working directory as workspace"
|
|
),
|
|
no_thinking: bool = typer.Option(
|
|
False, "--no-thinking", help="Disable thinking display"
|
|
),
|
|
auto_approve: bool = typer.Option(
|
|
False,
|
|
"--auto-approve",
|
|
help="Skip tool approval prompts for HITL actions",
|
|
),
|
|
auto_mode: bool | None = typer.Option(
|
|
None,
|
|
"--auto-mode/--no-auto-mode",
|
|
help="Run unattended: skip ask_user and tool approval prompts "
|
|
"(default: on when --output-format stream-json)",
|
|
),
|
|
ask_user: bool = typer.Option(
|
|
False,
|
|
"--ask-user",
|
|
help="Enable agent to ask clarifying questions about your research preferences",
|
|
),
|
|
dangerous: bool = typer.Option(
|
|
False,
|
|
"--dangerous",
|
|
help="DANGEROUS: real-filesystem access (no workspace confinement); implies --auto-approve",
|
|
),
|
|
auth_mode: str | None = typer.Option(
|
|
None,
|
|
"--auth-mode",
|
|
help="Auth mode for Anthropic/OpenAI: api_key (default) or oauth (ccproxy).",
|
|
),
|
|
ui: str | None = typer.Option(
|
|
None,
|
|
"--ui",
|
|
help="UI backend: tui (default), cli, or webui.",
|
|
),
|
|
host: str | None = typer.Option(
|
|
None,
|
|
"--host",
|
|
help="Interface to bind servers to (defaults: langgraph_dev_host "
|
|
"127.0.0.1, webui_host 0.0.0.0). Sets langgraph_dev_host, which "
|
|
"applies in EVERY UI mode — the background langgraph dev backend is "
|
|
"shared by tui/cli/webui/serve — and webui_host, which only matters in "
|
|
"WebUI mode. Pass 0.0.0.0 to reach both from another machine (the "
|
|
"backend has no auth).",
|
|
),
|
|
output_format: str | None = typer.Option(
|
|
None,
|
|
"--output-format",
|
|
help=(
|
|
"Output format for single-shot (-p) mode: 'text' (default) or "
|
|
"'stream-json' (line-delimited JSON events to stdout)."
|
|
),
|
|
),
|
|
):
|
|
"""EvoScientist Agent - AI-powered research & code execution CLI"""
|
|
# If a subcommand was invoked, don't run the default behavior
|
|
if ctx.invoked_subcommand is not None:
|
|
return
|
|
|
|
async_runtime = _get_cli_async_runtime(ctx)
|
|
|
|
# Load and apply configuration
|
|
from ..config import apply_config_to_env, get_effective_config
|
|
|
|
# Resolve the output format first. In stream-json mode stdout must carry
|
|
# only JSONL, so establish the mode and redirect the console to stderr
|
|
# BEFORE anything below (ccproxy startup, validation) can print a
|
|
# human-readable line to stdout.
|
|
effective_output_format = (output_format or "text").lower()
|
|
if effective_output_format not in ("text", "stream-json"):
|
|
raise typer.BadParameter("--output-format must be 'text' or 'stream-json'")
|
|
if effective_output_format == "stream-json":
|
|
if not prompt:
|
|
raise typer.BadParameter(
|
|
"--output-format stream-json requires -p/--prompt (single-shot mode)"
|
|
)
|
|
from ..stream.json_sink import redirect_console_to_stderr
|
|
|
|
redirect_console_to_stderr()
|
|
|
|
# stream-json is headless, so auto-mode defaults on (auto-handle approval and
|
|
# ask_user gates) unless the caller explicitly passed --no-auto-mode. Without
|
|
# it the run would stall at the first gate and end without doing the work;
|
|
# --no-auto-mode is the (experimental) opt-in to receiving interrupt/ask_user
|
|
# events and driving resume yourself.
|
|
effective_auto_mode = _resolve_stream_json_auto_mode(
|
|
auto_mode, effective_output_format
|
|
)
|
|
if effective_output_format == "stream-json" and auto_mode is False:
|
|
console.print(
|
|
"[yellow]--no-auto-mode with stream-json is experimental: an "
|
|
"interrupt/ask_user event ends the run early and is not yet "
|
|
"resumable.[/yellow]"
|
|
)
|
|
|
|
# Build CLI overrides dict
|
|
cli_overrides = {}
|
|
if mode:
|
|
cli_overrides["default_mode"] = mode
|
|
if workdir:
|
|
cli_overrides["default_workdir"] = workdir
|
|
if no_thinking:
|
|
cli_overrides["show_thinking"] = False
|
|
if ui:
|
|
cli_overrides["ui_backend"] = ui
|
|
if host is not None and host.strip():
|
|
# One flag drives both servers. Note this is NOT WebUI-specific: the
|
|
# langgraph dev backend is auto-started for tui/cli/serve too (see
|
|
# _ensure_async_subagent_server), so --host narrows or widens the
|
|
# agent API in every mode. Only webui_host is WebUI-only.
|
|
cli_overrides["webui_host"] = host.strip()
|
|
cli_overrides["langgraph_dev_host"] = host.strip()
|
|
if auto_approve:
|
|
cli_overrides["auto_approve"] = True
|
|
if effective_auto_mode:
|
|
cli_overrides["auto_mode"] = True
|
|
cli_overrides["auto_approve"] = True
|
|
cli_overrides["enable_ask_user"] = False
|
|
else:
|
|
# An explicit --no-auto-mode (auto_mode is False, not None) must win over
|
|
# a config file that enables auto-mode; without this the resolved-off
|
|
# value writes nothing and silently falls back to the config default.
|
|
if auto_mode is False:
|
|
cli_overrides["auto_mode"] = False
|
|
if ask_user:
|
|
cli_overrides["enable_ask_user"] = True
|
|
if dangerous:
|
|
cli_overrides["dangerous_mode"] = True
|
|
if auth_mode:
|
|
if auth_mode not in ("api_key", "oauth"):
|
|
raise typer.BadParameter("--auth-mode must be 'api_key' or 'oauth'")
|
|
cli_overrides["anthropic_auth_mode"] = auth_mode
|
|
cli_overrides["openai_auth_mode"] = auth_mode
|
|
|
|
config = get_effective_config(cli_overrides)
|
|
apply_config_to_env(config)
|
|
|
|
# Auto-start ccproxy if any provider uses OAuth mode
|
|
_ccproxy_proc = None
|
|
if config.anthropic_auth_mode == "oauth" or config.openai_auth_mode == "oauth":
|
|
try:
|
|
from ..ccproxy_manager import maybe_start_ccproxy, stop_ccproxy
|
|
|
|
_ccproxy_proc = maybe_start_ccproxy(config)
|
|
if _ccproxy_proc:
|
|
import atexit
|
|
|
|
atexit.register(stop_ccproxy, _ccproxy_proc)
|
|
except RuntimeError as exc:
|
|
console.print(f"[red]{exc}[/red]")
|
|
raise typer.Exit(1) from exc
|
|
|
|
show_thinking = config.show_thinking if not no_thinking else False
|
|
effective_channel_thinking = config.channel_send_thinking and (not no_thinking)
|
|
|
|
# Validate mutually exclusive options
|
|
if workdir and use_cwd:
|
|
raise typer.BadParameter("Use either --workdir or --use-cwd, not both.")
|
|
|
|
if mode and (workdir or use_cwd):
|
|
raise typer.BadParameter(
|
|
"--mode cannot be combined with --workdir or --use-cwd"
|
|
)
|
|
|
|
if mode and mode not in ("run", "daemon"):
|
|
raise typer.BadParameter("--mode must be 'run' or 'daemon'")
|
|
if ui and ui.lower() not in ("cli", "tui", "webui"):
|
|
raise typer.BadParameter("--ui must be 'tui', 'cli', or 'webui'")
|
|
|
|
# --name only makes sense in run mode
|
|
if name and not (
|
|
mode == "run"
|
|
or (not mode and not workdir and not use_cwd and config.default_mode == "run")
|
|
):
|
|
raise typer.BadParameter("--name can only be used with --mode run")
|
|
|
|
# Sanitize run name: allow alphanumeric, hyphens, underscores
|
|
if name:
|
|
if not re.fullmatch(r"[A-Za-z0-9_-]+", name):
|
|
raise typer.BadParameter(
|
|
"--name may only contain letters, digits, hyphens, and underscores"
|
|
)
|
|
|
|
# Resolve effective mode from config (CLI mode already applied via overrides)
|
|
effective_mode: str | None = (
|
|
None # None means explicit --workdir/--use-cwd was used
|
|
)
|
|
|
|
# Resolve workspace directory for this session
|
|
# Priority: --workdir > --mode (explicit) > default_workdir > default_mode > cwd
|
|
# --use-cwd is kept for backward compat but is now the default behavior
|
|
if use_cwd:
|
|
workspace_dir = os.getcwd()
|
|
set_workspace_root(workspace_dir)
|
|
workspace_fixed = True
|
|
elif workdir:
|
|
workspace_dir = os.path.abspath(os.path.expanduser(workdir))
|
|
os.makedirs(workspace_dir, exist_ok=True)
|
|
set_workspace_root(workspace_dir)
|
|
workspace_fixed = True
|
|
elif mode:
|
|
# Explicit --mode overrides default_workdir
|
|
effective_mode = mode
|
|
workspace_root = config.default_workdir or os.getcwd()
|
|
workspace_root = os.path.abspath(os.path.expanduser(workspace_root))
|
|
set_workspace_root(workspace_root)
|
|
if effective_mode == "run":
|
|
runs_dir = Path(workspace_root, "runs")
|
|
session_id = (
|
|
_deduplicate_run_name(name, runs_dir)
|
|
if name
|
|
else datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
)
|
|
workspace_dir = os.path.join(runs_dir, session_id)
|
|
os.makedirs(workspace_dir, exist_ok=True)
|
|
workspace_fixed = False
|
|
else: # daemon
|
|
workspace_dir = workspace_root
|
|
workspace_fixed = True
|
|
elif config.default_workdir:
|
|
# Use configured default workdir with configured mode
|
|
workspace_root = os.path.abspath(os.path.expanduser(config.default_workdir))
|
|
set_workspace_root(workspace_root)
|
|
effective_mode = config.default_mode
|
|
if effective_mode == "run":
|
|
runs_dir = Path(workspace_root, "runs")
|
|
session_id = (
|
|
_deduplicate_run_name(name, runs_dir)
|
|
if name
|
|
else datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
)
|
|
workspace_dir = os.path.join(runs_dir, session_id)
|
|
os.makedirs(workspace_dir, exist_ok=True)
|
|
workspace_fixed = False
|
|
else: # daemon
|
|
workspace_dir = workspace_root
|
|
workspace_fixed = True
|
|
else:
|
|
effective_mode = config.default_mode
|
|
workspace_root = os.getcwd()
|
|
set_workspace_root(workspace_root)
|
|
if effective_mode == "run":
|
|
workspace_dir = _create_session_workspace(name)
|
|
workspace_fixed = False
|
|
else: # daemon mode (default) — use current directory
|
|
workspace_dir = workspace_root
|
|
workspace_fixed = True
|
|
|
|
# Ensure memory and skills subdirs exist in workspace
|
|
ensure_dirs()
|
|
|
|
# WebUI mode: instead of the in-terminal CLI/TUI, run a deploy-style
|
|
# langgraph server (full MCP + async) + the published @evoscientist/webui
|
|
# front-end (npx) in THIS terminal, then block. Reuses start_langgraph_dev
|
|
# but leaves `EvoSci deploy` untouched (it stays a clean server for external
|
|
# UIs / SDK clients).
|
|
#
|
|
# The browser app is only launched for a FRESH interactive session. With
|
|
# `-p` (one-shot) or `--resume`/`--thread-id` (continue a specific
|
|
# conversation), there is concrete terminal output to render, so fall back
|
|
# to the Rich CLI instead of opening the browser UI.
|
|
from .tui_runtime import normalize_ui_backend
|
|
|
|
if normalize_ui_backend(config.ui_backend) == "webui":
|
|
if _is_fresh_interactive_session(prompt, thread_id):
|
|
from ..deploy.webui import run_webui
|
|
|
|
run_webui(config, workspace_dir=workspace_dir)
|
|
return
|
|
config.ui_backend = "cli"
|
|
|
|
# Auto-start langgraph dev (after workspace resolution, so deployed
|
|
# async sub-agents inherit the CLI's workspace via EVOSCIENTIST_WORKSPACE_DIR).
|
|
_ensure_async_subagent_server(config, workspace_dir=workspace_dir)
|
|
|
|
if prompt:
|
|
# Single-shot mode: wrap in persistent checkpointer
|
|
import asyncio
|
|
|
|
from ..sessions import get_checkpointer
|
|
from ..stream.json_sink import stream_json
|
|
from .interactive import _wait_for_memory_workers_before_exit, cmd_run
|
|
from .resume_hint import print_resume_hint
|
|
|
|
runtime_gateways = create_runtime_gateways()
|
|
graph_gateway = runtime_gateways.graph_gateway
|
|
|
|
async def _single_shot():
|
|
async with get_checkpointer() as checkpointer:
|
|
# Resolve resume target first so a bad --resume/--thread-id
|
|
# exits before the slow _load_agent() provider setup.
|
|
if thread_id:
|
|
resolution = await graph_gateway.resolve_thread(thread_id)
|
|
if resolution.thread_id:
|
|
tid = resolution.thread_id
|
|
elif resolution.matches:
|
|
console.print(
|
|
f"[yellow]Ambiguous thread ID '{escape(thread_id)}'. Matches:[/yellow]"
|
|
)
|
|
for s in resolution.matches:
|
|
console.print(f" [cyan]{escape(s)}[/cyan]")
|
|
raise typer.Exit(1)
|
|
else:
|
|
console.print(
|
|
f"[red]Thread '{escape(thread_id)}' not found.[/red]"
|
|
)
|
|
raise typer.Exit(1)
|
|
else:
|
|
tid = await graph_gateway.create_thread()
|
|
console.print("[dim]Loading agent...[/dim]")
|
|
agent = await asyncio.to_thread(
|
|
_load_agent,
|
|
workspace_dir=workspace_dir,
|
|
checkpointer=checkpointer,
|
|
config=config,
|
|
runtime=async_runtime,
|
|
)
|
|
try:
|
|
if effective_output_format == "stream-json":
|
|
# Headless JSONL path: drive the sink through the gateway
|
|
# directly. We are already inside the async single-shot
|
|
# loop, so this is a plain await — no nested-loop juggling,
|
|
# and the gateway seam keeps it execution-backend agnostic.
|
|
request = RunRequest(
|
|
message=prompt,
|
|
thread_id=tid,
|
|
metadata=build_metadata(workspace_dir, config.model),
|
|
target=GraphTarget(
|
|
local_graph=agent, workspace_dir=workspace_dir
|
|
),
|
|
)
|
|
try:
|
|
await stream_json(graph_gateway, request)
|
|
except Exception as exc:
|
|
# stream_events already emitted a terminal `error`
|
|
# event onto the JSON stream before re-raising; exit
|
|
# cleanly so the stream ends with that event instead
|
|
# of a raw traceback.
|
|
raise typer.Exit(1) from exc
|
|
finally:
|
|
# Let post-run memory workers persist before exit,
|
|
# matching the text path (cmd_run does this itself).
|
|
_wait_for_memory_workers_before_exit()
|
|
else:
|
|
stream_worker = asyncio.create_task(
|
|
asyncio.to_thread(
|
|
cmd_run,
|
|
agent,
|
|
prompt,
|
|
thread_id=tid,
|
|
show_thinking=show_thinking,
|
|
workspace_dir=workspace_dir,
|
|
model=config.model,
|
|
ui_backend=config.ui_backend,
|
|
runtime_gateways=runtime_gateways,
|
|
async_runtime=async_runtime,
|
|
)
|
|
)
|
|
try:
|
|
await asyncio.shield(stream_worker)
|
|
except asyncio.CancelledError:
|
|
from ..stream.display import request_stream_cancel
|
|
from .tui_runtime import settle_cancelled_worker
|
|
|
|
await settle_cancelled_worker(
|
|
stream_worker,
|
|
on_cancel=request_stream_cancel,
|
|
)
|
|
raise
|
|
finally:
|
|
# Model failures can bypass middleware ``after_agent``
|
|
# hooks. Close any remaining QuickJS workers while this
|
|
# event loop is still available; their synchronous GC
|
|
# fallback can deadlock during interpreter shutdown.
|
|
from ..middleware.code_interpreter import (
|
|
aclose_code_interpreters,
|
|
)
|
|
|
|
await aclose_code_interpreters()
|
|
try:
|
|
print_resume_hint(tid, console=console)
|
|
except Exception:
|
|
pass
|
|
|
|
async_runtime.run_sync(_single_shot)
|
|
else:
|
|
from .interactive import cmd_interactive
|
|
|
|
# Interactive mode (default) — checkpointer managed inside cmd_interactive
|
|
cmd_interactive(
|
|
show_thinking=show_thinking,
|
|
channel_send_thinking=effective_channel_thinking,
|
|
workspace_dir=workspace_dir,
|
|
workspace_fixed=workspace_fixed,
|
|
mode=effective_mode,
|
|
model=config.model,
|
|
provider=config.provider,
|
|
run_name=name,
|
|
thread_id=thread_id,
|
|
ui_backend=config.ui_backend,
|
|
config=config,
|
|
async_runtime=async_runtime,
|
|
)
|
|
|
|
|
|
def _configure_logging():
|
|
"""Configure logging with warning symbols for better visibility."""
|
|
from rich.logging import RichHandler
|
|
|
|
from ..config import get_effective_config
|
|
|
|
def _resolve_log_level() -> int:
|
|
"""Resolve the root log level from config/env with a safe fallback."""
|
|
try:
|
|
raw = (get_effective_config().log_level or "").strip().upper()
|
|
except Exception:
|
|
raw = ""
|
|
if raw == "WARN":
|
|
raw = "WARNING"
|
|
return getattr(logging, raw, logging.WARNING)
|
|
|
|
resolved_level = _resolve_log_level()
|
|
verbose_logging = resolved_level <= logging.DEBUG
|
|
|
|
class DimWarningHandler(RichHandler):
|
|
"""Custom handler that renders warnings in dim style."""
|
|
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
if record.levelno == logging.WARNING:
|
|
# Use Rich console to print dim warning
|
|
msg = record.getMessage()
|
|
console.print(
|
|
f"[dim yellow]\u26a0\ufe0f Warning:[/dim yellow] [dim]{escape(msg)}[/dim]"
|
|
)
|
|
else:
|
|
super().emit(record)
|
|
|
|
# Configure root logger to use our handler for WARNING and above
|
|
handler = DimWarningHandler(
|
|
console=console,
|
|
show_time=verbose_logging,
|
|
show_path=verbose_logging,
|
|
show_level=verbose_logging,
|
|
)
|
|
handler.setLevel(resolved_level)
|
|
|
|
# Apply to root logger (catches all loggers including deepagents)
|
|
root_logger = logging.getLogger()
|
|
# Remove existing handlers to avoid duplicate output
|
|
for h in root_logger.handlers[:]:
|
|
root_logger.removeHandler(h)
|
|
root_logger.addHandler(handler)
|
|
root_logger.setLevel(resolved_level)
|
|
|
|
# Suppress noisy schema warnings from langchain_google_genai
|
|
# (e.g. "Key '$schema' is not supported in schema, ignoring")
|
|
logging.getLogger("langchain_google_genai._function_utils").setLevel(logging.ERROR)
|