This commit is contained in:
X-iZhang
2026-02-09 20:11:16 +00:00
parent 250a23682c
commit 3840a3dfea
5 changed files with 367 additions and 212 deletions
+1
View File
@@ -22,6 +22,7 @@ uv.lock
.agents/
.claude/
.cursor/
.codex/
.cursorrules
.ruff_cache/
+148
View File
@@ -0,0 +1,148 @@
# Contributing to EvoScientist
Thank you for your interest in contributing to EvoScientist.
## Core Principles
- Less is more.
- Keep solutions simple, composable, and maintainable.
- Extend existing abstractions before introducing new paths.
## Agent-First Workflow
EvoScientist is commonly developed with AI agents as collaborators.
Before coding, make sure your agent can clearly explain:
- What this project does
- What writing/coding style this repository follows
- What constraints must not be broken
Before opening a PR, ask your agent to confirm:
- The implementation is the smallest viable change
- Existing behavior is preserved
- The feature works with both defaults and custom settings
## Architecture Guardrails
For new features:
- Prefer configuration and reuse over hardcoded special cases.
- Do not add feature-specific channels or one-off pipelines unless strictly necessary.
- Do not degrade, bypass, or complicate the main flow to support a single feature.
- Keep backward compatibility for existing CLI commands, config keys, and common workflows.
If a change requires a new path, explain why existing paths cannot be safely extended.
## Scope
Contributions are welcome for:
- Bug fixes
- New features and improvements
- Tests and CI stability
- Documentation updates
If your change is large or affects architecture, open an issue first to align on scope.
## Development Setup
Requirements:
- Python 3.11+
Install project with development dependencies:
```bash
python -m pip install -e ".[dev]"
```
Run the CLI locally:
```bash
python -m EvoScientist
# or
EvoSci
```
## Project Structure
Main code is under `EvoScientist/`, including:
- `cli.py` (Typer CLI)
- `EvoScientist.py` (agent wiring)
- `llm/` (model/provider logic)
- `stream/` (stream rendering/state)
- `channels/` (channel integrations)
- `skills/` (built-in skills)
Tests live in `tests/` and follow `test_*.py` naming.
## Coding Standards
- Use 4-space indentation and Python 3.11+ features.
- Add explicit type hints for public APIs when practical.
- Keep docstrings concise and focused on non-trivial behavior.
- Naming conventions:
- `snake_case` for modules, functions, variables
- `PascalCase` for classes
- `UPPER_SNAKE_CASE` for constants
Run lint before opening a PR:
```bash
ruff check .
```
## Testing
Run all tests:
```bash
pytest -v
```
Guidelines:
- Add or update tests for every behavior change.
- Place tests near the affected domain (example: `EvoScientist/llm/...` -> `tests/test_llm.py`).
- Use fixtures and `unittest.mock` to isolate external services/API calls.
## Commit Message Convention
Use Conventional Commits where possible:
- `feat(llm): add provider fallback`
- `fix(stream): guard empty events`
- `test(asyncio): cover loop reuse`
## Pull Request Checklist
Please include:
- A clear summary of what changed and why
- Why this is the minimal solution (`less is more`)
- How compatibility is preserved across existing settings/workflows
- Linked issue(s), if applicable
- Validation evidence (output summary of `ruff check .` and `pytest -v`)
- Screenshots or terminal snippets for user-facing behavior changes, when applicable
Before requesting review, ensure:
- Lint passes
- Tests pass
- Main flow behavior is unchanged unless explicitly documented
- No feature-specific channel/pipeline was introduced without architectural justification
- Documentation is updated if behavior changed
## Security and Configuration
- Copy `.env.example` to create local config:
```bash
cp .env.example .env
```
- Never commit real API keys or secrets.
- Configure credentials via environment variables:
- `ANTHROPIC_API_KEY`
- `OPENAI_API_KEY`
- `NVIDIA_API_KEY`
- `TAVILY_API_KEY`
## Questions
If anything is unclear, open an issue and describe:
- current behavior
- expected behavior
- reproducible context (commands/logs/environment)
+50 -48
View File
@@ -1,58 +1,60 @@
"""EvoScientist Agent — AI-powered research & code execution."""
"""EvoScientist Agent - AI-powered research and code execution.
from .backends import CustomSandboxBackend, ReadOnlyFilesystemBackend
from .config import (
EvoScientistConfig,
load_config,
save_config,
get_effective_config,
get_config_path,
)
from .llm import get_chat_model, MODELS, list_models, DEFAULT_MODEL
from .middleware import create_skills_middleware
from .prompts import get_system_prompt, RESEARCHER_INSTRUCTIONS
from .tools import tavily_search, think_tool, view_image
This package exposes a convenience API at the package root while keeping
imports lazy, so lightweight modules (for example config helpers) can be used
without importing heavy runtime dependencies.
"""
# Lazy imports for EvoScientist_agent and create_cli_agent to avoid
# triggering model initialization at import time. This allows CLI commands
# like `onboard` and `config` to run without API keys being configured.
from __future__ import annotations
from importlib import import_module
def __getattr__(name):
if name == "EvoScientist_agent":
from .EvoScientist import EvoScientist_agent
return EvoScientist_agent
if name == "create_cli_agent":
from .EvoScientist import create_cli_agent
return create_cli_agent
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
# Agent graph (main export, lazily loaded)
"EvoScientist_agent",
"create_cli_agent",
_EXPORTS: dict[str, tuple[str, str]] = {
# Agent graph (lazy to avoid expensive initialization at import time)
"EvoScientist_agent": (".EvoScientist", "EvoScientist_agent"),
"create_cli_agent": (".EvoScientist", "create_cli_agent"),
# Backends
"CustomSandboxBackend",
"ReadOnlyFilesystemBackend",
"CustomSandboxBackend": (".backends", "CustomSandboxBackend"),
"ReadOnlyFilesystemBackend": (".backends", "ReadOnlyFilesystemBackend"),
# Configuration
"EvoScientistConfig",
"load_config",
"save_config",
"get_effective_config",
"get_config_path",
"EvoScientistConfig": (".config", "EvoScientistConfig"),
"load_config": (".config", "load_config"),
"save_config": (".config", "save_config"),
"get_effective_config": (".config", "get_effective_config"),
"get_config_path": (".config", "get_config_path"),
# LLM
"get_chat_model",
"MODELS",
"list_models",
"DEFAULT_MODEL",
"get_chat_model": (".llm", "get_chat_model"),
"MODELS": (".llm", "MODELS"),
"list_models": (".llm", "list_models"),
"DEFAULT_MODEL": (".llm", "DEFAULT_MODEL"),
# Middleware
"create_skills_middleware",
"create_skills_middleware": (".middleware", "create_skills_middleware"),
# Prompts
"get_system_prompt",
"RESEARCHER_INSTRUCTIONS",
"get_system_prompt": (".prompts", "get_system_prompt"),
"RESEARCHER_INSTRUCTIONS": (".prompts", "RESEARCHER_INSTRUCTIONS"),
# Tools
"tavily_search",
"think_tool",
"view_image",
]
"tavily_search": (".tools", "tavily_search"),
"think_tool": (".tools", "think_tool"),
"view_image": (".tools", "view_image"),
}
def __getattr__(name: str):
target = _EXPORTS.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_name, attr_name = target
module = import_module(module_name, package=__name__)
value = getattr(module, attr_name)
# Cache after first load to avoid repeated import lookups.
globals()[name] = value
return value
def __dir__() -> list[str]:
return sorted(set(globals()) | set(_EXPORTS))
__all__ = list(_EXPORTS)
+165 -162
View File
@@ -419,10 +419,146 @@ def _mcp_list_servers() -> None:
console.print()
def _mcp_add_server_from_kwargs(
kwargs: dict[str, Any],
*,
show_reload_hint: bool = False,
) -> bool:
"""Add an MCP server from prepared kwargs."""
from .mcp import add_mcp_server
try:
entry = add_mcp_server(**kwargs)
console.print(f"[green]Added MCP server:[/green] [cyan]{kwargs['name']}[/cyan] ({entry['transport']})")
if show_reload_hint:
console.print("[dim]Reload with /new to apply.[/dim]")
return True
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
return False
def _mcp_edit_server_fields(
name: str,
fields: dict[str, Any],
*,
show_reload_hint: bool = False,
) -> bool:
"""Edit an MCP server from prepared field updates."""
from .mcp import edit_mcp_server
if not fields:
console.print("[red]No fields to edit. Use --transport, --command, --url, --tools, --expose-to, etc.[/red]")
return False
try:
edit_mcp_server(name, **fields)
console.print(f"[green]Updated MCP server:[/green] [cyan]{name}[/cyan]")
for k, v in fields.items():
console.print(f" [dim]{k}:[/dim] {v}")
if show_reload_hint:
console.print("[dim]Reload with /new to apply.[/dim]")
return True
except KeyError as exc:
console.print(f"[red]{exc}[/red]")
return False
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
return False
def _mcp_remove_server(name: str, *, show_reload_hint: bool = False) -> bool:
"""Remove an MCP server by name."""
from .mcp import remove_mcp_server
clean_name = name.strip()
if not clean_name:
console.print("[red]Usage:[/red] /mcp remove <name>")
return False
if remove_mcp_server(clean_name):
console.print(f"[green]Removed MCP server:[/green] [cyan]{clean_name}[/cyan]")
if show_reload_hint:
console.print("[dim]Reload with /new to apply.[/dim]")
return True
console.print(f"[red]Server not found:[/red] {clean_name}")
return False
def _render_mcp_server_config_table(name: str, server: dict[str, Any]) -> None:
"""Render one MCP server config table."""
table = Table(
title=f"MCP Server: {name}",
show_header=True,
title_style="bold cyan",
)
table.add_column("Setting", style="cyan")
table.add_column("Value")
table.add_row("transport", str(server.get("transport", "(not set)")))
if server.get("command"):
table.add_row("command", str(server["command"]))
if server.get("args"):
table.add_row("args", " ".join(str(a) for a in server["args"]))
if server.get("url"):
table.add_row("url", str(server["url"]))
if server.get("headers"):
for k, v in server["headers"].items():
table.add_row(f"header: {k}", str(v))
if server.get("env"):
for k, v in server["env"].items():
table.add_row(f"env: {k}", str(v))
tools = server.get("tools")
table.add_row("tools", ", ".join(tools) if tools else "[dim](all)[/dim]")
expose_to = server.get("expose_to", ["main"])
if isinstance(expose_to, str):
expose_to = [expose_to]
table.add_row("expose_to", ", ".join(expose_to))
console.print(table)
console.print()
def _show_mcp_config(name: str = "", *, show_blank_line: bool = True) -> str:
"""Show MCP config details.
Returns:
"ok" when rendered, "empty" when no config exists, "missing" when
a specific server name is requested but not found.
"""
from .mcp import load_mcp_config
from .mcp.client import USER_MCP_CONFIG
config = load_mcp_config()
if not config:
console.print("[dim]No MCP servers configured.[/dim]")
if show_blank_line:
console.print()
return "empty"
name = name.strip()
if name and name not in config:
console.print(f"[red]Server not found:[/red] {name}")
if show_blank_line:
console.print()
return "missing"
servers = {name: config[name]} if name else config
for srv_name, srv in servers.items():
_render_mcp_server_config_table(srv_name, srv)
console.print(f"[dim]Config file: {USER_MCP_CONFIG}[/dim]")
if show_blank_line:
console.print()
return "ok"
def _cmd_mcp_add(args_str: str) -> None:
"""Handle ``/mcp add ...``."""
import shlex
from .mcp import add_mcp_server, parse_mcp_add_args
from .mcp import parse_mcp_add_args
if not args_str.strip():
console.print("[bold]Usage:[/bold] /mcp add <name> <command-or-url> [args...]")
@@ -447,9 +583,7 @@ def _cmd_mcp_add(args_str: str) -> None:
try:
tokens = shlex.split(args_str)
kwargs = parse_mcp_add_args(tokens)
entry = add_mcp_server(**kwargs)
console.print(f"[green]Added MCP server:[/green] [cyan]{kwargs['name']}[/cyan] ({entry['transport']})")
console.print("[dim]Reload with /new to apply.[/dim]")
_mcp_add_server_from_kwargs(kwargs, show_reload_hint=True)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
console.print()
@@ -458,7 +592,7 @@ def _cmd_mcp_add(args_str: str) -> None:
def _cmd_mcp_edit(args_str: str) -> None:
"""Handle ``/mcp edit <name> --field value ...``."""
import shlex
from .mcp import edit_mcp_server, parse_mcp_edit_args
from .mcp import parse_mcp_edit_args
if not args_str.strip():
console.print("[bold]Usage:[/bold] /mcp edit <name> --<field> <value> ...")
@@ -477,13 +611,7 @@ def _cmd_mcp_edit(args_str: str) -> None:
try:
tokens = shlex.split(args_str)
name, fields = parse_mcp_edit_args(tokens)
edit_mcp_server(name, **fields)
console.print(f"[green]Updated MCP server:[/green] [cyan]{name}[/cyan]")
for k, v in fields.items():
console.print(f" [dim]{k}:[/dim] {v}")
console.print("[dim]Reload with /new to apply.[/dim]")
except KeyError as exc:
console.print(f"[red]{exc}[/red]")
_mcp_edit_server_fields(name, fields, show_reload_hint=True)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
console.print()
@@ -491,90 +619,36 @@ def _cmd_mcp_edit(args_str: str) -> None:
def _cmd_mcp_remove(name: str) -> None:
"""Handle ``/mcp remove <name>``."""
from .mcp import remove_mcp_server
if not name.strip():
console.print("[red]Usage:[/red] /mcp remove <name>")
console.print()
return
if remove_mcp_server(name.strip()):
console.print(f"[green]Removed MCP server:[/green] [cyan]{name.strip()}[/cyan]")
console.print("[dim]Reload with /new to apply.[/dim]")
else:
console.print(f"[red]Server not found:[/red] {name.strip()}")
_mcp_remove_server(name, show_reload_hint=True)
console.print()
def _cmd_mcp_config(name: str) -> None:
"""Handle ``/mcp config [name]``."""
from .mcp import load_mcp_config
from .mcp.client import USER_MCP_CONFIG
config = load_mcp_config()
if not config:
console.print("[dim]No MCP servers configured.[/dim]")
console.print()
return
name = name.strip()
if name and name not in config:
console.print(f"[red]Server not found:[/red] {name}")
console.print()
return
servers = {name: config[name]} if name else config
for srv_name, srv in servers.items():
table = Table(
title=f"MCP Server: {srv_name}",
show_header=True,
title_style="bold cyan",
)
table.add_column("Setting", style="cyan")
table.add_column("Value")
table.add_row("transport", str(srv.get("transport", "(not set)")))
if srv.get("command"):
table.add_row("command", str(srv["command"]))
if srv.get("args"):
table.add_row("args", " ".join(str(a) for a in srv["args"]))
if srv.get("url"):
table.add_row("url", str(srv["url"]))
if srv.get("headers"):
for k, v in srv["headers"].items():
table.add_row(f"header: {k}", str(v))
if srv.get("env"):
for k, v in srv["env"].items():
table.add_row(f"env: {k}", str(v))
tools = srv.get("tools")
table.add_row("tools", ", ".join(tools) if tools else "[dim](all)[/dim]")
expose_to = srv.get("expose_to", ["main"])
if isinstance(expose_to, str):
expose_to = [expose_to]
table.add_row("expose_to", ", ".join(expose_to))
console.print(table)
console.print()
console.print(f"[dim]Config file: {USER_MCP_CONFIG}[/dim]")
console.print()
_show_mcp_config(name, show_blank_line=True)
def _cmd_mcp(args: str) -> None:
"""Dispatch ``/mcp`` subcommands."""
args = args.strip()
if not args or args == "list":
if not args:
_mcp_list_servers()
elif args.startswith("add"):
_cmd_mcp_add(args[3:].strip())
elif args.startswith("edit"):
_cmd_mcp_edit(args[4:].strip())
elif args.startswith("remove"):
_cmd_mcp_remove(args[6:].strip())
elif args.startswith("config"):
_cmd_mcp_config(args[6:].strip())
return
parts = args.split(maxsplit=1)
subcmd = parts[0].lower()
subargs = parts[1] if len(parts) > 1 else ""
if subcmd == "list":
_mcp_list_servers()
elif subcmd == "add":
_cmd_mcp_add(subargs)
elif subcmd == "edit":
_cmd_mcp_edit(subargs)
elif subcmd == "remove":
_cmd_mcp_remove(subargs)
elif subcmd == "config":
_cmd_mcp_config(subargs)
else:
console.print("[bold]MCP commands:[/bold]")
console.print(" /mcp List configured servers")
@@ -1233,59 +1307,13 @@ def mcp_config(
evosci mcp config # Show all servers in detail
evosci mcp config filesystem # Show one server
"""
from .mcp import load_mcp_config
from .mcp.client import USER_MCP_CONFIG
config = load_mcp_config()
if not config:
console.print("[dim]No MCP servers configured.[/dim]")
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 name and name not in config:
console.print(f"[red]Server not found:[/red] {name}")
if status == "missing":
raise typer.Exit(1)
servers = {name: config[name]} if name else config
for srv_name, srv in servers.items():
table = Table(
title=f"MCP Server: {srv_name}",
show_header=True,
title_style="bold cyan",
)
table.add_column("Setting", style="cyan")
table.add_column("Value")
table.add_row("transport", str(srv.get("transport", "(not set)")))
if srv.get("command"):
table.add_row("command", str(srv["command"]))
if srv.get("args"):
table.add_row("args", " ".join(str(a) for a in srv["args"]))
if srv.get("url"):
table.add_row("url", str(srv["url"]))
if srv.get("headers"):
for k, v in srv["headers"].items():
table.add_row(f"header: {k}", str(v))
if srv.get("env"):
for k, v in srv["env"].items():
table.add_row(f"env: {k}", str(v))
tools = srv.get("tools")
table.add_row("tools", ", ".join(tools) if tools else "[dim](all)[/dim]")
expose_to = srv.get("expose_to", ["main"])
if isinstance(expose_to, str):
expose_to = [expose_to]
table.add_row("expose_to", ", ".join(expose_to))
console.print(table)
console.print()
console.print(f"[dim]Config file: {USER_MCP_CONFIG}[/dim]")
@mcp_app.command("add")
def mcp_add(
@@ -1311,7 +1339,6 @@ def mcp_add(
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 add_mcp_server
from .mcp.client import _infer_transport
if transport is None:
@@ -1351,11 +1378,7 @@ def mcp_add(
if expose_to:
kwargs["expose_to"] = [a.strip() for a in expose_to.split(",") if a.strip()]
try:
entry = add_mcp_server(**kwargs)
console.print(f"[green]Added MCP server:[/green] [cyan]{name}[/cyan] ({entry['transport']})")
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
if not _mcp_add_server_from_kwargs(kwargs, show_reload_hint=False):
raise typer.Exit(1)
@@ -1379,8 +1402,6 @@ def mcp_edit(
evosci mcp edit my-api --url http://new-host:9090/mcp
evosci mcp edit my-api --tools none
"""
from .mcp import edit_mcp_server
fields: dict = {}
if transport is not None:
fields["transport"] = transport
@@ -1409,20 +1430,7 @@ def mcp_edit(
if env_dict:
fields["env"] = env_dict
if not fields:
console.print("[red]No fields to edit. Use --transport, --command, --url, --tools, --expose-to, etc.[/red]")
raise typer.Exit(1)
try:
edit_mcp_server(name, **fields)
console.print(f"[green]Updated MCP server:[/green] [cyan]{name}[/cyan]")
for k, v in fields.items():
console.print(f" [dim]{k}:[/dim] {v}")
except KeyError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
if not _mcp_edit_server_fields(name, fields, show_reload_hint=False):
raise typer.Exit(1)
@@ -1431,12 +1439,7 @@ def mcp_remove(
name: str = typer.Argument(..., help="Server name to remove"),
):
"""Remove an MCP server from user config."""
from .mcp import remove_mcp_server
if remove_mcp_server(name):
console.print(f"[green]Removed MCP server:[/green] [cyan]{name}[/cyan]")
else:
console.print(f"[red]Server not found:[/red] {name}")
if not _mcp_remove_server(name, show_reload_hint=False):
raise typer.Exit(1)
+3 -2
View File
@@ -25,7 +25,8 @@ def skill_manager(
action="list":
List installed skills. By default only shows user-installed skills.
Set include_system=True to also show built-in system skills (peft, accelerate, flash-attention, etc.).
Set include_system=True to also show built-in system skills.
Built-in skills evolve over time, so use action="list" to see the current set.
action="info" (requires name):
Get details (description, source, path) about a specific skill by name.
@@ -37,7 +38,7 @@ def skill_manager(
Args:
action: The operation to perform — "install", "list", "info", or "uninstall"
source: Required for install — GitHub shorthand, GitHub URL, or local directory path
name: Required for info and uninstall — the skill name (e.g. "peft", "my-custom-skill")
name: Required for info and uninstall — the skill name (for example, one returned by action="list")
include_system: Only for list — set True to include built-in system skills in the output
Returns: