7ebaa2cec0
* refactor(mcp): extract MCP server registry from onboard into shared module Move _RECOMMENDED_MCP_SERVERS, _install_pip_package, and _pip_install_hint from config/onboard.py into mcp/registry.py as a shared MCPServerEntry dataclass and registry functions. This enables reuse by the new /install-mcp command and marketplace integration. * feat(mcp): add /install-mcp command for browsing and installing MCP servers Interactive browser for MCP servers (built-in registry + EvoSkills marketplace), supporting three modes: - /install-mcp — interactive tag filter + checkbox selection - /install-mcp <name> — direct install by name or tag pre-filter - /install-mcp file.yaml — import servers from arbitrary YAML file Also available as /mcp install and EvoSci mcp install. Includes TUI browser widget (MCPBrowserWidget) mirroring the skill browser UX. * chore(tavily): conditionally pass tavily_search when TAVILY_API_KEY is set * fix(tui): Enter key detection for /install-mcp - Fix message handler names: Textual converts MCPBrowserWidget to mcpbrowser_widget (not mcp_browser_widget), so Confirmed/Cancelled messages were never received by the app - Distinguish empty selection from cancel in result handling * chore(widgets): stop auto-advancing cursor on Space toggle in browser widgets * style: linter * refactor(mcp): simplify MCP registry to marketplace-only Remove built-in server list and arbitrary YAML import — all server definitions now come from the EvoSkills marketplace (mcp/*.yaml). Onboarding filters by the `onboarding` tag instead of a hardcoded list. * refactor(mcp): consolidate /install-mcp into /mcp install Remove standalone /install-mcp command — use /mcp install as the single entry point. CLI adapter now delegates logic to the shared InstallMCPCommand class, keeping only the questionary UI layer. * chore(cli): rm reference to yaml import
229 lines
7.2 KiB
Python
229 lines
7.2 KiB
Python
"""Utility functions for EvoScientist.
|
|
|
|
This module primarily contains helpers for displaying messages and prompts in
|
|
notebooks, and lightweight configuration loaders used by the agent runtime.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
|
|
logger = logging.getLogger(__name__)
|
|
console = Console()
|
|
|
|
|
|
def format_message_content(message):
|
|
"""Convert message content to displayable string.
|
|
|
|
Args:
|
|
message: A LangChain message object with content attribute.
|
|
|
|
Returns:
|
|
Formatted string representation of the message content.
|
|
"""
|
|
parts = []
|
|
tool_calls_processed = False
|
|
|
|
# Handle main content
|
|
if isinstance(message.content, str):
|
|
parts.append(message.content)
|
|
elif isinstance(message.content, list):
|
|
# Handle complex content like tool calls (Anthropic format)
|
|
for item in message.content:
|
|
if item.get("type") == "text":
|
|
parts.append(item["text"])
|
|
elif item.get("type") == "tool_use":
|
|
parts.append(f"\n🔧 Tool Call: {item['name']}")
|
|
parts.append(f" Args: {json.dumps(item['input'], indent=2)}")
|
|
parts.append(f" ID: {item.get('id', 'N/A')}")
|
|
tool_calls_processed = True
|
|
else:
|
|
parts.append(str(message.content))
|
|
|
|
# Handle tool calls attached to the message (OpenAI format) - only if not already processed
|
|
if (
|
|
not tool_calls_processed
|
|
and hasattr(message, "tool_calls")
|
|
and message.tool_calls
|
|
):
|
|
for tool_call in message.tool_calls:
|
|
parts.append(f"\n🔧 Tool Call: {tool_call['name']}")
|
|
parts.append(f" Args: {json.dumps(tool_call['args'], indent=2)}")
|
|
parts.append(f" ID: {tool_call['id']}")
|
|
|
|
return "\n".join(parts)
|
|
|
|
|
|
def format_messages(messages):
|
|
"""Format and display a list of messages with Rich formatting.
|
|
|
|
Args:
|
|
messages: List of LangChain message objects to display.
|
|
"""
|
|
for m in messages:
|
|
msg_type = m.__class__.__name__.replace("Message", "")
|
|
content = format_message_content(m)
|
|
|
|
if msg_type == "Human":
|
|
console.print(Panel(content, title="🧑 Human", border_style="blue"))
|
|
elif msg_type == "Ai":
|
|
console.print(Panel(content, title="🤖 Assistant", border_style="green"))
|
|
elif msg_type == "Tool":
|
|
console.print(Panel(content, title="🔧 Tool Output", border_style="yellow"))
|
|
else:
|
|
console.print(Panel(content, title=f"📝 {msg_type}", border_style="white"))
|
|
|
|
|
|
def show_prompt(prompt_text: str, title: str = "Prompt", border_style: str = "blue"):
|
|
"""Display a prompt with rich formatting and XML tag highlighting.
|
|
|
|
Args:
|
|
prompt_text: The prompt string to display
|
|
title: Title for the panel (default: "Prompt")
|
|
border_style: Border color style (default: "blue")
|
|
"""
|
|
# Create a formatted display of the prompt
|
|
formatted_text = Text(prompt_text)
|
|
formatted_text.highlight_regex(r"<[^>]+>", style="bold blue") # Highlight XML tags
|
|
formatted_text.highlight_regex(
|
|
r"##[^#\n]+", style="bold magenta"
|
|
) # Highlight headers
|
|
formatted_text.highlight_regex(
|
|
r"###[^#\n]+", style="bold cyan"
|
|
) # Highlight sub-headers
|
|
|
|
# Display in a panel for better presentation
|
|
console.print(
|
|
Panel(
|
|
formatted_text,
|
|
title=f"[bold green]{title}[/bold green]",
|
|
border_style=border_style,
|
|
padding=(1, 2),
|
|
)
|
|
)
|
|
|
|
|
|
def load_subagents(
|
|
config_path: Path,
|
|
*,
|
|
tool_registry: dict[str, Any],
|
|
prompt_refs: dict[str, str] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Load subagent definitions from YAML and wire up tools.
|
|
|
|
NOTE: This is a custom utility. deepagents does not natively load subagents
|
|
from files - they're normally defined inline in the create_deep_agent() call.
|
|
We externalize to YAML here to keep configuration separate from code.
|
|
|
|
Supported YAML schemas:
|
|
|
|
1) Mapping style (recommended):
|
|
planner-agent:
|
|
description: "..."
|
|
tools: [think_tool]
|
|
system_prompt: |
|
|
...
|
|
research-agent:
|
|
description: "..."
|
|
tools: [tavily_search, think_tool]
|
|
system_prompt_ref: RESEARCHER_INSTRUCTIONS
|
|
|
|
2) List style (legacy):
|
|
subagents:
|
|
- name: planner-agent
|
|
description: "..."
|
|
tools: [think_tool]
|
|
system_prompt: |
|
|
...
|
|
"""
|
|
prompt_refs = prompt_refs or {}
|
|
|
|
with config_path.open(encoding="utf-8") as f:
|
|
config = yaml.safe_load(f) or {}
|
|
|
|
if not isinstance(config, dict) or not config:
|
|
raise ValueError("subagent.yaml must be a mapping or contain 'subagents:'")
|
|
|
|
subagents: list[dict[str, Any]] = []
|
|
|
|
def _build_one(name: str, spec: dict[str, Any]) -> dict[str, Any]:
|
|
subagent: dict[str, Any] = {
|
|
"name": name,
|
|
"description": spec.get("description", ""),
|
|
}
|
|
|
|
if "system_prompt_ref" in spec:
|
|
ref = spec["system_prompt_ref"]
|
|
if ref not in prompt_refs:
|
|
raise ValueError(
|
|
f"Unknown system_prompt_ref '{ref}' for subagent '{name}'"
|
|
)
|
|
subagent["system_prompt"] = prompt_refs[ref]
|
|
else:
|
|
subagent["system_prompt"] = spec.get("system_prompt", "")
|
|
|
|
if "model" in spec:
|
|
subagent["model"] = spec["model"]
|
|
|
|
if "skills" in spec:
|
|
subagent["skills"] = spec["skills"]
|
|
|
|
if "tools" in spec:
|
|
resolved = []
|
|
for t in spec["tools"]:
|
|
if t in tool_registry:
|
|
resolved.append(tool_registry[t])
|
|
else:
|
|
logger.warning(
|
|
"Subagent %r: tool %r not in registry, skipping", name, t
|
|
)
|
|
subagent["tools"] = resolved
|
|
|
|
return subagent
|
|
|
|
# Legacy list style
|
|
if "subagents" in config:
|
|
items = config.get("subagents")
|
|
if not isinstance(items, list) or not items:
|
|
raise ValueError("subagent.yaml must contain a non-empty 'subagents:' list")
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
name = item.get("name")
|
|
if not name:
|
|
raise ValueError("Each subagent entry must have a 'name'")
|
|
subagents.append(_build_one(name, item))
|
|
return subagents
|
|
|
|
# Mapping style: {<name>: <spec>}
|
|
for name, spec in config.items():
|
|
if not isinstance(spec, dict):
|
|
continue
|
|
subagents.append(_build_one(name, spec))
|
|
|
|
return subagents
|
|
|
|
|
|
def load_subagent(
|
|
config_path: Path,
|
|
name: str,
|
|
*,
|
|
tool_registry: dict[str, Any],
|
|
prompt_refs: dict[str, str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Load a single sub-agent by name from YAML."""
|
|
for agent in load_subagents(
|
|
config_path,
|
|
tool_registry=tool_registry,
|
|
prompt_refs=prompt_refs,
|
|
):
|
|
if agent.get("name") == name:
|
|
return agent
|
|
raise KeyError(f"Sub-agent not found: {name}")
|