Files
EvoScientist-Multi/EvoScientist/utils.py
T
ADITYA 1f3e8f57a3 fix: resolve subagent tools at execution decision (#381)
* fix: defer subagent tool resolution

Signed-off-by: Aditya Datta <crazyme07071996@gmail.com>

* fix: preserve inherited subagent tools

* test: cover injected subagent tools

* style: format subagent regression

---------

Signed-off-by: Aditya Datta <crazyme07071996@gmail.com>
2026-08-11 14:13:04 +01:00

286 lines
10 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 resolve_subagent_tools(
subagent: dict[str, Any], tool_registry: dict[str, Any]
) -> dict[str, Any]:
"""Resolve deferred YAML tool names while preserving injected tool objects."""
tool_names = subagent.pop("_tool_names", None)
if tool_names is None:
return subagent
resolved = list(subagent.get("tools", []))
for tool_name in tool_names:
if tool_name in tool_registry:
resolved.append(tool_registry[tool_name])
else:
logger.warning(
"Subagent %r: tool %r not in registry, skipping",
subagent.get("name"),
tool_name,
)
subagent["tools"] = resolved
return subagent
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,
*,
prompt_refs: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
"""Load subagent definitions from a directory of YAML files.
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.
``config_path`` must be a directory containing one ``<name>.yaml`` per
sub-agent. All ``*.yaml`` files are merged into a single mapping. Files
starting with ``.`` (dotfiles, editor swap files) or ``_`` (private /
disabled) are ignored. ``.yml`` is intentionally not supported — keeps
one canonical extension and avoids the dev-vs-wheel packaging mismatch.
Each file's top level must be a mapping ``{<agent-name>: <spec>}``::
planner-agent:
description: "..."
tools: [think_tool]
system_prompt: |
...
research-agent:
description: "..."
tools: [tavily_search, think_tool]
system_prompt: |
...
Tool names are retained until the caller selects an execution mode. This
avoids resolving async specs against the in-process registry; the selected
in-process or remote graph resolves names against its terminal registry.
"""
prompt_refs = prompt_refs or {}
if not config_path.is_dir():
raise ValueError(
f"{config_path}: sub-agent config must be a directory "
f"containing one <name>.yaml per agent"
)
# Only ``.yaml`` is supported (canonical extension). Skip files starting
# with ``_`` (private / disabled) or ``.`` (dotfiles, editor swap files
# like ``.foo.yaml.swp``). ``.yml`` is intentionally not loaded — keeps
# one canonical extension across the project, simplifies packaging
# (no need for a parallel ``subagents/*.yml`` entry in ``pyproject.toml``
# ``package-data``), and matches every existing yaml file in this repo.
config: dict[str, Any] = {}
for yml in sorted(config_path.glob("*.yaml")):
if yml.name.startswith(".") or yml.name.startswith("_"):
continue
with yml.open(encoding="utf-8") as f:
data = yaml.safe_load(f)
if data is None:
# Empty file — skip silently (matches dotfile/underscore skip behavior)
continue
if not isinstance(data, dict):
raise ValueError(
f"{yml}: top-level must be a mapping (one entry per sub-agent)"
)
# Detect duplicate keys across files
for key in data:
if key in config:
raise ValueError(
f"Sub-agent {key!r} defined in multiple files; "
f"second occurrence in {yml.name}"
)
for key, spec in data.items():
if not isinstance(spec, dict):
raise ValueError(
f"{yml}: sub-agent {key!r} must map to a spec dict, "
f"got {type(spec).__name__}"
)
config.update(data)
if not config:
raise ValueError(
f"{config_path}: no sub-agent definitions found "
f"(expected one or more <name>.yaml files)"
)
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"]
# Defer YAML-name resolution until the caller decides whether this
# spec will run in-process. Async specs are replaced by remote graph
# references and must not warn against the caller's unrelated registry.
if "tools" in spec:
subagent["_tool_names"] = list(spec["tools"])
subagent["tools"] = []
# Internal field: carries the ``async:`` yaml flag through to
# ``_maybe_swap_async_subagents`` so the swap doesn't need a second
# yaml pass to discover async-flagged agents. Underscore prefix marks
# it as internal — must be popped before passing to deepagents.
async_val = spec.get("async", False)
if not isinstance(async_val, bool):
# Reject quoted-string yaml values like ``async: "false"`` —
# ``bool("false")`` is ``True`` (non-empty string), which silently
# flips the agent into async mode. Fail loud instead.
raise ValueError(
f"Subagent {name!r}: 'async' must be a boolean, "
f"got {type(async_val).__name__}: {async_val!r}"
)
subagent["_async"] = async_val
return subagent
for name, spec in config.items():
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 and resolve a single sub-agent by name from YAML."""
for agent in load_subagents(
config_path,
prompt_refs=prompt_refs,
):
if agent.get("name") == name:
return resolve_subagent_tools(agent, tool_registry)
raise KeyError(f"Sub-agent not found: {name}")