Files
EvoScientist-Multi/EvoScientist/config/onboard/steps.py
T
m4 470cf75722 merge: bring upstream v0.3.0 (72 commits) into Ai4Sci fork
Merged upstream/main (418abca, release v0.3.0) into our fork on a
dedicated branch. 21 conflicting files resolved; main worktree untouched.

Resolution policy and key decisions:
- Keep Ai4Sci runtime endpoints, durable dispatch, workspace scopes and
  the HITL/DynamicReview approval chain (approval path is product-critical).
- Adopt upstream model registry (llm/registry.py): our 136 model entries
  are a strict subset of upstream's 180, so dropping our inline table
  loses nothing and gains 44 new models.
- Adopt upstream native EvoChatDeepSeek; drop our obsolete
  _patch_deepseek_reasoning_passback monkey patch.
- Keep our six patches.py additions, ported onto upstream's new
  _OpenAICompatContent class: stable tool-call ids, tool-history
  sanitization, drop_reasoning_metadata, empty-SSE keepalive,
  extracted-document-text patch, _has_assistant_tool_protocol.
- Keep our skill-budget middleware path (skills=None) instead of passing
  skills through, to avoid double loading.
- Keep sanitized error labels (_safe_error_label) while adopting
  upstream's injected MiddlewareEventSink for fallback narration.
- Keep port 3076 and the LANGGRAPH_SERVER_URL override; adopt upstream's
  host/probe-host handling and CONFIG_DRIFT_SINCE_LAUNCH.
- Adopt upstream dependency stack: deepagents 0.7.6, langchain-quickjs
  0.3.7, langgraph-api 0.14; keep our extra deps (rfc8785, pillow,
  firecrawl-anydoc, nest-asyncio).
- Align call sites with upstream APIs: create_tool_selector_middleware
  now takes events= instead of track_stream_selection=.
2026-09-13 16:07:27 +08:00

1355 lines
45 KiB
Python

"""Individual wizard step functions.
Each ``_step_*`` prompts the user for one logical decision and returns the
chosen value. Conditional steps (auth mode, base URL) are only called by
``run_onboard`` when the provider needs them.
"""
from __future__ import annotations
import os
from pathlib import Path
import questionary
from prompt_toolkit.formatted_text import FormattedText
from questionary import Choice
from ...llm import get_models_for_provider
from ...llm.ollama_discovery import validate_ollama_connection
from ..settings import EvoScientistConfig
from .helpers import (
_auto_install_latexmk,
_check_latex_components,
_detect_tinytex_install_method,
_ensure_npx,
_install_ccproxy,
_install_tinytex,
_print_latex_status,
_prompt_and_validate_api_key,
_prompt_ccproxy_port,
_provider_key_info,
_run_ccproxy_login,
)
from .style import (
CONFIRM_STYLE,
QMARK,
WIZARD_STYLE,
_checkbox_ask,
_print_step_result,
_print_step_skipped,
console,
)
from .validators import validate_tavily_key
def _step_ui_backend(config: EvoScientistConfig) -> str:
"""Step 0: Select UI backend (desktop WebUI, Textual TUI, or Rich CLI).
Args:
config: Current configuration.
Returns:
Selected backend name ("tui", "cli", or "webui").
"""
choices = [
Choice(title="WebUI (desktop interface, modern)", value="webui"),
Choice(title="TUI (full-screen interface, recommended)", value="tui"),
Choice(title="CLI (classic terminal, lightweight)", value="cli"),
]
# Map legacy values to current ones
_legacy_map = {"textual": "tui", "rich": "cli"}
default_backend = _legacy_map.get(config.ui_backend, config.ui_backend)
if default_backend not in ("tui", "cli", "webui"):
default_backend = "tui"
backend = questionary.select(
"Select UI mode:",
choices=choices,
default=default_backend,
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
).ask()
if backend is None:
raise KeyboardInterrupt()
return backend
def _step_langgraph_dev_port(config: EvoScientistConfig) -> int:
"""Step 0.5: Choose the local TCP port for the langgraph dev subprocess.
EvoSci auto-starts a ``langgraph dev`` server in the background to host
deployed sub-agents (writing-agent, data-analysis-agent) when
``enable_async_subagents`` is True. This step lets the user pick a free
port, with a live conflict check on the configured default.
Returns the chosen port; caller assigns it to ``config.langgraph_dev_port``.
"""
if not getattr(config, "enable_async_subagents", True):
# User has async disabled — port is irrelevant, no prompt.
return getattr(config, "langgraph_dev_port", 3076)
from ...langgraph_dev.manager import _is_port_occupied, is_langgraph_dev_running
current_port = getattr(config, "langgraph_dev_port", 3076)
current_occupied = _is_port_occupied(current_port)
if current_occupied and is_langgraph_dev_running(port=current_port):
# Another EvoSci shell is already serving on this port — reuse, don't
# force the user to renumber.
current_occupied = False
# Bake the live status into the prompt label so the user sees it WITH
# the question, not as a side-effect line that prints before input.
# Single set of parens, no nesting (mirrors ccproxy's prompt style).
if current_occupied:
prompt_label = (
f"Enter port for EvoScientist server "
f"(Current: {current_port}, occupied, pick another):"
)
else:
prompt_label = (
f"Enter port for EvoScientist server "
f"(Current: {current_port}, available, Enter to keep):"
)
def valid_port(value: str) -> bool:
if not value:
# Allow keeping the default only if it's actually free; otherwise
# require the user to pick something else.
return not current_occupied
try:
port = int(value)
except (ValueError, TypeError):
return False
if not (1024 < port < 65536):
return False
# Reject user-typed ports that are already occupied UNLESS the
# occupier is our own langgraph dev (e.g., another EvoSci shell) —
# in that case the runtime will reuse it.
if not _is_port_occupied(port):
return True
return is_langgraph_dev_running(port=port)
raw = questionary.text(
prompt_label,
validate=valid_port,
style=WIZARD_STYLE,
qmark=QMARK,
).ask()
if raw is None:
raise KeyboardInterrupt()
port = int(raw) if raw else current_port
# Final probe — warn (don't fail) if the chosen port is still occupied
# by something OTHER than our own langgraph dev. Reuse of an existing
# EvoSci server on that port is fine. They can always change later via:
# EvoSci config set langgraph_dev_port <port>
if _is_port_occupied(port) and not is_langgraph_dev_running(port=port):
console.print(
f" [yellow]⚠ Port {port} is occupied. EvoSci may fail to start its "
f"server. Free the port or change later with: "
f"EvoSci config set langgraph_dev_port <other-port>[/yellow]"
)
else:
# Render the address the configured bind actually produces rather than
# a hard-coded loopback URL — the two diverge once langgraph_dev_host
# is pinned to a specific interface.
from ...langgraph_dev.manager import _base_url
host = getattr(config, "langgraph_dev_host", "")
console.print(
f" [green]✓ EvoScientist will run on {_base_url(port, host)}[/green]"
)
return port
def _step_webui_port(config: EvoScientistConfig) -> int:
"""Step 0.6: Choose the local TCP port for the WebUI front-end.
Only asked when ``ui_backend == "webui"``. This is the Next.js server port
the browser opens (``@evoscientist/webui``); the backend keeps its own
``langgraph_dev_port``. Mirrors the langgraph-dev port prompt's UX.
Returns the chosen port; caller assigns it to ``config.webui_port``.
"""
from ...langgraph_dev.manager import _is_port_occupied
current_port = getattr(config, "webui_port", 4716)
backend_port = getattr(config, "langgraph_dev_port", 3076)
occupied = _is_port_occupied(current_port)
conflicts_backend = current_port == backend_port
# Bake live availability into the label (same single-paren style as the
# langgraph-dev / ccproxy prompts) so the status shows WITH the question.
# The WebUI port must also differ from the backend (langgraph dev) port —
# run_webui refuses equal ports at startup, so reject them here too instead
# of saving a config that fails to launch later.
if occupied or conflicts_backend:
reason = "occupied" if occupied else f"= backend port {backend_port}"
prompt_label = (
f"Enter port for WebUI server "
f"(Current: {current_port}, {reason}, pick another):"
)
else:
prompt_label = (
f"Enter port for WebUI server "
f"(Current: {current_port}, available, Enter to keep):"
)
def valid_port(value: str) -> bool:
if not value:
# Keep the default only if it's free AND not the backend port.
return not occupied and not conflicts_backend
try:
port = int(value)
except (ValueError, TypeError):
return False
if not (1024 < port < 65536):
return False
if port == backend_port:
return False
return not _is_port_occupied(port)
raw = questionary.text(
prompt_label,
validate=valid_port,
style=WIZARD_STYLE,
qmark=QMARK,
).ask()
if raw is None:
raise KeyboardInterrupt()
port = int(raw) if raw else current_port
# Same reasoning as the langgraph-dev step: render the configured bind, not
# a hard-coded localhost. A wildcard bind still shows loopback here — that
# is the address this machine's own browser opens.
from ...langgraph_dev.manager import _format_hostport
host = getattr(config, "webui_host", "")
console.print(
f" [green]✓ WebUI will open at http://{_format_hostport(host, port)}[/green]"
)
console.print(
" [yellow]⚠️ Note: the WebUI won't show your CLI/TUI chat history "
"yet.[/yellow]"
)
return port
def _step_provider(
config: EvoScientistConfig,
*,
label: str | None = None,
default_value: str | None = None,
) -> str:
"""Step 1: Select LLM provider.
Args:
config: Current configuration.
label: Optional role label (e.g. "co-pilot") to clarify which model this
provider is for. When omitted, the generic main-model prompt is used.
default_value: Preselect this provider instead of ``config.provider``
(e.g. the auxiliary provider when configuring the co-pilot).
Returns:
Selected provider name.
"""
choices = [
# Direct providers
Choice(title="Anthropic (Claude models — API / OAuth)", value="anthropic"),
Choice(title="OpenAI (GPT models — API / OAuth)", value="openai"),
Choice(title="Google GenAI (Gemini models)", value="google-genai"),
Choice(
title="MiniMax (M2 — M3 models, up to 1M context, thinking)",
value="minimax",
),
Choice(title="ZhipuAI (智谱 — GLM models)", value="zhipu"),
Choice(
title="ZhipuAI CodePlan (智谱代码计划 — GLM models for coding)",
value="zhipu-code",
),
Choice(
title="Volcengine (火山引擎 — Doubao models)",
value="volcengine",
),
Choice(
title="Volcengine Coding Plan (火山引擎代码计划 — coding models)",
value="volcengine-code",
),
Choice(
title="DashScope (阿里云 — Qwen models)",
value="dashscope",
),
Choice(
title="DashScope Coding Plan (阿里云代码计划 — Qwen models)",
value="dashscope-code",
),
Choice(
title="DeepSeek (DeepSeek-R1, DeepSeek-V3)",
value="deepseek",
),
Choice(
title="Moonshot (月之暗面 — Moonshot models)",
value="moonshot",
),
Choice(
title="Kimi Coding Plan (Kimi 代码计划 — coding-focused)",
value="kimi-coding",
),
# Local
Choice(title="Ollama (local models)", value="ollama"),
# Third-party / aggregator
Choice(title="NVIDIA (third party — limited free requests)", value="nvidia"),
Choice(
title="SiliconFlow (aggregator — GLM, Kimi, MiniMax, etc.)",
value="siliconflow",
),
Choice(
title="OpenRouter (aggregator — Grok, Gemini, Qwen, etc.)",
value="openrouter",
),
Choice(
title="Atlas Cloud (aggregator — DeepSeek, Qwen, etc.)",
value="atlascloud",
),
Choice(
title="Requesty (aggregator — OpenAI, Anthropic, Gemini, xAI, etc.)",
value="requesty",
),
Choice(
title="Novita (aggregator — DeepSeek, Qwen, GLM, etc.)",
value="novita",
),
Choice(
title="OpenAI-compatible (third-party OpenAI endpoint)",
value="custom-openai",
),
Choice(
title="Claude-compatible (third-party Anthropic endpoint)",
value="custom-anthropic",
),
]
# Set default based on current config (or an explicit override).
valid_providers = {c.value for c in choices}
preferred = default_value or config.provider
default = preferred if preferred in valid_providers else "anthropic"
provider = questionary.select(
f"Select {label} provider:" if label else "Select your LLM provider:",
choices=choices,
default=default,
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
).ask()
if provider is None:
raise KeyboardInterrupt()
return provider
_MINIMAX_REGIONS: dict[str, str] = {
"global": "https://api.minimax.io/anthropic",
"cn": "https://api.minimaxi.com/anthropic",
}
def _step_minimax_region(config: EvoScientistConfig) -> str:
"""Step 2a (MiniMax): Select API region.
MiniMax has two regional endpoints — Global (api.minimax.io) and
Mainland China (api.minimaxi.com). API keys are region-bound.
Returns:
The selected base URL.
"""
current = config.minimax_base_url or os.environ.get("MINIMAX_BASE_URL", "")
if current == _MINIMAX_REGIONS["global"]:
default = "global"
else:
default = "cn"
region = questionary.select(
"Select MiniMax API region (must match where your key was created):",
choices=[
Choice(
title="Global (api.minimax.io — platform.minimax.io keys)",
value="global",
),
Choice(
title="Mainland China (api.minimaxi.com — platform.minimaxi.com keys)",
value="cn",
),
],
default=default,
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
).ask()
if region is None:
raise KeyboardInterrupt()
return _MINIMAX_REGIONS[region]
def _step_oauth_auth_mode(
config: EvoScientistConfig,
*,
provider_label: str,
ccproxy_provider: str,
config_attr: str,
prompt_login_label: str,
oauth_choice_label: str | None = None,
status_label: str | None = None,
question_label: str | None = None,
) -> str:
"""Select API-key vs ccproxy OAuth authentication for a provider.
Args:
config: Current configuration.
provider_label: Provider display name for direct API-key access.
ccproxy_provider: ccproxy auth provider name.
config_attr: Config attribute storing this provider's auth mode.
prompt_login_label: Label used in "Log in to ..." prompts.
oauth_choice_label: Optional display label for the OAuth choice.
status_label: Optional display label for status messages.
question_label: Optional prompt label override.
Returns:
Selected auth mode: "api_key" or "oauth".
"""
from ...ccproxy_manager import check_ccproxy_auth, is_ccproxy_available
ccproxy_available = is_ccproxy_available()
from .prompter import BACK_SENTINEL, GoBack, install_navigation_keys
oauth_label = oauth_choice_label or f"{prompt_login_label} OAuth"
auth_status_label = status_label or oauth_label
auth_question_label = question_label or f"{provider_label} authentication mode"
choices = [
Choice(title=f"API Key (direct {provider_label} access)", value="api_key"),
Choice(
title=f"{oauth_label} (via ccproxy — no API key needed)"
+ (
""
if ccproxy_available
else " [requires: pip install evoscientist[oauth]]"
),
value="oauth",
),
questionary.Separator(),
Choice(title="← Back (re-pick provider)", value=BACK_SENTINEL),
]
current = getattr(config, config_attr)
if current not in ("api_key", "oauth"):
current = "api_key"
question = questionary.select(
f"{auth_question_label} [Esc/← to go back]:",
choices=choices,
default=current,
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
)
install_navigation_keys(question, with_back=True)
auth_mode = question.ask()
if auth_mode is None:
raise KeyboardInterrupt()
if auth_mode == BACK_SENTINEL:
raise GoBack()
if auth_mode == "oauth" and not ccproxy_available:
console.print(" [yellow]✗ ccproxy not installed[/yellow]")
console.print()
install = questionary.confirm(
'Install ccproxy now? (pip install "evoscientist[oauth]")',
default=True,
style=WIZARD_STYLE,
qmark=f" {QMARK}",
).ask()
if install is None:
raise KeyboardInterrupt()
if install:
console.print()
if _install_ccproxy():
console.print(" [green]✓ ccproxy installed successfully.[/green]")
else:
console.print(" [yellow]Falling back to API key mode.[/yellow]")
return "api_key"
else:
console.print(
' [dim]Skipped. Install manually: pip install "evoscientist[oauth]"[/dim]'
)
return "api_key"
if auth_mode == "oauth":
_prompt_ccproxy_port(config)
authed, msg = check_ccproxy_auth(ccproxy_provider)
if authed:
console.print(f" [green]✓ {auth_status_label}: {msg}[/green]")
relogin = questionary.confirm(
"Re-authenticate to refresh credentials?",
default=False,
style=CONFIRM_STYLE,
qmark=QMARK,
).ask()
if relogin is None:
raise KeyboardInterrupt()
if relogin:
_run_ccproxy_login(ccproxy_provider, auth_status_label)
else:
console.print(
f" [yellow]{auth_status_label} not authenticated: {msg}[/yellow]"
)
login = questionary.confirm(
f"Log in to {prompt_login_label} now?",
default=True,
style=CONFIRM_STYLE,
qmark=QMARK,
).ask()
if login is None:
raise KeyboardInterrupt()
if login:
_run_ccproxy_login(ccproxy_provider, auth_status_label)
return auth_mode
def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
"""Step 2a: Select Anthropic authentication mode (API key vs OAuth).
Args:
config: Current configuration.
Returns:
Selected auth mode: "api_key" or "oauth".
"""
return _step_oauth_auth_mode(
config,
provider_label="Anthropic",
ccproxy_provider="claude_api",
config_attr="anthropic_auth_mode",
prompt_login_label="Claude",
oauth_choice_label="Claude Code OAuth",
status_label="OAuth",
question_label="Authentication mode",
)
def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
"""Step 2b: Select OpenAI authentication mode (API key vs Codex OAuth).
Args:
config: Current configuration.
Returns:
Selected auth mode: "api_key" or "oauth".
"""
return _step_oauth_auth_mode(
config,
provider_label="OpenAI",
ccproxy_provider="codex",
config_attr="openai_auth_mode",
prompt_login_label="Codex",
oauth_choice_label="Codex OAuth",
status_label="Codex OAuth",
question_label="OpenAI authentication mode",
)
def _step_provider_api_key(
config: EvoScientistConfig,
provider: str,
skip_validation: bool = False,
) -> str | None:
"""Step 2: Enter API key for the selected provider.
Args:
config: Current configuration.
provider: Selected provider name.
skip_validation: Skip API key validation.
Returns:
New API key or None if unchanged.
"""
key_name, current, validate_fn = _provider_key_info(config, provider)
hint = f"Current: ***{current[-4:]}" if current else "Not set"
prompt_text = f"Enter {key_name} API key ({hint}, Enter to keep):"
return _prompt_and_validate_api_key(
prompt_text,
current,
validate_fn,
skip_validation,
)
def _step_base_url(config: EvoScientistConfig, current_value: str | None = None) -> str:
"""Prompt for custom provider base URL.
Args:
config: Current configuration.
current_value: Current base URL value (if None, defaults to empty).
Returns:
Base URL string.
"""
current = current_value if current_value is not None else ""
hint = f"Current: {current}" if current else ""
default = current or ""
url = questionary.text(
f"Base URL{' (' + hint + ', Enter to keep)' if hint else ''}:",
default=default,
style=WIZARD_STYLE,
qmark=QMARK,
placeholder=FormattedText([("fg:#858585", " e.g. https://api.example.com/v1")])
if not default
else None,
).ask()
if url is None:
raise KeyboardInterrupt()
return url.strip()
def _step_ollama_base_url(config: EvoScientistConfig) -> tuple[str, list[str]]:
"""Prompt for Ollama server base URL and validate connection.
Args:
config: Current configuration.
Returns:
Tuple of (base_url, detected_model_names).
"""
current = config.ollama_base_url or os.environ.get("OLLAMA_BASE_URL", "")
default = current or "http://localhost:11434"
url = questionary.text(
f"Ollama base URL (Enter for {default}):",
default=default,
style=WIZARD_STYLE,
qmark=QMARK,
).ask()
if url is None:
raise KeyboardInterrupt()
url = url.strip()
detected_models: list[str] = []
if url:
console.print(" [dim]Checking Ollama connection...[/dim]", end="")
valid, msg, detected_models = validate_ollama_connection(url)
if valid:
console.print(f"\r [green]\u2713 {msg}[/green] ")
else:
console.print(f"\r [yellow]\u2717 {msg}[/yellow] ")
console.print(" [dim]You can start Ollama later and it will work.[/dim]")
return url, detected_models
def _step_model(
config: EvoScientistConfig,
provider: str,
*,
ollama_detected_models: list[str] | None = None,
label: str | None = None,
default_value: str | None = None,
) -> str:
"""Step 3: Select model for the provider.
Args:
config: Current configuration.
provider: Selected provider name.
ollama_detected_models: Model names detected from a live Ollama server.
label: Optional role label (e.g. "co-pilot") for the prompt. When omitted,
the generic main-model prompt is used.
default_value: Preselect this model instead of ``config.model`` (e.g. the
auxiliary model when configuring the co-pilot).
Returns:
Selected model name.
"""
model_prompt = f"Select {label} model:" if label else "Select model:"
model_default = default_value or config.model
# Ollama: show only what's actually pulled on the server
if provider == "ollama":
if ollama_detected_models:
_CUSTOM_SENTINEL = "__custom__"
choices = [
Choice(title=name, value=name) for name in ollama_detected_models
]
choices.append(Choice(title="Type a model name...", value=_CUSTOM_SENTINEL))
default = ollama_detected_models[0]
if model_default in ollama_detected_models:
default = model_default
selected = questionary.select(
model_prompt,
choices=choices,
default=default,
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
).ask()
if selected is None:
raise KeyboardInterrupt()
if selected != _CUSTOM_SENTINEL:
return selected
# No detected models (server down or empty) — direct text input
if not ollama_detected_models:
console.print(
" [dim]No models detected — type the model name you plan to pull.[/dim]"
)
model = questionary.text(
"Model name:",
style=WIZARD_STYLE,
qmark=QMARK,
placeholder=FormattedText([("fg:#858585", " e.g. qwen3-coder-next")]),
).ask()
if model is None:
raise KeyboardInterrupt()
model = model.strip()
if not model:
model = "qwen3-coder-next"
console.print(f" [dim]Using default: {model}[/dim]")
return model
# Get models for the selected provider
entries = get_models_for_provider(provider)
if not entries:
# Custom / unknown provider: direct text input.
# Keep prompting until a non-empty model name is provided — saving an
# empty string here leaves the first request broken with an opaque
# "model required" error from the provider SDK.
while True:
model = questionary.text(
"Model name:",
style=WIZARD_STYLE,
qmark=QMARK,
placeholder=FormattedText([("fg:#858585", " e.g. owner/model-name")]),
default=model_default or "",
).ask()
if model is None:
raise KeyboardInterrupt()
model = model.strip()
if model:
return model
console.print(
" [yellow]Model name cannot be empty for a custom provider. "
"Press Ctrl+C to cancel.[/yellow]"
)
provider_models = [name for name, _ in entries]
# Create choices with model IDs as hints
_CUSTOM_SENTINEL = "__custom__"
choices = []
for name, model_id in entries:
choices.append(Choice(title=f"{name} ({model_id})", value=name))
choices.append(Choice(title="Type a model name...", value=_CUSTOM_SENTINEL))
# Determine default. An explicit ``default_value`` override (e.g. a saved
# co-pilot model on a re-run) that isn't a registry model is a custom name:
# preselect "Type a model name..." and prefill it. A plain ``config.model``
# that just isn't in the current provider's list (e.g. the provider was
# changed) falls back to the first model, NOT the custom entry.
custom_default = (
default_value if default_value and default_value not in provider_models else ""
)
if model_default in provider_models:
default = model_default
elif custom_default:
default = _CUSTOM_SENTINEL
else:
default = provider_models[0]
selected = questionary.select(
model_prompt,
choices=choices,
default=default,
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
).ask()
if selected is None:
raise KeyboardInterrupt()
if selected != _CUSTOM_SENTINEL:
return selected
model = questionary.text(
"Model name:",
default=custom_default,
style=WIZARD_STYLE,
qmark=QMARK,
placeholder=FormattedText([("fg:#858585", " e.g. owner/model-name")]),
).ask()
if model is None:
raise KeyboardInterrupt()
model = model.strip()
if not model:
model = provider_models[0]
console.print(f" [dim]Using default: {model}[/dim]")
return model
def _step_auxiliary_enable(config: EvoScientistConfig) -> bool:
"""Step 3.25: Choose whether to assemble a co-pilot (auxiliary) model.
The co-pilot runs background/helper LLM calls — EvoMemory (memory workers)
and the main agent's tool selector — so it can be a cheaper/faster model.
Returns True when the user picks "Assemble"; the caller then runs the
provider/key/model pickers. Returns False to keep the pilot (main model)
everywhere.
"""
console.print(
" [dim]A cheaper/faster co-pilot for EvoMemory (memory workers).[/dim]"
)
choice = questionary.select(
"Co-pilot (auxiliary model):",
choices=[
Choice(
title="Skip — single pilot (main model handles everything)",
value="skip",
),
Choice(
title="Assemble a co-pilot — separate cheaper/faster model",
value="assemble",
),
],
default="assemble" if config.auxiliary_model else "skip",
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
).ask()
if choice is None:
raise KeyboardInterrupt()
return choice == "assemble"
def _step_reasoning_effort(config: EvoScientistConfig) -> str:
"""Step 3.5: Configure OpenRouter reasoning effort level.
Only shown when the selected provider is OpenRouter. See:
https://openrouter.ai/docs/guides/best-practices/reasoning-tokens
Args:
config: Current configuration.
Returns:
Selected reasoning effort level, or empty string to use default.
"""
effort_choices = [
Choice(title="xhigh — ~95% of max_tokens for reasoning", value="xhigh"),
Choice(title="high — ~80% of max_tokens (recommended)", value="high"),
Choice(title="medium — ~50% of max_tokens", value="medium"),
Choice(title="low — ~20% of max_tokens", value="low"),
Choice(title="minimal — ~10% of max_tokens", value="minimal"),
Choice(title="none — disable reasoning entirely", value="none"),
]
current = config.reasoning_effort or "high"
effort = questionary.select(
"Select reasoning effort level:",
choices=effort_choices,
default=current,
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
).ask()
if effort is None:
raise KeyboardInterrupt()
return effort
def _step_tavily_key(
config: EvoScientistConfig,
skip_validation: bool = False,
) -> str | None:
"""Step 4: Enter Tavily API key for web search.
Args:
config: Current configuration.
skip_validation: Skip API key validation.
Returns:
New API key or None if unchanged.
"""
current = config.tavily_api_key or os.environ.get("TAVILY_API_KEY", "")
hint = f"Current: ***{current[-4:]}" if current else "Not set"
prompt_text = f"Tavily API key for web search ({hint}, Enter to keep):"
return _prompt_and_validate_api_key(
prompt_text,
current,
validate_tavily_key,
skip_validation,
placeholder=FormattedText([("fg:#858585", " (recommended for web search)")]),
)
def _step_workspace(config: EvoScientistConfig) -> str:
"""Step 5: Configure workspace mode.
Args:
config: Current configuration.
Returns:
Selected mode ("daemon" or "run").
"""
mode_choices = [
Choice(
title="Daemon (persistent workspace)",
value="daemon",
),
Choice(
title="Run (isolated per-session)",
value="run",
),
]
mode = questionary.select(
"Default workspace mode:",
choices=mode_choices,
default=config.default_mode,
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
).ask()
if mode is None:
raise KeyboardInterrupt()
return mode
def _step_thinking(config: EvoScientistConfig) -> bool:
"""Step 6: Configure thinking panel visibility.
Args:
config: Current configuration.
Returns:
Whether to show thinking panels in the interface.
"""
thinking_choices = [
Choice(title="On (show model reasoning)", value=True),
Choice(title="Off (hide model reasoning)", value=False),
]
show_thinking = questionary.select(
"Show thinking panel?",
choices=thinking_choices,
default=config.show_thinking,
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
).ask()
if show_thinking is None:
raise KeyboardInterrupt()
return show_thinking
_RECOMMENDED_SKILLS = [
# ── Official (EvoScientist) ──
{
"label": "EvoSci Skills (optimized for EvoScientist — paper planning, writing, review, etc.) 👈 Recommended",
"source": "EvoScientist/EvoSkills@skills",
},
# ── Third-party (K-Dense) ──
{
"label": "Scientific Skills (143 research & experiment skills, third party by K-Dense)",
"source": "K-Dense-AI/scientific-agent-skills@skills",
},
{
"label": "Scientific Writer (27 writing, review & presentation skills, third party by K-Dense)",
"source": "K-Dense-AI/claude-scientific-writer@skills",
},
# ── Third-party (Orchestra Research) ──
{
"label": "AI Research Skills (98 skills for training, evaluation, deployment, etc., third party by Orchestra Research)",
"source": "Orchestra-Research/AI-Research-SKILLs",
},
# ── Third-party (Google DeepMind) ──
{
"label": "Science Skills (37 genomics, structural-biology & literature skills, third party by Google DeepMind)",
"source": "google-deepmind/science-skills@skills",
},
# ── Third-party (Anthropic) ──
{
"label": "Anthropic Skills (co-authoring, design, etc., third party by Anthropic)",
"source": "anthropics/skills@skills",
},
# ── Third-party (HuggingFace) ──
{
"label": "HuggingFace Skills (dataset creation, model training & evaluation, third party by HuggingFace)",
"source": "huggingface/skills@skills",
},
# ── Third-party (NVIDIA BioNeMo) ──
{
"label": "BioNeMo Skills (31 protein folding, docking, generative chemistry & genomics skills, third party by NVIDIA)",
"source": "NVIDIA-BioNeMo/bionemo-agent-toolkit@plugins/bionemo-agent-toolkit/skills",
},
]
def _step_tinytex() -> None:
"""Step 9: Prepare LaTeX environment (TinyTeX).
Asks the user whether they want to set up LaTeX for paper compilation.
If yes, checks for an existing installation and offers to install TinyTeX
when none is found. The agent can auto-install missing LaTeX packages at
runtime via ``tlmgr``, so only the base TinyTeX is needed here.
"""
latex_choices = [
Choice(title="No need (skip LaTeX setup)", value=False),
Choice(title="Install now (TinyTeX compiler)", value=True),
]
prepare = questionary.select(
"LaTeX environment (needed to compile .tex → .pdf):",
choices=latex_choices,
default=False,
style=WIZARD_STYLE,
qmark=QMARK,
).ask()
if prepare is None:
raise KeyboardInterrupt()
if not prepare:
_print_step_skipped("LaTeX", "skipped")
console.print(
" [dim]Install later:"
' curl -sL "https://yihui.org/tinytex/install-bin-unix.sh" | sh[/dim]'
)
return
# User wants LaTeX — check existing installation
console.print(" [dim]Checking LaTeX environment...[/dim]")
components = _check_latex_components()
if components["pdflatex"]:
# Already installed — show detailed status
_print_latex_status(components)
# Auto-fix missing latexmk if tlmgr is available
if not components["latexmk"] and components["tlmgr"]:
_auto_install_latexmk()
return
# Not installed — detect install method and offer
console.print(" [yellow]✗ pdflatex not found[/yellow]")
method, command = _detect_tinytex_install_method()
if method == "manual":
_print_step_skipped("LaTeX", "manual install needed")
console.print(f" [dim]Install TinyTeX: {command}[/dim]")
return
install = questionary.confirm(
f"Install TinyTeX via {method}?",
default=True,
style=WIZARD_STYLE,
qmark=f" {QMARK}",
).ask()
if install is None:
raise KeyboardInterrupt()
if not install:
_print_step_skipped("LaTeX", "skipped")
console.print(f" [dim]Install later: {command}[/dim]")
return
console.print(" [dim]Installing TinyTeX (this may take a minute)...[/dim]")
if _install_tinytex(method, command):
post = _check_latex_components()
if post["pdflatex"]:
_print_latex_status(post)
_print_step_result("LaTeX", "TinyTeX installed")
else:
console.print(" [green]✓ TinyTeX installed[/green]")
console.print(
" [yellow]⚠ Restart your terminal"
" for pdflatex to appear in PATH[/yellow]"
)
_print_step_result("LaTeX", "installed (restart terminal for PATH)")
else:
console.print(f" [dim]Install manually: {command}[/dim]")
_print_step_result("LaTeX", "installation failed", success=False)
def _step_skills() -> list[str]:
"""Step 7: Optionally install recommended skills.
Shows checkbox first. Already-installed skills are shown as disabled
so users don't accidentally reinstall them. If user selects nothing,
checks npx as an easter egg — confirms skill discovery is available,
or offers to install Node.js if missing.
Returns:
List of skill sources that were selected (empty if skipped).
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
from ...paths import GLOBAL_SKILLS_DIR, USER_SKILLS_DIR
from ...tools.skills_manager import installed_provenance, resolve_remote_head
# Collect installed-skill dir names across both tiers. The dir-name match
# is a best-effort fallback for legacy installs without manifests; the
# provenance map below is the authoritative signal (handles packs that
# explode into many child directories with unrelated names).
installed_names: set[str] = set()
for skills_dir in (Path(USER_SKILLS_DIR), Path(GLOBAL_SKILLS_DIR)):
if skills_dir.exists():
installed_names.update(e.name for e in skills_dir.iterdir() if e.is_dir())
provenance = installed_provenance()
installed_src = set(provenance)
def _hint_name(source: str) -> str:
"""Derive expected skill directory name from source URL."""
if "@" in source and "://" not in source:
return source.split("@", 1)[1].strip()
return source.rstrip("/").rsplit("/", 1)[-1]
def _is_installed(source: str) -> bool:
return source in installed_src or _hint_name(source) in installed_names
# For installed packs with a stored commit, ask GitHub if upstream has
# moved. Bounded parallel calls so onboard stays snappy; failures are
# silently treated as "unknown" (label falls back to plain "installed").
sources_to_check = [
skill["source"]
for skill in _RECOMMENDED_SKILLS
if _is_installed(skill["source"])
and provenance.get(skill["source"], {}).get("commit")
]
upstream_heads: dict[str, str | None] = {}
if sources_to_check:
with ThreadPoolExecutor(max_workers=min(6, len(sources_to_check))) as ex:
futures = {
ex.submit(resolve_remote_head, src): src for src in sources_to_check
}
for fut in as_completed(futures):
src = futures[fut]
try:
upstream_heads[src] = fut.result()
except Exception:
upstream_heads[src] = None
def _has_update(source: str) -> bool:
head = upstream_heads.get(source)
recorded = provenance.get(source, {}).get("commit")
return bool(head and recorded and head != recorded)
choices = []
for skill in _RECOMMENDED_SKILLS:
src = skill["source"]
if _is_installed(src):
hint = (
" (installed — update available, re-select to sync)"
if _has_update(src)
else " (installed — re-select to sync)"
)
choices.append(
Choice(
title=[
("", skill["label"]),
("class:instruction", hint),
],
value=src,
)
)
else:
choices.append(Choice(title=skill["label"], value=src))
all_installed = all(_is_installed(skill["source"]) for skill in _RECOMMENDED_SKILLS)
has_updates = any(_has_update(skill["source"]) for skill in _RECOMMENDED_SKILLS)
if all_installed:
if has_updates:
console.print(
" [green]✓ All recommended skills installed; "
"[yellow]updates available[/yellow] — re-select any to sync.[/green]"
)
else:
console.print(
" [green]✓ All recommended skills are already installed "
"and up to date.[/green]"
)
# No updates AND nothing new to install — nothing useful the
# picker can do.
return []
prompt_label = (
"Re-select installed skills to sync, or pick new ones:"
if all_installed
else "Install or Sync predefined skills:"
)
selected = _checkbox_ask(choices, prompt_label)
if selected is None:
raise KeyboardInterrupt()
if not selected:
# Verify skill discovery environment
console.print(" [dim]Checking skill discovery environment...[/dim]")
has_npx = _ensure_npx("skill discovery requires Node.js")
if has_npx:
_print_step_skipped("Skills", "none selected — good choice!")
console.print(" [green]✓ npx found — skill discovery available[/green]")
console.print(
" [yellow bold]* Less is more[/yellow bold] [dim](EvoScientist can discover and install skills on its own)[/dim]"
)
else:
_print_step_skipped("Skills", "none selected")
return []
from ...tools.skills_manager import install_skill
installed = []
for source in selected:
label = next(s["label"] for s in _RECOMMENDED_SKILLS if s["source"] == source)
try:
result = install_skill(source)
if result.get("success"):
_print_step_result("Skill", label)
installed.append(source)
else:
_print_step_result(
"Skill", f"{label} — {result.get('error', 'failed')}", success=False
)
except Exception as e:
_print_step_result("Skill", f"{label} — {e}", success=False)
return installed
def _step_mcp_servers() -> list[str]:
"""Step 8: Optionally install recommended MCP servers.
Shows a checkbox list of recommended servers. Already-configured servers
are shown as disabled so users don't accidentally override them.
Selected ones are added to the user MCP config via ``install_mcp_server()``.
Handles env-key prompts, pip package installs, and URL-based servers.
Returns:
List of server names that were installed.
"""
from ...mcp.client import _load_user_config
from ...mcp.registry import fetch_marketplace_index, install_mcp_server
try:
all_servers = fetch_marketplace_index()
except Exception as exc:
console.print(
" [yellow]\u26a0 Could not fetch MCP marketplace index "
f"({type(exc).__name__}). Skipping MCP setup \u2014 "
"you can re-run with [bold]EvoSci configure mcp[/bold] later.[/yellow]"
)
return []
servers = [s for s in all_servers if "onboarding" in s.tags]
existing_config = _load_user_config()
choices = []
for srv in servers:
if srv.name in existing_config:
choices.append(
Choice(
title=[
("", srv.label),
("class:instruction", " (already configured)"),
],
value=srv.name,
disabled=True,
)
)
else:
choices.append(Choice(title=srv.label, value=srv.name))
# Only declare "all configured" when there ARE recommended servers AND
# every one is already in the user's config \u2014 distinct from "marketplace
# returned nothing" (a transient failure) which is handled above.
if servers and all(srv.name in existing_config for srv in servers):
console.print(
"[green]\u2713 All recommended MCP servers are already configured.[/green]"
)
return []
if not servers:
console.print(
" [dim]No recommended MCP servers are tagged for onboarding right now.[/dim]"
)
return []
selected = _checkbox_ask(choices, "Install recommended MCP servers:")
if selected is None:
raise KeyboardInterrupt()
if not selected:
_print_step_skipped("MCP Servers", "none selected")
console.print(
" [dim]Add later with: EvoSci mcp add <name> <command> [--env-ref KEY] -- [args][/dim]"
)
return []
# Check if any selected servers require npx
needs_npx = any(srv.command == "npx" for srv in servers if srv.name in selected)
if needs_npx:
if not _ensure_npx("some MCP servers require Node.js"):
npx_servers = {
srv.name
for srv in servers
if srv.name in selected and srv.command == "npx"
}
selected = [s for s in selected if s not in npx_servers]
if npx_servers:
console.print(
f" [yellow]\u26a0 Skipping {', '.join(sorted(npx_servers))} (npx not available)[/yellow]"
)
if not selected:
return []
installed = []
for name in selected:
srv = next(s for s in servers if s.name == name)
try:
if install_mcp_server(srv):
_print_step_result("MCP", f"{name}")
installed.append(name)
else:
_print_step_result(
"MCP", f"{name} — installation failed", success=False
)
except Exception as e:
_print_step_result("MCP", f"{name} — {e}", success=False)
return installed