chore: update CONTRIBUTING.md
This commit is contained in:
+18
-894
@@ -1,16 +1,26 @@
|
||||
# Contributing to EvoScientist
|
||||
|
||||
This guide serves both **human contributors** and **AI coding agents** (Claude Code, Cursor, etc.). It provides the architectural context, patterns, and extension points needed to safely and effectively modify the codebase.
|
||||
We appreciate your interest and the time you spend helping improve EvoScientist. Please read the following instruction on how to help us.
|
||||
|
||||
## Core Principles
|
||||
## How you can contribute
|
||||
|
||||
1. **Less is more.** The smallest change that achieves the goal is the best change.
|
||||
2. **Keep solutions simple, composable, and maintainable.**
|
||||
3. **Extend existing abstractions before introducing new paths.** If a change requires a new path, explain why existing paths cannot be safely extended.
|
||||
- Report ideas, bugs, and feature requests: open an issue describing intent, motivation, and high-level impact. Make sure to correctly label the Issue. Use the provided template.
|
||||
- Propose design changes: use issues or discussion threads to outline the problem, alternatives, and trade-offs before implementing.
|
||||
- Contribute code or docs: submit PRs that tackle an open issue. They must have a clear rationale and tests where applicable.
|
||||
|
||||
## What we are looking for in PRs
|
||||
|
||||
We aim to keep the EvoScientist with only core functionality that will be used by majority of users. Therefore PRs should only include:
|
||||
- Bug fixes / Improvements to existing features
|
||||
- New features that were proposed in an Issue and agreed with Contributor
|
||||
- Updating documentation
|
||||
- Improving test suite with meaningful tests
|
||||
|
||||
If you want to add nice to have or niche feature look at contributing to the [EvoSkills repository](https://github.com/EvoScientist/EvoSkills)
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
## Project overview
|
||||
|
||||
EvoScientist is a multi-agent AI system for automated scientific experimentation and discovery. It orchestrates specialized sub-agents that plan experiments, search literature, write code, debug, analyze data, and draft reports.
|
||||
|
||||
@@ -58,891 +68,5 @@ Stream Events --> Emitter --> Tracker --> State --> Rich Display / TUI
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11 or higher
|
||||
- `uv` (recommended) or `pip`
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
cd EvoScientist
|
||||
|
||||
# Option A: uv (recommended)
|
||||
uv sync --dev
|
||||
|
||||
# Option B: pip (requires pip >=25.0 for PEP 735 dependency groups)
|
||||
pip install -e . --dependency-groups dev
|
||||
|
||||
# Channel extras (optional)
|
||||
pip install -e ".[telegram]" # single channel
|
||||
pip install -e ".[all-channels]" # all channels
|
||||
```
|
||||
|
||||
### Configure
|
||||
|
||||
```bash
|
||||
# Interactive wizard (recommended)
|
||||
EvoSci onboard
|
||||
|
||||
# Manual alternative
|
||||
# Edit ~/.config/evoscientist/config.yaml directly, or:
|
||||
EvoSci config set anthropic_api_key sk-ant-...
|
||||
EvoSci config set tavily_api_key tvly-...
|
||||
```
|
||||
|
||||
**Config priority chain** (highest wins):
|
||||
|
||||
| Priority | Source | Example |
|
||||
|----------|--------|---------|
|
||||
| 1 | CLI arguments | `--model gpt-4o` |
|
||||
| 2 | Environment variables | `ANTHROPIC_API_KEY=sk-...` |
|
||||
| 3 | Config file | `~/.config/evoscientist/config.yaml` |
|
||||
| 4 | Defaults | `provider: anthropic`, `model: claude-sonnet-4-6` |
|
||||
|
||||
Implementation: `config/settings.py` — `get_effective_config()` merges all four sources.
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
python -m EvoScientist # interactive mode (daemon workspace)
|
||||
python -m EvoScientist -p "..." # single-shot query
|
||||
EvoSci # alias (same as above)
|
||||
EvoSci serve # headless mode (channels only, no interactive prompt)
|
||||
EvoSci serve --auto-approve # headless with auto-approve
|
||||
langgraph dev # LangGraph dev server
|
||||
```
|
||||
|
||||
### Entry Points (from `pyproject.toml`)
|
||||
|
||||
All four CLI aliases (`evoscientist`, `EvoScientist`, `evosci`, `EvoSci`) resolve to `EvoScientist.cli:main`.
|
||||
|
||||
### Workspace Modes
|
||||
|
||||
| Mode | Workspace | `/new` behavior | Use case |
|
||||
|------|-----------|-----------------|----------|
|
||||
| `daemon` (default) | `workspace/` | New thread, same workspace | Long-term development |
|
||||
| `run` | `workspace/runs/<timestamp>/` | New thread + new workspace | Isolated experiments |
|
||||
|
||||
Workspace priority: `--workdir` > `--mode` > `default_workdir` config > `default_mode` config > cwd.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```txt
|
||||
EvoScientist/EvoScientist/
|
||||
|-- __init__.py # Lazy-loaded exports (__getattr__ pattern)
|
||||
|-- __main__.py # python -m EvoScientist entry
|
||||
|-- EvoScientist.py # Agent graph construction, create_cli_agent()
|
||||
|-- backends.py # CompositeBackend, CustomSandboxBackend, MergedReadOnlyBackend
|
||||
|-- prompts.py # 3-layer prompt: EXPERIMENT_WORKFLOW + DELEGATION_STRATEGY + RESEARCHER_INSTRUCTIONS
|
||||
|-- paths.py # Workspace path management: set_workspace_root(), resolve_virtual_path()
|
||||
|-- sessions.py # SQLite checkpoint persistence for LangGraph threads
|
||||
|-- utils.py # Subagent loader, shared helpers
|
||||
|-- subagent.yaml # 6 sub-agent definitions (prompts, tools, descriptions)
|
||||
|
|
||||
|-- cli/ # CLI module
|
||||
| |-- _app.py # Typer app instances (main + sub-apps)
|
||||
| |-- interactive.py # Main interactive loop, Rich Live display
|
||||
| |-- commands.py # Workspace handling, config CLI, MCP commands, serve command (headless channel processing)
|
||||
| |-- agent.py # Agent loading and session workspace creation
|
||||
| |-- channel.py # Queue-based channel integration (shares agent session)
|
||||
| |-- mcp_ui.py # MCP server management UI
|
||||
| |-- skills_cmd.py # /install-skill, /uninstall-skill, /skills commands
|
||||
| |-- clipboard.py # Clipboard utilities
|
||||
| |-- _constants.py # CLI constants
|
||||
| |-- tui_interactive.py # Textual-based TUI (alternative to Rich CLI)
|
||||
| |-- tui_backends.py # TUI backend adapters
|
||||
| |-- tui_runtime.py # TUI runtime loop
|
||||
| +-- widgets/ # 8 Textual widgets (assistant, loading, subagent, system, thinking, todo, tool_call, user)
|
||||
|
|
||||
|-- config/ # Configuration module
|
||||
| |-- __init__.py # Re-exports
|
||||
| |-- settings.py # EvoScientistConfig dataclass, get_effective_config(), apply_config_to_env()
|
||||
| +-- onboard.py # Interactive setup wizard (questionary-based)
|
||||
|
|
||||
|-- llm/ # LLM provider module
|
||||
| |-- __init__.py # Re-exports
|
||||
| +-- models.py # MODELS registry, get_chat_model(), DEFAULT_MODEL
|
||||
|
|
||||
|-- middleware/ # Agent middleware
|
||||
| |-- __init__.py # Re-exports, create_memory_middleware()
|
||||
| |-- memory.py # EvoMemoryMiddleware (injection + extraction)
|
||||
| +-- tool_error_handler.py # ToolErrorHandlerMiddleware
|
||||
|
|
||||
|-- tools/ # Custom tools
|
||||
| |-- __init__.py # Re-exports
|
||||
| |-- search.py # tavily_search, fetch_webpage_content
|
||||
| |-- think.py # think_tool (structured reflection)
|
||||
| |-- skill_manager.py # skill_manager tool (install/list/uninstall)
|
||||
| +-- skills_manager.py # Core skill install logic (local, GitHub, batch)
|
||||
|
|
||||
|-- mcp/ # MCP integration
|
||||
| |-- __init__.py # load_mcp_tools()
|
||||
| +-- client.py # load_mcp_config(), MCP transport handling
|
||||
|
|
||||
|-- stream/ # Streaming display pipeline
|
||||
| |-- __init__.py # Re-exports
|
||||
| |-- emitter.py # StreamEventEmitter
|
||||
| |-- tracker.py # ToolCallTracker (incremental JSON parsing)
|
||||
| |-- state.py # StreamState, SubAgentState, sub-agent name resolution
|
||||
| |-- events.py # Event types
|
||||
| |-- display.py # Rich rendering
|
||||
| |-- formatter.py # Output formatting
|
||||
| +-- utils.py # Stream helpers
|
||||
|
|
||||
|-- channels/ # 10 messaging channels
|
||||
| |-- __init__.py
|
||||
| |-- base.py # Channel ABC, IncomingMessage, OutgoingMessage, chunk_text()
|
||||
| |-- bus/ # Message bus (events, routing)
|
||||
| |-- capabilities.py # ChannelCapabilities feature abstraction
|
||||
| |-- consumer.py # Inbound message processing
|
||||
| |-- formatter.py # UnifiedFormatter
|
||||
| |-- middleware.py # TypingManager
|
||||
| |-- plugin.py # ChannelPlugin, ChannelMeta, ReloadPolicy
|
||||
| |-- config.py # Channel config
|
||||
| |-- retry.py # Retry logic
|
||||
| |-- mixins.py # Shared mixins
|
||||
| |-- standalone.py # Standalone channel server
|
||||
| |-- imessage/ # macOS iMessage (imsg CLI + JSON-RPC)
|
||||
| |-- telegram/ # Telegram Bot API
|
||||
| |-- discord/ # Discord bot
|
||||
| |-- slack/ # Slack bot
|
||||
| |-- wechat/ # WeChat (WeCom / MP)
|
||||
| |-- dingtalk/ # DingTalk
|
||||
| |-- feishu/ # Feishu (Lark)
|
||||
| |-- email/ # Email (IMAP/SMTP)
|
||||
| |-- qq/ # QQ (botpy)
|
||||
| +-- signal/ # Signal (signal-cli)
|
||||
|
|
||||
+-- skills/ # Built-in skills (read-only to agent)
|
||||
|-- find-skills/ # Skill discovery
|
||||
+-- skill-creator/ # Skill creation wizard (Apache 2.0 licensed, see LICENSE.txt)
|
||||
|-- scripts/
|
||||
| |-- run_eval.py # Single-skill trigger evaluation via LLM tool-calling
|
||||
| |-- run_loop.py # Iterative description optimization (train/test split)
|
||||
| +-- improve_description.py # LLM-based description improvement
|
||||
+-- eval-viewer/ # HTML eval result viewer
|
||||
```
|
||||
|
||||
10 additional research-lifecycle skills are available in the [EvoSkills repo](../EvoSkills/skills/) covering ideation, experimentation, writing, and support phases. Install via `/install-skill ../EvoSkills/skills` (batch) or `/install-skill ../EvoSkills/skills/<name>` (single).
|
||||
|
||||
### Tests
|
||||
|
||||
36 test files under `tests/`, following `test_*.py` naming. Tests are placed near the affected domain:
|
||||
|
||||
```txt
|
||||
tests/
|
||||
|-- conftest.py # Shared fixtures
|
||||
|-- test_backends.py # CustomSandboxBackend, validate_command
|
||||
|-- test_config.py # EvoScientistConfig, get_effective_config
|
||||
|-- test_llm.py # Model registry, get_chat_model
|
||||
|-- test_stream_state.py # StreamState, sub-agent name resolution
|
||||
|-- test_stream_emitter.py # StreamEventEmitter
|
||||
|-- test_stream_tracker.py # ToolCallTracker
|
||||
|-- test_tools.py # tavily_search, think_tool, skill_manager
|
||||
|-- test_skills_manager.py # Skill install/uninstall/batch
|
||||
|-- test_memory_merge.py # EvoMemoryMiddleware extraction
|
||||
|-- test_mcp_client.py # MCP config loading, tool routing
|
||||
|-- test_agent_mcp_cache.py # MCP caching by config signature
|
||||
|-- test_sessions.py # SQLite checkpointer
|
||||
|-- test_paths.py # Workspace path management
|
||||
|-- test_telegram_channel.py # Channel-specific tests
|
||||
|-- test_discord_channel.py
|
||||
|-- test_slack_channel.py
|
||||
|-- test_wechat_channel.py
|
||||
|-- test_feishu_channel.py
|
||||
|-- test_dingtalk_channel.py
|
||||
|-- test_bus_integration.py # Message bus integration
|
||||
|-- ... # (see tests/ for full list)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Deep Dive
|
||||
|
||||
### Agent Construction Pipeline
|
||||
|
||||
The sequence from config to running agent (`EvoScientist.py`):
|
||||
|
||||
1. **`_ensure_config()`** — Loads and caches `EvoScientistConfig` from 4 sources, calls `apply_config_to_env()` to set API keys as env vars.
|
||||
2. **`_ensure_chat_model()`** — Creates a LangChain chat model via `get_chat_model(model, provider)` from `llm/models.py`. Auto-enables extended thinking for Anthropic models.
|
||||
3. **`_ensure_system_prompt()`** — Builds the 3-layer system prompt from `prompts.py`.
|
||||
4. **Backend construction** — `CompositeBackend` with 3 routes (workspace, skills, memory).
|
||||
5. **Middleware construction** — `ToolErrorHandlerMiddleware` + `EvoMemoryMiddleware`.
|
||||
6. **MCP tools** — `_load_mcp_tools_cached()` loads tools from `~/.config/evoscientist/mcp.yaml`, cached by config signature hash.
|
||||
7. **Sub-agent loading** — `load_subagents()` reads `subagent.yaml`, wires tools from registry, injects `ToolErrorHandlerMiddleware` into each sub-agent.
|
||||
8. **`create_deep_agent(**kwargs)`** — DeepAgents constructs the LangGraph agent graph.
|
||||
9. **`.with_config({"recursion_limit": 1000})`** — Sets LangGraph recursion limit.
|
||||
|
||||
Two variants exist:
|
||||
- **`EvoScientist_agent`** — Lazy-loaded default (no checkpointer). For `langgraph dev`, notebooks, LangSmith.
|
||||
- **`create_cli_agent(workspace_dir)`** — With checkpointer for CLI multi-turn. Constructs fresh backends on every call so runtime `set_workspace_root()` changes are respected.
|
||||
|
||||
### Backend Architecture
|
||||
|
||||
`CompositeBackend` (`backends.py`) routes virtual paths to three backends:
|
||||
|
||||
| Route | Backend | Purpose |
|
||||
|-------|---------|---------|
|
||||
| `/` (default) | `CustomSandboxBackend` | Read/write workspace with shell execution |
|
||||
| `/skills/` | `MergedReadOnlyBackend` | Read-only, user skills override built-in |
|
||||
| `/memory/` | `FilesystemBackend` | Persistent memory, shared across sessions |
|
||||
|
||||
**`CustomSandboxBackend`** extends `FilesystemBackend` + `LocalShellBackend`:
|
||||
- **Path sanitization** — `_resolve_path()` auto-corrects LLM hallucinated paths: strips `/workspace/` prefixes, system path prefixes (`/Users/`, `/home/`, etc.). See `_SYSTEM_PATH_PREFIXES` at `backends.py:21`.
|
||||
- **Command validation** — `validate_command()` blocks directory traversal (`..`), dangerous commands (`sudo`, `chmod`, `chown`, `mkfs`, `dd`, `shutdown`, `reboot`), and dangerous patterns (`rm -rf /`). See `BLOCKED_COMMANDS` at `backends.py:35`.
|
||||
- **Limits** — 300s execution timeout, 100KB output limit.
|
||||
|
||||
**`MergedReadOnlyBackend`** (`backends.py`) merges two directories for `ls` and `read_file`. User skills (`workspace/skills/`) take priority over built-in skills (`EvoScientist/skills/`).
|
||||
|
||||
### Configuration System
|
||||
|
||||
`config/settings.py`:
|
||||
- **`EvoScientistConfig`** — Dataclass with all settings: API keys (Anthropic, OpenAI, Google, NVIDIA, Tavily, SiliconFlow, OpenRouter, ZhipuAI, custom, Ollama), LLM settings (provider, model), workspace settings (default_mode, default_workdir), UI settings (show_thinking, ui_backend), and channel-specific settings.
|
||||
- **`get_effective_config(cli_overrides)`** — Merges 4 sources in priority order (CLI > env > file > defaults).
|
||||
- **`apply_config_to_env(config)`** — Sets API keys as env vars for downstream libraries (LangChain, Tavily).
|
||||
- **`load_config()` / `save_config()`** — YAML file I/O at `~/.config/evoscientist/config.yaml`.
|
||||
|
||||
`config/onboard.py`:
|
||||
- **Interactive wizard** — Uses `questionary` to prompt for API keys, validates them, and saves to config file.
|
||||
|
||||
### Memory Middleware
|
||||
|
||||
`middleware/memory.py` — `EvoMemoryMiddleware` provides two mechanisms:
|
||||
|
||||
1. **Injection** (every LLM call): Reads `/memory/MEMORY.md` and appends its contents to the system prompt. The agent always has accumulated context.
|
||||
2. **Extraction** (threshold-triggered): When conversation exceeds a configurable message count (default 20), uses an LLM call to extract structured facts (`ExtractedMemory` Pydantic model) and merges them into MEMORY.md sections (user_profile, research_preferences, experiment_conclusions, etc.).
|
||||
|
||||
Memory always uses the shared `MEMORY_DIR` regardless of workspace mode, ensuring cross-session persistence.
|
||||
|
||||
### Streaming Pipeline
|
||||
|
||||
`stream/` — The rendering pipeline for CLI output:
|
||||
|
||||
```txt
|
||||
StreamEventEmitter (emitter.py)
|
||||
| Converts raw LangGraph stream events into typed events
|
||||
v
|
||||
ToolCallTracker (tracker.py)
|
||||
| Incremental JSON parsing of tool call arguments
|
||||
v
|
||||
StreamState / SubAgentState (state.py)
|
||||
| Tracks active sub-agents, thinking panels, tool calls
|
||||
v
|
||||
Rich rendering (display.py) / TUI widgets (cli/widgets/)
|
||||
```
|
||||
|
||||
**Sub-agent name resolution** — `_get_subagent_name()` in `events.py` and `_resolve_subagent_name()` in `state.py` use a 6-level priority chain:
|
||||
1. `metadata["lc_agent_name"]` — most reliable; filters out generic names
|
||||
2. Task ID from namespace tuple → maps to announced task `tool_call_id`
|
||||
3. Task ID from metadata fields
|
||||
4. Cached real name (skips "sub-agent" entries)
|
||||
5. Queue-based assignment from `_register_task_tool_call()`
|
||||
6. Fallback "sub-agent" (NOT cached, allows re-resolution on later events)
|
||||
|
||||
### Channel Architecture
|
||||
|
||||
`channels/base.py` — `Channel` ABC defines the interface all channels implement:
|
||||
|
||||
- **`start()`** — Start listening for incoming messages (no callback; inbound messages arrive via the bus)
|
||||
- **`_send_chunk(chat_id, formatted_text, raw_text, reply_to, metadata)`** — Send a single message chunk to the channel
|
||||
- **Text chunking** — `chunk_text()` splits messages at code block boundaries > paragraph breaks > newlines > spaces > hard cut
|
||||
|
||||
**Dual-thread design**: Bus thread enqueues `ChannelMessage` on a thread-safe queue. The consuming thread polls the queue, processes with `run_streaming` (Rich Live, real-time), sets response via `_set_channel_response()`. Bus thread publishes outbound.
|
||||
|
||||
Two consumer implementations exist:
|
||||
- **Interactive mode** (`cli/interactive.py` — `_process_channel_message()`): async queue polling with CLI display integration (prompt clearing, separators, prompt redraw).
|
||||
- **Serve mode** (`cli/commands.py` — `_serve_process_message()`): synchronous `queue.get(timeout=1.0)` loop with minimal log output. Same channel callbacks (thinking, todo, media, HITL) but no interactive prompt manipulation.
|
||||
|
||||
**Display format** (in `cli/interactive.py`):
|
||||
|
||||
```txt
|
||||
❯ message content <-- user input style (❯ bold blue)
|
||||
[channel: Received from sender] <-- dim text, sender in cyan
|
||||
-----------------
|
||||
<-- Rich Live streaming (thinking, tools, markdown)
|
||||
[channel: Replied to sender] <-- dim text, sender in cyan
|
||||
-----------------
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Patterns & Invariants
|
||||
|
||||
Each pattern includes WHY it exists, so contributors understand the reason behind the rule.
|
||||
|
||||
### 1. Lazy Loading via `__getattr__`
|
||||
|
||||
**Where**: `EvoScientist/__init__.py`, `EvoScientist/EvoScientist.py`
|
||||
|
||||
**Why**: Non-agent CLI commands (`EvoSci config list`, `EvoSci onboard`) must start instantly without importing heavy dependencies (DeepAgents, LangChain, LangGraph). The `__init__.py` `_EXPORTS` dict maps attribute names to `(module, attr)` tuples; first access triggers import and caches the result.
|
||||
|
||||
**Rule**: Never import `deepagents`, `langchain`, or `langgraph` at the top level of entry-point modules. Defer to function/method scope.
|
||||
|
||||
### 2. Fresh Backends on Every `create_cli_agent()` Call
|
||||
|
||||
**Where**: `EvoScientist.py:318` — `create_cli_agent()`
|
||||
|
||||
**Why**: Users can call `set_workspace_root()` between sessions (via `--workdir`, `/new`, `--mode=run`). A stale backend would point to the wrong directory. Fresh construction on every call ensures current paths are respected.
|
||||
|
||||
**Rule**: Never cache or reuse backend instances across agent creation calls.
|
||||
|
||||
### 3. MCP Tools Cached by Config Signature Hash
|
||||
|
||||
**Where**: `EvoScientist.py:100` — `_mcp_config_signature()`, `_load_mcp_tools_cached()`
|
||||
|
||||
**Why**: MCP tool loading spawns subprocesses and connects to servers — expensive. But on `/new` (new session), the agent is recreated. Caching by config signature avoids redundant reconnection when config hasn't changed.
|
||||
|
||||
**Rule**: When adding MCP-related config fields, ensure they're included in the serialized config signature.
|
||||
|
||||
### 4. Memory Always Uses Shared Directory
|
||||
|
||||
**Where**: `paths.py` — `MEMORY_DIR`, `EvoScientist.py:375`
|
||||
|
||||
**Why**: Memory must persist across sessions, even in `run` mode where each session gets an isolated workspace. The `/memory/` backend route always points to the shared `MEMORY_DIR`, never the per-session workspace.
|
||||
|
||||
**Rule**: Never route `/memory/` to a per-session directory.
|
||||
|
||||
### 5. Path Sanitization — Auto-Corrects Hallucinated Paths
|
||||
|
||||
**Where**: `backends.py:21` — `_SYSTEM_PATH_PREFIXES`, `_resolve_path()`
|
||||
|
||||
**Why**: LLMs frequently hallucinate absolute paths like `/Users/alice/project/file.py` or `/workspace/file.py` instead of using virtual paths. The backend strips these prefixes automatically, converting them to workspace-relative paths.
|
||||
|
||||
**Rule**: When adding new path handling, always use `_resolve_path()`. Never trust raw LLM-provided paths.
|
||||
|
||||
### 6. Command Validation — Blocks Traversal + Dangerous Commands
|
||||
|
||||
**Where**: `backends.py:28-43` — `BLOCKED_PATTERNS`, `BLOCKED_COMMANDS`, `validate_command()`
|
||||
|
||||
**Why**: The sandbox must prevent workspace escape and system damage. Commands are split into segments (handling `&&`, `||`, `;`, `|`) and each base command is checked against blocklists.
|
||||
|
||||
**Rule**: When extending the executor, always route through `validate_command()`. Add new dangerous commands to `BLOCKED_COMMANDS`.
|
||||
|
||||
### 7. Channel = Text Injection into CLI
|
||||
|
||||
**Where**: `cli/interactive.py` — `_process_channel_message()`, `cli/commands.py` — `_serve_process_message()`
|
||||
|
||||
**Why**: Channel messages must look exactly as if the user typed them at the prompt. The CLI is the only rendering engine; channels are just different input sources. This ensures consistent behavior regardless of input source.
|
||||
|
||||
**Rules**:
|
||||
- Use `run_streaming` (Rich Live, real-time) — NOT `_astream_to_console` (static, end-of-stream).
|
||||
- Labels use `dim` style; sender ID in `cyan`.
|
||||
- In interactive mode, after response, manually redraw the prompt via `sys.stdout.write` (prompt_toolkit doesn't know the terminal was modified by Rich Live).
|
||||
- In serve mode, no prompt manipulation — just `console.print` log lines for monitoring.
|
||||
|
||||
### 8. Incremental JSON Parsing
|
||||
|
||||
**Where**: `stream/tracker.py` — `ToolCallTracker`
|
||||
|
||||
**Why**: Tool call arguments stream incrementally — `subagent_type` may be empty initially, then filled in later as more tokens arrive. The tracker parses partial JSON to extract fields as early as possible for responsive UI updates.
|
||||
|
||||
**Rule**: Never assume tool call args are complete on first event. Handle partial/empty fields gracefully.
|
||||
|
||||
### 9. Sub-Agent Name Resolution 6-Level Priority
|
||||
|
||||
**Where**: `stream/events.py` — `_get_subagent_name()`, `stream/state.py` — `_resolve_subagent_name()`
|
||||
|
||||
**Why**: Sub-agent identity comes from multiple unreliable sources. The 6-level priority chain ensures the most reliable source wins. Critically, the fallback "sub-agent" name is NOT cached, allowing re-resolution when a more reliable source arrives later.
|
||||
|
||||
**Rule**: When modifying sub-agent handling, preserve the fallback non-caching behavior.
|
||||
|
||||
### 10. Dual-Thread Channel Architecture
|
||||
|
||||
**Where**: `channels/bus/`, `cli/channel.py`, `cli/interactive.py`, `cli/commands.py`
|
||||
|
||||
**Why**: Channel I/O (network polling) must not block the agent processing loop. A dedicated bus thread handles inbound/outbound messages via a thread-safe queue, while the main thread polls the queue and processes messages with full streaming support. This pattern is used in both interactive mode (`_process_channel_message`) and serve mode (`_serve_process_message`).
|
||||
|
||||
**Rule**: Never perform blocking I/O in the main thread. Use the message bus queue.
|
||||
|
||||
---
|
||||
|
||||
## Extension Guides
|
||||
|
||||
Each guide follows: **where to add** → **interface to implement** → **how to wire** → **how to test**.
|
||||
|
||||
### Adding a New Tool
|
||||
|
||||
1. **Create the tool** in `EvoScientist/tools/` using the LangChain `@tool` decorator:
|
||||
|
||||
```python
|
||||
# EvoScientist/tools/my_tool.py
|
||||
from langchain_core.tools import tool
|
||||
|
||||
@tool
|
||||
def my_tool(query: str) -> str:
|
||||
"""One-line description shown to the agent.
|
||||
|
||||
Args:
|
||||
query: What to process.
|
||||
"""
|
||||
return f"Result for {query}"
|
||||
```
|
||||
|
||||
2. **Re-export** from `EvoScientist/tools/__init__.py`:
|
||||
|
||||
```python
|
||||
from .my_tool import my_tool
|
||||
|
||||
__all__ = [..., "my_tool"]
|
||||
```
|
||||
|
||||
3. **Wire to main agent** — Add to `_build_base_kwargs()` in `EvoScientist.py`:
|
||||
|
||||
```python
|
||||
base_tools = [think_tool, skill_manager, my_tool]
|
||||
```
|
||||
|
||||
4. **Wire to sub-agents** (if needed) — Add to the tool registry and reference in `subagent.yaml`:
|
||||
|
||||
```python
|
||||
# EvoScientist.py - in _build_base_kwargs()
|
||||
tool_registry = {"think_tool": think_tool, "tavily_search": tavily_search, "my_tool": my_tool}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# subagent.yaml
|
||||
my-sub-agent:
|
||||
tools: [my_tool, think_tool]
|
||||
```
|
||||
|
||||
5. **Test** — Add tests in `tests/test_tools.py` using `unittest.mock` for external services.
|
||||
|
||||
### Adding a New Channel
|
||||
|
||||
1. **Create the channel directory**: `EvoScientist/channels/mychannel/`
|
||||
|
||||
2. **Implement the `Channel` ABC** (`channels/base.py`):
|
||||
|
||||
```python
|
||||
# EvoScientist/channels/mychannel/channel.py
|
||||
from ..base import Channel, IncomingMessage
|
||||
|
||||
class MyChannel(Channel):
|
||||
name = "mychannel"
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start listening. Inbound messages arrive via the bus (set_bus)."""
|
||||
...
|
||||
|
||||
async def _send_chunk(
|
||||
self, chat_id: str, formatted_text: str, raw_text: str,
|
||||
reply_to: str | None, metadata: dict,
|
||||
) -> None:
|
||||
"""Send a single text chunk to the channel."""
|
||||
...
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Graceful shutdown."""
|
||||
...
|
||||
```
|
||||
|
||||
3. **Add capabilities** — Return a `ChannelCapabilities` from your channel describing supported features (max text length, media support, etc.).
|
||||
|
||||
4. **Add config fields** — Add channel-specific settings to `EvoScientistConfig` in `config/settings.py`:
|
||||
|
||||
```python
|
||||
mychannel_api_key: str = ""
|
||||
mychannel_allowed_senders: str = ""
|
||||
```
|
||||
|
||||
5. **Register** — Add to the channel manager in `channels/channel_manager.py` and the onboard wizard in `config/onboard.py`.
|
||||
|
||||
6. **Add optional dependency** — In `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[project.optional-dependencies]
|
||||
mychannel = ["mychannel-sdk>=1.0"]
|
||||
```
|
||||
|
||||
7. **Test** — Add `tests/test_mychannel_channel.py`. Use a stub/mock for the external SDK.
|
||||
|
||||
### Adding a New Sub-Agent
|
||||
|
||||
1. **Add entry to `subagent.yaml`**:
|
||||
|
||||
```yaml
|
||||
my-agent:
|
||||
description: "One-line description of what this agent does."
|
||||
tools: [think_tool] # tools from the registry
|
||||
system_prompt: |
|
||||
You are the my-agent. Your role is...
|
||||
|
||||
Guidelines:
|
||||
- ...
|
||||
|
||||
When responding, include:
|
||||
- ...
|
||||
```
|
||||
|
||||
Or reference a shared prompt:
|
||||
```yaml
|
||||
my-agent:
|
||||
description: "..."
|
||||
tools: [think_tool]
|
||||
system_prompt_ref: MY_PROMPT_CONSTANT # must exist in prompts.py
|
||||
```
|
||||
|
||||
2. **Add tools to registry** (if using new tools) — Update `tool_registry` in `_build_base_kwargs()` and `load_mcp_and_build_kwargs()` in `EvoScientist.py`.
|
||||
|
||||
3. **Test** — Verify the agent appears in loaded subagents. Add tests for any new tools it uses.
|
||||
|
||||
### Adding a New Middleware
|
||||
|
||||
1. **Subclass `AgentMiddleware`** from LangChain:
|
||||
|
||||
```python
|
||||
# EvoScientist/middleware/my_middleware.py
|
||||
from deepagents.middleware.types import AgentMiddleware, ModelRequest, ModelResponse
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
class MyMiddleware(AgentMiddleware):
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse:
|
||||
"""Modify the request before it reaches the LLM (sync)."""
|
||||
# Modify request here, then pass to handler
|
||||
return handler(request)
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse:
|
||||
"""Modify the request before it reaches the LLM (async)."""
|
||||
return await handler(request)
|
||||
```
|
||||
|
||||
2. **Re-export** from `EvoScientist/middleware/__init__.py`.
|
||||
|
||||
3. **Wire into the pipeline** — Add to `_get_default_middleware()` and the middleware list in `create_cli_agent()` in `EvoScientist.py`:
|
||||
|
||||
```python
|
||||
mw = [
|
||||
ToolErrorHandlerMiddleware(),
|
||||
MyMiddleware(),
|
||||
create_memory_middleware(memory_dir, extraction_model=model),
|
||||
]
|
||||
```
|
||||
|
||||
4. **Test** — Add `tests/test_my_middleware.py`. Mock the LLM and verify request/response modifications.
|
||||
|
||||
### Adding a New Config Field
|
||||
|
||||
1. **Add the field** to `EvoScientistConfig` in `config/settings.py`:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class EvoScientistConfig:
|
||||
...
|
||||
my_setting: str = "default_value"
|
||||
```
|
||||
|
||||
2. **Add env var mapping** (if applicable) — Update `_ENV_MAP` in `config/settings.py` or `apply_config_to_env()`.
|
||||
|
||||
3. **Add to onboard wizard** (if user-facing) — Update `config/onboard.py` to prompt for the new field during `EvoSci onboard`.
|
||||
|
||||
4. **Use the field** — Access via `cfg = _ensure_config()` in `EvoScientist.py` or `get_effective_config()` elsewhere.
|
||||
|
||||
5. **Test** — Add test cases in `tests/test_config.py` for default value, env override, and file override.
|
||||
|
||||
### Adding a New LLM Provider
|
||||
|
||||
There are three levels of provider integration, from simplest to most involved:
|
||||
|
||||
#### A. Adding models to an existing provider
|
||||
|
||||
Just add entries to `_MODEL_ENTRIES` in `llm/models.py`:
|
||||
|
||||
```python
|
||||
_MODEL_ENTRIES = [
|
||||
...
|
||||
("my-model", "my-model-id", "existing-provider"),
|
||||
]
|
||||
```
|
||||
|
||||
No config, dependency, or onboard changes needed.
|
||||
|
||||
#### B. Adding a new third-party provider (routes through OpenAI)
|
||||
|
||||
Third-party providers that expose an OpenAI-compatible API use the `_THIRD_PARTY_PROVIDERS` pattern (e.g., SiliconFlow, OpenRouter, ZhipuAI). This avoids adding a new `langchain-*` dependency.
|
||||
|
||||
1. **Add provider routing** to `_THIRD_PARTY_PROVIDERS` in `llm/models.py`:
|
||||
|
||||
```python
|
||||
_THIRD_PARTY_PROVIDERS = {
|
||||
...
|
||||
"myprovider": ("https://api.myprovider.com/v1", "MYPROVIDER_API_KEY"),
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add model entries** to `_MODEL_ENTRIES`:
|
||||
|
||||
```python
|
||||
("my-model", "my-model-id", "myprovider"),
|
||||
```
|
||||
|
||||
3. **Add config fields** — Add `myprovider_api_key` to `EvoScientistConfig` in `config/settings.py`, the `_ENV_MAP`, and `apply_config_to_env()`.
|
||||
|
||||
4. **Update onboard wizard** — Add API key prompt in `config/onboard.py`.
|
||||
|
||||
5. **Test** — Add provider tests in `tests/test_llm.py`. Mock the chat model constructor.
|
||||
|
||||
#### C. Adding a new native provider (new LangChain package)
|
||||
|
||||
For providers that require their own `langchain-*` package (e.g., Anthropic, Google GenAI, NVIDIA):
|
||||
|
||||
1. **Add model entries** to `_MODEL_ENTRIES` in `llm/models.py`.
|
||||
|
||||
2. **Update `get_chat_model()`** in `llm/models.py` — Add provider-specific initialization if needed (API key handling, special kwargs, auto-config in `_apply_auto_config()`).
|
||||
|
||||
3. **Add config fields** — Add `myprovider_api_key` to `EvoScientistConfig` and the env var mapping.
|
||||
|
||||
4. **Add dependency** — Add the LangChain provider package to `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
dependencies = [
|
||||
...
|
||||
"langchain-myprovider>=1.0",
|
||||
]
|
||||
```
|
||||
|
||||
5. **Update onboard wizard** — Add API key prompt in `config/onboard.py`.
|
||||
|
||||
6. **Test** — Add provider tests in `tests/test_llm.py`. Mock the chat model constructor.
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
### Style
|
||||
|
||||
- **Indentation**: 4 spaces (no tabs).
|
||||
- **Naming**: `snake_case` for modules/functions/variables, `PascalCase` for classes, `UPPER_SNAKE_CASE` for constants.
|
||||
- **Type hints**: Required for public APIs. Types go in function signatures, NOT in docstrings.
|
||||
- **Docstrings**: Google-style. Focus on "why" rather than "what". Keep concise.
|
||||
|
||||
```python
|
||||
def send_request(url: str, *, timeout: int = 30) -> dict[str, Any]:
|
||||
"""Fetch data from the upstream API.
|
||||
|
||||
Retries once on timeout before raising.
|
||||
|
||||
Args:
|
||||
url: The API endpoint to call.
|
||||
timeout: Request timeout in seconds.
|
||||
|
||||
Returns:
|
||||
Parsed JSON response body.
|
||||
|
||||
Raises:
|
||||
httpx.TimeoutException: If both attempts time out.
|
||||
"""
|
||||
```
|
||||
|
||||
- **American English** spelling (e.g., "behavior", not "behaviour").
|
||||
- **Single backticks** for inline code in docstrings (`` `code` ``), not Sphinx-style double backticks.
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
ruff check .
|
||||
```
|
||||
|
||||
Prefer inline `# noqa: RULE` for individual exceptions over `per-file-ignores` in `pyproject.toml`. Reserve `per-file-ignores` for categorical policy (e.g., `"tests/**" = ["S101"]`).
|
||||
|
||||
### Security
|
||||
|
||||
- No `eval()`, `exec()`, or `pickle` on user-controlled input.
|
||||
- No bare `except:` — always catch specific exceptions.
|
||||
- Proper resource cleanup (file handles, connections, threads).
|
||||
- Remove unreachable/commented code before committing.
|
||||
- Use `msg` variable for error messages before raising.
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# All tests (~890 tests, no API keys needed)
|
||||
pytest -v
|
||||
|
||||
# Single file
|
||||
pytest tests/test_stream_state.py -v
|
||||
|
||||
# Single class
|
||||
pytest tests/test_stream_state.py::TestResolveSubagentName -v
|
||||
|
||||
# With coverage
|
||||
pytest --cov=EvoScientist --cov-report=term-missing
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
- Every behavior change needs a test. Every bug fix needs a regression test.
|
||||
- Tests must pass on Python 3.11 and 3.12.
|
||||
- All tests have a 30-second timeout (`pytest-timeout`).
|
||||
- No API keys or network calls in unit tests — use `unittest.mock`.
|
||||
|
||||
### Test Patterns
|
||||
|
||||
- **Placement**: Tests near the affected domain — `EvoScientist/llm/...` → `tests/test_llm.py`.
|
||||
- **Mocking**: Use `unittest.mock.patch` for external services, API calls, and file system operations.
|
||||
- **Stub channels**: Use `StubChannel` (see `tests/test_channel_comprehensive.py`) for channel tests rather than real connections.
|
||||
- **Fixtures**: See `tests/conftest.py` for shared fixtures (temp directories, mock configs, etc.).
|
||||
|
||||
### CI Workflows
|
||||
|
||||
Three GitHub Actions workflows in `.github/workflows/`:
|
||||
|
||||
| Workflow | What it checks |
|
||||
|----------|---------------|
|
||||
| `test.yml` | `pytest -v` on Python 3.11 + 3.12 matrix |
|
||||
| `lint.yml` | `ruff check .` |
|
||||
| `build.yml` | `python -m build` |
|
||||
|
||||
All workflows have `timeout-minutes` set to prevent hangs.
|
||||
|
||||
---
|
||||
|
||||
## Commit & PR Standards
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
```
|
||||
|
||||
- **Lowercase** titles (except proper nouns).
|
||||
- **Required scope** — always include one.
|
||||
- **Allowed types**: `feat`, `fix`, `test`, `docs`, `chore`, `refactor`, `perf`, `ci`, `build`, `style`
|
||||
- **Common scopes**: `llm`, `stream`, `cli`, `channels`, `config`, `mcp`, `tools`, `backends`, `middleware`, `skills`, `asyncio`, `deps`
|
||||
|
||||
Examples:
|
||||
```
|
||||
feat(llm): add provider fallback for NVIDIA models
|
||||
fix(stream): guard empty events in sub-agent name resolution
|
||||
test(asyncio): cover event loop reuse edge case
|
||||
docs(contributing): add extension guides
|
||||
```
|
||||
|
||||
### Pull Request Checklist
|
||||
|
||||
**Description must include**:
|
||||
- Clear summary of what changed and why
|
||||
- Why this is the minimal solution (`less is more`)
|
||||
- How backward compatibility is preserved
|
||||
- Linked issue(s), if applicable
|
||||
- Validation evidence (output of `ruff check .` and `pytest -v`)
|
||||
- Screenshots or terminal snippets for user-facing changes
|
||||
- **AI disclaimer**: Mention how AI agents were involved in the contribution
|
||||
|
||||
**Before requesting review**:
|
||||
- [ ] `ruff check .` passes
|
||||
- [ ] `pytest -v` passes
|
||||
- [ ] Main flow behavior unchanged unless explicitly documented
|
||||
- [ ] No feature-specific channel/pipeline introduced without architectural justification
|
||||
- [ ] Documentation updated if behavior changed
|
||||
- [ ] Backward compatibility preserved for CLI commands, config keys, and common workflows
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
Read **this file** (`CONTRIBUTING.md`) as primary context.
|
||||
|
||||
### 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
|
||||
- `ruff check .` and `pytest -v` both pass
|
||||
|
||||
---
|
||||
|
||||
## Security & Configuration
|
||||
|
||||
### API Keys
|
||||
|
||||
Never commit real API keys or secrets. Configure via:
|
||||
- `EvoSci onboard` (interactive wizard)
|
||||
- `EvoSci config set <key> <value>`
|
||||
- Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `NVIDIA_API_KEY`, `TAVILY_API_KEY`, `ZHIPU_API_KEY`, etc.)
|
||||
|
||||
Config file: `~/.config/evoscientist/config.yaml`
|
||||
MCP config: `~/.config/evoscientist/mcp.yaml`
|
||||
|
||||
### MCP Security
|
||||
|
||||
MCP config supports `${VAR}` environment variable interpolation for secrets — never hardcode tokens in `mcp.yaml`.
|
||||
|
||||
### Backend Security
|
||||
|
||||
The `CustomSandboxBackend` validates all shell commands and file paths. See [Key Patterns #5 and #6](#5-path-sanitization--auto-corrects-hallucinated-paths) for details on the security model.
|
||||
|
||||
---
|
||||
|
||||
## Key Dependencies & Resources
|
||||
|
||||
| Package | Version | Purpose | Docs |
|
||||
|---------|---------|---------|------|
|
||||
| `deepagents` | >=0.4.5 | Multi-agent orchestration framework | [GitHub](https://github.com/langchain-ai/deepagents) |
|
||||
| `langchain` | >=1.2.10 | LLM framework | [Docs](https://python.langchain.com/) |
|
||||
| `langchain-anthropic` | >=1.3.3 | Anthropic provider (Claude) | [Docs](https://python.langchain.com/docs/integrations/chat/anthropic/) |
|
||||
| `langchain-openai` | >=0.3 | OpenAI provider (GPT) | [Docs](https://python.langchain.com/docs/integrations/chat/openai/) |
|
||||
| `langchain-google-genai` | >=4.2 | Google GenAI provider (Gemini) | [Docs](https://python.langchain.com/docs/integrations/chat/google_generative_ai/) |
|
||||
| `langchain-nvidia-ai-endpoints` | >=0.3 | NVIDIA provider | [Docs](https://python.langchain.com/docs/integrations/chat/nvidia_ai_endpoints/) |
|
||||
| `langchain-ollama` | >=1.0 | Ollama local models | [Docs](https://python.langchain.com/docs/integrations/chat/ollama/) |
|
||||
| `langgraph-cli[inmem]` | >=0.4 | Graph infrastructure | [Docs](https://langchain-ai.github.io/langgraph/) |
|
||||
| `langgraph-checkpoint-sqlite` | >=3.0.0 | SQLite persistence | [Docs](https://langchain-ai.github.io/langgraph/) |
|
||||
| `langchain-mcp-adapters` | >=0.1 | MCP integration | [GitHub](https://github.com/langchain-ai/langchain-mcp-adapters) |
|
||||
| `tavily-python` | >=0.7 | Web search API | [Docs](https://docs.tavily.com/) |
|
||||
| `rich` | >=14.0 | Terminal formatting/Live display | [Docs](https://rich.readthedocs.io/) |
|
||||
| `prompt-toolkit` | >=3.0 | Interactive CLI input | [Docs](https://python-prompt-toolkit.readthedocs.io/) |
|
||||
| `textual` | >=0.80 | TUI framework | [Docs](https://textual.textualize.io/) |
|
||||
| `typer` | >=0.12 | CLI framework | [Docs](https://typer.tiangolo.com/) |
|
||||
| `questionary` | >=2.0.1 | Interactive prompts | [GitHub](https://github.com/tmbo/questionary) |
|
||||
| `httpx` | >=0.27 | Async HTTP client | [Docs](https://www.python-httpx.org/) |
|
||||
| `markdownify` | >=0.14 | HTML to Markdown | [PyPI](https://pypi.org/project/markdownify/) |
|
||||
| `pyyaml` | >=6.0 | YAML parsing | [Docs](https://pyyaml.org/) |
|
||||
| `pytest` | >=8.0 | Testing framework | [Docs](https://docs.pytest.org/) |
|
||||
| `ruff` | >=0.5 | Linting | [Docs](https://docs.astral.sh/ruff/) |
|
||||
|
||||
### Textual Resources (for TUI development)
|
||||
|
||||
- [Guide](https://textual.textualize.io/guide/)
|
||||
- [Widget gallery](https://textual.textualize.io/widget_gallery/)
|
||||
- [CSS reference](https://textual.textualize.io/styles/)
|
||||
- [Workers (async operations)](https://textual.textualize.io/guide/workers/)
|
||||
- [Events (message passing)](https://textual.textualize.io/guide/events/)
|
||||
- [Testing guide](https://textual.textualize.io/guide/testing/)
|
||||
- [Blog: Anatomy of a Textual User Interface](https://textual.textualize.io/blog/2024/09/15/anatomy-of-a-textual-user-interface/)
|
||||
|
||||
---
|
||||
|
||||
## Questions & Support
|
||||
|
||||
If anything is unclear, open an issue at [GitHub Issues](https://github.com/EvoScientist/EvoScientist/issues) and describe:
|
||||
- **Current behavior** — what you observe
|
||||
- **Expected behavior** — what you expected
|
||||
- **Reproducible context** — commands, logs, environment details
|
||||
## Need help?
|
||||
You can reach us on Discord or WeChat linked in README
|
||||
|
||||
Reference in New Issue
Block a user